diff --git a/app/adminpage/main/_components/AdminMainScreen.tsx b/app/adminpage/main/_components/AdminMainScreen.tsx index b8c731c..d7f40f1 100644 --- a/app/adminpage/main/_components/AdminMainScreen.tsx +++ b/app/adminpage/main/_components/AdminMainScreen.tsx @@ -10,6 +10,7 @@ import { BarChart3, LogOut, Shield, + Bell, } from "lucide-react"; const MENU_ITEMS = [ @@ -32,6 +33,15 @@ const MENU_ITEMS = [ active: true, gradient: "from-[#6366f1] to-[#8b5cf6]", }, + { + id: "notices", + title: "공지사항 관리", + description: "서비스 공지사항을 등록, 수정, 삭제합니다", + icon: Bell, + href: "/adminpage/notices", + active: true, + gradient: "from-[#10b981] to-[#059669]", + }, { id: "users", title: "사용자 관리", diff --git a/app/adminpage/notices/_actions/noticeActions.ts b/app/adminpage/notices/_actions/noticeActions.ts new file mode 100644 index 0000000..3e848ec --- /dev/null +++ b/app/adminpage/notices/_actions/noticeActions.ts @@ -0,0 +1,63 @@ +"use server"; + +import { serverApi, isAxiosError } from "@/lib/server-api"; +import { + type CreateNoticeBody, + type UpdateNoticeBody, +} from "@/hooks/admin/useAdminNotices"; + +/** + * 공지사항 등록 Server Action + */ +export async function createNoticeAction(body: CreateNoticeBody) { + try { + const response = await serverApi.post({ + path: "/api/v1/admin/notices", + body, + }); + return response.data; + } catch (error) { + if (isAxiosError(error)) { + throw error; + } + throw new Error("공지사항 등록 중 알 수 없는 에러가 발생했습니다."); + } +} + +/** + * 공지사항 수정 Server Action + */ +export async function updateNoticeAction( + noticeId: number, + body: UpdateNoticeBody, +) { + try { + const response = await serverApi.patch({ + path: `/api/v1/admin/notices/${noticeId}`, + body, + }); + return response.data; + } catch (error) { + if (isAxiosError(error)) { + throw error; + } + throw new Error("공지사항 수정 중 알 수 없는 에러가 발생했습니다."); + } +} + +/** + * 공지사항 삭제 Server Action + */ +export async function deleteNoticeAction(noticeId: number) { + try { + const response = await serverApi.delete({ + path: `/api/v1/admin/notices/${noticeId}`, + }); + return response.data; + } catch (error) { + if (isAxiosError(error)) { + throw error; + } + throw new Error("공지사항 삭제 중 알 수 없는 에러가 발생했습니다."); + } +} diff --git a/app/adminpage/notices/_components/AdminNotices.tsx b/app/adminpage/notices/_components/AdminNotices.tsx new file mode 100644 index 0000000..7e01602 --- /dev/null +++ b/app/adminpage/notices/_components/AdminNotices.tsx @@ -0,0 +1,451 @@ +"use client"; + +import React, { useState, useId } from "react"; +import Link from "next/link"; +import { + useAllNotices, + useCreateNotice, + useUpdateNotice, + useDeleteNotice, + type CreateNoticeBody, + type NoticeItem, +} from "@/hooks/admin/useAdminNotices"; +import { + ArrowLeft, + Bell, + Loader2, + Plus, + Trash2, + X, + Inbox, + Pencil, + Clock, + Calendar, + Check, +} from "lucide-react"; + +/* ── 날짜 포맷 헬퍼 ── */ +function formatDateTime(dateStr: string) { + const d = new Date(dateStr); + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}.${pad(d.getMonth() + 1)}.${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +function toLocalDateTimeString(date: Date) { + const pad = (n: number) => String(n).padStart(2, "0"); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +function getStatusInfo(startTime: string, endTime: string, active: boolean) { + if (active) { + return { + label: "활성", + color: "text-emerald-400", + bg: "bg-emerald-500/10", + }; + } + + const now = new Date(); + const start = new Date(startTime); + + if (now < start) { + return { label: "예정", color: "text-amber-400", bg: "bg-amber-500/10" }; + } else { + return { label: "종료", color: "text-[#4a4e69]", bg: "bg-[#1e2030]" }; + } +} + +/* ── 공지사항 작성/수정 폼 컴포넌트 ── */ +function NoticeForm({ + onSuccess, + editingNotice, + onCancelEdit, +}: { + onSuccess: () => void; + editingNotice?: NoticeItem | null; + onCancelEdit?: () => void; +}) { + const isEditMode = !!editingNotice; + const createMutation = useCreateNotice(); + const updateMutation = useUpdateNotice(); + const isPending = createMutation.isPending || updateMutation.isPending; + + const [title, setTitle] = useState(editingNotice?.title || ""); + const [content, setContent] = useState(editingNotice?.content || ""); + const [startTime, setStartTime] = useState( + editingNotice + ? toLocalDateTimeString(new Date(editingNotice.startTime)) + : "", + ); + const [endTime, setEndTime] = useState( + editingNotice ? toLocalDateTimeString(new Date(editingNotice.endTime)) : "", + ); + + const idPrefix = useId(); + const titleId = `${idPrefix}-title`; + const contentId = `${idPrefix}-content`; + const startTimeId = `${idPrefix}-startTime`; + const endTimeId = `${idPrefix}-endTime`; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + if (!title.trim()) return alert("제목을 입력해 주세요."); + if (title.length > 200) return alert("제목은 200자 이내로 입력해 주세요."); + if (!content.trim()) return alert("내용을 입력해 주세요."); + if (!startTime) return alert("시작 시간을 선택해 주세요."); + if (!endTime) return alert("종료 시간을 선택해 주세요."); + if (new Date(startTime) >= new Date(endTime)) + return alert("시작 시간은 종료 시간보다 이전이어야 합니다."); + + const body: CreateNoticeBody = { + title: title.trim(), + content: content.trim(), + startTime, + endTime, + }; + + if (isEditMode && editingNotice) { + updateMutation.mutate( + { noticeId: editingNotice.noticeId, body }, + { + onSuccess: () => { + onSuccess(); + onCancelEdit?.(); + }, + onError: (error) => { + alert( + error.response?.data?.message || "공지사항 수정에 실패했습니다.", + ); + }, + }, + ); + } else { + createMutation.mutate(body, { + onSuccess: () => { + setTitle(""); + setContent(""); + setStartTime(""); + setEndTime(""); + onSuccess(); + }, + onError: (error) => { + alert( + error.response?.data?.message || "공지사항 등록에 실패했습니다.", + ); + }, + }); + } + }; + + return ( +
+

+ {isEditMode ? "공지사항 수정" : "새 공지사항 등록"} +

+ +
+ {/* 제목 */} +
+ + setTitle(e.target.value)} + maxLength={200} + placeholder="공지사항 제목을 입력하세요" + className="rounded-xl border border-[#2a2d42] bg-[#0f1117] px-4 py-3 text-sm text-white placeholder:text-[#4a4e69] focus:border-[#10b981] focus:outline-none" + /> + + {title.length}/200 + +
+ + {/* 내용 */} +
+ +