Se agrgan filtros
This commit is contained in:
@@ -10,26 +10,44 @@ 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; nombre: string; nivel: string | null; duracion: string | null;
|
id: string
|
||||||
total_creditos: number | null; estado: string | null; fecha_creacion: string | null; carrera_id: string | null
|
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 & {
|
type PlanRow = PlanDeEstudios & {
|
||||||
carreras: {
|
carreras: {
|
||||||
id: string; nombre: string;
|
id: string
|
||||||
facultades?: { id: string; nombre: string; color?: string | null; icon?: string | null } | null
|
nombre: string
|
||||||
|
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 () => {
|
||||||
@@ -45,93 +63,191 @@ 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 } = Route.useSearch()
|
const { plan, facultad, carrera } = 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"
|
||||||
|
|
||||||
const showFacultad = auth.claims?.role === "lci" || auth.claims?.role === "vicerrectoria"
|
// 🟣 Lista única de facultades
|
||||||
const showCarrera = showFacultad || auth.claims?.role === "secretario_academico"
|
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 filtered = useMemo(() => {
|
||||||
const term = plan?.trim().toLowerCase()
|
const term = plan?.trim().toLowerCase()
|
||||||
if (!term || !data) return data
|
let results = data ?? []
|
||||||
return data.filter((p) =>
|
|
||||||
|
if (term) {
|
||||||
|
results = results.filter((p) =>
|
||||||
[p.nombre, p.nivel, p.estado, p.carreras?.nombre, p.carreras?.facultades?.nombre]
|
[p.nombre, p.nivel, p.estado, p.carreras?.nombre, p.carreras?.facultades?.nombre]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.some((v) => String(v).toLowerCase().includes(term))
|
.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 => navigate({ search: { plan: e.target.value } })}
|
onChange={(e) =>
|
||||||
|
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 con estilo suave por facultad */}
|
{/* GRID de tarjetas */}
|
||||||
<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 = (fac?.icon && (Icons as any)[fac.icon]) || Building2
|
const IconComp =
|
||||||
|
(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 className="inline-flex items-center justify-center rounded-2xl border px-2.5 py-2"
|
<span
|
||||||
style={{ borderColor: styles.borderColor as string, background: 'rgba(255,255,255,.6)' }}>
|
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" />
|
<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.duracion ? `· ${p.duracion}` : ""}
|
{p.nivel ?? "—"}{" "}
|
||||||
|
{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
|
||||||
@@ -148,18 +264,21 @@ 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={{ 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>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
@@ -167,16 +286,14 @@ function RouteComponent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!filtered?.length && (
|
{!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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<CreatePlanDialog
|
<CreatePlanDialog open={openCreate} onOpenChange={setOpenCreate} />
|
||||||
open={openCreate}
|
|
||||||
onOpenChange={setOpenCreate}
|
|
||||||
/>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user