diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33677c4..855c64e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,7 +66,6 @@ jobs: NEXT_PUBLIC_SUPABASE_URL: "https://dummy.supabase.co" NEXT_PUBLIC_SUPABASE_ANON_KEY: "dummy-key-for-build-test" - # 9. 성공 시 체크마크 출력 - name: All checks passed - run: echo "모든 코드 품질 검사를 통과했습니다!" \ No newline at end of file + run: echo "모든 코드 품질 검사를 통과했습니다!" diff --git a/.github/workflows/devlop.yml b/.github/workflows/devlop.yml deleted file mode 100644 index 6d37717..0000000 --- a/.github/workflows/devlop.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: Develop - Integration Test - -on: - push: - branches: - - develop - pull_request: - branches: - - develop - -# 동시 실행 제한 -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -env: - # Develop 환경 변수 (GitHub Secrets 사용) - NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} - NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} - -jobs: - integration-test: - name: Integration Test - runs-on: ubuntu-latest - - strategy: - matrix: - node-version: [20.x] - - steps: - # 1. 코드 체크아웃 - - name: Checkout repository - uses: actions/checkout@v4 - - # 2. pnpm 설정 - - name: Setup pnpm - uses: pnpm/action-setup@v4 - - # 3. Node.js 설정 - - name: Setup Node.js ${{ matrix.node-version}} - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - cache: 'pnpm' - - # 4. 의존성 설치 - - name: Install dependencies - run: pnpm install --frozen-lockfile - - # 5. 코드 품질 검사 - - name: Code formatting check - run: pnpm run format:check - - - name: Lint check - run: pnpm run lint - - - name: Type check - run: pnpm run type-check - - # 6. 실제 환경변수로 빌드 테스트 - - name: Build for develop - run: pnpm run build - - # # 7. Jest 테스트 (나중에 활성화) - # - name: Run Tests - # run: pnpm run test - - # 8. E2E 테스트 (Playwright - 나중에 활성화) - # - name: E2E Tests - # run: pnpm exec playwright test - - # 9. 빌드 결과물 업로드 (디버깅용) - - name: Upload build artifacts - uses: actions/upload-artifact@v4 - if: failure() - with: - name: build-output - path: .next/ - retention-days: 1 - - # 10. 성공 알림 - - name: Integration test passed - run: echo "Develop 브랜치 통합 테스트 완료!" diff --git a/.github/workflows/production.yml b/.github/workflows/production.yml index e98084b..07ac25a 100644 --- a/.github/workflows/production.yml +++ b/.github/workflows/production.yml @@ -106,10 +106,9 @@ jobs: vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }} # TODO: 나중에 배포 후 입력 vercel-args: "--prod" - # 배포 성공 알림 (선택사항) - name: Deployment success notification run: | echo "프로덕션 배포 완료!" echo "배포 시간: ${date}" - echo "커밋: ${{ github.sha}}" \ No newline at end of file + echo "커밋: ${{ github.sha}}" diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index 28bff16..8e5c9d4 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -1,16 +1,142 @@ -import LoginPageContent from "@/components/auth/LoginPageContent"; -import { Suspense } from "react"; +"use client"; + +import AuthGuard from "@/components/auth/AuthGuard"; +import { useAuth } from "@/hooks/auth"; +import { LoginFormValues, loginSchema } from "@/lib/validations/auth"; +import { zodResolver } from "@hookform/resolvers/zod"; +import Link from "next/link"; +import { useForm } from "react-hook-form"; + +const LoginPage = () => { + const { login, isLoggingIn, loginError } = useAuth(); + + const { + register, + handleSubmit, + formState: { errors, isValid }, + } = useForm({ + resolver: zodResolver(loginSchema), + mode: "onChange", // 실시간 검증 + }); + + const onSubmit = async (data: LoginFormValues) => { + try { + await login(data); + } catch (error) { + // 에러는 useAuth에서 처리됨 + console.error("로그인 실패:", error); + } + }; -export default function LoginPage() { return ( - -
+ +
+
+
+ + DaylyLog + +

로그인

+

