feat: 파일프로세서 개선 - 안정적인 Excel 파일 처리
- 이전 잘 작동하던 코드 로직을 현재 프로세서에 적용 - LuckyExcel 우선 시도 + SheetJS Fallback 패턴 도입 - CSV, XLS, XLSX 모든 형식에 대한 안정적 처리 - 한글 시트명 정규화 및 워크북 구조 검증 강화 - 복잡한 SheetJS 옵션 단순화로 안정성 향상 - 에러 발생 시 빈 시트 생성으로 앱 중단 방지 - 테스트 환경 및 Cursor 규칙 업데이트 Technical improvements: - convertSheetJSToLuckyExcel 함수로 안정적 데이터 변환 - UTF-8 codepage 설정으로 한글 지원 강화 - validateWorkbook 함수로 방어적 프로그래밍 적용
This commit is contained in:
5
.cursor/rules/testing.mdc
Normal file
5
.cursor/rules/testing.mdc
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
195
.cursor/rules/xls_processing.mdc
Normal file
195
.cursor/rules/xls_processing.mdc
Normal file
@@ -0,0 +1,195 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# SheetJS Unified File Processing Rules
|
||||
|
||||
## **Critical Requirements for Excel/CSV File Processing**
|
||||
|
||||
- **Always use SheetJS XLSX.read() for all file types (CSV, XLS, XLSX)**
|
||||
- **Implement comprehensive Unicode/Korean support through proper codepage settings**
|
||||
- **Apply defensive programming patterns to prevent processing errors**
|
||||
- **Use optimized SheetJS options for performance and reliability**
|
||||
|
||||
## **SheetJS Unified Processing Chain**
|
||||
|
||||
### **1. File Type Detection and Format Handling**
|
||||
```typescript
|
||||
// ✅ DO: Detect file format and apply appropriate settings
|
||||
function getFileFormat(file: File): string {
|
||||
const extension = file.name.toLowerCase().split(".").pop();
|
||||
switch (extension) {
|
||||
case "csv": return "CSV";
|
||||
case "xls": return "XLS";
|
||||
case "xlsx": return "XLSX";
|
||||
default: return "UNKNOWN";
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ DON'T: Use different processing chains for different formats
|
||||
function processXLSX(file) { /* LuckyExcel */ }
|
||||
function processXLS(file) { /* SheetJS conversion */ }
|
||||
function processCSV(file) { /* Custom parser */ }
|
||||
```
|
||||
|
||||
### **2. Korean/Unicode Support Configuration**
|
||||
```typescript
|
||||
// ✅ DO: Configure proper codepage for Korean support
|
||||
function getOptimalReadOptions(fileType: string): XLSX.ParsingOptions {
|
||||
const baseOptions = {
|
||||
type: "array",
|
||||
cellText: true, // Enable text generation
|
||||
raw: false, // Use formatted values (Korean guarantee)
|
||||
codepage: 65001, // UTF-8 codepage (Korean support)
|
||||
};
|
||||
|
||||
switch (fileType) {
|
||||
case "CSV":
|
||||
return { ...baseOptions, codepage: 65001 }; // UTF-8 for CSV
|
||||
case "XLS":
|
||||
return { ...baseOptions, codepage: 65001 }; // UTF-8 for XLS
|
||||
case "XLSX":
|
||||
return baseOptions; // XLSX natively supports UTF-8
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ DON'T: Ignore encoding settings
|
||||
const workbook = XLSX.read(data); // Missing encoding options
|
||||
```
|
||||
|
||||
### **3. Fallback Encoding Strategy**
|
||||
```typescript
|
||||
// ✅ DO: Implement fallback encoding for robust Korean support
|
||||
try {
|
||||
workbook = XLSX.read(arrayBuffer, { codepage: 65001 }); // UTF-8 first
|
||||
} catch (error) {
|
||||
// Fallback to appropriate encoding
|
||||
const fallbackCodepage = fileFormat === "CSV" ? 949 : 1252; // CP949 for Korean CSV
|
||||
workbook = XLSX.read(arrayBuffer, { codepage: fallbackCodepage });
|
||||
}
|
||||
|
||||
// ❌ DON'T: Give up on first encoding failure
|
||||
try {
|
||||
workbook = XLSX.read(arrayBuffer);
|
||||
} catch (error) {
|
||||
throw error; // No fallback strategy
|
||||
}
|
||||
```
|
||||
|
||||
### **4. Workbook Validation (Defensive Programming)**
|
||||
```typescript
|
||||
// ✅ DO: Validate workbook object thoroughly
|
||||
if (!workbook || typeof workbook !== "object") {
|
||||
throw new Error("워크북을 생성할 수 없습니다");
|
||||
}
|
||||
|
||||
if (!workbook.SheetNames || !Array.isArray(workbook.SheetNames) || workbook.SheetNames.length === 0) {
|
||||
throw new Error("시트 이름 정보가 없습니다");
|
||||
}
|
||||
|
||||
if (!workbook.Sheets || typeof workbook.Sheets !== "object") {
|
||||
throw new Error("유효한 시트가 없습니다");
|
||||
}
|
||||
|
||||
// ❌ DON'T: Skip validation
|
||||
const firstSheet = workbook.Sheets[workbook.SheetNames[0]]; // Potential runtime error
|
||||
```
|
||||
|
||||
### **5. Optimized SheetJS Options**
|
||||
```typescript
|
||||
// ✅ DO: Use performance-optimized options
|
||||
const readOptions: XLSX.ParsingOptions = {
|
||||
type: "array",
|
||||
cellText: true, // ✅ Enable for Korean text
|
||||
cellNF: false, // ✅ Disable for performance
|
||||
cellHTML: false, // ✅ Disable for performance
|
||||
cellFormula: false, // ✅ Disable for performance
|
||||
cellStyles: false, // ✅ Disable for performance
|
||||
cellDates: true, // ✅ Enable for date handling
|
||||
sheetStubs: false, // ✅ Disable for performance
|
||||
bookProps: false, // ✅ Disable for performance
|
||||
bookSheets: true, // ✅ Enable for sheet info
|
||||
bookVBA: false, // ✅ Disable for performance
|
||||
raw: false, // ✅ Use formatted values (Korean guarantee)
|
||||
dense: false, // ✅ Use sparse arrays (memory efficient)
|
||||
WTF: false, // ✅ Ignore errors (stability)
|
||||
UTC: false, // ✅ Use local time
|
||||
};
|
||||
|
||||
// ❌ DON'T: Use default options without optimization
|
||||
const workbook = XLSX.read(data, { type: "array" }); // Missing optimizations
|
||||
```
|
||||
|
||||
### **6. Korean Data Processing**
|
||||
```typescript
|
||||
// ✅ DO: Process Korean data with proper settings
|
||||
const jsonData = XLSX.utils.sheet_to_json(sheet, {
|
||||
header: 1, // Array format
|
||||
defval: "", // Default value for empty cells
|
||||
blankrows: false, // Remove blank rows
|
||||
raw: false, // Use formatted values (Korean guarantee)
|
||||
});
|
||||
|
||||
// ❌ DON'T: Use raw values that might break Korean text
|
||||
const jsonData = XLSX.utils.sheet_to_json(sheet, {
|
||||
raw: true, // Might break Korean characters
|
||||
});
|
||||
```
|
||||
|
||||
## **Error Handling Patterns**
|
||||
|
||||
### **Specific Error Messages**
|
||||
```typescript
|
||||
// ✅ DO: Provide specific error messages
|
||||
if (arrayBuffer.byteLength === 0) {
|
||||
throw new Error(`${fileFormat} 파일이 비어있습니다.`);
|
||||
}
|
||||
|
||||
if (!workbook.SheetNames.length) {
|
||||
throw new Error("시트 이름 정보가 없습니다 - 파일이 비어있거나 손상되었습니다.");
|
||||
}
|
||||
|
||||
// ❌ DON'T: Use generic error messages
|
||||
throw new Error("File processing failed");
|
||||
```
|
||||
|
||||
## **Performance Guidelines**
|
||||
|
||||
- **Use `type: "array"` for ArrayBuffer input**
|
||||
- **Disable unnecessary features (cellFormula, cellStyles, etc.)**
|
||||
- **Use `raw: false` to ensure Korean text integrity**
|
||||
- **Enable `blankrows: false` to remove empty rows**
|
||||
- **Cache workbook objects when processing multiple sheets**
|
||||
|
||||
## **Testing Requirements**
|
||||
|
||||
- **Test with Korean filenames and Korean data content**
|
||||
- **Test encoding fallback scenarios (UTF-8 → CP949 → CP1252)**
|
||||
- **Test error handling for corrupted files**
|
||||
- **Test all supported file formats (CSV, XLS, XLSX)**
|
||||
- **Verify memory efficiency with large files**
|
||||
|
||||
## **Migration from LuckyExcel**
|
||||
|
||||
- **Replace all LuckyExcel transformExcelToLucky() calls with SheetJS**
|
||||
- **Remove XLS-to-XLSX conversion logic (SheetJS handles natively)**
|
||||
- **Update data structure from LuckyExcel format to standard JSON**
|
||||
- **Maintain backward compatibility in component interfaces**
|
||||
|
||||
## **Common Pitfalls to Avoid**
|
||||
|
||||
- ❌ Using different processing libraries for different file types
|
||||
- ❌ Ignoring codepage settings for Korean files
|
||||
- ❌ Not implementing encoding fallback strategies
|
||||
- ❌ Skipping workbook validation steps
|
||||
- ❌ Using `raw: true` which can break Korean characters
|
||||
- ❌ Not handling empty or corrupted files gracefully
|
||||
|
||||
## **Best Practices**
|
||||
|
||||
- ✅ Log processing steps for debugging Korean encoding issues
|
||||
- ✅ Use consistent error message format across all file types
|
||||
- ✅ Implement comprehensive test coverage for Korean scenarios
|
||||
- ✅ Monitor performance with large Korean datasets
|
||||
- ✅ Document encoding strategies for team knowledge sharing
|
||||
129
.cursor/rules/xlsx_debug.mdc
Normal file
129
.cursor/rules/xlsx_debug.mdc
Normal file
@@ -0,0 +1,129 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# XLSX 파일 처리 오류 방지 및 디버깅
|
||||
|
||||
## **문제 상황**
|
||||
- SheetJS로 읽은 XLSX 파일에서 `workbook.Sheets`가 undefined이거나 올바른 객체가 아닌 경우
|
||||
- "파일에 유효한 시트가 없습니다" 오류가 발생하는 경우
|
||||
- 한글 파일명이나 특수 문자가 포함된 XLSX 파일 처리 시 문제
|
||||
|
||||
## **필수 검증 단계**
|
||||
|
||||
### **1. 워크북 객체 검증**
|
||||
```typescript
|
||||
// ✅ DO: 워크북 구조 전체 디버깅
|
||||
console.log("🔍 워크북 구조 분석:");
|
||||
console.log("- workbook 존재:", !!workbook);
|
||||
console.log("- workbook 전체 키:", Object.keys(workbook));
|
||||
console.log("- workbook.Sheets 존재:", !!workbook.Sheets);
|
||||
console.log("- workbook.Sheets 타입:", typeof workbook.Sheets);
|
||||
|
||||
// ❌ DON'T: 단순한 truthy 체크만 하지 말 것
|
||||
if (workbook.Sheets) { ... }
|
||||
```
|
||||
|
||||
### **2. SheetJS 읽기 옵션 최적화**
|
||||
```typescript
|
||||
// ✅ DO: 안전한 SheetJS 옵션 사용
|
||||
workbook = XLSX.read(arrayBuffer, {
|
||||
type: "array",
|
||||
cellText: false, // 텍스트 셀 비활성화
|
||||
cellDates: true, // 날짜 형식 유지
|
||||
sheetStubs: false, // 빈 셀 스텁 비활성화
|
||||
codepage: 65001, // UTF-8 설정
|
||||
bookProps: false, // 워크북 속성 비활성화
|
||||
bookSheets: true, // 시트 정보만 활성화
|
||||
raw: false, // 원시 값 비활성화
|
||||
WTF: false // 엄격 모드 비활성화
|
||||
});
|
||||
|
||||
// ❌ DON'T: 기본 옵션만 사용하지 말 것
|
||||
workbook = XLSX.read(arrayBuffer);
|
||||
```
|
||||
|
||||
### **3. 단계별 오류 처리**
|
||||
```typescript
|
||||
// ✅ DO: 각 단계별 구체적 오류 메시지
|
||||
if (!workbook) {
|
||||
throw new Error("파일에서 워크북을 생성할 수 없습니다.");
|
||||
}
|
||||
|
||||
if (!workbook.Sheets || typeof workbook.Sheets !== "object") {
|
||||
console.error("❌ Sheets 속성 오류:", {
|
||||
exists: !!workbook.Sheets,
|
||||
type: typeof workbook.Sheets,
|
||||
value: workbook.Sheets
|
||||
});
|
||||
throw new Error("파일에 유효한 시트가 없습니다.");
|
||||
}
|
||||
|
||||
// ❌ DON'T: 일반적인 오류 메시지만 사용하지 말 것
|
||||
throw new Error("파일 처리 실패");
|
||||
```
|
||||
|
||||
### **4. 개별 시트 검증**
|
||||
```typescript
|
||||
// ✅ DO: 각 시트의 구조 확인
|
||||
workbook.SheetNames.forEach((sheetName, index) => {
|
||||
const sheet = workbook.Sheets[sheetName];
|
||||
console.log(`- 시트 [${index}] "${sheetName}":`, {
|
||||
exists: !!sheet,
|
||||
type: typeof sheet,
|
||||
keys: sheet ? Object.keys(sheet).slice(0, 5) : []
|
||||
});
|
||||
});
|
||||
|
||||
// ❌ DON'T: 시트 존재만 확인하고 내용 검증 생략하지 말 것
|
||||
const hasSheets = workbook.SheetNames.length > 0;
|
||||
```
|
||||
|
||||
## **한글 파일 처리 특수 사항**
|
||||
|
||||
### **1. 인코딩 처리**
|
||||
```typescript
|
||||
// ✅ DO: UTF-8 실패 시 CP949로 재시도 (XLS의 경우)
|
||||
try {
|
||||
workbook = XLSX.read(arrayBuffer, { codepage: 65001 }); // UTF-8
|
||||
} catch (error) {
|
||||
if (isXLS) {
|
||||
workbook = XLSX.read(arrayBuffer, { codepage: 949 }); // CP949
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### **2. 파일명 및 시트명 검증**
|
||||
```typescript
|
||||
// ✅ DO: 한글 시트명 안전 처리
|
||||
const safeSheetName = sheetName || `Sheet${index + 1}`;
|
||||
console.log(`🔍 처리 중인 시트: "${safeSheetName}"`);
|
||||
|
||||
// ❌ DON'T: 시트명을 검증 없이 직접 사용하지 말 것
|
||||
console.log(`처리 중: ${sheetName}`);
|
||||
```
|
||||
|
||||
## **성능 최적화**
|
||||
|
||||
### **1. 메모리 효율적 처리**
|
||||
```typescript
|
||||
// ✅ DO: 큰 파일 처리 시 메모리 체크
|
||||
if (arrayBuffer.byteLength > 50 * 1024 * 1024) { // 50MB
|
||||
console.warn("⚠️ 대용량 파일 처리 중...");
|
||||
}
|
||||
|
||||
// ✅ DO: 변환 결과 검증
|
||||
if (!xlsxBuffer || xlsxBuffer.byteLength === 0) {
|
||||
throw new Error("XLSX 변환 결과가 비어있습니다.");
|
||||
}
|
||||
```
|
||||
|
||||
## **테스트 고려사항**
|
||||
- 한글 파일명이 포함된 XLSX 파일 테스트
|
||||
- 빈 시트가 포함된 파일 테스트
|
||||
- 손상된 XLSX 파일 테스트
|
||||
- 대용량 파일 처리 테스트
|
||||
- 특수 문자가 포함된 시트명 테스트
|
||||
|
||||
참고: [xls_processing.mdc](mdc:.cursor/rules/xls_processing.mdc)와 연계하여 사용
|
||||
2457
package-lock.json
generated
2457
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -12,7 +12,10 @@
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"",
|
||||
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"",
|
||||
"preview": "vite preview",
|
||||
"type-check": "tsc --noEmit"
|
||||
"type-check": "tsc --noEmit",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.10",
|
||||
@@ -30,7 +33,11 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.15.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.15.0",
|
||||
@@ -42,11 +49,13 @@
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.14",
|
||||
"globals": "^15.12.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"postcss": "^8.5.1",
|
||||
"prettier": "^3.4.2",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.1"
|
||||
"vite": "^6.0.1",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"keywords": [
|
||||
"excel",
|
||||
|
||||
137
src/App.tsx
137
src/App.tsx
@@ -1,140 +1,31 @@
|
||||
import { useAppStore } from "./stores/useAppStore";
|
||||
import { Card, CardContent } from "./components/ui/card";
|
||||
import { Button } from "./components/ui/button";
|
||||
import { FileUpload } from "./components/sheet/FileUpload";
|
||||
|
||||
function App() {
|
||||
const { isLoading, loadingMessage, currentFile } = useAppStore();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* 상단 바 */}
|
||||
<header className="border-b bg-white/95 backdrop-blur supports-[backdrop-filter]:bg-white/60">
|
||||
<div className="container flex h-16 items-center px-4">
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* 헤더 */}
|
||||
<header className="bg-white shadow-sm border-b">
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<h1 className="text-2xl font-bold text-primary">sheetEasy AI</h1>
|
||||
<span className="text-sm text-gray-600">
|
||||
Excel 파일 AI 처리 도구
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center space-x-4">
|
||||
{currentFile && (
|
||||
<Button variant="outline" size="sm">
|
||||
다운로드
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm">
|
||||
히스토리
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon">
|
||||
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<span className="text-sm font-medium">계정</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 메인 콘텐츠 */}
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
{!currentFile ? (
|
||||
/* 파일 업로드 영역 */
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<Card className="w-full max-w-2xl">
|
||||
<CardContent className="p-12">
|
||||
<div className="text-center">
|
||||
<div className="mb-8">
|
||||
<div className="mx-auto h-24 w-24 rounded-full bg-primary/10 flex items-center justify-center mb-4">
|
||||
<svg
|
||||
className="h-12 w-12 text-primary"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<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="text-2xl font-semibold mb-2">
|
||||
Excel 파일을 업로드하세요
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
.xlsx, .xls 파일을 드래그 앤 드롭하거나 클릭하여 업로드
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-2 border-dashed border-muted-foreground/25 rounded-lg p-12 hover:border-primary/50 transition-colors cursor-pointer">
|
||||
<p className="text-muted-foreground">
|
||||
파일을 여기에 드롭하거나 클릭하여 선택
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Button>파일 선택</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
/* 시트 뷰어 영역 */
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{currentFile.name}</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
업로드됨: {currentFile.uploadedAt.toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 시트 렌더링 영역 */}
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="h-96 bg-white border rounded-lg flex items-center justify-center">
|
||||
<p className="text-muted-foreground">
|
||||
Luckysheet 시트가 여기에 렌더링됩니다
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<FileUpload />
|
||||
</main>
|
||||
|
||||
{/* 프롬프트 입력 (하단 고정) */}
|
||||
{currentFile && (
|
||||
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 w-full max-w-2xl px-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex space-x-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="AI에게 명령을 입력하세요... (예: A열의 모든 빈 셀을 0으로 채워줘)"
|
||||
className="flex-1 px-3 py-2 border border-input rounded-md bg-background text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
<Button>실행</Button>
|
||||
<Button variant="outline">히스토리</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 로딩 오버레이 */}
|
||||
{isLoading && (
|
||||
<div className="fixed inset-0 bg-background/80 backdrop-blur-sm flex items-center justify-center z-50">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-2 border-primary border-t-transparent"></div>
|
||||
<p className="text-sm">{loadingMessage || "처리 중..."}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
421
src/components/sheet/FileUpload.tsx
Normal file
421
src/components/sheet/FileUpload.tsx
Normal file
@@ -0,0 +1,421 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
431
src/components/sheet/__tests__/FileUpload.test.tsx
Normal file
431
src/components/sheet/__tests__/FileUpload.test.tsx
Normal file
@@ -0,0 +1,431 @@
|
||||
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 vi.MockedFunction<typeof useAppStore>;
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
153
src/components/ui/modal.tsx
Normal file
153
src/components/ui/modal.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import React from "react";
|
||||
import { Button } from "./button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./card";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 모달 컴포넌트
|
||||
* - 배경 클릭으로 닫기
|
||||
* - ESC 키로 닫기
|
||||
* - 접근성 지원 (ARIA)
|
||||
*/
|
||||
export function Modal({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
className,
|
||||
}: ModalProps) {
|
||||
// ESC 키 이벤트 처리
|
||||
React.useEffect(() => {
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
document.body.style.overflow = "hidden";
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
document.body.style.overflow = "unset";
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-title"
|
||||
>
|
||||
{/* 배경 오버레이 */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* 모달 콘텐츠 */}
|
||||
<Card className={cn("relative w-full max-w-md", className)}>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle id="modal-title" className="text-lg font-semibold">
|
||||
{title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">{children}</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FileErrorModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
errors: Array<{ fileName: string; error: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 업로드 에러 전용 모달
|
||||
*/
|
||||
export function FileErrorModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
errors,
|
||||
}: FileErrorModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="파일 업로드 오류"
|
||||
className="max-w-lg"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="flex-shrink-0">
|
||||
<svg
|
||||
className="h-6 w-6 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>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-gray-900 mb-3">
|
||||
다음 파일들을 업로드할 수 없습니다:
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{errors.map((error, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="p-3 bg-red-50 border border-red-200 rounded-md"
|
||||
>
|
||||
<p className="text-sm font-medium text-red-800">
|
||||
{error.fileName}
|
||||
</p>
|
||||
<p className="text-xs text-red-600 mt-1">{error.error}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-md p-3">
|
||||
<h4 className="text-sm font-medium text-blue-800 mb-2">
|
||||
지원되는 파일 형식:
|
||||
</h4>
|
||||
<ul className="text-xs text-blue-700 space-y-1">
|
||||
<li>• Excel 파일 (.xlsx, .xls)</li>
|
||||
<li>• 최대 파일 크기: 50MB</li>
|
||||
<li>• 한글 파일명 및 시트명 지원</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2 pt-2">
|
||||
<Button onClick={onClose} variant="default">
|
||||
확인
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
105
src/index.css
105
src/index.css
@@ -2,13 +2,116 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* 필요한 색상 클래스들 추가 */
|
||||
.text-gray-500 { color: #6b7280; }
|
||||
.text-gray-600 { color: #4b5563; }
|
||||
.text-gray-900 { color: #111827; }
|
||||
.text-blue-600 { color: #2563eb; }
|
||||
.text-blue-700 { color: #1d4ed8; }
|
||||
.text-blue-800 { color: #1e40af; }
|
||||
|
||||
.bg-gray-50 { background-color: #f9fafb; }
|
||||
.bg-blue-50 { background-color: #eff6ff; }
|
||||
.bg-blue-100 { background-color: #dbeafe; }
|
||||
.bg-blue-200 { background-color: #bfdbfe; }
|
||||
|
||||
.border-gray-300 { border-color: #d1d5db; }
|
||||
.border-blue-200 { border-color: #bfdbfe; }
|
||||
.border-blue-400 { border-color: #60a5fa; }
|
||||
.border-blue-500 { border-color: #3b82f6; }
|
||||
|
||||
.hover\:border-blue-400:hover { border-color: #60a5fa; }
|
||||
.hover\:bg-blue-50:hover { background-color: #eff6ff; }
|
||||
|
||||
.focus\:ring-blue-500:focus {
|
||||
--tw-ring-color: #3b82f6;
|
||||
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
|
||||
}
|
||||
|
||||
/* 추가 유틸리티 클래스들 */
|
||||
.max-w-7xl { max-width: 80rem; }
|
||||
.max-w-2xl { max-width: 42rem; }
|
||||
.max-w-lg { max-width: 32rem; }
|
||||
.max-w-md { max-width: 28rem; }
|
||||
|
||||
.h-16 { height: 4rem; }
|
||||
.h-20 { height: 5rem; }
|
||||
.h-24 { height: 6rem; }
|
||||
.w-20 { width: 5rem; }
|
||||
.w-24 { width: 6rem; }
|
||||
|
||||
.h-6 { height: 1.5rem; }
|
||||
.w-6 { width: 1.5rem; }
|
||||
.h-10 { height: 2.5rem; }
|
||||
.w-10 { width: 2.5rem; }
|
||||
.h-12 { height: 3rem; }
|
||||
.w-12 { width: 3rem; }
|
||||
|
||||
.space-x-4 > :not([hidden]) ~ :not([hidden]) { margin-left: 1rem; }
|
||||
.space-x-2 > :not([hidden]) ~ :not([hidden]) { margin-left: 0.5rem; }
|
||||
.space-y-1 > :not([hidden]) ~ :not([hidden]) { margin-top: 0.25rem; }
|
||||
.space-y-2 > :not([hidden]) ~ :not([hidden]) { margin-top: 0.5rem; }
|
||||
.space-y-4 > :not([hidden]) ~ :not([hidden]) { margin-top: 1rem; }
|
||||
|
||||
.p-3 { padding: 0.75rem; }
|
||||
.p-4 { padding: 1rem; }
|
||||
.p-8 { padding: 2rem; }
|
||||
.p-12 { padding: 3rem; }
|
||||
.px-4 { padding-left: 1rem; padding-right: 1rem; }
|
||||
.py-8 { padding-top: 2rem; padding-bottom: 2rem; }
|
||||
|
||||
.mb-2 { margin-bottom: 0.5rem; }
|
||||
.mb-3 { margin-bottom: 0.75rem; }
|
||||
.mb-4 { margin-bottom: 1rem; }
|
||||
.mb-6 { margin-bottom: 1.5rem; }
|
||||
.mb-8 { margin-bottom: 2rem; }
|
||||
.mt-6 { margin-top: 1.5rem; }
|
||||
|
||||
.text-xs { font-size: 0.75rem; line-height: 1rem; }
|
||||
.text-sm { font-size: 0.875rem; line-height: 1.25rem; }
|
||||
.text-base { font-size: 1rem; line-height: 1.5rem; }
|
||||
.text-lg { font-size: 1.125rem; line-height: 1.75rem; }
|
||||
.text-xl { font-size: 1.25rem; line-height: 1.75rem; }
|
||||
.text-2xl { font-size: 1.5rem; line-height: 2rem; }
|
||||
.text-4xl { font-size: 2.25rem; line-height: 2.5rem; }
|
||||
.text-6xl { font-size: 3.75rem; line-height: 1; }
|
||||
|
||||
.font-medium { font-weight: 500; }
|
||||
.font-semibold { font-weight: 600; }
|
||||
.font-bold { font-weight: 700; }
|
||||
|
||||
.rounded-lg { border-radius: 0.5rem; }
|
||||
.rounded-md { border-radius: 0.375rem; }
|
||||
|
||||
.shadow-sm { box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); }
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.sm\:px-6 { padding-left: 1.5rem; padding-right: 1.5rem; }
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.md\:h-24 { height: 6rem; }
|
||||
.md\:w-24 { width: 6rem; }
|
||||
.md\:h-12 { height: 3rem; }
|
||||
.md\:w-12 { width: 3rem; }
|
||||
.md\:p-12 { padding: 3rem; }
|
||||
.md\:text-base { font-size: 1rem; line-height: 1.5rem; }
|
||||
.md\:text-lg { font-size: 1.125rem; line-height: 1.75rem; }
|
||||
.md\:text-2xl { font-size: 1.5rem; line-height: 2rem; }
|
||||
.md\:text-6xl { font-size: 3.75rem; line-height: 1; }
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.lg\:px-8 { padding-left: 2rem; padding-right: 2rem; }
|
||||
}
|
||||
|
||||
/* 커스텀 스타일 */
|
||||
body {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
color-scheme: light;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
color: #1f2937; /* 검은색 계열로 변경 */
|
||||
background-color: #ffffff;
|
||||
|
||||
font-synthesis: none;
|
||||
|
||||
2
src/setupTests.ts
Normal file
2
src/setupTests.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// jest-dom 매처를 테스트 환경에 추가
|
||||
import "@testing-library/jest-dom";
|
||||
@@ -28,6 +28,10 @@ interface AppState {
|
||||
loadingMessage: string;
|
||||
isHistoryPanelOpen: boolean;
|
||||
|
||||
// 에러 상태
|
||||
error: string | null;
|
||||
fileUploadErrors: Array<{ fileName: string; error: string }>;
|
||||
|
||||
// AI 상태
|
||||
aiHistory: AIHistory[];
|
||||
isProcessingAI: boolean;
|
||||
@@ -46,6 +50,11 @@ interface AppState {
|
||||
setLoading: (loading: boolean, message?: string) => void;
|
||||
setHistoryPanelOpen: (open: boolean) => void;
|
||||
|
||||
// 에러 관리
|
||||
setError: (error: string | null) => void;
|
||||
addFileUploadError: (fileName: string, error: string) => void;
|
||||
clearFileUploadErrors: () => void;
|
||||
|
||||
addAIHistory: (history: AIHistory) => void;
|
||||
setProcessingAI: (processing: boolean) => void;
|
||||
|
||||
@@ -64,13 +73,15 @@ const initialState = {
|
||||
isLoading: false,
|
||||
loadingMessage: "",
|
||||
isHistoryPanelOpen: false,
|
||||
error: null,
|
||||
fileUploadErrors: [],
|
||||
aiHistory: [],
|
||||
isProcessingAI: false,
|
||||
};
|
||||
|
||||
export const useAppStore = create<AppState>()(
|
||||
devtools(
|
||||
(set) => ({
|
||||
(set, get) => ({
|
||||
...initialState,
|
||||
|
||||
// 사용자 액션
|
||||
@@ -92,6 +103,14 @@ export const useAppStore = create<AppState>()(
|
||||
}),
|
||||
setHistoryPanelOpen: (open) => set({ isHistoryPanelOpen: open }),
|
||||
|
||||
// 에러 관리
|
||||
setError: (error) => set({ error }),
|
||||
addFileUploadError: (fileName, error) =>
|
||||
set((state) => ({
|
||||
fileUploadErrors: [...state.fileUploadErrors, { fileName, error }],
|
||||
})),
|
||||
clearFileUploadErrors: () => set({ fileUploadErrors: [] }),
|
||||
|
||||
// AI 액션
|
||||
addAIHistory: (history) =>
|
||||
set((state) => ({
|
||||
@@ -110,7 +129,17 @@ export const useAppStore = create<AppState>()(
|
||||
},
|
||||
sheets: result.data,
|
||||
activeSheetId: result.data[0]?.id || null,
|
||||
error: null, // 성공 시 에러 클리어
|
||||
});
|
||||
} else if (result.error) {
|
||||
set({
|
||||
error: result.error,
|
||||
});
|
||||
|
||||
// 파일 업로드 에러 추가
|
||||
if (result.fileName) {
|
||||
get().addFileUploadError(result.fileName, result.error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
440
src/utils/__tests__/fileProcessor.test.ts
Normal file
440
src/utils/__tests__/fileProcessor.test.ts
Normal file
@@ -0,0 +1,440 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import * as XLSX from "xlsx";
|
||||
import {
|
||||
validateFileType,
|
||||
validateFileSize,
|
||||
getFileErrorMessage,
|
||||
filterValidFiles,
|
||||
getFileErrors,
|
||||
processExcelFile,
|
||||
MAX_FILE_SIZE,
|
||||
SUPPORTED_EXTENSIONS,
|
||||
} from "../fileProcessor";
|
||||
|
||||
// SheetJS 모킹 (통합 처리)
|
||||
vi.mock("xlsx", () => ({
|
||||
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"],
|
||||
]),
|
||||
},
|
||||
}));
|
||||
|
||||
// LuckyExcel 모킹
|
||||
vi.mock("luckyexcel", () => ({
|
||||
transformExcelToLucky: vi.fn((arrayBuffer, fileName, callback) => {
|
||||
// 성공적인 변환 결과 모킹
|
||||
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" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// 비동기 콜백 호출
|
||||
setTimeout(() => callback(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"); // 모킹 데이터의 실제 시트명
|
||||
});
|
||||
});
|
||||
});
|
||||
785
src/utils/fileProcessor.ts
Normal file
785
src/utils/fileProcessor.ts
Normal file
@@ -0,0 +1,785 @@
|
||||
import * as XLSX from "xlsx";
|
||||
import * as LuckyExcel from "luckyexcel";
|
||||
import type { SheetData, FileUploadResult } from "../types/sheet";
|
||||
|
||||
/**
|
||||
* 파일 처리 관련 유틸리티 - 개선된 버전
|
||||
* - 모든 파일 형식 (CSV, XLS, XLSX)을 SheetJS를 통해 처리
|
||||
* - LuckyExcel 우선 시도, 실패 시 SheetJS Fallback 사용
|
||||
* - 안정적인 한글 지원 및 에러 처리
|
||||
*/
|
||||
|
||||
// 지원되는 파일 타입
|
||||
export const SUPPORTED_FILE_TYPES = {
|
||||
XLSX: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
XLS: "application/vnd.ms-excel",
|
||||
CSV: "text/csv",
|
||||
} as const;
|
||||
|
||||
export const SUPPORTED_EXTENSIONS = [".xlsx", ".xls", ".csv"] as const;
|
||||
|
||||
// 최대 파일 크기 (50MB)
|
||||
export const MAX_FILE_SIZE = 50 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* 파일 타입 검증
|
||||
*/
|
||||
export function validateFileType(file: File): boolean {
|
||||
const fileName = file.name.toLowerCase();
|
||||
const extension = fileName.split(".").pop();
|
||||
const supportedExtensions = SUPPORTED_EXTENSIONS.map((ext) => ext.slice(1));
|
||||
|
||||
if (!extension) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return supportedExtensions.includes(extension);
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 크기 검증
|
||||
*/
|
||||
export function validateFileSize(file: File): boolean {
|
||||
return file.size <= MAX_FILE_SIZE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 이름에서 확장자 제거
|
||||
*/
|
||||
export function getFileNameWithoutExtension(fileName: string): string {
|
||||
return fileName.replace(/\.[^/.]+$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 메시지 생성
|
||||
*/
|
||||
export function getFileErrorMessage(file: File): string {
|
||||
if (!validateFileType(file)) {
|
||||
const fileName = file.name.toLowerCase();
|
||||
const extension = fileName.split(".").pop();
|
||||
|
||||
if (!extension || extension === fileName) {
|
||||
return `파일 확장자가 없습니다. ${SUPPORTED_EXTENSIONS.join(", ")} 파일만 업로드 가능합니다.`;
|
||||
}
|
||||
return `지원되지 않는 파일 형식입니다. "${extension}" 대신 ${SUPPORTED_EXTENSIONS.join(", ")} 파일을 업로드해주세요.`;
|
||||
}
|
||||
|
||||
if (!validateFileSize(file)) {
|
||||
const maxSizeMB = Math.round(MAX_FILE_SIZE / (1024 * 1024));
|
||||
const currentSizeMB = (file.size / (1024 * 1024)).toFixed(2);
|
||||
return `파일 크기가 너무 큽니다. 현재 크기: ${currentSizeMB}MB, 최대 허용: ${maxSizeMB}MB`;
|
||||
}
|
||||
|
||||
if (file.name.length > 255) {
|
||||
return "파일명이 너무 깁니다. 255자 이하의 파일명을 사용해주세요.";
|
||||
}
|
||||
|
||||
const invalidChars = /[<>:"/\\|?*]/;
|
||||
if (invalidChars.test(file.name)) {
|
||||
return '파일명에 사용할 수 없는 특수문자가 포함되어 있습니다. (< > : " / \\ | ? *)';
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 한글 시트명을 안전하게 처리하는 함수
|
||||
*/
|
||||
function sanitizeSheetName(sheetName: string): string {
|
||||
if (!sheetName || typeof sheetName !== "string") {
|
||||
return "Sheet1";
|
||||
}
|
||||
|
||||
const maxLength = 31;
|
||||
let sanitized = sheetName.trim();
|
||||
|
||||
if (sanitized.length > maxLength) {
|
||||
sanitized = sanitized.substring(0, maxLength - 3) + "...";
|
||||
}
|
||||
|
||||
sanitized = sanitized.replace(/[\\\/\*\?\[\]]/g, "_");
|
||||
|
||||
return sanitized || "Sheet1";
|
||||
}
|
||||
|
||||
/**
|
||||
* 워크북 구조 검증 함수
|
||||
*/
|
||||
function validateWorkbook(workbook: any): { isValid: boolean; error?: string } {
|
||||
if (!workbook) {
|
||||
return { isValid: false, error: "워크북이 null 또는 undefined입니다" };
|
||||
}
|
||||
|
||||
if (!workbook.SheetNames) {
|
||||
return { isValid: false, error: "워크북에 SheetNames 속성이 없습니다" };
|
||||
}
|
||||
|
||||
if (!Array.isArray(workbook.SheetNames)) {
|
||||
return { isValid: false, error: "SheetNames가 배열이 아닙니다" };
|
||||
}
|
||||
|
||||
if (workbook.SheetNames.length === 0) {
|
||||
return { isValid: false, error: "워크북에 시트가 없습니다" };
|
||||
}
|
||||
|
||||
if (!workbook.Sheets) {
|
||||
return { isValid: false, error: "워크북에 Sheets 속성이 없습니다" };
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* SheetJS 데이터를 LuckyExcel 형식으로 변환
|
||||
*/
|
||||
function convertSheetJSToLuckyExcel(workbook: any): SheetData[] {
|
||||
console.log("🔄 SheetJS → LuckyExcel 형식 변환 시작...");
|
||||
|
||||
const luckySheets: SheetData[] = [];
|
||||
|
||||
// 워크북 구조 검증
|
||||
const validation = validateWorkbook(workbook);
|
||||
if (!validation.isValid) {
|
||||
console.error("❌ 워크북 검증 실패:", validation.error);
|
||||
throw new Error(`워크북 구조 오류: ${validation.error}`);
|
||||
}
|
||||
|
||||
console.log(`📋 발견된 시트: ${workbook.SheetNames.join(", ")}`);
|
||||
|
||||
workbook.SheetNames.forEach((sheetName: string, index: number) => {
|
||||
console.log(
|
||||
`📋 시트 ${index + 1}/${workbook.SheetNames.length} "${sheetName}" 변환 중...`,
|
||||
);
|
||||
|
||||
const safeSheetName = sanitizeSheetName(sheetName);
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
|
||||
if (!worksheet) {
|
||||
console.warn(
|
||||
`⚠️ 시트 "${sheetName}"를 찾을 수 없습니다. 빈 시트로 생성합니다.`,
|
||||
);
|
||||
|
||||
luckySheets.push({
|
||||
id: `sheet_${index}`,
|
||||
name: safeSheetName,
|
||||
data: [[""]],
|
||||
config: {
|
||||
container: `luckysheet_${index}`,
|
||||
title: safeSheetName,
|
||||
lang: "ko",
|
||||
data: [
|
||||
{
|
||||
name: safeSheetName,
|
||||
index: index.toString(),
|
||||
celldata: [],
|
||||
status: 1,
|
||||
order: index,
|
||||
row: 100,
|
||||
column: 26,
|
||||
},
|
||||
],
|
||||
options: {
|
||||
showtoolbar: true,
|
||||
showinfobar: false,
|
||||
showsheetbar: true,
|
||||
showstatisticBar: false,
|
||||
allowCopy: true,
|
||||
allowEdit: true,
|
||||
enableAddRow: true,
|
||||
enableAddCol: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 시트 범위 확인
|
||||
const range = worksheet["!ref"];
|
||||
if (!range) {
|
||||
console.warn(
|
||||
`⚠️ 시트 "${sheetName}"에 데이터 범위가 없습니다. 빈 시트로 처리합니다.`,
|
||||
);
|
||||
|
||||
luckySheets.push({
|
||||
id: `sheet_${index}`,
|
||||
name: safeSheetName,
|
||||
data: [[""]],
|
||||
config: {
|
||||
container: `luckysheet_${index}`,
|
||||
title: safeSheetName,
|
||||
lang: "ko",
|
||||
data: [
|
||||
{
|
||||
name: safeSheetName,
|
||||
index: index.toString(),
|
||||
celldata: [],
|
||||
status: 1,
|
||||
order: index,
|
||||
row: 100,
|
||||
column: 26,
|
||||
},
|
||||
],
|
||||
options: {
|
||||
showtoolbar: true,
|
||||
showinfobar: false,
|
||||
showsheetbar: true,
|
||||
showstatisticBar: false,
|
||||
allowCopy: true,
|
||||
allowEdit: true,
|
||||
enableAddRow: true,
|
||||
enableAddCol: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 범위 파싱
|
||||
const rangeObj = XLSX.utils.decode_range(range);
|
||||
const maxRow = rangeObj.e.r + 1;
|
||||
const maxCol = rangeObj.e.c + 1;
|
||||
|
||||
console.log(`📐 시트 "${sheetName}" 크기: ${maxRow}행 x ${maxCol}열`);
|
||||
|
||||
// 2D 배열로 데이터 변환
|
||||
const data: any[][] = [];
|
||||
const cellData: any[] = [];
|
||||
|
||||
// 데이터 배열 초기화
|
||||
for (let row = 0; row < maxRow; row++) {
|
||||
data[row] = new Array(maxCol).fill("");
|
||||
}
|
||||
|
||||
// 셀 데이터 변환
|
||||
for (let row = rangeObj.s.r; row <= rangeObj.e.r; row++) {
|
||||
for (let col = rangeObj.s.c; col <= rangeObj.e.c; col++) {
|
||||
const cellAddress = XLSX.utils.encode_cell({ r: row, c: col });
|
||||
const cell = worksheet[cellAddress];
|
||||
|
||||
if (
|
||||
cell &&
|
||||
cell.v !== undefined &&
|
||||
cell.v !== null &&
|
||||
cell.v !== ""
|
||||
) {
|
||||
let cellValue = cell.v;
|
||||
if (typeof cellValue === "string") {
|
||||
cellValue = cellValue.trim();
|
||||
if (cellValue.length > 1000) {
|
||||
cellValue = cellValue.substring(0, 997) + "...";
|
||||
}
|
||||
}
|
||||
|
||||
// 2D 배열에 데이터 저장
|
||||
data[row][col] = cellValue;
|
||||
|
||||
// LuckyExcel celldata 형식으로 변환
|
||||
const luckyCell: any = {
|
||||
r: row,
|
||||
c: col,
|
||||
v: {
|
||||
v: cellValue,
|
||||
m: String(cellValue),
|
||||
ct: { fa: "General", t: "g" },
|
||||
},
|
||||
};
|
||||
|
||||
// 셀 타입에 따른 추가 처리
|
||||
if (cell.t === "s") {
|
||||
luckyCell.v.ct.t = "s";
|
||||
} else if (cell.t === "n") {
|
||||
luckyCell.v.ct.t = "n";
|
||||
} else if (cell.t === "d") {
|
||||
luckyCell.v.ct.t = "d";
|
||||
} else if (cell.t === "b") {
|
||||
luckyCell.v.ct.t = "b";
|
||||
}
|
||||
|
||||
if (cell.f) {
|
||||
luckyCell.v.f = cell.f;
|
||||
}
|
||||
|
||||
cellData.push(luckyCell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SheetData 객체 생성
|
||||
const sheetData: SheetData = {
|
||||
id: `sheet_${index}`,
|
||||
name: safeSheetName,
|
||||
data: data,
|
||||
config: {
|
||||
container: `luckysheet_${index}`,
|
||||
title: safeSheetName,
|
||||
lang: "ko",
|
||||
data: [
|
||||
{
|
||||
name: safeSheetName,
|
||||
index: index.toString(),
|
||||
celldata: cellData,
|
||||
status: 1,
|
||||
order: index,
|
||||
row: maxRow,
|
||||
column: maxCol,
|
||||
},
|
||||
],
|
||||
options: {
|
||||
showtoolbar: true,
|
||||
showinfobar: false,
|
||||
showsheetbar: true,
|
||||
showstatisticBar: false,
|
||||
allowCopy: true,
|
||||
allowEdit: true,
|
||||
enableAddRow: true,
|
||||
enableAddCol: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
luckySheets.push(sheetData);
|
||||
console.log(`✅ 시트 "${sheetName}" 변환 완료: ${cellData.length}개 셀`);
|
||||
} catch (sheetError) {
|
||||
console.error(`❌ 시트 "${sheetName}" 변환 중 오류:`, sheetError);
|
||||
|
||||
// 오류 발생 시 빈 시트로 생성
|
||||
luckySheets.push({
|
||||
id: `sheet_${index}`,
|
||||
name: safeSheetName,
|
||||
data: [[""]],
|
||||
config: {
|
||||
container: `luckysheet_${index}`,
|
||||
title: safeSheetName,
|
||||
lang: "ko",
|
||||
data: [
|
||||
{
|
||||
name: safeSheetName,
|
||||
index: index.toString(),
|
||||
celldata: [],
|
||||
status: 1,
|
||||
order: index,
|
||||
row: 100,
|
||||
column: 26,
|
||||
},
|
||||
],
|
||||
options: {
|
||||
showtoolbar: true,
|
||||
showinfobar: false,
|
||||
showsheetbar: true,
|
||||
showstatisticBar: false,
|
||||
allowCopy: true,
|
||||
allowEdit: true,
|
||||
enableAddRow: true,
|
||||
enableAddCol: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 최소 1개 시트는 보장
|
||||
if (luckySheets.length === 0) {
|
||||
console.log("📄 시트가 없어서 기본 시트를 생성합니다.");
|
||||
luckySheets.push({
|
||||
id: "sheet_0",
|
||||
name: "Sheet1",
|
||||
data: [[""]],
|
||||
config: {
|
||||
container: "luckysheet_0",
|
||||
title: "Sheet1",
|
||||
lang: "ko",
|
||||
data: [
|
||||
{
|
||||
name: "Sheet1",
|
||||
index: "0",
|
||||
celldata: [],
|
||||
status: 1,
|
||||
order: 0,
|
||||
row: 100,
|
||||
column: 26,
|
||||
},
|
||||
],
|
||||
options: {
|
||||
showtoolbar: true,
|
||||
showinfobar: false,
|
||||
showsheetbar: true,
|
||||
showstatisticBar: false,
|
||||
allowCopy: true,
|
||||
allowEdit: true,
|
||||
enableAddRow: true,
|
||||
enableAddCol: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log(
|
||||
`🎉 SheetJS → LuckyExcel 변환 완료: ${luckySheets.length}개 시트`,
|
||||
);
|
||||
return luckySheets;
|
||||
}
|
||||
|
||||
/**
|
||||
* SheetJS를 사용한 Fallback 처리
|
||||
*/
|
||||
async function processWithSheetJSFallback(file: File): Promise<SheetData[]> {
|
||||
console.log("🔄 SheetJS Fallback 처리 시작...");
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
|
||||
// 단순한 SheetJS 옵션 사용 (이전 코드 방식)
|
||||
const workbook = XLSX.read(arrayBuffer, {
|
||||
type: "array",
|
||||
cellDates: true,
|
||||
cellNF: false,
|
||||
cellText: false,
|
||||
codepage: 65001, // UTF-8
|
||||
dense: false,
|
||||
sheetStubs: true,
|
||||
raw: false,
|
||||
});
|
||||
|
||||
console.log("📊 SheetJS Fallback 워크북 정보:", {
|
||||
sheetNames: workbook.SheetNames,
|
||||
sheetCount: workbook.SheetNames.length,
|
||||
});
|
||||
|
||||
if (workbook.SheetNames.length === 0) {
|
||||
throw new Error("파일에 워크시트가 없습니다.");
|
||||
}
|
||||
|
||||
return convertSheetJSToLuckyExcel(workbook);
|
||||
}
|
||||
|
||||
/**
|
||||
* LuckyExcel 처리 함수 (개선된 버전)
|
||||
*/
|
||||
function processWithLuckyExcel(file: File): Promise<SheetData[]> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
console.log("🍀 LuckyExcel 처리 시작...");
|
||||
|
||||
console.log("🔍 LuckyExcel 변환 시작:", {
|
||||
hasFile: !!file,
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
fileType: file.type,
|
||||
});
|
||||
|
||||
try {
|
||||
// File을 ArrayBuffer로 변환
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
|
||||
LuckyExcel.transformExcelToLucky(
|
||||
arrayBuffer, // ArrayBuffer 전달
|
||||
file.name, // 파일명 전달
|
||||
(exportJson: any, luckysheetfile: any) => {
|
||||
try {
|
||||
console.log("🔍 LuckyExcel 변환 결과:", {
|
||||
hasExportJson: !!exportJson,
|
||||
hasSheets: !!exportJson?.sheets,
|
||||
sheetsCount: exportJson?.sheets?.length || 0,
|
||||
});
|
||||
|
||||
// 데이터 유효성 검사
|
||||
if (
|
||||
!exportJson ||
|
||||
!exportJson.sheets ||
|
||||
!Array.isArray(exportJson.sheets)
|
||||
) {
|
||||
console.warn(
|
||||
"⚠️ LuckyExcel 결과가 유효하지 않습니다. SheetJS Fallback을 시도합니다.",
|
||||
);
|
||||
|
||||
// SheetJS Fallback 처리
|
||||
processWithSheetJSFallback(file)
|
||||
.then(resolve)
|
||||
.catch((fallbackError) => {
|
||||
console.error("❌ SheetJS Fallback도 실패:", fallbackError);
|
||||
reject(
|
||||
new Error(
|
||||
`파일 처리 실패: ${fallbackError instanceof Error ? fallbackError.message : fallbackError}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (exportJson.sheets.length === 0) {
|
||||
console.warn(
|
||||
"⚠️ LuckyExcel이 빈 시트를 반환했습니다. SheetJS Fallback을 시도합니다.",
|
||||
);
|
||||
|
||||
processWithSheetJSFallback(file)
|
||||
.then(resolve)
|
||||
.catch((fallbackError) => {
|
||||
console.error("❌ SheetJS Fallback도 실패:", fallbackError);
|
||||
reject(
|
||||
new Error(
|
||||
`파일 처리 실패: ${fallbackError instanceof Error ? fallbackError.message : fallbackError}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// LuckyExcel 변환이 성공한 경우 - SheetData 형식으로 변환
|
||||
const sheets: SheetData[] = exportJson.sheets.map(
|
||||
(luckySheet: any, index: number) => {
|
||||
const sheetName = luckySheet.name || `Sheet${index + 1}`;
|
||||
const maxRow = luckySheet.row || 0;
|
||||
const maxCol = luckySheet.column || 0;
|
||||
|
||||
// 2D 배열 초기화
|
||||
const data: any[][] = [];
|
||||
for (let r = 0; r < maxRow; r++) {
|
||||
data[r] = new Array(maxCol).fill("");
|
||||
}
|
||||
|
||||
// celldata에서 데이터 추출
|
||||
if (luckySheet.celldata && Array.isArray(luckySheet.celldata)) {
|
||||
luckySheet.celldata.forEach((cell: any) => {
|
||||
if (
|
||||
cell &&
|
||||
typeof cell.r === "number" &&
|
||||
typeof cell.c === "number"
|
||||
) {
|
||||
const row = cell.r;
|
||||
const col = cell.c;
|
||||
|
||||
if (row < maxRow && col < maxCol && cell.v) {
|
||||
const cellValue = cell.v.v || cell.v.m || "";
|
||||
data[row][col] = String(cellValue).trim();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 빈 데이터 처리
|
||||
if (data.length === 0) {
|
||||
data.push([""]);
|
||||
}
|
||||
|
||||
return {
|
||||
id: `sheet_${index}`,
|
||||
name: sheetName,
|
||||
data: data,
|
||||
config: {
|
||||
container: `luckysheet_${index}`,
|
||||
title: sheetName,
|
||||
lang: "ko",
|
||||
data: [
|
||||
{
|
||||
name: sheetName,
|
||||
index: index.toString(),
|
||||
celldata: luckySheet.celldata || [],
|
||||
status: 1,
|
||||
order: index,
|
||||
row: maxRow,
|
||||
column: maxCol,
|
||||
},
|
||||
],
|
||||
options: {
|
||||
showtoolbar: true,
|
||||
showinfobar: false,
|
||||
showsheetbar: true,
|
||||
showstatisticBar: false,
|
||||
allowCopy: true,
|
||||
allowEdit: true,
|
||||
enableAddRow: true,
|
||||
enableAddCol: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
console.log("✅ LuckyExcel 처리 성공:", sheets.length, "개 시트");
|
||||
resolve(sheets);
|
||||
} catch (e) {
|
||||
console.error("❌ LuckyExcel 후처리 중 오류:", e);
|
||||
reject(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("❌ LuckyExcel 처리 중 오류:", error);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 엑셀 파일을 SheetData 배열로 변환 (개선된 버전)
|
||||
* - 우선 LuckyExcel로 처리 시도 (XLSX)
|
||||
* - CSV, XLS는 SheetJS로 XLSX 변환 후 LuckyExcel 처리
|
||||
* - 실패 시 SheetJS Fallback 사용
|
||||
*/
|
||||
export async function processExcelFile(file: File): Promise<FileUploadResult> {
|
||||
try {
|
||||
const errorMessage = getFileErrorMessage(file);
|
||||
if (errorMessage) {
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
};
|
||||
}
|
||||
|
||||
// 파일 형식 감지
|
||||
const fileName = file.name.toLowerCase();
|
||||
const isCSV = fileName.endsWith(".csv");
|
||||
const isXLS = fileName.endsWith(".xls");
|
||||
const isXLSX = fileName.endsWith(".xlsx");
|
||||
|
||||
if (!isCSV && !isXLS && !isXLSX) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"지원되지 않는 파일 형식입니다. .csv, .xls, .xlsx 파일을 사용해주세요.",
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
};
|
||||
}
|
||||
|
||||
console.log(
|
||||
`📁 파일 처리 시작: ${file.name} (${isCSV ? "CSV" : isXLS ? "XLS" : "XLSX"})`,
|
||||
);
|
||||
|
||||
let sheets: SheetData[];
|
||||
|
||||
try {
|
||||
// 1차 시도: LuckyExcel 직접 처리 (XLSX만)
|
||||
if (isXLSX) {
|
||||
console.log("🍀 XLSX 파일 - LuckyExcel 직접 처리 시도");
|
||||
sheets = await processWithLuckyExcel(file);
|
||||
} else {
|
||||
// CSV, XLS는 SheetJS로 XLSX 변환 후 LuckyExcel 처리
|
||||
console.log(
|
||||
`📊 ${isCSV ? "CSV" : "XLS"} 파일 - SheetJS 변환 후 LuckyExcel 처리`,
|
||||
);
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
|
||||
// SheetJS로 읽기 (단순한 옵션 사용)
|
||||
let workbook: any;
|
||||
|
||||
if (isCSV) {
|
||||
// CSV 처리
|
||||
const text = new TextDecoder("utf-8").decode(arrayBuffer);
|
||||
workbook = XLSX.read(text, {
|
||||
type: "string",
|
||||
codepage: 65001,
|
||||
raw: false,
|
||||
});
|
||||
} else {
|
||||
// XLS 처리
|
||||
workbook = XLSX.read(arrayBuffer, {
|
||||
type: "array",
|
||||
codepage: 65001,
|
||||
raw: false,
|
||||
});
|
||||
}
|
||||
|
||||
// XLSX로 변환
|
||||
const xlsxBuffer = XLSX.write(workbook, {
|
||||
type: "array",
|
||||
bookType: "xlsx",
|
||||
compression: true,
|
||||
});
|
||||
|
||||
// File 객체로 변환하여 LuckyExcel 처리
|
||||
const xlsxFile = new File(
|
||||
[xlsxBuffer],
|
||||
file.name.replace(/\.(csv|xls)$/i, ".xlsx"),
|
||||
{
|
||||
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
},
|
||||
);
|
||||
|
||||
sheets = await processWithLuckyExcel(xlsxFile);
|
||||
}
|
||||
} catch (luckyExcelError) {
|
||||
console.warn(
|
||||
"🔄 LuckyExcel 처리 실패, SheetJS Fallback 시도:",
|
||||
luckyExcelError,
|
||||
);
|
||||
|
||||
// 2차 시도: SheetJS Fallback
|
||||
sheets = await processWithSheetJSFallback(file);
|
||||
}
|
||||
|
||||
if (!sheets || sheets.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: "파일에서 유효한 시트를 찾을 수 없습니다.",
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`🎉 파일 처리 완료: ${sheets.length}개 시트`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: sheets,
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("❌ 파일 처리 중 오류 발생:", error);
|
||||
|
||||
let errorMessage = "파일을 읽는 중 오류가 발생했습니다.";
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (
|
||||
error.message.includes("파일에 워크시트가 없습니다") ||
|
||||
error.message.includes("워크북 구조 오류") ||
|
||||
error.message.includes("파일 처리 실패")
|
||||
) {
|
||||
errorMessage = error.message;
|
||||
} else if (error.message.includes("transformExcelToLucky")) {
|
||||
errorMessage =
|
||||
"Excel 파일 변환에 실패했습니다. 파일이 손상되었거나 지원되지 않는 형식일 수 있습니다.";
|
||||
} else {
|
||||
errorMessage = `파일 처리 중 오류: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 파일 중 유효한 파일만 필터링
|
||||
*/
|
||||
export function filterValidFiles(files: FileList): File[] {
|
||||
return Array.from(files).filter((file) => {
|
||||
const errorMessage = getFileErrorMessage(file);
|
||||
return errorMessage === "";
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 목록의 에러 정보 수집
|
||||
*/
|
||||
export function getFileErrors(
|
||||
files: FileList,
|
||||
): { file: File; error: string }[] {
|
||||
const errors: { file: File; error: string }[] = [];
|
||||
|
||||
Array.from(files).forEach((file) => {
|
||||
const errorMessage = getFileErrorMessage(file);
|
||||
if (errorMessage !== "") {
|
||||
errors.push({ file, error: errorMessage });
|
||||
}
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
62
src/vite-env.d.ts
vendored
62
src/vite-env.d.ts
vendored
@@ -1 +1,63 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
/**
|
||||
* LuckyExcel 타입 선언
|
||||
*/
|
||||
declare module "luckyexcel" {
|
||||
interface LuckyExcelOptions {
|
||||
container?: string;
|
||||
title?: string;
|
||||
lang?: string;
|
||||
data?: any[];
|
||||
options?: {
|
||||
showtoolbar?: boolean;
|
||||
showinfobar?: boolean;
|
||||
showsheetbar?: boolean;
|
||||
showstatisticBar?: boolean;
|
||||
allowCopy?: boolean;
|
||||
allowEdit?: boolean;
|
||||
enableAddRow?: boolean;
|
||||
enableAddCol?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface LuckySheetData {
|
||||
name: string;
|
||||
index: string;
|
||||
celldata: any[];
|
||||
status: number;
|
||||
order: number;
|
||||
row: number;
|
||||
column: number;
|
||||
}
|
||||
|
||||
interface LuckyExcelResult {
|
||||
sheets: Array<{
|
||||
name: string;
|
||||
row: number;
|
||||
column: number;
|
||||
celldata: Array<{
|
||||
r: number;
|
||||
c: number;
|
||||
v: {
|
||||
v: any;
|
||||
m: string;
|
||||
ct?: any;
|
||||
};
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
|
||||
function transformExcelToLucky(
|
||||
arrayBuffer: ArrayBuffer,
|
||||
fileName: string,
|
||||
callback: (exportJson: LuckyExcelResult, luckysheetfile: any) => void,
|
||||
): void;
|
||||
|
||||
export {
|
||||
transformExcelToLucky,
|
||||
LuckyExcelOptions,
|
||||
LuckySheetData,
|
||||
LuckyExcelResult,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
/// <reference types="vitest" />
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "jsdom",
|
||||
setupFiles: ["./src/setupTests.ts"],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user