From e4fcfd453d22ebce600fe07024278976b3c184fb Mon Sep 17 00:00:00 2001 From: chpark Date: Sat, 30 May 2026 15:42:11 +0900 Subject: [PATCH] =?UTF-8?q?feat(notices):=20=EA=B6=8C=ED=95=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=ED=99=94=EB=A9=B4=EA=B3=BC=20=EB=8F=99=EC=9D=BC?= =?UTF-8?q?=ED=95=9C=203-=ED=8C=A8=EB=84=90=20UI=20=EB=A1=9C=20=EC=A0=84?= =?UTF-8?q?=EB=A9=B4=20=EC=9E=AC=EC=84=A4=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 요청 사항 반영: 1) UI 패턴을 권한 관리 화면(admin-panel AuthManagement)과 통일 - 좌측 (260px): 수신자 그룹 목록 + 검색 + [+ 생성] 버튼 - 우측 상단: 그룹 멤버 / [‹ 추가 / 제거 ›] / 멤버 아닌 사용자 풀 (권한있는/권한없는 직원 양쪽 패널 패턴 그대로) - 우측 하단: 발송 내용 (제목/본문/이미지/링크) + 발송 버튼 2) 그룹 추가/제거를 클릭 한 번으로 (전체 풀에서 체크 후 추가 → 멤버 패널로 이동) 3) 더블클릭으로 그룹 이름/설명 수정 또는 삭제 4) [전체 구독자에게 발송] 옵션 — 그룹 선택 없이도 전체 푸시 가능 5) 화면 안의 '최근 공지' 카드 제거 — 좌측 메뉴 [푸시알림 발송이력] 로 일원화 --- src/app/(main)/m/admin/notices/page.tsx | 629 +++++++++++------------- 1 file changed, 299 insertions(+), 330 deletions(-) diff --git a/src/app/(main)/m/admin/notices/page.tsx b/src/app/(main)/m/admin/notices/page.tsx index 94daa06..42c9509 100644 --- a/src/app/(main)/m/admin/notices/page.tsx +++ b/src/app/(main)/m/admin/notices/page.tsx @@ -1,18 +1,18 @@ "use client"; +// 푸시알림 게시판 — 권한 관리 화면(admin-panel/AuthManagement)과 동일한 3-패널 패턴. +// 좌측 : 수신자 그룹 목록 [+ 생성] +// 우측 상단 : 그룹 멤버 / [추가/제거] / 전체 사용자 풀 ← 권한있는/권한없는 직원 +// 우측 하단 : 컨텐츠(제목/본문/이미지/링크) + 발송 +// 발송이력은 별도 메뉴(/m/admin/notice-history)로 분리됨 — 본 화면에서 제거. + import { useEffect, useState, useCallback, useMemo } from "react"; import Swal from "sweetalert2"; -import { Send, Upload, X, Bell, Plus, Pencil, Trash2, Users, Save } from "lucide-react"; +import { Send, Upload, X, Bell, Users, Shield } 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 Group { + OBJID: string; NAME: string; DESCRIPTION: string | null; + REGDATE: string; MEMBER_CNT: number; MEMBER_IDS: string[]; } interface AllUser { USER_ID: string; @@ -22,163 +22,140 @@ interface AllUser { 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; - REGDATE: string; SENT_COUNT: number; -} 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 [groupQuery, setGroupQuery] = useState(""); + const [activeGroup, setActiveGroup] = useState(null); + + // ===== 우측 상단: 멤버 양쪽 패널 ===== const [allUsers, setAllUsers] = useState([]); - const [groupSaving, setGroupSaving] = useState(false); + const [memberQ, setMemberQ] = useState(""); + const [availQ, setAvailQ] = useState(""); + const [chkMember, setChkMember] = useState>(new Set()); + const [chkAvail, setChkAvail] = useState>(new Set()); - // 구독자(개별 선택) - const [recipients, setRecipients] = useState([]); - 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(""); const [linkUrl, setLinkUrl] = useState(""); const [uploading, setUploading] = useState(false); const [sending, setSending] = useState(false); - - // 과거 공지 - const [past, setPast] = useState([]); - - // 멤버 picker 검색 - const [memberPickerKw, setMemberPickerKw] = useState(""); + // 그룹 외 추가 옵션 — 활성 그룹 없이 발송하고 싶을 때 + const [sendAll, setSendAll] = useState(false); 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 j = await r.json(); + const list = (j.RESULTLIST ?? []) as Group[]; + setGroups(list); + // 활성 그룹이 갱신되도록 sync + setActiveGroup((cur) => (cur ? (list.find((g) => g.OBJID === cur.OBJID) ?? null) : null)); }, []); 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(() => { loadGroups(); loadRecipients(); loadAllUsers(); loadPast(); }, [loadGroups, loadRecipients, loadAllUsers, loadPast]); + useEffect(() => { loadGroups(); loadAllUsers(); }, [loadGroups, loadAllUsers]); - const filteredRecipients = 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; + // 활성 그룹이 바뀌면 체크박스 초기화 + useEffect(() => { setChkMember(new Set()); setChkAvail(new Set()); }, [activeGroup?.OBJID]); + + const filteredGroups = useMemo(() => groups.filter((g) => + !groupQuery || g.NAME.toLowerCase().includes(groupQuery.toLowerCase()) || (g.DESCRIPTION ?? "").toLowerCase().includes(groupQuery.toLowerCase()) + ), [groups, groupQuery]); + + // 활성 그룹 멤버 / 풀 + const memberSet = useMemo(() => new Set(activeGroup?.MEMBER_IDS ?? []), [activeGroup]); + const members = useMemo(() => allUsers.filter((u) => memberSet.has(u.USER_ID)), [allUsers, memberSet]); + const available = useMemo(() => allUsers.filter((u) => !memberSet.has(u.USER_ID)), [allUsers, memberSet]); + const filteredMembers = useMemo(() => members.filter((u) => !memberQ || u.USER_NAME.includes(memberQ) || u.USER_ID.includes(memberQ) || (u.DEPT_NAME ?? "").includes(memberQ)), [members, memberQ]); + const filteredAvail = useMemo(() => available.filter((u) => !availQ || u.USER_NAME.includes(availQ) || u.USER_ID.includes(availQ) || (u.DEPT_NAME ?? "").includes(availQ)), [available, availQ]); + + // ===== 그룹 생성/수정/삭제 (SweetAlert) ===== + const onCreate = async () => { + const r = await Swal.fire({ + title: "수신자 그룹 생성", + html: ` + + + `, + showCancelButton: true, confirmButtonText: "생성", confirmButtonColor: "#0f766e", + preConfirm: () => ({ + name: (document.getElementById("sw_name") as HTMLInputElement).value.trim(), + description: (document.getElementById("sw_desc") as HTMLInputElement).value.trim(), + }), + }); + if (!r.isConfirmed || !r.value?.name) return; + const sv = await fetch("/api/m/admin/notices/groups/save", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: r.value.name, description: r.value.description }), + }); + const sj = await sv.json(); + if (!sj.success) { Swal.fire({ icon: "error", title: "생성 실패", text: sj.message }); return; } + await loadGroups(); + // 새로 만든 그룹을 활성화 + setActiveGroup({ OBJID: sj.objId, NAME: r.value.name, DESCRIPTION: r.value.description || null, REGDATE: "", MEMBER_CNT: 0, MEMBER_IDS: [] }); + }; + + const onRename = async (g: Group) => { + const r = await Swal.fire({ + title: "그룹 수정", icon: "info", + html: ` + + + `, + showCancelButton: true, showDenyButton: true, denyButtonText: "삭제", confirmButtonText: "저장", confirmButtonColor: "#0f766e", + preConfirm: () => ({ + name: (document.getElementById("sw_name") as HTMLInputElement).value.trim(), + description: (document.getElementById("sw_desc") as HTMLInputElement).value.trim(), + }), + }); + if (r.isDenied) { + const ok = await Swal.fire({ icon: "warning", title: `"${g.NAME}" 삭제?`, showCancelButton: true, confirmButtonColor: "#dc2626", confirmButtonText: "삭제" }); + if (!ok.isConfirmed) return; + const res = await fetch("/api/m/admin/notices/groups/save", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ objid: g.OBJID, action: "delete" }), + }); + if ((await res.json()).success) { + if (activeGroup?.OBJID === g.OBJID) setActiveGroup(null); + loadGroups(); } - return true; - }); - }, [recipients, typeFilter, keyword]); - - // 발송 대상 = 선택 그룹들의 멤버 ∪ 개별 선택 - 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; } - return Array.from(set); - }, [groups, selectedGroups, selectedUsers]); - - // ===== 그룹 편집 ===== - const openNewGroup = () => { - setEditingGroup({ NAME: "", DESCRIPTION: "" }); - setGroupMemberSet(new Set()); - setMemberPickerKw(""); - }; - 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 (n.has(uid)) n.delete(uid); else n.add(uid); - return n; + if (!r.isConfirmed || !r.value?.name) return; + const res = await fetch("/api/m/admin/notices/groups/save", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ objid: g.OBJID, name: r.value.name, description: r.value.description }), }); + if ((await res.json()).success) loadGroups(); }; - 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 saveMembers = async (newIds: string[]) => { + if (!activeGroup) return false; + const r = await fetch("/api/m/admin/notices/groups/members/save", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ groupObjid: activeGroup.OBJID, userIds: newIds }), }); + const j = await r.json(); + if (!j.success) { Swal.fire({ icon: "error", title: "저장 실패", text: j.message }); return false; } + return true; }; - 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 addSelected = async () => { + if (!activeGroup) return Swal.fire({ icon: "warning", title: "그룹을 선택하세요" }); + if (chkAvail.size === 0) return Swal.fire({ icon: "warning", title: "추가할 사용자를 선택하세요" }); + const newIds = Array.from(new Set([...(activeGroup.MEMBER_IDS ?? []), ...Array.from(chkAvail)])); + if (await saveMembers(newIds)) { setChkAvail(new Set()); loadGroups(); } + }; + const removeSelected = async () => { + if (!activeGroup) return Swal.fire({ icon: "warning", title: "그룹을 선택하세요" }); + if (chkMember.size === 0) return Swal.fire({ icon: "warning", title: "제거할 사용자를 선택하세요" }); + const newIds = (activeGroup.MEMBER_IDS ?? []).filter((id) => !chkMember.has(id)); + if (await saveMembers(newIds)) { setChkMember(new Set()); loadGroups(); } }; // ===== 이미지 업로드 ===== @@ -195,15 +172,17 @@ export default function AdminNoticesPage() { }; // ===== 발송 ===== + const targetUserIds = useMemo(() => sendAll ? [] : (activeGroup?.MEMBER_IDS ?? []), [sendAll, activeGroup]); + const targetCount = sendAll ? -1 : targetUserIds.length; + const send = async () => { if (!title.trim()) return Swal.fire({ icon: "warning", title: "제목을 입력하세요" }); - if (finalUserIds.length === 0) return Swal.fire({ icon: "warning", title: "수신자(그룹 또는 개별)를 선택하세요" }); + if (!sendAll && targetUserIds.length === 0) return Swal.fire({ icon: "warning", title: "그룹(또는 멤버)을 선택하거나 [전체 구독자] 옵션을 켜세요" }); + + const targetLabel = sendAll ? "전체 구독자" : `${activeGroup?.NAME} (${targetUserIds.length}명)`; const ok = await Swal.fire({ - icon: "question", - title: `${finalUserIds.length}명에게 푸시 발송`, - text: `제목: ${title}`, - showCancelButton: true, confirmButtonText: "발송", cancelButtonText: "취소", - confirmButtonColor: "#0f766e", + icon: "question", title: `${targetLabel} 에게 발송`, text: `제목: ${title}`, + showCancelButton: true, confirmButtonText: "발송", cancelButtonText: "취소", confirmButtonColor: "#0f766e", }); if (!ok.isConfirmed) return; setSending(true); @@ -215,190 +194,190 @@ 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; - const groupNames = groups.filter((g) => selectedGroups.has(g.OBJID)).map((g) => g.NAME); - const send = await fetch("/api/m/admin/notices/send-push", { + const groupNames = activeGroup && !sendAll ? [activeGroup.NAME] : []; + const sendRes = 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: finalUserIds, + userIds: sendAll ? undefined : targetUserIds, + sendAll, groupNames, }), }); - const sj = await send.json(); + const sj = await sendRes.json(); if (sj.success) { await Swal.fire({ icon: "success", title: "발송 완료", text: `성공 ${sj.sent} · 실패 ${sj.failed}`, timer: 1800, showConfirmButton: false }); - setTitle(""); setBodyText(""); setImageUrl(""); setLinkUrl(""); - setSelectedGroups(new Set()); setSelectedUsers(new Set()); - loadPast(); + setTitle(""); setBodyText(""); setImageUrl(""); setLinkUrl(""); setSendAll(false); } else { Swal.fire({ icon: "error", title: "발송 실패", text: sj.message }); } } 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 (

