b11d0df704
푸시알림 게시판: - 본문 입력을 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>
457 lines
25 KiB
TypeScript
457 lines
25 KiB
TypeScript
"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, """)}">
|
||
<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;
|
||
}
|
||
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(/ /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>
|
||
);
|
||
}
|