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

@@ -13,10 +13,12 @@ import { Label } from "@/components/ui/label"
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select"
import {
RefreshCcw, ShieldCheck, ShieldAlert, Pencil, Mail, CheckCircle2, XCircle,
Cpu, Building2, ScrollText, GraduationCap, GanttChart
Cpu, Building2, ScrollText, GraduationCap, GanttChart, Plus, Eye, EyeOff
} from "lucide-react"
import { SupabaseClient } from "@supabase/supabase-js"
import { CarreraCombobox, FacultadCombobox } from "@/components/users/procedencia-combobox"
import { toast } from "sonner"
type AdminUser = {
id: string
@@ -106,6 +108,70 @@ function RouteComponent() {
carrera_id?: string | null;
}>({})
const [createOpen, setCreateOpen] = useState(false)
const [createSaving, setCreateSaving] = useState(false)
const [showPwd, setShowPwd] = useState(false)
const [createForm, setCreateForm] = useState<{
email: string
password: string
role?: Role
claims_admin?: boolean
nombre?: string; apellidos?: string; title?: string; clave?: string; avatar?: string
facultad_id?: string | null
carrera_id?: string | null
}>({ email: "", password: "" })
function genPassword() {
// 14 chars pseudo-aleatoria
const s = Array.from(crypto.getRandomValues(new Uint32Array(4)))
.map(n => n.toString(36)).join("")
return s.slice(0, 14)
}
async function createUserNow() {
if (!createForm.email?.trim()) { toast.error("Correo requerido"); return }
try {
setCreateSaving(true)
const admin = new SupabaseClient(
import.meta.env.VITE_SUPABASE_URL,
import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY
)
const password = createForm.password?.trim() || genPassword()
const { error } = await admin.auth.admin.createUser({
email: createForm.email.trim(),
password,
email_confirm: false,
user_metadata: {
nombre: createForm.nombre ?? "",
apellidos: createForm.apellidos ?? "",
title: createForm.title ?? "",
clave: createForm.clave ?? "",
avatar: createForm.avatar ?? ""
},
app_metadata: {
role: createForm.role,
claims_admin: !!createForm.claims_admin,
facultad_id: createForm.facultad_id ?? null,
carrera_id: createForm.carrera_id ?? null
}
})
if (error) throw error
toast.success("Usuario creado")
setCreateOpen(false)
setCreateForm({ email: "", password: "" })
router.invalidate()
} catch (e: any) {
console.error(e)
toast.error(e?.message || "No se pudo crear el usuario")
} finally {
setCreateSaving(false)
}
}
if (!auth.claims?.claims_admin) {
return <div className="p-6 text-sm text-red-600">No tienes permisos para administrar usuarios.</div>
}
@@ -176,6 +242,11 @@ function RouteComponent() {
<Button variant="outline" size="icon" title="Recargar" onClick={() => router.invalidate()}>
<RefreshCcw className="w-4 h-4" />
</Button>
{/* NUEVO: abrir modal de alta */}
<Button onClick={() => setCreateOpen(true)} className="whitespace-nowrap">
<Plus className="w-4 h-4 mr-1" /> Nuevo usuario
</Button>
</div>
</CardHeader>
@@ -395,6 +466,145 @@ function RouteComponent() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* Modal: Nuevo usuario */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="w-[min(92vw,720px)] sm:max-w-xl md:max-w-2xl">
<DialogHeader><DialogTitle>Nuevo usuario</DialogTitle></DialogHeader>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-1 md:col-span-2">
<Label>Correo</Label>
<Input
type="email"
value={createForm.email}
onChange={(e) => setCreateForm(s => ({ ...s, email: e.target.value }))}
placeholder="usuario@lasalle.mx"
/>
</div>
<div className="space-y-1 md:col-span-2">
<Label>Contraseña temporal</Label>
<div className="flex gap-2">
<Input
type={showPwd ? "text" : "password"}
value={createForm.password}
onChange={(e) => setCreateForm(s => ({ ...s, password: e.target.value }))}
placeholder="Se generará si la dejas vacía"
/>
<Button type="button" variant="outline" onClick={() => setCreateForm(s => ({ ...s, password: genPassword() }))}>
Generar
</Button>
<Button type="button" variant="outline" onClick={() => setShowPwd(v => !v)} aria-label="Mostrar u ocultar">
{showPwd ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</Button>
</div>
<p className="text-[11px] text-neutral-500">Pídeles cambiarla al iniciar sesión.</p>
</div>
<div className="space-y-1">
<Label>Nombre</Label>
<Input value={createForm.nombre ?? ""} onChange={(e) => setCreateForm(s => ({ ...s, nombre: e.target.value }))} />
</div>
<div className="space-y-1">
<Label>Apellidos</Label>
<Input value={createForm.apellidos ?? ""} onChange={(e) => setCreateForm(s => ({ ...s, apellidos: e.target.value }))} />
</div>
<div className="space-y-1">
<Label>Título</Label>
<Input value={createForm.title ?? ""} onChange={(e) => setCreateForm(s => ({ ...s, title: e.target.value }))} />
</div>
<div className="space-y-1">
<Label>Clave</Label>
<Input value={createForm.clave ?? ""} onChange={(e) => setCreateForm(s => ({ ...s, clave: e.target.value }))} />
</div>
<div className="space-y-1 md:col-span-2">
<Label>Avatar (URL)</Label>
<Input value={createForm.avatar ?? ""} onChange={(e) => setCreateForm(s => ({ ...s, avatar: e.target.value }))} />
</div>
{/* Rol */}
<div className="space-y-1 md:col-span-2">
<Label>Rol</Label>
<Select
value={createForm.role ?? ""}
onValueChange={(v) => {
setCreateForm(s => {
const role = v as Role
if (role === "jefe_carrera") return { ...s, role, facultad_id: s.facultad_id ?? "", carrera_id: s.carrera_id ?? "" }
if (role === "secretario_academico") return { ...s, role, facultad_id: s.facultad_id ?? "", carrera_id: null }
return { ...s, role, facultad_id: null, carrera_id: null }
})
}}
>
<SelectTrigger className="w-full"><SelectValue placeholder="Selecciona un rol" /></SelectTrigger>
<SelectContent className="max-h-72">
{ROLES.map(code => {
const M = ROLE_META[code]; const I = M.Icon
return (
<SelectItem key={code} value={code} className="whitespace-normal text-sm leading-snug py-2">
<span className="inline-flex items-center gap-2"><I className="w-4 h-4" /> {M.label}</span>
</SelectItem>
)
})}
</SelectContent>
</Select>
</div>
{/* SECRETARIO: Facultad */}
{createForm.role === "secretario_academico" && (
<div className="md:col-span-2 space-y-1">
<Label>Facultad</Label>
<FacultadCombobox
value={createForm.facultad_id ?? ""}
onChange={(id) => setCreateForm(s => ({ ...s, facultad_id: id, carrera_id: null }))}
/>
</div>
)}
{/* JEFE_CARRERA: Facultad + Carrera */}
{createForm.role === "jefe_carrera" && (
<div className="grid gap-4 md:col-span-2 sm:grid-cols-2">
<div className="space-y-1">
<Label>Facultad</Label>
<FacultadCombobox
value={createForm.facultad_id ?? ""}
onChange={(id) => setCreateForm(s => ({ ...s, facultad_id: id, carrera_id: "" }))}
/>
</div>
<div className="space-y-1">
<Label>Carrera</Label>
<CarreraCombobox
facultadId={createForm.facultad_id ?? ""}
value={createForm.carrera_id ?? ""}
onChange={(id) => setCreateForm(s => ({ ...s, carrera_id: id }))}
disabled={!createForm.facultad_id}
/>
</div>
</div>
)}
<div className="space-y-1 md:col-span-2">
<Label>Permisos</Label>
<Select value={String(!!createForm.claims_admin)} onValueChange={(v) => setCreateForm(s => ({ ...s, claims_admin: v === "true" }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="true">Administrador</SelectItem>
<SelectItem value="false">Usuario</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancelar</Button>
<Button onClick={createUserNow} disabled={!createForm.email || createSaving}>
{createSaving ? "Creando…" : "Crear usuario"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div >
)
}