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

@@ -8,6 +8,12 @@ import { Badge } from "@/components/ui/badge"
import * as Icons from "lucide-react"
import { Plus, RefreshCcw, Building2 } from "lucide-react"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { CarreraCombobox, FacultadCombobox } from "@/components/users/procedencia-combobox"
export type PlanDeEstudios = {
id: string; nombre: string; nivel: string | null; duracion: string | null;
total_creditos: number | null; estado: string | null; fecha_creacion: string | null; carrera_id: string | null
@@ -64,7 +70,7 @@ function InfoChip({
return (
<span
title={label}
className="inline-flex max-w-full items-center gap-1 rounded-lg border px-2.5 py-1 text-xs leading-none bg-white/70 text-neutral-800"
className="inline-flex max-w-full items-center gap-1 rounded-lg border px-2.5 py-1 text-xs leading-none"
style={style}
>
{icon}
@@ -77,6 +83,7 @@ function InfoChip({
function RouteComponent() {
const auth = useSupabaseAuth()
const [q, setQ] = useState("")
const [openCreate, setOpenCreate] = useState(false)
const data = Route.useLoaderData() as PlanRow[]
const router = useRouter()
@@ -105,9 +112,10 @@ function RouteComponent() {
<Button variant="outline" size="icon" onClick={() => router.invalidate()} title="Recargar">
<RefreshCcw className="h-4 w-4" />
</Button>
<Button>
<Button onClick={() => setOpenCreate(true)}>
<Plus className="mr-2 h-4 w-4" /> Nuevo plan
</Button>
</div>
</CardHeader>
@@ -184,6 +192,193 @@ function RouteComponent() {
)}
</CardContent>
</Card>
<CreatePlanDialog
open={openCreate}
onOpenChange={setOpenCreate}
onCreated={(id) => {
setOpenCreate(false)
router.invalidate()
router.navigate({ to: "/plan/$planId", params: { planId: id } })
}}
/>
</div>
)
}
function CreatePlanDialog({
open, onOpenChange, onCreated,
}: {
open: boolean
onOpenChange: (v: boolean) => void
onCreated: (newId: string) => void
}) {
const auth = useSupabaseAuth()
const role = auth.claims?.role
const defaultFac = auth.claims?.facultad_id ?? ""
const defaultCar = auth.claims?.carrera_id ?? ""
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [form, setForm] = useState<{
nombre: string
nivel: string
duracion: string
total_creditos: number | null
facultad_id: string
carrera_id: string
objetivo_general?: string
}>({
nombre: "",
nivel: "",
duracion: "",
total_creditos: null,
facultad_id: defaultFac,
carrera_id: defaultCar,
objetivo_general: "",
})
// Reglas por rol:
const lockFacultad = role === "secretario_academico" || role === "jefe_carrera"
const lockCarrera = role === "jefe_carrera"
const needsFacultad = role === "secretario_academico" || role === "jefe_carrera" || role === "vicerrectoria" || role === "lci"
const needsCarrera = role !== "planeacion" // en general todos crean sobre una carrera
async function createPlan() {
setError(null)
if (!form.nombre.trim()) return setError("El nombre es obligatorio.")
if (needsCarrera && !form.carrera_id) return setError("Selecciona una carrera.")
setSaving(true)
const { data, error } = await supabase
.from("plan_estudios")
.insert({
nombre: form.nombre.trim(),
nivel: form.nivel || null,
duracion: form.duracion || null,
total_creditos: form.total_creditos ?? null,
objetivo_general: form.objetivo_general || null,
carrera_id: form.carrera_id,
estado: "activo",
})
.select("id")
.single()
setSaving(false)
if (error) {
setError(error.message)
return
}
onCreated(data!.id)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[min(92vw,720px)]">
<DialogHeader>
<DialogTitle>Nuevo plan de estudios</DialogTitle>
</DialogHeader>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-1 md:col-span-2">
<Label>Nombre *</Label>
<Input
value={form.nombre}
onChange={(e) => setForm(s => ({ ...s, nombre: e.target.value }))}
placeholder="Ej. Licenciatura en Ciberseguridad"
/>
</div>
<div className="space-y-1">
<Label>Nivel</Label>
<Input
value={form.nivel}
onChange={(e) => setForm(s => ({ ...s, nivel: e.target.value }))}
placeholder="Licenciatura / Maestría…"
/>
</div>
<div className="space-y-1">
<Label>Duración</Label>
<Input
value={form.duracion}
onChange={(e) => setForm(s => ({ ...s, duracion: e.target.value }))}
placeholder="9 semestres / 3 años…"
/>
</div>
<div className="space-y-1">
<Label>Créditos totales</Label>
<Input
inputMode="numeric"
value={form.total_creditos ?? ""}
onChange={(e) => {
const v = e.target.value.trim()
setForm(s => ({ ...s, total_creditos: v === "" ? null : Number(v) || 0 }))
}}
placeholder="270"
/>
</div>
<div className="space-y-1 md:col-span-2">
<Label>Objetivo general</Label>
<Textarea
value={form.objetivo_general ?? ""}
onChange={(e) => setForm(s => ({ ...s, objetivo_general: e.target.value }))}
placeholder="Describe el objetivo general del plan…"
className="min-h-[90px]"
/>
</div>
{/* Procedencia */}
{needsFacultad && (
<div className="space-y-1">
<Label>Facultad</Label>
<FacultadCombobox
value={form.facultad_id}
onChange={(id) =>
setForm(s => ({
...s,
facultad_id: id,
carrera_id: lockCarrera ? s.carrera_id : "", // limpia carrera si no está bloqueada
}))
}
disabled={lockFacultad}
/>
{lockFacultad && (
<p className="text-[11px] text-neutral-500">Fijado por tu rol.</p>
)}
</div>
)}
{needsCarrera && (
<div className="space-y-1">
<Label>Carrera *</Label>
<CarreraCombobox
facultadId={form.facultad_id}
value={form.carrera_id}
onChange={(id) => setForm(s => ({ ...s, carrera_id: id }))}
disabled={lockCarrera || !form.facultad_id}
placeholder={form.facultad_id ? "Selecciona carrera…" : "Selecciona una facultad primero"}
/>
{lockCarrera && (
<p className="text-[11px] text-neutral-500">Fijada por tu rol.</p>
)}
</div>
)}
</div>
{error && <div className="text-sm text-red-600">{error}</div>}
<DialogFooter className="flex flex-col-reverse sm:flex-row gap-2">
<Button variant="outline" className="w-full sm:w-auto" onClick={() => onOpenChange(false)}>Cancelar</Button>
<Button className="w-full sm:w-auto" onClick={createPlan} disabled={saving}>
{saving ? "Creando…" : "Crear plan"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}