diff --git a/src/components/AddTransactionButton.tsx b/src/components/AddTransactionButton.tsx index d96b8c8..0e22202 100644 --- a/src/components/AddTransactionButton.tsx +++ b/src/components/AddTransactionButton.tsx @@ -11,6 +11,7 @@ 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); @@ -55,6 +56,9 @@ const AddTransactionButton = () => { // BudgetContext를 통해 지출 추가 addTransaction(newExpense); + // 제목 추천 관리 로직 호출 (새로운 함수) + manageTitleSuggestions(newExpense); + // 다이얼로그를 닫습니다 setShowExpenseDialog(false); diff --git a/src/components/transaction/useTransactionEdit.ts b/src/components/transaction/useTransactionEdit.ts index ee6f5d0..99387da 100644 --- a/src/components/transaction/useTransactionEdit.ts +++ b/src/components/transaction/useTransactionEdit.ts @@ -1,138 +1,56 @@ -import { useState, useRef, useEffect } from 'react'; -import { UseFormReturn, useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; +import { useState } from 'react'; import { Transaction } from '@/contexts/budget/types'; import { useBudget } from '@/contexts/budget/BudgetContext'; -import { toast } from '@/components/ui/use-toast'; -import { TransactionFormValues, transactionFormSchema, formatWithCommas } from './TransactionFormFields'; -import { mapCategoryToNew } from './categoryUtils'; +import { toast } from '@/hooks/useToast.wrapper'; +import { manageTitleSuggestions } from '@/utils/userTitlePreferences'; // 새로운 제목 관리 가져오기 -/** - * 트랜잭션 편집 커스텀 훅 - 상태 및 핸들러 로직 분리 - */ export const useTransactionEdit = ( transaction: Transaction, - open: boolean, - onOpenChange: (open: boolean) => void, - onSave?: (updatedTransaction: Transaction) => void, - onDelete?: (id: string) => Promise | boolean, + onClose: () => void ) => { - const { updateTransaction, deleteTransaction } = useBudget(); const [isSubmitting, setIsSubmitting] = useState(false); - - // 작업 중첩 방지를 위한 참조 - const isProcessingRef = useRef(false); - - // 폼 설정 - const form = useForm({ - resolver: zodResolver(transactionFormSchema), - defaultValues: { - title: transaction.title, - amount: formatWithCommas(transaction.amount.toString()), - category: mapCategoryToNew(transaction.category), - paymentMethod: transaction.paymentMethod || '신용카드', // 지출 방법 추가, 기본값은 신용카드 - }, - }); - - // 다이얼로그가 열릴 때 폼 값 초기화 - useEffect(() => { - if (open) { - form.reset({ - title: transaction.title, - amount: formatWithCommas(transaction.amount.toString()), - category: mapCategoryToNew(transaction.category), - paymentMethod: transaction.paymentMethod || '신용카드', // 지출 방법 기본값 - }); - } - }, [open, transaction, form]); + const { updateTransaction } = useBudget(); - // 저장 처리 함수 - const handleSubmit = async (values: TransactionFormValues) => { - // 중복 제출 방지 - if (isProcessingRef.current) return; - isProcessingRef.current = true; - setIsSubmitting(true); - + const handleSubmit = (updatedTransaction: Transaction) => { try { - // 쉼표 제거 및 숫자로 변환 - const cleanAmount = values.amount.replace(/,/g, ''); + setIsSubmitting(true); - const updatedTransaction = { - ...transaction, - title: values.title, - amount: Number(cleanAmount), - category: values.category, - paymentMethod: values.paymentMethod, // 지출 방법 업데이트 - }; - - // 컨텍스트를 통해 트랜잭션 업데이트 + // 트랜잭션 업데이트 updateTransaction(updatedTransaction); - // 부모 컴포넌트의 onSave 콜백이 있다면 호출 - if (onSave) { - onSave(updatedTransaction); + // 지출일 경우 제목 관리 로직 실행 + if (updatedTransaction.type === 'expense') { + manageTitleSuggestions(updatedTransaction); } - // 다이얼로그 닫기 - onOpenChange(false); - - // 토스트 메시지 + // 성공 메시지 표시 toast({ - title: "지출이 수정되었습니다", - description: `${values.title} 항목이 ${formatWithCommas(cleanAmount)}원으로 수정되었습니다.`, + title: "거래 내역이 업데이트되었습니다", + description: `${updatedTransaction.title} 항목이 수정되었습니다.`, }); + + // 이벤트 발생 처리 + window.dispatchEvent(new CustomEvent('transactionChanged', { + detail: { type: 'update', transaction: updatedTransaction } + })); + + // 다이얼로그 닫기 + onClose(); } catch (error) { - console.error('트랜잭션 업데이트 오류:', error); + console.error('거래 내역 업데이트 중 오류 발생:', error); toast({ - title: "저장 실패", - description: "지출 항목을 저장하는데 문제가 발생했습니다.", + title: "거래 내역 업데이트 실패", + description: "내역을 업데이트하는 도중 오류가 발생했습니다.", variant: "destructive" }); } finally { - // 상태 초기화 setIsSubmitting(false); - isProcessingRef.current = false; - } - }; - - // 삭제 처리 함수 - const handleDelete = async (): Promise => { - // 중복 처리 방지 - if (isProcessingRef.current) return false; - isProcessingRef.current = true; - - try { - // 다이얼로그 닫기를 먼저 수행 (UI 블로킹 방지) - onOpenChange(false); - - // 부모 컴포넌트의 onDelete 콜백이 있다면 호출 - if (onDelete) { - const result = await onDelete(transaction.id); - isProcessingRef.current = false; - return result; - } - - // 부모 컴포넌트에서 처리하지 않은 경우 기본 처리 - deleteTransaction(transaction.id); - isProcessingRef.current = false; - return true; - } catch (error) { - console.error('트랜잭션 삭제 중 오류:', error); - toast({ - title: "삭제 실패", - description: "지출 항목을 삭제하는데 문제가 발생했습니다.", - variant: "destructive" - }); - isProcessingRef.current = false; - return false; } }; return { - form, isSubmitting, - handleSubmit, - handleDelete + handleSubmit }; }; diff --git a/src/utils/userTitlePreferences.ts b/src/utils/userTitlePreferences.ts index f617a0e..6932e30 100644 --- a/src/utils/userTitlePreferences.ts +++ b/src/utils/userTitlePreferences.ts @@ -5,8 +5,11 @@ import { CATEGORY_TITLE_SUGGESTIONS } from '@/constants/categoryIcons'; // 지출 제목 사용 빈도를 저장하는 로컬 스토리지 키 const TITLE_PREFERENCES_KEY = 'userTitlePreferences'; -// 기본 분석 대상 트랜잭션 수 -const DEFAULT_ANALYSIS_COUNT = 50; +// 최대 저장 제목 개수 (카테고리별) +const MAX_TITLES_PER_CATEGORY = 15; + +// 최소 사용 횟수 (이 횟수 미만이면 삭제 대상) +const MIN_USAGE_COUNT = 2; // 사용자 제목 선호도 타입 정의 export interface TitlePreference { @@ -77,8 +80,9 @@ export const updateTitleUsage = (transaction: Transaction): void => { preferences[category] = {}; } - // 해당 제목이 없으면 초기화 + // 해당 제목이 없으면 새로 추가 (새 제목 삽입) if (!preferences[category][title]) { + console.log(`새 제목 추가: "${title}" (${category} 카테고리)`); preferences[category][title] = { count: 0, lastUsed: new Date().toISOString() @@ -89,58 +93,34 @@ export const updateTitleUsage = (transaction: Transaction): void => { preferences[category][title].count += 1; preferences[category][title].lastUsed = new Date().toISOString(); - // 저장 - saveUserTitlePreferences(preferences); -}; - -/** - * 트랜잭션 목록에서 제목 사용 빈도 분석하여 업데이트 - * 앱 초기화 시 또는 주기적으로 호출하여 최신 데이터 반영 - */ -export const analyzeTransactionTitles = ( - transactions: Transaction[], - analysisCount: number = DEFAULT_ANALYSIS_COUNT -): void => { - // 지출 항목만 필터링하고 최신 순으로 정렬 - const recentTransactions = transactions - .filter(tx => tx.type === 'expense') - .sort((a, b) => { - const dateA = a.localTimestamp ? new Date(a.localTimestamp).getTime() : 0; - const dateB = b.localTimestamp ? new Date(b.localTimestamp).getTime() : 0; - return dateB - dateA; - }) - .slice(0, analysisCount); // 최신 N개만 분석 - - // 선호도 데이터 초기화 (기존 데이터 유지를 원하면 이 부분 제거) - let preferences: UserTitlePreferences = { - 음식: {}, - 쇼핑: {}, - 교통: {}, - 기타: {} - }; - - // 트랜잭션 분석 - recentTransactions.forEach(tx => { - if (!tx.category || !tx.title) return; + // 카테고리별 최대 제목 수 관리 (사용 빈도가 낮은 제목 제거) + const titles = Object.entries(preferences[category]); + if (titles.length > MAX_TITLES_PER_CATEGORY) { + // 사용 횟수 및 최근 사용일 기준으로 정렬 + const sortedTitles = titles.sort((a, b) => { + // 먼저 사용 횟수로 비교 (내림차순) + const countDiff = b[1].count - a[1].count; + if (countDiff !== 0) return countDiff; + + // 사용 횟수가 같으면 최근 사용일로 비교 (내림차순) + return new Date(b[1].lastUsed).getTime() - new Date(a[1].lastUsed).getTime(); + }); - if (!preferences[tx.category]) { - preferences[tx.category] = {}; + // 정렬 후 하위 항목 제거 (기준: MIN_USAGE_COUNT 미만 & 가장 적게 사용됨) + const titlesToRemove = sortedTitles + .slice(MAX_TITLES_PER_CATEGORY) + .filter(([_, pref]) => pref.count < MIN_USAGE_COUNT) + .map(([title]) => title); + + if (titlesToRemove.length > 0) { + console.log(`사용 빈도가 낮은 제목 제거: ${titlesToRemove.length}개`); + + // 제거할 제목들을 선호도에서 삭제 + titlesToRemove.forEach(title => { + delete preferences[category][title]; + }); } - - if (!preferences[tx.category][tx.title]) { - preferences[tx.category][tx.title] = { - count: 0, - lastUsed: tx.localTimestamp || new Date().toISOString() - }; - } - - preferences[tx.category][tx.title].count += 1; - - // 가장 최근 사용일자 업데이트 - if (tx.localTimestamp) { - preferences[tx.category][tx.title].lastUsed = tx.localTimestamp; - } - }); + } // 저장 saveUserTitlePreferences(preferences); @@ -184,3 +164,22 @@ export const getPersonalizedTitleSuggestions = (category: string): string[] => { return defaultSuggestions; } }; + +/** + * 트랜잭션 추가 시 제목 사용 빈도 업데이트 및 관리 함수 + * AddTransactionButton에서 호출하기 위한 래퍼 함수 + */ +export const manageTitleSuggestions = (transaction: Transaction): void => { + // 제목 사용 업데이트 (추가 및 카운트 증가) + updateTitleUsage(transaction); + + // 개발 모드에서 저장된 제목 선호도 로깅 + if (process.env.NODE_ENV === 'development') { + const preferences = loadUserTitlePreferences(); + const category = transaction.category; + if (preferences[category]) { + const count = Object.keys(preferences[category]).length; + console.log(`${category} 카테고리 저장된 제목 수: ${count}개`); + } + } +};