diff --git a/src/components/AddTransactionButton.tsx b/src/components/AddTransactionButton.tsx index bf7cce6..3925a3e 100644 --- a/src/components/AddTransactionButton.tsx +++ b/src/components/AddTransactionButton.tsx @@ -1,8 +1,7 @@ - import React, { useState } from 'react'; import { PlusIcon } from 'lucide-react'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog'; -import { toast } from '@/hooks/useToast.wrapper'; +import { toast } from '@/hooks/useToast.wrapper'; // 래퍼 사용 import { useBudget } from '@/contexts/BudgetContext'; import { supabase } from '@/lib/supabase'; import { isSyncEnabled } from '@/utils/syncUtils'; @@ -41,7 +40,7 @@ const AddTransactionButton = () => { amount: parseInt(numericAmount), date: formattedDate, category: data.category, - type: 'expense' // 명시적으로 'expense'로 설정 + type: 'expense' }; console.log('새 지출 추가:', newExpense); @@ -49,7 +48,6 @@ const AddTransactionButton = () => { // BudgetContext를 통해 지출 추가 addTransaction(newExpense); - // 동기화가 활성화되어 있고 사용자가 로그인되어 있다면 Supabase에도 저장 try { const { data: { user } } = await supabase.auth.getUser(); @@ -74,19 +72,17 @@ const AddTransactionButton = () => { // 다이얼로그를 닫습니다 setShowExpenseDialog(false); - // 사용자에게 알림을 표시합니다 (지연 추가하여 다른 토스트와 충돌 방지) - setTimeout(() => { - toast({ - title: "지출이 추가되었습니다", - description: `${data.title} 항목이 ${formatWithCommas(numericAmount)}원으로 등록되었습니다.`, - duration: 3000 - }); - }, 100); + // 이벤트 발생 처리 - 단일 이벤트로 통합 + window.dispatchEvent(new CustomEvent('transactionChanged', { + detail: { type: 'add', transaction: newExpense } + })); - // 브라우저 이벤트 발생시켜 다른 페이지에서도 업데이트되도록 함 - window.dispatchEvent(new Event('budgetDataUpdated')); - window.dispatchEvent(new Event('transactionAdded')); - window.dispatchEvent(new Event('transactionUpdated')); + // 토스트는 한 번만 표시 (지연 제거하여 래퍼에서 처리되도록) + toast({ + title: "지출이 추가되었습니다", + description: `${data.title} 항목이 ${formatWithCommas(numericAmount)}원으로 등록되었습니다.`, + duration: 3000 + }); } catch (error) { console.error('지출 추가 중 오류 발생:', error); toast({ diff --git a/src/components/BudgetTabContent.tsx b/src/components/BudgetTabContent.tsx index 43d19af..6f79d69 100644 --- a/src/components/BudgetTabContent.tsx +++ b/src/components/BudgetTabContent.tsx @@ -5,7 +5,7 @@ import { Check, ChevronDown, ChevronUp, Calculator } from 'lucide-react'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import BudgetProgress from './BudgetProgress'; import CategoryBudgetInputs from './CategoryBudgetInputs'; -import { toast } from '@/components/ui/use-toast'; +import { toast } from '@/hooks/useToast.wrapper'; // 래퍼 함수 사용 import { EXPENSE_CATEGORIES } from '@/constants/categoryIcons'; interface BudgetData { @@ -60,13 +60,23 @@ const BudgetTabContent: React.FC = ({ // Calculate total from all categories const totalAmount = Object.values(categoryBudgets).reduce((sum, value) => sum + value, 0); + // 버튼 중복 클릭 방지를 위해 비활성화 처리 (setTimeout 사용) + const button = document.querySelector('button[type="button"]') as HTMLButtonElement; + if (button) { + button.disabled = true; + setTimeout(() => { + button.disabled = false; + }, 1000); + } + // 카테고리 예산도 함께 전달합니다 onSaveBudget(totalAmount, categoryBudgets); - toast({ - title: "예산 설정 완료", - description: "카테고리별 예산이 성공적으로 저장되었습니다." - }); + + // 단일 토스트만 표시하고 즉시 패널 닫음 setIsOpen(false); + + // 토스트는 이벤트 발생 후 처리되므로 여기서는 호출하지 않음 + // 이 함수에서 직접 toast 호출하지 않고 budgetStorage에서 처리되도록 함 }; // Format with commas for display diff --git a/src/contexts/budget/hooks/useTransactionState.ts b/src/contexts/budget/hooks/useTransactionState.ts index 68df31e..31d9c14 100644 --- a/src/contexts/budget/hooks/useTransactionState.ts +++ b/src/contexts/budget/hooks/useTransactionState.ts @@ -1,4 +1,3 @@ - import { useState, useEffect, useCallback } from 'react'; import { Transaction } from '../types'; import { @@ -6,11 +5,12 @@ import { saveTransactionsToStorage, clearAllTransactions } from '../storage'; -import { toast } from '@/components/ui/use-toast'; +import { toast } from '@/hooks/useToast.wrapper'; // 래퍼 사용 // 트랜잭션 상태 관리 훅 export const useTransactionState = () => { const [transactions, setTransactions] = useState([]); + const [lastDeletedId, setLastDeletedId] = useState(null); // 초기 트랜잭션 로드 및 이벤트 리스너 설정 useEffect(() => { @@ -75,10 +75,20 @@ export const useTransactionState = () => { // 트랜잭션 삭제 함수 const deleteTransaction = useCallback((transactionId: string) => { console.log('트랜잭션 삭제:', transactionId); + + // 중복 삭제 방지 + if (lastDeletedId === transactionId) { + console.log('중복 삭제 요청 무시:', transactionId); + return; + } + + setLastDeletedId(transactionId); + setTransactions(prev => { const updated = prev.filter(transaction => transaction.id !== transactionId); saveTransactionsToStorage(updated); + // 토스트는 한 번만 호출 toast({ title: "지출이 삭제되었습니다", description: "지출 항목이 성공적으로 삭제되었습니다.", @@ -86,7 +96,10 @@ export const useTransactionState = () => { return updated; }); - }, []); + + // 5초 후 lastDeletedId 초기화 + setTimeout(() => setLastDeletedId(null), 5000); + }, [lastDeletedId]); // 트랜잭션 초기화 함수 const resetTransactions = useCallback(() => { diff --git a/src/contexts/budget/storage/budgetStorage.ts b/src/contexts/budget/storage/budgetStorage.ts index 044d7ed..8a9463c 100644 --- a/src/contexts/budget/storage/budgetStorage.ts +++ b/src/contexts/budget/storage/budgetStorage.ts @@ -1,7 +1,7 @@ import { BudgetData } from '../types'; import { getInitialBudgetData } from '../budgetUtils'; -import { toast } from '@/components/ui/use-toast'; +import { toast } from '@/hooks/useToast.wrapper'; // 래퍼 사용 /** * 예산 데이터 불러오기 @@ -70,6 +70,19 @@ export const saveBudgetDataToStorage = (budgetData: BudgetData): void => { // 데이터 문자열로 변환 const dataString = JSON.stringify(budgetData); + // 이전 예산과 비교하여 변경 여부 확인 + let hasChanged = true; + try { + const oldDataString = localStorage.getItem('budgetData'); + if (oldDataString) { + const oldData = JSON.parse(oldDataString); + // 월간 예산이 동일하면 변경되지 않은 것으로 판단 + hasChanged = oldData.monthly.targetAmount !== budgetData.monthly.targetAmount; + } + } catch (e) { + console.error('이전 예산 비교 오류:', e); + } + // 로컬 스토리지에 저장 localStorage.setItem('budgetData', dataString); console.log('예산 데이터 저장 완료', budgetData); @@ -78,19 +91,14 @@ export const saveBudgetDataToStorage = (budgetData: BudgetData): void => { localStorage.setItem('budgetData_backup', dataString); localStorage.setItem('lastBudgetSaveTime', new Date().toISOString()); - // 이벤트 발생 - try { - window.dispatchEvent(new Event('budgetDataUpdated')); - window.dispatchEvent(new StorageEvent('storage', { - key: 'budgetData', - newValue: dataString - })); - } catch (e) { - console.error('이벤트 발생 오류:', e); - } + // 이벤트 발생 (단일 이벤트로 통합) + const event = new CustomEvent('budgetChanged', { + detail: { data: budgetData, hasChanged } + }); + window.dispatchEvent(event); - // toast 알림 - if (budgetData.monthly.targetAmount > 0) { + // toast 알림 (변경된 경우에만) + if (hasChanged && budgetData.monthly.targetAmount > 0) { toast({ title: "예산 저장 완료", description: `월 예산이 ${budgetData.monthly.targetAmount.toLocaleString()}원으로 설정되었습니다.`, diff --git a/src/contexts/budget/storage/categoryStorage.ts b/src/contexts/budget/storage/categoryStorage.ts index 79b4f6e..33e7928 100644 --- a/src/contexts/budget/storage/categoryStorage.ts +++ b/src/contexts/budget/storage/categoryStorage.ts @@ -1,6 +1,7 @@ + import { DEFAULT_CATEGORY_BUDGETS } from '../budgetUtils'; import { EXPENSE_CATEGORIES } from '@/constants/categoryIcons'; -import { toast } from '@/components/ui/use-toast'; +import { toast } from '@/hooks/useToast.wrapper'; // 래퍼 사용 /** * 카테고리 예산 불러오기 @@ -53,6 +54,21 @@ export const loadCategoryBudgetsFromStorage = (): Record => { */ export const saveCategoryBudgetsToStorage = (categoryBudgets: Record): void => { try { + // 이전 예산과 비교하여 변경 여부 확인 + let hasChanged = true; + try { + const oldDataString = localStorage.getItem('categoryBudgets'); + if (oldDataString) { + const oldData = JSON.parse(oldDataString); + // 총 예산이 동일하면 변경되지 않은 것으로 판단 + const oldTotal = Object.values(oldData).reduce((sum: number, val: number) => sum + val, 0); + const newTotal = Object.values(categoryBudgets).reduce((sum: number, val: number) => sum + val, 0); + hasChanged = oldTotal !== newTotal; + } + } catch (e) { + console.error('이전 카테고리 예산 비교 오류:', e); + } + // 3개 카테고리만 유지하고 나머지는 제거 const filteredBudgets: Record = {}; EXPENSE_CATEGORIES.forEach(category => { @@ -69,23 +85,18 @@ export const saveCategoryBudgetsToStorage = (categoryBudgets: Record sum + val, 0); - if (totalBudget > 0) { + if (hasChanged && totalBudget > 0) { toast({ title: "카테고리 예산 저장 완료", description: `카테고리별 예산 총 ${totalBudget.toLocaleString()}원이 설정되었습니다.`, diff --git a/src/hooks/use-toast.ts b/src/hooks/use-toast.ts index 5e8883f..4094035 100644 --- a/src/hooks/use-toast.ts +++ b/src/hooks/use-toast.ts @@ -1,8 +1,7 @@ - import * as React from "react" -const TOAST_LIMIT = 20 -export const TOAST_REMOVE_DELAY = 1000000 +const TOAST_LIMIT = 5 // 최대 5개로 제한 +export const TOAST_REMOVE_DELAY = 5000 // 5초 후 DOM에서 제거 export type ToasterToast = { id: string @@ -55,6 +54,7 @@ interface State { const toastTimeouts = new Map>() +// 토스트 자동 제거 함수 const addToRemoveQueue = (toastId: string) => { if (toastTimeouts.has(toastId)) { return @@ -126,11 +126,37 @@ export const reducer = (state: State, action: Action): State => { } } +// 전역 상태 및 리스너 const listeners: Array<(state: State) => void> = [] - let memoryState: State = { toasts: [] } +// 마지막 액션 추적 (중복 방지용) +let lastAction: { type: string; id?: string; time: number } | null = null + function dispatch(action: Action) { + // 동일한 토스트에 대한 중복 액션 방지 + const now = Date.now(); + const isSameAction = lastAction && + lastAction.type === action.type && + ((action.type === actionTypes.ADD_TOAST && + lastAction.time > now - 1000) || // ADD 액션은 1초 내 중복 방지 + (action.type !== actionTypes.ADD_TOAST && + action.toast?.id === lastAction.id && + lastAction.time > now - 300)); // 다른 액션은 300ms 내 중복 방지 + + if (isSameAction) { + console.log('중복 토스트 액션 무시:', action.type); + return; + } + + // 액션 추적 업데이트 + lastAction = { + type: action.type, + id: action.toast?.id, + time: now + }; + + // 실제 상태 업데이트 및 리스너 호출 memoryState = reducer(memoryState, action) listeners.forEach((listener) => { listener(memoryState) @@ -158,7 +184,7 @@ function toast({ ...props }: Toast) { onOpenChange: (open) => { if (!open) dismiss() }, - duration: props.duration || 5000, // 기본 지속 시간 설정 + duration: props.duration || 3000, // 기본 지속 시간 3초로 단축 }, }) diff --git a/src/hooks/useToast.wrapper.ts b/src/hooks/useToast.wrapper.ts index a34b283..ed274d8 100644 --- a/src/hooks/useToast.wrapper.ts +++ b/src/hooks/useToast.wrapper.ts @@ -1,25 +1,46 @@ import { useToast as useOriginalToast, toast as originalToast, ToasterToast } from '@/hooks/use-toast'; -// 토스트 중복 호출 방지를 위한 디바운스 구현 -let lastToastTime = 0; -let lastToastMessage = ''; -const DEBOUNCE_TIME = 500; // 0.5초 내에 동일 메시지 방지 +// 토스트 이벤트 추적을 위한 히스토리 및 설정 +let toastHistory: { message: string; timestamp: number }[] = []; +const DEBOUNCE_TIME = 1000; // 1초 내에 동일 메시지 방지 +const HISTORY_LIMIT = 10; // 히스토리에 유지할 최대 토스트 개수 +const CLEAR_INTERVAL = 30000; // 30초마다 오래된 히스토리 정리 + +// 히스토리 정리 함수 +const cleanupHistory = () => { + const now = Date.now(); + toastHistory = toastHistory.filter(item => (now - item.timestamp) < 10000); // 10초 이상 지난 항목 제거 +}; + +// 주기적 히스토리 정리 +setInterval(cleanupHistory, CLEAR_INTERVAL); // 중복 토스트 방지 래퍼 함수 const debouncedToast = (params: Omit) => { + const currentMessage = params.description?.toString() || params.title?.toString() || ''; const now = Date.now(); - const currentMessage = params.description?.toString() || ''; - // 동일 메시지가 짧은 시간 내에 반복되는 경우 무시 - if (now - lastToastTime < DEBOUNCE_TIME && currentMessage === lastToastMessage) { + // 유사한 메시지가 최근에 표시되었는지 확인 + const isDuplicate = toastHistory.some(item => + item.message === currentMessage && (now - item.timestamp) < DEBOUNCE_TIME + ); + + // 중복이면 무시 + if (isDuplicate) { console.log('중복 토스트 감지로 무시됨:', currentMessage); return; } - // 정상적인 토스트 호출 - lastToastTime = now; - lastToastMessage = currentMessage; + // 히스토리에 추가 + toastHistory.push({ message: currentMessage, timestamp: now }); + + // 히스토리 크기 제한 + if (toastHistory.length > HISTORY_LIMIT) { + toastHistory.shift(); // 가장 오래된 항목 제거 + } + + // 실제 토스트 표시 originalToast({ ...params, duration: params.duration || 3000, // 기본 3초로 설정