02ad043ed6
- Introduced a new route for user management under /usuarios. - Implemented user listing with search and edit capabilities. - Added role management with visual indicators for user roles. - Created a modal for editing user details, including role and permissions. - Integrated Supabase for user data retrieval and updates. - Enhanced UI components for better user experience. - Removed unused planes route and related components. - Added a new plan detail modal for displaying plan information. - Updated navigation to include new Usuarios link.
226 lines
8.1 KiB
TypeScript
226 lines
8.1 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, SheetTrigger } from "@/components/ui/sheet"
|
|
import { ModeToggle } from "@/components/mode-toggle"
|
|
import {
|
|
Menu,
|
|
Bell,
|
|
Search as SearchIcon,
|
|
LayoutDashboard,
|
|
GraduationCap,
|
|
FileText,
|
|
LogOut,
|
|
KeySquare,
|
|
IdCard,
|
|
Users2Icon,
|
|
} from "lucide-react"
|
|
import { useSupabaseAuth } from "@/auth/supabase"
|
|
|
|
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 },
|
|
] 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,
|
|
isAdmin: Boolean(claims?.claims_admin),
|
|
}
|
|
}
|
|
|
|
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">
|
|
<Sidebar onNavigate={() => {}} />
|
|
</SheetContent>
|
|
</Sheet>
|
|
|
|
{/* Brand */}
|
|
<Link to={user.isAdmin ? "/dashboard" : "/planes"} className="hidden items-center gap-2 md:flex">
|
|
<span className="inline-flex h-8 w-8 items-center justify-center rounded-xl bg-primary/10 text-primary font-bold">U</span>
|
|
<div className="flex flex-col leading-tight">
|
|
<span className="font-semibold tracking-tight">La Salle · Ingeniería</span>
|
|
<span className="text-[10px] text-muted-foreground font-mono uppercase tracking-wider">Génesis</span>
|
|
</div>
|
|
</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>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
|
const { claims } = useSupabaseAuth()
|
|
const isAdmin = Boolean(claims?.claims_admin)
|
|
|
|
return (
|
|
<div className="h-full">
|
|
<div className="flex items-center gap-2 p-4 md:hidden">
|
|
<span className="inline-flex h-8 w-8 items-center justify-center rounded-xl bg-primary/10 text-primary font-bold">U</span>
|
|
<div className="leading-tight">
|
|
<span className="font-semibold">La Salle · Ingeniería</span>
|
|
<div className="text-[10px] text-muted-foreground font-mono uppercase tracking-wider">Génesis</div>
|
|
</div>
|
|
</div>
|
|
<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}
|
|
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>
|
|
))}
|
|
|
|
{isAdmin && (
|
|
<Link
|
|
to="/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>
|
|
)
|
|
}
|