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자 이내)
+
+
+ +