158 lines
6.3 KiB
TypeScript
158 lines
6.3 KiB
TypeScript
import { createFileRoute, useRouter, Link } from "@tanstack/react-router"
|
|
import { useMemo, useState } from "react"
|
|
import { supabase, useSupabaseAuth } from "@/auth/supabase"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Button } from "@/components/ui/button"
|
|
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"
|
|
|
|
export type PlanDeEstudios = {
|
|
id: string; nombre: string; nivel: string | null; duracion: string | null;
|
|
total_creditos: number | null; estado: string | null; fecha_creacion: string | null; carrera_id: string | null
|
|
}
|
|
type PlanRow = PlanDeEstudios & {
|
|
carreras: {
|
|
id: string; nombre: string;
|
|
facultades?: { id: string; nombre: string; color?: string | null; icon?: string | null } | null
|
|
} | null
|
|
}
|
|
|
|
export const Route = createFileRoute("/_authenticated/planes")({
|
|
component: RouteComponent,
|
|
loader: async () => {
|
|
const { data, error } = await supabase
|
|
.from("plan_estudios")
|
|
.select(`
|
|
*,
|
|
carreras (
|
|
id,
|
|
nombre,
|
|
facultades:facultades ( id, nombre, color, icon )
|
|
)
|
|
`)
|
|
.order("fecha_creacion", { ascending: false })
|
|
.limit(100)
|
|
if (error) throw new Error(error.message)
|
|
return (data ?? []) as PlanRow[]
|
|
},
|
|
|
|
})
|
|
|
|
/* ---------- helpers de estilo suave ---------- */
|
|
function hexToRgb(hex?: string | null): [number, number, number] {
|
|
if (!hex) return [37, 99, 235] // azul por defecto
|
|
const h = hex.replace('#', '')
|
|
const v = h.length === 3 ? h.split('').map(c => c + c).join('') : h
|
|
const n = parseInt(v, 16)
|
|
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
|
|
}
|
|
function softCardStyles(color?: string | null) {
|
|
const [r, g, b] = hexToRgb(color)
|
|
return {
|
|
// borde + velo muy sutil del color de la facultad
|
|
borderColor: `rgba(${r},${g},${b},.28)`,
|
|
background: `linear-gradient(180deg, rgba(${r},${g},${b},.15), rgba(${r},${g},${b},.02))`,
|
|
} as React.CSSProperties
|
|
}
|
|
|
|
function RouteComponent() {
|
|
const auth = useSupabaseAuth()
|
|
const [q, setQ] = useState("")
|
|
const data = Route.useLoaderData() as PlanRow[]
|
|
const router = useRouter()
|
|
|
|
const showFacultad = auth.claims?.role === "lci" || auth.claims?.role === "vicerrectoria"
|
|
const showCarrera = showFacultad || auth.claims?.role === "secretario_academico"
|
|
|
|
const filtered = useMemo(() => {
|
|
const term = q.trim().toLowerCase()
|
|
if (!term || !data) return data
|
|
return data.filter((p) =>
|
|
[p.nombre, p.nivel, p.estado, p.carreras?.nombre, p.carreras?.facultades?.nombre]
|
|
.filter(Boolean)
|
|
.some((v) => String(v).toLowerCase().includes(term))
|
|
)
|
|
}, [q, data])
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<Card>
|
|
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
|
<CardTitle className="text-xl">Planes de estudio</CardTitle>
|
|
<div className="flex w-full items-center gap-2 md:w-auto">
|
|
<div className="relative w-full md:w-80">
|
|
<Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Buscar por nombre, nivel, estado…" />
|
|
</div>
|
|
<Button variant="outline" size="icon" onClick={() => router.invalidate()} title="Recargar">
|
|
<RefreshCcw className="h-4 w-4" />
|
|
</Button>
|
|
<Button>
|
|
<Plus className="mr-2 h-4 w-4" /> Nuevo plan
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
{/* GRID de tarjetas con estilo suave por facultad */}
|
|
<CardContent>
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
|
{filtered?.map((p) => {
|
|
const fac = p.carreras?.facultades
|
|
const styles = softCardStyles(fac?.color)
|
|
const IconComp = (fac?.icon && (Icons as any)[fac.icon]) || Building2
|
|
|
|
return (
|
|
<Link
|
|
key={p.id}
|
|
to="/planes/$planId/modal"
|
|
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}
|
|
>
|
|
<div className="relative p-5 h-40 flex flex-col justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<span className="inline-flex items-center justify-center rounded-2xl border px-2.5 py-2"
|
|
style={{ borderColor: styles.borderColor as string, background: 'rgba(255,255,255,.6)' }}>
|
|
<IconComp className="w-6 h-6" />
|
|
</span>
|
|
<div className="min-w-0">
|
|
<div className="font-semibold truncate">{p.nombre}</div>
|
|
<div className="text-xs text-neutral-600 truncate">
|
|
{p.nivel ?? "—"} {p.duracion ? `· ${p.duracion}` : ""}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 text-xs">
|
|
{showCarrera && p.carreras?.nombre && (
|
|
<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 w-fit" style={{ borderColor: styles.borderColor }}>
|
|
<BookOpen className="mr-1 h-3 w-3" /> {fac?.nombre}
|
|
</Badge>
|
|
)}
|
|
{p.estado && (
|
|
<Badge variant="outline" className="ml-auto bg-white/60" style={{ borderColor: styles.borderColor }}>
|
|
{p.estado}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{!filtered?.length && (
|
|
<div className="text-center text-sm text-muted-foreground py-10">Sin resultados</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|