2026-10-07 12:49:10 +03:00

224 lines
6.7 KiB
TypeScript

import { useLayoutEffect, useMemo, useRef, useState } from "react";
import { Table } from "antd";
import Icon, { FilterFilled, FilterOutlined } from "@ant-design/icons";
import type { ColumnGroupType, ColumnsType, ColumnType } from "antd/es/table";
import type { TableProps } from "antd";
import { CustomFilterDropdown } from "./CustomFilterDropdown";
import { Highlight } from "./Highlight";
import {
isGroup,
LeafColumnDef,
type ColumnFilters,
type FilterOption,
type SortState,
type TableConfig,
} from "./tableTypes";
type ExtraColumn<T> = ColumnType<T> & {
position?: number | "start" | "end";
afterKey?: Extract<keyof T, string>;
};
interface DataTableProps<T extends Record<string, any>> {
config: TableConfig<T>;
rows: T[];
allRows: T[];
loading?: boolean;
visibleKeys: Array<Extract<keyof T, string>>;
globalQuery?: string;
columnFilters: ColumnFilters<T>;
onColumnFiltersChange: (next: ColumnFilters<T>) => void;
sortState: SortState<T>;
onSortChange: (next: SortState<T>) => void;
extraColumns?: ColumnsType<T>;
rowSelection?: TableProps<T>["rowSelection"];
scrollY?: number | string;
onRow?: TableProps<T>["onRow"];
}
export function DataTable<T extends Record<string, any>>({
config,
rows,
allRows,
loading,
visibleKeys,
globalQuery = "",
columnFilters,
onColumnFiltersChange,
sortState,
onSortChange,
extraColumns = [],
rowSelection,
scrollY = 480,
onRow,
}: DataTableProps<T>) {
const { columns: defs, searchableKeys = [], rowKey } = config;
const normalizedQuery = globalQuery.trim().toLowerCase();
const hasGlobalSearch =
searchableKeys.length > 0 && normalizedQuery.length > 0;
const getOptions = (key: Extract<keyof T, string>): FilterOption[] => {
const seen = new Set<string | number>();
const opts: FilterOption[] = [];
for (const r of allRows) {
const v = r[key];
if (!seen.has(v)) {
seen.add(v);
opts.push({ label: String(v), value: v });
}
}
return opts;
};
const buildLeaf = (def: LeafColumnDef<T>): ColumnType<T> => {
const { key, title, features = {}, render, width, fixed } = def;
const hasDropdown =
features.multiselect || features.search || features.sort;
const isSearchable = searchableKeys.includes(key);
return {
key,
title,
dataIndex: key,
width,
fixed,
render: (value: T[keyof T], row: T) => {
if (render) return render(value, row);
const text = String(value ?? "");
return isSearchable && hasGlobalSearch ? (
<Highlight text={text} query={globalQuery.trim()} />
) : (
text
);
},
...(hasDropdown
? {
filterIcon: () => {
const active =
(columnFilters[key]?.length ?? 0) > 0 || sortState?.key === key;
return (
<span
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
padding: "4px 6px",
borderRadius: 6,
background: active
? "rgba(245, 158, 11, 0.14)"
: "transparent",
color: active ? "rgb(22, 101, 52)" : "#bfbfbf",
transition: "background 0.15s",
}}
>
<FilterOutlined style={{ fontSize: 12 }} />
</span>
);
},
filterDropdown: ({ confirm, clearFilters }) => (
<CustomFilterDropdown
selectedKeys={(columnFilters[key] ?? []) as React.Key[]}
setSelectedKeys={(keys) =>
onColumnFiltersChange({
...columnFilters,
[key]: keys as (string | number)[],
})
}
confirm={confirm}
clearFilters={clearFilters}
features={features}
options={
features.multiselect || features.search ? getOptions(key) : []
}
sortDirection={sortState?.key === key ? sortState.dir : null}
onSort={(dir) => onSortChange(dir ? { key, dir } : null)}
/>
),
}
: {}),
};
};
const columns: ColumnsType<T> = useMemo(() => {
const built: ColumnsType<T> = [];
for (const def of defs) {
if (isGroup(def)) {
const visibleChildren = def.children.filter((c) =>
visibleKeys.includes(c.key),
);
if (visibleChildren.length === 0) continue;
built.push({
key: def.groupKey,
title: def.title,
fixed: def.fixed,
children: visibleChildren.map(buildLeaf),
} as ColumnGroupType<T>);
} else {
if (!visibleKeys.includes(def.key)) continue;
built.push(buildLeaf(def));
}
}
for (const col of extraColumns) {
const { position, afterKey, ...rest } = col as ExtraColumn<T> & {
position?: any;
afterKey?: any;
};
let idx: number;
if (afterKey) {
const f = built.findIndex(
(c: any) => c.key === afterKey || c.dataIndex === afterKey,
);
idx = f === -1 ? built.length : f + 1;
} else if (position === "start") idx = 0;
else if (position === "end" || position === undefined) idx = built.length;
else idx = Math.max(0, Math.min(position, built.length));
built.splice(idx, 0, rest as ColumnsType<T>[number]);
}
return built;
}, [
defs,
visibleKeys,
sortState,
allRows,
searchableKeys,
globalQuery,
hasGlobalSearch,
columnFilters,
onColumnFiltersChange,
onSortChange,
extraColumns,
]);
const wrapperRef = useRef<HTMLDivElement>(null);
const [appliedScrollY, setAppliedScrollY] = useState(scrollY);
useLayoutEffect(() => {
const wrapper = wrapperRef.current;
if (!wrapper) return;
const update = () => {
const total = wrapper.clientHeight;
const header = wrapper.querySelector<HTMLElement>(".ant-table-thead");
const headerH = header?.offsetHeight ?? 55;
setAppliedScrollY(Math.max(0, total - headerH));
};
update();
const ro = new ResizeObserver(update);
ro.observe(wrapper);
return () => ro.disconnect();
}, []);
return (
<div ref={wrapperRef} style={{ flex: 1, height: "100%" }}>
<Table<T>
rowKey={rowKey}
loading={loading}
columns={columns}
dataSource={rows}
rowSelection={rowSelection}
pagination={false}
scroll={{ x: "max-content", y: appliedScrollY }}
onRow={onRow}
/>
</div>
);
}