Skip to content

Commit 32616ea

Browse files
Merge pull request #47 from DeveloperBlog-Devflow/feature/plan-management-page
feat: 계획 관리 페이지 하위 항목 제거 기능 및 마감일 설정 기능 구현
2 parents b759e96 + 6f448e6 commit 32616ea

7 files changed

Lines changed: 827 additions & 131 deletions

File tree

app/(with-sidebar)/plans/page.tsx

Lines changed: 42 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@
33
import { useState, useEffect } from 'react';
44
import { User, onAuthStateChanged } from 'firebase/auth';
55
import { auth } from '@/lib/firebase';
6-
import { addPlan, deletePlan, fetchPlans, Plan } from '@/lib/planManageService';
6+
import {
7+
addPlan,
8+
deletePlan,
9+
deletePlanItem,
10+
fetchPlans,
11+
Plan,
12+
} from '@/lib/planManageService';
713

814
import PageHeader from '@/components/common/PageHeader';
915
import Card from '@/components/home/Card';
@@ -147,39 +153,42 @@ const Page = () => {
147153
{/* 검색 바 */}
148154
<SearchBar />
149155

150-
{/* 메인 플랜 목록 */}
151-
<section className="space-y-6">
152-
{isLoading ? (
153-
<p>플랜을 불러오는 중...</p>
154-
) : plans.length > 0 && user ? (
155-
plans.map((plan) => (
156-
<PlanSection
157-
key={plan.id}
158-
userId={user.uid}
159-
planId={plan.id}
160-
title={plan.title}
161-
description={plan.description}
162-
onDelete={handleDeletePlan}
163-
/>
164-
))
165-
) : (
166-
<p>아직 생성된 플랜이 없습니다. 첫 플랜을 추가해보세요!</p>
167-
)}
168-
</section>
169-
170-
{/* 하단 추가 버튼 or 인라인 폼 */}
171-
<div className="mt-6">
172-
{isAdding ? (
173-
<InlineAddPlanForm
174-
onSave={handleSavePlan}
175-
onCancel={handleCancelAdd}
176-
/>
177-
) : (
178-
<div onClick={() => setIsAdding(true)}>
179-
<AddPlanButton />
156+
{isLoading ? (
157+
<p>플랜을 불러오는 중...</p>
158+
) : (
159+
<div>
160+
<section className="space-y-6">
161+
{plans.length > 0 && user ? (
162+
plans.map((plan) => (
163+
<PlanSection
164+
key={plan.id}
165+
userId={user.uid}
166+
planId={plan.id}
167+
title={plan.title}
168+
description={plan.description}
169+
onDeletePlan={handleDeletePlan}
170+
/>
171+
))
172+
) : (
173+
<p>아직 생성된 플랜이 없습니다. 첫 플랜을 추가해보세요!</p>
174+
)}
175+
</section>
176+
177+
{/* 하단 추가 버튼 or 인라인 폼 */}
178+
<div className="mt-6">
179+
{isAdding ? (
180+
<InlineAddPlanForm
181+
onSave={handleSavePlan}
182+
onCancel={handleCancelAdd}
183+
/>
184+
) : (
185+
<div onClick={() => setIsAdding(true)}>
186+
<AddPlanButton />
187+
</div>
188+
)}
180189
</div>
181-
)}
182-
</div>
190+
</div>
191+
)}
183192
</div>
184193
);
185194
};

