자동 채우기 핸들

This commit is contained in:
kjs 2026-01-08 12:04:31 +09:00
parent 83eb92cb27
commit b61cb17aea
2 changed files with 762 additions and 161 deletions

View File

@ -0,0 +1,695 @@
"use client";
import React, { useState, useRef, useEffect, useCallback } from "react";
import { cn } from "@/lib/utils";
interface EditableSpreadsheetProps {
columns: string[];
data: Record<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>
);
};

View File

@ -24,8 +24,6 @@ import {
FileSpreadsheet,
AlertCircle,
CheckCircle2,
Plus,
Minus,
ArrowRight,
Zap,
} from "lucide-react";
@ -34,6 +32,7 @@ import { DynamicFormApi } from "@/lib/api/dynamicForm";
import { getTableSchema, TableColumn } from "@/lib/api/tableSchema";
import { cn } from "@/lib/utils";
import { findMappingByColumns, saveMappingTemplate } from "@/lib/api/excelMapping";
import { EditableSpreadsheet } from "./EditableSpreadsheet";
export interface ExcelUploadModalProps {
open: boolean;
@ -167,56 +166,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
}
};
// 행 추가
const handleAddRow = () => {
const newRow: Record<string, any> = {};
excelColumns.forEach((col) => {
newRow[col] = "";
});
setDisplayData([...displayData, newRow]);
toast.success("행이 추가되었습니다.");
};
// 행 삭제
const handleRemoveRow = () => {
if (displayData.length > 1) {
setDisplayData(displayData.slice(0, -1));
toast.success("마지막 행이 삭제되었습니다.");
} else {
toast.error("최소 1개의 행이 필요합니다.");
}
};
// 열 추가
const handleAddColumn = () => {
const newColName = `Column${excelColumns.length + 1}`;
setExcelColumns([...excelColumns, newColName]);
setDisplayData(
displayData.map((row) => ({
...row,
[newColName]: "",
}))
);
toast.success("열이 추가되었습니다.");
};
// 열 삭제
const handleRemoveColumn = () => {
if (excelColumns.length > 1) {
const lastCol = excelColumns[excelColumns.length - 1];
setExcelColumns(excelColumns.slice(0, -1));
setDisplayData(
displayData.map((row) => {
const { [lastCol]: removed, ...rest } = row;
return rest;
})
);
toast.success("마지막 열이 삭제되었습니다.");
} else {
toast.error("최소 1개의 열이 필요합니다.");
}
};
// 테이블 스키마 가져오기 (2단계 진입 시)
useEffect(() => {
if (currentStep === 2 && tableName) {
@ -336,6 +285,42 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
return;
}
// 1단계 → 2단계 전환 시: 빈 헤더 열 제외
if (currentStep === 1) {
// 빈 헤더가 아닌 열만 필터링
const validColumnIndices: number[] = [];
const validColumns: string[] = [];
excelColumns.forEach((col, index) => {
if (col && col.trim() !== "") {
validColumnIndices.push(index);
validColumns.push(col);
}
});
// 빈 헤더 열이 있었다면 데이터에서도 해당 열 제거
if (validColumns.length < excelColumns.length) {
const removedCount = excelColumns.length - validColumns.length;
// 새로운 데이터: 유효한 열만 포함
const cleanedData = displayData.map((row) => {
const newRow: Record<string, any> = {};
validColumns.forEach((colName) => {
newRow[colName] = row[colName];
});
return newRow;
});
setExcelColumns(validColumns);
setDisplayData(cleanedData);
setAllData(cleanedData);
if (removedCount > 0) {
toast.info(`빈 헤더 ${removedCount}개 열이 제외되었습니다.`);
}
}
}
setCurrentStep((prev) => Math.min(prev + 1, 3));
};
@ -599,8 +584,8 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
{/* 파일이 선택된 경우에만 미리보기 표시 */}
{file && displayData.length > 0 && (
<>
{/* 시트 선택 + 행/열 편집 버튼 */}
<div className="flex flex-wrap items-center gap-3">
{/* 시트 선택 */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<Label className="text-xs text-muted-foreground sm:text-sm">
:
@ -622,115 +607,36 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
</SelectContent>
</Select>
</div>
<div className="ml-auto flex flex-wrap gap-1">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddRow}
className="h-7 px-2 text-xs"
>
<Plus className="mr-1 h-3 w-3" />
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleRemoveRow}
className="h-7 px-2 text-xs"
>
<Minus className="mr-1 h-3 w-3" />
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddColumn}
className="h-7 px-2 text-xs"
>
<Plus className="mr-1 h-3 w-3" />
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleRemoveColumn}
className="h-7 px-2 text-xs"
>
<Minus className="mr-1 h-3 w-3" />
</Button>
</div>
<span className="text-xs text-muted-foreground">
{displayData.length} · , Tab/Enter로
</span>
</div>
{/* 감지된 범위 */}
<div className="text-xs text-muted-foreground">
: <span className="font-medium">{detectedRange}</span>
<span className="ml-2">({displayData.length} )</span>
</div>
{/* 데이터 미리보기 테이블 */}
<div className="max-h-[280px] overflow-auto rounded-md border border-border">
<table className="min-w-full text-[10px] sm:text-xs">
<thead className="sticky top-0 bg-muted">
<tr>
<th className="whitespace-nowrap border-b border-r border-border bg-primary/10 px-2 py-1 text-center font-medium"></th>
{excelColumns.map((col, index) => (
<th
key={col}
className="whitespace-nowrap border-b border-r border-border bg-primary/10 px-2 py-1 text-center font-medium"
>
{String.fromCharCode(65 + index)}
</th>
))}
</tr>
</thead>
<tbody>
<tr className="bg-primary/5">
<td className="whitespace-nowrap border-b border-r border-border bg-primary/10 px-2 py-1 text-center font-medium">
1
</td>
{excelColumns.map((col) => (
<td
key={col}
className="whitespace-nowrap border-b border-r border-border px-2 py-1 font-medium text-primary"
>
{col}
</td>
))}
</tr>
{displayData.slice(0, 10).map((row, rowIndex) => (
<tr
key={rowIndex}
className="border-b border-border last:border-0"
>
<td className="whitespace-nowrap border-r border-border bg-muted/50 px-2 py-1 text-center font-medium text-muted-foreground">
{rowIndex + 2}
</td>
{excelColumns.map((col) => (
<td
key={col}
className="max-w-[150px] truncate whitespace-nowrap border-r border-border px-2 py-1"
title={String(row[col])}
>
{String(row[col] || "")}
</td>
))}
</tr>
))}
{displayData.length > 10 && (
<tr>
<td
colSpan={excelColumns.length + 1}
className="bg-muted/30 px-2 py-1 text-center text-muted-foreground"
>
... {displayData.length - 10}
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* 엑셀처럼 편집 가능한 스프레드시트 */}
<EditableSpreadsheet
columns={excelColumns}
data={displayData}
onColumnsChange={(newColumns) => {
setExcelColumns(newColumns);
// 범위 재계산
const lastCol =
newColumns.length > 0
? String.fromCharCode(64 + newColumns.length)
: "A";
setDetectedRange(`A1:${lastCol}${displayData.length + 1}`);
}}
onDataChange={(newData) => {
setDisplayData(newData);
setAllData(newData);
// 범위 재계산
const lastCol =
excelColumns.length > 0
? String.fromCharCode(64 + excelColumns.length)
: "A";
setDetectedRange(`A1:${lastCol}${newData.length + 1}`);
}}
maxHeight="320px"
/>
</>
)}
</div>