feat: add CarreraDetailDialog and CriterioFormDialog components for managing carrera criteria
feat: implement CarreraFormDialog for creating and editing carreras feat: create StatusPill component for active/inactive status display feat: add openContextMenu utility for context menu interactions feat: add tint utility function for color manipulation refactor: update archivos route to use font-mono for CardTitle refactor: update asignaturas route to use font-mono for headings refactor: update carreras route to modularize components and improve readability refactor: update dashboard route to use font-mono for CardTitle refactor: update plan detail route to use font-mono for CardTitle refactor: update planes route to use font-mono for CardTitle refactor: update usuarios route to use font-mono for CardTitle refactor: update login route to use font-mono for CardTitle
This commit is contained in:
170
src/components/carreras/CarreraDetailDialog.tsx
Normal file
170
src/components/carreras/CarreraDetailDialog.tsx
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
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>{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>
|
||||||
|
)
|
||||||
|
}
|
||||||
133
src/components/carreras/CarreraFormDialog.tsx
Normal file
133
src/components/carreras/CarreraFormDialog.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { supabase } from "@/auth/supabase"
|
||||||
|
import { type CarreraRow, type FacultadLite } from "@/routes/_authenticated/carreras"
|
||||||
|
|
||||||
|
export function CarreraFormDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
mode,
|
||||||
|
carrera,
|
||||||
|
facultades,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (o: boolean) => void
|
||||||
|
mode: "create" | "edit"
|
||||||
|
carrera?: CarreraRow
|
||||||
|
facultades: FacultadLite[]
|
||||||
|
onSaved?: () => void
|
||||||
|
}) {
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [nombre, setNombre] = useState(carrera?.nombre ?? "")
|
||||||
|
const [semestres, setSemestres] = useState<number>(carrera?.semestres ?? 9)
|
||||||
|
const [activo, setActivo] = useState<boolean>(carrera?.activo ?? true)
|
||||||
|
const [facultadId, setFacultadId] = useState<string | "none">(carrera?.facultad_id ?? "none")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode === "edit" && carrera) {
|
||||||
|
setNombre(carrera.nombre)
|
||||||
|
setSemestres(carrera.semestres)
|
||||||
|
setActivo(carrera.activo)
|
||||||
|
setFacultadId(carrera.facultad_id ?? "none")
|
||||||
|
} else if (mode === "create") {
|
||||||
|
setNombre("")
|
||||||
|
setSemestres(9)
|
||||||
|
setActivo(true)
|
||||||
|
setFacultadId("none")
|
||||||
|
}
|
||||||
|
}, [mode, carrera, open])
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!nombre.trim()) {
|
||||||
|
alert("Escribe un nombre")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
const payload = {
|
||||||
|
nombre: nombre.trim(),
|
||||||
|
semestres: Number(semestres) || 9,
|
||||||
|
activo,
|
||||||
|
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 { error } = await action
|
||||||
|
setSaving(false)
|
||||||
|
if (error) {
|
||||||
|
alert(error.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onOpenChange(false)
|
||||||
|
onSaved?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{mode === "create" ? "Nueva carrera" : "Editar carrera"}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{mode === "create" ? "Crea una nueva carrera en la base de datos." : "Actualiza los datos de la carrera."}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Nombre</Label>
|
||||||
|
<Input value={nombre} onChange={(e) => setNombre(e.target.value)} placeholder="Ing. en Software" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Estado</Label>
|
||||||
|
<div className="flex h-10 items-center gap-2 rounded-md border px-3">
|
||||||
|
<Switch checked={activo} onCheckedChange={setActivo} />
|
||||||
|
<span className="text-sm">{activo ? "Activa" : "Inactiva"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">Sin facultad</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>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
114
src/components/carreras/CriterioFormDialog.tsx
Normal file
114
src/components/carreras/CriterioFormDialog.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { 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 { Label } from "@/components/ui/label"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { supabase } from "@/auth/supabase"
|
||||||
|
import { criteriosKeys } from "@/routes/_authenticated/carreras"
|
||||||
|
|
||||||
|
export function CriterioFormDialog({
|
||||||
|
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>("")
|
||||||
|
const [descripcion, setDescripcion] = useState("")
|
||||||
|
const [obligatorio, setObligatorio] = useState(true)
|
||||||
|
const [referencia, setReferencia] = useState("")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setNombre("")
|
||||||
|
setTipo("")
|
||||||
|
setDescripcion("")
|
||||||
|
setObligatorio(true)
|
||||||
|
setReferencia("")
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!carreraId) 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,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
setSaving(false)
|
||||||
|
if (error) {
|
||||||
|
alert(error.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onOpenChange(false)
|
||||||
|
await qc.invalidateQueries({ queryKey: criteriosKeys.byCarrera(carreraId) })
|
||||||
|
onSaved?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Nuevo criterio</DialogTitle>
|
||||||
|
<DialogDescription>Agrega un criterio para esta carrera.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Nombre</Label>
|
||||||
|
<Input value={nombre} onChange={(e) => setNombre(e.target.value)} placeholder="Infraestructura de laboratorios" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Tipo</Label>
|
||||||
|
<Input value={tipo} onChange={(e) => setTipo(e.target.value)} placeholder="Académico / Operativo / Otro" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Descripción</Label>
|
||||||
|
<Input value={descripcion} onChange={(e) => setDescripcion(e.target.value)} placeholder="Detalle o alcance del criterio" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3 items-center">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>¿Obligatorio?</Label>
|
||||||
|
<div className="flex h-10 items-center gap-2 rounded-md border px-3">
|
||||||
|
<Switch checked={obligatorio} onCheckedChange={setObligatorio} />
|
||||||
|
<span className="text-sm">{obligatorio ? "Sí" : "No"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Referencia (URL)</Label>
|
||||||
|
<Input value={referencia} onChange={(e) => setReferencia(e.target.value)} placeholder="https://…" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button onClick={save} disabled={saving}>
|
||||||
|
{saving ? "Guardando…" : "Crear"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
12
src/components/carreras/StatusPill.tsx
Normal file
12
src/components/carreras/StatusPill.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export function StatusPill({ active }: { active: boolean }) {
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
15
src/components/carreras/openContextMenu.ts
Normal file
15
src/components/carreras/openContextMenu.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export function openContextMenu(e: React.MouseEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
// Simulate right click by opening context menu
|
||||||
|
const trigger = e.currentTarget
|
||||||
|
if (!(trigger instanceof HTMLElement)) return
|
||||||
|
const event = new window.MouseEvent("contextmenu", {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
view: window,
|
||||||
|
clientX: e.clientX,
|
||||||
|
clientY: e.clientY,
|
||||||
|
})
|
||||||
|
trigger.dispatchEvent(event)
|
||||||
|
}
|
||||||
10
src/components/carreras/utils.ts
Normal file
10
src/components/carreras/utils.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export 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 n = parseInt(v, 16)
|
||||||
|
const r = (n >> 16) & 255,
|
||||||
|
g = (n >> 8) & 255,
|
||||||
|
b = n & 255
|
||||||
|
return `rgba(${r},${g},${b},${a})`
|
||||||
|
}
|
||||||
@@ -90,7 +90,7 @@ function RouteComponent() {
|
|||||||
<div className="p-6 space-y-4">
|
<div className="p-6 space-y-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<CardTitle>Archivos de referencia</CardTitle>
|
<CardTitle className="font-mono">Archivos de referencia</CardTitle>
|
||||||
|
|
||||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
|
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
|
||||||
<div className="relative w-full sm:w-80">
|
<div className="relative w-full sm:w-80">
|
||||||
|
|||||||
@@ -361,7 +361,7 @@ function RouteComponent() {
|
|||||||
<div className="rounded-3xl border bg-white/75 dark:bg-neutral-900/60 shadow-sm">
|
<div className="rounded-3xl border bg-white/75 dark:bg-neutral-900/60 shadow-sm">
|
||||||
<div className="p-5 flex flex-col gap-3">
|
<div className="p-5 flex flex-col gap-3">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<h1 className="text-xl font-bold flex items-center gap-2">
|
<h1 className="text-xl font-bold flex items-center gap-2 font-mono">
|
||||||
<Icons.BookOpen className="w-5 h-5" />
|
<Icons.BookOpen className="w-5 h-5" />
|
||||||
Asignaturas
|
Asignaturas
|
||||||
</h1>
|
</h1>
|
||||||
@@ -705,7 +705,7 @@ function AsignaturaCard({ a, onClone, onAddToCart }: { a: Asignatura; onClone: (
|
|||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<h4 className="font-semibold leading-tight truncate" title={a.nombre}>{a.nombre}</h4>
|
<h4 className="font-mono leading-tight truncate" title={a.nombre}>{a.nombre}</h4>
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
|
|||||||
@@ -1,24 +1,29 @@
|
|||||||
// routes/_authenticated/carreras.tsx (refactor a TanStack Query v5)
|
// routes/_authenticated/carreras.tsx (refactor a TanStack Query v5)
|
||||||
import { createFileRoute, useRouter } from "@tanstack/react-router"
|
import { createFileRoute, useRouter } from "@tanstack/react-router"
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { useSuspenseQuery, useQueryClient, queryOptions, useQuery } from "@tanstack/react-query"
|
import { useSuspenseQuery, useQueryClient, queryOptions } from "@tanstack/react-query"
|
||||||
import { supabase } from "@/auth/supabase"
|
import { supabase } from "@/auth/supabase"
|
||||||
import * as Icons from "lucide-react"
|
import * as Icons from "lucide-react"
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Badge } from "@/components/ui/badge"
|
// import { Badge } from "@/components/ui/badge" // unused
|
||||||
import { Label } from "@/components/ui/label"
|
// import { Label } from "@/components/ui/label" // unused
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
// import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" // unused
|
||||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
|
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
|
||||||
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "@/components/ui/accordion"
|
// import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "@/components/ui/accordion" // unused
|
||||||
import { Switch } from "@/components/ui/switch"
|
// import { Switch } from "@/components/ui/switch" // unused
|
||||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from "@/components/ui/context-menu"
|
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from "@/components/ui/context-menu"
|
||||||
import { useDeleteCarreraDialog } from "@/components/carreras/DeleteCarreras"
|
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 -------------------- */
|
/* -------------------- Tipos -------------------- */
|
||||||
type FacultadLite = { id: string; nombre: string; color?: string | null; icon?: string | null }
|
export type FacultadLite = { id: string; nombre: string; color?: string | null; icon?: string | null }
|
||||||
export type CarreraRow = {
|
export type CarreraRow = {
|
||||||
id: string
|
id: string
|
||||||
nombre: string
|
nombre: string
|
||||||
@@ -33,11 +38,11 @@ export const carrerasKeys = {
|
|||||||
root: ["carreras"] as const,
|
root: ["carreras"] as const,
|
||||||
list: () => [...carrerasKeys.root, "list"] as const,
|
list: () => [...carrerasKeys.root, "list"] as const,
|
||||||
}
|
}
|
||||||
const facultadesKeys = {
|
export const facultadesKeys = {
|
||||||
root: ["facultades"] as const,
|
root: ["facultades"] as const,
|
||||||
all: () => [...facultadesKeys.root, "all"] as const,
|
all: () => [...facultadesKeys.root, "all"] as const,
|
||||||
}
|
}
|
||||||
const criteriosKeys = {
|
export const criteriosKeys = {
|
||||||
root: ["criterios_carrera"] as const,
|
root: ["criterios_carrera"] as const,
|
||||||
byCarrera: (id: string) => [...criteriosKeys.root, { carreraId: id }] as const,
|
byCarrera: (id: string) => [...criteriosKeys.root, { carreraId: id }] as const,
|
||||||
}
|
}
|
||||||
@@ -82,13 +87,13 @@ async function fetchCriterios(carreraId: string): Promise<CriterioRow[]> {
|
|||||||
return (data ?? []) as CriterioRow[]
|
return (data ?? []) as CriterioRow[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const carrerasOptions = () =>
|
export const carrerasOptions = () =>
|
||||||
queryOptions({ queryKey: carrerasKeys.list(), queryFn: fetchCarreras, staleTime: 60_000 })
|
queryOptions({ queryKey: carrerasKeys.list(), queryFn: fetchCarreras, staleTime: 60_000 })
|
||||||
|
|
||||||
const facultadesOptions = () =>
|
export const facultadesOptions = () =>
|
||||||
queryOptions({ queryKey: facultadesKeys.all(), queryFn: fetchFacultades, staleTime: 5 * 60_000 })
|
queryOptions({ queryKey: facultadesKeys.all(), queryFn: fetchFacultades, staleTime: 5 * 60_000 })
|
||||||
|
|
||||||
const criteriosOptions = (carreraId: string) =>
|
export const criteriosOptions = (carreraId: string) =>
|
||||||
queryOptions({ queryKey: criteriosKeys.byCarrera(carreraId), queryFn: () => fetchCriterios(carreraId) })
|
queryOptions({ queryKey: criteriosKeys.byCarrera(carreraId), queryFn: () => fetchCriterios(carreraId) })
|
||||||
|
|
||||||
/* -------------------- Ruta -------------------- */
|
/* -------------------- Ruta -------------------- */
|
||||||
@@ -104,27 +109,7 @@ export const Route = createFileRoute("/_authenticated/carreras")({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
/* -------------------- Helpers UI -------------------- */
|
// ...existing code...
|
||||||
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 n = parseInt(v, 16)
|
|
||||||
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"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{active ? "Activa" : "Inactiva"}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
|
|
||||||
/* -------------------- Página -------------------- */
|
/* -------------------- Página -------------------- */
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
@@ -157,7 +142,7 @@ function RouteComponent() {
|
|||||||
<div className="p-6 space-y-4">
|
<div className="p-6 space-y-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
<CardHeader className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
<CardTitle>Carreras</CardTitle>
|
<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="flex w-full flex-col gap-2 md:w-auto md:flex-row md:items-center">
|
||||||
<div className="relative w-full md:w-80">
|
<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" />
|
<Icons.Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
|
||||||
@@ -299,364 +284,8 @@ function RouteComponent() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function openContextMenu(e: React.MouseEvent) {
|
// ...existing code...
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
// Simulate right click by opening context menu
|
|
||||||
const trigger = e.currentTarget
|
|
||||||
if (!(trigger instanceof HTMLElement)) return
|
|
||||||
const event = new window.MouseEvent("contextmenu", {
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true,
|
|
||||||
view: window,
|
|
||||||
clientX: e.clientX,
|
|
||||||
clientY: e.clientY,
|
|
||||||
})
|
|
||||||
trigger.dispatchEvent(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -------------------- Form crear/editar -------------------- */
|
// ...existing code...
|
||||||
function CarreraFormDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
mode,
|
|
||||||
carrera,
|
|
||||||
facultades,
|
|
||||||
onSaved,
|
|
||||||
}: {
|
|
||||||
open: boolean
|
|
||||||
onOpenChange: (o: boolean) => void
|
|
||||||
mode: "create" | "edit"
|
|
||||||
carrera?: CarreraRow
|
|
||||||
facultades: FacultadLite[]
|
|
||||||
onSaved?: () => void
|
|
||||||
}) {
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
const [nombre, setNombre] = useState(carrera?.nombre ?? "")
|
|
||||||
const [semestres, setSemestres] = useState<number>(carrera?.semestres ?? 9)
|
|
||||||
const [activo, setActivo] = useState<boolean>(carrera?.activo ?? true)
|
|
||||||
const [facultadId, setFacultadId] = useState<string | "none">(carrera?.facultad_id ?? "none")
|
|
||||||
|
|
||||||
useEffect(() => {
|
// ...existing code...
|
||||||
if (mode === "edit" && carrera) {
|
|
||||||
setNombre(carrera.nombre)
|
|
||||||
setSemestres(carrera.semestres)
|
|
||||||
setActivo(carrera.activo)
|
|
||||||
setFacultadId(carrera.facultad_id ?? "none")
|
|
||||||
} else if (mode === "create") {
|
|
||||||
setNombre("")
|
|
||||||
setSemestres(9)
|
|
||||||
setActivo(true)
|
|
||||||
setFacultadId("none")
|
|
||||||
}
|
|
||||||
}, [mode, carrera, open])
|
|
||||||
|
|
||||||
async function save() {
|
|
||||||
if (!nombre.trim()) {
|
|
||||||
alert("Escribe un nombre")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setSaving(true)
|
|
||||||
const payload = {
|
|
||||||
nombre: nombre.trim(),
|
|
||||||
semestres: Number(semestres) || 9,
|
|
||||||
activo,
|
|
||||||
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 { error } = await action
|
|
||||||
setSaving(false)
|
|
||||||
if (error) {
|
|
||||||
alert(error.message)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
onOpenChange(false)
|
|
||||||
onSaved?.()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="max-w-lg">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{mode === "create" ? "Nueva carrera" : "Editar carrera"}</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{mode === "create" ? "Crea una nueva carrera en la base de datos." : "Actualiza los datos de la carrera."}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="grid gap-3">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>Nombre</Label>
|
|
||||||
<Input value={nombre} onChange={(e) => setNombre(e.target.value)} placeholder="Ing. en Software" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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))} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>Estado</Label>
|
|
||||||
<div className="flex h-10 items-center gap-2 rounded-md border px-3">
|
|
||||||
<Switch checked={activo} onCheckedChange={setActivo} />
|
|
||||||
<span className="text-sm">{activo ? "Activa" : "Inactiva"}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="none">Sin facultad</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>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -------------------- Detalle (criterios) -------------------- */
|
|
||||||
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 [q, setQ] = useState("")
|
|
||||||
const [newCritOpen, setNewCritOpen] = useState(false)
|
|
||||||
|
|
||||||
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])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={!!carrera} onOpenChange={(o) => !o && onOpenChange(null)}>
|
|
||||||
<DialogContent className="max-w-3xl">
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</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">
|
|
||||||
<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>
|
|
||||||
</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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -------------------- Form crear criterio -------------------- */
|
|
||||||
function CriterioFormDialog({
|
|
||||||
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>("")
|
|
||||||
const [descripcion, setDescripcion] = useState("")
|
|
||||||
const [obligatorio, setObligatorio] = useState(true)
|
|
||||||
const [referencia, setReferencia] = useState("")
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
setNombre("")
|
|
||||||
setTipo("")
|
|
||||||
setDescripcion("")
|
|
||||||
setObligatorio(true)
|
|
||||||
setReferencia("")
|
|
||||||
}
|
|
||||||
}, [open])
|
|
||||||
|
|
||||||
async function save() {
|
|
||||||
if (!carreraId) 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,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
setSaving(false)
|
|
||||||
if (error) {
|
|
||||||
alert(error.message)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
onOpenChange(false)
|
|
||||||
await qc.invalidateQueries({ queryKey: criteriosKeys.byCarrera(carreraId) })
|
|
||||||
onSaved?.()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="max-w-lg">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Nuevo criterio</DialogTitle>
|
|
||||||
<DialogDescription>Agrega un criterio para esta carrera.</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="grid gap-3">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>Nombre</Label>
|
|
||||||
<Input value={nombre} onChange={(e) => setNombre(e.target.value)} placeholder="Infraestructura de laboratorios" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>Tipo</Label>
|
|
||||||
<Input value={tipo} onChange={(e) => setTipo(e.target.value)} placeholder="Académico / Operativo / Otro" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>Descripción</Label>
|
|
||||||
<Input value={descripcion} onChange={(e) => setDescripcion(e.target.value)} placeholder="Detalle o alcance del criterio" />
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-3 items-center">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>¿Obligatorio?</Label>
|
|
||||||
<div className="flex h-10 items-center gap-2 rounded-md border px-3">
|
|
||||||
<Switch checked={obligatorio} onCheckedChange={setObligatorio} />
|
|
||||||
<span className="text-sm">{obligatorio ? "Sí" : "No"}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>Referencia (URL)</Label>
|
|
||||||
<Input value={referencia} onChange={(e) => setReferencia(e.target.value)} placeholder="https://…" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
||||||
Cancelar
|
|
||||||
</Button>
|
|
||||||
<Button onClick={save} disabled={saving}>
|
|
||||||
{saving ? "Guardando…" : "Crear"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -279,7 +279,7 @@ function RouteComponent() {
|
|||||||
<div className="grid gap-6 lg:grid-cols-3">
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
<Card className="lg:col-span-2">
|
<Card className="lg:col-span-2">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2 font-mono">
|
||||||
<Icons.CheckCircle2 className="w-5 h-5" /> Calidad de planes
|
<Icons.CheckCircle2 className="w-5 h-5" /> Calidad de planes
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -293,7 +293,7 @@ function RouteComponent() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2 font-mono">
|
||||||
<Icons.HeartPulse className="w-5 h-5" /> Salud de asignaturas
|
<Icons.HeartPulse className="w-5 h-5" /> Salud de asignaturas
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -308,7 +308,7 @@ function RouteComponent() {
|
|||||||
{/* Actividad reciente */}
|
{/* Actividad reciente */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2 font-mono">
|
||||||
<Icons.Activity className="w-5 h-5" /> Actividad reciente
|
<Icons.Activity className="w-5 h-5" /> Actividad reciente
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ function RouteComponent() {
|
|||||||
<IconComp className="w-6 h-6" />
|
<IconComp className="w-6 h-6" />
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<CardTitle className="hdr-title truncate">{plan.nombre}</CardTitle>
|
<CardTitle className="hdr-title truncate font-mono">{plan.nombre}</CardTitle>
|
||||||
<div className="hdr-chips text-xs text-neutral-600 truncate">
|
<div className="hdr-chips text-xs text-neutral-600 truncate">
|
||||||
{showCarrera && plan.carreras?.nombre ? `Carrera: ${plan.carreras.nombre}` : null}
|
{showCarrera && plan.carreras?.nombre ? `Carrera: ${plan.carreras.nombre}` : null}
|
||||||
{showFacultad && fac?.nombre ? `${showCarrera ? ' · ' : ''}Facultad: ${fac.nombre}` : null}
|
{showFacultad && fac?.nombre ? `${showCarrera ? ' · ' : ''}Facultad: ${fac.nombre}` : null}
|
||||||
@@ -113,7 +113,7 @@ function RouteComponent() {
|
|||||||
</div>
|
</div>
|
||||||
<Card className="border shadow-sm">
|
<Card className="border shadow-sm">
|
||||||
<CardHeader className="flex items-center justify-between gap-2">
|
<CardHeader className="flex items-center justify-between gap-2">
|
||||||
<CardTitle className="text-base">Asignaturas ({asignaturasCount})</CardTitle>
|
<CardTitle className="text-base font-mono">Asignaturas ({asignaturasCount})</CardTitle>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<AddAsignaturaButton planId={plan.id} onAdded={() => {
|
<AddAsignaturaButton planId={plan.id} onAdded={() => {
|
||||||
qc.invalidateQueries({ queryKey: asignaturaKeys.count(plan.id) })
|
qc.invalidateQueries({ queryKey: asignaturaKeys.count(plan.id) })
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ function RouteComponent() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||||
<CardTitle className="text-xl">Planes de estudio</CardTitle>
|
<CardTitle className="text-xl font-mono">Planes de estudio</CardTitle>
|
||||||
<div className="flex w-full items-center gap-2 md:w-auto">
|
<div className="flex w-full items-center gap-2 md:w-auto">
|
||||||
<div className="relative w-full md:w-80">
|
<div className="relative w-full md:w-80">
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -297,7 +297,7 @@ function RouteComponent() {
|
|||||||
<div className="p-6 space-y-4">
|
<div className="p-6 space-y-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="grid gap-3 sm:grid-cols-2 sm:items-center">
|
<CardHeader className="grid gap-3 sm:grid-cols-2 sm:items-center">
|
||||||
<CardTitle>Usuarios</CardTitle>
|
<CardTitle className="font-mono">Usuarios</CardTitle>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input placeholder="Buscar por nombre, email o rol…" value={q} onChange={(e) => setQ(e.target.value)} className="w-full" />
|
<Input placeholder="Buscar por nombre, email o rol…" value={q} onChange={(e) => setQ(e.target.value)} className="w-full" />
|
||||||
<Button variant="outline" size="icon" title="Recargar" onClick={invalidateAll}>
|
<Button variant="outline" size="icon" title="Recargar" onClick={invalidateAll}>
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ function LoginComponent() {
|
|||||||
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-2xl bg-muted">
|
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-2xl bg-muted">
|
||||||
<Shield className="h-6 w-6 text-foreground" aria-hidden />
|
<Shield className="h-6 w-6 text-foreground" aria-hidden />
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-2xl font-mono">Iniciar sesión</CardTitle>
|
<CardTitle className="font-mono text-2xl">Iniciar sesión</CardTitle>
|
||||||
<CardDescription className="text-muted-foreground">
|
<CardDescription className="text-muted-foreground">
|
||||||
Accede a tu panel para gestionar planes y materias
|
Accede a tu panel para gestionar planes y materias
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
|
|||||||
Reference in New Issue
Block a user