"use client" import * as React from "react" import type { DataTableFilterField, ExtendedSortingState } from "@/types/table" import { getCoreRowModel, getFacetedRowModel, getFacetedUniqueValues, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, getGroupedRowModel, getExpandedRowModel, useReactTable, type ColumnFiltersState, type PaginationState, type RowSelectionState, type SortingState, type TableOptions, type TableState, type Updater, type VisibilityState, type ExpandedState, } from "@tanstack/react-table" import { parseAsArrayOf, parseAsInteger, parseAsString, useQueryState, useQueryStates, type Parser, type UseQueryStateOptions, } from "nuqs" import { getSortingStateParser } from "@/lib/parsers" import { useDebouncedCallback } from "@/hooks/use-debounced-callback" interface UseDataTableProps extends Omit< TableOptions, | "state" | "pageCount" | "getCoreRowModel" | "manualFiltering" | "manualPagination" | "manualSorting" | "onGroupingChange" | "onExpandedChange" | "getExpandedRowModel" >, Required, "pageCount">> { filterFields?: DataTableFilterField[] enableAdvancedFilter?: boolean history?: "push" | "replace" scroll?: boolean shallow?: boolean throttleMs?: number debounceMs?: number startTransition?: React.TransitionStartFunction clearOnDefault?: boolean initialState?: Omit, "sorting"> & { sorting?: ExtendedSortingState /** * 기본 그룹핑 컬럼 배열 (멀티 그룹핑) */ grouping?: string[] /** * 그룹 확장/접기 상태 */ expanded?: Record } } export function useDataTable({ pageCount = -1, filterFields = [], enableAdvancedFilter = false, history = "replace", scroll = false, shallow = true, throttleMs = 50, debounceMs = 300, clearOnDefault = false, startTransition, initialState, ...props }: UseDataTableProps) { // 공통 URL QueryState 옵션 const queryStateOptions = React.useMemo< Omit, "parse"> >(() => { return { history, scroll, shallow, throttleMs, debounceMs, clearOnDefault, startTransition, } }, [ history, scroll, shallow, throttleMs, debounceMs, clearOnDefault, startTransition, ]) // -------- RowSelection & ColumnVisibility는 URL 동기화 없이 로컬 상태 ---------- const [rowSelection, setRowSelection] = React.useState( initialState?.rowSelection ?? {} ) const [columnVisibility, setColumnVisibility] = React.useState(initialState?.columnVisibility ?? {}) // -------- Pagination (page, perPage) URL 동기화 -------- const [page, setPage] = useQueryState( "page", parseAsInteger.withOptions(queryStateOptions).withDefault(1) ) const [perPage, setPerPage] = useQueryState( "perPage", parseAsInteger .withOptions(queryStateOptions) .withDefault(initialState?.pagination?.pageSize ?? 10) ) // -------- Sorting (sort) URL 동기화 -------- const [sorting, setSorting] = useQueryState( "sort", getSortingStateParser() .withOptions(queryStateOptions) .withDefault(initialState?.sorting ?? []) ) function onSortingChange(updaterOrValue: Updater) { if (typeof updaterOrValue === "function") { const newSorting = updaterOrValue(sorting) as ExtendedSortingState void setSorting(newSorting) } else { void setSorting(updaterOrValue as ExtendedSortingState) } } // -------- Grouping (group) URL 동기화 (멀티 컬럼) -------- const [grouping, setGrouping] = useQueryState( "group", parseAsArrayOf(parseAsString, ",") .withOptions(queryStateOptions) .withDefault(initialState?.grouping ?? []) ) function onGroupingChange(updaterOrValue: Updater) { if (typeof updaterOrValue === "function") { const newGrouping = updaterOrValue(grouping) void setGrouping(newGrouping) } else { void setGrouping(updaterOrValue) } } // -------- Group Expand/Collapse -------- const [expanded, setExpanded] = React.useState({}) // or true/false function onExpandedChange(updater: Updater) { setExpanded((old) => (typeof updater === "function" ? updater(old) : updater)) } // -------- Filters (search/faceted) URL 동기화 -------- const filterParsers = React.useMemo(() => { return filterFields.reduce< Record | Parser> >((acc, field) => { if (field.options) { // Faceted filter -> 여러 값 가능 acc[field.id] = parseAsArrayOf(parseAsString, ",").withOptions( queryStateOptions ) } else { // Search filter -> 단일 값 acc[field.id] = parseAsString.withOptions(queryStateOptions) } return acc }, {}) }, [filterFields, queryStateOptions]) const [filterValues, setFilterValues] = useQueryStates(filterParsers) const debouncedSetFilterValues = useDebouncedCallback( setFilterValues, debounceMs ) // -------- PaginationState 객체 -------- const pagination: PaginationState = { pageIndex: page - 1, pageSize: perPage, } function onPaginationChange(updaterOrValue: Updater) { if (typeof updaterOrValue === "function") { const newPagination = updaterOrValue(pagination) void setPage(newPagination.pageIndex + 1) void setPerPage(newPagination.pageSize) } else { void setPage(updaterOrValue.pageIndex + 1) void setPerPage(updaterOrValue.pageSize) } } // -------- ColumnFiltersState -------- const initialColumnFilters: ColumnFiltersState = React.useMemo(() => { // AdvancedFilter 모드가 아니면 URL에서 온 filterValues를 그대로 적용 return enableAdvancedFilter ? [] : Object.entries(filterValues).reduce( (filters, [key, value]) => { if (value !== null) { filters.push({ id: key, value: Array.isArray(value) ? value : [value], }) } return filters }, [] ) }, [filterValues, enableAdvancedFilter]) const [columnFilters, setColumnFilters] = React.useState(initialColumnFilters) // 검색용 / Facet 필터용 컬럼 구분 const { searchableColumns, filterableColumns } = React.useMemo(() => { return enableAdvancedFilter ? { searchableColumns: [], filterableColumns: [] } : { searchableColumns: filterFields.filter((field) => !field.options), filterableColumns: filterFields.filter((field) => field.options), } }, [filterFields, enableAdvancedFilter]) const onColumnFiltersChange = React.useCallback( (updaterOrValue: Updater) => { if (enableAdvancedFilter) return setColumnFilters((prev) => { const next = typeof updaterOrValue === "function" ? updaterOrValue(prev) : updaterOrValue const filterUpdates = next.reduce< Record >((acc, filter) => { if (searchableColumns.find((col) => col.id === filter.id)) { acc[filter.id] = filter.value as string } else if (filterableColumns.find((col) => col.id === filter.id)) { acc[filter.id] = filter.value as string[] } return acc }, {}) // 빠진 필터는 null로 설정 prev.forEach((prevFilter) => { if (!next.some((filter) => filter.id === prevFilter.id)) { filterUpdates[prevFilter.id] = null } }) // 필터가 바뀌면 첫 페이지로 void setPage(1) debouncedSetFilterValues(filterUpdates) return next }) }, [ debouncedSetFilterValues, enableAdvancedFilter, filterableColumns, searchableColumns, setPage, ] ) // -------- TanStack Table 인스턴스 생성 -------- const table = useReactTable({ ...props, initialState, pageCount, state: { pagination, sorting, columnVisibility, rowSelection, columnFilters: enableAdvancedFilter ? [] : columnFilters, grouping, expanded, }, // 콜백들 onRowSelectionChange: setRowSelection, onPaginationChange, onSortingChange, onColumnFiltersChange, onColumnVisibilityChange: setColumnVisibility, onGroupingChange, onExpandedChange, // 기본 모델 getCoreRowModel: getCoreRowModel(), // 필터 (Advanced 모드 아니면 사용) getFilteredRowModel: enableAdvancedFilter ? undefined : getFilteredRowModel(), getPaginationRowModel: getPaginationRowModel(), getSortedRowModel: getSortedRowModel(), // 그룹 + 확장 getGroupedRowModel: getGroupedRowModel(), getExpandedRowModel: getExpandedRowModel(), // Faceted (Facet 필터용) getFacetedRowModel: enableAdvancedFilter ? undefined : getFacetedRowModel(), getFacetedUniqueValues: enableAdvancedFilter ? undefined : getFacetedUniqueValues(), // 서버 사이드 사용 manualPagination: true, manualSorting: true, manualFiltering: true, // 그룹핑도 서버에서 처리한다면 별도 로직이 필요하지만, // TanStack Table v8에는 manualGrouping 옵션은 없음 // (그룹핑을 서버에서 이미 해서 내려받는다면 row 구조 처리 필요) }) return { table } }