Facturas de cliente
This commit is contained in:
parent
af2997465e
commit
94be2d066f
@ -31,7 +31,8 @@
|
|||||||
},
|
},
|
||||||
"suspicious": {
|
"suspicious": {
|
||||||
"noImplicitAnyLet": "info",
|
"noImplicitAnyLet": "info",
|
||||||
"noExplicitAny": "info"
|
"noExplicitAny": "info",
|
||||||
|
"noArrayIndexKey": "info"
|
||||||
},
|
},
|
||||||
"style": {
|
"style": {
|
||||||
"useImportType": "off",
|
"useImportType": "off",
|
||||||
|
|||||||
@ -14,7 +14,11 @@
|
|||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
|
|
||||||
"rows_selected": "{{count}} fila(s) seleccionadas.",
|
"rows_selected": "{{count}} fila(s) seleccionadas.",
|
||||||
"rows_selected_of_total": "{{count}} de {{total}} fila(s) seleccionadas."
|
"rows_selected_of_total": "{{count}} de {{total}} fila(s) seleccionadas.",
|
||||||
|
|
||||||
|
"search_placeholder": "Type for search...",
|
||||||
|
"search": "Search",
|
||||||
|
"clear": "Clear"
|
||||||
},
|
},
|
||||||
|
|
||||||
"catalog": {
|
"catalog": {
|
||||||
|
|||||||
@ -14,7 +14,11 @@
|
|||||||
"actions": "Acciones",
|
"actions": "Acciones",
|
||||||
|
|
||||||
"rows_selected": "{{count}} fila(s) seleccionadas.",
|
"rows_selected": "{{count}} fila(s) seleccionadas.",
|
||||||
"rows_selected_of_total": "{{count}} de {{total}} fila(s) seleccionadas."
|
"rows_selected_of_total": "{{count}} de {{total}} fila(s) seleccionadas.",
|
||||||
|
|
||||||
|
"search_placeholder": "Escribe aquí para buscar...",
|
||||||
|
"search": "Buscar",
|
||||||
|
"clear": "Limpiar"
|
||||||
},
|
},
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"status": {
|
"status": {
|
||||||
|
|||||||
@ -3,8 +3,17 @@ import { useDataSource } from "@erp/core/hooks";
|
|||||||
import { DefaultError, QueryKey, useQuery } from "@tanstack/react-query";
|
import { DefaultError, QueryKey, useQuery } from "@tanstack/react-query";
|
||||||
import { CustomerInvoicesPage } from '../schemas';
|
import { CustomerInvoicesPage } from '../schemas';
|
||||||
|
|
||||||
export const CUSTOMER_INVOICES_QUERY_KEY = (criteria?: CriteriaDTO): QueryKey =>
|
export const CUSTOMER_INVOICES_QUERY_KEY = (criteria: CriteriaDTO): QueryKey => [
|
||||||
["customer_invoices", criteria] as const;
|
"customer_invoices", {
|
||||||
|
pageNumber: criteria.pageNumber ?? 0,
|
||||||
|
pageSize: criteria.pageSize ?? 10,
|
||||||
|
q: criteria.q ?? "",
|
||||||
|
filters: criteria.filters ?? [],
|
||||||
|
orderBy: criteria.orderBy ?? "",
|
||||||
|
order: criteria.order ?? "",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
type InvoicesQueryOptions = {
|
type InvoicesQueryOptions = {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
@ -21,8 +30,8 @@ export const useInvoicesQuery = (options?: InvoicesQueryOptions) => {
|
|||||||
queryKey: CUSTOMER_INVOICES_QUERY_KEY(criteria),
|
queryKey: CUSTOMER_INVOICES_QUERY_KEY(criteria),
|
||||||
queryFn: async ({ signal }) => {
|
queryFn: async ({ signal }) => {
|
||||||
return await dataSource.getList<CustomerInvoicesPage>("customer-invoices", {
|
return await dataSource.getList<CustomerInvoicesPage>("customer-invoices", {
|
||||||
...criteria,
|
|
||||||
signal,
|
signal,
|
||||||
|
...criteria,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
enabled,
|
enabled,
|
||||||
|
|||||||
@ -2,9 +2,9 @@ import type { CellKeyDownEvent, RowClickedEvent } from "ag-grid-community";
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
|
|
||||||
import { DataTable, LoadingOverlay } from '@repo/rdx-ui/components';
|
import { DataTable, SkeletonDataTable } from '@repo/rdx-ui/components';
|
||||||
import { Button, Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@repo/shadcn-ui/components';
|
import { Button, InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Spinner } from '@repo/shadcn-ui/components';
|
||||||
import { FileDownIcon, FilterIcon, SearchIcon } from 'lucide-react';
|
import { FileDownIcon, FilterIcon, SearchIcon, XIcon } from 'lucide-react';
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useTranslation } from "../../i18n";
|
import { useTranslation } from "../../i18n";
|
||||||
import { InvoiceSummaryFormData, InvoicesPageFormData } from '../../schemas';
|
import { InvoiceSummaryFormData, InvoicesPageFormData } from '../../schemas';
|
||||||
@ -14,8 +14,13 @@ export type InvoiceUpdateCompProps = {
|
|||||||
invoicesPage: InvoicesPageFormData;
|
invoicesPage: InvoicesPageFormData;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
|
||||||
onPageChange?: (pageIndex: number) => void;
|
pageIndex: number;
|
||||||
|
pageSize: number;
|
||||||
|
onPageChange?: (pageNumber: number) => void;
|
||||||
onPageSizeChange?: (pageSize: number) => void;
|
onPageSizeChange?: (pageSize: number) => void;
|
||||||
|
|
||||||
|
searchValue: string;
|
||||||
|
onSearchChange: (value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -23,8 +28,11 @@ export type InvoiceUpdateCompProps = {
|
|||||||
export const InvoicesListGrid = ({
|
export const InvoicesListGrid = ({
|
||||||
invoicesPage,
|
invoicesPage,
|
||||||
loading,
|
loading,
|
||||||
|
pageIndex,
|
||||||
|
pageSize,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
onPageSizeChange,
|
onPageSizeChange,
|
||||||
|
searchValue, onSearchChange,
|
||||||
}: InvoiceUpdateCompProps) => {
|
}: InvoiceUpdateCompProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -33,8 +41,7 @@ export const InvoicesListGrid = ({
|
|||||||
const [statusFilter, setStatusFilter] = useState("todas");
|
const [statusFilter, setStatusFilter] = useState("todas");
|
||||||
|
|
||||||
const columns = useInvoicesListColumns();
|
const columns = useInvoicesListColumns();
|
||||||
const { items, page, per_page, total_pages, total_items } = invoicesPage;
|
const { items, total_items } = invoicesPage;
|
||||||
|
|
||||||
|
|
||||||
// Navegación accesible (click o teclado)
|
// Navegación accesible (click o teclado)
|
||||||
const goToRow = useCallback(
|
const goToRow = useCallback(
|
||||||
@ -77,6 +84,15 @@ export const InvoicesListGrid = ({
|
|||||||
[goToRow]
|
[goToRow]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Handlers de búsqueda
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => onSearchChange(e.target.value);
|
||||||
|
const handleClear = () => onSearchChange("");
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
// Envío inmediato: forzar “salto” del debounce
|
||||||
|
onSearchChange((e.target as HTMLInputElement).value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleRowClick = useCallback(
|
const handleRowClick = useCallback(
|
||||||
(invoice: InvoiceSummaryFormData, _index: number, e: React.MouseEvent) => {
|
(invoice: InvoiceSummaryFormData, _index: number, e: React.MouseEvent) => {
|
||||||
@ -87,20 +103,53 @@ export const InvoicesListGrid = ({
|
|||||||
[navigate]
|
[navigate]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<SkeletonDataTable
|
||||||
|
columns={columns.length}
|
||||||
|
rows={Math.max(6, pageSize)}
|
||||||
|
showFooter
|
||||||
|
footerProps={{ pageIndex, pageSize, totalItems: total_items ?? 0 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Render principal
|
// Render principal
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{/* Barra de filtros */}
|
{/* Barra de filtros */}
|
||||||
<div className="flex flex-col sm:flex-row gap-4 mb-6">
|
<div className="flex flex-col sm:flex-row gap-4 mb-6">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1" role="search" aria-label={t("pages.list.searchPlaceholder")}>
|
||||||
<SearchIcon className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
|
||||||
<Input
|
|
||||||
placeholder="Buscar por número o cliente..."
|
<InputGroup className='bg-background' data-disabled={loading}>
|
||||||
value={searchTerm}
|
<InputGroupInput
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
placeholder={t("common.search_placeholder")}
|
||||||
className="pl-10 "
|
value={searchValue}
|
||||||
/>
|
onChange={handleInputChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
inputMode="search"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<InputGroupAddon>
|
||||||
|
<SearchIcon />
|
||||||
|
</InputGroupAddon>
|
||||||
|
<InputGroupAddon align="inline-end">
|
||||||
|
{loading && <Spinner />}
|
||||||
|
{!searchValue && !loading && <InputGroupButton variant='secondary' className='cursor-pointer'>
|
||||||
|
{t("common.search")}
|
||||||
|
</InputGroupButton>}
|
||||||
|
{searchValue && !loading && <InputGroupButton variant='secondary' className='cursor-pointer' aria-label={t("common.clear")} onClick={handleClear}>
|
||||||
|
<XIcon className="size-4" aria-hidden />
|
||||||
|
<span className="sr-only">{t("common.search")}</span>
|
||||||
|
</InputGroupButton>}
|
||||||
|
|
||||||
|
</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
</div>
|
</div>
|
||||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
<SelectTrigger className="w-full sm:w-48 bg-white border-gray-200 shadow-sm">
|
<SelectTrigger className="w-full sm:w-48 bg-white border-gray-200 shadow-sm">
|
||||||
@ -120,27 +169,22 @@ export const InvoicesListGrid = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-hidden">
|
<div className="overflow-hidden">
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={items}
|
data={items}
|
||||||
readOnly
|
readOnly
|
||||||
enableRowSelection
|
enableRowSelection
|
||||||
enablePagination
|
enablePagination
|
||||||
|
|
||||||
manualPagination
|
manualPagination
|
||||||
pageIndex={page - 1}
|
pageIndex={pageIndex} // DataTable usa 0-based
|
||||||
pageSize={per_page}
|
pageSize={pageSize}
|
||||||
totalItems={total_items}
|
totalItems={total_items}
|
||||||
onPageChange={onPageChange}
|
onPageChange={onPageChange}
|
||||||
onPageSizeChange={onPageSizeChange}
|
onPageSizeChange={onPageSizeChange}
|
||||||
onRowClick={handleRowClick}
|
onRowClick={handleRowClick}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{loading && (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-background/50">
|
|
||||||
<LoadingOverlay />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { ErrorAlert } from '@erp/customers/components';
|
import { ErrorAlert } from '@erp/customers/components';
|
||||||
import { AppBreadcrumb, AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components";
|
import { AppBreadcrumb, AppContent, AppHeader, BackHistoryButton, useDebounce } from "@repo/rdx-ui/components";
|
||||||
import { Button } from "@repo/shadcn-ui/components";
|
import { Button } from "@repo/shadcn-ui/components";
|
||||||
import { FilePenIcon, PlusIcon } from "lucide-react";
|
import { FilePenIcon, PlusIcon } from "lucide-react";
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
@ -13,8 +13,20 @@ export const InvoiceListPage = () => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [pageIndex, setOageIndex] = useState(0);
|
const [pageIndex, setPageIndex] = useState(5);
|
||||||
const [pageSize, setPageSize] = useState(10);
|
const [pageSize, setPageSize] = useState(10);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const debouncedQ = useDebounce(search, 300);
|
||||||
|
|
||||||
|
|
||||||
|
const criteria = useMemo(
|
||||||
|
() => ({
|
||||||
|
q: debouncedQ || "",
|
||||||
|
pageSize,
|
||||||
|
pageNumber: pageIndex,
|
||||||
|
}),
|
||||||
|
[pageSize, pageIndex, debouncedQ]
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
@ -22,10 +34,7 @@ export const InvoiceListPage = () => {
|
|||||||
isError,
|
isError,
|
||||||
error,
|
error,
|
||||||
} = useInvoicesQuery({
|
} = useInvoicesQuery({
|
||||||
criteria: {
|
criteria
|
||||||
pageSize,
|
|
||||||
pageNumber: pageIndex
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const invoicesPageData = useMemo(() => {
|
const invoicesPageData = useMemo(() => {
|
||||||
@ -38,12 +47,19 @@ export const InvoiceListPage = () => {
|
|||||||
|
|
||||||
const handlePageChange = (newPageIndex: number) => {
|
const handlePageChange = (newPageIndex: number) => {
|
||||||
// TanStack usa pageIndex 0-based → API usa 0-based también
|
// TanStack usa pageIndex 0-based → API usa 0-based también
|
||||||
setOageIndex(newPageIndex);
|
setPageIndex(newPageIndex);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePageSizeChange = (newSize: number) => {
|
const handlePageSizeChange = (newSize: number) => {
|
||||||
setPageSize(newSize);
|
setPageSize(newSize);
|
||||||
setOageIndex(0);
|
setPageIndex(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearchChange = (value: string) => {
|
||||||
|
// Normalización ligera: recorta y colapsa espacios internos
|
||||||
|
const cleaned = value.trim().replace(/\s+/g, " ");
|
||||||
|
setSearch(cleaned);
|
||||||
|
setPageIndex(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isError || !invoicesPageData) {
|
if (isError || !invoicesPageData) {
|
||||||
@ -70,22 +86,6 @@ export const InvoiceListPage = () => {
|
|||||||
|
|
||||||
|
|
||||||
/>
|
/>
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-4xl font-bold bg-gradient-to-r from-blue-600 to-violet-600 bg-clip-text text-transparent mb-2">
|
|
||||||
Facturas
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-600">Gestiona y consulta todas tus facturas de cliente</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button onClick={() => navigate(-1)} className="bg-gradient-to-r from-blue-600 to-violet-600 hover:from-blue-700 hover:to-violet-700 text-white shadow-lg shadow-blue-500/30">
|
|
||||||
<PlusIcon className="mr-2 h-4 w-4" />
|
|
||||||
Nueva Factura
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</AppHeader>
|
</AppHeader>
|
||||||
<AppContent>
|
<AppContent>
|
||||||
<div className='flex items-center justify-between space-y-6'>
|
<div className='flex items-center justify-between space-y-6'>
|
||||||
@ -108,8 +108,12 @@ export const InvoiceListPage = () => {
|
|||||||
<InvoicesListGrid
|
<InvoicesListGrid
|
||||||
invoicesPage={invoicesPageData}
|
invoicesPage={invoicesPageData}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
|
pageIndex={pageIndex}
|
||||||
|
pageSize={pageSize}
|
||||||
onPageChange={handlePageChange}
|
onPageChange={handlePageChange}
|
||||||
onPageSizeChange={handlePageSizeChange}
|
onPageSizeChange={handlePageSizeChange}
|
||||||
|
searchValue={search}
|
||||||
|
onSearchChange={handleSearchChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</AppContent>
|
</AppContent>
|
||||||
|
|||||||
@ -21,14 +21,19 @@ import { DataTableMeta } from './data-table.tsx';
|
|||||||
|
|
||||||
interface DataTablePaginationProps<TData> {
|
interface DataTablePaginationProps<TData> {
|
||||||
table: Table<TData>;
|
table: Table<TData>;
|
||||||
|
onPageChange?: (pageIndex: number) => void;
|
||||||
|
onPageSizeChange?: (pageSize: number) => void;
|
||||||
|
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DataTablePagination<TData>({ table, className }: DataTablePaginationProps<TData>) {
|
export function DataTablePagination<TData>({
|
||||||
|
table, onPageChange, onPageSizeChange, className }: DataTablePaginationProps<TData>) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { pageIndex: rawIndex, pageSize: rawSize } = table.getState().pagination;
|
const { pageIndex: rawIndex, pageSize: rawSize } = table.getState().pagination;
|
||||||
const totalRows = (table.options.meta as DataTableMeta<TData>)?.totalItems ?? table.getFilteredRowModel().rows.length;
|
const meta = table.options.meta as DataTableMeta<TData>;
|
||||||
|
const totalRows = meta?.totalItems ?? table.getFilteredRowModel().rows.length;
|
||||||
|
|
||||||
// Normalización segura
|
// Normalización segura
|
||||||
const pageIndex = Number.isFinite(rawIndex) && rawIndex >= 0 ? rawIndex : 0;
|
const pageIndex = Number.isFinite(rawIndex) && rawIndex >= 0 ? rawIndex : 0;
|
||||||
@ -41,23 +46,28 @@ export function DataTablePagination<TData>({ table, className }: DataTablePagina
|
|||||||
const start = totalRows > 0 ? pageIndex * pageSize + 1 : 0;
|
const start = totalRows > 0 ? pageIndex * pageSize + 1 : 0;
|
||||||
const end = totalRows > 0 ? Math.min(start + pageSize - 1, totalRows) : 0;
|
const end = totalRows > 0 ? Math.min(start + pageSize - 1, totalRows) : 0;
|
||||||
|
|
||||||
|
// Handlers de navegación controlada
|
||||||
|
const notify = (next: Partial<{ pageIndex: number; pageSize: number }>) =>
|
||||||
|
table.options.onPaginationChange?.({ pageIndex, pageSize, ...next });
|
||||||
|
|
||||||
|
const gotoPage = (index: number) => onPageChange ? onPageChange(index) : notify({ pageIndex: index });
|
||||||
|
const handlePageSizeChange = (size: string) => onPageSizeChange ?
|
||||||
|
onPageSizeChange(Number(size)) :
|
||||||
|
notify({ pageSize: Number(size) });
|
||||||
|
|
||||||
|
const gotoPreviousPage = () => gotoPage(pageIndex - 1);
|
||||||
|
const gotoNextPage = () => gotoPage(pageIndex + 1);
|
||||||
|
const gotoFirstPage = () => gotoPage(0);
|
||||||
|
const gotoLastPage = () => gotoPage(pageCount - 1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(
|
<div className={cn("flex items-center justify-between", className)}>
|
||||||
"flex items-center justify-between",
|
|
||||||
className
|
|
||||||
)}>
|
|
||||||
{/* Información izquierda */}
|
{/* Información izquierda */}
|
||||||
<div className="flex flex-col sm:flex-row items-center gap-4 flex-1 text-sm text-muted-foreground">
|
<div className="flex flex-col sm:flex-row items-center gap-4 flex-1 text-sm text-muted-foreground">
|
||||||
{/* Rango visible */}
|
|
||||||
<span aria-live="polite">
|
<span aria-live="polite">
|
||||||
{t("components.datatable.pagination.showing_range", {
|
{t("components.datatable.pagination.showing_range", { start, end, total: totalRows })}
|
||||||
start,
|
|
||||||
end,
|
|
||||||
total: totalRows,
|
|
||||||
})}
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Selección de filas */}
|
|
||||||
{hasSelected && (
|
{hasSelected && (
|
||||||
<span aria-live="polite">
|
<span aria-live="polite">
|
||||||
{t("components.datatable.pagination.rows_selected", {
|
{t("components.datatable.pagination.rows_selected", {
|
||||||
@ -68,13 +78,8 @@ export function DataTablePagination<TData>({ table, className }: DataTablePagina
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm text-muted-foreground text-nowrap">
|
<span>{t("components.datatable.pagination.rows_per_page")}</span>
|
||||||
{t("components.datatable.pagination.rows_per_page")}
|
<Select value={String(pageSize)} onValueChange={handlePageSizeChange}>
|
||||||
</span>
|
|
||||||
<Select
|
|
||||||
value={String(pageSize)}
|
|
||||||
onValueChange={(value) => table.setPageSize(Number(value))}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-20 h-8 bg-white border-gray-200">
|
<SelectTrigger className="w-20 h-8 bg-white border-gray-200">
|
||||||
<SelectValue placeholder={String(pageSize)} />
|
<SelectValue placeholder={String(pageSize)} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@ -86,6 +91,7 @@ export function DataTablePagination<TData>({ table, className }: DataTablePagina
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
{pageIndex + 1} / {pageCount}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -93,63 +99,61 @@ export function DataTablePagination<TData>({ table, className }: DataTablePagina
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Pagination>
|
<Pagination>
|
||||||
<PaginationContent>
|
<PaginationContent>
|
||||||
{/* Primera página */}
|
|
||||||
<PaginationItem>
|
<PaginationItem>
|
||||||
<PaginationLink
|
<PaginationLink
|
||||||
aria-label={t("components.datatable.pagination.goto_first_page")}
|
aria-label={t("components.datatable.pagination.goto_first_page")}
|
||||||
onClick={() => table.setPageIndex(0)}
|
onClick={() => {
|
||||||
isActive={!table.getCanPreviousPage()}
|
if (pageIndex > 0) gotoFirstPage();
|
||||||
|
}}
|
||||||
|
isActive={pageIndex > 0}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="px-2.5"
|
className="px-2.5 cursor-pointer"
|
||||||
>
|
>
|
||||||
<ChevronsLeftIcon className="size-4" />
|
<ChevronsLeftIcon className="size-4" />
|
||||||
</PaginationLink>
|
</PaginationLink>
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
|
|
||||||
{/* Anterior */}
|
|
||||||
<PaginationItem>
|
<PaginationItem>
|
||||||
<PaginationLink
|
<PaginationLink
|
||||||
aria-label={t("components.datatable.pagination.goto_previous_page")}
|
aria-label={t("components.datatable.pagination.goto_previous_page")}
|
||||||
onClick={() => table.previousPage()}
|
onClick={() => {
|
||||||
isActive={!table.getCanPreviousPage()}
|
if (pageIndex > 0) gotoPreviousPage();
|
||||||
|
}}
|
||||||
|
isActive={pageIndex > 0}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="px-2.5"
|
className="px-2.5 cursor-pointer"
|
||||||
>
|
>
|
||||||
<ChevronLeftIcon className="size-4" />
|
<ChevronLeftIcon className="size-4" />
|
||||||
</PaginationLink>
|
</PaginationLink>
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
|
|
||||||
<span
|
<span className="text-sm text-muted-foreground px-2" aria-live="polite">
|
||||||
className="text-sm text-muted-foreground px-2"
|
{t("components.datatable.pagination.page_of", { page: pageIndex + 1, of: pageCount })}
|
||||||
aria-live="polite"
|
|
||||||
>
|
|
||||||
{t("components.datatable.pagination.page_of", {
|
|
||||||
page: pageIndex + 1,
|
|
||||||
of: pageCount || 1,
|
|
||||||
})}
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Siguiente */}
|
|
||||||
<PaginationItem>
|
<PaginationItem>
|
||||||
<PaginationLink
|
<PaginationLink
|
||||||
aria-label={t("components.datatable.pagination.goto_next_page")}
|
aria-label={t("components.datatable.pagination.goto_next_page")}
|
||||||
onClick={() => table.nextPage()}
|
onClick={() => {
|
||||||
isActive={!table.getCanNextPage()}
|
if (pageIndex < pageCount - 1) gotoNextPage();
|
||||||
|
}}
|
||||||
|
isActive={pageIndex < pageCount - 1}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="px-2.5"
|
className="px-2.5 cursor-pointer"
|
||||||
>
|
>
|
||||||
<ChevronRightIcon className="size-4" />
|
<ChevronRightIcon className="size-4" />
|
||||||
</PaginationLink>
|
</PaginationLink>
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
|
|
||||||
{/* Última página */}
|
|
||||||
<PaginationItem>
|
<PaginationItem>
|
||||||
<PaginationLink
|
<PaginationLink
|
||||||
aria-label={t("components.datatable.pagination.goto_last_page")}
|
aria-label={t("components.datatable.pagination.goto_last_page")}
|
||||||
onClick={() => table.setPageIndex(pageCount - 1)}
|
onClick={() => {
|
||||||
isActive={!table.getCanNextPage()}
|
if (pageIndex < pageCount - 1) gotoLastPage();
|
||||||
|
}}
|
||||||
|
isActive={pageIndex < pageCount - 1}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="px-2.5"
|
className="px-2.5 cursor-pointer"
|
||||||
>
|
>
|
||||||
<ChevronsRightIcon className="size-4" />
|
<ChevronsRightIcon className="size-4" />
|
||||||
</PaginationLink>
|
</PaginationLink>
|
||||||
|
|||||||
@ -101,7 +101,7 @@ export function DataTable<TData, TValue>({
|
|||||||
|
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
enablePagination = true,
|
enablePagination = true,
|
||||||
pageSize = 25,
|
pageSize = 10,
|
||||||
enableRowSelection = false,
|
enableRowSelection = false,
|
||||||
EditorComponent,
|
EditorComponent,
|
||||||
|
|
||||||
@ -145,10 +145,7 @@ export function DataTable<TData, TValue>({
|
|||||||
columnVisibility,
|
columnVisibility,
|
||||||
rowSelection,
|
rowSelection,
|
||||||
columnFilters,
|
columnFilters,
|
||||||
pagination: {
|
pagination: { pageIndex, pageSize },
|
||||||
pageIndex,
|
|
||||||
pageSize,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
|
||||||
manualPagination,
|
manualPagination,
|
||||||
@ -156,13 +153,14 @@ export function DataTable<TData, TValue>({
|
|||||||
? Math.ceil((totalItems ?? data.length) / (pageSize ?? 25))
|
? Math.ceil((totalItems ?? data.length) / (pageSize ?? 25))
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|
||||||
|
// Propagar cambios al padre
|
||||||
onPaginationChange: (updater) => {
|
onPaginationChange: (updater) => {
|
||||||
const next =
|
const next = typeof updater === "function"
|
||||||
typeof updater === "function"
|
? updater({ pageIndex, pageSize })
|
||||||
? updater({ pageIndex, pageSize })
|
: updater;
|
||||||
: updater;
|
|
||||||
if (next.pageIndex !== undefined) onPageChange?.(next.pageIndex);
|
if (typeof next.pageIndex === "number") onPageChange?.(next.pageIndex);
|
||||||
if (next.pageSize !== undefined) onPageSizeChange?.(next.pageSize);
|
if (typeof next.pageSize === "number") onPageSizeChange?.(next.pageSize);
|
||||||
},
|
},
|
||||||
|
|
||||||
enableRowSelection,
|
enableRowSelection,
|
||||||
@ -183,126 +181,129 @@ export function DataTable<TData, TValue>({
|
|||||||
|
|
||||||
// Render principal
|
// Render principal
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-0">
|
<div
|
||||||
<DataTableToolbar table={table} showViewOptions={!readOnly} />
|
className="overflow-hidden transition-[max-height] duration-300 ease-in-out"
|
||||||
|
style={{ maxHeight: `${table.getRowModel().rows.length * 56}px` }} // 56≈altura fila
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-0">
|
||||||
|
<DataTableToolbar table={table} showViewOptions={!readOnly} />
|
||||||
|
|
||||||
<div className="overflow-hidden rounded-md border">
|
<div className="overflow-hidden rounded-md border">
|
||||||
<TableComp className="w-full text-sm">
|
<TableComp className="w-full text-sm">
|
||||||
{/* CABECERA */}
|
{/* CABECERA */}
|
||||||
<TableHeader className="sticky top-0 z-10">
|
<TableHeader className="sticky top-0 z-10">
|
||||||
{table.getHeaderGroups().map((hg) => (
|
{table.getHeaderGroups().map((hg) => (
|
||||||
<TableRow key={hg.id}>
|
<TableRow key={hg.id}>
|
||||||
{hg.headers.map((h) => {
|
{hg.headers.map((h) => {
|
||||||
const w = h.getSize();
|
const w = h.getSize();
|
||||||
const minW = h.column.columnDef.minSize;
|
const minW = h.column.columnDef.minSize;
|
||||||
const maxW = h.column.columnDef.maxSize;
|
const maxW = h.column.columnDef.maxSize;
|
||||||
return (
|
|
||||||
<TableHead
|
|
||||||
key={h.id}
|
|
||||||
colSpan={h.colSpan}
|
|
||||||
style={{
|
|
||||||
width: w ? `${w}px` : undefined,
|
|
||||||
minWidth: typeof minW === "number" ? `${minW}px` : undefined,
|
|
||||||
maxWidth: typeof maxW === "number" ? `${maxW}px` : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className={"text-xs text-muted-foreground text-nowrap cursor-default"}>
|
|
||||||
{h.isPlaceholder
|
|
||||||
? null
|
|
||||||
: flexRender(h.column.columnDef.header, h.getContext())}
|
|
||||||
</div>
|
|
||||||
</TableHead>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableHeader>
|
|
||||||
|
|
||||||
{/* CUERPO */}
|
|
||||||
<TableBody>
|
|
||||||
{table.getRowModel().rows.length ? (
|
|
||||||
table.getRowModel().rows.map((row, rowIndex) => (
|
|
||||||
<TableRow
|
|
||||||
key={row.id}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
data-state={row.getIsSelected() && "selected"}
|
|
||||||
className={`group bg-background ${readOnly ? "cursor-default" : "cursor-pointer"}`}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") onRowClick?.(row.original, rowIndex, e as any);
|
|
||||||
}}
|
|
||||||
onDoubleClick={
|
|
||||||
!readOnly && !onRowClick ? () => setEditIndex(rowIndex) : undefined
|
|
||||||
} >
|
|
||||||
{row.getVisibleCells().map((cell) => {
|
|
||||||
const w = cell.column.getSize();
|
|
||||||
const minW = cell.column.columnDef.minSize;
|
|
||||||
const maxW = cell.column.columnDef.maxSize;
|
|
||||||
return (
|
return (
|
||||||
<TableCell
|
<TableHead
|
||||||
key={cell.id}
|
key={h.id}
|
||||||
className="align-top"
|
colSpan={h.colSpan}
|
||||||
style={{
|
style={{
|
||||||
width: w ? `${w}px` : undefined,
|
width: w ? `${w}px` : undefined,
|
||||||
minWidth: typeof minW === "number" ? `${minW}px` : undefined,
|
minWidth: typeof minW === "number" ? `${minW}px` : undefined,
|
||||||
maxWidth: typeof maxW === "number" ? `${maxW}px` : undefined,
|
maxWidth: typeof maxW === "number" ? `${maxW}px` : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
<div className={"text-xs text-muted-foreground text-nowrap cursor-default"}>
|
||||||
</TableCell>
|
{h.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(h.column.columnDef.header, h.getContext())}
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))}
|
||||||
) : (
|
</TableHeader>
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={columns.length} className="h-24 text-center text-muted-foreground">
|
|
||||||
{t("components.datatable.empty")}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
{/* Paginación */}
|
|
||||||
{enablePagination && (
|
|
||||||
<TableFooter>
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={100}>
|
|
||||||
<DataTablePagination table={table} />
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableFooter>)
|
|
||||||
}
|
|
||||||
|
|
||||||
</TableComp>
|
{/* CUERPO */}
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows.length ? (
|
||||||
|
table.getRowModel().rows.map((row, rowIndex) => (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
data-state={row.getIsSelected() && "selected"}
|
||||||
|
className={`group bg-background ${readOnly ? "cursor-default" : "cursor-pointer"}`}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") onRowClick?.(row.original, rowIndex, e as any);
|
||||||
|
}}
|
||||||
|
onDoubleClick={
|
||||||
|
!readOnly && !onRowClick ? () => setEditIndex(rowIndex) : undefined
|
||||||
|
} >
|
||||||
|
{row.getVisibleCells().map((cell) => {
|
||||||
|
const w = cell.column.getSize();
|
||||||
|
const minW = cell.column.columnDef.minSize;
|
||||||
|
const maxW = cell.column.columnDef.maxSize;
|
||||||
|
return (
|
||||||
|
<TableCell
|
||||||
|
key={cell.id}
|
||||||
|
className="align-top"
|
||||||
|
style={{
|
||||||
|
width: w ? `${w}px` : undefined,
|
||||||
|
minWidth: typeof minW === "number" ? `${minW}px` : undefined,
|
||||||
|
maxWidth: typeof maxW === "number" ? `${maxW}px` : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</TableCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={columns.length} className="h-24 text-center text-muted-foreground">
|
||||||
|
{t("components.datatable.empty")}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
{/* Paginación */}
|
||||||
|
{enablePagination && (
|
||||||
|
<TableFooter>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={100}>
|
||||||
|
<DataTablePagination table={table} onPageChange={onPageChange} onPageSizeChange={onPageSizeChange} />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableFooter>)
|
||||||
|
}
|
||||||
|
|
||||||
|
</TableComp>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Editor modal */}
|
||||||
|
{EditorComponent && editIndex !== null && (
|
||||||
|
<Dialog open onOpenChange={handleCloseEditor}>
|
||||||
|
<DialogContent className="max-w-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("components.datatable.editor.title")}</DialogTitle>
|
||||||
|
<DialogDescription>{t("components.datatable.editor.subtitle")}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<EditorComponent
|
||||||
|
row={data[editIndex]}
|
||||||
|
index={editIndex}
|
||||||
|
onClose={handleCloseEditor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={handleCloseEditor}>
|
||||||
|
{t("common.close")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* Editor modal */}
|
|
||||||
{EditorComponent && editIndex !== null && (
|
|
||||||
<Dialog open onOpenChange={handleCloseEditor}>
|
|
||||||
<DialogContent className="max-w-3xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{t("components.datatable.editor.title")}</DialogTitle>
|
|
||||||
<DialogDescription>{t("components.datatable.editor.subtitle")}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="mt-4">
|
|
||||||
<EditorComponent
|
|
||||||
row={data[editIndex]}
|
|
||||||
index={editIndex}
|
|
||||||
onClose={handleCloseEditor}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="secondary" onClick={handleCloseEditor}>
|
|
||||||
{t("common.close")}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
export * from "./data-table-column-header.tsx";
|
export * from "./data-table-column-header.tsx";
|
||||||
export * from "./data-table.tsx";
|
export * from "./data-table.tsx";
|
||||||
|
|
||||||
|
export * from "./skeleton-data-table.tsx";
|
||||||
export * from "./with-row-selection.tsx";
|
export * from "./with-row-selection.tsx";
|
||||||
|
|||||||
@ -0,0 +1,69 @@
|
|||||||
|
// SkeletonTableFooter.tsx
|
||||||
|
|
||||||
|
/** Skeleton del footer de paginación, imita DataTablePagination */
|
||||||
|
export function SkeletonDataTableFooter({
|
||||||
|
pageIndex = 0,
|
||||||
|
pageSize = 10,
|
||||||
|
totalItems = 0,
|
||||||
|
}: {
|
||||||
|
pageIndex?: number; // 0-based
|
||||||
|
pageSize?: number;
|
||||||
|
totalItems?: number;
|
||||||
|
}) {
|
||||||
|
// Cálculo de rango (1-based visual)
|
||||||
|
const start = totalItems > 0 ? pageIndex * pageSize + 1 : 0;
|
||||||
|
const end = totalItems > 0 ? Math.min(start + pageSize - 1, totalItems) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-busy="true"
|
||||||
|
className="flex items-center justify-between border-t border-border px-4 py-3 bg-background"
|
||||||
|
>
|
||||||
|
{/* Izquierda: rango visible */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-center gap-4 flex-1 text-sm text-muted-foreground">
|
||||||
|
<span aria-live="polite">
|
||||||
|
{/* Texto real + shimmer leve para coherencia visual */}
|
||||||
|
{`Mostrando ${start}–${end} de ${totalItems} registros`}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* 'Filas por página' + trigger del select simulado */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>Filas por página</span>
|
||||||
|
<div
|
||||||
|
className="h-8 w-20 rounded bg-foreground/10 animate-pulse motion-reduce:animate-none"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Derecha: controles de paginación simulados */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Primera */}
|
||||||
|
<div
|
||||||
|
className="h-8 w-8 rounded-md bg-foreground/10 animate-pulse motion-reduce:animate-none"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
{/* Anterior */}
|
||||||
|
<div
|
||||||
|
className="h-8 w-8 rounded-md bg-foreground/10 animate-pulse motion-reduce:animate-none"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
{/* Indicador de página */}
|
||||||
|
<div className="h-5 w-28 rounded bg-foreground/10 animate-pulse motion-reduce:animate-none" />
|
||||||
|
{/* Siguiente */}
|
||||||
|
<div
|
||||||
|
className="h-8 w-8 rounded-md bg-foreground/10 animate-pulse motion-reduce:animate-none"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
{/* Última */}
|
||||||
|
<div
|
||||||
|
className="h-8 w-8 rounded-md bg-foreground/10 animate-pulse motion-reduce:animate-none"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="sr-only">Loading pagination…</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,74 @@
|
|||||||
|
import {
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
Table as TableComp,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@repo/shadcn-ui/components";
|
||||||
|
// SkeletonTable.tsx
|
||||||
|
import * as React from "react";
|
||||||
|
import { SkeletonDataTableFooter } from './skeleton-data-table-footer.tsx';
|
||||||
|
|
||||||
|
export type SkeletonTableProps = {
|
||||||
|
columns?: number;
|
||||||
|
rows?: number;
|
||||||
|
stickyHeader?: boolean;
|
||||||
|
showFooter?: boolean;
|
||||||
|
footerProps?: { pageIndex?: number; pageSize?: number; totalItems?: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Componente Skeleton de tabla genérico
|
||||||
|
export const SkeletonDataTable = ({
|
||||||
|
columns = 6,
|
||||||
|
rows = 10,
|
||||||
|
stickyHeader = true,
|
||||||
|
showFooter = true,
|
||||||
|
footerProps,
|
||||||
|
}: SkeletonTableProps) => {
|
||||||
|
// Genera arrays para mapear
|
||||||
|
const cols = React.useMemo(() => Array.from({ length: columns }), [columns]);
|
||||||
|
const rws = React.useMemo(() => Array.from({ length: rows }), [rows]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-md border bg-background" role="status" aria-busy="true">
|
||||||
|
<TableComp className="w-full text-sm">
|
||||||
|
<TableHeader className={stickyHeader ? "sticky top-0 z-10 bg-muted" : ""}>
|
||||||
|
<TableRow>
|
||||||
|
{cols.map((_, i) => (
|
||||||
|
<TableHead key={`sk-th-${i}`}>
|
||||||
|
<div className="h-4 w-24 rounded bg-foreground/10 animate-pulse motion-reduce:animate-none" />
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{rws.map((_, r) => (
|
||||||
|
<TableRow key={`sk-tr-${r}`}>
|
||||||
|
{cols.map((_, c) => (
|
||||||
|
<TableCell key={`sk-td-${r}-${c}`} className="align-top">
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
"h-4 rounded bg-foreground/10 animate-pulse motion-reduce:animate-none",
|
||||||
|
c === 0 ? "w-36" : "w-full",
|
||||||
|
"my-2",
|
||||||
|
].join(" ")}
|
||||||
|
style={{ maxWidth: c % 3 === 0 ? "14rem" : c % 3 === 1 ? "10rem" : "100%" }}
|
||||||
|
/>
|
||||||
|
{c > 0 && (
|
||||||
|
<div className="mt-2 h-3 w-1/2 rounded bg-foreground/10 animate-pulse motion-reduce:animate-none" />
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</TableComp>
|
||||||
|
|
||||||
|
{showFooter && <SkeletonDataTableFooter {...footerProps} />}
|
||||||
|
|
||||||
|
<span className="sr-only">Loading table…</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user