ERP-node/frontend/components/common/EditableSpreadsheet.tsx

1012 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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;
}
// 셀 범위 정의
interface CellRange {
startRow: number;
startCol: number;
endRow: number;
endCol: number;
}
/**
* 엑셀처럼 편집 가능한 스프레드시트 컴포넌트
* - 셀 클릭으로 편집
* - Tab/Enter로 다음 셀 이동
* - 마지막 행/열에서 자동 추가
* - 헤더(컬럼명)도 편집 가능
* - 다중 셀 선택 (드래그)
* - 자동 채우기 (드래그 핸들) - 다중 셀 지원
*/
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>("");
// 선택 범위 (다중 셀 선택)
const [selection, setSelection] = useState<CellRange | null>(null);
// 셀 선택 드래그 중
const [isDraggingSelection, setIsDraggingSelection] = useState(false);
// 자동 채우기 드래그 상태
const [isDraggingFill, setIsDraggingFill] = useState(false);
const [fillPreviewEnd, setFillPreviewEnd] = useState<number | null>(null);
// 복사된 범위 (점선 애니메이션 표시용)
const [copiedRange, setCopiedRange] = useState<CellRange | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const tableRef = useRef<HTMLDivElement>(null);
// 범위 정규화 (시작이 끝보다 크면 교환)
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;
};
// 셀 선택 시작 (클릭)
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]);
// 셀 편집 시작 (더블클릭)
const startEditing = useCallback(
(row: number, col: number) => {
setEditingCell({ row, col });
setSelection({
startRow: row,
startCol: col,
endRow: row,
endCol: 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<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();
setSelection(null);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [finishEditing]);
// ============ 복사/붙여넣기 ============
// 셀이 복사 범위 내에 있는지 확인
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;
}
if ((e.ctrlKey || e.metaKey) && e.key === "c") {
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();
} else if (e.key === "Escape") {
// Esc로 복사 범위 표시 취소
setCopiedRange(null);
}
};
document.addEventListener("keydown", handleGlobalKeyDown);
return () => document.removeEventListener("keydown", handleGlobalKeyDown);
}, [editingCell, selection, handleCopy, handlePaste, handleDelete]);
// 행 삭제
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;
};
// ============ 자동 채우기 로직 ============
// 값에서 마지막 숫자 패턴 추출
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();
if (!selection) return;
const norm = normalizeRange(selection);
if (norm.startRow < 0) return; // 헤더는 제외
setIsDraggingFill(true);
setFillPreviewEnd(norm.endRow);
};
// 자동 채우기 드래그 중
const handleFillDragMove = useCallback((e: MouseEvent) => {
if (!isDraggingFill || !selection || !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, selection]);
// 자동 채우기 드래그 종료 (다중 셀 지원)
const handleFillDragEnd = useCallback(() => {
if (!isDraggingFill || !selection || fillPreviewEnd === null) {
setIsDraggingFill(false);
setFillPreviewEnd(null);
return;
}
const norm = normalizeRange(selection);
const endRow = fillPreviewEnd;
const selectionHeight = norm.endRow - norm.startRow + 1;
if (endRow !== norm.endRow && norm.startRow >= 0) {
const newData = [...data];
if (endRow > norm.endRow) {
// 아래로 채우기
for (let targetRow = norm.endRow + 1; targetRow <= endRow; targetRow++) {
// 선택 범위 내 행 순환
const sourceRowOffset = (targetRow - norm.startRow) % selectionHeight;
const sourceRow = norm.startRow + sourceRowOffset;
const stepMultiplier = Math.floor((targetRow - norm.startRow) / selectionHeight);
if (!newData[targetRow]) {
newData[targetRow] = {};
columns.forEach((c) => {
newData[targetRow][c] = "";
});
}
// 선택된 모든 열에 대해 채우기
for (let col = norm.startCol; col <= norm.endCol; col++) {
const colName = columns[col];
const sourceValue = String(data[sourceRow]?.[colName] ?? "");
const step = targetRow - sourceRow;
newData[targetRow] = {
...newData[targetRow],
[colName]: generateNextValue(sourceValue, step),
};
}
}
} else if (endRow < norm.startRow) {
// 위로 채우기
for (let targetRow = norm.startRow - 1; targetRow >= endRow; targetRow--) {
const sourceRowOffset = (norm.startRow - targetRow - 1) % selectionHeight;
const sourceRow = norm.endRow - sourceRowOffset;
if (!newData[targetRow]) {
newData[targetRow] = {};
columns.forEach((c) => {
newData[targetRow][c] = "";
});
}
for (let col = norm.startCol; col <= norm.endCol; col++) {
const colName = columns[col];
const sourceValue = String(data[sourceRow]?.[colName] ?? "");
const step = targetRow - sourceRow;
newData[targetRow] = {
...newData[targetRow],
[colName]: generateNextValue(sourceValue, step),
};
}
}
}
onDataChange(newData);
}
setIsDraggingFill(false);
setFillPreviewEnd(null);
}, [isDraggingFill, selection, 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 || !selection || fillPreviewEnd === null) return false;
const norm = normalizeRange(selection);
// 열이 선택 범위 내에 있어야 함
if (colIndex < norm.startCol || colIndex > norm.endCol) return false;
if (fillPreviewEnd > norm.endRow) {
return rowIndex > norm.endRow && rowIndex <= fillPreviewEnd;
} else if (fillPreviewEnd < norm.startRow) {
return rowIndex >= fillPreviewEnd && rowIndex < norm.startRow;
}
return false;
};
return (
<div
ref={tableRef}
tabIndex={0}
className="overflow-auto rounded-md border border-border select-none outline-none focus:ring-2 focus:ring-primary/20"
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) ||
isCellInSelection(-1, colIndex)
? "ring-2 ring-primary ring-inset"
: ""
)}
onMouseDown={(e) => handleCellMouseDown(-1, colIndex, e)}
onMouseEnter={() => handleCellMouseEnter(-1, colIndex)}
onDoubleClick={() => startEditing(-1, colIndex)}
>
{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) => {
const isSelected = isCellInSelection(rowIndex, colIndex);
const isEditing = editingCell?.row === rowIndex && editingCell?.col === colIndex;
const inFillPreview = isInFillPreview(rowIndex, colIndex);
const isSelectionEnd = isCellSelectionEnd(rowIndex, colIndex);
const copiedBorder = getCopiedBorderPosition(rowIndex, colIndex);
const isCopied = isCellInCopiedRange(rowIndex, colIndex);
return (
<td
key={colIndex}
className={cn(
"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" : ""
)}
onMouseDown={(e) => handleCellMouseDown(rowIndex, colIndex, e)}
onMouseEnter={() => handleCellMouseEnter(rowIndex, colIndex)}
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>
)}
{/* 복사 범위 점선 테두리 (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" />
)}
</>
)}
{/* 자동 채우기 핸들 - 선택 범위의 우하단에서만 표시 */}
{isSelectionEnd && !isEditing && selection && normalizeRange(selection).startRow >= 0 && (
<div
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"
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>
);
};