diff --git a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx index 9604e7d2..47849849 100644 --- a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx @@ -31,8 +31,8 @@ import { CSS } from "@dnd-kit/utilities"; // SortableRow 컴포넌트 - 드래그 가능한 테이블 행 interface SortableRowProps { id: string; - children: (props: { - attributes: React.HTMLAttributes; + children: (props: { + attributes: React.HTMLAttributes; listeners: React.HTMLAttributes | undefined; isDragging: boolean; }) => React.ReactNode; @@ -40,14 +40,7 @@ interface SortableRowProps { } function SortableRow({ id, children, className }: SortableRowProps) { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id }); + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); const style: React.CSSProperties = { transform: CSS.Transform.toString(transform), @@ -93,9 +86,9 @@ export function RepeaterTable({ }: RepeaterTableProps) { // 컨테이너 ref - 실제 너비 측정용 const containerRef = useRef(null); - - // 균등 분배 모드 상태 (true일 때 테이블이 컨테이너에 맞춤) - const [isEqualizedMode, setIsEqualizedMode] = useState(false); + + // 초기 균등 분배 실행 여부 (마운트 시 한 번만 실행) + const initializedRef = useRef(false); // DnD 센서 설정 const sensors = useSensors( @@ -106,7 +99,7 @@ export function RepeaterTable({ }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, - }) + }), ); // 드래그 종료 핸들러 @@ -140,15 +133,15 @@ export function RepeaterTable({ } } }; - + const [editingCell, setEditingCell] = useState<{ rowIndex: number; field: string; } | null>(null); - + // 동적 데이터 소스 Popover 열림 상태 const [openPopover, setOpenPopover] = useState(null); - + // 컬럼 너비 상태 관리 const [columnWidths, setColumnWidths] = useState>(() => { const widths: Record = {}; @@ -157,7 +150,7 @@ export function RepeaterTable({ }); return widths; }); - + // 기본 너비 저장 (리셋용) const defaultWidths = React.useMemo(() => { const widths: Record = {}; @@ -166,10 +159,10 @@ export function RepeaterTable({ }); return widths; }, [columns]); - + // 리사이즈 상태 const [resizing, setResizing] = useState<{ field: string; startX: number; startWidth: number } | null>(null); - + // 리사이즈 핸들러 const handleMouseDown = (e: React.MouseEvent, field: string) => { e.preventDefault(); @@ -178,104 +171,163 @@ export function RepeaterTable({ 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; + // 컨테이너 가용 너비 계산 + const getAvailableWidth = (): number => { + if (!containerRef.current) return 800; + const containerWidth = containerRef.current.offsetWidth; + // 드래그 핸들(32px) + 체크박스 컬럼(40px) + border(2px) + return containerWidth - 74; + }; - // 헤더 텍스트 길이 (대략 8px per character + padding) - const headerWidth = (column.label?.length || field.length) * 8 + 40; + // 텍스트 너비 계산 (한글/영문/숫자 혼합 고려) + 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 = columns.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 => { + + data.forEach((row) => { const value = row[field]; - if (value !== undefined && value !== null) { + if (value !== undefined && value !== null && value !== "") { + hasValue = true; let displayText = String(value); - - // 숫자는 천단위 구분자 포함 - if (typeof value === 'number') { + + 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; + + const textWidth = measureTextWidth(displayText) + 20; // padding maxDataWidth = Math.max(maxDataWidth, textWidth); } }); - // 헤더와 데이터 중 큰 값 사용, 최소 60px, 최대 400px - const optimalWidth = Math.max(headerWidth, maxDataWidth); - return Math.min(Math.max(optimalWidth, 60), 400); + // 값이 없으면 균등 분배 너비 사용 + if (!hasValue) { + return equalWidth; + } + + // 헤더 텍스트 너비 + const headerText = column.label || field; + const headerWidth = measureTextWidth(headerText) + 24; // padding + + // 헤더와 데이터 중 큰 값 사용 + return Math.max(headerWidth, maxDataWidth); }; - // 더블클릭으로 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; - }); + const availableWidth = getAvailableWidth(); + const equalWidth = Math.max(60, Math.floor(availableWidth / columns.length)); + const contentWidth = calculateColumnContentWidth(field, equalWidth); + setColumnWidths((prev) => ({ + ...prev, + [field]: contentWidth, + })); }; - // 균등 분배 트리거 감지 - 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 applyEqualizeWidths = () => { + const availableWidth = getAvailableWidth(); 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]); - + }; + + // 자동 맞춤: 각 컬럼을 글자 너비에 맞추고, 컨테이너보다 작으면 남는 공간 분배 + const applyAutoFitWidths = () => { + if (columns.length === 0) return; + + // 균등 분배 너비 계산 (값이 없는 컬럼의 최소값) + const availableWidth = getAvailableWidth(); + const equalWidth = Math.max(60, Math.floor(availableWidth / columns.length)); + + // 1. 각 컬럼의 글자 너비 계산 (값이 없으면 균등 분배 너비 사용) + const newWidths: Record = {}; + columns.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 / columns.length); + columns.forEach((col) => { + newWidths[col.field] += extraPerColumn; + }); + } + // 컨테이너보다 크면 그대로 (스크롤 생성됨) + + setColumnWidths(newWidths); + }; + + // 초기 마운트 시 균등 분배 적용 + useEffect(() => { + if (initializedRef.current) return; + if (!containerRef.current || columns.length === 0) return; + + const timer = setTimeout(() => { + applyEqualizeWidths(); + initializedRef.current = true; + }, 100); + + return () => clearTimeout(timer); + }, [columns]); + + // 트리거 감지: 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; @@ -285,14 +337,14 @@ export function RepeaterTable({ [resizing.field]: newWidth, })); }; - + const handleMouseUp = () => { setResizing(null); }; - + document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); - + return () => { document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); @@ -336,13 +388,8 @@ export function RepeaterTable({ 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 renderCell = (row: any, column: RepeaterColumnConfig, rowIndex: number) => { + const isEditing = editingCell?.rowIndex === rowIndex && editingCell?.field === column.field; const value = row[column.field]; // 계산 필드는 편집 불가 @@ -359,14 +406,8 @@ export function RepeaterTable({ return num.toLocaleString("ko-KR"); } }; - - return ( -
- {column.type === "number" - ? formatNumber(value) - : value || "-"} -
- ); + + return
{column.type === "number" ? formatNumber(value) : value || "-"}
; } // 편집 가능한 필드 @@ -377,22 +418,22 @@ export function RepeaterTable({ 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 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" + onChange={(e) => { + 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" /> ); @@ -414,25 +455,21 @@ export function RepeaterTable({ } 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" + 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 ( - handleCellEdit(rowIndex, column.field, newValue)}> + @@ -451,7 +488,7 @@ export function RepeaterTable({ type="text" value={value || ""} onChange={(e) => 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" + 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" /> ); } @@ -461,126 +498,113 @@ export function RepeaterTable({ const sortableItems = data.map((_, idx) => `row-${idx}`); return ( - +
-
- +
sum + w, 0) + 74}px)`, + }} > - + {/* 드래그 핸들 헤더 */} - {/* 체크박스 헤더 */} - - {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 ( - - ); - })} + + ); + })} @@ -589,7 +613,7 @@ export function RepeaterTable({ @@ -600,19 +624,19 @@ export function RepeaterTable({ key={`row-${rowIndex}`} id={`row-${rowIndex}`} className={cn( - "hover:bg-blue-50/50 transition-colors", - selectedRows.has(rowIndex) && "bg-blue-50" + "transition-colors hover:bg-blue-50/50", + selectedRows.has(rowIndex) && "bg-blue-50", )} > {({ attributes, listeners, isDragging }) => ( <> {/* 드래그 핸들 */} - {/* 체크박스 */} - {/* 데이터 컬럼들 */} {columns.map((col) => ( - @@ -651,4 +678,3 @@ export function RepeaterTable({ ); } - diff --git a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx index 54866fd0..172e7037 100644 --- a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx +++ b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback, useMemo } from "react"; import { Button } from "@/components/ui/button"; -import { Plus, Columns } from "lucide-react"; +import { Plus, Columns, AlignJustify } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; @@ -170,8 +170,8 @@ export function TableSectionRenderer({ // 체크박스 선택 상태 const [selectedRows, setSelectedRows] = useState>(new Set()); - // 균등 분배 트리거 - const [equalizeWidthsTrigger, setEqualizeWidthsTrigger] = useState(0); + // 너비 조정 트리거 (홀수: 자동맞춤, 짝수: 균등분배) + const [widthTrigger, setWidthTrigger] = useState(0); // 동적 데이터 소스 활성화 상태 const [activeDataSources, setActiveDataSources] = useState>({}); @@ -438,12 +438,21 @@ export function TableSectionRenderer({ )} @@ -478,7 +487,7 @@ export function TableSectionRenderer({ onDataSourceChange={handleDataSourceChange} selectedRows={selectedRows} onSelectionChange={setSelectedRows} - equalizeWidthsTrigger={equalizeWidthsTrigger} + equalizeWidthsTrigger={widthTrigger} /> {/* 항목 선택 모달 */} diff --git a/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx index 653ee860..0976e1a4 100644 --- a/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx +++ b/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx @@ -1232,12 +1232,27 @@ export function TableSectionSettingsModal({ {(tableConfig.calculations || []).map((calc, index) => (
- updateCalculation(index, { resultField: e.target.value })} - placeholder="결과 필드" - className="h-8 text-xs w-[150px]" - /> + =
+ 순서 + handleDoubleClick(col.field)} - title={isExpanded ? "더블클릭하여 기본 너비로 복구" : "더블클릭하여 내용에 맞게 확장"} - > -
-
- {hasDynamicSource ? ( - setOpenPopover(open ? col.field : null)} - > - - - - { + 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 ( +
handleDoubleClick(col.field)} + title="더블클릭하여 글자 너비에 맞춤" + > +
+
+ {hasDynamicSource ? ( + setOpenPopover(open ? col.field : null)} > -
- 데이터 소스 선택 -
- {col.dynamicDataSource!.options.map((option) => ( + - ))} - -
- ) : ( - <> - {col.label} - {col.required && *} - - )} + + +
+ 데이터 소스 선택 +
+ {col.dynamicDataSource!.options.map((option) => ( + + ))} +
+ + ) : ( + <> + {col.label} + {col.required && *} + + )} +
+ {/* 리사이즈 핸들 */} +
handleMouseDown(e, col.field)} + title="드래그하여 너비 조정" + />
- {/* 리사이즈 핸들 */} -
handleMouseDown(e, col.field)} - title="드래그하여 너비 조정" - /> -
-
추가된 항목이 없습니다 + + handleRowSelect(rowIndex, !!checked)} @@ -630,10 +654,13 @@ export function RepeaterTable({ {renderCell(row, col, rowIndex)}