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,
};
}