Files
emaui/libs/ui/src/lib/data/AdvancedTable.tsx
Estifo77 173d6eccb6 fixes
2026-07-28 10:26:55 +03:00

217 lines
6.2 KiB
TypeScript

import { ReactNode, useState } from "react";
import {
Table,
Button,
Menu,
Checkbox,
Group,
Text,
Pagination,
Loader,
Center,
Paper,
Badge,
} from "@mantine/core";
import { IconRefresh, IconEye, IconInbox } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
export interface AdvancedColumn<T> {
header: ReactNode;
/** Dot-path into the row, used when no `cell` is given (e.g. "expectation.name"). */
accessorKey?: string;
cell?: (ctx: { row: { original: T }; value: unknown }) => ReactNode;
size?: number;
align?: "left" | "center" | "right";
/** Whether column starts visible. Default true. */
enabled?: boolean;
}
interface AdvancedTableProps<T> {
columns: AdvancedColumn<T>[];
data: T[];
tableName: string;
/** Total item count on the server (drives pagination), not data.length. */
itemCount: number;
/** 0-based current page. */
pageIndex: number;
onPageChange: (pageIndex: number) => void;
pageSize?: number;
refresh?: () => void;
/** Server-side search — debounced internally. Omit to hide the search box. */
onSearchChange?: (q: string) => void;
isLoading?: boolean;
emptyText?: string;
}
function getByPath(obj: unknown, path?: string): unknown {
if (!path) return undefined;
return path
.split(".")
.reduce<unknown>(
(acc, key) =>
acc && typeof acc === "object"
? (acc as Record<string, unknown>)[key]
: undefined,
obj,
);
}
export function AdvancedTable<T extends { id?: string | number }>({
columns,
data,
tableName,
itemCount,
pageIndex,
onPageChange,
pageSize = 10,
refresh,
isLoading = false,
emptyText,
}: AdvancedTableProps<T>) {
const { t } = useTranslation();
const [visible, setVisible] = useState<boolean[]>(
columns.map((c) => c.enabled ?? true),
);
const toggleColumn = (i: number) =>
setVisible((prev) => {
if (prev[i] && prev.filter(Boolean).length === 1) return prev; // keep at least one column visible
return prev.map((v, idx) => (idx === i ? !v : v));
});
const shownColumns = columns.filter((_, i) => visible[i] ?? true);
return (
<Paper radius="md" p="md">
<Group justify="space-between" mb="md">
<Group gap="xs">
<Text fw={600}>{""}</Text>
</Group>
<Group gap="xs">
<Menu
closeOnItemClick={false}
shadow="md"
position="bottom-end"
width={220}
>
{refresh && (
<Button
variant="default"
size="sm"
leftSection={<IconRefresh size={16} />}
onClick={refresh}
loading={isLoading}
>
{t("common.refresh", "Refresh")}
</Button>
)}
<Menu.Target>
<Button
variant="default"
size="sm"
leftSection={<IconEye size={16} />}
>
{t("common.view", "View")}
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>
{t("common.toggleColumns", "Toggle columns")}
</Menu.Label>
{columns.map((col, i) => (
<Menu.Item key={i} onClick={() => toggleColumn(i)}>
<Checkbox
label={col.header}
checked={visible[i] ?? true}
disabled={
visible.filter(Boolean).length === 1 &&
(visible[i] ?? true)
}
readOnly
tabIndex={-1}
styles={{
input: { cursor: "pointer" },
label: { cursor: "pointer" },
}}
/>
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
</Group>
</Group>
<Table.ScrollContainer minWidth={480}>
<Table
striped
highlightOnHover
withTableBorder
withColumnBorders
verticalSpacing="sm"
>
<Table.Thead>
<Table.Tr>
{shownColumns.map((col, i) => (
<Table.Th
key={i}
style={{ width: col.size, textAlign: col.align }}
>
{col.header}
</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{isLoading ? (
<Table.Tr>
<Table.Td colSpan={shownColumns.length}>
<Center py="xl">
<Loader size="sm" />
</Center>
</Table.Td>
</Table.Tr>
) : data.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={shownColumns.length}>
<Center py="xl">
<Group gap="xs" c="dimmed">
<IconInbox size={18} />
<Text c="dimmed">
{emptyText ?? t("common.noResult", "No results")}
</Text>
</Group>
</Center>
</Table.Td>
</Table.Tr>
) : (
data.map((row, rowIndex) => (
<Table.Tr key={row.id ?? rowIndex}>
{shownColumns.map((col, i) => {
const value = getByPath(row, col.accessorKey);
return (
<Table.Td key={i} style={{ textAlign: col.align }}>
{col.cell
? col.cell({ row: { original: row }, value })
: ((value as ReactNode) ?? "-")}
</Table.Td>
);
})}
</Table.Tr>
))
)}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
{itemCount > pageSize && (
<Group justify="flex-end" mt="md">
<Pagination
total={Math.ceil(itemCount / pageSize)}
value={pageIndex + 1}
onChange={(page) => onPageChange(page - 1)}
size="sm"
/>
</Group>
)}
</Paper>
);
}