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:
@@ -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 >
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user