2026-10-09 16:00:41 +00:00
|
|
|
import { Button } from "@repo/shadcn-ui/components/button";
|
|
|
|
|
import { Input } from "@repo/shadcn-ui/components/input";
|
2026-10-07 15:27:23 +00:00
|
|
|
import type { ReactNode } from "react";
|
|
|
|
|
import { useId } from "react";
|
|
|
|
|
|
|
|
|
|
import { useEntityTable } from "../hooks/use-entity-table";
|
|
|
|
|
import { entityListLabels } from "../lib/entity-list";
|
|
|
|
|
|
|
|
|
|
import { EntityListPagination } from "./entity-list-controls";
|
|
|
|
|
import { EntityTableConfiguration } from "./entity-table-configuration";
|
|
|
|
|
import { EntityTableContent } from "./entity-table-content";
|
|
|
|
|
|
|
|
|
|
export { EntityAction, EntityRowActions, EntityStatus } from "./entity-list-controls";
|
|
|
|
|
|
|
|
|
|
export function EntityListHeader({
|
|
|
|
|
title,
|
|
|
|
|
description,
|
2026-10-16 18:09:02 +00:00
|
|
|
icon,
|
2026-10-07 15:27:23 +00:00
|
|
|
actions,
|
|
|
|
|
locale = "es",
|
|
|
|
|
}: {
|
|
|
|
|
title: string;
|
|
|
|
|
description?: string;
|
2026-10-16 18:09:02 +00:00
|
|
|
icon?: ReactNode;
|
2026-10-07 15:27:23 +00:00
|
|
|
actions?: ReactNode;
|
|
|
|
|
locale?: string;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<header className="flex flex-wrap items-start justify-between gap-4">
|
|
|
|
|
<div className="min-w-0">
|
2026-10-16 18:09:02 +00:00
|
|
|
<h2 className="flex items-center gap-2 text-2xl font-semibold">
|
|
|
|
|
{icon && (
|
|
|
|
|
<span
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
className="inline-flex size-6 shrink-0 items-center justify-center [&>svg]:size-6"
|
|
|
|
|
>
|
|
|
|
|
{icon}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
{title}
|
|
|
|
|
</h2>
|
2026-10-07 15:27:23 +00:00
|
|
|
<p className="mt-1 text-base font-medium text-muted-foreground">
|
|
|
|
|
{description ??
|
|
|
|
|
`${entityListLabels(locale).description} ${title.toLocaleLowerCase(locale)}.`}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
{actions && <div className="flex flex-wrap items-center justify-end gap-2">{actions}</div>}
|
|
|
|
|
</header>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-10-10 10:01:58 +00:00
|
|
|
|
2026-10-07 15:27:23 +00:00
|
|
|
export type EntityColumn<T> = {
|
|
|
|
|
id: string;
|
|
|
|
|
label: string;
|
|
|
|
|
render: (item: T) => ReactNode;
|
|
|
|
|
value?: (item: T) => string | number | boolean | null | undefined;
|
2026-10-15 09:36:42 +00:00
|
|
|
defaultSort?: "asc" | "desc";
|
2026-10-07 15:27:23 +00:00
|
|
|
hideable?: boolean;
|
2026-10-19 09:45:09 +00:00
|
|
|
defaultVisible?: boolean;
|
2026-10-07 15:27:23 +00:00
|
|
|
};
|
2026-10-10 10:01:58 +00:00
|
|
|
|
2026-10-07 15:27:23 +00:00
|
|
|
export function EntityTable<T extends { id: string }>({
|
|
|
|
|
items,
|
|
|
|
|
columns,
|
|
|
|
|
title,
|
|
|
|
|
entityLabel = title,
|
|
|
|
|
locale = "es",
|
|
|
|
|
search,
|
|
|
|
|
onSearchChange,
|
|
|
|
|
searchPlaceholder,
|
|
|
|
|
filters,
|
|
|
|
|
filtersApplied = false,
|
|
|
|
|
onClearFilters,
|
|
|
|
|
filterKey = "",
|
2026-10-19 09:45:09 +00:00
|
|
|
columnVisibilityStorageKey,
|
2026-10-07 15:27:23 +00:00
|
|
|
loading = false,
|
|
|
|
|
unavailable = false,
|
|
|
|
|
emptyMessage,
|
|
|
|
|
rowClassName,
|
2026-10-15 10:12:53 +00:00
|
|
|
onRowClick,
|
2026-10-15 13:58:01 +00:00
|
|
|
layout = "content",
|
2026-10-07 15:27:23 +00:00
|
|
|
}: {
|
|
|
|
|
items: readonly T[];
|
|
|
|
|
columns: readonly EntityColumn<T>[];
|
|
|
|
|
title: string;
|
|
|
|
|
entityLabel?: string;
|
|
|
|
|
locale?: string;
|
|
|
|
|
search?: string;
|
|
|
|
|
onSearchChange?: (value: string) => void;
|
|
|
|
|
searchPlaceholder?: string;
|
|
|
|
|
filters?: ReactNode;
|
|
|
|
|
filtersApplied?: boolean;
|
|
|
|
|
onClearFilters?: () => void;
|
|
|
|
|
filterKey?: string;
|
2026-10-19 09:45:09 +00:00
|
|
|
columnVisibilityStorageKey?: string | undefined;
|
2026-10-07 15:27:23 +00:00
|
|
|
loading?: boolean;
|
|
|
|
|
unavailable?: boolean;
|
|
|
|
|
emptyMessage?: string;
|
|
|
|
|
rowClassName?: (item: T) => string;
|
2026-10-15 10:12:53 +00:00
|
|
|
onRowClick?: ((item: T) => void) | undefined;
|
2026-10-15 13:58:01 +00:00
|
|
|
layout?: "content" | "fill";
|
2026-10-07 15:27:23 +00:00
|
|
|
}) {
|
|
|
|
|
const id = useId();
|
|
|
|
|
const labels = entityListLabels(locale);
|
|
|
|
|
const { table, total, range, pagination, setPagination, currentSearch, setLocalSearch } =
|
2026-10-19 09:45:09 +00:00
|
|
|
useEntityTable({
|
|
|
|
|
items,
|
|
|
|
|
columns,
|
|
|
|
|
search,
|
|
|
|
|
onSearchChange,
|
|
|
|
|
filterKey,
|
|
|
|
|
columnVisibilityStorageKey,
|
|
|
|
|
});
|
2026-10-07 15:27:23 +00:00
|
|
|
const info = entityTableInfo(
|
|
|
|
|
loading,
|
|
|
|
|
unavailable,
|
|
|
|
|
labels.loading,
|
|
|
|
|
labels.range(range.from, range.to, total, entityLabel.toLocaleLowerCase(locale))
|
|
|
|
|
);
|
|
|
|
|
const message = entityTableMessage(
|
|
|
|
|
loading,
|
|
|
|
|
unavailable,
|
|
|
|
|
Boolean(currentSearch || filtersApplied),
|
|
|
|
|
labels,
|
|
|
|
|
emptyMessage
|
|
|
|
|
);
|
2026-10-10 10:01:58 +00:00
|
|
|
|
2026-10-07 15:27:23 +00:00
|
|
|
const clear = () => {
|
|
|
|
|
(onSearchChange ?? setLocalSearch)("");
|
|
|
|
|
onClearFilters?.();
|
|
|
|
|
setPagination((previous) => ({ ...previous, pageIndex: 0 }));
|
|
|
|
|
};
|
2026-10-10 10:01:58 +00:00
|
|
|
|
2026-10-07 15:27:23 +00:00
|
|
|
return (
|
|
|
|
|
<section
|
|
|
|
|
aria-busy={loading}
|
|
|
|
|
aria-label={title}
|
2026-10-15 13:58:01 +00:00
|
|
|
className={`min-w-0 rounded-xl border bg-card text-card-foreground ${layout === "fill" ? "flex min-h-96 flex-1 flex-col overflow-hidden sm:min-h-80 [&>[data-slot=table-container]]:min-h-0 [&>[data-slot=table-container]]:flex-1 [&>[data-slot=table-container]]:overflow-auto [&>[data-slot=table-container]]:[scrollbar-gutter:stable] [&_[data-slot=table-header]]:sticky [&_[data-slot=table-header]]:top-0 [&_[data-slot=table-header]]:z-20 [&_[data-slot=table-header]]:bg-background" : ""}`}
|
2026-10-07 15:27:23 +00:00
|
|
|
>
|
2026-10-15 13:58:01 +00:00
|
|
|
<div className="flex shrink-0 flex-wrap items-start justify-between gap-3 border-b p-4">
|
2026-10-07 15:27:23 +00:00
|
|
|
<div className="w-full sm:max-w-sm">
|
|
|
|
|
<label className="sr-only" htmlFor={`${id}-search`}>
|
|
|
|
|
{searchPlaceholder ?? labels.search}
|
|
|
|
|
</label>
|
|
|
|
|
<Input
|
2026-10-16 09:41:38 +00:00
|
|
|
data-quick-search=""
|
2026-10-07 15:27:23 +00:00
|
|
|
id={`${id}-search`}
|
|
|
|
|
onChange={(event) => (onSearchChange ?? setLocalSearch)(event.target.value)}
|
|
|
|
|
placeholder={searchPlaceholder ?? labels.placeholder}
|
|
|
|
|
value={currentSearch}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex min-w-0 flex-wrap items-center justify-end gap-3 [&>fieldset]:min-w-40">
|
|
|
|
|
{filters}
|
|
|
|
|
{(currentSearch || filtersApplied) && (
|
|
|
|
|
<Button onClick={clear} size="sm" variant="outline">
|
|
|
|
|
{labels.clear}
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-10-15 13:58:01 +00:00
|
|
|
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b px-4 py-3">
|
2026-10-07 15:27:23 +00:00
|
|
|
<p aria-live="polite" className="text-sm text-muted-foreground">
|
|
|
|
|
{info}
|
|
|
|
|
</p>
|
|
|
|
|
<EntityTableConfiguration
|
|
|
|
|
locale={locale}
|
|
|
|
|
onSort={() => setPagination((previous) => ({ ...previous, pageIndex: 0 }))}
|
|
|
|
|
table={table}
|
2026-10-10 10:01:58 +00:00
|
|
|
/>
|
2026-10-07 15:27:23 +00:00
|
|
|
</div>
|
2026-10-09 21:00:34 +00:00
|
|
|
|
2026-10-07 15:27:23 +00:00
|
|
|
<EntityTableContent
|
|
|
|
|
columns={columns}
|
|
|
|
|
loading={loading}
|
|
|
|
|
message={message}
|
2026-10-15 10:12:53 +00:00
|
|
|
onRowClick={onRowClick}
|
2026-10-07 15:27:23 +00:00
|
|
|
onSort={() => setPagination((previous) => ({ ...previous, pageIndex: 0 }))}
|
|
|
|
|
rowClassName={rowClassName}
|
|
|
|
|
table={table}
|
|
|
|
|
title={title}
|
|
|
|
|
unavailable={unavailable}
|
2026-10-10 10:01:58 +00:00
|
|
|
/>
|
|
|
|
|
|
2026-10-15 13:58:01 +00:00
|
|
|
<footer className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-t px-4 py-3">
|
2026-10-07 15:27:23 +00:00
|
|
|
<p className="text-sm text-muted-foreground">{info}</p>
|
|
|
|
|
<EntityListPagination
|
|
|
|
|
id={id}
|
|
|
|
|
locale={locale}
|
|
|
|
|
onPage={(pageIndex) => setPagination({ ...pagination, pageIndex })}
|
|
|
|
|
onPageSize={(pageSize) => setPagination({ pageIndex: 0, pageSize })}
|
|
|
|
|
page={range.page}
|
|
|
|
|
pageSize={pagination.pageSize}
|
|
|
|
|
pages={range.pages}
|
|
|
|
|
/>
|
|
|
|
|
</footer>
|
|
|
|
|
</section>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function entityTableInfo(
|
|
|
|
|
loading: boolean,
|
|
|
|
|
unavailable: boolean,
|
|
|
|
|
loadingLabel: string,
|
|
|
|
|
info: string
|
|
|
|
|
) {
|
|
|
|
|
if (loading) {
|
|
|
|
|
return loadingLabel;
|
|
|
|
|
}
|
2026-10-10 10:01:58 +00:00
|
|
|
|
2026-10-07 15:27:23 +00:00
|
|
|
return unavailable ? "—" : info;
|
|
|
|
|
}
|
2026-10-10 10:01:58 +00:00
|
|
|
|
2026-10-07 15:27:23 +00:00
|
|
|
function entityTableMessage(
|
|
|
|
|
loading: boolean,
|
|
|
|
|
unavailable: boolean,
|
|
|
|
|
filtered: boolean,
|
|
|
|
|
labels: ReturnType<typeof entityListLabels>,
|
|
|
|
|
emptyMessage: string | undefined
|
|
|
|
|
) {
|
|
|
|
|
if (loading) {
|
|
|
|
|
return labels.loading;
|
|
|
|
|
}
|
|
|
|
|
if (unavailable) {
|
|
|
|
|
return labels.unavailable;
|
|
|
|
|
}
|
2026-10-10 10:01:58 +00:00
|
|
|
|
2026-10-07 15:27:23 +00:00
|
|
|
return filtered ? labels.noResults : (emptyMessage ?? labels.empty);
|
|
|
|
|
}
|