Refactor title suggestion logic
Implement new logic for title suggestions based on usage frequency.
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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> | boolean,
|
||||
onClose: () => void
|
||||
) => {
|
||||
const { updateTransaction, deleteTransaction } = useBudget();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { updateTransaction } = useBudget();
|
||||
|
||||
// 작업 중첩 방지를 위한 참조
|
||||
const isProcessingRef = useRef(false);
|
||||
|
||||
// 폼 설정
|
||||
const form = useForm<TransactionFormValues>({
|
||||
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 handleSubmit = async (values: TransactionFormValues) => {
|
||||
// 중복 제출 방지
|
||||
if (isProcessingRef.current) return;
|
||||
isProcessingRef.current = true;
|
||||
const handleSubmit = (updatedTransaction: Transaction) => {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
// 쉼표 제거 및 숫자로 변환
|
||||
const cleanAmount = values.amount.replace(/,/g, '');
|
||||
|
||||
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<boolean> => {
|
||||
// 중복 처리 방지
|
||||
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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,59 +93,35 @@ export const updateTitleUsage = (transaction: Transaction): void => {
|
||||
preferences[category][title].count += 1;
|
||||
preferences[category][title].lastUsed = new Date().toISOString();
|
||||
|
||||
// 저장
|
||||
saveUserTitlePreferences(preferences);
|
||||
};
|
||||
// 카테고리별 최대 제목 수 관리 (사용 빈도가 낮은 제목 제거)
|
||||
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;
|
||||
|
||||
/**
|
||||
* 트랜잭션 목록에서 제목 사용 빈도 분석하여 업데이트
|
||||
* 앱 초기화 시 또는 주기적으로 호출하여 최신 데이터 반영
|
||||
*/
|
||||
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;
|
||||
|
||||
if (!preferences[tx.category]) {
|
||||
preferences[tx.category] = {};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
// 사용 횟수가 같으면 최근 사용일로 비교 (내림차순)
|
||||
return new Date(b[1].lastUsed).getTime() - new Date(a[1].lastUsed).getTime();
|
||||
});
|
||||
|
||||
// 정렬 후 하위 항목 제거 (기준: 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];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 저장
|
||||
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}개`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user