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

696 lines
22 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;
}
/**
* 엑셀처럼 편집 가능한 스프레드시트 컴포넌트
* - 셀 클릭으로 편집
* - 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>
);
};