From 1ad6e5b685cb2cd3c6f10cf485ccd52e2bf3d5b7 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 05:09:25 +0000 Subject: [PATCH] Refactor form component Refactor the form component to use a hybrid approach. --- package-lock.json | 133 +++++++++++++++ package.json | 1 + src/components/AddTransactionButton.tsx | 40 ++++- src/components/SyncSettings.tsx | 123 ++++++++++++++ src/lib/supabase.ts | 8 + src/pages/Settings.tsx | 7 + src/utils/syncUtils.ts | 211 ++++++++++++++++++++++++ 7 files changed, 518 insertions(+), 5 deletions(-) create mode 100644 src/components/SyncSettings.tsx create mode 100644 src/lib/supabase.ts create mode 100644 src/utils/syncUtils.ts diff --git a/package-lock.json b/package-lock.json index ea0cc44..da48d6b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.4", + "@supabase/supabase-js": "^2.49.1", "@tanstack/react-query": "^5.56.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -2833,6 +2834,80 @@ "win32" ] }, + "node_modules/@supabase/auth-js": { + "version": "2.68.0", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.68.0.tgz", + "integrity": "sha512-odG7nb7aOmZPUXk6SwL2JchSsn36Ppx11i2yWMIc/meUO2B2HK9YwZHPK06utD9Ql9ke7JKDbwGin/8prHKxxQ==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.4.4.tgz", + "integrity": "sha512-WL2p6r4AXNGwop7iwvul2BvOtuJ1YQy8EbOd0dhG1oN1q8el/BIRSFCFnWAMM/vJJlHWLi4ad22sKbKr9mvjoA==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/node-fetch": { + "version": "2.6.15", + "resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz", + "integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + } + }, + "node_modules/@supabase/postgrest-js": { + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.19.2.tgz", + "integrity": "sha512-MXRbk4wpwhWl9IN6rIY1mR8uZCCG4MZAEji942ve6nMwIqnBgBnZhZlON6zTTs6fgveMnoCILpZv1+K91jN+ow==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.11.2.tgz", + "integrity": "sha512-u/XeuL2Y0QEhXSoIPZZwR6wMXgB+RQbJzG9VErA3VghVt7uRfSVsjeqd7m5GhX3JR6dM/WRmLbVR8URpDWG4+w==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14", + "@types/phoenix": "^1.5.4", + "@types/ws": "^8.5.10", + "ws": "^8.18.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.7.1.tgz", + "integrity": "sha512-asYHcyDR1fKqrMpytAS1zjyEfvxuOIp1CIXX7ji4lHHcJKqyk+sLl/Vxgm4sN6u8zvuUtae9e4kDxQP2qrwWBA==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.49.1", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.49.1.tgz", + "integrity": "sha512-lKaptKQB5/juEF5+jzmBeZlz69MdHZuxf+0f50NwhL+IE//m4ZnOeWlsKRjjsM0fVayZiQKqLvYdBn0RLkhGiQ==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.68.0", + "@supabase/functions-js": "2.4.4", + "@supabase/node-fetch": "2.6.15", + "@supabase/postgrest-js": "1.19.2", + "@supabase/realtime-js": "2.11.2", + "@supabase/storage-js": "2.7.1" + } + }, "node_modules/@swc/core": { "version": "1.7.39", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.7.39.tgz", @@ -3208,6 +3283,12 @@ "undici-types": "~6.19.2" } }, + "node_modules/@types/phoenix": { + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz", + "integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==", + "license": "MIT" + }, "node_modules/@types/prop-types": { "version": "15.7.13", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz", @@ -3242,6 +3323,15 @@ "integrity": "sha512-+OpjSaq85gvlZAYINyzKpLeiFkSC4EsC6IIiT6v6TLSU5k5U83fHGj9Lel8oKEXM0HqgrMVCjXPDPVICtxF7EQ==", "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8svvI3hMyvN0kKCJMvTJP/x6Y/EoQbepff882wL+Sn5QsXb3etnamgrJq4isrBxSJj5L2AuXcI0+bgkoAXGUJw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.11.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.11.0.tgz", @@ -7631,6 +7721,12 @@ "node": ">=8.0" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -7926,6 +8022,22 @@ } } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -8039,6 +8151,27 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xml2js": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", diff --git a/package.json b/package.json index 7847cd7..13e2ce5 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.4", + "@supabase/supabase-js": "^2.49.1", "@tanstack/react-query": "^5.56.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/src/components/AddTransactionButton.tsx b/src/components/AddTransactionButton.tsx index 1c4a018..dd1c55e 100644 --- a/src/components/AddTransactionButton.tsx +++ b/src/components/AddTransactionButton.tsx @@ -1,5 +1,4 @@ - -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { PlusIcon, X, Coffee, Home, Car } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useNavigate } from 'react-router-dom'; @@ -10,6 +9,8 @@ import { Button } from './ui/button'; import { useForm } from 'react-hook-form'; import { ToggleGroup, ToggleGroupItem } from './ui/toggle-group'; import { toast } from '@/components/ui/use-toast'; +import { supabase } from '@/lib/supabase'; +import { isSyncEnabled } from '@/utils/syncUtils'; interface ExpenseFormValues { title: string; @@ -17,8 +18,6 @@ interface ExpenseFormValues { category: string; } -const EXPENSE_CATEGORIES = ['식비', '생활비', '교통비']; - // Define category icons mapping const categoryIcons: Record = { 식비: , @@ -28,6 +27,7 @@ const categoryIcons: Record = { const AddTransactionButton = () => { const [showExpenseDialog, setShowExpenseDialog] = useState(false); + const [userId, setUserId] = useState(null); const navigate = useNavigate(); const form = useForm({ @@ -38,6 +38,16 @@ const AddTransactionButton = () => { } }); + useEffect(() => { + // 현재 로그인한 사용자 가져오기 + const getUser = async () => { + const { data: { user } } = await supabase.auth.getUser(); + setUserId(user?.id || null); + }; + + getUser(); + }, []); + // Format number with commas const formatWithCommas = (value: string): string => { // Remove commas first to avoid duplicates when typing @@ -50,7 +60,7 @@ const AddTransactionButton = () => { form.setValue('amount', formattedValue); }; - const onSubmit = (data: ExpenseFormValues) => { + const onSubmit = async (data: ExpenseFormValues) => { // Remove commas before processing the amount const numericAmount = data.amount.replace(/,/g, ''); @@ -75,6 +85,26 @@ const AddTransactionButton = () => { existingTransactions = [newExpense, ...existingTransactions]; localStorage.setItem('transactions', JSON.stringify(existingTransactions)); + // 동기화가 활성화되어 있고 사용자가 로그인되어 있다면 Supabase에도 저장 + if (isSyncEnabled() && userId) { + try { + const { error } = await supabase.from('transactions').insert({ + user_id: userId, + title: data.title, + amount: parseInt(numericAmount), + date: formattedDate, + category: data.category, + type: 'expense', + transaction_id: newExpense.id + }); + + if (error) throw error; + } catch (error) { + console.error('Supabase에 지출 추가 실패:', error); + // 실패해도 로컬에는 저장되어 있으므로 사용자에게 알리지 않음 + } + } + // 폼을 초기화하고 다이얼로그를 닫습니다 form.reset(); setShowExpenseDialog(false); diff --git a/src/components/SyncSettings.tsx b/src/components/SyncSettings.tsx new file mode 100644 index 0000000..acb58d2 --- /dev/null +++ b/src/components/SyncSettings.tsx @@ -0,0 +1,123 @@ + +import React, { useState, useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { Switch } from '@/components/ui/switch'; +import { Label } from '@/components/ui/label'; +import { Loader2, CloudSync } from 'lucide-react'; +import { isSyncEnabled, setSyncEnabled, syncAllData } from '@/utils/syncUtils'; +import { supabase } from '@/lib/supabase'; +import { toast } from '@/components/ui/use-toast'; + +const SyncSettings = () => { + const [syncEnabled, setSyncEnabledState] = useState(isSyncEnabled()); + const [loading, setLoading] = useState(false); + const [userId, setUserId] = useState(null); + + useEffect(() => { + // 현재 로그인한 사용자 가져오기 + const getUser = async () => { + const { data: { user } } = await supabase.auth.getUser(); + setUserId(user?.id || null); + }; + + getUser(); + }, []); + + const handleSyncToggle = (checked: boolean) => { + setSyncEnabledState(checked); + setSyncEnabled(checked); + + if (checked) { + toast({ + title: "동기화 활성화됨", + description: "데이터가 클라우드에 자동으로 백업됩니다.", + }); + } else { + toast({ + title: "동기화 비활성화됨", + description: "데이터가 이 기기에만 저장됩니다.", + }); + } + }; + + const handleSyncNow = async () => { + if (!userId) { + toast({ + title: "로그인이 필요합니다", + description: "동기화를 사용하려면 먼저 로그인해주세요.", + variant: "destructive" + }); + return; + } + + setLoading(true); + try { + await syncAllData(userId); + toast({ + title: "동기화 완료", + description: "모든 데이터가 성공적으로 동기화되었습니다." + }); + } catch (error) { + console.error("동기화 오류:", error); + toast({ + title: "동기화 실패", + description: "데이터 동기화 중 오류가 발생했습니다.", + variant: "destructive" + }); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+

데이터 동기화

+

+ 여러 기기에서 데이터를 동기화하고 백업합니다 +

+
+ +
+ + {syncEnabled && ( +
+ +

+ 마지막 동기화: {new Date().toLocaleString('ko-KR')} +

+
+ )} + + {!userId && ( +

+ 동기화를 사용하려면 로그인이 필요합니다 +

+ )} +
+ ); +}; + +export default SyncSettings; diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts new file mode 100644 index 0000000..d4e54e6 --- /dev/null +++ b/src/lib/supabase.ts @@ -0,0 +1,8 @@ + +import { createClient } from '@supabase/supabase-js'; + +// Supabase URL과 anon key는 실제 프로젝트 값으로 대체해야 합니다 +const supabaseUrl = 'YOUR_SUPABASE_URL'; +const supabaseAnonKey = 'YOUR_SUPABASE_ANON_KEY'; + +export const supabase = createClient(supabaseUrl, supabaseAnonKey); diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 861f7ca..1955b91 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { useNavigate } from 'react-router-dom'; import NavBar from '@/components/NavBar'; +import SyncSettings from '@/components/SyncSettings'; import { User, CreditCard, Bell, Lock, HelpCircle, LogOut, ChevronRight } from 'lucide-react'; import { cn } from '@/lib/utils'; @@ -52,6 +53,12 @@ const Settings = () => { + {/* Data Sync Settings */} +
+

