"use client"; import React, { useState, useEffect, useRef, useMemo } from "react"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { ChevronDown, Check, GripVertical } from "lucide-react"; import { Checkbox } from "@/components/ui/checkbox"; import { RepeaterColumnConfig } from "./types"; import { cn } from "@/lib/utils"; // @dnd-kit imports import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragEndEvent, } from "@dnd-kit/core"; import { SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable, arrayMove, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; // SortableRow 컴포넌트 - 드래그 가능한 테이블 행 interface SortableRowProps { id: string; children: (props: { attributes: React.HTMLAttributes; listeners: React.HTMLAttributes | undefined; isDragging: boolean; }) => React.ReactNode; className?: string; } function SortableRow({ id, children, className }: SortableRowProps) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); const style: React.CSSProperties = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, backgroundColor: isDragging ? "#f0f9ff" : undefined, }; return ( {children({ attributes, listeners, isDragging })} ); } interface RepeaterTableProps { columns: RepeaterColumnConfig[]; data: any[]; onDataChange: (newData: any[]) => void; onRowChange: (index: number, newRow: any) => void; onRowDelete: (index: number) => void; // 동적 데이터 소스 관련 activeDataSources?: Record; // 컬럼별 현재 활성화된 데이터 소스 ID onDataSourceChange?: (columnField: string, optionId: string) => void; // 데이터 소스 변경 콜백 // 체크박스 선택 관련 selectedRows: Set; // 선택된 행 인덱스 onSelectionChange: (selectedRows: Set) => void; // 선택 변경 콜백 // 균등 분배 트리거 equalizeWidthsTrigger?: number; // 값이 변경되면 균등 분배 실행 } export function RepeaterTable({ columns, data, onDataChange, onRowChange, onRowDelete, activeDataSources = {}, onDataSourceChange, selectedRows, onSelectionChange, equalizeWidthsTrigger, }: RepeaterTableProps) { // 히든 컬럼을 제외한 표시 가능한 컬럼만 필터링 const visibleColumns = useMemo(() => columns.filter((col) => !col.hidden), [columns]); // 컨테이너 ref - 실제 너비 측정용 const containerRef = useRef(null); // 초기 균등 분배 실행 여부 (마운트 시 한 번만 실행) const initializedRef = useRef(false); // DnD 센서 설정 const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8, // 8px 이동해야 드래그 시작 (클릭과 구분) }, }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }), ); // 드래그 종료 핸들러 const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; if (over && active.id !== over.id) { const oldIndex = data.findIndex((_, idx) => `row-${idx}` === active.id); const newIndex = data.findIndex((_, idx) => `row-${idx}` === over.id); if (oldIndex !== -1 && newIndex !== -1) { const newData = arrayMove(data, oldIndex, newIndex); onDataChange(newData); // 선택된 행 인덱스도 업데이트 if (selectedRows.size > 0) { const newSelectedRows = new Set(); selectedRows.forEach((oldIdx) => { if (oldIdx === oldIndex) { newSelectedRows.add(newIndex); } else if (oldIdx > oldIndex && oldIdx <= newIndex) { newSelectedRows.add(oldIdx - 1); } else if (oldIdx < oldIndex && oldIdx >= newIndex) { newSelectedRows.add(oldIdx + 1); } else { newSelectedRows.add(oldIdx); } }); onSelectionChange(newSelectedRows); } } } }; const [editingCell, setEditingCell] = useState<{ rowIndex: number; field: string; } | null>(null); // 동적 데이터 소스 Popover 열림 상태 const [openPopover, setOpenPopover] = useState(null); // 컬럼 너비 상태 관리 const [columnWidths, setColumnWidths] = useState>(() => { const widths: Record = {}; columns.filter((col) => !col.hidden).forEach((col) => { widths[col.field] = col.width ? parseInt(col.width) : 120; }); return widths; }); // 기본 너비 저장 (리셋용) const defaultWidths = React.useMemo(() => { const widths: Record = {}; visibleColumns.forEach((col) => { widths[col.field] = col.width ? parseInt(col.width) : 120; }); return widths; }, [visibleColumns]); // 리사이즈 상태 const [resizing, setResizing] = useState<{ field: string; startX: number; startWidth: number } | null>(null); // 리사이즈 핸들러 const handleMouseDown = (e: React.MouseEvent, field: string) => { e.preventDefault(); setResizing({ field, startX: e.clientX, startWidth: columnWidths[field] || 120, }); }; // 컨테이너 가용 너비 계산 const getAvailableWidth = (): number => { if (!containerRef.current) return 800; const containerWidth = containerRef.current.offsetWidth; // 드래그 핸들(32px) + 체크박스 컬럼(40px) + border(2px) return containerWidth - 74; }; // 텍스트 너비 계산 (한글/영문/숫자 혼합 고려) const measureTextWidth = (text: string): number => { if (!text) return 0; let width = 0; for (const char of text) { if (/[가-힣]/.test(char)) { width += 15; // 한글 (text-xs 12px 기준) } else if (/[a-zA-Z]/.test(char)) { width += 9; // 영문 } else if (/[0-9]/.test(char)) { width += 8; // 숫자 } else if (/[_\-.]/.test(char)) { width += 6; // 특수문자 } else if (/[\(\)]/.test(char)) { width += 6; // 괄호 } else { width += 8; // 기타 } } return width; }; // 해당 컬럼의 가장 긴 글자 너비 계산 // equalWidth: 균등 분배 시 너비 (값이 없는 컬럼의 최소값으로 사용) const calculateColumnContentWidth = (field: string, equalWidth: number): number => { const column = visibleColumns.find((col) => col.field === field); if (!column) return equalWidth; // 날짜 필드는 110px (yyyy-MM-dd) if (column.type === "date") { return 110; } // 해당 컬럼에 값이 있는지 확인 let hasValue = false; let maxDataWidth = 0; data.forEach((row) => { const value = row[field]; if (value !== undefined && value !== null && value !== "") { hasValue = true; let displayText = String(value); if (typeof value === "number") { displayText = value.toLocaleString(); } const textWidth = measureTextWidth(displayText) + 20; // padding maxDataWidth = Math.max(maxDataWidth, textWidth); } }); // 값이 없으면 균등 분배 너비 사용 if (!hasValue) { return equalWidth; } // 헤더 텍스트 너비 (동적 데이터 소스가 있으면 headerLabel 사용) let headerText = column.label || field; if (column.dynamicDataSource?.enabled && column.dynamicDataSource.options.length > 0) { const activeOptionId = activeDataSources[field] || column.dynamicDataSource.defaultOptionId; const activeOption = column.dynamicDataSource.options.find((opt) => opt.id === activeOptionId) || column.dynamicDataSource.options[0]; if (activeOption?.headerLabel) { headerText = activeOption.headerLabel; } } const headerWidth = measureTextWidth(headerText) + 32; // padding + 드롭다운 아이콘 // 헤더와 데이터 중 큰 값 사용 return Math.max(headerWidth, maxDataWidth); }; // 헤더 더블클릭: 해당 컬럼만 글자 너비에 맞춤 const handleDoubleClick = (field: string) => { const availableWidth = getAvailableWidth(); const equalWidth = Math.max(60, Math.floor(availableWidth / visibleColumns.length)); const contentWidth = calculateColumnContentWidth(field, equalWidth); setColumnWidths((prev) => ({ ...prev, [field]: contentWidth, })); }; // 균등 분배: 컬럼 수로 테이블 너비를 균등 분배 const applyEqualizeWidths = () => { const availableWidth = getAvailableWidth(); const equalWidth = Math.max(60, Math.floor(availableWidth / visibleColumns.length)); const newWidths: Record = {}; visibleColumns.forEach((col) => { newWidths[col.field] = equalWidth; }); setColumnWidths(newWidths); }; // 자동 맞춤: 각 컬럼을 글자 너비에 맞추고, 컨테이너보다 작으면 남는 공간 분배 const applyAutoFitWidths = () => { if (visibleColumns.length === 0) return; // 균등 분배 너비 계산 (값이 없는 컬럼의 최소값) const availableWidth = getAvailableWidth(); const equalWidth = Math.max(60, Math.floor(availableWidth / visibleColumns.length)); // 1. 각 컬럼의 글자 너비 계산 (값이 없으면 균등 분배 너비 사용) const newWidths: Record = {}; visibleColumns.forEach((col) => { newWidths[col.field] = calculateColumnContentWidth(col.field, equalWidth); }); // 2. 컨테이너 너비와 비교 const totalContentWidth = Object.values(newWidths).reduce((sum, w) => sum + w, 0); // 3. 컨테이너보다 작으면 남는 공간을 균등 분배 (테이블 꽉 참 유지) if (totalContentWidth < availableWidth) { const extraSpace = availableWidth - totalContentWidth; const extraPerColumn = Math.floor(extraSpace / visibleColumns.length); visibleColumns.forEach((col) => { newWidths[col.field] += extraPerColumn; }); } // 컨테이너보다 크면 그대로 (스크롤 생성됨) setColumnWidths(newWidths); }; // 초기 마운트 시 균등 분배 적용 useEffect(() => { if (initializedRef.current) return; if (!containerRef.current || visibleColumns.length === 0) return; const timer = setTimeout(() => { applyEqualizeWidths(); initializedRef.current = true; }, 100); return () => clearTimeout(timer); }, [visibleColumns]); // 트리거 감지: 1=균등분배, 2=자동맞춤 useEffect(() => { if (equalizeWidthsTrigger === undefined || equalizeWidthsTrigger === 0) return; // 홀수면 자동맞춤, 짝수면 균등분배 (토글 방식) if (equalizeWidthsTrigger % 2 === 1) { applyAutoFitWidths(); } else { applyEqualizeWidths(); } }, [equalizeWidthsTrigger]); useEffect(() => { if (!resizing) return; const handleMouseMove = (e: MouseEvent) => { if (!resizing) return; const diff = e.clientX - resizing.startX; const newWidth = Math.max(60, resizing.startWidth + diff); setColumnWidths((prev) => ({ ...prev, [resizing.field]: newWidth, })); }; const handleMouseUp = () => { setResizing(null); }; document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); return () => { document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); }; }, [resizing, visibleColumns, data]); // 데이터 변경 감지 (필요시 활성화) // useEffect(() => { // console.log("📊 RepeaterTable 데이터 업데이트:", data.length, "개 행"); // }, [data]); const handleCellEdit = (rowIndex: number, field: string, value: any) => { const newRow = { ...data[rowIndex], [field]: value }; onRowChange(rowIndex, newRow); }; // 전체 선택 체크박스 핸들러 const handleSelectAll = (checked: boolean) => { if (checked) { // 모든 행 선택 const allIndices = new Set(data.map((_, index) => index)); onSelectionChange(allIndices); } else { // 전체 해제 onSelectionChange(new Set()); } }; // 개별 행 선택 핸들러 const handleRowSelect = (rowIndex: number, checked: boolean) => { const newSelection = new Set(selectedRows); if (checked) { newSelection.add(rowIndex); } else { newSelection.delete(rowIndex); } onSelectionChange(newSelection); }; // 전체 선택 상태 계산 const isAllSelected = data.length > 0 && selectedRows.size === data.length; const isIndeterminate = selectedRows.size > 0 && selectedRows.size < data.length; const renderCell = (row: any, column: RepeaterColumnConfig, rowIndex: number) => { const isEditing = editingCell?.rowIndex === rowIndex && editingCell?.field === column.field; const value = row[column.field]; // 계산 필드는 편집 불가 if (column.calculated || !column.editable) { // 숫자 포맷팅 함수: 정수/소수점 자동 구분 const formatNumber = (val: any): string => { if (val === undefined || val === null || val === "") return "0"; const num = typeof val === "number" ? val : parseFloat(val); if (isNaN(num)) return "0"; // 정수면 소수점 없이, 소수면 소수점 유지 if (Number.isInteger(num)) { return num.toLocaleString("ko-KR"); } else { return num.toLocaleString("ko-KR"); } }; return
{column.type === "number" ? formatNumber(value) : value || "-"}
; } // 편집 가능한 필드 switch (column.type) { case "number": // 숫자 표시: 정수/소수점 자동 구분 const displayValue = (() => { if (value === undefined || value === null || value === "") return ""; const num = typeof value === "number" ? value : parseFloat(value); if (isNaN(num)) return ""; return num.toString(); })(); return ( { const val = e.target.value; // 숫자와 소수점만 허용 if (val === "" || /^-?\d*\.?\d*$/.test(val)) { handleCellEdit(rowIndex, column.field, val === "" ? 0 : parseFloat(val) || 0); } }} className="h-8 w-full min-w-0 rounded-none border-gray-200 text-right text-xs focus:border-blue-500 focus:ring-1 focus:ring-blue-500" /> ); case "date": // ISO 형식(2025-11-23T00:00:00.000Z)을 yyyy-mm-dd로 변환 const formatDateValue = (val: any): string => { if (!val) return ""; // 이미 yyyy-mm-dd 형식이면 그대로 반환 if (typeof val === "string" && /^\d{4}-\d{2}-\d{2}$/.test(val)) { return val; } // ISO 형식이면 날짜 부분만 추출 if (typeof val === "string" && val.includes("T")) { return val.split("T")[0]; } // Date 객체이면 변환 if (val instanceof Date) { return val.toISOString().split("T")[0]; } return String(val); }; return ( handleCellEdit(rowIndex, column.field, e.target.value)} onClick={(e) => (e.target as HTMLInputElement).showPicker?.()} className="h-8 w-full min-w-0 cursor-pointer rounded-none border border-gray-200 bg-white px-2 text-xs focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-inner-spin-button]:hidden" /> ); case "select": return ( ); default: // text return ( handleCellEdit(rowIndex, column.field, e.target.value)} className="h-8 w-full min-w-0 rounded-none border-gray-200 text-xs focus:border-blue-500 focus:ring-1 focus:ring-blue-500" /> ); } }; // 드래그 아이템 ID 목록 const sortableItems = data.map((_, idx) => `row-${idx}`); return (
sum + w, 0) + 74}px)`, }} > {/* 드래그 핸들 헤더 - 좌측 고정 */} {/* 체크박스 헤더 - 좌측 고정 */} {visibleColumns.map((col) => { const hasDynamicSource = col.dynamicDataSource?.enabled && col.dynamicDataSource.options.length > 0; const activeOptionId = activeDataSources[col.field] || col.dynamicDataSource?.defaultOptionId; const activeOption = hasDynamicSource ? col.dynamicDataSource!.options.find((opt) => opt.id === activeOptionId) || col.dynamicDataSource!.options[0] : null; return ( ); })} {data.length === 0 ? ( ) : ( data.map((row, rowIndex) => ( {({ attributes, listeners, isDragging }) => ( <> {/* 드래그 핸들 - 좌측 고정 */} {/* 체크박스 - 좌측 고정 */} {/* 데이터 컬럼들 */} {visibleColumns.map((col) => ( ))} )} )) )}
순서 handleDoubleClick(col.field)} title="더블클릭하여 글자 너비에 맞춤" >
{hasDynamicSource ? ( setOpenPopover(open ? col.field : null)} >
데이터 소스 선택
{col.dynamicDataSource!.options.map((option) => ( ))}
) : ( <> {col.label} {col.required && *} )}
{/* 리사이즈 핸들 */}
handleMouseDown(e, col.field)} title="드래그하여 너비 조정" />
추가된 항목이 없습니다
handleRowSelect(rowIndex, !!checked)} className="border-gray-400" /> {renderCell(row, col, rowIndex)}
); }