diff --git a/src/features/asset/asset.api.ts b/src/features/asset/asset.api.ts index 1ef12b6..b5575bd 100644 --- a/src/features/asset/asset.api.ts +++ b/src/features/asset/asset.api.ts @@ -1,10 +1,222 @@ +/** + * 자산/거래 분석 및 계좌 관련 API + * Swagger: https://api.valuedi.site/swagger-ui/index.html → Ledger (거래내역) + */ + +import { apiGet, apiPost, ApiResponse } from '@/utils/api'; import type { ConnectedBanksResponse, BankAccountsResponse, Account } from './asset.types'; export type { Account }; const API_BASE_URL = 'https://api.valuedi.site'; -// 인증 헤더 생성 함수 +// ========== 거래(가계부) 관련 타입 및 API ========== + +/** rematchCategories 요청 (Swagger 스펙에 맞게 조정) */ +export interface RematchCategoriesRequest { + yearMonth?: string; // YYYY-MM + [key: string]: unknown; +} + +/** rematchCategories 응답 */ +export interface RematchCategoriesResult { + matchedCount?: number; + message?: string; + [key: string]: unknown; +} + +/** + * 거래 내역 카테고리 재매칭 (키워드 등으로 재분류) + * POST /api/transactions/rematch-categories + * Swagger: Ledger (거래내역) → rematchCategories + */ +export const rematchCategoriesApi = async ( + params?: RematchCategoriesRequest +): Promise> => { + return apiPost('/api/transactions/rematch-categories', params ?? {}); +}; + +// ========== GET /api/transactions/by-category (getCategoryStats) ========== + +/** 카테고리별 소비 집계 응답 한 항목 */ +export interface TransactionByCategoryItem { + categoryCode: string; + categoryName: string; + totalAmount: number; + percentage: number; // 0~100 또는 0~1 (백엔드 스펙에 맞춰 매핑 시 처리) +} + +/** + * 카테고리별 소비 집계 + * GET /api/transactions/by-category?yearMonth=YYYY-MM + * Swagger: Ledger (거래내역) → getCategoryStats + */ +export const getTransactionsByCategoryApi = async ( + yearMonth: string +): Promise> => { + return apiGet( + `/api/transactions/by-category?yearMonth=${encodeURIComponent(yearMonth)}` + ); +}; + +// ========== GET /api/transactions (거래 내역 조회) ========== + +/** API가 내려주는 카테고리 객체 (중첩 시, camel/snake 둘 다 허용) */ +export interface LedgerCategoryRef { + code?: string; + name?: string; + id?: number; + category_code?: string; + category_name?: string; + category_id?: number; + [key: string]: unknown; +} + +export interface LedgerTransactionItem { + transactionId?: number; + date?: string; + transactionAt?: string; // API가 ISO 날짜로 내려줄 수 있음 + amount?: number; + title?: string; + categoryCode?: string; + categoryName?: string; + categoryId?: number; + /** snake_case 응답 대응 */ + category_code?: string; + category_name?: string; + category_id?: number; + /** 문자열이면 코드, 객체면 { code, name, id } */ + category?: string | LedgerCategoryRef; + type?: string; + [key: string]: unknown; +} + +export interface LedgerListResponse { + content?: LedgerTransactionItem[]; + totalElements?: number; + totalPages?: number; + [key: string]: unknown; +} + +// ========== POST /api/transactions/sync (거래 동기화·목록) ========== + +/** sync 요청 (Swagger 스펙에 맞게 조정) */ +export interface SyncTransactionsRequest { + yearMonth?: string; // YYYY-MM + [key: string]: unknown; +} + +/** sync 응답 - 동기화 후 거래 목록을 내려줄 수 있음 */ +export interface SyncTransactionsResult { + content?: LedgerTransactionItem[]; + totalElements?: number; + totalPages?: number; + syncedCount?: number; + [key: string]: unknown; +} + +/** + * 거래 내역 동기화 (외부 연동 후 최신 거래 반영, 응답에 목록 포함 시 사용) + * POST /api/transactions/sync + * Swagger: Ledger (거래내역) → sync + */ +export const syncTransactionsApi = async ( + params?: SyncTransactionsRequest +): Promise> => { + return apiPost('/api/transactions/sync', params ?? {}); +}; + +/** + * 거래 내역 조회 (월별/일별, 페이징) + * GET /api/transactions?yearMonth=YYYY-MM&date=YYYY-MM-DD&page=0&size=20&sort=LATEST + * Swagger: Ledger (거래내역) → getTransactions + */ +export const getTransactionsApi = async (params: { + yearMonth: string; + date?: string; + page?: number; + size?: number; + sort?: 'LATEST' | 'OLDEST' | 'AMOUNT_DESC' | 'AMOUNT_ASC'; +}): Promise> => { + const search = new URLSearchParams({ yearMonth: params.yearMonth }); + if (params.date) search.set('date', params.date); + if (params.page != null) search.set('page', String(params.page)); + if (params.size != null) search.set('size', String(params.size)); + if (params.sort) search.set('sort', params.sort); + return apiGet(`/api/transactions?${search.toString()}`); +}; + +// ========== GET /api/transactions/summary (월 소비 내역 요약) ========== + +export interface LedgerSummaryResult { + totalIncome?: number; + totalExpense?: number; + previousMonthExpense?: number; + expenseDiff?: number; // 전월 대비 증감 (음수: 덜 씀, 양수: 더 씀) + [key: string]: unknown; +} + +/** + * 월 소비 내역 요약 (총 수입/지출, 전월 대비) + * GET /api/transactions/summary?yearMonth=YYYY-MM + * Swagger: Ledger (거래내역) → getMonthlySummary + */ +export const getMonthlySummaryApi = async (yearMonth: string): Promise> => { + return apiGet(`/api/transactions/summary?yearMonth=${encodeURIComponent(yearMonth)}`); +}; + +// ========== GET /api/transactions/trend (월별 지출 추이) ========== + +export interface TrendItem { + yearMonth?: string; // "YYYY-MM" + amount?: number; + totalExpense?: number; + [key: string]: unknown; +} + +/** + * 월별 지출 추이 (차트용) + * GET /api/transactions/trend?fromYearMonth=YYYY-MM&toYearMonth=YYYY-MM + * Swagger: Ledger (거래내역) → getTrend + */ +export const getTrendApi = async (params: { + fromYearMonth: string; + toYearMonth: string; +}): Promise> => { + const search = new URLSearchParams({ + fromYearMonth: params.fromYearMonth, + toYearMonth: params.toYearMonth, + }); + return apiGet(`/api/transactions/trend?${search.toString()}`); +}; + +// ========== GET /api/transactions/top-category (최다 소비 항목) ========== + +export interface TopCategoryItem { + categoryCode?: string; + categoryName?: string; + totalAmount?: number; + percentage?: number; + [key: string]: unknown; +} + +/** + * 최다 소비 항목 (상위 N개) + * GET /api/transactions/top-category?yearMonth=YYYY-MM&limit=3 + * Swagger: Ledger (거래내역) → getTopCategories + */ +export const getTopCategoriesApi = async (params: { + yearMonth: string; + limit?: number; +}): Promise> => { + const search = new URLSearchParams({ yearMonth: params.yearMonth }); + if (params.limit != null) search.set('limit', String(params.limit)); + return apiGet(`/api/transactions/top-category?${search.toString()}`); +}; + +// ========== 자산(계좌) 관련 타입 및 API ========== + +// 인증 헤더 생성 함수 (fetch 사용 시) const getAuthHeaders = () => { const token = localStorage.getItem('accessToken'); const headers: Record = { @@ -25,8 +237,6 @@ export const assetApi = { async getConnectedBanks(): Promise { const url = `${API_BASE_URL}/api/assets/banks`; - console.log('연동된 은행 목록 조회 API 요청:', { url }); - const response = await fetch(url, { method: 'GET', headers: getAuthHeaders(), @@ -37,7 +247,6 @@ export const assetApi = { let errorData; try { errorData = await response.json(); - console.error('서버 에러 응답:', errorData); } catch { errorData = { message: response.statusText }; } @@ -53,7 +262,6 @@ export const assetApi = { } const result = await response.json(); - console.log('연동된 은행 목록 조회 성공:', result); return result; }, @@ -73,7 +281,6 @@ export const assetApi = { let errorData; try { errorData = await response.json(); - console.error('서버 에러 응답:', errorData); } catch { errorData = { message: response.statusText }; } diff --git a/src/features/asset/constants/category.ts b/src/features/asset/constants/category.ts index 374985d..e1760c5 100644 --- a/src/features/asset/constants/category.ts +++ b/src/features/asset/constants/category.ts @@ -17,6 +17,158 @@ export interface CategoryStyle { icon: string; } +/** 프론트에서 쓰는 카테고리 키 목록 (아이콘/스타일 매칭용) */ +const FRONTEND_KEYS = [ + 'transfer', 'traffic', 'shopping', 'food', 'leisure', 'medical', 'market', 'living', 'cafe', 'others', +] as const; + +/** API에서 오는 카테고리 코드 → 프론트 키 (백엔드 DB code 컬럼 기준) */ +const API_CATEGORY_CODE_MAP: Record = { + // 백엔드 DB code (소문자 매칭) + transfer: 'transfer', + food: 'food', + hobby_leisure: 'leisure', + mart_etc: 'market', + transport: 'traffic', + shopping: 'shopping', + housing_comm: 'living', + cafe_snack: 'cafe', + medical_life: 'medical', + etc: 'others', + // 레거시/축약 코드 + fd: 'food', + f: 'food', + sh: 'shopping', + tr: 'traffic', + tf: 'traffic', + traffic: 'traffic', + lv: 'living', + living: 'living', + md: 'medical', + medical: 'medical', + mr: 'market', + market: 'market', + cf: 'cafe', + cafe: 'cafe', + lr: 'leisure', + leisure: 'leisure', + other: 'others', + others: 'others', + unknown: 'others', + '': 'others', +}; + +/** 백엔드 category.id 또는 sort_order (1~10) → 프론트 키 */ +const API_CATEGORY_ID_MAP: Record = { + 1: 'transfer', + 2: 'food', + 3: 'leisure', + 4: 'market', + 5: 'traffic', + 6: 'shopping', + 7: 'living', + 8: 'cafe', + 9: 'medical', + 10: 'others', +}; + +/** API가 categoryName(한글)만 줄 때: 한글 이름 → 프론트 키 (백엔드 DB name 기준) */ +const CATEGORY_NAME_TO_KEY: Record = { + // 백엔드 DB name (슬래시 구분) + 이체: 'transfer', + 식비: 'food', + '취미/여가': 'leisure', + '편의점/마트/잡화': 'market', + '교통/자동차': 'traffic', + 쇼핑: 'shopping', + '주거/통신': 'living', + '카페/간식': 'cafe', + '의료/생활': 'medical', + '카테고리 없음(기타)': 'others', + // 단일 한글 (API가 일부만 보낼 때) + 교통: 'traffic', + 자동차: 'traffic', + // 점/공백 구분 변형 + '교통 · 자동차': 'traffic', + 취미: 'leisure', + 여가: 'leisure', + '취미 · 여가': 'leisure', + 의료: 'medical', + 건강: 'medical', + '의료 · 건강': 'medical', + 편의점: 'market', + 마트: 'market', + 잡화: 'market', + '편의점 · 마트 · 잡화': 'market', + 주거: 'living', + 통신: 'living', + '주거 · 통신': 'living', + 카페: 'cafe', + 디저트: 'cafe', + '카페 · 디저트': 'cafe', + 그외: 'others', + 기타: 'others', + 음식: 'food', +}; + +/** + * API categoryCode / categoryName / categoryId를 프론트 카테고리 키로 통일 + * - categoryId가 있으면 ID 매핑 우선 (DB id 1~10) + * - 그다음 categoryCode 매핑 (문자열 코드 또는 숫자 문자열=ID) + * - 없거나 매칭 안 되면 categoryName(한글)으로 매칭 + */ +export function normalizeCategoryCode( + apiCode: string | null | undefined, + categoryName?: string | null, + categoryId?: number | string | null +): string { + const id = + categoryId != null + ? typeof categoryId === 'string' + ? parseInt(categoryId, 10) + : categoryId + : undefined; + if (id != null && !Number.isNaN(id) && API_CATEGORY_ID_MAP[id]) return API_CATEGORY_ID_MAP[id]; + + const codeRaw = (apiCode ?? '').toString().trim().toLowerCase(); + if (codeRaw && API_CATEGORY_CODE_MAP[codeRaw]) return API_CATEGORY_CODE_MAP[codeRaw]; + if (codeRaw && FRONTEND_KEYS.includes(codeRaw as (typeof FRONTEND_KEYS)[number])) return codeRaw; + // API가 code 대신 category id를 문자열로 보낼 수 있음 (예: "2" → food) + const codeAsId = /^\d+$/.test(codeRaw) ? parseInt(codeRaw, 10) : NaN; + if (!Number.isNaN(codeAsId) && API_CATEGORY_ID_MAP[codeAsId]) return API_CATEGORY_ID_MAP[codeAsId]; + + const nameRaw = (categoryName ?? '').toString().trim(); + if (nameRaw && CATEGORY_NAME_TO_KEY[nameRaw]) return CATEGORY_NAME_TO_KEY[nameRaw]; + for (const [name, key] of Object.entries(CATEGORY_NAME_TO_KEY)) { + if (name.length > 0 && nameRaw.includes(name)) return key; + } + + return codeRaw || 'others'; +} + +/** API가 그외(ETC)로 내려줄 때, 거래 제목(가맹점명)으로 카테고리 추정 (백엔드 keyword 미적용 월 보정용) */ +const TITLE_KEYWORDS_TO_CATEGORY: { keywords: string[]; key: string }[] = [ + { keywords: ['티머니', '지하철', '후불교통', '교통', '택시', '버스', '카카오택시', '주차'], key: 'traffic' }, + { keywords: ['이체', '펌뱅킹', '토스', '오픈뱅킹'], key: 'transfer' }, + { keywords: ['커피', '카페', '스타벅스', '이디야', '빽다방', '디저트', '제과'], key: 'cafe' }, + { keywords: ['편의점', '세븐일레븐', 'gs25', 'cu', '이마트24', '다이소'], key: 'market' }, + { keywords: ['쿠팡', '올리브영', '백화점', '쇼핑'], key: 'shopping' }, + { keywords: ['식비', '한식', '중식', '일식', '맛집', '배달', '요식'], key: 'food' }, + { keywords: ['의료', '약국', '병원', '치과', '한의원'], key: 'medical' }, + { keywords: ['주거', '통신', '전기', '가스', '관리비'], key: 'living' }, + { keywords: ['영화', '레저', '숙박', '취미', '여가'], key: 'leisure' }, +]; + +export function inferCategoryFromTitle(title: string | null | undefined): string | null { + const t = (title ?? '').toString().trim(); + if (!t) return null; + const lower = t.toLowerCase(); + for (const { keywords, key } of TITLE_KEYWORDS_TO_CATEGORY) { + if (keywords.some((kw) => lower.includes(kw.toLowerCase()))) return key; + } + return null; +} + // 💡 새로 추가할 한글 라벨 정의 export const CATEGORY_LABELS: Record = { transfer: '이체', diff --git a/src/hooks/Asset/useGetAssetAnalysis.ts b/src/hooks/Asset/useGetAssetAnalysis.ts index 23a9557..6b16cf2 100644 --- a/src/hooks/Asset/useGetAssetAnalysis.ts +++ b/src/hooks/Asset/useGetAssetAnalysis.ts @@ -1,72 +1,183 @@ import { useState, useEffect, useMemo } from 'react'; -import { transformToCategoryGroups, TransactionWithDetails } from '@/pages/Asset/tab/SectorAnalysis/utils/sectorUtils'; +import { + TransactionWithDetails, + transformToCategoryGroups, + normalizeSectorPercentages, + getIntegerPercentagesSum100, +} from '@/pages/Asset/tab/SectorAnalysis/utils/sectorUtils'; import { useGetAccountDetail } from '@/hooks/Asset/useGetAccountDetail'; -import { ASSET_ANALYSIS_RAW_DATA } from '@/features/asset/constants/mockData'; // 💡 데이터 소스 임포트 +import { + rematchCategoriesApi, + getTransactionsByCategoryApi, + syncTransactionsApi, + getTransactionsApi, + type LedgerTransactionItem, +} from '@/features/asset/asset.api'; +import { normalizeCategoryCode, inferCategoryFromTitle } from '@/features/asset/constants/category'; + +function getCategoryFromItem(item: LedgerTransactionItem): { code: string; name: string; id?: number } { + const cat = item.category; + if (cat && typeof cat === 'object' && cat !== null) { + const code = + (cat.code ?? cat.category_code ?? '').toString().trim() as string; + const name = + (cat.name ?? cat.category_name ?? '').toString().trim() as string; + const id = + typeof cat.id === 'number' + ? cat.id + : typeof cat.category_id === 'number' + ? cat.category_id + : undefined; + return { code, name, id }; + } + const code = ( + item.categoryCode ?? + item.category_code ?? + (typeof cat === 'string' ? cat : '') ?? + '' + ).toString().trim(); + const name = (item.categoryName ?? item.category_name ?? '').toString().trim(); + const id = + typeof item.categoryId === 'number' + ? item.categoryId + : typeof item.category_id === 'number' + ? item.category_id + : undefined; + return { code, name, id }; +} +import type { SectorData } from '@/pages/Asset/tab/SectorAnalysis/components/SectorListItem'; + +function mapLedgerItemToTransactionWithDetails( + item: LedgerTransactionItem, + accountDisplay: string +): TransactionWithDetails { + const amount = Number(item.amount) ?? 0; + const type = (item.type?.toUpperCase() === 'INCOME' ? 'income' : 'expense') as 'income' | 'expense'; + const rawDate = item.date ?? item.transactionAt ?? ''; + const date = rawDate.slice(0, 10); // YYYY-MM-DD만 사용 (transactionAt이 ISO면 앞 10자리) + const { code, name, id } = getCategoryFromItem(item); + let category = normalizeCategoryCode(code || undefined, name || undefined, id ?? item.categoryId ?? item.category_id); + if (category === 'others') { + const inferred = inferCategoryFromTitle(item.title); + if (inferred) category = inferred; + } + const displayName = name || (item.categoryName as string) || ''; + return { + id: item.transactionId ?? 0, + title: item.title ?? '', + sub: displayName, + amount, + type, + category, + date, + displayDetails: [ + { label: '거래일자', value: date.replace(/-/g, '.') }, + { label: '거래구분', value: displayName || '-' }, + { label: '거래금액', value: `${Math.abs(amount).toLocaleString()}원`, isBold: true }, + { label: '입금계좌', value: accountDisplay }, + ], + }; +} export const useGetAssetAnalysis = (selectedDate: Date = new Date()) => { const { accountInfo } = useGetAccountDetail(); const accountDisplay = accountInfo?.accountNumber || '국민은행 592802-04-170725'; - // 💡 1. 로딩 상태 관리 (스켈레톤 제어용) const [isLoading, setIsLoading] = useState(true); + const [sectorsFromApi, setSectorsFromApi] = useState([]); + const [totalExpenseFromApi, setTotalExpenseFromApi] = useState(0); + const [transactionsFromApi, setTransactionsFromApi] = useState([]); - // 💡 2. 날짜 변경 시 0.8초 동안 로딩 상태 유지 + // Swagger: rematchCategories → sync(동기화 트리거) → by-category + getTransactions(거래 목록 조회) useEffect(() => { setIsLoading(true); - const timer = setTimeout(() => { - setIsLoading(false); - }, 800); - return () => clearTimeout(timer); - }, [selectedDate]); + const yearMonth = `${selectedDate.getFullYear()}-${String(selectedDate.getMonth() + 1).padStart(2, '0')}`; + + const run = async () => { + await rematchCategoriesApi({ yearMonth }).catch(() => {}); + await syncTransactionsApi({ yearMonth }).catch(() => {}); + return Promise.all([ + getTransactionsByCategoryApi(yearMonth), + getTransactionsApi({ yearMonth, size: 200, sort: 'LATEST' }), + ]); + }; - // 💡 3. 선택된 연/월에 맞는 데이터 필터링 - const filteredData = useMemo(() => { - const targetYear = selectedDate.getFullYear(); - const targetMonth = selectedDate.getMonth(); + run() + .then(([categoryRes, listRes]) => { + if (categoryRes?.isSuccess && Array.isArray(categoryRes?.result)) { + const total = categoryRes.result.reduce((sum, item) => sum + item.totalAmount, 0); + const mapped: SectorData[] = categoryRes.result.map((item) => ({ + key: normalizeCategoryCode(item.categoryCode, item.categoryName), + category: item.categoryName, + amount: item.totalAmount, + percentage: item.percentage <= 1 ? item.percentage * 100 : item.percentage, + items: [], + })); + setSectorsFromApi(mapped); + setTotalExpenseFromApi(total); + } else { + setSectorsFromApi([]); + setTotalExpenseFromApi(0); + } - return ASSET_ANALYSIS_RAW_DATA.filter((item) => { - const itemDate = new Date(item.date); - return itemDate.getFullYear() === targetYear && itemDate.getMonth() === targetMonth; - }); - }, [selectedDate]); + // result.content / result.transactions / result가 배열인 경우 모두 처리 (백엔드 스펙 차이 대응) + const raw = listRes?.result as { content?: LedgerTransactionItem[]; transactions?: LedgerTransactionItem[] } | LedgerTransactionItem[] | null; + const content = Array.isArray(raw) + ? raw + : Array.isArray(raw?.content) + ? raw.content + : Array.isArray((raw as { transactions?: LedgerTransactionItem[] })?.transactions) + ? (raw as { transactions: LedgerTransactionItem[] }).transactions + : []; + if (listRes?.isSuccess && content.length >= 0) { + setTransactionsFromApi( + content.map((item: LedgerTransactionItem) => + mapLedgerItemToTransactionWithDetails(item, accountDisplay) + ) + ); + } else { + setTransactionsFromApi([]); + } + }) + .catch(() => { + setSectorsFromApi([]); + setTotalExpenseFromApi(0); + setTransactionsFromApi([]); + }) + .finally(() => setIsLoading(false)); + }, [selectedDate, accountDisplay]); - // 💡 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]); + const totalExpense = useMemo(() => { + if (totalExpenseFromApi > 0) return totalExpenseFromApi; + return transactionsFromApi + .filter((t) => t.type === 'expense') + .reduce((sum, t) => sum + Math.abs(t.amount), 0); + }, [totalExpenseFromApi, transactionsFromApi]); - // 💡 5. 총 지출액 계산 - const totalExpense = useMemo( - () => - mockTransactions.filter((item) => item.type === 'expense').reduce((sum, item) => sum + Math.abs(item.amount), 0), - [mockTransactions] - ); + const hasUsefulCategoryApi = + sectorsFromApi.length > 1 || sectorsFromApi.some((s) => s.key !== 'others'); - // 💡 6. 카테고리별 그룹화 데이터 생성 - const allSectors = useMemo( - () => transformToCategoryGroups(mockTransactions, totalExpense), - [mockTransactions, totalExpense] - ); + const allSectors = useMemo((): SectorData[] => { + let sectors: SectorData[]; + if (hasUsefulCategoryApi) { + sectors = sectorsFromApi.map((s) => ({ + ...s, + items: transactionsFromApi.filter( + (t) => t.type === 'expense' && (t.category === s.key || (t.category || 'others') === s.key) + ), + })); + } else { + sectors = transformToCategoryGroups(transactionsFromApi, totalExpense); + } + sectors = normalizeSectorPercentages(sectors, totalExpense); + const displayPcts = getIntegerPercentagesSum100(sectors.map((s) => s.percentage)); + return sectors.map((s, i) => ({ ...s, displayPct: displayPcts[i] ?? 0 })); + }, [sectorsFromApi, transactionsFromApi, hasUsefulCategoryApi, totalExpense]); return { - isLoading, // 💡 부모에게 로딩 상태 전달 + isLoading, totalExpense, - transactions: mockTransactions, + transactions: transactionsFromApi, allSectors, topSectors: allSectors.slice(0, 6), otherSectors: allSectors.slice(6), diff --git a/src/pages/Asset/tab/SectorAnalysis/SectorAnalysisPage.tsx b/src/pages/Asset/tab/SectorAnalysis/SectorAnalysisPage.tsx index f84c347..33e7b9c 100644 --- a/src/pages/Asset/tab/SectorAnalysis/SectorAnalysisPage.tsx +++ b/src/pages/Asset/tab/SectorAnalysis/SectorAnalysisPage.tsx @@ -1,65 +1,53 @@ -import { useState, useMemo } from 'react'; // 💡 useMemo 추가 +import { useState, useMemo } from 'react'; 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); + const { totalExpense, isLoading, allSectors } = useGetAssetAnalysis(memoizedDate); - // 💡 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)); + 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? 추가하셔야 빨간줄 사라져요! */}
diff --git a/src/pages/Asset/tab/SectorAnalysis/SectorDetailPage.tsx b/src/pages/Asset/tab/SectorAnalysis/SectorDetailPage.tsx index d9f3c36..2afb9a4 100644 --- a/src/pages/Asset/tab/SectorAnalysis/SectorDetailPage.tsx +++ b/src/pages/Asset/tab/SectorAnalysis/SectorDetailPage.tsx @@ -10,13 +10,10 @@ 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'; @@ -26,26 +23,55 @@ export const SectorDetailPage = () => { const location = useLocation(); const selectedDate = location.state?.selectedDate ? new Date(location.state.selectedDate) : new Date(); - const { transactions, totalExpense } = useGetAssetAnalysis(selectedDate); + const { allSectors, isLoading } = useGetAssetAnalysis(selectedDate); - // 1. 상세 모달 상태 const [selectedItem, setSelectedItem] = useState(null); - /** - * 2. 데이터 로드 로직 - * 부모 페이지에서 넘겨준 state가 있으면 우선 사용하고, 없으면 직접 훅으로 가져옵니다. ㅋ - */ const stateData = location.state?.sectorData as SectorData | undefined; + const selectedCategory = stateData || allSectors.find((s) => s.key === categoryKey); - const selectedCategory = - stateData || transformToCategoryGroups(transactions, totalExpense).find((s) => s.key === categoryKey); + // 로딩 중이면 스켈레톤, 카테고리 없으면 안내 후 뒤로가기 + if (isLoading && !stateData) { + return ( + +
+ navigate(-1)} text="" className="bg-white" titleColor="text-neutral-90" /> +
+
+
+
+
+
+ + ); + } - // 데이터가 없으면 안전하게 차단 ㅋ - if (!selectedCategory || !selectedCategory.items) return null; + if (!selectedCategory) { + return ( + +
+ navigate('/asset/sector', { state: { selectedDate: selectedDate.toISOString() } })} text="" className="bg-white" titleColor="text-neutral-90" /> +
+
+ + 카테고리 정보를 찾을 수 없습니다. + + +
+
+ ); + } - const { key, amount: totalAmount, items } = selectedCategory; + const items = selectedCategory.items ?? []; + const { key, amount: totalAmount } = selectedCategory; const style = CATEGORY_STYLES[key] || CATEGORY_STYLES.default; - const label = CATEGORY_LABELS[key] || CATEGORY_LABELS.default; + const label = CATEGORY_LABELS[key] || selectedCategory.category || CATEGORY_LABELS.default; // 3. 화면 렌더링을 위한 날짜별 그룹화 실행 const historyData: SectorTransactionGroup[] = transformToDateGroups(items); diff --git a/src/pages/Asset/tab/SectorAnalysis/SectorFullListPage.tsx b/src/pages/Asset/tab/SectorAnalysis/SectorFullListPage.tsx index 5fcecb8..2fbc8b4 100644 --- a/src/pages/Asset/tab/SectorAnalysis/SectorFullListPage.tsx +++ b/src/pages/Asset/tab/SectorAnalysis/SectorFullListPage.tsx @@ -5,24 +5,21 @@ 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'; +import type { SectorData } from './components/SectorListItem'; +import { Skeleton } from '@/components/skeleton/Skeleton'; export const SectorFullListPage = () => { const navigate = useNavigate(); const location = useLocation(); - // 1. 데이터 기준 날짜 가져오기 (2026년 기준) - const selectedDate = location.state?.selectedDate || new Date(); + const selectedDate = location.state?.selectedDate + ? new Date(location.state.selectedDate) + : new Date(); - // 2. 해당 월의 데이터 로드 및 변환 - const { totalExpense, transactions } = useGetAssetAnalysis(selectedDate); - const allSectors = transformToCategoryGroups(transactions, totalExpense); + const { allSectors, isLoading } = useGetAssetAnalysis(selectedDate); - // 3. 필터 로직 ("그외" 항목인 경우 7번째 아이템부터 표시) const isFilterOthers = location.state?.filter === 'others'; const displayItems = isFilterOthers ? allSectors.slice(5) : allSectors; - - // 4. 동적 타이틀 설정 const title = isFilterOthers ? `그외 ${displayItems.length}개` : `분야별 전체내역`; return ( @@ -41,22 +38,36 @@ export const SectorFullListPage = () => { {/* 분야별 리스트 영역 */}
- {displayItems.map((item: SectorData) => { - const categoryKey = item.key || 'default'; - - return ( - { - navigate(`/asset/sector/${categoryKey}`, { - state: { sectorData: item }, - }); - }} - /> - ); - })} + {isLoading ? ( + Array.from({ length: 5 }).map((_, idx) => ( +
+
+ +
+ + +
+
+ +
+ )) + ) : ( + displayItems.map((item: SectorData) => { + const categoryKey = item.key || 'default'; + return ( + { + navigate(`/asset/sector/${categoryKey}`, { + state: { sectorData: item, selectedDate: selectedDate.toISOString() }, + }); + }} + /> + ); + }) + )}
diff --git a/src/pages/Asset/tab/SectorAnalysis/components/SectorListItem.tsx b/src/pages/Asset/tab/SectorAnalysis/components/SectorListItem.tsx index e7e3ba6..834caf7 100644 --- a/src/pages/Asset/tab/SectorAnalysis/components/SectorListItem.tsx +++ b/src/pages/Asset/tab/SectorAnalysis/components/SectorListItem.tsx @@ -12,7 +12,8 @@ import { TransactionWithDetails } from '../utils/sectorUtils'; export interface SectorData { key: string; // 'food', 'transfer' 등 (카테고리 구분값) amount: number; // 해당 카테고리 총 지출 금액 - percentage: number; + percentage: number; // 차트용 (합 100%) + displayPct?: number; // 리스트 표시용 정수 퍼센트 (합 100) category: string; // 전체 대비 비중 items?: TransactionWithDetails[]; // 상세 내역 리스트 } @@ -49,9 +50,9 @@ export const SectorListItem = ({ data, label, onClick }: SectorListItemProps) => {label} {/* 퍼센트가 0보다 클 때만 노출 (소수점 없이 정수형) */} - {data.percentage > 0 && ( + {(data.displayPct ?? data.percentage) > 0 && ( - {Math.floor(data.percentage)}% {/* 💡 여기서도 한 번 더 안전하게 처리 ㅋ */} + {data.displayPct ?? Math.floor(data.percentage)}% )}
diff --git a/src/pages/Asset/tab/SectorAnalysis/components/SectorTrendChart.tsx b/src/pages/Asset/tab/SectorAnalysis/components/SectorTrendChart.tsx new file mode 100644 index 0000000..3e4d292 --- /dev/null +++ b/src/pages/Asset/tab/SectorAnalysis/components/SectorTrendChart.tsx @@ -0,0 +1,50 @@ +import { Typography } from '@/components/typography'; +import type { TrendItem } from '@/features/asset/asset.api'; +import { cn } from '@/utils/cn'; + +interface SectorTrendChartProps { + data: TrendItem[]; + isLoading?: boolean; + className?: string; +} + +export function SectorTrendChart({ data, isLoading, className }: SectorTrendChartProps) { + if (isLoading) { + return ( +
+ ); + } + + const values = data.map((d) => Number(d.amount ?? d.totalExpense ?? 0)); + const maxAmount = Math.max(1, ...values); + + return ( +
+ + 월별 지출 추이 + +
+ {data.map((item, idx) => { + const amount = Number(item.amount ?? item.totalExpense ?? 0); + const heightPct = maxAmount > 0 ? (amount / maxAmount) * 100 : 0; + const monthLabel = item.yearMonth + ? `${new Date(item.yearMonth + '-01').getMonth() + 1}월` + : `${idx + 1}`; + return ( +
+
+
+
+ + {monthLabel} + +
+ ); + })} +
+
+ ); +} diff --git a/src/pages/Asset/tab/SectorAnalysis/sections/SectorListSection.tsx b/src/pages/Asset/tab/SectorAnalysis/sections/SectorListSection.tsx index 94f5887..77fa77a 100644 --- a/src/pages/Asset/tab/SectorAnalysis/sections/SectorListSection.tsx +++ b/src/pages/Asset/tab/SectorAnalysis/sections/SectorListSection.tsx @@ -1,9 +1,20 @@ import { useNavigate } from 'react-router-dom'; +import { Typography } from '@/components/typography'; import { SectorListItem, SectorData } from '../components/SectorListItem'; import { CATEGORY_LABELS } from '@/features/asset/constants/category'; -import { Skeleton } from '@/components/skeleton/Skeleton'; // 💡 1. 스켈레톤 임포트 +import { Skeleton } from '@/components/skeleton/Skeleton'; + +/** 카테고리 key → 한글 라벨 (key 대소문자 무관, API 한글명 fallback) */ +function getCategoryLabel(item: SectorData): string { + const k = item.key; + return ( + CATEGORY_LABELS[k ?? ''] ?? + CATEGORY_LABELS[(k ?? '').toLowerCase()] ?? + (typeof item.category === 'string' ? item.category : '') ?? + CATEGORY_LABELS.default + ); +} -// 💡 2. interface에 isLoading 추가 interface SectorListSectionProps { data: SectorData[]; isLoading?: boolean; @@ -12,7 +23,7 @@ interface SectorListSectionProps { export const SectorListSection = ({ data, - isLoading = false, // 💡 3. props에서 꺼내기 + isLoading = false, selectedDate, }: SectorListSectionProps) => { const navigate = useNavigate(); @@ -22,32 +33,38 @@ export const SectorListSection = ({ const otherCount = otherSectors.length; const otherTotalAmount = otherSectors.reduce((sum, item) => sum + item.amount, 0); + const top5PctSum = topSectors.reduce((sum, item) => sum + (item.displayPct ?? Math.floor(item.percentage)), 0); + const othersDisplayPct = Math.max(0, 100 - top5PctSum); return (
- {/* 💡 4. 로딩 중일 때 보여줄 리스트 스켈레톤 (5개) */} {isLoading ? ( Array.from({ length: 5 }).map((_, idx) => (
- {/* 아이콘 자리 */} +
- {/* 카테고리명 자리 */} - {/* 퍼센트 자리 */} + +
- {/* 금액 자리 */} +
)) + ) : data.length === 0 ? ( +
+ + 이 달에는 거래 내역이 없어요 + +
) : ( <> - {/* Top 5 리스트 */} {topSectors.map((item) => ( { navigate(`/asset/sector/${item.key}`, { state: { sectorData: item, selectedDate: selectedDate.toISOString() }, @@ -63,6 +80,7 @@ export const SectorListSection = ({ key: 'others', amount: otherTotalAmount, percentage: 0, + displayPct: othersDisplayPct, category: 'others', items: [], }} diff --git a/src/pages/Asset/tab/SectorAnalysis/sections/SectorSummarySection.tsx b/src/pages/Asset/tab/SectorAnalysis/sections/SectorSummarySection.tsx index 3d53379..8b87f51 100644 --- a/src/pages/Asset/tab/SectorAnalysis/sections/SectorSummarySection.tsx +++ b/src/pages/Asset/tab/SectorAnalysis/sections/SectorSummarySection.tsx @@ -1,51 +1,36 @@ import { useNavigate } from 'react-router-dom'; import { Typography } from '@/components/typography'; import { SectorChart } from '../components/SectorChart'; -import { SectorChartSkeleton } from '../components/SectorChartSkeleton'; // 💡 1. 스켈레톤 임포트 추가! +import { SectorChartSkeleton } from '../components/SectorChartSkeleton'; import { SectorData } from '../utils/sectorUtils'; -import { Skeleton } from '@/components/skeleton/Skeleton'; // 💡 2. 텍스트용 스켈레톤 +import { Skeleton } from '@/components/skeleton/Skeleton'; interface SectorSummarySectionProps { selectedDate: Date; totalAmount: number; + diffAmountText: string; + isMore: boolean; sectorData: SectorData[]; onPrev: () => void; onNext: () => void; - diffAmountText: string; - isMore: boolean; isLoading?: boolean; } export const SectorSummarySection = ({ selectedDate, totalAmount, + diffAmountText, + isMore, sectorData, onPrev, onNext, - diffAmountText, - isMore, - isLoading = false, // 💡 3. 여기서 isLoading을 꼭 꺼내주세요! + isLoading = false, }: 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 (
- {/* 📅 날짜 선택 */}
- {/* 💰 이번 달 총 지출 금액 섹션 */}
!isLoading && navigate('/asset/sector-full', { state: { selectedDate } })} className="cursor-pointer active:opacity-70 transition-opacity" > - {/* 💡 4. 로딩 중일 때는 금액 대신 스켈레톤 표시 */} {isLoading ? ( ) : ( @@ -73,20 +56,37 @@ export const SectorSummarySection = ({ )}
- {/* 📉 지난달 비교 문구 섹션 */} {isLoading ? ( ) : ( - - 지난 달 같은 기간보다 {diffAmountText}원 + + 지난 달 같은 기간보다{' '} + {diffAmountText}원 {isMore ? ' 더 ' : ' 덜 '} 썼어요 )} - {/* 📊 차트 섹션 */}
- {/* 💡 5. 로딩 중일 때는 차트 대신 아까 만든 차트 스켈레톤 표시! */} - {isLoading ? : } + {isLoading ? ( + + ) : ( + sum + i.amount, 0) > 0 + ? [ + { + key: 'others', + amount: sectorData.slice(5).reduce((sum, i) => sum + i.amount, 0), + percentage: sectorData.slice(5).reduce((sum, i) => sum + i.percentage, 0), + category: 'others', + items: [], + }, + ] + : []), + ]} + /> + )}
); diff --git a/src/pages/Asset/tab/SectorAnalysis/utils/sectorUtils.ts b/src/pages/Asset/tab/SectorAnalysis/utils/sectorUtils.ts index 79c1ee0..f8afce2 100644 --- a/src/pages/Asset/tab/SectorAnalysis/utils/sectorUtils.ts +++ b/src/pages/Asset/tab/SectorAnalysis/utils/sectorUtils.ts @@ -69,6 +69,42 @@ export const transformToCategoryGroups = ( ); }; +/** + * 💡 4-1. 카테고리별 퍼센트 합이 정확히 100이 되도록 정규화 + */ +export function normalizeSectorPercentages( + sectors: SectorData[], + totalExpense: number +): SectorData[] { + if (sectors.length === 0 || totalExpense <= 0) return sectors; + const totalPct = sectors.reduce((sum, s) => sum + s.percentage, 0); + const scale = totalPct > 0 ? 100 / totalPct : 1; + return sectors.map((s) => ({ + ...s, + percentage: s.percentage * scale, + })); +} + +/** + * 💡 4-2. 퍼센트 배열을 정수로 반올림했을 때 합이 100이 되도록 (최대 나머지법) + */ +export function getIntegerPercentagesSum100(percentages: number[]): number[] { + if (percentages.length === 0) return []; + const total = percentages.reduce((a, b) => a + b, 0); + if (total <= 0) return percentages.map(() => 0); + const scale = 100 / total; + const scaled = percentages.map((p) => p * scale); + const floor = scaled.map((p) => Math.floor(p)); + let sum = floor.reduce((a, b) => a + b, 0); + const remainder = scaled.map((p, i) => ({ i, r: p - floor[i] })); + remainder.sort((a, b) => b.r - a.r); + for (let i = 0; sum < 100 && i < remainder.length; i++) { + floor[remainder[i].i] += 1; + sum += 1; + } + return floor; +} + /** * 💡 5. 날짜별 그룹화 (상세 페이지용) */