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