// routes/_authenticated/carreras.tsx (refactor a TanStack Query v5) import { createFileRoute, useRouter } from "@tanstack/react-router" import { useEffect, useMemo, useState } from "react" import { useSuspenseQuery, useQueryClient, queryOptions, useQuery } from "@tanstack/react-query" import { supabase } from "@/auth/supabase" import * as Icons from "lucide-react" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Input } from "@/components/ui/input" import { Button } from "@/components/ui/button" import { Badge } from "@/components/ui/badge" import { Label } from "@/components/ui/label" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select" import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "@/components/ui/accordion" import { Switch } from "@/components/ui/switch" /* -------------------- Tipos -------------------- */ type FacultadLite = { id: string; nombre: string; color?: string | null; icon?: string | null } export type CarreraRow = { id: string nombre: string semestres: number activo: boolean facultad_id: string | null facultades?: FacultadLite | null } /* -------------------- Query Keys & Fetchers -------------------- */ const carrerasKeys = { root: ["carreras"] as const, list: () => [...carrerasKeys.root, "list"] as const, } const facultadesKeys = { root: ["facultades"] as const, all: () => [...facultadesKeys.root, "all"] as const, } const criteriosKeys = { root: ["criterios_carrera"] as const, byCarrera: (id: string) => [...criteriosKeys.root, { carreraId: id }] as const, } async function fetchCarreras(): Promise { const { data, error } = await supabase .from("carreras") .select( `id, nombre, semestres, activo, facultad_id, facultades:facultades ( id, nombre, color, icon )` ) .order("nombre", { ascending: true }) if (error) throw error return (data ?? []) as unknown as CarreraRow[] } async function fetchFacultades(): Promise { const { data, error } = await supabase .from("facultades") .select("id, nombre, color, icon") .order("nombre", { ascending: true }) if (error) throw error return (data ?? []) as FacultadLite[] } export type CriterioRow = { id: number nombre: string descripcion: string | null tipo: string | null obligatorio: boolean | null referencia_documento: string | null fecha_creacion: string | null } async function fetchCriterios(carreraId: string): Promise { const { data, error } = await supabase .from("criterios_carrera") .select("id, nombre, descripcion, tipo, obligatorio, referencia_documento, fecha_creacion") .eq("carrera_id", carreraId) .order("fecha_creacion", { ascending: true }) if (error) throw error return (data ?? []) as CriterioRow[] } const carrerasOptions = () => queryOptions({ queryKey: carrerasKeys.list(), queryFn: fetchCarreras, staleTime: 60_000 }) const facultadesOptions = () => queryOptions({ queryKey: facultadesKeys.all(), queryFn: fetchFacultades, staleTime: 5 * 60_000 }) const criteriosOptions = (carreraId: string) => queryOptions({ queryKey: criteriosKeys.byCarrera(carreraId), queryFn: () => fetchCriterios(carreraId) }) /* -------------------- Ruta -------------------- */ export const Route = createFileRoute("/_authenticated/carreras")({ component: RouteComponent, // Prefetch con TanStack Query (sin llamadas sueltas a Supabase fuera de QueryClient) loader: async ({ context: { queryClient } }) => { await Promise.all([ queryClient.ensureQueryData(carrerasOptions()), queryClient.ensureQueryData(facultadesOptions()), ]) return null }, }) /* -------------------- Helpers UI -------------------- */ const tint = (hex?: string | null, a = 0.18) => { if (!hex) return `rgba(37,99,235,${a})` const h = hex.replace("#", "") const v = h.length === 3 ? h.split("").map((c) => c + c).join("") : h const n = parseInt(v, 16) const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255 return `rgba(${r},${g},${b},${a})` } const StatusPill = ({ active }: { active: boolean }) => ( {active ? "Activa" : "Inactiva"} ) /* -------------------- Página -------------------- */ function RouteComponent() { const router = useRouter() const qc = useQueryClient() const { data: carreras } = useSuspenseQuery(carrerasOptions()) const { data: facultades } = useSuspenseQuery(facultadesOptions()) const [q, setQ] = useState("") const [fac, setFac] = useState("todas") const [state, setState] = useState<"todas" | "activas" | "inactivas">("todas") const [detail, setDetail] = useState(null) const [editCarrera, setEditCarrera] = useState(null) const [createOpen, setCreateOpen] = useState(false) const filtered = useMemo(() => { const term = q.trim().toLowerCase() return carreras.filter((c) => { if (fac !== "todas" && c.facultad_id !== fac) return false if (state === "activas" && !c.activo) return false if (state === "inactivas" && c.activo) return false if (!term) return true return [c.nombre, c.facultades?.nombre].filter(Boolean).some((v) => String(v).toLowerCase().includes(term)) }) }, [q, fac, state, carreras]) return (
Carreras
setQ(e.target.value)} placeholder="Buscar por nombre o facultad…" className="pl-8" />
{filtered.map((c) => { const fac = c.facultades const border = tint(fac?.color, 0.28) const chip = tint(fac?.color, 0.1) const IconComp = (fac?.icon && (Icons as any)[fac.icon]) || Icons.Building2 return (
{c.nombre}
{fac?.nombre ?? "—"} · {c.semestres} semestres
) })}
{!filtered.length &&
No hay resultados
}
{/* Crear / Editar */} { await qc.invalidateQueries({ queryKey: carrerasKeys.root }) router.invalidate() }} /> !o && setEditCarrera(null)} facultades={facultades} mode="edit" carrera={editCarrera ?? undefined} onSaved={async () => { setEditCarrera(null) await qc.invalidateQueries({ queryKey: carrerasKeys.root }) router.invalidate() }} /> {/* Detalle + añadir criterio */} { await qc.invalidateQueries({ queryKey: carrerasKeys.root }) if (detail) await qc.invalidateQueries({ queryKey: criteriosKeys.byCarrera(detail.id) }) }} />
) } /* -------------------- Form crear/editar -------------------- */ function CarreraFormDialog({ open, onOpenChange, mode, carrera, facultades, onSaved, }: { open: boolean onOpenChange: (o: boolean) => void mode: "create" | "edit" carrera?: CarreraRow facultades: FacultadLite[] onSaved?: () => void }) { const [saving, setSaving] = useState(false) const [nombre, setNombre] = useState(carrera?.nombre ?? "") const [semestres, setSemestres] = useState(carrera?.semestres ?? 9) const [activo, setActivo] = useState(carrera?.activo ?? true) const [facultadId, setFacultadId] = useState(carrera?.facultad_id ?? "none") useEffect(() => { if (mode === "edit" && carrera) { setNombre(carrera.nombre) setSemestres(carrera.semestres) setActivo(carrera.activo) setFacultadId(carrera.facultad_id ?? "none") } else if (mode === "create") { setNombre("") setSemestres(9) setActivo(true) setFacultadId("none") } }, [mode, carrera, open]) async function save() { if (!nombre.trim()) { alert("Escribe un nombre") return } setSaving(true) const payload = { nombre: nombre.trim(), semestres: Number(semestres) || 9, activo, facultad_id: facultadId === "none" ? null : facultadId, } const action = mode === "create" ? supabase.from("carreras").insert([payload]).select("id").single() : supabase.from("carreras").update(payload).eq("id", carrera!.id).select("id").single() const { error } = await action setSaving(false) if (error) { alert(error.message) return } onOpenChange(false) onSaved?.() } return ( {mode === "create" ? "Nueva carrera" : "Editar carrera"} {mode === "create" ? "Crea una nueva carrera en la base de datos." : "Actualiza los datos de la carrera."}
setNombre(e.target.value)} placeholder="Ing. en Software" />
setSemestres(parseInt(e.target.value || "9", 10))} />
{activo ? "Activa" : "Inactiva"}
) } /* -------------------- Detalle (criterios) -------------------- */ function CarreraDetailDialog({ carrera, onOpenChange, onChanged, }: { carrera: CarreraRow | null onOpenChange: (c: CarreraRow | null) => void onChanged?: () => void }) { const carreraId = carrera?.id ?? "" const { data: criterios = [], isFetching } = useQuery({ ...criteriosOptions(carreraId || "noop"), enabled: !!carreraId, }) const [q, setQ] = useState("") const [newCritOpen, setNewCritOpen] = useState(false) const filtered = useMemo(() => { const t = q.trim().toLowerCase() if (!t) return criterios return criterios.filter((c) => [c.nombre, c.descripcion, c.tipo, c.referencia_documento].filter(Boolean).some((v) => String(v).toLowerCase().includes(t)) ) }, [q, criterios]) return ( !o && onOpenChange(null)}> {carrera?.nombre} {carrera?.facultades?.nombre ?? "—"} · {carrera?.semestres} semestres {typeof carrera?.activo === "boolean" && ( {carrera?.activo ? "Activa" : "Inactiva"} )}
setQ(e.target.value)} placeholder="Buscar criterio por nombre, tipo o referencia…" className="pl-8" />
{isFetching ? (
Cargando criterios…
) : ( <>
{filtered.length} criterio(s){q ? " (filtrado)" : ""}
{filtered.length === 0 ? (
No hay criterios
) : ( {filtered.map((c) => (
{c.nombre}
{c.tipo && {c.tipo}} {c.obligatorio ? "Obligatorio" : "Opcional"}
{c.descripcion &&

{c.descripcion}

}
{c.referencia_documento && ( Referencia )} {c.fecha_creacion && ( {new Date(c.fecha_creacion).toLocaleString()} )}
))}
)} )}
{/* Crear criterio */}
) } /* -------------------- Form crear criterio -------------------- */ function CriterioFormDialog({ open, onOpenChange, carreraId, onSaved, }: { open: boolean onOpenChange: (o: boolean) => void carreraId: string onSaved?: () => void }) { const qc = useQueryClient() const [saving, setSaving] = useState(false) const [nombre, setNombre] = useState("") const [tipo, setTipo] = useState("") const [descripcion, setDescripcion] = useState("") const [obligatorio, setObligatorio] = useState(true) const [referencia, setReferencia] = useState("") useEffect(() => { if (!open) { setNombre("") setTipo("") setDescripcion("") setObligatorio(true) setReferencia("") } }, [open]) async function save() { if (!carreraId) return if (!nombre.trim()) { alert("Escribe un nombre") return } setSaving(true) const { error } = await supabase.from("criterios_carrera").insert([ { nombre: nombre.trim(), tipo: tipo || null, descripcion: descripcion || null, obligatorio, referencia_documento: referencia || null, carrera_id: carreraId, }, ]) setSaving(false) if (error) { alert(error.message) return } onOpenChange(false) await qc.invalidateQueries({ queryKey: criteriosKeys.byCarrera(carreraId) }) onSaved?.() } return ( Nuevo criterio Agrega un criterio para esta carrera.
setNombre(e.target.value)} placeholder="Infraestructura de laboratorios" />
setTipo(e.target.value)} placeholder="Académico / Operativo / Otro" />
setDescripcion(e.target.value)} placeholder="Detalle o alcance del criterio" />
{obligatorio ? "Sí" : "No"}
setReferencia(e.target.value)} placeholder="https://…" />
) }