From dd694d541328eff88f40b2f9a193bf12540b478c Mon Sep 17 00:00:00 2001 From: YeongTaek Date: Tue, 16 Sep 2025 13:12:29 +0900 Subject: [PATCH 01/12] =?UTF-8?q?fix:=20=ED=9A=8C=EA=B3=A0=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20=EC=98=A4=EB=A5=98=20&=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=EC=8B=9C=20=EB=A6=AC=EB=8B=A4=EC=9D=B4=EB=A0=89=ED=8A=B8=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/reflections/[id]/page.tsx | 117 +++++++------- src/app/reflections/new/page.tsx | 32 ++-- src/app/reflections/page.tsx | 150 +++++++++--------- src/components/reflections/ReflectionCard.tsx | 33 ++-- src/hooks/reflections/useReflections.ts | 16 +- 5 files changed, 181 insertions(+), 167 deletions(-) diff --git a/src/app/reflections/[id]/page.tsx b/src/app/reflections/[id]/page.tsx index e160c53..b796314 100644 --- a/src/app/reflections/[id]/page.tsx +++ b/src/app/reflections/[id]/page.tsx @@ -1,11 +1,11 @@ -"use client"; +'use client'; -import AuthGuard from "@/components/auth/AuthGuard"; -import { useAuth } from "@/hooks/auth"; -import { useReflections } from "@/hooks/reflections/useReflections"; -import { supabase } from "@/lib/supabase"; -import { ReflectionWithKeywords } from "@/types/reflections"; -import { format } from "date-fns"; +import AuthGuard from '@/components/auth/AuthGuard'; +import { useAuth } from '@/hooks/auth'; +import { useReflections } from '@/hooks/reflections/useReflections'; +import { supabase } from '@/lib/supabase'; +import { ReflectionWithKeywords } from '@/types/reflections'; +import { format } from 'date-fns'; import { ArrowLeft, Calendar, @@ -17,10 +17,10 @@ import { Share2, Trash2, User, -} from "lucide-react"; -import Link from "next/link"; -import { useParams, useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; +} from 'lucide-react'; +import Link from 'next/link'; +import { useParams, useRouter } from 'next/navigation'; +import { useEffect, useState } from 'react'; export interface KeywordRelation { keyword: { @@ -43,7 +43,7 @@ const ReflectionDetailPage = () => { }); const [reflection, setReflection] = useState( - null, + null ); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -56,7 +56,7 @@ const ReflectionDetailPage = () => { try { setIsLoading(true); const { data, error } = await supabase - .from("reflections") + .from('reflections') .select( ` *, @@ -64,15 +64,15 @@ const ReflectionDetailPage = () => { keywords:reflection_keyword_relations( keyword:reflection_keywords(*) ) - `, + ` ) - .eq("id", id) - .single(); + .eq('id', id) + .maybeSingle(); if (error) throw error; if (!data) { - setError("회고를 찾을 수 없습니다."); + setError('회고를 찾을 수 없습니다.'); return; } @@ -81,19 +81,19 @@ const ReflectionDetailPage = () => { const isPublic = data.is_public && data.is_neighbor_visible; if (!isOwn && !isPublic) { - setError("이 회고에 접근할 권한이 없습니다."); + setError('이 회고에 접근할 권한이 없습니다.'); return; } // 데이터 변환 const keywords = data.keywords?.map((rk: KeywordRelation) => rk.keyword) || []; - const effective_visibility: "public" | "neighbors" | "private" = + const effective_visibility: 'public' | 'neighbors' | 'private' = data.is_public && data.is_neighbor_visible - ? "public" + ? 'public' : data.is_public && !data.is_neighbor_visible - ? "neighbors" - : "private"; + ? 'neighbors' + : 'private'; setReflection({ ...data, @@ -102,8 +102,8 @@ const ReflectionDetailPage = () => { is_own: isOwn, }); } catch (err) { - console.error("회고 조회 실패:", err); - setError("회고를 불러오는 중 오류가 발생했습니다."); + console.error('회고 조회 실패:', err); + setError('회고를 불러오는 중 오류가 발생했습니다.'); } finally { setIsLoading(false); } @@ -112,60 +112,69 @@ const ReflectionDetailPage = () => { fetchReflection(); }, [id, user?.id]); - const handleDelete = () => { + const handleDelete = async () => { if (!reflection) return; - if (confirm("정말 삭제하시겠습니까? 삭제된 회고는 복구할 수 없습니다.")) { - deleteReflection(reflection.id); - router.push("./reflections"); + if (confirm('정말 삭제하시겠습니까? 삭제된 회고는 복구할 수 없습니다.')) { + try { + await new Promise((resolve, reject) => { + deleteReflection(reflection.id, { + onSuccess: resolve, + onError: reject, + }); + }); + router.push('/reflections'); + } catch (error) { + console.error('삭제 실패:', error); + } } }; const handleShare = async () => { if (!reflection) return; - if (reflection.effective_visibility === "private") { - alert("비공개 회고는 공유할 수 없습니다."); + if (reflection.effective_visibility === 'private') { + alert('비공개 회고는 공유할 수 없습니다.'); return; } try { await navigator.share({ - title: reflection.title || "회고", - text: reflection.content.slice(1, 100) + "...", + title: reflection.title || '회고', + text: reflection.content.slice(1, 100) + '...', url: window.location.href, }); } catch { // Web Share API를 지원하지 않는 경우 URL 복사 await navigator.clipboard.writeText(window.location.href); - alert("링크가 클립보드에 복사되었습니다."); + alert('링크가 클립보드에 복사되었습니다.'); } }; const getVisibilityInfo = ( - visibility: "public" | "neighbors" | "private", + visibility: 'public' | 'neighbors' | 'private' ) => { switch (visibility) { - case "public": + case 'public': return { icon: Eye, - label: "전체 공개", - color: "text-green-600", - bgColor: "bg-green-100", + label: '전체 공개', + color: 'text-green-600', + bgColor: 'bg-green-100', }; - case "neighbors": + case 'neighbors': return { icon: User, - label: "이웃 공개", - color: "text-blue-600", - bgColor: "bg-blue-100", + label: '이웃 공개', + color: 'text-blue-600', + bgColor: 'bg-blue-100', }; - case "private": + case 'private': return { icon: Lock, - label: "비공개", - color: "text-gray-600", - bgColor: "bg-gray-100", + label: '비공개', + color: 'text-gray-600', + bgColor: 'bg-gray-100', }; } }; @@ -214,7 +223,7 @@ const ReflectionDetailPage = () => {

- {error || "회고를 찾을 수 없습니다"} + {error || '회고를 찾을 수 없습니다'}

{ ); } - const isGratitude = reflection.category?.name === "gratitude"; + const isGratitude = reflection.category?.name === 'gratitude'; const TypeIcon = isGratitude ? Heart : Lightbulb; const visibilityInfo = getVisibilityInfo(reflection.effective_visibility); const VisibilityIcon = visibilityInfo.icon; @@ -287,19 +296,19 @@ const ReflectionDetailPage = () => {
- {format(new Date(reflection.created_at), "yyyy. M. d. HH:mm")} + {format(new Date(reflection.created_at), 'yyyy. M. d. HH:mm')}
{reflection.category?.display_name} @@ -312,8 +321,8 @@ const ReflectionDetailPage = () => {
{reflection.updated_at !== reflection.created_at && ( - 수정일:{" "} - {format(new Date(reflection.updated_at), "yyyy. M. d. HH:mm")} + 수정일:{' '} + {format(new Date(reflection.updated_at), 'yyyy. M. d. HH:mm')} )}
diff --git a/src/app/reflections/new/page.tsx b/src/app/reflections/new/page.tsx index ce04b41..48c8c8a 100644 --- a/src/app/reflections/new/page.tsx +++ b/src/app/reflections/new/page.tsx @@ -1,14 +1,14 @@ -"use client"; +'use client'; -import AuthGuard from "@/components/auth/AuthGuard"; -import ReflectionForm from "@/components/reflections/ReflectionForm"; -import { useAuth } from "@/hooks/auth"; -import { useReflections } from "@/hooks/reflections/useReflections"; -import { ReflectionFormData } from "@/types/reflections"; -import { ArrowLeft, BookOpen } from "lucide-react"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useState } from "react"; +import AuthGuard from '@/components/auth/AuthGuard'; +import ReflectionForm from '@/components/reflections/ReflectionForm'; +import { useAuth } from '@/hooks/auth'; +import { useReflections } from '@/hooks/reflections/useReflections'; +import { ReflectionFormData } from '@/types/reflections'; +import { ArrowLeft, BookOpen } from 'lucide-react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; const NewReflectionPage = () => { const router = useRouter(); @@ -27,18 +27,18 @@ const NewReflectionPage = () => { { ...formData, user_id: user.id }, { onSuccess: () => { - router.push("/reflections"); + router.push('/reflections'); }, onError: (error) => { - console.error("회고 작성 실패:", error); - setError("회고 작성 중 오류가 발생했습니다. 다시 시도해주세요."); + console.error('회고 작성 실패:', error); + setError('회고 작성 중 오류가 발생했습니다. 다시 시도해주세요.'); }, - }, + } ); }; const handleCancel = () => { - if (confirm("작성을 취소하시겠습니까? 작성된 내용이 사라집니다.")) { + if (confirm('작성을 취소하시겠습니까? 작성된 내용이 사라집니다.')) { router.back(); } }; @@ -73,7 +73,7 @@ const NewReflectionPage = () => { {/* 브레드크럼 네비게이션 */}
))}

{hasActiveFilters - ? "필터 조건에 맞는 회고가 없습니다." - : "아직 작성한 회고가 없습니다."} + ? '필터 조건에 맞는 회고가 없습니다.' + : '아직 작성한 회고가 없습니다.'}

{hasActiveFilters - ? "다른 필터 조건으로 시도해보세요." - : "첫 번째 회고를 작성해서 성장의 여정을 시작해보세요."} + ? '다른 필터 조건으로 시도해보세요.' + : '첫 번째 회고를 작성해서 성장의 여정을 시작해보세요.'}

{hasActiveFilters ? ( diff --git a/src/components/reflections/ReflectionCard.tsx b/src/components/reflections/ReflectionCard.tsx index 8b0e128..64644a2 100644 --- a/src/components/reflections/ReflectionCard.tsx +++ b/src/components/reflections/ReflectionCard.tsx @@ -1,17 +1,17 @@ -"use client"; +'use client'; -import { ReflectionWithKeywords } from "@/types/reflections/ui"; +import { ReflectionWithKeywords } from '@/types/reflections/ui'; import { getReflectionTypeBgColor, getReflectionTypeColor, getReflectionTypeLabel, getVisibilityLabel, getVisibilityStatus, -} from "@/utils/reflections/helpers"; -import { format } from "date-fns"; -import { ko } from "date-fns/locale"; -import { Calendar, Edit, Heart, Lightbulb, Trash2 } from "lucide-react"; -import Link from "next/link"; +} from '@/utils/reflections/helpers'; +import { format } from 'date-fns'; +import { ko } from 'date-fns/locale'; +import { Calendar, Edit, Heart, Lightbulb, Trash2 } from 'lucide-react'; +import Link from 'next/link'; interface ReflectionCardProps { reflection: ReflectionWithKeywords; @@ -24,7 +24,7 @@ const ReflectionCard = ({ onEdit, onDelete, }: ReflectionCardProps) => { - const isGratitude = reflection.category?.name === "gratitude"; + const isGratitude = reflection.category?.name === 'gratitude'; const TypeIcon = isGratitude ? Heart : Lightbulb; const handleEdit = (e: React.MouseEvent) => { @@ -35,6 +35,7 @@ const ReflectionCard = ({ const handleDelete = (e: React.MouseEvent) => { e.stopPropagation(); + e.preventDefault(); onDelete(reflection.id); }; @@ -47,10 +48,10 @@ const ReflectionCard = ({
@@ -58,27 +59,27 @@ const ReflectionCard = ({ {getVisibilityStatus( reflection.is_public, - reflection.is_neighbor_visible, + reflection.is_neighbor_visible )} {reflection.category?.display_name || getReflectionTypeLabel( - reflection.category?.name || "gratitude", + reflection.category?.name || 'gratitude' )}
- {format(new Date(reflection.date), "yyyy. M. d.", { + {format(new Date(reflection.date), 'yyyy. M. d.', { locale: ko, })}
@@ -113,7 +114,7 @@ const ReflectionCard = ({ {reflection.title} ) : ( -

{""}

+

{''}

)} {/* 내용 */} diff --git a/src/hooks/reflections/useReflections.ts b/src/hooks/reflections/useReflections.ts index 8315e6c..d6a3b53 100644 --- a/src/hooks/reflections/useReflections.ts +++ b/src/hooks/reflections/useReflections.ts @@ -144,14 +144,18 @@ export const useReflections = ({ // 회고 생성 const createReflectionMutation = useMutation({ mutationFn: async (formData: ReflectionFormData & { user_id: string }) => { - const { keywords: keywordNames, ...reflectionData } = formData; + const { keywords: keywordNames } = formData; // 기본값 설정 - const dataWithDefaults = { - ...reflectionData, - is_public: reflectionData.is_public ?? true, - is_neighbor_visible: reflectionData.is_neighbor_visible ?? true, - }; + const dataWithDefaults = { + user_id: formData.user_id, + category_id: formData.category_id, + title: formData.title, + content: formData.content, + date: formData.date, + is_public: formData.is_public ?? true, + is_neighbor_visible: formData.is_neighbor_visible ?? true, + }; // 1. 회고 생성 const { data: reflection, error: reflectionError } = await supabase From 7902ccb80ee7bf0f620b464df627cffb8cd5ee21 Mon Sep 17 00:00:00 2001 From: YeongTaek Date: Tue, 16 Sep 2025 22:10:30 +0900 Subject: [PATCH 02/12] =?UTF-8?q?remove:=20=EB=AA=A9=ED=91=9C=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EC=B4=88=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/goals/page.tsx | 328 ------------------ src/app/layout.tsx | 29 +- .../budget/TransactionAlertProvider.tsx | 54 --- src/components/goals/GoalCard.tsx | 106 ------ .../goals/GoalCompletionNotification.tsx | 64 ---- .../goals/GoalCompletionProvider.tsx | 30 -- src/components/goals/GoalEditModal.tsx | 206 ----------- .../goals/GoalUpdateNotification.tsx | 96 ----- src/components/layout/Header.tsx | 62 ++-- src/hooks/budget/useTransactionAlert.ts | 44 --- src/hooks/goals/useFoalCompletionAlert.ts | 30 -- src/hooks/goals/useGoalChecker.ts | 84 ----- src/hooks/goals/useGoalStatusUpdater.ts | 153 -------- src/hooks/goals/useGoals.ts | 210 ----------- 14 files changed, 44 insertions(+), 1452 deletions(-) delete mode 100644 src/app/goals/page.tsx delete mode 100644 src/components/budget/TransactionAlertProvider.tsx delete mode 100644 src/components/goals/GoalCard.tsx delete mode 100644 src/components/goals/GoalCompletionNotification.tsx delete mode 100644 src/components/goals/GoalCompletionProvider.tsx delete mode 100644 src/components/goals/GoalEditModal.tsx delete mode 100644 src/components/goals/GoalUpdateNotification.tsx delete mode 100644 src/hooks/budget/useTransactionAlert.ts delete mode 100644 src/hooks/goals/useFoalCompletionAlert.ts delete mode 100644 src/hooks/goals/useGoalChecker.ts delete mode 100644 src/hooks/goals/useGoalStatusUpdater.ts delete mode 100644 src/hooks/goals/useGoals.ts diff --git a/src/app/goals/page.tsx b/src/app/goals/page.tsx deleted file mode 100644 index b4b32bf..0000000 --- a/src/app/goals/page.tsx +++ /dev/null @@ -1,328 +0,0 @@ -"use client"; - -import AuthGuard from "@/components/auth/AuthGuard"; -import GoalCard from "@/components/goals/GoalCard"; -import { useAuth } from "@/hooks/auth"; -import { useGoals } from "@/hooks/goals/useGoals"; -import { - Calendar, - Clock, - Filter, - Plus, - Search, - Target, - TrendingUp, - Trophy, -} from "lucide-react"; -import Link from "next/link"; -import { useMemo, useState } from "react"; - -const GoalsPage = () => { - const { user } = useAuth(); - const { goals, activeGoals, completedGoals, isLoading } = useGoals({ - userId: user?.id, - }); - - // 필터 상태 - const [activeTab, setActiveTab] = useState< - "active" | "completed" | "paused" | "cancelled" | "all" - >("active"); - const [searchTerm, setSearchTerm] = useState(""); - const [typeFilter, setTypeFilter] = useState<"all" | "income" | "expense">( - "all", - ); - - // 필터링된 목표들 - const filteredGoals = useMemo(() => { - let filtered = goals; - - if (activeTab !== "all") { - filtered = goals.filter((goal) => goal.status === activeTab); - } - - if (searchTerm) { - filtered = filtered.filter( - (goal) => - goal.title.toLowerCase().includes(searchTerm.toLowerCase()) || - goal.description?.toLowerCase().includes(searchTerm.toLowerCase()), - ); - } - - if (typeFilter !== "all") { - filtered = filtered.filter((goal) => { - return typeFilter === "income" - ? goal.type === "increase_income" - : goal.type === "reduce_expense"; - }); - } - - return filtered; - }, [goals, activeTab, searchTerm, typeFilter]); - - // 통계 계산 - const statistics = { - total: goals.length, - active: activeGoals.length, - completed: completedGoals.length, - completionRate: - goals.length > 0 ? (completedGoals.length / goals.length) * 100 : 0, - }; - - // 핸들러들 - const handleTypeFilterChange = (value: string) => { - if (value === "all" || value === "income" || value === "expense") { - setTypeFilter(value); - } - }; - - if (!user?.id) { - return ( - -
-
-

- 로그인이 필요합니다 -

-

- 목표를 관리하려면 먼저 로그인해주세요. -

-
-
-
- ); - } - - if (isLoading) { - return ( - -
-
-
-
- {[...Array(6)].map((_, i) => ( -
- ))} -
-
-
-
- ); - } - - return ( - -
- {/* 헤더 */} -
-
-

내 목표

-

- 진행 중인 챌린지와 달성한 목표를 확인하세요. -

-
- -
- -
-
- - {/* 통계 카드 */} -
-
-
-
- -
-
-

전체

-

- {statistics.total} -

-
-
-
- -
-
-
- -
-
-

진행 중

-

- {statistics.active} -

-
-
-
- -
-
-
- -
-
-

완료

-

- {statistics.completed} -

-
-
-
- -
-
-
- -
-
-

달성률

-

- {Math.round(statistics.completionRate)}% -

-
-
-
-
- - {/* 필터 및 검색 */} -
-
- {/* 탭 */} -
- - - - - -
- - {/* 검색 */} -
- - setSearchTerm(e.target.value)} - className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-accent-500" - /> -
- - {/* 타입 필터 */} -
- - -
-
-
- - {/* 목표 목록 */} - {filteredGoals.length > 0 ? ( -
- {filteredGoals.map((goal) => ( - - ))} -
- ) : ( -
-
- -
-

- {activeTab === "active" - ? "진행 중인 목표가 없습니다." - : activeTab === "completed" - ? "완료된 목표가 없습니다." - : searchTerm - ? "검색 결과가 없습니다." - : "아직 목표가 없습니다."} -

-

- {activeTab === "active" - ? "가계부에서 챌린지를 시작하거나 새로운 목표를 만들어보세요." - : activeTab === "completed" - ? "목표를 달성하면 여기에 표시됩니다." - : searchTerm - ? "다른 검색어로 시도해보세요." - : "첫 번째 목표를 만들어 성장을 시작해보세요."} -

- {!searchTerm && ( -
- - - - 가계부 챌린지 - -
- )} -
- )} -
-
- ); -}; - -export default GoalsPage; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1dafc92..dd04ad7 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,15 +1,14 @@ -import { Inter } from "next/font/google"; -import "./globals.css"; -import Providers from "./providers"; -import Header from "@/components/layout/Header"; -import TransactionAlertProvider from "@/components/budget/TransactionAlertProvider"; -const inter = Inter({ subsets: ["latin"] }); +import { Inter } from 'next/font/google'; +import './globals.css'; +import Providers from './providers'; +import Header from '@/components/layout/Header'; +const inter = Inter({ subsets: ['latin'] }); export const metadata = { - title: "DaylyLog - 매일의 기록이 만드는 특별한 변화", + title: 'DaylyLog - 매일의 기록이 만드는 특별한 변화', description: - "일상을 기록하고, 목표를 달성하며, 성장을 추적하는 스마트한 방법", - keywords: "가계부, 목표달성, 일상기록, 성찰, 자기계발", + '일상을 기록하고, 목표를 달성하며, 성장을 추적하는 스마트한 방법', + keywords: '가계부, 목표달성, 일상기록, 성찰, 자기계발', }; export default function RootLayout({ @@ -25,13 +24,11 @@ export default function RootLayout({ - -
-
-
{children}
- {/* // TODO: Footer 추가 */} -
-
+
+
+
{children}
+ {/* // TODO: Footer 추가 */} +
diff --git a/src/components/budget/TransactionAlertProvider.tsx b/src/components/budget/TransactionAlertProvider.tsx deleted file mode 100644 index 8d2e8a9..0000000 --- a/src/components/budget/TransactionAlertProvider.tsx +++ /dev/null @@ -1,54 +0,0 @@ -"use client"; - -import { createContext, ReactNode, useContext } from "react"; -import GoalUpdateNotification from "../goals/GoalUpdateNotification"; -import { useTransactionAlert } from "@/hooks/budget/useTransactionAlert"; - -interface TransactionAlertContextType { - showAlert: (categoryName: string, type: "income" | "expense") => void; - hideAlert: (categoryName: string) => void; - clearAllAlerts: () => void; -} - -const TransactionAlertContext = - createContext(null); - -export const useTransactionAlertContext = () => { - const context = useContext(TransactionAlertContext); - if (!context) { - throw new Error( - "useTransactionAlertContext must be used within a TransactionAlertProvider", - ); - } - return context; -}; - -interface TransactionAlertProviderProps { - children: ReactNode; -} - -const TransactionAlertProvider = ({ - children, -}: TransactionAlertProviderProps) => { - const { alerts, showAlert, hideAlert, clearAllAlerts } = - useTransactionAlert(); - - return ( - - {children} - - {/* 알림들 렌더링 */} - {alerts.map((alert) => ( - hideAlert(alert.categoryName)} - /> - ))} - - ); -}; - -export default TransactionAlertProvider; diff --git a/src/components/goals/GoalCard.tsx b/src/components/goals/GoalCard.tsx deleted file mode 100644 index 1225bab..0000000 --- a/src/components/goals/GoalCard.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { Goal, GoalProgressInfo } from "@/types/goals"; -import { format } from "date-fns"; -import { ko } from "date-fns/locale"; -import { CheckCircle, TrendingDown, TrendingUp } from "lucide-react"; - -interface GoalCardProps { - goal: Goal & { progress: GoalProgressInfo }; -} - -const GoalCard = ({ goal }: GoalCardProps) => { - const { progress } = goal; - - return ( -
-
-
-
- {goal.type === "increase_income" ? ( - - ) : ( - - )} -
-
-

{goal.title}

-

{progress.progressText}

-
-
- - {progress.isComplete && ( - - )} -
- - {/* 진행률 바 */} -
-
- 진행률 - {Math.round(progress.overallProgress)}% -
-
-
-
-
- - {/* 목표 상세 정보 */} -
- {goal.target_amount && ( -
- 금액 목표: - - {goal.current_amount.toLocaleString()} /{" "} - {goal.target_amount.toLocaleString()}원 - -
- )} - - {goal.target_count && ( -
- 횟수 목표: - - {goal.current_count} / {goal.target_count}회 - -
- )} - - {goal.target_date && ( -
- 마감일: - - {format(new Date(goal.target_date), "yyyy년 M월 d일", { - locale: ko, - })} - -
- )} -
- - {/* 이유 */} - {goal.reason && ( -
-

{goal.reason}

-
- )} -
- ); -}; - -export default GoalCard; diff --git a/src/components/goals/GoalCompletionNotification.tsx b/src/components/goals/GoalCompletionNotification.tsx deleted file mode 100644 index 56e5756..0000000 --- a/src/components/goals/GoalCompletionNotification.tsx +++ /dev/null @@ -1,64 +0,0 @@ -"use client"; - -import { CheckCircle, Trophy, X } from "lucide-react"; -import { useEffect, useState } from "react"; - -interface GoalCompletionNotificationProps { - goalTitle: string; - onClose: () => void; - autoCloseDelay?: number; -} - -const GoalCompletionNotification = ({ - goalTitle, - onClose, - autoCloseDelay = 5000, -}: GoalCompletionNotificationProps) => { - const [isVisible, setIsVisible] = useState(true); - - useEffect(() => { - const timer = setTimeout(() => { - setIsVisible(false); - setTimeout(onClose, 300); - }, autoCloseDelay); - - return () => clearTimeout(timer); - }, [autoCloseDelay, onClose]); - - if (!isVisible) return null; - - return ( -
-
-
- -
- -
-
- -

목표 달성!

-
-

- {goalTitle} 챌린지를 성공적으로 완료했습니다! -

-

- 축하합니다! 꾸준한 노력이 결실을 맺었네요. -

-
- - -
-
- ); -}; - -export default GoalCompletionNotification; diff --git a/src/components/goals/GoalCompletionProvider.tsx b/src/components/goals/GoalCompletionProvider.tsx deleted file mode 100644 index 83c38b4..0000000 --- a/src/components/goals/GoalCompletionProvider.tsx +++ /dev/null @@ -1,30 +0,0 @@ -"use client"; - -import { useGoalCompletionAlert } from "@/hooks/goals/useFoalCompletionAlert"; -import { ReactNode } from "react"; -import GoalCompletionNotification from "./GoalCompletionNotification"; - -interface GoalCompletionProviderProps { - children: ReactNode; -} - -const GoalCompletionProvider = ({ children }: GoalCompletionProviderProps) => { - const { completedGoals, hideCompletionAlert } = useGoalCompletionAlert(); - - return ( - <> - {children} - - {/* 완료 알림들 렌더링 */} - {completedGoals.map((goal) => ( - hideCompletionAlert(goal.id)} - /> - ))} - - ); -}; - -export default GoalCompletionProvider; diff --git a/src/components/goals/GoalEditModal.tsx b/src/components/goals/GoalEditModal.tsx deleted file mode 100644 index 6fec423..0000000 --- a/src/components/goals/GoalEditModal.tsx +++ /dev/null @@ -1,206 +0,0 @@ -"use client"; - -import { useGoals } from "@/hooks/goals/useGoals"; -import { Goal } from "@/types/goals"; -import { useState } from "react"; -import Modal from "../common/Modal"; -import { Target, TrendingDown, TrendingUp } from "lucide-react"; -import { format } from "date-fns"; - -interface GoalEditModalProps { - isOpen: boolean; - onClose: () => void; - goals: Array<{ goal: Goal; currentAmount: number; currentCount: number }>; - currentIndex: number; - totalCount: number; -} - -const GoalEditModal = ({ - isOpen, - onClose, - goals, - currentIndex, - totalCount, -}: GoalEditModalProps) => { - const { updateGoal, isUpdatingGoal } = useGoals(); - - const currentGoalData = goals[currentIndex]; - const { goal, currentAmount, currentCount } = currentGoalData; - - const [formData, setFormData] = useState({ - target_amount: goal.target_amount?.toString() || "", - target_count: goal.target_count?.toString() || "", - target_date: goal.target_date?.toString() || "", - }); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - - updateGoal({ - id: goal.id, - target_amount: formData.target_amount - ? Number(formData.target_amount) - : null, - target_count: formData.target_count - ? Number(formData.target_count) - : null, - target_date: formData.target_date || null, - }); - - // 다음 목표로 이동 또는 완료 - if (currentIndex + 1 < totalCount) { - // 다음 목표의 데이터로 폼 초기화 - const nextGoal = goals[currentIndex + 1].goal; - setFormData({ - target_amount: nextGoal.target_amount?.toString() || "", - target_count: nextGoal.target_count?.toString() || "", - target_date: nextGoal.target_date?.toString() || "", - }); - } - - onClose(); // 부모 컴포넌트에서 다음 목표 처리 - }; - - const isIncome = goal.type === "increase_income"; - - return ( - - -
-
- -
-
- - 챌린지 수정 ({currentIndex + 1}/{totalCount}) - - - 거래 내역 변경으로 인해 목표를 조정하세요. - -
-
-
- - -
- {/* 현재 상황 표시 */} -
-

- {isIncome ? ( - - ) : ( - - )} - {goal.title} -

- -
-
- 현재 실제: -
- {currentAmount.toLocaleString()}원 ({currentCount}건) -
-
-
- 기존 목표: -
- {goal.target_amount?.toLocaleString() || 0}원 ( - {goal.target_count || 0}건) -
-
-
-
- -
- {/* 목표 금액 */} -
- -
- - setFormData((prev) => ({ - ...prev, - target_amount: e.target.value, - })) - } - placeholder={currentAmount.toString()} - className="w-full px-3 py-2 pr-12 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-accent-500" - /> - - 원 - -
-

- 현재 실제 금액: {currentAmount.toLocaleString()}원 -

-
- - {/* 목표 횟수 */} -
- -
- - setFormData((prev) => ({ - ...prev, - target_count: e.target.value, - })) - } - placeholder={currentCount.toString()} - className="w-full px-3 py-2 pr-12 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-accent-500" - /> - - 회 - -
-

- 현재 실제 횟수: {currentCount}회 -

-
- - {/* 마감일 */} -
- - - setFormData((prev) => ({ - ...prev, - target_date: e.target.value, - })) - } - min={format(new Date(), "yyyy-MM-dd")} - className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-accent-500" - /> -
-
-
-
- - - 취소 - - 수정하기 - - -
- ); -}; - -export default GoalEditModal; diff --git a/src/components/goals/GoalUpdateNotification.tsx b/src/components/goals/GoalUpdateNotification.tsx deleted file mode 100644 index 36b46bf..0000000 --- a/src/components/goals/GoalUpdateNotification.tsx +++ /dev/null @@ -1,96 +0,0 @@ -"use client"; - -import { useAuth } from "@/hooks/auth"; -import { useGoalChecker } from "@/hooks/goals/useGoalChecker"; -import { AlertTriangle, Edit3, X } from "lucide-react"; -import { useState } from "react"; -import GoalEditModal from "./GoalEditModal"; - -interface GoalUpdateNotificationProps { - categoryName: string; - onClose: () => void; -} - -const GoalUpdateNotification = ({ - categoryName, - onClose, -}: GoalUpdateNotificationProps) => { - const { user } = useAuth(); - const { affectedGoals, hasAffectedGoals } = useGoalChecker( - user?.id, - categoryName, - ); - const [currentEditIndex, setCurrentEditIndex] = useState(-1); - - // 편집할 목표들 준비 - const editableGoals = affectedGoals.map((goalData) => ({ - goal: goalData, - currentAmount: goalData.currentMonthAmount, - currentCount: goalData.currentMonthCount, - })); - - if (!hasAffectedGoals) return null; - - return ( - <> -
-
- -
-

- {categoryName} 챌린지 업데이트 필요 -

-

- 내역이 변경되어 {affectedGoals.length}개의 챌린지를 수정해야 - 합니다. -

- - {affectedGoals.length === 1 ? ( - - ) : ( - - )} -
- - -
-
- - {currentEditIndex >= 0 && ( - { - if (currentEditIndex + 1 < editableGoals.length) { - setCurrentEditIndex((prev) => prev + 1); - } else { - setCurrentEditIndex(-1); - onClose(); - } - }} - goals={editableGoals} - currentIndex={currentEditIndex} - totalCount={editableGoals.length} - /> - )} - - ); -}; - -export default GoalUpdateNotification; diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 582c3a8..bf5b63c 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -1,7 +1,7 @@ -"use client"; +'use client'; -import Link from "next/link"; -import { usePathname } from "next/navigation"; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; import { BarChart3, Target, @@ -12,9 +12,9 @@ import { User, Settings, LogOut, -} from "lucide-react"; -import { useEffect, useRef, useState } from "react"; -import { useAuth } from "@/hooks/auth"; +} from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; +import { useAuth } from '@/hooks/auth'; export default function Header() { const pathname = usePathname(); @@ -34,46 +34,46 @@ export default function Header() { } }; - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); }, []); // preview 페이지에서는 헤더를 렌더링하지 않음 - if (pathname === "/preview" || pathname?.startsWith("/auth/")) { + if (pathname === '/preview' || pathname?.startsWith('/auth/')) { return null; } // 네비게이션 메뉴 항목 const navigationItems = [ { - name: "가계부", - href: "/budget", + name: '가계부', + href: '/budget', icon: BarChart3, - description: "지출 관리", + description: '지출 관리', }, { - name: "목표", - href: "/goals", + name: '목표', + href: '/goals', icon: Target, - description: "목표 설정", + description: '목표 설정', }, { - name: "질문", - href: "/questions", + name: '질문', + href: '/questions', icon: MessageSquare, - description: "성찰 질문", + description: '성찰 질문', }, { - name: "회고", - href: "/reflections", + name: '회고', + href: '/reflections', icon: BookOpen, - description: "일상 회고", + description: '일상 회고', }, ]; // 현재 경로가 활성 상태인지 확인 const isActiveRoute = (href: string) => { - return pathname === href || pathname?.startsWith(href + "/"); + return pathname === href || pathname?.startsWith(href + '/'); }; // 로그아웃 처리 @@ -82,15 +82,15 @@ export default function Header() { logout(); setIsUserMenuOpen(false); // 로그아웃 후 즉시 메인화면으로 이동 - window.location.href = "/"; + window.location.href = '/'; } catch (error) { - console.error("로그아웃 실패:", error); + console.error('로그아웃 실패:', error); } }; // 사용자 이름 표시 const getUserDisplayName = () => { - if (!user) return "사용자"; + if (!user) return '사용자'; // 프로필에서 우선 조회 if (profile?.nickname) return profile.nickname; @@ -101,7 +101,7 @@ export default function Header() { const name = user.user_metadata?.name; const email = user.email; - return nickname || name || email?.split("@")[0] || "사용자"; + return nickname || name || email?.split('@')[0] || '사용자'; }; return ( @@ -111,7 +111,7 @@ export default function Header() { {/* 로고 */}
DaylyLog @@ -133,8 +133,8 @@ export default function Header() { flex items-center gap-2 px-3 py-2 rounded-lg transition-all duration-200 text-sm laptop:text-base ${ isActive - ? "bg-accent-100 text-accent-700 font-semibold" - : "text-accent-600 hover:text-accent-800 hover:bg-accent-50" + ? 'bg-accent-100 text-accent-700 font-semibold' + : 'text-accent-600 hover:text-accent-800 hover:bg-accent-50' } `} > @@ -239,8 +239,8 @@ export default function Header() { flex items-center gap-3 px-3 py-3 rounded-lg transition-all duration-200 ${ isActive - ? "bg-accent-100 text-accent-700 font-semibold" - : "text-accent-600 hover:text-accent-800 hover:bg-accent-50" + ? 'bg-accent-100 text-accent-700 font-semibold' + : 'text-accent-600 hover:text-accent-800 hover:bg-accent-50' } `} > diff --git a/src/hooks/budget/useTransactionAlert.ts b/src/hooks/budget/useTransactionAlert.ts deleted file mode 100644 index 101f5b3..0000000 --- a/src/hooks/budget/useTransactionAlert.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { useCallback, useState } from "react"; - -interface TransactionAlert { - categoryName: string; - type: "income" | "expense"; - isVisible: boolean; -} - -export const useTransactionAlert = () => { - const [alerts, setAlerts] = useState([]); - - const showAlert = useCallback( - (categoryName: string, type: "income" | "expense") => { - // 중복 알림 방지 - setAlerts((prev) => { - const exists = prev.some( - (alert) => alert.categoryName === categoryName && alert.type === type, - ); - - if (exists) return prev; - - return [...prev, { categoryName, type, isVisible: true }]; - }); - }, - [], - ); - - const hideAlert = useCallback((categoryName: string) => { - setAlerts((prev) => - prev.filter((alert) => alert.categoryName !== categoryName), - ); - }, []); - - const clearAllAlerts = useCallback(() => { - setAlerts([]); - }, []); - - return { - alerts, - showAlert, - hideAlert, - clearAllAlerts, - }; -}; diff --git a/src/hooks/goals/useFoalCompletionAlert.ts b/src/hooks/goals/useFoalCompletionAlert.ts deleted file mode 100644 index b44dd46..0000000 --- a/src/hooks/goals/useFoalCompletionAlert.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { useCallback, useState } from "react"; - -interface CompletedGoal { - id: string; - title: string; -} - -export const useGoalCompletionAlert = () => { - const [completedGoals, setCompletedGoals] = useState([]); - - const showCompletionAlert = useCallback((goal: CompletedGoal) => { - setCompletedGoals((prev) => { - // 중복 방지 - const exists = prev.some((g) => g.id === goal.id); - if (exists) return prev; - - return [...prev, goal]; - }); - }, []); - - const hideCompletionAlert = useCallback((goalId: string) => { - setCompletedGoals((prev) => prev.filter((g) => g.id !== goalId)); - }, []); - - return { - completedGoals, - showCompletionAlert, - hideCompletionAlert, - }; -}; diff --git a/src/hooks/goals/useGoalChecker.ts b/src/hooks/goals/useGoalChecker.ts deleted file mode 100644 index 952ad7c..0000000 --- a/src/hooks/goals/useGoalChecker.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { supabase } from "@/lib/supabase"; -import { Goal } from "@/types/goals"; -import { useQuery } from "@tanstack/react-query"; -import { endOfMonth, format, startOfMonth } from "date-fns"; - -interface GoalWithCurrentData extends Goal { - currentMonthAmount: number; - currentMonthCount: number; - hasDataMismatch: boolean; -} - -export const useGoalChecker = (userId?: string, categoryName?: string) => { - const { data: affectedGoals = [] } = useQuery({ - queryKey: ["affected-goals", userId, categoryName], - queryFn: async (): Promise => { - if (!userId || !categoryName) return []; - - // 현재 월의 시작일과 종료일 - const now = new Date(); - const monthStart = format(startOfMonth(now), "yyyy-MM-dd"); - const monthEnd = format(endOfMonth(now), "yyyy-MM-dd"); - // 활성화된 목표들 조회 - const { data: goals, error: goalsError } = await supabase - .from("goals") - .select("*") - .eq("user_id", userId) - .eq("status", "active"); - - if (goalsError) throw goalsError; - if (!goals || goals.length === 0) return []; - - // 각 목표에 대해 현재 월 실제 데이터 계산 - const goalsWithCurrentData = await Promise.all( - goals.map(async (goal) => { - const tableName = - goal.type === "increase_income" ? "incomes" : "expenses"; - - // 해당 목표와 연관될 수 있는 카테고리의 현재 월 데이터 조회 - const { data: transactions } = await supabase - .from(tableName) - .select("amount, category:categories(name)") - .eq("user_id", userId) - .gte("date", monthStart) - .lte("date", monthEnd); - - // 해당 카테고리 이름과 관련된 데이터 집계 - const relatedTransactions = (transactions || []).filter( - (t) => - t.category && - "name" in t.category && - t.category.name === categoryName, - ); - - const currentMonthAmount = relatedTransactions.reduce( - (sum, t) => sum + t.amount, - 0, - ); - const currentMonthCount = relatedTransactions.length; - - // 데이터 불일치 여부 확인 - const hasDataMismatch = - goal.current_amount !== currentMonthAmount || // 1원 이상 차이 - goal.current_count !== currentMonthCount; // 1회 이상 차이 - - return { - ...goal, - currentMonthAmount, - currentMonthCount, - hasDataMismatch, - }; - }), - ); - - // 불일치가 있는 목표들만 반환 - return goalsWithCurrentData.filter((goal) => goal.hasDataMismatch); - }, - enabled: !!userId && !!categoryName, - }); - - return { - affectedGoals, - hasAffectedGoals: affectedGoals.length > 0, - }; -}; diff --git a/src/hooks/goals/useGoalStatusUpdater.ts b/src/hooks/goals/useGoalStatusUpdater.ts deleted file mode 100644 index 64a46cd..0000000 --- a/src/hooks/goals/useGoalStatusUpdater.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { supabase } from "@/lib/supabase"; -import { useQueryClient } from "@tanstack/react-query"; -import { useGoalCompletionAlert } from "./useFoalCompletionAlert"; - -export const useGoalStatusUpdater = () => { - const queryClient = useQueryClient(); - const { showCompletionAlert } = useGoalCompletionAlert(); - - const checkAndUpdateGoalStatus = async (goalId: string, userId: string) => { - try { - // 목표 정보 조회 - const { data: goal, error: goalError } = await supabase - .from("goals") - .select("*") - .eq("id", goalId) - .eq("user_id", userId) - .single(); - - if (goalError || !goal) { - console.error("목표 조회 실패:", goalError); - return; - } - - // 이미 완료된 목표는 건너뛰기 - if (goal.status === "completed") return; - - // 현재 월의 실제 데이터 조회 - const now = new Date(); - const monthStart = new Date(now.getFullYear(), now.getMonth(), 1) - .toISOString() - .split("T")[0]; - const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0) - .toISOString() - .split("T")[0]; - - const tableName = - goal.type === "increase_income" ? "incomes" : "expenses"; - - const { data: transactions } = await supabase - .from(tableName) - .select("amount") - .eq("user_id", userId) - .gte("date", monthStart) - .lte("date", monthEnd); - - if (!transactions) return; - - // 실제 달성 현황 계산 - const actualAmount = transactions.reduce((sum, t) => sum + t.amount, 0); - const actualCount = transactions.length; - - // 목표 달성 여부 확인 - let isCompleted = false; - - if (goal.challenge_mode === "amount") { - isCompleted = goal.target_amount - ? actualAmount >= goal.target_amount - : false; - } else if (goal.challenge_mode === "count") { - isCompleted = goal.target_count - ? actualCount >= goal.target_count - : false; - } else { - // 'both' - OR 조건 - const amountComplete = goal.target_amount - ? actualAmount >= goal.target_amount - : true; - const countComplete = goal.target_count - ? actualCount >= goal.target_count - : true; - isCompleted = amountComplete || countComplete; - } - - // 목표 달성 시 상태 업데이트 - if (isCompleted) { - const { error: updateError } = await supabase - .from("goals") - .update({ - status: "completed", - current_amount: actualAmount, - current_count: actualCount, - }) - .eq("id", goalId); - - if (updateError) { - console.error("목표 상태 업데이트 실패:", updateError); - } else { - // 캐시 무효화 - queryClient.invalidateQueries({ queryKey: ["goals"] }); - - // 완료 알림 표시 - showCompletionAlert({ - id: goal.id, - title: goal.totle, - }); - } - } else { - // 완료되지 않았지만 current 값은 업데이트 - const { error: updateError } = await supabase - .from("goals") - .update({ - current_amount: actualAmount, - current_count: actualCount, - }) - .eq("id", goalId); - - if (updateError) { - console.error("목표 진행률 업데이트 실패:", updateError); - } - } - } catch (error) { - console.error("목표 상태 확인 중 오류:", error); - } - }; - - // 여러 목표를 배치로 확인 - const checkMultipleGoals = async (goalIds: string[], userId: string) => { - await Promise.allSettled( - goalIds.map((goalId) => checkAndUpdateGoalStatus(goalId, userId)), - ); - }; - - // 특정 카테고리와 관련된 모든 목표 확인 - const checkGoalsByCategory = async ( - userId: string, - categoryName: string, - type: "income" | "expense", - ) => { - try { - const goalType = type === "income" ? "increase_income" : "reduce_expense"; - - const { data: goals } = await supabase - .from("goals") - .select("id") - .eq("user_id", userId) - .eq("type", goalType) - .eq("status", "active"); - - if (goals && goals.length > 0) { - const goalIds = goals.map((g) => g.id); - await checkMultipleGoals(goalIds, userId); - } - } catch (error) { - console.error("카테고리별 목표 확인 실패:", error); - } - }; - - return { - checkAndUpdateGoalStatus, - checkMultipleGoals, - checkGoalsByCategory, - }; -}; diff --git a/src/hooks/goals/useGoals.ts b/src/hooks/goals/useGoals.ts deleted file mode 100644 index ceb35df..0000000 --- a/src/hooks/goals/useGoals.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { supabase } from "@/lib/supabase"; -import { Goal, GoalFormData, GoalProgressInfo } from "@/types/goals"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useMemo } from "react"; - -interface UseGoalsProps { - userId?: string; - status?: Goal["status"]; -} - -export const useGoals = ({ userId, status }: UseGoalsProps = {}) => { - const queryClient = useQueryClient(); - - // 목표 조회 - const { - data: goals = [], - isLoading, - error, - } = useQuery({ - queryKey: ["goals", userId, status], - queryFn: async (): Promise => { - let query = supabase - .from("goals") - .select( - ` - *, - category:categories(*) - `, - ) - .order("created_at", { ascending: false }); - - if (userId) { - query = query.eq("user_id", userId); - } - - if (status) { - query = query.eq("status", status); - } - - const { data, error } = await query; - if (error) throw error; - return data || []; - }, - enabled: !!userId, - }); - - // 목표 생성 - const createGoalMutation = useMutation({ - mutationFn: async (newGoal: GoalFormData & { user_id: string }) => { - const goalData = { - ...newGoal, - current_amount: 0, - current_count: 0, - status: "active" as const, - created_from_date: new Date().toISOString().split("T")[0], - }; - - const { data, error } = await supabase - .from("goals") - .insert([goalData]) - .select( - ` - *, - category:categories(*) - `, - ) - .single(); - - if (error) throw error; - return data; - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["goals"] }); - }, - }); - - // 목표 수정 - const updateGoalMutation = useMutation({ - mutationFn: async (updates: { id: string } & Partial) => { - const { id, ...goalUpdates } = updates; - - const { data, error } = await supabase - .from("goals") - .update(goalUpdates) - .eq("id", id) - .select( - ` - *, - category:categories(*) - `, - ) - .single(); - - if (error) throw error; - return data; - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["goals"] }); - }, - }); - - // 목표 진행률 계산 - const goalsWithProgress = useMemo(() => { - return goals.map((goal) => { - const progressInfo = calculateGoalProgress(goal); - return { - ...goal, - progress: progressInfo, - }; - }); - }, [goals]); - - // 활성 목표만 필터링 - const activeGoals = useMemo(() => { - return goalsWithProgress.filter((goal) => goal.status === "active"); - }, [goalsWithProgress]); - - // 완료된 목표만 필터링 - const completedGoals = useMemo(() => { - return goalsWithProgress.filter((goal) => goal.status === "completed"); - }, [goalsWithProgress]); - - return { - goals: goalsWithProgress, - activeGoals, - completedGoals, - isLoading, - error, - createGoal: createGoalMutation.mutate, - isCreatingGoal: createGoalMutation.isPending, - updateGoal: updateGoalMutation.mutate, - isUpdatingGoal: updateGoalMutation.isPending, - }; -}; - -// 목표 진행률 계산 함수 -const calculateGoalProgress = (goal: Goal): GoalProgressInfo => { - const currentDate = new Date(); - const targetDate = goal.target_date ? new Date(goal.target_date) : null; - - // 남은 일수 계산 - const daysLeft = targetDate - ? Math.max( - 0, - Math.ceil( - (targetDate.getTime() - currentDate.getTime()) / - (1000 * 60 * 60 * 24), - ), - ) - : 0; - - // 금액 진행률 계산 - const amountProgress = - goal.target_amount && goal.target_amount > 0 - ? Math.min(100, (goal.current_amount / goal.target_amount) * 100) - : 0; - - // 횟수 진행률 계산 - const countProgress = - goal.target_count && goal.target_count > 0 - ? Math.min(100, (goal.current_count / goal.target_count) * 100) - : 0; - - // 완료 상태 체크 - const isAmountComplete = goal.target_amount - ? goal.current_amount >= goal.target_amount - : true; - const isCountComplete = goal.target_count - ? goal.current_count >= goal.target_count - : true; - - // 전체 진행률 계산 (OR 조건 - 하나만 완료해도 성공) - let overallProgress = 0; - let isComplete = false; - - if (goal.challenge_mode === "amount") { - overallProgress = amountProgress; - isComplete = isAmountComplete; - } else if (goal.challenge_mode === "count") { - overallProgress = countProgress; - isComplete = isCountComplete; - } else { - // 'both' - OR 조건 - overallProgress = Math.max(amountProgress, countProgress); - isComplete = isAmountComplete || isCountComplete; - } - - // 진행 상태 텍스트 - let progressText = ""; - if (isComplete) { - progressText = "목표 달성! 🎉"; - } else if (daysLeft === 0 && targetDate) { - progressText = "기한 만료"; - } else if (daysLeft > 0) { - progressText = `${daysLeft}일 남음`; - } else { - progressText = "진행 중"; - } - - return { - amountProgress, - countProgress, - overallProgress, - isAmountComplete, - isCountComplete, - isComplete, - daysLeft, - progressText, - }; -}; From c3ba11839a64409250b2570a9afc070d8fedeab9 Mon Sep 17 00:00:00 2001 From: YeongTaek Date: Tue, 16 Sep 2025 22:11:34 +0900 Subject: [PATCH 03/12] =?UTF-8?q?feat:=20Budget=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20=EC=B1=8C=EB=A6=B0=EC=A7=80=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=EA=B3=BC=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20?= =?UTF-8?q?=ED=95=AD=EB=AA=A9=20=EA=B0=AF=EC=88=98=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=EC=8B=9C=20=EC=88=98=EC=A0=95=20=EB=AA=A8=EB=8B=AC=20=EB=82=98?= =?UTF-8?q?=ED=83=80=EB=82=98=EA=B8=B0=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/goals/[id]/edit/page.tsx | 293 +++++++++ src/app/goals/[id]/page.tsx | 293 +++++++++ src/app/goals/page.tsx | 147 +++++ src/components/budget/BudgetForm.tsx | 743 ++++++++++++---------- src/components/budget/GoalUpdateModal.tsx | 166 +++++ src/hooks/budget/useBudget.ts | 170 ++++- src/hooks/budget/useChallenge.ts | 77 ++- src/hooks/goals/useGoals.ts | 48 ++ src/hooks/goals/useGoalsByBudget.ts | 56 ++ 9 files changed, 1622 insertions(+), 371 deletions(-) create mode 100644 src/app/goals/[id]/edit/page.tsx create mode 100644 src/app/goals/[id]/page.tsx create mode 100644 src/app/goals/page.tsx create mode 100644 src/components/budget/GoalUpdateModal.tsx create mode 100644 src/hooks/goals/useGoals.ts create mode 100644 src/hooks/goals/useGoalsByBudget.ts diff --git a/src/app/goals/[id]/edit/page.tsx b/src/app/goals/[id]/edit/page.tsx new file mode 100644 index 0000000..0dfbc88 --- /dev/null +++ b/src/app/goals/[id]/edit/page.tsx @@ -0,0 +1,293 @@ +'use client'; + +import { useAuth } from '@/hooks/auth'; +import { useGoals } from '@/hooks/goals/useGoals'; +import { useParams, useRouter } from 'next/navigation'; + +const GoalDetailPage = () => { + const { user } = useAuth(); + const { goals, isLoading } = useGoals({ userId: user?.id }); + const params = useParams(); + const router = useRouter(); + + const goalId = params.id as string; + const goal = goals.find((g) => g.id === goalId); + + // 로딩 상태 + if (isLoading) { + return ( +
+

목표 상세

+

로딩 중...

+
+ ); + } + + // 사용자 인증 확인 + if (!user?.id) { + return ( +
+

목표 상세

+

로그인이 필요합니다.

+
+ ); + } + + // 목표를 찾을 수 없는 경우 + if (!goal) { + return ( +
+

목표 상세

+

목표를 찾을 수 없습니다.

+ +
+ ); + } + + const handleEdit = () => { + router.push(`/goals/${goalId}/edit`); + }; + + const handleDelete = () => { + if (confirm('정말로 이 목표를 삭제하시겠습니까?')) { + // TODO: 목표 삭제 구현 + console.log('목표 삭제:', goalId); + router.push('/goals'); + } + }; + + const getStatusText = (status: string) => { + switch (status) { + case 'active': + return '진행 중'; + case 'completed': + return '완료됨'; + case 'paused': + return '일시정지'; + case 'cancelled': + return '취소됨'; + default: + return status; + } + }; + + const getTypeText = (type: string) => { + switch (type) { + case 'save_money': + return '돈 저축'; + case 'reduce_expense': + return '지출 줄이기'; + case 'increase_income': + return '수입 늘리기'; + case 'custom': + return '사용자 정의'; + default: + return type; + } + }; + + const getChallengeMode = (mode: string) => { + switch (mode) { + case 'amount': + return '금액'; + case 'count': + return '횟수'; + case 'both': + return '금액 + 횟수'; + default: + return mode; + } + }; + + return ( +
+
+ +
+ +

{goal.title}

+ + {/* 기본 정보 */} +
+

기본 정보

+
+

+ 설명: {goal.description || '설명 없음'} +

+

+ 이유: {goal.reason || '이유 없음'} +

+

+ 타입: {getTypeText(goal.type)} +

+

+ 상태: {getStatusText(goal.status)} +

+

+ 챌린지 모드:{' '} + {getChallengeMode(goal.challenge_mode)} +

+ {goal.category && ( +

+ 카테고리: {goal.category.name} +

+ )} +
+
+ + {/* 목표 정보 */} +
+

목표 정보

+
+ {goal.target_amount && ( +

+ 목표 금액: {goal.target_amount.toLocaleString()} + 원 + {goal.current_amount !== undefined && ( + (현재: {goal.current_amount.toLocaleString()}원) + )} +

+ )} + {goal.target_count && ( +

+ 목표 횟수: {goal.target_count}회 + {goal.current_count !== undefined && ( + (현재: {goal.current_count}회) + )} +

+ )} + {goal.target_date && ( +

+ 목표 날짜:{' '} + {new Date(goal.target_date).toLocaleDateString()} +

+ )} + {goal.created_from_date && ( +

+ 시작 날짜:{' '} + {new Date(goal.created_from_date).toLocaleDateString()} +

+ )} +
+
+ + {/* 진행률 */} + {goal.target_amount && goal.current_amount !== undefined && ( +
+

금액 진행률

+
+

+ 진행률:{' '} + {((goal.current_amount / goal.target_amount) * 100).toFixed(1)}% +

+
+
+
+
+
+ )} + + {/* 횟수 진행률 */} + {goal.target_count && goal.current_count !== undefined && ( +
+

횟수 진행률

+
+

+ 진행률:{' '} + {((goal.current_count / goal.target_count) * 100).toFixed(1)}% +

+
+
+
+
+
+ )} + + {/* 날짜 정보 */} +
+

날짜 정보

+
+

+ 생성일:{' '} + {new Date(goal.created_at).toLocaleDateString()} +

+

+ 수정일:{' '} + {new Date(goal.updated_at).toLocaleDateString()} +

+
+
+ + {/* 액션 버튼들 */} + {goal.status === 'active' && ( +
+ + +
+ )} +
+ ); +}; + +export default GoalDetailPage; diff --git a/src/app/goals/[id]/page.tsx b/src/app/goals/[id]/page.tsx new file mode 100644 index 0000000..0dfbc88 --- /dev/null +++ b/src/app/goals/[id]/page.tsx @@ -0,0 +1,293 @@ +'use client'; + +import { useAuth } from '@/hooks/auth'; +import { useGoals } from '@/hooks/goals/useGoals'; +import { useParams, useRouter } from 'next/navigation'; + +const GoalDetailPage = () => { + const { user } = useAuth(); + const { goals, isLoading } = useGoals({ userId: user?.id }); + const params = useParams(); + const router = useRouter(); + + const goalId = params.id as string; + const goal = goals.find((g) => g.id === goalId); + + // 로딩 상태 + if (isLoading) { + return ( +
+

목표 상세

+

로딩 중...

+
+ ); + } + + // 사용자 인증 확인 + if (!user?.id) { + return ( +
+

목표 상세

+

로그인이 필요합니다.

+
+ ); + } + + // 목표를 찾을 수 없는 경우 + if (!goal) { + return ( +
+

목표 상세

+

목표를 찾을 수 없습니다.

+ +
+ ); + } + + const handleEdit = () => { + router.push(`/goals/${goalId}/edit`); + }; + + const handleDelete = () => { + if (confirm('정말로 이 목표를 삭제하시겠습니까?')) { + // TODO: 목표 삭제 구현 + console.log('목표 삭제:', goalId); + router.push('/goals'); + } + }; + + const getStatusText = (status: string) => { + switch (status) { + case 'active': + return '진행 중'; + case 'completed': + return '완료됨'; + case 'paused': + return '일시정지'; + case 'cancelled': + return '취소됨'; + default: + return status; + } + }; + + const getTypeText = (type: string) => { + switch (type) { + case 'save_money': + return '돈 저축'; + case 'reduce_expense': + return '지출 줄이기'; + case 'increase_income': + return '수입 늘리기'; + case 'custom': + return '사용자 정의'; + default: + return type; + } + }; + + const getChallengeMode = (mode: string) => { + switch (mode) { + case 'amount': + return '금액'; + case 'count': + return '횟수'; + case 'both': + return '금액 + 횟수'; + default: + return mode; + } + }; + + return ( +
+
+ +
+ +

{goal.title}

+ + {/* 기본 정보 */} +
+

기본 정보

+
+

+ 설명: {goal.description || '설명 없음'} +

+

+ 이유: {goal.reason || '이유 없음'} +

+

+ 타입: {getTypeText(goal.type)} +

+

+ 상태: {getStatusText(goal.status)} +

+

+ 챌린지 모드:{' '} + {getChallengeMode(goal.challenge_mode)} +

+ {goal.category && ( +

+ 카테고리: {goal.category.name} +

+ )} +
+
+ + {/* 목표 정보 */} +
+

목표 정보

+
+ {goal.target_amount && ( +

+ 목표 금액: {goal.target_amount.toLocaleString()} + 원 + {goal.current_amount !== undefined && ( + (현재: {goal.current_amount.toLocaleString()}원) + )} +

+ )} + {goal.target_count && ( +

+ 목표 횟수: {goal.target_count}회 + {goal.current_count !== undefined && ( + (현재: {goal.current_count}회) + )} +

+ )} + {goal.target_date && ( +

+ 목표 날짜:{' '} + {new Date(goal.target_date).toLocaleDateString()} +

+ )} + {goal.created_from_date && ( +

+ 시작 날짜:{' '} + {new Date(goal.created_from_date).toLocaleDateString()} +

+ )} +
+
+ + {/* 진행률 */} + {goal.target_amount && goal.current_amount !== undefined && ( +
+

금액 진행률

+
+

+ 진행률:{' '} + {((goal.current_amount / goal.target_amount) * 100).toFixed(1)}% +

+
+
+
+
+
+ )} + + {/* 횟수 진행률 */} + {goal.target_count && goal.current_count !== undefined && ( +
+

횟수 진행률

+
+

+ 진행률:{' '} + {((goal.current_count / goal.target_count) * 100).toFixed(1)}% +

+
+
+
+
+
+ )} + + {/* 날짜 정보 */} +
+

날짜 정보

+
+

+ 생성일:{' '} + {new Date(goal.created_at).toLocaleDateString()} +

+

+ 수정일:{' '} + {new Date(goal.updated_at).toLocaleDateString()} +

+
+
+ + {/* 액션 버튼들 */} + {goal.status === 'active' && ( +
+ + +
+ )} +
+ ); +}; + +export default GoalDetailPage; diff --git a/src/app/goals/page.tsx b/src/app/goals/page.tsx new file mode 100644 index 0000000..a7f6e28 --- /dev/null +++ b/src/app/goals/page.tsx @@ -0,0 +1,147 @@ +'use client'; + +import { useAuth } from '@/hooks/auth'; +import { useGoals } from '@/hooks/goals/useGoals'; + +const GoalsPage = () => { + const { user, isLoading: isAuthLoading } = useAuth(); + + const { + activeGoals, + completedGoals, + goals, + isLoading: isGoalsLoading, + } = useGoals({ + userId: user?.id, + }); + + // 로딩 상태 + if (isAuthLoading || isGoalsLoading) { + return ( +
+

목표 관리

+

로딩 중...

+
+ ); + } + + // 사용자 인증 확인 + if (!user?.id) { + return ( +
+

목표 관리

+

로그인이 필요합니다.

+
+ ); + } + + return ( +
+

목표 관리

+ + {/* 기본 통계 */} +
+

목표 현황

+
    +
  • 전체 목표: {goals.length}개
  • +
  • 진행 중인 목표: {activeGoals.length}개
  • +
  • 완료된 목표: {completedGoals.length}개
  • +
+
+ + {/* 진행 중인 목표들 */} +
+

진행 중인 목표

+ {activeGoals.length === 0 ? ( +

진행 중인 목표가 없습니다.

+ ) : ( +
    + {activeGoals.map((goal) => ( +
  • +
    +

    {goal.title}

    +

    {goal.description}

    +

    타입: {goal.type}

    +

    상태: {goal.status}

    + {goal.target_amount && ( +

    + 목표 금액: {goal.target_amount.toLocaleString()}원 + {goal.current_amount !== undefined && ( + + {' '} + (현재: {goal.current_amount.toLocaleString()}원) + + )} +

    + )} + {goal.target_count && ( +

    + 목표 횟수: {goal.target_count}회 + {goal.current_count !== undefined && ( + (현재: {goal.current_count}회) + )} +

    + )} + {goal.target_date &&

    목표 날짜: {goal.target_date}

    } + {goal.challenge_mode && ( +

    + 챌린지 모드:{' '} + {goal.challenge_mode === 'both' + ? '금액 + 횟수' + : goal.challenge_mode === 'amount' + ? '금액' + : '횟수'} +

    + )} + {goal.category &&

    카테고리: {goal.category.name}

    } +

    + 생성일: {new Date(goal.created_at).toLocaleDateString()} +

    +
    +
    +
  • + ))} +
+ )} +
+ + {/* 완료된 목표들 */} + {completedGoals.length > 0 && ( +
+

완료된 목표

+
    + {completedGoals.map((goal) => ( +
  • +
    +

    {goal.title}

    +

    {goal.description}

    +

    타입: {goal.type}

    + {goal.target_amount && ( +

    달성 금액: {goal.current_amount?.toLocaleString()}원

    + )} + {goal.target_count && ( +

    달성 횟수: {goal.current_count}회

    + )} +

    + 완료일: {new Date(goal.updated_at).toLocaleDateString()} +

    +
    +
    +
  • + ))} +
+
+ )} + + {/* 목표가 없을 때 */} + {goals.length === 0 && ( +
+

아직 목표가 없습니다

+

예산 페이지에서 챌린지를 만들어 목표를 시작해보세요!

+
+ )} +
+ ); +}; + +export default GoalsPage; diff --git a/src/components/budget/BudgetForm.tsx b/src/components/budget/BudgetForm.tsx index 22b56c2..c55027b 100644 --- a/src/components/budget/BudgetForm.tsx +++ b/src/components/budget/BudgetForm.tsx @@ -1,11 +1,12 @@ -"use client"; - -import { useAuth } from "@/hooks/auth"; -import { supabase } from "@/lib/supabase"; -import { BudgetFormProps, Category } from "@/types/budget"; -import { useQueryClient } from "@tanstack/react-query"; -import { format, parseISO } from "date-fns"; -import { ko } from "date-fns/locale"; +'use client'; + +import { useAuth } from '@/hooks/auth'; +import { supabase } from '@/lib/supabase'; +import { BudgetFormProps, Category } from '@/types/budget'; +import { Goal } from '@/types/goals'; +import { useQueryClient } from '@tanstack/react-query'; +import { format, parseISO } from 'date-fns'; +import { ko } from 'date-fns/locale'; import { ArrowLeft, ChevronDown, @@ -17,9 +18,10 @@ import { TrendingDown, TrendingUp, X, -} from "lucide-react"; -import { useRouter } from "next/navigation"; -import { useState } from "react"; +} from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { useEffect, useState } from 'react'; +import GoalUpdateModal, { CategoryChangeData } from './GoalUpdateModal'; export default function BudgetForm({ selectedDate, @@ -33,10 +35,79 @@ export default function BudgetForm({ const queryClient = useQueryClient(); const router = useRouter(); const { user } = useAuth(); + const [goalUpdateModal, setGoalUpdateModal] = useState<{ + isOpen: boolean; + goals: Goal[]; + categoryChange: CategoryChangeData | null; + }>({ + isOpen: false, + goals: [], + categoryChange: null, + }); + + useEffect(() => { + const handleGoalUpdateNeeded = (event: CustomEvent) => { + const { + categoryName, + newAmount, + newCount, + oldAmount, + oldCount, + type, + goals, + } = event.detail; + + triggerGoalUpdateModal({ + categoryName, + newAmount, + newCount, + oldAmount, + oldCount, + type, + goals, + }); + }; + + window.addEventListener( + 'goalUpdateNeeded', + handleGoalUpdateNeeded as EventListener + ); + + return () => { + window.removeEventListener( + 'goalUpdateNeeded', + handleGoalUpdateNeeded as EventListener + ); + }; + }, []); + + // 목표 업데이트 모달 트리거 + const triggerGoalUpdateModal = (data: { + categoryName: string; + newAmount: number; + newCount: number; + oldAmount: number; + oldCount: number; + type: 'income' | 'expense'; + goals: Goal[]; + }) => { + setGoalUpdateModal({ + isOpen: true, + goals: data.goals, + categoryChange: { + categoryName: data.categoryName, + newAmount: data.newAmount, + newCount: data.newCount, + oldAmount: data.oldAmount, + oldCount: data.oldCount, + type: data.type, + }, + }); + }; const deletedCategories = allCategories.filter( (cat) => - cat.type === `${newItem.type}_${newItem.categoryType}` && cat.is_deleted, + cat.type === `${newItem.type}_${newItem.categoryType}` && cat.is_deleted ); // 삭제된 카테고리 관리 @@ -53,7 +124,7 @@ export default function BudgetForm({ e.preventDefault(); if (!newItem.newCategoryName.trim()) { - console.error("카테고리 이름이 비어있습니다"); + console.error('카테고리 이름이 비어있습니다'); return; } @@ -63,18 +134,18 @@ export default function BudgetForm({ (cat) => cat.name === newItem.newCategoryName.trim() && cat.type === categoryType && - cat.is_deleted, + cat.is_deleted ); if (existingCategory) { const shouldRestore = confirm( - `'${newItem.newCategoryName.trim()}' 카테고리를 복원하시겠습니까?`, + `'${newItem.newCategoryName.trim()}' 카테고리를 복원하시겠습니까?` ); if (shouldRestore) { await handleRestoreCategory(existingCategory.id); onNewItemChange({ - newCategoryName: "", + newCategoryName: '', category: existingCategory.name, isCreatingCategory: false, }); @@ -86,17 +157,17 @@ export default function BudgetForm({ }; // 날짜 네비게이션 핸들러 추가 - const handleDateChange = (direction: "prev" | "next") => { + const handleDateChange = (direction: 'prev' | 'next') => { const currentDateObj = parseISO(selectedDate); const newDate = new Date(currentDateObj); - if (direction === "prev") { + if (direction === 'prev') { newDate.setDate(newDate.getDate() - 1); } else { newDate.setDate(newDate.getDate() + 1); } - const newDateString = format(newDate, "yyyy-MM-dd"); + const newDateString = format(newDate, 'yyyy-MM-dd'); router.push(`/budget/${newDateString}`); }; @@ -112,28 +183,28 @@ export default function BudgetForm({ // 선택된 카테고리 해제 const deletedCategory = allCategories.find((cat) => cat.id === categoryId); if (newItem.category === deletedCategory?.name) { - onNewItemChange({ category: "" }); + onNewItemChange({ category: '' }); } // 올바른 queryClient 인스턴스 사용 queryClient.setQueryData( - ["categories", user?.id], + ['categories', user?.id], (oldData: Category[] = []) => oldData.map((cat) => - cat.id === categoryId ? { ...cat, is_deleted: true } : cat, - ), + cat.id === categoryId ? { ...cat, is_deleted: true } : cat + ) ); try { const { error } = await supabase - .from("categories") + .from('categories') .update({ is_deleted: true }) - .eq("id", categoryId); + .eq('id', categoryId); if (error) throw error; } catch (error) { - console.error("카테고리 삭제 실패:", error); - queryClient.invalidateQueries({ queryKey: ["categories", user?.id] }); + console.error('카테고리 삭제 실패:', error); + queryClient.invalidateQueries({ queryKey: ['categories', user?.id] }); } }; @@ -141,346 +212,364 @@ export default function BudgetForm({ const handleRestoreCategory = async (categoryId: string) => { // 낙관적 업데이트 queryClient.setQueryData( - ["categories", user?.id], + ['categories', user?.id], (oldData: Category[] = []) => oldData.map((cat) => - cat.id === categoryId ? { ...cat, is_deleted: false } : cat, - ), + cat.id === categoryId ? { ...cat, is_deleted: false } : cat + ) ); try { const { error } = await supabase - .from("categories") + .from('categories') .update({ is_deleted: false }) - .eq("id", categoryId); + .eq('id', categoryId); if (error) throw error; } catch (error) { - console.error("카테고리 복구 실패:", error); + console.error('카테고리 복구 실패:', error); // 실패 시 롤백 - queryClient.invalidateQueries({ queryKey: ["categories", user?.id] }); + queryClient.invalidateQueries({ queryKey: ['categories', user?.id] }); } }; return ( -
-
-
- - -

- {format(parseISO(selectedDate), "M월 d일", { locale: ko })} -

- - -
+ <> +
+
+
+ + +

+ {format(parseISO(selectedDate), 'M월 d일', { locale: ko })} +

-
-
- {/* 날짜 직접 선택 */} - -
-
+
+
+ +
- {/* 항목 추가 폼 */} -
-
-

항목

+ {/* 날짜 직접 선택 */} + +
- {/* 수입/지출 타입 선택 */} -
- - -
+ {/* 항목 추가 폼 */} +
+
+

항목

+
- {/* 고정/변동 타입 선택 */} -
- - -
+ {/* 수입/지출 타입 선택 */} +
+ + +
-
- {/* 카테고리 선택/생성 */} -
-
- - - {newItem.isCreatingCategory ? ( -
-
- - onNewItemChange({ newCategoryName: e.target.value }) - } - className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-accent-500" - /> - - + {/* 고정/변동 타입 선택 */} +
+ + +
+ + + {/* 카테고리 선택/생성 */} +
+
+ + + {newItem.isCreatingCategory ? ( +
+
+ + onNewItemChange({ newCategoryName: e.target.value }) + } + className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-accent-500" + /> + + +
-
- ) : ( -
- {/* 활성 카테고리 태그들 */} -
- {allCategories - .filter( - (cat) => - cat.type === - `${newItem.type}_${newItem.categoryType}` && - !cat.is_deleted, - ) - .map((category) => ( -
- - -
- ))} - - {/* 카테고리 추가 버튼 */} - -
- {/* 사용하지 않는 카테고리 섹션 */} - {/* TODO: 삭제할 때, 일괄적으로 삭제할 수 있도록 하기. 소프트 - 삭제를 하되 언제든지 다시 복구할 수 있도록 하기 - 또한 해당 카테고리의 건수가 0일 경우에는 카테고리를 영구삭제 할 수 있도록 하기 - 소프트 삭제를 고려한 이유는 데이터 보존을 위해 UI 측면에서만 작동하도록 한 것이다.*/} - {deletedCategories.length > 0 && ( -
+ + +
+ ))} + + {/* 카테고리 추가 버튼 */} - - {showDeletedCategories && ( -
- {deletedCategories.map((category) => ( -
- - {category.name} - - - ({category.transactionCount || 0}건) - - -
- ))} -
- )}
- )} -
- )} -
- -
- - onNewItemChange({ name: e.target.value })} - className="w-full border border-gray-300 rounded-lg px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-accent-500" - /> -
-
- -
-
- - onNewItemChange({ amount: e.target.value })} - className="w-full border border-gray-300 rounded-lg px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-accent-500" - /> + {/* 사용하지 않는 카테고리 섹션 */} + {/* TODO: 삭제할 때, 일괄적으로 삭제할 수 있도록 하기. 소프트 + 삭제를 하되 언제든지 다시 복구할 수 있도록 하기 + 또한 해당 카테고리의 건수가 0일 경우에는 카테고리를 영구삭제 할 수 있도록 하기 + 소프트 삭제를 고려한 이유는 데이터 보존을 위해 UI 측면에서만 작동하도록 한 것이다.*/} + {deletedCategories.length > 0 && ( +
+ + + {showDeletedCategories && ( +
+ {deletedCategories.map((category) => ( +
+ + {category.name} + + + ({category.transactionCount || 0}건) + + +
+ ))} +
+ )} +
+ )} +
+ )} +
+ +
+ + onNewItemChange({ name: e.target.value })} + className="w-full border border-gray-300 rounded-lg px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-accent-500" + /> +
-
- +
+
+ + onNewItemChange({ amount: e.target.value })} + className="w-full border border-gray-300 rounded-lg px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-accent-500" + /> +
+ +
+ +
-
- + +
-
+ + {/* 목표 업데이트 모달 */} + {goalUpdateModal.categoryChange && ( + + setGoalUpdateModal({ + isOpen: false, + goals: [], + categoryChange: null, + }) + } + goals={goalUpdateModal.goals} + categoryChange={goalUpdateModal.categoryChange} + /> + )} + ); } diff --git a/src/components/budget/GoalUpdateModal.tsx b/src/components/budget/GoalUpdateModal.tsx new file mode 100644 index 0000000..a6a3c84 --- /dev/null +++ b/src/components/budget/GoalUpdateModal.tsx @@ -0,0 +1,166 @@ +import { Goal } from '@/types/goals'; +import Modal from '../common/Modal'; +import { Target, TrendingDown, TrendingUp } from 'lucide-react'; +import { useRouter } from 'next/navigation'; + +export interface CategoryChangeData { + categoryName: string; + newAmount: number; + newCount: number; + oldAmount: number; + oldCount: number; + type: 'income' | 'expense'; +} + +interface GoalUpdateModalProps { + isOpen: boolean; + onClose: () => void; + goals: Goal[]; + categoryChange: CategoryChangeData; +} + +const GoalUpdateModal = ({ + isOpen, + onClose, + goals, + categoryChange, +}: GoalUpdateModalProps) => { + const router = useRouter(); + + const handleGoalEdit = (goalId: string) => { + router.push(`/goals/${goalId}/edit`); + onClose(); + }; + + return ( + + +
+
+ +
+
+ 목표 업데이트 + +
+ {categoryChange.categoryName} 카테고리의 + 데이터가 변경되었습니다. 관련 목표들을 확인하고 + 수정하시겠습니까? +
+
+
+
+
+ + + {/* 변경사항 요약 */} +
+

+ {categoryChange.type === 'income' ? ( + + ) : ( + + )} + 카테고리 변경사항 +

+ +
+
+ 금액: + + {categoryChange.oldAmount.toLocaleString()}원 →{' '} + {categoryChange.newAmount.toLocaleString()}원 + categoryChange.oldAmount + ? 'text-green-600' + : 'text-red-600' + }`} + > + ( + {categoryChange.newAmount > categoryChange.oldAmount + ? '+' + : ''} + {( + ((categoryChange.newAmount - categoryChange.oldAmount) / + categoryChange.oldAmount) * + 100 + ).toFixed(1)} + %) + + +
+
+ 횟수: + + {categoryChange.oldCount}회 → {categoryChange.newCount}회 + categoryChange.oldCount + ? 'text-green-600' + : 'text-red-600' + }`} + > + ( + {categoryChange.newCount > categoryChange.oldCount ? '+' : ''} + {categoryChange.newCount - categoryChange.oldCount}) + + +
+
+
+ + {/* 관련 목표 리스트 */} +
+

관련 목표들

+ {goals.map((goal) => ( +
handleGoalEdit(goal.id)} + > +
+
+
{goal.title}
+

+ {goal.description} +

+ +
+ {goal.target_amount && ( + + 목표 금액: {goal.target_amount.toLocaleString()}원 + + )} + {goal.target_count && ( + + 목표 횟수: {goal.target_count}회 + + )} +
+
+ +
+ + {goal.challenge_mode === 'both' + ? '금액 + 횟수' + : goal.challenge_mode === 'amount' + ? '금액' + : '횟수'} + + 클릭하여 수정 +
+
+
+ ))} +
+
+ + + 나중에 수정 + +
+ ); +}; + +export default GoalUpdateModal; diff --git a/src/hooks/budget/useBudget.ts b/src/hooks/budget/useBudget.ts index 403657a..8a68fc9 100644 --- a/src/hooks/budget/useBudget.ts +++ b/src/hooks/budget/useBudget.ts @@ -1,4 +1,3 @@ -import { useTransactionAlertContext } from "@/components/budget/TransactionAlertProvider"; import { supabase } from "@/lib/supabase"; import { BudgetFormData, @@ -10,7 +9,6 @@ import { import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { endOfMonth, format, parseISO, startOfMonth } from "date-fns"; import { useMemo } from "react"; -import { useGoalStatusUpdater } from "../goals/useGoalStatusUpdater"; interface UseBudgetProps { userId?: string; @@ -20,8 +18,6 @@ interface UseBudgetProps { export const useBudget = ({ userId, date, month }: UseBudgetProps = {}) => { const queryClient = useQueryClient(); - const { showAlert } = useTransactionAlertContext(); - const { checkGoalsByCategory } = useGoalStatusUpdater(); // 통합 데이터 조회 (수입 + 지출) const { @@ -270,6 +266,21 @@ export const useBudget = ({ userId, date, month }: UseBudgetProps = {}) => { const tableName = newTransaction.type === "income" ? "incomes" : "expenses"; + const { data: categoryInfo } = await supabase + .from("categories") + .select('name') + .eq('id', newTransaction.category_id) + .single(); + + const categoryName = categoryInfo?.name || ''; + + // 트랜잭션 추가 전 카테고리 통계 저장 + const beforeStats = await getCategoryStats( + newTransaction.user_id, + categoryName, + newTransaction.type + ); + const { type, ...transactionData } = newTransaction; const { data, error } = await supabase @@ -284,25 +295,46 @@ export const useBudget = ({ userId, date, month }: UseBudgetProps = {}) => { .single(); if (error) throw error; - return { ...data, type }; + + // 트랜잭션 추가 후 카테고리 통계 조회 + const afterStats = await getCategoryStats( + newTransaction.user_id, + data.category?.name || '', + type + ); + + return { ...data, type, beforeStats, afterStats }; }, - onSuccess: async (newTransaction) => { + onSuccess: async (result) => { queryClient.invalidateQueries({ queryKey: ["budget"] }); queryClient.invalidateQueries({ queryKey: ["goals"] }); - // 알림 표시 - if (newTransaction.category?.name && userId) { - // 목표 상태 확인 (알림보다 먼저 실행) - await checkGoalsByCategory( + // 카테고리 통계가 변경되었는지 확인 + const { beforeStats, afterStats, category } = result; + + if (beforeStats && afterStats && category?.name && userId && (beforeStats.amount !== afterStats.amount || beforeStats.count !== afterStats.count)) { + // 해당 카테고리에 연결된 목표들이 있는지 확인 + const categoryGoals = await checkCategoryGoals( userId, - newTransaction.category.name, - newTransaction.type, + category.name, + result.type ); - // 지연을 두어 TanStack Query 업데이트 후 알림 표시 - setTimeout(() => { - showAlert(newTransaction.category!.name, newTransaction.type); - }, 100); + if (categoryGoals.length > 0) { + // 목표 업데이트 모달 트리거 - 컴포넌트에서 처리 + // 여기서는 상태만 업데이트, 실제 모달은 컴포넌트에서 처리 + window.dispatchEvent(new CustomEvent('goalUpdateNeeded', { + detail: { + categoryName: category.name, + newAmount: afterStats.amount, + newCount: afterStats.count, + oldAmount: beforeStats.amount, + oldCount: beforeStats.count, + type: result.type, + goals: categoryGoals + } + })) + } } }, }); @@ -324,21 +356,54 @@ export const useBudget = ({ userId, date, month }: UseBudgetProps = {}) => { .eq("id", id) .single(); - const { error } = await supabase.from(tableName).delete().eq("id", id); + if (!transactionToDelete) throw new Error('삭제할 거래를 찾을 수 없습니다.'); + // 삭제 전 카테고리 통계 저장 + const beforeStats = await getCategoryStats( + transactionToDelete.user_id, + transactionToDelete.category?.name || '', + type + ); + + const { error } = await supabase.from(tableName).delete().eq("id", id); if (error) throw error; - return { deletedTransaction: transactionToDelete, type }; + // 삭제 후 카테고리 통계 조회 + const afterStats = await getCategoryStats( + transactionToDelete.user_id, + transactionToDelete.category?.name || '', + type + ); + + return { deletedTransaction: transactionToDelete, type, beforeStats, afterStats }; }, - onSuccess: ({ deletedTransaction, type }) => { + onSuccess: async ({ deletedTransaction, type, beforeStats, afterStats }) => { queryClient.invalidateQueries({ queryKey: ["budget"] }); queryClient.invalidateQueries({ queryKey: ["goals"] }); - // 알림 표시 - if (deletedTransaction?.category?.name && userId) { - setTimeout(() => { - showAlert(deletedTransaction.category!.name, type); - }, 100); + // 카테고리 통계가 변경되었는지 확인 + if (beforeStats && afterStats && deletedTransaction?.category?.name && userId) { + if (beforeStats.amount !== afterStats.amount || beforeStats.count !== afterStats.count) { + const categoryGoals = await checkCategoryGoals( + userId, + deletedTransaction.category.name, + type + ); + + if (categoryGoals.length > 0) { + window.dispatchEvent(new CustomEvent('goalUpdateNeeded', { + detail: { + categoryName: deletedTransaction.category.name, + newAmount: afterStats.amount, + newCount: afterStats.count, + oldAmount: beforeStats.amount, + oldCount: beforeStats.count, + type: type, + goals: categoryGoals + } + })); + } + } } }, }); @@ -393,3 +458,60 @@ export const useBudget = ({ userId, date, month }: UseBudgetProps = {}) => { isUpdatingTransaction: updateTransactionMutation.isPending, }; }; + +// 카테고리별 통계 조회 헬퍼 함수 +const getCategoryStats = async (userId: string, categoryName: string, type: "income" | "expense") => { + const startOfMonth = format(new Date(new Date().getFullYear(), new Date().getMonth(), 1), "yyyy-MM-dd"); + const endOfMonth = format(new Date(new Date().getFullYear(), new Date().getMonth() + 1, 0), "yyyy-MM-dd"); + + const tableName = type === "income" ? "incomes" : "expenses"; + + const { data, error } = await supabase + .from(tableName) + .select(` + amount, + category:categories!inner(name) + `) + .eq("user_id", userId) + .eq("category.name", categoryName) + .gte("date", startOfMonth) + .lte("date", endOfMonth); + + if (error) throw error; + + return { + amount: data?.reduce((sum, item) => sum + item.amount, 0) || 0, + count: data?.length || 0 + }; +}; + +// 카테고리별 목표 조회 헬퍼 함수 +const checkCategoryGoals = async ( + userId: string, + categoryName: string, + type: "income" | "expense" +) => { + const { data: categoryData } = await supabase + .from("categories") + .select("id") + .eq("user_id", userId) + .eq("name", categoryName) + .in("type", [`${type}_fixed`, `${type}_variable`]); + + if (!categoryData || categoryData.length === 0) return []; + + const categoryIds = categoryData.map(cat => cat.id); + + const { data, error } = await supabase + .from("goals") + .select(` + *, + category:categories(*) + `) + .eq("user_id", userId) + .in("category_id", categoryIds) + .eq("status", "active"); + + if (error) throw error; + return data || []; +}; \ No newline at end of file diff --git a/src/hooks/budget/useChallenge.ts b/src/hooks/budget/useChallenge.ts index e96372a..ad812ae 100644 --- a/src/hooks/budget/useChallenge.ts +++ b/src/hooks/budget/useChallenge.ts @@ -1,5 +1,4 @@ import { supabase } from "@/lib/supabase"; -import { GoalFormData } from "@/types/goals"; import { useMutation, useQueryClient } from "@tanstack/react-query"; interface CreateChallengeData { @@ -30,10 +29,16 @@ export const useChallenge = () => { } // 목표 타입 결정 - const goalType = - data.categoryType === "income" ? "increase_income" : "reduce_expense"; + const goalType = data.categoryType === "income" ? "increase_income" : "reduce_expense"; - const goalData: GoalFormData & { user_id: string } = { + // 카테고리 ID 조회 + const categoryId = await getCategoryIdByName( + data.category, + data.categoryType, + data.userId + ); + + const goalData = { user_id: data.userId, title: data.title, description: data.description || null, @@ -43,33 +48,30 @@ export const useChallenge = () => { target_count: data.enableCountGoal ? Number(data.targetCount) : null, target_date: data.targetDate, challenge_mode: challengeMode, - category_id: null, // TODO:카테고리별 목표가 필요하다면 추후 구현 + category_id: categoryId, + current_amount: 0, + current_count: 0, + status: "active" as const, + created_from_date: new Date().toISOString().split("T")[0], }; const { data: result, error } = await supabase .from("goals") - .insert([ - { - ...goalData, - current_amount: 0, - current_count: 0, - status: "active", - created_from_date: new Date().toISOString().split("T")[0], - }, - ]) - .select( - ` + .insert([goalData]) + .select(` *, category:categories(*) - `, - ) + `) .single(); - if (error) throw error; + if (error) { + console.error("목표 생성 에러:", error); + throw error; + } + return result; }, onSuccess: () => { - // goals 관련 쿼리들 무효화 queryClient.invalidateQueries({ queryKey: ["goals"] }); }, }); @@ -80,3 +82,38 @@ export const useChallenge = () => { createChallengeError: createChallengeMutation.error, }; }; + +const getCategoryIdByName = async ( + categoryName: string, + categoryType: "income" | "expense", + userId: string +): Promise => { + try { + // 두 타입 모두 한 번에 조회 + const { data: categories, error } = await supabase + .from("categories") + .select("id, type") + .eq("user_id", userId) + .eq("name", categoryName) + .in("type", [`${categoryType}_fixed`, `${categoryType}_variable`]); + + if (error) { + console.error("카테고리 조회 에러:", error); + return null; + } + + if (!categories || categories.length === 0) { + console.warn(`카테고리를 찾을 수 없습니다: ${categoryName} (${categoryType})`); + return null; + } + + const fixedCategory = categories.find(cat => cat.type === `${categoryType}_fixed`); + const variableCategory = categories.find(cat => cat.type === `${categoryType}_variable`); + + return fixedCategory?.id || variableCategory?.id || null; + + } catch (error) { + console.error("카테고리 ID 조회 실패:", error); + return null; + } +}; \ No newline at end of file diff --git a/src/hooks/goals/useGoals.ts b/src/hooks/goals/useGoals.ts new file mode 100644 index 0000000..7f06727 --- /dev/null +++ b/src/hooks/goals/useGoals.ts @@ -0,0 +1,48 @@ +import { supabase } from '@/lib/supabase'; +import { Goal } from '@/types/goals'; +import { useQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; + +interface UseGoalsProps { + userId?: string; +} + +export const useGoals = ({ userId }: UseGoalsProps = {}) => { + const { data: goals = [], isLoading } = useQuery({ + queryKey: ['goals', userId], + queryFn: async (): Promise => { + if (!userId) return []; + + const { data, error } = await supabase + .from('goals') + .select(` + *, + category:categories(*) + `) + .eq('user_id', userId) + .order('created_at', { ascending: false }); + + if (error) throw error; + return data || []; + }, + enabled: !!userId, + }); + + // 목표 상태별 분류 + const { activeGoals, completedGoals } = useMemo(() => { + const active = goals.filter(goal => goal.status === 'active'); + const completed = goals.filter(goal => goal.status === 'completed'); + + return { + activeGoals: active, + completedGoals: completed, + }; + }, [goals]); + + return { + goals, + activeGoals, + completedGoals, + isLoading, + }; +}; \ No newline at end of file diff --git a/src/hooks/goals/useGoalsByBudget.ts b/src/hooks/goals/useGoalsByBudget.ts new file mode 100644 index 0000000..67f8696 --- /dev/null +++ b/src/hooks/goals/useGoalsByBudget.ts @@ -0,0 +1,56 @@ +import { supabase } from '@/lib/supabase'; +import { Goal } from '@/types/goals'; +import { useQuery } from '@tanstack/react-query'; + +interface UseGoalsByBudgetProps { + userId?: string; + categoryName?: string; + type?: "income" | "expense"; +} + +export const useGoalsByBudget = ({ + userId, + categoryName, + type, +}: UseGoalsByBudgetProps = {}) => { + const { data: goals = [], isLoading } = useQuery({ + queryKey: ["goals-by-budget", userId, categoryName, type], + queryFn: async (): Promise => { + if (!userId || !categoryName) return []; + + // 카테고리와 연결된 목표들 조회 + const { data: categoryData } = await supabase + .from("categories") + .select("id") + .eq("user_id", userId) + .eq("name", categoryName) + .in("type", [`${type}_variable`, `${type}_fixed`]); + + if (!categoryData || categoryData.length === 0) return []; + + const categoryIds = categoryData.map(cat => cat.id); + + const { data, error } = await supabase + .from("goals") + .select(` + *, + category:categories(*) + `) + .eq("user_id", userId) + .in("category_id", categoryIds) + .eq("status", "active"); + + if (error) throw error; + return data || []; + }, + enabled: !!userId && !!categoryName && !!type, + }); + + return { + goals, + isLoading, + hasMultipleGoals: goals.length > 1, + hasSingleGoal: goals.length === 1, + hasNoGoals: goals.length === 0, + }; +}; \ No newline at end of file From db84293dbf667fa9a098aedd3bb94e703c80c1dc Mon Sep 17 00:00:00 2001 From: YeongTaek Date: Wed, 17 Sep 2025 12:11:34 +0900 Subject: [PATCH 04/12] =?UTF-8?q?feat:=20=EB=AA=A9=ED=91=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20=EC=82=AD=EC=A0=9C=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/goals/useGoals.ts | 51 +++++++++++++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/src/hooks/goals/useGoals.ts b/src/hooks/goals/useGoals.ts index 7f06727..30112f5 100644 --- a/src/hooks/goals/useGoals.ts +++ b/src/hooks/goals/useGoals.ts @@ -1,6 +1,6 @@ import { supabase } from '@/lib/supabase'; -import { Goal } from '@/types/goals'; -import { useQuery } from '@tanstack/react-query'; +import { Goal, GoalFormData } from '@/types/goals'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMemo } from 'react'; interface UseGoalsProps { @@ -8,6 +8,8 @@ interface UseGoalsProps { } export const useGoals = ({ userId }: UseGoalsProps = {}) => { + const queryClient = useQueryClient(); + const { data: goals = [], isLoading } = useQuery({ queryKey: ['goals', userId], queryFn: async (): Promise => { @@ -28,6 +30,47 @@ export const useGoals = ({ userId }: UseGoalsProps = {}) => { enabled: !!userId, }); + // 목표 업데이트 + const updateGoalMutation = useMutation({ + mutationFn: async ({ id, updates }: { id: string; updates: Partial }) => { + const updateData = { + ...updates, + updated_at: new Date().toISOString(), + }; + + const { data, error } = await supabase + .from('goals') + .update(updateData) + .eq('id', id) + .select(` + *, + category:categories(*) + `) + .single(); + + if (error) throw error; + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['goals'] }); + }, + }); + + // 목표 삭제 + const deleteGoalMutation = useMutation({ + mutationFn: async (goalId: string) => { + const { error } = await supabase + .from('goals') + .delete() + .eq('id', goalId); + + if (error) throw error; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['goals'] }); + }, + }); + // 목표 상태별 분류 const { activeGoals, completedGoals } = useMemo(() => { const active = goals.filter(goal => goal.status === 'active'); @@ -44,5 +87,9 @@ export const useGoals = ({ userId }: UseGoalsProps = {}) => { activeGoals, completedGoals, isLoading, + updateGoal: updateGoalMutation.mutate, + isUpdatingGoal: updateGoalMutation.isPending, + deleteGoal: deleteGoalMutation.mutate, + isDeletingGoal: deleteGoalMutation.isPending, }; }; \ No newline at end of file From 1369b220b0795d247a940afd8f89e896665cecd3 Mon Sep 17 00:00:00 2001 From: YeongTaek Date: Wed, 17 Sep 2025 12:11:59 +0900 Subject: [PATCH 05/12] =?UTF-8?q?refactor:=20=EC=B1=8C=EB=A6=B0=EC=A7=80?= =?UTF-8?q?=20=EC=83=81=ED=83=9C=20=EC=86=8D=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/types/goals/database.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/types/goals/database.ts b/src/types/goals/database.ts index ad0debe..ad3e7d9 100644 --- a/src/types/goals/database.ts +++ b/src/types/goals/database.ts @@ -29,6 +29,7 @@ export interface GoalFormData { target_amount?: number | null; target_count?: number | null; target_date?: string | null; + status: "active" | "completed" | "paused" | "cancelled"; challenge_mode: "amount" | "count" | "both"; category_id?: string | null; } From 4e22b6cc0ecabe3c1036835e31f28db02221e879 Mon Sep 17 00:00:00 2001 From: YeongTaek Date: Wed, 17 Sep 2025 12:12:22 +0900 Subject: [PATCH 06/12] =?UTF-8?q?refactor:=20goals=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=ED=97=AC=ED=8D=BC=ED=95=A8=EC=88=98=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/goals/goalsHelpers.ts | 105 ++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 src/utils/goals/goalsHelpers.ts diff --git a/src/utils/goals/goalsHelpers.ts b/src/utils/goals/goalsHelpers.ts new file mode 100644 index 0000000..681de4c --- /dev/null +++ b/src/utils/goals/goalsHelpers.ts @@ -0,0 +1,105 @@ +export const getStatusText = (status: string): string => { + switch (status) { + case 'active': + return '진행 중'; + case 'completed': + return '완료됨'; + case 'paused': + return '일시정지'; + case 'cancelled': + return '취소됨'; + default: + return status; + } +}; + +export const getTypeText = (type: string): string => { + switch (type) { + case 'save_money': + return '돈 저축'; + case 'reduce_expense': + return '지출 줄이기'; + case 'increase_income': + return '수입 늘리기'; + case 'custom': + return '사용자 정의'; + default: + return type; + } +}; + +export const getChallengeMode = (mode: string): string => { + switch (mode) { + case 'amount': + return '금액'; + case 'count': + return '횟수'; + case 'both': + return '금액 + 횟수'; + default: + return mode; + } +}; + +export const calculateProgress = (current: number, target: number): number => { + if (target === 0) return 0; + return Math.round((current / target) * 100); +}; + +export const getDaysLeft = (targetDate: string | null | undefined): number | null => { + if (!targetDate) return null; + const daysLeft = Math.ceil( + (new Date(targetDate).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24) + ); + return daysLeft; +}; + +export const getGoalProgress = (goal: { + target_amount?: number | null; + current_amount?: number; + target_count?: number | null; + current_count?: number; +}) => { + const amountProgress = goal.target_amount && goal.current_amount !== undefined + ? calculateProgress(goal.current_amount, goal.target_amount) + : 0; + + const countProgress = goal.target_count && goal.current_count !== undefined + ? calculateProgress(goal.current_count, goal.target_count) + : 0; + + // 종합 진행률 (둘 다 있으면 평균, 하나만 있으면 그것을 사용) + let overallProgress = 0; + if (goal.target_amount && goal.target_count) { + overallProgress = (amountProgress + countProgress) / 2; + } else if (goal.target_amount) { + overallProgress = amountProgress; + } else if (goal.target_count) { + overallProgress = countProgress; + } + + return { + amountProgress, + countProgress, + overallProgress: Math.round(overallProgress), + }; +}; + +export const getStatusColorClass = (status: string): string => { + switch (status) { + case 'active': + return 'bg-blue-100 text-blue-800'; + case 'completed': + return 'bg-green-100 text-green-800'; + case 'paused': + return 'bg-yellow-100 text-yellow-800'; + case 'cancelled': + return 'bg-red-100 text-red-800'; + default: + return 'bg-gray-100 text-gray-800'; + } +}; + +export const getProgressBarColor = (type: string): string => { + return type === 'increase_income' ? '#22c55e' : '#ef4444'; +}; \ No newline at end of file From 635ca40830e0aa65a396860785df0f0a532053dc Mon Sep 17 00:00:00 2001 From: YeongTaek Date: Wed, 17 Sep 2025 14:57:12 +0900 Subject: [PATCH 07/12] =?UTF-8?q?feat:=20=EB=AA=A9=ED=91=9C=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/goals/[id]/edit/page.tsx | 688 +++++++++++++++-------- src/app/goals/[id]/page.tsx | 467 ++++++++------- src/app/goals/page.tsx | 297 ++++++---- src/components/budget/ChallengeModal.tsx | 6 +- src/components/goals/ActionButton.tsx | 44 ++ src/components/goals/GoalCard.tsx | 178 ++++++ src/components/goals/InfoSection.tsx | 53 ++ src/components/goals/ProgressBar.tsx | 109 ++++ src/components/goals/StatCard.tsx | 47 ++ src/components/goals/StatusBadge.tsx | 71 +++ src/hooks/goals/useGoalStatistics.ts | 34 ++ 11 files changed, 1402 insertions(+), 592 deletions(-) create mode 100644 src/components/goals/ActionButton.tsx create mode 100644 src/components/goals/GoalCard.tsx create mode 100644 src/components/goals/InfoSection.tsx create mode 100644 src/components/goals/ProgressBar.tsx create mode 100644 src/components/goals/StatCard.tsx create mode 100644 src/components/goals/StatusBadge.tsx create mode 100644 src/hooks/goals/useGoalStatistics.ts diff --git a/src/app/goals/[id]/edit/page.tsx b/src/app/goals/[id]/edit/page.tsx index 0dfbc88..ff0dd29 100644 --- a/src/app/goals/[id]/edit/page.tsx +++ b/src/app/goals/[id]/edit/page.tsx @@ -2,23 +2,183 @@ import { useAuth } from '@/hooks/auth'; import { useGoals } from '@/hooks/goals/useGoals'; +import { GoalFormData } from '@/types/goals'; +import { + getChallengeMode, + getStatusText, + getTypeText, +} from '@/utils/goals/goalsHelpers'; +import { format } from 'date-fns'; +import { + BarChart3, + Calendar, + Edit3, + HandCoins, + Hash, + Save, + Target, + X, +} from 'lucide-react'; import { useParams, useRouter } from 'next/navigation'; +import { FormEvent, useEffect, useState } from 'react'; -const GoalDetailPage = () => { +interface GoalEditFormData { + title: string; + description: string; + reason: string; + target_amount: string; + target_count: string; + target_date: string; + status: 'active' | 'completed' | 'paused' | 'cancelled'; + challenge_mode: 'amount' | 'count' | 'both'; +} + +const GoalEditPage = () => { const { user } = useAuth(); - const { goals, isLoading } = useGoals({ userId: user?.id }); + const { + goals, + isLoading: isGoalsLoading, + updateGoal, + isUpdatingGoal, + } = useGoals({ userId: user?.id }); const params = useParams(); const router = useRouter(); const goalId = params.id as string; const goal = goals.find((g) => g.id === goalId); + const [formData, setFormData] = useState({ + title: '', + description: '', + reason: '', + target_amount: '', + target_count: '', + target_date: '', + status: 'active', + challenge_mode: 'amount', + }); + + const [errors, setErrors] = useState>({}); + + // 목표 데이터로 폼 초기화 + useEffect(() => { + if (goal) { + setFormData({ + title: goal.title || '', + description: goal.description || '', + reason: goal.reason || '', + target_amount: goal.target_amount ? goal.target_amount.toString() : '', + target_count: goal.target_count ? goal.target_count.toString() : '', + target_date: goal.target_date + ? format(new Date(goal.target_date), 'yyyy-MM-dd') + : '', + status: goal.status, + challenge_mode: goal.challenge_mode, + }); + } + }, [goal]); + + const handleInputChange = (field: keyof GoalEditFormData, value: string) => { + setFormData((prev) => ({ ...prev, [field]: value })); + // 에러 클리어 + if (errors[field]) { + setErrors((prev) => ({ ...prev, [field]: undefined })); + } + }; + + const validateForm = (): boolean => { + const newErrors: Partial = {}; + + if (!formData.title.trim()) { + newErrors.title = '목표 제목은 필수 입니다.'; + } + + if ( + formData.challenge_mode === 'amount' || + formData.challenge_mode === 'both' + ) { + if (!formData.target_amount || Number(formData.target_amount) <= 0) { + newErrors.target_amount = '목표 금액을 올바르게 입력해주세요.'; + } + } + + if ( + formData.challenge_mode === 'count' || + formData.challenge_mode === 'both' + ) { + if (!formData.target_count || Number(formData.target_count) <= 0) { + newErrors.target_count = '목표 횟수를 올바르게 입력해주세요.'; + } + } + + if (!formData.target_date) { + newErrors.target_date = '목표 날짜는 필수입니다.'; + } else { + const targetDate = new Date(formData.target_date); + const today = new Date(); + today.setHours(0, 0, 0, 0); + + if (targetDate < today) { + newErrors.target_date = '목표 날짜는 오늘 이후여야 합니다.'; + } + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + + if (!validateForm()) { + return; + } + + const updateData: Partial = { + title: formData.title.trim(), + description: formData.description.trim() || null, + reason: formData.reason.trim() || null, + target_date: formData.target_date || null, + status: formData.status, + challenge_mode: formData.challenge_mode, + }; + + // 챌린지 모드에 따라 목표값 설정 + if (formData.challenge_mode === 'amount') { + updateData.target_amount = Number(formData.target_amount); + updateData.target_count = null; + } else if (formData.challenge_mode === 'count') { + updateData.target_count = Number(formData.target_count); + updateData.target_amount = null; + } else { + updateData.target_amount = Number(formData.target_amount); + updateData.target_count = Number(formData.target_count); + } + + try { + updateGoal({ id: goalId, updates: updateData }); + alert('목표가 성공적으로 수정되었습니다.'); + router.push(`/goals/${goalId}`); + } catch (error) { + console.error('목표 수정 실패:', error); + alert('목표 수정에 실패했습니다.'); + } + }; + // 로딩 상태 - if (isLoading) { + if (isGoalsLoading) { return ( -
-

목표 상세

-

로딩 중...

+
+
+
+
+
+
+

+ 목표를 불러오는 중 +

+

잠시만 기다려주세요...

+
); } @@ -26,9 +186,14 @@ const GoalDetailPage = () => { // 사용자 인증 확인 if (!user?.id) { return ( -
-

목표 상세

-

로그인이 필요합니다.

+
+
+
+ +
+

목표 수정

+

로그인이 필요한 서비스입니다.

+
); } @@ -36,258 +201,295 @@ const GoalDetailPage = () => { // 목표를 찾을 수 없는 경우 if (!goal) { return ( -
-

목표 상세

-

목표를 찾을 수 없습니다.

- +
+
+
+ +
+

+ 목표를 찾을 수 없습니다 +

+

+ 수정하려는 목표가 존재하지 않거나 삭제되었습니다. +

+ +
); } - const handleEdit = () => { - router.push(`/goals/${goalId}/edit`); - }; + return ( +
+
+ {/* 메인 폼 */} +
+ {/* 헤더 섹션 */} +
+
+ +

목표 수정

+
+
+ {goal.category && ( + <> + {goal.category.name} + + + )} + {getTypeText(goal.type)} + + {getStatusText(goal.status)} +
+
- const handleDelete = () => { - if (confirm('정말로 이 목표를 삭제하시겠습니까?')) { - // TODO: 목표 삭제 구현 - console.log('목표 삭제:', goalId); - router.push('/goals'); - } - }; +
+ {/* 기본 정보 섹션 */} +
+

+ + 기본 정보 +

- const getStatusText = (status: string) => { - switch (status) { - case 'active': - return '진행 중'; - case 'completed': - return '완료됨'; - case 'paused': - return '일시정지'; - case 'cancelled': - return '취소됨'; - default: - return status; - } - }; +
+ {/* 목표 제목 */} +
+ + handleInputChange('title', e.target.value)} + className={`w-full px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-500 transition-colors hover:border-accent-300 ${ + errors.title ? 'border-red-500' : 'border-gray-400' + }`} + placeholder="목표 제목을 작성하세요." + /> + {errors.title && ( +

{errors.title}

+ )} +
- const getTypeText = (type: string) => { - switch (type) { - case 'save_money': - return '돈 저축'; - case 'reduce_expense': - return '지출 줄이기'; - case 'increase_income': - return '수입 늘리기'; - case 'custom': - return '사용자 정의'; - default: - return type; - } - }; + {/* 목표 설명 */} +
+ +