This repository has been archived on 2026-01-21. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Acad-IA/src/routes/_authenticated.tsx
Guillermo Arrieta Medina ce2cd6b397 Eliminada dependencia de llave de servicio para el manejo de usuarios y eliminado el hard-code de los roles
supabase.tsx
Se añadieron los roles al contexto de autenticación. Se modificó la interfaz de UserClaims que consiste en la información que se obtiene de los usuarios. Se obtienen los roles desde la base de datos.

_authenticated.tsx
Ya todos pueden ver el enlace a la página de facultades.

login.tsx
Se movió el enlace de '¿Olvidaste tu contraseña?' a después del input de la contraseña, para mejorar la usabilidad.

usuarios.tsx
- La obtención de los usuarios ahora se hace a con el cliente de llave anónima de supabase y se obtiene de tablas en el esquema public a través de una función de PostgreSQL.
- La información de los roles se obtiene del contexto de autenticación para mostrarla en la página.
- El RolePill se movió a dentro del componente para poder usar la información del contexto.
- Se añadieron validaciones para poder crear un usuario.
- Se muestra la información para editar los usuarios y se actualiza en la BDD con una función de PostgreSQL.
2025-10-20 17:09:14 -06:00

244 lines
8.6 KiB
TypeScript

import { createFileRoute, Outlet, redirect, Link } from "@tanstack/react-router"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
import { ModeToggle } from "@/components/mode-toggle"
import {
Menu,
Bell,
Search as SearchIcon,
LayoutDashboard,
GraduationCap,
FileText,
LogOut,
KeySquare,
IdCard,
Users2Icon,
FileAxis3D,
FolderCheck,
} from "lucide-react"
import { useSupabaseAuth } from "@/auth/supabase"
import { Toaster } from "sonner"
export const Route = createFileRoute("/_authenticated")({
beforeLoad: ({ context }) => {
if (!context.auth.isAuthenticated) {
throw redirect({ to: "/login", search: { redirect: location.href } })
}
},
component: Layout,
})
const nav = [
{ to: "/planes", label: "Planes", icon: GraduationCap },
{ to: "/asignaturas", label: "Asignaturas", icon: FileText },
{ to: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
{ to: "/usuarios", label: "Usuarios", icon: Users2Icon },
{ to: "/archivos", label: "Archivos de referencia", icon: FileAxis3D },
] as const
function getInitials(name?: string) {
if (!name) return "LS"
const parts = name.trim().split(/\s+/).slice(0, 2)
return parts.map(p => p[0]?.toUpperCase()).join("") || "LS"
}
function useUserDisplay() {
const { claims, user } = useSupabaseAuth()
const nombre = claims?.nombre ?? ""
const apellidos = claims?.apellidos ?? ""
const titulo = claims?.title ?? ""
const clave = claims?.clave ?? ""
const fullName = [titulo, nombre, apellidos].filter(Boolean).join(" ")
const shortName = [titulo, nombre, apellidos.split(" ")[0] ?? ""].filter(Boolean).join(" ")
const role = claims?.role ?? ""
return {
fullName,
shortName,
clave,
email: user?.email,
avatar: claims?.avatar ?? null,
initials: getInitials([nombre, apellidos].filter(Boolean).join(" ")),
role,
}
}
function Layout() {
Route.useRouteContext()
const [query, setQuery] = useState("")
const user = useUserDisplay()
return (
<div className="min-h-screen bg-background text-foreground">
{/* Top bar */}
<header className="sticky top-0 z-40 border-b border-border bg-card/80 backdrop-blur supports-[backdrop-filter]:bg-card/60">
<div className="mx-auto flex h-16 max-w-screen-2xl items-center gap-3 px-4">
{/* Mobile menu */}
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="md:hidden" aria-label="Abrir menú">
<Menu className="h-5 w-5" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="p-0">
<SheetHeader>
<SheetTitle>Menú de Navegación</SheetTitle>
</SheetHeader> <Sidebar onNavigate={() => { }} />
</SheetContent>
</Sheet>
{/* Brand */}
<Link to="/dashboard" className="hidden items-center gap-2 md:flex">
<span className="inline-flex h-8 w-25 items-center justify-center rounded-xl bg-primary/10 text-primary font-bold">
<img src="https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcS8C2SvZe281wTNc9werkudO9aJiX3dOZm9T3s5DAfS0OUOvDbgc_WC61U_esY8GE8bZoI&usqp=CAU" alt="" />
</span>
</Link>
{/* Search */}
<div className="ml-auto hidden items-center gap-2 md:flex">
<div className="relative">
<SearchIcon className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Buscar…"
className="w-64 pl-9"
aria-label="Buscar"
/>
</div>
<Button variant="ghost" size="icon" aria-label="Notificaciones">
<Bell className="h-5 w-5" />
</Button>
<ModeToggle />
<UserMenu user={user} />
</div>
</div>
</header>
{/* Shell */}
<div className="mx-auto grid max-w-screen-2xl grid-cols-1 md:grid-cols-[260px_1fr] gap-0">
{/* Sidebar (desktop) */}
<aside className="sticky top-16 hidden h-[calc(100dvh-4rem)] border-r border-border bg-card/40 md:block">
<Sidebar />
</aside>
{/* Main content */}
<main className="min-h-[calc(100dvh-4rem)] p-4 md:p-6">
{/* Welcome strip */}
<Outlet />
</main>
<Toaster />
</div>
</div>
)
}
function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
const { claims } = useSupabaseAuth()
const isAdmin = claims?.role === 'lci' || claims?.role === 'vicerrectoria'
const canSeeCarreras = ["jefe_carrera", 'vicerrectoria', 'secretario_academico', 'lci'].includes(claims?.role ?? '')
return (
<div className="h-full">
<Separator />
<ScrollArea className="h-[calc(100%-4rem)] p-2">
<nav className="grid gap-1 p-2">
{nav.map((item) => (
<Link
key={item.to}
to={item.to}
search={{ ...(item.to === '/planes' ? { plan: '' } : {}) }}
activeOptions={{ exact: true }}
activeProps={{ className: "bg-primary/10 text-foreground" }}
className="group inline-flex items-center gap-3 rounded-xl px-3 py-2 text-sm text-muted-foreground hover:bg-primary/10 hover:text-foreground"
onClick={onNavigate}
>
<item.icon className="h-4 w-4" />
<span className="truncate">{item.label}</span>
</Link>
))}
{canSeeCarreras && (
<Link
to="/carreras"
key='/carreras'
activeOptions={{ exact: true }}
activeProps={{ className: "bg-primary/10 text-foreground" }}
className="group inline-flex items-center gap-3 rounded-xl px-3 py-2 text-sm text-muted-foreground hover:bg-primary/10 hover:text-foreground"
>
<FolderCheck className="h-4 w-4" />
<span className="truncate">Carreras</span>
</Link>
)}
<Link
to="/facultades"
key='facultades'
activeOptions={{ exact: true }}
activeProps={{ className: "bg-primary/10 text-foreground" }}
className="group inline-flex items-center gap-3 rounded-xl px-3 py-2 text-sm text-muted-foreground hover:bg-primary/10 hover:text-foreground"
>
<KeySquare className="h-4 w-4" />
<span className="truncate">Facultades</span>
</Link>
</nav>
</ScrollArea>
<Separator className="mt-auto" />
<div className="flex items-center gap-2 p-3 text-xs text-muted-foreground">
<IdCard className="h-3.5 w-3.5" />
© {new Date().getFullYear()} La Salle
</div>
</div>
)
}
function UserMenu({ user }: { user: ReturnType<typeof useUserDisplay> }) {
const auth = useSupabaseAuth()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-9 gap-2 px-2">
<Avatar className="h-6 w-6">
<AvatarImage src={user.avatar ?? undefined} />
<AvatarFallback className="text-xs">{user.initials}</AvatarFallback>
</Avatar>
<span className="hidden max-w-[16ch] truncate text-sm md:inline">{user.shortName}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuLabel className="truncate">
<div className="font-mono text-sm leading-tight">
{user.fullName}
</div>
<div className="text-xs text-muted-foreground truncate">
{`Clave: ${user.clave} · ${user.email}`}
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive" onClick={auth.logout}>
<LogOut className="mr-2 h-4 w-4" /> Cerrar sesión
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}