SPMS_WEB/react/src/features/message/components/MessageSlidePanel.tsx
SEAN 2549930a5a feat: 메시지 관리 페이지 API 연동 (#35)
- types.ts: Mock 데이터 및 camelCase 타입 삭제, swagger 기준 snake_case 타입 추가
- message.api.ts: 신규 생성 (목록/상세/저장/삭제/검증 API 함수)
- MessageListPage: MOCK_MESSAGES → fetchMessages API, 서비스 필터 fetchServices로 실제 로드
- MessageSlidePanel: MOCK_MESSAGE_DETAILS → fetchMessageInfo API, deleteMessage API 연동
- MessageRegisterPage: SERVICE_OPTIONS → fetchServices API, validateMessage → saveMessage 흐름
- MessageRegisterPage: 서비스 선택을 FilterDropdown 스타일 커스텀 드롭다운으로 변경
- MessagePreview: 빈 내용 시 플레이스홀더 텍스트 제거

Closes #35
2026-03-02 09:51:02 +09:00

326 lines
12 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import CopyButton from "@/components/common/CopyButton";
import MessagePreview from "./MessagePreview";
import { fetchMessageInfo, deleteMessage } from "@/api/message.api";
import type { MessageInfoResponse } from "../types";
interface MessageSlidePanelProps {
isOpen: boolean;
onClose: () => void;
messageCode: string | null;
serviceCode: string | null;
}
export default function MessageSlidePanel({
isOpen,
onClose,
messageCode,
serviceCode,
}: MessageSlidePanelProps) {
const [detail, setDetail] = useState<MessageInfoResponse | null>(null);
const [loading, setLoading] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleting, setDeleting] = useState(false);
const bodyRef = useRef<HTMLDivElement>(null);
// 메시지 상세 조회
useEffect(() => {
if (!isOpen || !messageCode || !serviceCode) {
setDetail(null);
return;
}
(async () => {
setLoading(true);
try {
const res = await fetchMessageInfo(
{ message_code: messageCode },
serviceCode,
);
setDetail(res.data.data);
} catch {
setDetail(null);
toast.error("메시지 상세 조회에 실패했습니다.");
} finally {
setLoading(false);
}
})();
}, [isOpen, messageCode, serviceCode]);
// 패널 열릴 때 스크롤 최상단으로 리셋
useEffect(() => {
if (isOpen) {
bodyRef.current?.scrollTo(0, 0);
}
}, [isOpen, messageCode]);
// 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]);
// 삭제 처리
const handleDelete = async () => {
if (!detail?.message_code || !serviceCode) return;
setDeleting(true);
try {
await deleteMessage(
{ message_code: detail.message_code },
serviceCode,
);
toast.success(`${detail.message_code} 메시지가 삭제되었습니다.`);
setShowDeleteConfirm(false);
onClose();
} catch {
toast.error("메시지 삭제에 실패했습니다.");
} finally {
setDeleting(false);
}
};
// 기타 정보 문자열 변환
const extraText =
detail?.data != null
? typeof detail.data === "string"
? detail.data
: JSON.stringify(detail.data, null, 2)
: "";
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">
{loading ? (
/* 로딩 스켈레톤 */
<div className="space-y-5">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i}>
<div className="h-3 w-16 rounded bg-gray-100 animate-pulse mb-2" />
<div className="h-9 w-full rounded bg-gray-100 animate-pulse" />
</div>
))}
</div>
) : 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.message_code}
</code>
<CopyButton text={detail.message_code ?? ""} />
</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.service_name}
</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.image_url
? "text-gray-500"
: "italic text-gray-400"
}`}
>
{detail.image_url || "등록된 이미지 없음"}
</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.link_type === "deeplink"
? "bg-purple-100 text-purple-700"
: "bg-blue-100 text-blue-700"
}`}
>
{detail.link_type === "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.link_url || "—"}
</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">
{extraText || "—"}
</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.image_url}
appName={detail.service_name ?? ""}
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.message_code}</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)}
disabled={deleting}
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={handleDelete}
disabled={deleting}
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 disabled:opacity-50"
>
<span className="material-symbols-outlined text-base">
delete
</span>
<span>{deleting ? "삭제 중..." : "삭제"}</span>
</button>
</div>
</div>
</div>
)}
</>
);
}