refactor: clean up code formatting and improve layout in usuarios and procedencia-combobox components

This commit is contained in:
2025-08-21 15:57:12 -06:00
parent 02ad043ed6
commit 3e12f4f15a
2 changed files with 103 additions and 54 deletions

View File

@@ -6,11 +6,11 @@ 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(" ")
const cls = (...a: (string | false | undefined)[]) => a.filter(Boolean).join(" ")
/* --------- COMBOBOX BASE --------- */
function ComboBase({
placeholder, value, onChange, options, icon:Icon,
placeholder, value, onChange, options, icon: Icon,
}: {
placeholder: string
value?: string | null
@@ -28,7 +28,7 @@ function ComboBase({
type="button"
variant="outline"
role="combobox"
className="w-full sm:w-[420px] justify-between truncate"
className="w-full justify-between truncate"
title={current?.label ?? placeholder}
>
<span className="flex items-center gap-2 truncate">

View File

@@ -18,7 +18,6 @@ import {
import { SupabaseClient } from "@supabase/supabase-js"
import { CarreraCombobox, FacultadCombobox } from "@/components/users/procedencia-combobox"
/* ---------- Tipos ---------- */
type AdminUser = {
id: string
email: string | null
@@ -31,7 +30,6 @@ type AdminUser = {
const ROLES = ["lci", "vicerrectoria", "secretario_academico", "jefe_carrera", "planeacion"] as const
export type Role = typeof ROLES[number]
/* ---------- Meta bonita de roles (codificado internamente) ---------- */
const ROLE_META: Record<Role, {
label: string
Icon: React.ComponentType<React.SVGProps<SVGSVGElement>>
@@ -69,13 +67,16 @@ function RolePill({ role }: { role: Role }) {
if (!meta) return null
const { Icon, className, label } = meta
return (
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[10px] ${className}`}>
<Icon className="h-3 w-3" /> {label}
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[10px] sm:text-[11px] ${className} max-w-[160px] sm:max-w-none truncate`}
title={label}
>
<Icon className="h-3 w-3 shrink-0" />
<span className="truncate">{label}</span>
</span>
)
}
/* ---------- Página ---------- */
export const Route = createFileRoute("/_authenticated/usuarios")({
component: RouteComponent,
loader: async () => {
@@ -163,15 +164,21 @@ function RouteComponent() {
return (
<div className="p-6 space-y-4">
<Card>
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<CardHeader className="grid gap-3 sm:grid-cols-2 sm:items-center">
<CardTitle>Usuarios</CardTitle>
<div className="flex items-center gap-2">
<Input placeholder="Buscar por nombre, email o rol…" value={q} onChange={e => setQ(e.target.value)} className="w-72" />
<Input
placeholder="Buscar por nombre, email o rol…"
value={q}
onChange={(e) => setQ(e.target.value)}
className="w-full"
/>
<Button variant="outline" size="icon" title="Recargar" onClick={() => router.invalidate()}>
<RefreshCcw className="w-4 h-4" />
</Button>
</div>
</CardHeader>
<CardContent>
<div className="grid gap-3">
{filtered.map(u => {
@@ -179,50 +186,96 @@ function RouteComponent() {
const a = u.app_metadata || {}
const roleCode: Role | undefined = a.role
return (
<div key={u.id} className="flex items-center gap-4 rounded-2xl border p-3">
<img
src={m.avatar || `https://api.dicebear.com/7.x/initials/svg?seed=${encodeURIComponent(m.nombre || u.email || 'U')}`}
alt="" className="h-10 w-10 rounded-full object-cover"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<div className="font-medium truncate">
{m.nombre ? `${m.nombre} ${m.apellidos ?? ""}` : (u.email ?? "—")}
<div className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-3 sm:p-4 hover:shadow-sm transition">
<div className="flex items-start gap-3 sm:gap-4">
<img
src={m.avatar || `https://api.dicebear.com/7.x/initials/svg?seed=${encodeURIComponent(m.nombre || u.email || "U")}`}
alt=""
className="h-10 w-10 rounded-full object-cover"
/>
<div className="min-w-0 flex-1">
{/* Fila superior: nombre + chips + botón (desktop) */}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="font-medium truncate">
{m.title ? `${m.title} ` : ""}
{m.nombre ? `${m.nombre} ${m.apellidos ?? ""}` : (u.email ?? "—")}
</div>
<div className="mt-1 flex flex-wrap items-center gap-1.5 sm:gap-2">
{roleCode && <RolePill role={roleCode} />} {/* usa el pill responsivo */}
{a.claims_admin ? (
<Badge className="gap-1" variant="secondary">
<ShieldCheck className="w-3 h-3" /> Administrador
</Badge>
) : (
<Badge className="gap-1" variant="outline">
<ShieldAlert className="w-3 h-3" /> Usuario
</Badge>
)}
</div>
</div>
{/* Desktop: botón con texto */}
<Button
variant="ghost"
size="sm"
className="hidden sm:inline-flex shrink-0"
onClick={() => openEdit(u)}
>
<Pencil className="w-4 h-4 mr-1" /> Editar
</Button>
</div>
{/* Fila inferior: metadatos (wrapping) */}
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[11px] sm:text-xs text-neutral-600">
<span className="inline-flex items-center gap-1">
<Mail className="w-3 h-3" /> {u.email ?? "—"}
</span>
<span className="hidden xs:inline">
Creado: {new Date(u.created_at).toLocaleDateString()}
</span>
<span className="hidden md:inline">
Último acceso: {u.last_sign_in_at ? new Date(u.last_sign_in_at).toLocaleDateString() : "—"}
</span>
{m.email_verified ? (
<span className="inline-flex items-center gap-1 text-emerald-600">
<CheckCircle2 className="w-3 h-3" /> Verificado
</span>
) : (
<span className="inline-flex items-center gap-1 text-neutral-500">
<XCircle className="w-3 h-3" /> No verificado
</span>
)}
</div>
{roleCode && <RolePill role={roleCode} />}
{a.claims_admin ? (
<Badge className="gap-1" variant="secondary"><ShieldCheck className="w-3 h-3" /> Administrador</Badge>
) : (
<Badge className="gap-1" variant="outline"><ShieldAlert className="w-3 h-3" /> Usuario</Badge>
)}
</div>
<div className="text-xs text-neutral-600 flex flex-wrap items-center gap-3">
<span className="inline-flex items-center gap-1"><Mail className="w-3 h-3" /> {u.email ?? "—"}</span>
<span>Creado: {new Date(u.created_at).toLocaleDateString()}</span>
<span>Último acceso: {u.last_sign_in_at ? new Date(u.last_sign_in_at).toLocaleDateString() : "—"}</span>
{m.email_verified ? (
<span className="inline-flex items-center gap-1 text-emerald-600"><CheckCircle2 className="w-3 h-3" /> Verificado</span>
) : (
<span className="inline-flex items-center gap-1 text-neutral-500"><XCircle className="w-3 h-3" /> No verificado</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => openEdit(u)}>
<Pencil className="w-4 h-4 mr-1" /> Editar
{/* Mobile: icon-only */}
<Button
variant="ghost"
size="icon"
className="sm:hidden self-start shrink-0"
onClick={() => openEdit(u)}
aria-label="Editar"
>
<Pencil className="w-4 h-4" />
</Button>
</div>
</div>
)
})}
{!filtered.length && <div className="text-sm text-neutral-500 text-center py-10">Sin usuarios</div>}
{!filtered.length && (
<div className="text-sm text-neutral-500 text-center py-10">Sin usuarios</div>
)}
</div>
</CardContent>
</Card>
{/* Dialog de edición */}
<Dialog open={!!editing} onOpenChange={(o) => { if (!o) setEditing(null) }}>
<DialogContent className="w-[min(92vw,720px)] sm:max-w-2xl">
<DialogContent className="w-[min(92vw,720px)] sm:max-w-xl md:max-w-2xl">
<DialogHeader><DialogTitle>Editar usuario</DialogTitle></DialogHeader>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-1">
@@ -265,36 +318,31 @@ function RouteComponent() {
}}
>
{/* Hace que el popper herede ancho del trigger y no se salga */}
<SelectTrigger className="w-full sm:w-[420px]">
<SelectTrigger className="w-full">
<SelectValue placeholder="Selecciona un rol" />
</SelectTrigger>
<SelectContent
position="popper"
side="bottom"
align="start"
className="min-w-fit max-w-full max-h-72 overflow-auto"
className="w-[--radix-select-trigger-width] max-w-[min(92vw,28rem)] max-h-72 overflow-auto"
>
{ROLES.map(code => {
const meta = ROLE_META[code]; const Icon = meta.Icon
return (
<SelectItem
key={code}
value={code}
className="whitespace-normal text-sm leading-snug py-2"
>
<SelectItem key={code} value={code} className="whitespace-normal text-sm leading-snug py-2">
<span className="inline-flex items-center gap-2">
<Icon className="w-4 h-4" />
{meta.label}
<Icon className="w-4 h-4" /> {meta.label}
</span>
</SelectItem>
)
})}
</SelectContent>
</Select>
</div>
{/* Solo SECRETARIO: facultad */}
{/* SECRETARIO: solo facultad */}
{form.role === "secretario_academico" && (
<div className="md:col-span-2 space-y-1">
<Label>Facultad</Label>
@@ -308,7 +356,7 @@ function RouteComponent() {
{/* JEFE DE CARRERA: ambos */}
{form.role === "jefe_carrera" && (
<>
< div className="grid gap-4 sm:grid-cols-2"> {/* 👈 asegura wrap en XS */}
<div className="space-y-1">
<Label>Facultad</Label>
<FacultadCombobox
@@ -325,7 +373,8 @@ function RouteComponent() {
disabled={!form.facultad_id}
/>
</div>
</>
</div>
)}
@@ -346,6 +395,6 @@ function RouteComponent() {
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div >
)
}