171 lines
9.1 KiB
TypeScript
171 lines
9.1 KiB
TypeScript
import { useMemo, useState } from "react"
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "@/components/ui/accordion"
|
|
import * as Icons from "lucide-react"
|
|
import { type CarreraRow } from "@/routes/_authenticated/carreras"
|
|
import { criteriosOptions } from "@/routes/_authenticated/carreras"
|
|
import { CriterioFormDialog } from "./CriterioFormDialog"
|
|
import { supabase } from "@/auth/supabase"
|
|
|
|
export 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 qc = useQueryClient()
|
|
const [q, setQ] = useState("")
|
|
const [newCritOpen, setNewCritOpen] = useState(false)
|
|
const [deletingId, setDeletingId] = useState<number | null>(null)
|
|
|
|
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])
|
|
|
|
async function removeCriterio(id: number) {
|
|
if (!carreraId) return
|
|
if (!confirm("¿Seguro que quieres eliminar este criterio?")) return
|
|
setDeletingId(id)
|
|
const { error } = await supabase.from("criterios_carrera").delete().eq("id", id)
|
|
setDeletingId(null)
|
|
if (error) {
|
|
alert(error.message)
|
|
return
|
|
}
|
|
await qc.invalidateQueries({ queryKey: criteriosOptions(carreraId).queryKey })
|
|
onChanged?.()
|
|
}
|
|
|
|
return (
|
|
<Dialog open={!!carrera} onOpenChange={(o) => !o && onOpenChange(null)}>
|
|
<DialogContent className="max-w-3xl">
|
|
<DialogHeader>
|
|
<DialogTitle className="font-mono" >{carrera?.nombre}</DialogTitle>
|
|
<DialogDescription>
|
|
{carrera?.facultades?.nombre ?? "—"} · {carrera?.semestres} semestres{" "}
|
|
{typeof carrera?.activo === "boolean" && (
|
|
<Badge variant="outline" className="ml-2">
|
|
{carrera?.activo ? "Activa" : "Inactiva"}
|
|
</Badge>
|
|
)}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="relative flex-1">
|
|
<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 criterio por nombre, tipo o referencia…"
|
|
className="pl-8"
|
|
/>
|
|
</div>
|
|
<Button onClick={() => setNewCritOpen(true)}>
|
|
<Icons.Plus className="h-4 w-4 mr-2" /> Añadir criterio
|
|
</Button>
|
|
</div>
|
|
|
|
{isFetching ? (
|
|
<div className="text-sm text-neutral-500">Cargando criterios…</div>
|
|
) : (
|
|
<>
|
|
<div className="text-xs text-neutral-600">
|
|
{filtered.length} criterio(s)
|
|
{q ? " (filtrado)" : ""}
|
|
</div>
|
|
|
|
{filtered.length === 0 ? (
|
|
<div className="text-sm text-neutral-500 py-6 text-center">No hay criterios</div>
|
|
) : (
|
|
<Accordion type="multiple" className="mt-1">
|
|
{filtered.map((c) => (
|
|
<AccordionItem key={c.id} value={`c-${c.id}`} className="border rounded-xl mb-2 overflow-hidden">
|
|
<AccordionTrigger className="px-4 py-2 hover:no-underline text-left">
|
|
<div className="flex items-center justify-between w-full gap-3">
|
|
<span className="font-medium">{c.nombre}</span>
|
|
<div className="flex items-center gap-2 text-[11px]">
|
|
{c.tipo && <Badge variant="outline">{c.tipo}</Badge>}
|
|
<Badge variant="outline">{c.obligatorio ? "Obligatorio" : "Opcional"}</Badge>
|
|
<Button
|
|
variant="destructive"
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
removeCriterio(c.id)
|
|
}}
|
|
disabled={deletingId === c.id}
|
|
title="Eliminar criterio"
|
|
>
|
|
{deletingId === c.id ? (
|
|
<Icons.Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Icons.Trash2 className="h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</AccordionTrigger>
|
|
<AccordionContent className="px-5 pb-3">
|
|
{c.descripcion && <p className="text-sm text-neutral-800 leading-relaxed mb-2">{c.descripcion}</p>}
|
|
<div className="text-xs text-neutral-600 flex flex-wrap gap-3">
|
|
{c.referencia_documento && (
|
|
<span className="inline-flex items-center gap-1">
|
|
<Icons.Link className="h-3 w-3" />
|
|
<a className="underline" href={c.referencia_documento} target="_blank" rel="noreferrer">
|
|
Referencia
|
|
</a>
|
|
</span>
|
|
)}
|
|
{c.fecha_creacion && (
|
|
<span className="inline-flex items-center gap-1">
|
|
<Icons.CalendarClock className="h-3 w-3" />
|
|
{new Date(c.fecha_creacion).toLocaleString()}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
))}
|
|
</Accordion>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button onClick={() => onOpenChange(null)}>Cerrar</Button>
|
|
</DialogFooter>
|
|
|
|
{/* Crear criterio */}
|
|
<CriterioFormDialog
|
|
open={newCritOpen}
|
|
onOpenChange={setNewCritOpen}
|
|
carreraId={carrera?.id ?? ""}
|
|
onSaved={onChanged}
|
|
/>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|