푸시알림 게시판

-

왼쪽에서 수신자 그룹을 선택(또는 개별 선택)하고, 오른쪽에 제목·본문·이미지를 작성한 뒤 [발송]을 누르세요.

+

왼쪽에서 수신자 그룹을 선택하고, 가운데 추가/제거로 멤버를 관리한 뒤, 아래쪽에서 내용을 작성해 [발송]을 누르세요. 발송 이력은 좌측 메뉴 푸시알림 발송이력에서 확인합니다.

-
- {/* ===== 왼쪽 ===== */} -
- {/* 그룹 selector */} -
-
- 수신자 그룹 ({groups.length}) - -
-
- {groups.length === 0 ? ( -
그룹이 없습니다. [+ 새 그룹] 으로 만들어보세요.
- ) : groups.map((g) => { - const checked = selectedGroups.has(g.OBJID); - return ( -
- toggleGroup(g.OBJID)} className="accent-emerald-600 shrink-0" /> -
toggleGroup(g.OBJID)}> -
{g.NAME} ({g.MEMBER_CNT}명)
- {g.DESCRIPTION &&
{g.DESCRIPTION}
} -
- -
- ); - })} + {/* ===== 상단: 좌측 그룹 목록 + 우측 멤버 양쪽 패널 ===== */} +
+ {/* 좌측: 수신자 그룹 목록 */} +
+
+
+ 수신자 그룹 ({groups.length})
+
- - {/* 그룹 편집 패널 (권한그룹 스타일 — 아래쪽 전개) */} - {editingGroup && ( -
-
- {editingGroup.OBJID ? "그룹 멤버 관리" : "새 그룹"} - -
-
-
- - setEditingGroup({ ...editingGroup, NAME: e.target.value })} - placeholder="예: 본사 거래처" - className="w-full h-9 px-2 rounded border border-slate-200 text-sm mt-1" /> +
+ setGroupQuery(e.target.value)} + placeholder="검색..." className="w-full h-8 px-2 text-xs border border-slate-200 rounded" /> +
+
+ {filteredGroups.length === 0 ? ( +
그룹이 없습니다.
+ ) : filteredGroups.map((g) => ( + - ) :
} - -
-
-
- )} - - {/* 개별 선택 (구독자) */} -
-
- 개별 선택 (구독자 {recipients.length}명 / 선택 {selectedUsers.size}) - -
-
- setKeyword(e.target.value)} placeholder="이름/아이디 검색" - className="flex-1 h-8 px-2 rounded border border-slate-200 text-xs" /> - -
-
- {filteredRecipients.length === 0 ? ( -
구독자가 없습니다.
- ) : filteredRecipients.map((u) => { - const checked = selectedUsers.has(u.USER_ID); - return ( - - ); - })} -
+ ))}
- {/* ===== 오른쪽 ===== */} -
-
+ {/* 우측: 멤버 양쪽 패널 */} +
+ {/* 그룹 멤버 (= 권한있는 직원) */} +
+
+ 그룹 멤버 ({members.length}) +
+
+ + setMemberQ(e.target.value)} + placeholder="검색" className="ml-auto h-7 w-40 px-2 text-xs border border-slate-200 rounded" /> +
+
+ + + + + + {filteredMembers.map((u) => ( + + + + + + + ))} + {filteredMembers.length === 0 && ( + + )} + +
부서이름ID
+ { const s = new Set(chkMember); if (e.target.checked) s.add(u.USER_ID); else s.delete(u.USER_ID); setChkMember(s); }} + className="w-4 h-4 accent-emerald-600" /> + {u.DEPT_NAME || "-"} + {u.USER_NAME} + {u.IS_ADMIN && 관리자} + {u.USER_ID}
+ {activeGroup ? "멤버가 없습니다. 오른쪽 풀에서 추가하세요." : "왼쪽에서 수신자 그룹을 선택하세요"} +
+
+
+ + {/* 추가/제거 버튼 */} +
+ + +
+ + {/* 전체 사용자 풀 (= 권한없는 직원) */} +
+
+ 멤버 아닌 사용자 ({available.length}) +
+
+ + setAvailQ(e.target.value)} + placeholder="검색" className="ml-auto h-7 w-40 px-2 text-xs border border-slate-200 rounded" /> +
+
+ + + + + + {filteredAvail.map((u) => ( + + + + + + + ))} + {filteredAvail.length === 0 && ( + + )} + +
부서이름ID
+ { const s = new Set(chkAvail); if (e.target.checked) s.add(u.USER_ID); else s.delete(u.USER_ID); setChkAvail(s); }} + className="w-4 h-4 accent-emerald-600" /> + {u.DEPT_NAME || "-"} + {u.USER_NAME} + {u.IS_ADMIN && 관리자} + {u.SUBSCRIBED && 구독중} + {u.USER_ID}
+ {activeGroup ? "추가 가능한 사용자가 없습니다." : "왼쪽에서 수신자 그룹을 선택하세요"} +
+
+
+
+
+ + {/* ===== 하단: 컨텐츠 작성 + 발송 ===== */} +
+
+ 발송 내용 +
+
+
setTitle(e.target.value)} @@ -413,6 +392,8 @@ export default function AdminNoticesPage() { className="w-full px-3 py-2 rounded-lg border border-slate-200 text-sm mt-1 resize-y" />
푸시 본문에는 앞 240자만 노출. 전체 본문은 공지 페이지에서 확인.
+
+
@@ -428,7 +409,7 @@ export default function AdminNoticesPage() {
{imageUrl && ( // eslint-disable-next-line @next/next/no-img-element - 첨부 + 첨부 )}
@@ -438,38 +419,26 @@ export default function AdminNoticesPage() { className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm mt-1" />
비워두면 자체 공지 페이지(이미지+본문)로 이동합니다.
-
-
- 선택 그룹 {selectedGroups.size} · 개별 {selectedUsers.size} · 대상 {finalUserIds.length}명 -
- -
+
- {/* 최근 공지 */} -
-
최근 공지 ({past.length}건)
-
- {past.length === 0 ? ( -
아직 작성된 공지가 없습니다.
- ) : past.map((n) => ( - - {n.IMAGE_URL && ( - // eslint-disable-next-line @next/next/no-img-element - - )} -
-
{n.TITLE}
-
{n.REGDATE} · 발송 {n.SENT_COUNT ?? 0}건
-
-
- ))} -
+
+
+ + {!sendAll && ( + + 대상 그룹: {activeGroup?.NAME ?? "(선택 없음)"} + {activeGroup && · {targetUserIds.length}명} + + )}
+