- 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
326 lines
12 KiB
TypeScript
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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|