데이터 동기화

+ +
+ {/* Settings Options */}

계정

diff --git a/src/utils/syncUtils.ts b/src/utils/syncUtils.ts new file mode 100644 index 0000000..f61dfef --- /dev/null +++ b/src/utils/syncUtils.ts @@ -0,0 +1,211 @@ + +import { supabase } from '@/lib/supabase'; +import { Transaction } from '@/components/TransactionCard'; + +// 동기화 상태 확인 +export const isSyncEnabled = (): boolean => { + return localStorage.getItem('syncEnabled') === 'true'; +}; + +// 동기화 설정 변경 +export const setSyncEnabled = (enabled: boolean): void => { + localStorage.setItem('syncEnabled', enabled.toString()); +}; + +// 로컬 트랜잭션 데이터를 Supabase에 업로드 +export const uploadTransactions = async (userId: string): Promise => { + if (!isSyncEnabled()) return; + + try { + const localTransactions = localStorage.getItem('transactions'); + if (!localTransactions) return; + + const transactions: Transaction[] = JSON.parse(localTransactions); + + // 기존 데이터 삭제 후 새로 업로드 + await supabase + .from('transactions') + .delete() + .eq('user_id', userId); + + // 트랜잭션 배치 처리 + const { error } = await supabase.from('transactions').insert( + transactions.map(t => ({ + user_id: userId, + title: t.title, + amount: t.amount, + date: t.date, + category: t.category, + type: t.type, + transaction_id: t.id // 로컬 ID 보존 + })) + ); + + if (error) throw error; + + console.log('트랜잭션 업로드 완료'); + } catch (error) { + console.error('트랜잭션 업로드 실패:', error); + } +}; + +// Supabase에서 트랜잭션 데이터 다운로드 +export const downloadTransactions = async (userId: string): Promise => { + if (!isSyncEnabled()) return; + + try { + const { data, error } = await supabase + .from('transactions') + .select('*') + .eq('user_id', userId); + + if (error) throw error; + + if (data && data.length > 0) { + // Supabase 형식에서 로컬 형식으로 변환 + const transactions = data.map(t => ({ + id: t.transaction_id || t.id, + title: t.title, + amount: t.amount, + date: t.date, + category: t.category, + type: t.type + })); + + localStorage.setItem('transactions', JSON.stringify(transactions)); + console.log('트랜잭션 다운로드 완료'); + } + } catch (error) { + console.error('트랜잭션 다운로드 실패:', error); + } +}; + +// 예산 데이터 업로드 +export const uploadBudgets = async (userId: string): Promise => { + if (!isSyncEnabled()) return; + + try { + const budgetData = localStorage.getItem('budgetData'); + const categoryBudgets = localStorage.getItem('categoryBudgets'); + + if (budgetData) { + const parsedBudgetData = JSON.parse(budgetData); + + // 기존 예산 데이터 삭제 + await supabase + .from('budgets') + .delete() + .eq('user_id', userId); + + // 새 예산 데이터 삽입 + const { error } = await supabase.from('budgets').insert({ + user_id: userId, + daily_target: parsedBudgetData.daily.targetAmount, + weekly_target: parsedBudgetData.weekly.targetAmount, + monthly_target: parsedBudgetData.monthly.targetAmount + }); + + if (error) throw error; + } + + if (categoryBudgets) { + const parsedCategoryBudgets = JSON.parse(categoryBudgets); + + // 기존 카테고리 예산 삭제 + await supabase + .from('category_budgets') + .delete() + .eq('user_id', userId); + + // 카테고리별 예산 데이터 변환 및 삽입 + const categoryEntries = Object.entries(parsedCategoryBudgets).map( + ([category, amount]) => ({ + user_id: userId, + category, + amount + }) + ); + + const { error } = await supabase + .from('category_budgets') + .insert(categoryEntries); + + if (error) throw error; + } + + console.log('예산 데이터 업로드 완료'); + } catch (error) { + console.error('예산 데이터 업로드 실패:', error); + } +}; + +// 예산 데이터 다운로드 +export const downloadBudgets = async (userId: string): Promise => { + if (!isSyncEnabled()) return; + + try { + // 예산 데이터 가져오기 + const { data: budgetData, error: budgetError } = await supabase + .from('budgets') + .select('*') + .eq('user_id', userId) + .single(); + + if (budgetError && budgetError.code !== 'PGRST116') throw budgetError; + + // 카테고리 예산 가져오기 + const { data: categoryData, error: categoryError } = await supabase + .from('category_budgets') + .select('*') + .eq('user_id', userId); + + if (categoryError) throw categoryError; + + if (budgetData) { + // 예산 데이터 로컬 형식으로 변환 + const localBudgetData = { + daily: { + targetAmount: budgetData.daily_target, + spentAmount: 0, // 지출액은 로컬에서 계산 + remainingAmount: budgetData.daily_target + }, + weekly: { + targetAmount: budgetData.weekly_target, + spentAmount: 0, + remainingAmount: budgetData.weekly_target + }, + monthly: { + targetAmount: budgetData.monthly_target, + spentAmount: 0, + remainingAmount: budgetData.monthly_target + } + }; + + localStorage.setItem('budgetData', JSON.stringify(localBudgetData)); + } + + if (categoryData && categoryData.length > 0) { + // 카테고리 예산 로컬 형식으로 변환 + const localCategoryBudgets = categoryData.reduce((acc, curr) => { + acc[curr.category] = curr.amount; + return acc; + }, {} as Record); + + localStorage.setItem('categoryBudgets', JSON.stringify(localCategoryBudgets)); + } + + console.log('예산 데이터 다운로드 완료'); + } catch (error) { + console.error('예산 데이터 다운로드 실패:', error); + } +}; + +// 전체 데이터 동기화 +export const syncAllData = async (userId: string): Promise => { + if (!userId || !isSyncEnabled()) return; + + await downloadTransactions(userId); + await downloadBudgets(userId); + await uploadTransactions(userId); + await uploadBudgets(userId); +};