From 56608001ff9a6c787802bd01f61b9307e895e22b Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 16 Dec 2025 11:39:30 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(modal-repeater-table):=20=EC=B2=B4?= =?UTF-8?q?=ED=81=AC=EB=B0=95=EC=8A=A4=20=EA=B8=B0=EB=B0=98=20=EC=9D=BC?= =?UTF-8?q?=EA=B4=84=20=EC=82=AD=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RepeaterTable: 체크박스 컬럼 추가 (전체 선택/개별 선택 지원) - RepeaterTable: 선택된 행 시각적 피드백 (bg-blue-50) - RepeaterTable: 기존 개별 삭제 버튼 컬럼 제거 - ModalRepeaterTableComponent: selectedRows 상태 및 handleBulkDelete 함수 추가 - ModalRepeaterTableComponent: "선택 삭제" 버튼 UI 추가 - RepeatScreenModalConfigPanel: 행 번호 컬럼 선택에서 빈 값 필터링 --- .../ModalRepeaterTableComponent.tsx | 43 ++++++++-- .../modal-repeater-table/RepeaterTable.tsx | 80 ++++++++++++++----- .../RepeatScreenModalConfigPanel.tsx | 12 +-- 3 files changed, 102 insertions(+), 33 deletions(-) diff --git a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx index e16c5d72..6177f647 100644 --- a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx @@ -328,6 +328,9 @@ export function ModalRepeaterTableComponent({ const companyCode = componentConfig?.companyCode || propCompanyCode; const [modalOpen, setModalOpen] = useState(false); + // 체크박스 선택 상태 + const [selectedRows, setSelectedRows] = useState>(new Set()); + // 🆕 납기일 일괄 적용 플래그 (딱 한 번만 실행) const [isDeliveryDateApplied, setIsDeliveryDateApplied] = useState(false); @@ -794,6 +797,18 @@ export function ModalRepeaterTableComponent({ handleChange(newData); }; + // 선택된 항목 일괄 삭제 핸들러 + const handleBulkDelete = () => { + if (selectedRows.size === 0) return; + + // 선택되지 않은 항목만 남김 + const newData = localValue.filter((_, index) => !selectedRows.has(index)); + + // 데이터 업데이트 및 선택 상태 초기화 + handleChange(newData); + setSelectedRows(new Set()); + }; + // 컬럼명 -> 라벨명 매핑 생성 (sourceColumnLabels 우선, 없으면 columns에서 가져옴) const columnLabels = columns.reduce((acc, col) => { // sourceColumnLabels에 정의된 라벨 우선 사용 @@ -807,14 +822,26 @@ export function ModalRepeaterTableComponent({
{localValue.length > 0 && `${localValue.length}개 항목`} + {selectedRows.size > 0 && ` (${selectedRows.size}개 선택됨)`} +
+
+ {selectedRows.size > 0 && ( + + )} +
-
{/* Repeater 테이블 */} @@ -826,6 +853,8 @@ export function ModalRepeaterTableComponent({ onRowDelete={handleRowDelete} activeDataSources={activeDataSources} onDataSourceChange={handleDataSourceChange} + selectedRows={selectedRows} + onSelectionChange={setSelectedRows} /> {/* 항목 선택 모달 */} diff --git a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx index 56e9a321..1badecf9 100644 --- a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx @@ -3,9 +3,9 @@ import React, { useState, useEffect } from "react"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { Trash2, ChevronDown, Check } from "lucide-react"; +import { ChevronDown, Check } from "lucide-react"; +import { Checkbox } from "@/components/ui/checkbox"; import { RepeaterColumnConfig } from "./types"; import { cn } from "@/lib/utils"; @@ -18,6 +18,9 @@ interface RepeaterTableProps { // 동적 데이터 소스 관련 activeDataSources?: Record; // 컬럼별 현재 활성화된 데이터 소스 ID onDataSourceChange?: (columnField: string, optionId: string) => void; // 데이터 소스 변경 콜백 + // 체크박스 선택 관련 + selectedRows: Set; // 선택된 행 인덱스 + onSelectionChange: (selectedRows: Set) => void; // 선택 변경 콜백 } export function RepeaterTable({ @@ -28,6 +31,8 @@ export function RepeaterTable({ onRowDelete, activeDataSources = {}, onDataSourceChange, + selectedRows, + onSelectionChange, }: RepeaterTableProps) { const [editingCell, setEditingCell] = useState<{ rowIndex: number; @@ -112,6 +117,33 @@ export function RepeaterTable({ 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, @@ -215,8 +247,17 @@ export function RepeaterTable({ - {columns.map((col) => { const hasDynamicSource = col.dynamicDataSource?.enabled && col.dynamicDataSource.options.length > 0; @@ -303,16 +344,13 @@ export function RepeaterTable({ ); })} - {data.length === 0 ? ( ) : ( data.map((row, rowIndex) => ( - - + {columns.map((col) => ( ))} - )) )} diff --git a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel.tsx b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel.tsx index 0c2edc4e..2a77f96a 100644 --- a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel.tsx +++ b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPanel.tsx @@ -1744,11 +1744,13 @@ function RowNumberingConfigSection({ - {tableColumns.map((col, index) => ( - - {col.label || col.field} - - ))} + {tableColumns + .filter((col) => col.field && col.field.trim() !== "") + .map((col, index) => ( + + {col.label || col.field} + + ))}

From 342042d761d9f1091d967c2bf1c54d41d4f68f6b Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 16 Dec 2025 13:58:30 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(repeater-table):=20=ED=96=89=20?= =?UTF-8?q?=EB=93=9C=EB=9E=98=EA=B7=B8=20=EC=95=A4=20=EB=93=9C=EB=A1=AD=20?= =?UTF-8?q?=EB=B0=8F=20=EC=BB=AC=EB=9F=BC=20=EB=84=88=EB=B9=84=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @dnd-kit 라이브러리로 행 순서 드래그 앤 드롭 구현 - SortableRow 컴포넌트로 드래그 가능한 테이블 행 구현 - GripVertical 아이콘 드래그 핸들 추가 - 드래그 시 선택된 행 인덱스 자동 재계산 - "균등 분배" 버튼으로 컬럼 너비 컨테이너에 맞게 균등 분배 - 컬럼 헤더 더블클릭으로 데이터 기준 자동 확장/복구 토글 - Input 컴포넌트 min-w-0 w-full 적용으로 컬럼 너비 초과 방지 --- .../ModalRepeaterTableComponent.tsx | 26 +- .../modal-repeater-table/RepeaterTable.tsx | 375 ++++++++++++++---- 2 files changed, 330 insertions(+), 71 deletions(-) diff --git a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx index 6177f647..c7d7c8b6 100644 --- a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; -import { Plus } from "lucide-react"; +import { Plus, Columns } from "lucide-react"; import { ItemSelectionModal } from "./ItemSelectionModal"; import { RepeaterTable } from "./RepeaterTable"; import { ModalRepeaterTableProps, RepeaterColumnConfig, JoinCondition, DynamicDataSourceOption } from "./types"; @@ -331,6 +331,9 @@ export function ModalRepeaterTableComponent({ // 체크박스 선택 상태 const [selectedRows, setSelectedRows] = useState>(new Set()); + // 균등 분배 트리거 (값이 변경되면 RepeaterTable에서 균등 분배 실행) + const [equalizeWidthsTrigger, setEqualizeWidthsTrigger] = useState(0); + // 🆕 납기일 일괄 적용 플래그 (딱 한 번만 실행) const [isDeliveryDateApplied, setIsDeliveryDateApplied] = useState(false); @@ -820,9 +823,23 @@ export function ModalRepeaterTableComponent({

{/* 추가 버튼 */}
-
- {localValue.length > 0 && `${localValue.length}개 항목`} - {selectedRows.size > 0 && ` (${selectedRows.size}개 선택됨)`} +
+ + {localValue.length > 0 && `${localValue.length}개 항목`} + {selectedRows.size > 0 && ` (${selectedRows.size}개 선택됨)`} + + {columns.length > 0 && ( + + )}
{selectedRows.size > 0 && ( @@ -855,6 +872,7 @@ export function ModalRepeaterTableComponent({ onDataSourceChange={handleDataSourceChange} selectedRows={selectedRows} onSelectionChange={setSelectedRows} + equalizeWidthsTrigger={equalizeWidthsTrigger} /> {/* 항목 선택 모달 */} diff --git a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx index 1badecf9..4d6c9086 100644 --- a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx @@ -1,14 +1,68 @@ "use client"; -import React, { useState, useEffect } from "react"; +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 } from "lucide-react"; +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[]; @@ -21,6 +75,8 @@ interface RepeaterTableProps { // 체크박스 선택 관련 selectedRows: Set; // 선택된 행 인덱스 onSelectionChange: (selectedRows: Set) => void; // 선택 변경 콜백 + // 균등 분배 트리거 + equalizeWidthsTrigger?: number; // 값이 변경되면 균등 분배 실행 } export function RepeaterTable({ @@ -33,7 +89,58 @@ export function RepeaterTable({ 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; @@ -71,15 +178,100 @@ export function RepeaterTable({ startX: e.clientX, startWidth: columnWidths[field] || 120, }); + // 수동 조정 시 균등 분배 모드 해제 + setIsEqualizedMode(false); }; - // 더블클릭으로 기본 너비로 리셋 - const handleDoubleClick = (field: string) => { - setColumnWidths((prev) => ({ - ...prev, - [field]: defaultWidths[field] || 120, - })); + // 컬럼 확장 상태 추적 (토글용) + 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; @@ -176,7 +368,7 @@ export function RepeaterTable({ onChange={(e) => 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" + 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" /> ); @@ -204,7 +396,7 @@ export function RepeaterTable({ type="date" value={formatDateValue(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" + 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" /> ); @@ -216,7 +408,7 @@ export function RepeaterTable({ handleCellEdit(rowIndex, column.field, newValue) } > - + @@ -235,30 +427,49 @@ 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" + 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 ( -
-
-
- # + + - 삭제 -
추가된 항목이 없습니다 @@ -320,25 +358,25 @@ export function RepeaterTable({
- {rowIndex + 1} +
+ handleRowSelect(rowIndex, !!checked)} + className="border-gray-400" + /> {renderCell(row, col, rowIndex)} - -
- - - + +
+
+
- -
+ + + {/* 드래그 핸들 헤더 */} + + {/* 체크박스 헤더 */} + {columns.map((col) => { const hasDynamicSource = col.dynamicDataSource?.enabled && col.dynamicDataSource.options.length > 0; const activeOptionId = activeDataSources[col.field] || col.dynamicDataSource?.defaultOptionId; @@ -266,13 +477,15 @@ export function RepeaterTable({ ? 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) => ( - - - {columns.map((col) => ( - + {data.length === 0 ? ( + + - ))} - - )) - )} - -
+ 순서 + + + handleDoubleClick(col.field)} - title="더블클릭하여 기본 너비로 되돌리기" + title={isExpanded ? "더블클릭하여 기본 너비로 복구" : "더블클릭하여 내용에 맞게 확장"} >
@@ -344,46 +557,74 @@ export function RepeaterTable({
- 추가된 항목이 없습니다 -
- handleRowSelect(rowIndex, !!checked)} - className="border-gray-400" - /> - - {renderCell(row, col, rowIndex)} + + +
+ 추가된 항목이 없습니다
+ + ) : ( + data.map((row, rowIndex) => ( + + {({ attributes, listeners, isDragging }) => ( + <> + {/* 드래그 핸들 */} + + + + {/* 체크박스 */} + + handleRowSelect(rowIndex, !!checked)} + className="border-gray-400" + /> + + {/* 데이터 컬럼들 */} + {columns.map((col) => ( + + {renderCell(row, col, rowIndex)} + + ))} + + )} + + )) + )} + + + + - + ); }