SPMS_WEB/react/src/features/message/components/MessageSlidePanel.tsx
SEAN 6653e5da2a feat: 메시지 관리 페이지 구현 (#19)
- 메시지 타입 정의 + 목 데이터 10건 (types.ts)
- iOS/Android 푸시 알림 프리뷰 컴포넌트 (MessagePreview)
- 슬라이드 패널 상세 보기 + 삭제 기능 (MessageSlidePanel)
- 메시지 목록 페이지: 필터(ID/제목 검색, 서비스), 테이블, 페이지네이션
- 메시지 작성 페이지: 12-col 그리드 폼 + 실시간 프리뷰, 필수 검증, 저장 모달

Closes #19

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 15:33:42 +09:00

263 lines
9.7 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import CopyButton from "@/components/common/CopyButton";
import MessagePreview from "./MessagePreview";
import { MOCK_MESSAGE_DETAILS } from "../types";
interface MessageSlidePanelProps {
isOpen: boolean;
onClose: () => void;
messageId: string | null;
}
export default function MessageSlidePanel({
isOpen,
onClose,
messageId,
}: MessageSlidePanelProps) {
const detail = messageId ? MOCK_MESSAGE_DETAILS[messageId] : null;
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const bodyRef = useRef<HTMLDivElement>(null);
// 패널 열릴 때 스크롤 최상단으로 리셋
useEffect(() => {
if (isOpen) {
bodyRef.current?.scrollTo(0, 0);
}
}, [isOpen, messageId]);
// ESC 닫기
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen) onClose();
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
// 패널 열릴 때 body 스크롤 잠금
useEffect(() => {
if (isOpen) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [isOpen]);
return (
<>
{/* 오버레이 */}
<div
className={`fixed inset-0 bg-black/30 z-50 transition-opacity duration-300 ${
isOpen ? "opacity-100" : "opacity-0 pointer-events-none"
}`}
onClick={onClose}
/>
{/* 패널 */}
<aside
className={`fixed top-0 right-0 h-full w-[480px] bg-white shadow-2xl z-50 flex flex-col transition-transform duration-300 ${
isOpen ? "translate-x-0" : "translate-x-full"
}`}
>
{/* 패널 헤더 */}
<div className="flex items-center justify-between px-6 py-5 border-b border-gray-200">
<h2 className="text-lg font-bold text-[#0f172a]"> </h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors p-1 rounded-lg hover:bg-gray-100"
>
<span className="material-symbols-outlined text-xl">close</span>
</button>
</div>
{/* 패널 본문 */}
<div ref={bodyRef} className="flex-1 overflow-y-auto p-6">
{detail ? (
<div className="space-y-5">
{/* 메시지 ID */}
<div className="flex items-center gap-3">
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider flex-shrink-0">
ID
</label>
<code className="text-sm font-medium text-[#2563EB] bg-[#2563EB]/5 px-2 py-0.5 rounded">
{detail.messageId}
</code>
<CopyButton text={detail.messageId} />
</div>
<div className="h-px bg-gray-100" />
{/* 서비스 선택 */}
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-2">
</label>
<p className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded text-sm text-gray-700">
{detail.serviceName}
</p>
</div>
{/* 제목 */}
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-2">
</label>
<p className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded text-sm text-gray-700">
{detail.title}
</p>
</div>
{/* 내용 */}
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-2">
</label>
<div className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded text-sm text-gray-700 leading-relaxed min-h-[120px]">
{detail.body}
</div>
</div>
{/* 이미지 URL */}
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-2">
URL
</label>
<p
className={`w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded text-sm truncate ${
detail.imageUrl
? "text-gray-500"
: "italic text-gray-400"
}`}
>
{detail.imageUrl || "등록된 이미지 없음"}
</p>
</div>
{/* 링크 URL */}
<div>
<div className="flex items-center gap-2 mb-2">
<label className="text-sm font-medium text-[#0f172a]">
URL
</label>
<span
className={`text-[11px] font-medium px-1.5 py-0.5 rounded ${
detail.linkType === "deeplink"
? "bg-purple-100 text-purple-700"
: "bg-blue-100 text-blue-700"
}`}
>
{detail.linkType === "deeplink" ? "딥링크" : "웹 링크"}
</span>
</div>
<p className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded text-sm text-gray-500 truncate">
{detail.linkUrl || "—"}
</p>
</div>
{/* 기타 정보 */}
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-2">
</label>
<div className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded text-sm text-gray-700 leading-relaxed min-h-[80px] whitespace-pre-line">
{detail.extra || "—"}
</div>
</div>
<div className="h-px bg-gray-100" />
{/* 프리뷰 */}
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-3">
</label>
<MessagePreview
title={detail.title}
body={detail.body}
hasImage={!!detail.imageUrl}
appName={detail.serviceName}
variant="small"
/>
</div>
<div className="h-px bg-gray-100" />
{/* 삭제 버튼 */}
<button
type="button"
onClick={() => setShowDeleteConfirm(true)}
className="w-full flex items-center justify-center border border-red-300 text-red-600 hover:bg-red-50 px-5 py-2.5 rounded text-sm font-medium transition"
>
</button>
</div>
) : (
<div className="flex items-center justify-center h-full text-gray-400 text-sm">
</div>
)}
</div>
</aside>
{/* 삭제 확인 모달 */}
{showDeleteConfirm && detail && (
<div className="fixed inset-0 z-[60] flex items-center justify-center">
<div
className="absolute inset-0 bg-black/40"
onClick={() => setShowDeleteConfirm(false)}
/>
<div className="relative bg-white rounded-xl shadow-2xl max-w-md w-full mx-4 p-6 border border-gray-200">
<div className="flex items-center gap-3 mb-4">
<div className="size-10 rounded-full bg-red-100 flex items-center justify-center flex-shrink-0">
<span className="material-symbols-outlined text-red-600 text-xl">
warning
</span>
</div>
<h3 className="text-lg font-bold text-[#0f172a]"> </h3>
</div>
<p className="text-sm text-[#0f172a] mb-2">
<strong>{detail.messageId}</strong> ?
</p>
<div className="bg-red-50 border border-red-200 rounded-lg px-4 py-3 mb-5">
<div className="flex items-center gap-1.5 text-red-700 text-xs font-medium">
<span
className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}
>
info
</span>
<span> .</span>
</div>
</div>
<div className="flex justify-end gap-3">
<button
onClick={() => setShowDeleteConfirm(false)}
className="bg-white border border-gray-300 hover:bg-gray-50 text-[#0f172a] px-5 py-2.5 rounded text-sm font-medium transition"
>
</button>
<button
onClick={() => {
toast.success(`${detail.messageId} 메시지가 삭제되었습니다.`);
setShowDeleteConfirm(false);
onClose();
}}
className="bg-red-600 hover:bg-red-700 text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm flex items-center gap-2"
>
<span className="material-symbols-outlined text-base">
delete
</span>
<span></span>
</button>
</div>
</div>
</div>
)}
</>
);
}