refactor: clean up code formatting and improve layout in usuarios and procedencia-combobox components
This commit is contained in:
@@ -6,11 +6,11 @@ import { Check, ChevronsUpDown, Building2, GraduationCap } from "lucide-react"
|
|||||||
import { supabase } from "@/auth/supabase"
|
import { supabase } from "@/auth/supabase"
|
||||||
|
|
||||||
/* Util simple */
|
/* Util simple */
|
||||||
const cls = (...a: (string|false|undefined)[]) => a.filter(Boolean).join(" ")
|
const cls = (...a: (string | false | undefined)[]) => a.filter(Boolean).join(" ")
|
||||||
|
|
||||||
/* --------- COMBOBOX BASE --------- */
|
/* --------- COMBOBOX BASE --------- */
|
||||||
function ComboBase({
|
function ComboBase({
|
||||||
placeholder, value, onChange, options, icon:Icon,
|
placeholder, value, onChange, options, icon: Icon,
|
||||||
}: {
|
}: {
|
||||||
placeholder: string
|
placeholder: string
|
||||||
value?: string | null
|
value?: string | null
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
<img
|
<div className="flex items-start gap-3 sm:gap-4">
|
||||||
src={m.avatar || `https://api.dicebear.com/7.x/initials/svg?seed=${encodeURIComponent(m.nombre || u.email || 'U')}`}
|
<img
|
||||||
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=""
|
||||||
<div className="min-w-0 flex-1">
|
className="h-10 w-10 rounded-full object-cover"
|
||||||
<div className="flex items-center gap-2">
|
/>
|
||||||
<div className="font-medium truncate">
|
|
||||||
{m.nombre ? `${m.nombre} ${m.apellidos ?? ""}` : (u.email ?? "—")}
|
<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>
|
</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>
|
||||||
<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>
|
{/* Mobile: icon-only */}
|
||||||
<span>Creado: {new Date(u.created_at).toLocaleDateString()}</span>
|
<Button
|
||||||
<span>Último acceso: {u.last_sign_in_at ? new Date(u.last_sign_in_at).toLocaleDateString() : "—"}</span>
|
variant="ghost"
|
||||||
{m.email_verified ? (
|
size="icon"
|
||||||
<span className="inline-flex items-center gap-1 text-emerald-600"><CheckCircle2 className="w-3 h-3" /> Verificado</span>
|
className="sm:hidden self-start shrink-0"
|
||||||
) : (
|
onClick={() => openEdit(u)}
|
||||||
<span className="inline-flex items-center gap-1 text-neutral-500"><XCircle className="w-3 h-3" /> No verificado</span>
|
aria-label="Editar"
|
||||||
)}
|
>
|
||||||
</div>
|
<Pencil className="w-4 h-4" />
|
||||||
</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
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
@@ -346,6 +395,6 @@ function RouteComponent() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div >
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user