Compare commits

...

16 Commits

Author SHA1 Message Date
sheetEasy AI Team
fede2eda26 feat: Footer/TopBar 라우팅 및 모든 페이지 네비게이션 핸들러 개선, Suspense 오류 대응, sheetEasyAI 로고 클릭 시 랜딩 이동 기능 구현 2025-07-02 16:36:46 +09:00
sheetEasy AI Team
2f3515985d feat: 튜토리얼 UX 개선 - 네비게이션 및 프롬프트 피드백 강화
🔧 주요 개선사항:

1. Topbar 네비게이션 문제 해결
   - 튜토리얼 페이지에서 메뉴 항목 클릭 시 올바른 라우팅 구현
   - Tutorial 메뉴 클릭 시 페이지 리로드 기능 추가 (컴포넌트 리마운트)
   - 라우팅 우선, 스크롤 폴백 패턴 적용

2. PromptInput 플레이스홀더 개선
   - 튜토리얼 실행 후 실제 사용된 프롬프트를 플레이스홀더에 표시
   - 명확한 프롬프트 → 실행 → 결과 추적 가능
   - 새 튜토리얼 선택 시 이전 프롬프트 초기화

3. 새로운 튜토리얼 시스템 구축
   - TutorialSheetViewer: 단계별 튜토리얼 플로우 구현
   - TutorialCard: 개별 튜토리얼 카드 컴포넌트
   - TutorialExecutor: 튜토리얼 실행 엔진
   - TutorialDataGenerator: 10개 Excel 함수 데이터 생성

📁 변경된 파일들:
- src/App.tsx: 네비게이션 핸들러 추가
- src/components/ui/topbar.tsx: 라우팅 기반 네비게이션 구현
- src/components/sheet/PromptInput.tsx: 동적 플레이스홀더 추가
- src/components/TutorialSheetViewer.tsx: 튜토리얼 전용 뷰어 구현
- src/types/tutorial.ts: 튜토리얼 타입 정의
- .cursor/rules/tutorial-navigation-fix.mdc: 구현 패턴 문서화

 검증 완료:
- 모든 topbar 메뉴 정상 네비게이션
- 튜토리얼 페이지 리로드 기능 작동
- 실행된 프롬프트 플레이스홀더 표시
- AI 워크플로우 시뮬레이션 완성
2025-07-01 15:47:26 +09:00
sheetEasy AI Team
535281f0fb feat: 랜딩페이지 완성 및 에디터 홈 버튼 기능 구현
주요 변경사항:
- 🎨 가격 섹션을 CTA 섹션과 동일한 그라디언트 스타일로 변경
- 🗑️ 중복된 CTA 섹션 제거 및 페이지 구성 최적화
- 🏠 에디터 상단 로고 클릭 시 홈 이동 기능 추가 (저장 경고 포함)
- 📱 인증 페이지 컴포넌트 추가 (SignIn/SignUp)
- 💰 가격 정보 섹션 및 FAQ 섹션 추가
- 🔧 TopBar 컴포넌트에 로고 클릭 핸들러 추가

UI/UX 개선:
- 가격 섹션: 파란색-보라색 그라디언트 배경 적용
- 카드 스타일: 반투명 배경 및 backdrop-blur 효과
- 텍스트 색상: 그라디언트 배경에 맞는 흰색/파란색 톤 적용
- 버튼 스타일: 인기 플랜 노란색 강조, 일반 플랜 반투명 스타일

기능 추가:
- 에디터에서 로고 클릭 시 작업 손실 경고 및 홈 이동
- 완전한 인증 플로우 UI 구성
- 반응형 가격 정보 표시
2025-06-30 15:30:21 +09:00
sheetEasy AI Team
3d0a5799ff feat: 프롬프트 입력창을 Univer 위 오버레이로 변경 및 기본적으로 항상 표시되도록 개선\n\n- 프롬프트 입력창을 Univer 시트 위에 반투명 오버레이로 구현\n- 우하단 플로팅 버튼으로 토글 가능 (기본값: 항상 표시)\n- 입력창 디자인을 컴팩트하게 개선, 반응형 오버레이 적용\n- 기존 하단 고정 방식 제거, Univer 전체 화면 활용\n- 사용자 경험 및 접근성 향상 2025-06-27 16:22:58 +09:00
sheetEasy AI Team
1419bf415f feat: T-011 랜딩페이지 UI 마크업 구현 완료
🎯 주요 구현 내용:
- TopBar: Logo, Download, Account 버튼 포함한 sticky 헤더
- HeroSection: Vooster.ai 스타일 메인 영역 + 3개 핵심 가치 카드
- FeaturesSection: 6개 주요 기능 소개 카드 (반응형 그리드)
- CTASection: 가격 플랜 미리보기 + 행동 유도 버튼
- Footer: 4단 컬럼 레이아웃 + 소셜 링크

 기술적 특징:
- ShadCN UI 컴포넌트 시스템 활용
- Semantic HTML5 태그 사용 (header, main, section, footer)
- ARIA 레이블 및 키보드 네비게이션 지원
- 완전한 반응형 디자인 (모바일-태블릿-데스크톱)
- Tailwind CSS 그라데이션 및 애니메이션 효과

🎨 디자인:
- Vooster.ai 참고한 모던하고 깔끔한 UI/UX
- 라이트 모드 고정 (PRD 요구사항)
- 그리드 패턴 배경 장식
- Hover 효과 및 부드러운 전환 애니메이션

 접근성:
