From cbea0f4b9fa7544887e29fd98fd851bc97eac618 Mon Sep 17 00:00:00 2001 From: chpark Date: Fri, 29 May 2026 11:04:55 +0900 Subject: [PATCH] =?UTF-8?q?feat(notices):=20=ED=91=B8=EC=8B=9C=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EA=B2=8C=EC=8B=9C=ED=8C=90=20=E2=80=94=20=EC=88=98?= =?UTF-8?q?=EC=8B=A0=EC=9E=90=20=EC=84=A0=ED=83=9D=20+=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20+=20=EB=B0=9C=EC=86=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자가 공지(제목·본문·이미지+선택적 외부링크)를 작성하고 푸시 구독자 중 원하는 사람에게 발송. 사용자가 알림 탭하면 자체 공지 페이지(/m/notices/[id]) 또는 지정 URL 로 이동. - lib/notices: momo_notices 테이블 자동 생성. - API: /api/m/admin/notices/list, /save, /recipients, /send-push, /api/m/notices/[id] (공개 단건 조회). - Admin UI(/m/admin/notices): 좌 수신자 다중선택+검색+거래처/관리자 필터, 우 제목/본문/이미지 업로드/외부링크. [N명에게 발송] 한 번으로 공지 저장+푸시. - 공개 페이지(/m/notices/[id]): 이미지+제목+본문 렌더. - 이미지 업로드는 기존 /api/m/items/upload-image 재사용. - 사이드바: 마스터 관리 > 푸시알림 게시판 (menu_info 9000299) 신규 등록. --- src/app/(main)/m/admin/notices/page.tsx | 264 ++++++++++++++++++ src/app/(main)/m/notices/[id]/page.tsx | 74 +++++ src/app/api/m/admin/notices/list/route.ts | 22 ++ .../api/m/admin/notices/recipients/route.ts | 27 ++ src/app/api/m/admin/notices/save/route.ts | 39 +++ .../api/m/admin/notices/send-push/route.ts | 50 ++++ src/app/api/m/notices/[id]/route.ts | 22 ++ src/lib/notices.ts | 26 ++ 8 files changed, 524 insertions(+) create mode 100644 src/app/(main)/m/admin/notices/page.tsx create mode 100644 src/app/(main)/m/notices/[id]/page.tsx create mode 100644 src/app/api/m/admin/notices/list/route.ts create mode 100644 src/app/api/m/admin/notices/recipients/route.ts create mode 100644 src/app/api/m/admin/notices/save/route.ts create mode 100644 src/app/api/m/admin/notices/send-push/route.ts create mode 100644 src/app/api/m/notices/[id]/route.ts create mode 100644 src/lib/notices.ts diff --git a/src/app/(main)/m/admin/notices/page.tsx b/src/app/(main)/m/admin/notices/page.tsx new file mode 100644 index 0000000..c5e7f08 --- /dev/null +++ b/src/app/(main)/m/admin/notices/page.tsx @@ -0,0 +1,264 @@ +"use client"; + +import { useEffect, useState, useCallback, useMemo } from "react"; +import Swal from "sweetalert2"; +import { Send, Upload, X, Bell } from "lucide-react"; + +interface Recipient { + USER_ID: string; + USER_NAME: string; + USER_TYPE: string; + IS_ADMIN: boolean; + DEPT_NAME: string; + USER_AGENT: string; + LAST_SEEN: string; + DEVICE_CNT: number; +} +interface Notice { + OBJID: string; TITLE: string; BODY: string | null; + IMAGE_URL: string | null; URL: string | null; + REGDATE: string; SENT_COUNT: number; +} + +export default function AdminNoticesPage() { + const [recipients, setRecipients] = useState([]); + const [selected, setSelected] = useState>(new Set()); + const [keyword, setKeyword] = useState(""); + const [typeFilter, setTypeFilter] = useState<"" | "U" | "A">(""); + + const [title, setTitle] = useState(""); + const [bodyText, setBodyText] = useState(""); + const [imageUrl, setImageUrl] = useState(""); + const [linkUrl, setLinkUrl] = useState(""); + const [uploading, setUploading] = useState(false); + const [sending, setSending] = useState(false); + + const [past, setPast] = useState([]); + + const loadRecipients = useCallback(async () => { + const r = await fetch("/api/m/admin/notices/recipients", { method: "POST" }); + setRecipients((await r.json()).RESULTLIST ?? []); + }, []); + const loadPast = useCallback(async () => { + const r = await fetch("/api/m/admin/notices/list", { method: "POST" }); + setPast((await r.json()).RESULTLIST ?? []); + }, []); + useEffect(() => { loadRecipients(); loadPast(); }, [loadRecipients, loadPast]); + + const filtered = useMemo(() => { + return recipients.filter((u) => { + if (typeFilter && (typeFilter === "A" ? !u.IS_ADMIN : u.IS_ADMIN)) return false; + if (keyword) { + const k = keyword.toLowerCase(); + if (!u.USER_NAME?.toLowerCase().includes(k) && !u.USER_ID?.toLowerCase().includes(k)) return false; + } + return true; + }); + }, [recipients, typeFilter, keyword]); + + const toggle = (id: string) => { + setSelected((p) => { + const n = new Set(p); + if (n.has(id)) n.delete(id); else n.add(id); + return n; + }); + }; + const toggleAllFiltered = () => { + const allOn = filtered.length > 0 && filtered.every((u) => selected.has(u.USER_ID)); + setSelected((p) => { + const n = new Set(p); + if (allOn) filtered.forEach((u) => n.delete(u.USER_ID)); + else filtered.forEach((u) => n.add(u.USER_ID)); + return n; + }); + }; + + const onUpload = async (file: File) => { + setUploading(true); + try { + const fd = new FormData(); + fd.append("file", file); + const r = await fetch("/api/m/items/upload-image", { method: "POST", body: fd }); + const j = await r.json(); + if (j.success) setImageUrl(j.url); + else Swal.fire({ icon: "error", title: "업로드 실패", text: j.message }); + } finally { setUploading(false); } + }; + + const send = async () => { + if (!title.trim()) { + return Swal.fire({ icon: "warning", title: "제목을 입력하세요" }); + } + if (selected.size === 0) { + return Swal.fire({ icon: "warning", title: "수신자를 선택하세요" }); + } + const ok = await Swal.fire({ + icon: "question", + title: `${selected.size}명에게 푸시 발송`, + text: `제목: ${title}`, + showCancelButton: true, confirmButtonText: "발송", cancelButtonText: "취소", + confirmButtonColor: "#0f766e", + }); + if (!ok.isConfirmed) return; + setSending(true); + try { + // 1) 공지 저장 + const sv = await fetch("/api/m/admin/notices/save", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title, body: bodyText, imageUrl, url: linkUrl }), + }); + const svj = await sv.json(); + if (!svj.success) { Swal.fire({ icon: "error", title: "저장 실패", text: svj.message }); return; } + const noticeObjid = svj.objId; + + // 2) 푸시 발송 (URL = 외부 링크 우선, 없으면 자체 공지 페이지) + const send = await fetch("/api/m/admin/notices/send-push", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + noticeObjid, title, message: bodyText, + url: linkUrl || `/m/notices/${noticeObjid}`, + userIds: Array.from(selected), + }), + }); + const sj = await send.json(); + if (sj.success) { + await Swal.fire({ icon: "success", title: "발송 완료", text: `성공 ${sj.sent} · 실패 ${sj.failed}`, timer: 1800, showConfirmButton: false }); + setTitle(""); setBodyText(""); setImageUrl(""); setLinkUrl(""); + setSelected(new Set()); + loadPast(); + } else { + Swal.fire({ icon: "error", title: "발송 실패", text: sj.message }); + } + } finally { setSending(false); } + }; + + return ( +
+
+
+

+ 푸시알림 게시판 +

+

왼쪽에서 수신자를 선택하고, 오른쪽에 제목·본문·이미지를 작성한 뒤 [발송]을 누르세요. 사용자가 알림을 탭하면 작성하신 공지 페이지로 이동합니다.

+
+
+ +
+ {/* 좌: 수신자 */} +
+
+ 수신자 (구독자 {recipients.length}명 / 선택 {selected.size}) + +
+
+ setKeyword(e.target.value)} placeholder="이름/아이디 검색" + className="flex-1 h-8 px-2 rounded border border-slate-200 text-xs" /> + +
+
+ {filtered.length === 0 ? ( +
구독자가 없습니다. 사용자가 출고요청 화면에서 알림을 켜야 목록에 나타납니다.
+ ) : filtered.map((u) => { + const checked = selected.has(u.USER_ID); + const sb = (u.USER_AGENT || "").includes("SamsungBrowser"); + return ( + + ); + })} +
+
+ + {/* 우: 작성 */} +
+
+
+ + setTitle(e.target.value)} + placeholder="예: 오늘의 특가" maxLength={60} + className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm mt-1" /> +
푸시 알림의 제목으로 표시 (60자 이내)
+
+
+ +