diff --git a/prisma/migrations/20251009093736_add_banner/migration.sql b/prisma/migrations/20251009093736_add_banner/migration.sql
new file mode 100644
index 0000000..46df16f
--- /dev/null
+++ b/prisma/migrations/20251009093736_add_banner/migration.sql
@@ -0,0 +1,19 @@
+-- CreateTable
+CREATE TABLE "banners" (
+ "id" TEXT NOT NULL PRIMARY KEY,
+ "title" TEXT NOT NULL,
+ "imageUrl" TEXT NOT NULL,
+ "linkUrl" TEXT,
+ "active" BOOLEAN NOT NULL DEFAULT true,
+ "sortOrder" INTEGER NOT NULL DEFAULT 0,
+ "startAt" DATETIME,
+ "endAt" DATETIME,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" DATETIME NOT NULL
+);
+
+-- CreateIndex
+CREATE INDEX "banners_active_sortOrder_idx" ON "banners"("active", "sortOrder");
+
+-- CreateIndex
+CREATE INDEX "banners_startAt_endAt_idx" ON "banners"("startAt", "endAt");
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 51cd3bc..bbfbfc6 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -632,6 +632,24 @@ model Partner {
@@map("partners")
}
+// 배너/공지 노출용
+model Banner {
+ id String @id @default(cuid())
+ title String
+ imageUrl String
+ linkUrl String?
+ active Boolean @default(true)
+ sortOrder Int @default(0)
+ startAt DateTime?
+ endAt DateTime?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@index([active, sortOrder])
+ @@index([startAt, endAt])
+ @@map("banners")
+}
+
// 제휴 문의
model PartnerInquiry {
id String @id @default(cuid())
diff --git a/public/erd.svg b/public/erd.svg
index cf2c2e3..c5ee4d4 100644
--- a/public/erd.svg
+++ b/public/erd.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/src/app/admin/banners/page.tsx b/src/app/admin/banners/page.tsx
new file mode 100644
index 0000000..db11f27
--- /dev/null
+++ b/src/app/admin/banners/page.tsx
@@ -0,0 +1,43 @@
+"use client";
+import useSWR from "swr";
+import { useState } from "react";
+
+const fetcher = (url: string) => fetch(url).then((r) => r.json());
+
+export default function AdminBannersPage() {
+ const { data, mutate } = useSWR<{ banners: any[] }>("/api/admin/banners", fetcher);
+ const banners = data?.banners ?? [];
+ const [form, setForm] = useState({ title: "", imageUrl: "", linkUrl: "", active: true, sortOrder: 0 });
+ async function create() {
+ const r = await fetch("/api/admin/banners", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(form) });
+ if (r.ok) { setForm({ title: "", imageUrl: "", linkUrl: "", active: true, sortOrder: 0 }); mutate(); }
+ }
+ return (
+
+ );
+}
+
+
diff --git a/src/app/api/admin/banners/[id]/route.ts b/src/app/api/admin/banners/[id]/route.ts
new file mode 100644
index 0000000..ebb1347
--- /dev/null
+++ b/src/app/api/admin/banners/[id]/route.ts
@@ -0,0 +1,23 @@
+import { NextResponse } from "next/server";
+import prisma from "@/lib/prisma";
+
+export async function PATCH(req: Request, context: { params: Promise<{ id: string }> }) {
+ const { id } = await context.params;
+ const body = await req.json().catch(() => ({}));
+ const data: any = {};
+ for (const k of ["title", "imageUrl", "linkUrl", "active", "sortOrder", "startAt", "endAt"]) {
+ if (k in body) data[k] = body[k];
+ }
+ if (data.startAt) data.startAt = new Date(data.startAt);
+ if (data.endAt) data.endAt = new Date(data.endAt);
+ const banner = await prisma.banner.update({ where: { id }, data });
+ return NextResponse.json({ banner });
+}
+
+export async function DELETE(_: Request, context: { params: Promise<{ id: string }> }) {
+ const { id } = await context.params;
+ await prisma.banner.delete({ where: { id } });
+ return NextResponse.json({ ok: true });
+}
+
+
diff --git a/src/app/api/admin/banners/route.ts b/src/app/api/admin/banners/route.ts
new file mode 100644
index 0000000..d84f63c
--- /dev/null
+++ b/src/app/api/admin/banners/route.ts
@@ -0,0 +1,28 @@
+import { NextResponse } from "next/server";
+import prisma from "@/lib/prisma";
+import { z } from "zod";
+
+export async function GET() {
+ const banners = await prisma.banner.findMany({ orderBy: [{ active: "desc" }, { sortOrder: "asc" }, { createdAt: "desc" }] });
+ return NextResponse.json({ banners });
+}
+
+const createSchema = z.object({
+ title: z.string().min(1),
+ imageUrl: z.string().url(),
+ linkUrl: z.string().url().optional(),
+ active: z.boolean().optional(),
+ sortOrder: z.coerce.number().int().optional(),
+ startAt: z.coerce.date().optional(),
+ endAt: z.coerce.date().optional(),
+});
+
+export async function POST(req: Request) {
+ const body = await req.json().catch(() => ({}));
+ const parsed = createSchema.safeParse(body);
+ if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
+ const banner = await prisma.banner.create({ data: parsed.data });
+ return NextResponse.json({ banner }, { status: 201 });
+}
+
+
diff --git a/src/app/api/banners/route.ts b/src/app/api/banners/route.ts
new file mode 100644
index 0000000..f9d33a1
--- /dev/null
+++ b/src/app/api/banners/route.ts
@@ -0,0 +1,19 @@
+import { NextResponse } from "next/server";
+import prisma from "@/lib/prisma";
+
+export async function GET() {
+ const now = new Date();
+ const banners = await prisma.banner.findMany({
+ where: {
+ active: true,
+ AND: [
+ { OR: [{ startAt: null }, { startAt: { lte: now } }] },
+ { OR: [{ endAt: null }, { endAt: { gte: now } }] },
+ ],
+ },
+ orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
+ });
+ return NextResponse.json({ banners });
+}
+
+
diff --git a/src/app/components/HeroBanner.tsx b/src/app/components/HeroBanner.tsx
index 9d98c63..7eb66a8 100644
--- a/src/app/components/HeroBanner.tsx
+++ b/src/app/components/HeroBanner.tsx
@@ -1,22 +1,30 @@
"use client";
import { useEffect, useState } from "react";
-const defaultSlides = [
- { id: 1, title: "공지사항", subtitle: "중요 공지 확인하기" },
- { id: 2, title: "이벤트", subtitle: "진행중인 이벤트" },
-];
+type Banner = { id: string; title: string; imageUrl: string; linkUrl?: string | null };
export function HeroBanner() {
+ const [banners, setBanners] = useState([]);
const [idx, setIdx] = useState(0);
useEffect(() => {
- const t = setInterval(() => setIdx((i) => (i + 1) % defaultSlides.length), 3000);
- return () => clearInterval(t);
+ fetch("/api/banners").then((r) => r.json()).then((d) => setBanners(d.banners ?? []));
}, []);
- const slide = defaultSlides[idx];
- return (
-
+ useEffect(() => {
+ if ((banners?.length ?? 0) < 2) return;
+ const t = setInterval(() => setIdx((i) => (i + 1) % banners.length), 3000);
+ return () => clearInterval(t);
+ }, [banners]);
+ const slide = banners[idx];
+ if (!slide) return null;
+ const content = (
+
+
{slide.title}
-
{slide.subtitle}
+
+ );
+ return (
+
);
}
diff --git a/todolist.txt b/todolist.txt
index 06f222f..bf79cb3 100644
--- a/todolist.txt
+++ b/todolist.txt
@@ -72,7 +72,7 @@
10.1 대시보드 핵심 지표 위젯 o
10.2 게시판 스키마/설정 관리 UI o
10.3 사용자 검색/정지/권한 변경 o
-10.4 공지/배너 등록 및 노출 설정
+10.4 공지/배너 등록 및 노출 설정 o
10.5 감사 이력/신고 내역/열람 로그
10.6 카테고리 유형/설정 관리(일반/특수/승인/레벨/익명/태그)