diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx
index c5dc28c..f372ef7 100644
--- a/src/app/dashboard/page.tsx
+++ b/src/app/dashboard/page.tsx
@@ -9,7 +9,6 @@ import { format } from "date-fns";
import { ko } from "date-fns/locale";
import {
ArrowRight,
- BarChart3,
Calendar,
Plus,
Target,
@@ -27,6 +26,7 @@ import DashboardReflectionWidget from "@/components/reflections/DashboardReflect
import DashboardBudgetWidget from "@/components/budget/DashboardBudgetWidget";
import Modal from "@/components/common/Modal";
import QuestionForm from "@/components/questions/QuestionForm";
+import { getDaysLeft, getGoalProgress } from "@/utils/goals/goalsHelpers";
const DashboardPage = () => {
const { user, profile, isLoading: isAuthLoading } = useAuth();
@@ -76,14 +76,15 @@ const DashboardPage = () => {
// 곧 마감되는 목표들 (7일 이내)
const soonDueGoals = activeGoals.filter((goal) => {
if (!goal.target_date) return false;
- const daysLeft = goal.progress.daysLeft;
- return daysLeft <= 7 && daysLeft > 0;
+ const daysLeft = getDaysLeft(goal.target_date);
+ return daysLeft !== null && daysLeft <= 7 && daysLeft > 0;
});
// 달성률이 80% 이상인 목표들
- const nearCompletionGoals = activeGoals.filter(
- (goal) => goal.progress.overallProgress >= 80,
- );
+ const nearCompletionGoals = activeGoals.filter((goal) => {
+ const progress = getGoalProgress(goal);
+ return progress.overallProgress >= 80;
+ });
return {
total: totalGoals,
@@ -156,6 +157,17 @@ const DashboardPage = () => {
// 상위 목표들 (진행률 기준)
const topProgressGoals = useMemo(() => {
return activeGoals
+ .map((goal) => ({
+ ...goal,
+ progress: {
+ ...getGoalProgress(goal),
+ daysLeft: getDaysLeft(goal.target_date),
+ progressText: `진행률 ${getGoalProgress(goal).overallProgress}%`,
+ isComplete:
+ goal.status === "completed" ||
+ getGoalProgress(goal).overallProgress >= 100,
+ },
+ }))
.sort((a, b) => b.progress.overallProgress - a.progress.overallProgress)
.slice(0, 3);
}, [activeGoals]);
@@ -302,7 +314,7 @@ const DashboardPage = () => {
{/* 이번 주 활동 요약 */}
-
+ {/*
@@ -327,7 +339,7 @@ const DashboardPage = () => {
-
+
*/}
{/* 위젯 그리드 */}
diff --git a/src/app/goals/[id]/edit/page.tsx b/src/app/goals/[id]/edit/page.tsx
new file mode 100644
index 0000000..0670b1a
--- /dev/null
+++ b/src/app/goals/[id]/edit/page.tsx
@@ -0,0 +1,495 @@
+"use client";
+
+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";
+
+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: 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 (isGoalsLoading) {
+ return (
+
+
+
+
+ 목표를 불러오는 중
+
+
잠시만 기다려주세요...
+
+
+ );
+ }
+
+ // 사용자 인증 확인
+ if (!user?.id) {
+ return (
+
+
+
+
+
+
목표 수정
+
로그인이 필요한 서비스입니다.
+
+
+ );
+ }
+
+ // 목표를 찾을 수 없는 경우
+ if (!goal) {
+ return (
+
+
+
+
+
+
+ 목표를 찾을 수 없습니다
+
+
+ 수정하려는 목표가 존재하지 않거나 삭제되었습니다.
+
+
+
+
+ );
+ }
+
+ return (
+
+
+ {/* 메인 폼 */}
+
+ {/* 헤더 섹션 */}
+
+
+
+
목표 수정
+
+
+ {goal.category && (
+ <>
+ {goal.category.name}
+ •
+ >
+ )}
+ {getTypeText(goal.type)}
+ •
+ {getStatusText(goal.status)}
+
+
+
+
+
+
+
+ );
+};
+
+export default GoalEditPage;
diff --git a/src/app/goals/[id]/page.tsx b/src/app/goals/[id]/page.tsx
new file mode 100644
index 0000000..d625088
--- /dev/null
+++ b/src/app/goals/[id]/page.tsx
@@ -0,0 +1,276 @@
+"use client";
+
+import ActionButtons from "@/components/goals/ActionButton";
+import InfoSection, { InfoItem } from "@/components/goals/InfoSection";
+import ProgressBar from "@/components/goals/ProgressBar";
+import StatusBadge from "@/components/goals/StatusBadge";
+import { useAuth } from "@/hooks/auth";
+import { useGoals } from "@/hooks/goals/useGoals";
+import { supabase } from "@/lib/supabase";
+import {
+ getChallengeMode,
+ getGoalProgress,
+ getTypeText,
+} from "@/utils/goals/goalsHelpers";
+import { useQueryClient } from "@tanstack/react-query";
+import { format } from "date-fns";
+import { ko } from "date-fns/locale";
+import { ArrowLeft, Calendar, Info, Target } from "lucide-react";
+import { useParams, useRouter } from "next/navigation";
+import { useState } from "react";
+
+const GoalDetailPage = () => {
+ const { user } = useAuth();
+ const { goals } = useGoals({ userId: user?.id });
+ const params = useParams();
+ const router = useRouter();
+ const queryClient = useQueryClient();
+ const [isDeleting, setIsDeleting] = useState(false);
+
+ const goalId = params.id as string;
+ const goal = goals.find((g) => g.id === goalId);
+
+ const handleEdit = () => {
+ router.push(`/goals/${goalId}/edit`);
+ };
+
+ const handleDelete = async () => {
+ if (!confirm("정말로 이 목표를 삭제하시겠습니까?")) {
+ return;
+ }
+
+ setIsDeleting(true);
+
+ try {
+ const { error } = await supabase.from("goals").delete().eq("id", goalId);
+
+ if (error) {
+ console.error("목표 삭제 실패:", error);
+ alert("목표 삭제에 실패했습니다.");
+ return;
+ }
+
+ queryClient.invalidateQueries({ queryKey: ["goals"] });
+ alert("목표가 성공적으로 삭제되었습니다.");
+ router.push("/goals");
+ } catch (error) {
+ console.error("목표 삭제 오류:", error);
+ alert("목표 삭제 중 오류가 발생했습니다.");
+ } finally {
+ setIsDeleting(false);
+ }
+ };
+
+ // 사용자 인증 확인
+ if (!user?.id) {
+ return (
+
+
+
+
+
+
목표 상세
+
로그인이 필요한 서비스입니다.
+
+
+
+ );
+ }
+
+ // 목표를 찾을 수 없는 경우
+ if (!goal) {
+ return (
+
+
+
+
+
+
+ 목표를 찾을 수 없습니다.
+
+
+ 요청한 목표가 존재하지 않거나 삭제되었습니다.
+
+
+
+
+ );
+ }
+
+ // 진행률 계산
+ const progress = getGoalProgress(goal);
+
+ return (
+
+
+ {/* 헤더 */}
+
+
+
+
+ {/* 메인 컨텐츠 */}
+
+ {/* 헤더 섹션 */}
+
+
+
+
+
+
{goal.title}
+ {goal.description && (
+
+ {goal.description}
+
+ )}
+
+
+
+
+ {/* 기본 정보 */}
+
}>
+
+ {goal.category && (
+
+ )}
+ ({" "}
+
+ )
+
+
+ {goal.category && (
+
+ )}
+ {goal.reason &&
}
+
+
+ {/* 목표 정보 */}
+
}
+ >
+
+ {goal.created_from_date && (
+
+ )}{" "}
+ ~
+ {goal.target_date && (
+
+ )}
+
+ {goal.target_amount && goal.current_amount !== undefined && (
+
+ )}
+
+ {goal.target_count && goal.current_count !== undefined && (
+
+ )}
+
+ {/* 종합 진행률 (둘 다 있을 때만) */}
+ {goal.target_amount && goal.target_count && (
+
+
+
+ 종합 달성률
+
+
+ {progress.overallProgress}%
+
+
+
+ )}
+
+
+ {/* 날짜 정보 */}
+
}
+ >
+
+
+
+
+ {/* 액션 버튼 */}
+
+
+
+
+
+ );
+};
+
+export default GoalDetailPage;
diff --git a/src/app/goals/page.tsx b/src/app/goals/page.tsx
index b4b32bf..4a4f87f 100644
--- a/src/app/goals/page.tsx
+++ b/src/app/goals/page.tsx
@@ -1,327 +1,237 @@
"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,
+ Clock,
TrendingUp,
+ BarChart3,
+ Plus,
+ Zap,
Trophy,
+ ArrowRight,
+ Star,
} from "lucide-react";
import Link from "next/link";
-import { useMemo, useState } from "react";
+import GoalCard from "@/components/goals/GoalCard";
+import StatCard from "@/components/goals/StatCard";
+import useGoalStatistics from "@/hooks/goals/useGoalStatistics";
const GoalsPage = () => {
- const { user } = useAuth();
- const { goals, activeGoals, completedGoals, isLoading } = useGoals({
+ const { user, isLoading: isAuthLoading } = useAuth();
+
+ const {
+ activeGoals,
+ completedGoals,
+ goals,
+ isLoading: isGoalsLoading,
+ } = 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);
- }
- };
+ const statistics = useGoalStatistics(goals);
- if (!user?.id) {
+ // 로딩 상태
+ if (isAuthLoading || isGoalsLoading) {
return (
-
-
-
-
- 로그인이 필요합니다
-
-
- 목표를 관리하려면 먼저 로그인해주세요.
-
+
+
+
+
+ 목표를 불러오는 중
+
+
잠시만 기다려주세요...
-
+
);
}
- if (isLoading) {
+ // 사용자 인증 확인
+ if (!user?.id) {
return (
-
-
-
-
-
- {[...Array(6)].map((_, i) => (
-
- ))}
-
+
+
+
+
+
목표 관리
+
+ 나만의 목표를 설정하고 달성 과정을 추적해보세요
+
+
+ 시작하기
+
+
-
+
);
}
return (
-
-
+
+
{/* 헤더 */}
-
-
-
내 목표
-
- 진행 중인 챌린지와 달성한 목표를 확인하세요.
-
-
-
-
-
+
+
+
+ 목표를 설정하고 꾸준히 달성해보세요 ✨
+
- {/* 통계 카드 */}
-
-
-
-
-
-
-
-
전체
-
- {statistics.total}
-
+ {/* 목표가 없을 때 */}
+ {statistics.total === 0 && (
+
+
+
+
+
+ 첫 번째 목표를 만들어보세요!
+
+
+ 예산 페이지에서 챌린지를 생성하여
+ 목표 달성 여정을 시작하세요.
+
+
+
+ 목표 만들기
+
+
+ )}
-
-
-
-
-
-
-
진행 중
-
- {statistics.active}
-
-
+ {/* 목표가 있을 때 */}
+ {statistics.total > 0 && (
+ <>
+ {/* 통계 카드들 */}
+
+ }
+ title="전체 목표"
+ value={`${statistics.total}개`}
+ color="accent"
+ />
+ }
+ title="진행 중"
+ value={`${statistics.active}개`}
+ color="purple"
+ />
+ }
+ title="완료 목표"
+ value={`${statistics.completed}개`}
+ color="green"
+ />
+ }
+ title="곧 마감"
+ value={`${statistics.soonDue}개`}
+ color={statistics.soonDue > 0 ? "yellow" : "accent"}
+ />
-
-
-
-
-
+ {/* 완료율 표시 */}
+
+
+
+ 전체 달성률
+
+
+
+
+ {statistics.completionRate}%
+
+
+
+
+
+
-
-
완료
-
- {statistics.completed}
-
+
+
+ {statistics.completionRate >= 80 && (
+
+ )}
-
-
-
-
-
+ {/* 진행 중인 목표들 */}
+ {activeGoals.length > 0 && (
+
+
+
+
+
+
+
+ 진행 중인 목표
+
+
+ {activeGoals.length}개의 목표가 진행 중입니다.
+
+
+
+
+ {activeGoals.map((goal) => (
+
+ ))}
+
-
-
달성률
-
- {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 && (
-
-
-
-
- 가계부 챌린지
-
+ {/* 완료된 목표들 */}
+ {completedGoals.length > 0 && (
+
+
+
+
+
+
+
+ 완료 목표
+
+
+ {completedGoals.length}개의 목표를 달성했습니다.
+
+
+
+
+ {completedGoals.map((goal) => (
+
+ ))}
+
)}
-
+ >
)}
-
+
);
};
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 1dafc92..84600c6 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -2,7 +2,6 @@ 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"] });
export const metadata = {
@@ -25,13 +24,11 @@ export default function RootLayout({
-
-
-
- {children}
- {/* // TODO: Footer 추가 */}
-
-
+
+
+ {children}
+ {/* // TODO: Footer 추가 */}
+