refactor: clean up code formatting and improve layout in usuarios and procedencia-combobox components
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
<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"
|
||||
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">
|
||||
{/* 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>
|
||||
{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 ? (
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
<Badge className="gap-1" variant="outline">
|
||||
<ShieldAlert className="w-3 h-3" /> Usuario
|
||||
</Badge>
|
||||
)}
|
||||
</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
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
|
||||
)}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user