"use client"; import React, { useState, useEffect, useRef } 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) { // 컨테이너 ref - 실제 너비 측정용 const containerRef = useRef(null); // 균등 분배 모드 상태 (true일 때 테이블이 컨테이너에 맞춤) const [isEqualizedMode, setIsEqualizedMode] = useState(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.forEach((col) => { widths[col.field] = col.width ? parseInt(col.width) : 120; }); return widths; }); // 기본 너비 저장 (리셋용) const defaultWidths = React.useMemo(() => { const widths: Record = {}; columns.forEach((col) => { widths[col.field] = col.width ? parseInt(col.width) : 120; }); return widths; }, [columns]); // 리사이즈 상태 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, }); // 수동 조정 시 균등 분배 모드 해제 setIsEqualizedMode(false); }; // 컬럼 확장 상태 추적 (토글용) const [expandedColumns, setExpandedColumns] = useState>(new Set()); // 데이터 기준 최적 너비 계산 const calculateAutoFitWidth = (field: string): number => { const column = columns.find(col => col.field === field); if (!column) return 120; // 헤더 텍스트 길이 (대략 8px per character + padding) const headerWidth = (column.label?.length || field.length) * 8 + 40; // 데이터 중 가장 긴 텍스트 찾기 let maxDataWidth = 0; data.forEach(row => { const value = row[field]; if (value !== undefined && value !== null) { let displayText = String(value); // 숫자는 천단위 구분자 포함 if (typeof value === 'number') { displayText = value.toLocaleString(); } // 날짜는 yyyy-mm-dd 형식 if (column.type === 'date' && displayText.includes('T')) { displayText = displayText.split('T')[0]; } // 대략적인 너비 계산 (8px per character + padding) const textWidth = displayText.length * 8 + 32; maxDataWidth = Math.max(maxDataWidth, textWidth); } }); // 헤더와 데이터 중 큰 값 사용, 최소 60px, 최대 400px const optimalWidth = Math.max(headerWidth, maxDataWidth); return Math.min(Math.max(optimalWidth, 60), 400); }; // 더블클릭으로 auto-fit / 기본 너비 토글 const handleDoubleClick = (field: string) => { // 개별 컬럼 조정 시 균등 분배 모드 해제 setIsEqualizedMode(false); setExpandedColumns(prev => { const newSet = new Set(prev); if (newSet.has(field)) { // 확장 상태 → 기본 너비로 복구 newSet.delete(field); setColumnWidths(prevWidths => ({ ...prevWidths, [field]: defaultWidths[field] || 120, })); } else { // 기본 상태 → 데이터 기준 auto-fit newSet.add(field); const autoWidth = calculateAutoFitWidth(field); setColumnWidths(prevWidths => ({ ...prevWidths, [field]: autoWidth, })); } return newSet; }); }; // 균등 분배 트리거 감지 useEffect(() => { if (equalizeWidthsTrigger === undefined || equalizeWidthsTrigger === 0) return; if (!containerRef.current) return; // 실제 컨테이너 너비 측정 const containerWidth = containerRef.current.offsetWidth; // 체크박스 컬럼 너비(40px) + 테이블 border(2px) 제외한 가용 너비 계산 const checkboxColumnWidth = 40; const borderWidth = 2; const availableWidth = containerWidth - checkboxColumnWidth - borderWidth; // 컬럼 수로 나눠서 균등 분배 (최소 60px 보장) const equalWidth = Math.max(60, Math.floor(availableWidth / columns.length)); const newWidths: Record = {}; columns.forEach((col) => { newWidths[col.field] = equalWidth; }); setColumnWidths(newWidths); setExpandedColumns(new Set()); // 확장 상태 초기화 setIsEqualizedMode(true); // 균등 분배 모드 활성화 }, [equalizeWidthsTrigger, columns]); 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, columns, 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 ""; // 정수면 소수점 없이, 소수면 소수점 유지 if (Number.isInteger(num)) { return num.toString(); } else { return num.toString(); } })(); return ( handleCellEdit(rowIndex, column.field, parseFloat(e.target.value) || 0) } className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none min-w-0 w-full" /> ); 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)} className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none min-w-0 w-full" /> ); case "select": return ( ); default: // text return ( handleCellEdit(rowIndex, column.field, e.target.value)} className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none min-w-0 w-full" /> ); } }; // 드래그 아이템 ID 목록 const sortableItems = data.map((_, idx) => `row-${idx}`); return (
{/* 드래그 핸들 헤더 */} {/* 체크박스 헤더 */} {columns.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; const isExpanded = expandedColumns.has(col.field); return ( ); })} {data.length === 0 ? ( ) : ( data.map((row, rowIndex) => ( {({ attributes, listeners, isDragging }) => ( <> {/* 드래그 핸들 */} {/* 체크박스 */} {/* 데이터 컬럼들 */} {columns.map((col) => ( ))} )} )) )}
순서 handleDoubleClick(col.field)} title={isExpanded ? "더블클릭하여 기본 너비로 복구" : "더블클릭하여 내용에 맞게 확장"} >
{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)}
); }