components/plans/DatePicker.tsx

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
'use client';
2+
3+
import * as Popover from '@radix-ui/react-popover';
4+
import { format } from 'date-fns';
5+
import { ko } from 'date-fns/locale';
6+
import { CalendarIcon } from 'lucide-react';
7+
import { useState } from 'react';
8+
import { DayPicker } from 'react-day-picker';
9+
import 'react-day-picker/dist/style.css';
10+
11+
interface DatePickerProps {
12+
date: Date | undefined;
13+
setDate: (date: Date | undefined) => void;
14+
}
15+
16+
const DatePicker = ({ date, setDate }: DatePickerProps) => {
17+
const [isOpen, setIsOpen] = useState(false);
18+
19+
const handleSelectDate = (selectedDate: Date | undefined) => {
20+
setDate(selectedDate);
21+
setIsOpen(false); // 날짜 고르면 팝오버 닫기
22+
};
23+
24+
return (
25+
<Popover.Root open={isOpen} onOpenChange={setIsOpen}>
26+
<Popover.Trigger asChild>
27+
<button
28+
className={`bg-background flex items-center gap-1.5 rounded-md border-2 px-2 py-1 text-xs text-gray-600 transition-colors hover:bg-gray-200 ${
29+
date
30+
? 'bg-background border-[#d5dcfb] text-gray-900 hover:bg-gray-50'
31+
: 'bg-surface border-gray-200 text-gray-400 hover:text-gray-600'
32+
} focus:outline-none`}
33+
>
34+
<CalendarIcon size={16} />
35+
{date ? format(date, 'yyyy-MM-dd') : <span>마감일 선택</span>}
36+
</button>
37+
</Popover.Trigger>
38+
39+
<Popover.Portal>
40+
<Popover.Content
41+
className="animate-in fade-in zoom-in-95 z-50 rounded-xl border border-gray-100 bg-white p-2 shadow-xl duration-200"
42+
align="start" // 버튼의 왼쪽 라인에 맞춰서 열림
43+
sideOffset={5} // 버튼과 5px 간격
44+
>
45+
<DayPicker
46+
mode="single" // 날짜 하나만 선택
47+
selected={date} // 현재 선택된 날짜 표시
48+
onSelect={handleSelectDate} // 선택 핸들러
49+
locale={ko} // 한국어 달력 (월, 요일)
50+
// 커스텀 스타일 (Tailwind 색상 적용)
51+
modifiersClassNames={{
52+
selected:
53+
'bg-purple-600 text-white hover:bg-purple-500 rounded-full', // 선택된 날짜 색상
54+
today: 'text-purple-600 font-bold', // 오늘 날짜 색상
55+
}}
56+
// DayPicker 기본 스타일 오버라이드 (선택사항)
57+
styles={{
58+
head_cell: { width: '40px', color: '#9ca3af' },
59+
cell: { width: '40px' },
60+
day: { margin: 'auto', borderRadius: '50%' },
61+
}}
62+
/>
63+
</Popover.Content>
64+
</Popover.Portal>
65+
</Popover.Root>
66+
);
67+
};
68+
69+
export default DatePicker;

components/plans/InlineAddTaskForm.tsx

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useState } from 'react';
44
import { Calendar, Plus, X } from 'lucide-react';
5+
import DatePicker from './DatePicker';
56

