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": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"",
|
||||||
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"",
|
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"type-check": "tsc --noEmit"
|
"type-check": "tsc --noEmit",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:ui": "vitest --ui",
|
||||||
|
"test:coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.10",
|
"@tailwindcss/postcss": "^4.1.10",
|
||||||
@@ -30,7 +33,11 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.15.0",
|
"@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/file-saver": "^2.0.7",
|
||||||
|
"@types/jest": "^30.0.0",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.15.0",
|
"@typescript-eslint/eslint-plugin": "^8.15.0",
|
||||||
@@ -42,11 +49,13 @@
|
|||||||
"eslint-plugin-react-hooks": "^5.0.0",
|
"eslint-plugin-react-hooks": "^5.0.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.14",
|
"eslint-plugin-react-refresh": "^0.4.14",
|
||||||
"globals": "^15.12.0",
|
"globals": "^15.12.0",
|
||||||
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
"postcss": "^8.5.1",
|
"postcss": "^8.5.1",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "~5.6.2",
|
"typescript": "~5.6.2",
|
||||||
"vite": "^6.0.1"
|
"vite": "^6.0.1",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"excel",
|
"excel",
|
||||||
|
|||||||
137
src/App.tsx
137
src/App.tsx
@@ -1,140 +1,31 @@
|
|||||||
import { useAppStore } from "./stores/useAppStore";
|
import { useAppStore } from "./stores/useAppStore";
|
||||||
import { Card, CardContent } from "./components/ui/card";
|
import { Card, CardContent } from "./components/ui/card";
|
||||||
import { Button } from "./components/ui/button";
|
import { Button } from "./components/ui/button";
|
||||||
|
import { FileUpload } from "./components/sheet/FileUpload";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { isLoading, loadingMessage, currentFile } = useAppStore();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-gray-50">
|
||||||
{/* 상단 바 */}
|
{/* 헤더 */}
|
||||||
<header className="border-b bg-white/95 backdrop-blur supports-[backdrop-filter]:bg-white/60">
|
<header className="bg-white shadow-sm border-b">
|
||||||
<div className="container flex h-16 items-center px-4">
|
<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">
|
<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>
|
||||||
|
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* 메인 콘텐츠 */}
|
{/* 메인 콘텐츠 */}
|
||||||
<main className="container mx-auto px-4 py-8">
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
{!currentFile ? (
|
<FileUpload />
|
||||||
/* 파일 업로드 영역 */
|
|
||||||
<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>
|
</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>
|
</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 components;
|
||||||
@tailwind utilities;
|
@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 {
|
body {
|
||||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
color: rgba(255, 255, 255, 0.87);
|
color: #1f2937; /* 검은색 계열로 변경 */
|
||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
|
|
||||||
font-synthesis: none;
|
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;
|
loadingMessage: string;
|
||||||
isHistoryPanelOpen: boolean;
|
isHistoryPanelOpen: boolean;
|
||||||
|
|
||||||
|
// 에러 상태
|
||||||
|
error: string | null;
|
||||||
|
fileUploadErrors: Array<{ fileName: string; error: string }>;
|
||||||
|
|
||||||
// AI 상태
|
// AI 상태
|
||||||
aiHistory: AIHistory[];
|
aiHistory: AIHistory[];
|
||||||
isProcessingAI: boolean;
|
isProcessingAI: boolean;
|
||||||
@@ -46,6 +50,11 @@ interface AppState {
|
|||||||
setLoading: (loading: boolean, message?: string) => void;
|
setLoading: (loading: boolean, message?: string) => void;
|
||||||
setHistoryPanelOpen: (open: boolean) => void;
|
setHistoryPanelOpen: (open: boolean) => void;
|
||||||
|
|
||||||
|
// 에러 관리
|
||||||
|
setError: (error: string | null) => void;
|
||||||
|
addFileUploadError: (fileName: string, error: string) => void;
|
||||||
|
clearFileUploadErrors: () => void;
|
||||||
|
|
||||||
addAIHistory: (history: AIHistory) => void;
|
addAIHistory: (history: AIHistory) => void;
|
||||||
setProcessingAI: (processing: boolean) => void;
|
setProcessingAI: (processing: boolean) => void;
|
||||||
|
|
||||||
@@ -64,13 +73,15 @@ const initialState = {
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
loadingMessage: "",
|
loadingMessage: "",
|
||||||
isHistoryPanelOpen: false,
|
isHistoryPanelOpen: false,
|
||||||
|
error: null,
|
||||||
|
fileUploadErrors: [],
|
||||||
aiHistory: [],
|
aiHistory: [],
|
||||||
isProcessingAI: false,
|
isProcessingAI: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useAppStore = create<AppState>()(
|
export const useAppStore = create<AppState>()(
|
||||||
devtools(
|
devtools(
|
||||||
(set) => ({
|
(set, get) => ({
|
||||||
...initialState,
|
...initialState,
|
||||||
|
|
||||||
// 사용자 액션
|
// 사용자 액션
|
||||||
@@ -92,6 +103,14 @@ export const useAppStore = create<AppState>()(
|
|||||||
}),
|
}),
|
||||||
setHistoryPanelOpen: (open) => set({ isHistoryPanelOpen: open }),
|
setHistoryPanelOpen: (open) => set({ isHistoryPanelOpen: open }),
|
||||||
|
|
||||||
|
// 에러 관리
|
||||||
|
setError: (error) => set({ error }),
|
||||||
|
addFileUploadError: (fileName, error) =>
|
||||||
|
set((state) => ({
|
||||||
|
fileUploadErrors: [...state.fileUploadErrors, { fileName, error }],
|
||||||
|
})),
|
||||||
|
clearFileUploadErrors: () => set({ fileUploadErrors: [] }),
|
||||||
|
|
||||||
// AI 액션
|
// AI 액션
|
||||||
addAIHistory: (history) =>
|
addAIHistory: (history) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
@@ -110,7 +129,17 @@ export const useAppStore = create<AppState>()(
|
|||||||
},
|
},
|
||||||
sheets: result.data,
|
sheets: result.data,
|
||||||
activeSheetId: result.data[0]?.id || null,
|
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" />
|
/// <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'
|
/// <reference types="vitest" />
|
||||||
import react from '@vitejs/plugin-react'
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
})
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: "jsdom",
|
||||||
|
setupFiles: ["./src/setupTests.ts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user