diff --git a/src/app/globals.css b/src/app/globals.css index 0975fcf2..0e761cfa 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -128,6 +128,7 @@ --sub2-weight: var(--font-weight-black); --sub3-size: 16px; --sub3-line-height: 20px; + --sub3-weight: var(--font-weight-semibold); --body1-size: 16px; --body1-line-height: 24px; --body1-weight: 470; @@ -212,6 +213,7 @@ --sub2-size: 14px; --sub2-line-height: 18px; --sub3-size: 14px; + --font-weight: var(--sub3-weight); --sub3-line-height: 18px; --body1-size: 14px; --body1-line-height: 22px; diff --git a/src/assets/icons/index.ts b/src/assets/icons/index.ts index ac12b53e..5c0e1b3e 100644 --- a/src/assets/icons/index.ts +++ b/src/assets/icons/index.ts @@ -51,3 +51,4 @@ export { default as TrashcanIcon } from './trash_can.svg'; export { default as PersonIcon } from './person.svg'; export { default as LogoutIcon } from './logout.svg'; export { default as TimeIcon } from './time.svg'; +export { default as LocationIcon } from './location.svg'; diff --git a/src/assets/icons/location.svg b/src/assets/icons/location.svg new file mode 100644 index 00000000..9b57a26c --- /dev/null +++ b/src/assets/icons/location.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/admin/CardinalDropdown.tsx b/src/components/admin/CardinalDropdown.tsx index 51318dc7..db34bcec 100644 --- a/src/components/admin/CardinalDropdown.tsx +++ b/src/components/admin/CardinalDropdown.tsx @@ -24,7 +24,7 @@ function CardinalDropdown({ cardinals, activeCardinal, onSelect }: CardinalDropd type="button" className="border-line flex cursor-pointer items-center gap-700 rounded-sm border py-300 pr-300 pl-400" > - + {activeCardinal ? `${activeCardinal.cardinalNumber}기` : '기수'} diff --git a/src/components/admin/index.ts b/src/components/admin/index.ts index 90e245fa..70f3b87d 100644 --- a/src/components/admin/index.ts +++ b/src/components/admin/index.ts @@ -1,5 +1,6 @@ // admin components index file export { CardinalDropdown, type CardinalDropdownProps } from './CardinalDropdown'; +export { ModalIconButton, type ModalIconButtonProps } from './modal/ModalIconButton'; export { AttendanceCard, type AttendanceCardProps } from './attendance/AttendanceCard'; export { AttendancePageContent } from './attendance/AttendancePageContent'; export { AdminInfoCard, type AdminInfoCardProps } from './club-info/AdminInfoCard'; @@ -21,13 +22,16 @@ export { MemberPageContent } from './member/MemberPageContent'; export { MemberSearchBar, type MemberSearchBarProps } from './member/MemberSearchBar'; export { MemberTable } from './member/MemberTable'; export { MemberTopBar, type MemberTopBarProps } from './member/MemberTopBar'; -export { SchedulePageContent } from './schedule/SchedulePageContent'; +export { SchedulePageContent } from './schedule/general/SchedulePageContent'; export { EditScheduleModal, type EditScheduleModalProps } from './schedule/modal/EditScheduleModal'; export { CreateScheduleModal, type CreateScheduleModalProps, } from './schedule/modal/CreateScheduleModal'; -export { ScheduleFormField, type ScheduleFormFieldProps } from './schedule/ScheduleFormField'; +export { + ScheduleFormField, + type ScheduleFormFieldProps, +} from './schedule/general/ScheduleFormField'; export { BoardCard, type BoardCardProps } from './board/BoardCard'; export { BoardToolbar, type BoardToolbarProps } from './board/BoardToolbar'; export { BoardPageContent } from './board/BoardPageContent'; diff --git a/src/components/admin/layout/Header.tsx b/src/components/admin/layout/Header.tsx index f2caed0b..418b6c6c 100644 --- a/src/components/admin/layout/Header.tsx +++ b/src/components/admin/layout/Header.tsx @@ -1,51 +1,51 @@ -'use client'; - -import { usePathname } from 'next/navigation'; +// const PAGE_METADATA: Record = { +// '/admin/member': { +// title: '멤버 관리', +// description: +// '가입 승인 등 멤버를 관리하는 페이지입니다. 정기모임을 모두 입력하신 후에 가입 승인을 해주시길 바랍니다.', +// }, +// '/admin/schedule': { +// title: '일정 관리', +// description: +// '일반 일정과 세션 일정을 추가하고 수정하는 페이지입니다. 동아리 활동과 정기 모임 일정을 관리할 수 있습니다.', +// }, +// '/admin/attendance': { +// title: '출석 관리', +// description: '기수를 선택하고, 해당 모임에 대한 출석을 수정하는 페이지입니다.', +// }, +// '/admin/board': { +// title: '게시판 관리', +// description: +// '게시판을 관리하는 페이지입니다. 유저 서비스에서 사용할 게시판을 설정할 수 있습니다.', +// }, +// '/admin/club-info': { +// title: '동아리 관리', +// description: '부원에게 공유할 동아리의 프로필과 그 외의 정보를 수정하는 페이지입니다.', +// }, -const PAGE_METADATA: Record = { - '/admin/member': { - title: '멤버 관리', - description: - '가입 승인 등 멤버를 관리하는 페이지입니다. 정기모임을 모두 입력하신 후에 가입 승인을 해주시길 바랍니다.', - }, - '/admin/schedule': { - title: '일정 관리', - description: - '일반 일정과 세션 일정을 추가하고 수정하는 페이지입니다. 동아리 활동과 정기 모임 일정을 관리할 수 있습니다.', - }, - '/admin/attendance': { - title: '출석 관리', - description: '기수를 선택하고, 해당 모임에 대한 출석을 수정하는 페이지입니다.', - }, - '/admin/board': { - title: '게시판 관리', - description: - '게시판을 관리하는 페이지입니다. 유저 서비스에서 사용할 게시판을 설정할 수 있습니다.', - }, - '/admin/club-info': { - title: '동아리 관리', - description: '부원에게 공유할 동아리의 프로필과 그 외의 정보를 수정하는 페이지입니다.', - }, +// // '/admin/penalty': { +// // title: '페널티 관리', +// // description: '기수를 선택하고, 해당 멤버에 대한 페널티를 수정하는 페이지입니다.', +// // }, +// // '/admin/dues': { +// // title: '회비 관리', +// // description: +// // '기수 시작시 이월된 회비와 해당 기수 회비를 종합해 회비를 등록해주시기 바랍니다. 회비 등록은 기수당 한 번만 가능합니다.', +// // }, +// }; +'use client'; - // '/admin/penalty': { - // title: '페널티 관리', - // description: '기수를 선택하고, 해당 멤버에 대한 페널티를 수정하는 페이지입니다.', - // }, - // '/admin/dues': { - // title: '회비 관리', - // description: - // '기수 시작시 이월된 회비와 해당 기수 회비를 종합해 회비를 등록해주시기 바랍니다. 회비 등록은 기수당 한 번만 가능합니다.', - // }, -}; +import { useLogout } from '@/hooks'; export function Header() { - const pathname = usePathname(); + const handleLogout = useLogout(); + // const pathname = usePathname(); - const metadata = Object.entries(PAGE_METADATA).find(([path]) => pathname.startsWith(path))?.[1]; + // const metadata = Object.entries(PAGE_METADATA).find(([path]) => pathname.startsWith(path))?.[1]; return ( -
- {metadata && ( +
+ {/* {metadata && ( <>
{metadata.title} @@ -53,9 +53,13 @@ export function Header() { {metadata.description} - )} + )} */} -
diff --git a/src/components/admin/layout/NavItem.tsx b/src/components/admin/layout/NavItem.tsx index ee9839aa..2da052b7 100644 --- a/src/components/admin/layout/NavItem.tsx +++ b/src/components/admin/layout/NavItem.tsx @@ -51,21 +51,21 @@ function NavItem({ onClick={() => window.open(path, '_blank', 'noopener,noreferrer')} > {iconEl} - {!collapsed && {label}} + {!collapsed && {label}} ); } else if (external) { el = ( {iconEl} - {!collapsed && {label}} + {!collapsed && {label}} ); } else { el = ( {iconEl} - {!collapsed && {label}} + {!collapsed && {label}} ); } diff --git a/src/components/admin/schedule/general/MonthNavigator.tsx b/src/components/admin/schedule/general/MonthNavigator.tsx index 1f54ab73..b5d949e9 100644 --- a/src/components/admin/schedule/general/MonthNavigator.tsx +++ b/src/components/admin/schedule/general/MonthNavigator.tsx @@ -23,7 +23,7 @@ function MonthNavigator({ className, year, month, onPrev, onNext, ...props }: Mo > 이전 달 - {formatYearMonth(year, month)} + {formatYearMonth(year, month)} @@ -170,13 +137,13 @@ function SchedulePageContent() { schedules={sortedSchedules} onEdit={setEditTarget} onDelete={handleDelete} - onCreateClick={() => setCreateModalOpen(true)} + onCreateClick={() => openCreateModal('EVENT')} /> - setCreateModalOpen(true)} /> + openCreateModal('SESSION')} /> @@ -185,13 +152,14 @@ function SchedulePageContent() { open={createModalOpen} onOpenChange={setCreateModalOpen} cardinalNumber={activeCardinal?.cardinalNumber ?? null} - initialTab={activeTab === 'session' ? 'SESSION' : 'GENERAL'} + activeTab={createModalTab} + onActiveTabChange={setCreateModalTab} /> {/* Edit schedule modal */} - {editTarget && ( + {editTarget?.type === 'EVENT' && ( { if (!open) setEditTarget(null); @@ -200,6 +168,26 @@ function SchedulePageContent() { onDelete={handleDelete} /> )} + + {/* Edit session modal */} + {editTarget?.type === 'SESSION' && ( + { + if (!open) setEditTarget(null); + }} + target={{ + id: editTarget.id, + cardinal: editTarget.cardinal, + title: editTarget.title, + start: editTarget.start, + end: editTarget.end, + status: 'SCHEDULED', + }} + onDelete={() => handleDelete(editTarget)} + /> + )} ); } diff --git a/src/components/admin/schedule/general/ScheduleTag.tsx b/src/components/admin/schedule/general/ScheduleTag.tsx index 9a5ed7f3..39c5e5f3 100644 --- a/src/components/admin/schedule/general/ScheduleTag.tsx +++ b/src/components/admin/schedule/general/ScheduleTag.tsx @@ -3,7 +3,7 @@ import { cva, type VariantProps } from 'class-variance-authority'; import { cn } from '@/lib/cn'; import { Icon } from '@/components/ui'; import { AdminCalendarIcon } from '@/assets/icons/admin'; -import { PinIcon } from '@/assets/icons'; +import { LocationIcon } from '@/assets/icons'; const scheduleTagVariants = cva( 'typo-caption1 inline-flex h-6 items-center gap-100 rounded-sm px-200 py-100 whitespace-nowrap', @@ -31,7 +31,9 @@ function ScheduleTag({ className, variant, icon, children, ...props }: ScheduleT {icon === 'calendar' && ( )} - {icon === 'location' && } + {icon === 'location' && ( + + )} {children} ); diff --git a/src/components/admin/schedule/ScheduleTextField.tsx b/src/components/admin/schedule/general/ScheduleTextField.tsx similarity index 57% rename from src/components/admin/schedule/ScheduleTextField.tsx rename to src/components/admin/schedule/general/ScheduleTextField.tsx index 56ee1f9b..f758ae89 100644 --- a/src/components/admin/schedule/ScheduleTextField.tsx +++ b/src/components/admin/schedule/general/ScheduleTextField.tsx @@ -1,13 +1,20 @@ -import { ScheduleFormField } from '@/components/admin/schedule/ScheduleFormField'; +import { ScheduleFormField } from '@/components/admin/schedule/general/ScheduleFormField'; interface ScheduleTextFieldProps { label: string; value: string; onChange: (value: string) => void; placeholder?: string; + maxLength?: number; } -function ScheduleTextField({ label, value, onChange, placeholder }: ScheduleTextFieldProps) { +function ScheduleTextField({ + label, + value, + onChange, + placeholder, + maxLength, +}: ScheduleTextFieldProps) { return ( onChange(e.target.value)} placeholder={placeholder} + maxLength={maxLength} className="bg-container-neutral typo-body1 placeholder:text-text-alternative text-text-normal h-12 w-full rounded-sm px-400 py-300 focus:outline-none" /> + {maxLength !== undefined && ( + + {value.length}/{maxLength} + + )} ); } diff --git a/src/components/admin/schedule/ScheduleTextareaField.tsx b/src/components/admin/schedule/general/ScheduleTextareaField.tsx similarity index 57% rename from src/components/admin/schedule/ScheduleTextareaField.tsx rename to src/components/admin/schedule/general/ScheduleTextareaField.tsx index 03608ae0..3d6c3c88 100644 --- a/src/components/admin/schedule/ScheduleTextareaField.tsx +++ b/src/components/admin/schedule/general/ScheduleTextareaField.tsx @@ -1,10 +1,11 @@ -import { ScheduleFormField } from '@/components/admin/schedule/ScheduleFormField'; +import { ScheduleFormField } from '@/components/admin/schedule/general/ScheduleFormField'; interface ScheduleTextareaFieldProps { label: string; value: string; onChange: (value: string) => void; placeholder?: string; + maxLength?: number; } function ScheduleTextareaField({ @@ -12,6 +13,7 @@ function ScheduleTextareaField({ value, onChange, placeholder, + maxLength, }: ScheduleTextareaFieldProps) { return ( @@ -19,8 +21,14 @@ function ScheduleTextareaField({ value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder} - className="bg-container-neutral typo-body1 placeholder:text-text-alternative text-text-normal h-[150px] w-full resize-none rounded-sm px-400 py-300 focus:outline-none" + maxLength={maxLength} + className="bg-container-neutral typo-body1 placeholder:text-text-alternative text-text-normal h-37.5 w-full resize-none rounded-sm px-400 py-300 focus:outline-none" /> + {maxLength !== undefined && ( + + {value.length}/{maxLength} + + )} ); } diff --git a/src/components/admin/schedule/modal/CreateGeneralScheduleForm.tsx b/src/components/admin/schedule/modal/CreateGeneralScheduleForm.tsx index 041641a9..88ac1602 100644 --- a/src/components/admin/schedule/modal/CreateGeneralScheduleForm.tsx +++ b/src/components/admin/schedule/modal/CreateGeneralScheduleForm.tsx @@ -4,7 +4,13 @@ import { useState } from 'react'; import { Button } from '@/components/ui'; import { ScheduleFormBody } from '@/components/admin/schedule/modal/ScheduleFormBody'; +import { useCreateSchedule } from '@/hooks/queries/admin/useAdminScheduleQueries'; import { toDateInputValue } from '@/utils/shared/date'; +import { + isScheduleContentValid, + isScheduleLocationValid, + isScheduleTitleValid, +} from '@/utils/admin/scheduleFormUtils'; import { isDateRangeValid, type ScheduleFormState } from './types'; @@ -25,21 +31,36 @@ interface CreateGeneralScheduleFormProps { function CreateGeneralScheduleForm({ cardinalNumber, onClose }: CreateGeneralScheduleFormProps) { const [form, setForm] = useState(INITIAL_FORM); + const { mutate, isPending } = useCreateSchedule(); const updateForm = (patch: Partial) => setForm((prev) => ({ ...prev, ...patch })); - const isValid = form.title.trim().length > 0 && isDateRangeValid(form) && cardinalNumber !== null; + const isValid = + isScheduleTitleValid(form.title) && + isScheduleLocationValid(form.location) && + isScheduleContentValid(form.content) && + isDateRangeValid(form) && + cardinalNumber !== null; const handleSubmit = () => { - if (!isValid) return; - // TODO: 일반 일정 API 연동 시 구현 - onClose(); + if (!isValid || cardinalNumber === null) return; + mutate( + { + title: form.title, + content: form.content, + location: form.location, + cardinal: cardinalNumber, + start: `${form.startDate}T${form.startTime}:00`, + end: `${form.endDate}T${form.endTime}:00`, + }, + { onSuccess: onClose }, + ); }; return ( <> -
+

일정 생성

취소 -
diff --git a/src/components/admin/schedule/modal/CreateScheduleModal.tsx b/src/components/admin/schedule/modal/CreateScheduleModal.tsx index ee7f760e..7ce0c2e4 100644 --- a/src/components/admin/schedule/modal/CreateScheduleModal.tsx +++ b/src/components/admin/schedule/modal/CreateScheduleModal.tsx @@ -1,7 +1,5 @@ 'use client'; -import { useState } from 'react'; - import { Icon, Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui'; import { Dialog, DialogContent } from '@/components/ui/dialog'; import { AdminCloseIcon } from '@/assets/icons/admin'; @@ -15,7 +13,8 @@ interface CreateScheduleModalProps { open: boolean; onOpenChange: (open: boolean) => void; cardinalNumber: number | null; - initialTab?: ScheduleType; + activeTab: ScheduleType; + onActiveTabChange: (tab: ScheduleType) => void; onCreateSession?: (body: CreateSessionBody) => void; } @@ -23,36 +22,27 @@ function CreateScheduleModal({ open, onOpenChange, cardinalNumber, - initialTab = 'GENERAL', + activeTab, + onActiveTabChange, onCreateSession, }: CreateScheduleModalProps) { - const [activeTab, setActiveTab] = useState(initialTab); - - const handleClose = () => { - onOpenChange(false); - setActiveTab(initialTab); - }; - - const handleDialogOpenChange = (nextOpen: boolean) => { - onOpenChange(nextOpen); - if (!nextOpen) setActiveTab(initialTab); - }; + const handleClose = () => onOpenChange(false); return ( - + {/* Header with tabs */}
setActiveTab(v as ScheduleType)} + onValueChange={(v) => onActiveTabChange(v as ScheduleType)} className="gap-0" > - {SCHEDULE_TYPE_LABEL.GENERAL} + {SCHEDULE_TYPE_LABEL.EVENT} {SCHEDULE_TYPE_LABEL.SESSION} diff --git a/src/components/admin/schedule/modal/CreateSessionScheduleForm.tsx b/src/components/admin/schedule/modal/CreateSessionScheduleForm.tsx index c6c79988..7c3b48c2 100644 --- a/src/components/admin/schedule/modal/CreateSessionScheduleForm.tsx +++ b/src/components/admin/schedule/modal/CreateSessionScheduleForm.tsx @@ -8,6 +8,8 @@ import { useCardinals } from '@/hooks/queries'; import { addYearsToDateInput, toDateInputValue } from '@/utils/shared/date'; import type { CreateSessionBody } from '@/types/admin/session'; +import { isScheduleTitleValid } from '@/utils/admin/scheduleFormUtils'; + import { isDateRangeValid, type ScheduleFormState, type SessionFormState } from './types'; const INITIAL_FORM: ScheduleFormState = { @@ -78,7 +80,7 @@ function CreateSessionScheduleForm({ onCreateSession, onClose }: CreateSessionSc }; const isValid = - form.title.trim().length > 0 && + isScheduleTitleValid(form.title) && selectedCardinal != null && isDateRangeValid(form) && isRecurrenceEndValid; @@ -102,7 +104,7 @@ function CreateSessionScheduleForm({ onCreateSession, onClose }: CreateSessionSc return ( <> -
+

세션 생성

void) | null>(null); + + const handleClose = () => onOpenChange(false); + + return ( + { + if (nextOpen) return; + if (requestCloseRef.current) requestCloseRef.current(); + else handleClose(); + }} + > + { + if (hasChangesRef.current) e.preventDefault(); + }} + > + }> + + + + + ); +} + +function EditScheduleModalLoading({ onClose }: { onClose: () => void }) { + return ( + <> +
+
+ + 일반 일정 + +
+ +
+
+

일반 일정 수정

+
+

불러오는 중...

+
+
+ + ); +} + +interface EditScheduleModalContentProps { + scheduleId: number; + onClose: () => void; + onDelete?: (schedule: Schedule) => void; + hasChangesRef: RefObject; + requestCloseRef: RefObject<(() => void) | null>; +} + +function EditScheduleModalContent({ + scheduleId, + onClose, + onDelete, + hasChangesRef, + requestCloseRef, +}: EditScheduleModalContentProps) { + const { data: detail } = useAdminScheduleDetail(scheduleId); + + const [initialForm] = useState(() => toInitialScheduleForm(detail)); const [form, setForm] = useState(initialForm); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [discardSource, setDiscardSource] = useState<'close' | 'cancel' | null>(null); + const { mutate, isPending } = useUpdateSchedule(); + const hasChanges = isFormChanged(form, initialForm); + const isValid = + isScheduleTitleValid(form.title) && + isScheduleLocationValid(form.location) && + isScheduleContentValid(form.content) && + isDateRangeValid(form); + const updateForm = (patch: Partial) => { setForm((prev) => ({ ...prev, ...patch })); }; - const handleClose = () => onOpenChange(false); - const handleTryClose = (source: 'close' | 'cancel') => { - if (hasChanges) { - setDiscardSource(source); - } else { - handleClose(); - } + if (hasChanges) setDiscardSource(source); + else onClose(); }; + useEffect(() => { + hasChangesRef.current = hasChanges; + requestCloseRef.current = () => { + if (hasChanges) setDiscardSource('close'); + else onClose(); + }; + return () => { + hasChangesRef.current = false; + requestCloseRef.current = null; + }; + }, [hasChanges, hasChangesRef, onClose, requestCloseRef]); + const handleSubmit = () => { - if (!form.title.trim()) return; - // TODO: API 연동 시 수정 요청 - handleClose(); + if (!isValid) return; + mutate( + { + eventId: scheduleId, + body: { + title: form.title, + content: form.content, + location: form.location, + start: `${form.startDate}T${form.startTime}:00`, + end: `${form.endDate}T${form.endTime}:00`, + }, + }, + { onSuccess: onClose }, + ); }; const handleDeleteConfirm = () => { setDeleteConfirmOpen(false); - handleClose(); - onDelete?.(schedule); + onClose(); + onDelete?.(detail); }; const handleDiscardConfirm = () => { setDiscardSource(null); - handleClose(); + onClose(); }; const closeDiscardAlert = (source: 'close' | 'cancel') => (next: boolean) => { @@ -72,95 +183,67 @@ function EditScheduleModal({ open, onOpenChange, schedule, onDelete }: EditSched return ( <> - { - if (!nextOpen) handleTryClose('close'); - }} - > - { - if (hasChanges) e.preventDefault(); - }} - > - {/* Header */} -
-
- - 일반 일정 - -
-
- - - - - - setDeleteConfirmOpen(true)}> - 일반 일정 삭제 - - - - - - - -
-
- - {/* Body */} -
-

일반 일정 수정

- +
+ + 일반 일정 + +
+
+ + + + + + setDeleteConfirmOpen(true)}> + 일반 일정 삭제 + + + + + + handleTryClose('close')} /> -
- - {/* Footer */} -
- - - - -
- -
+ +
+
+ + {/* Body */} +
+

일반 일정 수정

+ +
+ + {/* Footer */} +
+ + + + +
{/* 삭제 확인 */} { - if (!form.title.trim()) return; + if (!isScheduleTitleValid(form.title)) return; if (isRecurring) { setSaveConfirmOpen(true); } else { @@ -96,7 +97,7 @@ function EditSessionModal({ open, onOpenChange, target, onDelete, onSave }: Edit }} > { if (hasChanges) e.preventDefault(); @@ -112,12 +113,7 @@ function EditSessionModal({ open, onOpenChange, target, onDelete, onSave }: Edit
- + setDeleteConfirmOpen(true)}> @@ -132,20 +128,17 @@ function EditSessionModal({ open, onOpenChange, target, onDelete, onSave }: Edit onConfirm={handleDiscardConfirm} placement="below-right" > - + />
{/* Body */} -
+

세션 수정

저장 diff --git a/src/components/admin/schedule/modal/ScheduleFormBody.tsx b/src/components/admin/schedule/modal/ScheduleFormBody.tsx index 7351571f..861d9ea0 100644 --- a/src/components/admin/schedule/modal/ScheduleFormBody.tsx +++ b/src/components/admin/schedule/modal/ScheduleFormBody.tsx @@ -1,7 +1,9 @@ -import { ScheduleTextField } from '@/components/admin/schedule/ScheduleTextField'; -import { ScheduleTextareaField } from '@/components/admin/schedule/ScheduleTextareaField'; -import { DateTimeInput } from '@/components/ui/DateTimeInput'; +import { DateTimeInput } from '@/components/ui'; +import { ScheduleTextField } from '@/components/admin/schedule/general/ScheduleTextField'; +import { ScheduleTextareaField } from '@/components/admin/schedule/general/ScheduleTextareaField'; +import { SCHEDULE_FIELD_LIMITS } from '@/utils/admin/scheduleFormUtils'; +import { isDateRangeValid } from './types'; import type { ScheduleFormState } from './types'; interface ScheduleFormBodyProps { @@ -24,23 +26,31 @@ function ScheduleFormBody({ value={form.title} onChange={(v) => onFormChange({ title: v })} placeholder={titlePlaceholder} + maxLength={SCHEDULE_FIELD_LIMITS.title} /> -
- onFormChange({ startDate: v })} - onTimeChange={(v) => onFormChange({ startTime: v })} - /> - onFormChange({ endDate: v })} - onTimeChange={(v) => onFormChange({ endTime: v })} - /> +
+
+ onFormChange({ startDate: v })} + onTimeChange={(v) => onFormChange({ startTime: v })} + /> + onFormChange({ endDate: v })} + onTimeChange={(v) => onFormChange({ endTime: v })} + /> +
+ {!isDateRangeValid(form) && ( + + 종료 일시는 시작 일시보다 이후여야 합니다. + + )}
onFormChange({ location: v })} placeholder="장소를 입력해주세요." + maxLength={SCHEDULE_FIELD_LIMITS.location} /> onFormChange({ content: v })} placeholder="일정에 대한 설명을 입력해주세요." + maxLength={SCHEDULE_FIELD_LIMITS.content} />
); diff --git a/src/components/admin/schedule/modal/SessionScheduleForm.tsx b/src/components/admin/schedule/modal/SessionScheduleForm.tsx index 9b9317e3..fe5b04e5 100644 --- a/src/components/admin/schedule/modal/SessionScheduleForm.tsx +++ b/src/components/admin/schedule/modal/SessionScheduleForm.tsx @@ -3,31 +3,27 @@ import Image from 'next/image'; import { + CalendarPicker, + DateTimeInput, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui'; -import { CalendarPicker } from '@/components/ui/CalendarPicker'; -import { DateTimeInput } from '@/components/ui/DateTimeInput'; import { ArrowDownIcon } from '@/assets/icons'; -import { ScheduleFormField } from '@/components/admin/schedule/ScheduleFormField'; -import { ScheduleTextField } from '@/components/admin/schedule/ScheduleTextField'; -import { ScheduleTextareaField } from '@/components/admin/schedule/ScheduleTextareaField'; +import { ScheduleFormField } from '@/components/admin/schedule/general/ScheduleFormField'; +import { ScheduleTextField } from '@/components/admin/schedule/general/ScheduleTextField'; +import { ScheduleTextareaField } from '@/components/admin/schedule/general/ScheduleTextareaField'; import { SESSION_RECURRENCE_LABEL, SESSION_RECURRENCE_OPTIONS, } from '@/constants/admin/session.constants'; import { addYearsToDateInput } from '@/utils/shared/date'; +import type { Cardinal } from '@/types/admin/cardinal'; import type { ScheduleFormState, SessionFormState } from './types'; import SessionInfobanner from '../session/SessionInfoBanner'; -interface Cardinal { - id: number; - cardinalNumber: number; -} - interface SessionScheduleFormProps { form: ScheduleFormState; onFormChange: (patch: Partial) => void; @@ -71,7 +67,7 @@ function SessionScheduleForm({ - + {cardinals.length === 0 ? ( 기수 없음 ) : ( @@ -120,7 +116,7 @@ function SessionScheduleForm({ - + {SESSION_RECURRENCE_OPTIONS.map((type) => ( diff --git a/src/components/layout/header/MobileNavSheet.tsx b/src/components/layout/header/MobileNavSheet.tsx index 6c6bd5c0..d944ceb1 100644 --- a/src/components/layout/header/MobileNavSheet.tsx +++ b/src/components/layout/header/MobileNavSheet.tsx @@ -13,14 +13,15 @@ import { MenuIcon, } from '@/assets/icons'; import { Icon, Sheet, SheetClose, SheetContent, SheetTrigger } from '@/components/ui'; -import { logoutAction } from '@/lib/actions/auth'; import { cn } from '@/lib/cn'; +import { useLogout } from '@/hooks'; import { useIsAdmin } from '@/hooks/shared'; function MobileNavSheet() { const pathname = usePathname(); const { clubId } = useParams<{ clubId: string }>(); const { isAdmin } = useIsAdmin(); + const handleLogout = useLogout(); const navItems = [ { id: 'home', label: 'HOME', href: `/${clubId}/home`, icon: HomeIcon }, @@ -43,7 +44,7 @@ function MobileNavSheet() { -
+
- +
); diff --git a/src/components/mypage/MyPageDropdownMenu.tsx b/src/components/mypage/MyPageDropdownMenu.tsx index d5689859..5017a49f 100644 --- a/src/components/mypage/MyPageDropdownMenu.tsx +++ b/src/components/mypage/MyPageDropdownMenu.tsx @@ -15,12 +15,13 @@ import { Icon, } from '@/components/ui'; import { AdminMeatballIcon } from '@/assets/icons/admin'; -import { logoutAction } from '@/lib/actions/auth'; +import { useLogout } from '@/hooks'; function MyPageDropdownMenu() { const { clubId } = useParams<{ clubId: string }>(); // const [withdrawOpen, setWithdrawOpen] = useState(false); const [logoutOpen, setLogoutOpen] = useState(false); + const handleLogout = useLogout(); return ( <> @@ -65,9 +66,7 @@ function MyPageDropdownMenu() { title={'로그아웃'} description="로그아웃 하시겠습니까?" > -
- 로그아웃 -
+ 로그아웃 취소 diff --git a/src/components/ui/CalendarPicker.tsx b/src/components/ui/CalendarPicker.tsx index 2a89c754..90c0a4ae 100644 --- a/src/components/ui/CalendarPicker.tsx +++ b/src/components/ui/CalendarPicker.tsx @@ -102,7 +102,7 @@ function CalendarPicker({ value, onChange, minDate, maxDate }: CalendarPickerPro {/* Month navigation */}
diff --git a/src/components/ui/DateTimeInput.tsx b/src/components/ui/DateTimeInput.tsx index ad38d0bf..a05f7c7a 100644 --- a/src/components/ui/DateTimeInput.tsx +++ b/src/components/ui/DateTimeInput.tsx @@ -24,7 +24,7 @@ function DateTimeInput({ return (
- {label} + {label}
diff --git a/src/components/ui/DropdownMenu.tsx b/src/components/ui/DropdownMenu.tsx index 7e8c825f..35932d58 100644 --- a/src/components/ui/DropdownMenu.tsx +++ b/src/components/ui/DropdownMenu.tsx @@ -40,7 +40,7 @@ function DropdownMenuContent({ data-slot="dropdown-menu-content" sideOffset={sideOffset} className={cn( - 'bg-container-neutral z-50 flex min-w-[144px] flex-col items-center overflow-hidden rounded-md shadow-[0px_4px_14px_0px_rgba(0,0,0,0.25)]', + 'bg-container-neutral z-90 flex min-w-[144px] flex-col items-center overflow-hidden rounded-md shadow-[0px_4px_14px_0px_rgba(0,0,0,0.25)]', 'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95', 'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95', className, diff --git a/src/components/ui/TimePicker.tsx b/src/components/ui/TimePicker.tsx index 59946faf..d7c19341 100644 --- a/src/components/ui/TimePicker.tsx +++ b/src/components/ui/TimePicker.tsx @@ -94,7 +94,7 @@ function TimePicker({ value, onChange }: TimePickerProps) { ref={attachWheel} sideOffset={4} align="start" - className="bg-container-neutral z-50 flex h-60 rounded-md shadow-[0px_4px_14px_0px_rgba(0,0,0,0.25)]" + className="bg-container-neutral z-90 flex h-60 rounded-md shadow-[0px_4px_14px_0px_rgba(0,0,0,0.25)]" > {/* Hours */}
diff --git a/src/constants/admin/schedule.constants.ts b/src/constants/admin/schedule.constants.ts index 15507bf8..bed34326 100644 --- a/src/constants/admin/schedule.constants.ts +++ b/src/constants/admin/schedule.constants.ts @@ -2,5 +2,9 @@ import type { ScheduleType } from '@/types/admin/schedule'; export const SCHEDULE_TYPE_LABEL: Record = { SESSION: '세션', - GENERAL: '일반 일정', + EVENT: '일반 일정', +}; + +export const SCHEDULE_ERROR_MESSAGE: Record = { + 20800: '존재하지 않는 일정입니다.', }; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index c88c5b21..f1c71f2e 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,4 +1,5 @@ // hooks index file +export { useLogout } from './useLogout'; export { useAutoScrollIntoView } from './useAutoScrollIntoView'; export { useClickOutside } from './useClickOutside'; export { useDragScroll } from './useDragScroll'; @@ -20,3 +21,4 @@ export { useCardinalSelector } from './useCardinalSelector'; export { useImageDrop } from './useImageDrop'; export { useProgressAnimation } from './useProgressAnimation'; export { useCodeHighlight } from './useCodeHighlight'; +export { useMonthNavigator } from './useMonthNavigator'; diff --git a/src/hooks/queries/admin/useAdminScheduleQueries.ts b/src/hooks/queries/admin/useAdminScheduleQueries.ts new file mode 100644 index 00000000..1304335f --- /dev/null +++ b/src/hooks/queries/admin/useAdminScheduleQueries.ts @@ -0,0 +1,94 @@ +import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'; +import { isAxiosError } from 'axios'; + +import { SCHEDULE_ERROR_MESSAGE } from '@/constants/admin/schedule.constants'; +import { adminScheduleApi } from '@/lib/apis/adminSchedule'; +import { useClubId } from '@/stores'; +import { toastError } from '@/stores/useToastStore'; +import type { CreateEventBody, UpdateEventBody } from '@/types/admin/schedule'; +import { MutationCallbacks } from '@/types'; + +function toMonthRange(year: number, month: number) { + const pad = (n: number) => String(n).padStart(2, '0'); + const lastDay = new Date(year, month, 0).getDate(); + return { + start: `${year}-${pad(month)}-01T00:00:00`, + end: `${year}-${pad(month)}-${pad(lastDay)}T23:59:59`, + }; +} + +export function useAdminMonthlySchedules(year: number, month: number) { + const clubId = useClubId(); + const { start, end } = toMonthRange(year, month); + + return useQuery({ + queryKey: ['admin', 'schedules', clubId, year, month], + queryFn: async () => { + const res = await adminScheduleApi.getMonthly(clubId!, start, end); + return res.data.data; + }, + enabled: !!clubId, + }); +} + +export function useAdminScheduleDetail(eventId: number) { + const clubId = useClubId(); + + return useSuspenseQuery({ + queryKey: ['admin', 'schedule', clubId, eventId], + queryFn: () => adminScheduleApi.getEventDetail(clubId!, eventId).then((res) => res.data.data), + staleTime: 5 * 60 * 1000, + }); +} + +export function useCreateSchedule() { + const clubId = useClubId(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (body: CreateEventBody) => adminScheduleApi.createEvent(clubId!, body), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin', 'schedules'] }); + }, + onError: (error) => { + const code = isAxiosError(error) ? error.response?.data?.code : undefined; + toastError(code ? (SCHEDULE_ERROR_MESSAGE[code] ?? undefined) : undefined); + }, + }); +} + +export function useUpdateSchedule() { + const clubId = useClubId(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ eventId, body }: { eventId: number; body: UpdateEventBody }) => + adminScheduleApi.updateEvent(clubId!, eventId, body), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin', 'schedules'] }); + }, + onError: (error) => { + const code = isAxiosError(error) ? error.response?.data?.code : undefined; + toastError(code ? (SCHEDULE_ERROR_MESSAGE[code] ?? undefined) : undefined); + }, + }); +} + +export function useDeleteSchedule(callback?: MutationCallbacks) { + const clubId = useClubId(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (eventId: number) => adminScheduleApi.deleteEvent(clubId!, eventId), + onSuccess: () => { + if (callback?.onSuccess) { + callback.onSuccess(); + queryClient.invalidateQueries({ queryKey: ['admin', 'schedules'] }); + } + }, + onError: (error) => { + const code = isAxiosError(error) ? error.response?.data?.code : undefined; + toastError(code ? (SCHEDULE_ERROR_MESSAGE[code] ?? undefined) : undefined); + }, + }); +} diff --git a/src/hooks/useLogout.ts b/src/hooks/useLogout.ts new file mode 100644 index 00000000..447fd2f9 --- /dev/null +++ b/src/hooks/useLogout.ts @@ -0,0 +1,11 @@ +import { logoutAction } from '@/lib/actions/auth'; +import { useClubActions } from '@/stores/useClubStore'; + +export function useLogout() { + const { reset } = useClubActions(); + + return async () => { + reset(); + await logoutAction(); + }; +} diff --git a/src/hooks/useMonthNavigator.ts b/src/hooks/useMonthNavigator.ts new file mode 100644 index 00000000..82ca73dc --- /dev/null +++ b/src/hooks/useMonthNavigator.ts @@ -0,0 +1,30 @@ +'use client'; + +import { useState } from 'react'; + +function useMonthNavigator() { + const [year, setYear] = useState(() => new Date().getFullYear()); + const [month, setMonth] = useState(() => new Date().getMonth() + 1); + + const prev = () => { + if (month === 1) { + setYear((y) => y - 1); + setMonth(12); + } else { + setMonth((m) => m - 1); + } + }; + + const next = () => { + if (month === 12) { + setYear((y) => y + 1); + setMonth(1); + } else { + setMonth((m) => m + 1); + } + }; + + return { year, month, prev, next }; +} + +export { useMonthNavigator }; diff --git a/src/lib/apis/adminSchedule.ts b/src/lib/apis/adminSchedule.ts new file mode 100644 index 00000000..7f41fd01 --- /dev/null +++ b/src/lib/apis/adminSchedule.ts @@ -0,0 +1,23 @@ +import { apiClient } from '@/lib/apis/client'; +import type { ApiResponse } from '@/types/common'; +import type { + CreateEventBody, + Schedule, + ScheduleDetail, + UpdateEventBody, +} from '@/types/admin/schedule'; + +export const adminScheduleApi = { + getMonthly: (clubId: string, start: string, end: string) => + apiClient.get>(`/clubs/${clubId}/schedules/monthly`, { + params: { start, end }, + }), + getEventDetail: (clubId: string, eventId: number) => + apiClient.get>(`/clubs/${clubId}/events/${eventId}`), + createEvent: (clubId: string, body: CreateEventBody) => + apiClient.post>(`/admin/clubs/${clubId}/events`, body), + updateEvent: (clubId: string, eventId: number, body: UpdateEventBody) => + apiClient.patch>(`/admin/clubs/${clubId}/events/${eventId}`, body), + deleteEvent: (clubId: string, eventId: number) => + apiClient.delete>(`/admin/clubs/${clubId}/events/${eventId}`), +}; diff --git a/src/lib/apis/index.ts b/src/lib/apis/index.ts index 66325508..a2913aa3 100644 --- a/src/lib/apis/index.ts +++ b/src/lib/apis/index.ts @@ -15,3 +15,4 @@ export { cardinalApi } from './cardinal'; export { inquiryApi } from './inquiry'; export { adminClubApi } from './adminClub'; export { adminAttendanceApi } from './adminAttendance'; +export { adminScheduleApi } from './adminSchedule'; diff --git a/src/types/admin/schedule.d.ts b/src/types/admin/schedule.d.ts index 36e18698..43f627cd 100644 --- a/src/types/admin/schedule.d.ts +++ b/src/types/admin/schedule.d.ts @@ -1,11 +1,35 @@ -export type ScheduleType = 'SESSION' | 'GENERAL'; +export type ScheduleType = 'SESSION' | 'EVENT'; export interface Schedule { - scheduleId: number; + id: number; title: string; type: ScheduleType; - startDateTime: string; - endDateTime: string; + start: string; + end: string; location: string; - cardinalNumber: number; + cardinal: number; +} + +export interface ScheduleDetail extends Schedule { + content: string; + name: string; + createdAt: string; + modifiedAt: string; +} + +export interface CreateEventBody { + title: string; + content: string; + location: string; + cardinal: number; + start: string; + end: string; +} + +export interface UpdateEventBody { + title: string; + content: string; + location: string; + start: string; + end: string; } diff --git a/src/utils/admin/scheduleFormUtils.ts b/src/utils/admin/scheduleFormUtils.ts index 22b096aa..7cfd635c 100644 --- a/src/utils/admin/scheduleFormUtils.ts +++ b/src/utils/admin/scheduleFormUtils.ts @@ -1,8 +1,26 @@ -import type { Schedule } from '@/types/admin/schedule'; +import type { ScheduleDetail } from '@/types/admin/schedule'; import type { AdminSession, AdminSessionGroup } from '@/types/admin/session'; import type { ScheduleFormState } from '../../components/admin/schedule/modal/types'; +export const SCHEDULE_FIELD_LIMITS = { + title: 30, + location: 30, + content: 500, +} as const; + +export function isScheduleTitleValid(title: string): boolean { + return title.trim().length > 0 && title.length <= SCHEDULE_FIELD_LIMITS.title; +} + +export function isScheduleLocationValid(location: string): boolean { + return location.length <= SCHEDULE_FIELD_LIMITS.location; +} + +export function isScheduleContentValid(content: string): boolean { + return content.length <= SCHEDULE_FIELD_LIMITS.content; +} + export function isSessionGroup( target: AdminSession | AdminSessionGroup, ): target is AdminSessionGroup { @@ -32,15 +50,15 @@ export function toInitialSessionForm(target: AdminSession | AdminSessionGroup): }; } -export function toInitialScheduleForm(schedule: Schedule): ScheduleFormState { +export function toInitialScheduleForm(detail: ScheduleDetail): ScheduleFormState { return { - title: schedule.title, - startDate: schedule.startDateTime.slice(0, 10), - startTime: schedule.startDateTime.slice(11, 16), - endDate: schedule.endDateTime.slice(0, 10), - endTime: schedule.endDateTime.slice(11, 16), - location: schedule.location, - content: '', + title: detail.title, + startDate: detail.start.slice(0, 10), + startTime: detail.start.slice(11, 16), + endDate: detail.end.slice(0, 10), + endTime: detail.end.slice(11, 16), + location: detail.location, + content: detail.content, }; }