FesaComponents
DataTableEmpezar

Tutorial: tu primer listado

El módulo de facturas completo — action, columnas, view y client en cinco archivos.

Vamos a construir el módulo facturas completo: tabla paginada en servidor con búsqueda, filtros, orden, URL compartible, doble clic al detalle y eliminación con refresco. Cinco archivos, todos completos — puedes copiarlos y renombrar.

Archivo 1 — Los tipos

features/invoices/types/invoice.types.ts
export interface InvoiceRow {
  id: string;
  code: string;
  clientName: string;
  amount: number;
  status: "PAID" | "PENDING" | "CANCELLED";
  issuedAt: Date;
}

La fila que viaja del servidor al cliente. Solo los campos que la tabla necesita — nunca el objeto entero de Prisma con relaciones (pesa en el payload RSC y expone datos de más).

Archivo 2 — La page action

features/invoices/actions/invoices.actions.ts
"use server";

import type { DataTableFetchParams, DataTableFetchResult } from "@fesa/components/datatable";
import { parseRangeToken } from "@fesa/components/datatable";
import { currentUser } from "@/lib/user";
import { db } from "@/lib/db";
import type { InvoiceRow } from "../types/invoice.types";

interface InvoiceFilters {
  status?: string[];
  issued?: string[];      // token de rango: "2026-01-01..2026-03-31"
}

export async function getInvoicesPageAction(
  params: DataTableFetchParams & InvoiceFilters
): Promise<DataTableFetchResult<InvoiceRow>> {
  const user = await currentUser();
  if (!user || user.role !== "ADMIN") {
    throw new Error("Acceso denegado");
  }

  const range = parseRangeToken(params.issued?.[0]);
  const where = {
    ...(params.globalFilter
      ? { OR: [
          { code: { contains: params.globalFilter } },
          { clientName: { contains: params.globalFilter } },
        ] }
      : {}),
    ...(params.status?.length ? { status: { in: params.status } } : {}),
    ...(range.from || range.to
      ? { issuedAt: {
          ...(range.from ? { gte: new Date(range.from) } : {}),
          ...(range.to ? { lte: new Date(range.to) } : {}),
        } }
      : {}),
  };

  const sort = params.sorting[0];
  const [rows, total] = await Promise.all([
    db.invoice.findMany({
      where,
      orderBy: sort ? { [sort.id]: sort.desc ? "desc" : "asc" } : { issuedAt: "desc" },
      skip: params.pageIndex * params.pageSize,
      take: params.pageSize,
      select: { id: true, code: true, clientName: true,
                amount: true, status: true, issuedAt: true },
    }),
    db.invoice.count({ where }),
  ]);

  return { data: rows, totalRows: total };
}

export async function deleteInvoiceAction(id: string) {
  const user = await currentUser();
  if (!user || user.role !== "ADMIN") return { error: "Acceso denegado" };
  await db.invoice.delete({ where: { id } });
  return { success: "Factura eliminada" };
}

Fíjate en cuatro cosas:

  • El permiso va dentro de la action. Un archivo "use server" es un endpoint público: cualquier usuario autenticado puede invocarlo directo aunque la página tenga guard.
  • La page action lanza (throw): la tabla mostrará ese mensaje exacto en un toast y, si quedó vacía, con botón Reintentar. Las mutaciones normales siguen el patrón { error } / { success } de siempre.
  • skip/take nunca reciben más de 100: la librería ya acota pageSize con MAX_PAGE_SIZE.
  • findMany y count en Promise.all — sin waterfall.

Archivo 3 — Las columnas

features/invoices/components/invoices.columns.tsx
"use client";

