Skip to content

Commit 703f5f3

Browse files
committed
feat(planner) : 플래너 계획 수정하고 목록 불러오기 구현
1 parent 0bfed83 commit 703f5f3

21 files changed

Lines changed: 662 additions & 83 deletions
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"use client";
2+
3+
import { useEffect } from "react";
4+
import { Button } from "@/components/ui/button";
5+
import { useRouter } from "next/navigation";
6+
import { LockIcon, ArrowLeftIcon, HomeIcon, AlertCircle } from "lucide-react";
7+
8+
export default function Error({ error }: { error: Error & { digest?: string } }) {
9+
const router = useRouter();
10+
11+
useEffect(() => {
12+
console.error("Error:", error);
13+
}, [error]);
14+
15+
// 에러 메시지 추출
16+
const errorMessage = error.message || "오류가 발생했습니다.";
17+
const isAccessDenied = errorMessage.includes("접근할 권한이 없습니다");
18+
19+
return (
20+
<div className="from-background via-background to-point-main/5 flex min-h-screen items-center justify-center bg-linear-to-br px-4 py-12">
21+
<div className="w-full max-w-lg">
22+
<div className="relative z-10">
23+
{/* 아이콘 */}
24+
<div className="mb-8 flex justify-center">
25+
<div className="relative">
26+
<div className="bg-point-main/15 relative rounded-full p-6">
27+
<LockIcon className="text-point-main h-10 w-10" strokeWidth={1.5} />
28+
</div>
29+
</div>
30+
</div>
31+
32+
{/* 제목 */}
33+
<h1 className="text-foreground mb-4 text-center text-3xl font-bold">
34+
{isAccessDenied ? "접근할 수 없어요!" : "오류가 발생했어요!"}
35+
</h1>
36+
37+
{/* ✅ 에러 메시지 표시 */}
38+
<div className="mb-8 text-center text-base">
39+
<p className="text-text-sub mb-2 leading-relaxed">{errorMessage}</p>
40+
{isAccessDenied && (
41+
<p className="text-text-sub/70">
42+
플래너 소유자에게 접근 권한을 부여받으면 사용할 수 있어요.
43+
</p>
44+
)}
45+
</div>
46+
47+
{/* 안내 박스 */}
48+
<div className="bg-point-main/5 border-point-main/30 mb-8 flex gap-3 rounded-lg border p-4">
49+
<AlertCircle className="text-point-main mt-1 size-4 shrink-0" />
50+
<div className="text-text-sub text-left text-sm">
51+
<p className="text-foreground mb-1 text-base font-medium">안내</p>
52+
<p>
53+
{isAccessDenied
54+
? "소유자가 공유 설정을 통해 접근 권한을 부여할 수 있습니다."
55+
: "페이지를 다시 로드하거나 지원팀에 문의해주세요."}
56+
</p>
57+
</div>
58+
</div>
59+
60+
{/* 버튼 */}
61+
<div className="mb-8 flex flex-col gap-3">
62+
<Button
63+
onClick={() => router.back()}
64+
variant="outline"
65+
size="lg"
66+
className="border-border hover:border-point-main/50 w-full"
67+
>
68+
<ArrowLeftIcon className="h-4 w-4" />
69+
뒤로 가기
70+
</Button>
71+
<Button
72+
onClick={() => router.push("/")}
73+
size="lg"
74+
className="bg-point-main hover:bg-point-main/90 w-full"
75+
>
76+
<HomeIcon className="h-4 w-4" />
77+
홈으로 돌아가기
78+
</Button>
79+
</div>
80+
81+
{/* 지원 */}
82+
<div className="border-border/50 border-t pt-6">
83+
<p className="text-text-sub/70 mb-4 text-center text-sm">문제가 계속되나요?</p>
84+
<Button
85+
variant="ghost"
86+
className="text-point-main hover:text-point-main hover:bg-point-main/5 w-full"
87+
asChild
88+
>
89+
<a href="mailto:support@naeconcertbutakhae.shop">고객 지원팀에 문의하기</a>
90+
</Button>
91+
</div>
92+
93+
{/* 에러 ID (개발 모드) */}
94+
{process.env.NODE_ENV === "development" && error.digest && (
95+
<div className="border-border/30 mt-8 border-t pt-6 text-center">
96+
<p className="text-text-sub/50 font-mono text-xs">Error ID: {error.digest}</p>
97+
</div>
98+
)}
99+
</div>
100+
</div>
101+
</div>
102+
);
103+
}

