Skip to content
Open
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
420 changes: 420 additions & 0 deletions frontend/.claude/agents/상태관리부서.md

Large diffs are not rendered by default.

21 changes: 12 additions & 9 deletions frontend/.claude/commands/commit.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
description: 세션 작업 기록 + 기능 문서화 + 변경 내용 커밋
allowed-tools: Bash(mkdir *), Bash(ls *), Bash(date *), Bash(git status), Bash(git diff *), Bash(git log *), Bash(git add *), Bash(git commit *), Read, Write, Edit, Glob, Grep
allowed-tools: Bash(mkdir *), Bash(ls *), Bash(date *), Bash(npm run format), Bash(git status), Bash(git diff *), Bash(git log *), Bash(git add *), Bash(git commit *), Read, Write, Edit, Glob, Grep
---

# 작업 지시
Expand Down Expand Up @@ -107,15 +107,18 @@ allowed-tools: Bash(mkdir *), Bash(ls *), Bash(date *), Bash(git status), Bash(g

기록이 완료되면 커밋을 수행합니다.

1. `git status`로 변경된 파일 확인
2. `git diff HEAD`로 모든 변경사항 확인 (또는 `git diff`와 `git diff --staged`를 각각 실행)
3. `git log --oneline -5`로 최근 커밋 스타일 참고
4. 변경 내용을 분석하여 커밋 메시지 작성
5. 관련 파일만 `git add`로 스테이징
- `docs/features/` 문서 파일 포함
1. `npm run format` 실행하여 코드 포맷팅
2. `git status`로 변경된 파일 확인
3. `git diff HEAD`로 모든 변경사항 확인 (또는 `git diff`와 `git diff --staged`를 각각 실행)
4. `git log --oneline -5`로 최근 커밋 스타일 참고
5. 변경된 파일을 **기능(scope) 단위로 그룹핑**
- 같은 기능/도메인에 속하는 파일끼리 묶음 (예: store 변경, admin UI 변경, hooks 변경 등)
- 논리적으로 독립적인 변경은 별도 커밋으로 분리
- `docs/features/` 문서 파일은 관련 기능 커밋에 포함
- `dailyNote/`는 gitignore 대상이므로 제외
6. **커밋 전에 변경 내용과 커밋 메시지를 사용자에게 확인 요청**
7. 사용자 승인 후 커밋 실행
6. **커밋 전에 그룹핑 계획과 각 커밋 메시지를 사용자에게 확인 요청**
7. 사용자 승인 후 그룹별로 순서대로 커밋 실행
- 각 그룹: `git add <관련 파일들>` → `git commit -m "..."` 순으로 반복

**커밋 메시지 형식:**

Expand Down
54 changes: 54 additions & 0 deletions frontend/docs/features/store/useAdminClubStore.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# useAdminClubStore — 어드민 전역 상태 관리

`AdminClubContext`를 Zustand로 마이그레이션하여 SSE 업데이트로 인한 불필요한 리렌더링을 제거한 store.

## 배경

기존 `AdminClubContext`에는 4개 상태가 묶여 있었다:
- `clubId` — 로그인한 관리자의 클럽 ID
- `hasConsented` — 개인정보 동의 여부
- `applicantsData` — SSE로 실시간 업데이트되는 지원자 데이터
- `applicationFormId` — SSE 연결 트리거용 ID

`applicantsData`가 SSE 이벤트마다 변경되면서 Context를 구독하는 9개 컴포넌트 전부가 리렌더링되었다. `applicantsData`의 실제 소비자는 `ApplicantsTab` 하나뿐이었기 때문에 나머지 8개 컴포넌트의 리렌더링은 불필요했다.

## 구조

```
src/store/useAdminClubStore.ts — clubId, hasConsented (전역 공유 필요)
src/hooks/useApplicantSSE.ts — applicantsData + SSE 연결 (ApplicantsTab 스코프)
```
Comment thread
seongwon030 marked this conversation as resolved.

`applicationFormId`는 store에 포함하지 않는다. SSE 훅의 인자로 직접 전달한다.

## Selector 훅

```typescript
import { useAdminClubId } from '@/store/useAdminClubStore';
import { useAdminHasConsented } from '@/store/useAdminClubStore';

const { clubId, setClubId } = useAdminClubId();
const { hasConsented, setHasConsented } = useAdminHasConsented();
```

각 selector 훅은 해당 상태만 구독하므로, 다른 상태가 변경되어도 리렌더링이 발생하지 않는다.

## useApplicantSSE

```typescript
import { useApplicantSSE } from '@/hooks/useApplicantSSE';

const { applicantsData, setApplicantsData } = useApplicantSSE(applicationFormId);
```

- `applicationFormId`가 변경되면 SSE 연결을 재수립한다
- 컴포넌트 언마운트 시 SSE 연결을 자동으로 닫는다
- 에러 발생 시 2초 후 자동 재연결한다
- `setApplicantsData`로 초기 데이터(`useGetApplicants` 결과)를 주입할 수 있다

## 관련 코드

- `src/store/useAdminClubStore.ts` — Zustand store 정의
- `src/hooks/useApplicantSSE.ts` — SSE 연결 관리 및 applicantsData 상태
- `src/apis/clubSSE.ts` — EventSource 생성 유틸
- `src/pages/AdminPage/tabs/ApplicantsTab/ApplicantsTab.tsx` — useApplicantSSE 사용처
9 changes: 3 additions & 6 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from 'styled-components';
import { ScrollToTopButton } from '@/components/common/ScrollToTopButton/ScrollToTopButton';
import { AdminClubProvider } from '@/context/AdminClubContext';
import { ScrollToTop } from '@/hooks/Scroll/ScrollToTop';
import LoginTab from '@/pages/AdminPage/auth/LoginTab/LoginTab';
import PrivateRoute from '@/pages/AdminPage/auth/PrivateRoute/PrivateRoute';
Expand Down Expand Up @@ -120,11 +119,9 @@ const App = () => {
path='/admin/*'
element={
<ContentErrorBoundary>
<AdminClubProvider>
<PrivateRoute>
<AdminRoutes />
</PrivateRoute>
</AdminClubProvider>
<PrivateRoute>
<AdminRoutes />
</PrivateRoute>
</ContentErrorBoundary>
}
/>
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/common/Header/admin/AdminProfile.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import DefaultMoadongLogo from '@/assets/images/logos/default_profile_image.svg';
import { useAdminClubContext } from '@/context/AdminClubContext';
import { useGetClubDetail } from '@/hooks/Queries/useClub';
import { useAdminClubId } from '@/store/useAdminClubStore';
import * as Styled from '../Header.styles';

const AdminProfile = () => {
const { clubId } = useAdminClubContext();
const { clubId } = useAdminClubId();
const { data: clubDetail } = useGetClubDetail(clubId || '');
const { name, logo } = clubDetail || {};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,50 +1,18 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { createApplicantSSE } from '@/apis/clubSSE';
import {
ApplicantsInfo,
ApplicantStatusEvent,
ApplicationStatus,
} from '@/types/applicants';

interface AdminClubContextType {
clubId: string | null;
setClubId: (id: string | null) => void;
applicantsData: ApplicantsInfo | null;
setApplicantsData: (data: ApplicantsInfo | null) => void;
applicationFormId: string | null;
setApplicationFormId: (id: string | null) => void;
hasConsented: boolean;
setHasConsented: (value: boolean) => void;
}

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

export const AdminClubProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
const [clubId, setClubId] = useState<string | null>(null);
export const useApplicantSSE = (applicationFormId: string | undefined) => {
const [applicantsData, setApplicantsData] = useState<ApplicantsInfo | null>(
null,
);
const [applicationFormId, setApplicationFormId] = useState<string | null>(
null,
);
const [hasConsented, setHasConsented] = useState<boolean>(true);
const eventSourceRef = useRef<EventSource | null>(null);
const reconnectTimeoutRef = useRef<number | null>(null);

// SSE 이벤트 핸들러
const handleApplicantStatusChange = useCallback(
(event: ApplicantStatusEvent) => {
setApplicantsData((prevData) => {
Expand Down Expand Up @@ -77,6 +45,8 @@ export const AdminClubProvider = ({
useEffect(() => {
if (!applicationFormId) return;

setApplicantsData(null);

const sseConnect = () => {
eventSourceRef.current?.close();

Expand Down Expand Up @@ -107,29 +77,5 @@ export const AdminClubProvider = ({
};
}, [applicationFormId, handleApplicantStatusChange]);

return (
<AdminClubContext.Provider
value={{
clubId,
setClubId,
applicantsData,
setApplicantsData,
applicationFormId,
setApplicationFormId,
hasConsented,
setHasConsented,
}}
>
{children}
</AdminClubContext.Provider>
);
};

export const useAdminClubContext = () => {
const context = useContext(AdminClubContext);
if (!context)
throw new Error(
'useAdminClubContext는 AdminClubProvider 내부에서만 사용할 수 있습니다',
);
return context;
return { applicantsData, setApplicantsData };
};
17 changes: 14 additions & 3 deletions frontend/src/pages/AdminPage/AdminPage.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import { useState } from 'react';
import { Outlet } from 'react-router-dom';
import Header from '@/components/common/Header/Header';
import { useAdminClubContext } from '@/context/AdminClubContext';
import { STORAGE_KEYS } from '@/constants/storageKeys';
import { useGetClubDetail } from '@/hooks/Queries/useClub';
import PersonalInfoConsentModal from '@/pages/AdminPage/components/PersonalInfoConsentModal/PersonalInfoConsentModal';
import SideBar from '@/pages/AdminPage/components/SideBar/SideBar';
import { useAdminClubId } from '@/store/useAdminClubStore';
import * as Styled from './AdminPage.styles';

const AdminPage = () => {
const { clubId, hasConsented } = useAdminClubContext();
const { clubId } = useAdminClubId();
const [hasConsented, setHasConsented] = useState(
() =>
localStorage.getItem(STORAGE_KEYS.HAS_CONSENTED_PERSONAL_INFO) === 'true',
);
const { data: clubDetail, error } = useGetClubDetail(clubId || '');

if (!clubDetail) {
Expand All @@ -19,7 +25,12 @@ const AdminPage = () => {
return (
<>
<Header />
{!hasConsented && <PersonalInfoConsentModal clubName={clubDetail.name} />}
{!hasConsented && (
<PersonalInfoConsentModal
clubName={clubDetail.name}
onConsent={() => setHasConsented(true)}
/>
)}
<Styled.Background>
<Styled.Layout>
<SideBar />
Expand Down
12 changes: 3 additions & 9 deletions frontend/src/pages/AdminPage/auth/PrivateRoute/PrivateRoute.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,21 @@
import { useEffect } from 'react';
import { Navigate } from 'react-router-dom';
import Spinner from '@/components/common/Spinner/Spinner';
import { STORAGE_KEYS } from '@/constants/storageKeys';
import { useAdminClubContext } from '@/context/AdminClubContext';
import useAuth from '@/hooks/useAuth';
import { useAdminClubId } from '@/store/useAdminClubStore';

// import { useGetApplicants } from '@/hooks/queries/applicants/useGetApplicants';

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

useEffect(() => {
if (clubId) {
setClubId(clubId);
const consented =
localStorage.getItem(STORAGE_KEYS.HAS_CONSENTED_PERSONAL_INFO) ===
'true';
setHasConsented(consented);
}
}, [clubId, setClubId, setHasConsented]);
}, [clubId, setClubId]);

// useEffect(() => {
// if (clubId && applicantsData) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import { useRef } from 'react';
import defaultCover from '@/assets/images/logos/default_profile_image.svg';
import { ADMIN_EVENT } from '@/constants/eventName';
import { MAX_FILE_SIZE } from '@/constants/uploadLimit';
import { useAdminClubContext } from '@/context/AdminClubContext';
import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack';
import { useDeleteCover, useUploadCover } from '@/hooks/Queries/useClubCover';
import { useAdminClubId } from '@/store/useAdminClubStore';
import * as Styled from './ClubCoverEditor.styles';

interface ClubCoverEditorProps {
Expand All @@ -13,7 +13,7 @@ interface ClubCoverEditorProps {

const ClubCoverEditor = ({ coverImage }: ClubCoverEditorProps) => {
const trackEvent = useMixpanelTrack();
const { clubId } = useAdminClubContext();
const { clubId } = useAdminClubId();
const fileInputRef = useRef<HTMLInputElement>(null);

if (!clubId) return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import React, { useRef } from 'react';
import defaultLogo from '@/assets/images/logos/default_profile_image.svg';
import { ADMIN_EVENT } from '@/constants/eventName';
import { MAX_FILE_SIZE } from '@/constants/uploadLimit';
import { useAdminClubContext } from '@/context/AdminClubContext';
import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack';
import { useDeleteLogo, useUploadLogo } from '@/hooks/Queries/useClubImages';
import { useAdminClubId } from '@/store/useAdminClubStore';
import * as Styled from './ClubLogoEditor.styles';

interface ClubLogoEditorProps {
Expand All @@ -14,7 +14,7 @@ interface ClubLogoEditorProps {
const ClubLogoEditor = ({ clubLogo }: ClubLogoEditorProps) => {
const trackEvent = useMixpanelTrack();

const { clubId } = useAdminClubContext();
const { clubId } = useAdminClubId();
if (!clubId) return null;

const fileInputRef = useRef<HTMLInputElement>(null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { allowPersonalInformation } from '@/apis/auth';
import Button from '@/components/common/Button/Button';
import PortalModal from '@/components/common/Modal/PortalModal';
import { STORAGE_KEYS } from '@/constants/storageKeys';
import { useAdminClubContext } from '@/context/AdminClubContext';
import * as Styled from './PersonalInfoConsentModal.styles';

const GUIDE_ITEMS = [
Expand All @@ -20,12 +19,13 @@ const GUIDE_ITEMS = [

interface PersonalInfoConsentModalProps {
clubName: string;
onConsent: () => void;
}

const PersonalInfoConsentModal = ({
clubName,
onConsent,
}: PersonalInfoConsentModalProps) => {
const { setHasConsented } = useAdminClubContext();
const [loading, setLoading] = useState(false);

const handleConsent = async () => {
Expand All @@ -34,7 +34,7 @@ const PersonalInfoConsentModal = ({
try {
await allowPersonalInformation();
localStorage.setItem(STORAGE_KEYS.HAS_CONSENTED_PERSONAL_INFO, 'true');
setHasConsented(true);
onConsent();
} catch (error) {
console.error('서비스 동의 실패:', error);
alert('동의 처리에 실패했습니다. 다시 시도해주세요.');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ import PrevApplicantButton from '@/assets/images/icons/prev_applicant.svg';
import Header from '@/components/common/Header/Header';
import Spinner from '@/components/common/Spinner/Spinner';
import { AVAILABLE_STATUSES } from '@/constants/status';
import { useAdminClubContext } from '@/context/AdminClubContext';
import {
useGetApplicants,
useUpdateApplicant,
} from '@/hooks/Queries/useApplicants';
import { useGetApplication } from '@/hooks/Queries/useApplication';
import QuestionAnswerer from '@/pages/ApplicationFormPage/components/QuestionAnswerer/QuestionAnswerer';
import QuestionContainer from '@/pages/ApplicationFormPage/components/QuestionContainer/QuestionContainer';
import { useAdminClubId } from '@/store/useAdminClubStore';
import { ApplicationStatus } from '@/types/applicants';
import { Question } from '@/types/application';
import mapStatusToGroup from '@/utils/mapStatusToGroup';
Expand Down Expand Up @@ -51,7 +51,7 @@ const ApplicantDetailPage = () => {
const [applicantStatus, setApplicantStatus] = useState<ApplicationStatus>(
ApplicationStatus.SUBMITTED,
);
const { clubId } = useAdminClubContext();
const { clubId } = useAdminClubId();
const {
data: applicantsData,
isLoading: isApplicantsLoading,
Expand Down
Loading
Loading