From bc5d9f688543eb395fed80cca3ed81674fa12b99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A4=80=EC=98=81?= Date: Sun, 22 Feb 2026 22:51:28 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=EB=AA=A8=EC=A7=91=20=EA=B3=B5?= =?UTF-8?q?=EA=B3=A0=EC=97=90=20=EC=8B=9C=EC=9E=91,=20=EC=A2=85=EB=A3=8C?= =?UTF-8?q?=20=EC=8B=9C=EA=B0=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apis/club/entity.ts | 20 +- .../ClubDetail/components/ClubRecruitment.tsx | 4 +- .../Club/ClubList/components/ClubCard.tsx | 3 +- .../Manager/ManagedRecruitment/index.tsx | 2 +- .../Manager/ManagedRecruitmentWrite/index.tsx | 230 ++++++++++++----- .../Manager/components/TimePicker/index.tsx | 244 ++++++++++++++++++ 6 files changed, 420 insertions(+), 83 deletions(-) create mode 100644 src/pages/Manager/components/TimePicker/index.tsx diff --git a/src/apis/club/entity.ts b/src/apis/club/entity.ts index 47219264..b3de64c1 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; @@ -176,13 +176,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 { @@ -290,8 +290,8 @@ export interface PreMemberDeleteRequest { //========================== Club Settings Entities =========================// interface ClubSettingsRecruitment { - startDate: string; - endDate: string; + startAt: string; + endAt: string; isAlwaysRecruiting: boolean; } diff --git a/src/pages/Club/ClubDetail/components/ClubRecruitment.tsx b/src/pages/Club/ClubDetail/components/ClubRecruitment.tsx index 8d4604b0..bb54a2a5 100644 --- a/src/pages/Club/ClubDetail/components/ClubRecruitment.tsx +++ b/src/pages/Club/ClubDetail/components/ClubRecruitment.tsx @@ -44,8 +44,8 @@ function ClubRecruitment({ clubId, isMember }: ClubRecruitProps) {
신입 회원 모집
모집 기간 :{' '} - {clubRecruitment.startDate && clubRecruitment.endDate - ? `${clubRecruitment.startDate} ~ ${clubRecruitment.endDate}` + {clubRecruitment.startAt && clubRecruitment.endAt + ? `${clubRecruitment.startAt} ~ ${clubRecruitment.endAt}` : '상시 모집'}
diff --git a/src/pages/Club/ClubList/components/ClubCard.tsx b/src/pages/Club/ClubList/components/ClubCard.tsx index c8ec4ff8..4bee16a3 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(' ')[0]; + 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/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..41c34c4a 100644 --- a/src/pages/Manager/ManagedRecruitmentWrite/index.tsx +++ b/src/pages/Manager/ManagedRecruitmentWrite/index.tsx @@ -4,10 +4,12 @@ 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'; @@ -19,11 +21,16 @@ 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'; +const compactDateButtonStyle = + '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)]'; +const compactTimeButtonStyle = + '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)]'; +const TIME_MINUTE_STEP = 5; +const DEFAULT_START_TIME = '00:00'; +const DEFAULT_END_TIME = '23:55'; function formatDateDot(date: Date): string { const year = date.getFullYear(); @@ -32,9 +39,38 @@ function formatDateDot(date: Date): string { return `${year}.${month}.${day}`; } -function parseDateDot(dateStr: string): Date { - const [year, month, day] = dateStr.split('.').map(Number); - return new Date(year, month - 1, day); +function isValidTimeFormat(value: string): boolean { + return /^([01]\d|2[0-3]):([0-5]\d)$/.test(value); +} + +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')}`; +} + +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) }; +} + +function formatDateTimeDot(date: Date, time: string, fallbackTime: string): string { + return `${formatDateDot(date)} ${normalizeTime(time, fallbackTime)}`; +} + +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; } function ManagedRecruitmentWrite() { @@ -43,6 +79,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 +106,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 +132,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 +238,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 +271,104 @@ function ManagedRecruitmentWrite() { {isAlwaysRecruiting ? (

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

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

{getDateErrorMessage()}

} + ( + + )} + /> +
+
+
+
+ {hasDateError && ( +
+ {getDateErrorMessage()} +
+ )}
)} 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)} +
+
+
+ + +
+
+ + + ); +} From 9eb540c2282095597aefb4fb0841cbdf62b1f0fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A4=80=EC=98=81?= Date: Sun, 22 Feb 2026 22:52:09 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20=EC=A7=80=EC=9B=90=EC=9E=90=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A0=95=EB=A0=AC=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=EC=8B=9C=EA=B0=84=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apis/club/entity.ts | 14 +++++- src/apis/club/index.ts | 6 ++- .../ManagedApplicationDetail/index.tsx | 47 ++++++++++--------- .../Manager/ManagedApplicationList/index.tsx | 8 ++-- .../Manager/hooks/useManagedApplications.ts | 2 +- src/utils/ts/date.ts | 15 ++++++ 6 files changed, 62 insertions(+), 30 deletions(-) diff --git a/src/apis/club/entity.ts b/src/apis/club/entity.ts index b3de64c1..be8288e8 100644 --- a/src/apis/club/entity.ts +++ b/src/apis/club/entity.ts @@ -137,13 +137,25 @@ export interface AppliedClubResponse { //========================== Club Manager Entities =========================// interface Application { id: number; - studentNumber: number; + studentNumber: string; name: string; imageUrl: string; appliedAt: string; + feePaymentImageUrl?: string; +} + +export interface ClubApplicationsParams { + page?: number; + limit?: number; + sortBy?: 'APPLIED_AT' | 'STUDENT_NUMBER' | 'NAME'; + sortDirection?: 'ASC' | 'DESC'; } export interface ClubApplicationsResponse { + totalCount: number; + currentCount: number; + totalPage: number; + currentPage: number; applications: Application[]; } diff --git a/src/apis/club/index.ts b/src/apis/club/index.ts index 02af9c9c..729e20c3 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,8 +90,9 @@ export const getManagedClubs = async () => { return response; }; -export const getManagedClubApplications = async (clubId: number) => { - const response = await apiClient.get(`clubs/${clubId}/applications`, { +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/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/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..582a5333 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 ''; From 9040175fe7afd4bf833fc8c98c38d23304327fba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A4=80=EC=98=81?= Date: Sun, 22 Feb 2026 22:59:58 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85,=20=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20=EA=B3=B5=EA=B3=A0=20=EB=A7=81=ED=81=AC=20=ED=8C=8C?= =?UTF-8?q?=EC=8B=B1=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/common/LinkifiedText.tsx | 109 ++++++++++++++++++ src/pages/Chat/ChatRoom.tsx | 5 +- .../ClubDetail/components/ClubRecruitment.tsx | 4 +- 3 files changed, 115 insertions(+), 3 deletions(-) create mode 100644 src/components/common/LinkifiedText.tsx diff --git a/src/components/common/LinkifiedText.tsx b/src/components/common/LinkifiedText.tsx new file mode 100644 index 00000000..cc8cf115 --- /dev/null +++ b/src/components/common/LinkifiedText.tsx @@ -0,0 +1,109 @@ +import { Fragment, useMemo } from 'react'; +import { cn } from '@/utils/ts/cn'; + +const URL_REGEX = /(?:https?:\/\/|www\.)[^\s]+/gi; +const TRAILING_PUNCTUATION_REGEX = /[)\]}>.,!?;:'"`]+$/; + +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; +}; + +function LinkifiedText({ text, className, linkClassName }: LinkifiedTextProps) { + const parts = useMemo(() => parseLinkParts(text), [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 bb54a2a5..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) { @@ -78,7 +80,7 @@ function ClubRecruitment({ clubId, isMember }: ClubRecruitProps) {
모집 공고
- {clubRecruitment.content.replace(/\\n/g, '\n')} +
{clubRecruitment.images.length > 0 && (
From 795d226bb4470dbd98588e8c264efcd5131ea7a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A4=80=EC=98=81?= Date: Sun, 22 Feb 2026 23:03:46 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20=EC=9D=B8=EC=8A=A4=ED=83=80?= =?UTF-8?q?=EA=B7=B8=EB=9E=A8=20=ED=8C=8C=EC=8B=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/common/LinkifiedText.tsx | 50 ++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/src/components/common/LinkifiedText.tsx b/src/components/common/LinkifiedText.tsx index cc8cf115..0c7d1433 100644 --- a/src/components/common/LinkifiedText.tsx +++ b/src/components/common/LinkifiedText.tsx @@ -3,6 +3,8 @@ 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 = | { @@ -78,8 +80,54 @@ const parseLinkParts = (text: string): LinkPart[] => { 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(() => parseLinkParts(text), [text]); + 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') { From 4c9b5615562c39f33f7c27a1fa7615bafc0d54b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A4=80=EC=98=81?= Date: Sun, 22 Feb 2026 23:07:14 +0900 Subject: [PATCH 5/8] fix: prettier error --- src/apis/club/index.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/apis/club/index.ts b/src/apis/club/index.ts index 729e20c3..f1ec51b5 100644 --- a/src/apis/club/index.ts +++ b/src/apis/club/index.ts @@ -91,10 +91,13 @@ export const getManagedClubs = async () => { }; export const getManagedClubApplications = async (clubId: number, params?: ClubApplicationsParams) => { - const response = await apiClient.get(`clubs/${clubId}/applications`, { - params, - requiresAuth: true, - }); + const response = await apiClient.get( + `clubs/${clubId}/applications`, + { + params, + requiresAuth: true, + } + ); return response; }; From f89bb16bab8372384e8649beab0b2dde39afc0c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A4=80=EC=98=81?= Date: Sun, 22 Feb 2026 23:19:05 +0900 Subject: [PATCH 6/8] =?UTF-8?q?refactor:=20=EC=9C=A0=ED=8B=B8=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20=ED=83=80=EC=9E=85=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apis/club/entity.ts | 2 +- .../Club/ClubList/components/ClubCard.tsx | 2 +- .../Manager/ManagedRecruitmentWrite/index.tsx | 53 ++++--------------- .../Manager/ManagedRecruitmentWrite/utils.ts | 40 ++++++++++++++ src/utils/ts/date.ts | 7 +++ 5 files changed, 58 insertions(+), 46 deletions(-) create mode 100644 src/pages/Manager/ManagedRecruitmentWrite/utils.ts diff --git a/src/apis/club/entity.ts b/src/apis/club/entity.ts index be8288e8..169fba75 100644 --- a/src/apis/club/entity.ts +++ b/src/apis/club/entity.ts @@ -168,7 +168,7 @@ interface ApplicationAnswer { export interface ClubApplicationDetailResponse { applicationId: number; - studentNumber: number; + studentNumber: string; name: string; imageUrl: string; appliedAt: string; diff --git a/src/pages/Club/ClubList/components/ClubCard.tsx b/src/pages/Club/ClubList/components/ClubCard.tsx index 4bee16a3..6e859987 100644 --- a/src/pages/Club/ClubList/components/ClubCard.tsx +++ b/src/pages/Club/ClubList/components/ClubCard.tsx @@ -7,7 +7,7 @@ interface ClubCardProps { } function getDDay(dateString: string): string { - const datePart = dateString.split(' ')[0]; + 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/ManagedRecruitmentWrite/index.tsx b/src/pages/Manager/ManagedRecruitmentWrite/index.tsx index 41c34c4a..3dbfa353 100644 --- a/src/pages/Manager/ManagedRecruitmentWrite/index.tsx +++ b/src/pages/Manager/ManagedRecruitmentWrite/index.tsx @@ -14,6 +14,15 @@ import { useCreateRecruitment, useGetManagedRecruitments } from '@/pages/Manager 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; // 새 이미지일 경우에만 존재 @@ -28,50 +37,6 @@ const compactDateButtonStyle = '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)]'; const compactTimeButtonStyle = '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)]'; -const TIME_MINUTE_STEP = 5; -const DEFAULT_START_TIME = '00:00'; -const DEFAULT_END_TIME = '23:55'; - -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 isValidTimeFormat(value: string): boolean { - return /^([01]\d|2[0-3]):([0-5]\d)$/.test(value); -} - -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')}`; -} - -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) }; -} - -function formatDateTimeDot(date: Date, time: string, fallbackTime: string): string { - return `${formatDateDot(date)} ${normalizeTime(time, fallbackTime)}`; -} - -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; -} function ManagedRecruitmentWrite() { const { clubId } = useParams<{ clubId: string }>(); 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/utils/ts/date.ts b/src/utils/ts/date.ts index 582a5333..3af6a8dc 100644 --- a/src/utils/ts/date.ts +++ b/src/utils/ts/date.ts @@ -23,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}`; +} From a642c551ff75118bc78a34e63f505ab255fec898 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A4=80=EC=98=81?= Date: Sun, 22 Feb 2026 23:44:21 +0900 Subject: [PATCH 7/8] =?UTF-8?q?chore:=20=ED=83=80=EC=9E=85=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apis/club/entity.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/apis/club/entity.ts b/src/apis/club/entity.ts index 169fba75..2271e2a6 100644 --- a/src/apis/club/entity.ts +++ b/src/apis/club/entity.ts @@ -301,11 +301,17 @@ export interface PreMemberDeleteRequest { //========================== Club Settings Entities =========================// -interface ClubSettingsRecruitment { - startAt: string; - endAt: string; - isAlwaysRecruiting: boolean; -} +type ClubSettingsRecruitment = + | { + isAlwaysRecruiting: true; + startAt?: never; + endAt?: never; + } + | { + isAlwaysRecruiting: false; + startAt: string; + endAt: string; + }; interface ClubSettingsApplication { questionCount: number; From 104d73e54eabedf4c4cfec5e2daa04b3f598b348 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A4=80=EC=98=81?= Date: Sun, 22 Feb 2026 23:47:22 +0900 Subject: [PATCH 8/8] =?UTF-8?q?chore:=20=EB=8F=99=EC=9D=BC=20=EC=8A=A4?= =?UTF-8?q?=ED=83=80=EC=9D=BC=20=ED=86=B5=ED=95=A9=20=EB=B0=8F=20entity=20?= =?UTF-8?q?=ED=99=95=EC=9E=A5=20=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apis/club/entity.ts | 6 +----- src/pages/Manager/ManagedRecruitmentWrite/index.tsx | 12 +++++------- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/src/apis/club/entity.ts b/src/apis/club/entity.ts index 2271e2a6..82c3af4c 100644 --- a/src/apis/club/entity.ts +++ b/src/apis/club/entity.ts @@ -151,11 +151,7 @@ export interface ClubApplicationsParams { sortDirection?: 'ASC' | 'DESC'; } -export interface ClubApplicationsResponse { - totalCount: number; - currentCount: number; - totalPage: number; - currentPage: number; +export interface ClubApplicationsResponse extends PaginationResponse { applications: Application[]; } diff --git a/src/pages/Manager/ManagedRecruitmentWrite/index.tsx b/src/pages/Manager/ManagedRecruitmentWrite/index.tsx index 3dbfa353..81064caa 100644 --- a/src/pages/Manager/ManagedRecruitmentWrite/index.tsx +++ b/src/pages/Manager/ManagedRecruitmentWrite/index.tsx @@ -33,9 +33,7 @@ interface ImageItem { 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'; -const compactDateButtonStyle = - '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)]'; -const compactTimeButtonStyle = +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() { @@ -253,7 +251,7 @@ function ManagedRecruitmentWrite() { selectedDate={startDate} onChange={setStartDate} renderTrigger={(toggle) => ( -