diff --git a/src/features/events/EventsDetailScreen.tsx b/src/features/events/EventsDetailScreen.tsx
index 34e4bbb..8fff24e 100644
--- a/src/features/events/EventsDetailScreen.tsx
+++ b/src/features/events/EventsDetailScreen.tsx
@@ -3,6 +3,7 @@ import { useNavigate, useParams } from "react-router-dom";
import EventsDetailContent from "@/features/events/components/EventsDetailContent";
import EventsDetailSkeleton from "@/features/events/components/EventsDetailSkeleton";
+import { EVENTS_TAB_PATHS } from "@/features/events/constants/events";
// 행사 상세 화면 — 이동 같은 화면 동작만 정하고, 행사 데이터를 받는 동안은 상세 배치를 따른
// 스켈레톤을 보여준다. 헤더도 데이터(사진 유무)에 따라 달라서(EventsDetailContent 주석 참고) 화면 전체가 데이터 영역이다.
@@ -18,6 +19,11 @@ function EventsDetailScreen() {
navigate(`/events/${id}/apply`, { viewTransition: true })
}
onBack={() => navigate(-1)}
+ onViewApplication={(applicationId) =>
+ navigate(`${EVENTS_TAB_PATHS.application}/${applicationId}`, {
+ viewTransition: true,
+ })
+ }
/>
);
diff --git a/src/features/events/EventsListScreen.tsx b/src/features/events/EventsListScreen.tsx
index c5d918b..a1404a1 100644
--- a/src/features/events/EventsListScreen.tsx
+++ b/src/features/events/EventsListScreen.tsx
@@ -112,6 +112,11 @@ function EventsListScreen({ tab }: EventsListScreenProps) {
onSelect={(eventId) =>
navigate(`/events/${eventId}`, { viewTransition: true })
}
+ onViewApplication={(applicationId) =>
+ navigate(`${EVENTS_TAB_PATHS.application}/${applicationId}`, {
+ viewTransition: true,
+ })
+ }
statusFilter={listStatusFilter}
/>
diff --git a/src/features/events/components/EventsCard.tsx b/src/features/events/components/EventsCard.tsx
index b39c7de..ef1a13f 100644
--- a/src/features/events/components/EventsCard.tsx
+++ b/src/features/events/components/EventsCard.tsx
@@ -11,6 +11,8 @@ import type { EventListItem } from "@/shared/entities/events/eventsTypes";
interface EventsCardProps {
event: EventListItem;
onApply?: () => void;
+ /** 이미 신청한 행사의 "신청완료" 버튼 탭 — 신청내역 상세로 보낸다 */
+ onViewApplication?: (applicationId: number) => void;
/** 카드(CTA 제외 영역) 탭 — 상세 화면으로 보낸다 */
onSelect?: () => void;
}
@@ -22,8 +24,16 @@ interface EventsCardProps {
// 색 보정이 필요 없다 — WDS Button의 size="small"이 padding 7/14·radius 8·label2로 Figma와 맞고,
// disabled 상태 색(label.assistive + interaction.disable)도 모집예정/모집종료 스펙과 그대로 일치한다
// (button/style.js의 &[aria-disabled='true'] 블록 확인). 그래서 sx 없이 disabled prop만 쓴다.
-function EventsCard({ event, onApply, onSelect }: EventsCardProps) {
- const { title, recruitStatus, thumbnailUrl } = event;
+//
+// 이미 신청한 행사는 모집 상태와 상관없이 "신청완료"를 outlined로 그리고, 누르면 신청내역 상세로 보낸다.
+// Figma에 이 상태가 없어서 정한 것이다 — disabled로 막으면 모집예정·모집종료와 같은 회색이 돼 구분되지 않는다.
+function EventsCard({
+ event,
+ onApply,
+ onViewApplication,
+ onSelect,
+}: EventsCardProps) {
+ const { title, recruitStatus, thumbnailUrl, applicationId } = event;
const isOpen = recruitStatus === "OPEN";
return (
@@ -70,15 +80,26 @@ function EventsCard({ event, onApply, onSelect }: EventsCardProps) {
-
+ {applicationId === null ? (
+
+ ) : (
+
+ )}
diff --git a/src/features/events/components/EventsDetailContent.tsx b/src/features/events/components/EventsDetailContent.tsx
index 9075bec..4658e40 100644
--- a/src/features/events/components/EventsDetailContent.tsx
+++ b/src/features/events/components/EventsDetailContent.tsx
@@ -27,6 +27,7 @@ interface EventsDetailContentProps {
eventId: string;
onBack: () => void;
onApply: (eventId: string) => void;
+ onViewApplication: (applicationId: string) => void;
}
// Figma: 행사 상세 (nodeId 1133:42433 모집중 / 1156:53992 모집예정 / 1156:54125 모집종료)
@@ -43,10 +44,13 @@ interface EventsDetailContentProps {
// - 모집중: 활성. 단 신청 자격(isFeeEligible)이 없으면 disabled에 "학생회비 납부자만 신청할 수 있어요"
// - 모집예정: disabled에 신청 시작 일시("8월 10일 18시 오픈")
// - 모집종료: Figma "CTA 없음"(1156:54125)대로 하단 버튼(Action Area)을 그리지 않는다
+// 이미 신청한 행사는 모집 상태와 상관없이 하단 버튼이 "신청내역 보기"가 돼 신청내역 상세로 보낸다 —
+// 모집종료여도 내 신청을 확인하러 올 수 있어서 버튼을 그린다. Figma에 이 상태가 없어서 정한 것이다.
function EventsDetailContent({
eventId,
onBack,
onApply,
+ onViewApplication,
}: EventsDetailContentProps) {
const { data: event } = useSuspenseQuery(eventsQueries.detail(eventId));
const photoCount = event?.images.length ?? 0;
@@ -85,11 +89,25 @@ function EventsDetailContent({
// 레거시 납부자를 미납으로 보여서 쓰지 않는다
const isFeeRequired = !event.isFeeEligible;
const canApply = event.recruitStatus === "OPEN" && !isFeeRequired;
- const actionLabel =
+ const applyLabel =
event.recruitStatus === "OPEN" && isFeeRequired
? EVENTS_FEE_PAID_ONLY_LABEL
: getEventDetailActionLabel(event);
- const hasActionArea = event.recruitStatus !== "CLOSED";
+ const { applicationId } = event;
+ const action =
+ applicationId === null
+ ? {
+ disabled: !canApply,
+ label: applyLabel,
+ onClick: () => onApply(eventId),
+ }
+ : {
+ disabled: false,
+ label: "신청내역 보기",
+ onClick: () => onViewApplication(String(applicationId)),
+ };
+ const hasActionArea =
+ applicationId !== null || event.recruitStatus !== "CLOSED";
return (
// Figma 상세 루트 배경은 Background/Normal/Normal(#FFFFFF)이다 — ScreenLayout 기본
@@ -218,7 +236,7 @@ function EventsDetailContent({
{/* Action Area가 본문을 덮으므로 끝부분이 영구히 가리지 않도록 같은 높이를 비워둔다.
96px = Action Area 위아래 padding 20+20 + 버튼 56. 데스크톱 프레임에서는
홈 인디케이터 여백 14px이 더 붙는다(아래 filler와 같은 규칙).
- 모집종료는 Action Area가 없어서 이 여백도 두지 않는다 — Figma(1156:54125)도 본문에서 끝난다. */}
+ 신청하지 않은 모집종료는 Action Area가 없어서 이 여백도 두지 않는다 — Figma(1156:54125)도 본문에서 끝난다. */}
{hasActionArea && }
@@ -228,11 +246,11 @@ function EventsDetailContent({
{/* WDS ActionAreaButton은 항상 Button size="large"(padding 12px 28px → 48px)로 그리는데,
Figma Main Action은 padding 16px 28px(56px)이라 세로 padding만 sx로 맞춘다. */}
onApply(eventId)}
+ disabled={action.disabled}
+ onClick={action.onClick}
sx={{ paddingBlock: "16px" }}
>
- {actionLabel}
+ {action.label}
{/* Figma Action Area(110px)는 버튼 아래가 iOS Home Bar 여백까지 합쳐 34px인데,
diff --git a/src/features/events/components/EventsList.tsx b/src/features/events/components/EventsList.tsx
index 833a00b..ba1d40d 100644
--- a/src/features/events/components/EventsList.tsx
+++ b/src/features/events/components/EventsList.tsx
@@ -39,6 +39,7 @@ interface EventsListProps {
statusFilter: EventStatusFilter;
onSelect: (eventId: string) => void;
onApply: (eventId: string) => void;
+ onViewApplication: (applicationId: string) => void;
onBrowseArchives: () => void;
}
@@ -48,6 +49,7 @@ function EventsList({
statusFilter,
onSelect,
onApply,
+ onViewApplication,
onBrowseArchives,
}: EventsListProps) {
// 모집 상태는 서버가 걸러 준다 — 페이지로 나눠 받아서 받은 것만 거르면 다음 페이지의 행사가 빠진다
@@ -104,6 +106,9 @@ function EventsList({
event={event}
onApply={() => onApply(String(event.eventId))}
onSelect={() => onSelect(String(event.eventId))}
+ onViewApplication={(applicationId) =>
+ onViewApplication(String(applicationId))
+ }
/>
))}
diff --git a/src/features/search/components/SearchCategoryItems.tsx b/src/features/search/components/SearchCategoryItems.tsx
index 620390c..568962b 100644
--- a/src/features/search/components/SearchCategoryItems.tsx
+++ b/src/features/search/components/SearchCategoryItems.tsx
@@ -3,6 +3,7 @@ import { Fragment, type ReactNode } from "react";
import { Link, useNavigate } from "react-router-dom";
import BillilgeItemCard from "@/features/billilge/components/BillilgeItemCard";
import EventsCard from "@/features/events/components/EventsCard";
+import { EVENTS_TAB_PATHS } from "@/features/events/constants/events";
import FeedbacksCard from "@/features/feedbacks/components/FeedbacksCard";
import {
formatFeedbackRound,
@@ -63,6 +64,9 @@ function SearchCategoryItems({ entries, footer }: SearchCategoryItemsProps) {
event={event}
onApply={() => navigate(`/events/${event.eventId}/apply`)}
onSelect={() => navigate(`/events/${event.eventId}`)}
+ onViewApplication={(applicationId) =>
+ navigate(`${EVENTS_TAB_PATHS.application}/${applicationId}`)
+ }
/>
),
}));
diff --git a/src/shared/entities/events/eventsKeys.ts b/src/shared/entities/events/eventsKeys.ts
index d78c201..8faf4d6 100644
--- a/src/shared/entities/events/eventsKeys.ts
+++ b/src/shared/entities/events/eventsKeys.ts
@@ -3,13 +3,14 @@ import type { EventRecruitStatus } from "@/shared/entities/events/eventsTypes";
// 조회 캐시 key. 조회와 무효화가 같은 함수로 key를 만들어서, 한쪽만 바뀌어 무효화가 빗나가지 않게 한다.
// 행사 목록은 모집 상태 필터·검색어마다 따로 받아서 lists() 아래에 둔다 — lists() 하나로 모든 필터를 같이 무효화한다.
// 신청내역 상세는 목록 key 아래에 둬서, 목록 key 하나로 목록·상세를 같이 무효화한다.
+// 행사 상세는 details() 아래에 둔다 — 신청 취소는 어느 행사인지 받지 않아서 모든 상세를 같이 무효화한다.
export const eventsKeys = {
root: () => ["events"] as const,
lists: () => [...eventsKeys.root(), "list"] as const,
list: (recruitStatus: EventRecruitStatus | null, keyword: string | null) =>
[...eventsKeys.lists(), recruitStatus, keyword] as const,
- detail: (eventId: string) =>
- [...eventsKeys.root(), "detail", eventId] as const,
+ details: () => [...eventsKeys.root(), "detail"] as const,
+ detail: (eventId: string) => [...eventsKeys.details(), eventId] as const,
form: (eventId: string) => [...eventsKeys.root(), "form", eventId] as const,
applications: () => [...eventsKeys.root(), "applications"] as const,
application: (applicationId: string) =>
diff --git a/src/shared/entities/events/eventsQueries.ts b/src/shared/entities/events/eventsQueries.ts
index 3d6ad28..e57e2be 100644
--- a/src/shared/entities/events/eventsQueries.ts
+++ b/src/shared/entities/events/eventsQueries.ts
@@ -13,6 +13,7 @@ import type {
EventRecruitStatus,
} from "@/shared/entities/events/eventsTypes";
import { homeKeys } from "@/shared/entities/home/homeKeys";
+import { searchKeys } from "@/shared/entities/search/searchKeys";
// 서버 enum(RecruitStatus·RecruitType·QuestionType·EventApplicationStatus) 값 — 타입은 eventsTypes.ts가 여기서 만든다
export const EVENT_RECRUIT_STATUSES = [
@@ -44,6 +45,8 @@ export const eventListItemSchema = z.looseObject({
recruitStatus: z.enum(EVENT_RECRUIT_STATUSES),
// 신청 마감일까지 남은 날 수(0이면 오늘 마감). 모집중일 때만 있다
daysUntilDeadline: z.number().nullable(),
+ // 이 학생이 낸 신청 중 취소하지 않은 것의 ID(stream-server#302). 신청하지 않았거나 취소했으면 null이다
+ applicationId: z.number().nullable(),
});
const eventListPageSchema = z.looseObject({
@@ -75,6 +78,8 @@ export const eventDetailSchema = z.looseObject({
// 이 학생이 학생회비 조건을 채우는지. 납부자 전용이 아니면 항상 true, 납부자 전용이면 납부자 명부에 있을 때만 true다.
// 모집 기간·정원은 반영하지 않아서 recruitStatus와 같이 본다
isFeeEligible: z.boolean(),
+ // 목록 항목과 같다 — 신청했으면 모집 상태와 상관없이 있다
+ applicationId: z.number().nullable(),
});
// 신청서 질문 — 양식 조회와 신청 상세가 같이 싣는 부분. 화면에는 eventsQuestions.ts에서 EventsQuestion으로 바꿔 넘긴다
@@ -270,7 +275,8 @@ export const eventsQueries = {
};
// 행사 신청(POST /v1/app/events/{eventId}/applications). 신청이 생기고 정원이 차서 모집 상태가 바뀔 수도 있어서
-// 신청내역과 행사 목록·상세를 무효화한다. 홈의 신청 안내 카드(신청 여부)와 신청한 행사도 바뀌어서 홈도 무효화한다.
+// 신청내역과 행사 목록·상세를 무효화한다. 홈의 신청 안내 카드(신청 여부)와 신청한 행사도 바뀌어서 홈도 무효화하고,
+// 통합 검색의 행사 카드도 신청 여부(applicationId)를 그려서 같이 무효화한다.
// 마감·이미 신청·신청 자격 없음으로 실패해도 지금 캐시가 낡았다는 뜻이라 같이 무효화한다.
// 신청서 양식은 무효화하지 않는다 — 신청서 화면이 떠 있는 동안 다시 받으면 마감된 양식이 화면을 마감으로 보낸다.
// 응답 형식이 달라도 서버에는 이미 신청이 생겼으니 실패로 바꾸지 않고 null을 돌려준다(사물함 신청과 같다).
@@ -282,6 +288,7 @@ export function useApplyEventMutation(eventId: string) {
queryClient.invalidateQueries({ queryKey: eventsKeys.lists() }),
queryClient.invalidateQueries({ queryKey: eventsKeys.detail(eventId) }),
queryClient.invalidateQueries({ queryKey: homeKeys.root() }),
+ queryClient.invalidateQueries({ queryKey: searchKeys.root() }),
]);
return useMutation({
@@ -314,7 +321,8 @@ export function useApplyEventMutation(eventId: string) {
// 신청 취소(PATCH /v1/app/events/applications/{applicationId}/cancel). 신청 기록은 남고 상태만 취소로 바뀐다.
// 성공하면 신청내역 목록·상세를 다시 받아 취소 상태를 보여 준다. 홈의 신청한 행사·신청 안내 카드도 바뀌어서
-// 홈도 무효화한다. 이미 취소된 신청이었다면 지금 캐시가 낡았다는 뜻이라 똑같이 다시 받는다.
+// 홈도 무효화한다. 행사 목록·상세·통합 검색도 신청 여부(applicationId)를 그려서 무효화한다 — 취소는 어느 행사인지
+// 받지 않아서 행사 상세는 모두 무효화한다. 이미 취소된 신청이었다면 지금 캐시가 낡았다는 뜻이라 똑같이 다시 받는다.
export function useCancelEventApplicationMutation() {
const queryClient = useQueryClient();
// Promise를 돌려줘서 다시 받은 데이터가 들어온 뒤에 mutation이 끝나게 한다 —
@@ -330,6 +338,9 @@ export function useCancelEventApplicationMutation() {
queryKey: eventsKeys.applications(),
}),
queryClient.invalidateQueries({ queryKey: homeKeys.root() }),
+ queryClient.invalidateQueries({ queryKey: eventsKeys.lists() }),
+ queryClient.invalidateQueries({ queryKey: eventsKeys.details() }),
+ queryClient.invalidateQueries({ queryKey: searchKeys.root() }),
]);
return useMutation({