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:
hansoo
2025-07-12 15:27:54 +09:00
parent 6a208d6b06
commit 9851627ff1
411 changed files with 14458 additions and 8680 deletions

View File

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

View File

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

View File

@@ -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. 앱 심사 제출
## 중요 팁
- 배포 전 다양한 기기에서 앱 테스트 필수
- 앱 출시 후 지속적인 모니터링 및 업데이트 계획
- 사용자 피드백 수집 및 반영 메커니즘 구축

View File

@@ -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 앱**: 아이폰 및 아이패드 지원
- **안드로이드 앱**: 안드로이드 기기 지원
- **웹 앱**: 브라우저에서도 동일한 경험
### 네이티브 기능
- **푸시 알림**: 중요 알림 실시간 전달
- **오프라인 모드**: 인터넷 연결 없이도 기본 기능 사용
- **기기 저장소 접근**: 데이터 내보내기/가져오기

View File

@@ -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'}`}>

View File

@@ -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({

View File

@@ -20,7 +20,7 @@ const DebugInfoCollapsible: React.FC<DebugInfoCollapsibleProps> = ({
showDebug,
setShowDebug
}) => {
if (!testResults.debugInfo) return null;
if (!testResults.debugInfo) {return null;}
return (
<Collapsible

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = {

View File

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

View File

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

View File

@@ -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 || '알 수 없는 오류'}`

View File

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

View File

@@ -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 || '알 수 없는 오류'}`

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) => (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 제거 */}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
import React from 'react';
import React from "react";
// 이 컴포넌트는 더 이상 사용되지 않으며 BudgetDialog로 대체되었습니다
const BudgetInputForm = () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}
/>
) : (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) => (

View File

@@ -1,5 +1,4 @@
import { ReactNode } from 'react';
import { ReactNode } from "react";
export type SecuritySetting = {
id: string;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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