diff options
| author | joonhoekim <26rote@gmail.com> | 2025-03-25 15:55:45 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-03-25 15:55:45 +0900 |
| commit | 1a2241c40e10193c5ff7008a7b7b36cc1d855d96 (patch) | |
| tree | 8a5587f10ca55b162d7e3254cb088b323a34c41b /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.tsx | 61 | ||||
| -rw-r--r-- | components/client-data-table/data-table-filter-list.tsx | 662 | ||||
| -rw-r--r-- | components/client-data-table/data-table-group-list.tsx | 279 | ||||
| -rw-r--r-- | components/client-data-table/data-table-pagination.tsx | 132 | ||||
| -rw-r--r-- | components/client-data-table/data-table-resizer.tsx | 119 | ||||
| -rw-r--r-- | components/client-data-table/data-table-sort-list.tsx | 272 | ||||
| -rw-r--r-- | components/client-data-table/data-table-toolbar.tsx | 100 | ||||
| -rw-r--r-- | components/client-data-table/data-table-view-options.tsx | 192 | ||||
| -rw-r--r-- | components/client-data-table/data-table.tsx | 336 |
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 |
