From 71c0ba1f01b98770ec2c60cdb935ffb36c1830a9 Mon Sep 17 00:00:00 2001
From: joonhoekim <26rote@gmail.com>
Date: Mon, 8 Dec 2025 12:08:00 +0900
Subject: (김준회) 테이블 커스텀 훅 버전 생성
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../client-table-v3/client-table-column-header.tsx | 237 +++++++++++++++++++++
 1 file changed, 237 insertions(+)
 create mode 100644 components/client-table-v3/client-table-column-header.tsx

(limited to 'components/client-table-v3/client-table-column-header.tsx')

diff --git a/components/client-table-v3/client-table-column-header.tsx b/components/client-table-v3/client-table-column-header.tsx
new file mode 100644
index 00000000..3be20565
--- /dev/null
+++ b/components/client-table-v3/client-table-column-header.tsx
@@ -0,0 +1,237 @@
+"use client"
+
+import * as React from "react"
+import { Header, Column } from "@tanstack/react-table"
+import { useSortable } from "@dnd-kit/sortable"
+import { CSS } from "@dnd-kit/utilities"
+import { flexRender } from "@tanstack/react-table"
+import {
+  ContextMenu,
+  ContextMenuContent,
+  ContextMenuItem,
+  ContextMenuSeparator,
+  ContextMenuTrigger,
+} from "@/components/ui/context-menu"
+import {
+  ArrowDown,
+  ArrowUp,
+  ChevronsUpDown,
+  EyeOff,
+  PinOff,
+  MoveLeft,
+  MoveRight,
+  Group,
+  Ungroup,
+} from "lucide-react"
+import { cn } from "@/lib/utils"
+import { ClientTableFilter } from "./client-table-filter"
+
+interface ClientTableColumnHeaderProps<TData, TValue>
+  extends React.HTMLAttributes<HTMLTableHeaderCellElement> {
+  header: Header<TData, TValue>
+  enableReordering?: boolean
+  renderHeaderVisualFeedback?: (props: {
+    column: Column<TData, TValue>
+    isPinned: boolean | string
+    isSorted: boolean | string
+    isFiltered: boolean
+    isGrouped: boolean
+  }) => React.ReactNode
+}
+
+export function ClientTableColumnHeader<TData, TValue>({
+  header,
+  enableReordering = true,
+  renderHeaderVisualFeedback,
+  className,
+  ...props
+}: ClientTableColumnHeaderProps<TData, TValue>) {
+  const column = header.column
+  const {
+    attributes,
+    listeners,
+    setNodeRef,
+    transform,
+    transition,
+    isDragging,
+  } = useSortable({
+    id: header.id,
+    disabled: !enableReordering || column.getIsResizing(),
+  })
+
+  // -- Styles --
+  const style: React.CSSProperties = {
+    // Apply transform only if reordering is enabled and active
+    transform: enableReordering ? CSS.Translate.toString(transform) : undefined,
+    transition: enableReordering ? transition : undefined,
+    width: header.getSize(),
+    zIndex: isDragging ? 100 : 0,
+    position: "relative",
+    ...props.style,
+  }
+
+  // Pinning Styles
+  const isPinned = column.getIsPinned()
+  const isSorted = column.getIsSorted()
+  const isFiltered = column.getFilterValue() !== undefined
+  const isGrouped = column.getIsGrouped()
+
+  if (isPinned === "left") {
+    style.left = `${column.getStart("left")}px`
+    style.position = "sticky"
+    style.zIndex = 30 // Pinned columns needs to be higher than normal headers
+  } else if (isPinned === "right") {
+    style.right = `${column.getAfter("right")}px`
+    style.position = "sticky"
+    style.zIndex = 30 // Pinned columns needs to be higher than normal headers
+  }
+
+  // -- Handlers --
+  const handleHide = () => column.toggleVisibility(false)
+  const handlePinLeft = () => column.pin("left")
+  const handlePinRight = () => column.pin("right")
+  const handleUnpin = () => column.pin(false)
+  const handleToggleGrouping = () => column.toggleGrouping()
+
+  // -- Content --
+  const content = (
+    <>
+      <div
+        className={cn(
+          "flex items-center gap-2",
+          column.getCanSort() ? "cursor-pointer select-none" : ""
+        )}
+        onClick={column.getToggleSortingHandler()}
+      >
+        {flexRender(column.columnDef.header, header.getContext())}
+        {column.getCanSort() && (
+          <span className="flex items-center">
+            {column.getIsSorted() === "desc" ? (
+              <ArrowDown className="h-4 w-4" />
+            ) : column.getIsSorted() === "asc" ? (
+              <ArrowUp className="h-4 w-4" />
+            ) : (
+              <ChevronsUpDown className="h-4 w-4 opacity-50" />
+            )}
+          </span>
+        )}
+        {isGrouped && <Group className="h-4 w-4 text-blue-500" />}
+      </div>
+
+      {/* Resize Handle */}
+      <div
+        onMouseDown={header.getResizeHandler()}
+        onTouchStart={header.getResizeHandler()}
+        onPointerDown={(e) => e.stopPropagation()}
+        onClick={(e) => e.stopPropagation()} // Prevent sort trigger
+        className={cn(
+          "absolute right-0 top-0 h-full w-2 cursor-col-resize select-none touch-none z-10",
+          "after:absolute after:right-0 after:top-0 after:h-full after:w-[1px] after:bg-border", // 시각적 구분선
+          "hover:bg-primary/20 hover:w-4 hover:-right-2", // 호버 시 클릭 영역 확장
+          header.column.getIsResizing() ? "bg-primary/50 w-1" : "bg-transparent"
+        )}
+      />
+
+      {/* Filter */}
+      {column.getCanFilter() && <ClientTableFilter column={column} />}
+
+      {/* Visual Feedback Indicators */}
+      {renderHeaderVisualFeedback ? (
+        renderHeaderVisualFeedback({
+          column,
+          isPinned,
+          isSorted,
+          isFiltered,
+          isGrouped,
+        })
+      ) : (
+        (isPinned || isFiltered || isGrouped) && (
+          <div className="absolute top-0.5 right-1 flex gap-1 z-10 pointer-events-none">
+            {isPinned && <div className="h-1.5 w-1.5 rounded-full bg-blue-500" />}
+            {isFiltered && <div className="h-1.5 w-1.5 rounded-full bg-yellow-500" />}
+            {isGrouped && <div className="h-1.5 w-1.5 rounded-full bg-green-500" />}
+          </div>
+        )
+      )}
+    </>
+  )
+
+  if (header.isPlaceholder) {
+    return (
+      <th
+        colSpan={header.colSpan}
+        style={style}
+        className={cn("border-b px-4 py-2 text-left text-sm font-medium bg-muted", className)}
+        {...props}
+      >
+        {null}
+      </th>
+    )
+  }
+
+  return (
+    <ContextMenu>
+      <ContextMenuTrigger asChild>
+        <th
+          ref={setNodeRef}
+          colSpan={header.colSpan}
+          style={style}
+          className={cn(
+            "border-b px-4 py-2 text-left text-sm font-medium bg-muted group transition-colors",
+            isDragging ? "opacity-50 bg-accent" : "",
+            isPinned ? "shadow-[0_0_10px_rgba(0,0,0,0.1)]" : "",
+            className
+          )}
+          {...attributes}
+          {...listeners}
+          {...props}
+        >
+          {content}
+        </th>
+      </ContextMenuTrigger>
+      <ContextMenuContent className="w-48">
+        <ContextMenuItem onClick={handleHide}>
+          <EyeOff className="mr-2 h-4 w-4" />
+          Hide Column
+        </ContextMenuItem>
+
+        {column.getCanGroup() && (
+          <>
+            <ContextMenuSeparator />
+            <ContextMenuItem onClick={handleToggleGrouping}>
+              {isGrouped ? (
+                <>
+                  <Ungroup className="mr-2 h-4 w-4" />
+                  Ungroup
+                </>
+              ) : (
+                <>
+                  <Group className="mr-2 h-4 w-4" />
+                  Group by {column.id}
+                </>
+              )}
+            </ContextMenuItem>
+          </>
+        )}
+
+        <ContextMenuSeparator />
+        <ContextMenuItem onClick={handlePinLeft}>
+          <MoveLeft className="mr-2 h-4 w-4" />
+          Pin Left
+        </ContextMenuItem>
+        <ContextMenuItem onClick={handlePinRight}>
+          <MoveRight className="mr-2 h-4 w-4" />
+          Pin Right
+        </ContextMenuItem>
+        {isPinned && (
+          <ContextMenuItem onClick={handleUnpin}>
+            <PinOff className="mr-2 h-4 w-4" />
+            Unpin
+          </ContextMenuItem>
+        )}
+      </ContextMenuContent>
+    </ContextMenu>
+  )
+}
+
+
-- 
cgit v1.2.3

