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 { + 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({ + value, + type, + onSave, + onCancel, + onChange, + className, + options = [], + placeholder, + disabled = false, + maxLength, + validation, + autoSave = true, + initialEditMode = false, + isModified = false, +}: EditableCellProps) { + const [isEditing, setIsEditing] = React.useState(initialEditMode) + const [editValue, setEditValue] = React.useState(value) + const [error, setError] = React.useState(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 ( +
+
+ {type === "checkbox" ? ( + + ) : ( + + {value === null || value === undefined || value === "" + ? (placeholder || "값 없음") + : String(value) + } + + )} +
+ {!disabled && ( + + )} +
+ ) + } + + // 편집 모드 + return ( +
+ {type === "text" && ( + 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" && ( +