feat(notices): 푸시알림 게시판 — 수신자 선택 + 작성 + 발송
Deploy momo-erp / deploy (push) Successful in 1m57s

관리자가 공지(제목·본문·이미지+선택적 외부링크)를 작성하고 푸시 구독자 중
원하는 사람에게 발송. 사용자가 알림 탭하면 자체 공지 페이지(/m/notices/[id])
또는 지정 URL 로 이동.

- lib/notices: momo_notices 테이블 자동 생성.
- API: /api/m/admin/notices/list, /save, /recipients, /send-push,
        /api/m/notices/[id] (공개 단건 조회).
- Admin UI(/m/admin/notices): 좌 수신자 다중선택+검색+거래처/관리자 필터,
  우 제목/본문/이미지 업로드/외부링크. [N명에게 발송] 한 번으로 공지 저장+푸시.
- 공개 페이지(/m/notices/[id]): 이미지+제목+본문 렌더.
- 이미지 업로드는 기존 /api/m/items/upload-image 재사용.
- 사이드바: 마스터 관리 > 푸시알림 게시판 (menu_info 9000299) 신규 등록.
This commit is contained in:
chpark
2026-05-29 11:04:55 +09:00
parent d0c602dda3
commit cbea0f4b9f
8 changed files with 524 additions and 0 deletions
+264
View File
@@ -0,0 +1,264 @@
"use client";
import { useEffect, useState, useCallback, useMemo } from "react";
import Swal from "sweetalert2";
import { Send, Upload, X, Bell } from "lucide-react";
interface Recipient {
USER_ID: string;
USER_NAME: string;
USER_TYPE: string;
IS_ADMIN: boolean;
DEPT_NAME: string;
USER_AGENT: string;
LAST_SEEN: string;
DEVICE_CNT: number;
}
interface Notice {
OBJID: string; TITLE: string; BODY: string | null;
IMAGE_URL: string | null; URL: string | null;
REGDATE: string; SENT_COUNT: number;
}
export default function AdminNoticesPage() {
const [recipients, setRecipients] = useState<Recipient[]>([]);
const [selected, setSelected] = 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("");
const [linkUrl, setLinkUrl] = useState("");
const [uploading, setUploading] = useState(false);
const [sending, setSending] = useState(false);
const [past, setPast] = useState<Notice[]>([]);
const loadRecipients = useCallback(async () => {
const r = await fetch("/api/m/admin/notices/recipients", { method: "POST" });
setRecipients((await r.json()).RESULTLIST ?? []);
}, []);
const loadPast = useCallback(async () => {
const r = await fetch("/api/m/admin/notices/list", { method: "POST" });
setPast((await r.json()).RESULTLIST ?? []);
}, []);
useEffect(() => { loadRecipients(); loadPast(); }, [loadRecipients, loadPast]);
const filtered = useMemo(() => {
return recipients.filter((u) => {
if (typeFilter && (typeFilter === "A" ? !u.IS_ADMIN : u.IS_ADMIN)) return false;
if (keyword) {
const k = keyword.toLowerCase();
if (!u.USER_NAME?.toLowerCase().includes(k) && !u.USER_ID?.toLowerCase().includes(k)) return false;
}
return true;
});
}, [recipients, typeFilter, keyword]);
const toggle = (id: string) => {
setSelected((p) => {
const n = new Set(p);
if (n.has(id)) n.delete(id); else n.add(id);
return n;
});
};
const toggleAllFiltered = () => {
const allOn = filtered.length > 0 && filtered.every((u) => selected.has(u.USER_ID));
setSelected((p) => {
const n = new Set(p);
if (allOn) filtered.forEach((u) => n.delete(u.USER_ID));
else filtered.forEach((u) => n.add(u.USER_ID));
return n;
});
};
const onUpload = async (file: File) => {
setUploading(true);
try {
const fd = new FormData();
fd.append("file", file);
const r = await fetch("/api/m/items/upload-image", { method: "POST", body: fd });
const j = await r.json();
if (j.success) setImageUrl(j.url);
else Swal.fire({ icon: "error", title: "업로드 실패", text: j.message });
} finally { setUploading(false); }
};
const send = async () => {
if (!title.trim()) {
return Swal.fire({ icon: "warning", title: "제목을 입력하세요" });
}
if (selected.size === 0) {
return Swal.fire({ icon: "warning", title: "수신자를 선택하세요" });
}
const ok = await Swal.fire({
icon: "question",
title: `${selected.size}명에게 푸시 발송`,
text: `제목: ${title}`,
showCancelButton: true, confirmButtonText: "발송", cancelButtonText: "취소",
confirmButtonColor: "#0f766e",
});
if (!ok.isConfirmed) return;
setSending(true);
try {
// 1) 공지 저장
const sv = await fetch("/api/m/admin/notices/save", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, body: bodyText, imageUrl, url: linkUrl }),
});
const svj = await sv.json();
if (!svj.success) { Swal.fire({ icon: "error", title: "저장 실패", text: svj.message }); return; }
const noticeObjid = svj.objId;
// 2) 푸시 발송 (URL = 외부 링크 우선, 없으면 자체 공지 페이지)
const send = await fetch("/api/m/admin/notices/send-push", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({
noticeObjid, title, message: bodyText,
url: linkUrl || `/m/notices/${noticeObjid}`,
userIds: Array.from(selected),
}),
});
const sj = await send.json();
if (sj.success) {
await Swal.fire({ icon: "success", title: "발송 완료", text: `성공 ${sj.sent} · 실패 ${sj.failed}`, timer: 1800, showConfirmButton: false });
setTitle(""); setBodyText(""); setImageUrl(""); setLinkUrl("");
setSelected(new Set());
loadPast();
} else {
Swal.fire({ icon: "error", title: "발송 실패", text: sj.message });
}
} finally { setSending(false); }
};
return (
<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>
<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>
<div className="text-[10px] text-slate-500 truncate">{u.USER_ID} · {u.LAST_SEEN} {u.DEVICE_CNT > 1 && `(${u.DEVICE_CNT}대)`}</div>
</div>
</label>
);
})}
</div>
</div>
{/* 우: 작성 */}
<div className="space-y-3">
<div className="bg-white border border-slate-200 rounded-xl p-4 space-y-3">
<div>
<label className="text-xs font-semibold text-slate-600"> *</label>
<input value={title} onChange={(e) => setTitle(e.target.value)}
placeholder="예: 오늘의 특가" maxLength={60}
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"> (60 )</div>
</div>
<div>
<label className="text-xs font-semibold text-slate-600"></label>
<textarea value={bodyText} onChange={(e) => setBodyText(e.target.value)}
placeholder="알림 내용 + 공지 페이지 본문이 됩니다." rows={6}
className="w-full px-3 py-2 rounded-lg border border-slate-200 text-sm mt-1 resize-y" />
<div className="text-[10px] text-slate-400 mt-0.5"> 240 . .</div>
</div>
<div>
<label className="text-xs font-semibold text-slate-600"> ()</label>
<div className="flex items-center gap-2 mt-1">
<label className="inline-flex items-center gap-1 h-9 px-3 rounded-lg border border-slate-300 bg-white text-xs font-semibold cursor-pointer hover:bg-slate-50">
<Upload size={14} />
<input type="file" accept="image/*" className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) onUpload(f); }} />
</label>
{imageUrl && (
<button type="button" onClick={() => setImageUrl("")} className="text-rose-500 hover:text-rose-700" title="이미지 제거"><X size={14} /></button>
)}
{uploading && <span className="text-xs text-slate-400"> </span>}
</div>
{imageUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img src={imageUrl} alt="첨부" className="mt-2 max-h-64 rounded border border-slate-200" />
)}
</div>
<div>
<label className="text-xs font-semibold text-slate-600"> ()</label>
<input value={linkUrl} onChange={(e) => setLinkUrl(e.target.value)}
placeholder="https://… (입력 시 푸시 탭하면 해당 URL 로 이동)"
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">
<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}명에게 발송`}
</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">
{past.length === 0 ? (
<div className="text-center py-8 text-slate-400 text-sm"> .</div>
) : past.map((n) => (
<a key={n.OBJID} href={`/m/notices/${n.OBJID}`} target="_blank" rel="noreferrer"
className="flex items-center gap-3 px-3 py-2 hover:bg-slate-50">
{n.IMAGE_URL && (
// eslint-disable-next-line @next/next/no-img-element
<img src={n.IMAGE_URL} alt="" className="w-12 h-12 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">{n.REGDATE} · {n.SENT_COUNT ?? 0}</div>
</div>
</a>
))}
</div>
</div>
</div>
</div>
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
"use client";
import { useEffect, useState, use } from "react";
import { ArrowLeft } from "lucide-react";
import { useRouter } from "next/navigation";
interface Notice {
OBJID: string; TITLE: string; BODY: string | null;
IMAGE_URL: string | null; URL: string | null; REGDATE: string;
}
export default function NoticeViewPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
const router = useRouter();
const [notice, setNotice] = useState<Notice | null>(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState("");
useEffect(() => {
fetch(`/api/m/notices/${id}`)
.then((r) => r.json())
.then((j) => {
if (j.success) setNotice(j.notice);
else setErr(j.message || "공지를 찾을 수 없습니다.");
})
.catch(() => setErr("불러오기 실패"))
.finally(() => setLoading(false));
}, [id]);
const back = () => {
if (typeof window !== "undefined" && window.history.length > 1) router.back();
else router.push("/m/orders/new");
};
if (loading) return <div className="text-center py-12 text-slate-400"> </div>;
if (err || !notice) return (
<div className="text-center py-12">
<div className="text-slate-400 mb-3">{err || "공지를 찾을 수 없습니다."}</div>
<button onClick={back} className="inline-flex items-center gap-1 h-9 px-3 rounded-lg border border-slate-300 bg-white text-sm font-semibold">
<ArrowLeft size={14} />
</button>
</div>
);
return (
<div className="max-w-2xl mx-auto space-y-4">
<button onClick={back} className="inline-flex items-center gap-1 text-xs text-slate-500 hover:text-emerald-700">
<ArrowLeft size={14} />
</button>
<article className="bg-white border border-slate-200 rounded-xl overflow-hidden">
{notice.IMAGE_URL && (
// eslint-disable-next-line @next/next/no-img-element
<img src={notice.IMAGE_URL} alt={notice.TITLE} className="w-full object-contain bg-slate-50" />
)}
<div className="p-4 sm:p-6 space-y-3">
<h1 className="text-xl sm:text-2xl font-bold text-slate-900">{notice.TITLE}</h1>
<div className="text-[11px] text-slate-400">{notice.REGDATE}</div>
{notice.BODY && (
<div className="text-sm sm:text-base text-slate-700 whitespace-pre-wrap leading-relaxed pt-2 border-t border-slate-100">
{notice.BODY}
</div>
)}
{notice.URL && (
<a href={notice.URL} target="_blank" rel="noreferrer"
className="inline-flex items-center gap-1 text-sm font-bold text-emerald-700 hover:underline pt-2 border-t border-slate-100">
</a>
)}
</div>
</article>
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
// 푸시 공지 목록 (admin) — 최근 작성한 공지
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 objid AS "OBJID", title AS "TITLE", body AS "BODY",
image_url AS "IMAGE_URL", url AS "URL",
TO_CHAR(regdate, 'YYYY-MM-DD HH24:MI') AS "REGDATE",
regid AS "REGID", sent_count AS "SENT_COUNT"
FROM momo_notices
WHERE COALESCE(is_del,'N') != 'Y'
ORDER BY regdate DESC
LIMIT 100`
);
return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length });
}
@@ -0,0 +1,27 @@
// 푸시 공지 수신자 후보 — 현재 푸시 구독한 사용자 목록.
// 구독이 없는 사용자에게는 보내봐야 도착하지 않으므로 구독자만 보여준다.
import { NextResponse } from "next/server";
import { queryRows } from "@/lib/db";
import { requireMomoAdmin } from "@/lib/momo-guard";
import { ensurePushTable } from "@/lib/push";
export async function POST() {
const g = await requireMomoAdmin();
if (g instanceof NextResponse) return g;
await ensurePushTable();
const rows = await queryRows(
`SELECT s.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",
MAX(s.user_agent) AS "USER_AGENT",
TO_CHAR(MAX(s.last_seen),'YYYY-MM-DD HH24:MI') AS "LAST_SEEN",
COUNT(*)::int AS "DEVICE_CNT"
FROM momo_push_subscriptions s
LEFT JOIN user_info u ON u.user_id = s.user_id
GROUP BY s.user_id, u.user_name, u.user_type, u.dept_name
ORDER BY u.user_name NULLS LAST, s.user_id`
);
return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length });
}
+39
View File
@@ -0,0 +1,39 @@
// 푸시 공지 저장 (admin) — 신규/수정 모두 처리. 발송은 별도 send-push 라우트에서.
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, title, body: text, imageUrl, url } = body as {
objid?: string; title?: string; body?: string; imageUrl?: string; url?: string;
};
if (!title || !title.trim()) {
return NextResponse.json({ success: false, message: "제목은 필수입니다." }, { status: 400 });
}
if (objid) {
const cur = await queryOne(`SELECT 1 FROM momo_notices WHERE objid = $1`, [objid]);
if (!cur) return NextResponse.json({ success: false, message: "공지를 찾을 수 없습니다." }, { status: 404 });
await execute(
`UPDATE momo_notices SET title=$2, body=$3, image_url=$4, url=$5 WHERE objid=$1`,
[objid, title.trim(), text ?? null, imageUrl || null, url || null]
);
return NextResponse.json({ success: true, objId: objid });
}
const newId = createObjectId();
await execute(
`INSERT INTO momo_notices (objid, title, body, image_url, url, regdate, regid)
VALUES ($1, $2, $3, $4, $5, NOW(), $6)`,
[newId, title.trim(), text ?? null, imageUrl || null, url || null, userId]
);
return NextResponse.json({ success: true, objId: newId });
}
@@ -0,0 +1,50 @@
// 공지 푸시 발송 — 선택한 사용자(userIds) 에게 푸시. URL 은 자체 공지 페이지(/m/notices/[id]).
import { NextRequest, NextResponse } from "next/server";
import { execute } from "@/lib/db";
import { requireMomoAdmin } from "@/lib/momo-guard";
import { sendPush, ensurePushTable } from "@/lib/push";
import { ensureNoticesTable } from "@/lib/notices";
export async function POST(req: NextRequest) {
const g = await requireMomoAdmin();
if (g instanceof NextResponse) return g;
await ensurePushTable();
await ensureNoticesTable();
const body = await req.json().catch(() => ({}));
const { noticeObjid, title, message, url, userIds, sendAll } = body as {
noticeObjid?: string;
title?: string;
message?: string;
url?: string;
userIds?: string[];
sendAll?: boolean;
};
if (!title || !title.trim()) {
return NextResponse.json({ success: false, message: "제목은 필수입니다." }, { status: 400 });
}
const targets = Array.isArray(userIds) ? userIds.filter(Boolean) : [];
if (!sendAll && targets.length === 0) {
return NextResponse.json({ success: false, message: "수신자를 선택하세요." }, { status: 400 });
}
const targetUrl = url && url.trim() !== "" ? url
: noticeObjid ? `/m/notices/${noticeObjid}`
: "/m/orders/new";
const res = await sendPush(
{ title: title.trim(), body: (message ?? "").slice(0, 240), url: targetUrl, tag: noticeObjid ? `notice-${noticeObjid}` : undefined },
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]
).catch(() => {});
}
console.log(`[notices/send-push] notice=${noticeObjid ?? "-"} title="${title}" targets=${sendAll ? "ALL" : targets.length} sent=${res.sent} failed=${res.failed}`);
return NextResponse.json({ success: true, sent: res.sent, failed: res.failed });
}
+22
View File
@@ -0,0 +1,22 @@
// 푸시 공지 단건 조회 — 로그인한 모든 사용자 가능 (푸시 탭하면 열리는 페이지가 호출).
import { NextRequest, NextResponse } from "next/server";
import { queryOne } from "@/lib/db";
import { requireMomoUser } from "@/lib/momo-guard";
import { ensureNoticesTable } from "@/lib/notices";
export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const r = await requireMomoUser();
if (r instanceof NextResponse) return r;
await ensureNoticesTable();
const { id } = await ctx.params;
const row = await queryOne(
`SELECT objid AS "OBJID", title AS "TITLE", body AS "BODY",
image_url AS "IMAGE_URL", url AS "URL",
TO_CHAR(regdate, 'YYYY-MM-DD HH24:MI') AS "REGDATE"
FROM momo_notices
WHERE objid = $1 AND COALESCE(is_del,'N') != 'Y'`,
[id]
);
if (!row) return NextResponse.json({ success: false, message: "공지를 찾을 수 없습니다." }, { status: 404 });
return NextResponse.json({ success: true, notice: row });
}
+26
View File
@@ -0,0 +1,26 @@
// 푸시 공지 게시판 — 관리자가 작성한 공지(이미지+본문)를 푸시로 발송하고,
// 사용자는 푸시를 탭하면 /m/notices/[id] 페이지에서 본문/이미지를 확인할 수 있다.
import { pool } from "./db";
let ensured = false;
export async function ensureNoticesTable() {
if (ensured) return;
try {
await pool.query(`
CREATE TABLE IF NOT EXISTS momo_notices (
objid TEXT PRIMARY KEY,
title TEXT NOT NULL,
body TEXT,
image_url TEXT,
url TEXT, -- 외부 링크가 있으면 사용 (없으면 자체 /m/notices/[id])
regdate TIMESTAMP DEFAULT NOW(),
regid TEXT,
sent_count INTEGER DEFAULT 0,
is_del CHAR(1) DEFAULT 'N'
);
`);
ensured = true;
} catch (err) {
console.error("[notices/ensure]", err);
}
}