주요 변경사항: • Clerk 인증 시스템 통합 및 설정 • Supabase 데이터베이스 스키마 설계 및 적용 • JWT 기반 Row Level Security (RLS) 정책 구현 • 기존 Appwrite 인증을 Clerk로 완전 교체 기술적 개선: • 무한 로딩 문제 해결 - Index.tsx 인증 로직 수정 • React root 마운팅 오류 수정 - main.tsx 개선 • CORS 설정 추가 - vite.config.ts 수정 • Sentry 에러 모니터링 통합 추가된 컴포넌트: • AuthGuard: 인증 보호 컴포넌트 • SignIn/SignUp: Clerk 기반 인증 UI • ClerkProvider: Clerk 설정 래퍼 • EnvTest: 개발환경 디버깅 도구 데이터베이스: • user_profiles, transactions, budgets, category_budgets 테이블 • Clerk JWT 토큰 기반 RLS 정책 • 자동 사용자 프로필 생성 및 동기화 Task Master: • Task 11.1, 11.2, 11.4 완료 • 프로젝트 관리 시스템 업데이트 Note: ESLint 정리는 별도 커밋에서 진행 예정 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
246 lines
8.7 KiB
TypeScript
246 lines
8.7 KiB
TypeScript
import React, { useState, useEffect, Suspense, lazy } from "react";
|
|
import { logger } from "@/utils/logger";
|
|
import NavBar from "@/components/NavBar";
|
|
import { useBudget } from "@/stores";
|
|
import { MONTHS_KR } from "@/hooks/useTransactions";
|
|
import { useIsMobile } from "@/hooks/use-mobile";
|
|
import { getCategoryColor } from "@/utils/categoryColorUtils";
|
|
import { MonthlyData } from "@/types";
|
|
|
|
// 차트 관련 컴포넌트들을 동적 import로 변경
|
|
const ExpenseChart = lazy(() => import("@/components/ExpenseChart"));
|
|
const PeriodSelector = lazy(
|
|
() => import("@/components/analytics/PeriodSelector")
|
|
);
|
|
const SummaryCards = lazy(() => import("@/components/analytics/SummaryCards"));
|
|
const MonthlyComparisonChart = lazy(
|
|
() => import("@/components/analytics/MonthlyComparisonChart")
|
|
);
|
|
const CategorySpendingList = lazy(
|
|
() => import("@/components/analytics/CategorySpendingList")
|
|
);
|
|
const PaymentMethodChart = lazy(
|
|
() => import("@/components/analytics/PaymentMethodChart")
|
|
);
|
|
const AddTransactionButton = lazy(
|
|
() => import("@/components/AddTransactionButton")
|
|
);
|
|
|
|
// 로딩 스피너 컴포넌트
|
|
const ChartLoadingSpinner = () => (
|
|
<div className="h-48 flex items-center justify-center">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
|
|
</div>
|
|
);
|
|
|
|
const Analytics = () => {
|
|
const [_selectedPeriod, _setSelectedPeriod] = useState("이번 달");
|
|
const {
|
|
budgetData,
|
|
getCategorySpending,
|
|
getPaymentMethodStats,
|
|
// 새로 추가된 메서드
|
|
transactions: _transactions,
|
|
} = useBudget();
|
|
const _isMobile = useIsMobile();
|
|
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
|
const [monthlyData, setMonthlyData] = useState<MonthlyData[]>([]);
|
|
|
|
// 페이지 가시성 변경시 데이터 새로고침
|
|
useEffect(() => {
|
|
const handleVisibilityChange = () => {
|
|
if (document.visibilityState === "visible") {
|
|
logger.info("분석 페이지 보임 - 데이터 새로고침");
|
|
setRefreshTrigger((prev) => prev + 1);
|
|
|
|
// 이벤트 발생시켜 데이터 새로고침
|
|
try {
|
|
window.dispatchEvent(new Event("storage"));
|
|
window.dispatchEvent(new Event("transactionUpdated"));
|
|
window.dispatchEvent(new Event("budgetDataUpdated"));
|
|
window.dispatchEvent(new Event("categoryBudgetsUpdated"));
|
|
} catch (e) {
|
|
logger.error("이벤트 발생 오류:", e);
|
|
}
|
|
}
|
|
};
|
|
const handleFocus = () => {
|
|
logger.info("분석 페이지 포커스 - 데이터 새로고침");
|
|
setRefreshTrigger((prev) => prev + 1);
|
|
|
|
// 이벤트 발생시켜 데이터 새로고침
|
|
try {
|
|
window.dispatchEvent(new Event("storage"));
|
|
window.dispatchEvent(new Event("transactionUpdated"));
|
|
window.dispatchEvent(new Event("budgetDataUpdated"));
|
|
window.dispatchEvent(new Event("categoryBudgetsUpdated"));
|
|
} catch (e) {
|
|
logger.error("이벤트 발생 오류:", e);
|
|
}
|
|
};
|
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
window.addEventListener("focus", handleFocus);
|
|
window.addEventListener("transactionUpdated", () =>
|
|
setRefreshTrigger((prev) => prev + 1)
|
|
);
|
|
window.addEventListener("budgetDataUpdated", () =>
|
|
setRefreshTrigger((prev) => prev + 1)
|
|
);
|
|
window.addEventListener("categoryBudgetsUpdated", () =>
|
|
setRefreshTrigger((prev) => prev + 1)
|
|
);
|
|
|
|
// 컴포넌트 마운트 시 초기 데이터 로드 이벤트 트리거
|
|
handleFocus();
|
|
return () => {
|
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
window.removeEventListener("focus", handleFocus);
|
|
window.removeEventListener("transactionUpdated", () => {});
|
|
window.removeEventListener("budgetDataUpdated", () => {});
|
|
window.removeEventListener("categoryBudgetsUpdated", () => {});
|
|
};
|
|
}, []);
|
|
|
|
// 실제 예산 및 지출 데이터 사용
|
|
const totalBudget = budgetData?.monthly?.targetAmount || 0;
|
|
const totalExpense = budgetData?.monthly?.spentAmount || 0;
|
|
const savings = Math.max(0, totalBudget - totalExpense);
|
|
const savingsPercentage =
|
|
totalBudget > 0 ? Math.round((savings / totalBudget) * 100) : 0;
|
|
|
|
// 카테고리별 지출 차트 데이터 생성 - 색상 유틸리티 사용
|
|
const categorySpending = getCategorySpending();
|
|
const expenseData = categorySpending.map((category) => ({
|
|
name: category.title,
|
|
value: category.current,
|
|
color: getCategoryColor(category.title), // 일관된 색상 적용
|
|
}));
|
|
|
|
// 결제 방법 데이터 가져오기
|
|
const paymentMethodData = getPaymentMethodStats();
|
|
const hasPaymentData = paymentMethodData.some((method) => method.amount > 0);
|
|
|
|
// 월별 데이터 생성 - 샘플 데이터 제거하고 현재 달만 실제 데이터 사용
|
|
useEffect(() => {
|
|
logger.info("Analytics 페이지: 월별 데이터 생성", {
|
|
totalBudget,
|
|
totalExpense,
|
|
});
|
|
|
|
// 현재 월 가져오기
|
|
const today = new Date();
|
|
const currentMonth = today.getMonth();
|
|
|
|
// 현재 달만 실제 데이터 사용하는 배열 생성
|
|
const monthlyDataArray = [
|
|
{
|
|
name: MONTHS_KR[currentMonth].split(" ")[0],
|
|
// '8월' 형식으로 변환
|
|
budget: totalBudget,
|
|
expense: totalExpense,
|
|
},
|
|
];
|
|
setMonthlyData(monthlyDataArray);
|
|
logger.info("Analytics 페이지: 월별 데이터 생성 완료", monthlyDataArray);
|
|
}, [totalBudget, totalExpense, refreshTrigger]);
|
|
|
|
// 이전/다음 기간 이동 처리
|
|
const handlePrevPeriod = () => {
|
|
logger.info("이전 기간으로 이동");
|
|
};
|
|
const handleNextPeriod = () => {
|
|
logger.info("다음 기간으로 이동");
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen bg-neuro-background pb-24">
|
|
<div className="max-w-md mx-auto px-6">
|
|
{/* Header */}
|
|
<header className="py-4 w-full">
|
|
<h1 className="font-bold neuro-text mb-3 text-xl">지출 분석</h1>
|
|
|
|
{/* Period Selector */}
|
|
<Suspense fallback={<ChartLoadingSpinner />}>
|
|
<PeriodSelector
|
|
selectedPeriod={_selectedPeriod}
|
|
onPrevPeriod={handlePrevPeriod}
|
|
onNextPeriod={handleNextPeriod}
|
|
/>
|
|
</Suspense>
|
|
|
|
{/* Summary Cards */}
|
|
<Suspense fallback={<ChartLoadingSpinner />}>
|
|
<SummaryCards
|
|
totalBudget={totalBudget}
|
|
totalExpense={totalExpense}
|
|
savingsPercentage={savingsPercentage}
|
|
/>
|
|
</Suspense>
|
|
</header>
|
|
|
|
{/* Monthly Comparison Chart */}
|
|
<div className="mb-8 w-full">
|
|
<h2 className="text-lg font-semibold mb-3">월별 그래프</h2>
|
|
<Suspense fallback={<ChartLoadingSpinner />}>
|
|
<MonthlyComparisonChart
|
|
monthlyData={monthlyData}
|
|
isEmpty={totalBudget === 0 && totalExpense === 0}
|
|
/>
|
|
</Suspense>
|
|
</div>
|
|
|
|
{/* 카테고리 비율과 지출을 하나의 카드로 합침 */}
|
|
<h2 className="text-lg font-semibold mb-3">카테고리 비율</h2>
|
|
<div className="neuro-card w-full mb-8">
|
|
<div className="w-full">
|
|
{expenseData.some((item) => item.value > 0) ? (
|
|
<>
|
|
<div className="h-72 flex items-center justify-center">
|
|
<Suspense fallback={<ChartLoadingSpinner />}>
|
|
<ExpenseChart data={expenseData} />
|
|
</Suspense>
|
|
</div>
|
|
{/* 원그래프 아래에 카테고리 지출 목록 추가 */}
|
|
<Suspense fallback={<ChartLoadingSpinner />}>
|
|
<CategorySpendingList
|
|
categories={categorySpending}
|
|
totalExpense={totalExpense}
|
|
showCard={false} // 카드 감싸지 않도록 설정
|
|
/>
|
|
</Suspense>
|
|
</>
|
|
) : (
|
|
<div className="h-52 w-full flex items-center justify-center text-gray-400">
|
|
<p>데이터가 없습니다</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 결제 방법 차트 추가 */}
|
|
<h2 className="text-lg font-semibold mb-3">결제 방법 비율</h2>
|
|
<Suspense fallback={<ChartLoadingSpinner />}>
|
|
<PaymentMethodChart
|
|
data={paymentMethodData}
|
|
isEmpty={!hasPaymentData}
|
|
/>
|
|
</Suspense>
|
|
|
|
{/* 결제 방법 차트 아래 80px 여유 공간 추가 */}
|
|
<div className="h-20"></div>
|
|
</div>
|
|
|
|
<Suspense
|
|
fallback={
|
|
<div className="fixed bottom-20 right-4 w-14 h-14 rounded-full bg-gray-200 animate-pulse"></div>
|
|
}
|
|
>
|
|
<AddTransactionButton />
|
|
</Suspense>
|
|
<NavBar />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Analytics;
|