diff --git a/app/(with-sidebar)/plans/page.tsx b/app/(with-sidebar)/plans/page.tsx index 701cce3..c811ec4 100644 --- a/app/(with-sidebar)/plans/page.tsx +++ b/app/(with-sidebar)/plans/page.tsx @@ -7,8 +7,11 @@ import { addPlan, deletePlan, deletePlanItem, + fetchAllPlanItems, fetchPlans, Plan, + PlanItem, + updatePlan, } from '@/services/plans/planManageService.service'; import PageHeader from '@/components/common/PageHeader'; @@ -18,6 +21,7 @@ import PlanSection from '@/components/plans/PlanSection'; import SearchBar from '@/components/plans/SearchBar'; import InlineAddPlanForm from '@/components/plans/InlineAddPlanForm'; import { Target, Calendar, CheckCircle2 } from 'lucide-react'; +import Pagination from '@/components/common/Pagination'; const Page = () => { const [user, setUser] = useState(null); @@ -25,6 +29,61 @@ const Page = () => { const [isAdding, setIsAdding] = useState(false); const [isLoading, setIsLoading] = useState(true); + const [currentPage, setCurrentPage] = useState(1); + const itemsPerPage = 4; + + const [stats, setStats] = useState({ + total: 0, + working: 0, // 진행중 + completed: 0, // 완료됨 + }); + + // 카드 업데이트를 위한 stats 가져오기 메서드 + const fetchAndCalculate = async ( + uid: string, + preload?: { plans: Plan[]; items: PlanItem[] } + ) => { + try { + // 1. 데이터 병렬 로드 + const [fetchedPlans, fetchedItems] = preload + ? [preload.plans, preload.items] + : await Promise.all([fetchPlans(uid), fetchAllPlanItems(uid)]); + + setPlans(fetchedPlans); // 플랜 목록 업데이트 + + // 2. 통계 계산 로직 + let completedCount = 0; + let workingCount = 0; + + fetchedPlans.forEach((plan) => { + const myItems = fetchedItems.filter( + (item: PlanItem) => item.planId === plan.id + ); + // 하위 항목이 하나라도 있는 경우에만 + if (myItems.length > 0) { + const isAllChecked = myItems.every( + (item: PlanItem) => item.isChecked + ); + + if (isAllChecked) { + completedCount++; // 모두 완료됨 + } else { + workingCount++; // 항목은 있는데 아직 다 완료 안 됨 -> 진행중 + } + } + }); + + // 3. 통계 State 업데이트 + setStats({ + total: fetchedPlans.length, + completed: completedCount, + working: workingCount, + }); + } catch (err) { + console.error(err); + } + }; + // 사용자 인증 상태 리스너 및 초기 플랜 목록 로드 useEffect(() => { const unsubscribe = onAuthStateChanged(auth, async (currentUser) => { @@ -32,11 +91,23 @@ const Page = () => { if (currentUser) { try { - const fetchedPlans = await fetchPlans(currentUser.uid); + const [fetchedPlans, fetchedItems] = await Promise.all([ + fetchPlans(currentUser.uid), + fetchAllPlanItems(currentUser.uid), + ]); + setPlans(fetchedPlans); + setCurrentPage(1); + + await fetchAndCalculate(currentUser.uid, { + plans: fetchedPlans, + items: fetchedItems, + }); } catch (err) { console.error('플랜 목록 로딩 실패:', err); setPlans([]); + + setCurrentPage(1); } } else { setPlans([]); @@ -58,6 +129,9 @@ const Page = () => { // 목록 새로고침 const fetchedPlans = await fetchPlans(user.uid); setPlans(fetchedPlans); + setCurrentPage(1); + + await fetchAndCalculate(user.uid); setIsAdding(false); // 폼 닫기 } catch (err) { @@ -88,12 +162,60 @@ const Page = () => { // 목록 새로고침 const fetchedPlans = await fetchPlans(user.uid); setPlans(fetchedPlans); + + await fetchAndCalculate(user.uid); + + // 페이지 조절 + if ( + currentPage > Math.ceil((plans.length - 1) / itemsPerPage) && + Math.ceil((plans.length - 1) / itemsPerPage) > 0 + ) { + setCurrentPage(Math.ceil((plans.length - 1) / itemsPerPage)); + } else if (Math.ceil((plans.length - 1) / itemsPerPage) === 0) { + setCurrentPage(1); + } } catch (err) { console.error(err); } } }; + // 플랜 수정 핸들러 + const handleUpdatePlan = async ( + planId: string, + title: string, + description: string + ) => { + if (!user) { + alert('로그인이 필요합니다.'); + return; + } + + try { + await updatePlan(user.uid, planId, { title, description }); + + // 목록 새로고침 + const fetchedPlans = await fetchPlans(user.uid); + setPlans(fetchedPlans); + } catch (err) { + console.error(err); + } + }; + + // 페이지네이션 파트 + const totalPages = Math.ceil(plans.length / itemsPerPage); + const startIndex = (currentPage - 1) * itemsPerPage; + const endIndex = startIndex + itemsPerPage; + const paginatedPlans = plans.slice(startIndex, endIndex); + + useEffect(() => { + if (currentPage > totalPages && totalPages > 0) { + setCurrentPage(totalPages); + } else if (totalPages === 0 && currentPage !== 1) { + setCurrentPage(1); + } + }, [plans.length, totalPages, currentPage]); + return (
{ 진행중인 플랜 수 - 1 + + {stats.working} +
{/* 오른쪽: 아이콘 영역 */} @@ -140,7 +264,9 @@ const Page = () => { 완료된 플랜 수 - 1 + + {stats.completed} + {/* 오른쪽: 아이콘 영역 */} @@ -153,13 +279,27 @@ const Page = () => { {/* 검색 바 */} - {isLoading ? ( -

플랜을 불러오는 중...

- ) : ( -
-
- {plans.length > 0 && user ? ( - plans.map((plan) => ( + {/* 하단 추가 버튼 or 인라인 폼 */} +
+ {isAdding ? ( + + ) : ( +
setIsAdding(true)}> + +
+ )} +
+ +
+ {isLoading ? ( +

플랜을 불러오는 중...

+ ) : ( +
+ {paginatedPlans.length > 0 && user ? ( + paginatedPlans.map((plan) => ( { title={plan.title} description={plan.description} onDeletePlan={handleDeletePlan} + onUpdatePlan={handleUpdatePlan} + onChangeStats={() => fetchAndCalculate(user!.uid)} /> )) ) : (

아직 생성된 플랜이 없습니다. 첫 플랜을 추가해보세요!

)} -
- - {/* 하단 추가 버튼 or 인라인 폼 */} -
- {isAdding ? ( - - ) : ( -
setIsAdding(true)}> - -
- )}
-
+ )} + + + {/* 페이지네이션 컴포넌트 */} + {totalPages > 1 && ( + )} ); diff --git a/components/plans/PlanSection.tsx b/components/plans/PlanSection.tsx index 924dcbb..9cfa093 100644 --- a/components/plans/PlanSection.tsx +++ b/components/plans/PlanSection.tsx @@ -2,12 +2,14 @@ import { useState, useEffect, useRef } from 'react'; import { + Check, ChevronDown, ChevronUp, Edit2, MoreVertical, Plus, Trash2, + X, } from 'lucide-react'; import Card from '../home/Card'; import TaskItem from './TaskItem'; @@ -26,6 +28,12 @@ interface PlanSectionProps { title: string; description?: string; onDeletePlan: (planId: string, title: string) => void; + onUpdatePlan: ( + planId: string, + newTitle: string, + newDescription: string + ) => void; + onChangeStats: () => void; } export default function PlanSection({ @@ -34,14 +42,19 @@ export default function PlanSection({ title, description, onDeletePlan, + onUpdatePlan, + onChangeStats, }: PlanSectionProps) { - const [isOpen, setIsOpen] = useState(true); + const [isOpen, setIsOpen] = useState(false); const [tasks, setTasks] = useState([]); const [isTasksLoading, setIsTasksLoading] = useState(true); const [isAddingTask, setIsAddingTask] = useState(false); - const [showPlanMenu, setShowPlanMenu] = useState(false); + const [isEditingPlan, setIsEditingPlan] = useState(false); + const [editTitle, setEditTitle] = useState(title); + const [editDesc, setEditDesc] = useState(description || ''); + const [showPlanMenu, setShowPlanMenu] = useState(false); const menuRef = useRef(null); // 1. 초기 데이터 로드 @@ -100,6 +113,8 @@ export default function PlanSection({ // 데이터 최신화: DB 업데이트 후 목록을 다시 불러옵니다. const updatedTasks = await fetchPlanItems(userId, planId); setTasks(updatedTasks); + + onChangeStats(); } catch (error) { console.error('상태 변경 실패: ', error); // 에러 시 원래대로 돌리거나 다시 불러오기 @@ -131,6 +146,8 @@ export default function PlanSection({ onDeletePlan(planId, title); setShowPlanMenu(false); + + onChangeStats(); }; // 5. 하위 항목 삭제 핸들러 @@ -146,6 +163,36 @@ export default function PlanSection({ console.error(err); } } + + onChangeStats(); + }; + + // 6. 플랜 수정 핸들러 + const handleStartEdit = (e: React.MouseEvent) => { + e.stopPropagation(); // 아코디언 토글 방지 + + setEditTitle(title); // props로 받은 최신값으로 초기화 + setEditDesc(description || ''); + + setIsEditingPlan(true); + setShowPlanMenu(false); // 메뉴 닫기 + }; + + // 플랜 수정 핸들러 + const handleSaveEdit = (e: React.MouseEvent) => { + e.stopPropagation(); + + // 부모 컴포넌트에게 변경된 내용 전달 + onUpdatePlan(planId, editTitle, editDesc); + + setIsEditingPlan(false); // 수정 모드 종료 + }; + + // 7. 플랜 수정 취소 핸들러 + const handleCancelEdit = (e: React.MouseEvent) => { + e.stopPropagation(); + + setIsEditingPlan(false); // 수정 모드 종료 }; // 완료된 할 일 개수 계산 @@ -162,15 +209,54 @@ export default function PlanSection({ {/* 헤더 영역 */}
setIsOpen(!isOpen)} + className="flex cursor-pointer items-center justify-between" + onClick={() => !isEditingPlan && setIsOpen(!isOpen)} > -
-

{title}

-

{description}

-
+ {isEditingPlan ? ( +
e.stopPropagation()} + > + {/* 제목 입력창 */} + setEditTitle(e.target.value)} + className="w-full border-b-2 border-[#556BD6] bg-transparent text-xl font-bold text-gray-900 focus:outline-none" + placeholder="플랜 제목" + autoFocus + /> + {/* 설명 입력창 */} + setEditDesc(e.target.value)} + className="w-full border-b border-gray-300 bg-transparent text-sm text-gray-600 focus:border-[#556BD6] focus:outline-none" + placeholder="설명 (선택사항)" + /> +
+ + +
+
+ ) : ( +
+

{title}

+

{description}

+
+ )} -
+
진행률 @@ -193,10 +279,7 @@ export default function PlanSection({
diff --git a/components/plans/TaskItem.tsx b/components/plans/TaskItem.tsx index f8613f2..c4a2f23 100644 --- a/components/plans/TaskItem.tsx +++ b/components/plans/TaskItem.tsx @@ -28,7 +28,13 @@ export default function TaskItem({ const formatDate = (d: string | Date | undefined) => { if (!d) return ''; if (typeof d === 'string') return d; - return d.toISOString().split('T')[0]; // YYYY-MM-DD 형식 + + // 로컬 시간 메서드 사용 (시차 방지) + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + + return `${year}-${month}-${day}`; }; // 1-2. 마우스 클릭 감지 diff --git a/services/plans/planManageService.service.ts b/services/plans/planManageService.service.ts index 987508b..c374414 100644 --- a/services/plans/planManageService.service.ts +++ b/services/plans/planManageService.service.ts @@ -28,7 +28,7 @@ export interface PlanItem { planId: string; text: string; isChecked: boolean; - deadline: Date; + deadline?: Date; createdAt: Date; } @@ -145,7 +145,32 @@ export const deletePlanItem = async (uid: string, itemId: string) => { await deleteDoc(itemRef); }; -// 8. 하위 항목 수정 (제목, 설명, 마감일) +// 8. 플랜 수정 (제목, 설명) +export const updatePlan = async ( + uid: string, + planId: string, + updates: { title?: string; description?: string } +) => { + const planRef = doc(db, 'users', uid, 'plans', planId); + + const updatePayload: { + title?: string; + description?: string; + } = {}; + + if (updates.title !== undefined) { + updatePayload.title = updates.title; + } + if (updates.description !== undefined) { + updatePayload.description = updates.description; + } + + if (Object.keys(updatePayload).length > 0) { + await updateDoc(planRef, updatePayload); + } +}; + +// 9. 하위 항목 수정 (제목, 설명, 마감일) export const updatePlanItem = async ( uid: string, itemId: string, @@ -187,6 +212,22 @@ export const updatePlanItem = async ( } }; +// 10. 하위 항목 모두 가져오기 +export const fetchAllPlanItems = async (uid: string): Promise => { + const allPlanItemsRef = collection(db, 'users', uid, 'planItems'); + const q = query(allPlanItemsRef); + const snapshot = await getDocs(q); + + return snapshot.docs.map((doc) => { + const data = doc.data(); + return { + id: doc.id, + ...data, + createdAt: data.createdAt?.toDate(), + deadline: data.deadline?.toDate() ?? null, + }; + }) as PlanItem[]; +}; // 오늘의 할일 가져오기 export const fetchTodayPlanItems = async (uid: string): Promise => { const itemsRef = collection(db, 'users', uid, 'planItems');