Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions apps/land/constants/endpoint.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down
1 change: 1 addition & 0 deletions apps/member/src/api/config/api-endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export const END_POINT = {
RECRUITMENT: {
BASE: "recruitments",
OPEN: "recruitments/open",
DETAIL: (recruitmentId: number) => `recruitments/${recruitmentId}`,
},
COMMUNITY: {
BOARD: {
Expand Down
12 changes: 11 additions & 1 deletion apps/member/src/api/recruitment/api.model.ts
Original file line number Diff line number Diff line change
@@ -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;
};
8 changes: 8 additions & 0 deletions apps/member/src/api/recruitment/api.query.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -13,4 +16,9 @@ export const recruitmentQueries = {
queryKey: recruitmentKeys.open(),
queryFn: () => getOpenRecruitments(),
}),
getRecruitmentDetailsQuery: (recruitmentId: number) =>
queryOptions({
queryKey: recruitmentKeys.detail(recruitmentId),
queryFn: () => getRecruitmentDetails(recruitmentId),
}),
};
3 changes: 1 addition & 2 deletions apps/member/src/api/recruitment/getOpenRecruitments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import type { OpenRecruitmentResponseDto } from "./api.model";
export async function getOpenRecruitments(): Promise<
ApiResult<ApiResponse<OpenRecruitmentResponseDto[]>>
> {
// TODO: 응답 스키마 백엔드 확인
const res = await authApi.get<ApiResponse<unknown>>(
END_POINT.RECRUITMENT.OPEN,
);
Expand Down Expand Up @@ -39,6 +38,6 @@ function isOpenRecruitment(
return (
isObject(value) &&
typeof value.id === "number" &&
typeof value.title === "string"
typeof value.applicationType === "string"
);
}
10 changes: 10 additions & 0 deletions apps/member/src/api/recruitment/getRecruitmentDetails.ts
Original file line number Diff line number Diff line change
@@ -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<ApiResponse<RecruitmentDetailsResponseDto>>(
END_POINT.RECRUITMENT.DETAIL(recruitmentId),
);
}
48 changes: 45 additions & 3 deletions apps/member/src/components/admin/ApplicationCard.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -16,10 +17,14 @@ export default function ApplicationCard({
onApprove,
onReject,
}: ApplicationCardProps) {
const [isDetailsOpen, setIsDetailsOpen] = useState(false);

return (
<div className="border-gray-2 gap-sm flex flex-col rounded-xl border bg-white p-4">
<div className="gap-xs flex flex-wrap items-center">
<Chip color="yellow">신청</Chip>
<Chip color={application.isPass ? "green" : "red"}>
{application.isPass ? "합격" : "불합격"}
</Chip>
<span className="text-12-regular text-gray-4 ml-auto">
{formatRelativeTime(application.createdAt)}
</span>
Expand All @@ -33,17 +38,54 @@ export default function ApplicationCard({
학적 {application.studentStatus ?? "-"} · {application.grade}학년
</p>
</div>
<div className="text-12-regular text-gray-4 gap-xs flex flex-col">
<p>연락처 {application.contact}</p>
<p>{application.email}</p>
{application.githubUrl && (
<a
href={application.githubUrl}
target="_blank"
rel="noreferrer"
className="text-primary w-fit underline underline-offset-2"
>
GitHub 프로필 보기
</a>
)}
</div>
<button
type="button"
onClick={() => setIsDetailsOpen((isOpen) => !isOpen)}
className="text-12-medium text-primary w-fit"
>
{isDetailsOpen ? "지원서 정보 접기" : "지원서 정보 더 보기"}
</button>
{isDetailsOpen && (
<dl className="border-gray-2 text-12-regular gap-sm grid grid-cols-[5rem_1fr] rounded-lg border p-3 text-black">
<dt className="text-gray-4">생년월일</dt>
<dd>{application.birth || "-"}</dd>
<dt className="text-gray-4">주소</dt>
<dd>{application.address || "-"}</dd>
<dt className="text-gray-4">관심 분야</dt>
<dd className="whitespace-pre-wrap">
{application.interests || "-"}
</dd>
<dt className="text-gray-4">기타 활동</dt>
<dd className="whitespace-pre-wrap">
{application.otherActivities || "-"}
</dd>
</dl>
)}
<div className="gap-sm flex justify-end">
<Button
size="small"
color="outlineActive"
onClick={onReject}
disabled={isMutating}
>
거절
불합격
</Button>
<Button size="small" onClick={onApprove} disabled={isMutating}>
승인
합격
</Button>
</div>
</div>
Expand Down
44 changes: 39 additions & 5 deletions apps/member/src/components/admin/ApplicationSection.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand All @@ -26,6 +30,7 @@ interface ApplicationSectionProps {
export default function ApplicationSection({
recruitments,
selectedRecruitmentId,
passFilter,
applications,
totalItems,
currentPage,
Expand All @@ -34,13 +39,14 @@ export default function ApplicationSection({
isError,
isMutating,
onSelectRecruitment,
onSelectPassFilter,
onApprove,
onReject,
onPageChange,
}: ApplicationSectionProps) {
return (
<Section title={`가입 신청 ${totalItems}건`}>
{recruitments.length > 1 && (
{recruitments.length > 0 && (
<div className="gap-sm mb-lg flex flex-wrap">
{recruitments.map((recruitment) => (
<button
Expand All @@ -61,12 +67,40 @@ export default function ApplicationSection({
))}
</div>
)}
<div className="gap-sm mb-lg flex flex-wrap">
{(
[
["ALL", "전체"],
["PASS", "합격"],
["FAIL", "불합격"],
] as const
).map(([value, label]) => (
<button
key={value}
type="button"
onClick={() => onSelectPassFilter(value)}
>
<Chip color={passFilter === value ? "primary" : "disabled"}>
{label}
</Chip>
</button>
))}
</div>
<AdminListState
isPending={isPending}
isError={isError}
isEmpty={!isPending && applications.length === 0}
emptyMessage="처리 대기 중인 가입 신청이 없습니다."
isEmpty={
selectedRecruitmentId !== null &&
!isPending &&
applications.length === 0
}
emptyMessage="조건에 맞는 가입 신청이 없습니다."
/>
{selectedRecruitmentId === null && !isPending && !isError && (
<div className="text-14-regular text-gray-4 py-2xl text-center">
조회할 모집 공고를 선택해 주세요.
</div>
)}
<div className="gap-md flex flex-col">
{applications.map((application) => (
<ApplicationCard
Expand Down
84 changes: 60 additions & 24 deletions apps/member/src/pages/admin/AdminMemberPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import {
Tabs,
Title,
} from "@clab/design-system";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
useMutation,
useQueries,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { GoChevronLeft } from "react-icons/go";
import { useNavigate, useSearchParams } from "react-router";
Expand All @@ -21,6 +26,7 @@ import {
MemberCard,
RoleSelectModal,
} from "@/components/admin";
import type { ApplicationPassFilter } from "@/components/admin/ApplicationSection";
import { ConfirmModal } from "@/components/common";

import { applicationKeys, applicationQueries } from "@/api/application";
Expand Down Expand Up @@ -62,6 +68,8 @@ export default function AdminMemberPage() {
const [selectedRecruitmentId, setSelectedRecruitmentId] = useState<
number | null
>(null);
const [applicationPassFilter, setApplicationPassFilter] =
useState<ApplicationPassFilter>("ALL");
const [roleModalMember, setRoleModalMember] =
useState<AdminMemberItem | null>(null);
const [pendingAction, setPendingAction] = useState<PendingAction | null>(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -248,23 +277,30 @@ export default function AdminMemberPage() {
{tab === "APPLICATIONS" ? (
<Scrollable className="px-gutter py-xl">
<ApplicationSection
recruitments={openRecruitments}
selectedRecruitmentId={effectiveRecruitmentId}
recruitments={recruitments}
selectedRecruitmentId={selectedRecruitmentId}
passFilter={applicationPassFilter}
applications={applications}
totalItems={applicationPageData?.totalItems ?? 0}
currentPage={applicationPage}
totalPages={applicationPageData?.totalPages ?? 0}
isPending={
openRecruitmentsQuery.isPending || applicationQuery.isPending
isRecruitmentsPending ||
(selectedRecruitmentId !== null && applicationQuery.isPending)
}
isError={
openRecruitmentsQuery.isError || applicationQuery.isError
isRecruitmentsError ||
(selectedRecruitmentId !== null && applicationQuery.isError)
}
isMutating={isApplicationMutating}
onSelectRecruitment={(id) => {
setSelectedRecruitmentId(id);
setApplicationPage(0);
}}
onSelectPassFilter={(filter) => {
setApplicationPassFilter(filter);
setApplicationPage(0);
}}
onApprove={(application) =>
setPendingAction({
type: "approve",
Expand Down
Loading