4 Commits

Author SHA1 Message Date
6e2b3d72f1 Se envían correctamente los ids de los archivos de referencia para su procesamiento en el backend 2025-10-27 17:14:50 -06:00
0c5c3f935b comm 2025-10-27 15:45:16 -06:00
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
7 changed files with 131 additions and 271 deletions

View File

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

View File

@@ -11,9 +11,11 @@ import { supabase, useSupabaseAuth } from "@/auth/supabase"
import { Field } from "./Field" import { Field } from "./Field"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs" import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"
import { asignaturaKeys } from "./planQueries" import { asignaturaKeys } from "./planQueries"
import { useRouter } from "@tanstack/react-router"
export function AddAsignaturaButton({ planId, onAdded }: { planId: string; onAdded?: () => void }) { export function AddAsignaturaButton({ planId, onAdded }: { planId: string; onAdded?: () => void }) {
const qc = useQueryClient() const qc = useQueryClient()
const router = useRouter()
const supabaseAuth = useSupabaseAuth() const supabaseAuth = useSupabaseAuth()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
@@ -45,7 +47,13 @@ export function AddAsignaturaButton({ planId, onAdded }: { planId: string; onAdd
objetivos: toNull(f.objetivos), objetivos: toNull(f.objetivos),
contenidos: [], bibliografia: [], criterios_evaluacion: null, 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) setSaving(false)
if (error) { alert(error.message); return } if (error) { alert(error.message); return }
setOpen(false) 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 }), 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()) 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 } }) confetti({ particleCount: 120, spread: 80, origin: { y: 0.6 } })
setOpen(false) setOpen(false)
router.invalidate()
router.navigate({
to: "/asignatura/$asignaturaId",
params: { asignaturaId },
})
onAdded?.() onAdded?.()
// qc.invalidateQueries({ queryKey: asignaturaKeys.preview(planId) }) // qc.invalidateQueries({ queryKey: asignaturaKeys.preview(planId) })
// qc.invalidateQueries({ queryKey: asignaturaKeys.count(planId) }) // qc.invalidateQueries({ queryKey: asignaturaKeys.count(planId) })

View File

