summaryrefslogtreecommitdiff
path: root/components/data-table/editable-cell.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'components/data-table/editable-cell.tsx')
-rw-r--r--components/data-table/editable-cell.tsx369
1 files changed, 369 insertions, 0 deletions
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}
+ />
+ )
+ },
+ }
+}