src/app/(protect)/planner/[id]/page.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
import PlannerTopActions from "@/components/planner/PlannerTopActions";
2-
import PlannerTopHeader from "@/components/planner/PlannerTopHeader";
1+
import PlannerTopActions from "@/components/planner/top/PlannerTopActions";
2+
import PlannerTopHeader from "@/components/planner/top/PlannerTopHeader";
33
import PlannerTimelineSection from "@/components/planner/PlannerTimelineSection";
44

5-
export default function Page() {
5+
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
6+
const { id } = await params;
7+
68
return (
79
<>
8-
<PlannerTopHeader />
10+
<PlannerTopHeader planId={id} />
911
<PlannerTopActions />
1012
<PlannerTimelineSection />
1113
</>
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import PlannerTopHeaderSkeleton from "@/components/loading/planner/PlannerTopHeaderSkeleton";
2+
import PlannerTopActionsSkeleton from "@/components/loading/planner/PlannerTopActionsSkeleton";
3+
import PlannerTimelineSectionSkeleton from "@/components/loading/planner/PlannerTimelineSectionSkeleton";
4+
import PlannerCreate from "@/components/planner/PlannerCreate";
5+
6+
export default function Page() {
7+
return (
8+
<>
9+
<PlannerTopHeaderSkeleton />
10+
<PlannerTopActionsSkeleton />
11+
<PlannerTimelineSectionSkeleton />
12+
<PlannerCreate />
13+
</>
14+
);
15+
}

src/app/(protect)/planner/page.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
import PlannerTopHeaderSkeleton from "@/components/loading/planner/PlannerTopHeaderSkeleton";
22
import PlannerTopActionsSkeleton from "@/components/loading/planner/PlannerTopActionsSkeleton";
33
import PlannerTimelineSectionSkeleton from "@/components/loading/planner/PlannerTimelineSectionSkeleton";
4-
import PlannerCreate from "@/components/planner/PlannerCreate";
4+
import { getPlanList } from "@/lib/api/planner/planner.server";
5+
import PlannerLists from "@/components/planner/PlannerLists";
6+
7+
export default async function Page() {
8+
const planLists = await getPlanList();
59

6-
export default function Page() {
710
return (
811
<>
912
<PlannerTopHeaderSkeleton />
1013
<PlannerTopActionsSkeleton />
1114
<PlannerTimelineSectionSkeleton />
12-
<PlannerCreate />
15+
<PlannerLists planLists={planLists} />
1316
</>
1417
);
1518
}

src/components/common/BackPageButton.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
"use client";
22

33
import { useRouter } from "next/navigation";
4-
import { Button } from "../ui/button";
54
import { ButtonProps } from "@/types/planner";
65

76
export default function BackPageButton({ children, ...rest }: ButtonProps) {
@@ -13,8 +12,8 @@ export default function BackPageButton({ children, ...rest }: ButtonProps) {
1312
};
1413

1514
return (
16-
<Button onClick={handlePageBack} {...rest}>
15+
<button onClick={handlePageBack} {...rest}>
1716
{children}
18-
</Button>
17+
</button>
1918
);
2019
}

src/components/concert/detail/ConcertDatePicker.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export function ConcertDatePicker({
2626

2727
const handleSelect = (selected: Date | undefined) => {
2828
if (selected) {
29-
// 선택된 날짜를 자정으로 정규화
29+
// 선택된 날짜를 그대로 사용 (브라우저에서 자정으로 설정됨)
3030
const normalizedDate = new Date(
3131
selected.getFullYear(),
3232
selected.getMonth(),

src/components/concert/detail/ConcertHeaderBtn.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,12 @@ import { ConcertDetail, TicketOffice } from "@/types/concerts";
1919
import { Popover } from "@radix-ui/react-popover";
2020
import { PopoverContent, PopoverTrigger } from "@/components/ui/popover";
2121
import { Calendar } from "@/components/ui/calendar";
22-
import { createPlanner } from "@/lib/api/planner/planner.client";
22+
import { createNewPlan } from "@/lib/api/planner/planner.client";
2323
import { toast } from "sonner";
2424
import { useRouter } from "next/navigation";
2525
import { ko } from "date-fns/locale";
2626
import { patchTicketTimeSet } from "@/lib/api/admin/admin.client";
27-
import { getConcertStartDate, isSameDay } from "@/utils/helpers/handleDate";
27+
import { getConcertStartDate, isSameDay, dateToISOString } from "@/utils/helpers/handleDate";
2828

2929
export default function ConcertHeaderBtn({
3030
concertDetail,
@@ -79,7 +79,7 @@ export default function ConcertHeaderBtn({
7979
};
8080

8181
// 플래너 생성 핸들러
82-
const handleCreatePlanner = async () => {
82+
const handleCreateNewPlan = async () => {
8383
if (!concertDetail?.concertId) {
8484
toast.error("선택된 공연이 없습니다.");
8585
return;
@@ -100,10 +100,10 @@ export default function ConcertHeaderBtn({
100100
return;
101101
}
102102

103-
const data = await createPlanner({
103+
const data = await createNewPlan({
104104
concertId: concertDetail.concertId,
105105
title: plannerTitle.trim(),
106-
planDate: plannerDate.toISOString(),
106+
planDate: dateToISOString(plannerDate),
107107
});
108108

109109
if (!data) {
@@ -292,7 +292,7 @@ export default function ConcertHeaderBtn({
292292
<Button variant="outline" onClick={handleClosePlannerModal}>
293293
취소
294294
</Button>
295-
<Button onClick={handleCreatePlanner}>만들기</Button>
295+
<Button onClick={handleCreateNewPlan}>만들기</Button>
296296
</DialogFooter>
297297
</DialogContent>
298298
</Dialog>

src/components/concert/detail/QuickActionsSection.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,11 @@ import {
3737
AlertDialogFooter,
3838
AlertDialogDescription,
3939
} from "@/components/ui/alert-dialog";
40-
import { createPlanner } from "@/lib/api/planner/planner.client";
40+
import { createNewPlan } from "@/lib/api/planner/planner.client";
4141
import { useRouter } from "next/navigation";
4242
import { User } from "@/types/user";
4343
import { postLikeConcert } from "@/lib/api/concerts/concerts.client";
44-
import { getConcertStartDate, isSameDay } from "@/utils/helpers/handleDate";
44+
import { getConcertStartDate, isSameDay, dateToISOString } from "@/utils/helpers/handleDate";
4545

4646
export default function QuickActionsSection({
4747
concertId,
@@ -109,7 +109,7 @@ export default function QuickActionsSection({
109109
};
110110

111111
// 플래너 생성 핸들러
112-
const handleCreatePlanner = async () => {
112+
const handleCreateNewPlan = async () => {
113113
if (!concertId) {
114114
toast.error("선택된 공연이 없습니다.");
115115
return;
@@ -130,10 +130,10 @@ export default function QuickActionsSection({
130130
return;
131131
}
132132

133-
const data = await createPlanner({
133+
const data = await createNewPlan({
134134
concertId: concertId,
135135
title: plannerTitle.trim(),
136-
planDate: plannerDate.toISOString(),
136+
planDate: dateToISOString(plannerDate),
137137
});
138138

139139
if (!data) {
@@ -321,7 +321,7 @@ export default function QuickActionsSection({
321321
<Button variant="outline" onClick={handleClosePlannerModal}>
322322
취소
323323
</Button>
324-
<Button onClick={handleCreatePlanner}>만들기</Button>
324+
<Button onClick={handleCreateNewPlan}>만들기</Button>
325325
</DialogFooter>
326326
</DialogContent>
327327
</Dialog>
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
2+
import { TicketOffice } from "@/types/concerts";
3+
import Link from "next/link";
4+
5+
interface TicketModalProps {
6+
open: boolean;
7+
onOpenChange: (open: boolean) => void;
8+
ticketingData: TicketOffice[] | null;
9+
}
10+
11+
export default function TicketDialog({ open, onOpenChange, ticketingData }: TicketModalProps) {
12+
return (
13+
<Dialog open={open} onOpenChange={onOpenChange} aria-description="티켓 예매처 목록">
14+
<DialogContent>
15+
<DialogHeader>
16+
<DialogTitle>티켓 예매하기</DialogTitle>
17+
</DialogHeader>
18+
<div className="max-h-[60vh] overflow-y-auto p-4">
19+
<div className="flex flex-col gap-3">
20+
{ticketingData?.length ? (
21+
ticketingData.map((ticket: TicketOffice) => (
22+
<Link
23+
key={ticket.ticketOfficeName}
24+
href={ticket.ticketOfficeUrl}
25+
target="_blank"
26+
rel="noreferrer"
27+
className="border-border hover:border-point-main hover:bg-point-main/5 block rounded-lg border px-4 py-3 transition"
28+
>
29+
<div className="flex items-center justify-between gap-3">
30+
<div className="flex flex-col gap-1">
31+
<span className="text-foreground text-sm font-medium">
32+
{ticket.ticketOfficeName}
33+
</span>
34+
<span className="text-text-sub text-xs">
35+
{ticket.ticketOfficeName} 공식 예매처로 이동합니다.
36+
</span>
37+
</div>
38+
<span className="bg-point-main text-bg-main rounded-full px-3 py-1 text-xs font-semibold">
39+
예매하기
40+
</span>
41+
</div>
42+
</Link>
43+
))
44+
) : (
45+
<p className="text-text-sub py-6 text-center text-sm">
46+
등록된 티켓 예매처가 없습니다.
47+
</p>
48+
)}
49+
</div>
50+
</div>
51+
</DialogContent>
52+
</Dialog>
53+
);
54+
}

src/components/planner/PlannerCreate.tsx

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
"use client";
2-
32
import { useEffect, useOptimistic, useState, useTransition } from "react";
43
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
54
import { ConcertDatePicker } from "../concert/detail/ConcertDatePicker";
@@ -15,8 +14,8 @@ import { getConcertDetail } from "@/lib/api/concerts/concerts.client";
1514
import { ConcertDetail } from "@/types/concerts";
1615
import { toast } from "sonner";
1716
import { formatDateRange } from "@/utils/helpers/formatters";
18-
import { createPlanner } from "@/lib/api/planner/planner.client";
19-
import { getConcertStartDate, isSameDay } from "@/utils/helpers/handleDate";
17+
import { createNewPlan } from "@/lib/api/planner/planner.client";
18+
import { getConcertStartDate, isSameDay, dateToISOString } from "@/utils/helpers/handleDate";
2019

2120
export default function PlannerCreate() {
2221
const router = useRouter();
@@ -32,10 +31,10 @@ export default function PlannerCreate() {
3231
// 선택된 콘서트 상태
3332
const [selectedConcert, setSelectedConcert] = useState<AutoCompleteConcerts | null>(null);
3433

35-
// 선택된 콘서트의 상세 정보
34+
// 선택된 콘서트의 상세 정보
3635
const [concertDetail, setConcertDetail] = useState<ConcertDetail | null>(null);
3736

38-
// 콘서트 상세 정보 로딩 상태
37+
// 콘서트 상세 정보 로딩 상태
3938
const [isLoadingConcertDetail, setIsLoadingConcertDetail] = useState(false);
4039

4140
const [search, setSearch] = useState<string>("");
@@ -142,7 +141,7 @@ export default function PlannerCreate() {
142141
}, [plannerDialogOpen, router]);
143142

144143
// 플래너 생성 핸들러
145-
const handleCreatePlanner = async () => {
144+
const handleCreateNewPlan = async () => {
146145
if (!selectedConcert?.id) {
147146
toast.error("선택된 공연이 없습니다.");
148147
return;
@@ -166,10 +165,10 @@ export default function PlannerCreate() {
166165
try {
167166
startTransition(async () => {
168167
try {
169-
const data = await createPlanner({
168+
const data = await createNewPlan({
170169
concertId: selectedConcert.id.toString(),
171170
title: plannerTitle.trim(),
172-
planDate: plannerDate.toISOString(),
171+
planDate: dateToISOString(plannerDate),
173172
});
174173

175174
// 성공 후 링크 이동
@@ -315,7 +314,7 @@ export default function PlannerCreate() {
315314
취소
316315
</Button>
317316
<Button
318-
onClick={handleCreatePlanner}
317+
onClick={handleCreateNewPlan}
319318
disabled={!plannerTitle.trim() || !plannerDate || isPending}
320319
>
321320
{isPending ? "생성 중..." : "만들기"}

0 commit comments

Comments
 (0)