feat(notices): 권한 관리 화면과 동일한 3-패널 UI 로 전면 재설계
Deploy momo-erp / deploy (push) Failing after 15m3s

요청 사항 반영:
1) UI 패턴을 권한 관리 화면(admin-panel AuthManagement)과 통일
   - 좌측 (260px): 수신자 그룹 목록 + 검색 + [+ 생성] 버튼
   - 우측 상단: 그룹 멤버 / [‹ 추가 / 제거 ›] / 멤버 아닌 사용자 풀
     (권한있는/권한없는 직원 양쪽 패널 패턴 그대로)
   - 우측 하단: 발송 내용 (제목/본문/이미지/링크) + 발송 버튼
2) 그룹 추가/제거를 클릭 한 번으로 (전체 풀에서 체크 후 추가 → 멤버 패널로 이동)
3) 더블클릭으로 그룹 이름/설명 수정 또는 삭제
4) [전체 구독자에게 발송] 옵션 — 그룹 선택 없이도 전체 푸시 가능
5) 화면 안의 '최근 공지' 카드 제거 — 좌측 메뉴 [푸시알림 발송이력] 로 일원화
This commit is contained in:
chpark
2026-05-30 15:42:11 +09:00
parent 63d83b5004
commit e4fcfd453d
+278 -309
View File
@@ -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;
}
return true;
// 활성 그룹이 바뀌면 체크박스 초기화
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(),
}),
});
}, [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 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;
});
};
const saveGroup = async () => {
if (!editingGroup) return;
if (!editingGroup.NAME.trim()) return Swal.fire({ icon: "warning", title: "그룹명을 입력하세요" });
setGroupSaving(true);
try {
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({ objid: editingGroup.OBJID, name: editingGroup.NAME, description: editingGroup.DESCRIPTION }),
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; }
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); }
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 deleteGroup = async () => {
if (!editingGroup?.OBJID) return;
const ok = await Swal.fire({ icon: "warning", title: `"${editingGroup.NAME}" 그룹 삭제?`, showCancelButton: true, confirmButtonColor: "#dc2626", confirmButtonText: "삭제" });
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;
setGroupSaving(true);
try {
const r = await fetch("/api/m/admin/notices/groups/save", {
const res = await fetch("/api/m/admin/notices/groups/save", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ objid: editingGroup.OBJID, action: "delete" }),
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) {
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); }
if (!j.success) { Swal.fire({ icon: "error", title: "저장 실패", text: j.message }); return false; }
return true;
};
// ===== 개별 선택 토글 =====
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 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 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 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} />
{/* ===== 상단: 좌측 그룹 목록 + 우측 멤버 양쪽 패널 ===== */}
<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="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 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 onClick={() => openEditGroup(g)} title="멤버 관리" className="shrink-0 text-slate-400 hover:text-emerald-700 p-1">
<Pencil size={13} />
</button>
</div>
);
})}
))}
</div>
</div>
{/* 그룹 편집 패널 (권한그룹 스타일 — 아래쪽 전개) */}
{editingGroup && (
<div className="bg-white border border-emerald-300 rounded-xl overflow-hidden ring-2 ring-emerald-100">
<div className="px-3 py-2 bg-emerald-50 border-b border-emerald-200 text-xs font-semibold text-emerald-800 flex items-center justify-between">
<span>{editingGroup.OBJID ? "그룹 멤버 관리" : "새 그룹"}</span>
<button onClick={closeEditGroup} className="text-slate-500 hover:text-rose-600"><X size={14} /></button>
{/* 우측: 멤버 양쪽 패널 */}
<div 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-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="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>
<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 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 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}
{/* 추가/제거 버튼 */}
<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>}
</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>
</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>
)}
{/* 개별 선택 (구독자) */}
<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>
</tbody>
</table>
</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="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 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)}
@@ -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 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="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 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>
<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>