This repository has been archived on 2026-01-21. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Acad-IA/src/routes/_authenticated/carreras.tsx
Guillermo Arrieta Medina 8da08b6bf1 La pantalla se volvía negra al abrir el dialogo de eliminar carrera
La razón es que se rendereaba un dialogo de borrado por carrera, pero al abrir uno se abrian los demás también
2025-10-24 13:01:33 -06:00

305 lines
12 KiB
TypeScript

// routes/_authenticated/carreras.tsx (refactor a TanStack Query v5)
import { createFileRoute, useRouter } from "@tanstack/react-router"
import { useMemo, useState } from "react"
import { useSuspenseQuery, useQueryClient, queryOptions } 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" // unused
// import { Label } from "@/components/ui/label" // unused
// import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" // unused
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
// import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "@/components/ui/accordion" // unused
// import { Switch } from "@/components/ui/switch" // unused
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from "@/components/ui/context-menu"
import { useDeleteCarreraDialog } from "@/components/carreras/DeleteCarreras"
// Modularized components
import { CarreraFormDialog } from "@/components/carreras/CarreraFormDialog"
import { CarreraDetailDialog } from "@/components/carreras/CarreraDetailDialog"
import { StatusPill } from "@/components/carreras/StatusPill"
import { tint } from "@/components/carreras/utils"
import { openContextMenu } from "@/components/carreras/openContextMenu"
/* -------------------- Tipos -------------------- */
export 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 -------------------- */
export const carrerasKeys = {
root: ["carreras"] as const,
list: () => [...carrerasKeys.root, "list"] as const,
}
export const facultadesKeys = {
root: ["facultades"] as const,
all: () => [...facultadesKeys.root, "all"] as const,
}
export const criteriosKeys = {
root: ["criterios_carrera"] as const,
byCarrera: (id: string) => [...criteriosKeys.root, { carreraId: id }] as const,
}
async function fetchCarreras(): Promise<CarreraRow[]> {
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<FacultadLite[]> {
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<CriterioRow[]> {
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[]
}
export const carrerasOptions = () =>
queryOptions({ queryKey: carrerasKeys.list(), queryFn: fetchCarreras, staleTime: 60_000 })
export const facultadesOptions = () =>
queryOptions({ queryKey: facultadesKeys.all(), queryFn: fetchFacultades, staleTime: 5 * 60_000 })
export 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
},
})
// ...existing code...
/* -------------------- 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<string>("todas")
const [state, setState] = useState<"todas" | "activas" | "inactivas">("todas")
const [detail, setDetail] = useState<CarreraRow | null>(null)
const [editCarrera, setEditCarrera] = useState<CarreraRow | null>(null)
const [createOpen, setCreateOpen] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<CarreraRow | null>(null)
// ✅ Se declara UNA SOLA VEZ
const { setOpen: setDeleteOpen, dialog: deleteDialog } = useDeleteCarreraDialog(
deleteTarget?.id ?? "",
async () => {
await qc.invalidateQueries({ queryKey: carrerasKeys.root })
router.invalidate()
// setDeleteTarget(null)
}
)
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 (
<div className="p-6 space-y-4">
<Card>
<CardHeader className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<CardTitle className="text-xl font-mono">Carreras</CardTitle>
<div className="flex w-full flex-col gap-2 md:w-auto md:flex-row md:items-center">
<div className="relative w-full md:w-80">
<Icons.Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
<Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Buscar por nombre o facultad…" className="pl-8" />
</div>
<Select value={fac} onValueChange={(v) => setFac(v)}>
<SelectTrigger className="md:w-[220px]"><SelectValue placeholder="Facultad" /></SelectTrigger>
<SelectContent>
<SelectItem value="todas">Todas las facultades</SelectItem>
{facultades.map((f) => (
<SelectItem key={f.id} value={f.id}>
{f.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={state} onValueChange={(v: any) => setState(v)}>
<SelectTrigger className="md:w-[160px]"><SelectValue placeholder="Estado" /></SelectTrigger>
<SelectContent>
<SelectItem value="todas">Todas</SelectItem>
<SelectItem value="activas">Activas</SelectItem>
<SelectItem value="inactivas">Inactivas</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="icon"
onClick={async () => {
await Promise.all([
qc.invalidateQueries({ queryKey: carrerasKeys.root }),
qc.invalidateQueries({ queryKey: facultadesKeys.root }),
])
router.invalidate()
}}
title="Recargar"
>
<Icons.RefreshCcw className="h-4 w-4" />
</Button>
<Button onClick={() => setCreateOpen(true)}>
<Icons.Plus className="h-4 w-4 mr-2" /> Nueva carrera
</Button>
</div>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{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 (
<ContextMenu key={c.id}>
<ContextMenuTrigger onClick={(e) => openContextMenu(e)}>
<article
className="group relative overflow-hidden rounded-3xl bg-white shadow-sm ring-1 transition-all hover:shadow-md hover:-translate-y-0.5"
style={{ borderColor: border, background: `linear-gradient(180deg, ${chip}, transparent)` }}
>
<div className="p-5 h-44 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 bg-white/70" style={{ borderColor: border }}>
<IconComp className="w-6 h-6" />
</span>
<div className="min-w-0">
<div className="font-semibold truncate">{c.nombre}</div>
<div className="text-xs text-neutral-600 truncate">{fac?.nombre ?? "—"} · {c.semestres} semestres</div>
</div>
</div>
<div className="mt-2 flex items-center justify-between">
<StatusPill active={c.activo} />
</div>
</div>
</article>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => setDetail(c)}>
<Icons.Eye className="w-4 h-4 mr-2" /> Ver
</ContextMenuItem>
<ContextMenuItem onClick={() => setEditCarrera(c)}>
<Icons.Pencil className="w-4 h-4 mr-2" /> Editar
</ContextMenuItem>
<ContextMenuItem onClick={() => {
setDeleteTarget(c)
setDeleteOpen(true)
}}>
<Icons.Trash className="w-4 h-4 mr-2" /> Eliminar
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
})}
</div>
{!filtered.length && <div className="text-center text-sm text-neutral-500 py-10">No hay resultados</div>}
</CardContent>
</Card>
{deleteDialog}
{/* Crear / Editar */}
<CarreraFormDialog
open={createOpen}
onOpenChange={setCreateOpen}
facultades={facultades}
mode="create"
onSaved={async () => {
await qc.invalidateQueries({ queryKey: carrerasKeys.root })
router.invalidate()
}}
/>
<CarreraFormDialog
open={!!editCarrera}
onOpenChange={(o) => !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 */}
<CarreraDetailDialog
carrera={detail}
onOpenChange={setDetail}
onChanged={async () => {
await qc.invalidateQueries({ queryKey: carrerasKeys.root })
if (detail) await qc.invalidateQueries({ queryKey: criteriosKeys.byCarrera(detail.id) })
}}
/>
</div>
)
}
// ...existing code...
// ...existing code...
// ...existing code...