diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-03-26 00:37:41 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-03-26 00:37:41 +0000 |
| commit | e0dfb55c5457aec489fc084c4567e791b4c65eb1 (patch) | |
| tree | 68543a65d88f5afb3a0202925804103daa91bc6f /components/client-data-table/data-table-group-list.tsx | |
3/25 까지의 대표님 작업사항
Diffstat (limited to 'components/client-data-table/data-table-group-list.tsx')
| -rw-r--r-- | components/client-data-table/data-table-group-list.tsx | 279 |
1 files changed, 279 insertions, 0 deletions
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 |
