feat: implement asignaturas management with dynamic routing and UI updates

This commit is contained in:
2025-08-22 08:10:49 -06:00
parent 8f46acd4b3
commit 9727f4c691
8 changed files with 249 additions and 212 deletions

View File

@@ -0,0 +1,142 @@
import { createFileRoute, useRouter } from "@tanstack/react-router"
import { supabase } from "@/auth/supabase"
import * as Icons from "lucide-react"
import { useMemo, useState } from "react"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
type Asignatura = {
id: string
nombre: string
semestre: number | null
creditos: number | null
horas_teoricas: number | null
horas_practicas: number | null
}
type ModalData = {
planId: string
planNombre: string
asignaturas: Asignatura[]
}
export const Route = createFileRoute("/_authenticated/asignaturas/$planId")({
component: ModalComponent,
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, semestre, creditos, horas_teoricas, horas_practicas")
.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[],
}
},
})
function ModalComponent() {
const { planId, planNombre, asignaturas } = Route.useLoaderData() as ModalData
const router = useRouter()
const [q, setQ] = useState("")
const filtered = useMemo(() => {
const t = q.trim().toLowerCase()
if (!t) return asignaturas
return asignaturas.filter(a =>
[a.nombre, a.semestre, a.creditos]
.filter(Boolean)
.some(v => String(v).toLowerCase().includes(t))
)
}, [q, asignaturas])
// Agrupar por semestre
const groups = useMemo(() => {
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])
return (
<Dialog
open
onOpenChange={() =>
router.navigate({ to: "/plan/$planId", params: { planId }, replace: true })
}
>
<DialogContent className="w-[min(92vw,900px)]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Icons.BookOpen className="w-5 h-5" />
Asignaturas · <span className="font-normal">{planNombre}</span>
</DialogTitle>
</DialogHeader>
<div className="flex items-center gap-2 pb-3">
<Input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Buscar por nombre, semestre…"
className="w-full"
/>
</div>
<div className="max-h-[65vh] overflow-auto pr-1">
{groups.length === 0 && (
<div className="text-sm text-neutral-500 py-8 text-center">Sin asignaturas</div>
)}
<div className="space-y-5">
{groups.map(([sem, items]) => (
<div key={String(sem)}>
<div className="mb-2 text-xs font-semibold text-neutral-500">
Semestre {sem}
</div>
<ul className="grid gap-2 sm:grid-cols-2">
{items.map(a => (
<li key={a.id} className="rounded-xl border p-3 bg-white/70 dark:bg-neutral-900/60">
<div className="font-medium truncate">{a.nombre}</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-neutral-600">
{a.creditos != null && (
<Badge variant="outline" className="bg-white/60">Créditos: {a.creditos}</Badge>
)}
{(a.horas_teoricas ?? 0) + (a.horas_practicas ?? 0) > 0 && (
<Badge variant="secondary" className="bg-neutral-100 text-neutral-800 dark:bg-neutral-800 dark:text-neutral-100">
Hrs T/P: {a.horas_teoricas ?? 0}/{a.horas_practicas ?? 0}
</Badge>
)}
</div>
</li>
))}
</ul>
</div>
))}
</div>
</div>
</DialogContent>
</Dialog>
)
}