2 Commits

Author SHA1 Message Date
8da08b6bf1 La pantalla se volvía negra al abrir el dialogo de eliminar carrera
La razón es que se rendereaba un dialogo de borrado por carrera, pero al abrir uno se abrian los demás también
2025-10-24 13:01:33 -06:00
1fe8f2b6a8 Se corrigen bugs sobre crear carreras, filtrado y que aparezcan las materias cuando se crean 2025-10-24 12:36:39 -06:00
6 changed files with 117 additions and 257 deletions

View File

@@ -30,7 +30,7 @@ export function useDeleteCarreraDialog(carreraId: string, onDeleted?: () => void
const dialog = (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogContent className="bg-white">
<DialogHeader>
<DialogTitle className="font-mono" >¿Eliminar carrera?</DialogTitle>
<DialogDescription>

View File

@@ -11,9 +11,11 @@ import { supabase, useSupabaseAuth } from "@/auth/supabase"
import { Field } from "./Field"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"
import { asignaturaKeys } from "./planQueries"
import { useRouter } from "@tanstack/react-router"
export function AddAsignaturaButton({ planId, onAdded }: { planId: string; onAdded?: () => void }) {
const qc = useQueryClient()
const router = useRouter()
const supabaseAuth = useSupabaseAuth()
const [open, setOpen] = useState(false)
const [saving, setSaving] = useState(false)
@@ -45,7 +47,13 @@ export function AddAsignaturaButton({ planId, onAdded }: { planId: string; onAdd
objetivos: toNull(f.objetivos),
contenidos: [], bibliografia: [], criterios_evaluacion: null,
}
const { error } = await supabase.from("asignaturas").insert([payload])
const { error,data } = await supabase.from("asignaturas").insert([payload]).select().single()
console.log(data);
router.invalidate()
router.navigate({
to: "/asignatura/$asignaturaId",
params: { asignaturaId: data.id },
})
setSaving(false)
if (error) { alert(error.message); return }
setOpen(false)
@@ -64,8 +72,17 @@ export function AddAsignaturaButton({ planId, onAdded }: { planId: string; onAdd
body: JSON.stringify({ planEstudiosId: planId, prompt: iaPrompt, semestre: iaSemestre.trim() ? Number(iaSemestre) : undefined, insert: true, uuid: supabaseAuth.user?.id }),
})
if (!res.ok) throw new Error(await res.text())
const data = await res.json()
console.log("Asignatura generada:", data)
const asignaturaId = data.asignaturaId || data.insertResult?.id
if (!asignaturaId) throw new Error("No se recibió el ID de la asignatura generada")
confetti({ particleCount: 120, spread: 80, origin: { y: 0.6 } })
setOpen(false)
router.invalidate()
router.navigate({
to: "/asignatura/$asignaturaId",
params: { asignaturaId },
})
onAdded?.()
// qc.invalidateQueries({ queryKey: asignaturaKeys.preview(planId) })
// qc.invalidateQueries({ queryKey: asignaturaKeys.count(planId) })

View File

@@ -169,36 +169,6 @@ function RouteComponent() {
const [groupBy, setGroupBy] = useState<'semestre' | 'ninguno'>('semestre')
const [flag, setFlag] = useState<'' | 'sinBibliografia' | 'sinCriterios' | 'sinContenidos'>(search.f ?? '')
const [facultad, setFacultad] = useState("todas")
const [carrera, setCarrera] = useState("todas")
// 🟣 Lista única de facultades
const facultadesList = useMemo(() => {
const unique = new Map<string, string>()
planes?.forEach((p) => {
const fac = p.carrera?.facultad
if (fac?.id && fac?.nombre) unique.set(fac.id, fac.nombre)
})
return Array.from(unique.entries())
}, [planes])
// 🎓 Lista de carreras según la facultad seleccionada
const carrerasList = useMemo(() => {
const unique = new Map<string, string>()
planes?.forEach((p) => {
if (
p.carrera?.id &&
p.carrera?.nombre &&
(!facultad || facultad === "todas" || p.carrera?.facultad?.id === facultad)
) {
unique.set(p.carrera.id, p.carrera.nombre)
}
})
return Array.from(unique.entries())
}, [planes, facultad])
// NEW: Clonado individual
const [cloneOpen, setCloneOpen] = useState(false)
const [cloneTarget, setCloneTarget] = useState<Asignatura | null>(null)
@@ -247,30 +217,28 @@ const carrerasList = useMemo(() => {
return { sinBibliografia, sinCriterios, sinContenidos }
}, [asignaturas])
// Filtrado
const filtered = useMemo(() => {
const t = q.trim().toLowerCase()
return asignaturas.filter(a => {
const matchesQ =
!t ||
[a.nombre, a.clave, a.tipo, a.objetivos, a.plan?.nombre, a.plan?.carrera?.nombre, a.plan?.carrera?.facultad?.nombre]
.filter(Boolean)
.some(v => String(v).toLowerCase().includes(t))
const t = q.trim().toLowerCase()
return asignaturas.filter(a => {
const matchesQ =
!t ||
[a.nombre, a.clave, a.tipo, a.objetivos, a.plan?.nombre, a.plan?.carrera?.nombre, a.plan?.carrera?.facultad?.nombre]
.filter(Boolean)
.some(v => String(v).toLowerCase().includes(t))
const semOK = sem === 'todos' || String(a.semestre ?? '—') === sem
const tipoOK = tipo === 'todos' || (a.tipo ?? '—') === tipo
const carreraOK = carrera === 'todas' || a.plan?.carrera?.id === carrera
const facultadOK = facultad === 'todas' || a.plan?.carrera?.facultad?.id === facultad
const semOK = sem === 'todos' || String(a.semestre ?? '—') === sem
const tipoOK = tipo === 'todos' || (a.tipo ?? '—') === tipo
const flagOK =
!flag ||
(flag === 'sinBibliografia' && (!a.bibliografia || a.bibliografia.length === 0)) ||
(flag === 'sinCriterios' && (!a.criterios_evaluacion || !a.criterios_evaluacion.trim())) ||
(flag === 'sinContenidos' && (!a.contenidos || Object.keys(a.contenidos ?? {}).length === 0))
return matchesQ && semOK && tipoOK && flagOK && carreraOK && facultadOK
})
}, [q, sem, tipo, flag, carrera, facultad, asignaturas])
const flagOK =
!flag ||
(flag === 'sinBibliografia' && (!a.bibliografia || a.bibliografia.length === 0)) ||
(flag === 'sinCriterios' && (!a.criterios_evaluacion || !a.criterios_evaluacion.trim())) ||
(flag === 'sinContenidos' && (!a.contenidos || Object.keys(a.contenidos ?? {}).length === 0))
return matchesQ && semOK && tipoOK && flagOK
})
}, [q, sem, tipo, flag, asignaturas])
// Agrupación
const groups = useMemo(() => {
@@ -289,7 +257,7 @@ const carrerasList = useMemo(() => {
}, [filtered, groupBy])
// Helpers
const clearFilters = () => { setQ(''); setSem('todos'); setTipo('todos'); setCarrera('todas'); setFlag('') }
const clearFilters = () => { setQ(''); setSem('todos'); setTipo('todos'); setFlag('') }
// NEW: util para clonar 1 asignatura
async function cloneOne(src: Asignatura, overrides: {
@@ -426,7 +394,7 @@ const carrerasList = useMemo(() => {
</div>
{/* Filtros */}
<div className="grid gap-4 sm:grid-cols-5">
<div className="grid gap-4 sm:grid-cols-4">
<div>
<Label>Búsqueda</Label>
<Input
@@ -448,53 +416,29 @@ const carrerasList = useMemo(() => {
</div>
<div>
<Label>Facultad</Label>
<Select
value={facultad ?? "todas"}
onValueChange={(val) => {
setFacultad(val)
setCarrera("todas") // reset de carrera al cambiar facultad
}}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Filtrar por facultad" />
</SelectTrigger>
<SelectContent>
<SelectItem value="todas">Todas las facultades</SelectItem>
{facultadesList.map(([id, nombre]) => (
<SelectItem key={id} value={id}>
{nombre}
</SelectItem>
))}
<Label>Tipo</Label>
<Select value={tipo} onValueChange={setTipo}>
<SelectTrigger><SelectValue placeholder="Todos" /></SelectTrigger>
<SelectContent className="max-h-64">
<SelectItem value="todos">Todos</SelectItem>
{tipos.map(t => <SelectItem key={t} value={t}>{t}</SelectItem>)}
</SelectContent>
</Select>
</div>
{facultad && facultad !== "todas" && (
<div>
<Label>Carrera</Label>
<Select
value={carrera ?? "todas"}
onValueChange={(val) => setCarrera(val)}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Filtrar por carrera" />
</SelectTrigger>
<Label>Agrupación</Label>
<Select value={groupBy} onValueChange={(v) => setGroupBy(v as any)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="todas">Todas las carreras</SelectItem>
{carrerasList.map(([id, nombre]) => (
<SelectItem key={id} value={id}>
{nombre}
</SelectItem>
))}
<SelectItem value="semestre">Por semestre</SelectItem>
<SelectItem value="ninguno">Sin agrupación</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
{/* Chips de salud */}
<div className="flex flex-wrap items-center gap-2">
<HealthChip

View File

@@ -126,6 +126,17 @@ function RouteComponent() {
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()
@@ -198,10 +209,7 @@ function RouteComponent() {
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
const { setOpen: setDeleteOpen, dialog: deleteDialog } = useDeleteCarreraDialog(c.id, async () => {
await qc.invalidateQueries({ queryKey: carrerasKeys.root })
router.invalidate()
})
return (
<ContextMenu key={c.id}>
<ContextMenuTrigger onClick={(e) => openContextMenu(e)}>
@@ -233,11 +241,14 @@ function RouteComponent() {
<ContextMenuItem onClick={() => setEditCarrera(c)}>
<Icons.Pencil className="w-4 h-4 mr-2" /> Editar
</ContextMenuItem>
<ContextMenuItem onClick={() => setDeleteOpen(true)}>
<ContextMenuItem onClick={() => {
setDeleteTarget(c)
setDeleteOpen(true)
}}>
<Icons.Trash className="w-4 h-4 mr-2" /> Eliminar
</ContextMenuItem>
</ContextMenuContent>
{deleteDialog}
</ContextMenu>
)
})}
@@ -247,6 +258,8 @@ function RouteComponent() {
</CardContent>
</Card>
{deleteDialog}
{/* Crear / Editar */}
<CarreraFormDialog
open={createOpen}

View File

@@ -49,7 +49,10 @@ export const Route = createFileRoute("/_authenticated/plan/$planId")({
// ...existing code...
function RouteComponent() {
const qc = useQueryClient()
const { plan, asignaturas: asignaturasPreview } = Route.useLoaderData() as LoaderData
//const { plan, asignaturas: asignaturasPreview } = Route.useLoaderData() as LoaderData
const { plan } = Route.useLoaderData() as LoaderData
const { data: asignaturasPreview } = useSuspenseQuery(asignaturasPreviewOptions(plan.id))
const auth = useSupabaseAuth()
const asignaturasCount = asignaturasPreview.length

View File

@@ -10,44 +10,26 @@ import { Plus, RefreshCcw, Building2 } from "lucide-react"
import { InfoChip } from "@/components/planes/InfoChip"
import { CreatePlanDialog } from "@/components/planes/CreatePlanDialog"
import { chipTint } from "@/components/planes/chipTint"
import { z } from "zod"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { z } from 'zod'
export type PlanDeEstudios = {
id: string
nombre: string
nivel: string | null
duracion: string | null
total_creditos: number | null
estado: string | null
fecha_creacion: string | null
carrera_id: string | null
id: string; nombre: string; nivel: string | null; duracion: string | null;
total_creditos: number | null; estado: string | null; fecha_creacion: string | null; carrera_id: string | null
}
type PlanRow = PlanDeEstudios & {
carreras: {
id: string
nombre: string
facultades?: {
id: string
nombre: string
color?: string | null
icon?: string | null
} | null
id: string; nombre: string;
facultades?: { id: string; nombre: string; color?: string | null; icon?: string | null } | null
} | null
}
const planSearchSchema = z.object({
plan: z.string().nullable(),
facultad: z.string().nullable().optional(),
carrera: z.string().nullable().optional(),
plan: z.string().nullable()
})
export const Route = createFileRoute("/_authenticated/planes")({
component: RouteComponent,
loader: async () => {
@@ -63,191 +45,93 @@ export const Route = createFileRoute("/_authenticated/planes")({
`)
.order("fecha_creacion", { ascending: false })
.limit(100)
console.log({ data, error })
if (error) throw new Error(error.message)
return (data ?? []) as PlanRow[]
},
validateSearch: planSearchSchema,
})
function RouteComponent() {
const auth = useSupabaseAuth()
const { plan, facultad, carrera } = Route.useSearch()
const { plan } = Route.useSearch()
const [openCreate, setOpenCreate] = useState(false)
const data = Route.useLoaderData() as PlanRow[]
const router = useRouter()
const navigate = useNavigate({ from: Route.fullPath })
const showFacultad =
auth.claims?.role === "lci" || auth.claims?.role === "vicerrectoria"
const showCarrera =
showFacultad || auth.claims?.role === "secretario_academico"
// 🟣 Lista única de facultades
const facultadesList = useMemo(() => {
const unique = new Map<string, string>()
data?.forEach((p) => {
const fac = p.carreras?.facultades
if (fac?.id && fac?.nombre) unique.set(fac.id, fac.nombre)
})
return Array.from(unique.entries())
}, [data])
const showFacultad = auth.claims?.role === "lci" || auth.claims?.role === "vicerrectoria"
const showCarrera = showFacultad || auth.claims?.role === "secretario_academico"
// 🎓 Lista de carreras según facultad seleccionada
const carrerasList = useMemo(() => {
const unique = new Map<string, string>()
data?.forEach((p) => {
if (
p.carreras?.id &&
p.carreras?.nombre &&
(!facultad || p.carreras?.facultades?.id === facultad)
) {
unique.set(p.carreras.id, p.carreras.nombre)
}
})
return Array.from(unique.entries())
}, [data, facultad])
// 🧩 Filtrado general
const filtered = useMemo(() => {
const term = plan?.trim().toLowerCase()
let results = data ?? []
if (term) {
results = results.filter((p) =>
[p.nombre, p.nivel, p.estado, p.carreras?.nombre, p.carreras?.facultades?.nombre]
.filter(Boolean)
.some((v) => String(v).toLowerCase().includes(term))
)
}
if (facultad && facultad !== "todas") {
results = results.filter((p) => p.carreras?.facultades?.id === facultad)
}
if (carrera && carrera !== "todas") {
results = results.filter((p) => p.carreras?.id === carrera)
}
return results
}, [plan, facultad, carrera, data])
if (!term || !data) return data
return data.filter((p) =>
[p.nombre, p.nivel, p.estado, p.carreras?.nombre, p.carreras?.facultades?.nombre]
.filter(Boolean)
.some((v) => String(v).toLowerCase().includes(term))
)
}, [plan, data])
return (
<div className="space-y-4">
<Card>
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<CardTitle className="text-xl font-mono">Planes de estudio</CardTitle>
<div className="flex w-full flex-col md:flex-row items-center gap-2 md:w-auto">
{/* 🔍 Buscador */}
<div className="flex w-full items-center gap-2 md:w-auto">
<div className="relative w-full md:w-80">
<Input
value={plan ?? ""}
onChange={(e) =>
navigate({ search: { plan: e.target.value, facultad, carrera } })
}
value={plan ?? ''}
onChange={e => navigate({ search: { plan: e.target.value } })}
placeholder="Buscar por nombre, nivel, estado…"
/>
</div>
{/* 🏛️ Filtro por facultad */}
<Select
value={facultad ?? "todas"}
onValueChange={(val) =>
navigate({ search: { plan, facultad: val, carrera: "todas" } })
}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Filtrar por facultad" />
</SelectTrigger>
<SelectContent>
<SelectItem value="todas">Todas las facultades</SelectItem>
{facultadesList.map(([id, nombre]) => (
<SelectItem key={id} value={id}>
{nombre}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 🎓 Filtro por carrera (según facultad) */}
{facultad && facultad !== "todas" && (
<Select
value={carrera ?? "todas"}
onValueChange={(val) =>
navigate({ search: { plan, facultad, carrera: val } })
}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Filtrar por carrera" />
</SelectTrigger>
<SelectContent>
<SelectItem value="todas">Todas las carreras</SelectItem>
{carrerasList.map(([id, nombre]) => (
<SelectItem key={id} value={id}>
{nombre}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{/* 🔁 Recargar */}
<Button
variant="outline"
size="icon"
onClick={() => router.invalidate()}
title="Recargar"
>
<Button variant="outline" size="icon" onClick={() => router.invalidate()} title="Recargar">
<RefreshCcw className="h-4 w-4" />
</Button>
{/* Nuevo plan */}
<Button onClick={() => setOpenCreate(true)}>
<Plus className="mr-2 h-4 w-4" /> Nuevo plan
</Button>
</div>
</CardHeader>
{/* GRID de tarjetas */}
{/* GRID de tarjetas con estilo suave por facultad */}
<CardContent>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{filtered?.map((p) => {
const fac = p.carreras?.facultades
const styles = chipTint(fac?.color)
const IconComp =
(fac?.icon && (Icons as any)[fac.icon]) || Building2
const IconComp = (fac?.icon && (Icons as any)[fac.icon]) || Building2
return (
<Link
key={p.id}
to="/plan/$planId"
mask={{ to: "/plan/$planId", params: { planId: p.id } }}
mask={{ to: '/plan/$planId', params: { planId: p.id } }}
className="group relative overflow-hidden rounded-3xl bg-white shadow-sm ring-1 transition-all hover:shadow-md hover:-translate-y-0.5"
params={{ planId: p.id }}
style={styles}
>
<div className="relative p-5 h-40 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"
style={{
borderColor: styles.borderColor as string,
background: "rgba(255,255,255,.6)",
}}
>
<span className="inline-flex items-center justify-center rounded-2xl border px-2.5 py-2"
style={{ borderColor: styles.borderColor as string, background: 'rgba(255,255,255,.6)' }}>
<IconComp className="w-6 h-6" />
</span>
<div className="min-w-0">
<div className="font-semibold truncate">{p.nombre}</div>
<div className="text-xs text-neutral-600 truncate">
{p.nivel ?? "—"}{" "}
{p.duracion ? `· ${p.duracion}` : ""}
{p.nivel ?? "—"} {p.duracion ? `· ${p.duracion}` : ""}
</div>
</div>
</div>
{/* Dentro del map de tarjetas, sustituye SOLO el footer inferior */}
<div className="mt-3 flex items-center gap-2">
{/* grupo izquierdo: chips (wrap si no caben) */}
<div className="min-w-0 flex-1 flex flex-wrap items-center gap-2">
{showCarrera && p.carreras?.nombre && (
<InfoChip
@@ -264,21 +148,18 @@ function RouteComponent() {
)}
</div>
{/* derecha: estado */}
{p.estado && (
<Badge
variant="outline"
className="bg-white/60"
style={{
borderColor:
chipTint(fac?.color).borderColor as string,
}}
style={{ borderColor: (chipTint(fac?.color).borderColor as string) }}
>
{p.estado.length > 10
? `${p.estado.slice(0, 10)}`
: p.estado}
{p.estado && p.estado.length > 10 ? `${p.estado.slice(0, 10)}` : p.estado}
</Badge>
)}
</div>
</div>
</Link>
)
@@ -286,14 +167,16 @@ function RouteComponent() {
</div>
{!filtered?.length && (
<div className="text-center text-sm text-muted-foreground py-10">
Sin resultados
</div>
<div className="text-center text-sm text-muted-foreground py-10">Sin resultados</div>
)}
</CardContent>
</Card>
<CreatePlanDialog open={openCreate} onOpenChange={setOpenCreate} />
<CreatePlanDialog
open={openCreate}
onOpenChange={setOpenCreate}
/>
</div>
)
}