수신자 그룹: - 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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,24 +237,130 @@ 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>
|
||||
<p className="text-xs text-slate-500 mt-0.5">왼쪽에서 <b>수신자 그룹</b>을 선택(또는 개별 선택)하고, 오른쪽에 제목·본문·이미지를 작성한 뒤 [발송]을 누르세요.</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<button onClick={() => openEditGroup(g)} title="멤버 관리" className="shrink-0 text-slate-400 hover:text-emerald-700 p-1">
|
||||
<Pencil size={13} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</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">
|
||||
{/* 그룹 편집 패널 (권한그룹 스타일 — 아래쪽 전개) */}
|
||||
{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}명 / 선택 {selected.size})</span>
|
||||
<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">
|
||||
{filtered.length > 0 && filtered.every((u) => selected.has(u.USER_ID)) ? "전체 해제" : "표시된 전체 선택"}
|
||||
{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">
|
||||
@@ -162,31 +373,30 @@ export default function AdminNoticesPage() {
|
||||
<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");
|
||||
<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-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" />
|
||||
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="text-sm font-bold truncate flex items-center gap-1.5">
|
||||
<div className="font-bold truncate">
|
||||
{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>}
|
||||
{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} · 마지막 갱신 {u.LAST_SEEN} {u.DEVICE_CNT > 1 && `(${u.DEVICE_CNT}대)`}</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 });
|
||||
}
|
||||
@@ -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(() => {});
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user