- WCAG 가이드라인 준수
- 모든 인터랙티브 요소에 적절한 aria-label
- 키보드만으로 완전한 탐색 가능
- 충분한 색상 대비 및 폰트 크기

🔄 기능 통합:
- App.tsx에서 랜딩페이지 ↔ 에디터 모드 전환
- 랜딩페이지에서 '시작하기' 버튼으로 에디터 진입
- 에디터에서 '홈으로' 버튼으로 랜딩페이지 복귀

📋 Acceptance Criteria 100% 달성:
 모든 기기에서 레이아웃 정상 작동
 Semantic HTML 및 ARIA 접근성 적용
 ShadCN Card, Button 컴포넌트 활용
 Vooster.ai와 비슷한 모던 디자인
 TopBar, 서비스 소개, 주요 기능, CTA 배치
 반응형 레이아웃 및 섹션별 구분
2025-06-27 15:07:07 +09:00
sheetEasy AI Team
b09a417291 히스토리 패널 작업 완료. 2025-06-27 14:47:47 +09:00
sheetEasy AI Team
e5ee01553a 히스토리 패널 UI 작업 완료
- T-008 태스크 완료: 히스토리 패널 UI 마크업 구현 (슬라이드 인)
- 히스토리, UNDO, 전송하기 버튼을 세로로 균등 간격 배치
- Tailwind CSS v3/v4 버전 충돌 문제 해결
  - v4 패키지 완전 제거 (@tailwindcss/postcss 등)
  - PostCSS 설정을 v3 방식으로 수정
  - CSS 파일에서 수동 클래스 정의 제거
  - Vite 캐시 완전 삭제로 설정 변경 반영
- 히스토리 패널 기능 개선
  - 우측 슬라이드 인 애니메이션
  - 파일 업로드 시에만 표시
  - 상태별 아이콘과 시간순 로그 리스트
  - 재적용 및 전체 삭제 기능
- 새로운 rule 파일 생성: tailwind-css-management.mdc
2025-06-26 18:25:25 +09:00
sheetEasy AI Team
2d8e4524b7 redi 이슈 해결 2025-06-26 17:01:28 +09:00
sheetEasy AI Team
71036d3727 AI커맨드 반영 셀 선택 완료 2025-06-25 19:51:38 +09:00
sheetEasy AI Team
17d17511f5 셀선택시 프롬프트 창에 자동 입력 완료 2025-06-25 16:18:23 +09:00
sheetEasy AI Team
5712c40ec9 엑셀 파일 부르기 완료, 입력창 UI 설정 완료 2025-06-24 17:48:11 +09:00
sheetEasy AI Team
105265a384 xlsx 파일 주입 완료 2025-06-24 16:38:17 +09:00
sheetEasy AI Team
164db92e06 배경 블러및 업로드 버튼 연결 완료 2025-06-24 15:18:23 +09:00
sheetEasy AI Team
ba58aaabf5 유니버CE 초기화 테스트 완료 2025-06-24 14:15:09 +09:00
sheetEasy AI Team
d9a198a157 Fix Luckysheet functionlist error and improve file processing 2025-06-23 14:35:15 +09:00
sheetEasy AI Team
de6b4debac 럭키시트 로드 가능, 옵션이 안불러짐 2025-06-21 13:58:49 +09:00
66 changed files with 20944 additions and 2982 deletions

View 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]

View 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.

View 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)

View File

@@ -0,0 +1,5 @@
---
description:
globs:
alwaysApply: false
---

View 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

View File

@@ -0,0 +1,5 @@
---
description:
globs:
alwaysApply: false
---

View File

@@ -0,0 +1,5 @@
---
description:
globs:
alwaysApply: false
---

View 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 아키텍처 특성상 필수적임
- 전역 인스턴스 관리로 브라우저 재로드 없이 안정적 운용 가능
- 개발 환경에서 오류 발생 시 반드시 캐시 삭제 후 재시작

View File

@@ -0,0 +1,5 @@
---
description:
globs:
alwaysApply: false
---

View 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

View 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

Binary file not shown.

7376
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

View File

@@ -1,6 +1,6 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
tailwindcss: {},
autoprefixer: {},
},
}

1701
public/output.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,33 +1,616 @@
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() {
const [currentView, setCurrentView] = useState<AppView>("landing");
const {
isAuthenticated,
setAuthenticated,
setUser,
user,
currentFile,
startTutorial,
setLanguage,
setHistoryPanelPosition,
} = useAppStore();
// 초기화: 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">
{/* 헤더 */}
<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>
{/* 메인 콘텐츠 */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<FileUpload />
<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>
);
}
export default App;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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 };

View 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 };

File diff suppressed because it is too large Load Diff

View File

@@ -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>
);
}

View 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;

View File

@@ -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");
});
});
});

View 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,
};

View 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 };

View 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,
};

View 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 };

View 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 };

View 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 };

View 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 };

View 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;

View 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;

View 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 };

View 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 };

View 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,
};

View 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 };

View 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>
);
};

View 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>
);
};

View File

@@ -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
View 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);
};

View File

@@ -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 />);

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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
View 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;
}

View File

@@ -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
View 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();

View 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
View 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}`;
};

View File

@@ -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;
}

View 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"],
};
}
}

View 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
View File

@@ -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 {};

View File

@@ -22,5 +22,6 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
"include": ["src"],
"exclude": ["**/__tests__/**", "**/*.test.ts", "**/*.test.tsx"]
}

View File

@@ -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"]
}

View File

@@ -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",