요청 사항 반영:
1) UI 패턴을 권한 관리 화면(admin-panel AuthManagement)과 통일
- 좌측 (260px): 수신자 그룹 목록 + 검색 + [+ 생성] 버튼
- 우측 상단: 그룹 멤버 / [‹ 추가 / 제거 ›] / 멤버 아닌 사용자 풀
(권한있는/권한없는 직원 양쪽 패널 패턴 그대로)
- 우측 하단: 발송 내용 (제목/본문/이미지/링크) + 발송 버튼
2) 그룹 추가/제거를 클릭 한 번으로 (전체 풀에서 체크 후 추가 → 멤버 패널로 이동)
3) 더블클릭으로 그룹 이름/설명 수정 또는 삭제
4) [전체 구독자에게 발송] 옵션 — 그룹 선택 없이도 전체 푸시 가능
5) 화면 안의 '최근 공지' 카드 제거 — 좌측 메뉴 [푸시알림 발송이력] 로 일원화
This commit is contained in:
@@ -1,18 +1,18 @@
|
||||
"use client";
|
||||
|
||||
// 푸시알림 게시판 — 권한 관리 화면(admin-panel/AuthManagement)과 동일한 3-패널 패턴.
|
||||
// 좌측 : 수신자 그룹 목록 [+ 생성]
|
||||
// 우측 상단 : 그룹 멤버 / [추가/제거] / 전체 사용자 풀 ← 권한있는/권한없는 직원
|
||||
// 우측 하단 : 컨텐츠(제목/본문/이미지/링크) + 발송
|
||||
// 발송이력은 별도 메뉴(/m/admin/notice-history)로 분리됨 — 본 화면에서 제거.
|
||||
|
||||
import { useEffect, useState, useCallback, useMemo } from "react";
|
||||
import Swal from "sweetalert2";
|
||||
import { Send, Upload, X, Bell, Plus, Pencil, Trash2, Users, Save } from "lucide-react";
|
||||
import { Send, Upload, X, Bell, Users, Shield } from "lucide-react";
|
||||
|
||||
interface Recipient {
|
||||
USER_ID: string;
|
||||
USER_NAME: string;
|
||||
USER_TYPE: string;
|
||||
IS_ADMIN: boolean;
|
||||
DEPT_NAME: string;
|
||||
USER_AGENT: string;
|
||||
LAST_SEEN: string;
|
||||
DEVICE_CNT: number;
|
||||
interface Group {
|
||||
OBJID: string; NAME: string; DESCRIPTION: string | null;
|
||||
REGDATE: string; MEMBER_CNT: number; MEMBER_IDS: string[];
|
||||
}
|
||||
interface AllUser {
|
||||
USER_ID: string;
|
||||
@@ -22,163 +22,140 @@ interface AllUser {
|
||||
DEPT_NAME: string;
|
||||
SUBSCRIBED: boolean;
|
||||
}
|
||||
interface Group {
|
||||
OBJID: string; NAME: string; DESCRIPTION: string | null;
|
||||
REGDATE: string; MEMBER_CNT: number; MEMBER_IDS: string[];
|
||||
}
|
||||
interface Notice {
|
||||
OBJID: string; TITLE: string; BODY: string | null;
|
||||
IMAGE_URL: string | null; URL: string | null;
|
||||
REGDATE: string; SENT_COUNT: number;
|
||||
}
|
||||
|
||||
export default function AdminNoticesPage() {
|
||||
// 그룹
|
||||
// ===== 좌측: 그룹 목록 =====
|
||||
const [groups, setGroups] = useState<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 [groupQuery, setGroupQuery] = useState("");
|
||||
const [activeGroup, setActiveGroup] = useState<Group | null>(null);
|
||||
|
||||
// ===== 우측 상단: 멤버 양쪽 패널 =====
|
||||
const [allUsers, setAllUsers] = useState<AllUser[]>([]);
|
||||
const [groupSaving, setGroupSaving] = useState(false);
|
||||
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 [recipients, setRecipients] = useState<Recipient[]>([]);
|
||||
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("");
|
||||
const [linkUrl, setLinkUrl] = useState("");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
// 과거 공지
|
||||
const [past, setPast] = useState<Notice[]>([]);
|
||||
|
||||
// 멤버 picker 검색
|
||||
const [memberPickerKw, setMemberPickerKw] = useState("");
|
||||
// 그룹 외 추가 옵션 — 활성 그룹 없이 발송하고 싶을 때
|
||||
const [sendAll, setSendAll] = useState(false);
|
||||
|
||||
const loadGroups = useCallback(async () => {
|
||||
const r = await fetch("/api/m/admin/notices/groups/list", { method: "POST" });
|
||||
setGroups((await r.json()).RESULTLIST ?? []);
|
||||
}, []);
|
||||
const loadRecipients = useCallback(async () => {
|
||||
const r = await fetch("/api/m/admin/notices/recipients", { method: "POST" });
|
||||
setRecipients((await r.json()).RESULTLIST ?? []);
|
||||
const j = await r.json();
|
||||
const list = (j.RESULTLIST ?? []) as Group[];
|
||||
setGroups(list);
|
||||
// 활성 그룹이 갱신되도록 sync
|
||||
setActiveGroup((cur) => (cur ? (list.find((g) => g.OBJID === cur.OBJID) ?? null) : null));
|
||||
}, []);
|
||||
const loadAllUsers = useCallback(async () => {
|
||||
const r = await fetch("/api/m/admin/notices/all-users", { method: "POST" });
|
||||
setAllUsers((await r.json()).RESULTLIST ?? []);
|
||||
}, []);
|
||||
const loadPast = useCallback(async () => {
|
||||
const r = await fetch("/api/m/admin/notices/list", { method: "POST" });
|
||||
setPast((await r.json()).RESULTLIST ?? []);
|
||||
}, []);
|
||||
useEffect(() => { loadGroups(); loadRecipients(); loadAllUsers(); loadPast(); }, [loadGroups, loadRecipients, loadAllUsers, loadPast]);
|
||||
useEffect(() => { loadGroups(); loadAllUsers(); }, [loadGroups, loadAllUsers]);
|
||||
|
||||
const filteredRecipients = useMemo(() => {
|
||||
return recipients.filter((u) => {
|
||||
if (typeFilter && (typeFilter === "A" ? !u.IS_ADMIN : u.IS_ADMIN)) return false;
|
||||
if (keyword) {
|
||||
const k = keyword.toLowerCase();
|
||||
if (!u.USER_NAME?.toLowerCase().includes(k) && !u.USER_ID?.toLowerCase().includes(k)) return false;
|
||||
// 활성 그룹이 바뀌면 체크박스 초기화
|
||||
useEffect(() => { setChkMember(new Set()); setChkAvail(new Set()); }, [activeGroup?.OBJID]);
|
||||
|
||||
const filteredGroups = useMemo(() => groups.filter((g) =>
|
||||
!groupQuery || g.NAME.toLowerCase().includes(groupQuery.toLowerCase()) || (g.DESCRIPTION ?? "").toLowerCase().includes(groupQuery.toLowerCase())
|
||||
), [groups, groupQuery]);
|
||||
|
||||
// 활성 그룹 멤버 / 풀
|
||||
const memberSet = useMemo(() => new Set(activeGroup?.MEMBER_IDS ?? []), [activeGroup]);
|
||||
const members = useMemo(() => allUsers.filter((u) => memberSet.has(u.USER_ID)), [allUsers, memberSet]);
|
||||
const available = useMemo(() => allUsers.filter((u) => !memberSet.has(u.USER_ID)), [allUsers, memberSet]);
|
||||
const filteredMembers = useMemo(() => members.filter((u) => !memberQ || u.USER_NAME.includes(memberQ) || u.USER_ID.includes(memberQ) || (u.DEPT_NAME ?? "").includes(memberQ)), [members, memberQ]);
|
||||
const filteredAvail = useMemo(() => available.filter((u) => !availQ || u.USER_NAME.includes(availQ) || u.USER_ID.includes(availQ) || (u.DEPT_NAME ?? "").includes(availQ)), [available, availQ]);
|
||||
|
||||
// ===== 그룹 생성/수정/삭제 (SweetAlert) =====
|
||||
const onCreate = async () => {
|
||||
const r = await Swal.fire({
|
||||
title: "수신자 그룹 생성",
|
||||
html: `
|
||||
<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, """)}">
|
||||
<input id="sw_desc" class="swal2-input" placeholder="설명 (선택)" value="${(g.DESCRIPTION ?? "").replace(/"/g, """)}">
|
||||
`,
|
||||
showCancelButton: true, showDenyButton: true, denyButtonText: "삭제", confirmButtonText: "저장", confirmButtonColor: "#0f766e",
|
||||
preConfirm: () => ({
|
||||
name: (document.getElementById("sw_name") as HTMLInputElement).value.trim(),
|
||||
description: (document.getElementById("sw_desc") as HTMLInputElement).value.trim(),
|
||||
}),
|
||||
});
|
||||
if (r.isDenied) {
|
||||
const ok = await Swal.fire({ icon: "warning", title: `"${g.NAME}" 삭제?`, showCancelButton: true, confirmButtonColor: "#dc2626", confirmButtonText: "삭제" });
|
||||
if (!ok.isConfirmed) return;
|
||||
const res = await fetch("/api/m/admin/notices/groups/save", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ objid: g.OBJID, action: "delete" }),
|
||||
});
|
||||
if ((await res.json()).success) {
|
||||
if (activeGroup?.OBJID === g.OBJID) setActiveGroup(null);
|
||||
loadGroups();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [recipients, typeFilter, keyword]);
|
||||
|
||||
// 발송 대상 = 선택 그룹들의 멤버 ∪ 개별 선택
|
||||
const finalUserIds = useMemo(() => {
|
||||
const set = new Set<string>(selectedUsers);
|
||||
for (const g of groups) {
|
||||
if (selectedGroups.has(g.OBJID)) for (const uid of g.MEMBER_IDS ?? []) set.add(uid);
|
||||
return;
|
||||
}
|
||||
return Array.from(set);
|
||||
}, [groups, selectedGroups, selectedUsers]);
|
||||
|
||||
// ===== 그룹 편집 =====
|
||||
const openNewGroup = () => {
|
||||
setEditingGroup({ NAME: "", DESCRIPTION: "" });
|
||||
setGroupMemberSet(new Set());
|
||||
setMemberPickerKw("");
|
||||
};
|
||||
const openEditGroup = (g: Group) => {
|
||||
setEditingGroup({ OBJID: g.OBJID, NAME: g.NAME, DESCRIPTION: g.DESCRIPTION ?? "" });
|
||||
setGroupMemberSet(new Set(g.MEMBER_IDS ?? []));
|
||||
setMemberPickerKw("");
|
||||
};
|
||||
const closeEditGroup = () => { setEditingGroup(null); setGroupMemberSet(new Set()); };
|
||||
|
||||
const toggleMemberInEdit = (uid: string) => {
|
||||
setGroupMemberSet((p) => {
|
||||
const n = new Set(p);
|
||||
if (n.has(uid)) n.delete(uid); else n.add(uid);
|
||||
return n;
|
||||
if (!r.isConfirmed || !r.value?.name) return;
|
||||
const res = await fetch("/api/m/admin/notices/groups/save", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ objid: g.OBJID, name: r.value.name, description: r.value.description }),
|
||||
});
|
||||
if ((await res.json()).success) loadGroups();
|
||||
};
|
||||
|
||||
const saveGroup = async () => {
|
||||
if (!editingGroup) return;
|
||||
if (!editingGroup.NAME.trim()) return Swal.fire({ icon: "warning", title: "그룹명을 입력하세요" });
|
||||
setGroupSaving(true);
|
||||
try {
|
||||
const sv = await fetch("/api/m/admin/notices/groups/save", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ objid: editingGroup.OBJID, name: editingGroup.NAME, description: editingGroup.DESCRIPTION }),
|
||||
});
|
||||
const sj = await sv.json();
|
||||
if (!sj.success) { Swal.fire({ icon: "error", title: "저장 실패", text: sj.message }); return; }
|
||||
const groupObjid = sj.objId;
|
||||
const mv = await fetch("/api/m/admin/notices/groups/members/save", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ groupObjid, userIds: Array.from(groupMemberSet) }),
|
||||
});
|
||||
const mj = await mv.json();
|
||||
if (!mj.success) { Swal.fire({ icon: "error", title: "멤버 저장 실패", text: mj.message }); return; }
|
||||
await Swal.fire({ icon: "success", title: "그룹 저장 완료", timer: 1200, showConfirmButton: false });
|
||||
closeEditGroup();
|
||||
loadGroups();
|
||||
} finally { setGroupSaving(false); }
|
||||
};
|
||||
|
||||
const deleteGroup = async () => {
|
||||
if (!editingGroup?.OBJID) return;
|
||||
const ok = await Swal.fire({ icon: "warning", title: `"${editingGroup.NAME}" 그룹 삭제?`, showCancelButton: true, confirmButtonColor: "#dc2626", confirmButtonText: "삭제" });
|
||||
if (!ok.isConfirmed) return;
|
||||
setGroupSaving(true);
|
||||
try {
|
||||
const r = await fetch("/api/m/admin/notices/groups/save", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ objid: editingGroup.OBJID, action: "delete" }),
|
||||
});
|
||||
const j = await r.json();
|
||||
if (j.success) {
|
||||
await Swal.fire({ icon: "success", title: "삭제됨", timer: 1000, showConfirmButton: false });
|
||||
// 발송 대상에서도 제거
|
||||
setSelectedGroups((p) => { const n = new Set(p); n.delete(editingGroup.OBJID!); return n; });
|
||||
closeEditGroup(); loadGroups();
|
||||
} else Swal.fire({ icon: "error", title: "삭제 실패", text: j.message });
|
||||
} finally { setGroupSaving(false); }
|
||||
};
|
||||
|
||||
// ===== 개별 선택 토글 =====
|
||||
const toggleUser = (id: string) => {
|
||||
setSelectedUsers((p) => { const n = new Set(p); if (n.has(id)) n.delete(id); else n.add(id); return n; });
|
||||
};
|
||||
const toggleAllFiltered = () => {
|
||||
const allOn = filteredRecipients.length > 0 && filteredRecipients.every((u) => selectedUsers.has(u.USER_ID));
|
||||
setSelectedUsers((p) => {
|
||||
const n = new Set(p);
|
||||
if (allOn) filteredRecipients.forEach((u) => n.delete(u.USER_ID));
|
||||
else filteredRecipients.forEach((u) => n.add(u.USER_ID));
|
||||
return n;
|
||||
// ===== 멤버 추가/제거 =====
|
||||
const saveMembers = async (newIds: string[]) => {
|
||||
if (!activeGroup) return false;
|
||||
const r = await fetch("/api/m/admin/notices/groups/members/save", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ groupObjid: activeGroup.OBJID, userIds: newIds }),
|
||||
});
|
||||
const j = await r.json();
|
||||
if (!j.success) { Swal.fire({ icon: "error", title: "저장 실패", text: j.message }); return false; }
|
||||
return true;
|
||||
};
|
||||
const toggleGroup = (id: string) => {
|
||||
setSelectedGroups((p) => { const n = new Set(p); if (n.has(id)) n.delete(id); else n.add(id); return n; });
|
||||
const addSelected = async () => {
|
||||
if (!activeGroup) return Swal.fire({ icon: "warning", title: "그룹을 선택하세요" });
|
||||
if (chkAvail.size === 0) return Swal.fire({ icon: "warning", title: "추가할 사용자를 선택하세요" });
|
||||
const newIds = Array.from(new Set([...(activeGroup.MEMBER_IDS ?? []), ...Array.from(chkAvail)]));
|
||||
if (await saveMembers(newIds)) { setChkAvail(new Set()); loadGroups(); }
|
||||
};
|
||||
const removeSelected = async () => {
|
||||
if (!activeGroup) return Swal.fire({ icon: "warning", title: "그룹을 선택하세요" });
|
||||
if (chkMember.size === 0) return Swal.fire({ icon: "warning", title: "제거할 사용자를 선택하세요" });
|
||||
const newIds = (activeGroup.MEMBER_IDS ?? []).filter((id) => !chkMember.has(id));
|
||||
if (await saveMembers(newIds)) { setChkMember(new Set()); loadGroups(); }
|
||||
};
|
||||
|
||||
// ===== 이미지 업로드 =====
|
||||
@@ -195,15 +172,17 @@ export default function AdminNoticesPage() {
|
||||
};
|
||||
|
||||
// ===== 발송 =====
|
||||
const targetUserIds = useMemo(() => sendAll ? [] : (activeGroup?.MEMBER_IDS ?? []), [sendAll, activeGroup]);
|
||||
const targetCount = sendAll ? -1 : targetUserIds.length;
|
||||
|
||||
const send = async () => {
|
||||
if (!title.trim()) return Swal.fire({ icon: "warning", title: "제목을 입력하세요" });
|
||||
if (finalUserIds.length === 0) return Swal.fire({ icon: "warning", title: "수신자(그룹 또는 개별)를 선택하세요" });
|
||||
if (!sendAll && targetUserIds.length === 0) return Swal.fire({ icon: "warning", title: "그룹(또는 멤버)을 선택하거나 [전체 구독자] 옵션을 켜세요" });
|
||||
|
||||
const targetLabel = sendAll ? "전체 구독자" : `${activeGroup?.NAME} (${targetUserIds.length}명)`;
|
||||
const ok = await Swal.fire({
|
||||
icon: "question",
|
||||
title: `${finalUserIds.length}명에게 푸시 발송`,
|
||||
text: `제목: ${title}`,
|
||||
showCancelButton: true, confirmButtonText: "발송", cancelButtonText: "취소",
|
||||
confirmButtonColor: "#0f766e",
|
||||
icon: "question", title: `${targetLabel} 에게 발송`, text: `제목: ${title}`,
|
||||
showCancelButton: true, confirmButtonText: "발송", cancelButtonText: "취소", confirmButtonColor: "#0f766e",
|
||||
});
|
||||
if (!ok.isConfirmed) return;
|
||||
setSending(true);
|
||||
@@ -215,190 +194,190 @@ export default function AdminNoticesPage() {
|
||||
const svj = await sv.json();
|
||||
if (!svj.success) { Swal.fire({ icon: "error", title: "저장 실패", text: svj.message }); return; }
|
||||
const noticeObjid = svj.objId;
|
||||
const groupNames = groups.filter((g) => selectedGroups.has(g.OBJID)).map((g) => g.NAME);
|
||||
const send = await fetch("/api/m/admin/notices/send-push", {
|
||||
const groupNames = activeGroup && !sendAll ? [activeGroup.NAME] : [];
|
||||
const sendRes = await fetch("/api/m/admin/notices/send-push", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
noticeObjid, title, message: bodyText,
|
||||
url: linkUrl || `/m/notices/${noticeObjid}`,
|
||||
userIds: finalUserIds,
|
||||
userIds: sendAll ? undefined : targetUserIds,
|
||||
sendAll,
|
||||
groupNames,
|
||||
}),
|
||||
});
|
||||
const sj = await send.json();
|
||||
const sj = await sendRes.json();
|
||||
if (sj.success) {
|
||||
await Swal.fire({ icon: "success", title: "발송 완료", text: `성공 ${sj.sent} · 실패 ${sj.failed}`, timer: 1800, showConfirmButton: false });
|
||||
setTitle(""); setBodyText(""); setImageUrl(""); setLinkUrl("");
|
||||
setSelectedGroups(new Set()); setSelectedUsers(new Set());
|
||||
loadPast();
|
||||
setTitle(""); setBodyText(""); setImageUrl(""); setLinkUrl(""); setSendAll(false);
|
||||
} else {
|
||||
Swal.fire({ icon: "error", title: "발송 실패", text: sj.message });
|
||||
}
|
||||
} finally { setSending(false); }
|
||||
};
|
||||
|
||||
// 멤버 picker (그룹 편집 패널)
|
||||
const memberPickerFiltered = useMemo(() => {
|
||||
return allUsers.filter((u) => {
|
||||
if (memberPickerKw) {
|
||||
const k = memberPickerKw.toLowerCase();
|
||||
if (!u.USER_NAME.toLowerCase().includes(k) && !u.USER_ID.toLowerCase().includes(k)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [allUsers, memberPickerKw]);
|
||||
|
||||
return (
|
||||
<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>을 선택(또는 개별 선택)하고, 오른쪽에 제목·본문·이미지를 작성한 뒤 [발송]을 누르세요.</p>
|
||||
<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-[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 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>
|
||||
|
||||
{/* 그룹 편집 패널 (권한그룹 스타일 — 아래쪽 전개) */}
|
||||
{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 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>
|
||||
<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 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>}
|
||||
</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)}
|
||||
@@ -413,6 +392,8 @@ export default function AdminNoticesPage() {
|
||||
className="w-full px-3 py-2 rounded-lg border border-slate-200 text-sm mt-1 resize-y" />
|
||||
<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">
|
||||
@@ -428,7 +409,7 @@ export default function AdminNoticesPage() {
|
||||
</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" />
|
||||
<img src={imageUrl} alt="첨부" className="mt-2 max-h-56 rounded border border-slate-200" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
@@ -438,38 +419,26 @@ export default function AdminNoticesPage() {
|
||||
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm mt-1" />
|
||||
<div className="text-[10px] text-slate-400 mt-0.5">비워두면 자체 공지 페이지(이미지+본문)로 이동합니다.</div>
|
||||
</div>
|
||||
<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 ? "발송 중…" : `${finalUserIds.length}명에게 발송`}
|
||||
</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">최근 공지 ({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 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>
|
||||
|
||||
Reference in New Issue
Block a user