diff --git a/frontend/package-lock.json b/frontend/package-lock.json index dec18283a..eae25ed58 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,6 +15,7 @@ "@tanstack/react-query": "^5.66.0", "date-fns": "^4.1.0", "dotenv-webpack": "^8.1.0", + "eventsource": "^4.1.0", "framer-motion": "^12.23.12", "jest-fixed-jsdom": "^0.0.9", "mixpanel-browser": "^2.60.0", @@ -58,7 +59,6 @@ "css-loader": "^7.1.2", "css-minimizer-webpack-plugin": "^7.0.0", "detect-port": "^2.1.0", - "dotenv-cli": "^11.0.0", "esbuild-loader": "^4.3.0", "eslint": "^9.17.0", "eslint-config-prettier": "^10.1.5", @@ -75,6 +75,7 @@ "path": "^0.12.7", "prettier": "^3.4.2", "react-refresh": "^0.16.0", + "rollup-plugin-visualizer": "^5.14.0", "storybook": "^10.1.11", "style-loader": "^4.0.0", "tough-cookie": "^6.0.0", @@ -7397,35 +7398,6 @@ "url": "https://dotenvx.com" } }, - "node_modules/dotenv-cli": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/dotenv-cli/-/dotenv-cli-11.0.0.tgz", - "integrity": "sha512-r5pA8idbk7GFWuHEU7trSTflWcdBpQEK+Aw17UrSHjS6CReuhrrPcyC3zcQBPQvhArRHnBo/h6eLH1fkCvNlww==", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.6", - "dotenv": "^17.1.0", - "dotenv-expand": "^12.0.0", - "minimist": "^1.2.6" - }, - "bin": { - "dotenv": "cli.js" - } - }, - "node_modules/dotenv-cli/node_modules/dotenv": { - "version": "17.2.3", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", - "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, "node_modules/dotenv-defaults": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/dotenv-defaults/-/dotenv-defaults-2.0.2.tgz", @@ -7444,22 +7416,6 @@ "node": ">=10" } }, - "node_modules/dotenv-expand": { - "version": "12.0.3", - "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.3.tgz", - "integrity": "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "dotenv": "^16.4.5" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, "node_modules/dotenv-webpack": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/dotenv-webpack/-/dotenv-webpack-8.1.0.tgz", @@ -8804,6 +8760,27 @@ "node": ">=0.8.x" } }, + "node_modules/eventsource": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-4.1.0.tgz", + "integrity": "sha512-2GuF51iuHX6A9xdTccMTsNb7VO0lHZihApxhvQzJB5A03DvHDd2FQepodbMaztPBmBcE/ox7o2gqaxGhYB9LhQ==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -16185,6 +16162,117 @@ "fsevents": "~2.3.2" } }, + "node_modules/rollup-plugin-visualizer": { + "version": "5.14.0", + "resolved": "https://registry.npmjs.org/rollup-plugin-visualizer/-/rollup-plugin-visualizer-5.14.0.tgz", + "integrity": "sha512-VlDXneTDaKsHIw8yzJAFWtrzguoJ/LnQ+lMpoVfYJ3jJF4Ihe5oYLAqLklIK/35lgUY+1yEzCkHyZ1j4A5w5fA==", + "dev": true, + "license": "MIT", + "dependencies": { + "open": "^8.4.0", + "picomatch": "^4.0.2", + "source-map": "^0.7.4", + "yargs": "^17.5.1" + }, + "bin": { + "rollup-plugin-visualizer": "dist/bin/cli.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "rolldown": "1.x", + "rollup": "2.x || 3.x || 4.x" + }, + "peerDependenciesMeta": { + "rolldown": { + "optional": true + }, + "rollup": { + "optional": true + } + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, "node_modules/rrdom": { "version": "2.0.0-alpha.18", "resolved": "https://registry.npmjs.org/rrdom/-/rrdom-2.0.0-alpha.18.tgz", diff --git a/frontend/package.json b/frontend/package.json index b911f1502..ced01971f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,6 +29,7 @@ "@tanstack/react-query": "^5.66.0", "date-fns": "^4.1.0", "dotenv-webpack": "^8.1.0", + "eventsource": "^4.1.0", "framer-motion": "^12.23.12", "jest-fixed-jsdom": "^0.0.9", "mixpanel-browser": "^2.60.0", diff --git a/frontend/src/apis/clubSSE.ts b/frontend/src/apis/clubSSE.ts new file mode 100644 index 000000000..559bb7feb --- /dev/null +++ b/frontend/src/apis/clubSSE.ts @@ -0,0 +1,54 @@ +import { EventSource } from 'eventsource'; +import API_BASE_URL from '@/constants/api'; +import { + ApplicantSSECallbacks, + ApplicantStatusEvent, +} from '@/types/applicants'; + +export const createApplicantSSE = ( + applicationFormId: string, + eventHandlers: ApplicantSSECallbacks, +): EventSource | null => { + let eventSource: EventSource | null = null; + + const connect = (): EventSource | null => { + const accessToken = localStorage.getItem('accessToken'); + if (!accessToken) return null; + + const source = new EventSource( + `${API_BASE_URL}/api/club/applicant/${applicationFormId}/sse`, + { + fetch: (input, init) => + fetch(input, { + ...init, + headers: { + ...init?.headers, + Authorization: `Bearer ${accessToken}`, + }, + credentials: 'include', + }), + }, + ); + + source.addEventListener('applicant-status-changed', (e) => { + try { + const eventData: ApplicantStatusEvent = JSON.parse(e.data); + eventHandlers.onStatusChange(eventData); + } catch (parseError) { + console.error('SSE PARSING ERROR:', parseError); + } + }); + + source.onerror = (error) => { + source.close(); + eventHandlers.onError( + new Error(error?.message || 'SSE connection error'), + ); + }; + + return source; + }; + + eventSource = connect(); + return eventSource; +}; diff --git a/frontend/src/context/AdminClubContext.tsx b/frontend/src/context/AdminClubContext.tsx index 449b7aa55..b379ec593 100644 --- a/frontend/src/context/AdminClubContext.tsx +++ b/frontend/src/context/AdminClubContext.tsx @@ -1,11 +1,25 @@ -import { createContext, useContext, useState } from 'react'; -import { ApplicantsInfo } from '@/types/applicants'; +import { + createContext, + useCallback, + useContext, + 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; } const AdminClubContext = createContext( @@ -21,6 +35,74 @@ export const AdminClubProvider = ({ const [applicantsData, setApplicantsData] = useState( null, ); + const [applicationFormId, setApplicationFormId] = useState( + null, + ); + const eventSourceRef = useRef(null); + const reconnectTimeoutRef = useRef(null); + + // SSE 이벤트 핸들러 + const handleApplicantStatusChange = useCallback( + (event: ApplicantStatusEvent) => { + setApplicantsData((prevData) => { + if (!prevData) return null; + + const updatedApplicants = prevData.applicants.map((applicant) => + applicant.id === event.applicantId + ? { ...applicant, status: event.status, memo: event.memo } + : applicant, + ); + + return { + ...prevData, + applicants: updatedApplicants, + reviewRequired: updatedApplicants.filter( + (a) => a.status === ApplicationStatus.SUBMITTED, + ).length, + scheduledInterview: updatedApplicants.filter( + (a) => a.status === ApplicationStatus.INTERVIEW_SCHEDULED, + ).length, + accepted: updatedApplicants.filter( + (a) => a.status === ApplicationStatus.ACCEPTED, + ).length, + }; + }); + }, + [], + ); + + useEffect(() => { + if (!applicationFormId) return; + + const sseConnect = () => { + eventSourceRef.current?.close(); + + eventSourceRef.current = createApplicantSSE(applicationFormId, { + onStatusChange: handleApplicantStatusChange, + onError: (error) => { + console.error('SSE connection error:', error); + + if (reconnectTimeoutRef.current) return; + + reconnectTimeoutRef.current = window.setTimeout(() => { + reconnectTimeoutRef.current = null; + sseConnect(); + }, 2000); + }, + }); + }; + + sseConnect(); + + return () => { + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + eventSourceRef.current?.close(); + eventSourceRef.current = null; + }; + }, [applicationFormId, handleApplicantStatusChange]); return ( {children} diff --git a/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantDetailPage/ApplicantDetailPage.tsx b/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantDetailPage/ApplicantDetailPage.tsx index 6342f2011..563f703de 100644 --- a/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantDetailPage/ApplicantDetailPage.tsx +++ b/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantDetailPage/ApplicantDetailPage.tsx @@ -62,7 +62,7 @@ const ApplicantDetailPage = () => { setAppMemo(applicant.memo); setApplicantStatus(mapStatusToGroup(applicant.status).status); } - }, [applicant]); + }, [applicant, applicant?.status, applicant?.memo]); const updateApplicantDetail = useMemo( () => diff --git a/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantsTab.tsx b/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantsTab.tsx index 626bf5625..3f9f0227c 100644 --- a/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantsTab.tsx +++ b/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantsTab.tsx @@ -23,7 +23,8 @@ const sortOptions = [ ] as const; const ApplicantsTab = () => { - const { clubId, applicantsData, setApplicantsData } = useAdminClubContext(); + const { clubId, applicantsData, setApplicantsData, setApplicationFormId } = + useAdminClubContext(); const { applicationFormId } = useParams<{ applicationFormId: string }>(); const navigate = useNavigate(); @@ -62,6 +63,13 @@ const ApplicantsTab = () => { (typeof sortOptions)[number] >(sortOptions[0]); + // SSE 연결 활성화 + useEffect(() => { + setApplicationFormId(applicationFormId ?? null); + return () => setApplicationFormId(null); + }, [applicationFormId, setApplicationFormId]); + + // 초기 데이터 로드 useEffect(() => { if (fetchData) { setApplicantsData(fetchData); diff --git a/frontend/src/pages/AdminPage/tabs/ClubIntroEditTab/components/AwardEditor/AwardEditor.tsx b/frontend/src/pages/AdminPage/tabs/ClubIntroEditTab/components/AwardEditor/AwardEditor.tsx index 9737129b0..647490e01 100644 --- a/frontend/src/pages/AdminPage/tabs/ClubIntroEditTab/components/AwardEditor/AwardEditor.tsx +++ b/frontend/src/pages/AdminPage/tabs/ClubIntroEditTab/components/AwardEditor/AwardEditor.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'; import deleteButton from '@/assets/images/icons/delete_button_icon.svg'; import selectIcon from '@/assets/images/icons/selectArrow.svg'; import { CustomDropDown } from '@/components/common/CustomDropDown/CustomDropDown'; -import { Award } from '@/types/club'; +import { Award, SemesterTerm, SemesterTermType } from '@/types/club'; import * as Styled from './AwardEditor.styles'; interface AwardEditorProps { @@ -12,12 +12,15 @@ interface AwardEditorProps { const START_YEAR = 2020; -const parseSemester = (semester: string): number => { - const match = semester.match(/(\d{4})\s+(\d)학기/); - if (!match) return 0; - const year = parseInt(match[1], 10); - const semesterNumber = parseInt(match[2], 10); - return year * 10 + semesterNumber; +const getSemesterSortValue = (award: Award): number => { + const semesterValue = award.semester === SemesterTerm.FIRST ? 1 : 2; + return award.year * 10 + semesterValue; +}; + +const formatSemesterLabel = (award: Award): string => { + const semesterLabel = + award.semester === SemesterTerm.FIRST ? '1학기' : '2학기'; + return `${award.year} ${semesterLabel}`; }; const generateYearOptions = (currentYear: number) => { @@ -32,44 +35,51 @@ const generateYearOptions = (currentYear: number) => { }; const SEMESTER_OPTIONS = [ - { value: '1', label: '1학기' }, - { value: '2', label: '2학기' }, + { value: SemesterTerm.FIRST, label: '1학기' }, + { value: SemesterTerm.SECOND, label: '2학기' }, ] as const; const AwardEditor = ({ awards, onChange }: AwardEditorProps) => { const currentYear = new Date().getFullYear(); const [selectedYear, setSelectedYear] = useState(currentYear.toString()); - const [selectedSemester, setSelectedSemester] = useState('1'); + const [selectedSemester, setSelectedSemester] = useState( + SemesterTerm.FIRST, + ); const [isYearDropdownOpen, setIsYearDropdownOpen] = useState(false); const [isSemesterDropdownOpen, setIsSemesterDropdownOpen] = useState(false); - const [lastAddedSemester, setLastAddedSemester] = useState( - null, - ); + const [lastAddedKey, setLastAddedKey] = useState(null); const inputRefs = useRef>({}); const yearOptions = generateYearOptions(currentYear); const sortedAwards = [...awards].sort( (awardA, awardB) => - parseSemester(awardB.semester) - parseSemester(awardA.semester), + getSemesterSortValue(awardB) - getSemesterSortValue(awardA), ); + const getAwardKey = (award: Award, index: number): string => + `${award.year}-${award.semester}-${index}`; + const handleAddSemester = () => { - const semesterText = `${selectedYear} ${selectedSemester}학기`; + const year = parseInt(selectedYear, 10); - const isDuplicate = awards.some((award) => award.semester === semesterText); + const isDuplicate = awards.some( + (award) => award.year === year && award.semester === selectedSemester, + ); if (isDuplicate) { alert('이미 추가된 학기입니다.'); return; } const newAward: Award = { - semester: semesterText, + year, + semester: selectedSemester, achievements: [''], }; - onChange([...awards, newAward]); - setLastAddedSemester(semesterText); + const newAwards = [...awards, newAward]; + onChange(newAwards); + setLastAddedKey(getAwardKey(newAward, newAwards.length - 1)); }; const handleRemoveSemester = (semesterIndex: number) => { @@ -83,7 +93,7 @@ const AwardEditor = ({ awards, onChange }: AwardEditorProps) => { : award, ); onChange(updatedAwards); - setLastAddedSemester(awards[semesterIndex].semester); + setLastAddedKey(getAwardKey(awards[semesterIndex], semesterIndex)); }; const handleRemoveAchievement = ( @@ -122,21 +132,22 @@ const AwardEditor = ({ awards, onChange }: AwardEditorProps) => { }; useEffect(() => { - if (lastAddedSemester) { - const award = awards.find( - (award) => award.semester === lastAddedSemester, + if (lastAddedKey) { + const awardIndex = awards.findIndex( + (award, index) => getAwardKey(award, index) === lastAddedKey, ); + const award = awardIndex !== -1 ? awards[awardIndex] : null; if (award) { const lastIndex = award.achievements.length - 1; - const key = `${lastAddedSemester}-${lastIndex}`; + const key = `${lastAddedKey}-${lastIndex}`; const inputRef = inputRefs.current[key]; if (inputRef) { inputRef.focus(); } } - setLastAddedSemester(null); + setLastAddedKey(null); } - }, [awards, lastAddedSemester]); + }, [awards, lastAddedKey]); return ( @@ -174,7 +185,7 @@ const AwardEditor = ({ awards, onChange }: AwardEditorProps) => { setSelectedSemester(value as SemesterTermType)} open={isSemesterDropdownOpen} onToggle={(isOpen) => setIsSemesterDropdownOpen(!isOpen)} style={{ width: '100px' }} @@ -206,14 +217,20 @@ const AwardEditor = ({ awards, onChange }: AwardEditorProps) => { - {sortedAwards.map((award) => { + {sortedAwards.map((award, sortedIndex) => { const originalIndex = awards.findIndex( - (originalAward) => originalAward.semester === award.semester, + (originalAward, idx) => + originalAward.year === award.year && + originalAward.semester === award.semester && + originalAward.achievements === award.achievements, ); + const awardKey = getAwardKey(award, originalIndex); return ( - + - {award.semester} + + {formatSemesterLabel(award)} + handleRemoveSemester(originalIndex)} > @@ -226,7 +243,7 @@ const AwardEditor = ({ awards, onChange }: AwardEditorProps) => { { - const key = `${award.semester}-${achievementIndex}`; + const key = `${awardKey}-${achievementIndex}`; inputRefs.current[key] = element; }} placeholder='수상 내역을 입력하세요' diff --git a/frontend/src/pages/ClubDetailPage/components/ClubIntroContent/ClubIntroContent.tsx b/frontend/src/pages/ClubDetailPage/components/ClubIntroContent/ClubIntroContent.tsx index 93385fe02..190ea61af 100644 --- a/frontend/src/pages/ClubDetailPage/components/ClubIntroContent/ClubIntroContent.tsx +++ b/frontend/src/pages/ClubDetailPage/components/ClubIntroContent/ClubIntroContent.tsx @@ -1,29 +1,16 @@ -import { useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { USER_EVENT } from '@/constants/eventName'; import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; +import { Award, FAQ, IdealCandidate } from '@/types/club'; +import { formatSemesterLabel, getAwardKey } from '@/utils/awardHelpers'; import * as Styled from './ClubIntroContent.styles'; -export interface Award { - semester: string; - achievements: string[]; -} - -export interface IdealCandidate { - tags?: string[]; // TODO: tags가 추가될수도 있음 - content: string; -} - -export interface Faq { - question: string; - answer: string; -} - interface ClubIntroContentProps { activityDescription?: string; awards?: Award[]; idealCandidate?: IdealCandidate; benefits?: string; - faqs?: Faq[]; + faqs?: FAQ[]; } const ClubIntroContent = ({ @@ -35,21 +22,33 @@ const ClubIntroContent = ({ }: ClubIntroContentProps) => { const trackEvent = useMixpanelTrack(); - const [openFaqIndices, setOpenFaqIndices] = useState([]); + const [openFaqIndexes, setOpenFaqIndexes] = useState>(new Set()); - const handleToggleFaq = (index: number) => { - const isOpening = !openFaqIndices.includes(index); - setOpenFaqIndices((prev) => - prev.includes(index) ? prev.filter((i) => i !== index) : [...prev, index], - ); + const validAwards = useMemo( + () => awards?.filter((award) => formatSemesterLabel(award) !== null) || [], + [awards], + ); - if (faqs && faqs[index]) { - trackEvent(USER_EVENT.FAQ_TOGGLE_CLICKED, { - question: faqs[index].question, - action: isOpening ? 'open' : 'close', + const handleToggleFaq = useCallback( + (index: number) => { + const isOpening = !openFaqIndexes.has(index); + + setOpenFaqIndexes((prev) => { + const newSet = new Set(prev); + if (isOpening) newSet.add(index); + else newSet.delete(index); + return newSet; }); - } - }; + + if (faqs?.[index]) { + trackEvent(USER_EVENT.FAQ_TOGGLE_CLICKED, { + question: faqs[index].question, + action: isOpening ? 'open' : 'close', + }); + } + }, + [faqs, trackEvent, openFaqIndexes], + ); return ( @@ -62,22 +61,26 @@ const ClubIntroContent = ({ )} - {awards && awards.length > 0 && ( + {validAwards.length > 0 && ( 동아리 성과 - {awards.map((award) => ( - - {award.semester} - - {award.achievements.map((item, idx) => ( - - {item} - - ))} - - - ))} + {validAwards.map((award, index) => { + const semesterLabel = formatSemesterLabel(award)!; + const awardKey = getAwardKey(award, index); + return ( + + {semesterLabel} + + {award.achievements.map((item, idx) => ( + + {item} + + ))} + + + ); + })} )} @@ -102,7 +105,7 @@ const ClubIntroContent = ({ FAQ {faqs.map((faq, index) => { - const isOpen = openFaqIndices.includes(index); + const isOpen = openFaqIndexes.has(index); return ( handleToggleFaq(index)}> diff --git a/frontend/src/types/applicants.ts b/frontend/src/types/applicants.ts index ad97a968e..955a9295b 100644 --- a/frontend/src/types/applicants.ts +++ b/frontend/src/types/applicants.ts @@ -29,3 +29,17 @@ export interface UpdateApplicantParams { status: ApplicationStatus; applicantId: string | undefined; } + +export interface ApplicantStatusEvent { + applicantId: string; + status: ApplicationStatus; + memo: string; + timestamp: string; + clubId: string; + applicationFormId: string; +} + +export interface ApplicantSSECallbacks { + onStatusChange: (event: ApplicantStatusEvent) => void; + onError: (error: Error) => void; +} diff --git a/frontend/src/types/club.ts b/frontend/src/types/club.ts index d48f82cff..08d171de9 100644 --- a/frontend/src/types/club.ts +++ b/frontend/src/types/club.ts @@ -41,8 +41,16 @@ export interface ClubDescription { recruitmentTarget: string; } +export const SemesterTerm = { + FIRST: 'FIRST', + SECOND: 'SECOND', +} as const; + +export type SemesterTermType = (typeof SemesterTerm)[keyof typeof SemesterTerm]; + export interface Award { - semester: string; + year: number; + semester: SemesterTermType; achievements: string[]; } diff --git a/frontend/src/utils/awardHelpers.test.ts b/frontend/src/utils/awardHelpers.test.ts new file mode 100644 index 000000000..a62aa2f2e --- /dev/null +++ b/frontend/src/utils/awardHelpers.test.ts @@ -0,0 +1,99 @@ +import { Award, SemesterTerm, SemesterTermType } from '@/types/club'; +import { formatSemesterLabel, getAwardKey } from './awardHelpers'; + +describe('awardHelpers', () => { + const createAward = ( + year: number, + semester: SemesterTermType, + achievements: string[] = [], + ): Award => ({ + year, + semester, + achievements, + }); + + const validAward2024First = createAward(2024, SemesterTerm.FIRST); + const validAward2024Second = createAward(2024, SemesterTerm.SECOND); + const validAward2023First = createAward(2023, SemesterTerm.FIRST); + const validAward2025Second = createAward(2025, SemesterTerm.SECOND); + + describe('formatSemesterLabel', () => { + it('1학기를 올바른 형식으로 반환해야 한다', () => { + expect(formatSemesterLabel(validAward2024First)).toBe('2024 1학기'); + }); + + it('2학기를 올바른 형식으로 반환해야 한다', () => { + expect(formatSemesterLabel(validAward2024Second)).toBe('2024 2학기'); + }); + + it('year가 없으면 null을 반환해야 한다', () => { + const award: Partial = { + semester: SemesterTerm.FIRST, + achievements: [], + }; + + expect(formatSemesterLabel(award as Award)).toBeNull(); + }); + + it('semester가 없으면 null을 반환해야 한다', () => { + const award: Partial = { + year: 2024, + achievements: [], + }; + + expect(formatSemesterLabel(award as Award)).toBeNull(); + }); + + it('year와 semester가 모두 없으면 null을 반환해야 한다', () => { + const award: Partial = { + achievements: [], + }; + + expect(formatSemesterLabel(award as Award)).toBeNull(); + }); + + it('award가 null이면 null을 반환해야 한다', () => { + expect(formatSemesterLabel(null as unknown as Award)).toBeNull(); + }); + + it('award가 undefined이면 null을 반환해야 한다', () => { + expect(formatSemesterLabel(undefined as unknown as Award)).toBeNull(); + }); + + it('다양한 연도를 올바르게 처리해야 한다', () => { + expect(formatSemesterLabel(validAward2023First)).toBe('2023 1학기'); + expect(formatSemesterLabel(validAward2025Second)).toBe('2025 2학기'); + }); + }); + + describe('getAwardKey', () => { + it('year, semester, index를 조합한 고유 키를 생성해야 한다', () => { + expect(getAwardKey(validAward2024First, 0)).toBe('2024-FIRST-0'); + }); + + it('인덱스가 다르면 다른 키를 생성해야 한다', () => { + const key1 = getAwardKey(validAward2024First, 0); + const key2 = getAwardKey(validAward2024First, 1); + + expect(key1).not.toBe(key2); + expect(key1).toBe('2024-FIRST-0'); + expect(key2).toBe('2024-FIRST-1'); + }); + + it('학기가 다르면 다른 키를 생성해야 한다', () => { + expect(getAwardKey(validAward2024First, 0)).toBe('2024-FIRST-0'); + expect(getAwardKey(validAward2024Second, 0)).toBe('2024-SECOND-0'); + }); + + it('연도가 다르면 다른 키를 생성해야 한다', () => { + const award2024 = createAward(2024, SemesterTerm.FIRST); + + expect(getAwardKey(validAward2023First, 0)).toBe('2023-FIRST-0'); + expect(getAwardKey(award2024, 0)).toBe('2024-FIRST-0'); + }); + + it('큰 인덱스 숫자를 올바르게 처리해야 한다', () => { + expect(getAwardKey(validAward2024First, 999)).toBe('2024-FIRST-999'); + }); + }); +}); diff --git a/frontend/src/utils/awardHelpers.ts b/frontend/src/utils/awardHelpers.ts new file mode 100644 index 000000000..849f64efb --- /dev/null +++ b/frontend/src/utils/awardHelpers.ts @@ -0,0 +1,13 @@ +import { Award, SemesterTerm } from '@/types/club'; + +export const formatSemesterLabel = (award: Award): string | null => { + if (award?.year && award?.semester) { + const semesterLabel = + award.semester === SemesterTerm.FIRST ? '1학기' : '2학기'; + return `${award.year} ${semesterLabel}`; + } + return null; +}; + +export const getAwardKey = (award: Award, index: number): string => + `${award.year}-${award.semester}-${index}`;