feat: implement asignaturas management with dynamic routing and UI updates
This commit is contained in:
142
src/routes/_authenticated/asignaturas/$planId.tsx
Normal file
142
src/routes/_authenticated/asignaturas/$planId.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user