Files
Checkmate/client/src/Components/design-elements/Table.tsx
T

591 lines
14 KiB
TypeScript
Raw Normal View History

2026-01-23 23:54:58 +00:00
import Stack from "@mui/material/Stack";
2026-02-01 00:49:35 +00:00
import Grid2 from "@mui/material/Grid";
2026-01-23 23:54:58 +00:00
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import Paper from "@mui/material/Paper";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
import Collapse from "@mui/material/Collapse";
import Tooltip from "@mui/material/Tooltip";
import IconButton from "@mui/material/IconButton";
import {
ChevronsLeft,
ChevronsRight,
ChevronLeft,
ChevronRight,
Ellipsis,
} from "lucide-react";
import { EmptyState } from "./EmptyState";
2026-01-23 23:54:58 +00:00
import TablePagination from "@mui/material/TablePagination";
2026-01-24 00:29:19 +00:00
import type { TablePaginationOwnProps } from "@mui/material/TablePagination";
2026-01-23 23:54:58 +00:00
import { useTranslation } from "react-i18next";
2026-02-05 18:30:43 +00:00
import { useState, Fragment, useEffect } from "react";
2026-01-23 23:54:58 +00:00
import { useTheme } from "@mui/material/styles";
2026-02-28 16:30:04 +00:00
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
2026-02-05 21:40:36 +00:00
import type { SxProps, Theme } from "@mui/material/styles";
2026-01-23 23:54:58 +00:00
import useMediaQuery from "@mui/material/useMediaQuery";
export type Header<T> = {
id: number | string;
content: React.ReactNode;
mobileLabel?: React.ReactNode;
align?: "left" | "center" | "right" | "justify" | "inherit";
2026-01-23 23:54:58 +00:00
onClick?: (event: React.MouseEvent<HTMLTableCellElement | null>, row: T) => void;
render: (row: T) => React.ReactNode;
};
type DataTableProps<T extends { id?: string | number; _id?: string | number }> = {
headers: Header<T>[];
data: T[];
onRowClick?: (row: T) => void;
cardsOnSmallScreens?: boolean;
expandableRows?: boolean;
renderExpandedContent?: (row: T) => React.ReactNode;
emptyViewText?: string;
emptyViewPositive?: boolean;
2026-02-05 21:40:36 +00:00
getRowSx?: (row: T) => SxProps<Theme>;
2026-01-23 23:54:58 +00:00
};
export function DataTable<
T extends {
id?: string | number;
_id?: string | number;
onRowClick?: (row: T) => void;
},
>({
headers,
data,
onRowClick,
cardsOnSmallScreens = true,
expandableRows = false,
renderExpandedContent,
emptyViewText,
emptyViewPositive,
2026-02-05 21:40:36 +00:00
getRowSx,
2026-01-23 23:54:58 +00:00
}: DataTableProps<T>) {
const theme = useTheme();
const [expanded, setExpanded] = useState<(string | number) | null>(null);
const handleExpand = (row: T) => {
const key = row.id || row._id || null;
setExpanded(expanded === key ? null : key);
};
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
if (data.length === 0 || headers.length === 0) {
return (
<EmptyView
text={emptyViewText}
positive={emptyViewPositive}
/>
);
}
const keys = [];
// Return stack of cards for small screens
if (isSmall && cardsOnSmallScreens) {
return (
2026-02-28 16:30:04 +00:00
<Stack spacing={theme.spacing(LAYOUT.XS)}>
2026-01-23 23:54:58 +00:00
{data.map((row) => {
const key = row.id || row._id || Math.random();
keys.push(key);
return (
<Stack
onClick={() => (onRowClick ? onRowClick(row) : null)}
2026-02-28 16:30:04 +00:00
spacing={theme.spacing(LAYOUT.XS)}
2026-01-23 23:54:58 +00:00
sx={{
borderStyle: "solid",
borderWidth: 1,
borderColor: theme.palette.divider,
borderRadius: theme.shape.borderRadius,
2026-02-28 16:30:04 +00:00
padding: theme.spacing(LAYOUT.XS),
2026-01-23 23:54:58 +00:00
cursor: onRowClick ? "pointer" : "default",
}}
key={key}
>
{headers.map((header) => {
if (header.mobileLabel === null) {
return (
<Grid2
container
key={header.id}
>
<Grid2
size={12}
display="flex"
alignItems="center"
justifyContent="flex-end"
>
{header.render(row)}
</Grid2>
</Grid2>
);
}
2026-01-23 23:54:58 +00:00
return (
<Grid2
container
key={header.id}
>
<Grid2
size={5}
display={"flex"}
alignItems={"center"}
>
<Typography
component="div"
color={theme.palette.text.primary}
>
{header.mobileLabel !== undefined
? header.mobileLabel
: header.content}
2026-01-23 23:54:58 +00:00
</Typography>
</Grid2>
<Grid2
size={7}
display="flex"
alignItems={"center"}
>
{header.render(row)}{" "}
</Grid2>
</Grid2>
);
})}
{expandableRows && renderExpandedContent && renderExpandedContent(row) && (
<Grid2 size={12}>{renderExpandedContent(row)}</Grid2>
)}
</Stack>
);
})}
</Stack>
);
}
return (
<TableContainer
component={Paper}
elevation={0}
sx={{ boxShadow: "none", overflowX: "hidden" }}
2026-01-23 23:54:58 +00:00
>
<Table
sx={{
"&.MuiTable-root :is(.MuiTableHead-root, .MuiTableBody-root) :is(th, td)": {
2026-02-28 16:30:04 +00:00
paddingLeft: theme.spacing(LAYOUT.MD),
2026-01-23 23:54:58 +00:00
},
"& .MuiTableCell-root": {
borderBottom: `1px solid ${theme.palette.divider}`,
},
"& .MuiTableHead-root .MuiTableRow-root": {
height: "28px",
borderBottom: `1px solid ${theme.palette.divider}`,
},
"& .MuiTableHead-root .MuiTableCell-root": {
borderBottom: "none",
2026-01-23 23:54:58 +00:00
},
"& :is(th)": {
backgroundColor: theme.palette.background.paper,
color: theme.palette.text.secondary,
fontWeight: 500,
textTransform: "uppercase",
letterSpacing: "0.08em",
2026-02-28 16:30:04 +00:00
padding: `${theme.spacing(SPACING.LG)} ${theme.spacing(LAYOUT.MD)}`,
fontSize: 11,
2026-01-23 23:54:58 +00:00
},
"& :is(td)": {
backgroundColor: theme.palette.background.paper,
color: theme.palette.text.secondary,
2026-02-28 16:30:04 +00:00
padding: `${theme.spacing(LAYOUT.MD)} ${theme.spacing(LAYOUT.MD)}`,
2026-01-23 23:54:58 +00:00
fontSize: theme.typography.fontSize,
overflowX: "hidden",
2026-01-23 23:54:58 +00:00
},
"& .MuiTableBody-root .MuiTableRow-root:last-child .MuiTableCell-root": {
borderBottom: "none",
},
}}
>
<TableHead>
<TableRow>
{headers.map((header, idx) => {
return (
<TableCell
align={header.align ?? (idx === 0 ? "left" : "center")}
2026-01-23 23:54:58 +00:00
key={header.id}
>
{header.content}
</TableCell>
);
})}
</TableRow>
</TableHead>
<TableBody>
{data.map((row) => {
const key = row.id || row._id || Math.random();
const isExpanded = expanded === key;
return (
<Fragment key={key}>
<TableRow
sx={{
cursor: onRowClick ? "pointer" : "default",
2026-02-05 21:40:36 +00:00
...(getRowSx?.(row) as object),
2026-01-23 23:54:58 +00:00
}}
onClick={() => {
if (expandableRows) handleExpand(row);
else if (onRowClick) onRowClick(row);
}}
>
{headers.map((header, index) => {
return (
<TableCell
align={header.align ?? (index === 0 ? "left" : "center")}
2026-01-23 23:54:58 +00:00
key={header.id}
onClick={
header.onClick ? (e) => header.onClick!(e, row) : undefined
}
>
{header.render(row)}
</TableCell>
);
})}
</TableRow>
{expandableRows &&
renderExpandedContent &&
renderExpandedContent(row) && (
<TableRow>
<TableCell
colSpan={headers.length}
style={{
borderBottom: isExpanded ? undefined : "none",
paddingTop: 0,
paddingBottom: 0,
}}
>
<Collapse
in={isExpanded}
timeout="auto"
unmountOnExit
>
<Box sx={{ pt: 4, pb: 4 }}>{renderExpandedContent(row)}</Box>
</Collapse>
</TableCell>
</TableRow>
)}
</Fragment>
);
})}
</TableBody>
</Table>
</TableContainer>
);
}
interface TablePaginationActionsProps {
count: number;
page: number;
rowsPerPage: number;
onPageChange: (event: React.MouseEvent<HTMLButtonElement>, newPage: number) => void;
}
function TablePaginationActions(props: TablePaginationActionsProps) {
const theme = useTheme();
const { count, page, rowsPerPage, onPageChange } = props;
const handleFirstPageButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
onPageChange(event, 0);
};
const handleBackButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
onPageChange(event, page - 1);
};
const handleNextButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
onPageChange(event, page + 1);
};
const handleLastPageButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
onPageChange(event, Math.max(0, Math.ceil(count / rowsPerPage) - 1));
};
return (
<Box
sx={{ flexShrink: 0, ml: { xs: 0, md: 2.5 } }}
className="table-pagination-actions"
>
<IconButton
onClick={handleFirstPageButtonClick}
disabled={page === 0}
aria-label="first page"
>
{theme.direction === "rtl" ? (
<ChevronsRight
size={20}
strokeWidth={1.5}
/>
) : (
<ChevronsLeft
size={20}
strokeWidth={1.5}
/>
)}
</IconButton>
<IconButton
onClick={handleBackButtonClick}
disabled={page === 0}
aria-label="previous page"
>
{theme.direction === "rtl" ? (
<ChevronRight
size={20}
strokeWidth={1.5}
/>
) : (
<ChevronLeft
size={20}
strokeWidth={1.5}
/>
)}
</IconButton>
<IconButton
onClick={handleNextButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="next page"
>
{theme.direction === "rtl" ? (
<ChevronLeft
size={20}
strokeWidth={1.5}
/>
) : (
<ChevronRight
size={20}
strokeWidth={1.5}
/>
)}
</IconButton>
<IconButton
onClick={handleLastPageButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="last page"
>
{theme.direction === "rtl" ? (
<ChevronsLeft
size={20}
strokeWidth={1.5}
/>
) : (
<ChevronsRight
size={20}
strokeWidth={1.5}
/>
)}
</IconButton>
</Box>
);
}
interface HasMoreTablePaginationActionsProps {
hasMore?: boolean;
page: number;
onPageChange: (event: React.MouseEvent<HTMLButtonElement>, newPage: number) => void;
}
function HasMoreTablePaginationActions(props: HasMoreTablePaginationActionsProps) {
const theme = useTheme();
const { hasMore, page, onPageChange } = props;
const handleFirstPageButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
onPageChange(event, 0);
};
const handleBackButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
onPageChange(event, page - 1);
};
const handleNextButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
onPageChange(event, page + 1);
};
return (
<Box
sx={{ flexShrink: 0, ml: { xs: 0, md: 2.5 } }}
className="table-pagination-actions"
>
<IconButton
onClick={handleFirstPageButtonClick}
disabled={page === 0}
aria-label="first page"
>
{theme.direction === "rtl" ? (
<ChevronsRight
size={20}
strokeWidth={1.5}
/>
) : (
<ChevronsLeft
size={20}
strokeWidth={1.5}
/>
)}
</IconButton>
<IconButton
onClick={handleBackButtonClick}
disabled={page === 0}
aria-label="previous page"
>
{theme.direction === "rtl" ? (
<ChevronRight
size={20}
strokeWidth={1.5}
/>
) : (
<ChevronLeft
size={20}
strokeWidth={1.5}
/>
)}
</IconButton>
<IconButton
onClick={handleNextButtonClick}
disabled={hasMore === false}
aria-label="next page"
>
{theme.direction === "rtl" ? (
<ChevronLeft
size={20}
strokeWidth={1.5}
/>
) : (
<ChevronRight
size={20}
strokeWidth={1.5}
/>
)}
</IconButton>
<Tooltip title={hasMore === true ? "More pages available" : "No more pages"}>
<IconButton
disabled={hasMore === false}
aria-label="next page"
>
<Ellipsis
size={20}
strokeWidth={1.5}
/>
</IconButton>
</Tooltip>
</Box>
);
}
interface PaginationProps extends TablePaginationOwnProps {
component?: React.ElementType;
hasMore?: boolean;
2026-02-05 18:30:43 +00:00
itemsOnPage?: number;
2026-01-23 23:54:58 +00:00
}
export const Pagination = ({ ...props }: PaginationProps) => {
2026-02-05 18:30:43 +00:00
const { hasMore, itemsOnPage, ...rest } = props;
2026-01-23 23:54:58 +00:00
const isSmall = useMediaQuery((theme: any) => theme.breakpoints.down("sm"));
const theme = useTheme();
2026-02-05 18:30:43 +00:00
useEffect(() => {
if (
typeof itemsOnPage === "number" &&
itemsOnPage === 0 &&
rest.count > 0 &&
rest.page > 0 &&
rest.onPageChange
) {
rest.onPageChange(null, rest.page - 1);
}
}, [itemsOnPage, rest.count, rest.page, rest.onPageChange]);
2026-01-23 23:54:58 +00:00
const labelDisplayedRows = ({
from,
to,
count,
}: {
from: number;
to: number;
count: number;
}) => {
if (hasMore) {
return to === 0 ? "" : `${from}–${to}`;
}
return `${from}–${to} of ${count}`;
};
return (
<TablePagination
ActionsComponent={(props) =>
typeof hasMore === "boolean" ? (
<HasMoreTablePaginationActions
{...props}
hasMore={hasMore}
/>
) : (
<TablePaginationActions {...props} />
)
}
rowsPerPageOptions={[5, 10, 25]}
labelDisplayedRows={labelDisplayedRows}
{...rest}
sx={{
"& .MuiTablePagination-toolbar": isSmall
? {
display: "grid",
gridTemplateColumns: "1fr 1fr",
gridAutoRows: "auto",
2026-02-28 16:30:04 +00:00
rowGap: theme.spacing(SPACING.SM),
2026-01-23 23:54:58 +00:00
alignItems: "center",
justifyItems: "center",
paddingLeft: 0,
paddingRight: 0,
}
: { display: "flex", alignItems: "center" },
"& .MuiTablePagination-selectLabel": {
gridColumn: "1",
gridRow: "1",
justifySelf: "center",
},
"& .MuiTablePagination-select": {
gridColumn: "2",
gridRow: "1",
justifySelf: "center",
},
"& .MuiTablePagination-displayedRows": isSmall
? { gridColumn: "1 / span 2", gridRow: "2", justifySelf: "center" }
: {},
"& .table-pagination-actions": isSmall
? { gridColumn: "1 / span 2", gridRow: "3", justifySelf: "center" }
: {},
"& .MuiSelect-select": {
border: 1,
borderColor: theme.palette.divider,
borderRadius: theme.shape.borderRadius,
},
}}
/>
);
};
const EmptyView = ({ text }: { text?: string; positive?: boolean }) => {
2026-01-23 23:54:58 +00:00
const { t } = useTranslation();
const theme = useTheme();
2026-01-23 23:54:58 +00:00
return (
<Box
2026-01-23 23:54:58 +00:00
sx={{
border: `1px solid ${theme.palette.divider}`,
borderRadius: 1,
backgroundColor: theme.palette.background.paper,
2026-01-23 23:54:58 +00:00
}}
>
<EmptyState title={text ?? t("common.table.empty")} />
</Box>
2026-01-23 23:54:58 +00:00
);
};