2 Commits

Author SHA1 Message Date
bec6405c54 Se agrgan filtros 2025-10-29 14:44:47 -06:00
53502d927b Se agregan filros por carrera y facultad 2025-10-29 14:43:19 -06:00
6 changed files with 257 additions and 117 deletions

View File

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

View File

@@ -11,11 +11,9 @@ 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)
@@ -47,13 +45,7 @@ export function AddAsignaturaButton({ planId, onAdded }: { planId: string; onAdd
objetivos: toNull(f.objetivos),
contenidos: [], bibliografia: [], criterios_evaluacion: null,
}
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 },
})
const { error } = await supabase.from("asignaturas").insert([payload])
setSaving(false)
if (error) { alert(error.message); return }
setOpen(false)
@@ -72,17 +64,8 @@ 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,6 +169,36 @@ 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)
@@ -217,28 +247,30 @@ function RouteComponent() {
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 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 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))
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])
return matchesQ && semOK && tipoOK && flagOK
})
}, [q, sem, tipo, flag, asignaturas])
// Agrupación
const groups = useMemo(() => {
@@ -257,7 +289,7 @@ function RouteComponent() {
}, [filtered, groupBy])
// Helpers
const clearFilters = () => { setQ(''); setSem('todos'); setTipo('todos'); setFlag('') }
const clearFilters = () => { setQ(''); setSem('todos'); setTipo('todos'); setCarrera('todas'); setFlag('') }
// NEW: util para clonar 1 asignatura
async function cloneOne(src: Asignatura, overrides: {
@@ -394,7 +426,7 @@ function RouteComponent() {
</div>
{/* Filtros */}
<div className="grid gap-4 sm:grid-cols-4">
<div className="grid gap-4 sm:grid-cols-5">
<div>
<Label>Búsqueda</Label>
<Input
@@ -416,29 +448,53 @@ function RouteComponent() {
</div>
<div>
<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>)}
<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>
))}
</SelectContent>
</Select>
</div>
{facultad && facultad !== "todas" && (
<div>
<Label>Agrupación</Label>
<Select value={groupBy} onValueChange={(v) => setGroupBy(v as any)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<Label>Carrera</Label>
<Select
value={carrera ?? "todas"}
onValueChange={(val) => setCarrera(val)}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Filtrar por carrera" />
</SelectTrigger>
<SelectContent>
<SelectItem value="semestre">Por semestre</SelectItem>
<SelectItem value="ninguno">Sin agrupación</SelectItem>
<SelectItem value="todas">Todas las carreras</SelectItem>
{carrerasList.map(([id, nombre]) => (
<SelectItem key={id} value={id}>
{nombre}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
{/* Chips de salud */}
<div className="flex flex-wrap items-center gap-2">
<HealthChip

View File

@@ -126,17 +126,6 @@ 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()
@@ -209,7 +198,10 @@ 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)}>
@@ -241,14 +233,11 @@ function RouteComponent() {
<ContextMenuItem onClick={() => setEditCarrera(c)}>
<Icons.Pencil className="w-4 h-4 mr-2" /> Editar
</ContextMenuItem>
<ContextMenuItem onClick={() => {
setDeleteTarget(c)
setDeleteOpen(true)
}}>
<ContextMenuItem onClick={() => setDeleteOpen(true)}>
<Icons.Trash className="w-4 h-4 mr-2" /> Eliminar
</ContextMenuItem>
</ContextMenuContent>
{deleteDialog}
</ContextMenu>
)
})}
@@ -258,8 +247,6 @@ function RouteComponent() {
</CardContent>
</Card>
{deleteDialog}
{/* Crear / Editar */}
<CarreraFormDialog
open={createOpen}

View File

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

View File

@@ -10,26 +10,44 @@ 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 { z } from "zod"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
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()
plan: z.string().nullable(),
facultad: z.string().nullable().optional(),
carrera: z.string().nullable().optional(),
})
export const Route = createFileRoute("/_authenticated/planes")({
component: RouteComponent,
loader: async () => {
@@ -45,93 +63,191 @@ 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 } = Route.useSearch()
const { plan, facultad, carrera } = 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"
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])
// 🎓 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()
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])
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])
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 items-center gap-2 md:w-auto">
<div className="flex w-full flex-col md:flex-row items-center gap-2 md:w-auto">
{/* 🔍 Buscador */}
<div className="relative w-full md:w-80">
<Input
value={plan ?? ''}
onChange={e => navigate({ search: { plan: e.target.value } })}
value={plan ?? ""}
onChange={(e) =>
navigate({ search: { plan: e.target.value, facultad, carrera } })
}
placeholder="Buscar por nombre, nivel, estado…"
/>
</div>
<Button variant="outline" size="icon" onClick={() => router.invalidate()} title="Recargar">
{/* 🏛️ 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"
>
<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 con estilo suave por facultad */}
{/* GRID de tarjetas */}
<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
@@ -148,18 +264,21 @@ 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 && p.estado.length > 10 ? `${p.estado.slice(0, 10)}` : p.estado}
{p.estado.length > 10
? `${p.estado.slice(0, 10)}`
: p.estado}
</Badge>
)}
</div>
</div>
</Link>
)
@@ -167,16 +286,14 @@ 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>
)
}