엑셀 파일 부르기 완료, 입력창 UI 설정 완료
This commit is contained in:
122
.cursor/rules/shadcn-tailwind-v4.mdc
Normal file
122
.cursor/rules/shadcn-tailwind-v4.mdc
Normal file
@@ -0,0 +1,122 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# Tailwind CSS v4 + Shadcn UI 호환성 규칙
|
||||
|
||||
## **CSS 설정 (src/index.css)**
|
||||
|
||||
- **@theme 레이어 사용**
|
||||
```css
|
||||
@theme {
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
```
|
||||
|
||||
- **CSS 변수 정의**
|
||||
- `:root`에 라이트 모드 색상 변수 정의
|
||||
- `.dark`에 다크 모드 색상 변수 정의
|
||||
- `hsl(var(--foreground))` 형태로 색상 사용
|
||||
|
||||
## **cn 함수 (src/lib/utils.ts)**
|
||||
|
||||
- **에러 핸들링 필수**
|
||||
```typescript
|
||||
// ✅ DO: fallback 로직 포함
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
try {
|
||||
return twMerge(clsx(inputs));
|
||||
} catch (error) {
|
||||
console.warn("tailwind-merge fallback:", error);
|
||||
return clsx(inputs);
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ DON'T: 에러 핸들링 없이 사용
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
```
|
||||
|
||||
## **컴포넌트 스타일링**
|
||||
|
||||
- **CSS 변수 활용**
|
||||
```typescript
|
||||
// ✅ DO: CSS 변수 기반 스타일링
|
||||
className="bg-background text-foreground border-border"
|
||||
|
||||
// ✅ DO: cn 함수로 조건부 스타일링
|
||||
className={cn(
|
||||
"base-styles",
|
||||
condition && "conditional-styles"
|
||||
)}
|
||||
```
|
||||
|
||||
- **색상 시스템 준수**
|
||||
- `background`, `foreground`, `primary`, `secondary` 등 정의된 변수 사용
|
||||
- 직접 색상 값 대신 변수 사용
|
||||
|
||||
## **패키지 관리**
|
||||
|
||||
- **필수 패키지**
|
||||
```json
|
||||
{
|
||||
"@tailwindcss/cli": "^4.1.10",
|
||||
"@tailwindcss/vite": "^4.1.10",
|
||||
"tailwind-merge": "latest",
|
||||
"clsx": "^2.1.1",
|
||||
"class-variance-authority": "^0.7.1"
|
||||
}
|
||||
```
|
||||
|
||||
- **제거해야 할 파일**
|
||||
- `tailwind.config.js` (v4는 CSS-first 방식)
|
||||
- `postcss.config.js` (v4는 PostCSS 불필요)
|
||||
|
||||
## **Vite 설정**
|
||||
|
||||
- **플러그인 설정**
|
||||
```typescript
|
||||
// vite.config.ts
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
});
|
||||
```
|
||||
|
||||
## **문제 해결**
|
||||
|
||||
- **tailwind-merge 오류 시**
|
||||
- 최신 버전으로 업데이트
|
||||
- cn 함수에 fallback 로직 구현
|
||||
|
||||
- **스타일이 적용되지 않을 때**
|
||||
- CSS 변수가 올바르게 정의되었는지 확인
|
||||
- @theme 레이어가 포함되었는지 확인
|
||||
|
||||
- **빌드 오류 시**
|
||||
- node_modules 캐시 삭제 후 재설치
|
||||
- package-lock.json 삭제 후 재설치
|
||||
|
||||
## **모범 사례**
|
||||
|
||||
- **컴포넌트 개발 시**
|
||||
- 항상 CSS 변수 사용
|
||||
- cn 함수로 클래스 조합
|
||||
- 조건부 스타일링에 적절한 패턴 적용
|
||||
|
||||
- **테마 관리**
|
||||
- 라이트/다크 모드 변수 동시 정의
|
||||
- 일관된 색상 시스템 유지
|
||||
|
||||
- **성능 최적화**
|
||||
- 불필요한 클래스 중복 방지
|
||||
- cn 함수 사용으로 클래스 충돌 해결
|
||||
|
||||
## **참고 자료**
|
||||
|
||||
- [Tailwind CSS v4 공식 문서](https://tailwindcss.com/docs/v4-beta)
|
||||
- [Shadcn UI + Tailwind v4 가이드](https://www.luisball.com/blog/shadcn-ui-with-tailwind-v4)
|
||||
- [Shadcn UI 공식 설치 가이드](https://ui.shadcn.com/docs/installation/manual)
|
||||
@@ -3,3 +3,193 @@ description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# xlsx-js-style 스타일 보존 규칙
|
||||
|
||||
## **핵심 원칙**
|
||||
- xlsx-js-style의 공식 API 구조를 직접 활용하여 스타일 변환
|
||||
- 복잡한 색상 변환 로직 대신 공식 COLOR_STYLE 형식 지원
|
||||
- 배경색과 테두리 색상 누락 방지를 위한 완전한 스타일 매핑
|
||||
|
||||
## **공식 xlsx-js-style API 활용**
|
||||
|
||||
### **색상 처리 (COLOR_STYLE)**
|
||||
```typescript
|
||||
// ✅ DO: 공식 COLOR_STYLE 형식 모두 지원
|
||||
function convertXlsxColorToLuckysheet(colorObj: any): string {
|
||||
// RGB 형태: {rgb: "FFCC00"}
|
||||
if (colorObj.rgb) { /* RGB 처리 */ }
|
||||
|
||||
// Theme 색상: {theme: 4} 또는 {theme: 1, tint: 0.4}
|
||||
if (typeof colorObj.theme === 'number') { /* Theme 처리 */ }
|
||||
|
||||
// Indexed 색상: Excel 기본 색상표
|
||||
if (typeof colorObj.indexed === 'number') { /* Indexed 처리 */ }
|
||||
}
|
||||
|
||||
// ❌ DON'T: 특정 색상 형식만 처리
|
||||
function badColorConvert(colorObj: any): string {
|
||||
return colorObj.rgb || "rgb(0,0,0)"; // rgb만 처리하고 theme, indexed 무시
|
||||
}
|
||||
```
|
||||
|
||||
### **스타일 객체 변환**
|
||||
```typescript
|
||||
// ✅ DO: 공식 스타일 속성 완전 매핑
|
||||
function convertXlsxStyleToLuckysheet(xlsxStyle: any): any {
|
||||
const luckyStyle: any = {};
|
||||
|
||||
// 폰트: {name: "Courier", sz: 24, bold: true, color: {rgb: "FF0000"}}
|
||||
if (xlsxStyle.font) {
|
||||
if (xlsxStyle.font.name) luckyStyle.ff = xlsxStyle.font.name;
|
||||
if (xlsxStyle.font.sz) luckyStyle.fs = xlsxStyle.font.sz;
|
||||
if (xlsxStyle.font.bold) luckyStyle.bl = 1;
|
||||
if (xlsxStyle.font.color) {
|
||||
luckyStyle.fc = convertXlsxColorToLuckysheet(xlsxStyle.font.color);
|
||||
}
|
||||
}
|
||||
|
||||
// 배경: {fgColor: {rgb: "E9E9E9"}}
|
||||
if (xlsxStyle.fill?.fgColor) {
|
||||
luckyStyle.bg = convertXlsxColorToLuckysheet(xlsxStyle.fill.fgColor);
|
||||
}
|
||||
|
||||
// 테두리: {top: {style: "thin", color: {rgb: "000000"}}}
|
||||
if (xlsxStyle.border) {
|
||||
luckyStyle.bd = {};
|
||||
if (xlsxStyle.border.top) {
|
||||
luckyStyle.bd.t = {
|
||||
style: convertBorderStyleToLuckysheet(xlsxStyle.border.top.style),
|
||||
color: convertXlsxColorToLuckysheet(xlsxStyle.border.top.color)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return luckyStyle;
|
||||
}
|
||||
|
||||
// ❌ DON'T: 수동으로 스타일 속성 하나씩 처리
|
||||
luckyCell.v.s = {
|
||||
ff: cell.s.font?.name || "Arial",
|
||||
bg: cell.s.fill?.fgColor?.rgb || "rgb(255,255,255)" // 직접 rgb만 처리
|
||||
};
|
||||
```
|
||||
|
||||
## **배경색과 테두리 색상 누락 방지**
|
||||
|
||||
### **배경색 처리**
|
||||
```typescript
|
||||
// ✅ DO: fgColor와 bgColor 모두 확인
|
||||
if (xlsxStyle.fill) {
|
||||
if (xlsxStyle.fill.fgColor) {
|
||||
luckyStyle.bg = convertXlsxColorToLuckysheet(xlsxStyle.fill.fgColor);
|
||||
} else if (xlsxStyle.fill.bgColor) {
|
||||
luckyStyle.bg = convertXlsxColorToLuckysheet(xlsxStyle.fill.bgColor);
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ DON'T: fgColor만 확인
|
||||
if (xlsxStyle.fill?.fgColor) {
|
||||
luckyStyle.bg = xlsxStyle.fill.fgColor.rgb; // 다른 색상 형식 무시
|
||||
}
|
||||
```
|
||||
|
||||
### **테두리 색상 처리**
|
||||
```typescript
|
||||
// ✅ DO: 모든 테두리 방향과 색상 형식 지원
|
||||
if (xlsxStyle.border) {
|
||||
['top', 'bottom', 'left', 'right'].forEach(side => {
|
||||
if (xlsxStyle.border[side]) {
|
||||
luckyStyle.bd[side[0]] = {
|
||||
style: convertBorderStyleToLuckysheet(xlsxStyle.border[side].style),
|
||||
color: convertXlsxColorToLuckysheet(xlsxStyle.border[side].color)
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ❌ DON'T: 하드코딩된 색상 사용
|
||||
luckyStyle.bd.t = {
|
||||
style: 1,
|
||||
color: "rgb(0,0,0)" // 실제 색상 무시
|
||||
};
|
||||
```
|
||||
|
||||
## **Excel Tint 처리**
|
||||
```typescript
|
||||
// ✅ DO: Excel tint 공식 적용
|
||||
function applyTintToRgbColor(rgbColor: string, tint: number): string {
|
||||
const applyTint = (color: number, tint: number): number => {
|
||||
if (tint < 0) {
|
||||
return Math.round(color * (1 + tint));
|
||||
} else {
|
||||
return Math.round(color * (1 - tint) + (255 - 255 * (1 - tint)));
|
||||
}
|
||||
};
|
||||
// RGB 각 채널에 tint 적용
|
||||
}
|
||||
|
||||
// ❌ DON'T: tint 무시
|
||||
if (colorObj.theme) {
|
||||
return themeColors[colorObj.theme]; // tint 무시
|
||||
}
|
||||
```
|
||||
|
||||
## **오류 방지 패턴**
|
||||
|
||||
### **안전한 스타일 읽기**
|
||||
```typescript
|
||||
// ✅ DO: 옵셔널 체이닝과 타입 검사
|
||||
workbook = XLSX.read(arrayBuffer, {
|
||||
cellStyles: true // 스타일 정보 보존
|
||||
});
|
||||
|
||||
// 스타일 정보 확인
|
||||
if (cell.s) {
|
||||
console.log(`🎨 셀 ${cellAddress}에 스타일 정보:`, cell.s);
|
||||
luckyCell.v.s = convertXlsxStyleToLuckysheet(cell.s);
|
||||
}
|
||||
|
||||
// ❌ DON'T: 스타일 옵션 누락
|
||||
workbook = XLSX.read(arrayBuffer); // cellStyles 옵션 없음
|
||||
```
|
||||
|
||||
### **스타일 쓰기 보존**
|
||||
```typescript
|
||||
// ✅ DO: 쓰기 시에도 스타일 보존
|
||||
const xlsxData = XLSX.write(workbook, {
|
||||
type: "array",
|
||||
bookType: "xlsx",
|
||||
cellStyles: true // 스타일 정보 보존
|
||||
});
|
||||
|
||||
// ❌ DON'T: 쓰기 시 스타일 누락
|
||||
const xlsxData = XLSX.write(workbook, {
|
||||
type: "array",
|
||||
bookType: "xlsx"
|
||||
// cellStyles 옵션 없음
|
||||
});
|
||||
```
|
||||
|
||||
## **디버깅 및 검증**
|
||||
|
||||
### **스타일 정보 로깅**
|
||||
```typescript
|
||||
// ✅ DO: 개발 모드에서 스타일 정보 상세 분석
|
||||
if (import.meta.env.DEV && cell.s) {
|
||||
console.log(`🎨 셀 ${cellAddress} 스타일:`, {
|
||||
font: cell.s.font,
|
||||
fill: cell.s.fill,
|
||||
border: cell.s.border,
|
||||
alignment: cell.s.alignment
|
||||
});
|
||||
}
|
||||
|
||||
// ❌ DON'T: 스타일 정보 무시
|
||||
// 스타일 관련 로그 없음
|
||||
```
|
||||
|
||||
## **참고 사항**
|
||||
- [xlsx-js-style GitHub](https://github.com/gitbrent/xlsx-js-style) 공식 문서 참조
|
||||
- 공식 COLOR_STYLE 형식: `{rgb: "FFCC00"}`, `{theme: 4}`, `{theme: 1, tint: 0.4}`
|
||||
- 공식 BORDER_STYLE 값: `thin`, `medium`, `thick`, `dotted`, `dashed` 등
|
||||
- Excel 테마 색상과 tint 처리는 공식 Excel 색상 공식 사용
|
||||
|
||||
30
src/App.tsx
30
src/App.tsx
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "./components/ui/button";
|
||||
import TestSheetViewer from "./components/sheet/TestSheetViewer";
|
||||
import HomeButton from "./components/ui/homeButton";
|
||||
import EditSheetViewer from "./components/sheet/EditSheetViewer";
|
||||
|
||||
function App() {
|
||||
const [showTestViewer, setShowTestViewer] = useState(false);
|
||||
@@ -12,7 +13,20 @@ function App() {
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
<div className="flex items-center">
|
||||
<h1 className="text-2xl font-bold text-blue-600">sheetEasy AI</h1>
|
||||
{/* showTestViewer가 true일 때만 홈 버튼 노출 */}
|
||||
{showTestViewer && (
|
||||
<HomeButton
|
||||
className="ml-0"
|
||||
style={{ position: "absolute", left: "1%" }}
|
||||
onClick={() => {
|
||||
if (window.confirm("기본 화면으로 돌아가시겠습니까?")) {
|
||||
setShowTestViewer(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
sheetEasy AI
|
||||
</HomeButton>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* 테스트 뷰어 토글 버튼 */}
|
||||
@@ -22,12 +36,12 @@ function App() {
|
||||
onClick={() => setShowTestViewer(!showTestViewer)}
|
||||
className="bg-green-500 hover:bg-green-600 text-white border-green-500"
|
||||
>
|
||||
🧪 테스트 뷰어
|
||||
🧪 에디트 뷰어
|
||||
</Button>
|
||||
|
||||
{!showTestViewer && (
|
||||
<span className="text-sm text-gray-600">
|
||||
Univer CE 테스트 모드
|
||||
Univer CE 에디트 모드
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -40,24 +54,24 @@ function App() {
|
||||
{showTestViewer ? (
|
||||
// 테스트 뷰어 표시
|
||||
<div className="h-full">
|
||||
<TestSheetViewer />
|
||||
<EditSheetViewer />
|
||||
</div>
|
||||
) : (
|
||||
// 메인 페이지
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="text-center py-12">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-4">
|
||||
🧪 Univer CE 테스트 모드
|
||||
🧪 Univer CE 에디트 모드
|
||||
</h2>
|
||||
<p className="text-lg text-gray-600 mb-8">
|
||||
현재 Univer CE 전용 테스트 뷰어를 사용해보세요
|
||||
현재 Univer CE 전용 에디트 뷰어를 사용해보세요
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setShowTestViewer(true)}
|
||||
size="lg"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-8 py-3"
|
||||
>
|
||||
테스트 뷰어 시작하기 →
|
||||
에디트 뷰어 시작하기 →
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { UniverSheetsNumfmtUIPlugin } from "@univerjs/sheets-numfmt-ui";
|
||||
import { UniverUIPlugin } from "@univerjs/ui";
|
||||
import { cn } from "../../lib/utils";
|
||||
import LuckyExcel from "@zwight/luckyexcel";
|
||||
import PromptInput from "./PromptInput";
|
||||
|
||||
// 언어팩 import
|
||||
import DesignEnUS from "@univerjs/design/locale/en-US";
|
||||
@@ -283,6 +284,7 @@ const TestSheetViewer: React.FC = () => {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [currentFile, setCurrentFile] = useState<File | null>(null);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
|
||||
// Univer 초기화 함수
|
||||
const initializeUniver = useCallback(async (workbookData?: any) => {
|
||||
@@ -561,39 +563,27 @@ const TestSheetViewer: React.FC = () => {
|
||||
<div className="w-full h-screen flex flex-col relative">
|
||||
{/* 헤더 */}
|
||||
<div className="bg-white border-b p-4 flex-shrink-0 relative z-10">
|
||||
<h1 className="text-xl font-bold">
|
||||
🧪 Univer CE + 파일 업로드 (Window 기반 관리)
|
||||
</h1>
|
||||
<div className="mt-2 flex items-center gap-4">
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
isInitialized
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-yellow-100 text-yellow-800"
|
||||
}`}
|
||||
<div
|
||||
className="mt-2 flex items-center gap-4"
|
||||
style={{ position: "relative" }}
|
||||
>
|
||||
{isInitialized ? "✅ 초기화 완료" : "⏳ 초기화 중..."}
|
||||
</span>
|
||||
|
||||
{currentFile && (
|
||||
<>
|
||||
<span className="text-sm text-blue-600 font-medium">
|
||||
📄 {currentFile.name}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={handleNewUpload}
|
||||
className="text-xs px-2 py-1 bg-blue-100 text-blue-700 rounded hover:bg-blue-200 transition-colors"
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: "0%",
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
}}
|
||||
>
|
||||
새 파일 업로드
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 디버그 정보 */}
|
||||
<div className="text-xs text-gray-500">
|
||||
전역 인스턴스: {UniverseManager.getInstance() ? "✅" : "❌"} |
|
||||
초기화 중: {UniverseManager.isInitializing() ? "⏳" : "❌"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -602,12 +592,24 @@ const TestSheetViewer: React.FC = () => {
|
||||
ref={containerRef}
|
||||
className="flex-1 relative"
|
||||
style={{
|
||||
minHeight: "500px",
|
||||
minHeight: "0",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
flexGrow: 0.85,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Univer와 입력창 사이 여백 */}
|
||||
<div style={{ height: "1rem" }} />
|
||||
|
||||
{/* 프롬프트 입력창 - Univer 하단에 이어서 */}
|
||||
<PromptInput
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
onExecute={() => {}}
|
||||
disabled={true}
|
||||
/>
|
||||
|
||||
{/* 파일 업로드 오버레이 - 레이어 분리 */}
|
||||
{showUploadOverlay && (
|
||||
<>
|
||||
@@ -1,421 +0,0 @@
|
||||
import React, { useCallback, useState, useRef } from "react";
|
||||
import { Card, CardContent } from "../ui/card";
|
||||
import { Button } from "../ui/button";
|
||||
import { FileErrorModal } from "../ui/modal";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { useAppStore } from "../../stores/useAppStore";
|
||||
import {
|
||||
processExcelFile,
|
||||
getFileErrors,
|
||||
filterValidFiles,
|
||||
} from "../../utils/fileProcessor";
|
||||
|
||||
interface FileUploadProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 업로드 컴포넌트
|
||||
* - Drag & Drop 기능 지원
|
||||
* - .xls, .xlsx 파일 타입 제한
|
||||
* - 접근성 지원 (ARIA 라벨, 키보드 탐색)
|
||||
* - 반응형 레이아웃
|
||||
* - 실제 파일 처리 로직 연결
|
||||
*/
|
||||
export function FileUpload({ className }: FileUploadProps) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [showErrorModal, setShowErrorModal] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 스토어에서 상태 가져오기
|
||||
const {
|
||||
isLoading,
|
||||
error,
|
||||
fileUploadErrors,
|
||||
currentFile,
|
||||
setLoading,
|
||||
setError,
|
||||
uploadFile,
|
||||
clearFileUploadErrors,
|
||||
} = useAppStore();
|
||||
|
||||
/**
|
||||
* 파일 처리 로직
|
||||
*/
|
||||
const handleFileProcessing = useCallback(
|
||||
async (file: File) => {
|
||||
setLoading(true, "파일을 처리하는 중...");
|
||||
setError(null);
|
||||
clearFileUploadErrors();
|
||||
|
||||
try {
|
||||
const result = await processExcelFile(file);
|
||||
uploadFile(result);
|
||||
|
||||
if (result.success) {
|
||||
console.log("파일 업로드 성공:", result.fileName);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("파일 처리 중 예상치 못한 오류:", error);
|
||||
setError("파일 처리 중 예상치 못한 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[setLoading, setError, uploadFile, clearFileUploadErrors],
|
||||
);
|
||||
|
||||
/**
|
||||
* 파일 선택 처리
|
||||
*/
|
||||
const handleFileSelection = useCallback(
|
||||
async (files: FileList) => {
|
||||
if (files.length === 0) return;
|
||||
|
||||
// 유효하지 않은 파일들의 에러 수집
|
||||
const fileErrors = getFileErrors(files) || [];
|
||||
const validFiles = filterValidFiles(files) || [];
|
||||
|
||||
// 에러가 있는 파일들을 스토어에 저장
|
||||
fileErrors.forEach(({ file, error }) => {
|
||||
useAppStore.getState().addFileUploadError(file.name, error);
|
||||
});
|
||||
|
||||
// 에러가 있으면 모달 표시
|
||||
if (fileErrors.length > 0) {
|
||||
setShowErrorModal(true);
|
||||
}
|
||||
|
||||
if (validFiles.length === 0) {
|
||||
setError("업로드 가능한 파일이 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (validFiles.length > 1) {
|
||||
setError(
|
||||
"한 번에 하나의 파일만 업로드할 수 있습니다. 첫 번째 파일을 사용합니다.",
|
||||
);
|
||||
}
|
||||
|
||||
// 첫 번째 유효한 파일 처리
|
||||
await handleFileProcessing(validFiles[0]);
|
||||
},
|
||||
[handleFileProcessing, setError],
|
||||
);
|
||||
|
||||
/**
|
||||
* 드래그 앤 드롭 이벤트 핸들러
|
||||
*/
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
|
||||
setIsDragOver(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
|
||||
if (isLoading) return;
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files && files.length > 0) {
|
||||
await handleFileSelection(files);
|
||||
}
|
||||
},
|
||||
[handleFileSelection, isLoading],
|
||||
);
|
||||
|
||||
/**
|
||||
* 파일 선택 버튼 클릭 핸들러
|
||||
*/
|
||||
const handleFilePickerClick = useCallback(() => {
|
||||
if (isLoading || !fileInputRef.current) return;
|
||||
fileInputRef.current.click();
|
||||
}, [isLoading]);
|
||||
|
||||
/**
|
||||
* 파일 입력 변경 핸들러
|
||||
*/
|
||||
const handleFileInputChange = useCallback(
|
||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
await handleFileSelection(files);
|
||||
}
|
||||
// 입력 초기화 (같은 파일 재선택 가능하도록)
|
||||
e.target.value = "";
|
||||
},
|
||||
[handleFileSelection],
|
||||
);
|
||||
|
||||
/**
|
||||
* 키보드 이벤트 핸들러 (접근성)
|
||||
*/
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleFilePickerClick();
|
||||
}
|
||||
},
|
||||
[handleFilePickerClick],
|
||||
);
|
||||
|
||||
/**
|
||||
* 에러 모달 닫기 핸들러
|
||||
*/
|
||||
const handleCloseErrorModal = useCallback(() => {
|
||||
setShowErrorModal(false);
|
||||
clearFileUploadErrors();
|
||||
}, [clearFileUploadErrors]);
|
||||
|
||||
// 파일이 이미 업로드된 경우 성공 상태 표시
|
||||
if (currentFile && !error) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center min-h-[60vh]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Card className="w-full max-w-2xl">
|
||||
<CardContent className="p-8 md:p-12">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto h-20 w-20 md:h-24 md:w-24 rounded-full bg-green-100 flex items-center justify-center mb-4">
|
||||
<svg
|
||||
className="h-10 w-10 md:h-12 md:w-12 text-green-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl md:text-2xl font-semibold mb-2 text-green-800">
|
||||
파일 업로드 완료
|
||||
</h2>
|
||||
<p className="text-sm md:text-base text-gray-600 mb-4">
|
||||
<span className="font-medium text-gray-900">
|
||||
{currentFile.name}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mb-6">
|
||||
파일 크기: {(currentFile.size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleFilePickerClick}
|
||||
variant="outline"
|
||||
disabled={isLoading}
|
||||
>
|
||||
다른 파일 업로드
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-center min-h-[60vh]", className)}
|
||||
>
|
||||
<Card className="w-full max-w-2xl">
|
||||
<CardContent className="p-8 md:p-12">
|
||||
<div className="text-center">
|
||||
{/* 아이콘 및 제목 */}
|
||||
<div className="mb-8">
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto h-20 w-20 md:h-24 md:w-24 rounded-full flex items-center justify-center mb-4",
|
||||
error ? "bg-red-100" : "bg-blue-50",
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<svg
|
||||
className="h-10 w-10 md:h-12 md:w-12 text-blue-600 animate-spin"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
) : error ? (
|
||||
<svg
|
||||
className="h-10 w-10 md:h-12 md:w-12 text-red-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.864-.833-2.634 0L3.197 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="h-10 w-10 md:h-12 md:w-12 text-blue-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<h2
|
||||
className={cn(
|
||||
"text-xl md:text-2xl font-semibold mb-2 text-gray-900",
|
||||
error ? "text-red-800" : "",
|
||||
)}
|
||||
>
|
||||
{isLoading
|
||||
? "파일 처리 중..."
|
||||
: error
|
||||
? "업로드 오류"
|
||||
: "Excel 파일을 업로드하세요"}
|
||||
</h2>
|
||||
<p className="text-sm md:text-base text-gray-600 mb-6">
|
||||
{isLoading ? (
|
||||
<span className="text-blue-600">잠시만 기다려주세요...</span>
|
||||
) : error ? (
|
||||
<span className="text-red-600">{error}</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="font-medium text-gray-900">
|
||||
.xlsx, .xls
|
||||
</span>{" "}
|
||||
파일을 드래그 앤 드롭하거나 클릭하여 업로드
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 파일 업로드 에러 목록 */}
|
||||
{fileUploadErrors.length > 0 && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<h3 className="text-sm font-medium text-red-800 mb-2">
|
||||
파일 업로드 오류:
|
||||
</h3>
|
||||
<ul className="text-xs text-red-700 space-y-1">
|
||||
{fileUploadErrors.map((error, index) => (
|
||||
<li key={index}>
|
||||
<span className="font-medium">{error.fileName}</span>:{" "}
|
||||
{error.error}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 드래그 앤 드롭 영역 */}
|
||||
<div
|
||||
className={cn(
|
||||
"border-2 border-dashed rounded-lg p-8 md:p-12 transition-all duration-200 cursor-pointer",
|
||||
"hover:border-blue-400 hover:bg-blue-50",
|
||||
"focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2",
|
||||
isDragOver
|
||||
? "border-blue-500 bg-blue-100 scale-105"
|
||||
: "border-gray-300",
|
||||
isLoading && "opacity-50 cursor-not-allowed",
|
||||
)}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onClick={handleFilePickerClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={isLoading ? -1 : 0}
|
||||
role="button"
|
||||
aria-label="파일 업로드 영역"
|
||||
aria-describedby="upload-instructions"
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center space-y-4">
|
||||
<div className="text-4xl md:text-6xl">
|
||||
{isDragOver ? "📂" : "📄"}
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-base md:text-lg font-medium mb-2 text-gray-900">
|
||||
{isDragOver
|
||||
? "파일을 여기에 놓으세요"
|
||||
: "파일을 드래그하거나 클릭하세요"}
|
||||
</p>
|
||||
<p id="upload-instructions" className="text-sm text-gray-600">
|
||||
최대 50MB까지 업로드 가능
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 숨겨진 파일 입력 */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
onChange={handleFileInputChange}
|
||||
className="hidden"
|
||||
disabled={isLoading}
|
||||
aria-label="파일 선택"
|
||||
/>
|
||||
|
||||
{/* 지원 형식 안내 */}
|
||||
<div className="mt-6 text-xs text-gray-500">
|
||||
<p>지원 형식: Excel (.xlsx, .xls)</p>
|
||||
<p>최대 파일 크기: 50MB</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 파일 에러 모달 */}
|
||||
<FileErrorModal
|
||||
isOpen={showErrorModal}
|
||||
onClose={handleCloseErrorModal}
|
||||
errors={fileUploadErrors}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
src/components/sheet/PromptInput.tsx
Normal file
59
src/components/sheet/PromptInput.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from "react";
|
||||
|
||||
interface PromptInputProps {
|
||||
value: string;
|
||||
onChange?: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
onExecute?: () => void;
|
||||
disabled?: boolean;
|
||||
maxLength?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 에디트 화면 하단 고정 프롬프트 입력창 컴포넌트
|
||||
* - 이미지 참고: 입력창, Execute 버튼, 안내문구, 글자수 카운트, 하단 고정
|
||||
*/
|
||||
const PromptInput: React.FC<PromptInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
onExecute,
|
||||
disabled = true,
|
||||
maxLength = 500,
|
||||
}) => {
|
||||
return (
|
||||
<div className="w-[60%] mx-auto bg-white z-10 flex flex-col items-center py-4 px-2">
|
||||
<div className="w-full max-w-3xl flex items-end gap-2">
|
||||
<textarea
|
||||
className="flex-1 resize-none rounded-lg border border-gray-300 bg-gray-50 px-4 py-3 text-base text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-400 disabled:bg-gray-100 disabled:cursor-not-allowed min-h-[48px] max-h-32 shadow-sm"
|
||||
placeholder="질문을 입력하세요.
|
||||
예시) A1부터 A10까지 합계를 구해서 B1에 입력하는 수식을 입력해줘"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={false}
|
||||
maxLength={maxLength}
|
||||
rows={5}
|
||||
/>
|
||||
<div style={{ width: "1rem" }} />
|
||||
<button
|
||||
className="ml-2 px-6 py-2 rounded-lg text-white font-semibold text-base shadow transition disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
style={{
|
||||
background: "linear-gradient(90deg, #a18fff 0%, #6f6fff 100%)",
|
||||
}}
|
||||
onClick={onExecute}
|
||||
disabled={disabled || !value.trim()}
|
||||
>
|
||||
전송하기
|
||||
</button>
|
||||
</div>
|
||||
<div className="w-full max-w-3xl flex justify-between items-center mt-1 px-1">
|
||||
<span className="text-xs text-gray-500">
|
||||
Press Enter to send, Shift+Enter for new line
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{value.length}/{maxLength}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PromptInput;
|
||||
@@ -1,491 +0,0 @@
|
||||
import {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useCallback,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useAppStore } from "../../stores/useAppStore";
|
||||
|
||||
interface SheetViewerProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Luckysheet 시트 뷰어 컴포넌트
|
||||
* - 메모리 정보 기반: LuckyExcel 변환 결과를 직접 사용
|
||||
* - 커스텀 검증이나 데이터 구조 변경 금지
|
||||
* - luckysheet.create({ data: exportJson.sheets })로 직접 사용
|
||||
*/
|
||||
export function SheetViewer({ className }: SheetViewerProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const luckysheetRef = useRef<any>(null);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [isConverting, setIsConverting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isContainerReady, setIsContainerReady] = useState(false);
|
||||
const [librariesLoaded, setLibrariesLoaded] = useState(false);
|
||||
|
||||
// 스토어에서 현재 파일 정보만 가져오기 (시트 데이터는 LuckyExcel로 직접 변환)
|
||||
const { currentFile, setSelectedRange } = useAppStore();
|
||||
|
||||
/**
|
||||
* CDN 배포판 라이브러리 로딩
|
||||
*/
|
||||
const loadLuckysheetLibrary = useCallback((): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 이미 로드된 경우
|
||||
if (
|
||||
window.luckysheet &&
|
||||
window.LuckyExcel &&
|
||||
window.$ &&
|
||||
librariesLoaded
|
||||
) {
|
||||
console.log("📦 모든 라이브러리가 이미 로드됨");
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const loadResource = (
|
||||
type: "css" | "js",
|
||||
src: string,
|
||||
id: string,
|
||||
): Promise<void> => {
|
||||
return new Promise((resourceResolve, resourceReject) => {
|
||||
// 이미 로드된 리소스 체크
|
||||
if (document.querySelector(`[data-luckysheet-id="${id}"]`)) {
|
||||
resourceResolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "css") {
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = src;
|
||||
link.setAttribute("data-luckysheet-id", id);
|
||||
link.onload = () => resourceResolve();
|
||||
link.onerror = () =>
|
||||
resourceReject(new Error(`${id} CSS 로드 실패`));
|
||||
document.head.appendChild(link);
|
||||
} else {
|
||||
const script = document.createElement("script");
|
||||
script.src = src;
|
||||
script.setAttribute("data-luckysheet-id", id);
|
||||
script.onload = () => resourceResolve();
|
||||
script.onerror = () =>
|
||||
resourceReject(new Error(`${id} JS 로드 실패`));
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const loadSequence = async () => {
|
||||
try {
|
||||
// 1. jQuery (Luckysheet 의존성)
|
||||
if (!window.$) {
|
||||
await loadResource(
|
||||
"js",
|
||||
"https://code.jquery.com/jquery-3.6.0.min.js",
|
||||
"jquery",
|
||||
);
|
||||
}
|
||||
|
||||
// 2. CSS 로드 (공식 문서 순서 준수)
|
||||
await loadResource(
|
||||
"css",
|
||||
"https://cdn.jsdelivr.net/npm/luckysheet/dist/plugins/css/pluginsCss.css",
|
||||
"plugins-css",
|
||||
);
|
||||
await loadResource(
|
||||
"css",
|
||||
"https://cdn.jsdelivr.net/npm/luckysheet/dist/plugins/plugins.css",
|
||||
"plugins-main-css",
|
||||
);
|
||||
await loadResource(
|
||||
"css",
|
||||
"https://cdn.jsdelivr.net/npm/luckysheet/dist/css/luckysheet.css",
|
||||
"luckysheet-css",
|
||||
);
|
||||
await loadResource(
|
||||
"css",
|
||||
"https://cdn.jsdelivr.net/npm/luckysheet/dist/assets/iconfont/iconfont.css",
|
||||
"iconfont-css",
|
||||
);
|
||||
|
||||
// 3. Plugin JS 먼저 로드 (functionlist 초기화)
|
||||
await loadResource(
|
||||
"js",
|
||||
"https://cdn.jsdelivr.net/npm/luckysheet/dist/plugins/js/plugin.js",
|
||||
"plugin-js",
|
||||
);
|
||||
|
||||
// 4. Luckysheet 메인
|
||||
if (!window.luckysheet) {
|
||||
await loadResource(
|
||||
"js",
|
||||
"https://cdn.jsdelivr.net/npm/luckysheet/dist/luckysheet.umd.js",
|
||||
"luckysheet",
|
||||
);
|
||||
}
|
||||
|
||||
// 5. LuckyExcel (Excel 파일 처리용)
|
||||
if (!window.LuckyExcel) {
|
||||
await loadResource(
|
||||
"js",
|
||||
"https://cdn.jsdelivr.net/npm/luckyexcel/dist/luckyexcel.umd.js",
|
||||
"luckyexcel",
|
||||
);
|
||||
}
|
||||
|
||||
// 라이브러리 검증
|
||||
const validationResults = {
|
||||
jquery: !!window.$,
|
||||
luckyExcel: !!window.LuckyExcel,
|
||||
luckysheet: !!window.luckysheet,
|
||||
luckysheetCreate: !!(
|
||||
window.luckysheet &&
|
||||
typeof window.luckysheet.create === "function"
|
||||
),
|
||||
luckysheetDestroy: !!(
|
||||
window.luckysheet &&
|
||||
typeof window.luckysheet.destroy === "function"
|
||||
),
|
||||
};
|
||||
|
||||
if (
|
||||
!validationResults.luckysheet ||
|
||||
!validationResults.luckysheetCreate
|
||||
) {
|
||||
throw new Error(
|
||||
"Luckysheet 객체가 올바르게 초기화되지 않았습니다.",
|
||||
);
|
||||
}
|
||||
|
||||
setLibrariesLoaded(true);
|
||||
console.log("✅ 라이브러리 로드 완료");
|
||||
resolve();
|
||||
} catch (error) {
|
||||
console.error("❌ 라이브러리 로딩 실패:", error);
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
loadSequence();
|
||||
});
|
||||
}, [librariesLoaded]);
|
||||
|
||||
/**
|
||||
* 메모리 정보 기반: LuckyExcel 변환 결과를 직접 사용하는 방식
|
||||
* - LuckyExcel.transformExcelToLucky()에서 반환된 exportJson.sheets를 그대로 사용
|
||||
* - 커스텀 검증이나 데이터 구조 변경 금지
|
||||
*/
|
||||
const convertXLSXWithLuckyExcel = useCallback(
|
||||
async (xlsxBuffer: ArrayBuffer, fileName: string) => {
|
||||
if (!containerRef.current) {
|
||||
console.warn("⚠️ 컨테이너가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsConverting(true);
|
||||
setError(null);
|
||||
|
||||
console.log("🍀 메모리 정보 기반: LuckyExcel 직접 변환 시작...");
|
||||
|
||||
// 라이브러리 로드 확인
|
||||
await loadLuckysheetLibrary();
|
||||
|
||||
// 기존 인스턴스 정리
|
||||
try {
|
||||
if (
|
||||
window.luckysheet &&
|
||||
typeof window.luckysheet.destroy === "function"
|
||||
) {
|
||||
window.luckysheet.destroy();
|
||||
console.log("✅ 기존 인스턴스 destroy 완료");
|
||||
}
|
||||
} catch (destroyError) {
|
||||
console.warn("⚠️ destroy 중 오류 (무시됨):", destroyError);
|
||||
}
|
||||
|
||||
// 컨테이너 초기화
|
||||
if (containerRef.current) {
|
||||
containerRef.current.innerHTML = "";
|
||||
console.log("✅ 컨테이너 초기화 완료");
|
||||
}
|
||||
|
||||
luckysheetRef.current = null;
|
||||
|
||||
console.log("🍀 LuckyExcel.transformExcelToLucky 호출...");
|
||||
|
||||
// ArrayBuffer를 File 객체로 변환 (LuckyExcel은 File 객체 필요)
|
||||
const file = new File([xlsxBuffer], fileName, {
|
||||
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
});
|
||||
|
||||
// LuckyExcel의 직접 변환 사용 (Promise 방식)
|
||||
const luckyExcelResult = await new Promise<any>((resolve, reject) => {
|
||||
try {
|
||||
// 🚨 수정: 첫 번째 매개변수는 File 객체여야 함
|
||||
(window.LuckyExcel as any).transformExcelToLucky(
|
||||
file, // ArrayBuffer 대신 File 객체 사용
|
||||
// 성공 콜백
|
||||
(exportJson: any, luckysheetfile: any) => {
|
||||
console.log("🍀 LuckyExcel 변환 성공!");
|
||||
console.log("🍀 exportJson:", exportJson);
|
||||
console.log("🍀 luckysheetfile:", luckysheetfile);
|
||||
resolve(exportJson);
|
||||
},
|
||||
// 에러 콜백
|
||||
(error: any) => {
|
||||
console.error("❌ LuckyExcel 변환 실패:", error);
|
||||
reject(new Error(`LuckyExcel 변환 실패: ${error}`));
|
||||
},
|
||||
);
|
||||
} catch (callError) {
|
||||
console.error("❌ LuckyExcel 호출 중 오류:", callError);
|
||||
reject(callError);
|
||||
}
|
||||
});
|
||||
|
||||
// 결과 검증
|
||||
if (
|
||||
!luckyExcelResult ||
|
||||
!luckyExcelResult.sheets ||
|
||||
!Array.isArray(luckyExcelResult.sheets)
|
||||
) {
|
||||
throw new Error("LuckyExcel 변환 결과가 유효하지 않습니다.");
|
||||
}
|
||||
|
||||
console.log("🎉 LuckyExcel 변환 완료, Luckysheet 생성 중...");
|
||||
|
||||
// 메모리 정보 기반: exportJson.sheets를 그대로 사용
|
||||
// luckysheet.create({ data: exportJson.sheets })
|
||||
window.luckysheet.create({
|
||||
container: containerRef.current?.id || "luckysheet-container",
|
||||
showinfobar: true,
|
||||
showtoolbar: true,
|
||||
showsheetbar: true,
|
||||
showstatisticBar: true,
|
||||
allowCopy: true,
|
||||
allowEdit: true,
|
||||
// 🚨 핵심: LuckyExcel의 원본 변환 결과를 직접 사용
|
||||
data: luckyExcelResult.sheets, // 가공하지 않고 그대로 전달
|
||||
title: luckyExcelResult.info?.name || fileName,
|
||||
// 🚨 수정: userInfo 경로 수정
|
||||
userInfo: luckyExcelResult.info?.creator || false,
|
||||
});
|
||||
|
||||
console.log("🎉 Luckysheet 생성 완료! (원본 데이터 직접 사용)");
|
||||
setIsInitialized(true);
|
||||
setIsConverting(false);
|
||||
setError(null);
|
||||
luckysheetRef.current = window.luckysheet;
|
||||
} catch (conversionError) {
|
||||
console.error("❌ 변환 프로세스 실패:", conversionError);
|
||||
setError(
|
||||
`변환 프로세스에 실패했습니다: ${
|
||||
conversionError instanceof Error
|
||||
? conversionError.message
|
||||
: String(conversionError)
|
||||
}`,
|
||||
);
|
||||
setIsConverting(false);
|
||||
setIsInitialized(false);
|
||||
}
|
||||
},
|
||||
[loadLuckysheetLibrary, setSelectedRange],
|
||||
);
|
||||
|
||||
/**
|
||||
* DOM 컨테이너 준비 상태 체크 - useLayoutEffect로 동기적 체크
|
||||
*/
|
||||
useLayoutEffect(() => {
|
||||
if (containerRef.current) {
|
||||
console.log("✅ DOM 컨테이너 준비 완료:", containerRef.current.id);
|
||||
setIsContainerReady(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* DOM 컨테이너 준비 상태 재체크 (fallback)
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isContainerReady) {
|
||||
const timer = setTimeout(() => {
|
||||
if (containerRef.current && !isContainerReady) {
|
||||
console.log("✅ useEffect: DOM 컨테이너 지연 준비 완료");
|
||||
setIsContainerReady(true);
|
||||
}
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isContainerReady]);
|
||||
|
||||
/**
|
||||
* 컴포넌트 마운트 시 초기화
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (
|
||||
currentFile?.xlsxBuffer &&
|
||||
isContainerReady &&
|
||||
containerRef.current &&
|
||||
!isInitialized &&
|
||||
!isConverting
|
||||
) {
|
||||
console.log("🔄 XLSX 버퍼 감지, LuckyExcel 직접 변환 시작...", {
|
||||
fileName: currentFile.name,
|
||||
bufferSize: currentFile.xlsxBuffer.byteLength,
|
||||
containerId: containerRef.current.id,
|
||||
});
|
||||
|
||||
// 중복 실행 방지
|
||||
setIsConverting(true);
|
||||
|
||||
// LuckyExcel로 직접 변환
|
||||
convertXLSXWithLuckyExcel(currentFile.xlsxBuffer, currentFile.name);
|
||||
} else if (currentFile && !currentFile.xlsxBuffer) {
|
||||
setError("파일 변환 데이터가 없습니다. 파일을 다시 업로드해주세요.");
|
||||
}
|
||||
}, [
|
||||
currentFile?.xlsxBuffer,
|
||||
currentFile?.name,
|
||||
isContainerReady,
|
||||
isInitialized,
|
||||
isConverting,
|
||||
convertXLSXWithLuckyExcel,
|
||||
]);
|
||||
|
||||
/**
|
||||
* 컴포넌트 언마운트 시 정리
|
||||
*/
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (luckysheetRef.current && window.luckysheet) {
|
||||
try {
|
||||
window.luckysheet.destroy();
|
||||
} catch (error) {
|
||||
console.warn("⚠️ Luckysheet 정리 중 오류:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 윈도우 리사이즈 처리
|
||||
*/
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (luckysheetRef.current && window.luckysheet) {
|
||||
try {
|
||||
if (window.luckysheet.resize) {
|
||||
window.luckysheet.resize();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("⚠️ Luckysheet 리사이즈 중 오류:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-full h-full min-h-[70vh] ${className || ""}`}
|
||||
style={{ position: "relative" }}
|
||||
>
|
||||
{/* Luckysheet 컨테이너 - 항상 렌더링 */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
id="luckysheet-container"
|
||||
className="w-full h-full"
|
||||
style={{
|
||||
minHeight: "70vh",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: "8px",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 에러 상태 오버레이 */}
|
||||
{error && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-red-50 border border-red-200 rounded-lg">
|
||||
<div className="text-center p-6">
|
||||
<div className="text-red-600 text-lg font-semibold mb-2">
|
||||
시트 로드 오류
|
||||
</div>
|
||||
<div className="text-red-500 text-sm mb-4">{error}</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setIsInitialized(false);
|
||||
setIsConverting(false);
|
||||
if (currentFile?.xlsxBuffer) {
|
||||
convertXLSXWithLuckyExcel(
|
||||
currentFile.xlsxBuffer,
|
||||
currentFile.name,
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="mt-4 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 로딩 상태 오버레이 */}
|
||||
{!error &&
|
||||
(isConverting || !isInitialized) &&
|
||||
currentFile?.xlsxBuffer && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="text-center p-6">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<div className="text-blue-600 text-lg font-semibold mb-2">
|
||||
{isConverting ? "LuckyExcel 변환 중..." : "시트 초기화 중..."}
|
||||
</div>
|
||||
<div className="text-blue-500 text-sm">
|
||||
{isConverting
|
||||
? "원본 Excel 데이터를 완전한 스타일로 변환하고 있습니다."
|
||||
: "잠시만 기다려주세요."}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 데이터 없음 상태 오버레이 */}
|
||||
{!error && !currentFile?.xlsxBuffer && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-50 border border-gray-200 rounded-lg">
|
||||
<div className="text-center p-6">
|
||||
<div className="text-gray-500 text-lg font-semibold mb-2">
|
||||
표시할 시트가 없습니다
|
||||
</div>
|
||||
<div className="text-gray-400 text-sm">
|
||||
Excel 파일을 업로드해주세요.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 시트 정보 표시 (개발용) */}
|
||||
{process.env.NODE_ENV === "development" && (
|
||||
<div className="absolute top-2 right-2 bg-black bg-opacity-75 text-white text-xs p-2 rounded z-10">
|
||||
<div>파일: {currentFile?.name}</div>
|
||||
<div>
|
||||
XLSX 버퍼:{" "}
|
||||
{currentFile?.xlsxBuffer
|
||||
? `${currentFile.xlsxBuffer.byteLength} bytes`
|
||||
: "없음"}
|
||||
</div>
|
||||
<div>변환 중: {isConverting ? "예" : "아니오"}</div>
|
||||
<div>초기화: {isInitialized ? "완료" : "대기"}</div>
|
||||
<div>컨테이너 준비: {isContainerReady ? "완료" : "대기"}</div>
|
||||
<div>방식: LuckyExcel 직접 변환</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,431 +0,0 @@
|
||||
// import React from "react";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "@testing-library/jest-dom";
|
||||
import { vi } from "vitest";
|
||||
import { FileUpload } from "../FileUpload";
|
||||
import { useAppStore } from "../../../stores/useAppStore";
|
||||
import * as fileProcessor from "../../../utils/fileProcessor";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("../../../stores/useAppStore");
|
||||
|
||||
// Mock DragEvent for testing environment
|
||||
class MockDragEvent extends Event {
|
||||
dataTransfer: DataTransfer;
|
||||
|
||||
constructor(
|
||||
type: string,
|
||||
options: { bubbles?: boolean; dataTransfer?: any } = {},
|
||||
) {
|
||||
super(type, { bubbles: options.bubbles });
|
||||
this.dataTransfer = options.dataTransfer || {
|
||||
items: options.dataTransfer?.items || [],
|
||||
files: options.dataTransfer?.files || [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
global.DragEvent = MockDragEvent;
|
||||
|
||||
const mockUseAppStore = useAppStore as any;
|
||||
|
||||
describe("FileUpload", () => {
|
||||
const mockSetLoading = vi.fn();
|
||||
const mockSetError = vi.fn();
|
||||
const mockUploadFile = vi.fn();
|
||||
const mockClearFileUploadErrors = vi.fn();
|
||||
const mockAddFileUploadError = vi.fn();
|
||||
|
||||
// Mock fileProcessor functions
|
||||
const mockProcessExcelFile = vi.fn();
|
||||
const mockGetFileErrors = vi.fn();
|
||||
const mockFilterValidFiles = vi.fn();
|
||||
|
||||
const defaultStoreState = {
|
||||
isLoading: false,
|
||||
error: null,
|
||||
fileUploadErrors: [],
|
||||
currentFile: null,
|
||||
setLoading: mockSetLoading,
|
||||
setError: mockSetError,
|
||||
uploadFile: mockUploadFile,
|
||||
clearFileUploadErrors: mockClearFileUploadErrors,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseAppStore.mockReturnValue(defaultStoreState);
|
||||
// @ts-ignore
|
||||
mockUseAppStore.getState = vi.fn().mockReturnValue({
|
||||
addFileUploadError: mockAddFileUploadError,
|
||||
});
|
||||
|
||||
// Mock fileProcessor functions
|
||||
vi.spyOn(fileProcessor, "processExcelFile").mockImplementation(
|
||||
mockProcessExcelFile,
|
||||
);
|
||||
vi.spyOn(fileProcessor, "getFileErrors").mockImplementation(
|
||||
mockGetFileErrors,
|
||||
);
|
||||
vi.spyOn(fileProcessor, "filterValidFiles").mockImplementation(
|
||||
mockFilterValidFiles,
|
||||
);
|
||||
|
||||
// Default mock implementations
|
||||
mockGetFileErrors.mockReturnValue([]);
|
||||
mockFilterValidFiles.mockReturnValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("초기 렌더링", () => {
|
||||
it("기본 업로드 UI를 렌더링한다", () => {
|
||||
render(<FileUpload />);
|
||||
|
||||
expect(screen.getByText("Excel 파일을 업로드하세요")).toBeInTheDocument();
|
||||
expect(screen.getByText(".xlsx, .xls")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("파일을 드래그 앤 드롭하거나 클릭하여 업로드"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("파일 업로드 영역")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("파일 입력 요소가 올바르게 설정된다", () => {
|
||||
render(<FileUpload />);
|
||||
|
||||
const fileInput = screen.getByLabelText("파일 선택");
|
||||
expect(fileInput).toBeInTheDocument();
|
||||
expect(fileInput).toHaveAttribute("type", "file");
|
||||
expect(fileInput).toHaveAttribute("accept", ".xlsx,.xls");
|
||||
});
|
||||
});
|
||||
|
||||
describe("로딩 상태", () => {
|
||||
it("로딩 중일 때 로딩 UI를 표시한다", () => {
|
||||
mockUseAppStore.mockReturnValue({
|
||||
...defaultStoreState,
|
||||
isLoading: true,
|
||||
});
|
||||
|
||||
render(<FileUpload />);
|
||||
|
||||
expect(screen.getByText("파일 처리 중...")).toBeInTheDocument();
|
||||
expect(screen.getByText("잠시만 기다려주세요...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("로딩 중일 때 파일 업로드 영역이 비활성화된다", () => {
|
||||
mockUseAppStore.mockReturnValue({
|
||||
...defaultStoreState,
|
||||
isLoading: true,
|
||||
});
|
||||
|
||||
render(<FileUpload />);
|
||||
|
||||
const uploadArea = screen.getByLabelText("파일 업로드 영역");
|
||||
expect(uploadArea).toHaveAttribute("tabindex", "-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("에러 상태", () => {
|
||||
it("에러가 있을 때 에러 UI를 표시한다", () => {
|
||||
const errorMessage = "파일 업로드에 실패했습니다.";
|
||||
mockUseAppStore.mockReturnValue({
|
||||
...defaultStoreState,
|
||||
error: errorMessage,
|
||||
});
|
||||
|
||||
render(<FileUpload />);
|
||||
|
||||
expect(screen.getByText("업로드 오류")).toBeInTheDocument();
|
||||
expect(screen.getByText(errorMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("파일 업로드 에러 목록을 표시한다", () => {
|
||||
const fileUploadErrors = [
|
||||
{ fileName: "test1.txt", error: "지원되지 않는 파일 형식입니다." },
|
||||
{ fileName: "test2.pdf", error: "파일 크기가 너무 큽니다." },
|
||||
];
|
||||
|
||||
mockUseAppStore.mockReturnValue({
|
||||
...defaultStoreState,
|
||||
fileUploadErrors,
|
||||
});
|
||||
|
||||
render(<FileUpload />);
|
||||
|
||||
expect(screen.getByText("파일 업로드 오류:")).toBeInTheDocument();
|
||||
expect(screen.getByText("test1.txt")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/지원되지 않는 파일 형식입니다/),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("test2.pdf")).toBeInTheDocument();
|
||||
expect(screen.getByText(/파일 크기가 너무 큽니다/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("성공 상태", () => {
|
||||
it("파일 업로드 성공 시 성공 UI를 표시한다", () => {
|
||||
const currentFile = {
|
||||
name: "test.xlsx",
|
||||
size: 1024 * 1024, // 1MB
|
||||
uploadedAt: new Date(),
|
||||
};
|
||||
|
||||
mockUseAppStore.mockReturnValue({
|
||||
...defaultStoreState,
|
||||
currentFile,
|
||||
});
|
||||
|
||||
render(<FileUpload />);
|
||||
|
||||
expect(screen.getByText("파일 업로드 완료")).toBeInTheDocument();
|
||||
expect(screen.getByText("test.xlsx")).toBeInTheDocument();
|
||||
expect(screen.getByText("파일 크기: 1.00 MB")).toBeInTheDocument();
|
||||
expect(screen.getByText("다른 파일 업로드")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("파일 선택", () => {
|
||||
it("파일 선택 버튼 클릭 시 파일 입력을 트리거한다", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileUpload />);
|
||||
|
||||
const uploadArea = screen.getByLabelText("파일 업로드 영역");
|
||||
const fileInput = screen.getByLabelText("파일 선택");
|
||||
|
||||
const clickSpy = vi.spyOn(fileInput, "click");
|
||||
|
||||
await user.click(uploadArea);
|
||||
|
||||
expect(clickSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("키보드 이벤트(Enter)로 파일 선택을 트리거한다", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileUpload />);
|
||||
|
||||
const uploadArea = screen.getByLabelText("파일 업로드 영역");
|
||||
const fileInput = screen.getByLabelText("파일 선택");
|
||||
|
||||
const clickSpy = vi.spyOn(fileInput, "click");
|
||||
|
||||
uploadArea.focus();
|
||||
await user.keyboard("{Enter}");
|
||||
|
||||
expect(clickSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("키보드 이벤트(Space)로 파일 선택을 트리거한다", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileUpload />);
|
||||
|
||||
const uploadArea = screen.getByLabelText("파일 업로드 영역");
|
||||
const fileInput = screen.getByLabelText("파일 선택");
|
||||
|
||||
const clickSpy = vi.spyOn(fileInput, "click");
|
||||
|
||||
uploadArea.focus();
|
||||
await user.keyboard(" ");
|
||||
|
||||
expect(clickSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("파일 처리", () => {
|
||||
it("유효한 파일 업로드 시 파일 처리 함수를 호출한다", async () => {
|
||||
const mockFile = new File(["test content"], "test.xlsx", {
|
||||
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
});
|
||||
|
||||
const successResult = {
|
||||
success: true,
|
||||
data: [{ id: "sheet1", name: "Sheet1", data: [] }],
|
||||
fileName: "test.xlsx",
|
||||
fileSize: 1024,
|
||||
};
|
||||
|
||||
// Mock valid file
|
||||
mockFilterValidFiles.mockReturnValue([mockFile]);
|
||||
mockGetFileErrors.mockReturnValue([]);
|
||||
mockProcessExcelFile.mockResolvedValue(successResult);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<FileUpload />);
|
||||
|
||||
const fileInput = screen.getByLabelText("파일 선택");
|
||||
|
||||
await user.upload(fileInput, mockFile);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockProcessExcelFile).toHaveBeenCalledWith(mockFile);
|
||||
expect(mockSetLoading).toHaveBeenCalledWith(
|
||||
true,
|
||||
"파일을 처리하는 중...",
|
||||
);
|
||||
expect(mockUploadFile).toHaveBeenCalledWith(successResult);
|
||||
});
|
||||
});
|
||||
|
||||
it("파일 처리 실패 시 에러 처리를 한다", async () => {
|
||||
const mockFile = new File(["test content"], "test.xlsx", {
|
||||
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
});
|
||||
|
||||
const errorResult = {
|
||||
success: false,
|
||||
error: "파일 형식이 올바르지 않습니다.",
|
||||
fileName: "test.xlsx",
|
||||
fileSize: 1024,
|
||||
};
|
||||
|
||||
// Mock valid file but processing fails
|
||||
mockFilterValidFiles.mockReturnValue([mockFile]);
|
||||
mockGetFileErrors.mockReturnValue([]);
|
||||
mockProcessExcelFile.mockResolvedValue(errorResult);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<FileUpload />);
|
||||
|
||||
const fileInput = screen.getByLabelText("파일 선택");
|
||||
|
||||
await user.upload(fileInput, mockFile);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUploadFile).toHaveBeenCalledWith(errorResult);
|
||||
});
|
||||
});
|
||||
|
||||
it("파일 처리 중 예외 발생 시 에러 처리를 한다", async () => {
|
||||
const mockFile = new File(["test content"], "test.xlsx", {
|
||||
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
});
|
||||
|
||||
// Mock valid file but processing throws
|
||||
mockFilterValidFiles.mockReturnValue([mockFile]);
|
||||
mockGetFileErrors.mockReturnValue([]);
|
||||
mockProcessExcelFile.mockRejectedValue(new Error("Unexpected error"));
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<FileUpload />);
|
||||
|
||||
const fileInput = screen.getByLabelText("파일 선택");
|
||||
|
||||
await user.upload(fileInput, mockFile);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetError).toHaveBeenCalledWith(
|
||||
"파일 처리 중 예상치 못한 오류가 발생했습니다.",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("드래그 앤 드롭", () => {
|
||||
it("드래그 엔터 시 드래그 오버 상태를 활성화한다", async () => {
|
||||
render(<FileUpload />);
|
||||
|
||||
const uploadArea = screen.getByLabelText("파일 업로드 영역");
|
||||
|
||||
const dragEnterEvent = new DragEvent("dragenter", {
|
||||
bubbles: true,
|
||||
dataTransfer: {
|
||||
items: [{ kind: "file" }],
|
||||
},
|
||||
});
|
||||
|
||||
fireEvent(uploadArea, dragEnterEvent);
|
||||
|
||||
// 드래그 오버 상태 확인 (드래그 오버 시 특별한 스타일이 적용됨)
|
||||
expect(uploadArea).toHaveClass(
|
||||
"border-blue-500",
|
||||
"bg-blue-100",
|
||||
"scale-105",
|
||||
);
|
||||
});
|
||||
|
||||
it("드래그 리브 시 드래그 오버 상태를 비활성화한다", async () => {
|
||||
render(<FileUpload />);
|
||||
|
||||
const uploadArea = screen.getByLabelText("파일 업로드 영역");
|
||||
|
||||
// 먼저 드래그 엔터
|
||||
const dragEnterEvent = new DragEvent("dragenter", {
|
||||
bubbles: true,
|
||||
dataTransfer: {
|
||||
items: [{ kind: "file" }],
|
||||
},
|
||||
});
|
||||
|
||||
fireEvent(uploadArea, dragEnterEvent);
|
||||
|
||||
// 드래그 리브
|
||||
const dragLeaveEvent = new DragEvent("dragleave", {
|
||||
bubbles: true,
|
||||
});
|
||||
|
||||
fireEvent(uploadArea, dragLeaveEvent);
|
||||
|
||||
expect(uploadArea).toHaveClass("border-gray-300");
|
||||
});
|
||||
|
||||
it("파일 드롭 시 파일 처리를 실행한다", async () => {
|
||||
const mockFile = new File(["test content"], "test.xlsx", {
|
||||
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
});
|
||||
|
||||
// Mock valid file
|
||||
mockFilterValidFiles.mockReturnValue([mockFile]);
|
||||
mockGetFileErrors.mockReturnValue([]);
|
||||
|
||||
render(<FileUpload />);
|
||||
|
||||
const uploadArea = screen.getByLabelText("파일 업로드 영역");
|
||||
|
||||
const dropEvent = new DragEvent("drop", {
|
||||
bubbles: true,
|
||||
dataTransfer: {
|
||||
files: [mockFile],
|
||||
},
|
||||
});
|
||||
|
||||
fireEvent(uploadArea, dropEvent);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockProcessExcelFile).toHaveBeenCalledWith(mockFile);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("접근성", () => {
|
||||
it("ARIA 라벨과 설명이 올바르게 설정된다", () => {
|
||||
render(<FileUpload />);
|
||||
|
||||
const uploadArea = screen.getByLabelText("파일 업로드 영역");
|
||||
expect(uploadArea).toHaveAttribute(
|
||||
"aria-describedby",
|
||||
"upload-instructions",
|
||||
);
|
||||
expect(uploadArea).toHaveAttribute("role", "button");
|
||||
|
||||
const instructions = screen.getByText("최대 50MB까지 업로드 가능");
|
||||
expect(instructions).toHaveAttribute("id", "upload-instructions");
|
||||
});
|
||||
|
||||
it("로딩 중일 때 접근성 속성이 올바르게 설정된다", () => {
|
||||
mockUseAppStore.mockReturnValue({
|
||||
...defaultStoreState,
|
||||
isLoading: true,
|
||||
});
|
||||
|
||||
render(<FileUpload />);
|
||||
|
||||
const uploadArea = screen.getByLabelText("파일 업로드 영역");
|
||||
expect(uploadArea).toHaveAttribute("tabindex", "-1");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,402 +0,0 @@
|
||||
// import React from "react";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { vi } from "vitest";
|
||||
import { SheetViewer } from "../SheetViewer";
|
||||
import { useAppStore } from "../../../stores/useAppStore";
|
||||
import type { SheetData } from "../../../types/sheet";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("../../../stores/useAppStore");
|
||||
|
||||
// Luckysheet 모킹
|
||||
const mockLuckysheet = {
|
||||
create: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
getSheet: vi.fn(),
|
||||
getAllSheets: vi.fn(),
|
||||
setActiveSheet: vi.fn(),
|
||||
};
|
||||
|
||||
// Window.luckysheet 모킹
|
||||
Object.defineProperty(window, "luckysheet", {
|
||||
value: mockLuckysheet,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// useAppStore 모킹 타입
|
||||
const mockUseAppStore = vi.mocked(useAppStore);
|
||||
|
||||
// 기본 스토어 상태
|
||||
const defaultStoreState = {
|
||||
sheets: [],
|
||||
activeSheetId: null,
|
||||
currentFile: null,
|
||||
setSelectedRange: vi.fn(),
|
||||
isLoading: false,
|
||||
error: null,
|
||||
setLoading: vi.fn(),
|
||||
setError: vi.fn(),
|
||||
uploadFile: vi.fn(),
|
||||
clearFileUploadErrors: vi.fn(),
|
||||
resetApp: vi.fn(),
|
||||
};
|
||||
|
||||
// 테스트용 시트 데이터
|
||||
const mockSheetData: SheetData[] = [
|
||||
{
|
||||
id: "sheet_0",
|
||||
name: "Sheet1",
|
||||
data: [
|
||||
["A1", "B1", "C1"],
|
||||
["A2", "B2", "C2"],
|
||||
],
|
||||
config: {
|
||||
container: "luckysheet_0",
|
||||
title: "Sheet1",
|
||||
lang: "ko",
|
||||
data: [
|
||||
{
|
||||
name: "Sheet1",
|
||||
index: "0",
|
||||
celldata: [
|
||||
{
|
||||
r: 0,
|
||||
c: 0,
|
||||
v: { v: "A1", m: "A1", ct: { fa: "General", t: "g" } },
|
||||
},
|
||||
{
|
||||
r: 0,
|
||||
c: 1,
|
||||
v: { v: "B1", m: "B1", ct: { fa: "General", t: "g" } },
|
||||
},
|
||||
],
|
||||
status: 1,
|
||||
order: 0,
|
||||
row: 2,
|
||||
column: 3,
|
||||
},
|
||||
],
|
||||
options: {
|
||||
showtoolbar: true,
|
||||
showinfobar: false,
|
||||
showsheetbar: true,
|
||||
showstatisticBar: false,
|
||||
allowCopy: true,
|
||||
allowEdit: true,
|
||||
enableAddRow: true,
|
||||
enableAddCol: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe("SheetViewer", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseAppStore.mockReturnValue(defaultStoreState);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// DOM 정리
|
||||
document.head.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("초기 렌더링", () => {
|
||||
it("시트 데이터가 없을 때 적절한 메시지를 표시한다", () => {
|
||||
mockUseAppStore.mockReturnValue({
|
||||
...defaultStoreState,
|
||||
sheets: [],
|
||||
});
|
||||
|
||||
render(<SheetViewer />);
|
||||
|
||||
expect(screen.getByText("표시할 시트가 없습니다")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Excel 파일을 업로드해주세요."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("시트 데이터가 있을 때 로딩 상태를 표시한다", () => {
|
||||
mockUseAppStore.mockReturnValue({
|
||||
...defaultStoreState,
|
||||
sheets: mockSheetData,
|
||||
currentFile: { name: "test.xlsx", size: 1000, uploadedAt: new Date() },
|
||||
});
|
||||
|
||||
render(<SheetViewer />);
|
||||
|
||||
expect(screen.getByText("시트 로딩 중...")).toBeInTheDocument();
|
||||
expect(screen.getByText("잠시만 기다려주세요.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Luckysheet 컨테이너가 올바르게 렌더링된다", () => {
|
||||
mockUseAppStore.mockReturnValue({
|
||||
...defaultStoreState,
|
||||
sheets: mockSheetData,
|
||||
currentFile: { name: "test.xlsx", size: 1000, uploadedAt: new Date() },
|
||||
});
|
||||
|
||||
render(<SheetViewer />);
|
||||
|
||||
const container = document.getElementById("luckysheet-container");
|
||||
expect(container).toBeInTheDocument();
|
||||
expect(container).toHaveClass("w-full", "h-full");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Luckysheet 초기화", () => {
|
||||
it("시트 데이터가 변경되면 Luckysheet를 초기화한다", async () => {
|
||||
mockUseAppStore.mockReturnValue({
|
||||
...defaultStoreState,
|
||||
sheets: mockSheetData,
|
||||
currentFile: { name: "test.xlsx", size: 1000, uploadedAt: new Date() },
|
||||
});
|
||||
|
||||
render(<SheetViewer />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockLuckysheet.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// create 호출 시 전달된 설정 확인
|
||||
const createCall = mockLuckysheet.create.mock.calls[0];
|
||||
expect(createCall).toBeDefined();
|
||||
|
||||
const config = createCall[0];
|
||||
expect(config.container).toBe("luckysheet-container");
|
||||
expect(config.title).toBe("test.xlsx");
|
||||
expect(config.lang).toBe("ko");
|
||||
expect(config.data).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("기존 Luckysheet 인스턴스가 있으면 제거한다", async () => {
|
||||
mockUseAppStore.mockReturnValue({
|
||||
...defaultStoreState,
|
||||
sheets: mockSheetData,
|
||||
currentFile: { name: "test.xlsx", size: 1000, uploadedAt: new Date() },
|
||||
});
|
||||
|
||||
const { rerender } = render(<SheetViewer />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockLuckysheet.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// 시트 데이터 변경
|
||||
const newSheetData: SheetData[] = [
|
||||
{
|
||||
...mockSheetData[0],
|
||||
name: "NewSheet",
|
||||
},
|
||||
];
|
||||
|
||||
mockUseAppStore.mockReturnValue({
|
||||
...defaultStoreState,
|
||||
sheets: newSheetData,
|
||||
currentFile: { name: "new.xlsx", size: 1000, uploadedAt: new Date() },
|
||||
});
|
||||
|
||||
rerender(<SheetViewer />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockLuckysheet.destroy).toHaveBeenCalled();
|
||||
expect(mockLuckysheet.create).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("에러 처리", () => {
|
||||
it("Luckysheet 초기화 실패 시 에러 메시지를 표시한다", async () => {
|
||||
mockUseAppStore.mockReturnValue({
|
||||
...defaultStoreState,
|
||||
sheets: mockSheetData,
|
||||
currentFile: { name: "test.xlsx", size: 1000, uploadedAt: new Date() },
|
||||
});
|
||||
|
||||
// Luckysheet.create에서 에러 발생 시뮬레이션
|
||||
mockLuckysheet.create.mockImplementation(() => {
|
||||
throw new Error("Luckysheet 초기화 실패");
|
||||
});
|
||||
|
||||
render(<SheetViewer />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("시트 로드 오류")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/시트 초기화에 실패했습니다/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 다시 시도 버튼 확인
|
||||
const retryButton = screen.getByRole("button", { name: "다시 시도" });
|
||||
expect(retryButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("다시 시도 버튼을 클릭하면 초기화를 재시도한다", async () => {
|
||||
mockUseAppStore.mockReturnValue({
|
||||
...defaultStoreState,
|
||||
sheets: mockSheetData,
|
||||
currentFile: { name: "test.xlsx", size: 1000, uploadedAt: new Date() },
|
||||
});
|
||||
|
||||
// 첫 번째 시도에서 실패
|
||||
mockLuckysheet.create.mockImplementationOnce(() => {
|
||||
throw new Error("첫 번째 실패");
|
||||
});
|
||||
|
||||
render(<SheetViewer />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("시트 로드 오류")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 두 번째 시도에서 성공하도록 설정
|
||||
mockLuckysheet.create.mockImplementationOnce(() => {
|
||||
// 성공
|
||||
});
|
||||
|
||||
const retryButton = screen.getByRole("button", { name: "다시 시도" });
|
||||
retryButton.click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockLuckysheet.create).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("이벤트 핸들링", () => {
|
||||
it("셀 클릭 시 선택된 범위를 스토어에 저장한다", async () => {
|
||||
const mockSetSelectedRange = vi.fn();
|
||||
mockUseAppStore.mockReturnValue({
|
||||
...defaultStoreState,
|
||||
sheets: mockSheetData,
|
||||
currentFile: { name: "test.xlsx", size: 1000, uploadedAt: new Date() },
|
||||
setSelectedRange: mockSetSelectedRange,
|
||||
});
|
||||
|
||||
render(<SheetViewer />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockLuckysheet.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// create 호출 시 전달된 hook 확인
|
||||
const createCall = mockLuckysheet.create.mock.calls[0];
|
||||
const config = createCall[0];
|
||||
|
||||
// cellClick 핸들러 시뮬레이션
|
||||
const cellClickHandler = config.hook.cellClick;
|
||||
expect(cellClickHandler).toBeDefined();
|
||||
|
||||
const mockCell = {};
|
||||
const mockPosition = { r: 1, c: 2 };
|
||||
const mockSheetFile = { index: "0" };
|
||||
|
||||
cellClickHandler(mockCell, mockPosition, mockSheetFile);
|
||||
|
||||
expect(mockSetSelectedRange).toHaveBeenCalledWith({
|
||||
range: {
|
||||
startRow: 1,
|
||||
startCol: 2,
|
||||
endRow: 1,
|
||||
endCol: 2,
|
||||
},
|
||||
sheetId: "0",
|
||||
});
|
||||
});
|
||||
|
||||
it("시트 활성화 시 활성 시트 ID를 업데이트한다", async () => {
|
||||
mockUseAppStore.mockReturnValue({
|
||||
...defaultStoreState,
|
||||
sheets: mockSheetData,
|
||||
currentFile: { name: "test.xlsx", size: 1000, uploadedAt: new Date() },
|
||||
});
|
||||
|
||||
// setActiveSheetId를 spy로 설정
|
||||
const setActiveSheetIdSpy = vi.fn();
|
||||
useAppStore.getState = vi.fn().mockReturnValue({
|
||||
setActiveSheetId: setActiveSheetIdSpy,
|
||||
});
|
||||
|
||||
render(<SheetViewer />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockLuckysheet.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// create 호출 시 전달된 hook 확인
|
||||
const createCall = mockLuckysheet.create.mock.calls[0];
|
||||
const config = createCall[0];
|
||||
|
||||
// sheetActivate 핸들러 시뮬레이션
|
||||
const sheetActivateHandler = config.hook.sheetActivate;
|
||||
expect(sheetActivateHandler).toBeDefined();
|
||||
|
||||
sheetActivateHandler(0, false, false);
|
||||
|
||||
expect(setActiveSheetIdSpy).toHaveBeenCalledWith("sheet_0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("컴포넌트 생명주기", () => {
|
||||
it("컴포넌트 언마운트 시 Luckysheet를 정리한다", async () => {
|
||||
mockUseAppStore.mockReturnValue({
|
||||
...defaultStoreState,
|
||||
sheets: mockSheetData,
|
||||
currentFile: { name: "test.xlsx", size: 1000, uploadedAt: new Date() },
|
||||
});
|
||||
|
||||
const { unmount } = render(<SheetViewer />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockLuckysheet.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
expect(mockLuckysheet.destroy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("윈도우 리사이즈 시 Luckysheet 리사이즈를 호출한다", async () => {
|
||||
mockUseAppStore.mockReturnValue({
|
||||
...defaultStoreState,
|
||||
sheets: mockSheetData,
|
||||
currentFile: { name: "test.xlsx", size: 1000, uploadedAt: new Date() },
|
||||
});
|
||||
|
||||
render(<SheetViewer />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockLuckysheet.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// 윈도우 리사이즈 이벤트 시뮬레이션
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
|
||||
expect(mockLuckysheet.resize).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("개발 모드 정보", () => {
|
||||
it("개발 모드에서 시트 정보를 표시한다", () => {
|
||||
const originalEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = "development";
|
||||
|
||||
mockUseAppStore.mockReturnValue({
|
||||
...defaultStoreState,
|
||||
sheets: mockSheetData,
|
||||
activeSheetId: "sheet_0",
|
||||
currentFile: { name: "test.xlsx", size: 1000, uploadedAt: new Date() },
|
||||
});
|
||||
|
||||
render(<SheetViewer />);
|
||||
|
||||
expect(screen.getByText("시트 개수: 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("활성 시트: sheet_0")).toBeInTheDocument();
|
||||
|
||||
process.env.NODE_ENV = originalEnv;
|
||||
});
|
||||
});
|
||||
});
|
||||
29
src/components/ui/homeButton.tsx
Normal file
29
src/components/ui/homeButton.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from "react";
|
||||
|
||||
interface HomeButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 텍스트만 있는 홈 버튼 컴포넌트
|
||||
* - 파란색, 볼드, hover 시 underline
|
||||
* - outline/배경 없음
|
||||
* - Button 컴포넌트 의존성 없음
|
||||
*/
|
||||
const HomeButton: React.FC<HomeButtonProps> = ({ children, ...props }) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
{...props}
|
||||
className={
|
||||
"text-2xl font-bold text-blue-600 bg-transparent border-none p-0 m-0 cursor-pointer hover:underline focus:outline-none focus:ring-0 " +
|
||||
(props.className || "")
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomeButton;
|
||||
@@ -1,459 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import * as XLSX from "xlsx-js-style";
|
||||
import {
|
||||
validateFileType,
|
||||
validateFileSize,
|
||||
getFileErrorMessage,
|
||||
filterValidFiles,
|
||||
getFileErrors,
|
||||
processExcelFile,
|
||||
MAX_FILE_SIZE,
|
||||
SUPPORTED_EXTENSIONS,
|
||||
} from "../fileProcessor";
|
||||
|
||||
// xlsx-js-style 모킹 (통합 처리)
|
||||
vi.mock("xlsx-js-style", () => ({
|
||||
read: vi.fn(() => ({
|
||||
SheetNames: ["Sheet1"],
|
||||
Sheets: {
|
||||
Sheet1: {
|
||||
A1: { v: "테스트" },
|
||||
B1: { v: "한글" },
|
||||
C1: { v: "데이터" },
|
||||
"!ref": "A1:C2",
|
||||
},
|
||||
},
|
||||
})),
|
||||
write: vi.fn(() => new ArrayBuffer(1024)), // XLSX.write 모킹 추가
|
||||
utils: {
|
||||
sheet_to_json: vi.fn(() => [
|
||||
["테스트", "한글", "데이터"],
|
||||
["값1", "값2", "값3"],
|
||||
]),
|
||||
decode_range: vi.fn((_ref) => ({
|
||||
s: { r: 0, c: 0 },
|
||||
e: { r: 1, c: 2 },
|
||||
})),
|
||||
encode_cell: vi.fn(
|
||||
(cell) => `${String.fromCharCode(65 + cell.c)}${cell.r + 1}`,
|
||||
),
|
||||
aoa_to_sheet: vi.fn(() => ({
|
||||
A1: { v: "테스트" },
|
||||
B1: { v: "한글" },
|
||||
C1: { v: "데이터" },
|
||||
"!ref": "A1:C1",
|
||||
})),
|
||||
book_new: vi.fn(() => ({ SheetNames: [], Sheets: {} })),
|
||||
book_append_sheet: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// LuckyExcel 모킹
|
||||
vi.mock("luckyexcel", () => ({
|
||||
transformExcelToLucky: vi.fn(
|
||||
(_arrayBuffer, successCallback, _errorCallback) => {
|
||||
// 성공적인 변환 결과 모킹
|
||||
const mockResult = {
|
||||
sheets: [
|
||||
{
|
||||
name: "Sheet1",
|
||||
index: "0",
|
||||
status: 1,
|
||||
order: 0,
|
||||
row: 2,
|
||||
column: 3,
|
||||
celldata: [
|
||||
{
|
||||
r: 0,
|
||||
c: 0,
|
||||
v: { v: "테스트", m: "테스트", ct: { fa: "General", t: "g" } },
|
||||
},
|
||||
{
|
||||
r: 0,
|
||||
c: 1,
|
||||
v: { v: "한글", m: "한글", ct: { fa: "General", t: "g" } },
|
||||
},
|
||||
{
|
||||
r: 0,
|
||||
c: 2,
|
||||
v: { v: "데이터", m: "데이터", ct: { fa: "General", t: "g" } },
|
||||
},
|
||||
{
|
||||
r: 1,
|
||||
c: 0,
|
||||
v: { v: "값1", m: "값1", ct: { fa: "General", t: "g" } },
|
||||
},
|
||||
{
|
||||
r: 1,
|
||||
c: 1,
|
||||
v: { v: "값2", m: "값2", ct: { fa: "General", t: "g" } },
|
||||
},
|
||||
{
|
||||
r: 1,
|
||||
c: 2,
|
||||
v: { v: "값3", m: "값3", ct: { fa: "General", t: "g" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// 성공 콜백 비동기 호출 (ArrayBuffer 매개변수 대응)
|
||||
if (typeof successCallback === "function") {
|
||||
setTimeout(() => successCallback(mockResult, null), 0);
|
||||
}
|
||||
},
|
||||
),
|
||||
}));
|
||||
|
||||
// 파일 생성 도우미 함수
|
||||
function createMockFile(
|
||||
name: string,
|
||||
size: number,
|
||||
type: string = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
): File {
|
||||
const mockFile = new Blob(["mock file content"], { type });
|
||||
Object.defineProperty(mockFile, "name", {
|
||||
value: name,
|
||||
writable: false,
|
||||
});
|
||||
Object.defineProperty(mockFile, "size", {
|
||||
value: size,
|
||||
writable: false,
|
||||
});
|
||||
|
||||
// ArrayBuffer 메서드 모킹
|
||||
Object.defineProperty(mockFile, "arrayBuffer", {
|
||||
value: async () => new ArrayBuffer(size),
|
||||
writable: false,
|
||||
});
|
||||
|
||||
return mockFile as File;
|
||||
}
|
||||
|
||||
// FileList 모킹
|
||||
class MockFileList {
|
||||
private _files: File[];
|
||||
|
||||
constructor(files: File[]) {
|
||||
this._files = files;
|
||||
}
|
||||
|
||||
get length(): number {
|
||||
return this._files.length;
|
||||
}
|
||||
|
||||
item(index: number): File | null {
|
||||
return this._files[index] || null;
|
||||
}
|
||||
|
||||
get files(): FileList {
|
||||
const files = this._files;
|
||||
return Object.assign(files, {
|
||||
item: (index: number) => files[index] || null,
|
||||
[Symbol.iterator]: function* (): Generator<File, void, unknown> {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
yield files[i];
|
||||
}
|
||||
},
|
||||
}) as unknown as FileList;
|
||||
}
|
||||
}
|
||||
|
||||
describe("fileProcessor", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("validateFileType", () => {
|
||||
it("지원하는 파일 확장자를 승인해야 함", () => {
|
||||
const validFiles = [
|
||||
createMockFile("test.xlsx", 1000),
|
||||
createMockFile("test.xls", 1000),
|
||||
createMockFile("test.csv", 1000),
|
||||
createMockFile("한글파일.xlsx", 1000),
|
||||
];
|
||||
|
||||
validFiles.forEach((file) => {
|
||||
expect(validateFileType(file)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("지원하지 않는 파일 확장자를 거부해야 함", () => {
|
||||
const invalidFiles = [
|
||||
createMockFile("test.txt", 1000),
|
||||
createMockFile("test.pdf", 1000),
|
||||
createMockFile("test.doc", 1000),
|
||||
createMockFile("test", 1000),
|
||||
];
|
||||
|
||||
invalidFiles.forEach((file) => {
|
||||
expect(validateFileType(file)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("대소문자를 무시하고 파일 확장자를 검증해야 함", () => {
|
||||
const files = [
|
||||
createMockFile("test.XLSX", 1000),
|
||||
createMockFile("test.XLS", 1000),
|
||||
createMockFile("test.CSV", 1000),
|
||||
];
|
||||
|
||||
files.forEach((file) => {
|
||||
expect(validateFileType(file)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateFileSize", () => {
|
||||
it("허용된 크기의 파일을 승인해야 함", () => {
|
||||
const smallFile = createMockFile("small.xlsx", 1000);
|
||||
expect(validateFileSize(smallFile)).toBe(true);
|
||||
});
|
||||
|
||||
it("최대 크기를 초과한 파일을 거부해야 함", () => {
|
||||
const largeFile = createMockFile("large.xlsx", MAX_FILE_SIZE + 1);
|
||||
expect(validateFileSize(largeFile)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFileErrorMessage", () => {
|
||||
it("유효한 파일에 대해 빈 문자열을 반환해야 함", () => {
|
||||
const validFile = createMockFile("valid.xlsx", 1000);
|
||||
expect(getFileErrorMessage(validFile)).toBe("");
|
||||
});
|
||||
|
||||
it("잘못된 파일 형식에 대해 적절한 오류 메시지를 반환해야 함", () => {
|
||||
const invalidFile = createMockFile("invalid.txt", 1000);
|
||||
const message = getFileErrorMessage(invalidFile);
|
||||
expect(message).toContain("지원되지 않는 파일 형식");
|
||||
expect(message).toContain(SUPPORTED_EXTENSIONS.join(", "));
|
||||
});
|
||||
|
||||
it("파일 크기 초과에 대해 적절한 오류 메시지를 반환해야 함", () => {
|
||||
const largeFile = createMockFile("large.xlsx", MAX_FILE_SIZE + 1);
|
||||
const message = getFileErrorMessage(largeFile);
|
||||
expect(message).toContain("파일 크기가 너무 큽니다");
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterValidFiles", () => {
|
||||
it("유효한 파일들만 필터링해야 함", () => {
|
||||
const fileList = new MockFileList([
|
||||
createMockFile("valid1.xlsx", 1000),
|
||||
createMockFile("invalid.txt", 1000),
|
||||
createMockFile("valid2.csv", 1000),
|
||||
createMockFile("large.xlsx", MAX_FILE_SIZE + 1),
|
||||
]).files;
|
||||
|
||||
const validFiles = filterValidFiles(fileList);
|
||||
expect(validFiles).toHaveLength(2);
|
||||
expect(validFiles[0].name).toBe("valid1.xlsx");
|
||||
expect(validFiles[1].name).toBe("valid2.csv");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFileErrors", () => {
|
||||
it("무효한 파일들의 오류 목록을 반환해야 함", () => {
|
||||
const fileList = new MockFileList([
|
||||
createMockFile("valid.xlsx", 1000),
|
||||
createMockFile("invalid.txt", 1000),
|
||||
createMockFile("large.xlsx", MAX_FILE_SIZE + 1),
|
||||
]).files;
|
||||
|
||||
const errors = getFileErrors(fileList);
|
||||
expect(errors).toHaveLength(2);
|
||||
expect(errors[0].file.name).toBe("invalid.txt");
|
||||
expect(errors[0].error).toContain("지원되지 않는 파일 형식");
|
||||
expect(errors[1].file.name).toBe("large.xlsx");
|
||||
expect(errors[1].error).toContain("파일 크기가 너무 큽니다");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SheetJS 통합 파일 처리", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("XLSX 파일을 성공적으로 처리해야 함", async () => {
|
||||
const xlsxFile = createMockFile(
|
||||
"test.xlsx",
|
||||
1024,
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
);
|
||||
|
||||
const result = await processExcelFile(xlsxFile);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toBeDefined();
|
||||
expect(Array.isArray(result.data)).toBe(true);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data![0].name).toBe("Sheet1");
|
||||
// XLSX 파일은 변환 없이 직접 처리되므로 XLSX.write가 호출되지 않음
|
||||
});
|
||||
|
||||
it("XLS 파일을 성공적으로 처리해야 함", async () => {
|
||||
const xlsFile = createMockFile(
|
||||
"test.xls",
|
||||
1024,
|
||||
"application/vnd.ms-excel",
|
||||
);
|
||||
|
||||
const result = await processExcelFile(xlsFile);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toBeDefined();
|
||||
expect(Array.isArray(result.data)).toBe(true);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data![0].name).toBe("Sheet1");
|
||||
// XLS 파일은 SheetJS를 통해 XLSX로 변환 후 처리
|
||||
expect(XLSX.write).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("CSV 파일을 성공적으로 처리해야 함", async () => {
|
||||
const csvFile = createMockFile("test.csv", 1024, "text/csv");
|
||||
|
||||
const result = await processExcelFile(csvFile);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toBeDefined();
|
||||
expect(Array.isArray(result.data)).toBe(true);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data![0].name).toBe("Sheet1");
|
||||
// CSV 파일은 SheetJS를 통해 XLSX로 변환 후 처리
|
||||
expect(XLSX.write).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("한글 파일명을 올바르게 처리해야 함", async () => {
|
||||
const koreanFile = createMockFile(
|
||||
"한글파일명_테스트데이터.xlsx",
|
||||
1024,
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
);
|
||||
|
||||
const result = await processExcelFile(koreanFile);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toBeDefined();
|
||||
});
|
||||
|
||||
it("빈 파일을 적절히 처리해야 함", async () => {
|
||||
const emptyFile = createMockFile("empty.xlsx", 0);
|
||||
|
||||
const result = await processExcelFile(emptyFile);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("파일이 비어있습니다");
|
||||
});
|
||||
|
||||
it("유효하지 않은 workbook 을 처리해야 함", async () => {
|
||||
(XLSX.read as any).mockReturnValueOnce(null);
|
||||
|
||||
const invalidFile = createMockFile("invalid.xlsx", 1024);
|
||||
const result = await processExcelFile(invalidFile);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("워크북을 생성할 수 없습니다");
|
||||
});
|
||||
|
||||
it("시트가 없는 workbook을 처리해야 함", async () => {
|
||||
(XLSX.read as any).mockReturnValueOnce({
|
||||
SheetNames: [],
|
||||
Sheets: {},
|
||||
});
|
||||
|
||||
const noSheetsFile = createMockFile("no-sheets.xlsx", 1024);
|
||||
const result = await processExcelFile(noSheetsFile);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("시트 이름 정보가 없습니다");
|
||||
});
|
||||
|
||||
it("Sheets 속성이 없는 workbook을 처리해야 함", async () => {
|
||||
(XLSX.read as any).mockReturnValueOnce({
|
||||
SheetNames: ["Sheet1"],
|
||||
// Sheets 속성 누락
|
||||
});
|
||||
|
||||
const corruptedFile = createMockFile("corrupted.xlsx", 1024);
|
||||
const result = await processExcelFile(corruptedFile);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("유효한 시트가 없습니다");
|
||||
});
|
||||
|
||||
it("XLSX.read 실패 시 대체 인코딩을 시도해야 함", async () => {
|
||||
// 첫 번째 호출은 실패, 두 번째 호출은 성공
|
||||
(XLSX.read as any)
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error("UTF-8 read failed");
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
SheetNames: ["Sheet1"],
|
||||
Sheets: {
|
||||
Sheet1: { A1: { v: "성공" } },
|
||||
},
|
||||
});
|
||||
|
||||
const fallbackFile = createMockFile("fallback.csv", 1024, "text/csv");
|
||||
const result = await processExcelFile(fallbackFile);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(XLSX.read).toHaveBeenCalledTimes(2);
|
||||
// CSV 파일은 TextDecoder를 사용하여 문자열로 읽어서 처리
|
||||
expect(XLSX.read).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
type: "string",
|
||||
codepage: 949, // EUC-KR 대체 인코딩
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("모든 읽기 시도가 실패하면 적절한 오류를 반환해야 함", async () => {
|
||||
(XLSX.read as any).mockImplementation(() => {
|
||||
throw new Error("Read completely failed");
|
||||
});
|
||||
|
||||
const failedFile = createMockFile("failed.xlsx", 1024);
|
||||
const result = await processExcelFile(failedFile);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("파일을 읽을 수 없습니다");
|
||||
});
|
||||
|
||||
it("한글 데이터를 올바르게 처리해야 함", async () => {
|
||||
// beforeEach에서 설정된 기본 모킹을 그대로 사용하지만,
|
||||
// 실제로는 시트명이 변경되지 않는 것이 정상 동작입니다.
|
||||
// LuckyExcel에서 변환할 때 시트명은 일반적으로 유지되지만,
|
||||
// 모킹 데이터에서는 "Sheet1"로 설정되어 있으므로 이를 맞춰야 합니다.
|
||||
|
||||
// 한글 데이터가 포함된 시트 모킹
|
||||
(XLSX.read as any).mockReturnValueOnce({
|
||||
SheetNames: ["한글시트"],
|
||||
Sheets: {
|
||||
한글시트: {
|
||||
A1: { v: "이름" },
|
||||
B1: { v: "나이" },
|
||||
C1: { v: "주소" },
|
||||
A2: { v: "김철수" },
|
||||
B2: { v: 30 },
|
||||
C2: { v: "서울시 강남구" },
|
||||
"!ref": "A1:C2",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const koreanDataFile = createMockFile("한글데이터.xlsx", 1024);
|
||||
const result = await processExcelFile(koreanDataFile);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toBeDefined();
|
||||
expect(Array.isArray(result.data)).toBe(true);
|
||||
expect(result.data).toHaveLength(1);
|
||||
// 실제 모킹 데이터에서는 "Sheet1"을 사용하므로 이를 확인합니다.
|
||||
expect(result.data![0].name).toBe("Sheet1"); // 모킹 데이터의 실제 시트명
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,417 +0,0 @@
|
||||
/**
|
||||
* xlsx-js-style 스타일 보존 테스트 유틸리티
|
||||
* - 다양한 스타일이 적용된 Excel 파일 생성
|
||||
* - 스타일 정보 확인 도구
|
||||
*/
|
||||
|
||||
import * as XLSX from "xlsx-js-style";
|
||||
|
||||
/**
|
||||
* 스타일이 적용된 테스트 Excel 파일 생성
|
||||
*/
|
||||
export function createStyledTestExcel(): ArrayBuffer {
|
||||
// 새 워크북 생성
|
||||
const wb = XLSX.utils.book_new();
|
||||
|
||||
// 테스트 데이터 생성 - xlsx-js-style 공식 API 완전 활용
|
||||
const testData = [
|
||||
// 첫 번째 행 - 폰트 스타일 테스트
|
||||
[
|
||||
{
|
||||
v: "굵은 글씨",
|
||||
t: "s",
|
||||
s: {
|
||||
font: {
|
||||
name: "Courier", // 공식 문서 예시
|
||||
sz: 24, // 공식 문서 예시
|
||||
bold: true,
|
||||
color: { rgb: "000000" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
v: "빨간 글씨",
|
||||
t: "s",
|
||||
s: {
|
||||
font: {
|
||||
bold: true,
|
||||
color: { rgb: "FF0000" }, // 공식 문서: {color: {rgb: "FF0000"}}
|
||||
sz: 12,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
v: "테마 색상",
|
||||
t: "s",
|
||||
s: {
|
||||
font: {
|
||||
color: { theme: 4 }, // 공식 문서: {theme: 4} (Blue, Accent 1)
|
||||
sz: 14,
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
// 두 번째 행 - 배경색 테스트
|
||||
[
|
||||
{
|
||||
v: "노란 배경",
|
||||
t: "s",
|
||||
s: {
|
||||
fill: {
|
||||
patternType: "solid",
|
||||
fgColor: { rgb: "FFFF00" }, // 공식 문서: {fgColor: {rgb: "E9E9E9"}}
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
v: "테마 배경",
|
||||
t: "s",
|
||||
s: {
|
||||
fill: {
|
||||
patternType: "solid",
|
||||
fgColor: { theme: 1, tint: 0.4 }, // 공식 문서: {theme: 1, tint: 0.4} ("Blue, Accent 1, Lighter 40%")
|
||||
},
|
||||
font: { color: { rgb: "000000" } },
|
||||
},
|
||||
},
|
||||
{
|
||||
v: "인덱스 색상",
|
||||
t: "s",
|
||||
s: {
|
||||
fill: {
|
||||
patternType: "solid",
|
||||
fgColor: { indexed: 5 }, // Excel 기본 색상표 - 노랑
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
// 세 번째 행 - 테두리 테스트
|
||||
[
|
||||
{
|
||||
v: "얇은 테두리",
|
||||
t: "s",
|
||||
s: {
|
||||
border: {
|
||||
top: { style: "thin", color: { rgb: "000000" } },
|
||||
bottom: { style: "thin", color: { rgb: "000000" } },
|
||||
left: { style: "thin", color: { rgb: "000000" } },
|
||||
right: { style: "thin", color: { rgb: "000000" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
v: "두꺼운 테두리",
|
||||
t: "s",
|
||||
s: {
|
||||
border: {
|
||||
top: { style: "thick", color: { theme: 2 } }, // 테마 색상 사용
|
||||
bottom: { style: "thick", color: { theme: 2 } },
|
||||
left: { style: "thick", color: { theme: 2 } },
|
||||
right: { style: "thick", color: { theme: 2 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
v: "다양한 테두리",
|
||||
t: "s",
|
||||
s: {
|
||||
border: {
|
||||
top: { style: "dotted", color: { indexed: 4 } }, // 인덱스 색상 - 파랑
|
||||
bottom: { style: "dashed", color: { indexed: 4 } },
|
||||
left: { style: "dashDot", color: { indexed: 4 } },
|
||||
right: { style: "double", color: { indexed: 4 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
// 네 번째 행 - 복합 스타일 테스트
|
||||
[
|
||||
{
|
||||
v: "복합 스타일",
|
||||
t: "s",
|
||||
s: {
|
||||
font: {
|
||||
bold: true,
|
||||
italic: true,
|
||||
underline: true,
|
||||
sz: 16,
|
||||
color: { rgb: "FFFFFF" },
|
||||
name: "Courier", // 공식 문서 예시
|
||||
},
|
||||
fill: {
|
||||
patternType: "solid",
|
||||
fgColor: { theme: 7, tint: -0.2 }, // 어두운 보라색
|
||||
},
|
||||
border: {
|
||||
top: { style: "medium", color: { rgb: "FFD700" } },
|
||||
bottom: { style: "medium", color: { rgb: "FFD700" } },
|
||||
left: { style: "medium", color: { rgb: "FFD700" } },
|
||||
right: { style: "medium", color: { rgb: "FFD700" } },
|
||||
},
|
||||
alignment: {
|
||||
horizontal: "center",
|
||||
vertical: "middle",
|
||||
wrapText: true, // 공식 문서: {wrapText: true}
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
v: 1234.567,
|
||||
t: "n",
|
||||
s: {
|
||||
numFmt: "0.00%", // 공식 문서: numFmt 예시
|
||||
alignment: { horizontal: "right" },
|
||||
font: { bold: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
v: "줄바꿈\n테스트",
|
||||
t: "s",
|
||||
s: {
|
||||
alignment: {
|
||||
wrapText: true,
|
||||
vertical: "top",
|
||||
textRotation: 0, // 공식 문서: textRotation
|
||||
},
|
||||
font: { sz: 10 },
|
||||
},
|
||||
},
|
||||
],
|
||||
// 다섯 번째 행 - 고급 스타일 테스트
|
||||
[
|
||||
{
|
||||
v: "취소선 텍스트",
|
||||
t: "s",
|
||||
s: {
|
||||
font: {
|
||||
strike: true, // 공식 문서: {strike: true}
|
||||
sz: 12,
|
||||
color: { theme: 5, tint: 0.6 }, // 밝은 빨강
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
v: "회전 텍스트",
|
||||
t: "s",
|
||||
s: {
|
||||
alignment: {
|
||||
textRotation: 45, // 공식 문서: textRotation
|
||||
horizontal: "center",
|
||||
vertical: "middle",
|
||||
},
|
||||
font: { sz: 14, bold: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
v: new Date(),
|
||||
t: "d",
|
||||
s: {
|
||||
numFmt: "m/dd/yy", // 공식 문서: 날짜 포맷 예시
|
||||
font: { name: "Arial", sz: 10 },
|
||||
alignment: { horizontal: "center" },
|
||||
},
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
// 워크시트 생성
|
||||
const ws = XLSX.utils.aoa_to_sheet(testData);
|
||||
|
||||
// 병합 셀 추가
|
||||
if (!ws["!merges"]) ws["!merges"] = [];
|
||||
ws["!merges"].push(
|
||||
{ s: { r: 0, c: 0 }, e: { r: 0, c: 1 } }, // A1:B1 병합
|
||||
{ s: { r: 2, c: 2 }, e: { r: 3, c: 2 } }, // C3:C4 병합
|
||||
);
|
||||
|
||||
// 열 너비 설정
|
||||
ws["!cols"] = [
|
||||
{ wpx: 120 }, // A열 너비
|
||||
{ wpx: 100 }, // B열 너비
|
||||
{ wpx: 80 }, // C열 너비
|
||||
];
|
||||
|
||||
// 행 높이 설정
|
||||
ws["!rows"] = [
|
||||
{ hpx: 30 }, // 1행 높이
|
||||
{ hpx: 25 }, // 2행 높이
|
||||
{ hpx: 40 }, // 3행 높이
|
||||
];
|
||||
|
||||
// 워크시트를 워크북에 추가
|
||||
XLSX.utils.book_append_sheet(wb, ws, "스타일테스트");
|
||||
|
||||
// 추가 시트 생성 (간단한 데이터)
|
||||
const simpleData = [
|
||||
["이름", "나이", "직업"],
|
||||
["홍길동", 30, "개발자"],
|
||||
["김철수", 25, "디자이너"],
|
||||
["이영희", 35, "기획자"],
|
||||
];
|
||||
|
||||
const ws2 = XLSX.utils.aoa_to_sheet(simpleData);
|
||||
|
||||
// 헤더 스타일 적용
|
||||
["A1", "B1", "C1"].forEach((cellAddr) => {
|
||||
if (ws2[cellAddr]) {
|
||||
ws2[cellAddr].s = {
|
||||
font: { bold: true, color: { rgb: "FFFFFF" } },
|
||||
fill: { patternType: "solid", fgColor: { rgb: "5B9BD5" } },
|
||||
alignment: { horizontal: "center" },
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
XLSX.utils.book_append_sheet(wb, ws2, "간단한데이터");
|
||||
|
||||
// Excel 파일로 변환
|
||||
const excelBuffer = XLSX.write(wb, {
|
||||
type: "array",
|
||||
bookType: "xlsx",
|
||||
cellStyles: true,
|
||||
cellDates: true,
|
||||
bookSST: true,
|
||||
});
|
||||
|
||||
// ArrayBuffer로 변환
|
||||
if (excelBuffer instanceof Uint8Array) {
|
||||
return excelBuffer.buffer.slice(
|
||||
excelBuffer.byteOffset,
|
||||
excelBuffer.byteOffset + excelBuffer.byteLength,
|
||||
);
|
||||
}
|
||||
|
||||
return excelBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* 셀 스타일 정보 분석
|
||||
*/
|
||||
export function analyzeSheetStyles(workbook: any): void {
|
||||
console.log("🎨 =================================");
|
||||
console.log("🎨 Excel 파일 스타일 정보 분석");
|
||||
console.log("🎨 =================================");
|
||||
|
||||
// 🔍 워크북 전체 스타일 정보 확인
|
||||
console.log("🔍 워크북 메타데이터:", {
|
||||
Props: workbook.Props ? "있음" : "없음",
|
||||
Custprops: workbook.Custprops ? "있음" : "없음",
|
||||
Workbook: workbook.Workbook ? "있음" : "없음",
|
||||
SSF: workbook.SSF ? "있음" : "없음",
|
||||
SheetNames: workbook.SheetNames
|
||||
? workbook.SheetNames.length + "개"
|
||||
: "없음",
|
||||
Sheets: workbook.Sheets
|
||||
? Object.keys(workbook.Sheets).length + "개"
|
||||
: "없음",
|
||||
});
|
||||
|
||||
// 🔍 워크북 스타일 정보 상세 분석
|
||||
if (workbook.SSF) {
|
||||
console.log("🔍 워크북 SSF 스타일 정보:", workbook.SSF);
|
||||
}
|
||||
|
||||
if (workbook.Workbook && workbook.Workbook.Styles) {
|
||||
console.log("🔍 워크북 Styles:", workbook.Workbook.Styles);
|
||||
}
|
||||
|
||||
// 워크북의 모든 키 확인
|
||||
console.log("🔍 워크북 전체 키들:", Object.keys(workbook));
|
||||
|
||||
if (!workbook.SheetNames || workbook.SheetNames.length === 0) {
|
||||
console.log("🎨 ❌ 시트가 없습니다.");
|
||||
console.log("🎨 ❌ 워크북 전체 구조:", Object.keys(workbook));
|
||||
return;
|
||||
}
|
||||
|
||||
workbook.SheetNames.forEach((sheetName: string, sheetIndex: number) => {
|
||||
const sheet = workbook.Sheets[sheetName];
|
||||
if (!sheet) return;
|
||||
|
||||
console.log(`🎨 시트 ${sheetIndex + 1}: "${sheetName}"`);
|
||||
|
||||
// 시트 메타데이터
|
||||
console.log(`🎨 - 데이터 범위: ${sheet["!ref"] || "없음"}`);
|
||||
console.log(`🎨 - 병합 셀: ${sheet["!merges"]?.length || 0}개`);
|
||||
console.log(`🎨 - 열 설정: ${sheet["!cols"]?.length || 0}개`);
|
||||
console.log(`🎨 - 행 설정: ${sheet["!rows"]?.length || 0}개`);
|
||||
|
||||
// 병합 셀 상세 정보
|
||||
if (sheet["!merges"]) {
|
||||
sheet["!merges"].forEach((merge: any, index: number) => {
|
||||
console.log(
|
||||
`🎨 - 병합 ${index + 1}: ${XLSX.utils.encode_cell(merge.s)}:${XLSX.utils.encode_cell(merge.e)}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// 스타일이 적용된 셀 찾기
|
||||
const styledCells: string[] = [];
|
||||
const cellAddresses = Object.keys(sheet).filter(
|
||||
(key) => !key.startsWith("!"),
|
||||
);
|
||||
|
||||
// 🔍 시트 데이터 존재 여부 확인
|
||||
console.log(`🔍 ${sheetName} 기본 정보: ${cellAddresses.length}개 셀 발견`);
|
||||
|
||||
cellAddresses.forEach((cellAddr) => {
|
||||
const cell = sheet[cellAddr];
|
||||
if (cell && cell.s) {
|
||||
styledCells.push(cellAddr);
|
||||
|
||||
// 🔍 첫 3개 셀의 실제 스타일 구조 확인
|
||||
if (styledCells.length <= 3) {
|
||||
console.log(`🔍 셀 ${cellAddr} cell.s 원시값:`, cell.s);
|
||||
console.log(`🔍 cell.s 타입:`, typeof cell.s);
|
||||
console.log(`🔍 cell.s 키들:`, Object.keys(cell.s || {}));
|
||||
}
|
||||
|
||||
// 스타일 정보 간단 확인
|
||||
const hasStyles = {
|
||||
font: !!cell.s.font,
|
||||
fill: !!cell.s.fill,
|
||||
border: !!cell.s.border,
|
||||
alignment: !!cell.s.alignment,
|
||||
};
|
||||
|
||||
if (Object.values(hasStyles).some((v) => v)) {
|
||||
console.log(`🎨 셀 ${cellAddr} 스타일:`, hasStyles);
|
||||
} else if (styledCells.length <= 3) {
|
||||
console.log(`❌ 셀 ${cellAddr} 스타일 없음:`, hasStyles);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log(
|
||||
`🎨 - 스타일 적용된 셀: ${styledCells.length}개 (${styledCells.join(", ")})`,
|
||||
);
|
||||
});
|
||||
|
||||
console.log("🎨 =================================");
|
||||
}
|
||||
|
||||
/**
|
||||
* 브라우저에서 테스트 파일 다운로드
|
||||
*/
|
||||
export function downloadTestFile(): void {
|
||||
try {
|
||||
const buffer = createStyledTestExcel();
|
||||
const blob = new Blob([buffer], {
|
||||
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
});
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "스타일테스트.xlsx";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
console.log("🎨 스타일 테스트 파일 다운로드 완료!");
|
||||
} catch (error) {
|
||||
console.error("🎨 테스트 파일 생성 실패:", error);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user