import { memo } from "react";
import { MoreHorizontal, Trash2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
  DropdownMenu, DropdownMenuContent,
  DropdownMenuItem, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { CustomColumnDef } from "@fesa/components/datatable";
import { formatCurrency, formatDateShort } from "@/lib/format";
import type { InvoiceRow } from "../types/invoice.types";

const STATUS_VARIANT = {
  PAID: "default", PENDING: "secondary", CANCELLED: "destructive",
} as const;

const StatusCell = memo(({ status }: { status: InvoiceRow["status"] }) => (
  <Badge variant={STATUS_VARIANT[status]}>{status}</Badge>
));
StatusCell.displayName = "StatusCell";

interface InvoiceActions {
  onDelete: (row: InvoiceRow) => void;
}

const ActionsCell = memo(({ row, actions }: { row: InvoiceRow; actions: InvoiceActions }) => (
  <DropdownMenu>
    <DropdownMenuTrigger asChild>
      <Button variant="ghost" size="icon" className="h-8 w-8">
        <MoreHorizontal className="h-4 w-4" />
      </Button>
    </DropdownMenuTrigger>
    <DropdownMenuContent align="end">
      <DropdownMenuItem onClick={() => actions.onDelete(row)} className="text-destructive">
        <Trash2 className="mr-2 h-4 w-4" /> Eliminar
      </DropdownMenuItem>
    </DropdownMenuContent>
  </DropdownMenu>
));
ActionsCell.displayName = "ActionsCell";

export function createInvoiceColumns(
  actions: InvoiceActions
): CustomColumnDef<InvoiceRow>[] {
  return [
    { id: "code", header: "Código", accessorKey: "code",
      cell: ({ row }) => <span className="font-mono">{row.code}</span> },
    { id: "clientName", header: "Cliente", accessorKey: "clientName",
      cell: ({ row }) => row.clientName },
    { id: "amount", header: "Monto", accessorKey: "amount", align: "right",
      cell: ({ row }) => formatCurrency(row.amount) },
    { id: "status", header: "Estado", accessorKey: "status",
      cell: ({ row }) => <StatusCell status={row.status} /> },
    { id: "issuedAt", header: "Emitida", accessorKey: "issuedAt",
      cell: ({ row }) => formatDateShort(row.issuedAt) },
    { id: "actions", header: "", enableSorting: false, enableHiding: false,
      cell: ({ row }) => <ActionsCell row={row} actions={actions} /> },
  ];
}
  • Cada celda con contenido propio es un mini-componente en memo() con displayName — es lo que hace posible el "1 fila = 1 re-render".
  • accessorKey en todas las columnas de datos: sin ella, export, copiar e imprimir dejan la celda vacía.
  • La columna actions se fija sola a la derecha (por su id).

Archivo 4 — La view

features/invoices/view/invoices.view.tsx
import { Suspense } from "react";
import { parseTableSearchParams, readFilterList, readFilter,
         type PageSearchParams } from "@fesa/components/datatable/server";
import { getInvoicesPageAction } from "../actions/invoices.actions";
import { InvoicesClient } from "./invoices.client";
import { InvoicesSkeleton } from "../components/invoices.skeleton";

export async function InvoicesView({ searchParams }: { searchParams?: PageSearchParams }) {
  const params = (await searchParams) ?? {};
  const tableParams = parseTableSearchParams(params, { defaultPageSize: 20 });
  const status = readFilterList(params, "status");
  const issued = readFilter(params, "issued");

  const first = await getInvoicesPageAction({
    ...tableParams,
    status,
    issued: issued ? [issued] : undefined,
  });

  return (
    <Suspense fallback={<InvoicesSkeleton />}>
      <InvoicesClient
        initialData={first.data}
        initialTotalRows={first.totalRows}
        initialPageIndex={tableParams.pageIndex}
        initialPageSize={tableParams.pageSize}
        initialSorting={tableParams.sorting}
        initialFilter={tableParams.globalFilter}
      />
    </Suspense>
  );
}

El servidor lee la misma URL que leerá el cliente, así que el initialData siempre corresponde a lo que la tabla espera: recargar la página 3 ordenada por monto pinta la página 3 ordenada por monto, sin parpadeos ni doble consulta.

Archivo 5 — El client

features/invoices/view/invoices.client.tsx
"use client";

import { memo, useCallback, useMemo, useRef, useState, useTransition } from "react";
import { toast } from "sonner";
import { SmartDataTable, useSmartTable,
         type FilterDefinition } from "@fesa/components/datatable";
import { createInvoiceColumns } from "../components/invoices.columns";
import { deleteInvoiceAction, getInvoicesPageAction } from "../actions/invoices.actions";
import { DeleteInvoiceDialog } from "../components/delete-invoice-dialog";
import type { InvoiceRow } from "../types/invoice.types";

const FILTER_DEFINITIONS: FilterDefinition[] = [
  { key: "status", title: "Estado", options: [
    { label: "Pagada", value: "PAID" },
    { label: "Pendiente", value: "PENDING" },
    { label: "Cancelada", value: "CANCELLED" },
  ]},
  { key: "issued", title: "Fecha de emisión", type: "dateRange" },
];

const TABLE_STYLE = { striped: true, hover: true, stickyHeader: true, rounded: true } as const;

interface InvoicesClientProps {
  initialData: InvoiceRow[];
  initialTotalRows: number;
  initialPageIndex: number;
  initialPageSize: number;
  initialSorting: { id: string; desc: boolean }[];
  initialFilter: string;
}

export const InvoicesClient = memo(function InvoicesClient(props: InvoicesClientProps) {
  const [pendingDelete, setPendingDelete] = useState<InvoiceRow | null>(null);
  const [isPending, startTransition] = useTransition();

  const table = useSmartTable<InvoiceRow>({
    fetchPage: (params, filters) =>
      getInvoicesPageAction({ ...params, status: filters.status, issued: filters.issued }),
    filterDefinitions: FILTER_DEFINITIONS,
    defaultPageSize: 20,
    navigateTo: "/invoices",
  });

  const actionsRef = useRef({ onDelete: setPendingDelete });
  actionsRef.current = { onDelete: setPendingDelete };
  const columns = useMemo(() => createInvoiceColumns(actionsRef.current), []);

  const confirmDelete = useCallback(() => {
    if (!pendingDelete) return;
    startTransition(async () => {
      const result = await deleteInvoiceAction(pendingDelete.id);
      if (result.error) {
        toast.error(result.error);
        return;
      }
      toast.success(result.success);
      setPendingDelete(null);
      void table.tableRef.current?.silentRefetch();
    });
  }, [pendingDelete, table.tableRef]);

  const getRowId = useCallback((row: InvoiceRow) => row.id, []);

  return (
    <>
      <SmartDataTable
        {...table.smartProps}
        initialData={props.initialData}
        initialTotalRows={props.initialTotalRows}
        columns={columns}
        getRowId={getRowId}
        pagination={{ pageSizeOptions: [10, 20, 50], showRowsInfo: true }}
        filter={{ placeholder: "Buscar código o cliente…" }}
        columnVisibility={{ enabled: true, alwaysVisibleColumns: ["code", "actions"] }}
        toolbarConfig={{ customStart: table.filtersUI, showColumnVisibility: true }}
        export={{ enabled: true, formats: ["csv", "xlsx"], filename: "facturas" }}
        style={TABLE_STYLE}
        emptyMessage="No hay facturas con estos filtros"
      />

      <DeleteInvoiceDialog
        invoice={pendingDelete}
        isPending={isPending}
        onClose={() => setPendingDelete(null)}
        onConfirm={confirmDelete}
      />
    </>
  );
});

Qué está pasando aquí:

  • useSmartTable conecta URL + filtros + fetch en una llamada. table.smartProps lleva el ref, el fetchFn, los initial* de la URL y el doble clic hacia /invoices/{id}.
  • Los initialData/initialTotalRows de la view se pasan aparte (vienen del servidor, no de la URL).
  • El patrón actionsRef mantiene la identidad de columns estable aunque los handlers cambien: la tabla no se re-renderiza por gusto.
  • Tras eliminar: silentRefetch() — la tabla trae la página fresca sin parpadear. Si la página tuviera KPIs server-rendered, añade router.refresh().
  • Export CSV/XLSX sale gratis: sin onExport, la librería genera el archivo con los headers de las columnas.

Con esto ya tienes

Paginación y orden en servidor · búsqueda con debounce · filtros de estado y rango de fechas · URL compartible (?page=2&status=PAID&issued=2026-01-01..2026-03-31) · export · errores del servidor visibles con reintento · doble clic al detalle · columnas ocultables · toolbar completo.

En esta pagina