Merge remote-tracking branch 'origin/main'

This commit is contained in:
hansoo
2025-03-18 15:47:45 +09:00
74 changed files with 3087 additions and 1862 deletions

View File

@@ -8,6 +8,7 @@ import { supabase } from '@/lib/supabase';
import { isSyncEnabled } from '@/utils/syncUtils'; import { isSyncEnabled } from '@/utils/syncUtils';
import ExpenseForm, { ExpenseFormValues } from './expenses/ExpenseForm'; import ExpenseForm, { ExpenseFormValues } from './expenses/ExpenseForm';
import { Transaction } from '@/components/TransactionCard'; import { Transaction } from '@/components/TransactionCard';
import { normalizeDate } from '@/utils/sync/transaction/dateUtils';
const AddTransactionButton = () => { const AddTransactionButton = () => {
const [showExpenseDialog, setShowExpenseDialog] = useState(false); const [showExpenseDialog, setShowExpenseDialog] = useState(false);
@@ -53,11 +54,14 @@ const AddTransactionButton = () => {
const { data: { user } } = await supabase.auth.getUser(); const { data: { user } } = await supabase.auth.getUser();
if (isSyncEnabled() && user) { if (isSyncEnabled() && user) {
// ISO 형식으로 날짜 변환
const isoDate = normalizeDate(formattedDate);
const { error } = await supabase.from('transactions').insert({ const { error } = await supabase.from('transactions').insert({
user_id: user.id, user_id: user.id,
title: data.title, title: data.title,
amount: parseInt(numericAmount), amount: parseInt(numericAmount),
date: formattedDate, date: isoDate, // ISO 형식 사용
category: data.category, category: data.category,
type: 'expense', type: 'expense',
transaction_id: newExpense.id transaction_id: newExpense.id

View File

@@ -52,14 +52,22 @@ const BudgetTabContent: React.FC<BudgetTabContentProps> = ({
// 카테고리별 예산 합계 계산 // 카테고리별 예산 합계 계산
const calculateTotalBudget = () => { 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 handleSaveCategoryBudgets = () => {
const totalBudget = calculateTotalBudget(); const totalBudget = calculateTotalBudget();
onSaveBudget(totalBudget, categoryBudgets); console.log('카테고리 예산 저장 및 총 예산 설정:', totalBudget, categoryBudgets);
setShowBudgetInput(false); // 총액이 0이 아닐 때만 저장 처리
if (totalBudget > 0) {
onSaveBudget(totalBudget, categoryBudgets);
setShowBudgetInput(false);
} else {
alert('예산을 입력해주세요.');
}
}; };
// 기존 카테고리 예산 불러오기 // 기존 카테고리 예산 불러오기
@@ -69,7 +77,9 @@ const BudgetTabContent: React.FC<BudgetTabContentProps> = ({
try { try {
const storedCategoryBudgets = localStorage.getItem('categoryBudgets'); const storedCategoryBudgets = localStorage.getItem('categoryBudgets');
if (storedCategoryBudgets) { if (storedCategoryBudgets) {
setCategoryBudgets(JSON.parse(storedCategoryBudgets)); const parsedBudgets = JSON.parse(storedCategoryBudgets);
console.log('저장된 카테고리 예산 불러옴:', parsedBudgets);
setCategoryBudgets(parsedBudgets);
} }
} catch (error) { } catch (error) {
console.error('카테고리 예산 불러오기 오류:', error); console.error('카테고리 예산 불러오기 오류:', error);
@@ -107,12 +117,9 @@ const BudgetTabContent: React.FC<BudgetTabContentProps> = ({
</div> </div>
<div className="mt-6"> <div className="mt-6">
<button <button onClick={toggleBudgetInput} className="text-neuro-income hover:underline flex items-center text-lg font-bold group">
onClick={toggleBudgetInput} <CirclePlus size={26} className="mr-2 text-neuro-income transition-transform group-hover:scale-110" />
className="text-neuro-income hover:underline flex items-center text-lg font-bold group" <span className="text-base font-semibold">{budgetButtonText}</span>
>
<CirclePlus size={26} className="mr-2 text-neuro-income animate-pulse transition-transform group-hover:scale-110" />
<span className="text-base font-semibold animate-pulse">{budgetButtonText}</span>
</button> </button>
</div> </div>
</> : <div className="py-4 text-center"> </> : <div className="py-4 text-center">
@@ -126,13 +133,13 @@ const BudgetTabContent: React.FC<BudgetTabContentProps> = ({
{showBudgetInput && <div className="mt-4"> {showBudgetInput && <div className="mt-4">
<div className="neuro-card p-4"> <div className="neuro-card p-4">
<div> <div>
<h3 className="text-base font-medium mb-3"> </h3> <h3 className="text-base font-medium mb-3"> </h3>
<p className="text-sm text-gray-500 mb-4"> , , .</p> <p className="text-sm text-gray-500 mb-4"> . , .</p>
<CategoryBudgetInputs categoryBudgets={categoryBudgets} handleCategoryInputChange={handleCategoryInputChange} /> <CategoryBudgetInputs categoryBudgets={categoryBudgets} handleCategoryInputChange={handleCategoryInputChange} />
<div className="mt-4 border-t border-gray-300 pt-3"> <div className="mt-4 border-t border-gray-300 pt-3">
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-4">
<h3 className="font-medium text-sm px-[34px]"> :</h3> <h3 className="font-medium text-sm px-[34px]"> :</h3>
<p className="font-bold text-neuro-income text-base px-[10px]">{formatCurrency(calculateTotalBudget())}</p> <p className="font-bold text-neuro-income text-base px-[10px]">{formatCurrency(calculateTotalBudget())}</p>
</div> </div>

View File

@@ -1,4 +1,5 @@
import React, { useState } from 'react';
import React, { useState, useCallback, useRef } from 'react';
import { Transaction } from './TransactionCard'; import { Transaction } from './TransactionCard';
import TransactionEditDialog from './TransactionEditDialog'; import TransactionEditDialog from './TransactionEditDialog';
import { ChevronRight } from 'lucide-react'; import { ChevronRight } from 'lucide-react';
@@ -6,64 +7,208 @@ import { useBudget } from '@/contexts/BudgetContext';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { categoryIcons } from '@/constants/categoryIcons'; import { categoryIcons } from '@/constants/categoryIcons';
import TransactionIcon from './transaction/TransactionIcon'; import TransactionIcon from './transaction/TransactionIcon';
import { toast } from '@/hooks/useToast.wrapper';
interface RecentTransactionsSectionProps { interface RecentTransactionsSectionProps {
transactions: Transaction[]; transactions: Transaction[];
onUpdateTransaction?: (transaction: Transaction) => void; onUpdateTransaction?: (transaction: Transaction) => void;
} }
const RecentTransactionsSection: React.FC<RecentTransactionsSectionProps> = ({ const RecentTransactionsSection: React.FC<RecentTransactionsSectionProps> = ({
transactions, transactions,
onUpdateTransaction onUpdateTransaction
}) => { }) => {
const [selectedTransaction, setSelectedTransaction] = useState<Transaction | null>(null); const [selectedTransaction, setSelectedTransaction] = useState<Transaction | null>(null);
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const { const [isDeleting, setIsDeleting] = useState(false);
updateTransaction, const { updateTransaction, deleteTransaction } = useBudget();
deleteTransaction
} = useBudget(); // 삭제 중인 ID 추적
const deletingIdRef = useRef<string | null>(null);
// 타임아웃 추적
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
// 삭제 요청 타임스탬프 추적 (급발진 방지)
const lastDeleteTimeRef = useRef<Record<string, number>>({});
const handleTransactionClick = (transaction: Transaction) => { const handleTransactionClick = (transaction: Transaction) => {
setSelectedTransaction(transaction); setSelectedTransaction(transaction);
setIsDialogOpen(true); setIsDialogOpen(true);
}; };
const handleUpdateTransaction = (updatedTransaction: Transaction) => {
const handleUpdateTransaction = useCallback((updatedTransaction: Transaction) => {
if (onUpdateTransaction) { if (onUpdateTransaction) {
onUpdateTransaction(updatedTransaction); onUpdateTransaction(updatedTransaction);
} }
// 직접 컨텍스트를 통해 업데이트 // 직접 컨텍스트를 통해 업데이트
updateTransaction(updatedTransaction); updateTransaction(updatedTransaction);
}; }, [onUpdateTransaction, updateTransaction]);
const handleDeleteTransaction = (id: string) => {
// 직접 컨텍스트를 통해 삭제 // 완전히 새로운 삭제 처리 함수
deleteTransaction(id); const handleDeleteTransaction = useCallback(async (id: string): Promise<boolean> => {
}; 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) => { const formatCurrency = (amount: number) => {
return amount.toLocaleString('ko-KR') + '원'; return amount.toLocaleString('ko-KR') + '원';
}; };
return <div className="mt-6 mb-[50px]">
return (
<div className="mt-6 mb-[50px]">
<div className="flex justify-between items-center mb-3"> <div className="flex justify-between items-center mb-3">
<h2 className="text-lg font-semibold"> </h2> <h2 className="text-lg font-semibold"> </h2>
<Link to="/transactions" className="text-sm text-neuro-income flex items-center"> <Link to="/transactions" className="text-sm text-neuro-income flex items-center">
<ChevronRight size={16} /> <ChevronRight size={16} />
</Link> </Link>
</div> </div>
<div className="neuro-card divide-y divide-gray-100 w-full"> <div className="neuro-card divide-y divide-gray-100 w-full">
{transactions.length > 0 ? transactions.map(transaction => <div key={transaction.id} onClick={() => handleTransactionClick(transaction)} className="flex justify-between py-3 cursor-pointer hover:bg-gray-50 px-[5px]"> {transactions.length > 0 ? transactions.map(transaction => (
<div className="flex items-center"> <div
<TransactionIcon category={transaction.category} /> key={transaction.id}
<div className="ml-3"> onClick={() => handleTransactionClick(transaction)}
<h3 className="font-medium text-black text-left">{transaction.title}</h3> className="flex justify-between py-3 cursor-pointer hover:bg-gray-50 px-[5px]"
<p className="text-xs text-gray-500">{transaction.date}</p> >
</div> <div className="flex items-center">
<TransactionIcon category={transaction.category} />
<div className="ml-3">
<h3 className="font-medium text-black text-left">{transaction.title}</h3>
<p className="text-xs text-gray-500">{transaction.date}</p>
</div> </div>
<div className="text-right"> </div>
<p className="font-semibold text-neuro-income">-{formatCurrency(transaction.amount)}</p> <div className="text-right">
<p className="text-xs text-gray-500">{transaction.category}</p> <p className="font-semibold text-neuro-income">-{formatCurrency(transaction.amount)}</p>
</div> <p className="text-xs text-gray-500">{transaction.category}</p>
</div>) : <div className="py-4 text-center text-gray-500"> </div>
</div>
)) : (
<div className="py-4 text-center text-gray-500">
</div>} </div>
)}
</div> </div>
{selectedTransaction && <TransactionEditDialog transaction={selectedTransaction} open={isDialogOpen} onOpenChange={setIsDialogOpen} onSave={handleUpdateTransaction} onDelete={handleDeleteTransaction} />} {selectedTransaction && (
</div>; <TransactionEditDialog
transaction={selectedTransaction}
open={isDialogOpen}
onOpenChange={setIsDialogOpen}
onSave={handleUpdateTransaction}
onDelete={handleDeleteTransaction}
/>
)}
</div>
);
}; };
export default RecentTransactionsSection;
export default RecentTransactionsSection;

View File

@@ -1,11 +1,12 @@
import React from 'react'; import React, { useEffect } from 'react';
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { CloudUpload } from "lucide-react"; import { CloudUpload } from "lucide-react";
import { useSyncSettings } from '@/hooks/useSyncSettings'; import { useSyncSettings } from '@/hooks/useSyncSettings';
import SyncStatus from '@/components/sync/SyncStatus'; import SyncStatus from '@/components/sync/SyncStatus';
import SyncExplanation from '@/components/sync/SyncExplanation'; import SyncExplanation from '@/components/sync/SyncExplanation';
import { isSyncEnabled } from '@/utils/sync/syncSettings';
const SyncSettings = () => { const SyncSettings = () => {
const { const {
@@ -17,6 +18,30 @@ const SyncSettings = () => {
handleManualSync handleManualSync
} = useSyncSettings(); } = 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* 동기화 토글 컨트롤 */} {/* 동기화 토글 컨트롤 */}

View File

@@ -19,18 +19,27 @@ export type Transaction = {
interface TransactionCardProps { interface TransactionCardProps {
transaction: Transaction; transaction: Transaction;
onUpdate?: (updatedTransaction: Transaction) => void; onUpdate?: (updatedTransaction: Transaction) => void;
onDelete?: (id: string) => Promise<boolean> | boolean; // 타입 변경됨: boolean 또는 Promise<boolean> 반환
} }
const TransactionCard: React.FC<TransactionCardProps> = ({ const TransactionCard: React.FC<TransactionCardProps> = ({
transaction, transaction,
onUpdate onDelete,
}) => { }) => {
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const { title, amount, date, category, type } = transaction; const { title, amount, date, category } = transaction;
const handleSaveTransaction = (updatedTransaction: Transaction) => { // 삭제 핸들러 - 인자로 받은 onDelete가 없거나 타입이 맞지 않을 때 기본 함수 제공
if (onUpdate) { const handleDelete = async (id: string): Promise<boolean> => {
onUpdate(updatedTransaction); 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<TransactionCardProps> = ({
transaction={transaction} transaction={transaction}
open={isEditDialogOpen} open={isEditDialogOpen}
onOpenChange={setIsEditDialogOpen} onOpenChange={setIsEditDialogOpen}
onSave={handleSaveTransaction} onDelete={handleDelete} // 래핑된 핸들러 사용
/> />
</> </>
); );

View File

@@ -10,7 +10,8 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogFooter, DialogFooter,
DialogClose DialogClose,
DialogDescription
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Form } from '@/components/ui/form'; import { Form } from '@/components/ui/form';
@@ -28,7 +29,7 @@ interface TransactionEditDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onSave?: (updatedTransaction: Transaction) => void; onSave?: (updatedTransaction: Transaction) => void;
onDelete?: (id: string) => void; onDelete?: (id: string) => Promise<boolean> | boolean;
} }
const TransactionEditDialog: React.FC<TransactionEditDialogProps> = ({ const TransactionEditDialog: React.FC<TransactionEditDialogProps> = ({
@@ -39,6 +40,7 @@ const TransactionEditDialog: React.FC<TransactionEditDialogProps> = ({
onDelete onDelete
}) => { }) => {
const { updateTransaction, deleteTransaction } = useBudget(); const { updateTransaction, deleteTransaction } = useBudget();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const form = useForm<TransactionFormValues>({ const form = useForm<TransactionFormValues>({
@@ -77,21 +79,28 @@ const TransactionEditDialog: React.FC<TransactionEditDialogProps> = ({
}); });
}; };
const handleDelete = () => { const handleDelete = async (): Promise<boolean> => {
// 컨텍스트를 통해 트랜잭션 삭제 try {
deleteTransaction(transaction.id); // 다이얼로그 닫기를 먼저 수행 (UI 블로킹 방지)
onOpenChange(false);
// 부모 컴포넌트의 onDelete 콜백이 있다면 호출
if (onDelete) { // 삭제 처리 - 부모 컴포넌트의 onDelete 콜백이 있다면 호출
onDelete(transaction.id); 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 ( return (
@@ -99,6 +108,9 @@ const TransactionEditDialog: React.FC<TransactionEditDialogProps> = ({
<DialogContent className={`sm:max-w-md mx-auto ${isMobile ? 'rounded-xl overflow-hidden' : ''}`}> <DialogContent className={`sm:max-w-md mx-auto ${isMobile ? 'rounded-xl overflow-hidden' : ''}`}>
<DialogHeader> <DialogHeader>
<DialogTitle> </DialogTitle> <DialogTitle> </DialogTitle>
<DialogDescription>
.
</DialogDescription>
</DialogHeader> </DialogHeader>
<Form {...form}> <Form {...form}>

View File

@@ -2,15 +2,7 @@
import React from 'react'; import React from 'react';
import { CloudOff, Loader2 } from 'lucide-react'; import { CloudOff, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogClose } from '@/components/ui/dialog';
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogClose
} from '@/components/ui/dialog';
interface DataResetDialogProps { interface DataResetDialogProps {
isOpen: boolean; isOpen: boolean;
@@ -18,6 +10,7 @@ interface DataResetDialogProps {
onConfirm: () => Promise<void>; onConfirm: () => Promise<void>;
isResetting: boolean; isResetting: boolean;
isLoggedIn: boolean; isLoggedIn: boolean;
syncEnabled: boolean;
} }
const DataResetDialog: React.FC<DataResetDialogProps> = ({ const DataResetDialog: React.FC<DataResetDialogProps> = ({
@@ -25,25 +18,26 @@ const DataResetDialog: React.FC<DataResetDialogProps> = ({
onOpenChange, onOpenChange,
onConfirm, onConfirm,
isResetting, isResetting,
isLoggedIn isLoggedIn,
syncEnabled
}) => { }) => {
return ( return <Dialog open={isOpen} onOpenChange={onOpenChange}>
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle> ?</DialogTitle> <DialogTitle> ?</DialogTitle>
<DialogDescription> <DialogDescription>
{isLoggedIn ? ( {isLoggedIn ? <>
<>
, , . , , .
<div className="flex items-center mt-2 text-amber-600"> <div className="flex items-center mt-2 text-amber-600">
<CloudOff size={16} className="mr-2" /> <CloudOff size={16} className="mr-2" />
. .
</div> </div>
</> {syncEnabled && (
) : ( <div className="mt-2 text-amber-600">
"이 작업은 되돌릴 수 없으며, 모든 예산, 지출 내역, 설정이 영구적으로 삭제됩니다." .
)} </div>
)}
</> : "이 작업은 되돌릴 수 없으며, 모든 예산, 지출 내역, 설정이 영구적으로 삭제됩니다."}
<div className="mt-2"> <div className="mt-2">
, '환영합니다' . , '환영합니다' .
</div> </div>
@@ -53,22 +47,15 @@ const DataResetDialog: React.FC<DataResetDialogProps> = ({
<DialogClose asChild> <DialogClose asChild>
<Button variant="outline" className="sm:mr-2" disabled={isResetting}></Button> <Button variant="outline" className="sm:mr-2" disabled={isResetting}></Button>
</DialogClose> </DialogClose>
<Button <Button variant="destructive" onClick={onConfirm} disabled={isResetting}>
variant="destructive" {isResetting ? <>
onClick={onConfirm}
disabled={isResetting}
>
{isResetting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
... ...
</> </> : isLoggedIn ? '확인, 로컬 및 클라우드 데이터 초기화' : '확인, 모든 데이터 초기화'}
) : isLoggedIn ? '확인, 로컬 및 클라우드 데이터 초기화' : '확인, 모든 데이터 초기화'}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>;
);
}; };
export default DataResetDialog; export default DataResetDialog;

View File

@@ -5,15 +5,21 @@ import { Button } from '@/components/ui/button';
import { useAuth } from '@/contexts/auth/AuthProvider'; import { useAuth } from '@/contexts/auth/AuthProvider';
import { useDataReset } from '@/hooks/useDataReset'; import { useDataReset } from '@/hooks/useDataReset';
import DataResetDialog from './DataResetDialog'; import DataResetDialog from './DataResetDialog';
import { isSyncEnabled } from '@/utils/sync/syncSettings';
import { toast } from '@/hooks/useToast.wrapper';
const DataResetSection = () => { const DataResetSection = () => {
const [isResetDialogOpen, setIsResetDialogOpen] = useState(false); const [isResetDialogOpen, setIsResetDialogOpen] = useState(false);
const { user } = useAuth(); const { user } = useAuth();
const { isResetting, resetAllData } = useDataReset(); const { isResetting, resetAllData } = useDataReset();
const syncEnabled = isSyncEnabled();
const handleResetAllData = async () => { const handleResetAllData = async () => {
await resetAllData(); await resetAllData();
setIsResetDialogOpen(false); setIsResetDialogOpen(false);
// 데이터 초기화 후 애플리케이션 리로드
// toast 알림은 useDataReset.ts에서 처리하므로 여기서는 제거
}; };
return ( return (
@@ -26,7 +32,9 @@ const DataResetSection = () => {
<div className="text-left"> <div className="text-left">
<h3 className="font-medium"> </h3> <h3 className="font-medium"> </h3>
<p className="text-xs text-gray-500 mt-1"> <p className="text-xs text-gray-500 mt-1">
{user ? "로컬 및 클라우드의 모든 예산, 지출 내역이 초기화됩니다." : "모든 예산, 지출 내역, 설정이 초기화됩니다."} {user
? "로컬 및 클라우드의 모든 예산, 지출 내역이 초기화됩니다. 동기화 설정은 비활성화됩니다."
: "모든 예산, 지출 내역, 설정이 초기화됩니다."}
</p> </p>
</div> </div>
</div> </div>
@@ -46,6 +54,7 @@ const DataResetSection = () => {
onConfirm={handleResetAllData} onConfirm={handleResetAllData}
isResetting={isResetting} isResetting={isResetting}
isLoggedIn={!!user} isLoggedIn={!!user}
syncEnabled={syncEnabled}
/> />
</> </>
); );

View File

@@ -16,7 +16,7 @@ const SyncExplanation: React.FC<SyncExplanationProps> = ({ enabled }) => {
<AlertTitle className="text-black"> </AlertTitle> <AlertTitle className="text-black"> </AlertTitle>
<AlertDescription className="text-sm text-black"> <AlertDescription className="text-sm text-black">
. . . .
. .
</AlertDescription> </AlertDescription>
</Alert> </Alert>
); );

View File

@@ -1,7 +1,7 @@
import React from 'react'; import React, { useState } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Trash2 } from 'lucide-react'; import { Trash2, Loader2 } from 'lucide-react';
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -15,19 +15,51 @@ import {
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
interface TransactionDeleteAlertProps { interface TransactionDeleteAlertProps {
onDelete: () => void; onDelete: () => Promise<boolean> | boolean;
} }
const TransactionDeleteAlert: React.FC<TransactionDeleteAlertProps> = ({ onDelete }) => { const TransactionDeleteAlert: React.FC<TransactionDeleteAlertProps> = ({ 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 ( return (
<AlertDialog> <AlertDialog open={isOpen} onOpenChange={(open) => {
// 삭제 중에는 닫기 방지
if (isDeleting && !open) return;
setIsOpen(open);
}}>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
className="border-red-200 text-red-500 hover:bg-red-50" className="border-red-200 text-red-500 hover:bg-red-50"
> >
<Trash2 size={16} /> <Trash2 size={16} className="mr-1" />
</Button> </Button>
</AlertDialogTrigger> </AlertDialogTrigger>
@@ -39,13 +71,24 @@ const TransactionDeleteAlert: React.FC<TransactionDeleteAlertProps> = ({ onDelet
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel> <AlertDialogCancel disabled={isDeleting}></AlertDialogCancel>
<AlertDialogAction <Button
className="bg-red-500 hover:bg-red-600" className="bg-red-500 hover:bg-red-600"
onClick={onDelete} onClick={handleDelete}
disabled={isDeleting}
> >
{isDeleting ? (
</AlertDialogAction> <>
<Loader2 size={16} className="mr-1 animate-spin" />
...
</>
) : (
<>
<Trash2 size={16} className="mr-1" />
</>
)}
</Button>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>

View File

@@ -0,0 +1,37 @@
import React from 'react';
interface EmptyTransactionsProps {
searchQuery: string;
selectedMonth: string;
setSearchQuery: (query: string) => void;
isDisabled: boolean;
}
const EmptyTransactions: React.FC<EmptyTransactionsProps> = ({
searchQuery,
selectedMonth,
setSearchQuery,
isDisabled
}) => {
return (
<div className="text-center py-10">
<p className="text-gray-500 mb-3">
{searchQuery.trim()
? '검색 결과가 없습니다.'
: `${selectedMonth}에 등록된 지출이 없습니다.`}
</p>
{searchQuery.trim() && (
<button
className="text-neuro-income"
onClick={() => setSearchQuery('')}
disabled={isDisabled}
>
</button>
)}
</div>
);
};
export default EmptyTransactions;

View File

@@ -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> | boolean;
}
const TransactionDateGroup: React.FC<TransactionDateGroupProps> = ({
date,
transactions,
onTransactionDelete
}) => {
// onTransactionDelete 함수를 래핑하여 Promise<boolean>을 반환하도록 보장
const handleDelete = async (id: string): Promise<boolean> => {
try {
return await onTransactionDelete(id);
} catch (error) {
console.error('트랜잭션 삭제 처리 중 오류:', error);
return false;
}
};
return (
<div>
<div className="flex items-center gap-2 mb-3">
<div className="h-1 flex-1 neuro-pressed"></div>
<h2 className="text-sm font-medium text-gray-500">{date}</h2>
<div className="h-1 flex-1 neuro-pressed"></div>
</div>
<div className="grid gap-3">
{transactions.map(transaction => (
<TransactionCard
key={transaction.id}
transaction={transaction}
onDelete={handleDelete}
/>
))}
</div>
</div>
);
};
export default TransactionDateGroup;

View File

@@ -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<string, Transaction[]>;
searchQuery: string;
selectedMonth: string;
setSearchQuery: (query: string) => void;
onTransactionDelete: (id: string) => Promise<boolean> | boolean;
isDisabled: boolean;
}
const TransactionsContent: React.FC<TransactionsContentProps> = ({
isLoading,
isProcessing,
transactions,
groupedTransactions,
searchQuery,
selectedMonth,
setSearchQuery,
onTransactionDelete,
isDisabled
}) => {
if (isLoading || isProcessing) {
return <LoadingState isProcessing={isProcessing} />;
}
if (!isLoading && !isProcessing && transactions.length === 0) {
return (
<EmptyTransactions
searchQuery={searchQuery}
selectedMonth={selectedMonth}
setSearchQuery={setSearchQuery}
isDisabled={isDisabled}
/>
);
}
return (
<TransactionsList
groupedTransactions={groupedTransactions}
onTransactionDelete={onTransactionDelete}
/>
);
};
const LoadingState: React.FC<{ isProcessing: boolean }> = ({ isProcessing }) => (
<div className="flex justify-center items-center py-10">
<Loader2 className="h-8 w-8 animate-spin text-neuro-income" />
<span className="ml-2 text-gray-500">{isProcessing ? '처리 중...' : '로딩 중...'}</span>
</div>
);
export default TransactionsContent;

View File

@@ -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<TransactionsHeaderProps> = ({
selectedMonth,
searchQuery,
setSearchQuery,
handlePrevMonth,
handleNextMonth,
budgetData,
totalExpenses,
isDisabled
}) => {
console.log('TransactionsHeader 렌더링:', { selectedMonth, totalExpenses });
// 예산 정보가 없는 경우 기본값 사용
const targetAmount = budgetData?.monthly?.targetAmount || 0;
return (
<header className="py-8">
<h1 className="text-2xl font-bold neuro-text mb-5"> </h1>
{/* Search */}
<div className="neuro-pressed mb-5 flex items-center px-4 py-3 rounded-xl">
<Search size={18} className="text-gray-500 mr-2" />
<input
type="text"
placeholder="지출 검색..."
className="bg-transparent flex-1 outline-none text-sm"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
disabled={isDisabled}
/>
</div>
{/* Month Selector */}
<div className="flex items-center justify-between mb-5">
<button
className="neuro-flat p-2 rounded-full"
onClick={handlePrevMonth}
disabled={isDisabled}
>
<ChevronLeft size={20} />
</button>
<div className="flex items-center gap-2">
<Calendar size={18} className="text-neuro-income" />
<span className="font-medium text-lg">{selectedMonth}</span>
</div>
<button
className="neuro-flat p-2 rounded-full"
onClick={handleNextMonth}
disabled={isDisabled}
>
<ChevronRight size={20} />
</button>
</div>
{/* Summary */}
<div className="grid grid-cols-2 gap-4 mb-8">
<div className="neuro-card">
<p className="text-sm text-gray-500 mb-1"> </p>
<p className="text-lg font-bold text-neuro-income">
{formatCurrency(targetAmount)}
</p>
</div>
<div className="neuro-card">
<p className="text-sm text-gray-500 mb-1"> </p>
<p className="text-lg font-bold text-neuro-income">
{formatCurrency(totalExpenses)}
</p>
</div>
</div>
</header>
);
};
export default TransactionsHeader;

View File

@@ -0,0 +1,29 @@
import React from 'react';
import TransactionCard, { Transaction } from '@/components/TransactionCard';
import TransactionDateGroup from './TransactionDateGroup';
interface TransactionsListProps {
groupedTransactions: Record<string, Transaction[]>;
onTransactionDelete: (id: string) => Promise<boolean> | boolean;
}
const TransactionsList: React.FC<TransactionsListProps> = ({
groupedTransactions,
onTransactionDelete
}) => {
return (
<div className="space-y-6 mb-[50px]">
{Object.entries(groupedTransactions).map(([date, dateTransactions]) => (
<TransactionDateGroup
key={date}
date={date}
transactions={dateTransactions}
onTransactionDelete={onTransactionDelete}
/>
))}
</div>
);
};
export default TransactionsList;

View File

@@ -1,3 +1,4 @@
import * as React from "react" import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast" import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority"
@@ -92,7 +93,7 @@ const ToastTitle = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<ToastPrimitives.Title <ToastPrimitives.Title
ref={ref} ref={ref}
className={cn("text-sm font-semibold", className)} className={cn("text-sm font-semibold text-center", className)}
{...props} {...props}
/> />
)) ))
@@ -104,7 +105,7 @@ const ToastDescription = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<ToastPrimitives.Description <ToastPrimitives.Description
ref={ref} ref={ref}
className={cn("text-sm opacity-90", className)} className={cn("text-sm opacity-90 text-center", className)}
{...props} {...props}
/> />
)) ))

View File

@@ -17,7 +17,7 @@ export function Toaster() {
{toasts.map(function ({ id, title, description, action, ...props }) { {toasts.map(function ({ id, title, description, action, ...props }) {
return ( return (
<Toast key={id} {...props}> <Toast key={id} {...props}>
<div className="grid gap-1"> <div className="grid gap-1 w-full text-center">
{title && <ToastTitle>{title}</ToastTitle>} {title && <ToastTitle>{title}</ToastTitle>}
{description && ( {description && (
<ToastDescription>{description}</ToastDescription> <ToastDescription>{description}</ToastDescription>

View File

@@ -1,70 +1,45 @@
import { supabase } from '@/integrations/supabase/client'; import { supabase } from '@/lib/supabase';
import { import { showAuthToast } from '@/utils/auth';
handleNetworkError,
parseResponse,
showAuthToast,
verifyServerConnection
} from '@/utils/auth';
import { signInWithDirectApi } from './signInUtils';
import { getProxyType, isCorsProxyEnabled } from '@/lib/supabase/config';
/**
* 로그인 기능 - Supabase Cloud 환경에 최적화
*/
export const signIn = async (email: string, password: string) => { export const signIn = async (email: string, password: string) => {
try { try {
console.log('로그인 시도 중:', email); console.log('로그인 시도 중:', email);
// 기본 Supabase 인증 방식 시도 // Supabase 인증 방식 시도
try { const { data, error } = await supabase.auth.signInWithPassword({
const { data, error } = await supabase.auth.signInWithPassword({ email,
email, password
password });
});
if (!error && data.user) {
showAuthToast('로그인 성공', '환영합니다!');
return { error: null, user: data.user };
} else if (error) {
console.error('로그인 오류:', error.message);
if (!error && data.user) { let errorMessage = error.message;
showAuthToast('로그인 성공', '환영합니다!'); if (error.message.includes('Invalid login credentials')) {
return { error: null, user: data.user }; errorMessage = '이메일 또는 비밀번호가 올바르지 않습니다.';
} else if (error) { } else if (error.message.includes('Email not confirmed')) {
console.error('Supabase 기본 로그인 오류:', error.message); errorMessage = '이메일 인증이 완료되지 않았습니다. 이메일을 확인해주세요.';
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 };
} }
} catch (basicAuthError: any) {
console.warn('Supabase 기본 인증 방식 예외 발생:', basicAuthError); showAuthToast('로그인 실패', errorMessage, 'destructive');
throw basicAuthError; return { error: { message: errorMessage }, user: null };
} }
// 여기까지 왔다면 모든 로그인 시도가 실패한 것 // 여기까지 왔다면 오류가 발생한 것
showAuthToast('로그인 실패', '로그인 처리 중 오류가 발생했습니다.', 'destructive'); showAuthToast('로그인 실패', '로그인 처리 중 오류가 발생했습니다.', 'destructive');
return { error: { message: '로그인 처리 중 오류가 발생했습니다.' }, user: null }; return { error: { message: '로그인 처리 중 오류가 발생했습니다.' }, user: null };
} catch (error: any) { } catch (error: any) {
console.error('로그인 중 예외 발생:', error); console.error('로그인 중 예외 발생:', error);
const errorMessage = error.message || '로그인 중 오류가 발생했습니다.';
// 프록시 설정 확인 및 추천
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 프록시로 변경을 권장합니다)`;
}
}
showAuthToast('로그인 오류', errorMessage, 'destructive'); showAuthToast('로그인 오류', errorMessage, 'destructive');
return { error: { message: errorMessage }, user: null }; return { error: { message: errorMessage }, user: null };
} }
}; };

View File

@@ -1,208 +1,53 @@
import { supabase } from '@/lib/supabase'; import { supabase } from '@/lib/supabase';
import { parseResponse, showAuthToast, handleNetworkError } from '@/utils/auth'; import { showAuthToast } from '@/utils/auth';
import { getProxyType, isCorsProxyEnabled, getSupabaseUrl, getOriginalSupabaseUrl } from '@/lib/supabase/config';
/** /**
* 직접 API 호출을 통한 로그인 시도 (대체 방법) * 로그인 기능 - Supabase Cloud 환경에 최적화
*/ */
export const signInWithDirectApi = async (email: string, password: string) => { export const signInWithDirectApi = async (email: string, password: string) => {
console.log('직접 API 호출로 로그인 시도'); console.log('Supabase Cloud 로그인 시도');
try { try {
// API 호출 URL 및 헤더 설정 // Supabase Cloud를 통한 로그인 요청
const supabaseUrl = getOriginalSupabaseUrl(); // 원본 URL 사용 const { data, error } = await supabase.auth.signInWithPassword({
const proxyUrl = getSupabaseUrl(); // 프록시 적용된 URL email,
const supabaseKey = localStorage.getItem('supabase_key') || supabase.supabaseKey; password
// 프록시 정보 로그
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 })
}); });
// 응답 상태 확인 및 로깅 // 오류 응답 처리
console.log('로그인 응답 상태:', response.status); if (error) {
console.error('로그인 오류:', error);
// HTTP 상태 코드 확인
if (response.status === 401) {
console.log('로그인 실패: 인증 오류');
showAuthToast('로그인 실패', '이메일 또는 비밀번호가 올바르지 않습니다.', 'destructive');
return {
error: { message: '인증 실패: 이메일 또는 비밀번호가 올바르지 않습니다.' },
user: null
};
}
if (response.status === 404) {
console.warn('API 경로를 찾을 수 없음 (404). 새 엔드포인트 시도 중...');
// 대체 엔드포인트 시도 (/token 대신 /signin) // 오류 메시지 포맷팅
const signinUrl = `${baseUrl}/signin`; let errorMessage = error.message;
try { if (error.message.includes('Invalid login credentials')) {
const signinResponse = await fetch(signinUrl, { errorMessage = '이메일 또는 비밀번호가 올바르지 않습니다.';
method: 'POST', } else if (error.message.includes('Email not confirmed')) {
headers: { errorMessage = '이메일 인증이 완료되지 않았습니다. 이메일을 확인해주세요.';
'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);
} }
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'); showAuthToast('로그인 실패', errorMessage, 'destructive');
return { error: { message: errorMessage }, user: null }; return { error: { message: errorMessage }, user: null };
} }
// 로그인 성공 응답 처리 // 로그인 성공 처리
if (response.ok && responseData?.access_token) { if (data && data.user) {
try { console.log('로그인 성공:', data.user);
// 로그인 성공 시 Supabase 세션 설정 showAuthToast('로그인 성공', '환영합니다!');
await supabase.auth.setSession({ return { error: null, user: data.user };
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 };
}
} else { } else {
// 오류 응답이나 예상치 못한 응답 형식 처리 // 사용자 정보가 없는 경우 (드문 경우)
console.error('로그인 오류 응답:', responseData); console.warn('로그인 성공했지만 사용자 정보가 없습니다');
showAuthToast('로그인 부분 성공', '로그인은 성공했지만 사용자 정보를 가져오지 못했습니다.', 'default');
const errorMessage = responseData?.error_description || return { error: { message: '사용자 정보 조회 실패' }, user: null };
responseData?.error ||
responseData?.message ||
'로그인에 실패했습니다. 이메일과 비밀번호를 확인하세요.';
showAuthToast('로그인 실패', errorMessage, 'destructive');
return { error: { message: errorMessage }, user: null };
} }
} catch (fetchError) { } catch (error: any) {
console.error('로그인 요청 중 fetch 오류:', fetchError); console.error('로그인 요청 중 예외:', error);
const errorMessage = error.message || '로그인 중 오류가 발생했습니다.';
// 오류 발생 시 프록시 설정 확인 정보 출력 showAuthToast('로그인 요청 실패', errorMessage, 'destructive');
const usingProxy = isCorsProxyEnabled(); return { error: { message: errorMessage }, user: null };
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 };
} }
}; };

View File

@@ -1,8 +1,10 @@
import { supabase } from '@/lib/supabase'; import { supabase } from '@/lib/supabase';
import { showAuthToast, verifyServerConnection } from '@/utils/auth'; import { showAuthToast, verifyServerConnection } from '@/utils/auth';
import { signUpWithDirectApi } from './signUpUtils';
/**
* 회원가입 기능 - Supabase Cloud 환경에 최적화
*/
export const signUp = async (email: string, password: string, username: string) => { export const signUp = async (email: string, password: string, username: string) => {
try { try {
// 서버 연결 상태 확인 // 서버 연결 상태 확인
@@ -15,137 +17,74 @@ export const signUp = async (email: string, password: string, username: string)
console.log('회원가입 시도:', email); 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 가져오기 // 현재 브라우저 URL 가져오기
const currentUrl = window.location.origin; const currentUrl = window.location.origin;
// 해시 대신 쿼리 파라미터 방식으로 URL 구성 (auth_callback 파라미터 추가)
const redirectUrl = `${currentUrl}/login?auth_callback=true`; const redirectUrl = `${currentUrl}/login?auth_callback=true`;
console.log('이메일 인증 리디렉션 URL:', redirectUrl); console.log('이메일 인증 리디렉션 URL:', redirectUrl);
// 기본 회원가입 시도 // 회원가입 요청
try { const { data, error } = await supabase.auth.signUp({
// 디버깅용 로그 email,
console.log('Supabase 회원가입 요청 시작 - 이메일:', email, '사용자명:', username); password,
options: {
data: {
username, // 사용자 이름을 메타데이터에 저장
},
emailRedirectTo: redirectUrl
}
});
if (error) {
console.error('회원가입 오류:', error);
const { data, error } = await supabase.auth.signUp({ // 오류 메시지 처리
email, let errorMessage = error.message;
password,
options: {
data: {
username, // 사용자 이름을 메타데이터에 저장
},
emailRedirectTo: redirectUrl // 현재 도메인 기반 리디렉션 URL 사용
}
});
console.log('Supabase 회원가입 응답:', { data, error }); if (error.message.includes('User already registered')) {
errorMessage = '이미 등록된 사용자입니다.';
if (error) { } else if (error.message.includes('Signup not allowed')) {
console.error('회원가입 오류:', error); errorMessage = '회원가입이 허용되지 않습니다.';
} else if (error.message.includes('Email link invalid')) {
// REST API 오류인 경우 직접 API 호출 시도 errorMessage = '이메일 링크가 유효하지 않습니다.';
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 };
} }
// 회원가입 성공 showAuthToast('회원가입 실패', errorMessage, 'destructive');
if (data && data.user) { return { error: { message: errorMessage }, user: null };
// 이메일 확인이 필요한지 확인
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 };
} }
// 회원가입 성공
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) { } catch (error: any) {
console.error('회원가입 전역 예외:', error); console.error('회원가입 전역 예외:', error);
showAuthToast('회원가입 오류', error.message || '알 수 없는 오류', 'destructive'); showAuthToast('회원가입 오류', error.message || '알 수 없는 오류', 'destructive');

View File

@@ -1,156 +1,87 @@
import { supabase } from '@/lib/supabase'; import { supabase } from '@/lib/supabase';
import { parseResponse, showAuthToast } from '@/utils/auth'; 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) => { export const signUpWithDirectApi = async (email: string, password: string, username: string, redirectUrl?: string) => {
try { try {
console.log('직접 API 호출로 회원가입 시도 중'); console.log('Supabase Cloud 회원가입 시도 중');
// 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
};
}
// 리디렉션 URL 설정 (전달되지 않은 경우 기본값 사용) // 리디렉션 URL 설정 (전달되지 않은 경우 기본값 사용)
// 해시(#) 대신 쿼리 파라미터(?token=) 방식으로 URL 구성
const finalRedirectUrl = redirectUrl || `${window.location.origin}/login?auth_callback=true`; const finalRedirectUrl = redirectUrl || `${window.location.origin}/login?auth_callback=true`;
console.log('이메일 인증 리디렉션 URL (API):', finalRedirectUrl); console.log('이메일 인증 리디렉션 URL:', finalRedirectUrl);
// API 요청 전송 // Supabase Cloud API를 통한 회원가입 요청
const response = await sendSignUpApiRequest(email, password, username, finalRedirectUrl, supabaseKey); const { data, error } = await supabase.auth.signUp({
email,
// 401 오류 처리 (권한 없음) password,
if (response.status === 401) { options: {
showAuthToast('회원가입 실패', '회원가입 권한이 없습니다. Supabase 설정 또는 권한을 확인하세요.', 'destructive'); data: {
return { username // 사용자 이름을 메타데이터에 저장
error: { message: '회원가입 권한이 없습니다. Supabase 설정 또는 권한을 확인하세요.' }, },
user: null, emailRedirectTo: finalRedirectUrl
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
};
} }
});
// 오류 처리
if (error) {
console.error('회원가입 오류:', error);
if (response.status >= 200 && response.status < 300) { let errorMessage = error.message;
// 성공 응답이지만 JSON이 아닌 경우 (빈 응답 등) if (error.message.includes('User already registered')) {
responseData = { success: true }; errorMessage = '이미 등록된 사용자입니다.';
} else { } else if (error.message.includes('Signup not allowed')) {
responseData = { error: '서버 응답을 처리할 수 없습니다' }; 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'); showAuthToast('회원가입 실패', errorMessage, 'destructive');
return { error: { message: errorMessage }, user: null }; 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 { if (data && data.user) {
await supabase.auth.signInWithPassword({ email, password }); // 이메일 확인이 필요한지 확인
} catch (loginError) { const isEmailConfirmationRequired = data.user.identities &&
console.warn('자동 로그인 실패:', loginError); 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 };
} }
}; };

View File

@@ -53,78 +53,51 @@ export const calculateCategorySpending = (
})); }));
}; };
// 예산 데이터 업데이트 계산 // 예산 데이터 업데이트 계산 - 완전히 수정된 함수
export const calculateUpdatedBudgetData = ( export const calculateUpdatedBudgetData = (
prevBudgetData: BudgetData, prevBudgetData: BudgetData,
type: BudgetPeriod, type: BudgetPeriod,
amount: number amount: number
): BudgetData => { ): BudgetData => {
if (type === 'monthly') { console.log(`예산 업데이트 계산: 타입=${type}, 금액=${amount}`);
const dailyAmount = Math.round(amount / 30);
const weeklyAmount = Math.round(amount / 4.3); // 모든 타입에 대해 월간 예산을 기준으로 계산
let monthlyAmount = amount;
return {
daily: { // 선택된 탭이 월간이 아닌 경우, 올바른 월간 값으로 변환
targetAmount: dailyAmount, if (type === 'daily') {
spentAmount: prevBudgetData.daily.spentAmount, // 일일 예산이 입력된 경우: 일일 * 30 = 월간
remainingAmount: Math.max(0, dailyAmount - prevBudgetData.daily.spentAmount) monthlyAmount = amount * 30;
}, console.log(`일일 예산 ${amount}원 → 월간 예산 ${monthlyAmount}원으로 변환`);
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)
}
};
} else if (type === 'weekly') { } else if (type === 'weekly') {
// 주간 예산이 설정되면 월간 예산도 자동 계산 // 주간 예산이 입력된 경우: 주간 * 4.3 = 월간
const monthlyAmount = Math.round(amount * 4.3); monthlyAmount = Math.round(amount * 4.3);
const dailyAmount = Math.round(amount / 7); console.log(`주간 예산 ${amount}원 → 월간 예산 ${monthlyAmount}원으로 변환`);
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)
}
};
} }
// 월간 예산을 기준으로 일일, 주간 예산 계산
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)
}
};
}; };
// 지출액 계산 (일일, 주간, 월간) // 지출액 계산 (일일, 주간, 월간)

View File

@@ -119,18 +119,28 @@ export const useBudgetDataState = (transactions: any[]) => {
) => { ) => {
try { try {
console.log(`예산 목표 업데이트: ${type}, 금액: ${amount}`); console.log(`예산 목표 업데이트: ${type}, 금액: ${amount}`);
// 월간 예산 직접 업데이트 (카테고리 예산이 없는 경우)
if (!newCategoryBudgets) { // 금액이 유효한지 확인
const updatedBudgetData = calculateUpdatedBudgetData(budgetData, type, amount); if (isNaN(amount) || amount <= 0) {
console.log(' 예산 데이터:', updatedBudgetData); console.error('유효하지 않은 예산 금액:', amount);
toast({
// 상태 및 스토리지 둘 다 업데이트 title: "예산 설정 오류",
setBudgetData(updatedBudgetData); description: "유효한 예산 금액을 입력해주세요.",
saveBudgetDataToStorage(updatedBudgetData); variant: "destructive"
});
// 저장 시간 업데이트 return;
localStorage.setItem('lastBudgetSaveTime', new Date().toISOString());
} }
// 예산 업데이트 (카테고리 예산이 있든 없든 무조건 실행)
const updatedBudgetData = calculateUpdatedBudgetData(budgetData, type, amount);
console.log('새 예산 데이터:', updatedBudgetData);
// 상태 및 스토리지 둘 다 업데이트
setBudgetData(updatedBudgetData);
saveBudgetDataToStorage(updatedBudgetData);
// 저장 시간 업데이트
localStorage.setItem('lastBudgetSaveTime', new Date().toISOString());
} catch (error) { } catch (error) {
console.error('예산 목표 업데이트 중 오류:', error); console.error('예산 목표 업데이트 중 오류:', error);
toast({ toast({

View File

@@ -1,3 +1,4 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { Transaction } from '../types'; import { Transaction } from '../types';
import { import {
@@ -11,6 +12,7 @@ import { toast } from '@/hooks/useToast.wrapper'; // 래퍼 사용
export const useTransactionState = () => { export const useTransactionState = () => {
const [transactions, setTransactions] = useState<Transaction[]>([]); const [transactions, setTransactions] = useState<Transaction[]>([]);
const [lastDeletedId, setLastDeletedId] = useState<string | null>(null); const [lastDeletedId, setLastDeletedId] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
// 초기 트랜잭션 로드 및 이벤트 리스너 설정 // 초기 트랜잭션 로드 및 이벤트 리스너 설정
useEffect(() => { useEffect(() => {
@@ -72,9 +74,15 @@ export const useTransactionState = () => {
}); });
}, []); }, []);
// 트랜잭션 삭제 함수 // 트랜잭션 삭제 함수 - 안정성 개선
const deleteTransaction = useCallback((transactionId: string) => { const deleteTransaction = useCallback((transactionId: string) => {
console.log('트랜잭션 삭제:', transactionId); // 이미 삭제 중이면 중복 삭제 방지
if (isDeleting) {
console.log('이미 삭제 작업이 진행 중입니다.');
return;
}
console.log('트랜잭션 삭제 시작:', transactionId);
// 중복 삭제 방지 // 중복 삭제 방지
if (lastDeletedId === transactionId) { if (lastDeletedId === transactionId) {
@@ -82,24 +90,50 @@ export const useTransactionState = () => {
return; return;
} }
setIsDeleting(true);
setLastDeletedId(transactionId); setLastDeletedId(transactionId);
setTransactions(prev => { try {
const updated = prev.filter(transaction => transaction.id !== transactionId); setTransactions(prev => {
saveTransactionsToStorage(updated); // 기존 트랜잭션 목록 백업 (문제 발생 시 복원용)
const originalTransactions = [...prev];
// 토스트는 한 번만 호출
toast({ // 삭제할 항목 필터링
title: "지출이 삭제되었습니다", const updated = prev.filter(transaction => transaction.id !== transactionId);
description: "지출 항목이 성공적으로 삭제되었습니다.",
// 항목이 실제로 삭제되었는지 확인
if (updated.length === originalTransactions.length) {
console.log('삭제할 트랜잭션을 찾을 수 없음:', transactionId);
setIsDeleting(false);
return originalTransactions;
}
// 저장소에 업데이트된 목록 저장
saveTransactionsToStorage(updated);
// 토스트 메시지 표시
toast({
title: "지출이 삭제되었습니다",
description: "지출 항목이 성공적으로 삭제되었습니다.",
});
return updated;
}); });
} catch (error) {
return updated; console.error('트랜잭션 삭제 중 오류 발생:', error);
}); toast({
title: "삭제 실패",
// 5초 후 lastDeletedId 초기화 description: "지출 항목 삭제 중 오류가 발생했습니다.",
setTimeout(() => setLastDeletedId(null), 5000); variant: "destructive"
}, [lastDeletedId]); });
} finally {
// 삭제 상태 초기화 (1초 후)
setTimeout(() => {
setIsDeleting(false);
setLastDeletedId(null);
}, 1000);
}
}, [lastDeletedId, isDeleting]);
// 트랜잭션 초기화 함수 // 트랜잭션 초기화 함수
const resetTransactions = useCallback(() => { const resetTransactions = useCallback(() => {

View File

@@ -4,7 +4,7 @@ import { clearAllCategoryBudgets } from './categoryStorage';
import { clearAllBudgetData } from './budgetStorage'; import { clearAllBudgetData } from './budgetStorage';
import { DEFAULT_CATEGORY_BUDGETS } from '../budgetUtils'; import { DEFAULT_CATEGORY_BUDGETS } from '../budgetUtils';
import { getInitialBudgetData } 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', 'categoryBudgets',
'budgetData', 'budgetData',
'budget', 'budget',
'monthlyExpenses', // 월간 지출 데이터 'monthlyExpenses',
'categorySpending', // 카테고리별 지출 데이터 'categorySpending',
'expenseAnalytics', // 지출 분석 데이터 'expenseAnalytics',
'expenseHistory', // 지출 이력 'expenseHistory',
'budgetHistory', // 예산 이력 'budgetHistory',
'analyticsCache', // 분석 캐시 데이터 'analyticsCache',
'monthlyTotals', // 월간 합계 데이터 'monthlyTotals',
'analytics', // 분석 페이지 데이터 'analytics',
'dailyBudget', // 일일 예산 'dailyBudget',
'weeklyBudget', // 주간 예산 'weeklyBudget',
'monthlyBudget', // 월간 예산 'monthlyBudget',
'chartData', // 차트 데이터 'chartData',
]; ];
try { try {
@@ -42,6 +42,7 @@ export const resetAllData = (): void => {
dataKeys.forEach(key => { dataKeys.forEach(key => {
console.log(`삭제 중: ${key}`); console.log(`삭제 중: ${key}`);
localStorage.removeItem(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('categoryBudgets_backup', JSON.stringify(DEFAULT_CATEGORY_BUDGETS));
localStorage.setItem('transactions_backup', JSON.stringify([])); localStorage.setItem('transactions_backup', JSON.stringify([]));
// 이벤트 발생시켜 데이터 로드 트리거 // 이벤트 발생시켜 데이터 로드 트리거 - 이벤트 순서 최적화
try { const events = [
window.dispatchEvent(new Event('transactionUpdated')); new Event('transactionUpdated'),
window.dispatchEvent(new Event('budgetDataUpdated')); new Event('budgetDataUpdated'),
window.dispatchEvent(new Event('categoryBudgetsUpdated')); new Event('categoryBudgetsUpdated'),
window.dispatchEvent(new StorageEvent('storage')); new StorageEvent('storage')
} catch (e) { ];
console.error('이벤트 발생 오류:', e);
} // 모든 이벤트 동시에 발생
events.forEach(event => window.dispatchEvent(event));
// 중요: 사용자 설정 값 복원 (백업한 값이 있는 경우) // 중요: 사용자 설정 값 복원 (백업한 값이 있는 경우)
if (dontShowWelcomeValue) { if (dontShowWelcomeValue) {

View File

@@ -10,24 +10,41 @@ export const loadTransactionsFromStorage = (): Transaction[] => {
// 메인 스토리지에서 먼저 시도 // 메인 스토리지에서 먼저 시도
const storedTransactions = localStorage.getItem('transactions'); const storedTransactions = localStorage.getItem('transactions');
if (storedTransactions) { if (storedTransactions) {
const parsedData = JSON.parse(storedTransactions); try {
console.log('트랜잭션 로드 완료, 항목 수:', parsedData.length); const parsedData = JSON.parse(storedTransactions);
return parsedData; 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'); const backupTransactions = localStorage.getItem('transactions_backup');
if (backupTransactions) { if (backupTransactions) {
const parsedBackup = JSON.parse(backupTransactions); try {
console.log('백업에서 트랜잭션 복구, 항목 수:', parsedBackup.length); const parsedBackup = JSON.parse(backupTransactions);
// 메인 스토리지도 복구 console.log('백업에서 트랜잭션 복구, 항목 수:', parsedBackup.length);
localStorage.setItem('transactions', backupTransactions); // 메인 스토리지도 복구
return parsedBackup; localStorage.setItem('transactions', backupTransactions);
return parsedBackup;
} catch (e) {
console.error('백업 트랜잭션 데이터 파싱 오류:', e);
}
} }
} catch (error) { } catch (error) {
console.error('트랜잭션 데이터 파싱 오류:', error); console.error('트랜잭션 데이터 로드 중 오류:', error);
} }
// 데이터가 없을 경우 빈 배열 반환 (샘플 데이터 생성하지 않음)
// 데이터가 없을 경우 빈 배열 반환
console.log('트랜잭션 데이터 없음, 빈 배열 반환');
return []; return [];
}; };
@@ -36,6 +53,8 @@ export const loadTransactionsFromStorage = (): Transaction[] => {
*/ */
export const saveTransactionsToStorage = (transactions: Transaction[]): void => { export const saveTransactionsToStorage = (transactions: Transaction[]): void => {
try { try {
console.log('트랜잭션 저장 시작, 항목 수:', transactions.length);
// 먼저 문자열로 변환 // 먼저 문자열로 변환
const dataString = JSON.stringify(transactions); const dataString = JSON.stringify(transactions);
@@ -49,6 +68,9 @@ export const saveTransactionsToStorage = (transactions: Transaction[]): void =>
// 스토리지 이벤트 수동 트리거 (동일 창에서도 감지하기 위함) // 스토리지 이벤트 수동 트리거 (동일 창에서도 감지하기 위함)
try { try {
window.dispatchEvent(new Event('transactionUpdated')); window.dispatchEvent(new Event('transactionUpdated'));
window.dispatchEvent(new CustomEvent('transactionChanged', {
detail: { type: 'save', count: transactions.length }
}));
window.dispatchEvent(new StorageEvent('storage', { window.dispatchEvent(new StorageEvent('storage', {
key: 'transactions', key: 'transactions',
newValue: dataString newValue: dataString
@@ -88,6 +110,9 @@ export const clearAllTransactions = (): void => {
// 스토리지 이벤트 수동 트리거 // 스토리지 이벤트 수동 트리거
window.dispatchEvent(new Event('transactionUpdated')); window.dispatchEvent(new Event('transactionUpdated'));
window.dispatchEvent(new CustomEvent('transactionChanged', {
detail: { type: 'clear' }
}));
window.dispatchEvent(new StorageEvent('storage', { window.dispatchEvent(new StorageEvent('storage', {
key: 'transactions', key: 'transactions',
newValue: emptyData newValue: emptyData

View File

@@ -1,70 +1,133 @@
import { useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { BudgetPeriod } from './types'; import { BudgetData, BudgetPeriod, Transaction } from './types';
import { useTransactionState } from './hooks/useTransactionState';
import { useCategoryBudgetState } from './hooks/useCategoryBudgetState';
import { useBudgetDataState } from './hooks/useBudgetDataState'; import { useBudgetDataState } from './hooks/useBudgetDataState';
import { useCategorySpending } from './hooks/useCategorySpending'; import { useCategoryBudgetState } from './hooks/useCategoryBudgetState';
import { useBudgetBackup } from './hooks/useBudgetBackup'; import { useTransactionState } from './hooks/useTransactionState';
import { useBudgetReset } from './hooks/useBudgetReset'; import { calculateCategorySpending } from './budgetUtils';
import { useExtendedBudgetUpdate } from './hooks/useExtendedBudgetUpdate'; import { toast } from '@/hooks/useToast.wrapper';
import { loadCategoryBudgetsFromStorage, saveCategoryBudgetsToStorage } from './storage';
/**
* 예산 상태 관리를 위한 메인 훅
*/
export const useBudgetState = () => { export const useBudgetState = () => {
// 상태 관리 훅 사용 // 트랜잭션 상태 관리
const { const {
transactions, transactions,
addTransaction, addTransaction,
updateTransaction, updateTransaction,
deleteTransaction, deleteTransaction
resetTransactions
} = useTransactionState(); } = useTransactionState();
const { // 예산 데이터 상태 관리
categoryBudgets, const {
setCategoryBudgets, budgetData,
selectedTab,
setSelectedTab,
handleBudgetGoalUpdate,
resetBudgetData
} = useBudgetDataState(transactions);
// 카테고리 예산 상태 관리
const {
categoryBudgets,
updateCategoryBudgets, updateCategoryBudgets,
resetCategoryBudgets resetCategoryBudgets
} = useCategoryBudgetState(); } = useCategoryBudgetState();
const { // 카테고리별 지출 계산
budgetData, const getCategorySpending = useCallback(() => {
selectedTab, return calculateCategorySpending(transactions, categoryBudgets);
setSelectedTab, }, [transactions, categoryBudgets]);
handleBudgetGoalUpdate,
resetBudgetData: resetBudgetDataInternal // 예산 목표 업데이트 함수 (기존 함수 래핑)
} = useBudgetDataState(transactions); const handleBudgetUpdate = useCallback((
type: BudgetPeriod,
const { getCategorySpending } = useCategorySpending(transactions, categoryBudgets); amount: number,
newCategoryBudgets?: Record<string, number>
// 자동 백업 사용 ) => {
useBudgetBackup(budgetData, categoryBudgets, transactions); console.log(`예산 업데이트 시작: ${type}, 금액: ${amount}, 카테고리 예산:`, newCategoryBudgets);
// 확장된 예산 업데이트 로직 사용 try {
const { extendedBudgetGoalUpdate } = useExtendedBudgetUpdate( // 금액이 유효한지 확인
budgetData, if (isNaN(amount) || amount <= 0) {
categoryBudgets, console.error('유효하지 않은 예산 금액:', amount);
handleBudgetGoalUpdate, toast({
updateCategoryBudgets title: "예산 설정 오류",
); description: "유효한 예산 금액을 입력해주세요.",
variant: "destructive"
// 리셋 로직 사용 });
const { resetBudgetData } = useBudgetReset( return;
resetTransactions, }
resetCategoryBudgets,
resetBudgetDataInternal // 카테고리 예산이 제공된 경우
); 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 { return {
// 데이터
transactions, transactions,
categoryBudgets,
budgetData, budgetData,
categoryBudgets,
selectedTab, selectedTab,
// 상태 변경 함수
setSelectedTab, setSelectedTab,
addTransaction, addTransaction,
updateTransaction, updateTransaction,
deleteTransaction, deleteTransaction,
handleBudgetGoalUpdate: extendedBudgetGoalUpdate, handleBudgetGoalUpdate: handleBudgetUpdate, // 래핑된 함수 사용
// 도우미 함수
getCategorySpending, getCategorySpending,
resetBudgetData
// 데이터 초기화
resetBudgetData: resetAllData
}; };
}; };

View File

@@ -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"
});
}
};

View File

@@ -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 };
};

View File

@@ -0,0 +1,31 @@
import { useState, useEffect } from 'react';
import { getLastSyncTime } from '@/utils/syncUtils';
/**
* 동기화 상태와 마지막 동기화 시간을 관리하는 커스텀 훅
*/
export const useSyncStatus = () => {
const [lastSync, setLastSync] = useState<string | null>(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 };
};

View File

@@ -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;
}
};

View File

@@ -1,3 +1,3 @@
export const TOAST_LIMIT = 5 // 최대 5개로 제한 export const TOAST_LIMIT = 5 // 최대 5개로 제한
export const TOAST_REMOVE_DELAY = 3000 // 3초 후 DOM에서 제거 (5초에서 3초로 변경) export const TOAST_REMOVE_DELAY = 3000 // 3초 후 DOM에서 제거

View File

@@ -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 = (): string => {
export const getCurrentMonth = () => { const now = new Date();
const today = new Date(); const month = now.getMonth(); // 0-indexed
return MONTHS_KR[today.getMonth()]; 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]}`;
}
}; };

View File

@@ -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<React.SetStateAction<Transaction[]>>
) => {
// 삭제 중인 트랜잭션 추적
const pendingDeletionRef = useRef<Set<string>>(new Set());
const { user } = useAuth();
// 삭제 요청 타임스탬프 (중복 방지)
const lastDeleteTimeRef = useRef<Record<string, number>>({});
// 삭제 핵심 함수
const deleteTransactionCore = useDeleteTransactionCore(
transactions,
setTransactions,
user,
pendingDeletionRef
);
// 삭제 함수 (안정성 최적화)
const deleteTransaction = useCallback((id: string): Promise<boolean> => {
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;
};

View File

@@ -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
};
};

View File

@@ -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;
}

View File

@@ -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<FilteringProps, 'transactions' | 'selectedMonth' | 'searchQuery' | 'setFilteredTransactions'>) => {
// 거래 필터링 함수
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
};
};

View File

@@ -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
};
};

View File

@@ -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
};
};

View File

@@ -2,9 +2,47 @@
import { Transaction } from '@/components/TransactionCard'; import { Transaction } from '@/components/TransactionCard';
import { supabase } from '@/lib/supabase'; import { supabase } from '@/lib/supabase';
import { isSyncEnabled } from '@/utils/syncUtils'; 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<Transaction[]> => { export const syncTransactionsWithSupabase = async (user: any, transactions: Transaction[]): Promise<Transaction[]> => {
if (!user || !isSyncEnabled()) return transactions; if (!user || !isSyncEnabled()) return transactions;
@@ -51,17 +89,20 @@ export const syncTransactionsWithSupabase = async (user: any, transactions: Tran
return transactions; return transactions;
}; };
// Supabase에 트랜잭션 업데이트 // Supabase에 트랜잭션 업데이트 - Cloud 최적화 버전
export const updateTransactionInSupabase = async (user: any, transaction: Transaction): Promise<void> => { export const updateTransactionInSupabase = async (user: any, transaction: Transaction): Promise<void> => {
if (!user || !isSyncEnabled()) return; if (!user || !isSyncEnabled()) return;
try { try {
// 날짜를 ISO 형식으로 변환
const isoDate = convertDateToISO(transaction.date);
const { error } = await supabase.from('transactions') const { error } = await supabase.from('transactions')
.upsert({ .upsert({
user_id: user.id, user_id: user.id,
title: transaction.title, title: transaction.title,
amount: transaction.amount, amount: transaction.amount,
date: transaction.date, date: isoDate, // ISO 형식 사용
category: transaction.category, category: transaction.category,
type: transaction.type, type: transaction.type,
transaction_id: transaction.id transaction_id: transaction.id
@@ -69,13 +110,15 @@ export const updateTransactionInSupabase = async (user: any, transaction: Transa
if (error) { if (error) {
console.error('트랜잭션 업데이트 오류:', error); console.error('트랜잭션 업데이트 오류:', error);
} else {
console.log('Supabase 트랜잭션 업데이트 성공:', transaction.id);
} }
} catch (error) { } catch (error) {
console.error('Supabase 업데이트 오류:', error); console.error('Supabase 업데이트 오류:', error);
} }
}; };
// Supabase에서 트랜잭션 삭제 // Supabase에서 트랜잭션 삭제 - Cloud 최적화 버전
export const deleteTransactionFromSupabase = async (user: any, transactionId: string): Promise<void> => { export const deleteTransactionFromSupabase = async (user: any, transactionId: string): Promise<void> => {
if (!user || !isSyncEnabled()) return; if (!user || !isSyncEnabled()) return;
@@ -86,6 +129,8 @@ export const deleteTransactionFromSupabase = async (user: any, transactionId: st
if (error) { if (error) {
console.error('트랜잭션 삭제 오류:', error); console.error('트랜잭션 삭제 오류:', error);
} else {
console.log('Supabase 트랜잭션 삭제 성공:', transactionId);
} }
} catch (error) { } catch (error) {
console.error('Supabase 삭제 오류:', error); console.error('Supabase 삭제 오류:', error);

View File

@@ -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<React.SetStateAction<Transaction[]>>,
user: any,
pendingDeletionRef: MutableRefObject<Set<string>>
) => {
return useCallback(async (id: string): Promise<boolean> => {
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<boolean>((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]);
};

View File

@@ -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<Set<string>>
): Promise<boolean> => {
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);
}
});
};

View File

@@ -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);
};

View File

@@ -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<React.SetStateAction<Transaction[]>>
) => {
// 삭제 중인 트랜잭션 추적
const pendingDeletionRef = useRef<Set<string>>(new Set());
const { user } = useAuth();
// 삭제 요청 타임스탬프 추적 (중복 방지)
const lastDeleteTimeRef = useRef<Record<string, number>>({});
// 삭제 핵심 로직
const deleteTransactionCore = useDeleteTransactionCore(
transactions,
setTransactions,
user,
pendingDeletionRef
);
// 삭제 함수 - 완전 재구현
const deleteTransaction = useCallback((id: string): Promise<boolean> => {
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;
};

View File

@@ -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<React.SetStateAction<Transaction[]>>,
user: any,
pendingDeletionRef: MutableRefObject<Set<string>>
) => {
return useCallback(async (id: string): Promise<boolean> => {
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<boolean>((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]);
};

View File

@@ -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<React.SetStateAction<Transaction[]>>
) => {
// 삭제 기능 (전용 훅 사용)
const deleteTransaction = useDeleteTransaction(transactions, setTransactions);
// 업데이트 기능
const handleUpdateTransaction = useCallback((
updatedTransaction: Transaction
) => {
const updateTransaction = useUpdateTransaction(transactions, setTransactions);
updateTransaction(updatedTransaction);
}, [transactions, setTransactions]);
return {
updateTransaction: handleUpdateTransaction,
deleteTransaction
};
};

View File

@@ -0,0 +1,12 @@
import { Transaction } from '@/components/TransactionCard';
export interface TransactionOperationProps {
transactions: Transaction[];
setTransactions: React.Dispatch<React.SetStateAction<Transaction[]>>;
}
export interface TransactionOperationReturn {
updateTransaction: (updatedTransaction: Transaction) => void;
deleteTransaction: (id: string) => Promise<boolean>;
}

View File

@@ -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<React.SetStateAction<Transaction[]>>
) => {
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]);
};

View File

@@ -44,13 +44,13 @@ export const useTransactionsCore = () => {
handlePrevMonth, handlePrevMonth,
handleNextMonth, handleNextMonth,
getTotalExpenses getTotalExpenses
} = useTransactionsFiltering( } = useTransactionsFiltering({
transactions, transactions,
selectedMonth, selectedMonth,
setSelectedMonth, setSelectedMonth,
searchQuery, searchQuery,
setFilteredTransactions setFilteredTransactions
); });
// 트랜잭션 작업 // 트랜잭션 작업
const { const {
@@ -61,11 +61,12 @@ export const useTransactionsCore = () => {
setTransactions setTransactions
); );
// 이벤트 리스너 // 이벤트 리스너 - 삭제 이벤트 포함
useTransactionsEvents(loadTransactions, refreshKey); useTransactionsEvents(loadTransactions, refreshKey);
// 데이터 강제 새로고침 // 데이터 강제 새로고침
const refreshTransactions = useCallback(() => { const refreshTransactions = useCallback(() => {
console.log('트랜잭션 강제 새로고침');
setRefreshKey(prev => prev + 1); setRefreshKey(prev => prev + 1);
loadTransactions(); loadTransactions();
}, [loadTransactions, setRefreshKey]); }, [loadTransactions, setRefreshKey]);

View File

@@ -2,58 +2,111 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
/** /**
* 트랜잭션 이벤트 관련 * 트랜잭션 이벤트 리스너
* 각종 이벤트 리스너를 설정합니다. * 트랜잭션 업데이트 이벤트 리스합니다.
*/ */
export const useTransactionsEvents = ( export const useTransactionsEvents = (
loadTransactions: () => void, loadTransactions: () => void,
refreshKey: number refreshKey: number
) => { ) => {
// 이벤트 리스너 설정
useEffect(() => { useEffect(() => {
console.log('useTransactions - 이벤트 리스너 설정'); console.log('useTransactions - 이벤트 리스너 설정');
// 트랜잭션 업데이트 이벤트 리스너 // 바운싱 방지 변수
const handleTransactionUpdated = () => { let isProcessing = false;
console.log('트랜잭션 업데이트 이벤트 감지됨');
loadTransactions(); // 트랜잭션 업데이트 이벤트
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) { if (e.key === 'transactions' || e.key === null) {
console.log('로컬 스토리지 변경 감지:', e.key); console.log('스토리지 이벤트 감지:', e.key);
loadTransactions();
// 처리 중 중복 호출 방지
if (isProcessing) return;
isProcessing = true;
setTimeout(() => {
loadTransactions();
isProcessing = false;
}, 150);
} }
}; };
// 페이지 포커스/가시성 이벤트 리스너 // 포커스 이벤트
const handleFocus = () => { const handleFocus = () => {
console.log('창 포커스 - 트랜잭션 새로고침'); console.log('창 포커스: 트랜잭션 새로고침');
loadTransactions();
}; // 처리 중 중복 호출 방지
if (isProcessing) return;
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') { isProcessing = true;
console.log('페이지 가시성 변경 - 트랜잭션 새로고침'); setTimeout(() => {
loadTransactions(); loadTransactions();
} isProcessing = false;
}, 200);
}; };
// 이벤트 리스너 등록 // 이벤트 리스너 등록
window.addEventListener('transactionUpdated', handleTransactionUpdated); window.addEventListener('transactionUpdated', handleTransactionUpdate);
window.addEventListener('storage', handleStorageChange); window.addEventListener('transactionDeleted', handleTransactionDelete);
window.addEventListener('transactionChanged', handleTransactionChange as EventListener);
window.addEventListener('storage', handleStorageEvent);
window.addEventListener('focus', handleFocus); window.addEventListener('focus', handleFocus);
document.addEventListener('visibilitychange', handleVisibilityChange);
// 컴포넌트 마운트시에만 수동으로 트랜잭션 업데이트 이벤트 발생 // 새로고침 키가 변경되면 데이터 로드
window.dispatchEvent(new Event('transactionUpdated')); if (!isProcessing) {
loadTransactions();
}
// 클린업 함수
return () => { return () => {
window.removeEventListener('transactionUpdated', handleTransactionUpdated); console.log('useTransactions - 이벤트 리스너 제거');
window.removeEventListener('storage', handleStorageChange); window.removeEventListener('transactionUpdated', handleTransactionUpdate);
window.removeEventListener('transactionDeleted', handleTransactionDelete);
window.removeEventListener('transactionChanged', handleTransactionChange as EventListener);
window.removeEventListener('storage', handleStorageEvent);
window.removeEventListener('focus', handleFocus); window.removeEventListener('focus', handleFocus);
document.removeEventListener('visibilitychange', handleVisibilityChange);
}; };
}, [loadTransactions, refreshKey]); }, [loadTransactions, refreshKey]);
}; };

View File

@@ -1,55 +1,5 @@
import { useEffect } from 'react'; import { useTransactionsFiltering } from './filterOperations';
import { Transaction } from '@/components/TransactionCard';
import {
filterTransactionsByMonth,
filterTransactionsByQuery,
calculateTotalExpenses
} from './filterUtils';
import { getPrevMonth, getNextMonth } from './dateUtils';
/** // 기존 훅을 그대로 내보내기
* 트랜잭션 필터링 관련 훅 export { useTransactionsFiltering };
* 월별 및 검색어 필터링 기능을 제공합니다.
*/
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
};
};

View File

@@ -25,9 +25,11 @@ export const useTransactionsLoader = (
const localTransactions = loadTransactionsFromStorage(); const localTransactions = loadTransactionsFromStorage();
setTransactions(localTransactions); setTransactions(localTransactions);
// 예산 가져오기 // 예산 가져오기 (월간 예산만 설정)
const budgetAmount = loadBudgetFromStorage(); const budgetAmount = loadBudgetFromStorage();
setTotalBudget(budgetAmount); setTotalBudget(budgetAmount);
console.log('로드된 예산 금액:', budgetAmount);
} catch (err) { } catch (err) {
console.error('트랜잭션 로드 중 오류:', err); console.error('트랜잭션 로드 중 오류:', err);
setError('데이터를 불러오는 중 문제가 발생했습니다.'); setError('데이터를 불러오는 중 문제가 발생했습니다.');

View File

@@ -1,251 +1,2 @@
import { useCallback, useRef } from 'react'; export { useTransactionsOperations } from './transactionOperations';
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<React.SetStateAction<Transaction[]>>
) => {
// 현재 진행 중인 삭제 작업 추적을 위한 ref
const pendingDeletionRef = useRef<Set<string>>(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<boolean> => {
// pendingDeletionRef 초기화 확인
if (!pendingDeletionRef.current) {
pendingDeletionRef.current = new Set<string>();
}
// 기존 promise를 변수로 저장해서 참조 가능하게 함
const promiseObj = new Promise<boolean>((resolve, reject) => {
// 삭제 작업 취소 플래그 초기화
let isCanceled = false;
let timeoutId: ReturnType<typeof setTimeout> | 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
};
};

View File

@@ -3,8 +3,9 @@ import { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useToast } from '@/hooks/useToast.wrapper'; import { useToast } from '@/hooks/useToast.wrapper';
import { resetAllStorageData } from '@/utils/storageUtils'; import { resetAllStorageData } from '@/utils/storageUtils';
import { clearCloudData } from '@/utils/syncUtils'; import { clearCloudData } from '@/utils/sync/clearCloudData';
import { useAuth } from '@/contexts/auth/AuthProvider'; import { useAuth } from '@/contexts/auth/AuthProvider';
import { isSyncEnabled, setSyncEnabled } from '@/utils/sync/syncSettings';
export interface DataResetResult { export interface DataResetResult {
isCloudResetSuccess: boolean | null; isCloudResetSuccess: boolean | null;
@@ -22,6 +23,10 @@ export const useDataReset = () => {
setIsResetting(true); setIsResetting(true);
console.log('모든 데이터 초기화 시작'); console.log('모든 데이터 초기화 시작');
// 현재 동기화 설정 저장
const syncWasEnabled = isSyncEnabled();
console.log('데이터 초기화 전 동기화 상태:', syncWasEnabled ? '활성화' : '비활성화');
// 클라우드 데이터 초기화 먼저 시도 (로그인 상태인 경우) // 클라우드 데이터 초기화 먼저 시도 (로그인 상태인 경우)
let cloudResetSuccess = false; let cloudResetSuccess = false;
if (user) { 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('transactionUpdated'));
window.dispatchEvent(new Event('budgetDataUpdated')); window.dispatchEvent(new Event('budgetDataUpdated'));
window.dispatchEvent(new Event('categoryBudgetsUpdated')); window.dispatchEvent(new Event('categoryBudgetsUpdated'));
window.dispatchEvent(new StorageEvent('storage')); window.dispatchEvent(new StorageEvent('storage'));
window.dispatchEvent(new Event('auth-state-changed')); // 동기화 상태 변경 이벤트 추가
// 클라우드 초기화 상태에 따라 다른 메시지 표시 // 클라우드 초기화 상태에 따라 다른 메시지 표시
if (user) { if (user) {
if (cloudResetSuccess) { if (cloudResetSuccess) {
toast({ toast({
title: "모든 데이터가 초기화되었습니다.", title: "모든 데이터가 초기화되었습니다.",
description: "로컬 및 클라우드의 모든 데이터가 초기화되었습니다.", description: "로컬 및 클라우드의 모든 데이터가 초기화되었으며, 동기화 설정이 비활성화되었습니다.",
}); });
} else { } else {
toast({ toast({
title: "로컬 데이터만 초기화됨", title: "로컬 데이터만 초기화됨",
description: "로컬 데이터는 초기화되었지만, 클라우드 데이터 초기화 중 문제가 발생했습니다.", description: "로컬 데이터는 초기화되었지만, 클라우드 데이터 초기화 중 문제가 발생했습니다. 동기화 설정이 비활성화되었습니다.",
variant: "destructive" variant: "destructive"
}); });
} }
@@ -111,8 +124,8 @@ export const useDataReset = () => {
console.log('모든 데이터 초기화 완료'); console.log('모든 데이터 초기화 완료');
// 초기화 후 설정 페이지로 이동 (타임아웃으로 약간 지연) // 페이지 리프레시 대신 navigate 사용 (딜레이 제거)
setTimeout(() => navigate('/settings'), 500); navigate('/settings', { replace: true });
return { isCloudResetSuccess: cloudResetSuccess }; return { isCloudResetSuccess: cloudResetSuccess };
} catch (error) { } catch (error) {

View File

@@ -1,169 +1,18 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useAuth } from '@/contexts/auth'; import { useAuth } from '@/contexts/auth';
import { toast } from '@/hooks/useToast.wrapper'; import { useSyncToggle } from './sync/useSyncToggle';
import { import { useManualSync } from './sync/useManualSync';
isSyncEnabled, import { useSyncStatus } from './sync/useSyncStatus';
setSyncEnabled,
getLastSyncTime,
trySyncAllData,
SyncResult
} from '@/utils/syncUtils';
/** /**
* 동기화 설정 관리를 위한 커스텀 훅 * 동기화 설정 관리를 위한 커스텀 훅
*/ */
export const useSyncSettings = () => { export const useSyncSettings = () => {
const [enabled, setEnabled] = useState(isSyncEnabled());
const [syncing, setSyncing] = useState(false);
const [lastSync, setLastSync] = useState<string | null>(getLastSyncTime());
const { user } = useAuth(); const { user } = useAuth();
const { enabled, setEnabled, handleSyncToggle } = useSyncToggle();
// 사용자 로그인 상태 변경 감지 const { syncing, handleManualSync } = useManualSync(user);
useEffect(() => { const { lastSync, formatLastSyncTime } = useSyncStatus();
// 사용자 로그인 상태에 따라 동기화 설정 업데이트
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()}`;
};
return { return {
enabled, enabled,

View File

@@ -10,7 +10,7 @@ let supabaseClient;
try { try {
console.log(`Supabase 클라이언트 생성 중: ${supabaseUrl}`); console.log(`Supabase 클라이언트 생성 중: ${supabaseUrl}`);
// Supabase 클라이언트 생성 // Supabase 클라이언트 생성 - Cloud 환경에 최적화
supabaseClient = createClient(supabaseUrl, supabaseAnonKey, { supabaseClient = createClient(supabaseUrl, supabaseAnonKey, {
auth: { auth: {
autoRefreshToken: true, autoRefreshToken: true,
@@ -18,7 +18,7 @@ try {
} }
}); });
console.log('Supabase 클라이언트가 생성되었습니다.'); console.log('Supabase 클라이언트가 성공적으로 생성되었습니다.');
} catch (error) { } catch (error) {
console.error('Supabase 클라이언트 생성 오류:', error); console.error('Supabase 클라이언트 생성 오류:', error);

View File

@@ -8,20 +8,20 @@ export const getSupabaseKey = () => {
return "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InFuZXJlYnR2d3dmb2JmemRvZnR4Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDIwNTE0MzgsImV4cCI6MjA1NzYyNzQzOH0.Wm7h2DUhoQbeANuEM3wm2tz22ITrVEW8FizyLgIVmv8"; return "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InFuZXJlYnR2d3dmb2JmemRvZnR4Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDIwNTE0MzgsImV4cCI6MjA1NzYyNzQzOH0.Wm7h2DUhoQbeANuEM3wm2tz22ITrVEW8FizyLgIVmv8";
}; };
// Supabase 키 유효성 검사 // Supabase 키 유효성 검사 - Cloud 환경에서는 항상 유효함
export const isValidSupabaseKey = () => { export const isValidSupabaseKey = () => {
return true; // Supabase Cloud에서는 항상 유효함 return true;
}; };
// CORS 프록시 관련 함수들 // 다음 함수들은 Cloud 환경에서는 필요 없지만 호환성을 위해 유지
export const isCorsProxyEnabled = () => { export const isCorsProxyEnabled = () => {
return false; // Supabase Cloud에서는 CORS 프록시가 필요 없음 return false;
}; };
export const getProxyType = () => { export const getProxyType = () => {
return 'none'; // Supabase Cloud에서는 프록시가 필요 없음 return 'none';
}; };
export const getOriginalSupabaseUrl = () => { export const getOriginalSupabaseUrl = () => {
return getSupabaseUrl(); // 원본 URL 반환 (프록시 없음) return getSupabaseUrl();
}; };

View File

@@ -1,12 +1,13 @@
import React, { useState, useEffect } from 'react'; import React, { useEffect, useState, useRef } from 'react';
import NavBar from '@/components/NavBar'; import NavBar from '@/components/NavBar';
import TransactionCard from '@/components/TransactionCard';
import AddTransactionButton from '@/components/AddTransactionButton'; 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 { 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 Transactions = () => {
const { const {
@@ -17,13 +18,18 @@ const Transactions = () => {
setSearchQuery, setSearchQuery,
handlePrevMonth, handlePrevMonth,
handleNextMonth, handleNextMonth,
updateTransaction, refreshTransactions,
deleteTransaction,
totalExpenses, totalExpenses,
deleteTransaction
} = useTransactions(); } = useTransactions();
const { budgetData } = useBudget(); const { budgetData } = useBudget();
const [isDataLoaded, setIsDataLoaded] = useState(false); const [isDataLoaded, setIsDataLoaded] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
// 더블 클릭 방지용 래퍼
const deletionTimestampRef = useRef<Record<string, number>>({});
// 데이터 로드 상태 관리 // 데이터 로드 상태 관리
useEffect(() => { useEffect(() => {
@@ -32,31 +38,87 @@ const Transactions = () => {
} }
}, [budgetData, isLoading]); }, [budgetData, isLoading]);
// 트랜잭션을 날짜별로 그룹화 // 트랜잭션 삭제 핸들러 - 완전히 개선된 버전
const groupedTransactions: Record<string, typeof transactions> = {}; const handleTransactionDelete = async (id: string): Promise<boolean> => {
// 삭제 중인지 확인
transactions.forEach(transaction => { if (isProcessing || deletingId) {
const datePart = transaction.date.split(',')[0]; console.log('이미 삭제 작업이 진행 중입니다:', deletingId);
if (!groupedTransactions[datePart]) { return true;
groupedTransactions[datePart] = [];
} }
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<boolean>((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(() => { useEffect(() => {
const handleVisibilityChange = () => { const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') { if (document.visibilityState === 'visible' && !isProcessing) {
console.log('거래내역 페이지 보임 - 데이터 새로고침'); console.log('거래내역 페이지 보임 - 데이터 새로고침');
// 상태 업데이트 트리거 refreshTransactions();
setIsDataLoaded(prev => !prev);
} }
}; };
const handleFocus = () => { const handleFocus = () => {
console.log('거래내역 페이지 포커스 - 데이터 새로고침'); if (!isProcessing) {
// 상태 업데이트 트리거 console.log('거래내역 페이지 포커스 - 데이터 새로고침');
setIsDataLoaded(prev => !prev); refreshTransactions();
}
}; };
document.addEventListener('visibilitychange', handleVisibilityChange); document.addEventListener('visibilitychange', handleVisibilityChange);
@@ -66,117 +128,54 @@ const Transactions = () => {
document.removeEventListener('visibilitychange', handleVisibilityChange); document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener('focus', handleFocus); window.removeEventListener('focus', handleFocus);
}; };
}, []); }, [refreshTransactions, isProcessing]);
// 트랜잭션을 날짜별로 그룹화
const groupTransactionsByDate = (transactions: Transaction[]): Record<string, Transaction[]> => {
const grouped: Record<string, Transaction[]> = {};
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 ( return (
<div className="min-h-screen bg-neuro-background pb-24"> <div className="min-h-screen bg-neuro-background pb-24">
<div className="max-w-md mx-auto px-6"> <div className="max-w-md mx-auto px-6">
{/* Header */} <TransactionsHeader
<header className="py-8"> selectedMonth={selectedMonth}
<h1 className="text-2xl font-bold neuro-text mb-5"> </h1> searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
{/* Search */} handlePrevMonth={handlePrevMonth}
<div className="neuro-pressed mb-5 flex items-center px-4 py-3 rounded-xl"> handleNextMonth={handleNextMonth}
<Search size={18} className="text-gray-500 mr-2" /> budgetData={budgetData}
<input totalExpenses={totalExpenses}
type="text" isDisabled={isDisabled}
placeholder="지출 검색..." />
className="bg-transparent flex-1 outline-none text-sm"
value={searchQuery} <TransactionsContent
onChange={(e) => setSearchQuery(e.target.value)} isLoading={isLoading}
/> isProcessing={isProcessing}
</div> transactions={transactions}
groupedTransactions={groupedTransactions}
{/* Month Selector */} searchQuery={searchQuery}
<div className="flex items-center justify-between mb-5"> selectedMonth={selectedMonth}
<button setSearchQuery={setSearchQuery}
className="neuro-flat p-2 rounded-full" onTransactionDelete={handleTransactionDelete}
onClick={handlePrevMonth} isDisabled={isDisabled}
> />
<ChevronLeft size={20} />
</button>
<div className="flex items-center gap-2">
<Calendar size={18} className="text-neuro-income" />
<span className="font-medium text-lg">{selectedMonth}</span>
</div>
<button
className="neuro-flat p-2 rounded-full"
onClick={handleNextMonth}
>
<ChevronRight size={20} />
</button>
</div>
{/* Summary */}
<div className="grid grid-cols-2 gap-4 mb-8">
<div className="neuro-card">
<p className="text-sm text-gray-500 mb-1"> </p>
<p className="text-lg font-bold text-neuro-income">
{formatCurrency(budgetData?.monthly?.targetAmount || 0)}
</p>
</div>
<div className="neuro-card">
<p className="text-sm text-gray-500 mb-1"> </p>
<p className="text-lg font-bold text-neuro-income">
{formatCurrency(totalExpenses)}
</p>
</div>
</div>
</header>
{/* Loading State */}
{isLoading && (
<div className="flex justify-center items-center py-10">
<Loader2 className="h-8 w-8 animate-spin text-neuro-income" />
<span className="ml-2 text-gray-500"> ...</span>
</div>
)}
{/* Empty State */}
{!isLoading && transactions.length === 0 && (
<div className="text-center py-10">
<p className="text-gray-500 mb-3">
{searchQuery.trim()
? '검색 결과가 없습니다.'
: `${selectedMonth}에 등록된 지출이 없습니다.`}
</p>
{searchQuery.trim() && (
<button
className="text-neuro-income"
onClick={() => setSearchQuery('')}
>
</button>
)}
</div>
)}
{/* Transactions By Date */}
{!isLoading && transactions.length > 0 && (
<div className="space-y-6 mb-[50px]">
{Object.entries(groupedTransactions).map(([date, transactions]) => (
<div key={date}>
<div className="flex items-center gap-2 mb-3">
<div className="h-1 flex-1 neuro-pressed"></div>
<h2 className="text-sm font-medium text-gray-500">{date}</h2>
<div className="h-1 flex-1 neuro-pressed"></div>
</div>
<div className="grid gap-3">
{transactions.map(transaction => (
<TransactionCard
key={transaction.id}
transaction={transaction}
onUpdate={updateTransaction}
/>
))}
</div>
</div>
))}
</div>
)}
</div> </div>
<AddTransactionButton /> <AddTransactionButton />

View File

@@ -5,6 +5,8 @@ export * from './networkUtils';
export * from './responseUtils'; export * from './responseUtils';
export * from './validationUtils'; export * from './validationUtils';
export * from './handleNetworkError'; export * from './handleNetworkError';
export * from './loginUtils';
// 새로운 네트워크 모듈 직접 내보냅니다 (선택적) // 모듈 직접 접근을 위한 내보내기
export * from './network'; export * from './network';
export * from './login';

View File

@@ -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')
);
};

View File

@@ -0,0 +1,4 @@
// 로그인 관련 모든 유틸리티 내보내기
export * from './errorHandlers';
export * from './toastHandlers';

View File

@@ -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"
});
};

View File

@@ -1,68 +1,9 @@
import { toast } from "@/components/ui/use-toast"; // 이 파일은 하위 모듈로 분리된 로그인 유틸리티를 다시 내보냅니다.
// 향후 개발에서는 직접 하위 모듈을 임포트하는 것이 권장됩니다.
/** export {
* 로그인 오류 메시지를 처리하는 유틸리티 함수 getLoginErrorMessage,
*/ isCorsOrJsonError,
export const getLoginErrorMessage = (error: any): string => { showLoginSuccessToast,
let errorMessage = "로그인에 실패했습니다."; showLoginErrorToast
} from './login';
// 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')
);
};

View File

@@ -0,0 +1,7 @@
/**
* 호환성을 위한 더미 함수들 - Cloud 환경에서는 항상 false를 반환
*/
export const hasCorsIssue = (): boolean => false;
export const handleHttpUrlWithoutProxy = (): boolean => true;
export const logProxyInfo = (): void => {};

View File

@@ -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
};
}
};

View File

@@ -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: '네트워크 연결 또는 서버 주소를 확인하세요'
};
};

View File

@@ -1,9 +1,14 @@
// 네트워크 유틸리티 모듈 // 모든 네트워크 유틸리티 모듈 내보내기
export { export {
verifyServerConnection,
verifySupabaseConnection,
hasCorsIssue, hasCorsIssue,
handleHttpUrlWithoutProxy, handleHttpUrlWithoutProxy,
logProxyInfo, logProxyInfo
verifyServerConnection,
verifySupabaseConnection
} from './networkUtils'; } from './networkUtils';
// 직접 접근을 위한 개별 모듈도 내보내기
export * from './connectionVerifier';
export * from './enhancedVerifier';
export * from './compatUtils';

View File

@@ -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 {
*/ verifyServerConnection,
export const hasCorsIssue = (error: any): boolean => { verifySupabaseConnection,
if (!error) return false; hasCorsIssue,
handleHttpUrlWithoutProxy,
const errorMessage = error.message || ''; logProxyInfo
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: '네트워크 연결 또는 서버 주소를 확인하세요'
};
}; };

View File

@@ -65,7 +65,7 @@ export const loadBudgetFromStorage = (): number => {
return 0; return 0;
}; };
// 모든 데이터 완전히 초기화 // 모든 데이터 완전히 초기화 - 성능 최적화
export const resetAllStorageData = (): void => { export const resetAllStorageData = (): void => {
console.log('완전 초기화 시작 - resetAllStorageData'); console.log('완전 초기화 시작 - resetAllStorageData');
@@ -80,7 +80,7 @@ export const resetAllStorageData = (): void => {
// 모든 Storage 키 목록 (로그인 관련 항목 제외) // 모든 Storage 키 목록 (로그인 관련 항목 제외)
const keysToRemove = [ const keysToRemove = [
'transactions', 'transactions',
'budget', 'budget',
'monthlyExpenses', 'monthlyExpenses',
'budgetData', 'budgetData',
@@ -103,35 +103,34 @@ export const resetAllStorageData = (): void => {
'syncEnabled' 'syncEnabled'
]; ];
// 키 삭제 // 키 동시에 삭제 (성능 최적화)
keysToRemove.forEach(key => { keysToRemove.forEach(key => {
console.log(`삭제 중: ${key}`); console.log(`삭제 중: ${key}`);
localStorage.removeItem(key); localStorage.removeItem(key);
localStorage.removeItem(`${key}_backup`); // 백업 키도 함께 삭제
}); });
// 백업 키도 삭제 // 기본값으로 초기화 - 한번에 처리
keysToRemove.forEach(key => { const defaultData = {
localStorage.removeItem(`${key}_backup`); 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) { if (dontShowWelcomeValue) {
localStorage.setItem('dontShowWelcome', dontShowWelcomeValue); localStorage.setItem('dontShowWelcome', dontShowWelcomeValue);
@@ -154,13 +153,22 @@ export const resetAllStorageData = (): void => {
localStorage.setItem('supabase.auth.token', supabase); localStorage.setItem('supabase.auth.token', supabase);
} }
// 이벤트 발생 // 동기화 설정은 무조건 OFF로 설정
window.dispatchEvent(new Event('transactionUpdated')); localStorage.setItem('syncEnabled', 'false');
window.dispatchEvent(new Event('budgetDataUpdated')); console.log('동기화 설정이 OFF로 변경되었습니다');
window.dispatchEvent(new Event('categoryBudgetsUpdated'));
window.dispatchEvent(new StorageEvent('storage'));
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) { } catch (error) {
console.error('데이터 초기화 중 오류:', error); console.error('데이터 초기화 중 오류:', error);
} }

View File

@@ -11,6 +11,23 @@ export const downloadBudgets = async (userId: string): Promise<void> => {
try { try {
console.log('서버에서 예산 데이터 다운로드 시작'); 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([ const [budgetData, categoryData] = await Promise.all([
fetchBudgetData(userId), fetchBudgetData(userId),
@@ -19,16 +36,24 @@ export const downloadBudgets = async (userId: string): Promise<void> => {
// 예산 데이터 처리 // 예산 데이터 처리
if (budgetData) { if (budgetData) {
await processBudgetData(budgetData); await processBudgetData(budgetData, localBudgetData);
} else { } else {
console.log('서버에서 예산 데이터를 찾을 수 없음'); console.log('서버에서 예산 데이터를 찾을 수 없음');
// 로컬 데이터가 있으면 유지
if (localBudgetData) {
console.log('로컬 예산 데이터 유지');
}
} }
// 카테고리 예산 데이터 처리 // 카테고리 예산 데이터 처리
if (categoryData && categoryData.length > 0) { if (categoryData && categoryData.length > 0) {
await processCategoryBudgetData(categoryData); await processCategoryBudgetData(categoryData, localCategoryBudgets);
} else { } else {
console.log('서버에서 카테고리 예산 데이터를 찾을 수 없음'); console.log('서버에서 카테고리 예산 데이터를 찾을 수 없음');
// 로컬 데이터가 있으면 유지
if (localCategoryBudgets) {
console.log('로컬 카테고리 예산 데이터 유지');
}
} }
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); console.log('서버에서 예산 데이터 수신:', budgetData);
// 기존 로컬 데이터 가져오기 // 서버 예산이 0이고 로컬 예산이 있으면 로컬 데이터 유지
const localBudgetDataStr = localStorage.getItem('budgetData'); if (budgetData.total_budget === 0 && localBudgetDataStr) {
console.log('서버 예산이 0이고 로컬 예산이 있어 로컬 데이터 유지');
return;
}
// 기존 로컬 데이터 가져오기
let localBudgetData = localBudgetDataStr ? JSON.parse(localBudgetDataStr) : { let localBudgetData = localBudgetDataStr ? JSON.parse(localBudgetDataStr) : {
daily: { targetAmount: 0, spentAmount: 0, remainingAmount: 0 }, daily: { targetAmount: 0, spentAmount: 0, remainingAmount: 0 },
weekly: { 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 = { const updatedBudgetData = {
daily: { daily: {
targetAmount: Math.round(budgetData.total_budget / 30), targetAmount: dailyBudget,
spentAmount: localBudgetData.daily.spentAmount, spentAmount: localBudgetData.daily.spentAmount,
remainingAmount: Math.round(budgetData.total_budget / 30) - localBudgetData.daily.spentAmount remainingAmount: dailyBudget - localBudgetData.daily.spentAmount
}, },
weekly: { weekly: {
targetAmount: Math.round(budgetData.total_budget / 4.3), targetAmount: weeklyBudget,
spentAmount: localBudgetData.weekly.spentAmount, spentAmount: localBudgetData.weekly.spentAmount,
remainingAmount: Math.round(budgetData.total_budget / 4.3) - localBudgetData.weekly.spentAmount remainingAmount: weeklyBudget - localBudgetData.weekly.spentAmount
}, },
monthly: { monthly: {
targetAmount: budgetData.total_budget, targetAmount: monthlyBudget,
spentAmount: localBudgetData.monthly.spentAmount, 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', JSON.stringify(updatedBudgetData));
localStorage.setItem('budgetData_backup', JSON.stringify(updatedBudgetData));
console.log('예산 데이터 로컬 저장 완료', updatedBudgetData); console.log('예산 데이터 로컬 저장 완료', updatedBudgetData);
// 이벤트 발생시켜 UI 업데이트 // 이벤트 발생시켜 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}개의 카테고리 예산 수신`); 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) => { const localCategoryBudgets = categoryData.reduce((acc, curr) => {
acc[curr.category] = curr.amount; acc[curr.category] = curr.amount;
@@ -134,6 +181,7 @@ async function processCategoryBudgetData(categoryData: any[]) {
// 로컬 스토리지에 저장 // 로컬 스토리지에 저장
localStorage.setItem('categoryBudgets', JSON.stringify(localCategoryBudgets)); localStorage.setItem('categoryBudgets', JSON.stringify(localCategoryBudgets));
localStorage.setItem('categoryBudgets_backup', JSON.stringify(localCategoryBudgets));
console.log('카테고리 예산 로컬 저장 완료', localCategoryBudgets); console.log('카테고리 예산 로컬 저장 완료', localCategoryBudgets);
// 이벤트 발생시켜 UI 업데이트 // 이벤트 발생시켜 UI 업데이트

View File

@@ -64,6 +64,8 @@ async function uploadBudgetData(userId: string, parsedBudgetData: any): Promise<
// 월간 타겟 금액 가져오기 // 월간 타겟 금액 가져오기
const monthlyTarget = parsedBudgetData.monthly.targetAmount; const monthlyTarget = parsedBudgetData.monthly.targetAmount;
console.log('업로드할 월간 예산:', monthlyTarget);
// 업데이트 또는 삽입 결정 // 업데이트 또는 삽입 결정
if (existingBudgets && existingBudgets.length > 0) { if (existingBudgets && existingBudgets.length > 0) {
// 기존 데이터 업데이트 // 기존 데이터 업데이트

View File

@@ -52,8 +52,12 @@ export const clearCloudData = async (userId: string): Promise<boolean> => {
} catch (e) { } catch (e) {
console.log('category_budgets 테이블이 없거나 삭제 중 오류 발생:', e); console.log('category_budgets 테이블이 없거나 삭제 중 오류 발생:', e);
} }
// 동기화 설정 초기화 및 마지막 동기화 시간 초기화
localStorage.removeItem('lastSync');
localStorage.setItem('syncEnabled', 'false'); // 동기화 설정을 OFF로 변경
console.log('클라우드 데이터 초기화 완료'); console.log('클라우드 데이터 초기화 완료 및 동기화 설정 OFF');
return true; return true;
} catch (error) { } catch (error) {
console.error('클라우드 데이터 초기화 중 오류 발생:', error); console.error('클라우드 데이터 초기화 중 오류 발생:', error);

View File

@@ -1,71 +1,193 @@
import { supabase } from '@/lib/supabase'; import { supabase } from '@/lib/supabase';
import { uploadBudgets, downloadBudgets } from './budget';
import { uploadTransactions, downloadTransactions } from './transaction';
import { setLastSyncTime } from './time'; 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<void> => { export const syncAllData = async (userId: string): Promise<SyncResult> => {
if (!userId) { // 로컬 데이터 백업
throw new Error('사용자 ID가 필요합니다'); const backupBudgetData = localStorage.getItem('budgetData');
} const backupCategoryBudgets = localStorage.getItem('categoryBudgets');
const backupTransactions = localStorage.getItem('transactions');
try {
// 로컬 트랜잭션 데이터 가져오기 const result: SyncResult = {
const transactionsJSON = localStorage.getItem('transactions'); success: false,
const transactions = transactionsJSON ? JSON.parse(transactionsJSON) : []; partial: false,
uploadSuccess: false,
// 예산 데이터 가져오기 downloadSuccess: false,
const budgetDataJSON = localStorage.getItem('budgetData'); details: {
const budgetData = budgetDataJSON ? JSON.parse(budgetDataJSON) : {}; budgetUpload: false,
budgetDownload: false,
// 카테고리 예산 가져오기 transactionUpload: false,
const categoryBudgetsJSON = localStorage.getItem('categoryBudgets'); transactionDownload: false
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
});
}
} }
};
// 예산 데이터 동기화
await supabase.from('budget_data').upsert({ try {
user_id: userId, console.log('데이터 동기화 시작 - 사용자 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();
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) { } catch (error) {
console.error('데이터 동기화 중 오류 발생:', error); console.error('데이터 동기화 중 치명적 오류:', error);
throw 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<SyncResult> => {
console.log('안전한 데이터 동기화 시도');
let attempts = 0;
const trySync = async (): Promise<SyncResult> => {
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();
};

View File

@@ -4,13 +4,15 @@ import { isSyncEnabled } from '../syncSettings';
import { toast } from '@/hooks/useToast.wrapper'; import { toast } from '@/hooks/useToast.wrapper';
/** /**
* 특정 트랜잭션 ID 삭제 처리 * 특정 트랜잭션 ID 삭제 처리 - 안정성 개선 버전
*/ */
export const deleteTransactionFromServer = async (userId: string, transactionId: string): Promise<void> => { export const deleteTransactionFromServer = async (userId: string, transactionId: string): Promise<void> => {
if (!isSyncEnabled()) return; if (!isSyncEnabled()) return;
try { try {
console.log(`트랜잭션 삭제 요청: ${transactionId}`); console.log(`트랜잭션 삭제 요청: ${transactionId}`);
// 삭제 요청 (타임아웃 처리 없음 - 불필요한 복잡성 제거)
const { error } = await supabase const { error } = await supabase
.from('transactions') .from('transactions')
.delete() .delete()
@@ -25,11 +27,16 @@ export const deleteTransactionFromServer = async (userId: string, transactionId:
console.log(`트랜잭션 ${transactionId} 삭제 완료`); console.log(`트랜잭션 ${transactionId} 삭제 완료`);
} catch (error) { } catch (error) {
console.error('트랜잭션 삭제 중 오류:', error); console.error('트랜잭션 삭제 중 오류:', error);
// 에러 발생 시 토스트 알림
// 오류 메시지 (중요도 낮음)
toast({ toast({
title: "삭제 동기화 실패", title: "동기화 문제",
description: "서버에서 트랜잭션을 삭제하는데 문제가 발생했습니다.", description: "서버에서 삭제 중 문제가 발생했습니다.",
variant: "destructive" variant: "default",
duration: 1500
}); });
// 오류 다시 던지기 (호출자가 처리하도록)
throw error;
} }
}; };