300 lines
10 KiB
TypeScript
300 lines
10 KiB
TypeScript
import { createFileRoute, useRouter, Link, useNavigate } 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 } from "lucide-react"
|
||
import { InfoChip } from "@/components/planes/InfoChip"
|
||
import { CreatePlanDialog } from "@/components/planes/CreatePlanDialog"
|
||
import { chipTint } from "@/components/planes/chipTint"
|
||
import { z } from "zod"
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select"
|
||
|
||
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
|
||
}
|
||
|
||
const planSearchSchema = z.object({
|
||
plan: z.string().nullable(),
|
||
facultad: z.string().nullable().optional(),
|
||
carrera: z.string().nullable().optional(),
|
||
})
|
||
|
||
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[]
|
||
},
|
||
validateSearch: planSearchSchema,
|
||
})
|
||
|
||
function RouteComponent() {
|
||
const auth = useSupabaseAuth()
|
||
const { plan, facultad, carrera } = Route.useSearch()
|
||
const [openCreate, setOpenCreate] = useState(false)
|
||
const data = Route.useLoaderData() as PlanRow[]
|
||
const router = useRouter()
|
||
const navigate = useNavigate({ from: Route.fullPath })
|
||
|
||
const showFacultad =
|
||
auth.claims?.role === "lci" || auth.claims?.role === "vicerrectoria"
|
||
const showCarrera =
|
||
showFacultad || auth.claims?.role === "secretario_academico"
|
||
|
||
// 🟣 Lista única de facultades
|
||
const facultadesList = useMemo(() => {
|
||
const unique = new Map<string, string>()
|
||
data?.forEach((p) => {
|
||
const fac = p.carreras?.facultades
|
||
if (fac?.id && fac?.nombre) unique.set(fac.id, fac.nombre)
|
||
})
|
||
return Array.from(unique.entries())
|
||
}, [data])
|
||
|
||
// 🎓 Lista de carreras según facultad seleccionada
|
||
const carrerasList = useMemo(() => {
|
||
const unique = new Map<string, string>()
|
||
data?.forEach((p) => {
|
||
if (
|
||
p.carreras?.id &&
|
||
p.carreras?.nombre &&
|
||
(!facultad || p.carreras?.facultades?.id === facultad)
|
||
) {
|
||
unique.set(p.carreras.id, p.carreras.nombre)
|
||
}
|
||
})
|
||
return Array.from(unique.entries())
|
||
}, [data, facultad])
|
||
|
||
// 🧩 Filtrado general
|
||
const filtered = useMemo(() => {
|
||
const term = plan?.trim().toLowerCase()
|
||
let results = data ?? []
|
||
|
||
if (term) {
|
||
results = results.filter((p) =>
|
||
[p.nombre, p.nivel, p.estado, p.carreras?.nombre, p.carreras?.facultades?.nombre]
|
||
.filter(Boolean)
|
||
.some((v) => String(v).toLowerCase().includes(term))
|
||
)
|
||
}
|
||
|
||
if (facultad && facultad !== "todas") {
|
||
results = results.filter((p) => p.carreras?.facultades?.id === facultad)
|
||
}
|
||
|
||
if (carrera && carrera !== "todas") {
|
||
results = results.filter((p) => p.carreras?.id === carrera)
|
||
}
|
||
|
||
return results
|
||
}, [plan, facultad, carrera, 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 font-mono">Planes de estudio</CardTitle>
|
||
|
||
<div className="flex w-full flex-col md:flex-row items-center gap-2 md:w-auto">
|
||
{/* 🔍 Buscador */}
|
||
<div className="relative w-full md:w-80">
|
||
<Input
|
||
value={plan ?? ""}
|
||
onChange={(e) =>
|
||
navigate({ search: { plan: e.target.value, facultad, carrera } })
|
||
}
|
||
placeholder="Buscar por nombre, nivel, estado…"
|
||
/>
|
||
</div>
|
||
|
||
{/* 🏛️ Filtro por facultad */}
|
||
<Select
|
||
value={facultad ?? "todas"}
|
||
onValueChange={(val) =>
|
||
navigate({ search: { plan, facultad: val, carrera: "todas" } })
|
||
}
|
||
>
|
||
<SelectTrigger className="w-[200px]">
|
||
<SelectValue placeholder="Filtrar por facultad" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="todas">Todas las facultades</SelectItem>
|
||
{facultadesList.map(([id, nombre]) => (
|
||
<SelectItem key={id} value={id}>
|
||
{nombre}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
|
||
{/* 🎓 Filtro por carrera (según facultad) */}
|
||
{facultad && facultad !== "todas" && (
|
||
<Select
|
||
value={carrera ?? "todas"}
|
||
onValueChange={(val) =>
|
||
navigate({ search: { plan, facultad, carrera: val } })
|
||
}
|
||
>
|
||
<SelectTrigger className="w-[200px]">
|
||
<SelectValue placeholder="Filtrar por carrera" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="todas">Todas las carreras</SelectItem>
|
||
{carrerasList.map(([id, nombre]) => (
|
||
<SelectItem key={id} value={id}>
|
||
{nombre}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
)}
|
||
|
||
|
||
{/* 🔁 Recargar */}
|
||
<Button
|
||
variant="outline"
|
||
size="icon"
|
||
onClick={() => router.invalidate()}
|
||
title="Recargar"
|
||
>
|
||
<RefreshCcw className="h-4 w-4" />
|
||
</Button>
|
||
|
||
{/* ➕ Nuevo plan */}
|
||
<Button onClick={() => setOpenCreate(true)}>
|
||
<Plus className="mr-2 h-4 w-4" /> Nuevo plan
|
||
</Button>
|
||
</div>
|
||
</CardHeader>
|
||
|
||
{/* GRID de tarjetas */}
|
||
<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 = chipTint(fac?.color)
|
||
const IconComp =
|
||
(fac?.icon && (Icons as any)[fac.icon]) || Building2
|
||
|
||
return (
|
||
<Link
|
||
key={p.id}
|
||
to="/plan/$planId"
|
||
mask={{ to: "/plan/$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}
|
||
>
|
||
<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="mt-3 flex items-center gap-2">
|
||
<div className="min-w-0 flex-1 flex flex-wrap items-center gap-2">
|
||
{showCarrera && p.carreras?.nombre && (
|
||
<InfoChip
|
||
icon={<Icons.GraduationCap className="h-3 w-3" />}
|
||
label={p.carreras.nombre}
|
||
/>
|
||
)}
|
||
{showFacultad && fac?.nombre && (
|
||
<InfoChip
|
||
icon={<Icons.Building2 className="h-3 w-3" />}
|
||
label={fac.nombre}
|
||
tint={fac.color}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{p.estado && (
|
||
<Badge
|
||
variant="outline"
|
||
className="bg-white/60"
|
||
style={{
|
||
borderColor:
|
||
chipTint(fac?.color).borderColor as string,
|
||
}}
|
||
>
|
||
{p.estado.length > 10
|
||
? `${p.estado.slice(0, 10)}…`
|
||
: 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>
|
||
|
||
<CreatePlanDialog open={openCreate} onOpenChange={setOpenCreate} />
|
||
</div>
|
||
)
|
||
}
|