From b61cb17aeafcd3d74a59752fe9ae8faa2dbcc7c4 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 8 Jan 2026 12:04:31 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EC=B1=84=EC=9A=B0?= =?UTF-8?q?=EA=B8=B0=20=ED=95=B8=EB=93=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/common/EditableSpreadsheet.tsx | 695 ++++++++++++++++++ .../components/common/ExcelUploadModal.tsx | 228 ++---- 2 files changed, 762 insertions(+), 161 deletions(-) create mode 100644 frontend/components/common/EditableSpreadsheet.tsx diff --git a/frontend/components/common/EditableSpreadsheet.tsx b/frontend/components/common/EditableSpreadsheet.tsx new file mode 100644 index 00000000..de9c827d --- /dev/null +++ b/frontend/components/common/EditableSpreadsheet.tsx @@ -0,0 +1,695 @@ +"use client"; + +import React, { useState, useRef, useEffect, useCallback } from "react"; +import { cn } from "@/lib/utils"; + +interface EditableSpreadsheetProps { + columns: string[]; + data: Record[]; + onColumnsChange: (columns: string[]) => void; + onDataChange: (data: Record[]) => void; + maxHeight?: string; +} + +/** + * 엑셀처럼 편집 가능한 스프레드시트 컴포넌트 + * - 셀 클릭으로 편집 + * - Tab/Enter로 다음 셀 이동 + * - 마지막 행/열에서 자동 추가 + * - 헤더(컬럼명)도 편집 가능 + * - 자동 채우기 (드래그 핸들) + */ +export const EditableSpreadsheet: React.FC = ({ + columns, + data, + onColumnsChange, + onDataChange, + maxHeight = "350px", +}) => { + // 현재 편집 중인 셀 (row: -1은 헤더) + const [editingCell, setEditingCell] = useState<{ + row: number; + col: number; + } | null>(null); + const [editValue, setEditValue] = useState(""); + + // 현재 선택된 셀 (편집 모드 아닐 때도 표시) + const [selectedCell, setSelectedCell] = useState<{ + row: number; + col: number; + } | null>(null); + + // 자동 채우기 드래그 상태 + const [isDraggingFill, setIsDraggingFill] = useState(false); + const [fillPreviewEnd, setFillPreviewEnd] = useState(null); + + const inputRef = useRef(null); + const tableRef = useRef(null); + + // 셀 선택 (클릭만, 편집 아님) + const selectCell = useCallback((row: number, col: number) => { + setSelectedCell({ row, col }); + }, []); + + // 셀 편집 시작 (더블클릭 또는 타이핑 시작) + const startEditing = useCallback( + (row: number, col: number) => { + setEditingCell({ row, col }); + setSelectedCell({ row, col }); + if (row === -1) { + // 헤더 편집 + setEditValue(columns[col] || ""); + } else { + // 데이터 셀 편집 + const colName = columns[col]; + setEditValue(String(data[row]?.[colName] ?? "")); + } + }, + [columns, data] + ); + + // 편집 완료 + const finishEditing = useCallback(() => { + if (!editingCell) return; + + const { row, col } = editingCell; + + if (row === -1) { + // 헤더(컬럼명) 변경 + const newColumns = [...columns]; + const oldColName = newColumns[col]; + const newColName = editValue.trim() || `Column${col + 1}`; + + if (oldColName !== newColName) { + newColumns[col] = newColName; + onColumnsChange(newColumns); + + // 데이터의 키도 함께 변경 + const newData = data.map((rowData) => { + const newRowData: Record = {}; + Object.keys(rowData).forEach((key) => { + if (key === oldColName) { + newRowData[newColName] = rowData[key]; + } else { + newRowData[key] = rowData[key]; + } + }); + return newRowData; + }); + onDataChange(newData); + } + } else { + // 데이터 셀 변경 + const colName = columns[col]; + const newData = [...data]; + if (!newData[row]) { + newData[row] = {}; + } + newData[row] = { ...newData[row], [colName]: editValue }; + onDataChange(newData); + } + + setEditingCell(null); + setEditValue(""); + }, [editingCell, editValue, columns, data, onColumnsChange, onDataChange]); + + // 다음 셀로 이동 + const moveToNextCell = useCallback( + (direction: "right" | "down" | "left" | "up") => { + if (!editingCell) return; + + finishEditing(); + + const { row, col } = editingCell; + let nextRow = row; + let nextCol = col; + + switch (direction) { + case "right": + if (col < columns.length - 1) { + nextCol = col + 1; + } else { + // 마지막 열에서 Tab → 새 열 추가 (빈 헤더로) + const tempColId = `__temp_${Date.now()}`; + const newColumns = [...columns, ""]; + onColumnsChange(newColumns); + + // 모든 행에 새 컬럼 추가 (임시 키 사용) + const newData = data.map((rowData) => ({ + ...rowData, + [tempColId]: "", + })); + onDataChange(newData); + + nextCol = columns.length; + } + break; + + case "down": + if (row === -1) { + nextRow = 0; + } else if (row < data.length - 1) { + nextRow = row + 1; + } else { + // 마지막 행에서 Enter → 새 행 추가 + const newRow: Record = {}; + columns.forEach((c) => { + newRow[c] = ""; + }); + onDataChange([...data, newRow]); + nextRow = data.length; + } + break; + + case "left": + if (col > 0) { + nextCol = col - 1; + } + break; + + case "up": + if (row > -1) { + nextRow = row - 1; + } + break; + } + + // 다음 셀 편집 시작 + setTimeout(() => { + startEditing(nextRow, nextCol); + }, 0); + }, + [editingCell, columns, data, onColumnsChange, onDataChange, finishEditing, startEditing] + ); + + // 키보드 이벤트 처리 + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + switch (e.key) { + case "Tab": + e.preventDefault(); + moveToNextCell(e.shiftKey ? "left" : "right"); + break; + case "Enter": + e.preventDefault(); + moveToNextCell("down"); + break; + case "Escape": + setEditingCell(null); + setEditValue(""); + break; + case "ArrowUp": + if (!e.shiftKey) { + e.preventDefault(); + moveToNextCell("up"); + } + break; + case "ArrowDown": + if (!e.shiftKey) { + e.preventDefault(); + moveToNextCell("down"); + } + break; + case "ArrowLeft": + // 커서가 맨 앞이면 왼쪽 셀로 + if (inputRef.current?.selectionStart === 0) { + e.preventDefault(); + moveToNextCell("left"); + } + break; + case "ArrowRight": + // 커서가 맨 뒤면 오른쪽 셀로 + if (inputRef.current?.selectionStart === editValue.length) { + e.preventDefault(); + moveToNextCell("right"); + } + break; + } + }, + [moveToNextCell, editValue] + ); + + // 편집 모드일 때 input에 포커스 + useEffect(() => { + if (editingCell && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [editingCell]); + + // 외부 클릭 시 편집 종료 + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (tableRef.current && !tableRef.current.contains(e.target as Node)) { + finishEditing(); + setSelectedCell(null); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [finishEditing]); + + // 행 삭제 + const handleDeleteRow = (rowIndex: number) => { + const newData = data.filter((_, i) => i !== rowIndex); + onDataChange(newData); + }; + + // 열 삭제 + const handleDeleteColumn = (colIndex: number) => { + if (columns.length <= 1) return; + + const colName = columns[colIndex]; + const newColumns = columns.filter((_, i) => i !== colIndex); + onColumnsChange(newColumns); + + const newData = data.map((row) => { + const { [colName]: removed, ...rest } = row; + return rest; + }); + onDataChange(newData); + }; + + // 컬럼 문자 (A, B, C, ...) + const getColumnLetter = (index: number): string => { + let letter = ""; + let i = index; + while (i >= 0) { + letter = String.fromCharCode(65 + (i % 26)) + letter; + i = Math.floor(i / 26) - 1; + } + return letter; + }; + + // ============ 자동 채우기 로직 ============ + + // 값에서 마지막 숫자 패턴 추출 (예: "26-item-0005" → prefix: "26-item-", number: 5, suffix: "", numLength: 4) + const extractNumberPattern = (value: string): { + prefix: string; + number: number; + suffix: string; + numLength: number; + isZeroPadded: boolean; + } | null => { + // 숫자만 있는 경우 + if (/^-?\d+(\.\d+)?$/.test(value)) { + const isZeroPadded = value.startsWith("0") && value.length > 1 && !value.includes("."); + return { + prefix: "", + number: parseFloat(value), + suffix: "", + numLength: value.replace("-", "").split(".")[0].length, + isZeroPadded + }; + } + + // 마지막 숫자 시퀀스를 찾기 (greedy하게 prefix를 찾음) + // 예: "26-item-0005" → prefix: "26-item-", number: "0005", suffix: "" + const match = value.match(/^(.*)(\d+)(\D*)$/); + if (match) { + const numStr = match[2]; + const isZeroPadded = numStr.startsWith("0") && numStr.length > 1; + return { + prefix: match[1], + number: parseInt(numStr, 10), + suffix: match[3], + numLength: numStr.length, + isZeroPadded + }; + } + + return null; + }; + + // 날짜 패턴 인식 + const extractDatePattern = (value: string): Date | null => { + // YYYY-MM-DD 형식 + const dateMatch = value.match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (dateMatch) { + const date = new Date(parseInt(dateMatch[1]), parseInt(dateMatch[2]) - 1, parseInt(dateMatch[3])); + if (!isNaN(date.getTime())) { + return date; + } + } + return null; + }; + + // 다음 값 생성 + const generateNextValue = (sourceValue: string, step: number): string => { + // 빈 값이면 그대로 + if (!sourceValue || sourceValue.trim() === "") { + return ""; + } + + // 날짜 패턴 체크 + const datePattern = extractDatePattern(sourceValue); + if (datePattern) { + const newDate = new Date(datePattern); + newDate.setDate(newDate.getDate() + step); + const year = newDate.getFullYear(); + const month = String(newDate.getMonth() + 1).padStart(2, "0"); + const day = String(newDate.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; + } + + // 숫자 패턴 체크 + const numberPattern = extractNumberPattern(sourceValue); + if (numberPattern) { + const newNumber = numberPattern.number + step; + + // 음수 방지 (필요시) + const absNumber = Math.max(0, newNumber); + + let numStr: string; + if (numberPattern.isZeroPadded) { + // 제로패딩 유지 (예: 0005 → 0006) + numStr = String(absNumber).padStart(numberPattern.numLength, "0"); + } else { + numStr = String(absNumber); + } + + return numberPattern.prefix + numStr + numberPattern.suffix; + } + + // 패턴 없으면 복사 + return sourceValue; + }; + + // 자동 채우기 드래그 시작 + const handleFillDragStart = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (!selectedCell || selectedCell.row < 0) return; + + setIsDraggingFill(true); + setFillPreviewEnd(selectedCell.row); + }; + + // 자동 채우기 드래그 중 + const handleFillDragMove = useCallback((e: MouseEvent) => { + if (!isDraggingFill || !selectedCell || !tableRef.current) return; + + const rows = tableRef.current.querySelectorAll("tbody tr"); + const mouseY = e.clientY; + + // 마우스 위치에 해당하는 행 찾기 + for (let i = 0; i < rows.length - 1; i++) { // 마지막 행(추가 영역) 제외 + const row = rows[i] as HTMLElement; + const rect = row.getBoundingClientRect(); + + if (mouseY >= rect.top && mouseY <= rect.bottom) { + setFillPreviewEnd(i); + break; + } else if (mouseY > rect.bottom && i === rows.length - 2) { + setFillPreviewEnd(i); + } + } + }, [isDraggingFill, selectedCell]); + + // 자동 채우기 드래그 종료 + const handleFillDragEnd = useCallback(() => { + if (!isDraggingFill || !selectedCell || fillPreviewEnd === null) { + setIsDraggingFill(false); + setFillPreviewEnd(null); + return; + } + + const { row: startRow, col } = selectedCell; + const endRow = fillPreviewEnd; + + if (startRow !== endRow && startRow >= 0) { + const colName = columns[col]; + const sourceValue = String(data[startRow]?.[colName] ?? ""); + const newData = [...data]; + + if (endRow > startRow) { + // 아래로 채우기 + for (let i = startRow + 1; i <= endRow; i++) { + const step = i - startRow; + if (!newData[i]) { + newData[i] = {}; + columns.forEach((c) => { + newData[i][c] = ""; + }); + } + newData[i] = { + ...newData[i], + [colName]: generateNextValue(sourceValue, step), + }; + } + } else { + // 위로 채우기 + for (let i = startRow - 1; i >= endRow; i--) { + const step = i - startRow; + if (!newData[i]) { + newData[i] = {}; + columns.forEach((c) => { + newData[i][c] = ""; + }); + } + newData[i] = { + ...newData[i], + [colName]: generateNextValue(sourceValue, step), + }; + } + } + + onDataChange(newData); + } + + setIsDraggingFill(false); + setFillPreviewEnd(null); + }, [isDraggingFill, selectedCell, fillPreviewEnd, columns, data, onDataChange]); + + // 드래그 이벤트 리스너 + useEffect(() => { + if (isDraggingFill) { + document.addEventListener("mousemove", handleFillDragMove); + document.addEventListener("mouseup", handleFillDragEnd); + return () => { + document.removeEventListener("mousemove", handleFillDragMove); + document.removeEventListener("mouseup", handleFillDragEnd); + }; + } + }, [isDraggingFill, handleFillDragMove, handleFillDragEnd]); + + // 셀이 자동 채우기 미리보기 범위에 있는지 확인 + const isInFillPreview = (rowIndex: number, colIndex: number): boolean => { + if (!isDraggingFill || !selectedCell || fillPreviewEnd === null) return false; + if (colIndex !== selectedCell.col) return false; + + const startRow = selectedCell.row; + const endRow = fillPreviewEnd; + + if (endRow > startRow) { + return rowIndex > startRow && rowIndex <= endRow; + } else { + return rowIndex >= endRow && rowIndex < startRow; + } + }; + + return ( +
+ + {/* 열 인덱스 헤더 (A, B, C, ...) */} + + + {/* 빈 코너 셀 */} + + {columns.map((_, colIndex) => ( + + ))} + {/* 새 열 추가 버튼 */} + + + + {/* 컬럼명 헤더 (편집 가능) */} + + + {columns.map((colName, colIndex) => ( + + ))} + + + + + + {data.map((row, rowIndex) => ( + + {/* 행 번호 */} + + + {/* 데이터 셀 */} + {columns.map((colName, colIndex) => { + const isSelected = selectedCell?.row === rowIndex && selectedCell?.col === colIndex; + const isEditing = editingCell?.row === rowIndex && editingCell?.col === colIndex; + const inFillPreview = isInFillPreview(rowIndex, colIndex); + + return ( + + ); + })} + + + ))} + + {/* 새 행 추가 영역 */} + + + + + +
+ + +
+ {getColumnLetter(colIndex)} + {columns.length > 1 && ( + + )} +
+
+ +
+ 1 + { + selectCell(-1, colIndex); + startEditing(-1, colIndex); + }} + > + {editingCell?.row === -1 && editingCell?.col === colIndex ? ( + setEditValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={finishEditing} + className="w-full bg-white px-2 py-1 text-xs font-medium text-primary outline-none" + /> + ) : ( +
{colName || 빈 헤더}
+ )} +
+
+ {rowIndex + 2} + +
+
{ + selectCell(rowIndex, colIndex); + if (!isEditing) { + // 단일 클릭은 선택만 + } + }} + onDoubleClick={() => startEditing(rowIndex, colIndex)} + > + {isEditing ? ( + setEditValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={finishEditing} + className="w-full bg-white px-2 py-1 text-xs outline-none" + /> + ) : ( +
+ {String(row[colName] ?? "")} +
+ )} + + {/* 자동 채우기 핸들 - 선택된 셀에서만 표시 */} + {isSelected && !isEditing && ( +
+ )} +
+ + { + const newRow: Record = {}; + columns.forEach((c) => { + newRow[c] = ""; + }); + onDataChange([...data, newRow]); + // 새 행의 첫 번째 셀 편집 시작 + setTimeout(() => { + startEditing(data.length, 0); + }, 0); + }} + > + 클릭하여 새 행 추가... +
+
+ ); +}; diff --git a/frontend/components/common/ExcelUploadModal.tsx b/frontend/components/common/ExcelUploadModal.tsx index 01c39351..867b6f85 100644 --- a/frontend/components/common/ExcelUploadModal.tsx +++ b/frontend/components/common/ExcelUploadModal.tsx @@ -24,8 +24,6 @@ import { FileSpreadsheet, AlertCircle, CheckCircle2, - Plus, - Minus, ArrowRight, Zap, } from "lucide-react"; @@ -34,6 +32,7 @@ import { DynamicFormApi } from "@/lib/api/dynamicForm"; import { getTableSchema, TableColumn } from "@/lib/api/tableSchema"; import { cn } from "@/lib/utils"; import { findMappingByColumns, saveMappingTemplate } from "@/lib/api/excelMapping"; +import { EditableSpreadsheet } from "./EditableSpreadsheet"; export interface ExcelUploadModalProps { open: boolean; @@ -167,56 +166,6 @@ export const ExcelUploadModal: React.FC = ({ } }; - // 행 추가 - const handleAddRow = () => { - const newRow: Record = {}; - excelColumns.forEach((col) => { - newRow[col] = ""; - }); - setDisplayData([...displayData, newRow]); - toast.success("행이 추가되었습니다."); - }; - - // 행 삭제 - const handleRemoveRow = () => { - if (displayData.length > 1) { - setDisplayData(displayData.slice(0, -1)); - toast.success("마지막 행이 삭제되었습니다."); - } else { - toast.error("최소 1개의 행이 필요합니다."); - } - }; - - // 열 추가 - const handleAddColumn = () => { - const newColName = `Column${excelColumns.length + 1}`; - setExcelColumns([...excelColumns, newColName]); - setDisplayData( - displayData.map((row) => ({ - ...row, - [newColName]: "", - })) - ); - toast.success("열이 추가되었습니다."); - }; - - // 열 삭제 - const handleRemoveColumn = () => { - if (excelColumns.length > 1) { - const lastCol = excelColumns[excelColumns.length - 1]; - setExcelColumns(excelColumns.slice(0, -1)); - setDisplayData( - displayData.map((row) => { - const { [lastCol]: removed, ...rest } = row; - return rest; - }) - ); - toast.success("마지막 열이 삭제되었습니다."); - } else { - toast.error("최소 1개의 열이 필요합니다."); - } - }; - // 테이블 스키마 가져오기 (2단계 진입 시) useEffect(() => { if (currentStep === 2 && tableName) { @@ -336,6 +285,42 @@ export const ExcelUploadModal: React.FC = ({ return; } + // 1단계 → 2단계 전환 시: 빈 헤더 열 제외 + if (currentStep === 1) { + // 빈 헤더가 아닌 열만 필터링 + const validColumnIndices: number[] = []; + const validColumns: string[] = []; + + excelColumns.forEach((col, index) => { + if (col && col.trim() !== "") { + validColumnIndices.push(index); + validColumns.push(col); + } + }); + + // 빈 헤더 열이 있었다면 데이터에서도 해당 열 제거 + if (validColumns.length < excelColumns.length) { + const removedCount = excelColumns.length - validColumns.length; + + // 새로운 데이터: 유효한 열만 포함 + const cleanedData = displayData.map((row) => { + const newRow: Record = {}; + validColumns.forEach((colName) => { + newRow[colName] = row[colName]; + }); + return newRow; + }); + + setExcelColumns(validColumns); + setDisplayData(cleanedData); + setAllData(cleanedData); + + if (removedCount > 0) { + toast.info(`빈 헤더 ${removedCount}개 열이 제외되었습니다.`); + } + } + } + setCurrentStep((prev) => Math.min(prev + 1, 3)); }; @@ -599,8 +584,8 @@ export const ExcelUploadModal: React.FC = ({ {/* 파일이 선택된 경우에만 미리보기 표시 */} {file && displayData.length > 0 && ( <> - {/* 시트 선택 + 행/열 편집 버튼 */} -
+ {/* 시트 선택 */} +
- -
- - - - -
+ + {displayData.length}개 행 · 셀을 클릭하여 편집, Tab/Enter로 이동 +
- {/* 감지된 범위 */} -
- 감지된 범위: {detectedRange} - ({displayData.length}개 행) -
- - {/* 데이터 미리보기 테이블 */} -
- - - - - {excelColumns.map((col, index) => ( - - ))} - - - - - - {excelColumns.map((col) => ( - - ))} - - {displayData.slice(0, 10).map((row, rowIndex) => ( - - - {excelColumns.map((col) => ( - - ))} - - ))} - {displayData.length > 10 && ( - - - - )} - -
- {String.fromCharCode(65 + index)} -
- 1 - - {col} -
- {rowIndex + 2} - - {String(row[col] || "")} -
- ... 외 {displayData.length - 10}개 행 -
-
+ {/* 엑셀처럼 편집 가능한 스프레드시트 */} + { + setExcelColumns(newColumns); + // 범위 재계산 + const lastCol = + newColumns.length > 0 + ? String.fromCharCode(64 + newColumns.length) + : "A"; + setDetectedRange(`A1:${lastCol}${displayData.length + 1}`); + }} + onDataChange={(newData) => { + setDisplayData(newData); + setAllData(newData); + // 범위 재계산 + const lastCol = + excelColumns.length > 0 + ? String.fromCharCode(64 + excelColumns.length) + : "A"; + setDetectedRange(`A1:${lastCol}${newData.length + 1}`); + }} + maxHeight="320px" + /> )}