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:
2025-08-20 19:09:31 -06:00
parent b33a016ee2
commit 51faa98022
17 changed files with 1279 additions and 108 deletions

View File

@@ -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>
)
}