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