Files
distribution_erp/src/app/(main)/m/admin/notices/page.tsx
T
chpark b11d0df704 feat(notices): 본문 textarea → Tiptap 리치 에디터 + 공지 페이지 HTML 렌더
푸시알림 게시판:
- 본문 입력을 Tiptap 위지위그로 교체
- 이미지 복붙(Ctrl+V) / 드래그앤드롭 → 자동 업로드 → img 태그 삽입
- 푸시 메시지는 HTML 태그 제거 후 plain text 로 전송

공지 상세(/m/notices/[id]):
- BODY 를 dangerouslySetInnerHTML 로 HTML 렌더링
- 관리자만 작성 가능(requireMomoAdmin) 이라 XSS 위험 낮음

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 00:34:33 +09:00

457 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
// 푸시알림 게시판 — 권한 관리 화면(admin-panel/AuthManagement)과 동일한 3-패널 패턴.
// 좌측 : 수신자 그룹 목록 [+ 생성]
// 우측 상단 : 그룹 멤버 / [추가/제거] / 전체 사용자 풀 ← 권한있는/권한없는 직원
// 우측 하단 : 컨텐츠(제목/리치 본문/외부링크) + 발송
// - 본문은 Tiptap 리치 에디터 (이미지 복붙/드래그 자동 업로드, 볼드/리스트/링크 등)
// 발송이력은 별도 메뉴(/m/admin/notice-history)로 분리됨 — 본 화면에서 제거.
import { useEffect, useState, useCallback, useMemo } from "react";
import Swal from "sweetalert2";
import { Send, X, Bell, Users, Shield, Upload } from "lucide-react";
import { RichEditor } from "@/components/rich-editor";
interface Group {
OBJID: string; NAME: string; DESCRIPTION: string | null;
REGDATE: string; MEMBER_CNT: number; MEMBER_IDS: string[];
}
interface AllUser {
USER_ID: string;
USER_NAME: string;
USER_TYPE: string;
IS_ADMIN: boolean;
DEPT_NAME: string;
SUBSCRIBED: boolean;
}
export default function AdminNoticesPage() {
// ===== 좌측: 그룹 목록 =====
const [groups, setGroups] = useState<Group[]>([]);
const [groupQuery, setGroupQuery] = useState("");
const [activeGroup, setActiveGroup] = useState<Group | null>(null);
// ===== 우측 상단: 멤버 양쪽 패널 =====
const [allUsers, setAllUsers] = useState<AllUser[]>([]);
const [memberQ, setMemberQ] = useState("");
const [availQ, setAvailQ] = useState("");
const [chkMember, setChkMember] = useState<Set<string>>(new Set());
const [chkAvail, setChkAvail] = useState<Set<string>>(new Set());
// ===== 우측 하단: 작성 폼 =====
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 [sendAll, setSendAll] = useState(false);
const loadGroups = useCallback(async () => {
const r = await fetch("/api/m/admin/notices/groups/list", { method: "POST" });
const j = await r.json();
const list = (j.RESULTLIST ?? []) as Group[];
setGroups(list);
// 활성 그룹이 갱신되도록 sync
setActiveGroup((cur) => (cur ? (list.find((g) => g.OBJID === cur.OBJID) ?? null) : null));
}, []);
const loadAllUsers = useCallback(async () => {
const r = await fetch("/api/m/admin/notices/all-users", { method: "POST" });
setAllUsers((await r.json()).RESULTLIST ?? []);
}, []);
useEffect(() => { loadGroups(); loadAllUsers(); }, [loadGroups, loadAllUsers]);
// 활성 그룹이 바뀌면 체크박스 초기화
useEffect(() => { setChkMember(new Set()); setChkAvail(new Set()); }, [activeGroup?.OBJID]);
const filteredGroups = useMemo(() => groups.filter((g) =>
!groupQuery || g.NAME.toLowerCase().includes(groupQuery.toLowerCase()) || (g.DESCRIPTION ?? "").toLowerCase().includes(groupQuery.toLowerCase())
), [groups, groupQuery]);
// 활성 그룹 멤버 / 풀
const memberSet = useMemo(() => new Set(activeGroup?.MEMBER_IDS ?? []), [activeGroup]);
const members = useMemo(() => allUsers.filter((u) => memberSet.has(u.USER_ID)), [allUsers, memberSet]);
const available = useMemo(() => allUsers.filter((u) => !memberSet.has(u.USER_ID)), [allUsers, memberSet]);
const filteredMembers = useMemo(() => members.filter((u) => !memberQ || u.USER_NAME.includes(memberQ) || u.USER_ID.includes(memberQ) || (u.DEPT_NAME ?? "").includes(memberQ)), [members, memberQ]);
const filteredAvail = useMemo(() => available.filter((u) => !availQ || u.USER_NAME.includes(availQ) || u.USER_ID.includes(availQ) || (u.DEPT_NAME ?? "").includes(availQ)), [available, availQ]);
// ===== 그룹 생성/수정/삭제 (SweetAlert) =====
const onCreate = async () => {
const r = await Swal.fire({
title: "수신자 그룹 생성",
html: `
<input id="sw_name" class="swal2-input" placeholder="그룹명 (예: 본사 거래처)">
<input id="sw_desc" class="swal2-input" placeholder="설명 (선택)">
`,
showCancelButton: true, confirmButtonText: "생성", confirmButtonColor: "#0f766e",
preConfirm: () => ({
name: (document.getElementById("sw_name") as HTMLInputElement).value.trim(),
description: (document.getElementById("sw_desc") as HTMLInputElement).value.trim(),
}),
});
if (!r.isConfirmed || !r.value?.name) return;
const sv = await fetch("/api/m/admin/notices/groups/save", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: r.value.name, description: r.value.description }),
});
const sj = await sv.json();
if (!sj.success) { Swal.fire({ icon: "error", title: "생성 실패", text: sj.message }); return; }
await loadGroups();
// 새로 만든 그룹을 활성화
setActiveGroup({ OBJID: sj.objId, NAME: r.value.name, DESCRIPTION: r.value.description || null, REGDATE: "", MEMBER_CNT: 0, MEMBER_IDS: [] });
};
const onRename = async (g: Group) => {
const r = await Swal.fire({
title: "그룹 수정", icon: "info",
html: `
<input id="sw_name" class="swal2-input" value="${g.NAME.replace(/"/g, "&quot;")}">
<input id="sw_desc" class="swal2-input" placeholder="설명 (선택)" value="${(g.DESCRIPTION ?? "").replace(/"/g, "&quot;")}">
`,
showCancelButton: true, showDenyButton: true, denyButtonText: "삭제", confirmButtonText: "저장", confirmButtonColor: "#0f766e",
preConfirm: () => ({
name: (document.getElementById("sw_name") as HTMLInputElement).value.trim(),
description: (document.getElementById("sw_desc") as HTMLInputElement).value.trim(),
}),
});
if (r.isDenied) {
const ok = await Swal.fire({ icon: "warning", title: `"${g.NAME}" 삭제?`, showCancelButton: true, confirmButtonColor: "#dc2626", confirmButtonText: "삭제" });
if (!ok.isConfirmed) return;
const res = await fetch("/api/m/admin/notices/groups/save", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ objid: g.OBJID, action: "delete" }),
});
if ((await res.json()).success) {
if (activeGroup?.OBJID === g.OBJID) setActiveGroup(null);
loadGroups();
}
return;
}
if (!r.isConfirmed || !r.value?.name) return;
const res = await fetch("/api/m/admin/notices/groups/save", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ objid: g.OBJID, name: r.value.name, description: r.value.description }),
});
if ((await res.json()).success) loadGroups();
};
// ===== 멤버 추가/제거 =====
const saveMembers = async (newIds: string[]) => {
if (!activeGroup) return false;
const r = await fetch("/api/m/admin/notices/groups/members/save", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ groupObjid: activeGroup.OBJID, userIds: newIds }),
});
const j = await r.json();
if (!j.success) { Swal.fire({ icon: "error", title: "저장 실패", text: j.message }); return false; }
return true;
};
const addSelected = async () => {
if (!activeGroup) return Swal.fire({ icon: "warning", title: "그룹을 선택하세요" });
if (chkAvail.size === 0) return Swal.fire({ icon: "warning", title: "추가할 사용자를 선택하세요" });
const newIds = Array.from(new Set([...(activeGroup.MEMBER_IDS ?? []), ...Array.from(chkAvail)]));
if (await saveMembers(newIds)) { setChkAvail(new Set()); loadGroups(); }
};
const removeSelected = async () => {
if (!activeGroup) return Swal.fire({ icon: "warning", title: "그룹을 선택하세요" });
if (chkMember.size === 0) return Swal.fire({ icon: "warning", title: "제거할 사용자를 선택하세요" });
const newIds = (activeGroup.MEMBER_IDS ?? []).filter((id) => !chkMember.has(id));
if (await saveMembers(newIds)) { setChkMember(new Set()); loadGroups(); }
};
// ===== 이미지 업로드 =====
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 targetUserIds = useMemo(() => sendAll ? [] : (activeGroup?.MEMBER_IDS ?? []), [sendAll, activeGroup]);
const targetCount = sendAll ? -1 : targetUserIds.length;
const send = async () => {
if (!title.trim()) return Swal.fire({ icon: "warning", title: "제목을 입력하세요" });
if (!sendAll && targetUserIds.length === 0) return Swal.fire({ icon: "warning", title: "그룹(또는 멤버)을 선택하거나 [전체 구독자] 옵션을 켜세요" });
const targetLabel = sendAll ? "전체 구독자" : `${activeGroup?.NAME} (${targetUserIds.length}명)`;
const ok = await Swal.fire({
icon: "question", title: `${targetLabel} 에게 발송`, text: `제목: ${title}`,
showCancelButton: true, confirmButtonText: "발송", cancelButtonText: "취소", confirmButtonColor: "#0f766e",
});
if (!ok.isConfirmed) return;
setSending(true);
try {
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;
const groupNames = activeGroup && !sendAll ? [activeGroup.NAME] : [];
// 푸시 본문은 plain text — HTML 태그 제거 + 공백 압축
const plainBody = bodyText.replace(/<[^>]+>/g, " ").replace(/&nbsp;/g, " ").replace(/\s+/g, " ").trim();
const sendRes = await fetch("/api/m/admin/notices/send-push", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({
noticeObjid, title, message: plainBody,
url: linkUrl || `/m/notices/${noticeObjid}`,
userIds: sendAll ? undefined : targetUserIds,
sendAll,
groupNames,
}),
});
const sj = await sendRes.json();
if (sj.success) {
await Swal.fire({ icon: "success", title: "발송 완료", text: `성공 ${sj.sent} · 실패 ${sj.failed}`, timer: 1800, showConfirmButton: false });
setTitle(""); setBodyText(""); setImageUrl(""); setLinkUrl(""); setSendAll(false);
} else {
Swal.fire({ icon: "error", title: "발송 실패", text: sj.message });
}
} finally { setSending(false); }
};
return (
<div className="space-y-3">
<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> , / , [] . <b> </b> .</p>
</div>
{/* ===== 상단: 좌측 그룹 목록 + 우측 멤버 양쪽 패널 ===== */}
<div className="grid grid-cols-1 lg:grid-cols-[260px_1fr] gap-3">
{/* 좌측: 수신자 그룹 목록 */}
<div className="bg-white border border-slate-200 rounded-xl flex flex-col">
<div className="px-3 py-2 border-b border-slate-100 flex items-center justify-between">
<div className="text-sm font-bold text-slate-700 inline-flex items-center gap-1.5">
<Shield size={14} className="text-emerald-700" /> ({groups.length})
</div>
<button onClick={onCreate} className="inline-flex items-center gap-1 h-7 px-2.5 rounded bg-emerald-600 text-white text-[11px] font-bold hover:bg-emerald-700">
<span>+ </span>
</button>
</div>
<div className="px-3 py-2 border-b border-slate-100">
<input value={groupQuery} onChange={(e) => setGroupQuery(e.target.value)}
placeholder="검색..." className="w-full h-8 px-2 text-xs border border-slate-200 rounded" />
</div>
<div className="flex-1 overflow-auto max-h-[40vh] lg:max-h-[calc(100vh-540px)] divide-y divide-slate-100">
{filteredGroups.length === 0 ? (
<div className="p-6 text-center text-xs text-slate-400"> .</div>
) : filteredGroups.map((g) => (
<button
key={g.OBJID}
onClick={() => setActiveGroup(g)}
onDoubleClick={() => onRename(g)}
className={`w-full text-left px-3 py-2 hover:bg-slate-50 transition-colors ${
activeGroup?.OBJID === g.OBJID ? "bg-emerald-50/70 border-l-4 border-l-emerald-600" : ""
}`}
title="더블클릭: 수정/삭제"
>
<div className="text-sm font-bold text-slate-800 truncate">{g.NAME}</div>
<div className="text-[10px] text-slate-500 truncate">
{g.MEMBER_CNT}{g.DESCRIPTION ? ` · ${g.DESCRIPTION}` : ""}
</div>
</button>
))}
</div>
</div>
{/* 우측: 멤버 양쪽 패널 */}
<div className="grid grid-cols-1 lg:grid-cols-[1fr_auto_1fr] gap-3">
{/* 그룹 멤버 (= 권한있는 직원) */}
<div className="bg-white border border-slate-200 rounded-xl flex flex-col">
<div className="px-3 py-2 border-b border-slate-100 text-sm font-bold text-slate-700 inline-flex items-center gap-1.5">
<Users size={14} className="text-emerald-700" /> ({members.length})
</div>
<div className="p-2 flex items-center gap-2 border-b border-slate-100">
<label className="text-xs inline-flex items-center gap-1.5">
<input type="checkbox"
checked={filteredMembers.length > 0 && filteredMembers.every((u) => chkMember.has(u.USER_ID))}
onChange={(e) => setChkMember(e.target.checked ? new Set(filteredMembers.map((u) => u.USER_ID)) : new Set())}
className="w-4 h-4 accent-emerald-600" />
</label>
<input value={memberQ} onChange={(e) => setMemberQ(e.target.value)}
placeholder="검색" className="ml-auto h-7 w-40 px-2 text-xs border border-slate-200 rounded" />
</div>
<div className="flex-1 overflow-auto max-h-[40vh]">
<table className="w-full text-xs">
<thead className="bg-slate-50 sticky top-0">
<tr><th className="w-8 p-1.5"></th><th className="text-left p-1.5"></th><th className="text-left p-1.5"></th><th className="text-left p-1.5">ID</th></tr>
</thead>
<tbody>
{filteredMembers.map((u) => (
<tr key={u.USER_ID} className="border-t hover:bg-slate-50">
<td className="text-center p-1.5">
<input type="checkbox" checked={chkMember.has(u.USER_ID)}
onChange={(e) => { const s = new Set(chkMember); if (e.target.checked) s.add(u.USER_ID); else s.delete(u.USER_ID); setChkMember(s); }}
className="w-4 h-4 accent-emerald-600" />
</td>
<td className="p-1.5">{u.DEPT_NAME || "-"}</td>
<td className="p-1.5 font-semibold">
{u.USER_NAME}
{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>}
</td>
<td className="p-1.5 text-slate-400 font-mono">{u.USER_ID}</td>
</tr>
))}
{filteredMembers.length === 0 && (
<tr><td colSpan={4} className="p-6 text-center text-slate-400">
{activeGroup ? "멤버가 없습니다. 오른쪽 풀에서 추가하세요." : "왼쪽에서 수신자 그룹을 선택하세요"}
</td></tr>
)}
</tbody>
</table>
</div>
</div>
{/* 추가/제거 버튼 */}
<div className="flex flex-row lg:flex-col items-center justify-center gap-3 px-2 lg:px-4 min-w-[120px]">
<button type="button" onClick={addSelected} disabled={!activeGroup}
className="h-12 w-full lg:w-32 rounded-lg bg-emerald-600 text-white text-sm font-bold hover:bg-emerald-700 active:bg-emerald-800 disabled:opacity-40 disabled:cursor-not-allowed shadow-sm transition-colors">
</button>
<button type="button" onClick={removeSelected} disabled={!activeGroup}
className="h-12 w-full lg:w-32 rounded-lg border border-slate-300 bg-white text-slate-700 text-sm font-bold hover:bg-rose-50 hover:border-rose-300 hover:text-rose-700 active:bg-rose-100 disabled:opacity-40 disabled:cursor-not-allowed transition-colors">
</button>
</div>
{/* 전체 사용자 풀 (= 권한없는 직원) */}
<div className="bg-white border border-slate-200 rounded-xl flex flex-col">
<div className="px-3 py-2 border-b border-slate-100 text-sm font-bold text-slate-700 inline-flex items-center gap-1.5">
<Users size={14} className="text-slate-400" /> ({available.length})
</div>
<div className="p-2 flex items-center gap-2 border-b border-slate-100">
<label className="text-xs inline-flex items-center gap-1.5">
<input type="checkbox"
checked={filteredAvail.length > 0 && filteredAvail.every((u) => chkAvail.has(u.USER_ID))}
onChange={(e) => setChkAvail(e.target.checked ? new Set(filteredAvail.map((u) => u.USER_ID)) : new Set())}
className="w-4 h-4 accent-emerald-600" />
</label>
<input value={availQ} onChange={(e) => setAvailQ(e.target.value)}
placeholder="검색" className="ml-auto h-7 w-40 px-2 text-xs border border-slate-200 rounded" />
</div>
<div className="flex-1 overflow-auto max-h-[40vh]">
<table className="w-full text-xs">
<thead className="bg-slate-50 sticky top-0">
<tr><th className="w-8 p-1.5"></th><th className="text-left p-1.5"></th><th className="text-left p-1.5"></th><th className="text-left p-1.5">ID</th></tr>
</thead>
<tbody>
{filteredAvail.map((u) => (
<tr key={u.USER_ID} className="border-t hover:bg-slate-50">
<td className="text-center p-1.5">
<input type="checkbox" checked={chkAvail.has(u.USER_ID)}
onChange={(e) => { const s = new Set(chkAvail); if (e.target.checked) s.add(u.USER_ID); else s.delete(u.USER_ID); setChkAvail(s); }}
className="w-4 h-4 accent-emerald-600" />
</td>
<td className="p-1.5">{u.DEPT_NAME || "-"}</td>
<td className="p-1.5 font-semibold">
{u.USER_NAME}
{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>}
</td>
<td className="p-1.5 text-slate-400 font-mono">{u.USER_ID}</td>
</tr>
))}
{filteredAvail.length === 0 && (
<tr><td colSpan={4} className="p-6 text-center text-slate-400">
{activeGroup ? "추가 가능한 사용자가 없습니다." : "왼쪽에서 수신자 그룹을 선택하세요"}
</td></tr>
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
{/* ===== 하단: 컨텐츠 작성 + 발송 ===== */}
<div className="bg-white border border-slate-200 rounded-xl p-4">
<div className="text-sm font-bold text-slate-700 inline-flex items-center gap-1.5 mb-3">
<Send size={14} className="text-emerald-700" />
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="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>
<div className="mt-1">
<RichEditor
value={bodyText}
onChange={setBodyText}
placeholder="알림 내용 + 공지 페이지 본문이 됩니다. 이미지는 그냥 복사해서 붙여넣으세요 (Ctrl+V)."
minHeight="200px"
/>
</div>
<div className="text-[10px] text-slate-400 mt-0.5"> 240() . · .</div>
</div>
</div>
<div className="space-y-3">
<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-56 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>
</div>
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-2 pt-3 mt-3 border-t border-slate-100">
<div className="flex items-center gap-3 text-xs text-slate-600">
<label className="inline-flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" checked={sendAll} onChange={(e) => setSendAll(e.target.checked)} className="w-4 h-4 accent-emerald-600" />
<span> ( )</span>
</label>
{!sendAll && (
<span>
: <b className="text-emerald-700">{activeGroup?.NAME ?? "(선택 없음)"}</b>
{activeGroup && <span className="ml-1 text-slate-500">· {targetUserIds.length}</span>}
</span>
)}
</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 ? "발송 중…" : sendAll ? "전체 구독자에게 발송" : `${targetCount < 0 ? "전체" : targetCount}명에게 발송`}
</button>
</div>
</div>
</div>
);
}