From d84fdac107ed2c6df914ea960dd0e72f1247bc40 Mon Sep 17 00:00:00 2001 From: seoJing Date: Sun, 16 Aug 2026 22:14:41 +0900 Subject: [PATCH] =?UTF-8?q?:sparkles:=20(member)=20=EC=96=B4=EB=93=9C?= =?UTF-8?q?=EB=AF=BC=20=EC=A7=80=EC=9B=90=EC=9E=90=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/land/constants/endpoint.ts | 5 +- apps/member/src/api/config/api-endpoint.ts | 1 + apps/member/src/api/recruitment/api.model.ts | 12 ++- apps/member/src/api/recruitment/api.query.ts | 8 ++ .../api/recruitment/getOpenRecruitments.ts | 3 +- .../api/recruitment/getRecruitmentDetails.ts | 10 +++ .../src/components/admin/ApplicationCard.tsx | 48 ++++++++++- .../components/admin/ApplicationSection.tsx | 44 ++++++++-- .../src/pages/admin/AdminMemberPage.tsx | 84 +++++++++++++------ 9 files changed, 176 insertions(+), 39 deletions(-) create mode 100644 apps/member/src/api/recruitment/getRecruitmentDetails.ts diff --git a/apps/land/constants/endpoint.ts b/apps/land/constants/endpoint.ts index fe0230f..d099e50 100644 --- a/apps/land/constants/endpoint.ts +++ b/apps/land/constants/endpoint.ts @@ -1,7 +1,4 @@ -// 브라우저 요청은 항상 Next.js rewrite를 거친다. 원격/Tailscale dev 접속에서도 -// 사용자의 localhost가 아니라 개발 서버가 설정된 API로 프록시해야 한다. -export const API_BASE_URL = - typeof window !== 'undefined' ? '/api' : process.env.NEXT_PUBLIC_API_URL; +export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL; export const SERVER_BASE_URL = process.env.NEXT_PUBLIC_SERVER_URL; export const CHANNEL_TALK_TOKEN = process.env.NEXT_PUBLIC_CHANNEL_TALK_TOKEN; diff --git a/apps/member/src/api/config/api-endpoint.ts b/apps/member/src/api/config/api-endpoint.ts index 01d81f0..29a5d25 100644 --- a/apps/member/src/api/config/api-endpoint.ts +++ b/apps/member/src/api/config/api-endpoint.ts @@ -30,6 +30,7 @@ export const END_POINT = { RECRUITMENT: { BASE: "recruitments", OPEN: "recruitments/open", + DETAIL: (recruitmentId: number) => `recruitments/${recruitmentId}`, }, COMMUNITY: { BOARD: { diff --git a/apps/member/src/api/recruitment/api.model.ts b/apps/member/src/api/recruitment/api.model.ts index d7897ef..42ba32e 100644 --- a/apps/member/src/api/recruitment/api.model.ts +++ b/apps/member/src/api/recruitment/api.model.ts @@ -1,5 +1,15 @@ +import type { ApplicationType } from "@/api/application"; + export type OpenRecruitmentResponseDto = { + id: number; + applicationType: ApplicationType; +}; + +export type RecruitmentDetailsResponseDto = { id: number; title: string; - [key: string]: unknown; + applicationType: ApplicationType; + startDate: string; + endDate: string; + status: string; }; diff --git a/apps/member/src/api/recruitment/api.query.ts b/apps/member/src/api/recruitment/api.query.ts index 7add205..b388ce3 100644 --- a/apps/member/src/api/recruitment/api.query.ts +++ b/apps/member/src/api/recruitment/api.query.ts @@ -1,10 +1,13 @@ import { queryOptions } from "@tanstack/react-query"; import { getOpenRecruitments } from "./getOpenRecruitments"; +import { getRecruitmentDetails } from "./getRecruitmentDetails"; export const recruitmentKeys = { all: ["recruitment"] as const, open: () => [...recruitmentKeys.all, "open"] as const, + detail: (recruitmentId: number) => + [...recruitmentKeys.all, "detail", recruitmentId] as const, }; export const recruitmentQueries = { @@ -13,4 +16,9 @@ export const recruitmentQueries = { queryKey: recruitmentKeys.open(), queryFn: () => getOpenRecruitments(), }), + getRecruitmentDetailsQuery: (recruitmentId: number) => + queryOptions({ + queryKey: recruitmentKeys.detail(recruitmentId), + queryFn: () => getRecruitmentDetails(recruitmentId), + }), }; diff --git a/apps/member/src/api/recruitment/getOpenRecruitments.ts b/apps/member/src/api/recruitment/getOpenRecruitments.ts index 0a8efab..4c356ff 100644 --- a/apps/member/src/api/recruitment/getOpenRecruitments.ts +++ b/apps/member/src/api/recruitment/getOpenRecruitments.ts @@ -7,7 +7,6 @@ import type { OpenRecruitmentResponseDto } from "./api.model"; export async function getOpenRecruitments(): Promise< ApiResult> > { - // TODO: 응답 스키마 백엔드 확인 const res = await authApi.get>( END_POINT.RECRUITMENT.OPEN, ); @@ -39,6 +38,6 @@ function isOpenRecruitment( return ( isObject(value) && typeof value.id === "number" && - typeof value.title === "string" + typeof value.applicationType === "string" ); } diff --git a/apps/member/src/api/recruitment/getRecruitmentDetails.ts b/apps/member/src/api/recruitment/getRecruitmentDetails.ts new file mode 100644 index 0000000..c604f6a --- /dev/null +++ b/apps/member/src/api/recruitment/getRecruitmentDetails.ts @@ -0,0 +1,10 @@ +import type { ApiResponse } from "@/api/config"; +import { authApi, END_POINT } from "@/api/config"; + +import type { RecruitmentDetailsResponseDto } from "./api.model"; + +export function getRecruitmentDetails(recruitmentId: number) { + return authApi.get>( + END_POINT.RECRUITMENT.DETAIL(recruitmentId), + ); +} diff --git a/apps/member/src/components/admin/ApplicationCard.tsx b/apps/member/src/components/admin/ApplicationCard.tsx index e334099..b510826 100644 --- a/apps/member/src/components/admin/ApplicationCard.tsx +++ b/apps/member/src/components/admin/ApplicationCard.tsx @@ -1,4 +1,5 @@ import { Button, Chip } from "@clab/design-system"; +import { useState } from "react"; import type { ApplicationResponseDto } from "@/api/application"; import { formatRelativeTime } from "@/utils/date"; @@ -16,10 +17,14 @@ export default function ApplicationCard({ onApprove, onReject, }: ApplicationCardProps) { + const [isDetailsOpen, setIsDetailsOpen] = useState(false); + return (
- 신청 + + {application.isPass ? "합격" : "불합격"} + {formatRelativeTime(application.createdAt)} @@ -33,6 +38,43 @@ export default function ApplicationCard({ 학적 {application.studentStatus ?? "-"} · {application.grade}학년

+
+

연락처 {application.contact}

+

{application.email}

+ {application.githubUrl && ( + + GitHub 프로필 보기 + + )} +
+ + {isDetailsOpen && ( +
+
생년월일
+
{application.birth || "-"}
+
주소
+
{application.address || "-"}
+
관심 분야
+
+ {application.interests || "-"} +
+
기타 활동
+
+ {application.otherActivities || "-"} +
+
+ )}
diff --git a/apps/member/src/components/admin/ApplicationSection.tsx b/apps/member/src/components/admin/ApplicationSection.tsx index 44b14a9..6cf937c 100644 --- a/apps/member/src/components/admin/ApplicationSection.tsx +++ b/apps/member/src/components/admin/ApplicationSection.tsx @@ -1,15 +1,18 @@ import { Chip, Section } from "@clab/design-system"; import type { ApplicationResponseDto } from "@/api/application"; -import type { OpenRecruitmentResponseDto } from "@/api/recruitment"; +import type { RecruitmentDetailsResponseDto } from "@/api/recruitment"; import AdminListState from "./AdminListState"; import AdminPagination from "./AdminPagination"; import ApplicationCard from "./ApplicationCard"; +export type ApplicationPassFilter = "ALL" | "PASS" | "FAIL"; + interface ApplicationSectionProps { - recruitments: OpenRecruitmentResponseDto[]; + recruitments: RecruitmentDetailsResponseDto[]; selectedRecruitmentId: number | null; + passFilter: ApplicationPassFilter; applications: ApplicationResponseDto[]; totalItems: number; currentPage: number; @@ -18,6 +21,7 @@ interface ApplicationSectionProps { isError: boolean; isMutating: boolean; onSelectRecruitment: (id: number) => void; + onSelectPassFilter: (filter: ApplicationPassFilter) => void; onApprove: (application: ApplicationResponseDto) => void; onReject: (application: ApplicationResponseDto) => void; onPageChange: (page: number) => void; @@ -26,6 +30,7 @@ interface ApplicationSectionProps { export default function ApplicationSection({ recruitments, selectedRecruitmentId, + passFilter, applications, totalItems, currentPage, @@ -34,13 +39,14 @@ export default function ApplicationSection({ isError, isMutating, onSelectRecruitment, + onSelectPassFilter, onApprove, onReject, onPageChange, }: ApplicationSectionProps) { return (
- {recruitments.length > 1 && ( + {recruitments.length > 0 && (
{recruitments.map((recruitment) => ( + ))} +
+ {selectedRecruitmentId === null && !isPending && !isError && ( +
+ 조회할 모집 공고를 선택해 주세요. +
+ )}
{applications.map((application) => ( (null); + const [applicationPassFilter, setApplicationPassFilter] = + useState("ALL"); const [roleModalMember, setRoleModalMember] = useState(null); const [pendingAction, setPendingAction] = useState( @@ -112,32 +120,53 @@ export default function AdminMemberPage() { : [], [openRecruitmentsQuery.data], ); - - const effectiveRecruitmentId = - selectedRecruitmentId ?? openRecruitments[0]?.id ?? null; - const badgeRecruitmentId = openRecruitments[0]?.id; - const applicationBadgeQuery = useQuery({ - ...applicationQueries.getApplicationConditionsQuery({ - recruitmentId: badgeRecruitmentId, - isPass: false, - page: 0, - size: 1, - }), - enabled: typeof badgeRecruitmentId === "number", + const recruitmentDetailQueries = useQueries({ + queries: openRecruitments.map((recruitment) => + recruitmentQueries.getRecruitmentDetailsQuery(recruitment.id), + ), + }); + const recruitments = useMemo( + () => + recruitmentDetailQueries.flatMap((query) => + query.data?.ok && query.data.data.data ? [query.data.data.data] : [], + ), + [recruitmentDetailQueries], + ); + const isRecruitmentsPending = + openRecruitmentsQuery.isPending || + recruitmentDetailQueries.some((query) => query.isPending); + const isRecruitmentsError = + openRecruitmentsQuery.isError || + recruitmentDetailQueries.some((query) => query.isError); + const applicationBadgeQueries = useQueries({ + queries: openRecruitments.map((recruitment) => + applicationQueries.getApplicationConditionsQuery({ + recruitmentId: recruitment.id, + page: 0, + size: 1, + }), + ), }); - const applicationBadgeCount = - applicationBadgeQuery.data?.ok && applicationBadgeQuery.data.data.data - ? applicationBadgeQuery.data.data.data.totalItems - : 0; + const applicationBadgeCount = applicationBadgeQueries.reduce( + (count, query) => + count + + (query.data?.ok && query.data.data.data + ? query.data.data.data.totalItems + : 0), + 0, + ); const applicationQuery = useQuery({ ...applicationQueries.getApplicationConditionsQuery({ - recruitmentId: effectiveRecruitmentId ?? undefined, - isPass: false, + recruitmentId: selectedRecruitmentId ?? undefined, + isPass: + applicationPassFilter === "ALL" + ? undefined + : applicationPassFilter === "PASS", page: applicationPage, size: PAGE_SIZE, }), - enabled: tab === "APPLICATIONS" && effectiveRecruitmentId !== null, + enabled: tab === "APPLICATIONS" && selectedRecruitmentId !== null, }); const applicationPageData = applicationQuery.data?.ok && applicationQuery.data.data.data @@ -248,23 +277,30 @@ export default function AdminMemberPage() { {tab === "APPLICATIONS" ? ( { setSelectedRecruitmentId(id); setApplicationPage(0); }} + onSelectPassFilter={(filter) => { + setApplicationPassFilter(filter); + setApplicationPage(0); + }} onApprove={(application) => setPendingAction({ type: "approve",