|
|
|
@@ -1,5 +1,5 @@
|
|
|
|
|
import { createFileRoute, useRouter, Link } from "@tanstack/react-router"
|
|
|
|
|
import { useEffect, useMemo, useState } from "react"
|
|
|
|
|
import { useMemo, useState } from "react"
|
|
|
|
|
import { supabase, useSupabaseAuth } from "@/auth/supabase"
|
|
|
|
|
import { Input } from "@/components/ui/input"
|
|
|
|
|
import { Button } from "@/components/ui/button"
|
|
|
|
@@ -7,7 +7,6 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
|
|
|
import { Badge } from "@/components/ui/badge"
|
|
|
|
|
import * as Icons from "lucide-react"
|
|
|
|
|
import { Plus, RefreshCcw, Building2, ScrollText, BookOpen } from "lucide-react"
|
|
|
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
|
|
|
|
|
|
|
|
|
export type PlanDeEstudios = {
|
|
|
|
|
id: string; nombre: string; nivel: string | null; duracion: string | null;
|
|
|
|
@@ -38,6 +37,7 @@ export const Route = createFileRoute("/_authenticated/planes")({
|
|
|
|
|
if (error) throw new Error(error.message)
|
|
|
|
|
return (data ?? []) as PlanRow[]
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
/* ---------- helpers de estilo suave ---------- */
|
|
|
|
@@ -62,10 +62,9 @@ function RouteComponent() {
|
|
|
|
|
const [q, setQ] = useState("")
|
|
|
|
|
const data = Route.useLoaderData() as PlanRow[]
|
|
|
|
|
const router = useRouter()
|
|
|
|
|
const search = Route.useSearch<{ planId?: string }>() // usaremos ?planId=... para el modal
|
|
|
|
|
|
|
|
|
|
const showFacultad = auth.claims?.role === "lci" || auth.claims?.role === "vicerrectoria"
|
|
|
|
|
const showCarrera = auth.claims?.role === "secretario_academico"
|
|
|
|
|
const showCarrera = showFacultad || auth.claims?.role === "secretario_academico"
|
|
|
|
|
|
|
|
|
|
const filtered = useMemo(() => {
|
|
|
|
|
const term = q.trim().toLowerCase()
|
|
|
|
@@ -106,11 +105,7 @@ function RouteComponent() {
|
|
|
|
|
return (
|
|
|
|
|
<Link
|
|
|
|
|
key={p.id}
|
|
|
|
|
// Runtime navega con ?planId=... (abrimos el modal),
|
|
|
|
|
// pero la URL se enmascara SIN el search param:
|
|
|
|
|
to="/planes/$planId/modal"
|
|
|
|
|
search={{ planId: p.id }}
|
|
|
|
|
mask={{ to: '/planes/$planId', params: { planId: p.id } }}
|
|
|
|
|
className="group relative overflow-hidden rounded-3xl bg-white shadow-sm ring-1 transition-all hover:shadow-md hover:-translate-y-0.5"
|
|
|
|
|
params={{ planId: p.id }}
|
|
|
|
|
style={styles}
|
|
|
|
@@ -131,12 +126,12 @@ function RouteComponent() {
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-2 text-xs">
|
|
|
|
|
{showCarrera && p.carreras?.nombre && (
|
|
|
|
|
<Badge variant="secondary" className="border text-neutral-700 bg-white/70">
|
|
|
|
|
<Badge variant="secondary" className="border text-neutral-700 bg-white/70 w-fit">
|
|
|
|
|
<ScrollText className="mr-1 h-3 w-3" /> {p.carreras?.nombre}
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
{showFacultad && fac?.nombre && (
|
|
|
|
|
<Badge variant="outline" className="bg-white/60" style={{ borderColor: styles.borderColor }}>
|
|
|
|
|
<Badge variant="outline" className="bg-white/60 w-fit" style={{ borderColor: styles.borderColor }}>
|
|
|
|
|
<BookOpen className="mr-1 h-3 w-3" /> {fac?.nombre}
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
@@ -157,95 +152,6 @@ function RouteComponent() {
|
|
|
|
|
)}
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
{/* MODAL: se muestra si existe ?planId=... */}
|
|
|
|
|
<PlanPreviewModal planId={search?.planId} onClose={() =>
|
|
|
|
|
router.navigate({ to: "/planes", replace: true })
|
|
|
|
|
} />
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---------- Modal (carga ligera por id) ---------- */
|
|
|
|
|
function PlanPreviewModal({ planId, onClose }: { planId?: string; onClose: () => void }) {
|
|
|
|
|
const [loading, setLoading] = useState(false)
|
|
|
|
|
const [plan, setPlan] = useState<null | {
|
|
|
|
|
id: string; nombre: string; nivel: string | null; duracion: string | null;
|
|
|
|
|
total_creditos: number | null; estado: string | null;
|
|
|
|
|
carreras: { nombre: string; facultades?: { nombre: string; color?: string | null; icon?: string | null } | null } | null
|
|
|
|
|
}>(null)
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
let alive = true
|
|
|
|
|
async function fetchPlan() {
|
|
|
|
|
if (!planId) return
|
|
|
|
|
setLoading(true)
|
|
|
|
|
const { data, error } = await supabase
|
|
|
|
|
.from("plan_estudios")
|
|
|
|
|
.select(`
|
|
|
|
|
id, nombre, nivel, duracion, total_creditos, estado,
|
|
|
|
|
carreras (
|
|
|
|
|
nombre,
|
|
|
|
|
facultades:facultades ( nombre, color, icon )
|
|
|
|
|
)
|
|
|
|
|
`)
|
|
|
|
|
.eq("id", planId)
|
|
|
|
|
.single()
|
|
|
|
|
if (!alive) return
|
|
|
|
|
if (!error) setPlan(data as any)
|
|
|
|
|
setLoading(false)
|
|
|
|
|
}
|
|
|
|
|
fetchPlan()
|
|
|
|
|
return () => { alive = false }
|
|
|
|
|
}, [planId])
|
|
|
|
|
|
|
|
|
|
const fac = plan?.carreras?.facultades
|
|
|
|
|
const [r, g, b] = hexToRgb(fac?.color)
|
|
|
|
|
const headerStyle = { background: `linear-gradient(135deg, rgba(${r},${g},${b},.14), rgba(${r},${g},${b},.06))` }
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Dialog open={!!planId} onOpenChange={() => onClose()}>
|
|
|
|
|
<DialogContent className="max-w-2xl p-0 overflow-hidden">
|
|
|
|
|
<div className="p-6" style={headerStyle}>
|
|
|
|
|
<DialogHeader className="space-y-1">
|
|
|
|
|
<DialogTitle>{plan?.nombre ?? "Cargando…"}</DialogTitle>
|
|
|
|
|
<div className="text-xs text-neutral-600">
|
|
|
|
|
{plan?.carreras?.nombre ?? "—"} {fac?.nombre ? `· ${fac?.nombre}` : ""}
|
|
|
|
|
</div>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="p-6 space-y-4">
|
|
|
|
|
{loading && <div className="text-sm text-neutral-500">Cargando…</div>}
|
|
|
|
|
{!loading && plan && (
|
|
|
|
|
<>
|
|
|
|
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
|
|
|
|
<div><span className="text-neutral-500">Nivel:</span> <span className="font-medium">{plan.nivel ?? "—"}</span></div>
|
|
|
|
|
<div><span className="text-neutral-500">Duración:</span> <span className="font-medium">{plan.duracion ?? "—"}</span></div>
|
|
|
|
|
<div><span className="text-neutral-500">Créditos:</span> <span className="font-medium">{plan.total_creditos ?? "—"}</span></div>
|
|
|
|
|
<div><span className="text-neutral-500">Estado:</span> <span className="font-medium">{plan.estado ?? "—"}</span></div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
<Link
|
|
|
|
|
to="/_authenticated/planes/$planId"
|
|
|
|
|
params={{ planId: plan.id }}
|
|
|
|
|
className="inline-flex items-center gap-2 rounded-xl bg-black text-white px-4 py-2 hover:opacity-90"
|
|
|
|
|
>
|
|
|
|
|
<Icons.FileText className="w-4 h-4" /> Ver ficha
|
|
|
|
|
</Link>
|
|
|
|
|
<Link
|
|
|
|
|
to="/_authenticated/asignaturas"
|
|
|
|
|
search={{ planId: plan.id }}
|
|
|
|
|
className="inline-flex items-center gap-2 rounded-xl border px-4 py-2 hover:bg-neutral-50"
|
|
|
|
|
>
|
|
|
|
|
<Icons.BookOpen className="w-4 h-4" /> Ver asignaturas
|
|
|
|
|
</Link>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|