8da08b6bf1
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...
|