ai_api / src /shared /components /DataTable.tsx
Yogesh
initial deploy
cd8bd0a
Raw
History Blame Contribute Delete
5.46 kB
"use client";
import { useTranslations } from "next-intl";
/**
* DataTable — Shared UI primitive (T-29)
*
* Configurable data table with sticky header, row click,
* and optional loading/empty states. Extracts the shared
* table rendering pattern from RequestLoggerV2 and ProxyLogger.
*
* Usage:
* <DataTable
* columns={visibleColumns}
* data={filteredLogs}
* renderCell={(row, column) => <span>{row[column.key]}</span>}
* onRowClick={(row) => openDetail(row)}
* selectedId={selectedLog?.id}
* loading={isLoading}
* emptyIcon="📋"
* emptyMessage="No logs found"
* />
*/
interface DataTableColumn {
key: string;
label: string;
maxWidth?: string;
}
interface DataTableRow {
id?: string | number;
[key: string]: unknown;
}
interface DataTableProps {
columns?: DataTableColumn[];
data?: DataTableRow[];
renderCell: (row: DataTableRow, column: DataTableColumn) => React.ReactNode;
renderHeader?: (column: DataTableColumn) => React.ReactNode;
onRowClick?: (row: DataTableRow) => void;
selectedId?: string | number;
loading?: boolean;
maxHeight?: string;
emptyIcon?: string;
emptyMessage?: string;
}
export default function DataTable({
columns = [],
data = [],
renderCell,
renderHeader,
onRowClick,
selectedId,
loading = false,
maxHeight = "calc(100vh - 320px)",
emptyIcon = "📭",
emptyMessage,
}: DataTableProps) {
const t = useTranslations("common");
const resolvedEmptyMessage = emptyMessage ?? t("noData");
if (loading) {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "48px 24px",
color: "var(--color-text-muted)",
fontSize: "14px",
}}
>
<span style={{ animation: "spin 1s linear infinite", marginRight: "8px" }}></span>
{t("loading")}
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div>
);
}
if (data.length === 0) {
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
padding: "48px 24px",
color: "var(--color-text-muted)",
fontSize: "14px",
}}
>
<span style={{ fontSize: "32px", marginBottom: "8px", opacity: 0.6 }}>{emptyIcon}</span>
{resolvedEmptyMessage}
</div>
);
}
return (
<div
style={{
overflow: "auto",
maxHeight,
borderRadius: "8px",
// Opaque surface so the body grid wallpaper never bleeds through the
// transparent even-rows / low-alpha zebra when the table renders card-less.
background: "var(--color-surface)",
}}
>
<table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: "12px",
tableLayout: "auto",
}}
>
<thead>
<tr>
{columns.map((col) => (
<th
key={col.key}
style={{
padding: "8px 10px",
textAlign: "left",
fontWeight: 600,
color: "var(--color-text-muted)",
borderBottom: "1px solid var(--color-border)",
position: "sticky",
top: 0,
background: "var(--table-header-bg)",
zIndex: 1,
whiteSpace: "nowrap",
fontSize: "11px",
textTransform: "uppercase",
letterSpacing: "0.5px",
}}
>
{renderHeader ? renderHeader(col) : col.label}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, idx) => (
<tr
key={row.id || idx}
onClick={() => onRowClick?.(row)}
style={{
cursor: onRowClick ? "pointer" : "default",
background:
row.id === selectedId
? "var(--table-row-selected)"
: idx % 2 === 0
? "transparent"
: "var(--table-row-zebra)",
transition: "background 0.15s",
}}
onMouseEnter={(e) => {
if (row.id !== selectedId) {
e.currentTarget.style.background = "var(--table-row-hover)";
}
}}
onMouseLeave={(e) => {
if (row.id !== selectedId) {
e.currentTarget.style.background =
idx % 2 === 0 ? "transparent" : "var(--table-row-zebra)";
}
}}
>
{columns.map((col) => (
<td
key={col.key}
style={{
padding: "6px 10px",
borderBottom: "1px solid var(--table-cell-border)",
whiteSpace: "nowrap",
maxWidth: col.maxWidth || "200px",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{renderCell(row, col)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}