summaryrefslogtreecommitdiff
path: root/components/client-data-table
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-03-25 15:55:45 +0900
committerjoonhoekim <26rote@gmail.com>2025-03-25 15:55:45 +0900
commit1a2241c40e10193c5ff7008a7b7b36cc1d855d96 (patch)
tree8a5587f10ca55b162d7e3254cb088b323a34c41b /components/client-data-table
initial commit
Diffstat (limited to 'components/client-data-table')
-rw-r--r--components/client-data-table/data-table-column-simple-header.tsx61
-rw-r--r--components/client-data-table/data-table-filter-list.tsx662
-rw-r--r--components/client-data-table/data-table-group-list.tsx279
-rw-r--r--components/client-data-table/data-table-pagination.tsx132
-rw-r--r--components/client-data-table/data-table-resizer.tsx119
-rw-r--r--components/client-data-table/data-table-sort-list.tsx272
-rw-r--r--components/client-data-table/data-table-toolbar.tsx100
-rw-r--r--components/client-data-table/data-table-view-options.tsx192
-rw-r--r--components/client-data-table/data-table.tsx336
9 files changed, 2153 insertions, 0 deletions
diff --git a/components/client-data-table/data-table-column-simple-header.tsx b/components/client-data-table/data-table-column-simple-header.tsx
new file mode 100644
index 00000000..0f3997c6
--- /dev/null
+++ b/components/client-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 ClientDataTableColumnHeaderSimple<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="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
+ onClick={handleClick}
+ className={cn(
+ "flex cursor-pointer select-none items-center gap-1",
+ className
+ )}
+ >
+ <span>{title}</span>
+ {icon}
+ </div>
+ )
+} \ No newline at end of file
diff --git a/components/client-data-table/data-table-filter-list.tsx b/components/client-data-table/data-table-filter-list.tsx
new file mode 100644
index 00000000..f06d837e
--- /dev/null
+++ b/components/client-data-table/data-table-filter-list.tsx
@@ -0,0 +1,662 @@
+"use client"
+
+import * as React from "react"
+import type {
+ DataTableAdvancedFilterField,
+ Filter,
+ FilterOperator,
+ JoinOperator,
+} 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 { getDefaultFilterOperator, getFilterOperators } from "@/lib/data-table"
+import { cn, formatDate } from "@/lib/utils"
+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"
+
+
+interface DataTableAdvancedFilterProps<TData> {
+ table: Table<TData>
+ filterFields: DataTableAdvancedFilterField<TData>[]
+ debounceMs?: number
+}
+
+export function ClientDataTableAdvancedFilter<TData>({
+ table,
+ filterFields,
+ debounceMs = 300,
+}: DataTableAdvancedFilterProps<TData>) {
+ const popoverId = React.useId()
+
+
+ // 1) local filter state
+ const [filters, setFilters] = React.useState<Filter<TData>[]>([])
+
+ // 2) local joinOperator (and/or)
+ const [joinOperator, setJoinOperator] = React.useState<JoinOperator>("and")
+
+ // 3) Sync to table
+ React.useEffect(() => {
+ const newColumnFilters = filters.map((f) => {
+ // If it's numeric, transform f.value from string → number
+ if (f.type === "number") {
+ return {
+ id: String(f.id),
+ value: {
+ operator: f.operator,
+ inputValue: parseFloat(String(f.value)),
+ }
+ }
+ }
+ else {
+ // For text, date, boolean, etc., it's fine to keep value as a string or whatever
+ return {
+ id: String(f.id),
+ value: f.value,
+ }
+ }
+ })
+
+ table.setColumnFilters(newColumnFilters)
+ }, [filters, joinOperator, table])
+
+
+ function addFilter() {
+ if (!filterFields.length) return
+ const firstField = filterFields[0]
+
+ setFilters((prev) => [
+ ...prev,
+ {
+ id: firstField.id,
+ type: firstField.type,
+ operator: getDefaultFilterOperator(firstField.type),
+ value: "",
+ rowId: customAlphabet(
+ "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
+ 6
+ )(),
+ },
+ ])
+ }
+
+ function updateFilter(rowId: string, patch: Partial<Filter<TData>>) {
+ setFilters((prev) =>
+ prev.map((f) => (f.rowId === rowId ? { ...f, ...patch } : f))
+ )
+ }
+
+ function removeFilter(rowId: string) {
+ setFilters((prev) => prev.filter((f) => f.rowId !== rowId))
+ }
+
+ function moveFilter(activeIndex: number, overIndex: number) {
+ setFilters((prev) => {
+ const arr = [...prev]
+ const [removed] = arr.splice(activeIndex, 1)
+ if (!removed) return prev
+ arr.splice(overIndex, 0, removed)
+ return arr
+ })
+ }
+
+ /**
+ * Render the input UI for each filter type (text, select, date, etc.)
+ */
+ function renderFilterInput(filter: Filter<TData>) {
+
+ const fieldDef = filterFields.find((f) => f.id === filter.id)
+ if (!fieldDef) return null
+
+ // For "isEmpty"/"isNotEmpty", no input needed
+ if (filter.operator === "isEmpty" || filter.operator === "isNotEmpty") {
+ return (
+ <div
+ id={filter.id}
+ role="status"
+ aria-live="polite"
+ aria-label={`${fieldDef.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={fieldDef.id}
+ type={filter.type}
+ aria-label={`${fieldDef.label} filter value`}
+ aria-describedby={`${fieldDef.id}-description`}
+ placeholder={fieldDef.placeholder ?? "Enter..."}
+ 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(
+ filter.rowId,
+ { value: event.target.value },
+ )
+ }
+ />
+ )
+ case "select":
+ return (
+ <FacetedFilter>
+ <FacetedFilterTrigger asChild>
+ <Button
+ // id={inputId}
+ variant="outline"
+ size="sm"
+ aria-label={`${fieldDef.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"
+ >
+ {fieldDef?.options?.find(
+ (option) => option.value === filter.value
+ )?.label || filter.value}
+ </Badge>
+ ) : (
+ <>
+ {fieldDef.placeholder ?? "Select an option..."}
+ <ChevronsUpDown className="size-4" aria-hidden="true" />
+ </>
+ )}
+ </Button>
+ </FacetedFilterTrigger>
+ <FacetedFilterContent className="w-[12.5rem]">
+ <FacetedFilterInput placeholder={fieldDef.label} />
+ <FacetedFilterList>
+ <FacetedFilterEmpty>{"No options"}</FacetedFilterEmpty>
+ <FacetedFilterGroup>
+ {fieldDef.options?.map((opt) => (
+ <FacetedFilterItem
+ key={opt.value}
+ value={String(opt.value)}
+ selected={filter.value === opt.value}
+ onSelect={(val) => {
+ updateFilter(filter.rowId, { value: val })
+ }}
+ >
+ {opt.label}
+ </FacetedFilterItem>
+ ))}
+ </FacetedFilterGroup>
+ </FacetedFilterList>
+ </FacetedFilterContent>
+ </FacetedFilter>
+ )
+
+ case "multi-select":
+ const selectedValues = new Set(
+ Array.isArray(filter.value) ? filter.value : []
+ )
+
+ return (
+ <FacetedFilter>
+ <FacetedFilterTrigger asChild>
+ <Button
+ id={filter.id}
+ variant="outline"
+ size="sm"
+ aria-label={`${filter.id} filter values`}
+ aria-controls={`${filter.id}-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 && (
+ <>
+ {"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>
+ ) : (
+ fieldDef?.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={`${filter.id}-listbox`}
+ className="w-[12.5rem] origin-[var(--radix-popover-content-transform-origin)]"
+ >
+ <FacetedFilterInput
+ aria-label={`Search ${fieldDef?.label} options`}
+ placeholder={fieldDef?.label ?? "Search options..."}
+ />
+ <FacetedFilterList>
+ <FacetedFilterEmpty>No options found.</FacetedFilterEmpty>
+ <FacetedFilterGroup>
+ {fieldDef?.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(
+ filter.rowId,
+ { 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={fieldDef.id}
+ variant="outline"
+ size="sm"
+ aria-label={`${fieldDef.label} date filter`}
+ aria-controls={`${fieldDef.id}-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={`${fieldDef.id}-calendar`}
+ align="start"
+ className="w-auto p-0"
+ >
+ {filter.operator === "isBetween" ? (
+ <Calendar
+ id={`${fieldDef.id}-calendar`}
+ mode="range"
+ aria-label={`Select ${fieldDef.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(
+ filter.rowId,
+ {
+ value: date
+ ? [
+ date.from?.toISOString() ?? "",
+ date.to?.toISOString() ?? "",
+ ]
+ : [],
+ },
+ )
+ }}
+ initialFocus
+ numberOfMonths={1}
+ />
+ ) : (
+ <Calendar
+ id={`${fieldDef.id}-calendar`}
+ mode="single"
+ aria-label={`Select ${fieldDef.label} date`}
+ selected={dateValue[0] ? new Date(dateValue[0]) : undefined}
+ onSelect={(date) => {
+ updateFilter(
+ filter.rowId,
+ { value: date?.toISOString() ?? "" },
+ )
+
+ setTimeout(() => {
+ document.getElementById(fieldDef.id)?.click()
+ }, 0)
+ }}
+ initialFocus
+ />
+ )}
+ </PopoverContent>
+ </Popover>
+ )
+ case "boolean": {
+ if (Array.isArray(filter.value)) return null
+
+ return (
+ <Select
+ value={filter.value}
+ onValueChange={(value) =>
+ updateFilter(filter.rowId, { value })
+ }
+ >
+ <SelectTrigger
+ id={fieldDef.id}
+ aria-label={`${fieldDef.label} boolean filter`}
+ aria-controls={`${fieldDef.id}-listbox`}
+ className="h-8 w-full rounded bg-transparent"
+ >
+ <SelectValue placeholder={filter.value ? "True" : "False"} />
+ </SelectTrigger>
+ <SelectContent id={`${fieldDef.id}-listbox`}>
+ <SelectItem value="true">True</SelectItem>
+ <SelectItem value="false">False</SelectItem>
+ </SelectContent>
+ </Select>
+ )
+ }
+ default:
+ return null
+ }
+ }
+
+
+ const filterCount = filters.length
+
+ return (
+ <Sortable
+ value={filters.map((f) => ({ id: f.rowId }))}
+ onMove={({ activeIndex, overIndex }) => moveFilter(activeIndex, overIndex)}
+ >
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <ListFilter className="h-4 w-4" />
+ {"Filters"}
+ {filterCount > 0 && (
+ <Badge variant="secondary" className="ml-1">
+ {filterCount}
+ </Badge>
+ )}
+ </Button>
+ </PopoverTrigger>
+
+ <PopoverContent
+ id={popoverId}
+ align="start"
+ className="flex w-[36rem] max-w-[calc(100vw-3rem)] flex-col gap-3 p-4"
+ >
+ {filterCount > 0 ? (
+ <h4 className="font-medium">
+ {"Filters"}
+ </h4>
+ ) : (
+ <div>
+ <h4 className="font-medium">
+ {"No filters applied"}
+ </h4>
+ <p className="text-sm text-muted-foreground">
+ {"Add filters below"}
+ </p>
+ </div>
+ )}
+
+ {/* Filter list */}
+ <div className="flex max-h-60 flex-col gap-2 overflow-y-auto pr-1">
+ {filters.map((filter, index) => (
+ <SortableItem key={filter.rowId} value={filter.rowId} asChild>
+ <div className="flex items-center gap-2">
+ <div className="w-24 text-center">
+ {index === 0 ? (
+ <span className="text-sm text-muted-foreground">
+ {"Where"}
+ </span>
+ ) : index === 1 ? (
+ <Select
+ value={(joinOperator)}
+ onValueChange={(val) => setJoinOperator(val as JoinOperator)}
+ >
+ <SelectTrigger className="h-8 w-24 lowercase text-sm">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="and">{"AND"}</SelectItem>
+ <SelectItem value="or">{"OR"}</SelectItem>
+ </SelectContent>
+ </Select>
+ ) : (
+ <span className="text-sm text-muted-foreground uppercase">
+ {joinOperator === "and"
+ ? "AND"
+ : "OR"}
+ </span>
+ )}
+ </div>
+
+ {/* Field (column) selection */}
+ <Popover modal>
+ <PopoverTrigger asChild>
+ <Button variant="outline" size="sm" className="h-8 w-32 justify-between">
+ {
+ filterFields.find((f) => f.id === filter.id)?.label ??
+ "Search fields..."
+ }
+ <ChevronsUpDown className="h-4 w-4 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-40 p-0">
+ <Command>
+ <CommandInput placeholder={"Select field"} />
+ <CommandList>
+ <CommandEmpty>{"No fields found."}</CommandEmpty>
+ <CommandGroup>
+ {filterFields.map((ff) => (
+ <CommandItem
+ key={ff.id as string}
+ value={String(ff.id)}
+ onSelect={(val) => {
+ const newField = filterFields.find((x) => x.id === val)
+ if (!newField) return
+ updateFilter(filter.rowId, {
+ id: newField.id,
+ type: newField.type,
+ operator: getDefaultFilterOperator(newField.type),
+ value: "",
+ })
+ }}
+ >
+ {ff.label}
+ <Check
+ className={cn(
+ "ml-auto h-4 w-4",
+ ff.id === filter.id ? "opacity-100" : "opacity-0"
+ )}
+ />
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+
+ {/* Operator selection */}
+ <Select
+ value={filter.operator}
+ onValueChange={(val: FilterOperator) => {
+ updateFilter(filter.rowId, {
+ operator: val,
+ value:
+ val === "isEmpty" || val === "isNotEmpty" ? "" : filter.value,
+ })
+ }}
+ >
+ <SelectTrigger className="h-8 w-28 text-sm">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {getFilterOperators(filter.type).map((op) => (
+ <SelectItem key={op.value} value={op.value}>
+ {op.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+
+ {/* The actual filter input */}
+ <div className="flex-1">{renderFilterInput(filter)}</div>
+
+ {/* Remove button */}
+ <Button
+ variant="outline"
+ size="icon"
+ onClick={() => removeFilter(filter.rowId)}
+ className="h-8 w-8"
+ >
+ <Trash2 className="h-4 w-4" />
+ </Button>
+
+ {/* Drag handle */}
+ <SortableDragHandle variant="outline" size="icon" className="h-8 w-8">
+ <GripVertical className="h-4 w-4" />
+ </SortableDragHandle>
+ </div>
+ </SortableItem>
+ ))}
+ </div>
+
+ {/* Footer: Add filter / Reset */}
+ <div className="flex items-center gap-2">
+ <Button size="sm" onClick={addFilter}>
+ {"Add filter"}
+ </Button>
+ {filterCount > 0 && (
+ <Button
+ size="sm"
+ variant="outline"
+ onClick={() => {
+ setFilters([])
+ setJoinOperator("and")
+ }}
+ >
+ {"Reset"}
+ </Button>
+ )}
+ </div>
+ </PopoverContent>
+ </Popover>
+ </Sortable>
+ )
+} \ No newline at end of file
diff --git a/components/client-data-table/data-table-group-list.tsx b/components/client-data-table/data-table-group-list.tsx
new file mode 100644
index 00000000..519b7327
--- /dev/null
+++ b/components/client-data-table/data-table-group-list.tsx
@@ -0,0 +1,279 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+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 DataTableGroupListLocalProps<TData> {
+ /** TanStack Table 인스턴스 (grouping을 사용할 수 있어야 함) */
+ table: Table<TData>
+}
+
+export function ClientDataTableGroupList<TData>({
+ table,
+}: DataTableGroupListLocalProps<TData>) {
+ // -----------------------------
+ // 1) Local grouping state
+ // -----------------------------
+ const [grouping, setGrouping] = React.useState<string[]>(
+ (table.initialState.grouping as string[]) ?? []
+ )
+
+ // Keep the table grouping in sync
+ React.useEffect(() => {
+ table.setGrouping(grouping)
+ }, [grouping, table])
+
+ // Avoid duplicates (just in case)
+ const uniqueGrouping = React.useMemo(
+ () => [...new Set(grouping)],
+ [grouping]
+ )
+
+ // -----------------------------
+ // 2) Groupable columns
+ // -----------------------------
+ const groupableColumns = React.useMemo(
+ () =>
+ table
+ .getAllColumns()
+ .filter((col) => col.getCanGroup?.() !== false)
+ .map((col) => {
+ // If meta?.excelHeader is missing or undefined, fall back to `col.id`
+ const friendlyName =
+ typeof col.columnDef?.meta?.excelHeader === "string"
+ ? col.columnDef.meta.excelHeader
+ : col.id
+
+ return {
+ id: col.id,
+ // Ensure it's always a string, so no type error:
+ label: toSentenceCase(friendlyName),
+ }
+ }),
+ [table]
+ )
+
+ const ungroupedColumns = React.useMemo(
+ () => groupableColumns.filter((c) => !uniqueGrouping.includes(c.id)),
+ [groupableColumns, uniqueGrouping]
+ )
+
+ // -----------------------------
+ // 3) Handlers
+ // -----------------------------
+ // Add the first ungrouped column
+ function addGroup() {
+ const firstAvailable = ungroupedColumns[0]
+ if (!firstAvailable) return
+ setGrouping((prev) => [...prev, firstAvailable.id])
+ }
+
+ // Remove a group
+ function removeGroup(colId: string) {
+ setGrouping((prev) => prev.filter((g) => g !== colId))
+ }
+
+ // Reset grouping entirely
+ function resetGrouping() {
+ setGrouping([])
+ }
+
+ // Reorder groups via Sortable
+ function onGroupOrderChange(newGroups: string[]) {
+ setGrouping(newGroups)
+ }
+
+ // -----------------------------
+ // 4) Render
+ // -----------------------------
+ return (
+ <Sortable
+ value={uniqueGrouping.map((id) => ({ id }))}
+ onValueChange={(items) => onGroupOrderChange(items.map((i) => i.id))}
+ 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">
+ <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
+ align="start"
+ collisionPadding={16}
+ className={cn(
+ "flex w-[calc(100vw-theme(spacing.20))] min-w-72 max-w-[25rem] flex-col p-4 sm:w-[25rem]",
+ uniqueGrouping.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">
+ Grouping is applied to the currently loaded data only.
+ </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>
+ )}
+
+ {/* Current groups */}
+ <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) => {
+ // Find the column's friendly label
+ const colDef = groupableColumns.find((c) => c.id === colId)
+ const label = colDef?.label ?? toSentenceCase(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">{label}</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) => {
+ // Replace colId with new 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>
+
+ {/* Footer: "Add group" & "Reset grouping" */}
+ <div className="flex w-full items-center gap-2">
+ <Button
+ size="sm"
+ className="h-[1.85rem] rounded"
+ onClick={addGroup}
+ disabled={uniqueGrouping.length >= groupableColumns.length}
+ >
+ Add group
+ </Button>
+ {uniqueGrouping.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/client-data-table/data-table-pagination.tsx b/components/client-data-table/data-table-pagination.tsx
new file mode 100644
index 00000000..5abd3470
--- /dev/null
+++ b/components/client-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 ClientDataTablePagination<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/client-data-table/data-table-resizer.tsx b/components/client-data-table/data-table-resizer.tsx
new file mode 100644
index 00000000..7dc8f523
--- /dev/null
+++ b/components/client-data-table/data-table-resizer.tsx
@@ -0,0 +1,119 @@
+"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>
+ onResizeStart?: () => void
+ onResizeEnd?: () => void
+}
+
+export function DataTableResizer<TData, TValue>({
+ header,
+ onResizeStart,
+ onResizeEnd,
+ className,
+ ...props
+}: DataTableResizerProps<TData, TValue>) {
+ const contentRef = React.useRef<HTMLDivElement>(null)
+
+ // 더블클릭 시 너비 자동 조정 함수
+ const handleDoubleClick = React.useCallback((e: React.MouseEvent) => {
+ e.stopPropagation(); // 이벤트 버블링 중지
+
+ // 테이블 인스턴스 가져오기
+ 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)
+
+ // 콘솔 로그 추가 (디버깅용)
+ console.log(`컬럼 [${columnId}] 너비 자동 조정: ${finalWidth}px`);
+ }, [header])
+
+ // 마우스 다운 핸들러 (리사이징 시작)
+ const handleMouseDown = React.useCallback((e: React.MouseEvent | React.TouchEvent) => {
+ // 리사이즈 시작을 알림
+ if (onResizeStart) onResizeStart()
+
+ // 기존 리사이즈 핸들러 호출
+ if (header.getResizeHandler()) {
+ header.getResizeHandler()(e as any)
+ }
+ }, [header, onResizeStart])
+
+ return (
+ <>
+ {/* 헤더 콘텐츠 참조를 위한 요소 */}
+ <div ref={contentRef} className="absolute opacity-0 pointer-events-none" />
+
+ {/* 리사이저 */}
+ <div
+ {...props}
+ onMouseDown={handleMouseDown}
+ onTouchStart={handleMouseDown}
+ 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/client-data-table/data-table-sort-list.tsx b/components/client-data-table/data-table-sort-list.tsx
new file mode 100644
index 00000000..b67fdde3
--- /dev/null
+++ b/components/client-data-table/data-table-sort-list.tsx
@@ -0,0 +1,272 @@
+"use client"
+
+import * as React from "react"
+import { type SortingState, type Table } from "@tanstack/react-table"
+import {
+ ArrowDownUp,
+ ChevronsUpDown,
+ GripVertical,
+ Trash2,
+} from "lucide-react"
+
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command"
+import {
+ Sortable,
+ SortableItem,
+ SortableDragHandle,
+} from "@/components/ui/sortable"
+import { cn, toSentenceCase } from "@/lib/utils"
+
+
+/**
+ * A simpler, local-state version of the column "sort list".
+ * - No `useQueryState` or URL sync
+ * - We store a local `sorting: SortingState` and whenever it changes, we do `table.setSorting(sorting)`.
+ */
+interface DataTableSortListLocalProps<TData> {
+ /** TanStack Table instance */
+ table: Table<TData>
+}
+
+export function ClientDataTableSortList<TData>({ table }: DataTableSortListLocalProps<TData>) {
+
+
+ // 2) local SortingState
+ const [sorting, setSorting] = React.useState<SortingState>([])
+
+ // 3) Keep the table in sync
+ React.useEffect(() => {
+ table.setSorting(sorting)
+ }, [sorting, table])
+
+ // 4) columns that can be sorted
+ const sortableColumns = React.useMemo(() => {
+ return table
+ .getAllColumns()
+ .filter((col) => col.getCanSort())
+ .map((col) => ({
+ id: col.id,
+ // excelHeader 를 사용 중이면 label이 없을 수 있으니 fallback
+ label: (col.columnDef.meta?.excelHeader as string) || toSentenceCase(col.id),
+ }))
+ }, [table])
+
+ // 5) "Add sort" → pick first unsorted column
+ function addSort() {
+ const used = new Set(sorting.map((s) => s.id))
+ const firstUnused = sortableColumns.find((col) => !used.has(col.id))
+ if (!firstUnused) return
+ setSorting((prev) => [...prev, { id: firstUnused.id, desc: false }])
+ }
+
+ // 6) update sort item by column id
+ function updateSort(
+ columnId: string,
+ patch: Partial<{ id: string; desc: boolean }>
+ ) {
+ setSorting((prev) =>
+ prev.map((s) => (s.id === columnId ? { ...s, ...patch } : s))
+ )
+ }
+
+ // 7) remove a sort item
+ function removeSort(columnId: string) {
+ setSorting((prev) => prev.filter((s) => s.id !== columnId))
+ }
+
+ // 8) reorder sorting items via drag
+ function moveSort(activeIndex: number, overIndex: number) {
+ setSorting((prev) => {
+ const arr = [...prev]
+ const [removed] = arr.splice(activeIndex, 1)
+ if (!removed) return prev
+ arr.splice(overIndex, 0, removed)
+ return arr
+ })
+ }
+
+ const isSortingEmpty = sorting.length === 0
+
+ return (
+ <Sortable
+ value={sorting}
+ onValueChange={setSorting}
+ onMove={({ activeIndex, overIndex }) => moveSort(activeIndex, overIndex)}
+ 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">
+ <ArrowDownUp className="h-4 w-4" aria-hidden="true" />
+ { "Sort"}
+ {sorting.length > 0 && (
+ <Badge
+ variant="secondary"
+ className="h-[1.14rem] rounded-[0.2rem] px-[0.32rem] font-mono text-[0.65rem] font-normal"
+ >
+ {sorting.length}
+ </Badge>
+ )}
+ </Button>
+ </PopoverTrigger>
+
+ <PopoverContent
+ 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"
+ )}
+ >
+ {isSortingEmpty ? (
+ <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>
+ ) : (
+ <h4 className="font-medium leading-none">
+ { "Sort by"}
+ </h4>
+ )}
+
+ {/* Sorting items */}
+ {sorting.length > 0 && (
+ <div className="flex max-h-40 flex-col gap-2 overflow-y-auto p-0.5">
+ {sorting.map((sortItem) => {
+ const col = sortableColumns.find((c) => c.id === sortItem.id)
+ const columnLabel = col ? col.label : toSentenceCase(sortItem.id)
+
+ return (
+ <SortableItem key={sortItem.id} value={sortItem.id} asChild>
+ <div className="flex items-center gap-2">
+ {/* Column name selector */}
+ <Popover modal>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ className="h-8 w-[11.25rem] justify-between"
+ >
+ <span className="truncate">{columnLabel}</span>
+ <ChevronsUpDown className="h-4 w-4 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
+ <Command>
+ <CommandInput placeholder={"Search columns..." } />
+ <CommandList>
+ <CommandEmpty>
+ { "No columns found."}
+ </CommandEmpty>
+ <CommandGroup>
+ {sortableColumns.map((col) => (
+ <CommandItem
+ key={col.id}
+ value={col.id}
+ onSelect={(val) => {
+ // change the ID of the sort item
+ updateSort(sortItem.id, { id: val, desc: false })
+ }}
+ >
+ {col.label}
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+
+ {/* Sort direction */}
+ <Select
+ value={sortItem.desc ? "desc" : "asc"}
+ onValueChange={(val) =>
+ updateSort(sortItem.id, { desc: val === "desc" })
+ }
+ >
+ <SelectTrigger className="h-8 w-24">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent className="min-w-[var(--radix-select-trigger-width)]">
+ <SelectItem value="asc">
+ {"Asc" }
+ </SelectItem>
+ <SelectItem value="desc">
+ { "Desc"}
+ </SelectItem>
+ </SelectContent>
+ </Select>
+
+ {/* remove sort */}
+ <Button
+ variant="outline"
+ size="icon"
+ onClick={() => removeSort(sortItem.id)}
+ className="size-8 shrink-0 rounded"
+ >
+ <Trash2 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>
+ )}
+
+ {/* Footer: "Add sort" & "Reset" */}
+ <div className="flex items-center gap-2">
+ <Button size="sm" onClick={addSort}>
+ {"Add sort" }
+ </Button>
+ {sorting.length > 0 && (
+ <Button size="sm" variant="outline" onClick={() => setSorting([])}>
+ {"Reset sorting" }
+ </Button>
+ )}
+ </div>
+ </PopoverContent>
+ </Popover>
+ </Sortable>
+ )
+} \ No newline at end of file
diff --git a/components/client-data-table/data-table-toolbar.tsx b/components/client-data-table/data-table-toolbar.tsx
new file mode 100644
index 00000000..286cffd6
--- /dev/null
+++ b/components/client-data-table/data-table-toolbar.tsx
@@ -0,0 +1,100 @@
+"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 { ClientDataTableViewOptions } from "./data-table-view-options"
+import { Button } from "../ui/button"
+import { Download } from "lucide-react"
+
+import { exportTableToExcel } from "@/lib/export_all"
+import { ClientDataTableSortList } from "./data-table-sort-list"
+import { ClientDataTableGroupList } from "./data-table-group-list"
+import { ClientDataTableAdvancedFilter } from "./data-table-filter-list"
+
+interface DataTableAdvancedToolbarProps<TData>
+ extends React.HTMLAttributes<HTMLDivElement> {
+ table: Table<TData>
+ filterFields: DataTableAdvancedFilterField<TData>[]
+ debounceMs?: number
+ shallow?: boolean
+ children?: React.ReactNode
+}
+
+export function ClientDataTableAdvancedToolbar<TData>({
+ table,
+ filterFields = [],
+ debounceMs = 300,
+ shallow = true,
+ children,
+ className,
+ ...props
+}: DataTableAdvancedToolbarProps<TData>) {
+
+ // 전체 엑셀 내보내기
+ const handleExportAll = async () => {
+ try {
+ await exportTableToExcel(table, {
+ filename: "my-data",
+ onlySelected: false,
+ excludeColumns: ["select", "actions", "validation", "requestedAmount", "update"],
+ useGroupHeader: false,
+ allPages: true,
+
+ })
+ } catch (err) {
+ console.error("Export error:", err)
+ // 필요하면 토스트나 알림 처리
+ }
+ }
+
+
+
+ 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">
+ <ClientDataTableAdvancedFilter
+ table={table}
+ filterFields={filterFields}
+ />
+ <ClientDataTableSortList table={table} />
+ <ClientDataTableViewOptions table={table} />
+ <ClientDataTableGroupList table={table}/>
+ <Button variant="outline" size="sm" onClick={handleExportAll}>
+ <Download className="size-4" />
+ {/* i18n 버튼 문구 */}
+ <span className="hidden sm:inline">
+ {"Export All" }
+ </span>
+ </Button>
+ </div>
+
+ {/* 오른쪽: Export 버튼 + children */}
+ <div className="flex items-center gap-2">
+
+
+ {/* 자식 컴포넌트(추가 버튼 등) */}
+ {children}
+
+ {/* 선택된 행만 내보내기 버튼 예시 (필요 시 주석 해제) */}
+ {/*
+ <Button variant="outline" size="sm" onClick={handleExportSelected}>
+ <Download className="size-4" />
+ <span className="hidden sm:inline">
+ {t("common.exportSelected", { defaultValue: "Export Selected" })}
+ </span>
+ </Button>
+ */}
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/components/client-data-table/data-table-view-options.tsx b/components/client-data-table/data-table-view-options.tsx
new file mode 100644
index 00000000..68026ff5
--- /dev/null
+++ b/components/client-data-table/data-table-view-options.tsx
@@ -0,0 +1,192 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import {
+ ArrowUpDown,
+ 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 ClientDataTableViewOptions<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() && col.accessorFn !== undefined)
+ }, [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) => col.getCanHide() && col.accessorFn !== undefined)
+ .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 = ["select",...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"
+ >
+ <ArrowUpDown className="size-2.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/client-data-table/data-table.tsx b/components/client-data-table/data-table.tsx
new file mode 100644
index 00000000..ff10bfe4
--- /dev/null
+++ b/components/client-data-table/data-table.tsx
@@ -0,0 +1,336 @@
+"use client"
+
+import * as React from "react"
+import {
+ ColumnDef,
+ ColumnFiltersState,
+ SortingState,
+ VisibilityState,
+ flexRender,
+ getCoreRowModel,
+ getFacetedRowModel,
+ getFacetedUniqueValues,
+ getFilteredRowModel,
+ getPaginationRowModel,
+ getSortedRowModel,
+ useReactTable,
+ Table,
+ getGroupedRowModel,
+ getExpandedRowModel,
+ ColumnSizingState, ColumnPinningState
+} from "@tanstack/react-table"
+import {
+ Table as UiTable,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import { getCommonPinningStyles } from "@/lib/data-table"
+import { ChevronRight, ChevronUp } from "lucide-react"
+
+import { ClientDataTableAdvancedToolbar } from "./data-table-toolbar"
+import { ClientDataTablePagination } from "./data-table-pagination"
+import { DataTableResizer } from "./data-table-resizer"
+import { useAutoSizeColumns } from "@/hooks/useAutoSizeColumns"
+
+interface DataTableProps<TData, TValue> {
+ columns: ColumnDef<TData, TValue>[]
+ data: TData[]
+ advancedFilterFields: any[]
+ autoSizeColumns?: boolean
+ onSelectedRowsChange?: (selected: TData[]) => void
+
+ /** 추가로 표시할 버튼/컴포넌트 */
+ children?: React.ReactNode
+}
+
+export function ClientDataTable<TData, TValue>({
+ columns,
+ data,
+ advancedFilterFields,
+ autoSizeColumns = true,
+ children,
+ onSelectedRowsChange
+}: DataTableProps<TData, TValue>) {
+
+
+ // (1) React Table 상태
+ const [rowSelection, setRowSelection] = React.useState({})
+ const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
+ const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
+ const [sorting, setSorting] = React.useState<SortingState>([])
+ const [grouping, setGrouping] = React.useState<string[]>([])
+ const [columnSizing, setColumnSizing] = React.useState<ColumnSizingState>({})
+
+ // 실제 리사이징 상태만 추적
+ const [isResizing, setIsResizing] = React.useState(false)
+
+ // 리사이징 상태를 추적하기 위한 ref
+ const isResizingRef = React.useRef(false)
+
+ // 리사이징 이벤트 핸들러
+ const handleResizeStart = React.useCallback(() => {
+ isResizingRef.current = true
+ setIsResizing(true)
+ }, [])
+
+ const handleResizeEnd = React.useCallback(() => {
+ isResizingRef.current = false
+ setIsResizing(false)
+ }, [])
+
+ const [columnPinning, setColumnPinning] = React.useState<ColumnPinningState>({
+ left: [],
+ right: ["update"],
+ })
+
+ const table = useReactTable({
+ data,
+ columns,
+ state: {
+ sorting,
+ columnVisibility,
+ rowSelection,
+ columnFilters,
+ grouping,
+ columnSizing,
+ columnPinning
+ },
+ columnResizeMode: "onChange",
+ onColumnSizingChange: setColumnSizing,
+ enableRowSelection: true,
+ onRowSelectionChange: setRowSelection,
+ onSortingChange: setSorting,
+ onColumnFiltersChange: setColumnFilters,
+ onColumnVisibilityChange: setColumnVisibility,
+ onGroupingChange: setGrouping,
+ getCoreRowModel: getCoreRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFacetedRowModel: getFacetedRowModel(),
+ getFacetedUniqueValues: getFacetedUniqueValues(),
+ getGroupedRowModel: getGroupedRowModel(),
+ autoResetPageIndex: false,
+ getExpandedRowModel: getExpandedRowModel(),
+ enableColumnPinning:true,
+ onColumnPinningChange:setColumnPinning
+
+ })
+
+ useAutoSizeColumns(table, autoSizeColumns)
+
+ // 컴포넌트 마운트 시 강제로 리사이징 상태 초기화
+ React.useEffect(() => {
+ // 강제로 초기 상태는 리사이징 비활성화
+ setIsResizing(false)
+ isResizingRef.current = false
+
+ // 전역 마우스 이벤트 핸들러
+ const handleMouseUp = () => {
+ if (isResizingRef.current) {
+ handleResizeEnd()
+ }
+ }
+
+ // 이벤트 리스너 등록
+ window.addEventListener('mouseup', handleMouseUp)
+ window.addEventListener('touchend', handleMouseUp)
+
+ return () => {
+ // 이벤트 리스너 정리
+ window.removeEventListener('mouseup', handleMouseUp)
+ window.removeEventListener('touchend', handleMouseUp)
+
+ // 컴포넌트 언마운트 시 정리
+ setIsResizing(false)
+ isResizingRef.current = false
+ }
+ }, [handleResizeEnd])
+
+ React.useEffect(() => {
+ if (!onSelectedRowsChange) return
+ const selectedRows = table
+ .getSelectedRowModel()
+ .flatRows.map((row) => row.original)
+ onSelectedRowsChange(selectedRows)
+ }, [rowSelection, table, onSelectedRowsChange])
+
+ // (2) 렌더
+ return (
+ <div className="space-y-4">
+ {/* 툴바에 children을 넘기기 */}
+ <ClientDataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ {children}
+ </ClientDataTableAdvancedToolbar>
+
+ <div className="rounded-md border">
+ <div className="overflow-auto" style={{maxHeight:'33.6rem'}}>
+ <UiTable
+ className="[&>thead]:sticky [&>thead]:top-0 [&>thead]:z-10 table-fixed"
+ >
+ <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}
+ className="relative"
+ style={{
+ ...getCommonPinningStyles({ column: header.column }),
+ width: header.getSize()
+ }}
+ >
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+
+ {/* 리사이즈 핸들 - 헤더에만 추가 */}
+ {header.column.getCanResize() && (
+ <DataTableResizer
+ header={header}
+ onResizeStart={handleResizeStart}
+ onResizeEnd={handleResizeEnd}
+ />
+ )}
+ </TableHead>
+ )
+ })}
+ </TableRow>
+ ))}
+ </TableHeader>
+ <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>
+ </UiTable>
+
+ {/* 리사이징 시에만 캡처 레이어 활성화 */}
+ {isResizing && (
+ <div className="fixed inset-0 cursor-col-resize select-none z-50" />
+ )}
+ </div>
+ </div>
+
+ <ClientDataTablePagination table={table} />
+ </div>
+ )
+} \ No newline at end of file