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/asignaturas/$planId.tsx
Alejandro Rosales ca3fed69b2 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.
2025-08-22 14:32:43 -06:00

479 lines
19 KiB
TypeScript

// routes/_authenticated/asignaturas/$planId.tsx
import { createFileRoute, Link, useRouter } from "@tanstack/react-router"
import { supabase } from "@/auth/supabase"
import * as Icons from "lucide-react"
import { useEffect, useMemo, useRef, useState } from "react"
import {
Dialog, DialogContent, DialogHeader, DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Select, SelectTrigger, SelectContent, SelectItem, SelectValue,
} from "@/components/ui/select"
import { Badge } from "@/components/ui/badge"
/* ================== Tipos ================== */
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
}
type ModalData = {
planId: string
planNombre: string
asignaturas: Asignatura[]
}
/* ================== Ruta (modal) ================== */
export const Route = createFileRoute("/_authenticated/asignaturas/$planId")({
component: Page,
loader: async ({ params }): Promise<ModalData> => {
const planId = params.planId
const { data: plan, error: planErr } = await supabase
.from("plan_estudios")
.select("id, nombre")
.eq("id", planId)
.single()
if (planErr || !plan) throw planErr ?? new Error("Plan no encontrado")
const { data: asignaturas, error: aErr } = await supabase
.from("asignaturas")
.select(`
id, nombre, clave, tipo, semestre, creditos, horas_teoricas, horas_practicas,
objetivos, contenidos, bibliografia, criterios_evaluacion
`)
.eq("plan_id", planId)
.order("semestre", { ascending: true })
.order("nombre", { ascending: true })
if (aErr) throw aErr
return { planId, planNombre: plan.nombre, asignaturas: (asignaturas ?? []) as Asignatura[] }
},
})
/* ================== Página ================== */
function Page() {
const { planId, planNombre, asignaturas } = Route.useLoaderData() as ModalData
const router = useRouter()
// ---- estado UI
const [query, setQuery] = useState("")
const [sem, setSem] = useState<string>("todos")
const [tipo, setTipo] = useState<string>("todos")
const [orden, setOrden] = useState<"nombre" | "semestre" | "creditos">("semestre")
const [vista, setVista] = useState<"cards" | "tabla">("cards")
// ---- atajos
const searchRef = useRef<HTMLInputElement | null>(null)
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const meta = e.ctrlKey || e.metaKey
if (meta && e.key.toLowerCase() === "k") {
e.preventDefault()
searchRef.current?.focus()
}
if (e.key === "Escape") {
router.navigate({ to: "/plan/$planId", params: { planId }, replace: true })
}
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [router, planId])
// ---- semestres y tipos disponibles
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])
// ---- KPIs
const kpis = useMemo(() => {
const total = asignaturas.length
const creditos = asignaturas.reduce((acc, a) => acc + (a.creditos ?? 0), 0)
const ht = asignaturas.reduce((acc, a) => acc + (a.horas_teoricas ?? 0), 0)
const hp = asignaturas.reduce((acc, a) => acc + (a.horas_practicas ?? 0), 0)
const porTipo: Record<string, number> = {}
asignaturas.forEach(a => {
const key = (a.tipo ?? "—").toLowerCase()
porTipo[key] = (porTipo[key] ?? 0) + 1
})
return { total, creditos, ht, hp, porTipo }
}, [asignaturas])
// ---- filtro + orden
const filtradas = useMemo(() => {
const t = query.trim().toLowerCase()
const list = asignaturas.filter(a => {
const matchTexto =
!t ||
[a.nombre, a.clave, a.tipo, a.objetivos]
.filter(Boolean)
.some(v => String(v).toLowerCase().includes(t))
const semOK = sem === "todos" || String(a.semestre ?? "—") === sem
const tipoOK = tipo === "todos" || (a.tipo ?? "—") === tipo
return matchTexto && semOK && tipoOK
})
const sortList = [...list].sort((A, B) => {
if (orden === "nombre") return A.nombre.localeCompare(B.nombre)
if (orden === "creditos") return (B.creditos ?? 0) - (A.creditos ?? 0)
// semestre
const a = A.semestre ?? 999
const b = B.semestre ?? 999
if (a === b) return A.nombre.localeCompare(B.nombre)
return a - b
})
return sortList
}, [asignaturas, query, sem, tipo, orden])
// ---- agrupación por semestre (para la vista de cards)
const grupos = useMemo(() => {
const m = new Map<number | string, Asignatura[]>()
for (const a of filtradas) {
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)
})
}, [filtradas])
// ---- helpers
const limpiar = () => { setQuery(""); setSem("todos"); setTipo("todos"); setOrden("semestre") }
return (
<Dialog
open
onOpenChange={() => router.navigate({ to: "/plan/$planId", params: { planId }, replace: true })}
>
<DialogContent className="p-0 min-w-[94vw] h-[min(94vh,920px)] sm:rounded-2xl overflow-hidden flex flex-col">
{/* HERO ===================================================== */}
<div className="relative">
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 via-primary/10 to-transparent" />
<div className="relative px-5 pt-5 pb-3 flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2 text-xs text-neutral-500">
<Icons.ScrollText className="h-4 w-4" />
Plan
</div>
<DialogHeader className="p-0">
<DialogTitle className="truncate text-xl sm:text-2xl">{planNombre}</DialogTitle>
</DialogHeader>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
<KpiChip icon={Icons.BookOpen} label="Asignaturas" value={kpis.total} />
<KpiChip icon={Icons.Coins} label="Créditos" value={kpis.creditos} />
<KpiChip icon={Icons.Clock} label="Horas T/P" value={`${kpis.ht}/${kpis.hp}`} />
{Object.entries(kpis.porTipo).slice(0, 3).map(([t, n]) => (
<Badge key={t} variant="secondary" className="text-[10px]">
{t} · {n}
</Badge>
))}
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" asChild>
<Link to="/plan/$planId" params={{ planId }}>Ir al plan</Link>
</Button>
</div>
</div>
{/* TOOLBAR sticky ========================================= */}
<div className="sticky top-0 z-10 border-y bg-white/90 backdrop-blur">
<div className="px-5 py-3 grid gap-2 items-center
sm:grid-cols-[1fr,140px,160px,160px,auto]">
<div className="relative">
<Icons.Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
<Input
ref={searchRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Buscar (⌘/Ctrl K)…"
className="pl-8"
/>
</div>
<Select value={sem} onValueChange={setSem}>
<SelectTrigger className="w-full"><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 className="w-full"><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={orden} onValueChange={(v) => setOrden(v as any)}>
<SelectTrigger className="w-full"><SelectValue placeholder="Ordenar por" /></SelectTrigger>
<SelectContent>
<SelectItem value="semestre">Semestre</SelectItem>
<SelectItem value="nombre">Nombre</SelectItem>
<SelectItem value="creditos">Créditos</SelectItem>
</SelectContent>
</Select>
<div className="flex items-center justify-end gap-2">
<ViewToggle value={vista} onChange={setVista} />
{(query || sem !== "todos" || tipo !== "todos" || orden !== "semestre") && (
<Button variant="ghost" size="sm" onClick={limpiar}>
<Icons.Eraser className="h-4 w-4 mr-1" /> Limpiar
</Button>
)}
</div>
</div>
</div>
</div>
{/* CONTENIDO scrolleable ==================================== */}
<div className="flex-1 overflow-auto px-5 py-5">
{filtradas.length === 0 ? (
<EmptyState />
) : vista === "tabla" ? (
<Tabla asignaturas={filtradas} />
) : (
<div className="space-y-8">
{grupos.map(([sem, items]) => (
<section key={String(sem)} className="space-y-3">
<h3 className="text-xs font-semibold text-neutral-600">Semestre {sem}</h3>
<ul className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{items.map(a => <AsignaturaCard key={a.id} a={a} />)}
</ul>
</section>
))}
</div>
)}
</div>
</DialogContent>
</Dialog>
)
}
/* ================== UI bits ================== */
function KpiChip({ icon: Icon, label, value }:{ icon: any; label: string; value: number | string }) {
return (
<span className="inline-flex items-center gap-1 rounded-full border bg-white/60 px-2 py-0.5 text-[11px]">
<Icon className="h-3.5 w-3.5" /> {label}: <span className="font-medium">{value}</span>
</span>
)
}
function ViewToggle({ value, onChange }:{ value:"cards"|"tabla"; onChange:(v:"cards"|"tabla")=>void }) {
return (
<div className="inline-flex rounded-lg border bg-white overflow-hidden">
<button
className={`px-2.5 py-1.5 text-xs flex items-center gap-1 ${value==="cards" ? "bg-neutral-100" : ""}`}
onClick={() => onChange("cards")}
title="Tarjetas"
>
<Icons.LayoutGrid className="h-4 w-4" /> Cards
</button>
<button
className={`px-2.5 py-1.5 text-xs flex items-center gap-1 border-l ${value==="tabla" ? "bg-neutral-100" : ""}`}
onClick={() => onChange("tabla")}
title="Tabla compacta"
>
<Icons.Table2 className="h-4 w-4" /> Tabla
</button>
</div>
)
}
function EmptyState() {
return (
<div className="grid place-items-center h-[48vh]">
<div className="text-center max-w-sm">
<div className="mx-auto w-12 h-12 rounded-2xl grid place-items-center bg-neutral-100">
<Icons.Inbox className="h-6 w-6 text-neutral-500" />
</div>
<h4 className="mt-3 font-semibold">Sin resultados</h4>
<p className="text-sm text-neutral-600">Ajusta los filtros o la búsqueda.</p>
</div>
</div>
)
}
/* ================== Card ================== */
function tipoMeta(tipo?: string | null) {
const t = (tipo ?? "").toLowerCase()
if (t.includes("oblig")) return { label: "Obligatoria", color: "emerald" }
if (t.includes("opt")) return { label: "Optativa", color: "amber" }
if (t.includes("taller")) return { label: "Taller", color: "indigo" }
if (t.includes("lab")) return { label: "Laboratorio", color: "sky" }
return { label: tipo ?? "Genérica", color: "neutral" }
}
function AsignaturaCard({ a }: { a: Asignatura }) {
const horasT = a.horas_teoricas ?? 0
const horasP = a.horas_practicas ?? 0
const meta = tipoMeta(a.tipo)
return (
<li className="group relative overflow-hidden rounded-2xl border bg-white/75 shadow-sm hover:shadow-md transition-all">
{/* franja lateral por tipo */}
<span
aria-hidden
className={`absolute left-0 top-0 h-full w-1 bg-${meta.color}-500/80`}
/>
<div className="p-3 pl-4">
<div className="flex items-start gap-3">
<div className={`mt-0.5 h-8 w-8 rounded-xl grid place-items-center border bg-white/80`}>
<Icons.BookOpen className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<h4 className="font-semibold leading-tight truncate" title={a.nombre}>{a.nombre}</h4>
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px]">
{a.clave && <Chip><Icons.KeyRound className="h-3 w-3" /> {a.clave}</Chip>}
<Chip className={`bg-${meta.color}-50 text-${meta.color}-800 border-${meta.color}-200`}>
<Icons.BadgeCheck className="h-3 w-3" /> {meta.label}
</Chip>
{a.creditos != null && <Chip><Icons.Coins className="h-3 w-3" /> {a.creditos} créditos</Chip>}
{(horasT + horasP) > 0 && <Chip><Icons.Clock className="h-3 w-3" /> H T/P: {horasT}/{horasP}</Chip>}
<Chip><Icons.CalendarDays className="h-3 w-3" /> Semestre {a.semestre ?? "—"}</Chip>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="mt-[-2px]">
<Icons.MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[190px]">
<DropdownMenuItem className="gap-2" asChild>
<Link to="/asignatura/$asignaturaId" params={{ asignaturaId: a.id }}>
<Icons.FileText className="h-4 w-4" /> Ver detalles
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="gap-2">
<Icons.Pencil className="h-4 w-4" /> Editar
</DropdownMenuItem>
<DropdownMenuItem className="gap-2">
<Icons.Sparkles className="h-4 w-4" /> Ajustar con IA
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* objetivo (clamp) */}
{a.objetivos && (
<p className="mt-2 text-xs text-neutral-700 line-clamp-2">{a.objetivos}</p>
)}
{/* CTA */}
<div className="mt-2 flex justify-end">
<Link
to="/asignatura/$asignaturaId"
params={{ asignaturaId: a.id }}
className="inline-flex items-center gap-1 rounded-lg border px-3 py-1.5 text-xs hover:bg-neutral-50"
>
Ver ficha <Icons.ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
</div>
</li>
)
}
function Chip({ children, className = "" }:{ children: React.ReactNode; className?: string }) {
return (
<span className={`inline-flex items-center gap-1 rounded-full border bg-white/70 px-2 py-0.5 text-[11px] ${className}`}>
{children}
</span>
)
}
/* ================== Tabla compacta ================== */
function Tabla({ asignaturas }: { asignaturas: Asignatura[] }) {
return (
<div className="rounded-2xl border bg-white/70 overflow-auto">
<table className="min-w-full text-sm">
<thead className="sticky top-0 bg-white/90 backdrop-blur text-[12px]">
<tr className="[&>th]:px-3 [&>th]:py-2 text-left text-neutral-500">
<th>Nombre</th>
<th className="whitespace-nowrap">Clave</th>
<th>Tipo</th>
<th>Sem.</th>
<th>Créditos</th>
<th className="whitespace-nowrap">H T/P</th>
<th></th>
</tr>
</thead>
<tbody className="[&>tr:nth-child(odd)]:bg-neutral-50/40">
{asignaturas.map(a => (
<tr key={a.id} className="align-top">
<td className="px-3 py-2">
<div className="font-medium truncate max-w-[36ch]" title={a.nombre}>{a.nombre}</div>
{a.objetivos && <div className="text-xs text-neutral-600 line-clamp-1 max-w-[56ch]">{a.objetivos}</div>}
</td>
<td className="px-3 py-2 text-neutral-700">{a.clave ?? "—"}</td>
<td className="px-3 py-2">{a.tipo ?? "—"}</td>
<td className="px-3 py-2 tabular-nums">{a.semestre ?? "—"}</td>
<td className="px-3 py-2 tabular-nums">{a.creditos ?? "—"}</td>
<td className="px-3 py-2 tabular-nums">
{(a.horas_teoricas ?? 0)}/{(a.horas_practicas ?? 0)}
</td>
<td className="px-3 py-2">
<div className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Icons.MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[180px]">
<DropdownMenuItem asChild className="gap-2">
<Link to="/asignatura/$asignaturaId" params={{ asignaturaId: a.id }}>
<Icons.FileText className="h-4 w-4" /> Ver detalles
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="gap-2">
<Icons.Pencil className="h-4 w-4" /> Editar
</DropdownMenuItem>
<DropdownMenuItem className="gap-2">
<Icons.Sparkles className="h-4 w-4" /> Ajustar con IA
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}