La razón es que se rendereaba un dialogo de borrado por carrera, pero al abrir uno se abrian los demás también
305 lines
12 KiB
TypeScript
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...
|