listado de planes exitoso
This commit is contained in:
@@ -19,8 +19,8 @@
|
||||
"dependencies": {
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
@@ -50,7 +50,8 @@
|
||||
"react-dom": "^19.2.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.0.6",
|
||||
"tw-animate-css": "^1.3.6"
|
||||
"tw-animate-css": "^1.3.6",
|
||||
"use-debounce": "^10.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tanstack/devtools-vite": "^0.3.11",
|
||||
|
||||
@@ -42,19 +42,34 @@ export type PlanListFilters = {
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
// Helper para limpiar texto (lo movemos fuera para reutilizar o lo dejas en un utils)
|
||||
const cleanText = (text: string) => {
|
||||
return text
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase();
|
||||
};
|
||||
|
||||
export async function plans_list(
|
||||
filters: PlanListFilters = {},
|
||||
): Promise<Paged<PlanEstudio>> {
|
||||
const supabase = supabaseBrowser();
|
||||
|
||||
// 1. Construimos la query.
|
||||
// TypeScript validará que "planes_estudio" existe en Database
|
||||
// 1. Construimos la query base
|
||||
// NOTA IMPORTANTE: Para filtrar planes basados en facultad (que está en carreras),
|
||||
// necesitamos hacer un INNER JOIN. En Supabase se usa "!inner".
|
||||
// Si filters.facultadId existe, forzamos el inner join, si no, lo dejamos normal.
|
||||
|
||||
const carreraModifier = filters.facultadId && filters.facultadId !== "todas"
|
||||
? "!inner"
|
||||
: "";
|
||||
|
||||
let q = supabase
|
||||
.from("planes_estudio")
|
||||
.select(
|
||||
`
|
||||
*,
|
||||
carreras (
|
||||
carreras${carreraModifier} (
|
||||
*,
|
||||
facultades (*)
|
||||
),
|
||||
@@ -66,15 +81,30 @@ export async function plans_list(
|
||||
.order("actualizado_en", { ascending: false });
|
||||
|
||||
// 2. Aplicamos filtros dinámicos
|
||||
if (filters.search?.trim()) {
|
||||
q = q.ilike("nombre", `%${filters.search.trim()}%`);
|
||||
}
|
||||
if (filters.carreraId) q = q.eq("carrera_id", filters.carreraId);
|
||||
if (filters.estadoId) q = q.eq("estado_actual_id", filters.estadoId);
|
||||
if (typeof filters.activo === "boolean") q = q.eq("activo", filters.activo);
|
||||
|
||||
// filtro por FK “hacia arriba” (PostgREST soporta filtros en recursos embebidos)
|
||||
if (filters.facultadId) q = q.eq("carreras.facultad_id", filters.facultadId);
|
||||
// SOLUCIÓN SEARCH: Limpiamos el input y buscamos en la columna generada
|
||||
if (filters.search?.trim()) {
|
||||
const cleanTerm = cleanText(filters.search.trim());
|
||||
// Usamos la columna nueva creada en el Paso 1
|
||||
q = q.ilike("nombre_search", `%${cleanTerm}%`);
|
||||
}
|
||||
|
||||
if (filters.carreraId && filters.carreraId !== "todas") {
|
||||
q = q.eq("carrera_id", filters.carreraId);
|
||||
}
|
||||
|
||||
if (filters.estadoId && filters.estadoId !== "todos") {
|
||||
q = q.eq("estado_actual_id", filters.estadoId);
|
||||
}
|
||||
|
||||
if (typeof filters.activo === "boolean") {
|
||||
q = q.eq("activo", filters.activo);
|
||||
}
|
||||
|
||||
// Filtro por facultad (gracias al !inner arriba, esto filtrará los planes)
|
||||
if (filters.facultadId && filters.facultadId !== "todas") {
|
||||
q = q.eq("carreras.facultad_id", filters.facultadId);
|
||||
}
|
||||
|
||||
// 3. Paginación
|
||||
const { from, to } = buildRange(filters.limit, filters.offset);
|
||||
@@ -305,3 +335,19 @@ export async function plans_get_document(
|
||||
planId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCatalogos() {
|
||||
const supabase = supabaseBrowser();
|
||||
|
||||
const [facRes, carRes, estRes] = await Promise.all([
|
||||
supabase.from("facultades").select("*").order("nombre"),
|
||||
supabase.from("carreras").select("*").order("nombre"),
|
||||
supabase.from("estados_plan").select("*").order("orden"),
|
||||
]);
|
||||
|
||||
return {
|
||||
facultades: facRes.data ?? [],
|
||||
carreras: carRes.data ?? [],
|
||||
estados: estRes.data ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
|
||||
import {
|
||||
ai_generate_plan,
|
||||
getCatalogos,
|
||||
plan_asignaturas_list,
|
||||
plan_lineas_list,
|
||||
plans_clone_from_existing,
|
||||
@@ -92,6 +93,14 @@ export function usePlanDocumento(planId: UUID | null | undefined) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useCatalogosPlanes() {
|
||||
return useQuery({
|
||||
queryKey: ["catalogos_planes"],
|
||||
queryFn: getCatalogos,
|
||||
staleTime: 1000 * 60 * 60, // 1 hora de caché (estos datos casi no cambian)
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------ Mutations ------------------ */
|
||||
|
||||
export function useCreatePlanManual() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Enums, Tables } from "../../types/supabase";
|
||||
import type { Database, Enums, Tables } from "../../types/supabase";
|
||||
|
||||
export type UUID = string;
|
||||
|
||||
@@ -51,6 +51,17 @@ export type PlanDatosSep = {
|
||||
propuesta_de_evaluacion_periodica_del_plan_de_estudios?: string | null;
|
||||
};
|
||||
|
||||
export type PlanEstudioWithRel =
|
||||
& Database["public"]["Tables"]["planes_estudio"]["Row"]
|
||||
& {
|
||||
carreras:
|
||||
| Database["public"]["Tables"]["carreras"]["Row"] & {
|
||||
facultades: Database["public"]["Tables"]["facultades"]["Row"] | null;
|
||||
}
|
||||
| null;
|
||||
estados_plan: Database["public"]["Tables"]["estados_plan"]["Row"] | null;
|
||||
};
|
||||
|
||||
export type Paged<T> = { data: Array<T>; count: number | null };
|
||||
|
||||
export type FacultadRow = Tables<"facultades">;
|
||||
|
||||
@@ -8,30 +8,30 @@ export function useNuevoPlanWizard() {
|
||||
const [wizard, setWizard] = useState<NewPlanWizardState>({
|
||||
step: 1,
|
||||
modoCreacion: null,
|
||||
// datosBasicos: {
|
||||
// nombrePlan: "",
|
||||
// carreraId: "",
|
||||
// facultadId: "",
|
||||
// nivel: "",
|
||||
// tipoCiclo: "",
|
||||
// numCiclos: undefined,
|
||||
// plantillaPlanId: "",
|
||||
// plantillaPlanVersion: "",
|
||||
// plantillaMapaId: "",
|
||||
// plantillaMapaVersion: "",
|
||||
// },
|
||||
datosBasicos: {
|
||||
nombrePlan: "Medicina",
|
||||
carreraId: "medico",
|
||||
facultadId: "med",
|
||||
nivel: "Licenciatura",
|
||||
tipoCiclo: "SEMESTRE",
|
||||
numCiclos: 8,
|
||||
plantillaPlanId: "sep-2025",
|
||||
plantillaPlanVersion: "v2025.2 (Vigente)",
|
||||
plantillaMapaId: "sep-2017-xlsx",
|
||||
plantillaMapaVersion: "v2017.0",
|
||||
nombrePlan: "",
|
||||
carreraId: "",
|
||||
facultadId: "",
|
||||
nivel: "",
|
||||
tipoCiclo: "",
|
||||
numCiclos: undefined,
|
||||
plantillaPlanId: "",
|
||||
plantillaPlanVersion: "",
|
||||
plantillaMapaId: "",
|
||||
plantillaMapaVersion: "",
|
||||
},
|
||||
// datosBasicos: {
|
||||
// nombrePlan: "Medicina",
|
||||
// carreraId: "medico",
|
||||
// facultadId: "med",
|
||||
// nivel: "Licenciatura",
|
||||
// tipoCiclo: "SEMESTRE",
|
||||
// numCiclos: 8,
|
||||
// plantillaPlanId: "sep-2025",
|
||||
// plantillaPlanVersion: "v2025.2 (Vigente)",
|
||||
// plantillaMapaId: "sep-2017-xlsx",
|
||||
// plantillaMapaVersion: "v2017.0",
|
||||
// },
|
||||
clonInterno: { planOrigenId: null },
|
||||
clonTradicional: {
|
||||
archivoWordPlanId: null,
|
||||
|
||||
10
src/features/planes/utils/icon-utils.ts
Normal file
10
src/features/planes/utils/icon-utils.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// src/features/planes/utils/icon-utils.ts
|
||||
import * as Icons from "lucide-react";
|
||||
import { BookOpen } from "lucide-react";
|
||||
|
||||
export const getIconByName = (iconName: string | null) => {
|
||||
if (!iconName) return BookOpen;
|
||||
// "as any" es necesario aquí porque el string es dinámico
|
||||
const Icon = (Icons as any)[iconName];
|
||||
return Icon || BookOpen;
|
||||
};
|
||||
@@ -1,12 +1,15 @@
|
||||
import { createFileRoute, Outlet, useNavigate } from '@tanstack/react-router'
|
||||
import * as Icons from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import type { Option } from '@/components/planes/Filtro'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useDebounce } from 'use-debounce'
|
||||
|
||||
// Componentes
|
||||
import BarraBusqueda from '@/components/planes/BarraBusqueda'
|
||||
import Filtro from '@/components/planes/Filtro'
|
||||
import PlanEstudiosCard from '@/components/planes/PlanEstudiosCard'
|
||||
// Hooks y Utils (ajusta las rutas de importación)
|
||||
import { usePlanes, useCatalogosPlanes } from '@/data/hooks/usePlans'
|
||||
import { getIconByName } from '@/features/planes/utils/icon-utils'
|
||||
|
||||
export const Route = createFileRoute('/planes/_lista')({
|
||||
component: RouteComponent,
|
||||
@@ -14,215 +17,104 @@ export const Route = createFileRoute('/planes/_lista')({
|
||||
|
||||
function RouteComponent() {
|
||||
const navigate = useNavigate()
|
||||
type Facultad = { id: string; nombre: string; color: string }
|
||||
type Carrera = { id: string; nombre: string; facultadId: string }
|
||||
type Plan = {
|
||||
id: string
|
||||
icon: string
|
||||
nombrePrograma: string
|
||||
nivel: string
|
||||
ciclos: string
|
||||
facultadId: string
|
||||
carreraId: string
|
||||
estado:
|
||||
| 'Aprobado'
|
||||
| 'Pendiente'
|
||||
| 'En proceso'
|
||||
| 'Revisión expertos'
|
||||
| 'Actualización'
|
||||
claseColorEstado: string
|
||||
}
|
||||
|
||||
// Simulación: datos provenientes de Supabase (hardcode)
|
||||
const facultades: Array<Facultad> = [
|
||||
{ id: 'ing', nombre: 'Facultad de Ingeniería', color: '#2563eb' },
|
||||
{ id: 'med', nombre: 'Facultad de Medicina', color: '#dc2626' },
|
||||
{ id: 'neg', nombre: 'Facultad de Negocios', color: '#059669' },
|
||||
{
|
||||
id: 'arq',
|
||||
nombre: 'Facultad Mexicana de Arquitectura, Diseño y Comunicación',
|
||||
color: '#ea580c',
|
||||
},
|
||||
{
|
||||
id: 'sal',
|
||||
nombre: 'Escuela de Altos Estudios en Salud',
|
||||
color: '#0891b2',
|
||||
},
|
||||
{ id: 'der', nombre: 'Facultad de Derecho', color: '#7c3aed' },
|
||||
{ id: 'qui', nombre: 'Facultad de Ciencias Químicas', color: '#65a30d' },
|
||||
]
|
||||
|
||||
const carreras: Array<Carrera> = [
|
||||
{
|
||||
id: 'sis',
|
||||
nombre: 'Ingeniería en Sistemas Computacionales',
|
||||
facultadId: 'ing',
|
||||
},
|
||||
{ id: 'medico', nombre: 'Médico Cirujano', facultadId: 'med' },
|
||||
{ id: 'act', nombre: 'Licenciatura en Actuaría', facultadId: 'neg' },
|
||||
{ id: 'arq', nombre: 'Licenciatura en Arquitectura', facultadId: 'arq' },
|
||||
{ id: 'fisio', nombre: 'Licenciatura en Fisioterapia', facultadId: 'sal' },
|
||||
{ id: 'der', nombre: 'Licenciatura en Derecho', facultadId: 'der' },
|
||||
{ id: 'qfb', nombre: 'Químico Farmacéutico Biólogo', facultadId: 'qui' },
|
||||
]
|
||||
|
||||
const estados: Array<Option> = [
|
||||
{ value: 'todos', label: 'Todos los estados' },
|
||||
{ value: 'Aprobado', label: 'Aprobado' },
|
||||
{ value: 'Pendiente', label: 'Pendiente' },
|
||||
{ value: 'En proceso', label: 'En proceso' },
|
||||
{ value: 'Revisión expertos', label: 'Revisión expertos' },
|
||||
{ value: 'Actualización', label: 'Actualización' },
|
||||
]
|
||||
|
||||
const planes: Array<Plan> = [
|
||||
{
|
||||
id: 'p1',
|
||||
icon: 'Laptop',
|
||||
nombrePrograma: 'Ingeniería en Sistemas Computacionales',
|
||||
nivel: 'Licenciatura',
|
||||
ciclos: '8 semestres',
|
||||
facultadId: 'ing',
|
||||
carreraId: 'sis',
|
||||
estado: 'Revisión expertos',
|
||||
claseColorEstado: 'bg-amber-600',
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
icon: 'Stethoscope',
|
||||
nombrePrograma: 'Médico Cirujano',
|
||||
nivel: 'Licenciatura',
|
||||
ciclos: '10 semestres',
|
||||
facultadId: 'med',
|
||||
carreraId: 'medico',
|
||||
estado: 'Aprobado',
|
||||
claseColorEstado: 'bg-emerald-600',
|
||||
},
|
||||
{
|
||||
id: 'p3',
|
||||
icon: 'Calculator',
|
||||
nombrePrograma: 'Licenciatura en Actuaría',
|
||||
nivel: 'Licenciatura',
|
||||
ciclos: '9 semestres',
|
||||
facultadId: 'neg',
|
||||
carreraId: 'act',
|
||||
estado: 'Aprobado',
|
||||
claseColorEstado: 'bg-emerald-600',
|
||||
},
|
||||
{
|
||||
id: 'p4',
|
||||
icon: 'PencilRuler',
|
||||
nombrePrograma: 'Licenciatura en Arquitectura',
|
||||
nivel: 'Licenciatura',
|
||||
ciclos: '10 semestres',
|
||||
facultadId: 'arq',
|
||||
carreraId: 'arq',
|
||||
estado: 'En proceso',
|
||||
claseColorEstado: 'bg-orange-500',
|
||||
},
|
||||
{
|
||||
id: 'p5',
|
||||
icon: 'Activity',
|
||||
nombrePrograma: 'Licenciatura en Fisioterapia',
|
||||
nivel: 'Licenciatura',
|
||||
ciclos: '8 semestres',
|
||||
facultadId: 'sal',
|
||||
carreraId: 'fisio',
|
||||
estado: 'Revisión expertos',
|
||||
claseColorEstado: 'bg-amber-600',
|
||||
},
|
||||
{
|
||||
id: 'p6',
|
||||
icon: 'Scale',
|
||||
nombrePrograma: 'Licenciatura en Derecho',
|
||||
nivel: 'Licenciatura',
|
||||
ciclos: '10 semestres',
|
||||
facultadId: 'der',
|
||||
carreraId: 'der',
|
||||
estado: 'Pendiente',
|
||||
claseColorEstado: 'bg-yellow-500',
|
||||
},
|
||||
{
|
||||
id: 'p7',
|
||||
icon: 'FlaskConical',
|
||||
nombrePrograma: 'Químico Farmacéutico Biólogo',
|
||||
nivel: 'Licenciatura',
|
||||
ciclos: '9 semestres',
|
||||
facultadId: 'qui',
|
||||
carreraId: 'qfb',
|
||||
estado: 'Actualización',
|
||||
claseColorEstado: 'bg-lime-600',
|
||||
},
|
||||
]
|
||||
|
||||
// Estado de filtros
|
||||
// 1. Estados de Filtros
|
||||
const [search, setSearch] = useState('')
|
||||
// Debounce para evitar llamadas excesivas a la API
|
||||
const [debouncedSearch] = useDebounce(search, 500)
|
||||
|
||||
const [facultadSel, setFacultadSel] = useState<string>('todas')
|
||||
const [carreraSel, setCarreraSel] = useState<string>('todas')
|
||||
const [estadoSel, setEstadoSel] = useState<string>('todos')
|
||||
|
||||
// Opciones para filtros
|
||||
const facultadesOptions: Array<Option> = useMemo(
|
||||
// Paginación (opcional si la implementas en UI)
|
||||
const [page, setPage] = useState(0)
|
||||
const pageSize = 12
|
||||
|
||||
// 2. Carga de datos remotos
|
||||
const { data: catalogos } = useCatalogosPlanes()
|
||||
|
||||
// Limpiamos el texto de búsqueda (quitar acentos) para enviarlo limpio a la API
|
||||
// O lo puedes limpiar en el servicio. Aquí lo enviamos tal cual viene del debounce.
|
||||
// Nota: Si usaste la solución "unaccent" en BD, envía el texto tal cual, postgres lo maneja.
|
||||
const cleanSearchTerm = debouncedSearch.trim()
|
||||
|
||||
const {
|
||||
data: planesData,
|
||||
isLoading,
|
||||
isError,
|
||||
} = usePlanes({
|
||||
search: cleanSearchTerm,
|
||||
facultadId: facultadSel,
|
||||
carreraId: carreraSel,
|
||||
estadoId: estadoSel,
|
||||
limit: pageSize,
|
||||
offset: page * pageSize,
|
||||
})
|
||||
|
||||
// 3. Preparación de Opciones para Selects (Derived State)
|
||||
const facultadesOptions = useMemo(
|
||||
() => [
|
||||
{ value: 'todas', label: 'Todas las facultades' },
|
||||
...facultades.map((f) => ({ value: f.id, label: f.nombre })),
|
||||
...(catalogos?.facultades.map((f) => ({
|
||||
value: f.id,
|
||||
label: f.nombre,
|
||||
})) ?? []),
|
||||
],
|
||||
[facultades],
|
||||
[catalogos?.facultades],
|
||||
)
|
||||
|
||||
const carrerasOptions: Array<Option> = useMemo(() => {
|
||||
const list =
|
||||
const carrerasOptions = useMemo(() => {
|
||||
// Filtramos las carreras del catálogo base según la facultad seleccionada
|
||||
const rawCarreras = catalogos?.carreras ?? []
|
||||
const filtered =
|
||||
facultadSel === 'todas'
|
||||
? carreras
|
||||
: carreras.filter((c) => c.facultadId === facultadSel)
|
||||
? rawCarreras
|
||||
: rawCarreras.filter((c) => c.facultad_id === facultadSel)
|
||||
|
||||
return [
|
||||
{ value: 'todas', label: 'Todas las carreras' },
|
||||
...list.map((c) => ({ value: c.id, label: c.nombre })),
|
||||
...filtered.map((c) => ({ value: c.id, label: c.nombre })),
|
||||
]
|
||||
}, [carreras, facultadSel])
|
||||
}, [catalogos?.carreras, facultadSel])
|
||||
|
||||
// Filtrado de planes
|
||||
const filteredPlans = useMemo(() => {
|
||||
// Función helper para limpiar texto (quita acentos y hace minúsculas)
|
||||
const cleanText = (text: string) => {
|
||||
return text
|
||||
.normalize('NFD') // Descompone "á" en "a" + "´"
|
||||
.replace(/[\u0300-\u036f]/g, '') // Elimina los símbolos diacríticos
|
||||
.toLowerCase() // Convierte a minúsculas
|
||||
}
|
||||
// Limpiamos el término de búsqueda una sola vez antes de filtrar
|
||||
const term = cleanText(search.trim())
|
||||
return planes.filter((p) => {
|
||||
const matchName = term
|
||||
? // Limpiamos también el nombre del programa antes de comparar
|
||||
cleanText(p.nombrePrograma).includes(term)
|
||||
: true
|
||||
const matchFac =
|
||||
facultadSel === 'todas' ? true : p.facultadId === facultadSel
|
||||
const matchCar =
|
||||
carreraSel === 'todas' ? true : p.carreraId === carreraSel
|
||||
const matchEst = estadoSel === 'todos' ? true : p.estado === estadoSel
|
||||
return matchName && matchFac && matchCar && matchEst
|
||||
})
|
||||
}, [planes, search, facultadSel, carreraSel, estadoSel])
|
||||
const estadosOptions = useMemo(
|
||||
() => [
|
||||
{ value: 'todos', label: 'Todos los estados' },
|
||||
...(catalogos?.estados.map((e) => ({ value: e.id, label: e.etiqueta })) ??
|
||||
[]),
|
||||
],
|
||||
[catalogos?.estados],
|
||||
)
|
||||
|
||||
// 4. Handlers
|
||||
const resetFilters = () => {
|
||||
setSearch('')
|
||||
setFacultadSel('todas')
|
||||
setCarreraSel('todas')
|
||||
setEstadoSel('todos')
|
||||
setPage(0)
|
||||
}
|
||||
|
||||
const handleSearchChange = (val: string) => {
|
||||
setSearch(val)
|
||||
setPage(0) // Resetear página al buscar
|
||||
}
|
||||
|
||||
// Renderizado condicional básico
|
||||
if (isError)
|
||||
return <div className="p-8 text-red-500">Error cargando planes.</div>
|
||||
|
||||
return (
|
||||
<main className="bg-background min-h-screen w-full">
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4 px-4 py-6 md:px-6 lg:px-8">
|
||||
<div className="flex flex-col gap-4 lg:col-span-3">
|
||||
{/* Header y Botón Nuevo */}
|
||||
<div className="flex flex-col items-stretch justify-between gap-4 sm:flex-row sm:items-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-primary/10 text-primary flex h-10 w-10 items-center justify-center rounded-xl">
|
||||
<Icons.BookOpenText className="h-5 w-5" strokeWidth={2} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="font-display text-foreground text-2xl font-bold">
|
||||
Planes de Estudio
|
||||
@@ -232,31 +124,23 @@ function RouteComponent() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
'ring-offset-background focus-visible:ring-ring bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-11 items-center justify-center gap-2 rounded-md px-8 text-sm font-medium whitespace-nowrap shadow-md transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0'
|
||||
}
|
||||
aria-label="Nuevo plan de estudios"
|
||||
title="Nuevo plan de estudios"
|
||||
onClick={() => {
|
||||
navigate({ to: '/planes/nuevo' })
|
||||
}}
|
||||
onClick={() => navigate({ to: '/planes/nuevo' })}
|
||||
className="ring-offset-background bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-11 items-center justify-center gap-2 rounded-md px-8 text-sm font-medium shadow-md transition-colors"
|
||||
>
|
||||
<Icons.Plus className="" />
|
||||
Nuevo plan de estudios
|
||||
<Icons.Plus /> Nuevo plan de estudios
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Barra de Filtros */}
|
||||
<div className="flex flex-col items-stretch gap-2 lg:flex-row lg:items-center">
|
||||
<div className="min-w-0 flex-1">
|
||||
<BarraBusqueda
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder="Buscar por programa…"
|
||||
onChange={handleSearchChange}
|
||||
placeholder="Buscar por programa..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-stretch justify-between gap-2 lg:flex-row lg:items-center">
|
||||
<div className="w-full lg:w-44">
|
||||
<Filtro
|
||||
@@ -264,67 +148,93 @@ function RouteComponent() {
|
||||
value={facultadSel}
|
||||
onChange={(v) => {
|
||||
setFacultadSel(v)
|
||||
// Reset carrera si ya no pertenece
|
||||
setCarreraSel('todas')
|
||||
setPage(0)
|
||||
}}
|
||||
placeholder="Facultad"
|
||||
ariaLabel="Filtro por facultad"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full lg:w-44">
|
||||
<Filtro
|
||||
options={carrerasOptions}
|
||||
value={carreraSel}
|
||||
onChange={setCarreraSel}
|
||||
onChange={(v) => {
|
||||
setCarreraSel(v)
|
||||
setPage(0)
|
||||
}}
|
||||
placeholder="Carrera"
|
||||
ariaLabel="Filtro por carrera"
|
||||
disabled={facultadSel === 'todas'}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full lg:w-44">
|
||||
<Filtro
|
||||
options={estados}
|
||||
options={estadosOptions}
|
||||
value={estadoSel}
|
||||
onChange={setEstadoSel}
|
||||
onChange={(v) => {
|
||||
setEstadoSel(v)
|
||||
setPage(0)
|
||||
}}
|
||||
placeholder="Estado"
|
||||
ariaLabel="Filtro por estado"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetFilters}
|
||||
className={
|
||||
'ring-offset-background focus-visible:ring-ring bg-secondary text-secondary-foreground hover:bg-secondary/90 inline-flex h-9 items-center justify-center gap-2 rounded-md px-4 text-sm font-medium whitespace-nowrap shadow-md transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50'
|
||||
}
|
||||
title="Reiniciar filtros"
|
||||
aria-label="Reiniciar filtros"
|
||||
className="ring-offset-background bg-secondary text-secondary-foreground hover:bg-secondary/90 inline-flex h-9 items-center justify-center gap-2 rounded-md px-4 text-sm font-medium shadow-md transition-colors"
|
||||
>
|
||||
<Icons.X className="h-4 w-4" />
|
||||
Limpiar
|
||||
<Icons.X className="h-4 w-4" /> Limpiar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid de Resultados */}
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{filteredPlans.map((p) => {
|
||||
const fac = facultades.find((f) => f.id === p.facultadId)!
|
||||
const IconComp = (Icons as any)[p.icon] ?? Icons.BookOpenText
|
||||
{/* Skeleton básico o Spinner */}
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-64 w-full animate-pulse rounded-xl bg-gray-100/50"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{planesData?.data.map((plan) => {
|
||||
// Mapeo de datos: DB -> Props Componente
|
||||
const facultad = plan.carreras?.facultades
|
||||
const estado = plan.estados_plan
|
||||
|
||||
// NOTA: El color del estado no viene en BD por defecto,
|
||||
// puedes crear un mapa de colores o agregar columna 'color' a tabla 'estados_plan'
|
||||
// Aquí uso un fallback simple.
|
||||
const estadoColor = estado?.es_final
|
||||
? 'bg-emerald-600'
|
||||
: 'bg-amber-600'
|
||||
|
||||
return (
|
||||
<PlanEstudiosCard
|
||||
key={p.id}
|
||||
Icono={IconComp}
|
||||
nombrePrograma={p.nombrePrograma}
|
||||
nivel={p.nivel}
|
||||
ciclos={p.ciclos}
|
||||
facultad={fac.nombre}
|
||||
estado={p.estado}
|
||||
claseColorEstado={p.claseColorEstado}
|
||||
colorFacultad={fac.color}
|
||||
onClick={() => console.log('Ver', p.nombrePrograma)}
|
||||
key={plan.id}
|
||||
Icono={getIconByName(facultad?.icono ?? null)}
|
||||
nombrePrograma={plan.nombre}
|
||||
nivel={plan.nivel}
|
||||
ciclos={`${plan.numero_ciclos} ${plan.tipo_ciclo.toLowerCase()}s`}
|
||||
facultad={facultad?.nombre ?? 'Sin Facultad'}
|
||||
estado={estado?.etiqueta ?? 'Desconocido'}
|
||||
claseColorEstado={estadoColor}
|
||||
colorFacultad={facultad?.color ?? '#000000'}
|
||||
onClick={() => console.log('Ver plan', plan.id)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{planesData?.data.length === 0 && (
|
||||
<div className="text-muted-foreground col-span-full py-10 text-center">
|
||||
No se encontraron planes con estos filtros.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Outlet />
|
||||
</div>
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user