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
11 changes: 10 additions & 1 deletion frontend/app/(WithNavbar)/pass-state/[generation]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Txt from "@/components/common/Txt.component";
import ApplicantsList from "@/components/passState/ApplicantsList";
import BulkEmailButtons from "@/components/passState/BulkEmailButtons";
import { CURRENT_GENERATION } from "@/src/constants";
import Image from "next/image";
import Link from "next/link";
Expand All @@ -18,6 +19,8 @@ const PassStatePage = ({ searchParams: { sortedBy } }: PassStatePageProps) => {
{CURRENT_GENERATION}기 지원자 합격 상태 관리 페이지
</Txt>
<div className="mt-8" />
<BulkEmailButtons />
<div className="mt-10" />
{sortedBy === "field" && (
<Link href={`/pass-state/${CURRENT_GENERATION}`}>
<Txt typography="h5" className="text-secondary-200">
Expand All @@ -26,7 +29,7 @@ const PassStatePage = ({ searchParams: { sortedBy } }: PassStatePageProps) => {
<div className="mt-8" />
</Link>
)}
<div className="grid grid-cols-[8fr_8fr_4fr_3fr] gap-4">
<div className="grid grid-cols-[8fr_8fr_4fr_3fr_3fr] gap-4">

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The new /pass-state route is missing from the needValidatePath list in frontend/src/constants/index.ts. This list defines the routes that require authentication and authorization (e.g., /admin, /kanban, /applicant). Since this page allows managing applicant pass states and sending sensitive result emails, it should be protected to prevent unauthorized access. An attacker could potentially navigate to /pass-state/[generation] and perform administrative actions without proper authentication.

<Txt typography="h6" className="text-left text-secondary-100">
지원자 이름
</Txt>
Expand All @@ -49,6 +52,12 @@ const PassStatePage = ({ searchParams: { sortedBy } }: PassStatePageProps) => {
<Txt typography="h6" className="text-left text-secondary-100">
합격 상태
</Txt>
<Txt typography="h6" className="text-left text-secondary-100">
합격/불합격
</Txt>
<Txt typography="h6" className="text-left text-secondary-100">
이메일 발송
</Txt>
</div>
<div className="mt-2" />
<hr />
Expand Down
157 changes: 90 additions & 67 deletions frontend/components/passState/ApplicantsList.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
"use client";

import { getAllApplicantsWithPassState } from "@/src/apis/passState";
import {
getAllApplicantsWithPassState,
sendEmailToApplicant,
} from "@/src/apis/passState";
import { CURRENT_GENERATION } from "@/src/constants";
import { usePathname } from "next/navigation";
import Txt from "../common/Txt.component";
import { getApplicantPassState } from "@/src/functions/formatter";
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery } from "@tanstack/react-query";
import type {
PatchApplicantPassStateParams,
Answer,
} from "@/src/apis/passState";
import { useOptimisticApplicantPassUpdate } from "@/src/hooks/applicant/useOptimisticApplicantPassUpdate";


function sortApplicantsByField1(applicants: Answer[]) {
const passStateOrder = {
"final-passed": 0,
Expand All @@ -27,7 +31,7 @@ function sortApplicantsByField1(applicants: Answer[]) {
GAME: 3,
};

return applicants.sort((a, b) => {
return [...applicants].sort((a, b) => {
if (
passStateOrder[a.state.passState] !== passStateOrder[b.state.passState]
) {
Expand Down Expand Up @@ -58,30 +62,37 @@ const ApplicantsList = ({ sortedBy }: ApplicantsListProps) => {
// isLoading: isUpdatingApplicantPassState,
} = useOptimisticApplicantPassUpdate(selectedGeneration);

{
if (+selectedGeneration !== CURRENT_GENERATION) {
return <div>현재 지원중인 기수만 확인 가능합니다.</div>;
}
const { mutate: sendEmail } = useMutation(sendEmailToApplicant);

if (isLoading) {
return <div>Loading...</div>;
}
const onSendEmail = (name: string, applicantId: string) => {
const ok = confirm(`${name}님에게 결과 이메일을 발송하시겠습니까?`);
if (!ok) return;
sendEmail(applicantId);
};

if (isError) {
return <div>Error</div>;
}
if (+selectedGeneration !== CURRENT_GENERATION) {
return <div>현재 지원중인 기수만 확인 가능합니다.</div>;
}

if (!allApplicants) {
return <div>아직은 지원자가 없습니다 🥲</div>;
}
if (isLoading) {
return <div>Loading...</div>;
}

if (isError) {
return <div>Error</div>;
}

if (!allApplicants) {
return <div>아직은 지원자가 없습니다 🥲</div>;
}

const onChangeApplicantsPassState = (
applicantName: string,
params: PatchApplicantPassStateParams
) => {
const stateLabel = params.afterState === "pass" ? "합격" : "불합격";
const ok = confirm(
`${applicantName}님을 ${params.afterState} 처리하시겠습니까?`
`${applicantName}님을 ${stateLabel} 처리하시겠습니까?`
);
if (!ok) return;
updateApplicantPassState(params);
Expand All @@ -93,62 +104,74 @@ const ApplicantsList = ({ sortedBy }: ApplicantsListProps) => {
: allApplicants;

return (
<ul className="flex flex-col gap-4">
{applicants.map(
({ state: { passState }, field, field1, field2, id, name }) => (
<li
key={id}
className="grid grid-cols-[8fr_8fr_4fr_3fr] gap-4 items-center"
>
<Txt typography="h6" className="text-left truncate">
{`[${field}] ${name}`}
</Txt>
<Txt
className="text-left truncate"
color="gray"
>{`${field1}/${field2}`}</Txt>
<Txt
className="text-left truncate"
color={
passState === "non-passed"
? "red"
: passState === "final-passed"
? "blue"
: "black"
}
<>
<ul className="flex flex-col gap-4">
{applicants.map(
({ state: { passState }, field, field1, field2, id, name }) => (
<li
key={id}
className="grid grid-cols-[8fr_8fr_4fr_3fr_3fr] gap-4 items-center"
>
{getApplicantPassState(passState)}
</Txt>
<div className="flex justify-between">
<button
disabled={passState === "final-passed"}
className="border px-4 py-2 rounded-lg truncate hover:bg-primary-100 disabled:bg-primary-100 disabled:cursor-not-allowed"
onClick={() =>
onChangeApplicantsPassState(name, {
applicantId: id,
afterState: "pass",
})
<Txt typography="h6" className="text-left truncate">
{`[${field}] ${name}`}
</Txt>
<Txt
className="text-left truncate"
color="gray"
>{`${field1}/${field2}`}</Txt>
<Txt
className="text-left truncate"
color={
passState === "non-passed"
? "red"
: passState === "final-passed"
? "blue"
: "black"
}
>
합격
</button>
{getApplicantPassState(passState)}
</Txt>
<div className="flex justify-between">
<button
type="button"
disabled={passState === "final-passed"}
className="border px-4 py-2 rounded-lg truncate hover:bg-primary-100 disabled:bg-primary-100 disabled:cursor-not-allowed"
onClick={() =>
onChangeApplicantsPassState(name, {
applicantId: id,
afterState: "pass",
})
}
>
합격
</button>
<button
type="button"
disabled={passState === "non-passed"}
className="border px-4 rounded-lg truncate hover:bg-primary-100 disabled:bg-primary-100 disabled:cursor-not-allowed"
onClick={() =>
onChangeApplicantsPassState(name, {
applicantId: id,
afterState: "non-pass",
})
}
>
불합격
</button>
</div>
<button
disabled={passState === "non-passed"}
className="border px-4 rounded-lg truncate hover:bg-primary-100 disabled:bg-primary-100 disabled:cursor-not-allowed"
onClick={() =>
onChangeApplicantsPassState(name, {
applicantId: id,
afterState: "non-pass",
})
}
type="button"
disabled={passState === "non-processed"}
className="border px-4 py-2 rounded-lg hover:bg-primary-100 disabled:bg-primary-100 disabled:cursor-not-allowed"
onClick={() => onSendEmail(name, id)}
>
불합격
발송
</button>
</div>
</li>
)
)}
</ul>
</li>
)
)}
</ul>
</>
);
};

Expand Down
53 changes: 53 additions & 0 deletions frontend/components/passState/BulkEmailButtons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"use client";

import { sendEmailToAll, type EmailState } from "@/src/apis/passState";
import { useMutation } from "@tanstack/react-query";
import { usePathname } from "next/navigation";

const EMAIL_STATE_LABEL_MAP: Record<EmailState, string> = {
"first-passed": "1차 합격자",
"first-failed": "1차 불합격자",
"final-passed": "최종 합격자",
"final-failed": "최종 불합격자",
};

const EMAIL_STATES = Object.keys(EMAIL_STATE_LABEL_MAP) as EmailState[];

const BulkEmailButtons = () => {
const selectedGeneration = usePathname().split("/")[2];
const { mutate: sendEmailAll } = useMutation(sendEmailToAll);

const onSendEmailAll = (state: EmailState) => {
const ok = confirm(
`${EMAIL_STATE_LABEL_MAP[state]} 전체에게 결과 이메일을 발송하시겠습니까?`
);
if (!ok) return;
sendEmailAll(
{ year: Number(selectedGeneration), state },
{
onSuccess: () => alert(`${EMAIL_STATE_LABEL_MAP[state]} 이메일 발송이 완료되었습니다.`),
onError: () => alert(`이메일 발송 중 오류가 발생했습니다. 다시 시도해주세요.`),
}
);
};

return (
<div className="flex flex-col gap-2">
<h6 className="text-base font-bold text-secondary-100">일괄 발송</h6>
<div className="flex gap-2 flex-wrap">
{EMAIL_STATES.map((state) => (
<button
key={state}
type="button"
className="border px-4 py-2 rounded-lg hover:bg-primary-100"
onClick={() => onSendEmailAll(state)}
>
{EMAIL_STATE_LABEL_MAP[state]} 일괄 발송
</button>
))}
</div>
</div>
);
};

export default BulkEmailButtons;
20 changes: 20 additions & 0 deletions frontend/src/apis/passState/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,23 @@ export const patchApplicantPassState = async ({
`/applicants/${applicantId}/state?afterState=${afterState}`
);
};

export const sendEmailToApplicant = async (applicantId: string) => {
await https.post<void>(`/emails/${applicantId}`);
};

export type EmailState =
| "first-passed"
| "first-failed"
| "final-passed"
| "final-failed";

export interface SendEmailToAllParams {
year: number;
state: EmailState;
}
export const sendEmailToAll = async ({ year, state }: SendEmailToAllParams) => {
await https.post<void>(`/emails/all`, undefined, {
params: { year, state },
});
};
1 change: 1 addition & 0 deletions frontend/src/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ export const needValidatePath = [
"/applicant",
"/interview",
"/kanban",
"/pass-state",
];

export const MAX_BOOLEAN_TEXT_LENGTH = 800;
Expand Down
Loading