67
interface InlineAddTaskFormProps {
78
onSave: (text: string, date?: Date) => Promise<void>; // 저장 핸들러
@@ -15,8 +16,7 @@ export default function InlineAddTaskForm({
1516
const [text, setText] = useState('');
1617
const [isSubmitting, setIsSubmitting] = useState(false);
1718

18-
// 날짜 라이브러리를 붙일 예정이므로 지금은 임시 상태
19-
const [selectedDate, setSelectedDate] = useState<Date | undefined>(undefined);
19+
const [deadline, setDeadline] = useState<Date | undefined>();
2020

2121
const handleSubmit = async (e?: React.FormEvent) => {
2222
e?.preventDefault();
@@ -25,7 +25,7 @@ export default function InlineAddTaskForm({
2525
setIsSubmitting(true);
2626

2727
try {
28-
await onSave(text, selectedDate);
28+
await onSave(text, deadline);
2929
setText(''); // 저장 후 초기화
3030
onCancel(); // 저장 후 닫기
3131
} catch (error) {
@@ -45,7 +45,7 @@ export default function InlineAddTaskForm({
4545
};
4646

4747
return (
48-
<div className="border-border-focus-blue mb-2 flex items-center gap-3 rounded-xl border-2 bg-white p-2 pl-4 shadow-sm">
48+
<div className="mt-2 mb-2 flex items-center gap-3 rounded-xl border-2 border-[#D5DCFB] bg-white p-2 pl-4 shadow-sm">
4949
{/* 1. 체크박스 자리 (비활성 모양) */}
5050
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md border border-gray-200 bg-gray-50">
5151
<Plus size={14} className="text-gray-400" />
@@ -66,14 +66,7 @@ export default function InlineAddTaskForm({
6666

6767
{/* 3. 마감일 버튼 (요청하신 부분) */}
6868
<div className="flex items-center">
69-
<button
70-
type="button"
71-
className="flex items-center gap-1.5 rounded-md bg-gray-100 px-2 py-1 text-xs text-gray-600 transition-colors hover:bg-gray-200"
72-
onClick={() => alert('나중에 달력 라이브러리 연결될 곳!')}
73-
>
74-
<Calendar size={12} />
75-
{selectedDate ? selectedDate.toLocaleDateString() : '마감일 설정'}
76-
</button>
69+
<DatePicker date={deadline} setDate={setDeadline} />
7770
</div>
7871
</div>
7972

components/plans/PlanSection.tsx

Lines changed: 56 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client';
22

3-
import { useState, useEffect } from 'react';
3+
import { useState, useEffect, useRef } from 'react';
44
import {
55
ChevronDown,
66
ChevronUp,
@@ -17,30 +17,32 @@ import {
1717
toggleItemStatus,
1818
PlanItem,
1919
addPlanItem,
20-
deletePlan,
20+
deletePlanItem,
2121
} from '@/lib/planManageService';
2222

2323
interface PlanSectionProps {
2424
userId: string;
2525
planId: string;
2626
title: string;
2727
description?: string;
28-
onDelete: (planId: string, title: string) => void;
28+
onDeletePlan: (planId: string, title: string) => void;
2929
}
3030

3131
export default function PlanSection({
3232
userId,
3333
planId,
3434
title,
3535
description,
36-
onDelete,
36+
onDeletePlan,
3737
}: PlanSectionProps) {
3838
const [isOpen, setIsOpen] = useState(true);
3939
const [tasks, setTasks] = useState<PlanItem[]>([]);
4040
const [isTasksLoading, setIsTasksLoading] = useState(true);
4141
const [isAddingTask, setIsAddingTask] = useState(false);
4242

43-
const [showMenu, setShowMenu] = useState(false);
43+
const [showPlanMenu, setShowPlanMenu] = useState(false);
44+
45+
const menuRef = useRef<HTMLDivElement>(null);
4446

4547
// 1. 초기 데이터 로드
4648
useEffect(() => {
@@ -59,10 +61,29 @@ export default function PlanSection({
5961
setIsTasksLoading(false);
6062
}
6163
};
62-
6364
loadInitialTasks();
6465
}, [userId, planId]); // userId나 planId가 바뀔 때만 실행됨
6566

67+
// 1-2. 마우스 클릭 감지
68+
useEffect(() => {
69+
const handleClickOutside = (event: MouseEvent) => {
70+
// 메뉴가 열려있고, 클릭된 요소가 menuRef 내부가 아니라면 닫기
71+
if (
72+
showPlanMenu &&
73+
menuRef.current &&
74+
!menuRef.current.contains(event.target as Node)
75+
) {
76+
setShowPlanMenu(false);
77+
}
78+
};
79+
document.addEventListener('mousedown', handleClickOutside);
80+
81+
// 언마운트 시 제거
82+
return () => {
83+
document.removeEventListener('mousedown', handleClickOutside);
84+
};
85+
}, [showPlanMenu]);
86+
6687
// 2. 하위 항목 상태(완료/미완료) 토글 핸들러
6788
const handleToggleTask = async (itemId: string, currentStatus: boolean) => {
6889
try {
@@ -80,7 +101,7 @@ export default function PlanSection({
80101
const updatedTasks = await fetchPlanItems(userId, planId);
81102
setTasks(updatedTasks);
82103
} catch (error) {
83-
console.error('상태 변경 실패:', error);
104+
console.error('상태 변경 실패: ', error);
84105
// 에러 시 원래대로 돌리거나 다시 불러오기
85106
const rolledBackTasks = await fetchPlanItems(userId, planId);
86107
setTasks(rolledBackTasks);
@@ -99,17 +120,32 @@ export default function PlanSection({
99120

100121
setIsAddingTask(false); // 입력 폼 닫기
101122
} catch (error) {
102-
console.error('하위 항목 추가 실패:', error);
123+
console.error('하위 항목 추가 실패: ', error);
103124
}
104125
};
105126

106-
//
107-
const handleDeleteClick = (e: React.MouseEvent) => {
127+
// 4. 플랜 삭제 핸들러
128+
const handleDeletePlan = (e: React.MouseEvent) => {
108129
e.stopPropagation(); // 카드 열림/닫힘 방지
109130

110-
onDelete(planId, title);
131+
onDeletePlan(planId, title);
132+
133+
setShowPlanMenu(false);
134+
};
111135

112-
setShowMenu(false);
136+
// 5. 하위 항목 삭제 핸들러
137+
const handleDeletePlanItem = async (itemId: string, title: string) => {
138+
if (confirm(`'${title}' 하위 항목을 정말 삭제하시겠습니까?`)) {
139+
try {
140+
await deletePlanItem(userId, itemId);
141+
142+
// 목록 새로고침
143+
const fetchedPlanItems = await fetchPlanItems(userId, planId);
144+
setTasks(fetchedPlanItems);
145+
} catch (err) {
146+
console.error(err);
147+
}
148+
}
113149
};
114150

115151
// 완료된 할 일 개수 계산
@@ -119,7 +155,7 @@ export default function PlanSection({
119155
// isOpen 상태 false 시 포커스 초기화
120156
useEffect(() => {
121157
setIsAddingTask(false); //
122-
setShowMenu(false); // 드롭다운 메뉴
158+
setShowPlanMenu(false); // 드롭다운 메뉴
123159
}, [isOpen]);
124160

125161
return (
@@ -141,32 +177,32 @@ export default function PlanSection({
141177
{completedCount}/{totalCount}
142178
</span>
143179
</div>
144-
<div className="relative z-10">
180+
<div className="relative z-10" ref={menuRef}>
145181
<button
146182
onClick={(e) => {
147183
e.stopPropagation(); // 부모 클릭(아코디언 토글) 방지
148-
setShowMenu(!showMenu);
184+
setShowPlanMenu(!showPlanMenu);
149185
}}
150186
className="rounded-full p-1 text-gray-400 transition-colors hover:bg-gray-100"
151187
>
152188
<MoreVertical size={20} />
153189
</button>
154190

155191
{/* 드롭다운 메뉴 (절대 위치) */}
156-
{showMenu && (
192+
{showPlanMenu && (
157193
<div className="absolute top-8 right-0 w-32 overflow-hidden rounded-lg border border-gray-100 bg-white py-1 shadow-lg">
158194
<button
159195
className="flex w-full items-center gap-2 px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
160196
onClick={(e) => {
161197
e.stopPropagation();
162-
alert('수정 기능');
198+
alert('플랜 수정 기능');
163199
}}
164200
>
165201
<Edit2 size={14} /> 수정
166202
</button>
167203
<button
168204
className="flex w-full items-center gap-2 px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50"
169-
onClick={handleDeleteClick}
205+
onClick={handleDeletePlan}
170206
>
171207
<Trash2 size={14} /> 삭제
172208
</button>
@@ -201,6 +237,7 @@ export default function PlanSection({
201237
deadline={task.deadline}
202238
isCompleted={task.isChecked}
203239
onToggle={handleToggleTask}
240+
onDelete={handleDeletePlanItem}
204241
/>
205242
))
206243
)}
@@ -215,7 +252,7 @@ export default function PlanSection({
215252
) : (
216253
<button
217254
onClick={() => setIsAddingTask(true)}
218-
className="mt-2 flex w-full items-center justify-center gap-1 rounded-xl border-2 border-dashed border-[#556BD6]/30 py-3 text-sm font-medium text-[#556BD6] transition-colors hover:bg-[#556BD6]/5"
255+
className="mt-2 flex w-full items-center justify-center gap-2 rounded-xl border-2 border-dashed border-[#556BD6]/30 py-3 text-sm font-medium text-[#556BD6] transition-colors hover:bg-[#556BD6]/5"
219256
>
220257
<Plus size={16} /> 새 하위항목 추가
221258
</button>

0 commit comments

Comments
 (0)