Merge remote-tracking branch 'origin/main'
This commit is contained in:
@@ -8,6 +8,7 @@ import { supabase } from '@/lib/supabase';
|
||||
import { isSyncEnabled } from '@/utils/syncUtils';
|
||||
import ExpenseForm, { ExpenseFormValues } from './expenses/ExpenseForm';
|
||||
import { Transaction } from '@/components/TransactionCard';
|
||||
import { normalizeDate } from '@/utils/sync/transaction/dateUtils';
|
||||
|
||||
const AddTransactionButton = () => {
|
||||
const [showExpenseDialog, setShowExpenseDialog] = useState(false);
|
||||
@@ -53,11 +54,14 @@ const AddTransactionButton = () => {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
|
||||
if (isSyncEnabled() && user) {
|
||||
// ISO 형식으로 날짜 변환
|
||||
const isoDate = normalizeDate(formattedDate);
|
||||
|
||||
const { error } = await supabase.from('transactions').insert({
|
||||
user_id: user.id,
|
||||
title: data.title,
|
||||
amount: parseInt(numericAmount),
|
||||
date: formattedDate,
|
||||
date: isoDate, // ISO 형식 사용
|
||||
category: data.category,
|
||||
type: 'expense',
|
||||
transaction_id: newExpense.id
|
||||
|
||||
@@ -52,14 +52,22 @@ const BudgetTabContent: React.FC<BudgetTabContentProps> = ({
|
||||
|
||||
// 카테고리별 예산 합계 계산
|
||||
const calculateTotalBudget = () => {
|
||||
return Object.values(categoryBudgets).reduce((sum, value) => sum + value, 0);
|
||||
const total = Object.values(categoryBudgets).reduce((sum, value) => sum + value, 0);
|
||||
console.log('카테고리 예산 총합:', total, categoryBudgets);
|
||||
return total;
|
||||
};
|
||||
|
||||
// 카테고리 예산 저장
|
||||
const handleSaveCategoryBudgets = () => {
|
||||
const totalBudget = calculateTotalBudget();
|
||||
onSaveBudget(totalBudget, categoryBudgets);
|
||||
setShowBudgetInput(false);
|
||||
console.log('카테고리 예산 저장 및 총 예산 설정:', totalBudget, categoryBudgets);
|
||||
// 총액이 0이 아닐 때만 저장 처리
|
||||
if (totalBudget > 0) {
|
||||
onSaveBudget(totalBudget, categoryBudgets);
|
||||
setShowBudgetInput(false);
|
||||
} else {
|
||||
alert('예산을 입력해주세요.');
|
||||
}
|
||||
};
|
||||
|
||||
// 기존 카테고리 예산 불러오기
|
||||
@@ -69,7 +77,9 @@ const BudgetTabContent: React.FC<BudgetTabContentProps> = ({
|
||||
try {
|
||||
const storedCategoryBudgets = localStorage.getItem('categoryBudgets');
|
||||
if (storedCategoryBudgets) {
|
||||
setCategoryBudgets(JSON.parse(storedCategoryBudgets));
|
||||
const parsedBudgets = JSON.parse(storedCategoryBudgets);
|
||||
console.log('저장된 카테고리 예산 불러옴:', parsedBudgets);
|
||||
setCategoryBudgets(parsedBudgets);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('카테고리 예산 불러오기 오류:', error);
|
||||
@@ -107,12 +117,9 @@ const BudgetTabContent: React.FC<BudgetTabContentProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<button
|
||||
onClick={toggleBudgetInput}
|
||||
className="text-neuro-income hover:underline flex items-center text-lg font-bold group"
|
||||
>
|
||||
<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 onClick={toggleBudgetInput} className="text-neuro-income hover:underline flex items-center text-lg font-bold group">
|
||||
<CirclePlus size={26} className="mr-2 text-neuro-income transition-transform group-hover:scale-110" />
|
||||
<span className="text-base font-semibold">{budgetButtonText}</span>
|
||||
</button>
|
||||
</div>
|
||||
</> : <div className="py-4 text-center">
|
||||
@@ -126,13 +133,13 @@ const BudgetTabContent: React.FC<BudgetTabContentProps> = ({
|
||||
{showBudgetInput && <div className="mt-4">
|
||||
<div className="neuro-card p-4">
|
||||
<div>
|
||||
<h3 className="text-base font-medium mb-3">카테고리별 예산 설정</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">카테고리 예산을 설정하면 일일, 주간, 월간 예산이 자동으로 합산됩니다.</p>
|
||||
<h3 className="text-base font-medium mb-3">카테고리별 월간 예산 설정</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">카테고리별로 월간 예산을 설정하세요. 일일, 주간 예산은 자동으로 계산됩니다.</p>
|
||||
<CategoryBudgetInputs categoryBudgets={categoryBudgets} handleCategoryInputChange={handleCategoryInputChange} />
|
||||
|
||||
<div className="mt-4 border-t border-gray-300 pt-3">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { Transaction } from './TransactionCard';
|
||||
import TransactionEditDialog from './TransactionEditDialog';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
@@ -6,64 +7,208 @@ import { useBudget } from '@/contexts/BudgetContext';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { categoryIcons } from '@/constants/categoryIcons';
|
||||
import TransactionIcon from './transaction/TransactionIcon';
|
||||
import { toast } from '@/hooks/useToast.wrapper';
|
||||
|
||||
interface RecentTransactionsSectionProps {
|
||||
transactions: Transaction[];
|
||||
onUpdateTransaction?: (transaction: Transaction) => void;
|
||||
}
|
||||
|
||||
const RecentTransactionsSection: React.FC<RecentTransactionsSectionProps> = ({
|
||||
transactions,
|
||||
onUpdateTransaction
|
||||
}) => {
|
||||
const [selectedTransaction, setSelectedTransaction] = useState<Transaction | null>(null);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const {
|
||||
updateTransaction,
|
||||
deleteTransaction
|
||||
} = useBudget();
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const { updateTransaction, 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) => {
|
||||
setSelectedTransaction(transaction);
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
const handleUpdateTransaction = (updatedTransaction: Transaction) => {
|
||||
|
||||
const handleUpdateTransaction = useCallback((updatedTransaction: Transaction) => {
|
||||
if (onUpdateTransaction) {
|
||||
onUpdateTransaction(updatedTransaction);
|
||||
}
|
||||
// 직접 컨텍스트를 통해 업데이트
|
||||
updateTransaction(updatedTransaction);
|
||||
};
|
||||
const handleDeleteTransaction = (id: string) => {
|
||||
// 직접 컨텍스트를 통해 삭제
|
||||
deleteTransaction(id);
|
||||
};
|
||||
}, [onUpdateTransaction, updateTransaction]);
|
||||
|
||||
// 완전히 새로운 삭제 처리 함수
|
||||
const handleDeleteTransaction = useCallback(async (id: string): Promise<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) => {
|
||||
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">
|
||||
<h2 className="text-lg font-semibold">최근 지출</h2>
|
||||
<Link to="/transactions" className="text-sm text-neuro-income flex items-center">
|
||||
더보기 <ChevronRight size={16} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<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]">
|
||||
<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>
|
||||
{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]"
|
||||
>
|
||||
<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 className="text-right">
|
||||
<p className="font-semibold text-neuro-income">-{formatCurrency(transaction.amount)}</p>
|
||||
<p className="text-xs text-gray-500">{transaction.category}</p>
|
||||
</div>
|
||||
</div>) : <div className="py-4 text-center text-gray-500">
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-semibold text-neuro-income">-{formatCurrency(transaction.amount)}</p>
|
||||
<p className="text-xs text-gray-500">{transaction.category}</p>
|
||||
</div>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="py-4 text-center text-gray-500">
|
||||
지출 내역이 없습니다
|
||||
</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedTransaction && <TransactionEditDialog transaction={selectedTransaction} open={isDialogOpen} onOpenChange={setIsDialogOpen} onSave={handleUpdateTransaction} onDelete={handleDeleteTransaction} />}
|
||||
</div>;
|
||||
{selectedTransaction && (
|
||||
<TransactionEditDialog
|
||||
transaction={selectedTransaction}
|
||||
open={isDialogOpen}
|
||||
onOpenChange={setIsDialogOpen}
|
||||
onSave={handleUpdateTransaction}
|
||||
onDelete={handleDeleteTransaction}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default RecentTransactionsSection;
|
||||
|
||||
export default RecentTransactionsSection;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { CloudUpload } from "lucide-react";
|
||||
import { useSyncSettings } from '@/hooks/useSyncSettings';
|
||||
import SyncStatus from '@/components/sync/SyncStatus';
|
||||
import SyncExplanation from '@/components/sync/SyncExplanation';
|
||||
import { isSyncEnabled } from '@/utils/sync/syncSettings';
|
||||
|
||||
const SyncSettings = () => {
|
||||
const {
|
||||
@@ -17,6 +18,30 @@ const SyncSettings = () => {
|
||||
handleManualSync
|
||||
} = useSyncSettings();
|
||||
|
||||
// 동기화 설정 변경 모니터링
|
||||
useEffect(() => {
|
||||
const checkSyncStatus = () => {
|
||||
const currentStatus = isSyncEnabled();
|
||||
console.log('현재 동기화 상태:', currentStatus ? '활성화됨' : '비활성화됨');
|
||||
};
|
||||
|
||||
// 초기 상태 확인
|
||||
checkSyncStatus();
|
||||
|
||||
// 스토리지 변경 이벤트에도 동기화 상태 확인 추가
|
||||
const handleStorageChange = () => {
|
||||
checkSyncStatus();
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
window.addEventListener('auth-state-changed', handleStorageChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorageChange);
|
||||
window.removeEventListener('auth-state-changed', handleStorageChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 동기화 토글 컨트롤 */}
|
||||
|
||||
@@ -19,18 +19,27 @@ export type Transaction = {
|
||||
interface TransactionCardProps {
|
||||
transaction: Transaction;
|
||||
onUpdate?: (updatedTransaction: Transaction) => void;
|
||||
onDelete?: (id: string) => Promise<boolean> | boolean; // 타입 변경됨: boolean 또는 Promise<boolean> 반환
|
||||
}
|
||||
|
||||
const TransactionCard: React.FC<TransactionCardProps> = ({
|
||||
transaction,
|
||||
onUpdate
|
||||
onDelete,
|
||||
}) => {
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
const { title, amount, date, category, type } = transaction;
|
||||
const { title, amount, date, category } = transaction;
|
||||
|
||||
const handleSaveTransaction = (updatedTransaction: Transaction) => {
|
||||
if (onUpdate) {
|
||||
onUpdate(updatedTransaction);
|
||||
// 삭제 핸들러 - 인자로 받은 onDelete가 없거나 타입이 맞지 않을 때 기본 함수 제공
|
||||
const handleDelete = async (id: string): Promise<boolean> => {
|
||||
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}
|
||||
open={isEditDialogOpen}
|
||||
onOpenChange={setIsEditDialogOpen}
|
||||
onSave={handleSaveTransaction}
|
||||
onDelete={handleDelete} // 래핑된 핸들러 사용
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogClose
|
||||
DialogClose,
|
||||
DialogDescription
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Form } from '@/components/ui/form';
|
||||
@@ -28,7 +29,7 @@ interface TransactionEditDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSave?: (updatedTransaction: Transaction) => void;
|
||||
onDelete?: (id: string) => void;
|
||||
onDelete?: (id: string) => Promise<boolean> | boolean;
|
||||
}
|
||||
|
||||
const TransactionEditDialog: React.FC<TransactionEditDialogProps> = ({
|
||||
@@ -39,6 +40,7 @@ const TransactionEditDialog: React.FC<TransactionEditDialogProps> = ({
|
||||
onDelete
|
||||
}) => {
|
||||
const { updateTransaction, deleteTransaction } = useBudget();
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const form = useForm<TransactionFormValues>({
|
||||
@@ -77,21 +79,28 @@ const TransactionEditDialog: React.FC<TransactionEditDialogProps> = ({
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
// 컨텍스트를 통해 트랜잭션 삭제
|
||||
deleteTransaction(transaction.id);
|
||||
|
||||
// 부모 컴포넌트의 onDelete 콜백이 있다면 호출
|
||||
if (onDelete) {
|
||||
onDelete(transaction.id);
|
||||
const handleDelete = async (): Promise<boolean> => {
|
||||
try {
|
||||
// 다이얼로그 닫기를 먼저 수행 (UI 블로킹 방지)
|
||||
onOpenChange(false);
|
||||
|
||||
// 삭제 처리 - 부모 컴포넌트의 onDelete 콜백이 있다면 호출
|
||||
if (onDelete) {
|
||||
return await onDelete(transaction.id);
|
||||
}
|
||||
|
||||
// 부모 컴포넌트에서 처리하지 않은 경우 기본 처리
|
||||
deleteTransaction(transaction.id);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('트랜잭션 삭제 중 오류:', error);
|
||||
toast({
|
||||
title: "삭제 실패",
|
||||
description: "지출 항목을 삭제하는데 문제가 발생했습니다.",
|
||||
variant: "destructive"
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
onOpenChange(false);
|
||||
|
||||
toast({
|
||||
title: "지출이 삭제되었습니다",
|
||||
description: `${transaction.title} 항목이 삭제되었습니다.`,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -99,6 +108,9 @@ const TransactionEditDialog: React.FC<TransactionEditDialogProps> = ({
|
||||
<DialogContent className={`sm:max-w-md mx-auto ${isMobile ? 'rounded-xl overflow-hidden' : ''}`}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>지출 수정</DialogTitle>
|
||||
<DialogDescription>
|
||||
지출 내역을 수정하거나 삭제할 수 있습니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
|
||||
@@ -2,15 +2,7 @@
|
||||
import React from 'react';
|
||||
import { CloudOff, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogClose
|
||||
} from '@/components/ui/dialog';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogClose } from '@/components/ui/dialog';
|
||||
|
||||
interface DataResetDialogProps {
|
||||
isOpen: boolean;
|
||||
@@ -18,6 +10,7 @@ interface DataResetDialogProps {
|
||||
onConfirm: () => Promise<void>;
|
||||
isResetting: boolean;
|
||||
isLoggedIn: boolean;
|
||||
syncEnabled: boolean;
|
||||
}
|
||||
|
||||
const DataResetDialog: React.FC<DataResetDialogProps> = ({
|
||||
@@ -25,25 +18,26 @@ const DataResetDialog: React.FC<DataResetDialogProps> = ({
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
isResetting,
|
||||
isLoggedIn
|
||||
isLoggedIn,
|
||||
syncEnabled
|
||||
}) => {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
return <Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>정말 모든 데이터를 초기화하시겠습니까?</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isLoggedIn ? (
|
||||
<>
|
||||
{isLoggedIn ? <>
|
||||
이 작업은 되돌릴 수 없으며, 로컬 및 클라우드에 저장된 모든 예산, 지출 내역이 영구적으로 삭제됩니다.
|
||||
<div className="flex items-center mt-2 text-amber-600">
|
||||
<CloudOff size={16} className="mr-2" />
|
||||
클라우드 데이터도 함께 삭제됩니다.
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
"이 작업은 되돌릴 수 없으며, 모든 예산, 지출 내역, 설정이 영구적으로 삭제됩니다."
|
||||
)}
|
||||
{syncEnabled && (
|
||||
<div className="mt-2 text-amber-600">
|
||||
동기화 설정이 비활성화됩니다.
|
||||
</div>
|
||||
)}
|
||||
</> : "이 작업은 되돌릴 수 없으며, 모든 예산, 지출 내역, 설정이 영구적으로 삭제됩니다."}
|
||||
<div className="mt-2">
|
||||
단, '환영합니다' 화면 표시 설정과 로그인 상태는 유지됩니다.
|
||||
</div>
|
||||
@@ -53,22 +47,15 @@ const DataResetDialog: React.FC<DataResetDialogProps> = ({
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" className="sm:mr-2" disabled={isResetting}>취소</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={onConfirm}
|
||||
disabled={isResetting}
|
||||
>
|
||||
{isResetting ? (
|
||||
<>
|
||||
<Button variant="destructive" onClick={onConfirm} disabled={isResetting}>
|
||||
{isResetting ? <>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
초기화 중...
|
||||
</>
|
||||
) : isLoggedIn ? '확인, 로컬 및 클라우드 데이터 초기화' : '확인, 모든 데이터 초기화'}
|
||||
</> : isLoggedIn ? '확인, 로컬 및 클라우드 데이터 초기화' : '확인, 모든 데이터 초기화'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
</Dialog>;
|
||||
};
|
||||
|
||||
export default DataResetDialog;
|
||||
|
||||
@@ -5,15 +5,21 @@ import { Button } from '@/components/ui/button';
|
||||
import { useAuth } from '@/contexts/auth/AuthProvider';
|
||||
import { useDataReset } from '@/hooks/useDataReset';
|
||||
import DataResetDialog from './DataResetDialog';
|
||||
import { isSyncEnabled } from '@/utils/sync/syncSettings';
|
||||
import { toast } from '@/hooks/useToast.wrapper';
|
||||
|
||||
const DataResetSection = () => {
|
||||
const [isResetDialogOpen, setIsResetDialogOpen] = useState(false);
|
||||
const { user } = useAuth();
|
||||
const { isResetting, resetAllData } = useDataReset();
|
||||
const syncEnabled = isSyncEnabled();
|
||||
|
||||
const handleResetAllData = async () => {
|
||||
await resetAllData();
|
||||
setIsResetDialogOpen(false);
|
||||
|
||||
// 데이터 초기화 후 애플리케이션 리로드
|
||||
// toast 알림은 useDataReset.ts에서 처리하므로 여기서는 제거
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -26,7 +32,9 @@ const DataResetSection = () => {
|
||||
<div className="text-left">
|
||||
<h3 className="font-medium">데이터 초기화</h3>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{user ? "로컬 및 클라우드의 모든 예산, 지출 내역이 초기화됩니다." : "모든 예산, 지출 내역, 설정이 초기화됩니다."}
|
||||
{user
|
||||
? "로컬 및 클라우드의 모든 예산, 지출 내역이 초기화됩니다. 동기화 설정은 비활성화됩니다."
|
||||
: "모든 예산, 지출 내역, 설정이 초기화됩니다."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -46,6 +54,7 @@ const DataResetSection = () => {
|
||||
onConfirm={handleResetAllData}
|
||||
isResetting={isResetting}
|
||||
isLoggedIn={!!user}
|
||||
syncEnabled={syncEnabled}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -16,7 +16,7 @@ const SyncExplanation: React.FC<SyncExplanationProps> = ({ enabled }) => {
|
||||
<AlertTitle className="text-black">동기화 작동 방식</AlertTitle>
|
||||
<AlertDescription className="text-sm text-black">
|
||||
이 기능은 양방향 동기화입니다. 로그인 후 동기화를 켜면 서버 데이터와 로컬 데이터가 병합됩니다.
|
||||
데이터 초기화 후에도 동기화 버튼을 누르면 서버에 저장된 데이터를 다시 불러옵니다.
|
||||
데이터 초기화 시 동기화 설정은 자동으로 비활성화됩니다.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { Trash2, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -15,19 +15,51 @@ import {
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
interface TransactionDeleteAlertProps {
|
||||
onDelete: () => void;
|
||||
onDelete: () => Promise<boolean> | boolean;
|
||||
}
|
||||
|
||||
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 (
|
||||
<AlertDialog>
|
||||
<AlertDialog open={isOpen} onOpenChange={(open) => {
|
||||
// 삭제 중에는 닫기 방지
|
||||
if (isDeleting && !open) return;
|
||||
setIsOpen(open);
|
||||
}}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="border-red-200 text-red-500 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
<Trash2 size={16} className="mr-1" />
|
||||
삭제
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
@@ -39,13 +71,24 @@ const TransactionDeleteAlert: React.FC<TransactionDeleteAlertProps> = ({ onDelet
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
<AlertDialogCancel disabled={isDeleting}>취소</AlertDialogCancel>
|
||||
<Button
|
||||
className="bg-red-500 hover:bg-red-600"
|
||||
onClick={onDelete}
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<Loader2 size={16} className="mr-1 animate-spin" />
|
||||
삭제 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 size={16} className="mr-1" />
|
||||
삭제
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
37
src/components/transactions/EmptyTransactions.tsx
Normal file
37
src/components/transactions/EmptyTransactions.tsx
Normal 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;
|
||||
47
src/components/transactions/TransactionDateGroup.tsx
Normal file
47
src/components/transactions/TransactionDateGroup.tsx
Normal 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;
|
||||
61
src/components/transactions/TransactionsContent.tsx
Normal file
61
src/components/transactions/TransactionsContent.tsx
Normal 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;
|
||||
92
src/components/transactions/TransactionsHeader.tsx
Normal file
92
src/components/transactions/TransactionsHeader.tsx
Normal 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;
|
||||
29
src/components/transactions/TransactionsList.tsx
Normal file
29
src/components/transactions/TransactionsList.tsx
Normal 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;
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
import * as React from "react"
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
@@ -92,7 +93,7 @@ const ToastTitle = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold", className)}
|
||||
className={cn("text-sm font-semibold text-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
@@ -104,7 +105,7 @@ const ToastDescription = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
className={cn("text-sm opacity-90 text-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -17,7 +17,7 @@ export function Toaster() {
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
<div className="grid gap-1 w-full text-center">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
|
||||
@@ -1,70 +1,45 @@
|
||||
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import {
|
||||
handleNetworkError,
|
||||
parseResponse,
|
||||
showAuthToast,
|
||||
verifyServerConnection
|
||||
} from '@/utils/auth';
|
||||
import { signInWithDirectApi } from './signInUtils';
|
||||
import { getProxyType, isCorsProxyEnabled } from '@/lib/supabase/config';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { showAuthToast } from '@/utils/auth';
|
||||
|
||||
/**
|
||||
* 로그인 기능 - Supabase Cloud 환경에 최적화
|
||||
*/
|
||||
export const signIn = async (email: string, password: string) => {
|
||||
try {
|
||||
console.log('로그인 시도 중:', email);
|
||||
|
||||
// 기본 Supabase 인증 방식 시도
|
||||
try {
|
||||
const { data, error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password
|
||||
});
|
||||
// Supabase 인증 방식 시도
|
||||
const { data, error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password
|
||||
});
|
||||
|
||||
if (!error && data.user) {
|
||||
showAuthToast('로그인 성공', '환영합니다!');
|
||||
return { error: null, user: data.user };
|
||||
} else if (error) {
|
||||
console.error('로그인 오류:', error.message);
|
||||
|
||||
if (!error && data.user) {
|
||||
showAuthToast('로그인 성공', '환영합니다!');
|
||||
return { error: null, user: data.user };
|
||||
} else if (error) {
|
||||
console.error('Supabase 기본 로그인 오류:', error.message);
|
||||
|
||||
let errorMessage = error.message;
|
||||
if (error.message.includes('Invalid login credentials')) {
|
||||
errorMessage = '이메일 또는 비밀번호가 올바르지 않습니다.';
|
||||
} else if (error.message.includes('Email not confirmed')) {
|
||||
errorMessage = '이메일 인증이 완료되지 않았습니다. 이메일을 확인해주세요.';
|
||||
}
|
||||
|
||||
showAuthToast('로그인 실패', errorMessage, 'destructive');
|
||||
return { error: { message: errorMessage }, user: null };
|
||||
let errorMessage = error.message;
|
||||
if (error.message.includes('Invalid login credentials')) {
|
||||
errorMessage = '이메일 또는 비밀번호가 올바르지 않습니다.';
|
||||
} else if (error.message.includes('Email not confirmed')) {
|
||||
errorMessage = '이메일 인증이 완료되지 않았습니다. 이메일을 확인해주세요.';
|
||||
}
|
||||
} catch (basicAuthError: any) {
|
||||
console.warn('Supabase 기본 인증 방식 예외 발생:', basicAuthError);
|
||||
throw basicAuthError;
|
||||
|
||||
showAuthToast('로그인 실패', errorMessage, 'destructive');
|
||||
return { error: { message: errorMessage }, user: null };
|
||||
}
|
||||
|
||||
// 여기까지 왔다면 모든 로그인 시도가 실패한 것
|
||||
// 여기까지 왔다면 오류가 발생한 것
|
||||
showAuthToast('로그인 실패', '로그인 처리 중 오류가 발생했습니다.', 'destructive');
|
||||
return { error: { message: '로그인 처리 중 오류가 발생했습니다.' }, user: null };
|
||||
} catch (error: any) {
|
||||
console.error('로그인 중 예외 발생:', error);
|
||||
|
||||
// 프록시 설정 확인 및 추천
|
||||
const usingProxy = isCorsProxyEnabled();
|
||||
const proxyType = getProxyType();
|
||||
|
||||
// 네트워크 오류 확인
|
||||
let errorMessage = handleNetworkError(error);
|
||||
|
||||
// CORS 또는 네트워크 오류인 경우 Cloudflare 프록시 추천
|
||||
if (errorMessage.includes('CORS') || errorMessage.includes('네트워크') || errorMessage.includes('연결')) {
|
||||
if (!usingProxy) {
|
||||
errorMessage = `${errorMessage} (설정에서 Cloudflare CORS 프록시 활성화를 권장합니다)`;
|
||||
} else if (proxyType !== 'cloudflare') {
|
||||
errorMessage = `${errorMessage} (설정에서 Cloudflare CORS 프록시로 변경을 권장합니다)`;
|
||||
}
|
||||
}
|
||||
const errorMessage = error.message || '로그인 중 오류가 발생했습니다.';
|
||||
|
||||
showAuthToast('로그인 오류', errorMessage, 'destructive');
|
||||
|
||||
return { error: { message: errorMessage }, user: null };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,208 +1,53 @@
|
||||
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { parseResponse, showAuthToast, handleNetworkError } from '@/utils/auth';
|
||||
import { getProxyType, isCorsProxyEnabled, getSupabaseUrl, getOriginalSupabaseUrl } from '@/lib/supabase/config';
|
||||
import { showAuthToast } from '@/utils/auth';
|
||||
|
||||
/**
|
||||
* 직접 API 호출을 통한 로그인 시도 (대체 방법)
|
||||
* 로그인 기능 - Supabase Cloud 환경에 최적화
|
||||
*/
|
||||
export const signInWithDirectApi = async (email: string, password: string) => {
|
||||
console.log('직접 API 호출로 로그인 시도');
|
||||
console.log('Supabase Cloud 로그인 시도');
|
||||
|
||||
try {
|
||||
// API 호출 URL 및 헤더 설정
|
||||
const supabaseUrl = getOriginalSupabaseUrl(); // 원본 URL 사용
|
||||
const proxyUrl = getSupabaseUrl(); // 프록시 적용된 URL
|
||||
const supabaseKey = localStorage.getItem('supabase_key') || supabase.supabaseKey;
|
||||
|
||||
// 프록시 정보 로그
|
||||
const usingProxy = isCorsProxyEnabled();
|
||||
const proxyType = getProxyType();
|
||||
console.log(`CORS 프록시 사용: ${usingProxy ? '예' : '아니오'}, 타입: ${proxyType}, 프록시 URL: ${proxyUrl}`);
|
||||
|
||||
// 실제 요청에 사용할 URL 결정 (항상 프록시 URL 사용)
|
||||
const useUrl = usingProxy ? proxyUrl : supabaseUrl;
|
||||
|
||||
// URL에 auth/v1이 이미 포함되어있는지 확인
|
||||
const baseUrl = useUrl.includes('/auth/v1') ? useUrl : `${useUrl}/auth/v1`;
|
||||
|
||||
// 토큰 엔드포인트 경로
|
||||
const tokenUrl = `${baseUrl}/token?grant_type=password`;
|
||||
|
||||
console.log('로그인 API 요청 URL:', tokenUrl);
|
||||
|
||||
// 로그인 요청 보내기
|
||||
const response = await fetch(tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'apikey': supabaseKey
|
||||
},
|
||||
body: JSON.stringify({ email, password })
|
||||
// Supabase Cloud를 통한 로그인 요청
|
||||
const { data, error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password
|
||||
});
|
||||
|
||||
// 응답 상태 확인 및 로깅
|
||||
console.log('로그인 응답 상태:', response.status);
|
||||
|
||||
// HTTP 상태 코드 확인
|
||||
if (response.status === 401) {
|
||||
console.log('로그인 실패: 인증 오류');
|
||||
showAuthToast('로그인 실패', '이메일 또는 비밀번호가 올바르지 않습니다.', 'destructive');
|
||||
return {
|
||||
error: { message: '인증 실패: 이메일 또는 비밀번호가 올바르지 않습니다.' },
|
||||
user: null
|
||||
};
|
||||
}
|
||||
|
||||
if (response.status === 404) {
|
||||
console.warn('API 경로를 찾을 수 없음 (404). 새 엔드포인트 시도 중...');
|
||||
// 오류 응답 처리
|
||||
if (error) {
|
||||
console.error('로그인 오류:', error);
|
||||
|
||||
// 대체 엔드포인트 시도 (/token 대신 /signin)
|
||||
const signinUrl = `${baseUrl}/signin`;
|
||||
// 오류 메시지 포맷팅
|
||||
let errorMessage = error.message;
|
||||
|
||||
try {
|
||||
const signinResponse = await fetch(signinUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'apikey': supabaseKey
|
||||
},
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
|
||||
console.log('대체 로그인 경로 응답 상태:', signinResponse.status);
|
||||
|
||||
if (signinResponse.status === 404) {
|
||||
showAuthToast('로그인 실패', '서버 설정을 확인하세요: 인증 API 경로를 찾을 수 없습니다.', 'destructive');
|
||||
return {
|
||||
error: { message: '서버 설정 문제: 인증 API 경로를 찾을 수 없습니다. Supabase URL을 확인하세요.' },
|
||||
user: null
|
||||
};
|
||||
}
|
||||
|
||||
// 대체 응답 처리
|
||||
const signinData = await parseResponse(signinResponse);
|
||||
if (signinData.error) {
|
||||
showAuthToast('로그인 실패', signinData.error, 'destructive');
|
||||
return { error: { message: signinData.error }, user: null };
|
||||
}
|
||||
|
||||
if (signinData.access_token) {
|
||||
await supabase.auth.setSession({
|
||||
access_token: signinData.access_token,
|
||||
refresh_token: signinData.refresh_token || ''
|
||||
});
|
||||
|
||||
const { data: userData } = await supabase.auth.getUser();
|
||||
showAuthToast('로그인 성공', '환영합니다!');
|
||||
return { error: null, user: userData.user };
|
||||
}
|
||||
} catch (altError) {
|
||||
console.error('대체 로그인 엔드포인트 오류:', altError);
|
||||
if (error.message.includes('Invalid login credentials')) {
|
||||
errorMessage = '이메일 또는 비밀번호가 올바르지 않습니다.';
|
||||
} else if (error.message.includes('Email not confirmed')) {
|
||||
errorMessage = '이메일 인증이 완료되지 않았습니다. 이메일을 확인해주세요.';
|
||||
}
|
||||
|
||||
showAuthToast('로그인 실패', '서버 설정을 확인하세요: 인증 API 경로를 찾을 수 없습니다.', 'destructive');
|
||||
return {
|
||||
error: { message: '서버 경로를 찾을 수 없습니다. Supabase URL을 확인하세요.' },
|
||||
user: null
|
||||
};
|
||||
}
|
||||
|
||||
// 응답 처리
|
||||
const responseText = await response.text();
|
||||
console.log('로그인 응답 내용:', responseText);
|
||||
|
||||
let responseData;
|
||||
try {
|
||||
// 응답이 비어있지 않은 경우에만 JSON 파싱 시도
|
||||
responseData = responseText ? JSON.parse(responseText) : {};
|
||||
} catch (e) {
|
||||
console.warn('JSON 파싱 실패:', e, '원본 응답:', responseText);
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
// 성공 응답이지만 JSON이 아닌 경우 (빈 응답 등)
|
||||
responseData = { success: true };
|
||||
} else {
|
||||
responseData = { error: '서버 응답을 처리할 수 없습니다' };
|
||||
}
|
||||
}
|
||||
|
||||
// 오류 응답 확인
|
||||
if (responseData?.error) {
|
||||
const errorMessage = responseData.error_description || responseData.error;
|
||||
showAuthToast('로그인 실패', errorMessage, 'destructive');
|
||||
return { error: { message: errorMessage }, user: null };
|
||||
}
|
||||
|
||||
// 로그인 성공 응답 처리
|
||||
if (response.ok && responseData?.access_token) {
|
||||
try {
|
||||
// 로그인 성공 시 Supabase 세션 설정
|
||||
await supabase.auth.setSession({
|
||||
access_token: responseData.access_token,
|
||||
refresh_token: responseData.refresh_token || ''
|
||||
});
|
||||
|
||||
// 사용자 정보 가져오기
|
||||
const { data: userData } = await supabase.auth.getUser();
|
||||
|
||||
console.log('로그인 성공:', userData);
|
||||
|
||||
showAuthToast('로그인 성공', '환영합니다!');
|
||||
|
||||
return { error: null, user: userData.user };
|
||||
} catch (sessionError) {
|
||||
console.error('세션 설정 오류:', sessionError);
|
||||
showAuthToast('로그인 후처리 오류', '로그인에 성공했지만 세션 설정에 실패했습니다.', 'destructive');
|
||||
return { error: { message: '세션 설정 오류' }, user: null };
|
||||
}
|
||||
} else if (response.ok) {
|
||||
// 응답 내용 없이 성공 상태인 경우
|
||||
try {
|
||||
// 사용자 세션 확인 시도
|
||||
const { data: userData } = await supabase.auth.getUser();
|
||||
if (userData.user) {
|
||||
showAuthToast('로그인 성공', '환영합니다!');
|
||||
return { error: null, user: userData.user };
|
||||
} else {
|
||||
// 세션은 있지만 사용자 정보가 없는 경우
|
||||
showAuthToast('로그인 부분 성공', '로그인은 성공했지만 사용자 정보를 가져오지 못했습니다.', 'default');
|
||||
return { error: { message: '사용자 정보 조회 실패' }, user: null };
|
||||
}
|
||||
} catch (userError) {
|
||||
console.error('사용자 정보 조회 오류:', userError);
|
||||
showAuthToast('로그인 후처리 오류', '로그인은 성공했지만 사용자 정보를 가져오지 못했습니다.', 'destructive');
|
||||
return { error: { message: '사용자 정보 조회 실패' }, user: null };
|
||||
}
|
||||
// 로그인 성공 처리
|
||||
if (data && data.user) {
|
||||
console.log('로그인 성공:', data.user);
|
||||
showAuthToast('로그인 성공', '환영합니다!');
|
||||
return { error: null, user: data.user };
|
||||
} else {
|
||||
// 오류 응답이나 예상치 못한 응답 형식 처리
|
||||
console.error('로그인 오류 응답:', responseData);
|
||||
|
||||
const errorMessage = responseData?.error_description ||
|
||||
responseData?.error ||
|
||||
responseData?.message ||
|
||||
'로그인에 실패했습니다. 이메일과 비밀번호를 확인하세요.';
|
||||
|
||||
showAuthToast('로그인 실패', errorMessage, 'destructive');
|
||||
return { error: { message: errorMessage }, user: null };
|
||||
// 사용자 정보가 없는 경우 (드문 경우)
|
||||
console.warn('로그인 성공했지만 사용자 정보가 없습니다');
|
||||
showAuthToast('로그인 부분 성공', '로그인은 성공했지만 사용자 정보를 가져오지 못했습니다.', 'default');
|
||||
return { error: { message: '사용자 정보 조회 실패' }, user: null };
|
||||
}
|
||||
} catch (fetchError) {
|
||||
console.error('로그인 요청 중 fetch 오류:', fetchError);
|
||||
} catch (error: any) {
|
||||
console.error('로그인 요청 중 예외:', error);
|
||||
const errorMessage = error.message || '로그인 중 오류가 발생했습니다.';
|
||||
|
||||
// 오류 발생 시 프록시 설정 확인 정보 출력
|
||||
const usingProxy = isCorsProxyEnabled();
|
||||
const proxyType = getProxyType();
|
||||
console.log(`오류 발생 시 CORS 설정 - 프록시 사용: ${usingProxy ? '예' : '아니오'}, 타입: ${proxyType}`);
|
||||
|
||||
// Cloudflare 프록시 추천 메시지 추가
|
||||
const errorMessage = handleNetworkError(fetchError);
|
||||
let enhancedMessage = errorMessage;
|
||||
|
||||
if (!usingProxy || proxyType !== 'cloudflare') {
|
||||
enhancedMessage = `${errorMessage} (설정에서 Cloudflare CORS 프록시 사용을 권장합니다)`;
|
||||
}
|
||||
|
||||
showAuthToast('로그인 요청 실패', enhancedMessage, 'destructive');
|
||||
|
||||
return { error: { message: enhancedMessage }, user: null };
|
||||
showAuthToast('로그인 요청 실패', errorMessage, 'destructive');
|
||||
return { error: { message: errorMessage }, user: null };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { showAuthToast, verifyServerConnection } from '@/utils/auth';
|
||||
import { signUpWithDirectApi } from './signUpUtils';
|
||||
|
||||
/**
|
||||
* 회원가입 기능 - Supabase Cloud 환경에 최적화
|
||||
*/
|
||||
export const signUp = async (email: string, password: string, username: string) => {
|
||||
try {
|
||||
// 서버 연결 상태 확인
|
||||
@@ -15,137 +17,74 @@ export const signUp = async (email: string, password: string, username: string)
|
||||
|
||||
console.log('회원가입 시도:', email);
|
||||
|
||||
// Supabase anon 키 확인
|
||||
const supabaseKey = localStorage.getItem('supabase_key');
|
||||
if (!supabaseKey || supabaseKey.includes('your-onpremise-anon-key')) {
|
||||
showAuthToast('설정 오류', 'Supabase 설정이 올바르지 않습니다. 설정 페이지에서 확인해주세요.', 'destructive');
|
||||
return { error: { message: 'Supabase 설정이 올바르지 않습니다. 설정 페이지에서 확인해주세요.' }, user: null };
|
||||
}
|
||||
|
||||
// 현재 브라우저 URL 가져오기
|
||||
const currentUrl = window.location.origin;
|
||||
// 해시 대신 쿼리 파라미터 방식으로 URL 구성 (auth_callback 파라미터 추가)
|
||||
const redirectUrl = `${currentUrl}/login?auth_callback=true`;
|
||||
|
||||
console.log('이메일 인증 리디렉션 URL:', redirectUrl);
|
||||
|
||||
// 기본 회원가입 시도
|
||||
try {
|
||||
// 디버깅용 로그
|
||||
console.log('Supabase 회원가입 요청 시작 - 이메일:', email, '사용자명:', username);
|
||||
// 회원가입 요청
|
||||
const { data, error } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
data: {
|
||||
username, // 사용자 이름을 메타데이터에 저장
|
||||
},
|
||||
emailRedirectTo: redirectUrl
|
||||
}
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('회원가입 오류:', error);
|
||||
|
||||
const { data, error } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
data: {
|
||||
username, // 사용자 이름을 메타데이터에 저장
|
||||
},
|
||||
emailRedirectTo: redirectUrl // 현재 도메인 기반 리디렉션 URL 사용
|
||||
}
|
||||
});
|
||||
// 오류 메시지 처리
|
||||
let errorMessage = error.message;
|
||||
|
||||
console.log('Supabase 회원가입 응답:', { data, error });
|
||||
|
||||
if (error) {
|
||||
console.error('회원가입 오류:', error);
|
||||
|
||||
// REST API 오류인 경우 직접 API 호출 시도
|
||||
if (error.message.includes('json') ||
|
||||
error.message.includes('Unexpected end') ||
|
||||
error.message.includes('404') ||
|
||||
error.message.includes('Not Found') ||
|
||||
error.message.includes('Failed to fetch')) {
|
||||
console.warn('기본 회원가입 실패, 직접 API 호출 시도:', error.message);
|
||||
|
||||
// 직접 API 호출에도 현재 도메인 기반 리디렉션 URL 전달
|
||||
return await signUpWithDirectApi(email, password, username, redirectUrl);
|
||||
}
|
||||
|
||||
// 401 오류 감지 및 처리
|
||||
if (error.message.includes('401') || error.message.includes('권한이 없습니다') ||
|
||||
error.message.includes('Unauthorized') || error.status === 401) {
|
||||
const errorMessage = '회원가입 권한이 없습니다. Supabase 설정 또는 권한을 확인하세요.';
|
||||
showAuthToast('회원가입 실패', errorMessage, 'destructive');
|
||||
return { error: { message: errorMessage }, user: null, redirectToSettings: true };
|
||||
}
|
||||
|
||||
// 기타 오류 처리
|
||||
let errorMessage = error.message;
|
||||
|
||||
if (error.message.includes('User already registered')) {
|
||||
errorMessage = '이미 등록된 사용자입니다.';
|
||||
} else if (error.message.includes('Signup not allowed')) {
|
||||
errorMessage = '회원가입이 허용되지 않습니다.';
|
||||
} else if (error.message.includes('Email link invalid')) {
|
||||
errorMessage = '이메일 링크가 유효하지 않습니다.';
|
||||
}
|
||||
|
||||
showAuthToast('회원가입 실패', errorMessage, 'destructive');
|
||||
return { error: { message: errorMessage }, user: null };
|
||||
if (error.message.includes('User already registered')) {
|
||||
errorMessage = '이미 등록된 사용자입니다.';
|
||||
} else if (error.message.includes('Signup not allowed')) {
|
||||
errorMessage = '회원가입이 허용되지 않습니다.';
|
||||
} else if (error.message.includes('Email link invalid')) {
|
||||
errorMessage = '이메일 링크가 유효하지 않습니다.';
|
||||
}
|
||||
|
||||
// 회원가입 성공
|
||||
if (data && data.user) {
|
||||
// 이메일 확인이 필요한지 확인
|
||||
const isEmailConfirmationRequired = data.user.identities &&
|
||||
data.user.identities.length > 0 &&
|
||||
!data.user.identities[0].identity_data?.email_verified;
|
||||
|
||||
if (isEmailConfirmationRequired) {
|
||||
// 인증 메일 전송 성공 메시지와 이메일 확인 안내
|
||||
showAuthToast('회원가입 성공', '인증 메일이 발송되었습니다. 스팸 폴더도 확인해주세요.', 'default');
|
||||
console.log('인증 메일 발송됨:', email);
|
||||
|
||||
return {
|
||||
error: null,
|
||||
user: data.user,
|
||||
message: '이메일 인증 필요',
|
||||
emailConfirmationRequired: true
|
||||
};
|
||||
} else {
|
||||
showAuthToast('회원가입 성공', '환영합니다!', 'default');
|
||||
return { error: null, user: data.user };
|
||||
}
|
||||
}
|
||||
|
||||
// 사용자 데이터가 없는 경우 (드물게 발생)
|
||||
console.warn('회원가입 응답은 성공했지만 사용자 데이터가 없습니다');
|
||||
showAuthToast('회원가입 성공', '계정이 생성되었습니다. 이메일 인증을 완료한 후 로그인해주세요.', 'default');
|
||||
return {
|
||||
error: null,
|
||||
user: { email },
|
||||
message: '회원가입 완료',
|
||||
emailConfirmationRequired: true
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('기본 회원가입 프로세스 예외:', error);
|
||||
|
||||
// 401 오류 감지 및 처리
|
||||
if (error.status === 401 || (error.message && error.message.includes('401'))) {
|
||||
const errorMessage = '회원가입 권한이 없습니다. Supabase 설정 또는 권한을 확인하세요.';
|
||||
showAuthToast('회원가입 실패', errorMessage, 'destructive');
|
||||
return { error: { message: errorMessage }, user: null, redirectToSettings: true };
|
||||
}
|
||||
|
||||
// 직접 API 호출로 대체 시도
|
||||
if (error.message && (
|
||||
error.message.includes('json') ||
|
||||
error.message.includes('fetch') ||
|
||||
error.message.includes('404') ||
|
||||
error.message.includes('Not Found') ||
|
||||
error.message.includes('timed out') ||
|
||||
error.message.includes('Failed to fetch'))) {
|
||||
console.warn('직접 API 호출로 재시도:', error);
|
||||
|
||||
// 현재 도메인 기반 리디렉션 URL 전달
|
||||
return await signUpWithDirectApi(email, password, username, redirectUrl);
|
||||
}
|
||||
|
||||
// 기타 예외 처리
|
||||
showAuthToast('회원가입 예외', error.message || '알 수 없는 오류', 'destructive');
|
||||
return { error: { message: error.message || '알 수 없는 오류' }, user: null };
|
||||
showAuthToast('회원가입 실패', errorMessage, 'destructive');
|
||||
return { error: { message: errorMessage }, user: null };
|
||||
}
|
||||
|
||||
// 회원가입 성공
|
||||
if (data && data.user) {
|
||||
// 이메일 확인이 필요한지 확인
|
||||
const isEmailConfirmationRequired = data.user.identities &&
|
||||
data.user.identities.length > 0 &&
|
||||
!data.user.identities[0].identity_data?.email_verified;
|
||||
|
||||
if (isEmailConfirmationRequired) {
|
||||
showAuthToast('회원가입 성공', '인증 메일이 발송되었습니다. 스팸 폴더도 확인해주세요.', 'default');
|
||||
console.log('인증 메일 발송됨:', email);
|
||||
|
||||
return {
|
||||
error: null,
|
||||
user: data.user,
|
||||
message: '이메일 인증 필요',
|
||||
emailConfirmationRequired: true
|
||||
};
|
||||
} else {
|
||||
showAuthToast('회원가입 성공', '환영합니다!', 'default');
|
||||
return { error: null, user: data.user };
|
||||
}
|
||||
}
|
||||
|
||||
// 사용자 데이터가 없는 경우 (드물게 발생)
|
||||
console.warn('회원가입 응답은 성공했지만 사용자 데이터가 없습니다');
|
||||
showAuthToast('회원가입 성공', '계정이 생성되었습니다. 이메일 인증을 완료한 후 로그인해주세요.', 'default');
|
||||
return {
|
||||
error: null,
|
||||
user: { email },
|
||||
message: '회원가입 완료',
|
||||
emailConfirmationRequired: true
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('회원가입 전역 예외:', error);
|
||||
showAuthToast('회원가입 오류', error.message || '알 수 없는 오류', 'destructive');
|
||||
|
||||
@@ -1,156 +1,87 @@
|
||||
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { parseResponse, showAuthToast } from '@/utils/auth';
|
||||
import { sendSignUpApiRequest, getStatusErrorMessage } from './signUpApiCalls';
|
||||
import { handleSignUpApiError, handleResponseError } from './signUpErrorHandlers';
|
||||
|
||||
/**
|
||||
* 직접 API 호출을 통한 회원가입
|
||||
* 회원가입 기능 - Supabase Cloud 환경에 최적화
|
||||
*/
|
||||
export const signUpWithDirectApi = async (email: string, password: string, username: string, redirectUrl?: string) => {
|
||||
try {
|
||||
console.log('직접 API 호출로 회원가입 시도 중');
|
||||
|
||||
// Supabase 키 가져오기
|
||||
const supabaseKey = localStorage.getItem('supabase_key') || supabase.supabaseKey;
|
||||
|
||||
// Supabase 키 유효성 검사
|
||||
if (!supabaseKey || supabaseKey.includes('your-onpremise-anon-key')) {
|
||||
return {
|
||||
error: { message: 'Supabase 설정이 올바르지 않습니다. 설정 페이지에서 확인해주세요.' },
|
||||
user: null,
|
||||
redirectToSettings: true
|
||||
};
|
||||
}
|
||||
console.log('Supabase Cloud 회원가입 시도 중');
|
||||
|
||||
// 리디렉션 URL 설정 (전달되지 않은 경우 기본값 사용)
|
||||
// 해시(#) 대신 쿼리 파라미터(?token=) 방식으로 URL 구성
|
||||
const finalRedirectUrl = redirectUrl || `${window.location.origin}/login?auth_callback=true`;
|
||||
console.log('이메일 인증 리디렉션 URL (API):', finalRedirectUrl);
|
||||
console.log('이메일 인증 리디렉션 URL:', finalRedirectUrl);
|
||||
|
||||
// API 요청 전송
|
||||
const response = await sendSignUpApiRequest(email, password, username, finalRedirectUrl, supabaseKey);
|
||||
|
||||
// 401 오류 처리 (권한 없음)
|
||||
if (response.status === 401) {
|
||||
showAuthToast('회원가입 실패', '회원가입 권한이 없습니다. Supabase 설정 또는 권한을 확인하세요.', 'destructive');
|
||||
return {
|
||||
error: { message: '회원가입 권한이 없습니다. Supabase 설정 또는 권한을 확인하세요.' },
|
||||
user: null,
|
||||
redirectToSettings: true
|
||||
};
|
||||
}
|
||||
|
||||
// HTTP 상태 코드 확인
|
||||
if (response.status === 404) {
|
||||
showAuthToast('회원가입 실패', '서버 경로를 찾을 수 없습니다. Supabase URL을 확인하세요.', 'destructive');
|
||||
return {
|
||||
error: { message: '서버 경로를 찾을 수 없습니다. Supabase URL을 확인하세요.' },
|
||||
user: null,
|
||||
redirectToSettings: true
|
||||
};
|
||||
}
|
||||
|
||||
// 응답 내용 가져오기
|
||||
const responseText = await response.text();
|
||||
console.log('회원가입 응답 내용:', responseText);
|
||||
|
||||
let responseData;
|
||||
try {
|
||||
// 응답이 비어있지 않은 경우에만 JSON 파싱 시도
|
||||
responseData = responseText && responseText.trim() !== '' ? JSON.parse(responseText) : {};
|
||||
} catch (e) {
|
||||
console.warn('JSON 파싱 실패:', e, '원본 응답:', responseText);
|
||||
|
||||
// 401 응답은 인증 실패로 처리
|
||||
if (response.status === 401) {
|
||||
return {
|
||||
error: {
|
||||
message: '회원가입 권한이 없습니다. Supabase 설정 또는 권한을 확인하세요.'
|
||||
},
|
||||
user: null,
|
||||
redirectToSettings: true
|
||||
};
|
||||
// Supabase Cloud API를 통한 회원가입 요청
|
||||
const { data, error } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
data: {
|
||||
username // 사용자 이름을 메타데이터에 저장
|
||||
},
|
||||
emailRedirectTo: finalRedirectUrl
|
||||
}
|
||||
});
|
||||
|
||||
// 오류 처리
|
||||
if (error) {
|
||||
console.error('회원가입 오류:', error);
|
||||
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
// 성공 응답이지만 JSON이 아닌 경우 (빈 응답 등)
|
||||
responseData = { success: true };
|
||||
} else {
|
||||
responseData = { error: '서버 응답을 처리할 수 없습니다' };
|
||||
let errorMessage = error.message;
|
||||
if (error.message.includes('User already registered')) {
|
||||
errorMessage = '이미 등록된 사용자입니다.';
|
||||
} else if (error.message.includes('Signup not allowed')) {
|
||||
errorMessage = '회원가입이 허용되지 않습니다.';
|
||||
} else if (error.message.includes('Email link invalid')) {
|
||||
errorMessage = '이메일 링크가 유효하지 않습니다.';
|
||||
}
|
||||
}
|
||||
|
||||
// 응답 에러 처리
|
||||
const errorResult = handleResponseError(responseData);
|
||||
if (errorResult) return errorResult;
|
||||
|
||||
// 응답 상태 코드가 성공(2xx)이면서 사용자 데이터가 있는 경우
|
||||
if (response.ok && responseData && responseData.id) {
|
||||
return processSuccessfulSignup(responseData, email, password);
|
||||
}
|
||||
// 응답이 성공(2xx)이지만, 사용자 정보가 없는 경우 또는 응답 본문이 비어있는 경우
|
||||
else if (response.ok) {
|
||||
// 응답 본문이 비어 있는 경우는 서버가 성공을 반환했지만 데이터가 없는 경우 (일부 Supabase 버전에서 발생)
|
||||
showAuthToast('회원가입 요청 완료', '회원가입 요청이 처리되었습니다. 이메일을 확인하거나 로그인을 시도해보세요.', 'default');
|
||||
return {
|
||||
error: null,
|
||||
user: { email },
|
||||
message: '회원가입 처리 완료'
|
||||
};
|
||||
}
|
||||
// 401 오류인 경우 인증 실패로 처리
|
||||
else if (response.status === 401) {
|
||||
const errorMessage = '회원가입 권한이 없습니다. Supabase 설정 또는 권한을 확인하세요.';
|
||||
showAuthToast('회원가입 실패', errorMessage, 'destructive');
|
||||
return { error: { message: errorMessage }, user: null, redirectToSettings: true };
|
||||
}
|
||||
// 다른 모든 오류 상태
|
||||
else {
|
||||
// 응답 상태 코드에 따른 오류 메시지
|
||||
const errorMessage = getStatusErrorMessage(response.status);
|
||||
|
||||
showAuthToast('회원가입 실패', errorMessage, 'destructive');
|
||||
return { error: { message: errorMessage }, user: null };
|
||||
}
|
||||
} catch (error: any) {
|
||||
return handleSignUpApiError(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 성공적인 회원가입 응답 처리
|
||||
*/
|
||||
const processSuccessfulSignup = async (responseData: any, email: string, password: string) => {
|
||||
const user = {
|
||||
id: responseData.id,
|
||||
email: responseData.email,
|
||||
user_metadata: responseData.user_metadata || { username: responseData.user_metadata?.username || '' },
|
||||
app_metadata: responseData.app_metadata || {},
|
||||
created_at: responseData.created_at,
|
||||
};
|
||||
|
||||
const confirmEmail = !responseData.confirmed_at;
|
||||
|
||||
if (confirmEmail) {
|
||||
showAuthToast('회원가입 성공', '이메일 인증을 완료해주세요.', 'default');
|
||||
return {
|
||||
error: null,
|
||||
user,
|
||||
message: '이메일 인증 필요',
|
||||
emailConfirmationRequired: true
|
||||
};
|
||||
} else {
|
||||
showAuthToast('회원가입 성공', '환영합니다!', 'default');
|
||||
|
||||
// 성공 시 바로 로그인 세션 설정 시도
|
||||
try {
|
||||
await supabase.auth.signInWithPassword({ email, password });
|
||||
} catch (loginError) {
|
||||
console.warn('자동 로그인 실패:', loginError);
|
||||
// 무시하고 계속 진행 (회원가입은 성공)
|
||||
// 회원가입 성공
|
||||
if (data && data.user) {
|
||||
// 이메일 확인이 필요한지 확인
|
||||
const isEmailConfirmationRequired = data.user.identities &&
|
||||
data.user.identities.length > 0 &&
|
||||
!data.user.identities[0].identity_data?.email_verified;
|
||||
|
||||
if (isEmailConfirmationRequired) {
|
||||
// 인증 메일 전송 성공 메시지와 이메일 확인 안내
|
||||
showAuthToast('회원가입 성공', '인증 메일이 발송되었습니다. 스팸 폴더도 확인해주세요.', 'default');
|
||||
console.log('인증 메일 발송됨:', email);
|
||||
|
||||
return {
|
||||
error: null,
|
||||
user: data.user,
|
||||
message: '이메일 인증 필요',
|
||||
emailConfirmationRequired: true
|
||||
};
|
||||
} else {
|
||||
showAuthToast('회원가입 성공', '환영합니다!', 'default');
|
||||
return { error: null, user: data.user };
|
||||
}
|
||||
}
|
||||
|
||||
return { error: null, user };
|
||||
// 사용자 데이터가 없는 경우 (드물게 발생)
|
||||
console.warn('회원가입 응답은 성공했지만 사용자 데이터가 없습니다');
|
||||
showAuthToast('회원가입 성공', '계정이 생성되었습니다. 이메일 인증을 완료한 후 로그인해주세요.', 'default');
|
||||
|
||||
return {
|
||||
error: null,
|
||||
user: { email },
|
||||
message: '회원가입 완료',
|
||||
emailConfirmationRequired: true
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('회원가입 중 예외 발생:', error);
|
||||
|
||||
const errorMessage = error.message || '알 수 없는 오류가 발생했습니다.';
|
||||
showAuthToast('회원가입 오류', errorMessage, 'destructive');
|
||||
|
||||
return { error: { message: errorMessage }, user: null };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -53,78 +53,51 @@ export const calculateCategorySpending = (
|
||||
}));
|
||||
};
|
||||
|
||||
// 예산 데이터 업데이트 계산
|
||||
// 예산 데이터 업데이트 계산 - 완전히 수정된 함수
|
||||
export const calculateUpdatedBudgetData = (
|
||||
prevBudgetData: BudgetData,
|
||||
type: BudgetPeriod,
|
||||
amount: number
|
||||
): BudgetData => {
|
||||
if (type === 'monthly') {
|
||||
const dailyAmount = Math.round(amount / 30);
|
||||
const weeklyAmount = Math.round(amount / 4.3);
|
||||
|
||||
return {
|
||||
daily: {
|
||||
targetAmount: dailyAmount,
|
||||
spentAmount: prevBudgetData.daily.spentAmount,
|
||||
remainingAmount: Math.max(0, dailyAmount - prevBudgetData.daily.spentAmount)
|
||||
},
|
||||
weekly: {
|
||||
targetAmount: weeklyAmount,
|
||||
spentAmount: prevBudgetData.weekly.spentAmount,
|
||||
remainingAmount: Math.max(0, weeklyAmount - prevBudgetData.weekly.spentAmount)
|
||||
},
|
||||
monthly: {
|
||||
targetAmount: amount,
|
||||
spentAmount: prevBudgetData.monthly.spentAmount,
|
||||
remainingAmount: Math.max(0, amount - prevBudgetData.monthly.spentAmount)
|
||||
}
|
||||
};
|
||||
console.log(`예산 업데이트 계산: 타입=${type}, 금액=${amount}`);
|
||||
|
||||
// 모든 타입에 대해 월간 예산을 기준으로 계산
|
||||
let monthlyAmount = amount;
|
||||
|
||||
// 선택된 탭이 월간이 아닌 경우, 올바른 월간 값으로 변환
|
||||
if (type === 'daily') {
|
||||
// 일일 예산이 입력된 경우: 일일 * 30 = 월간
|
||||
monthlyAmount = amount * 30;
|
||||
console.log(`일일 예산 ${amount}원 → 월간 예산 ${monthlyAmount}원으로 변환`);
|
||||
} else if (type === 'weekly') {
|
||||
// 주간 예산이 설정되면 월간 예산도 자동 계산
|
||||
const monthlyAmount = Math.round(amount * 4.3);
|
||||
const dailyAmount = Math.round(amount / 7);
|
||||
|
||||
return {
|
||||
daily: {
|
||||
targetAmount: dailyAmount,
|
||||
spentAmount: prevBudgetData.daily.spentAmount,
|
||||
remainingAmount: Math.max(0, dailyAmount - prevBudgetData.daily.spentAmount)
|
||||
},
|
||||
weekly: {
|
||||
targetAmount: amount,
|
||||
spentAmount: prevBudgetData.weekly.spentAmount,
|
||||
remainingAmount: Math.max(0, amount - prevBudgetData.weekly.spentAmount)
|
||||
},
|
||||
monthly: {
|
||||
targetAmount: monthlyAmount,
|
||||
spentAmount: prevBudgetData.monthly.spentAmount,
|
||||
remainingAmount: Math.max(0, monthlyAmount - prevBudgetData.monthly.spentAmount)
|
||||
}
|
||||
};
|
||||
} else {
|
||||
// 일일 예산이 설정되면 주간/월간 예산도 자동 계산
|
||||
const weeklyAmount = Math.round(amount * 7);
|
||||
const monthlyAmount = Math.round(amount * 30);
|
||||
|
||||
return {
|
||||
daily: {
|
||||
targetAmount: amount,
|
||||
spentAmount: prevBudgetData.daily.spentAmount,
|
||||
remainingAmount: Math.max(0, amount - prevBudgetData.daily.spentAmount)
|
||||
},
|
||||
weekly: {
|
||||
targetAmount: weeklyAmount,
|
||||
spentAmount: prevBudgetData.weekly.spentAmount,
|
||||
remainingAmount: Math.max(0, weeklyAmount - prevBudgetData.weekly.spentAmount)
|
||||
},
|
||||
monthly: {
|
||||
targetAmount: monthlyAmount,
|
||||
spentAmount: prevBudgetData.monthly.spentAmount,
|
||||
remainingAmount: Math.max(0, monthlyAmount - prevBudgetData.monthly.spentAmount)
|
||||
}
|
||||
};
|
||||
// 주간 예산이 입력된 경우: 주간 * 4.3 = 월간
|
||||
monthlyAmount = Math.round(amount * 4.3);
|
||||
console.log(`주간 예산 ${amount}원 → 월간 예산 ${monthlyAmount}원으로 변환`);
|
||||
}
|
||||
|
||||
// 월간 예산을 기준으로 일일, 주간 예산 계산
|
||||
const dailyAmount = Math.round(monthlyAmount / 30);
|
||||
const weeklyAmount = Math.round(monthlyAmount / 4.3);
|
||||
|
||||
console.log(`최종 예산 계산: 월간=${monthlyAmount}원, 주간=${weeklyAmount}원, 일일=${dailyAmount}원`);
|
||||
|
||||
return {
|
||||
daily: {
|
||||
targetAmount: dailyAmount,
|
||||
spentAmount: prevBudgetData.daily.spentAmount,
|
||||
remainingAmount: Math.max(0, dailyAmount - prevBudgetData.daily.spentAmount)
|
||||
},
|
||||
weekly: {
|
||||
targetAmount: weeklyAmount,
|
||||
spentAmount: prevBudgetData.weekly.spentAmount,
|
||||
remainingAmount: Math.max(0, weeklyAmount - prevBudgetData.weekly.spentAmount)
|
||||
},
|
||||
monthly: {
|
||||
targetAmount: monthlyAmount,
|
||||
spentAmount: prevBudgetData.monthly.spentAmount,
|
||||
remainingAmount: Math.max(0, monthlyAmount - prevBudgetData.monthly.spentAmount)
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// 지출액 계산 (일일, 주간, 월간)
|
||||
|
||||
@@ -119,18 +119,28 @@ export const useBudgetDataState = (transactions: any[]) => {
|
||||
) => {
|
||||
try {
|
||||
console.log(`예산 목표 업데이트: ${type}, 금액: ${amount}`);
|
||||
// 월간 예산 직접 업데이트 (카테고리 예산이 없는 경우)
|
||||
if (!newCategoryBudgets) {
|
||||
const updatedBudgetData = calculateUpdatedBudgetData(budgetData, type, amount);
|
||||
console.log('새 예산 데이터:', updatedBudgetData);
|
||||
|
||||
// 상태 및 스토리지 둘 다 업데이트
|
||||
setBudgetData(updatedBudgetData);
|
||||
saveBudgetDataToStorage(updatedBudgetData);
|
||||
|
||||
// 저장 시간 업데이트
|
||||
localStorage.setItem('lastBudgetSaveTime', new Date().toISOString());
|
||||
|
||||
// 금액이 유효한지 확인
|
||||
if (isNaN(amount) || amount <= 0) {
|
||||
console.error('유효하지 않은 예산 금액:', amount);
|
||||
toast({
|
||||
title: "예산 설정 오류",
|
||||
description: "유효한 예산 금액을 입력해주세요.",
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 예산 업데이트 (카테고리 예산이 있든 없든 무조건 실행)
|
||||
const updatedBudgetData = calculateUpdatedBudgetData(budgetData, type, amount);
|
||||
console.log('새 예산 데이터:', updatedBudgetData);
|
||||
|
||||
// 상태 및 스토리지 둘 다 업데이트
|
||||
setBudgetData(updatedBudgetData);
|
||||
saveBudgetDataToStorage(updatedBudgetData);
|
||||
|
||||
// 저장 시간 업데이트
|
||||
localStorage.setItem('lastBudgetSaveTime', new Date().toISOString());
|
||||
} catch (error) {
|
||||
console.error('예산 목표 업데이트 중 오류:', error);
|
||||
toast({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Transaction } from '../types';
|
||||
import {
|
||||
@@ -11,6 +12,7 @@ import { toast } from '@/hooks/useToast.wrapper'; // 래퍼 사용
|
||||
export const useTransactionState = () => {
|
||||
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
||||
const [lastDeletedId, setLastDeletedId] = useState<string | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// 초기 트랜잭션 로드 및 이벤트 리스너 설정
|
||||
useEffect(() => {
|
||||
@@ -72,9 +74,15 @@ export const useTransactionState = () => {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 트랜잭션 삭제 함수
|
||||
// 트랜잭션 삭제 함수 - 안정성 개선
|
||||
const deleteTransaction = useCallback((transactionId: string) => {
|
||||
console.log('트랜잭션 삭제:', transactionId);
|
||||
// 이미 삭제 중이면 중복 삭제 방지
|
||||
if (isDeleting) {
|
||||
console.log('이미 삭제 작업이 진행 중입니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('트랜잭션 삭제 시작:', transactionId);
|
||||
|
||||
// 중복 삭제 방지
|
||||
if (lastDeletedId === transactionId) {
|
||||
@@ -82,24 +90,50 @@ export const useTransactionState = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDeleting(true);
|
||||
setLastDeletedId(transactionId);
|
||||
|
||||
setTransactions(prev => {
|
||||
const updated = prev.filter(transaction => transaction.id !== transactionId);
|
||||
saveTransactionsToStorage(updated);
|
||||
|
||||
// 토스트는 한 번만 호출
|
||||
toast({
|
||||
title: "지출이 삭제되었습니다",
|
||||
description: "지출 항목이 성공적으로 삭제되었습니다.",
|
||||
try {
|
||||
setTransactions(prev => {
|
||||
// 기존 트랜잭션 목록 백업 (문제 발생 시 복원용)
|
||||
const originalTransactions = [...prev];
|
||||
|
||||
// 삭제할 항목 필터링
|
||||
const updated = prev.filter(transaction => transaction.id !== transactionId);
|
||||
|
||||
// 항목이 실제로 삭제되었는지 확인
|
||||
if (updated.length === originalTransactions.length) {
|
||||
console.log('삭제할 트랜잭션을 찾을 수 없음:', transactionId);
|
||||
setIsDeleting(false);
|
||||
return originalTransactions;
|
||||
}
|
||||
|
||||
// 저장소에 업데이트된 목록 저장
|
||||
saveTransactionsToStorage(updated);
|
||||
|
||||
// 토스트 메시지 표시
|
||||
toast({
|
||||
title: "지출이 삭제되었습니다",
|
||||
description: "지출 항목이 성공적으로 삭제되었습니다.",
|
||||
});
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
// 5초 후 lastDeletedId 초기화
|
||||
setTimeout(() => setLastDeletedId(null), 5000);
|
||||
}, [lastDeletedId]);
|
||||
} catch (error) {
|
||||
console.error('트랜잭션 삭제 중 오류 발생:', error);
|
||||
toast({
|
||||
title: "삭제 실패",
|
||||
description: "지출 항목 삭제 중 오류가 발생했습니다.",
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
// 삭제 상태 초기화 (1초 후)
|
||||
setTimeout(() => {
|
||||
setIsDeleting(false);
|
||||
setLastDeletedId(null);
|
||||
}, 1000);
|
||||
}
|
||||
}, [lastDeletedId, isDeleting]);
|
||||
|
||||
// 트랜잭션 초기화 함수
|
||||
const resetTransactions = useCallback(() => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { clearAllCategoryBudgets } from './categoryStorage';
|
||||
import { clearAllBudgetData } from './budgetStorage';
|
||||
import { DEFAULT_CATEGORY_BUDGETS } from '../budgetUtils';
|
||||
import { getInitialBudgetData } from '../budgetUtils';
|
||||
import { toast } from '@/components/ui/use-toast';
|
||||
import { toast } from '@/hooks/useToast.wrapper';
|
||||
|
||||
/**
|
||||
* 모든 데이터 초기화 (첫 로그인 상태)
|
||||
@@ -23,18 +23,18 @@ export const resetAllData = (): void => {
|
||||
'categoryBudgets',
|
||||
'budgetData',
|
||||
'budget',
|
||||
'monthlyExpenses', // 월간 지출 데이터
|
||||
'categorySpending', // 카테고리별 지출 데이터
|
||||
'expenseAnalytics', // 지출 분석 데이터
|
||||
'expenseHistory', // 지출 이력
|
||||
'budgetHistory', // 예산 이력
|
||||
'analyticsCache', // 분석 캐시 데이터
|
||||
'monthlyTotals', // 월간 합계 데이터
|
||||
'analytics', // 분석 페이지 데이터
|
||||
'dailyBudget', // 일일 예산
|
||||
'weeklyBudget', // 주간 예산
|
||||
'monthlyBudget', // 월간 예산
|
||||
'chartData', // 차트 데이터
|
||||
'monthlyExpenses',
|
||||
'categorySpending',
|
||||
'expenseAnalytics',
|
||||
'expenseHistory',
|
||||
'budgetHistory',
|
||||
'analyticsCache',
|
||||
'monthlyTotals',
|
||||
'analytics',
|
||||
'dailyBudget',
|
||||
'weeklyBudget',
|
||||
'monthlyBudget',
|
||||
'chartData',
|
||||
];
|
||||
|
||||
try {
|
||||
@@ -42,6 +42,7 @@ export const resetAllData = (): void => {
|
||||
dataKeys.forEach(key => {
|
||||
console.log(`삭제 중: ${key}`);
|
||||
localStorage.removeItem(key);
|
||||
localStorage.removeItem(`${key}_backup`); // 백업 키도 함께 삭제
|
||||
});
|
||||
|
||||
// 파일별 초기화 함수 호출
|
||||
@@ -60,15 +61,16 @@ export const resetAllData = (): void => {
|
||||
localStorage.setItem('categoryBudgets_backup', JSON.stringify(DEFAULT_CATEGORY_BUDGETS));
|
||||
localStorage.setItem('transactions_backup', JSON.stringify([]));
|
||||
|
||||
// 이벤트 발생시켜 데이터 로드 트리거
|
||||
try {
|
||||
window.dispatchEvent(new Event('transactionUpdated'));
|
||||
window.dispatchEvent(new Event('budgetDataUpdated'));
|
||||
window.dispatchEvent(new Event('categoryBudgetsUpdated'));
|
||||
window.dispatchEvent(new StorageEvent('storage'));
|
||||
} catch (e) {
|
||||
console.error('이벤트 발생 오류:', e);
|
||||
}
|
||||
// 이벤트 발생시켜 데이터 로드 트리거 - 이벤트 순서 최적화
|
||||
const events = [
|
||||
new Event('transactionUpdated'),
|
||||
new Event('budgetDataUpdated'),
|
||||
new Event('categoryBudgetsUpdated'),
|
||||
new StorageEvent('storage')
|
||||
];
|
||||
|
||||
// 모든 이벤트 동시에 발생
|
||||
events.forEach(event => window.dispatchEvent(event));
|
||||
|
||||
// 중요: 사용자 설정 값 복원 (백업한 값이 있는 경우)
|
||||
if (dontShowWelcomeValue) {
|
||||
|
||||
@@ -10,24 +10,41 @@ export const loadTransactionsFromStorage = (): Transaction[] => {
|
||||
// 메인 스토리지에서 먼저 시도
|
||||
const storedTransactions = localStorage.getItem('transactions');
|
||||
if (storedTransactions) {
|
||||
const parsedData = JSON.parse(storedTransactions);
|
||||
console.log('트랜잭션 로드 완료, 항목 수:', parsedData.length);
|
||||
return parsedData;
|
||||
try {
|
||||
const parsedData = JSON.parse(storedTransactions);
|
||||
console.log('트랜잭션 로드 완료, 항목 수:', parsedData.length);
|
||||
|
||||
// 트랜잭션 데이터 유효성 검사
|
||||
if (Array.isArray(parsedData)) {
|
||||
return parsedData;
|
||||
} else {
|
||||
console.error('트랜잭션 데이터가 배열이 아닙니다:', typeof parsedData);
|
||||
return [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('트랜잭션 데이터 파싱 오류:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 백업에서 시도
|
||||
const backupTransactions = localStorage.getItem('transactions_backup');
|
||||
if (backupTransactions) {
|
||||
const parsedBackup = JSON.parse(backupTransactions);
|
||||
console.log('백업에서 트랜잭션 복구, 항목 수:', parsedBackup.length);
|
||||
// 메인 스토리지도 복구
|
||||
localStorage.setItem('transactions', backupTransactions);
|
||||
return parsedBackup;
|
||||
try {
|
||||
const parsedBackup = JSON.parse(backupTransactions);
|
||||
console.log('백업에서 트랜잭션 복구, 항목 수:', parsedBackup.length);
|
||||
// 메인 스토리지도 복구
|
||||
localStorage.setItem('transactions', backupTransactions);
|
||||
return parsedBackup;
|
||||
} catch (e) {
|
||||
console.error('백업 트랜잭션 데이터 파싱 오류:', e);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('트랜잭션 데이터 파싱 오류:', error);
|
||||
console.error('트랜잭션 데이터 로드 중 오류:', error);
|
||||
}
|
||||
// 데이터가 없을 경우 빈 배열 반환 (샘플 데이터 생성하지 않음)
|
||||
|
||||
// 데이터가 없을 경우 빈 배열 반환
|
||||
console.log('트랜잭션 데이터 없음, 빈 배열 반환');
|
||||
return [];
|
||||
};
|
||||
|
||||
@@ -36,6 +53,8 @@ export const loadTransactionsFromStorage = (): Transaction[] => {
|
||||
*/
|
||||
export const saveTransactionsToStorage = (transactions: Transaction[]): void => {
|
||||
try {
|
||||
console.log('트랜잭션 저장 시작, 항목 수:', transactions.length);
|
||||
|
||||
// 먼저 문자열로 변환
|
||||
const dataString = JSON.stringify(transactions);
|
||||
|
||||
@@ -49,6 +68,9 @@ export const saveTransactionsToStorage = (transactions: Transaction[]): void =>
|
||||
// 스토리지 이벤트 수동 트리거 (동일 창에서도 감지하기 위함)
|
||||
try {
|
||||
window.dispatchEvent(new Event('transactionUpdated'));
|
||||
window.dispatchEvent(new CustomEvent('transactionChanged', {
|
||||
detail: { type: 'save', count: transactions.length }
|
||||
}));
|
||||
window.dispatchEvent(new StorageEvent('storage', {
|
||||
key: 'transactions',
|
||||
newValue: dataString
|
||||
@@ -88,6 +110,9 @@ export const clearAllTransactions = (): void => {
|
||||
|
||||
// 스토리지 이벤트 수동 트리거
|
||||
window.dispatchEvent(new Event('transactionUpdated'));
|
||||
window.dispatchEvent(new CustomEvent('transactionChanged', {
|
||||
detail: { type: 'clear' }
|
||||
}));
|
||||
window.dispatchEvent(new StorageEvent('storage', {
|
||||
key: 'transactions',
|
||||
newValue: emptyData
|
||||
|
||||
@@ -1,70 +1,133 @@
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { BudgetPeriod } from './types';
|
||||
import { useTransactionState } from './hooks/useTransactionState';
|
||||
import { useCategoryBudgetState } from './hooks/useCategoryBudgetState';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { BudgetData, BudgetPeriod, Transaction } from './types';
|
||||
import { useBudgetDataState } from './hooks/useBudgetDataState';
|
||||
import { useCategorySpending } from './hooks/useCategorySpending';
|
||||
import { useBudgetBackup } from './hooks/useBudgetBackup';
|
||||
import { useBudgetReset } from './hooks/useBudgetReset';
|
||||
import { useExtendedBudgetUpdate } from './hooks/useExtendedBudgetUpdate';
|
||||
import { useCategoryBudgetState } from './hooks/useCategoryBudgetState';
|
||||
import { useTransactionState } from './hooks/useTransactionState';
|
||||
import { calculateCategorySpending } from './budgetUtils';
|
||||
import { toast } from '@/hooks/useToast.wrapper';
|
||||
import { loadCategoryBudgetsFromStorage, saveCategoryBudgetsToStorage } from './storage';
|
||||
|
||||
/**
|
||||
* 예산 상태 관리를 위한 메인 훅
|
||||
*/
|
||||
export const useBudgetState = () => {
|
||||
// 각 상태 관리 훅 사용
|
||||
const {
|
||||
transactions,
|
||||
addTransaction,
|
||||
updateTransaction,
|
||||
deleteTransaction,
|
||||
resetTransactions
|
||||
// 트랜잭션 상태 관리
|
||||
const {
|
||||
transactions,
|
||||
addTransaction,
|
||||
updateTransaction,
|
||||
deleteTransaction
|
||||
} = useTransactionState();
|
||||
|
||||
const {
|
||||
categoryBudgets,
|
||||
setCategoryBudgets,
|
||||
// 예산 데이터 상태 관리
|
||||
const {
|
||||
budgetData,
|
||||
selectedTab,
|
||||
setSelectedTab,
|
||||
handleBudgetGoalUpdate,
|
||||
resetBudgetData
|
||||
} = useBudgetDataState(transactions);
|
||||
|
||||
// 카테고리 예산 상태 관리
|
||||
const {
|
||||
categoryBudgets,
|
||||
updateCategoryBudgets,
|
||||
resetCategoryBudgets
|
||||
} = useCategoryBudgetState();
|
||||
|
||||
const {
|
||||
budgetData,
|
||||
selectedTab,
|
||||
setSelectedTab,
|
||||
handleBudgetGoalUpdate,
|
||||
resetBudgetData: resetBudgetDataInternal
|
||||
} = useBudgetDataState(transactions);
|
||||
|
||||
const { getCategorySpending } = useCategorySpending(transactions, categoryBudgets);
|
||||
|
||||
// 자동 백업 사용
|
||||
useBudgetBackup(budgetData, categoryBudgets, transactions);
|
||||
|
||||
// 확장된 예산 업데이트 로직 사용
|
||||
const { extendedBudgetGoalUpdate } = useExtendedBudgetUpdate(
|
||||
budgetData,
|
||||
categoryBudgets,
|
||||
handleBudgetGoalUpdate,
|
||||
updateCategoryBudgets
|
||||
);
|
||||
|
||||
// 리셋 로직 사용
|
||||
const { resetBudgetData } = useBudgetReset(
|
||||
resetTransactions,
|
||||
resetCategoryBudgets,
|
||||
resetBudgetDataInternal
|
||||
);
|
||||
|
||||
// 카테고리별 지출 계산
|
||||
const getCategorySpending = useCallback(() => {
|
||||
return calculateCategorySpending(transactions, categoryBudgets);
|
||||
}, [transactions, categoryBudgets]);
|
||||
|
||||
// 예산 목표 업데이트 함수 (기존 함수 래핑)
|
||||
const handleBudgetUpdate = useCallback((
|
||||
type: BudgetPeriod,
|
||||
amount: number,
|
||||
newCategoryBudgets?: Record<string, number>
|
||||
) => {
|
||||
console.log(`예산 업데이트 시작: ${type}, 금액: ${amount}, 카테고리 예산:`, newCategoryBudgets);
|
||||
|
||||
try {
|
||||
// 금액이 유효한지 확인
|
||||
if (isNaN(amount) || amount <= 0) {
|
||||
console.error('유효하지 않은 예산 금액:', amount);
|
||||
toast({
|
||||
title: "예산 설정 오류",
|
||||
description: "유효한 예산 금액을 입력해주세요.",
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 카테고리 예산이 제공된 경우
|
||||
if (newCategoryBudgets) {
|
||||
console.log('카테고리 예산도 함께 업데이트:', newCategoryBudgets);
|
||||
|
||||
// 카테고리 예산의 합계 검증 - 가져온 totalBudget과 카테고리 총합이 같아야 함
|
||||
const categoryTotal = Object.values(newCategoryBudgets).reduce((sum, val) => sum + val, 0);
|
||||
console.log(`카테고리 예산 합계: ${categoryTotal}, 입력 금액: ${amount}`);
|
||||
|
||||
// 금액이 카테고리 합계와 다르면 로그 기록 (허용 오차 ±10)
|
||||
if (Math.abs(categoryTotal - amount) > 10) {
|
||||
console.warn('카테고리 예산 합계와 총 예산이 일치하지 않음 - 카테고리 합계를 사용함');
|
||||
// 카테고리 합계를 기준으로 예산 설정
|
||||
amount = categoryTotal;
|
||||
}
|
||||
|
||||
// 카테고리 예산 저장
|
||||
updateCategoryBudgets(newCategoryBudgets);
|
||||
saveCategoryBudgetsToStorage(newCategoryBudgets);
|
||||
console.log('카테고리 예산 저장 완료');
|
||||
}
|
||||
|
||||
// 항상 월간 타입으로 예산 업데이트 (BudgetTabContent에서는 항상 월간 예산을 전달)
|
||||
handleBudgetGoalUpdate('monthly', amount);
|
||||
console.log('예산 데이터 업데이트 완료');
|
||||
|
||||
} catch (error) {
|
||||
console.error('예산 업데이트 오류:', error);
|
||||
toast({
|
||||
title: "예산 업데이트 실패",
|
||||
description: "예산 설정 중 오류가 발생했습니다.",
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
}, [handleBudgetGoalUpdate, updateCategoryBudgets]);
|
||||
|
||||
// 모든 데이터 초기화
|
||||
const resetAllData = useCallback(() => {
|
||||
resetBudgetData?.();
|
||||
resetCategoryBudgets();
|
||||
}, [resetBudgetData, resetCategoryBudgets]);
|
||||
|
||||
// 상태 디버깅 (개발 시 유용)
|
||||
useEffect(() => {
|
||||
console.log('BudgetState 훅 - 현재 상태:');
|
||||
console.log('- 예산 데이터:', budgetData);
|
||||
console.log('- 카테고리 예산:', categoryBudgets);
|
||||
console.log('- 트랜잭션 수:', transactions.length);
|
||||
}, [budgetData, categoryBudgets, transactions.length]);
|
||||
|
||||
return {
|
||||
// 데이터
|
||||
transactions,
|
||||
categoryBudgets,
|
||||
budgetData,
|
||||
categoryBudgets,
|
||||
selectedTab,
|
||||
|
||||
// 상태 변경 함수
|
||||
setSelectedTab,
|
||||
addTransaction,
|
||||
updateTransaction,
|
||||
deleteTransaction,
|
||||
handleBudgetGoalUpdate: extendedBudgetGoalUpdate,
|
||||
handleBudgetGoalUpdate: handleBudgetUpdate, // 래핑된 함수 사용
|
||||
|
||||
// 도우미 함수
|
||||
getCategorySpending,
|
||||
resetBudgetData
|
||||
|
||||
// 데이터 초기화
|
||||
resetBudgetData: resetAllData
|
||||
};
|
||||
};
|
||||
|
||||
41
src/hooks/sync/syncResultHandler.ts
Normal file
41
src/hooks/sync/syncResultHandler.ts
Normal 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"
|
||||
});
|
||||
}
|
||||
};
|
||||
52
src/hooks/sync/useManualSync.ts
Normal file
52
src/hooks/sync/useManualSync.ts
Normal 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 };
|
||||
};
|
||||
31
src/hooks/sync/useSyncStatus.ts
Normal file
31
src/hooks/sync/useSyncStatus.ts
Normal 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 };
|
||||
};
|
||||
130
src/hooks/sync/useSyncToggle.ts
Normal file
130
src/hooks/sync/useSyncToggle.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -1,3 +1,3 @@
|
||||
|
||||
export const TOAST_LIMIT = 5 // 최대 5개로 제한
|
||||
export const TOAST_REMOVE_DELAY = 3000 // 3초 후 DOM에서 제거 (5초에서 3초로 변경)
|
||||
export const TOAST_REMOVE_DELAY = 3000 // 3초 후 DOM에서 제거
|
||||
|
||||
@@ -1,23 +1,47 @@
|
||||
|
||||
// 월 이름 상수와 날짜 관련 유틸리티 함수
|
||||
/**
|
||||
* 한글 월 이름 배열
|
||||
*/
|
||||
export const MONTHS_KR = [
|
||||
'1월', '2월', '3월', '4월', '5월', '6월',
|
||||
'7월', '8월', '9월', '10월', '11월', '12월'
|
||||
];
|
||||
|
||||
// 월 이름 상수
|
||||
export const MONTHS_KR = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'];
|
||||
|
||||
// 현재 월 가져오기
|
||||
export const getCurrentMonth = () => {
|
||||
const today = new Date();
|
||||
return MONTHS_KR[today.getMonth()];
|
||||
/**
|
||||
* 현재 월 가져오기
|
||||
*/
|
||||
export const getCurrentMonth = (): string => {
|
||||
const now = new Date();
|
||||
const month = now.getMonth(); // 0-indexed
|
||||
return `${MONTHS_KR[month]}`;
|
||||
};
|
||||
|
||||
// 이전 월 가져오기
|
||||
export const getPrevMonth = (currentMonth: string) => {
|
||||
const index = MONTHS_KR.indexOf(currentMonth);
|
||||
return index > 0 ? MONTHS_KR[index - 1] : MONTHS_KR[11];
|
||||
/**
|
||||
* 이전 월 가져오기
|
||||
*/
|
||||
export const getPrevMonth = (currentMonth: string): string => {
|
||||
const currentMonthIdx = MONTHS_KR.findIndex(m => m === currentMonth);
|
||||
|
||||
if (currentMonthIdx === 0) {
|
||||
// 1월인 경우 12월로 변경
|
||||
return `${MONTHS_KR[11]}`;
|
||||
} else {
|
||||
const prevMonthIdx = currentMonthIdx - 1;
|
||||
return `${MONTHS_KR[prevMonthIdx]}`;
|
||||
}
|
||||
};
|
||||
|
||||
// 다음 월 가져오기
|
||||
export const getNextMonth = (currentMonth: string) => {
|
||||
const index = MONTHS_KR.indexOf(currentMonth);
|
||||
return index < 11 ? MONTHS_KR[index + 1] : MONTHS_KR[0];
|
||||
/**
|
||||
* 다음 월 가져오기
|
||||
*/
|
||||
export const getNextMonth = (currentMonth: string): string => {
|
||||
const currentMonthIdx = MONTHS_KR.findIndex(m => m === currentMonth);
|
||||
|
||||
if (currentMonthIdx === 11) {
|
||||
// 12월인 경우 1월로 변경
|
||||
return `${MONTHS_KR[0]}`;
|
||||
} else {
|
||||
const nextMonthIdx = currentMonthIdx + 1;
|
||||
return `${MONTHS_KR[nextMonthIdx]}`;
|
||||
}
|
||||
};
|
||||
|
||||
105
src/hooks/transactions/deleteTransaction.ts
Normal file
105
src/hooks/transactions/deleteTransaction.ts
Normal 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;
|
||||
};
|
||||
47
src/hooks/transactions/filterOperations/index.ts
Normal file
47
src/hooks/transactions/filterOperations/index.ts
Normal 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
|
||||
};
|
||||
};
|
||||
17
src/hooks/transactions/filterOperations/types.ts
Normal file
17
src/hooks/transactions/filterOperations/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
34
src/hooks/transactions/filterOperations/useMonthSelection.ts
Normal file
34
src/hooks/transactions/filterOperations/useMonthSelection.ts
Normal 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
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -2,9 +2,47 @@
|
||||
import { Transaction } from '@/components/TransactionCard';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { isSyncEnabled } from '@/utils/syncUtils';
|
||||
import { useAuth } from '@/contexts/auth/AuthProvider';
|
||||
import { formatISO } from 'date-fns';
|
||||
|
||||
// Supabase와 트랜잭션 동기화
|
||||
// ISO 형식으로 날짜 변환 (Supabase 저장용)
|
||||
const convertDateToISO = (dateStr: string): string => {
|
||||
try {
|
||||
// 이미 ISO 형식인 경우 그대로 반환
|
||||
if (dateStr.match(/^\d{4}-\d{2}-\d{2}T/)) {
|
||||
return dateStr;
|
||||
}
|
||||
|
||||
// "오늘, 시간" 형식 처리
|
||||
if (dateStr.includes('오늘')) {
|
||||
const today = new Date();
|
||||
|
||||
// 시간 추출 시도
|
||||
const timeMatch = dateStr.match(/(\d{1,2}):(\d{2})/);
|
||||
if (timeMatch) {
|
||||
const hours = parseInt(timeMatch[1], 10);
|
||||
const minutes = parseInt(timeMatch[2], 10);
|
||||
today.setHours(hours, minutes, 0, 0);
|
||||
}
|
||||
|
||||
return formatISO(today);
|
||||
}
|
||||
|
||||
// 일반 날짜 문자열은 그대로 Date 객체로 변환 시도
|
||||
const date = new Date(dateStr);
|
||||
if (!isNaN(date.getTime())) {
|
||||
return formatISO(date);
|
||||
}
|
||||
|
||||
// 변환 실패 시 현재 시간 반환
|
||||
console.warn(`날짜 변환 오류: "${dateStr}"를 ISO 형식으로 변환할 수 없습니다.`);
|
||||
return formatISO(new Date());
|
||||
} catch (error) {
|
||||
console.error(`날짜 변환 오류: "${dateStr}"`, error);
|
||||
return formatISO(new Date());
|
||||
}
|
||||
};
|
||||
|
||||
// Supabase와 트랜잭션 동기화 - Cloud 최적화 버전
|
||||
export const syncTransactionsWithSupabase = async (user: any, transactions: Transaction[]): Promise<Transaction[]> => {
|
||||
if (!user || !isSyncEnabled()) return transactions;
|
||||
|
||||
@@ -51,17 +89,20 @@ export const syncTransactionsWithSupabase = async (user: any, transactions: Tran
|
||||
return transactions;
|
||||
};
|
||||
|
||||
// Supabase에 트랜잭션 업데이트
|
||||
// Supabase에 트랜잭션 업데이트 - Cloud 최적화 버전
|
||||
export const updateTransactionInSupabase = async (user: any, transaction: Transaction): Promise<void> => {
|
||||
if (!user || !isSyncEnabled()) return;
|
||||
|
||||
try {
|
||||
// 날짜를 ISO 형식으로 변환
|
||||
const isoDate = convertDateToISO(transaction.date);
|
||||
|
||||
const { error } = await supabase.from('transactions')
|
||||
.upsert({
|
||||
user_id: user.id,
|
||||
title: transaction.title,
|
||||
amount: transaction.amount,
|
||||
date: transaction.date,
|
||||
date: isoDate, // ISO 형식 사용
|
||||
category: transaction.category,
|
||||
type: transaction.type,
|
||||
transaction_id: transaction.id
|
||||
@@ -69,13 +110,15 @@ export const updateTransactionInSupabase = async (user: any, transaction: Transa
|
||||
|
||||
if (error) {
|
||||
console.error('트랜잭션 업데이트 오류:', error);
|
||||
} else {
|
||||
console.log('Supabase 트랜잭션 업데이트 성공:', transaction.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Supabase 업데이트 오류:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Supabase에서 트랜잭션 삭제
|
||||
// Supabase에서 트랜잭션 삭제 - Cloud 최적화 버전
|
||||
export const deleteTransactionFromSupabase = async (user: any, transactionId: string): Promise<void> => {
|
||||
if (!user || !isSyncEnabled()) return;
|
||||
|
||||
@@ -86,6 +129,8 @@ export const deleteTransactionFromSupabase = async (user: any, transactionId: st
|
||||
|
||||
if (error) {
|
||||
console.error('트랜잭션 삭제 오류:', error);
|
||||
} else {
|
||||
console.log('Supabase 트랜잭션 삭제 성공:', transactionId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Supabase 삭제 오류:', error);
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
29
src/hooks/transactions/transactionOperations/index.ts
Normal file
29
src/hooks/transactions/transactionOperations/index.ts
Normal 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
|
||||
};
|
||||
};
|
||||
12
src/hooks/transactions/transactionOperations/types.ts
Normal file
12
src/hooks/transactions/transactionOperations/types.ts
Normal 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>;
|
||||
}
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -44,13 +44,13 @@ export const useTransactionsCore = () => {
|
||||
handlePrevMonth,
|
||||
handleNextMonth,
|
||||
getTotalExpenses
|
||||
} = useTransactionsFiltering(
|
||||
} = useTransactionsFiltering({
|
||||
transactions,
|
||||
selectedMonth,
|
||||
setSelectedMonth,
|
||||
searchQuery,
|
||||
setFilteredTransactions
|
||||
);
|
||||
});
|
||||
|
||||
// 트랜잭션 작업
|
||||
const {
|
||||
@@ -61,11 +61,12 @@ export const useTransactionsCore = () => {
|
||||
setTransactions
|
||||
);
|
||||
|
||||
// 이벤트 리스너
|
||||
// 이벤트 리스너 - 삭제 이벤트 포함
|
||||
useTransactionsEvents(loadTransactions, refreshKey);
|
||||
|
||||
// 데이터 강제 새로고침
|
||||
const refreshTransactions = useCallback(() => {
|
||||
console.log('트랜잭션 강제 새로고침');
|
||||
setRefreshKey(prev => prev + 1);
|
||||
loadTransactions();
|
||||
}, [loadTransactions, setRefreshKey]);
|
||||
|
||||
@@ -2,58 +2,111 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* 트랜잭션 이벤트 관련 훅
|
||||
* 각종 이벤트 리스너를 설정합니다.
|
||||
* 트랜잭션 이벤트 리스너 훅
|
||||
* 트랜잭션 업데이트 이벤트를 리스닝합니다.
|
||||
*/
|
||||
export const useTransactionsEvents = (
|
||||
loadTransactions: () => void,
|
||||
refreshKey: number
|
||||
) => {
|
||||
// 이벤트 리스너 설정
|
||||
useEffect(() => {
|
||||
console.log('useTransactions - 이벤트 리스너 설정');
|
||||
|
||||
// 트랜잭션 업데이트 이벤트 리스너
|
||||
const handleTransactionUpdated = () => {
|
||||
console.log('트랜잭션 업데이트 이벤트 감지됨');
|
||||
loadTransactions();
|
||||
// 바운싱 방지 변수
|
||||
let isProcessing = false;
|
||||
|
||||
// 트랜잭션 업데이트 이벤트
|
||||
const handleTransactionUpdate = (e?: any) => {
|
||||
console.log('트랜잭션 업데이트 이벤트 감지:', e);
|
||||
|
||||
// 처리 중 중복 호출 방지
|
||||
if (isProcessing) return;
|
||||
|
||||
isProcessing = true;
|
||||
setTimeout(() => {
|
||||
loadTransactions();
|
||||
isProcessing = false;
|
||||
}, 150);
|
||||
};
|
||||
|
||||
// 스토리지 변경 이벤트 리스너
|
||||
const handleStorageChange = (e: StorageEvent) => {
|
||||
// 트랜잭션 삭제 이벤트
|
||||
const handleTransactionDelete = () => {
|
||||
console.log('트랜잭션 삭제 이벤트 감지됨');
|
||||
|
||||
// 처리 중 중복 호출 방지
|
||||
if (isProcessing) return;
|
||||
|
||||
isProcessing = true;
|
||||
setTimeout(() => {
|
||||
loadTransactions();
|
||||
isProcessing = false;
|
||||
}, 200);
|
||||
};
|
||||
|
||||
// 트랜잭션 변경 이벤트 (통합 이벤트)
|
||||
const handleTransactionChange = (e: CustomEvent) => {
|
||||
console.log('트랜잭션 변경 이벤트 감지:', e.detail?.type);
|
||||
|
||||
// 처리 중 중복 호출 방지
|
||||
if (isProcessing) return;
|
||||
|
||||
isProcessing = true;
|
||||
setTimeout(() => {
|
||||
loadTransactions();
|
||||
isProcessing = false;
|
||||
}, 150);
|
||||
};
|
||||
|
||||
// 스토리지 이벤트
|
||||
const handleStorageEvent = (e: StorageEvent) => {
|
||||
if (e.key === 'transactions' || e.key === null) {
|
||||
console.log('로컬 스토리지 변경 감지됨:', e.key);
|
||||
loadTransactions();
|
||||
console.log('스토리지 이벤트 감지:', e.key);
|
||||
|
||||
// 처리 중 중복 호출 방지
|
||||
if (isProcessing) return;
|
||||
|
||||
isProcessing = true;
|
||||
setTimeout(() => {
|
||||
loadTransactions();
|
||||
isProcessing = false;
|
||||
}, 150);
|
||||
}
|
||||
};
|
||||
|
||||
// 페이지 포커스/가시성 이벤트 리스너
|
||||
// 포커스 이벤트
|
||||
const handleFocus = () => {
|
||||
console.log('창 포커스 - 트랜잭션 새로고침');
|
||||
loadTransactions();
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
console.log('페이지 가시성 변경 - 트랜잭션 새로고침');
|
||||
console.log('창 포커스: 트랜잭션 새로고침');
|
||||
|
||||
// 처리 중 중복 호출 방지
|
||||
if (isProcessing) return;
|
||||
|
||||
isProcessing = true;
|
||||
setTimeout(() => {
|
||||
loadTransactions();
|
||||
}
|
||||
isProcessing = false;
|
||||
}, 200);
|
||||
};
|
||||
|
||||
// 이벤트 리스너 등록
|
||||
window.addEventListener('transactionUpdated', handleTransactionUpdated);
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
window.addEventListener('transactionUpdated', handleTransactionUpdate);
|
||||
window.addEventListener('transactionDeleted', handleTransactionDelete);
|
||||
window.addEventListener('transactionChanged', handleTransactionChange as EventListener);
|
||||
window.addEventListener('storage', handleStorageEvent);
|
||||
window.addEventListener('focus', handleFocus);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
// 컴포넌트 마운트시에만 수동으로 트랜잭션 업데이트 이벤트 발생
|
||||
window.dispatchEvent(new Event('transactionUpdated'));
|
||||
// 새로고침 키가 변경되면 데이터 로드
|
||||
if (!isProcessing) {
|
||||
loadTransactions();
|
||||
}
|
||||
|
||||
// 클린업 함수
|
||||
return () => {
|
||||
window.removeEventListener('transactionUpdated', handleTransactionUpdated);
|
||||
window.removeEventListener('storage', handleStorageChange);
|
||||
console.log('useTransactions - 이벤트 리스너 제거');
|
||||
window.removeEventListener('transactionUpdated', handleTransactionUpdate);
|
||||
window.removeEventListener('transactionDeleted', handleTransactionDelete);
|
||||
window.removeEventListener('transactionChanged', handleTransactionChange as EventListener);
|
||||
window.removeEventListener('storage', handleStorageEvent);
|
||||
window.removeEventListener('focus', handleFocus);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [loadTransactions, refreshKey]);
|
||||
};
|
||||
|
||||
@@ -1,55 +1,5 @@
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { Transaction } from '@/components/TransactionCard';
|
||||
import {
|
||||
filterTransactionsByMonth,
|
||||
filterTransactionsByQuery,
|
||||
calculateTotalExpenses
|
||||
} from './filterUtils';
|
||||
import { getPrevMonth, getNextMonth } from './dateUtils';
|
||||
import { useTransactionsFiltering } from './filterOperations';
|
||||
|
||||
/**
|
||||
* 트랜잭션 필터링 관련 훅
|
||||
* 월별 및 검색어 필터링 기능을 제공합니다.
|
||||
*/
|
||||
export const useTransactionsFiltering = (
|
||||
transactions: Transaction[],
|
||||
selectedMonth: string,
|
||||
setSelectedMonth: (month: string) => void,
|
||||
searchQuery: string,
|
||||
setFilteredTransactions: (transactions: Transaction[]) => void
|
||||
) => {
|
||||
// 월 변경 처리
|
||||
const handlePrevMonth = () => {
|
||||
setSelectedMonth(getPrevMonth(selectedMonth));
|
||||
};
|
||||
|
||||
const handleNextMonth = () => {
|
||||
setSelectedMonth(getNextMonth(selectedMonth));
|
||||
};
|
||||
|
||||
// 필터 적용
|
||||
useEffect(() => {
|
||||
// 1. 월별 필터링
|
||||
let filtered = filterTransactionsByMonth(transactions, selectedMonth);
|
||||
|
||||
// 2. 검색어 필터링
|
||||
if (searchQuery.trim()) {
|
||||
filtered = filterTransactionsByQuery(filtered, searchQuery);
|
||||
}
|
||||
|
||||
console.log('필터링 결과:', filtered.length, '트랜잭션');
|
||||
setFilteredTransactions(filtered);
|
||||
}, [transactions, selectedMonth, searchQuery, setFilteredTransactions]);
|
||||
|
||||
// 필터링된 트랜잭션의 총 지출 계산
|
||||
const getTotalExpenses = (filteredTransactions: Transaction[]) => {
|
||||
return calculateTotalExpenses(filteredTransactions);
|
||||
};
|
||||
|
||||
return {
|
||||
handlePrevMonth,
|
||||
handleNextMonth,
|
||||
getTotalExpenses
|
||||
};
|
||||
};
|
||||
// 기존 훅을 그대로 내보내기
|
||||
export { useTransactionsFiltering };
|
||||
|
||||
@@ -25,9 +25,11 @@ export const useTransactionsLoader = (
|
||||
const localTransactions = loadTransactionsFromStorage();
|
||||
setTransactions(localTransactions);
|
||||
|
||||
// 예산 가져오기
|
||||
// 예산 가져오기 (월간 예산만 설정)
|
||||
const budgetAmount = loadBudgetFromStorage();
|
||||
setTotalBudget(budgetAmount);
|
||||
|
||||
console.log('로드된 예산 금액:', budgetAmount);
|
||||
} catch (err) {
|
||||
console.error('트랜잭션 로드 중 오류:', err);
|
||||
setError('데이터를 불러오는 중 문제가 발생했습니다.');
|
||||
|
||||
@@ -1,251 +1,2 @@
|
||||
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { Transaction } from '@/components/TransactionCard';
|
||||
import { useAuth } from '@/contexts/auth/AuthProvider';
|
||||
import { toast } from '@/hooks/useToast.wrapper';
|
||||
import { saveTransactionsToStorage } from './storageUtils';
|
||||
import {
|
||||
updateTransactionInSupabase,
|
||||
deleteTransactionFromSupabase
|
||||
} from './supabaseUtils';
|
||||
|
||||
/**
|
||||
* 트랜잭션 작업 관련 훅
|
||||
* 트랜잭션 업데이트, 삭제 기능을 제공합니다.
|
||||
*/
|
||||
export const useTransactionsOperations = (
|
||||
transactions: Transaction[],
|
||||
setTransactions: React.Dispatch<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
|
||||
};
|
||||
};
|
||||
export { useTransactionsOperations } from './transactionOperations';
|
||||
|
||||
@@ -3,8 +3,9 @@ import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useToast } from '@/hooks/useToast.wrapper';
|
||||
import { resetAllStorageData } from '@/utils/storageUtils';
|
||||
import { clearCloudData } from '@/utils/syncUtils';
|
||||
import { clearCloudData } from '@/utils/sync/clearCloudData';
|
||||
import { useAuth } from '@/contexts/auth/AuthProvider';
|
||||
import { isSyncEnabled, setSyncEnabled } from '@/utils/sync/syncSettings';
|
||||
|
||||
export interface DataResetResult {
|
||||
isCloudResetSuccess: boolean | null;
|
||||
@@ -22,6 +23,10 @@ export const useDataReset = () => {
|
||||
setIsResetting(true);
|
||||
console.log('모든 데이터 초기화 시작');
|
||||
|
||||
// 현재 동기화 설정 저장
|
||||
const syncWasEnabled = isSyncEnabled();
|
||||
console.log('데이터 초기화 전 동기화 상태:', syncWasEnabled ? '활성화' : '비활성화');
|
||||
|
||||
// 클라우드 데이터 초기화 먼저 시도 (로그인 상태인 경우)
|
||||
let cloudResetSuccess = false;
|
||||
if (user) {
|
||||
@@ -82,23 +87,31 @@ export const useDataReset = () => {
|
||||
}
|
||||
});
|
||||
|
||||
// 중요: 동기화 설정은 초기화 후 항상 비활성화
|
||||
setSyncEnabled(false);
|
||||
console.log('동기화 설정이 비활성화되었습니다.');
|
||||
|
||||
// 마지막 동기화 시간은 초기화
|
||||
localStorage.removeItem('lastSync');
|
||||
|
||||
// 스토리지 이벤트 트리거하여 다른 컴포넌트에 변경 알림
|
||||
window.dispatchEvent(new Event('transactionUpdated'));
|
||||
window.dispatchEvent(new Event('budgetDataUpdated'));
|
||||
window.dispatchEvent(new Event('categoryBudgetsUpdated'));
|
||||
window.dispatchEvent(new StorageEvent('storage'));
|
||||
window.dispatchEvent(new Event('auth-state-changed')); // 동기화 상태 변경 이벤트 추가
|
||||
|
||||
// 클라우드 초기화 상태에 따라 다른 메시지 표시
|
||||
if (user) {
|
||||
if (cloudResetSuccess) {
|
||||
toast({
|
||||
title: "모든 데이터가 초기화되었습니다.",
|
||||
description: "로컬 및 클라우드의 모든 데이터가 초기화되었습니다.",
|
||||
description: "로컬 및 클라우드의 모든 데이터가 초기화되었으며, 동기화 설정이 비활성화되었습니다.",
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: "로컬 데이터만 초기화됨",
|
||||
description: "로컬 데이터는 초기화되었지만, 클라우드 데이터 초기화 중 문제가 발생했습니다.",
|
||||
description: "로컬 데이터는 초기화되었지만, 클라우드 데이터 초기화 중 문제가 발생했습니다. 동기화 설정이 비활성화되었습니다.",
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
@@ -111,8 +124,8 @@ export const useDataReset = () => {
|
||||
|
||||
console.log('모든 데이터 초기화 완료');
|
||||
|
||||
// 초기화 후 설정 페이지로 이동 (타임아웃으로 약간 지연)
|
||||
setTimeout(() => navigate('/settings'), 500);
|
||||
// 페이지 리프레시 대신 navigate 사용 (딜레이 제거)
|
||||
navigate('/settings', { replace: true });
|
||||
|
||||
return { isCloudResetSuccess: cloudResetSuccess };
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,169 +1,18 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAuth } from '@/contexts/auth';
|
||||
import { toast } from '@/hooks/useToast.wrapper';
|
||||
import {
|
||||
isSyncEnabled,
|
||||
setSyncEnabled,
|
||||
getLastSyncTime,
|
||||
trySyncAllData,
|
||||
SyncResult
|
||||
} from '@/utils/syncUtils';
|
||||
import { useSyncToggle } from './sync/useSyncToggle';
|
||||
import { useManualSync } from './sync/useManualSync';
|
||||
import { useSyncStatus } from './sync/useSyncStatus';
|
||||
|
||||
/**
|
||||
* 동기화 설정 관리를 위한 커스텀 훅
|
||||
*/
|
||||
export const useSyncSettings = () => {
|
||||
const [enabled, setEnabled] = useState(isSyncEnabled());
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [lastSync, setLastSync] = useState<string | null>(getLastSyncTime());
|
||||
const { user } = useAuth();
|
||||
|
||||
// 사용자 로그인 상태 변경 감지
|
||||
useEffect(() => {
|
||||
// 사용자 로그인 상태에 따라 동기화 설정 업데이트
|
||||
const updateSyncState = () => {
|
||||
if (!user && isSyncEnabled()) {
|
||||
// 사용자가 로그아웃했고 동기화가 활성화되어 있으면 비활성화
|
||||
setSyncEnabled(false);
|
||||
setEnabled(false);
|
||||
setLastSync(null);
|
||||
console.log('로그아웃으로 인해 동기화 설정이 비활성화되었습니다.');
|
||||
}
|
||||
|
||||
// 동기화 상태 업데이트
|
||||
setEnabled(isSyncEnabled());
|
||||
setLastSync(getLastSyncTime());
|
||||
};
|
||||
|
||||
// 초기 호출
|
||||
updateSyncState();
|
||||
|
||||
// 인증 상태 변경 이벤트 리스너
|
||||
window.addEventListener('auth-state-changed', updateSyncState);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('auth-state-changed', updateSyncState);
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
// 마지막 동기화 시간 정기적으로 업데이트
|
||||
useEffect(() => {
|
||||
const intervalId = setInterval(() => {
|
||||
setLastSync(getLastSyncTime());
|
||||
}, 10000); // 10초마다 업데이트
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, []);
|
||||
|
||||
// 동기화 토글 핸들러
|
||||
const handleSyncToggle = async (checked: boolean) => {
|
||||
if (!user && checked) {
|
||||
toast({
|
||||
title: "로그인 필요",
|
||||
description: "데이터 동기화를 위해 로그인이 필요합니다.",
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setEnabled(checked);
|
||||
setSyncEnabled(checked);
|
||||
|
||||
if (checked && user) {
|
||||
// 동기화 활성화 시 즉시 동기화 실행
|
||||
await performSync();
|
||||
}
|
||||
};
|
||||
|
||||
// 수동 동기화 핸들러
|
||||
const handleManualSync = async () => {
|
||||
if (!user) {
|
||||
toast({
|
||||
title: "로그인 필요",
|
||||
description: "데이터 동기화를 위해 로그인이 필요합니다.",
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await performSync();
|
||||
};
|
||||
|
||||
// 실제 동기화 수행 함수
|
||||
const performSync = async () => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
setSyncing(true);
|
||||
// 안전한 동기화 함수 사용
|
||||
const result = await trySyncAllData(user.id);
|
||||
|
||||
handleSyncResult(result);
|
||||
setLastSync(getLastSyncTime());
|
||||
} catch (error) {
|
||||
console.error('동기화 오류:', error);
|
||||
toast({
|
||||
title: "동기화 오류",
|
||||
description: "동기화 중 문제가 발생했습니다. 다시 시도해주세요.",
|
||||
variant: "destructive"
|
||||
});
|
||||
|
||||
// 심각한 오류 발생 시 동기화 비활성화
|
||||
if (!enabled) {
|
||||
setEnabled(false);
|
||||
setSyncEnabled(false);
|
||||
}
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 동기화 결과 처리 함수
|
||||
const handleSyncResult = (result: SyncResult) => {
|
||||
if (result.success) {
|
||||
if (result.downloadSuccess && result.uploadSuccess) {
|
||||
toast({
|
||||
title: "동기화 완료",
|
||||
description: "모든 데이터가 클라우드에 동기화되었습니다.",
|
||||
});
|
||||
} else if (result.downloadSuccess) {
|
||||
toast({
|
||||
title: "다운로드만 성공",
|
||||
description: "서버 데이터를 가져왔지만, 업로드에 실패했습니다.",
|
||||
variant: "destructive"
|
||||
});
|
||||
} else if (result.uploadSuccess) {
|
||||
toast({
|
||||
title: "업로드만 성공",
|
||||
description: "로컬 데이터를 업로드했지만, 다운로드에 실패했습니다.",
|
||||
variant: "destructive"
|
||||
});
|
||||
} else if (result.partial) {
|
||||
toast({
|
||||
title: "동기화 일부 완료",
|
||||
description: "일부 데이터만 동기화되었습니다. 다시 시도해보세요.",
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
} else {
|
||||
toast({
|
||||
title: "일부 동기화 실패",
|
||||
description: "일부 데이터 동기화 중 문제가 발생했습니다. 다시 시도해주세요.",
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 마지막 동기화 시간 포맷팅
|
||||
const formatLastSyncTime = () => {
|
||||
if (!lastSync) return "아직 동기화된 적 없음";
|
||||
|
||||
if (lastSync === '부분-다운로드') return "부분 동기화 (다운로드만)";
|
||||
|
||||
const date = new Date(lastSync);
|
||||
return `${date.toLocaleDateString()} ${date.toLocaleTimeString()}`;
|
||||
};
|
||||
const { enabled, setEnabled, handleSyncToggle } = useSyncToggle();
|
||||
const { syncing, handleManualSync } = useManualSync(user);
|
||||
const { lastSync, formatLastSyncTime } = useSyncStatus();
|
||||
|
||||
return {
|
||||
enabled,
|
||||
|
||||
@@ -10,7 +10,7 @@ let supabaseClient;
|
||||
try {
|
||||
console.log(`Supabase 클라이언트 생성 중: ${supabaseUrl}`);
|
||||
|
||||
// Supabase 클라이언트 생성
|
||||
// Supabase 클라이언트 생성 - Cloud 환경에 최적화
|
||||
supabaseClient = createClient(supabaseUrl, supabaseAnonKey, {
|
||||
auth: {
|
||||
autoRefreshToken: true,
|
||||
@@ -18,7 +18,7 @@ try {
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Supabase 클라이언트가 생성되었습니다.');
|
||||
console.log('Supabase 클라이언트가 성공적으로 생성되었습니다.');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Supabase 클라이언트 생성 오류:', error);
|
||||
|
||||
@@ -8,20 +8,20 @@ export const getSupabaseKey = () => {
|
||||
return "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InFuZXJlYnR2d3dmb2JmemRvZnR4Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDIwNTE0MzgsImV4cCI6MjA1NzYyNzQzOH0.Wm7h2DUhoQbeANuEM3wm2tz22ITrVEW8FizyLgIVmv8";
|
||||
};
|
||||
|
||||
// Supabase 키 유효성 검사
|
||||
// Supabase 키 유효성 검사 - Cloud 환경에서는 항상 유효함
|
||||
export const isValidSupabaseKey = () => {
|
||||
return true; // Supabase Cloud에서는 항상 유효함
|
||||
return true;
|
||||
};
|
||||
|
||||
// CORS 프록시 관련 함수들
|
||||
// 다음 함수들은 Cloud 환경에서는 필요 없지만 호환성을 위해 유지
|
||||
export const isCorsProxyEnabled = () => {
|
||||
return false; // Supabase Cloud에서는 CORS 프록시가 필요 없음
|
||||
return false;
|
||||
};
|
||||
|
||||
export const getProxyType = () => {
|
||||
return 'none'; // Supabase Cloud에서는 프록시가 필요 없음
|
||||
return 'none';
|
||||
};
|
||||
|
||||
export const getOriginalSupabaseUrl = () => {
|
||||
return getSupabaseUrl(); // 원본 URL 반환 (프록시 없음)
|
||||
return getSupabaseUrl();
|
||||
};
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import NavBar from '@/components/NavBar';
|
||||
import TransactionCard from '@/components/TransactionCard';
|
||||
import AddTransactionButton from '@/components/AddTransactionButton';
|
||||
import { Calendar, Search, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
|
||||
import { formatCurrency } from '@/utils/formatters';
|
||||
import { useTransactions, MONTHS_KR } from '@/hooks/transactions';
|
||||
import { useBudget } from '@/contexts/BudgetContext';
|
||||
import { useTransactions } from '@/hooks/transactions';
|
||||
import TransactionsHeader from '@/components/transactions/TransactionsHeader';
|
||||
import TransactionsContent from '@/components/transactions/TransactionsContent';
|
||||
import { Transaction } from '@/components/TransactionCard';
|
||||
import { toast } from '@/hooks/useToast.wrapper';
|
||||
|
||||
const Transactions = () => {
|
||||
const {
|
||||
@@ -17,13 +18,18 @@ const Transactions = () => {
|
||||
setSearchQuery,
|
||||
handlePrevMonth,
|
||||
handleNextMonth,
|
||||
updateTransaction,
|
||||
deleteTransaction,
|
||||
refreshTransactions,
|
||||
totalExpenses,
|
||||
deleteTransaction
|
||||
} = useTransactions();
|
||||
|
||||
const { budgetData } = useBudget();
|
||||
const [isDataLoaded, setIsDataLoaded] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
// 더블 클릭 방지용 래퍼
|
||||
const deletionTimestampRef = useRef<Record<string, number>>({});
|
||||
|
||||
// 데이터 로드 상태 관리
|
||||
useEffect(() => {
|
||||
@@ -32,31 +38,87 @@ const Transactions = () => {
|
||||
}
|
||||
}, [budgetData, isLoading]);
|
||||
|
||||
// 트랜잭션을 날짜별로 그룹화
|
||||
const groupedTransactions: Record<string, typeof transactions> = {};
|
||||
|
||||
transactions.forEach(transaction => {
|
||||
const datePart = transaction.date.split(',')[0];
|
||||
if (!groupedTransactions[datePart]) {
|
||||
groupedTransactions[datePart] = [];
|
||||
// 트랜잭션 삭제 핸들러 - 완전히 개선된 버전
|
||||
const handleTransactionDelete = async (id: string): Promise<boolean> => {
|
||||
// 삭제 중인지 확인
|
||||
if (isProcessing || deletingId) {
|
||||
console.log('이미 삭제 작업이 진행 중입니다:', deletingId);
|
||||
return true;
|
||||
}
|
||||
groupedTransactions[datePart].push(transaction);
|
||||
});
|
||||
|
||||
// 더블 클릭 방지 - 최근 2초 이내 동일한 삭제 요청이 있었는지 확인
|
||||
const now = Date.now();
|
||||
const lastDeletionTime = deletionTimestampRef.current[id] || 0;
|
||||
if (now - lastDeletionTime < 2000) { // 2초
|
||||
console.log('중복 삭제 요청 무시:', id);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 타임스탬프 업데이트
|
||||
deletionTimestampRef.current[id] = now;
|
||||
|
||||
try {
|
||||
// 삭제 상태 설정
|
||||
setIsProcessing(true);
|
||||
setDeletingId(id);
|
||||
|
||||
console.log('트랜잭션 삭제 시작 (ID):', id);
|
||||
|
||||
// 안전한 타임아웃 설정 (최대 5초)
|
||||
const timeoutPromise = new Promise<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(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
if (document.visibilityState === 'visible' && !isProcessing) {
|
||||
console.log('거래내역 페이지 보임 - 데이터 새로고침');
|
||||
// 상태 업데이트 트리거
|
||||
setIsDataLoaded(prev => !prev);
|
||||
refreshTransactions();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
console.log('거래내역 페이지 포커스 - 데이터 새로고침');
|
||||
// 상태 업데이트 트리거
|
||||
setIsDataLoaded(prev => !prev);
|
||||
if (!isProcessing) {
|
||||
console.log('거래내역 페이지 포커스 - 데이터 새로고침');
|
||||
refreshTransactions();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
@@ -66,117 +128,54 @@ const Transactions = () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
window.removeEventListener('focus', handleFocus);
|
||||
};
|
||||
}, []);
|
||||
}, [refreshTransactions, isProcessing]);
|
||||
|
||||
// 트랜잭션을 날짜별로 그룹화
|
||||
const groupTransactionsByDate = (transactions: Transaction[]): Record<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 (
|
||||
<div className="min-h-screen bg-neuro-background pb-24">
|
||||
<div className="max-w-md mx-auto px-6">
|
||||
{/* Header */}
|
||||
<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)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Month Selector */}
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<button
|
||||
className="neuro-flat p-2 rounded-full"
|
||||
onClick={handlePrevMonth}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
<TransactionsHeader
|
||||
selectedMonth={selectedMonth}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
handlePrevMonth={handlePrevMonth}
|
||||
handleNextMonth={handleNextMonth}
|
||||
budgetData={budgetData}
|
||||
totalExpenses={totalExpenses}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
|
||||
<TransactionsContent
|
||||
isLoading={isLoading}
|
||||
isProcessing={isProcessing}
|
||||
transactions={transactions}
|
||||
groupedTransactions={groupedTransactions}
|
||||
searchQuery={searchQuery}
|
||||
selectedMonth={selectedMonth}
|
||||
setSearchQuery={setSearchQuery}
|
||||
onTransactionDelete={handleTransactionDelete}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AddTransactionButton />
|
||||
|
||||
@@ -5,6 +5,8 @@ export * from './networkUtils';
|
||||
export * from './responseUtils';
|
||||
export * from './validationUtils';
|
||||
export * from './handleNetworkError';
|
||||
export * from './loginUtils';
|
||||
|
||||
// 새로운 네트워크 모듈도 직접 내보냅니다 (선택적)
|
||||
// 모듈별 직접 접근을 위한 내보내기
|
||||
export * from './network';
|
||||
export * from './login';
|
||||
|
||||
40
src/utils/auth/login/errorHandlers.ts
Normal file
40
src/utils/auth/login/errorHandlers.ts
Normal 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')
|
||||
);
|
||||
};
|
||||
4
src/utils/auth/login/index.ts
Normal file
4
src/utils/auth/login/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
|
||||
// 로그인 관련 모든 유틸리티 내보내기
|
||||
export * from './errorHandlers';
|
||||
export * from './toastHandlers';
|
||||
24
src/utils/auth/login/toastHandlers.ts
Normal file
24
src/utils/auth/login/toastHandlers.ts
Normal 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"
|
||||
});
|
||||
};
|
||||
@@ -1,68 +1,9 @@
|
||||
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
|
||||
/**
|
||||
* 로그인 오류 메시지를 처리하는 유틸리티 함수
|
||||
*/
|
||||
export const getLoginErrorMessage = (error: any): string => {
|
||||
let errorMessage = "로그인에 실패했습니다.";
|
||||
|
||||
// Supabase 오류 메시지 처리
|
||||
if (error.message) {
|
||||
if (error.message.includes("Invalid login credentials")) {
|
||||
errorMessage = "이메일 또는 비밀번호가 올바르지 않습니다.";
|
||||
} else if (error.message.includes("Email not confirmed")) {
|
||||
errorMessage = "이메일 인증이 완료되지 않았습니다. 이메일을 확인해주세요.";
|
||||
} else if (error.message.includes("JSON")) {
|
||||
errorMessage = "서버 응답 오류: JSON 파싱 실패. 네트워크 연결이나 CORS 설정을 확인하세요.";
|
||||
} else if (error.message.includes("CORS") || error.message.includes("프록시")) {
|
||||
errorMessage = "CORS 오류: 프록시 설정을 확인하거나 다른 프록시를 시도해보세요.";
|
||||
} else if (error.message.includes("fetch") || error.message.includes("네트워크")) {
|
||||
errorMessage = "네트워크 오류: 서버 연결에 실패했습니다.";
|
||||
} else if (error.message.includes("404") || error.message.includes("Not Found")) {
|
||||
errorMessage = "서버 오류: API 경로를 찾을 수 없습니다. 서버 설정을 확인하세요.";
|
||||
} else {
|
||||
errorMessage = `오류: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
return errorMessage;
|
||||
};
|
||||
|
||||
/**
|
||||
* 로그인 성공 시 사용자에게 알림을 표시합니다.
|
||||
*/
|
||||
export const showLoginSuccessToast = (mode?: string) => {
|
||||
toast({
|
||||
title: "로그인 성공",
|
||||
description: mode ? `${mode}로 로그인되었습니다.` : "환영합니다! 대시보드로 이동합니다.",
|
||||
variant: "default"
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 로그인 오류 시 사용자에게 알림을 표시합니다.
|
||||
*/
|
||||
export const showLoginErrorToast = (errorMessage: string) => {
|
||||
toast({
|
||||
title: "로그인 실패",
|
||||
description: errorMessage,
|
||||
variant: "destructive"
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* CORS 또는 JSON 관련 오류인지 확인합니다.
|
||||
*/
|
||||
export const isCorsOrJsonError = (errorMessage: string | null): boolean => {
|
||||
if (!errorMessage) return false;
|
||||
|
||||
return (
|
||||
errorMessage.includes('JSON') ||
|
||||
errorMessage.includes('CORS') ||
|
||||
errorMessage.includes('프록시') ||
|
||||
errorMessage.includes('서버 응답') ||
|
||||
errorMessage.includes('404') ||
|
||||
errorMessage.includes('Not Found')
|
||||
);
|
||||
};
|
||||
// 이 파일은 하위 모듈로 분리된 로그인 유틸리티를 다시 내보냅니다.
|
||||
// 향후 개발에서는 직접 하위 모듈을 임포트하는 것이 권장됩니다.
|
||||
export {
|
||||
getLoginErrorMessage,
|
||||
isCorsOrJsonError,
|
||||
showLoginSuccessToast,
|
||||
showLoginErrorToast
|
||||
} from './login';
|
||||
|
||||
7
src/utils/auth/network/compatUtils.ts
Normal file
7
src/utils/auth/network/compatUtils.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
|
||||
/**
|
||||
* 호환성을 위한 더미 함수들 - Cloud 환경에서는 항상 false를 반환
|
||||
*/
|
||||
export const hasCorsIssue = (): boolean => false;
|
||||
export const handleHttpUrlWithoutProxy = (): boolean => true;
|
||||
export const logProxyInfo = (): void => {};
|
||||
75
src/utils/auth/network/connectionVerifier.ts
Normal file
75
src/utils/auth/network/connectionVerifier.ts
Normal 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
|
||||
};
|
||||
}
|
||||
};
|
||||
64
src/utils/auth/network/enhancedVerifier.ts
Normal file
64
src/utils/auth/network/enhancedVerifier.ts
Normal 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: '네트워크 연결 또는 서버 주소를 확인하세요'
|
||||
};
|
||||
};
|
||||
@@ -1,9 +1,14 @@
|
||||
|
||||
// 네트워크 유틸리티 모듈
|
||||
// 모든 네트워크 유틸리티 모듈 내보내기
|
||||
export {
|
||||
verifyServerConnection,
|
||||
verifySupabaseConnection,
|
||||
hasCorsIssue,
|
||||
handleHttpUrlWithoutProxy,
|
||||
logProxyInfo,
|
||||
verifyServerConnection,
|
||||
verifySupabaseConnection
|
||||
logProxyInfo
|
||||
} from './networkUtils';
|
||||
|
||||
// 직접 접근을 위한 개별 모듈도 내보내기
|
||||
export * from './connectionVerifier';
|
||||
export * from './enhancedVerifier';
|
||||
export * from './compatUtils';
|
||||
|
||||
@@ -1,257 +1,14 @@
|
||||
|
||||
import { getSupabaseUrl, isCorsProxyEnabled, getProxyType, getOriginalSupabaseUrl } from '@/lib/supabase/config';
|
||||
// 네트워크 유틸리티 모듈을 개별 파일로 분리하여 관리하기 쉽게 구성
|
||||
import { verifyServerConnection } from './connectionVerifier';
|
||||
import { verifySupabaseConnection } from './enhancedVerifier';
|
||||
import { hasCorsIssue, handleHttpUrlWithoutProxy, logProxyInfo } from './compatUtils';
|
||||
|
||||
/**
|
||||
* CORS 문제 확인
|
||||
*/
|
||||
export const hasCorsIssue = (error: any): boolean => {
|
||||
if (!error) return false;
|
||||
|
||||
const errorMessage = error.message || '';
|
||||
return (
|
||||
errorMessage.includes('Failed to fetch') ||
|
||||
errorMessage.includes('CORS') ||
|
||||
errorMessage.includes('Network') ||
|
||||
errorMessage.includes('프록시')
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* HTTP URL이 프록시 없이 사용되고 있는지 확인하고 처리
|
||||
*/
|
||||
export const handleHttpUrlWithoutProxy = (): boolean => {
|
||||
// HTTP URL을 사용하는데 프록시가 비활성화된 경우
|
||||
const originalUrl = getOriginalSupabaseUrl();
|
||||
const usingProxy = isCorsProxyEnabled();
|
||||
|
||||
if (originalUrl.startsWith('http:') && !usingProxy) {
|
||||
// 자동으로 프록시 활성화
|
||||
localStorage.setItem('use_cors_proxy', 'true');
|
||||
localStorage.setItem('proxy_type', 'cloudflare');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* 프록시 정보 로깅
|
||||
*/
|
||||
export const logProxyInfo = (): void => {
|
||||
const usingProxy = isCorsProxyEnabled();
|
||||
const proxyType = getProxyType();
|
||||
const supabaseUrl = getSupabaseUrl();
|
||||
|
||||
console.log(`연결 테스트 - CORS 프록시: ${usingProxy ? '사용 중' : '미사용'}, 타입: ${proxyType}, URL: ${supabaseUrl}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 서버 연결 상태 검사
|
||||
*/
|
||||
export const verifyServerConnection = async (): Promise<{
|
||||
connected: boolean;
|
||||
message: string;
|
||||
statusCode?: number;
|
||||
}> => {
|
||||
try {
|
||||
const start = Date.now();
|
||||
|
||||
// Supabase URL 가져오기 (프록시 적용 URL)
|
||||
const supabaseUrl = getSupabaseUrl();
|
||||
|
||||
if (!supabaseUrl) {
|
||||
return {
|
||||
connected: false,
|
||||
message: 'Supabase URL이 설정되지 않았습니다. 설정 페이지에서 구성하세요.'
|
||||
};
|
||||
}
|
||||
|
||||
// 프록시 설정 상태 확인
|
||||
logProxyInfo();
|
||||
|
||||
// 단순 헬스 체크 요청 - 무작위 쿼리 파라미터 추가
|
||||
const cacheParam = `?_nocache=${Date.now()}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${supabaseUrl}/auth/v1/${cacheParam}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'apikey': localStorage.getItem('supabase_key') || ''
|
||||
},
|
||||
signal: AbortSignal.timeout(15000) // 타임아웃 시간 증가
|
||||
});
|
||||
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
// 200, 401, 404 응답도 서버가 살아있다는 신호로 간주
|
||||
if (response.ok || response.status === 401 || response.status === 404) {
|
||||
return {
|
||||
connected: true,
|
||||
message: `서버 연결 성공 (응답 시간: ${elapsed}ms)`,
|
||||
statusCode: response.status
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
connected: false,
|
||||
message: `서버 응답 오류: ${response.status} ${response.statusText}`,
|
||||
statusCode: response.status
|
||||
};
|
||||
}
|
||||
} catch (fetchError: any) {
|
||||
console.error('기본 연결 확인 실패, 상태 확인 시도:', fetchError);
|
||||
|
||||
// HTTP URL을 사용하는데 프록시가 비활성화된 경우 처리
|
||||
if (handleHttpUrlWithoutProxy()) {
|
||||
return {
|
||||
connected: false,
|
||||
message: 'HTTP URL에 직접 접근할 수 없어 CORS 프록시를 자동으로 활성화했습니다. 페이지를 새로고침하고 다시 시도하세요.'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// 대체 경로로 상태 확인 - 무작위 쿼리 파라미터 추가
|
||||
const altResponse = await fetch(`${supabaseUrl}/${cacheParam}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
signal: AbortSignal.timeout(15000) // 타임아웃 시간 증가
|
||||
});
|
||||
|
||||
// 어떤 응답이라도 오면 서버가 살아있다고 간주
|
||||
const elapsed = Date.now() - start;
|
||||
return {
|
||||
connected: true,
|
||||
message: `서버 연결 성공 (기본 경로, 응답 시간: ${elapsed}ms)`,
|
||||
statusCode: altResponse.status
|
||||
};
|
||||
} catch (altError) {
|
||||
console.error('기본 경로 확인도 실패:', altError);
|
||||
throw fetchError; // 원래 에러를 던짐
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('서버 연결 확인 중 오류:', error);
|
||||
|
||||
// 프록시 설정 확인
|
||||
const usingProxy = isCorsProxyEnabled();
|
||||
const proxyType = getProxyType();
|
||||
|
||||
// 오류 유형에 따른 메시지 설정
|
||||
let errorMessage = '알 수 없는 네트워크 오류';
|
||||
|
||||
if (error.message) {
|
||||
if (error.message.includes('Failed to fetch')) {
|
||||
errorMessage = 'CORS 정책 오류 또는 서버 연결 실패';
|
||||
} else if (error.message.includes('NetworkError')) {
|
||||
errorMessage = '네트워크 연결 실패';
|
||||
} else if (error.message.includes('TypeError')) {
|
||||
errorMessage = '네트워크 요청 형식 오류';
|
||||
} else if (error.message.includes('timeout') || error.message.includes('timed out')) {
|
||||
errorMessage = '서버 응답 시간 초과';
|
||||
} else if (error.message.includes('aborted')) {
|
||||
errorMessage = '요청이 중단됨';
|
||||
} else {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
// HTTP URL을 사용하는데 프록시가 비활성화된 경우 처리
|
||||
if (handleHttpUrlWithoutProxy()) {
|
||||
errorMessage = 'HTTP URL에 직접 접근할 수 없어 CORS 프록시를 자동으로 활성화했습니다. 페이지를 새로고침하고 다시 시도하세요.';
|
||||
}
|
||||
|
||||
// Cloudflare 프록시 추천 메시지 추가
|
||||
if (errorMessage.includes('CORS') || errorMessage.includes('fetch') || errorMessage.includes('네트워크')) {
|
||||
if (!usingProxy) {
|
||||
console.log('CORS 오류 감지, Cloudflare 프록시 사용 권장');
|
||||
errorMessage += '. Cloudflare CORS 프록시 사용을 권장합니다.';
|
||||
} else if (proxyType !== 'cloudflare') {
|
||||
console.log('CORS 오류 감지, Cloudflare 프록시로 변경 권장');
|
||||
errorMessage += '. Cloudflare CORS 프록시로 변경을 권장합니다.';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
connected: false,
|
||||
message: errorMessage
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 강화된 서버 연결 검사: 다양한 경로로 시도
|
||||
*/
|
||||
export const verifySupabaseConnection = async (): Promise<{
|
||||
connected: boolean;
|
||||
message: string;
|
||||
statusCode?: number;
|
||||
details?: string;
|
||||
}> => {
|
||||
const supabaseUrl = getSupabaseUrl();
|
||||
if (!supabaseUrl) {
|
||||
return {
|
||||
connected: false,
|
||||
message: 'Supabase URL이 설정되지 않았습니다'
|
||||
};
|
||||
}
|
||||
|
||||
// 프록시 정보 로깅
|
||||
const usingProxy = isCorsProxyEnabled();
|
||||
const proxyType = getProxyType();
|
||||
console.log(`강화된 연결 테스트 - 프록시: ${usingProxy ? '사용 중' : '미사용'}, 타입: ${proxyType}, URL: ${supabaseUrl}`);
|
||||
|
||||
// 무작위 쿼리 파라미터를 추가하여 캐시 방지
|
||||
const cacheParam = `?_nocache=${Date.now()}`;
|
||||
|
||||
// 다양한 경로를 순차적으로 시도
|
||||
const paths = [
|
||||
'/auth/v1/',
|
||||
'/',
|
||||
'/rest/v1/',
|
||||
'/storage/v1/'
|
||||
];
|
||||
|
||||
for (const path of paths) {
|
||||
try {
|
||||
console.log(`경로 시도: ${path}`);
|
||||
const response = await fetch(`${supabaseUrl}${path}${cacheParam}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'apikey': localStorage.getItem('supabase_key') || ''
|
||||
},
|
||||
signal: AbortSignal.timeout(15000) // 타임아웃 시간 증가
|
||||
});
|
||||
|
||||
console.log(`경로 ${path} 응답 상태:`, response.status);
|
||||
|
||||
// 어떤 응답이든 서버가 살아있다는 신호로 간주
|
||||
return {
|
||||
connected: true,
|
||||
message: `서버 연결 성공 (${path})`,
|
||||
statusCode: response.status,
|
||||
details: `${response.status} ${response.statusText}`
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn(`${path} 경로 연결 실패:`, error);
|
||||
// 계속 다음 경로 시도
|
||||
}
|
||||
}
|
||||
|
||||
// HTTP URL을 사용하는데 프록시가 비활성화된 경우 처리
|
||||
if (handleHttpUrlWithoutProxy()) {
|
||||
return {
|
||||
connected: false,
|
||||
message: 'HTTP URL에 직접 접근할 수 없어 CORS 프록시를 자동으로 활성화했습니다. 페이지를 새로고침하고 다시 시도하세요.',
|
||||
details: 'CORS 제한으로 인해 HTTP URL에 직접 접근할 수 없습니다'
|
||||
};
|
||||
}
|
||||
|
||||
// 모든 경로 시도 실패
|
||||
return {
|
||||
connected: false,
|
||||
message: '모든 Supabase 경로에 대한 연결 시도 실패',
|
||||
details: '네트워크 연결 또는 서버 주소를 확인하세요'
|
||||
};
|
||||
// 모든 기능 재내보내기
|
||||
export {
|
||||
verifyServerConnection,
|
||||
verifySupabaseConnection,
|
||||
hasCorsIssue,
|
||||
handleHttpUrlWithoutProxy,
|
||||
logProxyInfo
|
||||
};
|
||||
|
||||
@@ -65,7 +65,7 @@ export const loadBudgetFromStorage = (): number => {
|
||||
return 0;
|
||||
};
|
||||
|
||||
// 모든 데이터 완전히 초기화
|
||||
// 모든 데이터 완전히 초기화 - 성능 최적화
|
||||
export const resetAllStorageData = (): void => {
|
||||
console.log('완전 초기화 시작 - resetAllStorageData');
|
||||
|
||||
@@ -80,7 +80,7 @@ export const resetAllStorageData = (): void => {
|
||||
|
||||
// 모든 Storage 키 목록 (로그인 관련 항목 제외)
|
||||
const keysToRemove = [
|
||||
'transactions',
|
||||
'transactions',
|
||||
'budget',
|
||||
'monthlyExpenses',
|
||||
'budgetData',
|
||||
@@ -103,35 +103,34 @@ export const resetAllStorageData = (): void => {
|
||||
'syncEnabled'
|
||||
];
|
||||
|
||||
// 키 삭제
|
||||
// 키 동시에 삭제 (성능 최적화)
|
||||
keysToRemove.forEach(key => {
|
||||
console.log(`삭제 중: ${key}`);
|
||||
localStorage.removeItem(key);
|
||||
localStorage.removeItem(`${key}_backup`); // 백업 키도 함께 삭제
|
||||
});
|
||||
|
||||
// 백업 키도 삭제
|
||||
keysToRemove.forEach(key => {
|
||||
localStorage.removeItem(`${key}_backup`);
|
||||
// 기본값으로 초기화 - 한번에 처리
|
||||
const defaultData = {
|
||||
transactions: JSON.stringify([]),
|
||||
budgetData: JSON.stringify({
|
||||
daily: {targetAmount: 0, spentAmount: 0, remainingAmount: 0},
|
||||
weekly: {targetAmount: 0, spentAmount: 0, remainingAmount: 0},
|
||||
monthly: {targetAmount: 0, spentAmount: 0, remainingAmount: 0}
|
||||
}),
|
||||
categoryBudgets: JSON.stringify({
|
||||
식비: 0,
|
||||
교통비: 0,
|
||||
생활비: 0
|
||||
})
|
||||
};
|
||||
|
||||
// 모든 기본값 한번에 설정
|
||||
Object.entries(defaultData).forEach(([key, value]) => {
|
||||
localStorage.setItem(key, value);
|
||||
localStorage.setItem(`${key}_backup`, value);
|
||||
});
|
||||
|
||||
// 기본값으로 초기화
|
||||
localStorage.setItem('transactions', JSON.stringify([]));
|
||||
localStorage.setItem('budgetData', JSON.stringify({
|
||||
daily: {targetAmount: 0, spentAmount: 0, remainingAmount: 0},
|
||||
weekly: {targetAmount: 0, spentAmount: 0, remainingAmount: 0},
|
||||
monthly: {targetAmount: 0, spentAmount: 0, remainingAmount: 0}
|
||||
}));
|
||||
localStorage.setItem('categoryBudgets', JSON.stringify({
|
||||
식비: 0,
|
||||
교통비: 0,
|
||||
생활비: 0
|
||||
}));
|
||||
|
||||
// 백업 생성
|
||||
localStorage.setItem('transactions_backup', JSON.stringify([]));
|
||||
localStorage.setItem('budgetData_backup', localStorage.getItem('budgetData') || '');
|
||||
localStorage.setItem('categoryBudgets_backup', localStorage.getItem('categoryBudgets') || '');
|
||||
|
||||
// 사용자 설정 값 복원
|
||||
if (dontShowWelcomeValue) {
|
||||
localStorage.setItem('dontShowWelcome', dontShowWelcomeValue);
|
||||
@@ -154,13 +153,22 @@ export const resetAllStorageData = (): void => {
|
||||
localStorage.setItem('supabase.auth.token', supabase);
|
||||
}
|
||||
|
||||
// 이벤트 발생
|
||||
window.dispatchEvent(new Event('transactionUpdated'));
|
||||
window.dispatchEvent(new Event('budgetDataUpdated'));
|
||||
window.dispatchEvent(new Event('categoryBudgetsUpdated'));
|
||||
window.dispatchEvent(new StorageEvent('storage'));
|
||||
// 동기화 설정은 무조건 OFF로 설정
|
||||
localStorage.setItem('syncEnabled', 'false');
|
||||
console.log('동기화 설정이 OFF로 변경되었습니다');
|
||||
|
||||
console.log('모든 저장소 데이터가 완전히 초기화되었습니다. (로그인 상태는 유지)');
|
||||
// 모든 이벤트 한 번에 발생 (성능 최적화)
|
||||
const events = [
|
||||
new Event('transactionUpdated'),
|
||||
new Event('budgetDataUpdated'),
|
||||
new Event('categoryBudgetsUpdated'),
|
||||
new StorageEvent('storage'),
|
||||
new Event('auth-state-changed')
|
||||
];
|
||||
|
||||
events.forEach(event => window.dispatchEvent(event));
|
||||
|
||||
console.log('모든 저장소 데이터가 완전히 초기화되었습니다. (동기화 설정이 OFF로 변경됨)');
|
||||
} catch (error) {
|
||||
console.error('데이터 초기화 중 오류:', error);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,23 @@ export const downloadBudgets = async (userId: string): Promise<void> => {
|
||||
try {
|
||||
console.log('서버에서 예산 데이터 다운로드 시작');
|
||||
|
||||
// 현재 로컬 예산 데이터 백업
|
||||
const localBudgetData = localStorage.getItem('budgetData');
|
||||
const localCategoryBudgets = localStorage.getItem('categoryBudgets');
|
||||
|
||||
// 서버에 데이터가 없는지 확인
|
||||
const { data: budgetExists, error: checkError } = await supabase
|
||||
.from('budgets')
|
||||
.select('count')
|
||||
.eq('user_id', userId)
|
||||
.single();
|
||||
|
||||
// 서버에 데이터가 없고 로컬에 데이터가 있으면 다운로드 건너뜀
|
||||
if ((budgetExists?.count === 0 || !budgetExists) && localBudgetData) {
|
||||
console.log('서버에 예산 데이터가 없고 로컬 데이터가 있어 다운로드 건너뜀');
|
||||
return;
|
||||
}
|
||||
|
||||
// 예산 데이터 및 카테고리 예산 데이터 가져오기
|
||||
const [budgetData, categoryData] = await Promise.all([
|
||||
fetchBudgetData(userId),
|
||||
@@ -19,16 +36,24 @@ export const downloadBudgets = async (userId: string): Promise<void> => {
|
||||
|
||||
// 예산 데이터 처리
|
||||
if (budgetData) {
|
||||
await processBudgetData(budgetData);
|
||||
await processBudgetData(budgetData, localBudgetData);
|
||||
} else {
|
||||
console.log('서버에서 예산 데이터를 찾을 수 없음');
|
||||
// 로컬 데이터가 있으면 유지
|
||||
if (localBudgetData) {
|
||||
console.log('로컬 예산 데이터 유지');
|
||||
}
|
||||
}
|
||||
|
||||
// 카테고리 예산 데이터 처리
|
||||
if (categoryData && categoryData.length > 0) {
|
||||
await processCategoryBudgetData(categoryData);
|
||||
await processCategoryBudgetData(categoryData, localCategoryBudgets);
|
||||
} else {
|
||||
console.log('서버에서 카테고리 예산 데이터를 찾을 수 없음');
|
||||
// 로컬 데이터가 있으면 유지
|
||||
if (localCategoryBudgets) {
|
||||
console.log('로컬 카테고리 예산 데이터 유지');
|
||||
}
|
||||
}
|
||||
|
||||
console.log('예산 데이터 다운로드 완료');
|
||||
@@ -82,11 +107,16 @@ async function fetchCategoryBudgetData(userId: string) {
|
||||
/**
|
||||
* 예산 데이터 처리 및 로컬 저장
|
||||
*/
|
||||
async function processBudgetData(budgetData: any) {
|
||||
async function processBudgetData(budgetData: any, localBudgetDataStr: string | null) {
|
||||
console.log('서버에서 예산 데이터 수신:', budgetData);
|
||||
|
||||
// 기존 로컬 데이터 가져오기
|
||||
const localBudgetDataStr = localStorage.getItem('budgetData');
|
||||
// 서버 예산이 0이고 로컬 예산이 있으면 로컬 데이터 유지
|
||||
if (budgetData.total_budget === 0 && localBudgetDataStr) {
|
||||
console.log('서버 예산이 0이고 로컬 예산이 있어 로컬 데이터 유지');
|
||||
return;
|
||||
}
|
||||
|
||||
// 기존 로컬 데이터 가져오기
|
||||
let localBudgetData = localBudgetDataStr ? JSON.parse(localBudgetDataStr) : {
|
||||
daily: { targetAmount: 0, spentAmount: 0, remainingAmount: 0 },
|
||||
weekly: { targetAmount: 0, spentAmount: 0, remainingAmount: 0 },
|
||||
@@ -94,26 +124,34 @@ async function processBudgetData(budgetData: any) {
|
||||
};
|
||||
|
||||
// 서버 데이터로 업데이트 (지출 금액은 유지)
|
||||
// 수정: 올바른 예산 계산 방식으로 변경
|
||||
const monthlyBudget = budgetData.total_budget;
|
||||
const dailyBudget = Math.round(monthlyBudget / 30); // 월간 예산 / 30일
|
||||
const weeklyBudget = Math.round(monthlyBudget / 4.3); // 월간 예산 / 4.3주
|
||||
|
||||
const updatedBudgetData = {
|
||||
daily: {
|
||||
targetAmount: Math.round(budgetData.total_budget / 30),
|
||||
targetAmount: dailyBudget,
|
||||
spentAmount: localBudgetData.daily.spentAmount,
|
||||
remainingAmount: Math.round(budgetData.total_budget / 30) - localBudgetData.daily.spentAmount
|
||||
remainingAmount: dailyBudget - localBudgetData.daily.spentAmount
|
||||
},
|
||||
weekly: {
|
||||
targetAmount: Math.round(budgetData.total_budget / 4.3),
|
||||
targetAmount: weeklyBudget,
|
||||
spentAmount: localBudgetData.weekly.spentAmount,
|
||||
remainingAmount: Math.round(budgetData.total_budget / 4.3) - localBudgetData.weekly.spentAmount
|
||||
remainingAmount: weeklyBudget - localBudgetData.weekly.spentAmount
|
||||
},
|
||||
monthly: {
|
||||
targetAmount: budgetData.total_budget,
|
||||
targetAmount: monthlyBudget,
|
||||
spentAmount: localBudgetData.monthly.spentAmount,
|
||||
remainingAmount: budgetData.total_budget - localBudgetData.monthly.spentAmount
|
||||
remainingAmount: monthlyBudget - localBudgetData.monthly.spentAmount
|
||||
}
|
||||
};
|
||||
|
||||
console.log('계산된 예산 데이터:', updatedBudgetData);
|
||||
|
||||
// 로컬 스토리지에 저장
|
||||
localStorage.setItem('budgetData', JSON.stringify(updatedBudgetData));
|
||||
localStorage.setItem('budgetData_backup', JSON.stringify(updatedBudgetData));
|
||||
console.log('예산 데이터 로컬 저장 완료', updatedBudgetData);
|
||||
|
||||
// 이벤트 발생시켜 UI 업데이트
|
||||
@@ -123,9 +161,18 @@ async function processBudgetData(budgetData: any) {
|
||||
/**
|
||||
* 카테고리 예산 데이터 처리 및 로컬 저장
|
||||
*/
|
||||
async function processCategoryBudgetData(categoryData: any[]) {
|
||||
async function processCategoryBudgetData(categoryData: any[], localCategoryBudgetsStr: string | null) {
|
||||
console.log(`${categoryData.length}개의 카테고리 예산 수신`);
|
||||
|
||||
// 서버 카테고리 예산 합계 계산
|
||||
const serverTotal = categoryData.reduce((sum, item) => sum + item.amount, 0);
|
||||
|
||||
// 로컬 카테고리 예산이 있고 서버 데이터가 비어있거나 합계가 0이면 로컬 데이터 유지
|
||||
if (localCategoryBudgetsStr && (categoryData.length === 0 || serverTotal === 0)) {
|
||||
console.log('서버 카테고리 예산이 없거나 0이고 로컬 데이터가 있어 로컬 데이터 유지');
|
||||
return;
|
||||
}
|
||||
|
||||
// 카테고리 예산 로컬 형식으로 변환
|
||||
const localCategoryBudgets = categoryData.reduce((acc, curr) => {
|
||||
acc[curr.category] = curr.amount;
|
||||
@@ -134,6 +181,7 @@ async function processCategoryBudgetData(categoryData: any[]) {
|
||||
|
||||
// 로컬 스토리지에 저장
|
||||
localStorage.setItem('categoryBudgets', JSON.stringify(localCategoryBudgets));
|
||||
localStorage.setItem('categoryBudgets_backup', JSON.stringify(localCategoryBudgets));
|
||||
console.log('카테고리 예산 로컬 저장 완료', localCategoryBudgets);
|
||||
|
||||
// 이벤트 발생시켜 UI 업데이트
|
||||
|
||||
@@ -64,6 +64,8 @@ async function uploadBudgetData(userId: string, parsedBudgetData: any): Promise<
|
||||
// 월간 타겟 금액 가져오기
|
||||
const monthlyTarget = parsedBudgetData.monthly.targetAmount;
|
||||
|
||||
console.log('업로드할 월간 예산:', monthlyTarget);
|
||||
|
||||
// 업데이트 또는 삽입 결정
|
||||
if (existingBudgets && existingBudgets.length > 0) {
|
||||
// 기존 데이터 업데이트
|
||||
|
||||
@@ -52,8 +52,12 @@ export const clearCloudData = async (userId: string): Promise<boolean> => {
|
||||
} catch (e) {
|
||||
console.log('category_budgets 테이블이 없거나 삭제 중 오류 발생:', e);
|
||||
}
|
||||
|
||||
// 동기화 설정 초기화 및 마지막 동기화 시간 초기화
|
||||
localStorage.removeItem('lastSync');
|
||||
localStorage.setItem('syncEnabled', 'false'); // 동기화 설정을 OFF로 변경
|
||||
|
||||
console.log('클라우드 데이터 초기화 완료');
|
||||
console.log('클라우드 데이터 초기화 완료 및 동기화 설정 OFF');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('클라우드 데이터 초기화 중 오류 발생:', error);
|
||||
|
||||
@@ -1,71 +1,193 @@
|
||||
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { uploadBudgets, downloadBudgets } from './budget';
|
||||
import { uploadTransactions, downloadTransactions } from './transaction';
|
||||
import { setLastSyncTime } from './time';
|
||||
|
||||
export interface SyncResult {
|
||||
success: boolean;
|
||||
partial: boolean;
|
||||
uploadSuccess: boolean;
|
||||
downloadSuccess: boolean;
|
||||
details?: {
|
||||
budgetUpload?: boolean;
|
||||
budgetDownload?: boolean;
|
||||
transactionUpload?: boolean;
|
||||
transactionDownload?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 데이터 동기화 기능
|
||||
* 모든 데이터를 동기화합니다 (업로드 우선 수행)
|
||||
*/
|
||||
export const syncAllData = async (userId: string): Promise<void> => {
|
||||
if (!userId) {
|
||||
throw new Error('사용자 ID가 필요합니다');
|
||||
}
|
||||
|
||||
try {
|
||||
// 로컬 트랜잭션 데이터 가져오기
|
||||
const transactionsJSON = localStorage.getItem('transactions');
|
||||
const transactions = transactionsJSON ? JSON.parse(transactionsJSON) : [];
|
||||
|
||||
// 예산 데이터 가져오기
|
||||
const budgetDataJSON = localStorage.getItem('budgetData');
|
||||
const budgetData = budgetDataJSON ? JSON.parse(budgetDataJSON) : {};
|
||||
|
||||
// 카테고리 예산 가져오기
|
||||
const categoryBudgetsJSON = localStorage.getItem('categoryBudgets');
|
||||
const categoryBudgets = categoryBudgetsJSON ? JSON.parse(categoryBudgetsJSON) : {};
|
||||
|
||||
// 트랜잭션 데이터 동기화
|
||||
for (const transaction of transactions) {
|
||||
// 이미 동기화된 데이터인지 확인 (transaction_id로 확인)
|
||||
const { data: existingData } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('transaction_id', transaction.id)
|
||||
.eq('user_id', userId);
|
||||
|
||||
// 존재하지 않는 경우에만 삽입
|
||||
if (!existingData || existingData.length === 0) {
|
||||
await supabase.from('transactions').insert({
|
||||
user_id: userId,
|
||||
title: transaction.title,
|
||||
amount: transaction.amount,
|
||||
date: transaction.date,
|
||||
category: transaction.category,
|
||||
type: transaction.type,
|
||||
transaction_id: transaction.id
|
||||
});
|
||||
}
|
||||
export const syncAllData = async (userId: string): Promise<SyncResult> => {
|
||||
// 로컬 데이터 백업
|
||||
const backupBudgetData = localStorage.getItem('budgetData');
|
||||
const backupCategoryBudgets = localStorage.getItem('categoryBudgets');
|
||||
const backupTransactions = localStorage.getItem('transactions');
|
||||
|
||||
const result: SyncResult = {
|
||||
success: false,
|
||||
partial: false,
|
||||
uploadSuccess: false,
|
||||
downloadSuccess: false,
|
||||
details: {
|
||||
budgetUpload: false,
|
||||
budgetDownload: false,
|
||||
transactionUpload: false,
|
||||
transactionDownload: false
|
||||
}
|
||||
|
||||
// 예산 데이터 동기화
|
||||
await supabase.from('budget_data').upsert({
|
||||
user_id: userId,
|
||||
data: budgetData,
|
||||
updated_at: new Date().toISOString()
|
||||
});
|
||||
|
||||
// 카테고리 예산 동기화
|
||||
await supabase.from('category_budgets').upsert({
|
||||
user_id: userId,
|
||||
data: categoryBudgets,
|
||||
updated_at: new Date().toISOString()
|
||||
});
|
||||
|
||||
// 마지막 동기화 시간 업데이트
|
||||
setLastSyncTime();
|
||||
};
|
||||
|
||||
try {
|
||||
console.log('데이터 동기화 시작 - 사용자 ID:', userId);
|
||||
|
||||
console.log('모든 데이터가 성공적으로 동기화되었습니다');
|
||||
// 여기서는 업로드를 먼저 시도합니다 (로컬 데이터 보존을 위해)
|
||||
try {
|
||||
// 예산 데이터 업로드
|
||||
await uploadBudgets(userId);
|
||||
result.details!.budgetUpload = true;
|
||||
console.log('예산 업로드 성공');
|
||||
|
||||
// 트랜잭션 데이터 업로드
|
||||
await uploadTransactions(userId);
|
||||
result.details!.transactionUpload = true;
|
||||
console.log('트랜잭션 업로드 성공');
|
||||
|
||||
// 업로드 성공 설정
|
||||
result.uploadSuccess = true;
|
||||
} catch (uploadError) {
|
||||
console.error('데이터 업로드 실패:', uploadError);
|
||||
result.uploadSuccess = false;
|
||||
}
|
||||
|
||||
// 그 다음 다운로드 시도
|
||||
try {
|
||||
// 서버에 데이터가 없는 경우를 확인하기 위해 먼저 데이터 유무 검사
|
||||
const { data: budgetData } = await supabase
|
||||
.from('budgets')
|
||||
.select('count')
|
||||
.eq('user_id', userId)
|
||||
.single();
|
||||
|
||||
const { data: transactionsData } = await supabase
|
||||
.from('transactions')
|
||||
.select('count')
|
||||
.eq('user_id', userId)
|
||||
.single();
|
||||
|
||||
// 서버에 데이터가 없지만 로컬에 데이터가 있는 경우, 다운로드를 건너뜀
|
||||
const serverHasData = (budgetData?.count || 0) > 0 || (transactionsData?.count || 0) > 0;
|
||||
|
||||
if (!serverHasData && (backupBudgetData || backupTransactions)) {
|
||||
console.log('서버에 데이터가 없고 로컬 데이터가 있어 다운로드 건너뜀');
|
||||
result.downloadSuccess = true;
|
||||
result.details!.budgetDownload = true;
|
||||
result.details!.transactionDownload = true;
|
||||
} else {
|
||||
// 예산 데이터 다운로드
|
||||
await downloadBudgets(userId);
|
||||
result.details!.budgetDownload = true;
|
||||
console.log('예산 다운로드 성공');
|
||||
|
||||
// 트랜잭션 데이터 다운로드
|
||||
await downloadTransactions(userId);
|
||||
result.details!.transactionDownload = true;
|
||||
console.log('트랜잭션 다운로드 성공');
|
||||
|
||||
// 다운로드 성공 설정
|
||||
result.downloadSuccess = true;
|
||||
}
|
||||
} catch (downloadError) {
|
||||
console.error('데이터 다운로드 실패:', downloadError);
|
||||
result.downloadSuccess = false;
|
||||
|
||||
// 다운로드 실패 시 로컬 데이터 복원
|
||||
if (backupBudgetData) {
|
||||
localStorage.setItem('budgetData', backupBudgetData);
|
||||
}
|
||||
if (backupCategoryBudgets) {
|
||||
localStorage.setItem('categoryBudgets', backupCategoryBudgets);
|
||||
}
|
||||
if (backupTransactions) {
|
||||
localStorage.setItem('transactions', backupTransactions);
|
||||
}
|
||||
|
||||
// UI 업데이트
|
||||
window.dispatchEvent(new Event('budgetDataUpdated'));
|
||||
window.dispatchEvent(new Event('categoryBudgetsUpdated'));
|
||||
window.dispatchEvent(new Event('transactionUpdated'));
|
||||
}
|
||||
|
||||
// 부분 성공 여부 설정
|
||||
result.partial = (result.uploadSuccess || result.downloadSuccess) && !(result.uploadSuccess && result.downloadSuccess);
|
||||
|
||||
// 전체 성공 여부 설정
|
||||
result.success = result.uploadSuccess || result.downloadSuccess;
|
||||
|
||||
// 동기화 시간 기록
|
||||
if (result.success) {
|
||||
setLastSyncTime(new Date().toISOString());
|
||||
}
|
||||
|
||||
console.log('데이터 동기화 결과:', result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('데이터 동기화 중 오류 발생:', error);
|
||||
throw error;
|
||||
console.error('데이터 동기화 중 치명적 오류:', error);
|
||||
|
||||
// 백업 데이터 복원
|
||||
if (backupBudgetData) {
|
||||
localStorage.setItem('budgetData', backupBudgetData);
|
||||
}
|
||||
if (backupCategoryBudgets) {
|
||||
localStorage.setItem('categoryBudgets', backupCategoryBudgets);
|
||||
}
|
||||
if (backupTransactions) {
|
||||
localStorage.setItem('transactions', backupTransactions);
|
||||
}
|
||||
|
||||
// UI 업데이트
|
||||
window.dispatchEvent(new Event('budgetDataUpdated'));
|
||||
window.dispatchEvent(new Event('categoryBudgetsUpdated'));
|
||||
window.dispatchEvent(new Event('transactionUpdated'));
|
||||
|
||||
result.success = false;
|
||||
result.partial = false;
|
||||
result.uploadSuccess = false;
|
||||
result.downloadSuccess = false;
|
||||
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 서버에 대한 안전한 동기화 래퍼
|
||||
* 오류 처리와 재시도 로직을 포함
|
||||
*/
|
||||
export const trySyncAllData = async (userId: string): Promise<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();
|
||||
};
|
||||
|
||||
@@ -4,13 +4,15 @@ import { isSyncEnabled } from '../syncSettings';
|
||||
import { toast } from '@/hooks/useToast.wrapper';
|
||||
|
||||
/**
|
||||
* 특정 트랜잭션 ID 삭제 처리
|
||||
* 특정 트랜잭션 ID 삭제 처리 - 안정성 개선 버전
|
||||
*/
|
||||
export const deleteTransactionFromServer = async (userId: string, transactionId: string): Promise<void> => {
|
||||
if (!isSyncEnabled()) return;
|
||||
|
||||
try {
|
||||
console.log(`트랜잭션 삭제 요청: ${transactionId}`);
|
||||
|
||||
// 삭제 요청 (타임아웃 처리 없음 - 불필요한 복잡성 제거)
|
||||
const { error } = await supabase
|
||||
.from('transactions')
|
||||
.delete()
|
||||
@@ -25,11 +27,16 @@ export const deleteTransactionFromServer = async (userId: string, transactionId:
|
||||
console.log(`트랜잭션 ${transactionId} 삭제 완료`);
|
||||
} catch (error) {
|
||||
console.error('트랜잭션 삭제 중 오류:', error);
|
||||
// 에러 발생 시 토스트 알림
|
||||
|
||||
// 오류 메시지 (중요도 낮음)
|
||||
toast({
|
||||
title: "삭제 동기화 실패",
|
||||
description: "서버에서 트랜잭션을 삭제하는데 문제가 발생했습니다.",
|
||||
variant: "destructive"
|
||||
title: "동기화 문제",
|
||||
description: "서버에서 삭제 중 문제가 발생했습니다.",
|
||||
variant: "default",
|
||||
duration: 1500
|
||||
});
|
||||
|
||||
// 오류 다시 던지기 (호출자가 처리하도록)
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user