696 lines
22 KiB
TypeScript
696 lines
22 KiB
TypeScript
"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;
|
||
}
|
||
|
||
/**
|
||
* 엑셀처럼 편집 가능한 스프레드시트 컴포넌트
|
||
* - 셀 클릭으로 편집
|
||
* - 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 [selectedCell, setSelectedCell] = useState<{
|
||
row: number;
|
||
col: number;
|
||
} | null>(null);
|
||
|
||
// 자동 채우기 드래그 상태
|
||
const [isDraggingFill, setIsDraggingFill] = useState(false);
|
||
const [fillPreviewEnd, setFillPreviewEnd] = useState<number | null>(null);
|
||
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
const tableRef = useRef<HTMLDivElement>(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<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();
|
||
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 (
|
||
<div
|
||
ref={tableRef}
|
||
className="overflow-auto rounded-md border border-border"
|
||
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) ||
|
||
(selectedCell?.row === -1 && selectedCell?.col === colIndex)
|
||
? "ring-2 ring-primary ring-inset"
|
||
: ""
|
||
)}
|
||
onClick={() => {
|
||
selectCell(-1, colIndex);
|
||
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 = selectedCell?.row === rowIndex && selectedCell?.col === colIndex;
|
||
const isEditing = editingCell?.row === rowIndex && editingCell?.col === colIndex;
|
||
const inFillPreview = isInFillPreview(rowIndex, colIndex);
|
||
|
||
return (
|
||
<td
|
||
key={colIndex}
|
||
className={cn(
|
||
"relative cursor-pointer border-b border-r border-border px-0 py-0",
|
||
isSelected || isEditing ? "ring-2 ring-primary ring-inset" : "",
|
||
inFillPreview ? "bg-primary/10" : ""
|
||
)}
|
||
onClick={() => {
|
||
selectCell(rowIndex, colIndex);
|
||
if (!isEditing) {
|
||
// 단일 클릭은 선택만
|
||
}
|
||
}}
|
||
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>
|
||
)}
|
||
|
||
{/* 자동 채우기 핸들 - 선택된 셀에서만 표시 */}
|
||
{isSelected && !isEditing && (
|
||
<div
|
||
className="absolute bottom-0 right-0 h-2 w-2 translate-x-1/2 translate-y-1/2 cursor-crosshair 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>
|
||
);
|
||
};
|