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

@@ -28,7 +28,7 @@ function ComboBase({
type="button" type="button"
variant="outline" variant="outline"
role="combobox" role="combobox"
className="w-full sm:w-[420px] justify-between truncate" className="w-full justify-between truncate"
title={current?.label ?? placeholder} title={current?.label ?? placeholder}
> >
<span className="flex items-center gap-2 truncate"> <span className="flex items-center gap-2 truncate">

View File

@@ -18,7 +18,6 @@ import {
import { SupabaseClient } from "@supabase/supabase-js" import { SupabaseClient } from "@supabase/supabase-js"
import { CarreraCombobox, FacultadCombobox } from "@/components/users/procedencia-combobox" import { CarreraCombobox, FacultadCombobox } from "@/components/users/procedencia-combobox"
/* ---------- Tipos ---------- */
type AdminUser = { type AdminUser = {
id: string id: string
email: string | null email: string | null
@@ -31,7 +30,6 @@ type AdminUser = {
const ROLES = ["lci", "vicerrectoria", "secretario_academico", "jefe_carrera", "planeacion"] as const const ROLES = ["lci", "vicerrectoria", "secretario_academico", "jefe_carrera", "planeacion"] as const
export type Role = typeof ROLES[number] export type Role = typeof ROLES[number]
/* ---------- Meta bonita de roles (codificado internamente) ---------- */
const ROLE_META: Record<Role, { const ROLE_META: Record<Role, {
label: string label: string
Icon: React.ComponentType<React.SVGProps<SVGSVGElement>> Icon: React.ComponentType<React.SVGProps<SVGSVGElement>>
@@ -69,13 +67,16 @@ function RolePill({ role }: { role: Role }) {
if (!meta) return null if (!meta) return null
const { Icon, className, label } = meta const { Icon, className, label } = meta
return ( return (
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[10px] ${className}`}> <span
<Icon className="h-3 w-3" /> {label} 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> </span>
) )
} }
/* ---------- Página ---------- */
export const Route = createFileRoute("/_authenticated/usuarios")({ export const Route = createFileRoute("/_authenticated/usuarios")({
component: RouteComponent, component: RouteComponent,
loader: async () => { loader: async () => {
@@ -163,15 +164,21 @@ function RouteComponent() {
return ( return (
<div className="p-6 space-y-4"> <div className="p-6 space-y-4">
<Card> <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> <CardTitle>Usuarios</CardTitle>
<div className="flex items-center gap-2"> <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()}> <Button variant="outline" size="icon" title="Recargar" onClick={() => router.invalidate()}>
<RefreshCcw className="w-4 h-4" /> <RefreshCcw className="w-4 h-4" />
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid gap-3"> <div className="grid gap-3">
{filtered.map(u => { {filtered.map(u => {
@@ -179,50 +186,96 @@ function RouteComponent() {
const a = u.app_metadata || {} const a = u.app_metadata || {}
const roleCode: Role | undefined = a.role const roleCode: Role | undefined = a.role
return ( return (
<div key={u.id} className="flex items-center gap-4 rounded-2xl border p-3"> <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 <img
src={m.avatar || `https://api.dicebear.com/7.x/initials/svg?seed=${encodeURIComponent(m.nombre || u.email || 'U')}`} 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" alt=""
className="h-10 w-10 rounded-full object-cover"
/> />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> {/* 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"> <div className="font-medium truncate">
{m.title ? `${m.title} ` : ""}
{m.nombre ? `${m.nombre} ${m.apellidos ?? ""}` : (u.email ?? "—")} {m.nombre ? `${m.nombre} ${m.apellidos ?? ""}` : (u.email ?? "—")}
</div> </div>
{roleCode && <RolePill role={roleCode} />}
<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 ? ( {a.claims_admin ? (
<Badge className="gap-1" variant="secondary"><ShieldCheck className="w-3 h-3" /> Administrador</Badge> <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> <Badge className="gap-1" variant="outline">
)} <ShieldAlert className="w-3 h-3" /> Usuario
</div> </Badge>
<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> </div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => openEdit(u)}> {/* 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 <Pencil className="w-4 h-4 mr-1" /> Editar
</Button> </Button>
</div> </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> </div>
</div>
{/* 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> </div>
</CardContent> </CardContent>
</Card> </Card>
{/* Dialog de edición */} {/* Dialog de edición */}
<Dialog open={!!editing} onOpenChange={(o) => { if (!o) setEditing(null) }}> <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> <DialogHeader><DialogTitle>Editar usuario</DialogTitle></DialogHeader>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<div className="space-y-1"> <div className="space-y-1">
@@ -265,36 +318,31 @@ function RouteComponent() {
}} }}
> >
{/* Hace que el popper herede ancho del trigger y no se salga */} {/* 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" /> <SelectValue placeholder="Selecciona un rol" />
</SelectTrigger> </SelectTrigger>
<SelectContent <SelectContent
position="popper" position="popper"
side="bottom" side="bottom"
align="start" 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 => { {ROLES.map(code => {
const meta = ROLE_META[code]; const Icon = meta.Icon const meta = ROLE_META[code]; const Icon = meta.Icon
return ( return (
<SelectItem <SelectItem key={code} value={code} className="whitespace-normal text-sm leading-snug py-2">
key={code}
value={code}
className="whitespace-normal text-sm leading-snug py-2"
>
<span className="inline-flex items-center gap-2"> <span className="inline-flex items-center gap-2">
<Icon className="w-4 h-4" /> <Icon className="w-4 h-4" /> {meta.label}
{meta.label}
</span> </span>
</SelectItem> </SelectItem>
) )
})} })}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
{/* Solo SECRETARIO: facultad */} {/* Solo SECRETARIO: facultad */}
{/* SECRETARIO: solo facultad */}
{form.role === "secretario_academico" && ( {form.role === "secretario_academico" && (
<div className="md:col-span-2 space-y-1"> <div className="md:col-span-2 space-y-1">
<Label>Facultad</Label> <Label>Facultad</Label>
@@ -308,7 +356,7 @@ function RouteComponent() {
{/* JEFE DE CARRERA: ambos */} {/* JEFE DE CARRERA: ambos */}
{form.role === "jefe_carrera" && ( {form.role === "jefe_carrera" && (
<> < div className="grid gap-4 sm:grid-cols-2"> {/* 👈 asegura wrap en XS */}
<div className="space-y-1"> <div className="space-y-1">
<Label>Facultad</Label> <Label>Facultad</Label>
<FacultadCombobox <FacultadCombobox
@@ -325,7 +373,8 @@ function RouteComponent() {
disabled={!form.facultad_id} disabled={!form.facultad_id}
/> />
</div> </div>
</> </div>
)} )}