From fbd9924004bb08e9cb0cf4c1bea08123ab01158b Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sat, 15 Mar 2025 07:41:09 +0000 Subject: [PATCH] Implement transaction history page This commit implements the transaction history page. --- src/components/TransactionEditDialog.tsx | 68 +++++- src/hooks/useTransactions.ts | 268 +++++++++++++++++++++++ src/pages/Transactions.tsx | 218 +++++++++--------- 3 files changed, 447 insertions(+), 107 deletions(-) create mode 100644 src/hooks/useTransactions.ts diff --git a/src/components/TransactionEditDialog.tsx b/src/components/TransactionEditDialog.tsx index a185d0b..3207abc 100644 --- a/src/components/TransactionEditDialog.tsx +++ b/src/components/TransactionEditDialog.tsx @@ -15,7 +15,9 @@ import { import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; -import { Coffee, Home, Car } from 'lucide-react'; +import { Coffee, Home, Car, Trash2 } from 'lucide-react'; +import { toast } from '@/components/ui/use-toast'; +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'; // Form schema for validation const formSchema = z.object({ @@ -35,13 +37,15 @@ interface TransactionEditDialogProps { open: boolean; onOpenChange: (open: boolean) => void; onSave: (updatedTransaction: Transaction) => void; + onDelete?: (id: string) => void; } const TransactionEditDialog: React.FC = ({ transaction, open, onOpenChange, - onSave + onSave, + onDelete }) => { const form = useForm>({ resolver: zodResolver(formSchema), @@ -66,6 +70,18 @@ const TransactionEditDialog: React.FC = ({ onOpenChange(false); }; + const handleDelete = () => { + if (onDelete) { + onDelete(transaction.id); + onOpenChange(false); + + toast({ + title: "지출이 삭제되었습니다", + description: `${transaction.title} 항목이 삭제되었습니다.`, + }); + } + }; + const handleAmountChange = (e: React.ChangeEvent) => { const formattedValue = formatWithCommas(e.target.value); form.setValue('amount', formattedValue); @@ -147,11 +163,49 @@ const TransactionEditDialog: React.FC = ({ )} /> - - - - - + + {onDelete && ( + + + + + + + 지출 삭제 + + 정말로 이 지출 항목을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다. + + + + 취소 + + 삭제 + + + + + )} +
+ + + + +
diff --git a/src/hooks/useTransactions.ts b/src/hooks/useTransactions.ts new file mode 100644 index 0000000..2be514d --- /dev/null +++ b/src/hooks/useTransactions.ts @@ -0,0 +1,268 @@ + +import { useState, useEffect } from 'react'; +import { Transaction } from '@/components/TransactionCard'; +import { supabase } from '@/lib/supabase'; +import { useAuth } from '@/contexts/auth/AuthProvider'; +import { toast } from '@/hooks/useToast.wrapper'; +import { isSyncEnabled } from '@/utils/syncUtils'; + +// 월 이름 정의 +export const MONTHS_KR = [ + '1월', '2월', '3월', '4월', '5월', '6월', + '7월', '8월', '9월', '10월', '11월', '12월' +]; + +// 현재 월 가져오기 +const getCurrentMonth = () => { + const now = new Date(); + return MONTHS_KR[now.getMonth()]; +}; + +export const useTransactions = () => { + const [transactions, setTransactions] = useState([]); + const [filteredTransactions, setFilteredTransactions] = useState([]); + const [selectedMonth, setSelectedMonth] = useState(getCurrentMonth()); + const [searchQuery, setSearchQuery] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [totalBudget, setTotalBudget] = useState(1000000); // 기본 예산 + const { user } = useAuth(); + + // 월 변경 처리 + const handlePrevMonth = () => { + const currentIndex = MONTHS_KR.indexOf(selectedMonth); + if (currentIndex > 0) { + setSelectedMonth(MONTHS_KR[currentIndex - 1]); + } else { + setSelectedMonth(MONTHS_KR[MONTHS_KR.length - 1]); + } + }; + + const handleNextMonth = () => { + const currentIndex = MONTHS_KR.indexOf(selectedMonth); + if (currentIndex < MONTHS_KR.length - 1) { + setSelectedMonth(MONTHS_KR[currentIndex + 1]); + } else { + setSelectedMonth(MONTHS_KR[0]); + } + }; + + // 트랜잭션 로드 + const loadTransactions = () => { + setIsLoading(true); + setError(null); + + try { + // 로컬 스토리지에서 트랜잭션 데이터 가져오기 + const localData = localStorage.getItem('transactions'); + + if (localData) { + const parsedData = JSON.parse(localData) as Transaction[]; + setTransactions(parsedData); + } else { + // 샘플 데이터 설정 (실제 앱에서는 빈 배열로 시작할 수 있음) + const sampleData: Transaction[] = [{ + id: '1', + title: '식료품 구매', + amount: 25000, + date: `${selectedMonth} 25일, 12:30 PM`, + category: '식비', + type: 'expense' + }, { + id: '2', + title: '주유소', + amount: 50000, + date: `${selectedMonth} 24일, 3:45 PM`, + category: '교통비', + type: 'expense' + }, { + id: '4', + title: '생필품 구매', + amount: 35000, + date: `${selectedMonth} 18일, 6:00 AM`, + category: '생활비', + type: 'expense' + }, { + id: '5', + title: '월세', + amount: 650000, + date: `${selectedMonth} 15일, 10:00 AM`, + category: '생활비', + type: 'expense' + }, { + id: '6', + title: '식당', + amount: 15500, + date: `${selectedMonth} 12일, 2:15 PM`, + category: '식비', + type: 'expense' + }]; + + setTransactions(sampleData); + localStorage.setItem('transactions', JSON.stringify(sampleData)); + } + + // 예산 가져오기 + const budgetData = localStorage.getItem('budget'); + if (budgetData) { + const parsedBudget = JSON.parse(budgetData); + setTotalBudget(parsedBudget.total || 1000000); + } + } catch (err) { + console.error('트랜잭션 로드 중 오류:', err); + setError('데이터를 불러오는 중 문제가 발생했습니다.'); + toast({ + title: "데이터 로드 실패", + description: "지출 내역을 불러오는데 실패했습니다.", + variant: "destructive" + }); + } finally { + setIsLoading(false); + } + }; + + // 필터 적용 + useEffect(() => { + // 1. 월별 필터링 + let filtered = transactions.filter(transaction => + transaction.date.includes(selectedMonth) && transaction.type === 'expense' + ); + + // 2. 검색어 필터링 (검색어가 있는 경우) + if (searchQuery.trim()) { + const query = searchQuery.toLowerCase().trim(); + filtered = filtered.filter(transaction => + transaction.title.toLowerCase().includes(query) || + transaction.category.toLowerCase().includes(query) + ); + } + + setFilteredTransactions(filtered); + }, [transactions, selectedMonth, searchQuery]); + + // 초기 데이터 로드 + useEffect(() => { + loadTransactions(); + + // Supabase 동기화 (로그인 상태인 경우) + const syncWithSupabase = async () => { + if (user && isSyncEnabled()) { + try { + const { data, error } = await supabase + .from('transactions') + .select('*') + .eq('user_id', user.id); + + if (error) throw error; + + if (data && data.length > 0) { + // Supabase 데이터 로컬 형식으로 변환 + const supabaseTransactions = data.map(t => ({ + id: t.transaction_id || t.id, + title: t.title, + amount: t.amount, + date: t.date, + category: t.category, + type: t.type + })); + + // 로컬 데이터와 병합 (중복 ID 제거) + const mergedTransactions = [...transactions]; + + supabaseTransactions.forEach(newTx => { + const existingIndex = mergedTransactions.findIndex(t => t.id === newTx.id); + if (existingIndex >= 0) { + mergedTransactions[existingIndex] = newTx; + } else { + mergedTransactions.push(newTx); + } + }); + + setTransactions(mergedTransactions); + localStorage.setItem('transactions', JSON.stringify(mergedTransactions)); + } + } catch (err) { + console.error('Supabase 동기화 오류:', err); + // 동기화 실패해도 기존 로컬 데이터는 유지 + } + } + }; + + syncWithSupabase(); + }, [user]); + + // 트랜잭션 업데이트 + const updateTransaction = (updatedTransaction: Transaction) => { + const updatedTransactions = transactions.map(transaction => + transaction.id === updatedTransaction.id ? updatedTransaction : transaction + ); + + setTransactions(updatedTransactions); + localStorage.setItem('transactions', JSON.stringify(updatedTransactions)); + + // Supabase에도 업데이트 (로그인 및 동기화 활성화된 경우) + if (user && isSyncEnabled()) { + supabase.from('transactions') + .upsert({ + user_id: user.id, + title: updatedTransaction.title, + amount: updatedTransaction.amount, + date: updatedTransaction.date, + category: updatedTransaction.category, + type: updatedTransaction.type, + transaction_id: updatedTransaction.id + }) + .then(({ error }) => { + if (error) { + console.error('Supabase 업데이트 오류:', error); + } + }); + } + + toast({ + title: "지출이 수정되었습니다", + description: `${updatedTransaction.title} 항목이 업데이트되었습니다.`, + }); + }; + + // 트랜잭션 삭제 + const deleteTransaction = (id: string) => { + const updatedTransactions = transactions.filter(transaction => transaction.id !== id); + + setTransactions(updatedTransactions); + localStorage.setItem('transactions', JSON.stringify(updatedTransactions)); + + // Supabase에서도 삭제 (로그인 및 동기화 활성화된 경우) + if (user && isSyncEnabled()) { + supabase.from('transactions') + .delete() + .eq('transaction_id', id) + .then(({ error }) => { + if (error) { + console.error('Supabase 삭제 오류:', error); + } + }); + } + + toast({ + title: "지출이 삭제되었습니다", + description: "선택한 지출 항목이 삭제되었습니다.", + }); + }; + + return { + transactions: filteredTransactions, + isLoading, + error, + totalBudget, + selectedMonth, + searchQuery, + setSearchQuery, + handlePrevMonth, + handleNextMonth, + updateTransaction, + deleteTransaction, + totalExpenses: filteredTransactions.reduce((sum, t) => sum + t.amount, 0), + refreshTransactions: loadTransactions + }; +}; diff --git a/src/pages/Transactions.tsx b/src/pages/Transactions.tsx index 3a165f6..4699f1c 100644 --- a/src/pages/Transactions.tsx +++ b/src/pages/Transactions.tsx @@ -1,64 +1,45 @@ -import React, { useState } from 'react'; +import React, { useEffect } from 'react'; import NavBar from '@/components/NavBar'; -import TransactionCard, { Transaction } from '@/components/TransactionCard'; +import TransactionCard from '@/components/TransactionCard'; import AddTransactionButton from '@/components/AddTransactionButton'; -import { Calendar, Search, ChevronLeft, ChevronRight } from 'lucide-react'; -import { toast } from '@/components/ui/use-toast'; +import { Calendar, Search, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'; +import { useTransactions } from '@/hooks/useTransactions'; +import { formatCurrency } from '@/utils/formatters'; const Transactions = () => { - const [selectedMonth, setSelectedMonth] = useState('8월'); + const { + transactions, + isLoading, + error, + totalBudget, + selectedMonth, + searchQuery, + setSearchQuery, + handlePrevMonth, + handleNextMonth, + updateTransaction, + totalExpenses, + refreshTransactions + } = useTransactions(); - // Sample data - updated to use only the three specified categories - const [transactions, setTransactions] = useState([{ - id: '1', - title: '식료품 구매', - amount: 25000, - date: '8월 25일, 12:30 PM', - category: '식비', - type: 'expense' - }, { - id: '2', - title: '주유소', - amount: 50000, - date: '8월 24일, 3:45 PM', - category: '교통비', - type: 'expense' - }, { - id: '4', - title: '생필품 구매', - amount: 35000, - date: '8월 18일, 6:00 AM', - category: '생활비', - type: 'expense' - }, { - id: '5', - title: '월세', - amount: 650000, - date: '8월 15일, 10:00 AM', - category: '생활비', - type: 'expense' - }, { - id: '6', - title: '식당', - amount: 15500, - date: '8월 12일, 2:15 PM', - category: '식비', - type: 'expense' - }]); - - // Filter only expense transactions - const expenseTransactions = transactions.filter(t => t.type === 'expense'); - - // Calculate total expenses - const totalExpenses = expenseTransactions.reduce((sum, transaction) => sum + transaction.amount, 0); - - // Set budget (for demo purposes - in a real app this would come from user settings) - const totalBudget = 1000000; + // 페이지 진입/초점 시 새로고침 + useEffect(() => { + const handleFocus = () => { + refreshTransactions(); + }; + + window.addEventListener('focus', handleFocus); + + return () => { + window.removeEventListener('focus', handleFocus); + }; + }, [refreshTransactions]); - // Group transactions by date - const groupedTransactions: Record = {}; - expenseTransactions.forEach(transaction => { + // 트랜잭션을 날짜별로 그룹화 + const groupedTransactions: Record = {}; + + transactions.forEach(transaction => { const datePart = transaction.date.split(',')[0]; if (!groupedTransactions[datePart]) { groupedTransactions[datePart] = []; @@ -66,20 +47,8 @@ const Transactions = () => { groupedTransactions[datePart].push(transaction); }); - const handleUpdateTransaction = (updatedTransaction: Transaction) => { - setTransactions(prev => - prev.map(transaction => - transaction.id === updatedTransaction.id ? updatedTransaction : transaction - ) - ); - - toast({ - title: "지출이 수정되었습니다", - description: `${updatedTransaction.title} 항목이 업데이트되었습니다.`, - }); - }; - - return
+ return ( +
{/* Header */}
@@ -88,12 +57,21 @@ const Transactions = () => { {/* Search */}
- + setSearchQuery(e.target.value)} + />
{/* Month Selector */}
- @@ -102,7 +80,10 @@ const Transactions = () => { {selectedMonth}
-
@@ -112,51 +93,88 @@ const Transactions = () => {

총 예산

- {new Intl.NumberFormat('ko-KR', { - style: 'currency', - currency: 'KRW', - maximumFractionDigits: 0 - }).format(totalBudget)} + {formatCurrency(totalBudget)}

총 지출

- {new Intl.NumberFormat('ko-KR', { - style: 'currency', - currency: 'KRW', - maximumFractionDigits: 0 - }).format(totalExpenses)} + {formatCurrency(totalExpenses)}

+ {/* Loading State */} + {isLoading && ( +
+ + 로딩 중... +
+ )} + + {/* Error State */} + {error && ( +
+

{error}

+ +
+ )} + + {/* Empty State */} + {!isLoading && !error && transactions.length === 0 && ( +
+

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

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

{date}

-
+ {!isLoading && !error && ( +
+ {Object.entries(groupedTransactions).map(([date, transactions]) => ( +
+
+
+

{date}

+
+
+ +
+ {transactions.map(transaction => ( + + ))} +
- -
- {transactions.map(transaction => ( - - ))} -
-
)} -
+ ))} +
+ )}
-
; + + ); }; export default Transactions;