- Added dashboard route with loader fetching KPIs, recent plans and subjects, and health metrics. - Created visual components for displaying KPIs and recent activity. - Implemented gradient background and user greeting based on role. - Added input for global search and quick links for creating new plans and subjects. refactor: update facultad progress ring rendering - Fixed rendering of progress ring in facultad detail view. fix: remove unnecessary link to subjects in plan detail view - Removed link to view subjects from the plan detail page for cleaner UI. feat: add create plan dialog in planes route - Introduced a dialog for creating new plans with form validation and role-based field visibility. - Integrated Supabase for creating plans and handling user roles. feat: enhance user management with create user dialog - Added functionality to create new users with role and claims management. - Implemented password generation and input handling for user creation. fix: update login redirect to dashboard - Changed default redirect after login from /planes to /dashboard for better user experience.
147 lines
4.3 KiB
TypeScript
147 lines
4.3 KiB
TypeScript
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"
|
|
|
|
const cls = (...a: (string | false | undefined)[]) => a.filter(Boolean).join(" ")
|
|
|
|
/* ---------- Base reutilizable ---------- */
|
|
function ComboBase({
|
|
placeholder,
|
|
value,
|
|
onChange,
|
|
options,
|
|
icon: Icon,
|
|
disabled = false,
|
|
}: {
|
|
placeholder: string
|
|
value?: string | null
|
|
onChange: (id: string) => void
|
|
options: { id: string; label: string }[]
|
|
icon?: any
|
|
disabled?: boolean
|
|
}) {
|
|
const [open, setOpen] = useState(false)
|
|
const current = useMemo(() => options.find(o => o.id === value), [options, value])
|
|
|
|
return (
|
|
<Popover open={disabled ? false : open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
role="combobox"
|
|
disabled={disabled}
|
|
className={cls("w-full justify-between truncate", disabled && "opacity-60 cursor-not-allowed")}
|
|
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>
|
|
)
|
|
}
|
|
|
|
/* ---------- Facultades ---------- */
|
|
export function FacultadCombobox({
|
|
value,
|
|
onChange,
|
|
disabled = false,
|
|
placeholder = "Selecciona facultad…",
|
|
}: {
|
|
value?: string | null
|
|
onChange: (id: string) => void
|
|
disabled?: boolean
|
|
placeholder?: string
|
|
}) {
|
|
const [items, setItems] = useState<{ id: string; label: string }[]>([])
|
|
useEffect(() => {
|
|
supabase
|
|
.from("facultades")
|
|
.select("id, nombre")
|
|
.order("nombre", { ascending: true })
|
|
.then(({ data }) => setItems((data ?? []).map(f => ({ id: f.id, label: f.nombre }))))
|
|
}, [])
|
|
return (
|
|
<ComboBase
|
|
placeholder={placeholder}
|
|
value={value}
|
|
onChange={onChange}
|
|
options={items}
|
|
icon={Building2}
|
|
disabled={disabled}
|
|
/>
|
|
)
|
|
}
|
|
|
|
/* ---------- Carreras (filtra por facultad) ---------- */
|
|
export function CarreraCombobox({
|
|
facultadId,
|
|
value,
|
|
onChange,
|
|
disabled = false,
|
|
placeholder,
|
|
}: {
|
|
facultadId?: string | null
|
|
value?: string | null
|
|
onChange: (id: string) => void
|
|
disabled?: boolean
|
|
placeholder?: string
|
|
}) {
|
|
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])
|
|
|
|
const ph = placeholder ?? (facultadId ? "Selecciona carrera…" : "Selecciona una facultad primero")
|
|
|
|
return (
|
|
<ComboBase
|
|
placeholder={ph}
|
|
value={value}
|
|
onChange={onChange}
|
|
options={items}
|
|
icon={GraduationCap}
|
|
disabled={disabled}
|
|
/>
|
|
)
|
|
}
|