2026-01-08 12:04:31 +09:00
|
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
|
|
import React, { useState, useRef, useEffect, useCallback } from "react";
|
|
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
|
|
|
|
|
|
|
interface EditableSpreadsheetProps {
|
|
|
|
|
|
columns: string[];
|
|
|
|
|
|
data: Record<string, any>[];
|
|
|
|
|
|
onColumnsChange: (columns: string[]) => void;
|
|
|
|
|
|
onDataChange: (data: Record<string, any>[]) => void;
|
|
|
|
|
|
maxHeight?: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-08 12:14:04 +09:00
|
|
|
|
// 셀 범위 정의
|
|
|
|
|
|
interface CellRange {
|
|
|
|
|
|
startRow: number;
|
|
|
|
|
|
startCol: number;
|
|
|
|
|
|
endRow: number;
|
|
|
|
|
|
endCol: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-08 12:04:31 +09:00
|
|
|
|
/**
|
|
|
|
|
|
* 엑셀처럼 편집 가능한 스프레드시트 컴포넌트
|
|
|
|
|
|
* - 셀 클릭으로 편집
|
|
|
|
|
|
* - Tab/Enter로 다음 셀 이동
|
|
|
|
|
|
* - 마지막 행/열에서 자동 추가
|
|
|
|
|
|
* - 헤더(컬럼명)도 편집 가능
|
2026-01-08 12:14:04 +09:00
|
|
|
|
* - 다중 셀 선택 (드래그)
|
|
|
|
|
|
* - 자동 채우기 (드래그 핸들) - 다중 셀 지원
|
2026-01-08 12:04:31 +09:00
|
|
|
|
*/
|
|
|
|
|
|
export const EditableSpreadsheet: React.FC<EditableSpreadsheetProps> = ({
|
|
|
|
|
|
columns,
|
|
|
|
|
|
data,
|
|
|
|
|
|
onColumnsChange,
|
|
|
|
|
|
onDataChange,
|
|
|
|
|
|
maxHeight = "350px",
|
|
|
|
|
|
}) => {
|
|
|
|
|
|
// 현재 편집 중인 셀 (row: -1은 헤더)
|
|
|
|
|
|
const [editingCell, setEditingCell] = useState<{
|
|
|
|
|
|
row: number;
|
|
|
|
|
|
col: number;
|
|
|
|
|
|
} | null>(null);
|
|
|
|
|
|
const [editValue, setEditValue] = useState<string>("");
|
|
|
|
|
|
|
2026-01-08 12:14:04 +09:00
|
|
|
|
// 선택 범위 (다중 셀 선택)
|
|
|
|
|
|
const [selection, setSelection] = useState<CellRange | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
// 셀 선택 드래그 중
|
|
|
|
|
|
const [isDraggingSelection, setIsDraggingSelection] = useState(false);
|
2026-01-08 12:04:31 +09:00
|
|
|
|
|
|
|
|
|
|
// 자동 채우기 드래그 상태
|
|
|
|
|
|
const [isDraggingFill, setIsDraggingFill] = useState(false);
|
|
|
|
|
|
const [fillPreviewEnd, setFillPreviewEnd] = useState<number | null>(null);
|
|
|
|
|
|
|
2026-01-08 12:14:04 +09:00
|
|
|
|
// 복사된 범위 (점선 애니메이션 표시용)
|
|
|
|
|
|
const [copiedRange, setCopiedRange] = useState<CellRange | null>(null);
|
|
|
|
|
|
|
2026-01-08 12:28:48 +09:00
|
|
|
|
// Undo/Redo 히스토리
|
|
|
|
|
|
interface HistoryState {
|
|
|
|
|
|
columns: string[];
|
|
|
|
|
|
data: Record<string, any>[];
|
|
|
|
|
|
}
|
|
|
|
|
|
const [history, setHistory] = useState<HistoryState[]>([]);
|
|
|
|
|
|
const [historyIndex, setHistoryIndex] = useState(-1);
|
|
|
|
|
|
const [isUndoRedo, setIsUndoRedo] = useState(false);
|
|
|
|
|
|
|
2026-01-08 12:04:31 +09:00
|
|
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
|
|
const tableRef = useRef<HTMLDivElement>(null);
|
2026-01-08 12:28:48 +09:00
|
|
|
|
|
|
|
|
|
|
// 히스토리에 현재 상태 저장
|
|
|
|
|
|
const saveToHistory = useCallback(() => {
|
|
|
|
|
|
if (isUndoRedo) return;
|
|
|
|
|
|
|
|
|
|
|
|
const newState: HistoryState = {
|
|
|
|
|
|
columns: [...columns],
|
|
|
|
|
|
data: data.map(row => ({ ...row })),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
setHistory(prev => {
|
|
|
|
|
|
// 현재 인덱스 이후의 히스토리는 삭제 (새로운 분기)
|
|
|
|
|
|
const newHistory = prev.slice(0, historyIndex + 1);
|
|
|
|
|
|
newHistory.push(newState);
|
|
|
|
|
|
// 최대 50개까지만 유지
|
|
|
|
|
|
if (newHistory.length > 50) {
|
|
|
|
|
|
newHistory.shift();
|
|
|
|
|
|
return newHistory;
|
|
|
|
|
|
}
|
|
|
|
|
|
return newHistory;
|
|
|
|
|
|
});
|
|
|
|
|
|
setHistoryIndex(prev => Math.min(prev + 1, 49));
|
|
|
|
|
|
}, [columns, data, historyIndex, isUndoRedo]);
|
|
|
|
|
|
|
|
|
|
|
|
// 초기 상태 저장
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (history.length === 0 && (columns.length > 0 || data.length > 0)) {
|
|
|
|
|
|
setHistory([{ columns: [...columns], data: data.map(row => ({ ...row })) }]);
|
|
|
|
|
|
setHistoryIndex(0);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
// 데이터 변경 시 히스토리 저장 (Undo/Redo가 아닌 경우)
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!isUndoRedo && historyIndex >= 0) {
|
|
|
|
|
|
const currentState = history[historyIndex];
|
|
|
|
|
|
if (currentState) {
|
|
|
|
|
|
const columnsChanged = JSON.stringify(columns) !== JSON.stringify(currentState.columns);
|
|
|
|
|
|
const dataChanged = JSON.stringify(data) !== JSON.stringify(currentState.data);
|
|
|
|
|
|
if (columnsChanged || dataChanged) {
|
|
|
|
|
|
saveToHistory();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
setIsUndoRedo(false);
|
|
|
|
|
|
}, [columns, data]);
|
|
|
|
|
|
|
|
|
|
|
|
// Undo 실행
|
|
|
|
|
|
const handleUndo = useCallback(() => {
|
|
|
|
|
|
if (historyIndex <= 0) return;
|
|
|
|
|
|
|
|
|
|
|
|
const prevIndex = historyIndex - 1;
|
|
|
|
|
|
const prevState = history[prevIndex];
|
|
|
|
|
|
if (prevState) {
|
|
|
|
|
|
setIsUndoRedo(true);
|
|
|
|
|
|
setHistoryIndex(prevIndex);
|
|
|
|
|
|
onColumnsChange([...prevState.columns]);
|
|
|
|
|
|
onDataChange(prevState.data.map(row => ({ ...row })));
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [history, historyIndex, onColumnsChange, onDataChange]);
|
|
|
|
|
|
|
|
|
|
|
|
// Redo 실행
|
|
|
|
|
|
const handleRedo = useCallback(() => {
|
|
|
|
|
|
if (historyIndex >= history.length - 1) return;
|
|
|
|
|
|
|
|
|
|
|
|
const nextIndex = historyIndex + 1;
|
|
|
|
|
|
const nextState = history[nextIndex];
|
|
|
|
|
|
if (nextState) {
|
|
|
|
|
|
setIsUndoRedo(true);
|
|
|
|
|
|
setHistoryIndex(nextIndex);
|
|
|
|
|
|
onColumnsChange([...nextState.columns]);
|
|
|
|
|
|
onDataChange(nextState.data.map(row => ({ ...row })));
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [history, historyIndex, onColumnsChange, onDataChange]);
|
2026-01-08 12:04:31 +09:00
|
|
|
|
|
2026-01-08 12:14:04 +09:00
|
|
|
|
// 범위 정규화 (시작이 끝보다 크면 교환)
|
|
|
|
|
|
const normalizeRange = (range: CellRange): CellRange => {
|
|
|
|
|
|
return {
|
|
|
|
|
|
startRow: Math.min(range.startRow, range.endRow),
|
|
|
|
|
|
startCol: Math.min(range.startCol, range.endCol),
|
|
|
|
|
|
endRow: Math.max(range.startRow, range.endRow),
|
|
|
|
|
|
endCol: Math.max(range.startCol, range.endCol),
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 셀이 선택 범위 내에 있는지 확인
|
|
|
|
|
|
const isCellInSelection = (row: number, col: number): boolean => {
|
|
|
|
|
|
if (!selection) return false;
|
|
|
|
|
|
const norm = normalizeRange(selection);
|
|
|
|
|
|
return (
|
|
|
|
|
|
row >= norm.startRow &&
|
|
|
|
|
|
row <= norm.endRow &&
|
|
|
|
|
|
col >= norm.startCol &&
|
|
|
|
|
|
col <= norm.endCol
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 셀이 선택 범위의 끝(우하단)인지 확인
|
|
|
|
|
|
const isCellSelectionEnd = (row: number, col: number): boolean => {
|
|
|
|
|
|
if (!selection) return false;
|
|
|
|
|
|
const norm = normalizeRange(selection);
|
|
|
|
|
|
return row === norm.endRow && col === norm.endCol;
|
|
|
|
|
|
};
|
2026-01-08 12:04:31 +09:00
|
|
|
|
|
2026-01-08 12:14:04 +09:00
|
|
|
|
// 셀 선택 시작 (클릭)
|
|
|
|
|
|
const handleCellMouseDown = useCallback((row: number, col: number, e: React.MouseEvent) => {
|
|
|
|
|
|
// 편집 중이면 종료
|
|
|
|
|
|
if (editingCell) {
|
|
|
|
|
|
setEditingCell(null);
|
|
|
|
|
|
setEditValue("");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 새 선택 시작
|
|
|
|
|
|
setSelection({
|
|
|
|
|
|
startRow: row,
|
|
|
|
|
|
startCol: col,
|
|
|
|
|
|
endRow: row,
|
|
|
|
|
|
endCol: col,
|
|
|
|
|
|
});
|
|
|
|
|
|
setIsDraggingSelection(true);
|
|
|
|
|
|
|
|
|
|
|
|
// 테이블에 포커스 (키보드 이벤트 수신용)
|
|
|
|
|
|
tableRef.current?.focus();
|
|
|
|
|
|
}, [editingCell]);
|
|
|
|
|
|
|
|
|
|
|
|
// 셀 선택 드래그 중
|
|
|
|
|
|
const handleCellMouseEnter = useCallback((row: number, col: number) => {
|
|
|
|
|
|
if (isDraggingSelection && selection) {
|
|
|
|
|
|
setSelection((prev) => prev ? {
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
endRow: row,
|
|
|
|
|
|
endCol: col,
|
|
|
|
|
|
} : null);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [isDraggingSelection, selection]);
|
|
|
|
|
|
|
|
|
|
|
|
// 셀 선택 드래그 종료
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const handleMouseUp = () => {
|
|
|
|
|
|
if (isDraggingSelection) {
|
|
|
|
|
|
setIsDraggingSelection(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
document.addEventListener("mouseup", handleMouseUp);
|
|
|
|
|
|
return () => document.removeEventListener("mouseup", handleMouseUp);
|
|
|
|
|
|
}, [isDraggingSelection]);
|
|
|
|
|
|
|
|
|
|
|
|
// 셀 편집 시작 (더블클릭)
|
2026-01-08 12:04:31 +09:00
|
|
|
|
const startEditing = useCallback(
|
|
|
|
|
|
(row: number, col: number) => {
|
|
|
|
|
|
setEditingCell({ row, col });
|
2026-01-08 12:14:04 +09:00
|
|
|
|
setSelection({
|
|
|
|
|
|
startRow: row,
|
|
|
|
|
|
startCol: col,
|
|
|
|
|
|
endRow: row,
|
|
|
|
|
|
endCol: col,
|
|
|
|
|
|
});
|
2026-01-08 12:04:31 +09:00
|
|
|
|
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<string, any> = {};
|
|
|
|
|
|
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<string, any> = {};
|
|
|
|
|
|
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<HTMLInputElement>) => {
|
|
|
|
|
|
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();
|
2026-01-08 12:14:04 +09:00
|
|
|
|
setSelection(null);
|
2026-01-08 12:04:31 +09:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
|
|
|
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
|
|
|
|
}, [finishEditing]);
|
|
|
|
|
|
|
2026-01-08 12:14:04 +09:00
|
|
|
|
// ============ 복사/붙여넣기 ============
|
|
|
|
|
|
|
|
|
|
|
|
// 셀이 복사 범위 내에 있는지 확인
|
|
|
|
|
|
const isCellInCopiedRange = (row: number, col: number): boolean => {
|
|
|
|
|
|
if (!copiedRange) return false;
|
|
|
|
|
|
const norm = normalizeRange(copiedRange);
|
|
|
|
|
|
return (
|
|
|
|
|
|
row >= norm.startRow &&
|
|
|
|
|
|
row <= norm.endRow &&
|
|
|
|
|
|
col >= norm.startCol &&
|
|
|
|
|
|
col <= norm.endCol
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 복사 범위의 테두리 위치 확인
|
|
|
|
|
|
const getCopiedBorderPosition = (row: number, col: number): { top: boolean; right: boolean; bottom: boolean; left: boolean } => {
|
|
|
|
|
|
if (!copiedRange) return { top: false, right: false, bottom: false, left: false };
|
|
|
|
|
|
const norm = normalizeRange(copiedRange);
|
|
|
|
|
|
|
|
|
|
|
|
if (!isCellInCopiedRange(row, col)) {
|
|
|
|
|
|
return { top: false, right: false, bottom: false, left: false };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
top: row === norm.startRow,
|
|
|
|
|
|
right: col === norm.endCol,
|
|
|
|
|
|
bottom: row === norm.endRow,
|
|
|
|
|
|
left: col === norm.startCol,
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 선택 범위 복사 (Ctrl+C)
|
|
|
|
|
|
const handleCopy = useCallback(async () => {
|
|
|
|
|
|
if (!selection || editingCell) return;
|
|
|
|
|
|
|
|
|
|
|
|
const norm = normalizeRange(selection);
|
|
|
|
|
|
const rows: string[] = [];
|
|
|
|
|
|
|
|
|
|
|
|
for (let r = norm.startRow; r <= norm.endRow; r++) {
|
|
|
|
|
|
const rowValues: string[] = [];
|
|
|
|
|
|
for (let c = norm.startCol; c <= norm.endCol; c++) {
|
|
|
|
|
|
if (r === -1) {
|
|
|
|
|
|
// 헤더 복사
|
|
|
|
|
|
rowValues.push(columns[c] || "");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 데이터 복사
|
|
|
|
|
|
const colName = columns[c];
|
|
|
|
|
|
rowValues.push(String(data[r]?.[colName] ?? ""));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
rows.push(rowValues.join("\t"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const text = rows.join("\n");
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await navigator.clipboard.writeText(text);
|
|
|
|
|
|
// 복사 범위 저장 (점선 애니메이션 표시)
|
|
|
|
|
|
setCopiedRange({ ...norm });
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.warn("클립보드 복사 실패:", err);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [selection, editingCell, columns, data]);
|
|
|
|
|
|
|
|
|
|
|
|
// 붙여넣기 (Ctrl+V)
|
|
|
|
|
|
const handlePaste = useCallback(async () => {
|
|
|
|
|
|
if (!selection || editingCell) return;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const text = await navigator.clipboard.readText();
|
|
|
|
|
|
if (!text) return;
|
|
|
|
|
|
|
|
|
|
|
|
const norm = normalizeRange(selection);
|
|
|
|
|
|
const pasteRows = text.split(/\r?\n/).map((row) => row.split("\t"));
|
|
|
|
|
|
|
|
|
|
|
|
// 빈 행 제거
|
|
|
|
|
|
const filteredRows = pasteRows.filter((row) => row.some((cell) => cell.trim() !== ""));
|
|
|
|
|
|
if (filteredRows.length === 0) return;
|
|
|
|
|
|
|
|
|
|
|
|
const newData = [...data];
|
|
|
|
|
|
const newColumns = [...columns];
|
|
|
|
|
|
let columnsChanged = false;
|
|
|
|
|
|
|
|
|
|
|
|
for (let ri = 0; ri < filteredRows.length; ri++) {
|
|
|
|
|
|
const pasteRow = filteredRows[ri];
|
|
|
|
|
|
const targetRow = norm.startRow + ri;
|
|
|
|
|
|
|
|
|
|
|
|
for (let ci = 0; ci < pasteRow.length; ci++) {
|
|
|
|
|
|
const targetCol = norm.startCol + ci;
|
|
|
|
|
|
const value = pasteRow[ci];
|
|
|
|
|
|
|
|
|
|
|
|
if (targetRow === -1) {
|
|
|
|
|
|
// 헤더에 붙여넣기
|
|
|
|
|
|
if (targetCol < newColumns.length) {
|
|
|
|
|
|
newColumns[targetCol] = value;
|
|
|
|
|
|
columnsChanged = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 데이터에 붙여넣기
|
|
|
|
|
|
if (targetCol < columns.length) {
|
|
|
|
|
|
// 필요시 행 추가
|
|
|
|
|
|
while (newData.length <= targetRow) {
|
|
|
|
|
|
const emptyRow: Record<string, any> = {};
|
|
|
|
|
|
columns.forEach((c) => {
|
|
|
|
|
|
emptyRow[c] = "";
|
|
|
|
|
|
});
|
|
|
|
|
|
newData.push(emptyRow);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const colName = columns[targetCol];
|
|
|
|
|
|
newData[targetRow] = {
|
|
|
|
|
|
...newData[targetRow],
|
|
|
|
|
|
[colName]: value,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (columnsChanged) {
|
|
|
|
|
|
onColumnsChange(newColumns);
|
|
|
|
|
|
}
|
|
|
|
|
|
onDataChange(newData);
|
|
|
|
|
|
|
|
|
|
|
|
// 붙여넣기 범위로 선택 확장
|
|
|
|
|
|
setSelection({
|
|
|
|
|
|
startRow: norm.startRow,
|
|
|
|
|
|
startCol: norm.startCol,
|
|
|
|
|
|
endRow: Math.min(norm.startRow + filteredRows.length - 1, data.length - 1),
|
|
|
|
|
|
endCol: Math.min(norm.startCol + (filteredRows[0]?.length || 1) - 1, columns.length - 1),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 붙여넣기 후 복사 범위 초기화
|
|
|
|
|
|
setCopiedRange(null);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.warn("클립보드 붙여넣기 실패:", err);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [selection, editingCell, columns, data, onColumnsChange, onDataChange]);
|
|
|
|
|
|
|
|
|
|
|
|
// Delete 키로 선택 범위 삭제
|
|
|
|
|
|
const handleDelete = useCallback(() => {
|
|
|
|
|
|
if (!selection || editingCell) return;
|
|
|
|
|
|
|
|
|
|
|
|
const norm = normalizeRange(selection);
|
|
|
|
|
|
const newData = [...data];
|
|
|
|
|
|
|
|
|
|
|
|
for (let r = norm.startRow; r <= norm.endRow; r++) {
|
|
|
|
|
|
if (r >= 0 && r < newData.length) {
|
|
|
|
|
|
for (let c = norm.startCol; c <= norm.endCol; c++) {
|
|
|
|
|
|
if (c < columns.length) {
|
|
|
|
|
|
const colName = columns[c];
|
|
|
|
|
|
newData[r] = {
|
|
|
|
|
|
...newData[r],
|
|
|
|
|
|
[colName]: "",
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
onDataChange(newData);
|
|
|
|
|
|
}, [selection, editingCell, columns, data, onDataChange]);
|
|
|
|
|
|
|
|
|
|
|
|
// 전역 키보드 이벤트 (복사/붙여넣기/삭제)
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const handleGlobalKeyDown = (e: KeyboardEvent) => {
|
|
|
|
|
|
// 편집 중이면 무시 (input에서 자체 처리)
|
|
|
|
|
|
if (editingCell) return;
|
|
|
|
|
|
|
|
|
|
|
|
// 선택이 없으면 무시
|
|
|
|
|
|
if (!selection) return;
|
|
|
|
|
|
|
|
|
|
|
|
// 다른 입력 필드에 포커스가 있으면 무시
|
|
|
|
|
|
const activeElement = document.activeElement;
|
|
|
|
|
|
const isInputFocused = activeElement instanceof HTMLInputElement ||
|
|
|
|
|
|
activeElement instanceof HTMLTextAreaElement ||
|
|
|
|
|
|
activeElement instanceof HTMLSelectElement;
|
|
|
|
|
|
|
|
|
|
|
|
// 테이블 내부의 input이 아닌 다른 input에 포커스가 있으면 무시
|
|
|
|
|
|
if (isInputFocused && !tableRef.current?.contains(activeElement)) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-08 12:28:48 +09:00
|
|
|
|
if ((e.ctrlKey || e.metaKey) && e.key === "z") {
|
|
|
|
|
|
// Ctrl+Z: Undo
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
handleUndo();
|
|
|
|
|
|
} else if ((e.ctrlKey || e.metaKey) && e.key === "y") {
|
|
|
|
|
|
// Ctrl+Y: Redo
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
handleRedo();
|
|
|
|
|
|
} else if ((e.ctrlKey || e.metaKey) && e.key === "c") {
|
2026-01-08 12:14:04 +09:00
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
handleCopy();
|
|
|
|
|
|
} else if ((e.ctrlKey || e.metaKey) && e.key === "v") {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
handlePaste();
|
|
|
|
|
|
} else if (e.key === "Delete" || e.key === "Backspace") {
|
|
|
|
|
|
// 다른 곳에 포커스가 있으면 Delete 무시
|
|
|
|
|
|
if (isInputFocused) return;
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
handleDelete();
|
2026-01-08 12:23:00 +09:00
|
|
|
|
} else if (e.key === "Escape") {
|
|
|
|
|
|
// Esc로 복사 범위 표시 취소
|
|
|
|
|
|
setCopiedRange(null);
|
2026-01-08 12:25:52 +09:00
|
|
|
|
} else if (e.key === "F2") {
|
|
|
|
|
|
// F2로 편집 모드 진입 (기존 값 유지)
|
|
|
|
|
|
const norm = normalizeRange(selection);
|
|
|
|
|
|
if (norm.startRow >= 0 && norm.startRow === norm.endRow && norm.startCol === norm.endCol) {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
const colName = columns[norm.startCol];
|
|
|
|
|
|
setEditingCell({ row: norm.startRow, col: norm.startCol });
|
|
|
|
|
|
setEditValue(String(data[norm.startRow]?.[colName] ?? ""));
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
|
|
|
|
|
// 일반 문자 키 입력 시 편집 모드 진입 (엑셀처럼)
|
|
|
|
|
|
const norm = normalizeRange(selection);
|
|
|
|
|
|
if (norm.startRow >= 0 && norm.startRow === norm.endRow && norm.startCol === norm.endCol) {
|
|
|
|
|
|
// 단일 셀 선택 시에만
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
setEditingCell({ row: norm.startRow, col: norm.startCol });
|
|
|
|
|
|
setEditValue(e.key); // 입력한 문자로 시작
|
|
|
|
|
|
}
|
2026-01-08 12:14:04 +09:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
document.addEventListener("keydown", handleGlobalKeyDown);
|
|
|
|
|
|
return () => document.removeEventListener("keydown", handleGlobalKeyDown);
|
2026-01-08 12:28:48 +09:00
|
|
|
|
}, [editingCell, selection, handleCopy, handlePaste, handleDelete, handleUndo, handleRedo]);
|
2026-01-08 12:14:04 +09:00
|
|
|
|
|
2026-01-08 12:04:31 +09:00
|
|
|
|
// 행 삭제
|
|
|
|
|
|
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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// ============ 자동 채우기 로직 ============
|
|
|
|
|
|
|
2026-01-08 12:14:04 +09:00
|
|
|
|
// 값에서 마지막 숫자 패턴 추출
|
2026-01-08 12:04:31 +09:00
|
|
|
|
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
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 => {
|
|
|
|
|
|
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) {
|
|
|
|
|
|
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();
|
|
|
|
|
|
|
2026-01-08 12:25:52 +09:00
|
|
|
|
// 편집 중이면 먼저 현재 편집 값을 저장
|
|
|
|
|
|
if (editingCell) {
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 데이터 셀 변경
|
|
|
|
|
|
const colName = columns[col];
|
|
|
|
|
|
const newData = [...data];
|
|
|
|
|
|
if (!newData[row]) {
|
|
|
|
|
|
newData[row] = {};
|
|
|
|
|
|
}
|
|
|
|
|
|
newData[row] = { ...newData[row], [colName]: editValue };
|
|
|
|
|
|
onDataChange(newData);
|
|
|
|
|
|
}
|
|
|
|
|
|
setEditingCell(null);
|
|
|
|
|
|
setEditValue("");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-08 12:14:04 +09:00
|
|
|
|
if (!selection) return;
|
|
|
|
|
|
const norm = normalizeRange(selection);
|
|
|
|
|
|
if (norm.startRow < 0) return; // 헤더는 제외
|
2026-01-08 12:04:31 +09:00
|
|
|
|
|
|
|
|
|
|
setIsDraggingFill(true);
|
2026-01-08 12:14:04 +09:00
|
|
|
|
setFillPreviewEnd(norm.endRow);
|
2026-01-08 12:04:31 +09:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 자동 채우기 드래그 중
|
|
|
|
|
|
const handleFillDragMove = useCallback((e: MouseEvent) => {
|
2026-01-08 12:14:04 +09:00
|
|
|
|
if (!isDraggingFill || !selection || !tableRef.current) return;
|
2026-01-08 12:04:31 +09:00
|
|
|
|
|
|
|
|
|
|
const rows = tableRef.current.querySelectorAll("tbody tr");
|
|
|
|
|
|
const mouseY = e.clientY;
|
|
|
|
|
|
|
2026-01-08 12:14:04 +09:00
|
|
|
|
for (let i = 0; i < rows.length - 1; i++) {
|
2026-01-08 12:04:31 +09:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-08 12:14:04 +09:00
|
|
|
|
}, [isDraggingFill, selection]);
|
2026-01-08 12:04:31 +09:00
|
|
|
|
|
2026-01-08 12:32:50 +09:00
|
|
|
|
// 열의 숫자 패턴 간격 계산 (예: 201, 202 → 간격 1)
|
|
|
|
|
|
const calculateColumnIncrement = (colIndex: number, startRow: number, endRow: number): number | null => {
|
|
|
|
|
|
if (startRow === endRow) return 1; // 단일 행이면 기본 증가 1
|
|
|
|
|
|
|
|
|
|
|
|
const colName = columns[colIndex];
|
|
|
|
|
|
const increments: number[] = [];
|
|
|
|
|
|
|
|
|
|
|
|
for (let row = startRow; row < endRow; row++) {
|
|
|
|
|
|
const currentValue = String(data[row]?.[colName] ?? "");
|
|
|
|
|
|
const nextValue = String(data[row + 1]?.[colName] ?? "");
|
|
|
|
|
|
|
|
|
|
|
|
const currentPattern = extractNumberPattern(currentValue);
|
|
|
|
|
|
const nextPattern = extractNumberPattern(nextValue);
|
|
|
|
|
|
|
|
|
|
|
|
if (currentPattern && nextPattern) {
|
|
|
|
|
|
// 접두사와 접미사가 같은지 확인
|
|
|
|
|
|
if (currentPattern.prefix === nextPattern.prefix && currentPattern.suffix === nextPattern.suffix) {
|
|
|
|
|
|
increments.push(nextPattern.number - currentPattern.number);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
return null; // 패턴이 다르면 복사 모드
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
return null; // 숫자 패턴이 없으면 복사 모드
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 모든 간격이 같은지 확인
|
|
|
|
|
|
if (increments.length > 0 && increments.every(inc => inc === increments[0])) {
|
|
|
|
|
|
return increments[0];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-08 12:14:04 +09:00
|
|
|
|
// 자동 채우기 드래그 종료 (다중 셀 지원)
|
2026-01-08 12:32:50 +09:00
|
|
|
|
// - 숫자 패턴이 있으면: 패턴 간격을 인식하여 증가 (201, 202 → 203, 204)
|
|
|
|
|
|
// - 숫자 패턴이 없으면: 선택된 패턴 그대로 반복 (복사)
|
2026-01-08 12:04:31 +09:00
|
|
|
|
const handleFillDragEnd = useCallback(() => {
|
2026-01-08 12:14:04 +09:00
|
|
|
|
if (!isDraggingFill || !selection || fillPreviewEnd === null) {
|
2026-01-08 12:04:31 +09:00
|
|
|
|
setIsDraggingFill(false);
|
|
|
|
|
|
setFillPreviewEnd(null);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-08 12:14:04 +09:00
|
|
|
|
const norm = normalizeRange(selection);
|
2026-01-08 12:04:31 +09:00
|
|
|
|
const endRow = fillPreviewEnd;
|
2026-01-08 12:14:04 +09:00
|
|
|
|
const selectionHeight = norm.endRow - norm.startRow + 1;
|
2026-01-08 12:04:31 +09:00
|
|
|
|
|
2026-01-08 12:14:04 +09:00
|
|
|
|
if (endRow !== norm.endRow && norm.startRow >= 0) {
|
2026-01-08 12:04:31 +09:00
|
|
|
|
const newData = [...data];
|
2026-01-08 12:32:50 +09:00
|
|
|
|
|
|
|
|
|
|
// 각 열별로 증가 패턴 계산
|
|
|
|
|
|
const columnIncrements: Map<number, number | null> = new Map();
|
|
|
|
|
|
for (let col = norm.startCol; col <= norm.endCol; col++) {
|
|
|
|
|
|
columnIncrements.set(col, calculateColumnIncrement(col, norm.startRow, norm.endRow));
|
|
|
|
|
|
}
|
2026-01-08 12:04:31 +09:00
|
|
|
|
|
2026-01-08 12:14:04 +09:00
|
|
|
|
if (endRow > norm.endRow) {
|
2026-01-08 12:04:31 +09:00
|
|
|
|
// 아래로 채우기
|
2026-01-08 12:14:04 +09:00
|
|
|
|
for (let targetRow = norm.endRow + 1; targetRow <= endRow; targetRow++) {
|
|
|
|
|
|
if (!newData[targetRow]) {
|
|
|
|
|
|
newData[targetRow] = {};
|
2026-01-08 12:04:31 +09:00
|
|
|
|
columns.forEach((c) => {
|
2026-01-08 12:14:04 +09:00
|
|
|
|
newData[targetRow][c] = "";
|
2026-01-08 12:04:31 +09:00
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-01-08 12:14:04 +09:00
|
|
|
|
|
|
|
|
|
|
// 선택된 모든 열에 대해 채우기
|
|
|
|
|
|
for (let col = norm.startCol; col <= norm.endCol; col++) {
|
|
|
|
|
|
const colName = columns[col];
|
2026-01-08 12:32:50 +09:00
|
|
|
|
const increment = columnIncrements.get(col);
|
|
|
|
|
|
|
|
|
|
|
|
if (increment !== null) {
|
|
|
|
|
|
// 숫자 패턴 증가 모드
|
|
|
|
|
|
// 마지막 선택 행의 값을 기준으로 증가
|
|
|
|
|
|
const lastValue = String(data[norm.endRow]?.[colName] ?? "");
|
|
|
|
|
|
const step = (targetRow - norm.endRow) * increment;
|
|
|
|
|
|
newData[targetRow] = {
|
|
|
|
|
|
...newData[targetRow],
|
|
|
|
|
|
[colName]: generateNextValue(lastValue, step),
|
|
|
|
|
|
};
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 복사 모드 (패턴 반복)
|
|
|
|
|
|
const sourceRowOffset = (targetRow - norm.endRow - 1) % selectionHeight;
|
|
|
|
|
|
const sourceRow = norm.startRow + sourceRowOffset;
|
|
|
|
|
|
const sourceValue = String(data[sourceRow]?.[colName] ?? "");
|
|
|
|
|
|
newData[targetRow] = {
|
|
|
|
|
|
...newData[targetRow],
|
|
|
|
|
|
[colName]: sourceValue,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-01-08 12:14:04 +09:00
|
|
|
|
}
|
2026-01-08 12:04:31 +09:00
|
|
|
|
}
|
2026-01-08 12:14:04 +09:00
|
|
|
|
} else if (endRow < norm.startRow) {
|
2026-01-08 12:04:31 +09:00
|
|
|
|
// 위로 채우기
|
2026-01-08 12:14:04 +09:00
|
|
|
|
for (let targetRow = norm.startRow - 1; targetRow >= endRow; targetRow--) {
|
|
|
|
|
|
if (!newData[targetRow]) {
|
|
|
|
|
|
newData[targetRow] = {};
|
2026-01-08 12:04:31 +09:00
|
|
|
|
columns.forEach((c) => {
|
2026-01-08 12:14:04 +09:00
|
|
|
|
newData[targetRow][c] = "";
|
2026-01-08 12:04:31 +09:00
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-01-08 12:14:04 +09:00
|
|
|
|
|
|
|
|
|
|
for (let col = norm.startCol; col <= norm.endCol; col++) {
|
|
|
|
|
|
const colName = columns[col];
|
2026-01-08 12:32:50 +09:00
|
|
|
|
const increment = columnIncrements.get(col);
|
|
|
|
|
|
|
|
|
|
|
|
if (increment !== null) {
|
|
|
|
|
|
// 숫자 패턴 감소 모드
|
|
|
|
|
|
const firstValue = String(data[norm.startRow]?.[colName] ?? "");
|
|
|
|
|
|
const step = (targetRow - norm.startRow) * increment;
|
|
|
|
|
|
newData[targetRow] = {
|
|
|
|
|
|
...newData[targetRow],
|
|
|
|
|
|
[colName]: generateNextValue(firstValue, step),
|
|
|
|
|
|
};
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 복사 모드 (패턴 반복)
|
|
|
|
|
|
const sourceRowOffset = (norm.startRow - targetRow - 1) % selectionHeight;
|
|
|
|
|
|
const sourceRow = norm.endRow - sourceRowOffset;
|
|
|
|
|
|
const sourceValue = String(data[sourceRow]?.[colName] ?? "");
|
|
|
|
|
|
newData[targetRow] = {
|
|
|
|
|
|
...newData[targetRow],
|
|
|
|
|
|
[colName]: sourceValue,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-01-08 12:14:04 +09:00
|
|
|
|
}
|
2026-01-08 12:04:31 +09:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
onDataChange(newData);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setIsDraggingFill(false);
|
|
|
|
|
|
setFillPreviewEnd(null);
|
2026-01-08 12:14:04 +09:00
|
|
|
|
}, [isDraggingFill, selection, fillPreviewEnd, columns, data, onDataChange]);
|
2026-01-08 12:04:31 +09:00
|
|
|
|
|
|
|
|
|
|
// 드래그 이벤트 리스너
|
|
|
|
|
|
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 => {
|
2026-01-08 12:14:04 +09:00
|
|
|
|
if (!isDraggingFill || !selection || fillPreviewEnd === null) return false;
|
|
|
|
|
|
|
|
|
|
|
|
const norm = normalizeRange(selection);
|
|
|
|
|
|
|
|
|
|
|
|
// 열이 선택 범위 내에 있어야 함
|
|
|
|
|
|
if (colIndex < norm.startCol || colIndex > norm.endCol) return false;
|
2026-01-08 12:04:31 +09:00
|
|
|
|
|
2026-01-08 12:14:04 +09:00
|
|
|
|
if (fillPreviewEnd > norm.endRow) {
|
|
|
|
|
|
return rowIndex > norm.endRow && rowIndex <= fillPreviewEnd;
|
|
|
|
|
|
} else if (fillPreviewEnd < norm.startRow) {
|
|
|
|
|
|
return rowIndex >= fillPreviewEnd && rowIndex < norm.startRow;
|
2026-01-08 12:04:31 +09:00
|
|
|
|
}
|
2026-01-08 12:14:04 +09:00
|
|
|
|
|
|
|
|
|
|
return false;
|
2026-01-08 12:04:31 +09:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
ref={tableRef}
|
2026-01-08 12:14:04 +09:00
|
|
|
|
tabIndex={0}
|
|
|
|
|
|
className="overflow-auto rounded-md border border-border select-none outline-none focus:ring-2 focus:ring-primary/20"
|
2026-01-08 12:04:31 +09:00
|
|
|
|
style={{ maxHeight }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<table className="min-w-full border-collapse text-xs">
|
|
|
|
|
|
{/* 열 인덱스 헤더 (A, B, C, ...) */}
|
|
|
|
|
|
<thead className="sticky top-0 z-10 bg-muted">
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
{/* 빈 코너 셀 */}
|
|
|
|
|
|
<th className="sticky left-0 z-20 w-10 border-b border-r border-border bg-muted px-1 py-1 text-center text-[10px] text-muted-foreground">
|
|
|
|
|
|
|
|
|
|
|
|
</th>
|
|
|
|
|
|
{columns.map((_, colIndex) => (
|
|
|
|
|
|
<th
|
|
|
|
|
|
key={colIndex}
|
|
|
|
|
|
className="min-w-[100px] border-b border-r border-border bg-muted px-2 py-1 text-center font-normal text-muted-foreground"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex items-center justify-center gap-1">
|
|
|
|
|
|
<span>{getColumnLetter(colIndex)}</span>
|
|
|
|
|
|
{columns.length > 1 && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => handleDeleteColumn(colIndex)}
|
|
|
|
|
|
className="ml-1 text-muted-foreground/50 hover:text-destructive"
|
|
|
|
|
|
title="열 삭제"
|
|
|
|
|
|
>
|
|
|
|
|
|
×
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</th>
|
|
|
|
|
|
))}
|
|
|
|
|
|
{/* 새 열 추가 버튼 */}
|
|
|
|
|
|
<th className="w-8 border-b border-border bg-muted px-1 py-1">
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
onColumnsChange([...columns, ""]);
|
|
|
|
|
|
const tempColId = `__temp_${Date.now()}`;
|
|
|
|
|
|
const newData = data.map((row) => ({ ...row, [tempColId]: "" }));
|
|
|
|
|
|
onDataChange(newData);
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="text-muted-foreground/50 hover:text-primary"
|
|
|
|
|
|
title="열 추가"
|
|
|
|
|
|
>
|
|
|
|
|
|
+
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 컬럼명 헤더 (편집 가능) */}
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th className="sticky left-0 z-20 w-10 border-b border-r border-border bg-primary/10 px-1 py-1 text-center font-medium">
|
|
|
|
|
|
1
|
|
|
|
|
|
</th>
|
|
|
|
|
|
{columns.map((colName, colIndex) => (
|
|
|
|
|
|
<th
|
|
|
|
|
|
key={colIndex}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"min-w-[100px] cursor-pointer border-b border-r border-border bg-primary/5 px-0 py-0 font-medium text-primary",
|
|
|
|
|
|
(editingCell?.row === -1 && editingCell?.col === colIndex) ||
|
2026-01-08 12:14:04 +09:00
|
|
|
|
isCellInSelection(-1, colIndex)
|
2026-01-08 12:04:31 +09:00
|
|
|
|
? "ring-2 ring-primary ring-inset"
|
|
|
|
|
|
: ""
|
|
|
|
|
|
)}
|
2026-01-08 12:14:04 +09:00
|
|
|
|
onMouseDown={(e) => handleCellMouseDown(-1, colIndex, e)}
|
|
|
|
|
|
onMouseEnter={() => handleCellMouseEnter(-1, colIndex)}
|
|
|
|
|
|
onDoubleClick={() => startEditing(-1, colIndex)}
|
2026-01-08 12:04:31 +09:00
|
|
|
|
>
|
|
|
|
|
|
{editingCell?.row === -1 && editingCell?.col === colIndex ? (
|
|
|
|
|
|
<input
|
|
|
|
|
|
ref={inputRef}
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
value={editValue}
|
|
|
|
|
|
onChange={(e) => 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"
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="px-2 py-1">{colName || <span className="text-muted-foreground/50 italic">빈 헤더</span>}</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</th>
|
|
|
|
|
|
))}
|
|
|
|
|
|
<th className="w-8 border-b border-border bg-primary/5"></th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
{data.map((row, rowIndex) => (
|
|
|
|
|
|
<tr key={rowIndex} className="group">
|
|
|
|
|
|
{/* 행 번호 */}
|
|
|
|
|
|
<td className="sticky left-0 z-10 w-10 border-b border-r border-border bg-muted/50 px-1 py-1 text-center text-muted-foreground">
|
|
|
|
|
|
<div className="flex items-center justify-center gap-0.5">
|
|
|
|
|
|
<span>{rowIndex + 2}</span>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => handleDeleteRow(rowIndex)}
|
|
|
|
|
|
className="hidden text-muted-foreground/50 hover:text-destructive group-hover:inline"
|
|
|
|
|
|
title="행 삭제"
|
|
|
|
|
|
>
|
|
|
|
|
|
×
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 데이터 셀 */}
|
|
|
|
|
|
{columns.map((colName, colIndex) => {
|
2026-01-08 12:14:04 +09:00
|
|
|
|
const isSelected = isCellInSelection(rowIndex, colIndex);
|
2026-01-08 12:04:31 +09:00
|
|
|
|
const isEditing = editingCell?.row === rowIndex && editingCell?.col === colIndex;
|
|
|
|
|
|
const inFillPreview = isInFillPreview(rowIndex, colIndex);
|
2026-01-08 12:14:04 +09:00
|
|
|
|
const isSelectionEnd = isCellSelectionEnd(rowIndex, colIndex);
|
|
|
|
|
|
const copiedBorder = getCopiedBorderPosition(rowIndex, colIndex);
|
|
|
|
|
|
const isCopied = isCellInCopiedRange(rowIndex, colIndex);
|
2026-01-08 12:04:31 +09:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<td
|
|
|
|
|
|
key={colIndex}
|
|
|
|
|
|
className={cn(
|
2026-01-08 12:14:04 +09:00
|
|
|
|
"relative cursor-cell border-b border-r border-border px-0 py-0",
|
|
|
|
|
|
isSelected ? "bg-primary/10" : "",
|
|
|
|
|
|
isEditing ? "ring-2 ring-primary ring-inset" : "",
|
|
|
|
|
|
inFillPreview ? "bg-primary/20" : ""
|
2026-01-08 12:04:31 +09:00
|
|
|
|
)}
|
2026-01-08 12:14:04 +09:00
|
|
|
|
onMouseDown={(e) => handleCellMouseDown(rowIndex, colIndex, e)}
|
|
|
|
|
|
onMouseEnter={() => handleCellMouseEnter(rowIndex, colIndex)}
|
2026-01-08 12:04:31 +09:00
|
|
|
|
onDoubleClick={() => startEditing(rowIndex, colIndex)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{isEditing ? (
|
|
|
|
|
|
<input
|
|
|
|
|
|
ref={inputRef}
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
value={editValue}
|
|
|
|
|
|
onChange={(e) => setEditValue(e.target.value)}
|
|
|
|
|
|
onKeyDown={handleKeyDown}
|
|
|
|
|
|
onBlur={finishEditing}
|
|
|
|
|
|
className="w-full bg-white px-2 py-1 text-xs outline-none"
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="max-w-[200px] truncate px-2 py-1"
|
|
|
|
|
|
title={String(row[colName] ?? "")}
|
|
|
|
|
|
>
|
|
|
|
|
|
{String(row[colName] ?? "")}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-01-08 12:14:04 +09:00
|
|
|
|
{/* 복사 범위 점선 테두리 (Marching Ants) */}
|
|
|
|
|
|
{isCopied && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
{copiedBorder.top && (
|
|
|
|
|
|
<div className="pointer-events-none absolute left-0 right-0 top-0 h-[2px] animate-marching-ants-h" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
{copiedBorder.right && (
|
|
|
|
|
|
<div className="pointer-events-none absolute bottom-0 right-0 top-0 w-[2px] animate-marching-ants-v" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
{copiedBorder.bottom && (
|
|
|
|
|
|
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-[2px] animate-marching-ants-h" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
{copiedBorder.left && (
|
|
|
|
|
|
<div className="pointer-events-none absolute bottom-0 left-0 top-0 w-[2px] animate-marching-ants-v" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-01-08 12:25:52 +09:00
|
|
|
|
{/* 자동 채우기 핸들 - 선택 범위의 우하단에서만 표시 (편집 중에도 표시) */}
|
|
|
|
|
|
{isSelectionEnd && selection && normalizeRange(selection).startRow >= 0 && (
|
2026-01-08 12:04:31 +09:00
|
|
|
|
<div
|
2026-01-08 12:14:04 +09:00
|
|
|
|
className="absolute bottom-0 right-0 z-20 h-2.5 w-2.5 translate-x-1/2 translate-y-1/2 cursor-crosshair border border-white bg-primary"
|
2026-01-08 12:04:31 +09:00
|
|
|
|
onMouseDown={handleFillDragStart}
|
|
|
|
|
|
title="자동 채우기"
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
<td className="w-8 border-b border-border"></td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 새 행 추가 영역 */}
|
|
|
|
|
|
<tr className="bg-muted/20">
|
|
|
|
|
|
<td className="sticky left-0 z-10 w-10 border-r border-border bg-muted/30 px-1 py-1 text-center">
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
const newRow: Record<string, any> = {};
|
|
|
|
|
|
columns.forEach((c) => {
|
|
|
|
|
|
newRow[c] = "";
|
|
|
|
|
|
});
|
|
|
|
|
|
onDataChange([...data, newRow]);
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="text-muted-foreground/50 hover:text-primary"
|
|
|
|
|
|
title="행 추가"
|
|
|
|
|
|
>
|
|
|
|
|
|
+
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td
|
|
|
|
|
|
colSpan={columns.length + 1}
|
|
|
|
|
|
className="cursor-pointer px-2 py-1 text-muted-foreground/50 hover:bg-muted/50"
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
const newRow: Record<string, any> = {};
|
|
|
|
|
|
columns.forEach((c) => {
|
|
|
|
|
|
newRow[c] = "";
|
|
|
|
|
|
});
|
|
|
|
|
|
onDataChange([...data, newRow]);
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
startEditing(data.length, 0);
|
|
|
|
|
|
}, 0);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
클릭하여 새 행 추가...
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|