From ecc14561e6a2cd41d765977784c80dd0a1d02a1d Mon Sep 17 00:00:00 2001 From: chpark Date: Sat, 30 May 2026 13:45:07 +0900 Subject: [PATCH] =?UTF-8?q?feat(notices):=20=EC=88=98=EC=8B=A0=EC=9E=90=20?= =?UTF-8?q?=EA=B7=B8=EB=A3=B9=20+=20=EB=B0=9C=EC=86=A1=EC=9D=B4=EB=A0=A5?= =?UTF-8?q?=20=EB=A9=94=EB=89=B4=20=EC=8B=A0=EC=84=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 수신자 그룹: - DB: momo_recipient_groups + momo_recipient_group_members (auto-ensure). - API: 그룹 list/save/delete + members get/save + all-users picker. - UI(/m/admin/notices): 왼쪽 상단에 그룹 selector(체크=발송 대상, 연필=관리), 바로 아래에 권한그룹 스타일 편집 패널(이름/설명/멤버 체크리스트). 기존 개별 선택 패널은 그대로 유지. 발송 = 그룹 멤버 ∪ 개별 선택 유니온. 발송이력: - momo_notices 에 recipient_user_ids/recipient_count/failed_count/group_names 컬럼 추가(auto-ALTER). send-push 가 발송 시 함께 기록. - 신규 페이지 /m/admin/notice-history: 시간/제목/그룹/대상수/성공/실패. 펼치면 본문 + 수신자 이름 칩 + 공지 페이지 링크. - 사이드바 메뉴: 마스터 관리 > 푸시알림 발송이력 (menu_info 9000298). --- .../(main)/m/admin/notice-history/page.tsx | 123 ++++++ src/app/(main)/m/admin/notices/page.tsx | 365 ++++++++++++++---- .../api/m/admin/notice-history/list/route.ts | 33 ++ .../api/m/admin/notices/all-users/route.ts | 22 ++ .../api/m/admin/notices/groups/list/route.ts | 23 ++ .../m/admin/notices/groups/members/route.ts | 24 ++ .../notices/groups/members/save/route.ts | 35 ++ .../api/m/admin/notices/groups/save/route.ts | 48 +++ .../api/m/admin/notices/send-push/route.ts | 18 +- src/lib/notices.ts | 19 + 10 files changed, 630 insertions(+), 80 deletions(-) create mode 100644 src/app/(main)/m/admin/notice-history/page.tsx create mode 100644 src/app/api/m/admin/notice-history/list/route.ts create mode 100644 src/app/api/m/admin/notices/all-users/route.ts create mode 100644 src/app/api/m/admin/notices/groups/list/route.ts create mode 100644 src/app/api/m/admin/notices/groups/members/route.ts create mode 100644 src/app/api/m/admin/notices/groups/members/save/route.ts create mode 100644 src/app/api/m/admin/notices/groups/save/route.ts diff --git a/src/app/(main)/m/admin/notice-history/page.tsx b/src/app/(main)/m/admin/notice-history/page.tsx new file mode 100644 index 0000000..706a1f2 --- /dev/null +++ b/src/app/(main)/m/admin/notice-history/page.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { History, Users, ExternalLink, ChevronDown, ChevronRight } from "lucide-react"; + +interface HistoryRow { + OBJID: string; TITLE: string; BODY: string | null; + IMAGE_URL: string | null; URL: string | null; + REGDATE: string; REGID: string; + SENT_COUNT: number; FAILED_COUNT: number; + RECIPIENT_COUNT: number; + RECIPIENT_USER_IDS: string[]; + GROUP_NAMES: string[]; + RECIPIENT_NAMES: string[]; +} + +export default function NoticeHistoryPage() { + const [list, setList] = useState([]); + const [loading, setLoading] = useState(false); + const [expanded, setExpanded] = useState>(new Set()); + + const load = useCallback(async () => { + setLoading(true); + try { + const r = await fetch("/api/m/admin/notice-history/list", { method: "POST" }); + setList((await r.json()).RESULTLIST ?? []); + } finally { setLoading(false); } + }, []); + useEffect(() => { load(); }, [load]); + + const toggle = (id: string) => { + setExpanded((p) => { + const n = new Set(p); + if (n.has(id)) n.delete(id); else n.add(id); + return n; + }); + }; + + return ( +
+
+

+ 푸시알림 발송이력 +

+

보낸 공지의 발송 시각·대상·성공/실패 카운트를 확인. 행을 펼치면 수신자 명단과 그룹·본문을 볼 수 있어요.

+
+ +
+
+ 총 {list.length}건 + +
+
+ {list.length === 0 ? ( +
{loading ? "불러오는 중…" : "발송 이력이 없습니다."}
+ ) : list.map((n) => { + const open = expanded.has(n.OBJID); + return ( +
+ + {open && ( +
+ {n.BODY && ( +
+ {n.BODY} +
+ )} +
+
수신자 {n.RECIPIENT_NAMES?.length ?? 0}명
+
+ {n.RECIPIENT_COUNT < 0 ? ( + 전체 구독자 + ) : n.RECIPIENT_NAMES?.length === 0 ? ( + 기록 없음 (구버전) + ) : ( + n.RECIPIENT_NAMES.map((nm, i) => ( + + {nm || n.RECIPIENT_USER_IDS?.[i]} + + )) + )} +
+
+
+ )} +
+ ); + })} +
+
+
+ ); +} diff --git a/src/app/(main)/m/admin/notices/page.tsx b/src/app/(main)/m/admin/notices/page.tsx index c5e7f08..94daa06 100644 --- a/src/app/(main)/m/admin/notices/page.tsx +++ b/src/app/(main)/m/admin/notices/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState, useCallback, useMemo } from "react"; import Swal from "sweetalert2"; -import { Send, Upload, X, Bell } from "lucide-react"; +import { Send, Upload, X, Bell, Plus, Pencil, Trash2, Users, Save } from "lucide-react"; interface Recipient { USER_ID: string; @@ -14,6 +14,18 @@ interface Recipient { LAST_SEEN: string; DEVICE_CNT: number; } +interface AllUser { + USER_ID: string; + USER_NAME: string; + USER_TYPE: string; + IS_ADMIN: boolean; + DEPT_NAME: string; + SUBSCRIBED: boolean; +} +interface Group { + OBJID: string; NAME: string; DESCRIPTION: string | null; + REGDATE: string; MEMBER_CNT: number; MEMBER_IDS: string[]; +} interface Notice { OBJID: string; TITLE: string; BODY: string | null; IMAGE_URL: string | null; URL: string | null; @@ -21,11 +33,21 @@ interface Notice { } export default function AdminNoticesPage() { + // 그룹 + const [groups, setGroups] = useState([]); + const [selectedGroups, setSelectedGroups] = useState>(new Set()); + const [editingGroup, setEditingGroup] = useState<{ OBJID?: string; NAME: string; DESCRIPTION: string } | null>(null); + const [groupMemberSet, setGroupMemberSet] = useState>(new Set()); + const [allUsers, setAllUsers] = useState([]); + const [groupSaving, setGroupSaving] = useState(false); + + // 구독자(개별 선택) const [recipients, setRecipients] = useState([]); - const [selected, setSelected] = useState>(new Set()); + const [selectedUsers, setSelectedUsers] = 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(""); @@ -33,19 +55,31 @@ export default function AdminNoticesPage() { const [uploading, setUploading] = useState(false); const [sending, setSending] = useState(false); + // 과거 공지 const [past, setPast] = useState([]); + // 멤버 picker 검색 + const [memberPickerKw, setMemberPickerKw] = useState(""); + + const loadGroups = useCallback(async () => { + const r = await fetch("/api/m/admin/notices/groups/list", { method: "POST" }); + setGroups((await r.json()).RESULTLIST ?? []); + }, []); const loadRecipients = useCallback(async () => { const r = await fetch("/api/m/admin/notices/recipients", { method: "POST" }); setRecipients((await r.json()).RESULTLIST ?? []); }, []); + const loadAllUsers = useCallback(async () => { + const r = await fetch("/api/m/admin/notices/all-users", { method: "POST" }); + setAllUsers((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]); + useEffect(() => { loadGroups(); loadRecipients(); loadAllUsers(); loadPast(); }, [loadGroups, loadRecipients, loadAllUsers, loadPast]); - const filtered = useMemo(() => { + const filteredRecipients = useMemo(() => { return recipients.filter((u) => { if (typeFilter && (typeFilter === "A" ? !u.IS_ADMIN : u.IS_ADMIN)) return false; if (keyword) { @@ -56,23 +90,98 @@ export default function AdminNoticesPage() { }); }, [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 finalUserIds = useMemo(() => { + const set = new Set(selectedUsers); + for (const g of groups) { + if (selectedGroups.has(g.OBJID)) for (const uid of g.MEMBER_IDS ?? []) set.add(uid); + } + return Array.from(set); + }, [groups, selectedGroups, selectedUsers]); + + // ===== 그룹 편집 ===== + const openNewGroup = () => { + setEditingGroup({ NAME: "", DESCRIPTION: "" }); + setGroupMemberSet(new Set()); + setMemberPickerKw(""); }; - const toggleAllFiltered = () => { - const allOn = filtered.length > 0 && filtered.every((u) => selected.has(u.USER_ID)); - setSelected((p) => { + const openEditGroup = (g: Group) => { + setEditingGroup({ OBJID: g.OBJID, NAME: g.NAME, DESCRIPTION: g.DESCRIPTION ?? "" }); + setGroupMemberSet(new Set(g.MEMBER_IDS ?? [])); + setMemberPickerKw(""); + }; + const closeEditGroup = () => { setEditingGroup(null); setGroupMemberSet(new Set()); }; + + const toggleMemberInEdit = (uid: string) => { + setGroupMemberSet((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)); + if (n.has(uid)) n.delete(uid); else n.add(uid); return n; }); }; + const saveGroup = async () => { + if (!editingGroup) return; + if (!editingGroup.NAME.trim()) return Swal.fire({ icon: "warning", title: "그룹명을 입력하세요" }); + setGroupSaving(true); + try { + const sv = await fetch("/api/m/admin/notices/groups/save", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ objid: editingGroup.OBJID, name: editingGroup.NAME, description: editingGroup.DESCRIPTION }), + }); + const sj = await sv.json(); + if (!sj.success) { Swal.fire({ icon: "error", title: "저장 실패", text: sj.message }); return; } + const groupObjid = sj.objId; + const mv = await fetch("/api/m/admin/notices/groups/members/save", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ groupObjid, userIds: Array.from(groupMemberSet) }), + }); + const mj = await mv.json(); + if (!mj.success) { Swal.fire({ icon: "error", title: "멤버 저장 실패", text: mj.message }); return; } + await Swal.fire({ icon: "success", title: "그룹 저장 완료", timer: 1200, showConfirmButton: false }); + closeEditGroup(); + loadGroups(); + } finally { setGroupSaving(false); } + }; + + const deleteGroup = async () => { + if (!editingGroup?.OBJID) return; + const ok = await Swal.fire({ icon: "warning", title: `"${editingGroup.NAME}" 그룹 삭제?`, showCancelButton: true, confirmButtonColor: "#dc2626", confirmButtonText: "삭제" }); + if (!ok.isConfirmed) return; + setGroupSaving(true); + try { + const r = await fetch("/api/m/admin/notices/groups/save", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ objid: editingGroup.OBJID, action: "delete" }), + }); + const j = await r.json(); + if (j.success) { + await Swal.fire({ icon: "success", title: "삭제됨", timer: 1000, showConfirmButton: false }); + // 발송 대상에서도 제거 + setSelectedGroups((p) => { const n = new Set(p); n.delete(editingGroup.OBJID!); return n; }); + closeEditGroup(); loadGroups(); + } else Swal.fire({ icon: "error", title: "삭제 실패", text: j.message }); + } finally { setGroupSaving(false); } + }; + + // ===== 개별 선택 토글 ===== + const toggleUser = (id: string) => { + setSelectedUsers((p) => { const n = new Set(p); if (n.has(id)) n.delete(id); else n.add(id); return n; }); + }; + const toggleAllFiltered = () => { + const allOn = filteredRecipients.length > 0 && filteredRecipients.every((u) => selectedUsers.has(u.USER_ID)); + setSelectedUsers((p) => { + const n = new Set(p); + if (allOn) filteredRecipients.forEach((u) => n.delete(u.USER_ID)); + else filteredRecipients.forEach((u) => n.add(u.USER_ID)); + return n; + }); + }; + const toggleGroup = (id: string) => { + setSelectedGroups((p) => { const n = new Set(p); if (n.has(id)) n.delete(id); else n.add(id); return n; }); + }; + + // ===== 이미지 업로드 ===== const onUpload = async (file: File) => { setUploading(true); try { @@ -85,16 +194,13 @@ export default function AdminNoticesPage() { } 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: "수신자를 선택하세요" }); - } + if (!title.trim()) return Swal.fire({ icon: "warning", title: "제목을 입력하세요" }); + if (finalUserIds.length === 0) return Swal.fire({ icon: "warning", title: "수신자(그룹 또는 개별)를 선택하세요" }); const ok = await Swal.fire({ icon: "question", - title: `${selected.size}명에게 푸시 발송`, + title: `${finalUserIds.length}명에게 푸시 발송`, text: `제목: ${title}`, showCancelButton: true, confirmButtonText: "발송", cancelButtonText: "취소", confirmButtonColor: "#0f766e", @@ -102,7 +208,6 @@ export default function AdminNoticesPage() { 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 }), @@ -110,21 +215,21 @@ export default function AdminNoticesPage() { const svj = await sv.json(); if (!svj.success) { Swal.fire({ icon: "error", title: "저장 실패", text: svj.message }); return; } const noticeObjid = svj.objId; - - // 2) 푸시 발송 (URL = 외부 링크 우선, 없으면 자체 공지 페이지) + const groupNames = groups.filter((g) => selectedGroups.has(g.OBJID)).map((g) => g.NAME); 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), + userIds: finalUserIds, + groupNames, }), }); 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()); + setSelectedGroups(new Set()); setSelectedUsers(new Set()); loadPast(); } else { Swal.fire({ icon: "error", title: "발송 실패", text: sj.message }); @@ -132,61 +237,166 @@ export default function AdminNoticesPage() { } finally { setSending(false); } }; + // 멤버 picker (그룹 편집 패널) + const memberPickerFiltered = useMemo(() => { + return allUsers.filter((u) => { + if (memberPickerKw) { + const k = memberPickerKw.toLowerCase(); + if (!u.USER_NAME.toLowerCase().includes(k) && !u.USER_ID.toLowerCase().includes(k)) return false; + } + return true; + }); + }, [allUsers, memberPickerKw]); + 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 ( -