Compare commits
16 Commits
bc5b316f3c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fede2eda26 | ||
|
|
2f3515985d | ||
|
|
535281f0fb | ||
|
|
3d0a5799ff | ||
|
|
1419bf415f | ||
|
|
b09a417291 | ||
|
|
e5ee01553a | ||
|
|
2d8e4524b7 | ||
|
|
71036d3727 | ||
|
|
17d17511f5 | ||
|
|
5712c40ec9 | ||
|
|
105265a384 | ||
|
|
164db92e06 | ||
|
|
ba58aaabf5 | ||
|
|
d9a198a157 | ||
|
|
de6b4debac |
103
.cursor/rules/cursor-step-by-step-rule.mdc
Normal file
103
.cursor/rules/cursor-step-by-step-rule.mdc
Normal file
@@ -0,0 +1,103 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
## Core Directive
|
||||
You are a senior software engineer AI assistant. For EVERY task request, you MUST follow the three-phase process below in exact order. Each phase must be completed with expert-level precision and detail.
|
||||
|
||||
## Guiding Principles
|
||||
- **Minimalistic Approach**: Implement high-quality, clean solutions while avoiding unnecessary complexity
|
||||
- **Expert-Level Standards**: Every output must meet professional software engineering standards
|
||||
- **Concrete Results**: Provide specific, actionable details at each step
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Codebase Exploration & Analysis
|
||||
**REQUIRED ACTIONS:**
|
||||
1. **Systematic File Discovery**
|
||||
- List ALL potentially relevant files, directories, and modules
|
||||
- Search for related keywords, functions, classes, and patterns
|
||||
- Examine each identified file thoroughly
|
||||
|
||||
2. **Convention & Style Analysis**
|
||||
- Document coding conventions (naming, formatting, architecture patterns)
|
||||
- Identify existing code style guidelines
|
||||
- Note framework/library usage patterns
|
||||
- Catalog error handling approaches
|
||||
|
||||
**OUTPUT FORMAT:**
|
||||
```
|
||||
### Codebase Analysis Results
|
||||
**Relevant Files Found:**
|
||||
- [file_path]: [brief description of relevance]
|
||||
|
||||
**Code Conventions Identified:**
|
||||
- Naming: [convention details]
|
||||
- Architecture: [pattern details]
|
||||
- Styling: [format details]
|
||||
|
||||
**Key Dependencies & Patterns:**
|
||||
- [library/framework]: [usage pattern]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Implementation Planning
|
||||
**REQUIRED ACTIONS:**
|
||||
Based on Phase 1 findings, create a detailed implementation roadmap.
|
||||
|
||||
**OUTPUT FORMAT:**
|
||||
```markdown
|
||||
## Implementation Plan
|
||||
|
||||
### Module: [Module Name]
|
||||
**Summary:** [1-2 sentence description of what needs to be implemented]
|
||||
|
||||
**Tasks:**
|
||||
- [ ] [Specific implementation task]
|
||||
- [ ] [Specific implementation task]
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] [Measurable success criterion]
|
||||
- [ ] [Measurable success criterion]
|
||||
- [ ] [Performance/quality requirement]
|
||||
|
||||
### Module: [Next Module Name]
|
||||
[Repeat structure above]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Implementation Execution
|
||||
**REQUIRED ACTIONS:**
|
||||
1. Implement each module following the plan from Phase 2
|
||||
2. Verify ALL acceptance criteria are met before proceeding
|
||||
3. Ensure code adheres to conventions identified in Phase 1
|
||||
|
||||
**QUALITY GATES:**
|
||||
- [ ] All acceptance criteria validated
|
||||
- [ ] Code follows established conventions
|
||||
- [ ] Minimalistic approach maintained
|
||||
- [ ] Expert-level implementation standards met
|
||||
|
||||
---
|
||||
|
||||
## Success Validation
|
||||
Before completing any task, confirm:
|
||||
- ✅ All three phases completed sequentially
|
||||
- ✅ Each phase output meets specified format requirements
|
||||
- ✅ Implementation satisfies all acceptance criteria
|
||||
- ✅ Code quality meets professional standards
|
||||
|
||||
## Response Structure
|
||||
Always structure your response as:
|
||||
1. **Phase 1 Results**: [Codebase analysis findings]
|
||||
2. **Phase 2 Plan**: [Implementation roadmap]
|
||||
3. **Phase 3 Implementation**: [Actual code with validation]
|
||||
194
.cursor/rules/luckysheet-functionlist-error-fix.mdc
Normal file
194
.cursor/rules/luckysheet-functionlist-error-fix.mdc
Normal file
@@ -0,0 +1,194 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# Luckysheet Functionlist Error Prevention
|
||||
|
||||
**Root Cause Analysis:**
|
||||
- Luckysheet internally references multiple functionlist objects at different namespaces
|
||||
- Timing issues between library loading and object initialization
|
||||
- Incomplete plugin.js loading which should initialize functionlist objects
|
||||
|
||||
## **Critical Loading Order (MUST Follow)**
|
||||
|
||||
```typescript
|
||||
// ✅ DO: Complete library loading sequence
|
||||
const loadSequence = async () => {
|
||||
// 1. jQuery (Luckysheet dependency)
|
||||
await loadResource("js", "https://code.jquery.com/jquery-3.6.0.min.js", "jquery");
|
||||
|
||||
// 2. CSS files (in specific order)
|
||||
await loadResource("css", "/luckysheet/dist/plugins/css/pluginsCss.css", "plugins-css");
|
||||
await loadResource("css", "/luckysheet/dist/plugins/plugins.css", "plugins-main-css");
|
||||
await loadResource("css", "/luckysheet/dist/css/luckysheet.css", "luckysheet-css");
|
||||
await loadResource("css", "/luckysheet/dist/assets/iconfont/iconfont.css", "iconfont-css");
|
||||
|
||||
// 3. LuckyExcel (for Excel file processing)
|
||||
await loadResource("js", "/luckysheet/dist/luckyexcel.umd.js", "luckyexcel");
|
||||
|
||||
// 4. Plugin JS (CRITICAL: initializes functionlist)
|
||||
await loadResource("js", "/luckysheet/dist/plugins/js/plugin.js", "plugin-js");
|
||||
|
||||
// 5. Luckysheet main library
|
||||
await loadResource("js", "/luckysheet/dist/luckysheet.umd.js", "luckysheet");
|
||||
|
||||
// 6. CRITICAL: Wait for internal object initialization
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
};
|
||||
```
|
||||
|
||||
## **Multi-Level Functionlist Initialization**
|
||||
|
||||
```typescript
|
||||
// ✅ DO: Initialize all possible functionlist reference paths
|
||||
const initializeFunctionlist = () => {
|
||||
try {
|
||||
// Level 1: Core Store objects
|
||||
if (!window.Store) window.Store = {};
|
||||
if (!window.Store.functionlist) window.Store.functionlist = [];
|
||||
if (!window.Store.luckysheet_function) window.Store.luckysheet_function = {};
|
||||
|
||||
// Level 2: Global function objects
|
||||
if (!window.luckysheet_function) window.luckysheet_function = {};
|
||||
if (!window.functionlist) window.functionlist = [];
|
||||
|
||||
// Level 3: Luckysheet internal objects
|
||||
if (window.luckysheet) {
|
||||
if (!window.luckysheet.functionlist) window.luckysheet.functionlist = [];
|
||||
if (!window.luckysheet.formula) window.luckysheet.formula = {};
|
||||
if (!window.luckysheet.formulaCache) window.luckysheet.formulaCache = {};
|
||||
if (!window.luckysheet.formulaObjects) window.luckysheet.formulaObjects = {};
|
||||
}
|
||||
|
||||
// Level 4: Store internal structure
|
||||
if (!window.Store.config) window.Store.config = {};
|
||||
if (!window.Store.luckysheetfile) window.Store.luckysheetfile = [];
|
||||
if (!window.Store.currentSheetIndex) window.Store.currentSheetIndex = 0;
|
||||
} catch (error) {
|
||||
console.warn("Functionlist initialization warning:", error);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## **TypeScript Window Interface Extension**
|
||||
|
||||
```typescript
|
||||
// ✅ DO: Extend Window interface for all Luckysheet globals
|
||||
declare global {
|
||||
interface Window {
|
||||
luckysheet: any;
|
||||
LuckyExcel: any;
|
||||
$: any; // jQuery
|
||||
Store: any; // Luckysheet Store
|
||||
luckysheet_function: any; // Luckysheet function list
|
||||
functionlist: any[]; // Global functionlist
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## **Critical Timing Requirements**
|
||||
|
||||
- **MUST** call `initializeFunctionlist()` at three points:
|
||||
1. After library loading sequence completion
|
||||
2. After 1000ms wait period for internal initialization
|
||||
3. Immediately before `luckysheet.create()` call
|
||||
|
||||
- **MUST** wait at least 1000ms after all libraries are loaded
|
||||
- **MUST** verify all functionlist objects exist before calling `luckysheet.create()`
|
||||
|
||||
## **Error Recovery Pattern**
|
||||
|
||||
```typescript
|
||||
// ✅ DO: Implement robust error recovery
|
||||
try {
|
||||
// Final verification before luckysheet.create()
|
||||
initializeFunctionlist();
|
||||
|
||||
// Verify critical objects exist
|
||||
const verificationResults = {
|
||||
store: !!window.Store,
|
||||
functionlist: !!window.Store?.functionlist,
|
||||
luckysheet: !!window.luckysheet,
|
||||
createFunction: typeof window.luckysheet?.create === "function"
|
||||
};
|
||||
|
||||
if (!verificationResults.luckysheet || !verificationResults.createFunction) {
|
||||
throw new Error("Luckysheet not properly initialized");
|
||||
}
|
||||
|
||||
window.luckysheet.create(options);
|
||||
} catch (error) {
|
||||
console.error("Luckysheet initialization failed:", error);
|
||||
// Implement retry logic or fallback
|
||||
}
|
||||
```
|
||||
|
||||
## **Common Anti-Patterns to Avoid**
|
||||
|
||||
```typescript
|
||||
// ❌ DON'T: Skip plugin.js loading
|
||||
// plugin.js is CRITICAL for functionlist initialization
|
||||
|
||||
// ❌ DON'T: Use insufficient wait times
|
||||
await new Promise(resolve => setTimeout(resolve, 100)); // TOO SHORT
|
||||
|
||||
// ❌ DON'T: Initialize only Store.functionlist
|
||||
// Multiple objects need initialization
|
||||
|
||||
// ❌ DON'T: Call luckysheet.create() immediately after library load
|
||||
// Internal objects need time to initialize
|
||||
```
|
||||
|
||||
## **Debugging Checklist**
|
||||
|
||||
When functionlist errors occur:
|
||||
1. ✅ Verify all libraries loaded in correct order
|
||||
2. ✅ Check plugin.js is included and loaded
|
||||
3. ✅ Confirm 1000ms wait after library loading
|
||||
4. ✅ Verify all functionlist objects are arrays/objects (not undefined)
|
||||
5. ✅ Check console for library loading errors
|
||||
6. ✅ Ensure complete Luckysheet distribution is used (not partial)
|
||||
|
||||
## **Critical: Use Official LuckyExcel Pattern**
|
||||
|
||||
```typescript
|
||||
// ✅ DO: Follow official LuckyExcel → Luckysheet pattern exactly
|
||||
LuckyExcel.transformExcelToLucky(arrayBuffer, fileName,
|
||||
// Success callback
|
||||
(exportJson: any, luckysheetfile: any) => {
|
||||
// CRITICAL: Use exportJson.sheets directly, no custom validation
|
||||
const luckysheetOptions = {
|
||||
container: 'luckysheet-container',
|
||||
data: exportJson.sheets, // Direct usage - don't modify!
|
||||
title: exportJson.info?.name || fileName,
|
||||
userInfo: exportJson.info?.creator || "User",
|
||||
lang: "ko"
|
||||
};
|
||||
|
||||
window.luckysheet.create(luckysheetOptions);
|
||||
},
|
||||
// Error callback
|
||||
(error: any) => {
|
||||
console.error("LuckyExcel conversion failed:", error);
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
## **Anti-Pattern: Over-Processing Data**
|
||||
|
||||
```typescript
|
||||
// ❌ DON'T: Modify or validate exportJson.sheets structure
|
||||
const validatedSheets = exportJson.sheets.map(sheet => ({
|
||||
name: sheet?.name || `Sheet${index}`,
|
||||
data: Array.isArray(sheet?.data) ? sheet.data : [],
|
||||
// ... other modifications
|
||||
}));
|
||||
|
||||
// ❌ DON'T: Use modified data
|
||||
luckysheet.create({ data: validatedSheets });
|
||||
```
|
||||
|
||||
The root cause of functionlist errors is often data structure mismatch between LuckyExcel output and Luckysheet expectations. Using exportJson.sheets directly maintains the proper internal structure that Luckysheet requires.
|
||||
|
||||
This pattern successfully resolves the "Cannot read properties of undefined (reading 'functionlist')" error by ensuring complete library loading sequence and multi-level functionlist initialization.
|
||||
122
.cursor/rules/shadcn-tailwind-v4.mdc
Normal file
122
.cursor/rules/shadcn-tailwind-v4.mdc
Normal file
@@ -0,0 +1,122 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# Tailwind CSS v4 + Shadcn UI 호환성 규칙
|
||||
|
||||
## **CSS 설정 (src/index.css)**
|
||||
|
||||
- **@theme 레이어 사용**
|
||||
```css
|
||||
@theme {
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
```
|
||||
|
||||
- **CSS 변수 정의**
|
||||
- `:root`에 라이트 모드 색상 변수 정의
|
||||
- `.dark`에 다크 모드 색상 변수 정의
|
||||
- `hsl(var(--foreground))` 형태로 색상 사용
|
||||
|
||||
## **cn 함수 (src/lib/utils.ts)**
|
||||
|
||||
- **에러 핸들링 필수**
|
||||
```typescript
|
||||
// ✅ DO: fallback 로직 포함
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
try {
|
||||
return twMerge(clsx(inputs));
|
||||
} catch (error) {
|
||||
console.warn("tailwind-merge fallback:", error);
|
||||
return clsx(inputs);
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ DON'T: 에러 핸들링 없이 사용
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
```
|
||||
|
||||
## **컴포넌트 스타일링**
|
||||
|
||||
- **CSS 변수 활용**
|
||||
```typescript
|
||||
// ✅ DO: CSS 변수 기반 스타일링
|
||||
className="bg-background text-foreground border-border"
|
||||
|
||||
// ✅ DO: cn 함수로 조건부 스타일링
|
||||
className={cn(
|
||||
"base-styles",
|
||||
condition && "conditional-styles"
|
||||
)}
|
||||
```
|
||||
|
||||
- **색상 시스템 준수**
|
||||
- `background`, `foreground`, `primary`, `secondary` 등 정의된 변수 사용
|
||||
- 직접 색상 값 대신 변수 사용
|
||||
|
||||
## **패키지 관리**
|
||||
|
||||
- **필수 패키지**
|
||||
```json
|
||||
{
|
||||
"@tailwindcss/cli": "^4.1.10",
|
||||
"@tailwindcss/vite": "^4.1.10",
|
||||
"tailwind-merge": "latest",
|
||||
"clsx": "^2.1.1",
|
||||
"class-variance-authority": "^0.7.1"
|
||||
}
|
||||
```
|
||||
|
||||
- **제거해야 할 파일**
|
||||
- `tailwind.config.js` (v4는 CSS-first 방식)
|
||||
- `postcss.config.js` (v4는 PostCSS 불필요)
|
||||
|
||||
## **Vite 설정**
|
||||
|
||||
- **플러그인 설정**
|
||||
```typescript
|
||||
// vite.config.ts
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
});
|
||||
```
|
||||
|
||||
## **문제 해결**
|
||||
|
||||
- **tailwind-merge 오류 시**
|
||||
- 최신 버전으로 업데이트
|
||||
- cn 함수에 fallback 로직 구현
|
||||
|
||||
- **스타일이 적용되지 않을 때**
|
||||
- CSS 변수가 올바르게 정의되었는지 확인
|
||||
- @theme 레이어가 포함되었는지 확인
|
||||
|
||||
- **빌드 오류 시**
|
||||
- node_modules 캐시 삭제 후 재설치
|
||||
- package-lock.json 삭제 후 재설치
|
||||
|
||||
## **모범 사례**
|
||||
|
||||
- **컴포넌트 개발 시**
|
||||
- 항상 CSS 변수 사용
|
||||
- cn 함수로 클래스 조합
|
||||
- 조건부 스타일링에 적절한 패턴 적용
|
||||
|
||||
- **테마 관리**
|
||||
- 라이트/다크 모드 변수 동시 정의
|
||||
- 일관된 색상 시스템 유지
|
||||
|
||||
- **성능 최적화**
|
||||
- 불필요한 클래스 중복 방지
|
||||
- cn 함수 사용으로 클래스 충돌 해결
|
||||
|
||||
## **참고 자료**
|
||||
|
||||
- [Tailwind CSS v4 공식 문서](https://tailwindcss.com/docs/v4-beta)
|
||||
- [Shadcn UI + Tailwind v4 가이드](https://www.luisball.com/blog/shadcn-ui-with-tailwind-v4)
|
||||
- [Shadcn UI 공식 설치 가이드](https://ui.shadcn.com/docs/installation/manual)
|
||||
5
.cursor/rules/tailwind-css-management.mdc
Normal file
5
.cursor/rules/tailwind-css-management.mdc
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
172
.cursor/rules/tailwind-v4-migration.mdc
Normal file
172
.cursor/rules/tailwind-v4-migration.mdc
Normal file
@@ -0,0 +1,172 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# Tailwind CSS v4 Migration Guide
|
||||
|
||||
## **Key Changes in Tailwind CSS v4**
|
||||
- **No PostCSS dependency**: v4 has built-in CSS processing
|
||||
- **Simplified import**: Use `@import "tailwindcss"` instead of separate directives
|
||||
- **Vite plugin**: Use `@tailwindcss/vite` for Vite integration
|
||||
- **No config file needed**: Configuration through CSS custom properties
|
||||
|
||||
## **Complete Migration Steps**
|
||||
|
||||
### **1. Remove All v3 Dependencies**
|
||||
```bash
|
||||
# Remove all Tailwind v3 and PostCSS packages
|
||||
npm uninstall tailwindcss @tailwindcss/postcss @tailwindcss/node @tailwindcss/oxide
|
||||
npm uninstall autoprefixer postcss postcss-import
|
||||
```
|
||||
|
||||
### **2. Install Tailwind CSS v4**
|
||||
```bash
|
||||
# Install v4 packages
|
||||
npm install @tailwindcss/cli@next @tailwindcss/vite@next
|
||||
npm install tailwind-merge # For utility merging
|
||||
```
|
||||
|
||||
### **3. Update CSS Import**
|
||||
```css
|
||||
/* ❌ DON'T: v3 style imports */
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* ✅ DO: v4 single import */
|
||||
@import "tailwindcss";
|
||||
```
|
||||
|
||||
### **4. Update Vite Configuration**
|
||||
```typescript
|
||||
// ❌ DON'T: v3 PostCSS setup
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "tailwindcss";
|
||||
import autoprefixer from "autoprefixer";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
css: {
|
||||
postcss: {
|
||||
plugins: [tailwindcss(), autoprefixer()],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// ✅ DO: v4 Vite plugin
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
});
|
||||
```
|
||||
|
||||
### **5. Remove Configuration Files**
|
||||
```bash
|
||||
# Delete these files (v4 doesn't need them)
|
||||
rm postcss.config.js
|
||||
rm tailwind.config.js
|
||||
```
|
||||
|
||||
## **v4 Configuration (Optional)**
|
||||
|
||||
### **CSS-based Configuration**
|
||||
```css
|
||||
/* src/index.css */
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Custom theme configuration */
|
||||
@theme {
|
||||
--color-primary: #3b82f6;
|
||||
--color-secondary: #64748b;
|
||||
--font-family-custom: "Inter", sans-serif;
|
||||
}
|
||||
```
|
||||
|
||||
### **Advanced Configuration**
|
||||
```css
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Custom utilities */
|
||||
@utility {
|
||||
.scroll-smooth {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom components */
|
||||
@component {
|
||||
.btn {
|
||||
@apply px-4 py-2 rounded font-medium;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-blue-500 text-white hover:bg-blue-600;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## **Migration Checklist**
|
||||
|
||||
### **Dependencies**
|
||||
- ✅ Remove: `tailwindcss`, `@tailwindcss/postcss`, `autoprefixer`, `postcss`
|
||||
- ✅ Install: `@tailwindcss/cli@next`, `@tailwindcss/vite@next`
|
||||
- ✅ Keep: `tailwind-merge`, `class-variance-authority`, `clsx`
|
||||
|
||||
### **Files**
|
||||
- ✅ Update: `src/index.css` - Use `@import "tailwindcss"`
|
||||
- ✅ Update: `vite.config.ts` - Use `@tailwindcss/vite` plugin
|
||||
- ✅ Delete: `postcss.config.js`, `tailwind.config.js`
|
||||
|
||||
### **Code Changes**
|
||||
- ✅ All existing Tailwind classes work the same
|
||||
- ✅ `tailwind-merge` still works for utility merging
|
||||
- ✅ Custom CSS can be added alongside Tailwind
|
||||
|
||||
## **Benefits of v4**
|
||||
- **Faster builds**: No PostCSS processing overhead
|
||||
- **Simpler setup**: Fewer configuration files
|
||||
- **Better performance**: Optimized CSS generation
|
||||
- **Modern architecture**: Built for current web standards
|
||||
|
||||
## **Troubleshooting**
|
||||
|
||||
### **Build Errors**
|
||||
```bash
|
||||
# If you see PostCSS errors, ensure all PostCSS packages are removed
|
||||
npm ls | grep postcss # Should return nothing
|
||||
|
||||
# Clean install if needed
|
||||
rm -rf node_modules package-lock.json
|
||||
npm install
|
||||
```
|
||||
|
||||
### **CSS Not Loading**
|
||||
```css
|
||||
/* Ensure correct import in src/index.css */
|
||||
@import "tailwindcss";
|
||||
|
||||
/* NOT these old imports */
|
||||
/* @tailwind base; */
|
||||
/* @tailwind components; */
|
||||
/* @tailwind utilities; */
|
||||
```
|
||||
|
||||
### **Vite Plugin Issues**
|
||||
```typescript
|
||||
// Ensure correct plugin import
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
// Add to plugins array
|
||||
plugins: [react(), tailwindcss()]
|
||||
```
|
||||
|
||||
## **Best Practices**
|
||||
- **Test thoroughly**: Verify all Tailwind classes still work
|
||||
- **Update incrementally**: Migrate one component at a time if needed
|
||||
- **Monitor bundle size**: v4 should reduce overall CSS size
|
||||
- **Use CSS-in-CSS**: Leverage v4's CSS-based configuration for themes
|
||||
5
.cursor/rules/tutorial-navigation-fix.mdc
Normal file
5
.cursor/rules/tutorial-navigation-fix.mdc
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
5
.cursor/rules/tutorial-simulation.mdc
Normal file
5
.cursor/rules/tutorial-simulation.mdc
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
326
.cursor/rules/univer-initialization.mdc
Normal file
326
.cursor/rules/univer-initialization.mdc
Normal file
@@ -0,0 +1,326 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# Univer CE 초기화 및 인스턴스 관리 규칙
|
||||
|
||||
## **핵심 원칙**
|
||||
- **단일 인스턴스**: 컴포넌트당 하나의 Univer 인스턴스만 유지
|
||||
- **중복 방지**: 플러그인 등록과 초기화 중복 실행 방지
|
||||
- **적절한 정리**: 메모리 누수 방지를 위한 dispose 패턴
|
||||
|
||||
## **초기화 패턴**
|
||||
|
||||
### ✅ DO: 안전한 초기화
|
||||
```typescript
|
||||
// 인스턴스 존재 확인 후 초기화
|
||||
useEffect(() => {
|
||||
if (!univerRef.current) {
|
||||
initializeUniver();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 처리 중 상태 확인으로 중복 방지
|
||||
const processFile = useCallback(async (file: File) => {
|
||||
if (isProcessing) {
|
||||
console.log("이미 파일 처리 중입니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
// ... 파일 처리 로직
|
||||
}, [isProcessing]);
|
||||
```
|
||||
|
||||
### ❌ DON'T: 중복 초기화 유발
|
||||
```typescript
|
||||
// 조건 없는 초기화 (중복 실행 위험)
|
||||
useEffect(() => {
|
||||
initializeUniver(); // 매번 실행됨
|
||||
}, []);
|
||||
|
||||
// 상태 확인 없는 처리
|
||||
const processFile = useCallback(async (file: File) => {
|
||||
// isProcessing 확인 없이 바로 실행
|
||||
setIsProcessing(true);
|
||||
}, []);
|
||||
```
|
||||
|
||||
## **인스턴스 관리**
|
||||
|
||||
### ✅ DO: 적절한 dispose 패턴
|
||||
```typescript
|
||||
// 새 인스턴스 생성 전 기존 인스턴스 정리
|
||||
if (univerRef.current) {
|
||||
univerRef.current.dispose();
|
||||
univerRef.current = null;
|
||||
}
|
||||
|
||||
// 컴포넌트 언마운트 시 정리
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (univerRef.current) {
|
||||
univerRef.current.dispose();
|
||||
univerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
```
|
||||
|
||||
### ❌ DON'T: 메모리 누수 위험
|
||||
```typescript
|
||||
// dispose 없이 새 인스턴스 생성
|
||||
univerRef.current = univer.createUnit(UnitType.UNIVER_SHEET, workbook);
|
||||
|
||||
// 정리 로직 없는 컴포넌트
|
||||
// useEffect cleanup 누락
|
||||
```
|
||||
|
||||
## **파일 처리 최적화**
|
||||
|
||||
### ✅ DO: 중복 처리 방지
|
||||
```typescript
|
||||
// 파일 입력 초기화로 재선택 가능
|
||||
finally {
|
||||
if (event.target) {
|
||||
event.target.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
// 의존성 배열에 상태 포함
|
||||
const processFile = useCallback(async (file: File) => {
|
||||
// ... 로직
|
||||
}, [isProcessing]);
|
||||
```
|
||||
|
||||
## **REDI 중복 로드 방지**
|
||||
|
||||
### ✅ DO: 라이브러리 중복 확인
|
||||
```typescript
|
||||
// 개발 환경에서 REDI 중복 로드 경고 모니터링
|
||||
// 동일한 라이브러리 버전 사용 확인
|
||||
// 번들링 시 중복 제거 설정
|
||||
```
|
||||
|
||||
### ❌ DON'T: 무시하면 안 되는 경고
|
||||
```typescript
|
||||
// REDI 중복 로드 경고 무시
|
||||
// 서로 다른 버전의 동일 라이브러리 사용
|
||||
// 번들 중복 제거 설정 누락
|
||||
```
|
||||
|
||||
## **디버깅 및 로깅**
|
||||
|
||||
### ✅ DO: 의미있는 로그
|
||||
```typescript
|
||||
console.log("초기화할 워크북 데이터:", workbookData);
|
||||
console.log("Univer 인스턴스 생성 완료");
|
||||
console.log("플러그인 등록 완료");
|
||||
```
|
||||
|
||||
### ❌ DON'T: 과도한 로깅
|
||||
```typescript
|
||||
// 매 렌더링마다 로그 출력
|
||||
// 민감한 데이터 로깅
|
||||
// 프로덕션 환경 디버그 로그 유지
|
||||
```
|
||||
|
||||
## **성능 최적화**
|
||||
|
||||
- **useCallback**: 이벤트 핸들러 메모이제이션
|
||||
- **의존성 최적화**: 필요한 의존성만 포함
|
||||
- **조건부 실행**: 불필요한 재실행 방지
|
||||
- **메모리 관리**: 적절한 dispose와 cleanup
|
||||
|
||||
이 규칙을 따르면 Univer CE가 안정적으로 작동하고 메모리 누수 없이 파일 처리가 가능합니다.
|
||||
|
||||
# Univer CE REDI 중복 로드 오류 완전 방지 규칙
|
||||
|
||||
## **문제 원인**
|
||||
- Univer CE의 REDI 시스템에서 "Identifier already exists" 오류는 동일한 서비스가 중복으로 등록될 때 발생
|
||||
- 컴포넌트 재마운트, HMR(Hot Module Reload), 또는 동시 초기화 시도로 인한 중복 인스턴스 생성
|
||||
- 브라우저 캐시에 잔존하는 이전 REDI 등록 정보
|
||||
|
||||
## **필수 해결 패턴**
|
||||
|
||||
### **1. 전역 인스턴스 관리자 패턴 사용**
|
||||
```typescript
|
||||
// ✅ DO: 모듈 레벨에서 단일 인스턴스 보장
|
||||
let globalUniver: Univer | null = null;
|
||||
let globalInitializing = false;
|
||||
let globalDisposing = false;
|
||||
|
||||
const UniverseManager = {
|
||||
async createInstance(container: HTMLElement): Promise<Univer> {
|
||||
if (globalUniver) return globalUniver;
|
||||
|
||||
if (globalInitializing) {
|
||||
// 초기화 완료까지 대기
|
||||
while (globalInitializing) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
if (globalUniver) return globalUniver;
|
||||
}
|
||||
|
||||
globalInitializing = true;
|
||||
try {
|
||||
// Univer 인스턴스 생성 로직
|
||||
globalUniver = new Univer({...});
|
||||
return globalUniver;
|
||||
} finally {
|
||||
globalInitializing = false;
|
||||
}
|
||||
},
|
||||
|
||||
getInstance(): Univer | null {
|
||||
return globalUniver;
|
||||
}
|
||||
};
|
||||
|
||||
// ❌ DON'T: 컴포넌트마다 개별 인스턴스 생성
|
||||
const MyComponent = () => {
|
||||
const [univer, setUniver] = useState<Univer | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const newUniver = new Univer({...}); // 중복 생성 위험
|
||||
setUniver(newUniver);
|
||||
}, []);
|
||||
};
|
||||
```
|
||||
|
||||
### **2. 상태 기반 중복 초기화 방지**
|
||||
```typescript
|
||||
// ✅ DO: 강화된 상태 확인으로 중복 실행 완전 차단
|
||||
const processFile = useCallback(async (file: File) => {
|
||||
if (
|
||||
isProcessing ||
|
||||
UniverseManager.isInitializing() ||
|
||||
UniverseManager.isDisposing()
|
||||
) {
|
||||
console.log("처리 중이거나 상태 변경 중입니다.");
|
||||
return; // 중복 실행 차단
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
// 파일 처리 로직
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}, [isProcessing]); // 의존성 배열에 상태 포함
|
||||
|
||||
// ❌ DON'T: 간단한 상태 확인만으로는 불충분
|
||||
const processFile = useCallback(async (file: File) => {
|
||||
if (isProcessing) return; // 다른 상태는 확인하지 않음
|
||||
}, []);
|
||||
```
|
||||
|
||||
### **3. 컴포넌트 마운트 시 기존 인스턴스 재사용**
|
||||
```typescript
|
||||
// ✅ DO: 기존 전역 인스턴스 우선 재사용
|
||||
useEffect(() => {
|
||||
const existingUniver = UniverseManager.getInstance();
|
||||
if (existingUniver && !UniverseManager.isInitializing()) {
|
||||
setIsInitialized(true);
|
||||
return; // 재사용으로 중복 초기화 방지
|
||||
}
|
||||
|
||||
if (containerRef.current && !UniverseManager.isInitializing()) {
|
||||
initializeUniver();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ❌ DON'T: 매번 새로운 초기화 시도
|
||||
useEffect(() => {
|
||||
initializeUniver(); // 기존 인스턴스 무시하고 새로 생성
|
||||
}, []);
|
||||
```
|
||||
|
||||
### **4. 디버깅을 위한 전역 상태 제공**
|
||||
```typescript
|
||||
// ✅ DO: 전역 디버그 객체로 상태 추적 가능
|
||||
if (typeof window !== "undefined") {
|
||||
(window as any).__UNIVER_DEBUG__ = {
|
||||
getGlobalUniver: () => globalUniver,
|
||||
getGlobalInitializing: () => globalInitializing,
|
||||
clearGlobalState: () => {
|
||||
globalUniver = null;
|
||||
globalInitializing = false;
|
||||
globalDisposing = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### **5. 기존 워크북 정리 시 API 호환성 처리**
|
||||
```typescript
|
||||
// ✅ DO: try-catch로 API 버전 차이 대응
|
||||
try {
|
||||
const existingUnits =
|
||||
(univer as any).getUnitsForType?.(UniverInstanceType.UNIVER_SHEET) || [];
|
||||
for (const unit of existingUnits) {
|
||||
(univer as any).disposeUnit?.(unit.getUnitId());
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("기존 워크북 정리 시 오류 (무시 가능):", error);
|
||||
}
|
||||
|
||||
// ❌ DON'T: API 호환성 고려하지 않은 직접 호출
|
||||
univer.getUnitsForType(UniverInstanceType.UNIVER_SHEET); // 버전에 따라 실패 가능
|
||||
```
|
||||
|
||||
## **브라우저 캐시 완전 삭제**
|
||||
|
||||
### **개발 환경에서 REDI 오류 발생 시 필수 작업**
|
||||
```bash
|
||||
# ✅ DO: 브라우저 캐시 완전 삭제
|
||||
rm -rf node_modules/.vite && rm -rf dist
|
||||
|
||||
# 추가적으로 브라우저에서:
|
||||
# - 강제 새로고침 (Ctrl+Shift+R 또는 Cmd+Shift+R)
|
||||
# - 개발자 도구 > Application > Storage > Clear storage
|
||||
```
|
||||
|
||||
## **오류 패턴 및 해결책**
|
||||
|
||||
### **"Identifier already exists" 오류**
|
||||
- **원인**: REDI 시스템에서 동일한 식별자의 서비스가 중복 등록
|
||||
- **해결**: 전역 인스턴스 관리자 패턴으로 단일 인스턴스 보장
|
||||
|
||||
### **컴포넌트 재마운트 시 중복 초기화**
|
||||
- **원인**: useEffect가 매번 새로운 초기화 시도
|
||||
- **해결**: 기존 전역 인스턴스 존재 확인 후 재사용
|
||||
|
||||
### **HMR 환경에서 상태 불안정**
|
||||
- **원인**: 모듈 재로드 시 전역 상태 초기화
|
||||
- **해결**: 브라우저 캐시 삭제 + window 객체 기반 상태 추적
|
||||
|
||||
## **성능 최적화**
|
||||
|
||||
### **초기화 대기 로직**
|
||||
```typescript
|
||||
// ✅ DO: Promise 기반 비동기 대기
|
||||
while (globalInitializing) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
// ❌ DON'T: 동기 대기나 긴 interval
|
||||
setInterval(() => { /* 체크 로직 */ }, 1000); // 너무 느림
|
||||
```
|
||||
|
||||
### **메모리 누수 방지**
|
||||
```typescript
|
||||
// ✅ DO: 컴포넌트 언마운트 시 상태만 정리
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setIsInitialized(false);
|
||||
// 전역 인스턴스는 앱 종료 시에만 정리
|
||||
};
|
||||
}, []);
|
||||
```
|
||||
|
||||
## **참고사항**
|
||||
- 이 패턴은 Univer CE의 REDI 아키텍처 특성상 필수적임
|
||||
- 전역 인스턴스 관리로 브라우저 재로드 없이 안정적 운용 가능
|
||||
- 개발 환경에서 오류 발생 시 반드시 캐시 삭제 후 재시작
|
||||
5
.cursor/rules/univer-presets-api.mdc
Normal file
5
.cursor/rules/univer-presets-api.mdc
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
154
.cursor/rules/univer-redi-management.mdc
Normal file
154
.cursor/rules/univer-redi-management.mdc
Normal file
@@ -0,0 +1,154 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
## REDI Duplicate Identifier Prevention in Univer CE
|
||||
|
||||
### **Problem Analysis**
|
||||
- **Root Cause**: REDI dependency injection system retains global service identifiers in memory even after Univer instance disposal
|
||||
- **Trigger**: Component remounting causes duplicate service registration attempts
|
||||
- **Symptoms**: "Identifier [service-name] already exists" console errors
|
||||
|
||||
### **Core Prevention Patterns**
|
||||
|
||||
#### **1. Global Instance Management**
|
||||
```typescript
|
||||
// ✅ DO: Use singleton pattern with proper state tracking
|
||||
const UniverseManager = {
|
||||
async createInstance(container: HTMLElement): Promise<any> {
|
||||
const state = getGlobalState();
|
||||
|
||||
// Check existing instance with complete validation
|
||||
if (state.instance && state.univerAPI &&
|
||||
!state.isInitializing && !state.isDisposing) {
|
||||
return { univer: state.instance, univerAPI: state.univerAPI };
|
||||
}
|
||||
|
||||
// Wait for ongoing operations
|
||||
if (state.isInitializing && state.initializationPromise) {
|
||||
return state.initializationPromise;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ❌ DON'T: Create new instances without checking global state
|
||||
const univer = createUniver({ /* config */ }); // Direct creation
|
||||
```
|
||||
|
||||
#### **2. Component Mount Lifecycle**
|
||||
```typescript
|
||||
// ✅ DO: Smart initialization with state checks
|
||||
useEffect(() => {
|
||||
const existingUniver = UniverseManager.getInstance();
|
||||
const state = getGlobalState();
|
||||
|
||||
// Reuse existing instance if available and stable
|
||||
if (existingUniver && state.univerAPI &&
|
||||
!UniverseManager.isInitializing() && !UniverseManager.isDisposing()) {
|
||||
setIsInitialized(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for ongoing operations
|
||||
if (UniverseManager.isInitializing() || UniverseManager.isDisposing()) {
|
||||
const waitTimer = setInterval(() => {
|
||||
if (!UniverseManager.isInitializing() && !UniverseManager.isDisposing()) {
|
||||
clearInterval(waitTimer);
|
||||
// Check and initialize as needed
|
||||
}
|
||||
}, 100);
|
||||
return () => clearInterval(waitTimer);
|
||||
}
|
||||
}, []); // Empty dependency array to prevent re-execution
|
||||
|
||||
// ❌ DON'T: Include dependencies that cause re-initialization
|
||||
useEffect(() => {
|
||||
initializeUniver();
|
||||
}, [initializeUniver]); // This causes re-execution on every render
|
||||
```
|
||||
|
||||
#### **3. Complete Cleanup Strategy**
|
||||
```typescript
|
||||
// ✅ DO: Implement thorough REDI state cleanup
|
||||
forceReset(): void {
|
||||
// Standard disposal
|
||||
if (state.instance) {
|
||||
state.instance.dispose();
|
||||
}
|
||||
|
||||
// REDI global state cleanup attempt
|
||||
if (typeof window !== "undefined") {
|
||||
const globalKeys = Object.keys(window).filter(key =>
|
||||
key.includes('redi') || key.includes('REDI') ||
|
||||
key.includes('univerjs') || key.includes('univer')
|
||||
);
|
||||
|
||||
globalKeys.forEach(key => {
|
||||
try {
|
||||
delete (window as any)[key];
|
||||
} catch (e) {
|
||||
console.warn(`Global key ${key} cleanup failed:`, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Reset all state flags
|
||||
Object.assign(state, {
|
||||
instance: null,
|
||||
univerAPI: null,
|
||||
isInitializing: false,
|
||||
isDisposing: false,
|
||||
initializationPromise: null,
|
||||
lastContainerId: null,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### **4. State Validation Patterns**
|
||||
```typescript
|
||||
// ✅ DO: Multi-level state validation
|
||||
const isInstanceReady = (state: GlobalUniverState): boolean => {
|
||||
return !!(
|
||||
state.instance &&
|
||||
state.univerAPI &&
|
||||
!state.isInitializing &&
|
||||
!state.isDisposing &&
|
||||
state.lastContainerId
|
||||
);
|
||||
};
|
||||
|
||||
// ❌ DON'T: Simple existence check
|
||||
if (state.instance) { /* insufficient validation */ }
|
||||
```
|
||||
|
||||
### **5. Debug Tools Integration**
|
||||
```typescript
|
||||
// ✅ DO: Provide comprehensive debugging tools
|
||||
window.__UNIVER_DEBUG__ = {
|
||||
getGlobalUniver: () => UniverseManager.getInstance(),
|
||||
getGlobalState: () => getGlobalState(),
|
||||
forceReset: () => UniverseManager.forceReset(),
|
||||
completeCleanup: () => UniverseManager.completeCleanup(),
|
||||
};
|
||||
```
|
||||
|
||||
### **Error Resolution Steps**
|
||||
1. **Immediate**: Call `window.__UNIVER_DEBUG__.completeCleanup()` in console
|
||||
2. **Prevention**: Use empty dependency arrays in useEffect for initialization
|
||||
3. **Validation**: Always check both `instance` and `univerAPI` existence
|
||||
4. **Cleanup**: Implement thorough REDI global state cleanup in disposal methods
|
||||
|
||||
### **Common Anti-Patterns to Avoid**
|
||||
- ❌ Re-initializing on every component render
|
||||
- ❌ Not waiting for ongoing initialization/disposal operations
|
||||
- ❌ Incomplete state validation before reuse
|
||||
- ❌ Missing REDI global state cleanup
|
||||
- ❌ Using complex dependency arrays in initialization useEffect
|
||||
|
||||
### **Best Practices**
|
||||
- ✅ Implement singleton pattern with proper state management
|
||||
- ✅ Use polling-based waiting for state transitions
|
||||
- ✅ Provide debug tools for runtime state inspection
|
||||
- ✅ Separate initialization concerns from component lifecycle
|
||||
- ✅ Implement both standard and emergency cleanup methods
|
||||
195
.cursor/rules/xlsx-js-style.mdc
Normal file
195
.cursor/rules/xlsx-js-style.mdc
Normal file
@@ -0,0 +1,195 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# xlsx-js-style 스타일 보존 규칙
|
||||
|
||||
## **핵심 원칙**
|
||||
- xlsx-js-style의 공식 API 구조를 직접 활용하여 스타일 변환
|
||||
- 복잡한 색상 변환 로직 대신 공식 COLOR_STYLE 형식 지원
|
||||
- 배경색과 테두리 색상 누락 방지를 위한 완전한 스타일 매핑
|
||||
|
||||
## **공식 xlsx-js-style API 활용**
|
||||
|
||||
### **색상 처리 (COLOR_STYLE)**
|
||||
```typescript
|
||||
// ✅ DO: 공식 COLOR_STYLE 형식 모두 지원
|
||||
function convertXlsxColorToLuckysheet(colorObj: any): string {
|
||||
// RGB 형태: {rgb: "FFCC00"}
|
||||
if (colorObj.rgb) { /* RGB 처리 */ }
|
||||
|
||||
// Theme 색상: {theme: 4} 또는 {theme: 1, tint: 0.4}
|
||||
if (typeof colorObj.theme === 'number') { /* Theme 처리 */ }
|
||||
|
||||
// Indexed 색상: Excel 기본 색상표
|
||||
if (typeof colorObj.indexed === 'number') { /* Indexed 처리 */ }
|
||||
}
|
||||
|
||||
// ❌ DON'T: 특정 색상 형식만 처리
|
||||
function badColorConvert(colorObj: any): string {
|
||||
return colorObj.rgb || "rgb(0,0,0)"; // rgb만 처리하고 theme, indexed 무시
|
||||
}
|
||||
```
|
||||
|
||||
### **스타일 객체 변환**
|
||||
```typescript
|
||||
// ✅ DO: 공식 스타일 속성 완전 매핑
|
||||
function convertXlsxStyleToLuckysheet(xlsxStyle: any): any {
|
||||
const luckyStyle: any = {};
|
||||
|
||||
// 폰트: {name: "Courier", sz: 24, bold: true, color: {rgb: "FF0000"}}
|
||||
if (xlsxStyle.font) {
|
||||
if (xlsxStyle.font.name) luckyStyle.ff = xlsxStyle.font.name;
|
||||
if (xlsxStyle.font.sz) luckyStyle.fs = xlsxStyle.font.sz;
|
||||
if (xlsxStyle.font.bold) luckyStyle.bl = 1;
|
||||
if (xlsxStyle.font.color) {
|
||||
luckyStyle.fc = convertXlsxColorToLuckysheet(xlsxStyle.font.color);
|
||||
}
|
||||
}
|
||||
|
||||
// 배경: {fgColor: {rgb: "E9E9E9"}}
|
||||
if (xlsxStyle.fill?.fgColor) {
|
||||
luckyStyle.bg = convertXlsxColorToLuckysheet(xlsxStyle.fill.fgColor);
|
||||
}
|
||||
|
||||
// 테두리: {top: {style: "thin", color: {rgb: "000000"}}}
|
||||
if (xlsxStyle.border) {
|
||||
luckyStyle.bd = {};
|
||||
if (xlsxStyle.border.top) {
|
||||
luckyStyle.bd.t = {
|
||||
style: convertBorderStyleToLuckysheet(xlsxStyle.border.top.style),
|
||||
color: convertXlsxColorToLuckysheet(xlsxStyle.border.top.color)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return luckyStyle;
|
||||
}
|
||||
|
||||
// ❌ DON'T: 수동으로 스타일 속성 하나씩 처리
|
||||
luckyCell.v.s = {
|
||||
ff: cell.s.font?.name || "Arial",
|
||||
bg: cell.s.fill?.fgColor?.rgb || "rgb(255,255,255)" // 직접 rgb만 처리
|
||||
};
|
||||
```
|
||||
|
||||
## **배경색과 테두리 색상 누락 방지**
|
||||
|
||||
### **배경색 처리**
|
||||
```typescript
|
||||
// ✅ DO: fgColor와 bgColor 모두 확인
|
||||
if (xlsxStyle.fill) {
|
||||
if (xlsxStyle.fill.fgColor) {
|
||||
luckyStyle.bg = convertXlsxColorToLuckysheet(xlsxStyle.fill.fgColor);
|
||||
} else if (xlsxStyle.fill.bgColor) {
|
||||
luckyStyle.bg = convertXlsxColorToLuckysheet(xlsxStyle.fill.bgColor);
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ DON'T: fgColor만 확인
|
||||
if (xlsxStyle.fill?.fgColor) {
|
||||
luckyStyle.bg = xlsxStyle.fill.fgColor.rgb; // 다른 색상 형식 무시
|
||||
}
|
||||
```
|
||||
|
||||
### **테두리 색상 처리**
|
||||
```typescript
|
||||
// ✅ DO: 모든 테두리 방향과 색상 형식 지원
|
||||
if (xlsxStyle.border) {
|
||||
['top', 'bottom', 'left', 'right'].forEach(side => {
|
||||
if (xlsxStyle.border[side]) {
|
||||
luckyStyle.bd[side[0]] = {
|
||||
style: convertBorderStyleToLuckysheet(xlsxStyle.border[side].style),
|
||||
color: convertXlsxColorToLuckysheet(xlsxStyle.border[side].color)
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ❌ DON'T: 하드코딩된 색상 사용
|
||||
luckyStyle.bd.t = {
|
||||
style: 1,
|
||||
color: "rgb(0,0,0)" // 실제 색상 무시
|
||||
};
|
||||
```
|
||||
|
||||
## **Excel Tint 처리**
|
||||
```typescript
|
||||
// ✅ DO: Excel tint 공식 적용
|
||||
function applyTintToRgbColor(rgbColor: string, tint: number): string {
|
||||
const applyTint = (color: number, tint: number): number => {
|
||||
if (tint < 0) {
|
||||
return Math.round(color * (1 + tint));
|
||||
} else {
|
||||
return Math.round(color * (1 - tint) + (255 - 255 * (1 - tint)));
|
||||
}
|
||||
};
|
||||
// RGB 각 채널에 tint 적용
|
||||
}
|
||||
|
||||
// ❌ DON'T: tint 무시
|
||||
if (colorObj.theme) {
|
||||
return themeColors[colorObj.theme]; // tint 무시
|
||||
}
|
||||
```
|
||||
|
||||
## **오류 방지 패턴**
|
||||
|
||||
### **안전한 스타일 읽기**
|
||||
```typescript
|
||||
// ✅ DO: 옵셔널 체이닝과 타입 검사
|
||||
workbook = XLSX.read(arrayBuffer, {
|
||||
cellStyles: true // 스타일 정보 보존
|
||||
});
|
||||
|
||||
// 스타일 정보 확인
|
||||
if (cell.s) {
|
||||
console.log(`🎨 셀 ${cellAddress}에 스타일 정보:`, cell.s);
|
||||
luckyCell.v.s = convertXlsxStyleToLuckysheet(cell.s);
|
||||
}
|
||||
|
||||
// ❌ DON'T: 스타일 옵션 누락
|
||||
workbook = XLSX.read(arrayBuffer); // cellStyles 옵션 없음
|
||||
```
|
||||
|
||||
### **스타일 쓰기 보존**
|
||||
```typescript
|
||||
// ✅ DO: 쓰기 시에도 스타일 보존
|
||||
const xlsxData = XLSX.write(workbook, {
|
||||
type: "array",
|
||||
bookType: "xlsx",
|
||||
cellStyles: true // 스타일 정보 보존
|
||||
});
|
||||
|
||||
// ❌ DON'T: 쓰기 시 스타일 누락
|
||||
const xlsxData = XLSX.write(workbook, {
|
||||
type: "array",
|
||||
bookType: "xlsx"
|
||||
// cellStyles 옵션 없음
|
||||
});
|
||||
```
|
||||
|
||||
## **디버깅 및 검증**
|
||||
|
||||
### **스타일 정보 로깅**
|
||||
```typescript
|
||||
// ✅ DO: 개발 모드에서 스타일 정보 상세 분석
|
||||
if (import.meta.env.DEV && cell.s) {
|
||||
console.log(`🎨 셀 ${cellAddress} 스타일:`, {
|
||||
font: cell.s.font,
|
||||
fill: cell.s.fill,
|
||||
border: cell.s.border,
|
||||
alignment: cell.s.alignment
|
||||
});
|
||||
}
|
||||
|
||||
// ❌ DON'T: 스타일 정보 무시
|
||||
// 스타일 관련 로그 없음
|
||||
```
|
||||
|
||||
## **참고 사항**
|
||||
- [xlsx-js-style GitHub](https://github.com/gitbrent/xlsx-js-style) 공식 문서 참조
|
||||
- 공식 COLOR_STYLE 형식: `{rgb: "FFCC00"}`, `{theme: 4}`, `{theme: 1, tint: 0.4}`
|
||||
- 공식 BORDER_STYLE 값: `thin`, `medium`, `thick`, `dotted`, `dashed` 등
|
||||
- Excel 테마 색상과 tint 처리는 공식 Excel 색상 공식 사용
|
||||
BIN
luckysheet-src.zip
Normal file
BIN
luckysheet-src.zip
Normal file
Binary file not shown.
7374
package-lock.json
generated
7374
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
37
package.json
37
package.json
@@ -18,17 +18,40 @@
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.10",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@univerjs/core": "^0.8.2",
|
||||
"@univerjs/design": "^0.8.2",
|
||||
"@univerjs/docs": "^0.8.2",
|
||||
"@univerjs/docs-ui": "^0.8.2",
|
||||
"@univerjs/engine-formula": "^0.8.2",
|
||||
"@univerjs/engine-numfmt": "^0.8.2",
|
||||
"@univerjs/engine-render": "^0.8.2",
|
||||
"@univerjs/presets": "^0.8.2",
|
||||
"@univerjs/sheets": "^0.8.2",
|
||||
"@univerjs/sheets-formula": "^0.8.2",
|
||||
"@univerjs/sheets-formula-ui": "^0.8.2",
|
||||
"@univerjs/sheets-numfmt": "^0.8.2",
|
||||
"@univerjs/sheets-numfmt-ui": "^0.8.2",
|
||||
"@univerjs/sheets-ui": "^0.8.2",
|
||||
"@univerjs/ui": "^0.8.2",
|
||||
"@univerjs/uniscript": "^0.8.2",
|
||||
"@zwight/luckyexcel": "^1.1.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"file-saver": "^2.0.5",
|
||||
"i18next": "^25.3.0",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"luckyexcel": "^1.0.1",
|
||||
"luckysheet": "^2.1.13",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"sheetjs-style": "^0.15.8",
|
||||
"react-i18next": "^15.5.3",
|
||||
"recharts": "^3.0.2",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"xlsx": "^0.18.5",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -43,7 +66,9 @@
|
||||
"@typescript-eslint/eslint-plugin": "^8.15.0",
|
||||
"@typescript-eslint/parser": "^8.15.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitest/ui": "^3.2.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"buffer": "^6.0.3",
|
||||
"eslint": "^9.15.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
@@ -52,6 +77,7 @@
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"postcss": "^8.5.1",
|
||||
"prettier": "^3.4.2",
|
||||
"stream-browserify": "^3.0.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.1",
|
||||
@@ -65,5 +91,8 @@
|
||||
"privacy"
|
||||
],
|
||||
"author": "sheetEasy AI Team",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"overrides": {
|
||||
"@wendellhu/redi": "0.18.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
1701
public/output.css
Normal file
1701
public/output.css
Normal file
File diff suppressed because it is too large
Load Diff
633
src/App.tsx
633
src/App.tsx
@@ -1,32 +1,615 @@
|
||||
import { useAppStore } from "./stores/useAppStore";
|
||||
import { Card, CardContent } from "./components/ui/card";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "./components/ui/button";
|
||||
import { FileUpload } from "./components/sheet/FileUpload";
|
||||
import { TopBar } from "./components/ui/topbar";
|
||||
import { lazy, Suspense } from "react";
|
||||
import LandingPage from "./components/LandingPage";
|
||||
import { SignUpPage } from "./components/auth/SignUpPage";
|
||||
import { SignInPage } from "./components/auth/SignInPage";
|
||||
import AccountPage from "./components/AccountPage";
|
||||
import LicensePage from "./components/LicensePage";
|
||||
import { useAppStore } from "./stores/useAppStore";
|
||||
import { I18nProvider } from "./lib/i18n";
|
||||
import type { TutorialItem } from "./types/tutorial";
|
||||
import { startTransition } from "react";
|
||||
|
||||
// TutorialSheetViewer 동적 import
|
||||
const TutorialSheetViewer = lazy(
|
||||
() => import("./components/TutorialSheetViewer"),
|
||||
);
|
||||
|
||||
// 동적 import로 EditSheetViewer 로드 (필요할 때만)
|
||||
const EditSheetViewer = lazy(
|
||||
() => import("./components/sheet/EditSheetViewer"),
|
||||
);
|
||||
|
||||
// 새로운 페이지들 동적 import
|
||||
const RoadmapPage = lazy(() => import("./components/RoadmapPage"));
|
||||
const UpdatesPage = lazy(() => import("./components/UpdatesPage"));
|
||||
const SupportPage = lazy(() => import("./components/SupportPage"));
|
||||
const ContactPage = lazy(() => import("./components/ContactPage"));
|
||||
const PrivacyPolicyPage = lazy(() => import("./components/PrivacyPolicyPage"));
|
||||
const TermsOfServicePage = lazy(
|
||||
() => import("./components/TermsOfServicePage"),
|
||||
);
|
||||
|
||||
// 앱 상태 타입 정의
|
||||
type AppView =
|
||||
| "landing"
|
||||
| "signUp"
|
||||
| "signIn"
|
||||
| "editor"
|
||||
| "account"
|
||||
| "tutorial"
|
||||
| "license"
|
||||
| "roadmap"
|
||||
| "updates"
|
||||
| "support"
|
||||
| "contact"
|
||||
| "privacy-policy"
|
||||
| "terms-of-service";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* 헤더 */}
|
||||
<header className="bg-white shadow-sm border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
<div className="flex items-center">
|
||||
<h1 className="text-2xl font-bold text-blue-600">sheetEasy AI</h1>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-sm text-gray-600">
|
||||
Excel 파일 AI 처리 도구
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
const [currentView, setCurrentView] = useState<AppView>("landing");
|
||||
const {
|
||||
isAuthenticated,
|
||||
setAuthenticated,
|
||||
setUser,
|
||||
user,
|
||||
currentFile,
|
||||
startTutorial,
|
||||
setLanguage,
|
||||
setHistoryPanelPosition,
|
||||
} = useAppStore();
|
||||
|
||||
{/* 메인 콘텐츠 */}
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<FileUpload />
|
||||
</main>
|
||||
</div>
|
||||
// 초기화: localStorage에서 설정 로드
|
||||
useEffect(() => {
|
||||
// 언어 설정 로드
|
||||
const savedLanguage = localStorage.getItem("sheeteasy-language");
|
||||
if (savedLanguage === "ko" || savedLanguage === "en") {
|
||||
setLanguage(savedLanguage);
|
||||
}
|
||||
|
||||
// 히스토리 패널 위치 설정 로드
|
||||
const savedPosition = localStorage.getItem(
|
||||
"sheeteasy-history-panel-position",
|
||||
);
|
||||
if (savedPosition === "left" || savedPosition === "right") {
|
||||
setHistoryPanelPosition(savedPosition);
|
||||
}
|
||||
}, [setLanguage, setHistoryPanelPosition]);
|
||||
|
||||
// CTA 버튼 클릭 핸들러 - 인증 상태에 따른 분기 처리
|
||||
const handleGetStarted = () => {
|
||||
if (isAuthenticated) {
|
||||
// 이미 로그인된 사용자는 바로 에디터로 이동
|
||||
setCurrentView("editor");
|
||||
} else {
|
||||
// 미인증 사용자는 가입 페이지로 이동
|
||||
setCurrentView("signUp");
|
||||
}
|
||||
};
|
||||
|
||||
// 다운로드 클릭 핸들러
|
||||
const handleDownloadClick = () => {
|
||||
if (currentFile?.xlsxBuffer) {
|
||||
// XLSX ArrayBuffer를 Blob으로 변환하여 다운로드
|
||||
const blob = new Blob([currentFile.xlsxBuffer], {
|
||||
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = currentFile.name || "sheet.xlsx";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
} else {
|
||||
alert("다운로드할 파일이 없습니다. 파일을 먼저 업로드해주세요.");
|
||||
}
|
||||
};
|
||||
|
||||
// 계정 클릭 핸들러
|
||||
const handleAccountClick = () => {
|
||||
if (isAuthenticated) {
|
||||
setCurrentView("account");
|
||||
} else {
|
||||
setCurrentView("signIn");
|
||||
}
|
||||
};
|
||||
|
||||
// 데모보기 버튼 클릭 핸들러 - 에디터로 바로 이동
|
||||
const handleDemoClick = () => {
|
||||
setCurrentView("editor");
|
||||
};
|
||||
|
||||
// 튜토리얼 페이지 이동 핸들러 - 페이지 리로드 기능 추가
|
||||
const handleTutorialClick = () => {
|
||||
if (currentView === "tutorial") {
|
||||
// 이미 튜토리얼 페이지에 있는 경우 컴포넌트 리마운트를 위해 key 변경
|
||||
setCurrentView("landing");
|
||||
setTimeout(() => {
|
||||
setCurrentView("tutorial");
|
||||
}, 10);
|
||||
} else {
|
||||
setCurrentView("tutorial");
|
||||
}
|
||||
};
|
||||
|
||||
// 네비게이션 핸들러들 - 라우팅 기반
|
||||
const handleHomeClick = () => {
|
||||
setCurrentView("landing");
|
||||
};
|
||||
|
||||
const handleFeaturesClick = () => {
|
||||
setCurrentView("landing");
|
||||
// 라우팅 후 스크롤
|
||||
setTimeout(() => {
|
||||
const element = document.getElementById("features");
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const handleFAQClick = () => {
|
||||
setCurrentView("landing");
|
||||
// 라우팅 후 스크롤
|
||||
setTimeout(() => {
|
||||
const element = document.getElementById("faq");
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const handlePricingClick = () => {
|
||||
setCurrentView("landing");
|
||||
// 라우팅 후 스크롤
|
||||
setTimeout(() => {
|
||||
const element = document.getElementById("pricing");
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// Footer 핸들러들 - 새로운 페이지 네비게이션
|
||||
const handleRoadmapClick = () => {
|
||||
startTransition(() => {
|
||||
setCurrentView("roadmap");
|
||||
});
|
||||
};
|
||||
const handleUpdatesClick = () => {
|
||||
startTransition(() => {
|
||||
setCurrentView("updates");
|
||||
});
|
||||
};
|
||||
const handleSupportClick = () => {
|
||||
if (isAuthenticated) {
|
||||
startTransition(() => {
|
||||
setCurrentView("support");
|
||||
});
|
||||
} else {
|
||||
alert("지원 서비스는 로그인 후 이용 가능합니다.");
|
||||
startTransition(() => {
|
||||
setCurrentView("signIn");
|
||||
});
|
||||
}
|
||||
};
|
||||
const handleContactClick = () => {
|
||||
startTransition(() => {
|
||||
setCurrentView("contact");
|
||||
});
|
||||
};
|
||||
const handlePrivacyPolicyClick = () => {
|
||||
startTransition(() => {
|
||||
setCurrentView("privacy-policy");
|
||||
});
|
||||
};
|
||||
const handleTermsOfServiceClick = () => {
|
||||
startTransition(() => {
|
||||
setCurrentView("terms-of-service");
|
||||
});
|
||||
};
|
||||
|
||||
// 튜토리얼 선택 핸들러 - 튜토리얼 시작 후 튜토리얼 페이지로 전환
|
||||
const handleTutorialSelect = (tutorial: TutorialItem) => {
|
||||
console.log("🎯 튜토리얼 선택됨:", tutorial.metadata.title);
|
||||
|
||||
// 앱 스토어에 선택된 튜토리얼 설정
|
||||
startTutorial(tutorial);
|
||||
|
||||
// 튜토리얼 페이지로 전환 (통합된 진입점)
|
||||
setCurrentView("tutorial");
|
||||
};
|
||||
|
||||
// 가입 처리 핸들러
|
||||
const handleSignUp = (email: string, password: string) => {
|
||||
// TODO: 실제 API 연동
|
||||
console.log("가입 요청:", { email, password });
|
||||
|
||||
// 임시로 자동 로그인 처리
|
||||
setUser({
|
||||
id: "temp-user-id",
|
||||
email,
|
||||
name: email.split("@")[0],
|
||||
createdAt: new Date(),
|
||||
lastLoginAt: new Date(),
|
||||
subscription: {
|
||||
plan: "free",
|
||||
status: "active",
|
||||
currentPeriodStart: new Date(),
|
||||
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30일 후
|
||||
usage: {
|
||||
aiQueries: 0,
|
||||
cellCount: 0,
|
||||
resetDate: new Date(),
|
||||
},
|
||||
},
|
||||
preferences: {
|
||||
language: "ko",
|
||||
historyPanelPosition: "right",
|
||||
autoSave: true,
|
||||
showAnimations: true,
|
||||
},
|
||||
});
|
||||
setAuthenticated(true);
|
||||
setCurrentView("editor");
|
||||
};
|
||||
|
||||
// 로그인 처리 핸들러
|
||||
const handleSignIn = (email: string, password: string) => {
|
||||
// TODO: 실제 API 연동
|
||||
console.log("로그인 요청:", { email, password });
|
||||
|
||||
// 임시로 자동 로그인 처리
|
||||
setUser({
|
||||
id: "temp-user-id",
|
||||
email,
|
||||
name: email.split("@")[0],
|
||||
createdAt: new Date(),
|
||||
lastLoginAt: new Date(),
|
||||
subscription: {
|
||||
plan: "lite",
|
||||
status: "active",
|
||||
currentPeriodStart: new Date(),
|
||||
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30일 후
|
||||
usage: {
|
||||
aiQueries: 15,
|
||||
cellCount: 234,
|
||||
resetDate: new Date(),
|
||||
},
|
||||
},
|
||||
preferences: {
|
||||
language: "ko",
|
||||
historyPanelPosition: "right",
|
||||
autoSave: true,
|
||||
showAnimations: true,
|
||||
},
|
||||
});
|
||||
setAuthenticated(true);
|
||||
setCurrentView("editor");
|
||||
};
|
||||
|
||||
// 로그아웃 핸들러
|
||||
const handleLogout = () => {
|
||||
setUser(null);
|
||||
setAuthenticated(false);
|
||||
setCurrentView("landing");
|
||||
};
|
||||
|
||||
// 뷰 전환 핸들러들
|
||||
const handleBackToLanding = () => setCurrentView("landing");
|
||||
const handleGoToSignIn = () => setCurrentView("signIn");
|
||||
const handleGoToSignUp = () => setCurrentView("signUp");
|
||||
const handleGoToEditor = () => setCurrentView("editor");
|
||||
const handleGoToLicense = () => {
|
||||
startTransition(() => {
|
||||
setCurrentView("license");
|
||||
});
|
||||
};
|
||||
|
||||
// 에디터에서 홈으로 돌아가기 핸들러 (워닝 포함)
|
||||
const handleEditorLogoClick = () => {
|
||||
const confirmed = window.confirm(
|
||||
"편집 중인 작업이 있습니다. 홈으로 돌아가면 저장되지 않은 작업이 손실될 수 있습니다.\n\n정말로 홈으로 돌아가시겠습니까?",
|
||||
);
|
||||
|
||||
if (confirmed) {
|
||||
setCurrentView("landing");
|
||||
}
|
||||
};
|
||||
|
||||
// 현재 뷰에 따른 렌더링
|
||||
const renderCurrentView = () => {
|
||||
switch (currentView) {
|
||||
case "signUp":
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<TopBar
|
||||
showDownload={false}
|
||||
showAccount={false}
|
||||
showNavigation={false}
|
||||
showAuthButtons={false}
|
||||
onAccountClick={handleAccountClick}
|
||||
/>
|
||||
<main className="container mx-auto py-8">
|
||||
<SignUpPage
|
||||
onSignUp={handleSignUp}
|
||||
onSignInClick={handleGoToSignIn}
|
||||
onBack={handleBackToLanding}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "signIn":
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<TopBar
|
||||
showDownload={false}
|
||||
showAccount={false}
|
||||
showNavigation={false}
|
||||
showAuthButtons={false}
|
||||
onAccountClick={handleAccountClick}
|
||||
/>
|
||||
<main className="container mx-auto py-8">
|
||||
<SignInPage
|
||||
onSignIn={handleSignIn}
|
||||
onSignUpClick={handleGoToSignUp}
|
||||
onBack={handleBackToLanding}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "account":
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<TopBar
|
||||
showDownload={false}
|
||||
showAccount={true}
|
||||
showNavigation={false}
|
||||
showAuthButtons={false}
|
||||
onAccountClick={handleAccountClick}
|
||||
/>
|
||||
<main>
|
||||
<AccountPage
|
||||
onGoToEditor={handleGoToEditor}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "license":
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<TopBar
|
||||
showDownload={false}
|
||||
showAccount={false}
|
||||
showNavigation={false}
|
||||
showAuthButtons={false}
|
||||
onAccountClick={handleAccountClick}
|
||||
/>
|
||||
<main>
|
||||
<LicensePage onBack={handleBackToLanding} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "tutorial":
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<TopBar
|
||||
showDownload={false}
|
||||
showAccount={true}
|
||||
showNavigation={true}
|
||||
showAuthButtons={false}
|
||||
onAccountClick={handleAccountClick}
|
||||
onLogoClick={handleBackToLanding}
|
||||
onTutorialClick={handleTutorialClick}
|
||||
onHomeClick={handleHomeClick}
|
||||
onFeaturesClick={handleFeaturesClick}
|
||||
onFAQClick={handleFAQClick}
|
||||
onPricingClick={handlePricingClick}
|
||||
/>
|
||||
<main className="h-[calc(100vh-4rem)]">
|
||||
<div className="h-full">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">📚 튜토리얼 로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TutorialSheetViewer />
|
||||
</Suspense>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "editor":
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<TopBar
|
||||
showDownload={true}
|
||||
showAccount={true}
|
||||
showNavigation={false}
|
||||
showAuthButtons={false}
|
||||
onDownloadClick={handleDownloadClick}
|
||||
onAccountClick={handleAccountClick}
|
||||
onLogoClick={handleEditorLogoClick}
|
||||
/>
|
||||
<main className="h-[calc(100vh-4rem)]">
|
||||
<div className="h-full">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">🚀 그리드 그리는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<EditSheetViewer />
|
||||
</Suspense>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "roadmap":
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<TopBar
|
||||
showDownload={false}
|
||||
showAccount={false}
|
||||
showNavigation={false}
|
||||
showAuthButtons={false}
|
||||
onAccountClick={handleAccountClick}
|
||||
onLogoClick={handleBackToLanding}
|
||||
/>
|
||||
<main>
|
||||
<RoadmapPage />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "updates":
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<TopBar
|
||||
showDownload={false}
|
||||
showAccount={false}
|
||||
showNavigation={false}
|
||||
showAuthButtons={false}
|
||||
onAccountClick={handleAccountClick}
|
||||
onLogoClick={handleBackToLanding}
|
||||
/>
|
||||
<main>
|
||||
<UpdatesPage />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "support":
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<TopBar
|
||||
showDownload={false}
|
||||
showAccount={false}
|
||||
showNavigation={false}
|
||||
showAuthButtons={false}
|
||||
onAccountClick={handleAccountClick}
|
||||
onLogoClick={handleBackToLanding}
|
||||
/>
|
||||
<main>
|
||||
<SupportPage />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "contact":
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<TopBar
|
||||
showDownload={false}
|
||||
showAccount={false}
|
||||
showNavigation={false}
|
||||
showAuthButtons={false}
|
||||
onAccountClick={handleAccountClick}
|
||||
onLogoClick={handleBackToLanding}
|
||||
/>
|
||||
<main>
|
||||
<ContactPage />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "privacy-policy":
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<TopBar
|
||||
showDownload={false}
|
||||
showAccount={false}
|
||||
showNavigation={false}
|
||||
showAuthButtons={false}
|
||||
onAccountClick={handleAccountClick}
|
||||
onLogoClick={handleBackToLanding}
|
||||
/>
|
||||
<main>
|
||||
<PrivacyPolicyPage />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "terms-of-service":
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<TopBar
|
||||
showDownload={false}
|
||||
showAccount={false}
|
||||
showNavigation={false}
|
||||
showAuthButtons={false}
|
||||
onAccountClick={handleAccountClick}
|
||||
onLogoClick={handleBackToLanding}
|
||||
/>
|
||||
<main>
|
||||
<TermsOfServicePage />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "landing":
|
||||
default:
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<TopBar
|
||||
showDownload={false}
|
||||
showAccount={false}
|
||||
showNavigation={true}
|
||||
showAuthButtons={true}
|
||||
showTestAccount={true}
|
||||
onSignInClick={handleGoToSignIn}
|
||||
onGetStartedClick={handleGetStarted}
|
||||
onAccountClick={handleAccountClick}
|
||||
onTutorialClick={handleTutorialClick}
|
||||
onHomeClick={handleHomeClick}
|
||||
onFeaturesClick={handleFeaturesClick}
|
||||
onFAQClick={handleFAQClick}
|
||||
onPricingClick={handlePricingClick}
|
||||
onLogoClick={handleBackToLanding}
|
||||
/>
|
||||
<LandingPage
|
||||
onGetStarted={handleGetStarted}
|
||||
onDownloadClick={handleDownloadClick}
|
||||
onAccountClick={handleAccountClick}
|
||||
onDemoClick={handleDemoClick}
|
||||
onTutorialSelect={handleTutorialSelect}
|
||||
onLicenseClick={handleGoToLicense}
|
||||
onRoadmapClick={handleRoadmapClick}
|
||||
onUpdatesClick={handleUpdatesClick}
|
||||
onSupportClick={handleSupportClick}
|
||||
onContactClick={handleContactClick}
|
||||
onPrivacyPolicyClick={handlePrivacyPolicyClick}
|
||||
onTermsOfServiceClick={handleTermsOfServiceClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<I18nProvider>
|
||||
<div className="min-h-screen">{renderCurrentView()}</div>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
552
src/components/AccountPage.tsx
Normal file
552
src/components/AccountPage.tsx
Normal file
@@ -0,0 +1,552 @@
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "./ui/card";
|
||||
import { Button } from "./ui/button";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Progress } from "./ui/progress";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "./ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "./ui/alert-dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "./ui/select";
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import { useAppStore } from "../stores/useAppStore";
|
||||
import { useTranslation, formatDate } from "../lib/i18n.tsx";
|
||||
import type { User } from "../types/user";
|
||||
import type { Language } from "../lib/i18n.tsx";
|
||||
|
||||
interface AccountPageProps {
|
||||
onGoToEditor?: () => void;
|
||||
onLogout?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현대적인 shadcn UI를 사용한 Account 페이지 컴포넌트
|
||||
* - 구독 플랜 관리 (Card, Dialog, AlertDialog)
|
||||
* - 사용량 분석 차트 (recharts)
|
||||
* - 사용량 한계 경고 (Badge, Progress)
|
||||
* - 사용자 설정
|
||||
*/
|
||||
const AccountPage: React.FC<AccountPageProps> = ({
|
||||
onGoToEditor,
|
||||
onLogout,
|
||||
}) => {
|
||||
const {
|
||||
user,
|
||||
language,
|
||||
historyPanelPosition,
|
||||
setLanguage,
|
||||
setHistoryPanelPosition,
|
||||
} = useAppStore();
|
||||
const { t } = useTranslation();
|
||||
const [changePlanOpen, setChangePlanOpen] = useState(false);
|
||||
const [cancelSubOpen, setCancelSubOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
|
||||
// 플랜별 한계값 계산
|
||||
const getPlanLimits = (plan: string) => {
|
||||
switch (plan) {
|
||||
case "free":
|
||||
return { aiQueries: 30, cellCount: 300 };
|
||||
case "lite":
|
||||
return { aiQueries: 100, cellCount: 1000 };
|
||||
case "pro":
|
||||
return { aiQueries: 500, cellCount: 5000 };
|
||||
default:
|
||||
return { aiQueries: 0, cellCount: 0 };
|
||||
}
|
||||
};
|
||||
|
||||
// 플랜 상태 배지 색상
|
||||
const getStatusBadgeVariant = (status: string) => {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return "default";
|
||||
case "trial":
|
||||
return "secondary";
|
||||
case "canceled":
|
||||
return "destructive";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
};
|
||||
|
||||
// 사용량 경고 여부 확인
|
||||
const isUsageWarning = (used: number, limit: number) => {
|
||||
return used / limit >= 0.8; // 80% 이상 사용 시 경고
|
||||
};
|
||||
|
||||
// 사용량 진행률 계산
|
||||
const getUsagePercentage = (used: number, limit: number) => {
|
||||
return Math.min((used / limit) * 100, 100);
|
||||
};
|
||||
|
||||
// 확장된 사용량 데이터 (최근 30일)
|
||||
const generateUsageData = () => {
|
||||
const data = [];
|
||||
const today = new Date();
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
const date = new Date(today);
|
||||
date.setDate(date.getDate() - i);
|
||||
data.push({
|
||||
date: formatDate(date, language),
|
||||
aiQueries: Math.floor(Math.random() * 10) + 1,
|
||||
cellCount: Math.floor(Math.random() * 50) + 10,
|
||||
promptCount: Math.floor(Math.random() * 8) + 1,
|
||||
editedCells: Math.floor(Math.random() * 40) + 5,
|
||||
});
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
const usageData = generateUsageData();
|
||||
const limits = getPlanLimits(user?.subscription?.plan || "free");
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="container mx-auto py-8">
|
||||
<div className="text-center">
|
||||
<p className="text-gray-600">{t.common.loading}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 space-y-6">
|
||||
{/* 페이지 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
{t.account.title}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">{t.account.subtitle}</p>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button onClick={onGoToEditor} variant="outline">
|
||||
{t.account.goToEditor}
|
||||
</Button>
|
||||
<Button onClick={onLogout} variant="destructive">
|
||||
{t.account.logout}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{/* 사용자 정보 카드 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t.account.userInfo}</CardTitle>
|
||||
<CardDescription>{t.account.userInfo}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t.account.email}</p>
|
||||
<p className="text-sm text-muted-foreground">{user.email}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t.account.name}</p>
|
||||
<p className="text-sm text-muted-foreground">{user.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t.account.joinDate}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{user.createdAt && formatDate(user.createdAt, language)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t.account.lastLogin}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{user.lastLoginAt && formatDate(user.lastLoginAt, language)}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 구독 플랜 카드 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
구독 플랜
|
||||
<Badge
|
||||
variant={getStatusBadgeVariant(user.subscription?.status || "")}
|
||||
>
|
||||
{user.subscription?.status?.toUpperCase()}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>현재 구독 정보</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<p className="text-2xl font-bold">
|
||||
{user.subscription?.plan?.toUpperCase()} 플랜
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
다음 결제일:{" "}
|
||||
{user.subscription?.currentPeriodEnd?.toLocaleDateString(
|
||||
"ko-KR",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Dialog open={changePlanOpen} onOpenChange={setChangePlanOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
플랜 변경
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>플랜 변경</DialogTitle>
|
||||
<DialogDescription>
|
||||
새로운 플랜을 선택하세요. 변경 사항은 즉시 적용됩니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Card className="cursor-pointer hover:bg-accent">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Free</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold">₩0</p>
|
||||
<p className="text-xs text-muted-foreground">월</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="cursor-pointer hover:bg-accent">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Lite</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold">₩5,900</p>
|
||||
<p className="text-xs text-muted-foreground">월</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="cursor-pointer hover:bg-accent">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Pro</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold">₩14,900</p>
|
||||
<p className="text-xs text-muted-foreground">월</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setChangePlanOpen(false)}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={() => setChangePlanOpen(false)}>
|
||||
변경하기
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog open={cancelSubOpen} onOpenChange={setCancelSubOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
구독 취소
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
구독을 취소하시겠습니까?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
구독을 취소하면 현재 결제 주기가 끝날 때까지 서비스를
|
||||
이용할 수 있습니다. 취소 후에는 Free 플랜으로 자동
|
||||
전환됩니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction>구독 취소</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 사용량 요약 카드 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>사용량 요약</CardTitle>
|
||||
<CardDescription>현재 사용량 및 한계</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* AI 쿼리 사용량 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-sm font-medium">AI 쿼리</p>
|
||||
{isUsageWarning(
|
||||
user.subscription?.usage?.aiQueries || 0,
|
||||
limits.aiQueries,
|
||||
) && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
경고
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Progress
|
||||
value={getUsagePercentage(
|
||||
user.subscription?.usage?.aiQueries || 0,
|
||||
limits.aiQueries,
|
||||
)}
|
||||
className={
|
||||
isUsageWarning(
|
||||
user.subscription?.usage?.aiQueries || 0,
|
||||
limits.aiQueries,
|
||||
)
|
||||
? "bg-red-100"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{user.subscription?.usage?.aiQueries || 0} / {limits.aiQueries}{" "}
|
||||
사용
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 셀 카운트 사용량 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-sm font-medium">셀 카운트</p>
|
||||
{isUsageWarning(
|
||||
user.subscription?.usage?.cellCount || 0,
|
||||
limits.cellCount,
|
||||
) && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
경고
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Progress
|
||||
value={getUsagePercentage(
|
||||
user.subscription?.usage?.cellCount || 0,
|
||||
limits.cellCount,
|
||||
)}
|
||||
className={
|
||||
isUsageWarning(
|
||||
user.subscription?.usage?.cellCount || 0,
|
||||
limits.cellCount,
|
||||
)
|
||||
? "bg-red-100"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{user.subscription?.usage?.cellCount || 0} / {limits.cellCount}{" "}
|
||||
사용
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 사용량 분석 차트 */}
|
||||
<Card className="col-span-full">
|
||||
<CardHeader>
|
||||
<CardTitle>{t.account.usageAnalytics}</CardTitle>
|
||||
<CardDescription>
|
||||
{t.account.usageAnalyticsDescription}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[400px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={usageData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" />
|
||||
<YAxis />
|
||||
<Tooltip
|
||||
formatter={(value, name) => [
|
||||
value,
|
||||
t.account.tooltipLabels[
|
||||
name as keyof typeof t.account.tooltipLabels
|
||||
] || name,
|
||||
]}
|
||||
labelFormatter={(label) => `${t.account.date}: ${label}`}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="aiQueries"
|
||||
stroke="#8884d8"
|
||||
strokeWidth={2}
|
||||
name="aiQueries"
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="cellCount"
|
||||
stroke="#82ca9d"
|
||||
strokeWidth={2}
|
||||
name="cellCount"
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="promptCount"
|
||||
stroke="#ff7300"
|
||||
strokeWidth={2}
|
||||
name="promptCount"
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="editedCells"
|
||||
stroke="#00bcd4"
|
||||
strokeWidth={2}
|
||||
name="editedCells"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 설정 카드 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t.account.settings}</CardTitle>
|
||||
<CardDescription>{t.account.settings}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">{t.account.language}</p>
|
||||
<Badge variant="outline">
|
||||
{language === "ko" ? t.account.korean : t.account.english}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">
|
||||
{t.account.historyPanelPosition}
|
||||
</p>
|
||||
<Badge variant="outline">
|
||||
{historyPanelPosition === "right"
|
||||
? t.account.right
|
||||
: t.account.left}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-4">
|
||||
<Dialog open={settingsOpen} onOpenChange={setSettingsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
{t.account.changeSettings}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t.account.changeSettings}</DialogTitle>
|
||||
<DialogDescription>{t.account.settings}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<label htmlFor="language" className="text-right">
|
||||
{t.account.language}
|
||||
</label>
|
||||
<div className="col-span-3">
|
||||
<Select
|
||||
value={language}
|
||||
onValueChange={(value: Language) => setLanguage(value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t.account.language} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ko">{t.account.korean}</SelectItem>
|
||||
<SelectItem value="en">
|
||||
{t.account.english}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<label htmlFor="panel-position" className="text-right">
|
||||
{t.account.historyPanelPosition}
|
||||
</label>
|
||||
<div className="col-span-3">
|
||||
<Select
|
||||
value={historyPanelPosition}
|
||||
onValueChange={(value: "left" | "right") =>
|
||||
setHistoryPanelPosition(value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t.account.historyPanelPosition}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">{t.account.left}</SelectItem>
|
||||
<SelectItem value="right">
|
||||
{t.account.right}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setSettingsOpen(false)}
|
||||
>
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button onClick={() => setSettingsOpen(false)}>
|
||||
{t.common.save}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountPage;
|
||||
417
src/components/ContactPage.tsx
Normal file
417
src/components/ContactPage.tsx
Normal file
@@ -0,0 +1,417 @@
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
interface ContactPageProps {
|
||||
className?: string;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
const ContactPage = React.forwardRef<HTMLDivElement, ContactPageProps>(
|
||||
({ className, onBack, ...props }, ref) => {
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
email: "",
|
||||
company: "",
|
||||
subject: "",
|
||||
message: "",
|
||||
contactReason: "",
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
|
||||
const contactReasons = [
|
||||
{ value: "sales", label: "영업 문의", icon: "💼" },
|
||||
{ value: "partnership", label: "파트너십", icon: "🤝" },
|
||||
{ value: "feedback", label: "피드백", icon: "💭" },
|
||||
{ value: "media", label: "미디어 문의", icon: "📰" },
|
||||
{ value: "general", label: "일반 문의", icon: "💬" },
|
||||
{ value: "other", label: "기타", icon: "❓" },
|
||||
];
|
||||
|
||||
const handleInputChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||
) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const emailData = {
|
||||
to: "contact@sheeteasy.ai",
|
||||
from: formData.email,
|
||||
subject: `[${formData.contactReason.toUpperCase()}] ${formData.subject}`,
|
||||
html: `
|
||||
<h2>문의사항</h2>
|
||||
<p><strong>이름:</strong> ${formData.name}</p>
|
||||
<p><strong>이메일:</strong> ${formData.email}</p>
|
||||
<p><strong>회사:</strong> ${formData.company || "개인"}</p>
|
||||
<p><strong>문의 유형:</strong> ${contactReasons.find((r) => r.value === formData.contactReason)?.label}</p>
|
||||
<p><strong>제목:</strong> ${formData.subject}</p>
|
||||
<hr>
|
||||
<p><strong>내용:</strong></p>
|
||||
<p>${formData.message.replace(/\n/g, "<br>")}</p>
|
||||
`,
|
||||
};
|
||||
|
||||
// TODO: 실제 이메일 서비스 연동
|
||||
console.log("문의사항 전송:", emailData);
|
||||
|
||||
setTimeout(() => {
|
||||
alert(
|
||||
"문의사항이 성공적으로 전송되었습니다. 빠른 시일 내에 답변드리겠습니다.",
|
||||
);
|
||||
setFormData({
|
||||
name: "",
|
||||
email: "",
|
||||
company: "",
|
||||
subject: "",
|
||||
message: "",
|
||||
contactReason: "",
|
||||
});
|
||||
setIsSubmitting(false);
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
alert("문의사항 전송에 실패했습니다. 다시 시도해주세요.");
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"min-h-screen bg-gradient-to-br from-slate-50 to-blue-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="container py-12">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">
|
||||
문의하기
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600">
|
||||
궁금한 점이나 제안사항이 있으시면 언제든 연락주세요
|
||||
</p>
|
||||
</div>
|
||||
{onBack && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
돌아가기
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Contact Form */}
|
||||
<div className="lg:col-span-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="text-2xl">✉️</span>
|
||||
문의 보내기
|
||||
</CardTitle>
|
||||
<p className="text-gray-600">
|
||||
아래 양식을 작성해주시면 빠르게 답변드리겠습니다.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Contact Reason */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
문의 유형
|
||||
</label>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{contactReasons.map((reason) => (
|
||||
<button
|
||||
key={reason.value}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
contactReason: reason.value,
|
||||
}))
|
||||
}
|
||||
className={cn(
|
||||
"p-3 rounded-lg border text-left transition-all",
|
||||
formData.contactReason === reason.value
|
||||
? "border-blue-500 bg-blue-50 text-blue-700"
|
||||
: "border-gray-200 hover:border-gray-300",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">{reason.icon}</span>
|
||||
<span className="text-sm font-medium">
|
||||
{reason.label}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
이름 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleInputChange}
|
||||
placeholder="성함을 입력해주세요"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
이메일 *
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
placeholder="답변받을 이메일을 입력해주세요"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Company */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
회사명 (선택)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="company"
|
||||
value={formData.company}
|
||||
onChange={handleInputChange}
|
||||
placeholder="소속 회사나 기관을 입력해주세요"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Subject */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
제목 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="subject"
|
||||
value={formData.subject}
|
||||
onChange={handleInputChange}
|
||||
placeholder="문의 제목을 입력해주세요"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
문의 내용 *
|
||||
</label>
|
||||
<textarea
|
||||
name="message"
|
||||
value={formData.message}
|
||||
onChange={handleInputChange}
|
||||
placeholder="문의 내용을 자세히 작성해주세요"
|
||||
rows={6}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-vertical"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
!formData.contactReason ||
|
||||
!formData.name.trim() ||
|
||||
!formData.email.trim() ||
|
||||
!formData.subject.trim() ||
|
||||
!formData.message.trim() ||
|
||||
isSubmitting
|
||||
}
|
||||
className="w-full bg-green-600 hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? "전송 중..." : "문의 보내기"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Contact Information */}
|
||||
<div className="space-y-6">
|
||||
{/* Contact Methods */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">연락처 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-sm">📧</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900">이메일</h4>
|
||||
<a
|
||||
href="mailto:contact@sheeteasy.ai"
|
||||
className="text-blue-600 hover:underline text-sm"
|
||||
>
|
||||
contact@sheeteasy.ai
|
||||
</a>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
24-48시간 내 답변
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-sm">💬</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900">
|
||||
실시간 채팅
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
평일 9:00 - 18:00
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
즉시 답변 가능
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 bg-purple-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-sm">📱</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900">
|
||||
소셜 미디어
|
||||
</h4>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<a
|
||||
href="#"
|
||||
className="text-blue-600 hover:underline text-sm"
|
||||
>
|
||||
Twitter
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
className="text-blue-600 hover:underline text-sm"
|
||||
>
|
||||
LinkedIn
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Business Hours */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">운영 시간</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">평일:</span>
|
||||
<span className="font-medium">09:00 - 18:00</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">토요일:</span>
|
||||
<span className="font-medium">10:00 - 15:00</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">일요일:</span>
|
||||
<span className="text-gray-500">휴무</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-3">
|
||||
* 한국 표준시(KST) 기준
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* FAQ Link */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">자주 묻는 질문</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
일반적인 질문들에 대한 답변을 미리 확인해보세요.
|
||||
</p>
|
||||
<Button variant="outline" className="w-full">
|
||||
FAQ 보러가기
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Response Time */}
|
||||
<Card className="bg-gradient-to-r from-green-50 to-blue-50">
|
||||
<CardContent className="p-4">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-2">
|
||||
<span className="text-xl">⚡</span>
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 mb-1">
|
||||
빠른 응답
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
평균 12시간 이내 답변드립니다
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ContactPage.displayName = "ContactPage";
|
||||
|
||||
export default ContactPage;
|
||||
78
src/components/LandingPage.tsx
Normal file
78
src/components/LandingPage.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import * as React from "react";
|
||||
import { HeroSection } from "./ui/hero-section";
|
||||
import { FeaturesSection } from "./ui/features-section";
|
||||
import { TutorialSection } from "./ui/tutorial-section";
|
||||
import { FAQSection } from "./ui/faq-section";
|
||||
import { PricingSection } from "./ui/pricing-section";
|
||||
import { Footer } from "./ui/footer";
|
||||
import type { TutorialItem } from "../types/tutorial";
|
||||
|
||||
interface LandingPageProps {
|
||||
onGetStarted?: () => void;
|
||||
onDownloadClick?: () => void;
|
||||
onAccountClick?: () => void;
|
||||
onDemoClick?: () => void;
|
||||
onTutorialSelect?: (tutorial: TutorialItem) => void;
|
||||
onLicenseClick?: () => void;
|
||||
onRoadmapClick?: () => void;
|
||||
onUpdatesClick?: () => void;
|
||||
onSupportClick?: () => void;
|
||||
onContactClick?: () => void;
|
||||
onPrivacyPolicyClick?: () => void;
|
||||
onTermsOfServiceClick?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* sheetEasy AI 랜딩페이지 메인 컴포넌트
|
||||
* - Vooster.ai 스타일을 참고한 모던한 디자인
|
||||
* - semantic HTML 및 접근성 지원
|
||||
* - 반응형 레이아웃
|
||||
* - ShadCN UI 컴포넌트 활용
|
||||
*/
|
||||
const LandingPage: React.FC<LandingPageProps> = ({
|
||||
onGetStarted,
|
||||
onDemoClick,
|
||||
onTutorialSelect,
|
||||
onLicenseClick,
|
||||
onRoadmapClick,
|
||||
onUpdatesClick,
|
||||
onSupportClick,
|
||||
onContactClick,
|
||||
onPrivacyPolicyClick,
|
||||
onTermsOfServiceClick,
|
||||
}) => {
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
{/* Main Content */}
|
||||
<main role="main">
|
||||
{/* Hero Section - 메인 소개 및 CTA */}
|
||||
<HeroSection onGetStarted={onGetStarted} onDemoClick={onDemoClick} />
|
||||
|
||||
{/* Features Section - 주요 기능 소개 */}
|
||||
<FeaturesSection />
|
||||
|
||||
{/* Tutorial Section - Excel 함수 튜토리얼 */}
|
||||
<TutorialSection onTutorialSelect={onTutorialSelect} />
|
||||
|
||||
{/* FAQ Section - 자주 묻는 질문 */}
|
||||
<FAQSection />
|
||||
|
||||
{/* Pricing Section - 가격 정보 */}
|
||||
<PricingSection onGetStarted={onGetStarted} />
|
||||
</main>
|
||||
|
||||
{/* Footer - 푸터 정보 */}
|
||||
<Footer
|
||||
onLicenseClick={onLicenseClick}
|
||||
onRoadmapClick={onRoadmapClick}
|
||||
onUpdatesClick={onUpdatesClick}
|
||||
onSupportClick={onSupportClick}
|
||||
onContactClick={onContactClick}
|
||||
onPrivacyPolicyClick={onPrivacyPolicyClick}
|
||||
onTermsOfServiceClick={onTermsOfServiceClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LandingPage;
|
||||
389
src/components/LicensePage.tsx
Normal file
389
src/components/LicensePage.tsx
Normal file
@@ -0,0 +1,389 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "./ui/card";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
interface LicensePageProps {
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
interface LibraryInfo {
|
||||
name: string;
|
||||
version: string;
|
||||
license: string;
|
||||
description: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 오픈소스 라이브러리 라이센스 정보 페이지
|
||||
* - 사용된 모든 오픈소스 라이브러리 목록
|
||||
* - 라이센스 타입별 분류
|
||||
* - 각 라이브러리의 상세 정보
|
||||
*/
|
||||
const LicensePage: React.FC<LicensePageProps> = ({ onBack }) => {
|
||||
// 사용된 오픈소스 라이브러리 정보
|
||||
const libraries: LibraryInfo[] = [
|
||||
// React 생태계
|
||||
{
|
||||
name: "React",
|
||||
version: "18.3.1",
|
||||
license: "MIT",
|
||||
description: "사용자 인터페이스 구축을 위한 JavaScript 라이브러리",
|
||||
url: "https://reactjs.org/",
|
||||
},
|
||||
{
|
||||
name: "React DOM",
|
||||
version: "18.3.1",
|
||||
license: "MIT",
|
||||
description: "React를 위한 DOM 렌더러",
|
||||
url: "https://reactjs.org/",
|
||||
},
|
||||
|
||||
// UI 프레임워크
|
||||
{
|
||||
name: "Tailwind CSS",
|
||||
version: "3.4.17",
|
||||
license: "MIT",
|
||||
description: "유틸리티 우선 CSS 프레임워크",
|
||||
url: "https://tailwindcss.com/",
|
||||
},
|
||||
{
|
||||
name: "Radix UI",
|
||||
version: "1.0+",
|
||||
license: "MIT",
|
||||
description: "접근 가능한 디자인 시스템 및 컴포넌트 라이브러리",
|
||||
url: "https://www.radix-ui.com/",
|
||||
},
|
||||
{
|
||||
name: "Lucide React",
|
||||
version: "0.468.0",
|
||||
license: "ISC",
|
||||
description: "아름다운 오픈소스 아이콘 라이브러리",
|
||||
url: "https://lucide.dev/",
|
||||
},
|
||||
{
|
||||
name: "class-variance-authority",
|
||||
version: "0.7.1",
|
||||
license: "Apache 2.0",
|
||||
description: "CSS-in-JS 변형 API",
|
||||
url: "https://cva.style/",
|
||||
},
|
||||
{
|
||||
name: "clsx",
|
||||
version: "2.1.1",
|
||||
license: "MIT",
|
||||
description: "조건부 CSS 클래스 이름 구성 유틸리티",
|
||||
url: "https://github.com/lukeed/clsx",
|
||||
},
|
||||
{
|
||||
name: "tailwind-merge",
|
||||
version: "2.5.4",
|
||||
license: "MIT",
|
||||
description: "Tailwind CSS 클래스 병합 유틸리티",
|
||||
url: "https://github.com/dcastil/tailwind-merge",
|
||||
},
|
||||
|
||||
// 차트 라이브러리
|
||||
{
|
||||
name: "Recharts",
|
||||
version: "2.12+",
|
||||
license: "MIT",
|
||||
description: "React용 재사용 가능한 차트 라이브러리",
|
||||
url: "https://recharts.org/",
|
||||
},
|
||||
|
||||
// Excel 처리
|
||||
{
|
||||
name: "Luckysheet",
|
||||
version: "2.1.13",
|
||||
license: "MIT",
|
||||
description: "온라인 스프레드시트 편집기",
|
||||
url: "https://mengshukeji.github.io/LuckysheetDocs/",
|
||||
},
|
||||
{
|
||||
name: "LuckyExcel",
|
||||
version: "1.0.1",
|
||||
license: "MIT",
|
||||
description: "Excel과 Luckysheet 간 변환 라이브러리",
|
||||
url: "https://github.com/mengshukeji/LuckyExcel",
|
||||
},
|
||||
{
|
||||
name: "@zwight/luckyexcel",
|
||||
version: "1.1.6",
|
||||
license: "MIT",
|
||||
description: "LuckyExcel의 개선된 버전",
|
||||
url: "https://github.com/zwight/LuckyExcel",
|
||||
},
|
||||
{
|
||||
name: "FileSaver.js",
|
||||
version: "2.0.5",
|
||||
license: "MIT",
|
||||
description: "클라이언트 사이드 파일 저장 라이브러리",
|
||||
url: "https://github.com/eligrey/FileSaver.js",
|
||||
},
|
||||
|
||||
// Univer 생태계
|
||||
{
|
||||
name: "Univer Core",
|
||||
version: "0.8.2",
|
||||
license: "Apache 2.0",
|
||||
description: "Univer 스프레드시트 엔진 코어",
|
||||
url: "https://univer.ai/",
|
||||
},
|
||||
{
|
||||
name: "Univer Sheets",
|
||||
version: "0.8.2",
|
||||
license: "Apache 2.0",
|
||||
description: "Univer 스프레드시트 컴포넌트",
|
||||
url: "https://univer.ai/",
|
||||
},
|
||||
{
|
||||
name: "Univer Presets",
|
||||
version: "0.8.2",
|
||||
license: "Apache 2.0",
|
||||
description: "Univer 사전 구성 패키지",
|
||||
url: "https://univer.ai/",
|
||||
},
|
||||
|
||||
// 상태 관리
|
||||
{
|
||||
name: "Zustand",
|
||||
version: "5.0.2",
|
||||
license: "MIT",
|
||||
description: "작고 빠른 확장 가능한 상태 관리 솔루션",
|
||||
url: "https://zustand-demo.pmnd.rs/",
|
||||
},
|
||||
|
||||
// 개발 도구
|
||||
{
|
||||
name: "Vite",
|
||||
version: "6.0.1",
|
||||
license: "MIT",
|
||||
description: "빠른 프론트엔드 빌드 도구",
|
||||
url: "https://vitejs.dev/",
|
||||
},
|
||||
{
|
||||
name: "TypeScript",
|
||||
version: "5.6.2",
|
||||
license: "Apache 2.0",
|
||||
description: "JavaScript의 타입 안전 상위 집합",
|
||||
url: "https://www.typescriptlang.org/",
|
||||
},
|
||||
{
|
||||
name: "ESLint",
|
||||
version: "9.15.0",
|
||||
license: "MIT",
|
||||
description: "JavaScript 및 JSX용 정적 코드 분석 도구",
|
||||
url: "https://eslint.org/",
|
||||
},
|
||||
{
|
||||
name: "Prettier",
|
||||
version: "3.4.2",
|
||||
license: "MIT",
|
||||
description: "코드 포맷터",
|
||||
url: "https://prettier.io/",
|
||||
},
|
||||
|
||||
// 테스트
|
||||
{
|
||||
name: "Vitest",
|
||||
version: "3.2.4",
|
||||
license: "MIT",
|
||||
description: "Vite 기반 단위 테스트 프레임워크",
|
||||
url: "https://vitest.dev/",
|
||||
},
|
||||
{
|
||||
name: "Testing Library",
|
||||
version: "16.3.0",
|
||||
license: "MIT",
|
||||
description: "간단하고 완전한 테스트 유틸리티",
|
||||
url: "https://testing-library.com/",
|
||||
},
|
||||
];
|
||||
|
||||
// 라이센스별 분류
|
||||
const licenseGroups = libraries.reduce(
|
||||
(groups, lib) => {
|
||||
const license = lib.license;
|
||||
if (!groups[license]) {
|
||||
groups[license] = [];
|
||||
}
|
||||
groups[license].push(lib);
|
||||
return groups;
|
||||
},
|
||||
{} as Record<string, LibraryInfo[]>,
|
||||
);
|
||||
|
||||
// 라이센스 타입별 색상
|
||||
const getLicenseBadgeVariant = (license: string) => {
|
||||
switch (license) {
|
||||
case "MIT":
|
||||
return "default";
|
||||
case "Apache 2.0":
|
||||
return "secondary";
|
||||
case "ISC":
|
||||
return "outline";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 space-y-6">
|
||||
{/* 페이지 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
오픈소스 라이센스
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
sheetEasy AI에서 사용된 오픈소스 라이브러리들
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={onBack} variant="outline">
|
||||
뒤로 가기
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 라이센스 요약 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>라이센스 요약</CardTitle>
|
||||
<CardDescription>
|
||||
사용된 오픈소스 라이브러리 {libraries.length}개의 라이센스 분포
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{Object.entries(licenseGroups).map(([license, libs]) => (
|
||||
<div key={license} className="flex items-center space-x-2">
|
||||
<Badge variant={getLicenseBadgeVariant(license)}>
|
||||
{license}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{libs.length}개
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 라이센스별 라이브러리 목록 */}
|
||||
<div className="space-y-6">
|
||||
{Object.entries(licenseGroups).map(([license, libs]) => (
|
||||
<Card key={license}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Badge variant={getLicenseBadgeVariant(license)}>
|
||||
{license}
|
||||
</Badge>
|
||||
<span>{license} 라이센스</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{libs.length}개의 라이브러리가 {license} 라이센스를 사용합니다
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-2 px-4 font-medium">
|
||||
라이브러리
|
||||
</th>
|
||||
<th className="text-left py-2 px-4 font-medium">버전</th>
|
||||
<th className="text-left py-2 px-4 font-medium">설명</th>
|
||||
<th className="text-left py-2 px-4 font-medium">링크</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{libs.map((lib, index) => (
|
||||
<tr key={index} className="border-b last:border-b-0">
|
||||
<td className="py-3 px-4 font-medium">{lib.name}</td>
|
||||
<td className="py-3 px-4 text-muted-foreground">
|
||||
{lib.version}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-muted-foreground max-w-md">
|
||||
{lib.description}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
{lib.url && (
|
||||
<a
|
||||
href={lib.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800 text-sm underline"
|
||||
>
|
||||
공식 사이트
|
||||
</a>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 추가 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>라이센스 정보</CardTitle>
|
||||
<CardDescription>오픈소스 라이센스에 대한 추가 정보</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">MIT 라이센스</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
가장 관대한 오픈소스 라이센스 중 하나로, 상업적 사용, 수정, 배포,
|
||||
개인 사용이 모두 허용됩니다. 저작권 표시와 라이센스 표시만
|
||||
유지하면 됩니다.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Apache 2.0 라이센스</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Apache Software Foundation에서 만든 라이센스로, MIT와 유사하게
|
||||
관대하지만 특허권에 대한 명시적인 부여와 보호를 포함합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">ISC 라이센스</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
MIT 라이센스와 기능적으로 동일하지만 더 간단한 언어로 작성된
|
||||
라이센스입니다.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 면책 조항 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>면책 조항</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
이 페이지에 나열된 모든 오픈소스 라이브러리는 각각의 라이센스 조건에
|
||||
따라 사용됩니다. 각 라이브러리의 정확한 라이센스 조건은 해당
|
||||
프로젝트의 공식 저장소에서 확인하시기 바랍니다. sheetEasy AI는
|
||||
이러한 훌륭한 오픈소스 커뮤니티의 기여에 감사드립니다.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LicensePage;
|
||||
410
src/components/PrivacyPolicyPage.tsx
Normal file
410
src/components/PrivacyPolicyPage.tsx
Normal file
@@ -0,0 +1,410 @@
|
||||
import * as React from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
interface PrivacyPolicyPageProps {
|
||||
className?: string;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
const PrivacyPolicyPage = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
PrivacyPolicyPageProps
|
||||
>(({ className, onBack, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"min-h-screen bg-gradient-to-br from-slate-50 to-blue-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="container py-12">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">
|
||||
개인정보처리방침
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600">
|
||||
sheetEasy AI 개인정보 보호 정책
|
||||
</p>
|
||||
</div>
|
||||
{onBack && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
돌아가기
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Policy Content */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="text-2xl">🔒</span>
|
||||
개인정보처리방침
|
||||
</CardTitle>
|
||||
<div className="text-sm text-gray-600">
|
||||
<p>시행일자: 2025년 1월 1일</p>
|
||||
<p>최종 개정일: 2025년 1월 1일</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="prose max-w-none space-y-8">
|
||||
{/* Section 1 */}
|
||||
<section>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
제1조 (개인정보의 처리목적)
|
||||
</h2>
|
||||
<div className="text-gray-700 space-y-4">
|
||||
<p>
|
||||
sheetEasy AI(이하 "회사")는 다음의 목적을 위하여 개인정보를
|
||||
처리합니다. 처리하고 있는 개인정보는 다음의 목적 이외의
|
||||
용도로는 이용되지 않으며, 이용 목적이 변경되는 경우에는
|
||||
개인정보보호법 제18조에 따라 별도의 동의를 받는 등 필요한
|
||||
조치를 이행할 예정입니다.
|
||||
</p>
|
||||
<div className="bg-blue-50 p-4 rounded-lg">
|
||||
<h3 className="font-semibold mb-2">개인정보 처리목적:</h3>
|
||||
<ul className="space-y-1 text-sm">
|
||||
<li>• 서비스 회원가입 및 관리</li>
|
||||
<li>• AI 기반 스프레드시트 편집 서비스 제공</li>
|
||||
<li>• 고객 문의 및 지원 서비스 제공</li>
|
||||
<li>• 서비스 개선 및 맞춤형 서비스 제공</li>
|
||||
<li>• 마케팅 및 광고에의 활용</li>
|
||||
<li>• 법정의무 이행</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Section 2 */}
|
||||
<section>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
제2조 (처리하는 개인정보의 항목)
|
||||
</h2>
|
||||
<div className="text-gray-700 space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-green-50 p-4 rounded-lg">
|
||||
<h3 className="font-semibold mb-2">필수항목</h3>
|
||||
<ul className="space-y-1 text-sm">
|
||||
<li>• 이메일 주소</li>
|
||||
<li>• 비밀번호 (암호화 저장)</li>
|
||||
<li>• 이름 또는 닉네임</li>
|
||||
<li>• 서비스 이용기록</li>
|
||||
<li>• 접속 IP 정보</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="bg-yellow-50 p-4 rounded-lg">
|
||||
<h3 className="font-semibold mb-2">선택항목</h3>
|
||||
<ul className="space-y-1 text-sm">
|
||||
<li>• 프로필 사진</li>
|
||||
<li>• 회사명</li>
|
||||
<li>• 전화번호</li>
|
||||
<li>• 마케팅 수신 동의</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
※ 업로드된 파일 데이터는 브라우저 내에서만 처리되며 서버에
|
||||
저장되지 않습니다.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Section 3 */}
|
||||
<section>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
제3조 (개인정보의 처리 및 보유기간)
|
||||
</h2>
|
||||
<div className="text-gray-700 space-y-4">
|
||||
<p>
|
||||
회사는 법령에 따른 개인정보 보유·이용기간 또는
|
||||
정보주체로부터 개인정보를 수집 시에 동의받은 개인정보
|
||||
보유·이용기간 내에서 개인정보를 처리·보유합니다.
|
||||
</p>
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<h3 className="font-semibold mb-3">보유기간</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span>회원 정보:</span>
|
||||
<span className="font-medium">회원 탈퇴시까지</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>서비스 이용기록:</span>
|
||||
<span className="font-medium">3년</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>결제정보:</span>
|
||||
<span className="font-medium">5년 (전자상거래법)</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>불만처리 기록:</span>
|
||||
<span className="font-medium">3년</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Section 4 */}
|
||||
<section>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
제4조 (개인정보의 제3자 제공)
|
||||
</h2>
|
||||
<div className="text-gray-700 space-y-4">
|
||||
<p>
|
||||
회사는 개인정보를 제1조(개인정보의 처리목적)에서 명시한 범위
|
||||
내에서만 처리하며, 정보주체의 동의, 법률의 특별한 규정 등
|
||||
개인정보보호법 제17조에 해당하는 경우에만 개인정보를
|
||||
제3자에게 제공합니다.
|
||||
</p>
|
||||
<div className="bg-red-50 p-4 rounded-lg border border-red-200">
|
||||
<h3 className="font-semibold mb-2 text-red-800">
|
||||
제3자 제공 현황
|
||||
</h3>
|
||||
<p className="text-sm text-red-700">
|
||||
현재 sheetEasy AI는 개인정보를 제3자에게 제공하지
|
||||
않습니다. 향후 제공이 필요한 경우 사전에 동의를
|
||||
받겠습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Section 5 */}
|
||||
<section>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
제5조 (개인정보처리의 위탁)
|
||||
</h2>
|
||||
<div className="text-gray-700 space-y-4">
|
||||
<p>
|
||||
회사는 원활한 개인정보 업무처리를 위하여 다음과 같이
|
||||
개인정보 처리업무를 위탁하고 있습니다.
|
||||
</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border border-gray-300 text-sm">
|
||||
<thead className="bg-gray-100">
|
||||
<tr>
|
||||
<th className="border border-gray-300 p-2 text-left">
|
||||
수탁업체
|
||||
</th>
|
||||
<th className="border border-gray-300 p-2 text-left">
|
||||
위탁업무
|
||||
</th>
|
||||
<th className="border border-gray-300 p-2 text-left">
|
||||
보유기간
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border border-gray-300 p-2">
|
||||
클라우드 서비스 제공업체
|
||||
</td>
|
||||
<td className="border border-gray-300 p-2">
|
||||
서버 운영 및 데이터 보관
|
||||
</td>
|
||||
<td className="border border-gray-300 p-2">
|
||||
위탁계약 종료시까지
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-300 p-2">
|
||||
이메일 서비스 제공업체
|
||||
</td>
|
||||
<td className="border border-gray-300 p-2">
|
||||
이메일 발송 서비스
|
||||
</td>
|
||||
<td className="border border-gray-300 p-2">
|
||||
위탁계약 종료시까지
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Section 6 */}
|
||||
<section>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
제6조 (정보주체의 권리·의무 및 행사방법)
|
||||
</h2>
|
||||
<div className="text-gray-700 space-y-4">
|
||||
<p>
|
||||
정보주체는 회사에 대해 언제든지 다음 각 호의 개인정보 보호
|
||||
관련 권리를 행사할 수 있습니다.
|
||||
</p>
|
||||
<div className="bg-blue-50 p-4 rounded-lg">
|
||||
<h3 className="font-semibold mb-2">정보주체의 권리</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li>• 개인정보 처리현황 통지요구</li>
|
||||
<li>• 개인정보 열람요구</li>
|
||||
<li>• 개인정보 정정·삭제요구</li>
|
||||
<li>• 개인정보 처리정지요구</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="bg-green-50 p-4 rounded-lg">
|
||||
<h3 className="font-semibold mb-2">권리 행사 방법</h3>
|
||||
<ul className="space-y-1 text-sm">
|
||||
<li>• 이메일: privacy@sheeteasy.ai</li>
|
||||
<li>• 서면, 전화, 이메일을 통하여 하실 수 있으며</li>
|
||||
<li>• 회사는 이에 대해 지체없이 조치하겠습니다.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Section 7 */}
|
||||
<section>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
제7조 (개인정보의 안전성 확보조치)
|
||||
</h2>
|
||||
<div className="text-gray-700 space-y-4">
|
||||
<p>
|
||||
회사는 개인정보보호법 제29조에 따라 다음과 같이 안전성
|
||||
확보에 필요한 기술적/관리적 및 물리적 조치를 하고 있습니다.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-purple-50 p-4 rounded-lg">
|
||||
<h3 className="font-semibold mb-2">기술적 조치</h3>
|
||||
<ul className="space-y-1 text-sm">
|
||||
<li>• SSL/TLS 암호화</li>
|
||||
<li>• 비밀번호 암호화</li>
|
||||
<li>• 접근권한 관리</li>
|
||||
<li>• 보안프로그램 설치</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="bg-orange-50 p-4 rounded-lg">
|
||||
<h3 className="font-semibold mb-2">관리적 조치</h3>
|
||||
<ul className="space-y-1 text-sm">
|
||||
<li>• 내부관리계획 수립</li>
|
||||
<li>• 정기적 직원 교육</li>
|
||||
<li>• 개인정보 취급자 지정</li>
|
||||
<li>• 손상·유출 방지</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<h3 className="font-semibold mb-2">물리적 조치</h3>
|
||||
<ul className="space-y-1 text-sm">
|
||||
<li>• 전산실, 자료보관실 통제</li>
|
||||
<li>• 개인정보 보관 시설 물리적 접근 통제</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Section 8 */}
|
||||
<section>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
제8조 (개인정보보호책임자)
|
||||
</h2>
|
||||
<div className="text-gray-700 space-y-4">
|
||||
<p>
|
||||
회사는 개인정보 처리에 관한 업무를 총괄해서 책임지고,
|
||||
개인정보 처리와 관련한 정보주체의 불만처리 및 피해구제 등을
|
||||
위하여 아래와 같이 개인정보보호책임자를 지정하고 있습니다.
|
||||
</p>
|
||||
<div className="bg-gray-50 p-6 rounded-lg">
|
||||
<h3 className="font-semibold mb-4">개인정보보호책임자</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">성명:</span>
|
||||
<span className="font-medium">개인정보보호책임자</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">직책:</span>
|
||||
<span className="font-medium">개발팀장</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">이메일:</span>
|
||||
<span className="font-medium">
|
||||
privacy@sheeteasy.ai
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Section 9 */}
|
||||
<section>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
제9조 (개인정보 처리방침 변경)
|
||||
</h2>
|
||||
<div className="text-gray-700 space-y-4">
|
||||
<p>
|
||||
이 개인정보처리방침은 시행일로부터 적용되며, 법령 및 방침에
|
||||
따른 변경내용의 추가, 삭제 및 정정이 있는 경우에는
|
||||
변경사항의 시행 7일 전부터 공지사항을 통하여 고지할
|
||||
것입니다.
|
||||
</p>
|
||||
<div className="bg-blue-50 p-4 rounded-lg border border-blue-200">
|
||||
<p className="text-sm text-blue-800">
|
||||
<strong>공지방법:</strong> 서비스 내 공지사항, 이메일 통지
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Contact Info */}
|
||||
<section className="bg-gradient-to-r from-green-50 to-blue-50 p-6 rounded-lg">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">문의처</h2>
|
||||
<div className="space-y-2 text-sm text-gray-700">
|
||||
<p>
|
||||
개인정보 처리방침에 대한 문의사항이 있으시면 아래로
|
||||
연락주시기 바랍니다.
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<p>
|
||||
<strong>이메일:</strong> privacy@sheeteasy.ai
|
||||
</p>
|
||||
<p>
|
||||
<strong>개인정보보호 전담 이메일:</strong>{" "}
|
||||
dpo@sheeteasy.ai
|
||||
</p>
|
||||
<p>
|
||||
<strong>
|
||||
개인정보보호위원회 개인정보보호 종합지원 포털:
|
||||
</strong>{" "}
|
||||
privacy.go.kr
|
||||
</p>
|
||||
<p>
|
||||
<strong>개인정보 침해신고센터:</strong> privacy.go.kr
|
||||
(국번없이 182)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
PrivacyPolicyPage.displayName = "PrivacyPolicyPage";
|
||||
|
||||
export default PrivacyPolicyPage;
|
||||
258
src/components/RoadmapPage.tsx
Normal file
258
src/components/RoadmapPage.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import * as React from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
interface RoadmapPageProps {
|
||||
className?: string;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
const RoadmapPage = React.forwardRef<HTMLDivElement, RoadmapPageProps>(
|
||||
({ className, onBack, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"min-h-screen bg-gradient-to-br from-slate-50 to-blue-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="container py-12">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">로드맵</h1>
|
||||
<p className="text-lg text-gray-600">
|
||||
sheetEasy AI의 미래 계획을 확인하세요
|
||||
</p>
|
||||
</div>
|
||||
{onBack && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
돌아가기
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Roadmap Items */}
|
||||
<div className="space-y-8">
|
||||
{/* Q3 2025 */}
|
||||
<Card className="border-l-4 border-l-green-500">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-2xl font-bold text-gray-900">
|
||||
Q3 2025
|
||||
</CardTitle>
|
||||
<Badge variant="default" className="bg-green-500">
|
||||
계획됨
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-green-100 text-green-600 mt-1">
|
||||
<span className="text-sm font-bold">🚀</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 mb-1">
|
||||
공식 v1.0 출시
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
베타 테스트를 통해 수집된 피드백을 바탕으로 안정화된 첫
|
||||
번째 정식 버전을 출시합니다. 향상된 성능과 안정성을
|
||||
제공합니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-green-100 text-green-600 mt-1">
|
||||
<span className="text-sm font-bold">🔧</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 mb-1">
|
||||
성능 최적화
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
대용량 스프레드시트 처리 속도 개선 및 메모리 사용량
|
||||
최적화를 통해 더 원활한 사용자 경험을 제공합니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-green-100 text-green-600 mt-1">
|
||||
<span className="text-sm font-bold">🌐</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 mb-1">
|
||||
다국어 지원 확장
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
한국어, 영어 외에 일본어, 중국어 간체/번체 지원을
|
||||
추가하여 글로벌 사용자들에게 서비스를 확장합니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Q4 2025 */}
|
||||
<Card className="border-l-4 border-l-blue-500">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-2xl font-bold text-gray-900">
|
||||
Q4 2025
|
||||
</CardTitle>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-blue-500 text-blue-600"
|
||||
>
|
||||
계획 중
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-blue-600 mt-1">
|
||||
<span className="text-sm font-bold">🤖</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 mb-1">
|
||||
자연어 데이터 분석 기능
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
"지난 3개월 매출 추이를 보여줘"와 같은 자연어 명령으로
|
||||
복잡한 데이터 분석을 수행할 수 있는 AI 기능을
|
||||
추가합니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-blue-600 mt-1">
|
||||
<span className="text-sm font-bold">📊</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 mb-1">
|
||||
애니메이션 시각화
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
시간 기반 데이터를 동적 애니메이션으로 표현하여 데이터의
|
||||
변화 과정을 직관적으로 이해할 수 있도록 합니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-blue-600 mt-1">
|
||||
<span className="text-sm font-bold">🔗</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 mb-1">
|
||||
외부 데이터 연동
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Google Sheets, Notion, Airtable 등 다양한 외부
|
||||
플랫폼과의 실시간 데이터 동기화 기능을 제공합니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Future Plans */}
|
||||
<Card className="border-l-4 border-l-purple-500 bg-gradient-to-r from-purple-50 to-pink-50">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-2xl font-bold text-gray-900">
|
||||
장기 계획 (2026+)
|
||||
</CardTitle>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-purple-500 text-purple-600"
|
||||
>
|
||||
연구 중
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<p className="text-gray-700 font-medium">
|
||||
다음과 같은 혁신적인 기능들을 연구하고 있습니다:
|
||||
</p>
|
||||
<ul className="space-y-2 text-gray-600">
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="text-purple-500">•</span>
|
||||
AI 기반 예측 분석 및 추천 시스템
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="text-purple-500">•</span>
|
||||
협업 기능 및 실시간 공동 작업
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="text-purple-500">•</span>
|
||||
모바일 앱 출시 (iOS/Android)
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="text-purple-500">•</span>
|
||||
음성 인터페이스 및 자동화 워크플로우
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* CTA Section */}
|
||||
<div className="mt-12 text-center">
|
||||
<Card className="bg-gradient-to-r from-green-500 to-blue-600 text-white">
|
||||
<CardContent className="p-8">
|
||||
<h2 className="text-2xl font-bold mb-2">
|
||||
로드맵에 대한 의견이 있으신가요?
|
||||
</h2>
|
||||
<p className="mb-4 opacity-90">
|
||||
사용자 피드백은 우리 제품 발전의 원동력입니다. 원하는 기능이나
|
||||
개선사항이 있다면 언제든 알려주세요.
|
||||
</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="bg-white text-gray-900 hover:bg-gray-100"
|
||||
>
|
||||
피드백 보내기
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
RoadmapPage.displayName = "RoadmapPage";
|
||||
|
||||
export default RoadmapPage;
|
||||
380
src/components/SupportPage.tsx
Normal file
380
src/components/SupportPage.tsx
Normal file
@@ -0,0 +1,380 @@
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useAppStore } from "../stores/useAppStore";
|
||||
|
||||
interface SupportPageProps {
|
||||
className?: string;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
const SupportPage = React.forwardRef<HTMLDivElement, SupportPageProps>(
|
||||
({ className, onBack, ...props }, ref) => {
|
||||
const { user, isAuthenticated } = useAppStore();
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>("");
|
||||
const [subject, setSubject] = useState<string>("");
|
||||
const [message, setMessage] = useState<string>("");
|
||||
const [priority, setPriority] = useState<string>("medium");
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
|
||||
const categories = [
|
||||
{ value: "account", label: "계정 관련 문제", icon: "👤" },
|
||||
{ value: "billing", label: "구독 및 결제", icon: "💳" },
|
||||
{ value: "bug", label: "버그 신고", icon: "🐛" },
|
||||
{ value: "feature", label: "기능 문의", icon: "✨" },
|
||||
{ value: "performance", label: "성능 이슈", icon: "⚡" },
|
||||
{ value: "data", label: "데이터 관련", icon: "📊" },
|
||||
{ value: "other", label: "기타", icon: "❓" },
|
||||
];
|
||||
|
||||
const priorities = [
|
||||
{ value: "low", label: "낮음", color: "bg-green-100 text-green-800" },
|
||||
{
|
||||
value: "medium",
|
||||
label: "보통",
|
||||
color: "bg-yellow-100 text-yellow-800",
|
||||
},
|
||||
{ value: "high", label: "높음", color: "bg-orange-100 text-orange-800" },
|
||||
{ value: "urgent", label: "긴급", color: "bg-red-100 text-red-800" },
|
||||
];
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
// 실제 이메일 전송 로직 (모의)
|
||||
try {
|
||||
const emailData = {
|
||||
to: "support@sheeteasy.ai",
|
||||
from: user?.email || "anonymous@sheeteasy.ai",
|
||||
subject: `[${selectedCategory.toUpperCase()}] ${subject}`,
|
||||
html: `
|
||||
<h2>지원 요청</h2>
|
||||
<p><strong>사용자:</strong> ${user?.name || "익명"} (${user?.email || "이메일 없음"})</p>
|
||||
<p><strong>카테고리:</strong> ${categories.find((c) => c.value === selectedCategory)?.label}</p>
|
||||
<p><strong>우선순위:</strong> ${priorities.find((p) => p.value === priority)?.label}</p>
|
||||
<p><strong>제목:</strong> ${subject}</p>
|
||||
<hr>
|
||||
<p><strong>내용:</strong></p>
|
||||
<p>${message.replace(/\n/g, "<br>")}</p>
|
||||
`,
|
||||
};
|
||||
|
||||
// TODO: 실제 이메일 서비스 연동
|
||||
console.log("지원 요청 전송:", emailData);
|
||||
|
||||
// 성공 시 폼 초기화
|
||||
setTimeout(() => {
|
||||
alert(
|
||||
"지원 요청이 성공적으로 전송되었습니다. 빠른 시일 내에 답변드리겠습니다.",
|
||||
);
|
||||
setSelectedCategory("");
|
||||
setSubject("");
|
||||
setMessage("");
|
||||
setPriority("medium");
|
||||
setIsSubmitting(false);
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
alert("지원 요청 전송에 실패했습니다. 다시 시도해주세요.");
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 로그인하지 않은 사용자 처리
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 flex items-center justify-center",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<span className="text-2xl">🔒</span>
|
||||
</div>
|
||||
<CardTitle className="text-2xl text-gray-900">
|
||||
로그인이 필요합니다
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center">
|
||||
<p className="text-gray-600 mb-6">
|
||||
지원 서비스는 로그인한 사용자만 이용할 수 있습니다.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{onBack && (
|
||||
<Button onClick={onBack} className="w-full">
|
||||
로그인하러 가기
|
||||
</Button>
|
||||
)}
|
||||
<p className="text-sm text-gray-500">
|
||||
일반적인 문의는 <strong>문의하기</strong> 페이지를
|
||||
이용해주세요.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"min-h-screen bg-gradient-to-br from-slate-50 to-blue-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="container py-12">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">
|
||||
지원 센터
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600">
|
||||
문제가 있으시거나 도움이 필요하시면 언제든 연락주세요
|
||||
</p>
|
||||
</div>
|
||||
{onBack && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
돌아가기
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Support Form */}
|
||||
<div className="lg:col-span-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="text-2xl">📝</span>
|
||||
지원 요청하기
|
||||
</CardTitle>
|
||||
<p className="text-gray-600">
|
||||
문제를 자세히 설명해주시면 빠르게 도움을 드리겠습니다.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Category Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
문의 유형
|
||||
</label>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category.value}
|
||||
type="button"
|
||||
onClick={() => setSelectedCategory(category.value)}
|
||||
className={cn(
|
||||
"p-3 rounded-lg border text-left transition-all",
|
||||
selectedCategory === category.value
|
||||
? "border-blue-500 bg-blue-50 text-blue-700"
|
||||
: "border-gray-200 hover:border-gray-300",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">{category.icon}</span>
|
||||
<span className="text-sm font-medium">
|
||||
{category.label}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Priority */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
우선순위
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{priorities.map((p) => (
|
||||
<button
|
||||
key={p.value}
|
||||
type="button"
|
||||
onClick={() => setPriority(p.value)}
|
||||
className={cn(
|
||||
"px-3 py-1 rounded-full text-sm font-medium transition-all",
|
||||
priority === p.value
|
||||
? p.color
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200",
|
||||
)}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subject */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
제목
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder="문제를 간단히 요약해주세요"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
상세 내용
|
||||
</label>
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="문제에 대해 자세히 설명해주세요. 발생 시점, 브라우저 종류, 에러 메시지 등을 포함하면 더 빠른 해결이 가능합니다."
|
||||
rows={6}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-vertical"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
!selectedCategory ||
|
||||
!subject.trim() ||
|
||||
!message.trim() ||
|
||||
isSubmitting
|
||||
}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? "전송 중..." : "지원 요청 보내기"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* User Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">계정 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">이름:</span>
|
||||
<span className="font-medium">{user?.name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">이메일:</span>
|
||||
<span className="font-medium">{user?.email}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">플랜:</span>
|
||||
<Badge variant="outline">
|
||||
{user?.subscription?.plan?.toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Quick Help */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">빠른 도움말</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 mb-1">
|
||||
일반적인 문제
|
||||
</h4>
|
||||
<ul className="space-y-1 text-gray-600">
|
||||
<li>• 파일 업로드가 안되는 경우</li>
|
||||
<li>• AI 처리가 느린 경우</li>
|
||||
<li>• 브라우저 호환성 문제</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 mb-1">
|
||||
응답 시간
|
||||
</h4>
|
||||
<ul className="space-y-1 text-gray-600">
|
||||
<li>• 긴급: 2시간 이내</li>
|
||||
<li>• 높음: 24시간 이내</li>
|
||||
<li>• 보통: 48시간 이내</li>
|
||||
<li>• 낮음: 72시간 이내</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Contact Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">직접 연락</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">📧</span>
|
||||
<a
|
||||
href="mailto:support@sheeteasy.ai"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
support@sheeteasy.ai
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">💬</span>
|
||||
<span className="text-gray-600">
|
||||
실시간 채팅 (평일 9-18시)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
SupportPage.displayName = "SupportPage";
|
||||
|
||||
export default SupportPage;
|
||||
277
src/components/TermsOfServicePage.tsx
Normal file
277
src/components/TermsOfServicePage.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
import * as React from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
interface TermsOfServicePageProps {
|
||||
className?: string;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
const TermsOfServicePage = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
TermsOfServicePageProps
|
||||
>(({ className, onBack, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"min-h-screen bg-gradient-to-br from-slate-50 to-blue-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="container py-12">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">
|
||||
서비스 이용약관
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600">
|
||||
sheetEasy AI 서비스 이용 약관
|
||||
</p>
|
||||
</div>
|
||||
{onBack && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
돌아가기
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Terms Content */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="text-2xl">📋</span>
|
||||
서비스 이용약관
|
||||
</CardTitle>
|
||||
<div className="text-sm text-gray-600">
|
||||
<p>시행일자: 2025년 1월 1일</p>
|
||||
<p>최종 개정일: 2025년 1월 1일</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="prose max-w-none space-y-6">
|
||||
{/* Section 1 */}
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-3">
|
||||
제1조 (목적)
|
||||
</h2>
|
||||
<div className="text-gray-700 space-y-2">
|
||||
<p>
|
||||
이 약관은 sheetEasy AI(이하 "회사")가 제공하는 AI 기반
|
||||
스프레드시트 편집 서비스(이하 "서비스")의 이용조건 및 절차,
|
||||
회사와 이용자의 권리, 의무, 책임사항을 규정함을 목적으로
|
||||
합니다.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Section 2 */}
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-3">
|
||||
제2조 (정의)
|
||||
</h2>
|
||||
<div className="text-gray-700 space-y-2">
|
||||
<div className="bg-gray-50 p-4 rounded-lg space-y-2">
|
||||
<div>
|
||||
<strong>1. "서비스"</strong>: AI 기반 스프레드시트 편집
|
||||
도구
|
||||
</div>
|
||||
<div>
|
||||
<strong>2. "이용자"</strong>: 본 약관에 동의하고 서비스를
|
||||
이용하는 자
|
||||
</div>
|
||||
<div>
|
||||
<strong>3. "계정"</strong>: 서비스 이용을 위해 생성된 개인
|
||||
식별 정보
|
||||
</div>
|
||||
<div>
|
||||
<strong>4. "콘텐츠"</strong>: 이용자가 업로드한 파일 및
|
||||
데이터
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Section 3 */}
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-3">
|
||||
제3조 (약관의 효력 및 변경)
|
||||
</h2>
|
||||
<div className="text-gray-700 space-y-2">
|
||||
<p>
|
||||
1. 본 약관은 서비스 화면에 게시하여 공시합니다.
|
||||
<br />
|
||||
2. 회사는 필요시 관련 법령에 위배되지 않는 범위에서 본
|
||||
약관을 개정할 수 있습니다.
|
||||
<br />
|
||||
3. 개정된 약관은 7일 전 공지하며, 불리한 변경의 경우 30일 전
|
||||
공지합니다.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Section 4 */}
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-3">
|
||||
제4조 (서비스의 제공)
|
||||
</h2>
|
||||
<div className="text-gray-700 space-y-2">
|
||||
<div className="bg-blue-50 p-4 rounded-lg">
|
||||
<h3 className="font-semibold mb-2">제공 서비스:</h3>
|
||||
<ul className="space-y-1 text-sm">
|
||||
<li>• AI 기반 스프레드시트 자동 편집</li>
|
||||
<li>• Excel, CSV 파일 처리 및 변환</li>
|
||||
<li>• 브라우저 기반 데이터 처리</li>
|
||||
<li>• 튜토리얼 및 사용자 지원</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Section 5 */}
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-3">
|
||||
제5조 (이용자의 의무)
|
||||
</h2>
|
||||
<div className="text-gray-700 space-y-2">
|
||||
<div className="bg-red-50 p-4 rounded-lg border border-red-200">
|
||||
<h3 className="font-semibold mb-2 text-red-800">
|
||||
금지사항:
|
||||
</h3>
|
||||
<ul className="space-y-1 text-sm text-red-700">
|
||||
<li>• 타인의 개인정보 무단 수집, 저장, 공개</li>
|
||||
<li>• 서비스의 안정적 운영을 방해하는 행위</li>
|
||||
<li>• 불법적인 목적으로 서비스 이용</li>
|
||||
<li>• 저작권 등 타인의 권리를 침해하는 행위</li>
|
||||
<li>• 서비스를 상업적으로 재판매하는 행위</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Section 6 */}
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-3">
|
||||
제6조 (데이터 보안 및 책임)
|
||||
</h2>
|
||||
<div className="text-gray-700 space-y-2">
|
||||
<div className="bg-green-50 p-4 rounded-lg">
|
||||
<h3 className="font-semibold mb-2">데이터 처리 원칙:</h3>
|
||||
<ul className="space-y-1 text-sm">
|
||||
<li>• 모든 파일은 브라우저 내에서만 처리됩니다</li>
|
||||
<li>• 업로드된 데이터는 서버에 저장되지 않습니다</li>
|
||||
<li>• 사용자가 직접 데이터 삭제 및 관리 가능</li>
|
||||
<li>• 개인정보보호법에 따른 안전조치 준수</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Section 7 */}
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-3">
|
||||
제7조 (서비스 이용제한)
|
||||
</h2>
|
||||
<div className="text-gray-700 space-y-2">
|
||||
<p>
|
||||
회사는 이용자가 다음 각 호에 해당하는 경우 사전 통지 없이
|
||||
서비스 이용을 제한할 수 있습니다:
|
||||
</p>
|
||||
<ul className="space-y-1 text-sm ml-4">
|
||||
<li>• 본 약관을 위반한 경우</li>
|
||||
<li>• 서비스의 정상적 운영을 방해한 경우</li>
|
||||
<li>• 타인의 권리를 침해한 경우</li>
|
||||
<li>• 기타 관련 법령을 위반한 경우</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Section 8 */}
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-3">
|
||||
제8조 (면책사항)
|
||||
</h2>
|
||||
<div className="text-gray-700 space-y-2">
|
||||
<div className="bg-yellow-50 p-4 rounded-lg border border-yellow-200">
|
||||
<h3 className="font-semibold mb-2">
|
||||
회사는 다음의 경우 책임을 지지 않습니다:
|
||||
</h3>
|
||||
<ul className="space-y-1 text-sm">
|
||||
<li>• 천재지변, 전쟁 등 불가항력적 사유</li>
|
||||
<li>• 이용자의 귀책사유로 인한 서비스 이용 장애</li>
|
||||
<li>• 무료 서비스 이용과 관련한 손해</li>
|
||||
<li>• 이용자가 업로드한 데이터의 내용에 대한 책임</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Section 9 */}
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-3">
|
||||
제9조 (분쟁해결)
|
||||
</h2>
|
||||
<div className="text-gray-700 space-y-2">
|
||||
<p>
|
||||
1. 본 약관은 대한민국 법률에 따라 해석됩니다.
|
||||
<br />
|
||||
2. 서비스 이용과 관련한 분쟁은 회사 소재지 관할법원에서
|
||||
해결합니다.
|
||||
<br />
|
||||
3. 분쟁 발생시 먼저 상호 협의를 통한 해결을 원칙으로 합니다.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Contact Info */}
|
||||
<section className="bg-gradient-to-r from-blue-50 to-purple-50 p-6 rounded-lg">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">문의처</h2>
|
||||
<div className="space-y-2 text-sm text-gray-700">
|
||||
<p>
|
||||
본 약관에 대한 문의사항이 있으시면 아래로 연락주시기
|
||||
바랍니다.
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<p>
|
||||
<strong>이메일:</strong> legal@sheeteasy.ai
|
||||
</p>
|
||||
<p>
|
||||
<strong>고객지원:</strong> support@sheeteasy.ai
|
||||
</p>
|
||||
<p>
|
||||
<strong>일반문의:</strong> contact@sheeteasy.ai
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
TermsOfServicePage.displayName = "TermsOfServicePage";
|
||||
|
||||
export default TermsOfServicePage;
|
||||
608
src/components/TutorialSheetViewer.tsx
Normal file
608
src/components/TutorialSheetViewer.tsx
Normal file
@@ -0,0 +1,608 @@
|
||||
import React, { useRef, useEffect, useState, useCallback } from "react";
|
||||
import { LocaleType } from "@univerjs/presets";
|
||||
|
||||
// Presets CSS import
|
||||
import "@univerjs/presets/lib/styles/preset-sheets-core.css";
|
||||
import { cn } from "../lib/utils";
|
||||
import PromptInput from "./sheet/PromptInput";
|
||||
import HistoryPanel from "./ui/historyPanel";
|
||||
import { useAppStore } from "../stores/useAppStore";
|
||||
import { rangeToAddress } from "../utils/cellUtils";
|
||||
import { CellSelectionHandler } from "../utils/cellSelectionHandler";
|
||||
import { aiProcessor } from "../utils/aiProcessor";
|
||||
import { TutorialExecutor } from "../utils/tutorialExecutor";
|
||||
import { TutorialDataGenerator } from "../utils/tutorialDataGenerator";
|
||||
import { TutorialCard } from "./ui/tutorial-card";
|
||||
import type { HistoryEntry } from "../types/ai";
|
||||
import type { TutorialItem } from "../types/tutorial";
|
||||
import { UniverseManager } from "./sheet/EditSheetViewer";
|
||||
|
||||
// 튜토리얼 단계 타입 정의
|
||||
type TutorialStep = "select" | "loaded" | "prompted" | "executed";
|
||||
|
||||
/**
|
||||
* 튜토리얼 전용 시트 뷰어
|
||||
* - EditSheetViewer 기반, 파일 업로드 기능 완전 제거
|
||||
* - 단계별 플로우: values → prompt → send → result
|
||||
* - Univer 인스턴스 중복 초기화 방지
|
||||
* - 일관된 네비게이션 경험 제공
|
||||
*/
|
||||
const TutorialSheetViewer: React.FC = () => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mountedRef = useRef<boolean>(false);
|
||||
|
||||
const [isInitialized, setIsInitialized] = useState<boolean>(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [showPromptInput, setShowPromptInput] = useState(false);
|
||||
const [currentStep, setCurrentStep] = useState<TutorialStep>("select");
|
||||
const [executedTutorialPrompt, setExecutedTutorialPrompt] =
|
||||
useState<string>("");
|
||||
|
||||
// 선택된 튜토리얼과 튜토리얼 목록
|
||||
const [selectedTutorial, setSelectedTutorial] = useState<TutorialItem | null>(
|
||||
null,
|
||||
);
|
||||
const [tutorialList] = useState<TutorialItem[]>(() =>
|
||||
TutorialDataGenerator.generateAllTutorials(),
|
||||
);
|
||||
|
||||
const appStore = useAppStore();
|
||||
|
||||
// CellSelectionHandler 및 TutorialExecutor 인스턴스
|
||||
const cellSelectionHandler = useRef(new CellSelectionHandler());
|
||||
const tutorialExecutor = useRef(new TutorialExecutor());
|
||||
|
||||
// 히스토리 관련 상태
|
||||
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
|
||||
const [history, setHistory] = useState<HistoryEntry[]>([]);
|
||||
|
||||
// 안전한 Univer 초기화 함수
|
||||
const initializeUniver = useCallback(async () => {
|
||||
if (!containerRef.current) {
|
||||
console.warn("⚠️ 컨테이너가 준비되지 않음");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isInitialized) {
|
||||
console.log("✅ 이미 초기화됨 - 스킵");
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("🚀 Univer 초기화 시작 (Tutorial)");
|
||||
setIsProcessing(true);
|
||||
|
||||
// UniverseManager를 통한 전역 인스턴스 생성
|
||||
const { univer, univerAPI } = await UniverseManager.createInstance(
|
||||
containerRef.current,
|
||||
);
|
||||
|
||||
// 기본 워크북 데이터 (튜토리얼용)
|
||||
const defaultWorkbook = {
|
||||
id: `tutorial-workbook-${Date.now()}`,
|
||||
locale: LocaleType.EN_US,
|
||||
name: "Tutorial Workbook",
|
||||
sheetOrder: ["tutorial-sheet"],
|
||||
sheets: {
|
||||
"tutorial-sheet": {
|
||||
type: 0,
|
||||
id: "tutorial-sheet",
|
||||
name: "Tutorial Sheet",
|
||||
tabColor: "",
|
||||
hidden: 0,
|
||||
rowCount: 100,
|
||||
columnCount: 20,
|
||||
zoomRatio: 1,
|
||||
scrollTop: 0,
|
||||
scrollLeft: 0,
|
||||
defaultColumnWidth: 93,
|
||||
defaultRowHeight: 27,
|
||||
cellData: {},
|
||||
rowData: {},
|
||||
columnData: {},
|
||||
showGridlines: 1,
|
||||
rowHeader: { width: 46, hidden: 0 },
|
||||
columnHeader: { height: 20, hidden: 0 },
|
||||
selections: ["A1"],
|
||||
rightToLeft: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 워크북 생성 (TutorialSheetViewer에서 누락된 부분)
|
||||
if (univerAPI) {
|
||||
console.log("✅ Presets 기반 univerAPI 초기화 완료");
|
||||
|
||||
// 새 워크북 생성 (presets univerAPI 방식)
|
||||
const workbook = univerAPI.createWorkbook(defaultWorkbook);
|
||||
console.log("✅ 튜토리얼용 워크북 생성 완료:", workbook?.getId());
|
||||
|
||||
// TutorialExecutor 초기화
|
||||
tutorialExecutor.current.setUniverAPI(univerAPI);
|
||||
|
||||
// CellSelectionHandler 초기화
|
||||
cellSelectionHandler.current.initialize(univer);
|
||||
|
||||
console.log("✅ Univer 초기화 완료 (Tutorial)");
|
||||
setIsInitialized(true);
|
||||
return true;
|
||||
} else {
|
||||
console.warn("⚠️ univerAPI가 제공되지 않음");
|
||||
setIsInitialized(false);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ Univer 초기화 실패:", error);
|
||||
setIsInitialized(false);
|
||||
return false;
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}, [isInitialized]);
|
||||
|
||||
// 히스토리 관련 핸들러들
|
||||
const handleHistoryToggle = () => {
|
||||
console.log("🔄 히스토리 토글:", !isHistoryOpen);
|
||||
setIsHistoryOpen(!isHistoryOpen);
|
||||
};
|
||||
|
||||
const handleHistoryClear = () => {
|
||||
if (window.confirm("모든 히스토리를 삭제하시겠습니까?")) {
|
||||
setHistory([]);
|
||||
}
|
||||
};
|
||||
|
||||
// 히스토리 항목 추가 함수
|
||||
const addHistoryEntry = (
|
||||
prompt: string,
|
||||
range: string,
|
||||
sheetName: string,
|
||||
actions: any[],
|
||||
status: "success" | "error" | "pending",
|
||||
error?: string,
|
||||
) => {
|
||||
const newEntry: HistoryEntry = {
|
||||
id: `tutorial-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
timestamp: new Date(),
|
||||
prompt,
|
||||
range,
|
||||
sheetName,
|
||||
actions,
|
||||
status,
|
||||
error,
|
||||
};
|
||||
|
||||
setHistory((prev) => [newEntry, ...prev]);
|
||||
return newEntry.id;
|
||||
};
|
||||
|
||||
// 히스토리 항목 업데이트 함수
|
||||
const updateHistoryEntry = (
|
||||
id: string,
|
||||
updates: Partial<Omit<HistoryEntry, "id" | "timestamp">>,
|
||||
) => {
|
||||
setHistory((prev) =>
|
||||
prev.map((entry) => (entry.id === id ? { ...entry, ...updates } : entry)),
|
||||
);
|
||||
};
|
||||
|
||||
// 튜토리얼 공식 적용 함수 - 사전 정의된 결과를 Univer에 적용
|
||||
const applyTutorialFormula = useCallback(async (tutorial: TutorialItem) => {
|
||||
try {
|
||||
if (!tutorialExecutor.current.isReady()) {
|
||||
throw new Error("TutorialExecutor가 준비되지 않았습니다.");
|
||||
}
|
||||
|
||||
console.log(`🎯 튜토리얼 "${tutorial.metadata.title}" 공식 적용 시작`);
|
||||
|
||||
// TutorialExecutor의 applyTutorialResult 메서드 사용
|
||||
const result =
|
||||
await tutorialExecutor.current.applyTutorialResult(tutorial);
|
||||
|
||||
console.log(`✅ 튜토리얼 "${tutorial.metadata.title}" 공식 적용 완료`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("❌ 튜토리얼 공식 적용 실패:", error);
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "알 수 없는 오류가 발생했습니다.",
|
||||
appliedActions: [],
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 히스토리 재적용 핸들러
|
||||
const handleHistoryReapply = useCallback(async (entry: HistoryEntry) => {
|
||||
console.log("🔄 히스토리 재적용 시작:", entry);
|
||||
|
||||
setPrompt(entry.prompt);
|
||||
|
||||
const confirmReapply = window.confirm(
|
||||
`다음 프롬프트를 다시 실행하시겠습니까?\n\n"${entry.prompt}"\n\n범위: ${entry.range} | 시트: ${entry.sheetName}`,
|
||||
);
|
||||
|
||||
if (!confirmReapply) return;
|
||||
|
||||
const reapplyHistoryId = addHistoryEntry(
|
||||
`[재적용] ${entry.prompt}`,
|
||||
entry.range,
|
||||
entry.sheetName,
|
||||
[],
|
||||
"pending",
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await aiProcessor.processPrompt(entry.prompt, true);
|
||||
|
||||
if (result.success) {
|
||||
updateHistoryEntry(reapplyHistoryId, {
|
||||
status: "success",
|
||||
actions:
|
||||
result.appliedCells?.map((cell) => ({
|
||||
type: "formula" as const,
|
||||
range: cell,
|
||||
formula: `=재적용 수식`,
|
||||
})) || [],
|
||||
});
|
||||
console.log(`✅ 재적용 성공: ${result.message}`);
|
||||
} else {
|
||||
updateHistoryEntry(reapplyHistoryId, {
|
||||
status: "error",
|
||||
error: result.message,
|
||||
actions: [],
|
||||
});
|
||||
alert(`❌ 재적용 실패: ${result.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "알 수 없는 오류가 발생했습니다.";
|
||||
updateHistoryEntry(reapplyHistoryId, {
|
||||
status: "error",
|
||||
error: errorMessage,
|
||||
actions: [],
|
||||
});
|
||||
alert(`❌ 재적용 오류: ${errorMessage}`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 단계별 튜토리얼 플로우 구현
|
||||
const handleTutorialSelect = useCallback(
|
||||
async (tutorial: TutorialItem) => {
|
||||
if (isProcessing) {
|
||||
console.log("⏳ 이미 처리 중이므로 스킵");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("🎯 튜토리얼 선택:", tutorial.metadata.title);
|
||||
setIsProcessing(true);
|
||||
setSelectedTutorial(tutorial);
|
||||
|
||||
// 새 튜토리얼 선택 시 이전 실행 프롬프트 초기화
|
||||
setExecutedTutorialPrompt("");
|
||||
|
||||
try {
|
||||
// Step 1: 초기화 확인
|
||||
if (!isInitialized) {
|
||||
const initSuccess = await initializeUniver();
|
||||
if (!initSuccess) {
|
||||
throw new Error("Univer 초기화 실패");
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: 값만 로드 (수식 없이)
|
||||
console.log("📊 Step 1: 예제 데이터 로드 중...");
|
||||
await tutorialExecutor.current.populateSampleData(tutorial.sampleData);
|
||||
|
||||
// Step 3: 프롬프트 미리 설정 (실행하지 않음)
|
||||
console.log("💭 Step 2: 프롬프트 미리 설정");
|
||||
setPrompt(tutorial.prompt);
|
||||
|
||||
// Step 4: 플로우 상태 업데이트
|
||||
setCurrentStep("loaded");
|
||||
setShowPromptInput(true);
|
||||
|
||||
console.log("✅ 튜토리얼 준비 완료 - 사용자가 Send 버튼을 눌러야 함");
|
||||
} catch (error) {
|
||||
console.error("❌ 튜토리얼 설정 실패:", error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "알 수 없는 오류";
|
||||
alert(`튜토리얼을 준비하는 중 오류가 발생했습니다: ${errorMessage}`);
|
||||
setCurrentStep("select");
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
},
|
||||
[isProcessing, isInitialized, initializeUniver],
|
||||
);
|
||||
|
||||
// 튜토리얼 시뮬레이션 핸들러 (Send 버튼용) - AI 대신 사전 정의된 공식 사용
|
||||
const handlePromptExecute = useCallback(async () => {
|
||||
if (!prompt.trim()) {
|
||||
alert("프롬프트를 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedTutorial) {
|
||||
alert("먼저 튜토리얼을 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("🎯 Step 3: 튜토리얼 시뮬레이션 시작");
|
||||
setIsProcessing(true);
|
||||
setCurrentStep("prompted");
|
||||
|
||||
const currentRange = appStore.selectedRange
|
||||
? rangeToAddress(appStore.selectedRange.range)
|
||||
: selectedTutorial.targetCell;
|
||||
const currentSheetName = selectedTutorial.metadata.title;
|
||||
|
||||
// 히스토리에 pending 상태로 추가
|
||||
const historyId = addHistoryEntry(
|
||||
prompt.trim(),
|
||||
currentRange,
|
||||
currentSheetName,
|
||||
[],
|
||||
"pending",
|
||||
);
|
||||
|
||||
try {
|
||||
// AI 처리 시뮬레이션 (0.8초 지연으로 자연스러운 처리 시간 연출)
|
||||
console.log("🤖 AI 처리 시뮬레이션 중...");
|
||||
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||
|
||||
// 선택된 튜토리얼의 사전 정의된 결과 적용
|
||||
const tutorialResult = await applyTutorialFormula(selectedTutorial);
|
||||
|
||||
if (tutorialResult.success) {
|
||||
updateHistoryEntry(historyId, {
|
||||
status: "success",
|
||||
actions: tutorialResult.appliedActions || [],
|
||||
});
|
||||
|
||||
// Step 4: 결과 완료 상태
|
||||
setCurrentStep("executed");
|
||||
console.log("✅ Step 4: 튜토리얼 시뮬레이션 완료");
|
||||
|
||||
// 실행된 프롬프트를 저장하고 입력창 비우기
|
||||
setExecutedTutorialPrompt(prompt.trim());
|
||||
setPrompt("");
|
||||
} else {
|
||||
updateHistoryEntry(historyId, {
|
||||
status: "error",
|
||||
error: tutorialResult.message,
|
||||
actions: [],
|
||||
});
|
||||
alert(`❌ 실행 실패: ${tutorialResult.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "알 수 없는 오류가 발생했습니다.";
|
||||
|
||||
updateHistoryEntry(historyId, {
|
||||
status: "error",
|
||||
error: errorMessage,
|
||||
actions: [],
|
||||
});
|
||||
|
||||
alert(`❌ 실행 오류: ${errorMessage}`);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}, [prompt, selectedTutorial, appStore.selectedRange]);
|
||||
|
||||
// 컴포넌트 마운트 시 초기화
|
||||
useEffect(() => {
|
||||
if (mountedRef.current) return;
|
||||
mountedRef.current = true;
|
||||
|
||||
const setupTutorial = async () => {
|
||||
try {
|
||||
// DOM과 컨테이너 준비 완료 대기
|
||||
await new Promise<void>((resolve) => {
|
||||
const checkContainer = () => {
|
||||
if (
|
||||
containerRef.current &&
|
||||
containerRef.current.offsetParent !== null
|
||||
) {
|
||||
resolve();
|
||||
} else {
|
||||
requestAnimationFrame(checkContainer);
|
||||
}
|
||||
};
|
||||
checkContainer();
|
||||
});
|
||||
|
||||
// 앱 스토어에서 선택된 튜토리얼 확인
|
||||
const activeTutorial = appStore.tutorialSession.activeTutorial;
|
||||
if (activeTutorial) {
|
||||
console.log(
|
||||
"📚 앱 스토어에서 활성 튜토리얼 발견:",
|
||||
activeTutorial.metadata.title,
|
||||
);
|
||||
await handleTutorialSelect(activeTutorial);
|
||||
} else {
|
||||
// Univer만 초기화하고 대기
|
||||
await initializeUniver();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 튜토리얼 설정 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
setupTutorial();
|
||||
}, [
|
||||
initializeUniver,
|
||||
handleTutorialSelect,
|
||||
appStore.tutorialSession.activeTutorial,
|
||||
]);
|
||||
|
||||
// 컴포넌트 언마운트 시 리소스 정리
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (cellSelectionHandler.current.isActive()) {
|
||||
cellSelectionHandler.current.dispose();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 단계별 UI 렌더링 도우미
|
||||
const renderStepIndicator = () => {
|
||||
const steps = [
|
||||
{ key: "select", label: "튜토리얼 선택", icon: "🎯" },
|
||||
{ key: "loaded", label: "데이터 로드됨", icon: "📊" },
|
||||
{ key: "prompted", label: "프롬프트 준비", icon: "💭" },
|
||||
{ key: "executed", label: "실행 완료", icon: "✅" },
|
||||
];
|
||||
|
||||
const currentIndex = steps.findIndex((step) => step.key === currentStep);
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
{steps.map((step, index) => (
|
||||
<div
|
||||
key={step.key}
|
||||
className={cn(
|
||||
"flex items-center space-x-1 px-3 py-1 rounded-full text-sm",
|
||||
index <= currentIndex
|
||||
? "bg-blue-100 text-blue-800"
|
||||
: "bg-gray-100 text-gray-500",
|
||||
)}
|
||||
>
|
||||
<span>{step.icon}</span>
|
||||
<span>{step.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-gray-50">
|
||||
{/* 튜토리얼 헤더 */}
|
||||
<div className="bg-white border-b border-gray-200 p-4 flex-shrink-0">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
📚 Excel 함수 튜토리얼
|
||||
</h1>
|
||||
|
||||
{/* 단계 표시기 */}
|
||||
{renderStepIndicator()}
|
||||
|
||||
<p className="text-gray-600 mb-4">
|
||||
{currentStep === "select" && "튜토리얼을 선택하여 시작하세요."}
|
||||
{currentStep === "loaded" &&
|
||||
"데이터가 로드되었습니다. 아래 프롬프트를 확인하고 Send 버튼을 클릭하세요."}
|
||||
{currentStep === "prompted" && "프롬프트를 실행 중입니다..."}
|
||||
{currentStep === "executed" &&
|
||||
"실행이 완료되었습니다! 결과를 확인해보세요."}
|
||||
</p>
|
||||
|
||||
{/* 현재 선택된 튜토리얼 정보 */}
|
||||
{selectedTutorial && (
|
||||
<div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<h3 className="font-semibold text-blue-900 mb-2">
|
||||
현재 튜토리얼: {selectedTutorial.metadata.title}
|
||||
</h3>
|
||||
<p className="text-blue-700 text-sm mb-2">
|
||||
{selectedTutorial.metadata.description}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded">
|
||||
함수: {selectedTutorial.functionName}
|
||||
</span>
|
||||
<span className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded">
|
||||
난이도: {selectedTutorial.metadata.difficulty}
|
||||
</span>
|
||||
<span className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded">
|
||||
소요시간: {selectedTutorial.metadata.estimatedTime}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 튜토리얼 선택 그리드 */}
|
||||
{currentStep === "select" && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 mb-4">
|
||||
{tutorialList.map((tutorial) => (
|
||||
<TutorialCard
|
||||
key={tutorial.metadata.id}
|
||||
tutorial={tutorial}
|
||||
onClick={handleTutorialSelect}
|
||||
isActive={
|
||||
selectedTutorial?.metadata.id === tutorial.metadata.id
|
||||
}
|
||||
showBadge={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 스프레드시트 영역 */}
|
||||
<div className="flex-1 relative">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute inset-0 bg-white"
|
||||
style={{
|
||||
minHeight: "0",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 히스토리 패널 */}
|
||||
<HistoryPanel
|
||||
isOpen={isHistoryOpen}
|
||||
onClose={() => setIsHistoryOpen(false)}
|
||||
history={history}
|
||||
onReapply={handleHistoryReapply}
|
||||
onClear={handleHistoryClear}
|
||||
/>
|
||||
|
||||
{/* 프롬프트 입력창 - 단계별 표시 */}
|
||||
{showPromptInput &&
|
||||
(currentStep === "loaded" || currentStep === "executed") && (
|
||||
<div className="absolute bottom-6 left-1/2 transform -translate-x-1/2 z-40">
|
||||
<div className="bg-white/95 backdrop-blur-sm border border-gray-200 rounded-2xl shadow-2xl p-4 max-w-4xl w-[90vw] sm:w-[80vw] md:w-[70vw] lg:w-[60vw]">
|
||||
<PromptInput
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
onExecute={handlePromptExecute}
|
||||
onHistoryToggle={handleHistoryToggle}
|
||||
historyCount={history.length}
|
||||
disabled={isProcessing || currentStep !== "loaded"}
|
||||
tutorialPrompt={executedTutorialPrompt}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 로딩 오버레이 */}
|
||||
{isProcessing && (
|
||||
<div className="absolute inset-0 bg-white/50 backdrop-blur-sm flex items-center justify-center z-50">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">
|
||||
{currentStep === "select" && "튜토리얼 준비 중..."}
|
||||
{currentStep === "loaded" && "데이터 로딩 중..."}
|
||||
{currentStep === "prompted" &&
|
||||
"🤖 AI가 수식을 생성하고 있습니다..."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TutorialSheetViewer;
|
||||
244
src/components/UpdatesPage.tsx
Normal file
244
src/components/UpdatesPage.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import * as React from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
interface UpdatesPageProps {
|
||||
className?: string;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
const UpdatesPage = React.forwardRef<HTMLDivElement, UpdatesPageProps>(
|
||||
({ className, onBack, ...props }, ref) => {
|
||||
const updates = [
|
||||
{
|
||||
version: "1.0.0",
|
||||
date: "2025-07-15",
|
||||
title: "🎉 공식 v1.0 출시",
|
||||
description: "sheetEasy AI의 첫 번째 정식 버전 출시",
|
||||
changes: [
|
||||
"✨ 새로운 기능: AI 기반 스프레드시트 편집",
|
||||
"🎨 사용자 인터페이스 대폭 개선",
|
||||
"🚀 성능 최적화 및 안정성 향상",
|
||||
"🌐 한국어/영어 다국어 지원",
|
||||
"📊 Excel, CSV 파일 완벽 지원",
|
||||
"🔒 브라우저 내 완전한 데이터 보안",
|
||||
"🎯 튜토리얼 시스템 추가",
|
||||
],
|
||||
isLatest: true,
|
||||
type: "major",
|
||||
},
|
||||
];
|
||||
|
||||
const getVersionBadge = (type: string, isLatest: boolean) => {
|
||||
if (isLatest) {
|
||||
return <Badge className="bg-green-500">최신</Badge>;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case "major":
|
||||
return <Badge variant="default">Major</Badge>;
|
||||
case "minor":
|
||||
return <Badge variant="secondary">Minor</Badge>;
|
||||
case "patch":
|
||||
return <Badge variant="outline">Patch</Badge>;
|
||||
default:
|
||||
return <Badge variant="outline">Update</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString("ko-KR", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"min-h-screen bg-gradient-to-br from-slate-50 to-blue-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="container py-12">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">
|
||||
업데이트
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600">
|
||||
sheetEasy AI의 최신 업데이트와 개선사항을 확인하세요
|
||||
</p>
|
||||
</div>
|
||||
{onBack && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
돌아가기
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Version History */}
|
||||
<div className="space-y-6">
|
||||
{updates.map((update, index) => (
|
||||
<Card
|
||||
key={update.version}
|
||||
className={cn(
|
||||
"transition-all hover:shadow-lg",
|
||||
update.isLatest &&
|
||||
"border-l-4 border-l-green-500 bg-gradient-to-r from-green-50 to-blue-50",
|
||||
)}
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<CardTitle className="text-2xl font-bold text-gray-900">
|
||||
v{update.version}
|
||||
</CardTitle>
|
||||
{getVersionBadge(update.type, update.isLatest)}
|
||||
</div>
|
||||
<div className="text-gray-500 text-sm">
|
||||
{formatDate(update.date)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<h2 className="text-xl font-semibold text-gray-800">
|
||||
{update.title}
|
||||
</h2>
|
||||
<p className="text-gray-600 mt-1">{update.description}</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-semibold text-gray-900 mb-3">
|
||||
📝 변경사항
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{update.changes.map((change, changeIndex) => (
|
||||
<li
|
||||
key={changeIndex}
|
||||
className="flex items-start gap-2 text-gray-700"
|
||||
>
|
||||
<span className="text-sm mt-1">•</span>
|
||||
<span>{change}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Coming Soon Section */}
|
||||
<Card className="mt-8 bg-gradient-to-r from-blue-50 to-purple-50 border-dashed border-2 border-blue-200">
|
||||
<CardContent className="p-8 text-center">
|
||||
<div className="mb-4">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-blue-100 text-blue-600 mb-4">
|
||||
<svg
|
||||
className="w-8 h-8"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-2">
|
||||
더 많은 업데이트가 곧 출시됩니다!
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
우리는 지속적으로 새로운 기능을 개발하고 있습니다. 로드맵을
|
||||
확인하여 예정된 기능들을 미리 살펴보세요.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<Button variant="outline" className="flex items-center gap-2">
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2H9a2 2 0 01-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
로드맵 보기
|
||||
</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700">
|
||||
알림 받기
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Newsletter Signup */}
|
||||
<Card className="mt-8">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center text-gray-900">
|
||||
📮 업데이트 소식 받기
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="max-w-md mx-auto">
|
||||
<p className="text-center text-gray-600 mb-4">
|
||||
새로운 업데이트와 기능 출시 소식을 이메일로 받아보세요
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="이메일 주소를 입력하세요"
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
<Button className="bg-green-600 hover:bg-green-700">
|
||||
구독하기
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 text-center mt-2">
|
||||
언제든지 구독을 취소할 수 있습니다. 개인정보는 안전하게
|
||||
보호됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
UpdatesPage.displayName = "UpdatesPage";
|
||||
|
||||
export default UpdatesPage;
|
||||
269
src/components/auth/SignInPage.tsx
Normal file
269
src/components/auth/SignInPage.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "../ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
interface SignInPageProps {
|
||||
className?: string;
|
||||
onSignIn?: (email: string, password: string) => void;
|
||||
onSignUpClick?: () => void;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 화면 컴포넌트
|
||||
* Vooster.ai 스타일의 세련된 디자인
|
||||
* 실제 API 연동은 나중에 구현
|
||||
*/
|
||||
const SignInPage = React.forwardRef<HTMLDivElement, SignInPageProps>(
|
||||
({ className, onSignIn, onSignUpClick, onBack, ...props }, ref) => {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errors, setErrors] = useState<{
|
||||
email?: string;
|
||||
password?: string;
|
||||
general?: string;
|
||||
}>({});
|
||||
|
||||
// 이메일 유효성 검사
|
||||
const validateEmail = (email: string) => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
};
|
||||
|
||||
// 폼 제출 핸들러
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// 유효성 검사
|
||||
const newErrors: typeof errors = {};
|
||||
|
||||
if (!email) {
|
||||
newErrors.email = "이메일을 입력해주세요.";
|
||||
} else if (!validateEmail(email)) {
|
||||
newErrors.email = "올바른 이메일 형식을 입력해주세요.";
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
newErrors.password = "비밀번호를 입력해주세요.";
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
|
||||
if (Object.keys(newErrors).length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// 로그인 처리 (실제 API 호출은 나중에)
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500)); // 시뮬레이션
|
||||
onSignIn?.(email, password);
|
||||
} catch (error) {
|
||||
console.error("로그인 실패:", error);
|
||||
setErrors({
|
||||
general: "로그인에 실패했습니다. 이메일과 비밀번호를 확인해주세요.",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 flex items-center justify-center p-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{/* Background decoration */}
|
||||
<div className="absolute inset-0 bg-grid-slate-100 [mask-image:linear-gradient(0deg,transparent,black)]" />
|
||||
|
||||
<div className="relative w-full max-w-md">
|
||||
{/* 로고 및 헤더 */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
sheetEasy AI
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
계정에 로그인하여 AI 편집을 계속하세요
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 로그인 폼 */}
|
||||
<Card className="shadow-xl border-0 bg-white/95 backdrop-blur-sm">
|
||||
<CardHeader className="text-center pb-6">
|
||||
<CardTitle className="text-2xl font-semibold text-gray-900">
|
||||
로그인
|
||||
</CardTitle>
|
||||
<p className="text-sm text-gray-600 mt-2">
|
||||
계정에 다시 오신 것을 환영합니다
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{/* 일반 오류 메시지 */}
|
||||
{errors.general && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-sm text-red-600">{errors.general}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* 이메일 입력 */}
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="text-sm font-medium text-gray-700"
|
||||
>
|
||||
이메일 주소
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className={cn(
|
||||
"w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors",
|
||||
errors.email ? "border-red-500" : "border-gray-300",
|
||||
)}
|
||||
placeholder="your@email.com"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-sm text-red-600">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 비밀번호 입력 */}
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="text-sm font-medium text-gray-700"
|
||||
>
|
||||
비밀번호
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className={cn(
|
||||
"w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors",
|
||||
errors.password ? "border-red-500" : "border-gray-300",
|
||||
)}
|
||||
placeholder="비밀번호를 입력해주세요"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="text-sm text-red-600">{errors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 비밀번호 찾기 링크 */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-blue-600 hover:text-blue-700 hover:underline"
|
||||
disabled={true} // 나중에 구현
|
||||
>
|
||||
비밀번호를 잊으셨나요? (준비중)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 로그인 버튼 */}
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-gradient-to-r from-green-500 to-blue-600 hover:from-green-600 hover:to-blue-700 text-white py-3 text-lg font-semibold shadow-lg hover:shadow-xl transition-all duration-300"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
|
||||
로그인 중...
|
||||
</div>
|
||||
) : (
|
||||
"로그인"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* 소셜 로그인 (나중에 구현) */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-white text-gray-500">또는</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full py-3 text-gray-700 border-gray-300 hover:bg-gray-50"
|
||||
disabled={true} // 나중에 구현
|
||||
>
|
||||
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
Google로 로그인 (준비중)
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 가입 링크 */}
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
아직 계정이 없으신가요?{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSignUpClick}
|
||||
className="text-blue-600 hover:text-blue-700 font-medium hover:underline"
|
||||
>
|
||||
무료로 가입하기
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 뒤로가기 버튼 */}
|
||||
<div className="text-center mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="text-gray-600 hover:text-gray-700 text-sm hover:underline"
|
||||
>
|
||||
← 메인화면으로 돌아가기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
SignInPage.displayName = "SignInPage";
|
||||
|
||||
export { SignInPage };
|
||||
291
src/components/auth/SignUpPage.tsx
Normal file
291
src/components/auth/SignUpPage.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "../ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
interface SignUpPageProps {
|
||||
className?: string;
|
||||
onSignUp?: (email: string, password: string) => void;
|
||||
onSignInClick?: () => void;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 가입화면 컴포넌트
|
||||
* Vooster.ai 스타일의 세련된 디자인
|
||||
* 실제 API 연동은 나중에 구현
|
||||
*/
|
||||
const SignUpPage = React.forwardRef<HTMLDivElement, SignUpPageProps>(
|
||||
({ className, onSignUp, onSignInClick, onBack, ...props }, ref) => {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errors, setErrors] = useState<{
|
||||
email?: string;
|
||||
password?: string;
|
||||
confirmPassword?: string;
|
||||
}>({});
|
||||
|
||||
// 이메일 유효성 검사
|
||||
const validateEmail = (email: string) => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
};
|
||||
|
||||
// 비밀번호 유효성 검사
|
||||
const validatePassword = (password: string) => {
|
||||
return password.length >= 8;
|
||||
};
|
||||
|
||||
// 폼 제출 핸들러
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// 유효성 검사
|
||||
const newErrors: typeof errors = {};
|
||||
|
||||
if (!email) {
|
||||
newErrors.email = "이메일을 입력해주세요.";
|
||||
} else if (!validateEmail(email)) {
|
||||
newErrors.email = "올바른 이메일 형식을 입력해주세요.";
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
newErrors.password = "비밀번호를 입력해주세요.";
|
||||
} else if (!validatePassword(password)) {
|
||||
newErrors.password = "비밀번호는 8자 이상이어야 합니다.";
|
||||
}
|
||||
|
||||
if (!confirmPassword) {
|
||||
newErrors.confirmPassword = "비밀번호 확인을 입력해주세요.";
|
||||
} else if (password !== confirmPassword) {
|
||||
newErrors.confirmPassword = "비밀번호가 일치하지 않습니다.";
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
|
||||
if (Object.keys(newErrors).length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// 가입 처리 (실제 API 호출은 나중에)
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500)); // 시뮬레이션
|
||||
onSignUp?.(email, password);
|
||||
} catch (error) {
|
||||
console.error("가입 실패:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 flex items-center justify-center p-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{/* Background decoration */}
|
||||
<div className="absolute inset-0 bg-grid-slate-100 [mask-image:linear-gradient(0deg,transparent,black)]" />
|
||||
|
||||
<div className="relative w-full max-w-md">
|
||||
{/* 로고 및 헤더 */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
sheetEasy AI
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
AI 기반 Excel 편집 도구에 오신 것을 환영합니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 가입 폼 */}
|
||||
<Card className="shadow-xl border-0 bg-white/95 backdrop-blur-sm">
|
||||
<CardHeader className="text-center pb-6">
|
||||
<CardTitle className="text-2xl font-semibold text-gray-900">
|
||||
계정 만들기
|
||||
</CardTitle>
|
||||
<p className="text-sm text-gray-600 mt-2">
|
||||
무료로 시작하고 AI의 힘을 경험해보세요
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* 이메일 입력 */}
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="text-sm font-medium text-gray-700"
|
||||
>
|
||||
이메일 주소
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className={cn(
|
||||
"w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors",
|
||||
errors.email ? "border-red-500" : "border-gray-300",
|
||||
)}
|
||||
placeholder="your@email.com"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-sm text-red-600">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 비밀번호 입력 */}
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="text-sm font-medium text-gray-700"
|
||||
>
|
||||
비밀번호
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className={cn(
|
||||
"w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors",
|
||||
errors.password ? "border-red-500" : "border-gray-300",
|
||||
)}
|
||||
placeholder="8자 이상 입력해주세요"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="text-sm text-red-600">{errors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 비밀번호 확인 */}
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="confirmPassword"
|
||||
className="text-sm font-medium text-gray-700"
|
||||
>
|
||||
비밀번호 확인
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className={cn(
|
||||
"w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors",
|
||||
errors.confirmPassword
|
||||
? "border-red-500"
|
||||
: "border-gray-300",
|
||||
)}
|
||||
placeholder="비밀번호를 다시 입력해주세요"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.confirmPassword && (
|
||||
<p className="text-sm text-red-600">
|
||||
{errors.confirmPassword}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 가입 버튼 */}
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-gradient-to-r from-green-500 to-blue-600 hover:from-green-600 hover:to-blue-700 text-white py-3 text-lg font-semibold shadow-lg hover:shadow-xl transition-all duration-300"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
|
||||
계정 생성 중...
|
||||
</div>
|
||||
) : (
|
||||
"무료로 시작하기"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* 소셜 로그인 (나중에 구현) */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-white text-gray-500">또는</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full py-3 text-gray-700 border-gray-300 hover:bg-gray-50"
|
||||
disabled={true} // 나중에 구현
|
||||
>
|
||||
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
Google로 계속하기 (준비중)
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 로그인 링크 */}
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
이미 계정이 있으신가요?{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSignInClick}
|
||||
className="text-blue-600 hover:text-blue-700 font-medium hover:underline"
|
||||
>
|
||||
로그인
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 뒤로가기 버튼 */}
|
||||
<div className="text-center mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="text-gray-600 hover:text-gray-700 text-sm hover:underline"
|
||||
>
|
||||
← 메인화면으로 돌아가기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
SignUpPage.displayName = "SignUpPage";
|
||||
|
||||
export { SignUpPage };
|
||||
1233
src/components/sheet/EditSheetViewer.tsx
Normal file
1233
src/components/sheet/EditSheetViewer.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,421 +0,0 @@
|
||||
import React, { useCallback, useState, useRef } from "react";
|
||||
import { Card, CardContent } from "../ui/card";
|
||||
import { Button } from "../ui/button";
|
||||
import { FileErrorModal } from "../ui/modal";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { useAppStore } from "../../stores/useAppStore";
|
||||
import {
|
||||
processExcelFile,
|
||||
getFileErrors,
|
||||
filterValidFiles,
|
||||
} from "../../utils/fileProcessor";
|
||||
|
||||
interface FileUploadProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 업로드 컴포넌트
|
||||
* - Drag & Drop 기능 지원
|
||||
* - .xls, .xlsx 파일 타입 제한
|
||||
* - 접근성 지원 (ARIA 라벨, 키보드 탐색)
|
||||
* - 반응형 레이아웃
|
||||
* - 실제 파일 처리 로직 연결
|
||||
*/
|
||||
export function FileUpload({ className }: FileUploadProps) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [showErrorModal, setShowErrorModal] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 스토어에서 상태 가져오기
|
||||
const {
|
||||
isLoading,
|
||||
error,
|
||||
fileUploadErrors,
|
||||
currentFile,
|
||||
setLoading,
|
||||
setError,
|
||||
uploadFile,
|
||||
clearFileUploadErrors,
|
||||
} = useAppStore();
|
||||
|
||||
/**
|
||||
* 파일 처리 로직
|
||||
*/
|
||||
const handleFileProcessing = useCallback(
|
||||
async (file: File) => {
|
||||
setLoading(true, "파일을 처리하는 중...");
|
||||
setError(null);
|
||||
clearFileUploadErrors();
|
||||
|
||||
try {
|
||||
const result = await processExcelFile(file);
|
||||
uploadFile(result);
|
||||
|
||||
if (result.success) {
|
||||
console.log("파일 업로드 성공:", result.fileName);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("파일 처리 중 예상치 못한 오류:", error);
|
||||
setError("파일 처리 중 예상치 못한 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[setLoading, setError, uploadFile, clearFileUploadErrors],
|
||||
);
|
||||
|
||||
/**
|
||||
* 파일 선택 처리
|
||||
*/
|
||||
const handleFileSelection = useCallback(
|
||||
async (files: FileList) => {
|
||||
if (files.length === 0) return;
|
||||
|
||||
// 유효하지 않은 파일들의 에러 수집
|
||||
const fileErrors = getFileErrors(files) || [];
|
||||
const validFiles = filterValidFiles(files) || [];
|
||||
|
||||
// 에러가 있는 파일들을 스토어에 저장
|
||||
fileErrors.forEach(({ file, error }) => {
|
||||
useAppStore.getState().addFileUploadError(file.name, error);
|
||||
});
|
||||
|
||||
// 에러가 있으면 모달 표시
|
||||
if (fileErrors.length > 0) {
|
||||
setShowErrorModal(true);
|
||||
}
|
||||
|
||||
if (validFiles.length === 0) {
|
||||
setError("업로드 가능한 파일이 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (validFiles.length > 1) {
|
||||
setError(
|
||||
"한 번에 하나의 파일만 업로드할 수 있습니다. 첫 번째 파일을 사용합니다.",
|
||||
);
|
||||
}
|
||||
|
||||
// 첫 번째 유효한 파일 처리
|
||||
await handleFileProcessing(validFiles[0]);
|
||||
},
|
||||
[handleFileProcessing, setError],
|
||||
);
|
||||
|
||||
/**
|
||||
* 드래그 앤 드롭 이벤트 핸들러
|
||||
*/
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
|
||||
setIsDragOver(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
|
||||
if (isLoading) return;
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files && files.length > 0) {
|
||||
await handleFileSelection(files);
|
||||
}
|
||||
},
|
||||
[handleFileSelection, isLoading],
|
||||
);
|
||||
|
||||
/**
|
||||
* 파일 선택 버튼 클릭 핸들러
|
||||
*/
|
||||
const handleFilePickerClick = useCallback(() => {
|
||||
if (isLoading || !fileInputRef.current) return;
|
||||
fileInputRef.current.click();
|
||||
}, [isLoading]);
|
||||
|
||||
/**
|
||||
* 파일 입력 변경 핸들러
|
||||
*/
|
||||
const handleFileInputChange = useCallback(
|
||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
await handleFileSelection(files);
|
||||
}
|
||||
// 입력 초기화 (같은 파일 재선택 가능하도록)
|
||||
e.target.value = "";
|
||||
},
|
||||
[handleFileSelection],
|
||||
);
|
||||
|
||||
/**
|
||||
* 키보드 이벤트 핸들러 (접근성)
|
||||
*/
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleFilePickerClick();
|
||||
}
|
||||
},
|
||||
[handleFilePickerClick],
|
||||
);
|
||||
|
||||
/**
|
||||
* 에러 모달 닫기 핸들러
|
||||
*/
|
||||
const handleCloseErrorModal = useCallback(() => {
|
||||
setShowErrorModal(false);
|
||||
clearFileUploadErrors();
|
||||
}, [clearFileUploadErrors]);
|
||||
|
||||
// 파일이 이미 업로드된 경우 성공 상태 표시
|
||||
if (currentFile && !error) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center min-h-[60vh]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Card className="w-full max-w-2xl">
|
||||
<CardContent className="p-8 md:p-12">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto h-20 w-20 md:h-24 md:w-24 rounded-full bg-green-100 flex items-center justify-center mb-4">
|
||||
<svg
|
||||
className="h-10 w-10 md:h-12 md:w-12 text-green-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl md:text-2xl font-semibold mb-2 text-green-800">
|
||||
파일 업로드 완료
|
||||
</h2>
|
||||
<p className="text-sm md:text-base text-gray-600 mb-4">
|
||||
<span className="font-medium text-gray-900">
|
||||
{currentFile.name}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mb-6">
|
||||
파일 크기: {(currentFile.size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleFilePickerClick}
|
||||
variant="outline"
|
||||
disabled={isLoading}
|
||||
>
|
||||
다른 파일 업로드
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-center min-h-[60vh]", className)}
|
||||
>
|
||||
<Card className="w-full max-w-2xl">
|
||||
<CardContent className="p-8 md:p-12">
|
||||
<div className="text-center">
|
||||
{/* 아이콘 및 제목 */}
|
||||
<div className="mb-8">
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto h-20 w-20 md:h-24 md:w-24 rounded-full flex items-center justify-center mb-4",
|
||||
error ? "bg-red-100" : "bg-blue-50",
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<svg
|
||||
className="h-10 w-10 md:h-12 md:w-12 text-blue-600 animate-spin"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
) : error ? (
|
||||
<svg
|
||||
className="h-10 w-10 md:h-12 md:w-12 text-red-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.864-.833-2.634 0L3.197 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="h-10 w-10 md:h-12 md:w-12 text-blue-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<h2
|
||||
className={cn(
|
||||
"text-xl md:text-2xl font-semibold mb-2 text-gray-900",
|
||||
error ? "text-red-800" : "",
|
||||
)}
|
||||
>
|
||||
{isLoading
|
||||
? "파일 처리 중..."
|
||||
: error
|
||||
? "업로드 오류"
|
||||
: "Excel 파일을 업로드하세요"}
|
||||
</h2>
|
||||
<p className="text-sm md:text-base text-gray-600 mb-6">
|
||||
{isLoading ? (
|
||||
<span className="text-blue-600">잠시만 기다려주세요...</span>
|
||||
) : error ? (
|
||||
<span className="text-red-600">{error}</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="font-medium text-gray-900">
|
||||
.xlsx, .xls
|
||||
</span>{" "}
|
||||
파일을 드래그 앤 드롭하거나 클릭하여 업로드
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 파일 업로드 에러 목록 */}
|
||||
{fileUploadErrors.length > 0 && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<h3 className="text-sm font-medium text-red-800 mb-2">
|
||||
파일 업로드 오류:
|
||||
</h3>
|
||||
<ul className="text-xs text-red-700 space-y-1">
|
||||
{fileUploadErrors.map((error, index) => (
|
||||
<li key={index}>
|
||||
<span className="font-medium">{error.fileName}</span>:{" "}
|
||||
{error.error}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 드래그 앤 드롭 영역 */}
|
||||
<div
|
||||
className={cn(
|
||||
"border-2 border-dashed rounded-lg p-8 md:p-12 transition-all duration-200 cursor-pointer",
|
||||
"hover:border-blue-400 hover:bg-blue-50",
|
||||
"focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2",
|
||||
isDragOver
|
||||
? "border-blue-500 bg-blue-100 scale-105"
|
||||
: "border-gray-300",
|
||||
isLoading && "opacity-50 cursor-not-allowed",
|
||||
)}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onClick={handleFilePickerClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={isLoading ? -1 : 0}
|
||||
role="button"
|
||||
aria-label="파일 업로드 영역"
|
||||
aria-describedby="upload-instructions"
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center space-y-4">
|
||||
<div className="text-4xl md:text-6xl">
|
||||
{isDragOver ? "📂" : "📄"}
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-base md:text-lg font-medium mb-2 text-gray-900">
|
||||
{isDragOver
|
||||
? "파일을 여기에 놓으세요"
|
||||
: "파일을 드래그하거나 클릭하세요"}
|
||||
</p>
|
||||
<p id="upload-instructions" className="text-sm text-gray-600">
|
||||
최대 50MB까지 업로드 가능
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 숨겨진 파일 입력 */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
onChange={handleFileInputChange}
|
||||
className="hidden"
|
||||
disabled={isLoading}
|
||||
aria-label="파일 선택"
|
||||
/>
|
||||
|
||||
{/* 지원 형식 안내 */}
|
||||
<div className="mt-6 text-xs text-gray-500">
|
||||
<p>지원 형식: Excel (.xlsx, .xls)</p>
|
||||
<p>최대 파일 크기: 50MB</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 파일 에러 모달 */}
|
||||
<FileErrorModal
|
||||
isOpen={showErrorModal}
|
||||
onClose={handleCloseErrorModal}
|
||||
errors={fileUploadErrors}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
260
src/components/sheet/PromptInput.tsx
Normal file
260
src/components/sheet/PromptInput.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useAppStore } from "../../stores/useAppStore";
|
||||
import { aiProcessor } from "../../utils/aiProcessor";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
interface PromptInputProps {
|
||||
value: string;
|
||||
onChange?: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
onExecute?: () => void;
|
||||
disabled?: boolean;
|
||||
maxLength?: number;
|
||||
onHistoryToggle?: () => void;
|
||||
historyCount?: number;
|
||||
tutorialPrompt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 에디트 화면 하단 고정 프롬프트 입력창 컴포넌트
|
||||
* - 이미지 참고: 입력창, Execute 버튼, 안내문구, 글자수 카운트, 하단 고정
|
||||
* - 유니버 시트에서 셀 선택 시 자동으로 셀 주소 삽입 기능 포함
|
||||
* - 선택된 셀 정보 실시간 표시 및 시각적 피드백 제공
|
||||
* - 현재 선택된 셀 정보 상태바 표시
|
||||
* - AI 프로세서 연동으로 전송하기 버튼 기능 구현
|
||||
*/
|
||||
const PromptInput: React.FC<PromptInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
onExecute,
|
||||
disabled: _disabled = true,
|
||||
maxLength = 500,
|
||||
onHistoryToggle,
|
||||
historyCount,
|
||||
tutorialPrompt,
|
||||
}) => {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [, setShowCellInsertFeedback] = useState(false);
|
||||
const [, setLastInsertedCell] = useState<string | null>(null);
|
||||
const [, setCurrentSelectedCell] = useState<string | null>(null);
|
||||
const [, setProcessingMessage] = useState<string>("");
|
||||
|
||||
const cellAddressToInsert = useAppStore((state) => state.cellAddressToInsert);
|
||||
const setCellAddressToInsert = useAppStore(
|
||||
(state) => state.setCellAddressToInsert,
|
||||
);
|
||||
const isProcessing = useAppStore((state) => state.isProcessing);
|
||||
|
||||
/**
|
||||
* 현재 선택된 셀 추적
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (cellAddressToInsert) {
|
||||
setCurrentSelectedCell(cellAddressToInsert);
|
||||
}
|
||||
}, [cellAddressToInsert]);
|
||||
|
||||
/**
|
||||
* 전송하기 버튼 클릭 핸들러 - 상위 컴포넌트 onExecute 사용 또는 기본 로직
|
||||
*/
|
||||
const handleExecute = async () => {
|
||||
if (!value.trim()) {
|
||||
alert("프롬프트를 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isProcessing || aiProcessor.isCurrentlyProcessing()) {
|
||||
alert("이미 처리 중입니다. 잠시 후 다시 시도해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 상위 컴포넌트에서 onExecute를 전달받은 경우 해당 함수 사용
|
||||
if (onExecute) {
|
||||
console.log("🚀 상위 컴포넌트 onExecute 함수 호출");
|
||||
await onExecute();
|
||||
return;
|
||||
}
|
||||
|
||||
// 폴백: 기본 AI 프로세서 직접 호출
|
||||
setProcessingMessage("AI가 요청을 처리하고 있습니다...");
|
||||
|
||||
try {
|
||||
console.log("🚀 전송하기 버튼 클릭 - 프롬프트:", value);
|
||||
|
||||
// AI 프로세서에 프롬프트 전송 (테스트 모드)
|
||||
const result = await aiProcessor.processPrompt(value, true);
|
||||
|
||||
console.log("🎉 AI 처리 결과:", result);
|
||||
|
||||
if (result.success) {
|
||||
setProcessingMessage(`✅ 완료: ${result.message}`);
|
||||
|
||||
// 성공 시 프롬프트 입력창 초기화 (선택사항)
|
||||
if (onChange && textareaRef.current) {
|
||||
textareaRef.current.value = "";
|
||||
const syntheticEvent = {
|
||||
target: textareaRef.current,
|
||||
currentTarget: textareaRef.current,
|
||||
} as React.ChangeEvent<HTMLTextAreaElement>;
|
||||
onChange(syntheticEvent);
|
||||
}
|
||||
|
||||
// 3초 후 메시지 숨김
|
||||
setTimeout(() => {
|
||||
setProcessingMessage("");
|
||||
}, 3000);
|
||||
} else {
|
||||
setProcessingMessage(`❌ 실패: ${result.message}`);
|
||||
|
||||
// 에러 메시지는 5초 후 숨김
|
||||
setTimeout(() => {
|
||||
setProcessingMessage("");
|
||||
}, 5000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ AI 프로세싱 오류:", error);
|
||||
setProcessingMessage("❌ 처리 중 오류가 발생했습니다.");
|
||||
|
||||
setTimeout(() => {
|
||||
setProcessingMessage("");
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 셀 주소 삽입 효과
|
||||
* cellAddressToInsert가 변경되면 textarea의 현재 커서 위치에 해당 주소를 삽입
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (cellAddressToInsert && textareaRef.current && onChange) {
|
||||
console.log(`🎯 PromptInput: 셀 주소 "${cellAddressToInsert}" 삽입 시작`);
|
||||
|
||||
const textarea = textareaRef.current;
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const currentValue = textarea.value;
|
||||
|
||||
console.log(
|
||||
`📍 PromptInput: 현재 커서 위치 ${start}-${end}, 현재 값: "${currentValue}"`,
|
||||
);
|
||||
|
||||
// 현재 커서 위치에 셀 주소 삽입
|
||||
const newValue =
|
||||
currentValue.slice(0, start) +
|
||||
cellAddressToInsert +
|
||||
currentValue.slice(end);
|
||||
|
||||
console.log(`✏️ PromptInput: 새 값: "${newValue}"`);
|
||||
|
||||
// textarea 값 업데이트
|
||||
textarea.value = newValue;
|
||||
|
||||
// 커서 위치를 삽입된 텍스트 뒤로 이동
|
||||
const newCursorPosition = start + cellAddressToInsert.length;
|
||||
textarea.selectionStart = textarea.selectionEnd = newCursorPosition;
|
||||
|
||||
// 상위 컴포넌트의 onChange 콜백 호출 (상태 동기화)
|
||||
const syntheticEvent = {
|
||||
target: textarea,
|
||||
currentTarget: textarea,
|
||||
} as React.ChangeEvent<HTMLTextAreaElement>;
|
||||
onChange(syntheticEvent);
|
||||
|
||||
// 포커스를 textarea로 이동
|
||||
textarea.focus();
|
||||
|
||||
// 시각적 피드백 표시
|
||||
setLastInsertedCell(cellAddressToInsert);
|
||||
setShowCellInsertFeedback(true);
|
||||
|
||||
// 2초 후 피드백 숨김
|
||||
setTimeout(() => {
|
||||
setShowCellInsertFeedback(false);
|
||||
}, 2000);
|
||||
|
||||
console.log(`✅ PromptInput: 셀 주소 "${cellAddressToInsert}" 삽입 완료`);
|
||||
|
||||
// 셀 주소 삽입 상태 초기화 (중복 삽입 방지)
|
||||
setCellAddressToInsert(null);
|
||||
}
|
||||
}, [cellAddressToInsert, onChange, setCellAddressToInsert]);
|
||||
|
||||
return (
|
||||
<div className="w-full z-10 flex flex-col items-center">
|
||||
<div className="w-full flex items-end gap-3">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="flex-1 resize-none rounded-xl border border-gray-300 bg-white px-4 py-3 text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-400 disabled:bg-gray-100 disabled:cursor-not-allowed min-h-[44px] max-h-28 shadow-sm"
|
||||
placeholder={
|
||||
tutorialPrompt
|
||||
? `실행된 프롬프트: ${tutorialPrompt}`
|
||||
: "AI에게 명령하세요...\n예: A1부터 A10까지 합계를 B1에 입력해줘"
|
||||
}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={isProcessing}
|
||||
maxLength={maxLength}
|
||||
rows={3}
|
||||
/>
|
||||
<div style={{ width: "1rem" }} />
|
||||
|
||||
{/* 버튼들을 세로로 배치 - 오버레이에 맞게 컴팩트 */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* 히스토리 버튼 */}
|
||||
{onHistoryToggle && (
|
||||
<button
|
||||
className="px-2 py-1.5 text-xs font-medium text-gray-700 bg-white border border-gray-300 rounded-lg shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors disabled:opacity-60 disabled:cursor-not-allowed flex items-center justify-center gap-1"
|
||||
onClick={onHistoryToggle}
|
||||
disabled={isProcessing}
|
||||
aria-label="작업 히스토리 보기"
|
||||
>
|
||||
📝
|
||||
{historyCount !== undefined && historyCount > 0 && (
|
||||
<span className="text-xs bg-blue-500 text-white rounded-full px-1.5 py-0.5 min-w-[16px] h-4 flex items-center justify-center text-[10px]">
|
||||
{historyCount > 99 ? "99+" : historyCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* UNDO 버튼 */}
|
||||
<button
|
||||
className="px-2 py-1.5 text-xs font-medium text-gray-700 bg-white border border-gray-300 rounded-lg shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors disabled:opacity-60 disabled:cursor-not-allowed flex items-center justify-center"
|
||||
onClick={() => {
|
||||
// TODO: UNDO 기능 구현
|
||||
console.log("🔄 UNDO 버튼 클릭");
|
||||
}}
|
||||
disabled={isProcessing}
|
||||
aria-label="실행 취소"
|
||||
>
|
||||
↶
|
||||
</button>
|
||||
|
||||
{/* 전송하기 버튼 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`text-xs px-3 py-1.5 ${
|
||||
isProcessing || !value.trim()
|
||||
? "bg-gray-400 text-white cursor-not-allowed border-gray-400"
|
||||
: "bg-green-500 hover:bg-green-600 text-white border-green-500"
|
||||
}`}
|
||||
onClick={handleExecute}
|
||||
disabled={isProcessing || !value.trim()}
|
||||
>
|
||||
{isProcessing ? "처리중" : "전송"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex justify-between items-center mt-2 px-1">
|
||||
<span className="text-xs text-gray-500">
|
||||
시트에서 셀 클릭시 자동 입력 | Enter로 전송
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{value.length}/{maxLength}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PromptInput;
|
||||
@@ -1,431 +0,0 @@
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "@testing-library/jest-dom";
|
||||
import { vi } from "vitest";
|
||||
import { FileUpload } from "../FileUpload";
|
||||
import { useAppStore } from "../../../stores/useAppStore";
|
||||
import * as fileProcessor from "../../../utils/fileProcessor";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("../../../stores/useAppStore");
|
||||
|
||||
// Mock DragEvent for testing environment
|
||||
class MockDragEvent extends Event {
|
||||
dataTransfer: DataTransfer;
|
||||
|
||||
constructor(
|
||||
type: string,
|
||||
options: { bubbles?: boolean; dataTransfer?: any } = {},
|
||||
) {
|
||||
super(type, { bubbles: options.bubbles });
|
||||
this.dataTransfer = options.dataTransfer || {
|
||||
items: options.dataTransfer?.items || [],
|
||||
files: options.dataTransfer?.files || [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
global.DragEvent = MockDragEvent;
|
||||
|
||||
const mockUseAppStore = useAppStore as 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
141
src/components/ui/alert-dialog.tsx
Normal file
141
src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
import { buttonVariants } from "./button";
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root;
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal;
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
));
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
AlertDialogHeader.displayName = "AlertDialogHeader";
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
AlertDialogFooter.displayName = "AlertDialogFooter";
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogDescription.displayName =
|
||||
AlertDialogPrimitive.Description.displayName;
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"mt-2 sm:mt-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
};
|
||||
36
src/components/ui/badge.tsx
Normal file
36
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
122
src/components/ui/dialog.tsx
Normal file
122
src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
122
src/components/ui/faq-section.tsx
Normal file
122
src/components/ui/faq-section.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import * as React from "react";
|
||||
import { Card, CardContent } from "./card";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
interface FAQSectionProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const FAQSection = React.forwardRef<HTMLElement, FAQSectionProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
const [openIndex, setOpenIndex] = React.useState<number | null>(null);
|
||||
|
||||
const faqs = [
|
||||
{
|
||||
question: "sheetEasy AI는 어떤 서비스인가요?",
|
||||
answer:
|
||||
"AI 기반 Excel 편집 도구로, 자연어 명령으로 수식, 차트, 데이터 분석을 자동 생성합니다. 모든 처리는 브라우저에서 안전하게 이루어집니다.",
|
||||
},
|
||||
{
|
||||
question: "파일이 서버로 전송되나요?",
|
||||
answer:
|
||||
"아니요! 모든 파일 처리는 브라우저에서 로컬로 이루어집니다. 귀하의 데이터는 외부로 전송되지 않아 완전한 프라이버시를 보장합니다.",
|
||||
},
|
||||
{
|
||||
question: "어떤 파일 형식을 지원하나요?",
|
||||
answer:
|
||||
"Excel(.xlsx, .xls)과 CSV 파일을 지원합니다. 업로드된 파일은 자동으로 최적화되어 처리됩니다.",
|
||||
},
|
||||
{
|
||||
question: "AI 기능은 어떻게 작동하나요?",
|
||||
answer:
|
||||
"GPT-4, Claude 등 최신 AI 모델을 활용하여 자연어 명령을 Excel 수식과 차트로 변환합니다. '매출 상위 10개 항목 강조해줘' 같은 명령으로 쉽게 사용할 수 있습니다.",
|
||||
},
|
||||
{
|
||||
question: "무료 플랜의 제한사항은 무엇인가요?",
|
||||
answer:
|
||||
"무료 플랜은 월 30회 AI 쿼리, 300개 셀 편집, 10MB 파일 크기 제한이 있습니다. 다운로드와 복사는 제한됩니다.",
|
||||
},
|
||||
{
|
||||
question: "데이터는 안전하게 보호되나요?",
|
||||
answer:
|
||||
"네! 모든 데이터는 브라우저에서만 처리되며, 서버 전송 없이 완전한 로컬 처리로 최고 수준의 보안을 제공합니다.",
|
||||
},
|
||||
];
|
||||
|
||||
const toggleFAQ = (index: number) => {
|
||||
setOpenIndex(openIndex === index ? null : index);
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={ref}
|
||||
id="faq"
|
||||
className={cn("py-20 sm:py-32 bg-gray-50", className)}
|
||||
{...props}
|
||||
>
|
||||
<div className="container">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
{/* Section Header */}
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl mb-4">
|
||||
자주 묻는 질문
|
||||
</h2>
|
||||
<p className="text-lg text-gray-600">
|
||||
sheetEasy AI에 대해 궁금한 점을 해결해 드립니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* FAQ Items */}
|
||||
<div className="space-y-4">
|
||||
{faqs.map((faq, index) => (
|
||||
<Card key={index} className="border border-gray-200 shadow-sm">
|
||||
<CardContent className="p-0">
|
||||
<button
|
||||
onClick={() => toggleFAQ(index)}
|
||||
className="w-full text-left p-6 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-inset"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
{faq.question}
|
||||
</h3>
|
||||
<div className="ml-6 flex-shrink-0">
|
||||
<svg
|
||||
className={cn(
|
||||
"h-5 w-5 text-gray-500 transition-transform duration-200",
|
||||
openIndex === index ? "rotate-180" : "",
|
||||
)}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{openIndex === index && (
|
||||
<div className="px-6 pb-6">
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
{faq.answer}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
FAQSection.displayName = "FAQSection";
|
||||
|
||||
export { FAQSection };
|
||||
153
src/components/ui/features-section.tsx
Normal file
153
src/components/ui/features-section.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import * as React from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./card";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
interface FeaturesSectionProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const FeaturesSection = React.forwardRef<HTMLElement, FeaturesSectionProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
const features = [
|
||||
{
|
||||
icon: "📊",
|
||||
title: "클라이언트-사이드 파일 처리",
|
||||
description:
|
||||
"XLS, XLSX 파일을 브라우저에서 직접 처리. 서버 전송 없이 완전한 프라이버시 보장.",
|
||||
highlights: [
|
||||
"50MB 대용량 파일 지원",
|
||||
"한글 파일명/시트명 완벽 지원",
|
||||
"변환 실패시 대안 경로 제공",
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: "🤖",
|
||||
title: "자연어 스프레드시트 조작",
|
||||
description: "복잡한 Excel 기능을 자연어로 간단하게 조작하세요.",
|
||||
highlights: ["빈 셀 자동 채우기", "조건부 서식 적용", "수식 자동 생성"],
|
||||
},
|
||||
{
|
||||
icon: "📈",
|
||||
title: "AI 기반 데이터 분석",
|
||||
description:
|
||||
"패턴 분석, 이상치 탐지, 통계 요약을 AI가 자동으로 수행합니다.",
|
||||
highlights: [
|
||||
"매출 상위 5% 자동 강조",
|
||||
"패턴 및 이상치 탐지",
|
||||
"통계 요약 리포트",
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: "📊",
|
||||
title: "자동 시각화",
|
||||
description:
|
||||
"데이터에 가장 적합한 차트를 AI가 추천하고 자동으로 생성합니다.",
|
||||
highlights: [
|
||||
"적합 차트 자동 추천",
|
||||
"실시간 차트 렌더링",
|
||||
"다양한 차트 타입 지원",
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: "⚡",
|
||||
title: "사용자 친화 인터페이스",
|
||||
description:
|
||||
"직관적이고 반응성이 뛰어난 인터페이스로 누구나 쉽게 사용할 수 있습니다.",
|
||||
highlights: [
|
||||
"실시간 입력 유효성 검사",
|
||||
"작업 히스토리 되돌리기",
|
||||
"한국어/영어 다국어 지원",
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: "🛡️",
|
||||
title: "보안 & 성능",
|
||||
description: "최고 수준의 보안과 최적화된 성능을 제공합니다.",
|
||||
highlights: [
|
||||
"브라우저 메모리 로드",
|
||||
"Web Worker 청크 처리",
|
||||
"60FPS 가상 렌더링",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={ref}
|
||||
id="features"
|
||||
className={cn("py-20 sm:py-32 bg-white", className)}
|
||||
{...props}
|
||||
>
|
||||
<div className="container">
|
||||
<div className="mx-auto max-w-2xl text-center mb-16">
|
||||
<h2 className="text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl mb-4">
|
||||
강력한 기능들
|
||||
</h2>
|
||||
<p className="text-lg leading-8 text-gray-600">
|
||||
AI 기술과 최신 웹 기술을 결합하여 Excel 작업을 혁신적으로
|
||||
바꿔보세요
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{features.map((feature, index) => (
|
||||
<Card
|
||||
key={index}
|
||||
className="group border-0 shadow-lg hover:shadow-2xl transition-all duration-500 hover:-translate-y-2 bg-gradient-to-br from-white to-gray-50 hover:from-blue-50 hover:to-purple-50 cursor-pointer overflow-hidden relative"
|
||||
>
|
||||
{/* Hover 효과를 위한 배경 그라데이션 */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-blue-500/5 to-purple-500/5 opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
|
||||
<CardHeader className="pb-4 relative z-10">
|
||||
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-r from-blue-500 to-purple-600 group-hover:scale-110 group-hover:from-blue-600 group-hover:to-purple-700 transition-all duration-300 shadow-md group-hover:shadow-lg">
|
||||
<span className="text-2xl group-hover:scale-110 transition-transform duration-300">
|
||||
{feature.icon}
|
||||
</span>
|
||||
</div>
|
||||
<CardTitle className="text-xl font-semibold text-gray-900 group-hover:text-blue-700 transition-colors duration-300">
|
||||
{feature.title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 relative z-10">
|
||||
<p className="text-gray-600 group-hover:text-gray-700 mb-4 leading-relaxed transition-colors duration-300">
|
||||
{feature.description}
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{feature.highlights.map((highlight, highlightIndex) => (
|
||||
<li
|
||||
key={highlightIndex}
|
||||
className="flex items-start text-sm text-gray-700 group-hover:text-gray-800 transition-colors duration-300"
|
||||
style={{
|
||||
animationDelay: `${highlightIndex * 100}ms`,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
className="mr-2 mt-0.5 h-4 w-4 text-green-500 group-hover:text-green-600 flex-shrink-0 transition-colors duration-300 group-hover:scale-110"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="group-hover:translate-x-1 transition-transform duration-300">
|
||||
{highlight}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
FeaturesSection.displayName = "FeaturesSection";
|
||||
|
||||
export { FeaturesSection };
|
||||
203
src/components/ui/footer.tsx
Normal file
203
src/components/ui/footer.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { useAppStore } from "../../stores/useAppStore";
|
||||
|
||||
interface FooterProps {
|
||||
className?: string;
|
||||
onLicenseClick?: () => void;
|
||||
onRoadmapClick?: () => void;
|
||||
onUpdatesClick?: () => void;
|
||||
onSupportClick?: () => void;
|
||||
onContactClick?: () => void;
|
||||
onPrivacyPolicyClick?: () => void;
|
||||
onTermsOfServiceClick?: () => void;
|
||||
}
|
||||
|
||||
const Footer = React.forwardRef<HTMLElement, FooterProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
onLicenseClick,
|
||||
onRoadmapClick,
|
||||
onUpdatesClick,
|
||||
onSupportClick,
|
||||
onContactClick,
|
||||
onPrivacyPolicyClick,
|
||||
onTermsOfServiceClick,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { isAuthenticated } = useAppStore();
|
||||
|
||||
return (
|
||||
<footer
|
||||
ref={ref}
|
||||
className={cn("bg-gray-900 text-white", className)}
|
||||
{...props}
|
||||
>
|
||||
<div className="container py-12">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
{/* Logo and description */}
|
||||
<div className="md:col-span-1">
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-r from-green-500 to-blue-600">
|
||||
<span className="text-sm font-bold text-white">📊</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold bg-gradient-to-r from-green-400 to-blue-400 bg-clip-text text-transparent">
|
||||
sheetEasy AI
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400 leading-relaxed">
|
||||
AI 기반 Excel 편집 도구로 생산성을 혁신하세요. 모든 처리는
|
||||
브라우저에서 안전하게.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Product */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-200 mb-4">제품</h3>
|
||||
<ul className="space-y-3 text-sm text-gray-400">
|
||||
<li>
|
||||
<button
|
||||
onClick={onRoadmapClick}
|
||||
className="hover:text-white transition-colors text-left"
|
||||
>
|
||||
로드맵
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
onClick={onUpdatesClick}
|
||||
className="hover:text-white transition-colors text-left"
|
||||
>
|
||||
업데이트
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Support */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-200 mb-4">지원</h3>
|
||||
<ul className="space-y-3 text-sm text-gray-400">
|
||||
<li>
|
||||
<button
|
||||
onClick={onSupportClick}
|
||||
disabled={!isAuthenticated}
|
||||
className={cn(
|
||||
"hover:text-white transition-colors text-left",
|
||||
!isAuthenticated && "opacity-50 cursor-not-allowed",
|
||||
)}
|
||||
title={!isAuthenticated ? "로그인이 필요합니다" : undefined}
|
||||
>
|
||||
🔒 지원 (로그인 필요)
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
onClick={onContactClick}
|
||||
className="hover:text-white transition-colors text-left"
|
||||
>
|
||||
문의하기
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Legal */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-200 mb-4">
|
||||
법적 고지
|
||||
</h3>
|
||||
<ul className="space-y-3 text-sm text-gray-400">
|
||||
<li>
|
||||
<button
|
||||
onClick={onPrivacyPolicyClick}
|
||||
className="hover:text-white transition-colors text-left"
|
||||
>
|
||||
개인정보처리방침
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
onClick={onTermsOfServiceClick}
|
||||
className="hover:text-white transition-colors text-left"
|
||||
>
|
||||
이용약관
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
onClick={onLicenseClick}
|
||||
className="hover:text-white transition-colors text-left"
|
||||
>
|
||||
📄 오픈소스 라이브러리 & 라이센스
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom section */}
|
||||
<div className="mt-12 pt-8 border-t border-gray-800 flex flex-col sm:flex-row justify-between items-center">
|
||||
<div className="text-sm text-gray-400">
|
||||
© 2025 sheetEasy AI. All rights reserved.
|
||||
</div>
|
||||
|
||||
{/* Social links */}
|
||||
<div className="flex items-center space-x-4 mt-4 sm:mt-0">
|
||||
<a
|
||||
href="#twitter"
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
aria-label="Twitter"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M6.29 18.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0020 3.92a8.19 8.19 0 01-2.357.646 4.118 4.118 0 001.804-2.27 8.224 8.224 0 01-2.605.996 4.107 4.107 0 00-6.993 3.743 11.65 11.65 0 01-8.457-4.287 4.106 4.106 0 001.27 5.477A4.073 4.073 0 01.8 7.713v.052a4.105 4.105 0 003.292 4.022 4.095 4.095 0 01-1.853.07 4.108 4.108 0 003.834 2.85A8.233 8.233 0 010 16.407a11.616 11.616 0 006.29 1.84" />
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="#github"
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
aria-label="GitHub"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="#discord"
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
aria-label="Discord"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M16.942 5.556a16.3 16.3 0 0 0-4.126-1.3 12.04 12.04 0 0 0-.529 1.1 15.175 15.175 0 0 0-4.573 0 11.585 11.585 0 0 0-.535-1.1 16.274 16.274 0 0 0-4.129 1.3A17.392 17.392 0 0 0 .182 13.218a15.785 15.785 0 0 0 4.963 2.521c.41-.564.773-1.16 1.084-1.785a10.63 10.63 0 0 1-1.706-.83c.143-.106.283-.217.418-.33a11.664 11.664 0 0 0 10.118 0c.137.113.277.224.418.33-.544.328-1.116.606-1.71.832a12.52 12.52 0 0 0 1.084 1.785 16.46 16.46 0 0 0 5.064-2.595 17.286 17.286 0 0 0-2.973-7.99ZM6.678 10.813a1.941 1.941 0 0 1-1.8-2.045 1.93 1.93 0 0 1 1.8-2.047 1.919 1.919 0 0 1 1.8 2.047 1.93 1.93 0 0 1-1.8 2.045Zm6.644 0a1.94 1.94 0 0 1-1.8-2.045 1.93 1.93 0 0 1 1.8-2.047 1.918 1.918 0 0 1 1.8 2.047 1.93 1.93 0 0 1-1.8 2.045Z" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Footer.displayName = "Footer";
|
||||
|
||||
export { Footer };
|
||||
133
src/components/ui/hero-section.tsx
Normal file
133
src/components/ui/hero-section.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import * as React from "react";
|
||||
import { Button } from "./button";
|
||||
import { Card, CardContent } from "./card";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
interface HeroSectionProps {
|
||||
className?: string;
|
||||
onGetStarted?: () => void;
|
||||
onDemoClick?: () => void;
|
||||
}
|
||||
|
||||
const HeroSection = React.forwardRef<HTMLElement, HeroSectionProps>(
|
||||
({ className, onGetStarted, onDemoClick, ...props }, ref) => {
|
||||
return (
|
||||
<section
|
||||
ref={ref}
|
||||
id="home"
|
||||
className={cn(
|
||||
"relative overflow-hidden bg-gradient-to-br from-slate-50 to-blue-50 py-20 sm:py-32",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{/* Background decoration */}
|
||||
<div className="absolute inset-0 bg-grid-slate-100 [mask-image:linear-gradient(0deg,transparent,black)]" />
|
||||
|
||||
<div className="container relative">
|
||||
<div className="mx-auto max-w-4xl text-center">
|
||||
{/* Badge */}
|
||||
<div className="mb-8 inline-flex items-center rounded-full bg-blue-50 px-6 py-2 text-sm font-medium text-blue-700 ring-1 ring-inset ring-blue-700/10">
|
||||
🎉 AI 기반 Excel 편집 도구 출시!
|
||||
</div>
|
||||
|
||||
{/* Main heading */}
|
||||
<h1 className="mb-6 text-4xl font-bold tracking-tight text-gray-900 sm:text-6xl">
|
||||
All in One{" "}
|
||||
<span className="bg-gradient-to-r from-green-600 to-blue-600 bg-clip-text text-transparent">
|
||||
Excel AI
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
{/* Subtitle */}
|
||||
<p className="mx-auto mb-10 max-w-2xl text-lg leading-8 text-gray-600">
|
||||
자연어로 Excel을 편집하세요. AI가 수식, 차트, 분석을 자동으로
|
||||
생성합니다.
|
||||
<br />
|
||||
<strong className="text-gray-900">
|
||||
모든 처리는 브라우저에서 안전하게!
|
||||
</strong>
|
||||
</p>
|
||||
|
||||
{/* CTA buttons */}
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-12">
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={onGetStarted}
|
||||
className="bg-gradient-to-r from-green-500 to-blue-600 hover:from-green-600 hover:to-blue-700 text-white px-8 py-3 text-lg font-semibold shadow-lg hover:shadow-xl transition-all duration-300"
|
||||
aria-label="sheetEasy AI 시작하기"
|
||||
>
|
||||
시작하기 →
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={onDemoClick}
|
||||
className="px-8 py-3 text-lg font-semibold"
|
||||
aria-label="데모보기"
|
||||
>
|
||||
데모보기
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Features preview */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 max-w-5xl mx-auto">
|
||||
<Card className="border-0 shadow-md bg-white/50 backdrop-blur-sm hover:shadow-lg transition-shadow duration-300">
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-green-100">
|
||||
<span className="text-2xl">🔒</span>
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-semibold text-gray-900">
|
||||
완전한 프라이버시
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
파일이 서버로 전송되지 않습니다.
|
||||
<br />
|
||||
모든 처리는 브라우저에서 안전하게!
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 shadow-md bg-white/50 backdrop-blur-sm hover:shadow-lg transition-shadow duration-300">
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-blue-100">
|
||||
<span className="text-2xl">🤖</span>
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-semibold text-gray-900">
|
||||
AI 자동 처리
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
자연어로 명령하면 AI가
|
||||
<br />
|
||||
수식과 차트를 자동 생성합니다.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 shadow-md bg-white/50 backdrop-blur-sm hover:shadow-lg transition-shadow duration-300">
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-purple-100">
|
||||
<span className="text-2xl">⚡</span>
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-semibold text-gray-900">
|
||||
3-스텝 간편함
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
업로드 → 프롬프트 → 다운로드
|
||||
<br />
|
||||
복잡한 설정 없이 바로 사용!
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
HeroSection.displayName = "HeroSection";
|
||||
|
||||
export { HeroSection };
|
||||
298
src/components/ui/historyPanel.tsx
Normal file
298
src/components/ui/historyPanel.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
import React from "react";
|
||||
import { Card, CardContent, CardHeader } from "./card";
|
||||
import { Button } from "./button";
|
||||
import type { HistoryPanelProps, HistoryEntry } from "../../types/ai";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { useAppStore } from "../../stores/useAppStore";
|
||||
import { useTranslation } from "../../lib/i18n";
|
||||
|
||||
/**
|
||||
* 히스토리 패널 컴포넌트
|
||||
* 우측에서 슬라이드 인하는 방식으로 작동
|
||||
*/
|
||||
const HistoryPanel: React.FC<HistoryPanelProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
history,
|
||||
onReapply,
|
||||
onClear,
|
||||
}) => {
|
||||
const { historyPanelPosition } = useAppStore();
|
||||
const { t } = useTranslation();
|
||||
// 키보드 접근성: Escape 키로 패널 닫기
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape" && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
// 포커스 트랩을 위해 패널에 포커스 설정
|
||||
const panel = document.getElementById("history-panel");
|
||||
if (panel) {
|
||||
panel.focus();
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// 시간 포맷팅 함수
|
||||
const formatTime = (date: Date): string => {
|
||||
return new Intl.DateTimeFormat("ko-KR", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
// 날짜 포맷팅 함수
|
||||
const formatDate = (date: Date): string => {
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
if (date.toDateString() === today.toDateString()) {
|
||||
return "오늘";
|
||||
} else if (date.toDateString() === yesterday.toDateString()) {
|
||||
return "어제";
|
||||
} else {
|
||||
return new Intl.DateTimeFormat("ko-KR", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(date);
|
||||
}
|
||||
};
|
||||
|
||||
// 상태별 아이콘 및 색상
|
||||
const getStatusIcon = (status: HistoryEntry["status"]) => {
|
||||
switch (status) {
|
||||
case "success":
|
||||
return <span className="text-green-500">✅</span>;
|
||||
case "error":
|
||||
return <span className="text-red-500">❌</span>;
|
||||
case "pending":
|
||||
return <span className="text-yellow-500 animate-pulse">⏳</span>;
|
||||
default:
|
||||
return <span className="text-gray-400">❓</span>;
|
||||
}
|
||||
};
|
||||
|
||||
// 액션 요약 생성
|
||||
const getActionSummary = (actions: HistoryEntry["actions"]): string => {
|
||||
if (actions.length === 0) return "액션 없음";
|
||||
|
||||
const actionTypes = actions.map((action) => {
|
||||
switch (action.type) {
|
||||
case "formula":
|
||||
return "수식";
|
||||
case "style":
|
||||
return "스타일";
|
||||
case "chart":
|
||||
return "차트";
|
||||
default:
|
||||
return "기타";
|
||||
}
|
||||
});
|
||||
|
||||
return `${actionTypes.join(", ")} (${actions.length}개)`;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 백드롭 오버레이 */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-60 z-40 transition-opacity duration-300"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 히스토리 패널 */}
|
||||
<div
|
||||
id="history-panel"
|
||||
className={cn(
|
||||
"bg-white shadow-2xl z-50",
|
||||
"transform transition-transform duration-300 ease-in-out",
|
||||
"flex flex-col",
|
||||
historyPanelPosition === "right"
|
||||
? "border-l border-gray-200"
|
||||
: "border-r border-gray-200",
|
||||
isOpen
|
||||
? "translate-x-0"
|
||||
: historyPanelPosition === "right"
|
||||
? "translate-x-full"
|
||||
: "-translate-x-full",
|
||||
)}
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 64, // App.tsx 헤더와 일치 (h-16 = 64px)
|
||||
[historyPanelPosition]: 0,
|
||||
height: "calc(100vh - 64px)", // 헤더 높이만큼 조정
|
||||
width: "384px", // w-96 = 384px
|
||||
backgroundColor: "#ffffff",
|
||||
zIndex: 50,
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="history-panel-title"
|
||||
aria-describedby="history-panel-description"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex-shrink-0 border-b border-gray-200 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2
|
||||
id="history-panel-title"
|
||||
className="text-lg font-semibold text-gray-900"
|
||||
>
|
||||
📝 {t.history.title}
|
||||
</h2>
|
||||
<p
|
||||
id="history-panel-description"
|
||||
className="text-sm text-gray-500 mt-1"
|
||||
>
|
||||
{t.history.subtitle} ({history.length}개)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 닫기 버튼 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
aria-label="히스토리 패널 닫기"
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 전체 삭제 버튼 */}
|
||||
{history.length > 0 && onClear && (
|
||||
<div className="mt-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onClear}
|
||||
className="w-full text-red-600 border-red-200 hover:bg-red-50"
|
||||
aria-label="모든 히스토리 삭제"
|
||||
>
|
||||
🗑️ 전체 삭제
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 히스토리 목록 */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{history.length === 0 ? (
|
||||
// 빈 상태
|
||||
<div className="flex flex-col items-center justify-center h-full text-gray-500">
|
||||
<div className="text-4xl mb-4">📋</div>
|
||||
<p className="text-center px-4">
|
||||
아직 실행된 AI 프롬프트가 없습니다.
|
||||
<br />
|
||||
<span className="text-sm text-gray-400">
|
||||
프롬프트를 입력하고 실행해보세요!
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
// 히스토리 항목들
|
||||
<div className="p-4 space-y-3">
|
||||
{history.map((entry, index) => (
|
||||
<Card
|
||||
key={entry.id}
|
||||
className={cn(
|
||||
"transition-all duration-200 hover:shadow-md",
|
||||
entry.status === "error" && "border-red-200 bg-red-50",
|
||||
entry.status === "success" &&
|
||||
"border-green-200 bg-green-50",
|
||||
entry.status === "pending" &&
|
||||
"border-yellow-200 bg-yellow-50",
|
||||
)}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
{getStatusIcon(entry.status)}
|
||||
<div className="text-xs text-gray-500">
|
||||
{formatDate(entry.timestamp)}{" "}
|
||||
{formatTime(entry.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
#{history.length - index}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pt-0">
|
||||
{/* 프롬프트 */}
|
||||
<div className="mb-3">
|
||||
<p className="text-sm font-medium text-gray-900 mb-1">
|
||||
프롬프트:
|
||||
</p>
|
||||
<p className="text-sm text-gray-700 bg-gray-100 rounded-md p-2 break-words">
|
||||
{entry.prompt}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 범위 및 시트 정보 */}
|
||||
<div className="mb-3 text-xs text-gray-600">
|
||||
<span className="font-medium">범위:</span> {entry.range} |
|
||||
<span className="font-medium ml-1">시트:</span>{" "}
|
||||
{entry.sheetName}
|
||||
</div>
|
||||
|
||||
{/* 액션 요약 */}
|
||||
<div className="mb-3">
|
||||
<p className="text-xs text-gray-600">
|
||||
<span className="font-medium">실행된 액션:</span>{" "}
|
||||
{getActionSummary(entry.actions)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{entry.status === "error" && entry.error && (
|
||||
<div className="mb-3 p-2 bg-red-100 border border-red-200 rounded-md">
|
||||
<p className="text-xs text-red-700">
|
||||
<span className="font-medium">오류:</span>{" "}
|
||||
{entry.error}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 재적용 버튼 */}
|
||||
{entry.status === "success" && onReapply && (
|
||||
<div className="mt-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onReapply(entry)}
|
||||
className="w-full text-blue-600 border-blue-200 hover:bg-blue-50"
|
||||
aria-label={`프롬프트 재적용: ${entry.prompt.slice(0, 20)}...`}
|
||||
>
|
||||
🔄 다시 적용
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default HistoryPanel;
|
||||
29
src/components/ui/homeButton.tsx
Normal file
29
src/components/ui/homeButton.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from "react";
|
||||
|
||||
interface HomeButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 텍스트만 있는 홈 버튼 컴포넌트
|
||||
* - 파란색, 볼드, hover 시 underline
|
||||
* - outline/배경 없음
|
||||
* - Button 컴포넌트 의존성 없음
|
||||
*/
|
||||
const HomeButton: React.FC<HomeButtonProps> = ({ children, ...props }) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
{...props}
|
||||
className={
|
||||
"text-2xl font-bold text-blue-600 bg-transparent border-none p-0 m-0 cursor-pointer hover:underline focus:outline-none focus:ring-0 " +
|
||||
(props.className || "")
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomeButton;
|
||||
207
src/components/ui/pricing-section.tsx
Normal file
207
src/components/ui/pricing-section.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import * as React from "react";
|
||||
import { Button } from "./button";
|
||||
import { Card, CardContent } from "./card";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
interface PricingSectionProps {
|
||||
className?: string;
|
||||
onGetStarted?: () => void;
|
||||
}
|
||||
|
||||
const PricingSection = React.forwardRef<HTMLElement, PricingSectionProps>(
|
||||
({ className, onGetStarted, ...props }, ref) => {
|
||||
const plans = [
|
||||
{
|
||||
name: "Free",
|
||||
price: "0원",
|
||||
period: "월",
|
||||
description: "개인 사용자를 위한 무료 플랜",
|
||||
features: [
|
||||
"월 30회 AI 쿼리",
|
||||
"300개 셀 편집",
|
||||
"10MB 파일 크기 제한",
|
||||
"다운로드 불가",
|
||||
"복사 불가",
|
||||
],
|
||||
buttonText: "무료로 시작하기",
|
||||
buttonVariant: "outline" as const,
|
||||
popular: false,
|
||||
},
|
||||
{
|
||||
name: "Lite",
|
||||
price: "5,900원",
|
||||
period: "월",
|
||||
description: "소규모 팀을 위한 기본 플랜",
|
||||
features: [
|
||||
"월 100회 AI 쿼리",
|
||||
"1,000개 셀 편집",
|
||||
"10MB 파일 크기 제한",
|
||||
"다운로드 가능",
|
||||
"복사 가능",
|
||||
],
|
||||
buttonText: "Lite 시작하기",
|
||||
buttonVariant: "default" as const,
|
||||
popular: true,
|
||||
},
|
||||
{
|
||||
name: "Pro",
|
||||
price: "14,900원",
|
||||
period: "월",
|
||||
description: "전문가를 위한 고급 플랜",
|
||||
features: [
|
||||
"월 500회 AI 쿼리",
|
||||
"5,000개 셀 편집",
|
||||
"50MB 파일 크기 제한",
|
||||
"다운로드 가능",
|
||||
"복사 가능",
|
||||
],
|
||||
buttonText: "Pro 시작하기",
|
||||
buttonVariant: "outline" as const,
|
||||
popular: false,
|
||||
},
|
||||
{
|
||||
name: "Enterprise",
|
||||
price: "협의",
|
||||
period: "",
|
||||
description: "대기업을 위한 맞춤형 플랜",
|
||||
features: [
|
||||
"무제한 AI 쿼리",
|
||||
"무제한 셀 편집",
|
||||
"무제한 파일 크기",
|
||||
"다운로드 가능",
|
||||
"복사 가능",
|
||||
],
|
||||
buttonText: "문의하기",
|
||||
buttonVariant: "outline" as const,
|
||||
popular: false,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={ref}
|
||||
id="pricing"
|
||||
className={cn(
|
||||
"relative py-20 sm:py-32 bg-gradient-to-br from-blue-600 to-purple-700 overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{/* Background decoration */}
|
||||
<div className="absolute inset-0 bg-grid-white/[0.05] [mask-image:linear-gradient(0deg,transparent,black)]" />
|
||||
|
||||
<div className="container relative">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
{/* Section Header */}
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl font-bold tracking-tight text-white sm:text-4xl mb-4">
|
||||
심플하고 투명한 가격
|
||||
</h2>
|
||||
<p className="text-lg text-blue-100">
|
||||
모든 기능을 제한 없이 사용하세요
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Pricing Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{plans.map((plan, index) => (
|
||||
<Card
|
||||
key={index}
|
||||
className={cn(
|
||||
"relative shadow-lg hover:shadow-xl transition-all duration-300 bg-white/10 backdrop-blur-sm text-white",
|
||||
plan.popular
|
||||
? "border-2 border-yellow-400 scale-105 shadow-2xl bg-white/15"
|
||||
: "border-0",
|
||||
)}
|
||||
>
|
||||
{plan.popular && (
|
||||
<div className="absolute -top-3 left-1/2 transform -translate-x-1/2">
|
||||
<span className="bg-yellow-400 text-gray-900 px-4 py-1 rounded-full text-sm font-medium">
|
||||
인기
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CardContent className="p-6">
|
||||
{/* Plan Header */}
|
||||
<div className="text-center mb-6">
|
||||
<h3 className="text-xl font-bold text-white mb-2">
|
||||
{plan.name}
|
||||
</h3>
|
||||
<div className="mb-2">
|
||||
<span className="text-3xl font-bold text-white">
|
||||
{plan.price}
|
||||
</span>
|
||||
{plan.period && (
|
||||
<span className="text-blue-200">/{plan.period}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-blue-100">
|
||||
{plan.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div className="mb-8">
|
||||
<ul className="space-y-3">
|
||||
{plan.features.map((feature, featureIndex) => (
|
||||
<li key={featureIndex} className="flex items-start">
|
||||
<div className="flex-shrink-0 mr-3 mt-1">
|
||||
<svg
|
||||
className="h-4 w-4 text-yellow-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm text-blue-100">
|
||||
{feature}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* CTA Button */}
|
||||
<Button
|
||||
variant={plan.buttonVariant}
|
||||
className={cn(
|
||||
"w-full",
|
||||
plan.popular
|
||||
? "bg-yellow-400 hover:bg-yellow-500 text-gray-900 font-semibold"
|
||||
: "bg-white/20 hover:bg-white/30 text-white border-white/30",
|
||||
)}
|
||||
onClick={onGetStarted}
|
||||
>
|
||||
{plan.buttonText}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Additional Info */}
|
||||
<div className="mt-12 text-center">
|
||||
<p className="text-sm text-blue-200">
|
||||
• 모든 플랜은 언제든지 취소 가능합니다 • AI 쿼리 또는 셀 카운트
|
||||
중 하나라도 도달하면 제한됩니다 • 결제는 한국 원화(KRW)로
|
||||
진행됩니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
PricingSection.displayName = "PricingSection";
|
||||
|
||||
export { PricingSection };
|
||||
28
src/components/ui/progress.tsx
Normal file
28
src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
));
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName;
|
||||
|
||||
export { Progress };
|
||||
157
src/components/ui/select.tsx
Normal file
157
src/components/ui/select.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
};
|
||||
308
src/components/ui/topbar.tsx
Normal file
308
src/components/ui/topbar.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import * as React from "react";
|
||||
import { Button } from "./button";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { useAppStore } from "../../stores/useAppStore";
|
||||
|
||||
interface TopBarProps {
|
||||
className?: string;
|
||||
onDownloadClick?: () => void;
|
||||
onAccountClick?: () => void;
|
||||
onSignInClick?: () => void;
|
||||
onGetStartedClick?: () => void;
|
||||
onLogoClick?: () => void;
|
||||
onTutorialClick?: () => void;
|
||||
onHomeClick?: () => void;
|
||||
onFeaturesClick?: () => void;
|
||||
onFAQClick?: () => void;
|
||||
onPricingClick?: () => void;
|
||||
showDownload?: boolean;
|
||||
showAccount?: boolean;
|
||||
showNavigation?: boolean;
|
||||
showAuthButtons?: boolean;
|
||||
showTestAccount?: boolean;
|
||||
}
|
||||
|
||||
const TopBar = React.forwardRef<HTMLElement, TopBarProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
onDownloadClick,
|
||||
onAccountClick,
|
||||
onSignInClick,
|
||||
onGetStartedClick,
|
||||
onLogoClick,
|
||||
onTutorialClick,
|
||||
onHomeClick,
|
||||
onFeaturesClick,
|
||||
onFAQClick,
|
||||
onPricingClick,
|
||||
showDownload = true,
|
||||
showAccount = true,
|
||||
showNavigation = false,
|
||||
showAuthButtons = false,
|
||||
showTestAccount = false,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { currentFile, user, isAuthenticated } = useAppStore();
|
||||
|
||||
// 기본 다운로드 핸들러 - 현재 파일을 XLSX로 다운로드
|
||||
const handleDownload = () => {
|
||||
if (onDownloadClick) {
|
||||
onDownloadClick();
|
||||
} else if (currentFile?.xlsxBuffer) {
|
||||
// XLSX ArrayBuffer를 Blob으로 변환하여 다운로드
|
||||
const blob = new Blob([currentFile.xlsxBuffer], {
|
||||
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = currentFile.name || "sheet.xlsx";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
} else {
|
||||
alert("다운로드할 파일이 없습니다. 파일을 먼저 업로드해주세요.");
|
||||
}
|
||||
};
|
||||
|
||||
// 기본 계정 핸들러
|
||||
const handleAccount = () => {
|
||||
if (onAccountClick) {
|
||||
onAccountClick();
|
||||
} else {
|
||||
// 기본 동작 - 계정 페이지로 이동 (추후 구현)
|
||||
console.log("계정 페이지로 이동");
|
||||
}
|
||||
};
|
||||
|
||||
// 네비게이션 메뉴 핸들러들 - 라우팅 기반으로 변경
|
||||
const handleNavigation = (section: string) => {
|
||||
switch (section) {
|
||||
case "home":
|
||||
if (onHomeClick) {
|
||||
onHomeClick();
|
||||
} else {
|
||||
// 폴백: 랜딩 페이지의 해당 섹션으로 스크롤
|
||||
const element = document.getElementById("home");
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "features":
|
||||
if (onFeaturesClick) {
|
||||
onFeaturesClick();
|
||||
} else {
|
||||
const element = document.getElementById("features");
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "faq":
|
||||
if (onFAQClick) {
|
||||
onFAQClick();
|
||||
} else {
|
||||
const element = document.getElementById("faq");
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "pricing":
|
||||
if (onPricingClick) {
|
||||
onPricingClick();
|
||||
} else {
|
||||
const element = document.getElementById("pricing");
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
console.warn(`Unknown navigation section: ${section}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<header
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="container flex h-16 items-center relative">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
className="flex items-center space-x-2 hover:opacity-80 transition-opacity"
|
||||
onClick={onLogoClick}
|
||||
aria-label="홈으로 이동"
|
||||
>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-r from-green-500 to-blue-600">
|
||||
<span className="text-sm font-bold text-white">📊</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold bg-gradient-to-r from-green-600 to-blue-600 bg-clip-text text-transparent">
|
||||
sheetEasy AI
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation Menu - 절대 중앙 위치 */}
|
||||
{showNavigation && (
|
||||
<nav className="hidden md:flex items-center space-x-8 absolute left-1/2 transform -translate-x-1/2">
|
||||
<button
|
||||
onClick={() => handleNavigation("home")}
|
||||
className="text-sm font-medium text-gray-700 hover:text-green-600 transition-colors"
|
||||
>
|
||||
홈
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleNavigation("features")}
|
||||
className="text-sm font-medium text-gray-700 hover:text-green-600 transition-colors"
|
||||
>
|
||||
기능소개
|
||||
</button>
|
||||
<button
|
||||
onClick={onTutorialClick}
|
||||
className="text-sm font-medium text-gray-700 hover:text-green-600 transition-colors"
|
||||
>
|
||||
튜토리얼
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleNavigation("faq")}
|
||||
className="text-sm font-medium text-gray-700 hover:text-green-600 transition-colors"
|
||||
>
|
||||
FAQ
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleNavigation("pricing")}
|
||||
className="text-sm font-medium text-gray-700 hover:text-green-600 transition-colors"
|
||||
>
|
||||
가격
|
||||
</button>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center space-x-4 ml-auto">
|
||||
{/* Auth Buttons - 랜딩 페이지용 */}
|
||||
{showAuthButtons && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onSignInClick}
|
||||
className="hidden sm:inline-flex text-gray-700 hover:text-green-600"
|
||||
>
|
||||
로그인
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onGetStartedClick}
|
||||
className="bg-green-600 hover:bg-green-700 text-white"
|
||||
>
|
||||
무료로 시작하기
|
||||
</Button>
|
||||
{/* 테스트용 어카운트 버튼 */}
|
||||
{showTestAccount && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onAccountClick}
|
||||
className="border-purple-500 text-purple-600 hover:bg-purple-50 hover:border-purple-600"
|
||||
aria-label="테스트용 계정 페이지"
|
||||
>
|
||||
어카운트
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Download 버튼 - 에디터용 */}
|
||||
{showDownload && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDownload}
|
||||
className="hidden sm:inline-flex hover:bg-green-50 hover:border-green-300"
|
||||
aria-label="파일 다운로드"
|
||||
disabled={!currentFile?.xlsxBuffer}
|
||||
>
|
||||
<svg
|
||||
className="mr-2 h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
다운로드
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Account 버튼 - 로그인 시 */}
|
||||
{showAccount && isAuthenticated && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleAccount}
|
||||
className="flex items-center space-x-2 hover:bg-purple-50"
|
||||
aria-label="계정 설정"
|
||||
>
|
||||
<div className="h-8 w-8 rounded-full bg-gradient-to-r from-purple-500 to-pink-500 flex items-center justify-center">
|
||||
<span className="text-xs font-medium text-white">
|
||||
{user?.name?.charAt(0).toUpperCase() || "U"}
|
||||
</span>
|
||||
</div>
|
||||
<span className="hidden sm:inline-block text-sm font-medium">
|
||||
{user?.name || "계정"}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Mobile Menu Button - 모바일용 */}
|
||||
{showNavigation && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="md:hidden"
|
||||
aria-label="메뉴 열기"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
TopBar.displayName = "TopBar";
|
||||
|
||||
export { TopBar };
|
||||
158
src/components/ui/tutorial-card.tsx
Normal file
158
src/components/ui/tutorial-card.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import * as React from "react";
|
||||
import { Clock, BookOpen, Tag, Play } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./card";
|
||||
import { Button } from "./button";
|
||||
import type { TutorialCardProps } from "../../types/tutorial";
|
||||
|
||||
/**
|
||||
* 개별 튜토리얼 카드 컴포넌트
|
||||
* - Excel 함수별 튜토리얼 정보 표시
|
||||
* - 난이도, 소요시간, 카테고리 표시
|
||||
* - 클릭 시 튜토리얼 실행
|
||||
*/
|
||||
export const TutorialCard: React.FC<TutorialCardProps> = ({
|
||||
tutorial,
|
||||
onClick,
|
||||
isActive = false,
|
||||
showBadge = true,
|
||||
}) => {
|
||||
const handleClick = () => {
|
||||
onClick(tutorial);
|
||||
};
|
||||
|
||||
const getDifficultyColor = (difficulty: string) => {
|
||||
switch (difficulty) {
|
||||
case "초급":
|
||||
return "bg-green-100 text-green-800 border-green-200";
|
||||
case "중급":
|
||||
return "bg-yellow-100 text-yellow-800 border-yellow-200";
|
||||
case "고급":
|
||||
return "bg-red-100 text-red-800 border-red-200";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800 border-gray-200";
|
||||
}
|
||||
};
|
||||
|
||||
const getCategoryColor = (category: string) => {
|
||||
const categoryMap: Record<string, string> = {
|
||||
basic_math: "bg-blue-50 text-blue-700",
|
||||
logical: "bg-purple-50 text-purple-700",
|
||||
statistical: "bg-green-50 text-green-700",
|
||||
lookup: "bg-orange-50 text-orange-700",
|
||||
text: "bg-pink-50 text-pink-700",
|
||||
advanced: "bg-gray-50 text-gray-700",
|
||||
};
|
||||
return categoryMap[category] || "bg-gray-50 text-gray-700";
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"group cursor-pointer transition-all duration-200 hover:shadow-lg hover:scale-[1.02]",
|
||||
"border-2 hover:border-blue-200",
|
||||
isActive && "ring-2 ring-blue-500 border-blue-500",
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-blue-600 text-white px-2 py-1 rounded text-sm font-mono font-bold">
|
||||
{tutorial.functionName}
|
||||
</div>
|
||||
{showBadge && (
|
||||
<span
|
||||
className={cn(
|
||||
"px-2 py-1 rounded-full text-xs font-medium border",
|
||||
getDifficultyColor(tutorial.metadata.difficulty),
|
||||
)}
|
||||
>
|
||||
{tutorial.metadata.difficulty}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Play className="w-4 h-4 text-blue-600 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
|
||||
<CardTitle className="text-lg font-semibold leading-tight">
|
||||
{tutorial.metadata.title}
|
||||
</CardTitle>
|
||||
|
||||
<p className="text-sm text-gray-600 line-clamp-2">
|
||||
{tutorial.metadata.description}
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pt-0">
|
||||
{/* 메타 정보 */}
|
||||
<div className="flex items-center gap-4 mb-3 text-sm text-gray-500">
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>{tutorial.metadata.estimatedTime}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<BookOpen className="w-4 h-4" />
|
||||
<span
|
||||
className={cn(
|
||||
"px-2 py-0.5 rounded text-xs font-medium",
|
||||
getCategoryColor(tutorial.metadata.category),
|
||||
)}
|
||||
>
|
||||
{tutorial.metadata.category === "basic_math"
|
||||
? "기본수학"
|
||||
: tutorial.metadata.category === "logical"
|
||||
? "논리함수"
|
||||
: tutorial.metadata.category === "statistical"
|
||||
? "통계함수"
|
||||
: tutorial.metadata.category === "lookup"
|
||||
? "조회함수"
|
||||
: tutorial.metadata.category === "text"
|
||||
? "텍스트함수"
|
||||
: "고급함수"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 태그 */}
|
||||
<div className="flex flex-wrap gap-1 mb-4">
|
||||
{tutorial.metadata.tags.slice(0, 3).map((tag, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-1 bg-gray-100 text-gray-700 px-2 py-1 rounded text-xs"
|
||||
>
|
||||
<Tag className="w-3 h-3" />
|
||||
<span>{tag}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 실행 버튼 */}
|
||||
<Button
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
|
||||
size="sm"
|
||||
>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
튜토리얼 시작
|
||||
</Button>
|
||||
|
||||
{/* 관련 함수 표시 */}
|
||||
{tutorial.relatedFunctions && tutorial.relatedFunctions.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-100">
|
||||
<p className="text-xs text-gray-500 mb-1">관련 함수:</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{tutorial.relatedFunctions.slice(0, 3).map((func, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="bg-gray-50 text-gray-600 px-1.5 py-0.5 rounded text-xs font-mono"
|
||||
>
|
||||
{func}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
221
src/components/ui/tutorial-section.tsx
Normal file
221
src/components/ui/tutorial-section.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { Filter, BookOpen, Sparkles } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Button } from "./button";
|
||||
import { TutorialCard } from "./tutorial-card";
|
||||
import { TutorialDataGenerator } from "../../utils/tutorialDataGenerator";
|
||||
import type { TutorialSectionProps } from "../../types/tutorial";
|
||||
import { TutorialCategory } from "../../types/tutorial";
|
||||
|
||||
/**
|
||||
* 전체 튜토리얼 섹션 컴포넌트
|
||||
* - 10개 Excel 함수 튜토리얼 표시
|
||||
* - 카테고리별 필터링
|
||||
* - 반응형 그리드 레이아웃
|
||||
* - 튜토리얼 선택 및 실행
|
||||
*/
|
||||
export const TutorialSection: React.FC<TutorialSectionProps> = ({
|
||||
onTutorialSelect,
|
||||
selectedCategory,
|
||||
onCategoryChange,
|
||||
}) => {
|
||||
const [activeCategory, setActiveCategory] = useState<
|
||||
TutorialCategory | "all"
|
||||
>(selectedCategory || "all");
|
||||
|
||||
// 전체 튜토리얼 데이터 로드
|
||||
const allTutorials = TutorialDataGenerator.generateAllTutorials();
|
||||
|
||||
// 카테고리별 필터링
|
||||
const filteredTutorials =
|
||||
activeCategory === "all"
|
||||
? allTutorials
|
||||
: allTutorials.filter(
|
||||
(tutorial) => tutorial.metadata.category === activeCategory,
|
||||
);
|
||||
|
||||
// 카테고리 변경 핸들러
|
||||
const handleCategoryChange = (category: TutorialCategory | "all") => {
|
||||
setActiveCategory(category);
|
||||
onCategoryChange?.(category === "all" ? undefined : category);
|
||||
};
|
||||
|
||||
// 튜토리얼 선택 핸들러
|
||||
const handleTutorialSelect = (tutorial: any) => {
|
||||
console.log(`📚 튜토리얼 선택: ${tutorial.metadata.title}`);
|
||||
onTutorialSelect?.(tutorial);
|
||||
};
|
||||
|
||||
// 카테고리별 이름 매핑
|
||||
const getCategoryName = (category: TutorialCategory | "all") => {
|
||||
const categoryNames: Record<TutorialCategory | "all", string> = {
|
||||
all: "전체",
|
||||
basic_math: "기본수학",
|
||||
logical: "논리함수",
|
||||
statistical: "통계함수",
|
||||
lookup: "조회함수",
|
||||
text: "텍스트함수",
|
||||
date_time: "날짜/시간",
|
||||
advanced: "고급함수",
|
||||
};
|
||||
return categoryNames[category] || "전체";
|
||||
};
|
||||
|
||||
// 사용 가능한 카테고리 목록
|
||||
const availableCategories = [
|
||||
"all" as const,
|
||||
...Array.from(new Set(allTutorials.map((t) => t.metadata.category))).sort(),
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="py-16 bg-gradient-to-b from-gray-50 to-white">
|
||||
<div className="container mx-auto px-4">
|
||||
{/* 섹션 헤더 */}
|
||||
<div className="text-center mb-12">
|
||||
<div className="flex items-center justify-center gap-2 mb-4">
|
||||
<div className="bg-blue-600 p-2 rounded-lg">
|
||||
<Sparkles className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-gray-900">
|
||||
Excel 함수 튜토리얼
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-lg text-gray-600 max-w-3xl mx-auto leading-relaxed">
|
||||
실제 데이터로 바로 체험하는 Excel 핵심 함수들!
|
||||
<br />
|
||||
AI가 자동으로 수식을 작성하고 결과를 보여드립니다.
|
||||
</p>
|
||||
<div className="mt-4 flex items-center justify-center gap-2 text-sm text-gray-500">
|
||||
<BookOpen className="w-4 h-4" />
|
||||
<span>
|
||||
{allTutorials.length}개 튜토리얼 • 한국어 지원 • 실시간 데모
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 필터 */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Filter className="w-5 h-5 text-gray-600" />
|
||||
<span className="font-medium text-gray-700">카테고리별 보기</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 justify-center md:justify-start">
|
||||
{availableCategories.map((category) => (
|
||||
<Button
|
||||
key={category}
|
||||
variant={activeCategory === category ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => handleCategoryChange(category)}
|
||||
className={cn(
|
||||
"transition-all duration-200",
|
||||
activeCategory === category
|
||||
? "bg-blue-600 hover:bg-blue-700 text-white shadow-md"
|
||||
: "hover:bg-blue-50 hover:border-blue-300 hover:text-blue-700",
|
||||
)}
|
||||
>
|
||||
{getCategoryName(category)}
|
||||
<span className="ml-2 text-xs bg-white/20 rounded px-1.5 py-0.5">
|
||||
{category === "all"
|
||||
? allTutorials.length
|
||||
: allTutorials.filter(
|
||||
(t) => t.metadata.category === category,
|
||||
).length}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 선택된 카테고리 정보 */}
|
||||
{activeCategory !== "all" && (
|
||||
<div className="mb-6 p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<h3 className="font-semibold text-blue-900 mb-1">
|
||||
{getCategoryName(activeCategory)} 함수들
|
||||
</h3>
|
||||
<p className="text-sm text-blue-700">
|
||||
{filteredTutorials.length}개의 튜토리얼이 준비되어 있습니다. 각
|
||||
튜토리얼은 실제 데이터와 함께 단계별로 진행됩니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 튜토리얼 그리드 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{filteredTutorials.map((tutorial) => (
|
||||
<TutorialCard
|
||||
key={tutorial.metadata.id}
|
||||
tutorial={tutorial}
|
||||
onClick={handleTutorialSelect}
|
||||
showBadge={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 빈 상태 */}
|
||||
{filteredTutorials.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<div className="bg-gray-100 rounded-full w-16 h-16 flex items-center justify-center mx-auto mb-4">
|
||||
<BookOpen className="w-8 h-8 text-gray-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
해당 카테고리에 튜토리얼이 없습니다
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
다른 카테고리를 선택하거나 전체 보기를 시도해보세요.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleCategoryChange("all")}
|
||||
>
|
||||
전체 튜토리얼 보기
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 추가 정보 섹션 */}
|
||||
<div className="mt-12 bg-white rounded-xl border border-gray-200 p-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 text-center">
|
||||
<div>
|
||||
<div className="bg-green-100 rounded-full w-12 h-12 flex items-center justify-center mx-auto mb-3">
|
||||
<span className="text-green-600 font-bold text-lg">1</span>
|
||||
</div>
|
||||
<h4 className="font-semibold text-gray-900 mb-2">
|
||||
샘플 데이터 자동 생성
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
실제 업무에서 사용하는 데이터와 유사한 샘플 데이터가 자동으로
|
||||
생성됩니다.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="bg-blue-100 rounded-full w-12 h-12 flex items-center justify-center mx-auto mb-3">
|
||||
<span className="text-blue-600 font-bold text-lg">2</span>
|
||||
</div>
|
||||
<h4 className="font-semibold text-gray-900 mb-2">
|
||||
AI 자동 수식 작성
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
AI가 자연어 프롬프트를 분석하여 정확한 Excel 수식을 자동으로
|
||||
작성합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="bg-purple-100 rounded-full w-12 h-12 flex items-center justify-center mx-auto mb-3">
|
||||
<span className="text-purple-600 font-bold text-lg">3</span>
|
||||
</div>
|
||||
<h4 className="font-semibold text-gray-900 mb-2">
|
||||
실시간 결과 확인
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
수식이 적용되는 과정을 실시간으로 보고 결과를 바로 확인할 수
|
||||
있습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
294
src/index.css
294
src/index.css
@@ -1,129 +1,39 @@
|
||||
/* Univer CE 공식 스타일 - @import는 맨 위에 */
|
||||
@import '@univerjs/design/lib/index.css';
|
||||
@import '@univerjs/ui/lib/index.css';
|
||||
@import '@univerjs/docs-ui/lib/index.css';
|
||||
@import '@univerjs/sheets-ui/lib/index.css';
|
||||
@import '@univerjs/sheets-formula-ui/lib/index.css';
|
||||
@import '@univerjs/sheets-numfmt-ui/lib/index.css';
|
||||
|
||||
/* Tailwind CSS */
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* 필요한 색상 클래스들 추가 */
|
||||
.text-gray-500 { color: #6b7280; }
|
||||
.text-gray-600 { color: #4b5563; }
|
||||
.text-gray-900 { color: #111827; }
|
||||
.text-blue-600 { color: #2563eb; }
|
||||
.text-blue-700 { color: #1d4ed8; }
|
||||
.text-blue-800 { color: #1e40af; }
|
||||
|
||||
.bg-gray-50 { background-color: #f9fafb; }
|
||||
.bg-blue-50 { background-color: #eff6ff; }
|
||||
.bg-blue-100 { background-color: #dbeafe; }
|
||||
.bg-blue-200 { background-color: #bfdbfe; }
|
||||
|
||||
.border-gray-300 { border-color: #d1d5db; }
|
||||
.border-blue-200 { border-color: #bfdbfe; }
|
||||
.border-blue-400 { border-color: #60a5fa; }
|
||||
.border-blue-500 { border-color: #3b82f6; }
|
||||
|
||||
.hover\:border-blue-400:hover { border-color: #60a5fa; }
|
||||
.hover\:bg-blue-50:hover { background-color: #eff6ff; }
|
||||
|
||||
.focus\:ring-blue-500:focus {
|
||||
--tw-ring-color: #3b82f6;
|
||||
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
|
||||
}
|
||||
|
||||
/* 추가 유틸리티 클래스들 */
|
||||
.max-w-7xl { max-width: 80rem; }
|
||||
.max-w-2xl { max-width: 42rem; }
|
||||
.max-w-lg { max-width: 32rem; }
|
||||
.max-w-md { max-width: 28rem; }
|
||||
|
||||
.h-16 { height: 4rem; }
|
||||
.h-20 { height: 5rem; }
|
||||
.h-24 { height: 6rem; }
|
||||
.w-20 { width: 5rem; }
|
||||
.w-24 { width: 6rem; }
|
||||
|
||||
.h-6 { height: 1.5rem; }
|
||||
.w-6 { width: 1.5rem; }
|
||||
.h-10 { height: 2.5rem; }
|
||||
.w-10 { width: 2.5rem; }
|
||||
.h-12 { height: 3rem; }
|
||||
.w-12 { width: 3rem; }
|
||||
|
||||
.space-x-4 > :not([hidden]) ~ :not([hidden]) { margin-left: 1rem; }
|
||||
.space-x-2 > :not([hidden]) ~ :not([hidden]) { margin-left: 0.5rem; }
|
||||
.space-y-1 > :not([hidden]) ~ :not([hidden]) { margin-top: 0.25rem; }
|
||||
.space-y-2 > :not([hidden]) ~ :not([hidden]) { margin-top: 0.5rem; }
|
||||
.space-y-4 > :not([hidden]) ~ :not([hidden]) { margin-top: 1rem; }
|
||||
|
||||
.p-3 { padding: 0.75rem; }
|
||||
.p-4 { padding: 1rem; }
|
||||
.p-8 { padding: 2rem; }
|
||||
.p-12 { padding: 3rem; }
|
||||
.px-4 { padding-left: 1rem; padding-right: 1rem; }
|
||||
.py-8 { padding-top: 2rem; padding-bottom: 2rem; }
|
||||
|
||||
.mb-2 { margin-bottom: 0.5rem; }
|
||||
.mb-3 { margin-bottom: 0.75rem; }
|
||||
.mb-4 { margin-bottom: 1rem; }
|
||||
.mb-6 { margin-bottom: 1.5rem; }
|
||||
.mb-8 { margin-bottom: 2rem; }
|
||||
.mt-6 { margin-top: 1.5rem; }
|
||||
|
||||
.text-xs { font-size: 0.75rem; line-height: 1rem; }
|
||||
.text-sm { font-size: 0.875rem; line-height: 1.25rem; }
|
||||
.text-base { font-size: 1rem; line-height: 1.5rem; }
|
||||
.text-lg { font-size: 1.125rem; line-height: 1.75rem; }
|
||||
.text-xl { font-size: 1.25rem; line-height: 1.75rem; }
|
||||
.text-2xl { font-size: 1.5rem; line-height: 2rem; }
|
||||
.text-4xl { font-size: 2.25rem; line-height: 2.5rem; }
|
||||
.text-6xl { font-size: 3.75rem; line-height: 1; }
|
||||
|
||||
.font-medium { font-weight: 500; }
|
||||
.font-semibold { font-weight: 600; }
|
||||
.font-bold { font-weight: 700; }
|
||||
|
||||
.rounded-lg { border-radius: 0.5rem; }
|
||||
.rounded-md { border-radius: 0.375rem; }
|
||||
|
||||
.shadow-sm { box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); }
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.sm\:px-6 { padding-left: 1.5rem; padding-right: 1.5rem; }
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.md\:h-24 { height: 6rem; }
|
||||
.md\:w-24 { width: 6rem; }
|
||||
.md\:h-12 { height: 3rem; }
|
||||
.md\:w-12 { width: 3rem; }
|
||||
.md\:p-12 { padding: 3rem; }
|
||||
.md\:text-base { font-size: 1rem; line-height: 1.5rem; }
|
||||
.md\:text-lg { font-size: 1.125rem; line-height: 1.75rem; }
|
||||
.md\:text-2xl { font-size: 1.5rem; line-height: 2rem; }
|
||||
.md\:text-6xl { font-size: 3.75rem; line-height: 1; }
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.lg\:px-8 { padding-left: 2rem; padding-right: 2rem; }
|
||||
}
|
||||
|
||||
/* 커스텀 스타일 */
|
||||
body {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
color-scheme: light;
|
||||
color: #1f2937; /* 검은색 계열로 변경 */
|
||||
background-color: #ffffff;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
/* 전역 스타일 */
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* 스크롤바 스타일링 */
|
||||
/* Univer 컨테이너 스타일 */
|
||||
.univer-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 커스텀 스크롤바 (Univer와 일치) */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@@ -132,20 +42,158 @@ body {
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a1a1a1;
|
||||
background: #a8a8a8;
|
||||
}
|
||||
|
||||
/* 파일 업로드 영역 스타일 */
|
||||
.file-upload-area {
|
||||
border: 2px dashed #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.file-upload-area:hover {
|
||||
border-color: #3b82f6;
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
|
||||
.file-upload-area.dragover {
|
||||
border-color: #3b82f6;
|
||||
background-color: #dbeafe;
|
||||
}
|
||||
|
||||
/* 로딩 애니메이션 */
|
||||
.loading-spinner {
|
||||
border: 2px solid #f3f3f3;
|
||||
border-top: 2px solid #3b82f6;
|
||||
border-radius: 50%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 상태 표시 점 애니메이션 */
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.status-dot.pulse {
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: .5;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
/* 에러 메시지 스타일 */
|
||||
.error-message {
|
||||
color: #dc2626;
|
||||
background-color: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.75rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
/* 성공 메시지 스타일 */
|
||||
.success-message {
|
||||
color: #059669;
|
||||
background-color: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.75rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
/* 정보 메시지 스타일 */
|
||||
.info-message {
|
||||
color: #2563eb;
|
||||
background-color: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.75rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
/* 모바일 반응형 */
|
||||
@media (max-width: 768px) {
|
||||
.file-upload-area {
|
||||
padding: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.univer-container {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 다크 모드 지원 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.file-upload-area {
|
||||
border-color: #374151;
|
||||
background-color: #1f2937;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.file-upload-area:hover {
|
||||
border-color: #60a5fa;
|
||||
background-color: #111827;
|
||||
}
|
||||
|
||||
.file-upload-area.dragover {
|
||||
border-color: #60a5fa;
|
||||
background-color: #1e3a8a;
|
||||
}
|
||||
}
|
||||
|
||||
/* 랜딩페이지 추가 스타일 */
|
||||
.bg-grid-slate-100 {
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' width='32' height='32' fill='none' stroke='rgb(15 23 42 / 0.04)'%3e%3cpath d='m0 .5h32v32'/%3e%3cpath d='m.5 0v32h32'/%3e%3c/svg%3e");
|
||||
}
|
||||
|
||||
.bg-grid-white\/\[0\.05\] {
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' width='32' height='32' fill='none' stroke='rgb(255 255 255 / 0.05)'%3e%3cpath d='m0 .5h32v32'/%3e%3cpath d='m.5 0v32h32'/%3e%3c/svg%3e");
|
||||
}
|
||||
|
||||
/* 부드러운 스크롤 */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* 컨테이너 스타일 */
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.container {
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.container {
|
||||
padding: 0 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
367
src/lib/i18n.tsx
Normal file
367
src/lib/i18n.tsx
Normal file
@@ -0,0 +1,367 @@
|
||||
import * as React from "react";
|
||||
import { createContext, useContext, useState, useEffect } from "react";
|
||||
|
||||
// 지원하는 언어 타입
|
||||
export type Language = "ko" | "en";
|
||||
|
||||
// 번역 키 타입 정의
|
||||
export interface Translations {
|
||||
// 공통 UI
|
||||
common: {
|
||||
loading: string;
|
||||
error: string;
|
||||
success: string;
|
||||
cancel: string;
|
||||
confirm: string;
|
||||
save: string;
|
||||
delete: string;
|
||||
edit: string;
|
||||
close: string;
|
||||
};
|
||||
|
||||
// 계정 페이지
|
||||
account: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
userInfo: string;
|
||||
subscriptionPlan: string;
|
||||
usageSummary: string;
|
||||
usageAnalytics: string;
|
||||
usageAnalyticsDescription: string;
|
||||
settings: string;
|
||||
logout: string;
|
||||
email: string;
|
||||
name: string;
|
||||
joinDate: string;
|
||||
lastLogin: string;
|
||||
currentPlan: string;
|
||||
planStatus: string;
|
||||
nextBilling: string;
|
||||
aiQueries: string;
|
||||
cellCount: string;
|
||||
language: string;
|
||||
historyPanelPosition: string;
|
||||
changeSettings: string;
|
||||
changePlan: string;
|
||||
cancelSubscription: string;
|
||||
confirmCancel: string;
|
||||
goToEditor: string;
|
||||
left: string;
|
||||
right: string;
|
||||
korean: string;
|
||||
english: string;
|
||||
usageWarning: string;
|
||||
dailyUsage: string;
|
||||
last30Days: string;
|
||||
date: string;
|
||||
tooltipLabels: {
|
||||
aiQueries: string;
|
||||
cellCount: string;
|
||||
promptCount: string;
|
||||
editedCells: string;
|
||||
};
|
||||
};
|
||||
|
||||
// 히스토리 패널
|
||||
history: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
empty: string;
|
||||
emptyDescription: string;
|
||||
clearAll: string;
|
||||
reapply: string;
|
||||
prompt: string;
|
||||
range: string;
|
||||
sheet: string;
|
||||
actions: string;
|
||||
error: string;
|
||||
today: string;
|
||||
yesterday: string;
|
||||
formula: string;
|
||||
style: string;
|
||||
chart: string;
|
||||
other: string;
|
||||
};
|
||||
|
||||
// 프롬프트 입력
|
||||
prompt: {
|
||||
placeholder: string;
|
||||
send: string;
|
||||
processing: string;
|
||||
selectRange: string;
|
||||
insertAddress: string;
|
||||
};
|
||||
}
|
||||
|
||||
// 한국어 번역
|
||||
const koTranslations: Translations = {
|
||||
common: {
|
||||
loading: "로딩 중...",
|
||||
error: "오류",
|
||||
success: "성공",
|
||||
cancel: "취소",
|
||||
confirm: "확인",
|
||||
save: "저장",
|
||||
delete: "삭제",
|
||||
edit: "편집",
|
||||
close: "닫기",
|
||||
},
|
||||
account: {
|
||||
title: "계정 관리",
|
||||
subtitle: "구독 정보와 사용량을 관리하세요",
|
||||
userInfo: "사용자 정보",
|
||||
subscriptionPlan: "구독 플랜",
|
||||
usageSummary: "사용량 요약",
|
||||
usageAnalytics: "사용량 분석",
|
||||
usageAnalyticsDescription: "최근 30일간 사용량 추이",
|
||||
settings: "설정",
|
||||
logout: "로그아웃",
|
||||
email: "이메일",
|
||||
name: "이름",
|
||||
joinDate: "가입일",
|
||||
lastLogin: "최근 로그인",
|
||||
currentPlan: "현재 플랜",
|
||||
planStatus: "플랜 상태",
|
||||
nextBilling: "다음 결제일",
|
||||
aiQueries: "AI 쿼리",
|
||||
cellCount: "셀 수",
|
||||
language: "언어",
|
||||
historyPanelPosition: "히스토리 패널 위치",
|
||||
changeSettings: "설정 변경",
|
||||
changePlan: "플랜 변경",
|
||||
cancelSubscription: "구독 취소",
|
||||
confirmCancel: "정말로 구독을 취소하시겠습니까?",
|
||||
goToEditor: "에디터로 이동",
|
||||
left: "좌측",
|
||||
right: "우측",
|
||||
korean: "한국어",
|
||||
english: "English",
|
||||
usageWarning: "사용량 경고",
|
||||
dailyUsage: "일일 사용량",
|
||||
last30Days: "최근 30일",
|
||||
date: "날짜",
|
||||
tooltipLabels: {
|
||||
aiQueries: "AI 쿼리",
|
||||
cellCount: "셀 카운트",
|
||||
promptCount: "프롬프트 수",
|
||||
editedCells: "편집된 셀",
|
||||
},
|
||||
},
|
||||
history: {
|
||||
title: "작업 히스토리",
|
||||
subtitle: "AI 프롬프트 실행 기록",
|
||||
empty: "아직 실행된 AI 프롬프트가 없습니다.",
|
||||
emptyDescription: "프롬프트를 입력하고 실행해보세요!",
|
||||
clearAll: "전체 삭제",
|
||||
reapply: "다시 적용",
|
||||
prompt: "프롬프트:",
|
||||
range: "범위:",
|
||||
sheet: "시트:",
|
||||
actions: "실행된 액션:",
|
||||
error: "오류:",
|
||||
today: "오늘",
|
||||
yesterday: "어제",
|
||||
formula: "수식",
|
||||
style: "스타일",
|
||||
chart: "차트",
|
||||
other: "기타",
|
||||
},
|
||||
prompt: {
|
||||
placeholder:
|
||||
"AI에게 명령을 입력하세요... (예: A열의 모든 빈 셀을 0으로 채워주세요)",
|
||||
send: "전송",
|
||||
processing: "처리 중...",
|
||||
selectRange: "범위 선택",
|
||||
insertAddress: "주소 삽입",
|
||||
},
|
||||
};
|
||||
|
||||
// 영어 번역
|
||||
const enTranslations: Translations = {
|
||||
common: {
|
||||
loading: "Loading...",
|
||||
error: "Error",
|
||||
success: "Success",
|
||||
cancel: "Cancel",
|
||||
confirm: "Confirm",
|
||||
save: "Save",
|
||||
delete: "Delete",
|
||||
edit: "Edit",
|
||||
close: "Close",
|
||||
},
|
||||
account: {
|
||||
title: "Account Management",
|
||||
subtitle: "Manage your subscription and usage",
|
||||
userInfo: "User Information",
|
||||
subscriptionPlan: "Subscription Plan",
|
||||
usageSummary: "Usage Summary",
|
||||
usageAnalytics: "Usage Analytics",
|
||||
usageAnalyticsDescription: "30-day usage trends",
|
||||
settings: "Settings",
|
||||
logout: "Logout",
|
||||
email: "Email",
|
||||
name: "Name",
|
||||
joinDate: "Join Date",
|
||||
lastLogin: "Last Login",
|
||||
currentPlan: "Current Plan",
|
||||
planStatus: "Plan Status",
|
||||
nextBilling: "Next Billing",
|
||||
aiQueries: "AI Queries",
|
||||
cellCount: "Cell Count",
|
||||
language: "Language",
|
||||
historyPanelPosition: "History Panel Position",
|
||||
changeSettings: "Change Settings",
|
||||
changePlan: "Change Plan",
|
||||
cancelSubscription: "Cancel Subscription",
|
||||
confirmCancel: "Are you sure you want to cancel your subscription?",
|
||||
goToEditor: "Go to Editor",
|
||||
left: "Left",
|
||||
right: "Right",
|
||||
korean: "한국어",
|
||||
english: "English",
|
||||
usageWarning: "Usage Warning",
|
||||
dailyUsage: "Daily Usage",
|
||||
last30Days: "Last 30 Days",
|
||||
date: "Date",
|
||||
tooltipLabels: {
|
||||
aiQueries: "AI Queries",
|
||||
cellCount: "Cell Count",
|
||||
promptCount: "Prompt Count",
|
||||
editedCells: "Edited Cells",
|
||||
},
|
||||
},
|
||||
history: {
|
||||
title: "Work History",
|
||||
subtitle: "AI prompt execution history",
|
||||
empty: "No AI prompts have been executed yet.",
|
||||
emptyDescription: "Try entering and executing a prompt!",
|
||||
clearAll: "Clear All",
|
||||
reapply: "Reapply",
|
||||
prompt: "Prompt:",
|
||||
range: "Range:",
|
||||
sheet: "Sheet:",
|
||||
actions: "Executed Actions:",
|
||||
error: "Error:",
|
||||
today: "Today",
|
||||
yesterday: "Yesterday",
|
||||
formula: "Formula",
|
||||
style: "Style",
|
||||
chart: "Chart",
|
||||
other: "Other",
|
||||
},
|
||||
prompt: {
|
||||
placeholder:
|
||||
"Enter AI command... (e.g., Fill all empty cells in column A with 0)",
|
||||
send: "Send",
|
||||
processing: "Processing...",
|
||||
selectRange: "Select Range",
|
||||
insertAddress: "Insert Address",
|
||||
},
|
||||
};
|
||||
|
||||
// 번역 데이터
|
||||
const translations: Record<Language, Translations> = {
|
||||
ko: koTranslations,
|
||||
en: enTranslations,
|
||||
};
|
||||
|
||||
// i18n Context 타입
|
||||
interface I18nContextType {
|
||||
language: Language;
|
||||
setLanguage: (lang: Language) => void;
|
||||
t: Translations;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
// Context 생성
|
||||
const I18nContext = createContext<I18nContextType | undefined>(undefined);
|
||||
|
||||
// localStorage 키
|
||||
const LANGUAGE_STORAGE_KEY = "sheeteasy-language";
|
||||
|
||||
// Provider 컴포넌트
|
||||
export const I18nProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [language, setLanguageState] = useState<Language>("ko");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// 초기 언어 설정 로드
|
||||
useEffect(() => {
|
||||
const savedLanguage = localStorage.getItem(
|
||||
LANGUAGE_STORAGE_KEY,
|
||||
) as Language;
|
||||
if (savedLanguage && (savedLanguage === "ko" || savedLanguage === "en")) {
|
||||
setLanguageState(savedLanguage);
|
||||
} else {
|
||||
// 브라우저 언어 감지
|
||||
const browserLanguage = navigator.language.toLowerCase();
|
||||
if (browserLanguage.startsWith("ko")) {
|
||||
setLanguageState("ko");
|
||||
} else {
|
||||
setLanguageState("en");
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
// 언어 변경 함수
|
||||
const setLanguage = (lang: Language) => {
|
||||
setLanguageState(lang);
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, lang);
|
||||
};
|
||||
|
||||
// 현재 번역 객체
|
||||
const t = translations[language];
|
||||
|
||||
const value: I18nContextType = {
|
||||
language,
|
||||
setLanguage,
|
||||
t,
|
||||
isLoading,
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
||||
};
|
||||
|
||||
// useTranslation 훅
|
||||
export const useTranslation = () => {
|
||||
const context = useContext(I18nContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useTranslation must be used within an I18nProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// 편의 함수들
|
||||
export const getLanguageDisplayName = (lang: Language): string => {
|
||||
return lang === "ko" ? "한국어" : "English";
|
||||
};
|
||||
|
||||
export const formatDate = (date: Date, language: Language): string => {
|
||||
const locale = language === "ko" ? "ko-KR" : "en-US";
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
export const formatDateTime = (date: Date, language: Language): string => {
|
||||
const locale = language === "ko" ? "ko-KR" : "en-US";
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(date);
|
||||
};
|
||||
13
src/main.tsx
13
src/main.tsx
@@ -1,10 +1,5 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App.tsx";
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
|
||||
@@ -7,22 +7,32 @@ import type {
|
||||
} from "../types/sheet";
|
||||
import type { AIHistory } from "../types/ai";
|
||||
import type { User } from "../types/user";
|
||||
import type { TutorialSessionState, TutorialItem } from "../types/tutorial";
|
||||
import type { Language } from "../lib/i18n.tsx";
|
||||
|
||||
interface AppState {
|
||||
// 사용자 상태
|
||||
user: User | null;
|
||||
isAuthenticated: boolean;
|
||||
|
||||
// 언어 및 UI 설정
|
||||
language: Language;
|
||||
historyPanelPosition: "left" | "right";
|
||||
|
||||
// 파일 및 시트 상태
|
||||
currentFile: {
|
||||
name: string;
|
||||
size: number;
|
||||
uploadedAt: Date;
|
||||
xlsxBuffer?: ArrayBuffer; // 변환된 XLSX ArrayBuffer
|
||||
} | null;
|
||||
sheets: SheetData[];
|
||||
activeSheetId: string | null;
|
||||
selectedRange: SelectedRange | null;
|
||||
|
||||
// 셀 주소 삽입 상태 (입력창용)
|
||||
cellAddressToInsert: string | null;
|
||||
|
||||
// UI 상태
|
||||
isLoading: boolean;
|
||||
loadingMessage: string;
|
||||
@@ -34,19 +44,34 @@ interface AppState {
|
||||
|
||||
// AI 상태
|
||||
aiHistory: AIHistory[];
|
||||
isProcessingAI: boolean;
|
||||
isProcessing: boolean;
|
||||
|
||||
// 튜토리얼 상태
|
||||
tutorialSession: TutorialSessionState;
|
||||
|
||||
// 액션들
|
||||
setUser: (user: User | null) => void;
|
||||
setAuthenticated: (authenticated: boolean) => void;
|
||||
|
||||
// 언어 및 UI 설정 액션
|
||||
setLanguage: (language: Language) => void;
|
||||
setHistoryPanelPosition: (position: "left" | "right") => void;
|
||||
|
||||
setCurrentFile: (
|
||||
file: { name: string; size: number; uploadedAt: Date } | null,
|
||||
file: {
|
||||
name: string;
|
||||
size: number;
|
||||
uploadedAt: Date;
|
||||
xlsxBuffer?: ArrayBuffer;
|
||||
} | null,
|
||||
) => void;
|
||||
setSheets: (sheets: SheetData[]) => void;
|
||||
setActiveSheetId: (sheetId: string | null) => void;
|
||||
setSelectedRange: (range: SelectedRange | null) => void;
|
||||
|
||||
// 셀 주소 삽입 액션
|
||||
setCellAddressToInsert: (address: string | null) => void;
|
||||
|
||||
setLoading: (loading: boolean, message?: string) => void;
|
||||
setHistoryPanelOpen: (open: boolean) => void;
|
||||
|
||||
@@ -56,7 +81,17 @@ interface AppState {
|
||||
clearFileUploadErrors: () => void;
|
||||
|
||||
addAIHistory: (history: AIHistory) => void;
|
||||
setProcessingAI: (processing: boolean) => void;
|
||||
setProcessing: (processing: boolean) => void;
|
||||
|
||||
// 튜토리얼 액션
|
||||
startTutorial: (tutorial: TutorialItem) => void;
|
||||
stopTutorial: () => void;
|
||||
updateTutorialExecution: (
|
||||
status: "준비중" | "실행중" | "완료" | "오류",
|
||||
currentStep?: number,
|
||||
errorMessage?: string,
|
||||
) => void;
|
||||
setHighlightedCells: (cells: string[]) => void;
|
||||
|
||||
// 복합 액션들
|
||||
uploadFile: (result: FileUploadResult) => void;
|
||||
@@ -66,17 +101,27 @@ interface AppState {
|
||||
const initialState = {
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
language: "ko" as Language,
|
||||
historyPanelPosition: "right" as "left" | "right",
|
||||
currentFile: null,
|
||||
sheets: [],
|
||||
activeSheetId: null,
|
||||
selectedRange: null,
|
||||
cellAddressToInsert: null,
|
||||
isLoading: false,
|
||||
loadingMessage: "",
|
||||
isHistoryPanelOpen: false,
|
||||
error: null,
|
||||
fileUploadErrors: [],
|
||||
aiHistory: [],
|
||||
isProcessingAI: false,
|
||||
isProcessing: false,
|
||||
tutorialSession: {
|
||||
activeTutorial: null,
|
||||
execution: null,
|
||||
isAutoMode: true,
|
||||
showStepByStep: false,
|
||||
highlightedCells: [],
|
||||
},
|
||||
};
|
||||
|
||||
export const useAppStore = create<AppState>()(
|
||||
@@ -89,12 +134,26 @@ export const useAppStore = create<AppState>()(
|
||||
setAuthenticated: (authenticated) =>
|
||||
set({ isAuthenticated: authenticated }),
|
||||
|
||||
// 언어 및 UI 설정 액션
|
||||
setLanguage: (language) => {
|
||||
set({ language });
|
||||
localStorage.setItem("sheeteasy-language", language);
|
||||
},
|
||||
setHistoryPanelPosition: (position) => {
|
||||
set({ historyPanelPosition: position });
|
||||
localStorage.setItem("sheeteasy-history-panel-position", position);
|
||||
},
|
||||
|
||||
// 파일 및 시트 액션
|
||||
setCurrentFile: (file) => set({ currentFile: file }),
|
||||
setSheets: (sheets) => set({ sheets }),
|
||||
setActiveSheetId: (sheetId) => set({ activeSheetId: sheetId }),
|
||||
setSelectedRange: (range) => set({ selectedRange: range }),
|
||||
|
||||
// 셀 주소 삽입 액션
|
||||
setCellAddressToInsert: (address) =>
|
||||
set({ cellAddressToInsert: address }),
|
||||
|
||||
// UI 액션
|
||||
setLoading: (loading, message = "") =>
|
||||
set({
|
||||
@@ -116,7 +175,59 @@ export const useAppStore = create<AppState>()(
|
||||
set((state) => ({
|
||||
aiHistory: [history, ...state.aiHistory].slice(0, 50), // 최대 50개 유지
|
||||
})),
|
||||
setProcessingAI: (processing) => set({ isProcessingAI: processing }),
|
||||
setProcessing: (processing) => set({ isProcessing: processing }),
|
||||
|
||||
// 튜토리얼 액션 구현
|
||||
startTutorial: (tutorial) =>
|
||||
set({
|
||||
tutorialSession: {
|
||||
activeTutorial: tutorial,
|
||||
execution: {
|
||||
tutorialId: tutorial.metadata.id,
|
||||
status: "준비중",
|
||||
currentStep: 0,
|
||||
totalSteps: 3, // 데이터 생성 -> 프롬프트 실행 -> 결과 확인
|
||||
},
|
||||
isAutoMode: true,
|
||||
showStepByStep: false,
|
||||
highlightedCells: [],
|
||||
},
|
||||
}),
|
||||
|
||||
stopTutorial: () =>
|
||||
set({
|
||||
tutorialSession: {
|
||||
activeTutorial: null,
|
||||
execution: null,
|
||||
isAutoMode: true,
|
||||
showStepByStep: false,
|
||||
highlightedCells: [],
|
||||
},
|
||||
}),
|
||||
|
||||
updateTutorialExecution: (status, currentStep, errorMessage) =>
|
||||
set((state) => ({
|
||||
tutorialSession: {
|
||||
...state.tutorialSession,
|
||||
execution: state.tutorialSession.execution
|
||||
? {
|
||||
...state.tutorialSession.execution,
|
||||
status,
|
||||
currentStep:
|
||||
currentStep ?? state.tutorialSession.execution.currentStep,
|
||||
errorMessage,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
})),
|
||||
|
||||
setHighlightedCells: (cells) =>
|
||||
set((state) => ({
|
||||
tutorialSession: {
|
||||
...state.tutorialSession,
|
||||
highlightedCells: cells,
|
||||
},
|
||||
})),
|
||||
|
||||
// 복합 액션
|
||||
uploadFile: (result) => {
|
||||
@@ -126,6 +237,7 @@ export const useAppStore = create<AppState>()(
|
||||
name: result.fileName || "Unknown",
|
||||
size: result.fileSize || 0,
|
||||
uploadedAt: new Date(),
|
||||
xlsxBuffer: result.xlsxBuffer,
|
||||
},
|
||||
sheets: result.data,
|
||||
activeSheetId: result.data[0]?.id || null,
|
||||
|
||||
@@ -48,3 +48,23 @@ export interface AIHistory {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// 히스토리 관련 타입 추가
|
||||
export interface HistoryEntry {
|
||||
id: string;
|
||||
timestamp: Date;
|
||||
prompt: string;
|
||||
range: string;
|
||||
sheetName: string;
|
||||
actions: AIAction[];
|
||||
status: "success" | "error" | "pending";
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface HistoryPanelProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
history: HistoryEntry[];
|
||||
onReapply?: (entry: HistoryEntry) => void;
|
||||
onClear?: () => void;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface SheetData {
|
||||
name: string;
|
||||
data: any[][]; // Luckysheet 데이터 형식
|
||||
config?: LuckysheetConfig;
|
||||
xlsxBuffer?: ArrayBuffer; // 변환된 XLSX ArrayBuffer
|
||||
}
|
||||
|
||||
export interface LuckysheetConfig {
|
||||
@@ -30,6 +31,8 @@ export interface FileUploadResult {
|
||||
error?: string;
|
||||
fileName?: string;
|
||||
fileSize?: number;
|
||||
file?: File;
|
||||
xlsxBuffer?: ArrayBuffer; // 변환된 XLSX ArrayBuffer
|
||||
}
|
||||
|
||||
export interface ExportOptions {
|
||||
|
||||
97
src/types/tutorial.ts
Normal file
97
src/types/tutorial.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
// Excel 튜토리얼 시스템 타입 정의
|
||||
|
||||
export enum TutorialCategory {
|
||||
BASIC_MATH = "basic_math",
|
||||
LOGICAL = "logical",
|
||||
LOOKUP = "lookup",
|
||||
TEXT = "text",
|
||||
DATE_TIME = "date_time",
|
||||
STATISTICAL = "statistical",
|
||||
ADVANCED = "advanced",
|
||||
}
|
||||
|
||||
export interface TutorialSampleData {
|
||||
cellAddress: string;
|
||||
value: string | number;
|
||||
formula?: string;
|
||||
style?: {
|
||||
backgroundColor?: string;
|
||||
fontWeight?: "bold" | "normal";
|
||||
color?: string;
|
||||
textAlign?: "left" | "center" | "right";
|
||||
};
|
||||
}
|
||||
|
||||
export interface TutorialMetadata {
|
||||
id: string;
|
||||
title: string;
|
||||
category: TutorialCategory;
|
||||
difficulty: "초급" | "중급" | "고급";
|
||||
estimatedTime: string; // "2분", "5분" 등
|
||||
description: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface TutorialItem {
|
||||
metadata: TutorialMetadata;
|
||||
functionName: string;
|
||||
prompt: string;
|
||||
targetCell: string;
|
||||
expectedResult: string | number;
|
||||
sampleData: TutorialSampleData[];
|
||||
beforeAfterDemo: {
|
||||
before: TutorialSampleData[];
|
||||
after: TutorialSampleData[];
|
||||
};
|
||||
explanation: string;
|
||||
relatedFunctions?: string[];
|
||||
}
|
||||
|
||||
export interface TutorialExecution {
|
||||
tutorialId: string;
|
||||
status: "준비중" | "실행중" | "완료" | "오류";
|
||||
currentStep: number;
|
||||
totalSteps: number;
|
||||
errorMessage?: string;
|
||||
executionTime?: number;
|
||||
}
|
||||
|
||||
export interface TutorialSessionState {
|
||||
activeTutorial: TutorialItem | null;
|
||||
execution: TutorialExecution | null;
|
||||
isAutoMode: boolean;
|
||||
showStepByStep: boolean;
|
||||
highlightedCells: string[];
|
||||
}
|
||||
|
||||
// 튜토리얼 섹션 UI 관련 타입
|
||||
export interface TutorialCardProps {
|
||||
tutorial: TutorialItem;
|
||||
onClick: (tutorial: TutorialItem) => void;
|
||||
isActive?: boolean;
|
||||
showBadge?: boolean;
|
||||
}
|
||||
|
||||
export interface TutorialSectionProps {
|
||||
onTutorialSelect?: (tutorial: TutorialItem) => void;
|
||||
selectedCategory?: TutorialCategory;
|
||||
onCategoryChange?: (category: TutorialCategory | undefined) => void;
|
||||
}
|
||||
|
||||
// 실시간 데모 관련 타입
|
||||
export interface LiveDemoConfig {
|
||||
autoExecute: boolean;
|
||||
stepDelay: number; // 밀리초
|
||||
highlightDuration: number;
|
||||
showFormula: boolean;
|
||||
enableAnimation: boolean;
|
||||
}
|
||||
|
||||
export interface TutorialResult {
|
||||
success: boolean;
|
||||
executedFormula: string;
|
||||
resultValue: string | number;
|
||||
executionTime: number;
|
||||
cellsModified: string[];
|
||||
error?: string;
|
||||
}
|
||||
@@ -1,440 +0,0 @@
|
||||
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"); // 모킹 데이터의 실제 시트명
|
||||
});
|
||||
});
|
||||
});
|
||||
377
src/utils/aiProcessor.ts
Normal file
377
src/utils/aiProcessor.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* AI 프로세서 - AI와의 통신 및 셀 적용 로직 처리
|
||||
* - 프롬프트를 AI에게 전송하고 응답 받기
|
||||
* - 응답 데이터를 파싱하여 각 셀에 수식 적용
|
||||
* - 10ms 인터벌로 순차적 셀 적용
|
||||
*/
|
||||
|
||||
// presets 환경에서는 FUniver import 불필요 (deprecated)
|
||||
// import { FUniver } from "@univerjs/core/facade";
|
||||
import { useAppStore } from "../stores/useAppStore";
|
||||
|
||||
// AI 응답 데이터 타입 정의
|
||||
export interface AIResponse {
|
||||
targetCell: string;
|
||||
formula: string;
|
||||
}
|
||||
|
||||
// AI 프로세서 결과 타입
|
||||
export interface ProcessResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
appliedCells?: string[];
|
||||
errors?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 프로세서 클래스
|
||||
* - SRP: AI 통신과 셀 적용만 담당
|
||||
* - 테스트 모드와 실제 AI 모드 지원
|
||||
*/
|
||||
export class AIProcessor {
|
||||
private isProcessing = false;
|
||||
|
||||
/**
|
||||
* 현재 처리 중인지 확인
|
||||
*/
|
||||
public isCurrentlyProcessing(): boolean {
|
||||
return this.isProcessing;
|
||||
}
|
||||
|
||||
/**
|
||||
* 테스트용 고정 데이터 생성
|
||||
* 실제 AI 연동 전 테스트를 위한 더미 데이터
|
||||
*/
|
||||
private generateTestData(): AIResponse[] {
|
||||
return [
|
||||
{
|
||||
targetCell: "E13",
|
||||
formula: "=C13+D13",
|
||||
},
|
||||
{
|
||||
targetCell: "E14",
|
||||
formula: "=C14+D14",
|
||||
},
|
||||
{
|
||||
targetCell: "E15",
|
||||
formula: "=C15+D15",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* AI에게 프롬프트 전송 (현재는 테스트 모드)
|
||||
* @param prompt - 사용자 입력 프롬프트
|
||||
* @param isTestMode - 테스트 모드 여부 (기본값: true)
|
||||
*/
|
||||
private async sendToAI(
|
||||
prompt: string,
|
||||
isTestMode: boolean = true,
|
||||
): Promise<AIResponse[]> {
|
||||
console.log("🤖 AI 프로세싱 시작:", prompt);
|
||||
|
||||
if (isTestMode) {
|
||||
console.log("🧪 테스트 모드: 고정 데이터 사용");
|
||||
|
||||
// 실제 AI 호출을 시뮬레이션하기 위한 딜레이
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
|
||||
return this.generateTestData();
|
||||
}
|
||||
|
||||
// TODO: 실제 AI API 호출 로직
|
||||
// const response = await fetch('/api/ai', {
|
||||
// method: 'POST',
|
||||
// headers: { 'Content-Type': 'application/json' },
|
||||
// body: JSON.stringify({ prompt })
|
||||
// });
|
||||
// return await response.json();
|
||||
|
||||
throw new Error("실제 AI API 연동은 아직 구현되지 않았습니다.");
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 셀에 수식 적용
|
||||
* @param univerAPI - FUniver API 인스턴스
|
||||
* @param targetCell - 대상 셀 주소 (예: "E13")
|
||||
* @param formula - 적용할 수식 (예: "=C13+D13")
|
||||
*/
|
||||
private async applyCellFormula(
|
||||
univerAPI: any,
|
||||
targetCell: string,
|
||||
formula: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
if (!univerAPI) {
|
||||
throw new Error("Univer API가 생성되지 않았습니다.");
|
||||
}
|
||||
|
||||
const activeWorkbook = univerAPI.getActiveWorkbook();
|
||||
if (!activeWorkbook) {
|
||||
throw new Error("활성 워크북을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
const activeSheet = activeWorkbook.getActiveSheet();
|
||||
if (!activeSheet) {
|
||||
throw new Error("활성 시트를 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
// 셀 주소를 행/열 좌표로 변환
|
||||
const cellCoords = this.parseCellAddress(targetCell);
|
||||
|
||||
// 👉 셀 선택(하이라이트) 추가
|
||||
// const selectionService = activeSheet.getSelection?.();
|
||||
// if (selectionService?.setSelections) {
|
||||
// selectionService.setSelections([
|
||||
// {
|
||||
// startRow: cellCoords.row,
|
||||
// startColumn: cellCoords.col,
|
||||
// endRow: cellCoords.row,
|
||||
// endColumn: cellCoords.col,
|
||||
// },
|
||||
// ]);
|
||||
// }
|
||||
|
||||
// 👉 셀 선택 (Facade API 기반)
|
||||
try {
|
||||
const activeWorkbook = univerAPI.getActiveWorkbook();
|
||||
if (activeWorkbook) {
|
||||
const activeSheet = activeWorkbook.getActiveSheet();
|
||||
if (activeSheet) {
|
||||
try {
|
||||
// Facade API의 표준 방식: Range를 통한 셀 선택
|
||||
const cellRange = activeSheet.getRange(targetCell);
|
||||
if (cellRange) {
|
||||
// 다양한 선택 방법 시도
|
||||
if (typeof cellRange.select === "function") {
|
||||
cellRange.select();
|
||||
console.log(`🎯 셀 ${targetCell} 선택 완료 (range.select)`);
|
||||
} else if (
|
||||
typeof activeWorkbook.setActiveRange === "function"
|
||||
) {
|
||||
activeWorkbook.setActiveRange(cellRange);
|
||||
console.log(`🎯 셀 ${targetCell} 활성 범위 설정 완료`);
|
||||
} else if (typeof activeSheet.activate === "function") {
|
||||
// 워크시트 활성화 후 셀 포커스
|
||||
activeSheet.activate();
|
||||
console.log(`🎯 워크시트 활성화 후 셀 ${targetCell} 포커스`);
|
||||
} else {
|
||||
console.log(`🔍 지원하는 선택 메소드를 찾을 수 없습니다.`);
|
||||
|
||||
// 디버깅을 위해 사용 가능한 메소드 출력
|
||||
const rangeMethods = Object.getOwnPropertyNames(cellRange)
|
||||
.filter(
|
||||
(name) => typeof (cellRange as any)[name] === "function",
|
||||
)
|
||||
.filter(
|
||||
(name) =>
|
||||
name.toLowerCase().includes("select") ||
|
||||
name.toLowerCase().includes("active"),
|
||||
);
|
||||
|
||||
const workbookMethods = Object.getOwnPropertyNames(
|
||||
activeWorkbook,
|
||||
)
|
||||
.filter(
|
||||
(name) =>
|
||||
typeof (activeWorkbook as any)[name] === "function",
|
||||
)
|
||||
.filter(
|
||||
(name) =>
|
||||
name.toLowerCase().includes("select") ||
|
||||
name.toLowerCase().includes("active"),
|
||||
);
|
||||
|
||||
console.log(`🔍 Range 선택 관련 메소드:`, rangeMethods);
|
||||
console.log(`🔍 Workbook 선택 관련 메소드:`, workbookMethods);
|
||||
}
|
||||
} else {
|
||||
console.warn(`⚠️ ${targetCell} 범위를 가져올 수 없음`);
|
||||
}
|
||||
} catch (rangeError) {
|
||||
console.warn(`⚠️ Range 기반 선택 실패:`, rangeError);
|
||||
|
||||
// 폴백: 좌표 기반 시도
|
||||
try {
|
||||
if (typeof activeSheet.setActiveCell === "function") {
|
||||
activeSheet.setActiveCell(cellCoords.row, cellCoords.col);
|
||||
console.log(`🎯 셀 ${targetCell} 좌표 기반 선택 완료`);
|
||||
} else {
|
||||
console.log(`🔍 setActiveCell 메소드 없음`);
|
||||
}
|
||||
} catch (coordError) {
|
||||
console.warn(`⚠️ 좌표 기반 선택도 실패:`, coordError);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn(`⚠️ ActiveSheet를 가져올 수 없음`);
|
||||
}
|
||||
} else {
|
||||
console.warn(`⚠️ ActiveWorkbook을 가져올 수 없음`);
|
||||
}
|
||||
} catch (selectionError) {
|
||||
console.warn(`⚠️ 셀 ${targetCell} 선택 전체 실패:`, selectionError);
|
||||
}
|
||||
|
||||
// 셀에 수식 설정
|
||||
const range = activeSheet.getRange(cellCoords.row, cellCoords.col, 1, 1);
|
||||
range.setValue(formula);
|
||||
|
||||
console.log(`✅ 셀 ${targetCell} 수식 적용 및 하이라이트 완료`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`❌ 셀 ${targetCell} 수식 적용 실패:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 셀 주소를 행/열 좌표로 변환
|
||||
* @param cellAddress - 셀 주소 (예: "E13")
|
||||
* @returns 행/열 좌표 객체
|
||||
*/
|
||||
private parseCellAddress(cellAddress: string): { row: number; col: number } {
|
||||
const match = cellAddress.match(/^([A-Z]+)(\d+)$/);
|
||||
if (!match) {
|
||||
throw new Error(`잘못된 셀 주소 형식: ${cellAddress}`);
|
||||
}
|
||||
|
||||
const colLetters = match[1];
|
||||
const rowNumber = parseInt(match[2]);
|
||||
|
||||
// 컬럼 문자를 숫자로 변환 (A=0, B=1, ..., Z=25, AA=26, ...)
|
||||
let col = 0;
|
||||
for (let i = 0; i < colLetters.length; i++) {
|
||||
col = col * 26 + (colLetters.charCodeAt(i) - 65 + 1);
|
||||
}
|
||||
col -= 1; // 0-based 인덱스로 변환
|
||||
|
||||
return {
|
||||
row: rowNumber - 1, // 0-based 인덱스로 변환
|
||||
col: col,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 메인 프로세싱 함수
|
||||
* @param prompt - 사용자 프롬프트
|
||||
* @param isTestMode - 테스트 모드 여부
|
||||
*/
|
||||
public async processPrompt(
|
||||
prompt: string,
|
||||
isTestMode: boolean = true,
|
||||
): Promise<ProcessResult> {
|
||||
if (this.isProcessing) {
|
||||
return {
|
||||
success: false,
|
||||
message: "이미 처리 중입니다. 잠시 후 다시 시도해주세요.",
|
||||
};
|
||||
}
|
||||
|
||||
// presets 환경에서는 전역 상태에서 univerAPI를 직접 가져옴
|
||||
const globalState = (window as any)["__GLOBAL_UNIVER_STATE__"];
|
||||
if (!globalState?.instance || !globalState?.univerAPI) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Univer 인스턴스 또는 API를 찾을 수 없습니다.",
|
||||
};
|
||||
}
|
||||
const univerAPI = globalState.univerAPI;
|
||||
|
||||
this.isProcessing = true;
|
||||
// zustand store에 연산 시작 알림
|
||||
try {
|
||||
useAppStore.getState().setProcessing(true);
|
||||
} catch (e) {
|
||||
/* SSR/테스트 환경 무시 */
|
||||
}
|
||||
const appliedCells: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
try {
|
||||
console.log("🚀 AI 프롬프트 처리 시작");
|
||||
console.log("📝 프롬프트:", prompt);
|
||||
|
||||
// 1. AI에게 프롬프트 전송
|
||||
const aiResponses = await this.sendToAI(prompt, isTestMode);
|
||||
console.log("🤖 AI 응답 받음:", aiResponses);
|
||||
|
||||
if (!Array.isArray(aiResponses) || aiResponses.length === 0) {
|
||||
throw new Error("AI 응답이 올바르지 않습니다.");
|
||||
}
|
||||
|
||||
// 2. 각 셀에 10ms 간격으로 순차 적용
|
||||
console.log(`📊 총 ${aiResponses.length}개 셀에 수식 적용 시작`);
|
||||
|
||||
for (let i = 0; i < aiResponses.length; i++) {
|
||||
const response = aiResponses[i];
|
||||
|
||||
try {
|
||||
const success = await this.applyCellFormula(
|
||||
univerAPI,
|
||||
response.targetCell,
|
||||
response.formula,
|
||||
);
|
||||
|
||||
if (success) {
|
||||
appliedCells.push(response.targetCell);
|
||||
console.log(
|
||||
`✅ [${i + 1}/${aiResponses.length}] ${response.targetCell} 적용 완료`,
|
||||
);
|
||||
} else {
|
||||
errors.push(`${response.targetCell}: 수식 적용 실패`);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = `${response.targetCell}: ${error instanceof Error ? error.message : "알 수 없는 오류"}`;
|
||||
errors.push(errorMsg);
|
||||
console.error(`❌ [${i + 1}/${aiResponses.length}] ${errorMsg}`);
|
||||
}
|
||||
|
||||
// 마지막 항목이 아니면 10ms 대기
|
||||
if (i < aiResponses.length - 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
const successCount = appliedCells.length;
|
||||
const errorCount = errors.length;
|
||||
|
||||
console.log(`🎉 처리 완료: 성공 ${successCount}개, 실패 ${errorCount}개`);
|
||||
|
||||
return {
|
||||
success: successCount > 0,
|
||||
message: `총 ${aiResponses.length}개 중 ${successCount}개 셀에 수식을 성공적으로 적용했습니다.${errorCount > 0 ? ` (실패: ${errorCount}개)` : ""}`,
|
||||
appliedCells,
|
||||
errors: errorCount > 0 ? errors : undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "알 수 없는 오류가 발생했습니다.";
|
||||
console.error("❌ AI 프롬프트 처리 실패:", error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: `처리 실패: ${errorMessage}`,
|
||||
appliedCells: appliedCells.length > 0 ? appliedCells : undefined,
|
||||
errors: [...errors, errorMessage],
|
||||
};
|
||||
} finally {
|
||||
this.isProcessing = false;
|
||||
// zustand store에 연산 종료 알림
|
||||
try {
|
||||
useAppStore.getState().setProcessing(false);
|
||||
} catch (e) {
|
||||
/* SSR/테스트 환경 무시 */
|
||||
}
|
||||
console.log("🏁 AI 프롬프트 처리 종료");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 전역 AI 프로세서 인스턴스
|
||||
* - 싱글톤 패턴으로 관리
|
||||
*/
|
||||
export const aiProcessor = new AIProcessor();
|
||||
182
src/utils/cellSelectionHandler.ts
Normal file
182
src/utils/cellSelectionHandler.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
// Facade API imports - 공식 문서 방식 (필요한 기능만 선택적 import)
|
||||
import "@univerjs/sheets/facade";
|
||||
import "@univerjs/sheets-ui/facade";
|
||||
|
||||
import { FUniver } from "@univerjs/core/facade";
|
||||
import { coordsToAddress, rangeToAddress } from "./cellUtils";
|
||||
import { useAppStore } from "../stores/useAppStore";
|
||||
|
||||
/**
|
||||
* 셀 선택 이벤트 핸들러 클래스
|
||||
* - Univer 셀 선택 이벤트를 감지하고 처리
|
||||
* - 선택된 셀 주소를 프롬프트 입력창에 자동 삽입
|
||||
* - SRP: 셀 선택과 관련된 로직만 담당
|
||||
*/
|
||||
export class CellSelectionHandler {
|
||||
private disposable: any = null;
|
||||
private univerAPI: any = null;
|
||||
|
||||
/**
|
||||
* 셀 선택 이벤트 리스너 초기화
|
||||
* @param univer - Univer 인스턴스
|
||||
*/
|
||||
public initialize(univer: any): void {
|
||||
try {
|
||||
// Univer CE 공식 방식으로 FUniver API 생성
|
||||
this.univerAPI = FUniver.newAPI(univer);
|
||||
|
||||
// 셀 선택 변경 이벤트 구독 - 공식 문서 패턴 사용
|
||||
this.disposable = this.univerAPI.addEvent(
|
||||
this.univerAPI.Event.SelectionChanged,
|
||||
this.handleSelectionChange.bind(this),
|
||||
);
|
||||
|
||||
console.log("📌 CellSelectionHandler: 셀 선택 이벤트 리스너 등록 완료");
|
||||
} catch (error) {
|
||||
console.error("❌ CellSelectionHandler 초기화 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 셀 선택 변경 이벤트 핸들러
|
||||
* @param params - 선택 변경 이벤트 파라미터
|
||||
*/
|
||||
private handleSelectionChange(params: any): void {
|
||||
try {
|
||||
console.log("🎯 셀 선택 변경 감지 - 전체 파라미터:", params);
|
||||
console.log("🔍 파라미터 타입:", typeof params);
|
||||
console.log("🔍 파라미터 키들:", Object.keys(params || {}));
|
||||
|
||||
// 다양한 경로로 선택 정보 탐색
|
||||
let selectionData = null;
|
||||
let cellAddress: string | null = null;
|
||||
let selectionType: "single" | "range" = "single";
|
||||
|
||||
// 방법 1: params.selections 경로
|
||||
if (params && params.selections && params.selections.length > 0) {
|
||||
console.log("📍 방법 1: params.selections 경로에서 데이터 발견");
|
||||
selectionData = params.selections[0];
|
||||
console.log("📍 selections[0]:", selectionData);
|
||||
console.log("📍 selections[0] 키들:", Object.keys(selectionData || {}));
|
||||
}
|
||||
|
||||
// 방법 2: params.selection 경로 (단수형)
|
||||
else if (params && params.selection) {
|
||||
console.log("📍 방법 2: params.selection 경로에서 데이터 발견");
|
||||
selectionData = params.selection;
|
||||
console.log("📍 selection:", selectionData);
|
||||
console.log("📍 selection 키들:", Object.keys(selectionData || {}));
|
||||
}
|
||||
|
||||
// 방법 3: params 자체가 선택 데이터인 경우
|
||||
else if (
|
||||
params &&
|
||||
(params.startRow !== undefined || params.row !== undefined)
|
||||
) {
|
||||
console.log("📍 방법 3: params 자체가 선택 데이터");
|
||||
selectionData = params;
|
||||
console.log("📍 직접 params:", selectionData);
|
||||
}
|
||||
|
||||
// 선택 데이터에서 좌표 추출
|
||||
if (selectionData) {
|
||||
let startRow, startCol, endRow, endCol;
|
||||
|
||||
// 패턴 A: range 객체 (기존 방식)
|
||||
if (selectionData.range) {
|
||||
console.log("🎯 패턴 A: range 객체 발견");
|
||||
const range = selectionData.range;
|
||||
startRow = range.startRow;
|
||||
startCol = range.startColumn || range.startCol;
|
||||
endRow = range.endRow;
|
||||
endCol = range.endColumn || range.endCol;
|
||||
}
|
||||
// 패턴 B: 직접 좌표 (startRow, startCol 등)
|
||||
else if (selectionData.startRow !== undefined) {
|
||||
console.log("🎯 패턴 B: 직접 좌표 발견 (startRow/startCol)");
|
||||
startRow = selectionData.startRow;
|
||||
startCol = selectionData.startColumn || selectionData.startCol;
|
||||
endRow = selectionData.endRow || startRow;
|
||||
endCol = selectionData.endColumn || selectionData.endCol || startCol;
|
||||
}
|
||||
// 패턴 C: row, col 형태
|
||||
else if (selectionData.row !== undefined) {
|
||||
console.log("🎯 패턴 C: row/col 형태 발견");
|
||||
startRow = selectionData.row;
|
||||
startCol = selectionData.col || selectionData.column || 0;
|
||||
endRow = selectionData.endRow || startRow;
|
||||
endCol = selectionData.endCol || selectionData.endColumn || startCol;
|
||||
}
|
||||
|
||||
// 좌표가 발견되었으면 주소 변환
|
||||
if (startRow !== undefined && startCol !== undefined) {
|
||||
console.log(
|
||||
`📍 좌표 발견: (${startRow}, ${startCol}) → (${endRow}, ${endCol})`,
|
||||
);
|
||||
|
||||
if (startRow === endRow && startCol === endCol) {
|
||||
// 단일 셀 선택
|
||||
cellAddress = coordsToAddress(startRow, startCol);
|
||||
selectionType = "single";
|
||||
console.log(
|
||||
`📍 단일 셀 선택: ${cellAddress} (row: ${startRow}, col: ${startCol})`,
|
||||
);
|
||||
} else {
|
||||
// 범위 선택
|
||||
cellAddress = rangeToAddress({
|
||||
startRow,
|
||||
startCol,
|
||||
endRow: endRow || startRow,
|
||||
endCol: endCol || startCol,
|
||||
});
|
||||
selectionType = "range";
|
||||
console.log(
|
||||
`📍 범위 선택: ${cellAddress} (${startRow},${startCol} → ${endRow},${endCol})`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`🎯 선택 타입: ${selectionType}, 주소: ${cellAddress}`);
|
||||
|
||||
// zustand store에 셀 주소 저장
|
||||
const appStore = useAppStore.getState();
|
||||
appStore.setCellAddressToInsert(cellAddress);
|
||||
|
||||
console.log(`✅ 셀 주소 "${cellAddress}" 스토어에 저장 완료`);
|
||||
} else {
|
||||
console.warn("⚠️ 좌표 정보를 찾을 수 없습니다:");
|
||||
console.warn("⚠️ selectionData:", selectionData);
|
||||
}
|
||||
} else {
|
||||
console.warn("⚠️ 선택 데이터를 찾을 수 없습니다:");
|
||||
console.warn("⚠️ 전체 params:", params);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 셀 선택 이벤트 처리 실패:", error);
|
||||
console.error("❌ 이벤트 파라미터:", params);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 리스너 정리
|
||||
*/
|
||||
public dispose(): void {
|
||||
if (this.disposable) {
|
||||
this.disposable.dispose();
|
||||
this.disposable = null;
|
||||
console.log("🧹 CellSelectionHandler: 이벤트 리스너 정리 완료");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 핸들러가 활성 상태인지 확인
|
||||
*/
|
||||
public isActive(): boolean {
|
||||
return this.disposable !== null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 전역 셀 선택 핸들러 인스턴스
|
||||
* - 싱글톤 패턴으로 관리
|
||||
*/
|
||||
export const cellSelectionHandler = new CellSelectionHandler();
|
||||
53
src/utils/cellUtils.ts
Normal file
53
src/utils/cellUtils.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 셀 좌표 관련 유틸리티 함수들
|
||||
*/
|
||||
|
||||
/**
|
||||
* 컬럼 번호를 Excel 컬럼 문자로 변환 (0-based)
|
||||
* 예: 0 -> A, 1 -> B, 25 -> Z, 26 -> AA
|
||||
*/
|
||||
export const numberToColumnLetter = (colNum: number): string => {
|
||||
let result = "";
|
||||
let num = colNum;
|
||||
|
||||
while (num >= 0) {
|
||||
result = String.fromCharCode(65 + (num % 26)) + result;
|
||||
num = Math.floor(num / 26) - 1;
|
||||
if (num < 0) break;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* row/col 좌표를 Excel 셀 주소로 변환 (0-based)
|
||||
* 예: (0, 0) -> A1, (19, 1) -> B20
|
||||
*/
|
||||
export const coordsToAddress = (row: number, col: number): string => {
|
||||
const columnLetter = numberToColumnLetter(col);
|
||||
const rowNumber = row + 1; // Excel은 1-based
|
||||
return `${columnLetter}${rowNumber}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* CellRange를 셀 주소 문자열로 변환
|
||||
* 단일 셀인 경우: "A1"
|
||||
* 범위인 경우: "A1:B3"
|
||||
*/
|
||||
export const rangeToAddress = (range: {
|
||||
startRow: number;
|
||||
startCol: number;
|
||||
endRow: number;
|
||||
endCol: number;
|
||||
}): string => {
|
||||
const startAddress = coordsToAddress(range.startRow, range.startCol);
|
||||
|
||||
// 단일 셀인 경우
|
||||
if (range.startRow === range.endRow && range.startCol === range.endCol) {
|
||||
return startAddress;
|
||||
}
|
||||
|
||||
// 범위인 경우
|
||||
const endAddress = coordsToAddress(range.endRow, range.endCol);
|
||||
return `${startAddress}:${endAddress}`;
|
||||
};
|
||||
@@ -1,945 +0,0 @@
|
||||
import * as XLSX from "xlsx";
|
||||
import * as LuckyExcel from "luckyexcel";
|
||||
import type { SheetData, FileUploadResult } from "../types/sheet";
|
||||
|
||||
/**
|
||||
* 파일 처리 관련 유틸리티 - 개선된 버전
|
||||
* - 모든 파일 형식을 SheetJS를 통해 읽은 후 XLSX로 변환
|
||||
* - 변환된 XLSX 파일을 LuckyExcel로 전달
|
||||
* - 안정적인 한글 지원 및 에러 처리
|
||||
*/
|
||||
|
||||
// 지원되는 파일 타입
|
||||
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: "워크북에 시트가 없습니다" };
|
||||
}
|
||||
|
||||
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로 파일을 읽고 XLSX로 변환한 뒤 LuckyExcel로 처리
|
||||
*/
|
||||
async function processFileWithSheetJSToXLSX(file: File): Promise<SheetData[]> {
|
||||
console.log("📊 SheetJS → XLSX → LuckyExcel 파이프라인 시작...");
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const fileName = file.name.toLowerCase();
|
||||
const isCSV = fileName.endsWith(".csv");
|
||||
const isXLS = fileName.endsWith(".xls");
|
||||
const isXLSX = fileName.endsWith(".xlsx");
|
||||
|
||||
// 1단계: SheetJS로 파일 읽기
|
||||
let workbook: any;
|
||||
|
||||
try {
|
||||
if (isCSV) {
|
||||
// CSV 파일 처리 - UTF-8 디코딩 후 읽기
|
||||
console.log("📄 CSV 파일을 SheetJS로 읽는 중...");
|
||||
const text = new TextDecoder("utf-8").decode(arrayBuffer);
|
||||
workbook = XLSX.read(text, {
|
||||
type: "string",
|
||||
codepage: 65001, // UTF-8
|
||||
raw: false,
|
||||
});
|
||||
} else {
|
||||
// XLS/XLSX 파일 처리 - 관대한 옵션으로 읽기
|
||||
console.log(`📊 ${isXLS ? "XLS" : "XLSX"} 파일을 SheetJS로 읽는 중...`);
|
||||
workbook = XLSX.read(arrayBuffer, {
|
||||
type: "array",
|
||||
cellText: true,
|
||||
sheetStubs: true,
|
||||
WTF: true,
|
||||
bookSheets: false,
|
||||
codepage: 65001,
|
||||
raw: false,
|
||||
});
|
||||
|
||||
// Sheets가 없고 SheetNames만 있는 경우 재시도
|
||||
if (workbook.SheetNames?.length > 0 && !workbook.Sheets) {
|
||||
console.log("⚠️ Sheets 속성이 없어서 재읽기 시도...");
|
||||
workbook = XLSX.read(arrayBuffer, {
|
||||
type: "array",
|
||||
cellText: true,
|
||||
sheetStubs: true,
|
||||
WTF: true,
|
||||
bookSheets: true, // 강제로 시트 읽기
|
||||
codepage: 65001,
|
||||
raw: false,
|
||||
});
|
||||
|
||||
// 여전히 실패하면 수동으로 빈 시트 생성
|
||||
if (!workbook.Sheets && workbook.SheetNames?.length > 0) {
|
||||
console.log("⚠️ 수동으로 빈 시트 생성...");
|
||||
workbook.Sheets = {};
|
||||
workbook.SheetNames.forEach((sheetName: string) => {
|
||||
workbook.Sheets[sheetName] = {
|
||||
"!ref": "A1:A1",
|
||||
A1: { v: "", t: "s" },
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (readError) {
|
||||
console.error("❌ SheetJS 파일 읽기 실패:", readError);
|
||||
throw new Error(
|
||||
`파일을 읽을 수 없습니다: ${readError instanceof Error ? readError.message : readError}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 파일 버퍼 크기 검증
|
||||
if (arrayBuffer.byteLength === 0) {
|
||||
throw new Error("파일이 비어있습니다.");
|
||||
}
|
||||
|
||||
// 워크북 null 체크
|
||||
if (!workbook) {
|
||||
throw new Error("워크북을 생성할 수 없습니다.");
|
||||
}
|
||||
|
||||
// workbook.Sheets 존재 및 타입 검증
|
||||
if (!workbook.Sheets || typeof workbook.Sheets !== "object") {
|
||||
throw new Error("유효한 시트가 없습니다.");
|
||||
}
|
||||
|
||||
// workbook.SheetNames 배열 검증
|
||||
if (!Array.isArray(workbook.SheetNames) || workbook.SheetNames.length === 0) {
|
||||
throw new Error("시트 이름 정보가 없습니다.");
|
||||
}
|
||||
|
||||
console.log("✅ SheetJS 워크북 읽기 성공:", {
|
||||
sheetNames: workbook.SheetNames,
|
||||
sheetCount: workbook.SheetNames.length,
|
||||
});
|
||||
|
||||
// 2단계: 워크북을 XLSX ArrayBuffer로 변환
|
||||
let xlsxArrayBuffer: ArrayBuffer;
|
||||
try {
|
||||
console.log("🔄 XLSX 형식으로 변환 중...");
|
||||
const xlsxData = XLSX.write(workbook, {
|
||||
type: "array",
|
||||
bookType: "xlsx",
|
||||
compression: true,
|
||||
});
|
||||
// xlsxData는 Uint8Array이므로 ArrayBuffer로 변환
|
||||
if (xlsxData instanceof Uint8Array) {
|
||||
xlsxArrayBuffer = xlsxData.buffer.slice(
|
||||
xlsxData.byteOffset,
|
||||
xlsxData.byteOffset + xlsxData.byteLength,
|
||||
);
|
||||
} else if (xlsxData instanceof ArrayBuffer) {
|
||||
xlsxArrayBuffer = xlsxData;
|
||||
} else {
|
||||
// 다른 타입의 경우 새 ArrayBuffer 생성
|
||||
xlsxArrayBuffer = new ArrayBuffer(xlsxData.length);
|
||||
const view = new Uint8Array(xlsxArrayBuffer);
|
||||
for (let i = 0; i < xlsxData.length; i++) {
|
||||
view[i] = xlsxData[i];
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ XLSX 변환 완료: ${xlsxArrayBuffer.byteLength} bytes`);
|
||||
|
||||
// ⏱️ ArrayBuffer 변환 완료 확인 및 검증
|
||||
console.log("⏱️ ArrayBuffer 변환 검증 중...");
|
||||
|
||||
// ArrayBuffer 무결성 검증
|
||||
if (!xlsxArrayBuffer || xlsxArrayBuffer.byteLength === 0) {
|
||||
throw new Error("ArrayBuffer 변환 실패: 빈 버퍼");
|
||||
}
|
||||
|
||||
// XLSX 파일 시그니처 사전 검증
|
||||
const uint8Check = new Uint8Array(xlsxArrayBuffer);
|
||||
const signatureCheck = Array.from(uint8Check.slice(0, 4))
|
||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||
.join(" ");
|
||||
|
||||
if (signatureCheck !== "50 4b 03 04") {
|
||||
console.warn(
|
||||
`⚠️ 잘못된 XLSX 시그니처: ${signatureCheck} (예상: 50 4b 03 04)`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`✅ ArrayBuffer 검증 완료: ${xlsxArrayBuffer.byteLength} bytes, 시그니처: ${signatureCheck}`,
|
||||
);
|
||||
} catch (writeError) {
|
||||
console.error("❌ XLSX 변환 실패:", writeError);
|
||||
throw new Error(
|
||||
`XLSX 변환 실패: ${writeError instanceof Error ? writeError.message : writeError}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 3단계: ArrayBuffer가 완전히 준비된 후 LuckyExcel로 처리
|
||||
console.log("🍀 LuckyExcel로 변환된 XLSX 처리 중...");
|
||||
|
||||
// ArrayBuffer 최종 검증
|
||||
if (!xlsxArrayBuffer) {
|
||||
throw new Error("ArrayBuffer가 생성되지 않았습니다");
|
||||
}
|
||||
|
||||
if (xlsxArrayBuffer.byteLength === 0) {
|
||||
throw new Error("ArrayBuffer 크기가 0입니다");
|
||||
}
|
||||
|
||||
// 원본 파일명에서 확장자를 .xlsx로 변경
|
||||
const xlsxFileName = file.name.replace(/\.(csv|xls|xlsx)$/i, ".xlsx");
|
||||
|
||||
// 🔍 LuckyExcel로 전달되는 파일 정보 출력
|
||||
console.log("📋 =================================");
|
||||
console.log("📋 LuckyExcel로 전달되는 파일 정보:");
|
||||
console.log("📋 =================================");
|
||||
console.log("📋 타이밍:", new Date().toISOString());
|
||||
console.log("📋 원본 파일명:", file.name);
|
||||
console.log("📋 변환된 파일명:", xlsxFileName);
|
||||
console.log("📋 ArrayBuffer 크기:", xlsxArrayBuffer.byteLength, "bytes");
|
||||
console.log("📋 ArrayBuffer 타입:", xlsxArrayBuffer.constructor.name);
|
||||
|
||||
// ArrayBuffer의 처음 100바이트를 16진수로 출력 (헥스 덤프)
|
||||
const uint8View = new Uint8Array(xlsxArrayBuffer);
|
||||
const firstBytes = Array.from(
|
||||
uint8View.slice(0, Math.min(100, uint8View.length)),
|
||||
)
|
||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||
.join(" ");
|
||||
console.log("📋 ArrayBuffer 처음 100바이트 (hex):", firstBytes);
|
||||
|
||||
// XLSX 파일 시그니처 확인 (PK\x03\x04 또는 50 4B 03 04)
|
||||
const signature = Array.from(uint8View.slice(0, 4))
|
||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||
.join(" ");
|
||||
console.log(
|
||||
"📋 파일 시그니처:",
|
||||
signature,
|
||||
signature === "50 4b 03 04" ? "(✅ 유효한 XLSX)" : "(❌ 잘못된 시그니처)",
|
||||
);
|
||||
console.log("📋 =================================");
|
||||
|
||||
// 🚀 LuckyExcel 호출 직전 최종 검증
|
||||
console.log("🚀 LuckyExcel 호출 직전 최종 검증:");
|
||||
console.log("🚀 ArrayBuffer 타입:", typeof xlsxArrayBuffer);
|
||||
console.log("🚀 ArrayBuffer 생성자 확인:", xlsxArrayBuffer.constructor.name);
|
||||
console.log("🚀 ArrayBuffer 크기:", xlsxArrayBuffer.byteLength);
|
||||
console.log("🚀 ArrayBuffer.isView:", ArrayBuffer.isView(xlsxArrayBuffer));
|
||||
console.log("🚀 fileName:", xlsxFileName, "타입:", typeof xlsxFileName);
|
||||
|
||||
console.log("🚀 LuckyExcel 객체:", typeof LuckyExcel);
|
||||
console.log(
|
||||
"🚀 transformExcelToLucky 함수:",
|
||||
typeof (LuckyExcel as any).transformExcelToLucky,
|
||||
);
|
||||
|
||||
console.log("🚀 LuckyExcel 호출 시작...");
|
||||
|
||||
// Promise를 사용한 LuckyExcel 처리
|
||||
return new Promise<SheetData[]>((resolve, reject) => {
|
||||
try {
|
||||
// LuckyExcel API는 (arrayBuffer, successCallback, errorCallback) 형태로 호출
|
||||
// 공식 문서: LuckyExcel.transformExcelToLucky(file, successCallback, errorCallback)
|
||||
(LuckyExcel as any).transformExcelToLucky(
|
||||
xlsxArrayBuffer,
|
||||
// 성공 콜백 함수 (두 번째 매개변수)
|
||||
(exportJson: any, luckysheetfile: any) => {
|
||||
try {
|
||||
console.log("🍀 =================================");
|
||||
console.log("🍀 LuckyExcel 변환 결과 상세 정보:");
|
||||
console.log("🍀 =================================");
|
||||
console.log("🍀 원본 파일명:", xlsxFileName);
|
||||
console.log("🍀 exportJson 존재:", !!exportJson);
|
||||
console.log("🍀 exportJson 타입:", typeof exportJson);
|
||||
|
||||
if (exportJson) {
|
||||
console.log("🍀 exportJson 전체 구조:", exportJson);
|
||||
console.log("🍀 exportJson.sheets 존재:", !!exportJson.sheets);
|
||||
console.log(
|
||||
"🍀 exportJson.sheets 타입:",
|
||||
typeof exportJson.sheets,
|
||||
);
|
||||
console.log(
|
||||
"🍀 exportJson.sheets 배열 여부:",
|
||||
Array.isArray(exportJson.sheets),
|
||||
);
|
||||
console.log("🍀 시트 개수:", exportJson?.sheets?.length || 0);
|
||||
|
||||
if (exportJson.sheets && Array.isArray(exportJson.sheets)) {
|
||||
exportJson.sheets.forEach((sheet: any, index: number) => {
|
||||
console.log(`🍀 시트 ${index + 1}:`, {
|
||||
name: sheet.name,
|
||||
row: sheet.row,
|
||||
column: sheet.column,
|
||||
celldata길이: sheet.celldata?.length || 0,
|
||||
키목록: Object.keys(sheet),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log("🍀 luckysheetfile 존재:", !!luckysheetfile);
|
||||
console.log("🍀 luckysheetfile 타입:", typeof luckysheetfile);
|
||||
if (luckysheetfile) {
|
||||
console.log("🍀 luckysheetfile 구조:", luckysheetfile);
|
||||
}
|
||||
console.log("🍀 =================================");
|
||||
|
||||
console.log("🔍 LuckyExcel 변환 결과:", {
|
||||
hasExportJson: !!exportJson,
|
||||
hasSheets: !!exportJson?.sheets,
|
||||
sheetsCount: exportJson?.sheets?.length || 0,
|
||||
});
|
||||
|
||||
// 데이터 유효성 검사
|
||||
if (
|
||||
!exportJson ||
|
||||
!exportJson.sheets ||
|
||||
!Array.isArray(exportJson.sheets) ||
|
||||
exportJson.sheets.length === 0
|
||||
) {
|
||||
console.warn(
|
||||
"⚠️ LuckyExcel 결과가 유효하지 않습니다. SheetJS 방식으로 대체 처리합니다.",
|
||||
);
|
||||
|
||||
// LuckyExcel 실패 시 SheetJS 데이터를 직접 변환
|
||||
const fallbackSheets = convertSheetJSToLuckyExcel(workbook);
|
||||
resolve(fallbackSheets);
|
||||
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 (processError) {
|
||||
console.error("❌ LuckyExcel 후처리 중 오류:", processError);
|
||||
|
||||
// LuckyExcel 후처리 실패 시 SheetJS 방식으로 대체
|
||||
try {
|
||||
console.log("🔄 SheetJS 방식으로 대체 처리...");
|
||||
const fallbackSheets = convertSheetJSToLuckyExcel(workbook);
|
||||
resolve(fallbackSheets);
|
||||
} catch (fallbackError) {
|
||||
console.error("❌ SheetJS 대체 처리도 실패:", fallbackError);
|
||||
reject(fallbackError);
|
||||
}
|
||||
}
|
||||
},
|
||||
// 오류 콜백 함수 (세 번째 매개변수)
|
||||
(error: any) => {
|
||||
console.error("❌ LuckyExcel 변환 오류:", error);
|
||||
|
||||
// LuckyExcel 오류 시 SheetJS 방식으로 대체
|
||||
try {
|
||||
console.log("🔄 SheetJS 방식으로 대체 처리...");
|
||||
const fallbackSheets = convertSheetJSToLuckyExcel(workbook);
|
||||
resolve(fallbackSheets);
|
||||
} catch (fallbackError) {
|
||||
console.error("❌ SheetJS 대체 처리도 실패:", fallbackError);
|
||||
reject(fallbackError);
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (luckyError) {
|
||||
console.error("❌ LuckyExcel 호출 중 오류:", luckyError);
|
||||
|
||||
// LuckyExcel 호출 실패 시 SheetJS 방식으로 대체
|
||||
try {
|
||||
console.log("🔄 SheetJS 방식으로 대체 처리...");
|
||||
const fallbackSheets = convertSheetJSToLuckyExcel(workbook);
|
||||
resolve(fallbackSheets);
|
||||
} catch (fallbackError) {
|
||||
console.error("❌ SheetJS 대체 처리도 실패:", fallbackError);
|
||||
reject(fallbackError);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 엑셀 파일을 SheetData 배열로 변환 (개선된 버전)
|
||||
* - 모든 파일을 SheetJS로 읽은 후 XLSX로 변환
|
||||
* - 변환된 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"})`,
|
||||
);
|
||||
|
||||
// 통합된 처리 방식: SheetJS → XLSX → LuckyExcel
|
||||
const sheets = await processFileWithSheetJSToXLSX(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("파일 처리 실패") ||
|
||||
error.message.includes("파일 읽기 실패") ||
|
||||
error.message.includes("XLSX 변환 실패") ||
|
||||
error.message.includes("파일이 비어있습니다") ||
|
||||
error.message.includes("워크북을 생성할 수 없습니다") ||
|
||||
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;
|
||||
}
|
||||
498
src/utils/tutorialDataGenerator.ts
Normal file
498
src/utils/tutorialDataGenerator.ts
Normal file
@@ -0,0 +1,498 @@
|
||||
/**
|
||||
* Excel 튜토리얼 데이터 생성기
|
||||
* - 상위 10개 Excel 함수에 대한 현실적인 샘플 데이터 생성
|
||||
* - 한글 지원 및 적절한 셀 참조
|
||||
* - AI 프롬프트 템플릿 시스템
|
||||
*/
|
||||
|
||||
import type { TutorialItem } from "../types/tutorial";
|
||||
import { TutorialCategory } from "../types/tutorial";
|
||||
|
||||
export class TutorialDataGenerator {
|
||||
/**
|
||||
* 전체 튜토리얼 데이터 생성 (상위 10개 함수)
|
||||
*/
|
||||
static generateAllTutorials(): TutorialItem[] {
|
||||
return [
|
||||
this.generateSumTutorial(),
|
||||
this.generateIfTutorial(),
|
||||
this.generateCountTutorial(),
|
||||
this.generateAverageTutorial(),
|
||||
this.generateSumifTutorial(),
|
||||
this.generateMaxMinTutorial(),
|
||||
this.generateVlookupTutorial(),
|
||||
this.generateTrimTutorial(),
|
||||
this.generateConcatenateTutorial(),
|
||||
this.generateIferrorTutorial(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* SUM 함수 튜토리얼
|
||||
*/
|
||||
private static generateSumTutorial(): TutorialItem {
|
||||
return {
|
||||
metadata: {
|
||||
id: "sum_tutorial",
|
||||
title: "SUM - 숫자 합계",
|
||||
category: TutorialCategory.BASIC_MATH,
|
||||
difficulty: "초급",
|
||||
estimatedTime: "2분",
|
||||
description: "지정된 범위의 숫자들의 총합을 계산합니다.",
|
||||
tags: ["기본함수", "수학", "합계"],
|
||||
},
|
||||
functionName: "SUM",
|
||||
prompt:
|
||||
"A1 셀에 B2부터 B10까지의 합계를 구하는 수식을 입력해줘. 절대 참조를 사용해서",
|
||||
targetCell: "A1",
|
||||
expectedResult: 169,
|
||||
sampleData: [
|
||||
{ cellAddress: "B2", value: 10 },
|
||||
{ cellAddress: "B3", value: 20 },
|
||||
{ cellAddress: "B4", value: 15 },
|
||||
{ cellAddress: "B5", value: 12 },
|
||||
{ cellAddress: "B6", value: 18 },
|
||||
{ cellAddress: "B7", value: 30 },
|
||||
{ cellAddress: "B8", value: 25 },
|
||||
{ cellAddress: "B9", value: 22 },
|
||||
{ cellAddress: "B10", value: 17 },
|
||||
],
|
||||
beforeAfterDemo: {
|
||||
before: [
|
||||
{ cellAddress: "A1", value: "" },
|
||||
{ cellAddress: "B2", value: 10 },
|
||||
{ cellAddress: "B3", value: 20 },
|
||||
{ cellAddress: "B4", value: 15 },
|
||||
],
|
||||
after: [
|
||||
{ cellAddress: "A1", value: 169, formula: "=SUM($B$2:$B$10)" },
|
||||
{ cellAddress: "B2", value: 10 },
|
||||
{ cellAddress: "B3", value: 20 },
|
||||
{ cellAddress: "B4", value: 15 },
|
||||
],
|
||||
},
|
||||
explanation:
|
||||
"SUM 함수는 지정된 범위의 모든 숫자를 더합니다. $를 사용한 절대 참조로 범위가 고정됩니다.",
|
||||
relatedFunctions: ["SUMIF", "SUMIFS", "AVERAGE"],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* IF 함수 튜토리얼
|
||||
*/
|
||||
private static generateIfTutorial(): TutorialItem {
|
||||
return {
|
||||
metadata: {
|
||||
id: "if_tutorial",
|
||||
title: "IF - 조건 판단",
|
||||
category: TutorialCategory.LOGICAL,
|
||||
difficulty: "초급",
|
||||
estimatedTime: "3분",
|
||||
description: "조건에 따라 다른 값을 반환하는 논리 함수입니다.",
|
||||
tags: ["논리함수", "조건", "판단"],
|
||||
},
|
||||
functionName: "IF",
|
||||
prompt:
|
||||
"D2 셀에 C2 값이 100 이상이면 '합격', 아니면 '불합격'을 표시하는 수식을 넣어줘",
|
||||
targetCell: "D2",
|
||||
expectedResult: "불합격",
|
||||
sampleData: [
|
||||
{ cellAddress: "B2", value: "김철수" },
|
||||
{ cellAddress: "C2", value: 85 },
|
||||
{ cellAddress: "B3", value: "이영희" },
|
||||
{ cellAddress: "C3", value: 92 },
|
||||
{ cellAddress: "B4", value: "박민수" },
|
||||
{ cellAddress: "C4", value: 78 },
|
||||
],
|
||||
beforeAfterDemo: {
|
||||
before: [
|
||||
{ cellAddress: "C2", value: 85 },
|
||||
{ cellAddress: "D2", value: "" },
|
||||
],
|
||||
after: [
|
||||
{ cellAddress: "C2", value: 85 },
|
||||
{
|
||||
cellAddress: "D2",
|
||||
value: "불합격",
|
||||
formula: '=IF(C2>=100,"합격","불합격")',
|
||||
},
|
||||
],
|
||||
},
|
||||
explanation:
|
||||
"IF 함수는 첫 번째 인수가 참이면 두 번째 값을, 거짓이면 세 번째 값을 반환합니다.",
|
||||
relatedFunctions: ["IFS", "AND", "OR"],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* COUNTA 함수 튜토리얼
|
||||
*/
|
||||
private static generateCountTutorial(): TutorialItem {
|
||||
return {
|
||||
metadata: {
|
||||
id: "count_tutorial",
|
||||
title: "COUNTA - 비어있지 않은 셀 개수",
|
||||
category: TutorialCategory.STATISTICAL,
|
||||
difficulty: "초급",
|
||||
estimatedTime: "2분",
|
||||
description: "지정된 범위에서 비어있지 않은 셀의 개수를 셉니다.",
|
||||
tags: ["통계함수", "개수", "카운트"],
|
||||
},
|
||||
functionName: "COUNTA",
|
||||
prompt:
|
||||
"A1 셀에 B2부터 B20까지 범위에서 비어있지 않은 셀의 개수를 세는 수식을 넣어줘",
|
||||
targetCell: "A1",
|
||||
expectedResult: 15,
|
||||
sampleData: [
|
||||
{ cellAddress: "B2", value: "사과" },
|
||||
{ cellAddress: "B3", value: "바나나" },
|
||||
{ cellAddress: "B4", value: "오렌지" },
|
||||
{ cellAddress: "B5", value: "" },
|
||||
{ cellAddress: "B6", value: "포도" },
|
||||
{ cellAddress: "B7", value: "딸기" },
|
||||
{ cellAddress: "B8", value: "" },
|
||||
{ cellAddress: "B9", value: "수박" },
|
||||
{ cellAddress: "B10", value: "메론" },
|
||||
{ cellAddress: "B11", value: "키위" },
|
||||
{ cellAddress: "B12", value: "망고" },
|
||||
{ cellAddress: "B13", value: "" },
|
||||
{ cellAddress: "B14", value: "파인애플" },
|
||||
{ cellAddress: "B15", value: "복숭아" },
|
||||
{ cellAddress: "B16", value: "자두" },
|
||||
{ cellAddress: "B17", value: "체리" },
|
||||
{ cellAddress: "B18", value: "라임" },
|
||||
],
|
||||
beforeAfterDemo: {
|
||||
before: [
|
||||
{ cellAddress: "A1", value: "" },
|
||||
{ cellAddress: "B2", value: "사과" },
|
||||
{ cellAddress: "B3", value: "바나나" },
|
||||
],
|
||||
after: [
|
||||
{ cellAddress: "A1", value: 15, formula: "=COUNTA(B2:B20)" },
|
||||
{ cellAddress: "B2", value: "사과" },
|
||||
{ cellAddress: "B3", value: "바나나" },
|
||||
],
|
||||
},
|
||||
explanation:
|
||||
"COUNTA는 숫자, 텍스트, 논리값 등 비어있지 않은 모든 셀을 카운트합니다.",
|
||||
relatedFunctions: ["COUNT", "COUNTIF", "COUNTIFS"],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* AVERAGE 함수 튜토리얼
|
||||
*/
|
||||
private static generateAverageTutorial(): TutorialItem {
|
||||
return {
|
||||
metadata: {
|
||||
id: "average_tutorial",
|
||||
title: "AVERAGE - 평균값",
|
||||
category: TutorialCategory.STATISTICAL,
|
||||
difficulty: "초급",
|
||||
estimatedTime: "2분",
|
||||
description: "지정된 범위 숫자들의 평균값을 계산합니다.",
|
||||
tags: ["통계함수", "평균", "수학"],
|
||||
},
|
||||
functionName: "AVERAGE",
|
||||
prompt:
|
||||
"A1 셀에 B2부터 B20까지 숫자들의 평균을 구하는 수식을 넣어줘. 절대 참조로",
|
||||
targetCell: "A1",
|
||||
expectedResult: 18.1,
|
||||
sampleData: [
|
||||
{ cellAddress: "B2", value: 15 },
|
||||
{ cellAddress: "B3", value: 22 },
|
||||
{ cellAddress: "B4", value: 18 },
|
||||
{ cellAddress: "B5", value: 25 },
|
||||
{ cellAddress: "B6", value: 12 },
|
||||
{ cellAddress: "B7", value: 30 },
|
||||
{ cellAddress: "B8", value: 16 },
|
||||
{ cellAddress: "B9", value: 19 },
|
||||
{ cellAddress: "B10", value: 14 },
|
||||
{ cellAddress: "B11", value: 10 },
|
||||
],
|
||||
beforeAfterDemo: {
|
||||
before: [
|
||||
{ cellAddress: "A1", value: "" },
|
||||
{ cellAddress: "B2", value: 15 },
|
||||
],
|
||||
after: [
|
||||
{ cellAddress: "A1", value: 18.1, formula: "=AVERAGE($B$2:$B$20)" },
|
||||
{ cellAddress: "B2", value: 15 },
|
||||
],
|
||||
},
|
||||
explanation:
|
||||
"AVERAGE 함수는 지정된 범위의 숫자들을 모두 더한 후 개수로 나누어 평균을 구합니다.",
|
||||
relatedFunctions: ["SUM", "COUNT", "MEDIAN"],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* SUMIF 함수 튜토리얼
|
||||
*/
|
||||
private static generateSumifTutorial(): TutorialItem {
|
||||
return {
|
||||
metadata: {
|
||||
id: "sumif_tutorial",
|
||||
title: "SUMIF - 조건부 합계",
|
||||
category: TutorialCategory.BASIC_MATH,
|
||||
difficulty: "중급",
|
||||
estimatedTime: "4분",
|
||||
description: "특정 조건을 만족하는 셀들의 합계를 계산합니다.",
|
||||
tags: ["조건부함수", "합계", "필터"],
|
||||
},
|
||||
functionName: "SUMIF",
|
||||
prompt: "A1 셀에 B열에서 '사과'인 행의 C열 값들을 합하는 수식을 넣어줘",
|
||||
targetCell: "A1",
|
||||
expectedResult: 150,
|
||||
sampleData: [
|
||||
{ cellAddress: "B2", value: "사과" },
|
||||
{ cellAddress: "C2", value: 50 },
|
||||
{ cellAddress: "B3", value: "바나나" },
|
||||
{ cellAddress: "C3", value: 30 },
|
||||
{ cellAddress: "B4", value: "사과" },
|
||||
{ cellAddress: "C4", value: 70 },
|
||||
{ cellAddress: "B5", value: "오렌지" },
|
||||
{ cellAddress: "C5", value: 40 },
|
||||
{ cellAddress: "B6", value: "사과" },
|
||||
{ cellAddress: "C6", value: 30 },
|
||||
],
|
||||
beforeAfterDemo: {
|
||||
before: [
|
||||
{ cellAddress: "A1", value: "" },
|
||||
{ cellAddress: "B2", value: "사과" },
|
||||
{ cellAddress: "C2", value: 50 },
|
||||
],
|
||||
after: [
|
||||
{ cellAddress: "A1", value: 150, formula: '=SUMIF(B:B,"사과",C:C)' },
|
||||
{ cellAddress: "B2", value: "사과" },
|
||||
{ cellAddress: "C2", value: 50 },
|
||||
],
|
||||
},
|
||||
explanation:
|
||||
"SUMIF는 첫 번째 범위에서 조건을 찾고, 해당하는 두 번째 범위의 값들을 합합니다.",
|
||||
relatedFunctions: ["SUMIFS", "COUNTIF", "AVERAGEIF"],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* MAX/MIN 함수 튜토리얼
|
||||
*/
|
||||
private static generateMaxMinTutorial(): TutorialItem {
|
||||
return {
|
||||
metadata: {
|
||||
id: "maxmin_tutorial",
|
||||
title: "MAX/MIN - 최대값/최소값",
|
||||
category: TutorialCategory.STATISTICAL,
|
||||
difficulty: "초급",
|
||||
estimatedTime: "3분",
|
||||
description: "지정된 범위에서 최대값과 최소값을 찾습니다.",
|
||||
tags: ["통계함수", "최대값", "최소값"],
|
||||
},
|
||||
functionName: "MAX",
|
||||
prompt:
|
||||
"A1에 B2부터 B50까지의 최대값을, A2에 최소값을 구하는 수식을 각각 넣어줘",
|
||||
targetCell: "A1",
|
||||
expectedResult: 95,
|
||||
sampleData: [
|
||||
{ cellAddress: "B2", value: 45 },
|
||||
{ cellAddress: "B3", value: 67 },
|
||||
{ cellAddress: "B4", value: 23 },
|
||||
{ cellAddress: "B5", value: 95 },
|
||||
{ cellAddress: "B6", value: 34 },
|
||||
{ cellAddress: "B7", value: 12 },
|
||||
{ cellAddress: "B8", value: 78 },
|
||||
{ cellAddress: "B9", value: 56 },
|
||||
{ cellAddress: "B10", value: 89 },
|
||||
],
|
||||
beforeAfterDemo: {
|
||||
before: [
|
||||
{ cellAddress: "A1", value: "" },
|
||||
{ cellAddress: "A2", value: "" },
|
||||
{ cellAddress: "B2", value: 45 },
|
||||
],
|
||||
after: [
|
||||
{ cellAddress: "A1", value: 95, formula: "=MAX(B2:B50)" },
|
||||
{ cellAddress: "A2", value: 12, formula: "=MIN(B2:B50)" },
|
||||
{ cellAddress: "B2", value: 45 },
|
||||
],
|
||||
},
|
||||
explanation:
|
||||
"MAX는 범위에서 가장 큰 값을, MIN은 가장 작은 값을 반환합니다.",
|
||||
relatedFunctions: ["LARGE", "SMALL", "AVERAGE"],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* XLOOKUP 함수 튜토리얼
|
||||
*/
|
||||
private static generateVlookupTutorial(): TutorialItem {
|
||||
return {
|
||||
metadata: {
|
||||
id: "xlookup_tutorial",
|
||||
title: "XLOOKUP - 고급 조회",
|
||||
category: TutorialCategory.LOOKUP,
|
||||
difficulty: "고급",
|
||||
estimatedTime: "5분",
|
||||
description: "지정된 값을 찾아 해당하는 데이터를 반환합니다.",
|
||||
tags: ["조회함수", "검색", "매칭"],
|
||||
},
|
||||
functionName: "XLOOKUP",
|
||||
prompt:
|
||||
"B2 셀에 A2의 이름과 일치하는 부서를 E2:F100 범위에서 찾아서 반환하는 XLOOKUP 수식을 넣어줘. 범위는 절대참조로",
|
||||
targetCell: "B2",
|
||||
expectedResult: "개발팀",
|
||||
sampleData: [
|
||||
{ cellAddress: "A2", value: "김철수" },
|
||||
{ cellAddress: "E2", value: "김철수" },
|
||||
{ cellAddress: "F2", value: "개발팀" },
|
||||
{ cellAddress: "E3", value: "이영희" },
|
||||
{ cellAddress: "F3", value: "마케팅팀" },
|
||||
{ cellAddress: "E4", value: "박민수" },
|
||||
{ cellAddress: "F4", value: "영업팀" },
|
||||
{ cellAddress: "E5", value: "최유리" },
|
||||
{ cellAddress: "F5", value: "인사팀" },
|
||||
],
|
||||
beforeAfterDemo: {
|
||||
before: [
|
||||
{ cellAddress: "A2", value: "김철수" },
|
||||
{ cellAddress: "B2", value: "" },
|
||||
{ cellAddress: "E2", value: "김철수" },
|
||||
{ cellAddress: "F2", value: "개발팀" },
|
||||
],
|
||||
after: [
|
||||
{ cellAddress: "A2", value: "김철수" },
|
||||
{
|
||||
cellAddress: "B2",
|
||||
value: "개발팀",
|
||||
formula: "=XLOOKUP(A2,$E$2:$E$100,$F$2:$F$100)",
|
||||
},
|
||||
{ cellAddress: "E2", value: "김철수" },
|
||||
{ cellAddress: "F2", value: "개발팀" },
|
||||
],
|
||||
},
|
||||
explanation:
|
||||
"XLOOKUP은 조회값을 찾아 해당하는 반환 범위의 값을 가져옵니다.",
|
||||
relatedFunctions: ["VLOOKUP", "INDEX", "MATCH"],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* TRIM 함수 튜토리얼
|
||||
*/
|
||||
private static generateTrimTutorial(): TutorialItem {
|
||||
return {
|
||||
metadata: {
|
||||
id: "trim_tutorial",
|
||||
title: "TRIM - 공백 제거",
|
||||
category: TutorialCategory.TEXT,
|
||||
difficulty: "초급",
|
||||
estimatedTime: "2분",
|
||||
description: "텍스트의 앞뒤 공백을 제거합니다.",
|
||||
tags: ["텍스트함수", "공백제거", "정리"],
|
||||
},
|
||||
functionName: "TRIM",
|
||||
prompt: "B2 셀에 A2 텍스트의 앞뒤 공백을 제거하는 TRIM 수식을 넣어줘",
|
||||
targetCell: "B2",
|
||||
expectedResult: "안녕하세요",
|
||||
sampleData: [{ cellAddress: "A2", value: " 안녕하세요 " }],
|
||||
beforeAfterDemo: {
|
||||
before: [
|
||||
{ cellAddress: "A2", value: " 안녕하세요 " },
|
||||
{ cellAddress: "B2", value: "" },
|
||||
],
|
||||
after: [
|
||||
{ cellAddress: "A2", value: " 안녕하세요 " },
|
||||
{ cellAddress: "B2", value: "안녕하세요", formula: "=TRIM(A2)" },
|
||||
],
|
||||
},
|
||||
explanation:
|
||||
"TRIM 함수는 텍스트의 앞뒤 공백과 중간의 여러 공백을 제거합니다.",
|
||||
relatedFunctions: ["CLEAN", "SUBSTITUTE"],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* TEXTJOIN 함수 튜토리얼
|
||||
*/
|
||||
private static generateConcatenateTutorial(): TutorialItem {
|
||||
return {
|
||||
metadata: {
|
||||
id: "textjoin_tutorial",
|
||||
title: "TEXTJOIN - 텍스트 결합",
|
||||
category: TutorialCategory.TEXT,
|
||||
difficulty: "중급",
|
||||
estimatedTime: "3분",
|
||||
description: "여러 텍스트를 구분자로 결합합니다.",
|
||||
tags: ["텍스트함수", "결합", "연결"],
|
||||
},
|
||||
functionName: "TEXTJOIN",
|
||||
prompt:
|
||||
"C2 셀에 A2의 성과 B2의 이름을 공백으로 연결해서 'Smith John' 형태로 만드는 TEXTJOIN 수식을 넣어줘",
|
||||
targetCell: "C2",
|
||||
expectedResult: "김 철수",
|
||||
sampleData: [
|
||||
{ cellAddress: "A2", value: "김" },
|
||||
{ cellAddress: "B2", value: "철수" },
|
||||
],
|
||||
beforeAfterDemo: {
|
||||
before: [
|
||||
{ cellAddress: "A2", value: "김" },
|
||||
{ cellAddress: "B2", value: "철수" },
|
||||
{ cellAddress: "C2", value: "" },
|
||||
],
|
||||
after: [
|
||||
{ cellAddress: "A2", value: "김" },
|
||||
{ cellAddress: "B2", value: "철수" },
|
||||
{
|
||||
cellAddress: "C2",
|
||||
value: "김 철수",
|
||||
formula: 'TEXTJOIN(" ",TRUE,A2,B2)',
|
||||
},
|
||||
],
|
||||
},
|
||||
explanation: "TEXTJOIN은 지정된 구분자로 여러 텍스트를 연결합니다.",
|
||||
relatedFunctions: ["CONCATENATE", "CONCAT"],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* IFERROR 함수 튜토리얼
|
||||
*/
|
||||
private static generateIferrorTutorial(): TutorialItem {
|
||||
return {
|
||||
metadata: {
|
||||
id: "iferror_tutorial",
|
||||
title: "IFERROR - 오류 처리",
|
||||
category: TutorialCategory.LOGICAL,
|
||||
difficulty: "중급",
|
||||
estimatedTime: "3분",
|
||||
description: "수식에서 오류가 발생하면 대체값을 반환합니다.",
|
||||
tags: ["논리함수", "오류처리"],
|
||||
},
|
||||
functionName: "IFERROR",
|
||||
prompt:
|
||||
"D2 셀에 C2를 B2로 나누는데, B2가 0이면 0을 반환하는 IFERROR 수식을 넣어줘",
|
||||
targetCell: "D2",
|
||||
expectedResult: 0,
|
||||
sampleData: [
|
||||
{ cellAddress: "B2", value: 0 },
|
||||
{ cellAddress: "C2", value: 100 },
|
||||
],
|
||||
beforeAfterDemo: {
|
||||
before: [
|
||||
{ cellAddress: "B2", value: 0 },
|
||||
{ cellAddress: "C2", value: 100 },
|
||||
{ cellAddress: "D2", value: "" },
|
||||
],
|
||||
after: [
|
||||
{ cellAddress: "B2", value: 0 },
|
||||
{ cellAddress: "C2", value: 100 },
|
||||
{ cellAddress: "D2", value: 0, formula: "=IFERROR(C2/B2,0)" },
|
||||
],
|
||||
},
|
||||
explanation: "IFERROR는 오류가 발생하면 지정된 대체값을 반환합니다.",
|
||||
relatedFunctions: ["ISERROR", "IF"],
|
||||
};
|
||||
}
|
||||
}
|
||||
404
src/utils/tutorialExecutor.ts
Normal file
404
src/utils/tutorialExecutor.ts
Normal file
@@ -0,0 +1,404 @@
|
||||
/**
|
||||
* 튜토리얼 실행 엔진
|
||||
* - 실시간 튜토리얼 데모 실행
|
||||
* - Univer 에디터 데이터 자동 생성
|
||||
* - AI 프롬프트 자동 입력
|
||||
* - 수식 실행 데모 관리
|
||||
*/
|
||||
|
||||
import type {
|
||||
TutorialItem,
|
||||
TutorialResult,
|
||||
LiveDemoConfig,
|
||||
} from "../types/tutorial";
|
||||
|
||||
export class TutorialExecutor {
|
||||
private univerAPI: any = null;
|
||||
private currentTutorial: TutorialItem | null = null;
|
||||
private isExecuting = false;
|
||||
|
||||
/**
|
||||
* 튜토리얼 실행기 초기화
|
||||
*/
|
||||
constructor() {
|
||||
console.log("🎯 TutorialExecutor 초기화");
|
||||
}
|
||||
|
||||
/**
|
||||
* Univer API 설정
|
||||
*/
|
||||
setUniverAPI(univerAPI: any): void {
|
||||
this.univerAPI = univerAPI;
|
||||
console.log("✅ TutorialExecutor에 Univer API 설정 완료");
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 실행 중인지 확인
|
||||
*/
|
||||
isCurrentlyExecuting(): boolean {
|
||||
return this.isExecuting;
|
||||
}
|
||||
|
||||
/**
|
||||
* TutorialExecutor가 사용할 준비가 되었는지 확인
|
||||
*/
|
||||
isReady(): boolean {
|
||||
return this.univerAPI !== null && !this.isExecuting;
|
||||
}
|
||||
|
||||
/**
|
||||
* 튜토리얼 시작
|
||||
*/
|
||||
async startTutorial(
|
||||
tutorial: TutorialItem,
|
||||
config: LiveDemoConfig = {
|
||||
autoExecute: true,
|
||||
stepDelay: 1000,
|
||||
highlightDuration: 2000,
|
||||
showFormula: true,
|
||||
enableAnimation: true,
|
||||
},
|
||||
): Promise<TutorialResult> {
|
||||
if (this.isExecuting) {
|
||||
throw new Error("이미 다른 튜토리얼이 실행 중입니다.");
|
||||
}
|
||||
|
||||
if (!this.univerAPI) {
|
||||
throw new Error("Univer API가 설정되지 않았습니다.");
|
||||
}
|
||||
|
||||
this.isExecuting = true;
|
||||
this.currentTutorial = tutorial;
|
||||
|
||||
console.log(`🚀 튜토리얼 "${tutorial.metadata.title}" 시작`);
|
||||
|
||||
try {
|
||||
const result = await this.executeTutorialSteps(tutorial, config);
|
||||
console.log(`✅ 튜토리얼 "${tutorial.metadata.title}" 완료`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`❌ 튜토리얼 실행 오류:`, error);
|
||||
throw error;
|
||||
} finally {
|
||||
this.isExecuting = false;
|
||||
this.currentTutorial = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 튜토리얼 단계별 실행
|
||||
*/
|
||||
private async executeTutorialSteps(
|
||||
tutorial: TutorialItem,
|
||||
config: LiveDemoConfig,
|
||||
): Promise<TutorialResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
// 1단계: 샘플 데이터 생성
|
||||
console.log("📊 1단계: 샘플 데이터 생성 중...");
|
||||
await this.populateSampleData(tutorial.sampleData);
|
||||
|
||||
if (config.autoExecute) {
|
||||
await this.delay(config.stepDelay);
|
||||
}
|
||||
|
||||
// 2단계: 대상 셀 하이라이트
|
||||
console.log("🎯 2단계: 대상 셀 하이라이트...");
|
||||
if (config.enableAnimation) {
|
||||
await this.highlightTargetCell(
|
||||
tutorial.targetCell,
|
||||
config.highlightDuration,
|
||||
);
|
||||
}
|
||||
|
||||
// 3단계: 수식 적용
|
||||
console.log("⚡ 3단계: 수식 적용 중...");
|
||||
const formula = this.generateFormulaFromPrompt(tutorial);
|
||||
await this.applyCellFormula(tutorial.targetCell, formula);
|
||||
|
||||
const executionTime = Date.now() - startTime;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
executedFormula: formula,
|
||||
resultValue: tutorial.expectedResult,
|
||||
executionTime,
|
||||
cellsModified: [
|
||||
tutorial.targetCell,
|
||||
...tutorial.sampleData.map((d) => d.cellAddress),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 샘플 데이터를 Univer 시트에 적용
|
||||
*/
|
||||
async populateSampleData(
|
||||
sampleData: TutorialItem["sampleData"],
|
||||
): Promise<void> {
|
||||
try {
|
||||
const activeWorkbook = this.univerAPI.getActiveWorkbook();
|
||||
if (!activeWorkbook) {
|
||||
throw new Error("활성 워크북을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
const activeSheet = activeWorkbook.getActiveSheet();
|
||||
if (!activeSheet) {
|
||||
throw new Error("활성 시트를 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
console.log(`📝 ${sampleData.length}개 샘플 데이터 적용 중...`);
|
||||
|
||||
for (const data of sampleData) {
|
||||
try {
|
||||
// Univer 셀에 값 설정
|
||||
const cellRange = activeSheet.getRange(data.cellAddress);
|
||||
if (cellRange) {
|
||||
if (data.formula) {
|
||||
// 수식이 있는 경우 수식 적용
|
||||
cellRange.setFormula(data.formula);
|
||||
} else {
|
||||
// 단순 값 적용
|
||||
cellRange.setValue(data.value);
|
||||
}
|
||||
|
||||
// 스타일 적용 (있는 경우)
|
||||
if (data.style) {
|
||||
if (data.style.backgroundColor) {
|
||||
cellRange.setBackgroundColor(data.style.backgroundColor);
|
||||
}
|
||||
if (data.style.fontWeight === "bold") {
|
||||
cellRange.setFontWeight("bold");
|
||||
}
|
||||
if (data.style.color) {
|
||||
cellRange.setFontColor(data.style.color);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`✅ ${data.cellAddress}에 값 "${data.value}" 적용 완료`,
|
||||
);
|
||||
}
|
||||
} catch (cellError) {
|
||||
console.warn(`⚠️ 셀 ${data.cellAddress} 적용 실패:`, cellError);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("✅ 모든 샘플 데이터 적용 완료");
|
||||
} catch (error) {
|
||||
console.error("❌ 샘플 데이터 적용 실패:", error);
|
||||
throw new Error("샘플 데이터를 적용할 수 없습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 대상 셀 하이라이트
|
||||
*/
|
||||
async highlightTargetCell(
|
||||
cellAddress: string,
|
||||
duration: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const activeWorkbook = this.univerAPI.getActiveWorkbook();
|
||||
const activeSheet = activeWorkbook?.getActiveSheet();
|
||||
|
||||
if (activeSheet) {
|
||||
const cellRange = activeSheet.getRange(cellAddress);
|
||||
if (cellRange) {
|
||||
// 셀 선택으로 하이라이트 효과
|
||||
cellRange.select();
|
||||
console.log(`🎯 셀 ${cellAddress} 하이라이트 완료`);
|
||||
|
||||
// 지정된 시간만큼 대기
|
||||
await this.delay(duration);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`⚠️ 셀 하이라이트 실패:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 셀에 수식 적용
|
||||
*/
|
||||
async applyCellFormula(cellAddress: string, formula: string): Promise<void> {
|
||||
try {
|
||||
const activeWorkbook = this.univerAPI.getActiveWorkbook();
|
||||
const activeSheet = activeWorkbook?.getActiveSheet();
|
||||
|
||||
if (activeSheet) {
|
||||
const cellRange = activeSheet.getRange(cellAddress);
|
||||
if (cellRange) {
|
||||
// 수식 적용
|
||||
cellRange.setFormula(formula);
|
||||
console.log(`⚡ 셀 ${cellAddress}에 수식 "${formula}" 적용 완료`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ 수식 적용 실패:`, error);
|
||||
throw new Error(`셀 ${cellAddress}에 수식을 적용할 수 없습니다.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 프롬프트에서 실제 Excel 수식 생성
|
||||
*/
|
||||
generateFormulaFromPrompt(tutorial: TutorialItem): string {
|
||||
// 튜토리얼 데이터에서 예상 수식 추출
|
||||
const afterDemo = tutorial.beforeAfterDemo.after.find(
|
||||
(item) => item.cellAddress === tutorial.targetCell,
|
||||
);
|
||||
|
||||
if (afterDemo?.formula) {
|
||||
return afterDemo.formula;
|
||||
}
|
||||
|
||||
// 함수별 기본 수식 생성 로직
|
||||
switch (tutorial.functionName.toUpperCase()) {
|
||||
case "SUM":
|
||||
return "=SUM($B$2:$B$10)";
|
||||
case "IF":
|
||||
return '=IF(C2>=100,"합격","불합격")';
|
||||
case "COUNTA":
|
||||
return "=COUNTA(B2:B20)";
|
||||
case "AVERAGE":
|
||||
return "=AVERAGE($B$2:$B$20)";
|
||||
case "SUMIF":
|
||||
return '=SUMIF(B:B,"사과",C:C)';
|
||||
case "MAX":
|
||||
return "=MAX(B2:B50)";
|
||||
case "XLOOKUP":
|
||||
return "=XLOOKUP(A2,$E$2:$E$100,$F$2:$F$100)";
|
||||
case "TRIM":
|
||||
return "=TRIM(A2)";
|
||||
case "TEXTJOIN":
|
||||
return 'TEXTJOIN(" ",TRUE,A2,B2)';
|
||||
case "IFERROR":
|
||||
return "=IFERROR(C2/B2,0)";
|
||||
default:
|
||||
return `=${tutorial.functionName}()`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 지연 함수
|
||||
*/
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* 튜토리얼 중단
|
||||
*/
|
||||
stopTutorial(): void {
|
||||
this.isExecuting = false;
|
||||
this.currentTutorial = null;
|
||||
console.log("⏹️ 튜토리얼 실행 중단");
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 튜토리얼 정보 반환
|
||||
*/
|
||||
getCurrentTutorial(): TutorialItem | null {
|
||||
return this.currentTutorial;
|
||||
}
|
||||
|
||||
/**
|
||||
* 튜토리얼의 사전 정의된 결과를 시트에 적용 (AI 시뮬레이션용)
|
||||
*/
|
||||
async applyTutorialResult(tutorial: TutorialItem): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
appliedActions: Array<{
|
||||
type: "formula";
|
||||
range: string;
|
||||
formula: string;
|
||||
}>;
|
||||
}> {
|
||||
try {
|
||||
if (!this.univerAPI) {
|
||||
throw new Error("Univer API가 설정되지 않았습니다.");
|
||||
}
|
||||
|
||||
console.log(`🎯 튜토리얼 "${tutorial.metadata.title}" 결과 적용 시작`);
|
||||
|
||||
// beforeAfterDemo.after 데이터에서 공식과 결과 추출
|
||||
const afterData = tutorial.beforeAfterDemo.after;
|
||||
const appliedActions: Array<{
|
||||
type: "formula";
|
||||
range: string;
|
||||
formula: string;
|
||||
}> = [];
|
||||
|
||||
const activeWorkbook = this.univerAPI.getActiveWorkbook();
|
||||
if (!activeWorkbook) {
|
||||
throw new Error("활성 워크북을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
const activeSheet = activeWorkbook.getActiveSheet();
|
||||
if (!activeSheet) {
|
||||
throw new Error("활성 시트를 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
// after 데이터의 각 셀에 공식 또는 값 적용
|
||||
for (const cellData of afterData) {
|
||||
try {
|
||||
const cellRange = activeSheet.getRange(cellData.cellAddress);
|
||||
if (cellRange) {
|
||||
if (cellData.formula) {
|
||||
// 공식이 있는 경우 공식 적용
|
||||
console.log(
|
||||
`📝 ${cellData.cellAddress}에 공식 "${cellData.formula}" 적용`,
|
||||
);
|
||||
cellRange.setFormula(cellData.formula);
|
||||
appliedActions.push({
|
||||
type: "formula",
|
||||
range: cellData.cellAddress,
|
||||
formula: cellData.formula,
|
||||
});
|
||||
} else {
|
||||
// 단순 값 적용
|
||||
console.log(
|
||||
`📝 ${cellData.cellAddress}에 값 "${cellData.value}" 적용`,
|
||||
);
|
||||
cellRange.setValue(cellData.value);
|
||||
}
|
||||
|
||||
// 스타일 적용 (있는 경우)
|
||||
if (cellData.style) {
|
||||
if (cellData.style.backgroundColor) {
|
||||
cellRange.setBackgroundColor(cellData.style.backgroundColor);
|
||||
}
|
||||
if (cellData.style.fontWeight === "bold") {
|
||||
cellRange.setFontWeight("bold");
|
||||
}
|
||||
if (cellData.style.color) {
|
||||
cellRange.setFontColor(cellData.style.color);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (cellError) {
|
||||
console.warn(`⚠️ 셀 ${cellData.cellAddress} 적용 실패:`, cellError);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ 튜토리얼 "${tutorial.metadata.title}" 결과 적용 완료`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `${tutorial.functionName} 함수가 성공적으로 적용되었습니다.`,
|
||||
appliedActions,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("❌ 튜토리얼 결과 적용 실패:", error);
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "알 수 없는 오류가 발생했습니다.",
|
||||
appliedActions: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
147
src/vite-env.d.ts
vendored
147
src/vite-env.d.ts
vendored
@@ -61,3 +61,150 @@ declare module "luckyexcel" {
|
||||
LuckyExcelResult,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Luckysheet 타입 선언
|
||||
*/
|
||||
declare module "luckysheet" {
|
||||
interface LuckysheetConfig {
|
||||
container?: string;
|
||||
title?: string;
|
||||
lang?: string;
|
||||
data?: any[];
|
||||
myFolderUrl?: string;
|
||||
plugins?: string[];
|
||||
fontList?: Array<{
|
||||
fontFamily: string;
|
||||
name: string;
|
||||
}>;
|
||||
options?: {
|
||||
showtoolbar?: boolean;
|
||||
showinfobar?: boolean;
|
||||
showsheetbar?: boolean;
|
||||
showstatisticBar?: boolean;
|
||||
allowCopy?: boolean;
|
||||
allowEdit?: boolean;
|
||||
enableAddRow?: boolean;
|
||||
enableAddCol?: boolean;
|
||||
sheetRightClickConfig?: {
|
||||
delete?: boolean;
|
||||
copy?: boolean;
|
||||
rename?: boolean;
|
||||
color?: boolean;
|
||||
hide?: boolean;
|
||||
move?: boolean;
|
||||
};
|
||||
cellRightClickConfig?: {
|
||||
copy?: boolean;
|
||||
copyAs?: boolean;
|
||||
paste?: boolean;
|
||||
insertRow?: boolean;
|
||||
insertColumn?: boolean;
|
||||
deleteRow?: boolean;
|
||||
deleteColumn?: boolean;
|
||||
deleteCell?: boolean;
|
||||
hideRow?: boolean;
|
||||
hideColumn?: boolean;
|
||||
rowHeight?: boolean;
|
||||
columnWidth?: boolean;
|
||||
clear?: boolean;
|
||||
matrix?: boolean;
|
||||
sort?: boolean;
|
||||
filter?: boolean;
|
||||
chart?: boolean;
|
||||
image?: boolean;
|
||||
link?: boolean;
|
||||
data?: boolean;
|
||||
cellFormat?: boolean;
|
||||
};
|
||||
};
|
||||
hook?: {
|
||||
cellMousedown?: (cell: any, postion: any, sheetFile: any) => void;
|
||||
cellClick?: (cell: any, postion: any, sheetFile: any) => void;
|
||||
sheetActivate?: (
|
||||
index: number,
|
||||
isPivotInitial: boolean,
|
||||
isInitialLoad: boolean,
|
||||
) => void;
|
||||
updated?: (operate: any) => void;
|
||||
};
|
||||
}
|
||||
|
||||
interface LuckysheetAPI {
|
||||
create: (config: LuckysheetConfig) => void;
|
||||
destroy: () => void;
|
||||
refreshFormula: () => void;
|
||||
setSheetData: (data: any[]) => void;
|
||||
getAllSheets: () => any[];
|
||||
getSheet: (index?: number) => any;
|
||||
setActiveSheet: (index: number) => void;
|
||||
getCellValue: (r: number, c: number, data?: any) => any;
|
||||
setCellValue: (r: number, c: number, d: any, isRefresh?: boolean) => void;
|
||||
getRange: () => any[];
|
||||
setRange: (range: any[]) => void;
|
||||
scroll: (settings: { scrollLeft?: number; scrollTop?: number }) => void;
|
||||
resize: () => void;
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
copy: () => void;
|
||||
paste: () => void;
|
||||
cut: () => void;
|
||||
insertRow: (index?: number) => void;
|
||||
insertColumn: (index?: number) => void;
|
||||
deleteRow: (start: number, end?: number) => void;
|
||||
deleteColumn: (start: number, end?: number) => void;
|
||||
hideRow: (rowIndexes: number[]) => void;
|
||||
showRow: (rowIndexes: number[]) => void;
|
||||
hideColumn: (columnIndexes: number[]) => void;
|
||||
showColumn: (columnIndexes: number[]) => void;
|
||||
setRowHeight: (rowInfo: { [key: number]: number }) => void;
|
||||
setColumnWidth: (columnInfo: { [key: number]: number }) => void;
|
||||
getRowHeight: (rowIndexes: number[]) => { [key: number]: number };
|
||||
getColumnWidth: (columnIndexes: number[]) => { [key: number]: number };
|
||||
setWorkbookName: (name: string) => void;
|
||||
getWorkbookName: () => string;
|
||||
exitEditMode: () => void;
|
||||
enterEditMode: (cell?: any) => void;
|
||||
updateCell: (r: number, c: number, value: any) => void;
|
||||
refreshCanvas: () => void;
|
||||
}
|
||||
|
||||
const luckysheet: LuckysheetAPI;
|
||||
export = luckysheet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Luckysheet 전역 변수 타입 선언
|
||||
*/
|
||||
declare global {
|
||||
interface Window {
|
||||
luckysheet: {
|
||||
create: (options: {
|
||||
container: string;
|
||||
data: any[];
|
||||
title?: string;
|
||||
userInfo?: string;
|
||||
[key: string]: any;
|
||||
}) => void;
|
||||
destroy: () => void;
|
||||
resize?: () => void;
|
||||
[key: string]: any;
|
||||
};
|
||||
LuckyExcel: {
|
||||
transformExcelToLucky: (
|
||||
file: File | ArrayBuffer,
|
||||
successCallback: (exportJson: any, luckysheetfile: any) => void,
|
||||
errorCallback: (error: any) => void,
|
||||
) => void;
|
||||
[key: string]: any;
|
||||
};
|
||||
$: any; // jQuery
|
||||
Store?: any; // Luckysheet Store
|
||||
luckysheet_function?: any; // Luckysheet function list
|
||||
functionlist?: any[]; // 글로벌 functionlist
|
||||
luckysheetConfigsetting?: any; // Luckysheet 설정 객체
|
||||
luckysheetPostil?: any; // Luckysheet 포스틸 객체
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
|
||||
@@ -22,5 +22,6 @@
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"exclude": ["**/__tests__/**", "**/*.test.ts", "**/*.test.tsx"]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"exclude": ["**/__tests__/**", "**/*.test.ts", "**/*.test.tsx"]
|
||||
}
|
||||
|
||||
@@ -1,10 +1,87 @@
|
||||
/// <reference types="vitest" />
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
|
||||
// Node.js 호환성 문제 해결
|
||||
define: {
|
||||
global: "globalThis",
|
||||
},
|
||||
|
||||
// Node.js 모듈 호환성 설정
|
||||
resolve: {
|
||||
alias: {
|
||||
stream: "stream-browserify",
|
||||
buffer: "buffer",
|
||||
},
|
||||
// 중복 모듈 해결을 위한 dedupe 설정
|
||||
dedupe: ["@wendellhu/redi"],
|
||||
},
|
||||
|
||||
// 의존성 최적화 설정
|
||||
optimizeDeps: {
|
||||
include: [
|
||||
// REDI 중복 로드 방지를 위해 명시적으로 포함
|
||||
"@wendellhu/redi",
|
||||
],
|
||||
exclude: [
|
||||
// Univer 관련 모듈만 제외
|
||||
"@univerjs/core",
|
||||
"@univerjs/design",
|
||||
"@univerjs/ui",
|
||||
"@univerjs/sheets",
|
||||
"@univerjs/sheets-ui",
|
||||
"@univerjs/docs",
|
||||
"@univerjs/docs-ui",
|
||||
"@univerjs/engine-render",
|
||||
"@univerjs/engine-formula",
|
||||
"@univerjs/sheets-formula",
|
||||
"@univerjs/sheets-formula-ui",
|
||||
"@univerjs/sheets-numfmt",
|
||||
"@univerjs/sheets-numfmt-ui",
|
||||
],
|
||||
},
|
||||
|
||||
// 빌드 설정
|
||||
build: {
|
||||
rollupOptions: {
|
||||
external: [],
|
||||
output: {
|
||||
manualChunks: {
|
||||
// REDI를 별도 청크로 분리하여 중복 방지
|
||||
redi: ["@wendellhu/redi"],
|
||||
// Univer 관련 라이브러리를 별도 청크로 분리
|
||||
"univer-core": [
|
||||
"@univerjs/core",
|
||||
"@univerjs/design",
|
||||
"@univerjs/engine-render",
|
||||
"@univerjs/engine-formula",
|
||||
],
|
||||
"univer-sheets": [
|
||||
"@univerjs/sheets",
|
||||
"@univerjs/sheets-ui",
|
||||
"@univerjs/sheets-formula",
|
||||
"@univerjs/sheets-formula-ui",
|
||||
"@univerjs/sheets-numfmt",
|
||||
"@univerjs/sheets-numfmt-ui",
|
||||
],
|
||||
"univer-docs": ["@univerjs/docs", "@univerjs/docs-ui"],
|
||||
"univer-ui": ["@univerjs/ui"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// 서버 설정
|
||||
server: {
|
||||
fs: {
|
||||
strict: false,
|
||||
},
|
||||
},
|
||||
|
||||
// @ts-ignore - vitest config
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "jsdom",
|
||||
|
||||
Reference in New Issue
Block a user