Tabla del admin

La tabla de usuarios, pedidos y reservaciones del admin con el lenguaje del sistema: capas, líneas de 1px, hover de fila y cifras alineadas. Se arma por columnas (cada una con un bloque que pinta su celda), trae celdas de ayuda para persona, estado, fecha y dinero, y en móvil se desplaza sola en horizontal sin desbordar la página.

Personas del estudio
Persona Rol Estado Reservas Registro
Ana GarcíaNuevo, ana.garcia@correo.mx Cliente Activo 14
Iván López, ivan@lunaestudio.mx Equipo Activo 0
Mariana PeñaNuevo, mariana.pena@correo.mx Cliente Activo 1
Sofía Ruiz, sofia@lunaestudio.mx Equipo Activo 0
Diego Hernández, diego.hdz@correo.mx Cliente Inactivo 23
Lucía TorresNuevo, lucia.torres@correo.mx Cliente Activo 2
Regina Castro, regina@lunaestudio.mx Admin Activo 0
Tomás NavarroNuevo, tomas.navarro@correo.mx Cliente Activo 8

Mostrando 1–8 de 47

app/views/… (la demo de arriba)

<%# Personas de ejemplo (Ui::Demo::Person): la tabla recibe cualquier lista de
    objetos, sin consultas. En tu app: rows: @people y href_for: ->(person) { person_path(person) }. %>
<% now = Time.current %>
<% roles = { "client" => "Cliente", "staff" => "Equipo", "admin" => "Admin" } %>
<% people = [
     [ "Ana", "García", "ana.garcia@correo.mx", "client", true, 2.hours, 14 ],
     [ "Iván", "López", "ivan@lunaestudio.mx", "staff", true, 240.days, 0 ],
     [ "Mariana", "Peña", "mariana.pena@correo.mx", "client", true, 5.hours, 1 ],
     [ "Sofía", "Ruiz", "sofia@lunaestudio.mx", "staff", true, 310.days, 0 ],
     [ "Diego", "Hernández", "diego.hdz@correo.mx", "client", false, 40.days, 23 ],
     [ "Lucía", "Torres", "lucia.torres@correo.mx", "client", true, 1.day, 2 ],
     [ "Regina", "Castro", "regina@lunaestudio.mx", "admin", true, 420.days, 0 ],
     [ "Tomás", "Navarro", "tomas.navarro@correo.mx", "client", true, 6.days, 8 ]
   ].map.with_index(1) do |(first, last, email, role, active, ago, bookings), id|
     Ui::Demo::Person.new(id:, first_name: first, last_name: last, email:, role:, active:, created_at: now - ago, bookings:)
   end %>
<% counts = people.map(&:role).tally %>

<div class="w-full max-w-4xl">
  <%= render Ui::DataTableComponent.new(rows: people, caption: "Personas del estudio",
                                        href_for: ->(person) { "#data-table-persona-#{person.id}" },
                                        row_data: ->(person) { { role: person.role } }) do |t| %>
    <% t.with_toolbar do %>
      <%= render Ui::SegmentedControlComponent.new(label: "Filtrar por rol", selected: "all",
                                                   data: { action: "segmented-control:change->data-table#filter", data_table_key_param: "role" }) do |seg| %>
        <% seg.with_option(label: "Todos", value: "all", count: people.size) %>
        <% seg.with_option(label: "Clientes", value: "client", count: counts["client"]) %>
        <% seg.with_option(label: "Equipo", value: "staff", count: counts["staff"]) %>
        <% seg.with_option(label: "Admins", value: "admin", count: counts["admin"]) %>
      <% end %>
      <%= t.search(placeholder: "Buscar por nombre o correo…", label: "Buscar personas") %>
    <% end %>

    <% t.with_column("Persona") { |person| t.person(person, badge: ("Nuevo" if person.recent?)) } %>
    <% t.with_column("Rol", hide_below: :md) { |person| roles[person.role] } %>
    <% t.with_column("Estado") { |person| t.status(person.active? ? "active" : "inactive", label: person.active? ? "Activo" : "Inactivo", tone: (:danger unless person.active?)) } %>
    <% t.with_column("Reservas", numeric: true, hide_below: :lg) { |person| person.bookings } %>
    <% t.with_column("Registro", align: :right, hide_below: :sm) { |person| t.date(person.created_at) } %>

    <% t.with_empty do %>
      <%= render Ui::EmptyStateComponent.new(title: "Nadie coincide", description: "Prueba con otro nombre, correo o rol.") %>
    <% end %>

    <% t.with_footer do %>
      <p class="tabular-nums">Mostrando <span class="text-foreground">1–8</span> de <span class="text-foreground">47</span></p>
      <nav class="flex items-center gap-1" aria-label="Paginación">
        <%= render Ui::ButtonComponent.new(variant: :outline, size: :sm, disabled: true, "aria-label": "Página anterior") do %>Anterior<% end %>
        <%= render Ui::ButtonComponent.new(variant: :outline, size: :sm, href: "#data-table-pagina-2") do %>Siguiente<% end %>
      </nav>
    <% end %>
  <% end %>
</div>

Qué hace

  • Fila clicable que respeta Cmd/Ctrl, clic medio, selección de texto y los botones de la fila.
  • Filtro del cliente para listas cortas — texto sin acentos y control segmentado combinados.
  • Orillas que se desvanecen hacia donde hay más tabla al desplazarse en horizontal.
  • Columnas que se esconden según el ancho de la propia tabla (container queries).

Props

Nombre Tipo Default Descripción
rows / caption Enumerable / String — Las filas (registros reales) y el nombre de la tabla para lectores de pantalla.
with_column(header, align:, numeric:, hide_below:, width:) bloque |fila| — Una columna; el bloque recibe la fila. numeric: alinea a la derecha con cifras tabulares; hide_below: :sm/:md/:lg la esconde si la tabla es más angosta.
href_for ->(fila) { url } nil Fila clicable: enlace real en la primera celda y clic en toda la fila (row_href: es su nombre anterior).
row_data / row_id ->(fila) { … } nil Atributos data (para filtrar en el cliente) e id de cada fila (para Turbo Streams).
density :compact | :regular | :relaxed :regular Aire de las celdas.
show_caption Boolean false Muestra el caption arriba de la tabla; si no, queda solo para lectores.
max_height String (CSS) nil Alto máximo con scroll propio; el encabezado se queda fijo arriba.
with_toolbar / with_footer / with_empty slots — Búsqueda y filtros, paginación, y el estado vacío (por defecto «Sin resultados»).
t.person / t.status / t.date / t.money / t.search celdas de ayuda — Avatar + nombre + correo, pastilla de estado :xs, fecha relativa o absoluta, $1,350 tabular y la búsqueda de la barra.

Accesibilidad