Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
229a341
feat: 지원 관리 탭 추가 및 사이드바 라벨 수정
lepitaaar Jul 28, 2025
b29c835
feat: 클럽 지원자 정보를 가져오는 API 및 훅 추가
lepitaaar Jul 28, 2025
78b3402
feat: 지원자 상태 및 정보 타입 정의 추가
lepitaaar Jul 28, 2025
73c3381
feat: 지원자 목록 및 통계 카드 UI 추가
lepitaaar Jul 28, 2025
808d00e
feat: 지원자 상세 페이지 추가 및 관리자 클럽 컨텍스트에 지원자 데이터 상태 관리 기능 추가
lepitaaar Jul 30, 2025
6f7264e
feat: 지원자 관리 탭에 지원자 상세 페이지 추가 및 사이드바 라벨 수정
lepitaaar Jul 30, 2025
2519381
refactor: 지원자 상태 관련 한국어 상수 정의 제거
lepitaaar Jul 30, 2025
2b6f5cd
refactor: 지원서관리탭 스타일파일 이름 수정
seongwon030 Jul 30, 2025
cf6cf25
feat: snsLink 이벤트 추적 추가
seongwon030 Aug 2, 2025
95a6005
feat: 검색 시 전체 동아리 기준으로 검색하도록 수정
seongwon030 Aug 2, 2025
b507b1d
refactor: SNS 링크 클릭 이벤트를 아이콘에서 링크로 이동
seongwon030 Aug 3, 2025
14a4bcd
feat: SnsLinkIcons 컴포넌트에 clubName props 추가
seongwon030 Aug 3, 2025
7853a0c
refactor: ShareButton 이벤트 이름을 '공유하기 버튼 클릭'으로 통일하고 clubName을 필드로 분리
seongwon030 Aug 3, 2025
307f2f0
Merge pull request #616 from Moadong/feature/#615-search-all-clubs-MO…
seongwon030 Aug 3, 2025
c431434
Merge pull request #614 from Moadong/feature/#613-sns-link-track-even…
seongwon030 Aug 3, 2025
cbc1850
refactor: 지원자 상세 페이지에서 질문 ID를 지원자 ID로 변경하고, 지원자 상태 매핑 함수 추가
lepitaaar Aug 6, 2025
99f7032
style: 사이드바 스타일 파일에서 불필요한 공백 제거
lepitaaar Aug 6, 2025
c926e54
fix: 사이드바에서 로그아웃 시 refreshToken 쿠키 확인 후 로그아웃 처리 추가
lepitaaar Aug 6, 2025
1129623
Merge pull request #625 from Moadong/fix/logout-fail
lepitaaar Aug 6, 2025
5382f19
Merge pull request #612 from Moadong/feature/info-applicants
lepitaaar Aug 7, 2025
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
10 changes: 10 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import LoginTab from '@/pages/AdminPage/auth/LoginTab/LoginTab';
import PrivateRoute from '@/pages/AdminPage/auth/PrivateRoute/PrivateRoute';
import PhotoEditTab from '@/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab';
import ApplicationFormPage from './pages/ApplicationFormPage/ApplicationFormPage';
import ApplicantsTab from './pages/AdminPage/tabs/ApplicantsTab/ApplicantsTab';
import ApplicantDetailPage from './pages/AdminPage/tabs/ApplicantsTab/ApplicantDetailPage';

const queryClient = new QueryClient();

