Refactor user management in usuarios.tsx: integrate react-query for data fetching and mutations, streamline role handling, and enhance user ban/unban functionality.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
// routes/_authenticated/carreras.tsx
|
||||
// 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"
|
||||
|
||||
@@ -9,20 +10,14 @@ 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 { 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 }
|
||||
type CarreraRow = {
|
||||
export type CarreraRow = {
|
||||
id: string
|
||||
nombre: string
|
||||
semestres: number
|
||||
@@ -30,39 +25,102 @@ type CarreraRow = {
|
||||
facultad_id: string | null
|
||||
facultades?: FacultadLite | null
|
||||
}
|
||||
type LoaderData = { carreras: CarreraRow[]; facultades: FacultadLite[] }
|
||||
|
||||
/* -------------------- 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<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[]
|
||||
}
|
||||
|
||||
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,
|
||||
loader: async (): Promise<LoaderData> => {
|
||||
const [{ data: carreras, error: e1 }, { data: facultades, error: e2 }] = await Promise.all([
|
||||
supabase
|
||||
.from("carreras")
|
||||
.select(`id, nombre, semestres, activo, facultad_id, facultades:facultades ( id, nombre, color, icon )`)
|
||||
.order("nombre", { ascending: true }),
|
||||
supabase.from("facultades").select("id, nombre, color, icon").order("nombre", { ascending: true }),
|
||||
// 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()),
|
||||
])
|
||||
if (e1) throw e1
|
||||
if (e2) throw e2
|
||||
return {
|
||||
carreras: (carreras ?? []) as unknown as CarreraRow[],
|
||||
facultades: (facultades ?? []) as FacultadLite[],
|
||||
}
|
||||
return null
|
||||
},
|
||||
})
|
||||
|
||||
/* -------------------- Helpers UI -------------------- */
|
||||
const tint = (hex?: string | null, a = .18) => {
|
||||
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 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
|
||||
const r = (n >> 16) & 255,
|
||||
g = (n >> 8) & 255,
|
||||
b = n & 255
|
||||
return `rgba(${r},${g},${b},${a})`
|
||||
}
|
||||
const StatusPill = ({ active }: { active: boolean }) => (
|
||||
<span className={`text-[10px] px-2 py-0.5 rounded-full border ${active ? "bg-emerald-50 text-emerald-700 border-emerald-200" : "bg-neutral-100 text-neutral-700 border-neutral-200"}`}>
|
||||
<span
|
||||
className={`text-[10px] px-2 py-0.5 rounded-full border ${
|
||||
active
|
||||
? "bg-emerald-50 text-emerald-700 border-emerald-200"
|
||||
: "bg-neutral-100 text-neutral-700 border-neutral-200"
|
||||
}`}
|
||||
>
|
||||
{active ? "Activa" : "Inactiva"}
|
||||
</span>
|
||||
)
|
||||
@@ -70,7 +128,10 @@ const StatusPill = ({ active }: { active: boolean }) => (
|
||||
/* -------------------- Página -------------------- */
|
||||
function RouteComponent() {
|
||||
const router = useRouter()
|
||||
const { carreras, facultades } = Route.useLoaderData() as LoaderData
|
||||
const qc = useQueryClient()
|
||||
|
||||
const { data: carreras } = useSuspenseQuery(carrerasOptions())
|
||||
const { data: facultades } = useSuspenseQuery(facultadesOptions())
|
||||
|
||||
const [q, setQ] = useState("")
|
||||
const [fac, setFac] = useState<string>("todas")
|
||||
@@ -87,8 +148,7 @@ function RouteComponent() {
|
||||
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))
|
||||
return [c.nombre, c.facultades?.nombre].filter(Boolean).some((v) => String(v).toLowerCase().includes(term))
|
||||
})
|
||||
}, [q, fac, state, carreras])
|
||||
|
||||
@@ -100,19 +160,18 @@ function RouteComponent() {
|
||||
<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"
|
||||
/>
|
||||
<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>)}
|
||||
{facultades.map((f) => (
|
||||
<SelectItem key={f.id} value={f.id}>
|
||||
{f.nombre}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -125,7 +184,18 @@ function RouteComponent() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button variant="outline" size="icon" onClick={() => router.invalidate()} title="Recargar">
|
||||
<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>
|
||||
|
||||
@@ -137,10 +207,10 @@ function RouteComponent() {
|
||||
|
||||
<CardContent>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{filtered.map(c => {
|
||||
{filtered.map((c) => {
|
||||
const fac = c.facultades
|
||||
const border = tint(fac?.color, .28)
|
||||
const chip = tint(fac?.color, .10)
|
||||
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 (
|
||||
<article
|
||||
@@ -150,15 +220,12 @@ function RouteComponent() {
|
||||
>
|
||||
<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 }}>
|
||||
<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 className="text-xs text-neutral-600 truncate">{fac?.nombre ?? "—"} · {c.semestres} semestres</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -179,9 +246,7 @@ function RouteComponent() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{!filtered.length && (
|
||||
<div className="text-center text-sm text-neutral-500 py-10">No hay resultados</div>
|
||||
)}
|
||||
{!filtered.length && <div className="text-center text-sm text-neutral-500 py-10">No hay resultados</div>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -191,7 +256,10 @@ function RouteComponent() {
|
||||
onOpenChange={setCreateOpen}
|
||||
facultades={facultades}
|
||||
mode="create"
|
||||
onSaved={() => router.invalidate()}
|
||||
onSaved={async () => {
|
||||
await qc.invalidateQueries({ queryKey: carrerasKeys.root })
|
||||
router.invalidate()
|
||||
}}
|
||||
/>
|
||||
<CarreraFormDialog
|
||||
open={!!editCarrera}
|
||||
@@ -199,18 +267,34 @@ function RouteComponent() {
|
||||
facultades={facultades}
|
||||
mode="edit"
|
||||
carrera={editCarrera ?? undefined}
|
||||
onSaved={() => { setEditCarrera(null); router.invalidate() }}
|
||||
onSaved={async () => {
|
||||
setEditCarrera(null)
|
||||
await qc.invalidateQueries({ queryKey: carrerasKeys.root })
|
||||
router.invalidate()
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Detalle + añadir criterio */}
|
||||
<CarreraDetailDialog carrera={detail} onOpenChange={setDetail} onChanged={() => router.invalidate()} />
|
||||
<CarreraDetailDialog
|
||||
carrera={detail}
|
||||
onOpenChange={setDetail}
|
||||
onChanged={async () => {
|
||||
await qc.invalidateQueries({ queryKey: carrerasKeys.root })
|
||||
if (detail) await qc.invalidateQueries({ queryKey: criteriosKeys.byCarrera(detail.id) })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* -------------------- Form crear/editar -------------------- */
|
||||
function CarreraFormDialog({
|
||||
open, onOpenChange, mode, carrera, facultades, onSaved,
|
||||
open,
|
||||
onOpenChange,
|
||||
mode,
|
||||
carrera,
|
||||
facultades,
|
||||
onSaved,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (o: boolean) => void
|
||||
@@ -240,7 +324,10 @@ function CarreraFormDialog({
|
||||
}, [mode, carrera, open])
|
||||
|
||||
async function save() {
|
||||
if (!nombre.trim()) { alert("Escribe un nombre"); return }
|
||||
if (!nombre.trim()) {
|
||||
alert("Escribe un nombre")
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
const payload = {
|
||||
nombre: nombre.trim(),
|
||||
@@ -249,13 +336,17 @@ function CarreraFormDialog({
|
||||
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 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 }
|
||||
if (error) {
|
||||
alert(error.message)
|
||||
return
|
||||
}
|
||||
onOpenChange(false)
|
||||
onSaved?.()
|
||||
}
|
||||
@@ -279,13 +370,7 @@ function CarreraFormDialog({
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>Semestres</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
value={semestres}
|
||||
onChange={(e) => setSemestres(parseInt(e.target.value || "9", 10))}
|
||||
/>
|
||||
<Input type="number" min={1} max={20} value={semestres} onChange={(e) => setSemestres(parseInt(e.target.value || "9", 10))} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Estado</Label>
|
||||
@@ -299,18 +384,28 @@ function CarreraFormDialog({
|
||||
<div className="space-y-1">
|
||||
<Label>Facultad</Label>
|
||||
<Select value={facultadId} onValueChange={(v) => setFacultadId(v as any)}>
|
||||
<SelectTrigger><SelectValue placeholder="Selecciona una facultad (opcional)" /></SelectTrigger>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecciona una facultad (opcional)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">Sin facultad</SelectItem>
|
||||
{facultades.map(f => <SelectItem key={f.id} value={f.id}>{f.nombre}</SelectItem>)}
|
||||
{facultades.map((f) => (
|
||||
<SelectItem key={f.id} value={f.id}>
|
||||
{f.nombre}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancelar</Button>
|
||||
<Button onClick={save} disabled={saving}>{saving ? "Guardando…" : (mode === "create" ? "Crear" : "Guardar")}</Button>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={save} disabled={saving}>
|
||||
{saving ? "Guardando…" : mode === "create" ? "Crear" : "Guardar"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -327,41 +422,19 @@ function CarreraDetailDialog({
|
||||
onOpenChange: (c: CarreraRow | null) => void
|
||||
onChanged?: () => void
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [criterios, setCriterios] = useState<Array<{
|
||||
id: number
|
||||
nombre: string
|
||||
descripcion: string | null
|
||||
tipo: string | null
|
||||
obligatorio: boolean | null
|
||||
referencia_documento: string | null
|
||||
fecha_creacion: string | null
|
||||
}>>([])
|
||||
const carreraId = carrera?.id ?? ""
|
||||
const { data: criterios = [], isFetching } = useQuery({
|
||||
...criteriosOptions(carreraId || "noop"),
|
||||
enabled: !!carreraId,
|
||||
})
|
||||
const [q, setQ] = useState("")
|
||||
const [newCritOpen, setNewCritOpen] = useState(false)
|
||||
|
||||
async function load() {
|
||||
if (!carrera) return
|
||||
setLoading(true)
|
||||
const { data, error } = await supabase
|
||||
.from("criterios_carrera")
|
||||
.select("id, nombre, descripcion, tipo, obligatorio, referencia_documento, fecha_creacion")
|
||||
.eq("carrera_id", carrera.id)
|
||||
.order("fecha_creacion", { ascending: true })
|
||||
setLoading(false)
|
||||
if (error) { alert(error.message); return }
|
||||
setCriterios(data ?? [])
|
||||
}
|
||||
|
||||
useEffect(() => { load() /* eslint-disable-next-line */ }, [carrera?.id])
|
||||
|
||||
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)),
|
||||
return criterios.filter((c) =>
|
||||
[c.nombre, c.descripcion, c.tipo, c.referencia_documento].filter(Boolean).some((v) => String(v).toLowerCase().includes(t))
|
||||
)
|
||||
}, [q, criterios])
|
||||
|
||||
@@ -371,9 +444,10 @@ function CarreraDetailDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle>{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>
|
||||
{carrera?.facultades?.nombre ?? "—"} · {carrera?.semestres} semestres {typeof carrera?.activo === "boolean" && (
|
||||
<Badge variant="outline" className="ml-2">
|
||||
{carrera?.activo ? "Activa" : "Inactiva"}
|
||||
</Badge>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -382,25 +456,18 @@ function CarreraDetailDialog({
|
||||
<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"
|
||||
/>
|
||||
<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>
|
||||
|
||||
{loading ? (
|
||||
{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>
|
||||
<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>
|
||||
@@ -453,7 +520,7 @@ function CarreraDetailDialog({
|
||||
open={newCritOpen}
|
||||
onOpenChange={setNewCritOpen}
|
||||
carreraId={carrera?.id ?? ""}
|
||||
onSaved={() => { setNewCritOpen(false); load(); onChanged?.() }}
|
||||
onSaved={onChanged}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -462,13 +529,17 @@ function CarreraDetailDialog({
|
||||
|
||||
/* -------------------- Form crear criterio -------------------- */
|
||||
function CriterioFormDialog({
|
||||
open, onOpenChange, carreraId, onSaved,
|
||||
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<string>("")
|
||||
@@ -478,24 +549,38 @@ function CriterioFormDialog({
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setNombre(""); setTipo(""); setDescripcion(""); setObligatorio(true); setReferencia("")
|
||||
setNombre("")
|
||||
setTipo("")
|
||||
setDescripcion("")
|
||||
setObligatorio(true)
|
||||
setReferencia("")
|
||||
}
|
||||
}, [open])
|
||||
|
||||
async function save() {
|
||||
if (!carreraId) return
|
||||
if (!nombre.trim()) { alert("Escribe un nombre"); 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,
|
||||
}])
|
||||
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 }
|
||||
if (error) {
|
||||
alert(error.message)
|
||||
return
|
||||
}
|
||||
onOpenChange(false)
|
||||
await qc.invalidateQueries({ queryKey: criteriosKeys.byCarrera(carreraId) })
|
||||
onSaved?.()
|
||||
}
|
||||
|
||||
@@ -536,8 +621,12 @@ function CriterioFormDialog({
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancelar</Button>
|
||||
<Button onClick={save} disabled={saving}>{saving ? "Guardando…" : "Crear"}</Button>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={save} disabled={saving}>
|
||||
{saving ? "Guardando…" : "Crear"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
Reference in New Issue
Block a user