feat: Add CI/CD pipeline and code quality improvements
- Add GitHub Actions workflow for automated CI/CD - Configure Node.js 18.x and 20.x matrix testing - Add TypeScript type checking step - Add ESLint code quality checks with enhanced rules - Add Prettier formatting verification - Add production build validation - Upload build artifacts for deployment - Set up automated testing on push/PR - Replace console.log with environment-aware logger - Add pre-commit hooks for code quality - Exclude archive folder from linting 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
129
src/App.tsx
129
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<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
}
|
||||
|
||||
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 || (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen p-4 text-center">
|
||||
<h2 className="text-xl font-bold mb-4">앱 로딩 중 오류가 발생했습니다</h2>
|
||||
<p className="mb-4">잠시 후 다시 시도해주세요.</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||
>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
return (
|
||||
this.props.fallback || (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen p-4 text-center">
|
||||
<h2 className="text-xl font-bold mb-4">
|
||||
앱 로딩 중 오류가 발생했습니다
|
||||
</h2>
|
||||
<p className="mb-4">잠시 후 다시 시도해주세요.</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||
>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}) => (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen p-4 text-center bg-neuro-background">
|
||||
<div className="text-red-500 text-5xl mb-4">⚠️</div>
|
||||
<h2 className="text-xl font-bold mb-4">애플리케이션 오류</h2>
|
||||
<p className="text-center mb-6">{error?.message || '애플리케이션 로딩 중 오류가 발생했습니다.'}</p>
|
||||
<button
|
||||
onClick={retry}
|
||||
<p className="text-center mb-6">
|
||||
{error?.message || "애플리케이션 로딩 중 오류가 발생했습니다."}
|
||||
</p>
|
||||
<button
|
||||
onClick={retry}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||
>
|
||||
재시도
|
||||
@@ -97,46 +113,51 @@ const BasicLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
);
|
||||
|
||||
function App() {
|
||||
const [appState, setAppState] = useState<'loading' | 'error' | 'ready'>('loading');
|
||||
const [appState, setAppState] = useState<"loading" | "error" | "ready">(
|
||||
"loading"
|
||||
);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [appwriteEnabled, setAppwriteEnabled] = useState(true);
|
||||
// Appwrite 설정 상태는 향후 사용 예정
|
||||
// const [appwriteEnabled, setAppwriteEnabled] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = "Zellyy Finance";
|
||||
|
||||
|
||||
// 애플리케이션 초기화 시간 지연 설정
|
||||
const timer = setTimeout(() => {
|
||||
setAppState('ready');
|
||||
setAppState("ready");
|
||||
}, 1500); // 1.5초 후 로딩 상태 해제
|
||||
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
|
||||
// 재시도 기능
|
||||
const handleRetry = () => {
|
||||
setAppState('loading');
|
||||
setAppState("loading");
|
||||
setError(null);
|
||||
|
||||
|
||||
// 재시도 시 지연 후 상태 변경
|
||||
setTimeout(() => {
|
||||
setAppState('ready');
|
||||
setAppState("ready");
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
// 로딩 상태 표시
|
||||
if (appState === 'loading') {
|
||||
if (appState === "loading") {
|
||||
return (
|
||||
<ErrorBoundary fallback={<ErrorScreen error={error} retry={handleRetry} />}>
|
||||
<ErrorBoundary
|
||||
fallback={<ErrorScreen error={error} retry={handleRetry} />}
|
||||
>
|
||||
<LoadingScreen />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// 오류 상태 표시
|
||||
if (appState === 'error') {
|
||||
if (appState === "error") {
|
||||
return <ErrorScreen error={error} retry={handleRetry} />;
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<ErrorBoundary fallback={<ErrorScreen error={error} retry={handleRetry} />}>
|
||||
<AuthProvider>
|
||||
@@ -152,10 +173,16 @@ function App() {
|
||||
<Route path="/profile" element={<ProfileManagement />} />
|
||||
<Route path="/payment-methods" element={<PaymentMethods />} />
|
||||
<Route path="/help-support" element={<HelpSupport />} />
|
||||
<Route path="/security-privacy" element={<SecurityPrivacySettings />} />
|
||||
<Route
|
||||
path="/security-privacy"
|
||||
element={<SecurityPrivacySettings />}
|
||||
/>
|
||||
<Route path="/notifications" element={<NotificationSettings />} />
|
||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||
<Route path="/appwrite-settings" element={<AppwriteSettingsPage />} />
|
||||
<Route
|
||||
path="/appwrite-settings"
|
||||
element={<AppwriteSettingsPage />}
|
||||
/>
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</BasicLayout>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
# 안드로이드 앱 아이콘 설정 가이드
|
||||
|
||||
안드로이드 앱 아이콘을 설정하려면 다음 단계를 따르세요:
|
||||
@@ -10,8 +9,8 @@
|
||||
- `mipmap-xhdpi/ic_launcher.png`: 96x96 px
|
||||
- `mipmap-xxhdpi/ic_launcher.png`: 144x144 px
|
||||
- `mipmap-xxxhdpi/ic_launcher.png`: 192x192 px
|
||||
|
||||
3. 적응형 아이콘의 경우 `mipmap-anydpi-v26/ic_launcher.xml` 파일을 편집합니다:
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
@@ -21,6 +20,7 @@
|
||||
```
|
||||
|
||||
4. 아이콘 배경색을 `android/app/src/main/res/values/colors.xml`에 추가합니다:
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
@@ -29,6 +29,7 @@
|
||||
```
|
||||
|
||||
5. `strings.xml` 파일에서 앱 이름이 올바르게 설정되어 있는지 확인합니다:
|
||||
|
||||
```xml
|
||||
<resources>
|
||||
<string name="app_name">젤리의 적자탈출</string>
|
||||
@@ -36,5 +37,6 @@
|
||||
```
|
||||
|
||||
이미지 변환 도구:
|
||||
|
||||
- Android Asset Studio: https://romannurik.github.io/AndroidAssetStudio/
|
||||
- App Icon Generator: https://appicon.co/
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
# 젤리의 적자탈출 앱 배포 가이드
|
||||
|
||||
## 준비 사항
|
||||
@@ -17,28 +16,33 @@
|
||||
## 배포 단계
|
||||
|
||||
### 1. 웹앱 빌드
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 2. Capacitor 설치 및 초기화 (처음 한 번만)
|
||||
|
||||
```bash
|
||||
npm install @capacitor/cli @capacitor/core
|
||||
npx cap init
|
||||
```
|
||||
|
||||
### 3. 네이티브 플랫폼 추가
|
||||
|
||||
```bash
|
||||
npx cap add android
|
||||
npx cap add ios # Mac에서만 가능
|
||||
```
|
||||
|
||||
### 4. Capacitor와 빌드된 웹앱 동기화
|
||||
|
||||
```bash
|
||||
npx cap sync
|
||||
```
|
||||
|
||||
### 5. 네이티브 IDE 열기
|
||||
|
||||
```bash
|
||||
npx cap open android # Android Studio 열기
|
||||
npx cap open ios # Xcode 열기 (Mac에서만 가능)
|
||||
@@ -47,6 +51,7 @@ npx cap open ios # Xcode 열기 (Mac에서만 가능)
|
||||
## 앱 아이콘 및 스플래시 스크린 설정
|
||||
|
||||
### 안드로이드 아이콘
|
||||
|
||||
- `android/app/src/main/res/` 폴더 내 각 mipmap 폴더에 다양한 크기의 아이콘 배치
|
||||
- 아이콘 크기:
|
||||
- mipmap-mdpi: 48x48 px
|
||||
@@ -56,6 +61,7 @@ npx cap open ios # Xcode 열기 (Mac에서만 가능)
|
||||
- mipmap-xxxhdpi: 192x192 px
|
||||
|
||||
### iOS 아이콘
|
||||
|
||||
- Xcode의 Assets.xcassets 내 AppIcon에 아이콘 설정
|
||||
- 다양한 크기 필요 (20pt~83.5pt, @1x, @2x, @3x)
|
||||
|
||||
@@ -64,6 +70,7 @@ npx cap open ios # Xcode 열기 (Mac에서만 가능)
|
||||
### Lovable에서 생성된 코드 관리하기
|
||||
|
||||
#### 1. 로컬 변경사항 백업
|
||||
|
||||
```bash
|
||||
# 현재 변경사항을 새 브랜치에 저장
|
||||
git checkout -b local-android-build
|
||||
@@ -72,6 +79,7 @@ git commit -m "안드로이드 빌드 환경 설정 및 서버 URL 변경"
|
||||
```
|
||||
|
||||
#### 2. 최신 코드 가져오기
|
||||
|
||||
```bash
|
||||
# 메인 브랜치로 돌아가기
|
||||
git checkout main
|
||||
@@ -81,6 +89,7 @@ git pull
|
||||
```
|
||||
|
||||
#### 3. 로컬 설정 적용하기
|
||||
|
||||
```bash
|
||||
# 필요한 파일만 선택적으로 가져오기
|
||||
git checkout local-android-build -- capacitor.config.ts android/
|
||||
@@ -90,6 +99,7 @@ git commit -m "안드로이드 빌드 환경 설정 및 서버 URL 변경 적용
|
||||
```
|
||||
|
||||
#### 4. 앱 빌드하기
|
||||
|
||||
```bash
|
||||
# 앱 동기화 및 빌드
|
||||
npx cap sync
|
||||
@@ -131,17 +141,20 @@ cd android && ./gradlew assembleDebug
|
||||
## 스토어 등록 정보 준비
|
||||
|
||||
### 공통 필요 자료
|
||||
|
||||
- 앱 설명 (짧은 설명 및 상세 설명)
|
||||
- 스크린샷 (다양한 기기)
|
||||
- 앱 아이콘 (고해상도)
|
||||
- 개인정보 처리방침 URL
|
||||
|
||||
### Google Play 스토어
|
||||
|
||||
- 앱 카테고리 선택
|
||||
- 콘텐츠 등급 설문 작성
|
||||
- 앱 가격 설정
|
||||
|
||||
### Apple App Store
|
||||
|
||||
- App Store Connect에서 앱 등록
|
||||
- 앱 심사 가이드라인 준수
|
||||
- TestFlight를 통한 베타 테스트 권장
|
||||
@@ -149,18 +162,21 @@ cd android && ./gradlew assembleDebug
|
||||
## 앱 빌드 및 제출
|
||||
|
||||
### 안드로이드
|
||||
|
||||
1. Android Studio에서 Build > Generate Signed Bundle/APK
|
||||
2. 앱 서명 키 생성 또는 기존 키 사용
|
||||
3. 앱 번들(AAB) 생성
|
||||
4. Google Play Console을 통해 제출
|
||||
|
||||
### iOS
|
||||
|
||||
1. Xcode에서 앱 인증서 및 프로비저닝 프로파일 설정
|
||||
2. Product > Archive
|
||||
3. App Store Connect에 업로드
|
||||
4. 앱 심사 제출
|
||||
|
||||
## 중요 팁
|
||||
|
||||
- 배포 전 다양한 기기에서 앱 테스트 필수
|
||||
- 앱 출시 후 지속적인 모니터링 및 업데이트 계획
|
||||
- 사용자 피드백 수집 및 반영 메커니즘 구축
|
||||
|
||||
@@ -1,25 +1,28 @@
|
||||
|
||||
# 젤리의 적자탈출 앱 - 기능 가이드
|
||||
|
||||
## 1. 핵심 기능
|
||||
|
||||
### 홈 대시보드
|
||||
|
||||
- **예산 진행 상황**: 월별/주별 예산 진행 상황 시각화
|
||||
- **카테고리별 예산**: 식비, 생활비 등 카테고리별 예산 vs 지출 현황
|
||||
- **최근 지출 내역**: 최근 지출 항목 빠른 확인
|
||||
|
||||
### 지출 관리
|
||||
|
||||
- **지출 기록**: 금액, 카테고리, 날짜별 지출 등록
|
||||
- **지출 내역 수정/삭제**: 기존 지출 정보 수정 및 삭제
|
||||
- **카테고리 필터링**: 카테고리별 지출 필터링
|
||||
- **검색 기능**: 지출 내역 검색
|
||||
|
||||
### 예산 설정
|
||||
|
||||
- **전체 예산 설정**: 월간 총 예산 금액 설정
|
||||
- **카테고리별 예산 설정**: 식비, 생활비 등 카테고리별 예산 할당
|
||||
- **예산 기간 설정**: 월간/주간 예산 타입 선택
|
||||
|
||||
### 데이터 분석
|
||||
|
||||
- **지출 통계**: 카테고리별, 기간별 지출 분석
|
||||
- **그래프 시각화**: 예산 대비 지출 그래프 시각화
|
||||
- **소비 패턴 분석**: 시간에 따른 지출 패턴 확인
|
||||
@@ -27,33 +30,39 @@
|
||||
## 2. 사용자 관리
|
||||
|
||||
### 회원 기능
|
||||
|
||||
- **회원가입**: 이메일 기반 회원가입
|
||||
- **로그인/로그아웃**: 사용자 인증
|
||||
- **비밀번호 재설정**: 잊어버린 비밀번호 복구
|
||||
|
||||
### 프로필 관리
|
||||
|
||||
- **개인정보 설정**: 사용자 프로필 정보 관리
|
||||
- **비밀번호 변경**: 보안 강화를 위한 비밀번호 변경
|
||||
|
||||
## 3. 데이터 동기화
|
||||
|
||||
### 클라우드 동기화
|
||||
|
||||
- **Supabase 연동**: 사용자 데이터 클라우드 저장
|
||||
- **데이터 백업**: 기기 변경 시에도 데이터 유지
|
||||
- **실시간 동기화**: 여러 기기에서 동일한 데이터 접근
|
||||
|
||||
### 오프라인 기능
|
||||
|
||||
- **로컬 데이터 저장**: 인터넷 연결 없이도 데이터 저장
|
||||
- **자동 동기화**: 인터넷 연결 시 자동 데이터 동기화
|
||||
|
||||
## 4. 설정 및 보안
|
||||
|
||||
### 앱 설정
|
||||
|
||||
- **알림 설정**: 예산 초과 알림 등 설정
|
||||
- **테마 설정**: 앱 테마 커스터마이징
|
||||
- **언어 설정**: 다국어 지원
|
||||
|
||||
### 보안 기능
|
||||
|
||||
- **데이터 암호화**: 민감한 재정 정보 보호
|
||||
- **보안 설정**: 앱 잠금 및 보안 강화 옵션
|
||||
- **데이터 초기화**: 모든 데이터 리셋 옵션
|
||||
@@ -61,11 +70,13 @@
|
||||
## 5. 사용자 경험
|
||||
|
||||
### UI/UX
|
||||
|
||||
- **네오모피즘 디자인**: 모던하고 직관적인 UI
|
||||
- **반응형 레이아웃**: 다양한 기기 화면 크기 지원
|
||||
- **다크 모드**: 배터리 절약 및 눈 피로도 감소
|
||||
|
||||
### 사용자 지원
|
||||
|
||||
- **도움말 및 지원**: 앱 사용 가이드
|
||||
- **튜토리얼**: 첫 사용자를 위한 온보딩 안내
|
||||
- **피드백 시스템**: 사용자 의견 수렴
|
||||
@@ -73,11 +84,13 @@
|
||||
## 6. 모바일 앱 지원
|
||||
|
||||
### 크로스 플랫폼
|
||||
|
||||
- **iOS 앱**: 아이폰 및 아이패드 지원
|
||||
- **안드로이드 앱**: 안드로이드 기기 지원
|
||||
- **웹 앱**: 브라우저에서도 동일한 경험
|
||||
|
||||
### 네이티브 기능
|
||||
|
||||
- **푸시 알림**: 중요 알림 실시간 전달
|
||||
- **오프라인 모드**: 인터넷 연결 없이도 기본 기능 사용
|
||||
- **기기 저장소 접근**: 데이터 내보내기/가져오기
|
||||
|
||||
@@ -11,7 +11,7 @@ interface SupabaseConnectionStatusProps {
|
||||
}
|
||||
|
||||
const SupabaseConnectionStatus = ({ testResults }: SupabaseConnectionStatusProps) => {
|
||||
if (!testResults) return null;
|
||||
if (!testResults) {return null;}
|
||||
|
||||
return (
|
||||
<div className={`mt-2 p-3 rounded-md text-sm ${testResults.connected ? 'bg-green-50' : 'bg-red-50'}`}>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { appwriteLogger } from '@/utils/logger';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Loader2, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
@@ -51,7 +52,7 @@ const SupabaseToAppwriteMigration: React.FC = () => {
|
||||
|
||||
// 마이그레이션 상태 확인
|
||||
const checkStatus = useCallback(async () => {
|
||||
if (!user || !isMounted) return;
|
||||
if (!user || !isMounted) {return;}
|
||||
|
||||
try {
|
||||
const status = await checkMigrationStatus(user);
|
||||
@@ -59,7 +60,7 @@ const SupabaseToAppwriteMigration: React.FC = () => {
|
||||
setMigrationStatus(status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('마이그레이션 상태 확인 오류:', error);
|
||||
appwriteLogger.error('마이그레이션 상태 확인 오류:', error);
|
||||
if (isMounted) {
|
||||
toast({
|
||||
title: '상태 확인 실패',
|
||||
@@ -72,7 +73,7 @@ const SupabaseToAppwriteMigration: React.FC = () => {
|
||||
|
||||
// 마이그레이션 실행
|
||||
const runMigration = useCallback(async () => {
|
||||
if (!user || !isMounted) return;
|
||||
if (!user || !isMounted) {return;}
|
||||
|
||||
// 진행 상태 초기화
|
||||
setMigrationProgress({
|
||||
@@ -88,11 +89,11 @@ const SupabaseToAppwriteMigration: React.FC = () => {
|
||||
try {
|
||||
// 진행 상황 콜백
|
||||
const progressCallback = (current: number, total: number) => {
|
||||
if (!isMounted) return;
|
||||
if (!isMounted) {return;}
|
||||
|
||||
// requestAnimationFrame을 사용하여 UI 업데이트 최적화
|
||||
requestAnimationFrame(() => {
|
||||
if (!isMounted) return;
|
||||
if (!isMounted) {return;}
|
||||
|
||||
setMigrationProgress({
|
||||
isRunning: true,
|
||||
@@ -106,7 +107,7 @@ const SupabaseToAppwriteMigration: React.FC = () => {
|
||||
// 마이그레이션 실행
|
||||
const result = await migrateTransactionsFromSupabase(user, progressCallback);
|
||||
|
||||
if (!isMounted) return;
|
||||
if (!isMounted) {return;}
|
||||
|
||||
// 결과 설정
|
||||
setMigrationResult(result);
|
||||
@@ -129,9 +130,9 @@ const SupabaseToAppwriteMigration: React.FC = () => {
|
||||
// 상태 다시 확인
|
||||
checkStatus();
|
||||
} catch (error) {
|
||||
console.error('마이그레이션 오류:', error);
|
||||
appwriteLogger.error('마이그레이션 오류:', error);
|
||||
|
||||
if (!isMounted) return;
|
||||
if (!isMounted) {return;}
|
||||
|
||||
// 오류 메시지
|
||||
toast({
|
||||
|
||||
@@ -20,7 +20,7 @@ const DebugInfoCollapsible: React.FC<DebugInfoCollapsibleProps> = ({
|
||||
showDebug,
|
||||
setShowDebug
|
||||
}) => {
|
||||
if (!testResults.debugInfo) return null;
|
||||
if (!testResults.debugInfo) {return null;}
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
|
||||
@@ -6,7 +6,7 @@ interface ErrorMessageCardProps {
|
||||
}
|
||||
|
||||
const ErrorMessageCard: React.FC<ErrorMessageCardProps> = ({ errors }) => {
|
||||
if (errors.length === 0) return null;
|
||||
if (errors.length === 0) {return null;}
|
||||
|
||||
return (
|
||||
<div className="bg-red-50 border border-red-200 rounded p-2 mt-2">
|
||||
|
||||
@@ -15,7 +15,7 @@ const ProxyRecommendationAlert: React.FC<ProxyRecommendationAlertProps> = ({ err
|
||||
err.includes('프록시를 활성화')
|
||||
);
|
||||
|
||||
if (!hasCorsError || errors.length === 0) return null;
|
||||
if (!hasCorsError || errors.length === 0) {return null;}
|
||||
|
||||
return (
|
||||
<Alert className="bg-amber-50 border-amber-200 mt-3">
|
||||
|
||||
@@ -8,7 +8,7 @@ interface TroubleshootingTipsProps {
|
||||
}
|
||||
|
||||
const TroubleshootingTips: React.FC<TroubleshootingTipsProps> = ({ testResults }) => {
|
||||
if (!(!testResults.restApi && testResults.auth)) return null;
|
||||
if (!(!testResults.restApi && testResults.auth)) {return null;}
|
||||
|
||||
return (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded p-2 mt-2">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
|
||||
import { Transaction } from '@/components/TransactionCard';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { isSyncEnabled } from '@/utils/syncUtils';
|
||||
import { formatISO } from 'date-fns';
|
||||
@@ -34,17 +35,17 @@ const convertDateToISO = (dateStr: string): string => {
|
||||
}
|
||||
|
||||
// 변환 실패 시 현재 시간 반환
|
||||
console.warn(`날짜 변환 오류: "${dateStr}"를 ISO 형식으로 변환할 수 없습니다.`);
|
||||
logger.warn(`날짜 변환 오류: "${dateStr}"를 ISO 형식으로 변환할 수 없습니다.`);
|
||||
return formatISO(new Date());
|
||||
} catch (error) {
|
||||
console.error(`날짜 변환 오류: "${dateStr}"`, error);
|
||||
logger.error(`날짜 변환 오류: "${dateStr}"`, error);
|
||||
return formatISO(new Date());
|
||||
}
|
||||
};
|
||||
|
||||
// Supabase와 트랜잭션 동기화 - Cloud 최적화 버전
|
||||
export const syncTransactionsWithSupabase = async (user: any, transactions: Transaction[]): Promise<Transaction[]> => {
|
||||
if (!user || !isSyncEnabled()) return transactions;
|
||||
if (!user || !isSyncEnabled()) {return transactions;}
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
@@ -53,7 +54,7 @@ export const syncTransactionsWithSupabase = async (user: any, transactions: Tran
|
||||
.eq('user_id', user.id);
|
||||
|
||||
if (error) {
|
||||
console.error('Supabase 데이터 조회 오류:', error);
|
||||
logger.error('Supabase 데이터 조회 오류:', error);
|
||||
return transactions;
|
||||
}
|
||||
|
||||
@@ -83,7 +84,7 @@ export const syncTransactionsWithSupabase = async (user: any, transactions: Tran
|
||||
return mergedTransactions;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Supabase 동기화 오류:', err);
|
||||
logger.error('Supabase 동기화 오류:', err);
|
||||
}
|
||||
|
||||
return transactions;
|
||||
@@ -91,7 +92,7 @@ export const syncTransactionsWithSupabase = async (user: any, transactions: Tran
|
||||
|
||||
// Supabase에 트랜잭션 업데이트 - Cloud 최적화 버전
|
||||
export const updateTransactionInSupabase = async (user: any, transaction: Transaction): Promise<void> => {
|
||||
if (!user || !isSyncEnabled()) return;
|
||||
if (!user || !isSyncEnabled()) {return;}
|
||||
|
||||
try {
|
||||
// 날짜를 ISO 형식으로 변환
|
||||
@@ -109,18 +110,18 @@ export const updateTransactionInSupabase = async (user: any, transaction: Transa
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('트랜잭션 업데이트 오류:', error);
|
||||
logger.error('트랜잭션 업데이트 오류:', error);
|
||||
} else {
|
||||
console.log('Supabase 트랜잭션 업데이트 성공:', transaction.id);
|
||||
logger.info('Supabase 트랜잭션 업데이트 성공:', transaction.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Supabase 업데이트 오류:', error);
|
||||
logger.error('Supabase 업데이트 오류:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Supabase에서 트랜잭션 삭제 - Cloud 최적화 버전
|
||||
export const deleteTransactionFromSupabase = async (user: any, transactionId: string): Promise<void> => {
|
||||
if (!user || !isSyncEnabled()) return;
|
||||
if (!user || !isSyncEnabled()) {return;}
|
||||
|
||||
try {
|
||||
const { error } = await supabase.from('transactions')
|
||||
@@ -128,11 +129,11 @@ export const deleteTransactionFromSupabase = async (user: any, transactionId: st
|
||||
.eq('transaction_id', transactionId);
|
||||
|
||||
if (error) {
|
||||
console.error('트랜잭션 삭제 오류:', error);
|
||||
logger.error('트랜잭션 삭제 오류:', error);
|
||||
} else {
|
||||
console.log('Supabase 트랜잭션 삭제 성공:', transactionId);
|
||||
logger.info('Supabase 트랜잭션 삭제 성공:', transactionId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Supabase 삭제 오류:', error);
|
||||
logger.error('Supabase 삭제 오류:', error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4,13 +4,13 @@ import type { Database } from './types';
|
||||
|
||||
const SUPABASE_URL = (() => {
|
||||
const url = import.meta.env.VITE_SUPABASE_URL;
|
||||
if (!url) throw new Error("VITE_SUPABASE_URL is not set");
|
||||
if (!url) {throw new Error("VITE_SUPABASE_URL is not set");}
|
||||
return url;
|
||||
})();
|
||||
|
||||
const SUPABASE_PUBLISHABLE_KEY = (() => {
|
||||
const key = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
||||
if (!key) throw new Error("VITE_SUPABASE_ANON_KEY is not set");
|
||||
if (!key) {throw new Error("VITE_SUPABASE_ANON_KEY is not set");}
|
||||
return key;
|
||||
})();
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ID, Query } from 'appwrite';
|
||||
import { appwriteLogger } from '@/utils/logger';
|
||||
import { supabase } from '@/archive/lib/supabase';
|
||||
import { databases, account } from './client';
|
||||
import { config } from './config';
|
||||
@@ -38,7 +39,7 @@ export const migrateTransactionsFromSupabase = async (
|
||||
.eq('user_id', user.id);
|
||||
|
||||
if (error) {
|
||||
console.error('Supabase 데이터 조회 오류:', error);
|
||||
appwriteLogger.error('Supabase 데이터 조회 오류:', error);
|
||||
return {
|
||||
success: false,
|
||||
migrated: 0,
|
||||
@@ -106,7 +107,7 @@ export const migrateTransactionsFromSupabase = async (
|
||||
|
||||
migratedCount++;
|
||||
} catch (docError) {
|
||||
console.error('트랜잭션 마이그레이션 오류:', docError);
|
||||
appwriteLogger.error('트랜잭션 마이그레이션 오류:', docError);
|
||||
}
|
||||
|
||||
// 진행 상황 콜백
|
||||
@@ -121,7 +122,7 @@ export const migrateTransactionsFromSupabase = async (
|
||||
total: totalCount
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('마이그레이션 오류:', error);
|
||||
appwriteLogger.error('마이그레이션 오류:', error);
|
||||
return {
|
||||
success: false,
|
||||
migrated: 0,
|
||||
@@ -175,7 +176,7 @@ export const checkMigrationStatus = async (
|
||||
isComplete: (supabaseCount || 0) <= appwriteCount
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('마이그레이션 상태 확인 오류:', error);
|
||||
appwriteLogger.error('마이그레이션 상태 확인 오류:', error);
|
||||
return {
|
||||
supabaseCount: 0,
|
||||
appwriteCount: 0,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { getSupabaseUrl, getSupabaseKey } from './config';
|
||||
|
||||
const supabaseUrl = getSupabaseUrl();
|
||||
@@ -8,7 +9,7 @@ const supabaseAnonKey = getSupabaseKey();
|
||||
let supabaseClient;
|
||||
|
||||
try {
|
||||
console.log(`Supabase 클라이언트 생성 중: ${supabaseUrl}`);
|
||||
logger.info(`Supabase 클라이언트 생성 중: ${supabaseUrl}`);
|
||||
|
||||
// Supabase 클라이언트 생성 - Cloud 환경에 최적화
|
||||
supabaseClient = createClient(supabaseUrl, supabaseAnonKey, {
|
||||
@@ -18,10 +19,10 @@ try {
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Supabase 클라이언트가 성공적으로 생성되었습니다.');
|
||||
logger.info('Supabase 클라이언트가 성공적으로 생성되었습니다.');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Supabase 클라이언트 생성 오류:', error);
|
||||
logger.error('Supabase 클라이언트 생성 오류:', error);
|
||||
|
||||
// 더미 클라이언트 생성 (앱이 완전히 실패하지 않도록)
|
||||
supabaseClient = {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
// Supabase Cloud URL과 anon key 설정
|
||||
export const getSupabaseUrl = () => {
|
||||
const url = import.meta.env.VITE_SUPABASE_URL;
|
||||
if (!url) throw new Error("VITE_SUPABASE_URL is not set");
|
||||
if (!url) {throw new Error("VITE_SUPABASE_URL is not set");}
|
||||
return url;
|
||||
};
|
||||
|
||||
export const getSupabaseKey = () => {
|
||||
const key = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
||||
if (!key) throw new Error("VITE_SUPABASE_ANON_KEY is not set");
|
||||
if (!key) {throw new Error("VITE_SUPABASE_ANON_KEY is not set");}
|
||||
return key;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
|
||||
import { getSupabaseKey } from './config';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { modifyStorageApiRequest } from './storageUtils';
|
||||
|
||||
/**
|
||||
@@ -28,16 +29,16 @@ export const customFetch = (...args: [RequestInfo | URL, RequestInit?]): Promise
|
||||
}
|
||||
|
||||
// URL 로깅 및 디버깅
|
||||
console.log('Supabase fetch 요청:', url);
|
||||
logger.info('Supabase fetch 요청:', url);
|
||||
|
||||
// 기본 fetch 호출
|
||||
return fetch(requestToUse, args[1])
|
||||
.then(response => {
|
||||
console.log('Supabase 응답 상태:', response.status);
|
||||
logger.info('Supabase 응답 상태:', response.status);
|
||||
return response;
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Supabase fetch 오류:', err);
|
||||
logger.error('Supabase fetch 오류:', err);
|
||||
throw err;
|
||||
});
|
||||
};
|
||||
@@ -50,7 +51,7 @@ function modifyStorageApiHeaders(options: RequestInit, supabaseAnonKey: string):
|
||||
return options;
|
||||
}
|
||||
|
||||
console.log('Storage API 호출 감지');
|
||||
logger.info('Storage API 호출 감지');
|
||||
|
||||
// 헤더 수정
|
||||
const originalHeaders = options.headers;
|
||||
@@ -72,7 +73,7 @@ function modifyStorageApiHeaders(options: RequestInit, supabaseAnonKey: string):
|
||||
|
||||
// 수정된 헤더로 새 옵션 객체 생성
|
||||
const newOptions = { ...options, headers: newHeaders };
|
||||
console.log('Storage API 헤더 형식 수정 완료');
|
||||
logger.info('Storage API 헤더 형식 수정 완료');
|
||||
|
||||
return newOptions;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
|
||||
import { supabase } from '../client';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { checkTablesStatus } from './status';
|
||||
|
||||
/**
|
||||
@@ -8,7 +9,7 @@ import { checkTablesStatus } from './status';
|
||||
*/
|
||||
export const createRequiredTables = async (): Promise<{ success: boolean; message: string }> => {
|
||||
try {
|
||||
console.log('데이터베이스 테이블 확인 시작...');
|
||||
logger.info('데이터베이스 테이블 확인 시작...');
|
||||
|
||||
// 테이블 상태 확인
|
||||
const tablesStatus = await checkTablesStatus();
|
||||
@@ -25,7 +26,7 @@ export const createRequiredTables = async (): Promise<{ success: boolean; messag
|
||||
message: '일부 필요한 테이블이 없습니다. Supabase 대시보드에서 확인해주세요.'
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('테이블 확인 중 오류 발생:', error);
|
||||
logger.error('테이블 확인 중 오류 발생:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: `테이블 확인 실패: ${error.message || '알 수 없는 오류'}`
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
|
||||
import { supabase } from '../client';
|
||||
|
||||
import { logger } from '@/utils/logger';
|
||||
/**
|
||||
* 테이블 상태를 확인합니다.
|
||||
*/
|
||||
@@ -39,7 +40,7 @@ export const checkTablesStatus = async (): Promise<{
|
||||
|
||||
return tables;
|
||||
} catch (error) {
|
||||
console.error('테이블 상태 확인 중 오류 발생:', error);
|
||||
logger.error('테이블 상태 확인 중 오류 발생:', error);
|
||||
return tables;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
|
||||
import { supabase } from '../client';
|
||||
|
||||
import { logger } from '@/utils/logger';
|
||||
/**
|
||||
* 트랜잭션 테이블을 생성합니다.
|
||||
*/
|
||||
@@ -47,7 +48,7 @@ export const createTransactionsTable = async (): Promise<{ success: boolean; mes
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('transactions 테이블 생성 실패:', error);
|
||||
logger.error('transactions 테이블 생성 실패:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: `transactions 테이블 생성 실패: ${error.message}`
|
||||
@@ -59,7 +60,7 @@ export const createTransactionsTable = async (): Promise<{ success: boolean; mes
|
||||
message: 'transactions 테이블 생성 성공'
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('트랜잭션 테이블 생성 중 오류 발생:', error);
|
||||
logger.error('트랜잭션 테이블 생성 중 오류 발생:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: `트랜잭션 테이블 생성 실패: ${error.message || '알 수 없는 오류'}`
|
||||
@@ -112,7 +113,7 @@ export const createBudgetsTable = async (): Promise<{ success: boolean; message:
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('budgets 테이블 생성 실패:', error);
|
||||
logger.error('budgets 테이블 생성 실패:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: `budgets 테이블 생성 실패: ${error.message}`
|
||||
@@ -124,7 +125,7 @@ export const createBudgetsTable = async (): Promise<{ success: boolean; message:
|
||||
message: 'budgets 테이블 생성 성공'
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('예산 테이블 생성 중 오류 발생:', error);
|
||||
logger.error('예산 테이블 생성 중 오류 발생:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: `예산 테이블 생성 실패: ${error.message || '알 수 없는 오류'}`
|
||||
@@ -154,7 +155,7 @@ export const createTestsTable = async (): Promise<{ success: boolean; message: s
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('_tests 테이블 생성 실패:', error);
|
||||
logger.error('_tests 테이블 생성 실패:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: `_tests 테이블 생성 실패: ${error.message}`
|
||||
@@ -166,7 +167,7 @@ export const createTestsTable = async (): Promise<{ success: boolean; message: s
|
||||
message: '_tests 테이블 생성 성공'
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('테스트 테이블 생성 중 오류 발생:', error);
|
||||
logger.error('테스트 테이블 생성 중 오류 발생:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: `테스트 테이블 생성 실패: ${error.message || '알 수 없는 오류'}`
|
||||
|
||||
@@ -16,7 +16,7 @@ export function modifyStorageApiRequest(
|
||||
// Storage API 엔드포인트 경로 수정 (buckets → bucket)
|
||||
if (url.includes('/storage/v1/buckets')) {
|
||||
url = url.replace('/storage/v1/buckets', '/storage/v1/bucket');
|
||||
console.log('Storage API 경로 수정:', url);
|
||||
storageLogger.info('Storage API 경로 수정:', url);
|
||||
return url;
|
||||
}
|
||||
} else if (request instanceof Request) {
|
||||
@@ -27,7 +27,7 @@ export function modifyStorageApiRequest(
|
||||
const newUrl = url.replace('/storage/v1/buckets', '/storage/v1/bucket');
|
||||
// Request 객체인 경우 새 Request 객체 생성
|
||||
const newRequest = new Request(newUrl, request);
|
||||
console.log('Storage API Request 객체 경로 수정:', newUrl);
|
||||
storageLogger.info('Storage API Request 객체 경로 수정:', newUrl);
|
||||
return newRequest;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { TestResult } from './types';
|
||||
import { getSupabaseUrl, getSupabaseKey } from '../config';
|
||||
|
||||
@@ -7,7 +8,7 @@ import { getSupabaseUrl, getSupabaseKey } from '../config';
|
||||
export const testRestApi = async (
|
||||
supabase: SupabaseClient
|
||||
): Promise<TestResult> => {
|
||||
console.log('REST API 테스트 시작...');
|
||||
logger.info('REST API 테스트 시작...');
|
||||
|
||||
try {
|
||||
const originalUrl = getSupabaseUrl();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
import { authLogger } from '@/utils/logger';
|
||||
import { supabase } from '../client';
|
||||
import { LoginTestResult, TestResult } from './types';
|
||||
|
||||
@@ -9,18 +10,18 @@ export const testAuth = async (
|
||||
url: string
|
||||
): Promise<TestResult> => {
|
||||
try {
|
||||
console.log('인증 서비스 테스트 시작...');
|
||||
authLogger.info('인증 서비스 테스트 시작...');
|
||||
const { data, error } = await supabase.auth.getSession();
|
||||
|
||||
if (error) {
|
||||
console.error('인증 테스트 실패:', error);
|
||||
authLogger.error('인증 테스트 실패:', error);
|
||||
return { success: false, error: `인증 서비스 오류: ${error.message}` };
|
||||
}
|
||||
|
||||
console.log('인증 테스트 성공');
|
||||
authLogger.info('인증 테스트 성공');
|
||||
return { success: true, error: null };
|
||||
} catch (err: any) {
|
||||
console.error('인증 테스트 중 예외:', err);
|
||||
authLogger.error('인증 테스트 중 예외:', err);
|
||||
return {
|
||||
success: false,
|
||||
error: `인증 테스트 중 예외 발생: ${err.message || '알 수 없는 오류'}`
|
||||
@@ -31,7 +32,7 @@ export const testAuth = async (
|
||||
// 테스트용 직접 로그인 함수 (디버깅 전용)
|
||||
export const testSupabaseLogin = async (email: string, password: string): Promise<LoginTestResult> => {
|
||||
try {
|
||||
console.log('테스트 로그인 시도:', email);
|
||||
authLogger.info('테스트 로그인 시도:', email);
|
||||
|
||||
const { data, error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
@@ -39,14 +40,14 @@ export const testSupabaseLogin = async (email: string, password: string): Promis
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('테스트 로그인 오류:', error);
|
||||
authLogger.error('테스트 로그인 오류:', error);
|
||||
return { success: false, error };
|
||||
}
|
||||
|
||||
console.log('테스트 로그인 성공:', data);
|
||||
authLogger.info('테스트 로그인 성공:', data);
|
||||
return { success: true, data };
|
||||
} catch (err) {
|
||||
console.error('테스트 로그인 중 예외 발생:', err);
|
||||
authLogger.error('테스트 로그인 중 예외 발생:', err);
|
||||
return { success: false, error: err };
|
||||
}
|
||||
};
|
||||
@@ -54,18 +55,18 @@ export const testSupabaseLogin = async (email: string, password: string): Promis
|
||||
// 인증 서비스 테스트
|
||||
export const testAuthService = async (): Promise<{ success: boolean; error?: any }> => {
|
||||
try {
|
||||
console.log('인증 서비스 테스트 시작...');
|
||||
authLogger.info('인증 서비스 테스트 시작...');
|
||||
const { data, error } = await supabase.auth.getSession();
|
||||
|
||||
if (error) {
|
||||
console.error('인증 테스트 실패:', error);
|
||||
authLogger.error('인증 테스트 실패:', error);
|
||||
return { success: false, error };
|
||||
}
|
||||
|
||||
console.log('인증 테스트 성공');
|
||||
authLogger.info('인증 테스트 성공');
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error('인증 테스트 중 예외:', err);
|
||||
authLogger.error('인증 테스트 중 예외:', err);
|
||||
return { success: false, error: err };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { Transaction } from '@/components/TransactionCard';
|
||||
import { isSyncEnabled } from '@/utils/syncUtils';
|
||||
import { toast } from '@/hooks/useToast.wrapper';
|
||||
@@ -9,7 +10,7 @@ export const syncTransactionsWithSupabase = async (
|
||||
user: any,
|
||||
transactions: Transaction[]
|
||||
): Promise<Transaction[]> => {
|
||||
if (!user || !isSyncEnabled()) return transactions;
|
||||
if (!user || !isSyncEnabled()) {return transactions;}
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
@@ -18,7 +19,7 @@ export const syncTransactionsWithSupabase = async (
|
||||
.eq('user_id', user.id);
|
||||
|
||||
if (error) {
|
||||
console.error('Supabase 데이터 조회 오류:', error);
|
||||
logger.error('Supabase 데이터 조회 오류:', error);
|
||||
return transactions;
|
||||
}
|
||||
|
||||
@@ -48,7 +49,7 @@ export const syncTransactionsWithSupabase = async (
|
||||
return mergedTransactions;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Supabase 동기화 오류:', err);
|
||||
logger.error('Supabase 동기화 오류:', err);
|
||||
}
|
||||
|
||||
return transactions;
|
||||
@@ -59,7 +60,7 @@ export const updateTransactionInSupabase = async (
|
||||
user: any,
|
||||
transaction: Transaction
|
||||
): Promise<void> => {
|
||||
if (!user || !isSyncEnabled()) return;
|
||||
if (!user || !isSyncEnabled()) {return;}
|
||||
|
||||
try {
|
||||
const { error } = await supabase.from('transactions')
|
||||
@@ -74,10 +75,10 @@ export const updateTransactionInSupabase = async (
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('트랜잭션 업데이트 오류:', error);
|
||||
logger.error('트랜잭션 업데이트 오류:', error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Supabase 업데이트 오류:', error);
|
||||
logger.error('Supabase 업데이트 오류:', error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -86,7 +87,7 @@ export const deleteTransactionFromSupabase = async (
|
||||
user: any,
|
||||
transactionId: string
|
||||
): Promise<void> => {
|
||||
if (!user || !isSyncEnabled()) return;
|
||||
if (!user || !isSyncEnabled()) {return;}
|
||||
|
||||
try {
|
||||
const { error } = await supabase.from('transactions')
|
||||
@@ -94,9 +95,9 @@ export const deleteTransactionFromSupabase = async (
|
||||
.eq('transaction_id', transactionId);
|
||||
|
||||
if (error) {
|
||||
console.error('트랜잭션 삭제 오류:', error);
|
||||
logger.error('트랜잭션 삭제 오류:', error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Supabase 삭제 오류:', error);
|
||||
logger.error('Supabase 삭제 오류:', error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,149 +1,160 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { PlusIcon } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
import { toast } from '@/hooks/useToast.wrapper'; // 래퍼 사용
|
||||
import { useBudget } from '@/contexts/budget/BudgetContext';
|
||||
import { supabase } from '@/archive/lib/supabase';
|
||||
import { isSyncEnabled, setLastSyncTime, trySyncAllData } from '@/utils/syncUtils';
|
||||
import ExpenseForm, { ExpenseFormValues } from './expenses/ExpenseForm';
|
||||
import { Transaction } from '@/contexts/budget/types';
|
||||
import { normalizeDate } from '@/utils/sync/transaction/dateUtils';
|
||||
import useNotifications from '@/hooks/useNotifications';
|
||||
import { checkNetworkStatus } from '@/utils/network/checker';
|
||||
import { manageTitleSuggestions } from '@/utils/userTitlePreferences'; // 새로운 제목 관리 추가
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { logger } from "@/utils/logger";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog";
|
||||
import { toast } from "@/hooks/useToast.wrapper"; // 래퍼 사용
|
||||
import { useBudget } from "@/contexts/budget/BudgetContext";
|
||||
import { supabase } from "@/archive/lib/supabase";
|
||||
import {
|
||||
isSyncEnabled,
|
||||
setLastSyncTime,
|
||||
trySyncAllData,
|
||||
} from "@/utils/syncUtils";
|
||||
import ExpenseForm, { ExpenseFormValues } from "./expenses/ExpenseForm";
|
||||
import { Transaction } from "@/contexts/budget/types";
|
||||
import { normalizeDate } from "@/utils/sync/transaction/dateUtils";
|
||||
import useNotifications from "@/hooks/useNotifications";
|
||||
import { checkNetworkStatus } from "@/utils/network/checker";
|
||||
import { manageTitleSuggestions } from "@/utils/userTitlePreferences"; // 새로운 제목 관리 추가
|
||||
|
||||
const AddTransactionButton = () => {
|
||||
const [showExpenseDialog, setShowExpenseDialog] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { addTransaction } = useBudget();
|
||||
const { addNotification } = useNotifications();
|
||||
|
||||
|
||||
// Format number with commas
|
||||
const formatWithCommas = (value: string): string => {
|
||||
// Remove commas first to avoid duplicates when typing
|
||||
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, ",");
|
||||
};
|
||||
|
||||
|
||||
const onSubmit = async (data: ExpenseFormValues) => {
|
||||
// 중복 제출 방지
|
||||
if (isSubmitting) return;
|
||||
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
|
||||
|
||||
// Remove commas before processing the amount
|
||||
const numericAmount = data.amount.replace(/,/g, '');
|
||||
|
||||
const numericAmount = data.amount.replace(/,/g, "");
|
||||
|
||||
// 현재 날짜와 시간을 가져옵니다
|
||||
const now = new Date();
|
||||
const formattedDate = `오늘, ${now.getHours()}:${now.getMinutes() < 10 ? '0' + now.getMinutes() : now.getMinutes()} ${now.getHours() >= 12 ? 'PM' : 'AM'}`;
|
||||
|
||||
const formattedDate = `오늘, ${now.getHours()}:${now.getMinutes() < 10 ? "0" + now.getMinutes() : now.getMinutes()} ${now.getHours() >= 12 ? "PM" : "AM"}`;
|
||||
|
||||
const newExpense: Transaction = {
|
||||
id: Date.now().toString(),
|
||||
title: data.title,
|
||||
amount: parseInt(numericAmount),
|
||||
date: formattedDate,
|
||||
category: data.category,
|
||||
type: 'expense',
|
||||
paymentMethod: data.paymentMethod // 지출 방법 필드 추가
|
||||
type: "expense",
|
||||
paymentMethod: data.paymentMethod, // 지출 방법 필드 추가
|
||||
};
|
||||
|
||||
console.log('새 지출 추가:', newExpense);
|
||||
|
||||
|
||||
logger.info("새 지출 추가:", newExpense);
|
||||
|
||||
// BudgetContext를 통해 지출 추가
|
||||
addTransaction(newExpense);
|
||||
|
||||
|
||||
// 제목 추천 관리 로직 호출 (새로운 함수)
|
||||
manageTitleSuggestions(newExpense);
|
||||
|
||||
|
||||
// 다이얼로그를 닫습니다
|
||||
setShowExpenseDialog(false);
|
||||
|
||||
|
||||
// 토스트는 한 번만 표시 (지연 제거하여 래퍼에서 처리되도록)
|
||||
toast({
|
||||
title: "지출이 추가되었습니다",
|
||||
description: `${data.title} 항목이 ${formatWithCommas(numericAmount)}원으로 등록되었습니다.`,
|
||||
duration: 3000
|
||||
duration: 3000,
|
||||
});
|
||||
|
||||
|
||||
// 네트워크 상태 확인 후 Supabase 동기화 시도
|
||||
const isOnline = await checkNetworkStatus();
|
||||
|
||||
|
||||
if (isSyncEnabled() && isOnline) {
|
||||
try {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (user) {
|
||||
// ISO 형식으로 날짜 변환
|
||||
const isoDate = normalizeDate(formattedDate);
|
||||
|
||||
console.log('Supabase에 지출 추가 시도 중...');
|
||||
const { error } = await supabase.from('transactions').insert({
|
||||
|
||||
logger.info("Supabase에 지출 추가 시도 중...");
|
||||
const { error } = await supabase.from("transactions").insert({
|
||||
user_id: user.id,
|
||||
title: data.title,
|
||||
amount: parseInt(numericAmount),
|
||||
date: isoDate, // ISO 형식 사용
|
||||
category: data.category,
|
||||
type: 'expense',
|
||||
type: "expense",
|
||||
transaction_id: newExpense.id,
|
||||
payment_method: data.paymentMethod // Supabase에 필드 추가
|
||||
payment_method: data.paymentMethod, // Supabase에 필드 추가
|
||||
});
|
||||
|
||||
|
||||
if (error) {
|
||||
console.error('Supabase 데이터 저장 오류:', error);
|
||||
logger.error("Supabase 데이터 저장 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
|
||||
// 지출 추가 후 자동 동기화 실행
|
||||
console.log('지출 추가 후 자동 동기화 시작');
|
||||
logger.info("지출 추가 후 자동 동기화 시작");
|
||||
const syncResult = await trySyncAllData(user.id);
|
||||
|
||||
|
||||
if (syncResult.success) {
|
||||
// 동기화 성공 시 마지막 동기화 시간 업데이트
|
||||
const currentTime = new Date().toISOString();
|
||||
console.log('자동 동기화 성공, 시간 업데이트:', currentTime);
|
||||
logger.info("자동 동기화 성공, 시간 업데이트:", currentTime);
|
||||
setLastSyncTime(currentTime);
|
||||
|
||||
|
||||
// 동기화 성공 알림 추가
|
||||
addNotification(
|
||||
'동기화 완료',
|
||||
'방금 추가하신 지출 데이터가 클라우드에 동기화되었습니다.'
|
||||
"동기화 완료",
|
||||
"방금 추가하신 지출 데이터가 클라우드에 동기화되었습니다."
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Supabase에 지출 추가 실패:', error);
|
||||
logger.error("Supabase에 지출 추가 실패:", error);
|
||||
// 실패해도 조용히 처리 (나중에 자동으로 재시도될 것임)
|
||||
console.log('로컬 데이터는 저장됨, 다음 동기화에서 재시도할 예정');
|
||||
logger.info("로컬 데이터는 저장됨, 다음 동기화에서 재시도할 예정");
|
||||
}
|
||||
} else if (isSyncEnabled() && !isOnline) {
|
||||
console.log('네트워크 연결이 없어 로컬에만 저장되었습니다. 다음 동기화에서 업로드될 예정입니다.');
|
||||
logger.info(
|
||||
"네트워크 연결이 없어 로컬에만 저장되었습니다. 다음 동기화에서 업로드될 예정입니다."
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// 이벤트 발생 처리 - 단일 이벤트로 통합
|
||||
window.dispatchEvent(new CustomEvent('transactionChanged', {
|
||||
detail: { type: 'add', transaction: newExpense }
|
||||
}));
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("transactionChanged", {
|
||||
detail: { type: "add", transaction: newExpense },
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('지출 추가 중 오류 발생:', error);
|
||||
logger.error("지출 추가 중 오류 발생:", error);
|
||||
toast({
|
||||
title: "지출 추가 실패",
|
||||
description: "지출을 추가하는 도중 오류가 발생했습니다.",
|
||||
variant: "destructive",
|
||||
duration: 4000
|
||||
duration: 4000,
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed bottom-24 right-6 z-20">
|
||||
<button
|
||||
<button
|
||||
className="transition-all duration-300 bg-neuro-income shadow-neuro-flat hover:shadow-neuro-convex text-white px-4 py-3 rounded-full"
|
||||
onClick={() => setShowExpenseDialog(true)}
|
||||
aria-label="지출 추가"
|
||||
@@ -155,17 +166,22 @@ const AddTransactionButton = () => {
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Dialog open={showExpenseDialog} onOpenChange={(open) => {
|
||||
if (!isSubmitting) setShowExpenseDialog(open);
|
||||
}}>
|
||||
|
||||
<Dialog
|
||||
open={showExpenseDialog}
|
||||
onOpenChange={(open) => {
|
||||
if (!isSubmitting) {
|
||||
setShowExpenseDialog(open);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="w-[90%] max-w-sm mx-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>지출 입력</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ExpenseForm
|
||||
onSubmit={onSubmit}
|
||||
onCancel={() => !isSubmitting && setShowExpenseDialog(false)}
|
||||
<ExpenseForm
|
||||
onSubmit={onSubmit}
|
||||
onCancel={() => !isSubmitting && setShowExpenseDialog(false)}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
</DialogContent>
|
||||
|
||||
@@ -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<AppVersionInfoProps> = ({
|
||||
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<VersionInfo>(hardcodedVersionInfo);
|
||||
const [versionInfo, setVersionInfo] =
|
||||
useState<VersionInfo>(hardcodedVersionInfo);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 개발자 정보 표시
|
||||
@@ -46,9 +46,13 @@ const AppVersionInfo: React.FC<AppVersionInfoProps> = ({
|
||||
return (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<p>
|
||||
{versionInfo.versionCode ? `버전 코드: ${versionInfo.versionCode}` : ''}
|
||||
{versionInfo.buildNumber ? `, 빌드: ${versionInfo.buildNumber}` : ''}
|
||||
{versionInfo.platform ? ` (${versionInfo.platform})` : ''}
|
||||
{versionInfo.versionCode
|
||||
? `버전 코드: ${versionInfo.versionCode}`
|
||||
: ""}
|
||||
{versionInfo.buildNumber
|
||||
? `, 빌드: ${versionInfo.buildNumber}`
|
||||
: ""}
|
||||
{versionInfo.platform ? ` (${versionInfo.platform})` : ""}
|
||||
</p>
|
||||
{versionInfo.errorMessage && (
|
||||
<p className="text-destructive">오류: {versionInfo.errorMessage}</p>
|
||||
@@ -64,11 +68,9 @@ const AppVersionInfo: React.FC<AppVersionInfoProps> = ({
|
||||
<div className="flex flex-col space-y-1">
|
||||
<Label className="text-base">버전 정보</Label>
|
||||
<p className="text-sm">
|
||||
{loading ? (
|
||||
"버전 정보 로딩 중..."
|
||||
) : (
|
||||
versionInfo.versionName || "알 수 없음"
|
||||
)}
|
||||
{loading
|
||||
? "버전 정보 로딩 중..."
|
||||
: versionInfo.versionName || "알 수 없음"}
|
||||
</p>
|
||||
{renderDevInfo()}
|
||||
</div>
|
||||
|
||||
@@ -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<AvatarImageViewProps> = ({
|
||||
className = "h-12 w-12",
|
||||
fallback = "ZY"
|
||||
fallback = "ZY",
|
||||
}) => {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const [imageSrc, setImageSrc] = useState<string>('/zellyy.png');
|
||||
const [imageSrc, setImageSrc] = useState<string>("/zellyy.png");
|
||||
|
||||
useEffect(() => {
|
||||
const loadImage = async () => {
|
||||
@@ -22,40 +23,41 @@ const AvatarImageView: React.FC<AvatarImageViewProps> = ({
|
||||
// 플랫폼 체크
|
||||
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<AvatarImageViewProps> = ({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<img
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt="Zellyy"
|
||||
className="h-full w-full object-cover"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { categoryIcons } from '@/constants/categoryIcons';
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { categoryIcons } from "@/constants/categoryIcons";
|
||||
|
||||
interface BudgetCardProps {
|
||||
title: string;
|
||||
@@ -10,54 +9,50 @@ interface BudgetCardProps {
|
||||
color?: string;
|
||||
}
|
||||
|
||||
const BudgetCard: React.FC<BudgetCardProps> = ({
|
||||
title,
|
||||
current,
|
||||
const BudgetCard: React.FC<BudgetCardProps> = ({
|
||||
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 (
|
||||
<div className="neuro-card">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="text-neuro-income">
|
||||
{categoryIcons[title]}
|
||||
</div>
|
||||
<div className="text-neuro-income">{categoryIcons[title]}</div>
|
||||
<h3 className="text-sm font-medium text-gray-600">{title}</h3>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-lg font-semibold">{formattedCurrent}</p>
|
||||
<p className="text-sm text-gray-500">/ {formattedTotal}</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="relative h-3 neuro-pressed overflow-hidden">
|
||||
<div
|
||||
<div
|
||||
className={`absolute top-0 left-0 h-full transition-all duration-700 ease-out ${progressBarColor}`}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="mt-2 flex justify-end">
|
||||
<span className="text-xs font-medium text-gray-500">
|
||||
{percentage}%
|
||||
</span>
|
||||
<span className="text-xs font-medium text-gray-500">{percentage}%</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<BudgetCategoriesSectionProps> = ({
|
||||
categories
|
||||
categories,
|
||||
}) => {
|
||||
return <>
|
||||
<h2 className="font-semibold mb-3 mt-8 text-lg">지출 그래프</h2>
|
||||
<div className="neuro-card mb-8">
|
||||
{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 (
|
||||
<>
|
||||
<h2 className="font-semibold mb-3 mt-8 text-lg">지출 그래프</h2>
|
||||
<div className="neuro-card mb-8">
|
||||
{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 <div key={index} className={`${index !== 0 ? 'mt-4 pt-4 border-t border-gray-100' : ''}`}>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="text-neuro-income">
|
||||
{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 (
|
||||
<div
|
||||
key={index}
|
||||
className={`${index !== 0 ? "mt-4 pt-4 border-t border-gray-100" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="text-neuro-income">
|
||||
{categoryIcons[category.title]}
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-gray-600">
|
||||
{category.title}
|
||||
{description && (
|
||||
<span className="text-gray-500 text-xs ml-1">
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="font-semibold text-base">
|
||||
{formatCurrency(category.current)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
/ {formatCurrency(category.total)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative h-3 neuro-pressed overflow-hidden">
|
||||
<div
|
||||
className={`absolute top-0 left-0 h-full transition-all duration-700 ease-out ${progressBarColor}`}
|
||||
style={{
|
||||
width: `${Math.min(displayPercentage, 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex justify-between items-center">
|
||||
<span
|
||||
className={`text-xs font-medium ${isOverBudget ? "text-red-500" : "text-neuro-income"}`}
|
||||
>
|
||||
{budgetStatusText}
|
||||
{formatCurrency(budgetAmount)}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-gray-500">
|
||||
{displayPercentage}%
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-gray-600">
|
||||
{category.title}
|
||||
{description && (
|
||||
<span className="text-gray-500 text-xs ml-1">
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="font-semibold text-base">{formatCurrency(category.current)}</p>
|
||||
<p className="text-sm text-gray-500">/ {formatCurrency(category.total)}</p>
|
||||
</div>
|
||||
|
||||
<div className="relative h-3 neuro-pressed overflow-hidden">
|
||||
<div className={`absolute top-0 left-0 h-full transition-all duration-700 ease-out ${progressBarColor}`} style={{
|
||||
width: `${Math.min(displayPercentage, 100)}%`
|
||||
}} />
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex justify-between items-center">
|
||||
<span className={`text-xs font-medium ${isOverBudget ? 'text-red-500' : 'text-neuro-income'}`}>
|
||||
{budgetStatusText}{formatCurrency(budgetAmount)}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-gray-500">
|
||||
{displayPercentage}%
|
||||
</span>
|
||||
</div>
|
||||
</div>;
|
||||
})}
|
||||
</div>
|
||||
</>;
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default BudgetCategoriesSection;
|
||||
|
||||
@@ -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<BudgetGoalProps> = ({
|
||||
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 (
|
||||
<Collapsible
|
||||
open={isOpen}
|
||||
<Collapsible
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
className={`neuro-card ${highlight ? 'border-2 border-neuro-income shadow-lg' : ''}`}
|
||||
className={`neuro-card ${highlight ? "border-2 border-neuro-income shadow-lg" : ""}`}
|
||||
>
|
||||
<CollapsibleTrigger className="flex items-center justify-between w-full p-4">
|
||||
<span className={`text-sm font-medium flex items-center ${highlight ? 'text-neuro-income' : ''}`}>
|
||||
<span
|
||||
className={`text-sm font-medium flex items-center ${highlight ? "text-neuro-income" : ""}`}
|
||||
>
|
||||
{highlight && <Wallet size={18} className="mr-2 animate-pulse" />}
|
||||
월간 예산 설정하기
|
||||
</span>
|
||||
@@ -100,17 +110,21 @@ const BudgetInputCard: React.FC<BudgetGoalProps> = ({
|
||||
<ChevronDown size={18} className="text-gray-500" />
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
|
||||
|
||||
<CollapsibleContent className="px-4 pb-4">
|
||||
<div className="space-y-4 mt-0">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Input
|
||||
<Input
|
||||
value={getDisplayValue()}
|
||||
onChange={e => handleInputChange(e.target.value)}
|
||||
placeholder="목표 금액 입력"
|
||||
className="neuro-pressed"
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
placeholder="목표 금액 입력"
|
||||
className="neuro-pressed"
|
||||
/>
|
||||
<Button onClick={handleSave} size="icon" className={`neuro-flat ${highlight ? 'bg-neuro-income hover:bg-neuro-income/90' : 'bg-slate-400 hover:bg-slate-300'} text-white`}>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
size="icon"
|
||||
className={`neuro-flat ${highlight ? "bg-neuro-income hover:bg-neuro-income/90" : "bg-slate-400 hover:bg-slate-300"} text-white`}
|
||||
>
|
||||
<Check size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
|
||||
import React from 'react';
|
||||
import React from "react";
|
||||
|
||||
interface BudgetProgressProps {
|
||||
spentAmount: number;
|
||||
@@ -12,25 +11,27 @@ const BudgetProgress: React.FC<BudgetProgressProps> = ({
|
||||
spentAmount,
|
||||
targetAmount,
|
||||
percentage,
|
||||
formatCurrency
|
||||
formatCurrency,
|
||||
}) => {
|
||||
// NaN 값을 방지하기 위해 percentage가 숫자가 아닌 경우 0으로 표시
|
||||
const displayPercentage = isNaN(percentage) ? 0 : percentage;
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-lg font-semibold">{formatCurrency(spentAmount)}</p>
|
||||
<p className="text-sm text-gray-500">/ {formatCurrency(targetAmount)}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
/ {formatCurrency(targetAmount)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="relative h-3 neuro-pressed overflow-hidden mt-2">
|
||||
<div
|
||||
style={{ width: `${displayPercentage}%` }}
|
||||
className={`absolute top-0 left-0 h-full transition-all duration-700 ease-out ${displayPercentage >= 90 ? "bg-yellow-400" : "bg-neuro-income"}`}
|
||||
<div
|
||||
style={{ width: `${displayPercentage}%` }}
|
||||
className={`absolute top-0 left-0 h-full transition-all duration-700 ease-out ${displayPercentage >= 90 ? "bg-yellow-400" : "bg-neuro-income"}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="mt-2 flex justify-end">
|
||||
<span className="text-xs font-medium text-gray-500">
|
||||
{displayPercentage}%
|
||||
|
||||
@@ -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<string, number>) => void;
|
||||
onSaveBudget: (
|
||||
type: BudgetPeriod,
|
||||
amount: number,
|
||||
newCategoryBudgets?: Record<string, number>
|
||||
) => void;
|
||||
}
|
||||
|
||||
const BudgetProgressCard: React.FC<BudgetProgressCardProps> = ({
|
||||
@@ -18,57 +22,63 @@ const BudgetProgressCard: React.FC<BudgetProgressCardProps> = ({
|
||||
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 (
|
||||
<div className="neuro-card mb-6 overflow-hidden w-full">
|
||||
<div className="text-sm text-gray-600 mb-2 px-3 pt-3">지출 / 예산</div>
|
||||
|
||||
<BudgetTabContent
|
||||
data={budgetData.monthly}
|
||||
formatCurrency={formatCurrency}
|
||||
calculatePercentage={calculatePercentage}
|
||||
onSaveBudget={(amount, categoryBudgets) => onSaveBudget('monthly', amount, categoryBudgets)}
|
||||
|
||||
<BudgetTabContent
|
||||
data={budgetData.monthly}
|
||||
formatCurrency={formatCurrency}
|
||||
calculatePercentage={calculatePercentage}
|
||||
onSaveBudget={(amount, categoryBudgets) =>
|
||||
onSaveBudget("monthly", amount, categoryBudgets)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<string, number>) => void;
|
||||
onSaveBudget: (
|
||||
amount: number,
|
||||
categoryBudgets?: Record<string, number>
|
||||
) => void;
|
||||
}
|
||||
|
||||
const BudgetTabContent: React.FC<BudgetTabContentProps> = ({
|
||||
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<BudgetTabContentProps> = ({
|
||||
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<BudgetTabContentProps> = ({
|
||||
|
||||
// 월간 예산 모드 로깅
|
||||
React.useEffect(() => {
|
||||
console.log('BudgetTabContent 렌더링: 월간 예산');
|
||||
console.log('현재 예산 데이터:', data);
|
||||
logger.info("BudgetTabContent 렌더링: 월간 예산");
|
||||
logger.info("현재 예산 데이터:", data);
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<div className="px-3 pb-3">
|
||||
{isBudgetSet ? (
|
||||
<>
|
||||
<BudgetHeader
|
||||
spentAmount={data.spentAmount}
|
||||
<BudgetHeader
|
||||
spentAmount={data.spentAmount}
|
||||
targetAmount={data.targetAmount}
|
||||
formatCurrency={formatCurrency}
|
||||
/>
|
||||
|
||||
<BudgetProgressBar
|
||||
percentage={percentage}
|
||||
progressBarColor={progressBarColor}
|
||||
<BudgetProgressBar
|
||||
percentage={percentage}
|
||||
progressBarColor={progressBarColor}
|
||||
/>
|
||||
|
||||
<BudgetStatusDisplay
|
||||
<BudgetStatusDisplay
|
||||
budgetStatusText={budgetStatusText}
|
||||
budgetAmount={budgetAmount}
|
||||
actualPercentage={actualPercentage}
|
||||
@@ -96,13 +99,13 @@ const BudgetTabContent: React.FC<BudgetTabContentProps> = ({
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<BudgetInputButton
|
||||
<BudgetInputButton
|
||||
isBudgetSet={isBudgetSet}
|
||||
budgetButtonText={budgetButtonText}
|
||||
toggleBudgetInput={handleOpenDialog}
|
||||
/>
|
||||
|
||||
<BudgetDialog
|
||||
<BudgetDialog
|
||||
open={showBudgetDialog}
|
||||
onOpenChange={setShowBudgetDialog}
|
||||
categoryBudgets={categoryBudgets}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { EXPENSE_CATEGORIES, categoryIcons } from '@/constants/categoryIcons';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { markSingleCategoryBudgetAsModified } from '@/utils/sync/budget/modifiedBudgetsTracker';
|
||||
import React from "react";
|
||||
import { logger } from "@/utils/logger";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { EXPENSE_CATEGORIES, categoryIcons } from "@/constants/categoryIcons";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { markSingleCategoryBudgetAsModified } from "@/utils/sync/budget/modifiedBudgetsTracker";
|
||||
|
||||
interface CategoryBudgetInputsProps {
|
||||
categoryBudgets: Record<string, number>;
|
||||
@@ -12,51 +12,63 @@ interface CategoryBudgetInputsProps {
|
||||
|
||||
const CategoryBudgetInputs: React.FC<CategoryBudgetInputsProps> = ({
|
||||
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<HTMLInputElement>, category: string) => {
|
||||
const handleInput = (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
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 (
|
||||
<div className="space-y-3 w-full">
|
||||
{EXPENSE_CATEGORIES.map(category => (
|
||||
<div key={category} className="flex items-center justify-between w-full p-2 rounded-lg">
|
||||
{EXPENSE_CATEGORIES.map((category) => (
|
||||
<div
|
||||
key={category}
|
||||
className="flex items-center justify-between w-full p-2 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-neuro-income">{categoryIcons[category]}</span>
|
||||
<label className="text-sm font-medium text-gray-700">{category}</label>
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
{category}
|
||||
</label>
|
||||
</div>
|
||||
<Input
|
||||
value={formatWithCommas(categoryBudgets[category] || 0)}
|
||||
<Input
|
||||
value={formatWithCommas(categoryBudgets[category] || 0)}
|
||||
onChange={(e) => 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]"}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -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<ExpenseChartProps> = ({ data }) => {
|
||||
paddingAngle={5}
|
||||
dataKey="value"
|
||||
labelLine={false}
|
||||
label={({ name, percent }) => (
|
||||
label={({ name, percent }) =>
|
||||
`${name} ${(percent * 100).toFixed(0)}%`
|
||||
)}
|
||||
}
|
||||
fontSize={12}
|
||||
>
|
||||
{data.map((entry, index) => (
|
||||
|
||||
@@ -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 (
|
||||
<header className={headerClass}>
|
||||
@@ -61,26 +60,28 @@ const Header: React.FC = () => {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<AvatarImage
|
||||
src="/zellyy.png"
|
||||
alt="Zellyy"
|
||||
className={imageLoaded ? 'opacity-100' : 'opacity-0'}
|
||||
onLoad={() => setImageLoaded(true)}
|
||||
onError={() => setImageError(true)}
|
||||
<AvatarImage
|
||||
src="/zellyy.png"
|
||||
alt="Zellyy"
|
||||
className={imageLoaded ? "opacity-100" : "opacity-0"}
|
||||
onLoad={() => setImageLoaded(true)}
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
{(imageError || !imageLoaded) && <AvatarFallback delayMs={100}>ZY</AvatarFallback>}
|
||||
{(imageError || !imageLoaded) && (
|
||||
<AvatarFallback delayMs={100}>ZY</AvatarFallback>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Avatar>
|
||||
<div>
|
||||
<h1 className="font-bold neuro-text text-xl">
|
||||
{user ? `${userName}님, 반갑습니다` : '반갑습니다'}
|
||||
{user ? `${userName}님, 반갑습니다` : "반갑습니다"}
|
||||
</h1>
|
||||
<p className="text-gray-500 text-left">젤리의 적자탈출</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="neuro-flat p-2.5 rounded-full">
|
||||
<NotificationPopover
|
||||
<NotificationPopover
|
||||
notifications={notifications}
|
||||
onClearAll={clearAllNotifications}
|
||||
onReadNotification={markAsRead}
|
||||
|
||||
@@ -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 NativeImageProps {
|
||||
@@ -11,11 +12,11 @@ interface NativeImageProps {
|
||||
fallback?: string;
|
||||
}
|
||||
|
||||
const NativeImage: React.FC<NativeImageProps> = ({
|
||||
resourceName,
|
||||
const NativeImage: React.FC<NativeImageProps> = ({
|
||||
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<NativeImageProps> = ({
|
||||
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<NativeImageProps> = ({
|
||||
}
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
console.error('이미지 로드 오류:', err);
|
||||
logger.error("이미지 로드 오류:", err);
|
||||
setError(true);
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -57,16 +58,14 @@ const NativeImage: React.FC<NativeImageProps> = ({
|
||||
) : (
|
||||
<>
|
||||
{!error && (
|
||||
<img
|
||||
src={imageSrc}
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={alt}
|
||||
className="h-full w-full object-cover"
|
||||
className="h-full w-full object-cover"
|
||||
onError={() => setError(true)}
|
||||
/>
|
||||
)}
|
||||
{error && (
|
||||
<AvatarFallback delayMs={100}>{fallback}</AvatarFallback>
|
||||
)}
|
||||
{error && <AvatarFallback delayMs={100}>{fallback}</AvatarFallback>}
|
||||
</>
|
||||
)}
|
||||
</Avatar>
|
||||
|
||||
@@ -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 (
|
||||
<div className="fixed bottom-0 left-0 right-0 p-4 z-10 animate-slide-up">
|
||||
<div className="neuro-flat mx-auto max-w-[500px] flex justify-around items-center py-3 px-6">
|
||||
@@ -40,7 +56,7 @@ const NavBar = () => {
|
||||
item.isActive ? "text-neuro-income" : "text-gray-500"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
<div
|
||||
className={cn(
|
||||
"p-2 rounded-full transition-all duration-300",
|
||||
item.isActive ? "neuro-pressed" : "neuro-flat"
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React from 'react';
|
||||
import { Transaction } from '@/contexts/budget/types';
|
||||
import TransactionEditDialog from './TransactionEditDialog';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { useBudget } from '@/contexts/budget/BudgetContext';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useRecentTransactions } from '@/hooks/transactions/useRecentTransactions';
|
||||
import { useRecentTransactionsDialog } from '@/hooks/transactions/useRecentTransactionsDialog';
|
||||
import RecentTransactionItem from './recent-transactions/RecentTransactionItem';
|
||||
import React from "react";
|
||||
import { Transaction } from "@/contexts/budget/types";
|
||||
import TransactionEditDialog from "./TransactionEditDialog";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { useBudget } from "@/contexts/budget/BudgetContext";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useRecentTransactions } from "@/hooks/transactions/useRecentTransactions";
|
||||
import { useRecentTransactionsDialog } from "@/hooks/transactions/useRecentTransactionsDialog";
|
||||
import RecentTransactionItem from "./recent-transactions/RecentTransactionItem";
|
||||
|
||||
interface RecentTransactionsSectionProps {
|
||||
transactions: Transaction[];
|
||||
@@ -15,19 +15,20 @@ interface RecentTransactionsSectionProps {
|
||||
|
||||
const RecentTransactionsSection: React.FC<RecentTransactionsSectionProps> = ({
|
||||
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<RecentTransactionsSectionProps> = ({
|
||||
<div className="mt-4 mb-[50px]">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h2 className="text-lg font-semibold">최근 지출</h2>
|
||||
<Link to="/transactions" className="text-sm text-neuro-income flex items-center">
|
||||
<Link
|
||||
to="/transactions"
|
||||
className="text-sm text-neuro-income flex items-center"
|
||||
>
|
||||
더보기 <ChevronRight size={16} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="neuro-card divide-y divide-gray-100 w-full">
|
||||
{transactions.length > 0 ? (
|
||||
transactions.map(transaction => (
|
||||
transactions.map((transaction) => (
|
||||
<RecentTransactionItem
|
||||
key={transaction.id}
|
||||
transaction={transaction}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { getResourceImage } from '@/plugins/imagePlugin';
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { logger } from "@/utils/logger";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { getResourceImage } from "@/plugins/imagePlugin";
|
||||
|
||||
interface ResourceImageProps {
|
||||
resourceName: string;
|
||||
@@ -14,7 +15,7 @@ const ResourceImage: React.FC<ResourceImageProps> = ({
|
||||
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<ResourceImageProps> = ({
|
||||
setImageSrc(imgSrc);
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
console.error('이미지 로드 실패:', err);
|
||||
logger.error("이미지 로드 실패:", err);
|
||||
setError(true);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -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<SafeAreaContainerProps> = ({
|
||||
children,
|
||||
className = '',
|
||||
extraBottomPadding = false
|
||||
className = "",
|
||||
extraBottomPadding = false,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'min-h-screen bg-neuro-background',
|
||||
'pt-safe pb-safe pl-safe pr-safe', // iOS 안전 영역 적용
|
||||
extraBottomPadding ? 'pb-24' : '',
|
||||
"min-h-screen bg-neuro-background",
|
||||
"pt-safe pb-safe pl-safe pr-safe", // iOS 안전 영역 적용
|
||||
extraBottomPadding ? "pb-24" : "",
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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<SimpleAvatarProps> = ({
|
||||
src,
|
||||
name,
|
||||
size = 'md',
|
||||
className = ''
|
||||
const SimpleAvatar: React.FC<SimpleAvatarProps> = ({
|
||||
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 (
|
||||
<div className={`flex items-center justify-center rounded-full bg-neuro-income text-white ${sizeClasses[size]} ${className}`}>
|
||||
<div
|
||||
className={`flex items-center justify-center rounded-full bg-neuro-income text-white ${sizeClasses[size]} ${className}`}
|
||||
>
|
||||
{src ? (
|
||||
<img src={src} alt={name} className="w-full h-full rounded-full object-cover" />
|
||||
<img
|
||||
src={src}
|
||||
alt={name}
|
||||
className="w-full h-full rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span>{initials}</span>
|
||||
)}
|
||||
|
||||
@@ -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} // 사용자가 로그아웃 상태이면서 동기화가 켜져있을 때 비활성화
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
{/* 동기화 상태 및 동작 */}
|
||||
<SyncStatus
|
||||
<SyncStatus
|
||||
enabled={enabled}
|
||||
syncing={syncing}
|
||||
lastSync={lastSync}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import TransactionEditDialog from './TransactionEditDialog';
|
||||
import TransactionIcon from './transaction/TransactionIcon';
|
||||
import TransactionDetails from './transaction/TransactionDetails';
|
||||
import TransactionAmount from './transaction/TransactionAmount';
|
||||
import { Transaction } from '@/contexts/budget/types';
|
||||
import React, { useState } from "react";
|
||||
import { logger } from "@/utils/logger";
|
||||
import { cn } from "@/lib/utils";
|
||||
import TransactionEditDialog from "./TransactionEditDialog";
|
||||
import TransactionIcon from "./transaction/TransactionIcon";
|
||||
import TransactionDetails from "./transaction/TransactionDetails";
|
||||
import TransactionAmount from "./transaction/TransactionAmount";
|
||||
import { Transaction } from "@/contexts/budget/types";
|
||||
|
||||
interface TransactionCardProps {
|
||||
transaction: Transaction;
|
||||
@@ -13,30 +13,30 @@ interface TransactionCardProps {
|
||||
onDelete?: (id: string) => Promise<boolean> | boolean; // 타입 변경됨: boolean 또는 Promise<boolean> 반환
|
||||
}
|
||||
|
||||
const TransactionCard: React.FC<TransactionCardProps> = ({
|
||||
const TransactionCard: React.FC<TransactionCardProps> = ({
|
||||
transaction,
|
||||
onDelete,
|
||||
onDelete,
|
||||
}) => {
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
const { title, amount, date, category } = transaction;
|
||||
|
||||
|
||||
// 삭제 핸들러 - 인자로 받은 onDelete가 없거나 타입이 맞지 않을 때 기본 함수 제공
|
||||
const handleDelete = async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
if (onDelete) {
|
||||
return await onDelete(id);
|
||||
}
|
||||
console.log('삭제 핸들러가 제공되지 않았습니다');
|
||||
logger.info("삭제 핸들러가 제공되지 않았습니다");
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('트랜잭션 삭제 처리 중 오류:', error);
|
||||
logger.error("트랜잭션 삭제 처리 중 오류:", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
<div
|
||||
className="neuro-flat p-4 transition-all duration-300 hover:shadow-neuro-convex animate-scale-in cursor-pointer"
|
||||
onClick={() => setIsEditDialogOpen(true)}
|
||||
>
|
||||
@@ -45,12 +45,12 @@ const TransactionCard: React.FC<TransactionCardProps> = ({
|
||||
<TransactionIcon category={category} />
|
||||
<TransactionDetails title={title} date={date} />
|
||||
</div>
|
||||
|
||||
|
||||
<TransactionAmount amount={amount} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TransactionEditDialog
|
||||
<TransactionEditDialog
|
||||
transaction={transaction}
|
||||
open={isEditDialogOpen}
|
||||
onOpenChange={setIsEditDialogOpen}
|
||||
@@ -62,4 +62,4 @@ const TransactionCard: React.FC<TransactionCardProps> = ({
|
||||
|
||||
export default TransactionCard;
|
||||
// Transaction 타입을 context에서 직접 다시 내보냅니다
|
||||
export type { Transaction } from '@/contexts/budget/types';
|
||||
export type { Transaction } from "@/contexts/budget/types";
|
||||
|
||||
@@ -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<TransactionEditDialogProps> = ({
|
||||
transaction,
|
||||
open,
|
||||
onOpenChange,
|
||||
onDelete
|
||||
onDelete,
|
||||
}) => {
|
||||
const isMobile = useIsMobile();
|
||||
const closeDialog = () => onOpenChange(false);
|
||||
|
||||
|
||||
// useTransactionEdit 훅 사용 - 인자를 2개만 전달
|
||||
const { form, isSubmitting, handleSubmit, handleDelete } = useTransactionEdit(
|
||||
transaction,
|
||||
closeDialog
|
||||
);
|
||||
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(newOpen) => {
|
||||
// 제출 중이면 닫기 방지
|
||||
if (isSubmitting && !newOpen) return;
|
||||
onOpenChange(newOpen);
|
||||
}}>
|
||||
<DialogContent className={`sm:max-w-md mx-auto bg-white ${isMobile ? 'rounded-xl overflow-hidden' : ''}`}>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(newOpen) => {
|
||||
// 제출 중이면 닫기 방지
|
||||
if (isSubmitting && !newOpen) {
|
||||
return;
|
||||
}
|
||||
onOpenChange(newOpen);
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className={`sm:max-w-md mx-auto bg-white ${isMobile ? "rounded-xl overflow-hidden" : ""}`}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>지출 수정</DialogTitle>
|
||||
<DialogDescription>
|
||||
지출 내역을 수정하거나 삭제할 수 있습니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
||||
<TransactionEditForm
|
||||
form={form}
|
||||
onSubmit={handleSubmit}
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
|
||||
/**
|
||||
* 젤리 아바타 컴포넌트
|
||||
* 웹과 앱 환경 모두에서 올바르게 표시되는 아바타 컴포넌트
|
||||
*/
|
||||
const ZellyAvatar: React.FC<{ className?: string }> = ({ 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<string>('/zellyy.png');
|
||||
const [imageSrc, setImageSrc] = useState<string>("/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
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<AvatarImage
|
||||
<AvatarImage
|
||||
src={imageSrc}
|
||||
alt="Zellyy"
|
||||
className={imageLoaded ? 'opacity-100' : 'opacity-0'}
|
||||
alt="Zellyy"
|
||||
className={imageLoaded ? "opacity-100" : "opacity-0"}
|
||||
onLoad={() => setImageLoaded(true)}
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
|
||||
@@ -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<CategorySpendingListProps> = ({
|
||||
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 (
|
||||
<div className="space-y-2 px-1 py-2">
|
||||
{categories.map((category) => {
|
||||
// 카테고리 이름을 직접 표시
|
||||
const categoryName = category.title;
|
||||
// 카테고리 설명 찾기
|
||||
const description = CATEGORY_DESCRIPTIONS[categoryName] || '';
|
||||
|
||||
const description = CATEGORY_DESCRIPTIONS[categoryName] || "";
|
||||
|
||||
return (
|
||||
<div key={categoryName} className="flex items-center justify-between">
|
||||
<div
|
||||
key={categoryName}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full" style={{
|
||||
backgroundColor: getCategoryColor(categoryName) // 일관된 색상 적용
|
||||
}}></div>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{
|
||||
backgroundColor: getCategoryColor(categoryName), // 일관된 색상 적용
|
||||
}}
|
||||
></div>
|
||||
<span className="text-xs">
|
||||
{categoryName}
|
||||
{description && (
|
||||
@@ -79,7 +84,7 @@ const CategorySpendingList: React.FC<CategorySpendingListProps> = ({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// 카드 없이 목록만 반환
|
||||
return renderCategoryList();
|
||||
};
|
||||
|
||||
@@ -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<MonthlyComparisonChartProps> = ({
|
||||
monthlyData,
|
||||
isEmpty = false
|
||||
isEmpty = false,
|
||||
}) => {
|
||||
// Format for Y-axis (K format)
|
||||
const formatYAxisTick = (value: number) => {
|
||||
@@ -21,31 +27,42 @@ const MonthlyComparisonChart: React.FC<MonthlyComparisonChartProps> = ({
|
||||
|
||||
// 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 = () => <div className="flex flex-col items-center justify-center h-48 text-gray-400">
|
||||
const EmptyGraphState = () => (
|
||||
<div className="flex flex-col items-center justify-center h-48 text-gray-400">
|
||||
<p>데이터가 없습니다</p>
|
||||
<p className="text-sm mt-2">지출 내역을 추가하면 그래프가 표시됩니다</p>
|
||||
</div>;
|
||||
</div>
|
||||
);
|
||||
|
||||
// 데이터 여부 확인 로직 개선 - 데이터가 비어있거나 모든 값이 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<MonthlyComparisonChartProps> = ({
|
||||
// 예산 색상을 좀 더 짙은 회색으로 변경
|
||||
const darkGrayColor = "#9F9EA1"; // 이전 색상 #C8C8C9에서 더 짙은 회색으로 변경
|
||||
|
||||
return <div className="neuro-card h-72 w-full">
|
||||
{hasValidData ? <ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={monthlyData} margin={{
|
||||
top: 20,
|
||||
right: 10,
|
||||
left: -10,
|
||||
bottom: 5
|
||||
}} style={{
|
||||
fontSize: '11px'
|
||||
}}>
|
||||
return (
|
||||
<div className="neuro-card h-72 w-full">
|
||||
{hasValidData ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={monthlyData}
|
||||
margin={{
|
||||
top: 20,
|
||||
right: 10,
|
||||
left: -10,
|
||||
bottom: 5,
|
||||
}}
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
}}
|
||||
>
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis tickFormatter={formatYAxisTick} />
|
||||
<Tooltip formatter={formatTooltip} contentStyle={{
|
||||
backgroundColor: 'white',
|
||||
border: 'none'
|
||||
}} cursor={{
|
||||
fill: 'transparent'
|
||||
}} />
|
||||
<Legend formatter={value => {
|
||||
// 범례 텍스트 색상 설정
|
||||
return <span style={{
|
||||
color: value === '지출' ? mainGreenColor : undefined
|
||||
}} className="text-sm">{value}</span>;
|
||||
}} />
|
||||
<Bar dataKey="budget" name="예산" fill={darkGrayColor} radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="expense" name="지출" fill={mainGreenColor} radius={[4, 4, 0, 0]}>
|
||||
<Tooltip
|
||||
formatter={formatTooltip}
|
||||
contentStyle={{
|
||||
backgroundColor: "white",
|
||||
border: "none",
|
||||
}}
|
||||
cursor={{
|
||||
fill: "transparent",
|
||||
}}
|
||||
/>
|
||||
<Legend
|
||||
formatter={(value) => {
|
||||
// 범례 텍스트 색상 설정
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
color: value === "지출" ? mainGreenColor : undefined,
|
||||
}}
|
||||
className="text-sm"
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="budget"
|
||||
name="예산"
|
||||
fill={darkGrayColor}
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="expense"
|
||||
name="지출"
|
||||
fill={mainGreenColor}
|
||||
radius={[4, 4, 0, 0]}
|
||||
>
|
||||
{/* 개별 셀 색상 설정은 제거하고 통일된 메인 그린 색상 사용 */}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer> : <EmptyGraphState />}
|
||||
</div>;
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<EmptyGraphState />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default MonthlyComparisonChart;
|
||||
export default MonthlyComparisonChart;
|
||||
|
||||
@@ -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<PaymentMethodChartProps> = ({ data, isEmpty }) => {
|
||||
const PaymentMethodChart: React.FC<PaymentMethodChartProps> = ({
|
||||
data,
|
||||
isEmpty,
|
||||
}) => {
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<div className="neuro-card h-52 w-full flex items-center justify-center text-gray-400">
|
||||
@@ -24,9 +21,9 @@ const PaymentMethodChart: React.FC<PaymentMethodChartProps> = ({ 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<PaymentMethodChartProps> = ({ 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) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={COLORS[index % COLORS.length]}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
{/* Legend 제거 */}
|
||||
|
||||
@@ -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<PeriodSelectorProps> = ({
|
||||
selectedPeriod,
|
||||
onPrevPeriod,
|
||||
onNextPeriod
|
||||
onNextPeriod,
|
||||
}) => {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-6 w-full">
|
||||
<button
|
||||
className="neuro-flat p-2 rounded-full"
|
||||
onClick={onPrevPeriod}
|
||||
>
|
||||
<button className="neuro-flat p-2 rounded-full" onClick={onPrevPeriod}>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
|
||||
|
||||
<div className="flex items-center">
|
||||
<span className="font-medium text-lg">{selectedPeriod}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="neuro-flat p-2 rounded-full"
|
||||
onClick={onNextPeriod}
|
||||
>
|
||||
|
||||
<button className="neuro-flat p-2 rounded-full" onClick={onNextPeriod}>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -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<SummaryCardsProps> = ({
|
||||
totalBudget,
|
||||
totalExpense,
|
||||
savingsPercentage
|
||||
savingsPercentage,
|
||||
}) => {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// 남은 예산 계산
|
||||
const remainingBudget = totalBudget - totalExpense;
|
||||
const isOverBudget = remainingBudget < 0;
|
||||
return <div className={`grid ${isMobile ? 'grid-cols-1' : 'grid-cols-3'} gap-3 mb-8 w-full desktop-card`}>
|
||||
return (
|
||||
<div
|
||||
className={`grid ${isMobile ? "grid-cols-1" : "grid-cols-3"} gap-3 mb-8 w-full desktop-card`}
|
||||
>
|
||||
<div className="neuro-card w-full">
|
||||
{isMobile ?
|
||||
// 모바일 레이아웃 (1줄: 아이콘, 제목, 금액 가로배치)
|
||||
<div className="flex items-center justify-between px-3 py-[5px]">
|
||||
{isMobile ? (
|
||||
// 모바일 레이아웃 (1줄: 아이콘, 제목, 금액 가로배치)
|
||||
<div className="flex items-center justify-between px-3 py-[5px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Wallet size={24} className="text-gray-500" />
|
||||
<p className="text-gray-500 text-base">예산</p>
|
||||
@@ -29,9 +32,10 @@ const SummaryCards: React.FC<SummaryCardsProps> = ({
|
||||
<p className="text-sm font-bold text-neuro-income">
|
||||
{formatCurrency(totalBudget)}
|
||||
</p>
|
||||
</div> :
|
||||
// 데스크탑 레이아웃 (2줄: 아이콘과 제목이 첫째 줄, 금액이 둘째 줄)
|
||||
<>
|
||||
</div>
|
||||
) : (
|
||||
// 데스크탑 레이아웃 (2줄: 아이콘과 제목이 첫째 줄, 금액이 둘째 줄)
|
||||
<>
|
||||
<div className="flex items-center justify-center gap-2 py-[5px]">
|
||||
<Wallet size={24} className="text-gray-500" />
|
||||
<p className="text-gray-500 text-base">예산</p>
|
||||
@@ -39,12 +43,13 @@ const SummaryCards: React.FC<SummaryCardsProps> = ({
|
||||
<p className="font-bold text-neuro-income text-center mt-3 text-xs">
|
||||
{formatCurrency(totalBudget)}
|
||||
</p>
|
||||
</>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="neuro-card w-full">
|
||||
{isMobile ?
|
||||
// 모바일 레이아웃 (1줄: 아이콘, 제목, 금액 가로배치)
|
||||
<div className="flex items-center justify-between px-3 py-[5px]">
|
||||
{isMobile ? (
|
||||
// 모바일 레이아웃 (1줄: 아이콘, 제목, 금액 가로배치)
|
||||
<div className="flex items-center justify-between px-3 py-[5px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<CreditCard size={24} className="text-gray-500" />
|
||||
<p className="text-gray-500 font-medium text-base">지출</p>
|
||||
@@ -52,9 +57,10 @@ const SummaryCards: React.FC<SummaryCardsProps> = ({
|
||||
<p className="text-sm font-bold text-neuro-income">
|
||||
{formatCurrency(totalExpense)}
|
||||
</p>
|
||||
</div> :
|
||||
// 데스크탑 레이아웃 (2줄: 아이콘과 제목이 첫째 줄, 금액이 둘째 줄)
|
||||
<>
|
||||
</div>
|
||||
) : (
|
||||
// 데스크탑 레이아웃 (2줄: 아이콘과 제목이 첫째 줄, 금액이 둘째 줄)
|
||||
<>
|
||||
<div className="flex items-center justify-center gap-2 py-[5px]">
|
||||
<CreditCard size={24} className="text-gray-500" />
|
||||
<p className="text-gray-500 font-medium text-base">지출</p>
|
||||
@@ -62,35 +68,47 @@ const SummaryCards: React.FC<SummaryCardsProps> = ({
|
||||
<p className="font-bold text-neuro-income text-center mt-3 text-xs">
|
||||
{formatCurrency(totalExpense)}
|
||||
</p>
|
||||
</>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="neuro-card w-full">
|
||||
{isMobile ?
|
||||
// 모바일 레이아웃 (1줄: 아이콘, 제목, 금액 가로배치)
|
||||
<div className="flex items-center justify-between px-3 py-[5px]">
|
||||
{isMobile ? (
|
||||
// 모바일 레이아웃 (1줄: 아이콘, 제목, 금액 가로배치)
|
||||
<div className="flex items-center justify-between px-3 py-[5px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Coins size={24} className="text-gray-500" />
|
||||
<p className="text-gray-500 text-base">잔액</p>
|
||||
</div>
|
||||
{isOverBudget ? <p className="text-sm font-bold text-red-500">
|
||||
{isOverBudget ? (
|
||||
<p className="text-sm font-bold text-red-500">
|
||||
초과액: {formatCurrency(Math.abs(remainingBudget))}
|
||||
</p> : <p className="text-sm font-bold text-neuro-income">
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm font-bold text-neuro-income">
|
||||
{formatCurrency(remainingBudget)}
|
||||
</p>}
|
||||
</div> :
|
||||
// 데스크탑 레이아웃 (2줄: 아이콘과 제목이 첫째 줄, 금액이 둘째 줄)
|
||||
<>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// 데스크탑 레이아웃 (2줄: 아이콘과 제목이 첫째 줄, 금액이 둘째 줄)
|
||||
<>
|
||||
<div className="flex items-center justify-center gap-2 py-[5px]">
|
||||
<Coins size={24} className="text-gray-500" />
|
||||
<p className="text-gray-500 text-base">잔액</p>
|
||||
</div>
|
||||
{isOverBudget ? <p className="text-sm font-bold text-red-500 text-center mt-3">
|
||||
{isOverBudget ? (
|
||||
<p className="text-sm font-bold text-red-500 text-center mt-3">
|
||||
초과액: {formatCurrency(Math.abs(remainingBudget))}
|
||||
</p> : <p className="font-bold text-neuro-income text-center mt-3 text-xs">
|
||||
</p>
|
||||
) : (
|
||||
<p className="font-bold text-neuro-income text-center mt-3 text-xs">
|
||||
{formatCurrency(remainingBudget)}
|
||||
</p>}
|
||||
</>}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>;
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default SummaryCards;
|
||||
export default SummaryCards;
|
||||
|
||||
@@ -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 (
|
||||
<div className={`mt-2 p-3 rounded-md text-sm ${testResults.connected ? 'bg-green-50' : 'bg-red-50'}`}>
|
||||
<div
|
||||
className={`mt-2 p-3 rounded-md text-sm ${testResults.connected ? "bg-green-50" : "bg-red-50"}`}
|
||||
>
|
||||
<div className="flex items-start">
|
||||
{testResults.connected ? (
|
||||
<CheckCircle className="h-5 w-5 text-green-500 mr-2 flex-shrink-0 mt-0.5" />
|
||||
@@ -21,16 +27,20 @@ const AppwriteConnectionStatus = ({ testResults }: AppwriteConnectionStatusProps
|
||||
<XCircle className="h-5 w-5 text-red-500 mr-2 flex-shrink-0 mt-0.5" />
|
||||
)}
|
||||
<div>
|
||||
<p className={`font-medium ${testResults.connected ? 'text-green-800' : 'text-red-800'}`}>
|
||||
{testResults.connected ? '연결됨' : '연결 실패'}
|
||||
<p
|
||||
className={`font-medium ${testResults.connected ? "text-green-800" : "text-red-800"}`}
|
||||
>
|
||||
{testResults.connected ? "연결됨" : "연결 실패"}
|
||||
</p>
|
||||
<p className={testResults.connected ? 'text-green-700' : 'text-red-700'}>
|
||||
<p
|
||||
className={
|
||||
testResults.connected ? "text-green-700" : "text-red-700"
|
||||
}
|
||||
>
|
||||
{testResults.message}
|
||||
</p>
|
||||
{testResults.details && (
|
||||
<p className="text-gray-500 mt-1 text-xs">
|
||||
{testResults.details}
|
||||
</p>
|
||||
<p className="text-gray-500 mt-1 text-xs">{testResults.details}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<boolean>(false);
|
||||
|
||||
|
||||
// 데이터베이스 설정 상태
|
||||
const [dbSetupDone, setDbSetupDone] = useState<boolean>(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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={testConnection}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
연결 테스트
|
||||
</Button>
|
||||
|
||||
|
||||
{testResults?.connected && !dbSetupDone && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={setupDatabase}
|
||||
disabled={loading}
|
||||
>
|
||||
@@ -137,7 +143,7 @@ const AppwriteConnectionTest = () => {
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<AppwriteConnectionStatus testResults={testResults} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
const EmailConfirmation: React.FC<EmailConfirmationProps> = ({ email, onBackToForm, onResendEmail }) => {
|
||||
const EmailConfirmation: React.FC<EmailConfirmationProps> = ({
|
||||
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<EmailConfirmationProps> = ({ email, onBackToFo
|
||||
<strong>{email}</strong>로 인증 링크가 포함된 이메일을 보냈습니다.
|
||||
이메일을 확인하고 링크를 클릭하여 계정 등록을 완료해주세요.
|
||||
</p>
|
||||
|
||||
|
||||
<Alert className="bg-blue-50 border-blue-200 my-6">
|
||||
<InfoIcon className="h-5 w-5 text-blue-600" />
|
||||
<AlertTitle className="text-blue-700">인증 이메일이 보이지 않나요?</AlertTitle>
|
||||
<AlertTitle className="text-blue-700">
|
||||
인증 이메일이 보이지 않나요?
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-blue-600">
|
||||
스팸 폴더를 확인해보세요. 또한 이메일 서비스에 따라 도착하는데 몇 분이 걸릴 수 있습니다.
|
||||
스팸 폴더를 확인해보세요. 또한 이메일 서비스에 따라 도착하는데 몇
|
||||
분이 걸릴 수 있습니다.
|
||||
{onResendEmail && (
|
||||
<div className="mt-2">
|
||||
아직도 받지 못했다면 아래 '인증 메일 재전송' 버튼을 클릭하세요.
|
||||
@@ -52,12 +61,12 @@ const EmailConfirmation: React.FC<EmailConfirmationProps> = ({ email, onBackToFo
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
|
||||
<div className="space-y-4 pt-4">
|
||||
{onResendEmail && (
|
||||
<Button
|
||||
onClick={handleResendEmail}
|
||||
variant="secondary"
|
||||
<Button
|
||||
onClick={handleResendEmail}
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
disabled={isResending}
|
||||
>
|
||||
@@ -67,25 +76,19 @@ const EmailConfirmation: React.FC<EmailConfirmationProps> = ({ email, onBackToFo
|
||||
인증 메일 전송 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
인증 메일 재전송
|
||||
</>
|
||||
<>인증 메일 재전송</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={() => navigate("/login")}
|
||||
variant="outline"
|
||||
|
||||
<Button
|
||||
onClick={() => navigate("/login")}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
로그인 페이지로 이동
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onBackToForm}
|
||||
variant="ghost"
|
||||
className="w-full"
|
||||
>
|
||||
<Button onClick={onBackToForm} variant="ghost" className="w-full">
|
||||
회원가입 양식으로 돌아가기
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -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<LoginFormProps> = ({
|
||||
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 <div className="neuro-flat p-8 mb-6">
|
||||
const isCorsOrJsonError =
|
||||
loginError &&
|
||||
(loginError.includes("JSON") ||
|
||||
loginError.includes("CORS") ||
|
||||
loginError.includes("프록시") ||
|
||||
loginError.includes("서버 응답") ||
|
||||
loginError.includes("네트워크") ||
|
||||
loginError.includes("404") ||
|
||||
loginError.includes("Not Found"));
|
||||
return (
|
||||
<div className="neuro-flat p-8 mb-6">
|
||||
<form onSubmit={handleLogin}>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email" className="text-base">이메일</Label>
|
||||
<Label htmlFor="email" className="text-base">
|
||||
이메일
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500 h-5 w-5" />
|
||||
<Input id="email" type="email" placeholder="your@email.com" value={email} onChange={e => setEmail(e.target.value)} className="pl-10 neuro-pressed" />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="your@email.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="pl-10 neuro-pressed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password" className="text-base">비밀번호</Label>
|
||||
<Label htmlFor="password" className="text-base">
|
||||
비밀번호
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<KeyRound className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500 h-5 w-5" />
|
||||
<Input id="password" type={showPassword ? "text" : "password"} placeholder="••••••••" value={password} onChange={e => setPassword(e.target.value)} className="pl-10 neuro-pressed" />
|
||||
<button type="button" onClick={() => setShowPassword(!showPassword)} className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-500">
|
||||
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="pl-10 neuro-pressed"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-500"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loginError && <div className={`p-3 ${isCorsOrJsonError ? 'bg-amber-50 text-amber-800' : 'bg-red-50 text-red-600'} rounded-md text-sm`}>
|
||||
|
||||
{loginError && (
|
||||
<div
|
||||
className={`p-3 ${isCorsOrJsonError ? "bg-amber-50 text-amber-800" : "bg-red-50 text-red-600"} rounded-md text-sm`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="h-5 w-5 flex-shrink-0 mt-0.5 text-amber-500" />
|
||||
<div>
|
||||
<p className="font-medium">{loginError}</p>
|
||||
|
||||
{isCorsOrJsonError && <ul className="mt-2 list-disc pl-5 text-xs space-y-1 text-amber-700">
|
||||
<li>설정 페이지에서 다른 CORS 프록시 유형을 시도해 보세요.</li>
|
||||
<li>HTTPS URL을 사용하는 Supabase 인스턴스로 변경해 보세요.</li>
|
||||
|
||||
{isCorsOrJsonError && (
|
||||
<ul className="mt-2 list-disc pl-5 text-xs space-y-1 text-amber-700">
|
||||
<li>
|
||||
설정 페이지에서 다른 CORS 프록시 유형을 시도해 보세요.
|
||||
</li>
|
||||
<li>
|
||||
HTTPS URL을 사용하는 Supabase 인스턴스로 변경해 보세요.
|
||||
</li>
|
||||
<li>네트워크 연결 상태를 확인하세요.</li>
|
||||
</ul>}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-right">
|
||||
<Link to="/forgot-password" className="text-sm text-neuro-income hover:underline">
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
className="text-sm text-neuro-income hover:underline"
|
||||
>
|
||||
비밀번호를 잊으셨나요?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={isLoading || isSettingUpTables} className="w-full hover:bg-neuro-income/80 text-white h-auto bg-neuro-income text-lg py-[10px]">
|
||||
{isLoading ? "로그인 중..." : isSettingUpTables ? "데이터베이스 설정 중..." : "로그인"}
|
||||
{!isLoading && !isSettingUpTables ? <ArrowRight className="ml-2 h-5 w-5" /> : <Loader2 className="ml-2 h-5 w-5 animate-spin" />}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading || isSettingUpTables}
|
||||
className="w-full hover:bg-neuro-income/80 text-white h-auto bg-neuro-income text-lg py-[10px]"
|
||||
>
|
||||
{isLoading
|
||||
? "로그인 중..."
|
||||
: isSettingUpTables
|
||||
? "데이터베이스 설정 중..."
|
||||
: "로그인"}
|
||||
{!isLoading && !isSettingUpTables ? (
|
||||
<ArrowRight className="ml-2 h-5 w-5" />
|
||||
) : (
|
||||
<Loader2 className="ml-2 h-5 w-5 animate-spin" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>;
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default LoginForm;
|
||||
export default LoginForm;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
@@ -7,7 +6,10 @@ const LoginLink: React.FC = () => {
|
||||
<div className="text-center mb-4">
|
||||
<p className="text-gray-500">
|
||||
이미 계정이 있으신가요?{" "}
|
||||
<Link to="/login" className="text-neuro-income font-medium hover:underline">
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-neuro-income font-medium hover:underline"
|
||||
>
|
||||
로그인
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
|
||||
import React from "react";
|
||||
|
||||
interface RegisterErrorDisplayProps {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const RegisterErrorDisplay: React.FC<RegisterErrorDisplayProps> = ({ error }) => {
|
||||
if (!error) return null;
|
||||
const RegisterErrorDisplay: React.FC<RegisterErrorDisplayProps> = ({
|
||||
error,
|
||||
}) => {
|
||||
if (!error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-md text-red-700 text-sm">
|
||||
|
||||
@@ -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<React.SetStateAction<ServerConnectionStatus>>;
|
||||
setRegisterError: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
@@ -29,7 +39,7 @@ const RegisterForm: React.FC<RegisterFormProps> = ({
|
||||
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<RegisterFormProps> = ({
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
toast({
|
||||
title: "비밀번호 불일치",
|
||||
@@ -51,7 +61,7 @@ const RegisterForm: React.FC<RegisterFormProps> = ({
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// 비밀번호 강도 검사
|
||||
if (password.length < 8) {
|
||||
toast({
|
||||
@@ -61,7 +71,7 @@ const RegisterForm: React.FC<RegisterFormProps> = ({
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// 이메일 형식 검사
|
||||
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailPattern.test(email)) {
|
||||
@@ -72,7 +82,7 @@ const RegisterForm: React.FC<RegisterFormProps> = ({
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -84,22 +94,24 @@ const RegisterForm: React.FC<RegisterFormProps> = ({
|
||||
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<RegisterFormProps> = ({
|
||||
// 현재 브라우저 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<RegisterFormProps> = ({
|
||||
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<RegisterFormProps> = ({
|
||||
// 이메일 확인이 필요하지 않은 경우 (자동 승인 등)
|
||||
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<RegisterFormProps> = ({
|
||||
|
||||
// 이메일 인증 안내 화면 (인증 메일이 발송된 경우)
|
||||
if (emailConfirmationSent) {
|
||||
return <EmailConfirmation
|
||||
email={email}
|
||||
onBackToForm={() => setEmailConfirmationSent(false)}
|
||||
onResendEmail={handleResendVerificationEmail}
|
||||
/>;
|
||||
return (
|
||||
<EmailConfirmation
|
||||
email={email}
|
||||
onBackToForm={() => setEmailConfirmationSent(false)}
|
||||
onResendEmail={handleResendVerificationEmail}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 일반 회원가입 양식
|
||||
@@ -265,13 +292,16 @@ const RegisterForm: React.FC<RegisterFormProps> = ({
|
||||
showPassword={showPassword}
|
||||
setShowPassword={setShowPassword}
|
||||
/>
|
||||
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-neuro-income hover:bg-neuro-income/80 text-white py-6 h-auto mt-6"
|
||||
disabled={isLoading || (!serverStatus.connected && serverStatus.checked)}
|
||||
disabled={
|
||||
isLoading || (!serverStatus.connected && serverStatus.checked)
|
||||
}
|
||||
>
|
||||
{isLoading ? "가입 중..." : "회원가입"} {!isLoading && <ArrowRight className="ml-2 h-5 w-5" />}
|
||||
{isLoading ? "가입 중..." : "회원가입"}{" "}
|
||||
{!isLoading && <ArrowRight className="ml-2 h-5 w-5" />}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -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<RegisterFormFieldsProps> = ({
|
||||
confirmPassword,
|
||||
setConfirmPassword,
|
||||
showPassword,
|
||||
setShowPassword
|
||||
setShowPassword,
|
||||
}) => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username" className="text-base">이름</Label>
|
||||
<Label htmlFor="username" className="text-base">
|
||||
이름
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500 h-5 w-5" />
|
||||
<Input
|
||||
@@ -46,9 +47,11 @@ const RegisterFormFields: React.FC<RegisterFormFieldsProps> = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email" className="text-base">이메일</Label>
|
||||
<Label htmlFor="email" className="text-base">
|
||||
이메일
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500 h-5 w-5" />
|
||||
<Input
|
||||
@@ -61,9 +64,11 @@ const RegisterFormFields: React.FC<RegisterFormFieldsProps> = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password" className="text-base">비밀번호</Label>
|
||||
<Label htmlFor="password" className="text-base">
|
||||
비밀번호
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<KeyRound className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500 h-5 w-5" />
|
||||
<Input
|
||||
@@ -79,16 +84,24 @@ const RegisterFormFields: React.FC<RegisterFormFieldsProps> = ({
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-500"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{password && password.length > 0 && password.length < 8 && (
|
||||
<p className="text-xs text-red-500 mt-1">비밀번호는 최소 8자 이상이어야 합니다.</p>
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
비밀번호는 최소 8자 이상이어야 합니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword" className="text-base">비밀번호 확인</Label>
|
||||
<Label htmlFor="confirmPassword" className="text-base">
|
||||
비밀번호 확인
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<KeyRound className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500 h-5 w-5" />
|
||||
<Input
|
||||
@@ -101,16 +114,18 @@ const RegisterFormFields: React.FC<RegisterFormFieldsProps> = ({
|
||||
/>
|
||||
</div>
|
||||
{confirmPassword && password !== confirmPassword && (
|
||||
<p className="text-xs text-red-500 mt-1">비밀번호가 일치하지 않습니다.</p>
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
비밀번호가 일치하지 않습니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<Alert className="bg-amber-50 border-amber-200">
|
||||
<InfoIcon className="h-5 w-5 text-amber-600" />
|
||||
<AlertTitle className="text-amber-700">이메일 인증 필요</AlertTitle>
|
||||
<AlertDescription className="text-amber-600">
|
||||
회원가입 후 이메일로 인증 링크가 발송됩니다.
|
||||
이메일 인증을 완료해야 로그인이 가능합니다.
|
||||
회원가입 후 이메일로 인증 링크가 발송됩니다. 이메일 인증을 완료해야
|
||||
로그인이 가능합니다.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
|
||||
import React from "react";
|
||||
|
||||
const RegisterHeader: React.FC = () => {
|
||||
return (
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-neuro-income mb-2">젤리의 적자탈출</h1>
|
||||
<h1 className="text-3xl font-bold text-neuro-income mb-2">
|
||||
젤리의 적자탈출
|
||||
</h1>
|
||||
<p className="text-gray-500">새 계정을 만들고 재정 관리를 시작하세요</p>
|
||||
<p className="text-xs text-neuro-income mt-2">온프레미스 Supabase 연결 최적화 완료</p>
|
||||
<p className="text-xs text-neuro-income mt-2">
|
||||
온프레미스 Supabase 연결 최적화 완료
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
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
|
||||
<AlertTitle>서버 연결 문제</AlertTitle>
|
||||
<AlertDescription className="flex flex-col">
|
||||
<span>{serverStatus.message}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2 self-start flex items-center gap-1"
|
||||
onClick={checkServerConnection}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { verifySupabaseConnection } from "@/utils/auth/networkUtils";
|
||||
import { toast } from "@/hooks/useToast.wrapper";
|
||||
@@ -9,17 +8,20 @@ interface TestConnectionSectionProps {
|
||||
setTestResults: (results: any) => void;
|
||||
}
|
||||
|
||||
const TestConnectionSection = ({ setLoginError, setTestResults }: TestConnectionSectionProps) => {
|
||||
const TestConnectionSection = ({
|
||||
setLoginError,
|
||||
setTestResults,
|
||||
}: TestConnectionSectionProps) => {
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
|
||||
const testConnection = async () => {
|
||||
setLoginError(null);
|
||||
setIsTesting(true);
|
||||
|
||||
|
||||
try {
|
||||
const results = await verifySupabaseConnection();
|
||||
setTestResults(results);
|
||||
|
||||
|
||||
if (results.connected) {
|
||||
toast({
|
||||
title: "연결 성공",
|
||||
@@ -29,19 +31,19 @@ const TestConnectionSection = ({ setLoginError, setTestResults }: TestConnection
|
||||
toast({
|
||||
title: "연결 실패",
|
||||
description: results.message,
|
||||
variant: "destructive"
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
setTestResults({
|
||||
connected: false,
|
||||
message: error.message || '알 수 없는 오류'
|
||||
message: error.message || "알 수 없는 오류",
|
||||
});
|
||||
|
||||
|
||||
toast({
|
||||
title: "테스트 오류",
|
||||
description: error.message || '알 수 없는 오류',
|
||||
variant: "destructive"
|
||||
description: error.message || "알 수 없는 오류",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
@@ -52,13 +54,13 @@ const TestConnectionSection = ({ setLoginError, setTestResults }: TestConnection
|
||||
<div className="mt-8 border-t pt-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-sm font-medium text-gray-700">연결 테스트</h3>
|
||||
<Button
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={testConnection}
|
||||
disabled={isTesting}
|
||||
>
|
||||
{isTesting ? '테스트 중...' : 'Supabase 연결 테스트'}
|
||||
{isTesting ? "테스트 중..." : "Supabase 연결 테스트"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<SignUpResponse>;
|
||||
serverStatus: ServerConnectionStatus;
|
||||
setServerStatus: React.Dispatch<React.SetStateAction<ServerConnectionStatus>>;
|
||||
setRegisterError: (error: string | null) => void;
|
||||
|
||||
@@ -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<BudgetDialogProps> = ({
|
||||
handleCategoryInputChange,
|
||||
handleSaveCategoryBudgets,
|
||||
calculateTotalBudget,
|
||||
isSubmitting = false
|
||||
isSubmitting = false,
|
||||
}) => {
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -40,10 +39,12 @@ const BudgetDialog: React.FC<BudgetDialogProps> = ({
|
||||
const formattedTotal = formatCurrency(calculateTotalBudget());
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(newOpen) => {
|
||||
if (isSubmitting && !newOpen) return;
|
||||
if (isSubmitting && !newOpen) {
|
||||
return;
|
||||
}
|
||||
onOpenChange(newOpen);
|
||||
}}
|
||||
>
|
||||
@@ -51,32 +52,35 @@ const BudgetDialog: React.FC<BudgetDialogProps> = ({
|
||||
<DialogHeader>
|
||||
<DialogTitle>예산 설정</DialogTitle>
|
||||
<DialogDescription>
|
||||
카테고리별로 월간 예산을 설정하세요. 일일, 주간 예산은 자동으로 계산됩니다.
|
||||
카테고리별로 월간 예산을 설정하세요. 일일, 주간 예산은 자동으로
|
||||
계산됩니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<CategoryBudgetInputs
|
||||
categoryBudgets={categoryBudgets}
|
||||
handleCategoryInputChange={handleCategoryInputChange}
|
||||
<CategoryBudgetInputs
|
||||
categoryBudgets={categoryBudgets}
|
||||
handleCategoryInputChange={handleCategoryInputChange}
|
||||
/>
|
||||
|
||||
<div className="border-t border-gray-300 pt-3 mt-4">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="font-medium text-sm">월간 총 예산:</h3>
|
||||
<p className="font-bold text-neuro-income text-base">{formattedTotal}</p>
|
||||
<p className="font-bold text-neuro-income text-base">
|
||||
{formattedTotal}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-neuro-income hover:bg-neuro-income/90 text-white"
|
||||
disabled={isSubmitting}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
|
||||
import React from 'react';
|
||||
import React from "react";
|
||||
|
||||
interface BudgetHeaderProps {
|
||||
spentAmount: number;
|
||||
@@ -10,12 +9,14 @@ interface BudgetHeaderProps {
|
||||
const BudgetHeader: React.FC<BudgetHeaderProps> = ({
|
||||
spentAmount,
|
||||
targetAmount,
|
||||
formatCurrency
|
||||
formatCurrency,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<div className="text-base font-bold">{formatCurrency(spentAmount)}</div>
|
||||
<div className="text-sm text-gray-500">/ {formatCurrency(targetAmount)}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
/ {formatCurrency(targetAmount)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
import React from 'react';
|
||||
import { CirclePlus } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import React from "react";
|
||||
import { logger } from "@/utils/logger";
|
||||
import { CirclePlus } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface BudgetInputButtonProps {
|
||||
isBudgetSet: boolean;
|
||||
@@ -12,12 +12,12 @@ interface BudgetInputButtonProps {
|
||||
const BudgetInputButton: React.FC<BudgetInputButtonProps> = ({
|
||||
isBudgetSet,
|
||||
budgetButtonText,
|
||||
toggleBudgetInput
|
||||
toggleBudgetInput,
|
||||
}) => {
|
||||
const handleButtonClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log('예산 수정 버튼 클릭됨');
|
||||
logger.info("예산 수정 버튼 클릭됨");
|
||||
toggleBudgetInput();
|
||||
};
|
||||
|
||||
@@ -29,7 +29,10 @@ const BudgetInputButton: React.FC<BudgetInputButtonProps> = ({
|
||||
className="text-neuro-income hover:underline flex items-center text-base font-semibold group"
|
||||
type="button"
|
||||
>
|
||||
<CirclePlus size={26} className="mr-2 text-neuro-income transition-transform group-hover:scale-110" />
|
||||
<CirclePlus
|
||||
size={26}
|
||||
className="mr-2 text-neuro-income transition-transform group-hover:scale-110"
|
||||
/>
|
||||
<span className="text-base font-semibold">{budgetButtonText}</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -39,9 +42,9 @@ const BudgetInputButton: React.FC<BudgetInputButtonProps> = ({
|
||||
return (
|
||||
<div className="py-4 text-center">
|
||||
<div className="text-gray-400 mb-4">아직 예산이 설정되지 않았습니다</div>
|
||||
<Button
|
||||
onClick={handleButtonClick}
|
||||
variant="default"
|
||||
<Button
|
||||
onClick={handleButtonClick}
|
||||
variant="default"
|
||||
className="bg-neuro-income hover:bg-neuro-income/90 animate-pulse shadow-lg"
|
||||
type="button"
|
||||
>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
|
||||
import React from 'react';
|
||||
import React from "react";
|
||||
|
||||
// 이 컴포넌트는 더 이상 사용되지 않으며 BudgetDialog로 대체되었습니다
|
||||
const BudgetInputForm = () => {
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
|
||||
import React from 'react';
|
||||
import React from "react";
|
||||
|
||||
interface BudgetProgressBarProps {
|
||||
percentage: number;
|
||||
progressBarColor: string;
|
||||
}
|
||||
|
||||
const BudgetProgressBar: React.FC<BudgetProgressBarProps> = ({
|
||||
percentage,
|
||||
progressBarColor
|
||||
const BudgetProgressBar: React.FC<BudgetProgressBarProps> = ({
|
||||
percentage,
|
||||
progressBarColor,
|
||||
}) => {
|
||||
return (
|
||||
<div className="w-full h-2 neuro-pressed overflow-hidden mb-3">
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
|
||||
import React from 'react';
|
||||
import React from "react";
|
||||
|
||||
interface BudgetStatusDisplayProps {
|
||||
budgetStatusText: string;
|
||||
@@ -12,12 +11,15 @@ const BudgetStatusDisplay: React.FC<BudgetStatusDisplayProps> = ({
|
||||
budgetStatusText,
|
||||
budgetAmount,
|
||||
actualPercentage,
|
||||
isOverBudget
|
||||
isOverBudget,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex justify-between items-center">
|
||||
<div className={`text-sm font-medium ${isOverBudget ? 'text-red-500' : 'text-gray-500'}`}>
|
||||
{budgetStatusText}{budgetAmount}원
|
||||
<div
|
||||
className={`text-sm font-medium ${isOverBudget ? "text-red-500" : "text-gray-500"}`}
|
||||
>
|
||||
{budgetStatusText}
|
||||
{budgetAmount}원
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-500">
|
||||
{actualPercentage}%
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
|
||||
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 ExpenseAmountInputProps {
|
||||
form: UseFormReturn<ExpenseFormValues>;
|
||||
@@ -11,23 +10,23 @@ interface ExpenseAmountInputProps {
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
const ExpenseAmountInput: React.FC<ExpenseAmountInputProps> = ({
|
||||
form,
|
||||
onFocus,
|
||||
isDisabled = false
|
||||
const ExpenseAmountInput: React.FC<ExpenseAmountInputProps> = ({
|
||||
form,
|
||||
onFocus,
|
||||
isDisabled = false,
|
||||
}) => {
|
||||
// Format number with commas
|
||||
const formatWithCommas = (value: string): string => {
|
||||
// Remove commas first to avoid duplicates when typing
|
||||
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, ",");
|
||||
};
|
||||
|
||||
const handleAmountChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const formattedValue = formatWithCommas(e.target.value);
|
||||
form.setValue('amount', formattedValue);
|
||||
form.setValue("amount", formattedValue);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -35,8 +34,8 @@ const ExpenseAmountInput: React.FC<ExpenseAmountInputProps> = ({
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>금액</FormLabel>
|
||||
<Input
|
||||
placeholder="금액을 입력하세요"
|
||||
<Input
|
||||
placeholder="금액을 입력하세요"
|
||||
value={field.value}
|
||||
onChange={handleAmountChange}
|
||||
onFocus={onFocus}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
|
||||
import React from 'react';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import { FormControl } from '@/components/ui/form';
|
||||
import { categoryIcons, EXPENSE_CATEGORIES } from '@/constants/categoryIcons';
|
||||
import React from "react";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { FormControl } from "@/components/ui/form";
|
||||
import { categoryIcons, EXPENSE_CATEGORIES } from "@/constants/categoryIcons";
|
||||
|
||||
interface ExpenseCategorySelectorProps {
|
||||
value: string;
|
||||
@@ -13,29 +12,29 @@ interface ExpenseCategorySelectorProps {
|
||||
const ExpenseCategorySelector: React.FC<ExpenseCategorySelectorProps> = ({
|
||||
value,
|
||||
onValueChange,
|
||||
isDisabled = false
|
||||
isDisabled = false,
|
||||
}) => {
|
||||
return (
|
||||
<FormControl>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
className="justify-between flex-nowrap gap-1"
|
||||
value={value}
|
||||
onValueChange={(value) => {
|
||||
if (value) onValueChange(value);
|
||||
if (value) {
|
||||
onValueChange(value);
|
||||
}
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{EXPENSE_CATEGORIES.map((category) => (
|
||||
<ToggleGroupItem
|
||||
key={category}
|
||||
<ToggleGroupItem
|
||||
key={category}
|
||||
value={category}
|
||||
className="px-3 py-2 rounded-md border flex items-center gap-1"
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<div className="text-neuro-income">
|
||||
{categoryIcons[category]}
|
||||
</div>
|
||||
<div className="text-neuro-income">{categoryIcons[category]}</div>
|
||||
<span>{category}</span>
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Form } from '@/components/ui/form';
|
||||
import ExpenseFormFields from './ExpenseFormFields';
|
||||
import ExpenseSubmitActions from './ExpenseSubmitActions';
|
||||
import React, { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import { PaymentMethod } from "@/types";
|
||||
import ExpenseFormFields from "./ExpenseFormFields";
|
||||
import ExpenseSubmitActions from "./ExpenseSubmitActions";
|
||||
|
||||
export interface ExpenseFormValues {
|
||||
title: string;
|
||||
amount: string;
|
||||
category: string;
|
||||
paymentMethod: '신용카드' | '현금';
|
||||
paymentMethod: PaymentMethod;
|
||||
}
|
||||
|
||||
interface ExpenseFormProps {
|
||||
@@ -18,32 +18,26 @@ interface ExpenseFormProps {
|
||||
isSubmitting?: boolean;
|
||||
}
|
||||
|
||||
const ExpenseForm: React.FC<ExpenseFormProps> = ({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
isSubmitting = false
|
||||
const ExpenseForm: React.FC<ExpenseFormProps> = ({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
isSubmitting = false,
|
||||
}) => {
|
||||
const form = useForm<ExpenseFormValues>({
|
||||
defaultValues: {
|
||||
title: '',
|
||||
amount: '',
|
||||
category: '음식',
|
||||
paymentMethod: '신용카드'
|
||||
}
|
||||
title: "",
|
||||
amount: "",
|
||||
category: "음식",
|
||||
paymentMethod: "신용카드",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<ExpenseFormFields
|
||||
form={form}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
|
||||
<ExpenseSubmitActions
|
||||
onCancel={onCancel}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
<ExpenseFormFields form={form} isSubmitting={isSubmitting} />
|
||||
|
||||
<ExpenseSubmitActions onCancel={onCancel} isSubmitting={isSubmitting} />
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
|
||||
@@ -1,29 +1,28 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { UseFormReturn } from 'react-hook-form';
|
||||
import { ExpenseFormValues } from './ExpenseForm';
|
||||
import ExpenseCategorySelector from './ExpenseCategorySelector';
|
||||
import ExpenseTitleSuggestions from './ExpenseTitleSuggestions';
|
||||
import ExpenseTitleInput from './ExpenseTitleInput';
|
||||
import ExpenseAmountInput from './ExpenseAmountInput';
|
||||
import ExpensePaymentMethod from './ExpensePaymentMethod';
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import { ExpenseFormValues } from "./ExpenseForm";
|
||||
import ExpenseCategorySelector from "./ExpenseCategorySelector";
|
||||
import ExpenseTitleSuggestions from "./ExpenseTitleSuggestions";
|
||||
import ExpenseTitleInput from "./ExpenseTitleInput";
|
||||
import ExpenseAmountInput from "./ExpenseAmountInput";
|
||||
import ExpensePaymentMethod from "./ExpensePaymentMethod";
|
||||
|
||||
interface ExpenseFormFieldsProps {
|
||||
form: UseFormReturn<ExpenseFormValues>;
|
||||
isSubmitting?: boolean;
|
||||
}
|
||||
|
||||
const ExpenseFormFields: React.FC<ExpenseFormFieldsProps> = ({
|
||||
form,
|
||||
isSubmitting = false
|
||||
const ExpenseFormFields: React.FC<ExpenseFormFieldsProps> = ({
|
||||
form,
|
||||
isSubmitting = false,
|
||||
}) => {
|
||||
// 상태 관리 추가
|
||||
const [showTitleSuggestions, setShowTitleSuggestions] = useState(false);
|
||||
const [showPaymentMethod, setShowPaymentMethod] = useState(false);
|
||||
|
||||
|
||||
// 현재 선택된 카테고리 가져오기
|
||||
const selectedCategory = form.watch('category');
|
||||
|
||||
const selectedCategory = form.watch("category");
|
||||
|
||||
// 카테고리가 변경될 때마다 제목 추천 표시 여부 결정
|
||||
useEffect(() => {
|
||||
if (selectedCategory) {
|
||||
@@ -32,46 +31,43 @@ const ExpenseFormFields: React.FC<ExpenseFormFieldsProps> = ({
|
||||
setShowTitleSuggestions(false);
|
||||
}
|
||||
}, [selectedCategory]);
|
||||
|
||||
|
||||
// 제안된 제목 클릭 시 제목 필드에 설정
|
||||
const handleTitleSuggestionClick = (suggestion: string) => {
|
||||
form.setValue('title', suggestion);
|
||||
form.setValue("title", suggestion);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 카테고리 필드를 가장 먼저 배치 */}
|
||||
<ExpenseCategorySelector
|
||||
value={form.watch('category')}
|
||||
onValueChange={(value) => form.setValue('category', value)}
|
||||
<ExpenseCategorySelector
|
||||
value={form.watch("category")}
|
||||
onValueChange={(value) => form.setValue("category", value)}
|
||||
isDisabled={isSubmitting}
|
||||
/>
|
||||
|
||||
|
||||
{/* 카테고리별 제목 제안 - 카테고리 선택 후에만 표시 */}
|
||||
{selectedCategory && showTitleSuggestions && (
|
||||
<ExpenseTitleSuggestions
|
||||
<ExpenseTitleSuggestions
|
||||
category={selectedCategory}
|
||||
onSuggestionClick={handleTitleSuggestionClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
{/* 제목 필드를 두 번째로 배치 */}
|
||||
<ExpenseTitleInput
|
||||
form={form}
|
||||
isDisabled={isSubmitting}
|
||||
/>
|
||||
|
||||
<ExpenseTitleInput form={form} isDisabled={isSubmitting} />
|
||||
|
||||
{/* 금액 필드를 세 번째로 배치 */}
|
||||
<ExpenseAmountInput
|
||||
form={form}
|
||||
onFocus={() => setShowPaymentMethod(true)}
|
||||
isDisabled={isSubmitting}
|
||||
<ExpenseAmountInput
|
||||
form={form}
|
||||
onFocus={() => setShowPaymentMethod(true)}
|
||||
isDisabled={isSubmitting}
|
||||
/>
|
||||
|
||||
{/* 지출 방법 필드는 금액 입력 시에만 표시 */}
|
||||
<ExpensePaymentMethod
|
||||
form={form}
|
||||
showPaymentMethod={showPaymentMethod}
|
||||
<ExpensePaymentMethod
|
||||
form={form}
|
||||
showPaymentMethod={showPaymentMethod}
|
||||
isDisabled={isSubmitting}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
|
||||
import React from 'react';
|
||||
import { FormField, FormItem, FormLabel } from '@/components/ui/form';
|
||||
import { CreditCard, Banknote } from 'lucide-react';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { UseFormReturn } from 'react-hook-form';
|
||||
import { ExpenseFormValues } from './ExpenseForm';
|
||||
import React from "react";
|
||||
import { FormField, FormItem, FormLabel } from "@/components/ui/form";
|
||||
import { CreditCard, Banknote } from "lucide-react";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import { ExpenseFormValues } from "./ExpenseForm";
|
||||
|
||||
interface ExpensePaymentMethodProps {
|
||||
form: UseFormReturn<ExpenseFormValues>;
|
||||
@@ -12,21 +11,21 @@ interface ExpensePaymentMethodProps {
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
const ExpensePaymentMethod: React.FC<ExpensePaymentMethodProps> = ({
|
||||
form,
|
||||
const ExpensePaymentMethod: React.FC<ExpensePaymentMethodProps> = ({
|
||||
form,
|
||||
showPaymentMethod,
|
||||
isDisabled = false
|
||||
isDisabled = false,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
<div
|
||||
className={`overflow-hidden transition-all duration-300 ease-out ${
|
||||
showPaymentMethod
|
||||
? 'max-h-36 opacity-100 translate-y-0'
|
||||
: 'max-h-0 opacity-0 -translate-y-4'
|
||||
showPaymentMethod
|
||||
? "max-h-36 opacity-100 translate-y-0"
|
||||
: "max-h-0 opacity-0 -translate-y-4"
|
||||
}`}
|
||||
>
|
||||
<Separator className="my-2" />
|
||||
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="paymentMethod"
|
||||
@@ -36,22 +35,26 @@ const ExpensePaymentMethod: React.FC<ExpensePaymentMethodProps> = ({
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div
|
||||
className={`flex items-center justify-center gap-2 p-2 rounded-md cursor-pointer border transition-colors ${
|
||||
field.value === '신용카드'
|
||||
? 'border-neuro-income bg-neuro-income/10'
|
||||
: 'border-gray-200 hover:bg-gray-50'
|
||||
} ${isDisabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
onClick={() => !isDisabled && form.setValue('paymentMethod', '신용카드')}
|
||||
field.value === "신용카드"
|
||||
? "border-neuro-income bg-neuro-income/10"
|
||||
: "border-gray-200 hover:bg-gray-50"
|
||||
} ${isDisabled ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||
onClick={() =>
|
||||
!isDisabled && form.setValue("paymentMethod", "신용카드")
|
||||
}
|
||||
>
|
||||
<CreditCard size={16} className="text-neuro-income" />
|
||||
<span className="text-xs">신용카드</span>
|
||||
</div>
|
||||
<div
|
||||
className={`flex items-center justify-center gap-2 p-2 rounded-md cursor-pointer border transition-colors ${
|
||||
field.value === '현금'
|
||||
? 'border-neuro-income bg-neuro-income/10'
|
||||
: 'border-gray-200 hover:bg-gray-50'
|
||||
} ${isDisabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
onClick={() => !isDisabled && form.setValue('paymentMethod', '현금')}
|
||||
field.value === "현금"
|
||||
? "border-neuro-income bg-neuro-income/10"
|
||||
: "border-gray-200 hover:bg-gray-50"
|
||||
} ${isDisabled ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||
onClick={() =>
|
||||
!isDisabled && form.setValue("paymentMethod", "현금")
|
||||
}
|
||||
>
|
||||
<Banknote size={16} className="text-neuro-income" />
|
||||
<span className="text-xs">현금</span>
|
||||
|
||||
@@ -1,28 +1,27 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
interface ExpenseSubmitActionsProps {
|
||||
onCancel: () => void;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
const ExpenseSubmitActions: React.FC<ExpenseSubmitActionsProps> = ({
|
||||
onCancel,
|
||||
isSubmitting
|
||||
const ExpenseSubmitActions: React.FC<ExpenseSubmitActionsProps> = ({
|
||||
onCancel,
|
||||
isSubmitting,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-neuro-income text-white hover:bg-neuro-income/90"
|
||||
disabled={isSubmitting}
|
||||
@@ -32,7 +31,9 @@ const ExpenseSubmitActions: React.FC<ExpenseSubmitActionsProps> = ({
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
저장 중...
|
||||
</>
|
||||
) : '저장'}
|
||||
) : (
|
||||
"저장"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<ExpenseFormValues>;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
const ExpenseTitleInput: React.FC<ExpenseTitleInputProps> = ({
|
||||
const ExpenseTitleInput: React.FC<ExpenseTitleInputProps> = ({
|
||||
form,
|
||||
isDisabled = false
|
||||
isDisabled = false,
|
||||
}) => {
|
||||
return (
|
||||
<FormField
|
||||
@@ -21,9 +20,9 @@ const ExpenseTitleInput: React.FC<ExpenseTitleInputProps> = ({
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>제목</FormLabel>
|
||||
<Input
|
||||
placeholder="지출 내역을 입력하세요"
|
||||
{...field}
|
||||
<Input
|
||||
placeholder="지출 내역을 입력하세요"
|
||||
{...field}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
@@ -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<ExpenseTitleSuggestionsProps> = ({
|
||||
category,
|
||||
onSuggestionClick
|
||||
onSuggestionClick,
|
||||
}) => {
|
||||
const titleSuggestions = getPersonalizedTitleSuggestions(category);
|
||||
|
||||
|
||||
if (!category || titleSuggestions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 mt-1 mb-2">
|
||||
{titleSuggestions.map((suggestion) => (
|
||||
<Badge
|
||||
<Badge
|
||||
key={suggestion}
|
||||
variant="outline"
|
||||
className="cursor-pointer hover:bg-neuro-income/10 transition-colors px-3 py-1"
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
|
||||
import React from 'react';
|
||||
import React from "react";
|
||||
|
||||
interface EmptyStateProps {
|
||||
message?: string;
|
||||
subMessage?: string;
|
||||
}
|
||||
|
||||
const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
message = "아직 데이터가 없습니다",
|
||||
subMessage = "예산을 설정하고 지출을 추가해 보세요"
|
||||
const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
message = "아직 데이터가 없습니다",
|
||||
subMessage = "예산을 설정하고 지출을 추가해 보세요",
|
||||
}) => {
|
||||
return (
|
||||
<div className="neuro-card py-8 text-center text-gray-400 mb-4">
|
||||
|
||||
@@ -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<string, number>) => void;
|
||||
handleBudgetGoalUpdate: (
|
||||
type: BudgetPeriod,
|
||||
amount: number,
|
||||
newCategoryBudgets?: Record<string, number>
|
||||
) => void;
|
||||
updateTransaction: (transaction: Transaction) => void;
|
||||
getCategorySpending: () => Array<{
|
||||
title: string;
|
||||
@@ -29,12 +32,13 @@ const HomeContent: React.FC<HomeContentProps> = ({
|
||||
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 (
|
||||
<div className="pb-[50px]">
|
||||
@@ -44,7 +48,7 @@ const HomeContent: React.FC<HomeContentProps> = ({
|
||||
<EmptyState />
|
||||
)}
|
||||
<h2 className="text-lg font-semibold mb-2 mt-4">월간 예산과 지출</h2>
|
||||
<BudgetProgressCard
|
||||
<BudgetProgressCard
|
||||
budgetData={budgetData}
|
||||
selectedTab={selectedTab}
|
||||
setSelectedTab={setSelectedTab}
|
||||
@@ -53,8 +57,8 @@ const HomeContent: React.FC<HomeContentProps> = ({
|
||||
onSaveBudget={handleBudgetGoalUpdate}
|
||||
/>
|
||||
{transactions.length > 0 ? (
|
||||
<RecentTransactionsSection
|
||||
transactions={transactions.slice(0, 5)}
|
||||
<RecentTransactionsSection
|
||||
transactions={transactions.slice(0, 5)}
|
||||
onUpdateTransaction={updateTransaction}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -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 (
|
||||
<div className="max-w-md mx-auto px-6">
|
||||
<Header />
|
||||
|
||||
<HomeContent
|
||||
<HomeContent
|
||||
transactions={transactions || []}
|
||||
budgetData={budgetData || defaultBudgetData}
|
||||
budgetData={budgetData || defaultBudgetData}
|
||||
selectedTab={selectedTab}
|
||||
setSelectedTab={setSelectedTab}
|
||||
handleBudgetGoalUpdate={handleBudgetGoalUpdate}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
|
||||
import React from 'react';
|
||||
import { BellRing, X, Check } from 'lucide-react';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { toast } from 'sonner';
|
||||
import React from "react";
|
||||
import { BellRing, X, Check } from "lucide-react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// 알림 타입 정의
|
||||
export interface Notification {
|
||||
@@ -25,21 +28,23 @@ interface NotificationPopoverProps {
|
||||
const NotificationPopover: React.FC<NotificationPopoverProps> = ({
|
||||
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<NotificationPopoverProps> = ({
|
||||
<Button variant="ghost" size="icon" className="relative">
|
||||
<BellRing size={20} className="text-gray-600" />
|
||||
{unreadCount > 0 && (
|
||||
<Badge
|
||||
className="absolute -top-1 -right-1 px-1.5 py-0.5 min-w-5 h-5 flex items-center justify-center text-xs bg-neuro-income text-white border-2 border-neuro-background"
|
||||
>
|
||||
<Badge className="absolute -top-1 -right-1 px-1.5 py-0.5 min-w-5 h-5 flex items-center justify-center text-xs bg-neuro-income text-white border-2 border-neuro-background">
|
||||
{unreadCount}
|
||||
</Badge>
|
||||
)}
|
||||
@@ -63,17 +66,15 @@ const NotificationPopover: React.FC<NotificationPopoverProps> = ({
|
||||
<BellRing size={16} className="mr-2 text-neuro-income" />
|
||||
<h3 className="font-medium">알림</h3>
|
||||
{unreadCount > 0 && (
|
||||
<Badge
|
||||
className="ml-2 px-1.5 py-0.5 bg-neuro-income text-white"
|
||||
>
|
||||
<Badge className="ml-2 px-1.5 py-0.5 bg-neuro-income text-white">
|
||||
{unreadCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{notifications.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearAll}
|
||||
className="text-xs hover:bg-red-100 hover:text-red-600"
|
||||
>
|
||||
@@ -81,9 +82,9 @@ const NotificationPopover: React.FC<NotificationPopoverProps> = ({
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<Separator />
|
||||
|
||||
|
||||
<div className="max-h-[300px] overflow-y-auto">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="py-6 text-center text-gray-500">
|
||||
@@ -91,12 +92,21 @@ const NotificationPopover: React.FC<NotificationPopoverProps> = ({
|
||||
</div>
|
||||
) : (
|
||||
notifications.map((notification) => (
|
||||
<div key={notification.id} className={`p-4 border-b last:border-b-0 ${!notification.read ? 'bg-[#F2FCE2]' : ''}`}>
|
||||
<div
|
||||
key={notification.id}
|
||||
className={`p-4 border-b last:border-b-0 ${!notification.read ? "bg-[#F2FCE2]" : ""}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-medium">{notification.title}</h4>
|
||||
<p className="text-xs text-gray-600 mt-1">{notification.message}</p>
|
||||
<p className="text-xs text-gray-400 mt-1">{formatDate(notification.timestamp)}</p>
|
||||
<h4 className="text-sm font-medium">
|
||||
{notification.title}
|
||||
</h4>
|
||||
<p className="text-xs text-gray-600 mt-1">
|
||||
{notification.message}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{formatDate(notification.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { logger } from "@/utils/logger";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ArrowRight, Wallet, PieChart, LineChart } from "lucide-react";
|
||||
@@ -9,21 +17,18 @@ interface WelcomeDialogProps {
|
||||
open: boolean;
|
||||
onClose: (dontShowAgain: boolean) => void;
|
||||
}
|
||||
const WelcomeDialog: React.FC<WelcomeDialogProps> = ({
|
||||
open,
|
||||
onClose
|
||||
}) => {
|
||||
const WelcomeDialog: React.FC<WelcomeDialogProps> = ({ open, onClose }) => {
|
||||
const [dontShowAgain, setDontShowAgain] = useState(false);
|
||||
|
||||
// 다이얼로그 열릴 때 localStorage 값 확인
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
try {
|
||||
const savedValue = localStorage.getItem('dontShowWelcome');
|
||||
console.log('WelcomeDialog - 저장된 dontShowWelcome 값:', savedValue);
|
||||
setDontShowAgain(savedValue === 'true');
|
||||
const savedValue = localStorage.getItem("dontShowWelcome");
|
||||
logger.info("WelcomeDialog - 저장된 dontShowWelcome 값:", savedValue);
|
||||
setDontShowAgain(savedValue === "true");
|
||||
} catch (error) {
|
||||
console.error('WelcomeDialog - localStorage 읽기 오류:', error);
|
||||
logger.error("WelcomeDialog - localStorage 읽기 오류:", error);
|
||||
}
|
||||
}
|
||||
}, [open]);
|
||||
@@ -32,32 +37,42 @@ const WelcomeDialog: React.FC<WelcomeDialogProps> = ({
|
||||
// 체크박스가 체크되어 있으면 localStorage에 저장
|
||||
if (dontShowAgain) {
|
||||
// 세션 스토리지와 로컬 스토리지 모두에 저장 (이중 보호)
|
||||
localStorage.setItem('dontShowWelcome', 'true');
|
||||
sessionStorage.setItem('dontShowWelcome', 'true');
|
||||
console.log('WelcomeDialog - dontShowWelcome 값이 true로 저장되었습니다');
|
||||
localStorage.setItem("dontShowWelcome", "true");
|
||||
sessionStorage.setItem("dontShowWelcome", "true");
|
||||
logger.info(
|
||||
"WelcomeDialog - dontShowWelcome 값이 true로 저장되었습니다"
|
||||
);
|
||||
|
||||
// 확인을 위한 즉시 재확인
|
||||
const savedValue = localStorage.getItem('dontShowWelcome');
|
||||
console.log('WelcomeDialog - 저장 직후 확인된 값:', savedValue);
|
||||
const savedValue = localStorage.getItem("dontShowWelcome");
|
||||
logger.info("WelcomeDialog - 저장 직후 확인된 값:", savedValue);
|
||||
|
||||
// 토스트 메시지로 사용자에게 알림
|
||||
toast.success('환영 메시지가 다시 표시되지 않도록 설정되었습니다');
|
||||
toast.success("환영 메시지가 다시 표시되지 않도록 설정되었습니다");
|
||||
} else {
|
||||
// 체크 해제 시 명시적 'false' 저장
|
||||
localStorage.setItem('dontShowWelcome', 'false');
|
||||
sessionStorage.setItem('dontShowWelcome', 'false');
|
||||
console.log('WelcomeDialog - dontShowWelcome 값이 false로 저장되었습니다');
|
||||
localStorage.setItem("dontShowWelcome", "false");
|
||||
sessionStorage.setItem("dontShowWelcome", "false");
|
||||
logger.info(
|
||||
"WelcomeDialog - dontShowWelcome 값이 false로 저장되었습니다"
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('WelcomeDialog - localStorage 저장 중 오류 발생:', error);
|
||||
logger.error("WelcomeDialog - localStorage 저장 중 오류 발생:", error);
|
||||
}
|
||||
|
||||
// 부모 컴포넌트에 상태 전달
|
||||
onClose(dontShowAgain);
|
||||
};
|
||||
return <Dialog open={open} onOpenChange={isOpen => {
|
||||
if (!isOpen) handleClose();
|
||||
}}>
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="w-[90%] max-w-sm mx-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-center text-neuro-income mb-2 text-xl">
|
||||
@@ -104,25 +119,38 @@ const WelcomeDialog: React.FC<WelcomeDialogProps> = ({
|
||||
<div>
|
||||
<h3 className="font-medium">적자 탈출하기</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
데이터를 분석하고 지출 패턴을 파악하여 효율적인 자산 관리를 시작하세요.
|
||||
데이터를 분석하고 지출 패턴을 파악하여 효율적인 자산 관리를
|
||||
시작하세요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 mt-4">
|
||||
<Checkbox id="dont-show-again" checked={dontShowAgain} onCheckedChange={checked => setDontShowAgain(checked === true)} className="focus:outline-none focus:ring-0 focus:ring-offset-0" />
|
||||
<label htmlFor="dont-show-again" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||
<Checkbox
|
||||
id="dont-show-again"
|
||||
checked={dontShowAgain}
|
||||
onCheckedChange={(checked) => setDontShowAgain(checked === true)}
|
||||
className="focus:outline-none focus:ring-0 focus:ring-offset-0"
|
||||
/>
|
||||
<label
|
||||
htmlFor="dont-show-again"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
더 이상 보지 않기
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="sm:justify-center">
|
||||
<Button className="w-full sm:w-auto bg-neuro-income text-white hover:bg-neuro-income/90 focus:outline-none focus:ring-0" onClick={handleClose}>
|
||||
<Button
|
||||
className="w-full sm:w-auto bg-neuro-income text-white hover:bg-neuro-income/90 focus:outline-none focus:ring-0"
|
||||
onClick={handleClose}
|
||||
>
|
||||
시작하기 <ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>;
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
export default WelcomeDialog;
|
||||
export default WelcomeDialog;
|
||||
|
||||
@@ -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<typeof passwordFormSchema>;
|
||||
|
||||
@@ -36,14 +55,14 @@ const PasswordChangeForm = () => {
|
||||
const passwordForm = useForm<PasswordFormValues>({
|
||||
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 = () => {
|
||||
<div className="space-y-2 mb-6">
|
||||
<h2 className="text-xl font-semibold neuro-text">비밀번호 변경</h2>
|
||||
<Form {...passwordForm}>
|
||||
<form onSubmit={passwordForm.handleSubmit(onPasswordSubmit)} className="space-y-6 neuro-flat p-6">
|
||||
<form
|
||||
onSubmit={passwordForm.handleSubmit(onPasswordSubmit)}
|
||||
className="space-y-6 neuro-flat p-6"
|
||||
>
|
||||
<FormField
|
||||
control={passwordForm.control}
|
||||
name="currentPassword"
|
||||
@@ -65,19 +87,25 @@ const PasswordChangeForm = () => {
|
||||
<FormLabel>현재 비밀번호</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showCurrentPassword ? "text" : "password"}
|
||||
placeholder="현재 비밀번호를 입력하세요"
|
||||
{...field}
|
||||
<Input
|
||||
type={showCurrentPassword ? "text" : "password"}
|
||||
placeholder="현재 비밀번호를 입력하세요"
|
||||
{...field}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1"
|
||||
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
|
||||
onClick={() =>
|
||||
setShowCurrentPassword(!showCurrentPassword)
|
||||
}
|
||||
>
|
||||
{showCurrentPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
{showCurrentPassword ? (
|
||||
<EyeOff size={18} />
|
||||
) : (
|
||||
<Eye size={18} />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
@@ -85,7 +113,7 @@ const PasswordChangeForm = () => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
|
||||
<FormField
|
||||
control={passwordForm.control}
|
||||
name="newPassword"
|
||||
@@ -94,10 +122,10 @@ const PasswordChangeForm = () => {
|
||||
<FormLabel>새 비밀번호</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showNewPassword ? "text" : "password"}
|
||||
placeholder="새 비밀번호를 입력하세요"
|
||||
{...field}
|
||||
<Input
|
||||
type={showNewPassword ? "text" : "password"}
|
||||
placeholder="새 비밀번호를 입력하세요"
|
||||
{...field}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -106,7 +134,11 @@ const PasswordChangeForm = () => {
|
||||
className="absolute right-1 top-1"
|
||||
onClick={() => setShowNewPassword(!showNewPassword)}
|
||||
>
|
||||
{showNewPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
{showNewPassword ? (
|
||||
<EyeOff size={18} />
|
||||
) : (
|
||||
<Eye size={18} />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
@@ -114,7 +146,7 @@ const PasswordChangeForm = () => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
|
||||
<FormField
|
||||
control={passwordForm.control}
|
||||
name="confirmPassword"
|
||||
@@ -123,19 +155,25 @@ const PasswordChangeForm = () => {
|
||||
<FormLabel>비밀번호 확인</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
placeholder="새 비밀번호를 다시 입력하세요"
|
||||
{...field}
|
||||
<Input
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
placeholder="새 비밀번호를 다시 입력하세요"
|
||||
{...field}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
onClick={() =>
|
||||
setShowConfirmPassword(!showConfirmPassword)
|
||||
}
|
||||
>
|
||||
{showConfirmPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff size={18} />
|
||||
) : (
|
||||
<Eye size={18} />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
@@ -143,11 +181,11 @@ const PasswordChangeForm = () => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full bg-neuro-income text-white hover:bg-neuro-income/90"
|
||||
>
|
||||
<Key className="mr-1" size={18} />
|
||||
@@ -158,12 +196,13 @@ const PasswordChangeForm = () => {
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>비밀번호 변경 확인</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
비밀번호를 변경하시겠습니까? 변경 후에는 새 비밀번호로 로그인해야 합니다.
|
||||
비밀번호를 변경하시겠습니까? 변경 후에는 새 비밀번호로
|
||||
로그인해야 합니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
<AlertDialogAction
|
||||
onClick={passwordForm.handleSubmit(onPasswordSubmit)}
|
||||
className="bg-neuro-income hover:bg-neuro-income/90"
|
||||
>
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
|
||||
import React, { useEffect } from '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 { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/contexts/auth';
|
||||
import React, { useEffect } from "react";
|
||||
import { logger } from "@/utils/logger";
|
||||
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 { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "@/contexts/auth";
|
||||
|
||||
const profileFormSchema = z.object({
|
||||
name: z.string().min(2, {
|
||||
message: '이름은 2글자 이상이어야 합니다.',
|
||||
message: "이름은 2글자 이상이어야 합니다.",
|
||||
}),
|
||||
email: z.string().email({
|
||||
message: '유효한 이메일 주소를 입력해주세요.',
|
||||
message: "유효한 이메일 주소를 입력해주세요.",
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -25,12 +32,12 @@ const ProfileForm = () => {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const { user } = useAuth();
|
||||
|
||||
|
||||
const form = useForm<ProfileFormValues>({
|
||||
resolver: zodResolver(profileFormSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
email: '',
|
||||
name: "",
|
||||
email: "",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -38,26 +45,29 @@ const ProfileForm = () => {
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
form.reset({
|
||||
name: user.user_metadata?.username || '',
|
||||
email: user.email || '',
|
||||
name: user.user_metadata?.username || "",
|
||||
email: user.email || "",
|
||||
});
|
||||
}
|
||||
}, [user, form]);
|
||||
|
||||
const onSubmit = (data: ProfileFormValues) => {
|
||||
console.log('Form submitted:', data);
|
||||
logger.info("Form submitted:", data);
|
||||
// Here you would typically update the profile data
|
||||
// Show success message
|
||||
toast({
|
||||
title: "프로필 업데이트 완료",
|
||||
description: "프로필 정보가 성공적으로 업데이트되었습니다.",
|
||||
});
|
||||
navigate('/settings');
|
||||
navigate("/settings");
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 neuro-flat p-6 mb-6">
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-6 neuro-flat p-6 mb-6"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
@@ -71,7 +81,7 @@ const ProfileForm = () => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
@@ -85,10 +95,10 @@ const ProfileForm = () => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
|
||||
<div className="pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-neuro-income text-white hover:bg-neuro-income/90"
|
||||
>
|
||||
저장하기
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
|
||||
import React from 'react';
|
||||
import { ArrowLeft, User } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import React from "react";
|
||||
import { ArrowLeft, User } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
|
||||
const ProfileHeader = () => {
|
||||
const navigate = useNavigate();
|
||||
@@ -11,17 +10,17 @@ const ProfileHeader = () => {
|
||||
return (
|
||||
<header className="py-8">
|
||||
<div className="flex items-center mb-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate('/settings')}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate("/settings")}
|
||||
className="mr-2"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
</Button>
|
||||
<h1 className="text-2xl font-bold neuro-text">프로필 관리</h1>
|
||||
</div>
|
||||
|
||||
|
||||
{/* User Profile Picture */}
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<div className="relative mb-4">
|
||||
|
||||
@@ -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<RecentTransactionItemProps> = ({
|
||||
transaction,
|
||||
onClick
|
||||
const RecentTransactionItem: React.FC<RecentTransactionItemProps> = ({
|
||||
transaction,
|
||||
onClick,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
|
||||
import React from 'react';
|
||||
import { CloudOff, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogClose } from '@/components/ui/dialog';
|
||||
import React from "react";
|
||||
import { CloudOff, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogClose,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface DataResetDialogProps {
|
||||
isOpen: boolean;
|
||||
@@ -19,17 +26,20 @@ const DataResetDialog: React.FC<DataResetDialogProps> = ({
|
||||
onConfirm,
|
||||
isResetting,
|
||||
isLoggedIn,
|
||||
syncEnabled
|
||||
syncEnabled,
|
||||
}) => {
|
||||
return <Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>정말 모든 데이터를 초기화하시겠습니까?</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isLoggedIn ? <>
|
||||
이 작업은 되돌릴 수 없으며, 로컬 및 클라우드에 저장된 모든 예산, 지출 내역이 영구적으로 삭제됩니다.
|
||||
{isLoggedIn ? (
|
||||
<>
|
||||
이 작업은 되돌릴 수 없으며, 로컬 및 클라우드에 저장된 모든 예산,
|
||||
지출 내역이 영구적으로 삭제됩니다.
|
||||
<div className="flex items-center mt-2 text-amber-600">
|
||||
<CloudOff size={16} className="mr-2" />
|
||||
<CloudOff size={16} className="mr-2" />
|
||||
클라우드 데이터도 함께 삭제됩니다.
|
||||
</div>
|
||||
{syncEnabled && (
|
||||
@@ -37,7 +47,10 @@ const DataResetDialog: React.FC<DataResetDialogProps> = ({
|
||||
※ 동기화 설정이 비활성화됩니다.
|
||||
</div>
|
||||
)}
|
||||
</> : "이 작업은 되돌릴 수 없으며, 모든 예산, 지출 내역, 설정이 영구적으로 삭제됩니다."}
|
||||
</>
|
||||
) : (
|
||||
"이 작업은 되돌릴 수 없으며, 모든 예산, 지출 내역, 설정이 영구적으로 삭제됩니다."
|
||||
)}
|
||||
<div className="mt-2">
|
||||
단, '환영합니다' 화면 표시 설정과 로그인 상태는 유지됩니다.
|
||||
</div>
|
||||
@@ -45,17 +58,34 @@ const DataResetDialog: React.FC<DataResetDialogProps> = ({
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex flex-col sm:flex-row gap-2 sm:gap-0">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" className="sm:mr-2" disabled={isResetting}>취소</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="sm:mr-2"
|
||||
disabled={isResetting}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button variant="destructive" onClick={onConfirm} disabled={isResetting}>
|
||||
{isResetting ? <>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={onConfirm}
|
||||
disabled={isResetting}
|
||||
>
|
||||
{isResetting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
초기화 중...
|
||||
</> : isLoggedIn ? '확인, 로컬 및 클라우드 데이터 초기화' : '확인, 모든 데이터 초기화'}
|
||||
</>
|
||||
) : isLoggedIn ? (
|
||||
"확인, 로컬 및 클라우드 데이터 초기화"
|
||||
) : (
|
||||
"확인, 모든 데이터 초기화"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>;
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataResetDialog;
|
||||
|
||||
@@ -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 = () => {
|
||||
<div className="text-left">
|
||||
<h3 className="font-medium">데이터 초기화</h3>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{user
|
||||
{user
|
||||
? "로컬 및 클라우드의 모든 예산, 지출 내역이 초기화됩니다. 동기화 설정은 비활성화됩니다."
|
||||
: "모든 예산, 지출 내역, 설정이 초기화됩니다."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full mt-4"
|
||||
onClick={() => setIsResetDialogOpen(true)}
|
||||
disabled={isResetting}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useToast } from '@/hooks/useToast.wrapper';
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useToast } from "@/hooks/useToast.wrapper";
|
||||
|
||||
type SaveSettingsButtonProps = {
|
||||
onSave?: () => void;
|
||||
@@ -17,18 +16,18 @@ const SaveSettingsButton = ({ onSave }: SaveSettingsButtonProps) => {
|
||||
if (onSave) {
|
||||
onSave();
|
||||
}
|
||||
|
||||
|
||||
// 기본 저장 동작
|
||||
toast({
|
||||
title: "보안 설정이 저장되었습니다.",
|
||||
description: "변경사항이 성공적으로 적용되었습니다.",
|
||||
});
|
||||
navigate('/settings');
|
||||
navigate("/settings");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-6">
|
||||
<Button
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
className="w-full bg-neuro-income text-white hover:bg-neuro-income/90"
|
||||
>
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
|
||||
import React from 'react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import React from "react";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const SecurityHeader = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
||||
return (
|
||||
<header className="py-8">
|
||||
<div className="flex items-center mb-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate('/settings')}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate("/settings")}
|
||||
className="mr-2"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import React from "react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
type SecuritySettingItemProps = {
|
||||
id: string;
|
||||
@@ -12,13 +11,13 @@ type SecuritySettingItemProps = {
|
||||
onToggle: (id: string) => void;
|
||||
};
|
||||
|
||||
const SecuritySettingItem = ({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
enabled,
|
||||
onToggle
|
||||
const SecuritySettingItem = ({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
enabled,
|
||||
onToggle,
|
||||
}: SecuritySettingItemProps) => {
|
||||
return (
|
||||
<div className="flex items-start justify-between">
|
||||
@@ -38,10 +37,7 @@ const SecuritySettingItem = ({
|
||||
onCheckedChange={() => onToggle(id)}
|
||||
className="data-[state=checked]:bg-neuro-income"
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`${id}-switch`}
|
||||
className="sr-only"
|
||||
>
|
||||
<Label htmlFor={`${id}-switch`} className="sr-only">
|
||||
{title}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useToast } from '@/hooks/useToast.wrapper';
|
||||
import SecuritySettingItem from './SecuritySettingItem';
|
||||
import { SecuritySetting } from './types';
|
||||
import React from "react";
|
||||
import { useToast } from "@/hooks/useToast.wrapper";
|
||||
import SecuritySettingItem from "./SecuritySettingItem";
|
||||
import { SecuritySetting } from "./types";
|
||||
|
||||
type SecuritySettingsListProps = {
|
||||
settings: SecuritySetting[];
|
||||
setSettings: React.Dispatch<React.SetStateAction<SecuritySetting[]>>;
|
||||
};
|
||||
|
||||
const SecuritySettingsList = ({ settings, setSettings }: SecuritySettingsListProps) => {
|
||||
const SecuritySettingsList = ({
|
||||
settings,
|
||||
setSettings,
|
||||
}: SecuritySettingsListProps) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleToggle = (id: string) => {
|
||||
@@ -18,18 +20,18 @@ const SecuritySettingsList = ({ settings, setSettings }: SecuritySettingsListPro
|
||||
setting.id === id ? { ...setting, enabled: !setting.enabled } : setting
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
// 토스트 메시지로 상태 변경 알림
|
||||
const setting = settings.find(s => s.id === id);
|
||||
const setting = settings.find((s) => s.id === id);
|
||||
if (setting) {
|
||||
const newState = !setting.enabled;
|
||||
toast({
|
||||
title: `${setting.title}이(가) ${newState ? '활성화' : '비활성화'}되었습니다.`,
|
||||
title: `${setting.title}이(가) ${newState ? "활성화" : "비활성화"}되었습니다.`,
|
||||
description: "보안 설정이 변경되었습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-6 neuro-flat p-6 mb-8">
|
||||
{settings.map((setting) => (
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export type SecuritySetting = {
|
||||
id: string;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
|
||||
import React from 'react';
|
||||
import React from "react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
|
||||
@@ -8,15 +7,18 @@ interface SyncExplanationProps {
|
||||
}
|
||||
|
||||
const SyncExplanation: React.FC<SyncExplanationProps> = ({ enabled }) => {
|
||||
if (!enabled) return null;
|
||||
if (!enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert className="bg-amber-50 border-amber-200">
|
||||
<AlertCircle className="h-4 w-4 text-neuro-income" />
|
||||
<AlertTitle className="text-neuro-income">동기화 작동 방식</AlertTitle>
|
||||
<AlertDescription className="text-sm text-neuro-income">
|
||||
이 기능은 양방향 동기화입니다. 로그인 후 동기화를 켜면 서버 데이터와 로컬 데이터가 병합됩니다.
|
||||
데이터 초기화 시 동기화 설정은 자동으로 비활성화됩니다.
|
||||
이 기능은 양방향 동기화입니다. 로그인 후 동기화를 켜면 서버 데이터와
|
||||
로컬 데이터가 병합됩니다. 데이터 초기화 시 동기화 설정은 자동으로
|
||||
비활성화됩니다.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect } from "react";
|
||||
import { syncLogger } from "@/utils/logger";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import useNotifications from '@/hooks/useNotifications';
|
||||
import useNotifications from "@/hooks/useNotifications";
|
||||
|
||||
interface SyncStatusProps {
|
||||
enabled: boolean;
|
||||
@@ -18,43 +18,51 @@ const SyncStatus: React.FC<SyncStatusProps> = ({
|
||||
syncing,
|
||||
lastSync,
|
||||
user,
|
||||
onManualSync
|
||||
onManualSync,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const { addNotification } = useNotifications();
|
||||
|
||||
// 동기화 버튼 클릭 시 알림 추가
|
||||
const handleSyncClick = async () => {
|
||||
if (syncing) return;
|
||||
|
||||
if (syncing) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onManualSync();
|
||||
} catch (error) {
|
||||
console.error('수동 동기화 실패:', error);
|
||||
syncLogger.error("수동 동기화 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (!enabled) return null;
|
||||
if (!enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{user ? (
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-muted-foreground">마지막 동기화: {lastSync}</span>
|
||||
<button
|
||||
<span className="text-muted-foreground">
|
||||
마지막 동기화: {lastSync}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleSyncClick}
|
||||
disabled={syncing}
|
||||
className="neuro-button py-1 px-3 flex items-center gap-1 bg-neuro-income text-white hover:bg-neuro-income/90"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${syncing ? 'animate-spin' : ''}`} />
|
||||
<span>{syncing ? '동기화 중...' : '지금 동기화'}</span>
|
||||
<RefreshCw className={`h-4 w-4 ${syncing ? "animate-spin" : ""}`} />
|
||||
<span>{syncing ? "동기화 중..." : "지금 동기화"}</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">로그인이 필요합니다</span>
|
||||
<Button
|
||||
onClick={() => navigate('/login')}
|
||||
<span className="text-sm text-muted-foreground">
|
||||
로그인이 필요합니다
|
||||
</span>
|
||||
<Button
|
||||
onClick={() => navigate("/login")}
|
||||
size="sm"
|
||||
className="py-1 px-3 bg-neuro-income text-white hover:bg-neuro-income/90"
|
||||
>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
|
||||
import React from 'react';
|
||||
import { formatCurrency } from '@/utils/formatters';
|
||||
import React from "react";
|
||||
import { formatCurrency } from "@/utils/formatters";
|
||||
|
||||
interface TransactionAmountProps {
|
||||
amount: number;
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
|
||||
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, formatWithCommas } 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,
|
||||
formatWithCommas,
|
||||
} from "./TransactionFormFields";
|
||||
|
||||
interface TransactionAmountInputProps {
|
||||
form: UseFormReturn<TransactionFormValues>;
|
||||
onFocus: () => void;
|
||||
}
|
||||
|
||||
const TransactionAmountInput: React.FC<TransactionAmountInputProps> = ({ form, onFocus }) => {
|
||||
const TransactionAmountInput: React.FC<TransactionAmountInputProps> = ({
|
||||
form,
|
||||
onFocus,
|
||||
}) => {
|
||||
const handleAmountChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const formattedValue = formatWithCommas(e.target.value);
|
||||
form.setValue('amount', formattedValue);
|
||||
form.setValue("amount", formattedValue);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -24,9 +35,9 @@ const TransactionAmountInput: React.FC<TransactionAmountInputProps> = ({ form, o
|
||||
<FormItem>
|
||||
<FormLabel>금액</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="금액을 입력하세요"
|
||||
{...field}
|
||||
<Input
|
||||
placeholder="금액을 입력하세요"
|
||||
{...field}
|
||||
onChange={handleAmountChange}
|
||||
onFocus={onFocus}
|
||||
/>
|
||||
|
||||
@@ -1,28 +1,45 @@
|
||||
import React from 'react';
|
||||
import { FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||
import { UseFormReturn } from 'react-hook-form';
|
||||
import { TransactionFormValues } from './TransactionFormFields';
|
||||
import { EXPENSE_CATEGORIES } from '@/constants/categoryIcons';
|
||||
import { categoryIcons } from '@/constants/categoryIcons';
|
||||
import React from "react";
|
||||
import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import { TransactionFormValues } from "./TransactionFormFields";
|
||||
import { EXPENSE_CATEGORIES } from "@/constants/categoryIcons";
|
||||
import { categoryIcons } from "@/constants/categoryIcons";
|
||||
interface TransactionCategorySelectorProps {
|
||||
form: UseFormReturn<TransactionFormValues>;
|
||||
}
|
||||
const TransactionCategorySelector: React.FC<TransactionCategorySelectorProps> = ({
|
||||
form
|
||||
}) => {
|
||||
return <FormField control={form.control} name="category" render={({
|
||||
field
|
||||
}) => <FormItem>
|
||||
const TransactionCategorySelector: React.FC<
|
||||
TransactionCategorySelectorProps
|
||||
> = ({ form }) => {
|
||||
return (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="category"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>카테고리</FormLabel>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{EXPENSE_CATEGORIES.map(category => <div key={category} className={`flex items-center gap-2 p-2 rounded-md cursor-pointer border ${field.value === category ? 'border-neuro-income bg-neuro-income/10' : 'border-gray-200'}`} onClick={() => form.setValue('category', category as any)}>
|
||||
{EXPENSE_CATEGORIES.map((category) => (
|
||||
<div
|
||||
key={category}
|
||||
className={`flex items-center gap-2 p-2 rounded-md cursor-pointer border ${field.value === category ? "border-neuro-income bg-neuro-income/10" : "border-gray-200"}`}
|
||||
onClick={() => form.setValue("category", category)}
|
||||
>
|
||||
<div className="p-1 rounded-full">
|
||||
{categoryIcons[category]}
|
||||
</div>
|
||||
<span className="text-sm">{category}</span>
|
||||
</div>)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>} />;
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
export default TransactionCategorySelector;
|
||||
export default TransactionCategorySelector;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Trash2, Loader2 } from 'lucide-react';
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Trash2, Loader2 } from "lucide-react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -11,9 +10,9 @@ import {
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { useDeleteAlert } from '@/hooks/transactions/useDeleteAlert';
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useDeleteAlert } from "@/hooks/transactions/useDeleteAlert";
|
||||
|
||||
interface TransactionDeleteAlertProps {
|
||||
onDelete: () => Promise<boolean> | boolean;
|
||||
@@ -23,15 +22,18 @@ interface TransactionDeleteAlertProps {
|
||||
* 트랜잭션 삭제 확인 다이얼로그 - 리팩토링된 버전
|
||||
* 삭제 로직을 useDeleteAlert 훅으로 분리하여 컴포넌트 간소화
|
||||
*/
|
||||
const TransactionDeleteAlert: React.FC<TransactionDeleteAlertProps> = ({ onDelete }) => {
|
||||
const TransactionDeleteAlert: React.FC<TransactionDeleteAlertProps> = ({
|
||||
onDelete,
|
||||
}) => {
|
||||
// 삭제 관련 로직을 커스텀 훅으로 분리
|
||||
const { isOpen, isDeleting, handleDelete, handleOpenChange } = useDeleteAlert(onDelete);
|
||||
|
||||
const { isOpen, isDeleting, handleDelete, handleOpenChange } =
|
||||
useDeleteAlert(onDelete);
|
||||
|
||||
return (
|
||||
<AlertDialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="border-red-200 text-red-500 hover:bg-red-50"
|
||||
>
|
||||
@@ -43,12 +45,13 @@ const TransactionDeleteAlert: React.FC<TransactionDeleteAlertProps> = ({ onDelet
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>지출 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
정말로 이 지출 항목을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.
|
||||
정말로 이 지출 항목을 삭제하시겠습니까? 이 작업은 취소할 수
|
||||
없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>취소</AlertDialogCancel>
|
||||
<Button
|
||||
<Button
|
||||
className="bg-red-500 hover:bg-red-600"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
|
||||
import React from 'react';
|
||||
import React from "react";
|
||||
interface TransactionDetailsProps {
|
||||
title: string;
|
||||
date: string;
|
||||
}
|
||||
const TransactionDetails: React.FC<TransactionDetailsProps> = ({
|
||||
title,
|
||||
date
|
||||
date,
|
||||
}) => {
|
||||
return <div>
|
||||
<h3 className="text-sm font-medium text-left">{title || '제목 없음'}</h3>
|
||||
<p className="text-xs text-gray-500 text-left">{date || '날짜 정보 없음'}</p>
|
||||
</div>;
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-left">{title || "제목 없음"}</h3>
|
||||
<p className="text-xs text-gray-500 text-left">
|
||||
{date || "날짜 정보 없음"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default TransactionDetails;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user