feat(notices): 수신자 그룹 + 발송이력 메뉴 신설
Deploy momo-erp / deploy (push) Failing after 12m27s

수신자 그룹:
- 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).
This commit is contained in:
chpark
2026-05-30 13:45:07 +09:00
parent 8e49fab63f
commit ecc14561e6
10 changed files with 630 additions and 80 deletions
@@ -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<HistoryRow[]>([]);
const [loading, setLoading] = useState(false);
const [expanded, setExpanded] = useState<Set<string>>(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 (
<div className="space-y-3">
<div>
<h1 className="text-xl sm:text-2xl font-bold inline-flex items-center gap-2">
<History size={18} className="text-emerald-700" />
</h1>
<p className="text-xs text-slate-500 mt-0.5"> ··/ . · .</p>
</div>
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
<div className="px-3 py-2 bg-slate-50 border-b border-slate-200 text-xs font-semibold text-slate-600 flex items-center justify-between">
<span> {list.length}</span>
<button onClick={load} disabled={loading}
className="text-[11px] px-2 py-0.5 rounded border border-slate-300 bg-white hover:bg-slate-100 disabled:opacity-50">
</button>
</div>
<div className="divide-y divide-slate-100 max-h-[calc(100vh-220px)] overflow-y-auto">
{list.length === 0 ? (
<div className="text-center py-10 text-slate-400 text-sm">{loading ? "불러오는 중…" : "발송 이력이 없습니다."}</div>
) : list.map((n) => {
const open = expanded.has(n.OBJID);
return (
<div key={n.OBJID} className="px-3 py-2">
<button onClick={() => toggle(n.OBJID)} className="w-full flex items-center gap-3 text-left">
<span className="text-slate-400 shrink-0">{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}</span>
{n.IMAGE_URL && (
// eslint-disable-next-line @next/next/no-img-element
<img src={n.IMAGE_URL} alt="" className="w-10 h-10 rounded object-cover shrink-0" />
)}
<div className="min-w-0 flex-1">
<div className="text-sm font-bold truncate">{n.TITLE}</div>
<div className="text-[11px] text-slate-500 truncate flex items-center gap-1.5">
<span>{n.REGDATE}</span>
<span className="text-slate-300">·</span>
<span> <b className="text-slate-700">{n.RECIPIENT_COUNT < 0 ? "전체" : n.RECIPIENT_COUNT}</b></span>
<span className="text-slate-300">·</span>
<span className="text-emerald-700"> {n.SENT_COUNT}</span>
{n.FAILED_COUNT > 0 && <><span className="text-slate-300">/</span><span className="text-rose-600"> {n.FAILED_COUNT}</span></>}
{n.GROUP_NAMES && n.GROUP_NAMES.length > 0 && (
<>
<span className="text-slate-300">·</span>
<span className="text-violet-700 inline-flex items-center gap-0.5"><Users size={11} />{n.GROUP_NAMES.join(", ")}</span>
</>
)}
</div>
</div>
<a href={`/m/notices/${n.OBJID}`} target="_blank" rel="noreferrer" onClick={(e) => e.stopPropagation()}
className="shrink-0 text-slate-400 hover:text-emerald-700 inline-flex items-center gap-0.5 text-[11px]" title="공지 페이지 열기">
<ExternalLink size={11} />
</a>
</button>
{open && (
<div className="mt-2 pl-7 space-y-2">
{n.BODY && (
<div className="bg-slate-50 rounded p-2 text-xs whitespace-pre-wrap leading-relaxed text-slate-700 border border-slate-100">
{n.BODY}
</div>
)}
<div>
<div className="text-[11px] font-bold text-slate-600 mb-1"> {n.RECIPIENT_NAMES?.length ?? 0}</div>
<div className="flex flex-wrap gap-1">
{n.RECIPIENT_COUNT < 0 ? (
<span className="text-[11px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 font-bold"> </span>
) : n.RECIPIENT_NAMES?.length === 0 ? (
<span className="text-[11px] text-slate-400"> ()</span>
) : (
n.RECIPIENT_NAMES.map((nm, i) => (
<span key={i} className="text-[11px] px-1.5 py-0.5 rounded bg-slate-100 text-slate-700">
{nm || n.RECIPIENT_USER_IDS?.[i]}
</span>
))
)}
</div>
</div>
</div>
)}
</div>
);
})}
</div>
</div>
</div>
);
}
+289 -76
View File
@@ -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<Group[]>([]);
const [selectedGroups, setSelectedGroups] = useState<Set<string>>(new Set());
const [editingGroup, setEditingGroup] = useState<{ OBJID?: string; NAME: string; DESCRIPTION: string } | null>(null);
const [groupMemberSet, setGroupMemberSet] = useState<Set<string>>(new Set());
const [allUsers, setAllUsers] = useState<AllUser[]>([]);
const [groupSaving, setGroupSaving] = useState(false);
// 구독자(개별 선택)
const [recipients, setRecipients] = useState<Recipient[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [selectedUsers, setSelectedUsers] = useState<Set<string>>(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<Notice[]>([]);
// 멤버 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<string>(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 (
<div className="space-y-3">
<div className="flex items-end justify-between flex-wrap gap-2">
<div>
<h1 className="text-xl sm:text-2xl font-bold inline-flex items-center gap-2">
<Bell size={18} className="text-emerald-700" />
</h1>
<p className="text-xs text-slate-500 mt-0.5"> , ·· [] . .</p>
</div>
<div>
<h1 className="text-xl sm:text-2xl font-bold inline-flex items-center gap-2">
<Bell size={18} className="text-emerald-700" />
</h1>
<p className="text-xs text-slate-500 mt-0.5"> <b> </b> ( ), ·· [] .</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-[420px_1fr] gap-3">
{/* 좌: 수신자 */}
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden flex flex-col">
<div className="px-3 py-2 bg-slate-50 border-b border-slate-200 text-xs font-semibold text-slate-600 flex items-center justify-between">
<span> ( {recipients.length} / {selected.size})</span>
<button onClick={toggleAllFiltered} className="text-[11px] px-2 py-0.5 rounded border border-slate-300 bg-white hover:bg-slate-100">
{filtered.length > 0 && filtered.every((u) => selected.has(u.USER_ID)) ? "전체 해제" : "표시된 전체 선택"}
</button>
</div>
<div className="p-2 flex gap-1.5 border-b border-slate-100">
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder="이름/아이디 검색"
className="flex-1 h-8 px-2 rounded border border-slate-200 text-xs" />
<select value={typeFilter} onChange={(e) => setTypeFilter(e.target.value as "" | "U" | "A")}
className="h-8 px-2 rounded border border-slate-200 bg-white text-xs">
<option value=""></option>
<option value="U"></option>
<option value="A"></option>
</select>
</div>
<div className="overflow-y-auto max-h-[60vh] lg:max-h-[calc(100vh-260px)]">
{filtered.length === 0 ? (
<div className="text-center py-10 text-slate-400 text-sm"> . .</div>
) : filtered.map((u) => {
const checked = selected.has(u.USER_ID);
const sb = (u.USER_AGENT || "").includes("SamsungBrowser");
return (
<label key={u.USER_ID}
className={`flex items-center gap-2 px-3 py-2 border-b border-slate-100 cursor-pointer ${checked ? "bg-emerald-50/60" : "hover:bg-slate-50"}`}>
<input type="checkbox" checked={checked} onChange={() => toggle(u.USER_ID)} className="accent-emerald-600 shrink-0" />
<div className="min-w-0 flex-1">
<div className="text-sm font-bold truncate flex items-center gap-1.5">
{u.USER_NAME || u.USER_ID}
{u.IS_ADMIN && <span className="text-[9px] px-1 py-0.5 rounded bg-violet-100 text-violet-700 font-bold"></span>}
{sb && <span className="text-[9px] px-1 py-0.5 rounded bg-slate-100 text-slate-500">SamsungBrowser</span>}
<div className="grid grid-cols-1 lg:grid-cols-[460px_1fr] gap-3 items-start">
{/* ===== 왼쪽 ===== */}
<div className="space-y-3">
{/* 그룹 selector */}
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
<div className="px-3 py-2 bg-slate-50 border-b border-slate-200 text-xs font-semibold text-slate-600 flex items-center justify-between">
<span className="inline-flex items-center gap-1"><Users size={13} /> ({groups.length})</span>
<button onClick={openNewGroup} className="text-[11px] inline-flex items-center gap-1 px-2 py-0.5 rounded bg-emerald-700 text-white font-bold hover:bg-emerald-800">
<Plus size={11} />
</button>
</div>
<div className="max-h-[28vh] overflow-y-auto">
{groups.length === 0 ? (
<div className="text-center py-6 text-slate-400 text-sm"> . [+ ] .</div>
) : groups.map((g) => {
const checked = selectedGroups.has(g.OBJID);
return (
<div key={g.OBJID}
className={`flex items-center gap-2 px-3 py-2 border-b border-slate-100 ${checked ? "bg-emerald-50/60" : "hover:bg-slate-50"}`}>
<input type="checkbox" checked={checked} onChange={() => toggleGroup(g.OBJID)} className="accent-emerald-600 shrink-0" />
<div className="min-w-0 flex-1 cursor-pointer" onClick={() => toggleGroup(g.OBJID)}>
<div className="text-sm font-bold truncate">{g.NAME} <span className="text-xs text-slate-500 font-normal">({g.MEMBER_CNT})</span></div>
{g.DESCRIPTION && <div className="text-[10px] text-slate-500 truncate">{g.DESCRIPTION}</div>}
</div>
<div className="text-[10px] text-slate-500 truncate">{u.USER_ID} · {u.LAST_SEEN} {u.DEVICE_CNT > 1 && `(${u.DEVICE_CNT}대)`}</div>
<button onClick={() => openEditGroup(g)} title="멤버 관리" className="shrink-0 text-slate-400 hover:text-emerald-700 p-1">
<Pencil size={13} />
</button>
</div>
</label>
);
})}
);
})}
</div>
</div>
{/* 그룹 편집 패널 (권한그룹 스타일 — 아래쪽 전개) */}
{editingGroup && (
<div className="bg-white border border-emerald-300 rounded-xl overflow-hidden ring-2 ring-emerald-100">
<div className="px-3 py-2 bg-emerald-50 border-b border-emerald-200 text-xs font-semibold text-emerald-800 flex items-center justify-between">
<span>{editingGroup.OBJID ? "그룹 멤버 관리" : "새 그룹"}</span>
<button onClick={closeEditGroup} className="text-slate-500 hover:text-rose-600"><X size={14} /></button>
</div>
<div className="p-3 space-y-2">
<div>
<label className="text-[11px] font-semibold text-slate-600"> *</label>
<input value={editingGroup.NAME} onChange={(e) => setEditingGroup({ ...editingGroup, NAME: e.target.value })}
placeholder="예: 본사 거래처"
className="w-full h-9 px-2 rounded border border-slate-200 text-sm mt-1" />
</div>
<div>
<label className="text-[11px] font-semibold text-slate-600"> ()</label>
<input value={editingGroup.DESCRIPTION} onChange={(e) => setEditingGroup({ ...editingGroup, DESCRIPTION: e.target.value })}
placeholder="용도/대상 메모"
className="w-full h-9 px-2 rounded border border-slate-200 text-sm mt-1" />
</div>
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-[11px] font-semibold text-slate-600"> ({groupMemberSet.size})</label>
<input value={memberPickerKw} onChange={(e) => setMemberPickerKw(e.target.value)}
placeholder="이름/아이디 검색"
className="w-40 h-7 px-2 rounded border border-slate-200 text-[11px]" />
</div>
<div className="max-h-[28vh] overflow-y-auto border border-slate-200 rounded">
{memberPickerFiltered.length === 0 ? (
<div className="text-center py-6 text-slate-400 text-xs"> .</div>
) : memberPickerFiltered.map((u) => {
const checked = groupMemberSet.has(u.USER_ID);
return (
<label key={u.USER_ID}
className={`flex items-center gap-2 px-2 py-1.5 border-b border-slate-100 cursor-pointer text-xs ${checked ? "bg-emerald-50/60" : "hover:bg-slate-50"}`}>
<input type="checkbox" checked={checked} onChange={() => toggleMemberInEdit(u.USER_ID)} className="accent-emerald-600 shrink-0" />
<div className="min-w-0 flex-1">
<div className="font-bold truncate">
{u.USER_NAME || u.USER_ID}
{u.IS_ADMIN && <span className="ml-1 text-[9px] px-1 py-0.5 rounded bg-violet-100 text-violet-700 font-bold"></span>}
{u.SUBSCRIBED && <span className="ml-1 text-[9px] px-1 py-0.5 rounded bg-emerald-100 text-emerald-700 font-bold"></span>}
</div>
<div className="text-[10px] text-slate-500 truncate">{u.USER_ID}</div>
</div>
</label>
);
})}
</div>
</div>
<div className="flex items-center justify-between pt-2 border-t border-slate-100">
{editingGroup.OBJID ? (
<button onClick={deleteGroup} disabled={groupSaving}
className="inline-flex items-center gap-1 h-9 px-3 rounded border border-rose-300 bg-white text-rose-700 text-xs font-bold hover:bg-rose-50 disabled:opacity-50">
<Trash2 size={13} />
</button>
) : <div />}
<button onClick={saveGroup} disabled={groupSaving}
className="inline-flex items-center gap-1 h-9 px-4 rounded bg-emerald-700 text-white text-xs font-bold hover:bg-emerald-800 disabled:opacity-50">
<Save size={13} /> {groupSaving ? "저장 중…" : "저장"}
</button>
</div>
</div>
</div>
)}
{/* 개별 선택 (구독자) */}
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
<div className="px-3 py-2 bg-slate-50 border-b border-slate-200 text-xs font-semibold text-slate-600 flex items-center justify-between">
<span> ( {recipients.length} / {selectedUsers.size})</span>
<button onClick={toggleAllFiltered} className="text-[11px] px-2 py-0.5 rounded border border-slate-300 bg-white hover:bg-slate-100">
{filteredRecipients.length > 0 && filteredRecipients.every((u) => selectedUsers.has(u.USER_ID)) ? "전체 해제" : "표시된 전체 선택"}
</button>
</div>
<div className="p-2 flex gap-1.5 border-b border-slate-100">
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder="이름/아이디 검색"
className="flex-1 h-8 px-2 rounded border border-slate-200 text-xs" />
<select value={typeFilter} onChange={(e) => setTypeFilter(e.target.value as "" | "U" | "A")}
className="h-8 px-2 rounded border border-slate-200 bg-white text-xs">
<option value=""></option>
<option value="U"></option>
<option value="A"></option>
</select>
</div>
<div className="overflow-y-auto max-h-[30vh]">
{filteredRecipients.length === 0 ? (
<div className="text-center py-6 text-slate-400 text-xs"> .</div>
) : filteredRecipients.map((u) => {
const checked = selectedUsers.has(u.USER_ID);
return (
<label key={u.USER_ID}
className={`flex items-center gap-2 px-3 py-1.5 border-b border-slate-100 cursor-pointer text-xs ${checked ? "bg-emerald-50/60" : "hover:bg-slate-50"}`}>
<input type="checkbox" checked={checked} onChange={() => toggleUser(u.USER_ID)} className="accent-emerald-600 shrink-0" />
<div className="min-w-0 flex-1">
<div className="font-bold truncate">
{u.USER_NAME || u.USER_ID}
{u.IS_ADMIN && <span className="ml-1 text-[9px] px-1 py-0.5 rounded bg-violet-100 text-violet-700 font-bold"></span>}
</div>
<div className="text-[10px] text-slate-500 truncate">{u.USER_ID}</div>
</div>
</label>
);
})}
</div>
</div>
</div>
{/* 우: 작성 */}
{/* ===== 오른쪽 ===== */}
<div className="space-y-3">
<div className="bg-white border border-slate-200 rounded-xl p-4 space-y-3">
<div>
@@ -228,15 +438,18 @@ export default function AdminNoticesPage() {
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm mt-1" />
<div className="text-[10px] text-slate-400 mt-0.5"> (+) .</div>
</div>
<div className="flex justify-end pt-2 border-t border-slate-100">
<div className="flex justify-between items-center pt-2 border-t border-slate-100">
<div className="text-xs text-slate-500">
{selectedGroups.size} · {selectedUsers.size} · <b className="text-emerald-700"> {finalUserIds.length}</b>
</div>
<button onClick={send} disabled={sending}
className="inline-flex items-center gap-2 px-5 h-11 rounded-xl bg-emerald-700 text-white text-sm font-bold hover:bg-emerald-800 disabled:opacity-50">
<Send size={14} /> {sending ? "발송 중…" : `선택한 ${selected.size}명에게 발송`}
<Send size={14} /> {sending ? "발송 중…" : `${finalUserIds.length}명에게 발송`}
</button>
</div>
</div>
{/* 최근 발송 */}
{/* 최근 공지 */}
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
<div className="px-3 py-2 bg-slate-50 border-b border-slate-200 text-xs font-semibold text-slate-600"> ({past.length})</div>
<div className="divide-y divide-slate-100 max-h-[40vh] overflow-y-auto">
@@ -0,0 +1,33 @@
// 푸시알림 발송이력 — 보낸 공지 전체 + 수신자 이름 조회.
// 각 행마다 recipient_user_ids 의 user_id 들을 user_info 와 조인해서 이름 배열로 반환.
import { NextResponse } from "next/server";
import { queryRows } from "@/lib/db";
import { requireMomoAdmin } from "@/lib/momo-guard";
import { ensureNoticesTable } from "@/lib/notices";
export async function POST() {
const g = await requireMomoAdmin();
if (g instanceof NextResponse) return g;
await ensureNoticesTable();
const rows = await queryRows(
`SELECT N.objid AS "OBJID", N.title AS "TITLE", N.body AS "BODY",
N.image_url AS "IMAGE_URL", N.url AS "URL",
TO_CHAR(N.regdate,'YYYY-MM-DD HH24:MI') AS "REGDATE",
N.regid AS "REGID",
COALESCE(N.sent_count,0) AS "SENT_COUNT",
COALESCE(N.failed_count,0) AS "FAILED_COUNT",
COALESCE(N.recipient_count,0) AS "RECIPIENT_COUNT",
COALESCE(N.recipient_user_ids, ARRAY[]::text[]) AS "RECIPIENT_USER_IDS",
COALESCE(N.group_names, ARRAY[]::text[]) AS "GROUP_NAMES",
COALESCE((
SELECT ARRAY_AGG(COALESCE(U.user_name, X.uid))
FROM UNNEST(COALESCE(N.recipient_user_ids, ARRAY[]::text[])) AS X(uid)
LEFT JOIN user_info U ON U.user_id = X.uid
), ARRAY[]::text[]) AS "RECIPIENT_NAMES"
FROM momo_notices N
WHERE COALESCE(N.is_del,'N') != 'Y'
ORDER BY N.regdate DESC
LIMIT 300`
);
return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length });
}
@@ -0,0 +1,22 @@
// 모든 사용자 목록 — 수신자 그룹 멤버 추가/제거 시 picker 용.
// 현재 푸시 구독 여부도 표시해서 admin 이 한눈에 알 수 있게 한다.
import { NextResponse } from "next/server";
import { queryRows } from "@/lib/db";
import { requireMomoAdmin } from "@/lib/momo-guard";
export async function POST() {
const g = await requireMomoAdmin();
if (g instanceof NextResponse) return g;
const rows = await queryRows(
`SELECT U.user_id AS "USER_ID",
COALESCE(U.user_name,'') AS "USER_NAME",
COALESCE(U.user_type,'') AS "USER_TYPE",
COALESCE(U.user_type,'')='A' AS "IS_ADMIN",
COALESCE(U.dept_name,'') AS "DEPT_NAME",
EXISTS (SELECT 1 FROM momo_push_subscriptions S WHERE S.user_id = U.user_id) AS "SUBSCRIBED"
FROM user_info U
WHERE COALESCE(U.status,'active') = 'active'
ORDER BY U.user_type DESC NULLS LAST, U.user_name NULLS LAST, U.user_id`
);
return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length });
}
@@ -0,0 +1,23 @@
// 수신자 그룹 목록 + 각 그룹의 멤버 수
import { NextResponse } from "next/server";
import { queryRows } from "@/lib/db";
import { requireMomoAdmin } from "@/lib/momo-guard";
import { ensureNoticesTable } from "@/lib/notices";
export async function POST() {
const g = await requireMomoAdmin();
if (g instanceof NextResponse) return g;
await ensureNoticesTable();
const rows = await queryRows(
`SELECT G.objid AS "OBJID", G.name AS "NAME", G.description AS "DESCRIPTION",
TO_CHAR(G.regdate,'YYYY-MM-DD') AS "REGDATE",
COALESCE((SELECT COUNT(*)::int FROM momo_recipient_group_members M
WHERE M.group_objid = G.objid), 0) AS "MEMBER_CNT",
COALESCE((SELECT ARRAY_AGG(M.user_id) FROM momo_recipient_group_members M
WHERE M.group_objid = G.objid), ARRAY[]::text[]) AS "MEMBER_IDS"
FROM momo_recipient_groups G
WHERE COALESCE(G.is_del,'N') != 'Y'
ORDER BY G.name`
);
return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length });
}
@@ -0,0 +1,24 @@
// 특정 수신자 그룹의 멤버 user_id 목록 + 사용자 정보
import { NextRequest, NextResponse } from "next/server";
import { queryRows } from "@/lib/db";
import { requireMomoAdmin } from "@/lib/momo-guard";
import { ensureNoticesTable } from "@/lib/notices";
export async function POST(req: NextRequest) {
const g = await requireMomoAdmin();
if (g instanceof NextResponse) return g;
await ensureNoticesTable();
const { groupObjid } = await req.json().catch(() => ({}));
if (!groupObjid) return NextResponse.json({ success: false, message: "groupObjid 필수" }, { status: 400 });
const rows = await queryRows(
`SELECT M.user_id AS "USER_ID",
COALESCE(U.user_name,'') AS "USER_NAME",
COALESCE(U.user_type,'') AS "USER_TYPE"
FROM momo_recipient_group_members M
LEFT JOIN user_info U ON U.user_id = M.user_id
WHERE M.group_objid = $1
ORDER BY U.user_name NULLS LAST, M.user_id`,
[groupObjid]
);
return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length });
}
@@ -0,0 +1,35 @@
// 수신자 그룹 멤버 일괄 교체 (전체 삭제 후 새로 INSERT)
import { NextRequest, NextResponse } from "next/server";
import { pool } from "@/lib/db";
import { requireMomoAdmin } from "@/lib/momo-guard";
import { ensureNoticesTable } from "@/lib/notices";
export async function POST(req: NextRequest) {
const g = await requireMomoAdmin();
if (g instanceof NextResponse) return g;
await ensureNoticesTable();
const { groupObjid, userIds } = await req.json().catch(() => ({}));
if (!groupObjid) return NextResponse.json({ success: false, message: "groupObjid 필수" }, { status: 400 });
const ids = Array.isArray(userIds) ? userIds.filter(Boolean) : [];
const client = await pool.connect();
try {
await client.query("BEGIN");
await client.query(`DELETE FROM momo_recipient_group_members WHERE group_objid = $1`, [groupObjid]);
if (ids.length > 0) {
const values = ids.map((_, i) => `($1, $${i + 2}, NOW())`).join(",");
await client.query(
`INSERT INTO momo_recipient_group_members (group_objid, user_id, added_at) VALUES ${values}`,
[groupObjid, ...ids]
);
}
await client.query("COMMIT");
return NextResponse.json({ success: true, count: ids.length });
} catch (err) {
await client.query("ROLLBACK");
console.error("[notices/groups/members/save]", err);
return NextResponse.json({ success: false, message: "저장 중 오류" }, { status: 500 });
} finally {
client.release();
}
}
@@ -0,0 +1,48 @@
// 수신자 그룹 생성/수정/삭제
// action="delete" 면 soft-delete + 멤버 row 모두 제거
// 그 외(생성/수정)는 name/description upsert
import { NextRequest, NextResponse } from "next/server";
import { execute, queryOne } from "@/lib/db";
import { createObjectId } from "@/lib/utils";
import { requireMomoAdmin } from "@/lib/momo-guard";
import { ensureNoticesTable } from "@/lib/notices";
export async function POST(req: NextRequest) {
const g = await requireMomoAdmin();
if (g instanceof NextResponse) return g;
await ensureNoticesTable();
const userId = g.user.objid || g.user.userId;
const body = await req.json().catch(() => ({}));
const { objid, name, description, action } = body as {
objid?: string; name?: string; description?: string; action?: "delete";
};
if (action === "delete") {
if (!objid) return NextResponse.json({ success: false, message: "objid 필수" }, { status: 400 });
await execute(`UPDATE momo_recipient_groups SET is_del='Y' WHERE objid=$1`, [objid]);
await execute(`DELETE FROM momo_recipient_group_members WHERE group_objid=$1`, [objid]);
return NextResponse.json({ success: true });
}
if (!name || !name.trim()) {
return NextResponse.json({ success: false, message: "그룹명은 필수입니다." }, { status: 400 });
}
if (objid) {
const cur = await queryOne(`SELECT 1 FROM momo_recipient_groups WHERE objid = $1`, [objid]);
if (!cur) return NextResponse.json({ success: false, message: "그룹을 찾을 수 없습니다." }, { status: 404 });
await execute(
`UPDATE momo_recipient_groups SET name=$2, description=$3 WHERE objid=$1`,
[objid, name.trim(), description ?? null]
);
return NextResponse.json({ success: true, objId: objid });
}
const newId = createObjectId();
await execute(
`INSERT INTO momo_recipient_groups (objid, name, description, regdate, regid)
VALUES ($1, $2, $3, NOW(), $4)`,
[newId, name.trim(), description ?? null, userId]
);
return NextResponse.json({ success: true, objId: newId });
}
+14 -4
View File
@@ -12,13 +12,14 @@ export async function POST(req: NextRequest) {
await ensureNoticesTable();
const body = await req.json().catch(() => ({}));
const { noticeObjid, title, message, url, userIds, sendAll } = body as {
const { noticeObjid, title, message, url, userIds, sendAll, groupNames } = body as {
noticeObjid?: string;
title?: string;
message?: string;
url?: string;
userIds?: string[];
sendAll?: boolean;
groupNames?: string[];
};
if (!title || !title.trim()) {
return NextResponse.json({ success: false, message: "제목은 필수입니다." }, { status: 400 });
@@ -37,11 +38,20 @@ export async function POST(req: NextRequest) {
sendAll ? undefined : targets
);
// 발송 통계 누적 — 공지가 있으면 sent_count 업데이트
// 발송 통계 + 수신자 이력 기록 (공지가 있으면)
if (noticeObjid) {
await execute(
`UPDATE momo_notices SET sent_count = COALESCE(sent_count,0) + $2 WHERE objid = $1`,
[noticeObjid, res.sent]
`UPDATE momo_notices
SET sent_count = COALESCE(sent_count,0) + $2,
failed_count = COALESCE(failed_count,0) + $3,
recipient_user_ids = $4::text[],
recipient_count = $5,
group_names = $6::text[]
WHERE objid = $1`,
[noticeObjid, res.sent, res.failed,
sendAll ? [] : targets,
sendAll ? -1 : targets.length,
Array.isArray(groupNames) ? groupNames : []]
).catch(() => {});
}
+19
View File
@@ -18,6 +18,25 @@ export async function ensureNoticesTable() {
sent_count INTEGER DEFAULT 0,
is_del CHAR(1) DEFAULT 'N'
);
CREATE TABLE IF NOT EXISTS momo_recipient_groups (
objid TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
regdate TIMESTAMP DEFAULT NOW(),
regid TEXT,
is_del CHAR(1) DEFAULT 'N'
);
CREATE TABLE IF NOT EXISTS momo_recipient_group_members (
group_objid TEXT NOT NULL,
user_id TEXT NOT NULL,
added_at TIMESTAMP DEFAULT NOW(),
PRIMARY KEY (group_objid, user_id)
);
-- 발송이력용 컬럼 (구버전 호환을 위해 IF NOT EXISTS)
ALTER TABLE momo_notices ADD COLUMN IF NOT EXISTS recipient_user_ids TEXT[];
ALTER TABLE momo_notices ADD COLUMN IF NOT EXISTS recipient_count INTEGER DEFAULT 0;
ALTER TABLE momo_notices ADD COLUMN IF NOT EXISTS failed_count INTEGER DEFAULT 0;
ALTER TABLE momo_notices ADD COLUMN IF NOT EXISTS group_names TEXT[];
`);
ensured = true;
} catch (err) {