Expand Down Expand Up @@ -76,6 +78,14 @@ const App = () => {
path='application-edit'
element={<ApplicationEditTab />}
/>
<Route
path='applicants'
element={<ApplicantsTab />}
/>
<Route
path='applicants/:questionId'
element={<ApplicantDetailPage />}
/>
Comment on lines +85 to +88
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

URL 파라미터 명 확인 필요: :questionId:applicantId?

개별 지원자 상세를 보여주는 페이지라면 파라미터명이 questionId보다는 applicantId(또는 유사 도메인명)가 자연스럽습니다. 내부에서 useParams로 어떤 키를 읽는지 확인해 주세요. 불일치 시 상세 페이지가 파라미터를 못 읽습니다.

다음 스크립트로 상세 페이지의 파라미터 사용을 점검할 수 있습니다(레포 루트에서 실행):

수정이 필요하다면:

-<Route 
-  path='applicants/:questionId'
-  element={<ApplicantDetailPage />}
-/>
+<Route 
+  path='applicants/:applicantId'
+  element={<ApplicantDetailPage />}
+/>

🏁 Script executed:

#!/bin/bash
# ApplicantDetailPage에서 사용하는 useParams 키 확인 및
# 'applicants/' 링크 생성부에서 어떤 파라미터명을 쓰는지 검색
rg -n "useParams|questionId|applicantId" frontend/src/pages/AdminPage/tabs/ApplicantsTab -A 4 -B 2
rg -n "applicants/:" frontend/src -A 2 -B 2
rg -n "to=.*applicants/\\$\\{.*\\}" frontend/src -A 2 -B 2

Length of output: 2998


파라미터명 일관성 확인 및 도메인 용어 구체화 제안

현재 RouteuseParams 모두 :questionId / questionId로 일관되게 쓰이고 있어 런타임 이슈는 없으나, 컴포넌트명이 ApplicantDetailPage임을 감안하면 파라미터명은 applicantId가 더 적합합니다. 아래 파일을 중심으로 네이밍을 통일해주세요.

• frontend/src/App.tsx
• frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantDetailPage.tsx
• (링크 생성부) frontend/src/pages/AdminPage/tabs/ApplicantsTab.tsx

예시 diff:

--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -85,4 +85,4 @@
-  <Route
-    path='applicants/:questionId'
-    element={<ApplicantDetailPage />}
-  />
+  <Route
+    path='applicants/:applicantId'
+    element={<ApplicantDetailPage />}
+  />
--- a/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantDetailPage.tsx
@@ -14,7 +14,7 @@
-  const { questionId } = useParams<{ questionId: string }>();
+  const { applicantId } = useParams<{ applicantId: string }>();

  // 지원자 찾기
- const applicant = applicantsData.applicants.find(a => a.id === questionId);
+ const applicant = applicantsData.applicants.find(a => a.id === applicantId);
  • ApplicantsTab.tsx에서 상세 페이지로 이동하는 링크/navigate() 호출부의 템플릿 리터럴도 동일하게 applicants/${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
<Route
path='applicants/:questionId'
element={<ApplicantDetailPage />}
/>
<Route
- path='applicants/:questionId'
+ path='applicants/:applicantId'
element={<ApplicantDetailPage />}
/>
Suggested change
<Route
path='applicants/:questionId'
element={<ApplicantDetailPage />}
/>
- const { questionId } = useParams<{ questionId: string }>();
+ const { applicantId } = useParams<{ applicantId: string }>();
// 지원자 찾기
- const applicant = applicantsData.applicants.find(a => a.id === questionId);
+ const applicant = applicantsData.applicants.find(a => a.id === applicantId);
🤖 Prompt for AI Agents
In frontend/src/App.tsx lines 85 to 88, rename the route parameter from
:questionId to :applicantId to better reflect the domain and component purpose.
Then, update all related references in
frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantDetailPage.tsx and
frontend/src/pages/AdminPage/tabs/ApplicantsTab.tsx, including useParams usage
and link or navigate calls, to consistently use applicantId instead of
questionId. This ensures parameter naming consistency and clarity across routing
and component code.

</Route>
</Routes>
</PrivateRoute>
Expand Down
20 changes: 20 additions & 0 deletions frontend/src/apis/applicants/getClubApplicants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import API_BASE_URL from '@/constants/api';
import { secureFetch } from '../auth/secureFetch';

const getClubApplicants = async (clubId: string) => {
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

반환 타입을 명시하고 도메인 타입을 사용하세요

동일 계층 API 함수들과의 일관성을 위해 명시적 반환 타입을 지정하고, applicants 도메인 타입을 재사용하는 편이 안전합니다.

예시(정확한 타입명은 frontend/src/types/applicants.ts에 맞춰 주세요):

-const getClubApplicants = async (clubId: string) => {
+import type { ApplicantsData } from '@/types/applicants';
+const getClubApplicants = async (clubId: string): Promise<ApplicantsData[]> => {
📝 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
const getClubApplicants = async (clubId: string) => {
import type { ApplicantsData } from '@/types/applicants';
const getClubApplicants = async (clubId: string): Promise<ApplicantsData[]> => {
🤖 Prompt for AI Agents
In frontend/src/apis/applicants/getClubApplicants.ts at line 4, the function
getClubApplicants lacks an explicit return type and does not reuse the
applicants domain type. To fix this, import the appropriate applicants domain
type from frontend/src/types/applicants.ts and specify it as the return type of
the async function to ensure type safety and consistency with other API
functions in the same layer.

try {
const response = await secureFetch(`${API_BASE_URL}/api/club/${clubId}/apply/info`);
if (!response.ok) {
console.error(`Failed to fetch: ${response.statusText}`)
throw new Error((await response.json()).message);
}

const result = await response.json();
return result.data;
Comment on lines +7 to +13
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

에러 응답 JSON 파싱 실패 대비 및 메시지 폴백 추가

서버가 비-JSON 응답을 돌려줄 경우 response.json()에서 추가 예외가 나면서 원인 파악이 어려워질 수 있습니다. 폴백 메시지를 두고 파싱 실패를 무시하는 것이 안전합니다.

[security]

-    if (!response.ok) {
-      console.error(`Failed to fetch: ${response.statusText}`)
-      throw new Error((await response.json()).message);
-    }
-
-    const result = await response.json();
-    return result.data;
+    if (!response.ok) {
+      console.error(`Failed to fetch: ${response.status} ${response.statusText}`);
+      let message = `요청 실패: ${response.status} ${response.statusText}`;
+      try {
+        const errBody = await response.json();
+        if (errBody?.message) message = errBody.message;
+      } catch {
+        // ignore JSON parse error
+      }
+      throw new Error(message);
+    }
+
+    const result = await response.json();
+    return result.data;

참고: 공용 API 유틸 층에서 콘솔 에러를 직접 찍는 대신(숨은 부작용), 호출 측에서 로깅/토스트를 책임지도록 위임하는 것도 고려해볼 만합니다.

📝 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
if (!response.ok) {
console.error(`Failed to fetch: ${response.statusText}`)
throw new Error((await response.json()).message);
}
const result = await response.json();
return result.data;
if (!response.ok) {
console.error(`Failed to fetch: ${response.status} ${response.statusText}`);
let message = `요청 실패: ${response.status} ${response.statusText}`;
try {
const errBody = await response.json();
if (errBody?.message) message = errBody.message;
} catch {
// ignore JSON parse error
}
throw new Error(message);
}
const result = await response.json();
return result.data;
🤖 Prompt for AI Agents
In frontend/src/apis/applicants/getClubApplicants.ts around lines 7 to 13, the
code calls response.json() directly on error responses, which can throw if the
response is not valid JSON. To fix this, wrap the JSON parsing in a try-catch
block and provide a fallback error message if parsing fails. Also, consider
removing direct console.error calls here and delegate error logging or user
notification to the caller to avoid hidden side effects.

} catch (error) {
console.error('Error fetching club applicants', error);
throw error;
}
};

export default getClubApplicants;
12 changes: 9 additions & 3 deletions frontend/src/components/common/SearchBox/SearchBox.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { useRef, useState } from 'react';
import { useSearch } from '@/context/SearchContext';
import { useCategory } from '@/context/CategoryContext';
import useMixpanelTrack from '@/hooks/useMixpanelTrack';
import * as Styled from './SearchBox.styles';
import SearchIcon from '@/assets/images/icons/search_button_icon.svg';
import { useLocation, useNavigate } from 'react-router-dom';

const SearchBox = () => {
const [isSearchBoxClicked, setIsSearchBoxClicked] = useState(false);
const { setKeyword, inputValue, setInputValue } = useSearch();
const { setKeyword, inputValue, setInputValue, setIsSearching } = useSearch();
const { setSelectedCategory } = useCategory();
const trackEvent = useMixpanelTrack();
const navigate = useNavigate();
const location = useLocation();
Expand All @@ -23,6 +25,8 @@ const SearchBox = () => {
const handleSearch = () => {
redirectToHome();
setKeyword(inputValue);
setSelectedCategory('all');
setIsSearching(true);

inputRef.current?.blur();

Expand All @@ -40,7 +44,8 @@ const SearchBox = () => {
return (
<Styled.SearchBoxContainer
$isFocused={isSearchBoxClicked}
onSubmit={handleSubmit}>
onSubmit={handleSubmit}
>
<Styled.SearchInputStyles
ref={inputRef}
type='text'
Expand All @@ -54,7 +59,8 @@ const SearchBox = () => {
<Styled.SearchButton
type='submit'
$isFocused={isSearchBoxClicked}
aria-label='검색'>
aria-label='검색'
>
<img src={SearchIcon} alt='Search Button' />
</Styled.SearchButton>
</Styled.SearchBoxContainer>
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/context/AdminClubContext.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { createContext, useContext, useState } from 'react';
import { ApplicantsInfo } from '@/types/applicants';

interface AdminClubContextType {
clubId: string | null;
setClubId: (id: string | null) => void;
applicantsData: ApplicantsInfo | null;
setApplicantsData: (data: ApplicantsInfo | null) => void;
}

const AdminClubContext = createContext<AdminClubContextType | undefined>(
undefined,
undefined
);

export const AdminClubProvider = ({
Expand All @@ -15,9 +18,10 @@ export const AdminClubProvider = ({
children: React.ReactNode;
}) => {
const [clubId, setClubId] = useState<string | null>(null);
const [applicantsData, setApplicantsData] = useState<ApplicantsInfo | null>(null);

return (
<AdminClubContext.Provider value={{ clubId, setClubId }}>
<AdminClubContext.Provider value={{ clubId, setClubId, applicantsData, setApplicantsData }}>
{children}
</AdminClubContext.Provider>
);
Expand Down
8 changes: 7 additions & 1 deletion frontend/src/context/SearchContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ interface SearchContextType {
setKeyword: (keyword: string) => void;
inputValue: string;
setInputValue: (value: string) => void;
isSearching: boolean;
setIsSearching: (isSearching: boolean) => void;
}

interface SearchProviderProps {
Expand All @@ -16,6 +18,7 @@ const SearchContext = createContext<SearchContextType | undefined>(undefined);
export const SearchProvider = ({ children }: SearchProviderProps) => {
const [keyword, setKeyword] = useState<string>('');
const [inputValue, setInputValue] = useState('');
const [isSearching, setIsSearching] = useState(false);

return (
<SearchContext.Provider
Expand All @@ -24,7 +27,10 @@ export const SearchProvider = ({ children }: SearchProviderProps) => {
setKeyword,
inputValue,
setInputValue,
}}>
isSearching,
setIsSearching,
}}
>
{children}
</SearchContext.Provider>
);
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/hooks/queries/applicants/useGetApplicants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import getClubApplicants from "@/apis/applicants/getClubApplicants"
import { useQuery } from "@tanstack/react-query"

export const useGetApplicants = (clubId: string) => {
return useQuery({
queryKey: ['clubApplicants', clubId],
queryFn: () => getClubApplicants(clubId),
retry: false,
})
}
Comment on lines +4 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.

⚠️ Potential issue

빈 clubId로 인한 불필요/오류 요청 방지 및 타입 강화 필요

현재 clubId가 빈 문자열일 때도 쿼리가 실행되어 API 오류 가능성이 있습니다. enabled 가드를 추가하고 반환 타입을 명시하세요.

-import getClubApplicants from "@/apis/applicants/getClubApplicants"
-import { useQuery } from "@tanstack/react-query"
+import getClubApplicants from "@/apis/applicants/getClubApplicants"
+import { useQuery } from "@tanstack/react-query"
+import { ApplicantsInfo } from "@/types/applicants"

-export const useGetApplicants = (clubId: string) => {
-  return useQuery({
+export const useGetApplicants = (clubId: string) => {
+  return useQuery<ApplicantsInfo, Error>({
     queryKey: ['clubApplicants', clubId],
     queryFn: () => getClubApplicants(clubId),
+    enabled: Boolean(clubId),
     retry: false,
   })
 }

참고: 기존 호출부(PrivateRoute) 변경 없이 안전해집니다. enabled가 false면 네트워크 호출이 발생하지 않습니다.

📝 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 useGetApplicants = (clubId: string) => {
return useQuery({
queryKey: ['clubApplicants', clubId],
queryFn: () => getClubApplicants(clubId),
retry: false,
})
}
import getClubApplicants from "@/apis/applicants/getClubApplicants"
import { useQuery } from "@tanstack/react-query"
import { ApplicantsInfo } from "@/types/applicants"
export const useGetApplicants = (clubId: string) => {
return useQuery<ApplicantsInfo, Error>({
queryKey: ['clubApplicants', clubId],
queryFn: () => getClubApplicants(clubId),
enabled: Boolean(clubId),
retry: false,
})
}
🤖 Prompt for AI Agents
In frontend/src/hooks/queries/applicants/useGetApplicants.ts around lines 4 to
10, the query runs even when clubId is an empty string, causing unnecessary or
error-prone API calls. Fix this by adding an 'enabled' option to the useQuery
call that disables the query when clubId is empty. Also, explicitly specify the
return type of the hook to strengthen type safety without changing existing call
sites.

14 changes: 12 additions & 2 deletions frontend/src/pages/AdminPage/auth/PrivateRoute/PrivateRoute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,25 @@ import useAuth from '@/hooks/useAuth';
import { Navigate } from 'react-router-dom';
import { useAdminClubContext } from '@/context/AdminClubContext';
import Spinner from '@/components/common/Spinner/Spinner';
import { useGetApplicants } from '@/hooks/queries/applicants/useGetApplicants';

const PrivateRoute = ({ children }: { children: React.ReactNode }) => {
const { isLoading, isAuthenticated, clubId } = useAuth();
const { setClubId } = useAdminClubContext();
const { setClubId, setApplicantsData } = useAdminClubContext();
const { data: applicantsData } = useGetApplicants(clubId ?? '');

useEffect(() => {
if (clubId) setClubId(clubId);
if (clubId) {
setClubId(clubId);
}
}, [clubId, setClubId]);

useEffect(() => {
if (clubId && applicantsData) {
setApplicantsData(applicantsData);
}
}, [clubId, applicantsData]);
Comment on lines +19 to +23
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

의존성 누락 및 이전 동아리 데이터 잔존 위험

useEffect의 의존성 배열에 setApplicantsData가 누락되어 있고, clubId 변경 시 이전 동아리의 지원자 데이터가 잠시 남아 있을 수 있습니다. 의존성 보강과 clubId 미존재 시 초기화 로직을 추가하세요.

-useEffect(() => {
-  if (clubId && applicantsData) {
-    setApplicantsData(applicantsData);
-  }
-}, [clubId, applicantsData]);
+useEffect(() => {
+  if (!clubId) {
+    setApplicantsData(null);
+    return;
+  }
+  if (applicantsData) {
+    setApplicantsData(applicantsData);
+  }
+}, [clubId, applicantsData, setApplicantsData]);
🤖 Prompt for AI Agents
In frontend/src/pages/AdminPage/auth/PrivateRoute/PrivateRoute.tsx around lines
19 to 23, the useEffect hook is missing setApplicantsData in its dependency
array, and when clubId changes, previous club applicants data may persist
temporarily. Add setApplicantsData to the dependency array and include logic to
clear applicantsData (e.g., set to null or empty) when clubId is not present to
prevent stale data display.


if (isLoading) return <Spinner />;
if (!isAuthenticated) return <Navigate to='/admin/login' replace />;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ export const SidebarWrapper = styled.aside`
word-wrap: break-word;
overflow-wrap: break-word;
white-space: normal;

width: 168px;
`;

Expand Down
7 changes: 5 additions & 2 deletions frontend/src/pages/AdminPage/components/SideBar/SideBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ const tabs = [
{ label: '기본 정보 수정', path: '/admin/club-info' },
{ label: '모집 정보 수정', path: '/admin/recruit-edit' },
{ label: '활동 사진 수정', path: '/admin/photo-edit' },
{ label: '지원 관리', path: '/admin/application-edit' },
{ label: '지원서 관리', path: '/admin/application-edit' },
{ label: '지원자 관리', path: '/admin/applicants' },
{ label: '계정 관리', path: '/admin/account-edit' },
];

Expand All @@ -40,7 +41,9 @@ const SideBar = ({ clubLogo, clubName }: SideBarProps) => {
if (!confirmed) return;

try {
await logout();
if (document.cookie.split(';').some((cookie) => cookie.trim().startsWith('refreshToken='))) {
await logout();
}
Comment on lines +44 to +46
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

치명적: HttpOnly 쿠키 환경에서 로그아웃 API가 건너뛰어질 수 있습니다

document.cookierefreshToken 존재 여부를 판단하면, HttpOnly 쿠키인 경우 항상 보이지 않아 로그아웃 요청을 호출하지 않게 됩니다. 서버가 쿠키 제거를 담당하도록, 조건 없이 로그아웃 API를 시도하고 실패는 무시한 뒤 클라이언트 상태를 정리하는 방식이 안전합니다.

[security]

아래와 같이 수정하면 네트워크/서버 오류와 무관하게 로컬 상태는 정리되고, 쿠키 제거는 가능한 경우에만 서버가 처리합니다.

-      if (document.cookie.split(';').some((cookie) => cookie.trim().startsWith('refreshToken='))) {
-        await logout();
-      }
+      await logout().catch(() => {
+        // 서버 쿠키/세션 정리 실패는 무시하고 클라이언트 상태를 우선 정리
+      });

추가적으로, 보다 견고하게 하려면 토큰 제거 및 이동을 finally로 옮기는 것도 고려해 주세요.

try {
  await logout();
} catch {
  // noop
} finally {
  localStorage.removeItem('accessToken');
  navigate('/admin/login', { replace: true });
}
🤖 Prompt for AI Agents
In frontend/src/pages/AdminPage/components/SideBar/SideBar.tsx around lines 44
to 46, the current code checks for the presence of the refreshToken cookie using
document.cookie, which fails for HttpOnly cookies and may skip the logout API
call. Remove the cookie existence check and always call the logout API inside a
try-catch block to ignore any errors. Then, in a finally block, clear the local
accessToken from localStorage and navigate to the login page to ensure client
state is cleaned regardless of logout success.

localStorage.removeItem('accessToken');
navigate('/admin/login', { replace: true });
} catch (error) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import React from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useAdminClubContext } from '@/context/AdminClubContext';
import Header from '@/components/common/Header/Header';
import { PageContainer } from '@/styles/PageContainer.styles';
import * as Styled from '@/pages/ApplicationFormPage/ApplicationFormPage.styles';
import QuestionContainer from '@/pages/ApplicationFormPage/components/QuestionContainer/QuestionContainer';
import QuestionAnswerer from '@/pages/ApplicationFormPage/components/QuestionAnswerer/QuestionAnswerer';
import { useGetApplication } from '@/hooks/queries/application/useGetApplication';
import Spinner from '@/components/common/Spinner/Spinner';
import backButtonIcon from '@/assets/images/icons/back_button_icon.svg';


const ApplicantDetailPage = () => {
const { questionId } = useParams<{ questionId: string }>();
const navigate = useNavigate();
const { applicantsData, clubId } = useAdminClubContext();

Comment on lines +15 to +18
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

URL 파라미터 명칭이 혼란을 유발합니다

questionId라는 이름으로 URL 파라미터를 받지만 실제로는 지원자 id를 의미합니다. 의미가 다른 용어를 혼용하면 추후 유지보수 시 혼란이 생길 수 있으므로 applicantId 등 보다 명확한 이름으로 변경하는 것을 권장합니다.

🤖 Prompt for AI Agents
In frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantDetailPage.tsx
around lines 15 to 18, the URL parameter named questionId is misleading because
it actually represents an applicant's id. Rename the parameter from questionId
to applicantId in the useParams hook and update all related references in the
file to use applicantId for clarity and maintainability.

// 지원서 질문 목록 fetch
const { data: formData, isLoading, isError } = useGetApplication(clubId!);

Comment on lines +20 to +21
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

clubId! 단언 사용 지양

clubIdundefined일 가능성을 완전히 배제할 수 없다면 non-null 단언보다는 앞단에서 null 체크 후 조기 리턴하거나 훅 호출 자체를 조건부로 분기해 주세요. 런타임에서 API 호출이 실패할 위험이 있습니다.

🤖 Prompt for AI Agents
In frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantDetailPage.tsx
around lines 20 to 21, avoid using the non-null assertion operator on clubId
when calling useGetApplication. Instead, add a null check for clubId before this
line and return early or conditionally call the hook only if clubId is defined
to prevent potential runtime errors from passing undefined to the hook.

if (!applicantsData) {
return <div>지원자 데이터를 불러올 수 없습니다.</div>;
}
if (isLoading) return <Spinner />;
if (isError || !formData) return <div>지원서 정보를 불러올 수 없습니다.</div>;

// questionId로 지원자 찾기
const applicant = applicantsData.applicants.find(
(a) => a.id === questionId
);
if (!applicant) {
return <div>해당 지원자를 찾을 수 없습니다.</div>;
}

// 답변 매핑 함수
const getAnswerByQuestionId = (qId: number) => {
return applicant.answers
.filter((ans) => ans.id === qId)
.map((ans) => ans.value);
};

return (
<>
<Header />
<PageContainer style={{ paddingTop: '80px' }}>
{/* FormTitle과 백아이콘을 한 줄에 배치 */}
<div
style={{
position: 'sticky',
top: 25,
zIndex: 10,
background: '#fff',
display: 'flex',
alignItems: 'center',
gap: 12,
marginBottom: 16,
}}
>
<button
onClick={() => navigate(-1)}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0 }}
aria-label="뒤로가기"
>
<img src={backButtonIcon} alt="뒤로가기" style={{ width: 16, height: 16 }} />
</button>
</div>
{/* 커서 고정 */}
<Styled.QuestionsWrapper style={{ cursor: 'default' }}>
{formData.questions.map((q: import('@/types/application').Question, i: number) => (
<QuestionContainer key={q.id} hasError={false}>
<QuestionAnswerer
question={q}
selectedAnswers={getAnswerByQuestionId(q.id)}
onChange={() => {}}
/>
</QuestionContainer>
))}
</Styled.QuestionsWrapper>
</PageContainer>
</>
);
};

export default ApplicantDetailPage;
Loading