feat: implement dashboard with KPIs, recent activity, and health metrics

- 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.
This commit is contained in:
2025-08-22 14:32:43 -06:00
parent 9727f4c691
commit ca3fed69b2
16 changed files with 2274 additions and 118 deletions

View File

@@ -0,0 +1,64 @@
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -0,0 +1,23 @@
import { useTheme } from "next-themes"
import { Toaster as Sonner, ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }

View File

@@ -5,30 +5,36 @@ 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 --------- */
/* ---------- Base reutilizable ---------- */
function ComboBase({
placeholder, value, onChange, options, icon: Icon,
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={open} onOpenChange={setOpen}>
<Popover open={disabled ? false : open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
className="w-full justify-between truncate"
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">
@@ -38,6 +44,7 @@ function ComboBase({
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
sideOffset={6}
@@ -67,38 +74,73 @@ function ComboBase({
)
}
/* --------- COMBO FACULTADES --------- */
/* ---------- Facultades ---------- */
export function FacultadCombobox({
value, onChange,
}: { value?: string | null; onChange: (id: string) => void }) {
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, color").order("nombre", { ascending: true })
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="Selecciona facultad…" value={value} onChange={onChange} options={items} icon={Building2} />
return (
<ComboBase
placeholder={placeholder}
value={value}
onChange={onChange}
options={items}
icon={Building2}
disabled={disabled}
/>
)
}
/* --------- COMBO CARRERAS (filtrado por facultad) --------- */
/* ---------- Carreras (filtra por facultad) ---------- */
export function CarreraCombobox({
facultadId, value, onChange, disabled,
}: { facultadId?: string | null; value?: string | null; onChange: (id: string) => void; disabled?: boolean }) {
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 })
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 (
<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>
<ComboBase
placeholder={ph}
value={value}
onChange={onChange}
options={items}
icon={GraduationCap}
disabled={disabled}
/>
)
}