WIP: feat: 메시지 관리 페이지 구현 (#19) #20

Merged
seonkyu.kim merged 1 commits from feature/SPMS-19-message-management into develop 2026-02-27 06:36:18 +00:00
6 changed files with 1397 additions and 5 deletions
Showing only changes of commit 6653e5da2a - Show all commits

View File

@ -0,0 +1,379 @@
import { useState } from "react";
interface MessagePreviewProps {
title: string;
body: string;
hasImage: boolean;
appName?: string;
variant?: "large" | "small";
}
// 폰 배경 이미지 (밝은 그래디언트 배경)
const PHONE_BG =
"https://lh3.googleusercontent.com/aida-public/AB6AXuDmbD5msJ9uegWOcy0256wH6JsipGzrgtab3foEKiGVs_a4SbUCTPti6BVDJOQEP4ZCvbcAw9hI3C7QuUdPxrBf3jJm3VgKkWoqSzl--ZEbPIzimbYnM1HQEsRbil7nmWG_XscwPP30V3OFnyleVY_R7Urk0UYbrL8P1OJwW1xwYfBDJv4htBuICd9GR2NIJlSShaBxfF9Kgp59Cte3VapdHxCz9p2Cb9tf1t13xc2LV348V-kfyQNtL8XCZNP3LMrrUIR4SrV3cGM";
export default function MessagePreview({
title,
body,
hasImage,
appName = "SPMS",
variant = "large",
}: MessagePreviewProps) {
const [tab, setTab] = useState<"ios" | "android">("ios");
const isLarge = variant === "large";
const phoneWidth = isLarge ? "w-[300px]" : "w-[240px]";
const truncatedBody =
body.length > 50 ? body.substring(0, 50) + "..." : body;
return (
<div>
{/* 탭 */}
<div className="flex border-b border-gray-200 mb-4">
<button
type="button"
className={`flex-1 px-3 py-2 text-xs font-medium border-b-2 transition-colors cursor-pointer ${
tab === "ios"
? "text-[#0f172a] border-[#2563EB]"
: "text-gray-400 border-transparent hover:text-[#0f172a]"
}`}
onClick={() => setTab("ios")}
>
iOS
</button>
<button
type="button"
className={`flex-1 px-3 py-2 text-xs font-medium border-b-2 transition-colors cursor-pointer ${
tab === "android"
? "text-[#0f172a] border-[#2563EB]"
: "text-gray-400 border-transparent hover:text-[#0f172a]"
}`}
onClick={() => setTab("android")}
>
Android
</button>
</div>
{/* iOS 프리뷰 */}
{tab === "ios" && (
<div className="flex flex-col gap-5">
{/* Banner Notification */}
<div className="min-h-[130px]">
<p className="text-[10px] uppercase font-bold text-gray-400 tracking-wider mb-2">
Banner Notification
</p>
<div className="bg-white/90 backdrop-blur-md rounded-2xl p-3 shadow-lg border border-gray-200">
<div className="flex items-center gap-2.5">
<div className="size-8 bg-[#2563EB] rounded-xl flex items-center justify-center flex-shrink-0 shadow-sm">
<span
className="material-symbols-outlined text-white"
style={{ fontSize: "16px" }}
>
shopping_bag
</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-xs font-bold text-[#0f172a] break-words">
{title || "메시지 제목을 입력하세요"}
</p>
<p className="text-[10px] text-gray-600 mt-0.5 break-words">
{body || "메시지 내용을 입력하세요"}
</p>
</div>
<div className="flex flex-col items-end gap-1 flex-shrink-0 self-start">
<p className="text-[10px] text-gray-400">now</p>
{hasImage && (
<div className="w-8 h-8 bg-gray-100 border border-dashed border-gray-300 rounded flex items-center justify-center">
<span
className="material-symbols-outlined text-gray-300"
style={{ fontSize: "14px" }}
>
image
</span>
</div>
)}
</div>
</div>
</div>
</div>
{/* Full Screen Preview - iOS */}
<div>
<p className="text-[10px] uppercase font-bold text-gray-400 tracking-wider mb-2">
Full Screen Preview
</p>
<div className="flex justify-center">
<div className={`relative ${phoneWidth}`}>
<div
className={`${isLarge ? "border-[10px]" : "border-[8px]"} border-gray-800 bg-gray-800 ${isLarge ? "rounded-[2.5rem]" : "rounded-[2rem]"} overflow-hidden shadow-xl`}
style={{ aspectRatio: "9/19.5" }}
>
{/* 노치 */}
<div
className={`absolute top-0 left-1/2 -translate-x-1/2 ${isLarge ? "w-32 h-7" : "w-24 h-5"} bg-black rounded-b-2xl z-10`}
/>
<div
className="w-full h-full bg-cover bg-center overflow-hidden relative"
style={{
backgroundImage: `url('${PHONE_BG}')`,
filter: "brightness(1)",
}}
>
{/* 상태바 */}
<div className="px-4 pt-2 pb-1 flex justify-between items-center text-white text-[10px] font-medium bg-black/20">
<span>9:41</span>
<div className="flex gap-0.5 items-center">
<span
className="material-symbols-outlined"
style={{ fontSize: "12px" }}
>
signal_cellular_alt
</span>
<span
className="material-symbols-outlined"
style={{ fontSize: "12px" }}
>
wifi
</span>
<span
className="material-symbols-outlined"
style={{ fontSize: "12px" }}
>
battery_full
</span>
</div>
</div>
{/* 알림 카드 */}
<div
className={`absolute ${isLarge ? "top-16 left-3 right-3" : "top-12 left-2 right-2"} bg-white/80 backdrop-blur-md rounded-lg shadow-lg border border-white/30 overflow-hidden`}
>
<div className="p-2.5">
<div className="flex items-center gap-2">
<div className="size-6 bg-[#2563EB] rounded-md flex items-center justify-center flex-shrink-0">
<span
className="material-symbols-outlined text-white"
style={{ fontSize: "12px" }}
>
shopping_bag
</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-[10px] font-bold text-[#0f172a] break-words">
{title || "메시지 제목을 입력하세요"}
</p>
<p className="text-[8px] text-gray-600 mt-0.5 break-words">
{truncatedBody || "메시지 내용을 입력하세요"}
</p>
</div>
<p className="text-[8px] text-gray-400 flex-shrink-0 self-start">
now
</p>
</div>
</div>
{hasImage && (
<div
className="w-full bg-gray-100 border-t border-dashed border-gray-200 flex items-center justify-center"
style={{ height: isLarge ? 150 : 120 }}
>
<span className="material-symbols-outlined text-gray-300 text-2xl">
image
</span>
</div>
)}
</div>
{/* 홈 인디케이터 */}
<div className="absolute bottom-0 left-0 right-0 h-5 flex items-center justify-center">
<div className="w-24 h-1 bg-white/60 rounded-full" />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)}
{/* Android 프리뷰 */}
{tab === "android" && (
<div className="flex flex-col gap-5">
{/* Banner Notification */}
<div className="min-h-[130px]">
<p className="text-[10px] uppercase font-bold text-gray-400 tracking-wider mb-2">
Banner Notification
</p>
<div className="bg-white rounded-2xl p-3 shadow-lg border border-gray-200">
<div className="flex items-center gap-1.5 mb-1.5">
<div className="size-3.5 bg-[#2563EB] rounded-full flex items-center justify-center flex-shrink-0">
<span
className="material-symbols-outlined text-white"
style={{ fontSize: "8px" }}
>
shopping_bag
</span>
</div>
<p className="text-[10px] text-gray-500 font-medium">{appName}</p>
<span className="text-[10px] text-gray-300 mx-0.5">&middot;</span>
<p className="text-[10px] text-gray-400">now</p>
</div>
<div className="flex items-center gap-2.5">
<div className="flex-1 min-w-0">
<p className="text-xs font-bold text-[#0f172a] break-words">
{title || "메시지 제목을 입력하세요"}
</p>
<p className="text-[10px] text-gray-600 mt-0.5 break-words">
{body || "메시지 내용을 입력하세요"}
</p>
</div>
{hasImage && (
<div className="w-8 h-8 bg-gray-100 border border-dashed border-gray-300 rounded flex items-center justify-center flex-shrink-0">
<span
className="material-symbols-outlined text-gray-300"
style={{ fontSize: "14px" }}
>
image
</span>
</div>
)}
</div>
</div>
</div>
{/* Full Screen Preview - Android */}
<div>
<p className="text-[10px] uppercase font-bold text-gray-400 tracking-wider mb-2">
Full Screen Preview
</p>
<div className="flex justify-center">
<div className={`relative ${phoneWidth}`}>
<div
className={`${isLarge ? "border-[10px]" : "border-[8px]"} border-gray-800 bg-gray-800 rounded-[2rem] overflow-hidden shadow-xl`}
style={{ aspectRatio: "9/19.5" }}
>
<div
className="w-full h-full bg-cover bg-center overflow-hidden relative"
style={{
backgroundImage: `url('${PHONE_BG}')`,
filter: "brightness(1)",
}}
>
{/* 상태바 */}
<div className="px-4 pt-2 pb-1 flex justify-between items-center text-white text-[10px] font-medium bg-black/20">
<span>12:30</span>
<div className="flex gap-0.5 items-center">
<span
className="material-symbols-outlined"
style={{ fontSize: "12px" }}
>
signal_cellular_alt
</span>
<span
className="material-symbols-outlined"
style={{ fontSize: "12px" }}
>
wifi
</span>
<span
className="material-symbols-outlined"
style={{ fontSize: "12px" }}
>
battery_full
</span>
</div>
</div>
{/* 알림 카드 */}
<div
className={`absolute ${isLarge ? "top-14" : "top-11"} left-2 right-2 bg-white/90 rounded-xl shadow-lg overflow-hidden`}
>
<div className="px-2.5 pt-2 pb-1.5">
<div className="flex items-center gap-1 mb-1">
<div className="size-3 bg-[#2563EB] rounded-full flex items-center justify-center flex-shrink-0">
<span
className="material-symbols-outlined text-white"
style={{ fontSize: "7px" }}
>
shopping_bag
</span>
</div>
<p className="text-[8px] text-gray-500 font-medium">
{appName}
</p>
<span className="text-[8px] text-gray-300 mx-0.5">
&middot;
</span>
<p className="text-[8px] text-gray-400">now</p>
<span
className="material-symbols-outlined text-gray-400 ml-auto"
style={{ fontSize: "10px" }}
>
expand_more
</span>
</div>
<div className="flex items-center gap-2">
<div className="flex-1 min-w-0">
<p className="text-[10px] font-bold text-[#0f172a] break-words">
{title || "메시지 제목을 입력하세요"}
</p>
<p className="text-[8px] text-gray-600 mt-0.5 break-words">
{truncatedBody || "메시지 내용을 입력하세요"}
</p>
</div>
{hasImage && (
<div className="w-8 h-8 bg-gray-100 border border-dashed border-gray-200 rounded flex items-center justify-center flex-shrink-0">
<span
className="material-symbols-outlined text-gray-300"
style={{ fontSize: "12px" }}
>
image
</span>
</div>
)}
</div>
</div>
{hasImage && (
<div
className="w-full bg-gray-100 border-t border-dashed border-gray-200 flex items-center justify-center"
style={{ height: isLarge ? 150 : 120 }}
>
<span className="material-symbols-outlined text-gray-300 text-2xl">
image
</span>
</div>
)}
</div>
{/* 네비게이션 바 */}
<div className="absolute bottom-0 left-0 right-0 h-4 flex items-center justify-center gap-10 bg-black/10">
<span
className="material-symbols-outlined text-white/60"
style={{ fontSize: "13px" }}
>
arrow_back
</span>
<span
className="material-symbols-outlined text-white/60"
style={{ fontSize: "13px" }}
>
circle
</span>
<span
className="material-symbols-outlined text-white/60"
style={{ fontSize: "13px" }}
>
crop_square
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)}
<p className="text-[10px] text-gray-400 leading-relaxed text-center mt-4">
.
<br />
OS .
</p>
</div>
);
}

View File

@ -0,0 +1,262 @@
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>
)}
</>
);
}

