summaryrefslogtreecommitdiff
path: root/components/data-table
diff options
context:
space:
mode:
Diffstat (limited to 'components/data-table')
-rw-r--r--components/data-table/data-table-advanced-toolbar.tsx104
-rw-r--r--components/data-table/data-table-column-header.tsx109
-rw-r--r--components/data-table/data-table-column-resizable.tsx57
-rw-r--r--components/data-table/data-table-column-simple-header.tsx61
-rw-r--r--components/data-table/data-table-faceted-filter.tsx151
-rw-r--r--components/data-table/data-table-filter-list.tsx787
-rw-r--r--components/data-table/data-table-grobal-filter.tsx49
-rw-r--r--components/data-table/data-table-group-list.tsx317
-rw-r--r--components/data-table/data-table-pagination.tsx132
-rw-r--r--components/data-table/data-table-pin-left.tsx95
-rw-r--r--components/data-table/data-table-pin-right.tsx88
-rw-r--r--components/data-table/data-table-pin.tsx146
-rw-r--r--components/data-table/data-table-resizer.tsx98
-rw-r--r--components/data-table/data-table-skeleton.tsx169
-rw-r--r--components/data-table/data-table-sort-list.tsx370
-rw-r--r--components/data-table/data-table-toolbar.tsx119
-rw-r--r--components/data-table/data-table-view-options.tsx191
-rw-r--r--components/data-table/data-table.tsx209
18 files changed, 3252 insertions, 0 deletions
diff --git a/components/data-table/data-table-advanced-toolbar.tsx b/components/data-table/data-table-advanced-toolbar.tsx
new file mode 100644
index 00000000..7c126c51
--- /dev/null
+++ b/components/data-table/data-table-advanced-toolbar.tsx
@@ -0,0 +1,104 @@
+"use client"
+
+import * as React from "react"
+import type { DataTableAdvancedFilterField } from "@/types/table"
+import { type Table } from "@tanstack/react-table"
+
+import { cn } from "@/lib/utils"
+import { DataTableFilterList } from "@/components/data-table/data-table-filter-list"
+import { DataTableSortList } from "@/components/data-table/data-table-sort-list"
+import { DataTableViewOptions } from "@/components/data-table/data-table-view-options"
+import { DataTablePinList } from "./data-table-pin"
+import { PinLeftButton } from "./data-table-pin-left"
+import { PinRightButton } from "./data-table-pin-right"
+import { DataTableGlobalFilter } from "./data-table-grobal-filter"
+import { DataTableGroupList } from "./data-table-group-list"
+
+interface DataTableAdvancedToolbarProps<TData>
+ extends React.HTMLAttributes<HTMLDivElement> {
+ /**
+ * The table instance returned from useDataTable hook with pagination, sorting, filtering, etc.
+ * @type Table<TData>
+ */
+ table: Table<TData>
+
+ /**
+ * An array of filter field configurations for the data table.
+ * @type DataTableAdvancedFilterField<TData>[]
+ * @example
+ * const filterFields = [
+ * {
+ * id: 'name',
+ * label: 'Name',
+ * type: 'text',
+ * placeholder: 'Filter by name...'
+ * },
+ * {
+ * id: 'status',
+ * label: 'Status',
+ * type: 'select',
+ * options: [
+ * { label: 'Active', value: 'active', count: 10 },
+ * { label: 'Inactive', value: 'inactive', count: 5 }
+ * ]
+ * }
+ * ]
+ */
+ filterFields: DataTableAdvancedFilterField<TData>[]
+
+ /**
+ * Debounce time (ms) for filter updates to enhance performance during rapid input.
+ * @default 300
+ */
+ debounceMs?: number
+
+ /**
+ * Shallow mode keeps query states client-side, avoiding server calls.
+ * Setting to `false` triggers a network request with the updated querystring.
+ * @default true
+ */
+ shallow?: boolean
+}
+
+export function DataTableAdvancedToolbar<TData>({
+ table,
+ filterFields = [],
+ debounceMs = 300,
+ shallow = true,
+ children,
+ className,
+ ...props
+}: DataTableAdvancedToolbarProps<TData>) {
+ return (
+ <div
+ className={cn(
+ "flex w-full items-center justify-between gap-2 overflow-auto p-1",
+ className
+ )}
+ {...props}
+ >
+ <div className="flex items-center gap-2">
+ <DataTableViewOptions table={table} />
+ <DataTableFilterList
+ table={table}
+ filterFields={filterFields}
+ debounceMs={debounceMs}
+ shallow={shallow}
+ />
+ <DataTableSortList
+ table={table}
+ debounceMs={debounceMs}
+ shallow={shallow}
+ />
+ <DataTableGroupList table={table} debounceMs={debounceMs}/>
+ <PinLeftButton table={table}/>
+ <PinRightButton table={table}/>
+ <DataTableGlobalFilter />
+ </div>
+ <div className="flex items-center gap-2">
+ {children}
+
+ </div>
+ </div>
+ )
+}
diff --git a/components/data-table/data-table-column-header.tsx b/components/data-table/data-table-column-header.tsx
new file mode 100644
index 00000000..aa0c754b
--- /dev/null
+++ b/components/data-table/data-table-column-header.tsx
@@ -0,0 +1,109 @@
+"use client"
+
+import { SelectIcon } from "@radix-ui/react-select"
+import { type Column } from "@tanstack/react-table"
+import { ArrowDown, ArrowUp, ChevronsUpDown, EyeOff } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+} from "@/components/ui/select"
+
+interface DataTableColumnHeaderProps<TData, TValue>
+ extends React.HTMLAttributes<HTMLDivElement> {
+ column: Column<TData, TValue>
+ title: string
+}
+
+export function DataTableColumnHeader<TData, TValue>({
+ column,
+ title,
+ className,
+}: DataTableColumnHeaderProps<TData, TValue>) {
+ if (!column.getCanSort() && !column.getCanHide()) {
+ return <div className={cn(className)}>{title}</div>
+ }
+
+ const ascValue = `${column.id}-asc`
+ const descValue = `${column.id}-desc`
+ const hideValue = `${column.id}-hide`
+
+ return (
+ <div className={cn("flex items-center gap-2", className)}>
+ <Select
+ value={
+ column.getIsSorted() === "desc"
+ ? descValue
+ : column.getIsSorted() === "asc"
+ ? ascValue
+ : undefined
+ }
+ onValueChange={(value) => {
+ if (value === ascValue) column.toggleSorting(false)
+ else if (value === descValue) column.toggleSorting(true)
+ else if (value === hideValue) column.toggleVisibility(false)
+ }}
+ >
+ <SelectTrigger
+ aria-label={
+ column.getIsSorted() === "desc"
+ ? "Sorted descending. Click to sort ascending."
+ : column.getIsSorted() === "asc"
+ ? "Sorted ascending. Click to sort descending."
+ : "Not sorted. Click to sort ascending."
+ }
+ className="-ml-3 h-8 w-fit border-none text-xs hover:bg-accent hover:text-accent-foreground data-[state=open]:bg-accent [&>svg:last-child]:hidden"
+ >
+ {title}
+ <SelectIcon asChild>
+ {column.getCanSort() && column.getIsSorted() === "desc" ? (
+ <ArrowDown className="ml-2.5 size-4" aria-hidden="true" />
+ ) : column.getIsSorted() === "asc" ? (
+ <ArrowUp className="ml-2.5 size-4" aria-hidden="true" />
+ ) : (
+ <ChevronsUpDown className="ml-2.5 size-4" aria-hidden="true" />
+ )}
+ </SelectIcon>
+ </SelectTrigger>
+ <SelectContent align="start">
+ {column.getCanSort() && (
+ <>
+ <SelectItem value={ascValue}>
+ <span className="flex items-center">
+ <ArrowUp
+ className="mr-2 size-3.5 text-muted-foreground/70"
+ aria-hidden="true"
+ />
+ Asc
+ </span>
+ </SelectItem>
+ <SelectItem value={descValue}>
+ <span className="flex items-center">
+ <ArrowDown
+ className="mr-2 size-3.5 text-muted-foreground/70"
+ aria-hidden="true"
+ />
+ Desc
+ </span>
+ </SelectItem>
+ </>
+ )}
+ {column.getCanHide() && (
+ <SelectItem value={hideValue}>
+ <span className="flex items-center">
+ <EyeOff
+ className="mr-2 size-3.5 text-muted-foreground/70"
+ aria-hidden="true"
+ />
+ Hide
+ </span>
+ </SelectItem>
+ )}
+ </SelectContent>
+ </Select>
+ </div>
+ )
+}
diff --git a/components/data-table/data-table-column-resizable.tsx b/components/data-table/data-table-column-resizable.tsx
new file mode 100644
index 00000000..2a91c998
--- /dev/null
+++ b/components/data-table/data-table-column-resizable.tsx
@@ -0,0 +1,57 @@
+"use client"
+
+import * as React from "react"
+import { cn } from "@/lib/utils"
+import { type Column } from "@tanstack/react-table"
+import { ArrowDown, ArrowUp, ChevronsUpDown } from "lucide-react"
+
+interface DataTableColumnHeaderProps<TData, TValue>
+ extends React.HTMLAttributes<HTMLDivElement> {
+ column: Column<TData, TValue>
+ title: string
+}
+
+export function DataTableColumnHeaderResizable<TData, TValue>({
+ column,
+ title,
+ className,
+}: DataTableColumnHeaderProps<TData, TValue>) {
+ // 정렬 상태: "asc" | "desc" | false
+ const sorted = column.getIsSorted()
+
+ // 아이콘 결정
+ let icon = <ChevronsUpDown className="ml-1 size-4" aria-hidden="true" />
+ if (sorted === "asc") {
+ icon = <ArrowUp className="ml-1 size-4" aria-hidden="true" />
+ } else if (sorted === "desc") {
+ icon = <ArrowDown className="ml-1 size-4" aria-hidden="true" />
+ }
+
+ // 클릭 핸들러: 무정렬 → asc → desc → 무정렬
+ function handleClick() {
+ if (!sorted) {
+ // 현재 무정렬 → asc
+ column.toggleSorting(false)
+ } else if (sorted === "asc") {
+ // asc → desc
+ column.toggleSorting(true)
+ } else {
+ // desc → 무정렬
+ column.toggleSorting(false)
+ }
+ }
+
+ return (
+ <div className={cn("relative flex items-center", className)}>
+ <div
+ onClick={handleClick}
+ className={cn(
+ "flex cursor-pointer select-none items-center gap-1"
+ )}
+ >
+ <span>{title}</span>
+ {column.getCanSort() && icon}
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/components/data-table/data-table-column-simple-header.tsx b/components/data-table/data-table-column-simple-header.tsx
new file mode 100644
index 00000000..a865df24
--- /dev/null
+++ b/components/data-table/data-table-column-simple-header.tsx
@@ -0,0 +1,61 @@
+"use client"
+
+import * as React from "react"
+import { cn } from "@/lib/utils"
+import { type Column } from "@tanstack/react-table"
+import { ArrowDown, ArrowUp, ChevronsUpDown } from "lucide-react"
+
+interface DataTableColumnHeaderSimpleProps<TData, TValue>
+ extends React.HTMLAttributes<HTMLDivElement> {
+ column: Column<TData, TValue>
+ title: string
+}
+
+export function DataTableColumnHeaderSimple<TData, TValue>({
+ column,
+ title,
+ className,
+}: DataTableColumnHeaderSimpleProps<TData, TValue>) {
+ // 정렬 불가능 시 → 제목만 보여주기
+ if (!column.getCanSort()) {
+ return <div className={cn(className)}>{title}</div>
+ }
+
+ // 정렬 상태: "asc" | "desc" | false
+ const sorted = column.getIsSorted()
+
+ // 아이콘 결정
+ let icon = <ChevronsUpDown className="w-4 h-4" aria-hidden="true" />
+ if (sorted === "asc") {
+ icon = <ArrowUp className="w-4 h-4" aria-hidden="true" />
+ } else if (sorted === "desc") {
+ icon = <ArrowDown className="w-4 h-4" aria-hidden="true" />
+ }
+
+ // 클릭 핸들러: 무정렬 → asc → desc → 무정렬
+ function handleClick() {
+ if (!sorted) {
+ // 현재 무정렬 → asc
+ column.toggleSorting(false)
+ } else if (sorted === "asc") {
+ // asc → desc
+ column.toggleSorting(true)
+ } else {
+ // desc → 무정렬
+ column.toggleSorting(false)
+ }
+ }
+
+ return (
+ <div
+ onClick={handleClick}
+ className={cn(
+ "flex items-center justify-between cursor-pointer select-none gap-2",
+ className
+ )}
+ >
+ <span>{title}</span>
+ {icon}
+ </div>
+ )
+} \ No newline at end of file
diff --git a/components/data-table/data-table-faceted-filter.tsx b/components/data-table/data-table-faceted-filter.tsx
new file mode 100644
index 00000000..d89ef03f
--- /dev/null
+++ b/components/data-table/data-table-faceted-filter.tsx
@@ -0,0 +1,151 @@
+"use client"
+
+import type { Option } from "@/types/table"
+import type { Column } from "@tanstack/react-table"
+import { Check, PlusCircle } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+ CommandSeparator,
+} from "@/components/ui/command"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+import { Separator } from "@/components/ui/separator"
+
+interface DataTableFacetedFilterProps<TData, TValue> {
+ column?: Column<TData, TValue>
+ title?: string
+ options: Option[]
+}
+
+export function DataTableFacetedFilter<TData, TValue>({
+ column,
+ title,
+ options,
+}: DataTableFacetedFilterProps<TData, TValue>) {
+ const unknownValue = column?.getFilterValue()
+ const selectedValues = new Set(
+ Array.isArray(unknownValue) ? unknownValue : []
+ )
+
+ return (
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button variant="outline" size="sm" className="h-8 border-dashed">
+ <PlusCircle className="mr-2 size-4" />
+ {title}
+ {selectedValues?.size > 0 && (
+ <>
+ <Separator orientation="vertical" className="mx-2 h-4" />
+ <Badge
+ variant="secondary"
+ className="rounded-sm px-1 font-normal lg:hidden"
+ >
+ {selectedValues.size}
+ </Badge>
+ <div className="hidden space-x-1 lg:flex">
+ {selectedValues.size > 2 ? (
+ <Badge
+ variant="secondary"
+ className="rounded-sm px-1 font-normal"
+ >
+ {selectedValues.size} selected
+ </Badge>
+ ) : (
+ options
+ .filter((option) => selectedValues.has(option.value))
+ .map((option) => (
+ <Badge
+ variant="secondary"
+ key={option.value}
+ className="rounded-sm px-1 font-normal"
+ >
+ {option.label}
+ </Badge>
+ ))
+ )}
+ </div>
+ </>
+ )}
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[12.5rem] p-0" align="start">
+ <Command>
+ <CommandInput placeholder={title} />
+ <CommandList className="max-h-full">
+ <CommandEmpty>No results found.</CommandEmpty>
+ <CommandGroup className="max-h-[18.75rem] overflow-y-auto overflow-x-hidden">
+ {options.map((option) => {
+ const isSelected = selectedValues.has(option.value)
+
+ return (
+ <CommandItem
+ key={option.value}
+ onSelect={() => {
+ if (isSelected) {
+ selectedValues.delete(option.value)
+ } else {
+ selectedValues.add(option.value)
+ }
+ const filterValues = Array.from(selectedValues)
+ column?.setFilterValue(
+ filterValues.length ? filterValues : undefined
+ )
+ }}
+ >
+ <div
+ className={cn(
+ "mr-2 flex size-4 items-center justify-center rounded-sm border border-primary",
+ isSelected
+ ? "bg-primary text-primary-foreground"
+ : "opacity-50 [&_svg]:invisible"
+ )}
+ >
+ <Check className="size-4" aria-hidden="true" />
+ </div>
+ {option.icon && (
+ <option.icon
+ className="mr-2 size-4 text-muted-foreground"
+ aria-hidden="true"
+ />
+ )}
+ <span>{option.label}</span>
+ {option.count && (
+ <span className="ml-auto flex size-4 items-center justify-center font-mono text-xs">
+ {option.count}
+ </span>
+ )}
+ </CommandItem>
+ )
+ })}
+ </CommandGroup>
+ {selectedValues.size > 0 && (
+ <>
+ <CommandSeparator />
+ <CommandGroup>
+ <CommandItem
+ onSelect={() => column?.setFilterValue(undefined)}
+ className="justify-center text-center"
+ >
+ Clear filters
+ </CommandItem>
+ </CommandGroup>
+ </>
+ )}
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ )
+}
diff --git a/components/data-table/data-table-filter-list.tsx b/components/data-table/data-table-filter-list.tsx
new file mode 100644
index 00000000..c51d4374
--- /dev/null
+++ b/components/data-table/data-table-filter-list.tsx
@@ -0,0 +1,787 @@
+"use client"
+
+import * as React from "react"
+import type {
+ DataTableAdvancedFilterField,
+ Filter,
+ FilterOperator,
+ JoinOperator,
+ StringKeyOf,
+} from "@/types/table"
+import { type Table } from "@tanstack/react-table"
+import {
+ CalendarIcon,
+ Check,
+ ChevronsUpDown,
+ GripVertical,
+ ListFilter,
+ Trash2,
+} from "lucide-react"
+import { customAlphabet } from "nanoid"
+import { parseAsStringEnum, useQueryState } from "nuqs"
+
+import { dataTableConfig } from "@/config/data-table"
+import { getDefaultFilterOperator, getFilterOperators } from "@/lib/data-table"
+import { getFiltersStateParser } from "@/lib/parsers"
+import { cn, formatDate } from "@/lib/utils"
+import { useDebouncedCallback } from "@/hooks/use-debounced-callback"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Calendar } from "@/components/ui/calendar"
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command"
+import {
+ FacetedFilter,
+ FacetedFilterContent,
+ FacetedFilterEmpty,
+ FacetedFilterGroup,
+ FacetedFilterInput,
+ FacetedFilterItem,
+ FacetedFilterList,
+ FacetedFilterTrigger,
+} from "@/components/ui/faceted-filter"
+import { Input } from "@/components/ui/input"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import {
+ Sortable,
+ SortableDragHandle,
+ SortableItem,
+} from "@/components/ui/sortable"
+import { useParams } from 'next/navigation';
+import { useTranslation } from '@/i18n/client'
+
+interface DataTableFilterListProps<TData> {
+ table: Table<TData>
+ filterFields: DataTableAdvancedFilterField<TData>[]
+ debounceMs: number
+ shallow?: boolean
+}
+
+export function DataTableFilterList<TData>({
+ table,
+ filterFields,
+ debounceMs,
+ shallow,
+}: DataTableFilterListProps<TData>) {
+
+ const params = useParams();
+ const lng = params.lng as string;
+
+ const { t, i18n } = useTranslation(lng);
+
+ const id = React.useId()
+ const [filters, setFilters] = useQueryState(
+ "filters",
+ getFiltersStateParser(table.getRowModel().rows[0]?.original)
+ .withDefault([])
+ .withOptions({
+ clearOnDefault: true,
+ shallow,
+ })
+ )
+
+ const [joinOperator, setJoinOperator] = useQueryState(
+ "joinOperator",
+ parseAsStringEnum(["and", "or"]).withDefault("and").withOptions({
+ clearOnDefault: true,
+ shallow,
+ })
+ )
+
+ const debouncedSetFilters = useDebouncedCallback(setFilters, debounceMs)
+
+ function addFilter() {
+ const filterField = filterFields[0]
+
+ if (!filterField) return
+
+ void setFilters([
+ ...filters,
+ {
+ id: filterField.id,
+ value: "",
+ type: filterField.type,
+ operator: getDefaultFilterOperator(filterField.type),
+ rowId: customAlphabet(
+ "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
+ 6
+ )(),
+ },
+ ])
+ }
+
+ function updateFilter({
+ rowId,
+ field,
+ debounced = false,
+ }: {
+ rowId: string
+ field: Omit<Partial<Filter<TData>>, "rowId">
+ debounced?: boolean
+ }) {
+ const updateFunction = debounced ? debouncedSetFilters : setFilters
+ updateFunction((prevFilters) => {
+ const updatedFilters = prevFilters.map((filter) => {
+ if (filter.rowId === rowId) {
+ return { ...filter, ...field }
+ }
+ return filter
+ })
+ return updatedFilters
+ })
+ }
+
+ function removeFilter(rowId: string) {
+ const updatedFilters = filters.filter((filter) => filter.rowId !== rowId)
+ void setFilters(updatedFilters)
+ }
+
+ function moveFilter(activeIndex: number, overIndex: number) {
+ void setFilters((prevFilters) => {
+ const newFilters = [...prevFilters]
+ const [removed] = newFilters.splice(activeIndex, 1)
+ if (!removed) return prevFilters
+ newFilters.splice(overIndex, 0, removed)
+ return newFilters
+ })
+ }
+
+ function renderFilterInput({
+ filter,
+ inputId,
+ }: {
+ filter: Filter<TData>
+ inputId: string
+ }) {
+ const filterField = filterFields.find((f) => f.id === filter.id)
+
+ if (!filterField) return null
+
+ if (filter.operator === "isEmpty" || filter.operator === "isNotEmpty") {
+ return (
+ <div
+ id={inputId}
+ role="status"
+ aria-live="polite"
+ aria-label={`${filterField.label} filter is ${filter.operator === "isEmpty" ? "empty" : "not empty"}`}
+ className="h-8 w-full rounded border border-dashed"
+ />
+ )
+ }
+
+ switch (filter.type) {
+ case "text":
+ case "number":
+ return (
+ <Input
+ id={inputId}
+ type={filter.type}
+ aria-label={`${filterField.label} filter value`}
+ aria-describedby={`${inputId}-description`}
+ placeholder={filterField.placeholder ?? t('filterInputPlaceholder')}
+ className="h-8 w-full focus:outline-none !important focus:ring-offset-4 !important"
+ defaultValue={
+ typeof filter.value === "string" ? filter.value : undefined
+ }
+ onChange={(event) =>
+ updateFilter({
+ rowId: filter.rowId,
+ field: { value: event.target.value },
+ debounced: true,
+ })
+ }
+ />
+ )
+ case "select":
+ return (
+ <FacetedFilter>
+ <FacetedFilterTrigger asChild>
+ <Button
+ id={inputId}
+ variant="outline"
+ size="sm"
+ aria-label={`${filterField.label} filter value`}
+ aria-controls={`${inputId}-listbox`}
+ className="h-8 w-full justify-start gap-2 rounded px-1.5 text-left text-muted-foreground hover:text-muted-foreground"
+ >
+ {filter.value && typeof filter.value === "string" ? (
+ <Badge
+ variant="secondary"
+ className="rounded-sm px-1 font-normal"
+ >
+ {filterField?.options?.find(
+ (option) => option.value === filter.value
+ )?.label || filter.value}
+ </Badge>
+ ) : (
+ <>
+ {filterField.placeholder ?? "Select an option..."}
+ <ChevronsUpDown className="size-4" aria-hidden="true" />
+ </>
+ )}
+ </Button>
+ </FacetedFilterTrigger>
+ <FacetedFilterContent
+ id={`${inputId}-listbox`}
+ className="w-[12.5rem] origin-[var(--radix-popover-content-transform-origin)]"
+ >
+ <FacetedFilterInput
+ placeholder={filterField?.label ?? "Search options..."}
+ aria-label={`Search ${filterField?.label} options`}
+ />
+ <FacetedFilterList>
+ <FacetedFilterEmpty>No options found.</FacetedFilterEmpty>
+ <FacetedFilterGroup>
+ {filterField?.options?.map((option) => (
+ <FacetedFilterItem
+ key={option.value}
+ value={String(option.value)}
+ selected={filter.value === option.value}
+ onSelect={(value) => {
+ updateFilter({ rowId: filter.rowId, field: { value } })
+ setTimeout(() => {
+ document.getElementById(inputId)?.click()
+ }, 0)
+ }}
+ >
+ {option.icon && (
+ <option.icon
+ className="mr-2 size-4 text-muted-foreground"
+ aria-hidden="true"
+ />
+ )}
+ <span>{option.label}</span>
+ {option.count && (
+ <span className="ml-auto flex size-4 items-center justify-center font-mono text-xs">
+ {option.count}
+ </span>
+ )}
+ </FacetedFilterItem>
+ ))}
+ </FacetedFilterGroup>
+ </FacetedFilterList>
+ </FacetedFilterContent>
+ </FacetedFilter>
+ )
+ case "multi-select":
+ const selectedValues = new Set(
+ Array.isArray(filter.value) ? filter.value : []
+ )
+
+ return (
+ <FacetedFilter>
+ <FacetedFilterTrigger asChild>
+ <Button
+ id={inputId}
+ variant="outline"
+ size="sm"
+ aria-label={`${filterField.label} filter values`}
+ aria-controls={`${inputId}-listbox`}
+ className="h-8 w-full justify-between gap-2 rounded px-1.5 text-left text-muted-foreground hover:text-muted-foreground"
+ >
+ <>
+ {selectedValues.size === 0 && (
+ <>
+ {filterField.placeholder ?? t("Select options")}
+ <ChevronsUpDown className="size-4" aria-hidden="true" />
+ </>
+ )}
+ </>
+ {selectedValues?.size > 0 && (
+ <div className="flex items-center">
+ <Badge
+ variant="secondary"
+ className="rounded-sm px-1 font-normal lg:hidden"
+ >
+ {selectedValues.size}
+ </Badge>
+ <div className="hidden min-w-0 gap-1 lg:flex">
+ {selectedValues.size > 2 ? (
+ <Badge
+ variant="secondary"
+ className="rounded-sm px-1 font-normal"
+ >
+ {selectedValues.size} selected
+ </Badge>
+ ) : (
+ filterField?.options
+ ?.filter((option) => selectedValues.has(String(option.value)))
+ .map((option) => (
+ <Badge
+ variant="secondary"
+ key={option.value}
+ className="truncate rounded-sm px-1 font-normal"
+ >
+ {option.label}
+ </Badge>
+ ))
+ )}
+ </div>
+ </div>
+ )}
+ </Button>
+ </FacetedFilterTrigger>
+ <FacetedFilterContent
+ id={`${inputId}-listbox`}
+ className="w-[12.5rem] origin-[var(--radix-popover-content-transform-origin)]"
+ >
+ <FacetedFilterInput
+ aria-label={`Search ${filterField?.label} options`}
+ placeholder={filterField?.label ?? "Search options..."}
+ />
+ <FacetedFilterList>
+ <FacetedFilterEmpty>No options found.</FacetedFilterEmpty>
+ <FacetedFilterGroup>
+ {filterField?.options?.map((option) => (
+ <FacetedFilterItem
+ key={option.value}
+ value={String(option.value)}
+ selected={selectedValues.has(String(option.value))}
+ onSelect={(value) => {
+ const currentValue = Array.isArray(filter.value)
+ ? filter.value
+ : []
+ const newValue = currentValue.includes(value)
+ ? currentValue.filter((v) => v !== value)
+ : [...currentValue, value]
+ updateFilter({
+ rowId: filter.rowId,
+ field: { value: newValue },
+ })
+ }}
+ >
+ {option.icon && (
+ <option.icon
+ className="mr-2 size-4 text-muted-foreground"
+ aria-hidden="true"
+ />
+ )}
+ <span>{option.label}</span>
+ {option.count && (
+ <span className="ml-auto flex size-4 items-center justify-center font-mono text-xs">
+ {option.count}
+ </span>
+ )}
+ </FacetedFilterItem>
+ ))}
+ </FacetedFilterGroup>
+ </FacetedFilterList>
+ </FacetedFilterContent>
+ </FacetedFilter>
+ )
+ case "date":
+ const dateValue = Array.isArray(filter.value)
+ ? filter.value.filter(Boolean)
+ : [filter.value, filter.value].filter(Boolean)
+
+ const displayValue =
+ filter.operator === "isBetween" && dateValue.length === 2
+ ? `${formatDate(dateValue[0] ?? new Date())} - ${formatDate(
+ dateValue[1] ?? new Date()
+ )}`
+ : dateValue[0]
+ ? formatDate(dateValue[0])
+ : "Pick a date"
+
+ return (
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ id={inputId}
+ variant="outline"
+ size="sm"
+ aria-label={`${filterField.label} date filter`}
+ aria-controls={`${inputId}-calendar`}
+ className={cn(
+ "h-8 w-full justify-start gap-2 rounded text-left font-normal",
+ !filter.value && "text-muted-foreground"
+ )}
+ >
+ <CalendarIcon
+ className="size-3.5 shrink-0"
+ aria-hidden="true"
+ />
+ <span className="truncate">{displayValue}</span>
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent
+ id={`${inputId}-calendar`}
+ align="start"
+ className="w-auto p-0"
+ >
+ {filter.operator === "isBetween" ? (
+ <Calendar
+ id={`${inputId}-calendar`}
+ mode="range"
+ aria-label={`Select ${filterField.label} date range`}
+ selected={
+ dateValue.length === 2
+ ? {
+ from: new Date(dateValue[0] ?? ""),
+ to: new Date(dateValue[1] ?? ""),
+ }
+ : {
+ from: new Date(),
+ to: new Date(),
+ }
+ }
+ onSelect={(date) => {
+ updateFilter({
+ rowId: filter.rowId,
+ field: {
+ value: date
+ ? [
+ date.from?.toISOString() ?? "",
+ date.to?.toISOString() ?? "",
+ ]
+ : [],
+ },
+ })
+ }}
+ initialFocus
+ numberOfMonths={1}
+ />
+ ) : (
+ <Calendar
+ id={`${inputId}-calendar`}
+ mode="single"
+ aria-label={`Select ${filterField.label} date`}
+ selected={dateValue[0] ? new Date(dateValue[0]) : undefined}
+ onSelect={(date) => {
+ updateFilter({
+ rowId: filter.rowId,
+ field: { value: date?.toISOString() ?? "" },
+ })
+
+ setTimeout(() => {
+ document.getElementById(inputId)?.click()
+ }, 0)
+ }}
+ initialFocus
+ />
+ )}
+ </PopoverContent>
+ </Popover>
+ )
+ case "boolean": {
+ if (Array.isArray(filter.value)) return null
+
+ return (
+ <Select
+ value={filter.value}
+ onValueChange={(value) =>
+ updateFilter({ rowId: filter.rowId, field: { value } })
+ }
+ >
+ <SelectTrigger
+ id={inputId}
+ aria-label={`${filterField.label} boolean filter`}
+ aria-controls={`${inputId}-listbox`}
+ className="h-8 w-full rounded bg-transparent"
+ >
+ <SelectValue placeholder={filter.value ? "True" : "False"} />
+ </SelectTrigger>
+ <SelectContent id={`${inputId}-listbox`}>
+ <SelectItem value="true">True</SelectItem>
+ <SelectItem value="false">False</SelectItem>
+ </SelectContent>
+ </Select>
+ )
+ }
+ default:
+ return null
+ }
+ }
+
+ return (
+ <Sortable
+ value={filters.map((item) => ({ id: item.rowId }))}
+ onMove={({ activeIndex, overIndex }) =>
+ moveFilter(activeIndex, overIndex)
+ }
+ overlay={
+ <div className="flex items-center gap-2">
+ <div className="h-8 min-w-[4.5rem] rounded-sm bg-primary/10" />
+ <div className="h-8 w-32 rounded-sm bg-primary/10" />
+ <div className="h-8 w-32 rounded-sm bg-primary/10" />
+ <div className="h-8 min-w-36 flex-1 rounded-sm bg-primary/10" />
+ <div className="size-8 shrink-0 rounded-sm bg-primary/10" />
+ <div className="size-8 shrink-0 rounded-sm bg-primary/10" />
+ </div>
+ }
+ >
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ className="gap-2"
+ aria-label="Open filters"
+ aria-controls={`${id}-filter-dialog`}
+ >
+ {/* 아이콘은 항상 표시 */}
+ <ListFilter className="size-3" aria-hidden="true" />
+
+ {/* 텍스트는 모바일에서 숨기고, sm 이상에서만 보임 */}
+ <span className="hidden sm:inline">
+ {t("Filters")}
+ </span>
+
+ {filters.length > 0 && (
+ <Badge
+ variant="secondary"
+ className="h-[1.14rem] rounded-[0.2rem] px-[0.32rem] font-mono text-[0.65rem] font-normal"
+ >
+ {filters.length}
+ </Badge>
+ )}
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent
+ id={`${id}-filter-dialog`}
+ align="start"
+ collisionPadding={16}
+ className={cn(
+ "flex w-[calc(100vw-theme(spacing.12))] min-w-60 origin-[var(--radix-popover-content-transform-origin)] flex-col p-4 sm:w-[39rem]",
+ filters.length > 0 ? "gap-3.5" : "gap-2"
+ )}
+ >
+ {filters.length > 0 ? (
+ <h4 className="font-medium leading-none"> {t("Filters")}</h4>
+ ) : (
+ <div className="flex flex-col gap-1">
+ <h4 className="font-medium leading-none">{t("nofilters")}</h4>
+ <p className="text-sm text-muted-foreground">
+ {t("addfilters")}
+ </p>
+ </div>
+ )}
+ <div className="flex max-h-40 flex-col gap-2 overflow-y-auto py-0.5 pr-1">
+ {filters.map((filter, index) => {
+ const filterId = `${id}-filter-${filter.rowId}`
+ const joinOperatorListboxId = `${filterId}-join-operator-listbox`
+ const fieldListboxId = `${filterId}-field-listbox`
+ const fieldTriggerId = `${filterId}-field-trigger`
+ const operatorListboxId = `${filterId}-operator-listbox`
+ const inputId = `${filterId}-input`
+
+ return (
+ <SortableItem key={filter.rowId} value={filter.rowId} asChild>
+ <div className="flex items-center gap-2">
+ <div className="w-[4.5rem] text-center">
+ {index === 0 ? (
+ <span className="text-sm text-muted-foreground">
+ {t("Where")}
+ </span>
+ ) : index === 1 ? (
+ <Select
+ value={joinOperator}
+ onValueChange={(value: JoinOperator) =>
+ setJoinOperator(value)
+ }
+ >
+ <SelectTrigger
+ aria-label="Select join operator"
+ aria-controls={joinOperatorListboxId}
+ className="h-8 rounded lowercase"
+ >
+ <SelectValue placeholder={joinOperator} />
+ </SelectTrigger>
+ <SelectContent
+ id={joinOperatorListboxId}
+ position="popper"
+ className="min-w-[var(--radix-select-trigger-width)] lowercase"
+ >
+ {dataTableConfig.joinOperators.map((op) => (
+ <SelectItem key={op.value} value={op.value}>
+ {t(op.label)}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ ) : (
+ <span className="text-sm text-muted-foreground">
+ {joinOperator}
+ </span>
+ )}
+ </div>
+ <Popover modal>
+ <PopoverTrigger asChild>
+ <Button
+ id={fieldTriggerId}
+ variant="outline"
+ size="sm"
+ role="combobox"
+ aria-label="Select filter field"
+ aria-controls={fieldListboxId}
+ className="h-8 w-32 justify-between gap-2 rounded focus:outline-none focus:ring-1 focus:ring-ring focus-visible:ring-0"
+ >
+ <span className="truncate">
+ {filterFields.find(
+ (field) => field.id === filter.id
+ )?.label ?? "Select field"}
+ </span>
+ <ChevronsUpDown className="size-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent
+ id={fieldListboxId}
+ align="start"
+ className="w-40 p-0"
+ onCloseAutoFocus={() =>
+ document.getElementById(fieldTriggerId)?.focus({
+ preventScroll: true,
+ })
+ }
+ >
+ <Command>
+ <CommandInput placeholder={t('searchFileds')}/>
+ <CommandList>
+ <CommandEmpty>{t("noFields")}</CommandEmpty>
+ <CommandGroup>
+ {filterFields.map((field) => (
+ <CommandItem
+ key={field.id}
+ value={field.id}
+ onSelect={(value) => {
+ const filterField = filterFields.find(
+ (col) => col.id === value
+ )
+
+ if (!filterField) return
+
+ updateFilter({
+ rowId: filter.rowId,
+ field: {
+ id: value as StringKeyOf<TData>,
+ type: filterField.type,
+ operator: getDefaultFilterOperator(
+ filterField.type
+ ),
+ value: "",
+ },
+ })
+
+ document
+ .getElementById(fieldTriggerId)
+ ?.click()
+ }}
+ >
+ <span className="mr-1.5 truncate">
+ {field.label}
+ </span>
+ <Check
+ className={cn(
+ "ml-auto size-4 shrink-0",
+ field.id === filter.id
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ <Select
+ value={filter.operator}
+ onValueChange={(value: FilterOperator) =>
+ updateFilter({
+ rowId: filter.rowId,
+ field: {
+ operator: value,
+ value:
+ value === "isEmpty" || value === "isNotEmpty"
+ ? ""
+ : filter.value,
+ },
+ })
+ }
+ >
+ <SelectTrigger
+ aria-label="Select filter operator"
+ aria-controls={operatorListboxId}
+ className="h-8 w-32 rounded"
+ >
+ <div className="truncate">
+ <SelectValue placeholder={filter.operator} />
+ </div>
+ </SelectTrigger>
+ <SelectContent id={operatorListboxId}>
+ {getFilterOperators(filter.type).map((op) => (
+ <SelectItem key={op.value} value={op.value}>
+ {t(op.label)}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <div className="min-w-36 flex-1 truncate">
+ {renderFilterInput({ filter, inputId })}
+ </div>
+ <Button
+ variant="outline"
+ size="icon"
+ aria-label={`Remove filter ${index + 1}`}
+ className="size-8 shrink-0 rounded"
+ onClick={() => removeFilter(filter.rowId)}
+ >
+ <Trash2 className="size-3.5" aria-hidden="true" />
+ </Button>
+ <SortableDragHandle
+ variant="outline"
+ size="icon"
+ className="size-8 shrink-0 rounded"
+ >
+ <GripVertical className="size-3.5" aria-hidden="true" />
+ </SortableDragHandle>
+ </div>
+ </SortableItem>
+ )
+ })}
+ </div>
+ <div className="flex w-full items-center gap-2">
+ <Button
+ size="sm"
+ className="h-[1.85rem] rounded"
+ onClick={addFilter}
+ >
+ {t('addFilter')}
+ </Button>
+ {filters.length > 0 ? (
+ <Button
+ size="sm"
+ variant="outline"
+ className="rounded"
+ onClick={() => {
+ void setFilters(null)
+ void setJoinOperator("and")
+ }}
+ >
+ {t('resetFilters')}
+ </Button>
+ ) : null}
+ </div>
+ </PopoverContent>
+ </Popover>
+ </Sortable>
+ )
+}
diff --git a/components/data-table/data-table-grobal-filter.tsx b/components/data-table/data-table-grobal-filter.tsx
new file mode 100644
index 00000000..a1f0a6f3
--- /dev/null
+++ b/components/data-table/data-table-grobal-filter.tsx
@@ -0,0 +1,49 @@
+"use client"
+
+import * as React from "react"
+import { useQueryState } from "nuqs"
+import { Input } from "@/components/ui/input"
+import { useDebouncedCallback } from "@/hooks/use-debounced-callback"
+
+/**
+ * A generic "Global Filter" input that syncs its value with `?search=...` in the URL (shallow).
+ * Uses a custom `useDebouncedCallback` to reduce rapid updates.
+ */
+export function DataTableGlobalFilter() {
+ // The actual "search" state is still read/written from URL
+ const [searchValue, setSearchValue] = useQueryState("search", {
+ parse: (str) => str,
+ serialize: (val) => val,
+ eq: (a, b) => a === b,
+ clearOnDefault: true,
+ shallow: false,
+ })
+
+ // Local tempValue to update instantly on user keystroke
+ const [tempValue, setTempValue] = React.useState(searchValue ?? "")
+
+ // Debounced callback that sets the URL param after `delay` ms
+ const debouncedSetSearch = useDebouncedCallback((value: string) => {
+ setSearchValue(value)
+ }, 300) // 300ms or chosen delay
+
+ // When user types, update local `tempValue` immediately,
+ // then call the debounced function to update the query param
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ const val = e.target.value
+ setTempValue(val)
+ debouncedSetSearch(val)
+ }
+
+ // Debug
+ console.log("tempValue:", tempValue, "searchValue:", searchValue)
+
+ return (
+ <Input
+ value={tempValue}
+ onChange={handleChange}
+ placeholder="Search..."
+ className="h-8 w-24 sm:w-40"
+ />
+ )
+} \ No newline at end of file
diff --git a/components/data-table/data-table-group-list.tsx b/components/data-table/data-table-group-list.tsx
new file mode 100644
index 00000000..cde1cadd
--- /dev/null
+++ b/components/data-table/data-table-group-list.tsx
@@ -0,0 +1,317 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { useQueryState, parseAsArrayOf, parseAsString } from "nuqs"
+import { Layers, Check, ChevronsUpDown, GripVertical, XCircle } from "lucide-react"
+
+import { toSentenceCase, cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import {
+ Popover,
+ PopoverTrigger,
+ PopoverContent,
+} from "@/components/ui/popover"
+import {
+ Command,
+ CommandList,
+ CommandGroup,
+ CommandItem,
+ CommandInput,
+ CommandEmpty,
+} from "@/components/ui/command"
+import {
+ Sortable,
+ SortableItem,
+ SortableDragHandle,
+} from "@/components/ui/sortable"
+
+interface DataTableGroupListProps<TData> {
+ /** TanStack Table 인스턴스 (grouping을 이미 사용할 수 있어야 함) */
+ table: Table<TData>
+ /** 정렬과 동일하게 URL 쿼리에 grouping을 저장할 때 쓰는 debounce 시간 (ms) */
+ debounceMs: number
+ /** shallow 라우팅 여부 */
+ shallow?: boolean
+}
+
+export function DataTableGroupList<TData>({
+ table,
+ debounceMs,
+ shallow,
+}: DataTableGroupListProps<TData>) {
+ const id = React.useId()
+
+ // ------------------------------------------------------
+ // 1) 초기 그룹핑 상태 + URL Query State 동기화
+ // ------------------------------------------------------
+ const initialGrouping = (table.initialState.grouping ?? []) as string[]
+
+ // group 쿼리 파라미터를 string[]로 파싱
+ // parseAsArrayOf(parseAsString, ',')를 이용
+ const [grouping, setGrouping] = useQueryState(
+ "group",
+ parseAsArrayOf(parseAsString, ",")
+ .withDefault(initialGrouping)
+ .withOptions({
+ clearOnDefault: true,
+ shallow,
+ })
+ )
+
+ // TanStack Table의 `table.setGrouping()`과 동기화
+ // (정렬 모달 예시에서 setSorting()을 쓰듯이 여기서는 setGrouping() 호출)
+ React.useEffect(() => {
+ table.setGrouping(grouping)
+ }, [grouping, table])
+
+ // 이미 중복 추가된 그룹은 제거
+ // (정렬 예시에서도 uniqueSorting 했듯이)
+ const uniqueGrouping = React.useMemo(
+ () => grouping.filter((id, i, self) => self.indexOf(id) === i),
+ [grouping]
+ )
+
+ // ------------------------------------------------------
+ // 2) 그룹핑 가능한 컬럼만 골라내기
+ // ------------------------------------------------------
+ const groupableColumns = React.useMemo(
+ () =>
+ table
+ .getAllColumns()
+ .filter((col) => col.getCanGroup?.() !== false)
+ .map((col) => ({
+ id: col.id,
+ label: toSentenceCase(col.id),
+ })),
+ [table]
+ )
+
+ // 이미 그룹핑 중인 컬럼 제외하고 "추가 가능"한 컬럼들
+ const ungroupedColumns = React.useMemo(() => {
+ return groupableColumns.filter(
+ (column) => !grouping.includes(column.id)
+ )
+ }, [groupableColumns, grouping])
+
+
+
+ // ------------------------------------------------------
+ // 3) 그룹 배열을 업데이트하는 함수들
+ // ------------------------------------------------------
+
+ // 드래그/드롭으로 순서 변경
+ function onGroupOrderChange(newGroups: string[]) {
+ setGrouping(newGroups)
+ }
+
+ // "Add group" : 아직 그룹핑되지 않은 첫 번째 컬럼 추가
+ function addGroup() {
+ const firstAvailable = ungroupedColumns[0]
+ if (!firstAvailable) return
+ setGrouping([...grouping, firstAvailable.id])
+ }
+
+ // 특정 아이템(그룹 컬럼 id) 제거
+ function removeGroup(id: string) {
+ setGrouping((prev) => prev.filter((g) => g !== id))
+ }
+
+ // 전체 그룹핑 초기화
+ function resetGrouping() {
+ setGrouping([])
+ }
+
+ // ------------------------------------------------------
+ // 4) 렌더링
+ // ------------------------------------------------------
+
+ return (
+ <Sortable
+ // sorting 예시처럼 Sortable 컨테이너로 감싸기
+ // 여기선 "grouping"을 바로 value로 넘길 수 없고,
+ // Sortable는 { id: UniqueIdentifier }[] 형태를 요구하므로 변환 필요
+ value={grouping.map((id) => ({ id }))}
+ onValueChange={(items) => {
+ // 드래그 완료 시 string[] 형태로 되돌림
+ onGroupOrderChange(items.map((i) => i.id))
+ }}
+ // overlay : 드래그 중 placeholder UI
+ overlay={
+ <div className="flex items-center gap-2">
+ <div className="h-8 w-[11.25rem] rounded-sm bg-primary/10" />
+ <div className="h-8 w-24 rounded-sm bg-primary/10" />
+ <div className="size-8 shrink-0 rounded-sm bg-primary/10" />
+ </div>
+ }
+ >
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ className="gap-2"
+ aria-label="Open grouping"
+ aria-controls={`${id}-group-dialog`}
+ >
+ <Layers className="size-3" aria-hidden="true" />
+ <span className="hidden sm:inline">Group</span>
+ {uniqueGrouping.length > 0 && (
+ <Badge
+ variant="secondary"
+ className="h-[1.14rem] rounded-[0.2rem] px-[0.32rem] font-mono text-[0.65rem] font-normal"
+ >
+ {uniqueGrouping.length}
+ </Badge>
+ )}
+ </Button>
+ </PopoverTrigger>
+
+ <PopoverContent
+ id={`${id}-group-dialog`}
+ align="start"
+ collisionPadding={16}
+ className={cn(
+ "flex w-[calc(100vw-theme(spacing.20))] min-w-72 max-w-[25rem] origin-[var(--radix-popover-content-transform-origin)] flex-col p-4 sm:w-[25rem]",
+ grouping.length > 0 ? "gap-3.5" : "gap-2"
+ )}
+ >
+ {uniqueGrouping.length > 0 ? (
+ <>
+ <h4 className="font-medium leading-none">Group by</h4>
+ <p className="text-sm text-muted-foreground">
+ 그룹핑은 불러온 데이터에 한해서 그룹핑이 됩니다.
+ </p>
+ </>
+
+ ) : (
+ <div className="flex flex-col gap-1">
+ <h4 className="font-medium leading-none">No grouping applied</h4>
+ <p className="text-sm text-muted-foreground">
+ Add grouping to organize your results.
+ </p>
+ </div>
+ )}
+
+ {/* 그룹 목록 */}
+ <div className="flex max-h-40 flex-col gap-2 overflow-y-auto p-0.5">
+ <div className="flex w-full flex-col gap-2">
+ {uniqueGrouping.map((colId) => {
+ // SortableItem에 key로 colId
+ return (
+ <SortableItem key={colId} value={colId} asChild>
+ <div className="flex items-center gap-2">
+ <Popover modal>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ role="combobox"
+ className="h-8 w-44 justify-between gap-2 rounded focus:outline-none focus:ring-1 focus:ring-ring"
+ aria-label={`Select column for group ${colId}`}
+ >
+ <span className="truncate">
+ {toSentenceCase(colId)}
+ </span>
+ <div className="ml-auto flex items-center gap-1">
+ <ChevronsUpDown
+ className="size-4 shrink-0 opacity-50"
+ aria-hidden="true"
+ />
+ </div>
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent
+ className="w-[var(--radix-popover-trigger-width)] p-0"
+ >
+ <Command>
+ <CommandInput placeholder="Search columns..." />
+ <CommandList>
+ <CommandEmpty>No columns found.</CommandEmpty>
+ <CommandGroup>
+ {ungroupedColumns.map((column) => (
+ <CommandItem
+ key={column.id}
+ value={column.id}
+ onSelect={(value) => {
+ // colId -> 새로 선택한 value로 교체
+ setGrouping((prev) =>
+ prev.map((g) =>
+ g === colId ? value : g
+ )
+ )
+ }}
+ >
+ <span className="mr-1.5 truncate">
+ {column.label}
+ </span>
+ <Check
+ className={cn(
+ "ml-auto size-4 shrink-0",
+ column.id === colId
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ aria-hidden="true"
+ />
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+
+ {/* remove group */}
+ <Button
+ variant="outline"
+ size="icon"
+ aria-label={`Remove group ${colId}`}
+ className="size-8 shrink-0 rounded"
+ onClick={() => removeGroup(colId)}
+ >
+ <XCircle className="size-3.5" aria-hidden="true" />
+ </Button>
+
+ {/* drag handle */}
+ <SortableDragHandle
+ variant="outline"
+ size="icon"
+ className="size-8 shrink-0 rounded"
+ >
+ <GripVertical className="size-3.5" aria-hidden="true" />
+ </SortableDragHandle>
+ </div>
+ </SortableItem>
+ )
+ })}
+ </div>
+ </div>
+
+ <div className="flex w-full items-center gap-2">
+ {/* 새 그룹 추가 */}
+ <Button
+ size="sm"
+ className="h-[1.85rem] rounded"
+ onClick={addGroup}
+ disabled={grouping.length >= groupableColumns.length}
+ >
+ Add group
+ </Button>
+ {grouping.length > 0 && (
+ <Button
+ size="sm"
+ variant="outline"
+ className="rounded"
+ onClick={resetGrouping}
+ >
+ Reset grouping
+ </Button>
+ )}
+ </div>
+ </PopoverContent>
+ </Popover>
+ </Sortable>
+ )
+} \ No newline at end of file
diff --git a/components/data-table/data-table-pagination.tsx b/components/data-table/data-table-pagination.tsx
new file mode 100644
index 00000000..7a2a03f8
--- /dev/null
+++ b/components/data-table/data-table-pagination.tsx
@@ -0,0 +1,132 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import {
+ ChevronLeft,
+ ChevronRight,
+ ChevronsLeft,
+ ChevronsRight,
+} from "lucide-react"
+
+import { Button } from "@/components/ui/button"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+
+interface DataTablePaginationProps<TData> {
+ table: Table<TData>
+ pageSizeOptions?: Array<number | "All">
+}
+
+export function DataTablePagination<TData>({
+ table,
+ pageSizeOptions = [10, 20, 30, 40, 50, "All"],
+}: DataTablePaginationProps<TData>) {
+ // 현재 테이블 pageSize
+ const currentPageSize = table.getState().pagination.pageSize
+
+ // "All"을 1,000,000으로 처리할 것이므로,
+ // 만약 현재 pageSize가 1,000,000이면 화면상 "All"로 표시
+ const selectValue =
+ currentPageSize === 1_000_000
+ ? "All"
+ : String(currentPageSize)
+
+ return (
+ <div className="flex w-full flex-col-reverse items-center justify-between gap-4 overflow-auto p-1 sm:flex-row sm:gap-8">
+ <div className="flex-1 whitespace-nowrap text-sm text-muted-foreground">
+ {table.getFilteredSelectedRowModel().rows.length} of{" "}
+ {table.getFilteredRowModel().rows.length} row(s) selected.
+ </div>
+ <div className="flex flex-col-reverse items-center gap-4 sm:flex-row sm:gap-6 lg:gap-8">
+ {/* Rows per page Select */}
+ <div className="flex items-center space-x-2">
+ <p className="whitespace-nowrap text-sm font-medium">Rows per page</p>
+ <Select
+ value={selectValue}
+ onValueChange={(value) => {
+ if (value === "All") {
+ // "All"을 1,000,000으로 치환
+ table.setPageSize(1_000_000)
+ } else {
+ table.setPageSize(Number(value))
+ }
+ }}
+ >
+ <SelectTrigger className="h-8 w-[4.5rem]">
+ <SelectValue placeholder={selectValue} />
+ </SelectTrigger>
+ <SelectContent side="top">
+ {pageSizeOptions.map((option) => {
+ // 화면에 표시할 라벨
+ const label = option === "All" ? "All" : String(option)
+ // value도 문자열화
+ const val = option === "All" ? "All" : String(option)
+
+ return (
+ <SelectItem key={val} value={val}>
+ {label}
+ </SelectItem>
+ )
+ })}
+ </SelectContent>
+ </Select>
+ </div>
+
+ {/* 현재 페이지 / 전체 페이지 */}
+ <div className="flex items-center justify-center text-sm font-medium">
+ Page {table.getState().pagination.pageIndex + 1} of{" "}
+ {table.getPageCount()}
+ </div>
+
+ {/* 페이지 이동 버튼 */}
+ <div className="flex items-center space-x-2">
+ <Button
+ aria-label="Go to first page"
+ variant="outline"
+ className="hidden size-8 p-0 lg:flex"
+ onClick={() => table.setPageIndex(0)}
+ disabled={!table.getCanPreviousPage()}
+ >
+ <ChevronsLeft className="size-4" aria-hidden="true" />
+ </Button>
+ <Button
+ aria-label="Go to previous page"
+ variant="outline"
+ size="icon"
+ className="size-8"
+ onClick={() => table.previousPage()}
+ disabled={!table.getCanPreviousPage()}
+ >
+ <ChevronLeft className="size-4" aria-hidden="true" />
+ </Button>
+ <Button
+ aria-label="Go to next page"
+ variant="outline"
+ size="icon"
+ className="size-8"
+ onClick={() => table.nextPage()}
+ disabled={!table.getCanNextPage()}
+ >
+ <ChevronRight className="size-4" aria-hidden="true" />
+ </Button>
+ <Button
+ aria-label="Go to last page"
+ variant="outline"
+ size="icon"
+ className="hidden size-8 lg:flex"
+ onClick={() => table.setPageIndex(table.getPageCount() - 1)}
+ disabled={!table.getCanNextPage()}
+ >
+ <ChevronsRight className="size-4" aria-hidden="true" />
+ </Button>
+ </div>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/components/data-table/data-table-pin-left.tsx b/components/data-table/data-table-pin-left.tsx
new file mode 100644
index 00000000..81e83564
--- /dev/null
+++ b/components/data-table/data-table-pin-left.tsx
@@ -0,0 +1,95 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Check, ChevronsUpDown, MoveLeft } from "lucide-react"
+
+import { cn, toSentenceCase } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+
+/**
+ * “Pin Left” Popover. Lists columns that can be pinned.
+ * If pinned===‘left’ → checked, if pinned!==‘left’ → unchecked.
+ * Toggling check => pin(‘left’) or pin(false).
+ */
+export function PinLeftButton<TData>({ table }: { table: Table<TData> }) {
+ const [open, setOpen] = React.useState(false)
+ const triggerRef = React.useRef<HTMLButtonElement>(null)
+
+ return (
+ <Popover open={open} onOpenChange={setOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ ref={triggerRef}
+ variant="outline"
+ size="sm"
+ className="h-8 gap-2"
+ >
+ <MoveLeft className="size-4" />
+
+ <span className="hidden sm:inline">
+ Left
+ </span>
+
+ <ChevronsUpDown className="ml-1 size-4 opacity-50 hidden sm:inline" />
+ </Button>
+ </PopoverTrigger>
+
+ <PopoverContent
+ align="end"
+ className="w-44 p-0"
+ onCloseAutoFocus={() => triggerRef.current?.focus()}
+ >
+ <Command>
+ <CommandInput placeholder="Search columns..." />
+ <CommandList>
+ <CommandEmpty>No columns found.</CommandEmpty>
+ <CommandGroup>
+ {table
+ .getAllLeafColumns()
+ .filter((col) => col.getCanPin?.())
+ .map((column) => {
+ const pinned = column.getIsPinned?.() // 'left'|'right'|false
+ // => pinned === 'left' => checked
+ return (
+ <CommandItem
+ key={column.id}
+ onSelect={() => {
+ // if currently pinned===left => unpin
+ // else => pin left
+ column.pin?.(pinned === "left" ? false : "left")
+ }}
+ >
+ <span className="truncate">
+ {toSentenceCase(column.id)}
+ </span>
+ {/* Check if pinned===‘left’ */}
+ <Check
+ className={cn(
+ "ml-auto size-4 shrink-0",
+ pinned === "left" ? "opacity-100" : "opacity-0"
+ )}
+ />
+ </CommandItem>
+ )
+ })}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ )
+} \ No newline at end of file
diff --git a/components/data-table/data-table-pin-right.tsx b/components/data-table/data-table-pin-right.tsx
new file mode 100644
index 00000000..051dd985
--- /dev/null
+++ b/components/data-table/data-table-pin-right.tsx
@@ -0,0 +1,88 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Check, ChevronsUpDown, MoveRight } from "lucide-react"
+
+import { cn, toSentenceCase } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+
+/**
+ * “Pin Right” Popover. Similar to PinLeftButton, but pins columns to "right".
+ */
+export function PinRightButton<TData>({ table }: { table: Table<TData> }) {
+ const [open, setOpen] = React.useState(false)
+ const triggerRef = React.useRef<HTMLButtonElement>(null)
+
+ return (
+ <Popover open={open} onOpenChange={setOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ ref={triggerRef}
+ variant="outline"
+ size="sm"
+ className="h-8 gap-2"
+ >
+ <MoveRight className="size-4" />
+
+ <span className="hidden sm:inline">
+ Right
+ </span>
+ <ChevronsUpDown className="ml-1 size-4 opacity-50 hidden sm:inline" />
+ </Button>
+ </PopoverTrigger>
+
+ <PopoverContent
+ align="end"
+ className="w-44 p-0"
+ onCloseAutoFocus={() => triggerRef.current?.focus()}
+ >
+ <Command>
+ <CommandInput placeholder="Search columns..." />
+ <CommandList>
+ <CommandEmpty>No columns found.</CommandEmpty>
+ <CommandGroup>
+ {table
+ .getAllLeafColumns()
+ .filter((col) => col.getCanPin?.())
+ .map((column) => {
+ const pinned = column.getIsPinned?.()
+ return (
+ <CommandItem
+ key={column.id}
+ onSelect={() => {
+ column.pin?.(pinned === "right" ? false : "right")
+ }}
+ >
+ <span className="truncate">
+ {toSentenceCase(column.id)}
+ </span>
+ <Check
+ className={cn(
+ "ml-auto size-4 shrink-0",
+ pinned === "right" ? "opacity-100" : "opacity-0"
+ )}
+ />
+ </CommandItem>
+ )
+ })}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ )
+} \ No newline at end of file
diff --git a/components/data-table/data-table-pin.tsx b/components/data-table/data-table-pin.tsx
new file mode 100644
index 00000000..991152f6
--- /dev/null
+++ b/components/data-table/data-table-pin.tsx
@@ -0,0 +1,146 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import {
+ MoveLeft,
+ MoveRight,
+ Slash,
+ ChevronsUpDown,
+} from "lucide-react"
+
+import { cn, toSentenceCase } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+
+/**
+ * Button that opens a popover with a list of columns.
+ * Each column can be pinned left, pinned right, or unpinned.
+ */
+export function DataTablePinList<TData>({ table }: { table: Table<TData> }) {
+ const [open, setOpen] = React.useState(false)
+
+ return (
+ <Popover open={open} onOpenChange={setOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ className="h-8 gap-2"
+ >
+ Pin
+ <ChevronsUpDown className="size-4 opacity-50" aria-hidden="true" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent align="end" className="w-48 p-0">
+ <Command>
+ <CommandInput placeholder="Search columns..." />
+ <CommandList>
+ <CommandEmpty>No columns found.</CommandEmpty>
+ <CommandGroup>
+ {table
+ .getAllLeafColumns()
+ .filter((col) => col.getCanPin?.()) // Only show columns that can be pinned
+ .map((column) => {
+ const pinned = column.getIsPinned?.() // 'left' | 'right' | false
+ return (
+ <PinColumnItem
+ key={column.id}
+ column={column}
+ pinned={pinned}
+ onPinnedChange={(newPin) => {
+ column.pin?.(newPin === "none" ? false : newPin)
+ }}
+ />
+ )
+ })}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ )
+}
+
+/**
+ * Renders a single column row (CommandItem) with sub-options:
+ * Left, Right, or Unpin
+ */
+function PinColumnItem({
+ column,
+ pinned,
+ onPinnedChange,
+}: {
+ column: any
+ pinned: "left" | "right" | false
+ onPinnedChange: (newPin: "left" | "right" | "none") => void
+}) {
+ const [subOpen, setSubOpen] = React.useState(false)
+ const colId = column.id
+
+ const handleMainSelect = () => {
+ // Toggle subOpen to show sub-options
+ setSubOpen((prev) => !prev)
+ }
+
+ return (
+ <>
+ <CommandItem onSelect={handleMainSelect}>
+ <span className="truncate">{toSentenceCase(colId)}</span>
+ {pinned === "left" && (
+ <MoveLeft className="ml-auto size-4 text-primary" />
+ )}
+ {pinned === "right" && (
+ <MoveRight className="ml-auto size-4 text-primary" />
+ )}
+ {pinned === false && (
+ <Slash className="ml-auto size-4 opacity-50" />
+ )}
+ </CommandItem>
+
+ {subOpen && (
+ <div className="ml-4 flex flex-col gap-1 border-l pl-2 pt-1">
+ <CommandItem
+ onSelect={() => {
+ onPinnedChange("left")
+ setSubOpen(false)
+ }}
+ >
+ <MoveLeft className="mr-2 size-4" />
+ Pin Left
+ </CommandItem>
+ <CommandItem
+ onSelect={() => {
+ onPinnedChange("right")
+ setSubOpen(false)
+ }}
+ >
+ <MoveRight className="mr-2 size-4" />
+ Pin Right
+ </CommandItem>
+ <CommandItem
+ onSelect={() => {
+ onPinnedChange("none")
+ setSubOpen(false)
+ }}
+ >
+ <Slash className="mr-2 size-4" />
+ No Pin
+ </CommandItem>
+ </div>
+ )}
+ </>
+ )
+} \ No newline at end of file
diff --git a/components/data-table/data-table-resizer.tsx b/components/data-table/data-table-resizer.tsx
new file mode 100644
index 00000000..9723a0b4
--- /dev/null
+++ b/components/data-table/data-table-resizer.tsx
@@ -0,0 +1,98 @@
+"use client"
+
+import * as React from "react"
+import { cn } from "@/lib/utils"
+import { Header } from "@tanstack/react-table"
+
+interface DataTableResizerProps<TData, TValue>
+ extends React.HTMLAttributes<HTMLDivElement> {
+ header: Header<TData, TValue>
+}
+
+export function DataTableResizer<TData, TValue>({
+ header,
+ className,
+ ...props
+}: DataTableResizerProps<TData, TValue>) {
+ const contentRef = React.useRef<HTMLDivElement>(null)
+
+ // 더블클릭 시 너비 자동 조정 함수
+ const handleDoubleClick = React.useCallback(() => {
+ // 테이블 인스턴스 가져오기
+ const table = header.getContext().table
+
+ // 0. 몇 가지 기본 설정
+ const defaultMinWidth = 80 // 기본 최소 너비
+ const extraPadding = 24 // 여유 공간
+
+ // 헤더 타이틀 얻기 시도
+ const headerElement = contentRef.current?.closest('th')
+ const headerText = headerElement?.textContent || ""
+
+ // 1. 컬럼 ID 가져오기
+ const columnId = header.column.id
+
+ // 2. 테이블 바디에서 해당 ID를 가진 모든 셀 선택
+ const allCells = document.querySelectorAll(`tbody td[data-column-id="${columnId}"]`)
+
+ // 3. 최대 컨텐츠 너비 측정을 위한 임시 요소 생성
+ const measureElement = document.createElement('div')
+ measureElement.style.position = 'absolute'
+ measureElement.style.visibility = 'hidden'
+ measureElement.style.whiteSpace = 'nowrap' // 내용이 줄바꿈되지 않도록
+ measureElement.style.font = window.getComputedStyle(headerElement || document.body).font // 동일한 폰트 사용
+ document.body.appendChild(measureElement)
+
+ // 4. 헤더 너비 측정
+ measureElement.textContent = headerText
+ let maxWidth = measureElement.getBoundingClientRect().width
+
+ // 5. 모든 셀의 내용 너비 측정하고 최대값 찾기
+ Array.from(allCells).forEach(cell => {
+ const cellText = cell.textContent || ""
+ measureElement.textContent = cellText
+ const cellWidth = measureElement.getBoundingClientRect().width
+ maxWidth = Math.max(maxWidth, cellWidth)
+ })
+
+ // 6. 측정용 요소 제거
+ document.body.removeChild(measureElement)
+
+ // 7. 계산된 너비에 여유 공간 추가
+ let finalWidth = maxWidth + extraPadding
+
+ // 8. 최소 너비 적용
+ const minWidth = header.column.columnDef.minSize || defaultMinWidth
+ finalWidth = Math.max(finalWidth, minWidth)
+
+ // 9. 컬럼 사이즈 업데이트
+ const columnSizingInfo = table.getState().columnSizing
+ const updatedSizing = {
+ ...columnSizingInfo,
+ [columnId]: finalWidth
+ }
+
+ table.setColumnSizing(updatedSizing)
+ }, [header])
+
+ return (
+ <>
+ {/* 헤더 콘텐츠 참조를 위한 요소 */}
+ <div ref={contentRef} className="absolute opacity-0 pointer-events-none" />
+
+ {/* 리사이저 */}
+ <div
+ {...props}
+ onMouseDown={header.getResizeHandler()}
+ onTouchStart={header.getResizeHandler()}
+ onDoubleClick={handleDoubleClick} // 더블클릭 핸들러 추가
+ className={cn(
+ "absolute right-0 top-0 h-full w-1 cursor-col-resize bg-transparent hover:bg-gray-300 active:bg-gray-400",
+ header.column.getIsResizing() ? "bg-gray-400" : "",
+ className
+ )}
+ title="더블 클릭하여 내용에 맞게 크기 조정" // 힌트 추가
+ />
+ </>
+ )
+} \ No newline at end of file
diff --git a/components/data-table/data-table-skeleton.tsx b/components/data-table/data-table-skeleton.tsx
new file mode 100644
index 00000000..09c394e1
--- /dev/null
+++ b/components/data-table/data-table-skeleton.tsx
@@ -0,0 +1,169 @@
+"use client"
+
+import { cn } from "@/lib/utils"
+import { Skeleton } from "@/components/ui/skeleton"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+
+interface DataTableSkeletonProps extends React.HTMLAttributes<HTMLDivElement> {
+ /**
+ * The number of columns in the table.
+ * @type number
+ */
+ columnCount: number
+
+ /**
+ * The number of rows in the table.
+ * @default 10
+ * @type number | undefined
+ */
+ rowCount?: number
+
+ /**
+ * The number of searchable columns in the table.
+ * @default 0
+ * @type number | undefined
+ */
+ searchableColumnCount?: number
+
+ /**
+ * The number of filterable columns in the table.
+ * @default 0
+ * @type number | undefined
+ */
+ filterableColumnCount?: number
+
+ /**
+ * Flag to show the table view options.
+ * @default undefined
+ * @type boolean | undefined
+ */
+ showViewOptions?: boolean
+
+ /**
+ * The width of each cell in the table.
+ * The length of the array should be equal to the columnCount.
+ * Any valid CSS width value is accepted.
+ * @default ["auto"]
+ * @type string[] | undefined
+ */
+ cellWidths?: string[]
+
+ /**
+ * Flag to show the pagination bar.
+ * @default true
+ * @type boolean | undefined
+ */
+ withPagination?: boolean
+
+ /**
+ * Flag to prevent the table cells from shrinking.
+ * @default false
+ * @type boolean | undefined
+ */
+ shrinkZero?: boolean
+}
+
+export function DataTableSkeleton(props: DataTableSkeletonProps) {
+ const {
+ columnCount,
+ rowCount = 10,
+ searchableColumnCount = 0,
+ filterableColumnCount = 0,
+ showViewOptions = true,
+ cellWidths = ["auto"],
+ withPagination = true,
+ shrinkZero = false,
+ className,
+ ...skeletonProps
+ } = props
+
+ return (
+ <div
+ className={cn("w-full space-y-2.5 overflow-auto", className)}
+ {...skeletonProps}
+ >
+ <div className="flex w-full items-center justify-between space-x-2 overflow-auto p-1">
+ <div className="flex flex-1 items-center space-x-2">
+ {searchableColumnCount > 0
+ ? Array.from({ length: searchableColumnCount }).map((_, i) => (
+ <Skeleton key={i} className="h-7 w-40 lg:w-60" />
+ ))
+ : null}
+ {filterableColumnCount > 0
+ ? Array.from({ length: filterableColumnCount }).map((_, i) => (
+ <Skeleton key={i} className="h-7 w-[4.5rem] border-dashed" />
+ ))
+ : null}
+ </div>
+ {showViewOptions ? (
+ <Skeleton className="ml-auto hidden h-7 w-[4.5rem] lg:flex" />
+ ) : null}
+ </div>
+ <div className="rounded-md border">
+ <Table>
+ <TableHeader>
+ {Array.from({ length: 1 }).map((_, i) => (
+ <TableRow key={i} className="hover:bg-transparent">
+ {Array.from({ length: columnCount }).map((_, j) => (
+ <TableHead
+ key={j}
+ style={{
+ width: cellWidths[j],
+ minWidth: shrinkZero ? cellWidths[j] : "auto",
+ }}
+ >
+ <Skeleton className="h-6 w-full" />
+ </TableHead>
+ ))}
+ </TableRow>
+ ))}
+ </TableHeader>
+ <TableBody>
+ {Array.from({ length: rowCount }).map((_, i) => (
+ <TableRow key={i} className="hover:bg-transparent">
+ {Array.from({ length: columnCount }).map((_, j) => (
+ <TableCell
+ key={j}
+ style={{
+ width: cellWidths[j],
+ minWidth: shrinkZero ? cellWidths[j] : "auto",
+ }}
+ >
+ <Skeleton className="h-6 w-full" />
+ </TableCell>
+ ))}
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </div>
+ {withPagination ? (
+ <div className="flex w-full items-center justify-between gap-4 overflow-auto p-1 sm:gap-8">
+ <Skeleton className="h-7 w-40 shrink-0" />
+ <div className="flex items-center gap-4 sm:gap-6 lg:gap-8">
+ <div className="flex items-center space-x-2">
+ <Skeleton className="h-7 w-24" />
+ <Skeleton className="h-7 w-[4.5rem]" />
+ </div>
+ <div className="flex items-center justify-center text-sm font-medium">
+ <Skeleton className="h-7 w-20" />
+ </div>
+ <div className="flex items-center space-x-2">
+ <Skeleton className="hidden size-7 lg:block" />
+ <Skeleton className="size-7" />
+ <Skeleton className="size-7" />
+ <Skeleton className="hidden size-7 lg:block" />
+ </div>
+ </div>
+ </div>
+ ) : null}
+ </div>
+ )
+}
diff --git a/components/data-table/data-table-sort-list.tsx b/components/data-table/data-table-sort-list.tsx
new file mode 100644
index 00000000..686545fc
--- /dev/null
+++ b/components/data-table/data-table-sort-list.tsx
@@ -0,0 +1,370 @@
+"use client"
+
+import * as React from "react"
+import type {
+ ExtendedColumnSort,
+ ExtendedSortingState,
+ StringKeyOf,
+} from "@/types/table"
+import type { SortDirection, Table } from "@tanstack/react-table"
+import {
+ ArrowDownUp,
+ Check,
+ ChevronsUpDown,
+ GripVertical,
+ Trash2,
+} from "lucide-react"
+import { useQueryState } from "nuqs"
+
+import { dataTableConfig } from "@/config/data-table"
+import { getSortingStateParser } from "@/lib/parsers"
+import { cn, toSentenceCase } from "@/lib/utils"
+import { useDebouncedCallback } from "@/hooks/use-debounced-callback"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import {
+ Sortable,
+ SortableDragHandle,
+ SortableItem,
+} from "@/components/ui/sortable"
+
+interface DataTableSortListProps<TData> {
+ table: Table<TData>
+ debounceMs: number
+ shallow?: boolean
+}
+
+export function DataTableSortList<TData>({
+ table,
+ debounceMs,
+ shallow,
+}: DataTableSortListProps<TData>) {
+ const id = React.useId()
+
+ const initialSorting = (table.initialState.sorting ??
+ []) as ExtendedSortingState<TData>
+
+ const [sorting, setSorting] = useQueryState(
+ "sort",
+ getSortingStateParser(table.getRowModel().rows[0]?.original)
+ .withDefault(initialSorting)
+ .withOptions({
+ clearOnDefault: true,
+ shallow,
+ })
+ )
+
+ const uniqueSorting = React.useMemo(
+ () =>
+ sorting.filter(
+ (sort, index, self) => index === self.findIndex((t) => t.id === sort.id)
+ ),
+ [sorting]
+ )
+
+ const debouncedSetSorting = useDebouncedCallback(setSorting, debounceMs)
+
+ const sortableColumns = React.useMemo(
+ () =>
+ table
+ .getAllColumns()
+ .filter(
+ (column) =>
+ column.getCanSort() && !sorting.some((s) => s.id === column.id)
+ )
+ .map((column) => ({
+ id: column.id,
+ label: toSentenceCase(column.id),
+ selected: false,
+ })),
+ [sorting, table]
+ )
+
+ function addSort() {
+ const firstAvailableColumn = sortableColumns.find(
+ (column) => !sorting.some((s) => s.id === column.id)
+ )
+ if (!firstAvailableColumn) return
+
+ void setSorting([
+ ...sorting,
+ {
+ id: firstAvailableColumn.id as StringKeyOf<TData>,
+ desc: false,
+ },
+ ])
+ }
+
+ function updateSort({
+ id,
+ field,
+ debounced = false,
+ }: {
+ id: string
+ field: Partial<ExtendedColumnSort<TData>>
+ debounced?: boolean
+ }) {
+ const updateFunction = debounced ? debouncedSetSorting : setSorting
+
+ updateFunction((prevSorting) => {
+ if (!prevSorting) return prevSorting
+
+ const updatedSorting = prevSorting.map((sort) =>
+ sort.id === id ? { ...sort, ...field } : sort
+ )
+ return updatedSorting
+ })
+ }
+
+ function removeSort(id: string) {
+ void setSorting((prevSorting) =>
+ prevSorting.filter((item) => item.id !== id)
+ )
+ }
+
+ return (
+ <Sortable
+ value={sorting}
+ onValueChange={setSorting}
+ overlay={
+ <div className="flex items-center gap-2">
+ <div className="h-8 w-[11.25rem] rounded-sm bg-primary/10" />
+ <div className="h-8 w-24 rounded-sm bg-primary/10" />
+ <div className="size-8 shrink-0 rounded-sm bg-primary/10" />
+ <div className="size-8 shrink-0 rounded-sm bg-primary/10" />
+ </div>
+ }
+ >
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ className="gap-2"
+ aria-label="Open sorting"
+ aria-controls={`${id}-sort-dialog`}
+ >
+ <ArrowDownUp className="size-3" aria-hidden="true" />
+
+ <span className="hidden sm:inline">
+ Sort
+ </span>
+
+ {uniqueSorting.length > 0 && (
+ <Badge
+ variant="secondary"
+ className="h-[1.14rem] rounded-[0.2rem] px-[0.32rem] font-mono text-[0.65rem] font-normal"
+ >
+ {uniqueSorting.length}
+ </Badge>
+ )}
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent
+ id={`${id}-sort-dialog`}
+ align="start"
+ collisionPadding={16}
+ className={cn(
+ "flex w-[calc(100vw-theme(spacing.20))] min-w-72 max-w-[25rem] origin-[var(--radix-popover-content-transform-origin)] flex-col p-4 sm:w-[25rem]",
+ sorting.length > 0 ? "gap-3.5" : "gap-2"
+ )}
+ >
+ {uniqueSorting.length > 0 ? (
+ <h4 className="font-medium leading-none">Sort by</h4>
+ ) : (
+ <div className="flex flex-col gap-1">
+ <h4 className="font-medium leading-none">No sorting applied</h4>
+ <p className="text-sm text-muted-foreground">
+ Add sorting to organize your results.
+ </p>
+ </div>
+ )}
+ <div className="flex max-h-40 flex-col gap-2 overflow-y-auto p-0.5">
+ <div className="flex w-full flex-col gap-2">
+ {uniqueSorting.map((sort) => {
+ const sortId = `${id}-sort-${sort.id}`
+ const fieldListboxId = `${sortId}-field-listbox`
+ const fieldTriggerId = `${sortId}-field-trigger`
+ const directionListboxId = `${sortId}-direction-listbox`
+
+ return (
+ <SortableItem key={sort.id} value={sort.id} asChild>
+ <div className="flex items-center gap-2">
+ <Popover modal>
+ <PopoverTrigger asChild>
+ <Button
+ id={fieldTriggerId}
+ variant="outline"
+ size="sm"
+ role="combobox"
+ className="h-8 w-44 justify-between gap-2 rounded focus:outline-none focus:ring-1 focus:ring-ring"
+ aria-controls={fieldListboxId}
+ >
+ <span className="truncate">
+ {toSentenceCase(sort.id)}
+ </span>
+ <div className="ml-auto flex items-center gap-1">
+ {initialSorting.length === 1 &&
+ initialSorting[0]?.id === sort.id ? (
+ <Badge
+ variant="secondary"
+ className="h-[1.125rem] rounded px-1 font-mono text-[0.65rem] font-normal"
+ >
+ Default
+ </Badge>
+ ) : null}
+ <ChevronsUpDown
+ className="size-4 shrink-0 opacity-50"
+ aria-hidden="true"
+ />
+ </div>
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent
+ id={fieldListboxId}
+ className="w-[var(--radix-popover-trigger-width)] p-0"
+ onCloseAutoFocus={() =>
+ document.getElementById(fieldTriggerId)?.focus()
+ }
+ >
+ <Command>
+ <CommandInput placeholder="Search fields..." />
+ <CommandList>
+ <CommandEmpty>No fields found.</CommandEmpty>
+ <CommandGroup>
+ {sortableColumns.map((column) => (
+ <CommandItem
+ key={column.id}
+ value={column.id}
+ onSelect={(value) => {
+ const newFieldTriggerId = `${id}-sort-${value}-field-trigger`
+
+ updateSort({
+ id: sort.id,
+ field: {
+ id: value as StringKeyOf<TData>,
+ },
+ })
+
+ requestAnimationFrame(() => {
+ document
+ .getElementById(newFieldTriggerId)
+ ?.focus()
+ })
+ }}
+ >
+ <span className="mr-1.5 truncate">
+ {column.label}
+ </span>
+ <Check
+ className={cn(
+ "ml-auto size-4 shrink-0",
+ column.id === sort.id
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ aria-hidden="true"
+ />
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ <Select
+ value={sort.desc ? "desc" : "asc"}
+ onValueChange={(value: SortDirection) =>
+ updateSort({
+ id: sort.id,
+ field: { id: sort.id, desc: value === "desc" },
+ })
+ }
+ >
+ <SelectTrigger
+ aria-label="Select sort direction"
+ aria-controls={directionListboxId}
+ className="h-8 w-24 rounded"
+ >
+ <div className="truncate">
+ <SelectValue />
+ </div>
+ </SelectTrigger>
+ <SelectContent
+ id={directionListboxId}
+ className="min-w-[var(--radix-select-trigger-width)]"
+ >
+ {dataTableConfig.sortOrders.map((order) => (
+ <SelectItem key={order.value} value={order.value}>
+ {order.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <Button
+ variant="outline"
+ size="icon"
+ aria-label={`Remove sort ${sort.id}`}
+ className="size-8 shrink-0 rounded"
+ onClick={() => removeSort(sort.id)}
+ >
+ <Trash2 className="size-3.5" aria-hidden="true" />
+ </Button>
+ <SortableDragHandle
+ variant="outline"
+ size="icon"
+ className="size-8 shrink-0 rounded"
+ >
+ <GripVertical className="size-3.5" aria-hidden="true" />
+ </SortableDragHandle>
+ </div>
+ </SortableItem>
+ )
+ })}
+ </div>
+ </div>
+ <div className="flex w-full items-center gap-2">
+ <Button
+ size="sm"
+ className="h-[1.85rem] rounded"
+ onClick={addSort}
+ disabled={sorting.length >= sortableColumns.length}
+ >
+ Add sort
+ </Button>
+ {sorting.length > 0 ? (
+ <Button
+ size="sm"
+ variant="outline"
+ className="rounded"
+ onClick={() => setSorting(null)}
+ >
+ Reset sorting
+ </Button>
+ ) : null}
+ </div>
+ </PopoverContent>
+ </Popover>
+ </Sortable>
+ )
+}
diff --git a/components/data-table/data-table-toolbar.tsx b/components/data-table/data-table-toolbar.tsx
new file mode 100644
index 00000000..78c7c39d
--- /dev/null
+++ b/components/data-table/data-table-toolbar.tsx
@@ -0,0 +1,119 @@
+"use client"
+
+import * as React from "react"
+import type { DataTableFilterField } from "@/types/table"
+import type { Table } from "@tanstack/react-table"
+import { X } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { DataTableFacetedFilter } from "@/components/data-table/data-table-faceted-filter"
+import { DataTableViewOptions } from "@/components/data-table/data-table-view-options"
+
+interface DataTableToolbarProps<TData>
+ extends React.HTMLAttributes<HTMLDivElement> {
+ table: Table<TData>
+ /**
+ * An array of filter field configurations for the data table.
+ * When options are provided, a faceted filter is rendered.
+ * Otherwise, a search filter is rendered.
+ *
+ * @example
+ * const filterFields = [
+ * {
+ * id: 'name',
+ * label: 'Name',
+ * placeholder: 'Filter by name...'
+ * },
+ * {
+ * id: 'status',
+ * label: 'Status',
+ * options: [
+ * { label: 'Active', value: 'active', icon: ActiveIcon, count: 10 },
+ * { label: 'Inactive', value: 'inactive', icon: InactiveIcon, count: 5 }
+ * ]
+ * }
+ * ]
+ */
+ filterFields?: DataTableFilterField<TData>[]
+}
+
+export function DataTableToolbar<TData>({
+ table,
+ filterFields = [],
+ children,
+ className,
+ ...props
+}: DataTableToolbarProps<TData>) {
+ const isFiltered = table.getState().columnFilters.length > 0
+
+ // Memoize computation of searchableColumns and filterableColumns
+ const { searchableColumns, filterableColumns } = React.useMemo(() => {
+ return {
+ searchableColumns: filterFields.filter((field) => !field.options),
+ filterableColumns: filterFields.filter((field) => field.options),
+ }
+ }, [filterFields])
+
+ return (
+ <div
+ className={cn(
+ "flex w-full items-center justify-between gap-2 overflow-auto p-1",
+ className
+ )}
+ {...props}
+ >
+ <div className="flex flex-1 items-center gap-2">
+ {searchableColumns.length > 0 &&
+ searchableColumns.map(
+ (column) =>
+ table.getColumn(column.id ? String(column.id) : "") && (
+ <Input
+ key={String(column.id)}
+ placeholder={column.placeholder}
+ value={
+ (table
+ .getColumn(String(column.id))
+ ?.getFilterValue() as string) ?? ""
+ }
+ onChange={(event) =>
+ table
+ .getColumn(String(column.id))
+ ?.setFilterValue(event.target.value)
+ }
+ className="h-8 w-40 lg:w-64"
+ />
+ )
+ )}
+ {filterableColumns.length > 0 &&
+ filterableColumns.map(
+ (column) =>
+ table.getColumn(column.id ? String(column.id) : "") && (
+ <DataTableFacetedFilter
+ key={String(column.id)}
+ column={table.getColumn(column.id ? String(column.id) : "")}
+ title={column.label}
+ options={column.options ?? []}
+ />
+ )
+ )}
+ {isFiltered && (
+ <Button
+ aria-label="Reset filters"
+ variant="ghost"
+ className="h-8 px-2 lg:px-3"
+ onClick={() => table.resetColumnFilters()}
+ >
+ Reset
+ <X className="ml-2 size-4" aria-hidden="true" />
+ </Button>
+ )}
+ </div>
+ <div className="flex items-center gap-2">
+ {children}
+ <DataTableViewOptions table={table} />
+ </div>
+ </div>
+ )
+}
diff --git a/components/data-table/data-table-view-options.tsx b/components/data-table/data-table-view-options.tsx
new file mode 100644
index 00000000..6120fff9
--- /dev/null
+++ b/components/data-table/data-table-view-options.tsx
@@ -0,0 +1,191 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import {
+ Check,
+ ChevronsUpDown,
+ GripVertical,
+ Settings2,
+} from "lucide-react"
+
+import { cn, toSentenceCase } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+
+// Sortable
+import {
+ Sortable,
+ SortableItem,
+ SortableDragHandle,
+} from "@/components/ui/sortable"
+
+
+/**
+ * ViewOptionsProps:
+ * - table: TanStack Table instance
+ */
+interface DataTableViewOptionsProps<TData> {
+ table: Table<TData>
+}
+
+declare module "@tanstack/react-table" {
+ interface ColumnMeta<TData, TValue> {
+ excelHeader?: string
+ group?: string
+ type?: string
+ // or whatever other fields you actually use
+ }
+}
+/**
+ * DataTableViewOptions:
+ * - Renders a Popover with hideable columns
+ * - Lets user reorder columns (drag & drop) + toggle visibility
+ */
+export function DataTableViewOptions<TData>({
+ table,
+}: DataTableViewOptionsProps<TData>) {
+ const triggerRef = React.useRef<HTMLButtonElement>(null)
+
+ // 1) Identify columns that can be hidden
+ const hideableCols = React.useMemo(() => {
+
+
+ return table
+ .getAllLeafColumns()
+ .filter((col) => col.getCanHide())
+ }, [table])
+
+
+ // 2) local state for "columnOrder" (just the ID of hideable columns)
+ // We'll reorder these with drag & drop
+ const [columnOrder, setColumnOrder] = React.useState<string[]>(() =>
+ hideableCols.map((c) => c.id)
+ )
+
+ // 3) onMove: when user finishes drag
+ // - update local `columnOrder` only (no table.setColumnOrder yet)
+ const handleMove = React.useCallback(
+ ({ activeIndex, overIndex }: { activeIndex: number; overIndex: number }) => {
+ setColumnOrder((prev) => {
+ const newOrder = [...prev]
+ const [removed] = newOrder.splice(activeIndex, 1)
+ newOrder.splice(overIndex, 0, removed)
+ return newOrder
+ })
+ },
+ []
+ )
+
+ // 4) After local state changes, reflect in tanstack table
+ // - We do this in useEffect to avoid "update a different component" error
+ React.useEffect(() => {
+ // Also consider "non-hideable" columns, if any, to keep them in original positions
+ const nonHideable = table
+ .getAllColumns()
+ .filter((col) => !hideableCols.some((hc) => hc.id === col.id))
+ .map((c) => c.id)
+
+ // e.g. place nonHideable at the front, then our local hideable order
+ const finalOrder = [...nonHideable, ...columnOrder]
+
+ // Now we set the table's official column order
+ table.setColumnOrder(finalOrder)
+ }, [columnOrder, hideableCols, table])
+
+
+ return (
+ <Popover modal>
+ <PopoverTrigger asChild>
+ <Button
+ ref={triggerRef}
+ aria-label="Toggle columns"
+ variant="outline"
+ role="combobox"
+ size="sm"
+ className="gap-2"
+ >
+ <Settings2 className="size-4" />
+ <span className="hidden sm:inline">View</span>
+ <ChevronsUpDown className="ml-auto size-4 shrink-0 opacity-50 hidden sm:inline" />
+ </Button>
+ </PopoverTrigger>
+
+ <PopoverContent
+ align="end"
+ className="w-44 p-0"
+ onCloseAutoFocus={() => triggerRef.current?.focus()}
+ >
+ <Command>
+ <CommandInput placeholder="Search columns..." />
+ <CommandList>
+ <CommandEmpty>No columns found.</CommandEmpty>
+
+ <CommandGroup>
+ {/**
+ * 5) Sortable: we pass an array of { id: string } from `columnOrder`,
+ * so we can reorder them with drag & drop
+ */}
+ <Sortable
+ value={columnOrder.map((id) => ({ id }))}
+ onMove={handleMove}
+ >
+ {columnOrder.map((colId) => {
+ // find column instance
+ const column = hideableCols.find((c) => c.id === colId)
+
+
+ if (!column) return null
+
+ return (
+ <SortableItem key={colId} value={colId} asChild>
+ <CommandItem
+ onSelect={() =>
+ column.toggleVisibility(!column.getIsVisible())
+ }
+ >
+ {/* Drag handle on the left */}
+ <SortableDragHandle
+ variant="outline"
+ size="icon"
+ className="mr-2 size-5 shrink-0 rounded cursor-grab active:cursor-grabbing"
+ >
+ <GripVertical className="size-3.5" aria-hidden="true" />
+ </SortableDragHandle>
+
+ {/* label */}
+ <span className="truncate">
+ {column?.columnDef?.meta?.excelHeader}
+ </span>
+
+ {/* check if visible */}
+ <Check
+ className={cn(
+ "ml-auto size-4 shrink-0",
+ column.getIsVisible() ? "opacity-100" : "opacity-0"
+ )}
+ />
+ </CommandItem>
+ </SortableItem>
+ )
+ })}
+ </Sortable>
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ )
+} \ No newline at end of file
diff --git a/components/data-table/data-table.tsx b/components/data-table/data-table.tsx
new file mode 100644
index 00000000..3d01994a
--- /dev/null
+++ b/components/data-table/data-table.tsx
@@ -0,0 +1,209 @@
+"use client"
+
+import * as React from "react"
+import { flexRender, type Table as TanstackTable } from "@tanstack/react-table"
+import { ChevronRight, ChevronUp } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+import { getCommonPinningStyles } from "@/lib/data-table"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import { DataTablePagination } from "@/components/data-table/data-table-pagination"
+import { DataTableResizer } from "@/components/data-table/data-table-resizer"
+import { useAutoSizeColumns } from "@/hooks/useAutoSizeColumns"
+
+interface DataTableProps<TData> extends React.HTMLAttributes<HTMLDivElement> {
+ table: TanstackTable<TData>
+ floatingBar?: React.ReactNode | null
+ autoSizeColumns?: boolean
+}
+
+/**
+ * 멀티 그룹핑 + 그룹 토글 + 그룹 컬럼/헤더 숨김 + Indent + 리사이징
+ */
+export function DataTable<TData>({
+ table,
+ floatingBar = null,
+ autoSizeColumns = true,
+ children,
+ className,
+ ...props
+}: DataTableProps<TData>) {
+
+ useAutoSizeColumns(table, autoSizeColumns)
+
+ return (
+ <div className={cn("w-full space-y-2.5 overflow-auto", className)} {...props}>
+ {children}
+ <div className="max-w-[100vw] overflow-auto" style={{maxHeight:'36.1rem'}}>
+ <Table className="[&>thead]:sticky [&>thead]:top-0 [&>thead]:z-10 table-fixed">
+ {/* -------------------------------
+ Table Header
+ → 그룹핑된 컬럼의 헤더는 숨김 처리
+ ------------------------------- */}
+ <TableHeader>
+ {table.getHeaderGroups().map((headerGroup) => (
+ <TableRow key={headerGroup.id}>
+ {headerGroup.headers.map((header) => {
+ // 만약 이 컬럼이 현재 "그룹핑" 상태라면 헤더도 표시하지 않음
+ if (header.column.getIsGrouped()) {
+ return null
+ }
+
+ return (
+ <TableHead
+ key={header.id}
+ colSpan={header.colSpan}
+ data-column-id={header.column.id}
+ style={{
+ ...getCommonPinningStyles({ column: header.column }),
+ width: header.getSize(), // 리사이징을 위한 너비 설정
+ position: "relative" // 리사이저를 위한 포지셔닝
+ }}
+ >
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+
+ {/* 리사이즈 핸들 - 별도의 컴포넌트로 분리 */}
+ {header.column.getCanResize() && (
+ <DataTableResizer header={header} />
+ )}
+ </TableHead>
+ )
+ })}
+ </TableRow>
+ ))}
+ </TableHeader>
+
+ {/* -------------------------------
+ Table Body
+ ------------------------------- */}
+ <TableBody>
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => {
+ // ---------------------------------------------------
+ // 1) "그룹핑 헤더" Row인지 확인
+ // ---------------------------------------------------
+ if (row.getIsGrouped()) {
+ // row.groupingColumnId로 어떤 컬럼을 기준으로 그룹화 되었는지 알 수 있음
+ const groupingColumnId = row.groupingColumnId ?? ""
+ const groupingColumn = table.getColumn(groupingColumnId) // 해당 column 객체
+
+ // 컬럼 라벨 가져오기
+ let columnLabel = groupingColumnId
+ if (groupingColumn) {
+ const headerDef = groupingColumn.columnDef.meta?.excelHeader
+ if (typeof headerDef === "string") {
+ columnLabel = headerDef
+ }
+ }
+
+ return (
+ <TableRow
+ key={row.id}
+ className="bg-muted/20"
+ data-state={row.getIsExpanded() && "expanded"}
+ >
+ {/* 그룹 헤더는 한 줄에 합쳐서 보여주고, 토글 버튼 + 그룹 라벨 + 값 표기 */}
+ <TableCell colSpan={table.getVisibleFlatColumns().length}>
+ {/* 확장/축소 버튼 (아이콘 중앙 정렬 + Indent) */}
+ {row.getCanExpand() && (
+ <button
+ onClick={row.getToggleExpandedHandler()}
+ className="inline-flex items-center justify-center mr-2 w-5 h-5"
+ style={{
+ // row.depth: 0이면 top-level, 1이면 그 하위 등
+ marginLeft: `${row.depth * 1.5}rem`,
+ }}
+ >
+ {row.getIsExpanded() ? (
+ <ChevronUp size={16} />
+ ) : (
+ <ChevronRight size={16} />
+ )}
+ </button>
+ )}
+
+ {/* Group Label + 값 */}
+ <span className="font-semibold">
+ {columnLabel}: {row.getValue(groupingColumnId)}
+ </span>
+ <span className="ml-2 text-xs text-muted-foreground">
+ ({row.subRows.length} rows)
+ </span>
+ </TableCell>
+ </TableRow>
+ )
+ }
+
+ // ---------------------------------------------------
+ // 2) 일반 Row
+ // → "그룹핑된 컬럼"은 숨긴다
+ // ---------------------------------------------------
+ return (
+ <TableRow
+ key={row.id}
+ data-state={row.getIsSelected() && "selected"}
+ >
+ {row.getVisibleCells().map((cell) => {
+ // 이 셀의 컬럼이 grouped라면 숨긴다
+ if (cell.column.getIsGrouped()) {
+ return null
+ }
+
+ return (
+ <TableCell
+ key={cell.id}
+ data-column-id={cell.column.id}
+ style={{
+ ...getCommonPinningStyles({ column: cell.column }),
+ width: cell.column.getSize(), // 리사이징을 위한 너비 설정
+ }}
+ >
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )}
+ </TableCell>
+ )
+ })}
+ </TableRow>
+ )
+ })
+ ) : (
+ // ---------------------------------------------------
+ // 3) 데이터가 없을 때
+ // ---------------------------------------------------
+ <TableRow>
+ <TableCell
+ colSpan={table.getAllColumns().length}
+ className="h-24 text-center"
+ >
+ No results.
+ </TableCell>
+ </TableRow>
+ )}
+ </TableBody>
+ </Table>
+ </div>
+
+ <div className="flex flex-col gap-2.5">
+ {/* Pagination */}
+ <DataTablePagination table={table} />
+
+ {/* Floating Bar (선택된 행 있을 때) */}
+ {table.getFilteredSelectedRowModel().rows.length > 0 && floatingBar}
+ </div>
+ </div>
+ )
+} \ No newline at end of file