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 (
+
+ );
+}
+
+/* ── 메인 컴포넌트 ── */
+export default function AdminNotices() {
+ const [showCreateForm, setShowCreateForm] = useState(false);
+ const [editingNotice, setEditingNotice] = useState(null);
+ const [deleteConfirmId, setDeleteConfirmId] = useState(null);
+
+ const { data: noticesData, isLoading, refetch } = useAllNotices();
+ const deleteMutation = useDeleteNotice();
+
+ const notices = noticesData?.data ?? [];
+
+ const handleDelete = (noticeId: number) => {
+ if (deleteConfirmId === noticeId) {
+ deleteMutation.mutate(noticeId, {
+ onSuccess: () => setDeleteConfirmId(null),
+ });
+ } else {
+ setDeleteConfirmId(noticeId);
+ }
+ };
+
+ const handleEdit = (notice: NoticeItem) => {
+ setEditingNotice(notice);
+ setShowCreateForm(false);
+ };
+
+ return (
+
+ {/* 헤더 */}
+
+
+
+
+
+
+
+
+
+
+
+ 공지사항 관리
+
+
+ 전체 공지 {notices.length}건
+
+
+
+
+
+
+
+
+ {/* 등록 폼 */}
+ {showCreateForm && (
+
+ {
+ setShowCreateForm(false);
+ refetch();
+ }}
+ />
+
+ )}
+
+ {/* 수정 폼 */}
+ {editingNotice && (
+
+ refetch()}
+ onCancelEdit={() => setEditingNotice(null)}
+ />
+
+ )}
+
+ {/* 공지사항 목록 */}
+ {isLoading ? (
+
+ ) : notices.length === 0 ? (
+
+
+
+
+
+ 등록된 공지사항이 없습니다
+
+
+ 공지 등록 버튼을 눌러 새 공지사항을 추가하세요
+
+
+ ) : (
+
+ {notices.map((notice) => {
+ const status = getStatusInfo(
+ notice.startTime,
+ notice.endTime,
+ notice.active,
+ );
+ return (
+
+ {/* 공지 정보 */}
+
+
+
+ {notice.title}
+
+
+ {status.label}
+
+
+
+
+ {notice.content}
+
+
+ {/* 시간 정보 */}
+
+
+
+ 시작: {formatDateTime(notice.startTime)}
+
+
+
+ 종료: {formatDateTime(notice.endTime)}
+
+
+
+
+ {/* 액션 버튼 */}
+
+
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/app/adminpage/notices/page.tsx b/app/adminpage/notices/page.tsx
new file mode 100644
index 0000000..adef734
--- /dev/null
+++ b/app/adminpage/notices/page.tsx
@@ -0,0 +1,27 @@
+import {
+ dehydrate,
+ HydrationBoundary,
+ QueryClient,
+} from "@tanstack/react-query";
+import { serverApi } from "@/lib/server-api";
+import AdminNotices from "./_components/AdminNotices";
+
+export default async function AdminNoticesPage() {
+ const queryClient = new QueryClient();
+
+ await queryClient.prefetchQuery({
+ queryKey: ["adminNotices"],
+ queryFn: async () => {
+ const res = await serverApi.get({
+ path: "/api/v1/admin/notices",
+ });
+ return res.data;
+ },
+ });
+
+ return (
+
+
+
+ );
+}
diff --git a/app/adminpage/products/_actions/productActions.ts b/app/adminpage/products/_actions/productActions.ts
new file mode 100644
index 0000000..2cd3017
--- /dev/null
+++ b/app/adminpage/products/_actions/productActions.ts
@@ -0,0 +1,39 @@
+"use server";
+
+import { serverApi, isAxiosError } from "@/lib/server-api";
+import { type CreateProductBody } from "@/hooks/admin/useAdminProducts";
+
+/**
+ * 상품 등록 Server Action
+ */
+export async function createProductAction(body: CreateProductBody) {
+ try {
+ const response = await serverApi.post({
+ path: "/api/v1/admin/shop/products",
+ body,
+ });
+ return response.data;
+ } catch (error) {
+ if (isAxiosError(error)) {
+ throw error;
+ }
+ throw new Error("상품 등록 중 알 수 없는 에러가 발생했습니다.");
+ }
+}
+
+/**
+ * 상품 삭제 Server Action
+ */
+export async function deleteProductAction(productId: number) {
+ try {
+ const response = await serverApi.delete({
+ path: `/api/v1/admin/shop/products/${productId}`,
+ });
+ return response.data;
+ } catch (error) {
+ if (isAxiosError(error)) {
+ throw error;
+ }
+ throw new Error("상품 삭제 중 알 수 없는 에러가 발생했습니다.");
+ }
+}
diff --git a/app/adminpage/products/_components/ProductCreateForm.tsx b/app/adminpage/products/_components/ProductCreateForm.tsx
index 3c53a30..8dfd775 100644
--- a/app/adminpage/products/_components/ProductCreateForm.tsx
+++ b/app/adminpage/products/_components/ProductCreateForm.tsx
@@ -1,6 +1,6 @@
"use client";
-import React, { useState } from "react";
+import React, { useState, useId } from "react";
import {
useCreateProduct,
type CreateProductBody,
@@ -34,6 +34,12 @@ export default function ProductCreateForm({
const [bonusRewards, setBonusRewards] = useState([]);
const [error, setError] = useState("");
+ const idPrefix = useId();
+ const nameId = `${idPrefix}-name`;
+ const descriptionId = `${idPrefix}-description`;
+ const priceId = `${idPrefix}-price`;
+ const displayOrderId = `${idPrefix}-displayOrder`;
+
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError("");
@@ -121,8 +127,14 @@ export default function ProductCreateForm({
{/* 상품명 */}
-
+
setName(e.target.value)}
@@ -134,10 +146,14 @@ export default function ProductCreateForm({
{/* 설명 */}
-