feat: Implement faculty management routes and UI components
- Added a new route for managing faculties with a grid display of faculties. - Created a detailed view for each faculty including metrics and recent activities. - Introduced a new loader for fetching faculty data and associated plans and subjects. - Enhanced the existing plans route to include a modal for plan details. - Updated the login and index pages with improved UI and styling. - Integrated a progress ring component to visualize the quality of plans. - Applied a new font style across the application for consistency.
This commit is contained in:
234
src/routes/_authenticated/facultad/$facultadId.tsx
Normal file
234
src/routes/_authenticated/facultad/$facultadId.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
// Reemplaza la sección del sparkline por estas tarjetas y ajusta el loader.
|
||||
// + Añade un ProgressRing (SVG) para el % de calidad.
|
||||
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import * as Icons from 'lucide-react'
|
||||
import { supabase } from '@/auth/supabase'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
type Facultad = { id: string; nombre: string; icon: string; color?: string | null }
|
||||
type Plan = {
|
||||
id: string; nombre: string; fecha_creacion: string | null;
|
||||
objetivo_general: string | null; perfil_ingreso: string | null; perfil_egreso: string | null;
|
||||
sistema_evaluacion: string | null; total_creditos: number | null;
|
||||
}
|
||||
type Asignatura = {
|
||||
id: string; nombre: string; fecha_creacion: string | null;
|
||||
contenidos: any | null; criterios_evaluacion: string | null; bibliografia: any | null;
|
||||
}
|
||||
type RecentItem = { id: string; tipo: 'plan' | 'asignatura'; nombre: string | null; fecha: string | null }
|
||||
|
||||
type LoaderData = {
|
||||
facultad: Facultad
|
||||
counts: { carreras: number; planes: number; asignaturas: number; criterios: number }
|
||||
recientes: RecentItem[]
|
||||
calidadPlanesPct: number
|
||||
saludAsignaturas: { sinBibliografia: number; sinCriterios: number; sinContenidos: number }
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/facultad/$facultadId')({
|
||||
component: RouteComponent,
|
||||
// puedes mantener tu DashboardSkeleton actual como pendingComponent si ya lo tienes
|
||||
loader: async ({ params }): Promise<LoaderData> => {
|
||||
const facultadId = params.facultadId
|
||||
|
||||
// Facultad
|
||||
const { data: facultad, error: facErr } = await supabase
|
||||
.from('facultades').select('id, nombre, icon, color').eq('id', facultadId).single()
|
||||
if (facErr || !facultad) throw facErr ?? new Error('Facultad no encontrada')
|
||||
|
||||
// Carreras
|
||||
const { data: carreras, error: carErr } = await supabase
|
||||
.from('carreras').select('id, nombre').eq('facultad_id', facultadId)
|
||||
if (carErr) throw carErr
|
||||
const carreraIds = (carreras ?? []).map(c => c.id)
|
||||
|
||||
// Planes
|
||||
let planes: Plan[] = []
|
||||
if (carreraIds.length) {
|
||||
const { data, error } = await supabase
|
||||
.from('plan_estudios')
|
||||
.select('id, nombre, fecha_creacion, objetivo_general, perfil_ingreso, perfil_egreso, sistema_evaluacion, total_creditos')
|
||||
.in('carrera_id', carreraIds)
|
||||
if (error) throw error
|
||||
planes = (data ?? []) as Plan[]
|
||||
}
|
||||
|
||||
// Asignaturas
|
||||
let asignaturas: Asignatura[] = []
|
||||
const planIds = planes.map(p => p.id)
|
||||
if (planIds.length) {
|
||||
const { data, error } = await supabase
|
||||
.from('asignaturas')
|
||||
.select('id, nombre, fecha_creacion, contenidos, criterios_evaluacion, bibliografia')
|
||||
.in('plan_id', planIds)
|
||||
if (error) throw error
|
||||
asignaturas = (data ?? []) as Asignatura[]
|
||||
}
|
||||
|
||||
// Criterios por carrera_id (tu cambio)
|
||||
let criterios = 0
|
||||
if (carreraIds.length) {
|
||||
const { count, error } = await supabase
|
||||
.from('criterios_carrera')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.in('carrera_id', carreraIds)
|
||||
if (error) throw error
|
||||
criterios = count ?? 0
|
||||
}
|
||||
|
||||
// ====== KPIs de calidad ======
|
||||
// Plan “completo” si tiene estos campos no vacíos:
|
||||
const planKeys: (keyof Plan)[] = [
|
||||
'objetivo_general', 'perfil_ingreso', 'perfil_egreso', 'sistema_evaluacion', 'total_creditos',
|
||||
]
|
||||
const completos = planes.filter(p =>
|
||||
planKeys.every(k => p[k] !== null && p[k] !== '' && p[k] !== 0)
|
||||
).length
|
||||
const calidadPlanesPct = planes.length ? Math.round((completos / planes.length) * 100) : 0
|
||||
|
||||
// Salud de asignaturas: faltantes
|
||||
const sinBibliografia = asignaturas.filter(a => !a.bibliografia || (Array.isArray(a.bibliografia) && a.bibliografia.length === 0)).length
|
||||
const sinCriterios = asignaturas.filter(a => !a.criterios_evaluacion || a.criterios_evaluacion.trim() === '').length
|
||||
const sinContenidos = asignaturas.filter(a => !a.contenidos || (Array.isArray(a.contenidos) && a.contenidos.length === 0)).length
|
||||
|
||||
// Actividad reciente (planes + asignaturas)
|
||||
const recientes: RecentItem[] = [
|
||||
...planes.map(p => ({ id: p.id, tipo: 'plan' as const, nombre: p.nombre, fecha: p.fecha_creacion })),
|
||||
...asignaturas.map(a => ({ id: a.id, tipo: 'asignatura' as const, nombre: a.nombre, fecha: a.fecha_creacion })),
|
||||
]
|
||||
.sort((a, b) => new Date(b.fecha ?? 0).getTime() - new Date(a.fecha ?? 0).getTime())
|
||||
.slice(0, 6)
|
||||
|
||||
return {
|
||||
facultad,
|
||||
counts: {
|
||||
carreras: carreras?.length ?? 0,
|
||||
planes: planes.length,
|
||||
asignaturas: asignaturas.length,
|
||||
criterios,
|
||||
},
|
||||
recientes,
|
||||
calidadPlanesPct,
|
||||
saludAsignaturas: { sinBibliografia, sinCriterios, sinContenidos },
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function gradientFrom(color?: string | null) {
|
||||
const base = (color && /^#([0-9a-f]{6}|[0-9a-f]{3})$/i.test(color)) ? color : '#2563eb'
|
||||
return `linear-gradient(135deg, ${base} 0%, ${base}CC 45%, ${base}99 75%, ${base}66 100%)`
|
||||
}
|
||||
|
||||
// ====== UI helpers ======
|
||||
function ProgressRing({ pct }: { pct: number }) {
|
||||
const r = 42, c = 2 * Math.PI * r
|
||||
const offset = c * (1 - Math.min(Math.max(pct, 0), 100) / 100)
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<svg width="112" height="112" viewBox="0 0 112 112" className="drop-shadow">
|
||||
<circle cx="56" cy="56" r={r} fill="none" stroke="rgba(0,0,0,.08)" strokeWidth="12" />
|
||||
<circle cx="56" cy="56" r={r} fill="none" stroke="currentColor" strokeWidth="12"
|
||||
strokeDasharray={c} strokeDashoffset={offset} strokeLinecap="round"
|
||||
transform="rotate(-90 56 56)" />
|
||||
</svg>
|
||||
<div>
|
||||
<div className="text-3xl font-bold tabular-nums">{pct}%</div>
|
||||
<div className="text-sm text-neutral-600">Planes con información clave completa</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HealthItem({ label, value, to }: { label: string; value: number; to: string }) {
|
||||
const warn = value > 0
|
||||
return (
|
||||
<Link to={to} className={`flex items-center justify-between rounded-xl px-4 py-3 border ${warn ? 'border-amber-300 bg-amber-50 text-amber-800' : 'border-neutral-200 bg-white'}`}>
|
||||
<span className="text-sm">{label}</span>
|
||||
<span className="text-lg font-semibold tabular-nums">{value}</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function RouteComponent() {
|
||||
const { facultad, counts, recientes, calidadPlanesPct, saludAsignaturas } = Route.useLoaderData() as LoaderData
|
||||
const HeaderIcon = (Icons as any)[facultad.icon] || Icons.Building
|
||||
const headerBg = useMemo(() => ({ background: gradientFrom(facultad.color) }), [facultad.color])
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-8">
|
||||
{/* Header */}
|
||||
<div className="relative rounded-3xl overflow-hidden text-white shadow-xl" style={headerBg}>
|
||||
<div className="absolute inset-0 opacity-20" style={{ background: 'radial-gradient(800px 300px at 20% -10%, #fff, transparent 60%)' }} />
|
||||
<div className="relative p-8 flex items-center gap-5">
|
||||
<HeaderIcon className="w-16 h-16 md:w-20 md:h-20 drop-shadow" />
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold">{facultad.nombre}</h1>
|
||||
<p className="opacity-90">Calidad y estado académico de la facultad</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Métricas principales */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Metric to={`/_authenticated/carreras?facultadId=${facultad.id}`} label="Carreras" value={counts.carreras} Icon={Icons.GraduationCap} />
|
||||
<Metric to={`/_authenticated/planes?facultadId=${facultad.id}`} label="Planes de estudio" value={counts.planes} Icon={Icons.ScrollText} />
|
||||
<Metric to={`/_authenticated/asignaturas?facultadId=${facultad.id}`} label="Asignaturas" value={counts.asignaturas} Icon={Icons.BookOpen} />
|
||||
<Metric to={`/_authenticated/criterios?facultadId=${facultad.id}`} label="Criterios de carrera" value={counts.criterios} Icon={Icons.CheckCircle2} />
|
||||
</div>
|
||||
|
||||
{/* Calidad + Salud */}
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="rounded-2xl bg-white shadow-lg ring-1 ring-black/5 p-5 lg:col-span-2">
|
||||
<div className="font-semibold mb-3">Calidad de planes</div>
|
||||
<ProgressRing pct={calidadPlanesPct} />
|
||||
<div className="mt-3 text-sm text-neutral-600">
|
||||
Considera <span className="font-medium">objetivo general, perfiles, sistema de evaluación y créditos</span>.
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-white shadow-lg ring-1 ring-black/5 p-5">
|
||||
<div className="font-semibold mb-3">Salud de asignaturas</div>
|
||||
<div className="space-y-2">
|
||||
<HealthItem label="Sin bibliografía" value={saludAsignaturas.sinBibliografia} to={`/_authenticated/asignaturas?facultadId=${facultad.id}&f=sinBibliografia`} />
|
||||
<HealthItem label="Sin criterios de evaluación" value={saludAsignaturas.sinCriterios} to={`/_authenticated/asignaturas?facultadId=${facultad.id}&f=sinCriterios`} />
|
||||
<HealthItem label="Sin contenidos" value={saludAsignaturas.sinContenidos} to={`/_authenticated/asignaturas?facultadId=${facultad.id}&f=sinContenidos`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actividad reciente */}
|
||||
<div className="rounded-2xl bg-white shadow-lg ring-1 ring-black/5 p-5">
|
||||
<div className="font-semibold mb-3">Actividad reciente</div>
|
||||
<ul className="space-y-2">
|
||||
{recientes.length === 0 && <li className="text-sm text-neutral-500">Sin actividad</li>}
|
||||
{recientes.map((r) => (
|
||||
<li key={`${r.tipo}-${r.id}`} className="flex items-center justify-between gap-3">
|
||||
<Link to={`/ _authenticated/${r.tipo === 'plan' ? 'planes' : 'asignaturas'}/${r.id}`} className="truncate hover:underline">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{r.tipo === 'plan' ? <Icons.ScrollText className="w-4 h-4" /> : <Icons.BookOpen className="w-4 h-4" />}
|
||||
{r.nombre ?? '—'}
|
||||
</span>
|
||||
</Link>
|
||||
<span className="text-xs text-neutral-500">{r.fecha ? new Date(r.fecha).toLocaleDateString() : ''}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Tarjeta métrica (igual a tu StatTile)
|
||||
function Metric({ to, label, value, Icon }:{ to: string; label: string; value: number; Icon: any }) {
|
||||
return (
|
||||
<Link to={to} className="group rounded-2xl bg-white shadow-lg ring-1 ring-black/5 p-5 flex items-center justify-between hover:-translate-y-0.5 transition-all">
|
||||
<div>
|
||||
<div className="text-sm text-neutral-500">{label}</div>
|
||||
<div className="text-3xl font-bold tabular-nums">{value}</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-xl bg-neutral-100 group-hover:bg-neutral-200">
|
||||
<Icon className="w-7 h-7" />
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
80
src/routes/_authenticated/facultades.tsx
Normal file
80
src/routes/_authenticated/facultades.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import * as Icons from 'lucide-react'
|
||||
import { supabase } from '@/auth/supabase'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
type Facultad = {
|
||||
id: string
|
||||
nombre: string
|
||||
icon: string
|
||||
color?: string | null
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/facultades')({
|
||||
component: RouteComponent,
|
||||
loader: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('facultades')
|
||||
.select('id, nombre, icon, color')
|
||||
.order('nombre')
|
||||
|
||||
if (error) {
|
||||
console.error(error)
|
||||
return { facultades: [] as Facultad[] }
|
||||
}
|
||||
return { facultades: (data ?? []) as Facultad[] }
|
||||
},
|
||||
})
|
||||
|
||||
function gradientFrom(color?: string | null) {
|
||||
const base = (color && /^#([0-9a-f]{6}|[0-9a-f]{3})$/i.test(color)) ? color : '#2563eb' // azul por defecto
|
||||
// degradado elegante con transparencia
|
||||
return `linear-gradient(135deg, ${base} 0%, ${base}CC 40%, ${base}99 70%, ${base}66 100%)`
|
||||
}
|
||||
|
||||
function RouteComponent() {
|
||||
const { facultades } = Route.useLoaderData() as { facultades: Facultad[] }
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{facultades.map((fac) => {
|
||||
const LucideIcon = (Icons as any)[fac.icon] || Icons.Building
|
||||
const bg = useMemo(() => ({ background: gradientFrom(fac.color) }), [fac.color])
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={fac.id}
|
||||
to="/facultad/$facultadId"
|
||||
params={{ facultadId: fac.id }}
|
||||
aria-label={`Administrar ${fac.nombre}`}
|
||||
className="group relative block rounded-3xl overflow-hidden shadow-xl focus:outline-none focus-visible:ring-4 ring-white/60"
|
||||
style={bg}
|
||||
>
|
||||
{/* capa brillo */}
|
||||
<div className="absolute inset-0 opacity-0 group-hover:opacity-15 transition-opacity" style={{
|
||||
background: 'radial-gradient(1200px 400px at 20% -20%, rgba(255,255,255,.45), transparent 60%)'
|
||||
}} />
|
||||
|
||||
{/* contenido */}
|
||||
<div className="relative h-56 sm:h-64 lg:h-72 p-6 flex flex-col justify-between text-white">
|
||||
<LucideIcon className="w-20 h-20 md:w-24 md:h-24 drop-shadow-md" />
|
||||
<div className="flex items-end justify-between">
|
||||
<h3 className="text-xl md:text-2xl font-bold drop-shadow-sm pr-2">
|
||||
{fac.nombre}
|
||||
</h3>
|
||||
<Icons.ArrowRight className="w-6 h-6 opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* borde dinámico al hover */}
|
||||
<div className="absolute inset-0 ring-0 group-hover:ring-4 group-active:ring-4 ring-white/40 transition-[ring-width]" />
|
||||
{/* animación sutil */}
|
||||
<div className="absolute inset-0 scale-100 group-hover:scale-[1.02] group-active:scale-[0.99] transition-transform duration-300" />
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,32 +1,23 @@
|
||||
import { createFileRoute, useRouter } from "@tanstack/react-router"
|
||||
import { useMemo, useState } from "react"
|
||||
import { createFileRoute, useRouter, Link } from "@tanstack/react-router"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { supabase, useSupabaseAuth } from "@/auth/supabase"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Plus, RefreshCcw } from "lucide-react"
|
||||
import * as Icons from "lucide-react"
|
||||
import { Plus, RefreshCcw, Building2, ScrollText, BookOpen } from "lucide-react"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
|
||||
// --- Tipo correcto según tu esquema ---
|
||||
export type PlanDeEstudios = {
|
||||
id: string
|
||||
nombre: string
|
||||
nivel: string | null
|
||||
objetivo_general: string | null
|
||||
perfil_ingreso: string | null
|
||||
perfil_egreso: string | null
|
||||
duracion: string | null
|
||||
total_creditos: number | null
|
||||
competencias_genericas: string | null
|
||||
competencias_especificas: string | null
|
||||
sistema_evaluacion: string | null
|
||||
indicadores_desempeno: string | null
|
||||
estado: string | null
|
||||
fecha_creacion: string | null // timestamp with time zone → string ISO
|
||||
pertinencia: string | null
|
||||
prompt: string | null
|
||||
carrera_id: string | null // uuid
|
||||
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
|
||||
} | null
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/_authenticated/planes")({
|
||||
@@ -34,26 +25,53 @@ export const Route = createFileRoute("/_authenticated/planes")({
|
||||
loader: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from("plan_estudios")
|
||||
.select("*")
|
||||
.select(`
|
||||
*,
|
||||
carreras (
|
||||
id,
|
||||
nombre,
|
||||
facultades:facultades ( id, nombre, color, icon )
|
||||
)
|
||||
`)
|
||||
.order("fecha_creacion", { ascending: false })
|
||||
.limit(100)
|
||||
|
||||
if (error) throw new Error(error.message)
|
||||
return data as PlanDeEstudios[]
|
||||
}
|
||||
return (data ?? []) as PlanRow[]
|
||||
},
|
||||
})
|
||||
|
||||
/* ---------- helpers de estilo suave ---------- */
|
||||
function hexToRgb(hex?: string | null): [number, number, number] {
|
||||
if (!hex) return [37, 99, 235] // azul por defecto
|
||||
const h = hex.replace('#', '')
|
||||
const v = h.length === 3 ? h.split('').map(c => c + c).join('') : h
|
||||
const n = parseInt(v, 16)
|
||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
|
||||
}
|
||||
function softCardStyles(color?: string | null) {
|
||||
const [r, g, b] = hexToRgb(color)
|
||||
return {
|
||||
// borde + velo muy sutil del color de la facultad
|
||||
borderColor: `rgba(${r},${g},${b},.28)`,
|
||||
background: `linear-gradient(180deg, rgba(${r},${g},${b},.15), rgba(${r},${g},${b},.02))`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
|
||||
function RouteComponent() {
|
||||
const auth = useSupabaseAuth()
|
||||
const [q, setQ] = useState("")
|
||||
const data = Route.useLoaderData()
|
||||
const data = Route.useLoaderData() as PlanRow[]
|
||||
const router = useRouter()
|
||||
const search = Route.useSearch<{ planId?: string }>() // usaremos ?planId=... para el modal
|
||||
|
||||
const showFacultad = auth.claims?.role === "lci" || auth.claims?.role === "vicerrectoria"
|
||||
const showCarrera = auth.claims?.role === "secretario_academico"
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const term = q.trim().toLowerCase()
|
||||
if (!term || !data) return data
|
||||
return data.filter((p) =>
|
||||
[p.nombre, p.nivel, p.estado]
|
||||
[p.nombre, p.nivel, p.estado, p.carreras?.nombre, p.carreras?.facultades?.nombre]
|
||||
.filter(Boolean)
|
||||
.some((v) => String(v).toLowerCase().includes(term))
|
||||
)
|
||||
@@ -66,7 +84,7 @@ function RouteComponent() {
|
||||
<CardTitle className="text-xl">Planes de estudio</CardTitle>
|
||||
<div className="flex w-full items-center gap-2 md:w-auto">
|
||||
<div className="relative w-full md:w-80">
|
||||
<Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Buscar por nombre, nivel o estado…" />
|
||||
<Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Buscar por nombre, nivel, estado…" />
|
||||
</div>
|
||||
<Button variant="outline" size="icon" onClick={() => router.invalidate()} title="Recargar">
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
@@ -76,56 +94,158 @@ function RouteComponent() {
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{/* GRID de tarjetas con estilo suave por facultad */}
|
||||
<CardContent>
|
||||
<div className="rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Nombre</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Nivel</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Créditos</TableHead>
|
||||
<TableHead>Duración</TableHead>
|
||||
<TableHead>Estado</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Creado</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered?.map((p) => (
|
||||
<TableRow key={p.id} className="hover:bg-muted/50">
|
||||
<TableCell className="font-medium">{p.nombre}</TableCell>
|
||||
<TableCell className="hidden md:table-cell">{p.nivel ?? "—"}</TableCell>
|
||||
<TableCell className="hidden md:table-cell">{p.total_creditos ?? "—"}</TableCell>
|
||||
<TableCell>{p.duracion ?? "—"}</TableCell>
|
||||
<TableCell>
|
||||
{p.estado ? (
|
||||
<Badge variant={p.estado === "activo" ? "default" : p.estado === "en revisión" ? "secondary" : "outline"}>
|
||||
<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 = softCardStyles(fac?.color)
|
||||
const IconComp = (fac?.icon && (Icons as any)[fac.icon]) || Building2
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={p.id}
|
||||
// Runtime navega con ?planId=... (abrimos el modal),
|
||||
// pero la URL se enmascara SIN el search param:
|
||||
to="/planes/$planId/modal"
|
||||
search={{ planId: p.id }}
|
||||
mask={{ to: '/planes/$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)' }}>
|
||||
<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}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
{showCarrera && p.carreras?.nombre && (
|
||||
<Badge variant="secondary" className="border text-neutral-700 bg-white/70">
|
||||
<ScrollText className="mr-1 h-3 w-3" /> {p.carreras?.nombre}
|
||||
</Badge>
|
||||
)}
|
||||
{showFacultad && fac?.nombre && (
|
||||
<Badge variant="outline" className="bg-white/60" style={{ borderColor: styles.borderColor }}>
|
||||
<BookOpen className="mr-1 h-3 w-3" /> {fac?.nombre}
|
||||
</Badge>
|
||||
)}
|
||||
{p.estado && (
|
||||
<Badge variant="outline" className="ml-auto bg-white/60" style={{ borderColor: styles.borderColor }}>
|
||||
{p.estado}
|
||||
</Badge>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
{p.fecha_creacion ? new Date(p.fecha_creacion).toLocaleDateString() : "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{!filtered?.length && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-sm text-muted-foreground">
|
||||
Sin resultados
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{!filtered?.length && (
|
||||
<div className="text-center text-sm text-muted-foreground py-10">Sin resultados</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Logueado como: <strong>{auth.user?.email}</strong>
|
||||
</div>
|
||||
{/* MODAL: se muestra si existe ?planId=... */}
|
||||
<PlanPreviewModal planId={search?.planId} onClose={() =>
|
||||
router.navigate({ to: "/planes", replace: true })
|
||||
} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---------- Modal (carga ligera por id) ---------- */
|
||||
function PlanPreviewModal({ planId, onClose }: { planId?: string; onClose: () => void }) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [plan, setPlan] = useState<null | {
|
||||
id: string; nombre: string; nivel: string | null; duracion: string | null;
|
||||
total_creditos: number | null; estado: string | null;
|
||||
carreras: { nombre: string; facultades?: { nombre: string; color?: string | null; icon?: string | null } | null } | null
|
||||
}>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
async function fetchPlan() {
|
||||
if (!planId) return
|
||||
setLoading(true)
|
||||
const { data, error } = await supabase
|
||||
.from("plan_estudios")
|
||||
.select(`
|
||||
id, nombre, nivel, duracion, total_creditos, estado,
|
||||
carreras (
|
||||
nombre,
|
||||
facultades:facultades ( nombre, color, icon )
|
||||
)
|
||||
`)
|
||||
.eq("id", planId)
|
||||
.single()
|
||||
if (!alive) return
|
||||
if (!error) setPlan(data as any)
|
||||
setLoading(false)
|
||||
}
|
||||
fetchPlan()
|
||||
return () => { alive = false }
|
||||
}, [planId])
|
||||
|
||||
const fac = plan?.carreras?.facultades
|
||||
const [r, g, b] = hexToRgb(fac?.color)
|
||||
const headerStyle = { background: `linear-gradient(135deg, rgba(${r},${g},${b},.14), rgba(${r},${g},${b},.06))` }
|
||||
|
||||
return (
|
||||
<Dialog open={!!planId} onOpenChange={() => onClose()}>
|
||||
<DialogContent className="max-w-2xl p-0 overflow-hidden">
|
||||
<div className="p-6" style={headerStyle}>
|
||||
<DialogHeader className="space-y-1">
|
||||
<DialogTitle>{plan?.nombre ?? "Cargando…"}</DialogTitle>
|
||||
<div className="text-xs text-neutral-600">
|
||||
{plan?.carreras?.nombre ?? "—"} {fac?.nombre ? `· ${fac?.nombre}` : ""}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
{loading && <div className="text-sm text-neutral-500">Cargando…</div>}
|
||||
{!loading && plan && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div><span className="text-neutral-500">Nivel:</span> <span className="font-medium">{plan.nivel ?? "—"}</span></div>
|
||||
<div><span className="text-neutral-500">Duración:</span> <span className="font-medium">{plan.duracion ?? "—"}</span></div>
|
||||
<div><span className="text-neutral-500">Créditos:</span> <span className="font-medium">{plan.total_creditos ?? "—"}</span></div>
|
||||
<div><span className="text-neutral-500">Estado:</span> <span className="font-medium">{plan.estado ?? "—"}</span></div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Link
|
||||
to="/_authenticated/planes/$planId"
|
||||
params={{ planId: plan.id }}
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-black text-white px-4 py-2 hover:opacity-90"
|
||||
>
|
||||
<Icons.FileText className="w-4 h-4" /> Ver ficha
|
||||
</Link>
|
||||
<Link
|
||||
to="/_authenticated/asignaturas"
|
||||
search={{ planId: plan.id }}
|
||||
className="inline-flex items-center gap-2 rounded-xl border px-4 py-2 hover:bg-neutral-50"
|
||||
>
|
||||
<Icons.BookOpen className="w-4 h-4" /> Ver asignaturas
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
9
src/routes/_authenticated/planes/$planId.tsx
Normal file
9
src/routes/_authenticated/planes/$planId.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/planes/$planId')({
|
||||
component: RouteComponent,
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
return <div>Hello "/_authenticated/planes/$planId"!</div>
|
||||
}
|
||||
105
src/routes/_authenticated/planes/$planId/modal.tsx
Normal file
105
src/routes/_authenticated/planes/$planId/modal.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { createFileRoute, useRouter } from "@tanstack/react-router"
|
||||
import { supabase } from "@/auth/supabase"
|
||||
import * as Icons from "lucide-react"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
|
||||
type PlanDetail = {
|
||||
id: string
|
||||
nombre: string
|
||||
nivel: string | null
|
||||
duracion: string | null
|
||||
total_creditos: number | null
|
||||
estado: string | null
|
||||
carreras: {
|
||||
id: string
|
||||
nombre: string
|
||||
facultades?: { id: string; nombre: string; color?: string | null; icon?: string | null } | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/_authenticated/planes/$planId/modal")({
|
||||
component: RouteComponent,
|
||||
loader: async ({ params }) => {
|
||||
const { data, error } = await supabase
|
||||
.from("plan_estudios")
|
||||
.select(`
|
||||
id, nombre, nivel, duracion, total_creditos, estado,
|
||||
carreras (
|
||||
id, nombre,
|
||||
facultades:facultades ( id, nombre, color, icon )
|
||||
)
|
||||
`)
|
||||
.eq("id", params.planId)
|
||||
.single()
|
||||
if (error) throw error
|
||||
return data as PlanDetail
|
||||
},
|
||||
})
|
||||
|
||||
function gradientFrom(color?: string | null) {
|
||||
const base = (color && /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(color)) ? color : "#2563eb"
|
||||
return `linear-gradient(135deg, ${base} 0%, ${base}CC 45%, ${base}99 75%, ${base}66 100%)`
|
||||
}
|
||||
|
||||
function RouteComponent() {
|
||||
const plan = Route.useLoaderData() as PlanDetail
|
||||
const router = useRouter()
|
||||
|
||||
const fac = plan.carreras?.facultades
|
||||
const IconComp = (fac?.icon && (Icons as any)[fac.icon]) || Icons.Building2
|
||||
const headerBg = { background: gradientFrom(fac?.color) }
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={() => router.navigate({ to: "/planes", replace: true })}>
|
||||
<DialogContent className="max-w-2xl p-0 overflow-hidden">
|
||||
{/* Header con color/ícono de facultad */}
|
||||
<div className="p-6 text-white" style={headerBg}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="inline-flex items-center justify-center rounded-2xl bg-white/15 backdrop-blur px-3 py-2">
|
||||
<IconComp className="w-6 h-6" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="truncate">{plan.nombre}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="text-xs opacity-90 truncate">
|
||||
{plan.carreras?.nombre ?? "—"} {fac?.nombre ? `· ${fac?.nombre}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
{plan.estado && (
|
||||
<Badge variant="outline" className="ml-auto bg-white/10 text-white border-white/40">
|
||||
{plan.estado}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cuerpo */}
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div><span className="text-neutral-500">Nivel:</span> <span className="font-medium">{plan.nivel ?? "—"}</span></div>
|
||||
<div><span className="text-neutral-500">Duración:</span> <span className="font-medium">{plan.duracion ?? "—"}</span></div>
|
||||
<div><span className="text-neutral-500">Créditos:</span> <span className="font-medium">{plan.total_creditos ?? "—"}</span></div>
|
||||
<div><span className="text-neutral-500">Facultad:</span> <span className="font-medium">{fac?.nombre ?? "—"}</span></div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<a
|
||||
href={`/_authenticated/planes/${plan.id}`}
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-black text-white px-4 py-2 hover:opacity-90"
|
||||
>
|
||||
<Icons.FileText className="w-4 h-4" /> Ver ficha
|
||||
</a>
|
||||
<a
|
||||
href={`/_authenticated/asignaturas?planId=${plan.id}`}
|
||||
className="inline-flex items-center gap-2 rounded-xl border px-4 py-2 hover:bg-neutral-50"
|
||||
>
|
||||
<Icons.BookOpen className="w-4 h-4" /> Ver asignaturas
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user