From 3f293c90beb58ce206a66ff444d7acfc41b56429 Mon Sep 17 00:00:00 2001
From: joonhoekim <26rote@gmail.com>
Date: Sun, 14 Sep 2025 13:27:37 +0000
Subject: (김준회) Vendor Pool 구현
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 components/data-table/editable-cell.tsx | 369 ++++++++++++++++++++++++++++++++
 1 file changed, 369 insertions(+)
 create mode 100644 components/data-table/editable-cell.tsx

(limited to 'components/data-table/editable-cell.tsx')

diff --git a/components/data-table/editable-cell.tsx b/components/data-table/editable-cell.tsx
new file mode 100644
index 00000000..05f5c4cb
--- /dev/null
+++ b/components/data-table/editable-cell.tsx
@@ -0,0 +1,369 @@
+"use client"
+
+import * as React from "react"
+import { useCallback } from "react"
+import { Edit2 } from "lucide-react"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select"
+import { Checkbox } from "@/components/ui/checkbox"
+import { cn } from "@/lib/utils"
+
+export type EditableCellType =
+  | "text"
+  | "textarea"
+  | "select"
+  | "checkbox"
+  | "number"
+
+export interface EditableCellProps<T = any> {
+  value: T
+  type: EditableCellType
+  onSave: (newValue: T) => void // 일괄 저장이므로 Promise 제거
+  onCancel?: () => void
+  onChange?: (newValue: T) => void // 값 변경 시 실시간 콜백
+  className?: string
+  options?: { label: string; value: T }[] // select 타입용
+  placeholder?: string
+  disabled?: boolean
+  maxLength?: number
+  validation?: (value: T) => string | null // 유효성 검증 함수
+  autoSave?: boolean // 자동 저장 여부 (기본값: true)
+  initialEditMode?: boolean // 초기 편집 모드 여부 (기본값: false)
+  isModified?: boolean // 수정되었는지 여부
+}
+
+export function EditableCell<T = any>({
+  value,
+  type,
+  onSave,
+  onCancel,
+  onChange,
+  className,
+  options = [],
+  placeholder,
+  disabled = false,
+  maxLength,
+  validation,
+  autoSave = true,
+  initialEditMode = false,
+  isModified = false,
+}: EditableCellProps<T>) {
+  const [isEditing, setIsEditing] = React.useState(initialEditMode)
+  const [editValue, setEditValue] = React.useState<T>(value)
+  const [error, setError] = React.useState<string | null>(null)
+  const handleStartEdit = useCallback((e: React.MouseEvent) => {
+    e.stopPropagation()
+    if (disabled) return
+    setIsEditing(true)
+    setEditValue(value)
+    setError(null)
+  }, [disabled, value])
+
+  const handleFinishEdit = useCallback((overrideValue?: T) => {
+    const currentValue = overrideValue !== undefined ? overrideValue : editValue
+
+    // 유효성 검증
+    if (validation && currentValue !== value) {
+      const validationError = validation(currentValue)
+      if (validationError) {
+        setError(validationError)
+        return
+      }
+    }
+
+    // 값이 변경된 경우에만 저장 (일괄 저장용)
+    if (currentValue !== value) {
+      onSave(currentValue)
+    }
+
+    setIsEditing(false)
+    setError(null)
+  }, [editValue, validation, value, onSave])
+
+  const handleCancelEdit = useCallback(() => {
+    // 취소 시 원래 값으로 복원하되, pendingChanges에서도 제거
+    onCancel?.()
+    setEditValue(value)
+    setIsEditing(false)
+    setError(null)
+  }, [onCancel, value])
+
+  const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
+    if (e.key === "Enter" && type !== "textarea") {
+      e.preventDefault()
+      handleFinishEdit()
+    } else if (e.key === "Escape") {
+      e.stopPropagation()
+      handleCancelEdit()
+    }
+  }, [type, handleFinishEdit, handleCancelEdit])
+
+  const handleBlur = useCallback(() => {
+    handleFinishEdit()
+  }, [handleFinishEdit])
+
+  // 값 표시를 위한 헬퍼 함수
+  const getDisplayValue = useCallback((val: T): string => {
+    if (val === null || val === undefined) return ""
+    if (typeof val === 'boolean') return val ? "true" : "false"
+    return String(val)
+  }, [])
+
+  const handleSelectChange = useCallback((newValue: string) => {
+    // string을 원래 타입으로 변환 시도
+    let convertedValue: T = newValue as T
+
+    // number 타입인 경우 변환
+    if (typeof value === 'number') {
+      const numValue = parseFloat(newValue)
+      if (!isNaN(numValue)) {
+        convertedValue = numValue as T
+      }
+    }
+
+    setEditValue(convertedValue)
+    if (autoSave) {
+      // 드롭다운 선택 시 자동 저장 - 선택된 값 직접 전달
+      handleFinishEdit(convertedValue)
+    } else {
+      // 일괄 저장 모드에서는 실시간 표시를 위해 즉시 onSave 호출
+      onSave(convertedValue)
+    }
+  }, [value, autoSave, handleFinishEdit, onSave])
+
+  const handleInputChange = useCallback(async (newValue: string) => {
+    // string을 원래 타입으로 변환 시도
+    let convertedValue: T = newValue as T
+
+    // number 타입인 경우 변환
+    if (type === 'number' && typeof value === 'number') {
+      const numValue = parseFloat(newValue)
+      if (!isNaN(numValue)) {
+        convertedValue = numValue as T
+      }
+    }
+
+    setEditValue(convertedValue)
+    setError(null)
+
+    // onChange 콜백 호출 (실시간 값 변경 알림)
+    if (onChange) {
+      await onChange(convertedValue)
+    }
+
+    // 실시간 표시를 위해 값이 변경될 때마다 onSave 호출 (일괄 저장용)
+    if (!autoSave && convertedValue !== value) {
+      onSave(convertedValue)
+    }
+  }, [type, value, autoSave, onSave, onChange])
+
+  const handleCheckboxChange = useCallback((checked: boolean) => {
+    const convertedValue = checked as T
+    setEditValue(convertedValue)
+    if (autoSave) {
+      // 체크박스 변경 시 자동 저장 - 값 직접 전달
+      handleFinishEdit(convertedValue)
+    } else {
+      // 일괄 저장 모드에서는 실시간 표시를 위해 즉시 onSave 호출
+      onSave(convertedValue)
+    }
+  }, [autoSave, handleFinishEdit, onSave])
+
+  // 읽기 전용 모드
+  if (!isEditing) {
+    return (
+      <div
+        className={cn(
+          "group flex items-center justify-between cursor-pointer hover:bg-muted/50 rounded px-2 py-1 min-h-[32px]",
+          disabled && "cursor-not-allowed opacity-50",
+          isModified && "bg-yellow-50 border border-yellow-200",
+          className
+        )}
+        onClick={handleStartEdit}
+        title={disabled ? "편집할 수 없습니다" : isModified ? "수정된 내용입니다 - 클릭하여 편집" : "클릭하여 편집"}
+      >
+        <div className="flex-1 truncate">
+          {type === "checkbox" ? (
+            <Checkbox checked={!!value} disabled />
+          ) : (
+            <span className={cn(
+              "block truncate",
+              value === null || value === undefined || value === ""
+                ? "text-muted-foreground italic"
+                : "text-foreground"
+            )}>
+              {value === null || value === undefined || value === ""
+                ? (placeholder || "값 없음")
+                : String(value)
+              }
+            </span>
+          )}
+        </div>
+        {!disabled && (
+          <Edit2 className="w-4 h-4 opacity-0 group-hover:opacity-50 transition-opacity flex-shrink-0 ml-2" />
+        )}
+      </div>
+    )
+  }
+
+  // 편집 모드
+  return (
+    <div className={cn("p-1", isModified && "bg-yellow-50 border border-yellow-200 rounded", className)}>
+      {type === "text" && (
+        <Input
+          value={getDisplayValue(editValue)}
+          onChange={(e) => handleInputChange(e.target.value)}
+          onKeyDown={handleKeyDown}
+          onBlur={handleBlur}
+          placeholder={placeholder}
+          maxLength={maxLength}
+          autoFocus
+          aria-label={`${placeholder || '텍스트 입력'}`}
+          className={cn("h-8", error && "border-red-500", isModified && "bg-yellow-50 border-yellow-300")}
+        />
+      )}
+
+      {type === "textarea" && (
+        <Textarea
+          value={getDisplayValue(editValue)}
+          onChange={(e) => handleInputChange(e.target.value)}
+          onKeyDown={handleKeyDown}
+          onBlur={handleBlur}
+          placeholder={placeholder}
+          maxLength={maxLength}
+          autoFocus
+          rows={2}
+          aria-label={`${placeholder || '텍스트 영역 입력'}`}
+          className={cn("min-h-[60px]", error && "border-red-500", isModified && "bg-yellow-50 border-yellow-300")}
+        />
+      )}
+
+      {type === "number" && (
+        <Input
+          type="number"
+          value={getDisplayValue(editValue)}
+          onChange={(e) => handleInputChange(e.target.value)}
+          onKeyDown={handleKeyDown}
+          onBlur={handleBlur}
+          placeholder={placeholder}
+          autoFocus
+          aria-label={`${placeholder || '숫자 입력'}`}
+          className={cn("h-8", error && "border-red-500", isModified && "bg-yellow-50 border-yellow-300")}
+        />
+      )}
+
+      {type === "select" && (
+        <Select
+          value={getDisplayValue(editValue)}
+          onValueChange={handleSelectChange}
+        >
+          <SelectTrigger
+            className={cn("h-8", error && "border-red-500", isModified && "bg-yellow-50 border-yellow-300")}
+            aria-label={`${placeholder || '옵션 선택'}`}
+          >
+            <SelectValue placeholder={placeholder} />
+          </SelectTrigger>
+          <SelectContent role="listbox" aria-label="옵션 목록">
+            {options.map((option) => (
+              <SelectItem key={getDisplayValue(option.value)} value={getDisplayValue(option.value)}>
+                {option.label}
+              </SelectItem>
+            ))}
+          </SelectContent>
+        </Select>
+      )}
+
+      {type === "checkbox" && (
+        <div className={cn("flex items-center gap-2", isModified && "bg-yellow-50 border border-yellow-200 rounded px-2 py-1")}>
+          <Checkbox
+            checked={!!editValue}
+            onCheckedChange={handleCheckboxChange}
+            autoFocus
+            aria-label={`${placeholder || '체크박스'}`}
+            className={isModified ? "border-yellow-300" : ""}
+          />
+        </div>
+      )}
+
+      {error && (
+        <div className="text-xs text-red-500 mt-1">{error}</div>
+      )}
+    </div>
+  )
+}
+
+// 편집 가능한 컬럼을 위한 헬퍼 함수
+export function createEditableColumn<TData extends Record<string, any>, TValue = any>(
+  accessorKey: keyof TData,
+  type: EditableCellType,
+  options?: {
+    header?: string
+    className?: string
+    placeholder?: string
+    disabled?: boolean
+    maxLength?: number
+    selectOptions?: { label: string; value: TValue }[]
+    validation?: (value: TValue) => string | null
+    autoSave?: boolean
+    initialEditMode?: boolean
+    isModified?: (row: TData, accessorKey: keyof TData) => boolean
+  }
+) {
+  return {
+    accessorKey,
+    header: options?.header || String(accessorKey),
+    cell: ({ row, table }: any) => {
+      const value = row.getValue(String(accessorKey))
+      const onSave = (newValue: any) => {
+        if (table.options.meta?.onCellUpdate) {
+          table.options.meta.onCellUpdate(row.original.id, accessorKey, newValue)
+        }
+      }
+
+      const onCancel = () => {
+        if (table.options.meta?.onCellCancel) {
+          table.options.meta.onCellCancel(row.original.id, accessorKey)
+        }
+      }
+
+      // 수정 여부 확인 (우선순위: 옵션으로 전달된 함수 > 자동 감지)
+      let isModified = false
+      if (options?.isModified) {
+        isModified = options.isModified(row.original, accessorKey)
+      } else if (table.options.meta?.getPendingChanges) {
+        // 자동 감지: pendingChanges에서 해당 필드가 수정되었는지 확인
+        const pendingChanges = table.options.meta.getPendingChanges()
+        const rowId = String(row.original.id)
+        const fieldKey = String(accessorKey)
+        const rowChanges = pendingChanges[rowId]
+        isModified = Boolean(rowChanges && fieldKey in rowChanges)
+      }
+
+      return (
+        <EditableCell
+          value={value}
+          type={type}
+          onSave={onSave}
+          onCancel={onCancel}
+          className={options?.className}
+          placeholder={options?.placeholder}
+          disabled={options?.disabled}
+          maxLength={options?.maxLength}
+          options={options?.selectOptions}
+          validation={options?.validation}
+          autoSave={options?.autoSave}
+          initialEditMode={options?.initialEditMode}
+          isModified={isModified}
+        />
+      )
+    },
+  }
+}
-- 
cgit v1.2.3

