diff --git a/src/components/AddTransactionButton.tsx b/src/components/AddTransactionButton.tsx index 03dc537..b692275 100644 --- a/src/components/AddTransactionButton.tsx +++ b/src/components/AddTransactionButton.tsx @@ -8,6 +8,7 @@ import { supabase } from '@/lib/supabase'; import { isSyncEnabled } from '@/utils/syncUtils'; import ExpenseForm, { ExpenseFormValues } from './expenses/ExpenseForm'; import { Transaction } from '@/components/TransactionCard'; +import { normalizeDate } from '@/utils/sync/transaction/dateUtils'; const AddTransactionButton = () => { const [showExpenseDialog, setShowExpenseDialog] = useState(false); @@ -53,11 +54,14 @@ const AddTransactionButton = () => { const { data: { user } } = await supabase.auth.getUser(); if (isSyncEnabled() && user) { + // ISO 형식으로 날짜 변환 + const isoDate = normalizeDate(formattedDate); + const { error } = await supabase.from('transactions').insert({ user_id: user.id, title: data.title, amount: parseInt(numericAmount), - date: formattedDate, + date: isoDate, // ISO 형식 사용 category: data.category, type: 'expense', transaction_id: newExpense.id diff --git a/src/components/BudgetTabContent.tsx b/src/components/BudgetTabContent.tsx index 7bbf2c1..07ebb40 100644 --- a/src/components/BudgetTabContent.tsx +++ b/src/components/BudgetTabContent.tsx @@ -52,14 +52,22 @@ const BudgetTabContent: React.FC = ({ // 카테고리별 예산 합계 계산 const calculateTotalBudget = () => { - return Object.values(categoryBudgets).reduce((sum, value) => sum + value, 0); + const total = Object.values(categoryBudgets).reduce((sum, value) => sum + value, 0); + console.log('카테고리 예산 총합:', total, categoryBudgets); + return total; }; // 카테고리 예산 저장 const handleSaveCategoryBudgets = () => { const totalBudget = calculateTotalBudget(); - onSaveBudget(totalBudget, categoryBudgets); - setShowBudgetInput(false); + console.log('카테고리 예산 저장 및 총 예산 설정:', totalBudget, categoryBudgets); + // 총액이 0이 아닐 때만 저장 처리 + if (totalBudget > 0) { + onSaveBudget(totalBudget, categoryBudgets); + setShowBudgetInput(false); + } else { + alert('예산을 입력해주세요.'); + } }; // 기존 카테고리 예산 불러오기 @@ -69,7 +77,9 @@ const BudgetTabContent: React.FC = ({ try { const storedCategoryBudgets = localStorage.getItem('categoryBudgets'); if (storedCategoryBudgets) { - setCategoryBudgets(JSON.parse(storedCategoryBudgets)); + const parsedBudgets = JSON.parse(storedCategoryBudgets); + console.log('저장된 카테고리 예산 불러옴:', parsedBudgets); + setCategoryBudgets(parsedBudgets); } } catch (error) { console.error('카테고리 예산 불러오기 오류:', error); @@ -107,12 +117,9 @@ const BudgetTabContent: React.FC = ({
-
:
@@ -126,13 +133,13 @@ const BudgetTabContent: React.FC = ({ {showBudgetInput &&
-

카테고리별 예산 설정

-

카테고리 예산을 설정하면 일일, 주간, 월간 예산이 자동으로 합산됩니다.

+

카테고리별 월간 예산 설정

+

카테고리별로 월간 예산을 설정하세요. 일일, 주간 예산은 자동으로 계산됩니다.

-

전체 예산:

+

월간 총 예산:

{formatCurrency(calculateTotalBudget())}

diff --git a/src/components/RecentTransactionsSection.tsx b/src/components/RecentTransactionsSection.tsx index b9bd6b8..bd8b525 100644 --- a/src/components/RecentTransactionsSection.tsx +++ b/src/components/RecentTransactionsSection.tsx @@ -1,4 +1,5 @@ -import React, { useState } from 'react'; + +import React, { useState, useCallback, useRef } from 'react'; import { Transaction } from './TransactionCard'; import TransactionEditDialog from './TransactionEditDialog'; import { ChevronRight } from 'lucide-react'; @@ -6,64 +7,208 @@ import { useBudget } from '@/contexts/BudgetContext'; import { Link } from 'react-router-dom'; import { categoryIcons } from '@/constants/categoryIcons'; import TransactionIcon from './transaction/TransactionIcon'; +import { toast } from '@/hooks/useToast.wrapper'; + interface RecentTransactionsSectionProps { transactions: Transaction[]; onUpdateTransaction?: (transaction: Transaction) => void; } + const RecentTransactionsSection: React.FC = ({ transactions, onUpdateTransaction }) => { const [selectedTransaction, setSelectedTransaction] = useState(null); const [isDialogOpen, setIsDialogOpen] = useState(false); - const { - updateTransaction, - deleteTransaction - } = useBudget(); + const [isDeleting, setIsDeleting] = useState(false); + const { updateTransaction, deleteTransaction } = useBudget(); + + // 삭제 중인 ID 추적 + const deletingIdRef = useRef(null); + + // 타임아웃 추적 + const timeoutRef = useRef(null); + + // 삭제 요청 타임스탬프 추적 (급발진 방지) + const lastDeleteTimeRef = useRef>({}); + const handleTransactionClick = (transaction: Transaction) => { setSelectedTransaction(transaction); setIsDialogOpen(true); }; - const handleUpdateTransaction = (updatedTransaction: Transaction) => { + + const handleUpdateTransaction = useCallback((updatedTransaction: Transaction) => { if (onUpdateTransaction) { onUpdateTransaction(updatedTransaction); } // 직접 컨텍스트를 통해 업데이트 updateTransaction(updatedTransaction); - }; - const handleDeleteTransaction = (id: string) => { - // 직접 컨텍스트를 통해 삭제 - deleteTransaction(id); - }; + }, [onUpdateTransaction, updateTransaction]); + + // 완전히 새로운 삭제 처리 함수 + const handleDeleteTransaction = useCallback(async (id: string): Promise => { + return new Promise((resolve) => { + try { + // 삭제 진행 중인지 확인 + if (isDeleting || deletingIdRef.current === id) { + console.log('이미 삭제 작업이 진행 중입니다'); + resolve(true); + return; + } + + // 급발진 방지 (300ms) + const now = Date.now(); + if (lastDeleteTimeRef.current[id] && (now - lastDeleteTimeRef.current[id] < 300)) { + console.warn('삭제 요청이 너무 빠릅니다. 무시합니다.'); + resolve(true); + return; + } + + // 타임스탬프 업데이트 + lastDeleteTimeRef.current[id] = now; + + // 삭제 상태 설정 + setIsDeleting(true); + deletingIdRef.current = id; + + // 먼저 다이얼로그 닫기 (UI 응답성 확보) + setIsDialogOpen(false); + + // 안전장치: 타임아웃 설정 (최대 900ms) + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + timeoutRef.current = setTimeout(() => { + console.warn('삭제 타임아웃 - 상태 초기화'); + setIsDeleting(false); + deletingIdRef.current = null; + resolve(true); // UI 응답성 위해 성공 간주 + }, 900); + + // 삭제 함수 호출 (Promise 래핑) + try { + // 비동기 작업을 동기적으로 처리하여 UI 블로킹 방지 + setTimeout(async () => { + try { + deleteTransaction(id); + + // 안전장치 타임아웃 제거 + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + + // 상태 초기화 (지연 적용) + setTimeout(() => { + setIsDeleting(false); + deletingIdRef.current = null; + }, 100); + } catch (err) { + console.error('삭제 처리 오류:', err); + } + }, 0); + + // 즉시 성공 반환 (UI 응답성 향상) + resolve(true); + } catch (error) { + console.error('deleteTransaction 호출 오류:', error); + + // 타임아웃 제거 + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + + // 상태 초기화 + setIsDeleting(false); + deletingIdRef.current = null; + + resolve(true); // UI 응답성 위해 성공 간주 + } + } catch (error) { + console.error('삭제 처리 전체 오류:', error); + + // 항상 상태 정리 + setIsDeleting(false); + deletingIdRef.current = null; + + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + + toast({ + title: "오류 발생", + description: "처리 중 문제가 발생했습니다.", + variant: "destructive", + duration: 1500 + }); + + resolve(false); + } + }); + }, [deleteTransaction, isDeleting]); + + // 컴포넌트 언마운트 시 타임아웃 정리 + React.useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + const formatCurrency = (amount: number) => { return amount.toLocaleString('ko-KR') + '원'; }; - return
+ + return ( +

최근 지출

더보기
+
- {transactions.length > 0 ? transactions.map(transaction =>
handleTransactionClick(transaction)} className="flex justify-between py-3 cursor-pointer hover:bg-gray-50 px-[5px]"> -
- -
-

{transaction.title}

-

{transaction.date}

-
+ {transactions.length > 0 ? transactions.map(transaction => ( +
handleTransactionClick(transaction)} + className="flex justify-between py-3 cursor-pointer hover:bg-gray-50 px-[5px]" + > +
+ +
+

{transaction.title}

+

{transaction.date}

-
-

-{formatCurrency(transaction.amount)}

-

{transaction.category}

-
-
) :
+
+
+

-{formatCurrency(transaction.amount)}

+

{transaction.category}

+
+
+ )) : ( +
지출 내역이 없습니다 -
} +
+ )}
- {selectedTransaction && } -
; + {selectedTransaction && ( + + )} +
+ ); }; -export default RecentTransactionsSection; \ No newline at end of file + +export default RecentTransactionsSection; diff --git a/src/components/SyncSettings.tsx b/src/components/SyncSettings.tsx index 53efaec..0922eae 100644 --- a/src/components/SyncSettings.tsx +++ b/src/components/SyncSettings.tsx @@ -1,11 +1,12 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; import { CloudUpload } from "lucide-react"; import { useSyncSettings } from '@/hooks/useSyncSettings'; import SyncStatus from '@/components/sync/SyncStatus'; import SyncExplanation from '@/components/sync/SyncExplanation'; +import { isSyncEnabled } from '@/utils/sync/syncSettings'; const SyncSettings = () => { const { @@ -17,6 +18,30 @@ const SyncSettings = () => { handleManualSync } = useSyncSettings(); + // 동기화 설정 변경 모니터링 + useEffect(() => { + const checkSyncStatus = () => { + const currentStatus = isSyncEnabled(); + console.log('현재 동기화 상태:', currentStatus ? '활성화됨' : '비활성화됨'); + }; + + // 초기 상태 확인 + checkSyncStatus(); + + // 스토리지 변경 이벤트에도 동기화 상태 확인 추가 + const handleStorageChange = () => { + checkSyncStatus(); + }; + + window.addEventListener('storage', handleStorageChange); + window.addEventListener('auth-state-changed', handleStorageChange); + + return () => { + window.removeEventListener('storage', handleStorageChange); + window.removeEventListener('auth-state-changed', handleStorageChange); + }; + }, []); + return (
{/* 동기화 토글 컨트롤 */} diff --git a/src/components/TransactionCard.tsx b/src/components/TransactionCard.tsx index dc0b174..968fda5 100644 --- a/src/components/TransactionCard.tsx +++ b/src/components/TransactionCard.tsx @@ -19,18 +19,27 @@ export type Transaction = { interface TransactionCardProps { transaction: Transaction; onUpdate?: (updatedTransaction: Transaction) => void; + onDelete?: (id: string) => Promise | boolean; // 타입 변경됨: boolean 또는 Promise 반환 } const TransactionCard: React.FC = ({ transaction, - onUpdate + onDelete, }) => { const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); - const { title, amount, date, category, type } = transaction; + const { title, amount, date, category } = transaction; - const handleSaveTransaction = (updatedTransaction: Transaction) => { - if (onUpdate) { - onUpdate(updatedTransaction); + // 삭제 핸들러 - 인자로 받은 onDelete가 없거나 타입이 맞지 않을 때 기본 함수 제공 + const handleDelete = async (id: string): Promise => { + try { + if (onDelete) { + return await onDelete(id); + } + console.log('삭제 핸들러가 제공되지 않았습니다'); + return false; + } catch (error) { + console.error('트랜잭션 삭제 처리 중 오류:', error); + return false; } }; @@ -54,7 +63,7 @@ const TransactionCard: React.FC = ({ transaction={transaction} open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen} - onSave={handleSaveTransaction} + onDelete={handleDelete} // 래핑된 핸들러 사용 /> ); diff --git a/src/components/TransactionEditDialog.tsx b/src/components/TransactionEditDialog.tsx index f9999c4..6c3148e 100644 --- a/src/components/TransactionEditDialog.tsx +++ b/src/components/TransactionEditDialog.tsx @@ -10,7 +10,8 @@ import { DialogHeader, DialogTitle, DialogFooter, - DialogClose + DialogClose, + DialogDescription } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Form } from '@/components/ui/form'; @@ -28,7 +29,7 @@ interface TransactionEditDialogProps { open: boolean; onOpenChange: (open: boolean) => void; onSave?: (updatedTransaction: Transaction) => void; - onDelete?: (id: string) => void; + onDelete?: (id: string) => Promise | boolean; } const TransactionEditDialog: React.FC = ({ @@ -39,6 +40,7 @@ const TransactionEditDialog: React.FC = ({ onDelete }) => { const { updateTransaction, deleteTransaction } = useBudget(); + const isMobile = useIsMobile(); const form = useForm({ @@ -77,21 +79,28 @@ const TransactionEditDialog: React.FC = ({ }); }; - const handleDelete = () => { - // 컨텍스트를 통해 트랜잭션 삭제 - deleteTransaction(transaction.id); - - // 부모 컴포넌트의 onDelete 콜백이 있다면 호출 - if (onDelete) { - onDelete(transaction.id); + const handleDelete = async (): Promise => { + try { + // 다이얼로그 닫기를 먼저 수행 (UI 블로킹 방지) + onOpenChange(false); + + // 삭제 처리 - 부모 컴포넌트의 onDelete 콜백이 있다면 호출 + if (onDelete) { + return await onDelete(transaction.id); + } + + // 부모 컴포넌트에서 처리하지 않은 경우 기본 처리 + deleteTransaction(transaction.id); + return true; + } catch (error) { + console.error('트랜잭션 삭제 중 오류:', error); + toast({ + title: "삭제 실패", + description: "지출 항목을 삭제하는데 문제가 발생했습니다.", + variant: "destructive" + }); + return false; } - - onOpenChange(false); - - toast({ - title: "지출이 삭제되었습니다", - description: `${transaction.title} 항목이 삭제되었습니다.`, - }); }; return ( @@ -99,6 +108,9 @@ const TransactionEditDialog: React.FC = ({ 지출 수정 + + 지출 내역을 수정하거나 삭제할 수 있습니다. +
diff --git a/src/components/security/DataResetDialog.tsx b/src/components/security/DataResetDialog.tsx index 994061f..c257a19 100644 --- a/src/components/security/DataResetDialog.tsx +++ b/src/components/security/DataResetDialog.tsx @@ -2,15 +2,7 @@ import React from 'react'; import { CloudOff, Loader2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogClose -} from '@/components/ui/dialog'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogClose } from '@/components/ui/dialog'; interface DataResetDialogProps { isOpen: boolean; @@ -18,6 +10,7 @@ interface DataResetDialogProps { onConfirm: () => Promise; isResetting: boolean; isLoggedIn: boolean; + syncEnabled: boolean; } const DataResetDialog: React.FC = ({ @@ -25,25 +18,26 @@ const DataResetDialog: React.FC = ({ onOpenChange, onConfirm, isResetting, - isLoggedIn + isLoggedIn, + syncEnabled }) => { - return ( - + return 정말 모든 데이터를 초기화하시겠습니까? - {isLoggedIn ? ( - <> + {isLoggedIn ? <> 이 작업은 되돌릴 수 없으며, 로컬 및 클라우드에 저장된 모든 예산, 지출 내역이 영구적으로 삭제됩니다.
클라우드 데이터도 함께 삭제됩니다.
- - ) : ( - "이 작업은 되돌릴 수 없으며, 모든 예산, 지출 내역, 설정이 영구적으로 삭제됩니다." - )} + {syncEnabled && ( +
+ 동기화 설정이 비활성화됩니다. +
+ )} + : "이 작업은 되돌릴 수 없으며, 모든 예산, 지출 내역, 설정이 영구적으로 삭제됩니다."}
단, '환영합니다' 화면 표시 설정과 로그인 상태는 유지됩니다.
@@ -53,22 +47,15 @@ const DataResetDialog: React.FC = ({ -
-
- ); +
; }; export default DataResetDialog; diff --git a/src/components/security/DataResetSection.tsx b/src/components/security/DataResetSection.tsx index a3afce0..a2ec32a 100644 --- a/src/components/security/DataResetSection.tsx +++ b/src/components/security/DataResetSection.tsx @@ -5,15 +5,21 @@ import { Button } from '@/components/ui/button'; import { useAuth } from '@/contexts/auth/AuthProvider'; import { useDataReset } from '@/hooks/useDataReset'; import DataResetDialog from './DataResetDialog'; +import { isSyncEnabled } from '@/utils/sync/syncSettings'; +import { toast } from '@/hooks/useToast.wrapper'; const DataResetSection = () => { const [isResetDialogOpen, setIsResetDialogOpen] = useState(false); const { user } = useAuth(); const { isResetting, resetAllData } = useDataReset(); + const syncEnabled = isSyncEnabled(); const handleResetAllData = async () => { await resetAllData(); setIsResetDialogOpen(false); + + // 데이터 초기화 후 애플리케이션 리로드 + // toast 알림은 useDataReset.ts에서 처리하므로 여기서는 제거 }; return ( @@ -26,7 +32,9 @@ const DataResetSection = () => {

데이터 초기화

- {user ? "로컬 및 클라우드의 모든 예산, 지출 내역이 초기화됩니다." : "모든 예산, 지출 내역, 설정이 초기화됩니다."} + {user + ? "로컬 및 클라우드의 모든 예산, 지출 내역이 초기화됩니다. 동기화 설정은 비활성화됩니다." + : "모든 예산, 지출 내역, 설정이 초기화됩니다."}

@@ -46,6 +54,7 @@ const DataResetSection = () => { onConfirm={handleResetAllData} isResetting={isResetting} isLoggedIn={!!user} + syncEnabled={syncEnabled} /> ); diff --git a/src/components/sync/SyncExplanation.tsx b/src/components/sync/SyncExplanation.tsx index c2b0722..cfaf665 100644 --- a/src/components/sync/SyncExplanation.tsx +++ b/src/components/sync/SyncExplanation.tsx @@ -16,7 +16,7 @@ const SyncExplanation: React.FC = ({ enabled }) => { 동기화 작동 방식 이 기능은 양방향 동기화입니다. 로그인 후 동기화를 켜면 서버 데이터와 로컬 데이터가 병합됩니다. - 데이터 초기화 후에도 동기화 버튼을 누르면 서버에 저장된 데이터를 다시 불러옵니다. + 데이터 초기화 시 동기화 설정은 자동으로 비활성화됩니다. ); diff --git a/src/components/transaction/TransactionDeleteAlert.tsx b/src/components/transaction/TransactionDeleteAlert.tsx index dc7962c..7bb9b87 100644 --- a/src/components/transaction/TransactionDeleteAlert.tsx +++ b/src/components/transaction/TransactionDeleteAlert.tsx @@ -1,7 +1,7 @@ -import React from 'react'; +import React, { useState } from 'react'; import { Button } from '@/components/ui/button'; -import { Trash2 } from 'lucide-react'; +import { Trash2, Loader2 } from 'lucide-react'; import { AlertDialog, AlertDialogAction, @@ -15,19 +15,51 @@ import { } from '@/components/ui/alert-dialog'; interface TransactionDeleteAlertProps { - onDelete: () => void; + onDelete: () => Promise | boolean; } const TransactionDeleteAlert: React.FC = ({ onDelete }) => { + const [isOpen, setIsOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + const handleDelete = async () => { + try { + if (isDeleting) return; // 중복 클릭 방지 + + setIsDeleting(true); + + // 비동기 실행 후 1초 내에 강제 닫힘 (UI 응답성 유지) + const deletePromise = onDelete(); + + // Promise 또는 boolean 값을 처리 (onDelete가 Promise가 아닐 수도 있음) + if (deletePromise instanceof Promise) { + await deletePromise; + } + + // 삭제 작업 완료 후 다이얼로그 닫기 (애니메이션 효과 위해 지연) + setTimeout(() => { + setIsOpen(false); + setTimeout(() => setIsDeleting(false), 300); // 추가 안전장치 + }, 300); + } catch (error) { + console.error('삭제 작업 처리 중 오류:', error); + setIsDeleting(false); + } + }; + return ( - + { + // 삭제 중에는 닫기 방지 + if (isDeleting && !open) return; + setIsOpen(open); + }}> @@ -39,13 +71,24 @@ const TransactionDeleteAlert: React.FC = ({ onDelet - 취소 - 취소 + diff --git a/src/components/transactions/EmptyTransactions.tsx b/src/components/transactions/EmptyTransactions.tsx new file mode 100644 index 0000000..eca4186 --- /dev/null +++ b/src/components/transactions/EmptyTransactions.tsx @@ -0,0 +1,37 @@ + +import React from 'react'; + +interface EmptyTransactionsProps { + searchQuery: string; + selectedMonth: string; + setSearchQuery: (query: string) => void; + isDisabled: boolean; +} + +const EmptyTransactions: React.FC = ({ + searchQuery, + selectedMonth, + setSearchQuery, + isDisabled +}) => { + return ( +
+

+ {searchQuery.trim() + ? '검색 결과가 없습니다.' + : `${selectedMonth}에 등록된 지출이 없습니다.`} +

+ {searchQuery.trim() && ( + + )} +
+ ); +}; + +export default EmptyTransactions; diff --git a/src/components/transactions/TransactionDateGroup.tsx b/src/components/transactions/TransactionDateGroup.tsx new file mode 100644 index 0000000..6321a3a --- /dev/null +++ b/src/components/transactions/TransactionDateGroup.tsx @@ -0,0 +1,47 @@ + +import React from 'react'; +import TransactionCard, { Transaction } from '@/components/TransactionCard'; + +interface TransactionDateGroupProps { + date: string; + transactions: Transaction[]; + onTransactionDelete: (id: string) => Promise | boolean; +} + +const TransactionDateGroup: React.FC = ({ + date, + transactions, + onTransactionDelete +}) => { + // onTransactionDelete 함수를 래핑하여 Promise을 반환하도록 보장 + const handleDelete = async (id: string): Promise => { + try { + return await onTransactionDelete(id); + } catch (error) { + console.error('트랜잭션 삭제 처리 중 오류:', error); + return false; + } + }; + + return ( +
+
+
+

{date}

+
+
+ +
+ {transactions.map(transaction => ( + + ))} +
+
+ ); +}; + +export default TransactionDateGroup; diff --git a/src/components/transactions/TransactionsContent.tsx b/src/components/transactions/TransactionsContent.tsx new file mode 100644 index 0000000..27aef88 --- /dev/null +++ b/src/components/transactions/TransactionsContent.tsx @@ -0,0 +1,61 @@ + +import React from 'react'; +import { Loader2 } from 'lucide-react'; +import { Transaction } from '@/components/TransactionCard'; +import TransactionsList from './TransactionsList'; +import EmptyTransactions from './EmptyTransactions'; + +interface TransactionsContentProps { + isLoading: boolean; + isProcessing: boolean; + transactions: Transaction[]; + groupedTransactions: Record; + searchQuery: string; + selectedMonth: string; + setSearchQuery: (query: string) => void; + onTransactionDelete: (id: string) => Promise | boolean; + isDisabled: boolean; +} + +const TransactionsContent: React.FC = ({ + isLoading, + isProcessing, + transactions, + groupedTransactions, + searchQuery, + selectedMonth, + setSearchQuery, + onTransactionDelete, + isDisabled +}) => { + if (isLoading || isProcessing) { + return ; + } + + if (!isLoading && !isProcessing && transactions.length === 0) { + return ( + + ); + } + + return ( + + ); +}; + +const LoadingState: React.FC<{ isProcessing: boolean }> = ({ isProcessing }) => ( +
+ + {isProcessing ? '처리 중...' : '로딩 중...'} +
+); + +export default TransactionsContent; diff --git a/src/components/transactions/TransactionsHeader.tsx b/src/components/transactions/TransactionsHeader.tsx new file mode 100644 index 0000000..8e241b3 --- /dev/null +++ b/src/components/transactions/TransactionsHeader.tsx @@ -0,0 +1,92 @@ + +import React from 'react'; +import { Calendar, Search, ChevronLeft, ChevronRight } from 'lucide-react'; +import { formatCurrency } from '@/utils/formatters'; + +interface TransactionsHeaderProps { + selectedMonth: string; + searchQuery: string; + setSearchQuery: (query: string) => void; + handlePrevMonth: () => void; + handleNextMonth: () => void; + budgetData: any; + totalExpenses: number; + isDisabled: boolean; +} + +const TransactionsHeader: React.FC = ({ + selectedMonth, + searchQuery, + setSearchQuery, + handlePrevMonth, + handleNextMonth, + budgetData, + totalExpenses, + isDisabled +}) => { + console.log('TransactionsHeader 렌더링:', { selectedMonth, totalExpenses }); + + // 예산 정보가 없는 경우 기본값 사용 + const targetAmount = budgetData?.monthly?.targetAmount || 0; + + return ( +
+

지출 내역

+ + {/* Search */} +
+ + setSearchQuery(e.target.value)} + disabled={isDisabled} + /> +
+ + {/* Month Selector */} +
+ + +
+ + {selectedMonth} +
+ + +
+ + {/* Summary */} +
+
+

총 예산

+

+ {formatCurrency(targetAmount)} +

+
+
+

총 지출

+

+ {formatCurrency(totalExpenses)} +

+
+
+
+ ); +}; + +export default TransactionsHeader; diff --git a/src/components/transactions/TransactionsList.tsx b/src/components/transactions/TransactionsList.tsx new file mode 100644 index 0000000..1e9c09c --- /dev/null +++ b/src/components/transactions/TransactionsList.tsx @@ -0,0 +1,29 @@ + +import React from 'react'; +import TransactionCard, { Transaction } from '@/components/TransactionCard'; +import TransactionDateGroup from './TransactionDateGroup'; + +interface TransactionsListProps { + groupedTransactions: Record; + onTransactionDelete: (id: string) => Promise | boolean; +} + +const TransactionsList: React.FC = ({ + groupedTransactions, + onTransactionDelete +}) => { + return ( +
+ {Object.entries(groupedTransactions).map(([date, dateTransactions]) => ( + + ))} +
+ ); +}; + +export default TransactionsList; diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx index a822477..5d47d4a 100644 --- a/src/components/ui/toast.tsx +++ b/src/components/ui/toast.tsx @@ -1,3 +1,4 @@ + import * as React from "react" import * as ToastPrimitives from "@radix-ui/react-toast" import { cva, type VariantProps } from "class-variance-authority" @@ -92,7 +93,7 @@ const ToastTitle = React.forwardRef< >(({ className, ...props }, ref) => ( )) @@ -104,7 +105,7 @@ const ToastDescription = React.forwardRef< >(({ className, ...props }, ref) => ( )) diff --git a/src/components/ui/toaster.tsx b/src/components/ui/toaster.tsx index 5b7ef65..628ec3c 100644 --- a/src/components/ui/toaster.tsx +++ b/src/components/ui/toaster.tsx @@ -17,7 +17,7 @@ export function Toaster() { {toasts.map(function ({ id, title, description, action, ...props }) { return ( -
+
{title && {title}} {description && ( {description} diff --git a/src/contexts/auth/signIn.ts b/src/contexts/auth/signIn.ts index e04fb0e..59f577b 100644 --- a/src/contexts/auth/signIn.ts +++ b/src/contexts/auth/signIn.ts @@ -1,70 +1,45 @@ -import { supabase } from '@/integrations/supabase/client'; -import { - handleNetworkError, - parseResponse, - showAuthToast, - verifyServerConnection -} from '@/utils/auth'; -import { signInWithDirectApi } from './signInUtils'; -import { getProxyType, isCorsProxyEnabled } from '@/lib/supabase/config'; +import { supabase } from '@/lib/supabase'; +import { showAuthToast } from '@/utils/auth'; +/** + * 로그인 기능 - Supabase Cloud 환경에 최적화 + */ export const signIn = async (email: string, password: string) => { try { console.log('로그인 시도 중:', email); - // 기본 Supabase 인증 방식 시도 - try { - const { data, error } = await supabase.auth.signInWithPassword({ - email, - password - }); + // Supabase 인증 방식 시도 + const { data, error } = await supabase.auth.signInWithPassword({ + email, + password + }); + + if (!error && data.user) { + showAuthToast('로그인 성공', '환영합니다!'); + return { error: null, user: data.user }; + } else if (error) { + console.error('로그인 오류:', error.message); - if (!error && data.user) { - showAuthToast('로그인 성공', '환영합니다!'); - return { error: null, user: data.user }; - } else if (error) { - console.error('Supabase 기본 로그인 오류:', error.message); - - let errorMessage = error.message; - if (error.message.includes('Invalid login credentials')) { - errorMessage = '이메일 또는 비밀번호가 올바르지 않습니다.'; - } else if (error.message.includes('Email not confirmed')) { - errorMessage = '이메일 인증이 완료되지 않았습니다. 이메일을 확인해주세요.'; - } - - showAuthToast('로그인 실패', errorMessage, 'destructive'); - return { error: { message: errorMessage }, user: null }; + let errorMessage = error.message; + if (error.message.includes('Invalid login credentials')) { + errorMessage = '이메일 또는 비밀번호가 올바르지 않습니다.'; + } else if (error.message.includes('Email not confirmed')) { + errorMessage = '이메일 인증이 완료되지 않았습니다. 이메일을 확인해주세요.'; } - } catch (basicAuthError: any) { - console.warn('Supabase 기본 인증 방식 예외 발생:', basicAuthError); - throw basicAuthError; + + showAuthToast('로그인 실패', errorMessage, 'destructive'); + return { error: { message: errorMessage }, user: null }; } - // 여기까지 왔다면 모든 로그인 시도가 실패한 것 + // 여기까지 왔다면 오류가 발생한 것 showAuthToast('로그인 실패', '로그인 처리 중 오류가 발생했습니다.', 'destructive'); return { error: { message: '로그인 처리 중 오류가 발생했습니다.' }, user: null }; } catch (error: any) { console.error('로그인 중 예외 발생:', error); - - // 프록시 설정 확인 및 추천 - const usingProxy = isCorsProxyEnabled(); - const proxyType = getProxyType(); - - // 네트워크 오류 확인 - let errorMessage = handleNetworkError(error); - - // CORS 또는 네트워크 오류인 경우 Cloudflare 프록시 추천 - if (errorMessage.includes('CORS') || errorMessage.includes('네트워크') || errorMessage.includes('연결')) { - if (!usingProxy) { - errorMessage = `${errorMessage} (설정에서 Cloudflare CORS 프록시 활성화를 권장합니다)`; - } else if (proxyType !== 'cloudflare') { - errorMessage = `${errorMessage} (설정에서 Cloudflare CORS 프록시로 변경을 권장합니다)`; - } - } + const errorMessage = error.message || '로그인 중 오류가 발생했습니다.'; showAuthToast('로그인 오류', errorMessage, 'destructive'); - return { error: { message: errorMessage }, user: null }; } }; diff --git a/src/contexts/auth/signInUtils.ts b/src/contexts/auth/signInUtils.ts index 688b661..cc2f9a9 100644 --- a/src/contexts/auth/signInUtils.ts +++ b/src/contexts/auth/signInUtils.ts @@ -1,208 +1,53 @@ import { supabase } from '@/lib/supabase'; -import { parseResponse, showAuthToast, handleNetworkError } from '@/utils/auth'; -import { getProxyType, isCorsProxyEnabled, getSupabaseUrl, getOriginalSupabaseUrl } from '@/lib/supabase/config'; +import { showAuthToast } from '@/utils/auth'; /** - * 직접 API 호출을 통한 로그인 시도 (대체 방법) + * 로그인 기능 - Supabase Cloud 환경에 최적화 */ export const signInWithDirectApi = async (email: string, password: string) => { - console.log('직접 API 호출로 로그인 시도'); + console.log('Supabase Cloud 로그인 시도'); try { - // API 호출 URL 및 헤더 설정 - const supabaseUrl = getOriginalSupabaseUrl(); // 원본 URL 사용 - const proxyUrl = getSupabaseUrl(); // 프록시 적용된 URL - const supabaseKey = localStorage.getItem('supabase_key') || supabase.supabaseKey; - - // 프록시 정보 로그 - const usingProxy = isCorsProxyEnabled(); - const proxyType = getProxyType(); - console.log(`CORS 프록시 사용: ${usingProxy ? '예' : '아니오'}, 타입: ${proxyType}, 프록시 URL: ${proxyUrl}`); - - // 실제 요청에 사용할 URL 결정 (항상 프록시 URL 사용) - const useUrl = usingProxy ? proxyUrl : supabaseUrl; - - // URL에 auth/v1이 이미 포함되어있는지 확인 - const baseUrl = useUrl.includes('/auth/v1') ? useUrl : `${useUrl}/auth/v1`; - - // 토큰 엔드포인트 경로 - const tokenUrl = `${baseUrl}/token?grant_type=password`; - - console.log('로그인 API 요청 URL:', tokenUrl); - - // 로그인 요청 보내기 - const response = await fetch(tokenUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'apikey': supabaseKey - }, - body: JSON.stringify({ email, password }) + // Supabase Cloud를 통한 로그인 요청 + const { data, error } = await supabase.auth.signInWithPassword({ + email, + password }); - // 응답 상태 확인 및 로깅 - console.log('로그인 응답 상태:', response.status); - - // HTTP 상태 코드 확인 - if (response.status === 401) { - console.log('로그인 실패: 인증 오류'); - showAuthToast('로그인 실패', '이메일 또는 비밀번호가 올바르지 않습니다.', 'destructive'); - return { - error: { message: '인증 실패: 이메일 또는 비밀번호가 올바르지 않습니다.' }, - user: null - }; - } - - if (response.status === 404) { - console.warn('API 경로를 찾을 수 없음 (404). 새 엔드포인트 시도 중...'); + // 오류 응답 처리 + if (error) { + console.error('로그인 오류:', error); - // 대체 엔드포인트 시도 (/token 대신 /signin) - const signinUrl = `${baseUrl}/signin`; + // 오류 메시지 포맷팅 + let errorMessage = error.message; - try { - const signinResponse = await fetch(signinUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'apikey': supabaseKey - }, - body: JSON.stringify({ email, password }) - }); - - console.log('대체 로그인 경로 응답 상태:', signinResponse.status); - - if (signinResponse.status === 404) { - showAuthToast('로그인 실패', '서버 설정을 확인하세요: 인증 API 경로를 찾을 수 없습니다.', 'destructive'); - return { - error: { message: '서버 설정 문제: 인증 API 경로를 찾을 수 없습니다. Supabase URL을 확인하세요.' }, - user: null - }; - } - - // 대체 응답 처리 - const signinData = await parseResponse(signinResponse); - if (signinData.error) { - showAuthToast('로그인 실패', signinData.error, 'destructive'); - return { error: { message: signinData.error }, user: null }; - } - - if (signinData.access_token) { - await supabase.auth.setSession({ - access_token: signinData.access_token, - refresh_token: signinData.refresh_token || '' - }); - - const { data: userData } = await supabase.auth.getUser(); - showAuthToast('로그인 성공', '환영합니다!'); - return { error: null, user: userData.user }; - } - } catch (altError) { - console.error('대체 로그인 엔드포인트 오류:', altError); + if (error.message.includes('Invalid login credentials')) { + errorMessage = '이메일 또는 비밀번호가 올바르지 않습니다.'; + } else if (error.message.includes('Email not confirmed')) { + errorMessage = '이메일 인증이 완료되지 않았습니다. 이메일을 확인해주세요.'; } - showAuthToast('로그인 실패', '서버 설정을 확인하세요: 인증 API 경로를 찾을 수 없습니다.', 'destructive'); - return { - error: { message: '서버 경로를 찾을 수 없습니다. Supabase URL을 확인하세요.' }, - user: null - }; - } - - // 응답 처리 - const responseText = await response.text(); - console.log('로그인 응답 내용:', responseText); - - let responseData; - try { - // 응답이 비어있지 않은 경우에만 JSON 파싱 시도 - responseData = responseText ? JSON.parse(responseText) : {}; - } catch (e) { - console.warn('JSON 파싱 실패:', e, '원본 응답:', responseText); - if (response.status >= 200 && response.status < 300) { - // 성공 응답이지만 JSON이 아닌 경우 (빈 응답 등) - responseData = { success: true }; - } else { - responseData = { error: '서버 응답을 처리할 수 없습니다' }; - } - } - - // 오류 응답 확인 - if (responseData?.error) { - const errorMessage = responseData.error_description || responseData.error; showAuthToast('로그인 실패', errorMessage, 'destructive'); return { error: { message: errorMessage }, user: null }; } - // 로그인 성공 응답 처리 - if (response.ok && responseData?.access_token) { - try { - // 로그인 성공 시 Supabase 세션 설정 - await supabase.auth.setSession({ - access_token: responseData.access_token, - refresh_token: responseData.refresh_token || '' - }); - - // 사용자 정보 가져오기 - const { data: userData } = await supabase.auth.getUser(); - - console.log('로그인 성공:', userData); - - showAuthToast('로그인 성공', '환영합니다!'); - - return { error: null, user: userData.user }; - } catch (sessionError) { - console.error('세션 설정 오류:', sessionError); - showAuthToast('로그인 후처리 오류', '로그인에 성공했지만 세션 설정에 실패했습니다.', 'destructive'); - return { error: { message: '세션 설정 오류' }, user: null }; - } - } else if (response.ok) { - // 응답 내용 없이 성공 상태인 경우 - try { - // 사용자 세션 확인 시도 - const { data: userData } = await supabase.auth.getUser(); - if (userData.user) { - showAuthToast('로그인 성공', '환영합니다!'); - return { error: null, user: userData.user }; - } else { - // 세션은 있지만 사용자 정보가 없는 경우 - showAuthToast('로그인 부분 성공', '로그인은 성공했지만 사용자 정보를 가져오지 못했습니다.', 'default'); - return { error: { message: '사용자 정보 조회 실패' }, user: null }; - } - } catch (userError) { - console.error('사용자 정보 조회 오류:', userError); - showAuthToast('로그인 후처리 오류', '로그인은 성공했지만 사용자 정보를 가져오지 못했습니다.', 'destructive'); - return { error: { message: '사용자 정보 조회 실패' }, user: null }; - } + // 로그인 성공 처리 + if (data && data.user) { + console.log('로그인 성공:', data.user); + showAuthToast('로그인 성공', '환영합니다!'); + return { error: null, user: data.user }; } else { - // 오류 응답이나 예상치 못한 응답 형식 처리 - console.error('로그인 오류 응답:', responseData); - - const errorMessage = responseData?.error_description || - responseData?.error || - responseData?.message || - '로그인에 실패했습니다. 이메일과 비밀번호를 확인하세요.'; - - showAuthToast('로그인 실패', errorMessage, 'destructive'); - return { error: { message: errorMessage }, user: null }; + // 사용자 정보가 없는 경우 (드문 경우) + console.warn('로그인 성공했지만 사용자 정보가 없습니다'); + showAuthToast('로그인 부분 성공', '로그인은 성공했지만 사용자 정보를 가져오지 못했습니다.', 'default'); + return { error: { message: '사용자 정보 조회 실패' }, user: null }; } - } catch (fetchError) { - console.error('로그인 요청 중 fetch 오류:', fetchError); + } catch (error: any) { + console.error('로그인 요청 중 예외:', error); + const errorMessage = error.message || '로그인 중 오류가 발생했습니다.'; - // 오류 발생 시 프록시 설정 확인 정보 출력 - const usingProxy = isCorsProxyEnabled(); - const proxyType = getProxyType(); - console.log(`오류 발생 시 CORS 설정 - 프록시 사용: ${usingProxy ? '예' : '아니오'}, 타입: ${proxyType}`); - - // Cloudflare 프록시 추천 메시지 추가 - const errorMessage = handleNetworkError(fetchError); - let enhancedMessage = errorMessage; - - if (!usingProxy || proxyType !== 'cloudflare') { - enhancedMessage = `${errorMessage} (설정에서 Cloudflare CORS 프록시 사용을 권장합니다)`; - } - - showAuthToast('로그인 요청 실패', enhancedMessage, 'destructive'); - - return { error: { message: enhancedMessage }, user: null }; + showAuthToast('로그인 요청 실패', errorMessage, 'destructive'); + return { error: { message: errorMessage }, user: null }; } }; diff --git a/src/contexts/auth/signUp.ts b/src/contexts/auth/signUp.ts index 54848b1..1adc2c1 100644 --- a/src/contexts/auth/signUp.ts +++ b/src/contexts/auth/signUp.ts @@ -1,8 +1,10 @@ import { supabase } from '@/lib/supabase'; import { showAuthToast, verifyServerConnection } from '@/utils/auth'; -import { signUpWithDirectApi } from './signUpUtils'; +/** + * 회원가입 기능 - Supabase Cloud 환경에 최적화 + */ export const signUp = async (email: string, password: string, username: string) => { try { // 서버 연결 상태 확인 @@ -15,137 +17,74 @@ export const signUp = async (email: string, password: string, username: string) console.log('회원가입 시도:', email); - // Supabase anon 키 확인 - const supabaseKey = localStorage.getItem('supabase_key'); - if (!supabaseKey || supabaseKey.includes('your-onpremise-anon-key')) { - showAuthToast('설정 오류', 'Supabase 설정이 올바르지 않습니다. 설정 페이지에서 확인해주세요.', 'destructive'); - return { error: { message: 'Supabase 설정이 올바르지 않습니다. 설정 페이지에서 확인해주세요.' }, user: null }; - } - // 현재 브라우저 URL 가져오기 const currentUrl = window.location.origin; - // 해시 대신 쿼리 파라미터 방식으로 URL 구성 (auth_callback 파라미터 추가) const redirectUrl = `${currentUrl}/login?auth_callback=true`; console.log('이메일 인증 리디렉션 URL:', redirectUrl); - // 기본 회원가입 시도 - try { - // 디버깅용 로그 - console.log('Supabase 회원가입 요청 시작 - 이메일:', email, '사용자명:', username); + // 회원가입 요청 + const { data, error } = await supabase.auth.signUp({ + email, + password, + options: { + data: { + username, // 사용자 이름을 메타데이터에 저장 + }, + emailRedirectTo: redirectUrl + } + }); + + if (error) { + console.error('회원가입 오류:', error); - const { data, error } = await supabase.auth.signUp({ - email, - password, - options: { - data: { - username, // 사용자 이름을 메타데이터에 저장 - }, - emailRedirectTo: redirectUrl // 현재 도메인 기반 리디렉션 URL 사용 - } - }); + // 오류 메시지 처리 + let errorMessage = error.message; - console.log('Supabase 회원가입 응답:', { data, error }); - - if (error) { - console.error('회원가입 오류:', error); - - // REST API 오류인 경우 직접 API 호출 시도 - if (error.message.includes('json') || - error.message.includes('Unexpected end') || - error.message.includes('404') || - error.message.includes('Not Found') || - error.message.includes('Failed to fetch')) { - console.warn('기본 회원가입 실패, 직접 API 호출 시도:', error.message); - - // 직접 API 호출에도 현재 도메인 기반 리디렉션 URL 전달 - return await signUpWithDirectApi(email, password, username, redirectUrl); - } - - // 401 오류 감지 및 처리 - if (error.message.includes('401') || error.message.includes('권한이 없습니다') || - error.message.includes('Unauthorized') || error.status === 401) { - const errorMessage = '회원가입 권한이 없습니다. Supabase 설정 또는 권한을 확인하세요.'; - showAuthToast('회원가입 실패', errorMessage, 'destructive'); - return { error: { message: errorMessage }, user: null, redirectToSettings: true }; - } - - // 기타 오류 처리 - let errorMessage = error.message; - - if (error.message.includes('User already registered')) { - errorMessage = '이미 등록된 사용자입니다.'; - } else if (error.message.includes('Signup not allowed')) { - errorMessage = '회원가입이 허용되지 않습니다.'; - } else if (error.message.includes('Email link invalid')) { - errorMessage = '이메일 링크가 유효하지 않습니다.'; - } - - showAuthToast('회원가입 실패', errorMessage, 'destructive'); - return { error: { message: errorMessage }, user: null }; + if (error.message.includes('User already registered')) { + errorMessage = '이미 등록된 사용자입니다.'; + } else if (error.message.includes('Signup not allowed')) { + errorMessage = '회원가입이 허용되지 않습니다.'; + } else if (error.message.includes('Email link invalid')) { + errorMessage = '이메일 링크가 유효하지 않습니다.'; } - // 회원가입 성공 - if (data && data.user) { - // 이메일 확인이 필요한지 확인 - const isEmailConfirmationRequired = data.user.identities && - data.user.identities.length > 0 && - !data.user.identities[0].identity_data?.email_verified; - - if (isEmailConfirmationRequired) { - // 인증 메일 전송 성공 메시지와 이메일 확인 안내 - showAuthToast('회원가입 성공', '인증 메일이 발송되었습니다. 스팸 폴더도 확인해주세요.', 'default'); - console.log('인증 메일 발송됨:', email); - - return { - error: null, - user: data.user, - message: '이메일 인증 필요', - emailConfirmationRequired: true - }; - } else { - showAuthToast('회원가입 성공', '환영합니다!', 'default'); - return { error: null, user: data.user }; - } - } - - // 사용자 데이터가 없는 경우 (드물게 발생) - console.warn('회원가입 응답은 성공했지만 사용자 데이터가 없습니다'); - showAuthToast('회원가입 성공', '계정이 생성되었습니다. 이메일 인증을 완료한 후 로그인해주세요.', 'default'); - return { - error: null, - user: { email }, - message: '회원가입 완료', - emailConfirmationRequired: true - }; - } catch (error: any) { - console.error('기본 회원가입 프로세스 예외:', error); - - // 401 오류 감지 및 처리 - if (error.status === 401 || (error.message && error.message.includes('401'))) { - const errorMessage = '회원가입 권한이 없습니다. Supabase 설정 또는 권한을 확인하세요.'; - showAuthToast('회원가입 실패', errorMessage, 'destructive'); - return { error: { message: errorMessage }, user: null, redirectToSettings: true }; - } - - // 직접 API 호출로 대체 시도 - if (error.message && ( - error.message.includes('json') || - error.message.includes('fetch') || - error.message.includes('404') || - error.message.includes('Not Found') || - error.message.includes('timed out') || - error.message.includes('Failed to fetch'))) { - console.warn('직접 API 호출로 재시도:', error); - - // 현재 도메인 기반 리디렉션 URL 전달 - return await signUpWithDirectApi(email, password, username, redirectUrl); - } - - // 기타 예외 처리 - showAuthToast('회원가입 예외', error.message || '알 수 없는 오류', 'destructive'); - return { error: { message: error.message || '알 수 없는 오류' }, user: null }; + showAuthToast('회원가입 실패', errorMessage, 'destructive'); + return { error: { message: errorMessage }, user: null }; } + + // 회원가입 성공 + if (data && data.user) { + // 이메일 확인이 필요한지 확인 + const isEmailConfirmationRequired = data.user.identities && + data.user.identities.length > 0 && + !data.user.identities[0].identity_data?.email_verified; + + if (isEmailConfirmationRequired) { + showAuthToast('회원가입 성공', '인증 메일이 발송되었습니다. 스팸 폴더도 확인해주세요.', 'default'); + console.log('인증 메일 발송됨:', email); + + return { + error: null, + user: data.user, + message: '이메일 인증 필요', + emailConfirmationRequired: true + }; + } else { + showAuthToast('회원가입 성공', '환영합니다!', 'default'); + return { error: null, user: data.user }; + } + } + + // 사용자 데이터가 없는 경우 (드물게 발생) + console.warn('회원가입 응답은 성공했지만 사용자 데이터가 없습니다'); + showAuthToast('회원가입 성공', '계정이 생성되었습니다. 이메일 인증을 완료한 후 로그인해주세요.', 'default'); + return { + error: null, + user: { email }, + message: '회원가입 완료', + emailConfirmationRequired: true + }; } catch (error: any) { console.error('회원가입 전역 예외:', error); showAuthToast('회원가입 오류', error.message || '알 수 없는 오류', 'destructive'); diff --git a/src/contexts/auth/signUpUtils.ts b/src/contexts/auth/signUpUtils.ts index 4373455..27cfc9e 100644 --- a/src/contexts/auth/signUpUtils.ts +++ b/src/contexts/auth/signUpUtils.ts @@ -1,156 +1,87 @@ import { supabase } from '@/lib/supabase'; import { parseResponse, showAuthToast } from '@/utils/auth'; -import { sendSignUpApiRequest, getStatusErrorMessage } from './signUpApiCalls'; -import { handleSignUpApiError, handleResponseError } from './signUpErrorHandlers'; /** - * 직접 API 호출을 통한 회원가입 + * 회원가입 기능 - Supabase Cloud 환경에 최적화 */ export const signUpWithDirectApi = async (email: string, password: string, username: string, redirectUrl?: string) => { try { - console.log('직접 API 호출로 회원가입 시도 중'); - - // Supabase 키 가져오기 - const supabaseKey = localStorage.getItem('supabase_key') || supabase.supabaseKey; - - // Supabase 키 유효성 검사 - if (!supabaseKey || supabaseKey.includes('your-onpremise-anon-key')) { - return { - error: { message: 'Supabase 설정이 올바르지 않습니다. 설정 페이지에서 확인해주세요.' }, - user: null, - redirectToSettings: true - }; - } + console.log('Supabase Cloud 회원가입 시도 중'); // 리디렉션 URL 설정 (전달되지 않은 경우 기본값 사용) - // 해시(#) 대신 쿼리 파라미터(?token=) 방식으로 URL 구성 const finalRedirectUrl = redirectUrl || `${window.location.origin}/login?auth_callback=true`; - console.log('이메일 인증 리디렉션 URL (API):', finalRedirectUrl); + console.log('이메일 인증 리디렉션 URL:', finalRedirectUrl); - // API 요청 전송 - const response = await sendSignUpApiRequest(email, password, username, finalRedirectUrl, supabaseKey); - - // 401 오류 처리 (권한 없음) - if (response.status === 401) { - showAuthToast('회원가입 실패', '회원가입 권한이 없습니다. Supabase 설정 또는 권한을 확인하세요.', 'destructive'); - return { - error: { message: '회원가입 권한이 없습니다. Supabase 설정 또는 권한을 확인하세요.' }, - user: null, - redirectToSettings: true - }; - } - - // HTTP 상태 코드 확인 - if (response.status === 404) { - showAuthToast('회원가입 실패', '서버 경로를 찾을 수 없습니다. Supabase URL을 확인하세요.', 'destructive'); - return { - error: { message: '서버 경로를 찾을 수 없습니다. Supabase URL을 확인하세요.' }, - user: null, - redirectToSettings: true - }; - } - - // 응답 내용 가져오기 - const responseText = await response.text(); - console.log('회원가입 응답 내용:', responseText); - - let responseData; - try { - // 응답이 비어있지 않은 경우에만 JSON 파싱 시도 - responseData = responseText && responseText.trim() !== '' ? JSON.parse(responseText) : {}; - } catch (e) { - console.warn('JSON 파싱 실패:', e, '원본 응답:', responseText); - - // 401 응답은 인증 실패로 처리 - if (response.status === 401) { - return { - error: { - message: '회원가입 권한이 없습니다. Supabase 설정 또는 권한을 확인하세요.' - }, - user: null, - redirectToSettings: true - }; + // Supabase Cloud API를 통한 회원가입 요청 + const { data, error } = await supabase.auth.signUp({ + email, + password, + options: { + data: { + username // 사용자 이름을 메타데이터에 저장 + }, + emailRedirectTo: finalRedirectUrl } + }); + + // 오류 처리 + if (error) { + console.error('회원가입 오류:', error); - if (response.status >= 200 && response.status < 300) { - // 성공 응답이지만 JSON이 아닌 경우 (빈 응답 등) - responseData = { success: true }; - } else { - responseData = { error: '서버 응답을 처리할 수 없습니다' }; + let errorMessage = error.message; + if (error.message.includes('User already registered')) { + errorMessage = '이미 등록된 사용자입니다.'; + } else if (error.message.includes('Signup not allowed')) { + errorMessage = '회원가입이 허용되지 않습니다.'; + } else if (error.message.includes('Email link invalid')) { + errorMessage = '이메일 링크가 유효하지 않습니다.'; } - } - - // 응답 에러 처리 - const errorResult = handleResponseError(responseData); - if (errorResult) return errorResult; - - // 응답 상태 코드가 성공(2xx)이면서 사용자 데이터가 있는 경우 - if (response.ok && responseData && responseData.id) { - return processSuccessfulSignup(responseData, email, password); - } - // 응답이 성공(2xx)이지만, 사용자 정보가 없는 경우 또는 응답 본문이 비어있는 경우 - else if (response.ok) { - // 응답 본문이 비어 있는 경우는 서버가 성공을 반환했지만 데이터가 없는 경우 (일부 Supabase 버전에서 발생) - showAuthToast('회원가입 요청 완료', '회원가입 요청이 처리되었습니다. 이메일을 확인하거나 로그인을 시도해보세요.', 'default'); - return { - error: null, - user: { email }, - message: '회원가입 처리 완료' - }; - } - // 401 오류인 경우 인증 실패로 처리 - else if (response.status === 401) { - const errorMessage = '회원가입 권한이 없습니다. Supabase 설정 또는 권한을 확인하세요.'; - showAuthToast('회원가입 실패', errorMessage, 'destructive'); - return { error: { message: errorMessage }, user: null, redirectToSettings: true }; - } - // 다른 모든 오류 상태 - else { - // 응답 상태 코드에 따른 오류 메시지 - const errorMessage = getStatusErrorMessage(response.status); showAuthToast('회원가입 실패', errorMessage, 'destructive'); return { error: { message: errorMessage }, user: null }; } - } catch (error: any) { - return handleSignUpApiError(error); - } -}; - -/** - * 성공적인 회원가입 응답 처리 - */ -const processSuccessfulSignup = async (responseData: any, email: string, password: string) => { - const user = { - id: responseData.id, - email: responseData.email, - user_metadata: responseData.user_metadata || { username: responseData.user_metadata?.username || '' }, - app_metadata: responseData.app_metadata || {}, - created_at: responseData.created_at, - }; - - const confirmEmail = !responseData.confirmed_at; - - if (confirmEmail) { - showAuthToast('회원가입 성공', '이메일 인증을 완료해주세요.', 'default'); - return { - error: null, - user, - message: '이메일 인증 필요', - emailConfirmationRequired: true - }; - } else { - showAuthToast('회원가입 성공', '환영합니다!', 'default'); - // 성공 시 바로 로그인 세션 설정 시도 - try { - await supabase.auth.signInWithPassword({ email, password }); - } catch (loginError) { - console.warn('자동 로그인 실패:', loginError); - // 무시하고 계속 진행 (회원가입은 성공) + // 회원가입 성공 + if (data && data.user) { + // 이메일 확인이 필요한지 확인 + const isEmailConfirmationRequired = data.user.identities && + data.user.identities.length > 0 && + !data.user.identities[0].identity_data?.email_verified; + + if (isEmailConfirmationRequired) { + // 인증 메일 전송 성공 메시지와 이메일 확인 안내 + showAuthToast('회원가입 성공', '인증 메일이 발송되었습니다. 스팸 폴더도 확인해주세요.', 'default'); + console.log('인증 메일 발송됨:', email); + + return { + error: null, + user: data.user, + message: '이메일 인증 필요', + emailConfirmationRequired: true + }; + } else { + showAuthToast('회원가입 성공', '환영합니다!', 'default'); + return { error: null, user: data.user }; + } } - return { error: null, user }; + // 사용자 데이터가 없는 경우 (드물게 발생) + console.warn('회원가입 응답은 성공했지만 사용자 데이터가 없습니다'); + showAuthToast('회원가입 성공', '계정이 생성되었습니다. 이메일 인증을 완료한 후 로그인해주세요.', 'default'); + + return { + error: null, + user: { email }, + message: '회원가입 완료', + emailConfirmationRequired: true + }; + } catch (error: any) { + console.error('회원가입 중 예외 발생:', error); + + const errorMessage = error.message || '알 수 없는 오류가 발생했습니다.'; + showAuthToast('회원가입 오류', errorMessage, 'destructive'); + + return { error: { message: errorMessage }, user: null }; } }; diff --git a/src/contexts/budget/budgetUtils.ts b/src/contexts/budget/budgetUtils.ts index 012f1c3..e40fbb3 100644 --- a/src/contexts/budget/budgetUtils.ts +++ b/src/contexts/budget/budgetUtils.ts @@ -53,78 +53,51 @@ export const calculateCategorySpending = ( })); }; -// 예산 데이터 업데이트 계산 +// 예산 데이터 업데이트 계산 - 완전히 수정된 함수 export const calculateUpdatedBudgetData = ( prevBudgetData: BudgetData, type: BudgetPeriod, amount: number ): BudgetData => { - if (type === 'monthly') { - const dailyAmount = Math.round(amount / 30); - const weeklyAmount = Math.round(amount / 4.3); - - return { - daily: { - targetAmount: dailyAmount, - spentAmount: prevBudgetData.daily.spentAmount, - remainingAmount: Math.max(0, dailyAmount - prevBudgetData.daily.spentAmount) - }, - weekly: { - targetAmount: weeklyAmount, - spentAmount: prevBudgetData.weekly.spentAmount, - remainingAmount: Math.max(0, weeklyAmount - prevBudgetData.weekly.spentAmount) - }, - monthly: { - targetAmount: amount, - spentAmount: prevBudgetData.monthly.spentAmount, - remainingAmount: Math.max(0, amount - prevBudgetData.monthly.spentAmount) - } - }; + console.log(`예산 업데이트 계산: 타입=${type}, 금액=${amount}`); + + // 모든 타입에 대해 월간 예산을 기준으로 계산 + let monthlyAmount = amount; + + // 선택된 탭이 월간이 아닌 경우, 올바른 월간 값으로 변환 + if (type === 'daily') { + // 일일 예산이 입력된 경우: 일일 * 30 = 월간 + monthlyAmount = amount * 30; + console.log(`일일 예산 ${amount}원 → 월간 예산 ${monthlyAmount}원으로 변환`); } else if (type === 'weekly') { - // 주간 예산이 설정되면 월간 예산도 자동 계산 - const monthlyAmount = Math.round(amount * 4.3); - const dailyAmount = Math.round(amount / 7); - - return { - daily: { - targetAmount: dailyAmount, - spentAmount: prevBudgetData.daily.spentAmount, - remainingAmount: Math.max(0, dailyAmount - prevBudgetData.daily.spentAmount) - }, - weekly: { - targetAmount: amount, - spentAmount: prevBudgetData.weekly.spentAmount, - remainingAmount: Math.max(0, amount - prevBudgetData.weekly.spentAmount) - }, - monthly: { - targetAmount: monthlyAmount, - spentAmount: prevBudgetData.monthly.spentAmount, - remainingAmount: Math.max(0, monthlyAmount - prevBudgetData.monthly.spentAmount) - } - }; - } else { - // 일일 예산이 설정되면 주간/월간 예산도 자동 계산 - const weeklyAmount = Math.round(amount * 7); - const monthlyAmount = Math.round(amount * 30); - - return { - daily: { - targetAmount: amount, - spentAmount: prevBudgetData.daily.spentAmount, - remainingAmount: Math.max(0, amount - prevBudgetData.daily.spentAmount) - }, - weekly: { - targetAmount: weeklyAmount, - spentAmount: prevBudgetData.weekly.spentAmount, - remainingAmount: Math.max(0, weeklyAmount - prevBudgetData.weekly.spentAmount) - }, - monthly: { - targetAmount: monthlyAmount, - spentAmount: prevBudgetData.monthly.spentAmount, - remainingAmount: Math.max(0, monthlyAmount - prevBudgetData.monthly.spentAmount) - } - }; + // 주간 예산이 입력된 경우: 주간 * 4.3 = 월간 + monthlyAmount = Math.round(amount * 4.3); + console.log(`주간 예산 ${amount}원 → 월간 예산 ${monthlyAmount}원으로 변환`); } + + // 월간 예산을 기준으로 일일, 주간 예산 계산 + const dailyAmount = Math.round(monthlyAmount / 30); + const weeklyAmount = Math.round(monthlyAmount / 4.3); + + console.log(`최종 예산 계산: 월간=${monthlyAmount}원, 주간=${weeklyAmount}원, 일일=${dailyAmount}원`); + + return { + daily: { + targetAmount: dailyAmount, + spentAmount: prevBudgetData.daily.spentAmount, + remainingAmount: Math.max(0, dailyAmount - prevBudgetData.daily.spentAmount) + }, + weekly: { + targetAmount: weeklyAmount, + spentAmount: prevBudgetData.weekly.spentAmount, + remainingAmount: Math.max(0, weeklyAmount - prevBudgetData.weekly.spentAmount) + }, + monthly: { + targetAmount: monthlyAmount, + spentAmount: prevBudgetData.monthly.spentAmount, + remainingAmount: Math.max(0, monthlyAmount - prevBudgetData.monthly.spentAmount) + } + }; }; // 지출액 계산 (일일, 주간, 월간) diff --git a/src/contexts/budget/hooks/useBudgetDataState.ts b/src/contexts/budget/hooks/useBudgetDataState.ts index 3d4c105..17c0861 100644 --- a/src/contexts/budget/hooks/useBudgetDataState.ts +++ b/src/contexts/budget/hooks/useBudgetDataState.ts @@ -119,18 +119,28 @@ export const useBudgetDataState = (transactions: any[]) => { ) => { try { console.log(`예산 목표 업데이트: ${type}, 금액: ${amount}`); - // 월간 예산 직접 업데이트 (카테고리 예산이 없는 경우) - if (!newCategoryBudgets) { - const updatedBudgetData = calculateUpdatedBudgetData(budgetData, type, amount); - console.log('새 예산 데이터:', updatedBudgetData); - - // 상태 및 스토리지 둘 다 업데이트 - setBudgetData(updatedBudgetData); - saveBudgetDataToStorage(updatedBudgetData); - - // 저장 시간 업데이트 - localStorage.setItem('lastBudgetSaveTime', new Date().toISOString()); + + // 금액이 유효한지 확인 + if (isNaN(amount) || amount <= 0) { + console.error('유효하지 않은 예산 금액:', amount); + toast({ + title: "예산 설정 오류", + description: "유효한 예산 금액을 입력해주세요.", + variant: "destructive" + }); + return; } + + // 예산 업데이트 (카테고리 예산이 있든 없든 무조건 실행) + const updatedBudgetData = calculateUpdatedBudgetData(budgetData, type, amount); + console.log('새 예산 데이터:', updatedBudgetData); + + // 상태 및 스토리지 둘 다 업데이트 + setBudgetData(updatedBudgetData); + saveBudgetDataToStorage(updatedBudgetData); + + // 저장 시간 업데이트 + localStorage.setItem('lastBudgetSaveTime', new Date().toISOString()); } catch (error) { console.error('예산 목표 업데이트 중 오류:', error); toast({ diff --git a/src/contexts/budget/hooks/useTransactionState.ts b/src/contexts/budget/hooks/useTransactionState.ts index 31d9c14..c116382 100644 --- a/src/contexts/budget/hooks/useTransactionState.ts +++ b/src/contexts/budget/hooks/useTransactionState.ts @@ -1,3 +1,4 @@ + import { useState, useEffect, useCallback } from 'react'; import { Transaction } from '../types'; import { @@ -11,6 +12,7 @@ import { toast } from '@/hooks/useToast.wrapper'; // 래퍼 사용 export const useTransactionState = () => { const [transactions, setTransactions] = useState([]); const [lastDeletedId, setLastDeletedId] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); // 초기 트랜잭션 로드 및 이벤트 리스너 설정 useEffect(() => { @@ -72,9 +74,15 @@ export const useTransactionState = () => { }); }, []); - // 트랜잭션 삭제 함수 + // 트랜잭션 삭제 함수 - 안정성 개선 const deleteTransaction = useCallback((transactionId: string) => { - console.log('트랜잭션 삭제:', transactionId); + // 이미 삭제 중이면 중복 삭제 방지 + if (isDeleting) { + console.log('이미 삭제 작업이 진행 중입니다.'); + return; + } + + console.log('트랜잭션 삭제 시작:', transactionId); // 중복 삭제 방지 if (lastDeletedId === transactionId) { @@ -82,24 +90,50 @@ export const useTransactionState = () => { return; } + setIsDeleting(true); setLastDeletedId(transactionId); - setTransactions(prev => { - const updated = prev.filter(transaction => transaction.id !== transactionId); - saveTransactionsToStorage(updated); - - // 토스트는 한 번만 호출 - toast({ - title: "지출이 삭제되었습니다", - description: "지출 항목이 성공적으로 삭제되었습니다.", + try { + setTransactions(prev => { + // 기존 트랜잭션 목록 백업 (문제 발생 시 복원용) + const originalTransactions = [...prev]; + + // 삭제할 항목 필터링 + const updated = prev.filter(transaction => transaction.id !== transactionId); + + // 항목이 실제로 삭제되었는지 확인 + if (updated.length === originalTransactions.length) { + console.log('삭제할 트랜잭션을 찾을 수 없음:', transactionId); + setIsDeleting(false); + return originalTransactions; + } + + // 저장소에 업데이트된 목록 저장 + saveTransactionsToStorage(updated); + + // 토스트 메시지 표시 + toast({ + title: "지출이 삭제되었습니다", + description: "지출 항목이 성공적으로 삭제되었습니다.", + }); + + return updated; }); - - return updated; - }); - - // 5초 후 lastDeletedId 초기화 - setTimeout(() => setLastDeletedId(null), 5000); - }, [lastDeletedId]); + } catch (error) { + console.error('트랜잭션 삭제 중 오류 발생:', error); + toast({ + title: "삭제 실패", + description: "지출 항목 삭제 중 오류가 발생했습니다.", + variant: "destructive" + }); + } finally { + // 삭제 상태 초기화 (1초 후) + setTimeout(() => { + setIsDeleting(false); + setLastDeletedId(null); + }, 1000); + } + }, [lastDeletedId, isDeleting]); // 트랜잭션 초기화 함수 const resetTransactions = useCallback(() => { diff --git a/src/contexts/budget/storage/resetStorage.ts b/src/contexts/budget/storage/resetStorage.ts index dcc9fc2..3cea7e1 100644 --- a/src/contexts/budget/storage/resetStorage.ts +++ b/src/contexts/budget/storage/resetStorage.ts @@ -4,7 +4,7 @@ import { clearAllCategoryBudgets } from './categoryStorage'; import { clearAllBudgetData } from './budgetStorage'; import { DEFAULT_CATEGORY_BUDGETS } from '../budgetUtils'; import { getInitialBudgetData } from '../budgetUtils'; -import { toast } from '@/components/ui/use-toast'; +import { toast } from '@/hooks/useToast.wrapper'; /** * 모든 데이터 초기화 (첫 로그인 상태) @@ -23,18 +23,18 @@ export const resetAllData = (): void => { 'categoryBudgets', 'budgetData', 'budget', - 'monthlyExpenses', // 월간 지출 데이터 - 'categorySpending', // 카테고리별 지출 데이터 - 'expenseAnalytics', // 지출 분석 데이터 - 'expenseHistory', // 지출 이력 - 'budgetHistory', // 예산 이력 - 'analyticsCache', // 분석 캐시 데이터 - 'monthlyTotals', // 월간 합계 데이터 - 'analytics', // 분석 페이지 데이터 - 'dailyBudget', // 일일 예산 - 'weeklyBudget', // 주간 예산 - 'monthlyBudget', // 월간 예산 - 'chartData', // 차트 데이터 + 'monthlyExpenses', + 'categorySpending', + 'expenseAnalytics', + 'expenseHistory', + 'budgetHistory', + 'analyticsCache', + 'monthlyTotals', + 'analytics', + 'dailyBudget', + 'weeklyBudget', + 'monthlyBudget', + 'chartData', ]; try { @@ -42,6 +42,7 @@ export const resetAllData = (): void => { dataKeys.forEach(key => { console.log(`삭제 중: ${key}`); localStorage.removeItem(key); + localStorage.removeItem(`${key}_backup`); // 백업 키도 함께 삭제 }); // 파일별 초기화 함수 호출 @@ -60,15 +61,16 @@ export const resetAllData = (): void => { localStorage.setItem('categoryBudgets_backup', JSON.stringify(DEFAULT_CATEGORY_BUDGETS)); localStorage.setItem('transactions_backup', JSON.stringify([])); - // 이벤트 발생시켜 데이터 로드 트리거 - try { - window.dispatchEvent(new Event('transactionUpdated')); - window.dispatchEvent(new Event('budgetDataUpdated')); - window.dispatchEvent(new Event('categoryBudgetsUpdated')); - window.dispatchEvent(new StorageEvent('storage')); - } catch (e) { - console.error('이벤트 발생 오류:', e); - } + // 이벤트 발생시켜 데이터 로드 트리거 - 이벤트 순서 최적화 + const events = [ + new Event('transactionUpdated'), + new Event('budgetDataUpdated'), + new Event('categoryBudgetsUpdated'), + new StorageEvent('storage') + ]; + + // 모든 이벤트 동시에 발생 + events.forEach(event => window.dispatchEvent(event)); // 중요: 사용자 설정 값 복원 (백업한 값이 있는 경우) if (dontShowWelcomeValue) { diff --git a/src/contexts/budget/storage/transactionStorage.ts b/src/contexts/budget/storage/transactionStorage.ts index 27f1be2..843e8a4 100644 --- a/src/contexts/budget/storage/transactionStorage.ts +++ b/src/contexts/budget/storage/transactionStorage.ts @@ -10,24 +10,41 @@ export const loadTransactionsFromStorage = (): Transaction[] => { // 메인 스토리지에서 먼저 시도 const storedTransactions = localStorage.getItem('transactions'); if (storedTransactions) { - const parsedData = JSON.parse(storedTransactions); - console.log('트랜잭션 로드 완료, 항목 수:', parsedData.length); - return parsedData; + try { + const parsedData = JSON.parse(storedTransactions); + console.log('트랜잭션 로드 완료, 항목 수:', parsedData.length); + + // 트랜잭션 데이터 유효성 검사 + if (Array.isArray(parsedData)) { + return parsedData; + } else { + console.error('트랜잭션 데이터가 배열이 아닙니다:', typeof parsedData); + return []; + } + } catch (e) { + console.error('트랜잭션 데이터 파싱 오류:', e); + } } // 백업에서 시도 const backupTransactions = localStorage.getItem('transactions_backup'); if (backupTransactions) { - const parsedBackup = JSON.parse(backupTransactions); - console.log('백업에서 트랜잭션 복구, 항목 수:', parsedBackup.length); - // 메인 스토리지도 복구 - localStorage.setItem('transactions', backupTransactions); - return parsedBackup; + try { + const parsedBackup = JSON.parse(backupTransactions); + console.log('백업에서 트랜잭션 복구, 항목 수:', parsedBackup.length); + // 메인 스토리지도 복구 + localStorage.setItem('transactions', backupTransactions); + return parsedBackup; + } catch (e) { + console.error('백업 트랜잭션 데이터 파싱 오류:', e); + } } } catch (error) { - console.error('트랜잭션 데이터 파싱 오류:', error); + console.error('트랜잭션 데이터 로드 중 오류:', error); } - // 데이터가 없을 경우 빈 배열 반환 (샘플 데이터 생성하지 않음) + + // 데이터가 없을 경우 빈 배열 반환 + console.log('트랜잭션 데이터 없음, 빈 배열 반환'); return []; }; @@ -36,6 +53,8 @@ export const loadTransactionsFromStorage = (): Transaction[] => { */ export const saveTransactionsToStorage = (transactions: Transaction[]): void => { try { + console.log('트랜잭션 저장 시작, 항목 수:', transactions.length); + // 먼저 문자열로 변환 const dataString = JSON.stringify(transactions); @@ -49,6 +68,9 @@ export const saveTransactionsToStorage = (transactions: Transaction[]): void => // 스토리지 이벤트 수동 트리거 (동일 창에서도 감지하기 위함) try { window.dispatchEvent(new Event('transactionUpdated')); + window.dispatchEvent(new CustomEvent('transactionChanged', { + detail: { type: 'save', count: transactions.length } + })); window.dispatchEvent(new StorageEvent('storage', { key: 'transactions', newValue: dataString @@ -88,6 +110,9 @@ export const clearAllTransactions = (): void => { // 스토리지 이벤트 수동 트리거 window.dispatchEvent(new Event('transactionUpdated')); + window.dispatchEvent(new CustomEvent('transactionChanged', { + detail: { type: 'clear' } + })); window.dispatchEvent(new StorageEvent('storage', { key: 'transactions', newValue: emptyData diff --git a/src/contexts/budget/useBudgetState.ts b/src/contexts/budget/useBudgetState.ts index 99b022c..ff68e98 100644 --- a/src/contexts/budget/useBudgetState.ts +++ b/src/contexts/budget/useBudgetState.ts @@ -1,70 +1,133 @@ -import { useCallback } from 'react'; -import { BudgetPeriod } from './types'; -import { useTransactionState } from './hooks/useTransactionState'; -import { useCategoryBudgetState } from './hooks/useCategoryBudgetState'; +import { useState, useEffect, useCallback } from 'react'; +import { BudgetData, BudgetPeriod, Transaction } from './types'; import { useBudgetDataState } from './hooks/useBudgetDataState'; -import { useCategorySpending } from './hooks/useCategorySpending'; -import { useBudgetBackup } from './hooks/useBudgetBackup'; -import { useBudgetReset } from './hooks/useBudgetReset'; -import { useExtendedBudgetUpdate } from './hooks/useExtendedBudgetUpdate'; +import { useCategoryBudgetState } from './hooks/useCategoryBudgetState'; +import { useTransactionState } from './hooks/useTransactionState'; +import { calculateCategorySpending } from './budgetUtils'; +import { toast } from '@/hooks/useToast.wrapper'; +import { loadCategoryBudgetsFromStorage, saveCategoryBudgetsToStorage } from './storage'; +/** + * 예산 상태 관리를 위한 메인 훅 + */ export const useBudgetState = () => { - // 각 상태 관리 훅 사용 - const { - transactions, - addTransaction, - updateTransaction, - deleteTransaction, - resetTransactions + // 트랜잭션 상태 관리 + const { + transactions, + addTransaction, + updateTransaction, + deleteTransaction } = useTransactionState(); - const { - categoryBudgets, - setCategoryBudgets, + // 예산 데이터 상태 관리 + const { + budgetData, + selectedTab, + setSelectedTab, + handleBudgetGoalUpdate, + resetBudgetData + } = useBudgetDataState(transactions); + + // 카테고리 예산 상태 관리 + const { + categoryBudgets, updateCategoryBudgets, resetCategoryBudgets } = useCategoryBudgetState(); - - const { - budgetData, - selectedTab, - setSelectedTab, - handleBudgetGoalUpdate, - resetBudgetData: resetBudgetDataInternal - } = useBudgetDataState(transactions); - - const { getCategorySpending } = useCategorySpending(transactions, categoryBudgets); - - // 자동 백업 사용 - useBudgetBackup(budgetData, categoryBudgets, transactions); - - // 확장된 예산 업데이트 로직 사용 - const { extendedBudgetGoalUpdate } = useExtendedBudgetUpdate( - budgetData, - categoryBudgets, - handleBudgetGoalUpdate, - updateCategoryBudgets - ); - - // 리셋 로직 사용 - const { resetBudgetData } = useBudgetReset( - resetTransactions, - resetCategoryBudgets, - resetBudgetDataInternal - ); + + // 카테고리별 지출 계산 + const getCategorySpending = useCallback(() => { + return calculateCategorySpending(transactions, categoryBudgets); + }, [transactions, categoryBudgets]); + + // 예산 목표 업데이트 함수 (기존 함수 래핑) + const handleBudgetUpdate = useCallback(( + type: BudgetPeriod, + amount: number, + newCategoryBudgets?: Record + ) => { + console.log(`예산 업데이트 시작: ${type}, 금액: ${amount}, 카테고리 예산:`, newCategoryBudgets); + + try { + // 금액이 유효한지 확인 + if (isNaN(amount) || amount <= 0) { + console.error('유효하지 않은 예산 금액:', amount); + toast({ + title: "예산 설정 오류", + description: "유효한 예산 금액을 입력해주세요.", + variant: "destructive" + }); + return; + } + + // 카테고리 예산이 제공된 경우 + if (newCategoryBudgets) { + console.log('카테고리 예산도 함께 업데이트:', newCategoryBudgets); + + // 카테고리 예산의 합계 검증 - 가져온 totalBudget과 카테고리 총합이 같아야 함 + const categoryTotal = Object.values(newCategoryBudgets).reduce((sum, val) => sum + val, 0); + console.log(`카테고리 예산 합계: ${categoryTotal}, 입력 금액: ${amount}`); + + // 금액이 카테고리 합계와 다르면 로그 기록 (허용 오차 ±10) + if (Math.abs(categoryTotal - amount) > 10) { + console.warn('카테고리 예산 합계와 총 예산이 일치하지 않음 - 카테고리 합계를 사용함'); + // 카테고리 합계를 기준으로 예산 설정 + amount = categoryTotal; + } + + // 카테고리 예산 저장 + updateCategoryBudgets(newCategoryBudgets); + saveCategoryBudgetsToStorage(newCategoryBudgets); + console.log('카테고리 예산 저장 완료'); + } + + // 항상 월간 타입으로 예산 업데이트 (BudgetTabContent에서는 항상 월간 예산을 전달) + handleBudgetGoalUpdate('monthly', amount); + console.log('예산 데이터 업데이트 완료'); + + } catch (error) { + console.error('예산 업데이트 오류:', error); + toast({ + title: "예산 업데이트 실패", + description: "예산 설정 중 오류가 발생했습니다.", + variant: "destructive" + }); + } + }, [handleBudgetGoalUpdate, updateCategoryBudgets]); + + // 모든 데이터 초기화 + const resetAllData = useCallback(() => { + resetBudgetData?.(); + resetCategoryBudgets(); + }, [resetBudgetData, resetCategoryBudgets]); + + // 상태 디버깅 (개발 시 유용) + useEffect(() => { + console.log('BudgetState 훅 - 현재 상태:'); + console.log('- 예산 데이터:', budgetData); + console.log('- 카테고리 예산:', categoryBudgets); + console.log('- 트랜잭션 수:', transactions.length); + }, [budgetData, categoryBudgets, transactions.length]); return { + // 데이터 transactions, - categoryBudgets, budgetData, + categoryBudgets, selectedTab, + + // 상태 변경 함수 setSelectedTab, addTransaction, updateTransaction, deleteTransaction, - handleBudgetGoalUpdate: extendedBudgetGoalUpdate, + handleBudgetGoalUpdate: handleBudgetUpdate, // 래핑된 함수 사용 + + // 도우미 함수 getCategorySpending, - resetBudgetData + + // 데이터 초기화 + resetBudgetData: resetAllData }; }; diff --git a/src/hooks/sync/syncResultHandler.ts b/src/hooks/sync/syncResultHandler.ts new file mode 100644 index 0000000..df54c8d --- /dev/null +++ b/src/hooks/sync/syncResultHandler.ts @@ -0,0 +1,41 @@ + +import { toast } from '@/hooks/useToast.wrapper'; +import { SyncResult } from '@/utils/syncUtils'; + +/** + * 동기화 결과 처리 함수 + */ +export const handleSyncResult = (result: SyncResult) => { + if (result.success) { + if (result.downloadSuccess && result.uploadSuccess) { + toast({ + title: "동기화 완료", + description: "모든 데이터가 클라우드에 동기화되었습니다.", + }); + } else if (result.downloadSuccess) { + toast({ + title: "다운로드만 성공", + description: "서버 데이터를 가져왔지만, 업로드에 실패했습니다.", + variant: "destructive" + }); + } else if (result.uploadSuccess) { + toast({ + title: "업로드만 성공", + description: "로컬 데이터를 업로드했지만, 다운로드에 실패했습니다.", + variant: "destructive" + }); + } else if (result.partial) { + toast({ + title: "동기화 일부 완료", + description: "일부 데이터만 동기화되었습니다. 다시 시도해보세요.", + variant: "destructive" + }); + } + } else { + toast({ + title: "일부 동기화 실패", + description: "일부 데이터 동기화 중 문제가 발생했습니다. 다시 시도해주세요.", + variant: "destructive" + }); + } +}; diff --git a/src/hooks/sync/useManualSync.ts b/src/hooks/sync/useManualSync.ts new file mode 100644 index 0000000..e6bc37c --- /dev/null +++ b/src/hooks/sync/useManualSync.ts @@ -0,0 +1,52 @@ + +import { useState } from 'react'; +import { toast } from '@/hooks/useToast.wrapper'; +import { trySyncAllData, SyncResult } from '@/utils/syncUtils'; +import { getLastSyncTime, setLastSyncTime } from '@/utils/syncUtils'; +import { handleSyncResult } from './syncResultHandler'; + +/** + * 수동 동기화 기능을 위한 커스텀 훅 + */ +export const useManualSync = (user: any) => { + const [syncing, setSyncing] = useState(false); + + // 수동 동기화 핸들러 + const handleManualSync = async () => { + if (!user) { + toast({ + title: "로그인 필요", + description: "데이터 동기화를 위해 로그인이 필요합니다.", + variant: "destructive" + }); + return; + } + + await performSync(user.id); + }; + + // 실제 동기화 수행 함수 + const performSync = async (userId: string) => { + if (!userId) return; + + try { + setSyncing(true); + // 안전한 동기화 함수 사용 + const result = await trySyncAllData(userId); + + handleSyncResult(result); + setLastSyncTime(getLastSyncTime()); + } catch (error) { + console.error('동기화 오류:', error); + toast({ + title: "동기화 오류", + description: "동기화 중 문제가 발생했습니다. 다시 시도해주세요.", + variant: "destructive" + }); + } finally { + setSyncing(false); + } + }; + + return { syncing, handleManualSync }; +}; diff --git a/src/hooks/sync/useSyncStatus.ts b/src/hooks/sync/useSyncStatus.ts new file mode 100644 index 0000000..3360f50 --- /dev/null +++ b/src/hooks/sync/useSyncStatus.ts @@ -0,0 +1,31 @@ + +import { useState, useEffect } from 'react'; +import { getLastSyncTime } from '@/utils/syncUtils'; + +/** + * 동기화 상태와 마지막 동기화 시간을 관리하는 커스텀 훅 + */ +export const useSyncStatus = () => { + const [lastSync, setLastSync] = useState(getLastSyncTime()); + + // 마지막 동기화 시간 정기적으로 업데이트 + useEffect(() => { + const intervalId = setInterval(() => { + setLastSync(getLastSyncTime()); + }, 10000); // 10초마다 업데이트 + + return () => clearInterval(intervalId); + }, []); + + // 마지막 동기화 시간 포맷팅 + const formatLastSyncTime = () => { + if (!lastSync) return "아직 동기화된 적 없음"; + + if (lastSync === '부분-다운로드') return "부분 동기화 (다운로드만)"; + + const date = new Date(lastSync); + return `${date.toLocaleDateString()} ${date.toLocaleTimeString()}`; + }; + + return { lastSync, formatLastSyncTime }; +}; diff --git a/src/hooks/sync/useSyncToggle.ts b/src/hooks/sync/useSyncToggle.ts new file mode 100644 index 0000000..287c489 --- /dev/null +++ b/src/hooks/sync/useSyncToggle.ts @@ -0,0 +1,130 @@ + +import { useState, useEffect } from 'react'; +import { useAuth } from '@/contexts/auth'; +import { toast } from '@/hooks/useToast.wrapper'; +import { + isSyncEnabled, + setSyncEnabled +} from '@/utils/syncUtils'; +import { trySyncAllData } from '@/utils/syncUtils'; + +/** + * 동기화 토글 기능을 위한 커스텀 훅 + */ +export const useSyncToggle = () => { + const [enabled, setEnabled] = useState(isSyncEnabled()); + const { user } = useAuth(); + + // 사용자 로그인 상태 변경 감지 + useEffect(() => { + // 사용자 로그인 상태에 따라 동기화 설정 업데이트 + const updateSyncState = () => { + if (!user && isSyncEnabled()) { + // 사용자가 로그아웃했고 동기화가 활성화되어 있으면 비활성화 + setSyncEnabled(false); + setEnabled(false); + console.log('로그아웃으로 인해 동기화 설정이 비활성화되었습니다.'); + } + + // 동기화 상태 업데이트 + setEnabled(isSyncEnabled()); + }; + + // 초기 호출 + updateSyncState(); + + // 인증 상태 변경 이벤트 리스너 + window.addEventListener('auth-state-changed', updateSyncState); + + // 스토리지 변경 이벤트에도 동기화 상태 확인 추가 + const handleStorageChange = (event: StorageEvent) => { + if (event.key === 'syncEnabled' || event.key === null) { + setEnabled(isSyncEnabled()); + console.log('스토리지 변경으로 동기화 상태 업데이트:', isSyncEnabled() ? '활성화' : '비활성화'); + } + }; + + window.addEventListener('storage', handleStorageChange); + + return () => { + window.removeEventListener('auth-state-changed', updateSyncState); + window.removeEventListener('storage', handleStorageChange); + }; + }, [user]); + + // 동기화 토글 핸들러 + const handleSyncToggle = async (checked: boolean) => { + if (!user && checked) { + toast({ + title: "로그인 필요", + description: "데이터 동기화를 위해 로그인이 필요합니다.", + variant: "destructive" + }); + return; + } + + // 현재 로컬 데이터 백업 + const budgetDataBackup = localStorage.getItem('budgetData'); + const categoryBudgetsBackup = localStorage.getItem('categoryBudgets'); + const transactionsBackup = localStorage.getItem('transactions'); + + console.log('동기화 설정 변경 전 로컬 데이터 백업:', { + budgetData: budgetDataBackup ? '있음' : '없음', + categoryBudgets: categoryBudgetsBackup ? '있음' : '없음', + transactions: transactionsBackup ? '있음' : '없음' + }); + + setEnabled(checked); + setSyncEnabled(checked); + + // 이벤트 트리거 + window.dispatchEvent(new Event('auth-state-changed')); + + if (checked && user) { + try { + // 동기화 활성화 시 즉시 동기화 실행 + await performSync(user.id); + } catch (error) { + console.error('동기화 중 오류, 로컬 데이터 복원 시도:', error); + + // 오류 발생 시 백업 데이터 복원 + if (budgetDataBackup) { + localStorage.setItem('budgetData', budgetDataBackup); + } + if (categoryBudgetsBackup) { + localStorage.setItem('categoryBudgets', categoryBudgetsBackup); + } + if (transactionsBackup) { + localStorage.setItem('transactions', transactionsBackup); + } + + // 이벤트 발생시켜 UI 업데이트 + window.dispatchEvent(new Event('budgetDataUpdated')); + window.dispatchEvent(new Event('categoryBudgetsUpdated')); + window.dispatchEvent(new Event('transactionUpdated')); + + toast({ + title: "동기화 오류", + description: "동기화 중 문제가 발생하여 로컬 데이터가 복원되었습니다.", + variant: "destructive" + }); + } + } + }; + + return { enabled, setEnabled, handleSyncToggle }; +}; + +// 실제 동기화 수행 함수 +const performSync = async (userId: string) => { + if (!userId) return; + + try { + // 안전한 동기화 함수 사용 + const result = await trySyncAllData(userId); + return result; + } catch (error) { + console.error('동기화 오류:', error); + throw error; + } +}; diff --git a/src/hooks/toast/constants.ts b/src/hooks/toast/constants.ts index 9d0e167..74c8129 100644 --- a/src/hooks/toast/constants.ts +++ b/src/hooks/toast/constants.ts @@ -1,3 +1,3 @@ export const TOAST_LIMIT = 5 // 최대 5개로 제한 -export const TOAST_REMOVE_DELAY = 3000 // 3초 후 DOM에서 제거 (5초에서 3초로 변경) +export const TOAST_REMOVE_DELAY = 3000 // 3초 후 DOM에서 제거 diff --git a/src/hooks/transactions/dateUtils.ts b/src/hooks/transactions/dateUtils.ts index 97b48e5..a249753 100644 --- a/src/hooks/transactions/dateUtils.ts +++ b/src/hooks/transactions/dateUtils.ts @@ -1,23 +1,47 @@ -// 월 이름 상수와 날짜 관련 유틸리티 함수 +/** + * 한글 월 이름 배열 + */ +export const MONTHS_KR = [ + '1월', '2월', '3월', '4월', '5월', '6월', + '7월', '8월', '9월', '10월', '11월', '12월' +]; -// 월 이름 상수 -export const MONTHS_KR = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월']; - -// 현재 월 가져오기 -export const getCurrentMonth = () => { - const today = new Date(); - return MONTHS_KR[today.getMonth()]; +/** + * 현재 월 가져오기 + */ +export const getCurrentMonth = (): string => { + const now = new Date(); + const month = now.getMonth(); // 0-indexed + return `${MONTHS_KR[month]}`; }; -// 이전 월 가져오기 -export const getPrevMonth = (currentMonth: string) => { - const index = MONTHS_KR.indexOf(currentMonth); - return index > 0 ? MONTHS_KR[index - 1] : MONTHS_KR[11]; +/** + * 이전 월 가져오기 + */ +export const getPrevMonth = (currentMonth: string): string => { + const currentMonthIdx = MONTHS_KR.findIndex(m => m === currentMonth); + + if (currentMonthIdx === 0) { + // 1월인 경우 12월로 변경 + return `${MONTHS_KR[11]}`; + } else { + const prevMonthIdx = currentMonthIdx - 1; + return `${MONTHS_KR[prevMonthIdx]}`; + } }; -// 다음 월 가져오기 -export const getNextMonth = (currentMonth: string) => { - const index = MONTHS_KR.indexOf(currentMonth); - return index < 11 ? MONTHS_KR[index + 1] : MONTHS_KR[0]; +/** + * 다음 월 가져오기 + */ +export const getNextMonth = (currentMonth: string): string => { + const currentMonthIdx = MONTHS_KR.findIndex(m => m === currentMonth); + + if (currentMonthIdx === 11) { + // 12월인 경우 1월로 변경 + return `${MONTHS_KR[0]}`; + } else { + const nextMonthIdx = currentMonthIdx + 1; + return `${MONTHS_KR[nextMonthIdx]}`; + } }; diff --git a/src/hooks/transactions/deleteTransaction.ts b/src/hooks/transactions/deleteTransaction.ts new file mode 100644 index 0000000..e5652f3 --- /dev/null +++ b/src/hooks/transactions/deleteTransaction.ts @@ -0,0 +1,105 @@ + +import { useCallback, useRef, useEffect } from 'react'; +import { Transaction } from '@/components/TransactionCard'; +import { useAuth } from '@/contexts/auth/AuthProvider'; +import { useDeleteTransactionCore } from './transactionOperations/deleteTransactionCore'; +import { toast } from '@/hooks/useToast.wrapper'; + +/** + * 트랜잭션 삭제 기능 - 완전히 새롭게 작성된 최적화 버전 + */ +export const useDeleteTransaction = ( + transactions: Transaction[], + setTransactions: React.Dispatch> +) => { + // 삭제 중인 트랜잭션 추적 + const pendingDeletionRef = useRef>(new Set()); + const { user } = useAuth(); + + // 삭제 요청 타임스탬프 (중복 방지) + const lastDeleteTimeRef = useRef>({}); + + // 삭제 핵심 함수 + const deleteTransactionCore = useDeleteTransactionCore( + transactions, + setTransactions, + user, + pendingDeletionRef + ); + + // 삭제 함수 (안정성 최적화) + const deleteTransaction = useCallback((id: string): Promise => { + return new Promise((resolve) => { + try { + const now = Date.now(); + + // 중복 요청 방지 (100ms 내 동일 ID) + if (lastDeleteTimeRef.current[id] && (now - lastDeleteTimeRef.current[id] < 100)) { + console.warn('중복 삭제 요청 무시:', id); + resolve(true); + return; + } + + // 타임스탬프 업데이트 + lastDeleteTimeRef.current[id] = now; + + // 이미 삭제 중인지 확인 + if (pendingDeletionRef.current.has(id)) { + console.warn('이미 삭제 중인 트랜잭션:', id); + resolve(true); + return; + } + + // 안전장치: 최대 1초 타임아웃 + const timeoutId = setTimeout(() => { + console.warn('삭제 전체 타임아웃 - 강제 종료'); + + // pending 상태 정리 + pendingDeletionRef.current.delete(id); + + // 타임아웃 처리 + resolve(true); + }, 1000); + + // 실제 삭제 실행 + deleteTransactionCore(id) + .then(result => { + clearTimeout(timeoutId); + resolve(result); + }) + .catch(error => { + console.error('삭제 작업 실패:', error); + clearTimeout(timeoutId); + resolve(true); // UI 응답성 유지 + }); + } catch (error) { + console.error('삭제 함수 오류:', error); + + // 항상 pending 상태 제거 + if (pendingDeletionRef.current.has(id)) { + pendingDeletionRef.current.delete(id); + } + + // 오류 알림 + toast({ + title: "오류 발생", + description: "처리 중 문제가 발생했습니다.", + variant: "destructive", + duration: 1500 + }); + + resolve(false); + } + }); + }, [deleteTransactionCore]); + + // 컴포넌트 언마운트 시 모든 상태 정리 + useEffect(() => { + return () => { + pendingDeletionRef.current.clear(); + console.log('삭제 상태 정리 완료'); + }; + }, []); + + return deleteTransaction; +}; diff --git a/src/hooks/transactions/filterOperations/index.ts b/src/hooks/transactions/filterOperations/index.ts new file mode 100644 index 0000000..2f09d8c --- /dev/null +++ b/src/hooks/transactions/filterOperations/index.ts @@ -0,0 +1,47 @@ + +import { useCallback } from 'react'; +import { FilteringProps, FilteringReturn } from './types'; +import { useMonthSelection } from './useMonthSelection'; +import { useFilterApplication } from './useFilterApplication'; +import { useTotalCalculation } from './useTotalCalculation'; + +/** + * 트랜잭션 필터링 관련 기능을 통합한 훅 + */ +export const useTransactionsFiltering = ({ + transactions, + selectedMonth, + setSelectedMonth, + searchQuery, + setFilteredTransactions +}: FilteringProps): FilteringReturn => { + // 월 선택 관련 기능 + const { handlePrevMonth, handleNextMonth } = useMonthSelection({ + selectedMonth, + setSelectedMonth + }); + + // 필터 적용 관련 기능 + const { filterTransactions } = useFilterApplication({ + transactions, + selectedMonth, + searchQuery, + setFilteredTransactions + }); + + // 총 지출 계산 관련 기능 + const { getTotalExpenses } = useTotalCalculation(); + + // 강제 필터링 실행 함수 (외부에서 호출 가능) + const forceRefresh = useCallback(() => { + console.log('필터 강제 새로고침'); + filterTransactions(); + }, [filterTransactions]); + + return { + handlePrevMonth, + handleNextMonth, + getTotalExpenses, + forceRefresh + }; +}; diff --git a/src/hooks/transactions/filterOperations/types.ts b/src/hooks/transactions/filterOperations/types.ts new file mode 100644 index 0000000..da24388 --- /dev/null +++ b/src/hooks/transactions/filterOperations/types.ts @@ -0,0 +1,17 @@ + +import { Transaction } from '@/components/TransactionCard'; + +export interface FilteringProps { + transactions: Transaction[]; + selectedMonth: string; + setSelectedMonth: (month: string) => void; + searchQuery: string; + setFilteredTransactions: (transactions: Transaction[]) => void; +} + +export interface FilteringReturn { + handlePrevMonth: () => void; + handleNextMonth: () => void; + getTotalExpenses: (filteredTransactions: Transaction[]) => number; + forceRefresh: () => void; +} diff --git a/src/hooks/transactions/filterOperations/useFilterApplication.ts b/src/hooks/transactions/filterOperations/useFilterApplication.ts new file mode 100644 index 0000000..d214967 --- /dev/null +++ b/src/hooks/transactions/filterOperations/useFilterApplication.ts @@ -0,0 +1,97 @@ + +import { useCallback, useEffect } from 'react'; +import { Transaction } from '@/components/TransactionCard'; +import { FilteringProps } from './types'; +import { MONTHS_KR } from '../dateUtils'; + +/** + * 거래 필터링 로직 + * 선택된 월과 검색어를 기준으로 거래를 필터링합니다. + */ +export const useFilterApplication = ({ + transactions, + selectedMonth, + searchQuery, + setFilteredTransactions +}: Pick) => { + + // 거래 필터링 함수 + const filterTransactions = useCallback(() => { + try { + console.log('필터링 시작, 전체 트랜잭션:', transactions.length); + console.log('선택된 월:', selectedMonth); + + // 선택된 월 정보 파싱 + const selectedMonthName = selectedMonth; + const monthNumber = MONTHS_KR.findIndex(month => month === selectedMonthName) + 1; + + // 월별 필터링 + let filtered = transactions.filter(transaction => { + if (!transaction.date) return false; + + console.log(`트랜잭션 날짜 확인: "${transaction.date}", 타입: ${typeof transaction.date}`); + + // 다양한 날짜 형식 처리 + if (transaction.date.includes(selectedMonthName)) { + return true; // 선택된 월 이름이 포함된 경우 + } + + if (transaction.date.includes('오늘')) { + // 오늘 날짜가 해당 월인지 확인 + const today = new Date(); + const currentMonth = today.getMonth() + 1; // 0부터 시작하므로 +1 + return currentMonth === monthNumber; + } + + // 다른 형식의 날짜도 시도 + try { + // ISO 형식이 아닌 경우 처리 + if (transaction.date.includes('년') || transaction.date.includes('월')) { + return transaction.date.includes(selectedMonthName); + } + + // 표준 날짜 문자열 처리 시도 + const date = new Date(transaction.date); + if (!isNaN(date.getTime())) { + const transactionMonth = date.getMonth() + 1; + return transactionMonth === monthNumber; + } + } catch (e) { + console.error('날짜 파싱 오류:', e); + } + + // 기본적으로 모든 트랜잭션 포함 + return true; + }); + + console.log(`월별 필터링 결과: ${filtered.length} 트랜잭션`); + + // 검색어에 따른 필터링 + if (searchQuery.trim()) { + const searchLower = searchQuery.toLowerCase(); + filtered = filtered.filter(transaction => + transaction.title.toLowerCase().includes(searchLower) || + transaction.category.toLowerCase().includes(searchLower) || + transaction.amount.toString().includes(searchQuery) + ); + console.log(`검색어 필터링 결과: ${filtered.length} 트랜잭션`); + } + + // 결과 설정 + setFilteredTransactions(filtered); + console.log('최종 필터링 결과:', filtered); + } catch (error) { + console.error('거래 필터링 중 오류:', error); + setFilteredTransactions([]); + } + }, [transactions, selectedMonth, searchQuery, setFilteredTransactions]); + + // 필터링 트리거 + useEffect(() => { + filterTransactions(); + }, [transactions, selectedMonth, searchQuery, filterTransactions]); + + return { + filterTransactions + }; +}; diff --git a/src/hooks/transactions/filterOperations/useMonthSelection.ts b/src/hooks/transactions/filterOperations/useMonthSelection.ts new file mode 100644 index 0000000..51046c7 --- /dev/null +++ b/src/hooks/transactions/filterOperations/useMonthSelection.ts @@ -0,0 +1,34 @@ + +import { useCallback } from 'react'; +import { getPrevMonth, getNextMonth } from '../dateUtils'; + +/** + * 월 선택 관련 훅 + * 이전/다음 월 이동 기능을 제공합니다. + */ +export const useMonthSelection = ({ + selectedMonth, + setSelectedMonth +}: { + selectedMonth: string; + setSelectedMonth: (month: string) => void; +}) => { + // 이전 월로 이동 + const handlePrevMonth = useCallback(() => { + const prevMonth = getPrevMonth(selectedMonth); + console.log(`월 변경: ${selectedMonth} -> ${prevMonth}`); + setSelectedMonth(prevMonth); + }, [selectedMonth, setSelectedMonth]); + + // 다음 월로 이동 + const handleNextMonth = useCallback(() => { + const nextMonth = getNextMonth(selectedMonth); + console.log(`월 변경: ${selectedMonth} -> ${nextMonth}`); + setSelectedMonth(nextMonth); + }, [selectedMonth, setSelectedMonth]); + + return { + handlePrevMonth, + handleNextMonth + }; +}; diff --git a/src/hooks/transactions/filterOperations/useTotalCalculation.ts b/src/hooks/transactions/filterOperations/useTotalCalculation.ts new file mode 100644 index 0000000..babbc12 --- /dev/null +++ b/src/hooks/transactions/filterOperations/useTotalCalculation.ts @@ -0,0 +1,18 @@ + +import { Transaction } from '@/components/TransactionCard'; +import { calculateTotalExpenses } from '../filterUtils'; + +/** + * 총 지출 계산 관련 훅 + * 필터링된 트랜잭션의 총 지출을 계산합니다. + */ +export const useTotalCalculation = () => { + // 필터링된 트랜잭션의 총 지출 계산 + const getTotalExpenses = (filteredTransactions: Transaction[]): number => { + return calculateTotalExpenses(filteredTransactions); + }; + + return { + getTotalExpenses + }; +}; diff --git a/src/hooks/transactions/supabaseUtils.ts b/src/hooks/transactions/supabaseUtils.ts index 6993cd9..782a9f9 100644 --- a/src/hooks/transactions/supabaseUtils.ts +++ b/src/hooks/transactions/supabaseUtils.ts @@ -2,9 +2,47 @@ import { Transaction } from '@/components/TransactionCard'; import { supabase } from '@/lib/supabase'; import { isSyncEnabled } from '@/utils/syncUtils'; -import { useAuth } from '@/contexts/auth/AuthProvider'; +import { formatISO } from 'date-fns'; -// Supabase와 트랜잭션 동기화 +// ISO 형식으로 날짜 변환 (Supabase 저장용) +const convertDateToISO = (dateStr: string): string => { + try { + // 이미 ISO 형식인 경우 그대로 반환 + if (dateStr.match(/^\d{4}-\d{2}-\d{2}T/)) { + return dateStr; + } + + // "오늘, 시간" 형식 처리 + if (dateStr.includes('오늘')) { + const today = new Date(); + + // 시간 추출 시도 + const timeMatch = dateStr.match(/(\d{1,2}):(\d{2})/); + if (timeMatch) { + const hours = parseInt(timeMatch[1], 10); + const minutes = parseInt(timeMatch[2], 10); + today.setHours(hours, minutes, 0, 0); + } + + return formatISO(today); + } + + // 일반 날짜 문자열은 그대로 Date 객체로 변환 시도 + const date = new Date(dateStr); + if (!isNaN(date.getTime())) { + return formatISO(date); + } + + // 변환 실패 시 현재 시간 반환 + console.warn(`날짜 변환 오류: "${dateStr}"를 ISO 형식으로 변환할 수 없습니다.`); + return formatISO(new Date()); + } catch (error) { + console.error(`날짜 변환 오류: "${dateStr}"`, error); + return formatISO(new Date()); + } +}; + +// Supabase와 트랜잭션 동기화 - Cloud 최적화 버전 export const syncTransactionsWithSupabase = async (user: any, transactions: Transaction[]): Promise => { if (!user || !isSyncEnabled()) return transactions; @@ -51,17 +89,20 @@ export const syncTransactionsWithSupabase = async (user: any, transactions: Tran return transactions; }; -// Supabase에 트랜잭션 업데이트 +// Supabase에 트랜잭션 업데이트 - Cloud 최적화 버전 export const updateTransactionInSupabase = async (user: any, transaction: Transaction): Promise => { if (!user || !isSyncEnabled()) return; try { + // 날짜를 ISO 형식으로 변환 + const isoDate = convertDateToISO(transaction.date); + const { error } = await supabase.from('transactions') .upsert({ user_id: user.id, title: transaction.title, amount: transaction.amount, - date: transaction.date, + date: isoDate, // ISO 형식 사용 category: transaction.category, type: transaction.type, transaction_id: transaction.id @@ -69,13 +110,15 @@ export const updateTransactionInSupabase = async (user: any, transaction: Transa if (error) { console.error('트랜잭션 업데이트 오류:', error); + } else { + console.log('Supabase 트랜잭션 업데이트 성공:', transaction.id); } } catch (error) { console.error('Supabase 업데이트 오류:', error); } }; -// Supabase에서 트랜잭션 삭제 +// Supabase에서 트랜잭션 삭제 - Cloud 최적화 버전 export const deleteTransactionFromSupabase = async (user: any, transactionId: string): Promise => { if (!user || !isSyncEnabled()) return; @@ -86,6 +129,8 @@ export const deleteTransactionFromSupabase = async (user: any, transactionId: st if (error) { console.error('트랜잭션 삭제 오류:', error); + } else { + console.log('Supabase 트랜잭션 삭제 성공:', transactionId); } } catch (error) { console.error('Supabase 삭제 오류:', error); diff --git a/src/hooks/transactions/transactionOperations/deleteOperation/deleteTransactionCore.ts b/src/hooks/transactions/transactionOperations/deleteOperation/deleteTransactionCore.ts new file mode 100644 index 0000000..41ac099 --- /dev/null +++ b/src/hooks/transactions/transactionOperations/deleteOperation/deleteTransactionCore.ts @@ -0,0 +1,114 @@ + +import { useCallback, MutableRefObject } from 'react'; +import { Transaction } from '@/components/TransactionCard'; +import { toast } from '@/hooks/useToast.wrapper'; +import { handleDeleteStorage } from './deleteTransactionStorage'; + +/** + * 트랜잭션 삭제 핵심 기능 - 완전 재구현 버전 + */ +export const useDeleteTransactionCore = ( + transactions: Transaction[], + setTransactions: React.Dispatch>, + user: any, + pendingDeletionRef: MutableRefObject> +) => { + return useCallback(async (id: string): Promise => { + try { + console.log('트랜잭션 삭제 시작 (ID):', id); + + // 중복 삭제 방지 + if (pendingDeletionRef.current.has(id)) { + console.warn('이미 삭제 중인 트랜잭션:', id); + return true; + } + + // 삭제 중인 상태 표시 + pendingDeletionRef.current.add(id); + + // 완전히 분리된 안전장치: 최대 700ms 후 강제로 pendingDeletion 상태 제거 + const timeoutId = setTimeout(() => { + if (pendingDeletionRef.current.has(id)) { + console.warn('안전장치: pendingDeletion 강제 제거 (700ms 타임아웃)'); + pendingDeletionRef.current.delete(id); + } + }, 700); + + // 트랜잭션 찾기 + const transactionToDelete = transactions.find(t => t.id === id); + + // 트랜잭션이 없는 경우 + if (!transactionToDelete) { + clearTimeout(timeoutId); + pendingDeletionRef.current.delete(id); + console.warn('삭제할 트랜잭션이 존재하지 않음:', id); + + toast({ + title: "삭제 실패", + description: "항목을 찾을 수 없습니다.", + variant: "destructive", + duration: 1500 + }); + + return false; + } + + // 1. UI 상태 즉시 업데이트 (가장 중요한 부분) + const updatedTransactions = transactions.filter(t => t.id !== id); + setTransactions(updatedTransactions); + + // 삭제 알림 표시 + toast({ + title: "삭제 완료", + description: "지출 항목이 삭제되었습니다.", + duration: 1500 + }); + + // 2. 스토리지 처리 (타임아웃 보호 적용) + try { + // 스토리지 작업에 타임아웃 적용 + const storagePromise = handleDeleteStorage(updatedTransactions, id, user, pendingDeletionRef); + const timeoutPromise = new Promise((resolve) => { + setTimeout(() => { + console.warn('스토리지 작업 타임아웃 - 강제 종료'); + resolve(true); + }, 500); + }); + + // 둘 중 먼저 완료되는 것 채택 + await Promise.race([storagePromise, timeoutPromise]); + } catch (storageError) { + console.error('스토리지 처리 오류 (무시됨):', storageError); + // 오류가 있어도 계속 진행 (UI는 이미 업데이트됨) + } + + // 안전장치 타임아웃 제거 + clearTimeout(timeoutId); + + // 이벤트 발생 시도 (오류 억제) + try { + window.dispatchEvent(new Event('transactionDeleted')); + } catch (e) { + console.error('이벤트 발생 오류 (무시됨):', e); + } + + console.log('삭제 작업 정상 완료:', id); + return true; + } catch (error) { + console.error('트랜잭션 삭제 전체 오류:', error); + + // 항상 pending 상태 제거 + pendingDeletionRef.current.delete(id); + + // 토스트 알림 + toast({ + title: "삭제 실패", + description: "지출 삭제 처리 중 문제가 발생했습니다.", + duration: 1500, + variant: "destructive" + }); + + return false; + } + }, [transactions, setTransactions, user, pendingDeletionRef]); +}; diff --git a/src/hooks/transactions/transactionOperations/deleteOperation/deleteTransactionStorage.ts b/src/hooks/transactions/transactionOperations/deleteOperation/deleteTransactionStorage.ts new file mode 100644 index 0000000..ba346bf --- /dev/null +++ b/src/hooks/transactions/transactionOperations/deleteOperation/deleteTransactionStorage.ts @@ -0,0 +1,60 @@ + +import { MutableRefObject } from 'react'; +import { Transaction } from '@/components/TransactionCard'; +import { saveTransactionsToStorage } from '../../storageUtils'; +import { deleteTransactionFromServer } from '@/utils/sync/transaction/deleteTransaction'; +import { toast } from '@/hooks/useToast.wrapper'; + +/** + * 스토리지 및 Supabase 삭제 처리 - 안정성 개선 버전 + */ +export const handleDeleteStorage = ( + updatedTransactions: Transaction[], + id: string, + user: any, + pendingDeletionRef: MutableRefObject> +): Promise => { + return new Promise((resolve) => { + try { + // 즉시 로컬 저장소 업데이트 (가장 중요한 부분) + try { + saveTransactionsToStorage(updatedTransactions); + console.log('로컬 스토리지에서 트랜잭션 삭제 완료 (ID: ' + id + ')'); + } catch (storageError) { + console.error('로컬 스토리지 저장 실패:', storageError); + } + + // 삭제 완료 상태로 업데이트 (pending 제거) + pendingDeletionRef.current.delete(id); + + // 로그인된 경우에만 서버 동기화 시도 + if (user && user.id) { + try { + // 비동기 작업 실행 (결과 기다리지 않음) + deleteTransactionFromServer(user.id, id) + .then(() => { + console.log('서버 삭제 완료:', id); + }) + .catch(serverError => { + console.error('서버 삭제 실패 (무시됨):', serverError); + }); + } catch (syncError) { + console.error('서버 동기화 요청 실패 (무시됨):', syncError); + } + } + + // 항상 성공으로 간주 (UI 응답성 우선) + resolve(true); + } catch (error) { + console.error('트랜잭션 삭제 스토리지 전체 오류:', error); + + // 안전하게 pending 상태 제거 + if (pendingDeletionRef.current.has(id)) { + pendingDeletionRef.current.delete(id); + } + + // 심각한 오류 발생해도 UI는 이미 업데이트되었으므로 성공 반환 + resolve(true); + } + }); +}; diff --git a/src/hooks/transactions/transactionOperations/deleteOperation/deleteTransactionUtils.ts b/src/hooks/transactions/transactionOperations/deleteOperation/deleteTransactionUtils.ts new file mode 100644 index 0000000..52a3070 --- /dev/null +++ b/src/hooks/transactions/transactionOperations/deleteOperation/deleteTransactionUtils.ts @@ -0,0 +1,62 @@ + +import { Transaction } from '@/components/TransactionCard'; + +/** + * 날짜 기준 트랜잭션 정렬 함수 + */ +export const sortTransactionsByDate = (transactions: Transaction[]): Transaction[] => { + return transactions.sort((a, b) => { + try { + // 날짜 형식이 다양할 수 있으므로 안전하게 처리 + let dateA = new Date(); + let dateB = new Date(); + + // 타입 안전성 확보 + if (a.date && typeof a.date === 'string') { + // 이미 포맷팅된 날짜 문자열 감지 + if (!a.date.includes('오늘,') && !a.date.includes('년')) { + const testDate = new Date(a.date); + if (!isNaN(testDate.getTime())) { + dateA = testDate; + } + } + } + + if (b.date && typeof b.date === 'string') { + // 이미 포맷팅된 날짜 문자열 감지 + if (!b.date.includes('오늘,') && !b.date.includes('년')) { + const testDate = new Date(b.date); + if (!isNaN(testDate.getTime())) { + dateB = testDate; + } + } + } + + return dateB.getTime() - dateA.getTime(); + } catch (error) { + console.error('날짜 정렬 오류:', error); + return 0; // 오류 발생 시 순서 유지 + } + }); +}; + +/** + * 트랜잭션 목록에서 삭제 대상 트랜잭션 찾기 + */ +export const findTransactionById = (transactions: Transaction[], id: string): Transaction | undefined => { + return transactions.find(transaction => transaction.id === id); +}; + +/** + * 중복 트랜잭션 검사 + */ +export const hasDuplicateTransaction = (transactions: Transaction[], id: string): boolean => { + return transactions.some(transaction => transaction.id === id); +}; + +/** + * 트랜잭션 목록에서 특정 ID 제외하기 + */ +export const removeTransactionById = (transactions: Transaction[], id: string): Transaction[] => { + return transactions.filter(transaction => transaction.id !== id); +}; diff --git a/src/hooks/transactions/transactionOperations/deleteTransaction.ts b/src/hooks/transactions/transactionOperations/deleteTransaction.ts new file mode 100644 index 0000000..0420b4a --- /dev/null +++ b/src/hooks/transactions/transactionOperations/deleteTransaction.ts @@ -0,0 +1,105 @@ + +import { useCallback, useRef, useEffect } from 'react'; +import { Transaction } from '@/components/TransactionCard'; +import { useAuth } from '@/contexts/auth/AuthProvider'; +import { useDeleteTransactionCore } from './deleteOperation/deleteTransactionCore'; +import { toast } from '@/hooks/useToast.wrapper'; + +/** + * 트랜잭션 삭제 기능 - 완전히 새롭게 작성된 안정화 버전 + */ +export const useDeleteTransaction = ( + transactions: Transaction[], + setTransactions: React.Dispatch> +) => { + // 삭제 중인 트랜잭션 추적 + const pendingDeletionRef = useRef>(new Set()); + const { user } = useAuth(); + + // 삭제 요청 타임스탬프 추적 (중복 방지) + const lastDeleteTimeRef = useRef>({}); + + // 삭제 핵심 로직 + const deleteTransactionCore = useDeleteTransactionCore( + transactions, + setTransactions, + user, + pendingDeletionRef + ); + + // 삭제 함수 - 완전 재구현 + const deleteTransaction = useCallback((id: string): Promise => { + return new Promise((resolve) => { + try { + const now = Date.now(); + + // 중복 요청 방지 (100ms 내 동일 ID) + if (lastDeleteTimeRef.current[id] && (now - lastDeleteTimeRef.current[id] < 100)) { + console.warn('중복 삭제 요청 차단:', id); + resolve(true); + return; + } + + // 타임스탬프 업데이트 + lastDeleteTimeRef.current[id] = now; + + // 이미 삭제 중인지 확인 + if (pendingDeletionRef.current.has(id)) { + console.warn('이미 삭제 중인 트랜잭션:', id); + resolve(true); + return; + } + + // 최대 타임아웃 설정 (1초) + const timeoutId = setTimeout(() => { + console.warn('삭제 전체 타임아웃 - 강제 종료'); + + // 대기 상태 정리 + pendingDeletionRef.current.delete(id); + + // 성공으로 간주 (UI는 이미 업데이트됨) + resolve(true); + }, 1000); + + // 실제 삭제 작업 실행 + deleteTransactionCore(id) + .then(result => { + clearTimeout(timeoutId); + resolve(result); + }) + .catch(error => { + console.error('삭제 작업 실패:', error); + clearTimeout(timeoutId); + resolve(true); // UI 응답성 위해 성공 간주 + }); + } catch (error) { + console.error('삭제 함수 오류:', error); + + // 항상 pending 상태 제거 보장 + if (pendingDeletionRef.current.has(id)) { + pendingDeletionRef.current.delete(id); + } + + // 오류 알림 + toast({ + title: "오류 발생", + description: "처리 중 문제가 발생했습니다.", + variant: "destructive", + duration: 1500 + }); + + resolve(false); + } + }); + }, [deleteTransactionCore]); + + // 컴포넌트 언마운트 시 모든 상태 정리 + useEffect(() => { + return () => { + pendingDeletionRef.current.clear(); + console.log('삭제 상태 정리 완료'); + }; + }, []); + + return deleteTransaction; +}; diff --git a/src/hooks/transactions/transactionOperations/deleteTransactionCore.ts b/src/hooks/transactions/transactionOperations/deleteTransactionCore.ts new file mode 100644 index 0000000..9186259 --- /dev/null +++ b/src/hooks/transactions/transactionOperations/deleteTransactionCore.ts @@ -0,0 +1,114 @@ + +import { useCallback, MutableRefObject } from 'react'; +import { Transaction } from '@/components/TransactionCard'; +import { toast } from '@/hooks/useToast.wrapper'; +import { handleDeleteStorage } from './deleteOperation/deleteTransactionStorage'; + +/** + * 트랜잭션 삭제 핵심 기능 - 완전히 재구현된 버전 + */ +export const useDeleteTransactionCore = ( + transactions: Transaction[], + setTransactions: React.Dispatch>, + user: any, + pendingDeletionRef: MutableRefObject> +) => { + return useCallback(async (id: string): Promise => { + try { + console.log('트랜잭션 삭제 시작 (ID):', id); + + // 중복 삭제 방지 + if (pendingDeletionRef.current.has(id)) { + console.warn('이미 삭제 중인 트랜잭션:', id); + return true; + } + + // 삭제 상태 표시 + pendingDeletionRef.current.add(id); + + // 안전장치: 최대 700ms 후 자동으로 pending 상태 제거 + const timeoutId = setTimeout(() => { + if (pendingDeletionRef.current.has(id)) { + console.warn('안전장치: 삭제 타임아웃으로 pending 상태 자동 제거'); + pendingDeletionRef.current.delete(id); + } + }, 700); + + // 트랜잭션 찾기 + const transactionToDelete = transactions.find(t => t.id === id); + + // 트랜잭션이 없으면 오류 반환 + if (!transactionToDelete) { + clearTimeout(timeoutId); + pendingDeletionRef.current.delete(id); + console.warn('삭제할 트랜잭션이 존재하지 않음:', id); + + toast({ + title: "삭제 실패", + description: "항목을 찾을 수 없습니다.", + variant: "destructive", + duration: 1500 + }); + + return false; + } + + // 1. UI 상태 즉시 업데이트 (사용자 경험 최우선) + const updatedTransactions = transactions.filter(t => t.id !== id); + setTransactions(updatedTransactions); + + // 성공 알림 표시 + toast({ + title: "삭제 완료", + description: "지출 항목이 삭제되었습니다.", + duration: 1500 + }); + + // 2. 스토리지 처리 (UI 블로킹 없음) + try { + // 스토리지 작업에 타임아웃 적용 (500ms 내에 완료되지 않으면 중단) + const storagePromise = handleDeleteStorage(updatedTransactions, id, user, pendingDeletionRef); + const timeoutPromise = new Promise((resolve) => { + setTimeout(() => { + console.warn('스토리지 작업 타임아웃 - 강제 완료'); + resolve(true); + }, 500); + }); + + // 빠른 것 우선 처리 + await Promise.race([storagePromise, timeoutPromise]); + } catch (storageError) { + console.error('스토리지 처리 오류 (무시됨):', storageError); + // 오류가 있어도 계속 진행 (UI는 이미 업데이트됨) + } + + // 안전장치 타임아웃 제거 + clearTimeout(timeoutId); + + // 업데이트 이벤트 발생 (오류 무시) + try { + window.dispatchEvent(new Event('transactionDeleted')); + } catch (e) { + console.error('이벤트 발생 오류 (무시됨):', e); + } + + console.log('삭제 작업 정상 완료:', id); + return true; + } catch (error) { + console.error('트랜잭션 삭제 전체 오류:', error); + + // 항상 pending 상태 제거 보장 + pendingDeletionRef.current.delete(id); + + // 오류 알림 + toast({ + title: "삭제 실패", + description: "지출 삭제 처리 중 문제가 발생했습니다.", + duration: 1500, + variant: "destructive" + }); + + return false; + } + }, [transactions, setTransactions, user, pendingDeletionRef]); +}; diff --git a/src/hooks/transactions/transactionOperations/index.ts b/src/hooks/transactions/transactionOperations/index.ts new file mode 100644 index 0000000..1d7b2a7 --- /dev/null +++ b/src/hooks/transactions/transactionOperations/index.ts @@ -0,0 +1,29 @@ + +import { useCallback } from 'react'; +import { Transaction } from '@/components/TransactionCard'; +import { useDeleteTransaction } from '../deleteTransaction'; +import { useUpdateTransaction } from './updateTransaction'; + +/** + * 트랜잭션 작업 통합 훅 + */ +export const useTransactionsOperations = ( + transactions: Transaction[], + setTransactions: React.Dispatch> +) => { + // 삭제 기능 (전용 훅 사용) + const deleteTransaction = useDeleteTransaction(transactions, setTransactions); + + // 업데이트 기능 + const handleUpdateTransaction = useCallback(( + updatedTransaction: Transaction + ) => { + const updateTransaction = useUpdateTransaction(transactions, setTransactions); + updateTransaction(updatedTransaction); + }, [transactions, setTransactions]); + + return { + updateTransaction: handleUpdateTransaction, + deleteTransaction + }; +}; diff --git a/src/hooks/transactions/transactionOperations/types.ts b/src/hooks/transactions/transactionOperations/types.ts new file mode 100644 index 0000000..887b889 --- /dev/null +++ b/src/hooks/transactions/transactionOperations/types.ts @@ -0,0 +1,12 @@ + +import { Transaction } from '@/components/TransactionCard'; + +export interface TransactionOperationProps { + transactions: Transaction[]; + setTransactions: React.Dispatch>; +} + +export interface TransactionOperationReturn { + updateTransaction: (updatedTransaction: Transaction) => void; + deleteTransaction: (id: string) => Promise; +} diff --git a/src/hooks/transactions/transactionOperations/updateTransaction.ts b/src/hooks/transactions/transactionOperations/updateTransaction.ts new file mode 100644 index 0000000..403be73 --- /dev/null +++ b/src/hooks/transactions/transactionOperations/updateTransaction.ts @@ -0,0 +1,55 @@ + +import { useCallback } from 'react'; +import { Transaction } from '@/components/TransactionCard'; +import { useAuth } from '@/contexts/auth/AuthProvider'; +import { toast } from '@/hooks/useToast.wrapper'; +import { saveTransactionsToStorage } from '../storageUtils'; +import { updateTransactionInSupabase } from '../supabaseUtils'; +import { TransactionOperationProps } from './types'; +import { normalizeDate } from '@/utils/sync/transaction/dateUtils'; + +/** + * 트랜잭션 업데이트 기능 + * 로컬 스토리지와 Supabase에 트랜잭션을 업데이트합니다. + */ +export const useUpdateTransaction = ( + transactions: Transaction[], + setTransactions: React.Dispatch> +) => { + const { user } = useAuth(); + + return useCallback((updatedTransaction: Transaction) => { + const updatedTransactions = transactions.map(transaction => + transaction.id === updatedTransaction.id ? updatedTransaction : transaction + ); + + // 로컬 스토리지 업데이트 + saveTransactionsToStorage(updatedTransactions); + + // 상태 업데이트 + setTransactions(updatedTransactions); + + // Supabase 업데이트 시도 (날짜 형식 변환 추가) + if (user) { + // ISO 형식으로 날짜 변환 + const transactionWithIsoDate = { + ...updatedTransaction, + dateForSync: normalizeDate(updatedTransaction.date) + }; + + updateTransactionInSupabase(user, transactionWithIsoDate); + } + + // 이벤트 발생 + window.dispatchEvent(new Event('transactionUpdated')); + + // 약간의 지연을 두고 토스트 표시 + setTimeout(() => { + toast({ + title: "지출이 수정되었습니다", + description: `${updatedTransaction.title} 항목이 업데이트되었습니다.`, + duration: 3000 + }); + }, 100); + }, [transactions, setTransactions, user]); +}; diff --git a/src/hooks/transactions/useTransactionsCore.ts b/src/hooks/transactions/useTransactionsCore.ts index c1624dd..2c22487 100644 --- a/src/hooks/transactions/useTransactionsCore.ts +++ b/src/hooks/transactions/useTransactionsCore.ts @@ -44,13 +44,13 @@ export const useTransactionsCore = () => { handlePrevMonth, handleNextMonth, getTotalExpenses - } = useTransactionsFiltering( + } = useTransactionsFiltering({ transactions, selectedMonth, setSelectedMonth, searchQuery, setFilteredTransactions - ); + }); // 트랜잭션 작업 const { @@ -61,11 +61,12 @@ export const useTransactionsCore = () => { setTransactions ); - // 이벤트 리스너 + // 이벤트 리스너 - 삭제 이벤트 포함 useTransactionsEvents(loadTransactions, refreshKey); // 데이터 강제 새로고침 const refreshTransactions = useCallback(() => { + console.log('트랜잭션 강제 새로고침'); setRefreshKey(prev => prev + 1); loadTransactions(); }, [loadTransactions, setRefreshKey]); diff --git a/src/hooks/transactions/useTransactionsEvents.ts b/src/hooks/transactions/useTransactionsEvents.ts index 973eaa8..edcaaf0 100644 --- a/src/hooks/transactions/useTransactionsEvents.ts +++ b/src/hooks/transactions/useTransactionsEvents.ts @@ -2,58 +2,111 @@ import { useEffect } from 'react'; /** - * 트랜잭션 이벤트 관련 훅 - * 각종 이벤트 리스너를 설정합니다. + * 트랜잭션 이벤트 리스너 훅 + * 트랜잭션 업데이트 이벤트를 리스닝합니다. */ export const useTransactionsEvents = ( loadTransactions: () => void, refreshKey: number ) => { - // 이벤트 리스너 설정 useEffect(() => { console.log('useTransactions - 이벤트 리스너 설정'); - // 트랜잭션 업데이트 이벤트 리스너 - const handleTransactionUpdated = () => { - console.log('트랜잭션 업데이트 이벤트 감지됨'); - loadTransactions(); + // 바운싱 방지 변수 + let isProcessing = false; + + // 트랜잭션 업데이트 이벤트 + const handleTransactionUpdate = (e?: any) => { + console.log('트랜잭션 업데이트 이벤트 감지:', e); + + // 처리 중 중복 호출 방지 + if (isProcessing) return; + + isProcessing = true; + setTimeout(() => { + loadTransactions(); + isProcessing = false; + }, 150); }; - // 스토리지 변경 이벤트 리스너 - const handleStorageChange = (e: StorageEvent) => { + // 트랜잭션 삭제 이벤트 + const handleTransactionDelete = () => { + console.log('트랜잭션 삭제 이벤트 감지됨'); + + // 처리 중 중복 호출 방지 + if (isProcessing) return; + + isProcessing = true; + setTimeout(() => { + loadTransactions(); + isProcessing = false; + }, 200); + }; + + // 트랜잭션 변경 이벤트 (통합 이벤트) + const handleTransactionChange = (e: CustomEvent) => { + console.log('트랜잭션 변경 이벤트 감지:', e.detail?.type); + + // 처리 중 중복 호출 방지 + if (isProcessing) return; + + isProcessing = true; + setTimeout(() => { + loadTransactions(); + isProcessing = false; + }, 150); + }; + + // 스토리지 이벤트 + const handleStorageEvent = (e: StorageEvent) => { if (e.key === 'transactions' || e.key === null) { - console.log('로컬 스토리지 변경 감지됨:', e.key); - loadTransactions(); + console.log('스토리지 이벤트 감지:', e.key); + + // 처리 중 중복 호출 방지 + if (isProcessing) return; + + isProcessing = true; + setTimeout(() => { + loadTransactions(); + isProcessing = false; + }, 150); } }; - // 페이지 포커스/가시성 이벤트 리스너 + // 포커스 이벤트 const handleFocus = () => { - console.log('창 포커스 - 트랜잭션 새로고침'); - loadTransactions(); - }; - - const handleVisibilityChange = () => { - if (document.visibilityState === 'visible') { - console.log('페이지 가시성 변경 - 트랜잭션 새로고침'); + console.log('창 포커스: 트랜잭션 새로고침'); + + // 처리 중 중복 호출 방지 + if (isProcessing) return; + + isProcessing = true; + setTimeout(() => { loadTransactions(); - } + isProcessing = false; + }, 200); }; // 이벤트 리스너 등록 - window.addEventListener('transactionUpdated', handleTransactionUpdated); - window.addEventListener('storage', handleStorageChange); + window.addEventListener('transactionUpdated', handleTransactionUpdate); + window.addEventListener('transactionDeleted', handleTransactionDelete); + window.addEventListener('transactionChanged', handleTransactionChange as EventListener); + window.addEventListener('storage', handleStorageEvent); window.addEventListener('focus', handleFocus); - document.addEventListener('visibilitychange', handleVisibilityChange); - // 컴포넌트 마운트시에만 수동으로 트랜잭션 업데이트 이벤트 발생 - window.dispatchEvent(new Event('transactionUpdated')); + // 새로고침 키가 변경되면 데이터 로드 + if (!isProcessing) { + loadTransactions(); + } + // 클린업 함수 return () => { - window.removeEventListener('transactionUpdated', handleTransactionUpdated); - window.removeEventListener('storage', handleStorageChange); + console.log('useTransactions - 이벤트 리스너 제거'); + window.removeEventListener('transactionUpdated', handleTransactionUpdate); + window.removeEventListener('transactionDeleted', handleTransactionDelete); + window.removeEventListener('transactionChanged', handleTransactionChange as EventListener); + window.removeEventListener('storage', handleStorageEvent); window.removeEventListener('focus', handleFocus); - document.removeEventListener('visibilitychange', handleVisibilityChange); }; }, [loadTransactions, refreshKey]); }; diff --git a/src/hooks/transactions/useTransactionsFiltering.ts b/src/hooks/transactions/useTransactionsFiltering.ts index 6e4054c..9fff4e8 100644 --- a/src/hooks/transactions/useTransactionsFiltering.ts +++ b/src/hooks/transactions/useTransactionsFiltering.ts @@ -1,55 +1,5 @@ -import { useEffect } from 'react'; -import { Transaction } from '@/components/TransactionCard'; -import { - filterTransactionsByMonth, - filterTransactionsByQuery, - calculateTotalExpenses -} from './filterUtils'; -import { getPrevMonth, getNextMonth } from './dateUtils'; +import { useTransactionsFiltering } from './filterOperations'; -/** - * 트랜잭션 필터링 관련 훅 - * 월별 및 검색어 필터링 기능을 제공합니다. - */ -export const useTransactionsFiltering = ( - transactions: Transaction[], - selectedMonth: string, - setSelectedMonth: (month: string) => void, - searchQuery: string, - setFilteredTransactions: (transactions: Transaction[]) => void -) => { - // 월 변경 처리 - const handlePrevMonth = () => { - setSelectedMonth(getPrevMonth(selectedMonth)); - }; - - const handleNextMonth = () => { - setSelectedMonth(getNextMonth(selectedMonth)); - }; - - // 필터 적용 - useEffect(() => { - // 1. 월별 필터링 - let filtered = filterTransactionsByMonth(transactions, selectedMonth); - - // 2. 검색어 필터링 - if (searchQuery.trim()) { - filtered = filterTransactionsByQuery(filtered, searchQuery); - } - - console.log('필터링 결과:', filtered.length, '트랜잭션'); - setFilteredTransactions(filtered); - }, [transactions, selectedMonth, searchQuery, setFilteredTransactions]); - - // 필터링된 트랜잭션의 총 지출 계산 - const getTotalExpenses = (filteredTransactions: Transaction[]) => { - return calculateTotalExpenses(filteredTransactions); - }; - - return { - handlePrevMonth, - handleNextMonth, - getTotalExpenses - }; -}; +// 기존 훅을 그대로 내보내기 +export { useTransactionsFiltering }; diff --git a/src/hooks/transactions/useTransactionsLoader.ts b/src/hooks/transactions/useTransactionsLoader.ts index ef2780d..585a1f5 100644 --- a/src/hooks/transactions/useTransactionsLoader.ts +++ b/src/hooks/transactions/useTransactionsLoader.ts @@ -25,9 +25,11 @@ export const useTransactionsLoader = ( const localTransactions = loadTransactionsFromStorage(); setTransactions(localTransactions); - // 예산 가져오기 + // 예산 가져오기 (월간 예산만 설정) const budgetAmount = loadBudgetFromStorage(); setTotalBudget(budgetAmount); + + console.log('로드된 예산 금액:', budgetAmount); } catch (err) { console.error('트랜잭션 로드 중 오류:', err); setError('데이터를 불러오는 중 문제가 발생했습니다.'); diff --git a/src/hooks/transactions/useTransactionsOperations.ts b/src/hooks/transactions/useTransactionsOperations.ts index 10cf0c7..690eaf7 100644 --- a/src/hooks/transactions/useTransactionsOperations.ts +++ b/src/hooks/transactions/useTransactionsOperations.ts @@ -1,251 +1,2 @@ -import { useCallback, useRef } from 'react'; -import { Transaction } from '@/components/TransactionCard'; -import { useAuth } from '@/contexts/auth/AuthProvider'; -import { toast } from '@/hooks/useToast.wrapper'; -import { saveTransactionsToStorage } from './storageUtils'; -import { - updateTransactionInSupabase, - deleteTransactionFromSupabase -} from './supabaseUtils'; - -/** - * 트랜잭션 작업 관련 훅 - * 트랜잭션 업데이트, 삭제 기능을 제공합니다. - */ -export const useTransactionsOperations = ( - transactions: Transaction[], - setTransactions: React.Dispatch> -) => { - // 현재 진행 중인 삭제 작업 추적을 위한 ref - const pendingDeletionRef = useRef>(new Set()); - const { user } = useAuth(); - - // 트랜잭션 업데이트 - const updateTransaction = useCallback((updatedTransaction: Transaction) => { - const updatedTransactions = transactions.map(transaction => - transaction.id === updatedTransaction.id ? updatedTransaction : transaction - ); - - // 로컬 스토리지 업데이트 - saveTransactionsToStorage(updatedTransactions); - - // 상태 업데이트 - setTransactions(updatedTransactions); - - // Supabase 업데이트 시도 - if (user) { - updateTransactionInSupabase(user, updatedTransaction); - } - - // 이벤트 발생 - window.dispatchEvent(new Event('transactionUpdated')); - - // 약간의 지연을 두고 토스트 표시 - setTimeout(() => { - toast({ - title: "지출이 수정되었습니다", - description: `${updatedTransaction.title} 항목이 업데이트되었습니다.`, - duration: 3000 - }); - }, 100); - }, [transactions, setTransactions, user]); - - // 트랜잭션 삭제 - 안정성과 성능 개선 버전 (버그 수정 및 메모리 누수 방지) - const deleteTransaction = useCallback((id: string): Promise => { - // pendingDeletionRef 초기화 확인 - if (!pendingDeletionRef.current) { - pendingDeletionRef.current = new Set(); - } - - // 기존 promise를 변수로 저장해서 참조 가능하게 함 - const promiseObj = new Promise((resolve, reject) => { - // 삭제 작업 취소 플래그 초기화 - let isCanceled = false; - let timeoutId: ReturnType | null = null; - - try { - console.log('트랜잭션 삭제 작업 시작 - ID:', id); - - // 이미 삭제 중인 트랜잭션인지 확인 - if (pendingDeletionRef.current.has(id)) { - console.warn('이미 삭제 중인 트랜잭션입니다:', id); - reject(new Error('이미 삭제 중인 트랜잭션입니다')); - return; - } - - // 삭제할 트랜잭션이 존재하는지 확인 및 데이터 복사 보관 - const transactionToDelete = transactions.find(t => t.id === id); - if (!transactionToDelete) { - console.warn('삭제할 트랜잭션이 존재하지 않음:', id); - reject(new Error('트랜잭션이 존재하지 않습니다')); - return; - } - - // 삭제 중인 상태로 표시 - pendingDeletionRef.current.add(id); - - // 즉시 상태 업데이트 (현재 상태 복사를 통한 안전한 처리) - const originalTransactions = [...transactions]; // 복구를 위한 상태 복사 - const updatedTransactions = transactions.filter(transaction => transaction.id !== id); - - // UI 업데이트 - 동기식 처리 - setTransactions(updatedTransactions); - - // 사용자 인터페이스 응답성 감소 전에 이벤트 발생 처리 - try { - // 상태 업데이트 바로 후 크로스 스레드 통신 방지 - setTimeout(() => { - try { - window.dispatchEvent(new Event('transactionUpdated')); - } catch (innerError) { - console.warn('이벤트 발생 중 비치명적 오류:', innerError); - } - }, 0); - } catch (eventError) { - console.warn('이벤트 디스패치 설정 오류:', eventError); - } - - // UI 스레드 블록하지 않는 너비로 requestAnimationFrame 사용 - requestAnimationFrame(() => { - if (isCanceled) { - console.log('작업이 취소되었습니다.'); - return; - } - - // 백그라운드 작업은 너비로 처리 - timeoutId = setTimeout(() => { - try { - if (isCanceled) { - console.log('백그라운드 작업이 취소되었습니다.'); - return; - } - - // 로컬 스토리지 업데이트 - saveTransactionsToStorage(updatedTransactions); - - // Supabase 업데이트 - if (user) { - deleteTransactionFromSupabase(user, id) - .then(() => { - if (!isCanceled) { - console.log('Supabase 트랜잭션 삭제 성공'); - // 성공 로그만 추가, UI 업데이트는 이미 수행됨 - } - }) - .catch(error => { - console.error('Supabase 삭제 오류:', error); - - // 비동기 작업 실패 시 새로운 상태를 확인하여 상태 복원 로직 실행 - if (!isCanceled) { - // 현재 상태에 해당 트랜잭션이 이미 있는지 확인 - const currentTransactions = [...transactions]; - const exists = currentTransactions.some(t => t.id === id); - - if (!exists) { - console.log('서버 삭제 실패, 상태 복원 시도...'); - // 현재 상태에 없을 경우에만 상태 복원 시도 - setTransactions(prevState => { - // 동일 트랜잭션이 없을 경우에만 추가 - const hasDuplicate = prevState.some(t => t.id === id); - if (hasDuplicate) return prevState; - - // 삭제되었던 트랜잭션 다시 추가 - const newState = [...prevState, transactionToDelete]; - - // 날짜 기준 정렬 - 안전한 경로 - return newState.sort((a, b) => { - try { - // 날짜 형식이 다양할 수 있으므로 안전하게 처리 - let dateA = new Date(); - let dateB = new Date(); - - // 타입 안전성 확보 - if (a.date && typeof a.date === 'string') { - // 이미 포맷팅된 날짜 문자열 감지 - if (!a.date.includes('오늘,') && !a.date.includes('년')) { - const testDate = new Date(a.date); - if (!isNaN(testDate.getTime())) { - dateA = testDate; - } - } - } - - if (b.date && typeof b.date === 'string') { - // 이미 포맷팅된 날짜 문자열 감지 - if (!b.date.includes('오늘,') && !b.date.includes('년')) { - const testDate = new Date(b.date); - if (!isNaN(testDate.getTime())) { - dateB = testDate; - } - } - } - - return dateB.getTime() - dateA.getTime(); - } catch (error) { - console.error('날짜 정렬 오류:', error); - return 0; // 오류 발생 시 순서 유지 - } - }); - }); - } - } - }) - .finally(() => { - if (!isCanceled) { - // 작업 완료 후 보류 중인 삭제 목록에서 제거 - pendingDeletionRef.current?.delete(id); - } - }); - } else { - // 사용자 정보 없을 경우 목록에서 제거 - pendingDeletionRef.current?.delete(id); - } - } catch (storageError) { - console.error('스토리지 작업 중 오류:', storageError); - pendingDeletionRef.current?.delete(id); - } - }, 0); // 흥미로운 사실: setTimeout(fn, 0)은 requestAnimationFrame 이후에 실행되어 UI 업데이트 완료 후 처리됨 - }); - - // 상태 업데이트가 이미 수행되었으므로 즉시 성공 반환 - console.log('트랜잭션 삭제 UI 업데이트 완료'); - resolve(true); - - // 취소 기능을 가진 Promise 객체 생성 - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (promiseObj as any).cancel = () => { - isCanceled = true; - - if (timeoutId !== null) { - clearTimeout(timeoutId); - } - - pendingDeletionRef.current?.delete(id); - console.log('트랜잭션 삭제 작업 취소 완료'); - }; - } catch (error) { - console.error('트랜잭션 삭제 초기화 중 오류:', error); - - // 오류 발생 시 토스트 표시 - toast({ - title: "시스템 오류", - description: "지출 삭제 중 오류가 발생했습니다.", - duration: 2000, - variant: "destructive" - }); - - // 캣치된 모든 오류에서 보류 삭제 표시 제거 - pendingDeletionRef.current?.delete(id); - reject(error); - } - }); - - return promiseObj; - }, [transactions, setTransactions, user]); - - return { - updateTransaction, - deleteTransaction - }; -}; +export { useTransactionsOperations } from './transactionOperations'; diff --git a/src/hooks/useDataReset.ts b/src/hooks/useDataReset.ts index e18b9e0..2fc60df 100644 --- a/src/hooks/useDataReset.ts +++ b/src/hooks/useDataReset.ts @@ -3,8 +3,9 @@ import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useToast } from '@/hooks/useToast.wrapper'; import { resetAllStorageData } from '@/utils/storageUtils'; -import { clearCloudData } from '@/utils/syncUtils'; +import { clearCloudData } from '@/utils/sync/clearCloudData'; import { useAuth } from '@/contexts/auth/AuthProvider'; +import { isSyncEnabled, setSyncEnabled } from '@/utils/sync/syncSettings'; export interface DataResetResult { isCloudResetSuccess: boolean | null; @@ -22,6 +23,10 @@ export const useDataReset = () => { setIsResetting(true); console.log('모든 데이터 초기화 시작'); + // 현재 동기화 설정 저장 + const syncWasEnabled = isSyncEnabled(); + console.log('데이터 초기화 전 동기화 상태:', syncWasEnabled ? '활성화' : '비활성화'); + // 클라우드 데이터 초기화 먼저 시도 (로그인 상태인 경우) let cloudResetSuccess = false; if (user) { @@ -82,23 +87,31 @@ export const useDataReset = () => { } }); + // 중요: 동기화 설정은 초기화 후 항상 비활성화 + setSyncEnabled(false); + console.log('동기화 설정이 비활성화되었습니다.'); + + // 마지막 동기화 시간은 초기화 + localStorage.removeItem('lastSync'); + // 스토리지 이벤트 트리거하여 다른 컴포넌트에 변경 알림 window.dispatchEvent(new Event('transactionUpdated')); window.dispatchEvent(new Event('budgetDataUpdated')); window.dispatchEvent(new Event('categoryBudgetsUpdated')); window.dispatchEvent(new StorageEvent('storage')); + window.dispatchEvent(new Event('auth-state-changed')); // 동기화 상태 변경 이벤트 추가 // 클라우드 초기화 상태에 따라 다른 메시지 표시 if (user) { if (cloudResetSuccess) { toast({ title: "모든 데이터가 초기화되었습니다.", - description: "로컬 및 클라우드의 모든 데이터가 초기화되었습니다.", + description: "로컬 및 클라우드의 모든 데이터가 초기화되었으며, 동기화 설정이 비활성화되었습니다.", }); } else { toast({ title: "로컬 데이터만 초기화됨", - description: "로컬 데이터는 초기화되었지만, 클라우드 데이터 초기화 중 문제가 발생했습니다.", + description: "로컬 데이터는 초기화되었지만, 클라우드 데이터 초기화 중 문제가 발생했습니다. 동기화 설정이 비활성화되었습니다.", variant: "destructive" }); } @@ -111,8 +124,8 @@ export const useDataReset = () => { console.log('모든 데이터 초기화 완료'); - // 초기화 후 설정 페이지로 이동 (타임아웃으로 약간 지연) - setTimeout(() => navigate('/settings'), 500); + // 페이지 리프레시 대신 navigate 사용 (딜레이 제거) + navigate('/settings', { replace: true }); return { isCloudResetSuccess: cloudResetSuccess }; } catch (error) { diff --git a/src/hooks/useSyncSettings.ts b/src/hooks/useSyncSettings.ts index de2147a..34ed383 100644 --- a/src/hooks/useSyncSettings.ts +++ b/src/hooks/useSyncSettings.ts @@ -1,169 +1,18 @@ import { useState, useEffect } from 'react'; import { useAuth } from '@/contexts/auth'; -import { toast } from '@/hooks/useToast.wrapper'; -import { - isSyncEnabled, - setSyncEnabled, - getLastSyncTime, - trySyncAllData, - SyncResult -} from '@/utils/syncUtils'; +import { useSyncToggle } from './sync/useSyncToggle'; +import { useManualSync } from './sync/useManualSync'; +import { useSyncStatus } from './sync/useSyncStatus'; /** * 동기화 설정 관리를 위한 커스텀 훅 */ export const useSyncSettings = () => { - const [enabled, setEnabled] = useState(isSyncEnabled()); - const [syncing, setSyncing] = useState(false); - const [lastSync, setLastSync] = useState(getLastSyncTime()); const { user } = useAuth(); - - // 사용자 로그인 상태 변경 감지 - useEffect(() => { - // 사용자 로그인 상태에 따라 동기화 설정 업데이트 - const updateSyncState = () => { - if (!user && isSyncEnabled()) { - // 사용자가 로그아웃했고 동기화가 활성화되어 있으면 비활성화 - setSyncEnabled(false); - setEnabled(false); - setLastSync(null); - console.log('로그아웃으로 인해 동기화 설정이 비활성화되었습니다.'); - } - - // 동기화 상태 업데이트 - setEnabled(isSyncEnabled()); - setLastSync(getLastSyncTime()); - }; - - // 초기 호출 - updateSyncState(); - - // 인증 상태 변경 이벤트 리스너 - window.addEventListener('auth-state-changed', updateSyncState); - - return () => { - window.removeEventListener('auth-state-changed', updateSyncState); - }; - }, [user]); - - // 마지막 동기화 시간 정기적으로 업데이트 - useEffect(() => { - const intervalId = setInterval(() => { - setLastSync(getLastSyncTime()); - }, 10000); // 10초마다 업데이트 - - return () => clearInterval(intervalId); - }, []); - - // 동기화 토글 핸들러 - const handleSyncToggle = async (checked: boolean) => { - if (!user && checked) { - toast({ - title: "로그인 필요", - description: "데이터 동기화를 위해 로그인이 필요합니다.", - variant: "destructive" - }); - return; - } - - setEnabled(checked); - setSyncEnabled(checked); - - if (checked && user) { - // 동기화 활성화 시 즉시 동기화 실행 - await performSync(); - } - }; - - // 수동 동기화 핸들러 - const handleManualSync = async () => { - if (!user) { - toast({ - title: "로그인 필요", - description: "데이터 동기화를 위해 로그인이 필요합니다.", - variant: "destructive" - }); - return; - } - - await performSync(); - }; - - // 실제 동기화 수행 함수 - const performSync = async () => { - if (!user) return; - - try { - setSyncing(true); - // 안전한 동기화 함수 사용 - const result = await trySyncAllData(user.id); - - handleSyncResult(result); - setLastSync(getLastSyncTime()); - } catch (error) { - console.error('동기화 오류:', error); - toast({ - title: "동기화 오류", - description: "동기화 중 문제가 발생했습니다. 다시 시도해주세요.", - variant: "destructive" - }); - - // 심각한 오류 발생 시 동기화 비활성화 - if (!enabled) { - setEnabled(false); - setSyncEnabled(false); - } - } finally { - setSyncing(false); - } - }; - - // 동기화 결과 처리 함수 - const handleSyncResult = (result: SyncResult) => { - if (result.success) { - if (result.downloadSuccess && result.uploadSuccess) { - toast({ - title: "동기화 완료", - description: "모든 데이터가 클라우드에 동기화되었습니다.", - }); - } else if (result.downloadSuccess) { - toast({ - title: "다운로드만 성공", - description: "서버 데이터를 가져왔지만, 업로드에 실패했습니다.", - variant: "destructive" - }); - } else if (result.uploadSuccess) { - toast({ - title: "업로드만 성공", - description: "로컬 데이터를 업로드했지만, 다운로드에 실패했습니다.", - variant: "destructive" - }); - } else if (result.partial) { - toast({ - title: "동기화 일부 완료", - description: "일부 데이터만 동기화되었습니다. 다시 시도해보세요.", - variant: "destructive" - }); - } - } else { - toast({ - title: "일부 동기화 실패", - description: "일부 데이터 동기화 중 문제가 발생했습니다. 다시 시도해주세요.", - variant: "destructive" - }); - } - }; - - // 마지막 동기화 시간 포맷팅 - const formatLastSyncTime = () => { - if (!lastSync) return "아직 동기화된 적 없음"; - - if (lastSync === '부분-다운로드') return "부분 동기화 (다운로드만)"; - - const date = new Date(lastSync); - return `${date.toLocaleDateString()} ${date.toLocaleTimeString()}`; - }; + const { enabled, setEnabled, handleSyncToggle } = useSyncToggle(); + const { syncing, handleManualSync } = useManualSync(user); + const { lastSync, formatLastSyncTime } = useSyncStatus(); return { enabled, diff --git a/src/lib/supabase/client.ts b/src/lib/supabase/client.ts index feebc28..7da8ac0 100644 --- a/src/lib/supabase/client.ts +++ b/src/lib/supabase/client.ts @@ -10,7 +10,7 @@ let supabaseClient; try { console.log(`Supabase 클라이언트 생성 중: ${supabaseUrl}`); - // Supabase 클라이언트 생성 + // Supabase 클라이언트 생성 - Cloud 환경에 최적화 supabaseClient = createClient(supabaseUrl, supabaseAnonKey, { auth: { autoRefreshToken: true, @@ -18,7 +18,7 @@ try { } }); - console.log('Supabase 클라이언트가 생성되었습니다.'); + console.log('Supabase 클라이언트가 성공적으로 생성되었습니다.'); } catch (error) { console.error('Supabase 클라이언트 생성 오류:', error); diff --git a/src/lib/supabase/config.ts b/src/lib/supabase/config.ts index 0e05a7c..1edcb4a 100644 --- a/src/lib/supabase/config.ts +++ b/src/lib/supabase/config.ts @@ -8,20 +8,20 @@ export const getSupabaseKey = () => { return "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InFuZXJlYnR2d3dmb2JmemRvZnR4Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDIwNTE0MzgsImV4cCI6MjA1NzYyNzQzOH0.Wm7h2DUhoQbeANuEM3wm2tz22ITrVEW8FizyLgIVmv8"; }; -// Supabase 키 유효성 검사 +// Supabase 키 유효성 검사 - Cloud 환경에서는 항상 유효함 export const isValidSupabaseKey = () => { - return true; // Supabase Cloud에서는 항상 유효함 + return true; }; -// CORS 프록시 관련 함수들 +// 다음 함수들은 Cloud 환경에서는 필요 없지만 호환성을 위해 유지 export const isCorsProxyEnabled = () => { - return false; // Supabase Cloud에서는 CORS 프록시가 필요 없음 + return false; }; export const getProxyType = () => { - return 'none'; // Supabase Cloud에서는 프록시가 필요 없음 + return 'none'; }; export const getOriginalSupabaseUrl = () => { - return getSupabaseUrl(); // 원본 URL 반환 (프록시 없음) + return getSupabaseUrl(); }; diff --git a/src/pages/Transactions.tsx b/src/pages/Transactions.tsx index 9887053..afd126f 100644 --- a/src/pages/Transactions.tsx +++ b/src/pages/Transactions.tsx @@ -1,12 +1,13 @@ -import React, { useState, useEffect } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; import NavBar from '@/components/NavBar'; -import TransactionCard from '@/components/TransactionCard'; import AddTransactionButton from '@/components/AddTransactionButton'; -import { Calendar, Search, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'; -import { formatCurrency } from '@/utils/formatters'; -import { useTransactions, MONTHS_KR } from '@/hooks/transactions'; import { useBudget } from '@/contexts/BudgetContext'; +import { useTransactions } from '@/hooks/transactions'; +import TransactionsHeader from '@/components/transactions/TransactionsHeader'; +import TransactionsContent from '@/components/transactions/TransactionsContent'; +import { Transaction } from '@/components/TransactionCard'; +import { toast } from '@/hooks/useToast.wrapper'; const Transactions = () => { const { @@ -17,13 +18,18 @@ const Transactions = () => { setSearchQuery, handlePrevMonth, handleNextMonth, - updateTransaction, - deleteTransaction, + refreshTransactions, totalExpenses, + deleteTransaction } = useTransactions(); const { budgetData } = useBudget(); const [isDataLoaded, setIsDataLoaded] = useState(false); + const [isProcessing, setIsProcessing] = useState(false); + const [deletingId, setDeletingId] = useState(null); + + // 더블 클릭 방지용 래퍼 + const deletionTimestampRef = useRef>({}); // 데이터 로드 상태 관리 useEffect(() => { @@ -32,31 +38,87 @@ const Transactions = () => { } }, [budgetData, isLoading]); - // 트랜잭션을 날짜별로 그룹화 - const groupedTransactions: Record = {}; - - transactions.forEach(transaction => { - const datePart = transaction.date.split(',')[0]; - if (!groupedTransactions[datePart]) { - groupedTransactions[datePart] = []; + // 트랜잭션 삭제 핸들러 - 완전히 개선된 버전 + const handleTransactionDelete = async (id: string): Promise => { + // 삭제 중인지 확인 + if (isProcessing || deletingId) { + console.log('이미 삭제 작업이 진행 중입니다:', deletingId); + return true; } - groupedTransactions[datePart].push(transaction); - }); + + // 더블 클릭 방지 - 최근 2초 이내 동일한 삭제 요청이 있었는지 확인 + const now = Date.now(); + const lastDeletionTime = deletionTimestampRef.current[id] || 0; + if (now - lastDeletionTime < 2000) { // 2초 + console.log('중복 삭제 요청 무시:', id); + return true; + } + + // 타임스탬프 업데이트 + deletionTimestampRef.current[id] = now; + + try { + // 삭제 상태 설정 + setIsProcessing(true); + setDeletingId(id); + + console.log('트랜잭션 삭제 시작 (ID):', id); + + // 안전한 타임아웃 설정 (최대 5초) + const timeoutPromise = new Promise((resolve) => { + setTimeout(() => { + console.warn('Transactions 페이지 삭제 타임아웃 - 강제 완료'); + setIsProcessing(false); + setDeletingId(null); + resolve(true); // UI는 이미 업데이트되었으므로 성공으로 간주 + }, 5000); + }); + + // 삭제 함수 호출 + const deletePromise = deleteTransaction(id); + + // 둘 중 하나가 먼저 완료되면 반환 + const result = await Promise.race([deletePromise, timeoutPromise]); + + console.log('삭제 작업 최종 결과:', result); + return result; + } catch (error) { + console.error('삭제 처리 중 오류:', error); + toast({ + title: "삭제 실패", + description: "지출 삭제 중 오류가 발생했습니다.", + variant: "destructive", + duration: 1500 + }); + return false; + } finally { + // 상태 초기화 (즉시) + setIsProcessing(false); + setDeletingId(null); + + // 새로고침 (약간 지연) + setTimeout(() => { + if (!isLoading) { + refreshTransactions(); + } + }, 500); + } + }; // 페이지 포커스나 가시성 변경 시 데이터 새로고침 useEffect(() => { const handleVisibilityChange = () => { - if (document.visibilityState === 'visible') { + if (document.visibilityState === 'visible' && !isProcessing) { console.log('거래내역 페이지 보임 - 데이터 새로고침'); - // 상태 업데이트 트리거 - setIsDataLoaded(prev => !prev); + refreshTransactions(); } }; const handleFocus = () => { - console.log('거래내역 페이지 포커스 - 데이터 새로고침'); - // 상태 업데이트 트리거 - setIsDataLoaded(prev => !prev); + if (!isProcessing) { + console.log('거래내역 페이지 포커스 - 데이터 새로고침'); + refreshTransactions(); + } }; document.addEventListener('visibilitychange', handleVisibilityChange); @@ -66,117 +128,54 @@ const Transactions = () => { document.removeEventListener('visibilitychange', handleVisibilityChange); window.removeEventListener('focus', handleFocus); }; - }, []); + }, [refreshTransactions, isProcessing]); + + // 트랜잭션을 날짜별로 그룹화 + const groupTransactionsByDate = (transactions: Transaction[]): Record => { + const grouped: Record = {}; + + transactions.forEach(transaction => { + if (!transaction.date) return; + + const datePart = transaction.date.split(',')[0]; + if (!grouped[datePart]) { + grouped[datePart] = []; + } + grouped[datePart].push(transaction); + }); + + return grouped; + }; + + // 로딩이나 처리 중이면 비활성화된 UI 상태 표시 + const isDisabled = isLoading || isProcessing; + const groupedTransactions = groupTransactionsByDate(transactions); return (
- {/* Header */} -
-

지출 내역

- - {/* Search */} -
- - setSearchQuery(e.target.value)} - /> -
- - {/* Month Selector */} -
- - -
- - {selectedMonth} -
- - -
- - {/* Summary */} -
-
-

총 예산

-

- {formatCurrency(budgetData?.monthly?.targetAmount || 0)} -

-
-
-

총 지출

-

- {formatCurrency(totalExpenses)} -

-
-
-
- - {/* Loading State */} - {isLoading && ( -
- - 로딩 중... -
- )} - - {/* Empty State */} - {!isLoading && transactions.length === 0 && ( -
-

- {searchQuery.trim() - ? '검색 결과가 없습니다.' - : `${selectedMonth}에 등록된 지출이 없습니다.`} -

- {searchQuery.trim() && ( - - )} -
- )} - - {/* Transactions By Date */} - {!isLoading && transactions.length > 0 && ( -
- {Object.entries(groupedTransactions).map(([date, transactions]) => ( -
-
-
-

{date}

-
-
- -
- {transactions.map(transaction => ( - - ))} -
-
- ))} -
- )} + + +
diff --git a/src/utils/auth/index.ts b/src/utils/auth/index.ts index f4f84d4..18842d1 100644 --- a/src/utils/auth/index.ts +++ b/src/utils/auth/index.ts @@ -5,6 +5,8 @@ export * from './networkUtils'; export * from './responseUtils'; export * from './validationUtils'; export * from './handleNetworkError'; +export * from './loginUtils'; -// 새로운 네트워크 모듈도 직접 내보냅니다 (선택적) +// 모듈별 직접 접근을 위한 내보내기 export * from './network'; +export * from './login'; diff --git a/src/utils/auth/login/errorHandlers.ts b/src/utils/auth/login/errorHandlers.ts new file mode 100644 index 0000000..011072f --- /dev/null +++ b/src/utils/auth/login/errorHandlers.ts @@ -0,0 +1,40 @@ + +/** + * 로그인 오류 메시지를 처리하는 유틸리티 함수 + */ +export const getLoginErrorMessage = (error: any): string => { + let errorMessage = "로그인에 실패했습니다."; + + // Supabase 오류 메시지 처리 + if (error.message) { + if (error.message.includes("Invalid login credentials")) { + errorMessage = "이메일 또는 비밀번호가 올바르지 않습니다."; + } else if (error.message.includes("Email not confirmed")) { + errorMessage = "이메일 인증이 완료되지 않았습니다. 이메일을 확인해주세요."; + } else if (error.message.includes("JSON")) { + errorMessage = "서버 응답 오류: JSON 파싱 실패. 네트워크 연결을 확인하세요."; + } else if (error.message.includes("fetch") || error.message.includes("네트워크")) { + errorMessage = "네트워크 오류: 서버 연결에 실패했습니다."; + } else if (error.message.includes("404") || error.message.includes("Not Found")) { + errorMessage = "서버 오류: API 경로를 찾을 수 없습니다. 서버 설정을 확인하세요."; + } else { + errorMessage = `오류: ${error.message}`; + } + } + + return errorMessage; +}; + +/** + * CORS 또는 JSON 관련 오류인지 확인합니다. + */ +export const isCorsOrJsonError = (errorMessage: string | null): boolean => { + if (!errorMessage) return false; + + return ( + errorMessage.includes('JSON') || + errorMessage.includes('서버 응답') || + errorMessage.includes('404') || + errorMessage.includes('Not Found') + ); +}; diff --git a/src/utils/auth/login/index.ts b/src/utils/auth/login/index.ts new file mode 100644 index 0000000..b859fc8 --- /dev/null +++ b/src/utils/auth/login/index.ts @@ -0,0 +1,4 @@ + +// 로그인 관련 모든 유틸리티 내보내기 +export * from './errorHandlers'; +export * from './toastHandlers'; diff --git a/src/utils/auth/login/toastHandlers.ts b/src/utils/auth/login/toastHandlers.ts new file mode 100644 index 0000000..e7ac20f --- /dev/null +++ b/src/utils/auth/login/toastHandlers.ts @@ -0,0 +1,24 @@ + +import { toast } from "@/components/ui/use-toast"; + +/** + * 로그인 성공 시 사용자에게 알림을 표시합니다. + */ +export const showLoginSuccessToast = (mode?: string) => { + toast({ + title: "로그인 성공", + description: mode ? `${mode}로 로그인되었습니다.` : "환영합니다! 대시보드로 이동합니다.", + variant: "default" + }); +}; + +/** + * 로그인 오류 시 사용자에게 알림을 표시합니다. + */ +export const showLoginErrorToast = (errorMessage: string) => { + toast({ + title: "로그인 실패", + description: errorMessage, + variant: "destructive" + }); +}; diff --git a/src/utils/auth/loginUtils.ts b/src/utils/auth/loginUtils.ts index fc0f0a4..da96e85 100644 --- a/src/utils/auth/loginUtils.ts +++ b/src/utils/auth/loginUtils.ts @@ -1,68 +1,9 @@ -import { toast } from "@/components/ui/use-toast"; - -/** - * 로그인 오류 메시지를 처리하는 유틸리티 함수 - */ -export const getLoginErrorMessage = (error: any): string => { - let errorMessage = "로그인에 실패했습니다."; - - // Supabase 오류 메시지 처리 - if (error.message) { - if (error.message.includes("Invalid login credentials")) { - errorMessage = "이메일 또는 비밀번호가 올바르지 않습니다."; - } else if (error.message.includes("Email not confirmed")) { - errorMessage = "이메일 인증이 완료되지 않았습니다. 이메일을 확인해주세요."; - } else if (error.message.includes("JSON")) { - errorMessage = "서버 응답 오류: JSON 파싱 실패. 네트워크 연결이나 CORS 설정을 확인하세요."; - } else if (error.message.includes("CORS") || error.message.includes("프록시")) { - errorMessage = "CORS 오류: 프록시 설정을 확인하거나 다른 프록시를 시도해보세요."; - } else if (error.message.includes("fetch") || error.message.includes("네트워크")) { - errorMessage = "네트워크 오류: 서버 연결에 실패했습니다."; - } else if (error.message.includes("404") || error.message.includes("Not Found")) { - errorMessage = "서버 오류: API 경로를 찾을 수 없습니다. 서버 설정을 확인하세요."; - } else { - errorMessage = `오류: ${error.message}`; - } - } - - return errorMessage; -}; - -/** - * 로그인 성공 시 사용자에게 알림을 표시합니다. - */ -export const showLoginSuccessToast = (mode?: string) => { - toast({ - title: "로그인 성공", - description: mode ? `${mode}로 로그인되었습니다.` : "환영합니다! 대시보드로 이동합니다.", - variant: "default" - }); -}; - -/** - * 로그인 오류 시 사용자에게 알림을 표시합니다. - */ -export const showLoginErrorToast = (errorMessage: string) => { - toast({ - title: "로그인 실패", - description: errorMessage, - variant: "destructive" - }); -}; - -/** - * CORS 또는 JSON 관련 오류인지 확인합니다. - */ -export const isCorsOrJsonError = (errorMessage: string | null): boolean => { - if (!errorMessage) return false; - - return ( - errorMessage.includes('JSON') || - errorMessage.includes('CORS') || - errorMessage.includes('프록시') || - errorMessage.includes('서버 응답') || - errorMessage.includes('404') || - errorMessage.includes('Not Found') - ); -}; +// 이 파일은 하위 모듈로 분리된 로그인 유틸리티를 다시 내보냅니다. +// 향후 개발에서는 직접 하위 모듈을 임포트하는 것이 권장됩니다. +export { + getLoginErrorMessage, + isCorsOrJsonError, + showLoginSuccessToast, + showLoginErrorToast +} from './login'; diff --git a/src/utils/auth/network/compatUtils.ts b/src/utils/auth/network/compatUtils.ts new file mode 100644 index 0000000..15d6a2b --- /dev/null +++ b/src/utils/auth/network/compatUtils.ts @@ -0,0 +1,7 @@ + +/** + * 호환성을 위한 더미 함수들 - Cloud 환경에서는 항상 false를 반환 + */ +export const hasCorsIssue = (): boolean => false; +export const handleHttpUrlWithoutProxy = (): boolean => true; +export const logProxyInfo = (): void => {}; diff --git a/src/utils/auth/network/connectionVerifier.ts b/src/utils/auth/network/connectionVerifier.ts new file mode 100644 index 0000000..cbcd604 --- /dev/null +++ b/src/utils/auth/network/connectionVerifier.ts @@ -0,0 +1,75 @@ + +import { getSupabaseUrl } from '@/lib/supabase/config'; + +/** + * 기본 서버 연결 상태 검사 유틸리티 + */ +export const verifyServerConnection = async (): Promise<{ + connected: boolean; + message: string; + statusCode?: number; +}> => { + try { + const start = Date.now(); + + // Supabase URL 가져오기 + const supabaseUrl = getSupabaseUrl(); + + if (!supabaseUrl) { + return { + connected: false, + message: 'Supabase URL이 설정되지 않았습니다.' + }; + } + + // 캐시 방지용 쿼리 파라미터 추가 + const cacheParam = `?_nocache=${Date.now()}`; + + // 서버 연결 상태 확인 + const response = await fetch(`${supabaseUrl}/auth/v1/${cacheParam}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + }, + signal: AbortSignal.timeout(8000) + }); + + const elapsed = Date.now() - start; + + // 200, 401, 404 응답도 서버가 살아있다는 신호로 간주 + if (response.ok || response.status === 401 || response.status === 404) { + return { + connected: true, + message: `서버 연결 성공 (응답 시간: ${elapsed}ms)`, + statusCode: response.status + }; + } else { + return { + connected: false, + message: `서버 응답 오류: ${response.status} ${response.statusText}`, + statusCode: response.status + }; + } + } catch (error: any) { + console.error('서버 연결 확인 중 오류:', error); + + let errorMessage = '알 수 없는 네트워크 오류'; + + if (error.message) { + if (error.message.includes('Failed to fetch')) { + errorMessage = '서버 연결 실패'; + } else if (error.message.includes('NetworkError')) { + errorMessage = '네트워크 연결 실패'; + } else if (error.message.includes('timeout') || error.message.includes('timed out')) { + errorMessage = '서버 응답 시간 초과'; + } else { + errorMessage = error.message; + } + } + + return { + connected: false, + message: errorMessage + }; + } +}; diff --git a/src/utils/auth/network/enhancedVerifier.ts b/src/utils/auth/network/enhancedVerifier.ts new file mode 100644 index 0000000..bb27cb3 --- /dev/null +++ b/src/utils/auth/network/enhancedVerifier.ts @@ -0,0 +1,64 @@ + +import { getSupabaseUrl } from '@/lib/supabase/config'; + +/** + * 강화된 서버 연결 검사 - Supabase Cloud 환경에 최적화 + */ +export const verifySupabaseConnection = async (): Promise<{ + connected: boolean; + message: string; + statusCode?: number; + details?: string; +}> => { + const supabaseUrl = getSupabaseUrl(); + if (!supabaseUrl) { + return { + connected: false, + message: 'Supabase URL이 설정되지 않았습니다' + }; + } + + // 무작위 쿼리 파라미터를 추가하여 캐시 방지 + const cacheParam = `?_nocache=${Date.now()}`; + + // 다양한 경로를 순차적으로 시도 + const paths = [ + '/auth/v1/', + '/', + '/rest/v1/', + '/storage/v1/' + ]; + + for (const path of paths) { + try { + console.log(`경로 시도: ${path}`); + const response = await fetch(`${supabaseUrl}${path}${cacheParam}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + }, + signal: AbortSignal.timeout(8000) + }); + + console.log(`경로 ${path} 응답 상태:`, response.status); + + // 어떤 응답이든 서버가 살아있다는 신호로 간주 + return { + connected: true, + message: `서버 연결 성공 (${path})`, + statusCode: response.status, + details: `${response.status} ${response.statusText}` + }; + } catch (error) { + console.warn(`${path} 경로 연결 실패:`, error); + // 계속 다음 경로 시도 + } + } + + // 모든 경로 시도 실패 + return { + connected: false, + message: '모든 Supabase 경로에 대한 연결 시도 실패', + details: '네트워크 연결 또는 서버 주소를 확인하세요' + }; +}; diff --git a/src/utils/auth/network/index.ts b/src/utils/auth/network/index.ts index 0388d73..7ae0c08 100644 --- a/src/utils/auth/network/index.ts +++ b/src/utils/auth/network/index.ts @@ -1,9 +1,14 @@ -// 네트워크 유틸리티 모듈 +// 모든 네트워크 유틸리티 모듈 내보내기 export { + verifyServerConnection, + verifySupabaseConnection, hasCorsIssue, handleHttpUrlWithoutProxy, - logProxyInfo, - verifyServerConnection, - verifySupabaseConnection + logProxyInfo } from './networkUtils'; + +// 직접 접근을 위한 개별 모듈도 내보내기 +export * from './connectionVerifier'; +export * from './enhancedVerifier'; +export * from './compatUtils'; diff --git a/src/utils/auth/network/networkUtils.ts b/src/utils/auth/network/networkUtils.ts index 1da2ed3..b95bf32 100644 --- a/src/utils/auth/network/networkUtils.ts +++ b/src/utils/auth/network/networkUtils.ts @@ -1,257 +1,14 @@ -import { getSupabaseUrl, isCorsProxyEnabled, getProxyType, getOriginalSupabaseUrl } from '@/lib/supabase/config'; +// 네트워크 유틸리티 모듈을 개별 파일로 분리하여 관리하기 쉽게 구성 +import { verifyServerConnection } from './connectionVerifier'; +import { verifySupabaseConnection } from './enhancedVerifier'; +import { hasCorsIssue, handleHttpUrlWithoutProxy, logProxyInfo } from './compatUtils'; -/** - * CORS 문제 확인 - */ -export const hasCorsIssue = (error: any): boolean => { - if (!error) return false; - - const errorMessage = error.message || ''; - return ( - errorMessage.includes('Failed to fetch') || - errorMessage.includes('CORS') || - errorMessage.includes('Network') || - errorMessage.includes('프록시') - ); -}; - -/** - * HTTP URL이 프록시 없이 사용되고 있는지 확인하고 처리 - */ -export const handleHttpUrlWithoutProxy = (): boolean => { - // HTTP URL을 사용하는데 프록시가 비활성화된 경우 - const originalUrl = getOriginalSupabaseUrl(); - const usingProxy = isCorsProxyEnabled(); - - if (originalUrl.startsWith('http:') && !usingProxy) { - // 자동으로 프록시 활성화 - localStorage.setItem('use_cors_proxy', 'true'); - localStorage.setItem('proxy_type', 'cloudflare'); - return true; - } - return false; -}; - -/** - * 프록시 정보 로깅 - */ -export const logProxyInfo = (): void => { - const usingProxy = isCorsProxyEnabled(); - const proxyType = getProxyType(); - const supabaseUrl = getSupabaseUrl(); - - console.log(`연결 테스트 - CORS 프록시: ${usingProxy ? '사용 중' : '미사용'}, 타입: ${proxyType}, URL: ${supabaseUrl}`); -}; - -/** - * 서버 연결 상태 검사 - */ -export const verifyServerConnection = async (): Promise<{ - connected: boolean; - message: string; - statusCode?: number; -}> => { - try { - const start = Date.now(); - - // Supabase URL 가져오기 (프록시 적용 URL) - const supabaseUrl = getSupabaseUrl(); - - if (!supabaseUrl) { - return { - connected: false, - message: 'Supabase URL이 설정되지 않았습니다. 설정 페이지에서 구성하세요.' - }; - } - - // 프록시 설정 상태 확인 - logProxyInfo(); - - // 단순 헬스 체크 요청 - 무작위 쿼리 파라미터 추가 - const cacheParam = `?_nocache=${Date.now()}`; - - try { - const response = await fetch(`${supabaseUrl}/auth/v1/${cacheParam}`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'apikey': localStorage.getItem('supabase_key') || '' - }, - signal: AbortSignal.timeout(15000) // 타임아웃 시간 증가 - }); - - const elapsed = Date.now() - start; - - // 200, 401, 404 응답도 서버가 살아있다는 신호로 간주 - if (response.ok || response.status === 401 || response.status === 404) { - return { - connected: true, - message: `서버 연결 성공 (응답 시간: ${elapsed}ms)`, - statusCode: response.status - }; - } else { - return { - connected: false, - message: `서버 응답 오류: ${response.status} ${response.statusText}`, - statusCode: response.status - }; - } - } catch (fetchError: any) { - console.error('기본 연결 확인 실패, 상태 확인 시도:', fetchError); - - // HTTP URL을 사용하는데 프록시가 비활성화된 경우 처리 - if (handleHttpUrlWithoutProxy()) { - return { - connected: false, - message: 'HTTP URL에 직접 접근할 수 없어 CORS 프록시를 자동으로 활성화했습니다. 페이지를 새로고침하고 다시 시도하세요.' - }; - } - - try { - // 대체 경로로 상태 확인 - 무작위 쿼리 파라미터 추가 - const altResponse = await fetch(`${supabaseUrl}/${cacheParam}`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json' - }, - signal: AbortSignal.timeout(15000) // 타임아웃 시간 증가 - }); - - // 어떤 응답이라도 오면 서버가 살아있다고 간주 - const elapsed = Date.now() - start; - return { - connected: true, - message: `서버 연결 성공 (기본 경로, 응답 시간: ${elapsed}ms)`, - statusCode: altResponse.status - }; - } catch (altError) { - console.error('기본 경로 확인도 실패:', altError); - throw fetchError; // 원래 에러를 던짐 - } - } - } catch (error: any) { - console.error('서버 연결 확인 중 오류:', error); - - // 프록시 설정 확인 - const usingProxy = isCorsProxyEnabled(); - const proxyType = getProxyType(); - - // 오류 유형에 따른 메시지 설정 - let errorMessage = '알 수 없는 네트워크 오류'; - - if (error.message) { - if (error.message.includes('Failed to fetch')) { - errorMessage = 'CORS 정책 오류 또는 서버 연결 실패'; - } else if (error.message.includes('NetworkError')) { - errorMessage = '네트워크 연결 실패'; - } else if (error.message.includes('TypeError')) { - errorMessage = '네트워크 요청 형식 오류'; - } else if (error.message.includes('timeout') || error.message.includes('timed out')) { - errorMessage = '서버 응답 시간 초과'; - } else if (error.message.includes('aborted')) { - errorMessage = '요청이 중단됨'; - } else { - errorMessage = error.message; - } - } - - // HTTP URL을 사용하는데 프록시가 비활성화된 경우 처리 - if (handleHttpUrlWithoutProxy()) { - errorMessage = 'HTTP URL에 직접 접근할 수 없어 CORS 프록시를 자동으로 활성화했습니다. 페이지를 새로고침하고 다시 시도하세요.'; - } - - // Cloudflare 프록시 추천 메시지 추가 - if (errorMessage.includes('CORS') || errorMessage.includes('fetch') || errorMessage.includes('네트워크')) { - if (!usingProxy) { - console.log('CORS 오류 감지, Cloudflare 프록시 사용 권장'); - errorMessage += '. Cloudflare CORS 프록시 사용을 권장합니다.'; - } else if (proxyType !== 'cloudflare') { - console.log('CORS 오류 감지, Cloudflare 프록시로 변경 권장'); - errorMessage += '. Cloudflare CORS 프록시로 변경을 권장합니다.'; - } - } - - return { - connected: false, - message: errorMessage - }; - } -}; - -/** - * 강화된 서버 연결 검사: 다양한 경로로 시도 - */ -export const verifySupabaseConnection = async (): Promise<{ - connected: boolean; - message: string; - statusCode?: number; - details?: string; -}> => { - const supabaseUrl = getSupabaseUrl(); - if (!supabaseUrl) { - return { - connected: false, - message: 'Supabase URL이 설정되지 않았습니다' - }; - } - - // 프록시 정보 로깅 - const usingProxy = isCorsProxyEnabled(); - const proxyType = getProxyType(); - console.log(`강화된 연결 테스트 - 프록시: ${usingProxy ? '사용 중' : '미사용'}, 타입: ${proxyType}, URL: ${supabaseUrl}`); - - // 무작위 쿼리 파라미터를 추가하여 캐시 방지 - const cacheParam = `?_nocache=${Date.now()}`; - - // 다양한 경로를 순차적으로 시도 - const paths = [ - '/auth/v1/', - '/', - '/rest/v1/', - '/storage/v1/' - ]; - - for (const path of paths) { - try { - console.log(`경로 시도: ${path}`); - const response = await fetch(`${supabaseUrl}${path}${cacheParam}`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'apikey': localStorage.getItem('supabase_key') || '' - }, - signal: AbortSignal.timeout(15000) // 타임아웃 시간 증가 - }); - - console.log(`경로 ${path} 응답 상태:`, response.status); - - // 어떤 응답이든 서버가 살아있다는 신호로 간주 - return { - connected: true, - message: `서버 연결 성공 (${path})`, - statusCode: response.status, - details: `${response.status} ${response.statusText}` - }; - } catch (error) { - console.warn(`${path} 경로 연결 실패:`, error); - // 계속 다음 경로 시도 - } - } - - // HTTP URL을 사용하는데 프록시가 비활성화된 경우 처리 - if (handleHttpUrlWithoutProxy()) { - return { - connected: false, - message: 'HTTP URL에 직접 접근할 수 없어 CORS 프록시를 자동으로 활성화했습니다. 페이지를 새로고침하고 다시 시도하세요.', - details: 'CORS 제한으로 인해 HTTP URL에 직접 접근할 수 없습니다' - }; - } - - // 모든 경로 시도 실패 - return { - connected: false, - message: '모든 Supabase 경로에 대한 연결 시도 실패', - details: '네트워크 연결 또는 서버 주소를 확인하세요' - }; +// 모든 기능 재내보내기 +export { + verifyServerConnection, + verifySupabaseConnection, + hasCorsIssue, + handleHttpUrlWithoutProxy, + logProxyInfo }; diff --git a/src/utils/storageUtils.ts b/src/utils/storageUtils.ts index f8ad441..c8a2949 100644 --- a/src/utils/storageUtils.ts +++ b/src/utils/storageUtils.ts @@ -65,7 +65,7 @@ export const loadBudgetFromStorage = (): number => { return 0; }; -// 모든 데이터 완전히 초기화 +// 모든 데이터 완전히 초기화 - 성능 최적화 export const resetAllStorageData = (): void => { console.log('완전 초기화 시작 - resetAllStorageData'); @@ -80,7 +80,7 @@ export const resetAllStorageData = (): void => { // 모든 Storage 키 목록 (로그인 관련 항목 제외) const keysToRemove = [ - 'transactions', + 'transactions', 'budget', 'monthlyExpenses', 'budgetData', @@ -103,35 +103,34 @@ export const resetAllStorageData = (): void => { 'syncEnabled' ]; - // 키 삭제 + // 키 동시에 삭제 (성능 최적화) keysToRemove.forEach(key => { console.log(`삭제 중: ${key}`); localStorage.removeItem(key); + localStorage.removeItem(`${key}_backup`); // 백업 키도 함께 삭제 }); - // 백업 키도 삭제 - keysToRemove.forEach(key => { - localStorage.removeItem(`${key}_backup`); + // 기본값으로 초기화 - 한번에 처리 + const defaultData = { + transactions: JSON.stringify([]), + budgetData: JSON.stringify({ + daily: {targetAmount: 0, spentAmount: 0, remainingAmount: 0}, + weekly: {targetAmount: 0, spentAmount: 0, remainingAmount: 0}, + monthly: {targetAmount: 0, spentAmount: 0, remainingAmount: 0} + }), + categoryBudgets: JSON.stringify({ + 식비: 0, + 교통비: 0, + 생활비: 0 + }) + }; + + // 모든 기본값 한번에 설정 + Object.entries(defaultData).forEach(([key, value]) => { + localStorage.setItem(key, value); + localStorage.setItem(`${key}_backup`, value); }); - // 기본값으로 초기화 - localStorage.setItem('transactions', JSON.stringify([])); - localStorage.setItem('budgetData', JSON.stringify({ - daily: {targetAmount: 0, spentAmount: 0, remainingAmount: 0}, - weekly: {targetAmount: 0, spentAmount: 0, remainingAmount: 0}, - monthly: {targetAmount: 0, spentAmount: 0, remainingAmount: 0} - })); - localStorage.setItem('categoryBudgets', JSON.stringify({ - 식비: 0, - 교통비: 0, - 생활비: 0 - })); - - // 백업 생성 - localStorage.setItem('transactions_backup', JSON.stringify([])); - localStorage.setItem('budgetData_backup', localStorage.getItem('budgetData') || ''); - localStorage.setItem('categoryBudgets_backup', localStorage.getItem('categoryBudgets') || ''); - // 사용자 설정 값 복원 if (dontShowWelcomeValue) { localStorage.setItem('dontShowWelcome', dontShowWelcomeValue); @@ -154,13 +153,22 @@ export const resetAllStorageData = (): void => { localStorage.setItem('supabase.auth.token', supabase); } - // 이벤트 발생 - window.dispatchEvent(new Event('transactionUpdated')); - window.dispatchEvent(new Event('budgetDataUpdated')); - window.dispatchEvent(new Event('categoryBudgetsUpdated')); - window.dispatchEvent(new StorageEvent('storage')); + // 동기화 설정은 무조건 OFF로 설정 + localStorage.setItem('syncEnabled', 'false'); + console.log('동기화 설정이 OFF로 변경되었습니다'); - console.log('모든 저장소 데이터가 완전히 초기화되었습니다. (로그인 상태는 유지)'); + // 모든 이벤트 한 번에 발생 (성능 최적화) + const events = [ + new Event('transactionUpdated'), + new Event('budgetDataUpdated'), + new Event('categoryBudgetsUpdated'), + new StorageEvent('storage'), + new Event('auth-state-changed') + ]; + + events.forEach(event => window.dispatchEvent(event)); + + console.log('모든 저장소 데이터가 완전히 초기화되었습니다. (동기화 설정이 OFF로 변경됨)'); } catch (error) { console.error('데이터 초기화 중 오류:', error); } diff --git a/src/utils/sync/budget/downloadBudget.ts b/src/utils/sync/budget/downloadBudget.ts index 3c204e2..117106f 100644 --- a/src/utils/sync/budget/downloadBudget.ts +++ b/src/utils/sync/budget/downloadBudget.ts @@ -11,6 +11,23 @@ export const downloadBudgets = async (userId: string): Promise => { try { console.log('서버에서 예산 데이터 다운로드 시작'); + // 현재 로컬 예산 데이터 백업 + const localBudgetData = localStorage.getItem('budgetData'); + const localCategoryBudgets = localStorage.getItem('categoryBudgets'); + + // 서버에 데이터가 없는지 확인 + const { data: budgetExists, error: checkError } = await supabase + .from('budgets') + .select('count') + .eq('user_id', userId) + .single(); + + // 서버에 데이터가 없고 로컬에 데이터가 있으면 다운로드 건너뜀 + if ((budgetExists?.count === 0 || !budgetExists) && localBudgetData) { + console.log('서버에 예산 데이터가 없고 로컬 데이터가 있어 다운로드 건너뜀'); + return; + } + // 예산 데이터 및 카테고리 예산 데이터 가져오기 const [budgetData, categoryData] = await Promise.all([ fetchBudgetData(userId), @@ -19,16 +36,24 @@ export const downloadBudgets = async (userId: string): Promise => { // 예산 데이터 처리 if (budgetData) { - await processBudgetData(budgetData); + await processBudgetData(budgetData, localBudgetData); } else { console.log('서버에서 예산 데이터를 찾을 수 없음'); + // 로컬 데이터가 있으면 유지 + if (localBudgetData) { + console.log('로컬 예산 데이터 유지'); + } } // 카테고리 예산 데이터 처리 if (categoryData && categoryData.length > 0) { - await processCategoryBudgetData(categoryData); + await processCategoryBudgetData(categoryData, localCategoryBudgets); } else { console.log('서버에서 카테고리 예산 데이터를 찾을 수 없음'); + // 로컬 데이터가 있으면 유지 + if (localCategoryBudgets) { + console.log('로컬 카테고리 예산 데이터 유지'); + } } console.log('예산 데이터 다운로드 완료'); @@ -82,11 +107,16 @@ async function fetchCategoryBudgetData(userId: string) { /** * 예산 데이터 처리 및 로컬 저장 */ -async function processBudgetData(budgetData: any) { +async function processBudgetData(budgetData: any, localBudgetDataStr: string | null) { console.log('서버에서 예산 데이터 수신:', budgetData); - // 기존 로컬 데이터 가져오기 - const localBudgetDataStr = localStorage.getItem('budgetData'); + // 서버 예산이 0이고 로컬 예산이 있으면 로컬 데이터 유지 + if (budgetData.total_budget === 0 && localBudgetDataStr) { + console.log('서버 예산이 0이고 로컬 예산이 있어 로컬 데이터 유지'); + return; + } + + // 기존 로컬 데이터 가져오기 let localBudgetData = localBudgetDataStr ? JSON.parse(localBudgetDataStr) : { daily: { targetAmount: 0, spentAmount: 0, remainingAmount: 0 }, weekly: { targetAmount: 0, spentAmount: 0, remainingAmount: 0 }, @@ -94,26 +124,34 @@ async function processBudgetData(budgetData: any) { }; // 서버 데이터로 업데이트 (지출 금액은 유지) + // 수정: 올바른 예산 계산 방식으로 변경 + const monthlyBudget = budgetData.total_budget; + const dailyBudget = Math.round(monthlyBudget / 30); // 월간 예산 / 30일 + const weeklyBudget = Math.round(monthlyBudget / 4.3); // 월간 예산 / 4.3주 + const updatedBudgetData = { daily: { - targetAmount: Math.round(budgetData.total_budget / 30), + targetAmount: dailyBudget, spentAmount: localBudgetData.daily.spentAmount, - remainingAmount: Math.round(budgetData.total_budget / 30) - localBudgetData.daily.spentAmount + remainingAmount: dailyBudget - localBudgetData.daily.spentAmount }, weekly: { - targetAmount: Math.round(budgetData.total_budget / 4.3), + targetAmount: weeklyBudget, spentAmount: localBudgetData.weekly.spentAmount, - remainingAmount: Math.round(budgetData.total_budget / 4.3) - localBudgetData.weekly.spentAmount + remainingAmount: weeklyBudget - localBudgetData.weekly.spentAmount }, monthly: { - targetAmount: budgetData.total_budget, + targetAmount: monthlyBudget, spentAmount: localBudgetData.monthly.spentAmount, - remainingAmount: budgetData.total_budget - localBudgetData.monthly.spentAmount + remainingAmount: monthlyBudget - localBudgetData.monthly.spentAmount } }; + console.log('계산된 예산 데이터:', updatedBudgetData); + // 로컬 스토리지에 저장 localStorage.setItem('budgetData', JSON.stringify(updatedBudgetData)); + localStorage.setItem('budgetData_backup', JSON.stringify(updatedBudgetData)); console.log('예산 데이터 로컬 저장 완료', updatedBudgetData); // 이벤트 발생시켜 UI 업데이트 @@ -123,9 +161,18 @@ async function processBudgetData(budgetData: any) { /** * 카테고리 예산 데이터 처리 및 로컬 저장 */ -async function processCategoryBudgetData(categoryData: any[]) { +async function processCategoryBudgetData(categoryData: any[], localCategoryBudgetsStr: string | null) { console.log(`${categoryData.length}개의 카테고리 예산 수신`); + // 서버 카테고리 예산 합계 계산 + const serverTotal = categoryData.reduce((sum, item) => sum + item.amount, 0); + + // 로컬 카테고리 예산이 있고 서버 데이터가 비어있거나 합계가 0이면 로컬 데이터 유지 + if (localCategoryBudgetsStr && (categoryData.length === 0 || serverTotal === 0)) { + console.log('서버 카테고리 예산이 없거나 0이고 로컬 데이터가 있어 로컬 데이터 유지'); + return; + } + // 카테고리 예산 로컬 형식으로 변환 const localCategoryBudgets = categoryData.reduce((acc, curr) => { acc[curr.category] = curr.amount; @@ -134,6 +181,7 @@ async function processCategoryBudgetData(categoryData: any[]) { // 로컬 스토리지에 저장 localStorage.setItem('categoryBudgets', JSON.stringify(localCategoryBudgets)); + localStorage.setItem('categoryBudgets_backup', JSON.stringify(localCategoryBudgets)); console.log('카테고리 예산 로컬 저장 완료', localCategoryBudgets); // 이벤트 발생시켜 UI 업데이트 diff --git a/src/utils/sync/budget/uploadBudget.ts b/src/utils/sync/budget/uploadBudget.ts index cf98516..ca46af2 100644 --- a/src/utils/sync/budget/uploadBudget.ts +++ b/src/utils/sync/budget/uploadBudget.ts @@ -64,6 +64,8 @@ async function uploadBudgetData(userId: string, parsedBudgetData: any): Promise< // 월간 타겟 금액 가져오기 const monthlyTarget = parsedBudgetData.monthly.targetAmount; + console.log('업로드할 월간 예산:', monthlyTarget); + // 업데이트 또는 삽입 결정 if (existingBudgets && existingBudgets.length > 0) { // 기존 데이터 업데이트 diff --git a/src/utils/sync/clearCloudData.ts b/src/utils/sync/clearCloudData.ts index 735becb..bb07d4c 100644 --- a/src/utils/sync/clearCloudData.ts +++ b/src/utils/sync/clearCloudData.ts @@ -52,8 +52,12 @@ export const clearCloudData = async (userId: string): Promise => { } catch (e) { console.log('category_budgets 테이블이 없거나 삭제 중 오류 발생:', e); } + + // 동기화 설정 초기화 및 마지막 동기화 시간 초기화 + localStorage.removeItem('lastSync'); + localStorage.setItem('syncEnabled', 'false'); // 동기화 설정을 OFF로 변경 - console.log('클라우드 데이터 초기화 완료'); + console.log('클라우드 데이터 초기화 완료 및 동기화 설정 OFF'); return true; } catch (error) { console.error('클라우드 데이터 초기화 중 오류 발생:', error); diff --git a/src/utils/sync/data.ts b/src/utils/sync/data.ts index b574b63..d3915a9 100644 --- a/src/utils/sync/data.ts +++ b/src/utils/sync/data.ts @@ -1,71 +1,193 @@ import { supabase } from '@/lib/supabase'; +import { uploadBudgets, downloadBudgets } from './budget'; +import { uploadTransactions, downloadTransactions } from './transaction'; import { setLastSyncTime } from './time'; +export interface SyncResult { + success: boolean; + partial: boolean; + uploadSuccess: boolean; + downloadSuccess: boolean; + details?: { + budgetUpload?: boolean; + budgetDownload?: boolean; + transactionUpload?: boolean; + transactionDownload?: boolean; + }; +} + /** - * 모든 데이터 동기화 기능 + * 모든 데이터를 동기화합니다 (업로드 우선 수행) */ -export const syncAllData = async (userId: string): Promise => { - if (!userId) { - throw new Error('사용자 ID가 필요합니다'); - } - - try { - // 로컬 트랜잭션 데이터 가져오기 - const transactionsJSON = localStorage.getItem('transactions'); - const transactions = transactionsJSON ? JSON.parse(transactionsJSON) : []; - - // 예산 데이터 가져오기 - const budgetDataJSON = localStorage.getItem('budgetData'); - const budgetData = budgetDataJSON ? JSON.parse(budgetDataJSON) : {}; - - // 카테고리 예산 가져오기 - const categoryBudgetsJSON = localStorage.getItem('categoryBudgets'); - const categoryBudgets = categoryBudgetsJSON ? JSON.parse(categoryBudgetsJSON) : {}; - - // 트랜잭션 데이터 동기화 - for (const transaction of transactions) { - // 이미 동기화된 데이터인지 확인 (transaction_id로 확인) - const { data: existingData } = await supabase - .from('transactions') - .select('*') - .eq('transaction_id', transaction.id) - .eq('user_id', userId); - - // 존재하지 않는 경우에만 삽입 - if (!existingData || existingData.length === 0) { - await supabase.from('transactions').insert({ - user_id: userId, - title: transaction.title, - amount: transaction.amount, - date: transaction.date, - category: transaction.category, - type: transaction.type, - transaction_id: transaction.id - }); - } +export const syncAllData = async (userId: string): Promise => { + // 로컬 데이터 백업 + const backupBudgetData = localStorage.getItem('budgetData'); + const backupCategoryBudgets = localStorage.getItem('categoryBudgets'); + const backupTransactions = localStorage.getItem('transactions'); + + const result: SyncResult = { + success: false, + partial: false, + uploadSuccess: false, + downloadSuccess: false, + details: { + budgetUpload: false, + budgetDownload: false, + transactionUpload: false, + transactionDownload: false } - - // 예산 데이터 동기화 - await supabase.from('budget_data').upsert({ - user_id: userId, - data: budgetData, - updated_at: new Date().toISOString() - }); - - // 카테고리 예산 동기화 - await supabase.from('category_budgets').upsert({ - user_id: userId, - data: categoryBudgets, - updated_at: new Date().toISOString() - }); - - // 마지막 동기화 시간 업데이트 - setLastSyncTime(); + }; + + try { + console.log('데이터 동기화 시작 - 사용자 ID:', userId); - console.log('모든 데이터가 성공적으로 동기화되었습니다'); + // 여기서는 업로드를 먼저 시도합니다 (로컬 데이터 보존을 위해) + try { + // 예산 데이터 업로드 + await uploadBudgets(userId); + result.details!.budgetUpload = true; + console.log('예산 업로드 성공'); + + // 트랜잭션 데이터 업로드 + await uploadTransactions(userId); + result.details!.transactionUpload = true; + console.log('트랜잭션 업로드 성공'); + + // 업로드 성공 설정 + result.uploadSuccess = true; + } catch (uploadError) { + console.error('데이터 업로드 실패:', uploadError); + result.uploadSuccess = false; + } + + // 그 다음 다운로드 시도 + try { + // 서버에 데이터가 없는 경우를 확인하기 위해 먼저 데이터 유무 검사 + const { data: budgetData } = await supabase + .from('budgets') + .select('count') + .eq('user_id', userId) + .single(); + + const { data: transactionsData } = await supabase + .from('transactions') + .select('count') + .eq('user_id', userId) + .single(); + + // 서버에 데이터가 없지만 로컬에 데이터가 있는 경우, 다운로드를 건너뜀 + const serverHasData = (budgetData?.count || 0) > 0 || (transactionsData?.count || 0) > 0; + + if (!serverHasData && (backupBudgetData || backupTransactions)) { + console.log('서버에 데이터가 없고 로컬 데이터가 있어 다운로드 건너뜀'); + result.downloadSuccess = true; + result.details!.budgetDownload = true; + result.details!.transactionDownload = true; + } else { + // 예산 데이터 다운로드 + await downloadBudgets(userId); + result.details!.budgetDownload = true; + console.log('예산 다운로드 성공'); + + // 트랜잭션 데이터 다운로드 + await downloadTransactions(userId); + result.details!.transactionDownload = true; + console.log('트랜잭션 다운로드 성공'); + + // 다운로드 성공 설정 + result.downloadSuccess = true; + } + } catch (downloadError) { + console.error('데이터 다운로드 실패:', downloadError); + result.downloadSuccess = false; + + // 다운로드 실패 시 로컬 데이터 복원 + if (backupBudgetData) { + localStorage.setItem('budgetData', backupBudgetData); + } + if (backupCategoryBudgets) { + localStorage.setItem('categoryBudgets', backupCategoryBudgets); + } + if (backupTransactions) { + localStorage.setItem('transactions', backupTransactions); + } + + // UI 업데이트 + window.dispatchEvent(new Event('budgetDataUpdated')); + window.dispatchEvent(new Event('categoryBudgetsUpdated')); + window.dispatchEvent(new Event('transactionUpdated')); + } + + // 부분 성공 여부 설정 + result.partial = (result.uploadSuccess || result.downloadSuccess) && !(result.uploadSuccess && result.downloadSuccess); + + // 전체 성공 여부 설정 + result.success = result.uploadSuccess || result.downloadSuccess; + + // 동기화 시간 기록 + if (result.success) { + setLastSyncTime(new Date().toISOString()); + } + + console.log('데이터 동기화 결과:', result); + return result; } catch (error) { - console.error('데이터 동기화 중 오류 발생:', error); - throw error; + console.error('데이터 동기화 중 치명적 오류:', error); + + // 백업 데이터 복원 + if (backupBudgetData) { + localStorage.setItem('budgetData', backupBudgetData); + } + if (backupCategoryBudgets) { + localStorage.setItem('categoryBudgets', backupCategoryBudgets); + } + if (backupTransactions) { + localStorage.setItem('transactions', backupTransactions); + } + + // UI 업데이트 + window.dispatchEvent(new Event('budgetDataUpdated')); + window.dispatchEvent(new Event('categoryBudgetsUpdated')); + window.dispatchEvent(new Event('transactionUpdated')); + + result.success = false; + result.partial = false; + result.uploadSuccess = false; + result.downloadSuccess = false; + + return result; } }; + +/** + * 서버에 대한 안전한 동기화 래퍼 + * 오류 처리와 재시도 로직을 포함 + */ +export const trySyncAllData = async (userId: string): Promise => { + console.log('안전한 데이터 동기화 시도'); + let attempts = 0; + + const trySync = async (): Promise => { + try { + return await syncAllData(userId); + } catch (error) { + attempts++; + console.error(`동기화 시도 ${attempts} 실패:`, error); + + if (attempts < 2) { + console.log('동기화 재시도 중...'); + return trySync(); + } + + return { + success: false, + partial: false, + uploadSuccess: false, + downloadSuccess: false + }; + } + }; + + return trySync(); +}; diff --git a/src/utils/sync/transaction/deleteTransaction.ts b/src/utils/sync/transaction/deleteTransaction.ts index b73421b..9403d44 100644 --- a/src/utils/sync/transaction/deleteTransaction.ts +++ b/src/utils/sync/transaction/deleteTransaction.ts @@ -4,13 +4,15 @@ import { isSyncEnabled } from '../syncSettings'; import { toast } from '@/hooks/useToast.wrapper'; /** - * 특정 트랜잭션 ID 삭제 처리 + * 특정 트랜잭션 ID 삭제 처리 - 안정성 개선 버전 */ export const deleteTransactionFromServer = async (userId: string, transactionId: string): Promise => { if (!isSyncEnabled()) return; try { console.log(`트랜잭션 삭제 요청: ${transactionId}`); + + // 삭제 요청 (타임아웃 처리 없음 - 불필요한 복잡성 제거) const { error } = await supabase .from('transactions') .delete() @@ -25,11 +27,16 @@ export const deleteTransactionFromServer = async (userId: string, transactionId: console.log(`트랜잭션 ${transactionId} 삭제 완료`); } catch (error) { console.error('트랜잭션 삭제 중 오류:', error); - // 에러 발생 시 토스트 알림 + + // 오류 메시지 (중요도 낮음) toast({ - title: "삭제 동기화 실패", - description: "서버에서 트랜잭션을 삭제하는데 문제가 발생했습니다.", - variant: "destructive" + title: "동기화 문제", + description: "서버에서 삭제 중 문제가 발생했습니다.", + variant: "default", + duration: 1500 }); + + // 오류 다시 던지기 (호출자가 처리하도록) + throw error; } };