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:
153
src/components/planes/academic-sections.tsx
Normal file
153
src/components/planes/academic-sections.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
184
src/components/ui/command.tsx
Normal file
184
src/components/ui/command.tsx
Normal 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,
|
||||
}
|
||||
46
src/components/ui/popover.tsx
Normal file
46
src/components/ui/popover.tsx
Normal 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 }
|
||||
183
src/components/ui/select.tsx
Normal file
183
src/components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
104
src/components/users/procedencia-combobox.tsx
Normal file
104
src/components/users/procedencia-combobox.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user