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:
64
src/components/ui/accordion.tsx
Normal file
64
src/components/ui/accordion.tsx
Normal 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 }
|
||||
23
src/components/ui/sonner.tsx
Normal file
23
src/components/ui/sonner.tsx
Normal 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 }
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user