242 lines
7.3 KiB
TypeScript
242 lines
7.3 KiB
TypeScript
"use client";
|
|
|
|
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
|
import clsx from "clsx";
|
|
import {
|
|
ChevronUp,
|
|
ArrowDownUp,
|
|
ArrowDownWideNarrow,
|
|
ArrowDownNarrowWide,
|
|
} from "lucide-react";
|
|
|
|
/* ================= TYPES ================= */
|
|
interface ColumnType<T> {
|
|
title: string | React.ReactNode;
|
|
render: (row: T, index: number, path?: number[]) => React.ReactNode;
|
|
className?: string;
|
|
checkBox?: boolean;
|
|
fixedLeft?: boolean;
|
|
fixedRight?: boolean;
|
|
maxWidth?: number | string;
|
|
sortTable?: string;
|
|
}
|
|
|
|
interface PropsTable<T> {
|
|
data: T[];
|
|
column: ColumnType<T>[];
|
|
fixedHeader?: boolean;
|
|
activeHeader?: boolean;
|
|
handleCheckedAll?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
|
isCheckedAll?: boolean;
|
|
handleCheckedRow?: (e: React.ChangeEvent<HTMLInputElement>, row: T) => void;
|
|
handleIsCheckedRow?: (row: T) => boolean;
|
|
rowKey: (row: T) => React.Key;
|
|
getChildren?: (row: T) => T[] | undefined;
|
|
useIndexPathAsKey?: boolean;
|
|
}
|
|
|
|
/* ================= COMPONENT ================= */
|
|
export default function Table<T>({
|
|
data,
|
|
column,
|
|
fixedHeader,
|
|
activeHeader,
|
|
handleCheckedAll,
|
|
isCheckedAll,
|
|
handleCheckedRow,
|
|
handleIsCheckedRow,
|
|
rowKey,
|
|
getChildren,
|
|
useIndexPathAsKey,
|
|
}: PropsTable<T>) {
|
|
const tableRef = useRef<HTMLDivElement>(null);
|
|
const thRefs = useRef<(HTMLTableCellElement | null)[]>([]);
|
|
|
|
const [expandedRows, setExpandedRows] = useState<React.Key[]>([]);
|
|
const [sortConfig, setSortConfig] = useState<{
|
|
key: string;
|
|
direction: "asc" | "desc" | null;
|
|
}>({ key: "", direction: null });
|
|
|
|
/* ================= SORT ================= */
|
|
const handleSort = (key: string) => {
|
|
setSortConfig((prev) => {
|
|
if (prev.key === key) {
|
|
const next =
|
|
prev.direction === "asc"
|
|
? "desc"
|
|
: prev.direction === "desc"
|
|
? null
|
|
: "asc";
|
|
return { key, direction: next };
|
|
}
|
|
return { key, direction: "asc" };
|
|
});
|
|
};
|
|
|
|
const sortedData = useMemo(() => {
|
|
if (!sortConfig.key || !sortConfig.direction) return data;
|
|
|
|
const copy = [...data];
|
|
|
|
copy.sort((a: any, b: any) => {
|
|
const aVal = a?.[sortConfig.key];
|
|
const bVal = b?.[sortConfig.key];
|
|
|
|
if (typeof aVal === "number" && typeof bVal === "number") {
|
|
return sortConfig.direction === "asc" ? aVal - bVal : bVal - aVal;
|
|
}
|
|
|
|
if (typeof aVal === "string" && typeof bVal === "string") {
|
|
return sortConfig.direction === "asc"
|
|
? aVal.localeCompare(bVal)
|
|
: bVal.localeCompare(aVal);
|
|
}
|
|
|
|
return 0;
|
|
});
|
|
|
|
return copy;
|
|
}, [data, sortConfig]);
|
|
|
|
/* ================= EXPAND ================= */
|
|
const toggleExpand = (key: React.Key) => {
|
|
setExpandedRows((prev) =>
|
|
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
|
|
);
|
|
};
|
|
|
|
/* ================= ROWS ================= */
|
|
const renderRows = (rows: T[], path: number[] = []): React.ReactNode =>
|
|
rows.map((row, i) => {
|
|
const currentPath = [...path, i];
|
|
const keyPath = useIndexPathAsKey ? currentPath.join(".") : rowKey(row);
|
|
|
|
const children = getChildren?.(row);
|
|
const expanded = expandedRows.includes(keyPath);
|
|
|
|
return (
|
|
<Fragment key={String(keyPath)}>
|
|
<tr className="bg-white border-b">
|
|
{getChildren && (
|
|
<td className="w-[60px] text-center">
|
|
{!!children?.length && (
|
|
<button
|
|
onClick={() => toggleExpand(keyPath)}
|
|
className={clsx(
|
|
"transition text-gray-400 hover:text-blue-500",
|
|
expanded && "rotate-180 text-blue-500",
|
|
)}
|
|
>
|
|
<ChevronUp size={18} />
|
|
</button>
|
|
)}
|
|
</td>
|
|
)}
|
|
|
|
{column.map((col, idx) => (
|
|
<td
|
|
key={idx}
|
|
className={clsx(
|
|
"px-4 py-3 text-sm font-medium whitespace-nowrap",
|
|
col.fixedLeft && "sticky left-0 bg-white z-10",
|
|
col.fixedRight && "sticky right-0 bg-white z-10",
|
|
)}
|
|
>
|
|
<div
|
|
style={{
|
|
maxWidth:
|
|
typeof col.maxWidth === "number"
|
|
? `${col.maxWidth}px`
|
|
: col.maxWidth,
|
|
}}
|
|
className={clsx("flex items-center gap-2")}
|
|
>
|
|
{col.checkBox && (
|
|
<input
|
|
type="checkbox"
|
|
className="w-4 h-4 accent-blue-500 cursor-pointer"
|
|
onChange={(e) => handleCheckedRow?.(e, row)}
|
|
checked={handleIsCheckedRow?.(row) ?? false}
|
|
/>
|
|
)}
|
|
{col.render(row, i, currentPath)}
|
|
</div>
|
|
</td>
|
|
))}
|
|
</tr>
|
|
|
|
{expanded && children && renderRows(children, currentPath)}
|
|
</Fragment>
|
|
);
|
|
});
|
|
|
|
/* ================= UI ================= */
|
|
return (
|
|
<div
|
|
ref={tableRef}
|
|
className={clsx(
|
|
"overflow-auto pb-2",
|
|
fixedHeader && "[&>table>thead]:sticky [&>table>thead]:top-0",
|
|
activeHeader && "[&>table>thead]:bg-[#F4F7FA]",
|
|
)}
|
|
>
|
|
<table className="w-full border-collapse">
|
|
<thead className="bg-white border-b">
|
|
<tr>
|
|
{getChildren && <th className="w-[60px]" />}
|
|
|
|
{column.map((col, i) => {
|
|
const isActive = col.sortTable === sortConfig.key;
|
|
|
|
return (
|
|
<th
|
|
key={i}
|
|
ref={(el) => {
|
|
thRefs.current[i] = el;
|
|
}}
|
|
onClick={() => col.sortTable && handleSort(col.sortTable)}
|
|
className={clsx(
|
|
"px-4 py-3 text-left text-[15px] font-semibold whitespace-nowrap",
|
|
col.sortTable && "cursor-pointer",
|
|
isActive && "text-blue-600",
|
|
col.fixedLeft && "sticky left-0 bg-white z-10",
|
|
col.fixedRight && "sticky right-0 bg-white z-10",
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-1">
|
|
{col.checkBox && (
|
|
<input
|
|
type="checkbox"
|
|
onChange={handleCheckedAll}
|
|
checked={isCheckedAll}
|
|
/>
|
|
)}
|
|
|
|
{col.title}
|
|
|
|
{col.sortTable &&
|
|
(isActive ? (
|
|
sortConfig.direction === "asc" ? (
|
|
<ArrowDownNarrowWide size={14} />
|
|
) : sortConfig.direction === "desc" ? (
|
|
<ArrowDownWideNarrow size={14} />
|
|
) : (
|
|
<ArrowDownUp size={14} />
|
|
)
|
|
) : (
|
|
<ArrowDownUp size={14} />
|
|
))}
|
|
</div>
|
|
</th>
|
|
);
|
|
})}
|
|
</tr>
|
|
</thead>
|
|
|
|
<tbody>{renderRows(sortedData)}</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|