diff --git a/src/apis/club/entity.ts b/src/apis/club/entity.ts index 47219264..82c3af4c 100644 --- a/src/apis/club/entity.ts +++ b/src/apis/club/entity.ts @@ -53,8 +53,8 @@ export interface ClubDetailResponse { interface Recruitment { status: 'BEFORE' | 'ONGOING' | 'CLOSED'; - startDate?: string; - endDate?: string; + startAt?: string; + endAt?: string; } export type PositionType = 'PRESIDENT' | 'VICE_PRESIDENT' | 'MANAGER' | 'MEMBER'; @@ -114,8 +114,8 @@ export interface ClubRecruitment { id: number; clubId: number; status: 'BEFORE' | 'ONGOING' | 'CLOSED'; - startDate: string; - endDate: string; + startAt: string; + endAt: string; content: string; images: ClubRecruitmentImage[]; isFeeRequired: boolean; @@ -137,13 +137,21 @@ export interface AppliedClubResponse { //========================== Club Manager Entities =========================// interface Application { id: number; - studentNumber: number; + studentNumber: string; name: string; imageUrl: string; appliedAt: string; + feePaymentImageUrl?: string; } -export interface ClubApplicationsResponse { +export interface ClubApplicationsParams { + page?: number; + limit?: number; + sortBy?: 'APPLIED_AT' | 'STUDENT_NUMBER' | 'NAME'; + sortDirection?: 'ASC' | 'DESC'; +} + +export interface ClubApplicationsResponse extends PaginationResponse { applications: Application[]; } @@ -156,7 +164,7 @@ interface ApplicationAnswer { export interface ClubApplicationDetailResponse { applicationId: number; - studentNumber: number; + studentNumber: string; name: string; imageUrl: string; appliedAt: string; @@ -176,13 +184,13 @@ type BaseRecruitment = { export type ClubRecruitmentRequest = | (BaseRecruitment & { isAlwaysRecruiting: true; - startDate?: never; - endDate?: never; + startAt?: never; + endAt?: never; }) | (BaseRecruitment & { isAlwaysRecruiting: false; - startDate: string; - endDate: string; + startAt: string; + endAt: string; }); export interface ClubQuestionRequest { @@ -289,11 +297,17 @@ export interface PreMemberDeleteRequest { //========================== Club Settings Entities =========================// -interface ClubSettingsRecruitment { - startDate: string; - endDate: string; - isAlwaysRecruiting: boolean; -} +type ClubSettingsRecruitment = + | { + isAlwaysRecruiting: true; + startAt?: never; + endAt?: never; + } + | { + isAlwaysRecruiting: false; + startAt: string; + endAt: string; + }; interface ClubSettingsApplication { questionCount: number; diff --git a/src/apis/club/index.ts b/src/apis/club/index.ts index 02af9c9c..f1ec51b5 100644 --- a/src/apis/club/index.ts +++ b/src/apis/club/index.ts @@ -31,6 +31,7 @@ import { type PreMembersList, type ClubSettingsResponse, type ClubSettingsPatchRequest, + type ClubApplicationsParams, } from './entity'; export const getClubs = async (params: ClubRequestParams) => { @@ -89,10 +90,14 @@ export const getManagedClubs = async () => { return response; }; -export const getManagedClubApplications = async (clubId: number) => { - const response = await apiClient.get(`clubs/${clubId}/applications`, { - requiresAuth: true, - }); +export const getManagedClubApplications = async (clubId: number, params?: ClubApplicationsParams) => { + const response = await apiClient.get( + `clubs/${clubId}/applications`, + { + params, + requiresAuth: true, + } + ); return response; }; diff --git a/src/components/common/LinkifiedText.tsx b/src/components/common/LinkifiedText.tsx new file mode 100644 index 00000000..0c7d1433 --- /dev/null +++ b/src/components/common/LinkifiedText.tsx @@ -0,0 +1,157 @@ +import { Fragment, useMemo } from 'react'; +import { cn } from '@/utils/ts/cn'; + +const URL_REGEX = /(?:https?:\/\/|www\.)[^\s]+/gi; +const TRAILING_PUNCTUATION_REGEX = /[)\]}>.,!?;:'"`]+$/; +const INSTAGRAM_HANDLE_REGEX = + /(^|[^A-Za-z0-9._])(@[A-Za-z0-9_](?:[A-Za-z0-9._]{0,28}[A-Za-z0-9_])?)(?=$|[^A-Za-z0-9._])/g; + +type LinkPart = + | { + type: 'text'; + value: string; + } + | { + type: 'link'; + value: string; + href: string; + }; + +interface LinkifiedTextProps { + text: string; + className?: string; + linkClassName?: string; +} + +const normalizeHref = (url: string) => { + if (/^https?:\/\//i.test(url)) { + return url; + } + + return `https://${url}`; +}; + +const splitTrailingPunctuation = (value: string) => { + const trailing = value.match(TRAILING_PUNCTUATION_REGEX)?.[0] ?? ''; + + if (!trailing) { + return { link: value, trailing: '' }; + } + + return { + link: value.slice(0, -trailing.length), + trailing, + }; +}; + +const parseLinkParts = (text: string): LinkPart[] => { + const parts: LinkPart[] = []; + const matcher = new RegExp(URL_REGEX); + let lastIndex = 0; + let match = matcher.exec(text); + + while (match) { + const matchedText = match[0]; + const startIndex = match.index; + const endIndex = startIndex + matchedText.length; + + if (startIndex > lastIndex) { + parts.push({ type: 'text', value: text.slice(lastIndex, startIndex) }); + } + + const { link, trailing } = splitTrailingPunctuation(matchedText); + + if (link) { + parts.push({ type: 'link', value: link, href: normalizeHref(link) }); + } + + if (trailing) { + parts.push({ type: 'text', value: trailing }); + } + + lastIndex = endIndex; + match = matcher.exec(text); + } + + if (lastIndex < text.length) { + parts.push({ type: 'text', value: text.slice(lastIndex) }); + } + + return parts; +}; + +const parseInstagramParts = (text: string): LinkPart[] => { + const parts: LinkPart[] = []; + const matcher = new RegExp(INSTAGRAM_HANDLE_REGEX); + let lastIndex = 0; + let match = matcher.exec(text); + + while (match) { + const prefix = match[1] ?? ''; + const handle = match[2]; + const startIndex = match.index; + const handleStartIndex = startIndex + prefix.length; + const handleEndIndex = handleStartIndex + handle.length; + + if (startIndex > lastIndex) { + parts.push({ type: 'text', value: text.slice(lastIndex, startIndex) }); + } + + if (prefix) { + parts.push({ type: 'text', value: prefix }); + } + + parts.push({ + type: 'link', + value: handle, + href: `https://instagram.com/${handle.slice(1)}`, + }); + + lastIndex = handleEndIndex; + match = matcher.exec(text); + } + + if (lastIndex < text.length) { + parts.push({ type: 'text', value: text.slice(lastIndex) }); + } + + return parts; +}; + +function LinkifiedText({ text, className, linkClassName }: LinkifiedTextProps) { + const parts = useMemo(() => { + return parseLinkParts(text).flatMap((part) => { + if (part.type === 'link') { + return [part]; + } + + return parseInstagramParts(part.value); + }); + }, [text]); + + const content = parts.map((part, index) => { + if (part.type === 'text') { + return {part.value}; + } + + return ( + + {part.value} + + ); + }); + + if (className) { + return {content}; + } + + return <>{content}; +} + +export default LinkifiedText; diff --git a/src/pages/Chat/ChatRoom.tsx b/src/pages/Chat/ChatRoom.tsx index a2e36473..dd07437b 100644 --- a/src/pages/Chat/ChatRoom.tsx +++ b/src/pages/Chat/ChatRoom.tsx @@ -2,6 +2,7 @@ import { useRef, useState } from 'react'; import clsx from 'clsx'; import { useParams } from 'react-router-dom'; import PaperPlaneIcon from '@/assets/svg/paper-plane.svg'; +import LinkifiedText from '@/components/common/LinkifiedText'; import useKeyboardHeight from '@/utils/hooks/useViewportHeight'; import useChat from './hooks/useChat'; import useChatRoomScroll from './hooks/useChatRoomScroll'; @@ -117,7 +118,7 @@ function ChatRoom() { )}
- {message.content} +
@@ -134,7 +135,7 @@ function ChatRoom() { {message.isMine && (
- {message.content} +
diff --git a/src/pages/Club/ClubDetail/components/ClubRecruitment.tsx b/src/pages/Club/ClubDetail/components/ClubRecruitment.tsx index 8d4604b0..60e8f629 100644 --- a/src/pages/Club/ClubDetail/components/ClubRecruitment.tsx +++ b/src/pages/Club/ClubDetail/components/ClubRecruitment.tsx @@ -1,6 +1,7 @@ import { Link, useNavigate } from 'react-router-dom'; import BottomModal from '@/components/common/BottomModal'; import Card from '@/components/common/Card'; +import LinkifiedText from '@/components/common/LinkifiedText'; import { useClubApplicationStore } from '@/stores/clubApplicationStore'; import useBooleanState from '@/utils/hooks/useBooleanState'; import useClubApply from '../../Application/hooks/useClubApply'; @@ -20,6 +21,7 @@ function ClubRecruitment({ clubId, isMember }: ClubRecruitProps) { const setApplication = useClubApplicationStore((s) => s.setApplication); const isRecruitmentOpen = clubRecruitment.status === 'ONGOING'; const canApply = isRecruitmentOpen && !clubRecruitment.isApplied && !isMember; + const recruitmentContent = clubRecruitment.content.replace(/\\n/g, '\n'); const handleApply = () => { if (isFeeRequired) { @@ -44,8 +46,8 @@ function ClubRecruitment({ clubId, isMember }: ClubRecruitProps) {
신입 회원 모집
모집 기간 :{' '} - {clubRecruitment.startDate && clubRecruitment.endDate - ? `${clubRecruitment.startDate} ~ ${clubRecruitment.endDate}` + {clubRecruitment.startAt && clubRecruitment.endAt + ? `${clubRecruitment.startAt} ~ ${clubRecruitment.endAt}` : '상시 모집'}
@@ -78,7 +80,7 @@ function ClubRecruitment({ clubId, isMember }: ClubRecruitProps) {
모집 공고
- {clubRecruitment.content.replace(/\\n/g, '\n')} +
{clubRecruitment.images.length > 0 && (
diff --git a/src/pages/Club/ClubList/components/ClubCard.tsx b/src/pages/Club/ClubList/components/ClubCard.tsx index c8ec4ff8..6e859987 100644 --- a/src/pages/Club/ClubList/components/ClubCard.tsx +++ b/src/pages/Club/ClubList/components/ClubCard.tsx @@ -7,7 +7,8 @@ interface ClubCardProps { } function getDDay(dateString: string): string { - const [year, month, day] = dateString.split('.').map(Number); + const datePart = dateString.split('T')[0].split(' ')[0].replace(/-/g, '.'); + const [year, month, day] = datePart.split('.').map(Number); const targetDate = new Date(year, month - 1, day); const today = new Date(); diff --git a/src/pages/Manager/ManagedApplicationDetail/index.tsx b/src/pages/Manager/ManagedApplicationDetail/index.tsx index 7ee6fbfc..9724faf9 100644 --- a/src/pages/Manager/ManagedApplicationDetail/index.tsx +++ b/src/pages/Manager/ManagedApplicationDetail/index.tsx @@ -5,7 +5,7 @@ import BottomModal from '@/components/common/BottomModal'; import Portal from '@/components/common/Portal'; import { useToastContext } from '@/contexts/useToastContext'; import useBooleanState from '@/utils/hooks/useBooleanState'; -import { formatIsoDateToYYYYMMDD } from '@/utils/ts/date'; +import { formatIsoDateToYYYYMMDDHHMM } from '@/utils/ts/date'; import { useApproveApplication, useRejectApplication, @@ -61,7 +61,9 @@ function ManagedApplicationDetail() { {application.name} ({application.studentNumber})
-
지원일: {formatIsoDateToYYYYMMDD(application.appliedAt)}
+
+ 지원일: {formatIsoDateToYYYYMMDDHHMM(application.appliedAt)} +
@@ -105,27 +107,26 @@ function ManagedApplicationDetail() { ))} - - -
- - +
+ + +
{/* Approve Confirm Modal */} diff --git a/src/pages/Manager/ManagedApplicationList/index.tsx b/src/pages/Manager/ManagedApplicationList/index.tsx index 41a113ea..6f19d0cb 100644 --- a/src/pages/Manager/ManagedApplicationList/index.tsx +++ b/src/pages/Manager/ManagedApplicationList/index.tsx @@ -1,7 +1,7 @@ import { useNavigate, useParams } from 'react-router-dom'; import Card from '@/components/common/Card'; import UserInfoCard from '@/pages/User/MyPage/components/UserInfoCard'; -import { formatIsoDateToYYYYMMDD } from '@/utils/ts/date'; +import { formatIsoDateToYYYYMMDDHHMM } from '@/utils/ts/date'; import { useApproveApplication, useRejectApplication, @@ -17,7 +17,7 @@ function ManagedApplicationList() { const { mutate: approve, isPending: isApproving } = useApproveApplication(clubId); const { mutate: reject, isPending: isRejecting } = useRejectApplication(clubId); - const total = managedClubApplicationList?.applications.length ?? 0; + const total = managedClubApplicationList?.totalCount ?? 0; const isPending = isApproving || isRejecting; const handleApprove = (e: React.MouseEvent, applicationId: number) => { @@ -60,7 +60,9 @@ function ManagedApplicationList() {
{application.name} ({application.studentNumber})
-
지원일 : {formatIsoDateToYYYYMMDD(application.appliedAt)}
+
+ 지원일 : {formatIsoDateToYYYYMMDDHHMM(application.appliedAt)} +
diff --git a/src/pages/Manager/ManagedRecruitment/index.tsx b/src/pages/Manager/ManagedRecruitment/index.tsx index 9d6fde20..3c8ec8a8 100644 --- a/src/pages/Manager/ManagedRecruitment/index.tsx +++ b/src/pages/Manager/ManagedRecruitment/index.tsx @@ -44,7 +44,7 @@ function ManagedRecruitment() { if (!settings?.isRecruitmentEnabled) return '모집공고가 비활성화되어 있습니다.'; if (!settings.recruitment) return '모집 기간을 설정해 주세요.'; if (settings.recruitment.isAlwaysRecruiting) return '상시 모집'; - return `모집 기간: ${settings.recruitment.startDate} ~ ${settings.recruitment.endDate}`; + return `모집 기간: ${settings.recruitment.startAt} ~ ${settings.recruitment.endAt}`; })(); const applicationContent = (() => { diff --git a/src/pages/Manager/ManagedRecruitmentWrite/index.tsx b/src/pages/Manager/ManagedRecruitmentWrite/index.tsx index 50f09549..81064caa 100644 --- a/src/pages/Manager/ManagedRecruitmentWrite/index.tsx +++ b/src/pages/Manager/ManagedRecruitmentWrite/index.tsx @@ -4,14 +4,25 @@ import { twMerge } from 'tailwind-merge'; import CalendarIcon from '@/assets/svg/calendar.svg'; import ChevronLeft from '@/assets/svg/chevron-left.svg'; import ChevronRight from '@/assets/svg/chevron-right.svg'; +import ClockIcon from '@/assets/svg/clock.svg'; import ImageIcon from '@/assets/svg/image.svg'; import BottomModal from '@/components/common/BottomModal'; import ToggleSwitch from '@/components/common/ToggleSwitch'; import DatePicker from '@/pages/Manager/components/DatePicker'; +import TimePicker from '@/pages/Manager/components/TimePicker'; import { useCreateRecruitment, useGetManagedRecruitments } from '@/pages/Manager/hooks/useManagedRecruitment'; import { usePatchClubSettings } from '@/pages/Manager/hooks/useManagedSettings'; import useBooleanState from '@/utils/hooks/useBooleanState'; import useUploadImage from '@/utils/hooks/useUploadImage'; +import { formatDateDot } from '@/utils/ts/date'; +import { + combineDateTime, + DEFAULT_END_TIME, + DEFAULT_START_TIME, + formatDateTimeDot, + parseDateTimeDot, + TIME_MINUTE_STEP, +} from './utils'; interface ImageItem { file?: File; // 새 이미지일 경우에만 존재 @@ -19,23 +30,11 @@ interface ImageItem { isExisting?: boolean; // 기존 이미지 여부 } -const dateButtonStyle = - 'group flex min-h-14 w-full items-center justify-between rounded-xl border border-indigo-50 bg-white px-4 py-3.5 text-left shadow-[0_6px_16px_rgba(2,23,48,0.08)]'; const sectionCardStyle = 'flex w-full flex-col gap-4 rounded-2xl border border-indigo-25 bg-white px-4 py-4 shadow-[0_4px_12px_rgba(2,23,48,0.06)]'; const sectionTitleStyle = 'text-h3 text-indigo-700'; - -function formatDateDot(date: Date): string { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - return `${year}.${month}.${day}`; -} - -function parseDateDot(dateStr: string): Date { - const [year, month, day] = dateStr.split('.').map(Number); - return new Date(year, month - 1, day); -} +const compactButtonStyle = + 'group flex h-10 min-w-0 w-full items-center justify-between rounded-lg border border-indigo-50 bg-white px-3 text-left shadow-[0_2px_6px_rgba(2,23,48,0.06)]'; function ManagedRecruitmentWrite() { const { clubId } = useParams<{ clubId: string }>(); @@ -43,6 +42,8 @@ function ManagedRecruitmentWrite() { const location = useLocation(); const [startDate, setStartDate] = useState(new Date()); const [endDate, setEndDate] = useState(new Date()); + const [startTime, setStartTime] = useState(DEFAULT_START_TIME); + const [endTime, setEndTime] = useState(DEFAULT_END_TIME); const [content, setContent] = useState(''); const [isAlwaysRecruiting, setIsAlwaysRecruiting] = useState(false); const [images, setImages] = useState([]); @@ -68,11 +69,19 @@ function ManagedRecruitmentWrite() { if (!existingRecruitment) return; setContent(existingRecruitment.content); - if (existingRecruitment.startDate && existingRecruitment.endDate) { - setStartDate(parseDateDot(existingRecruitment.startDate)); - setEndDate(parseDateDot(existingRecruitment.endDate)); + if (existingRecruitment.startAt && existingRecruitment.endAt) { + const parsedStart = parseDateTimeDot(existingRecruitment.startAt, DEFAULT_START_TIME); + const parsedEnd = parseDateTimeDot(existingRecruitment.endAt, DEFAULT_END_TIME); + if (parsedStart) { + setStartDate(parsedStart.date); + setStartTime(parsedStart.time); + } + if (parsedEnd) { + setEndDate(parsedEnd.date); + setEndTime(parsedEnd.time); + } } - const isAlways = !existingRecruitment.startDate || !existingRecruitment.endDate; + const isAlways = !existingRecruitment.startAt || !existingRecruitment.endAt; setIsAlwaysRecruiting(isAlways); if (existingRecruitment.images && existingRecruitment.images.length > 0) { setImages( @@ -86,23 +95,24 @@ function ManagedRecruitmentWrite() { closeChoiceModal(); }; - const today = new Date(); - today.setHours(0, 0, 0, 0); - - const isStartAfterEnd = startDate > endDate; - const isEndBeforeToday = endDate < today; - const hasDateError = !isAlwaysRecruiting && (isStartAfterEnd || isEndBeforeToday); + const startDateTime = combineDateTime(startDate, startTime); + const endDateTime = combineDateTime(endDate, endTime); + const isStartAfterEnd = startDateTime >= endDateTime; + const isEndBeforeNow = endDateTime < new Date(); + const hasDateError = !isAlwaysRecruiting && (isStartAfterEnd || isEndBeforeNow); const getDateErrorMessage = () => { if (isAlwaysRecruiting) return null; - if (isStartAfterEnd) return '시작일은 종료일보다 앞서야 합니다.'; - if (isEndBeforeToday) return '종료일은 오늘 이후여야 합니다.'; + if (isStartAfterEnd) return '시작 일시는 종료 일시보다 앞서야 합니다.'; + if (isEndBeforeNow) return '종료 일시는 현재 이후여야 합니다.'; return null; }; const handleReset = () => { setStartDate(new Date()); setEndDate(new Date()); + setStartTime(DEFAULT_START_TIME); + setEndTime(DEFAULT_END_TIME); setContent(''); setIsAlwaysRecruiting(false); images.forEach((img) => { @@ -191,8 +201,8 @@ function ManagedRecruitmentWrite() { content, images: imageData, isAlwaysRecruiting: false, - startDate: formatDateDot(startDate), - endDate: formatDateDot(endDate), + startAt: formatDateTimeDot(startDate, startTime, DEFAULT_START_TIME), + endAt: formatDateTimeDot(endDate, endTime, DEFAULT_END_TIME), }, { onSuccess } ); @@ -224,59 +234,104 @@ function ManagedRecruitmentWrite() { {isAlwaysRecruiting ? (

상시 모집이 설정되어 있어 모집 기간 제한이 없습니다.

) : ( -
- ( - +
+
-
-
- ~ -
-
- ( - + )} + /> + ( + + )} + />
- -
+ +
+ +
+
+ 종료 +
+ ( + + )} /> - - - )} - /> - {hasDateError &&

{getDateErrorMessage()}

} + ( + + )} + /> +
+
+
+
+ {hasDateError && ( +
+ {getDateErrorMessage()} +
+ )}
)} diff --git a/src/pages/Manager/ManagedRecruitmentWrite/utils.ts b/src/pages/Manager/ManagedRecruitmentWrite/utils.ts new file mode 100644 index 00000000..6a3cf0f9 --- /dev/null +++ b/src/pages/Manager/ManagedRecruitmentWrite/utils.ts @@ -0,0 +1,40 @@ +export const TIME_MINUTE_STEP = 5; +export const DEFAULT_START_TIME = '00:00'; +export const DEFAULT_END_TIME = '23:55'; + +export function isValidTimeFormat(value: string): boolean { + return /^([01]\d|2[0-3]):([0-5]\d)$/.test(value); +} + +export function normalizeTime(value: string, fallback: string): string { + const target = isValidTimeFormat(value) ? value : fallback; + const [hour, minute] = target.split(':').map(Number); + const normalizedMinute = Math.floor(minute / TIME_MINUTE_STEP) * TIME_MINUTE_STEP; + return `${String(hour).padStart(2, '0')}:${String(normalizedMinute).padStart(2, '0')}`; +} + +export function parseDateTimeDot(value: string, fallbackTime: string): { date: Date; time: string } | null { + const [datePart, timePart] = value.trim().split(/\s+/); + const [year, month, day] = datePart.split('.').map(Number); + + if (![year, month, day].every(Number.isFinite)) return null; + + const date = new Date(year, month - 1, day); + if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) return null; + + return { date, time: normalizeTime(timePart ?? '', fallbackTime) }; +} + +export function formatDateTimeDot(date: Date, time: string, fallbackTime: string): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}.${month}.${day} ${normalizeTime(time, fallbackTime)}`; +} + +export function combineDateTime(date: Date, time: string): Date { + const [hours, minutes] = normalizeTime(time, DEFAULT_START_TIME).split(':').map(Number); + const next = new Date(date); + next.setHours(hours, minutes, 0, 0); + return next; +} diff --git a/src/pages/Manager/components/TimePicker/index.tsx b/src/pages/Manager/components/TimePicker/index.tsx new file mode 100644 index 00000000..8b6dfee2 --- /dev/null +++ b/src/pages/Manager/components/TimePicker/index.tsx @@ -0,0 +1,244 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { twMerge } from 'tailwind-merge'; +import BottomModal from '@/components/common/BottomModal'; +import useBooleanState from '@/utils/hooks/useBooleanState'; + +interface TimePickerProps { + value: string; + onChange: (value: string) => void; + minuteStep?: number; + renderTrigger?: (toggle: () => void) => React.ReactNode; +} + +const TIME_REGEX = /^([01]\d|2[0-3]):([0-5]\d)$/; +const ITEM_HEIGHT = 40; +const PICKER_HEIGHT = 200; +const PADDING_HEIGHT = (PICKER_HEIGHT - ITEM_HEIGHT) / 2; + +function pad2(value: number): string { + return String(value).padStart(2, '0'); +} + +function parseTime(value: string): { hour: number; minute: number } { + const matched = value.match(TIME_REGEX); + if (!matched) { + return { hour: 0, minute: 0 }; + } + + return { hour: Number(matched[1]), minute: Number(matched[2]) }; +} + +function formatTime(hour: number, minute: number): string { + return `${pad2(hour)}:${pad2(minute)}`; +} + +export default function TimePicker({ value, onChange, minuteStep = 5, renderTrigger }: TimePickerProps) { + const { value: isOpen, setTrue: openModal, setFalse: closeModal } = useBooleanState(false); + const hourRef = useRef(null); + const minuteRef = useRef(null); + const [draftHour, setDraftHour] = useState(0); + const [draftMinute, setDraftMinute] = useState(0); + const [openSelection, setOpenSelection] = useState<{ hour: number; minute: number } | null>(null); + + const hourOptions = useMemo(() => Array.from({ length: 24 }, (_, hour) => hour), []); + const safeMinuteStep = useMemo( + () => (Number.isInteger(minuteStep) && minuteStep > 0 && minuteStep <= 30 ? minuteStep : 5), + [minuteStep] + ); + + const normalizeMinuteByStep = useCallback( + (minute: number) => Math.floor(minute / safeMinuteStep) * safeMinuteStep, + [safeMinuteStep] + ); + + const minuteOptions = useMemo(() => { + const options: number[] = []; + for (let minute = 0; minute < 60; minute += safeMinuteStep) { + options.push(minute); + } + return options; + }, [safeMinuteStep]); + + const syncScrollPosition = useCallback( + (ref: React.RefObject, items: number[], current: number) => { + if (!ref.current) return; + + const index = items.indexOf(current); + if (index < 0) return; + ref.current.scrollTo({ top: index * ITEM_HEIGHT, behavior: 'auto' }); + }, + [] + ); + + const createScrollHandler = useCallback( + (items: number[], onChangeValue: (value: number) => void) => (e: React.UIEvent) => { + const scrollTop = e.currentTarget.scrollTop; + const nearestIndex = Math.round(scrollTop / ITEM_HEIGHT); + const clampedIndex = Math.max(0, Math.min(items.length - 1, nearestIndex)); + const next = items[clampedIndex]; + + if (next !== undefined) { + onChangeValue(next); + } + }, + [] + ); + + const scrollToItem = useCallback( + (ref: React.RefObject, items: number[], valueToFind: number) => { + if (!ref.current) return; + + const index = items.indexOf(valueToFind); + if (index < 0) return; + ref.current.scrollTo({ top: index * ITEM_HEIGHT, behavior: 'smooth' }); + }, + [] + ); + + const handleOpenModal = () => { + const parsed = parseTime(value); + const normalizedMinute = normalizeMinuteByStep(parsed.minute); + setDraftHour(parsed.hour); + setDraftMinute(normalizedMinute); + setOpenSelection({ hour: parsed.hour, minute: normalizedMinute }); + openModal(); + }; + + const handleCloseModal = () => { + closeModal(); + setOpenSelection(null); + }; + + useEffect(() => { + if (!isOpen) return; + const parsedFromValue = parseTime(value); + const fallback = { + hour: parsedFromValue.hour, + minute: normalizeMinuteByStep(parsedFromValue.minute), + }; + const initial = openSelection ?? fallback; + + const rafId = requestAnimationFrame(() => { + syncScrollPosition(hourRef, hourOptions, initial.hour); + syncScrollPosition(minuteRef, minuteOptions, initial.minute); + }); + + return () => cancelAnimationFrame(rafId); + }, [isOpen, openSelection, value, hourOptions, minuteOptions, normalizeMinuteByStep, syncScrollPosition]); + + const handleConfirm = () => { + onChange(formatTime(draftHour, draftMinute)); + handleCloseModal(); + }; + + const renderTriggerElement = () => { + if (renderTrigger) return renderTrigger(handleOpenModal); + + return ( + + ); + }; + + return ( + <> + {renderTriggerElement()} + +
+
시간 선택
+

스크롤해서 시간을 선택하세요

+
+
+
+
+
+ +
+
+
+ {hourOptions.map((hour) => { + const isSelected = hour === draftHour; + return ( + + ); + })} +
+
+ +
+
+ {minuteOptions.map((minute) => { + const isSelected = minute === draftMinute; + return ( + + ); + })} +
+
+
+
+
+
+ 선택 시간 {formatTime(draftHour, draftMinute)} +
+
+
+ + +
+
+ + + ); +} diff --git a/src/pages/Manager/hooks/useManagedApplications.ts b/src/pages/Manager/hooks/useManagedApplications.ts index afea2fcb..4ecceae7 100644 --- a/src/pages/Manager/hooks/useManagedApplications.ts +++ b/src/pages/Manager/hooks/useManagedApplications.ts @@ -29,7 +29,7 @@ export const useGetManagedApplications = (clubId: number) => { queryKey: applicationQueryKeys.managedClubApplications(clubId), queryFn: async () => { try { - return await getManagedClubApplications(clubId); + return await getManagedClubApplications(clubId, { sortBy: 'APPLIED_AT', sortDirection: 'ASC' }); } catch (error) { if (isApiError(error) && error.apiError?.code === 'NOT_FOUND_CLUB_RECRUITMENT') { return null; diff --git a/src/utils/ts/date.ts b/src/utils/ts/date.ts index 4b3ff19b..3af6a8dc 100644 --- a/src/utils/ts/date.ts +++ b/src/utils/ts/date.ts @@ -1,3 +1,18 @@ +export const formatIsoDateToYYYYMMDDHHMM = (value: string): string => { + if (!value) return ''; + + const [datePart, timePart] = value.split('T'); + const [year, month, day] = datePart.split('-'); + + if (!year || !month || !day) return value; + + const date = `${year}.${month.padStart(2, '0')}.${day.padStart(2, '0')}`; + if (!timePart) return date; + + const [hour, minute] = timePart.split(':'); + return `${date} ${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`; +}; + export const formatIsoDateToYYYYMMDD = (value: string): string => { if (!value) return ''; @@ -8,3 +23,10 @@ export const formatIsoDateToYYYYMMDD = (value: string): string => { return `${year}.${month.padStart(2, '0')}.${day.padStart(2, '0')}`; }; + +export function formatDateDot(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}.${month}.${day}`; +}