metms_fe/src/components/customs/custom-table.tsx
2026-05-23 09:48:07 +07:00

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>
);
}