feat: add Usuarios route and user management functionality

- Introduced a new route for user management under /usuarios.
- Implemented user listing with search and edit capabilities.
- Added role management with visual indicators for user roles.
- Created a modal for editing user details, including role and permissions.
- Integrated Supabase for user data retrieval and updates.
- Enhanced UI components for better user experience.
- Removed unused planes route and related components.
- Added a new plan detail modal for displaying plan information.
- Updated navigation to include new Usuarios link.
This commit is contained in:
2025-08-21 15:30:50 -06:00
parent fe471bcfc2
commit 02ad043ed6
16 changed files with 1542 additions and 97 deletions

View File

@@ -0,0 +1,153 @@
import * as Icons from "lucide-react"
import { useMemo, useState } from "react"
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
import { Textarea } from "@/components/ui/textarea"
import { supabase } from "@/auth/supabase"
/* color helpers */
function hexToRgb(hex?: string | null): [number, number, number] {
if (!hex) return [37, 99, 235]
const h = hex.replace("#", ""); const v = h.length === 3 ? h.split("").map(c => c + c).join("") : h
const n = parseInt(v, 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
}
const rgba = (rgb: [number, number, number], a: number) => `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${a})`
/* texto con clamp */
function ExpandableText({ text, mono = false }: { text?: string | null; mono?: boolean }) {
const [open, setOpen] = useState(false)
if (!text) return <span className="text-neutral-400"></span>
return (
<div>
<div className={`${mono ? 'font-mono whitespace-pre-wrap' : ''} text-sm ${open ? '' : 'line-clamp-10'}`}>{text}</div>
{text.length > 220 && (
<button onClick={() => setOpen(v => !v)} className="mt-2 text-xs font-medium text-neutral-600 hover:underline">
{open ? 'Ver menos' : 'Ver más'}
</button>
)}
</div>
)
}
/* panel con estilo */
function SectionPanel({
title, icon: Icon, color, children,
}: { title: string; icon: any; color?: string | null; children: React.ReactNode }) {
const rgb = hexToRgb(color)
return (
<div className="rounded-3xl border backdrop-blur shadow-sm overflow-hidden">
<div className="px-4 py-3 flex items-center gap-2"
style={{ background: `linear-gradient(180deg, ${rgba(rgb, .10)}, transparent)` }}>
<span className="inline-flex items-center justify-center rounded-xl border px-2.5 py-2"
style={{ borderColor: rgba(rgb, .25), background: "rgba(255,255,255,.75)" }}>
<Icon className="w-4 h-4" />
</span>
<h3 className="font-semibold">{title}</h3>
</div>
<div className="p-5">{children}</div>
</div>
)
}
/* ---------- TABS + EDIT DIALOG ---------- */
type PlanTextFields = {
objetivo_general?: string | null; sistema_evaluacion?: string | null;
perfil_ingreso?: string | null; perfil_egreso?: string | null;
competencias_genericas?: string | null; competencias_especificas?: string | null;
indicadores_desempeno?: string | null; pertinencia?: string | null; prompt?: string | null;
}
export function AcademicSections({
planId, plan, color,
}: { planId: string; plan: PlanTextFields; color?: string | null }) {
// estado local editable
const [local, setLocal] = useState<PlanTextFields>({ ...plan })
const [editing, setEditing] = useState<null | { key: keyof PlanTextFields; title: string }>(null)
const [draft, setDraft] = useState("")
const [saving, setSaving] = useState(false)
const sections = useMemo(() => [
{ id: "obj", title: "Objetivo general", icon: Icons.Target, key: "objetivo_general" as const, mono: false },
{ id: "eval", title: "Sistema de evaluación", icon: Icons.CheckCircle2, key: "sistema_evaluacion" as const, mono: false },
{ id: "ing", title: "Perfil de ingreso", icon: Icons.UserPlus, key: "perfil_ingreso" as const, mono: false },
{ id: "egr", title: "Perfil de egreso", icon: Icons.GraduationCap, key: "perfil_egreso" as const, mono: false },
{ id: "cg", title: "Competencias genéricas", icon: Icons.Sparkles, key: "competencias_genericas" as const, mono: false },
{ id: "ce", title: "Competencias específicas", icon: Icons.Cog, key: "competencias_especificas" as const, mono: false },
{ id: "ind", title: "Indicadores de desempeño", icon: Icons.LineChart, key: "indicadores_desempeno" as const, mono: false },
{ id: "pert", title: "Pertinencia", icon: Icons.Lightbulb, key: "pertinencia" as const, mono: false },
{ id: "prompt", title: "Prompt (origen)", icon: Icons.Code2, key: "prompt" as const, mono: true },
], [])
async function handleSave() {
if (!editing) return
setSaving(true)
const payload: any = { [editing.key]: draft }
const { error } = await supabase.from("plan_estudios").update(payload).eq("id", planId)
setSaving(false)
if (error) {
console.error(error)
alert("No se pudo guardar 😓")
return
}
setLocal(prev => ({ ...prev, [editing.key]: draft }))
setEditing(null)
}
return (
<>
<Tabs defaultValue={sections[0].id} className="w-full">
{/* nav sticky con píldoras scrollables */}
<div className="sticky top-0 z-10 backdrop-blur border-b">
<TabsList className="w-full flex gap-2 p-3 scrollbar-none">
{sections.map(s => (
<TabsTrigger key={s.id} value={s.id}
className="data-[state=active]:bg-primary data-[state=active]:text-white p-3 text-xs">
{s.title}
</TabsTrigger>
))}
</TabsList>
</div>
{/* contenido */}
{sections.map(s => {
const text = local[s.key] ?? null
return (
<TabsContent key={s.id} value={s.id} className="mt-4">
<SectionPanel title={s.title} icon={s.icon} color={color}>
<ExpandableText text={text} mono={s.mono} />
<div className="mt-4 flex gap-2">
<Button variant="outline" size="sm" disabled={!text}
onClick={() => text && navigator.clipboard.writeText(text)}>Copiar</Button>
<Button variant="ghost" size="sm"
onClick={() => { setEditing({ key: s.key, title: s.title }); setDraft(text ?? "") }}>
Editar
</Button>
</div>
</SectionPanel>
</TabsContent>
)
})}
</Tabs>
{/* Dialog de edición */}
<Dialog open={!!editing} onOpenChange={(o) => { if (!o) setEditing(null) }}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{editing ? `Editar: ${sections.find(x => x.key === editing.key)?.title}` : ""}</DialogTitle>
</DialogHeader>
<Textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
className={`min-h-[260px] ${editing?.key === 'prompt' ? 'font-mono' : ''}`}
placeholder="Escribe aquí…"
/>
<DialogFooter>
<Button variant="outline" onClick={() => setEditing(null)}>Cancelar</Button>
<Button onClick={handleSave} disabled={saving}>{saving ? "Guardando…" : "Guardar"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}