= {
+ transfer: { bgColor: 'bg-atomic-yellow-90', barColor: 'bg-atomic-yellow-50', icon: WIcon }, // 이체
+ shopping: { bgColor: 'bg-atomic-orange-90', barColor: 'bg-atomic-orange-50', icon: ShoppingBag }, // 쇼핑
+ traffic: { bgColor: 'bg-atomic-red-90', barColor: 'bg-atomic-red-50', icon: TrafficIcon }, // 교통
+ food: { bgColor: 'bg-atomic-blue-90', barColor: 'bg-atomic-blue-50', icon: DishIcon }, // 음식
+ leisure: { bgColor: 'bg-neutral-10', barColor: 'bg-neutral-30', icon: LeisureIcon }, // 여가
+ default: { bgColor: 'bg-neutral-30', barColor: 'bg-neutral-50', icon: DefaultIcon }, // 기본값
+ medical: { bgColor: 'bg-neutral-40', barColor: 'bg-neutral-60', icon: MedicalIcon }, // 의료
+ market: { bgColor: 'bg-atomic-light-blue-90', barColor: 'bg-atomic-light-blue-50', icon: ShoppingIcon }, // 마켓
+ living: { bgColor: 'bg-atomic-purple-90', barColor: 'bg-atomic-purple-50', icon: LivingIcon }, // 주거
+ cafe: { bgColor: 'bg-atomic-green-90', barColor: 'bg-atomic-green-50', icon: CafeIcon }, // 카페
+ others: { bgColor: 'bg-neutral-60', barColor: 'bg-neutral-80', icon: OthersIcon }, // 그외
+};
\ No newline at end of file
diff --git a/src/features/asset/constants/mockData.ts b/src/features/asset/constants/mockData.ts
new file mode 100644
index 0000000..72ae69a
--- /dev/null
+++ b/src/features/asset/constants/mockData.ts
@@ -0,0 +1,333 @@
+import { TransactionItem } from '@/features/asset/constants/account';
+
+/**
+ * 💡 분석 페이지에서 사용하는 Mock 데이터
+ * 실제 API 연동 전까지 데이터 소스로 활용하며,
+ * hook(useGetAssetAnalysis)에서 import하여 사용합니다.
+ */
+export const ASSET_ANALYSIS_RAW_DATA: (TransactionItem & { date: string })[] = [
+ // --- 2026년 1월 (이번 달) ---
+ {
+ id: 101,
+ title: '신한할인캐쉬백',
+ sub: '금융수입 | 쏠편한 입출금통장',
+ amount: 3000,
+ type: 'income',
+ category: 'transfer',
+ date: '2026-01-19',
+ },
+ {
+ id: 102,
+ title: '김*주',
+ sub: '내계좌이체 | KB국민ONE통장',
+ amount: -30000,
+ type: 'expense',
+ category: 'transfer',
+ date: '2026-01-19',
+ },
+ {
+ id: 103,
+ title: '스타벅스 사당점',
+ sub: '식비 | 체크카드',
+ amount: -4500,
+ type: 'expense',
+ category: 'food',
+ date: '2026-01-19',
+ },
+ {
+ id: 104,
+ title: '아웃백 스테이크',
+ sub: '식비 | 신용카드',
+ amount: -150000,
+ type: 'expense',
+ category: 'food',
+ date: '2026-01-19',
+ },
+ {
+ id: 105,
+ title: '카카오T_택시',
+ sub: '교통 | 카카오뱅크 카드',
+ amount: -15000,
+ type: 'expense',
+ category: 'traffic',
+ date: '2026-01-19',
+ },
+ {
+ id: 106,
+ title: '올리브영 사당',
+ sub: '쇼핑 | 화장품',
+ amount: -30000,
+ type: 'expense',
+ category: 'shopping',
+ date: '2026-01-19',
+ },
+ {
+ id: 107,
+ title: '한림대병원',
+ sub: '의료 | 신용카드',
+ amount: -5000,
+ type: 'expense',
+ category: 'medical',
+ date: '2026-01-19',
+ },
+ {
+ id: 108,
+ title: '기타 지출',
+ sub: '카테고리 없음',
+ amount: -15000,
+ type: 'expense',
+ category: 'default',
+ date: '2026-01-19',
+ },
+
+ // --- 1월 중순 내역 ---
+ {
+ id: 109,
+ title: '맥도날드',
+ sub: '식비 | 현금',
+ amount: -78500,
+ type: 'expense',
+ category: 'food',
+ date: '2026-01-18',
+ },
+ {
+ id: 110,
+ title: '교보문고',
+ sub: '도서구입',
+ amount: -23000,
+ type: 'expense',
+ category: 'default',
+ date: '2026-01-18',
+ },
+ {
+ id: 111,
+ title: 'CU',
+ sub: '편의점 | 체크카드',
+ amount: -2500,
+ type: 'expense',
+ category: 'market',
+ date: '2026-01-18',
+ },
+ {
+ id: 112,
+ title: 'SKT',
+ sub: '통신 | 신용카드',
+ amount: -20000,
+ type: 'expense',
+ category: 'living',
+ date: '2026-01-17',
+ },
+ {
+ id: 113,
+ title: '메가커피',
+ sub: '카페 | 체크카드',
+ amount: -8000,
+ type: 'expense',
+ category: 'cafe',
+ date: '2026-01-17',
+ },
+ {
+ id: 114,
+ title: '김*주',
+ sub: '내계좌이체 | KB국민ONE통장',
+ amount: -30000,
+ type: 'expense',
+ category: 'transfer',
+ date: '2026-01-17',
+ },
+ {
+ id: 115,
+ title: '신한할인캐쉬백',
+ sub: '금융수입 | 쏠편한 입출금통장',
+ amount: 3000,
+ type: 'income',
+ category: 'transfer',
+ date: '2026-01-17',
+ },
+ {
+ id: 116,
+ title: '카카오T_택시',
+ sub: '교통 | 카카오뱅크 카드',
+ amount: -15000,
+ type: 'expense',
+ category: 'traffic',
+ date: '2026-01-17',
+ },
+ {
+ id: 117,
+ title: '올리브영 사당',
+ sub: '쇼핑 | 화장품',
+ amount: -30000,
+ type: 'expense',
+ category: 'shopping',
+ date: '2026-01-17',
+ },
+
+ // --- 2025년 12월 (지난달) ---
+ {
+ id: 201,
+ title: 'CGV 사당',
+ sub: '여가 | 영화 예매',
+ amount: -45000,
+ type: 'expense',
+ category: 'leisure',
+ date: '2025-12-25',
+ },
+ {
+ id: 202,
+ title: '올리브영 사당',
+ sub: '쇼핑 | 화장품',
+ amount: -30000,
+ type: 'expense',
+ category: 'shopping',
+ date: '2025-12-20',
+ },
+ {
+ id: 203,
+ title: '카카오T_택시',
+ sub: '교통 | 카카오뱅크 카드',
+ amount: -15000,
+ type: 'expense',
+ category: 'traffic',
+ date: '2025-12-15',
+ },
+ {
+ id: 204,
+ title: '신한할인캐쉬백',
+ sub: '금융수입',
+ amount: 3000,
+ type: 'income',
+ category: 'transfer',
+ date: '2025-12-10',
+ },
+ {
+ id: 205,
+ title: '신한할인캐쉬백',
+ sub: '금융수입 | 쏠편한 입출금통장',
+ amount: 3000,
+ type: 'income',
+ category: 'transfer',
+ date: '2025-12-10',
+ },
+
+ // --- 2025년 11월 ---
+ {
+ id: 301,
+ title: '유니클로',
+ sub: '쇼핑 | 의류',
+ amount: -89000,
+ type: 'expense',
+ category: 'shopping',
+ date: '2025-11-15',
+ },
+ {
+ id: 302,
+ title: '지하철 정기권',
+ sub: '교통 | 신용카드',
+ amount: -55000,
+ type: 'expense',
+ category: 'traffic',
+ date: '2025-11-15',
+ },
+ {
+ id: 305,
+ title: '올리브영 사당',
+ sub: '쇼핑 | 화장품',
+ amount: -30000,
+ type: 'expense',
+ category: 'shopping',
+ date: '2025-11-02',
+ },
+ {
+ id: 303,
+ title: '김*주',
+ sub: '내계좌이체 | KB국민ONE통장',
+ amount: -30000,
+ type: 'expense',
+ category: 'transfer',
+ date: '2025-11-01',
+ },
+ {
+ id: 304,
+ title: '신한할인캐쉬백',
+ sub: '금융수입 | 쏠편한 입출금통장',
+ amount: 3000,
+ type: 'income',
+ category: 'transfer',
+ date: '2025-11-01',
+ },
+
+ // --- 2025년 10월 ---
+ {
+ id: 401,
+ title: '카카오T_택시',
+ sub: '교통 | 카카오뱅크 카드',
+ amount: -15000,
+ type: 'expense',
+ category: 'traffic',
+ date: '2025-10-15',
+ },
+ {
+ id: 402,
+ title: '지하철 정기권',
+ sub: '교통 | 신용카드',
+ amount: -100000,
+ type: 'expense',
+ category: 'traffic',
+ date: '2025-10-15',
+ },
+ {
+ id: 403,
+ title: '올리브영 사당',
+ sub: '쇼핑 | 화장품',
+ amount: -30000,
+ type: 'expense',
+ category: 'shopping',
+ date: '2025-10-02',
+ },
+ {
+ id: 404,
+ title: '김*주',
+ sub: '내계좌이체 | KB국민ONE통장',
+ amount: -30000,
+ type: 'expense',
+ category: 'transfer',
+ date: '2025-10-01',
+ },
+ {
+ id: 405,
+ title: '신한할인캐쉬백',
+ sub: '금융수입 | 쏠편한 입출금통장',
+ amount: 3000,
+ type: 'income',
+ category: 'transfer',
+ date: '2025-10-01',
+ },
+ {
+ id: 406,
+ title: 'SKT',
+ sub: '통신 | 신용카드',
+ amount: -20000,
+ type: 'expense',
+ category: 'living',
+ date: '2025-10-17',
+ },
+ {
+ id: 407,
+ title: '메가커피',
+ sub: '카페 | 체크카드',
+ amount: -8000,
+ type: 'expense',
+ category: 'cafe',
+ date: '2025-10-17',
+ },
+ {
+ id: 408,
+ title: '하이디라오',
+ sub: '식비 | 신용카드',
+ amount: -100000,
+ type: 'expense',
+ category: 'food',
+ date: '2025-10-18',
+ },
+];
diff --git a/src/hooks/Asset/useGetAccountDetail.ts b/src/hooks/Asset/useGetAccountDetail.ts
index 2887367..ee3e12c 100644
--- a/src/hooks/Asset/useGetAccountDetail.ts
+++ b/src/hooks/Asset/useGetAccountDetail.ts
@@ -103,4 +103,4 @@ export const useGetAccountDetail = () => {
transactionHistory: mockHistory,
totalCount: 10,
};
-};
+};
\ No newline at end of file
diff --git a/src/hooks/Asset/useGetAssetAnalysis.ts b/src/hooks/Asset/useGetAssetAnalysis.ts
new file mode 100644
index 0000000..d0e8a71
--- /dev/null
+++ b/src/hooks/Asset/useGetAssetAnalysis.ts
@@ -0,0 +1,83 @@
+import { useState, useEffect, useMemo } from 'react';
+import { transformToCategoryGroups, TransactionWithDetails } from '@/pages/Asset/tab/SectorAnalysis/utils/sectorUtils';
+import { useGetAccountDetail } from '@/hooks/Asset/useGetAccountDetail';
+import { ASSET_ANALYSIS_RAW_DATA } from '@/features/asset/constants/mockData'; // 💡 데이터 소스 임포트
+
+export const useGetAssetAnalysis = (selectedDate: Date = new Date()) => {
+ const { accountInfo } = useGetAccountDetail();
+ const accountDisplay = accountInfo?.accountNumber || '국민은행 592802-04-170725';
+
+ // 💡 1. 로딩 상태 관리 (스켈레톤 제어용)
+ const [isLoading, setIsLoading] = useState(true);
+
+ // 💡 2. 날짜 변경 시 0.8초 동안 로딩 상태 유지
+ useEffect(() => {
+ setIsLoading(true);
+ const timer = setTimeout(() => {
+ setIsLoading(false);
+ }, 800);
+ return () => clearTimeout(timer);
+ }, [selectedDate]);
+
+ // 💡 3. 선택된 연/월에 맞는 데이터 필터링
+ const filteredData = useMemo(() => {
+ const targetYear = selectedDate.getFullYear();
+ const targetMonth = selectedDate.getMonth();
+
+ return ASSET_ANALYSIS_RAW_DATA.filter((item) => {
+ const itemDate = new Date(item.date);
+ return (
+ itemDate.getFullYear() === targetYear &&
+ itemDate.getMonth() === targetMonth
+ );
+ });
+ }, [selectedDate]);
+
+ // 💡 4. 상세 정보를 포함한 트랜잭션 데이터 가공
+ const mockTransactions = useMemo((): TransactionWithDetails[] => {
+ // 임시 시작 잔액
+ let tempBalance = 5230450;
+ return filteredData.map((item) => {
+ const simpleType = item.sub.includes('|') ? item.sub.split('|')[1].trim() : item.sub;
+ const currentBalance = tempBalance;
+ tempBalance -= item.amount; // 다음 아이템을 위해 역산 (리스트가 최신순일 경우)
+
+ return {
+ ...item,
+ displayDetails: [
+ { label: '거래시간', value: `${item.date.replace(/-/g, '.')} 18:44:44` },
+ { label: '거래구분', value: simpleType },
+ { label: '거래금액', value: `${Math.abs(item.amount).toLocaleString()}원`, isBold: true },
+ { label: '거래 후 잔액', value: `${currentBalance.toLocaleString()}원` },
+ { label: '입금계좌', value: accountDisplay },
+ ],
+ };
+ });
+ }, [filteredData, accountDisplay]);
+
+ // 💡 5. 총 지출액 계산
+ const totalExpense = useMemo(
+ () =>
+ mockTransactions
+ .filter((item) => item.type === 'expense')
+ .reduce((sum, item) => sum + Math.abs(item.amount), 0),
+ [mockTransactions]
+ );
+
+ // 💡 6. 카테고리별 그룹화 데이터 생성
+ const allSectors = useMemo(
+ () => transformToCategoryGroups(mockTransactions, totalExpense),
+ [mockTransactions, totalExpense]
+ );
+
+ return {
+ isLoading, // 💡 부모에게 로딩 상태 전달
+ totalExpense,
+ transactions: mockTransactions,
+ allSectors,
+ topSectors: allSectors.slice(0, 6),
+ otherSectors: allSectors.slice(6),
+ otherCount: Math.max(0, allSectors.length - 6),
+ otherTotalAmount: allSectors.slice(6).reduce((sum, s) => sum + s.amount, 0),
+ };
+};
\ No newline at end of file
diff --git a/src/pages/Asset/AssetPage.tsx b/src/pages/Asset/AssetPage.tsx
index c23bc33..c5c4f3e 100644
--- a/src/pages/Asset/AssetPage.tsx
+++ b/src/pages/Asset/AssetPage.tsx
@@ -2,16 +2,25 @@ import { useState } from 'react';
import { MobileLayout } from '@/components/layout/MobileLayout';
import { HomeGNB } from '@/components/gnb/HomeGNB';
import { BottomNavigation } from '@/components/gnb/BottomNavigation';
-import { useNavigate } from 'react-router-dom';
+import { useLocation, useNavigate } from 'react-router-dom';
import { Typography } from '@/components';
import { cn } from '@/utils/cn';
import { AssetDetails } from './tab/AssetDetails/AssetDetailsPage';
+import { SectorAnalysis } from './tab/SectorAnalysis/SectorAnalysisPage';
+import { CompareAnalysis } from './tab/CompareAnalysis/CompareAnalysisPage';
export const AssetPage = () => {
const navigate = useNavigate();
+ const { pathname } = useLocation();
- const [activeTab, setActiveTab] = useState<'details' | 'sector' | 'compare'>('details');
+ const activeTab = pathname.includes('/sector') ? 'sector' :
+ pathname.includes('/compare') ? 'compare' : 'details';
+
+ const handleTabClick = (tab: 'details' | 'sector' | 'compare') => {
+ if (tab === 'details') navigate('/asset');
+ else navigate(`/asset/${tab}`);
+ };
const handleNavClick = (item: 'home' | 'asset' | 'recommend' | 'goal') => {
switch (item) {
@@ -38,7 +47,7 @@ export const AssetPage = () => {
- {activeTab === 'details' &&
}
+
+ {activeTab === 'details' &&
}
+ {activeTab === 'sector' &&
}
+ {activeTab === 'compare' &&
}
+
@@ -86,4 +99,4 @@ export const AssetPage = () => {
);
};
-export default AssetPage;
+export default AssetPage;
\ No newline at end of file
diff --git a/src/pages/Asset/tab/AssetDetails/components/AssetLedger.tsx b/src/pages/Asset/tab/AssetDetails/components/AssetLedger.tsx
index e00b806..bb9ee42 100644
--- a/src/pages/Asset/tab/AssetDetails/components/AssetLedger.tsx
+++ b/src/pages/Asset/tab/AssetDetails/components/AssetLedger.tsx
@@ -80,4 +80,4 @@ export const AssetLedger = () => {
{viewMode === 'list' ? : }
);
-};
+};
\ No newline at end of file
diff --git a/src/pages/Asset/tab/AssetDetails/components/AssetList.tsx b/src/pages/Asset/tab/AssetDetails/components/AssetList.tsx
index 3b8ced4..fcc1913 100644
--- a/src/pages/Asset/tab/AssetDetails/components/AssetList.tsx
+++ b/src/pages/Asset/tab/AssetDetails/components/AssetList.tsx
@@ -150,4 +150,4 @@ export const AssetList = () => {
);
-};
+};
\ No newline at end of file
diff --git a/src/pages/Asset/tab/CompareAnalysis/CompareAnalysisPage.tsx b/src/pages/Asset/tab/CompareAnalysis/CompareAnalysisPage.tsx
new file mode 100644
index 0000000..86a543e
--- /dev/null
+++ b/src/pages/Asset/tab/CompareAnalysis/CompareAnalysisPage.tsx
@@ -0,0 +1,38 @@
+import { useState, useEffect } from 'react'; // 💡 추가
+import { MobileLayout } from '@/components/layout/MobileLayout';
+import { PeerCompareSection } from './components/PeerCompareSection';
+import { CategoryCompareSection } from './components/CategoryCompareSection';
+import { HistoryCompareSection } from './components/HistoryCompareSection';
+
+export const CompareAnalysis = () => {
+ // 💡 1. 로딩 상태 선언 (기본값 true)
+ const [isLoading, setIsLoading] = useState(true);
+
+ // 💡 2. 페이지 진입 시 0.8초 딜레이
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ setIsLoading(false);
+ }, 800);
+ return () => clearTimeout(timer);
+ }, []);
+
+ return (
+
+
+
+ {/* 1. 또래별 비교 섹션 */}
+
+
+
+
+ {/* 2. 카테고리별 비교 섹션 */}
+
+
+
+
+ {/* 3. 소비내역 비교 섹션 */}
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/pages/Asset/tab/CompareAnalysis/components/CategoryCompareSection.tsx b/src/pages/Asset/tab/CompareAnalysis/components/CategoryCompareSection.tsx
new file mode 100644
index 0000000..4102efd
--- /dev/null
+++ b/src/pages/Asset/tab/CompareAnalysis/components/CategoryCompareSection.tsx
@@ -0,0 +1,140 @@
+import { useState, useMemo, useRef } from 'react';
+import { Typography } from '@/components/typography';
+import { formatCurrency } from '@/utils/formatCurrency';
+import { useGetAssetAnalysis } from '@/hooks/Asset/useGetAssetAnalysis';
+import { PEER_AVERAGE_DATA } from '../constants/mockData';
+import { CompareBar } from './CompareBar';
+import { cn } from '@/utils/cn';
+import { Skeleton } from '@/components/skeleton/Skeleton'; // 💡 1. 추가
+import { CompareBarSkeleton } from './CompareBarSkeleton'; // 💡 2. 추가
+
+const DISPLAY_NAMES: Record = {
+ traffic: '교통',
+ transfer: '금융',
+ food: '식비',
+ living: '주거/통신',
+ shopping: '쇼핑',
+ leisure: '문화생활',
+};
+
+const TARGET_CATEGORIES = Object.keys(DISPLAY_NAMES);
+
+// 💡 3. 인터페이스 추가
+interface CategoryCompareSectionProps {
+ isLoading?: boolean;
+}
+
+export const CategoryCompareSection = ({ isLoading = false }: CategoryCompareSectionProps) => {
+ const [selectedCategory, setSelectedCategory] = useState('traffic');
+ const scrollRef = useRef(null);
+
+ const now = new Date();
+ const { transactions } = useGetAssetAnalysis(now);
+
+ const handleCategoryClick = (catKey: string, e: React.MouseEvent) => {
+ setSelectedCategory(catKey);
+ const container = scrollRef.current;
+ const target = e.currentTarget;
+
+ if (container && target) {
+ const containerWidth = container.offsetWidth;
+ const targetOffset = target.offsetLeft;
+ const targetWidth = target.offsetWidth;
+ const scrollTo = Math.max(0, targetOffset - containerWidth / 2 + targetWidth / 2);
+
+ container.scrollTo({ left: scrollTo, behavior: 'smooth' });
+ }
+ };
+
+ const myCategoryTotal = useMemo(() => {
+ return transactions
+ .filter((item) => item.category === selectedCategory && item.type === 'expense')
+ .reduce((sum, item) => sum + Math.abs(item.amount), 0);
+ }, [transactions, selectedCategory]);
+
+ const peerCategoryTotal = PEER_AVERAGE_DATA.categories[selectedCategory] || 0;
+
+ return (
+
+
+ 카테고리별 비교
+
+
+ {/* 💡 4. 칩 영역 로딩 처리 */}
+
+ {isLoading ? (
+ // 로딩 중일 땐 칩 모양 스켈레톤 5개 표시 ㅋ
+ Array.from({ length: 5 }).map((_, idx) => (
+
+ ))
+ ) : (
+ TARGET_CATEGORIES.map((catKey) => {
+ const isSelected = selectedCategory === catKey;
+ return (
+
+ );
+ })
+ )}
+
+
+ {/* 💡 5. 바 차트 영역 로딩 처리 */}
+
+ {isLoading ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+ {/* 💡 6. 하단 요약 카드 로딩 처리 */}
+
+ {isLoading ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+
+ 내 소비
+ {formatCurrency(myCategoryTotal)}
+
+
+ 또래 평균
+ {formatCurrency(peerCategoryTotal)}
+
+ >
+ )}
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/pages/Asset/tab/CompareAnalysis/components/CompareBar.tsx b/src/pages/Asset/tab/CompareAnalysis/components/CompareBar.tsx
new file mode 100644
index 0000000..ae65195
--- /dev/null
+++ b/src/pages/Asset/tab/CompareAnalysis/components/CompareBar.tsx
@@ -0,0 +1,42 @@
+import { Typography } from '@/components/typography';
+import { formatCurrency } from '@/utils/formatCurrency';
+import { cn } from '@/utils/cn';
+
+interface CompareBarProps {
+ label: string;
+ amount: number;
+ isHighlight?: boolean;
+ maxAmount?: number;
+}
+
+export const CompareBar = ({ label, amount, isHighlight, maxAmount = 200000 }: CompareBarProps) => {
+ const barHeight = Math.max(10, (amount / maxAmount) * 120);
+
+ return (
+
+ {/* 1. 금액 */}
+
+ {formatCurrency(amount)}
+
+
+ {/* 2. 막대 */}
+
+
+ {/* 3. 라벨 */}
+
+ {label}
+
+
+ );
+};
diff --git a/src/pages/Asset/tab/CompareAnalysis/components/CompareBarSkeleton.tsx b/src/pages/Asset/tab/CompareAnalysis/components/CompareBarSkeleton.tsx
new file mode 100644
index 0000000..446f3c2
--- /dev/null
+++ b/src/pages/Asset/tab/CompareAnalysis/components/CompareBarSkeleton.tsx
@@ -0,0 +1,16 @@
+import { Skeleton } from '@/components/skeleton/Skeleton';
+
+export const CompareBarSkeleton = () => {
+ return (
+
+ {/* 1. 금액 자리 (회색 박스) */}
+
+
+ {/* 2. 막대 자리 (기본 높이로 고정) */}
+
+
+ {/* 3. 라벨 자리 */}
+
+
+ );
+};
diff --git a/src/pages/Asset/tab/CompareAnalysis/components/HistoryCompareSection.tsx b/src/pages/Asset/tab/CompareAnalysis/components/HistoryCompareSection.tsx
new file mode 100644
index 0000000..fda2df1
--- /dev/null
+++ b/src/pages/Asset/tab/CompareAnalysis/components/HistoryCompareSection.tsx
@@ -0,0 +1,66 @@
+import { Typography } from '@/components/typography';
+import { useGetAssetAnalysis } from '@/hooks/Asset/useGetAssetAnalysis';
+import { CompareBar } from './CompareBar';
+import { formatCurrency } from '@/utils/formatCurrency';
+import { Skeleton } from '@/components/skeleton/Skeleton'; // 💡 1. 추가
+import { CompareBarSkeleton } from './CompareBarSkeleton'; // 💡 2. 추가
+
+// 💡 3. 인터페이스 추가
+interface HistoryCompareSectionProps {
+ isLoading?: boolean;
+}
+
+export const HistoryCompareSection = ({ isLoading = false }: HistoryCompareSectionProps) => {
+ // 1. 월별 기준 날짜 생성
+ const dateJan = new Date(2026, 0, 1);
+ const dateDec = new Date(2025, 11, 1);
+ const dateNov = new Date(2025, 10, 1);
+ const dateOct = new Date(2025, 9, 1);
+
+ // 2. 데이터 가져오기
+ const { totalExpense: totalJan } = useGetAssetAnalysis(dateJan);
+ const { totalExpense: totalDec } = useGetAssetAnalysis(dateDec);
+ const { totalExpense: totalNov } = useGetAssetAnalysis(dateNov);
+ const { totalExpense: totalOct } = useGetAssetAnalysis(dateOct);
+
+ const diffAmount = Math.abs(totalJan - totalDec);
+ const isReduced = totalJan < totalDec;
+ const maxAmount = Math.max(totalOct, totalNov, totalDec, totalJan, 150000);
+
+ return (
+
+
+ 소비내역 비교
+
+
+ {/* 💡 4. 문구 로딩 처리 */}
+ {isLoading ? (
+
+ ) : (
+
+ 소비내역이 지난 달보다 {formatCurrency(diffAmount)}{' '}
+ {isReduced ? '줄었어요' : '늘었어요'}
+
+ )}
+
+ {/* 💡 5. 4개월치 바 차트 스켈레톤/실제 데이터 */}
+
+ {isLoading ? (
+ <>
+
+
+
+
+ >
+ ) : (
+ <>
+
+
+
+
+ >
+ )}
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/pages/Asset/tab/CompareAnalysis/components/PeerCompareSection.tsx b/src/pages/Asset/tab/CompareAnalysis/components/PeerCompareSection.tsx
new file mode 100644
index 0000000..acc2b7d
--- /dev/null
+++ b/src/pages/Asset/tab/CompareAnalysis/components/PeerCompareSection.tsx
@@ -0,0 +1,61 @@
+import { Typography } from '@/components/typography';
+import { formatCurrency } from '@/utils/formatCurrency';
+import { useGetAssetAnalysis } from '@/hooks/Asset/useGetAssetAnalysis';
+import { PEER_AVERAGE_DATA } from '../constants/mockData';
+import { CompareBar } from './CompareBar';
+import { Skeleton } from '@/components/skeleton/Skeleton'; // 💡 1. 스켈레톤 임포트
+import { CompareBarSkeleton } from './CompareBarSkeleton'; // 💡 2. 바 차트 전용 스켈레톤
+
+// 💡 3. Props 인터페이스 추가
+interface PeerCompareSectionProps {
+ isLoading?: boolean;
+}
+
+export const PeerCompareSection = ({ isLoading = false }: PeerCompareSectionProps) => {
+ const now = new Date();
+ const { totalExpense: myTotal } = useGetAssetAnalysis(now);
+ const peerTotal = PEER_AVERAGE_DATA.total;
+
+ const diffAmount = Math.abs(myTotal - peerTotal);
+ const isMore = myTotal > peerTotal;
+
+ return (
+
+ {/* 제목 부분 */}
+
+ 또래별 비교
+
+
+ {/* 💡 4. 로딩 중일 땐 설명 문구 대신 스켈레톤! */}
+ {isLoading ? (
+
+ ) : (
+
+ 또래 평균보다 월별 {formatCurrency(diffAmount)}{' '}
+ {isMore ? '이상 더 지출해요' : '이하로 지출해요'}
+
+ )}
+
+ {/* 바 차트 컨테이너 */}
+
+ {/* 💡 5. 로딩 중일 땐 차트 대신 전용 스켈레톤 2개 배치! */}
+ {isLoading ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/pages/Asset/tab/CompareAnalysis/constants/mockData.ts b/src/pages/Asset/tab/CompareAnalysis/constants/mockData.ts
new file mode 100644
index 0000000..2199f2e
--- /dev/null
+++ b/src/pages/Asset/tab/CompareAnalysis/constants/mockData.ts
@@ -0,0 +1,23 @@
+/**
+ * 💡 비교 분석 페이지용 기준 데이터 (또래 평균)
+ * 내 소비 데이터와 비교할 '20대 또래 평균' 고정값들입니다.
+ */
+
+export const PEER_AVERAGE_DATA = {
+ // 1. 또래별 비교 (전체 평균 합계)
+ total: 124400,
+
+ // 2. 카테고리별 비교 (각 카테고리별 또래 평균 지출액)
+ categories: {
+ transfer: 110000, // 송금
+ food: 124400, // 식비
+ traffic: 45000, // 교통
+ shopping: 85000, // 쇼핑
+ medical: 15000, // 의료
+ cafe: 25000, // 카페/간식
+ market: 30000, // 편의점/마트
+ living: 60000, // 생활
+ leisure: 40000, // 문화/여가
+ default: 10000, // 기타
+ } as Record,
+};
diff --git a/src/pages/Asset/tab/SectorAnalysis/SectorAnalysisPage.tsx b/src/pages/Asset/tab/SectorAnalysis/SectorAnalysisPage.tsx
new file mode 100644
index 0000000..cecd91d
--- /dev/null
+++ b/src/pages/Asset/tab/SectorAnalysis/SectorAnalysisPage.tsx
@@ -0,0 +1,68 @@
+import { useState, useMemo } from 'react'; // 💡 useMemo 추가
+import { useLocation } from 'react-router-dom';
+import { MobileLayout } from '@/components/layout/MobileLayout';
+import { SectorSummarySection } from './sections/SectorSummarySection';
+import { SectorListSection } from './sections/SectorListSection';
+import { useGetAssetAnalysis } from '@/hooks/Asset/useGetAssetAnalysis';
+import { transformToCategoryGroups } from './utils/sectorUtils';
+
+export const SectorAnalysis = () => {
+ const location = useLocation();
+
+ // 선택된 날짜 상태 관리
+ const [selectedDate, setSelectedDate] = useState(
+ location.state?.selectedDate ? new Date(location.state.selectedDate) : new Date()
+ );
+
+ /**
+ * 💡 [성능 최적화] 무한 로딩 방지를 위해 날짜 객체 참조 고정
+ * selectedDate가 바뀔 때만 새로운 Date 객체를 생성하도록 합니다.
+ */
+ const memoizedDate = useMemo(() => new Date(selectedDate), [selectedDate]);
+
+ // 💡 1. 이번 달 데이터 가져오기 (isLoading 추가!)
+ const { totalExpense, transactions, isLoading } = useGetAssetAnalysis(memoizedDate);
+ const sectorData = transformToCategoryGroups(transactions, totalExpense);
+
+ // 💡 2. 지난달 데이터 가져오기 (지출 차액 계산용)
+ const lastMonthDate = useMemo(() =>
+ new Date(selectedDate.getFullYear(), selectedDate.getMonth() - 1, 1),
+ [selectedDate]
+ );
+ const { totalExpense: lastMonthTotal } = useGetAssetAnalysis(lastMonthDate);
+
+ // 💡 3. 차액 계산 로직
+ const diff = totalExpense - lastMonthTotal;
+ const isMore = diff > 0;
+ const diffAmountText = Math.abs(diff).toLocaleString();
+
+ // 날짜 핸들러
+ const handlePrevMonth = () => setSelectedDate(new Date(selectedDate.getFullYear(), selectedDate.getMonth() - 1, 1));
+ const handleNextMonth = () => setSelectedDate(new Date(selectedDate.getFullYear(), selectedDate.getMonth() + 1, 1));
+
+ return (
+
+
+ {/* 상단 요약 섹션 (날짜, 총액, 차트) */}
+
+
+ {/* 하단 리스트 섹션 (지출 상세) */}
+ {/* 💡 SectorListSection 내부 인터페이스에 isLoading? 추가하셔야 빨간줄 사라져요! */}
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/pages/Asset/tab/SectorAnalysis/SectorDetailPage.tsx b/src/pages/Asset/tab/SectorAnalysis/SectorDetailPage.tsx
new file mode 100644
index 0000000..3b3f978
--- /dev/null
+++ b/src/pages/Asset/tab/SectorAnalysis/SectorDetailPage.tsx
@@ -0,0 +1,128 @@
+import { useState } from 'react';
+import { useParams, useNavigate, useLocation } from 'react-router-dom';
+import { cn } from '@/utils/cn';
+import { Typography } from '@/components/typography';
+import { formatCurrency } from '@/utils/formatCurrency';
+import { MobileLayout } from '@/components/layout/MobileLayout';
+import BackPageGNB from '@/components/gnb/BackPageGNB';
+import { AssetDailyHeader } from '../AssetDetails/components/AssetDailyHeader';
+import { AssetItemList } from '../AssetDetails/components/AssetItemList';
+import { CATEGORY_STYLES, CATEGORY_LABELS } from '@/features/asset/constants/category';
+import { useGetAssetAnalysis } from '@/hooks/Asset/useGetAssetAnalysis';
+import { TransactionDetailModal } from './components/TransactionDetailModal';
+
+// 💡 리팩토링된 정석 타입 및 유틸 임포트
+import {
+ TransactionWithDetails,
+ SectorTransactionGroup,
+ transformToDateGroups,
+ transformToCategoryGroups,
+ SectorData,
+} from './utils/sectorUtils';
+
+export const SectorDetailPage = () => {
+ const { categoryKey } = useParams();
+ const navigate = useNavigate();
+ const location = useLocation();
+ const selectedDate = location.state?.selectedDate ? new Date(location.state.selectedDate) : new Date();
+
+ const { transactions, totalExpense } = useGetAssetAnalysis(selectedDate);
+
+ // 1. 상세 모달 상태
+ const [selectedItem, setSelectedItem] = useState(null);
+
+ /**
+ * 2. 데이터 로드 로직
+ * 부모 페이지에서 넘겨준 state가 있으면 우선 사용하고, 없으면 직접 훅으로 가져옵니다. ㅋ
+ */
+ const stateData = location.state?.sectorData as SectorData | undefined;
+
+ const selectedCategory =
+ stateData || transformToCategoryGroups(transactions, totalExpense).find((s) => s.key === categoryKey);
+
+ // 데이터가 없으면 안전하게 차단 ㅋ
+ if (!selectedCategory || !selectedCategory.items) return null;
+
+ const { key, amount: totalAmount, items } = selectedCategory;
+ const style = CATEGORY_STYLES[key] || CATEGORY_STYLES.default;
+ const label = CATEGORY_LABELS[key] || CATEGORY_LABELS.default;
+
+ // 3. 화면 렌더링을 위한 날짜별 그룹화 실행
+ const historyData: SectorTransactionGroup[] = transformToDateGroups(items);
+
+ return (
+
+
+ {/* 상단 GNB */}
+
+ {
+ navigate('/asset/sector', {
+ state: { selectedDate: selectedDate.toISOString() },
+ replace: true // 히스토리가 중복으로 쌓이지 않게 교체
+ });
+ }}
+ text=""
+ className="bg-white"
+ titleColor="text-neutral-90"
+ />
+
+
+ {/* 요약 카드: 카테고리별 테마 컬러(bgColor) 적용 ㅋ */}
+
+
+

+
+
+
+ {/* 💡 하드코딩 대신 동적 월 노출 (예: 1월) */}
+ {selectedDate.getMonth() + 1}월 {label} 총 금액
+
+
+ {formatCurrency(totalAmount)}
+
+
+
+
+ {/* 지출 리스트 영역 */}
+
+
+ 총 {items.length}건
+
+
+
+ {historyData.map((group: SectorTransactionGroup) => (
+
+ {/* 날짜 구분선 헤더 */}
+
+
+ {/* 해당 날짜의 지출 아이템들 ㅋ */}
+
+ {group.items.map((item: TransactionWithDetails) => (
+
setSelectedItem(item)}
+ className="cursor-pointer active:bg-neutral-5 rounded-lg transition-colors"
+ >
+
+
+ ))}
+
+
+ ))}
+
+
+
+ {/* 거래 내역 상세 모달 (Portal 사용) */}
+ {selectedItem &&
setSelectedItem(null)} />}
+
+
+ );
+};
diff --git a/src/pages/Asset/tab/SectorAnalysis/SectorFullListPage.tsx b/src/pages/Asset/tab/SectorAnalysis/SectorFullListPage.tsx
new file mode 100644
index 0000000..5fcecb8
--- /dev/null
+++ b/src/pages/Asset/tab/SectorAnalysis/SectorFullListPage.tsx
@@ -0,0 +1,64 @@
+import { useNavigate, useLocation } from 'react-router-dom';
+import { cn } from '@/utils/cn';
+import { MobileLayout } from '@/components/layout/MobileLayout';
+import BackPageGNB from '@/components/gnb/BackPageGNB';
+import { SectorListItem } from './components/SectorListItem';
+import { CATEGORY_LABELS } from '@/features/asset/constants/category';
+import { useGetAssetAnalysis } from '@/hooks/Asset/useGetAssetAnalysis';
+import { transformToCategoryGroups, SectorData } from './utils/sectorUtils';
+
+export const SectorFullListPage = () => {
+ const navigate = useNavigate();
+ const location = useLocation();
+
+ // 1. 데이터 기준 날짜 가져오기 (2026년 기준)
+ const selectedDate = location.state?.selectedDate || new Date();
+
+ // 2. 해당 월의 데이터 로드 및 변환
+ const { totalExpense, transactions } = useGetAssetAnalysis(selectedDate);
+ const allSectors = transformToCategoryGroups(transactions, totalExpense);
+
+ // 3. 필터 로직 ("그외" 항목인 경우 7번째 아이템부터 표시)
+ const isFilterOthers = location.state?.filter === 'others';
+ const displayItems = isFilterOthers ? allSectors.slice(5) : allSectors;
+
+ // 4. 동적 타이틀 설정
+ const title = isFilterOthers ? `그외 ${displayItems.length}개` : `분야별 전체내역`;
+
+ return (
+
+
+ {/* 상단 GNB */}
+
+ navigate('/asset/sector', { state: { selectedDate } })} // 💡 단순 -1 이동이 더 안전합니다 ㅋ
+ />
+
+
+ {/* 분야별 리스트 영역 */}
+
+ {displayItems.map((item: SectorData) => {
+ const categoryKey = item.key || 'default';
+
+ return (
+ {
+ navigate(`/asset/sector/${categoryKey}`, {
+ state: { sectorData: item },
+ });
+ }}
+ />
+ );
+ })}
+
+
+
+ );
+};
diff --git a/src/pages/Asset/tab/SectorAnalysis/components/SectorChart.tsx b/src/pages/Asset/tab/SectorAnalysis/components/SectorChart.tsx
new file mode 100644
index 0000000..a7a8d1c
--- /dev/null
+++ b/src/pages/Asset/tab/SectorAnalysis/components/SectorChart.tsx
@@ -0,0 +1,37 @@
+import { SectorData } from '../utils/sectorUtils';
+import { CATEGORY_STYLES } from '@/features/asset/constants/category';
+import { cn } from '@/utils/cn';
+
+interface SectorChartProps {
+ data: SectorData[];
+}
+
+export const SectorChart = ({ data }: SectorChartProps) => {
+ return (
+
+ {' '}
+ {/* 예전 코드처럼 px-5로 양옆 여백 확보 ㅋ */}
+
+ {data.map((item: SectorData) => {
+ // 💡 카테고리에 맞는 스타일 가져오기 (others는 bg-neutral-80)
+ const style = CATEGORY_STYLES[item.key] || CATEGORY_STYLES.default;
+
+ // 💡 금액이 0보다 큰 경우에만 렌더링
+ if (item.amount <= 0) return null;
+
+ return (
+
+ );
+ })}
+
+
+ );
+};
diff --git a/src/pages/Asset/tab/SectorAnalysis/components/SectorChartSkeleton.tsx b/src/pages/Asset/tab/SectorAnalysis/components/SectorChartSkeleton.tsx
new file mode 100644
index 0000000..3cac629
--- /dev/null
+++ b/src/pages/Asset/tab/SectorAnalysis/components/SectorChartSkeleton.tsx
@@ -0,0 +1,29 @@
+import { Skeleton } from '@/components/skeleton/Skeleton';
+
+
+
+export const SectorChartSkeleton = () => {
+
+ return (
+
+ // 실제 차트 컨테이너와 동일한 구조
+
+
+
+ {/* 비율이 다른 막대 3~4개를 배치 */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+
+};
\ No newline at end of file
diff --git a/src/pages/Asset/tab/SectorAnalysis/components/SectorListItem.tsx b/src/pages/Asset/tab/SectorAnalysis/components/SectorListItem.tsx
new file mode 100644
index 0000000..e7e3ba6
--- /dev/null
+++ b/src/pages/Asset/tab/SectorAnalysis/components/SectorListItem.tsx
@@ -0,0 +1,68 @@
+import { cn } from '@/utils/cn';
+import { Typography } from '@/components/typography';
+import { formatCurrency } from '@/utils/formatCurrency';
+import { CATEGORY_STYLES } from '@/features/asset/constants/category';
+// 💡 유틸리티 파일에서 모든 핵심 타입을 임포트합니다.
+import { TransactionWithDetails } from '../utils/sectorUtils';
+
+/**
+ * 💡 훅 -> 유틸리티를 거쳐 나오는 데이터 구조
+ * 이제 items는 유틸에서 정의한 완성형 타입을 따릅니다.
+ */
+export interface SectorData {
+ key: string; // 'food', 'transfer' 등 (카테고리 구분값)
+ amount: number; // 해당 카테고리 총 지출 금액
+ percentage: number;
+ category: string; // 전체 대비 비중
+ items?: TransactionWithDetails[]; // 상세 내역 리스트
+}
+
+interface SectorListItemProps {
+ data: SectorData;
+ label: string;
+ onClick?: () => void;
+}
+
+export const SectorListItem = ({ data, label, onClick }: SectorListItemProps) => {
+ // 데이터 구조에 맞춰 categoryKey 결정 (스타일 및 아이콘 매칭용)
+ const categoryKey = data.key || 'default';
+ const style = CATEGORY_STYLES[categoryKey] || CATEGORY_STYLES.default;
+
+ return (
+
+ {/* 왼쪽 영역: 카테고리 아이콘 + 텍스트 정보 */}
+
+
+

+
+
+
+
+ {label}
+
+ {/* 퍼센트가 0보다 클 때만 노출 (소수점 없이 정수형) */}
+ {data.percentage > 0 && (
+
+ {Math.floor(data.percentage)}% {/* 💡 여기서도 한 번 더 안전하게 처리 ㅋ */}
+
+ )}
+
+
+
+ {/* 오른쪽 영역: 총 지출 금액 */}
+
+
+ {formatCurrency(data.amount)}
+
+
+
+ );
+};
diff --git a/src/pages/Asset/tab/SectorAnalysis/components/TransactionDetailModal.tsx b/src/pages/Asset/tab/SectorAnalysis/components/TransactionDetailModal.tsx
new file mode 100644
index 0000000..449869c
--- /dev/null
+++ b/src/pages/Asset/tab/SectorAnalysis/components/TransactionDetailModal.tsx
@@ -0,0 +1,93 @@
+import { Typography } from '@/components/typography';
+import { cn } from '@/utils/cn';
+import { BaseButton } from '@/components/buttons/BaseButton';
+import PenIcon from '@/assets/icons/asset/Pen.svg';
+import BottomSheet from '@/components/common/BottomSheet'; // 💡 공통 바텀시트 임포트
+import { TransactionWithDetails, TransactionDetail } from '../utils/sectorUtils';
+
+interface TransactionDetailModalProps {
+ item: TransactionWithDetails;
+ onClose: () => void;
+}
+
+export const TransactionDetailModal = ({ item, onClose }: TransactionDetailModalProps) => {
+ // 💡 데이터가 없으면 렌더링하지 않음
+ if (!item) return null;
+
+ return (
+
+ {/* 💡 기존 모달 내부 레이아웃 유지
+ BottomSheet 내부에 이미 패딩이 있으므로 px-5 등은 상황에 맞게 조절했습니다. ㅋ
+ */}
+
+
+ {/* 제목 영역 */}
+
+ {item.title}
+
+
+ {/* 메모 입력창 */}
+
+
+

+
+
+ {/* 상세 정보 리스트 */}
+
+ {item.displayDetails?.map((detail: TransactionDetail, index: number) => (
+
+
+ {detail.label}
+
+
+
+ {detail.value}
+
+
+ ))}
+
+
+
+ {/* 하단 확인 버튼 */}
+
+
+
+
+
+ );
+};
diff --git a/src/pages/Asset/tab/SectorAnalysis/sections/SectorListSection.tsx b/src/pages/Asset/tab/SectorAnalysis/sections/SectorListSection.tsx
new file mode 100644
index 0000000..5819640
--- /dev/null
+++ b/src/pages/Asset/tab/SectorAnalysis/sections/SectorListSection.tsx
@@ -0,0 +1,76 @@
+import { useNavigate } from 'react-router-dom';
+import { SectorListItem, SectorData } from '../components/SectorListItem';
+import { CATEGORY_LABELS } from '@/features/asset/constants/category';
+import { Skeleton } from '@/components/skeleton/Skeleton'; // 💡 1. 스켈레톤 임포트
+
+// 💡 2. interface에 isLoading 추가
+interface SectorListSectionProps {
+ data: SectorData[];
+ isLoading?: boolean;
+ selectedDate: Date;
+}
+
+export const SectorListSection = ({
+ data,
+ isLoading = false, // 💡 3. props에서 꺼내기
+ selectedDate
+}: SectorListSectionProps) => {
+ const navigate = useNavigate();
+
+ const topSectors = data.slice(0, 5);
+ const otherSectors = data.slice(5);
+
+ const otherCount = otherSectors.length;
+ const otherTotalAmount = otherSectors.reduce((sum, item) => sum + item.amount, 0);
+
+ return (
+
+
+ {/* 💡 4. 로딩 중일 때 보여줄 리스트 스켈레톤 (5개) */}
+ {isLoading ? (
+ Array.from({ length: 5 }).map((_, idx) => (
+
+
+
{/* 아이콘 자리 */}
+
+ {/* 카테고리명 자리 */}
+ {/* 퍼센트 자리 */}
+
+
+
{/* 금액 자리 */}
+
+ ))
+ ) : (
+ <>
+ {/* Top 5 리스트 */}
+ {topSectors.map((item) => (
+
{
+ navigate(`/asset/sector/${item.key}`, { state: { sectorData: item, selectedDate: selectedDate.toISOString() } });
+ }}
+ />
+ ))}
+
+ {/* 그외 N개 로직 */}
+ {otherCount > 0 && (
+ navigate('/asset/sector-full', { state: { filter: 'others', selectedDate: selectedDate.toISOString() } })}
+ />
+ )}
+ >
+ )}
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/pages/Asset/tab/SectorAnalysis/sections/SectorSummarySection.tsx b/src/pages/Asset/tab/SectorAnalysis/sections/SectorSummarySection.tsx
new file mode 100644
index 0000000..555358c
--- /dev/null
+++ b/src/pages/Asset/tab/SectorAnalysis/sections/SectorSummarySection.tsx
@@ -0,0 +1,93 @@
+import { useNavigate } from 'react-router-dom';
+import { Typography } from '@/components/typography';
+import { SectorChart } from '../components/SectorChart';
+import { SectorChartSkeleton } from '../components/SectorChartSkeleton'; // 💡 1. 스켈레톤 임포트 추가!
+import { SectorData } from '../utils/sectorUtils';
+import { Skeleton } from '@/components/skeleton/Skeleton'; // 💡 2. 텍스트용 스켈레톤
+
+interface SectorSummarySectionProps {
+ selectedDate: Date;
+ totalAmount: number;
+ sectorData: SectorData[];
+ onPrev: () => void;
+ onNext: () => void;
+ diffAmountText: string;
+ isMore: boolean;
+ isLoading?: boolean;
+}
+
+export const SectorSummarySection = ({
+ selectedDate,
+ totalAmount,
+ sectorData,
+ onPrev,
+ onNext,
+ diffAmountText,
+ isMore,
+ isLoading = false, // 💡 3. 여기서 isLoading을 꼭 꺼내주세요!
+}: SectorSummarySectionProps) => {
+ const navigate = useNavigate();
+
+ // ... (중간 데이터 가공 로직은 동일) ...
+ const top5 = sectorData.slice(0, 5);
+ const others = sectorData.slice(5);
+ const otherTotalAmount = others.reduce((sum, item) => sum + item.amount, 0);
+ const otherPercentage = others.reduce((sum, item) => sum + item.percentage, 0);
+
+ const chartData = [
+ ...top5,
+ ...(otherTotalAmount > 0
+ ? [{ key: 'others', amount: otherTotalAmount, percentage: otherPercentage, category: 'others', items: [] }]
+ : []),
+ ];
+
+ const monthDisplay = `${selectedDate.getMonth() + 1}월`;
+
+ return (
+
+ {/* 📅 날짜 선택 */}
+
+
+
+ {monthDisplay}
+
+
+
+
+ {/* 💰 이번 달 총 지출 금액 섹션 */}
+ !isLoading && navigate('/asset/sector-full', { state: { selectedDate } })}
+ className="cursor-pointer active:opacity-70 transition-opacity"
+ >
+ {/* 💡 4. 로딩 중일 때는 금액 대신 스켈레톤 표시 */}
+ {isLoading ? (
+
+ ) : (
+
+ {totalAmount.toLocaleString()}원
+
+ )}
+
+
+ {/* 📉 지난달 비교 문구 섹션 */}
+ {isLoading ? (
+
+ ) : (
+
+ 지난 달 같은 기간보다 {diffAmountText}원
+ {isMore ? ' 더 ' : ' 덜 '} 썼어요
+
+ )}
+
+ {/* 📊 차트 섹션 */}
+
+ {/* 💡 5. 로딩 중일 때는 차트 대신 아까 만든 차트 스켈레톤 표시! */}
+ {isLoading ? (
+
+ ) : (
+
+ )}
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/pages/Asset/tab/SectorAnalysis/utils/sectorUtils.ts b/src/pages/Asset/tab/SectorAnalysis/utils/sectorUtils.ts
new file mode 100644
index 0000000..72f440c
--- /dev/null
+++ b/src/pages/Asset/tab/SectorAnalysis/utils/sectorUtils.ts
@@ -0,0 +1,95 @@
+import { TransactionItem, TransactionGroup } from '@/features/asset/constants/account';
+import { SectorData } from '../components/SectorListItem';
+
+/**
+ * 💡 1. 기초 상세 항목 타입 (모달용)
+ */
+export interface TransactionDetail {
+ label: string;
+ value: string;
+ isBold?: boolean;
+}
+
+/**
+ * 💡 2. 완성형 거래 내역 타입
+ * 기존 TransactionItem에 날짜(date)와 모달용 상세정보(displayDetails)를 합침
+ */
+export type TransactionWithDetails = TransactionItem & {
+ date: string;
+ displayDetails?: TransactionDetail[];
+};
+
+/**
+ * 💡 3. 유틸리티 전용 그룹 타입
+ */
+export interface SectorTransactionGroup extends Omit {
+ items: TransactionWithDetails[];
+}
+
+/**
+ * 💡 4. 카테고리별 그룹화 (가로형 막대 차트 및 메인 리스트용)
+ */
+export const transformToCategoryGroups = (
+ transactions: TransactionWithDetails[],
+ totalExpense: number
+): SectorData[] => {
+ // 카테고리별로 금액 합산
+ const sectorMap = transactions.reduce>((acc, item) => {
+ // 지출(expense) 데이터만 합산 로직에 포함
+ if (item.type !== 'expense') return acc;
+
+ const cat = item.category || 'others'; // 카테고리 없으면 '그외'로 분류 ㅋ
+
+ if (!acc[cat]) {
+ acc[cat] = {
+ key: cat,
+ amount: 0,
+ percentage: 0,
+ category: cat,
+ items: [],
+ };
+ }
+
+ acc[cat].amount += Math.abs(item.amount);
+ acc[cat].items?.push(item);
+
+ return acc;
+ }, {});
+
+ // 최종 배열 변환 및 비율(percentage) 계산
+ return (
+ Object.values(sectorMap)
+ .map((sector) => ({
+ ...sector,
+ // 💡 가로 막대 차트의 정밀한 너비를 위해 소수점까지 유지 (Math.round 제외)
+ percentage: totalExpense > 0 ? (sector.amount / totalExpense) * 100 : 0,
+ }))
+ // 금액이 큰 순서대로 정렬 (차트와 리스트가 시각적으로 안정감 있게 보임 ㅋ)
+ .sort((a, b) => b.amount - a.amount)
+ );
+};
+
+/**
+ * 💡 5. 날짜별 그룹화 (상세 페이지용)
+ */
+export const transformToDateGroups = (items: TransactionWithDetails[]): SectorTransactionGroup[] => {
+ return items.reduce((acc, item) => {
+ const dateStr = item.date || '날짜 정보 없음';
+ let group = acc.find((g) => g.date === dateStr);
+
+ if (!group) {
+ group = { date: dateStr, dailyTotal: 0, items: [] };
+ acc.push(group);
+ }
+
+ group.items.push(item);
+
+ if (item.type === 'expense') {
+ group.dailyTotal += Math.abs(item.amount);
+ }
+
+ return acc;
+ }, []);
+};
+
+export type { SectorData };
diff --git a/src/router/Router.tsx b/src/router/Router.tsx
index a36c36f..379d404 100644
--- a/src/router/Router.tsx
+++ b/src/router/Router.tsx
@@ -32,6 +32,11 @@ import {
CardAdditionalConnectionPage,
} from '@/pages/Card';
import { AssetAccountDetailPage } from '@/pages/Asset/tab/AssetDetails/AssetAccountDetailPage';
+import { SectorDetailPage } from '@/pages/Asset/tab/SectorAnalysis/SectorDetailPage';
+import { SectorFullListPage } from '@/pages/Asset/tab/SectorAnalysis/SectorFullListPage';
+import { AssetDetails } from '@/pages/Asset/tab/AssetDetails/AssetDetailsPage';
+import { SectorAnalysis } from '@/pages/Asset/tab/SectorAnalysis/SectorAnalysisPage';
+import { CompareAnalysis } from '@/pages/Asset/tab/CompareAnalysis/CompareAnalysisPage';
import { MbtiPage } from '@/features/mbti/MbtiPage';
export const paths = {
@@ -45,17 +50,6 @@ export const paths = {
},
} as const;
-export const paths = {
- goal: {
- current: '/goal/current',
- past: '/goal/past',
- amountAchieved: (id: string | number) => `/goal/detail/${id}/amount-achieved`,
- savingsSimulation: (id: string | number) => `/goal/detail/${id}/savingsimulation`,
- amountAchievedRoute: '/goal/detail/:id/amount-achieved',
- savingsSimulationRoute: '/goal/detail/:id/savingsimulation',
- },
-} as const;
-
export const router = createBrowserRouter([
{
path: '/',
@@ -63,6 +57,15 @@ export const router = createBrowserRouter([
{ index: true, element: },
{ path: 'onboarding', element: },
{ path: 'home', element: },
+ {
+ path: 'asset',
+ element: , // 여기에 탭 버튼과 레이아웃이 있음
+ children: [
+ { index: true, element: }, // /asset (기본탭)
+ { path: 'sector', element: }, // /asset/sector (분야별)
+ { path: 'compare', element: }, // /asset/compare (비교)
+ ],
+ },
{ path: 'asset', element: },
{ path: 'asset/account/:id', element: },
{ path: 'recommend', element: },
@@ -89,6 +92,8 @@ export const router = createBrowserRouter([
{ path: 'card/connecting', element: },
{ path: 'card/connected', element: },
{ path: 'card/additional', element: },
+ { path: 'asset/sector-full', element: },
+ { path: 'asset/sector/:categoryKey', element: },
],
},
]);
diff --git a/src/styles/globals.css b/src/styles/globals.css
index 4d6b623..051bfd4 100644
--- a/src/styles/globals.css
+++ b/src/styles/globals.css
@@ -20,3 +20,16 @@
@layer utilities {
/* 커스텀 유틸리티 클래스 */
}
+
+@keyframes slide-up {
+ from {
+ transform: translateY(100%);
+ }
+ to {
+ transform: translateY(0);
+ }
+}
+
+.animate-slide-up {
+ animation: slide-up 0.3s ease-out;
+}