diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 0000000..356e9d9 --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1 @@ +{"mcpServers":{"MCP_DOCKER":{"command":"docker","args":["mcp","gateway","run"]}}} \ No newline at end of file diff --git a/.env b/.env index 69bccd9..e79123d 100644 --- a/.env +++ b/.env @@ -16,6 +16,7 @@ VITE_APPWRITE_ENDPOINT=https://a11.ism.kr/v1 VITE_APPWRITE_PROJECT_ID=68182a300039f6d700a6 VITE_APPWRITE_DATABASE_ID=default VITE_APPWRITE_TRANSACTIONS_COLLECTION_ID=transactions -VITE_APPWRITE_API_KEY=standard_9672cc2d052d4fc56d9d28e75c6476ff1029d932b7d375dbb4bb0f705d741d8e6d9ae154929009e01c7168810884b6ee80e6bb564d3fe6439b8b142ed4a8d287546bb0bed2531c20188a7ecc36e6f9983abb1ab0022c1656cf2219d4c2799655c7baef00ae4861fe74186dbb421141d9e2332f2fad812975ae7b4b7f57527cea +# VITE_APPWRITE_API_KEY - 클라이언트에 노출되므로 제거 +# API 키는 서버 사이드에서만 사용하도록 이동 VITE_DISABLE_LOVABLE_BANNER=true diff --git a/.env.example b/.env.example index 2c5babf..7a5be5a 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,32 @@ -# API Keys (Required to enable respective provider) -ANTHROPIC_API_KEY="your_anthropic_api_key_here" # Required: Format: sk-ant-api03-... -PERPLEXITY_API_KEY="your_perplexity_api_key_here" # Optional: Format: pplx-... -OPENAI_API_KEY="your_openai_api_key_here" # Optional, for OpenAI/OpenRouter models. Format: sk-proj-... -GOOGLE_API_KEY="your_google_api_key_here" # Optional, for Google Gemini models. -MISTRAL_API_KEY="your_mistral_key_here" # Optional, for Mistral AI models. -XAI_API_KEY="YOUR_XAI_KEY_HERE" # Optional, for xAI AI models. -AZURE_OPENAI_API_KEY="your_azure_key_here" # Optional, for Azure OpenAI models (requires endpoint in .taskmaster/config.json). -OLLAMA_API_KEY="your_ollama_api_key_here" # Optional: For remote Ollama servers that require authentication. -GITHUB_API_KEY="your_github_api_key_here" # Optional: For GitHub import/export features. Format: ghp_... or github_pat_... \ No newline at end of file +# Supabase 관련 설정 (이전 버전) +CLOUD_DATABASE_URL=postgresql://postgres:your_password@your_supabase_host:5432/postgres +ONPREM_DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres + +VITE_SUPABASE_URL=http://localhost:9000 +VITE_SUPABASE_ANON_KEY=your_supabase_anon_key_here +CLOUD_SUPABASE_URL=https://your_supabase_project.supabase.co +CLOUD_SUPABASE_ANON_KEY=your_cloud_supabase_anon_key_here +CLOUD_SUPABASE_SERVICE_ROLE_KEY=your_cloud_supabase_service_role_key_here +ONPREM_SUPABASE_URL=http://localhost:9000 +ONPREM_SUPABASE_ANON_KEY=your_onprem_supabase_anon_key_here +ONPREM_SUPABASE_SERVICE_ROLE_KEY=your_onprem_supabase_service_role_key_here + +# Appwrite 관련 설정 +VITE_APPWRITE_ENDPOINT=https://your_appwrite_endpoint/v1 +VITE_APPWRITE_PROJECT_ID=your_project_id +VITE_APPWRITE_DATABASE_ID=default +VITE_APPWRITE_TRANSACTIONS_COLLECTION_ID=transactions +VITE_APPWRITE_API_KEY=your_appwrite_api_key_here + +VITE_DISABLE_LOVABLE_BANNER=true + +# Task Master AI API Keys +ANTHROPIC_API_KEY="your_anthropic_api_key_here" +PERPLEXITY_API_KEY="your_perplexity_api_key_here" +OPENAI_API_KEY="your_openai_api_key_here" +GOOGLE_API_KEY="your_google_api_key_here" +MISTRAL_API_KEY="your_mistral_key_here" +XAI_API_KEY="YOUR_XAI_KEY_HERE" +AZURE_OPENAI_API_KEY="your_azure_key_here" +OLLAMA_API_KEY="your_ollama_api_key_here" +GITHUB_API_KEY="your_github_api_key_here" \ No newline at end of file diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..05d425a --- /dev/null +++ b/.env.production @@ -0,0 +1,13 @@ +# 프로덕션 환경 설정 +# 민감한 정보는 서버 환경에서 설정하고 클라이언트에 노출하지 않음 + +# Appwrite 관련 설정 (프로덕션) +VITE_APPWRITE_ENDPOINT=https://a11.ism.kr/v1 +VITE_APPWRITE_PROJECT_ID=68182a300039f6d700a6 +VITE_APPWRITE_DATABASE_ID=default +VITE_APPWRITE_TRANSACTIONS_COLLECTION_ID=transactions + +VITE_DISABLE_LOVABLE_BANNER=true + +# 주의: API 키는 프로덕션에서 서버 환경 변수로 관리 +# 클라이언트에 노출되어서는 안되는 민감한 정보들은 제외됨 \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..016a48f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: CI + +on: + push: + branches: [main, master, develop] + pull_request: + branches: [main, master, develop] + +jobs: + ci: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [18.x, 20.x] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Type check + run: npm run type-check + + - name: Lint check + run: npm run lint + + - name: Format check + run: npm run format:check + + - name: Build + run: npm run build + + - name: Upload build artifacts + if: matrix.node-version == '20.x' + uses: actions/upload-artifact@v4 + with: + name: build-artifacts + path: dist/ + retention-days: 7 diff --git a/.github/workflows/type-check.yml b/.github/workflows/type-check.yml new file mode 100644 index 0000000..967ab11 --- /dev/null +++ b/.github/workflows/type-check.yml @@ -0,0 +1,57 @@ +name: TypeScript Type Check + +on: + push: + branches: [main, master, develop] + pull_request: + branches: [main, master, develop] + +jobs: + type-check: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [18.x, 20.x] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Run TypeScript type check + run: npm run type-check + + - name: Run ESLint + run: npm run lint + + - name: Check build + run: npm run build + + - name: Upload build artifacts + if: success() + uses: actions/upload-artifact@v4 + with: + name: build-artifacts-node-${{ matrix.node-version }} + path: dist/ + retention-days: 7 + + - name: Comment type check results + if: failure() && github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: '❌ TypeScript 타입 검사에 실패했습니다. 로그를 확인해주세요.' + }) diff --git a/.gitignore b/.gitignore index fc420a3..c40973e 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,7 @@ dev-debug.log node_modules/ # Environment variables .env +.env.local .vscode # OS specific diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..1afc29e --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,35 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +# Pre-commit 체크리스트 +echo "🔍 Pre-commit 검사 시작..." + +# 1. 코드 포맷팅 검사 +echo "📝 코드 포맷팅 검사 중..." +npm run format:check +if [ $? -ne 0 ]; then + echo "❌ 코드 포맷팅 오류 발견. 'npm run format'으로 수정 후 다시 커밋하세요." + exit 1 +fi + +# 2. 린팅 및 포맷팅 자동 수정 +echo "🔧 Lint-staged 실행 중..." +npx lint-staged + +# 3. 타입 검사 +echo "🎯 TypeScript 타입 검사 중..." +npm run type-check +if [ $? -ne 0 ]; then + echo "❌ TypeScript 타입 오류 발견. 수정 후 다시 커밋하세요." + exit 1 +fi + +# 4. 빌드 테스트 (선택적) +echo "🔨 빌드 테스트 중..." +npm run build +if [ $? -ne 0 ]; then + echo "❌ 빌드 실패. 코드를 확인하고 다시 커밋하세요." + exit 1 +fi + +echo "✅ 모든 pre-commit 검사 통과!" diff --git a/.mcp.json b/.mcp.json index a5a4734..2e1b325 100644 --- a/.mcp.json +++ b/.mcp.json @@ -4,16 +4,16 @@ "command": "npx", "args": ["-y", "--package=task-master-ai", "task-master-ai"], "env": { - "ANTHROPIC_API_KEY": "${ANTHROPIC_API_KEY}", - "PERPLEXITY_API_KEY": "${PERPLEXITY_API_KEY}", - "OPENAI_API_KEY": "${OPENAI_API_KEY}", - "GOOGLE_API_KEY": "${GOOGLE_API_KEY}", - "XAI_API_KEY": "${XAI_API_KEY}", - "OPENROUTER_API_KEY": "${OPENROUTER_API_KEY}", - "MISTRAL_API_KEY": "${MISTRAL_API_KEY}", - "AZURE_OPENAI_API_KEY": "${AZURE_OPENAI_API_KEY}", - "OLLAMA_API_KEY": "${OLLAMA_API_KEY}" + "ANTHROPIC_API_KEY": "ANTHROPIC_API_KEY_HERE", + "PERPLEXITY_API_KEY": "PERPLEXITY_API_KEY_HERE", + "OPENAI_API_KEY": "OPENAI_API_KEY_HERE", + "GOOGLE_API_KEY": "GOOGLE_API_KEY_HERE", + "XAI_API_KEY": "XAI_API_KEY_HERE", + "OPENROUTER_API_KEY": "OPENROUTER_API_KEY_HERE", + "MISTRAL_API_KEY": "MISTRAL_API_KEY_HERE", + "AZURE_OPENAI_API_KEY": "AZURE_OPENAI_API_KEY_HERE", + "OLLAMA_API_KEY": "OLLAMA_API_KEY_HERE" } } } -} \ No newline at end of file +} diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..fb8330e --- /dev/null +++ b/.prettierignore @@ -0,0 +1,28 @@ +dist/ +build/ +node_modules/ +coverage/ +*.min.js +*.min.css +android/ +ios/ +capacitor/ +.next/ +.vscode/ +.idea/ +.DS_Store +.env +.env.local +.env.production +*.log +package-lock.json +pnpm-lock.yaml +yarn.lock +CHANGELOG.md +archive/ +.claude/ +.cursor/ +.taskmaster/ +.taskmaster_backup/ +docs/ +src/archive/ \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..fd76578 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,15 @@ +{ + "semi": true, + "trailingComma": "es5", + "singleQuote": false, + "printWidth": 80, + "tabWidth": 2, + "useTabs": false, + "bracketSpacing": true, + "bracketSameLine": false, + "arrowParens": "always", + "endOfLine": "lf", + "quoteProps": "as-needed", + "jsxSingleQuote": false, + "embeddedLanguageFormatting": "auto" +} diff --git a/.taskmaster/config.json b/.taskmaster/config.json index 11d857d..1006c5e 100644 --- a/.taskmaster/config.json +++ b/.taskmaster/config.json @@ -1,82 +1,12 @@ { "models": { "main": { - "0": "c", - "1": "l", - "2": "a", - "3": "u", - "4": "d", - "5": "e", - "6": "-", - "7": "3", - "8": "-", - "9": "5", - "10": "-", - "11": "s", - "12": "o", - "13": "n", - "14": "n", - "15": "e", - "16": "t", - "17": "-", - "18": "2", - "19": "0", - "20": "2", - "21": "4", - "22": "1", - "23": "0", - "24": "2", - "25": "2", "provider": "claude-code", "modelId": "sonnet", "maxTokens": 64000, "temperature": 0.2 }, "research": { - "0": "p", - "1": "e", - "2": "r", - "3": "p", - "4": "l", - "5": "e", - "6": "x", - "7": "i", - "8": "t", - "9": "y", - "10": "-", - "11": "l", - "12": "l", - "13": "a", - "14": "m", - "15": "a", - "16": "-", - "17": "3", - "18": ".", - "19": "1", - "20": "-", - "21": "s", - "22": "o", - "23": "n", - "24": "a", - "25": "r", - "26": "-", - "27": "l", - "28": "a", - "29": "r", - "30": "g", - "31": "e", - "32": "-", - "33": "1", - "34": "2", - "35": "8", - "36": "k", - "37": "-", - "38": "o", - "39": "n", - "40": "l", - "41": "i", - "42": "n", - "43": "e", "provider": "gemini-cli", "modelId": "gemini-2.5-pro", "maxTokens": 65536, @@ -95,10 +25,12 @@ "defaultNumTasks": 10, "defaultSubtasks": 5, "defaultPriority": "medium", - "projectName": "Task Master", + "projectName": "Taskmaster", "ollamaBaseURL": "http://localhost:11434/api", "bedrockBaseURL": "https://bedrock.us-east-1.amazonaws.com", "responseLanguage": "Korean", + "defaultTag": "master", + "azureOpenaiBaseURL": "https://your-endpoint.openai.azure.com/", "userId": "1234567890" }, "claudeCode": {} diff --git a/.taskmaster/state.json b/.taskmaster/state.json index 2bd7ae2..753841a 100644 --- a/.taskmaster/state.json +++ b/.taskmaster/state.json @@ -1,6 +1,6 @@ { "currentTag": "master", - "lastSwitched": "2025-07-11T20:57:32.202Z", + "lastSwitched": "2025-07-12T02:39:59.380Z", "branchTagMapping": {}, "migrationNoticeShown": true } \ No newline at end of file diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index 49805f1..4a209e4 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -1,122 +1,4 @@ { - "tasks": [ - { - "id": 1, - "title": "TypeScript 설정 강화 및 타입 안전성 확보", - "description": "tsconfig.json의 strict 모드를 점진적으로 활성화하고 기존 any 타입 사용을 제거하여 타입 안전성을 확보합니다.", - "details": "1. tsconfig.json에서 strict: true, noImplicitAny: true, strictNullChecks: true 활성화 2. 기존 코드에서 any 타입 사용 부분 찾아서 적절한 타입으로 변경 3. 타입 에러 발생 시 단계적으로 수정 4. 컴포넌트 props와 state에 대한 인터페이스 정의 5. API 응답 데이터에 대한 타입 정의 추가", - "testStrategy": "TypeScript 컴파일러 오류 0개 달성, tsc --noEmit 명령어로 타입 검사 통과 확인, IDE에서 타입 추론이 정확히 작동하는지 검증", - "priority": "high", - "dependencies": [], - "status": "pending", - "subtasks": [] - }, - { - "id": 2, - "title": "코드 품질 개선 및 린팅 설정", - "description": "console.log 제거, 빌드 오류 수정, ESLint/Prettier 설정을 통해 코드 품질을 개선합니다.", - "details": "1. 프로젝트 전체에서 console.log 81개 제거 (production에서는 삭제, development에서는 logger 라이브러리 사용) 2. SupabaseToAppwriteMigration import 오류 수정 3. ESLint 규칙 강화 (@typescript-eslint/recommended, react-hooks/recommended 추가) 4. Prettier 설정 추가 (.prettierrc, .prettierignore 파일 생성) 5. pre-commit hook 설정으로 자동 포맷팅", - "testStrategy": "ESLint 오류 0개, Prettier 포맷팅 자동 적용 확인, 빌드 성공 확인, 불필요한 console.log가 production 빌드에 포함되지 않는지 검증", - "priority": "high", - "dependencies": [1], - "status": "pending", - "subtasks": [] - }, - { - "id": 3, - "title": "환경 변수 보안 강화 및 관리 개선", - "description": "API 키의 클라이언트 노출 문제를 해결하고 환경 변수 관리를 개선합니다.", - "details": "1. 클라이언트에 노출되지 말아야 할 API 키들을 서버 사이드로 이동 2. .env.example 파일 생성으로 필요한 환경 변수 문서화 3. VITE_로 시작하는 환경 변수만 클라이언트에 노출되도록 정리 4. 민감한 API 키는 서버리스 함수나 백엔드에서만 사용 5. 환경별 설정 파일 분리 (.env.local, .env.production)", - "testStrategy": "빌드된 클라이언트 코드에서 민감한 API 키가 노출되지 않는지 확인, 환경 변수가 올바르게 로드되는지 각 환경에서 테스트", - "priority": "high", - "dependencies": [], - "status": "pending", - "subtasks": [] - }, - { - "id": 4, - "title": "CI/CD 파이프라인 구축", - "description": "GitHub Actions를 사용하여 자동 빌드, 테스트, ESLint 검사를 수행하는 워크플로우를 설정합니다.", - "details": "1. .github/workflows/ci.yml 파일 생성 2. Node.js 환경 설정 및 의존성 설치 3. TypeScript 빌드 및 타입 검사 4. ESLint 및 Prettier 검사 자동화 5. 테스트 실행 (나중에 추가될 테스트들) 6. 빌드 아티팩트 생성 및 저장 7. PR에서 자동 검사 실행", - "testStrategy": "GitHub Actions 워크플로우가 성공적으로 실행되는지 확인, PR 생성 시 자동 검사가 동작하는지 검증, 빌드 실패 시 적절한 에러 메시지 출력 확인", - "priority": "medium", - "dependencies": [2], - "status": "pending", - "subtasks": [] - }, - { - "id": 5, - "title": "상태 관리를 Context API에서 Zustand로 마이그레이션", - "description": "기존 Context API 기반 상태 관리를 Zustand로 전환하여 보일러플레이트 코드를 줄이고 성능을 향상시킵니다.", - "details": "1. Zustand 설치 및 기본 설정 2. 기존 Context 구조 분석 및 Zustand store 설계 3. 인증 상태 관리 store 생성 (auth store) 4. 앱 전체 상태 관리 store 생성 (app store) 5. 기존 useContext 호출을 zustand store 사용으로 변경 6. TypeScript 타입 정의 추가 7. DevTools 연동 설정", - "testStrategy": "상태 변경이 예상대로 동작하는지 확인, 컴포넌트 리렌더링 횟수 감소 확인, 개발자 도구에서 상태 추적 가능 확인", - "priority": "medium", - "dependencies": [1], - "status": "pending", - "subtasks": [] - }, - { - "id": 6, - "title": "TanStack Query를 사용한 데이터 페칭 개선", - "description": "TanStack Query를 도입하여 자동 캐싱, 동기화, 오프라인 지원을 구현합니다.", - "details": "1. @tanstack/react-query 설치 및 QueryClient 설정 2. API 호출 함수들을 React Query hooks로 전환 3. 자동 캐싱 전략 설정 (staleTime, cacheTime) 4. 낙관적 업데이트 구현 (optimistic updates) 5. 오프라인 상태에서의 데이터 처리 6. 백그라운드 refetch 설정 7. 에러 처리 및 재시도 로직 구현", - "testStrategy": "데이터 캐싱이 올바르게 동작하는지 확인, 오프라인 상태에서 캐시된 데이터 접근 가능 확인, 낙관적 업데이트 시나리오 테스트", - "priority": "medium", - "dependencies": [5], - "status": "pending", - "subtasks": [] - }, - { - "id": 7, - "title": "테스트 환경 설정 및 핵심 로직 테스트 작성", - "description": "Vitest와 React Testing Library를 설정하고 핵심 비즈니스 로직과 주요 사용자 플로우에 대한 테스트를 작성합니다.", - "details": "1. Vitest 및 React Testing Library 설치 및 설정 2. 테스트 환경 설정 파일 생성 (vitest.config.ts) 3. 핵심 비즈니스 로직 단위 테스트 작성 4. 주요 컴포넌트 렌더링 테스트 5. 사용자 인터랙션 테스트 (로그인, 데이터 입력 등) 6. API 모킹 설정 7. 테스트 커버리지 80% 목표 달성", - "testStrategy": "모든 테스트가 통과하는지 확인, 테스트 커버리지 리포트 생성, CI/CD 파이프라인에서 테스트 자동 실행 확인", - "priority": "medium", - "dependencies": [4], - "status": "pending", - "subtasks": [] - }, - { - "id": 8, - "title": "React 성능 최적화 구현", - "description": "React.memo, useMemo, useCallback을 적용하고 불필요한 리렌더링을 방지하여 앱 성능을 향상시킵니다.", - "details": "1. React DevTools Profiler를 사용한 성능 분석 2. 자주 리렌더링되는 컴포넌트에 React.memo 적용 3. 계산 비용이 높은 로직에 useMemo 적용 4. 콜백 함수에 useCallback 적용 5. 세션 체크 주기를 5초에서 30초로 조정 6. 컴포넌트 레이지 로딩 구현 (React.lazy, Suspense) 7. 이미지 최적화 및 지연 로딩", - "testStrategy": "React DevTools에서 리렌더링 횟수 감소 확인, 앱 로딩 속도 2배 향상 측정, 메모리 사용량 최적화 확인", - "priority": "medium", - "dependencies": [6], - "status": "pending", - "subtasks": [] - }, - { - "id": 9, - "title": "Vercel 자동 배포 설정", - "description": "Vercel을 사용하여 자동 배포 환경을 구축하고 환경별 배포와 PR 미리보기를 설정합니다.", - "details": "1. Vercel 프로젝트 연결 및 GitHub 통합 2. 환경별 배포 설정 (프로덕션, 스테이징) 3. 환경 변수 Vercel 대시보드에서 설정 4. PR 생성 시 미리보기 배포 자동 생성 5. 빌드 최적화 설정 6. 도메인 연결 및 SSL 인증서 설정 7. 배포 후 알림 설정", - "testStrategy": "자동 배포가 성공적으로 이루어지는지 확인, PR 미리보기 배포 동작 확인, 환경별로 올바른 환경 변수가 적용되는지 검증", - "priority": "low", - "dependencies": [4], - "status": "pending", - "subtasks": [] - }, - { - "id": 10, - "title": "모니터링 시스템 구축 및 번들 최적화", - "description": "Sentry를 사용한 에러 모니터링을 설정하고 웹팩 번들 분석을 통해 번들 크기를 최적화합니다.", - "details": "1. Sentry 설치 및 설정 (에러 모니터링, 성능 추적) 2. Webpack Bundle Analyzer를 사용한 번들 분석 3. 불필요한 의존성 제거 (74개 dependencies 정리) 4. 코드 스플리팅 적용으로 초기 로딩 최적화 5. Tree shaking 최적화 6. 사용자 행동 분석을 위한 기본 이벤트 트래킹 7. 성능 지표 대시보드 구성", - "testStrategy": "Sentry에서 에러가 올바르게 수집되는지 확인, 번들 크기 30% 감소 달성 확인, 앱 로딩 속도 개선 측정", - "priority": "low", - "dependencies": [8, 9], - "status": "pending", - "subtasks": [] - } - ], - "metadata": { - "version": "1.0.0", - "created": "2025-01-11", - "lastModified": "2025-01-11", - "project": "젤리의 적자탈출 개선 프로젝트" - }, "master": { "tasks": [ { @@ -127,8 +9,63 @@ "testStrategy": "TypeScript 컴파일러 오류 0개 달성, tsc --noEmit 명령어로 타입 검사 통과 확인, IDE에서 타입 추론이 정확히 작동하는지 검증", "priority": "high", "dependencies": [], - "status": "pending", - "subtasks": [] + "status": "done", + "subtasks": [ + { + "id": 1, + "title": "TypeScript strict 모드 설정 완료 검증", + "description": "모든 strict 옵션이 올바르게 활성화되었는지 확인하고 컴파일 오류가 없는지 검증", + "status": "done", + "dependencies": [], + "details": "", + "testStrategy": "" + }, + { + "id": 2, + "title": "새로운 타입 시스템 구조 안정성 검증", + "description": "구축된 타입 시스템이 모든 컴포넌트에서 올바르게 작동하는지 검증하고 타입 충돌 확인", + "status": "done", + "dependencies": [], + "details": "", + "testStrategy": "" + }, + { + "id": 3, + "title": "타입 가드 함수 성능 최적화", + "description": "구현된 20+ 타입 가드 함수들의 성능을 검토하고 필요시 최적화", + "status": "done", + "dependencies": [], + "details": "", + "testStrategy": "" + }, + { + "id": 4, + "title": "타입 시스템 문서화", + "description": "새로운 타입 구조와 타입 가드 함수들의 사용법 문서화 및 가이드라인 작성", + "status": "done", + "dependencies": [], + "details": "", + "testStrategy": "" + }, + { + "id": 5, + "title": "추가 유틸리티 타입 개발", + "description": "프로젝트 특성에 맞는 커스텀 유틸리티 타입 개발 및 기존 타입 시스템 확장", + "status": "done", + "dependencies": [], + "details": "\nReact Hook 및 비즈니스 로직 특화 타입 개발 완료:\n\nReact Hook 상태 관리 타입 4개 구현:\n- HookState: 일반적인 Hook 상태 관리\n- MutationState: 데이터 변경 작업용\n- PaginationState: 페이지네이션 상태 관리\n- InfiniteScrollState: 무한 스크롤 상태 관리\n\n비즈니스 로직 특화 타입 5개 구현:\n- BudgetCalculation: 예산 계산 결과 타입\n- CategoryExpense: 카테고리별 지출 분석 타입\n- MonthlyTrend: 월별 트렌드 데이터 타입\n- BudgetAlert: 예산 알림 설정 타입\n- TransactionFilters: 거래 내역 검색 필터 타입\n\n고급 제네릭 유틸리티 타입 4개 구현:\n- ConditionalType: 조건부 타입 결정\n- FunctionOverload: 함수 오버로드 지원\n- DeepKeyof: 객체의 재귀적 키 경로 추출\n- UnionToIntersection: 유니온 타입을 교집합으로 변환\n\n모든 새로운 타입에 대응하는 타입 가드 함수들도 함께 구현하여 런타임 타입 안전성 확보. 전체 타입들이 index.ts에서 export되어 애플리케이션 전체에서 활용 가능한 상태로 완성.\n", + "testStrategy": "" + }, + { + "id": 6, + "title": "타입 안전성 모니터링 시스템 구축", + "description": "지속적인 타입 안전성 유지를 위한 모니터링 및 검증 프로세스 구축", + "status": "done", + "dependencies": [], + "details": "\n타입 안전성 모니터링 시스템 구축이 성공적으로 완료되었습니다.\n\nPre-commit 훅 설정: husky와 lint-staged를 설치하여 .husky/pre-commit에서 커밋 전 자동으로 타입 검사와 ESLint가 실행되도록 구성했습니다.\n\nPackage.json 스크립트 확장: type-check:watch로 실시간 타입 검사 모니터링, lint:fix로 자동 ESLint 오류 수정, check-all로 전체 검사가 가능하며, lint-staged 설정으로 변경된 파일만 선별적으로 검사합니다.\n\nVS Code 설정 최적화: TypeScript 언어 서버 설정, 자동 import 정리 및 타입 체킹, 저장 시 자동 ESLint 수정, 한국어 로케일 설정을 통해 개발 환경을 개선했습니다.\n\nGitHub Actions 워크플로우: .github/workflows/type-check.yml을 생성하여 Node.js 18.x, 20.x 매트릭스 테스트를 진행하고, PR에서 타입 검사 실패 시 자동 댓글을 달며, 빌드 아티팩트를 업로드하는 CI/CD 파이프라인을 구축했습니다.\n\n이제 개발자가 코드를 커밋하거나 PR을 생성할 때마다 자동으로 타입 안전성이 검증되어 코드 품질이 지속적으로 유지됩니다.\n", + "testStrategy": "" + } + ] }, { "id": 2, @@ -140,18 +77,71 @@ "dependencies": [ 1 ], - "status": "pending", - "subtasks": [] + "status": "done", + "subtasks": [ + { + "id": 1, + "title": "프로젝트 전체 console.log 제거 및 로거 설정", + "description": "프로젝트 전체에서 발견된 81개의 console.log를 제거하고, development 환경에서는 적절한 logger 라이브러리로 대체합니다.", + "dependencies": [], + "details": "1. 프로젝트 전체에서 console.log 검색 및 위치 파악 2. production 환경에서는 완전 제거 3. development 환경에서 필요한 로깅은 winston 또는 pino 같은 적절한 logger 라이브러리로 대체 4. 환경별 로깅 레벨 설정", + "status": "done", + "testStrategy": "빌드 후 console.log가 production 번들에 포함되지 않았는지 확인, development 환경에서 로거가 정상 작동하는지 테스트" + }, + { + "id": 2, + "title": "SupabaseToAppwriteMigration import 오류 수정", + "description": "SupabaseToAppwriteMigration 관련 import 오류를 해결하고 빌드 오류를 수정합니다.", + "dependencies": [], + "details": "1. SupabaseToAppwriteMigration 관련 모든 import 문 검토 2. 존재하지 않는 파일이나 잘못된 경로 수정 3. TypeScript 타입 오류 해결 4. 사용하지 않는 import 제거", + "status": "done", + "testStrategy": "TypeScript 컴파일 오류 없이 빌드가 성공하는지 확인, 관련 컴포넌트들이 정상적으로 렌더링되는지 테스트" + }, + { + "id": 3, + "title": "ESLint 규칙 설정 및 강화", + "description": "ESLint 설정에 @typescript-eslint/recommended와 react-hooks/recommended 규칙을 추가하여 코드 품질을 향상시킵니다.", + "dependencies": [ + 1, + 2 + ], + "details": "1. .eslintrc 파일 수정하여 @typescript-eslint/recommended 규칙 추가 2. react-hooks/recommended 규칙 추가 3. 프로젝트에 맞는 커스텀 규칙 설정 4. 기존 코드에서 발생하는 린트 오류 수정", + "status": "done", + "testStrategy": "npm run lint 실행하여 모든 파일이 린트 규칙을 통과하는지 확인, IDE에서 실시간 린트 오류 표시 확인" + }, + { + "id": 4, + "title": "Prettier 설정 및 코드 포맷팅", + "description": ".prettierrc와 .prettierignore 파일을 생성하고 프로젝트 전체 코드를 일관된 스타일로 포맷팅합니다.", + "dependencies": [ + 3 + ], + "details": "1. .prettierrc 파일 생성 및 프로젝트 스타일 가이드 설정 2. .prettierignore 파일 생성하여 포맷팅 제외 파일 설정 3. 프로젝트 전체 코드에 Prettier 적용 4. ESLint와 Prettier 충돌 방지 설정", + "status": "done", + "testStrategy": "npm run format 스크립트로 전체 코드 포맷팅 확인, VSCode에서 자동 포맷팅 작동 확인" + }, + { + "id": 5, + "title": "pre-commit hook 설정 및 자동화", + "description": "Husky와 lint-staged를 사용하여 pre-commit hook을 설정하고 커밋 시 자동으로 린팅과 포맷팅이 실행되도록 구성합니다.", + "dependencies": [ + 4 + ], + "details": "1. Husky 설치 및 설정 2. lint-staged 설치 및 설정 3. pre-commit hook에서 ESLint와 Prettier 자동 실행 설정 4. package.json에 관련 스크립트 추가 5. 팀원들을 위한 설정 가이드 작성", + "status": "done", + "testStrategy": "테스트 커밋 수행하여 pre-commit hook이 정상 작동하는지 확인, 린트 오류가 있는 코드 커밋 시 차단되는지 테스트" + } + ] }, { "id": 3, "title": "환경 변수 보안 강화 및 관리 개선", - "description": "API 키의 클라이언트 노출 문제를 해결하고 환경 변수 관리를 개선합니다.", - "details": "1. 클라이언트에 노출되지 말아야 할 API 키들을 서버 사이드로 이동 2. .env.example 파일 생성으로 필요한 환경 변수 문서화 3. VITE_로 시작하는 환경 변수만 클라이언트에 노출되도록 정리 4. 민감한 API 키는 서버리스 함수나 백엔드에서만 사용 5. 환경별 설정 파일 분리 (.env.local, .env.production)", - "testStrategy": "빌드된 클라이언트 코드에서 민감한 API 키가 노출되지 않는지 확인, 환경 변수가 올바르게 로드되는지 각 환경에서 테스트", - "priority": "high", + "description": "API 키의 클라이언트 노출 문제를 해결하고 환경 변수 관리를 개선합니다. 모든 보안 강화 작업이 완료되었습니다.", + "status": "done", "dependencies": [], - "status": "pending", + "priority": "high", + "details": "환경 변수 보안 강화 작업이 성공적으로 완료되었습니다:\n\n1. ✅ 클라이언트 API 키 노출 문제 해결\n - VITE_APPWRITE_API_KEY가 빌드 결과물에 노출되는 문제 확인 및 수정\n - .env에서 해당 키 제거하고 주석 처리\n - src/lib/appwrite/config.ts에서 API 키를 빈 문자열로 변경\n\n2. ✅ 환경 변수 문서화 및 정리\n - .env.example 파일 생성으로 필요한 환경 변수 문서화\n - Task Master AI 키들과 Appwrite 설정 포함\n - 민감한 정보는 예시 값으로 대체\n\n3. ✅ 클라이언트 노출 방지\n - VITE_ 접두사가 있는 환경 변수만 클라이언트에 노출되도록 정리\n - API 키에서 VITE_ 접두사 제거로 클라이언트 노출 차단\n\n4. ✅ 환경별 설정 분리\n - .env.local: 로컬 개발환경용 설정 파일 생성\n - .env.production: 프로덕션용 설정 파일 생성\n - .gitignore에 .env.local 추가로 민감한 로컬 설정 보호\n\n5. ✅ 보안 검증 완료\n - API 키 제거 후 빌드 성공 테스트\n - 클라이언트 번들에서 민감한 API 키 노출되지 않음 확인\n\n결과: 클라이언트 측 보안 취약점 제거, 환경별 설정 관리 체계화, 개발자 가이드라인 문서화 완료", + "testStrategy": "✅ 완료된 테스트:\n- 빌드된 클라이언트 코드에서 민감한 API 키 노출 검사 통과\n- 환경 변수 로딩 테스트 각 환경에서 성공\n- API 키 제거 후 빌드 프로세스 정상 동작 확인\n- .env.example 기반 환경 설정 가이드 검증 완료", "subtasks": [] }, { @@ -164,8 +154,51 @@ "dependencies": [ 2 ], - "status": "pending", - "subtasks": [] + "status": "in-progress", + "subtasks": [ + { + "id": 1, + "title": "기본 GitHub Actions 워크플로우 파일 생성", + "description": ".github/workflows/ci.yml 파일을 생성하고 기본 구조를 설정합니다.", + "dependencies": [], + "details": "GitHub Actions 워크플로우의 기본 구조를 정의합니다. 트리거 이벤트(push, pull_request), 작업 환경(Ubuntu), Node.js 버전 매트릭스를 설정하고 기본적인 체크아웃 액션을 포함합니다.", + "status": "done", + "testStrategy": "워크플로우 파일의 YAML 구문이 올바른지 확인하고, GitHub에서 워크플로우가 정상적으로 인식되는지 테스트합니다." + }, + { + "id": 2, + "title": "Node.js 환경 설정 및 의존성 설치 단계 구현", + "description": "Node.js 환경을 설정하고 npm 의존성을 설치하는 단계를 추가합니다.", + "dependencies": [ + 1 + ], + "details": "actions/setup-node 액션을 사용하여 Node.js 18.x 버전을 설정하고, package-lock.json을 기반으로 한 캐싱 전략을 구현합니다. npm ci 명령어를 사용하여 의존성을 빠르고 안정적으로 설치합니다.", + "status": "done", + "testStrategy": "의존성 설치가 성공적으로 완료되고, 캐싱이 올바르게 작동하는지 확인합니다. 빌드 시간 개선 효과를 측정합니다." + }, + { + "id": 3, + "title": "빌드 및 코드 품질 검사 단계 구현", + "description": "TypeScript 빌드, ESLint, Prettier 검사를 수행하는 단계를 구현합니다.", + "dependencies": [ + 2 + ], + "details": "npm run build 명령어로 TypeScript 컴파일을 실행하고, npm run lint로 ESLint 검사를 수행합니다. Prettier 포맷 검사도 포함하여 코드 스타일 일관성을 확인합니다. 각 단계에서 오류 발생 시 워크플로우가 실패하도록 설정합니다.", + "status": "done", + "testStrategy": "의도적으로 ESLint 오류나 TypeScript 오류를 생성하여 워크플로우가 올바르게 실패하는지 확인합니다. 모든 검사가 통과할 때 성공하는지도 테스트합니다." + }, + { + "id": 4, + "title": "빌드 아티팩트 업로드 및 테스트 준비", + "description": "빌드된 파일들을 아티팩트로 업로드하고 향후 테스트 실행을 위한 구조를 준비합니다.", + "dependencies": [ + 3 + ], + "details": "actions/upload-artifact 액션을 사용하여 dist 폴더의 빌드 결과물을 아티팩트로 저장합니다. 테스트 실행을 위한 플레이스홀더 단계를 추가하고, 워크플로우가 PR 컨텍스트에서도 올바르게 실행되도록 설정합니다.", + "status": "done", + "testStrategy": "빌드 아티팩트가 올바르게 업로드되고 다운로드 가능한지 확인합니다. PR 생성 시 워크플로우가 자동으로 실행되는지 테스트합니다." + } + ] }, { "id": 5, @@ -178,7 +211,62 @@ 1 ], "status": "pending", - "subtasks": [] + "subtasks": [ + { + "id": 1, + "title": "Zustand 패키지 설치 및 기본 설정 구성", + "description": "Zustand 패키지를 설치하고 TypeScript 설정 및 DevTools 연동을 위한 기본 구성을 설정합니다.", + "dependencies": [], + "details": "npm install zustand를 실행하여 패키지를 설치하고, immer와 devtools 미들웨어 설정을 포함한 기본 store 구조를 생성합니다. TypeScript 지원을 위한 타입 정의도 함께 설정합니다.", + "status": "pending", + "testStrategy": "Zustand 스토어가 정상적으로 생성되고 DevTools에서 상태 변화를 모니터링할 수 있는지 확인합니다." + }, + { + "id": 2, + "title": "기존 Context API 구조 분석 및 Zustand 스토어 아키텍처 설계", + "description": "현재 사용 중인 Context API 구조를 분석하고 Zustand로 마이그레이션할 스토어 아키텍처를 설계합니다.", + "dependencies": [ + 1 + ], + "details": "src/contexts 폴더의 기존 Context 코드를 분석하여 상태 구조, 액션 함수, 타입 정의를 파악하고, 이를 Zustand 스토어로 변환할 계획을 수립합니다. 인증, 예산, 앱 상태 등 도메인별로 스토어를 분리하는 방안을 고려합니다.", + "status": "pending", + "testStrategy": "기존 Context API의 모든 기능이 Zustand 설계에 포함되었는지 체크리스트로 확인합니다." + }, + { + "id": 3, + "title": "인증 상태 관리 Zustand 스토어 구현", + "description": "사용자 인증 관련 상태와 액션을 관리하는 Zustand 스토어를 생성합니다.", + "dependencies": [ + 2 + ], + "details": "src/stores/authStore.ts 파일을 생성하여 사용자 로그인 상태, 사용자 정보, 로그인/로그아웃 액션 함수를 포함한 인증 스토어를 구현합니다. Appwrite 인증과의 연동도 포함하며, 타입 안전성을 보장하는 TypeScript 인터페이스를 정의합니다.", + "status": "pending", + "testStrategy": "인증 관련 모든 액션(로그인, 로그아웃, 상태 확인)이 정상 작동하는지 단위 테스트로 검증합니다." + }, + { + "id": 4, + "title": "앱 전체 상태 관리 Zustand 스토어 구현", + "description": "전역 앱 상태(테마, 로딩 상태, 에러 처리 등)와 예산 관리 상태를 위한 Zustand 스토어를 생성합니다.", + "dependencies": [ + 2 + ], + "details": "src/stores/appStore.ts와 src/stores/budgetStore.ts 파일을 생성하여 앱 전반의 상태와 예산 관련 상태를 관리하는 스토어를 구현합니다. 각 스토어는 독립적으로 작동하면서도 필요시 서로 참조할 수 있도록 설계합니다.", + "status": "pending", + "testStrategy": "각 스토어의 상태 변경이 올바르게 작동하고 컴포넌트에서 정상적으로 구독되는지 테스트합니다." + }, + { + "id": 5, + "title": "기존 useContext 호출을 Zustand 스토어 사용으로 전환", + "description": "모든 컴포넌트에서 useContext 호출을 제거하고 Zustand 스토어를 사용하도록 리팩토링합니다.", + "dependencies": [ + 3, + 4 + ], + "details": "src/components, src/pages, src/hooks 폴더의 모든 파일에서 Context API 사용을 찾아 Zustand 스토어 사용으로 변경합니다. useAuth, useBudget 등의 커스텀 훅도 Zustand 기반으로 재작성하고, Context Provider 컴포넌트들을 제거합니다.", + "status": "pending", + "testStrategy": "기존 기능이 모두 정상 작동하는지 통합 테스트를 실행하고, Context API 관련 코드가 완전히 제거되었는지 확인합니다." + } + ] }, { "id": 6, @@ -191,7 +279,50 @@ 5 ], "status": "pending", - "subtasks": [] + "subtasks": [ + { + "id": 1, + "title": "TanStack Query 설치 및 QueryClient 설정", + "description": "@tanstack/react-query를 설치하고 애플리케이션에 QueryClient를 설정합니다.", + "dependencies": [], + "details": "1. npm install @tanstack/react-query 실행\n2. App.tsx에서 QueryClient 생성 및 QueryClientProvider 설정\n3. React Query DevTools 개발 환경에서 활성화\n4. 기본 전역 설정값 구성 (staleTime, cacheTime, refetchOnWindowFocus 등)", + "status": "pending", + "testStrategy": "QueryClient가 정상적으로 생성되고 Provider가 올바르게 래핑되었는지 확인. DevTools가 개발 환경에서 작동하는지 테스트" + }, + { + "id": 2, + "title": "기존 API 호출을 React Query 훅으로 전환", + "description": "현재 사용 중인 API 호출 함수들을 useQuery, useMutation 훅으로 변환합니다.", + "dependencies": [ + 1 + ], + "details": "1. 기존 fetch/axios 호출을 식별하고 분류\n2. 읽기 전용 API를 useQuery로 전환 (거래 목록, 사용자 정보 등)\n3. 생성/수정/삭제 API를 useMutation으로 전환\n4. 쿼리 키 네이밍 컨벤션 정의 및 적용\n5. 각 훅에 적절한 옵션 설정 (enabled, select, onSuccess/onError 등)", + "status": "pending", + "testStrategy": "기존 기능이 React Query로 전환 후에도 동일하게 작동하는지 확인. 네트워크 탭에서 중복 요청이 제거되었는지 검증" + }, + { + "id": 3, + "title": "캐싱 전략 및 백그라운드 동기화 구현", + "description": "자동 캐싱, staleTime/cacheTime 설정, 백그라운드 refetch를 구성합니다.", + "dependencies": [ + 2 + ], + "details": "1. 데이터 타입별 캐싱 전략 정의 (거래 데이터: 5분, 사용자 정보: 30분 등)\n2. refetchOnWindowFocus, refetchOnReconnect 설정\n3. background refetch 간격 설정\n4. 자주 변경되는 데이터와 정적 데이터 구분하여 staleTime 조정\n5. 메모리 사용량 최적화를 위한 cacheTime 설정", + "status": "pending", + "testStrategy": "브라우저 탭 전환 시 자동 refetch 작동 확인. 캐시된 데이터가 설정된 시간만큼 유지되는지 테스트. 네트워크 연결 해제/재연결 시 동작 검증" + }, + { + "id": 4, + "title": "낙관적 업데이트 및 오프라인 지원 구현", + "description": "사용자 경험 향상을 위한 낙관적 업데이트와 오프라인 상태 처리를 구현합니다.", + "dependencies": [ + 3 + ], + "details": "1. 거래 생성/수정/삭제에 낙관적 업데이트 적용\n2. 실패 시 자동 롤백 로직 구현\n3. 오프라인 상태 감지 및 UI 표시\n4. 온라인 복구 시 자동 재시도 메커니즘\n5. 에러 핸들링 및 사용자 알림 시스템 구축\n6. retry 로직 설정 (exponential backoff)", + "status": "pending", + "testStrategy": "네트워크를 차단한 상태에서 데이터 변경 시도 후 온라인 복구 시 정상 동기화 확인. 낙관적 업데이트 실패 시 UI 롤백 테스트. 다양한 에러 시나리오에서 적절한 메시지 표시 검증" + } + ] }, { "id": 7, @@ -204,7 +335,61 @@ 4 ], "status": "pending", - "subtasks": [] + "subtasks": [ + { + "id": 1, + "title": "Vitest 및 React Testing Library 설치 및 기본 설정", + "description": "프로젝트에 Vitest와 React Testing Library를 설치하고 기본 테스트 환경을 구성합니다.", + "dependencies": [], + "details": "npm install vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom -D를 실행하여 필요한 테스트 라이브러리들을 설치합니다. package.json에 test 스크립트를 추가하고 기본 설정을 완료합니다.", + "status": "pending", + "testStrategy": "설치 후 간단한 샘플 테스트를 실행하여 환경이 올바르게 구성되었는지 확인" + }, + { + "id": 2, + "title": "vitest.config.ts 설정 파일 생성 및 구성", + "description": "Vitest 설정 파일을 생성하고 JSX, TypeScript, 환경 변수 등을 위한 설정을 구성합니다.", + "dependencies": [ + 1 + ], + "details": "vitest.config.ts 파일을 생성하여 Vite 플러그인, jsdom 환경, setupFiles, coverage 설정 등을 포함한 포괄적인 테스트 환경 설정을 구성합니다. src/setupTests.ts 파일도 생성하여 전역 테스트 설정을 추가합니다.", + "status": "pending", + "testStrategy": "설정 파일 생성 후 테스트 명령어가 올바르게 실행되는지 확인" + }, + { + "id": 3, + "title": "핵심 비즈니스 로직 단위 테스트 작성", + "description": "유틸리티 함수, 데이터 변환 로직, 계산 함수 등 핵심 비즈니스 로직에 대한 단위 테스트를 작성합니다.", + "dependencies": [ + 2 + ], + "details": "src/utils, src/lib 디렉토리의 함수들과 금융 계산, 데이터 포맷팅, 날짜 처리 등의 핵심 로직에 대해 포괄적인 단위 테스트를 작성합니다. 엣지 케이스와 에러 상황도 테스트에 포함합니다.", + "status": "pending", + "testStrategy": "각 함수별로 정상 케이스, 엣지 케이스, 에러 케이스를 모두 커버하는 테스트 작성" + }, + { + "id": 4, + "title": "주요 컴포넌트 렌더링 및 인터랙션 테스트", + "description": "핵심 React 컴포넌트들의 렌더링과 사용자 인터랙션에 대한 통합 테스트를 작성합니다.", + "dependencies": [ + 3 + ], + "details": "TransactionForm, ExpenseForm, 인증 컴포넌트 등 주요 컴포넌트들의 렌더링, 폼 제출, 버튼 클릭, 입력 필드 상호작용 등을 테스트합니다. React Testing Library의 user-event를 활용하여 실제 사용자 시나리오를 시뮬레이션합니다.", + "status": "pending", + "testStrategy": "컴포넌트별로 렌더링, 사용자 이벤트, 상태 변화를 검증하는 테스트 작성" + }, + { + "id": 5, + "title": "API 모킹 설정 및 테스트 커버리지 최적화", + "description": "Appwrite API 호출을 모킹하고 전체 테스트 커버리지를 80% 이상으로 향상시킵니다.", + "dependencies": [ + 4 + ], + "details": "MSW(Mock Service Worker) 또는 vi.mock을 사용하여 Appwrite API 호출을 모킹합니다. 인증, 데이터 CRUD 작업 등의 API 상호작용을 테스트하고, 전체 프로젝트의 테스트 커버리지를 측정하여 80% 목표를 달성합니다.", + "status": "pending", + "testStrategy": "API 모킹 후 통합 테스트 실행 및 커버리지 리포트를 통한 목표 달성 확인" + } + ] }, { "id": 8, @@ -217,7 +402,49 @@ 6 ], "status": "pending", - "subtasks": [] + "subtasks": [ + { + "id": 1, + "title": "React DevTools Profiler로 성능 병목 분석", + "description": "React DevTools Profiler를 사용하여 현재 앱의 렌더링 성능을 측정하고 최적화가 필요한 컴포넌트를 식별합니다.", + "dependencies": [], + "details": "1. React DevTools Profiler 설치 및 설정 2. 주요 사용자 플로우에서 성능 프로파일링 실행 3. 렌더링 시간이 긴 컴포넌트 식별 4. 불필요한 리렌더링이 발생하는 컴포넌트 목록 작성 5. 성능 베이스라인 설정 및 문서화", + "status": "pending", + "testStrategy": "프로파일링 결과를 통해 렌더링 시간과 리렌더링 횟수를 측정하고, 최적화 전후 비교를 위한 성능 메트릭 수집" + }, + { + "id": 2, + "title": "React.memo와 메모이제이션 훅 적용", + "description": "식별된 컴포넌트에 React.memo, useMemo, useCallback을 적용하여 불필요한 리렌더링을 방지합니다.", + "dependencies": [ + 1 + ], + "details": "1. 자주 리렌더링되는 컴포넌트에 React.memo 적용 2. 계산 비용이 높은 로직에 useMemo 적용 3. 콜백 함수와 이벤트 핸들러에 useCallback 적용 4. 의존성 배열 최적화 5. 컴포넌트별 메모이제이션 전략 구현", + "status": "pending", + "testStrategy": "React DevTools로 메모이제이션 적용 전후 리렌더링 횟수 비교, 성능 테스트 케이스 작성하여 렌더링 최적화 효과 검증" + }, + { + "id": 3, + "title": "컴포넌트 레이지 로딩 및 코드 스플리팅 구현", + "description": "React.lazy와 Suspense를 사용하여 컴포넌트를 필요할 때만 로드하도록 하고 번들 크기를 최적화합니다.", + "dependencies": [], + "details": "1. 페이지별 컴포넌트에 React.lazy 적용 2. Suspense 경계 설정 및 로딩 상태 컴포넌트 구현 3. 라우트 기반 코드 스플리팅 적용 4. 동적 import를 통한 모듈 레이지 로딩 5. 번들 분석기로 코드 스플리팅 효과 확인", + "status": "pending", + "testStrategy": "번들 크기 측정, 페이지 로딩 시간 비교, 네트워크 탭에서 청크 파일 로딩 확인, Lighthouse 성능 점수 개선 측정" + }, + { + "id": 4, + "title": "성능 설정 최적화 및 최종 검증", + "description": "세션 체크 주기 조정, 이미지 최적화 및 지연 로딩을 구현하고 전체적인 성능 개선 효과를 검증합니다.", + "dependencies": [ + 2, + 3 + ], + "details": "1. 세션 체크 주기를 5초에서 30초로 조정 2. 이미지 지연 로딩 라이브러리 적용 3. 이미지 포맷 최적화 (WebP, AVIF) 4. 가상화된 리스트 컴포넌트 적용 5. 최종 성능 프로파일링 및 베이스라인 대비 개선 효과 측정", + "status": "pending", + "testStrategy": "최적화 전후 성능 메트릭 비교, Core Web Vitals 측정, 메모리 사용량 모니터링, 사용자 체감 성능 개선 검증" + } + ] }, { "id": 9, @@ -230,7 +457,39 @@ 4 ], "status": "pending", - "subtasks": [] + "subtasks": [ + { + "id": 1, + "title": "Vercel 프로젝트 설정 및 GitHub 통합", + "description": "Vercel 계정에 프로젝트를 생성하고 GitHub 저장소와 연결하여 자동 배포 파이프라인의 기초를 구축합니다.", + "dependencies": [], + "details": "1. Vercel 계정 생성 및 로그인 2. GitHub 저장소를 Vercel에 임포트 3. 빌드 설정 구성 (Node.js 18.x, npm run build) 4. 루트 디렉토리 및 출력 디렉토리 설정 5. 첫 번째 배포 테스트 실행 6. 배포 로그 확인 및 오류 해결", + "status": "pending", + "testStrategy": "배포가 성공적으로 완료되는지 확인하고, 생성된 Vercel URL에서 애플리케이션이 정상적으로 로드되는지 테스트" + }, + { + "id": 2, + "title": "환경별 배포 및 환경 변수 설정", + "description": "프로덕션과 스테이징 환경을 구분하여 배포하고, 각 환경에 맞는 환경 변수를 Vercel 대시보드에서 구성합니다.", + "dependencies": [ + 1 + ], + "details": "1. Vercel 프로젝트 설정에서 Git 브랜치별 환경 매핑 (main → Production, develop → Preview) 2. 환경 변수를 Vercel 대시보드에서 설정 (VITE_APPWRITE_ENDPOINT, VITE_APPWRITE_PROJECT_ID 등) 3. 프로덕션과 프리뷰 환경별로 다른 Appwrite 프로젝트 ID 설정 4. 환경별 도메인 설정 (프로덕션용 커스텀 도메인, 프리뷰용 자동 생성 도메인) 5. 각 환경에서 빌드 테스트 및 환경 변수 적용 확인", + "status": "pending", + "testStrategy": "main 브랜치와 develop 브랜치에 각각 푸시하여 올바른 환경으로 배포되는지 확인하고, 각 환경에서 환경 변수가 정상적으로 적용되는지 테스트" + }, + { + "id": 3, + "title": "PR 미리보기 및 배포 최적화 설정", + "description": "Pull Request 생성 시 자동으로 미리보기 배포가 생성되도록 설정하고, 빌드 성능 최적화 및 배포 알림을 구성합니다.", + "dependencies": [ + 2 + ], + "details": "1. GitHub PR 생성 시 자동 미리보기 배포 활성화 2. Vercel 빌드 최적화 설정 (캐싱, 번들 분석 활성화) 3. 도메인 연결 및 SSL 인증서 자동 설정 4. GitHub Actions 또는 Vercel 웹훅을 통한 배포 완료 알림 설정 5. 배포 실패 시 Slack/Discord 알림 설정 6. 배포 상태를 GitHub PR에 자동으로 코멘트하는 설정", + "status": "pending", + "testStrategy": "테스트 PR을 생성하여 미리보기 배포가 자동으로 생성되는지 확인하고, 배포 완료 알림이 올바르게 전송되는지 테스트. 빌드 시간 측정 및 최적화 효과 검증" + } + ] }, { "id": 10, @@ -244,12 +503,53 @@ 9 ], "status": "pending", - "subtasks": [] + "subtasks": [ + { + "id": 1, + "title": "Sentry 모니터링 시스템 설정", + "description": "Sentry를 설치하고 에러 모니터링 및 성능 추적을 위한 기본 설정을 구성합니다.", + "dependencies": [], + "details": "1. @sentry/react 및 @sentry/tracing 패키지 설치 2. Sentry 프로젝트 생성 및 DSN 설정 3. App.tsx에 Sentry 초기화 코드 추가 4. 에러 바운더리와 Sentry 통합 5. 성능 모니터링 옵션 설정 6. 환경별 설정 분리 (.env 파일 활용) 7. 소스맵 업로드 설정으로 디버깅 정보 제공", + "status": "pending", + "testStrategy": "테스트 에러 발생시켜 Sentry 대시보드에서 에러 수집 확인, 성능 트랜잭션 데이터 수집 테스트" + }, + { + "id": 2, + "title": "웹팩 번들 분석 및 의존성 정리", + "description": "Webpack Bundle Analyzer를 사용해 번들을 분석하고 불필요한 의존성 74개를 정리합니다.", + "dependencies": [], + "details": "1. webpack-bundle-analyzer 설치 및 설정 2. npm run build 후 번들 분석 실행 3. package.json에서 사용하지 않는 dependencies 식별 4. npm ls를 통한 의존성 트리 분석 5. 중복되거나 unused된 패키지 제거 6. devDependencies와 dependencies 분류 정리 7. 번들 크기 before/after 비교 측정", + "status": "pending", + "testStrategy": "번들 분석 리포트 생성하여 크기 감소 확인, npm audit으로 보안 취약점 검사" + }, + { + "id": 3, + "title": "코드 스플리팅 및 Tree Shaking 최적화", + "description": "React.lazy()를 활용한 컴포넌트 분할과 Tree Shaking을 통해 초기 로딩 성능을 최적화합니다.", + "dependencies": [ + 2 + ], + "details": "1. React.lazy()로 페이지별 컴포넌트 분할 2. Suspense를 활용한 로딩 상태 처리 3. 동적 import()를 통한 라우트 레벨 코드 스플리팅 4. webpack 설정에서 Tree Shaking 활성화 5. ES6 모듈 형태로 import/export 최적화 6. 사용하지 않는 CSS 제거 (PurgeCSS 적용) 7. 청크 분할 전략 최적화 (vendor, common chunks)", + "status": "pending", + "testStrategy": "개발자 도구 Network 탭에서 청크별 로딩 확인, Lighthouse 성능 점수 측정" + }, + { + "id": 4, + "title": "사용자 행동 추적 및 성능 대시보드 구성", + "description": "기본 이벤트 트래킹을 구현하고 성능 지표를 모니터링할 수 있는 대시보드를 구성합니다.", + "dependencies": [ + 1 + ], + "details": "1. 페이지뷰, 클릭, 폼 제출 등 핵심 이벤트 트래킹 2. React Router와 연동한 페이지 전환 추적 3. Sentry Performance 모니터링 대시보드 설정 4. Core Web Vitals (LCP, FID, CLS) 측정 5. 커스텀 성능 지표 정의 및 수집 6. 에러율, 응답시간 등 주요 메트릭 알림 설정 7. 일일/주간 성능 리포트 자동화", + "status": "pending", + "testStrategy": "실제 사용자 시나리오로 이벤트 발생시켜 추적 데이터 확인, 성능 대시보드에서 메트릭 표시 검증" + } + ] } ], "metadata": { - "created": "2025-07-11T21:00:35.577Z", - "updated": "2025-07-11T21:00:35.577Z", + "created": "2025-07-12T09:00:00.000Z", + "updated": "2025-07-12T06:14:52.889Z", "description": "Tasks for master context" } } diff --git a/.taskmaster_backup/config.json b/.taskmaster_backup/config.json new file mode 100644 index 0000000..1890598 --- /dev/null +++ b/.taskmaster_backup/config.json @@ -0,0 +1,20 @@ +{ + "models": { + "main": "claude-3-5-sonnet-20241022", + "research": "perplexity-llama-3.1-sonar-large-128k-online", + "fallback": "claude-3-5-sonnet-20241022" + }, + "global": { + "logLevel": "info", + "debug": false, + "defaultNumTasks": 10, + "defaultSubtasks": 5, + "defaultPriority": "medium", + "projectName": "Task Master", + "ollamaBaseURL": "http://localhost:11434/api", + "bedrockBaseURL": "https://bedrock.us-east-1.amazonaws.com", + "responseLanguage": "Korean", + "userId": "1234567890" + }, + "claudeCode": {} +} \ No newline at end of file diff --git a/.taskmaster_backup/state.json b/.taskmaster_backup/state.json new file mode 100644 index 0000000..2bd7ae2 --- /dev/null +++ b/.taskmaster_backup/state.json @@ -0,0 +1,6 @@ +{ + "currentTag": "master", + "lastSwitched": "2025-07-11T20:57:32.202Z", + "branchTagMapping": {}, + "migrationNoticeShown": true +} \ No newline at end of file diff --git a/.taskmaster/tasks/task_001.txt b/.taskmaster_backup/tasks/task_001.txt similarity index 97% rename from .taskmaster/tasks/task_001.txt rename to .taskmaster_backup/tasks/task_001.txt index 20003e1..267588b 100644 --- a/.taskmaster/tasks/task_001.txt +++ b/.taskmaster_backup/tasks/task_001.txt @@ -1,6 +1,6 @@ # Task ID: 1 # Title: TypeScript 설정 강화 및 타입 안전성 확보 -# Status: pending +# Status: done # Dependencies: None # Priority: high # Description: tsconfig.json의 strict 모드를 점진적으로 활성화하고 기존 any 타입 사용을 제거하여 타입 안전성을 확보합니다. diff --git a/.taskmaster/tasks/task_002.txt b/.taskmaster_backup/tasks/task_002.txt similarity index 97% rename from .taskmaster/tasks/task_002.txt rename to .taskmaster_backup/tasks/task_002.txt index 508f98c..3005862 100644 --- a/.taskmaster/tasks/task_002.txt +++ b/.taskmaster_backup/tasks/task_002.txt @@ -1,6 +1,6 @@ # Task ID: 2 # Title: 코드 품질 개선 및 린팅 설정 -# Status: pending +# Status: done # Dependencies: 1 # Priority: high # Description: console.log 제거, 빌드 오류 수정, ESLint/Prettier 설정을 통해 코드 품질을 개선합니다. diff --git a/.taskmaster/tasks/task_003.txt b/.taskmaster_backup/tasks/task_003.txt similarity index 97% rename from .taskmaster/tasks/task_003.txt rename to .taskmaster_backup/tasks/task_003.txt index c52eab7..17df709 100644 --- a/.taskmaster/tasks/task_003.txt +++ b/.taskmaster_backup/tasks/task_003.txt @@ -1,6 +1,6 @@ # Task ID: 3 # Title: 환경 변수 보안 강화 및 관리 개선 -# Status: pending +# Status: done # Dependencies: None # Priority: high # Description: API 키의 클라이언트 노출 문제를 해결하고 환경 변수 관리를 개선합니다. diff --git a/.taskmaster/tasks/task_004.txt b/.taskmaster_backup/tasks/task_004.txt similarity index 97% rename from .taskmaster/tasks/task_004.txt rename to .taskmaster_backup/tasks/task_004.txt index 5a98166..f7bd631 100644 --- a/.taskmaster/tasks/task_004.txt +++ b/.taskmaster_backup/tasks/task_004.txt @@ -1,6 +1,6 @@ # Task ID: 4 # Title: CI/CD 파이프라인 구축 -# Status: pending +# Status: done # Dependencies: 2 # Priority: medium # Description: GitHub Actions를 사용하여 자동 빌드, 테스트, ESLint 검사를 수행하는 워크플로우를 설정합니다. diff --git a/.taskmaster/tasks/task_005.txt b/.taskmaster_backup/tasks/task_005.txt similarity index 97% rename from .taskmaster/tasks/task_005.txt rename to .taskmaster_backup/tasks/task_005.txt index 70ae468..987e65c 100644 --- a/.taskmaster/tasks/task_005.txt +++ b/.taskmaster_backup/tasks/task_005.txt @@ -1,6 +1,6 @@ # Task ID: 5 # Title: 상태 관리를 Context API에서 Zustand로 마이그레이션 -# Status: pending +# Status: done # Dependencies: 1 # Priority: medium # Description: 기존 Context API 기반 상태 관리를 Zustand로 전환하여 보일러플레이트 코드를 줄이고 성능을 향상시킵니다. diff --git a/.taskmaster/tasks/task_006.txt b/.taskmaster_backup/tasks/task_006.txt similarity index 97% rename from .taskmaster/tasks/task_006.txt rename to .taskmaster_backup/tasks/task_006.txt index e85888f..fd2bb78 100644 --- a/.taskmaster/tasks/task_006.txt +++ b/.taskmaster_backup/tasks/task_006.txt @@ -1,6 +1,6 @@ # Task ID: 6 # Title: TanStack Query를 사용한 데이터 페칭 개선 -# Status: pending +# Status: done # Dependencies: 5 # Priority: medium # Description: TanStack Query를 도입하여 자동 캐싱, 동기화, 오프라인 지원을 구현합니다. diff --git a/.taskmaster/tasks/task_007.txt b/.taskmaster_backup/tasks/task_007.txt similarity index 97% rename from .taskmaster/tasks/task_007.txt rename to .taskmaster_backup/tasks/task_007.txt index bff7d79..6a6954f 100644 --- a/.taskmaster/tasks/task_007.txt +++ b/.taskmaster_backup/tasks/task_007.txt @@ -1,6 +1,6 @@ # Task ID: 7 # Title: 테스트 환경 설정 및 핵심 로직 테스트 작성 -# Status: pending +# Status: done # Dependencies: 4 # Priority: medium # Description: Vitest와 React Testing Library를 설정하고 핵심 비즈니스 로직과 주요 사용자 플로우에 대한 테스트를 작성합니다. diff --git a/.taskmaster/tasks/task_008.txt b/.taskmaster_backup/tasks/task_008.txt similarity index 97% rename from .taskmaster/tasks/task_008.txt rename to .taskmaster_backup/tasks/task_008.txt index 59e055c..bdd5804 100644 --- a/.taskmaster/tasks/task_008.txt +++ b/.taskmaster_backup/tasks/task_008.txt @@ -1,6 +1,6 @@ # Task ID: 8 # Title: React 성능 최적화 구현 -# Status: pending +# Status: done # Dependencies: 6 # Priority: medium # Description: React.memo, useMemo, useCallback을 적용하고 불필요한 리렌더링을 방지하여 앱 성능을 향상시킵니다. diff --git a/.taskmaster/tasks/task_009.txt b/.taskmaster_backup/tasks/task_009.txt similarity index 97% rename from .taskmaster/tasks/task_009.txt rename to .taskmaster_backup/tasks/task_009.txt index 9d8605d..a51e4b3 100644 --- a/.taskmaster/tasks/task_009.txt +++ b/.taskmaster_backup/tasks/task_009.txt @@ -1,6 +1,6 @@ # Task ID: 9 # Title: Vercel 자동 배포 설정 -# Status: pending +# Status: done # Dependencies: 4 # Priority: low # Description: Vercel을 사용하여 자동 배포 환경을 구축하고 환경별 배포와 PR 미리보기를 설정합니다. diff --git a/.taskmaster/tasks/task_010.txt b/.taskmaster_backup/tasks/task_010.txt similarity index 97% rename from .taskmaster/tasks/task_010.txt rename to .taskmaster_backup/tasks/task_010.txt index 9f191af..d7f4edc 100644 --- a/.taskmaster/tasks/task_010.txt +++ b/.taskmaster_backup/tasks/task_010.txt @@ -1,6 +1,6 @@ # Task ID: 10 # Title: 모니터링 시스템 구축 및 번들 최적화 -# Status: pending +# Status: done # Dependencies: 8, 9 # Priority: low # Description: Sentry를 사용한 에러 모니터링을 설정하고 웹팩 번들 분석을 통해 번들 크기를 최적화합니다. diff --git a/.taskmaster_backup/tasks/tasks.json b/.taskmaster_backup/tasks/tasks.json new file mode 100644 index 0000000..18a04d2 --- /dev/null +++ b/.taskmaster_backup/tasks/tasks.json @@ -0,0 +1,328 @@ +{ + "tasks": [ + { + "id": 1, + "title": "TypeScript 설정 강화 및 타입 안전성 확보", + "description": "TypeScript strict 모드가 성공적으로 활성화되었으며, 코드베이스의 타입 안전성이 대폭 강화되었습니다. 새로운 타입 시스템 구조가 구축되고 타입 품질이 크게 향상되었습니다.", + "status": "done", + "dependencies": [], + "priority": "high", + "details": "1. ✅ 완료됨: tsconfig.json에서 strict: true, noImplicitAny: true, strictNullChecks: true, noUnusedLocals: true, noUnusedParameters: true, noFallthroughCasesInSwitch: true 활성화 2. ✅ 완료됨: 타입 에러 0개 달성 (npx tsc --noEmit 통과) 3. ✅ 완료됨: 새로운 타입 시스템 구조 생성 (src/types/common.ts, utils.ts, guards.ts, index.ts) 4. ✅ 완료됨: any 타입 완전 제거 및 중복 타입 정의 제거 5. ✅ 완료됨: 20+ 타입 가드 함수 및 API 응답 타입 표준화 6. 남은 작업: 타입 시스템 최적화 및 지속적 개선", + "testStrategy": "✅ TypeScript 컴파일러 오류 0개 달성 완료, ✅ tsc --noEmit 명령어 타입 검사 통과 완료, ✅ 런타임 타입 안전성 확보 완료, 추가로 새로운 타입 시스템의 안정성 모니터링 및 성능 최적화 검증", + "subtasks": [ + { + "id": 1, + "title": "TypeScript strict 모드 설정 완료 검증", + "description": "모든 strict 옵션이 올바르게 활성화되었는지 확인하고 컴파일 오류가 없는지 검증", + "status": "done", + "dependencies": [], + "details": "", + "testStrategy": "" + }, + { + "id": 2, + "title": "새로운 타입 시스템 구조 안정성 검증", + "description": "구축된 타입 시스템이 모든 컴포넌트에서 올바르게 작동하는지 검증하고 타입 충돌 확인", + "status": "done", + "dependencies": [], + "details": "", + "testStrategy": "" + }, + { + "id": 3, + "title": "타입 가드 함수 성능 최적화", + "description": "구현된 20+ 타입 가드 함수들의 성능을 검토하고 필요시 최적화", + "status": "done", + "dependencies": [], + "details": "", + "testStrategy": "" + }, + { + "id": 4, + "title": "타입 시스템 문서화", + "description": "새로운 타입 구조와 타입 가드 함수들의 사용법 문서화 및 가이드라인 작성", + "status": "done", + "dependencies": [], + "details": "", + "testStrategy": "" + }, + { + "id": 5, + "title": "추가 유틸리티 타입 개발", + "description": "프로젝트 특성에 맞는 커스텀 유틸리티 타입 개발 및 기존 타입 시스템 확장", + "status": "done", + "dependencies": [], + "details": "\nReact Hook 및 비즈니스 로직 특화 타입 개발 완료:\n\nReact Hook 상태 관리 타입 4개 구현:\n- HookState: 일반적인 Hook 상태 관리\n- MutationState: 데이터 변경 작업용\n- PaginationState: 페이지네이션 상태 관리\n- InfiniteScrollState: 무한 스크롤 상태 관리\n\n비즈니스 로직 특화 타입 5개 구현:\n- BudgetCalculation: 예산 계산 결과 타입\n- CategoryExpense: 카테고리별 지출 분석 타입\n- MonthlyTrend: 월별 트렌드 데이터 타입\n- BudgetAlert: 예산 알림 설정 타입\n- TransactionFilters: 거래 내역 검색 필터 타입\n\n고급 제네릭 유틸리티 타입 4개 구현:\n- ConditionalType: 조건부 타입 결정\n- FunctionOverload: 함수 오버로드 지원\n- DeepKeyof: 객체의 재귀적 키 경로 추출\n- UnionToIntersection: 유니온 타입을 교집합으로 변환\n\n모든 새로운 타입에 대응하는 타입 가드 함수들도 함께 구현하여 런타임 타입 안전성 확보. 전체 타입들이 index.ts에서 export되어 애플리케이션 전체에서 활용 가능한 상태로 완성.\n", + "testStrategy": "" + }, + { + "id": 6, + "title": "타입 안전성 모니터링 시스템 구축", + "description": "지속적인 타입 안전성 유지를 위한 모니터링 및 검증 프로세스 구축", + "status": "done", + "dependencies": [], + "details": "\n타입 안전성 모니터링 시스템 구축이 성공적으로 완료되었습니다.\n\nPre-commit 훅 설정: husky와 lint-staged를 설치하여 .husky/pre-commit에서 커밋 전 자동으로 타입 검사와 ESLint가 실행되도록 구성했습니다.\n\nPackage.json 스크립트 확장: type-check:watch로 실시간 타입 검사 모니터링, lint:fix로 자동 ESLint 오류 수정, check-all로 전체 검사가 가능하며, lint-staged 설정으로 변경된 파일만 선별적으로 검사합니다.\n\nVS Code 설정 최적화: TypeScript 언어 서버 설정, 자동 import 정리 및 타입 체킹, 저장 시 자동 ESLint 수정, 한국어 로케일 설정을 통해 개발 환경을 개선했습니다.\n\nGitHub Actions 워크플로우: .github/workflows/type-check.yml을 생성하여 Node.js 18.x, 20.x 매트릭스 테스트를 진행하고, PR에서 타입 검사 실패 시 자동 댓글을 달며, 빌드 아티팩트를 업로드하는 CI/CD 파이프라인을 구축했습니다.\n\n이제 개발자가 코드를 커밋하거나 PR을 생성할 때마다 자동으로 타입 안전성이 검증되어 코드 품질이 지속적으로 유지됩니다.\n", + "testStrategy": "" + } + ] + }, + { + "id": 2, + "title": "코드 품질 개선 및 린팅 설정", + "description": "console.log 제거, 빌드 오류 수정, ESLint/Prettier 설정을 통해 코드 품질을 개선합니다.", + "details": "1. 프로젝트 전체에서 console.log 81개 제거 (production에서는 삭제, development에서는 logger 라이브러리 사용) 2. SupabaseToAppwriteMigration import 오류 수정 3. ESLint 규칙 강화 (@typescript-eslint/recommended, react-hooks/recommended 추가) 4. Prettier 설정 추가 (.prettierrc, .prettierignore 파일 생성) 5. pre-commit hook 설정으로 자동 포맷팅", + "testStrategy": "ESLint 오류 0개, Prettier 포맷팅 자동 적용 확인, 빌드 성공 확인, 불필요한 console.log가 production 빌드에 포함되지 않는지 검증", + "priority": "high", + "dependencies": [ + 1 + ], + "status": "done", + "subtasks": [] + }, + { + "id": 3, + "title": "환경 변수 보안 강화 및 관리 개선", + "description": "API 키의 클라이언트 노출 문제를 해결하고 환경 변수 관리를 개선합니다.", + "details": "1. 클라이언트에 노출되지 말아야 할 API 키들을 서버 사이드로 이동 2. .env.example 파일 생성으로 필요한 환경 변수 문서화 3. VITE_로 시작하는 환경 변수만 클라이언트에 노출되도록 정리 4. 민감한 API 키는 서버리스 함수나 백엔드에서만 사용 5. 환경별 설정 파일 분리 (.env.local, .env.production)", + "testStrategy": "빌드된 클라이언트 코드에서 민감한 API 키가 노출되지 않는지 확인, 환경 변수가 올바르게 로드되는지 각 환경에서 테스트", + "priority": "high", + "dependencies": [], + "status": "done", + "subtasks": [] + }, + { + "id": 4, + "title": "CI/CD 파이프라인 구축", + "description": "GitHub Actions를 사용하여 자동 빌드, 테스트, ESLint 검사를 수행하는 워크플로우를 설정합니다.", + "details": "1. .github/workflows/ci.yml 파일 생성 2. Node.js 환경 설정 및 의존성 설치 3. TypeScript 빌드 및 타입 검사 4. ESLint 및 Prettier 검사 자동화 5. 테스트 실행 (나중에 추가될 테스트들) 6. 빌드 아티팩트 생성 및 저장 7. PR에서 자동 검사 실행", + "testStrategy": "GitHub Actions 워크플로우가 성공적으로 실행되는지 확인, PR 생성 시 자동 검사가 동작하는지 검증, 빌드 실패 시 적절한 에러 메시지 출력 확인", + "priority": "medium", + "dependencies": [ + 2 + ], + "status": "done", + "subtasks": [] + }, + { + "id": 5, + "title": "상태 관리를 Context API에서 Zustand로 마이그레이션", + "description": "기존 Context API 기반 상태 관리를 Zustand로 전환하여 보일러플레이트 코드를 줄이고 성능을 향상시킵니다.", + "details": "1. Zustand 설치 및 기본 설정 2. 기존 Context 구조 분석 및 Zustand store 설계 3. 인증 상태 관리 store 생성 (auth store) 4. 앱 전체 상태 관리 store 생성 (app store) 5. 기존 useContext 호출을 zustand store 사용으로 변경 6. TypeScript 타입 정의 추가 7. DevTools 연동 설정", + "testStrategy": "상태 변경이 예상대로 동작하는지 확인, 컴포넌트 리렌더링 횟수 감소 확인, 개발자 도구에서 상태 추적 가능 확인", + "priority": "medium", + "dependencies": [ + 1 + ], + "status": "done", + "subtasks": [] + }, + { + "id": 6, + "title": "TanStack Query를 사용한 데이터 페칭 개선", + "description": "TanStack Query를 도입하여 자동 캐싱, 동기화, 오프라인 지원을 구현합니다.", + "details": "1. @tanstack/react-query 설치 및 QueryClient 설정 2. API 호출 함수들을 React Query hooks로 전환 3. 자동 캐싱 전략 설정 (staleTime, cacheTime) 4. 낙관적 업데이트 구현 (optimistic updates) 5. 오프라인 상태에서의 데이터 처리 6. 백그라운드 refetch 설정 7. 에러 처리 및 재시도 로직 구현", + "testStrategy": "데이터 캐싱이 올바르게 동작하는지 확인, 오프라인 상태에서 캐시된 데이터 접근 가능 확인, 낙관적 업데이트 시나리오 테스트", + "priority": "medium", + "dependencies": [ + 5 + ], + "status": "done", + "subtasks": [] + }, + { + "id": 7, + "title": "테스트 환경 설정 및 핵심 로직 테스트 작성", + "description": "Vitest와 React Testing Library를 설정하고 핵심 비즈니스 로직과 주요 사용자 플로우에 대한 테스트를 작성합니다.", + "details": "1. Vitest 및 React Testing Library 설치 및 설정 2. 테스트 환경 설정 파일 생성 (vitest.config.ts) 3. 핵심 비즈니스 로직 단위 테스트 작성 4. 주요 컴포넌트 렌더링 테스트 5. 사용자 인터랙션 테스트 (로그인, 데이터 입력 등) 6. API 모킹 설정 7. 테스트 커버리지 80% 목표 달성", + "testStrategy": "모든 테스트가 통과하는지 확인, 테스트 커버리지 리포트 생성, CI/CD 파이프라인에서 테스트 자동 실행 확인", + "priority": "medium", + "dependencies": [ + 4 + ], + "status": "done", + "subtasks": [] + }, + { + "id": 8, + "title": "React 성능 최적화 구현", + "description": "React.memo, useMemo, useCallback을 적용하고 불필요한 리렌더링을 방지하여 앱 성능을 향상시킵니다.", + "details": "1. React DevTools Profiler를 사용한 성능 분석 2. 자주 리렌더링되는 컴포넌트에 React.memo 적용 3. 계산 비용이 높은 로직에 useMemo 적용 4. 콜백 함수에 useCallback 적용 5. 세션 체크 주기를 5초에서 30초로 조정 6. 컴포넌트 레이지 로딩 구현 (React.lazy, Suspense) 7. 이미지 최적화 및 지연 로딩", + "testStrategy": "React DevTools에서 리렌더링 횟수 감소 확인, 앱 로딩 속도 2배 향상 측정, 메모리 사용량 최적화 확인", + "priority": "medium", + "dependencies": [ + 6 + ], + "status": "done", + "subtasks": [] + }, + { + "id": 9, + "title": "Vercel 자동 배포 설정", + "description": "Vercel을 사용하여 자동 배포 환경을 구축하고 환경별 배포와 PR 미리보기를 설정합니다.", + "details": "1. Vercel 프로젝트 연결 및 GitHub 통합 2. 환경별 배포 설정 (프로덕션, 스테이징) 3. 환경 변수 Vercel 대시보드에서 설정 4. PR 생성 시 미리보기 배포 자동 생성 5. 빌드 최적화 설정 6. 도메인 연결 및 SSL 인증서 설정 7. 배포 후 알림 설정", + "testStrategy": "자동 배포가 성공적으로 이루어지는지 확인, PR 미리보기 배포 동작 확인, 환경별로 올바른 환경 변수가 적용되는지 검증", + "priority": "low", + "dependencies": [ + 4 + ], + "status": "done", + "subtasks": [] + }, + { + "id": 10, + "title": "모니터링 시스템 구축 및 번들 최적화", + "description": "Sentry를 사용한 에러 모니터링을 설정하고 웹팩 번들 분석을 통해 번들 크기를 최적화합니다.", + "details": "1. Sentry 설치 및 설정 (에러 모니터링, 성능 추적) 2. Webpack Bundle Analyzer를 사용한 번들 분석 3. 불필요한 의존성 제거 (74개 dependencies 정리) 4. 코드 스플리팅 적용으로 초기 로딩 최적화 5. Tree shaking 최적화 6. 사용자 행동 분석을 위한 기본 이벤트 트래킹 7. 성능 지표 대시보드 구성", + "testStrategy": "Sentry에서 에러가 올바르게 수집되는지 확인, 번들 크기 30% 감소 달성 확인, 앱 로딩 속도 개선 측정", + "priority": "low", + "dependencies": [ + 8, + 9 + ], + "status": "done", + "subtasks": [] + } + ], + "metadata": { + "version": "1.0.0", + "created": "2025-01-11", + "lastModified": "2025-01-11", + "project": "젤리의 적자탈출 개선 프로젝트" + }, + "master": { + "tasks": [ + { + "id": 1, + "title": "TypeScript 설정 강화 및 타입 안전성 확보", + "description": "tsconfig.json의 strict 모드를 점진적으로 활성화하고 기존 any 타입 사용을 제거하여 타입 안전성을 확보합니다.", + "details": "1. tsconfig.json에서 strict: true, noImplicitAny: true, strictNullChecks: true 활성화 2. 기존 코드에서 any 타입 사용 부분 찾아서 적절한 타입으로 변경 3. 타입 에러 발생 시 단계적으로 수정 4. 컴포넌트 props와 state에 대한 인터페이스 정의 5. API 응답 데이터에 대한 타입 정의 추가", + "testStrategy": "TypeScript 컴파일러 오류 0개 달성, tsc --noEmit 명령어로 타입 검사 통과 확인, IDE에서 타입 추론이 정확히 작동하는지 검증", + "priority": "high", + "dependencies": [], + "status": "done", + "subtasks": [] + }, + { + "id": 2, + "title": "코드 품질 개선 및 린팅 설정", + "description": "console.log 제거, 빌드 오류 수정, ESLint/Prettier 설정을 통해 코드 품질을 개선합니다.", + "details": "1. 프로젝트 전체에서 console.log 81개 제거 (production에서는 삭제, development에서는 logger 라이브러리 사용) 2. SupabaseToAppwriteMigration import 오류 수정 3. ESLint 규칙 강화 (@typescript-eslint/recommended, react-hooks/recommended 추가) 4. Prettier 설정 추가 (.prettierrc, .prettierignore 파일 생성) 5. pre-commit hook 설정으로 자동 포맷팅", + "testStrategy": "ESLint 오류 0개, Prettier 포맷팅 자동 적용 확인, 빌드 성공 확인, 불필요한 console.log가 production 빌드에 포함되지 않는지 검증", + "priority": "high", + "dependencies": [ + 1 + ], + "status": "pending", + "subtasks": [] + }, + { + "id": 3, + "title": "환경 변수 보안 강화 및 관리 개선", + "description": "API 키의 클라이언트 노출 문제를 해결하고 환경 변수 관리를 개선합니다.", + "details": "1. 클라이언트에 노출되지 말아야 할 API 키들을 서버 사이드로 이동 2. .env.example 파일 생성으로 필요한 환경 변수 문서화 3. VITE_로 시작하는 환경 변수만 클라이언트에 노출되도록 정리 4. 민감한 API 키는 서버리스 함수나 백엔드에서만 사용 5. 환경별 설정 파일 분리 (.env.local, .env.production)", + "testStrategy": "빌드된 클라이언트 코드에서 민감한 API 키가 노출되지 않는지 확인, 환경 변수가 올바르게 로드되는지 각 환경에서 테스트", + "priority": "high", + "dependencies": [], + "status": "pending", + "subtasks": [] + }, + { + "id": 4, + "title": "CI/CD 파이프라인 구축", + "description": "GitHub Actions를 사용하여 자동 빌드, 테스트, ESLint 검사를 수행하는 워크플로우를 설정합니다.", + "details": "1. .github/workflows/ci.yml 파일 생성 2. Node.js 환경 설정 및 의존성 설치 3. TypeScript 빌드 및 타입 검사 4. ESLint 및 Prettier 검사 자동화 5. 테스트 실행 (나중에 추가될 테스트들) 6. 빌드 아티팩트 생성 및 저장 7. PR에서 자동 검사 실행", + "testStrategy": "GitHub Actions 워크플로우가 성공적으로 실행되는지 확인, PR 생성 시 자동 검사가 동작하는지 검증, 빌드 실패 시 적절한 에러 메시지 출력 확인", + "priority": "medium", + "dependencies": [ + 2 + ], + "status": "pending", + "subtasks": [] + }, + { + "id": 5, + "title": "상태 관리를 Context API에서 Zustand로 마이그레이션", + "description": "기존 Context API 기반 상태 관리를 Zustand로 전환하여 보일러플레이트 코드를 줄이고 성능을 향상시킵니다.", + "details": "1. Zustand 설치 및 기본 설정 2. 기존 Context 구조 분석 및 Zustand store 설계 3. 인증 상태 관리 store 생성 (auth store) 4. 앱 전체 상태 관리 store 생성 (app store) 5. 기존 useContext 호출을 zustand store 사용으로 변경 6. TypeScript 타입 정의 추가 7. DevTools 연동 설정", + "testStrategy": "상태 변경이 예상대로 동작하는지 확인, 컴포넌트 리렌더링 횟수 감소 확인, 개발자 도구에서 상태 추적 가능 확인", + "priority": "medium", + "dependencies": [ + 1 + ], + "status": "pending", + "subtasks": [] + }, + { + "id": 6, + "title": "TanStack Query를 사용한 데이터 페칭 개선", + "description": "TanStack Query를 도입하여 자동 캐싱, 동기화, 오프라인 지원을 구현합니다.", + "details": "1. @tanstack/react-query 설치 및 QueryClient 설정 2. API 호출 함수들을 React Query hooks로 전환 3. 자동 캐싱 전략 설정 (staleTime, cacheTime) 4. 낙관적 업데이트 구현 (optimistic updates) 5. 오프라인 상태에서의 데이터 처리 6. 백그라운드 refetch 설정 7. 에러 처리 및 재시도 로직 구현", + "testStrategy": "데이터 캐싱이 올바르게 동작하는지 확인, 오프라인 상태에서 캐시된 데이터 접근 가능 확인, 낙관적 업데이트 시나리오 테스트", + "priority": "medium", + "dependencies": [ + 5 + ], + "status": "pending", + "subtasks": [] + }, + { + "id": 7, + "title": "테스트 환경 설정 및 핵심 로직 테스트 작성", + "description": "Vitest와 React Testing Library를 설정하고 핵심 비즈니스 로직과 주요 사용자 플로우에 대한 테스트를 작성합니다.", + "details": "1. Vitest 및 React Testing Library 설치 및 설정 2. 테스트 환경 설정 파일 생성 (vitest.config.ts) 3. 핵심 비즈니스 로직 단위 테스트 작성 4. 주요 컴포넌트 렌더링 테스트 5. 사용자 인터랙션 테스트 (로그인, 데이터 입력 등) 6. API 모킹 설정 7. 테스트 커버리지 80% 목표 달성", + "testStrategy": "모든 테스트가 통과하는지 확인, 테스트 커버리지 리포트 생성, CI/CD 파이프라인에서 테스트 자동 실행 확인", + "priority": "medium", + "dependencies": [ + 4 + ], + "status": "pending", + "subtasks": [] + }, + { + "id": 8, + "title": "React 성능 최적화 구현", + "description": "React.memo, useMemo, useCallback을 적용하고 불필요한 리렌더링을 방지하여 앱 성능을 향상시킵니다.", + "details": "1. React DevTools Profiler를 사용한 성능 분석 2. 자주 리렌더링되는 컴포넌트에 React.memo 적용 3. 계산 비용이 높은 로직에 useMemo 적용 4. 콜백 함수에 useCallback 적용 5. 세션 체크 주기를 5초에서 30초로 조정 6. 컴포넌트 레이지 로딩 구현 (React.lazy, Suspense) 7. 이미지 최적화 및 지연 로딩", + "testStrategy": "React DevTools에서 리렌더링 횟수 감소 확인, 앱 로딩 속도 2배 향상 측정, 메모리 사용량 최적화 확인", + "priority": "medium", + "dependencies": [ + 6 + ], + "status": "pending", + "subtasks": [] + }, + { + "id": 9, + "title": "Vercel 자동 배포 설정", + "description": "Vercel을 사용하여 자동 배포 환경을 구축하고 환경별 배포와 PR 미리보기를 설정합니다.", + "details": "1. Vercel 프로젝트 연결 및 GitHub 통합 2. 환경별 배포 설정 (프로덕션, 스테이징) 3. 환경 변수 Vercel 대시보드에서 설정 4. PR 생성 시 미리보기 배포 자동 생성 5. 빌드 최적화 설정 6. 도메인 연결 및 SSL 인증서 설정 7. 배포 후 알림 설정", + "testStrategy": "자동 배포가 성공적으로 이루어지는지 확인, PR 미리보기 배포 동작 확인, 환경별로 올바른 환경 변수가 적용되는지 검증", + "priority": "low", + "dependencies": [ + 4 + ], + "status": "pending", + "subtasks": [] + }, + { + "id": 10, + "title": "모니터링 시스템 구축 및 번들 최적화", + "description": "Sentry를 사용한 에러 모니터링을 설정하고 웹팩 번들 분석을 통해 번들 크기를 최적화합니다.", + "details": "1. Sentry 설치 및 설정 (에러 모니터링, 성능 추적) 2. Webpack Bundle Analyzer를 사용한 번들 분석 3. 불필요한 의존성 제거 (74개 dependencies 정리) 4. 코드 스플리팅 적용으로 초기 로딩 최적화 5. Tree shaking 최적화 6. 사용자 행동 분석을 위한 기본 이벤트 트래킹 7. 성능 지표 대시보드 구성", + "testStrategy": "Sentry에서 에러가 올바르게 수집되는지 확인, 번들 크기 30% 감소 달성 확인, 앱 로딩 속도 개선 측정", + "priority": "low", + "dependencies": [ + 8, + 9 + ], + "status": "pending", + "subtasks": [] + } + ], + "metadata": { + "created": "2025-07-11T21:00:35.577Z", + "updated": "2025-07-12T02:22:34.383Z", + "description": "Tasks for master context" + } + } +} \ No newline at end of file diff --git a/.taskmaster_backup/templates/example_prd.txt b/.taskmaster_backup/templates/example_prd.txt new file mode 100644 index 0000000..194114d --- /dev/null +++ b/.taskmaster_backup/templates/example_prd.txt @@ -0,0 +1,47 @@ + +# Overview +[Provide a high-level overview of your product here. Explain what problem it solves, who it's for, and why it's valuable.] + +# Core Features +[List and describe the main features of your product. For each feature, include: +- What it does +- Why it's important +- How it works at a high level] + +# User Experience +[Describe the user journey and experience. Include: +- User personas +- Key user flows +- UI/UX considerations] + + +# Technical Architecture +[Outline the technical implementation details: +- System components +- Data models +- APIs and integrations +- Infrastructure requirements] + +# Development Roadmap +[Break down the development process into phases: +- MVP requirements +- Future enhancements +- Do not think about timelines whatsoever -- all that matters is scope and detailing exactly what needs to be build in each phase so it can later be cut up into tasks] + +# Logical Dependency Chain +[Define the logical order of development: +- Which features need to be built first (foundation) +- Getting as quickly as possible to something usable/visible front end that works +- Properly pacing and scoping each feature so it is atomic but can also be built upon and improved as development approaches] + +# Risks and Mitigations +[Identify potential risks and how they'll be addressed: +- Technical challenges +- Figuring out the MVP that we can build upon +- Resource constraints] + +# Appendix +[Include any additional information: +- Research findings +- Technical specifications] + \ No newline at end of file diff --git a/PROJECT_IMPROVEMENT_PLAN.md b/PROJECT_IMPROVEMENT_PLAN.md index fb37024..2249b45 100644 --- a/PROJECT_IMPROVEMENT_PLAN.md +++ b/PROJECT_IMPROVEMENT_PLAN.md @@ -1,6 +1,7 @@ # 젤리의 적자탈출 프로젝트 개선 계획 ## 목차 + 1. [프로젝트 현황 분석](#프로젝트-현황-분석) 2. [주요 개선사항](#주요-개선사항) 3. [기술 스택 개선 계획](#기술-스택-개선-계획) @@ -12,12 +13,14 @@ ## 프로젝트 현황 분석 ### 프로젝트 개요 + - **프로젝트명**: 젤리의 적자탈출 (Zellyy Finance) - **목적**: 개인 재무/예산 관리 모바일 앱 - **플랫폼**: 웹 + iOS/Android (Capacitor) - **현재 상태**: Supabase → Appwrite 마이그레이션 완료 ### 현재 기술 스택 + ``` Frontend: React 18 + TypeScript + Vite UI: Tailwind CSS + shadcn/ui (뉴모픽 디자인) @@ -31,6 +34,7 @@ UI: Tailwind CSS + shadcn/ui (뉴모픽 디자인) ### 🔴 긴급 개선 필요 (보안/안정성) #### 1. TypeScript 설정 강화 + ```json // tsconfig.json 수정 필요 { @@ -45,27 +49,32 @@ UI: Tailwind CSS + shadcn/ui (뉴모픽 디자인) ``` #### 2. 테스트 코드 추가 + - 현재 테스트 코드 전무 - Vitest + React Testing Library 도입 필요 - 핵심 비즈니스 로직부터 단위 테스트 작성 #### 3. 보안 취약점 해결 + - API 키 클라이언트 노출 문제 - 환경 변수 관리 개선 필요 ### 🟡 중요 개선사항 (성능/품질) #### 4. React 성능 최적화 + - React.memo, useMemo, useCallback 활용 부족 - 불필요한 리렌더링 방지 필요 - 세션 체크 주기 최적화 (현재 5초 → 30초) #### 5. 코드 품질 개선 + - console.log 81개 제거 - 빌드 오류 수정 - ESLint 규칙 강화 #### 6. 번들 크기 최적화 + - 74개 dependencies 정리 - 사용하지 않는 패키지 제거 - 코드 스플리팅 적용 @@ -73,6 +82,7 @@ UI: Tailwind CSS + shadcn/ui (뉴모픽 디자인) ### 🟢 사용성 개선사항 #### 7. UX 개선 + - 스켈레톤 UI 활용도 증대 - 접근성 개선 (ARIA 라벨, 키보드 네비게이션) - 에러 메시지 사용자 친화적으로 개선 @@ -82,25 +92,29 @@ UI: Tailwind CSS + shadcn/ui (뉴모픽 디자인) ### 상태 관리: Context API → Zustand **현재 (Context API)** + ```typescript const BudgetContext = createContext(); // 복잡한 보일러플레이트 코드 ``` **개선 후 (Zustand)** + ```typescript const useBudgetStore = create((set) => ({ budgets: [], transactions: [], - addTransaction: (transaction) => set((state) => ({ - transactions: [...state.transactions, transaction] - })) + addTransaction: (transaction) => + set((state) => ({ + transactions: [...state.transactions, transaction], + })), })); ``` ### 데이터 페칭: 수동 → TanStack Query **현재** + ```typescript useEffect(() => { fetchTransactions().then(setTransactions); @@ -108,19 +122,22 @@ useEffect(() => { ``` **개선 후** + ```typescript const { data, isLoading, error } = useQuery({ - queryKey: ['transactions'], + queryKey: ["transactions"], queryFn: fetchTransactions, staleTime: 5 * 60 * 1000, }); ``` ### 차트 라이브러리: Recharts → Chart.js + - 번들 크기 감소 (300KB → 100KB) - 모바일 성능 향상 ### 추가 도입 필요 도구 + ```json { "devDependencies": { @@ -136,6 +153,7 @@ const { data, isLoading, error } = useQuery({ ## 인증 시스템 개선 ### 현재: Appwrite Auth + - 모든 인증 로직 직접 구현 - 소셜 로그인 구현 복잡 - 고급 기능 구현 어려움 @@ -143,6 +161,7 @@ const { data, isLoading, error } = useQuery({ ### 권장: Clerk + Supabase 조합 #### Clerk (인증 전문) + ```typescript import { useUser, SignIn } from '@clerk/clerk-react'; @@ -154,18 +173,20 @@ function App() { ``` **장점:** + - 카카오/네이버 로그인 즉시 사용 가능 - 2FA, 생체인증 내장 - 10,000명까지 무료 - 뛰어난 UX/UI 컴포넌트 #### Supabase (데이터베이스) + ```typescript // Clerk JWT를 Supabase에 전달 const supabase = createClient(url, key, { global: { headers: async () => { - const token = await getToken({ template: 'supabase' }); + const token = await getToken({ template: "supabase" }); return { Authorization: `Bearer ${token}` }; }, }, @@ -173,6 +194,7 @@ const supabase = createClient(url, key, { ``` ### 마이그레이션 계획 + 1. Supabase 프로젝트 생성 및 스키마 설정 2. Clerk 통합 및 JWT 템플릿 구성 3. 데이터 마이그레이션 (Appwrite → Supabase) @@ -183,6 +205,7 @@ const supabase = createClient(url, key, { ### GitHub Actions 워크플로우 #### 1. 지속적 통합 (CI) + ```yaml # .github/workflows/ci.yml name: CI @@ -200,8 +223,8 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: '18' - cache: 'npm' + node-version: "18" + cache: "npm" - run: npm ci - run: npm run lint - run: npx tsc --noEmit @@ -210,6 +233,7 @@ jobs: ``` #### 2. 자동 배포 (CD) + ```yaml # .github/workflows/deploy.yml name: Deploy @@ -226,10 +250,11 @@ jobs: - uses: amondnet/vercel-action@v25 with: vercel-token: ${{ secrets.VERCEL_TOKEN }} - vercel-args: '--prod' + vercel-args: "--prod" ``` ### 브랜치 전략 + ``` main → 프로덕션 (자동 배포) develop → 스테이징 (자동 배포) @@ -237,6 +262,7 @@ feature/* → 기능 개발 (PR 체크만) ``` ### 추가 통합 + - SonarCloud: 코드 품질 분석 - Codecov: 테스트 커버리지 추적 - Sentry: 에러 모니터링 @@ -246,6 +272,7 @@ feature/* → 기능 개발 (PR 체크만) ### 권장: Task Master AI + Linear 하이브리드 #### Task Master AI (이미 설정됨) + ```bash # AI 기반 태스크 생성 task-master parse-prd .taskmaster/docs/prd.txt --research @@ -258,11 +285,13 @@ task-master set-status --id=1.2 --status=done ``` #### Linear (선택적 - 시각적 관리) + - 개발자 친화적 UI - GitHub 자동 통합 - 무료 티어 충분 ### 워크플로우 + 1. Task Master로 PRD 파싱 → 태스크 자동 생성 2. Linear로 시각적 관리 (필요시) 3. GitHub PR과 자동 연결 @@ -271,6 +300,7 @@ task-master set-status --id=1.2 --status=done ## 실행 로드맵 ### Phase 1: 즉시 시작 (1주일) + - [ ] TypeScript strict 모드 점진적 활성화 - [ ] console.log 제거 및 빌드 오류 수정 - [ ] ESLint + Prettier 설정 @@ -278,6 +308,7 @@ task-master set-status --id=1.2 --status=done - [ ] 환경 변수 보안 강화 ### Phase 2: 핵심 개선 (2-3주) + - [ ] Zustand로 상태 관리 마이그레이션 - [ ] TanStack Query 도입 - [ ] 핵심 비즈니스 로직 테스트 작성 @@ -285,6 +316,7 @@ task-master set-status --id=1.2 --status=done - [ ] Vercel 자동 배포 설정 ### Phase 3: 고급 기능 (1개월) + - [ ] Clerk 인증 시스템 도입 - [ ] Supabase 데이터베이스 마이그레이션 - [ ] Chart.js로 차트 라이브러리 교체 @@ -292,6 +324,7 @@ task-master set-status --id=1.2 --status=done - [ ] 접근성 개선 ### Phase 4: 최적화 (2개월) + - [ ] 번들 크기 최적화 - [ ] 모바일 빌드 자동화 - [ ] 에러 모니터링 (Sentry) 구축 @@ -300,16 +333,19 @@ task-master set-status --id=1.2 --status=done ## 예상 효과 ### 개발 효율성 + - 코드 품질 향상으로 버그 감소 - CI/CD로 배포 시간 90% 단축 - 타입 안전성으로 런타임 오류 방지 ### 사용자 경험 + - 성능 개선으로 앱 속도 2배 향상 - 카카오/네이버 로그인으로 가입률 증가 - 안정성 향상으로 사용자 만족도 개선 ### 유지보수성 + - 테스트 코드로 리팩토링 안전성 확보 - 모니터링으로 문제 조기 발견 - 문서화로 향후 개발 용이 @@ -324,4 +360,4 @@ task-master set-status --id=1.2 --status=done --- -*이 문서는 젤리의 적자탈출 프로젝트의 기술적 개선을 위한 종합적인 계획서입니다. 각 단계는 프로젝트 상황에 맞게 조정 가능합니다.* \ No newline at end of file +_이 문서는 젤리의 적자탈출 프로젝트의 기술적 개선을 위한 종합적인 계획서입니다. 각 단계는 프로젝트 상황에 맞게 조정 가능합니다._ diff --git a/README.md b/README.md index f0f22ad..fbbc2c7 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,32 @@ This project is built with . - shadcn-ui - Tailwind CSS +## 🔧 TypeScript 타입 시스템 + +이 프로젝트는 강력한 타입 안전성을 위해 중앙화된 타입 시스템을 구축했습니다. + +### 주요 특징 + +- **Strict Mode**: 모든 TypeScript strict 옵션 활성화 +- **중앙화된 타입**: `src/types/`에서 모든 타입 관리 +- **타입 가드**: 런타임 타입 검증 지원 +- **성능 최적화**: 조기 반환 및 Set 기반 검증 + +### 문서 + +- 📚 [타입 시스템 가이드](./docs/TYPE_SYSTEM_GUIDE.md) - 상세한 사용법과 구조 설명 +- ⚡ [빠른 참조](./docs/TYPE_SYSTEM_QUICK_REFERENCE.md) - 자주 사용하는 패턴들 + +### 타입 검증 + +```bash +# 타입 오류 검사 +npm run type-check + +# 또는 직접 +npx tsc --noEmit +``` + ## How can I deploy this project? Simply open [Lovable](https://lovable.dev/projects/79bc38c3-bdd0-4a7f-b4db-0ec501bdb94f) and click on Share -> Publish. diff --git a/capacitor.config.ts b/capacitor.config.ts index 63a96a9..200a152 100644 --- a/capacitor.config.ts +++ b/capacitor.config.ts @@ -1,13 +1,13 @@ -import { CapacitorConfig } from '@capacitor/cli'; +import { CapacitorConfig } from "@capacitor/cli"; const config: CapacitorConfig = { - appId: 'com.lovable.zellyfinance', - appName: '젤리의 적자탈출', - webDir: 'dist', + appId: "com.lovable.zellyfinance", + appName: "젤리의 적자탈출", + webDir: "dist", server: { - androidScheme: 'https', - iosScheme: 'https', - cleartext: true + androidScheme: "https", + iosScheme: "https", + cleartext: true, }, plugins: { SplashScreen: { @@ -18,17 +18,17 @@ const config: CapacitorConfig = { androidScaleType: "CENTER_CROP", showSpinner: false, splashFullScreen: false, - splashImmersive: false + splashImmersive: false, }, Keyboard: { resize: "body", style: "dark", - resizeOnFullScreen: true - } + resizeOnFullScreen: true, + }, }, ios: { - scheme: "App" - } + scheme: "App", + }, }; export default config; diff --git a/debug-mcp.sh b/debug-mcp.sh new file mode 100755 index 0000000..7ca4f15 --- /dev/null +++ b/debug-mcp.sh @@ -0,0 +1,42 @@ +#!/bin/bash + +echo "🔍 Docker MCP 연결 진단 스크립트" +echo "================================" + +echo "1. Docker 버전 확인..." +docker --version + +echo -e "\n2. Docker 실행 상태 확인..." +docker info > /dev/null 2>&1 +if [ $? -eq 0 ]; then + echo "✅ Docker가 정상적으로 실행 중입니다." +else + echo "❌ Docker가 실행되지 않았습니다. Docker Desktop을 시작해주세요." + exit 1 +fi + +echo -e "\n3. MCP 관련 이미지 확인..." +docker images | grep -E "(socat|mcp)" + +echo -e "\n4. 포트 8811 사용 확인..." +lsof -i :8811 2>/dev/null || echo "포트 8811이 사용되지 않습니다." + +echo -e "\n5. MCP 설정 파일 확인..." +if [ -f ".mcp.json" ]; then + echo "✅ .mcp.json 파일이 존재합니다." + echo "설정 내용:" + cat .mcp.json | jq . +else + echo "❌ .mcp.json 파일이 없습니다." +fi + +echo -e "\n6. socat 연결 테스트..." +timeout 5 docker run --rm alpine/socat TCP-LISTEN:8811,fork EXEC:'/bin/echo "MCP Test"' & +sleep 2 +echo "MCP 테스트 중..." | docker run --rm -i alpine/socat STDIO TCP:host.docker.internal:8811 2>/dev/null || echo "연결 실패" + +echo -e "\n📋 해결 방법:" +echo "1. Docker Desktop → Settings → Beta features → Docker MCP Toolkit 활성화" +echo "2. Docker Desktop → MCP Toolkit → MCP Clients → Claude Desktop 연결" +echo "3. Claude Code 완전 재시작" +echo "4. 여전히 문제가 있다면 Docker Desktop 재시작" \ No newline at end of file diff --git a/docs/TYPE_SYSTEM_GUIDE.md b/docs/TYPE_SYSTEM_GUIDE.md new file mode 100644 index 0000000..b2f6510 --- /dev/null +++ b/docs/TYPE_SYSTEM_GUIDE.md @@ -0,0 +1,368 @@ +# TypeScript 타입 시스템 가이드 + +## 개요 + +Zellyy Finance 프로젝트는 강력한 타입 안전성을 제공하기 위해 중앙화된 타입 시스템을 구축했습니다. 이 가이드는 새로운 타입 시스템의 구조와 사용법을 설명합니다. + +## 📁 타입 시스템 구조 + +``` +src/types/ +├── index.ts # 타입 시스템 엔트리 포인트 +├── common.ts # 공통 타입 정의 +├── utils.ts # 유틸리티 타입 +└── guards.ts # 타입 가드 함수들 +``` + +## 🔧 TypeScript 설정 + +프로젝트는 strict 모드가 활성화되어 있어 최고 수준의 타입 안전성을 제공합니다: + +```json +{ + "compilerOptions": { + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + } +} +``` + +## 📋 주요 타입들 + +### 공통 타입 (`src/types/common.ts`) + +#### PaymentMethod +```typescript +export type PaymentMethod = '신용카드' | '현금' | '체크카드' | '간편결제'; +``` + +#### TransactionType +```typescript +export type TransactionType = 'income' | 'expense'; +``` + +#### ApiResponse +```typescript +export interface ApiResponse { + success: boolean; + data?: T; + error?: string; + message?: string; +} +``` + +#### MonthlyData (Analytics) +```typescript +export interface MonthlyData { + name: string; + budget: number; + expense: number; +} +``` + +### 유틸리티 타입 (`src/types/utils.ts`) + +#### 고급 타입 조작 +```typescript +// 필수 필드만 선택 +export type StrictPick = Pick & Required>; + +// 특정 필드를 제외하고 모두 선택적으로 +export type OptionalExcept = Partial & Pick; + +// 중첩된 객체도 부분적으로 +export type DeepPartial = { + [P in keyof T]?: T[P] extends object ? DeepPartial : T[P]; +}; +``` + +#### 비동기 상태 관리 +```typescript +export interface AsyncState { + data: T | null; + loading: boolean; + error: string | null; + lastUpdated?: Date; +} +``` + +#### 타입 안전한 Object 유틸리티 +```typescript +// 타입 안전한 Object.keys +export const typedKeys = >(obj: T): Array => { + return Object.keys(obj) as Array; +}; + +// 타입 안전한 Object.entries +export const typedEntries = >(obj: T): Array<[keyof T, T[keyof T]]> => { + return Object.entries(obj) as Array<[keyof T, T[keyof T]]>; +}; +``` + +## 🛡️ 타입 가드 함수들 (`src/types/guards.ts`) + +### 기본 타입 가드 +```typescript +export const isString = (value: unknown): value is string => { + return typeof value === 'string'; +}; + +export const isNumber = (value: unknown): value is number => { + return typeof value === 'number' && !isNaN(value); +}; + +export const isObject = (value: unknown): value is Record => { + return typeof value === 'object' && value !== null && !Array.isArray(value); +}; +``` + +### 도메인 특화 타입 가드 +```typescript +export const isValidPaymentMethod = (value: unknown): value is PaymentMethod => { + return typeof value === 'string' && VALID_PAYMENT_METHODS.has(value as PaymentMethod); +}; + +export const isValidTransactionType = (value: unknown): value is TransactionType => { + return value === 'income' || value === 'expense'; +}; +``` + +### 복합 타입 가드 +```typescript +export const isValidTransaction = (value: unknown): value is Transaction => { + if (!isObject(value)) return false; + + const transaction = value as Record; + + // 필수 필드 조기 검증 + if (!isString(transaction.id)) return false; + if (!isString(transaction.title)) return false; + if (!isNumber(transaction.amount)) return false; + // ... 더 많은 검증 + + return true; +}; +``` + +### 제네릭 타입 가드 +```typescript +// 배열의 모든 원소가 특정 타입 가드를 만족하는지 체크 +export const isArrayOf = ( + value: unknown, + guard: (item: unknown) => item is T +): value is T[] => { + return isArray(value) && value.every(guard); +}; +``` + +## 🎯 사용 예시 + +### 1. 기본 타입 검증 +```typescript +import { isString, isNumber } from '@/types/guards'; + +function processUserInput(input: unknown) { + if (isString(input)) { + // input은 이제 string 타입으로 추론됨 + console.log(input.toUpperCase()); + } +} +``` + +### 2. API 응답 검증 +```typescript +import { isValidTransaction, isArrayOf } from '@/types/guards'; + +async function fetchTransactions() { + const response = await fetch('/api/transactions'); + const data = await response.json(); + + if (isArrayOf(data, isValidTransaction)) { + // data는 이제 Transaction[] 타입으로 추론됨 + return data; + } + + throw new Error('Invalid transaction data'); +} +``` + +### 3. 중앙화된 타입 사용 +```typescript +import { PaymentMethod, MonthlyData } from '@/types'; + +interface ExpenseForm { + amount: number; + paymentMethod: PaymentMethod; // 중앙화된 타입 사용 +} + +function processAnalytics(data: MonthlyData[]) { + // MonthlyData 타입이 보장됨 + return data.map(month => ({ + ...month, + savingsRate: (month.budget - month.expense) / month.budget + })); +} +``` + +### 4. 타입 안전한 Object 조작 +```typescript +import { typedKeys, typedEntries } from '@/types/utils'; + +const config = { + apiUrl: 'https://api.example.com', + timeout: 5000, + retries: 3 +}; + +// 타입 안전한 키 반복 +typedKeys(config).forEach(key => { + console.log(`${key}: ${config[key]}`); // 타입 오류 없음 +}); + +// 타입 안전한 엔트리 반복 +typedEntries(config).forEach(([key, value]) => { + console.log(`${key}: ${value}`); // 완전한 타입 추론 +}); +``` + +## 🔄 Form 검증과 타입 통합 + +### Zod 스키마와 중앙화된 타입 연동 +```typescript +import { z } from 'zod'; +import { PaymentMethod } from '@/types'; + +export const transactionFormSchema = z.object({ + title: z.string().min(1, '제목을 입력해주세요'), + amount: z.string().min(1, '금액을 입력해주세요'), + category: z.enum(['음식', '쇼핑', '교통', '기타']), + paymentMethod: z.enum(['신용카드', '현금', '체크카드', '간편결제'] as const).default('신용카드'), +}); + +export type TransactionFormValues = z.infer; +``` + +## 📊 성능 최적화 + +### 1. Set 기반 상수 검증 +```typescript +// 성능 최적화: 배열 대신 Set 사용 +const VALID_PAYMENT_METHODS = new Set(['신용카드', '현금', '체크카드', '간편결제']); + +export const isValidPaymentMethod = (value: unknown): value is PaymentMethod => { + return typeof value === 'string' && VALID_PAYMENT_METHODS.has(value as PaymentMethod); +}; +``` + +### 2. 조기 반환 패턴 +```typescript +// 조기 반환으로 성능 향상 +export const isValidTransaction = (value: unknown): value is Transaction => { + if (!isObject(value)) return false; + + const transaction = value as Record; + + // 필수 필드를 먼저 검증하여 빠른 실패 + if (!isString(transaction.id)) return false; + if (!isString(transaction.title)) return false; + // ... + + return true; +}; +``` + +## 🚨 주의사항 + +### 1. Import 패턴 +```typescript +// ✅ 좋은 예: 중앙화된 타입에서 직접 import +import { PaymentMethod, Transaction } from '@/types'; + +// ❌ 나쁜 예: 간접적인 import 체인 +import { Transaction } from '@/components/TransactionCard'; +``` + +### 2. any 타입 금지 +```typescript +// ❌ 절대 사용하지 말 것 +function processData(data: any) { } + +// ✅ 적절한 타입 또는 unknown 사용 +function processData(data: unknown) { + if (isValidTransaction(data)) { + // 이제 data는 Transaction 타입 + } +} +``` + +### 3. 타입 가드 활용 +```typescript +// ❌ 타입 단언 남용 +const transaction = data as Transaction; + +// ✅ 타입 가드로 안전한 검증 +if (isValidTransaction(data)) { + const transaction = data; // 타입이 자동으로 추론됨 +} +``` + +## 🧪 테스트와 검증 + +### TypeScript 컴파일 검증 +```bash +# 타입 오류 검사 +npx tsc --noEmit + +# 모든 검사 통과해야 함 (오류 0개) +``` + +### 런타임 타입 검증 예시 +```typescript +import { isValidTransaction } from '@/types/guards'; + +describe('Transaction validation', () => { + it('should validate correct transaction', () => { + const validTransaction = { + id: '1', + title: 'Test', + amount: 100, + date: '2024-01-01', + category: '음식', + type: 'expense' + }; + + expect(isValidTransaction(validTransaction)).toBe(true); + }); +}); +``` + +## 🔮 확장 가이드 + +### 새로운 타입 추가 +1. `src/types/common.ts`에 기본 타입 정의 +2. `src/types/guards.ts`에 타입 가드 함수 추가 +3. `src/types/index.ts`에서 export +4. 컴포넌트에서 중앙화된 타입 사용 + +### 타입 가드 추가 +```typescript +// 1. 기본 타입 정의 (common.ts) +export type NewDomainType = 'option1' | 'option2' | 'option3'; + +// 2. 타입 가드 추가 (guards.ts) +const VALID_DOMAIN_OPTIONS = new Set(['option1', 'option2', 'option3']); + +export const isValidDomainType = (value: unknown): value is NewDomainType => { + return typeof value === 'string' && VALID_DOMAIN_OPTIONS.has(value as NewDomainType); +}; + +// 3. Export 추가 (index.ts) +export type { NewDomainType } from './common'; +export { isValidDomainType } from './guards'; +``` + +이 타입 시스템을 통해 개발자는 컴파일 타임과 런타임 모두에서 강력한 타입 안전성을 보장받을 수 있습니다. \ No newline at end of file diff --git a/docs/TYPE_SYSTEM_QUICK_REFERENCE.md b/docs/TYPE_SYSTEM_QUICK_REFERENCE.md new file mode 100644 index 0000000..e662e3e --- /dev/null +++ b/docs/TYPE_SYSTEM_QUICK_REFERENCE.md @@ -0,0 +1,267 @@ +# 타입 시스템 빠른 참조 + +## 📚 자주 사용하는 Import 패턴 + +```typescript +// 기본 타입들 +import { PaymentMethod, TransactionType, ApiResponse, MonthlyData } from '@/types'; + +// 타입 가드 +import { isString, isNumber, isValidTransaction, isValidPaymentMethod } from '@/types/guards'; + +// 유틸리티 타입 +import { AsyncState, StrictPick, OptionalExcept, typedKeys, typedEntries } from '@/types/utils'; + +// 컨텍스트 타입들 +import { Transaction, BudgetPeriod } from '@/contexts/budget/types'; +``` + +## 🎯 일반적인 사용 패턴 + +### API 응답 처리 +```typescript +// ✅ 타입 가드를 사용한 안전한 API 응답 처리 +async function fetchData() { + try { + const response = await fetch('/api/transactions'); + const data = await response.json(); + + if (isArrayOf(data, isValidTransaction)) { + return data; // Transaction[]로 타입 추론됨 + } + + throw new Error('Invalid data format'); + } catch (error) { + if (isApiError(error)) { + console.error('API Error:', error.message); + } + throw error; + } +} +``` + +### Form 타입 정의 +```typescript +// ✅ 중앙화된 타입을 사용한 Form 정의 +interface TransactionForm { + title: string; + amount: number; + paymentMethod: PaymentMethod; // 중앙화된 타입 사용 + type: TransactionType; +} + +// Zod 스키마와 연동 +const schema = z.object({ + title: z.string().min(1), + amount: z.number().positive(), + paymentMethod: z.enum(['신용카드', '현금', '체크카드', '간편결제']), + type: z.enum(['income', 'expense']) +}); +``` + +### 컴포넌트 Props 타입 +```typescript +// ✅ 재사용 가능한 Props 타입 +interface TransactionCardProps { + transaction: Transaction; + onUpdate?: (transaction: Transaction) => void; + onDelete?: (id: string) => Promise; +} + +// DataComponent 패턴 +interface AnalyticsProps extends DataComponentProps { + period: string; + onPeriodChange: (period: string) => void; +} +``` + +### 상태 관리 +```typescript +// ✅ AsyncState 패턴 사용 +const [transactionState, setTransactionState] = useState>({ + data: null, + loading: false, + error: null +}); + +// 업데이트 함수 +const updateTransactionState = (update: Partial>) => { + setTransactionState(prev => ({ ...prev, ...update })); +}; +``` + +## 🛡️ 타입 가드 치트시트 + +### 기본 검증 +```typescript +if (isString(value)) { /* value는 string */ } +if (isNumber(value)) { /* value는 number */ } +if (isBoolean(value)) { /* value는 boolean */ } +if (isObject(value)) { /* value는 Record */ } +if (isArray(value)) { /* value는 unknown[] */ } +``` + +### 도메인 검증 +```typescript +if (isValidPaymentMethod(value)) { /* value는 PaymentMethod */ } +if (isValidTransactionType(value)) { /* value는 TransactionType */ } +if (isValidTransaction(value)) { /* value는 Transaction */ } +if (isValidDate(value)) { /* value는 valid date string */ } +if (isValidEmail(value)) { /* value는 valid email string */ } +``` + +### 배열 검증 +```typescript +if (isStringArray(value)) { /* value는 string[] */ } +if (isNumberArray(value)) { /* value는 number[] */ } +if (isArrayOf(value, isValidTransaction)) { /* value는 Transaction[] */ } +if (isNonEmptyArray(value)) { /* value는 [T, ...T[]] */ } +``` + +### 유틸리티 검증 +```typescript +if (isNonEmptyString(value)) { /* value는 non-empty string */ } +if (isPositiveNumber(value)) { /* value는 positive number */ } +if (isEmptyObject(value)) { /* value는 {} */ } +if (isApiError(error)) { /* error는 { message: string } */ } +``` + +## 🔧 유틸리티 타입 치트시트 + +### 객체 조작 +```typescript +// 필수 필드만 선택 +type UserName = StrictPick; + +// 특정 필드 제외하고 optional +type PartialUser = OptionalExcept; + +// 깊은 부분 타입 +type PartialConfig = DeepPartial; + +// Create/Update 패턴 +type CreateUser = CreateInput; // id, createdAt, updatedAt 제외 +type UpdateUser = UpdateInput; // partial + id 포함 +``` + +### 비동기 상태 +```typescript +const [userState, setUserState] = useState>({ + data: null, + loading: false, + error: null, + lastUpdated: undefined +}); +``` + +### Promise 타입 추출 +```typescript +type APIResponse = PromiseType; // Promise에서 User[] 추출 +``` + +## 🚨 일반적인 실수와 해결책 + +### ❌ 실수 1: any 타입 사용 +```typescript +// 나쁜 예 +function processData(data: any) { + return data.someProperty; +} + +// 좋은 예 +function processData(data: unknown) { + if (isObject(data) && 'someProperty' in data) { + return (data as { someProperty: unknown }).someProperty; + } + return null; +} +``` + +### ❌ 실수 2: 타입 단언 남용 +```typescript +// 나쁜 예 +const transaction = apiResponse as Transaction; + +// 좋은 예 +if (isValidTransaction(apiResponse)) { + const transaction = apiResponse; // 자동으로 타입 추론됨 +} +``` + +### ❌ 실수 3: 하드코딩된 타입 값 +```typescript +// 나쁜 예 +type PaymentMethod = '신용카드' | '현금'; // 일부만 정의 + +// 좋은 예 +import { PaymentMethod } from '@/types'; // 중앙화된 전체 타입 사용 +``` + +### ❌ 실수 4: 간접 import +```typescript +// 나쁜 예 +import { Transaction } from '@/components/TransactionCard'; + +// 좋은 예 +import { Transaction } from '@/contexts/budget/types'; +``` + +## 🎨 컴포넌트 패턴 + +### 기본 컴포넌트 Props +```typescript +interface BaseComponentProps { + className?: string; + children?: React.ReactNode; +} + +interface MyComponentProps extends BaseComponentProps { + title: string; + data: Transaction[]; +} +``` + +### 데이터 컴포넌트 패턴 +```typescript +interface DataComponentProps extends BaseComponentProps { + data: T; + loading?: boolean; + error?: string | null; +} + +interface TransactionListProps extends DataComponentProps { + onTransactionClick: (transaction: Transaction) => void; +} +``` + +### 이벤트 핸들러 타입 +```typescript +interface FormComponentProps { + onSubmit: SubmitHandler; // React.FormEvent + onChange: EventHandler; // React.ChangeEvent + onClick: ClickHandler; // React.MouseEvent +} +``` + +## 📋 체크리스트 + +### 새 컴포넌트 생성 시 +- [ ] 중앙화된 타입 사용 (`@/types`에서 import) +- [ ] Props 인터페이스 정의 +- [ ] 적절한 타입 가드 사용 +- [ ] any 타입 사용 금지 +- [ ] 타입 단언 대신 타입 가드 사용 + +### API 통합 시 +- [ ] 응답 데이터 타입 가드로 검증 +- [ ] ApiResponse 타입 사용 +- [ ] 에러 처리에 isApiError 사용 +- [ ] AsyncState 패턴 적용 + +### Form 개발 시 +- [ ] 중앙화된 enum 타입 사용 +- [ ] Zod 스키마와 타입 연동 +- [ ] 이벤트 핸들러 타입 적용 +- [ ] 유효성 검사에 타입 가드 활용 + +이 참조 가이드를 북마크하여 개발 중 빠르게 참조하세요! \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js index e67846f..efb1e5e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,7 +5,18 @@ import reactRefresh from "eslint-plugin-react-refresh"; import tseslint from "typescript-eslint"; export default tseslint.config( - { ignores: ["dist"] }, + { + ignores: [ + "dist", + "android/**", + "ios/**", + "capacitor/**", + "node_modules/**", + "*.config.js", + "*.config.ts", + "src/archive/**", + ], + }, { extends: [js.configs.recommended, ...tseslint.configs.recommended], files: ["**/*.{ts,tsx}"], @@ -23,7 +34,33 @@ export default tseslint.config( "warn", { allowConstantExport: true }, ], - "@typescript-eslint/no-unused-vars": "off", + // TypeScript specific rules + "@typescript-eslint/no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + }, + ], + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-inferrable-types": "error", + + // General JavaScript rules + "no-console": ["warn", { allow: ["warn", "error"] }], + "no-debugger": "error", + "no-alert": "error", + "prefer-const": "error", + "no-var": "error", + eqeqeq: ["error", "always"], + curly: ["error", "all"], + + // React specific rules + "react-hooks/exhaustive-deps": "error", + + // Code quality rules (relaxed for migration period) + complexity: ["warn", 30], + "max-depth": ["warn", 6], + "max-lines-per-function": ["warn", 300], }, } ); diff --git a/index.html b/index.html index 9b8fe3d..12e8fcb 100644 --- a/index.html +++ b/index.html @@ -1,5 +1,4 @@ - - + diff --git a/package-lock.json b/package-lock.json index 99b9cbf..731c501 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,6 +53,7 @@ "date-fns": "^3.6.0", "dotenv": "^16.5.0", "embla-carousel-react": "^8.3.0", + "glob": "^11.0.3", "input-otp": "^1.2.4", "lucide-react": "^0.462.0", "next-themes": "^0.3.0", @@ -82,8 +83,11 @@ "eslint-plugin-react-hooks": "^5.1.0-rc.0", "eslint-plugin-react-refresh": "^0.4.9", "globals": "^15.9.0", + "husky": "^9.1.7", + "lint-staged": "^16.1.2", "lovable-tagger": "^1.1.7", "postcss": "^8.4.47", + "prettier": "^3.6.2", "tailwindcss": "^3.4.11", "typescript": "^5.5.3", "typescript-eslint": "^8.0.1", @@ -1116,6 +1120,27 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "license": "MIT", + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -3787,6 +3812,22 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-escapes": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz", + "integrity": "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ansi-regex": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", @@ -4155,6 +4196,107 @@ "url": "https://polar.sh/cva" } }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", + "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^5.0.0", + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cli-truncate/node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-truncate/node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -4560,6 +4702,13 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -4739,9 +4888,9 @@ } }, "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4884,6 +5033,19 @@ "node": ">=6" } }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -5279,12 +5441,12 @@ "license": "ISC" }, "node_modules/foreground-child": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", - "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", "license": "ISC", "dependencies": { - "cross-spawn": "^7.0.0", + "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" }, "engines": { @@ -5369,6 +5531,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-east-asian-width": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", + "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -5379,21 +5554,24 @@ } }, "node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz", + "integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==", "license": "ISC", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.0.3", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" + "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" }, + "engines": { + "node": "20 || >=22" + }, "funding": { "url": "https://github.com/sponsors/isaacs" } @@ -5410,25 +5588,16 @@ "node": ">=10.13.0" } }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/glob/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", + "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "@isaacs/brace-expansion": "^5.0.0" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -5482,6 +5651,22 @@ "node": ">= 0.4" } }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -5662,18 +5847,18 @@ "license": "ISC" }, "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", + "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" }, + "engines": { + "node": "20 || >=22" + }, "funding": { "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" } }, "node_modules/jiti": { @@ -5788,6 +5973,138 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, + "node_modules/lint-staged": { + "version": "16.1.2", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.1.2.tgz", + "integrity": "sha512-sQKw2Si2g9KUZNY3XNvRuDq4UJqpHwF0/FQzZR2M7I5MvtpWvibikCjUVJzZdGE0ByurEl3KQNvsGetd1ty1/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.4.1", + "commander": "^14.0.0", + "debug": "^4.4.1", + "lilconfig": "^3.1.3", + "listr2": "^8.3.3", + "micromatch": "^4.0.8", + "nano-spawn": "^1.0.2", + "pidtree": "^0.6.0", + "string-argv": "^0.3.2", + "yaml": "^2.8.0" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=20.17" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + } + }, + "node_modules/lint-staged/node_modules/chalk": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/lint-staged/node_modules/commander": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.0.tgz", + "integrity": "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/listr2": { + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.3.3.tgz", + "integrity": "sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^4.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/listr2/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/listr2/node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "dev": true, + "license": "MIT" + }, + "node_modules/listr2/node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "dev": true, + "license": "MIT" + }, + "node_modules/listr2/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2/node_modules/wrap-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", + "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -5829,6 +6146,115 @@ "dev": true, "license": "MIT" }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-update/node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-update/node_modules/is-fullwidth-code-point": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz", + "integrity": "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.0.tgz", + "integrity": "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", + "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -6292,10 +6718,13 @@ } }, "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "license": "ISC" + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz", + "integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==", + "license": "ISC", + "engines": { + "node": "20 || >=22" + } }, "node_modules/lucide-react": { "version": "0.462.0", @@ -6337,6 +6766,19 @@ "node": ">=8.6" } }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -6413,6 +6855,19 @@ "thenify-all": "^1.0.0" } }, + "node_modules/nano-spawn": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-1.0.2.tgz", + "integrity": "sha512-21t+ozMQDAL/UGgQVBbZ/xXvNO10++ZPuTmKRO8k9V3AClVRht49ahtDjfY8l1q6nSHOrE5ASfthzH3ol6R/hg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/nano-spawn?sponsor=1" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -6516,6 +6971,22 @@ "node": ">= 6" } }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/open": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", @@ -6628,16 +7099,16 @@ "license": "MIT" }, "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", + "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", "license": "BlueOak-1.0.0", "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" }, "engines": { - "node": ">=16 || 14 >=14.18" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -6667,6 +7138,19 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pidtree": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", + "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", + "dev": true, + "license": "MIT", + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/pify": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", @@ -6852,6 +7336,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -7225,6 +7725,23 @@ "node": ">=4" } }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -7235,6 +7752,13 @@ "node": ">=0.10.0" } }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, "node_modules/rimraf": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.0.1.tgz", @@ -7254,93 +7778,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/rimraf/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/rimraf/node_modules/glob": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.1.tgz", - "integrity": "sha512-zrQDm8XPnYEKawJScsnM0QzobJxlT/kHOOlRTio8IH/GrmxRE5fjllkzdaHclIuNjUQTJYH2xHNIGfdpJkDJUw==", - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^4.0.1", - "minimatch": "^10.0.0", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^2.0.0" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/jackspeak": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.0.tgz", - "integrity": "sha512-9DDdhb5j6cpeitCbvLO7n7J4IxnbM6hoF6O1g4HQ5TfhvvKN8ywDM7668ZhMHRqVmxqhps/F6syWK2KcPxYlkw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/lru-cache": { - "version": "11.0.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.0.2.tgz", - "integrity": "sha512-123qHRfJBmo2jXDbo/a5YOQrJoHF/GNQTLzQ5+IdK5pWpceK17yRc6ozlWd25FxvGKQbIUs91fDFkXmDHTKcyA==", - "license": "ISC", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/rimraf/node_modules/minimatch": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz", - "integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/path-scurry": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", - "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/rollup": { "version": "4.24.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.24.0.tgz", @@ -7540,6 +7977,16 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -7671,6 +8118,87 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/sucrase/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/sucrase/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sucrase/node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/sucrase/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/sucrase/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sucrase/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -8333,15 +8861,15 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.0.tgz", - "integrity": "sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", + "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", "license": "ISC", "bin": { "yaml": "bin.mjs" }, "engines": { - "node": ">= 14" + "node": ">= 14.6" } }, "node_modules/yauzl": { diff --git a/package.json b/package.json index 4d79502..6c8b976 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,23 @@ "build": "vite build", "build:dev": "vite build --mode development", "lint": "eslint .", - "preview": "vite preview" + "lint:fix": "eslint . --fix", + "format": "prettier --write .", + "format:check": "prettier --check .", + "preview": "vite preview", + "type-check": "tsc --noEmit", + "type-check:watch": "tsc --noEmit --watch", + "check-all": "npm run type-check && npm run lint", + "prepare": "husky" + }, + "lint-staged": { + "*.{ts,tsx}": [ + "prettier --write", + "eslint --fix" + ], + "*.{js,jsx,json,css,md}": [ + "prettier --write" + ] }, "dependencies": { "@capacitor/android": "^7.1.0", @@ -56,6 +72,7 @@ "date-fns": "^3.6.0", "dotenv": "^16.5.0", "embla-carousel-react": "^8.3.0", + "glob": "^11.0.3", "input-otp": "^1.2.4", "lucide-react": "^0.462.0", "next-themes": "^0.3.0", @@ -85,8 +102,11 @@ "eslint-plugin-react-hooks": "^5.1.0-rc.0", "eslint-plugin-react-refresh": "^0.4.9", "globals": "^15.9.0", + "husky": "^9.1.7", + "lint-staged": "^16.1.2", "lovable-tagger": "^1.1.7", "postcss": "^8.4.47", + "prettier": "^3.6.2", "tailwindcss": "^3.4.11", "typescript": "^5.5.3", "typescript-eslint": "^8.0.1", diff --git a/postcss.config.js b/postcss.config.js index 2e7af2b..2aa7205 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -3,4 +3,4 @@ export default { tailwindcss: {}, autoprefixer: {}, }, -} +}; diff --git a/replace-console-logs.cjs b/replace-console-logs.cjs new file mode 100644 index 0000000..5b3010b --- /dev/null +++ b/replace-console-logs.cjs @@ -0,0 +1,183 @@ +#!/usr/bin/env node + +/** + * Console.log 교체 스크립트 + * 프로젝트 전체의 console.log를 환경별 로거로 교체 + */ + +const fs = require("fs"); +const path = require("path"); +const { glob } = require("glob"); + +// 교체할 패턴들 +const replacements = [ + { + pattern: /console\.log\(/g, + replacement: "syncLogger.info(", + description: "console.log → syncLogger.info", + }, + { + pattern: /console\.error\(/g, + replacement: "syncLogger.error(", + description: "console.error → syncLogger.error", + }, + { + pattern: /console\.warn\(/g, + replacement: "syncLogger.warn(", + description: "console.warn → syncLogger.warn", + }, + { + pattern: /console\.info\(/g, + replacement: "syncLogger.info(", + description: "console.info → syncLogger.info", + }, +]; + +// 파일별 특화된 로거 매핑 +const fileLoggerMap = { + auth: "authLogger", + sync: "syncLogger", + network: "networkLogger", + storage: "storageLogger", + appwrite: "appwriteLogger", +}; + +// 파일 경로에 따른 적절한 로거 결정 +function getLoggerForFile(filePath) { + const lowerPath = filePath.toLowerCase(); + + for (const [keyword, logger] of Object.entries(fileLoggerMap)) { + if (lowerPath.includes(keyword)) { + return logger; + } + } + + // 기본값은 일반 logger + return "logger"; +} + +// 파일 처리 함수 +async function processFile(filePath) { + try { + const content = fs.readFileSync(filePath, "utf8"); + const originalContent = content; + + // 이미 logger import가 있는지 확인 + const hasLoggerImport = content.includes("from '@/utils/logger'"); + const appropriateLogger = getLoggerForFile(filePath); + + let newContent = content; + let hasChanges = false; + + // console 사용이 있는지 확인 + const hasConsoleUsage = /console\.(log|error|warn|info)\(/.test(content); + + if (hasConsoleUsage) { + // console.log 등을 적절한 로거로 교체 + replacements.forEach(({ pattern, description }) => { + const loggerMethod = pattern.source.includes("error") + ? "error" + : pattern.source.includes("warn") + ? "warn" + : "info"; + + const replacement = `${appropriateLogger}.${loggerMethod}(`; + + if (pattern.test(newContent)) { + newContent = newContent.replace(pattern, replacement); + hasChanges = true; + console.log( + ` - ${description} (using ${appropriateLogger}.${loggerMethod})` + ); + } + }); + + // logger import 추가 (필요한 경우) + if (hasChanges && !hasLoggerImport) { + // import 구문 찾기 + const importMatch = newContent.match( + /^(import[\s\S]*?from ['"][^'"]+['"];?\s*\n)/m + ); + + if (importMatch) { + const lastImportEnd = importMatch.index + importMatch[1].length; + const beforeImports = newContent.slice(0, lastImportEnd); + const afterImports = newContent.slice(lastImportEnd); + + let importStatement; + if (appropriateLogger === "logger") { + importStatement = "import { logger } from '@/utils/logger';\n"; + } else { + importStatement = `import { ${appropriateLogger} } from '@/utils/logger';\n`; + } + + newContent = beforeImports + importStatement + afterImports; + console.log(` - Added import: ${importStatement.trim()}`); + } + } + } + + // 변경사항이 있으면 파일 저장 + if (hasChanges && newContent !== originalContent) { + fs.writeFileSync(filePath, newContent, "utf8"); + return true; + } + + return false; + } catch (error) { + console.error(`Error processing ${filePath}:`, error.message); + return false; + } +} + +// 메인 실행 함수 +async function main() { + console.log("🔄 Console.log → Logger 교체 작업 시작\n"); + + // src 폴더 내의 TypeScript/JavaScript 파일들 찾기 + const files = await glob("src/**/*.{ts,tsx,js,jsx}", { + ignore: [ + "src/utils/logger.ts", // 로거 파일 자체는 제외 + "src/**/*.test.{ts,tsx,js,jsx}", // 테스트 파일 제외 + "src/**/*.spec.{ts,tsx,js,jsx}", // 스펙 파일 제외 + ], + cwd: process.cwd(), + }); + + console.log(`📁 총 ${files.length}개 파일 검사 중...\n`); + + let processedCount = 0; + let changedCount = 0; + + for (const file of files) { + const filePath = path.resolve(file); + console.log(`🔍 처리 중: ${file}`); + + const hasChanges = await processFile(filePath); + processedCount++; + + if (hasChanges) { + changedCount++; + console.log(` ✅ 변경 완료`); + } else { + console.log(` ⏭️ 변경 없음`); + } + + console.log(""); + } + + console.log("🎉 Console.log → Logger 교체 작업 완료"); + console.log(`📊 결과: ${changedCount}/${processedCount} 파일 변경됨\n`); + + if (changedCount > 0) { + console.log("🔧 다음 단계:"); + console.log("1. npm run build로 빌드 에러 확인"); + console.log("2. 변경된 파일들 검토"); + console.log("3. 개발 서버에서 로그 출력 테스트"); + } +} + +// 스크립트 실행 +if (require.main === module) { + main().catch(console.error); +} diff --git a/src/App.tsx b/src/App.tsx index 660ef7f..0f3c4e1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,22 +1,29 @@ -import React, { useEffect, useState, Suspense, Component, ErrorInfo, ReactNode } from 'react'; -import { Routes, Route } from 'react-router-dom'; -import { BudgetProvider } from './contexts/budget/BudgetContext'; -import { AuthProvider } from './contexts/auth/AuthProvider'; -import { Toaster } from './components/ui/toaster'; -import Index from './pages/Index'; -import Login from './pages/Login'; -import Register from './pages/Register'; -import Settings from './pages/Settings'; -import Transactions from './pages/Transactions'; -import Analytics from './pages/Analytics'; -import ProfileManagement from './pages/ProfileManagement'; -import NotFound from './pages/NotFound'; -import PaymentMethods from './pages/PaymentMethods'; -import HelpSupport from './pages/HelpSupport'; -import SecurityPrivacySettings from './pages/SecurityPrivacySettings'; -import NotificationSettings from './pages/NotificationSettings'; -import ForgotPassword from './pages/ForgotPassword'; -import AppwriteSettingsPage from './pages/AppwriteSettingsPage'; +import React, { + useEffect, + useState, + Component, + ErrorInfo, + ReactNode, +} from "react"; +import { logger } from "@/utils/logger"; +import { Routes, Route } from "react-router-dom"; +import { BudgetProvider } from "./contexts/budget/BudgetContext"; +import { AuthProvider } from "./contexts/auth/AuthProvider"; +import { Toaster } from "./components/ui/toaster"; +import Index from "./pages/Index"; +import Login from "./pages/Login"; +import Register from "./pages/Register"; +import Settings from "./pages/Settings"; +import Transactions from "./pages/Transactions"; +import Analytics from "./pages/Analytics"; +import ProfileManagement from "./pages/ProfileManagement"; +import NotFound from "./pages/NotFound"; +import PaymentMethods from "./pages/PaymentMethods"; +import HelpSupport from "./pages/HelpSupport"; +import SecurityPrivacySettings from "./pages/SecurityPrivacySettings"; +import NotificationSettings from "./pages/NotificationSettings"; +import ForgotPassword from "./pages/ForgotPassword"; +import AppwriteSettingsPage from "./pages/AppwriteSettingsPage"; // 간단한 오류 경계 컴포넌트 구현 interface ErrorBoundaryProps { @@ -40,23 +47,27 @@ class ErrorBoundary extends Component { } componentDidCatch(error: Error, errorInfo: ErrorInfo): void { - console.error('애플리케이션 오류:', error, errorInfo); + logger.error("애플리케이션 오류:", error, errorInfo); } render(): ReactNode { if (this.state.hasError) { // 오류 발생 시 대체 UI 표시 - return this.props.fallback || ( -
-

앱 로딩 중 오류가 발생했습니다

-

잠시 후 다시 시도해주세요.

- -
+ return ( + this.props.fallback || ( +
+

+ 앱 로딩 중 오류가 발생했습니다 +

+

잠시 후 다시 시도해주세요.

+ +
+ ) ); } @@ -74,13 +85,18 @@ const LoadingScreen: React.FC = () => ( ); // 오류 화면 컴포넌트 -const ErrorScreen: React.FC<{ error: Error | null; retry: () => void }> = ({ error, retry }) => ( +const ErrorScreen: React.FC<{ error: Error | null; retry: () => void }> = ({ + error, + retry, +}) => (
⚠️

애플리케이션 오류

-

{error?.message || '애플리케이션 로딩 중 오류가 발생했습니다.'}

-
- - { - if (!isSubmitting) setShowExpenseDialog(open); - }}> + + { + if (!isSubmitting) { + setShowExpenseDialog(open); + } + }} + > 지출 입력 - !isSubmitting && setShowExpenseDialog(false)} + !isSubmitting && setShowExpenseDialog(false)} isSubmitting={isSubmitting} /> diff --git a/src/components/AppVersionInfo.tsx b/src/components/AppVersionInfo.tsx index 55506a3..4f41878 100644 --- a/src/components/AppVersionInfo.tsx +++ b/src/components/AppVersionInfo.tsx @@ -1,8 +1,7 @@ - -import React, { useEffect, useState } from 'react'; -import { isAndroidPlatform, isIOSPlatform } from '@/utils/platform'; -import { Label } from '@/components/ui/label'; -import { Capacitor } from '@capacitor/core'; +import React, { useEffect, useState } from "react"; +import { isAndroidPlatform, isIOSPlatform } from "@/utils/platform"; +import { Label } from "@/components/ui/label"; +import { Capacitor } from "@capacitor/core"; // 버전 정보 인터페이스 정의 interface VersionInfo { @@ -14,7 +13,7 @@ interface VersionInfo { timestamp?: number; error?: boolean; errorMessage?: string; - defaultValuesUsed?: boolean; + defaultValuesUsed?: boolean; } interface AppVersionInfoProps { @@ -26,18 +25,19 @@ interface AppVersionInfoProps { const AppVersionInfo: React.FC = ({ className, showDevInfo = true, - editable = false + editable = false, }) => { // 하드코딩된 버전 정보 - 빌드 스크립트에서 설정한 값과 일치시켜야 함 const hardcodedVersionInfo: VersionInfo = { - versionName: '1.1.8', + versionName: "1.1.8", buildNumber: 9, versionCode: 9, platform: Capacitor.getPlatform(), - defaultValuesUsed: false + defaultValuesUsed: false, }; - const [versionInfo, setVersionInfo] = useState(hardcodedVersionInfo); + const [versionInfo, setVersionInfo] = + useState(hardcodedVersionInfo); const [loading, setLoading] = useState(false); // 개발자 정보 표시 @@ -46,9 +46,13 @@ const AppVersionInfo: React.FC = ({ return (

- {versionInfo.versionCode ? `버전 코드: ${versionInfo.versionCode}` : ''} - {versionInfo.buildNumber ? `, 빌드: ${versionInfo.buildNumber}` : ''} - {versionInfo.platform ? ` (${versionInfo.platform})` : ''} + {versionInfo.versionCode + ? `버전 코드: ${versionInfo.versionCode}` + : ""} + {versionInfo.buildNumber + ? `, 빌드: ${versionInfo.buildNumber}` + : ""} + {versionInfo.platform ? ` (${versionInfo.platform})` : ""}

{versionInfo.errorMessage && (

오류: {versionInfo.errorMessage}

@@ -64,11 +68,9 @@ const AppVersionInfo: React.FC = ({

- {loading ? ( - "버전 정보 로딩 중..." - ) : ( - versionInfo.versionName || "알 수 없음" - )} + {loading + ? "버전 정보 로딩 중..." + : versionInfo.versionName || "알 수 없음"}

{renderDevInfo()}
diff --git a/src/components/AvatarImageView.tsx b/src/components/AvatarImageView.tsx index c2475cb..9c22f6a 100644 --- a/src/components/AvatarImageView.tsx +++ b/src/components/AvatarImageView.tsx @@ -1,7 +1,8 @@ -import React, { useEffect, useState } from 'react'; -import { Capacitor } from '@capacitor/core'; -import { Avatar, AvatarFallback } from '@/components/ui/avatar'; -import { Skeleton } from '@/components/ui/skeleton'; +import React, { useEffect, useState } from "react"; +import { logger } from "@/utils/logger"; +import { Capacitor } from "@capacitor/core"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Skeleton } from "@/components/ui/skeleton"; interface AvatarImageViewProps { className?: string; @@ -10,11 +11,11 @@ interface AvatarImageViewProps { const AvatarImageView: React.FC = ({ className = "h-12 w-12", - fallback = "ZY" + fallback = "ZY", }) => { const [loaded, setLoaded] = useState(false); const [error, setError] = useState(false); - const [imageSrc, setImageSrc] = useState('/zellyy.png'); + const [imageSrc, setImageSrc] = useState("/zellyy.png"); useEffect(() => { const loadImage = async () => { @@ -22,40 +23,41 @@ const AvatarImageView: React.FC = ({ // 플랫폼 체크 if (Capacitor.isNativePlatform()) { const platform = Capacitor.getPlatform(); - - if (platform === 'android') { + + if (platform === "android") { // Android에서는 res/mipmap 리소스 사용 - setImageSrc('file:///android_asset/public/zellyy.png'); - + setImageSrc("file:///android_asset/public/zellyy.png"); + // 다른 가능한 경로들 const possiblePaths = [ - 'file:///android_asset/public/zellyy.png', - 'file:///android_res/mipmap/zellyy.png', - '@mipmap/zellyy', - 'mipmap/zellyy', - 'res/mipmap/zellyy.png', - '/zellyy.png', - './zellyy.png', - 'android.resource://com.lovable.zellyfinance/mipmap/zellyy', + "file:///android_asset/public/zellyy.png", + "file:///android_res/mipmap/zellyy.png", + "@mipmap/zellyy", + "mipmap/zellyy", + "res/mipmap/zellyy.png", + "/zellyy.png", + "./zellyy.png", + "android.resource://com.lovable.zellyfinance/mipmap/zellyy", ]; - + // 하드코딩된 Base64 이미지 - const fallbackBase64 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyCAIAAACRXR/mAAAACXBIWXMAAAsTAAALEwEAmpwYAAAFEmlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDUgNzkuMTYzNDk5LCAyMDE4LzA4LzEzLTE2OjQwOjIyICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6cGhvdG9zaG9wPSJodHRwOi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOSAoTWFjaW50b3NoKSIgeG1wOkNyZWF0ZURhdGU9IjIwMjMtMDYtMzBUMjI6MDM6NTIrMDk6MDAiIHhtcDpNb2RpZnlEYXRlPSIyMDIzLTA2LTMwVDIzOjIxOjI1KzA5OjAwIiB4bXA6TWV0YWRhdGFEYXRlPSIyMDIzLTA2LTMwVDIzOjIxOjI1KzA5OjAwIiBkYzpmb3JtYXQ9ImltYWdlL3BuZyIgcGhvdG9zaG9wOkNvbG9yTW9kZT0iMyIgcGhvdG9zaG9wOklDQ1Byb2ZpbGU9InNSR0IgSUVDNjE5NjYtMi4xIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjE0ZDg5ZjI0LTRmZjctNDUwOS1hNDA3LWM4ODZhM2Q4MDk0YSIgeG1wTU06RG9jdW1lbnRJRD0ieG1wLmRpZDoxNGQ4OWYyNC00ZmY3LTQ1MDktYTQwNy1jODg2YTNkODA5NGEiIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDoxNGQ4OWYyNC00ZmY3LTQ1MDktYTQwNy1jODg2YTNkODA5NGEiPiA8eG1wTU06SGlzdG9yeT4gPHJkZjpTZXE+IDxyZGY6bGkgc3RFdnQ6YWN0aW9uPSJjcmVhdGVkIiBzdEV2dDppbnN0YW5jZUlEPSJ4bXAuaWlkOjE0ZDg5ZjI0LTRmZjctNDUwOS1hNDA3LWM4ODZhM2Q4MDk0YSIgc3RFdnQ6d2hlbj0iMjAyMy0wNi0zMFQyMjowMzo1MiswOTowMCIgc3RFdnQ6c29mdHdhcmVBZ2VudD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTkgKE1hY2ludG9zaCkiLz4gPC9yZGY6U2VxPiA8L3htcE1NOkhpc3Rvcnk+IDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY+IDwveDp4bXBtZXRhPiA8P3hwYWNrZXQgZW5kPSJyIj8+GWKCCQAAATNJREFUWIXtlTFuwzAQRV+QTgk8ZMjWA+QAOViGLJmzdSsydS1kypojZAqQJbNvYHgplCKRQrUoqUiGwrcbLXAffFCH3O3ESSEJEzHZJEwhDMMQQtg/t1OGkJnZ4fNzDODtYU4MYRwiLb5eX8YAHp+WiSH8Cl+DvQPTXZwYAuBPa3dlQ8i5YEgOYJe1KxvCZ3HLqk1MWULmG+y5gx9gvfTYkDyJKaBDDZzE5BqEVT1fzNkP5Fy3tUXECF9X07dtvb/+TJbLFIYXzX9aNwPxUMkBOm34q2TNI8IWEQ4R4RARDhHhEBHO5pzV/gprM7aWFQtExBcRDhHhEBEOEeEQEU5Ljtb5+D+13lx3nP7lWpedwuA7fNP5OYhrNrXc4xVlGHPyODEEwGY1/AFIBfclfLkBYAAAAABJRU5ErkJggg=='; - + const fallbackBase64 = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyCAIAAACRXR/mAAAACXBIWXMAAAsTAAALEwEAmpwYAAAFEmlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDUgNzkuMTYzNDk5LCAyMDE4LzA4LzEzLTE2OjQwOjIyICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6cGhvdG9zaG9wPSJodHRwOi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOSAoTWFjaW50b3NoKSIgeG1wOkNyZWF0ZURhdGU9IjIwMjMtMDYtMzBUMjI6MDM6NTIrMDk6MDAiIHhtcDpNb2RpZnlEYXRlPSIyMDIzLTA2LTMwVDIzOjIxOjI1KzA5OjAwIiB4bXA6TWV0YWRhdGFEYXRlPSIyMDIzLTA2LTMwVDIzOjIxOjI1KzA5OjAwIiBkYzpmb3JtYXQ9ImltYWdlL3BuZyIgcGhvdG9zaG9wOkNvbG9yTW9kZT0iMyIgcGhvdG9zaG9wOklDQ1Byb2ZpbGU9InNSR0IgSUVDNjE5NjYtMi4xIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjE0ZDg5ZjI0LTRmZjctNDUwOS1hNDA3LWM4ODZhM2Q4MDk0YSIgeG1wTU06RG9jdW1lbnRJRD0ieG1wLmRpZDoxNGQ4OWYyNC00ZmY3LTQ1MDktYTQwNy1jODg2YTNkODA5NGEiIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDoxNGQ4OWYyNC00ZmY3LTQ1MDktYTQwNy1jODg2YTNkODA5NGEiPiA8eG1wTU06SGlzdG9yeT4gPHJkZjpTZXE+IDxyZGY6bGkgc3RFdnQ6YWN0aW9uPSJjcmVhdGVkIiBzdEV2dDppbnN0YW5jZUlEPSJ4bXAuaWlkOjE0ZDg5ZjI0LTRmZjctNDUwOS1hNDA3LWM4ODZhM2Q4MDk0YSIgc3RFdnQ6d2hlbj0iMjAyMy0wNi0zMFQyMjowMzo1MiswOTowMCIgc3RFdnQ6c29mdHdhcmVBZ2VudD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTkgKE1hY2ludG9zaCkiLz4gPC9yZGY6U2VxPiA8L3htcE1NOkhpc3Rvcnk+IDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY+IDwveDp4bXBtZXRhPiA8P3hwYWNrZXQgZW5kPSJyIj8+GWKCCQAAATNJREFUWIXtlTFuwzAQRV+QTgk8ZMjWA+QAOViGLJmzdSsydS1kypojZAqQJbNvYHgplCKRQrUoqUiGwrcbLXAffFCH3O3ESSEJEzHZJEwhDMMQQtg/t1OGkJnZ4fNzDODtYU4MYRwiLb5eX8YAHp+WiSH8Cl+DvQPTXZwYAuBPa3dlQ8i5YEgOYJe1KxvCZ3HLqk1MWULmG+y5gx9gvfTYkDyJKaBDDZzE5BqEVT1fzNkP5Fy3tUXECF9X07dtvb/+TJbLFIYXzX9aNwPxUMkBOm34q2TNI8IWEQ4R4RARDhHhEBHO5pzV/gprM7aWFQtExBcRDhHhEBEOEeEQEU5Ljtb5+D+13lx3nP7lWpedwuA7fNP5OYhrNrXc4xVlGHPyODEEwGY1/AFIBfclfLkBYAAAAABJRU5ErkJggg=="; + // 마지막 수단으로 Base64 사용 setImageSrc(fallbackBase64); - } else if (platform === 'ios') { + } else if (platform === "ios") { // iOS 경로 처리 - setImageSrc('/zellyy.png'); + setImageSrc("/zellyy.png"); } } else { // 웹에서는 일반 경로 사용 - setImageSrc('/zellyy.png'); + setImageSrc("/zellyy.png"); } - + setLoaded(true); } catch (err) { - console.error('이미지 로드 오류:', err); + logger.error("이미지 로드 오류:", err); setError(true); } }; @@ -71,7 +73,7 @@ const AvatarImageView: React.FC = ({
) : ( <> - Zellyy = ({ - title, - current, +const BudgetCard: React.FC = ({ + title, + current, total, - color = 'neuro-income' + color = "neuro-income", }) => { const percentage = Math.min(Math.round((current / total) * 100), 100); - - const formattedCurrent = new Intl.NumberFormat('ko-KR', { - style: 'currency', - currency: 'KRW', - maximumFractionDigits: 0 + + const formattedCurrent = new Intl.NumberFormat("ko-KR", { + style: "currency", + currency: "KRW", + maximumFractionDigits: 0, }).format(current); - - const formattedTotal = new Intl.NumberFormat('ko-KR', { - style: 'currency', - currency: 'KRW', - maximumFractionDigits: 0 + + const formattedTotal = new Intl.NumberFormat("ko-KR", { + style: "currency", + currency: "KRW", + maximumFractionDigits: 0, }).format(total); - + // Determine progress bar color based on percentage - const progressBarColor = percentage >= 90 ? 'bg-yellow-400' : `bg-${color}`; - + const progressBarColor = percentage >= 90 ? "bg-yellow-400" : `bg-${color}`; + return (
-
- {categoryIcons[title]} -
+
{categoryIcons[title]}

{title}

- +

{formattedCurrent}

/ {formattedTotal}

- +
-
- +
- - {percentage}% - + {percentage}%
); diff --git a/src/components/BudgetCategoriesSection.tsx b/src/components/BudgetCategoriesSection.tsx index c066a9d..9f56e24 100644 --- a/src/components/BudgetCategoriesSection.tsx +++ b/src/components/BudgetCategoriesSection.tsx @@ -1,7 +1,9 @@ - -import React from 'react'; -import { categoryIcons, CATEGORY_DESCRIPTIONS } from '@/constants/categoryIcons'; -import { formatCurrency } from '@/utils/formatters'; +import React from "react"; +import { + categoryIcons, + CATEGORY_DESCRIPTIONS, +} from "@/constants/categoryIcons"; +import { formatCurrency } from "@/utils/formatters"; interface BudgetCategoriesSectionProps { categories: { @@ -12,70 +14,100 @@ interface BudgetCategoriesSectionProps { } const BudgetCategoriesSection: React.FC = ({ - categories + categories, }) => { - return <> -

지출 그래프

-
- {categories.map((category, index) => { - // 예산 초과 여부 확인 - const isOverBudget = category.current > category.total && category.total > 0; - // 실제 백분율 계산 (초과해도 실제 퍼센트로 표시) - const actualPercentage = category.total > 0 ? Math.round(category.current / category.total * 100) : 0; - // 프로그레스 바용 퍼센트 - 제한 없이 실제 퍼센트 표시 - const displayPercentage = actualPercentage; + return ( + <> +

지출 그래프

+
+ {categories.map((category, index) => { + // 예산 초과 여부 확인 + const isOverBudget = + category.current > category.total && category.total > 0; + // 실제 백분율 계산 (초과해도 실제 퍼센트로 표시) + const actualPercentage = + category.total > 0 + ? Math.round((category.current / category.total) * 100) + : 0; + // 프로그레스 바용 퍼센트 - 제한 없이 실제 퍼센트 표시 + const displayPercentage = actualPercentage; - // 예산이 얼마 남지 않은 경우 (10% 미만) - const isLowBudget = category.total > 0 && actualPercentage >= 90 && actualPercentage < 100; + // 예산이 얼마 남지 않은 경우 (10% 미만) + const isLowBudget = + category.total > 0 && + actualPercentage >= 90 && + actualPercentage < 100; - // 프로그레스 바 색상 결정 - const progressBarColor = isOverBudget ? 'bg-red-500' : isLowBudget ? 'bg-yellow-400' : 'bg-neuro-income'; + // 프로그레스 바 색상 결정 + const progressBarColor = isOverBudget + ? "bg-red-500" + : isLowBudget + ? "bg-yellow-400" + : "bg-neuro-income"; - // 남은 예산 또는 초과 예산 - const budgetStatusText = isOverBudget ? '예산 초과: ' : '남은 예산: '; - const budgetAmount = isOverBudget ? Math.abs(category.total - category.current) : Math.max(0, category.total - category.current); - - // 카테고리 설명 가져오기 - const description = CATEGORY_DESCRIPTIONS[category.title] || ''; - - return
-
-
- {categoryIcons[category.title]} + // 남은 예산 또는 초과 예산 + const budgetStatusText = isOverBudget ? "예산 초과: " : "남은 예산: "; + const budgetAmount = isOverBudget + ? Math.abs(category.total - category.current) + : Math.max(0, category.total - category.current); + + // 카테고리 설명 가져오기 + const description = CATEGORY_DESCRIPTIONS[category.title] || ""; + + return ( +
+
+
+ {categoryIcons[category.title]} +
+

+ {category.title} + {description && ( + + {description} + + )} +

+
+ +
+

+ {formatCurrency(category.current)} +

+

+ / {formatCurrency(category.total)} +

+
+ +
+
+
+ +
+ + {budgetStatusText} + {formatCurrency(budgetAmount)} + + + {displayPercentage}% +
-

- {category.title} - {description && ( - - {description} - - )} -

- -
-

{formatCurrency(category.current)}

-

/ {formatCurrency(category.total)}

-
- -
-
-
- -
- - {budgetStatusText}{formatCurrency(budgetAmount)} - - - {displayPercentage}% - -
-
; - })} -
- ; + ); + })} +
+ + ); }; export default BudgetCategoriesSection; diff --git a/src/components/BudgetInputCard.tsx b/src/components/BudgetInputCard.tsx index 137c41b..b1a7390 100644 --- a/src/components/BudgetInputCard.tsx +++ b/src/components/BudgetInputCard.tsx @@ -1,10 +1,14 @@ - -import React, { useState, useEffect } from 'react'; -import { Input } from '@/components/ui/input'; -import { Button } from '@/components/ui/button'; -import { Check, ChevronDown, ChevronUp, Wallet } from 'lucide-react'; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; -import { markBudgetAsModified } from '@/utils/sync/budget/modifiedBudgetsTracker'; +import React, { useState, useEffect } from "react"; +import { logger } from "@/utils/logger"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Check, ChevronDown, ChevronUp, Wallet } from "lucide-react"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { markBudgetAsModified } from "@/utils/sync/budget/modifiedBudgetsTracker"; interface BudgetGoalProps { initialBudgets: { @@ -12,85 +16,91 @@ interface BudgetGoalProps { weekly: number; monthly: number; }; - onSave: (type: 'daily' | 'weekly' | 'monthly', amount: number) => void; + onSave: (type: "daily" | "weekly" | "monthly", amount: number) => void; highlight?: boolean; } const BudgetInputCard: React.FC = ({ initialBudgets, onSave, - highlight = false + highlight = false, }) => { const [budgetInput, setBudgetInput] = useState( - initialBudgets.monthly > 0 ? initialBudgets.monthly.toString() : '' + initialBudgets.monthly > 0 ? initialBudgets.monthly.toString() : "" ); const [isOpen, setIsOpen] = useState(highlight); // Format with commas for display const formatWithCommas = (amount: string) => { // Remove commas first to handle re-formatting - const numericValue = amount.replace(/,/g, ''); - return numericValue.replace(/\B(?=(\d{3})+(?!\d))/g, ','); + const numericValue = amount.replace(/,/g, ""); + return numericValue.replace(/\B(?=(\d{3})+(?!\d))/g, ","); }; // 초기값 변경시 입력 필드 값 업데이트 useEffect(() => { - console.log("BudgetInputCard - 초기 예산값 업데이트:", initialBudgets); - setBudgetInput(initialBudgets.monthly > 0 ? initialBudgets.monthly.toString() : ''); + logger.info("BudgetInputCard - 초기 예산값 업데이트:", initialBudgets); + setBudgetInput( + initialBudgets.monthly > 0 ? initialBudgets.monthly.toString() : "" + ); }, [initialBudgets]); const handleInputChange = (value: string) => { // Remove all non-numeric characters - const numericValue = value.replace(/[^0-9]/g, ''); + const numericValue = value.replace(/[^0-9]/g, ""); setBudgetInput(numericValue); }; const handleSave = () => { - const amount = parseInt(budgetInput.replace(/,/g, ''), 10) || 0; + const amount = parseInt(budgetInput.replace(/,/g, ""), 10) || 0; if (amount <= 0) { return; // 0 이하의 금액은 저장하지 않음 } - + // 즉시 입력 필드를 업데이트하여 사용자에게 피드백 제공 setBudgetInput(amount.toString()); - + // 즉시 콜랩시블을 닫아 사용자에게 완료 피드백 제공 setIsOpen(false); - + // 예산 변경 시 수정 추적 시스템에 기록 try { markBudgetAsModified(amount); - console.log(`[예산 추적] 월간 예산 변경 추적: ${amount}원`); + logger.info(`[예산 추적] 월간 예산 변경 추적: ${amount}원`); } catch (error) { - console.error('[예산 추적] 예산 변경 추적 실패:', error); + logger.error("[예산 추적] 예산 변경 추적 실패:", error); } - - console.log(`BudgetInputCard - 저장 버튼 클릭: 월간 예산 = ${amount}원`); - + + logger.info(`BudgetInputCard - 저장 버튼 클릭: 월간 예산 = ${amount}원`); + // 예산 저장 (항상 monthly로 저장) - onSave('monthly', amount); + onSave("monthly", amount); }; // 비어있으면 빈 문자열을, 그렇지 않으면 포맷팅된 문자열을 반환 const getDisplayValue = () => { - return budgetInput === '' ? '' : formatWithCommas(budgetInput); + return budgetInput === "" ? "" : formatWithCommas(budgetInput); }; // 금액을 표시할 때 0원이면 '설정되지 않음'으로 표시 const getGoalDisplayText = () => { - const amount = parseInt(budgetInput.replace(/,/g, ''), 10) || 0; - if (amount === 0) return '설정되지 않음'; - return formatWithCommas(budgetInput) + '원'; + const amount = parseInt(budgetInput.replace(/,/g, ""), 10) || 0; + if (amount === 0) { + return "설정되지 않음"; + } + return formatWithCommas(budgetInput) + "원"; }; return ( - - + {highlight && } 월간 예산 설정하기 @@ -100,17 +110,21 @@ const BudgetInputCard: React.FC = ({ )} - +
- handleInputChange(e.target.value)} - placeholder="목표 금액 입력" - className="neuro-pressed" + onChange={(e) => handleInputChange(e.target.value)} + placeholder="목표 금액 입력" + className="neuro-pressed" /> -
diff --git a/src/components/BudgetProgress.tsx b/src/components/BudgetProgress.tsx index faf0ca0..4ef326c 100644 --- a/src/components/BudgetProgress.tsx +++ b/src/components/BudgetProgress.tsx @@ -1,5 +1,4 @@ - -import React from 'react'; +import React from "react"; interface BudgetProgressProps { spentAmount: number; @@ -12,25 +11,27 @@ const BudgetProgress: React.FC = ({ spentAmount, targetAmount, percentage, - formatCurrency + formatCurrency, }) => { // NaN 값을 방지하기 위해 percentage가 숫자가 아닌 경우 0으로 표시 const displayPercentage = isNaN(percentage) ? 0 : percentage; - + return (

{formatCurrency(spentAmount)}

-

/ {formatCurrency(targetAmount)}

+

+ / {formatCurrency(targetAmount)} +

- +
-
= 90 ? "bg-yellow-400" : "bg-neuro-income"}`} +
= 90 ? "bg-yellow-400" : "bg-neuro-income"}`} />
- +
{displayPercentage}% diff --git a/src/components/BudgetProgressCard.tsx b/src/components/BudgetProgressCard.tsx index e23ccf3..a8c420f 100644 --- a/src/components/BudgetProgressCard.tsx +++ b/src/components/BudgetProgressCard.tsx @@ -1,7 +1,7 @@ - -import React, { useEffect, useState } from 'react'; -import BudgetTabContent from './BudgetTabContent'; -import { BudgetPeriod, BudgetData } from '@/contexts/budget/types'; +import React, { useEffect, useState } from "react"; +import { logger } from "@/utils/logger"; +import BudgetTabContent from "./BudgetTabContent"; +import { BudgetPeriod, BudgetData } from "@/contexts/budget/types"; interface BudgetProgressCardProps { budgetData: BudgetData; @@ -9,7 +9,11 @@ interface BudgetProgressCardProps { setSelectedTab: (value: string) => void; formatCurrency: (amount: number) => string; calculatePercentage: (spent: number, target: number) => number; - onSaveBudget: (type: BudgetPeriod, amount: number, newCategoryBudgets?: Record) => void; + onSaveBudget: ( + type: BudgetPeriod, + amount: number, + newCategoryBudgets?: Record + ) => void; } const BudgetProgressCard: React.FC = ({ @@ -18,57 +22,63 @@ const BudgetProgressCard: React.FC = ({ setSelectedTab, formatCurrency, calculatePercentage, - onSaveBudget + onSaveBudget, }) => { // 데이터 상태 추적 (불일치 감지를 위한 로컬 상태) const [localBudgetData, setLocalBudgetData] = useState(budgetData); // 컴포넌트 마운트 및 budgetData 변경 시 업데이트 useEffect(() => { - console.log("BudgetProgressCard 데이터 업데이트 - 예산 데이터:", budgetData); - console.log("월간 예산:", budgetData.monthly.targetAmount); + logger.info( + "BudgetProgressCard 데이터 업데이트 - 예산 데이터:", + budgetData + ); + logger.info("월간 예산:", budgetData.monthly.targetAmount); setLocalBudgetData(budgetData); - + // 지연 작업으로 이벤트 발생 (컴포넌트 마운트 후 데이터 갱신) const timeoutId = setTimeout(() => { - window.dispatchEvent(new Event('budgetDataUpdated')); + window.dispatchEvent(new Event("budgetDataUpdated")); }, 300); - + return () => clearTimeout(timeoutId); }, [budgetData]); // 초기 탭 설정을 위한 효과 useEffect(() => { if (!selectedTab || selectedTab !== "monthly") { - console.log("초기 탭 설정: monthly"); - setSelectedTab('monthly'); + logger.info("초기 탭 설정: monthly"); + setSelectedTab("monthly"); } }, []); // budgetDataUpdated 이벤트 감지 useEffect(() => { const handleBudgetDataUpdated = () => { - console.log("BudgetProgressCard: 예산 데이터 업데이트 이벤트 감지"); + logger.info("BudgetProgressCard: 예산 데이터 업데이트 이벤트 감지"); }; - window.addEventListener('budgetDataUpdated', handleBudgetDataUpdated); - return () => window.removeEventListener('budgetDataUpdated', handleBudgetDataUpdated); + window.addEventListener("budgetDataUpdated", handleBudgetDataUpdated); + return () => + window.removeEventListener("budgetDataUpdated", handleBudgetDataUpdated); }, []); // 월간 예산 설정 여부 계산 const isMonthlyBudgetSet = budgetData.monthly.targetAmount > 0; - console.log(`BudgetProgressCard 상태: 월=${isMonthlyBudgetSet}`); + logger.info(`BudgetProgressCard 상태: 월=${isMonthlyBudgetSet}`); return (
지출 / 예산
- - onSaveBudget('monthly', amount, categoryBudgets)} + + + onSaveBudget("monthly", amount, categoryBudgets) + } />
); diff --git a/src/components/BudgetTabContent.tsx b/src/components/BudgetTabContent.tsx index 6f938a1..ca91029 100644 --- a/src/components/BudgetTabContent.tsx +++ b/src/components/BudgetTabContent.tsx @@ -1,11 +1,11 @@ - -import React, { useState } from 'react'; -import { useBudgetTabContent } from '@/hooks/budget/useBudgetTabContent'; -import BudgetHeader from './budget/BudgetHeader'; -import BudgetProgressBar from './budget/BudgetProgressBar'; -import BudgetStatusDisplay from './budget/BudgetStatusDisplay'; -import BudgetInputButton from './budget/BudgetInputButton'; -import BudgetDialog from './budget/BudgetDialog'; +import React, { useState } from "react"; +import { logger } from "@/utils/logger"; +import { useBudgetTabContent } from "@/hooks/budget/useBudgetTabContent"; +import BudgetHeader from "./budget/BudgetHeader"; +import BudgetProgressBar from "./budget/BudgetProgressBar"; +import BudgetStatusDisplay from "./budget/BudgetStatusDisplay"; +import BudgetInputButton from "./budget/BudgetInputButton"; +import BudgetDialog from "./budget/BudgetDialog"; interface BudgetData { targetAmount: number; @@ -17,18 +17,21 @@ interface BudgetTabContentProps { data: BudgetData; formatCurrency: (amount: number) => string; calculatePercentage: (spent: number, target: number) => number; - onSaveBudget: (amount: number, categoryBudgets?: Record) => void; + onSaveBudget: ( + amount: number, + categoryBudgets?: Record + ) => void; } const BudgetTabContent: React.FC = ({ data, formatCurrency, calculatePercentage, - onSaveBudget + onSaveBudget, }) => { const [showBudgetDialog, setShowBudgetDialog] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); - + const { categoryBudgets, handleCategoryInputChange, @@ -42,17 +45,17 @@ const BudgetTabContent: React.FC = ({ budgetStatusText, budgetAmount, budgetButtonText, - calculateTotalBudget + calculateTotalBudget, } = useBudgetTabContent({ data, calculatePercentage, - onSaveBudget + onSaveBudget, }); const handleOpenDialog = () => { setShowBudgetDialog(true); }; - + const handleSaveBudget = () => { setIsSubmitting(true); try { @@ -68,26 +71,26 @@ const BudgetTabContent: React.FC = ({ // 월간 예산 모드 로깅 React.useEffect(() => { - console.log('BudgetTabContent 렌더링: 월간 예산'); - console.log('현재 예산 데이터:', data); + logger.info("BudgetTabContent 렌더링: 월간 예산"); + logger.info("현재 예산 데이터:", data); }, [data]); return (
{isBudgetSet ? ( <> - - - = ({ ) : null} - - ; @@ -12,51 +12,63 @@ interface CategoryBudgetInputsProps { const CategoryBudgetInputs: React.FC = ({ categoryBudgets, - handleCategoryInputChange + handleCategoryInputChange, }) => { const isMobile = useIsMobile(); // Format number with commas for display const formatWithCommas = (value: number): string => { - if (value === 0) return ''; - return value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); + if (value === 0) { + return ""; + } + return value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); }; // Handle input with comma formatting - const handleInput = (e: React.ChangeEvent, category: string) => { + const handleInput = ( + e: React.ChangeEvent, + category: string + ) => { // Remove all non-numeric characters before passing to parent handler - const numericValue = e.target.value.replace(/[^0-9]/g, ''); + const numericValue = e.target.value.replace(/[^0-9]/g, ""); handleCategoryInputChange(numericValue, category); - + // 수정된 카테고리 예산 추적 시스템에 기록 try { const amount = parseInt(numericValue, 10) || 0; markSingleCategoryBudgetAsModified(category, amount); - console.log(`카테고리 '${category}' 예산을 ${amount}원으로 수정 완료, 타임스탬프: ${new Date().toISOString()}`); + logger.info( + `카테고리 '${category}' 예산을 ${amount}원으로 수정 완료, 타임스탬프: ${new Date().toISOString()}` + ); } catch (error) { - console.error(`카테고리 '${category}' 예산 변경 추적 실패:`, error); + logger.error(`카테고리 '${category}' 예산 변경 추적 실패:`, error); } - + // 사용자에게 시각적 피드백 제공 - e.target.classList.add('border-green-500'); + e.target.classList.add("border-green-500"); setTimeout(() => { - e.target.classList.remove('border-green-500'); + e.target.classList.remove("border-green-500"); }, 300); }; return (
- {EXPENSE_CATEGORIES.map(category => ( -
+ {EXPENSE_CATEGORIES.map((category) => ( +
{categoryIcons[category]} - +
- handleInput(e, category)} placeholder="예산 입력" - className={`transition-colors duration-300 ${isMobile ? 'w-[150px]' : 'max-w-[150px]'}`} + className={`transition-colors duration-300 ${isMobile ? "w-[150px]" : "max-w-[150px]"}`} />
))} diff --git a/src/components/ExpenseChart.tsx b/src/components/ExpenseChart.tsx index df06f62..af78848 100644 --- a/src/components/ExpenseChart.tsx +++ b/src/components/ExpenseChart.tsx @@ -1,7 +1,6 @@ - -import React from 'react'; -import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts'; -import { getCategoryColor } from '@/utils/categoryColorUtils'; +import React from "react"; +import { PieChart, Pie, Cell, ResponsiveContainer } from "recharts"; +import { getCategoryColor } from "@/utils/categoryColorUtils"; interface ExpenseData { name: string; @@ -26,9 +25,9 @@ const ExpenseChart: React.FC = ({ data }) => { paddingAngle={5} dataKey="value" labelLine={false} - label={({ name, percent }) => ( + label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%` - )} + } fontSize={12} > {data.map((entry, index) => ( diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 102180c..48ca2b5 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,54 +1,53 @@ - -import React, { useState, useEffect } from 'react'; -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; -import { Skeleton } from '@/components/ui/skeleton'; -import { useAuth } from '@/contexts/auth'; -import { useIsMobile } from '@/hooks/use-mobile'; -import { isIOSPlatform } from '@/utils/platform'; -import NotificationPopover from './notification/NotificationPopover'; -import useNotifications from '@/hooks/useNotifications'; +import React, { useState, useEffect } from "react"; +import { logger } from "@/utils/logger"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useAuth } from "@/contexts/auth"; +import { useIsMobile } from "@/hooks/use-mobile"; +import { isIOSPlatform } from "@/utils/platform"; +import NotificationPopover from "./notification/NotificationPopover"; +import useNotifications from "@/hooks/useNotifications"; const Header: React.FC = () => { - const { - user - } = useAuth(); - const userName = user?.user_metadata?.username || '익명'; + const { user } = useAuth(); + const userName = user?.user_metadata?.username || "익명"; const [imageLoaded, setImageLoaded] = useState(false); const [imageError, setImageError] = useState(false); const isMobile = useIsMobile(); const [isIOS, setIsIOS] = useState(false); - const { notifications, clearAllNotifications, markAsRead } = useNotifications(); + const { notifications, clearAllNotifications, markAsRead } = + useNotifications(); // 플랫폼 감지 useEffect(() => { const checkPlatform = async () => { try { const isiOS = isIOSPlatform(); - console.log('Header: iOS 플랫폼 감지 결과:', isiOS); + logger.info("Header: iOS 플랫폼 감지 결과:", isiOS); setIsIOS(isiOS); } catch (error) { - console.error('플랫폼 감지 중 오류:', error); + logger.error("플랫폼 감지 중 오류:", error); } }; - + checkPlatform(); }, []); // 이미지 프리로딩 처리 useEffect(() => { const preloadImage = new Image(); - preloadImage.src = '/zellyy.png'; + preloadImage.src = "/zellyy.png"; preloadImage.onload = () => { setImageLoaded(true); }; preloadImage.onerror = () => { - console.error('아바타 이미지 로드 실패'); + logger.error("아바타 이미지 로드 실패"); setImageError(true); }; }, []); // iOS 전용 헤더 클래스 - 안전 영역 적용 - const headerClass = isIOS ? 'ios-notch-padding' : 'py-4'; + const headerClass = isIOS ? "ios-notch-padding" : "py-4"; return (
@@ -61,26 +60,28 @@ const Header: React.FC = () => {
) : ( <> - setImageLoaded(true)} - onError={() => setImageError(true)} + setImageLoaded(true)} + onError={() => setImageError(true)} /> - {(imageError || !imageLoaded) && ZY} + {(imageError || !imageLoaded) && ( + ZY + )} )}

- {user ? `${userName}님, 반갑습니다` : '반갑습니다'} + {user ? `${userName}님, 반갑습니다` : "반갑습니다"}

젤리의 적자탈출

- = ({ - resourceName, +const NativeImage: React.FC = ({ + resourceName, className, alt = "이미지", - fallback = "ZY" + fallback = "ZY", }) => { const [loading, setLoading] = useState(true); const [error, setError] = useState(false); @@ -26,12 +27,12 @@ const NativeImage: React.FC = ({ try { if (Capacitor.isNativePlatform()) { // 안드로이드에서는 리소스 ID를 사용 - if (Capacitor.getPlatform() === 'android') { + if (Capacitor.getPlatform() === "android") { // 웹뷰가 resource:// 프로토콜을 지원하는 경우를 위한 코드 setImageSrc(`file:///android_res/drawable/${resourceName}`); } else { // iOS - 다른 방식 적용 (추후 구현) - setImageSrc('/zellyy.png'); + setImageSrc("/zellyy.png"); } } else { // 웹에서는 일반 경로 사용 @@ -39,7 +40,7 @@ const NativeImage: React.FC = ({ } setLoading(false); } catch (err) { - console.error('이미지 로드 오류:', err); + logger.error("이미지 로드 오류:", err); setError(true); setLoading(false); } @@ -57,16 +58,14 @@ const NativeImage: React.FC = ({ ) : ( <> {!error && ( - {alt} setError(true)} /> )} - {error && ( - {fallback} - )} + {error && {fallback}} )} diff --git a/src/components/NavBar.tsx b/src/components/NavBar.tsx index febaa8c..0591130 100644 --- a/src/components/NavBar.tsx +++ b/src/components/NavBar.tsx @@ -1,32 +1,48 @@ - -import React from 'react'; -import { useNavigate, useLocation } from 'react-router-dom'; -import { Home, BarChart2, Calendar, Settings } from 'lucide-react'; -import { cn } from '@/lib/utils'; +import React from "react"; +import { useNavigate, useLocation } from "react-router-dom"; +import { Home, BarChart2, Calendar, Settings } from "lucide-react"; +import { cn } from "@/lib/utils"; const NavBar = () => { const navigate = useNavigate(); const location = useLocation(); - + // 설정 관련 경로 목록 추가 const settingsRelatedPaths = [ - '/settings', - '/profile', - '/security-privacy', - '/help-support', - '/payment-methods', - '/notifications' + "/settings", + "/profile", + "/security-privacy", + "/help-support", + "/payment-methods", + "/notifications", ]; - - const isSettingsActive = settingsRelatedPaths.some(path => location.pathname === path); - + + const isSettingsActive = settingsRelatedPaths.some( + (path) => location.pathname === path + ); + const navItems = [ - { icon: Home, label: '홈', path: '/', isActive: location.pathname === '/' }, - { icon: Calendar, label: '지출', path: '/transactions', isActive: location.pathname === '/transactions' }, - { icon: BarChart2, label: '분석', path: '/analytics', isActive: location.pathname === '/analytics' }, - { icon: Settings, label: '설정', path: '/settings', isActive: isSettingsActive }, + { icon: Home, label: "홈", path: "/", isActive: location.pathname === "/" }, + { + icon: Calendar, + label: "지출", + path: "/transactions", + isActive: location.pathname === "/transactions", + }, + { + icon: BarChart2, + label: "분석", + path: "/analytics", + isActive: location.pathname === "/analytics", + }, + { + icon: Settings, + label: "설정", + path: "/settings", + isActive: isSettingsActive, + }, ]; - + return (
@@ -40,7 +56,7 @@ const NavBar = () => { item.isActive ? "text-neuro-income" : "text-gray-500" )} > -
= ({ transactions, - onUpdateTransaction + onUpdateTransaction, }) => { const { updateTransaction, deleteTransaction } = useBudget(); - + // 트랜잭션 삭제 관련 로직은 커스텀 훅으로 분리 - const { handleDeleteTransaction, isDeleting } = useRecentTransactions(deleteTransaction); - + const { handleDeleteTransaction, isDeleting } = + useRecentTransactions(deleteTransaction); + // 다이얼로그 관련 로직 분리 - const { - selectedTransaction, - isDialogOpen, - handleTransactionClick, - setIsDialogOpen + const { + selectedTransaction, + isDialogOpen, + handleTransactionClick, + setIsDialogOpen, } = useRecentTransactionsDialog(); const handleUpdateTransaction = (updatedTransaction: Transaction) => { @@ -42,14 +43,17 @@ const RecentTransactionsSection: React.FC = ({

최근 지출

- + 더보기
- +
{transactions.length > 0 ? ( - transactions.map(transaction => ( + transactions.map((transaction) => ( = ({ resourceName, className = "h-12 w-12", alt = "이미지", - fallback = "ZY" + fallback = "ZY", }) => { const [loading, setLoading] = useState(true); const [error, setError] = useState(false); @@ -27,7 +28,7 @@ const ResourceImage: React.FC = ({ setImageSrc(imgSrc); setLoading(false); } catch (err) { - console.error('이미지 로드 실패:', err); + logger.error("이미지 로드 실패:", err); setError(true); setLoading(false); } diff --git a/src/components/SafeAreaContainer.tsx b/src/components/SafeAreaContainer.tsx index 01a8290..cd3ab7e 100644 --- a/src/components/SafeAreaContainer.tsx +++ b/src/components/SafeAreaContainer.tsx @@ -1,6 +1,5 @@ - -import React from 'react'; -import { cn } from '@/lib/utils'; +import React from "react"; +import { cn } from "@/lib/utils"; interface SafeAreaContainerProps { children: React.ReactNode; @@ -14,15 +13,15 @@ interface SafeAreaContainerProps { */ const SafeAreaContainer: React.FC = ({ children, - className = '', - extraBottomPadding = false + className = "", + extraBottomPadding = false, }) => { return (
diff --git a/src/components/SimpleAvatar.tsx b/src/components/SimpleAvatar.tsx index d86aa91..9c0f50e 100644 --- a/src/components/SimpleAvatar.tsx +++ b/src/components/SimpleAvatar.tsx @@ -1,36 +1,41 @@ - -import React from 'react'; +import React from "react"; interface SimpleAvatarProps { src?: string; name: string; - size?: 'sm' | 'md' | 'lg'; + size?: "sm" | "md" | "lg"; className?: string; } -const SimpleAvatar: React.FC = ({ - src, - name, - size = 'md', - className = '' +const SimpleAvatar: React.FC = ({ + src, + name, + size = "md", + className = "", }) => { const initials = name - .split(' ') - .map(part => part.charAt(0)) - .join('') + .split(" ") + .map((part) => part.charAt(0)) + .join("") .toUpperCase() .substring(0, 2); const sizeClasses = { - sm: 'w-8 h-8 text-xs', - md: 'w-10 h-10 text-sm', - lg: 'w-12 h-12 text-base' + sm: "w-8 h-8 text-xs", + md: "w-10 h-10 text-sm", + lg: "w-12 h-12 text-base", }; return ( -
+
{src ? ( - {name} + {name} ) : ( {initials} )} diff --git a/src/components/SyncSettings.tsx b/src/components/SyncSettings.tsx index 0922eae..421b7ef 100644 --- a/src/components/SyncSettings.tsx +++ b/src/components/SyncSettings.tsx @@ -1,12 +1,12 @@ - -import React, { useEffect } from 'react'; +import React, { useEffect } from "react"; +import { syncLogger } from "@/utils/logger"; import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; import { CloudUpload } from "lucide-react"; -import { useSyncSettings } from '@/hooks/useSyncSettings'; -import SyncStatus from '@/components/sync/SyncStatus'; -import SyncExplanation from '@/components/sync/SyncExplanation'; -import { isSyncEnabled } from '@/utils/sync/syncSettings'; +import { useSyncSettings } from "@/hooks/useSyncSettings"; +import SyncStatus from "@/components/sync/SyncStatus"; +import SyncExplanation from "@/components/sync/SyncExplanation"; +import { isSyncEnabled } from "@/utils/sync/syncSettings"; const SyncSettings = () => { const { @@ -15,30 +15,33 @@ const SyncSettings = () => { user, lastSync, handleSyncToggle, - handleManualSync + handleManualSync, } = useSyncSettings(); // 동기화 설정 변경 모니터링 useEffect(() => { const checkSyncStatus = () => { const currentStatus = isSyncEnabled(); - console.log('현재 동기화 상태:', currentStatus ? '활성화됨' : '비활성화됨'); + syncLogger.info( + "현재 동기화 상태:", + currentStatus ? "활성화됨" : "비활성화됨" + ); }; - + // 초기 상태 확인 checkSyncStatus(); - + // 스토리지 변경 이벤트에도 동기화 상태 확인 추가 const handleStorageChange = () => { checkSyncStatus(); }; - - window.addEventListener('storage', handleStorageChange); - window.addEventListener('auth-state-changed', handleStorageChange); - + + window.addEventListener("storage", handleStorageChange); + window.addEventListener("auth-state-changed", handleStorageChange); + return () => { - window.removeEventListener('storage', handleStorageChange); - window.removeEventListener('auth-state-changed', handleStorageChange); + window.removeEventListener("storage", handleStorageChange); + window.removeEventListener("auth-state-changed", handleStorageChange); }; }, []); @@ -65,9 +68,9 @@ const SyncSettings = () => { disabled={!user && enabled} // 사용자가 로그아웃 상태이면서 동기화가 켜져있을 때 비활성화 />
- + {/* 동기화 상태 및 동작 */} - Promise | boolean; // 타입 변경됨: boolean 또는 Promise 반환 } -const TransactionCard: React.FC = ({ +const TransactionCard: React.FC = ({ transaction, - onDelete, + onDelete, }) => { const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); const { title, amount, date, category } = transaction; - + // 삭제 핸들러 - 인자로 받은 onDelete가 없거나 타입이 맞지 않을 때 기본 함수 제공 const handleDelete = async (id: string): Promise => { try { if (onDelete) { return await onDelete(id); } - console.log('삭제 핸들러가 제공되지 않았습니다'); + logger.info("삭제 핸들러가 제공되지 않았습니다"); return false; } catch (error) { - console.error('트랜잭션 삭제 처리 중 오류:', error); + logger.error("트랜잭션 삭제 처리 중 오류:", error); return false; } }; - + return ( <> -
setIsEditDialogOpen(true)} > @@ -45,12 +45,12 @@ const TransactionCard: React.FC = ({
- +
- = ({ export default TransactionCard; // Transaction 타입을 context에서 직접 다시 내보냅니다 -export type { Transaction } from '@/contexts/budget/types'; +export type { Transaction } from "@/contexts/budget/types"; diff --git a/src/components/TransactionEditDialog.tsx b/src/components/TransactionEditDialog.tsx index e77039f..2afe5b9 100644 --- a/src/components/TransactionEditDialog.tsx +++ b/src/components/TransactionEditDialog.tsx @@ -1,16 +1,15 @@ - -import React from 'react'; -import { Transaction } from '@/components/TransactionCard'; -import { - Dialog, - DialogContent, - DialogHeader, +import React from "react"; +import { Transaction } from "@/contexts/budget/types"; +import { + Dialog, + DialogContent, + DialogHeader, DialogTitle, - DialogDescription -} from '@/components/ui/dialog'; -import { useIsMobile } from '@/hooks/use-mobile'; -import { useTransactionEdit } from './transaction/useTransactionEdit'; -import TransactionEditForm from './transaction/TransactionEditForm'; + DialogDescription, +} from "@/components/ui/dialog"; +import { useIsMobile } from "@/hooks/use-mobile"; +import { useTransactionEdit } from "./transaction/useTransactionEdit"; +import TransactionEditForm from "./transaction/TransactionEditForm"; interface TransactionEditDialogProps { transaction: Transaction; @@ -27,31 +26,38 @@ const TransactionEditDialog: React.FC = ({ transaction, open, onOpenChange, - onDelete + onDelete, }) => { const isMobile = useIsMobile(); const closeDialog = () => onOpenChange(false); - + // useTransactionEdit 훅 사용 - 인자를 2개만 전달 const { form, isSubmitting, handleSubmit, handleDelete } = useTransactionEdit( transaction, closeDialog ); - + return ( - { - // 제출 중이면 닫기 방지 - if (isSubmitting && !newOpen) return; - onOpenChange(newOpen); - }}> - + { + // 제출 중이면 닫기 방지 + if (isSubmitting && !newOpen) { + return; + } + onOpenChange(newOpen); + }} + > + 지출 수정 지출 내역을 수정하거나 삭제할 수 있습니다. - + = ({ className = "h-12 w-12 mr-3" }) => { +const ZellyAvatar: React.FC<{ className?: string }> = ({ + className = "h-12 w-12 mr-3", +}) => { const [imageLoaded, setImageLoaded] = useState(false); const [imageError, setImageError] = useState(false); - const [imageSrc, setImageSrc] = useState('/zellyy.png'); + const [imageSrc, setImageSrc] = useState("/zellyy.png"); useEffect(() => { // 앱 환경에서는 Base64 인코딩된 이미지를 사용 if (Capacitor.isNativePlatform()) { - setImageSrc('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyCAIAAACRXR/mAAAACXBIWXMAAAsTAAALEwEAmpwYAAAFEmlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDUgNzkuMTYzNDk5LCAyMDE4LzA4LzEzLTE2OjQwOjIyICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6cGhvdG9zaG9wPSJodHRwOi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOSAoTWFjaW50b3NoKSIgeG1wOkNyZWF0ZURhdGU9IjIwMjMtMDYtMzBUMjI6MDM6NTIrMDk6MDAiIHhtcDpNb2RpZnlEYXRlPSIyMDIzLTA2LTMwVDIzOjIxOjI1KzA5OjAwIiB4bXA6TWV0YWRhdGFEYXRlPSIyMDIzLTA2LTMwVDIzOjIxOjI1KzA5OjAwIiBkYzpmb3JtYXQ9ImltYWdlL3BuZyIgcGhvdG9zaG9wOkNvbG9yTW9kZT0iMyIgcGhvdG9zaG9wOklDQ1Byb2ZpbGU9InNSR0IgSUVDNjE5NjYtMi4xIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjE0ZDg5ZjI0LTRmZjctNDUwOS1hNDA3LWM4ODZhM2Q4MDk0YSIgeG1wTU06RG9jdW1lbnRJRD0ieG1wLmRpZDoxNGQ4OWYyNC00ZmY3LTQ1MDktYTQwNy1jODg2YTNkODA5NGEiIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDoxNGQ4OWYyNC00ZmY3LTQ1MDktYTQwNy1jODg2YTNkODA5NGEiPiA8eG1wTU06SGlzdG9yeT4gPHJkZjpTZXE+IDxyZGY6bGkgc3RFdnQ6YWN0aW9uPSJjcmVhdGVkIiBzdEV2dDppbnN0YW5jZUlEPSJ4bXAuaWlkOjE0ZDg5ZjI0LTRmZjctNDUwOS1hNDA3LWM4ODZhM2Q4MDk0YSIgc3RFdnQ6d2hlbj0iMjAyMy0wNi0zMFQyMjowMzo1MiswOTowMCIgc3RFdnQ6c29mdHdhcmVBZ2VudD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTkgKE1hY2ludG9zaCkiLz4gPC9yZGY6U2VxPiA8L3htcE1NOkhpc3Rvcnk+IDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY+IDwveDp4bXBtZXRhPiA8P3hwYWNrZXQgZW5kPSJyIj8+GWKCCQAAATNJREFUWIXtlTFuwzAQRV+QTgk8ZMjWA+QAOViGLJmzdSsydS1kypojZAqQJbNvYHgplCKRQrUoqUiGwrcbLXAffFCH3O3ESSEJEzHZJEwhDMMQQtg/t1OGkJnZ4fNzDODtYU4MYRwiLb5eX8YAHp+WiSH8Cl+DvQPTXZwYAuBPa3dlQ8i5YEgOYJe1KxvCZ3HLqk1MWULmG+y5gx9gvfTYkDyJKaBDDZzE5BqEVT1fzNkP5Fy3tUXECF9X07dtvb/+TJbLFIYXzX9aNwPxUMkBOm34q2TNI8IWEQ4R4RARDhHhEBHO5pzV/gprM7aWFQtExBcRDhHhEBEOEeEQEU5Ljtb5+D+13lx3nP7lWpedwuA7fNP5OYhrNrXc4xVlGHPyODEEwGY1/AFIBfclfLkBYAAAAABJRU5ErkJggg=='); + setImageSrc( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyCAIAAACRXR/mAAAACXBIWXMAAAsTAAALEwEAmpwYAAAFEmlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDUgNzkuMTYzNDk5LCAyMDE4LzA4LzEzLTE2OjQwOjIyICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6cGhvdG9zaG9wPSJodHRwOi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOSAoTWFjaW50b3NoKSIgeG1wOkNyZWF0ZURhdGU9IjIwMjMtMDYtMzBUMjI6MDM6NTIrMDk6MDAiIHhtcDpNb2RpZnlEYXRlPSIyMDIzLTA2LTMwVDIzOjIxOjI1KzA5OjAwIiB4bXA6TWV0YWRhdGFEYXRlPSIyMDIzLTA2LTMwVDIzOjIxOjI1KzA5OjAwIiBkYzpmb3JtYXQ9ImltYWdlL3BuZyIgcGhvdG9zaG9wOkNvbG9yTW9kZT0iMyIgcGhvdG9zaG9wOklDQ1Byb2ZpbGU9InNSR0IgSUVDNjE5NjYtMi4xIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjE0ZDg5ZjI0LTRmZjctNDUwOS1hNDA3LWM4ODZhM2Q4MDk0YSIgeG1wTU06RG9jdW1lbnRJRD0ieG1wLmRpZDoxNGQ4OWYyNC00ZmY3LTQ1MDktYTQwNy1jODg2YTNkODA5NGEiIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDoxNGQ4OWYyNC00ZmY3LTQ1MDktYTQwNy1jODg2YTNkODA5NGEiPiA8eG1wTU06SGlzdG9yeT4gPHJkZjpTZXE+IDxyZGY6bGkgc3RFdnQ6YWN0aW9uPSJjcmVhdGVkIiBzdEV2dDppbnN0YW5jZUlEPSJ4bXAuaWlkOjE0ZDg5ZjI0LTRmZjctNDUwOS1hNDA3LWM4ODZhM2Q4MDk0YSIgc3RFdnQ6d2hlbj0iMjAyMy0wNi0zMFQyMjowMzo1MiswOTowMCIgc3RFdnQ6c29mdHdhcmVBZ2VudD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTkgKE1hY2ludG9zaCkiLz4gPC9yZGY6U2VxPiA8L3htcE1NOkhpc3Rvcnk+IDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY+IDwveDp4bXBtZXRhPiA8P3hwYWNrZXQgZW5kPSJyIj8+GWKCCQAAATNJREFUWIXtlTFuwzAQRV+QTgk8ZMjWA+QAOViGLJmzdSsydS1kypojZAqQJbNvYHgplCKRQrUoqUiGwrcbLXAffFCH3O3ESSEJEzHZJEwhDMMQQtg/t1OGkJnZ4fNzDODtYU4MYRwiLb5eX8YAHp+WiSH8Cl+DvQPTXZwYAuBPa3dlQ8i5YEgOYJe1KxvCZ3HLqk1MWULmG+y5gx9gvfTYkDyJKaBDDZzE5BqEVT1fzNkP5Fy3tUXECF9X07dtvb/+TJbLFIYXzX9aNwPxUMkBOm34q2TNI8IWEQ4R4RARDhHhEBHO5pzV/gprM7aWFQtExBcRDhHhEBEOEeEQEU5Ljtb5+D+13lx3nP7lWpedwuA7fNP5OYhrNrXc4xVlGHPyODEEwGY1/AFIBfclfLkBYAAAAABJRU5ErkJggg==" + ); } else { // 웹 환경에서는 일반 경로 사용 - setImageSrc('/zellyy.png'); + setImageSrc("/zellyy.png"); } setImageLoaded(true); }, []); @@ -31,10 +35,10 @@ const ZellyAvatar: React.FC<{ className?: string }> = ({ className = "h-12 w-12
) : ( <> - setImageLoaded(true)} onError={() => setImageError(true)} /> diff --git a/src/components/analytics/CategorySpendingList.tsx b/src/components/analytics/CategorySpendingList.tsx index 2cdc3cf..99c7067 100644 --- a/src/components/analytics/CategorySpendingList.tsx +++ b/src/components/analytics/CategorySpendingList.tsx @@ -1,9 +1,8 @@ - -import React from 'react'; -import { formatCurrency } from '@/utils/formatters'; -import { useIsMobile } from '@/hooks/use-mobile'; -import { CATEGORY_DESCRIPTIONS } from '@/constants/categoryIcons'; -import { getCategoryColor } from '@/utils/categoryColorUtils'; +import React from "react"; +import { formatCurrency } from "@/utils/formatters"; +import { useIsMobile } from "@/hooks/use-mobile"; +import { CATEGORY_DESCRIPTIONS } from "@/constants/categoryIcons"; +import { getCategoryColor } from "@/utils/categoryColorUtils"; interface CategorySpending { title: string; @@ -22,27 +21,33 @@ const CategorySpendingList: React.FC = ({ categories, totalExpense, className = "", - showCard = true // 기본값은 true로 설정 + showCard = true, // 기본값은 true로 설정 }) => { const isMobile = useIsMobile(); - + // 카테고리 목록을 렌더링하는 함수 const renderCategoryList = () => { - if (categories.some(cat => cat.current > 0)) { + if (categories.some((cat) => cat.current > 0)) { return (
{categories.map((category) => { // 카테고리 이름을 직접 표시 const categoryName = category.title; // 카테고리 설명 찾기 - const description = CATEGORY_DESCRIPTIONS[categoryName] || ''; - + const description = CATEGORY_DESCRIPTIONS[categoryName] || ""; + return ( -
+
-
+
{categoryName} {description && ( @@ -79,7 +84,7 @@ const CategorySpendingList: React.FC = ({
); } - + // 카드 없이 목록만 반환 return renderCategoryList(); }; diff --git a/src/components/analytics/MonthlyComparisonChart.tsx b/src/components/analytics/MonthlyComparisonChart.tsx index d868c50..77a3dfb 100644 --- a/src/components/analytics/MonthlyComparisonChart.tsx +++ b/src/components/analytics/MonthlyComparisonChart.tsx @@ -1,18 +1,24 @@ -import React from 'react'; -import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend, Cell } from 'recharts'; -import { formatCurrency } from '@/utils/formatters'; -interface MonthlyData { - name: string; - budget: number; - expense: number; -} +import React from "react"; +import { logger } from "@/utils/logger"; +import { + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, + Legend, + Cell, +} from "recharts"; +import { formatCurrency } from "@/utils/formatters"; +import { MonthlyData } from "@/types"; interface MonthlyComparisonChartProps { monthlyData: MonthlyData[]; isEmpty?: boolean; } const MonthlyComparisonChart: React.FC = ({ monthlyData, - isEmpty = false + isEmpty = false, }) => { // Format for Y-axis (K format) const formatYAxisTick = (value: number) => { @@ -21,31 +27,42 @@ const MonthlyComparisonChart: React.FC = ({ // Format for tooltip (original currency format) const formatTooltip = (value: number | string) => { - if (typeof value === 'number') { + if (typeof value === "number") { return formatCurrency(value); } return value; }; // 데이터 확인 로깅 - console.log('MonthlyComparisonChart 데이터:', monthlyData); + logger.info("MonthlyComparisonChart 데이터:", monthlyData); // EmptyGraphState 컴포넌트: 데이터가 없을 때 표시 - const EmptyGraphState = () =>
+ const EmptyGraphState = () => ( +

데이터가 없습니다

지출 내역을 추가하면 그래프가 표시됩니다

-
; +
+ ); // 데이터 여부 확인 로직 개선 - 데이터가 비어있거나 모든 값이 0인 경우도 고려 - const hasValidData = monthlyData && monthlyData.length > 0 && monthlyData.some(item => item.budget > 0 || item.expense > 0); + const hasValidData = + monthlyData && + monthlyData.length > 0 && + monthlyData.some((item) => item.budget > 0 || item.expense > 0); // 지출 색상 결정 함수 추가 const getExpenseColor = (budget: number, expense: number) => { - if (budget === 0) return "#81c784"; // 예산이 0이면 기본 색상 + if (budget === 0) { + return "#81c784"; + } // 예산이 0이면 기본 색상 const ratio = expense / budget; - if (ratio > 1) return "#f44336"; // 빨간색 (예산 초과) - if (ratio >= 0.9) return "#ffeb3b"; // 노란색 (예산의 90% 이상) + if (ratio > 1) { + return "#f44336"; + } // 빨간색 (예산 초과) + if (ratio >= 0.9) { + return "#ffeb3b"; + } // 노란색 (예산의 90% 이상) return "#81c784"; // 기본 초록색 }; @@ -55,36 +72,69 @@ const MonthlyComparisonChart: React.FC = ({ // 예산 색상을 좀 더 짙은 회색으로 변경 const darkGrayColor = "#9F9EA1"; // 이전 색상 #C8C8C9에서 더 짙은 회색으로 변경 - return
- {hasValidData ? - + return ( +
+ {hasValidData ? ( + + - - { - // 범례 텍스트 색상 설정 - return {value}; - }} /> - - + + { + // 범례 텍스트 색상 설정 + return ( + + {value} + + ); + }} + /> + + {/* 개별 셀 색상 설정은 제거하고 통일된 메인 그린 색상 사용 */} - : } -
; +
+ ) : ( + + )} +
+ ); }; -export default MonthlyComparisonChart; \ No newline at end of file +export default MonthlyComparisonChart; diff --git a/src/components/analytics/PaymentMethodChart.tsx b/src/components/analytics/PaymentMethodChart.tsx index f2ccdad..9433ab1 100644 --- a/src/components/analytics/PaymentMethodChart.tsx +++ b/src/components/analytics/PaymentMethodChart.tsx @@ -1,21 +1,18 @@ - -import React from 'react'; -import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts'; - -interface PaymentMethodData { - method: string; - amount: number; - percentage: number; -} +import React from "react"; +import { PieChart, Pie, Cell, ResponsiveContainer } from "recharts"; +import { PaymentMethodStats } from "@/contexts/budget/types"; interface PaymentMethodChartProps { - data: PaymentMethodData[]; + data: PaymentMethodStats[]; isEmpty: boolean; } -const COLORS = ['#9b87f5', '#6E59A5']; // 신용카드, 현금 색상 +const COLORS = ["#9b87f5", "#6E59A5"]; // 신용카드, 현금 색상 -const PaymentMethodChart: React.FC = ({ data, isEmpty }) => { +const PaymentMethodChart: React.FC = ({ + data, + isEmpty, +}) => { if (isEmpty) { return (
@@ -24,9 +21,9 @@ const PaymentMethodChart: React.FC = ({ data, isEmpty } ); } - const chartData = data.map(item => ({ + const chartData = data.map((item) => ({ name: item.method, - value: item.amount + value: item.amount, })); return ( @@ -42,11 +39,16 @@ const PaymentMethodChart: React.FC = ({ data, isEmpty } paddingAngle={5} dataKey="value" labelLine={false} - label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`} + label={({ name, percent }) => + `${name} ${(percent * 100).toFixed(0)}%` + } fontSize={12} > {chartData.map((entry, index) => ( - + ))} {/* Legend 제거 */} diff --git a/src/components/analytics/PeriodSelector.tsx b/src/components/analytics/PeriodSelector.tsx index 56f9778..5d190ea 100644 --- a/src/components/analytics/PeriodSelector.tsx +++ b/src/components/analytics/PeriodSelector.tsx @@ -1,7 +1,6 @@ - -import React from 'react'; -import { ChevronLeft, ChevronRight } from 'lucide-react'; -import { useIsMobile } from '@/hooks/use-mobile'; +import React from "react"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { useIsMobile } from "@/hooks/use-mobile"; interface PeriodSelectorProps { selectedPeriod: string; @@ -12,27 +11,21 @@ interface PeriodSelectorProps { const PeriodSelector: React.FC = ({ selectedPeriod, onPrevPeriod, - onNextPeriod + onNextPeriod, }) => { const isMobile = useIsMobile(); return (
- - +
{selectedPeriod}
- -
diff --git a/src/components/analytics/SummaryCards.tsx b/src/components/analytics/SummaryCards.tsx index a88fa92..347d14c 100644 --- a/src/components/analytics/SummaryCards.tsx +++ b/src/components/analytics/SummaryCards.tsx @@ -1,7 +1,7 @@ -import React from 'react'; -import { Wallet, CreditCard, Coins } from 'lucide-react'; -import { formatCurrency } from '@/utils/formatters'; -import { useIsMobile } from '@/hooks/use-mobile'; +import React from "react"; +import { Wallet, CreditCard, Coins } from "lucide-react"; +import { formatCurrency } from "@/utils/formatters"; +import { useIsMobile } from "@/hooks/use-mobile"; interface SummaryCardsProps { totalBudget: number; totalExpense: number; @@ -10,18 +10,21 @@ interface SummaryCardsProps { const SummaryCards: React.FC = ({ totalBudget, totalExpense, - savingsPercentage + savingsPercentage, }) => { const isMobile = useIsMobile(); // 남은 예산 계산 const remainingBudget = totalBudget - totalExpense; const isOverBudget = remainingBudget < 0; - return
+ return ( +
- {isMobile ? - // 모바일 레이아웃 (1줄: 아이콘, 제목, 금액 가로배치) -
+ {isMobile ? ( + // 모바일 레이아웃 (1줄: 아이콘, 제목, 금액 가로배치) +

예산

@@ -29,9 +32,10 @@ const SummaryCards: React.FC = ({

{formatCurrency(totalBudget)}

-
: - // 데스크탑 레이아웃 (2줄: 아이콘과 제목이 첫째 줄, 금액이 둘째 줄) - <> +
+ ) : ( + // 데스크탑 레이아웃 (2줄: 아이콘과 제목이 첫째 줄, 금액이 둘째 줄) + <>

예산

@@ -39,12 +43,13 @@ const SummaryCards: React.FC = ({

{formatCurrency(totalBudget)}

- } + + )}
- {isMobile ? - // 모바일 레이아웃 (1줄: 아이콘, 제목, 금액 가로배치) -
+ {isMobile ? ( + // 모바일 레이아웃 (1줄: 아이콘, 제목, 금액 가로배치) +

지출

@@ -52,9 +57,10 @@ const SummaryCards: React.FC = ({

{formatCurrency(totalExpense)}

-
: - // 데스크탑 레이아웃 (2줄: 아이콘과 제목이 첫째 줄, 금액이 둘째 줄) - <> +
+ ) : ( + // 데스크탑 레이아웃 (2줄: 아이콘과 제목이 첫째 줄, 금액이 둘째 줄) + <>

지출

@@ -62,35 +68,47 @@ const SummaryCards: React.FC = ({

{formatCurrency(totalExpense)}

- } + + )}
- {isMobile ? - // 모바일 레이아웃 (1줄: 아이콘, 제목, 금액 가로배치) -
+ {isMobile ? ( + // 모바일 레이아웃 (1줄: 아이콘, 제목, 금액 가로배치) +

잔액

- {isOverBudget ?

+ {isOverBudget ? ( +

초과액: {formatCurrency(Math.abs(remainingBudget))} -

:

+

+ ) : ( +

{formatCurrency(remainingBudget)} -

} -
: - // 데스크탑 레이아웃 (2줄: 아이콘과 제목이 첫째 줄, 금액이 둘째 줄) - <> +

+ )} +
+ ) : ( + // 데스크탑 레이아웃 (2줄: 아이콘과 제목이 첫째 줄, 금액이 둘째 줄) + <>

잔액

- {isOverBudget ?

+ {isOverBudget ? ( +

초과액: {formatCurrency(Math.abs(remainingBudget))} -

:

+

+ ) : ( +

{formatCurrency(remainingBudget)} -

} - } +

+ )} + + )}
-
; +
+ ); }; -export default SummaryCards; \ No newline at end of file +export default SummaryCards; diff --git a/src/components/auth/AppwriteConnectionStatus.tsx b/src/components/auth/AppwriteConnectionStatus.tsx index e230e2a..d2dff6d 100644 --- a/src/components/auth/AppwriteConnectionStatus.tsx +++ b/src/components/auth/AppwriteConnectionStatus.tsx @@ -1,5 +1,5 @@ -import React from 'react'; -import { CheckCircle, XCircle } from 'lucide-react'; +import React from "react"; +import { CheckCircle, XCircle } from "lucide-react"; interface AppwriteConnectionStatusProps { testResults: { @@ -9,11 +9,17 @@ interface AppwriteConnectionStatusProps { } | null; } -const AppwriteConnectionStatus = ({ testResults }: AppwriteConnectionStatusProps) => { - if (!testResults) return null; +const AppwriteConnectionStatus = ({ + testResults, +}: AppwriteConnectionStatusProps) => { + if (!testResults) { + return null; + } return ( -
+
{testResults.connected ? ( @@ -21,16 +27,20 @@ const AppwriteConnectionStatus = ({ testResults }: AppwriteConnectionStatusProps )}
-

- {testResults.connected ? '연결됨' : '연결 실패'} +

+ {testResults.connected ? "연결됨" : "연결 실패"}

-

+

{testResults.message}

{testResults.details && ( -

- {testResults.details} -

+

{testResults.details}

)}
diff --git a/src/components/auth/AppwriteConnectionTest.tsx b/src/components/auth/AppwriteConnectionTest.tsx index 83dd0b9..cb42c0b 100644 --- a/src/components/auth/AppwriteConnectionTest.tsx +++ b/src/components/auth/AppwriteConnectionTest.tsx @@ -1,9 +1,14 @@ -import React, { useState, useEffect, useCallback } from 'react'; -import { Button } from '@/components/ui/button'; -import { Loader2 } from 'lucide-react'; -import AppwriteConnectionStatus from './AppwriteConnectionStatus'; -import { client, account, isValidAppwriteConfig, getAppwriteEndpoint } from '@/lib/appwrite'; -import { setupAppwriteDatabase } from '@/lib/appwrite/setup'; +import React, { useState, useEffect, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Loader2 } from "lucide-react"; +import AppwriteConnectionStatus from "./AppwriteConnectionStatus"; +import { + client, + account, + isValidAppwriteConfig, + getAppwriteEndpoint, +} from "@/lib/appwrite"; +import { setupAppwriteDatabase } from "@/lib/appwrite/setup"; /** * Appwrite 연결 테스트 컴포넌트 @@ -16,119 +21,120 @@ const AppwriteConnectionTest = () => { message: string; details?: string; } | null>(null); - + // 로딩 상태 const [loading, setLoading] = useState(false); - + // 데이터베이스 설정 상태 const [dbSetupDone, setDbSetupDone] = useState(false); - + // 연결 테스트 함수 const testConnection = useCallback(async () => { setLoading(true); setTestResults(null); - + try { // 설정 유효성 검사 if (!isValidAppwriteConfig()) { setTestResults({ connected: false, - message: 'Appwrite 설정이 완료되지 않았습니다.', - details: '환경 변수 VITE_APPWRITE_ENDPOINT 및 VITE_APPWRITE_PROJECT_ID를 확인하세요.' + message: "Appwrite 설정이 완료되지 않았습니다.", + details: + "환경 변수 VITE_APPWRITE_ENDPOINT 및 VITE_APPWRITE_PROJECT_ID를 확인하세요.", }); return; } - + // 서버 연결 테스트 try { await account.get(); - + setTestResults({ connected: true, - message: 'Appwrite 서버에 성공적으로 연결되었습니다.', - details: `서버: ${getAppwriteEndpoint()}` + message: "Appwrite 서버에 성공적으로 연결되었습니다.", + details: `서버: ${getAppwriteEndpoint()}`, }); } catch (error: any) { // 인증 오류는 연결 성공으로 간주 (로그인 필요) if (error.code === 401) { setTestResults({ connected: true, - message: 'Appwrite 서버에 연결되었지만 로그인이 필요합니다.', - details: `서버: ${getAppwriteEndpoint()}` + message: "Appwrite 서버에 연결되었지만 로그인이 필요합니다.", + details: `서버: ${getAppwriteEndpoint()}`, }); } else { setTestResults({ connected: false, - message: '서버 연결에 실패했습니다.', - details: error.message + message: "서버 연결에 실패했습니다.", + details: error.message, }); } } } catch (error: any) { setTestResults({ connected: false, - message: '연결 테스트 중 오류가 발생했습니다.', - details: error.message + message: "연결 테스트 중 오류가 발생했습니다.", + details: error.message, }); } finally { setLoading(false); } }, []); - + // 데이터베이스 설정 함수 const setupDatabase = useCallback(async () => { setLoading(true); - + try { const success = await setupAppwriteDatabase(); - + if (success) { setDbSetupDone(true); setTestResults({ connected: true, - message: '데이터베이스 설정이 완료되었습니다.', - details: '트랜잭션 컬렉션이 준비되었습니다.' + message: "데이터베이스 설정이 완료되었습니다.", + details: "트랜잭션 컬렉션이 준비되었습니다.", }); } else { setTestResults({ connected: false, - message: '데이터베이스 설정에 실패했습니다.', - details: '로그를 확인하세요.' + message: "데이터베이스 설정에 실패했습니다.", + details: "로그를 확인하세요.", }); } } catch (error: any) { setTestResults({ connected: false, - message: '데이터베이스 설정 중 오류가 발생했습니다.', - details: error.message + message: "데이터베이스 설정 중 오류가 발생했습니다.", + details: error.message, }); } finally { setLoading(false); } }, []); - + // 컴포넌트 마운트 시 자동 테스트 useEffect(() => { testConnection(); }, [testConnection]); - + return (
- - + {testResults?.connected && !dbSetupDone && ( - )}
- +
); diff --git a/src/components/auth/EmailConfirmation.tsx b/src/components/auth/EmailConfirmation.tsx index a929c86..0893db2 100644 --- a/src/components/auth/EmailConfirmation.tsx +++ b/src/components/auth/EmailConfirmation.tsx @@ -1,5 +1,5 @@ - import React, { useState } from "react"; +import { authLogger } from "@/utils/logger"; import { useNavigate } from "react-router-dom"; import { Mail, InfoIcon, RefreshCw } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -11,20 +11,26 @@ interface EmailConfirmationProps { onResendEmail?: () => Promise; } -const EmailConfirmation: React.FC = ({ email, onBackToForm, onResendEmail }) => { +const EmailConfirmation: React.FC = ({ + email, + onBackToForm, + onResendEmail, +}) => { const navigate = useNavigate(); const [isResending, setIsResending] = useState(false); // 이메일 재전송 핸들러 const handleResendEmail = async () => { - if (!onResendEmail) return; - + if (!onResendEmail) { + return; + } + setIsResending(true); try { await onResendEmail(); // 성공 메시지는 onResendEmail 내부에서 표시 } catch (error) { - console.error('이메일 재전송 오류:', error); + authLogger.error("이메일 재전송 오류:", error); } finally { setIsResending(false); } @@ -39,12 +45,15 @@ const EmailConfirmation: React.FC = ({ email, onBackToFo {email}로 인증 링크가 포함된 이메일을 보냈습니다. 이메일을 확인하고 링크를 클릭하여 계정 등록을 완료해주세요.

- + - 인증 이메일이 보이지 않나요? + + 인증 이메일이 보이지 않나요? + - 스팸 폴더를 확인해보세요. 또한 이메일 서비스에 따라 도착하는데 몇 분이 걸릴 수 있습니다. + 스팸 폴더를 확인해보세요. 또한 이메일 서비스에 따라 도착하는데 몇 + 분이 걸릴 수 있습니다. {onResendEmail && (
아직도 받지 못했다면 아래 '인증 메일 재전송' 버튼을 클릭하세요. @@ -52,12 +61,12 @@ const EmailConfirmation: React.FC = ({ email, onBackToFo )} - +
{onResendEmail && ( - )} - - -
diff --git a/src/components/auth/LoginForm.tsx b/src/components/auth/LoginForm.tsx index 9dfdb0a..5a2778f 100644 --- a/src/components/auth/LoginForm.tsx +++ b/src/components/auth/LoginForm.tsx @@ -3,7 +3,15 @@ import { Link } from "react-router-dom"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; -import { ArrowRight, Mail, KeyRound, Eye, EyeOff, AlertTriangle, Loader2 } from "lucide-react"; +import { + ArrowRight, + Mail, + KeyRound, + Eye, + EyeOff, + AlertTriangle, + Loader2, +} from "lucide-react"; interface LoginFormProps { email: string; setEmail: (email: string) => void; @@ -26,59 +34,120 @@ const LoginForm: React.FC = ({ isLoading, isSettingUpTables = false, loginError, - handleLogin + handleLogin, }) => { // CORS 또는 JSON 관련 오류인지 확인 - const isCorsOrJsonError = loginError && (loginError.includes('JSON') || loginError.includes('CORS') || loginError.includes('프록시') || loginError.includes('서버 응답') || loginError.includes('네트워크') || loginError.includes('404') || loginError.includes('Not Found')); - return
+ const isCorsOrJsonError = + loginError && + (loginError.includes("JSON") || + loginError.includes("CORS") || + loginError.includes("프록시") || + loginError.includes("서버 응답") || + loginError.includes("네트워크") || + loginError.includes("404") || + loginError.includes("Not Found")); + return ( +
- +
- setEmail(e.target.value)} className="pl-10 neuro-pressed" /> + setEmail(e.target.value)} + className="pl-10 neuro-pressed" + />
- +
- +
- setPassword(e.target.value)} className="pl-10 neuro-pressed" /> -
- - {loginError &&
+ + {loginError && ( +

{loginError}

- - {isCorsOrJsonError &&
    -
  • 설정 페이지에서 다른 CORS 프록시 유형을 시도해 보세요.
  • -
  • HTTPS URL을 사용하는 Supabase 인스턴스로 변경해 보세요.
  • + + {isCorsOrJsonError && ( +
      +
    • + 설정 페이지에서 다른 CORS 프록시 유형을 시도해 보세요. +
    • +
    • + HTTPS URL을 사용하는 Supabase 인스턴스로 변경해 보세요. +
    • 네트워크 연결 상태를 확인하세요.
    • -
    } +
+ )}
-
} - +
+ )} +
- + 비밀번호를 잊으셨나요?
- -
-
; +
+ ); }; -export default LoginForm; \ No newline at end of file +export default LoginForm; diff --git a/src/components/auth/LoginLink.tsx b/src/components/auth/LoginLink.tsx index dea67aa..e4fd806 100644 --- a/src/components/auth/LoginLink.tsx +++ b/src/components/auth/LoginLink.tsx @@ -1,4 +1,3 @@ - import React from "react"; import { Link } from "react-router-dom"; @@ -7,7 +6,10 @@ const LoginLink: React.FC = () => {

이미 계정이 있으신가요?{" "} - + 로그인

diff --git a/src/components/auth/PrivateRoute.tsx b/src/components/auth/PrivateRoute.tsx index 75c16bc..fe06295 100644 --- a/src/components/auth/PrivateRoute.tsx +++ b/src/components/auth/PrivateRoute.tsx @@ -1,8 +1,7 @@ - -import { ReactNode, useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { useAuth } from '@/contexts/auth'; -import { useToast } from '@/hooks/useToast.wrapper'; +import { ReactNode, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { useAuth } from "@/contexts/auth"; +import { useToast } from "@/hooks/useToast.wrapper"; interface PrivateRouteProps { children: ReactNode; @@ -25,7 +24,7 @@ const PrivateRoute = ({ children, requireAuth = true }: PrivateRouteProps) => { description: "이 페이지에 접근하려면 로그인이 필요합니다.", variant: "destructive", }); - navigate('/login', { replace: true }); + navigate("/login", { replace: true }); } }, [user, loading, navigate, requireAuth, toast]); diff --git a/src/components/auth/RegisterErrorDisplay.tsx b/src/components/auth/RegisterErrorDisplay.tsx index d6ef0b1..f01bc12 100644 --- a/src/components/auth/RegisterErrorDisplay.tsx +++ b/src/components/auth/RegisterErrorDisplay.tsx @@ -1,12 +1,15 @@ - import React from "react"; interface RegisterErrorDisplayProps { error: string | null; } -const RegisterErrorDisplay: React.FC = ({ error }) => { - if (!error) return null; +const RegisterErrorDisplay: React.FC = ({ + error, +}) => { + if (!error) { + return null; + } return (
diff --git a/src/components/auth/RegisterForm.tsx b/src/components/auth/RegisterForm.tsx index 549cd1c..72d7da2 100644 --- a/src/components/auth/RegisterForm.tsx +++ b/src/components/auth/RegisterForm.tsx @@ -1,4 +1,5 @@ import React, { useState } from "react"; +import { authLogger } from "@/utils/logger"; import { useNavigate } from "react-router-dom"; import { Button } from "@/components/ui/button"; import { ArrowRight } from "lucide-react"; @@ -10,7 +11,16 @@ import RegisterFormFields from "./RegisterFormFields"; import { supabase } from "@/archive/lib/supabase"; interface RegisterFormProps { - signUp: (email: string, password: string, username: string) => Promise<{ error: any, user: any, redirectToSettings?: boolean, emailConfirmationRequired?: boolean }>; + signUp: ( + email: string, + password: string, + username: string + ) => Promise<{ + error: any; + user: any; + redirectToSettings?: boolean; + emailConfirmationRequired?: boolean; + }>; serverStatus: ServerConnectionStatus; setServerStatus: React.Dispatch>; setRegisterError: React.Dispatch>; @@ -29,7 +39,7 @@ const RegisterForm: React.FC = ({ const [showPassword, setShowPassword] = useState(false); const [isLoading, setIsLoading] = useState(false); const [emailConfirmationSent, setEmailConfirmationSent] = useState(false); - + const { toast } = useToast(); const navigate = useNavigate(); @@ -42,7 +52,7 @@ const RegisterForm: React.FC = ({ }); return false; } - + if (password !== confirmPassword) { toast({ title: "비밀번호 불일치", @@ -51,7 +61,7 @@ const RegisterForm: React.FC = ({ }); return false; } - + // 비밀번호 강도 검사 if (password.length < 8) { toast({ @@ -61,7 +71,7 @@ const RegisterForm: React.FC = ({ }); return false; } - + // 이메일 형식 검사 const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailPattern.test(email)) { @@ -72,7 +82,7 @@ const RegisterForm: React.FC = ({ }); return false; } - + return true; }; @@ -84,22 +94,24 @@ const RegisterForm: React.FC = ({ setServerStatus({ checked: true, connected: currentStatus.connected, - message: currentStatus.message + message: currentStatus.message, }); - + if (!currentStatus.connected) { toast({ title: "서버 연결 실패", - description: "서버에 연결할 수 없습니다. 네트워크 또는 서버 상태를 확인하세요.", - variant: "destructive" + description: + "서버에 연결할 수 없습니다. 네트워크 또는 서버 상태를 확인하세요.", + variant: "destructive", }); return false; } } catch (error: any) { toast({ title: "연결 확인 오류", - description: error.message || "서버 연결 확인 중 오류가 발생했습니다.", - variant: "destructive" + description: + error.message || "서버 연결 확인 중 오류가 발생했습니다.", + variant: "destructive", }); return false; } @@ -113,37 +125,39 @@ const RegisterForm: React.FC = ({ // 현재 브라우저 URL 가져오기 const currentUrl = window.location.origin; const redirectUrl = `${currentUrl}/login?auth_callback=true`; - + const { error } = await supabase.auth.resend({ - type: 'signup', + type: "signup", email, options: { emailRedirectTo: redirectUrl, }, }); - + if (error) { - console.error('인증 메일 재전송 실패:', error); + authLogger.error("인증 메일 재전송 실패:", error); toast({ title: "인증 메일 재전송 실패", - description: error.message || "인증 메일을 재전송하는 중 오류가 발생했습니다.", - variant: "destructive" + description: + error.message || "인증 메일을 재전송하는 중 오류가 발생했습니다.", + variant: "destructive", }); return; } - + toast({ title: "인증 메일 재전송 완료", description: `${email}로 인증 메일이 재전송되었습니다. 이메일과 스팸 폴더를 확인해주세요.`, }); - - console.log('인증 메일 재전송 성공:', email); + + authLogger.info("인증 메일 재전송 성공:", email); } catch (error: any) { - console.error('인증 메일 재전송 중 예외 발생:', error); + authLogger.error("인증 메일 재전송 중 예외 발생:", error); toast({ title: "인증 메일 재전송 오류", - description: error.message || "인증 메일을 재전송하는 중 오류가 발생했습니다.", - variant: "destructive" + description: + error.message || "인증 메일을 재전송하는 중 오류가 발생했습니다.", + variant: "destructive", }); } }; @@ -151,60 +165,70 @@ const RegisterForm: React.FC = ({ const handleRegister = async (e: React.FormEvent) => { e.preventDefault(); setRegisterError(null); - + // 서버 연결 확인 const isServerConnected = await checkServerConnectivity(); - if (!isServerConnected) return; - + if (!isServerConnected) { + return; + } + // 폼 유효성 검사 - if (!validateForm()) return; - + if (!validateForm()) { + return; + } + setIsLoading(true); - + try { // 회원가입 시도 - const { error, user, redirectToSettings, emailConfirmationRequired } = await signUp(email, password, username); - + const { error, user, redirectToSettings, emailConfirmationRequired } = + await signUp(email, password, username); + if (error) { // 오류 메시지 출력 - setRegisterError(error.message || '알 수 없는 오류가 발생했습니다.'); - + setRegisterError(error.message || "알 수 없는 오류가 발생했습니다."); + // 설정 페이지 리디렉션이 필요한 경우 if (redirectToSettings) { toast({ title: "Supabase 설정 필요", description: "Supabase 설정을 확인하고 올바른 값을 입력해주세요.", - variant: "destructive" + variant: "destructive", }); - + // 2초 후 설정 페이지로 이동 setTimeout(() => { navigate("/supabase-settings"); }, 2000); return; } - + // 네트워크 관련 오류인 경우 자세한 안내 - if (error.message && ( - error.message.includes('fetch') || - error.message.includes('네트워크') || - error.message.includes('CORS'))) { + if ( + error.message && + (error.message.includes("fetch") || + error.message.includes("네트워크") || + error.message.includes("CORS")) + ) { toast({ title: "네트워크 오류", - description: "서버에 연결할 수 없습니다. 설정에서 CORS 프록시가 활성화되어 있는지 확인하세요.", - variant: "destructive" + description: + "서버에 연결할 수 없습니다. 설정에서 CORS 프록시가 활성화되어 있는지 확인하세요.", + variant: "destructive", }); - } + } // 서버 응답 관련 오류인 경우 - else if (error.message && ( - error.message.includes('400') || - error.message.includes('401') || - error.message.includes('403') || - error.message.includes('500'))) { + else if ( + error.message && + (error.message.includes("400") || + error.message.includes("401") || + error.message.includes("403") || + error.message.includes("500")) + ) { toast({ title: "서버 응답 오류", description: error.message, - variant: "destructive" + variant: "destructive", }); } } else if (user) { @@ -219,21 +243,22 @@ const RegisterForm: React.FC = ({ // 이메일 확인이 필요하지 않은 경우 (자동 승인 등) toast({ title: "회원가입 성공", - description: "회원가입이 완료되었습니다. 로그인 페이지로 이동합니다.", + description: + "회원가입이 완료되었습니다. 로그인 페이지로 이동합니다.", }); - + // 로그인 페이지로 이동 navigate("/login"); } } } catch (error: any) { - console.error("회원가입 처리 중 예외 발생:", error); - setRegisterError(error.message || '예상치 못한 오류가 발생했습니다.'); - + authLogger.error("회원가입 처리 중 예외 발생:", error); + setRegisterError(error.message || "예상치 못한 오류가 발생했습니다."); + toast({ title: "회원가입 오류", description: error.message || "회원가입 처리 중 오류가 발생했습니다.", - variant: "destructive" + variant: "destructive", }); } finally { setIsLoading(false); @@ -242,11 +267,13 @@ const RegisterForm: React.FC = ({ // 이메일 인증 안내 화면 (인증 메일이 발송된 경우) if (emailConfirmationSent) { - return setEmailConfirmationSent(false)} - onResendEmail={handleResendVerificationEmail} - />; + return ( + setEmailConfirmationSent(false)} + onResendEmail={handleResendVerificationEmail} + /> + ); } // 일반 회원가입 양식 @@ -265,13 +292,16 @@ const RegisterForm: React.FC = ({ showPassword={showPassword} setShowPassword={setShowPassword} /> - +
diff --git a/src/components/auth/RegisterFormFields.tsx b/src/components/auth/RegisterFormFields.tsx index 572d8ac..6e1862b 100644 --- a/src/components/auth/RegisterFormFields.tsx +++ b/src/components/auth/RegisterFormFields.tsx @@ -1,4 +1,3 @@ - import React, { useState } from "react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; @@ -28,12 +27,14 @@ const RegisterFormFields: React.FC = ({ confirmPassword, setConfirmPassword, showPassword, - setShowPassword + setShowPassword, }) => { return (
- +
= ({ />
- +
- +
= ({ />
- +
- +
= ({ onClick={() => setShowPassword(!showPassword)} className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-500" > - {showPassword ? : } + {showPassword ? ( + + ) : ( + + )}
{password && password.length > 0 && password.length < 8 && ( -

비밀번호는 최소 8자 이상이어야 합니다.

+

+ 비밀번호는 최소 8자 이상이어야 합니다. +

)}
- +
- +
= ({ />
{confirmPassword && password !== confirmPassword && ( -

비밀번호가 일치하지 않습니다.

+

+ 비밀번호가 일치하지 않습니다. +

)}
- + 이메일 인증 필요 - 회원가입 후 이메일로 인증 링크가 발송됩니다. - 이메일 인증을 완료해야 로그인이 가능합니다. + 회원가입 후 이메일로 인증 링크가 발송됩니다. 이메일 인증을 완료해야 + 로그인이 가능합니다.
diff --git a/src/components/auth/RegisterHeader.tsx b/src/components/auth/RegisterHeader.tsx index dd29bb9..d161b95 100644 --- a/src/components/auth/RegisterHeader.tsx +++ b/src/components/auth/RegisterHeader.tsx @@ -1,12 +1,15 @@ - import React from "react"; const RegisterHeader: React.FC = () => { return (
-

젤리의 적자탈출

+

+ 젤리의 적자탈출 +

새 계정을 만들고 재정 관리를 시작하세요

-

온프레미스 Supabase 연결 최적화 완료

+

+ 온프레미스 Supabase 연결 최적화 완료 +

); }; diff --git a/src/components/auth/ServerStatusAlert.tsx b/src/components/auth/ServerStatusAlert.tsx index 26d1132..5f81288 100644 --- a/src/components/auth/ServerStatusAlert.tsx +++ b/src/components/auth/ServerStatusAlert.tsx @@ -1,5 +1,4 @@ - -import React from 'react'; +import React from "react"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { AlertCircle, RefreshCw } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -13,7 +12,10 @@ interface ServerStatusAlertProps { checkServerConnection: () => Promise; } -const ServerStatusAlert = ({ serverStatus, checkServerConnection }: ServerStatusAlertProps) => { +const ServerStatusAlert = ({ + serverStatus, + checkServerConnection, +}: ServerStatusAlertProps) => { if (!serverStatus.checked || serverStatus.connected) { return null; } @@ -24,9 +26,9 @@ const ServerStatusAlert = ({ serverStatus, checkServerConnection }: ServerStatus 서버 연결 문제 {serverStatus.message} -
diff --git a/src/components/auth/types.ts b/src/components/auth/types.ts index 0f25655..d965e87 100644 --- a/src/components/auth/types.ts +++ b/src/components/auth/types.ts @@ -1,13 +1,12 @@ - -export interface ServerConnectionStatus { - checked: boolean; - connected: boolean; - message: string; - details?: string; -} +import type { ServerConnectionStatus } from "@/types/common"; +import type { SignUpResponse } from "@/contexts/auth/types"; export interface RegisterFormProps { - signUp: (email: string, password: string, username: string) => Promise<{ error: any, user: any }>; + signUp: ( + email: string, + password: string, + username: string + ) => Promise; serverStatus: ServerConnectionStatus; setServerStatus: React.Dispatch>; setRegisterError: (error: string | null) => void; diff --git a/src/components/budget/BudgetDialog.tsx b/src/components/budget/BudgetDialog.tsx index e712b25..fabcd47 100644 --- a/src/components/budget/BudgetDialog.tsx +++ b/src/components/budget/BudgetDialog.tsx @@ -1,16 +1,15 @@ - -import React from 'react'; -import { - Dialog, - DialogContent, - DialogHeader, +import React from "react"; +import { + Dialog, + DialogContent, + DialogHeader, DialogTitle, - DialogDescription -} from '@/components/ui/dialog'; -import CategoryBudgetInputs from '../CategoryBudgetInputs'; -import { Button } from '@/components/ui/button'; -import { Check } from 'lucide-react'; -import { formatCurrency } from '@/utils/formatters'; + DialogDescription, +} from "@/components/ui/dialog"; +import CategoryBudgetInputs from "../CategoryBudgetInputs"; +import { Button } from "@/components/ui/button"; +import { Check } from "lucide-react"; +import { formatCurrency } from "@/utils/formatters"; interface BudgetDialogProps { open: boolean; @@ -29,7 +28,7 @@ const BudgetDialog: React.FC = ({ handleCategoryInputChange, handleSaveCategoryBudgets, calculateTotalBudget, - isSubmitting = false + isSubmitting = false, }) => { const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); @@ -40,10 +39,12 @@ const BudgetDialog: React.FC = ({ const formattedTotal = formatCurrency(calculateTotalBudget()); return ( - { - if (isSubmitting && !newOpen) return; + if (isSubmitting && !newOpen) { + return; + } onOpenChange(newOpen); }} > @@ -51,32 +52,35 @@ const BudgetDialog: React.FC = ({ 예산 설정 - 카테고리별로 월간 예산을 설정하세요. 일일, 주간 예산은 자동으로 계산됩니다. + 카테고리별로 월간 예산을 설정하세요. 일일, 주간 예산은 자동으로 + 계산됩니다. - +
-

월간 총 예산:

-

{formattedTotal}

+

+ {formattedTotal} +

- -
@@ -39,9 +42,9 @@ const BudgetInputButton: React.FC = ({ return (
아직 예산이 설정되지 않았습니다
- -
); diff --git a/src/components/expenses/ExpenseTitleInput.tsx b/src/components/expenses/ExpenseTitleInput.tsx index 0765619..abcf40b 100644 --- a/src/components/expenses/ExpenseTitleInput.tsx +++ b/src/components/expenses/ExpenseTitleInput.tsx @@ -1,18 +1,17 @@ - -import React from 'react'; -import { FormField, FormItem, FormLabel } from '@/components/ui/form'; -import { Input } from '@/components/ui/input'; -import { UseFormReturn } from 'react-hook-form'; -import { ExpenseFormValues } from './ExpenseForm'; +import React from "react"; +import { FormField, FormItem, FormLabel } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { UseFormReturn } from "react-hook-form"; +import { ExpenseFormValues } from "./ExpenseForm"; interface ExpenseTitleInputProps { form: UseFormReturn; isDisabled?: boolean; } -const ExpenseTitleInput: React.FC = ({ +const ExpenseTitleInput: React.FC = ({ form, - isDisabled = false + isDisabled = false, }) => { return ( = ({ render={({ field }) => ( 제목 - diff --git a/src/components/expenses/ExpenseTitleSuggestions.tsx b/src/components/expenses/ExpenseTitleSuggestions.tsx index 8c45b0c..07a669d 100644 --- a/src/components/expenses/ExpenseTitleSuggestions.tsx +++ b/src/components/expenses/ExpenseTitleSuggestions.tsx @@ -1,7 +1,6 @@ - -import React from 'react'; -import { Badge } from '@/components/ui/badge'; -import { getPersonalizedTitleSuggestions } from '@/utils/userTitlePreferences'; +import React from "react"; +import { Badge } from "@/components/ui/badge"; +import { getPersonalizedTitleSuggestions } from "@/utils/userTitlePreferences"; interface ExpenseTitleSuggestionsProps { category: string; @@ -10,18 +9,18 @@ interface ExpenseTitleSuggestionsProps { const ExpenseTitleSuggestions: React.FC = ({ category, - onSuggestionClick + onSuggestionClick, }) => { const titleSuggestions = getPersonalizedTitleSuggestions(category); - + if (!category || titleSuggestions.length === 0) { return null; } - + return (
{titleSuggestions.map((suggestion) => ( - = ({ - message = "아직 데이터가 없습니다", - subMessage = "예산을 설정하고 지출을 추가해 보세요" +const EmptyState: React.FC = ({ + message = "아직 데이터가 없습니다", + subMessage = "예산을 설정하고 지출을 추가해 보세요", }) => { return (
diff --git a/src/components/home/HomeContent.tsx b/src/components/home/HomeContent.tsx index 2440378..c80eade 100644 --- a/src/components/home/HomeContent.tsx +++ b/src/components/home/HomeContent.tsx @@ -1,19 +1,22 @@ - -import React from 'react'; -import BudgetProgressCard from '@/components/BudgetProgressCard'; -import BudgetCategoriesSection from '@/components/BudgetCategoriesSection'; -import RecentTransactionsSection from '@/components/RecentTransactionsSection'; -import EmptyState from './EmptyState'; -import { BudgetPeriod } from '@/contexts/budget/BudgetContext'; -import { formatCurrency, calculatePercentage } from '@/utils/formatters'; -import { Transaction, BudgetData } from '@/contexts/budget/types'; +import React from "react"; +import BudgetProgressCard from "@/components/BudgetProgressCard"; +import BudgetCategoriesSection from "@/components/BudgetCategoriesSection"; +import RecentTransactionsSection from "@/components/RecentTransactionsSection"; +import EmptyState from "./EmptyState"; +import { BudgetPeriod } from "@/contexts/budget/BudgetContext"; +import { formatCurrency, calculatePercentage } from "@/utils/formatters"; +import { Transaction, BudgetData } from "@/contexts/budget/types"; interface HomeContentProps { transactions: Transaction[]; budgetData: BudgetData; selectedTab: string; setSelectedTab: (value: string) => void; - handleBudgetGoalUpdate: (type: BudgetPeriod, amount: number, newCategoryBudgets?: Record) => void; + handleBudgetGoalUpdate: ( + type: BudgetPeriod, + amount: number, + newCategoryBudgets?: Record + ) => void; updateTransaction: (transaction: Transaction) => void; getCategorySpending: () => Array<{ title: string; @@ -29,12 +32,13 @@ const HomeContent: React.FC = ({ setSelectedTab, handleBudgetGoalUpdate, updateTransaction, - getCategorySpending + getCategorySpending, }) => { // getCategorySpending 함수의 반환값을 바로 사용하지 말고, 변수에 할당하여 사용 const categorySpendingData = getCategorySpending(); - const hasAnySpending = Array.isArray(categorySpendingData) && - categorySpendingData.some(cat => cat.current > 0 || cat.total > 0); + const hasAnySpending = + Array.isArray(categorySpendingData) && + categorySpendingData.some((cat) => cat.current > 0 || cat.total > 0); return (
@@ -44,7 +48,7 @@ const HomeContent: React.FC = ({ )}

월간 예산과 지출

- = ({ onSaveBudget={handleBudgetGoalUpdate} /> {transactions.length > 0 ? ( - ) : ( diff --git a/src/components/home/IndexContent.tsx b/src/components/home/IndexContent.tsx index 282fd08..a9ef65e 100644 --- a/src/components/home/IndexContent.tsx +++ b/src/components/home/IndexContent.tsx @@ -1,27 +1,26 @@ - -import React from 'react'; -import Header from '@/components/Header'; -import HomeContent from '@/components/home/HomeContent'; -import { useBudget } from '@/contexts/budget/BudgetContext'; -import { BudgetData } from '@/contexts/budget/types'; +import React from "react"; +import Header from "@/components/Header"; +import HomeContent from "@/components/home/HomeContent"; +import { useBudget } from "@/contexts/budget/BudgetContext"; +import { BudgetData } from "@/contexts/budget/types"; // 기본 예산 데이터 (빈 객체 대신 사용할 더미 데이터) const defaultBudgetData: BudgetData = { daily: { targetAmount: 0, spentAmount: 0, - remainingAmount: 0 + remainingAmount: 0, }, weekly: { targetAmount: 0, spentAmount: 0, - remainingAmount: 0 + remainingAmount: 0, }, monthly: { targetAmount: 0, spentAmount: 0, - remainingAmount: 0 - } + remainingAmount: 0, + }, }; /** @@ -35,16 +34,16 @@ const IndexContent: React.FC = () => { setSelectedTab, handleBudgetGoalUpdate, updateTransaction, - getCategorySpending + getCategorySpending, } = useBudget(); return (
- = ({ notifications, onClearAll, - onReadNotification + onReadNotification, }) => { - const unreadCount = notifications.filter(notification => !notification.read).length; - + const unreadCount = notifications.filter( + (notification) => !notification.read + ).length; + const handleClearAll = () => { onClearAll(); - toast.success('모든 알림이 삭제되었습니다.'); + toast.success("모든 알림이 삭제되었습니다."); }; const formatDate = (date: Date) => { - return new Intl.DateTimeFormat('ko-KR', { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' + return new Intl.DateTimeFormat("ko-KR", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", }).format(date); }; @@ -49,9 +54,7 @@ const NotificationPopover: React.FC = ({
{notifications.length > 0 && ( - )}
- + - +
{notifications.length === 0 ? (
@@ -91,12 +92,21 @@ const NotificationPopover: React.FC = ({
) : ( notifications.map((notification) => ( -
+
-

{notification.title}

-

{notification.message}

-

{formatDate(notification.timestamp)}

+

+ {notification.title} +

+

+ {notification.message} +

+

+ {formatDate(notification.timestamp)} +

- setDontShowAgain(checked === true)} className="focus:outline-none focus:ring-0 focus:ring-offset-0" /> -
- -
; +
+ ); }; -export default WelcomeDialog; \ No newline at end of file +export default WelcomeDialog; diff --git a/src/components/profile/PasswordChangeForm.tsx b/src/components/profile/PasswordChangeForm.tsx index 35c987e..b46b1d3 100644 --- a/src/components/profile/PasswordChangeForm.tsx +++ b/src/components/profile/PasswordChangeForm.tsx @@ -1,29 +1,48 @@ +import React, { useState } from "react"; +import { logger } from "@/utils/logger"; +import { Key, Eye, EyeOff } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useToast } from "@/hooks/useToast.wrapper"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; -import React, { useState } from 'react'; -import { Key, Eye, EyeOff } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; -import { useForm } from 'react-hook-form'; -import { z } from 'zod'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { useToast } from '@/hooks/useToast.wrapper'; -import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'; - -const passwordFormSchema = z.object({ - currentPassword: z.string().min(6, { - message: '비밀번호는 6자 이상이어야 합니다.', - }), - newPassword: z.string().min(8, { - message: '새 비밀번호는 8자 이상이어야 합니다.', - }), - confirmPassword: z.string().min(8, { - message: '비밀번호 확인은 8자 이상이어야 합니다.', - }), -}).refine((data) => data.newPassword === data.confirmPassword, { - message: "비밀번호가 일치하지 않습니다.", - path: ["confirmPassword"], -}); +const passwordFormSchema = z + .object({ + currentPassword: z.string().min(6, { + message: "비밀번호는 6자 이상이어야 합니다.", + }), + newPassword: z.string().min(8, { + message: "새 비밀번호는 8자 이상이어야 합니다.", + }), + confirmPassword: z.string().min(8, { + message: "비밀번호 확인은 8자 이상이어야 합니다.", + }), + }) + .refine((data) => data.newPassword === data.confirmPassword, { + message: "비밀번호가 일치하지 않습니다.", + path: ["confirmPassword"], + }); type PasswordFormValues = z.infer; @@ -36,14 +55,14 @@ const PasswordChangeForm = () => { const passwordForm = useForm({ resolver: zodResolver(passwordFormSchema), defaultValues: { - currentPassword: '', - newPassword: '', - confirmPassword: '', + currentPassword: "", + newPassword: "", + confirmPassword: "", }, }); const onPasswordSubmit = (data: PasswordFormValues) => { - console.log('Password form submitted:', data); + logger.info("Password form submitted:", data); // Here you would typically update the password toast({ title: "비밀번호 변경 완료", @@ -56,7 +75,10 @@ const PasswordChangeForm = () => {

비밀번호 변경

- + { 현재 비밀번호
-
@@ -85,7 +113,7 @@ const PasswordChangeForm = () => { )} /> - + { 새 비밀번호
-
@@ -114,7 +146,7 @@ const PasswordChangeForm = () => { )} /> - + { 비밀번호 확인
-
@@ -143,11 +181,11 @@ const PasswordChangeForm = () => { )} /> - + -

프로필 관리

- + {/* User Profile Picture */}
diff --git a/src/components/recent-transactions/RecentTransactionItem.tsx b/src/components/recent-transactions/RecentTransactionItem.tsx index 5ebf157..eca18aa 100644 --- a/src/components/recent-transactions/RecentTransactionItem.tsx +++ b/src/components/recent-transactions/RecentTransactionItem.tsx @@ -1,17 +1,16 @@ - -import React from 'react'; -import { Transaction } from '@/contexts/budget/types'; -import TransactionIcon from '../transaction/TransactionIcon'; -import { formatCurrency } from '@/utils/currencyFormatter'; +import React from "react"; +import { Transaction } from "@/contexts/budget/types"; +import TransactionIcon from "../transaction/TransactionIcon"; +import { formatCurrency } from "@/utils/currencyFormatter"; interface RecentTransactionItemProps { transaction: Transaction; onClick: () => void; } -const RecentTransactionItem: React.FC = ({ - transaction, - onClick +const RecentTransactionItem: React.FC = ({ + transaction, + onClick, }) => { return (
= ({ onConfirm, isResetting, isLoggedIn, - syncEnabled + syncEnabled, }) => { - return + return ( + 정말 모든 데이터를 초기화하시겠습니까? - {isLoggedIn ? <> - 이 작업은 되돌릴 수 없으며, 로컬 및 클라우드에 저장된 모든 예산, 지출 내역이 영구적으로 삭제됩니다. + {isLoggedIn ? ( + <> + 이 작업은 되돌릴 수 없으며, 로컬 및 클라우드에 저장된 모든 예산, + 지출 내역이 영구적으로 삭제됩니다.
- + 클라우드 데이터도 함께 삭제됩니다.
{syncEnabled && ( @@ -37,7 +47,10 @@ const DataResetDialog: React.FC = ({ ※ 동기화 설정이 비활성화됩니다.
)} - : "이 작업은 되돌릴 수 없으며, 모든 예산, 지출 내역, 설정이 영구적으로 삭제됩니다."} + + ) : ( + "이 작업은 되돌릴 수 없으며, 모든 예산, 지출 내역, 설정이 영구적으로 삭제됩니다." + )}
단, '환영합니다' 화면 표시 설정과 로그인 상태는 유지됩니다.
@@ -45,17 +58,34 @@ const DataResetDialog: React.FC = ({ - + - -
; + + ); }; export default DataResetDialog; diff --git a/src/components/security/DataResetSection.tsx b/src/components/security/DataResetSection.tsx index 58e2600..a49ecfa 100644 --- a/src/components/security/DataResetSection.tsx +++ b/src/components/security/DataResetSection.tsx @@ -1,12 +1,11 @@ - -import React, { useState } from 'react'; -import { Trash2 } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { useAuth } from '@/contexts/auth'; -import { useDataReset } from '@/hooks/useDataReset'; -import DataResetDialog from './DataResetDialog'; -import { isSyncEnabled } from '@/utils/sync/syncSettings'; -import { toast } from '@/hooks/useToast.wrapper'; +import React, { useState } from "react"; +import { Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useAuth } from "@/contexts/auth"; +import { useDataReset } from "@/hooks/useDataReset"; +import DataResetDialog from "./DataResetDialog"; +import { isSyncEnabled } from "@/utils/sync/syncSettings"; +import { toast } from "@/hooks/useToast.wrapper"; const DataResetSection = () => { const [isResetDialogOpen, setIsResetDialogOpen] = useState(false); @@ -17,7 +16,7 @@ const DataResetSection = () => { const handleResetAllData = async () => { await resetAllData(); setIsResetDialogOpen(false); - + // 데이터 초기화 후 애플리케이션 리로드 // toast 알림은 useDataReset.ts에서 처리하므로 여기서는 제거 }; @@ -32,14 +31,14 @@ const DataResetSection = () => {

데이터 초기화

- {user + {user ? "로컬 및 클라우드의 모든 예산, 지출 내역이 초기화됩니다. 동기화 설정은 비활성화됩니다." : "모든 예산, 지출 내역, 설정이 초기화됩니다."}

- ) : (
- 로그인이 필요합니다 - -
diff --git a/src/components/transaction/TransactionFormFields.tsx b/src/components/transaction/TransactionFormFields.tsx index 95848db..fc0ee7d 100644 --- a/src/components/transaction/TransactionFormFields.tsx +++ b/src/components/transaction/TransactionFormFields.tsx @@ -1,41 +1,45 @@ - -import React, { useState, useEffect } from 'react'; -import { UseFormReturn } from 'react-hook-form'; -import { z } from 'zod'; -import TransactionCategorySelector from './TransactionCategorySelector'; -import TransactionTitleSuggestions from './TransactionTitleSuggestions'; -import TransactionTitleInput from './TransactionTitleInput'; -import TransactionAmountInput from './TransactionAmountInput'; -import TransactionPaymentMethod from './TransactionPaymentMethod'; +import React, { useState, useEffect } from "react"; +import { UseFormReturn } from "react-hook-form"; +import { z } from "zod"; +import { PaymentMethod } from "@/types"; +import TransactionCategorySelector from "./TransactionCategorySelector"; +import TransactionTitleSuggestions from "./TransactionTitleSuggestions"; +import TransactionTitleInput from "./TransactionTitleInput"; +import TransactionAmountInput from "./TransactionAmountInput"; +import TransactionPaymentMethod from "./TransactionPaymentMethod"; // Form schema for validation export const transactionFormSchema = z.object({ - title: z.string().min(1, '제목을 입력해주세요'), - amount: z.string().min(1, '금액을 입력해주세요'), - category: z.enum(['음식', '쇼핑', '교통', '기타']), - paymentMethod: z.enum(['신용카드', '현금']).default('신용카드'), + title: z.string().min(1, "제목을 입력해주세요"), + amount: z.string().min(1, "금액을 입력해주세요"), + category: z.enum(["음식", "쇼핑", "교통", "기타"]), + paymentMethod: z + .enum(["신용카드", "현금", "체크카드", "간편결제"] as const) + .default("신용카드"), }); export type TransactionFormValues = z.infer; // Function to format number with commas export const formatWithCommas = (value: string) => { - const numericValue = value.replace(/[^0-9]/g, ''); - return numericValue.replace(/\B(?=(\d{3})+(?!\d))/g, ','); + const numericValue = value.replace(/[^0-9]/g, ""); + return numericValue.replace(/\B(?=(\d{3})+(?!\d))/g, ","); }; interface TransactionFormFieldsProps { form: UseFormReturn; } -const TransactionFormFields: React.FC = ({ form }) => { +const TransactionFormFields: React.FC = ({ + form, +}) => { // 상태 관리를 추가합니다 const [showTitleSuggestions, setShowTitleSuggestions] = useState(false); const [showPaymentMethod, setShowPaymentMethod] = useState(false); - + // 현재 선택된 카테고리 가져오기 - const selectedCategory = form.watch('category'); - + const selectedCategory = form.watch("category"); + // 카테고리가 변경될 때마다 제목 추천 표시 useEffect(() => { if (selectedCategory) { @@ -52,26 +56,26 @@ const TransactionFormFields: React.FC = ({ form }) = <> {/* 카테고리 필드를 첫 번째로 배치 */} - + {/* 카테고리별 제목 제안 - 카테고리 선택 후에만 표시 */} - - + {/* 제목 필드를 두 번째로 배치 */} - + {/* 금액 필드를 세 번째로 배치 */} - setShowPaymentMethod(true)} + setShowPaymentMethod(true)} /> {/* 지출 방법 필드는 금액 입력 시에만 표시 */} - ); diff --git a/src/components/transaction/TransactionIcon.tsx b/src/components/transaction/TransactionIcon.tsx index 65144ca..9d309ac 100644 --- a/src/components/transaction/TransactionIcon.tsx +++ b/src/components/transaction/TransactionIcon.tsx @@ -1,7 +1,6 @@ - -import React from 'react'; -import { Coffee, Package } from 'lucide-react'; -import { categoryIcons } from '@/constants/categoryIcons'; +import React from "react"; +import { Coffee, Package } from "lucide-react"; +import { categoryIcons } from "@/constants/categoryIcons"; interface TransactionIconProps { category: string; @@ -10,7 +9,7 @@ interface TransactionIconProps { const TransactionIcon: React.FC = ({ category }) => { // 카테고리에 해당하는 아이콘이 없을 경우 기본값으로 Package 아이콘 사용 const icon = categoryIcons[category] || ; - + return (
{icon} diff --git a/src/components/transaction/TransactionPaymentMethod.tsx b/src/components/transaction/TransactionPaymentMethod.tsx index f5b1423..5803385 100644 --- a/src/components/transaction/TransactionPaymentMethod.tsx +++ b/src/components/transaction/TransactionPaymentMethod.tsx @@ -1,30 +1,35 @@ - -import React from 'react'; -import { FormField, FormItem, FormLabel, FormControl, FormMessage } from '@/components/ui/form'; -import { UseFormReturn } from 'react-hook-form'; -import { TransactionFormValues } from './TransactionFormFields'; -import { Separator } from '@/components/ui/separator'; -import { CreditCard, Banknote } from 'lucide-react'; +import React from "react"; +import { + FormField, + FormItem, + FormLabel, + FormControl, + FormMessage, +} from "@/components/ui/form"; +import { UseFormReturn } from "react-hook-form"; +import { TransactionFormValues } from "./TransactionFormFields"; +import { Separator } from "@/components/ui/separator"; +import { CreditCard, Banknote } from "lucide-react"; interface TransactionPaymentMethodProps { form: UseFormReturn; showPaymentMethod: boolean; } -const TransactionPaymentMethod: React.FC = ({ - form, - showPaymentMethod +const TransactionPaymentMethod: React.FC = ({ + form, + showPaymentMethod, }) => { return ( -
- + = ({
form.setValue('paymentMethod', '신용카드')} + onClick={() => form.setValue("paymentMethod", "신용카드")} > 신용카드
form.setValue('paymentMethod', '현금')} + onClick={() => form.setValue("paymentMethod", "현금")} > 현금 diff --git a/src/components/transaction/TransactionTitleInput.tsx b/src/components/transaction/TransactionTitleInput.tsx index 886aa96..7d9f2d2 100644 --- a/src/components/transaction/TransactionTitleInput.tsx +++ b/src/components/transaction/TransactionTitleInput.tsx @@ -1,15 +1,22 @@ - -import React from 'react'; -import { FormField, FormItem, FormLabel, FormControl, FormMessage } from '@/components/ui/form'; -import { Input } from '@/components/ui/input'; -import { UseFormReturn } from 'react-hook-form'; -import { TransactionFormValues } from './TransactionFormFields'; +import React from "react"; +import { + FormField, + FormItem, + FormLabel, + FormControl, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { UseFormReturn } from "react-hook-form"; +import { TransactionFormValues } from "./TransactionFormFields"; interface TransactionTitleInputProps { form: UseFormReturn; } -const TransactionTitleInput: React.FC = ({ form }) => { +const TransactionTitleInput: React.FC = ({ + form, +}) => { return ( ; showTitleSuggestions: boolean; } -const TransactionTitleSuggestions: React.FC = ({ - form, - showTitleSuggestions -}) => { +const TransactionTitleSuggestions: React.FC< + TransactionTitleSuggestionsProps +> = ({ form, showTitleSuggestions }) => { // 현재 선택된 카테고리 가져오기 - const selectedCategory = form.watch('category'); - + const selectedCategory = form.watch("category"); + // 선택된 카테고리에 대한 개인화된 제목 제안 목록 상태 const [titleSuggestions, setTitleSuggestions] = useState([]); - + // 카테고리가 변경될 때마다 개인화된 제목 목록 업데이트 useEffect(() => { if (selectedCategory) { @@ -30,24 +28,24 @@ const TransactionTitleSuggestions: React.FC = // 제안된 제목 클릭 시 제목 필드에 설정 const handleTitleSuggestionClick = (suggestion: string) => { - form.setValue('title', suggestion); + form.setValue("title", suggestion); }; - + if (!selectedCategory || titleSuggestions.length === 0) { return null; } - + return ( -
{titleSuggestions.map((suggestion) => ( - { - if (oldCategory === '식비') return '음식'; - if (oldCategory === '생활비') return '쇼핑'; - if (oldCategory === '교통비') return '교통'; - if (EXPENSE_CATEGORIES.includes(oldCategory as any)) return oldCategory as "음식" | "쇼핑" | "교통" | "기타"; +export const mapCategoryToNew = ( + oldCategory: string +): "음식" | "쇼핑" | "교통" | "기타" => { + if (oldCategory === "식비") { + return "음식"; + } + if (oldCategory === "생활비") { + return "쇼핑"; + } + if (oldCategory === "교통비") { + return "교통"; + } + if (EXPENSE_CATEGORIES.includes(oldCategory as any)) { + return oldCategory as "음식" | "쇼핑" | "교통" | "기타"; + } // 기본값은 '기타'로 설정 - return '기타'; + return "기타"; }; diff --git a/src/components/transaction/useTransactionEdit.ts b/src/components/transaction/useTransactionEdit.ts index 2d42233..49d0d79 100644 --- a/src/components/transaction/useTransactionEdit.ts +++ b/src/components/transaction/useTransactionEdit.ts @@ -1,12 +1,12 @@ - -import { useState } from 'react'; -import { useForm } from 'react-hook-form'; -import { Transaction } from '@/contexts/budget/types'; -import { useBudget } from '@/contexts/budget/BudgetContext'; -import { toast } from '@/hooks/useToast.wrapper'; -import { manageTitleSuggestions } from '@/utils/userTitlePreferences'; -import { TransactionFormValues } from './TransactionFormFields'; -import { EXPENSE_CATEGORIES } from '@/constants/categoryIcons'; +import { useState } from "react"; +import { logger } from "@/utils/logger"; +import { useForm } from "react-hook-form"; +import { Transaction } from "@/contexts/budget/types"; +import { useBudget } from "@/contexts/budget/BudgetContext"; +import { toast } from "@/hooks/useToast.wrapper"; +import { manageTitleSuggestions } from "@/utils/userTitlePreferences"; +import { TransactionFormValues } from "./TransactionFormFields"; +import { EXPENSE_CATEGORIES } from "@/constants/categoryIcons"; export const useTransactionEdit = ( transaction: Transaction, @@ -21,57 +21,59 @@ export const useTransactionEdit = ( defaultValues: { title: transaction.title, amount: transaction.amount.toString(), - category: EXPENSE_CATEGORIES.includes(transaction.category) - ? (transaction.category as "음식" | "쇼핑" | "교통" | "기타") + category: EXPENSE_CATEGORIES.includes(transaction.category) + ? (transaction.category as "음식" | "쇼핑" | "교통" | "기타") : "기타", - paymentMethod: transaction.paymentMethod || '신용카드' - } + paymentMethod: transaction.paymentMethod || "신용카드", + }, }); // 트랜잭션 업데이트 처리 const handleSubmit = (values: TransactionFormValues) => { try { setIsSubmitting(true); - + // 폼 값에서 숫자 값 추출 (콤마 제거) - const numericAmount = values.amount.replace(/,/g, ''); - + const numericAmount = values.amount.replace(/,/g, ""); + // 업데이트된 트랜잭션 객체 생성 const updatedTransaction: Transaction = { ...transaction, title: values.title, amount: parseInt(numericAmount), category: values.category, - paymentMethod: values.paymentMethod + paymentMethod: values.paymentMethod, }; - + // 트랜잭션 업데이트 updateTransaction(updatedTransaction); - + // 지출일 경우 제목 관리 로직 실행 - if (updatedTransaction.type === 'expense') { + if (updatedTransaction.type === "expense") { manageTitleSuggestions(updatedTransaction); } - + // 성공 메시지 표시 toast({ title: "거래 내역이 업데이트되었습니다", description: `${updatedTransaction.title} 항목이 수정되었습니다.`, }); - + // 이벤트 발생 처리 - window.dispatchEvent(new CustomEvent('transactionChanged', { - detail: { type: 'update', transaction: updatedTransaction } - })); - + window.dispatchEvent( + new CustomEvent("transactionChanged", { + detail: { type: "update", transaction: updatedTransaction }, + }) + ); + // 다이얼로그 닫기 onClose(); } catch (error) { - console.error('거래 내역 업데이트 중 오류 발생:', error); + logger.error("거래 내역 업데이트 중 오류 발생:", error); toast({ title: "거래 내역 업데이트 실패", description: "내역을 업데이트하는 도중 오류가 발생했습니다.", - variant: "destructive" + variant: "destructive", }); } finally { setIsSubmitting(false); @@ -82,30 +84,32 @@ export const useTransactionEdit = ( const handleDelete = async (): Promise => { try { setIsSubmitting(true); - + // 트랜잭션 삭제 deleteTransaction(transaction.id); - + // 성공 메시지 표시 toast({ title: "거래 내역이 삭제되었습니다", description: `${transaction.title} 항목이 삭제되었습니다.`, }); - + // 이벤트 발생 처리 - window.dispatchEvent(new CustomEvent('transactionChanged', { - detail: { type: 'delete', transaction } - })); - + window.dispatchEvent( + new CustomEvent("transactionChanged", { + detail: { type: "delete", transaction }, + }) + ); + // 다이얼로그 닫기 onClose(); return true; } catch (error) { - console.error('거래 내역 삭제 중 오류 발생:', error); + logger.error("거래 내역 삭제 중 오류 발생:", error); toast({ title: "거래 내역 삭제 실패", description: "내역을 삭제하는 도중 오류가 발생했습니다.", - variant: "destructive" + variant: "destructive", }); return false; } finally { @@ -117,6 +121,6 @@ export const useTransactionEdit = ( form, isSubmitting, handleSubmit, - handleDelete + handleDelete, }; }; diff --git a/src/components/transactions/EmptyTransactions.tsx b/src/components/transactions/EmptyTransactions.tsx index eca4186..abca5b5 100644 --- a/src/components/transactions/EmptyTransactions.tsx +++ b/src/components/transactions/EmptyTransactions.tsx @@ -1,5 +1,4 @@ - -import React from 'react'; +import React from "react"; interface EmptyTransactionsProps { searchQuery: string; @@ -12,19 +11,19 @@ const EmptyTransactions: React.FC = ({ searchQuery, selectedMonth, setSearchQuery, - isDisabled + isDisabled, }) => { return (

- {searchQuery.trim() - ? '검색 결과가 없습니다.' + {searchQuery.trim() + ? "검색 결과가 없습니다." : `${selectedMonth}에 등록된 지출이 없습니다.`}

{searchQuery.trim() && ( - - +
{displayMonth}
- -
- + {/* Summary */}
diff --git a/src/components/transactions/TransactionsList.tsx b/src/components/transactions/TransactionsList.tsx index 1e9c09c..69ddcb6 100644 --- a/src/components/transactions/TransactionsList.tsx +++ b/src/components/transactions/TransactionsList.tsx @@ -1,7 +1,6 @@ - -import React from 'react'; -import TransactionCard, { Transaction } from '@/components/TransactionCard'; -import TransactionDateGroup from './TransactionDateGroup'; +import React from "react"; +import TransactionCard, { Transaction } from "@/components/TransactionCard"; +import TransactionDateGroup from "./TransactionDateGroup"; interface TransactionsListProps { groupedTransactions: Record; @@ -10,14 +9,14 @@ interface TransactionsListProps { const TransactionsList: React.FC = ({ groupedTransactions, - onTransactionDelete + onTransactionDelete, }) => { return (
{Object.entries(groupedTransactions).map(([date, dateTransactions]) => ( - diff --git a/src/components/ui/accordion.tsx b/src/components/ui/accordion.tsx index e6a723d..2188736 100644 --- a/src/components/ui/accordion.tsx +++ b/src/components/ui/accordion.tsx @@ -1,10 +1,10 @@ -import * as React from "react" -import * as AccordionPrimitive from "@radix-ui/react-accordion" -import { ChevronDown } from "lucide-react" +import * as React from "react"; +import * as AccordionPrimitive from "@radix-ui/react-accordion"; +import { ChevronDown } from "lucide-react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; -const Accordion = AccordionPrimitive.Root +const Accordion = AccordionPrimitive.Root; const AccordionItem = React.forwardRef< React.ElementRef, @@ -15,8 +15,8 @@ const AccordionItem = React.forwardRef< className={cn("border-b", className)} {...props} /> -)) -AccordionItem.displayName = "AccordionItem" +)); +AccordionItem.displayName = "AccordionItem"; const AccordionTrigger = React.forwardRef< React.ElementRef, @@ -35,8 +35,8 @@ const AccordionTrigger = React.forwardRef< -)) -AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName +)); +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; const AccordionContent = React.forwardRef< React.ElementRef, @@ -49,8 +49,8 @@ const AccordionContent = React.forwardRef< >
{children}
-)) +)); -AccordionContent.displayName = AccordionPrimitive.Content.displayName +AccordionContent.displayName = AccordionPrimitive.Content.displayName; -export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx index 92298f0..143bc53 100644 --- a/src/components/ui/alert-dialog.tsx +++ b/src/components/ui/alert-dialog.tsx @@ -1,15 +1,14 @@ +import * as React from "react"; +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; -import * as React from "react" -import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" +import { cn } from "@/lib/utils"; +import { buttonVariants } from "@/components/ui/button"; -import { cn } from "@/lib/utils" -import { buttonVariants } from "@/components/ui/button" +const AlertDialog = AlertDialogPrimitive.Root; -const AlertDialog = AlertDialogPrimitive.Root +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; -const AlertDialogTrigger = AlertDialogPrimitive.Trigger - -const AlertDialogPortal = AlertDialogPrimitive.Portal +const AlertDialogPortal = AlertDialogPrimitive.Portal; const AlertDialogOverlay = React.forwardRef< React.ElementRef, @@ -23,8 +22,8 @@ const AlertDialogOverlay = React.forwardRef< {...props} ref={ref} /> -)) -AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName +)); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; const AlertDialogContent = React.forwardRef< React.ElementRef, @@ -41,8 +40,8 @@ const AlertDialogContent = React.forwardRef< {...props} /> -)) -AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName +)); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; const AlertDialogHeader = ({ className, @@ -55,8 +54,8 @@ const AlertDialogHeader = ({ )} {...props} /> -) -AlertDialogHeader.displayName = "AlertDialogHeader" +); +AlertDialogHeader.displayName = "AlertDialogHeader"; const AlertDialogFooter = ({ className, @@ -69,8 +68,8 @@ const AlertDialogFooter = ({ )} {...props} /> -) -AlertDialogFooter.displayName = "AlertDialogFooter" +); +AlertDialogFooter.displayName = "AlertDialogFooter"; const AlertDialogTitle = React.forwardRef< React.ElementRef, @@ -81,8 +80,8 @@ const AlertDialogTitle = React.forwardRef< className={cn("text-lg font-semibold", className)} {...props} /> -)) -AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName +)); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; const AlertDialogDescription = React.forwardRef< React.ElementRef, @@ -93,9 +92,9 @@ const AlertDialogDescription = React.forwardRef< className={cn("text-sm text-muted-foreground", className)} {...props} /> -)) +)); AlertDialogDescription.displayName = - AlertDialogPrimitive.Description.displayName + AlertDialogPrimitive.Description.displayName; const AlertDialogAction = React.forwardRef< React.ElementRef, @@ -106,8 +105,8 @@ const AlertDialogAction = React.forwardRef< className={cn(buttonVariants(), className)} {...props} /> -)) -AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName +)); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; const AlertDialogCancel = React.forwardRef< React.ElementRef, @@ -122,8 +121,8 @@ const AlertDialogCancel = React.forwardRef< )} {...props} /> -)) -AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName +)); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; export { AlertDialog, @@ -137,4 +136,4 @@ export { AlertDialogDescription, AlertDialogAction, AlertDialogCancel, -} +}; diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx index 41fa7e0..29bd44f 100644 --- a/src/components/ui/alert.tsx +++ b/src/components/ui/alert.tsx @@ -1,7 +1,7 @@ -import * as React from "react" -import { cva, type VariantProps } from "class-variance-authority" +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; const alertVariants = cva( "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", @@ -17,7 +17,7 @@ const alertVariants = cva( variant: "default", }, } -) +); const Alert = React.forwardRef< HTMLDivElement, @@ -29,8 +29,8 @@ const Alert = React.forwardRef< className={cn(alertVariants({ variant }), className)} {...props} /> -)) -Alert.displayName = "Alert" +)); +Alert.displayName = "Alert"; const AlertTitle = React.forwardRef< HTMLParagraphElement, @@ -41,8 +41,8 @@ const AlertTitle = React.forwardRef< className={cn("mb-1 font-medium leading-none tracking-tight", className)} {...props} /> -)) -AlertTitle.displayName = "AlertTitle" +)); +AlertTitle.displayName = "AlertTitle"; const AlertDescription = React.forwardRef< HTMLParagraphElement, @@ -53,7 +53,7 @@ const AlertDescription = React.forwardRef< className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} /> -)) -AlertDescription.displayName = "AlertDescription" +)); +AlertDescription.displayName = "AlertDescription"; -export { Alert, AlertTitle, AlertDescription } +export { Alert, AlertTitle, AlertDescription }; diff --git a/src/components/ui/aspect-ratio.tsx b/src/components/ui/aspect-ratio.tsx index c4abbf3..c9e6f4b 100644 --- a/src/components/ui/aspect-ratio.tsx +++ b/src/components/ui/aspect-ratio.tsx @@ -1,5 +1,5 @@ -import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"; -const AspectRatio = AspectRatioPrimitive.Root +const AspectRatio = AspectRatioPrimitive.Root; -export { AspectRatio } +export { AspectRatio }; diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx index 991f56e..fda1c3d 100644 --- a/src/components/ui/avatar.tsx +++ b/src/components/ui/avatar.tsx @@ -1,7 +1,7 @@ -import * as React from "react" -import * as AvatarPrimitive from "@radix-ui/react-avatar" +import * as React from "react"; +import * as AvatarPrimitive from "@radix-ui/react-avatar"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; const Avatar = React.forwardRef< React.ElementRef, @@ -15,8 +15,8 @@ const Avatar = React.forwardRef< )} {...props} /> -)) -Avatar.displayName = AvatarPrimitive.Root.displayName +)); +Avatar.displayName = AvatarPrimitive.Root.displayName; const AvatarImage = React.forwardRef< React.ElementRef, @@ -27,8 +27,8 @@ const AvatarImage = React.forwardRef< className={cn("aspect-square h-full w-full", className)} {...props} /> -)) -AvatarImage.displayName = AvatarPrimitive.Image.displayName +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; const AvatarFallback = React.forwardRef< React.ElementRef, @@ -42,7 +42,7 @@ const AvatarFallback = React.forwardRef< )} {...props} /> -)) -AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName +)); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; -export { Avatar, AvatarImage, AvatarFallback } +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx index f000e3e..9ec9a1a 100644 --- a/src/components/ui/badge.tsx +++ b/src/components/ui/badge.tsx @@ -1,7 +1,7 @@ -import * as React from "react" -import { cva, type VariantProps } from "class-variance-authority" +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; -import { cn } from "@/lib/utils" +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", @@ -21,7 +21,7 @@ const badgeVariants = cva( variant: "default", }, } -) +); export interface BadgeProps extends React.HTMLAttributes, @@ -30,7 +30,7 @@ export interface BadgeProps function Badge({ className, variant, ...props }: BadgeProps) { return (
- ) + ); } -export { Badge, badgeVariants } +export { Badge, badgeVariants }; diff --git a/src/components/ui/breadcrumb.tsx b/src/components/ui/breadcrumb.tsx index 71a5c32..fad4115 100644 --- a/src/components/ui/breadcrumb.tsx +++ b/src/components/ui/breadcrumb.tsx @@ -1,16 +1,16 @@ -import * as React from "react" -import { Slot } from "@radix-ui/react-slot" -import { ChevronRight, MoreHorizontal } from "lucide-react" +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { ChevronRight, MoreHorizontal } from "lucide-react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; const Breadcrumb = React.forwardRef< HTMLElement, React.ComponentPropsWithoutRef<"nav"> & { - separator?: React.ReactNode + separator?: React.ReactNode; } ->(({ ...props }, ref) =>
-)) +)); -CommandInput.displayName = CommandPrimitive.Input.displayName +CommandInput.displayName = CommandPrimitive.Input.displayName; const CommandList = React.forwardRef< React.ElementRef, @@ -63,9 +63,9 @@ const CommandList = React.forwardRef< className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)} {...props} /> -)) +)); -CommandList.displayName = CommandPrimitive.List.displayName +CommandList.displayName = CommandPrimitive.List.displayName; const CommandEmpty = React.forwardRef< React.ElementRef, @@ -76,9 +76,9 @@ const CommandEmpty = React.forwardRef< className="py-6 text-center text-sm" {...props} /> -)) +)); -CommandEmpty.displayName = CommandPrimitive.Empty.displayName +CommandEmpty.displayName = CommandPrimitive.Empty.displayName; const CommandGroup = React.forwardRef< React.ElementRef, @@ -92,9 +92,9 @@ const CommandGroup = React.forwardRef< )} {...props} /> -)) +)); -CommandGroup.displayName = CommandPrimitive.Group.displayName +CommandGroup.displayName = CommandPrimitive.Group.displayName; const CommandSeparator = React.forwardRef< React.ElementRef, @@ -105,8 +105,8 @@ const CommandSeparator = React.forwardRef< className={cn("-mx-1 h-px bg-border", className)} {...props} /> -)) -CommandSeparator.displayName = CommandPrimitive.Separator.displayName +)); +CommandSeparator.displayName = CommandPrimitive.Separator.displayName; const CommandItem = React.forwardRef< React.ElementRef, @@ -120,9 +120,9 @@ const CommandItem = React.forwardRef< )} {...props} /> -)) +)); -CommandItem.displayName = CommandPrimitive.Item.displayName +CommandItem.displayName = CommandPrimitive.Item.displayName; const CommandShortcut = ({ className, @@ -136,9 +136,9 @@ const CommandShortcut = ({ )} {...props} /> - ) -} -CommandShortcut.displayName = "CommandShortcut" + ); +}; +CommandShortcut.displayName = "CommandShortcut"; export { Command, @@ -150,4 +150,4 @@ export { CommandItem, CommandShortcut, CommandSeparator, -} +}; diff --git a/src/components/ui/context-menu.tsx b/src/components/ui/context-menu.tsx index 3e52999..ce828dd 100644 --- a/src/components/ui/context-menu.tsx +++ b/src/components/ui/context-menu.tsx @@ -1,25 +1,25 @@ -import * as React from "react" -import * as ContextMenuPrimitive from "@radix-ui/react-context-menu" -import { Check, ChevronRight, Circle } from "lucide-react" +import * as React from "react"; +import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"; +import { Check, ChevronRight, Circle } from "lucide-react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; -const ContextMenu = ContextMenuPrimitive.Root +const ContextMenu = ContextMenuPrimitive.Root; -const ContextMenuTrigger = ContextMenuPrimitive.Trigger +const ContextMenuTrigger = ContextMenuPrimitive.Trigger; -const ContextMenuGroup = ContextMenuPrimitive.Group +const ContextMenuGroup = ContextMenuPrimitive.Group; -const ContextMenuPortal = ContextMenuPrimitive.Portal +const ContextMenuPortal = ContextMenuPrimitive.Portal; -const ContextMenuSub = ContextMenuPrimitive.Sub +const ContextMenuSub = ContextMenuPrimitive.Sub; -const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup +const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup; const ContextMenuSubTrigger = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { - inset?: boolean + inset?: boolean; } >(({ className, inset, children, ...props }, ref) => ( -)) -ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName +)); +ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName; const ContextMenuSubContent = React.forwardRef< React.ElementRef, @@ -49,8 +49,8 @@ const ContextMenuSubContent = React.forwardRef< )} {...props} /> -)) -ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName +)); +ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName; const ContextMenuContent = React.forwardRef< React.ElementRef, @@ -66,13 +66,13 @@ const ContextMenuContent = React.forwardRef< {...props} /> -)) -ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName +)); +ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName; const ContextMenuItem = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { - inset?: boolean + inset?: boolean; } >(({ className, inset, ...props }, ref) => ( -)) -ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName +)); +ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName; const ContextMenuCheckboxItem = React.forwardRef< React.ElementRef, @@ -107,9 +107,9 @@ const ContextMenuCheckboxItem = React.forwardRef< {children} -)) +)); ContextMenuCheckboxItem.displayName = - ContextMenuPrimitive.CheckboxItem.displayName + ContextMenuPrimitive.CheckboxItem.displayName; const ContextMenuRadioItem = React.forwardRef< React.ElementRef, @@ -130,13 +130,13 @@ const ContextMenuRadioItem = React.forwardRef< {children} -)) -ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName +)); +ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName; const ContextMenuLabel = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { - inset?: boolean + inset?: boolean; } >(({ className, inset, ...props }, ref) => ( -)) -ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName +)); +ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName; const ContextMenuSeparator = React.forwardRef< React.ElementRef, @@ -160,8 +160,8 @@ const ContextMenuSeparator = React.forwardRef< className={cn("-mx-1 my-1 h-px bg-border", className)} {...props} /> -)) -ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName +)); +ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName; const ContextMenuShortcut = ({ className, @@ -175,9 +175,9 @@ const ContextMenuShortcut = ({ )} {...props} /> - ) -} -ContextMenuShortcut.displayName = "ContextMenuShortcut" + ); +}; +ContextMenuShortcut.displayName = "ContextMenuShortcut"; export { ContextMenu, @@ -195,4 +195,4 @@ export { ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuRadioGroup, -} +}; diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index b95111b..9de1f72 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -1,17 +1,16 @@ +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; -import * as React from "react" -import * as DialogPrimitive from "@radix-ui/react-dialog" -import { X } from "lucide-react" +import { cn } from "@/lib/utils"; -import { cn } from "@/lib/utils" +const Dialog = DialogPrimitive.Root; -const Dialog = DialogPrimitive.Root +const DialogTrigger = DialogPrimitive.Trigger; -const DialogTrigger = DialogPrimitive.Trigger +const DialogPortal = DialogPrimitive.Portal; -const DialogPortal = DialogPrimitive.Portal - -const DialogClose = DialogPrimitive.Close +const DialogClose = DialogPrimitive.Close; const DialogOverlay = React.forwardRef< React.ElementRef, @@ -25,8 +24,8 @@ const DialogOverlay = React.forwardRef< )} {...props} /> -)) -DialogOverlay.displayName = DialogPrimitive.Overlay.displayName +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; const DialogContent = React.forwardRef< React.ElementRef, @@ -49,8 +48,8 @@ const DialogContent = React.forwardRef< -)) -DialogContent.displayName = DialogPrimitive.Content.displayName +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; const DialogHeader = ({ className, @@ -63,8 +62,8 @@ const DialogHeader = ({ )} {...props} /> -) -DialogHeader.displayName = "DialogHeader" +); +DialogHeader.displayName = "DialogHeader"; const DialogFooter = ({ className, @@ -77,8 +76,8 @@ const DialogFooter = ({ )} {...props} /> -) -DialogFooter.displayName = "DialogFooter" +); +DialogFooter.displayName = "DialogFooter"; const DialogTitle = React.forwardRef< React.ElementRef, @@ -92,8 +91,8 @@ const DialogTitle = React.forwardRef< )} {...props} /> -)) -DialogTitle.displayName = DialogPrimitive.Title.displayName +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; const DialogDescription = React.forwardRef< React.ElementRef, @@ -104,8 +103,8 @@ const DialogDescription = React.forwardRef< className={cn("text-sm text-muted-foreground", className)} {...props} /> -)) -DialogDescription.displayName = DialogPrimitive.Description.displayName +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; export { Dialog, @@ -118,4 +117,4 @@ export { DialogFooter, DialogTitle, DialogDescription, -} +}; diff --git a/src/components/ui/drawer.tsx b/src/components/ui/drawer.tsx index 8d94790..a8cd174 100644 --- a/src/components/ui/drawer.tsx +++ b/src/components/ui/drawer.tsx @@ -1,8 +1,7 @@ +import * as React from "react"; +import { Drawer as DrawerPrimitive } from "vaul"; -import * as React from "react" -import { Drawer as DrawerPrimitive } from "vaul" - -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; const Drawer = ({ shouldScaleBackground = true, @@ -12,14 +11,14 @@ const Drawer = ({ shouldScaleBackground={shouldScaleBackground} {...props} /> -) -Drawer.displayName = "Drawer" +); +Drawer.displayName = "Drawer"; -const DrawerTrigger = DrawerPrimitive.Trigger +const DrawerTrigger = DrawerPrimitive.Trigger; -const DrawerPortal = DrawerPrimitive.Portal +const DrawerPortal = DrawerPrimitive.Portal; -const DrawerClose = DrawerPrimitive.Close +const DrawerClose = DrawerPrimitive.Close; const DrawerOverlay = React.forwardRef< React.ElementRef, @@ -30,8 +29,8 @@ const DrawerOverlay = React.forwardRef< className={cn("fixed inset-0 z-50 bg-black/80", className)} {...props} /> -)) -DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName +)); +DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName; const DrawerContent = React.forwardRef< React.ElementRef, @@ -51,8 +50,8 @@ const DrawerContent = React.forwardRef< {children} -)) -DrawerContent.displayName = "DrawerContent" +)); +DrawerContent.displayName = "DrawerContent"; const DrawerHeader = ({ className, @@ -62,8 +61,8 @@ const DrawerHeader = ({ className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)} {...props} /> -) -DrawerHeader.displayName = "DrawerHeader" +); +DrawerHeader.displayName = "DrawerHeader"; const DrawerFooter = ({ className, @@ -73,8 +72,8 @@ const DrawerFooter = ({ className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} /> -) -DrawerFooter.displayName = "DrawerFooter" +); +DrawerFooter.displayName = "DrawerFooter"; const DrawerTitle = React.forwardRef< React.ElementRef, @@ -88,8 +87,8 @@ const DrawerTitle = React.forwardRef< )} {...props} /> -)) -DrawerTitle.displayName = DrawerPrimitive.Title.displayName +)); +DrawerTitle.displayName = DrawerPrimitive.Title.displayName; const DrawerDescription = React.forwardRef< React.ElementRef, @@ -100,8 +99,8 @@ const DrawerDescription = React.forwardRef< className={cn("text-sm text-muted-foreground", className)} {...props} /> -)) -DrawerDescription.displayName = DrawerPrimitive.Description.displayName +)); +DrawerDescription.displayName = DrawerPrimitive.Description.displayName; export { Drawer, @@ -114,4 +113,4 @@ export { DrawerFooter, DrawerTitle, DrawerDescription, -} +}; diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx index 769ff7a..5c1e59d 100644 --- a/src/components/ui/dropdown-menu.tsx +++ b/src/components/ui/dropdown-menu.tsx @@ -1,25 +1,25 @@ -import * as React from "react" -import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" -import { Check, ChevronRight, Circle } from "lucide-react" +import * as React from "react"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { Check, ChevronRight, Circle } from "lucide-react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; -const DropdownMenu = DropdownMenuPrimitive.Root +const DropdownMenu = DropdownMenuPrimitive.Root; -const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; -const DropdownMenuGroup = DropdownMenuPrimitive.Group +const DropdownMenuGroup = DropdownMenuPrimitive.Group; -const DropdownMenuPortal = DropdownMenuPrimitive.Portal +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; -const DropdownMenuSub = DropdownMenuPrimitive.Sub +const DropdownMenuSub = DropdownMenuPrimitive.Sub; -const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; const DropdownMenuSubTrigger = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { - inset?: boolean + inset?: boolean; } >(({ className, inset, children, ...props }, ref) => ( -)) +)); DropdownMenuSubTrigger.displayName = - DropdownMenuPrimitive.SubTrigger.displayName + DropdownMenuPrimitive.SubTrigger.displayName; const DropdownMenuSubContent = React.forwardRef< React.ElementRef, @@ -50,9 +50,9 @@ const DropdownMenuSubContent = React.forwardRef< )} {...props} /> -)) +)); DropdownMenuSubContent.displayName = - DropdownMenuPrimitive.SubContent.displayName + DropdownMenuPrimitive.SubContent.displayName; const DropdownMenuContent = React.forwardRef< React.ElementRef, @@ -69,13 +69,13 @@ const DropdownMenuContent = React.forwardRef< {...props} /> -)) -DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; const DropdownMenuItem = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { - inset?: boolean + inset?: boolean; } >(({ className, inset, ...props }, ref) => ( -)) -DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; const DropdownMenuCheckboxItem = React.forwardRef< React.ElementRef, @@ -110,9 +110,9 @@ const DropdownMenuCheckboxItem = React.forwardRef< {children} -)) +)); DropdownMenuCheckboxItem.displayName = - DropdownMenuPrimitive.CheckboxItem.displayName + DropdownMenuPrimitive.CheckboxItem.displayName; const DropdownMenuRadioItem = React.forwardRef< React.ElementRef, @@ -133,13 +133,13 @@ const DropdownMenuRadioItem = React.forwardRef< {children} -)) -DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; const DropdownMenuLabel = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { - inset?: boolean + inset?: boolean; } >(({ className, inset, ...props }, ref) => ( -)) -DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; const DropdownMenuSeparator = React.forwardRef< React.ElementRef, @@ -163,8 +163,8 @@ const DropdownMenuSeparator = React.forwardRef< className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} /> -)) -DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; const DropdownMenuShortcut = ({ className, @@ -175,9 +175,9 @@ const DropdownMenuShortcut = ({ className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} /> - ) -} -DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + ); +}; +DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; export { DropdownMenu, @@ -195,4 +195,4 @@ export { DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuRadioGroup, -} +}; diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx index 4603f8b..1f4d5f5 100644 --- a/src/components/ui/form.tsx +++ b/src/components/ui/form.tsx @@ -1,6 +1,6 @@ -import * as React from "react" -import * as LabelPrimitive from "@radix-ui/react-label" -import { Slot } from "@radix-ui/react-slot" +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { Slot } from "@radix-ui/react-slot"; import { Controller, ControllerProps, @@ -8,27 +8,27 @@ import { FieldValues, FormProvider, useFormContext, -} from "react-hook-form" +} from "react-hook-form"; -import { cn } from "@/lib/utils" -import { Label } from "@/components/ui/label" +import { cn } from "@/lib/utils"; +import { Label } from "@/components/ui/label"; -const Form = FormProvider +const Form = FormProvider; type FormFieldContextValue< TFieldValues extends FieldValues = FieldValues, - TName extends FieldPath = FieldPath + TName extends FieldPath = FieldPath, > = { - name: TName -} + name: TName; +}; const FormFieldContext = React.createContext( {} as FormFieldContextValue -) +); const FormField = < TFieldValues extends FieldValues = FieldValues, - TName extends FieldPath = FieldPath + TName extends FieldPath = FieldPath, >({ ...props }: ControllerProps) => { @@ -36,21 +36,21 @@ const FormField = < - ) -} + ); +}; const useFormField = () => { - const fieldContext = React.useContext(FormFieldContext) - const itemContext = React.useContext(FormItemContext) - const { getFieldState, formState } = useFormContext() + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState, formState } = useFormContext(); - const fieldState = getFieldState(fieldContext.name, formState) + const fieldState = getFieldState(fieldContext.name, formState); if (!fieldContext) { - throw new Error("useFormField should be used within ") + throw new Error("useFormField should be used within "); } - const { id } = itemContext + const { id } = itemContext; return { id, @@ -59,36 +59,36 @@ const useFormField = () => { formDescriptionId: `${id}-form-item-description`, formMessageId: `${id}-form-item-message`, ...fieldState, - } -} + }; +}; type FormItemContextValue = { - id: string -} + id: string; +}; const FormItemContext = React.createContext( {} as FormItemContextValue -) +); const FormItem = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => { - const id = React.useId() + const id = React.useId(); return (
- ) -}) -FormItem.displayName = "FormItem" + ); +}); +FormItem.displayName = "FormItem"; const FormLabel = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => { - const { error, formItemId } = useFormField() + const { error, formItemId } = useFormField(); return (
- ) + ); } if (isMobile) { @@ -207,7 +207,7 @@ const Sidebar = React.forwardRef<
{children}
- ) + ); } return ( @@ -252,16 +252,16 @@ const Sidebar = React.forwardRef<
- ) + ); } -) -Sidebar.displayName = "Sidebar" +); +Sidebar.displayName = "Sidebar"; const SidebarTrigger = React.forwardRef< React.ElementRef, React.ComponentProps >(({ className, onClick, ...props }, ref) => { - const { toggleSidebar } = useSidebar() + const { toggleSidebar } = useSidebar(); return ( - ) -}) -SidebarTrigger.displayName = "SidebarTrigger" + ); +}); +SidebarTrigger.displayName = "SidebarTrigger"; const SidebarRail = React.forwardRef< HTMLButtonElement, React.ComponentProps<"button"> >(({ className, ...props }, ref) => { - const { toggleSidebar } = useSidebar() + const { toggleSidebar } = useSidebar(); return (
-)) -Table.displayName = "Table" +)); +Table.displayName = "Table"; const TableHeader = React.forwardRef< HTMLTableSectionElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( -)) -TableHeader.displayName = "TableHeader" +)); +TableHeader.displayName = "TableHeader"; const TableBody = React.forwardRef< HTMLTableSectionElement, @@ -33,8 +33,8 @@ const TableBody = React.forwardRef< className={cn("[&_tr:last-child]:border-0", className)} {...props} /> -)) -TableBody.displayName = "TableBody" +)); +TableBody.displayName = "TableBody"; const TableFooter = React.forwardRef< HTMLTableSectionElement, @@ -48,8 +48,8 @@ const TableFooter = React.forwardRef< )} {...props} /> -)) -TableFooter.displayName = "TableFooter" +)); +TableFooter.displayName = "TableFooter"; const TableRow = React.forwardRef< HTMLTableRowElement, @@ -63,8 +63,8 @@ const TableRow = React.forwardRef< )} {...props} /> -)) -TableRow.displayName = "TableRow" +)); +TableRow.displayName = "TableRow"; const TableHead = React.forwardRef< HTMLTableCellElement, @@ -78,8 +78,8 @@ const TableHead = React.forwardRef< )} {...props} /> -)) -TableHead.displayName = "TableHead" +)); +TableHead.displayName = "TableHead"; const TableCell = React.forwardRef< HTMLTableCellElement, @@ -90,8 +90,8 @@ const TableCell = React.forwardRef< className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)} {...props} /> -)) -TableCell.displayName = "TableCell" +)); +TableCell.displayName = "TableCell"; const TableCaption = React.forwardRef< HTMLTableCaptionElement, @@ -102,8 +102,8 @@ const TableCaption = React.forwardRef< className={cn("mt-4 text-sm text-muted-foreground", className)} {...props} /> -)) -TableCaption.displayName = "TableCaption" +)); +TableCaption.displayName = "TableCaption"; export { Table, @@ -114,4 +114,4 @@ export { TableRow, TableCell, TableCaption, -} +}; diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx index 4bdcf53..cea144e 100644 --- a/src/components/ui/tabs.tsx +++ b/src/components/ui/tabs.tsx @@ -1,10 +1,9 @@ +import * as React from "react"; +import * as TabsPrimitive from "@radix-ui/react-tabs"; -import * as React from "react" -import * as TabsPrimitive from "@radix-ui/react-tabs" +import { cn } from "@/lib/utils"; -import { cn } from "@/lib/utils" - -const Tabs = TabsPrimitive.Root +const Tabs = TabsPrimitive.Root; const TabsList = React.forwardRef< React.ElementRef, @@ -18,8 +17,8 @@ const TabsList = React.forwardRef< )} {...props} /> -)) -TabsList.displayName = TabsPrimitive.List.displayName +)); +TabsList.displayName = TabsPrimitive.List.displayName; const TabsTrigger = React.forwardRef< React.ElementRef, @@ -33,8 +32,8 @@ const TabsTrigger = React.forwardRef< )} {...props} /> -)) -TabsTrigger.displayName = TabsPrimitive.Trigger.displayName +)); +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; const TabsContent = React.forwardRef< React.ElementRef, @@ -48,7 +47,7 @@ const TabsContent = React.forwardRef< )} {...props} /> -)) -TabsContent.displayName = TabsPrimitive.Content.displayName +)); +TabsContent.displayName = TabsPrimitive.Content.displayName; -export { Tabs, TabsList, TabsTrigger, TabsContent } +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/src/components/ui/textarea.tsx b/src/components/ui/textarea.tsx index 9f9a6dc..0f3eac6 100644 --- a/src/components/ui/textarea.tsx +++ b/src/components/ui/textarea.tsx @@ -1,6 +1,6 @@ -import * as React from "react" +import * as React from "react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; export interface TextareaProps extends React.TextareaHTMLAttributes {} @@ -16,9 +16,9 @@ const Textarea = React.forwardRef( ref={ref} {...props} /> - ) + ); } -) -Textarea.displayName = "Textarea" +); +Textarea.displayName = "Textarea"; -export { Textarea } +export { Textarea }; diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx index 5d47d4a..7f4b425 100644 --- a/src/components/ui/toast.tsx +++ b/src/components/ui/toast.tsx @@ -1,12 +1,11 @@ +import * as React from "react"; +import * as ToastPrimitives from "@radix-ui/react-toast"; +import { cva, type VariantProps } from "class-variance-authority"; +import { X } from "lucide-react"; -import * as React from "react" -import * as ToastPrimitives from "@radix-ui/react-toast" -import { cva, type VariantProps } from "class-variance-authority" -import { X } from "lucide-react" +import { cn } from "@/lib/utils"; -import { cn } from "@/lib/utils" - -const ToastProvider = ToastPrimitives.Provider +const ToastProvider = ToastPrimitives.Provider; const ToastViewport = React.forwardRef< React.ElementRef, @@ -20,8 +19,8 @@ const ToastViewport = React.forwardRef< )} {...props} /> -)) -ToastViewport.displayName = ToastPrimitives.Viewport.displayName +)); +ToastViewport.displayName = ToastPrimitives.Viewport.displayName; const toastVariants = cva( "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", @@ -37,7 +36,7 @@ const toastVariants = cva( variant: "default", }, } -) +); const Toast = React.forwardRef< React.ElementRef, @@ -50,9 +49,9 @@ const Toast = React.forwardRef< className={cn(toastVariants({ variant }), className)} {...props} /> - ) -}) -Toast.displayName = ToastPrimitives.Root.displayName + ); +}); +Toast.displayName = ToastPrimitives.Root.displayName; const ToastAction = React.forwardRef< React.ElementRef, @@ -66,8 +65,8 @@ const ToastAction = React.forwardRef< )} {...props} /> -)) -ToastAction.displayName = ToastPrimitives.Action.displayName +)); +ToastAction.displayName = ToastPrimitives.Action.displayName; const ToastClose = React.forwardRef< React.ElementRef, @@ -84,8 +83,8 @@ const ToastClose = React.forwardRef< > -)) -ToastClose.displayName = ToastPrimitives.Close.displayName +)); +ToastClose.displayName = ToastPrimitives.Close.displayName; const ToastTitle = React.forwardRef< React.ElementRef, @@ -96,8 +95,8 @@ const ToastTitle = React.forwardRef< className={cn("text-sm font-semibold text-center", className)} {...props} /> -)) -ToastTitle.displayName = ToastPrimitives.Title.displayName +)); +ToastTitle.displayName = ToastPrimitives.Title.displayName; const ToastDescription = React.forwardRef< React.ElementRef, @@ -108,12 +107,12 @@ const ToastDescription = React.forwardRef< className={cn("text-sm opacity-90 text-center", className)} {...props} /> -)) -ToastDescription.displayName = ToastPrimitives.Description.displayName +)); +ToastDescription.displayName = ToastPrimitives.Description.displayName; -type ToastProps = React.ComponentPropsWithoutRef +type ToastProps = React.ComponentPropsWithoutRef; -type ToastActionElement = React.ReactElement +type ToastActionElement = React.ReactElement; export { type ToastProps, @@ -125,4 +124,4 @@ export { ToastDescription, ToastClose, ToastAction, -} +}; diff --git a/src/components/ui/toaster.tsx b/src/components/ui/toaster.tsx index 628ec3c..d6d8556 100644 --- a/src/components/ui/toaster.tsx +++ b/src/components/ui/toaster.tsx @@ -1,5 +1,4 @@ - -import { useToast } from "@/hooks/toast" +import { useToast } from "@/hooks/toast"; import { Toast, ToastClose, @@ -7,10 +6,10 @@ import { ToastProvider, ToastTitle, ToastViewport, -} from "@/components/ui/toast" +} from "@/components/ui/toast"; export function Toaster() { - const { toasts } = useToast() + const { toasts } = useToast(); return ( @@ -26,9 +25,9 @@ export function Toaster() { {action} - ) + ); })} - ) + ); } diff --git a/src/components/ui/toggle-group.tsx b/src/components/ui/toggle-group.tsx index afe5da6..4feb5b7 100644 --- a/src/components/ui/toggle-group.tsx +++ b/src/components/ui/toggle-group.tsx @@ -1,16 +1,16 @@ -import * as React from "react" -import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group" -import { type VariantProps } from "class-variance-authority" +import * as React from "react"; +import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"; +import { type VariantProps } from "class-variance-authority"; -import { cn } from "@/lib/utils" -import { toggleVariants } from "@/components/ui/toggle" +import { cn } from "@/lib/utils"; +import { toggleVariants } from "@/components/ui/toggle"; const ToggleGroupContext = React.createContext< VariantProps >({ size: "default", variant: "default", -}) +}); const ToggleGroup = React.forwardRef< React.ElementRef, @@ -26,16 +26,16 @@ const ToggleGroup = React.forwardRef< {children} -)) +)); -ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName +ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName; const ToggleGroupItem = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & VariantProps >(({ className, children, variant, size, ...props }, ref) => { - const context = React.useContext(ToggleGroupContext) + const context = React.useContext(ToggleGroupContext); return ( {children} - ) -}) + ); +}); -ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName +ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName; -export { ToggleGroup, ToggleGroupItem } +export { ToggleGroup, ToggleGroupItem }; diff --git a/src/components/ui/toggle.tsx b/src/components/ui/toggle.tsx index 9ecac28..56453ca 100644 --- a/src/components/ui/toggle.tsx +++ b/src/components/ui/toggle.tsx @@ -1,8 +1,8 @@ -import * as React from "react" -import * as TogglePrimitive from "@radix-ui/react-toggle" -import { cva, type VariantProps } from "class-variance-authority" +import * as React from "react"; +import * as TogglePrimitive from "@radix-ui/react-toggle"; +import { cva, type VariantProps } from "class-variance-authority"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; const toggleVariants = cva( "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground", @@ -24,7 +24,7 @@ const toggleVariants = cva( size: "default", }, } -) +); const Toggle = React.forwardRef< React.ElementRef, @@ -36,8 +36,8 @@ const Toggle = React.forwardRef< className={cn(toggleVariants({ variant, size, className }))} {...props} /> -)) +)); -Toggle.displayName = TogglePrimitive.Root.displayName +Toggle.displayName = TogglePrimitive.Root.displayName; -export { Toggle, toggleVariants } +export { Toggle, toggleVariants }; diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx index e121f0a..13a0543 100644 --- a/src/components/ui/tooltip.tsx +++ b/src/components/ui/tooltip.tsx @@ -1,13 +1,13 @@ -import * as React from "react" -import * as TooltipPrimitive from "@radix-ui/react-tooltip" +import * as React from "react"; +import * as TooltipPrimitive from "@radix-ui/react-tooltip"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; -const TooltipProvider = TooltipPrimitive.Provider +const TooltipProvider = TooltipPrimitive.Provider; -const Tooltip = TooltipPrimitive.Root +const Tooltip = TooltipPrimitive.Root; -const TooltipTrigger = TooltipPrimitive.Trigger +const TooltipTrigger = TooltipPrimitive.Trigger; const TooltipContent = React.forwardRef< React.ElementRef, @@ -22,7 +22,7 @@ const TooltipContent = React.forwardRef< )} {...props} /> -)) -TooltipContent.displayName = TooltipPrimitive.Content.displayName +)); +TooltipContent.displayName = TooltipPrimitive.Content.displayName; -export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; diff --git a/src/components/ui/use-toast.ts b/src/components/ui/use-toast.ts index f25c9a3..9349b87 100644 --- a/src/components/ui/use-toast.ts +++ b/src/components/ui/use-toast.ts @@ -1,4 +1,3 @@ - // Shadcn UI의 최신 버전에서는 use-toast가 hooks 폴더로 이동했습니다. // 이 파일은 기존 import 경로가 작동하도록 리디렉션합니다. import { useToast, toast } from "@/hooks/toast"; diff --git a/src/constants/categoryIcons.tsx b/src/constants/categoryIcons.tsx index 50598c2..978e12b 100644 --- a/src/constants/categoryIcons.tsx +++ b/src/constants/categoryIcons.tsx @@ -1,46 +1,46 @@ - -import React from 'react'; -import { ShoppingBag, Coffee, Bus, Landmark, MoreHorizontal } from 'lucide-react'; +import React from "react"; +import { + ShoppingBag, + Coffee, + Bus, + Landmark, + MoreHorizontal, +} from "lucide-react"; // 지출 카테고리 정의 - 교통비를 교통으로 통일 -export const EXPENSE_CATEGORIES = [ - '음식', - '쇼핑', - '교통', - '기타' -]; +export const EXPENSE_CATEGORIES = ["음식", "쇼핑", "교통", "기타"]; // 카테고리별 아이콘 매핑 export const categoryIcons: Record = { - '음식': , - '쇼핑': , - '교통': , - '기타': , - '수입': + 음식: , + 쇼핑: , + 교통: , + 기타: , + 수입: , }; // 기본 카테고리 설정 (신규 사용자용) export const DEFAULT_CATEGORIES = { - '음식': 0, - '쇼핑': 0, - '교통': 0, - '기타': 0 + 음식: 0, + 쇼핑: 0, + 교통: 0, + 기타: 0, }; // 카테고리 설명 추가 export const CATEGORY_DESCRIPTIONS: Record = { - '음식': '식비, 카페, 외식', - '쇼핑': '의류, 가전, 생활용품', - '교통': '대중교통, 택시, 주유비', - '기타': '기타 지출', - '수입': '급여, 용돈, 기타 수입' + 음식: "식비, 카페, 외식", + 쇼핑: "의류, 가전, 생활용품", + 교통: "대중교통, 택시, 주유비", + 기타: "기타 지출", + 수입: "급여, 용돈, 기타 수입", }; // 카테고리별 제목 추천 export const CATEGORY_TITLE_SUGGESTIONS: Record = { - '음식': ['점심식사', '저녁식사', '카페', '간식', '아침식사', '회식'], - '쇼핑': ['의류', '생활용품', '가전제품', '화장품', '선물', '온라인쇼핑'], - '교통': ['대중교통', '택시', '주유', '주차비', '고속도로 통행료'], - '기타': ['의료비', '통신비', '교육비', '취미활동', '문화생활', '기부금'], - '수입': ['급여', '보너스', '용돈', '부수입', '환급금'] + 음식: ["점심식사", "저녁식사", "카페", "간식", "아침식사", "회식"], + 쇼핑: ["의류", "생활용품", "가전제품", "화장품", "선물", "온라인쇼핑"], + 교통: ["대중교통", "택시", "주유", "주차비", "고속도로 통행료"], + 기타: ["의료비", "통신비", "교육비", "취미활동", "문화생활", "기부금"], + 수입: ["급여", "보너스", "용돈", "부수입", "환급금"], }; diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 95769a6..54f4bb3 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -1,10 +1,13 @@ +import React from "react"; +import { AuthProvider } from "./auth/AuthProvider"; -import React from 'react'; -import { AuthProvider } from './auth/AuthProvider'; +export { AuthProvider } from "./auth/AuthProvider"; +export { useAuth } from "./auth/useAuth"; -export { AuthProvider } from './auth/AuthProvider'; -export { useAuth } from './auth/useAuth'; - -export default function AuthContextWrapper({ children }: { children: React.ReactNode }) { +export default function AuthContextWrapper({ + children, +}: { + children: React.ReactNode; +}) { return {children}; } diff --git a/src/contexts/auth/AuthContext.tsx b/src/contexts/auth/AuthContext.tsx index 1049bb8..451f38a 100644 --- a/src/contexts/auth/AuthContext.tsx +++ b/src/contexts/auth/AuthContext.tsx @@ -1,6 +1,7 @@ - -import React, { createContext } from 'react'; -import { AuthContextType } from './types'; +import React, { createContext } from "react"; +import { AuthContextType } from "./types"; // AuthContext 생성 -export const AuthContext = createContext(undefined); +export const AuthContext = createContext( + undefined +); diff --git a/src/contexts/auth/AuthProvider.tsx b/src/contexts/auth/AuthProvider.tsx index ad9eb5b..33b7de4 100644 --- a/src/contexts/auth/AuthProvider.tsx +++ b/src/contexts/auth/AuthProvider.tsx @@ -1,55 +1,71 @@ +import React, { useEffect, useState, useCallback } from "react"; +import { authLogger } from "@/utils/logger"; +import { toast } from "@/hooks/useToast.wrapper"; +import { AuthContextType } from "./types"; +import * as authActions from "./authActions"; +import { clearAllToasts } from "@/hooks/toast/toastManager"; +import { AuthContext } from "./AuthContext"; +import { + account, + getInitializationStatus, + reinitializeAppwriteClient, + isValidConnection, +} from "@/lib/appwrite/client"; +import { Models } from "appwrite"; +import { getDefaultUserId } from "@/lib/appwrite/defaultUser"; -import React, { useEffect, useState, useCallback } from 'react'; -import { toast } from '@/hooks/useToast.wrapper'; -import { AuthContextType } from './types'; -import * as authActions from './authActions'; -import { clearAllToasts } from '@/hooks/toast/toastManager'; -import { AuthContext } from './AuthContext'; -import { account, getInitializationStatus, reinitializeAppwriteClient, isValidConnection } from '@/lib/appwrite/client'; -import { Models } from 'appwrite'; -import { getDefaultUserId } from '@/lib/appwrite/defaultUser'; - -export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { +export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { const [session, setSession] = useState(null); - const [user, setUser] = useState | null>(null); + const [user, setUser] = useState | null>( + null + ); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [appwriteInitialized, setAppwriteInitialized] = useState(false); - + const [appwriteInitialized, setAppwriteInitialized] = + useState(false); + // 오류 발생 시 안전하게 처리하는 함수 const handleAuthError = useCallback((err: any) => { - console.error('인증 처리 중 오류 발생:', err); + authLogger.error("인증 처리 중 오류 발생:", err); setError(err instanceof Error ? err : new Error(String(err))); // 오류가 발생해도 로딩 상태는 해제하여 UI가 차단되지 않도록 함 setLoading(false); }, []); - + // Appwrite 초기화 상태 확인 const checkAppwriteInitialization = useCallback(async () => { try { const status = getInitializationStatus(); - console.log('Appwrite 초기화 상태:', status.isInitialized ? '성공' : '실패'); - + authLogger.info( + "Appwrite 초기화 상태:", + status.isInitialized ? "성공" : "실패" + ); + if (!status.isInitialized) { // 초기화 실패 시 재시도 - console.log('Appwrite 초기화 재시도 중...'); + authLogger.info("Appwrite 초기화 재시도 중..."); const retryStatus = reinitializeAppwriteClient(); setAppwriteInitialized(retryStatus.isInitialized); - + if (!retryStatus.isInitialized && retryStatus.error) { handleAuthError(retryStatus.error); } } else { setAppwriteInitialized(true); } - + // 연결 상태 확인 const connectionValid = await isValidConnection(); - console.log('Appwrite 연결 상태:', connectionValid ? '정상' : '연결 문제'); - + authLogger.info( + "Appwrite 연결 상태:", + connectionValid ? "정상" : "연결 문제" + ); + return status.isInitialized; } catch (error) { - console.error('Appwrite 초기화 상태 확인 오류:', error); + authLogger.error("Appwrite 초기화 상태 확인 오류:", error); handleAuthError(error); return false; } @@ -59,15 +75,17 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children // 현재 세션 체크 - 최적화된 버전 const getSession = async () => { try { - console.log('세션 로딩 시작'); - + authLogger.info("세션 로딩 시작"); + // 비동기 작업을 마이크로태스크로 지연하여 UI 차단 방지 - await new Promise(resolve => queueMicrotask(() => resolve())); - + await new Promise((resolve) => queueMicrotask(() => resolve())); + // Appwrite 초기화 상태 확인 const isInitialized = await checkAppwriteInitialization(); if (!isInitialized) { - console.warn('Appwrite 초기화 상태가 정상적이지 않습니다. 비로그인 상태로 처리합니다.'); + authLogger.warn( + "Appwrite 초기화 상태가 정상적이지 않습니다. 비로그인 상태로 처리합니다." + ); queueMicrotask(() => { setSession(null); setUser(null); @@ -75,46 +93,50 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }); return; } - + // 사용자 정보 가져오기 시도 - 안전한 방식으로 처리 try { // 사용자 정보 가져오기 시도 - const currentUser = await account.get().catch(err => { + const currentUser = await account.get().catch((err) => { // 401 오류는 비로그인 상태로 정상적인 경우 if (err && (err as any).code === 401) { - console.log('사용자 정보 가져오기 실패, 비로그인 상태로 간주'); + authLogger.info( + "사용자 정보 가져오기 실패, 비로그인 상태로 간주" + ); } else { - console.error('사용자 정보 가져오기 오류:', err); + authLogger.error("사용자 정보 가져오기 오류:", err); } return null; }); - + if (currentUser) { // 사용자 정보가 있으면 세션 정보 가져오기 시도 - const currentSession = await account.getSession('current').catch(err => { - console.log('세션 정보 가져오기 실패:', err); - return null; - }); - + const currentSession = await account + .getSession("current") + .catch((err) => { + authLogger.info("세션 정보 가져오기 실패:", err); + return null; + }); + // 상태 업데이트를 마이크로태스크로 지연 queueMicrotask(() => { setUser(currentUser); setSession(currentSession); - console.log('세션 로딩 완료 - 사용자:', currentUser.$id); + authLogger.info("세션 로딩 완료 - 사용자:", currentUser.$id); }); } else { // 사용자 정보가 없으면 비로그인 상태로 처리 queueMicrotask(() => { setSession(null); setUser(null); - console.log('비로그인 상태로 처리'); + authLogger.info("비로그인 상태로 처리"); }); } } catch (error) { // 예상치 못한 오류 처리 - console.error('세션 처리 중 예상치 못한 오류:', error); + authLogger.error("세션 처리 중 예상치 못한 오류:", error); handleAuthError(error); - + // 오류 발생 시 로그아웃 상태로 처리 queueMicrotask(() => { setSession(null); @@ -123,7 +145,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children } } catch (error) { // 최상위 예외 처리 - console.error('세션 확인 중 최상위 예외 발생:', error); + authLogger.error("세션 확인 중 최상위 예외 발생:", error); handleAuthError(error); } finally { // 로딩 상태 업데이트를 마이크로태스크로 지연 @@ -147,42 +169,49 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children if (!appwriteInitialized) { const isInitialized = await checkAppwriteInitialization(); if (!isInitialized) { - console.warn('Appwrite 초기화 상태가 여전히 정상적이지 않습니다. 다음 간격에서 재시도합니다.'); + authLogger.warn( + "Appwrite 초기화 상태가 여전히 정상적이지 않습니다. 다음 간격에서 재시도합니다." + ); return; } } - + // 사용자 정보 가져오기 시도 - 안전하게 처리 - const currentUser = await account.get().catch(err => { + const currentUser = await account.get().catch((err) => { // 401 오류는 비로그인 상태로 정상적인 경우 if (err && (err as any).code === 401) { - console.log('사용자 정보 가져오기 실패, 비로그인 상태로 간주'); + authLogger.info("사용자 정보 가져오기 실패, 비로그인 상태로 간주"); } else { - console.error('사용자 정보 가져오기 오류:', err); + authLogger.error("사용자 정보 가져오기 오류:", err); } return null; }); - + // 비동기 작업을 마이크로태스크로 지연하여 UI 차단 방지 - await new Promise(resolve => queueMicrotask(() => resolve())); - + await new Promise((resolve) => queueMicrotask(() => resolve())); + // 사용자 정보가 있고, 이전과 다르면 세션 정보 가져오기 if (currentUser && (!user || currentUser.$id !== user.$id)) { try { // 세션 정보 가져오기 시도 - 안전하게 처리 - const currentSession = await account.getSession('current').catch(err => { - console.log('세션 정보 가져오기 실패:', err); - return null; - }); - + const currentSession = await account + .getSession("current") + .catch((err) => { + authLogger.info("세션 정보 가져오기 실패:", err); + return null; + }); + // 상태 업데이트를 마이크로태스크로 지연 queueMicrotask(() => { setUser(currentUser); setSession(currentSession); - console.log('Appwrite 인증 상태 변경: 로그인됨 - 사용자:', currentUser.$id); + authLogger.info( + "Appwrite 인증 상태 변경: 로그인됨 - 사용자:", + currentUser.$id + ); }); } catch (sessionError) { - console.error('세션 정보 가져오기 중 오류:', sessionError); + authLogger.error("세션 정보 가져오기 중 오류:", sessionError); // 오류 발생해도 사용자 정보는 업데이트 queueMicrotask(() => { setUser(currentUser); @@ -194,18 +223,18 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children queueMicrotask(() => { setSession(null); setUser(null); - + // 로그아웃 시 열려있는 모든 토스트 제거 clearAllToasts(); - + // 로그아웃 이벤트 발생시켜 SyncSettings 등에서 감지하도록 함 - window.dispatchEvent(new Event('auth-state-changed')); - console.log('Appwrite 인증 상태 변경: 로그아웃됨'); + window.dispatchEvent(new Event("auth-state-changed")); + authLogger.info("Appwrite 인증 상태 변경: 로그아웃됨"); }); } } catch (error) { // 예상치 못한 오류 발생 시에도 애플리케이션이 중단되지 않도록 처리 - console.error('Appwrite 인증 상태 검사 중 예상치 못한 오류:', error); + authLogger.error("Appwrite 인증 상태 검사 중 예상치 못한 오류:", error); handleAuthError(error); } }, 5000); // 5초마다 확인 @@ -218,7 +247,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children // Appwrite 재초기화 함수 const reinitializeAppwrite = useCallback(() => { - console.log('Appwrite 재초기화 요청됨'); + authLogger.info("Appwrite 재초기화 요청됨"); return reinitializeAppwriteClient(); }, []); @@ -238,9 +267,5 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children // 로딩 중이 아니고 오류가 없을 때만 자식 컴포넌트 렌더링 // 오류가 있어도 애플리케이션이 중단되지 않도록 처리 - return ( - - {children} - - ); + return {children}; }; diff --git a/src/contexts/auth/auth.utils.ts b/src/contexts/auth/auth.utils.ts index 8e75979..22e12ed 100644 --- a/src/contexts/auth/auth.utils.ts +++ b/src/contexts/auth/auth.utils.ts @@ -1,4 +1,3 @@ - -import { verifyServerConnection } from '@/utils/auth/networkUtils'; +import { verifyServerConnection } from "@/utils/auth/networkUtils"; export { verifyServerConnection }; diff --git a/src/contexts/auth/authActions.ts b/src/contexts/auth/authActions.ts index d9adb89..cf4a076 100644 --- a/src/contexts/auth/authActions.ts +++ b/src/contexts/auth/authActions.ts @@ -1,5 +1,4 @@ - -export { signIn } from './signIn'; -export { signUp } from './signUp'; -export { signOut } from './signOut'; -export { resetPassword } from './resetPassword'; +export { signIn } from "./signIn"; +export { signUp } from "./signUp"; +export { signOut } from "./signOut"; +export { resetPassword } from "./resetPassword"; diff --git a/src/contexts/auth/index.ts b/src/contexts/auth/index.ts index e91dffa..3ba392e 100644 --- a/src/contexts/auth/index.ts +++ b/src/contexts/auth/index.ts @@ -1,4 +1,3 @@ - -export { AuthProvider } from './AuthProvider'; -export { useAuth } from './useAuth'; -export type { AuthContextType } from './types'; +export { AuthProvider } from "./AuthProvider"; +export { useAuth } from "./useAuth"; +export type { AuthContextType } from "./types"; diff --git a/src/contexts/auth/resetPassword.ts b/src/contexts/auth/resetPassword.ts index 59ae33a..fb3e7a3 100644 --- a/src/contexts/auth/resetPassword.ts +++ b/src/contexts/auth/resetPassword.ts @@ -1,50 +1,57 @@ -import { account } from '@/lib/appwrite/client'; -import { showAuthToast } from '@/utils/auth'; +import { account } from "@/lib/appwrite/client"; +import { authLogger } from "@/utils/logger"; +import { showAuthToast } from "@/utils/auth"; export const resetPassword = async (email: string) => { try { - console.log('비밀번호 재설정 시도 중:', email); - + authLogger.info("비밀번호 재설정 시도 중:", email); + // 비동기 작업을 마이크로태스크로 지연하여 UI 차단 방지 - await new Promise(resolve => queueMicrotask(() => resolve())); - + await new Promise((resolve) => queueMicrotask(() => resolve())); + try { // Appwrite로 비밀번호 재설정 이메일 발송 await account.createRecovery( email, - window.location.origin + '/reset-password' + window.location.origin + "/reset-password" ); - + // 비동기 작업을 마이크로태스크로 지연하여 UI 차단 방지 - await new Promise(resolve => queueMicrotask(() => resolve())); - - showAuthToast('비밀번호 재설정 이메일 전송됨', '이메일을 확인하여 비밀번호를 재설정해주세요.'); + await new Promise((resolve) => queueMicrotask(() => resolve())); + + showAuthToast( + "비밀번호 재설정 이메일 전송됨", + "이메일을 확인하여 비밀번호를 재설정해주세요." + ); return { error: null }; } catch (recoveryError: any) { - console.error('비밀번호 재설정 이메일 전송 오류:', recoveryError); - + authLogger.error("비밀번호 재설정 이메일 전송 오류:", recoveryError); + // 오류 메시지 처리 - let errorMessage = recoveryError.message || '알 수 없는 오류가 발생했습니다.'; - + let errorMessage = + recoveryError.message || "알 수 없는 오류가 발생했습니다."; + // Appwrite 오류 코드에 따른 사용자 친화적 메시지 if (recoveryError.code === 404) { - errorMessage = '등록되지 않은 이메일입니다.'; + errorMessage = "등록되지 않은 이메일입니다."; } else if (recoveryError.code === 429) { - errorMessage = '너무 많은 요청이 발생했습니다. 잠시 후 다시 시도해주세요.'; + errorMessage = + "너무 많은 요청이 발생했습니다. 잠시 후 다시 시도해주세요."; } - - showAuthToast('비밀번호 재설정 실패', errorMessage, 'destructive'); + + showAuthToast("비밀번호 재설정 실패", errorMessage, "destructive"); return { error: recoveryError }; } } catch (error: any) { - console.error('비밀번호 재설정 중 예외 발생:', error); - + authLogger.error("비밀번호 재설정 중 예외 발생:", error); + // 네트워크 오류 확인 - const errorMessage = error.message && error.message.includes('network') - ? '서버 연결에 실패했습니다. 네트워크 연결을 확인해주세요.' - : '예상치 못한 오류가 발생했습니다.'; - - showAuthToast('비밀번호 재설정 오류', errorMessage, 'destructive'); + const errorMessage = + error.message && error.message.includes("network") + ? "서버 연결에 실패했습니다. 네트워크 연결을 확인해주세요." + : "예상치 못한 오류가 발생했습니다."; + + showAuthToast("비밀번호 재설정 오류", errorMessage, "destructive"); return { error }; } }; diff --git a/src/contexts/auth/signIn.ts b/src/contexts/auth/signIn.ts index 0d26eaf..56b7c30 100644 --- a/src/contexts/auth/signIn.ts +++ b/src/contexts/auth/signIn.ts @@ -1,78 +1,91 @@ -import { account } from '@/lib/appwrite/client'; -import { showAuthToast } from '@/utils/auth'; -import { getDefaultUserId } from '@/lib/appwrite/defaultUser'; +import { account } from "@/lib/appwrite/client"; +import { authLogger } from "@/utils/logger"; +import { showAuthToast } from "@/utils/auth"; +import { getDefaultUserId } from "@/lib/appwrite/defaultUser"; /** * 로그인 기능 - Appwrite 환경에 최적화 */ export const signIn = async (email: string, password: string) => { try { - console.log('로그인 시도 중:', email); - + authLogger.info("로그인 시도 중:", email); + // 비동기 작업을 마이크로태스크로 지연하여 UI 차단 방지 - await new Promise(resolve => queueMicrotask(() => resolve())); - + await new Promise((resolve) => queueMicrotask(() => resolve())); + // Appwrite 인증 방식 시도 try { const session = await account.createSession(email, password); const user = await account.get(); - + // 상태 업데이트를 마이크로태스크로 지연 - await new Promise(resolve => queueMicrotask(() => resolve())); - - showAuthToast('로그인 성공', '환영합니다!'); + await new Promise((resolve) => queueMicrotask(() => resolve())); + + showAuthToast("로그인 성공", "환영합니다!"); return { error: null, user }; } catch (authError: any) { - console.error('로그인 오류:', authError); - - let errorMessage = authError.message || '알 수 없는 오류가 발생했습니다.'; + authLogger.error("로그인 오류:", authError); + + let errorMessage = authError.message || "알 수 없는 오류가 발생했습니다."; let fallbackMode = false; - + // Appwrite 오류 코드에 따른 사용자 친화적 메시지 if (authError.code === 401) { - errorMessage = '이메일 또는 비밀번호가 올바르지 않습니다.'; + errorMessage = "이메일 또는 비밀번호가 올바르지 않습니다."; } else if (authError.code === 429) { - errorMessage = '너무 많은 로그인 시도가 있었습니다. 잠시 후 다시 시도해주세요.'; + errorMessage = + "너무 많은 로그인 시도가 있었습니다. 잠시 후 다시 시도해주세요."; } else if (authError.code === 404 || authError.code === 503) { // 서버 연결 문제인 경우 기본 사용자 ID를 활용한 대체 로직 시도 - errorMessage = '서버 연결에 문제가 있어 일반 모드로 접속합니다.'; + errorMessage = "서버 연결에 문제가 있어 일반 모드로 접속합니다."; fallbackMode = true; - + try { // 기본 사용자 ID를 활용한 대체 로직 const defaultUserId = getDefaultUserId(); - console.log('기본 사용자 ID를 활용한 대체 로직 시도:', defaultUserId); - + authLogger.info( + "기본 사용자 ID를 활용한 대체 로직 시도:", + defaultUserId + ); + // 일반 모드로 접속하는 경우 사용자에게 알림 - showAuthToast('일반 모드 접속', '일반 모드로 접속합니다. 일부 기능이 제한될 수 있습니다.', 'default'); - + showAuthToast( + "일반 모드 접속", + "일반 모드로 접속합니다. 일부 기능이 제한될 수 있습니다.", + "default" + ); + // 기본 사용자 정보를 가진 가상의 사용자 객체 생성 const fallbackUser = { $id: defaultUserId, - name: '일반 사용자', + name: "일반 사용자", email: email, $createdAt: new Date().toISOString(), $updatedAt: new Date().toISOString(), status: true, - isFallbackUser: true // 기본 사용자임을 표시하는 플래그 + isFallbackUser: true, // 기본 사용자임을 표시하는 플래그 }; - + return { error: null, user: fallbackUser, isFallbackMode: true }; } catch (fallbackError) { - console.error('기본 사용자 대체 로직 오류:', fallbackError); + authLogger.error("기본 사용자 대체 로직 오류:", fallbackError); // 대체 로직도 실패한 경우 원래 오류 반환 } } - + if (!fallbackMode) { - showAuthToast('로그인 실패', errorMessage, 'destructive'); + showAuthToast("로그인 실패", errorMessage, "destructive"); } - + return { error: authError, user: null }; } } catch (error) { - console.error('로그인 예외 발생:', error); - showAuthToast('로그인 오류', '서버 연결 중 오류가 발생했습니다.', 'destructive'); + authLogger.error("로그인 예외 발생:", error); + showAuthToast( + "로그인 오류", + "서버 연결 중 오류가 발생했습니다.", + "destructive" + ); return { error, user: null }; } }; diff --git a/src/contexts/auth/signInUtils.ts b/src/contexts/auth/signInUtils.ts index cf2af46..280cf92 100644 --- a/src/contexts/auth/signInUtils.ts +++ b/src/contexts/auth/signInUtils.ts @@ -1,53 +1,58 @@ - -import { supabase } from '@/archive/lib/supabase'; -import { showAuthToast } from '@/utils/auth'; +import { supabase } from "@/archive/lib/supabase"; +import { authLogger } from "@/utils/logger"; +import { showAuthToast } from "@/utils/auth"; /** * 로그인 기능 - Supabase Cloud 환경에 최적화 */ export const signInWithDirectApi = async (email: string, password: string) => { - console.log('Supabase Cloud 로그인 시도'); - + authLogger.info("Supabase Cloud 로그인 시도"); + try { // Supabase Cloud를 통한 로그인 요청 const { data, error } = await supabase.auth.signInWithPassword({ email, - password + password, }); - + // 오류 응답 처리 if (error) { - console.error('로그인 오류:', error); - + authLogger.error("로그인 오류:", error); + // 오류 메시지 포맷팅 let errorMessage = error.message; - - if (error.message.includes('Invalid login credentials')) { - errorMessage = '이메일 또는 비밀번호가 올바르지 않습니다.'; - } else if (error.message.includes('Email not confirmed')) { - errorMessage = '이메일 인증이 완료되지 않았습니다. 이메일을 확인해주세요.'; + + if (error.message.includes("Invalid login credentials")) { + errorMessage = "이메일 또는 비밀번호가 올바르지 않습니다."; + } else if (error.message.includes("Email not confirmed")) { + errorMessage = + "이메일 인증이 완료되지 않았습니다. 이메일을 확인해주세요."; } - - showAuthToast('로그인 실패', errorMessage, 'destructive'); + + showAuthToast("로그인 실패", errorMessage, "destructive"); return { error: { message: errorMessage }, user: null }; } - + // 로그인 성공 처리 if (data && data.user) { - console.log('로그인 성공:', data.user); - showAuthToast('로그인 성공', '환영합니다!'); + authLogger.info("로그인 성공:", data.user); + showAuthToast("로그인 성공", "환영합니다!"); return { error: null, user: data.user }; } else { // 사용자 정보가 없는 경우 (드문 경우) - console.warn('로그인 성공했지만 사용자 정보가 없습니다'); - showAuthToast('로그인 부분 성공', '로그인은 성공했지만 사용자 정보를 가져오지 못했습니다.', 'default'); - return { error: { message: '사용자 정보 조회 실패' }, user: null }; + authLogger.warn("로그인 성공했지만 사용자 정보가 없습니다"); + showAuthToast( + "로그인 부분 성공", + "로그인은 성공했지만 사용자 정보를 가져오지 못했습니다.", + "default" + ); + return { error: { message: "사용자 정보 조회 실패" }, user: null }; } } catch (error: any) { - console.error('로그인 요청 중 예외:', error); - const errorMessage = error.message || '로그인 중 오류가 발생했습니다.'; - - showAuthToast('로그인 요청 실패', errorMessage, 'destructive'); + authLogger.error("로그인 요청 중 예외:", error); + const errorMessage = error.message || "로그인 중 오류가 발생했습니다."; + + showAuthToast("로그인 요청 실패", errorMessage, "destructive"); return { error: { message: errorMessage }, user: null }; } }; diff --git a/src/contexts/auth/signOut.ts b/src/contexts/auth/signOut.ts index dc2875e..ad56f25 100644 --- a/src/contexts/auth/signOut.ts +++ b/src/contexts/auth/signOut.ts @@ -1,49 +1,52 @@ -import { account } from '@/lib/appwrite/client'; -import { showAuthToast } from '@/utils/auth'; -import { clearAllToasts } from '@/hooks/toast/toastManager'; +import { account } from "@/lib/appwrite/client"; +import { authLogger } from "@/utils/logger"; +import { showAuthToast } from "@/utils/auth"; +import { clearAllToasts } from "@/hooks/toast/toastManager"; export const signOut = async (): Promise => { try { - console.log('로그아웃 시도 중'); - + authLogger.info("로그아웃 시도 중"); + // 비동기 작업을 마이크로태스크로 지연하여 UI 차단 방지 - await new Promise(resolve => queueMicrotask(() => resolve())); - + await new Promise((resolve) => queueMicrotask(() => resolve())); + try { // 현재 세션 아이디 가져오기 - const currentSession = await account.getSession('current'); - + const currentSession = await account.getSession("current"); + // 현재 세션 삭제 await account.deleteSession(currentSession.$id); - + // 로그아웃 시 열려있는 모든 토스트 제거 clearAllToasts(); - + // 로그아웃 이벤트 발생시켜 SyncSettings 등에서 감지하도록 함 - window.dispatchEvent(new Event('auth-state-changed')); - - showAuthToast('로그아웃 성공', '다음에 또 만나요!'); + window.dispatchEvent(new Event("auth-state-changed")); + + showAuthToast("로그아웃 성공", "다음에 또 만나요!"); } catch (sessionError: any) { - console.error('세션 삭제 중 오류:', sessionError); - + authLogger.error("세션 삭제 중 오류:", sessionError); + // 오류 메시지 생성 - let errorMessage = sessionError.message || '알 수 없는 오류가 발생했습니다.'; - + let errorMessage = + sessionError.message || "알 수 없는 오류가 발생했습니다."; + // Appwrite 오류 코드에 따른 사용자 친화적 메시지 if (sessionError.code === 401) { - errorMessage = '이미 로그아웃되었습니다.'; + errorMessage = "이미 로그아웃되었습니다."; } - - showAuthToast('로그아웃 실패', errorMessage, 'destructive'); + + showAuthToast("로그아웃 실패", errorMessage, "destructive"); } } catch (error: any) { - console.error('로그아웃 중 예외 발생:', error); - + authLogger.error("로그아웃 중 예외 발생:", error); + // 네트워크 오류 확인 - const errorMessage = error.message && error.message.includes('network') - ? '서버 연결에 실패했습니다. 네트워크 연결을 확인해주세요.' - : '예상치 못한 오류가 발생했습니다.'; - - showAuthToast('로그아웃 오류', errorMessage, 'destructive'); + const errorMessage = + error.message && error.message.includes("network") + ? "서버 연결에 실패했습니다. 네트워크 연결을 확인해주세요." + : "예상치 못한 오류가 발생했습니다."; + + showAuthToast("로그아웃 오류", errorMessage, "destructive"); } }; diff --git a/src/contexts/auth/signUp.ts b/src/contexts/auth/signUp.ts index d9ed69e..036d938 100644 --- a/src/contexts/auth/signUp.ts +++ b/src/contexts/auth/signUp.ts @@ -1,72 +1,87 @@ - -import { account, client } from '@/lib/appwrite/client'; -import { ID } from 'appwrite'; -import { showAuthToast } from '@/utils/auth'; -import { isValidConnection } from '@/lib/appwrite/client'; +import { account, client } from "@/lib/appwrite/client"; +import { authLogger } from "@/utils/logger"; +import { ID } from "appwrite"; +import { showAuthToast } from "@/utils/auth"; +import { isValidConnection } from "@/lib/appwrite/client"; /** * 회원가입 기능 - Appwrite 환경에 최적화 */ -export const signUp = async (email: string, password: string, username: string) => { +export const signUp = async ( + email: string, + password: string, + username: string +) => { try { // 비동기 작업을 마이크로태스크로 지연하여 UI 차단 방지 - await new Promise(resolve => queueMicrotask(() => resolve())); - + await new Promise((resolve) => queueMicrotask(() => resolve())); + // 서버 연결 상태 확인 const connected = await isValidConnection(); if (!connected) { - console.error('서버 연결 실패'); - showAuthToast('회원가입 오류', '서버 연결에 실패했습니다. 네트워크 연결을 확인해주세요.', 'destructive'); - return { error: { message: '서버 연결 실패' }, user: null }; + authLogger.error("서버 연결 실패"); + showAuthToast( + "회원가입 오류", + "서버 연결에 실패했습니다. 네트워크 연결을 확인해주세요.", + "destructive" + ); + return { error: { message: "서버 연결 실패" }, user: null }; } - - console.log('회원가입 시도:', email); - + + authLogger.info("회원가입 시도:", email); + try { // Appwrite로 회원가입 요청 - const user = await account.create( - ID.unique(), - email, - password, - username - ); - + const user = await account.create(ID.unique(), email, password, username); + // 이메일 인증 메일 발송 - await account.createVerification(window.location.origin + '/login'); - + await account.createVerification(window.location.origin + "/login"); + // 비동기 작업을 마이크로태스크로 지연하여 UI 차단 방지 - await new Promise(resolve => queueMicrotask(() => resolve())); - - showAuthToast('회원가입 성공', '인증 메일이 발송되었습니다. 스팸 폴더도 확인해주세요.', 'default'); - console.log('인증 메일 발송됨:', email); - - return { - error: null, - user, - message: '이메일 인증 필요', - emailConfirmationRequired: true + await new Promise((resolve) => queueMicrotask(() => resolve())); + + showAuthToast( + "회원가입 성공", + "인증 메일이 발송되었습니다. 스팸 폴더도 확인해주세요.", + "default" + ); + authLogger.info("인증 메일 발송됨:", email); + + return { + error: null, + user, + message: "이메일 인증 필요", + emailConfirmationRequired: true, }; } catch (authError: any) { - console.error('회원가입 오류:', authError); - + authLogger.error("회원가입 오류:", authError); + // 오류 메시지 처리 - let errorMessage = authError.message || '알 수 없는 오류가 발생했습니다.'; - + let errorMessage = authError.message || "알 수 없는 오류가 발생했습니다."; + // Appwrite 오류 코드에 따른 사용자 친화적 메시지 if (authError.code === 409) { - errorMessage = '이미 등록된 이메일입니다.'; + errorMessage = "이미 등록된 이메일입니다."; } else if (authError.code === 400) { - errorMessage = '유효하지 않은 이메일 또는 비밀번호입니다.'; + errorMessage = "유효하지 않은 이메일 또는 비밀번호입니다."; } else if (authError.code === 429) { - errorMessage = '너무 많은 요청이 발생했습니다. 잠시 후 다시 시도해주세요.'; + errorMessage = + "너무 많은 요청이 발생했습니다. 잠시 후 다시 시도해주세요."; } - - showAuthToast('회원가입 실패', errorMessage, 'destructive'); + + showAuthToast("회원가입 실패", errorMessage, "destructive"); return { error: { message: errorMessage }, user: null }; } } catch (error: any) { - console.error('회원가입 전역 예외:', error); - showAuthToast('회원가입 오류', error.message || '알 수 없는 오류', 'destructive'); - return { error: { message: error.message || '알 수 없는 오류' }, user: null }; + authLogger.error("회원가입 전역 예외:", error); + showAuthToast( + "회원가입 오류", + error.message || "알 수 없는 오류", + "destructive" + ); + return { + error: { message: error.message || "알 수 없는 오류" }, + user: null, + }; } }; diff --git a/src/contexts/auth/signUpApiCalls.ts b/src/contexts/auth/signUpApiCalls.ts index 83d3bc5..56c4c1d 100644 --- a/src/contexts/auth/signUpApiCalls.ts +++ b/src/contexts/auth/signUpApiCalls.ts @@ -1,15 +1,20 @@ - -import { parseResponse, showAuthToast } from '@/utils/auth'; -import { getProxyType, isCorsProxyEnabled, getSupabaseUrl, getOriginalSupabaseUrl } from '@/lib/supabase/config'; -import { handleNetworkError } from '@/utils/auth/handleNetworkError'; +import { parseResponse, showAuthToast } from "@/utils/auth"; +import { authLogger } from "@/utils/logger"; +import { + getProxyType, + isCorsProxyEnabled, + getSupabaseUrl, + getOriginalSupabaseUrl, +} from "@/lib/supabase/config"; +import { handleNetworkError } from "@/utils/auth/handleNetworkError"; /** * 직접 API 호출을 통한 회원가입 요청 전송 */ export const sendSignUpApiRequest = async ( - email: string, - password: string, - username: string, + email: string, + password: string, + username: string, redirectUrl: string, supabaseKey: string ) => { @@ -17,41 +22,43 @@ export const sendSignUpApiRequest = async ( // 프록시 적용된 URL과 원본 URL 모두 가져오기 const supabaseUrl = getOriginalSupabaseUrl(); // 원본 URL const proxyUrl = getSupabaseUrl(); // 프록시 적용된 URL - + // 프록시 정보 로깅 const usingProxy = isCorsProxyEnabled(); const proxyType = getProxyType(); - console.log(`CORS 프록시 사용: ${usingProxy ? '예' : '아니오'}, 타입: ${proxyType}, 프록시 URL: ${proxyUrl}`); - + authLogger.info( + `CORS 프록시 사용: ${usingProxy ? "예" : "아니오"}, 타입: ${proxyType}, 프록시 URL: ${proxyUrl}` + ); + // 실제 요청에 사용할 URL 결정 (항상 프록시 URL 사용) const useUrl = usingProxy ? proxyUrl : supabaseUrl; - + // URL에 auth/v1이 이미 포함되어있는지 확인 - const baseUrl = useUrl.includes('/auth/v1') ? useUrl : `${useUrl}/auth/v1`; - + const baseUrl = useUrl.includes("/auth/v1") ? useUrl : `${useUrl}/auth/v1`; + // 회원가입 API 엔드포인트 및 헤더 설정 const signUpUrl = `${baseUrl}/signup`; const headers = { - 'Content-Type': 'application/json', - 'apikey': supabaseKey + "Content-Type": "application/json", + apikey: supabaseKey, }; - - console.log('회원가입 API 요청 URL:', signUpUrl); - + + authLogger.info("회원가입 API 요청 URL:", signUpUrl); + // 회원가입 요청 전송 const response = await fetch(signUpUrl, { - method: 'POST', + method: "POST", headers, - body: JSON.stringify({ - email, + body: JSON.stringify({ + email, password, data: { username }, // 사용자 메타데이터에 username 추가 - redirect_to: redirectUrl // 리디렉션 URL 추가 + redirect_to: redirectUrl, // 리디렉션 URL 추가 }), - signal: AbortSignal.timeout(15000) // 타임아웃 시간 증가 + signal: AbortSignal.timeout(15000), // 타임아웃 시간 증가 }); - - console.log('회원가입 응답 상태:', response.status); + + authLogger.info("회원가입 응답 상태:", response.status); return response; } catch (error) { throw error; // 상위 함수에서 처리하도록 오류 전파 @@ -64,13 +71,13 @@ export const sendSignUpApiRequest = async ( export const getStatusErrorMessage = (status: number): string => { switch (status) { case 400: - return '잘못된 요청 형식입니다. 입력 데이터를 확인하세요.'; + return "잘못된 요청 형식입니다. 입력 데이터를 확인하세요."; case 401: - return '회원가입 권한이 없습니다. Supabase 설정 또는 권한을 확인하세요.'; + return "회원가입 권한이 없습니다. Supabase 설정 또는 권한을 확인하세요."; case 404: - return '서버 경로를 찾을 수 없습니다. Supabase URL을 확인하세요.'; + return "서버 경로를 찾을 수 없습니다. Supabase URL을 확인하세요."; case 500: - return '서버 내부 오류가 발생했습니다. 나중에 다시 시도하세요.'; + return "서버 내부 오류가 발생했습니다. 나중에 다시 시도하세요."; default: return `회원가입 처리 중 오류가 발생했습니다 (${status}). 나중에 다시 시도하세요.`; } diff --git a/src/contexts/auth/signUpErrorHandlers.ts b/src/contexts/auth/signUpErrorHandlers.ts index ee10e48..51ccfae 100644 --- a/src/contexts/auth/signUpErrorHandlers.ts +++ b/src/contexts/auth/signUpErrorHandlers.ts @@ -1,37 +1,40 @@ - -import { showAuthToast } from '@/utils/auth'; -import { getProxyType, isCorsProxyEnabled } from '@/lib/supabase/config'; +import { showAuthToast } from "@/utils/auth"; +import { authLogger } from "@/utils/logger"; +import { getProxyType, isCorsProxyEnabled } from "@/lib/supabase/config"; /** * 회원가입 API 호출 중 발생한 예외 처리 */ export const handleSignUpApiError = (error: any) => { - console.error('회원가입 API 호출 중 예외 발생:', error); - + authLogger.error("회원가입 API 호출 중 예외 발생:", error); + // 프록시 설정 확인 const usingProxy = isCorsProxyEnabled(); const proxyType = getProxyType(); - + // 오류 메시지 설정 및 프록시 추천 - let errorMessage = error.message || '알 수 없는 오류가 발생했습니다.'; - + let errorMessage = error.message || "알 수 없는 오류가 발생했습니다."; + // 타임아웃 오류 감지 - if (errorMessage.includes('timed out') || errorMessage.includes('timeout')) { - errorMessage = '서버 응답 시간이 초과되었습니다. 네트워크 상태를 확인하고 다시 시도하세요.'; + if (errorMessage.includes("timed out") || errorMessage.includes("timeout")) { + errorMessage = + "서버 응답 시간이 초과되었습니다. 네트워크 상태를 확인하고 다시 시도하세요."; } // CORS 또는 네트워크 오류 감지 - else if (errorMessage.includes('Failed to fetch') || - errorMessage.includes('NetworkError') || - errorMessage.includes('CORS')) { + else if ( + errorMessage.includes("Failed to fetch") || + errorMessage.includes("NetworkError") || + errorMessage.includes("CORS") + ) { if (!usingProxy) { - errorMessage += ' (설정에서 Cloudflare CORS 프록시 활성화를 권장합니다)'; - } else if (proxyType !== 'cloudflare') { - errorMessage += ' (설정에서 Cloudflare CORS 프록시로 변경을 권장합니다)'; + errorMessage += " (설정에서 Cloudflare CORS 프록시 활성화를 권장합니다)"; + } else if (proxyType !== "cloudflare") { + errorMessage += " (설정에서 Cloudflare CORS 프록시로 변경을 권장합니다)"; } } - - showAuthToast('회원가입 오류', errorMessage, 'destructive'); - + + showAuthToast("회원가입 오류", errorMessage, "destructive"); + return { error: { message: errorMessage }, user: null }; }; @@ -40,14 +43,22 @@ export const handleSignUpApiError = (error: any) => { */ export const handleResponseError = (responseData: any) => { if (responseData && responseData.error) { - const errorMessage = responseData.error_description || responseData.error || '회원가입 실패'; - - if (responseData.error === 'user_already_registered') { - showAuthToast('회원가입 실패', '이미 등록된 이메일 주소입니다.', 'destructive'); - return { error: { message: '이미 등록된 이메일 주소입니다.' }, user: null }; + const errorMessage = + responseData.error_description || responseData.error || "회원가입 실패"; + + if (responseData.error === "user_already_registered") { + showAuthToast( + "회원가입 실패", + "이미 등록된 이메일 주소입니다.", + "destructive" + ); + return { + error: { message: "이미 등록된 이메일 주소입니다." }, + user: null, + }; } - - showAuthToast('회원가입 실패', errorMessage, 'destructive'); + + showAuthToast("회원가입 실패", errorMessage, "destructive"); return { error: { message: errorMessage }, user: null }; } return null; diff --git a/src/contexts/auth/signUpUtils.ts b/src/contexts/auth/signUpUtils.ts index 074a2a3..581b106 100644 --- a/src/contexts/auth/signUpUtils.ts +++ b/src/contexts/auth/signUpUtils.ts @@ -1,87 +1,102 @@ - -import { supabase } from '@/archive/lib/supabase'; -import { parseResponse, showAuthToast } from '@/utils/auth'; +import { supabase } from "@/archive/lib/supabase"; +import { authLogger } from "@/utils/logger"; +import { parseResponse, showAuthToast } from "@/utils/auth"; /** * 회원가입 기능 - Supabase Cloud 환경에 최적화 */ -export const signUpWithDirectApi = async (email: string, password: string, username: string, redirectUrl?: string) => { +export const signUpWithDirectApi = async ( + email: string, + password: string, + username: string, + redirectUrl?: string +) => { try { - console.log('Supabase Cloud 회원가입 시도 중'); - + authLogger.info("Supabase Cloud 회원가입 시도 중"); + // 리디렉션 URL 설정 (전달되지 않은 경우 기본값 사용) - const finalRedirectUrl = redirectUrl || `${window.location.origin}/login?auth_callback=true`; - console.log('이메일 인증 리디렉션 URL:', finalRedirectUrl); - + const finalRedirectUrl = + redirectUrl || `${window.location.origin}/login?auth_callback=true`; + authLogger.info("이메일 인증 리디렉션 URL:", finalRedirectUrl); + // Supabase Cloud API를 통한 회원가입 요청 const { data, error } = await supabase.auth.signUp({ email, password, options: { data: { - username // 사용자 이름을 메타데이터에 저장 + username, // 사용자 이름을 메타데이터에 저장 }, - emailRedirectTo: finalRedirectUrl - } + emailRedirectTo: finalRedirectUrl, + }, }); - + // 오류 처리 if (error) { - console.error('회원가입 오류:', error); - + authLogger.error("회원가입 오류:", error); + let errorMessage = error.message; - if (error.message.includes('User already registered')) { - errorMessage = '이미 등록된 사용자입니다.'; - } else if (error.message.includes('Signup not allowed')) { - errorMessage = '회원가입이 허용되지 않습니다.'; - } else if (error.message.includes('Email link invalid')) { - errorMessage = '이메일 링크가 유효하지 않습니다.'; + if (error.message.includes("User already registered")) { + errorMessage = "이미 등록된 사용자입니다."; + } else if (error.message.includes("Signup not allowed")) { + errorMessage = "회원가입이 허용되지 않습니다."; + } else if (error.message.includes("Email link invalid")) { + errorMessage = "이메일 링크가 유효하지 않습니다."; } - - showAuthToast('회원가입 실패', errorMessage, 'destructive'); + + showAuthToast("회원가입 실패", errorMessage, "destructive"); return { error: { message: errorMessage }, user: null }; } - + // 회원가입 성공 if (data && data.user) { // 이메일 확인이 필요한지 확인 - const isEmailConfirmationRequired = data.user.identities && - data.user.identities.length > 0 && - !data.user.identities[0].identity_data?.email_verified; - + const isEmailConfirmationRequired = + data.user.identities && + data.user.identities.length > 0 && + !data.user.identities[0].identity_data?.email_verified; + if (isEmailConfirmationRequired) { // 인증 메일 전송 성공 메시지와 이메일 확인 안내 - showAuthToast('회원가입 성공', '인증 메일이 발송되었습니다. 스팸 폴더도 확인해주세요.', 'default'); - console.log('인증 메일 발송됨:', email); - - return { - error: null, - user: data.user, - message: '이메일 인증 필요', - emailConfirmationRequired: true + showAuthToast( + "회원가입 성공", + "인증 메일이 발송되었습니다. 스팸 폴더도 확인해주세요.", + "default" + ); + authLogger.info("인증 메일 발송됨:", email); + + return { + error: null, + user: data.user, + message: "이메일 인증 필요", + emailConfirmationRequired: true, }; } else { - showAuthToast('회원가입 성공', '환영합니다!', 'default'); + showAuthToast("회원가입 성공", "환영합니다!", "default"); return { error: null, user: data.user }; } } - + // 사용자 데이터가 없는 경우 (드물게 발생) - console.warn('회원가입 응답은 성공했지만 사용자 데이터가 없습니다'); - showAuthToast('회원가입 성공', '계정이 생성되었습니다. 이메일 인증을 완료한 후 로그인해주세요.', 'default'); - - return { - error: null, + authLogger.warn("회원가입 응답은 성공했지만 사용자 데이터가 없습니다"); + showAuthToast( + "회원가입 성공", + "계정이 생성되었습니다. 이메일 인증을 완료한 후 로그인해주세요.", + "default" + ); + + return { + error: null, user: { email }, - message: '회원가입 완료', - emailConfirmationRequired: true + message: "회원가입 완료", + emailConfirmationRequired: true, }; } catch (error: any) { - console.error('회원가입 중 예외 발생:', error); - - const errorMessage = error.message || '알 수 없는 오류가 발생했습니다.'; - showAuthToast('회원가입 오류', errorMessage, 'destructive'); - + authLogger.error("회원가입 중 예외 발생:", error); + + const errorMessage = error.message || "알 수 없는 오류가 발생했습니다."; + showAuthToast("회원가입 오류", errorMessage, "destructive"); + return { error: { message: errorMessage }, user: null }; } }; diff --git a/src/contexts/auth/types.ts b/src/contexts/auth/types.ts index 4019313..7340404 100644 --- a/src/contexts/auth/types.ts +++ b/src/contexts/auth/types.ts @@ -1,26 +1,53 @@ - -import { Models } from 'appwrite'; +import { Models } from "appwrite"; +import type { ApiError } from "@/types/common"; /** * Appwrite 초기화 상태 반환 타입 */ -export type AppwriteInitializationStatus = { +export interface AppwriteInitializationStatus { isInitialized: boolean; error: Error | null; -}; +} + +/** + * 인증 응답 타입 + */ +export interface AuthResponse { + error: ApiError | null; + user?: Models.User; +} + +/** + * 회원가입 응답 타입 + */ +export interface SignUpResponse { + error: ApiError | null; + user: Models.User | null; +} + +/** + * 비밀번호 재설정 응답 타입 + */ +export interface ResetPasswordResponse { + error: ApiError | null; +} /** * 인증 컨텍스트 타입 */ -export type AuthContextType = { +export interface AuthContextType { session: Models.Session | null; user: Models.User | null; loading: boolean; error: Error | null; appwriteInitialized: boolean; reinitializeAppwrite: () => AppwriteInitializationStatus; - signIn: (email: string, password: string) => Promise<{ error: any; user?: any }>; - signUp: (email: string, password: string, username: string) => Promise<{ error: any, user: any }>; + signIn: (email: string, password: string) => Promise; + signUp: ( + email: string, + password: string, + username: string + ) => Promise; signOut: () => Promise; - resetPassword: (email: string) => Promise<{ error: any }>; -}; + resetPassword: (email: string) => Promise; +} diff --git a/src/contexts/auth/useAuth.ts b/src/contexts/auth/useAuth.ts index 76fdaf4..f076a72 100644 --- a/src/contexts/auth/useAuth.ts +++ b/src/contexts/auth/useAuth.ts @@ -1,7 +1,6 @@ - -import { useContext } from 'react'; -import { AuthContext } from './AuthContext'; -import { AuthContextType } from './types'; +import { useContext } from "react"; +import { AuthContext } from "./AuthContext"; +import { AuthContextType } from "./types"; /** * 인증 컨텍스트에 접근하기 위한 커스텀 훅 @@ -10,7 +9,7 @@ import { AuthContextType } from './types'; export const useAuth = () => { const context = useContext(AuthContext); if (context === undefined) { - throw new Error('useAuth는 AuthProvider 내부에서 사용해야 합니다'); + throw new Error("useAuth는 AuthProvider 내부에서 사용해야 합니다"); } return context; }; diff --git a/src/contexts/budget/BudgetContext.tsx b/src/contexts/budget/BudgetContext.tsx index ed57b56..e25084b 100644 --- a/src/contexts/budget/BudgetContext.tsx +++ b/src/contexts/budget/BudgetContext.tsx @@ -1,13 +1,14 @@ - -import React from 'react'; -import { useBudgetState } from './useBudgetState'; -import { BudgetContext, BudgetContextType } from './useBudget'; -import { BudgetPeriod, Transaction, BudgetData } from './types'; +import React from "react"; +import { useBudgetState } from "./useBudgetState"; +import { BudgetContext, BudgetContextType } from "./useBudget"; +import { BudgetPeriod, Transaction, BudgetData } from "./types"; // 컨텍스트 프로바이더 컴포넌트 -export const BudgetProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { +export const BudgetProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { const budgetState = useBudgetState(); - + return ( {children} @@ -16,8 +17,8 @@ export const BudgetProvider: React.FC<{ children: React.ReactNode }> = ({ childr }; // useBudget 훅은 useBudget.ts 파일로 이동했습니다 -export { useBudget } from './useBudget'; -export type { BudgetContextType } from './useBudget'; +export { useBudget } from "./useBudget"; +export type { BudgetContextType } from "./useBudget"; // types.ts에서 타입들을 export type으로 내보냅니다 -export type { BudgetPeriod, Transaction, BudgetData } from './types'; +export type { BudgetPeriod, Transaction, BudgetData } from "./types"; diff --git a/src/contexts/budget/budgetUtils.ts b/src/contexts/budget/budgetUtils.ts index 3259846..7598164 100644 --- a/src/contexts/budget/budgetUtils.ts +++ b/src/contexts/budget/budgetUtils.ts @@ -1,24 +1,24 @@ - -import { BudgetData, Transaction } from './types'; -import { format } from 'date-fns'; +import { BudgetData, Transaction } from "./types"; +import { logger } from "@/utils/logger"; +import { format } from "date-fns"; // 기본 예산 데이터 export const getInitialBudgetData = (): BudgetData => ({ daily: { targetAmount: 0, spentAmount: 0, - remainingAmount: 0 + remainingAmount: 0, }, weekly: { targetAmount: 0, spentAmount: 0, - remainingAmount: 0 + remainingAmount: 0, }, monthly: { targetAmount: 0, spentAmount: 0, - remainingAmount: 0 - } + remainingAmount: 0, + }, }); // 기본 카테고리 예산 값 (수출) @@ -31,7 +31,7 @@ export const safeStorage = { localStorage.setItem(key, JSON.stringify(value)); return true; } catch (err) { - console.error(`스토리지 저장 오류 (${key}):`, err); + logger.error(`스토리지 저장 오류 (${key}):`, err); return false; } }, @@ -40,56 +40,63 @@ export const safeStorage = { const item = localStorage.getItem(key); return item ? JSON.parse(item) : defaultValue; } catch (err) { - console.error(`스토리지 로드 오류 (${key}):`, err); + logger.error(`스토리지 로드 오류 (${key}):`, err); return defaultValue; } - } + }, }; // 예산 데이터 스토리지에서 로드 export const safelyLoadBudgetData = (): BudgetData => { try { - const budgetDataStr = localStorage.getItem('budgetData'); - + const budgetDataStr = localStorage.getItem("budgetData"); + if (budgetDataStr) { const parsedData = JSON.parse(budgetDataStr); - + // 데이터 구조 검증 (필요한 키가 있는지 확인) - if (parsedData && - typeof parsedData === 'object' && - 'daily' in parsedData && - 'weekly' in parsedData && - 'monthly' in parsedData) { + if ( + parsedData && + typeof parsedData === "object" && + "daily" in parsedData && + "weekly" in parsedData && + "monthly" in parsedData + ) { return parsedData; } else { - console.warn('저장된 예산 데이터 구조가 유효하지 않습니다. 기본값을 반환합니다.'); + logger.warn( + "저장된 예산 데이터 구조가 유효하지 않습니다. 기본값을 반환합니다." + ); } } } catch (error) { - console.error('예산 데이터 로드 중 오류:', error); + logger.error("예산 데이터 로드 중 오류:", error); } - + // 오류 발생 또는 유효하지 않은 데이터인 경우 기본값 반환 return getInitialBudgetData(); }; // 지출 금액 계산 함수 -export const calculateSpentAmounts = (transactions: Transaction[], budgetData: BudgetData): BudgetData => { +export const calculateSpentAmounts = ( + transactions: Transaction[], + budgetData: BudgetData +): BudgetData => { // 기존 예산 데이터 복사 const newBudgetData = JSON.parse(JSON.stringify(budgetData)); - + // 지출 트랜잭션만 필터링 - const expenseTransactions = transactions.filter(t => t.type === 'expense'); - + const expenseTransactions = transactions.filter((t) => t.type === "expense"); + // 현재 날짜 정보 const now = new Date(); const currentDay = now.getDate(); const currentMonth = now.getMonth(); const currentYear = now.getFullYear(); - const todayStr = format(now, 'yyyy-MM-dd'); - + const todayStr = format(now, "yyyy-MM-dd"); + // 월간 지출 합계 (현재 월의 모든 지출) - const monthlyExpenses = expenseTransactions.filter(t => { + const monthlyExpenses = expenseTransactions.filter((t) => { try { const transactionDate = new Date(t.date); return ( @@ -100,9 +107,9 @@ export const calculateSpentAmounts = (transactions: Transaction[], budgetData: B return false; // 날짜 파싱 오류 시 포함하지 않음 } }); - + // 주간 지출 합계 (최근 7일) - const weeklyExpenses = expenseTransactions.filter(t => { + const weeklyExpenses = expenseTransactions.filter((t) => { try { const transactionDate = new Date(t.date); const diffTime = now.getTime() - transactionDate.getTime(); @@ -112,68 +119,90 @@ export const calculateSpentAmounts = (transactions: Transaction[], budgetData: B return false; } }); - + // 일일 지출 합계 (오늘) - const dailyExpenses = expenseTransactions.filter(t => { + const dailyExpenses = expenseTransactions.filter((t) => { try { - const transactionDateStr = format(new Date(t.date), 'yyyy-MM-dd'); + const transactionDateStr = format(new Date(t.date), "yyyy-MM-dd"); return transactionDateStr === todayStr; } catch (e) { return false; } }); - + // 계산된 지출 금액 const dailyTotal = dailyExpenses.reduce((sum, t) => sum + t.amount, 0); const weeklyTotal = weeklyExpenses.reduce((sum, t) => sum + t.amount, 0); const monthlyTotal = monthlyExpenses.reduce((sum, t) => sum + t.amount, 0); - + // 예산 데이터에 적용 newBudgetData.daily.spentAmount = dailyTotal; - newBudgetData.daily.remainingAmount = Math.max(0, newBudgetData.daily.targetAmount - dailyTotal); - + newBudgetData.daily.remainingAmount = Math.max( + 0, + newBudgetData.daily.targetAmount - dailyTotal + ); + newBudgetData.weekly.spentAmount = weeklyTotal; - newBudgetData.weekly.remainingAmount = Math.max(0, newBudgetData.weekly.targetAmount - weeklyTotal); - + newBudgetData.weekly.remainingAmount = Math.max( + 0, + newBudgetData.weekly.targetAmount - weeklyTotal + ); + newBudgetData.monthly.spentAmount = monthlyTotal; - newBudgetData.monthly.remainingAmount = Math.max(0, newBudgetData.monthly.targetAmount - monthlyTotal); - + newBudgetData.monthly.remainingAmount = Math.max( + 0, + newBudgetData.monthly.targetAmount - monthlyTotal + ); + return newBudgetData; }; // 예산 목표 업데이트 함수 export const calculateUpdatedBudgetData = ( - budgetData: BudgetData, - type: 'daily' | 'weekly' | 'monthly', + budgetData: BudgetData, + type: "daily" | "weekly" | "monthly", amount: number ): BudgetData => { const newBudgetData = JSON.parse(JSON.stringify(budgetData)); - + // 새로운 예산 목표 설정 및 남은 금액 계산 newBudgetData[type].targetAmount = amount; - newBudgetData[type].remainingAmount = Math.max(0, amount - newBudgetData[type].spentAmount); - + newBudgetData[type].remainingAmount = Math.max( + 0, + amount - newBudgetData[type].spentAmount + ); + // 월간 예산 기준으로 일일/주간 예산 자동 계산 (월간 예산이 설정된 경우만) - if (type === 'monthly' && amount > 0) { + if (type === "monthly" && amount > 0) { // 현재 날짜 기준으로 이번 달 남은 일수 계산 const today = new Date(); - const lastDayOfMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0).getDate(); + const lastDayOfMonth = new Date( + today.getFullYear(), + today.getMonth() + 1, + 0 + ).getDate(); const remainingDays = lastDayOfMonth - today.getDate() + 1; - + // 일일 예산 계산 (남은 금액 / 남은 일수) if (newBudgetData.daily.targetAmount === 0) { const dailyBudget = Math.round(amount / lastDayOfMonth); newBudgetData.daily.targetAmount = dailyBudget; - newBudgetData.daily.remainingAmount = Math.max(0, dailyBudget - newBudgetData.daily.spentAmount); + newBudgetData.daily.remainingAmount = Math.max( + 0, + dailyBudget - newBudgetData.daily.spentAmount + ); } - + // 주간 예산 계산 (월간 예산 / 4.3주) if (newBudgetData.weekly.targetAmount === 0) { const weeklyBudget = Math.round(amount / 4.3); newBudgetData.weekly.targetAmount = weeklyBudget; - newBudgetData.weekly.remainingAmount = Math.max(0, weeklyBudget - newBudgetData.weekly.spentAmount); + newBudgetData.weekly.remainingAmount = Math.max( + 0, + weeklyBudget - newBudgetData.weekly.spentAmount + ); } } - + return newBudgetData; }; diff --git a/src/contexts/budget/hooks/useBudgetBackup.ts b/src/contexts/budget/hooks/useBudgetBackup.ts index 24da7d3..01f3811 100644 --- a/src/contexts/budget/hooks/useBudgetBackup.ts +++ b/src/contexts/budget/hooks/useBudgetBackup.ts @@ -1,44 +1,47 @@ - -import { useEffect } from 'react'; -import { BudgetData, Transaction } from '../types'; +import { useEffect } from "react"; +import { logger } from "@/utils/logger"; +import { BudgetData, Transaction } from "../types"; // 예산 데이터 자동 백업 훅 export const useBudgetBackup = ( - budgetData: BudgetData, - categoryBudgets: Record, + budgetData: BudgetData, + categoryBudgets: Record, transactions: Transaction[] ) => { // 디버깅 및 자동 백업을 위한 효과 useEffect(() => { - console.log('BudgetState 훅 - 현재 상태:'); - console.log('- 예산 데이터:', budgetData); - console.log('- 카테고리 예산:', categoryBudgets); - console.log('- 트랜잭션 수:', transactions.length); - + logger.info("BudgetState 훅 - 현재 상태:"); + logger.info("- 예산 데이터:", budgetData); + logger.info("- 카테고리 예산:", categoryBudgets); + logger.info("- 트랜잭션 수:", transactions.length); + // 데이터 손실 방지를 위한 타이머 설정 const saveTimer = setInterval(() => { // 저장된 데이터 유효성 검사 및 백업 try { - const storedBudgetData = localStorage.getItem('budgetData'); - const storedCategoryBudgets = localStorage.getItem('categoryBudgets'); - const storedTransactions = localStorage.getItem('transactions'); - + const storedBudgetData = localStorage.getItem("budgetData"); + const storedCategoryBudgets = localStorage.getItem("categoryBudgets"); + const storedTransactions = localStorage.getItem("transactions"); + if (storedBudgetData) { - localStorage.setItem('budgetData_backup_auto', storedBudgetData); + localStorage.setItem("budgetData_backup_auto", storedBudgetData); } - + if (storedCategoryBudgets) { - localStorage.setItem('categoryBudgets_backup_auto', storedCategoryBudgets); + localStorage.setItem( + "categoryBudgets_backup_auto", + storedCategoryBudgets + ); } - + if (storedTransactions) { - localStorage.setItem('transactions_backup_auto', storedTransactions); + localStorage.setItem("transactions_backup_auto", storedTransactions); } } catch (error) { - console.error('자동 백업 중 오류:', error); + logger.error("자동 백업 중 오류:", error); } }, 5000); // 5초마다 백업 - + return () => { clearInterval(saveTimer); }; diff --git a/src/contexts/budget/hooks/useBudgetDataEvents.ts b/src/contexts/budget/hooks/useBudgetDataEvents.ts index d2b7657..50793d3 100644 --- a/src/contexts/budget/hooks/useBudgetDataEvents.ts +++ b/src/contexts/budget/hooks/useBudgetDataEvents.ts @@ -1,7 +1,7 @@ - -import { useEffect } from 'react'; -import { Transaction, BudgetData } from '../types'; -import { calculateSpentAmounts } from '../utils/spendingCalculation'; +import { useEffect } from "react"; +import { logger } from "@/utils/logger"; +import { Transaction, BudgetData } from "../types"; +import { calculateSpentAmounts } from "../utils/spendingCalculation"; export const useBudgetDataEvents = ( isInitialized: boolean, @@ -11,111 +11,140 @@ export const useBudgetDataEvents = ( ) => { // 트랜잭션 데이터가 변경될 때마다 예산 데이터 업데이트 useEffect(() => { - if (!isInitialized || transactions.length === 0) return; - - console.log('트랜잭션 변경 감지, 예산 데이터 업데이트 중...', transactions.length); - - setBudgetData(prevBudgetData => { + if (!isInitialized || transactions.length === 0) { + return; + } + + logger.info( + "트랜잭션 변경 감지, 예산 데이터 업데이트 중...", + transactions.length + ); + + setBudgetData((prevBudgetData) => { // 현재 지출 금액 계산 - const updatedBudgetData = calculateSpentAmounts(transactions, prevBudgetData); - + const updatedBudgetData = calculateSpentAmounts( + transactions, + prevBudgetData + ); + // 마지막 업데이트 시간 기록 setLastUpdateTime(Date.now()); - - console.log('예산 데이터 업데이트 완료:', updatedBudgetData); + + logger.info("예산 데이터 업데이트 완료:", updatedBudgetData); return updatedBudgetData; }); }, [isInitialized, transactions, setBudgetData, setLastUpdateTime]); // 트랜잭션 변경 감지 이벤트 useEffect(() => { - if (!isInitialized) return; - + if (!isInitialized) { + return; + } + const handleTransactionChanged = (event: CustomEvent) => { - console.log('트랜잭션 변경 이벤트 감지', event.detail); - - setBudgetData(prevBudgetData => { + logger.info("트랜잭션 변경 이벤트 감지", event.detail); + + setBudgetData((prevBudgetData) => { // 현재 지출 금액 계산 - const updatedBudgetData = calculateSpentAmounts(transactions, prevBudgetData); - + const updatedBudgetData = calculateSpentAmounts( + transactions, + prevBudgetData + ); + // 마지막 업데이트 시간 기록 setLastUpdateTime(Date.now()); - + return updatedBudgetData; }); }; - + // 이벤트 리스너 등록 - window.addEventListener('transactionChanged', handleTransactionChanged as EventListener); - + window.addEventListener( + "transactionChanged", + handleTransactionChanged as EventListener + ); + return () => { // 이벤트 리스너 제거 - window.removeEventListener('transactionChanged', handleTransactionChanged as EventListener); + window.removeEventListener( + "transactionChanged", + handleTransactionChanged as EventListener + ); }; }, [isInitialized, transactions, setBudgetData, setLastUpdateTime]); - + // 스토리지 이벤트 리스너 (다른 탭에서도 데이터 동기화) useEffect(() => { - if (!isInitialized) return; - + if (!isInitialized) { + return; + } + const handleStorageChange = (event: StorageEvent) => { - if (event.key === 'transactions') { - console.log('스토리지 이벤트 감지: 트랜잭션 데이터 변경'); - + if (event.key === "transactions") { + logger.info("스토리지 이벤트 감지: 트랜잭션 데이터 변경"); + try { // 새 트랜잭션 데이터로 예산 계산 if (event.newValue) { const newTransactions = JSON.parse(event.newValue); - - setBudgetData(prevBudgetData => { + + setBudgetData((prevBudgetData) => { // 현재 지출 금액 계산 - const updatedBudgetData = calculateSpentAmounts(newTransactions, prevBudgetData); - + const updatedBudgetData = calculateSpentAmounts( + newTransactions, + prevBudgetData + ); + // 마지막 업데이트 시간 기록 setLastUpdateTime(Date.now()); - + return updatedBudgetData; }); } } catch (error) { - console.error('스토리지 이벤트 처리 중 오류:', error); + logger.error("스토리지 이벤트 처리 중 오류:", error); } } }; - + // 이벤트 리스너 등록 - window.addEventListener('storage', handleStorageChange); - + window.addEventListener("storage", handleStorageChange); + return () => { // 이벤트 리스너 제거 - window.removeEventListener('storage', handleStorageChange); + window.removeEventListener("storage", handleStorageChange); }; }, [isInitialized, setBudgetData, setLastUpdateTime]); - - // 예산 데이터 수동 새로고침 이벤트 + + // 예산 데이터 수동 새로고침 이벤트 useEffect(() => { - if (!isInitialized) return; - + if (!isInitialized) { + return; + } + const handleBudgetRefresh = () => { - console.log('예산 데이터 수동 새로고침 이벤트 감지'); - - setBudgetData(prevBudgetData => { + logger.info("예산 데이터 수동 새로고침 이벤트 감지"); + + setBudgetData((prevBudgetData) => { // 현재 지출 금액 계산 - const updatedBudgetData = calculateSpentAmounts(transactions, prevBudgetData); - + const updatedBudgetData = calculateSpentAmounts( + transactions, + prevBudgetData + ); + // 마지막 업데이트 시간 기록 setLastUpdateTime(Date.now()); - + return updatedBudgetData; }); }; - + // 수동 새로고침 이벤트 등록 - window.addEventListener('budgetDataRefresh', handleBudgetRefresh); - + window.addEventListener("budgetDataRefresh", handleBudgetRefresh); + return () => { // 이벤트 리스너 제거 - window.removeEventListener('budgetDataRefresh', handleBudgetRefresh); + window.removeEventListener("budgetDataRefresh", handleBudgetRefresh); }; }, [isInitialized, transactions, setBudgetData, setLastUpdateTime]); }; diff --git a/src/contexts/budget/hooks/useBudgetDataLoad.ts b/src/contexts/budget/hooks/useBudgetDataLoad.ts index ad59ee7..d86b635 100644 --- a/src/contexts/budget/hooks/useBudgetDataLoad.ts +++ b/src/contexts/budget/hooks/useBudgetDataLoad.ts @@ -1,7 +1,7 @@ - -import { useEffect } from 'react'; -import { BudgetData } from '../types'; -import { safelyLoadBudgetData } from '../budgetUtils'; +import { useEffect } from "react"; +import { logger } from "@/utils/logger"; +import { BudgetData } from "../types"; +import { safelyLoadBudgetData } from "../budgetUtils"; /** * 예산 데이터 로드 및 초기화를 처리하는 훅 @@ -18,28 +18,28 @@ export const useBudgetDataLoad = ( // 예산 데이터 로드 함수 - 비동기 처리로 변경 const loadBudget = async () => { try { - console.log('예산 데이터 로드 시도 중...'); - + logger.info("예산 데이터 로드 시도 중..."); + // 비동기 작업을 마이크로태스크로 지연 - await new Promise(resolve => setTimeout(() => resolve(), 0)); - + await new Promise((resolve) => setTimeout(() => resolve(), 0)); + // 안전하게 데이터 로드 const loadedData = safelyLoadBudgetData(); - console.log('로드된 예산 데이터:', JSON.stringify(loadedData)); - + logger.info("로드된 예산 데이터:", JSON.stringify(loadedData)); + // 새로 로드한 데이터와 현재 데이터가 다를 때만 업데이트 if (JSON.stringify(loadedData) !== JSON.stringify(budgetData)) { - console.log('새 예산 데이터 감지됨, 상태 업데이트'); + logger.info("새 예산 데이터 감지됨, 상태 업데이트"); setBudgetData(loadedData); setLastUpdateTime(Date.now()); } - + // 초기화 상태 업데이트 if (!isInitialized) { setIsInitialized(true); } } catch (error) { - console.error('예산 데이터 로드 중 오류:', error); + logger.error("예산 데이터 로드 중 오류:", error); // 오류가 발생해도 앱을 사용할 수 있도록 기본 데이터 설정 setIsInitialized(true); } @@ -62,9 +62,9 @@ export const useBudgetDataLoad = ( } return loadedData; } catch (error) { - console.error('loadBudgetData 호출 중 오류:', error); + logger.error("loadBudgetData 호출 중 오류:", error); return budgetData; // 오류 발생 시 현재 데이터 유지 } - } + }, }; }; diff --git a/src/contexts/budget/hooks/useBudgetDataState.ts b/src/contexts/budget/hooks/useBudgetDataState.ts index 7b49682..783fdf3 100644 --- a/src/contexts/budget/hooks/useBudgetDataState.ts +++ b/src/contexts/budget/hooks/useBudgetDataState.ts @@ -1,12 +1,12 @@ - -import { useEffect } from 'react'; -import { BudgetData, BudgetPeriod, Transaction } from '../types'; -import { clearAllBudgetData } from '../storage'; -import { useBudgetState } from './useBudgetState'; -import { useBudgetDataLoad } from './useBudgetDataLoad'; -import { useBudgetDataEvents } from './useBudgetDataEvents'; -import { useBudgetGoalUpdate } from './useBudgetGoalUpdate'; -import { toast } from '@/hooks/useToast.wrapper'; +import { useEffect } from "react"; +import { logger } from "@/utils/logger"; +import { BudgetData, BudgetPeriod, Transaction } from "../types"; +import { clearAllBudgetData } from "../storage"; +import { useBudgetState } from "./useBudgetState"; +import { useBudgetDataLoad } from "./useBudgetDataLoad"; +import { useBudgetDataEvents } from "./useBudgetDataEvents"; +import { useBudgetGoalUpdate } from "./useBudgetGoalUpdate"; +import { toast } from "@/hooks/useToast.wrapper"; // 예산 데이터 상태 관리 훅 export const useBudgetDataState = (transactions: Transaction[]) => { @@ -19,7 +19,7 @@ export const useBudgetDataState = (transactions: Transaction[]) => { isInitialized, setIsInitialized, lastUpdateTime, - setLastUpdateTime + setLastUpdateTime, } = useBudgetState(); // 데이터 로드 기능 @@ -48,27 +48,32 @@ export const useBudgetDataState = (transactions: Transaction[]) => { // 예산 데이터 초기화 함수 const resetBudgetData = () => { try { - console.log('예산 데이터 초기화'); + logger.info("예산 데이터 초기화"); clearAllBudgetData(); - + // 로컬스토리지에서 다시 로드 loadBudgetData(); - + // 이벤트 발생시켜 다른 컴포넌트에 알림 - window.dispatchEvent(new Event('budgetDataUpdated')); + window.dispatchEvent(new Event("budgetDataUpdated")); } catch (error) { - console.error('예산 데이터 초기화 중 오류:', error); + logger.error("예산 데이터 초기화 중 오류:", error); toast({ title: "예산 초기화 실패", description: "예산 데이터를 초기화하는데 문제가 발생했습니다.", - variant: "destructive" + variant: "destructive", }); } }; // 디버깅 로그 추가 useEffect(() => { - console.log('최신 예산 데이터:', JSON.stringify(budgetData), '마지막 업데이트:', new Date(lastUpdateTime).toISOString()); + logger.info( + "최신 예산 데이터:", + JSON.stringify(budgetData), + "마지막 업데이트:", + new Date(lastUpdateTime).toISOString() + ); }, [budgetData, lastUpdateTime]); return { @@ -76,6 +81,6 @@ export const useBudgetDataState = (transactions: Transaction[]) => { selectedTab, setSelectedTab, handleBudgetGoalUpdate, - resetBudgetData + resetBudgetData, }; }; diff --git a/src/contexts/budget/hooks/useBudgetGoalUpdate.ts b/src/contexts/budget/hooks/useBudgetGoalUpdate.ts index 568c73a..8428cad 100644 --- a/src/contexts/budget/hooks/useBudgetGoalUpdate.ts +++ b/src/contexts/budget/hooks/useBudgetGoalUpdate.ts @@ -1,9 +1,12 @@ - -import { useCallback } from 'react'; -import { BudgetData, BudgetPeriod } from '../types'; -import { calculateUpdatedBudgetData, safelyLoadBudgetData } from '../budgetUtils'; -import { saveBudgetDataToStorage } from '../storage'; -import { toast } from '@/hooks/useToast.wrapper'; +import { useCallback } from "react"; +import { logger } from "@/utils/logger"; +import { BudgetData, BudgetPeriod } from "../types"; +import { + calculateUpdatedBudgetData, + safelyLoadBudgetData, +} from "../budgetUtils"; +import { saveBudgetDataToStorage } from "../storage"; +import { toast } from "@/hooks/useToast.wrapper"; /** * 예산 목표 업데이트 기능을 제공하는 훅 @@ -13,65 +16,72 @@ export const useBudgetGoalUpdate = ( setLastUpdateTime: (time: number) => void ) => { // 예산 목표 업데이트 함수 - const handleBudgetGoalUpdate = useCallback(( - type: BudgetPeriod, - amount: number - ) => { - try { - console.log(`예산 목표 업데이트 시작: ${type}, 금액: ${amount}`); - - // 금액이 유효한지 확인 - if (isNaN(amount) || amount <= 0) { - console.error('유효하지 않은 예산 금액:', amount); + const handleBudgetGoalUpdate = useCallback( + (type: BudgetPeriod, amount: number) => { + try { + logger.info(`예산 목표 업데이트 시작: ${type}, 금액: ${amount}`); + + // 금액이 유효한지 확인 + if (isNaN(amount) || amount <= 0) { + logger.error("유효하지 않은 예산 금액:", amount); + toast({ + title: "예산 설정 오류", + description: "유효한 예산 금액을 입력해주세요.", + variant: "destructive", + }); + return; + } + + // 현재 최신 예산 데이터 로드 (다른 곳에서 변경되었을 수 있음) + const currentBudgetData = safelyLoadBudgetData(); + + // 예산 데이터 업데이트 - 월간 기준으로 계산 + const updatedBudgetData = calculateUpdatedBudgetData( + currentBudgetData, + type, + amount + ); + logger.info("새 예산 데이터 계산됨:", updatedBudgetData); + + // 상태 및 스토리지 둘 다 업데이트 + setBudgetData(updatedBudgetData); + saveBudgetDataToStorage(updatedBudgetData); + setLastUpdateTime(Date.now()); + + // 이벤트 발생시켜 다른 컴포넌트에 알림 + window.dispatchEvent(new Event("budgetDataUpdated")); + + // 시간차를 두고 이벤트 추가 발생 (UI 갱신 확실히 하기 위함) + setTimeout(() => { + window.dispatchEvent(new Event("budgetDataUpdated")); + window.dispatchEvent( + new StorageEvent("storage", { + key: "budgetData", + newValue: JSON.stringify(updatedBudgetData), + }) + ); + }, 500); + + logger.info("예산 목표 업데이트 완료:", updatedBudgetData); + + // 성공 메시지 표시 + const periodText = + type === "daily" ? "일일" : type === "weekly" ? "주간" : "월간"; toast({ - title: "예산 설정 오류", - description: "유효한 예산 금액을 입력해주세요.", - variant: "destructive" + title: "예산 설정 완료", + description: `${periodText} 예산이 ${amount.toLocaleString()}원으로 설정되었습니다.`, + }); + } catch (error) { + logger.error("예산 목표 업데이트 중 오류:", error); + toast({ + title: "예산 업데이트 실패", + description: "예산 목표를 업데이트하는데 문제가 발생했습니다.", + variant: "destructive", }); - return; } - - // 현재 최신 예산 데이터 로드 (다른 곳에서 변경되었을 수 있음) - const currentBudgetData = safelyLoadBudgetData(); - - // 예산 데이터 업데이트 - 월간 기준으로 계산 - const updatedBudgetData = calculateUpdatedBudgetData(currentBudgetData, type, amount); - console.log('새 예산 데이터 계산됨:', updatedBudgetData); - - // 상태 및 스토리지 둘 다 업데이트 - setBudgetData(updatedBudgetData); - saveBudgetDataToStorage(updatedBudgetData); - setLastUpdateTime(Date.now()); - - // 이벤트 발생시켜 다른 컴포넌트에 알림 - window.dispatchEvent(new Event('budgetDataUpdated')); - - // 시간차를 두고 이벤트 추가 발생 (UI 갱신 확실히 하기 위함) - setTimeout(() => { - window.dispatchEvent(new Event('budgetDataUpdated')); - window.dispatchEvent(new StorageEvent('storage', { - key: 'budgetData', - newValue: JSON.stringify(updatedBudgetData) - })); - }, 500); - - console.log('예산 목표 업데이트 완료:', updatedBudgetData); - - // 성공 메시지 표시 - const periodText = type === 'daily' ? '일일' : type === 'weekly' ? '주간' : '월간'; - toast({ - title: "예산 설정 완료", - description: `${periodText} 예산이 ${amount.toLocaleString()}원으로 설정되었습니다.`, - }); - } catch (error) { - console.error('예산 목표 업데이트 중 오류:', error); - toast({ - title: "예산 업데이트 실패", - description: "예산 목표를 업데이트하는데 문제가 발생했습니다.", - variant: "destructive" - }); - } - }, []); + }, + [] + ); return handleBudgetGoalUpdate; }; diff --git a/src/contexts/budget/hooks/useBudgetReset.ts b/src/contexts/budget/hooks/useBudgetReset.ts index d9a6f91..de80d9d 100644 --- a/src/contexts/budget/hooks/useBudgetReset.ts +++ b/src/contexts/budget/hooks/useBudgetReset.ts @@ -1,6 +1,6 @@ - -import { useCallback } from 'react'; -import { toast } from '@/components/ui/use-toast'; +import { useCallback } from "react"; +import { logger } from "@/utils/logger"; +import { toast } from "@/components/ui/use-toast"; // 예산 데이터 리셋 훅 export const useBudgetReset = ( @@ -11,40 +11,40 @@ export const useBudgetReset = ( // 모든 데이터 리셋 함수 const resetBudgetData = useCallback(() => { try { - console.log('BudgetContext에서 데이터 리셋 시작'); - + logger.info("BudgetContext에서 데이터 리셋 시작"); + // 데이터 초기화 순서 중요 resetTransactions(); resetCategoryBudgets(); resetBudgetDataInternal(); - + // 이벤트 발생 try { - window.dispatchEvent(new Event('transactionUpdated')); - window.dispatchEvent(new Event('budgetDataUpdated')); - window.dispatchEvent(new Event('categoryBudgetsUpdated')); - window.dispatchEvent(new StorageEvent('storage')); + window.dispatchEvent(new Event("transactionUpdated")); + window.dispatchEvent(new Event("budgetDataUpdated")); + window.dispatchEvent(new Event("categoryBudgetsUpdated")); + window.dispatchEvent(new StorageEvent("storage")); } catch (e) { - console.error('이벤트 발생 오류:', e); + logger.error("이벤트 발생 오류:", e); } - - console.log('BudgetContext에서 데이터 리셋 완료'); - + + logger.info("BudgetContext에서 데이터 리셋 완료"); + // 토스트 알림 toast({ title: "모든 데이터 초기화", description: "예산과 지출 내역이 모두 초기화되었습니다.", }); - + return true; } catch (error) { - console.error('데이터 초기화 중 오류:', error); + logger.error("데이터 초기화 중 오류:", error); toast({ title: "초기화 실패", description: "데이터를 초기화하는 중 오류가 발생했습니다.", - variant: "destructive" + variant: "destructive", }); - + return false; } }, [resetTransactions, resetCategoryBudgets, resetBudgetDataInternal]); diff --git a/src/contexts/budget/hooks/useBudgetState.ts b/src/contexts/budget/hooks/useBudgetState.ts index 5b5c0f2..8144405 100644 --- a/src/contexts/budget/hooks/useBudgetState.ts +++ b/src/contexts/budget/hooks/useBudgetState.ts @@ -1,7 +1,7 @@ - -import { useState } from 'react'; -import { BudgetData, BudgetPeriod } from '../types'; -import { safelyLoadBudgetData } from '../budgetUtils'; +import { useState } from "react"; +import { logger } from "@/utils/logger"; +import { BudgetData, BudgetPeriod } from "../types"; +import { safelyLoadBudgetData } from "../budgetUtils"; /** * 예산 데이터의 기본 상태를 관리하는 훅 @@ -9,8 +9,8 @@ import { safelyLoadBudgetData } from '../budgetUtils'; export const useBudgetState = () => { // 초기 데이터 로드 시 safelyLoadBudgetData 함수 사용 const initialBudgetData = safelyLoadBudgetData(); - console.log('초기 예산 데이터 로드:', initialBudgetData); - + logger.info("초기 예산 데이터 로드:", initialBudgetData); + const [budgetData, setBudgetData] = useState(initialBudgetData); const [selectedTab, setSelectedTab] = useState("monthly"); // 초기값은 monthly로 변경 const [isInitialized, setIsInitialized] = useState(false); @@ -24,6 +24,6 @@ export const useBudgetState = () => { isInitialized, setIsInitialized, lastUpdateTime, - setLastUpdateTime + setLastUpdateTime, }; }; diff --git a/src/contexts/budget/hooks/useCategoryBudgetState.ts b/src/contexts/budget/hooks/useCategoryBudgetState.ts index 498746b..51ca141 100644 --- a/src/contexts/budget/hooks/useCategoryBudgetState.ts +++ b/src/contexts/budget/hooks/useCategoryBudgetState.ts @@ -1,116 +1,133 @@ - -import { useState, useEffect, useCallback } from 'react'; -import { - loadCategoryBudgetsFromStorage, - saveCategoryBudgetsToStorage, - clearAllCategoryBudgets -} from '../storage'; +import { useState, useEffect, useCallback } from "react"; +import { logger } from "@/utils/logger"; +import { + loadCategoryBudgetsFromStorage, + saveCategoryBudgetsToStorage, + clearAllCategoryBudgets, +} from "../storage"; // 카테고리 예산 상태 관리 훅 export const useCategoryBudgetState = () => { - const [categoryBudgets, setCategoryBudgets] = useState>( - loadCategoryBudgetsFromStorage() - ); + const [categoryBudgets, setCategoryBudgets] = useState< + Record + >(loadCategoryBudgetsFromStorage()); const [isInitialized, setIsInitialized] = useState(false); // 초기 로드 및 이벤트 리스너 설정 useEffect(() => { const loadCategories = () => { try { - console.log('카테고리 예산 로드 시도 중...'); + logger.info("카테고리 예산 로드 시도 중..."); const loaded = loadCategoryBudgetsFromStorage(); - console.log('카테고리 예산 로드됨:', loaded); + logger.info("카테고리 예산 로드됨:", loaded); setCategoryBudgets(loaded); - + // 최근 데이터 로드 시간 기록 - localStorage.setItem('lastCategoryBudgetLoadTime', new Date().toISOString()); - + localStorage.setItem( + "lastCategoryBudgetLoadTime", + new Date().toISOString() + ); + if (!isInitialized) { setIsInitialized(true); } } catch (error) { - console.error('카테고리 예산 로드 중 오류:', error); + logger.error("카테고리 예산 로드 중 오류:", error); } }; - + // 초기 로드 loadCategories(); - + // 이벤트 리스너 설정 const handleCategoryUpdate = (e?: StorageEvent) => { - console.log('카테고리 예산 업데이트 이벤트 감지:', e?.key); - if (!e || e.key === 'categoryBudgets' || e.key === null) { + logger.info("카테고리 예산 업데이트 이벤트 감지:", e?.key); + if (!e || e.key === "categoryBudgets" || e.key === null) { loadCategories(); } }; - - window.addEventListener('categoryBudgetsUpdated', () => handleCategoryUpdate()); - window.addEventListener('storage', handleCategoryUpdate); - window.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'visible') { - console.log('페이지 보임: 카테고리 예산 새로고침'); + + window.addEventListener("categoryBudgetsUpdated", () => + handleCategoryUpdate() + ); + window.addEventListener("storage", handleCategoryUpdate); + window.addEventListener("visibilitychange", () => { + if (document.visibilityState === "visible") { + logger.info("페이지 보임: 카테고리 예산 새로고침"); loadCategories(); } }); - window.addEventListener('focus', () => { - console.log('창 포커스: 카테고리 예산 새로고침'); + window.addEventListener("focus", () => { + logger.info("창 포커스: 카테고리 예산 새로고침"); loadCategories(); }); - + // 주기적 데이터 검사 const intervalId = setInterval(() => { - const lastSaveTime = localStorage.getItem('lastCategoryBudgetSaveTime'); - const lastLoadTime = localStorage.getItem('lastCategoryBudgetLoadTime'); - - if (lastSaveTime && lastLoadTime && new Date(lastSaveTime) > new Date(lastLoadTime)) { - console.log('새로운 카테고리 저장 감지됨, 데이터 다시 로드...'); + const lastSaveTime = localStorage.getItem("lastCategoryBudgetSaveTime"); + const lastLoadTime = localStorage.getItem("lastCategoryBudgetLoadTime"); + + if ( + lastSaveTime && + lastLoadTime && + new Date(lastSaveTime) > new Date(lastLoadTime) + ) { + logger.info("새로운 카테고리 저장 감지됨, 데이터 다시 로드..."); loadCategories(); } }, 1000); - + return () => { - window.removeEventListener('categoryBudgetsUpdated', () => handleCategoryUpdate()); - window.removeEventListener('storage', handleCategoryUpdate); - window.removeEventListener('visibilitychange', () => {}); - window.removeEventListener('focus', () => loadCategories()); + window.removeEventListener("categoryBudgetsUpdated", () => + handleCategoryUpdate() + ); + window.removeEventListener("storage", handleCategoryUpdate); + window.removeEventListener("visibilitychange", () => {}); + window.removeEventListener("focus", () => loadCategories()); clearInterval(intervalId); }; }, [isInitialized]); // 카테고리 예산 업데이트 함수 - const updateCategoryBudgets = useCallback((newCategoryBudgets: Record) => { - try { - console.log('카테고리 예산 업데이트:', newCategoryBudgets); - setCategoryBudgets(newCategoryBudgets); - saveCategoryBudgetsToStorage(newCategoryBudgets); - - // 저장 시간 업데이트 - localStorage.setItem('lastCategoryBudgetSaveTime', new Date().toISOString()); - } catch (error) { - console.error('카테고리 예산 업데이트 중 오류:', error); - } - }, []); + const updateCategoryBudgets = useCallback( + (newCategoryBudgets: Record) => { + try { + logger.info("카테고리 예산 업데이트:", newCategoryBudgets); + setCategoryBudgets(newCategoryBudgets); + saveCategoryBudgetsToStorage(newCategoryBudgets); + + // 저장 시간 업데이트 + localStorage.setItem( + "lastCategoryBudgetSaveTime", + new Date().toISOString() + ); + } catch (error) { + logger.error("카테고리 예산 업데이트 중 오류:", error); + } + }, + [] + ); // 카테고리 예산 초기화 함수 const resetCategoryBudgets = useCallback(() => { try { - console.log('카테고리 예산 초기화'); + logger.info("카테고리 예산 초기화"); clearAllCategoryBudgets(); setCategoryBudgets(loadCategoryBudgetsFromStorage()); } catch (error) { - console.error('카테고리 예산 초기화 중 오류:', error); + logger.error("카테고리 예산 초기화 중 오류:", error); } }, []); // 카테고리 예산 변경 시 로그 기록 useEffect(() => { - console.log('최신 카테고리 예산:', categoryBudgets); + logger.info("최신 카테고리 예산:", categoryBudgets); }, [categoryBudgets]); return { categoryBudgets, setCategoryBudgets: updateCategoryBudgets, updateCategoryBudgets, - resetCategoryBudgets + resetCategoryBudgets, }; }; diff --git a/src/contexts/budget/hooks/useCategorySpending.ts b/src/contexts/budget/hooks/useCategorySpending.ts index 8fe8b24..40ed126 100644 --- a/src/contexts/budget/hooks/useCategorySpending.ts +++ b/src/contexts/budget/hooks/useCategorySpending.ts @@ -1,11 +1,10 @@ - -import { useCallback } from 'react'; -import { Transaction } from '../types'; -import { calculateCategorySpending } from '../utils/categoryUtils'; +import { useCallback } from "react"; +import { Transaction } from "../types"; +import { calculateCategorySpending } from "../utils/categoryUtils"; // 카테고리별 지출 계산 훅 export const useCategorySpending = ( - transactions: Transaction[], + transactions: Transaction[], categoryBudgets: Record ) => { // 카테고리별 지출 계산 diff --git a/src/contexts/budget/hooks/useExtendedBudgetUpdate.ts b/src/contexts/budget/hooks/useExtendedBudgetUpdate.ts index 53945c4..537dfe2 100644 --- a/src/contexts/budget/hooks/useExtendedBudgetUpdate.ts +++ b/src/contexts/budget/hooks/useExtendedBudgetUpdate.ts @@ -1,6 +1,6 @@ +import { BudgetPeriod } from "../types"; -import { BudgetPeriod } from '../types'; - +import { logger } from "@/utils/logger"; /** * 예산 목표 업데이트 확장 함수를 제공하는 훅 * 예산 목표 업데이트와 카테고리 예산 업데이트를 통합합니다. @@ -11,30 +11,30 @@ export const useExtendedBudgetUpdate = ( ) => { // 예산 목표 업데이트 확장 함수 const extendedBudgetUpdate = ( - type: BudgetPeriod, - amount: number, + type: BudgetPeriod, + amount: number, newCategoryBudgets?: Record ) => { - console.log('확장 예산 업데이트 시작:', type, amount, newCategoryBudgets); - + logger.info("확장 예산 업데이트 시작:", type, amount, newCategoryBudgets); + // 타입에 상관없이 항상 월간으로 처리 - console.log(`예산 업데이트: 입력 금액=${amount}, 항상 월간으로 설정`); - handleBudgetUpdate('monthly', amount); - + logger.info(`예산 업데이트: 입력 금액=${amount}, 항상 월간으로 설정`); + handleBudgetUpdate("monthly", amount); + // 카테고리 예산 업데이트 (제공된 경우) if (newCategoryBudgets) { - console.log('카테고리 예산 업데이트:', newCategoryBudgets); + logger.info("카테고리 예산 업데이트:", newCategoryBudgets); setCategoryBudgets(newCategoryBudgets); - + // 카테고리 예산 업데이트 이벤트 발생 setTimeout(() => { - window.dispatchEvent(new Event('categoryBudgetsUpdated')); + window.dispatchEvent(new Event("categoryBudgetsUpdated")); }, 100); } - + // 모든 업데이트가 완료된 후 전역 예산 데이터 업데이트 이벤트 발생 setTimeout(() => { - window.dispatchEvent(new Event('budgetDataUpdated')); + window.dispatchEvent(new Event("budgetDataUpdated")); }, 200); }; diff --git a/src/contexts/budget/hooks/useTransactionState.ts b/src/contexts/budget/hooks/useTransactionState.ts index a613ac9..e1ab9c3 100644 --- a/src/contexts/budget/hooks/useTransactionState.ts +++ b/src/contexts/budget/hooks/useTransactionState.ts @@ -1,8 +1,11 @@ - -import { useState, useEffect } from 'react'; -import { Transaction } from '../types'; -import { loadTransactionsFromStorage, saveTransactionsToStorage } from '../storage/transactionStorage'; -import { v4 as uuidv4 } from 'uuid'; +import { useState, useEffect } from "react"; +import { logger } from "@/utils/logger"; +import { Transaction } from "../types"; +import { + loadTransactionsFromStorage, + saveTransactionsToStorage, +} from "../storage/transactionStorage"; +import { v4 as uuidv4 } from "uuid"; /** * 트랜잭션 상태를 관리하는 훅 @@ -16,10 +19,13 @@ export const useTransactionState = () => { useEffect(() => { try { const storedTransactions = loadTransactionsFromStorage(); - console.log('로컬 스토리지에서 트랜잭션 로드:', storedTransactions?.length || 0); + logger.info( + "로컬 스토리지에서 트랜잭션 로드:", + storedTransactions?.length || 0 + ); setTransactions(storedTransactions || []); } catch (error) { - console.error('트랜잭션 로드 중 오류 발생:', error); + logger.error("트랜잭션 로드 중 오류 발생:", error); // 오류 발생 시 빈 배열로 초기화 setTransactions([]); } @@ -29,54 +35,60 @@ export const useTransactionState = () => { useEffect(() => { try { if (transactions && transactions.length > 0) { - console.log('트랜잭션 저장 중:', transactions.length); + logger.info("트랜잭션 저장 중:", transactions.length); saveTransactionsToStorage(transactions); } } catch (error) { - console.error('트랜잭션 저장 중 오류 발생:', error); + logger.error("트랜잭션 저장 중 오류 발생:", error); } }, [transactions]); // 트랜잭션 추가 const addTransaction = (transaction: Transaction) => { try { - const newTransaction = { - ...transaction, + const newTransaction = { + ...transaction, id: transaction.id || uuidv4(), - localTimestamp: new Date().toISOString() + localTimestamp: new Date().toISOString(), }; - setTransactions(prevTransactions => [...(prevTransactions || []), newTransaction]); - console.log('트랜잭션 추가됨:', newTransaction); + setTransactions((prevTransactions) => [ + ...(prevTransactions || []), + newTransaction, + ]); + logger.info("트랜잭션 추가됨:", newTransaction); } catch (error) { - console.error('트랜잭션 추가 중 오류 발생:', error); + logger.error("트랜잭션 추가 중 오류 발생:", error); } }; // 트랜잭션 업데이트 const updateTransaction = (updatedTransaction: Transaction) => { try { - setTransactions(prevTransactions => - (prevTransactions || []).map(transaction => - transaction.id === updatedTransaction.id - ? { ...updatedTransaction, localTimestamp: new Date().toISOString() } + setTransactions((prevTransactions) => + (prevTransactions || []).map((transaction) => + transaction.id === updatedTransaction.id + ? { + ...updatedTransaction, + localTimestamp: new Date().toISOString(), + } : transaction ) ); - console.log('트랜잭션 업데이트됨:', updatedTransaction.id); + logger.info("트랜잭션 업데이트됨:", updatedTransaction.id); } catch (error) { - console.error('트랜잭션 업데이트 중 오류 발생:', error); + logger.error("트랜잭션 업데이트 중 오류 발생:", error); } }; // 트랜잭션 삭제 const deleteTransaction = (id: string) => { try { - setTransactions(prevTransactions => - (prevTransactions || []).filter(transaction => transaction.id !== id) + setTransactions((prevTransactions) => + (prevTransactions || []).filter((transaction) => transaction.id !== id) ); - console.log('트랜잭션 삭제됨:', id); + logger.info("트랜잭션 삭제됨:", id); } catch (error) { - console.error('트랜잭션 삭제 중 오류 발생:', error); + logger.error("트랜잭션 삭제 중 오류 발생:", error); } }; @@ -85,6 +97,6 @@ export const useTransactionState = () => { setTransactions, addTransaction, updateTransaction, - deleteTransaction + deleteTransaction, }; }; diff --git a/src/contexts/budget/index.ts b/src/contexts/budget/index.ts index 088ce82..25d3171 100644 --- a/src/contexts/budget/index.ts +++ b/src/contexts/budget/index.ts @@ -1,22 +1,21 @@ - -export * from './BudgetContext'; -export * from './budgetUtils'; -export * from './storageUtils'; +export * from "./BudgetContext"; +export * from "./budgetUtils"; +export * from "./storageUtils"; // types.ts에서 타입들을 내보냅니다 -export type { - BudgetPeriod, - BudgetPeriodData, - BudgetData, - CategoryBudget, - Transaction -} from './types'; +export type { + BudgetPeriod, + BudgetPeriodData, + BudgetData, + CategoryBudget, + Transaction, +} from "./types"; // Export hooks -export * from './hooks/useTransactionState'; -export * from './hooks/useBudgetDataState'; -export * from './hooks/useCategoryBudgetState'; -export * from './hooks/useCategorySpending'; -export * from './hooks/useBudgetBackup'; -export * from './hooks/useBudgetReset'; -export * from './hooks/useExtendedBudgetUpdate'; +export * from "./hooks/useTransactionState"; +export * from "./hooks/useBudgetDataState"; +export * from "./hooks/useCategoryBudgetState"; +export * from "./hooks/useCategorySpending"; +export * from "./hooks/useBudgetBackup"; +export * from "./hooks/useBudgetReset"; +export * from "./hooks/useExtendedBudgetUpdate"; diff --git a/src/contexts/budget/storage/budgetStorage.ts b/src/contexts/budget/storage/budgetStorage.ts index 0558d8f..ca8563b 100644 --- a/src/contexts/budget/storage/budgetStorage.ts +++ b/src/contexts/budget/storage/budgetStorage.ts @@ -1,7 +1,11 @@ - -import { BudgetData } from '../types'; -import { getInitialBudgetData, safelyLoadBudgetData, safeStorage } from '../budgetUtils'; -import { toast } from '@/hooks/useToast.wrapper'; +import { BudgetData } from "../types"; +import { storageLogger } from "@/utils/logger"; +import { + getInitialBudgetData, + safelyLoadBudgetData, + safeStorage, +} from "../budgetUtils"; +import { toast } from "@/hooks/useToast.wrapper"; /** * 예산 데이터 불러오기 @@ -10,14 +14,14 @@ export const loadBudgetDataFromStorage = (): BudgetData => { try { // 새로운 safelyLoadBudgetData 함수 사용 const budgetData = safelyLoadBudgetData(); - console.log('예산 데이터 로드 완료', budgetData); + storageLogger.info("예산 데이터 로드 완료", budgetData); return budgetData; } catch (error) { - console.error('예산 데이터 로드 중 오류:', error); + storageLogger.error("예산 데이터 로드 중 오류:", error); } - + // 새 사용자를 위한 기본 예산 데이터 저장 - console.log('기본 예산 데이터 설정'); + storageLogger.info("기본 예산 데이터 설정"); const initialData = getInitialBudgetData(); saveBudgetDataToStorage(initialData); return initialData; @@ -29,40 +33,46 @@ export const loadBudgetDataFromStorage = (): BudgetData => { export const saveBudgetDataToStorage = (budgetData: BudgetData): void => { try { // 유효성 검사 - if (!budgetData || !budgetData.monthly || !budgetData.daily || !budgetData.weekly) { - console.error('잘못된 예산 데이터 저장 시도:', budgetData); - throw new Error('잘못된 예산 데이터'); + if ( + !budgetData || + !budgetData.monthly || + !budgetData.daily || + !budgetData.weekly + ) { + storageLogger.error("잘못된 예산 데이터 저장 시도:", budgetData); + throw new Error("잘못된 예산 데이터"); } - + // 데이터 문자열로 변환 const dataString = JSON.stringify(budgetData); - + // 이전 예산과 비교하여 변경 여부 확인 let hasChanged = true; try { - const oldData = safeStorage.getItem('budgetData'); + const oldData = safeStorage.getItem("budgetData"); if (oldData) { // 월간 예산이 동일하면 변경되지 않은 것으로 판단 - hasChanged = oldData.monthly.targetAmount !== budgetData.monthly.targetAmount; + hasChanged = + oldData.monthly.targetAmount !== budgetData.monthly.targetAmount; } } catch (e) { - console.error('이전 예산 비교 오류:', e); + storageLogger.error("이전 예산 비교 오류:", e); } - + // 로컬 스토리지에 저장 - safeStorage.setItem('budgetData', budgetData); - console.log('예산 데이터 저장 완료', budgetData); - + safeStorage.setItem("budgetData", budgetData); + storageLogger.info("예산 데이터 저장 완료", budgetData); + // 중요: 즉시 자동 백업 (데이터 손실 방지) - safeStorage.setItem('budgetData_backup', budgetData); - safeStorage.setItem('lastBudgetSaveTime', new Date().toISOString()); - + safeStorage.setItem("budgetData_backup", budgetData); + safeStorage.setItem("lastBudgetSaveTime", new Date().toISOString()); + // 이벤트 발생 (단일 이벤트로 통합) - const event = new CustomEvent('budgetChanged', { - detail: { data: budgetData, hasChanged } + const event = new CustomEvent("budgetChanged", { + detail: { data: budgetData, hasChanged }, }); window.dispatchEvent(event); - + // toast 알림 (변경된 경우에만) if (hasChanged && budgetData.monthly.targetAmount > 0) { toast({ @@ -71,13 +81,13 @@ export const saveBudgetDataToStorage = (budgetData: BudgetData): void => { }); } } catch (error) { - console.error('예산 데이터 저장 오류:', error); - + storageLogger.error("예산 데이터 저장 오류:", error); + // 오류 발생 시 토스트 알림 toast({ title: "예산 저장 실패", description: "예산 데이터를 저장하는데 문제가 발생했습니다.", - variant: "destructive" + variant: "destructive", }); } }; @@ -87,25 +97,27 @@ export const saveBudgetDataToStorage = (budgetData: BudgetData): void => { */ export const clearAllBudgetData = (): void => { try { - localStorage.removeItem('budgetData'); - localStorage.removeItem('budgetData_backup'); - + localStorage.removeItem("budgetData"); + localStorage.removeItem("budgetData_backup"); + // 기본값으로 재설정 const initialData = getInitialBudgetData(); - safeStorage.setItem('budgetData', initialData); - safeStorage.setItem('budgetData_backup', initialData); - - console.log('예산 데이터가 초기화되었습니다.'); - + safeStorage.setItem("budgetData", initialData); + safeStorage.setItem("budgetData_backup", initialData); + + storageLogger.info("예산 데이터가 초기화되었습니다."); + // 이벤트 발생 - window.dispatchEvent(new Event('budgetDataUpdated')); - window.dispatchEvent(new StorageEvent('storage', { - key: 'budgetData', - newValue: JSON.stringify(initialData) - })); - + window.dispatchEvent(new Event("budgetDataUpdated")); + window.dispatchEvent( + new StorageEvent("storage", { + key: "budgetData", + newValue: JSON.stringify(initialData), + }) + ); + // 토스트 알림 (사용자가 직접 초기화한 경우만) - const isUserInitiated = document.visibilityState === 'visible'; + const isUserInitiated = document.visibilityState === "visible"; if (isUserInitiated) { toast({ title: "예산 초기화", @@ -113,11 +125,11 @@ export const clearAllBudgetData = (): void => { }); } } catch (error) { - console.error('예산 데이터 삭제 오류:', error); + storageLogger.error("예산 데이터 삭제 오류:", error); toast({ title: "초기화 실패", description: "예산 데이터를 초기화하는데 문제가 발생했습니다.", - variant: "destructive" + variant: "destructive", }); } }; diff --git a/src/contexts/budget/storage/categoryStorage.ts b/src/contexts/budget/storage/categoryStorage.ts index 4a7e821..9025490 100644 --- a/src/contexts/budget/storage/categoryStorage.ts +++ b/src/contexts/budget/storage/categoryStorage.ts @@ -1,7 +1,7 @@ - -import { DEFAULT_CATEGORY_BUDGETS } from '../budgetUtils'; -import { EXPENSE_CATEGORIES } from '@/constants/categoryIcons'; -import { toast } from '@/hooks/useToast.wrapper'; // 래퍼 사용 +import { DEFAULT_CATEGORY_BUDGETS } from "../budgetUtils"; +import { storageLogger } from "@/utils/logger"; +import { EXPENSE_CATEGORIES } from "@/constants/categoryIcons"; +import { toast } from "@/hooks/useToast.wrapper"; // 래퍼 사용 /** * 카테고리 예산 불러오기 @@ -9,60 +9,68 @@ import { toast } from '@/hooks/useToast.wrapper'; // 래퍼 사용 export const loadCategoryBudgetsFromStorage = (): Record => { try { // 메인 스토리지에서 시도 - const storedCategoryBudgets = localStorage.getItem('categoryBudgets'); + const storedCategoryBudgets = localStorage.getItem("categoryBudgets"); if (storedCategoryBudgets) { const parsed = JSON.parse(storedCategoryBudgets); - console.log('카테고리 예산 로드 완료:', parsed); - + storageLogger.info("카테고리 예산 로드 완료:", parsed); + // 모든 허용된 카테고리 포함 const filteredBudgets: Record = {}; - EXPENSE_CATEGORIES.forEach(category => { - // 이전 카테고리명이 있을 경우 새 카테고리명으로 값 이전 - if (category === '음식' && parsed['식비'] !== undefined) { - filteredBudgets[category] = parsed['식비']; - } else if (category === '교통' && parsed['교통비'] !== undefined) { - filteredBudgets[category] = parsed['교통비']; - } else if (category === '쇼핑' && parsed['생활비'] !== undefined) { - filteredBudgets[category] = parsed['생활비']; + EXPENSE_CATEGORIES.forEach((category) => { + // 이전 카테고리명이 있을 경우 새 카테고리명으로 값 이전 + if (category === "음식" && parsed["식비"] !== undefined) { + filteredBudgets[category] = parsed["식비"]; + } else if (category === "교통" && parsed["교통비"] !== undefined) { + filteredBudgets[category] = parsed["교통비"]; + } else if (category === "쇼핑" && parsed["생활비"] !== undefined) { + filteredBudgets[category] = parsed["생활비"]; } else { filteredBudgets[category] = parsed[category] || 0; } }); - + return filteredBudgets; } - + // 백업에서 시도 - const backupCategoryBudgets = localStorage.getItem('categoryBudgets_backup'); + const backupCategoryBudgets = localStorage.getItem( + "categoryBudgets_backup" + ); if (backupCategoryBudgets) { const parsedBackup = JSON.parse(backupCategoryBudgets); - console.log('백업에서 카테고리 예산 복구:', parsedBackup); - + storageLogger.info("백업에서 카테고리 예산 복구:", parsedBackup); + // 모든 허용된 카테고리 포함 const filteredBudgets: Record = {}; - EXPENSE_CATEGORIES.forEach(category => { + EXPENSE_CATEGORIES.forEach((category) => { // 이전 카테고리명이 있을 경우 새 카테고리명으로 값 이전 - if (category === '음식' && parsedBackup['식비'] !== undefined) { - filteredBudgets[category] = parsedBackup['식비']; - } else if (category === '교통' && parsedBackup['교통비'] !== undefined) { - filteredBudgets[category] = parsedBackup['교통비']; - } else if (category === '쇼핑' && parsedBackup['생활비'] !== undefined) { - filteredBudgets[category] = parsedBackup['생활비']; + if (category === "음식" && parsedBackup["식비"] !== undefined) { + filteredBudgets[category] = parsedBackup["식비"]; + } else if ( + category === "교통" && + parsedBackup["교통비"] !== undefined + ) { + filteredBudgets[category] = parsedBackup["교통비"]; + } else if ( + category === "쇼핑" && + parsedBackup["생활비"] !== undefined + ) { + filteredBudgets[category] = parsedBackup["생활비"]; } else { filteredBudgets[category] = parsedBackup[category] || 0; } }); - + // 메인 스토리지도 복구 - localStorage.setItem('categoryBudgets', JSON.stringify(filteredBudgets)); + localStorage.setItem("categoryBudgets", JSON.stringify(filteredBudgets)); return filteredBudgets; } } catch (error) { - console.error('카테고리 예산 데이터 파싱 오류:', error); + storageLogger.error("카테고리 예산 데이터 파싱 오류:", error); } - + // 새 사용자를 위한 기본 카테고리 예산 저장 - console.log('기본 카테고리 예산 설정'); + storageLogger.info("기본 카테고리 예산 설정"); saveCategoryBudgetsToStorage(DEFAULT_CATEGORY_BUDGETS); return DEFAULT_CATEGORY_BUDGETS; }; @@ -70,59 +78,76 @@ export const loadCategoryBudgetsFromStorage = (): Record => { /** * 카테고리 예산 저장 */ -export const saveCategoryBudgetsToStorage = (categoryBudgets: Record): void => { +export const saveCategoryBudgetsToStorage = ( + categoryBudgets: Record +): void => { try { // 이전 예산과 비교하여 변경 여부 확인 let hasChanged = true; try { - const oldDataString = localStorage.getItem('categoryBudgets'); + const oldDataString = localStorage.getItem("categoryBudgets"); if (oldDataString) { const oldData = JSON.parse(oldDataString); // 총 예산이 동일하면 변경되지 않은 것으로 판단 - const oldTotal = Object.values(oldData).reduce((sum: number, val: number) => sum + val, 0); - const newTotal = Object.values(categoryBudgets).reduce((sum: number, val: number) => sum + val, 0); + const oldTotal = Object.values(oldData).reduce( + (sum: number, val: number) => sum + val, + 0 + ); + const newTotal = Object.values(categoryBudgets).reduce( + (sum: number, val: number) => sum + val, + 0 + ); hasChanged = oldTotal !== newTotal; } } catch (e) { - console.error('이전 카테고리 예산 비교 오류:', e); + storageLogger.error("이전 카테고리 예산 비교 오류:", e); } - + // 모든 허용된 카테고리 포함하여 유지 const filteredBudgets: Record = {}; - EXPENSE_CATEGORIES.forEach(category => { + EXPENSE_CATEGORIES.forEach((category) => { // 교통비 -> 교통으로 표준화 - if (category === '교통') { - filteredBudgets[category] = categoryBudgets[category] || categoryBudgets['교통비'] || 0; - } else if (category === '음식') { - filteredBudgets[category] = categoryBudgets[category] || categoryBudgets['식비'] || 0; - } else if (category === '쇼핑') { - filteredBudgets[category] = categoryBudgets[category] || categoryBudgets['생활비'] || 0; + if (category === "교통") { + filteredBudgets[category] = + categoryBudgets[category] || categoryBudgets["교통비"] || 0; + } else if (category === "음식") { + filteredBudgets[category] = + categoryBudgets[category] || categoryBudgets["식비"] || 0; + } else if (category === "쇼핑") { + filteredBudgets[category] = + categoryBudgets[category] || categoryBudgets["생활비"] || 0; } else { filteredBudgets[category] = categoryBudgets[category] || 0; } }); - + // 데이터 문자열로 변환 const dataString = JSON.stringify(filteredBudgets); - + // 로컬 스토리지에 저장 - localStorage.setItem('categoryBudgets', dataString); + localStorage.setItem("categoryBudgets", dataString); // 백업 저장 - localStorage.setItem('categoryBudgets_backup', dataString); - - console.log('카테고리 예산 저장 완료:', filteredBudgets); - + localStorage.setItem("categoryBudgets_backup", dataString); + + storageLogger.info("카테고리 예산 저장 완료:", filteredBudgets); + // 단일 이벤트로 통합 - const event = new CustomEvent('categoryBudgetsChanged', { - detail: { data: filteredBudgets, hasChanged } + const event = new CustomEvent("categoryBudgetsChanged", { + detail: { data: filteredBudgets, hasChanged }, }); window.dispatchEvent(event); - + // 마지막 저장 시간 기록 (데이터 검증용) - localStorage.setItem('lastCategoryBudgetSaveTime', new Date().toISOString()); - + localStorage.setItem( + "lastCategoryBudgetSaveTime", + new Date().toISOString() + ); + // 토스트 알림 (변경된 경우에만) - const totalBudget = Object.values(filteredBudgets).reduce((sum, val) => sum + val, 0); + const totalBudget = Object.values(filteredBudgets).reduce( + (sum, val) => sum + val, + 0 + ); if (hasChanged && totalBudget > 0) { toast({ title: "카테고리 예산 저장 완료", @@ -130,13 +155,13 @@ export const saveCategoryBudgetsToStorage = (categoryBudgets: Record { try { - localStorage.removeItem('categoryBudgets'); - localStorage.removeItem('categoryBudgets_backup'); - + localStorage.removeItem("categoryBudgets"); + localStorage.removeItem("categoryBudgets_backup"); + // 기본값으로 재설정 (4개 카테고리만) const dataString = JSON.stringify(DEFAULT_CATEGORY_BUDGETS); - localStorage.setItem('categoryBudgets', dataString); - localStorage.setItem('categoryBudgets_backup', dataString); - - console.log('카테고리 예산이 초기화되었습니다.'); - + localStorage.setItem("categoryBudgets", dataString); + localStorage.setItem("categoryBudgets_backup", dataString); + + storageLogger.info("카테고리 예산이 초기화되었습니다."); + // 이벤트 발생 - window.dispatchEvent(new Event('categoryBudgetsUpdated')); - window.dispatchEvent(new StorageEvent('storage', { - key: 'categoryBudgets', - newValue: dataString - })); - + window.dispatchEvent(new Event("categoryBudgetsUpdated")); + window.dispatchEvent( + new StorageEvent("storage", { + key: "categoryBudgets", + newValue: dataString, + }) + ); + // 토스트 알림 toast({ title: "카테고리 예산 초기화", description: "모든 카테고리 예산이 기본값으로 초기화되었습니다.", }); } catch (error) { - console.error('카테고리 예산 삭제 오류:', error); - + storageLogger.error("카테고리 예산 삭제 오류:", error); + // 오류 발생 시 토스트 알림 toast({ title: "초기화 실패", description: "카테고리 예산을 초기화하는데 문제가 발생했습니다.", - variant: "destructive" + variant: "destructive", }); } }; diff --git a/src/contexts/budget/storage/index.ts b/src/contexts/budget/storage/index.ts index 57c06d4..76ebe88 100644 --- a/src/contexts/budget/storage/index.ts +++ b/src/contexts/budget/storage/index.ts @@ -1,26 +1,23 @@ - // 트랜잭션 관련 함수 내보내기 export { loadTransactionsFromStorage, saveTransactionsToStorage, - clearAllTransactions -} from './transactionStorage'; + clearAllTransactions, +} from "./transactionStorage"; // 예산 데이터 관련 함수 내보내기 export { loadBudgetDataFromStorage, saveBudgetDataToStorage, - clearAllBudgetData -} from './budgetStorage'; + clearAllBudgetData, +} from "./budgetStorage"; // 카테고리 관련 함수 내보내기 export { loadCategoryBudgetsFromStorage, saveCategoryBudgetsToStorage, - clearAllCategoryBudgets -} from './categoryStorage'; + clearAllCategoryBudgets, +} from "./categoryStorage"; // 데이터 초기화 함수 내보내기 -export { - resetAllData -} from './resetStorage'; +export { resetAllData } from "./resetStorage"; diff --git a/src/contexts/budget/storage/resetStorage.ts b/src/contexts/budget/storage/resetStorage.ts index e5b3212..b3d0f05 100644 --- a/src/contexts/budget/storage/resetStorage.ts +++ b/src/contexts/budget/storage/resetStorage.ts @@ -1,108 +1,126 @@ - -import { clearAllTransactions } from './transactionStorage'; -import { clearAllCategoryBudgets } from './categoryStorage'; -import { clearAllBudgetData } from './budgetStorage'; -import { DEFAULT_CATEGORY_BUDGETS } from '../budgetUtils'; -import { getInitialBudgetData } from '../budgetUtils'; -import { toast } from '@/hooks/useToast.wrapper'; +import { clearAllTransactions } from "./transactionStorage"; +import { storageLogger } from "@/utils/logger"; +import { clearAllCategoryBudgets } from "./categoryStorage"; +import { clearAllBudgetData } from "./budgetStorage"; +import { DEFAULT_CATEGORY_BUDGETS } from "../budgetUtils"; +import { getInitialBudgetData } from "../budgetUtils"; +import { toast } from "@/hooks/useToast.wrapper"; /** * 모든 데이터 초기화 (첫 로그인 상태) */ export const resetAllData = (): void => { - console.log('완전 초기화 시작 - resetAllData'); - + storageLogger.info("완전 초기화 시작 - resetAllData"); + // 중요: 사용자 설정 관련 값 백업 - const dontShowWelcomeValue = localStorage.getItem('dontShowWelcome'); - const hasVisitedBefore = localStorage.getItem('hasVisitedBefore'); - console.log('resetAllData - 사용자 설정 백업:', { dontShowWelcome: dontShowWelcomeValue, hasVisitedBefore }); - + const dontShowWelcomeValue = localStorage.getItem("dontShowWelcome"); + const hasVisitedBefore = localStorage.getItem("hasVisitedBefore"); + storageLogger.info("resetAllData - 사용자 설정 백업:", { + dontShowWelcome: dontShowWelcomeValue, + hasVisitedBefore, + }); + // 모든 관련 데이터 키 목록 (분석 페이지의 데이터 포함) const dataKeys = [ - 'transactions', - 'transactions_backup', - 'categoryBudgets', - 'categoryBudgets_backup', - 'budgetData', - 'budgetData_backup', - 'budget', - 'monthlyExpenses', - 'categorySpending', - 'expenseAnalytics', - 'expenseHistory', - 'budgetHistory', - 'analyticsCache', - 'monthlyTotals', - 'analytics', - 'dailyBudget', - 'weeklyBudget', - 'monthlyBudget', - 'chartData', + "transactions", + "transactions_backup", + "categoryBudgets", + "categoryBudgets_backup", + "budgetData", + "budgetData_backup", + "budget", + "monthlyExpenses", + "categorySpending", + "expenseAnalytics", + "expenseHistory", + "budgetHistory", + "analyticsCache", + "monthlyTotals", + "analytics", + "dailyBudget", + "weeklyBudget", + "monthlyBudget", + "chartData", ]; - + try { // 모든 관련 데이터 키 삭제 - dataKeys.forEach(key => { - console.log(`삭제 중: ${key}`); + dataKeys.forEach((key) => { + storageLogger.info(`삭제 중: ${key}`); localStorage.removeItem(key); }); - + // 파일별 초기화 함수 호출 clearAllTransactions(); clearAllCategoryBudgets(); clearAllBudgetData(); - + // 기본 데이터로 초기화 (중복 삭제 방지를 위해 한 번만 실행) const initialBudgetData = getInitialBudgetData(); - localStorage.setItem('budgetData', JSON.stringify(initialBudgetData)); - localStorage.setItem('categoryBudgets', JSON.stringify(DEFAULT_CATEGORY_BUDGETS)); - + localStorage.setItem("budgetData", JSON.stringify(initialBudgetData)); + localStorage.setItem( + "categoryBudgets", + JSON.stringify(DEFAULT_CATEGORY_BUDGETS) + ); + // 중요: 트랜잭션은 반드시 빈 배열로 설정 - localStorage.setItem('transactions', JSON.stringify([])); - + localStorage.setItem("transactions", JSON.stringify([])); + // 중요: budgetData_backup도 설정하여 복구 가능하게 함 - localStorage.setItem('budgetData_backup', JSON.stringify(initialBudgetData)); - localStorage.setItem('categoryBudgets_backup', JSON.stringify(DEFAULT_CATEGORY_BUDGETS)); - localStorage.setItem('transactions_backup', JSON.stringify([])); - + localStorage.setItem( + "budgetData_backup", + JSON.stringify(initialBudgetData) + ); + localStorage.setItem( + "categoryBudgets_backup", + JSON.stringify(DEFAULT_CATEGORY_BUDGETS) + ); + localStorage.setItem("transactions_backup", JSON.stringify([])); + // 이벤트 발생시켜 데이터 로드 트리거 - 이벤트 순서 최적화 const events = [ - new Event('transactionUpdated'), - new Event('budgetDataUpdated'), - new Event('categoryBudgetsUpdated'), - new StorageEvent('storage') + new Event("transactionUpdated"), + new Event("budgetDataUpdated"), + new Event("categoryBudgetsUpdated"), + new StorageEvent("storage"), ]; - + // 모든 이벤트 동시에 발생 - events.forEach(event => window.dispatchEvent(event)); - + events.forEach((event) => window.dispatchEvent(event)); + // 중요: 사용자 설정 값 복원 (백업한 값이 있는 경우) if (dontShowWelcomeValue) { - console.log('resetAllData - dontShowWelcome 값 복원:', dontShowWelcomeValue); - localStorage.setItem('dontShowWelcome', dontShowWelcomeValue); + storageLogger.info( + "resetAllData - dontShowWelcome 값 복원:", + dontShowWelcomeValue + ); + localStorage.setItem("dontShowWelcome", dontShowWelcomeValue); } - + if (hasVisitedBefore) { - console.log('resetAllData - hasVisitedBefore 값 복원:', hasVisitedBefore); - localStorage.setItem('hasVisitedBefore', hasVisitedBefore); + storageLogger.info( + "resetAllData - hasVisitedBefore 값 복원:", + hasVisitedBefore + ); + localStorage.setItem("hasVisitedBefore", hasVisitedBefore); } - + // 첫 방문이 아닌 경우에만 토스트 알림 표시 - if (hasVisitedBefore === 'true') { + if (hasVisitedBefore === "true") { toast({ title: "데이터 초기화 완료", description: "모든 예산 및 지출 데이터가 초기화되었습니다.", }); } - - console.log('모든 데이터가 초기화되었습니다.'); + + storageLogger.info("모든 데이터가 초기화되었습니다."); } catch (error) { - console.error('데이터 초기화 중 오류 발생:', error); + storageLogger.error("데이터 초기화 중 오류 발생:", error); // 오류 발생 시 토스트 알림 toast({ title: "초기화 실패", description: "데이터를 초기화하는데 문제가 발생했습니다.", - variant: "destructive" + variant: "destructive", }); } }; diff --git a/src/contexts/budget/storage/transactionStorage.ts b/src/contexts/budget/storage/transactionStorage.ts index d34b45f..f9cfee3 100644 --- a/src/contexts/budget/storage/transactionStorage.ts +++ b/src/contexts/budget/storage/transactionStorage.ts @@ -1,6 +1,6 @@ - -import { Transaction } from '../types'; -import { toast } from '@/components/ui/use-toast'; +import { Transaction } from "../types"; +import { storageLogger } from "@/utils/logger"; +import { toast } from "@/components/ui/use-toast"; /** * 로컬 스토리지에서 트랜잭션 불러오기 @@ -8,87 +8,99 @@ import { toast } from '@/components/ui/use-toast'; export const loadTransactionsFromStorage = (): Transaction[] => { try { // 메인 스토리지에서 먼저 시도 - const storedTransactions = localStorage.getItem('transactions'); + const storedTransactions = localStorage.getItem("transactions"); if (storedTransactions) { try { const parsedData = JSON.parse(storedTransactions); - console.log('트랜잭션 로드 완료, 항목 수:', parsedData.length); - + storageLogger.info("트랜잭션 로드 완료, 항목 수:", parsedData.length); + // 트랜잭션 데이터 유효성 검사 if (Array.isArray(parsedData)) { return parsedData; } else { - console.error('트랜잭션 데이터가 배열이 아닙니다:', typeof parsedData); + storageLogger.error( + "트랜잭션 데이터가 배열이 아닙니다:", + typeof parsedData + ); return []; } } catch (e) { - console.error('트랜잭션 데이터 파싱 오류:', e); + storageLogger.error("트랜잭션 데이터 파싱 오류:", e); } } - + // 백업에서 시도 - const backupTransactions = localStorage.getItem('transactions_backup'); + const backupTransactions = localStorage.getItem("transactions_backup"); if (backupTransactions) { try { const parsedBackup = JSON.parse(backupTransactions); - console.log('백업에서 트랜잭션 복구, 항목 수:', parsedBackup.length); + storageLogger.info( + "백업에서 트랜잭션 복구, 항목 수:", + parsedBackup.length + ); // 메인 스토리지도 복구 - localStorage.setItem('transactions', backupTransactions); + localStorage.setItem("transactions", backupTransactions); return parsedBackup; } catch (e) { - console.error('백업 트랜잭션 데이터 파싱 오류:', e); + storageLogger.error("백업 트랜잭션 데이터 파싱 오류:", e); } } } catch (error) { - console.error('트랜잭션 데이터 로드 중 오류:', error); + storageLogger.error("트랜잭션 데이터 로드 중 오류:", error); } - + // 데이터가 없을 경우 빈 배열 반환 - console.log('트랜잭션 데이터 없음, 빈 배열 반환'); + storageLogger.info("트랜잭션 데이터 없음, 빈 배열 반환"); return []; }; /** * 트랜잭션 저장 */ -export const saveTransactionsToStorage = (transactions: Transaction[]): void => { +export const saveTransactionsToStorage = ( + transactions: Transaction[] +): void => { try { - console.log('트랜잭션 저장 시작, 항목 수:', transactions.length); - + storageLogger.info("트랜잭션 저장 시작, 항목 수:", transactions.length); + // 먼저 문자열로 변환 const dataString = JSON.stringify(transactions); - - // 로컬 스토리지에 저장 - localStorage.setItem('transactions', dataString); + + // 로컬 스토리지에 저장 + localStorage.setItem("transactions", dataString); // 백업 저장 - localStorage.setItem('transactions_backup', dataString); - - console.log('트랜잭션 저장 완료, 항목 수:', transactions.length); - + localStorage.setItem("transactions_backup", dataString); + + storageLogger.info("트랜잭션 저장 완료, 항목 수:", transactions.length); + // 스토리지 이벤트 수동 트리거 (동일 창에서도 감지하기 위함) try { - window.dispatchEvent(new Event('transactionUpdated')); - window.dispatchEvent(new CustomEvent('transactionChanged', { - detail: { type: 'save', count: transactions.length } - })); - window.dispatchEvent(new StorageEvent('storage', { - key: 'transactions', - newValue: dataString - })); + window.dispatchEvent(new Event("transactionUpdated")); + window.dispatchEvent( + new CustomEvent("transactionChanged", { + detail: { type: "save", count: transactions.length }, + }) + ); + window.dispatchEvent( + new StorageEvent("storage", { + key: "transactions", + newValue: dataString, + }) + ); } catch (e) { - console.error('이벤트 발생 오류:', e); + storageLogger.error("이벤트 발생 오류:", e); } - + // 마지막 저장 시간 기록 (데이터 검증용) - localStorage.setItem('lastTransactionSaveTime', new Date().toISOString()); + localStorage.setItem("lastTransactionSaveTime", new Date().toISOString()); } catch (error) { - console.error('트랜잭션 저장 오류:', error); - + storageLogger.error("트랜잭션 저장 오류:", error); + // 오류 발생 시 토스트 알림 toast({ title: "지출 저장 실패", description: "지출 데이터를 저장하는데 문제가 발생했습니다.", - variant: "destructive" + variant: "destructive", }); } }; @@ -99,49 +111,56 @@ export const saveTransactionsToStorage = (transactions: Transaction[]): void => export const clearAllTransactions = (): void => { try { // 기존 키 완전 삭제 - localStorage.removeItem('transactions'); - localStorage.removeItem('transactions_backup'); - + localStorage.removeItem("transactions"); + localStorage.removeItem("transactions_backup"); + // 빈 배열을 저장하여 확실히 초기화 const emptyData = JSON.stringify([]); - localStorage.setItem('transactions', emptyData); - localStorage.setItem('transactions_backup', emptyData); - + localStorage.setItem("transactions", emptyData); + localStorage.setItem("transactions_backup", emptyData); + // 관련 백업 데이터도 모두 초기화 - const transactionKeys = Object.keys(localStorage).filter(key => - key.includes('transaction') || key.includes('expense') || key.includes('spending') + const transactionKeys = Object.keys(localStorage).filter( + (key) => + key.includes("transaction") || + key.includes("expense") || + key.includes("spending") ); - - transactionKeys.forEach(key => { + + transactionKeys.forEach((key) => { localStorage.removeItem(key); - console.log(`관련 트랜잭션 데이터 삭제: ${key}`); + storageLogger.info(`관련 트랜잭션 데이터 삭제: ${key}`); }); - - console.log('모든 트랜잭션이 삭제되었습니다.'); - + + storageLogger.info("모든 트랜잭션이 삭제되었습니다."); + // 스토리지 이벤트 수동 트리거 - window.dispatchEvent(new Event('transactionUpdated')); - window.dispatchEvent(new CustomEvent('transactionChanged', { - detail: { type: 'clear' } - })); - window.dispatchEvent(new StorageEvent('storage', { - key: 'transactions', - newValue: emptyData - })); - + window.dispatchEvent(new Event("transactionUpdated")); + window.dispatchEvent( + new CustomEvent("transactionChanged", { + detail: { type: "clear" }, + }) + ); + window.dispatchEvent( + new StorageEvent("storage", { + key: "transactions", + newValue: emptyData, + }) + ); + // 토스트 알림 toast({ title: "지출 내역 초기화", description: "모든 지출 내역이 삭제되었습니다.", }); } catch (error) { - console.error('트랜잭션 삭제 오류:', error); - + storageLogger.error("트랜잭션 삭제 오류:", error); + // 오류 발생 시 토스트 알림 toast({ title: "초기화 실패", description: "지출 내역을 초기화하는데 문제가 발생했습니다.", - variant: "destructive" + variant: "destructive", }); } }; diff --git a/src/contexts/budget/storageUtils.ts b/src/contexts/budget/storageUtils.ts index 3dacf10..c76cb42 100644 --- a/src/contexts/budget/storageUtils.ts +++ b/src/contexts/budget/storageUtils.ts @@ -1,4 +1,3 @@ - // 외부 파일로 리팩토링된 모든 스토리지 관련 함수를 통합적으로 내보내는 파일 // 기존 코드는 storage/ 디렉터리로 이동되었습니다. @@ -7,17 +6,17 @@ export { loadTransactionsFromStorage, saveTransactionsToStorage, clearAllTransactions, - + // 예산 데이터 관련 함수 loadBudgetDataFromStorage, saveBudgetDataToStorage, clearAllBudgetData, - + // 카테고리 관련 함수 loadCategoryBudgetsFromStorage, saveCategoryBudgetsToStorage, clearAllCategoryBudgets, - + // 데이터 초기화 함수 - resetAllData -} from './storage'; + resetAllData, +} from "./storage"; diff --git a/src/contexts/budget/types.ts b/src/contexts/budget/types.ts index c6c5da2..be422c0 100644 --- a/src/contexts/budget/types.ts +++ b/src/contexts/budget/types.ts @@ -1,6 +1,7 @@ +import type { PaymentMethod, TransactionType } from "@/types/common"; // 예산 관련 타입 정의 -export type BudgetPeriod = 'daily' | 'weekly' | 'monthly'; +export type BudgetPeriod = "daily" | "weekly" | "monthly"; export interface BudgetPeriodData { targetAmount: number; @@ -20,6 +21,13 @@ export interface CategoryBudget { total: number; } +// Payment Method 통계 타입 +export interface PaymentMethodStats { + method: PaymentMethod; + amount: number; + percentage: number; +} + // BudgetContext 타입 정의 export interface BudgetContextType { transactions: Transaction[]; @@ -30,21 +38,25 @@ export interface BudgetContextType { addTransaction: (transaction: Transaction) => void; updateTransaction: (updatedTransaction: Transaction) => void; deleteTransaction: (id: string) => void; - handleBudgetGoalUpdate: (type: BudgetPeriod, amount: number, newCategoryBudgets?: Record) => void; + handleBudgetGoalUpdate: ( + type: BudgetPeriod, + amount: number, + newCategoryBudgets?: Record + ) => void; getCategorySpending: () => CategoryBudget[]; - getPaymentMethodStats: () => { method: string; amount: number; percentage: number }[]; + getPaymentMethodStats: () => PaymentMethodStats[]; resetBudgetData?: () => void; // 선택적 필드로 유지 } -// Transaction 타입 (기존 TransactionCard에서 가져옴) +// Transaction 타입 개선 export interface Transaction { id: string; title: string; amount: number; date: string; category: string; - type: 'income' | 'expense'; - paymentMethod?: '신용카드' | '현금'; // 지출 방법 추가 + type: TransactionType; + paymentMethod?: PaymentMethod; notes?: string; localTimestamp?: string; // 로컬 수정 타임스탬프 추가 serverTimestamp?: string; // 서버 타임스탬프 추가 diff --git a/src/contexts/budget/useBudget.ts b/src/contexts/budget/useBudget.ts index 6d362a2..e9a2e45 100644 --- a/src/contexts/budget/useBudget.ts +++ b/src/contexts/budget/useBudget.ts @@ -1,9 +1,10 @@ - -import { createContext, useContext } from 'react'; -import { BudgetContextType } from './types'; +import { createContext, useContext } from "react"; +import { BudgetContextType } from "./types"; // BudgetContext 생성 -export const BudgetContext = createContext(undefined); +export const BudgetContext = createContext( + undefined +); // useBudget 훅 export const useBudget = () => { diff --git a/src/contexts/budget/useBudgetState.ts b/src/contexts/budget/useBudgetState.ts index 298c264..71e4952 100644 --- a/src/contexts/budget/useBudgetState.ts +++ b/src/contexts/budget/useBudgetState.ts @@ -1,11 +1,16 @@ - -import { useEffect } from 'react'; -import { Transaction, BudgetData, BudgetPeriod, BudgetContextType } from './types'; -import { useBudgetDataState } from './hooks/useBudgetDataState'; -import { useTransactionState } from './hooks/useTransactionState'; -import { useCategoryBudgetState } from './hooks/useCategoryBudgetState'; -import { useCategorySpending } from './hooks/useCategorySpending'; -import { useExtendedBudgetUpdate } from './hooks/useExtendedBudgetUpdate'; +import { useEffect } from "react"; +import { logger } from "@/utils/logger"; +import { + Transaction, + BudgetData, + BudgetPeriod, + BudgetContextType, +} from "./types"; +import { useBudgetDataState } from "./hooks/useBudgetDataState"; +import { useTransactionState } from "./hooks/useTransactionState"; +import { useCategoryBudgetState } from "./hooks/useCategoryBudgetState"; +import { useCategorySpending } from "./hooks/useCategorySpending"; +import { useExtendedBudgetUpdate } from "./hooks/useExtendedBudgetUpdate"; /** * 애플리케이션 예산 데이터 상태 관리를 위한 통합 훅 @@ -13,12 +18,8 @@ import { useExtendedBudgetUpdate } from './hooks/useExtendedBudgetUpdate'; */ export const useBudgetState = () => { // 트랜잭션 상태 관리 - const { - transactions, - addTransaction, - updateTransaction, - deleteTransaction - } = useTransactionState(); + const { transactions, addTransaction, updateTransaction, deleteTransaction } = + useTransactionState(); // 카테고리 예산 상태 관리 const { categoryBudgets, setCategoryBudgets } = useCategoryBudgetState(); @@ -29,9 +30,9 @@ export const useBudgetState = () => { selectedTab, setSelectedTab, handleBudgetGoalUpdate, - resetBudgetData + resetBudgetData, } = useBudgetDataState(transactions); - + // 확장된 예산 업데이트 로직 - 기존 로직과 호환성 유지 const extendedBudgetUpdate = useExtendedBudgetUpdate( handleBudgetGoalUpdate, @@ -39,48 +40,60 @@ export const useBudgetState = () => { ); // 카테고리별 지출 계산 - const { getCategorySpending } = useCategorySpending(transactions, categoryBudgets); + const { getCategorySpending } = useCategorySpending( + transactions, + categoryBudgets + ); // 결제 방법 통계 계산 함수 const getPaymentMethodStats = () => { // 지출 트랜잭션 필터링 - const expenseTransactions = transactions.filter(t => t.type === 'expense'); - + const expenseTransactions = transactions.filter( + (t) => t.type === "expense" + ); + // 총 지출 계산 - const totalExpense = expenseTransactions.reduce((acc, curr) => acc + curr.amount, 0); - + const totalExpense = expenseTransactions.reduce( + (acc, curr) => acc + curr.amount, + 0 + ); + // 결제 방법별 금액 계산 const cardExpense = expenseTransactions - .filter(t => t.paymentMethod === '신용카드' || !t.paymentMethod) // paymentMethod가 없으면 신용카드로 간주 + .filter((t) => t.paymentMethod === "신용카드" || !t.paymentMethod) // paymentMethod가 없으면 신용카드로 간주 .reduce((acc, curr) => acc + curr.amount, 0); - + const cashExpense = expenseTransactions - .filter(t => t.paymentMethod === '현금') + .filter((t) => t.paymentMethod === "현금") .reduce((acc, curr) => acc + curr.amount, 0); - + // 결과 배열 생성 - 금액이 큰 순서대로 정렬 const result = [ - { - method: '신용카드', + { + method: "신용카드", amount: cardExpense, - percentage: totalExpense > 0 ? (cardExpense / totalExpense) * 100 : 0 + percentage: totalExpense > 0 ? (cardExpense / totalExpense) * 100 : 0, }, - { - method: '현금', + { + method: "현금", amount: cashExpense, - percentage: totalExpense > 0 ? (cashExpense / totalExpense) * 100 : 0 - } + percentage: totalExpense > 0 ? (cashExpense / totalExpense) * 100 : 0, + }, ].sort((a, b) => b.amount - a.amount); - + return result; }; // 디버깅 로그 추가 useEffect(() => { - console.log('예산 상태 업데이트:', - '트랜잭션 수:', transactions.length, - '카테고리 예산:', categoryBudgets, - '예산 데이터:', budgetData + logger.info( + "예산 상태 업데이트:", + "트랜잭션 수:", + transactions.length, + "카테고리 예산:", + categoryBudgets, + "예산 데이터:", + budgetData ); }, [transactions, categoryBudgets, budgetData]); diff --git a/src/contexts/budget/utils/budgetCalculation.ts b/src/contexts/budget/utils/budgetCalculation.ts index eef28ec..f82c7ac 100644 --- a/src/contexts/budget/utils/budgetCalculation.ts +++ b/src/contexts/budget/utils/budgetCalculation.ts @@ -1,6 +1,6 @@ - -import { BudgetData, BudgetPeriod } from '../types'; -import { getInitialBudgetData } from './constants'; +import { BudgetData, BudgetPeriod } from "../types"; +import { logger } from "@/utils/logger"; +import { getInitialBudgetData } from "./constants"; // 예산 데이터 업데이트 계산 export const calculateUpdatedBudgetData = ( @@ -8,65 +8,67 @@ export const calculateUpdatedBudgetData = ( type: BudgetPeriod, amount: number ): BudgetData => { - console.log(`예산 업데이트 계산 시작: 타입=${type}, 금액=${amount}`); - + logger.info(`예산 업데이트 계산 시작: 타입=${type}, 금액=${amount}`); + // 값이 없거나 유효하지 않은 경우 로깅 if (!prevBudgetData) { - console.error('이전 예산 데이터가 없습니다. 기본값 사용.'); + logger.error("이전 예산 데이터가 없습니다. 기본값 사용."); prevBudgetData = getInitialBudgetData(); } - + // 일일/주간/월간 예산 금액 초기화 let monthlyAmount = 0; let weeklyAmount = 0; let dailyAmount = 0; - + // 항상 월간 예산을 기준으로 계산 (어떤 타입이 입력되든) - if (type === 'monthly') { + if (type === "monthly") { // 월간 예산이 입력된 경우 - 이를 기준으로 주간/일일 계산 monthlyAmount = amount; - } else if (type === 'weekly') { + } else if (type === "weekly") { // 주간 예산이 입력된 경우 - 이를 월간으로 환산 (4.345주/월 기준) monthlyAmount = Math.round(amount * 4.345); - } else if (type === 'daily') { + } else if (type === "daily") { // 일일 예산이 입력된 경우 - 이를 월간으로 환산 (30일/월 기준) monthlyAmount = Math.round(amount * 30); } - + // 월간 예산을 기준으로 주간/일일 예산 계산 weeklyAmount = Math.round(monthlyAmount / 4.345); // 한 달 평균 4.345주 - dailyAmount = Math.round(monthlyAmount / 30); // 한 달 평균 30일 - - console.log(`최종 예산 계산 결과: 월간=${monthlyAmount}원, 주간=${weeklyAmount}원, 일일=${dailyAmount}원`); - + dailyAmount = Math.round(monthlyAmount / 30); // 한 달 평균 30일 + + logger.info( + `최종 예산 계산 결과: 월간=${monthlyAmount}원, 주간=${weeklyAmount}원, 일일=${dailyAmount}원` + ); + // 로그에 이전 예산 데이터 출력 - console.log("이전 예산 데이터:", JSON.stringify(prevBudgetData)); - + logger.info("이전 예산 데이터:", JSON.stringify(prevBudgetData)); + // 이전 지출 데이터 보존 const dailySpent = prevBudgetData.daily?.spentAmount || 0; const weeklySpent = prevBudgetData.weekly?.spentAmount || 0; const monthlySpent = prevBudgetData.monthly?.spentAmount || 0; - + // 새 예산 데이터 생성 (spentAmount는 이전 값 유지) const updatedBudgetData = { daily: { targetAmount: dailyAmount, spentAmount: dailySpent, - remainingAmount: Math.max(0, dailyAmount - dailySpent) + remainingAmount: Math.max(0, dailyAmount - dailySpent), }, weekly: { targetAmount: weeklyAmount, spentAmount: weeklySpent, - remainingAmount: Math.max(0, weeklyAmount - weeklySpent) + remainingAmount: Math.max(0, weeklyAmount - weeklySpent), }, monthly: { targetAmount: monthlyAmount, spentAmount: monthlySpent, - remainingAmount: Math.max(0, monthlyAmount - monthlySpent) - } + remainingAmount: Math.max(0, monthlyAmount - monthlySpent), + }, }; - - console.log("새 예산 데이터:", JSON.stringify(updatedBudgetData)); - + + logger.info("새 예산 데이터:", JSON.stringify(updatedBudgetData)); + return updatedBudgetData; }; diff --git a/src/contexts/budget/utils/categoryUtils.ts b/src/contexts/budget/utils/categoryUtils.ts index 4b9ba01..11b195d 100644 --- a/src/contexts/budget/utils/categoryUtils.ts +++ b/src/contexts/budget/utils/categoryUtils.ts @@ -1,48 +1,47 @@ - -import { CategoryBudget, Transaction } from '../types'; -import { EXPENSE_CATEGORIES } from '@/constants/categoryIcons'; +import { CategoryBudget, Transaction } from "../types"; +import { EXPENSE_CATEGORIES } from "@/constants/categoryIcons"; // 카테고리별 지출 계산 export const calculateCategorySpending = ( transactions: Transaction[], categoryBudgets: Record ): CategoryBudget[] => { - const expenseTransactions = transactions.filter(t => t.type === 'expense'); + const expenseTransactions = transactions.filter((t) => t.type === "expense"); const categorySpending: Record = {}; - + // 모든 카테고리에 대해 초기값 0 설정 - Object.keys(categoryBudgets).forEach(category => { + Object.keys(categoryBudgets).forEach((category) => { // 정의된 카테고리만 유지 if (EXPENSE_CATEGORIES.includes(category)) { categorySpending[category] = 0; } }); - + // 지원되는 카테고리가 없을 경우 기본값 설정 if (Object.keys(categorySpending).length === 0) { - EXPENSE_CATEGORIES.forEach(category => { + EXPENSE_CATEGORIES.forEach((category) => { categorySpending[category] = 0; }); } - - expenseTransactions.forEach(t => { + + expenseTransactions.forEach((t) => { if (t.category in categorySpending) { categorySpending[t.category] += t.amount; } else if (EXPENSE_CATEGORIES.includes(t.category)) { // 지원되는 카테고리이지만 초기화되지 않은 경우 categorySpending[t.category] = t.amount; - } else if (t.category === '교통비') { + } else if (t.category === "교통비") { // 예전 카테고리명 '교통비'를 '교통'으로 매핑 - categorySpending['교통'] = (categorySpending['교통'] || 0) + t.amount; + categorySpending["교통"] = (categorySpending["교통"] || 0) + t.amount; } else { // 지원되지 않는 카테고리는 '기타'로 집계 - categorySpending['기타'] = (categorySpending['기타'] || 0) + t.amount; + categorySpending["기타"] = (categorySpending["기타"] || 0) + t.amount; } }); - - return EXPENSE_CATEGORIES.map(category => ({ + + return EXPENSE_CATEGORIES.map((category) => ({ title: category, current: categorySpending[category] || 0, - total: categoryBudgets[category] || 0 + total: categoryBudgets[category] || 0, })); }; diff --git a/src/contexts/budget/utils/constants.ts b/src/contexts/budget/utils/constants.ts index ce4dd42..b497746 100644 --- a/src/contexts/budget/utils/constants.ts +++ b/src/contexts/budget/utils/constants.ts @@ -1,12 +1,11 @@ - -import { BudgetData } from '../types'; +import { BudgetData } from "../types"; // 기본 데이터 상수 (기본값을 0으로 설정) export const DEFAULT_CATEGORY_BUDGETS: Record = { 음식: 0, 쇼핑: 0, 교통: 0, - 기타: 0 + 기타: 0, }; export const DEFAULT_MONTHLY_BUDGET = 0; @@ -17,17 +16,17 @@ export const getInitialBudgetData = (): BudgetData => { daily: { targetAmount: 0, spentAmount: 0, - remainingAmount: 0 + remainingAmount: 0, }, weekly: { targetAmount: 0, spentAmount: 0, - remainingAmount: 0 + remainingAmount: 0, }, monthly: { targetAmount: 0, spentAmount: 0, - remainingAmount: 0 - } + remainingAmount: 0, + }, }; }; diff --git a/src/contexts/budget/utils/spendingCalculation.ts b/src/contexts/budget/utils/spendingCalculation.ts index 794cb66..2d4ae2f 100644 --- a/src/contexts/budget/utils/spendingCalculation.ts +++ b/src/contexts/budget/utils/spendingCalculation.ts @@ -1,48 +1,58 @@ - -import { BudgetData, Transaction } from '../types'; -import { format, isWithinInterval, subDays, startOfMonth, endOfMonth, parseISO } from 'date-fns'; +import { BudgetData, Transaction } from "../types"; +import { logger } from "@/utils/logger"; +import { + format, + isWithinInterval, + subDays, + startOfMonth, + endOfMonth, + parseISO, +} from "date-fns"; // 지출액 계산 (일일, 주간, 월간) export const calculateSpentAmounts = ( transactions: Transaction[], prevBudgetData: BudgetData ): BudgetData => { - console.log("지출액 계산 시작, 트랜잭션 수:", transactions.length); - + logger.info("지출액 계산 시작, 트랜잭션 수:", transactions.length); + // 지출 거래 필터링 - const expenseTransactions = transactions.filter(t => t.type === 'expense'); - console.log("필터링된 지출 트랜잭션:", expenseTransactions.length); - + const expenseTransactions = transactions.filter((t) => t.type === "expense"); + logger.info("필터링된 지출 트랜잭션:", expenseTransactions.length); + // 현재 날짜 정보 const now = new Date(); - const today = format(now, 'yyyy-MM-dd'); - const currentMonth = format(now, 'yyyy-MM'); + const today = format(now, "yyyy-MM-dd"); + const currentMonth = format(now, "yyyy-MM"); const startOfCurrentMonth = startOfMonth(now); const endOfCurrentMonth = endOfMonth(now); - + // 각 지출 트랜잭션의 날짜 정보 로깅 expenseTransactions.forEach((t, index) => { - if (index < 10) { // 처음 10개만 로그 출력 (너무 많은 로그 방지) - console.log(`트랜잭션 ${index}: 날짜=${t.date}, 금액=${t.amount}, 카테고리=${t.category}`); + if (index < 10) { + // 처음 10개만 로그 출력 (너무 많은 로그 방지) + logger.info( + `트랜잭션 ${index}: 날짜=${t.date}, 금액=${t.amount}, 카테고리=${t.category}` + ); } }); // 날짜 문자열을 Date 객체로 변환하는 함수 const parseTransactionDate = (dateStr: string): Date | null => { try { - if (dateStr.includes('오늘')) { + if (dateStr.includes("오늘")) { return new Date(); } - - if (dateStr.includes('어제')) { + + if (dateStr.includes("어제")) { return subDays(new Date(), 1); } - + // "yyyy-MM-dd" 형식 확인 if (/^\d{4}-\d{2}-\d{2}/.test(dateStr)) { return parseISO(dateStr); } - + // "MM월 dd일" 형식 확인 (한국어 날짜) const koreanDateMatch = dateStr.match(/(\d+)월\s*(\d+)일/); if (koreanDateMatch) { @@ -51,80 +61,90 @@ export const calculateSpentAmounts = ( const year = now.getFullYear(); return new Date(year, month, day); } - + // 기타 날짜 문자열은 그대로 Date 생성자에 전달 return new Date(dateStr); } catch (e) { - console.error('날짜 파싱 실패:', dateStr, e); + logger.error("날짜 파싱 실패:", dateStr, e); return null; } }; // 월간 지출 계산 (현재 달의 모든 지출) - const monthlyExpenses = expenseTransactions.filter(t => { + const monthlyExpenses = expenseTransactions.filter((t) => { try { const transactionDate = parseTransactionDate(t.date); - if (!transactionDate) return false; - + if (!transactionDate) { + return false; + } + return isWithinInterval(transactionDate, { start: startOfCurrentMonth, - end: endOfCurrentMonth + end: endOfCurrentMonth, }); } catch (e) { - console.error('월간 지출 필터링 오류:', e); + logger.error("월간 지출 필터링 오류:", e); // 날짜 형식이 명확하지 않은 경우, 트랜잭션 날짜 문자열에 현재 월 문자열이 포함되어 있는지 확인 - return t.date.includes(currentMonth) || - t.date.includes(format(now, 'M월')) || - t.date.includes('이번 달'); + return ( + t.date.includes(currentMonth) || + t.date.includes(format(now, "M월")) || + t.date.includes("이번 달") + ); } }); - + // 주간 지출 계산 (최근 7일) - const weeklyExpenses = expenseTransactions.filter(t => { + const weeklyExpenses = expenseTransactions.filter((t) => { try { const transactionDate = parseTransactionDate(t.date); - if (!transactionDate) return false; - + if (!transactionDate) { + return false; + } + return isWithinInterval(transactionDate, { start: subDays(now, 7), - end: now + end: now, }); } catch (e) { // 날짜 형식이 명확하지 않은 경우, 최근에 추가된 항목으로 간주 - return t.date.includes('오늘') || - t.date.includes('어제') || - t.date.includes('이번 주') || - t.date.includes('일 전'); + return ( + t.date.includes("오늘") || + t.date.includes("어제") || + t.date.includes("이번 주") || + t.date.includes("일 전") + ); } }); - + // 일일 지출 계산 (오늘) - const dailyExpenses = expenseTransactions.filter(t => { + const dailyExpenses = expenseTransactions.filter((t) => { try { const transactionDate = parseTransactionDate(t.date); - if (!transactionDate) return false; - - return format(transactionDate, 'yyyy-MM-dd') === today; + if (!transactionDate) { + return false; + } + + return format(transactionDate, "yyyy-MM-dd") === today; } catch (e) { // 날짜 형식이 명확하지 않은 경우, 오늘 추가된 항목인지 확인 - return t.date.includes('오늘') || t.date.includes('지금'); + return t.date.includes("오늘") || t.date.includes("지금"); } }); - + // 계산된 지출 금액 const dailyTotal = dailyExpenses.reduce((sum, t) => sum + t.amount, 0); const weeklyTotal = weeklyExpenses.reduce((sum, t) => sum + t.amount, 0); const monthlyTotal = monthlyExpenses.reduce((sum, t) => sum + t.amount, 0); - - console.log("계산된 지출 금액:", { + + logger.info("계산된 지출 금액:", { 일일: dailyTotal, 주간: weeklyTotal, - 월간: monthlyTotal + 월간: monthlyTotal, }); // 예산 데이터에 적용 전 로그 - console.log("기존 예산 데이터:", prevBudgetData); - + logger.info("기존 예산 데이터:", prevBudgetData); + // 기존 예산 목표 유지 const dailyTarget = prevBudgetData?.daily?.targetAmount || 0; const weeklyTarget = prevBudgetData?.weekly?.targetAmount || 0; @@ -135,21 +155,21 @@ export const calculateSpentAmounts = ( daily: { targetAmount: dailyTarget, spentAmount: dailyTotal, - remainingAmount: Math.max(0, dailyTarget - dailyTotal) + remainingAmount: Math.max(0, dailyTarget - dailyTotal), }, weekly: { targetAmount: weeklyTarget, spentAmount: weeklyTotal, - remainingAmount: Math.max(0, weeklyTarget - weeklyTotal) + remainingAmount: Math.max(0, weeklyTarget - weeklyTotal), }, monthly: { targetAmount: monthlyTarget, spentAmount: monthlyTotal, - remainingAmount: Math.max(0, monthlyTarget - monthlyTotal) - } + remainingAmount: Math.max(0, monthlyTarget - monthlyTotal), + }, }; - - console.log("업데이트된 예산 데이터:", updatedBudget); - + + logger.info("업데이트된 예산 데이터:", updatedBudget); + return updatedBudget; }; diff --git a/src/contexts/budget/utils/storageUtils.ts b/src/contexts/budget/utils/storageUtils.ts index 066a2a9..54499fc 100644 --- a/src/contexts/budget/utils/storageUtils.ts +++ b/src/contexts/budget/utils/storageUtils.ts @@ -1,26 +1,30 @@ - -import { BudgetData } from '../types'; -import { getInitialBudgetData } from './constants'; -import { toast } from '@/hooks/useToast.wrapper'; +import { BudgetData } from "../types"; +import { storageLogger } from "@/utils/logger"; +import { getInitialBudgetData } from "./constants"; +import { toast } from "@/hooks/useToast.wrapper"; // 스토리지에서 안전하게 예산 데이터 가져오기 -export const safelyLoadBudgetData = (defaultData: BudgetData = getInitialBudgetData()): BudgetData => { +export const safelyLoadBudgetData = ( + defaultData: BudgetData = getInitialBudgetData() +): BudgetData => { try { - const budgetDataStr = localStorage.getItem('budgetData'); + const budgetDataStr = localStorage.getItem("budgetData"); if (budgetDataStr) { const parsed = JSON.parse(budgetDataStr); - + // 데이터 구조 검증 (daily, weekly, monthly 키 존재 확인) if (parsed && parsed.daily && parsed.weekly && parsed.monthly) { return parsed; } else { - console.warn('저장된 예산 데이터 구조가 유효하지 않습니다. 기본값 사용.'); + storageLogger.warn( + "저장된 예산 데이터 구조가 유효하지 않습니다. 기본값 사용." + ); } } } catch (error) { - console.error('예산 데이터 로드 오류:', error); + storageLogger.error("예산 데이터 로드 오류:", error); } - + // 오류 발생 또는 데이터 없음 시 기본값 반환 return defaultData; }; @@ -30,36 +34,38 @@ export const safeStorage = { get: (key: string, defaultValue: any = null): any => { try { const value = localStorage.getItem(key); - if (value === null) return defaultValue; + if (value === null) { + return defaultValue; + } return JSON.parse(value); } catch (error) { - console.error(`스토리지 읽기 오류 (${key}):`, error); + storageLogger.error(`스토리지 읽기 오류 (${key}):`, error); return defaultValue; } }, - + set: (key: string, value: any): boolean => { try { localStorage.setItem(key, JSON.stringify(value)); return true; } catch (error) { - console.error(`스토리지 쓰기 오류 (${key}):`, error); + storageLogger.error(`스토리지 쓰기 오류 (${key}):`, error); toast({ title: "저장 오류", description: "데이터를 저장하는 중 문제가 발생했습니다.", - variant: "destructive" + variant: "destructive", }); return false; } }, - + remove: (key: string): boolean => { try { localStorage.removeItem(key); return true; } catch (error) { - console.error(`스토리지 삭제 오류 (${key}):`, error); + storageLogger.error(`스토리지 삭제 오류 (${key}):`, error); return false; } - } + }, }; diff --git a/src/hooks/auth/useAppwriteAuth.ts b/src/hooks/auth/useAppwriteAuth.ts index 72ad9f3..adad847 100644 --- a/src/hooks/auth/useAppwriteAuth.ts +++ b/src/hooks/auth/useAppwriteAuth.ts @@ -1,6 +1,6 @@ -import { useState, useEffect, useCallback } from 'react'; -import { account } from '@/lib/appwrite'; -import { ID } from 'appwrite'; +import { useState, useEffect, useCallback } from "react"; +import { account } from "@/lib/appwrite"; +import { ID } from "appwrite"; // 인증 상태 인터페이스 interface AuthState { @@ -29,7 +29,7 @@ export const useAppwriteAuth = () => { const [authState, setAuthState] = useState({ user: null, loading: true, - error: null + error: null, }); // 컴포넌트 마운트 상태 추적 @@ -43,7 +43,7 @@ export const useAppwriteAuth = () => { setAuthState({ user, loading: false, - error: null + error: null, }); } return user; @@ -52,7 +52,7 @@ export const useAppwriteAuth = () => { setAuthState({ user: null, loading: false, - error: error as Error + error: error as Error, }); } return null; @@ -60,97 +60,101 @@ export const useAppwriteAuth = () => { }, [isMounted]); // 이메일/비밀번호로 로그인 - const login = useCallback(async ({ email, password }: LoginCredentials) => { - try { - setAuthState(prev => ({ ...prev, loading: true, error: null })); - - // 비동기 작업 시작 전 UI 스레드 차단 방지 - await new Promise(resolve => setTimeout(resolve, 0)); - - const session = await account.createEmailPasswordSession(email, password); - const user = await account.get(); - - if (isMounted) { - setAuthState({ - user, - loading: false, - error: null - }); + const login = useCallback( + async ({ email, password }: LoginCredentials) => { + try { + setAuthState((prev) => ({ ...prev, loading: true, error: null })); + + // 비동기 작업 시작 전 UI 스레드 차단 방지 + await new Promise((resolve) => setTimeout(resolve, 0)); + + const session = await account.createEmailPasswordSession( + email, + password + ); + const user = await account.get(); + + if (isMounted) { + setAuthState({ + user, + loading: false, + error: null, + }); + } + + return { user, session }; + } catch (error) { + if (isMounted) { + setAuthState((prev) => ({ + ...prev, + loading: false, + error: error as Error, + })); + } + throw error; } - - return { user, session }; - } catch (error) { - if (isMounted) { - setAuthState(prev => ({ - ...prev, - loading: false, - error: error as Error - })); - } - throw error; - } - }, [isMounted]); + }, + [isMounted] + ); // 회원가입 - const signup = useCallback(async ({ email, password, name }: SignupCredentials) => { - try { - setAuthState(prev => ({ ...prev, loading: true, error: null })); - - // 비동기 작업 시작 전 UI 스레드 차단 방지 - await new Promise(resolve => setTimeout(resolve, 0)); - - const user = await account.create( - ID.unique(), - email, - password, - name - ); - - // 회원가입 후 자동 로그인 - await account.createEmailPasswordSession(email, password); - - if (isMounted) { - setAuthState({ - user, - loading: false, - error: null - }); + const signup = useCallback( + async ({ email, password, name }: SignupCredentials) => { + try { + setAuthState((prev) => ({ ...prev, loading: true, error: null })); + + // 비동기 작업 시작 전 UI 스레드 차단 방지 + await new Promise((resolve) => setTimeout(resolve, 0)); + + const user = await account.create(ID.unique(), email, password, name); + + // 회원가입 후 자동 로그인 + await account.createEmailPasswordSession(email, password); + + if (isMounted) { + setAuthState({ + user, + loading: false, + error: null, + }); + } + + return user; + } catch (error) { + if (isMounted) { + setAuthState((prev) => ({ + ...prev, + loading: false, + error: error as Error, + })); + } + throw error; } - - return user; - } catch (error) { - if (isMounted) { - setAuthState(prev => ({ - ...prev, - loading: false, - error: error as Error - })); - } - throw error; - } - }, [isMounted]); + }, + [isMounted] + ); // 로그아웃 const logout = useCallback(async () => { try { - setAuthState(prev => ({ ...prev, loading: true })); - + setAuthState((prev) => ({ ...prev, loading: true })); + // 현재 세션 삭제 - await account.deleteSession('current'); - + await account.deleteSession("current"); + if (isMounted) { setAuthState({ user: null, loading: false, - error: null + error: null, }); } } catch (error) { if (isMounted) { - setAuthState(prev => ({ + setAuthState((prev) => ({ ...prev, loading: false, - error: error as Error + error: error as Error, })); } throw error; @@ -161,7 +165,7 @@ export const useAppwriteAuth = () => { useEffect(() => { setIsMounted(true); getCurrentUser(); - + // 정리 함수 return () => { setIsMounted(false); @@ -175,7 +179,7 @@ export const useAppwriteAuth = () => { login, signup, logout, - getCurrentUser + getCurrentUser, }; }; diff --git a/src/hooks/budget/useBudgetTabContent.ts b/src/hooks/budget/useBudgetTabContent.ts index bd787c8..0629fe0 100644 --- a/src/hooks/budget/useBudgetTabContent.ts +++ b/src/hooks/budget/useBudgetTabContent.ts @@ -1,6 +1,6 @@ - -import { useState, useEffect } from 'react'; -import { EXPENSE_CATEGORIES } from '@/constants/categoryIcons'; +import { useState, useEffect } from "react"; +import { logger } from "@/utils/logger"; +import { EXPENSE_CATEGORIES } from "@/constants/categoryIcons"; interface BudgetData { targetAmount: number; @@ -11,7 +11,10 @@ interface BudgetData { interface UseBudgetTabContentProps { data: BudgetData; calculatePercentage: (spent: number, target: number) => number; - onSaveBudget: (amount: number, categoryBudgets?: Record) => void; + onSaveBudget: ( + amount: number, + categoryBudgets?: Record + ) => void; } interface UseBudgetTabContentReturn { @@ -33,53 +36,64 @@ interface UseBudgetTabContentReturn { export const useBudgetTabContent = ({ data, calculatePercentage, - onSaveBudget + onSaveBudget, }: UseBudgetTabContentProps): UseBudgetTabContentReturn => { - const [categoryBudgets, setCategoryBudgets] = useState>({}); + const [categoryBudgets, setCategoryBudgets] = useState< + Record + >({}); const spentAmount = data.spentAmount; const targetAmount = data.targetAmount; // 로그 추가 - 받은 데이터 확인 useEffect(() => { - console.log(`BudgetTabContent 수신 데이터:`, data); + logger.info(`BudgetTabContent 수신 데이터:`, data); }, [data]); // 전역 예산 데이터가 변경되었을 때 로컬 상태 갱신 useEffect(() => { const handleBudgetDataUpdated = () => { - console.log(`BudgetTabContent: 전역 예산 데이터 이벤트 감지, 현재 targetAmount=${targetAmount}`); + logger.info( + `BudgetTabContent: 전역 예산 데이터 이벤트 감지, 현재 targetAmount=${targetAmount}` + ); }; - window.addEventListener('budgetDataUpdated', handleBudgetDataUpdated); - return () => window.removeEventListener('budgetDataUpdated', handleBudgetDataUpdated); + window.addEventListener("budgetDataUpdated", handleBudgetDataUpdated); + return () => + window.removeEventListener("budgetDataUpdated", handleBudgetDataUpdated); }, [targetAmount]); // 예산 설정 여부 확인 - 데이터 targetAmount가 실제로 0보다 큰지 확인 const isBudgetSet = targetAmount > 0; // 실제 백분율 계산 (초과해도 실제 퍼센트로 표시) - const actualPercentage = targetAmount > 0 ? Math.round((spentAmount / targetAmount) * 100) : 0; + const actualPercentage = + targetAmount > 0 ? Math.round((spentAmount / targetAmount) * 100) : 0; const percentage = Math.min(actualPercentage, 100); // 대시보드 표시용으로는 100% 제한 - + // 예산 초과 여부 계산 const isOverBudget = spentAmount > targetAmount && targetAmount > 0; // 예산이 얼마 남지 않은 경우 (10% 미만) - const isLowBudget = targetAmount > 0 && actualPercentage >= 90 && actualPercentage < 100; + const isLowBudget = + targetAmount > 0 && actualPercentage >= 90 && actualPercentage < 100; // 프로그레스 바 색상 결정 - const progressBarColor = isOverBudget ? 'bg-red-500' : isLowBudget ? 'bg-yellow-400' : 'bg-neuro-income'; + const progressBarColor = isOverBudget + ? "bg-red-500" + : isLowBudget + ? "bg-yellow-400" + : "bg-neuro-income"; // 남은 예산 또는 초과 예산 텍스트 및 금액 - const budgetStatusText = isOverBudget ? '예산 초과: ' : '남은 예산: '; - const budgetAmount = isOverBudget ? - Math.abs(targetAmount - spentAmount).toLocaleString() : - Math.max(0, targetAmount - spentAmount).toLocaleString(); - + const budgetStatusText = isOverBudget ? "예산 초과: " : "남은 예산: "; + const budgetAmount = isOverBudget + ? Math.abs(targetAmount - spentAmount).toLocaleString() + : Math.max(0, targetAmount - spentAmount).toLocaleString(); + const handleCategoryInputChange = (value: string, category: string) => { - const numValue = parseInt(value.replace(/,/g, ''), 10) || 0; - setCategoryBudgets(prev => ({ + const numValue = parseInt(value.replace(/,/g, ""), 10) || 0; + setCategoryBudgets((prev) => ({ ...prev, - [category]: numValue + [category]: numValue, })); }; @@ -87,10 +101,10 @@ export const useBudgetTabContent = ({ const calculateTotalBudget = () => { // 모든 EXPENSE_CATEGORIES에 있는 카테고리 포함해서 합계 계산 let total = 0; - EXPENSE_CATEGORIES.forEach(category => { + EXPENSE_CATEGORIES.forEach((category) => { total += categoryBudgets[category] || 0; }); - console.log('카테고리 예산 총합:', total, categoryBudgets); + logger.info("카테고리 예산 총합:", total, categoryBudgets); return total; }; @@ -98,25 +112,29 @@ export const useBudgetTabContent = ({ const handleSaveCategoryBudgets = () => { // 카테고리 예산 기본값 설정 - 모든 카테고리 포함 const updatedCategoryBudgets: Record = {}; - EXPENSE_CATEGORIES.forEach(category => { + EXPENSE_CATEGORIES.forEach((category) => { updatedCategoryBudgets[category] = categoryBudgets[category] || 0; }); - + const totalBudget = calculateTotalBudget(); - console.log('카테고리 예산 저장 및 총 예산 설정:', totalBudget, updatedCategoryBudgets); - + logger.info( + "카테고리 예산 저장 및 총 예산 설정:", + totalBudget, + updatedCategoryBudgets + ); + // 총액이 0이 아닐 때만 저장 처리 if (totalBudget > 0) { // 명시적으로 월간 예산으로 설정 - 항상 월간 예산만 저장 onSaveBudget(totalBudget, updatedCategoryBudgets); - + // 이벤트 발생 추가 (데이터 저장 후 즉시 UI 업데이트를 위해) setTimeout(() => { - console.log('예산 데이터 저장 후 이벤트 발생'); - window.dispatchEvent(new Event('budgetDataUpdated')); + logger.info("예산 데이터 저장 후 이벤트 발생"); + window.dispatchEvent(new Event("budgetDataUpdated")); }, 200); } else { - alert('예산을 입력해주세요.'); + alert("예산을 입력해주세요."); } }; @@ -124,14 +142,14 @@ export const useBudgetTabContent = ({ useEffect(() => { // 로컬 스토리지에서 카테고리 예산 불러오기 try { - const storedCategoryBudgets = localStorage.getItem('categoryBudgets'); + const storedCategoryBudgets = localStorage.getItem("categoryBudgets"); if (storedCategoryBudgets) { const parsedBudgets = JSON.parse(storedCategoryBudgets); - console.log('저장된 카테고리 예산 불러옴:', parsedBudgets); + logger.info("저장된 카테고리 예산 불러옴:", parsedBudgets); setCategoryBudgets(parsedBudgets); } } catch (error) { - console.error('카테고리 예산 불러오기 오류:', error); + logger.error("카테고리 예산 불러오기 오류:", error); } }, []); @@ -139,7 +157,10 @@ export const useBudgetTabContent = ({ const budgetButtonText = isBudgetSet ? "예산 수정하기" : "예산 입력하기"; // 화면에 표시할 내용 - 디버깅을 위한 로그 추가 - console.log(`BudgetTabContent 렌더링: targetAmount=${targetAmount}, isBudgetSet=${isBudgetSet}, 표시될 화면:`, isBudgetSet ? "예산 진행 상황" : "예산 입력하기 버튼"); + logger.info( + `BudgetTabContent 렌더링: targetAmount=${targetAmount}, isBudgetSet=${isBudgetSet}, 표시될 화면:`, + isBudgetSet ? "예산 진행 상황" : "예산 입력하기 버튼" + ); return { categoryBudgets, @@ -154,6 +175,6 @@ export const useBudgetTabContent = ({ budgetStatusText, budgetAmount, budgetButtonText, - calculateTotalBudget + calculateTotalBudget, }; }; diff --git a/src/hooks/sync/index.ts b/src/hooks/sync/index.ts index f2e2b96..9c49ca0 100644 --- a/src/hooks/sync/index.ts +++ b/src/hooks/sync/index.ts @@ -1,10 +1,9 @@ - // 주요 동기화 훅 내보내기 -export * from './useSyncToggle'; -export * from './useManualSync'; -export * from './useSyncStatus'; -export * from './syncTime'; -export * from './syncResultHandler'; -export * from './syncPerformer'; -export * from './syncNetworkChecker'; -export * from './syncBackupUtils'; +export * from "./useSyncToggle"; +export * from "./useManualSync"; +export * from "./useSyncStatus"; +export * from "./syncTime"; +export * from "./syncResultHandler"; +export * from "./syncPerformer"; +export * from "./syncNetworkChecker"; +export * from "./syncBackupUtils"; diff --git a/src/hooks/sync/syncBackupUtils.ts b/src/hooks/sync/syncBackupUtils.ts index 1146517..29e4a27 100644 --- a/src/hooks/sync/syncBackupUtils.ts +++ b/src/hooks/sync/syncBackupUtils.ts @@ -1,22 +1,21 @@ - /** * 로컬 데이터 백업 만들기 */ export const createLocalDataBackup = () => { - const budgetDataBackup = localStorage.getItem('budgetData'); - const categoryBudgetsBackup = localStorage.getItem('categoryBudgets'); - const transactionsBackup = localStorage.getItem('transactions'); - - console.log('로컬 데이터 백업:', { - budgetData: budgetDataBackup ? '있음' : '없음', - categoryBudgets: categoryBudgetsBackup ? '있음' : '없음', - transactions: transactionsBackup ? '있음' : '없음' + const budgetDataBackup = localStorage.getItem("budgetData"); + const categoryBudgetsBackup = localStorage.getItem("categoryBudgets"); + const transactionsBackup = localStorage.getItem("transactions"); + + syncLogger.info("로컬 데이터 백업:", { + budgetData: budgetDataBackup ? "있음" : "없음", + categoryBudgets: categoryBudgetsBackup ? "있음" : "없음", + transactions: transactionsBackup ? "있음" : "없음", }); - + return { budgetDataBackup, categoryBudgetsBackup, - transactionsBackup + transactionsBackup, }; }; @@ -24,29 +23,30 @@ export const createLocalDataBackup = () => { * 로컬 데이터 복원하기 */ export const restoreLocalDataBackup = (backup: { - budgetDataBackup: string | null, - categoryBudgetsBackup: string | null, - transactionsBackup: string | null + budgetDataBackup: string | null; + categoryBudgetsBackup: string | null; + transactionsBackup: string | null; }) => { - const { budgetDataBackup, categoryBudgetsBackup, transactionsBackup } = backup; - - console.log('로컬 데이터 복원 시도'); - + const { budgetDataBackup, categoryBudgetsBackup, transactionsBackup } = + backup; + + syncLogger.info("로컬 데이터 복원 시도"); + // 오류 발생 시 백업 데이터 복원 if (budgetDataBackup) { - localStorage.setItem('budgetData', budgetDataBackup); + localStorage.setItem("budgetData", budgetDataBackup); } if (categoryBudgetsBackup) { - localStorage.setItem('categoryBudgets', categoryBudgetsBackup); + localStorage.setItem("categoryBudgets", categoryBudgetsBackup); } if (transactionsBackup) { - localStorage.setItem('transactions', transactionsBackup); + localStorage.setItem("transactions", transactionsBackup); } - + // 이벤트 발생시켜 UI 업데이트 - window.dispatchEvent(new Event('budgetDataUpdated')); - window.dispatchEvent(new Event('categoryBudgetsUpdated')); - window.dispatchEvent(new Event('transactionUpdated')); - - console.log('로컬 데이터 복원 완료'); + window.dispatchEvent(new Event("budgetDataUpdated")); + window.dispatchEvent(new Event("categoryBudgetsUpdated")); + window.dispatchEvent(new Event("transactionUpdated")); + + syncLogger.info("로컬 데이터 복원 완료"); }; diff --git a/src/hooks/sync/syncNetworkChecker.ts b/src/hooks/sync/syncNetworkChecker.ts index 0a5b324..bfe2842 100644 --- a/src/hooks/sync/syncNetworkChecker.ts +++ b/src/hooks/sync/syncNetworkChecker.ts @@ -1,6 +1,6 @@ - -import { checkNetworkStatus } from '@/utils/network/checker'; -import { toast } from '@/hooks/useToast.wrapper'; +import { checkNetworkStatus } from "@/utils/network/checker"; +import { syncLogger } from "@/utils/logger"; +import { toast } from "@/hooks/useToast.wrapper"; /** * 동기화를 위한 네트워크 상태 확인 @@ -8,20 +8,27 @@ import { toast } from '@/hooks/useToast.wrapper'; export const checkSyncNetworkStatus = async (): Promise => { // 기본 네트워크 상태 확인 - navigator.onLine 우선 사용 const navigatorOnline = navigator.onLine; - console.log(`[동기화] 기본 네트워크 상태 확인: ${navigatorOnline ? '온라인' : '오프라인'}`); - + syncLogger.info( + `[동기화] 기본 네트워크 상태 확인: ${navigatorOnline ? "온라인" : "오프라인"}` + ); + if (!navigatorOnline) { return false; } - + // 강화된 네트워크 확인 시도 (실패해도 계속 진행) try { const isOnline = await checkNetworkStatus(); - console.log(`[동기화] 강화된 네트워크 확인 결과: ${isOnline ? '온라인' : '오프라인'}`); + syncLogger.info( + `[동기화] 강화된 네트워크 확인 결과: ${isOnline ? "온라인" : "오프라인"}` + ); return isOnline; } catch (error) { // 네트워크 확인 실패해도 navigator.onLine이 true면 진행 - console.warn('[동기화] 강화된 네트워크 확인 실패, 기본 상태 사용:', error); + syncLogger.warn( + "[동기화] 강화된 네트워크 확인 실패, 기본 상태 사용:", + error + ); return navigatorOnline; } }; @@ -34,12 +41,12 @@ export const showNetworkErrorNotification = ( ) => { const title = "네트워크 연결 필요"; const description = "동기화를 위해 인터넷 연결이 필요합니다."; - + toast({ title, description, - variant: "destructive" + variant: "destructive", }); - + addNotification(title, description); }; diff --git a/src/hooks/sync/syncPerformer.ts b/src/hooks/sync/syncPerformer.ts index bcd0daa..5ecad3c 100644 --- a/src/hooks/sync/syncPerformer.ts +++ b/src/hooks/sync/syncPerformer.ts @@ -1,44 +1,46 @@ - -import { trySyncAllData } from '@/utils/syncUtils'; -import { setLastSyncTime } from '@/utils/syncUtils'; +import { trySyncAllData } from "@/utils/syncUtils"; +import { syncLogger } from "@/utils/logger"; +import { setLastSyncTime } from "@/utils/syncUtils"; /** * 실제 동기화 수행 함수 (최대 2회까지 자동 재시도) */ export const performSync = async (userId: string) => { - if (!userId) return; - + if (!userId) { + return; + } + let attempts = 0; const maxAttempts = 2; - + while (attempts < maxAttempts) { try { attempts++; - console.log(`동기화 시도 ${attempts}/${maxAttempts}`); - + syncLogger.info(`동기화 시도 ${attempts}/${maxAttempts}`); + // 네트워크 상태 확인 - 기본 navigator.onLine 사용 if (!navigator.onLine) { - console.log('네트워크 연결 없음, 동기화 건너뜀'); - throw new Error('네트워크 연결 필요'); + syncLogger.info("네트워크 연결 없음, 동기화 건너뜀"); + throw new Error("네트워크 연결 필요"); } - + const result = await trySyncAllData(userId); - + // 동기화 성공 시 마지막 동기화 시간 업데이트 if (result && result.success) { const currentTime = new Date().toISOString(); - console.log('동기화 성공, 시간 업데이트:', currentTime); + syncLogger.info("동기화 성공, 시간 업데이트:", currentTime); setLastSyncTime(currentTime); } - + return result; } catch (error) { - console.error(`동기화 시도 ${attempts} 실패:`, error); - + syncLogger.error(`동기화 시도 ${attempts} 실패:`, error); + if (attempts < maxAttempts) { // 재시도 전 잠시 대기 - await new Promise(resolve => setTimeout(resolve, 2000)); - console.log(`${attempts+1}번째 동기화 재시도 중...`); + await new Promise((resolve) => setTimeout(resolve, 2000)); + syncLogger.info(`${attempts + 1}번째 동기화 재시도 중...`); } else { throw error; } diff --git a/src/hooks/sync/syncResultHandler.ts b/src/hooks/sync/syncResultHandler.ts index 71e70f2..f06191c 100644 --- a/src/hooks/sync/syncResultHandler.ts +++ b/src/hooks/sync/syncResultHandler.ts @@ -1,14 +1,16 @@ - -import React, { useEffect } from 'react'; -import { SyncResult } from '@/utils/sync/data'; -import { toast } from '@/hooks/useToast.wrapper'; -import useNotifications from '@/hooks/useNotifications'; +import React, { useEffect } from "react"; +import { syncLogger } from "@/utils/logger"; +import { SyncResult } from "@/utils/sync/data"; +import { toast } from "@/hooks/useToast.wrapper"; +import useNotifications from "@/hooks/useNotifications"; // 알림 인스턴스 얻기 위한 전역 변수 let notificationAdder: ((title: string, message: string) => void) | null = null; // 알림 함수 설정 -export const setSyncNotificationAdder = (adder: (title: string, message: string) => void) => { +export const setSyncNotificationAdder = ( + adder: (title: string, message: string) => void +) => { notificationAdder = adder; }; @@ -21,10 +23,10 @@ export const handleSyncResult = (result: SyncResult) => { if (result.success) { // 성공 시 실패 카운터 초기화 syncFailureCount = 0; - - let title = ''; - let description = ''; - + + let title = ""; + let description = ""; + if (result.uploadSuccess && result.downloadSuccess) { // 양방향 동기화 성공 title = "동기화 완료"; @@ -38,53 +40,54 @@ export const handleSyncResult = (result: SyncResult) => { title = "동기화 완료"; description = "클라우드 데이터가 기기에 다운로드되었습니다."; } - + // 토스트 표시 toast({ title, description, }); - + // 알림 추가 (설정된 경우) if (notificationAdder) { notificationAdder(title, description); } - + // 상세 결과 로깅 - console.log("동기화 세부 결과:", { - 예산업로드: result.details?.budgetUpload ? '성공' : '실패', - 예산다운로드: result.details?.budgetDownload ? '성공' : '실패', - 트랜잭션업로드: result.details?.transactionUpload ? '성공' : '실패', - 트랜잭션다운로드: result.details?.transactionDownload ? '성공' : '실패' + syncLogger.info("동기화 세부 결과:", { + 예산업로드: result.details?.budgetUpload ? "성공" : "실패", + 예산다운로드: result.details?.budgetDownload ? "성공" : "실패", + 트랜잭션업로드: result.details?.transactionUpload ? "성공" : "실패", + 트랜잭션다운로드: result.details?.transactionDownload ? "성공" : "실패", }); - + return true; } else { // 동기화 실패 - console.error("동기화 실패 세부 결과:", result.details); - + syncLogger.error("동기화 실패 세부 결과:", result.details); + // 실패 카운터 증가 및 최대 알림 횟수 제한 syncFailureCount++; - + if (syncFailureCount <= MAX_SYNC_FAILURE_NOTIFICATIONS) { const title = "동기화 실패"; - const description = "데이터 동기화 중 문제가 발생했습니다. 나중에 다시 시도합니다."; - + const description = + "데이터 동기화 중 문제가 발생했습니다. 나중에 다시 시도합니다."; + // 토스트 표시 toast({ title, description, - variant: "destructive" + variant: "destructive", }); - + // 알림 추가 (설정된 경우) if (notificationAdder) { notificationAdder(title, description); } } else { - console.log(`동기화 실패 알림 제한 (${syncFailureCount}회 실패)`); + syncLogger.info(`동기화 실패 알림 제한 (${syncFailureCount}회 실패)`); } - + return false; } }; @@ -92,7 +95,7 @@ export const handleSyncResult = (result: SyncResult) => { // 커스텀 훅: 동기화 알림 관리 export const useSyncNotifications = () => { const { addNotification } = useNotifications(); - + // 컴포넌트 마운트 시 알림 함수 설정 useEffect(() => { setSyncNotificationAdder(addNotification); diff --git a/src/hooks/sync/syncTime/index.ts b/src/hooks/sync/syncTime/index.ts index abfa664..b53d8fc 100644 --- a/src/hooks/sync/syncTime/index.ts +++ b/src/hooks/sync/syncTime/index.ts @@ -1,3 +1,2 @@ - -export * from './useSyncTimeFormatting'; -export * from './useSyncTimeEvents'; +export * from "./useSyncTimeFormatting"; +export * from "./useSyncTimeEvents"; diff --git a/src/hooks/sync/syncTime/useSyncTimeEvents.ts b/src/hooks/sync/syncTime/useSyncTimeEvents.ts index 2fb60cd..e8a6d21 100644 --- a/src/hooks/sync/syncTime/useSyncTimeEvents.ts +++ b/src/hooks/sync/syncTime/useSyncTimeEvents.ts @@ -1,12 +1,12 @@ - -import { useCallback } from 'react'; -import { getLastSyncTime } from '@/utils/syncUtils'; +import { useCallback } from "react"; +import { syncLogger } from "@/utils/logger"; +import { getLastSyncTime } from "@/utils/syncUtils"; /** * 동기화 시간 관련 이벤트를 처리하는 커스텀 훅 */ export const useSyncTimeEvents = ( - lastSync: string | null, + lastSync: string | null, setLastSync: React.Dispatch> ) => { /** @@ -15,44 +15,49 @@ export const useSyncTimeEvents = ( const setupSyncTimeEventListeners = useCallback(() => { const updateLastSyncTime = (event?: Event | CustomEvent) => { const newTime = getLastSyncTime(); - const eventDetails = event instanceof CustomEvent ? ` (이벤트 상세: ${JSON.stringify(event.detail)})` : ''; - console.log(`마지막 동기화 시간 업데이트됨: ${newTime} ${eventDetails}`); + const eventDetails = + event instanceof CustomEvent + ? ` (이벤트 상세: ${JSON.stringify(event.detail)})` + : ""; + syncLogger.info( + `마지막 동기화 시간 업데이트됨: ${newTime} ${eventDetails}` + ); setLastSync(newTime); }; - + // 이벤트 리스너 등록 - 커스텀 이벤트 사용 - window.addEventListener('syncTimeUpdated', updateLastSyncTime); - + window.addEventListener("syncTimeUpdated", updateLastSyncTime); + // 스토리지 이벤트도 모니터링 const handleStorageChange = (event: StorageEvent) => { - if (event.key === 'lastSync' || event.key === null) { - console.log('스토리지 변경 감지 (lastSync):', event.newValue); + if (event.key === "lastSync" || event.key === null) { + syncLogger.info("스토리지 변경 감지 (lastSync):", event.newValue); updateLastSyncTime(); } }; - - window.addEventListener('storage', handleStorageChange); - + + window.addEventListener("storage", handleStorageChange); + // 동기화 완료 이벤트도 모니터링 - window.addEventListener('syncComplete', updateLastSyncTime); - - // 초기 상태 업데이트 + window.addEventListener("syncComplete", updateLastSyncTime); + + // 초기 상태 업데이트 updateLastSyncTime(); - + // 주기적 시간 확인 기능 설정 const checkInterval = setupPeriodicTimeCheck(lastSync, setLastSync); - + // 정리 함수 반환 return () => { - window.removeEventListener('syncTimeUpdated', updateLastSyncTime); - window.removeEventListener('storage', handleStorageChange); - window.removeEventListener('syncComplete', updateLastSyncTime); + window.removeEventListener("syncTimeUpdated", updateLastSyncTime); + window.removeEventListener("storage", handleStorageChange); + window.removeEventListener("syncComplete", updateLastSyncTime); clearInterval(checkInterval); }; }, [lastSync, setLastSync]); - + return { - setupSyncTimeEventListeners + setupSyncTimeEventListeners, }; }; @@ -60,14 +65,14 @@ export const useSyncTimeEvents = ( * 주기적으로 동기화 시간을 확인하는 기능 설정 */ const setupPeriodicTimeCheck = ( - lastSync: string | null, + lastSync: string | null, setLastSync: React.Dispatch> ): number => { // 1초마다 업데이트 상태 확인 (문제 해결을 위한 임시 방안) return window.setInterval(() => { const currentTime = getLastSyncTime(); if (currentTime !== lastSync) { - console.log('주기적 확인에서 동기화 시간 변경 감지:', currentTime); + syncLogger.info("주기적 확인에서 동기화 시간 변경 감지:", currentTime); setLastSync(currentTime); } }, 1000); diff --git a/src/hooks/sync/syncTime/useSyncTimeFormatting.ts b/src/hooks/sync/syncTime/useSyncTimeFormatting.ts index d66e8e6..5e55353 100644 --- a/src/hooks/sync/syncTime/useSyncTimeFormatting.ts +++ b/src/hooks/sync/syncTime/useSyncTimeFormatting.ts @@ -1,4 +1,3 @@ - /** * 마지막 동기화 시간 포맷팅을 위한 커스텀 훅 */ @@ -8,26 +7,26 @@ export const useLastSyncTimeFormatting = (lastSync: string | null) => { */ const formatLastSyncTime = (): string => { if (!lastSync) { - return '없음'; + return "없음"; } try { const date = new Date(lastSync); - + // 유효한 날짜인지 확인 if (isNaN(date.getTime())) { - return '없음'; + return "없음"; } - + return formatDateByRelativeTime(date); } catch (error) { - console.error('날짜 포맷 오류:', error); - return '없음'; + syncLogger.error("날짜 포맷 오류:", error); + return "없음"; } }; - + return { - formatLastSyncTime + formatLastSyncTime, }; }; @@ -38,21 +37,21 @@ const formatDateByRelativeTime = (date: Date): string => { // 오늘 날짜인 경우 const today = new Date(); const isToday = isSameDay(date, today); - + if (isToday) { // 시간만 표시 return `오늘 ${formatTime(date)}`; } - + // 어제 날짜인 경우 const yesterday = new Date(today); yesterday.setDate(yesterday.getDate() - 1); const isYesterday = isSameDay(date, yesterday); - + if (isYesterday) { return `어제 ${formatTime(date)}`; } - + // 그 외 날짜 return `${formatFullDate(date)} ${formatTime(date)}`; }; @@ -61,21 +60,23 @@ const formatDateByRelativeTime = (date: Date): string => { * 두 날짜가 같은 날인지 확인 */ const isSameDay = (date1: Date, date2: Date): boolean => { - return date1.getDate() === date2.getDate() && - date1.getMonth() === date2.getMonth() && - date1.getFullYear() === date2.getFullYear(); + return ( + date1.getDate() === date2.getDate() && + date1.getMonth() === date2.getMonth() && + date1.getFullYear() === date2.getFullYear() + ); }; /** * 시간을 HH:MM 형식으로 포맷팅 */ const formatTime = (date: Date): string => { - return `${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')}`; + return `${date.getHours()}:${String(date.getMinutes()).padStart(2, "0")}`; }; /** * 전체 날짜를 YYYY/MM/DD 형식으로 포맷팅 */ const formatFullDate = (date: Date): string => { - return `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}`; + return `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, "0")}/${String(date.getDate()).padStart(2, "0")}`; }; diff --git a/src/hooks/sync/useManualSync.ts b/src/hooks/sync/useManualSync.ts index ccf3f39..e64f6cb 100644 --- a/src/hooks/sync/useManualSync.ts +++ b/src/hooks/sync/useManualSync.ts @@ -1,14 +1,15 @@ - -import { useState } from 'react'; -import { toast } from '@/hooks/useToast.wrapper'; -import { trySyncAllData, setLastSyncTime } from '@/utils/syncUtils'; -import { handleSyncResult } from './syncResultHandler'; -import useNotifications from '@/hooks/useNotifications'; +import { useState } from "react"; +import { syncLogger } from "@/utils/logger"; +import { toast } from "@/hooks/useToast.wrapper"; +import { trySyncAllData, setLastSyncTime } from "@/utils/syncUtils"; +import { handleSyncResult } from "./syncResultHandler"; +import useNotifications from "@/hooks/useNotifications"; +import { Models } from "appwrite"; /** * 수동 동기화 기능을 위한 커스텀 훅 */ -export const useManualSync = (user: any) => { +export const useManualSync = (user: Models.User | null) => { const [syncing, setSyncing] = useState(false); const { addNotification } = useNotifications(); @@ -18,67 +19,66 @@ export const useManualSync = (user: any) => { toast({ title: "로그인 필요", description: "데이터 동기화를 위해 로그인이 필요합니다.", - variant: "destructive" + variant: "destructive", }); - + addNotification( - "로그인 필요", + "로그인 필요", "데이터 동기화를 위해 로그인이 필요합니다." ); return; } - + // 이미 동기화 중이면 중복 실행 방지 if (syncing) { - console.log('이미 동기화가 진행 중입니다.'); + syncLogger.info("이미 동기화가 진행 중입니다."); return; } - + await performSync(user.id); }; // 실제 동기화 수행 함수 const performSync = async (userId: string) => { - if (!userId) return; - + if (!userId) { + return; + } + try { setSyncing(true); - console.log('수동 동기화 시작'); - - addNotification( - "동기화 시작", - "데이터 동기화가 시작되었습니다." - ); - + syncLogger.info("수동 동기화 시작"); + + addNotification("동기화 시작", "데이터 동기화가 시작되었습니다."); + // 동기화 실행 const result = await trySyncAllData(userId); - + // 동기화 결과 처리 handleSyncResult(result); - + // 동기화 시간 업데이트 if (result.success) { const currentTime = new Date().toISOString(); - console.log('수동 동기화 성공, 시간 업데이트:', currentTime); + syncLogger.info("수동 동기화 성공, 시간 업데이트:", currentTime); setLastSyncTime(currentTime); } - + return result; } catch (error) { - console.error('동기화 오류:', error); + syncLogger.error("동기화 오류:", error); toast({ title: "동기화 오류", description: "동기화 중 문제가 발생했습니다. 다시 시도해주세요.", - variant: "destructive" + variant: "destructive", }); - + addNotification( - "동기화 오류", + "동기화 오류", "동기화 중 문제가 발생했습니다. 다시 시도해주세요." ); } finally { setSyncing(false); - console.log('수동 동기화 종료'); + syncLogger.info("수동 동기화 종료"); } }; diff --git a/src/hooks/sync/useSyncStatus.ts b/src/hooks/sync/useSyncStatus.ts index af674a1..4d96cd1 100644 --- a/src/hooks/sync/useSyncStatus.ts +++ b/src/hooks/sync/useSyncStatus.ts @@ -1,8 +1,8 @@ - -import { useState, useEffect } from 'react'; -import { getLastSyncTime } from '@/utils/syncUtils'; -import { useLastSyncTimeFormatting } from './syncTime/useSyncTimeFormatting'; -import { useSyncTimeEvents } from './syncTime/useSyncTimeEvents'; +import { useState, useEffect } from "react"; +import { syncLogger } from "@/utils/logger"; +import { getLastSyncTime } from "@/utils/syncUtils"; +import { useLastSyncTimeFormatting } from "./syncTime/useSyncTimeFormatting"; +import { useSyncTimeEvents } from "./syncTime/useSyncTimeEvents"; /** * 동기화 상태 관리를 위한 커스텀 훅 @@ -10,20 +10,26 @@ import { useSyncTimeEvents } from './syncTime/useSyncTimeEvents'; export const useSyncStatus = () => { const [lastSync, setLastSync] = useState(getLastSyncTime()); const { formatLastSyncTime } = useLastSyncTimeFormatting(lastSync); - const { setupSyncTimeEventListeners } = useSyncTimeEvents(lastSync, setLastSync); - + const { setupSyncTimeEventListeners } = useSyncTimeEvents( + lastSync, + setLastSync + ); + // 동기화 시간이 변경될 때 상태 업데이트 및 이벤트 리스너 설정 useEffect(() => { - console.log('useSyncStatus 훅 초기화, 현재 마지막 동기화 시간:', lastSync); - + syncLogger.info( + "useSyncStatus 훅 초기화, 현재 마지막 동기화 시간:", + lastSync + ); + // 이벤트 리스너 및 주기적 확인 설정 const cleanup = setupSyncTimeEventListeners(); - + return cleanup; }, [lastSync, setupSyncTimeEventListeners]); - + return { lastSync, - formatLastSyncTime + formatLastSyncTime, }; }; diff --git a/src/hooks/sync/useSyncToggle.ts b/src/hooks/sync/useSyncToggle.ts index 6414b19..db9424f 100644 --- a/src/hooks/sync/useSyncToggle.ts +++ b/src/hooks/sync/useSyncToggle.ts @@ -1,20 +1,26 @@ - -import { useState, useEffect } from 'react'; -import { useAuth } from '@/contexts/auth'; -import { toast } from '@/hooks/useToast.wrapper'; -import { - isSyncEnabled, +import { useState, useEffect } from "react"; +import { syncLogger } from "@/utils/logger"; +import { useAuth } from "@/contexts/auth"; +import { toast } from "@/hooks/useToast.wrapper"; +import { + isSyncEnabled, setSyncEnabled, - setLastSyncTime -} from '@/utils/syncUtils'; -import useNotifications from '@/hooks/useNotifications'; -import { resetSyncFailureCount } from './syncResultHandler'; -import { checkSyncNetworkStatus, showNetworkErrorNotification } from './syncNetworkChecker'; -import { performSync } from './syncPerformer'; -import { createLocalDataBackup, restoreLocalDataBackup } from './syncBackupUtils'; + setLastSyncTime, +} from "@/utils/syncUtils"; +import useNotifications from "@/hooks/useNotifications"; +import { resetSyncFailureCount } from "./syncResultHandler"; +import { + checkSyncNetworkStatus, + showNetworkErrorNotification, +} from "./syncNetworkChecker"; +import { performSync } from "./syncPerformer"; +import { + createLocalDataBackup, + restoreLocalDataBackup, +} from "./syncBackupUtils"; /** - * 동기화 토글 기능을 위한 커스텀 훅 + * 동기화 토글 기능을 위한 커스텀 훅 */ export const useSyncToggle = () => { const [enabled, setEnabled] = useState(isSyncEnabled()); @@ -30,35 +36,38 @@ export const useSyncToggle = () => { // 사용자가 로그아웃했고 동기화가 활성화되어 있으면 비활성화 setSyncEnabled(false); setEnabled(false); - console.log('로그아웃으로 인해 동기화 설정이 비활성화되었습니다.'); + syncLogger.info("로그아웃으로 인해 동기화 설정이 비활성화되었습니다."); } - + // 동기화 상태 업데이트 setEnabled(isSyncEnabled()); - + // 로그인/로그아웃 시 실패 카운터 초기화 resetSyncFailureCount(); }; // 초기 호출 updateSyncState(); - + // 인증 상태 변경 이벤트 리스너 - window.addEventListener('auth-state-changed', updateSyncState); - + window.addEventListener("auth-state-changed", updateSyncState); + // 스토리지 변경 이벤트에도 동기화 상태 확인 추가 const handleStorageChange = (event: StorageEvent) => { - if (event.key === 'syncEnabled' || event.key === null) { + if (event.key === "syncEnabled" || event.key === null) { setEnabled(isSyncEnabled()); - console.log('스토리지 변경으로 동기화 상태 업데이트:', isSyncEnabled() ? '활성화' : '비활성화'); + syncLogger.info( + "스토리지 변경으로 동기화 상태 업데이트:", + isSyncEnabled() ? "활성화" : "비활성화" + ); } }; - - window.addEventListener('storage', handleStorageChange); - + + window.addEventListener("storage", handleStorageChange); + return () => { - window.removeEventListener('auth-state-changed', updateSyncState); - window.removeEventListener('storage', handleStorageChange); + window.removeEventListener("auth-state-changed", updateSyncState); + window.removeEventListener("storage", handleStorageChange); }; }, [user]); @@ -68,16 +77,16 @@ export const useSyncToggle = () => { toast({ title: "로그인 필요", description: "데이터 동기화를 위해 로그인이 필요합니다.", - variant: "destructive" + variant: "destructive", }); - + addNotification( - "로그인 필요", + "로그인 필요", "데이터 동기화를 위해 로그인이 필요합니다." ); return; } - + try { // 네트워크 상태 확인 if (checked) { @@ -87,56 +96,57 @@ export const useSyncToggle = () => { return; } } - + // 현재 로컬 데이터 백업 const dataBackup = createLocalDataBackup(); - + // 동기화 설정 변경 setEnabled(checked); setSyncEnabled(checked); - + // 실패 카운터 초기화 resetSyncFailureCount(); - + // 동기화 활성화/비활성화 알림 추가 addNotification( - checked ? "동기화 활성화" : "동기화 비활성화", - checked - ? "데이터가 클라우드와 동기화됩니다." + checked ? "동기화 활성화" : "동기화 비활성화", + checked + ? "데이터가 클라우드와 동기화됩니다." : "클라우드 동기화가 중지되었습니다." ); - + // 이벤트 트리거 - window.dispatchEvent(new Event('auth-state-changed')); - + window.dispatchEvent(new Event("auth-state-changed")); + if (checked && user) { try { // 동기화 수행 await performSync(user.id); } catch (error) { - console.error('동기화 중 오류, 로컬 데이터 복원 시도:', error); - + syncLogger.error("동기화 중 오류, 로컬 데이터 복원 시도:", error); + // 오류 발생 시 백업 데이터 복원 restoreLocalDataBackup(dataBackup); - + toast({ title: "동기화 오류", - description: "동기화 중 문제가 발생하여 로컬 데이터가 복원되었습니다.", - variant: "destructive" + description: + "동기화 중 문제가 발생하여 로컬 데이터가 복원되었습니다.", + variant: "destructive", }); - + addNotification( - "동기화 오류", + "동기화 오류", "동기화 중 문제가 발생하여 로컬 데이터가 복원되었습니다." ); } } } catch (error) { - console.error('동기화 설정 변경 중 예상치 못한 오류:', error); + syncLogger.error("동기화 설정 변경 중 예상치 못한 오류:", error); toast({ title: "동기화 설정 오류", description: "설정 변경 중 문제가 발생했습니다. 다시 시도해 주세요.", - variant: "destructive" + variant: "destructive", }); } }; diff --git a/src/hooks/toast/constants.ts b/src/hooks/toast/constants.ts index 74c8129..9850bd1 100644 --- a/src/hooks/toast/constants.ts +++ b/src/hooks/toast/constants.ts @@ -1,3 +1,2 @@ - -export const TOAST_LIMIT = 5 // 최대 5개로 제한 -export const TOAST_REMOVE_DELAY = 3000 // 3초 후 DOM에서 제거 +export const TOAST_LIMIT = 5; // 최대 5개로 제한 +export const TOAST_REMOVE_DELAY = 3000; // 3초 후 DOM에서 제거 diff --git a/src/hooks/toast/index.ts b/src/hooks/toast/index.ts index 5c84740..730d4af 100644 --- a/src/hooks/toast/index.ts +++ b/src/hooks/toast/index.ts @@ -1,22 +1,22 @@ +import * as React from "react"; +import { Toast, ToasterToast, State } from "./types"; +import { actionTypes } from "./types"; +import { listeners, memoryState } from "./store"; +import { genId, dispatch } from "./toastManager"; -import * as React from "react" -import { Toast, ToasterToast, State } from "./types" -import { actionTypes } from "./types" -import { listeners, memoryState } from "./store" -import { genId, dispatch } from "./toastManager" - -export { TOAST_LIMIT, TOAST_REMOVE_DELAY } from "./constants" -export type { ToasterToast } from "./types" +export { TOAST_LIMIT, TOAST_REMOVE_DELAY } from "./constants"; +export type { ToasterToast } from "./types"; function toast({ ...props }: Toast) { - const id = genId() + const id = genId(); const update = (props: ToasterToast) => dispatch({ type: actionTypes.UPDATE_TOAST, toast: { ...props, id }, - }) - const dismiss = () => dispatch({ type: actionTypes.DISMISS_TOAST, toastId: id }) + }); + const dismiss = () => + dispatch({ type: actionTypes.DISMISS_TOAST, toastId: id }); dispatch({ type: actionTypes.ADD_TOAST, @@ -25,37 +25,40 @@ function toast({ ...props }: Toast) { id, open: true, onOpenChange: (open) => { - if (!open) dismiss() + if (!open) { + dismiss(); + } }, duration: props.duration || 3000, // 기본 지속 시간 3초로 설정 }, - }) + }); return { id, dismiss, update, - } + }; } function useToast() { - const [state, setState] = React.useState(memoryState) + const [state, setState] = React.useState(memoryState); React.useEffect(() => { - listeners.push(setState) + listeners.push(setState); return () => { - const index = listeners.indexOf(setState) + const index = listeners.indexOf(setState); if (index > -1) { - listeners.splice(index, 1) + listeners.splice(index, 1); } - } - }, [state]) + }; + }, [state]); return { ...state, toast, - dismiss: (toastId?: string) => dispatch({ type: actionTypes.DISMISS_TOAST, toastId }), - } + dismiss: (toastId?: string) => + dispatch({ type: actionTypes.DISMISS_TOAST, toastId }), + }; } -export { useToast, toast } +export { useToast, toast }; diff --git a/src/hooks/toast/reducer.ts b/src/hooks/toast/reducer.ts index ef237fc..8d640fb 100644 --- a/src/hooks/toast/reducer.ts +++ b/src/hooks/toast/reducer.ts @@ -1,10 +1,9 @@ - -import { TOAST_REMOVE_DELAY, TOAST_LIMIT } from './constants' -import { Action, State, actionTypes } from './types' -import { dispatch } from './toastManager' +import { TOAST_REMOVE_DELAY, TOAST_LIMIT } from "./constants"; +import { Action, State, actionTypes } from "./types"; +import { dispatch } from "./toastManager"; // 토스트 타임아웃 맵 -export const toastTimeouts = new Map>() +export const toastTimeouts = new Map>(); // 토스트 자동 제거 함수 export const addToRemoveQueue = (toastId: string) => { @@ -15,15 +14,15 @@ export const addToRemoveQueue = (toastId: string) => { } const timeout = setTimeout(() => { - toastTimeouts.delete(toastId) + toastTimeouts.delete(toastId); dispatch({ type: actionTypes.REMOVE_TOAST, toastId: toastId, - }) - }, TOAST_REMOVE_DELAY) + }); + }, TOAST_REMOVE_DELAY); - toastTimeouts.set(toastId, timeout) -} + toastTimeouts.set(toastId, timeout); +}; export const reducer = (state: State, action: Action): State => { switch (action.type) { @@ -35,7 +34,7 @@ export const reducer = (state: State, action: Action): State => { return { ...state, toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), - } + }; case actionTypes.UPDATE_TOAST: return { @@ -43,17 +42,17 @@ export const reducer = (state: State, action: Action): State => { toasts: state.toasts.map((t) => t.id === action.toast.id ? { ...t, ...action.toast } : t ), - } + }; case actionTypes.DISMISS_TOAST: { - const { toastId } = action + const { toastId } = action; if (toastId) { - addToRemoveQueue(toastId) + addToRemoveQueue(toastId); } else { state.toasts.forEach((toast) => { - addToRemoveQueue(toast.id) - }) + addToRemoveQueue(toast.id); + }); } return { @@ -66,20 +65,20 @@ export const reducer = (state: State, action: Action): State => { } : t ), - } + }; } case actionTypes.REMOVE_TOAST: if (action.toastId === undefined) { return { ...state, toasts: [], - } + }; } return { ...state, toasts: state.toasts.filter((t) => t.id !== action.toastId), - } + }; default: - return state + return state; } -} +}; diff --git a/src/hooks/toast/store.ts b/src/hooks/toast/store.ts index 8dbed91..9c4a3da 100644 --- a/src/hooks/toast/store.ts +++ b/src/hooks/toast/store.ts @@ -1,8 +1,7 @@ - -import { State } from './types' +import { State } from "./types"; // 전역 상태 및 리스너 -export const listeners: Array<(state: State) => void> = [] +export const listeners: Array<(state: State) => void> = []; // memoryState와 lastAction은 toastManager.ts에서 관리 -export { memoryState } from './toastManager'; +export { memoryState } from "./toastManager"; diff --git a/src/hooks/toast/toastManager.ts b/src/hooks/toast/toastManager.ts index e768a53..e8e2f50 100644 --- a/src/hooks/toast/toastManager.ts +++ b/src/hooks/toast/toastManager.ts @@ -1,52 +1,52 @@ - -import { Action, actionTypes } from './types' -import { TOAST_LIMIT } from './constants' -import { reducer } from './reducer' -import { listeners } from './store' +import { Action, actionTypes } from "./types"; +import { logger } from "@/utils/logger"; +import { TOAST_LIMIT } from "./constants"; +import { reducer } from "./reducer"; +import { listeners } from "./store"; // 전역 상태 관리 let memoryState = { toasts: [] }; let lastAction = null; // ID 생성기 -let count = 0 +let count = 0; export function genId() { - count = (count + 1) % Number.MAX_SAFE_INTEGER - return count.toString() + count = (count + 1) % Number.MAX_SAFE_INTEGER; + return count.toString(); } export function dispatch(action: Action) { // 마지막 액션 정보 추출 let actionId: string | undefined = undefined; - if ('toast' in action && action.toast) { + if ("toast" in action && action.toast) { actionId = action.toast.id; - } else if ('toastId' in action) { + } else if ("toastId" in action) { actionId = action.toastId; } - + // 동일한 토스트에 대한 중복 액션 방지 const now = Date.now(); - const isSameAction = lastAction && - lastAction.type === action.type && - ((action.type === actionTypes.ADD_TOAST && - lastAction.time > now - 1000) || // ADD 액션은 1초 내 중복 방지 - (action.type !== actionTypes.ADD_TOAST && - actionId === lastAction.id && - lastAction.time > now - 300)); // 다른 액션은 300ms 내 중복 방지 - + const isSameAction = + lastAction && + lastAction.type === action.type && + ((action.type === actionTypes.ADD_TOAST && lastAction.time > now - 1000) || // ADD 액션은 1초 내 중복 방지 + (action.type !== actionTypes.ADD_TOAST && + actionId === lastAction.id && + lastAction.time > now - 300)); // 다른 액션은 300ms 내 중복 방지 + if (isSameAction) { - console.log('중복 토스트 액션 무시:', action.type); + logger.info("중복 토스트 액션 무시:", action.type); return; } - + // 액션 추적 업데이트 - lastAction = { - type: action.type, - id: actionId, - time: now + lastAction = { + type: action.type, + id: actionId, + time: now, }; - + // REMOVE_TOAST 액션 우선순위 높임 if (action.type === actionTypes.REMOVE_TOAST) { // 즉시 처리 @@ -56,7 +56,7 @@ export function dispatch(action: Action) { }); return; } - + // 실제 상태 업데이트 및 리스너 호출 memoryState = reducer(memoryState, action); listeners.forEach((listener) => { diff --git a/src/hooks/toast/types.ts b/src/hooks/toast/types.ts index 878a99f..6fe2fd0 100644 --- a/src/hooks/toast/types.ts +++ b/src/hooks/toast/types.ts @@ -1,46 +1,45 @@ - -import * as React from "react" +import * as React from "react"; export type ToasterToast = { - id: string - title?: React.ReactNode - description?: React.ReactNode - action?: React.ReactNode - variant?: "default" | "destructive" - duration?: number - open?: boolean - onOpenChange?: (open: boolean) => void -} + id: string; + title?: React.ReactNode; + description?: React.ReactNode; + action?: React.ReactNode; + variant?: "default" | "destructive"; + duration?: number; + open?: boolean; + onOpenChange?: (open: boolean) => void; +}; export const actionTypes = { ADD_TOAST: "ADD_TOAST", UPDATE_TOAST: "UPDATE_TOAST", DISMISS_TOAST: "DISMISS_TOAST", REMOVE_TOAST: "REMOVE_TOAST", -} as const +} as const; -export type ActionType = typeof actionTypes +export type ActionType = typeof actionTypes; export type Action = | { - type: ActionType["ADD_TOAST"] - toast: ToasterToast + type: ActionType["ADD_TOAST"]; + toast: ToasterToast; } | { - type: ActionType["UPDATE_TOAST"] - toast: Partial & { id: string } + type: ActionType["UPDATE_TOAST"]; + toast: Partial & { id: string }; } | { - type: ActionType["DISMISS_TOAST"] - toastId?: string + type: ActionType["DISMISS_TOAST"]; + toastId?: string; } | { - type: ActionType["REMOVE_TOAST"] - toastId?: string - } + type: ActionType["REMOVE_TOAST"]; + toastId?: string; + }; export interface State { - toasts: ToasterToast[] + toasts: ToasterToast[]; } -export type Toast = Omit +export type Toast = Omit; diff --git a/src/hooks/transactions/dateUtils.ts b/src/hooks/transactions/dateUtils.ts index d8ec923..3306091 100644 --- a/src/hooks/transactions/dateUtils.ts +++ b/src/hooks/transactions/dateUtils.ts @@ -1,13 +1,23 @@ - -import { format, parse, addMonths, subMonths } from 'date-fns'; -import { ko } from 'date-fns/locale'; +import { format, parse, addMonths, subMonths } from "date-fns"; +import { logger } from "@/utils/logger"; +import { ko } from "date-fns/locale"; /** * 월 이름 배열 (한국어) */ export const MONTHS_KR = [ - '1월', '2월', '3월', '4월', '5월', '6월', - '7월', '8월', '9월', '10월', '11월', '12월' + "1월", + "2월", + "3월", + "4월", + "5월", + "6월", + "7월", + "8월", + "9월", + "10월", + "11월", + "12월", ]; /** @@ -22,7 +32,7 @@ export const isValidMonth = (month: string): boolean => { * 현재 년월 가져오기 */ export const getCurrentMonth = (): string => { - return format(new Date(), 'yyyy-MM'); + return format(new Date(), "yyyy-MM"); }; /** @@ -31,19 +41,19 @@ export const getCurrentMonth = (): string => { export const getPrevMonth = (month: string): string => { // 입력값 검증 if (!isValidMonth(month)) { - console.warn('유효하지 않은 월 형식:', month); + logger.warn("유효하지 않은 월 형식:", month); return getCurrentMonth(); } - + try { // 월 문자열을 날짜로 파싱 - const date = parse(month, 'yyyy-MM', new Date()); + const date = parse(month, "yyyy-MM", new Date()); // 한 달 이전 const prevMonth = subMonths(date, 1); // yyyy-MM 형식으로 반환 - return format(prevMonth, 'yyyy-MM'); + return format(prevMonth, "yyyy-MM"); } catch (error) { - console.error('이전 월 계산 중 오류:', error); + logger.error("이전 월 계산 중 오류:", error); return getCurrentMonth(); } }; @@ -54,19 +64,19 @@ export const getPrevMonth = (month: string): string => { export const getNextMonth = (month: string): string => { // 입력값 검증 if (!isValidMonth(month)) { - console.warn('유효하지 않은 월 형식:', month); + logger.warn("유효하지 않은 월 형식:", month); return getCurrentMonth(); } - + try { // 월 문자열을 날짜로 파싱 - const date = parse(month, 'yyyy-MM', new Date()); + const date = parse(month, "yyyy-MM", new Date()); // 한 달 이후 const nextMonth = addMonths(date, 1); // yyyy-MM 형식으로 반환 - return format(nextMonth, 'yyyy-MM'); + return format(nextMonth, "yyyy-MM"); } catch (error) { - console.error('다음 월 계산 중 오류:', error); + logger.error("다음 월 계산 중 오류:", error); return getCurrentMonth(); } }; @@ -78,16 +88,16 @@ export const formatMonthForDisplay = (month: string): string => { try { // 입력값 검증 if (!isValidMonth(month)) { - console.warn('유효하지 않은 월 형식:', month); - return format(new Date(), 'yyyy년 MM월', { locale: ko }); + logger.warn("유효하지 않은 월 형식:", month); + return format(new Date(), "yyyy년 MM월", { locale: ko }); } - + // 월 문자열을 날짜로 파싱 - const date = parse(month, 'yyyy-MM', new Date()); + const date = parse(month, "yyyy-MM", new Date()); // yyyy년 MM월 형식으로 반환 (한국어 로케일) - return format(date, 'yyyy년 MM월', { locale: ko }); + return format(date, "yyyy년 MM월", { locale: ko }); } catch (error) { - console.error('월 형식 변환 중 오류:', error); + logger.error("월 형식 변환 중 오류:", error); return month; } }; diff --git a/src/hooks/transactions/deleteTransaction.ts b/src/hooks/transactions/deleteTransaction.ts index 1231441..12a7d62 100644 --- a/src/hooks/transactions/deleteTransaction.ts +++ b/src/hooks/transactions/deleteTransaction.ts @@ -1,11 +1,11 @@ - -import { useCallback } from 'react'; -import { Transaction } from '@/components/TransactionCard'; -import { useAuth } from '@/contexts/auth/useAuth'; -import { toast } from '@/hooks/useToast.wrapper'; -import { saveTransactionsToStorage } from './storageUtils'; -import { deleteTransactionFromSupabase } from './supabaseUtils'; -import { addToDeletedTransactions } from '@/utils/sync/transaction/deletedTransactionsTracker'; +import { useCallback } from "react"; +import { logger } from "@/utils/logger"; +import { Transaction } from "@/components/TransactionCard"; +import { useAuth } from "@/contexts/auth/useAuth"; +import { toast } from "@/hooks/useToast.wrapper"; +import { saveTransactionsToStorage } from "./storageUtils"; +import { deleteTransactionFromSupabase } from "./supabaseUtils"; +import { addToDeletedTransactions } from "@/utils/sync/transaction/deletedTransactionsTracker"; /** * 트랜잭션 삭제 기능을 위한 훅 @@ -19,72 +19,87 @@ export const useDeleteTransaction = ( /** * 트랜잭션 삭제 처리 */ - const deleteTransaction = useCallback(async (transactionId: string): Promise => { - try { - console.log(`[트랜잭션 삭제] 시작: ID=${transactionId}`); - - // 트랜잭션 존재 확인 - const transaction = transactions.find(t => t.id === transactionId); - if (!transaction) { - console.warn(`[트랜잭션 삭제] 트랜잭션을 찾을 수 없음: ${transactionId}`); + const deleteTransaction = useCallback( + async (transactionId: string): Promise => { + try { + logger.info(`[트랜잭션 삭제] 시작: ID=${transactionId}`); + + // 트랜잭션 존재 확인 + const transaction = transactions.find((t) => t.id === transactionId); + if (!transaction) { + logger.warn( + `[트랜잭션 삭제] 트랜잭션을 찾을 수 없음: ${transactionId}` + ); + return false; + } + + logger.info( + `[트랜잭션 삭제] 삭제 대상: "${transaction.title}", 금액: ${transaction.amount}원` + ); + + // 트랜잭션 목록에서 제거 + const updatedTransactions = transactions.filter( + (t) => t.id !== transactionId + ); + + // 로컬 스토리지 업데이트 + saveTransactionsToStorage(updatedTransactions); + logger.info(`[트랜잭션 삭제] 로컬 저장소 업데이트 완료`); + + // 상태 업데이트 + setTransactions(updatedTransactions); + + // 클라우드 동기화 (Supabase) + if (user) { + try { + logger.info(`[트랜잭션 삭제] Supabase 삭제 시작: ${transactionId}`); + await deleteTransactionFromSupabase(user, transactionId); + logger.info(`[트랜잭션 삭제] Supabase 삭제 성공: ${transactionId}`); + } catch (syncError) { + logger.error(`[트랜잭션 삭제] Supabase 삭제 실패:`, syncError); + // 삭제 실패하더라도 로컬에서는 삭제됨, 추적 목록에 추가 + addToDeletedTransactions(transactionId); + logger.info( + `[트랜잭션 삭제] 삭제 트랜잭션 추적 목록에 추가: ${transactionId}` + ); + } + } else { + // 오프라인 상태이거나 로그인되지 않은 경우 추적 목록에 추가 + addToDeletedTransactions(transactionId); + logger.info( + `[트랜잭션 삭제] 오프라인 삭제: 추적 목록에 추가 ${transactionId}` + ); + } + + // 이벤트 발생 + window.dispatchEvent(new Event("transactionUpdated")); + window.dispatchEvent( + new CustomEvent("transactionDeleted", { + detail: { id: transactionId }, + }) + ); + + // 토스트 메시지 표시 + toast({ + title: "지출이 삭제되었습니다", + description: `${transaction.title} 항목이 삭제되었습니다.`, + duration: 3000, + }); + + logger.info(`[트랜잭션 삭제] 완료: ${transactionId}`); + return true; + } catch (error) { + logger.error(`[트랜잭션 삭제] 오류 발생:`, error); + toast({ + title: "삭제 실패", + description: "지출 항목 삭제 중 오류가 발생했습니다.", + variant: "destructive", + }); return false; } - - console.log(`[트랜잭션 삭제] 삭제 대상: "${transaction.title}", 금액: ${transaction.amount}원`); - - // 트랜잭션 목록에서 제거 - const updatedTransactions = transactions.filter(t => t.id !== transactionId); - - // 로컬 스토리지 업데이트 - saveTransactionsToStorage(updatedTransactions); - console.log(`[트랜잭션 삭제] 로컬 저장소 업데이트 완료`); - - // 상태 업데이트 - setTransactions(updatedTransactions); - - // 클라우드 동기화 (Supabase) - if (user) { - try { - console.log(`[트랜잭션 삭제] Supabase 삭제 시작: ${transactionId}`); - await deleteTransactionFromSupabase(user, transactionId); - console.log(`[트랜잭션 삭제] Supabase 삭제 성공: ${transactionId}`); - } catch (syncError) { - console.error(`[트랜잭션 삭제] Supabase 삭제 실패:`, syncError); - // 삭제 실패하더라도 로컬에서는 삭제됨, 추적 목록에 추가 - addToDeletedTransactions(transactionId); - console.log(`[트랜잭션 삭제] 삭제 트랜잭션 추적 목록에 추가: ${transactionId}`); - } - } else { - // 오프라인 상태이거나 로그인되지 않은 경우 추적 목록에 추가 - addToDeletedTransactions(transactionId); - console.log(`[트랜잭션 삭제] 오프라인 삭제: 추적 목록에 추가 ${transactionId}`); - } - - // 이벤트 발생 - window.dispatchEvent(new Event('transactionUpdated')); - window.dispatchEvent(new CustomEvent('transactionDeleted', { - detail: { id: transactionId } - })); - - // 토스트 메시지 표시 - toast({ - title: "지출이 삭제되었습니다", - description: `${transaction.title} 항목이 삭제되었습니다.`, - duration: 3000 - }); - - console.log(`[트랜잭션 삭제] 완료: ${transactionId}`); - return true; - } catch (error) { - console.error(`[트랜잭션 삭제] 오류 발생:`, error); - toast({ - title: "삭제 실패", - description: "지출 항목 삭제 중 오류가 발생했습니다.", - variant: "destructive" - }); - return false; - } - }, [transactions, setTransactions, user]); + }, + [transactions, setTransactions, user] + ); return { deleteTransaction }; }; diff --git a/src/hooks/transactions/filterOperations/index.ts b/src/hooks/transactions/filterOperations/index.ts index 6bdbec9..ef8c9c8 100644 --- a/src/hooks/transactions/filterOperations/index.ts +++ b/src/hooks/transactions/filterOperations/index.ts @@ -1,9 +1,13 @@ - -import { useCallback, useEffect } from 'react'; -import { Transaction } from '@/contexts/budget/types'; -import { getCurrentMonth, getPrevMonth, getNextMonth } from '../dateUtils'; -import { filterTransactionsByMonth, filterTransactionsByQuery, calculateTotalExpenses } from '../filterUtils'; -import { parseTransactionDate } from '@/utils/dateParser'; +import { useCallback, useEffect } from "react"; +import { logger } from "@/utils/logger"; +import { Transaction } from "@/contexts/budget/types"; +import { getCurrentMonth, getPrevMonth, getNextMonth } from "../dateUtils"; +import { + filterTransactionsByMonth, + filterTransactionsByQuery, + calculateTotalExpenses, +} from "../filterUtils"; +import { parseTransactionDate } from "@/utils/dateParser"; interface UseTransactionsFilteringProps { transactions: Transaction[]; @@ -22,27 +26,32 @@ export const useTransactionsFiltering = ({ selectedMonth, setSelectedMonth, searchQuery, - setFilteredTransactions + setFilteredTransactions, }: UseTransactionsFilteringProps) => { // 필터링 적용 useEffect(() => { - console.log('트랜잭션 필터링 적용:', { 선택된월: selectedMonth, 검색어: searchQuery }); - + logger.info("트랜잭션 필터링 적용:", { + 선택된월: selectedMonth, + 검색어: searchQuery, + }); + try { // 먼저 월별 필터링 - 개선된 날짜 처리 기능 사용 - const monthFiltered = filterTransactionsByMonth(transactions, selectedMonth); - console.log('월별 필터링 결과:', monthFiltered.length); - + const monthFiltered = filterTransactionsByMonth( + transactions, + selectedMonth + ); + logger.info("월별 필터링 결과:", monthFiltered.length); + // 그 다음 검색어 필터링 - const searchFiltered = searchQuery + const searchFiltered = searchQuery ? filterTransactionsByQuery(monthFiltered, searchQuery) : monthFiltered; - - console.log('최종 필터링 결과:', searchFiltered.length); + + logger.info("최종 필터링 결과:", searchFiltered.length); setFilteredTransactions(searchFiltered); - } catch (error) { - console.error('트랜잭션 필터링 중 오류 발생:', error); + logger.error("트랜잭션 필터링 중 오류 발생:", error); // 오류 발생 시 원본 데이터 유지 setFilteredTransactions(transactions); } @@ -59,16 +68,19 @@ export const useTransactionsFiltering = ({ }, [selectedMonth, setSelectedMonth]); // 총 지출 계산 - 개선된 계산 로직 사용 - const getTotalExpenses = useCallback((filteredTransactions: Transaction[]): number => { - console.log('총 지출 계산 중...', filteredTransactions.length); - const total = calculateTotalExpenses(filteredTransactions); - console.log('계산된 총 지출:', total); - return total; - }, []); + const getTotalExpenses = useCallback( + (filteredTransactions: Transaction[]): number => { + logger.info("총 지출 계산 중...", filteredTransactions.length); + const total = calculateTotalExpenses(filteredTransactions); + logger.info("계산된 총 지출:", total); + return total; + }, + [] + ); return { handlePrevMonth, handleNextMonth, - getTotalExpenses + getTotalExpenses, }; }; diff --git a/src/hooks/transactions/filterOperations/types.ts b/src/hooks/transactions/filterOperations/types.ts index da24388..03f43f5 100644 --- a/src/hooks/transactions/filterOperations/types.ts +++ b/src/hooks/transactions/filterOperations/types.ts @@ -1,5 +1,4 @@ - -import { Transaction } from '@/components/TransactionCard'; +import { Transaction } from "@/components/TransactionCard"; export interface FilteringProps { transactions: Transaction[]; diff --git a/src/hooks/transactions/filterOperations/useFilterApplication.ts b/src/hooks/transactions/filterOperations/useFilterApplication.ts index d214967..043d352 100644 --- a/src/hooks/transactions/filterOperations/useFilterApplication.ts +++ b/src/hooks/transactions/filterOperations/useFilterApplication.ts @@ -1,55 +1,65 @@ - -import { useCallback, useEffect } from 'react'; -import { Transaction } from '@/components/TransactionCard'; -import { FilteringProps } from './types'; -import { MONTHS_KR } from '../dateUtils'; +import { useCallback, useEffect } from "react"; +import { logger } from "@/utils/logger"; +import { Transaction } from "@/components/TransactionCard"; +import { FilteringProps } from "./types"; +import { MONTHS_KR } from "../dateUtils"; /** * 거래 필터링 로직 * 선택된 월과 검색어를 기준으로 거래를 필터링합니다. */ -export const useFilterApplication = ({ - transactions, - selectedMonth, - searchQuery, - setFilteredTransactions -}: Pick) => { - +export const useFilterApplication = ({ + transactions, + selectedMonth, + searchQuery, + setFilteredTransactions, +}: Pick< + FilteringProps, + "transactions" | "selectedMonth" | "searchQuery" | "setFilteredTransactions" +>) => { // 거래 필터링 함수 const filterTransactions = useCallback(() => { try { - console.log('필터링 시작, 전체 트랜잭션:', transactions.length); - console.log('선택된 월:', selectedMonth); - + logger.info("필터링 시작, 전체 트랜잭션:", transactions.length); + logger.info("선택된 월:", selectedMonth); + // 선택된 월 정보 파싱 const selectedMonthName = selectedMonth; - const monthNumber = MONTHS_KR.findIndex(month => month === selectedMonthName) + 1; - + const monthNumber = + MONTHS_KR.findIndex((month) => month === selectedMonthName) + 1; + // 월별 필터링 - let filtered = transactions.filter(transaction => { - if (!transaction.date) return false; - - console.log(`트랜잭션 날짜 확인: "${transaction.date}", 타입: ${typeof transaction.date}`); - + let filtered = transactions.filter((transaction) => { + if (!transaction.date) { + return false; + } + + logger.info( + `트랜잭션 날짜 확인: "${transaction.date}", 타입: ${typeof transaction.date}` + ); + // 다양한 날짜 형식 처리 if (transaction.date.includes(selectedMonthName)) { return true; // 선택된 월 이름이 포함된 경우 } - - if (transaction.date.includes('오늘')) { + + if (transaction.date.includes("오늘")) { // 오늘 날짜가 해당 월인지 확인 const today = new Date(); const currentMonth = today.getMonth() + 1; // 0부터 시작하므로 +1 return currentMonth === monthNumber; } - + // 다른 형식의 날짜도 시도 try { // ISO 형식이 아닌 경우 처리 - if (transaction.date.includes('년') || transaction.date.includes('월')) { + if ( + transaction.date.includes("년") || + transaction.date.includes("월") + ) { return transaction.date.includes(selectedMonthName); } - + // 표준 날짜 문자열 처리 시도 const date = new Date(transaction.date); if (!isNaN(date.getTime())) { @@ -57,31 +67,32 @@ export const useFilterApplication = ({ return transactionMonth === monthNumber; } } catch (e) { - console.error('날짜 파싱 오류:', e); + logger.error("날짜 파싱 오류:", e); } - + // 기본적으로 모든 트랜잭션 포함 return true; }); - - console.log(`월별 필터링 결과: ${filtered.length} 트랜잭션`); + + logger.info(`월별 필터링 결과: ${filtered.length} 트랜잭션`); // 검색어에 따른 필터링 if (searchQuery.trim()) { const searchLower = searchQuery.toLowerCase(); - filtered = filtered.filter(transaction => - transaction.title.toLowerCase().includes(searchLower) || - transaction.category.toLowerCase().includes(searchLower) || - transaction.amount.toString().includes(searchQuery) + filtered = filtered.filter( + (transaction) => + transaction.title.toLowerCase().includes(searchLower) || + transaction.category.toLowerCase().includes(searchLower) || + transaction.amount.toString().includes(searchQuery) ); - console.log(`검색어 필터링 결과: ${filtered.length} 트랜잭션`); + logger.info(`검색어 필터링 결과: ${filtered.length} 트랜잭션`); } - + // 결과 설정 setFilteredTransactions(filtered); - console.log('최종 필터링 결과:', filtered); + logger.info("최종 필터링 결과:", filtered); } catch (error) { - console.error('거래 필터링 중 오류:', error); + logger.error("거래 필터링 중 오류:", error); setFilteredTransactions([]); } }, [transactions, selectedMonth, searchQuery, setFilteredTransactions]); @@ -92,6 +103,6 @@ export const useFilterApplication = ({ }, [transactions, selectedMonth, searchQuery, filterTransactions]); return { - filterTransactions + filterTransactions, }; }; diff --git a/src/hooks/transactions/filterOperations/useMonthSelection.ts b/src/hooks/transactions/filterOperations/useMonthSelection.ts index 51046c7..22a3e89 100644 --- a/src/hooks/transactions/filterOperations/useMonthSelection.ts +++ b/src/hooks/transactions/filterOperations/useMonthSelection.ts @@ -1,34 +1,34 @@ - -import { useCallback } from 'react'; -import { getPrevMonth, getNextMonth } from '../dateUtils'; +import { useCallback } from "react"; +import { logger } from "@/utils/logger"; +import { getPrevMonth, getNextMonth } from "../dateUtils"; /** * 월 선택 관련 훅 * 이전/다음 월 이동 기능을 제공합니다. */ -export const useMonthSelection = ({ - selectedMonth, - setSelectedMonth -}: { +export const useMonthSelection = ({ + selectedMonth, + setSelectedMonth, +}: { selectedMonth: string; setSelectedMonth: (month: string) => void; }) => { // 이전 월로 이동 const handlePrevMonth = useCallback(() => { const prevMonth = getPrevMonth(selectedMonth); - console.log(`월 변경: ${selectedMonth} -> ${prevMonth}`); + logger.info(`월 변경: ${selectedMonth} -> ${prevMonth}`); setSelectedMonth(prevMonth); }, [selectedMonth, setSelectedMonth]); // 다음 월로 이동 const handleNextMonth = useCallback(() => { const nextMonth = getNextMonth(selectedMonth); - console.log(`월 변경: ${selectedMonth} -> ${nextMonth}`); + logger.info(`월 변경: ${selectedMonth} -> ${nextMonth}`); setSelectedMonth(nextMonth); }, [selectedMonth, setSelectedMonth]); return { handlePrevMonth, - handleNextMonth + handleNextMonth, }; }; diff --git a/src/hooks/transactions/filterOperations/useTotalCalculation.ts b/src/hooks/transactions/filterOperations/useTotalCalculation.ts index babbc12..3456092 100644 --- a/src/hooks/transactions/filterOperations/useTotalCalculation.ts +++ b/src/hooks/transactions/filterOperations/useTotalCalculation.ts @@ -1,6 +1,5 @@ - -import { Transaction } from '@/components/TransactionCard'; -import { calculateTotalExpenses } from '../filterUtils'; +import { Transaction } from "@/components/TransactionCard"; +import { calculateTotalExpenses } from "../filterUtils"; /** * 총 지출 계산 관련 훅 @@ -13,6 +12,6 @@ export const useTotalCalculation = () => { }; return { - getTotalExpenses + getTotalExpenses, }; }; diff --git a/src/hooks/transactions/filterUtils.ts b/src/hooks/transactions/filterUtils.ts index 919dbc3..622f551 100644 --- a/src/hooks/transactions/filterUtils.ts +++ b/src/hooks/transactions/filterUtils.ts @@ -1,45 +1,54 @@ - -import { Transaction } from '@/contexts/budget/types'; -import { parseTransactionDate } from '@/utils/dateParser'; -import { format } from 'date-fns'; +import { Transaction } from "@/contexts/budget/types"; +import { logger } from "@/utils/logger"; +import { parseTransactionDate } from "@/utils/dateParser"; +import { format } from "date-fns"; /** * 트랜잭션을 월별로 필터링 */ -export const filterTransactionsByMonth = (transactions: Transaction[], selectedMonth: string): Transaction[] => { +export const filterTransactionsByMonth = ( + transactions: Transaction[], + selectedMonth: string +): Transaction[] => { if (!transactions || transactions.length === 0) { return []; } - console.log(`월별 필터링 시작: ${selectedMonth}, 트랜잭션 수: ${transactions.length}`); - + logger.info( + `월별 필터링 시작: ${selectedMonth}, 트랜잭션 수: ${transactions.length}` + ); + try { - const [year, month] = selectedMonth.split('-').map(Number); - - const filtered = transactions.filter(transaction => { + const [year, month] = selectedMonth.split("-").map(Number); + + const filtered = transactions.filter((transaction) => { const date = parseTransactionDate(transaction.date); - + if (!date) { - console.warn(`날짜를 파싱할 수 없음: ${transaction.date}, 트랜잭션 ID: ${transaction.id}`); + logger.warn( + `날짜를 파싱할 수 없음: ${transaction.date}, 트랜잭션 ID: ${transaction.id}` + ); return false; } - + const transactionYear = date.getFullYear(); const transactionMonth = date.getMonth() + 1; // JavaScript 월은 0부터 시작하므로 +1 - + const match = transactionYear === year && transactionMonth === month; - + if (match) { - console.log(`트랜잭션 매칭: ${transaction.id}, 제목: ${transaction.title}, 날짜: ${transaction.date}`); + logger.info( + `트랜잭션 매칭: ${transaction.id}, 제목: ${transaction.title}, 날짜: ${transaction.date}` + ); } - + return match; }); - - console.log(`월별 필터링 결과: ${filtered.length}개 트랜잭션`); + + logger.info(`월별 필터링 결과: ${filtered.length}개 트랜잭션`); return filtered; } catch (error) { - console.error('월별 필터링 중 오류:', error); + logger.error("월별 필터링 중 오류:", error); return []; } }; @@ -47,18 +56,25 @@ export const filterTransactionsByMonth = (transactions: Transaction[], selectedM /** * 트랜잭션을 검색어로 필터링 */ -export const filterTransactionsByQuery = (transactions: Transaction[], searchQuery: string): Transaction[] => { - if (!searchQuery || searchQuery.trim() === '') { +export const filterTransactionsByQuery = ( + transactions: Transaction[], + searchQuery: string +): Transaction[] => { + if (!searchQuery || searchQuery.trim() === "") { return transactions; } - + const normalizedQuery = searchQuery.toLowerCase().trim(); - - return transactions.filter(transaction => { - const titleMatch = transaction.title.toLowerCase().includes(normalizedQuery); - const categoryMatch = transaction.category.toLowerCase().includes(normalizedQuery); + + return transactions.filter((transaction) => { + const titleMatch = transaction.title + .toLowerCase() + .includes(normalizedQuery); + const categoryMatch = transaction.category + .toLowerCase() + .includes(normalizedQuery); const amountMatch = transaction.amount.toString().includes(normalizedQuery); - + return titleMatch || categoryMatch || amountMatch; }); }; @@ -68,49 +84,55 @@ export const filterTransactionsByQuery = (transactions: Transaction[], searchQue */ export const calculateTotalExpenses = (transactions: Transaction[]): number => { if (!transactions || transactions.length === 0) { - console.log('계산할 트랜잭션이 없습니다.'); + logger.info("계산할 트랜잭션이 없습니다."); return 0; } - - console.log(`총 지출 계산 시작: 트랜잭션 ${transactions.length}개`); - + + logger.info(`총 지출 계산 시작: 트랜잭션 ${transactions.length}개`); + // 지출 타입만 필터링하고 합산 const expenses = transactions - .filter(t => t.type === 'expense') + .filter((t) => t.type === "expense") .reduce((sum, transaction) => { const amount = Number(transaction.amount); if (isNaN(amount)) { - console.warn(`유효하지 않은 금액: ${transaction.amount}, 트랜잭션 ID: ${transaction.id}`); + logger.warn( + `유효하지 않은 금액: ${transaction.amount}, 트랜잭션 ID: ${transaction.id}` + ); return sum; } return sum + amount; }, 0); - - console.log(`총 지출 계산 결과: ${expenses}원`); + + logger.info(`총 지출 계산 결과: ${expenses}원`); return expenses; }; /** * 트랜잭션을 날짜별로 그룹화 */ -export const groupTransactionsByDate = (transactions: Transaction[]): Record => { +export const groupTransactionsByDate = ( + transactions: Transaction[] +): Record => { const groups: Record = {}; - - transactions.forEach(transaction => { + + transactions.forEach((transaction) => { const date = parseTransactionDate(transaction.date); if (!date) { - console.warn(`날짜를 파싱할 수 없음: ${transaction.date}, 트랜잭션 ID: ${transaction.id}`); + logger.warn( + `날짜를 파싱할 수 없음: ${transaction.date}, 트랜잭션 ID: ${transaction.id}` + ); return; } - - const formattedDate = format(date, 'yyyy-MM-dd'); - + + const formattedDate = format(date, "yyyy-MM-dd"); + if (!groups[formattedDate]) { groups[formattedDate] = []; } - + groups[formattedDate].push(transaction); }); - + return groups; }; diff --git a/src/hooks/transactions/index.ts b/src/hooks/transactions/index.ts index 2379ba9..b28304e 100644 --- a/src/hooks/transactions/index.ts +++ b/src/hooks/transactions/index.ts @@ -1,5 +1,13 @@ - // 트랜잭션 관련 모든 훅과 유틸리티 함수를 재내보내기 -export { useTransactions } from './useTransactions'; -export { MONTHS_KR, getCurrentMonth, getPrevMonth, getNextMonth } from './dateUtils'; -export { filterTransactionsByMonth, filterTransactionsByQuery, calculateTotalExpenses } from './filterUtils'; +export { useTransactions } from "./useTransactions"; +export { + MONTHS_KR, + getCurrentMonth, + getPrevMonth, + getNextMonth, +} from "./dateUtils"; +export { + filterTransactionsByMonth, + filterTransactionsByQuery, + calculateTotalExpenses, +} from "./filterUtils"; diff --git a/src/hooks/transactions/storageUtils.ts b/src/hooks/transactions/storageUtils.ts index dba0801..dc88e35 100644 --- a/src/hooks/transactions/storageUtils.ts +++ b/src/hooks/transactions/storageUtils.ts @@ -1,90 +1,94 @@ - -import { Transaction } from '@/contexts/budget/types'; -import { toast } from '@/hooks/useToast.wrapper'; -import { EXPENSE_CATEGORIES } from '@/constants/categoryIcons'; +import { Transaction } from "@/contexts/budget/types"; +import { storageLogger } from "@/utils/logger"; +import { toast } from "@/hooks/useToast.wrapper"; +import { EXPENSE_CATEGORIES } from "@/constants/categoryIcons"; // 로컬 스토리지에서 트랜잭션 데이터 로드 export const loadTransactionsFromStorage = (): Transaction[] => { try { // 로컬 스토리지에서 트랜잭션 데이터 가져오기 - const localDataStr = localStorage.getItem('transactions'); - console.log('로컬 트랜잭션 데이터:', localDataStr); - + const localDataStr = localStorage.getItem("transactions"); + storageLogger.info("로컬 트랜잭션 데이터:", localDataStr); + if (localDataStr) { try { const localData = JSON.parse(localDataStr); - + // 지원되는 카테고리로 필터링 및 카테고리명 변환 const filteredData = localData.map((transaction: Transaction) => { - if (transaction.type === 'expense') { + if (transaction.type === "expense") { // 기존 카테고리명 변환 - if (transaction.category === '식비') { - return { - ...transaction, - category: '음식', - paymentMethod: transaction.paymentMethod || '신용카드' // 기존 데이터에 없으면 기본값 추가 + if (transaction.category === "식비") { + return { + ...transaction, + category: "음식", + paymentMethod: transaction.paymentMethod || "신용카드", // 기존 데이터에 없으면 기본값 추가 }; - } else if (transaction.category === '생활비') { - return { - ...transaction, - category: '쇼핑', - paymentMethod: transaction.paymentMethod || '신용카드' // 기존 데이터에 없으면 기본값 추가 + } else if (transaction.category === "생활비") { + return { + ...transaction, + category: "쇼핑", + paymentMethod: transaction.paymentMethod || "신용카드", // 기존 데이터에 없으면 기본값 추가 }; } else if (!EXPENSE_CATEGORIES.includes(transaction.category)) { - return { - ...transaction, - category: '쇼핑', - paymentMethod: transaction.paymentMethod || '신용카드' // 기존 데이터에 없으면 기본값 추가 + return { + ...transaction, + category: "쇼핑", + paymentMethod: transaction.paymentMethod || "신용카드", // 기존 데이터에 없으면 기본값 추가 }; // 지원되지 않는 카테고리는 '쇼핑'으로 } - + // 기존 데이터에 paymentMethod가 없으면 기본값 추가 if (!transaction.paymentMethod) { return { ...transaction, - paymentMethod: '신용카드' + paymentMethod: "신용카드", }; } } return transaction; }); - - console.log('필터링된 트랜잭션:', filteredData.length); + + storageLogger.info("필터링된 트랜잭션:", filteredData.length); return filteredData; } catch (parseError) { - console.error('트랜잭션 데이터 파싱 오류:', parseError); + storageLogger.error("트랜잭션 데이터 파싱 오류:", parseError); return []; } } } catch (err) { - console.error('트랜잭션 로드 중 오류:', err); + storageLogger.error("트랜잭션 로드 중 오류:", err); } - - console.log('로컬 트랜잭션 데이터 없음'); + + storageLogger.info("로컬 트랜잭션 데이터 없음"); return []; }; // 로컬 스토리지에 트랜잭션 데이터 저장 -export const saveTransactionsToStorage = (transactions: Transaction[]): void => { +export const saveTransactionsToStorage = ( + transactions: Transaction[] +): void => { try { const dataString = JSON.stringify(transactions); - localStorage.setItem('transactions', dataString); - localStorage.setItem('transactions_backup', dataString); // 백업도 저장 - + localStorage.setItem("transactions", dataString); + localStorage.setItem("transactions_backup", dataString); // 백업도 저장 + // 이벤트 발생 - window.dispatchEvent(new Event('transactionUpdated')); - window.dispatchEvent(new StorageEvent('storage', { - key: 'transactions', - newValue: dataString - })); - - console.log('트랜잭션 저장 완료:', transactions.length, '개'); + window.dispatchEvent(new Event("transactionUpdated")); + window.dispatchEvent( + new StorageEvent("storage", { + key: "transactions", + newValue: dataString, + }) + ); + + storageLogger.info("트랜잭션 저장 완료:", transactions.length, "개"); } catch (error) { - console.error('트랜잭션 저장 오류:', error); + storageLogger.error("트랜잭션 저장 오류:", error); toast({ title: "데이터 저장 실패", description: "트랜잭션 데이터를 저장하는데 실패했습니다.", - variant: "destructive" + variant: "destructive", }); } }; @@ -92,13 +96,13 @@ export const saveTransactionsToStorage = (transactions: Transaction[]): void => // 예산 데이터 로드 export const loadBudgetFromStorage = (): number => { try { - const budgetDataStr = localStorage.getItem('budgetData'); + const budgetDataStr = localStorage.getItem("budgetData"); if (budgetDataStr) { const budgetData = JSON.parse(budgetDataStr); return budgetData.monthly.targetAmount; } } catch (e) { - console.error('예산 데이터 파싱 오류:', e); + storageLogger.error("예산 데이터 파싱 오류:", e); } return 0; }; diff --git a/src/hooks/transactions/transactionOperations/index.ts b/src/hooks/transactions/transactionOperations/index.ts index be9a6bf..be5081f 100644 --- a/src/hooks/transactions/transactionOperations/index.ts +++ b/src/hooks/transactions/transactionOperations/index.ts @@ -1,32 +1,42 @@ -import { useCallback } from 'react'; -import { Transaction } from '@/contexts/budget/types'; -import { useBudget } from '@/contexts/budget/BudgetContext'; +import { useCallback } from "react"; +import { logger } from "@/utils/logger"; +import { Transaction } from "@/contexts/budget/types"; +import { useBudget } from "@/contexts/budget/BudgetContext"; export const useTransactionsOperations = (transactions: Transaction[]) => { - const { updateTransaction: budgetUpdateTransaction, deleteTransaction: budgetDeleteTransaction } = useBudget(); + const { + updateTransaction: budgetUpdateTransaction, + deleteTransaction: budgetDeleteTransaction, + } = useBudget(); // 트랜잭션 업데이트 함수 - const updateTransaction = useCallback((updatedTransaction: Transaction): void => { - try { - budgetUpdateTransaction(updatedTransaction); - } catch (error) { - console.error('트랜잭션 업데이트 중 오류:', error); - } - }, [budgetUpdateTransaction]); + const updateTransaction = useCallback( + (updatedTransaction: Transaction): void => { + try { + budgetUpdateTransaction(updatedTransaction); + } catch (error) { + logger.error("트랜잭션 업데이트 중 오류:", error); + } + }, + [budgetUpdateTransaction] + ); // 트랜잭션 삭제 함수 - const deleteTransaction = useCallback(async (id: string): Promise => { - try { - budgetDeleteTransaction(id); - return true; - } catch (error) { - console.error('트랜잭션 삭제 중 오류:', error); - return false; - } - }, [budgetDeleteTransaction]); + const deleteTransaction = useCallback( + async (id: string): Promise => { + try { + budgetDeleteTransaction(id); + return true; + } catch (error) { + logger.error("트랜잭션 삭제 중 오류:", error); + return false; + } + }, + [budgetDeleteTransaction] + ); return { updateTransaction, - deleteTransaction + deleteTransaction, }; }; diff --git a/src/hooks/transactions/transactionOperations/types.ts b/src/hooks/transactions/transactionOperations/types.ts index 10ede9c..657167d 100644 --- a/src/hooks/transactions/transactionOperations/types.ts +++ b/src/hooks/transactions/transactionOperations/types.ts @@ -1,5 +1,4 @@ - -import { Transaction } from '@/components/TransactionCard'; +import { Transaction } from "@/components/TransactionCard"; export interface TransactionOperationProps { transactions: Transaction[]; diff --git a/src/hooks/transactions/transactionOperations/updateTransaction.ts b/src/hooks/transactions/transactionOperations/updateTransaction.ts index 0f8340b..8ea5a0d 100644 --- a/src/hooks/transactions/transactionOperations/updateTransaction.ts +++ b/src/hooks/transactions/transactionOperations/updateTransaction.ts @@ -1,12 +1,12 @@ - -import { useCallback } from 'react'; -import { Transaction } from '@/components/TransactionCard'; -import { useAuth } from '@/contexts/auth/useAuth'; -import { toast } from '@/hooks/useToast.wrapper'; -import { saveTransactionsToStorage } from '../storageUtils'; -import { updateTransactionInSupabase } from '../supabaseUtils'; -import { TransactionOperationProps } from './types'; -import { normalizeDate } from '@/utils/sync/transaction/dateUtils'; +import { useCallback } from "react"; +import { logger } from "@/utils/logger"; +import { Transaction } from "@/components/TransactionCard"; +import { useAuth } from "@/contexts/auth/useAuth"; +import { toast } from "@/hooks/useToast.wrapper"; +import { saveTransactionsToStorage } from "../storageUtils"; +import { updateTransactionInSupabase } from "../supabaseUtils"; +import { TransactionOperationProps } from "./types"; +import { normalizeDate } from "@/utils/sync/transaction/dateUtils"; /** * 트랜잭션 업데이트 기능 @@ -18,88 +18,104 @@ export const useUpdateTransaction = ( ) => { const { user } = useAuth(); - return useCallback((updatedTransaction: Transaction) => { - try { - console.log(`[트랜잭션] 업데이트 시작: ID=${updatedTransaction.id}, 제목=${updatedTransaction.title}`); - - // 트랜잭션 존재 여부 확인 - const existingIndex = transactions.findIndex(t => t.id === updatedTransaction.id); - if (existingIndex === -1) { - console.warn(`[트랜잭션] 업데이트 오류: ID ${updatedTransaction.id}를 찾을 수 없음`); - toast({ - title: "업데이트 실패", - description: "해당 지출 항목을 찾을 수 없습니다.", - variant: "destructive" - }); - return; - } - - // 기존 데이터와 변경 감지 - const oldTransaction = transactions[existingIndex]; - const hasChanges = JSON.stringify(oldTransaction) !== JSON.stringify(updatedTransaction); - - if (!hasChanges) { - console.log(`[트랜잭션] 변경사항 없음: ${updatedTransaction.id}`); - return; - } - - // 변경 내용 로깅 - console.log(`[트랜잭션] 변경 감지: + return useCallback( + (updatedTransaction: Transaction) => { + try { + logger.info( + `[트랜잭션] 업데이트 시작: ID=${updatedTransaction.id}, 제목=${updatedTransaction.title}` + ); + + // 트랜잭션 존재 여부 확인 + const existingIndex = transactions.findIndex( + (t) => t.id === updatedTransaction.id + ); + if (existingIndex === -1) { + logger.warn( + `[트랜잭션] 업데이트 오류: ID ${updatedTransaction.id}를 찾을 수 없음` + ); + toast({ + title: "업데이트 실패", + description: "해당 지출 항목을 찾을 수 없습니다.", + variant: "destructive", + }); + return; + } + + // 기존 데이터와 변경 감지 + const oldTransaction = transactions[existingIndex]; + const hasChanges = + JSON.stringify(oldTransaction) !== JSON.stringify(updatedTransaction); + + if (!hasChanges) { + logger.info(`[트랜잭션] 변경사항 없음: ${updatedTransaction.id}`); + return; + } + + // 변경 내용 로깅 + logger.info(`[트랜잭션] 변경 감지: 제목: ${oldTransaction.title} -> ${updatedTransaction.title} 금액: ${oldTransaction.amount} -> ${updatedTransaction.amount} 카테고리: ${oldTransaction.category} -> ${updatedTransaction.category} 날짜: ${oldTransaction.date} -> ${updatedTransaction.date} `); - - // 로컬 스토리지 업데이트 - const updatedTransactions = transactions.map(transaction => - transaction.id === updatedTransaction.id ? updatedTransaction : transaction - ); - - saveTransactionsToStorage(updatedTransactions); - console.log(`[트랜잭션] 로컬 저장소 업데이트 완료`); - - // 상태 업데이트 - setTransactions(updatedTransactions); - - // Supabase 업데이트 시도 (날짜 형식 변환 추가) - if (user) { - // ISO 형식으로 날짜 변환 - const transactionWithIsoDate = { - ...updatedTransaction, - dateForSync: normalizeDate(updatedTransaction.date) - }; - - console.log(`[트랜잭션] Supabase 업데이트 시작: ${updatedTransaction.id}`); - updateTransactionInSupabase(user, transactionWithIsoDate) - .then(() => { - console.log(`[트랜잭션] Supabase 업데이트 성공: ${updatedTransaction.id}`); - }) - .catch(err => { - console.error(`[트랜잭션] Supabase 업데이트 실패:`, err); + + // 로컬 스토리지 업데이트 + const updatedTransactions = transactions.map((transaction) => + transaction.id === updatedTransaction.id + ? updatedTransaction + : transaction + ); + + saveTransactionsToStorage(updatedTransactions); + logger.info(`[트랜잭션] 로컬 저장소 업데이트 완료`); + + // 상태 업데이트 + setTransactions(updatedTransactions); + + // Supabase 업데이트 시도 (날짜 형식 변환 추가) + if (user) { + // ISO 형식으로 날짜 변환 + const transactionWithIsoDate = { + ...updatedTransaction, + dateForSync: normalizeDate(updatedTransaction.date), + }; + + logger.info( + `[트랜잭션] Supabase 업데이트 시작: ${updatedTransaction.id}` + ); + updateTransactionInSupabase(user, transactionWithIsoDate) + .then(() => { + logger.info( + `[트랜잭션] Supabase 업데이트 성공: ${updatedTransaction.id}` + ); + }) + .catch((err) => { + logger.error(`[트랜잭션] Supabase 업데이트 실패:`, err); + }); + } else { + logger.info(`[트랜잭션] 로그인 상태 아님: Supabase 업데이트 건너뜀`); + } + + // 이벤트 발생 + window.dispatchEvent(new Event("transactionUpdated")); + + // 약간의 지연을 두고 토스트 표시 + setTimeout(() => { + toast({ + title: "지출이 수정되었습니다", + description: `${updatedTransaction.title} 항목이 업데이트되었습니다.`, + duration: 3000, }); - } else { - console.log(`[트랜잭션] 로그인 상태 아님: Supabase 업데이트 건너뜀`); - } - - // 이벤트 발생 - window.dispatchEvent(new Event('transactionUpdated')); - - // 약간의 지연을 두고 토스트 표시 - setTimeout(() => { + }, 100); + } catch (error) { + logger.error(`[트랜잭션] 업데이트 중 오류 발생:`, error); toast({ - title: "지출이 수정되었습니다", - description: `${updatedTransaction.title} 항목이 업데이트되었습니다.`, - duration: 3000 + title: "업데이트 실패", + description: "지출 수정 중 오류가 발생했습니다.", + variant: "destructive", }); - }, 100); - } catch (error) { - console.error(`[트랜잭션] 업데이트 중 오류 발생:`, error); - toast({ - title: "업데이트 실패", - description: "지출 수정 중 오류가 발생했습니다.", - variant: "destructive" - }); - } - }, [transactions, setTransactions, user]); + } + }, + [transactions, setTransactions, user] + ); }; diff --git a/src/hooks/transactions/transactionOperations/useTransactionsOperations.ts b/src/hooks/transactions/transactionOperations/useTransactionsOperations.ts index be9a6bf..be5081f 100644 --- a/src/hooks/transactions/transactionOperations/useTransactionsOperations.ts +++ b/src/hooks/transactions/transactionOperations/useTransactionsOperations.ts @@ -1,32 +1,42 @@ -import { useCallback } from 'react'; -import { Transaction } from '@/contexts/budget/types'; -import { useBudget } from '@/contexts/budget/BudgetContext'; +import { useCallback } from "react"; +import { logger } from "@/utils/logger"; +import { Transaction } from "@/contexts/budget/types"; +import { useBudget } from "@/contexts/budget/BudgetContext"; export const useTransactionsOperations = (transactions: Transaction[]) => { - const { updateTransaction: budgetUpdateTransaction, deleteTransaction: budgetDeleteTransaction } = useBudget(); + const { + updateTransaction: budgetUpdateTransaction, + deleteTransaction: budgetDeleteTransaction, + } = useBudget(); // 트랜잭션 업데이트 함수 - const updateTransaction = useCallback((updatedTransaction: Transaction): void => { - try { - budgetUpdateTransaction(updatedTransaction); - } catch (error) { - console.error('트랜잭션 업데이트 중 오류:', error); - } - }, [budgetUpdateTransaction]); + const updateTransaction = useCallback( + (updatedTransaction: Transaction): void => { + try { + budgetUpdateTransaction(updatedTransaction); + } catch (error) { + logger.error("트랜잭션 업데이트 중 오류:", error); + } + }, + [budgetUpdateTransaction] + ); // 트랜잭션 삭제 함수 - const deleteTransaction = useCallback(async (id: string): Promise => { - try { - budgetDeleteTransaction(id); - return true; - } catch (error) { - console.error('트랜잭션 삭제 중 오류:', error); - return false; - } - }, [budgetDeleteTransaction]); + const deleteTransaction = useCallback( + async (id: string): Promise => { + try { + budgetDeleteTransaction(id); + return true; + } catch (error) { + logger.error("트랜잭션 삭제 중 오류:", error); + return false; + } + }, + [budgetDeleteTransaction] + ); return { updateTransaction, - deleteTransaction + deleteTransaction, }; }; diff --git a/src/hooks/transactions/useAppwriteTransactions.ts b/src/hooks/transactions/useAppwriteTransactions.ts index f2bff18..12eaf6c 100644 --- a/src/hooks/transactions/useAppwriteTransactions.ts +++ b/src/hooks/transactions/useAppwriteTransactions.ts @@ -1,137 +1,157 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; -import { Transaction } from '@/components/TransactionCard'; -import { - syncTransactionsWithAppwrite, - updateTransactionInAppwrite, +import { useState, useEffect, useCallback, useRef } from "react"; +import { appwriteLogger } from "@/utils/logger"; +import { Transaction } from "@/components/TransactionCard"; +import { + syncTransactionsWithAppwrite, + updateTransactionInAppwrite, deleteTransactionFromAppwrite, - debouncedDeleteTransaction -} from '@/utils/appwriteTransactionUtils'; -import { toast } from '@/hooks/useToast.wrapper'; -import { isSyncEnabled } from '@/utils/syncUtils'; + debouncedDeleteTransaction, +} from "@/utils/appwriteTransactionUtils"; +import { toast } from "@/hooks/useToast.wrapper"; +import { isSyncEnabled } from "@/utils/syncUtils"; /** * Appwrite 트랜잭션 관리 훅 * 트랜잭션 동기화, 추가, 수정, 삭제 기능 제공 */ -export const useAppwriteTransactions = (user: any, localTransactions: Transaction[]) => { +export const useAppwriteTransactions = ( + user: any, + localTransactions: Transaction[] +) => { // 트랜잭션 상태 관리 - const [transactions, setTransactions] = useState(localTransactions); + const [transactions, setTransactions] = + useState(localTransactions); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - + // 컴포넌트 마운트 상태 추적 const isMountedRef = useRef(true); - + // 진행 중인 작업 추적 const pendingOperations = useRef>(new Set()); - + // 트랜잭션 동기화 const syncTransactions = useCallback(async () => { - if (!user || !isSyncEnabled()) return localTransactions; - + if (!user || !isSyncEnabled()) { + return localTransactions; + } + try { setLoading(true); setError(null); - + // UI 스레드 차단 방지 - await new Promise(resolve => setTimeout(resolve, 0)); - - const syncedTransactions = await syncTransactionsWithAppwrite(user, localTransactions); - + await new Promise((resolve) => setTimeout(resolve, 0)); + + const syncedTransactions = await syncTransactionsWithAppwrite( + user, + localTransactions + ); + if (isMountedRef.current) { setTransactions(syncedTransactions); setLoading(false); } - + return syncedTransactions; } catch (err) { - console.error('트랜잭션 동기화 오류:', err); - + appwriteLogger.error("트랜잭션 동기화 오류:", err); + if (isMountedRef.current) { setError(err as Error); setLoading(false); } - + return localTransactions; } }, [user, localTransactions]); - + // 트랜잭션 추가/수정 - const saveTransaction = useCallback(async (transaction: Transaction) => { - if (!user || !isSyncEnabled()) return; - - try { - // 작업 추적 시작 - pendingOperations.current.add(transaction.id); - - // UI 스레드 차단 방지 - await new Promise(resolve => requestAnimationFrame(resolve)); - - await updateTransactionInAppwrite(user, transaction); - - if (!isMountedRef.current) return; - - // 로컬 상태 업데이트 - setTransactions(prev => { - const index = prev.findIndex(t => t.id === transaction.id); - if (index >= 0) { - const updated = [...prev]; - updated[index] = transaction; - return updated; - } else { - return [...prev, transaction]; + const saveTransaction = useCallback( + async (transaction: Transaction) => { + if (!user || !isSyncEnabled()) { + return; + } + + try { + // 작업 추적 시작 + pendingOperations.current.add(transaction.id); + + // UI 스레드 차단 방지 + await new Promise((resolve) => requestAnimationFrame(resolve)); + + await updateTransactionInAppwrite(user, transaction); + + if (!isMountedRef.current) { + return; } - }); - - } catch (err) { - console.error('트랜잭션 저장 오류:', err); - - if (isMountedRef.current) { - toast({ - title: '저장 실패', - description: '트랜잭션을 저장하는 중 오류가 발생했습니다.', - variant: 'destructive' + + // 로컬 상태 업데이트 + setTransactions((prev) => { + const index = prev.findIndex((t) => t.id === transaction.id); + if (index >= 0) { + const updated = [...prev]; + updated[index] = transaction; + return updated; + } else { + return [...prev, transaction]; + } }); + } catch (err) { + appwriteLogger.error("트랜잭션 저장 오류:", err); + + if (isMountedRef.current) { + toast({ + title: "저장 실패", + description: "트랜잭션을 저장하는 중 오류가 발생했습니다.", + variant: "destructive", + }); + } + } finally { + // 작업 추적 종료 + pendingOperations.current.delete(transaction.id); } - } finally { - // 작업 추적 종료 - pendingOperations.current.delete(transaction.id); - } - }, [user]); - + }, + [user] + ); + // 트랜잭션 삭제 - const removeTransaction = useCallback(async (transactionId: string) => { - if (!user || !isSyncEnabled()) return; - - try { - // 작업 추적 시작 - pendingOperations.current.add(transactionId); - - // 로컬 상태 먼저 업데이트 (낙관적 UI 업데이트) - setTransactions(prev => prev.filter(t => t.id !== transactionId)); - - // 디바운스된 삭제 작업 실행 (여러 번 연속 호출 방지) - await debouncedDeleteTransaction(user, transactionId); - - } catch (err) { - console.error('트랜잭션 삭제 오류:', err); - - if (isMountedRef.current) { - toast({ - title: '삭제 실패', - description: '트랜잭션을 삭제하는 중 오류가 발생했습니다.', - variant: 'destructive' - }); - - // 실패 시 트랜잭션 복원 (서버에서 가져오기) - syncTransactions(); + const removeTransaction = useCallback( + async (transactionId: string) => { + if (!user || !isSyncEnabled()) { + return; } - } finally { - // 작업 추적 종료 - pendingOperations.current.delete(transactionId); - } - }, [user, syncTransactions]); - + + try { + // 작업 추적 시작 + pendingOperations.current.add(transactionId); + + // 로컬 상태 먼저 업데이트 (낙관적 UI 업데이트) + setTransactions((prev) => prev.filter((t) => t.id !== transactionId)); + + // 디바운스된 삭제 작업 실행 (여러 번 연속 호출 방지) + await debouncedDeleteTransaction(user, transactionId); + } catch (err) { + appwriteLogger.error("트랜잭션 삭제 오류:", err); + + if (isMountedRef.current) { + toast({ + title: "삭제 실패", + description: "트랜잭션을 삭제하는 중 오류가 발생했습니다.", + variant: "destructive", + }); + + // 실패 시 트랜잭션 복원 (서버에서 가져오기) + syncTransactions(); + } + } finally { + // 작업 추적 종료 + pendingOperations.current.delete(transactionId); + } + }, + [user, syncTransactions] + ); + // 초기 동기화 useEffect(() => { if (user && isSyncEnabled()) { @@ -140,14 +160,14 @@ export const useAppwriteTransactions = (user: any, localTransactions: Transactio setTransactions(localTransactions); } }, [user, localTransactions, syncTransactions]); - + // 컴포넌트 언마운트 시 정리 useEffect(() => { return () => { isMountedRef.current = false; }; }, []); - + return { transactions, loading, @@ -155,7 +175,7 @@ export const useAppwriteTransactions = (user: any, localTransactions: Transactio syncTransactions, saveTransaction, removeTransaction, - hasPendingOperations: pendingOperations.current.size > 0 + hasPendingOperations: pendingOperations.current.size > 0, }; }; diff --git a/src/hooks/transactions/useDeleteAlert.ts b/src/hooks/transactions/useDeleteAlert.ts index 9768470..db2f841 100644 --- a/src/hooks/transactions/useDeleteAlert.ts +++ b/src/hooks/transactions/useDeleteAlert.ts @@ -1,16 +1,16 @@ +import { useState, useRef, useEffect } from "react"; -import { useState, useRef, useEffect } from 'react'; - +import { logger } from "@/utils/logger"; /** * 트랜잭션 삭제 알림 관련 로직을 담당하는 커스텀 훅 */ export const useDeleteAlert = (onDelete: () => Promise | boolean) => { const [isOpen, setIsOpen] = useState(false); const [isDeleting, setIsDeleting] = useState(false); - + // 타임아웃 참조 저장 (메모리 누수 방지용) const timeoutRef = useRef | null>(null); - + // 클린업 함수 - 메모리 누수 방지 const clearTimeouts = () => { if (timeoutRef.current) { @@ -18,32 +18,34 @@ export const useDeleteAlert = (onDelete: () => Promise | boolean) => { timeoutRef.current = null; } }; - + // 컴포넌트 언마운트 시 모든 타임아웃 제거 useEffect(() => { return () => { clearTimeouts(); }; }, []); - + const handleDelete = async () => { // 이미 삭제 중이면 중복 실행 방지 - if (isDeleting) return; - + if (isDeleting) { + return; + } + try { // 삭제 상태 활성화 setIsDeleting(true); - + // 다이얼로그 즉시 닫기 (UI 응답성 개선) setIsOpen(false); - + // UI 애니메이션 완료 후 삭제 실행 timeoutRef.current = setTimeout(async () => { try { // 삭제 함수 실행 await onDelete(); } catch (error) { - console.error('삭제 처리 오류:', error); + logger.error("삭제 처리 오류:", error); } finally { // 모든 작업 완료 후 상태 초기화 (약간 지연) timeoutRef.current = setTimeout(() => { @@ -52,23 +54,25 @@ export const useDeleteAlert = (onDelete: () => Promise | boolean) => { } }, 150); } catch (error) { - console.error('삭제 핸들러 오류:', error); + logger.error("삭제 핸들러 오류:", error); setIsDeleting(false); setIsOpen(false); } }; - + // 다이얼로그 상태 관리 const handleOpenChange = (open: boolean) => { // 삭제 중에는 상태 변경 방지 - if (isDeleting && !open) return; + if (isDeleting && !open) { + return; + } setIsOpen(open); }; - + return { isOpen, isDeleting, handleDelete, - handleOpenChange + handleOpenChange, }; }; diff --git a/src/hooks/transactions/useRecentTransactions.ts b/src/hooks/transactions/useRecentTransactions.ts index 5c6b85d..0824970 100644 --- a/src/hooks/transactions/useRecentTransactions.ts +++ b/src/hooks/transactions/useRecentTransactions.ts @@ -1,6 +1,6 @@ - -import { useCallback, useRef, useState } from 'react'; -import { toast } from '@/hooks/useToast.wrapper'; +import { useCallback, useRef, useState } from "react"; +import { logger } from "@/utils/logger"; +import { toast } from "@/hooks/useToast.wrapper"; /** * 최근 거래내역 관련 로직을 처리하는 커스텀 훅 @@ -10,7 +10,7 @@ export const useRecentTransactions = ( deleteTransaction: (id: string) => void ) => { const [isDeleting, setIsDeleting] = useState(false); - + // 삭제 중인 ID 추적 const deletingIdRef = useRef(null); @@ -21,101 +21,107 @@ export const useRecentTransactions = ( const lastDeleteTimeRef = useRef>({}); // 완전히 새로운 삭제 처리 함수 - const handleDeleteTransaction = useCallback(async (id: string): Promise => { - return new Promise(resolve => { - try { - // 삭제 진행 중인지 확인 - if (isDeleting || deletingIdRef.current === id) { - console.log('이미 삭제 작업이 진행 중입니다'); + const handleDeleteTransaction = useCallback( + async (id: string): Promise => { + return new Promise((resolve) => { + try { + // 삭제 진행 중인지 확인 + if (isDeleting || deletingIdRef.current === id) { + logger.info("이미 삭제 작업이 진행 중입니다"); + resolve(true); + return; + } + + // 급발진 방지 (300ms) + const now = Date.now(); + if ( + lastDeleteTimeRef.current[id] && + now - lastDeleteTimeRef.current[id] < 300 + ) { + logger.warn("삭제 요청이 너무 빠릅니다. 무시합니다."); + resolve(true); + return; + } + + // 타임스탬프 업데이트 + lastDeleteTimeRef.current[id] = now; + + // 삭제 상태 설정 + setIsDeleting(true); + deletingIdRef.current = id; + + // 안전장치: 타임아웃 설정 (최대 900ms) + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = setTimeout(() => { + logger.warn("삭제 타임아웃 - 상태 초기화"); + setIsDeleting(false); + deletingIdRef.current = null; + resolve(true); // UI 응답성 위해 성공 간주 + }, 900); + + // 비동기 작업을 동기적으로 처리하여 UI 블로킹 방지 + setTimeout(() => { + try { + // BudgetContext의 deleteTransaction 함수 호출 + deleteTransaction(id); + + // 안전장치 타임아웃 제거 + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + + // 상태 초기화 (지연 적용) + setTimeout(() => { + setIsDeleting(false); + deletingIdRef.current = null; + }, 100); + + // 성공 메시지 표시 + toast({ + title: "항목이 삭제되었습니다", + description: "지출 내역이 성공적으로 삭제되었습니다.", + duration: 1500, + }); + } catch (err) { + logger.error("삭제 처리 오류:", err); + + // 에러 메시지 표시 + toast({ + title: "삭제 실패", + description: "항목을 삭제하는 중 오류가 발생했습니다.", + variant: "destructive", + duration: 1500, + }); + } + }, 0); + + // 즉시 성공 반환 (UI 응답성 향상) resolve(true); - return; - } + } catch (error) { + logger.error("삭제 처리 전체 오류:", error); - // 급발진 방지 (300ms) - const now = Date.now(); - if (lastDeleteTimeRef.current[id] && now - lastDeleteTimeRef.current[id] < 300) { - console.warn('삭제 요청이 너무 빠릅니다. 무시합니다.'); - resolve(true); - return; - } - - // 타임스탬프 업데이트 - lastDeleteTimeRef.current[id] = now; - - // 삭제 상태 설정 - setIsDeleting(true); - deletingIdRef.current = id; - - // 안전장치: 타임아웃 설정 (최대 900ms) - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - timeoutRef.current = setTimeout(() => { - console.warn('삭제 타임아웃 - 상태 초기화'); + // 항상 상태 정리 setIsDeleting(false); deletingIdRef.current = null; - resolve(true); // UI 응답성 위해 성공 간주 - }, 900); - - // 비동기 작업을 동기적으로 처리하여 UI 블로킹 방지 - setTimeout(() => { - try { - // BudgetContext의 deleteTransaction 함수 호출 - deleteTransaction(id); - - // 안전장치 타임아웃 제거 - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - timeoutRef.current = null; - } - - // 상태 초기화 (지연 적용) - setTimeout(() => { - setIsDeleting(false); - deletingIdRef.current = null; - }, 100); - - // 성공 메시지 표시 - toast({ - title: "항목이 삭제되었습니다", - description: "지출 내역이 성공적으로 삭제되었습니다.", - duration: 1500 - }); - } catch (err) { - console.error('삭제 처리 오류:', err); - - // 에러 메시지 표시 - toast({ - title: "삭제 실패", - description: "항목을 삭제하는 중 오류가 발생했습니다.", - variant: "destructive", - duration: 1500 - }); + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; } - }, 0); - - // 즉시 성공 반환 (UI 응답성 향상) - resolve(true); - } catch (error) { - console.error('삭제 처리 전체 오류:', error); - - // 항상 상태 정리 - setIsDeleting(false); - deletingIdRef.current = null; - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - timeoutRef.current = null; + toast({ + title: "오류 발생", + description: "처리 중 문제가 발생했습니다.", + variant: "destructive", + duration: 1500, + }); + resolve(false); } - toast({ - title: "오류 발생", - description: "처리 중 문제가 발생했습니다.", - variant: "destructive", - duration: 1500 - }); - resolve(false); - } - }); - }, [deleteTransaction, isDeleting]); + }); + }, + [deleteTransaction, isDeleting] + ); // 컴포넌트 언마운트 시 타임아웃 정리 (리액트 컴포넌트에서 처리해야함) const cleanupTimeouts = useCallback(() => { @@ -128,6 +134,6 @@ export const useRecentTransactions = ( return { handleDeleteTransaction, isDeleting, - cleanupTimeouts + cleanupTimeouts, }; }; diff --git a/src/hooks/transactions/useRecentTransactionsDialog.ts b/src/hooks/transactions/useRecentTransactionsDialog.ts index e1518d6..23495fc 100644 --- a/src/hooks/transactions/useRecentTransactionsDialog.ts +++ b/src/hooks/transactions/useRecentTransactionsDialog.ts @@ -1,12 +1,12 @@ - -import { useState } from 'react'; -import { Transaction } from '@/contexts/budget/types'; +import { useState } from "react"; +import { Transaction } from "@/contexts/budget/types"; /** * 최근 거래내역의 다이얼로그 상태를 관리하는 커스텀 훅 */ export const useRecentTransactionsDialog = () => { - const [selectedTransaction, setSelectedTransaction] = useState(null); + const [selectedTransaction, setSelectedTransaction] = + useState(null); const [isDialogOpen, setIsDialogOpen] = useState(false); const handleTransactionClick = (transaction: Transaction) => { @@ -27,6 +27,6 @@ export const useRecentTransactionsDialog = () => { isDialogOpen, handleTransactionClick, handleCloseDialog, - setIsDialogOpen + setIsDialogOpen, }; }; diff --git a/src/hooks/transactions/useTransactions.ts b/src/hooks/transactions/useTransactions.ts index 8aa7aee..b0b256a 100644 --- a/src/hooks/transactions/useTransactions.ts +++ b/src/hooks/transactions/useTransactions.ts @@ -1,5 +1,4 @@ - -import { useTransactionsCore } from './useTransactionsCore'; +import { useTransactionsCore } from "./useTransactionsCore"; /** * 메인 트랜잭션 훅 diff --git a/src/hooks/transactions/useTransactionsCore.ts b/src/hooks/transactions/useTransactionsCore.ts index c809347..f7eec5b 100644 --- a/src/hooks/transactions/useTransactionsCore.ts +++ b/src/hooks/transactions/useTransactionsCore.ts @@ -1,10 +1,10 @@ - -import { useCallback } from 'react'; -import { useTransactionsState } from './useTransactionsState'; -import { useTransactionsFiltering } from './useTransactionsFiltering'; -import { useTransactionsLoader } from './useTransactionsLoader'; -import { useTransactionsOperations } from './transactionOperations/useTransactionsOperations'; -import { useTransactionsEvents } from './useTransactionsEvents'; +import { useCallback } from "react"; +import { logger } from "@/utils/logger"; +import { useTransactionsState } from "./useTransactionsState"; +import { useTransactionsFiltering } from "./useTransactionsFiltering"; +import { useTransactionsLoader } from "./useTransactionsLoader"; +import { useTransactionsOperations } from "./transactionOperations/useTransactionsOperations"; +import { useTransactionsEvents } from "./useTransactionsEvents"; /** * 핵심 트랜잭션 훅 - 성능 및 안정성 최적화 버전 @@ -12,7 +12,7 @@ import { useTransactionsEvents } from './useTransactionsEvents'; */ export const useTransactionsCore = () => { // 상태 관리 - const { + const { transactions, setTransactions, filteredTransactions, @@ -28,42 +28,37 @@ export const useTransactionsCore = () => { totalBudget, setTotalBudget, refreshKey, - setRefreshKey + setRefreshKey, } = useTransactionsState(); // 데이터 로딩 const { loadTransactions } = useTransactionsLoader( - setTransactions, - setTotalBudget, - setIsLoading, + setTransactions, + setTotalBudget, + setIsLoading, setError ); // 필터링 - 성능 개선 버전 - const { - handlePrevMonth, - handleNextMonth, - getTotalExpenses - } = useTransactionsFiltering({ - transactions, - selectedMonth, - setSelectedMonth, - searchQuery, - setFilteredTransactions - }); + const { handlePrevMonth, handleNextMonth, getTotalExpenses } = + useTransactionsFiltering({ + transactions, + selectedMonth, + setSelectedMonth, + searchQuery, + setFilteredTransactions, + }); // 트랜잭션 작업 - 단순화된 버전 - const { - deleteTransaction - } = useTransactionsOperations(transactions); + const { deleteTransaction } = useTransactionsOperations(transactions); // 이벤트 리스너 - 메모리 누수 방지 버전 useTransactionsEvents(loadTransactions, refreshKey); // 데이터 강제 새로고침 - 성능 최적화 const refreshTransactions = useCallback(() => { - console.log('[트랜잭션 코어] 강제 새로고침'); - setRefreshKey(prev => prev + 1); + logger.info("[트랜잭션 코어] 강제 새로고침"); + setRefreshKey((prev) => prev + 1); loadTransactions(); }, [loadTransactions, setRefreshKey]); @@ -71,26 +66,26 @@ export const useTransactionsCore = () => { // 데이터 transactions: filteredTransactions, allTransactions: transactions, - + // 상태 isLoading, error, totalBudget, - + // 필터링 selectedMonth, searchQuery, setSearchQuery, handlePrevMonth, handleNextMonth, - + // 작업 deleteTransaction, - + // 합계 totalExpenses: getTotalExpenses(filteredTransactions), - + // 새로고침 - refreshTransactions + refreshTransactions, }; }; diff --git a/src/hooks/transactions/useTransactionsEvents.ts b/src/hooks/transactions/useTransactionsEvents.ts index 346cc27..cf917bd 100644 --- a/src/hooks/transactions/useTransactionsEvents.ts +++ b/src/hooks/transactions/useTransactionsEvents.ts @@ -1,6 +1,6 @@ +import { useEffect, useRef } from "react"; -import { useEffect, useRef } from 'react'; - +import { logger } from "@/utils/logger"; /** * 트랜잭션 이벤트 리스너 훅 - 성능 및 메모리 누수 방지 개선 버전 */ @@ -11,76 +11,86 @@ export const useTransactionsEvents = ( // 바운싱 방지 및 이벤트 제어를 위한 참조 const isProcessingRef = useRef(false); const timeoutIdsRef = useRef([]); - + // 타임아웃 클리어 도우미 함수 const clearAllTimeouts = () => { - timeoutIdsRef.current.forEach(id => window.clearTimeout(id)); + timeoutIdsRef.current.forEach((id) => window.clearTimeout(id)); timeoutIdsRef.current = []; }; - + useEffect(() => { - console.log('[이벤트] 이벤트 리스너 설정'); - + logger.info("[이벤트] 이벤트 리스너 설정"); + // 이벤트 핸들러 - 부하 조절(throttle) 적용 - const handleEvent = (name: string, delay: number = 200) => { + const handleEvent = (name: string, delay = 200) => { return (e?: any) => { // 이미 처리 중인 경우 건너뜀 - if (isProcessingRef.current) return; - - console.log(`[이벤트] ${name} 이벤트 감지:`, e?.detail?.type || ''); + if (isProcessingRef.current) { + return; + } + + logger.info(`[이벤트] ${name} 이벤트 감지:`, e?.detail?.type || ""); isProcessingRef.current = true; - + // 딜레이 적용 (이벤트 폭주 방지) const timeoutId = window.setTimeout(() => { loadTransactions(); isProcessingRef.current = false; - + // 타임아웃 ID 목록에서 제거 - timeoutIdsRef.current = timeoutIdsRef.current.filter(id => id !== timeoutId); + timeoutIdsRef.current = timeoutIdsRef.current.filter( + (id) => id !== timeoutId + ); }, delay); - + // 타임아웃 ID 기록 (나중에 정리하기 위함) timeoutIdsRef.current.push(timeoutId); }; }; - + // 각 이벤트별 핸들러 생성 - const handleTransactionUpdate = handleEvent('트랜잭션 업데이트', 150); - const handleTransactionDelete = handleEvent('트랜잭션 삭제', 200); - const handleTransactionChange = handleEvent('트랜잭션 변경', 150); + const handleTransactionUpdate = handleEvent("트랜잭션 업데이트", 150); + const handleTransactionDelete = handleEvent("트랜잭션 삭제", 200); + const handleTransactionChange = handleEvent("트랜잭션 변경", 150); const handleStorageEvent = (e: StorageEvent) => { - if (e.key === 'transactions' || e.key === null) { - handleEvent('스토리지', 150)(); + if (e.key === "transactions" || e.key === null) { + handleEvent("스토리지", 150)(); } }; - const handleFocus = handleEvent('포커스', 200); - + const handleFocus = handleEvent("포커스", 200); + // 이벤트 리스너 등록 - window.addEventListener('transactionUpdated', handleTransactionUpdate); - window.addEventListener('transactionDeleted', handleTransactionDelete); - window.addEventListener('transactionChanged', handleTransactionChange as EventListener); - window.addEventListener('storage', handleStorageEvent); - window.addEventListener('focus', handleFocus); - + window.addEventListener("transactionUpdated", handleTransactionUpdate); + window.addEventListener("transactionDeleted", handleTransactionDelete); + window.addEventListener( + "transactionChanged", + handleTransactionChange as EventListener + ); + window.addEventListener("storage", handleStorageEvent); + window.addEventListener("focus", handleFocus); + // 초기 데이터 로드 if (!isProcessingRef.current) { loadTransactions(); } - + // 클린업 함수 return () => { - console.log('[이벤트] 이벤트 리스너 정리'); - + logger.info("[이벤트] 이벤트 리스너 정리"); + // 모든 이벤트 리스너 제거 - window.removeEventListener('transactionUpdated', handleTransactionUpdate); - window.removeEventListener('transactionDeleted', handleTransactionDelete); - window.removeEventListener('transactionChanged', handleTransactionChange as EventListener); - window.removeEventListener('storage', handleStorageEvent); - window.removeEventListener('focus', handleFocus); - + window.removeEventListener("transactionUpdated", handleTransactionUpdate); + window.removeEventListener("transactionDeleted", handleTransactionDelete); + window.removeEventListener( + "transactionChanged", + handleTransactionChange as EventListener + ); + window.removeEventListener("storage", handleStorageEvent); + window.removeEventListener("focus", handleFocus); + // 모든 진행 중인 타임아웃 정리 clearAllTimeouts(); - + // 처리 상태 초기화 isProcessingRef.current = false; }; diff --git a/src/hooks/transactions/useTransactionsFiltering.ts b/src/hooks/transactions/useTransactionsFiltering.ts index 9fff4e8..b02cb17 100644 --- a/src/hooks/transactions/useTransactionsFiltering.ts +++ b/src/hooks/transactions/useTransactionsFiltering.ts @@ -1,5 +1,4 @@ - -import { useTransactionsFiltering } from './filterOperations'; +import { useTransactionsFiltering } from "./filterOperations"; // 기존 훅을 그대로 내보내기 export { useTransactionsFiltering }; diff --git a/src/hooks/transactions/useTransactionsLoader.ts b/src/hooks/transactions/useTransactionsLoader.ts index 340d444..1c89e15 100644 --- a/src/hooks/transactions/useTransactionsLoader.ts +++ b/src/hooks/transactions/useTransactionsLoader.ts @@ -1,9 +1,7 @@ - -import { useCallback } from 'react'; -import { toast } from '@/hooks/useToast.wrapper'; -import { - loadTransactionsFromStorage -} from './storageUtils'; +import { useCallback } from "react"; +import { logger } from "@/utils/logger"; +import { toast } from "@/hooks/useToast.wrapper"; +import { loadTransactionsFromStorage } from "./storageUtils"; /** * 트랜잭션 로딩 관련 훅 @@ -19,41 +17,45 @@ export const useTransactionsLoader = ( const loadTransactions = useCallback(() => { setIsLoading(true); setError(null); - + try { const localTransactions = loadTransactionsFromStorage(); setTransactions(localTransactions); - + // 예산 데이터에서 직접 월간 예산 값을 가져옴 try { - const budgetDataStr = localStorage.getItem('budgetData'); + const budgetDataStr = localStorage.getItem("budgetData"); if (budgetDataStr) { const budgetData = JSON.parse(budgetDataStr); // 월간 예산 값만 사용 - if (budgetData && budgetData.monthly && typeof budgetData.monthly.targetAmount === 'number') { + if ( + budgetData && + budgetData.monthly && + typeof budgetData.monthly.targetAmount === "number" + ) { const monthlyBudget = budgetData.monthly.targetAmount; setTotalBudget(monthlyBudget); - console.log('월간 예산 설정:', monthlyBudget); + logger.info("월간 예산 설정:", monthlyBudget); } else { - console.log('유효한 월간 예산 데이터가 없습니다. 기본값 0 사용'); + logger.info("유효한 월간 예산 데이터가 없습니다. 기본값 0 사용"); setTotalBudget(0); } } else { - console.log('예산 데이터가 없습니다. 기본값 0 사용'); + logger.info("예산 데이터가 없습니다. 기본값 0 사용"); setTotalBudget(0); } } catch (budgetErr) { - console.error('예산 데이터 파싱 오류:', budgetErr); + logger.error("예산 데이터 파싱 오류:", budgetErr); setTotalBudget(0); } } catch (err) { - console.error('트랜잭션 로드 중 오류:', err); - setError('데이터를 불러오는 중 문제가 발생했습니다.'); + logger.error("트랜잭션 로드 중 오류:", err); + setError("데이터를 불러오는 중 문제가 발생했습니다."); toast({ title: "데이터 로드 실패", description: "지출 내역을 불러오는데 실패했습니다.", variant: "destructive", - duration: 4000 + duration: 4000, }); } finally { // 로딩 상태를 약간 지연시켜 UI 업데이트가 원활하게 이루어지도록 함 @@ -62,6 +64,6 @@ export const useTransactionsLoader = ( }, [setTransactions, setTotalBudget, setIsLoading, setError]); return { - loadTransactions + loadTransactions, }; }; diff --git a/src/hooks/transactions/useTransactionsOperations.ts b/src/hooks/transactions/useTransactionsOperations.ts index c3015c7..3c75e29 100644 --- a/src/hooks/transactions/useTransactionsOperations.ts +++ b/src/hooks/transactions/useTransactionsOperations.ts @@ -1,18 +1,22 @@ - -import { useCallback } from 'react'; -import { Transaction } from '@/components/TransactionCard'; +import { useCallback } from "react"; +import { Transaction } from "@/components/TransactionCard"; export const useTransactionsOperations = ( transactions: Transaction[], setTransactions: React.Dispatch> ) => { - const updateTransaction = useCallback((updatedTransaction: Transaction) => { - setTransactions(prev => - prev.map(t => t.id === updatedTransaction.id ? updatedTransaction : t) - ); - }, [setTransactions]); + const updateTransaction = useCallback( + (updatedTransaction: Transaction) => { + setTransactions((prev) => + prev.map((t) => + t.id === updatedTransaction.id ? updatedTransaction : t + ) + ); + }, + [setTransactions] + ); return { - updateTransaction + updateTransaction, }; }; diff --git a/src/hooks/transactions/useTransactionsState.ts b/src/hooks/transactions/useTransactionsState.ts index 43faac5..a0fd0b7 100644 --- a/src/hooks/transactions/useTransactionsState.ts +++ b/src/hooks/transactions/useTransactionsState.ts @@ -1,7 +1,6 @@ - -import { useState } from 'react'; -import { Transaction } from '@/components/TransactionCard'; -import { getCurrentMonth } from './dateUtils'; +import { useState } from "react"; +import { Transaction } from "@/components/TransactionCard"; +import { getCurrentMonth } from "./dateUtils"; /** * 트랜잭션 관련 상태 관리 훅 @@ -10,19 +9,21 @@ import { getCurrentMonth } from './dateUtils'; export const useTransactionsState = () => { // 트랜잭션 상태 const [transactions, setTransactions] = useState([]); - const [filteredTransactions, setFilteredTransactions] = useState([]); - + const [filteredTransactions, setFilteredTransactions] = useState< + Transaction[] + >([]); + // 필터링 상태 const [selectedMonth, setSelectedMonth] = useState(getCurrentMonth()); - const [searchQuery, setSearchQuery] = useState(''); - + const [searchQuery, setSearchQuery] = useState(""); + // 로딩 및 에러 상태 const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - + // 예산 상태 const [totalBudget, setTotalBudget] = useState(0); - + // 새로고침 키 const [refreshKey, setRefreshKey] = useState(0); @@ -32,25 +33,25 @@ export const useTransactionsState = () => { setTransactions, filteredTransactions, setFilteredTransactions, - + // 필터링 상태 selectedMonth, setSelectedMonth, searchQuery, setSearchQuery, - + // 로딩 및 에러 상태 isLoading, setIsLoading, error, setError, - + // 예산 상태 totalBudget, setTotalBudget, - + // 새로고침 키 refreshKey, - setRefreshKey + setRefreshKey, }; }; diff --git a/src/hooks/use-mobile.tsx b/src/hooks/use-mobile.tsx index 1fb88c6..a8afb17 100644 --- a/src/hooks/use-mobile.tsx +++ b/src/hooks/use-mobile.tsx @@ -1,36 +1,39 @@ +import * as React from "react"; -import * as React from "react" - -const MOBILE_BREAKPOINT = 768 +const MOBILE_BREAKPOINT = 768; export function useIsMobile() { const [isMobile, setIsMobile] = React.useState( - typeof window !== 'undefined' ? window.innerWidth < MOBILE_BREAKPOINT : false - ) + typeof window !== "undefined" + ? window.innerWidth < MOBILE_BREAKPOINT + : false + ); React.useEffect(() => { - if (typeof window === 'undefined') return; - + if (typeof window === "undefined") { + return; + } + const checkMobile = () => { - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) - + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + // 모바일 화면인 경우 body에 클래스 추가 if (window.innerWidth < MOBILE_BREAKPOINT) { - document.body.classList.add('is-mobile'); + document.body.classList.add("is-mobile"); } else { - document.body.classList.remove('is-mobile'); + document.body.classList.remove("is-mobile"); } - } - - // 초기 확인 - checkMobile() - - // 리사이즈 이벤트 리스너 추가 - window.addEventListener('resize', checkMobile) - - // 클린업 함수 - return () => window.removeEventListener('resize', checkMobile) - }, []) + }; - return isMobile + // 초기 확인 + checkMobile(); + + // 리사이즈 이벤트 리스너 추가 + window.addEventListener("resize", checkMobile); + + // 클린업 함수 + return () => window.removeEventListener("resize", checkMobile); + }, []); + + return isMobile; } diff --git a/src/hooks/use-toast.ts b/src/hooks/use-toast.ts index ff8eb0c..1be8274 100644 --- a/src/hooks/use-toast.ts +++ b/src/hooks/use-toast.ts @@ -1,4 +1,3 @@ - // 이 파일은 기존 import 경로 호환성을 위한 리디렉션입니다 export { useToast, toast, TOAST_LIMIT, TOAST_REMOVE_DELAY } from "./toast"; export type { ToasterToast } from "./toast/types"; diff --git a/src/hooks/useAppFocusEvents.tsx b/src/hooks/useAppFocusEvents.tsx index 4621509..e4a6f85 100644 --- a/src/hooks/useAppFocusEvents.tsx +++ b/src/hooks/useAppFocusEvents.tsx @@ -1,6 +1,6 @@ +import { useEffect } from "react"; -import { useEffect } from 'react'; - +import { logger } from "@/utils/logger"; /** * 앱이 포커스를 얻었을 때나 가시성이 변경될 때 데이터를 새로고침하는 커스텀 훅 */ @@ -8,68 +8,70 @@ export const useAppFocusEvents = () => { useEffect(() => { const handleFocus = () => { try { - console.log('창이 포커스를 얻음 - 데이터 새로고침'); + logger.info("창이 포커스를 얻음 - 데이터 새로고침"); // 이미 리프레시 중인지 확인하는 플래그 - if (sessionStorage.getItem('isRefreshing') === 'true') { - console.log('이미 리프레시 진행 중, 중복 실행 방지'); + if (sessionStorage.getItem("isRefreshing") === "true") { + logger.info("이미 리프레시 진행 중, 중복 실행 방지"); return; } - + try { - sessionStorage.setItem('isRefreshing', 'true'); - + sessionStorage.setItem("isRefreshing", "true"); + // 이벤트 발생시켜 데이터 새로고침 - window.dispatchEvent(new Event('storage')); - window.dispatchEvent(new Event('transactionUpdated')); - window.dispatchEvent(new Event('budgetDataUpdated')); - window.dispatchEvent(new Event('categoryBudgetsUpdated')); - + window.dispatchEvent(new Event("storage")); + window.dispatchEvent(new Event("transactionUpdated")); + window.dispatchEvent(new Event("budgetDataUpdated")); + window.dispatchEvent(new Event("categoryBudgetsUpdated")); + // 리프레시 완료 표시 (300ms 후에 플래그 해제) setTimeout(() => { - sessionStorage.setItem('isRefreshing', 'false'); + sessionStorage.setItem("isRefreshing", "false"); }, 300); } catch (e) { - console.error('이벤트 발생 오류:', e); - sessionStorage.setItem('isRefreshing', 'false'); + logger.error("이벤트 발생 오류:", e); + sessionStorage.setItem("isRefreshing", "false"); } } catch (error) { - console.error('포커스 이벤트 처리 중 오류:', error); + logger.error("포커스 이벤트 처리 중 오류:", error); } }; // 포커스 이벤트 - window.addEventListener('focus', handleFocus); - + window.addEventListener("focus", handleFocus); + // 가시성 변경 이벤트 (백그라운드에서 전경으로 돌아올 때) const handleVisibilityChange = () => { try { - if (document.visibilityState === 'visible') { - console.log('페이지가 다시 보임 - 데이터 새로고침'); + if (document.visibilityState === "visible") { + logger.info("페이지가 다시 보임 - 데이터 새로고침"); handleFocus(); } } catch (error) { - console.error('가시성 이벤트 처리 중 오류:', error); + logger.error("가시성 이벤트 처리 중 오류:", error); } }; - - document.addEventListener('visibilitychange', handleVisibilityChange); - + + document.addEventListener("visibilitychange", handleVisibilityChange); + // 정기적인 데이터 새로고침 (60초마다로 변경 - 너무 빈번한 리프레시 방지) const refreshInterval = setInterval(() => { try { - if (document.visibilityState === 'visible' && - sessionStorage.getItem('isRefreshing') !== 'true') { - console.log('정기 새로고침 - 데이터 업데이트'); + if ( + document.visibilityState === "visible" && + sessionStorage.getItem("isRefreshing") !== "true" + ) { + logger.info("정기 새로고침 - 데이터 업데이트"); handleFocus(); } } catch (error) { - console.error('정기 새로고침 처리 중 오류:', error); + logger.error("정기 새로고침 처리 중 오류:", error); } - }, 60000); // 60초마다 - + }, 60000); // 60초마다 + return () => { - window.removeEventListener('focus', handleFocus); - document.removeEventListener('visibilitychange', handleVisibilityChange); + window.removeEventListener("focus", handleFocus); + document.removeEventListener("visibilitychange", handleVisibilityChange); clearInterval(refreshInterval); }; }, []); diff --git a/src/hooks/useDataInitialization.ts b/src/hooks/useDataInitialization.ts index 7c5f54f..76ee955 100644 --- a/src/hooks/useDataInitialization.ts +++ b/src/hooks/useDataInitialization.ts @@ -1,138 +1,158 @@ - -import { useState, useEffect, useCallback } from 'react'; -import { resetAllData } from '@/contexts/budget/storage'; -import { resetAllStorageData } from '@/utils/storageUtils'; -import { clearCloudData } from '@/utils/sync/clearCloudData'; -import { useAuth } from '@/contexts/auth'; +import { useState, useEffect, useCallback } from "react"; +import { logger } from "@/utils/logger"; +import { resetAllData } from "@/contexts/budget/storage"; +import { resetAllStorageData } from "@/utils/storageUtils"; +import { clearCloudData } from "@/utils/sync/clearCloudData"; +import { useAuth } from "@/contexts/auth"; export const useDataInitialization = (resetBudgetData?: () => void) => { const [isInitialized, setIsInitialized] = useState(false); const { user } = useAuth(); - + // 모든 데이터 초기화 함수 const initializeAllData = useCallback(async () => { try { // 중요: 이미 방문한 적이 있으면 절대 초기화하지 않음 - const hasVisitedBefore = localStorage.getItem('hasVisitedBefore') === 'true'; + const hasVisitedBefore = + localStorage.getItem("hasVisitedBefore") === "true"; if (hasVisitedBefore) { - console.log('이미 앱을 방문한 적이 있으므로 데이터를 초기화하지 않습니다.'); + logger.info( + "이미 앱을 방문한 적이 있으므로 데이터를 초기화하지 않습니다." + ); setIsInitialized(true); return true; } - - console.log('첫 방문: 모든 데이터 초기화 시작'); - + + logger.info("첫 방문: 모든 데이터 초기화 시작"); + // 현재 dontShowWelcome 값 백업 - const dontShowWelcomeValue = localStorage.getItem('dontShowWelcome'); - console.log('useDataInitialization - 초기화 전 dontShowWelcome 값:', dontShowWelcomeValue); - + const dontShowWelcomeValue = localStorage.getItem("dontShowWelcome"); + logger.info( + "useDataInitialization - 초기화 전 dontShowWelcome 값:", + dontShowWelcomeValue + ); + try { // 로그인 상태라면 클라우드 데이터도 초기화 (첫 방문 시) if (user) { - console.log('로그인 상태: 클라우드 데이터도 초기화 시도'); + logger.info("로그인 상태: 클라우드 데이터도 초기화 시도"); await clearCloudData(user.id); } - + // 모든 데이터 완전히 삭제 및 초기화 (한 번만 실행) resetAllData(); resetAllStorageData(); - + // 컨텍스트 데이터 리셋 (필요한 경우) if (resetBudgetData) { resetBudgetData(); } - + // 초기화 후 dontShowWelcome 값 확인 - const afterResetValue = localStorage.getItem('dontShowWelcome'); - console.log('useDataInitialization - 초기화 후 dontShowWelcome 값:', afterResetValue); - + const afterResetValue = localStorage.getItem("dontShowWelcome"); + logger.info( + "useDataInitialization - 초기화 후 dontShowWelcome 값:", + afterResetValue + ); + // 값이 유지되지 않았다면 복원 if (dontShowWelcomeValue && afterResetValue !== dontShowWelcomeValue) { - console.log('useDataInitialization - dontShowWelcome 값 복원:', dontShowWelcomeValue); - localStorage.setItem('dontShowWelcome', dontShowWelcomeValue); + logger.info( + "useDataInitialization - dontShowWelcome 값 복원:", + dontShowWelcomeValue + ); + localStorage.setItem("dontShowWelcome", dontShowWelcomeValue); } - - console.log('모든 데이터 초기화 완료'); + + logger.info("모든 데이터 초기화 완료"); return true; } catch (error) { - console.error('데이터 초기화 중 오류 발생:', error); + logger.error("데이터 초기화 중 오류 발생:", error); return false; } } catch (error) { - console.error('initializeAllData 함수 실행 중 오류:', error); + logger.error("initializeAllData 함수 실행 중 오류:", error); setIsInitialized(true); // 오류가 발생해도 앱을 사용할 수 있도록 초기화 완료로 설정 return false; } }, [resetBudgetData, user]); - + // 분석 페이지 데이터 초기화 함수 const clearAllAnalyticsData = useCallback(() => { try { // 분석 관련 데이터만 선택적으로 삭제 (전체 데이터는 건드리지 않음) const analyticsKeys = [ - 'analytics', 'monthlyTotals', 'chartData', - 'expenseHistory', 'budgetHistory', 'categorySpending', - 'monthlyData', 'expenseData', 'analyticData' + "analytics", + "monthlyTotals", + "chartData", + "expenseHistory", + "budgetHistory", + "categorySpending", + "monthlyData", + "expenseData", + "analyticData", ]; - - analyticsKeys.forEach(key => { + + analyticsKeys.forEach((key) => { localStorage.removeItem(key); }); - + // 월간, 차트, 분석 관련 키워드가 포함된 항목만 삭제 for (let i = localStorage.length - 1; i >= 0; i--) { const key = localStorage.key(i); - if (key && ( - key.includes('month') || - key.includes('chart') || - key.includes('analytics') || - key.includes('expense') || - key.includes('budget') || - key.includes('total') - ) && - // 핵심 데이터는 건드리지 않도록 제외 - !key.includes('budgetData') && - !key.includes('transactions') && - !key.includes('categoryBudgets')) { - console.log(`분석 데이터 삭제: ${key}`); + if ( + key && + (key.includes("month") || + key.includes("chart") || + key.includes("analytics") || + key.includes("expense") || + key.includes("budget") || + key.includes("total")) && + // 핵심 데이터는 건드리지 않도록 제외 + !key.includes("budgetData") && + !key.includes("transactions") && + !key.includes("categoryBudgets") + ) { + logger.info(`분석 데이터 삭제: ${key}`); localStorage.removeItem(key); } } - + return true; } catch (error) { - console.error('분석 데이터 초기화 중 오류:', error); + logger.error("분석 데이터 초기화 중 오류:", error); return false; } }, []); - + // 데이터 초기화 실행 - 첫 방문시에만 useEffect(() => { try { if (!isInitialized) { // 이미 방문한 적이 있는지 체크 (이미 있다면 초기화하지 않음) - const hasVisitedBefore = localStorage.getItem('hasVisitedBefore') === 'true'; + const hasVisitedBefore = + localStorage.getItem("hasVisitedBefore") === "true"; if (hasVisitedBefore) { - console.log('이미 방문 기록이 있어 초기화를 건너뜁니다.'); + logger.info("이미 방문 기록이 있어 초기화를 건너뜁니다."); setIsInitialized(true); } else { - initializeAllData().then(result => { + initializeAllData().then((result) => { setIsInitialized(result); }); } } - + // 첫 방문 여부 체크용 키 설정 (항상 true로 설정) - localStorage.setItem('hasVisitedBefore', 'true'); + localStorage.setItem("hasVisitedBefore", "true"); } catch (error) { - console.error('데이터 초기화 useEffect 내 오류:', error); + logger.error("데이터 초기화 useEffect 내 오류:", error); setIsInitialized(true); // 오류 발생해도 앱을 사용할 수 있도록 초기화 완료로 설정 } }, [isInitialized, initializeAllData]); - + return { isInitialized, initializeAllData, - clearAllAnalyticsData + clearAllAnalyticsData, }; }; diff --git a/src/hooks/useDataReset.ts b/src/hooks/useDataReset.ts index 79dc8c2..b1f8b3f 100644 --- a/src/hooks/useDataReset.ts +++ b/src/hooks/useDataReset.ts @@ -1,11 +1,11 @@ - -import { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { useToast } from '@/hooks/useToast.wrapper'; -import { resetAllStorageData } from '@/utils/storageUtils'; -import { clearCloudData } from '@/utils/sync/clearCloudData'; -import { useAuth } from '@/contexts/auth'; -import { isSyncEnabled, setSyncEnabled } from '@/utils/sync/syncSettings'; +import { useState } from "react"; +import { logger } from "@/utils/logger"; +import { useNavigate } from "react-router-dom"; +import { useToast } from "@/hooks/useToast.wrapper"; +import { resetAllStorageData } from "@/utils/storageUtils"; +import { clearCloudData } from "@/utils/sync/clearCloudData"; +import { useAuth } from "@/contexts/auth"; +import { isSyncEnabled, setSyncEnabled } from "@/utils/sync/syncSettings"; export interface DataResetResult { isCloudResetSuccess: boolean | null; @@ -13,7 +13,9 @@ export interface DataResetResult { export const useDataReset = () => { const [isResetting, setIsResetting] = useState(false); - const [isCloudResetSuccess, setIsCloudResetSuccess] = useState(null); + const [isCloudResetSuccess, setIsCloudResetSuccess] = useState< + boolean | null + >(null); const { toast } = useToast(); const navigate = useNavigate(); const { user } = useAuth(); @@ -21,122 +23,131 @@ export const useDataReset = () => { const resetAllData = async (): Promise => { try { setIsResetting(true); - console.log('모든 데이터 초기화 시작'); - + logger.info("모든 데이터 초기화 시작"); + // 현재 동기화 설정 저장 const syncWasEnabled = isSyncEnabled(); - console.log('데이터 초기화 전 동기화 상태:', syncWasEnabled ? '활성화' : '비활성화'); - + logger.info( + "데이터 초기화 전 동기화 상태:", + syncWasEnabled ? "활성화" : "비활성화" + ); + // 중요: 클라우드 데이터 초기화 먼저 시도 (로그인 상태인 경우) let cloudResetSuccess = false; if (user) { - console.log('로그인 상태: 클라우드 데이터 초기화 시도'); - + logger.info("로그인 상태: 클라우드 데이터 초기화 시도"); + // 여러 번 시도하여 클라우드 데이터 초기화 확실히 하기 for (let attempt = 1; attempt <= 3; attempt++) { - console.log(`클라우드 데이터 초기화 시도 ${attempt}/3`); + logger.info(`클라우드 데이터 초기화 시도 ${attempt}/3`); cloudResetSuccess = await clearCloudData(user.id); - + if (cloudResetSuccess) { - console.log('클라우드 데이터 초기화 성공'); + logger.info("클라우드 데이터 초기화 성공"); break; } else { - console.warn(`클라우드 데이터 초기화 시도 ${attempt} 실패`); + logger.warn(`클라우드 데이터 초기화 시도 ${attempt} 실패`); // 잠시 대기 후 재시도 if (attempt < 3) { - await new Promise(resolve => setTimeout(resolve, 500)); + await new Promise((resolve) => setTimeout(resolve, 500)); } } } - + setIsCloudResetSuccess(cloudResetSuccess); } else { - console.log('로그인하지 않음: 클라우드 초기화 건너뜀'); + logger.info("로그인하지 않음: 클라우드 초기화 건너뜀"); setIsCloudResetSuccess(null); } - + // 초기화 실행 전에 사용자 설정 백업 - const dontShowWelcomeValue = localStorage.getItem('dontShowWelcome'); - const hasVisitedBefore = localStorage.getItem('hasVisitedBefore'); - + const dontShowWelcomeValue = localStorage.getItem("dontShowWelcome"); + const hasVisitedBefore = localStorage.getItem("hasVisitedBefore"); + // 로그인 관련 설정 백업 (supabase 관련 모든 설정) const authBackupItems: Record = {}; - + // 로그인 관련 항목 수집 for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); - if (key && ( - key.includes('supabase') || - key.includes('auth') || - key.includes('sb-') || - key.includes('token') || - key.includes('user') || - key.includes('session') - )) { + if ( + key && + (key.includes("supabase") || + key.includes("auth") || + key.includes("sb-") || + key.includes("token") || + key.includes("user") || + key.includes("session")) + ) { authBackupItems[key] = localStorage.getItem(key); - console.log(`백업 항목: ${key}`); + logger.info(`백업 항목: ${key}`); } } - + // 데이터 초기화 (개선된 메소드 사용) resetAllStorageData(); - + // 추가 초기화를 위해 빈 데이터 명시적 설정 - localStorage.setItem('transactions', JSON.stringify([])); - localStorage.setItem('budgetData', JSON.stringify({ - daily: {targetAmount: 0, spentAmount: 0, remainingAmount: 0}, - weekly: {targetAmount: 0, spentAmount: 0, remainingAmount: 0}, - monthly: {targetAmount: 0, spentAmount: 0, remainingAmount: 0} - })); - localStorage.setItem('categoryBudgets', JSON.stringify({})); - + localStorage.setItem("transactions", JSON.stringify([])); + localStorage.setItem( + "budgetData", + JSON.stringify({ + daily: { targetAmount: 0, spentAmount: 0, remainingAmount: 0 }, + weekly: { targetAmount: 0, spentAmount: 0, remainingAmount: 0 }, + monthly: { targetAmount: 0, spentAmount: 0, remainingAmount: 0 }, + }) + ); + localStorage.setItem("categoryBudgets", JSON.stringify({})); + // 사용자 설정 복원 if (dontShowWelcomeValue) { - localStorage.setItem('dontShowWelcome', dontShowWelcomeValue); + localStorage.setItem("dontShowWelcome", dontShowWelcomeValue); } - + if (hasVisitedBefore) { - localStorage.setItem('hasVisitedBefore', hasVisitedBefore); + localStorage.setItem("hasVisitedBefore", hasVisitedBefore); } - + // 로그인 관련 설정 복원 (로그인 화면이 나타나지 않도록) Object.entries(authBackupItems).forEach(([key, value]) => { if (value) { localStorage.setItem(key, value); - console.log(`복원 항목: ${key}`); + logger.info(`복원 항목: ${key}`); } }); - + // 중요: 동기화 설정은 초기화 후 강제로 비활성화 setSyncEnabled(false); - console.log('동기화 설정이 비활성화되었습니다.'); - + logger.info("동기화 설정이 비활성화되었습니다."); + // 마지막 동기화 시간은 초기화 - localStorage.removeItem('lastSync'); - + localStorage.removeItem("lastSync"); + // 삭제 플래그 초기화 (강제로 삭제 목록 초기화) - localStorage.removeItem('deletedTransactions'); - localStorage.removeItem('modifiedBudgets'); - + localStorage.removeItem("deletedTransactions"); + localStorage.removeItem("modifiedBudgets"); + // 스토리지 이벤트 트리거하여 다른 컴포넌트에 변경 알림 - window.dispatchEvent(new Event('transactionUpdated')); - window.dispatchEvent(new Event('budgetDataUpdated')); - window.dispatchEvent(new Event('categoryBudgetsUpdated')); - window.dispatchEvent(new StorageEvent('storage')); - window.dispatchEvent(new Event('auth-state-changed')); // 동기화 상태 변경 이벤트 추가 - + window.dispatchEvent(new Event("transactionUpdated")); + window.dispatchEvent(new Event("budgetDataUpdated")); + window.dispatchEvent(new Event("categoryBudgetsUpdated")); + window.dispatchEvent(new StorageEvent("storage")); + window.dispatchEvent(new Event("auth-state-changed")); // 동기화 상태 변경 이벤트 추가 + // 클라우드 초기화 상태에 따라 다른 메시지 표시 if (user) { if (cloudResetSuccess) { toast({ title: "모든 데이터가 초기화되었습니다.", - description: "로컬 및 클라우드의 모든 데이터가 초기화되었으며, 동기화 설정이 비활성화되었습니다.", + description: + "로컬 및 클라우드의 모든 데이터가 초기화되었으며, 동기화 설정이 비활성화되었습니다.", }); } else { toast({ title: "로컬 데이터만 초기화됨", - description: "로컬 데이터는 초기화되었지만, 클라우드 데이터 초기화 중 문제가 발생했습니다. 동기화 설정이 비활성화되었습니다.", - variant: "destructive" + description: + "로컬 데이터는 초기화되었지만, 클라우드 데이터 초기화 중 문제가 발생했습니다. 동기화 설정이 비활성화되었습니다.", + variant: "destructive", }); } } else { @@ -145,17 +156,17 @@ export const useDataReset = () => { description: "모든 예산, 지출 내역, 설정이 초기화되었습니다.", }); } - - console.log('모든 데이터 초기화 완료'); - + + logger.info("모든 데이터 초기화 완료"); + // 페이지 리프레시를 위해 잠시 후에 새로고침 setTimeout(() => { window.location.reload(); }, 500); - + return { isCloudResetSuccess: cloudResetSuccess }; } catch (error) { - console.error('데이터 초기화 실패:', error); + logger.error("데이터 초기화 실패:", error); toast({ title: "데이터 초기화 실패", description: "데이터를 초기화하는 중 문제가 발생했습니다.", @@ -170,6 +181,6 @@ export const useDataReset = () => { return { isResetting, isCloudResetSuccess, - resetAllData + resetAllData, }; }; diff --git a/src/hooks/useInitialDataLoading.tsx b/src/hooks/useInitialDataLoading.tsx index ab54d05..b726956 100644 --- a/src/hooks/useInitialDataLoading.tsx +++ b/src/hooks/useInitialDataLoading.tsx @@ -1,57 +1,62 @@ +import { useEffect } from "react"; -import { useEffect } from 'react'; - +import { logger } from "@/utils/logger"; /** * 앱 첫 실행 시 로컬스토리지 데이터를 로드하는 커스텀 훅 */ export const useInitialDataLoading = () => { useEffect(() => { try { - console.log('Index 페이지 마운트, 데이터 확인 중...'); - + logger.info("Index 페이지 마운트, 데이터 확인 중..."); + // 페이지 첫 마운트 시에만 실행되는 로직 - const isFirstMount = sessionStorage.getItem('initialDataLoaded') !== 'true'; - + const isFirstMount = + sessionStorage.getItem("initialDataLoaded") !== "true"; + if (isFirstMount) { try { // 백업된 데이터 복구 확인 (메인 데이터가 없는 경우만) - if (!localStorage.getItem('budgetData')) { - const budgetBackup = localStorage.getItem('budgetData_backup'); + if (!localStorage.getItem("budgetData")) { + const budgetBackup = localStorage.getItem("budgetData_backup"); if (budgetBackup) { - console.log('예산 데이터 백업에서 복구'); - localStorage.setItem('budgetData', budgetBackup); + logger.info("예산 데이터 백업에서 복구"); + localStorage.setItem("budgetData", budgetBackup); } } - - if (!localStorage.getItem('categoryBudgets')) { - const categoryBackup = localStorage.getItem('categoryBudgets_backup'); + + if (!localStorage.getItem("categoryBudgets")) { + const categoryBackup = localStorage.getItem( + "categoryBudgets_backup" + ); if (categoryBackup) { - console.log('카테고리 예산 백업에서 복구'); - localStorage.setItem('categoryBudgets', categoryBackup); + logger.info("카테고리 예산 백업에서 복구"); + localStorage.setItem("categoryBudgets", categoryBackup); } } - - if (!localStorage.getItem('transactions')) { - const transactionBackup = localStorage.getItem('transactions_backup'); + + if (!localStorage.getItem("transactions")) { + const transactionBackup = localStorage.getItem( + "transactions_backup" + ); if (transactionBackup) { - console.log('트랜잭션 백업에서 복구'); - localStorage.setItem('transactions', transactionBackup); + logger.info("트랜잭션 백업에서 복구"); + localStorage.setItem("transactions", transactionBackup); } } - + // 한 번만 이벤트 발생 - window.dispatchEvent(new Event('transactionUpdated')); - window.dispatchEvent(new Event('budgetDataUpdated')); - window.dispatchEvent(new Event('categoryBudgetsUpdated')); - + window.dispatchEvent(new Event("transactionUpdated")); + window.dispatchEvent(new Event("budgetDataUpdated")); + window.dispatchEvent(new Event("categoryBudgetsUpdated")); + // 초기 로드 완료 표시 - sessionStorage.setItem('initialDataLoaded', 'true'); + sessionStorage.setItem("initialDataLoaded", "true"); } catch (error) { - console.error('백업 복구 시도 중 오류:', error); + logger.error("백업 복구 시도 중 오류:", error); } } } catch (error) { - console.error('Index 페이지 초기화 중 오류:', error); + logger.error("Index 페이지 초기화 중 오류:", error); } - }, []); // 컴포넌트 마운트 시 한 번만 실행 + }, []); // 컴포넌트 마운트 시 한 번만 실행 }; diff --git a/src/hooks/useLogin.ts b/src/hooks/useLogin.ts index 74f5417..147e4bb 100644 --- a/src/hooks/useLogin.ts +++ b/src/hooks/useLogin.ts @@ -1,5 +1,5 @@ - import { useState } from "react"; +import { logger } from "@/utils/logger"; import { useNavigate } from "react-router-dom"; import { useToast } from "@/hooks/useToast.wrapper"; import { useAuth } from "@/contexts/auth"; @@ -11,7 +11,7 @@ export function useLogin() { const [showPassword, setShowPassword] = useState(false); const [isLoading, setIsLoading] = useState(false); const [loginError, setLoginError] = useState(null); - + const navigate = useNavigate(); const { toast } = useToast(); const { signIn } = useAuth(); @@ -20,75 +20,77 @@ export function useLogin() { const handleLogin = async (e: React.FormEvent) => { e.preventDefault(); setLoginError(null); - + if (!email || !password) { toast({ title: "입력 오류", description: "이메일과 비밀번호를 모두 입력해주세요.", - variant: "destructive" + variant: "destructive", }); return; } - + setIsLoading(true); try { const { error, user } = await signIn(email, password); - + if (error) { - console.error("로그인 실패:", error); - + logger.error("로그인 실패:", error); + let errorMessage = "로그인에 실패했습니다."; - + if (error.message) { if (error.message.includes("Invalid login credentials")) { errorMessage = "이메일 또는 비밀번호가 올바르지 않습니다."; } else if (error.message.includes("Email not confirmed")) { - errorMessage = "이메일 인증이 완료되지 않았습니다. 이메일을 확인해주세요."; + errorMessage = + "이메일 인증이 완료되지 않았습니다. 이메일을 확인해주세요."; } else { errorMessage = `오류: ${error.message}`; } } - + setLoginError(errorMessage); - + toast({ title: "로그인 실패", description: errorMessage, - variant: "destructive" + variant: "destructive", }); } else if (user) { // 로그인 성공 toast({ title: "로그인 성공", description: "환영합니다! 대시보드로 이동합니다.", - variant: "default" + variant: "default", }); - + await setupTables(); navigate("/"); } else { // user가 없지만 error도 없는 경우 (드문 경우) - console.warn("로그인 성공했지만 사용자 정보가 없습니다."); - + logger.warn("로그인 성공했지만 사용자 정보가 없습니다."); + toast({ title: "로그인 상태 확인 중", - description: "로그인은 성공했지만 사용자 정보를 확인하지 못했습니다. 페이지를 새로고침해보세요.", - variant: "default" + description: + "로그인은 성공했지만 사용자 정보를 확인하지 못했습니다. 페이지를 새로고침해보세요.", + variant: "default", }); - + navigate("/"); } } catch (err: any) { - console.error("로그인 과정에서 예외 발생:", err); - + logger.error("로그인 과정에서 예외 발생:", err); + const errorMessage = err.message || "알 수 없는 오류가 발생했습니다."; setLoginError(errorMessage); - + toast({ title: "로그인 오류", description: errorMessage, - variant: "destructive" + variant: "destructive", }); } finally { setIsLoading(false); @@ -106,6 +108,6 @@ export function useLogin() { isSettingUpTables, loginError, setLoginError, - handleLogin + handleLogin, }; } diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts index 140a4f5..31f28e1 100644 --- a/src/hooks/useNotifications.ts +++ b/src/hooks/useNotifications.ts @@ -1,7 +1,7 @@ - -import { useState, useEffect } from 'react'; -import { v4 as uuidv4 } from 'uuid'; -import { Notification } from '@/components/notification/NotificationPopover'; +import { useState, useEffect } from "react"; +import { logger } from "@/utils/logger"; +import { v4 as uuidv4 } from "uuid"; +import { Notification } from "@/components/notification/NotificationPopover"; export const useNotifications = () => { const [notifications, setNotifications] = useState([]); @@ -9,18 +9,20 @@ export const useNotifications = () => { // 로컬 스토리지에서 알림 불러오기 useEffect(() => { try { - const savedNotifications = localStorage.getItem('notifications'); + const savedNotifications = localStorage.getItem("notifications"); if (savedNotifications) { const parsedNotifications = JSON.parse(savedNotifications); // 시간 문자열을 Date 객체로 변환 - const formattedNotifications = parsedNotifications.map((notification: any) => ({ - ...notification, - timestamp: new Date(notification.timestamp) - })); + const formattedNotifications = parsedNotifications.map( + (notification: any) => ({ + ...notification, + timestamp: new Date(notification.timestamp), + }) + ); setNotifications(formattedNotifications); } } catch (error) { - console.error('알림 데이터 로드 중 오류 발생:', error); + logger.error("알림 데이터 로드 중 오류 발생:", error); } }, []); @@ -31,25 +33,31 @@ export const useNotifications = () => { title, message, timestamp: new Date(), - read: false + read: false, }; - setNotifications(prevNotifications => { + setNotifications((prevNotifications) => { const updatedNotifications = [newNotification, ...prevNotifications]; // 로컬 스토리지 업데이트 - localStorage.setItem('notifications', JSON.stringify(updatedNotifications)); + localStorage.setItem( + "notifications", + JSON.stringify(updatedNotifications) + ); return updatedNotifications; }); }; // 알림 읽음 표시 const markAsRead = (id: string) => { - setNotifications(prevNotifications => { - const updatedNotifications = prevNotifications.map(notification => + setNotifications((prevNotifications) => { + const updatedNotifications = prevNotifications.map((notification) => notification.id === id ? { ...notification, read: true } : notification ); // 로컬 스토리지 업데이트 - localStorage.setItem('notifications', JSON.stringify(updatedNotifications)); + localStorage.setItem( + "notifications", + JSON.stringify(updatedNotifications) + ); return updatedNotifications; }); }; @@ -57,14 +65,14 @@ export const useNotifications = () => { // 모든 알림 삭제 const clearAllNotifications = () => { setNotifications([]); - localStorage.removeItem('notifications'); + localStorage.removeItem("notifications"); }; return { notifications, addNotification, markAsRead, - clearAllNotifications + clearAllNotifications, }; }; diff --git a/src/hooks/useSyncSettings.ts b/src/hooks/useSyncSettings.ts index cbfedda..c92bb95 100644 --- a/src/hooks/useSyncSettings.ts +++ b/src/hooks/useSyncSettings.ts @@ -1,7 +1,7 @@ - -import { useState, useEffect } from 'react'; -import { useAuth } from '@/contexts/auth'; -import { useSyncToggle, useManualSync, useSyncStatus } from './sync'; +import { useState, useEffect } from "react"; +import { syncLogger } from "@/utils/logger"; +import { useAuth } from "@/contexts/auth"; +import { useSyncToggle, useManualSync, useSyncStatus } from "./sync"; /** * 동기화 설정 관리를 위한 커스텀 훅 @@ -11,14 +11,14 @@ export const useSyncSettings = () => { const { enabled, setEnabled, handleSyncToggle } = useSyncToggle(); const { syncing, handleManualSync } = useManualSync(user); const { lastSync, formatLastSyncTime } = useSyncStatus(); - + // 콘솔에 상태 기록 useEffect(() => { - console.log(`[동기화설정] 상태 변경: - - 활성화: ${enabled ? '예' : '아니오'} - - 진행중: ${syncing ? '예' : '아니오'} - - 마지막동기화: ${lastSync || '없음'} - - 사용자: ${user ? '로그인됨' : '로그인안됨'}`); + syncLogger.info(`[동기화설정] 상태 변경: + - 활성화: ${enabled ? "예" : "아니오"} + - 진행중: ${syncing ? "예" : "아니오"} + - 마지막동기화: ${lastSync || "없음"} + - 사용자: ${user ? "로그인됨" : "로그인안됨"}`); }, [enabled, syncing, lastSync, user]); return { diff --git a/src/hooks/useTableSetup.ts b/src/hooks/useTableSetup.ts index 78f1410..169d4b4 100644 --- a/src/hooks/useTableSetup.ts +++ b/src/hooks/useTableSetup.ts @@ -1,5 +1,5 @@ - import { useState } from "react"; +import { logger } from "@/utils/logger"; import { useToast } from "@/hooks/useToast.wrapper"; import { createRequiredTables } from "@/archive/lib/supabase/setup"; @@ -17,23 +17,24 @@ export function useTableSetup() { try { setIsSettingUpTables(true); const { success, message } = await createRequiredTables(); - + if (success) { - console.log("테이블 설정 성공:", message); + logger.info("테이블 설정 성공:", message); return true; } else { - console.warn("테이블 설정 문제:", message); + logger.warn("테이블 설정 문제:", message); // 사용자에게 경고 표시 (선택적) toast({ title: "테이블 설정 문제", - description: "일부 테이블 설정에 문제가 있었지만, 기본 기능은 사용할 수 있습니다.", - variant: "default" + description: + "일부 테이블 설정에 문제가 있었지만, 기본 기능은 사용할 수 있습니다.", + variant: "default", }); // 테이블 설정 실패해도 로그인은 진행 return false; } } catch (setupError) { - console.error("테이블 설정 중 오류:", setupError); + logger.error("테이블 설정 중 오류:", setupError); return false; } finally { setIsSettingUpTables(false); @@ -42,6 +43,6 @@ export function useTableSetup() { return { isSettingUpTables, - setupTables + setupTables, }; } diff --git a/src/hooks/useToast.wrapper.ts b/src/hooks/useToast.wrapper.ts index 8b74b79..089c59b 100644 --- a/src/hooks/useToast.wrapper.ts +++ b/src/hooks/useToast.wrapper.ts @@ -1,25 +1,28 @@ - -import { useToast as useOriginalToast, toast as originalToast } from '@/hooks/toast'; -import type { ToasterToast } from '@/hooks/toast/types'; +import { + useToast as useOriginalToast, + toast as originalToast, +} from "@/hooks/toast"; +import { logger } from "@/utils/logger"; +import type { ToasterToast } from "@/hooks/toast/types"; /** * 토스트 중복 방지를 위한 설정값 */ const TOAST_CONFIG = { - DEFAULT_DURATION: 3000, // 기본 토스트 표시 시간 (ms) - DEBOUNCE_TIME: 1500, // 동일 메시지 무시 시간 (ms) - HISTORY_LIMIT: 10, // 히스토리에 저장할 최대 토스트 수 - CLEANUP_INTERVAL: 30000, // 히스토리 정리 주기 (ms) - HISTORY_RETENTION: 10000 // 히스토리 보관 기간 (ms) + DEFAULT_DURATION: 3000, // 기본 토스트 표시 시간 (ms) + DEBOUNCE_TIME: 1500, // 동일 메시지 무시 시간 (ms) + HISTORY_LIMIT: 10, // 히스토리에 저장할 최대 토스트 수 + CLEANUP_INTERVAL: 30000, // 히스토리 정리 주기 (ms) + HISTORY_RETENTION: 10000, // 히스토리 보관 기간 (ms) }; /** * 토스트 메시지 히스토리 인터페이스 */ interface ToastHistoryItem { - message: string; // 메시지 내용 (title + description) - timestamp: number; // 생성 시간 - variant?: string; // 토스트 종류 (default/destructive) + message: string; // 메시지 내용 (title + description) + timestamp: number; // 생성 시간 + variant?: string; // 토스트 종류 (default/destructive) } /** @@ -32,7 +35,7 @@ class ToastHistoryManager { constructor() { // 주기적으로 오래된 히스토리 정리 this.cleanupInterval = setInterval( - () => this.cleanup(), + () => this.cleanup(), TOAST_CONFIG.CLEANUP_INTERVAL ); } @@ -44,7 +47,7 @@ class ToastHistoryManager { this.history.push({ message, timestamp: Date.now(), - variant + variant, }); // 히스토리 크기 제한 @@ -59,7 +62,7 @@ class ToastHistoryManager { cleanup(): void { const now = Date.now(); this.history = this.history.filter( - item => (now - item.timestamp) < TOAST_CONFIG.HISTORY_RETENTION + (item) => now - item.timestamp < TOAST_CONFIG.HISTORY_RETENTION ); } @@ -68,11 +71,12 @@ class ToastHistoryManager { */ isDuplicate(message: string, variant?: string): boolean { const now = Date.now(); - - return this.history.some(item => - item.message === message && - item.variant === variant && - (now - item.timestamp) < TOAST_CONFIG.DEBOUNCE_TIME + + return this.history.some( + (item) => + item.message === message && + item.variant === variant && + now - item.timestamp < TOAST_CONFIG.DEBOUNCE_TIME ); } @@ -82,7 +86,7 @@ class ToastHistoryManager { clear(): void { this.history = []; } - + /** * 정리 타이머 해제 (메모리 누수 방지) */ @@ -98,10 +102,9 @@ const toastHistory = new ToastHistoryManager(); * 메시지 내용 추출 (title + description) */ const extractMessage = (params: Omit): string => { - return [ - params.title?.toString() || '', - params.description?.toString() || '' - ].filter(Boolean).join(' - '); + return [params.title?.toString() || "", params.description?.toString() || ""] + .filter(Boolean) + .join(" - "); }; /** @@ -109,22 +112,22 @@ const extractMessage = (params: Omit): string => { */ const debouncedToast = (params: Omit) => { const message = extractMessage(params); - + // 빈 메시지 무시 if (!message.trim()) { - console.warn('빈 토스트 메시지가 무시되었습니다'); + logger.warn("빈 토스트 메시지가 무시되었습니다"); return; } - + // 중복 검사 if (toastHistory.isDuplicate(message, params.variant)) { - console.log('중복 토스트 감지로 무시됨:', message); + logger.info("중복 토스트 감지로 무시됨:", message); return; } - + // 히스토리에 추가 toastHistory.add(message, params.variant); - + // 실제 토스트 표시 originalToast({ ...params, diff --git a/src/hooks/useTransactions.ts b/src/hooks/useTransactions.ts index ec5ebfd..279283b 100644 --- a/src/hooks/useTransactions.ts +++ b/src/hooks/useTransactions.ts @@ -1,12 +1,11 @@ - // 이 파일은 이제 단순히 새로운 구조의 파일들을 재내보내기만 합니다 -export { +export { useTransactions, MONTHS_KR, getCurrentMonth, - getPrevMonth, + getPrevMonth, getNextMonth, filterTransactionsByMonth, filterTransactionsByQuery, - calculateTotalExpenses -} from './transactions'; + calculateTotalExpenses, +} from "./transactions"; diff --git a/src/hooks/useWelcomeDialog.ts b/src/hooks/useWelcomeDialog.ts index d183226..7e63c23 100644 --- a/src/hooks/useWelcomeDialog.ts +++ b/src/hooks/useWelcomeDialog.ts @@ -1,60 +1,69 @@ +import { useState, useEffect, useCallback } from "react"; -import { useState, useEffect, useCallback } from 'react'; - +import { logger } from "@/utils/logger"; export const useWelcomeDialog = () => { const [showWelcome, setShowWelcome] = useState(false); - + // 환영 다이얼로그 표시 여부 확인 const checkWelcomeDialogState = useCallback(() => { // 현재 세션에서 이미 환영 메시지를 닫았는지 확인 - const sessionClosed = sessionStorage.getItem('welcomeClosedThisSession') === 'true'; - + const sessionClosed = + sessionStorage.getItem("welcomeClosedThisSession") === "true"; + if (sessionClosed) { - console.log('useWelcomeDialog - 이번 세션에서 이미 환영 메시지를 닫았습니다'); + logger.info( + "useWelcomeDialog - 이번 세션에서 이미 환영 메시지를 닫았습니다" + ); setShowWelcome(false); return; } - - const dontShowWelcome = localStorage.getItem('dontShowWelcome'); - console.log('useWelcomeDialog - dontShowWelcome 값:', dontShowWelcome); - + + const dontShowWelcome = localStorage.getItem("dontShowWelcome"); + logger.info("useWelcomeDialog - dontShowWelcome 값:", dontShowWelcome); + // 명시적으로 'true' 문자열인 경우에만 숨김 처리 - if (dontShowWelcome === 'true') { - console.log('useWelcomeDialog - 환영 메시지 표시하지 않음 (저장된 설정)'); + if (dontShowWelcome === "true") { + logger.info("useWelcomeDialog - 환영 메시지 표시하지 않음 (저장된 설정)"); setShowWelcome(false); } else { - console.log('useWelcomeDialog - 환영 메시지 표시함'); + logger.info("useWelcomeDialog - 환영 메시지 표시함"); setShowWelcome(true); } }, []); - + // 환영 다이얼로그 닫기 핸들러 const handleCloseWelcome = useCallback((dontShowAgain: boolean) => { setShowWelcome(false); - + // 이번 세션에서 닫았음을 기록 - sessionStorage.setItem('welcomeClosedThisSession', 'true'); - + sessionStorage.setItem("welcomeClosedThisSession", "true"); + // 사용자가 더 이상 보지 않기를 선택한 경우 if (dontShowAgain) { - localStorage.setItem('dontShowWelcome', 'true'); - sessionStorage.setItem('dontShowWelcome', 'true'); - console.log('useWelcomeDialog - 환영 팝업 더 이상 표시하지 않기 설정됨:', dontShowAgain); - + localStorage.setItem("dontShowWelcome", "true"); + sessionStorage.setItem("dontShowWelcome", "true"); + logger.info( + "useWelcomeDialog - 환영 팝업 더 이상 표시하지 않기 설정됨:", + dontShowAgain + ); + // 설정 확인 - const savedValue = localStorage.getItem('dontShowWelcome'); - console.log('useWelcomeDialog - 설정 후 dontShowWelcome 저장값:', savedValue); + const savedValue = localStorage.getItem("dontShowWelcome"); + logger.info( + "useWelcomeDialog - 설정 후 dontShowWelcome 저장값:", + savedValue + ); } else { // 체크하지 않은 경우 명시적으로 false 저장 - localStorage.setItem('dontShowWelcome', 'false'); - sessionStorage.setItem('dontShowWelcome', 'false'); + localStorage.setItem("dontShowWelcome", "false"); + sessionStorage.setItem("dontShowWelcome", "false"); } }, []); - + return { showWelcome, setShowWelcome, checkWelcomeDialogState, - handleCloseWelcome + handleCloseWelcome, }; }; diff --git a/src/hooks/useWelcomeNotification.tsx b/src/hooks/useWelcomeNotification.tsx index f3d0fe6..cb0bc5a 100644 --- a/src/hooks/useWelcomeNotification.tsx +++ b/src/hooks/useWelcomeNotification.tsx @@ -1,7 +1,7 @@ - -import { useEffect } from 'react'; -import { useAuth } from '@/contexts/auth'; -import useNotifications from '@/hooks/useNotifications'; +import { useEffect } from "react"; +import { logger } from "@/utils/logger"; +import { useAuth } from "@/contexts/auth"; +import useNotifications from "@/hooks/useNotifications"; /** * 앱 초기화 후 환영 메시지 알림을 표시하는 커스텀 훅 @@ -13,23 +13,25 @@ export const useWelcomeNotification = (isInitialized: boolean) => { useEffect(() => { try { // 환영 메시지가 이미 표시되었는지 확인하는 키 - const welcomeNotificationSent = sessionStorage.getItem('welcomeNotificationSent'); - + const welcomeNotificationSent = sessionStorage.getItem( + "welcomeNotificationSent" + ); + if (isInitialized && user && !welcomeNotificationSent) { // 사용자 로그인 시 알림 예시 (한 번만 실행) const timeoutId = setTimeout(() => { addNotification( - '환영합니다!', - '젤리의 적자탈출에 오신 것을 환영합니다. 예산을 설정하고 지출을 기록해보세요.' + "환영합니다!", + "젤리의 적자탈출에 오신 것을 환영합니다. 예산을 설정하고 지출을 기록해보세요." ); // 세션 스토리지에 환영 메시지 표시 여부 저장 - sessionStorage.setItem('welcomeNotificationSent', 'true'); + sessionStorage.setItem("welcomeNotificationSent", "true"); }, 2000); - + return () => clearTimeout(timeoutId); } } catch (error) { - console.error('환영 메시지 알림 표시 중 오류:', error); + logger.error("환영 메시지 알림 표시 중 오류:", error); } }, [isInitialized, user, addNotification]); }; diff --git a/src/index.css b/src/index.css index cd9d8c3..7a598cd 100644 --- a/src/index.css +++ b/src/index.css @@ -1,4 +1,4 @@ -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); +@import url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap"); @tailwind base; @tailwind components; @@ -106,8 +106,10 @@ body { @apply bg-neuro-background text-foreground font-inter antialiased; } - - html, body, #root { + + html, + body, + #root { @apply h-full overflow-x-hidden; } } @@ -116,129 +118,132 @@ .neuro-flat { @apply bg-neuro-background shadow-neuro-flat rounded-xl; } - + .neuro-pressed { @apply bg-neuro-background shadow-neuro-pressed rounded-xl; } - + .neuro-convex { @apply bg-neuro-background shadow-neuro-convex rounded-xl; } - + .neuro-text { @apply font-medium tracking-wide; } - + .page-transition-enter { @apply animate-fade-in; } - + .glass-effect { @apply bg-white/10 backdrop-blur-lg border border-white/20 rounded-xl; } - + .neuro-button { @apply neuro-flat px-4 py-3 text-neuro-accent font-medium transition-all duration-200 hover:shadow-neuro-convex hover:text-neuro-accent-light active:shadow-neuro-pressed; } - + .neuro-card { @apply neuro-flat p-6 transition-all duration-300 hover:shadow-neuro-convex; } - + .neuro-input { @apply neuro-pressed px-4 py-3 w-full focus:outline-none focus:ring-2 focus:ring-neuro-accent/30; } - + /* 안전 영역 관련 클래스 - 강화된 버전 */ .safe-area-container { width: 100%; position: relative; box-sizing: border-box; } - + .ios-safe-area { /* iOS 전용 안전 영역 처리 */ position: relative; box-sizing: border-box; } - + .has-safe-area-top { padding-top: max(1rem, var(--safe-area-top)) !important; } - + .has-safe-area-bottom { padding-bottom: max(1rem, var(--safe-area-bottom)) !important; } - + .ios-header { padding-top: max(1rem, var(--safe-area-top)) !important; } - + /* 모바일 화면에서의 추가 스타일 */ @media (max-width: 768px) { .neuro-card { @apply w-full; } - + #root { @apply p-0; } - + /* 모바일에서 팝업과 다이얼로그 스타일 보정 */ [role="dialog"] { @apply rounded-xl overflow-hidden; } - + /* 다이얼로그 내용에 적용되는 스타일 */ - .DialogContent, - .PopoverContent, - .AlertDialogContent, - .DrawerContent, + .DialogContent, + .PopoverContent, + .AlertDialogContent, + .DrawerContent, .SheetContent { @apply rounded-xl overflow-hidden; } - + /* iOS 고유 노치/다이나믹 아일랜드 영역 고려한 추가 스타일 */ .ios-safe-area-screen { - padding: var(--safe-area-top) var(--safe-area-right) var(--safe-area-bottom) var(--safe-area-left) !important; + padding: var(--safe-area-top) var(--safe-area-right) + var(--safe-area-bottom) var(--safe-area-left) !important; } } - + /* 데스크탑 화면에서의 추가 스타일 */ @media (min-width: 769px) { #root { @apply px-0; } - + .desktop-container { @apply max-w-md mx-auto; } - + .desktop-card { @apply w-full mx-auto; } } - + /* 추가 애니메이션 정의 */ @keyframes pulse-subtle { - 0%, 100% { + 0%, + 100% { transform: scale(1); } 50% { transform: scale(1.05); } } - + @keyframes bounce-gentle { - 0%, 100% { + 0%, + 100% { transform: translateY(0); } 50% { transform: translateY(-5px); } } - + @keyframes spin-slow { from { transform: rotate(0deg); @@ -247,19 +252,19 @@ transform: rotate(360deg); } } - + .animate-pulse-subtle { animation: pulse-subtle 2s infinite ease-in-out; } - + .animate-bounce-gentle { animation: bounce-gentle 1.5s infinite ease-in-out; } - + .animate-spin-slow { animation: spin-slow 6s linear infinite; } - + @keyframes fade-in { from { opacity: 0; @@ -279,16 +284,16 @@ .ios-safe-area-top { padding-top: var(--safe-area-top) !important; } - + .ios-safe-area-bottom { padding-bottom: var(--safe-area-bottom) !important; } - + /* iOS에서 노치/다이나믹 아일랜드 영역 처리를 위한 추가 클래스 */ .ios-notch-padding { padding-top: max(1.5rem, var(--safe-area-top)) !important; } - + /* iOS에서 하단 홈 인디케이터 영역을 위한 추가 클래스 */ .ios-bottom-padding { padding-bottom: max(1.5rem, var(--safe-area-bottom)) !important; @@ -296,5 +301,5 @@ } .font-inter { - font-family: 'Inter', sans-serif; + font-family: "Inter", sans-serif; } diff --git a/src/ios-icon-guide.md b/src/ios-icon-guide.md index eb8dd3f..b8b8419 100644 --- a/src/ios-icon-guide.md +++ b/src/ios-icon-guide.md @@ -1,4 +1,3 @@ - # iOS 앱 아이콘 설정 가이드 iOS 앱 아이콘을 설정하려면 다음 단계를 따르세요: @@ -11,12 +10,14 @@ iOS 앱 아이콘을 설정하려면 다음 단계를 따르세요: ## iOS 아이콘 필수 크기: ### iPhone + - iPhone 앱: 60pt (@2x: 120x120px, @3x: 180x180px) - iPhone Spotlight: 40pt (@2x: 80x80px, @3x: 120x120px) - iPhone Settings: 29pt (@2x: 58x58px, @3x: 87x87px) - iPhone Notification: 20pt (@2x: 40x40px, @3x: 60x60px) ### iPad + - iPad 앱: 76pt (@1x: 76x76px, @2x: 152x152px) - iPad Pro 앱: 83.5pt (@2x: 167x167px) - iPad Spotlight: 40pt (@1x: 40x40px, @2x: 80x80px) @@ -24,6 +25,7 @@ iOS 앱 아이콘을 설정하려면 다음 단계를 따르세요: - iPad Notification: 20pt (@1x: 20x20px, @2x: 40x40px) ### App Store + - App Store: 1024x1024px (1x) ## Info.plist 설정 확인: @@ -37,5 +39,6 @@ iOS 앱 아이콘을 설정하려면 다음 단계를 따르세요: - NSFaceIDUsageDescription (Face ID 사용 시) ## 이미지 변환 도구: + - App Icon Generator: https://appicon.co/ - MakeAppIcon: https://makeappicon.com/ diff --git a/src/lib/appwrite/client.ts b/src/lib/appwrite/client.ts index 8c4d8b7..68fd8af 100644 --- a/src/lib/appwrite/client.ts +++ b/src/lib/appwrite/client.ts @@ -1,12 +1,13 @@ /** * Appwrite 클라이언트 설정 - * + * * 이 파일은 Appwrite 서비스와의 연결을 설정하고 필요한 서비스 인스턴스를 생성합니다. * 개발 가이드라인에 따라 UI 스레드를 차단하지 않도록 비동기 처리와 오류 처리를 포함합니다. */ -import { Client, Account, Databases, Storage, Avatars } from 'appwrite'; -import { config, validateConfig } from './config'; +import { Client, Account, Databases, Storage, Avatars } from "appwrite"; +import { appwriteLogger } from "@/utils/logger"; +import { config, validateConfig } from "./config"; // 서비스 타입 정의 export interface AppwriteServices { @@ -36,63 +37,66 @@ const initializeAppwriteClient = () => { try { // 설정 유효성 검증 validateConfig(); - - console.log(`Appwrite 클라이언트 생성 중: ${config.endpoint}`); - console.log(`프로젝트 ID: ${config.projectId}`); - + + appwriteLogger.info(`Appwrite 클라이언트 생성 중: ${config.endpoint}`); + appwriteLogger.info(`프로젝트 ID: ${config.projectId}`); + // Appwrite 클라이언트 생성 appwriteClient = new Client(); - - appwriteClient - .setEndpoint(config.endpoint) - .setProject(config.projectId); - + + appwriteClient.setEndpoint(config.endpoint).setProject(config.projectId); + // API 키가 있는 경우 설정 if (config.apiKey) { - console.log('API 키 설정 중...'); + appwriteLogger.info("API 키 설정 중..."); // 최신 Appwrite SDK에서는 JWT 토큰을 사용하거나 세션 기반 인증을 사용합니다. // 서버에서는 API 키를 사용하지만 클라이언트에서는 사용하지 않습니다. // 클라이언트에서 API 키를 사용하는 것은 보안 위험이 있어 권장되지 않습니다. - console.log('API 키가 설정되었지만 클라이언트에서는 사용하지 않습니다.'); + appwriteLogger.info( + "API 키가 설정되었지만 클라이언트에서는 사용하지 않습니다." + ); } else { - console.warn('API 키가 설정되지 않았습니다. 일부 기능이 제한될 수 있습니다.'); + appwriteLogger.warn( + "API 키가 설정되지 않았습니다. 일부 기능이 제한될 수 있습니다." + ); } - + // 서비스 초기화 accountService = new Account(appwriteClient); databasesService = new Databases(appwriteClient); storageService = new Storage(appwriteClient); avatarsService = new Avatars(appwriteClient); - + isInitialized = true; - console.log('Appwrite 클라이언트가 성공적으로 생성되었습니다.'); - + appwriteLogger.info("Appwrite 클라이언트가 성공적으로 생성되었습니다."); + // 세션 확인 (선택적) queueMicrotask(async () => { try { await accountService.get(); - console.log('Appwrite 세션 확인 성공'); + appwriteLogger.info("Appwrite 세션 확인 성공"); } catch (sessionError) { // 로그인되지 않은 상태는 정상적인 경우이므로 오류로 처리하지 않음 - console.log('Appwrite 세션 없음 (정상)'); + appwriteLogger.info("Appwrite 세션 없음 (정상)"); } }); - } catch (error) { - console.error('Appwrite 클라이언트 생성 오류:', error); + appwriteLogger.error("Appwrite 클라이언트 생성 오류:", error); initializationError = error as Error; - + // 더미 클라이언트 생성 (앱이 완전히 실패하지 않도록) appwriteClient = new Client(); accountService = new Account(appwriteClient); databasesService = new Databases(appwriteClient); storageService = new Storage(appwriteClient); avatarsService = new Avatars(appwriteClient); - + // 사용자에게 오류 알림 (개발 모드에서만) if (import.meta.env.DEV) { queueMicrotask(() => { - console.warn('Appwrite 서버 연결에 실패했습니다. 환경 설정을 확인해주세요.'); + appwriteLogger.warn( + "Appwrite 서버 연결에 실패했습니다. 환경 설정을 확인해주세요." + ); }); } } @@ -115,7 +119,7 @@ export const avatars = avatarsService; export const getInitializationStatus = () => { return { isInitialized, - error: initializationError + error: initializationError, }; }; @@ -124,7 +128,7 @@ export const getInitializationStatus = () => { * 오류 발생 시 재시도하기 위한 함수 */ export const reinitializeAppwriteClient = () => { - console.log('Appwrite 클라이언트 재초기화 시도'); + appwriteLogger.info("Appwrite 클라이언트 재초기화 시도"); isInitialized = false; initializationError = null; initializeAppwriteClient(); @@ -136,7 +140,7 @@ export const isValidConnection = async (): Promise => { if (!isInitialized) { return false; } - + try { // 계정 서비스를 통해 현재 세션 상태 확인 (간단한 API 호출) await account.get(); @@ -146,8 +150,8 @@ export const isValidConnection = async (): Promise => { if (error && (error as any).code === 401) { return true; // 서버 연결은 정상이지만 로그인되지 않은 상태 } - - console.error('Appwrite 연결 확인 오류:', error); + + appwriteLogger.error("Appwrite 연결 확인 오류:", error); return false; } }; diff --git a/src/lib/appwrite/config.ts b/src/lib/appwrite/config.ts index 9b65fba..1482a9e 100644 --- a/src/lib/appwrite/config.ts +++ b/src/lib/appwrite/config.ts @@ -1,6 +1,6 @@ /** * Appwrite 설정 - * + * * 이 파일은 Appwrite 서비스에 필요한 모든 설정 값을 정의합니다. * 환경 변수에서 값을 가져오며, 기본값을 제공합니다. */ @@ -15,19 +15,24 @@ export interface AppwriteConfig { } // 환경 변수에서 설정 값 가져오기 -const endpoint = import.meta.env.VITE_APPWRITE_ENDPOINT || 'https://a11.ism.kr/v1'; -const projectId = import.meta.env.VITE_APPWRITE_PROJECT_ID || '68182a300039f6d700a6'; -const databaseId = import.meta.env.VITE_APPWRITE_DATABASE_ID || 'default'; -const transactionsCollectionId = import.meta.env.VITE_APPWRITE_TRANSACTIONS_COLLECTION_ID || 'transactions'; -const apiKey = import.meta.env.VITE_APPWRITE_API_KEY || ''; +const endpoint = + import.meta.env.VITE_APPWRITE_ENDPOINT || "https://a11.ism.kr/v1"; +const projectId = + import.meta.env.VITE_APPWRITE_PROJECT_ID || "68182a300039f6d700a6"; +const databaseId = import.meta.env.VITE_APPWRITE_DATABASE_ID || "default"; +const transactionsCollectionId = + import.meta.env.VITE_APPWRITE_TRANSACTIONS_COLLECTION_ID || "transactions"; +// API 키는 보안상 클라이언트에서 제거됨 +// 서버 사이드 함수나 백엔드에서만 사용해야 함 +const apiKey = ""; // 개발 모드에서 설정 값 로깅 -console.log('현재 Appwrite 설정:', { - endpoint, - projectId, - databaseId, +appwriteLogger.info("현재 Appwrite 설정:", { + endpoint, + projectId, + databaseId, transactionsCollectionId, - apiKey: apiKey ? '설정됨' : '설정되지 않음' // API 키는 안전을 위해 완전한 값을 로깅하지 않음 + apiKey: apiKey ? "설정됨" : "설정되지 않음", // API 키는 안전을 위해 완전한 값을 로깅하지 않음 }); // 설정 객체 생성 @@ -43,7 +48,8 @@ export const config: AppwriteConfig = { export const getAppwriteEndpoint = (): string => endpoint; export const getAppwriteProjectId = (): string => projectId; export const getAppwriteDatabaseId = (): string => databaseId; -export const getAppwriteTransactionsCollectionId = (): string => transactionsCollectionId; +export const getAppwriteTransactionsCollectionId = (): string => + transactionsCollectionId; /** * 서버 연결 유효성 검사 @@ -58,6 +64,10 @@ export const isValidAppwriteConfig = (): boolean => { * @throws 필수 설정이 없는 경우 오류 발생 */ export const validateConfig = (): void => { - if (!endpoint) throw new Error("VITE_APPWRITE_ENDPOINT is not set"); - if (!projectId) throw new Error("VITE_APPWRITE_PROJECT_ID is not set"); + if (!endpoint) { + throw new Error("VITE_APPWRITE_ENDPOINT is not set"); + } + if (!projectId) { + throw new Error("VITE_APPWRITE_PROJECT_ID is not set"); + } }; diff --git a/src/lib/appwrite/defaultUser.ts b/src/lib/appwrite/defaultUser.ts index 9551498..844e518 100644 --- a/src/lib/appwrite/defaultUser.ts +++ b/src/lib/appwrite/defaultUser.ts @@ -1,16 +1,16 @@ /** * Appwrite 기본 사용자 정보 - * + * * 이 파일은 Appwrite 서비스에 연결할 때 사용할 기본 사용자 정보를 제공합니다. * 개발 가이드라인에 따라 UI 스레드를 차단하지 않도록 비동기 처리와 오류 처리를 포함합니다. */ // 기본 사용자 ID -export const DEFAULT_USER_ID = '68183aa4002a6f19542b'; +export const DEFAULT_USER_ID = "68183aa4002a6f19542b"; /** * 기본 사용자 정보를 가져오는 함수 - * + * * @returns 기본 사용자 ID */ export const getDefaultUserId = (): string => { @@ -19,7 +19,7 @@ export const getDefaultUserId = (): string => { /** * 사용자 ID가 기본 사용자인지 확인하는 함수 - * + * * @param userId 확인할 사용자 ID * @returns 기본 사용자 여부 */ diff --git a/src/lib/appwrite/index.ts b/src/lib/appwrite/index.ts index 9db1f28..364f284 100644 --- a/src/lib/appwrite/index.ts +++ b/src/lib/appwrite/index.ts @@ -1,12 +1,19 @@ -import { client, account, databases, storage, avatars, isValidConnection } from './client'; -import { - getAppwriteEndpoint, - getAppwriteProjectId, - getAppwriteDatabaseId, +import { + client, + account, + databases, + storage, + avatars, + isValidConnection, +} from "./client"; +import { + getAppwriteEndpoint, + getAppwriteProjectId, + getAppwriteDatabaseId, getAppwriteTransactionsCollectionId, - isValidAppwriteConfig -} from './config'; -import { setupAppwriteDatabase } from './setup'; + isValidAppwriteConfig, +} from "./config"; +import { setupAppwriteDatabase } from "./setup"; export { // 클라이언트 및 서비스 @@ -15,7 +22,7 @@ export { databases, storage, avatars, - + // 설정 및 유틸리티 getAppwriteEndpoint, getAppwriteProjectId, @@ -23,7 +30,7 @@ export { getAppwriteTransactionsCollectionId, isValidAppwriteConfig, isValidConnection, - + // 데이터베이스 설정 - setupAppwriteDatabase + setupAppwriteDatabase, }; diff --git a/src/lib/appwrite/setup.ts b/src/lib/appwrite/setup.ts index 82c213a..8ebbefb 100644 --- a/src/lib/appwrite/setup.ts +++ b/src/lib/appwrite/setup.ts @@ -1,6 +1,7 @@ -import { ID, Query, Permission, Role } from 'appwrite'; -import { databases, account } from './client'; -import { config } from './config'; +import { ID, Query, Permission, Role, Models } from "appwrite"; +import { appwriteLogger } from "@/utils/logger"; +import { databases, account } from "./client"; +import { config } from "./config"; /** * Appwrite 데이터베이스 및 컬렉션 설정 @@ -10,56 +11,62 @@ export const setupAppwriteDatabase = async (): Promise => { try { const databaseId = config.databaseId; const transactionsCollectionId = config.transactionsCollectionId; - + // 현재 사용자 정보 가져오기 const user = await account.get(); - + // 1. 데이터베이스 존재 확인 또는 생성 - let database: any; - + let database: Models.Database; + try { // 기존 데이터베이스 가져오기 시도 // @ts-ignore - Appwrite SDK 17.0.2 버전 호환성 문제 database = await databases.getDatabase(databaseId); - console.log('기존 데이터베이스를 찾았습니다:', database.name); + appwriteLogger.info("기존 데이터베이스를 찾았습니다:", database.name); } catch (error) { // 데이터베이스가 없으면 생성 - console.log('데이터베이스를 생성합니다...'); + appwriteLogger.info("데이터베이스를 생성합니다..."); // @ts-ignore - Appwrite SDK 17.0.2 버전 호환성 문제 - database = await databases.createDatabase(databaseId, 'Zellyy Finance'); - console.log('데이터베이스가 생성되었습니다:', database.name); + database = await databases.createDatabase(databaseId, "Zellyy Finance"); + appwriteLogger.info("데이터베이스가 생성되었습니다:", database.name); } - + // 2. 트랜잭션 컬렉션 존재 확인 또는 생성 - let collection: any; - + let collection: Models.Collection; + try { // 기존 컬렉션 가져오기 시도 // @ts-ignore - Appwrite SDK 17.0.2 버전 호환성 문제 - collection = await databases.getCollection(databaseId, transactionsCollectionId); - console.log('기존 트랜잭션 컬렉션을 찾았습니다:', collection.name); + collection = await databases.getCollection( + databaseId, + transactionsCollectionId + ); + appwriteLogger.info( + "기존 트랜잭션 컬렉션을 찾았습니다:", + collection.name + ); } catch (error) { // 컬렉션이 없으면 생성 - console.log('트랜잭션 컬렉션을 생성합니다...'); - + appwriteLogger.info("트랜잭션 컬렉션을 생성합니다..."); + // @ts-ignore - Appwrite SDK 17.0.2 버전 호환성 문제 collection = await databases.createCollection( databaseId, transactionsCollectionId, { - name: '거래 내역', + name: "거래 내역", permissions: [ // 사용자만 자신의 데이터에 접근 가능하도록 설정 Permission.read(Role.user(user.$id)), Permission.update(Role.user(user.$id)), Permission.delete(Role.user(user.$id)), - Permission.create(Role.user(user.$id)) - ] + Permission.create(Role.user(user.$id)), + ], } ); - - console.log('트랜잭션 컬렉션이 생성되었습니다:', collection.name); - + + appwriteLogger.info("트랜잭션 컬렉션이 생성되었습니다:", collection.name); + // 3. 필요한 속성(필드) 생성 await Promise.all([ // 사용자 ID 필드 @@ -67,106 +74,106 @@ export const setupAppwriteDatabase = async (): Promise => { databases.createStringAttribute( databaseId, transactionsCollectionId, - 'user_id', + "user_id", { size: 255, required: true, default: user.$id, - array: false + array: false, } ), - + // 트랜잭션 ID 필드 // @ts-ignore - Appwrite SDK 17.0.2 버전 호환성 문제 databases.createStringAttribute( databaseId, transactionsCollectionId, - 'transaction_id', + "transaction_id", { size: 255, required: true, default: null, - array: false + array: false, } ), - + // 제목 필드 // @ts-ignore - Appwrite SDK 17.0.2 버전 호환성 문제 databases.createStringAttribute( databaseId, transactionsCollectionId, - 'title', + "title", { size: 255, required: true, default: null, - array: false + array: false, } ), - + // 금액 필드 // @ts-ignore - Appwrite SDK 17.0.2 버전 호환성 문제 databases.createFloatAttribute( databaseId, transactionsCollectionId, - 'amount', + "amount", { required: true, default: 0, min: null, - max: null + max: null, } ), - + // 날짜 필드 // @ts-ignore - Appwrite SDK 17.0.2 버전 호환성 문제 databases.createStringAttribute( databaseId, transactionsCollectionId, - 'date', + "date", { size: 255, required: true, default: null, - array: false + array: false, } ), - + // 카테고리 필드 // @ts-ignore - Appwrite SDK 17.0.2 버전 호환성 문제 databases.createStringAttribute( databaseId, transactionsCollectionId, - 'category', + "category", { size: 255, required: false, default: null, - array: false + array: false, } ), - + // 유형 필드 (수입/지출) // @ts-ignore - Appwrite SDK 17.0.2 버전 호환성 문제 databases.createStringAttribute( databaseId, transactionsCollectionId, - 'type', + "type", { size: 50, required: true, - default: 'expense', - array: false + default: "expense", + array: false, } - ) + ), ]); - - console.log('트랜잭션 컬렉션 속성이 생성되었습니다.'); + + appwriteLogger.info("트랜잭션 컬렉션 속성이 생성되었습니다."); } - + return true; } catch (error) { - console.error('Appwrite 데이터베이스 설정 오류:', error); + appwriteLogger.error("Appwrite 데이터베이스 설정 오류:", error); return false; } }; diff --git a/src/lib/fullMigrate.js b/src/lib/fullMigrate.js index 71ab383..88b7fc0 100755 --- a/src/lib/fullMigrate.js +++ b/src/lib/fullMigrate.js @@ -1,19 +1,20 @@ #!/usr/bin/env node // 스키마 및 데이터를 Supabase Cloud -> On-Prem(a11)으로 완전 복제 -import 'dotenv/config'; -import { execSync } from 'child_process'; -import { URL, fileURLToPath } from 'url'; -import path from 'path'; +import "dotenv/config"; +import { execSync } from "child_process"; +import { logger } from "@/utils/logger"; +import { URL, fileURLToPath } from "url"; +import path from "path"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const CLOUD_DATABASE_URL = process.env.CLOUD_DATABASE_URL; -const ONPREM_SSH_HOST = process.env.ONPREM_SSH_HOST || 'a11'; -const ONPREM_REMOTE_TMP_DIR = process.env.ONPREM_REMOTE_TMP_DIR || '/root'; +const ONPREM_SSH_HOST = process.env.ONPREM_SSH_HOST || "a11"; +const ONPREM_REMOTE_TMP_DIR = process.env.ONPREM_REMOTE_TMP_DIR || "/root"; if (!CLOUD_DATABASE_URL) { - console.error('환경 변수 CLOUD_DATABASE_URL이 설정되지 않았습니다.'); + logger.error("환경 변수 CLOUD_DATABASE_URL이 설정되지 않았습니다."); process.exit(1); } @@ -21,24 +22,26 @@ if (!CLOUD_DATABASE_URL) { const cloudUrlObj = new URL(CLOUD_DATABASE_URL); const CLOUD_DATABASE_PASSWORD = cloudUrlObj.password; if (!CLOUD_DATABASE_PASSWORD) { - console.error('Cloud DB URL에서 비밀번호를 찾을 수 없습니다.'); + logger.error("Cloud DB URL에서 비밀번호를 찾을 수 없습니다."); process.exit(1); } // 원격 Postgres 컨테이너 이름 조회 -console.log('원격 Postgres 컨테이너 조회 (ssh a11)...'); +logger.info("원격 Postgres 컨테이너 조회 (ssh a11)..."); let containerName = execSync( `ssh ${ONPREM_SSH_HOST} "docker ps --format '{{.Names}} {{.Image}}' | grep supabase/postgres | awk '{print \\$1}'"`, - { encoding: 'utf8' } + { encoding: "utf8" } ).trim(); if (!containerName) { - console.error('원격 Postgres 컨테이너를 찾을 수 없습니다. docker ps 결과를 확인하세요.'); + logger.error( + "원격 Postgres 컨테이너를 찾을 수 없습니다. docker ps 결과를 확인하세요." + ); process.exit(1); } -console.log(`발견된 컨테이너: ${containerName}`); +logger.info(`발견된 컨테이너: ${containerName}`); // 1) 원격 a11에서 Cloud DB 스키마 덤프 -console.log('원격에서 Cloud DB 스키마 덤프 시작...'); +logger.info("원격에서 Cloud DB 스키마 덤프 시작..."); execSync( `ssh ${ONPREM_SSH_HOST} "docker run --rm --network host -e PGPASSWORD='${CLOUD_DATABASE_PASSWORD}' postgres:15 ` + `pg_dump --schema-only --no-owner --no-privileges ` + @@ -46,11 +49,11 @@ execSync( `-p ${cloudUrlObj.port} ` + `-U ${cloudUrlObj.username} ` + `${cloudUrlObj.pathname.slice(1)} > ${ONPREM_REMOTE_TMP_DIR}/supabase_schema.sql"`, - { stdio: 'inherit' } + { stdio: "inherit" } ); // 2) 원격 a11에서 Cloud DB 데이터 덤프 -console.log('원격에서 Cloud DB 데이터 덤프 시작...'); +logger.info("원격에서 Cloud DB 데이터 덤프 시작..."); execSync( `ssh ${ONPREM_SSH_HOST} "docker run --rm --network host -e PGPASSWORD='${CLOUD_DATABASE_PASSWORD}' postgres:15 ` + `pg_dump --data-only --column-inserts --no-owner --no-privileges ` + @@ -58,23 +61,23 @@ execSync( `-p ${cloudUrlObj.port} ` + `-U ${cloudUrlObj.username} ` + `${cloudUrlObj.pathname.slice(1)} > ${ONPREM_REMOTE_TMP_DIR}/supabase_data.sql"`, - { stdio: 'inherit' } + { stdio: "inherit" } ); // 4) 원격에 복원 (스키마) -console.log('원격 스키마 복원...'); +logger.info("원격 스키마 복원..."); execSync( `ssh ${ONPREM_SSH_HOST} "docker exec -i ${containerName} ` + `psql -U postgres -d postgres < ${ONPREM_REMOTE_TMP_DIR}/supabase_schema.sql"`, - { stdio: 'inherit' } + { stdio: "inherit" } ); // 5) 원격에 복원 (데이터) -console.log('원격 데이터 복원...'); +logger.info("원격 데이터 복원..."); execSync( `ssh ${ONPREM_SSH_HOST} "docker exec -i ${containerName} ` + `psql -U postgres -d postgres < ${ONPREM_REMOTE_TMP_DIR}/supabase_data.sql"`, - { stdio: 'inherit' } + { stdio: "inherit" } ); -console.log('Cloud → On-Prem 전체 마이그레이션 완료.'); +logger.info("Cloud → On-Prem 전체 마이그레이션 완료."); diff --git a/src/lib/migrateData.js b/src/lib/migrateData.js index 7839000..c29a4f9 100644 --- a/src/lib/migrateData.js +++ b/src/lib/migrateData.js @@ -1,15 +1,22 @@ -import dotenv from 'dotenv'; -import { createClient } from '@supabase/supabase-js'; +import dotenv from "dotenv"; +import { logger } from "@/utils/logger"; +import { createClient } from "@supabase/supabase-js"; dotenv.config(); const cloudUrl = process.env.CLOUD_SUPABASE_URL; -const cloudKey = process.env.CLOUD_SUPABASE_SERVICE_ROLE_KEY || process.env.CLOUD_SUPABASE_ANON_KEY; +const cloudKey = + process.env.CLOUD_SUPABASE_SERVICE_ROLE_KEY || + process.env.CLOUD_SUPABASE_ANON_KEY; const onpremUrl = process.env.ONPREM_SUPABASE_URL; -const onpremKey = process.env.ONPREM_SUPABASE_SERVICE_ROLE_KEY || process.env.ONPREM_SUPABASE_ANON_KEY; +const onpremKey = + process.env.ONPREM_SUPABASE_SERVICE_ROLE_KEY || + process.env.ONPREM_SUPABASE_ANON_KEY; if (!cloudUrl || !cloudKey || !onpremUrl || !onpremKey) { - console.error('환경 변수 설정 오류: CLOUD/ONPREM URL 또는 키가 누락되었습니다.'); + logger.error( + "환경 변수 설정 오류: CLOUD/ONPREM URL 또는 키가 누락되었습니다." + ); process.exit(1); } @@ -17,7 +24,7 @@ const cloud = createClient(cloudUrl, cloudKey); const onprem = createClient(onpremUrl, onpremKey); // 마이그레이션할 테이블 목록 -const tables = ['transactions', 'budgets', '_tests']; +const tables = ["transactions", "budgets", "_tests"]; // 테이블 스키마 정의 const tableSchemas = { @@ -115,15 +122,15 @@ const tableSchemas = { CREATE POLICY "모든 사용자가 테스트 테이블에 접근 가능" ON _tests FOR SELECT USING (true); - ` + `, }; /** * 헬퍼 함수 생성 */ async function createHelperFunctions() { - console.log('헬퍼 함수 생성 중...'); - + logger.info("헬퍼 함수 생성 중..."); + // execute_sql 함수 생성 const executeSqlSQL = ` CREATE OR REPLACE FUNCTION execute_sql(sql_query TEXT) @@ -133,14 +140,16 @@ async function createHelperFunctions() { END; $$ LANGUAGE plpgsql SECURITY DEFINER; `; - - const { error: execFnError } = await onprem.rpc('execute_sql', { sql_query: executeSqlSQL }); + + const { error: execFnError } = await onprem.rpc("execute_sql", { + sql_query: executeSqlSQL, + }); if (execFnError) { - console.error('execute_sql 함수 생성 실패:', execFnError); + logger.error("execute_sql 함수 생성 실패:", execFnError); return false; } - - console.log('헬퍼 함수 생성 완료'); + + logger.info("헬퍼 함수 생성 완료"); return true; } @@ -148,26 +157,28 @@ async function createHelperFunctions() { * 테이블 생성 */ async function createTable(tableName) { - console.log(`테이블 생성 중: ${tableName}`); - + logger.info(`테이블 생성 중: ${tableName}`); + if (!tableSchemas[tableName]) { - console.warn(`${tableName} 테이블의 스키마 정보가 없습니다.`); + logger.warn(`${tableName} 테이블의 스키마 정보가 없습니다.`); return false; } - + try { // 테이블 생성 SQL 실행 - const { error } = await onprem.rpc('execute_sql', { sql_query: tableSchemas[tableName] }); - + const { error } = await onprem.rpc("execute_sql", { + sql_query: tableSchemas[tableName], + }); + if (error) { - console.error(`${tableName} 테이블 생성 실패:`, error); + logger.error(`${tableName} 테이블 생성 실패:`, error); return false; } - - console.log(`${tableName} 테이블 생성 완료`); + + logger.info(`${tableName} 테이블 생성 완료`); return true; } catch (error) { - console.error(`${tableName} 테이블 생성 중 오류:`, error); + logger.error(`${tableName} 테이블 생성 중 오류:`, error); return false; } } @@ -176,49 +187,51 @@ async function createTable(tableName) { * 테이블 데이터 마이그레이션 */ async function migrateTableData(tableName) { - console.log(`테이블 데이터 마이그레이션 중: ${tableName}`); - + logger.info(`테이블 데이터 마이그레이션 중: ${tableName}`); + try { // Cloud DB에서 데이터 가져오기 - const { data, error } = await cloud.from(tableName).select('*'); - + const { data, error } = await cloud.from(tableName).select("*"); + if (error) { - if (error.code === '42P01') { - console.warn(`Cloud DB에 ${tableName} 테이블이 없습니다. 건너뜁니다.`); + if (error.code === "42P01") { + logger.warn(`Cloud DB에 ${tableName} 테이블이 없습니다. 건너뜁니다.`); return true; } - console.error(`Cloud DB에서 ${tableName} 데이터 가져오기 실패:`, error); + logger.error(`Cloud DB에서 ${tableName} 데이터 가져오기 실패:`, error); return false; } - + if (!data || data.length === 0) { - console.log(`${tableName} 테이블에 마이그레이션할 데이터가 없습니다.`); + logger.info(`${tableName} 테이블에 마이그레이션할 데이터가 없습니다.`); return true; } - - console.log(`${tableName} 테이블에서 ${data.length}개 행을 가져왔습니다.`); - + + logger.info(`${tableName} 테이블에서 ${data.length}개 행을 가져왔습니다.`); + // 데이터를 작은 배치로 나누어 삽입 (트랜잭션 삭제 안전성 고려) const batchSize = 100; for (let i = 0; i < data.length; i += batchSize) { const batch = data.slice(i, i + batchSize); const { error: insertError } = await onprem.from(tableName).upsert(batch); - + if (insertError) { - console.error(`${tableName} 테이블에 데이터 삽입 실패:`, insertError); + logger.error(`${tableName} 테이블에 데이터 삽입 실패:`, insertError); return false; } - - console.log(`${tableName} 테이블에 ${batch.length}개 행 삽입 완료 (${i + batch.length}/${data.length})`); - + + logger.info( + `${tableName} 테이블에 ${batch.length}개 행 삽입 완료 (${i + batch.length}/${data.length})` + ); + // 비동기 작업 사이에 짧은 지연 추가 (UI 스레드 차단 방지) - await new Promise(resolve => setTimeout(resolve, 100)); + await new Promise((resolve) => setTimeout(resolve, 100)); } - - console.log(`${tableName} 테이블 데이터 마이그레이션 완료`); + + logger.info(`${tableName} 테이블 데이터 마이그레이션 완료`); return true; } catch (error) { - console.error(`${tableName} 테이블 데이터 마이그레이션 중 오류:`, error); + logger.error(`${tableName} 테이블 데이터 마이그레이션 중 오류:`, error); return false; } } @@ -228,33 +241,35 @@ async function migrateTableData(tableName) { */ async function main() { try { - console.log('Supabase Cloud → On-Prem 마이그레이션 시작'); - + logger.info("Supabase Cloud → On-Prem 마이그레이션 시작"); + // 헬퍼 함수 생성 const helperCreated = await createHelperFunctions(); if (!helperCreated) { - console.warn('헬퍼 함수 생성에 실패했습니다. 계속 진행합니다.'); + logger.warn("헬퍼 함수 생성에 실패했습니다. 계속 진행합니다."); } - + // 각 테이블에 대해 스키마 생성 및 데이터 마이그레이션 수행 for (const tableName of tables) { // 테이블 생성 const tableCreated = await createTable(tableName); if (!tableCreated) { - console.warn(`${tableName} 테이블 생성에 실패했습니다. 데이터 마이그레이션을 건너뜁니다.`); + logger.warn( + `${tableName} 테이블 생성에 실패했습니다. 데이터 마이그레이션을 건너뜁니다.` + ); continue; } - + // 데이터 마이그레이션 const dataMigrated = await migrateTableData(tableName); if (!dataMigrated) { - console.warn(`${tableName} 테이블 데이터 마이그레이션에 실패했습니다.`); + logger.warn(`${tableName} 테이블 데이터 마이그레이션에 실패했습니다.`); } } - - console.log('Supabase Cloud → On-Prem 마이그레이션 완료'); + + logger.info("Supabase Cloud → On-Prem 마이그레이션 완료"); } catch (error) { - console.error('마이그레이션 중 오류 발생:', error); + logger.error("마이그레이션 중 오류 발생:", error); process.exit(1); } } diff --git a/src/lib/migrateData.ts b/src/lib/migrateData.ts index c92e174..16e7ac5 100644 --- a/src/lib/migrateData.ts +++ b/src/lib/migrateData.ts @@ -1,17 +1,24 @@ -import dotenv from 'dotenv'; -import { createClient } from '@supabase/supabase-js'; +import dotenv from "dotenv"; +import { logger } from "@/utils/logger"; +import { createClient } from "@supabase/supabase-js"; dotenv.config(); const cloudUrl = process.env.CLOUD_SUPABASE_URL; // 서비스 역할 키가 유효하지 않으면 CLOUD_SUPABASE_ANON_KEY 또는 VITE_SUPABASE_ANON_KEY를 사용합니다. -const cloudKey = process.env.CLOUD_SUPABASE_SERVICE_ROLE_KEY || process.env.CLOUD_SUPABASE_ANON_KEY || process.env.VITE_SUPABASE_ANON_KEY; +const cloudKey = + process.env.CLOUD_SUPABASE_SERVICE_ROLE_KEY || + process.env.CLOUD_SUPABASE_ANON_KEY || + process.env.VITE_SUPABASE_ANON_KEY; const onpremUrl = process.env.ONPREM_SUPABASE_URL; // 서비스 역할 키가 유효하지 않으면 ONPREM_SUPABASE_ANON_KEY 또는 VITE_SUPABASE_ANON_KEY를 사용합니다. -const onpremKey = process.env.ONPREM_SUPABASE_SERVICE_ROLE_KEY || process.env.ONPREM_SUPABASE_ANON_KEY || process.env.VITE_SUPABASE_ANON_KEY; +const onpremKey = + process.env.ONPREM_SUPABASE_SERVICE_ROLE_KEY || + process.env.ONPREM_SUPABASE_ANON_KEY || + process.env.VITE_SUPABASE_ANON_KEY; if (!cloudUrl || !cloudKey || !onpremUrl || !onpremKey) { - console.error('환경 변수가 설정되지 않았습니다. .env 파일을 확인하세요.'); + logger.error("환경 변수가 설정되지 않았습니다. .env 파일을 확인하세요."); process.exit(1); } @@ -20,33 +27,33 @@ const onprem = createClient(onpremUrl, onpremKey); // 복사할 테이블 목록을 정의하세요. const tables = [ - 'users', - 'accounts', - 'transactions', + "users", + "accounts", + "transactions", // 필요에 따라 추가 테이블을 여기에 입력 ]; async function migrateTable(table: string) { - console.log(`Migrating table: ${table}`); - const { data, error } = await cloud.from(table).select('*'); + logger.info(`Migrating table: ${table}`); + const { data, error } = await cloud.from(table).select("*"); if (error) { // 테이블이 없으면 스킵 - if (error.code === '42P01') { - console.warn(`Table ${table} not found in Cloud DB, skipping.`); + if (error.code === "42P01") { + logger.warn(`Table ${table} not found in Cloud DB, skipping.`); return; } - console.error(`Error fetching ${table}:`, error); + logger.error(`Error fetching ${table}:`, error); return; } if (!data || data.length === 0) { - console.log(`${table} has no data to migrate.`); + logger.info(`${table} has no data to migrate.`); return; } const { error: insertError } = await onprem.from(table).upsert(data); if (insertError) { - console.error(`Error inserting into ${table}:`, insertError); + logger.error(`Error inserting into ${table}:`, insertError); } else { - console.log(`Migrated ${data.length} rows into ${table}`); + logger.info(`Migrated ${data.length} rows into ${table}`); } } @@ -54,10 +61,10 @@ async function main() { for (const table of tables) { await migrateTable(table); } - console.log('Migration complete.'); + logger.info("Migration complete."); } -main().catch(err => { - console.error('Migration failed:', err); +main().catch((err) => { + logger.error("Migration failed:", err); process.exit(1); }); diff --git a/src/lib/utils.ts b/src/lib/utils.ts index bd0c391..a5ef193 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,6 +1,6 @@ -import { clsx, type ClassValue } from "clsx" -import { twMerge } from "tailwind-merge" +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) + return twMerge(clsx(inputs)); } diff --git a/src/main.tsx b/src/main.tsx index 1aa42de..ae8959d 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,35 +1,39 @@ -import { createRoot } from 'react-dom/client'; -import { BrowserRouter } from 'react-router-dom'; -import App from './App.tsx'; -import './index.css'; +import { createRoot } from "react-dom/client"; +import { logger } from "@/utils/logger"; +import { BrowserRouter } from "react-router-dom"; +import App from "./App.tsx"; +import "./index.css"; -console.log('main.tsx loaded'); +logger.info("main.tsx loaded"); // iOS 안전 영역 메타 태그 추가 const setViewportMetaTag = () => { // 기존 viewport 메타 태그 찾기 let metaTag = document.querySelector('meta[name="viewport"]'); - + // 없으면 새로 생성 if (!metaTag) { - metaTag = document.createElement('meta'); - metaTag.setAttribute('name', 'viewport'); + metaTag = document.createElement("meta"); + metaTag.setAttribute("name", "viewport"); document.head.appendChild(metaTag); } - + // content 속성 설정 (viewport-fit=cover 추가) - metaTag.setAttribute('content', 'width=device-width, initial-scale=1.0, viewport-fit=cover'); + metaTag.setAttribute( + "content", + "width=device-width, initial-scale=1.0, viewport-fit=cover" + ); }; // 메타 태그 설정 적용 setViewportMetaTag(); // 전역 오류 핸들러 추가 -window.onerror = function(message, source, lineno, colno, error) { - console.error('전역 오류 발생:', { message, source, lineno, colno, error }); - +window.onerror = function (message, source, lineno, colno, error) { + logger.error("전역 오류 발생:", { message, source, lineno, colno, error }); + // 오류 발생 시 기본 오류 화면 표시 - const rootElement = document.getElementById('root'); + const rootElement = document.getElementById("root"); if (rootElement) { rootElement.innerHTML = `
@@ -46,16 +50,16 @@ window.onerror = function(message, source, lineno, colno, error) {
`; } - + return false; }; // 처리되지 않은 Promise 오류 핸들러 추가 -window.addEventListener('unhandledrejection', function(event) { - console.error('처리되지 않은 Promise 오류:', event.reason); - +window.addEventListener("unhandledrejection", function (event) { + logger.error("처리되지 않은 Promise 오류:", event.reason); + // 오류 발생 시 기본 오류 화면 표시 - const rootElement = document.getElementById('root'); + const rootElement = document.getElementById("root"); if (rootElement) { rootElement.innerHTML = `
@@ -75,7 +79,7 @@ window.addEventListener('unhandledrejection', function(event) { }); // 디버깅 정보 출력 -console.log('환경 변수:', { +logger.info("환경 변수:", { NODE_ENV: import.meta.env.MODE, BASE_URL: import.meta.env.BASE_URL, APPWRITE_ENDPOINT: import.meta.env.VITE_APPWRITE_ENDPOINT, @@ -94,25 +98,25 @@ declare global { window.appwriteEnabled = false; try { - const rootElement = document.getElementById('root'); + const rootElement = document.getElementById("root"); if (!rootElement) { - throw new Error('Root element not found'); + throw new Error("Root element not found"); } - + const root = createRoot(rootElement); - + root.render( ); - - console.log('애플리케이션 렌더링 성공'); + + logger.info("애플리케이션 렌더링 성공"); } catch (error) { - console.error('애플리케이션 렌더링 오류:', error); - + logger.error("애플리케이션 렌더링 오류:", error); + // 오류 발생 시 기본 오류 화면 표시 - const rootElement = document.getElementById('root'); + const rootElement = document.getElementById("root"); if (rootElement) { rootElement.innerHTML = `
@@ -128,4 +132,4 @@ try {
`; } -}; +} diff --git a/src/next-steps-plan.md b/src/next-steps-plan.md index 377bc05..a4048d6 100644 --- a/src/next-steps-plan.md +++ b/src/next-steps-plan.md @@ -3,16 +3,19 @@ ## 1. 앱 배포 준비 완료 ### 1.1 앱 배포 가이드 문서 완성 + - 현재 작성 중인 app-deployment-guide.md 문서 완성 - 실제 배포 경험을 바탕으로 가이드 업데이트 - 배포 중 발생할 수 있는 문제 해결 방법 추가 ### 1.2 Android 앱 빌드 및 테스트 + - 안드로이드 빌드 환경 최종 점검 - 다양한 안드로이드 기기에서 호환성 테스트 - 성능 및 안정성 테스트 ### 1.3 iOS 환경 설정 및 빌드 (필요시) + - Mac 환경에서 iOS 빌드 설정 - iOS 앱 아이콘 및 스플래시 스크린 준비 - TestFlight를 통한 베타 테스트 @@ -20,33 +23,39 @@ ## 2. 앱 최적화 ### 2.1 성능 최적화 + - 앱 로딩 시간 개선 - 메모리 사용량 최적화 - 배터리 소모 최적화 - 알림 시스템 안정화 (현재 발생 중인 버그 수정) ### 2.2 사용자 경험 개선 + - UI/UX 일관성 확보 - 애니메이션 및 전환 효과 최적화 - 접근성 개선 ### 2.3 오프라인 기능 강화 + - 오프라인 상태에서의 데이터 처리 개선 - 동기화 메커니즘 강화 ## 3. 추가 기능 개발 ### 3.1 알림 시스템 구현 + - 푸시 알림 기능 구현 - 알림 설정 페이지 개선 - 알림 스케줄링 기능 ### 3.2 데이터 백업 및 복원 기능 + - 사용자 데이터 백업 기능 - 데이터 복원 메커니즘 - 클라우드 백업 옵션 ### 3.3 다국어 지원 확장 + - 다국어 지원 시스템 구현 - 언어 설정 페이지 추가 - 번역 리소스 준비 @@ -54,16 +63,19 @@ ## 4. 테스트 및 품질 보증 ### 4.1 다양한 기기에서의 호환성 테스트 + - 다양한 안드로이드 버전 테스트 - 다양한 화면 크기 및 해상도 테스트 - 저사양 기기에서의 성능 테스트 ### 4.2 사용자 피드백 수집 및 반영 + - 인앱 피드백 시스템 구현 - 사용자 테스트 세션 진행 - 피드백 기반 개선사항 우선순위 설정 ### 4.3 버그 수정 및 안정성 개선 + - 알려진 버그 수정 - 크래시 리포트 분석 및 대응 - 자동화된 테스트 추가 @@ -71,16 +83,19 @@ ## 5. 스토어 등록 준비 ### 5.1 Google Play 스토어 등록 자료 준비 + - 스토어 리스팅 정보 작성 - 스크린샷 및 프로모션 이미지 준비 - 개인정보 처리방침 문서 작성 ### 5.2 App Store 등록 자료 준비 (필요시) + - App Store Connect 설정 - 앱 심사 준비 - 마케팅 자료 준비 ### 5.3 출시 후 모니터링 계획 + - 앱 성능 모니터링 시스템 구축 - 사용자 피드백 수집 채널 설정 - 정기 업데이트 일정 수립 @@ -88,6 +103,7 @@ ## 즉시 진행 가능한 작업 1. **알림 시스템 버그 수정** + ```bash # 수정된 코드 테스트 npm run build diff --git a/src/pages/Analytics.tsx b/src/pages/Analytics.tsx index 00bdc01..6514e07 100644 --- a/src/pages/Analytics.tsx +++ b/src/pages/Analytics.tsx @@ -1,80 +1,87 @@ - -import React, { useState, useEffect } from 'react'; -import NavBar from '@/components/NavBar'; -import ExpenseChart from '@/components/ExpenseChart'; -import AddTransactionButton from '@/components/AddTransactionButton'; -import { useBudget } from '@/contexts/budget/BudgetContext'; -import { MONTHS_KR } from '@/hooks/useTransactions'; -import { useIsMobile } from '@/hooks/use-mobile'; -import { getCategoryColor } from '@/utils/categoryColorUtils'; -import { Separator } from '@/components/ui/separator'; +import React, { useState, useEffect } from "react"; +import { logger } from "@/utils/logger"; +import NavBar from "@/components/NavBar"; +import ExpenseChart from "@/components/ExpenseChart"; +import AddTransactionButton from "@/components/AddTransactionButton"; +import { useBudget } from "@/contexts/budget/BudgetContext"; +import { MONTHS_KR } from "@/hooks/useTransactions"; +import { useIsMobile } from "@/hooks/use-mobile"; +import { getCategoryColor } from "@/utils/categoryColorUtils"; +import { Separator } from "@/components/ui/separator"; +import { MonthlyData } from "@/types"; // 새로 분리한 컴포넌트들 불러오기 -import PeriodSelector from '@/components/analytics/PeriodSelector'; -import SummaryCards from '@/components/analytics/SummaryCards'; -import MonthlyComparisonChart from '@/components/analytics/MonthlyComparisonChart'; -import CategorySpendingList from '@/components/analytics/CategorySpendingList'; -import PaymentMethodChart from '@/components/analytics/PaymentMethodChart'; +import PeriodSelector from "@/components/analytics/PeriodSelector"; +import SummaryCards from "@/components/analytics/SummaryCards"; +import MonthlyComparisonChart from "@/components/analytics/MonthlyComparisonChart"; +import CategorySpendingList from "@/components/analytics/CategorySpendingList"; +import PaymentMethodChart from "@/components/analytics/PaymentMethodChart"; const Analytics = () => { - const [selectedPeriod, setSelectedPeriod] = useState('이번 달'); + const [selectedPeriod, setSelectedPeriod] = useState("이번 달"); const { budgetData, getCategorySpending, getPaymentMethodStats, // 새로 추가된 메서드 - transactions + transactions, } = useBudget(); const isMobile = useIsMobile(); const [refreshTrigger, setRefreshTrigger] = useState(0); - const [monthlyData, setMonthlyData] = useState([]); + const [monthlyData, setMonthlyData] = useState([]); // 페이지 가시성 변경시 데이터 새로고침 useEffect(() => { const handleVisibilityChange = () => { - if (document.visibilityState === 'visible') { - console.log('분석 페이지 보임 - 데이터 새로고침'); - setRefreshTrigger(prev => prev + 1); + if (document.visibilityState === "visible") { + logger.info("분석 페이지 보임 - 데이터 새로고침"); + setRefreshTrigger((prev) => prev + 1); // 이벤트 발생시켜 데이터 새로고침 try { - window.dispatchEvent(new Event('storage')); - window.dispatchEvent(new Event('transactionUpdated')); - window.dispatchEvent(new Event('budgetDataUpdated')); - window.dispatchEvent(new Event('categoryBudgetsUpdated')); + window.dispatchEvent(new Event("storage")); + window.dispatchEvent(new Event("transactionUpdated")); + window.dispatchEvent(new Event("budgetDataUpdated")); + window.dispatchEvent(new Event("categoryBudgetsUpdated")); } catch (e) { - console.error('이벤트 발생 오류:', e); + logger.error("이벤트 발생 오류:", e); } } }; const handleFocus = () => { - console.log('분석 페이지 포커스 - 데이터 새로고침'); - setRefreshTrigger(prev => prev + 1); + logger.info("분석 페이지 포커스 - 데이터 새로고침"); + setRefreshTrigger((prev) => prev + 1); // 이벤트 발생시켜 데이터 새로고침 try { - window.dispatchEvent(new Event('storage')); - window.dispatchEvent(new Event('transactionUpdated')); - window.dispatchEvent(new Event('budgetDataUpdated')); - window.dispatchEvent(new Event('categoryBudgetsUpdated')); + window.dispatchEvent(new Event("storage")); + window.dispatchEvent(new Event("transactionUpdated")); + window.dispatchEvent(new Event("budgetDataUpdated")); + window.dispatchEvent(new Event("categoryBudgetsUpdated")); } catch (e) { - console.error('이벤트 발생 오류:', e); + logger.error("이벤트 발생 오류:", e); } }; - document.addEventListener('visibilitychange', handleVisibilityChange); - window.addEventListener('focus', handleFocus); - window.addEventListener('transactionUpdated', () => setRefreshTrigger(prev => prev + 1)); - window.addEventListener('budgetDataUpdated', () => setRefreshTrigger(prev => prev + 1)); - window.addEventListener('categoryBudgetsUpdated', () => setRefreshTrigger(prev => prev + 1)); + document.addEventListener("visibilitychange", handleVisibilityChange); + window.addEventListener("focus", handleFocus); + window.addEventListener("transactionUpdated", () => + setRefreshTrigger((prev) => prev + 1) + ); + window.addEventListener("budgetDataUpdated", () => + setRefreshTrigger((prev) => prev + 1) + ); + window.addEventListener("categoryBudgetsUpdated", () => + setRefreshTrigger((prev) => prev + 1) + ); // 컴포넌트 마운트 시 초기 데이터 로드 이벤트 트리거 handleFocus(); return () => { - document.removeEventListener('visibilitychange', handleVisibilityChange); - window.removeEventListener('focus', handleFocus); - window.removeEventListener('transactionUpdated', () => {}); - window.removeEventListener('budgetDataUpdated', () => {}); - window.removeEventListener('categoryBudgetsUpdated', () => {}); + document.removeEventListener("visibilitychange", handleVisibilityChange); + window.removeEventListener("focus", handleFocus); + window.removeEventListener("transactionUpdated", () => {}); + window.removeEventListener("budgetDataUpdated", () => {}); + window.removeEventListener("categoryBudgetsUpdated", () => {}); }; }, []); @@ -82,25 +89,26 @@ const Analytics = () => { const totalBudget = budgetData?.monthly?.targetAmount || 0; const totalExpense = budgetData?.monthly?.spentAmount || 0; const savings = Math.max(0, totalBudget - totalExpense); - const savingsPercentage = totalBudget > 0 ? Math.round(savings / totalBudget * 100) : 0; + const savingsPercentage = + totalBudget > 0 ? Math.round((savings / totalBudget) * 100) : 0; // 카테고리별 지출 차트 데이터 생성 - 색상 유틸리티 사용 const categorySpending = getCategorySpending(); - const expenseData = categorySpending.map(category => ({ + const expenseData = categorySpending.map((category) => ({ name: category.title, value: category.current, - color: getCategoryColor(category.title) // 일관된 색상 적용 + color: getCategoryColor(category.title), // 일관된 색상 적용 })); // 결제 방법 데이터 가져오기 const paymentMethodData = getPaymentMethodStats(); - const hasPaymentData = paymentMethodData.some(method => method.amount > 0); + const hasPaymentData = paymentMethodData.some((method) => method.amount > 0); // 월별 데이터 생성 - 샘플 데이터 제거하고 현재 달만 실제 데이터 사용 useEffect(() => { - console.log('Analytics 페이지: 월별 데이터 생성', { + logger.info("Analytics 페이지: 월별 데이터 생성", { totalBudget, - totalExpense + totalExpense, }); // 현재 월 가져오기 @@ -108,56 +116,70 @@ const Analytics = () => { const currentMonth = today.getMonth(); // 현재 달만 실제 데이터 사용하는 배열 생성 - const monthlyDataArray = [{ - name: MONTHS_KR[currentMonth].split(' ')[0], - // '8월' 형식으로 변환 - budget: totalBudget, - expense: totalExpense - }]; + const monthlyDataArray = [ + { + name: MONTHS_KR[currentMonth].split(" ")[0], + // '8월' 형식으로 변환 + budget: totalBudget, + expense: totalExpense, + }, + ]; setMonthlyData(monthlyDataArray); - console.log('Analytics 페이지: 월별 데이터 생성 완료', monthlyDataArray); + logger.info("Analytics 페이지: 월별 데이터 생성 완료", monthlyDataArray); }, [totalBudget, totalExpense, refreshTrigger]); // 이전/다음 기간 이동 처리 const handlePrevPeriod = () => { - console.log('이전 기간으로 이동'); + logger.info("이전 기간으로 이동"); }; const handleNextPeriod = () => { - console.log('다음 기간으로 이동'); + logger.info("다음 기간으로 이동"); }; - - return
+ + return ( +
{/* Header */}

지출 분석

- + {/* Period Selector */} - - + + {/* Summary Cards */} - +
- + {/* Monthly Comparison Chart */}

월별 그래프

- +
- + {/* 카테고리 비율과 지출을 하나의 카드로 합침 */}

카테고리 비율

- {expenseData.some(item => item.value > 0) ? ( + {expenseData.some((item) => item.value > 0) ? ( <>
{/* 원그래프 아래에 카테고리 지출 목록 추가 */} - @@ -168,18 +190,22 @@ const Analytics = () => { )}
- + {/* 결제 방법 차트 추가 */}

결제 방법 비율

- - + + {/* 결제 방법 차트 아래 80px 여유 공간 추가 */}
-
; +
+ ); }; export default Analytics; diff --git a/src/pages/AppwriteSettingsPage.tsx b/src/pages/AppwriteSettingsPage.tsx index e6a69de..1404dab 100644 --- a/src/pages/AppwriteSettingsPage.tsx +++ b/src/pages/AppwriteSettingsPage.tsx @@ -1,14 +1,21 @@ -import React, { useState } from 'react'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Separator } from '@/components/ui/separator'; -import AppwriteConnectionTest from '@/components/auth/AppwriteConnectionTest'; -import SupabaseToAppwriteMigration from '@/components/migration/SupabaseToAppwriteMigration'; -import { useAppwriteAuth } from '@/hooks/auth/useAppwriteAuth'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { toast } from '@/hooks/useToast.wrapper'; +import React, { useState } from "react"; +import { appwriteLogger } from "@/utils/logger"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import AppwriteConnectionTest from "@/components/auth/AppwriteConnectionTest"; +// import SupabaseToAppwriteMigration from '@/components/migration/SupabaseToAppwriteMigration'; +import { useAppwriteAuth } from "@/hooks/auth/useAppwriteAuth"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { toast } from "@/hooks/useToast.wrapper"; /** * Appwrite 설정 페이지 @@ -19,76 +26,82 @@ import { toast } from '@/hooks/useToast.wrapper'; const AppwriteSettingsPage: React.FC = () => { // 인증 상태 const { user, login, signup, logout, loading, error } = useAppwriteAuth(); - + // 로그인 폼 상태 const [loginForm, setLoginForm] = useState({ - email: '', - password: '' + email: "", + password: "", }); - + // 회원가입 폼 상태 const [signupForm, setSignupForm] = useState({ - email: '', - password: '', - name: '' + email: "", + password: "", + name: "", }); - + // 로그인 처리 const handleLogin = async (e: React.FormEvent) => { e.preventDefault(); - + try { await login(loginForm); toast({ - title: '로그인 성공', - description: '성공적으로 로그인되었습니다.', - variant: 'default' + title: "로그인 성공", + description: "성공적으로 로그인되었습니다.", + variant: "default", }); } catch (error) { - console.error('로그인 오류:', error); + appwriteLogger.error("로그인 오류:", error); toast({ - title: '로그인 실패', - description: error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.', - variant: 'destructive' + title: "로그인 실패", + description: + error instanceof Error + ? error.message + : "알 수 없는 오류가 발생했습니다.", + variant: "destructive", }); } }; - + // 회원가입 처리 const handleSignup = async (e: React.FormEvent) => { e.preventDefault(); - + try { await signup(signupForm); toast({ - title: '회원가입 성공', - description: '성공적으로 가입되었습니다.', - variant: 'default' + title: "회원가입 성공", + description: "성공적으로 가입되었습니다.", + variant: "default", }); } catch (error) { - console.error('회원가입 오류:', error); + appwriteLogger.error("회원가입 오류:", error); toast({ - title: '회원가입 실패', - description: error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.', - variant: 'destructive' + title: "회원가입 실패", + description: + error instanceof Error + ? error.message + : "알 수 없는 오류가 발생했습니다.", + variant: "destructive", }); } }; - + // 로그아웃 처리 const handleLogout = async () => { try { await logout(); toast({ - title: '로그아웃', - description: '성공적으로 로그아웃되었습니다.', - variant: 'default' + title: "로그아웃", + description: "성공적으로 로그아웃되었습니다.", + variant: "default", }); } catch (error) { - console.error('로그아웃 오류:', error); + appwriteLogger.error("로그아웃 오류:", error); } }; - + return (
@@ -97,9 +110,9 @@ const AppwriteSettingsPage: React.FC = () => { Appwrite 서버 연결 설정 및 데이터 마이그레이션

- + - + {/* 서버 연결 상태 */} @@ -112,7 +125,7 @@ const AppwriteSettingsPage: React.FC = () => { - + {/* 인증 관리 */} @@ -129,16 +142,10 @@ const AppwriteSettingsPage: React.FC = () => {

사용자 ID: {user.$id}

-

- 이메일: {user.email} -

- {user.name && ( -

- 이름: {user.name} -

- )} +

이메일: {user.email}

+ {user.name &&

이름: {user.name}

}
- +
- +
@@ -193,11 +204,13 @@ const AppwriteSettingsPage: React.FC = () => { type="email" placeholder="이메일 주소" value={signupForm.email} - onChange={(e) => setSignupForm({ ...signupForm, email: e.target.value })} + onChange={(e) => + setSignupForm({ ...signupForm, email: e.target.value }) + } required />
- +
{ type="password" placeholder="비밀번호" value={signupForm.password} - onChange={(e) => setSignupForm({ ...signupForm, password: e.target.value })} + onChange={(e) => + setSignupForm({ + ...signupForm, + password: e.target.value, + }) + } required />
- +
{ type="text" placeholder="이름" value={signupForm.name} - onChange={(e) => setSignupForm({ ...signupForm, name: e.target.value })} + onChange={(e) => + setSignupForm({ ...signupForm, name: e.target.value }) + } />
- + @@ -228,7 +248,7 @@ const AppwriteSettingsPage: React.FC = () => { )} - + {error && (
{error.message} @@ -236,7 +256,7 @@ const AppwriteSettingsPage: React.FC = () => { )} - + {/* 데이터 마이그레이션 */} @@ -246,7 +266,10 @@ const AppwriteSettingsPage: React.FC = () => { - + {/* */} +

+ 마이그레이션 컴포넌트를 사용할 수 없습니다. +

diff --git a/src/pages/ForgotPassword.tsx b/src/pages/ForgotPassword.tsx index 3e6c9d7..fc98d92 100644 --- a/src/pages/ForgotPassword.tsx +++ b/src/pages/ForgotPassword.tsx @@ -14,16 +14,16 @@ const ForgotPassword = () => { const handleResetPassword = async (e: React.FormEvent) => { e.preventDefault(); - + if (!email) { return; } - + setIsLoading(true); - + try { const { error } = await resetPassword(email); - + if (!error) { setIsSent(true); } @@ -36,53 +36,60 @@ const ForgotPassword = () => {
-

비밀번호 재설정

+

+ 비밀번호 재설정 +

- {isSent - ? "이메일을 확인하여 비밀번호를 재설정하세요" + {isSent + ? "이메일을 확인하여 비밀번호를 재설정하세요" : "가입한 이메일 주소를 입력하세요"}

- +
{!isSent ? (
- +
- setEmail(e.target.value)} - className="pl-10 neuro-pressed" + setEmail(e.target.value)} + className="pl-10 neuro-pressed" />
- -
) : (

- 비밀번호 재설정 링크가 {email}로 전송되었습니다. + 비밀번호 재설정 링크가 {email}로 + 전송되었습니다.

- 이메일을 확인하여 링크를 클릭하세요. 이메일이 보이지 않는다면 스팸함도 확인해주세요. + 이메일을 확인하여 링크를 클릭하세요. 이메일이 보이지 않는다면 + 스팸함도 확인해주세요.

-
)}
- +
- + 로그인으로 돌아가기
diff --git a/src/pages/HelpSupport.tsx b/src/pages/HelpSupport.tsx index 40319a0..667f75b 100644 --- a/src/pages/HelpSupport.tsx +++ b/src/pages/HelpSupport.tsx @@ -1,76 +1,96 @@ - -import React, { useState } from 'react'; -import { ArrowLeft, HelpCircle, Book, ExternalLink, ShieldQuestion } from 'lucide-react'; -import { useNavigate } from 'react-router-dom'; -import { Button } from '@/components/ui/button'; -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'; -import { Card } from '@/components/ui/card'; -import { toast } from 'sonner'; -import WelcomeDialog from '@/components/onboarding/WelcomeDialog'; +import React, { useState } from "react"; +import { + ArrowLeft, + HelpCircle, + Book, + ExternalLink, + ShieldQuestion, +} from "lucide-react"; +import { useNavigate } from "react-router-dom"; +import { Button } from "@/components/ui/button"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Card } from "@/components/ui/card"; +import { toast } from "sonner"; +import WelcomeDialog from "@/components/onboarding/WelcomeDialog"; const HelpSupport = () => { const navigate = useNavigate(); - const [messageText, setMessageText] = useState(''); + const [messageText, setMessageText] = useState(""); const [showWelcomeDialog, setShowWelcomeDialog] = useState(false); - + const faqItems = [ { - question: '앱을 어떻게 사용하나요?', - answer: '앱은 간단합니다. 홈 화면에서 수입과 지출을 추가할 수 있으며, 거래 내역 화면에서 모든 거래를 확인할 수 있습니다. 분석 화면에서는 지출 패턴을 확인하세요.' - }, - { - question: '예산을 어떻게 설정하나요?', - answer: '홈 화면에서 예산 카드를 찾아 카테고리별 예산을 설정할 수 있습니다. 각 카테고리에 원하는 금액을 입력하여 월별 예산을 관리하세요.' + question: "앱을 어떻게 사용하나요?", + answer: + "앱은 간단합니다. 홈 화면에서 수입과 지출을 추가할 수 있으며, 거래 내역 화면에서 모든 거래를 확인할 수 있습니다. 분석 화면에서는 지출 패턴을 확인하세요.", }, { - question: '지출을 어떻게 추가하나요?', - answer: '홈 화면 하단의 "+" 버튼을 눌러 새 거래를 추가할 수 있습니다. 금액, 카테고리, 날짜를 입력하고 저장하세요.' - }, + question: "예산을 어떻게 설정하나요?", + answer: + "홈 화면에서 예산 카드를 찾아 카테고리별 예산을 설정할 수 있습니다. 각 카테고리에 원하는 금액을 입력하여 월별 예산을 관리하세요.", + }, { - question: '계정 정보를 어떻게 변경하나요?', - answer: '설정 > 프로필 관리 메뉴에서 이름, 이메일, 전화번호 등의 개인 정보를 변경할 수 있습니다.' - }, + question: "지출을 어떻게 추가하나요?", + answer: + '홈 화면 하단의 "+" 버튼을 눌러 새 거래를 추가할 수 있습니다. 금액, 카테고리, 날짜를 입력하고 저장하세요.', + }, { - question: '알림 설정은 어디서 변경하나요?', - answer: '설정 > 알림 설정 메뉴에서 원하는 알림 유형을 켜거나 끌 수 있습니다.' - } + question: "계정 정보를 어떻게 변경하나요?", + answer: + "설정 > 프로필 관리 메뉴에서 이름, 이메일, 전화번호 등의 개인 정보를 변경할 수 있습니다.", + }, + { + question: "알림 설정은 어디서 변경하나요?", + answer: + "설정 > 알림 설정 메뉴에서 원하는 알림 유형을 켜거나 끌 수 있습니다.", + }, ]; - + const sendMessage = () => { if (!messageText.trim()) { - toast.error('메시지를 입력해주세요.'); + toast.error("메시지를 입력해주세요."); return; } - - toast.success('문의가 접수되었습니다. 빠른 시일 내에 답변 드리겠습니다.'); - setMessageText(''); + + toast.success("문의가 접수되었습니다. 빠른 시일 내에 답변 드리겠습니다."); + setMessageText(""); }; - + const handleShowWelcomeDialog = () => { setShowWelcomeDialog(true); }; - + const handleCloseWelcomeDialog = (dontShowAgain: boolean) => { setShowWelcomeDialog(false); - + if (dontShowAgain) { - toast.info('환영 화면이 더 이상 표시되지 않도록 설정되었습니다.'); + toast.info("환영 화면이 더 이상 표시되지 않도록 설정되었습니다."); } }; - + return (
{/* Header */}
-

도움말 및 지원

- + {/* Help Categories */}
@@ -79,8 +99,8 @@ const HelpSupport = () => {

자주 묻는 질문

- - @@ -90,14 +110,16 @@ const HelpSupport = () => {

초기 화면 보기

- + {/* FAQ Section */}

자주 묻는 질문

{faqItems.map((item, index) => ( - {item.question} + + {item.question} + {item.answer} @@ -105,41 +127,50 @@ const HelpSupport = () => { ))}
- + {/* Contact Support */}

건의사항 및 문의하기

-