View File

@ -1,7 +1,266 @@
import { useState, useMemo } from "react";
import { Link } from "react-router-dom";
import PageHeader from "@/components/common/PageHeader";
import SearchInput from "@/components/common/SearchInput";
import FilterDropdown from "@/components/common/FilterDropdown";
import FilterResetButton from "@/components/common/FilterResetButton";
import Pagination from "@/components/common/Pagination";
import EmptyState from "@/components/common/EmptyState";
import CopyButton from "@/components/common/CopyButton";
import MessageSlidePanel from "../components/MessageSlidePanel";
import { formatDate } from "@/utils/format";
import { MOCK_MESSAGES, SERVICE_FILTER_OPTIONS } from "../types";
const PAGE_SIZE = 10;
export default function MessageListPage() { export default function MessageListPage() {
// 필터 입력 상태
const [search, setSearch] = useState("");
const [serviceFilter, setServiceFilter] = useState("전체 서비스");
const [currentPage, setCurrentPage] = useState(1);
const [loading, setLoading] = useState(false);
// 실제 적용될 필터 (조회 버튼 클릭 시 반영)
const [appliedSearch, setAppliedSearch] = useState("");
const [appliedService, setAppliedService] = useState("전체 서비스");
// 슬라이드 패널 상태
const [panelOpen, setPanelOpen] = useState(false);
const [selectedMessageId, setSelectedMessageId] = useState<string | null>(
null
);
// 조회 버튼
const handleQuery = () => {
setLoading(true);
setTimeout(() => {
setAppliedSearch(search);
setAppliedService(serviceFilter);
setCurrentPage(1);
setLoading(false);
}, 400);
};
// 필터 초기화
const handleReset = () => {
setSearch("");
setServiceFilter("전체 서비스");
setAppliedSearch("");
setAppliedService("전체 서비스");
setCurrentPage(1);
};
// 필터링된 데이터
const filtered = useMemo(() => {
return MOCK_MESSAGES.filter((msg) => {
// 검색 (메시지 ID / 제목)
if (appliedSearch) {
const q = appliedSearch.toLowerCase();
if (
!msg.messageId.toLowerCase().includes(q) &&
!msg.title.toLowerCase().includes(q)
) return false;
}
// 서비스 필터
if (
appliedService !== "전체 서비스" &&
msg.serviceName !== appliedService
) {
return false;
}
return true;
});
}, [appliedSearch, appliedService]);
// 페이지네이션
const totalItems = filtered.length;
const totalPages = Math.max(1, Math.ceil(totalItems / PAGE_SIZE));
const paged = filtered.slice(
(currentPage - 1) * PAGE_SIZE,
currentPage * PAGE_SIZE
);
// 행 클릭 → 슬라이드 패널
const handleRowClick = (messageId: string) => {
setSelectedMessageId(messageId);
setPanelOpen(true);
};
return ( return (
<div className="p-6"> <div>
<h1 className="text-2xl font-bold"> </h1> <PageHeader
title="메시지 목록"
description="시스템에서 발송된 모든 메시지 내역을 관리합니다."
action={
<Link
to="/messages/register"
className="flex items-center justify-center gap-2 min-w-[154px] px-5 py-2.5 bg-[#2563EB] hover:bg-[#1d4ed8] text-white rounded-lg text-sm font-medium transition-colors shadow-sm"
>
<span className="material-symbols-outlined text-lg">add</span>
</Link>
}
/>
{/* 필터바 */}
<div className="bg-white border border-gray-200 rounded-lg p-6 mb-6">
<div className="flex items-end gap-4">
<SearchInput
value={search}
onChange={setSearch}
placeholder="검색어를 입력하세요"
label="메시지 ID / 제목"
disabled={loading}
/>
<FilterDropdown
label="서비스 구분"
value={serviceFilter}
options={SERVICE_FILTER_OPTIONS}
onChange={setServiceFilter}
className="w-[140px] flex-shrink-0"
disabled={loading}
/>
<FilterResetButton onClick={handleReset} disabled={loading} />
<button
onClick={handleQuery}
disabled={loading}
className="h-[38px] flex-shrink-0 bg-gray-800 hover:bg-gray-900 active:scale-95 text-white rounded-lg px-5 text-sm font-medium transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
</div>
</div>
{/* 테이블 */}
{loading ? (
/* 로딩 스켈레톤 */
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden shadow-sm">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-4 text-xs font-semibold uppercase text-gray-700 text-center">
ID
</th>
<th className="px-6 py-4 text-xs font-semibold uppercase text-gray-700 text-center">
</th>
<th className="px-6 py-4 text-xs font-semibold uppercase text-gray-700 text-center">
</th>
<th className="px-6 py-4 text-xs font-semibold uppercase text-gray-700 text-center">
</th>
</tr>
</thead>
<tbody>
{Array.from({ length: 5 }).map((_, i) => (
<tr
key={i}
className={i < 4 ? "border-b border-gray-100" : ""}
>
<td className="px-6 py-4 text-center">
<div className="h-4 w-20 rounded bg-gray-100 animate-pulse mx-auto" />
</td>
<td className="px-6 py-4">
<div className="h-4 w-48 rounded bg-gray-100 animate-pulse" />
</td>
<td className="px-6 py-4 text-center">
<div className="h-4 w-24 rounded bg-gray-100 animate-pulse mx-auto" />
</td>
<td className="px-6 py-4 text-center">
<div className="h-4 w-20 rounded bg-gray-100 animate-pulse mx-auto" />
</td>
</tr>
))}
</tbody>
</table>
</div>
) : paged.length > 0 ? (
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden shadow-sm">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-4 text-xs font-semibold uppercase text-gray-700 text-center">
ID
</th>
<th className="px-6 py-4 text-xs font-semibold uppercase text-gray-700 text-center">
</th>
<th className="px-6 py-4 text-xs font-semibold uppercase text-gray-700 text-center">
</th>
<th className="px-6 py-4 text-xs font-semibold uppercase text-gray-700 text-center">
</th>
</tr>
</thead>
<tbody>
{paged.map((msg, idx) => (
<tr
key={msg.messageId}
className={`${idx < paged.length - 1 ? "border-b border-gray-100" : ""} hover:bg-gray-50/50 transition-colors cursor-pointer`}
onClick={() => handleRowClick(msg.messageId)}
>
{/* 메시지 ID */}
<td className="px-6 py-4 text-center">
<div
className="flex items-center justify-center gap-2"
onClick={(e) => e.stopPropagation()}
>
<code className="text-sm text-gray-700 font-medium">
{msg.messageId}
</code>
<CopyButton text={msg.messageId} />
</div>
</td>
{/* 메시지 제목 */}
<td className="px-6 py-4">
<span className="text-sm text-gray-700 truncate max-w-xs block">
{msg.title}
</span>
</td>
{/* 서비스 */}
<td className="px-6 py-4 text-center">
<span className="text-sm text-gray-600">
{msg.serviceName}
</span>
</td>
{/* 작성일 */}
<td className="px-6 py-4 text-center">
<span className="text-sm text-gray-500">
{formatDate(msg.createdAt)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 페이지네이션 */}
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={totalItems}
pageSize={PAGE_SIZE}
onPageChange={setCurrentPage}
/>
</div>
) : (
<EmptyState
icon="search_off"
message="검색 결과가 없습니다"
description="다른 검색어를 입력하거나 필터를 변경해보세요."
/>
)}
{/* 슬라이드 패널 */}
<MessageSlidePanel
isOpen={panelOpen}
onClose={() => setPanelOpen(false)}
messageId={selectedMessageId}
/>
</div> </div>
); );
} }

View File

@ -1,7 +1,362 @@
import { useState, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import PageHeader from "@/components/common/PageHeader";
import useShake from "@/hooks/useShake";
import MessagePreview from "../components/MessagePreview";
import { SERVICE_OPTIONS, LINK_TYPE } from "../types";
import type { LinkType } from "../types";
export default function MessageRegisterPage() { export default function MessageRegisterPage() {
const navigate = useNavigate();
const { triggerShake, cls } = useShake();
// 폼 상태
const [service, setService] = useState("");
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const [imageUrl, setImageUrl] = useState("");
const [linkUrl, setLinkUrl] = useState("");
const [linkType, setLinkType] = useState<LinkType>(LINK_TYPE.WEB);
const [extra, setExtra] = useState("");
// 필드 ref (스크롤용)
const serviceRef = useRef<HTMLSelectElement>(null);
const titleRef = useRef<HTMLInputElement>(null);
// 에러 메시지 상태 (shake와 별도로 유지)
const [errors, setErrors] = useState<Record<string, string>>({});
// 확인 모달 상태
const [showConfirm, setShowConfirm] = useState(false);
// 필수 필드 검증
const validate = (): boolean => {
const newErrors: Record<string, string> = {};
const shakeFields: string[] = [];
if (!service) {
newErrors.service = "필수 입력 항목입니다.";
shakeFields.push("service");
}
if (!title.trim()) {
newErrors.title = "필수 입력 항목입니다.";
shakeFields.push("title");
}
setErrors(newErrors);
if (shakeFields.length > 0) {
triggerShake(shakeFields);
// 첫 번째 에러 필드로 스크롤 + 포커스
const firstRef = shakeFields[0] === "service" ? serviceRef : titleRef;
firstRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
firstRef.current?.focus({ preventScroll: true });
return false;
}
return true;
};
// 저장 버튼 클릭
const handleSave = () => {
if (!validate()) return;
setShowConfirm(true);
};
// 모달 확인 → 저장 실행
const handleConfirmSave = () => {
setShowConfirm(false);
toast.success("저장이 완료되었습니다.");
setTimeout(() => navigate("/messages"), 600);
};
// 입력 시 해당 필드 에러 제거
const clearError = (field: string) => {
if (errors[field]) {
setErrors((prev) => {
const next = { ...prev };
delete next[field];
return next;
});
}
};
return ( return (
<div className="p-6"> <div>
<h1 className="text-2xl font-bold"> </h1> <PageHeader
title="메시지 작성"
description="새로운 푸시 메시지를 작성하고 실시간 미리보기를 확인하세요."
/>
{/* 12-col 그리드 */}
<div className="grid grid-cols-12 gap-8">
{/* 좌측: 폼 (col-span-7) */}
<div className="col-span-7">
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
{/* 카드 헤더 */}
<div className="flex items-center gap-3 px-6 py-4 border-b border-gray-100 bg-gray-50">
<span className="material-symbols-outlined text-xl text-[#2563EB]">
edit_note
</span>
<h2 className="text-base font-bold text-[#0f172a]"> </h2>
</div>
{/* 폼 */}
<div className="p-6 space-y-6">
{/* 1. 서비스 선택 */}
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-2">
<span className="text-red-500">*</span>
</label>
<div className="relative">
<select
ref={serviceRef}
value={service}
onChange={(e) => {
setService(e.target.value);
clearError("service");
}}
className={`w-full px-4 py-2.5 bg-white border rounded text-sm focus:outline-none focus:ring-2 focus:ring-[#2563EB] focus:border-transparent transition-shadow cursor-pointer appearance-none ${
!service ? "text-gray-400" : "text-[#0f172a]"
} ${errors.service ? "border-red-500 ring-2 ring-red-500/15" : "border-gray-300"} ${cls("service")}`}
>
<option value="" disabled>
</option>
{SERVICE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<span
className="material-symbols-outlined absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 pointer-events-none"
style={{ fontSize: "20px" }}
>
expand_more
</span>
</div>
{errors.service && (
<p className="flex items-center gap-1 mt-1.5 text-red-500 text-xs">
<span
className="material-symbols-outlined"
style={{ fontSize: "14px" }}
>
error
</span>
{errors.service}
</p>
)}
</div>
{/* 2. 제목 */}
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-2">
<span className="text-red-500">*</span>
</label>
<input
ref={titleRef}
type="text"
value={title}
onChange={(e) => {
setTitle(e.target.value);
clearError("title");
}}
placeholder="메시지 제목을 입력하세요"
className={`w-full px-4 py-2.5 bg-white border rounded text-sm focus:outline-none focus:ring-2 focus:ring-[#2563EB] focus:border-transparent transition-shadow placeholder-gray-400 text-[#0f172a] ${
errors.title
? "border-red-500 ring-2 ring-red-500/15"
: "border-gray-300"
} ${cls("title")}`}
/>
{errors.title && (
<p className="flex items-center gap-1 mt-1.5 text-red-500 text-xs">
<span
className="material-symbols-outlined"
style={{ fontSize: "14px" }}
>
error
</span>
{errors.title}
</p>
)}
</div>
{/* 3. 내용 */}
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-2">
</label>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="메시지 내용을 입력하세요"
rows={6}
className="w-full px-4 py-2.5 bg-white border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-[#2563EB] focus:border-transparent transition-shadow placeholder-gray-400 text-[#0f172a] resize-none"
/>
</div>
{/* 4. 이미지 URL */}
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-2">
URL
</label>
<input
type="text"
value={imageUrl}
onChange={(e) => setImageUrl(e.target.value)}
placeholder="https://example.com/image.jpg"
className="w-full px-4 py-2.5 bg-white border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-[#2563EB] focus:border-transparent transition-shadow placeholder-gray-400 text-[#0f172a]"
/>
<p className="text-[11px] text-gray-400 mt-1.5 flex items-center gap-1">
<span
className="material-symbols-outlined flex-shrink-0"
style={{ fontSize: "14px" }}
>
info
</span>
URL을
.
</p>
</div>
{/* 5. 링크 URL + 라디오 */}
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-2">
URL
</label>
<input
type="text"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
placeholder="https://..."
className="w-full px-4 py-2.5 bg-white border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-[#2563EB] focus:border-transparent transition-shadow placeholder-gray-400 text-[#0f172a] mb-3"
/>
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="linkType"
value={LINK_TYPE.WEB}
checked={linkType === LINK_TYPE.WEB}
onChange={() => setLinkType(LINK_TYPE.WEB)}
className="w-4 h-4 text-[#2563EB] cursor-pointer"
/>
<span className="text-sm text-[#0f172a] font-medium">
URL
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="linkType"
value={LINK_TYPE.DEEPLINK}
checked={linkType === LINK_TYPE.DEEPLINK}
onChange={() => setLinkType(LINK_TYPE.DEEPLINK)}
className="w-4 h-4 text-[#2563EB] cursor-pointer"
/>
<span className="text-sm text-[#0f172a] font-medium">
</span>
</label>
</div>
</div>
{/* 6. 기타 정보 */}
<div>
<label className="block text-sm font-medium text-[#0f172a] mb-2">
</label>
<textarea
value={extra}
onChange={(e) => setExtra(e.target.value)}
placeholder="추가 정보를 입력하세요"
rows={6}
className="w-full px-4 py-2.5 bg-white border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-[#2563EB] focus:border-transparent transition-shadow placeholder-gray-400 text-[#0f172a] resize-none"
/>
</div>
{/* 저장 버튼 */}
<div className="pt-4 border-t border-gray-100">
<button
type="button"
onClick={handleSave}
className="flex items-center justify-center gap-2 bg-[#2563EB] hover:bg-[#1d4ed8] text-white px-6 py-2.5 rounded text-sm font-medium transition shadow-sm shadow-[#2563EB]/30 w-full"
>
</button>
</div>
</div>
</div>
</div>
{/* 우측: 프리뷰 (col-span-5, sticky) */}
<div className="col-span-5">
<div
className="sticky bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden p-6"
style={{ top: "6rem" }}
>
<MessagePreview
title={title}
body={body}
hasImage={!!imageUrl.trim()}
appName={SERVICE_OPTIONS.find((o) => o.value === service)?.label}
variant="large"
/>
</div>
</div>
</div>
{/* 확인 모달 */}
{showConfirm && (
<div
className="fixed inset-0 bg-black/40 z-[100] flex items-center justify-center"
onClick={(e) => {
if (e.target === e.currentTarget) setShowConfirm(false);
}}
>
<div className="bg-white rounded-xl shadow-2xl max-w-md w-full p-6 border border-gray-200">
<div className="flex items-center gap-3 mb-4">
<div className="size-10 rounded-full bg-amber-100 flex items-center justify-center flex-shrink-0">
<span className="material-symbols-outlined text-amber-600 text-xl">
warning
</span>
</div>
<h3 className="text-lg font-bold text-[#0f172a]"> </h3>
</div>
<p className="text-sm text-[#0f172a] mb-2">
?
</p>
<div className="bg-amber-50 border border-amber-200 rounded-lg px-4 py-3 mb-5">
<div className="flex items-center gap-1.5 text-amber-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={() => setShowConfirm(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={handleConfirmSave}
className="bg-[#2563EB] hover:bg-[#1d4ed8] text-white px-5 py-2.5 rounded text-sm font-medium transition shadow-sm shadow-[#2563EB]/30 flex items-center gap-2"
>
<span className="material-symbols-outlined text-base">
check
</span>
<span></span>
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@ -1 +1,138 @@
// Message feature 타입 정의 // 링크 타입
export const LINK_TYPE = {
WEB: "web",
DEEPLINK: "deeplink",
} as const;
export type LinkType = (typeof LINK_TYPE)[keyof typeof LINK_TYPE];
// 메시지 목록용 요약
export interface MessageSummary {
messageId: string;
title: string;
serviceName: string;
createdAt: string;
}
// 메시지 상세
export interface MessageDetail extends MessageSummary {
body: string;
imageUrl: string;
linkUrl: string;
linkType: LinkType;
extra: string;
}
// 메시지 작성 폼 데이터
export interface MessageFormData {
service: string;
title: string;
body: string;
imageUrl: string;
linkUrl: string;
linkType: LinkType;
extra: string;
}
// 서비스 옵션 (작성 폼 select용)
export const SERVICE_OPTIONS = [
{ value: "spms-shop", label: "SPMS 쇼핑몰" },
{ value: "spms-partner", label: "SPMS 파트너 센터" },
{ value: "spms-delivery", label: "SPMS 딜리버리" },
] as const;
// 서비스 필터 옵션 (목록 필터용)
export const SERVICE_FILTER_OPTIONS = [
"전체 서비스",
"Main App",
"Admin Portal",
"API Gateway",
];
// 목 데이터 - 메시지 목록
export const MOCK_MESSAGES: MessageSummary[] = [
{ messageId: "MSG-005", title: "[공지] 시스템 점검 안내 (2024-05-20)", serviceName: "Main App", createdAt: "2024-05-18" },
{ messageId: "MSG-004", title: "신규 가입 환영 웰컴 메시지", serviceName: "Admin Portal", createdAt: "2024-05-19" },
{ messageId: "MSG-003", title: "API 인증 오류 리포트 알림", serviceName: "API Gateway", createdAt: "2024-05-19" },
{ messageId: "MSG-002", title: "결제 시스템 업데이트 완료 안내", serviceName: "Main App", createdAt: "2024-05-17" },
{ messageId: "MSG-001", title: "[광고] 시즌 프로모션 혜택 안내", serviceName: "Main App", createdAt: "2024-05-16" },
{ messageId: "MSG-006", title: "배송 지연 안내 긴급 알림", serviceName: "Main App", createdAt: "2024-05-15" },
{ messageId: "MSG-007", title: "[이벤트] 출석 체크 보상 안내", serviceName: "Admin Portal", createdAt: "2024-05-14" },
{ messageId: "MSG-008", title: "서버 점검 완료 및 정상화 안내", serviceName: "API Gateway", createdAt: "2024-05-13" },
{ messageId: "MSG-009", title: "개인정보 처리방침 변경 안내", serviceName: "Main App", createdAt: "2024-05-12" },
{ messageId: "MSG-010", title: "[광고] 신규 회원 가입 혜택 안내", serviceName: "Admin Portal", createdAt: "2024-05-11" },
];
// 목 데이터 - 메시지 상세 (messageId → 상세 정보)
export const MOCK_MESSAGE_DETAILS: Record<string, MessageDetail> = {
"MSG-001": {
messageId: "MSG-001", title: "[광고] 시즌 프로모션 혜택 안내", serviceName: "Main App", createdAt: "2024-05-16",
body: "안녕하세요! 시즌 프로모션이 시작되었습니다. 지금 바로 참여하고 다양한 혜택을 받아보세요. 최대 50% 할인 쿠폰과 추가 적립 포인트가 제공됩니다.",
imageUrl: "https://cdn.spms.io/promo/summer-sale-banner.jpg",
linkUrl: "https://shop.spms.io/promo/summer2024", linkType: "web",
extra: "프로모션 기간: 2024-05-16 ~ 2024-06-30\n대상: 전체 회원\n쿠폰 코드: SUMMER50",
},
"MSG-002": {
messageId: "MSG-002", title: "결제 시스템 업데이트 완료 안내", serviceName: "Main App", createdAt: "2024-05-17",
body: "결제 시스템이 최신 버전으로 업데이트되었습니다. 새로운 결제 수단이 추가되었으며, 더 빠르고 안전한 결제 경험을 제공합니다.",
imageUrl: "",
linkUrl: "https://shop.spms.io/notice/payment-update", linkType: "web",
extra: "변경사항: 네이버페이, 카카오페이 간편결제 추가\nPG사: KG이니시스 v2.5 적용",
},
"MSG-003": {
messageId: "MSG-003", title: "API 인증 오류 리포트 알림", serviceName: "API Gateway", createdAt: "2024-05-19",
body: "API 인증 과정에서 오류가 감지되었습니다. 일부 클라이언트에서 토큰 갱신 실패가 발생하고 있으니 확인 부탁드립니다.",
imageUrl: "",
linkUrl: "spms://admin/api-monitor", linkType: "deeplink",
extra: "오류 코드: AUTH_TOKEN_EXPIRED\n영향 범위: v2.3 이하 클라이언트\n임시 조치: 수동 토큰 재발급 필요",
},
"MSG-004": {
messageId: "MSG-004", title: "신규 가입 환영 웰컴 메시지", serviceName: "Admin Portal", createdAt: "2024-05-19",
body: "환영합니다! SPMS 서비스에 가입해 주셔서 감사합니다. 신규 회원 혜택으로 7일간 프리미엄 기능을 무료로 이용하실 수 있습니다.",
imageUrl: "https://cdn.spms.io/welcome/onboarding-guide.png",
linkUrl: "spms://onboarding/start", linkType: "deeplink",
extra: "혜택 만료일: 가입일 기준 7일\n자동 전환: Basic 플랜",
},
"MSG-005": {
messageId: "MSG-005", title: "[공지] 시스템 점검 안내 (2024-05-20)", serviceName: "Main App", createdAt: "2024-05-18",
body: "서비스 안정성 개선을 위해 시스템 점검이 예정되어 있습니다. 점검 시간: 2024-05-20 02:00 ~ 06:00 (약 4시간). 해당 시간에는 서비스 이용이 제한됩니다.",
imageUrl: "",
linkUrl: "https://status.spms.io", linkType: "web",
extra: "점검 유형: 정기 점검\nDB 마이그레이션 포함\n긴급 연락: ops@spms.io",
},
"MSG-006": {
messageId: "MSG-006", title: "배송 지연 안내 긴급 알림", serviceName: "Main App", createdAt: "2024-05-15",
body: "고객님의 주문 상품 배송이 지연되고 있습니다. 물류 센터 사정으로 인해 1~2일 추가 소요될 수 있습니다. 불편을 드려 죄송합니다.",
imageUrl: "",
linkUrl: "spms://order/tracking", linkType: "deeplink",
extra: "지연 사유: 물류 센터 과부하\n보상: 500 포인트 자동 지급",
},
"MSG-007": {
messageId: "MSG-007", title: "[이벤트] 출석 체크 보상 안내", serviceName: "Admin Portal", createdAt: "2024-05-14",
body: "출석 체크 이벤트에 참여해 주셔서 감사합니다! 7일 연속 출석 보상으로 500 포인트가 지급되었습니다. 마이페이지에서 확인해 주세요.",
imageUrl: "https://cdn.spms.io/event/attendance-reward.png",
linkUrl: "spms://mypage/point", linkType: "deeplink",
extra: "이벤트 기간: 2024-05-01 ~ 2024-05-31\n추가 보너스: 30일 연속 출석 시 5,000P",
},
"MSG-008": {
messageId: "MSG-008", title: "서버 점검 완료 및 정상화 안내", serviceName: "API Gateway", createdAt: "2024-05-13",
body: "서버 정기 점검이 완료되었습니다. 모든 서비스가 정상적으로 운영되고 있습니다. 이용에 불편을 드려 죄송합니다.",
imageUrl: "",
linkUrl: "https://status.spms.io", linkType: "web",
extra: "점검 소요 시간: 3시간 42분\n적용 패치: v3.8.2",
},
"MSG-009": {
messageId: "MSG-009", title: "개인정보 처리방침 변경 안내", serviceName: "Main App", createdAt: "2024-05-12",
body: "개인정보 처리방침이 변경되었습니다. 주요 변경 사항: 수집 항목 변경, 보유 기간 조정. 자세한 내용은 설정 > 개인정보처리방침에서 확인해 주세요.",
imageUrl: "",
linkUrl: "https://shop.spms.io/privacy-policy", linkType: "web",
extra: "시행일: 2024-06-01\n주요 변경: 마케팅 수집 항목 세분화\n법적 근거: 개인정보보호법 제15조",
},
"MSG-010": {
messageId: "MSG-010", title: "[광고] 신규 회원 가입 혜택 안내", serviceName: "Admin Portal", createdAt: "2024-05-11",
body: "신규 회원 가입 시 다양한 혜택을 제공합니다. 가입 즉시 2,000원 할인 쿠폰과 무료 배송 쿠폰을 드립니다.",
imageUrl: "https://cdn.spms.io/ad/signup-benefit.jpg",
linkUrl: "https://shop.spms.io/signup", linkType: "web",
extra: "실패 사유: 수신 대상 토큰 만료\n재시도 예정: 수동 재발송 필요",
},
};