- 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.
479 lines
19 KiB
TypeScript
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>
|
|
)
|
|
}
|