diff --git a/frontend/src/components/common/CustomDropDown/CustomDropDown.styles.ts b/frontend/src/components/common/CustomDropDown/CustomDropDown.styles.ts index 4a54f5049..022011b75 100644 --- a/frontend/src/components/common/CustomDropDown/CustomDropDown.styles.ts +++ b/frontend/src/components/common/CustomDropDown/CustomDropDown.styles.ts @@ -3,58 +3,45 @@ import styled from 'styled-components'; export const DropDownWrapper = styled.div` position: relative; width: 100%; -`; - -export const Selected = styled.div<{ open: boolean }>` - padding: 12px 16px; - border-radius: 0.375rem; - background: ${({ open }) => (open ? '#fff' : '#f5f5f5')}; - color: #787878; - font-size: 0.875rem; - font-weight: 600; cursor: pointer; - border: 1px solid ${({ open }) => (open ? '#c5c5c5' : 'transparent')}; - transition: - border-color 0.2s ease, - background-color 0.2s ease; - - user-select: none; `; -export const OptionList = styled.ul` +export const OptionList = styled.ul<{ + $top?: string; + $width?: string; + $right?: string; +}>` position: absolute; - top: 100%; - left: 0; - width: 100%; - background: #fff; + top: ${({ $top }) => $top || '110%'}; + left: ${({ $right }) => ($right ? 'auto' : '0')}; + width: ${({ $width }) => $width || '100%'}; + right: ${({ $right }) => $right || 'auto'}; border-radius: 6px; - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.05); + border: 1px solid #dcdcdc; + background: #fff; + box-shadow: 0 1px 8px 0 rgba(0, 0, 0, 0.12); + padding: 6px 0; z-index: 10; + height: auto; list-style: none; `; -export const OptionItem = styled.li<{ isSelected: boolean }>` +export const OptionItem = styled.li<{ $isSelected: boolean }>` + display: flex; + align-items: center; + justify-content: center; text-align: center; - padding: 10px; - margin: 4px; font-weight: 600; - border-radius: 6px; color: #787878; - background-color: ${({ isSelected }) => (isSelected ? '#DCDCDC' : '#fff')}; + background-color: ${({ $isSelected }) => ($isSelected ? '#f5f5f5' : '#fff')}; cursor: pointer; + padding: 8px 13px; + height: 27px; &:hover { - background-color: #dcdcdc; + background-color: #f5f5f5; } transition: background-color 0.2s ease; user-select: none; `; - -export const Icon = styled.img` - position: absolute; - top: 50%; - right: 19px; - transform: translateY(-50%); - pointer-events: none; -`; diff --git a/frontend/src/components/common/CustomDropDown/CustomDropDown.tsx b/frontend/src/components/common/CustomDropDown/CustomDropDown.tsx index e5b7a437a..df53542ce 100644 --- a/frontend/src/components/common/CustomDropDown/CustomDropDown.tsx +++ b/frontend/src/components/common/CustomDropDown/CustomDropDown.tsx @@ -1,50 +1,119 @@ -import { useState } from 'react'; +import React, { createContext, useContext, ReactNode } from 'react'; import * as Styled from './CustomDropDown.styles'; -import dropdown_icon from '@/assets/images/icons/drop_button_icon.svg'; -interface DropdownOption { +interface DropdownOption { label: string; - value: string; + value: TValue; } -interface DropdownProps { - options: DropdownOption[]; - selected: string; - onSelect: (value: string) => void; +interface CustomDropDownContextProps { + open: boolean; + selected?: TValue; + options: readonly DropdownOption[]; + onToggle: (isOpen: boolean) => void; + handleSelect: (value: TValue) => void; } -const CustomDropdown = ({ options, selected, onSelect }: DropdownProps) => { - const [open, setOpen] = useState(false); +interface CustomDropDownProps { + children: ReactNode; + options: readonly DropdownOption[]; + selected?: TValue; + onSelect: (value: TValue) => void; + open: boolean; + onToggle: (isOpen: boolean) => void; + style?: React.CSSProperties; +} + +interface ItemProps { + value: TValue; + children: ReactNode; + style?: React.CSSProperties; +} + +const CustomDropDownContext = createContext< + CustomDropDownContextProps | undefined +>(undefined); + +const useDropDownContext = () => { + const context = useContext(CustomDropDownContext); + if (!context) { + throw new Error( + 'useDropDownContext는 CustomDropDownContextProvider 내부에서 사용할 수 있습니다.', + ); + } + return context; +}; + +const Trigger = ({ children }: { children: ReactNode }) => { + const { onToggle, open } = useDropDownContext(); + return ( +
{ + onToggle(open); + }} + > + {children} +
+ ); +}; - const handleSelect = (value: string) => { +interface MenuProps { + children: ReactNode; + top?: string; + width?: string; + right?: string; +} + +const Menu = ({ children, top, width, right }: MenuProps) => { + const { open } = useDropDownContext(); + return open ? ( + + {children} + + ) : null; +}; + +const Item = ({ + value, + children, + style, +}: ItemProps) => { + const { selected, handleSelect } = useDropDownContext(); + return ( + handleSelect(value)} + style={style} + > + {children} + + ); +}; + +export function CustomDropDown({ + children, + options, + selected, + onSelect, + open, + onToggle, + style, +}: CustomDropDownProps) { + const handleSelect = (value: T) => { onSelect(value); - setOpen(false); + onToggle(open); }; - const selectedLabel = - options.find((option) => option.value === selected)?.label || '선택하세요'; + const value = { open, selected, options, onToggle, handleSelect }; return ( - - setOpen((prev) => !prev)} open={open}> - {selectedLabel} - - - {open && ( - - {options.map(({ label, value }) => ( - handleSelect(value)} - > - {label} - - ))} - - )} - + + {children} + ); -}; +} -export default CustomDropdown; +CustomDropDown.Trigger = Trigger; +CustomDropDown.Menu = Menu; +CustomDropDown.Item = Item; diff --git a/frontend/src/pages/AdminPage/components/QuestionBuilder/QuestionBuilder.styles.ts b/frontend/src/pages/AdminPage/components/QuestionBuilder/QuestionBuilder.styles.ts index 37cff899a..eed7b824f 100644 --- a/frontend/src/pages/AdminPage/components/QuestionBuilder/QuestionBuilder.styles.ts +++ b/frontend/src/pages/AdminPage/components/QuestionBuilder/QuestionBuilder.styles.ts @@ -75,6 +75,30 @@ export const SelectionToggleButton = styled.button<{ active: boolean }>` color 0.2s ease; `; +export const Selected = styled.div<{ open: boolean }>` + padding: 12px 16px; + border-radius: 0.375rem; + background: ${({ open }) => (open ? '#fff' : '#f5f5f5')}; + color: #787878; + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + border: 1px solid ${({ open }) => (open ? '#c5c5c5' : 'transparent')}; + transition: + border-color 0.2s ease, + background-color 0.2s ease; + + user-select: none; +`; + +export const Icon = styled.img` + position: absolute; + top: 50%; + right: 19px; + transform: translateY(-50%); + pointer-events: none; +`; + export const DeleteButton = styled.button` display: flex; align-items: center; diff --git a/frontend/src/pages/AdminPage/components/QuestionBuilder/QuestionBuilder.tsx b/frontend/src/pages/AdminPage/components/QuestionBuilder/QuestionBuilder.tsx index 6c553a69b..1298cc08c 100644 --- a/frontend/src/pages/AdminPage/components/QuestionBuilder/QuestionBuilder.tsx +++ b/frontend/src/pages/AdminPage/components/QuestionBuilder/QuestionBuilder.tsx @@ -7,8 +7,9 @@ import { QuestionBuilderProps } from '@/types/application'; import { QUESTION_LABEL_MAP } from '@/constants/APPLICATION_FORM'; import { DROPDOWN_OPTIONS } from '@/constants/APPLICATION_FORM'; import * as Styled from './QuestionBuilder.styles'; -import CustomDropdown from '@/components/common/CustomDropDown/CustomDropDown'; import DeleteIcon from '@/assets/images/icons/delete_question.svg'; +import { CustomDropDown } from '@/components/common/CustomDropDown/CustomDropDown'; +import dropdown_icon from '@/assets/images/icons/drop_button_icon.svg'; const QuestionBuilder = ({ id, @@ -33,6 +34,8 @@ const QuestionBuilder = ({ type === 'MULTI_CHOICE' ? 'multi' : 'single', ); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + useEffect(() => { if (type === 'MULTI_CHOICE') { setSelectionType('multi'); @@ -118,6 +121,11 @@ const QuestionBuilder = ({ ); }; + const selectedType = type === 'MULTI_CHOICE' ? 'CHOICE' : type; + const selectedLabel = DROPDOWN_OPTIONS.find( + (option) => option.value === selectedType, + )?.label; + return ( @@ -127,13 +135,36 @@ const QuestionBuilder = ({ 답변 필수 - { onTypeChange?.(value as QuestionType); }} - /> + open={isDropdownOpen} + onToggle={(isOpen) => setIsDropdownOpen(!isOpen)} + > + + + {selectedLabel} + + + + + {DROPDOWN_OPTIONS.map(({ label, value }) => ( + + {label} + + ))} + + {renderSelectionToggle()} {!readOnly && ( onRemoveQuestion()}> diff --git a/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantsTab.styles.ts b/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantsTab.styles.ts index 0526d0461..e7aa8798a 100644 --- a/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantsTab.styles.ts +++ b/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantsTab.styles.ts @@ -153,13 +153,16 @@ export const StatusSelectMenuItem = styled.div` } `; -export const ApplicantFilterSelect = styled.select` +export const ApplicantFilterSelect = styled.div` + display: flex; + align-items: center; height: 35px; padding: 4px 32px 4px 14px; border-radius: 8px; border: none; background: var(--f5, #f5f5f5); font-size: 16px; + color: #000; -webkit-appearance: none; -moz-appearance: none; @@ -282,7 +285,8 @@ export const ApplicantAllSelectWrapper = styled.div` export const ApplicantAllSelectArrow = styled.img` position: absolute; - right: -1px; + right: -18px; + top: -7px; width: 16px; height: 16px; object-fit: none; diff --git a/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantsTab.tsx b/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantsTab.tsx index 8d04ff0f7..513b4d229 100644 --- a/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantsTab.tsx +++ b/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantsTab.tsx @@ -11,8 +11,29 @@ 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'; import { AVAILABLE_STATUSES } from '@/constants/status'; +import { CustomDropDown } from '@/components/common/CustomDropDown/CustomDropDown'; const ApplicantsTab = () => { + const statusOptions = AVAILABLE_STATUSES.map((status) => ({ + value: status, + label: mapStatusToGroup(status).label, + })); + + const filterOptions = ['ALL', ...Object.values(ApplicationStatus)].map( + (status) => ({ + value: status, + label: + status === 'ALL' + ? '전체' + : mapStatusToGroup(status as ApplicationStatus).label, + }), + ); + + const sortOptions = [ + { value: 'date', label: '제출순' }, + { value: 'name', label: '이름순' }, + ] as const; + const navigate = useNavigate(); const { clubId, applicantsData } = useAdminClubContext(); const [keyword, setKeyword] = useState(''); @@ -21,42 +42,67 @@ const ApplicantsTab = () => { ); const [selectAll, setSelectAll] = useState(false); const [open, setOpen] = useState(false); - const [statusOpen, setStatusOpen] = useState(false); + const [isStatusDropdownOpen, setIsStatusDropdownOpen] = useState(false); const [isChecked, setIsChecked] = useState(false); + const [isFilterOpen, setIsFilterOpen] = useState(false); + const [selectedFilter, setSelectedFilter] = useState('ALL'); + const [isSortOpen, setIsSortOpen] = useState(false); + const [selectedSort, setSelectedSort] = useState< + (typeof sortOptions)[number] + >(sortOptions[0]); + + // 모든 드롭다운을 닫는 함수 + const closeAllDropdowns = () => { + if (open) setOpen(false); + if (isStatusDropdownOpen) setIsStatusDropdownOpen(false); + if (isFilterOpen) setIsFilterOpen(false); + if (isSortOpen) setIsSortOpen(false); + }; const { mutate: deleteApplicants } = useDeleteApplicants(clubId!); const { mutate: updateDetailApplicants } = useUpdateApplicant(clubId!); - const allSelectRef = useRef(null); - const statusSelectRef = useRef(null); + const dropdownRef = useRef>([]); const filteredApplicants = useMemo(() => { if (!applicantsData?.applicants) return []; - if (!keyword.trim()) return applicantsData.applicants; + let applicants = [...applicantsData.applicants]; - return applicantsData.applicants.filter((user: Applicant) => - user.answers[0].value - .toLowerCase() - .includes(keyword.trim().toLowerCase()), - ); - }, [applicantsData, keyword]); + if (selectedFilter !== 'ALL') { + applicants = applicants.filter( + (applicant) => applicant.status === selectedFilter, + ); + } + + if (keyword.trim()) { + applicants = applicants.filter((user: Applicant) => + user.answers?.[0]?.value + ?.toLowerCase() + .includes(keyword.trim().toLowerCase()), + ); + } + + if (selectedSort.value === 'name') { + applicants.sort((a, b) => + a.answers?.[0]?.value.localeCompare(b.answers?.[0]?.value), + ); + } else { + applicants.sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); + } + + return applicants; + }, [applicantsData, keyword, selectedFilter, selectedSort.value]); 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) + dropdownRef.current && + !dropdownRef.current.some((ref) => ref && ref.contains(target)) ) { - setStatusOpen(false); + closeAllDropdowns(); } }; @@ -64,7 +110,7 @@ const ApplicantsTab = () => { return () => { document.removeEventListener('mousedown', handleOutsideClick); }; - }, [open, statusOpen]); + }, [open, isStatusDropdownOpen, isFilterOpen, isSortOpen]); useEffect(() => { const newMap = new Map(); @@ -132,8 +178,6 @@ const ApplicantsTab = () => { }); return newMap; }); - - if (open) setOpen(false); }; const checkoutAllApplicants = () => { @@ -158,7 +202,6 @@ const ApplicantsTab = () => { { onSuccess: () => { checkoutAllApplicants(); - setStatusOpen(false); }, onError: () => { alert('지원자 상태 변경에 실패했습니다. 다시 시도해주세요.'); @@ -171,11 +214,11 @@ const ApplicantsTab = () => { <> 지원 현황 - {/* + {/* + - ...다른 학기 */ - /*{' '} - */} + + */} @@ -213,41 +256,125 @@ const ApplicantsTab = () => { 지원자 목록 - - - - - + { + dropdownRef.current[2] = el; + }} + > + { + setSelectedFilter(value); + }} + open={isFilterOpen} + onToggle={(isOpen) => { + closeAllDropdowns(); + setIsFilterOpen(!isOpen); + }} + selected={selectedFilter} + style={{ width: '101px' }} + > + + + { + filterOptions.find( + (option) => option.value === selectedFilter, + )?.label + } + + + + + {filterOptions.map(({ value, label }) => ( + + {label} + + ))} + + - - - - - + { + dropdownRef.current[3] = el; + }} + > + { + const selected = sortOptions.find( + (option) => option.value === value, + ); + if (selected) { + setSelectedSort(selected); + } + }} + open={isSortOpen} + selected={selectedSort.value} + onToggle={(isOpen) => { + closeAllDropdowns(); + setIsSortOpen(!isOpen); + }} + style={{ width: '101px' }} + > + + + {selectedSort.label} + + + + + {sortOptions.map(({ value, label }) => ( + + {label} + + ))} + + { - if (!isChecked) return; - setStatusOpen((prev) => !prev); + ref={(el) => { + dropdownRef.current[0] = el; }} > - - 상태변경 - - - - {AVAILABLE_STATUSES.map((status) => ( - { - updateAllApplicants(status); - }} - > - {mapStatusToGroup(status).label} - - ))} - + + updateAllApplicants(status as ApplicationStatus) + } + open={isStatusDropdownOpen} + onToggle={(isOpen) => { + if (!isChecked) return; + closeAllDropdowns(); + setIsStatusDropdownOpen(!isOpen); + }} + > + + + 상태변경 + + + + + {statusOptions.map(({ value, label }) => ( + + {label} + + ))} + + { - + { + dropdownRef.current[1] = el; + }} + > ) => { @@ -283,52 +414,43 @@ const ApplicantsTab = () => { selectApplicantsByStatus('all'); }} /> - setOpen((prev) => !prev)} - /> - - { - if (selectAll) { - setOpen(false); - return; - } + { + if (status === 'ALL') { selectApplicantsByStatus('all'); - }} - > - 전체선택 - - { - selectApplicantsByStatus( - 'filter', - ApplicationStatus.SUBMITTED, - ); - }} - > - 서류 검토 필요 - - { + } else { selectApplicantsByStatus( 'filter', - ApplicationStatus.INTERVIEW_SCHEDULED, + status as ApplicationStatus, ); - }} - > - 면접예정 - - { - setOpen(false); - checkoutAllApplicants(); - }} - > - 선택해제 - - + } + }} + onToggle={(isOpen) => { + closeAllDropdowns(); + setOpen(!isOpen); + }} + open={open} + style={{ width: '0' }} + > + + + + + {filterOptions.map(({ value, label }) => ( + + {label} + + ))} + + diff --git a/frontend/src/utils/mapStatusToGroup.ts b/frontend/src/utils/mapStatusToGroup.ts index 9f57824e7..f1a95ad29 100644 --- a/frontend/src/utils/mapStatusToGroup.ts +++ b/frontend/src/utils/mapStatusToGroup.ts @@ -1,18 +1,23 @@ -import { ApplicationStatus } from "@/types/applicants"; +import { ApplicationStatus } from '@/types/applicants'; -const mapStatusToGroup = (status: ApplicationStatus): { status: ApplicationStatus, label: string } => { +const mapStatusToGroup = ( + status: ApplicationStatus, +): { status: ApplicationStatus; label: string } => { switch (status) { case ApplicationStatus.SUBMITTED: return { status: ApplicationStatus.SUBMITTED, label: '서류검토' }; case ApplicationStatus.INTERVIEW_SCHEDULED: - return { status: ApplicationStatus.INTERVIEW_SCHEDULED, label: '면접예정' }; + return { + status: ApplicationStatus.INTERVIEW_SCHEDULED, + label: '면접예정', + }; case ApplicationStatus.ACCEPTED: return { status: ApplicationStatus.ACCEPTED, label: '합격' }; case ApplicationStatus.DECLINED: return { status: ApplicationStatus.DECLINED, label: '불합' }; default: - return { status: ApplicationStatus.SUBMITTED, label: '서류검토'}; + return { status: ApplicationStatus.SUBMITTED, label: '전체' }; } -} +}; -export default mapStatusToGroup; \ No newline at end of file +export default mapStatusToGroup;