+ 계정에 로그인하여 DaylyLog를 시작하세요. +

+
+ +
+
+
+ + + {errors.email && ( +

+ {errors.email.message} +

+ )} +
+ +
+ + + {errors.password && ( +

+ {errors.password.message} +

+ )} +
+
+ + {/* 전역 에러 표시 */} + {loginError && ( +

+ {loginError.message || + "로그인에 실패했습니다. 다시 시도해주세요."} +

+ )} + +
+ +
+ +
+ + 계정이 없으신가요? 회원가입 + + + 메인 화면으로 돌아가기 + +
+
- } - > - - +
+
); -} +}; + +export default LoginPage; diff --git a/src/app/auth/signup/page.tsx b/src/app/auth/signup/page.tsx index b8c4a4a..c3f48f9 100644 --- a/src/app/auth/signup/page.tsx +++ b/src/app/auth/signup/page.tsx @@ -316,21 +316,19 @@ export default function SignupPage() { -
+
- 계정이 없으신가요? 회원가입 + 회원이신가요? 로그인 + + + 메인 화면으로 돌아가기 -
- - 메인 화면으로 돌아가기 - -
diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 49217e6..f372ef7 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -4,12 +4,11 @@ import { useState, useMemo } from "react"; import { useAuth } from "@/hooks/auth"; import { useGoals } from "@/hooks/goals/useGoals"; import { useQuestions } from "@/hooks/questions/useQuestions"; -import { QuestionKeyword } from "@/types/questions"; +import { QuestionFormData, QuestionKeyword } from "@/types/questions"; import { format } from "date-fns"; import { ko } from "date-fns/locale"; import { ArrowRight, - BarChart3, Calendar, Plus, Target, @@ -19,15 +18,21 @@ import { MessageSquare, Hash, Clock, + X, } from "lucide-react"; import Link from "next/link"; import AuthGuard from "@/components/auth/AuthGuard"; import DashboardReflectionWidget from "@/components/reflections/DashboardReflectionWidget"; 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(); + const { createQuestion, isCreatingQuestion } = useQuestions(); const [currentDate] = useState(new Date()); + const [showCreateModal, setShowCreateModal] = useState(false); // 사용자 ID가 있을 때만 데이터 쿼리 활성화 const shouldFetchData = !isAuthLoading && !!user?.id; @@ -52,6 +57,16 @@ const DashboardPage = () => { filters: {}, // 전체 기간 }); + const handleSubmitQuestion = (formData: QuestionFormData) => { + if (!user?.id) return; + + createQuestion({ + ...formData, + user_id: user.id, + }); + setShowCreateModal(false); + }; + // 목표 통계 const goalStats = useMemo(() => { const totalGoals = goals.length; @@ -61,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, @@ -141,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]); @@ -189,7 +216,7 @@ const DashboardPage = () => { - 가계부 내역 + 가계부 작성 @@ -201,21 +228,21 @@ const DashboardPage = () => { - 목표 설정 + 목표 작성 - setShowCreateModal(true)} + className="flex flex-col items-center p-4 bg-blue-100 rounded-lg hover:bg-blue-200 transition-colors group hover:cursor-pointer" >
- 성찰 질문 + 질문 작성 - + { - 일상 회고 + 회고 작성 @@ -287,7 +314,7 @@ const DashboardPage = () => { {/* 이번 주 활동 요약 */} -
+ {/*

@@ -312,7 +339,7 @@ const DashboardPage = () => {

-
+
*/} {/* 위젯 그리드 */} @@ -595,6 +622,29 @@ const DashboardPage = () => { + + {/* 질문 생성 모달 */} + setShowCreateModal(false)}> + +
+ +

질문 작성

+
+ setShowCreateModal(false)} + /> +
+
+ + + setShowCreateModal(false)} + isLoading={isCreatingQuestion} + /> + +
); }; 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)} +
+
+ +
+ {/* 기본 정보 섹션 */} +
+

+ + 기본 정보 +

+ +
+ {/* 목표 제목 */} +
+ + 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}

+ )} +
+ + {/* 목표 설명 */} +
+ +