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
13 changes: 4 additions & 9 deletions frontend/src/apis/application/updateApplicantDetail.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,20 @@
import API_BASE_URL from '@/constants/api';
import { secureFetch } from '@/apis/auth/secureFetch';
import { ApplicationStatus } from '@/types/applicants';
import { UpdateApplicantParams } from '@/types/applicants';

export const updateApplicantDetail = async (
memo: string,
status: ApplicationStatus,
applicant: UpdateApplicantParams[],
clubId: string,
applicantId: string,
) => {
try {
Comment on lines 5 to 9
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

⚠️ Potential issue

배치 업데이트 입력 검증 추가 + 파라미터 명확화.

빈 배열/누락된 applicantId를 조기에 차단하면 서버 4xx를 줄일 수 있습니다. 또한 변수명을 updates로 명확화하세요.

-export const updateApplicantDetail = async (
-  applicant: UpdateApplicantParams[],
+export const updateApplicantDetail = async (
+  updates: UpdateApplicantParams[],
   clubId: string,
 ) => {
   try {
+    if (!Array.isArray(updates) || updates.length === 0) {
+      return [];
+    }
+    if (updates.some((u) => !u.applicantId)) {
+      throw new Error('유효하지 않은 요청: applicantId 누락');
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const updateApplicantDetail = async (
memo: string,
status: ApplicationStatus,
applicant: UpdateApplicantParams[],
clubId: string,
applicantId: string,
) => {
try {
export const updateApplicantDetail = async (
updates: UpdateApplicantParams[],
clubId: string,
) => {
try {
if (!Array.isArray(updates) || updates.length === 0) {
return [];
}
if (updates.some((u) => !u.applicantId)) {
throw new Error('유효하지 않은 요청: applicantId 누락');
}
// ...rest of implementation
🤖 Prompt for AI Agents
In frontend/src/apis/application/updateApplicantDetail.ts around lines 5 to 9,
the function accepts applicant: UpdateApplicantParams[] without validating
inputs and uses an unclear parameter name; rename the parameter to updates and
add early validation that throws or returns a 4xx-friendly error when updates is
missing or an empty array, and iterate the array to verify each entry contains a
non-empty applicantId (and any required fields) before making the request;
return or throw a clear validation error message so callers get immediate
feedback and server-side 4xx calls are reduced.

const response = await secureFetch(
`${API_BASE_URL}/api/club/${clubId}/apply/${applicantId}`,
`${API_BASE_URL}/api/club/${clubId}/applicant`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
memo,
status
})
body: JSON.stringify(applicant),
},
);

Expand Down
20 changes: 10 additions & 10 deletions frontend/src/hooks/queries/applicants/useUpdateApplicant.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
import { updateApplicantDetail } from "@/apis/application/updateApplicantDetail";
import { ApplicationStatus } from "@/types/applicants";
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { updateApplicantDetail } from '@/apis/application/updateApplicantDetail';
import { UpdateApplicantParams } from '@/types/applicants';
import { useMutation, useQueryClient } from '@tanstack/react-query';

export const useUpdateApplicant = (clubId: string, applicantId: string) => {
export const useUpdateApplicant = (clubId: string) => {
const queryClient = useQueryClient();

return useMutation({
mutationFn: ({memo, status}: { memo: string, status: ApplicationStatus }) =>
updateApplicantDetail(memo, status, clubId, applicantId),
mutationFn: (applicant: UpdateApplicantParams[]) =>
updateApplicantDetail(applicant, clubId!),
Comment on lines +9 to +10
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

배치 페이로드 검증 및 널 단언 제거

  • 변수명을 복수형으로 변경해 의도를 명확히.
  • clubId!는 매개변수가 string이므로 불필요합니다.
  • UpdateApplicantParams.applicantIdstring | undefined이므로 런타임 검증이 필요합니다. 서버로 undefined가 전달되면 실패합니다.
-    mutationFn: (applicant: UpdateApplicantParams[]) =>
-      updateApplicantDetail(applicant, clubId!),
+    mutationFn: (applicants: UpdateApplicantParams[]) => {
+      if (applicants.length === 0) {
+        throw new Error('업데이트할 지원자가 없습니다.');
+      }
+      if (applicants.some(a => !a.applicantId)) {
+        throw new Error('applicantId가 없는 항목이 있습니다.');
+      }
+      return updateApplicantDetail(applicants, clubId);
+    },

또한, 디버깅 편의를 위해 mutationKey를 추가하는 것을 권장합니다.

   return useMutation({
+    mutationKey: ['updateApplicants', clubId],
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
mutationFn: (applicant: UpdateApplicantParams[]) =>
updateApplicantDetail(applicant, clubId!),
return useMutation({
mutationKey: ['updateApplicants', clubId],
mutationFn: (applicants: UpdateApplicantParams[]) => {
if (applicants.length === 0) {
throw new Error('업데이트할 지원자가 없습니다.');
}
if (applicants.some(a => !a.applicantId)) {
throw new Error('applicantId가 없는 항목이 있습니다.');
}
return updateApplicantDetail(applicants, clubId);
},
// …other options (onSuccess, onError, etc.) remain unchanged
});
🤖 Prompt for AI Agents
In frontend/src/hooks/queries/applicants/useUpdateApplicant.ts around lines
9–10, the mutation currently uses a singular variable name, a non-null assertion
on clubId, and can send undefined applicantId to the server; rename the
parameter to a plural (e.g., applicants), remove the unnecessary clubId!
non-null assertion, validate the batch payload before calling
updateApplicantDetail by checking it's a non-empty array and that each item has
a defined applicantId (either filter out invalid entries or throw a clear
error), and add a descriptive mutationKey to the hook options for easier
debugging.

onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["clubApplicants"] });
queryClient.invalidateQueries({ queryKey: ['clubApplicants'] });
Copy link
Contributor

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

쿼리 무효화 범위 축소 필요 (clubId 키 포함 권장)

다중 클럽 환경에서 불필요한 광범위 무효화를 막기 위해 키에 clubId를 포함하세요. 기존 조회 훅의 키 형식과도 일치시켜 주세요.

-      queryClient.invalidateQueries({ queryKey: ['clubApplicants'] });
+      queryClient.invalidateQueries({ queryKey: ['clubApplicants', clubId] });

검증 스크립트: 현재 조회 훅이 어떤 키를 쓰는지 확인하여 키 형식을 맞춰 주세요.


🏁 Script executed:

#!/bin/bash
# 'clubApplicants' 관련 useQuery 키 사용 패턴 확인
rg -nP -C2 "(useQuery|useInfiniteQuery)\s*\(\s*\{[^\}]*queryKey\s*:\s*\[[^\]]*'clubApplicants'[^\]]*\]" --type ts --type tsx

Length of output: 160


🏁 Script executed:

#!/bin/bash
# 'clubApplicants' 사용 파일 목록 검색
rg -l "clubApplicants" -g "*.ts*"
# 'clubApplicants' 쿼리Key 패턴 확인
rg -nP -C2 "queryKey\s*:\s*\[[^]]*['\"]clubApplicants['\"]" -g "*.ts*"

Length of output: 1758


쿼리 무효화에 clubId 추가 필요

두 곳의 invalidateQueries 호출에서 clubApplicants만 사용하여 모든 클럽의 캐시를 무효화하고 있습니다. useGetApplicants 훅의 키와 동일하게 범위를 좁히려면 clubId를 함께 전달하세요.

  • frontend/src/hooks/queries/applicants/useUpdateApplicant.ts (12행)
  • frontend/src/hooks/queries/applicants/useDeleteApplicants.ts (11행)
-      queryClient.invalidateQueries({ queryKey: ['clubApplicants'] });
+      queryClient.invalidateQueries({ queryKey: ['clubApplicants', clubId] });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
queryClient.invalidateQueries({ queryKey: ['clubApplicants'] });
queryClient.invalidateQueries({ queryKey: ['clubApplicants', clubId] });
🤖 Prompt for AI Agents
In frontend/src/hooks/queries/applicants/useUpdateApplicant.ts around line 12,
the call to queryClient.invalidateQueries({ queryKey: ['clubApplicants'] }) is
too broad and should include the clubId to match useGetApplicants' key; update
the invalidateQueries call to pass the same key shape, e.g. ['clubApplicants',
clubId], and apply the same change in
frontend/src/hooks/queries/applicants/useDeleteApplicants.ts (around line 11) so
only the specific club's applicants cache is invalidated.

},
onError: (error) => {
console.log(`Error updating applicant detail: ${error}`);
}
})
}
},
});
};
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,13 @@ const ApplicantDetailPage = () => {
const { questionId } = useParams<{ questionId: string }>();
const navigate = useNavigate();
const [applicantMemo, setAppMemo] = useState('');
const [applicantStatus, setApplicantStatus] = useState<ApplicationStatus>(ApplicationStatus.SUBMITTED);
const [applicantStatus, setApplicantStatus] = useState<ApplicationStatus>(
ApplicationStatus.SUBMITTED,
);
const { applicantsData, clubId } = useAdminClubContext();

const { data: formData, isLoading, isError } = useGetApplication(clubId!);
const { mutate: updateApplicant } = useUpdateApplicant(clubId!, questionId!);
const { mutate: updateApplicant } = useUpdateApplicant(clubId!);

const applicantIndex =
applicantsData?.applicants.findIndex((a) => a.id === questionId) ?? -1;
Expand All @@ -58,20 +60,23 @@ const ApplicantDetailPage = () => {
const updateApplicantDetail = useMemo(
() =>
debounce((memo, status) => {

function isApplicationStatus(v: unknown): v is ApplicationStatus {
return typeof v === 'string' && Object.values(ApplicationStatus).includes(v as ApplicationStatus);
return (
typeof v === 'string' &&
Object.values(ApplicationStatus).includes(v as ApplicationStatus)
);
}

if (typeof memo !== 'string') return;
if (!isApplicationStatus(status)) return;

updateApplicant(
updateApplicant([
{
memo,
status
}
);
memo,
status,
applicantId: questionId,
},
]);
}, 400),
[clubId, questionId],
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,21 +103,52 @@ export const VerticalLine = styled.div`
margin: 8px 4px;
`;

export const StatusSelect = styled.select`
export const StatusSelect = styled.p<{ disabled: boolean }>`
height: 30px;
border: 1px solid #dcdcdc;
background: #fff;
border-radius: 55px;
padding: 0px 22px 0px 8px;
margin: 5px 0px 5px 0px;
font-weight: 700;
color: ${({ disabled }) => (disabled ? '#DCDCDC' : '#000')};
padding: 0 22px 0 8px;
margin: 5px 0;

-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
${({ disabled }) =>
disabled
? 'color: #DCDCDC'
: 'color: #000; &:hover { background: #f5f5f5; }'};

&:not(:disabled):hover {
font-size: 12px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: default;

display: inline-flex;
align-items: center;
`;
Comment on lines +106 to +128
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

StatusSelect는 인터랙션 요소입니다. p → button으로 전환하고 접근성/포커스/비활성 처리를 명시하세요.

현재 styled.p에 클릭 동작을 부여하는 패턴은 접근성/키보드 내비게이션에 취약합니다. button으로 전환하고 disabled 시 pointer-events 차단, focus-visible 스타일을 추가하는 편이 안전합니다. ApplicantsTab.tsx에서 onClick을 래퍼가 아닌 버튼에 부여하는 변경과 세트로 적용해 주세요.

-export const StatusSelect = styled.p<{ disabled: boolean }>`
+export const StatusSelect = styled.button<{ disabled: boolean }>`
   height: 30px;
   border: 1px solid #dcdcdc;
   background: #fff;
   border-radius: 55px;
   padding: 0 22px 0 8px;
   margin: 5px 0;
-  ${({ disabled }) =>
-    disabled
-      ? 'color: #DCDCDC'
-      : 'color: #000; &:hover { background: #f5f5f5; }'};
+  ${({ disabled }) =>
+    disabled
+      ? `
+        color: #DCDCDC;
+        pointer-events: none;
+      `
+      : `
+        color: #000;
+        &:hover { background: #f5f5f5; }
+        &:focus-visible { outline: 2px solid #99c2ff; outline-offset: 2px; }
+      `};
   font-size: 12px;
   font-weight: 600;
   white-space: nowrap;
   overflow: hidden;
   text-overflow: ellipsis;
-  cursor: default;
+  cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
   display: inline-flex;
   align-items: center;
 `
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const StatusSelect = styled.p<{ disabled: boolean }>`
height: 30px;
border: 1px solid #dcdcdc;
background: #fff;
border-radius: 55px;
padding: 0px 22px 0px 8px;
margin: 5px 0px 5px 0px;
font-weight: 700;
color: ${({ disabled }) => (disabled ? '#DCDCDC' : '#000')};
padding: 0 22px 0 8px;
margin: 5px 0;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
${({ disabled }) =>
disabled
? 'color: #DCDCDC'
: 'color: #000; &:hover { background: #f5f5f5; }'};
&:not(:disabled):hover {
font-size: 12px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: default;
display: inline-flex;
align-items: center;
`;
export const StatusSelect = styled.button<{ disabled: boolean }>`
height: 30px;
border: 1px solid #dcdcdc;
background: #fff;
border-radius: 55px;
padding: 0 22px 0 8px;
margin: 5px 0;
${({ disabled }) =>
disabled
? `
color: #DCDCDC;
pointer-events: none;
`
: `
color: #000;
&:hover { background: #f5f5f5; }
&:focus-visible { outline: 2px solid #99c2ff; outline-offset: 2px; }
`};
font-size: 12px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
display: inline-flex;
align-items: center;
`
🤖 Prompt for AI Agents
In frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantsTab.styles.ts
around lines 106 to 128, the StatusSelect is currently a styled.p used as an
interactive control; change it to a styled.button, ensure it accepts and applies
the disabled prop properly (use disabled attribute instead of custom prop where
possible), add CSS to disable pointer-events and set aria-disabled/opacity for
disabled state, and add visible focus styles (focus-visible outline/box-shadow)
for keyboard users; also update ApplicantsTab.tsx so onClick is attached
directly to this button (not a wrapper) and ensure the component renders with
role/button semantics and proper disabled handling.


export const StatusSelectMenu = styled.div<{ open: boolean }>`
display: ${({ open }) => (open ? 'block' : 'none')};
position: absolute;
top: 100%;
height: auto;
background: #fff;
left: 0;
border: 1px solid #dcdcdc;
border-radius: 6px;
box-shadow: 0px 1px 8px 0px #0000001f;
z-index: 10;
padding: 8px 0;
color: #787878;
`;

export const StatusSelectMenuItem = styled.div`
font-size: 12px;
font-weight: 600;
padding: 8px 13px;
cursor: pointer;
text-align: left;
&:hover {
background: #f5f5f5;
}
`;
Comment on lines +145 to 154
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

메뉴 항목도 div → button으로 전환해 키보드 접근성을 확보하세요.

role/button semantics 없이 div 클릭은 스크린리더/키보드 접근성이 떨어집니다.

-export const StatusSelectMenuItem = styled.div`
+export const StatusSelectMenuItem = styled.button`
   font-size: 12px;
   font-weight: 600;
   padding: 8px 13px;
   cursor: pointer;
   text-align: left;
+  width: 100%;
+  border: 0;
+  background: transparent;
   &:hover {
     background: #f5f5f5;
   }
+  &:focus-visible {
+    outline: 2px solid #99c2ff;
+    outline-offset: 2px;
+  }
 `
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const StatusSelectMenuItem = styled.div`
font-size: 12px;
font-weight: 600;
padding: 8px 13px;
cursor: pointer;
text-align: left;
&:hover {
background: #f5f5f5;
}
`;
export const StatusSelectMenuItem = styled.button`
font-size: 12px;
font-weight: 600;
padding: 8px 13px;
cursor: pointer;
text-align: left;
width: 100%;
border: 0;
background: transparent;
&:hover {
background: #f5f5f5;
}
&:focus-visible {
outline: 2px solid #99c2ff;
outline-offset: 2px;
}
`;
🤖 Prompt for AI Agents
In frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantsTab.styles.ts
around lines 145 to 154, the StatusSelectMenuItem is a div which harms keyboard
and screen-reader accessibility; change it to a styled button element, add
type="button" where it’s rendered, reset default browser button styles
(background, border, padding) to preserve current visual styling, ensure cursor
and hover styles remain, and add visible focus styles (outline or box-shadow)
and appropriate ARIA attributes if this item functions as a selectable menu
option.

Expand Down
106 changes: 97 additions & 9 deletions frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantsTab.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useAdminClubContext } from '@/context/AdminClubContext';
import { Applicant, ApplicationStatus } from '@/types/applicants';
import React, { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import * as Styled from './ApplicantsTab.styles';
import { useNavigate } from 'react-router-dom';
import { useDeleteApplicants } from '@/hooks/queries/applicants/useDeleteApplicants';
Expand All @@ -9,6 +9,7 @@ import mapStatusToGroup from '@/utils/mapStatusToGroup';
import selectIcon from '@/assets/images/icons/selectArrow.svg';
import deleteIcon from '@/assets/images/icons/applicant_delete.svg';
import selectAllIcon from '@/assets/images/icons/applicant_select_arrow.svg';
import { useUpdateApplicant } from '@/hooks/queries/applicants/useUpdateApplicant';
Copy link
Contributor

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

상태 변경 중 UI 비활성화/스피너 연계를 위해 mutation 로딩 상태를 노출하세요.

로직 중복 클릭 방지와 접근성(aria-busy) 강화를 위해 isPending(or isLoading)을 구조분해 해 사용 권장.

다음 패치를 적용하세요(React Query v5 기준 isPending, v4면 isLoading으로 교체 필요).

-const { mutate: deleteApplicants } = useDeleteApplicants(clubId!);
-const { mutate: updateDetailApplicants } = useUpdateApplicant(clubId!);
+const { mutate: deleteApplicants, isPending: isDeleting } = useDeleteApplicants(clubId!);
+const { mutate: updateDetailApplicants, isPending: isUpdatingStatus } = useUpdateApplicant(clubId!);

React Query 버전(v4/v5) 확인 후 isPending/isLoading 네이밍을 맞춰주세요.

Also applies to: 25-26


🏁 Script executed:

#!/bin/bash
# React Query 버전 확인 (v4/v5 여부 결정)
grep -E '"(@tanstack/react-query|react-query)":' -n package.json

Length of output: 112


🏁 Script executed:

#!/bin/bash
# package.json 위치 검색 및 React Query 버전 추출
pkg_file=$(find . -maxdepth 3 -type f -name "package.json" | head -n1)
if [ -z "$pkg_file" ]; then
  echo "package.json 파일을 찾을 수 없습니다."
  exit 1
fi
grep -E '"(@tanstack/react-query|react-query)"' -n "$pkg_file"

Length of output: 252


React Query v5 isPending 구조분해 사용
변경 중 스피너/aria-busy 연계를 위해 mutate 호출부에서 isPending을 구조분해하여 사용하세요.

- const { mutate: deleteApplicants } = useDeleteApplicants(clubId!);
- const { mutate: updateDetailApplicants } = useUpdateApplicant(clubId!);
+ const { mutate: deleteApplicants, isPending: isDeleting } = useDeleteApplicants(clubId!);
+ const { mutate: updateDetailApplicants, isPending: isUpdatingStatus } = useUpdateApplicant(clubId!);

위치: frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantsTab.tsx (12, 25–26)

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import { useUpdateApplicant } from '@/hooks/queries/applicants/useUpdateApplicant';
// at the top of ApplicantsTab.tsx
import { useDeleteApplicants } from '@/hooks/queries/applicants/useDeleteApplicants';
import { useUpdateApplicant } from '@/hooks/queries/applicants/useUpdateApplicant';
const {
mutate: deleteApplicants,
isPending: isDeleting
} = useDeleteApplicants(clubId!);
const {
mutate: updateDetailApplicants,
isPending: isUpdatingStatus
} = useUpdateApplicant(clubId!);
🤖 Prompt for AI Agents
In frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantsTab.tsx around
lines 12 and 25–26, the React Query v5 isPending flag from useUpdateApplicant
should be destructured and used to drive the UI during mutation; change the hook
usage to const { mutate, isPending } = useUpdateApplicant() (or equivalent) and
then use isPending at the mutate call site to control the spinner/aria-busy
state (replace any current boolean or indirect loading checks with isPending so
the spinner and aria-busy reflect the actual mutation pending state).


const ApplicantsTab = () => {
const navigate = useNavigate();
Expand All @@ -19,7 +20,12 @@ const ApplicantsTab = () => {
);
const [selectAll, setSelectAll] = useState(false);
const [open, setOpen] = useState(false);
const [statusOpen, setStatusOpen] = useState(false);
const [isChecked, setIsChecked] = useState(false);
const { mutate: deleteApplicants } = useDeleteApplicants(clubId!);
const { mutate: updateDetailApplicants } = useUpdateApplicant(clubId!);
const allSelectRef = useRef<HTMLDivElement | null>(null);
const statusSelectRef = useRef<HTMLDivElement | null>(null);

const filteredApplicants = useMemo(() => {
if (!applicantsData?.applicants) return [];
Expand All @@ -33,6 +39,32 @@ const ApplicantsTab = () => {
);
}, [applicantsData, keyword]);

useEffect(() => {
const handleOutsideClick = (e: MouseEvent) => {
const target = e.target as Node;

if (
open &&
allSelectRef.current &&
!allSelectRef.current.contains(target)
) {
setOpen(false);
}
if (
statusOpen &&
statusSelectRef.current &&
!statusSelectRef.current.contains(target)
) {
setStatusOpen(false);
}
};

document.addEventListener('mousedown', handleOutsideClick);
return () => {
document.removeEventListener('mousedown', handleOutsideClick);
};
}, [open, statusOpen]);

useEffect(() => {
const newMap = new Map<string, boolean>();
filteredApplicants.forEach((user: Applicant) => {
Expand All @@ -45,6 +77,7 @@ const ApplicantsTab = () => {
const all =
checkedItem.size > 0 && Array.from(checkedItem.values()).every(Boolean);
setSelectAll(all);
setIsChecked(Array.from(checkedItem.values()).some(Boolean));
}, [checkedItem]);

if (!clubId) return null;
Expand Down Expand Up @@ -80,7 +113,7 @@ const ApplicantsTab = () => {
setCheckedItem((prev) => {
const newMap = new Map(prev);

const isAllChecked = Array.from(checkedItem.values()).every(Boolean);
const isAllChecked = Array.from(prev.values()).every(Boolean);

if (mode === 'all') {
newMap.forEach((_, key) => newMap.set(key, !isAllChecked));
Expand Down Expand Up @@ -112,6 +145,27 @@ const ApplicantsTab = () => {
});
};

const updateAllApplicants = (status: ApplicationStatus) => {
updateDetailApplicants(
applicantsData!.applicants
.filter((applicant) => checkedItem.get(applicant.id))
.map((applicant) => ({
applicantId: applicant.id,
memo: applicant.memo,
status: status,
})),
{
onSuccess: () => {
checkoutAllApplicants();
setStatusOpen(false);
},
onError: () => {
alert('지원자 상태 변경에 실패했습니다. 다시 시도해주세요.');
},
},
);
};

return (
<>
<Styled.ApplicationHeader>
Expand Down Expand Up @@ -171,18 +225,52 @@ const ApplicantsTab = () => {
<Styled.Arrow src={selectIcon} />
</Styled.SelectWrapper>
<Styled.VerticalLine />
<Styled.SelectWrapper>
<Styled.StatusSelect
disabled={!Array.from(checkedItem.values()).some(Boolean)}
>
<option value='상태변경'>상태변경</option>
<Styled.SelectWrapper
ref={statusSelectRef}
onClick={() => {
if (!isChecked) return;
setStatusOpen((prev) => !prev);
}}
>
<Styled.StatusSelect disabled={!isChecked}>
상태변경
</Styled.StatusSelect>
Comment on lines +228 to 237
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

클릭 핸들러를 래퍼에서 버튼으로 이동하고 ARIA를 부여하세요.

래퍼 div에 onClick을 두면 스크린리더/키보드 접근성이 떨어집니다. 버튼에 aria-haspopup/aria-expanded를 명시하고 메뉴/항목에 role을 부여하세요. styles에서 button 전환과 함께 적용하세요.

-            <Styled.SelectWrapper
-              ref={statusSelectRef}
-              onClick={() => {
-                if (!isChecked) return;
-                setStatusOpen((prev) => !prev);
-              }}
-            >
-              <Styled.StatusSelect disabled={!isChecked}>
+            <Styled.SelectWrapper ref={statusSelectRef}>
+              <Styled.StatusSelect
+                disabled={!isChecked || isUpdatingStatus}
+                aria-haspopup="menu"
+                aria-expanded={statusOpen}
+                type="button"
+                onClick={() => setStatusOpen((prev) => !prev)}
+              >
                 상태변경
               </Styled.StatusSelect>
               <Styled.Arrow width={8} height={8} src={selectIcon} />
-              <Styled.StatusSelectMenu open={statusOpen}>
-                <Styled.StatusSelectMenuItem
+              <Styled.StatusSelectMenu open={statusOpen} role="menu">
+                <Styled.StatusSelectMenuItem
+                  role="menuitem"
                   onClick={() => {
                     updateAllApplicants(ApplicationStatus.SUBMITTED);
                   }}
                 >
                   서류검토
                 </Styled.StatusSelectMenuItem>
-                <Styled.StatusSelectMenuItem
+                <Styled.StatusSelectMenuItem
+                  role="menuitem"
                   onClick={() => {
                     updateAllApplicants(
                       ApplicationStatus.INTERVIEW_SCHEDULED,
                     );
                   }}
                 >
                   면접예정
                 </Styled.StatusSelectMenuItem>
-                <Styled.StatusSelectMenuItem
+                <Styled.StatusSelectMenuItem
+                  role="menuitem"
                   onClick={() => {
                     updateAllApplicants(ApplicationStatus.ACCEPTED);
                   }}
                 >
                   합격
                 </Styled.StatusSelectMenuItem>
-                <Styled.StatusSelectMenuItem
+                <Styled.StatusSelectMenuItem
+                  role="menuitem"
                   onClick={() => {
                     updateAllApplicants(ApplicationStatus.DECLINED);
                   }}
                 >
                   불합격
                 </Styled.StatusSelectMenuItem>
               </Styled.StatusSelectMenu>
             </Styled.SelectWrapper>

Also applies to: 241-269

🤖 Prompt for AI Agents
In frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantsTab.tsx around
lines 230-239 (also apply same changes to 241-269), move the click handler from
the wrapping Styled.SelectWrapper div to an actual button element, update styles
to render the button visually identical to the current wrapper, and add proper
ARIA attributes: aria-haspopup="menu" and aria-expanded={statusOpen} on the
button; ensure the menu and each option have appropriate roles (e.g.,
role="menu" and role="menuitem") and keyboard handlers (Enter/Space to
toggle/open and arrow/escape support as needed); remove onClick from the wrapper
and ensure the button is disabled when !isChecked so it is keyboard and
screen-reader accessible.

<Styled.Arrow width={8} height={8} src={selectIcon} />
<Styled.StatusSelectMenu open={statusOpen}>
<Styled.StatusSelectMenuItem
onClick={() => {
updateAllApplicants(ApplicationStatus.SUBMITTED);
}}
>
서류검토
</Styled.StatusSelectMenuItem>
<Styled.StatusSelectMenuItem
onClick={() => {
updateAllApplicants(ApplicationStatus.INTERVIEW_SCHEDULED);
}}
>
면접예정
</Styled.StatusSelectMenuItem>
<Styled.StatusSelectMenuItem
onClick={() => {
updateAllApplicants(ApplicationStatus.ACCEPTED);
}}
>
합격
</Styled.StatusSelectMenuItem>
<Styled.StatusSelectMenuItem
onClick={() => {
updateAllApplicants(ApplicationStatus.DECLINED);
}}
>
불합격
</Styled.StatusSelectMenuItem>
</Styled.StatusSelectMenu>
Comment on lines +239 to +268
Copy link
Member

Choose a reason for hiding this comment

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

상태 항목들이 중복되어 코드가 길어지는 느낌이에용
상태 옵션을 상수로 분리해두고 map 렌더링 하는 방식은 어떤가용?
한번 같이 생각해봐요

</Styled.SelectWrapper>
<Styled.DeleteButton
src={deleteIcon}
alt='삭제'
disabled={!Array.from(checkedItem.values()).some(Boolean)}
disabled={!isChecked}
onClick={() => {
const toBeDeleted = Array.from(checkedItem.entries())
.filter(([_, isChecked]) => isChecked)
Expand All @@ -205,7 +293,7 @@ const ApplicantsTab = () => {
<Styled.ApplicantTableHeaderWrapper>
<Styled.ApplicantTableRow>
<Styled.ApplicantTableHeader width={55}>
<Styled.ApplicantAllSelectWrapper>
<Styled.ApplicantAllSelectWrapper ref={allSelectRef}>
<Styled.ApplicantTableAllSelectCheckbox
checked={selectAll}
onClick={(e: React.MouseEvent<HTMLInputElement>) => {
Expand Down
16 changes: 11 additions & 5 deletions frontend/src/types/applicants.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AnswerItem } from "./application";
import { AnswerItem } from './application';

export enum ApplicationStatus {
SUBMITTED = 'SUBMITTED', // 제출 완료
Expand All @@ -11,14 +11,20 @@ export interface ApplicantsInfo {
total: number;
reviewRequired: number;
scheduledInterview: number;
accepted: number;
applicants: Applicant[]
accepted: number;
applicants: Applicant[];
}

export interface Applicant {
id: string;
status: ApplicationStatus;
answers: AnswerItem[]
answers: AnswerItem[];
memo: string;
createdAt: string;
}
}

export interface UpdateApplicantParams {
memo: string;
status: ApplicationStatus;
applicantId: string | undefined;
}