feat: add Usuarios route and user management functionality

- 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.
This commit is contained in:
2025-08-21 15:30:50 -06:00
parent fe471bcfc2
commit 02ad043ed6
16 changed files with 1542 additions and 97 deletions

View File

@@ -0,0 +1,153 @@
import * as Icons from "lucide-react"
import { useMemo, useState } from "react"
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
import { Textarea } from "@/components/ui/textarea"
import { supabase } from "@/auth/supabase"
/* color helpers */
function hexToRgb(hex?: string | null): [number, number, number] {
if (!hex) return [37, 99, 235]
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]
}
const rgba = (rgb: [number, number, number], a: number) => `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${a})`
/* texto con clamp */
function ExpandableText({ text, mono = false }: { text?: string | null; mono?: boolean }) {
const [open, setOpen] = useState(false)
if (!text) return <span className="text-neutral-400"></span>
return (
<div>
<div className={`${mono ? 'font-mono whitespace-pre-wrap' : ''} text-sm ${open ? '' : 'line-clamp-10'}`}>{text}</div>
{text.length > 220 && (
<button onClick={() => setOpen(v => !v)} className="mt-2 text-xs font-medium text-neutral-600 hover:underline">
{open ? 'Ver menos' : 'Ver más'}
</button>
)}
</div>
)
}
/* panel con estilo */
function SectionPanel({
title, icon: Icon, color, children,
}: { title: string; icon: any; color?: string | null; children: React.ReactNode }) {
const rgb = hexToRgb(color)
return (
<div className="rounded-3xl border backdrop-blur shadow-sm overflow-hidden">
<div className="px-4 py-3 flex items-center gap-2"
style={{ background: `linear-gradient(180deg, ${rgba(rgb, .10)}, transparent)` }}>
<span className="inline-flex items-center justify-center rounded-xl border px-2.5 py-2"
style={{ borderColor: rgba(rgb, .25), background: "rgba(255,255,255,.75)" }}>
<Icon className="w-4 h-4" />
</span>
<h3 className="font-semibold">{title}</h3>
</div>
<div className="p-5">{children}</div>
</div>
)
}
/* ---------- TABS + EDIT DIALOG ---------- */
type PlanTextFields = {
objetivo_general?: string | null; sistema_evaluacion?: string | null;
perfil_ingreso?: string | null; perfil_egreso?: string | null;
competencias_genericas?: string | null; competencias_especificas?: string | null;
indicadores_desempeno?: string | null; pertinencia?: string | null; prompt?: string | null;
}
export function AcademicSections({
planId, plan, color,
}: { planId: string; plan: PlanTextFields; color?: string | null }) {
// estado local editable
const [local, setLocal] = useState<PlanTextFields>({ ...plan })
const [editing, setEditing] = useState<null | { key: keyof PlanTextFields; title: string }>(null)
const [draft, setDraft] = useState("")
const [saving, setSaving] = useState(false)
const sections = useMemo(() => [
{ id: "obj", title: "Objetivo general", icon: Icons.Target, key: "objetivo_general" as const, mono: false },
{ id: "eval", title: "Sistema de evaluación", icon: Icons.CheckCircle2, key: "sistema_evaluacion" as const, mono: false },
{ id: "ing", title: "Perfil de ingreso", icon: Icons.UserPlus, key: "perfil_ingreso" as const, mono: false },
{ id: "egr", title: "Perfil de egreso", icon: Icons.GraduationCap, key: "perfil_egreso" as const, mono: false },
{ id: "cg", title: "Competencias genéricas", icon: Icons.Sparkles, key: "competencias_genericas" as const, mono: false },
{ id: "ce", title: "Competencias específicas", icon: Icons.Cog, key: "competencias_especificas" as const, mono: false },
{ id: "ind", title: "Indicadores de desempeño", icon: Icons.LineChart, key: "indicadores_desempeno" as const, mono: false },
{ id: "pert", title: "Pertinencia", icon: Icons.Lightbulb, key: "pertinencia" as const, mono: false },
{ id: "prompt", title: "Prompt (origen)", icon: Icons.Code2, key: "prompt" as const, mono: true },
], [])
async function handleSave() {
if (!editing) return
setSaving(true)
const payload: any = { [editing.key]: draft }
const { error } = await supabase.from("plan_estudios").update(payload).eq("id", planId)
setSaving(false)
if (error) {
console.error(error)
alert("No se pudo guardar 😓")
return
}
setLocal(prev => ({ ...prev, [editing.key]: draft }))
setEditing(null)
}
return (
<>
<Tabs defaultValue={sections[0].id} className="w-full">
{/* nav sticky con píldoras scrollables */}
<div className="sticky top-0 z-10 backdrop-blur border-b">
<TabsList className="w-full flex gap-2 p-3 scrollbar-none">
{sections.map(s => (
<TabsTrigger key={s.id} value={s.id}
className="data-[state=active]:bg-primary data-[state=active]:text-white p-3 text-xs">
{s.title}
</TabsTrigger>
))}
</TabsList>
</div>
{/* contenido */}
{sections.map(s => {
const text = local[s.key] ?? null
return (
<TabsContent key={s.id} value={s.id} className="mt-4">
<SectionPanel title={s.title} icon={s.icon} color={color}>
<ExpandableText text={text} mono={s.mono} />
<div className="mt-4 flex gap-2">
<Button variant="outline" size="sm" disabled={!text}
onClick={() => text && navigator.clipboard.writeText(text)}>Copiar</Button>
<Button variant="ghost" size="sm"
onClick={() => { setEditing({ key: s.key, title: s.title }); setDraft(text ?? "") }}>
Editar
</Button>
</div>
</SectionPanel>
</TabsContent>
)
})}
</Tabs>
{/* Dialog de edición */}
<Dialog open={!!editing} onOpenChange={(o) => { if (!o) setEditing(null) }}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{editing ? `Editar: ${sections.find(x => x.key === editing.key)?.title}` : ""}</DialogTitle>
</DialogHeader>
<Textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
className={`min-h-[260px] ${editing?.key === 'prompt' ? 'font-mono' : ''}`}
placeholder="Escribe aquí…"
/>
<DialogFooter>
<Button variant="outline" onClick={() => setEditing(null)}>Cancelar</Button>
<Button onClick={handleSave} disabled={saving}>{saving ? "Guardando…" : "Guardar"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -0,0 +1,184 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@@ -0,0 +1,46 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@@ -0,0 +1,183 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -0,0 +1,104 @@
import { useEffect, useMemo, useState } from "react"
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"
import { Command, CommandInput, CommandList, CommandGroup, CommandItem, CommandEmpty } from "@/components/ui/command"
import { Button } from "@/components/ui/button"
import { Check, ChevronsUpDown, Building2, GraduationCap } from "lucide-react"
import { supabase } from "@/auth/supabase"
/* Util simple */
const cls = (...a: (string|false|undefined)[]) => a.filter(Boolean).join(" ")
/* --------- COMBOBOX BASE --------- */
function ComboBase({
placeholder, value, onChange, options, icon:Icon,
}: {
placeholder: string
value?: string | null
onChange: (id: string) => void
options: { id: string; label: string }[]
icon?: any
}) {
const [open, setOpen] = useState(false)
const current = useMemo(() => options.find(o => o.id === value), [options, value])
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
className="w-full sm:w-[420px] justify-between truncate"
title={current?.label ?? placeholder}
>
<span className="flex items-center gap-2 truncate">
{Icon ? <Icon className="h-4 w-4 opacity-70" /> : null}
<span className="truncate">{current?.label ?? placeholder}</span>
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
sideOffset={6}
className="w-[--radix-popover-trigger-width] max-w-[min(92vw,28rem)] p-0"
>
<Command>
<CommandInput placeholder="Buscar..." />
<CommandList className="max-h-72">
<CommandEmpty>Sin resultados</CommandEmpty>
<CommandGroup>
{options.map(o => (
<CommandItem
key={o.id}
value={o.label}
onSelect={() => { onChange(o.id); setOpen(false) }}
className="whitespace-normal"
>
<Check className={cls("mr-2 h-4 w-4", value === o.id ? "opacity-100" : "opacity-0")} />
{o.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
/* --------- COMBO FACULTADES --------- */
export function FacultadCombobox({
value, onChange,
}: { value?: string | null; onChange: (id: string) => void }) {
const [items, setItems] = useState<{ id: string; label: string }[]>([])
useEffect(() => {
supabase.from("facultades").select("id, nombre, color").order("nombre", { ascending: true })
.then(({ data }) => setItems((data ?? []).map(f => ({ id: f.id, label: f.nombre }))))
}, [])
return <ComboBase placeholder="Selecciona facultad…" value={value} onChange={onChange} options={items} icon={Building2} />
}
/* --------- COMBO CARRERAS (filtrado por facultad) --------- */
export function CarreraCombobox({
facultadId, value, onChange, disabled,
}: { facultadId?: string | null; value?: string | null; onChange: (id: string) => void; disabled?: boolean }) {
const [items, setItems] = useState<{ id: string; label: string }[]>([])
useEffect(() => {
if (!facultadId) { setItems([]); return }
supabase.from("carreras")
.select("id, nombre").eq("facultad_id", facultadId).order("nombre", { ascending: true })
.then(({ data }) => setItems((data ?? []).map(c => ({ id: c.id, label: c.nombre }))))
}, [facultadId])
return (
<div className={disabled ? "opacity-60 pointer-events-none" : ""}>
<ComboBase
placeholder={facultadId ? "Selecciona carrera…" : "Selecciona una facultad primero"}
value={value}
onChange={onChange}
options={items}
icon={GraduationCap}
/>
</div>
)
}