mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
217 lines
6.2 KiB
TypeScript
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>
|
|
);
|
|
}
|