diff options
| author | joonhoekim <26rote@gmail.com> | 2025-09-14 13:27:37 +0000 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-09-14 13:27:37 +0000 |
| commit | 3f293c90beb58ce206a66ff444d7acfc41b56429 (patch) | |
| tree | 7e0eb2f07b211b856d44c6bddad67d72759e1f47 /components | |
| parent | de81b281d9a3c2883a623c3f25e2889ec10a091b (diff) | |
(김준회) Vendor Pool 구현
Diffstat (limited to 'components')
| -rw-r--r-- | components/data-table/editable-cell.tsx | 369 |
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} + /> + ) + }, + } +} |