@@ -104,7 +104,7 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
setDbFiles((data || []).map((file: any) => ({ setDbFiles((data || []).map((file: any) => ({
id: file.documentos_id, id: file.documentos_id,
titulo: file.titulo_archivo, titulo: file.titulo_archivo,
s3_file_path: file.titulo_archivo, s3_file_path: `prueba-referencias/documento_${file.documentos_id}.pdf`,
fecha_subida: file.fecha_subida, fecha_subida: file.fecha_subida,
tags: file.tags || [], tags: file.tags || [],
}))); })));
@@ -118,35 +118,35 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
const isSelected = useCallback((path: string) => selectedFiles.includes(path), [selectedFiles]); const isSelected = useCallback((path: string) => selectedFiles.includes(path), [selectedFiles]);
const toggleSelected = useCallback((path: string) => { const toggleSelected = useCallback((id: string) => {
setSelectedFiles(prev => prev.includes(path) ? prev.filter(p => p !== path) : [...prev, path]); setSelectedFiles(prev => prev.includes(id) ? prev.filter(p => p !== id) : [...prev, id]);
}, []); }, []);
const replaceSelection = useCallback((path: string) => { const replaceSelection = useCallback((id: string) => {
setSelectedFiles([path]); setSelectedFiles([id]);
}, []); }, []);
const rangeSelect = useCallback((start: number, end: number) => { const rangeSelect = useCallback((start: number, end: number) => {
const [s, e] = start < end ? [start, end] : [end, start]; const [s, e] = start < end ? [start, end] : [end, start];
const paths = dbFiles.slice(s, e + 1).map(f => f.s3_file_path); const ids = dbFiles.slice(s, e + 1).map(f => f.id);
setSelectedFiles(prev => Array.from(new Set([...prev, ...paths]))); setSelectedFiles(prev => Array.from(new Set([...prev, ...ids])));
}, [dbFiles]); }, [dbFiles]);
const handleCardClick = useCallback((e: React.MouseEvent, index: number, file: { s3_file_path: string }) => { const handleCardClick = useCallback((e: React.MouseEvent, index: number, file: { id: string }) => {
const path = file.s3_file_path; const id = file.id;
if (e.shiftKey && lastSelectedIndex !== null) { if (e.shiftKey && lastSelectedIndex !== null) {
rangeSelect(lastSelectedIndex, index); rangeSelect(lastSelectedIndex, index);
} else if (e.metaKey || e.ctrlKey) { } else if (e.metaKey || e.ctrlKey) {
toggleSelected(path); toggleSelected(id);
setLastSelectedIndex(index); setLastSelectedIndex(index);
} else { } else {
if (isSelected(path) && selectedFiles.length === 1) { if (isSelected(id) && selectedFiles.length === 1) {
// si ya es el único seleccionado, des-selecciona // si ya es el único seleccionado, des-selecciona
setSelectedFiles([]); setSelectedFiles([]);
setLastSelectedIndex(null); setLastSelectedIndex(null);
} else { } else {
replaceSelection(path); replaceSelection(id);
setLastSelectedIndex(index); setLastSelectedIndex(index);
} }
} }
@@ -167,7 +167,7 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
prompt: prompt, prompt: prompt,
insert: true, insert: true,
files: selectedFiles, files: selectedFiles,
uuid: auth.user?.id, created_by: auth.user?.id,
}) })
const newId = (res as any)?.id || (res as any)?.plan?.id || (res as any)?.data?.id const newId = (res as any)?.id || (res as any)?.plan?.id || (res as any)?.data?.id
if (newId) { if (newId) {
@@ -261,7 +261,7 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
<div role="grid" className="grid gap-4 xs:grid-cols-2 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5"> <div role="grid" className="grid gap-4 xs:grid-cols-2 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
{dbFiles.map((file, index) => { {dbFiles.map((file, index) => {
const ext = fileExt(file.titulo); const ext = fileExt(file.titulo);
const selected = isSelected(file.s3_file_path); const selected = isSelected(file.id);
console.log(file); console.log(file);
return ( return (

View File

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

View File

@@ -126,6 +126,17 @@ function RouteComponent() {
const [detail, setDetail] = useState<CarreraRow | null>(null) const [detail, setDetail] = useState<CarreraRow | null>(null)
const [editCarrera, setEditCarrera] = useState<CarreraRow | null>(null) const [editCarrera, setEditCarrera] = useState<CarreraRow | null>(null)
const [createOpen, setCreateOpen] = useState(false) 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 filtered = useMemo(() => {
const term = q.trim().toLowerCase() const term = q.trim().toLowerCase()
@@ -198,10 +209,7 @@ function RouteComponent() {
const border = tint(fac?.color, 0.28) const border = tint(fac?.color, 0.28)
const chip = tint(fac?.color, 0.1) const chip = tint(fac?.color, 0.1)
const IconComp = (fac?.icon && (Icons as any)[fac.icon]) || Icons.Building2 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 ( return (
<ContextMenu key={c.id}> <ContextMenu key={c.id}>
<ContextMenuTrigger onClick={(e) => openContextMenu(e)}> <ContextMenuTrigger onClick={(e) => openContextMenu(e)}>
@@ -233,11 +241,14 @@ function RouteComponent() {
<ContextMenuItem onClick={() => setEditCarrera(c)}> <ContextMenuItem onClick={() => setEditCarrera(c)}>
<Icons.Pencil className="w-4 h-4 mr-2" /> Editar <Icons.Pencil className="w-4 h-4 mr-2" /> Editar
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem onClick={() => setDeleteOpen(true)}> <ContextMenuItem onClick={() => {
setDeleteTarget(c)
setDeleteOpen(true)
}}>
<Icons.Trash className="w-4 h-4 mr-2" /> Eliminar <Icons.Trash className="w-4 h-4 mr-2" /> Eliminar
</ContextMenuItem> </ContextMenuItem>
</ContextMenuContent> </ContextMenuContent>
{deleteDialog}
</ContextMenu> </ContextMenu>
) )
})} })}
@@ -247,6 +258,8 @@ function RouteComponent() {
</CardContent> </CardContent>
</Card> </Card>
{deleteDialog}
{/* Crear / Editar */} {/* Crear / Editar */}
<CarreraFormDialog <CarreraFormDialog
open={createOpen} open={createOpen}

View File

@@ -49,7 +49,10 @@ export const Route = createFileRoute("/_authenticated/plan/$planId")({
// ...existing code... // ...existing code...
function RouteComponent() { function RouteComponent() {
const qc = useQueryClient() 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 auth = useSupabaseAuth()
const asignaturasCount = asignaturasPreview.length const asignaturasCount = asignaturasPreview.length

View File

@@ -10,44 +10,26 @@ import { Plus, RefreshCcw, Building2 } from "lucide-react"
import { InfoChip } from "@/components/planes/InfoChip" import { InfoChip } from "@/components/planes/InfoChip"
import { CreatePlanDialog } from "@/components/planes/CreatePlanDialog" import { CreatePlanDialog } from "@/components/planes/CreatePlanDialog"
import { chipTint } from "@/components/planes/chipTint" 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 = { export type PlanDeEstudios = {
id: string id: string; nombre: string; nivel: string | null; duracion: string | null;
nombre: string total_creditos: number | null; estado: string | null; fecha_creacion: string | null; carrera_id: string | null
nivel: string | null
duracion: string | null
total_creditos: number | null
estado: string | null
fecha_creacion: string | null
carrera_id: string | null
} }
type PlanRow = PlanDeEstudios & { type PlanRow = PlanDeEstudios & {
carreras: { carreras: {
id: string id: string; nombre: string;
nombre: string facultades?: { id: string; nombre: string; color?: string | null; icon?: string | null } | null
facultades?: {
id: string
nombre: string
color?: string | null
icon?: string | null
} | null
} | null } | null
} }
const planSearchSchema = z.object({ 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")({ export const Route = createFileRoute("/_authenticated/planes")({
component: RouteComponent, component: RouteComponent,
loader: async () => { loader: async () => {
@@ -63,191 +45,93 @@ export const Route = createFileRoute("/_authenticated/planes")({
`) `)
.order("fecha_creacion", { ascending: false }) .order("fecha_creacion", { ascending: false })
.limit(100) .limit(100)
console.log({ data, error })
if (error) throw new Error(error.message) if (error) throw new Error(error.message)
return (data ?? []) as PlanRow[] return (data ?? []) as PlanRow[]
}, },
validateSearch: planSearchSchema, validateSearch: planSearchSchema,
}) })
function RouteComponent() { function RouteComponent() {
const auth = useSupabaseAuth() const auth = useSupabaseAuth()
const { plan, facultad, carrera } = Route.useSearch() const { plan } = Route.useSearch()
const [openCreate, setOpenCreate] = useState(false) const [openCreate, setOpenCreate] = useState(false)
const data = Route.useLoaderData() as PlanRow[] const data = Route.useLoaderData() as PlanRow[]
const router = useRouter() const router = useRouter()
const navigate = useNavigate({ from: Route.fullPath }) 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 showFacultad = auth.claims?.role === "lci" || auth.claims?.role === "vicerrectoria"
const facultadesList = useMemo(() => { const showCarrera = showFacultad || auth.claims?.role === "secretario_academico"
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 filtered = useMemo(() => {
const term = plan?.trim().toLowerCase() const term = plan?.trim().toLowerCase()
let results = data ?? [] if (!term || !data) return data
return data.filter((p) =>
if (term) { [p.nombre, p.nivel, p.estado, p.carreras?.nombre, p.carreras?.facultades?.nombre]
results = results.filter((p) => .filter(Boolean)
[p.nombre, p.nivel, p.estado, p.carreras?.nombre, p.carreras?.facultades?.nombre] .some((v) => String(v).toLowerCase().includes(term))
.filter(Boolean) )
.some((v) => String(v).toLowerCase().includes(term)) }, [plan, data])
)
}
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 ( return (
<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 font-mono">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 flex-col md:flex-row items-center gap-2 md:w-auto">
{/* 🔍 Buscador */}
<div className="relative w-full md:w-80"> <div className="relative w-full md:w-80">
<Input <Input
value={plan ?? ""} value={plan ?? ''}
onChange={(e) => onChange={e => navigate({ search: { plan: e.target.value } })}
navigate({ search: { plan: e.target.value, facultad, carrera } })
}
placeholder="Buscar por nombre, nivel, estado…" placeholder="Buscar por nombre, nivel, estado…"
/> />
</div> </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" /> <RefreshCcw className="h-4 w-4" />
</Button> </Button>
{/* Nuevo plan */}
<Button onClick={() => setOpenCreate(true)}> <Button onClick={() => setOpenCreate(true)}>
<Plus className="mr-2 h-4 w-4" /> Nuevo plan <Plus className="mr-2 h-4 w-4" /> Nuevo plan
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
{/* GRID de tarjetas */} {/* GRID de tarjetas con estilo suave por facultad */}
<CardContent> <CardContent>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{filtered?.map((p) => { {filtered?.map((p) => {
const fac = p.carreras?.facultades const fac = p.carreras?.facultades
const styles = chipTint(fac?.color) const styles = chipTint(fac?.color)
const IconComp = const IconComp = (fac?.icon && (Icons as any)[fac.icon]) || Building2
(fac?.icon && (Icons as any)[fac.icon]) || Building2
return ( return (
<Link <Link
key={p.id} key={p.id}
to="/plan/$planId" 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" 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 }} params={{ planId: p.id }}
style={styles} style={styles}
> >
<div className="relative p-5 h-40 flex flex-col justify-between"> <div className="relative p-5 h-40 flex flex-col justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span <span className="inline-flex items-center justify-center rounded-2xl border px-2.5 py-2"
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)' }}>
style={{
borderColor: styles.borderColor as string,
background: "rgba(255,255,255,.6)",
}}
>
<IconComp className="w-6 h-6" /> <IconComp className="w-6 h-6" />
</span> </span>
<div className="min-w-0"> <div className="min-w-0">
<div className="font-semibold truncate">{p.nombre}</div> <div className="font-semibold truncate">{p.nombre}</div>
<div className="text-xs text-neutral-600 truncate"> <div className="text-xs text-neutral-600 truncate">
{p.nivel ?? "—"}{" "} {p.nivel ?? "—"} {p.duracion ? `· ${p.duracion}` : ""}
{p.duracion ? `· ${p.duracion}` : ""}
</div> </div>
</div> </div>
</div> </div>
{/* Dentro del map de tarjetas, sustituye SOLO el footer inferior */}
<div className="mt-3 flex items-center gap-2"> <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"> <div className="min-w-0 flex-1 flex flex-wrap items-center gap-2">
{showCarrera && p.carreras?.nombre && ( {showCarrera && p.carreras?.nombre && (
<InfoChip <InfoChip
@@ -264,21 +148,18 @@ function RouteComponent() {
)} )}
</div> </div>
{/* derecha: estado */}
{p.estado && ( {p.estado && (
<Badge <Badge
variant="outline" variant="outline"
className="bg-white/60" className="bg-white/60"
style={{ style={{ borderColor: (chipTint(fac?.color).borderColor as string) }}
borderColor:
chipTint(fac?.color).borderColor as string,
}}
> >
{p.estado.length > 10 {p.estado && p.estado.length > 10 ? `${p.estado.slice(0, 10)}` : p.estado}
? `${p.estado.slice(0, 10)}`
: p.estado}
</Badge> </Badge>
)} )}
</div> </div>
</div> </div>
</Link> </Link>
) )
@@ -286,14 +167,16 @@ function RouteComponent() {
</div> </div>
{!filtered?.length && ( {!filtered?.length && (
<div className="text-center text-sm text-muted-foreground py-10"> <div className="text-center text-sm text-muted-foreground py-10">Sin resultados</div>
Sin resultados
</div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
<CreatePlanDialog open={openCreate} onOpenChange={setOpenCreate} /> <CreatePlanDialog
open={openCreate}
onOpenChange={setOpenCreate}
/>
</div> </div>
) )
} }