feat: 메시지 관리 페이지 API 연동 (#35) #36

Merged
seonkyu.kim merged 1 commits from feature/SPMS-35-message-api-integration into develop 2026-03-02 00:59:19 +00:00
6 changed files with 470 additions and 246 deletions

View File

@ -0,0 +1,58 @@
import { apiClient } from "./client";
import type { ApiResponse } from "@/types/api";
import type {
MessageListRequest,
MessageListResponse,
MessageInfoRequest,
MessageInfoResponse,
MessageSaveRequest,
MessageDeleteRequest,
MessageValidateRequest,
} from "@/features/message/types";
/** 메시지 목록 조회 */
export function fetchMessages(data: MessageListRequest) {
return apiClient.post<ApiResponse<MessageListResponse>>(
"/v1/in/message/list",
data,
);
}
/** 메시지 상세 조회 */
export function fetchMessageInfo(
data: MessageInfoRequest,
serviceCode: string,
) {
return apiClient.post<ApiResponse<MessageInfoResponse>>(
"/v1/in/message/info",
data,
{ headers: { "X-Service-Code": serviceCode } },
);
}
/** 메시지 저장 */
export function saveMessage(data: MessageSaveRequest, serviceCode: string) {
return apiClient.post<ApiResponse<null>>("/v1/in/message/save", data, {
headers: { "X-Service-Code": serviceCode },
});
}
/** 메시지 삭제 */
export function deleteMessage(
data: MessageDeleteRequest,
serviceCode: string,
) {
return apiClient.post<ApiResponse<null>>("/v1/in/message/delete", data, {
headers: { "X-Service-Code": serviceCode },
});
}
/** 메시지 검증 */
export function validateMessage(
data: MessageValidateRequest,
serviceCode: string,
) {
return apiClient.post<ApiResponse<null>>("/v1/in/message/validate", data, {
headers: { "X-Service-Code": serviceCode },
});
}

View File

@ -77,7 +77,7 @@ export default function MessagePreview({
{title || "메시지 제목을 입력하세요"}
</p>
<p className="text-[10px] text-gray-600 mt-0.5 break-words">
{body || "메시지 내용을 입력하세요"}
{body}
</p>
</div>
<div className="flex flex-col items-end gap-1 flex-shrink-0 self-start">
@ -162,7 +162,7 @@ export default function MessagePreview({
{title || "메시지 제목을 입력하세요"}
</p>
<p className="text-[8px] text-gray-600 mt-0.5 break-words">
{truncatedBody || "메시지 내용을 입력하세요"}
{truncatedBody}
</p>
</div>
<p className="text-[8px] text-gray-400 flex-shrink-0 self-start">
@ -221,7 +221,7 @@ export default function MessagePreview({
{title || "메시지 제목을 입력하세요"}
</p>
<p className="text-[10px] text-gray-600 mt-0.5 break-words">
{body || "메시지 내용을 입력하세요"}
{body}
</p>
</div>
{hasImage && (
@ -314,7 +314,7 @@ export default function MessagePreview({
{title || "메시지 제목을 입력하세요"}
</p>
<p className="text-[8px] text-gray-600 mt-0.5 break-words">
{truncatedBody || "메시지 내용을 입력하세요"}
{truncatedBody}
</p>
</div>
{hasImage && (

View File

@ -2,29 +2,57 @@ 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";
import { fetchMessageInfo, deleteMessage } from "@/api/message.api";
import type { MessageInfoResponse } from "../types";
interface MessageSlidePanelProps {
isOpen: boolean;
onClose: () => void;
messageId: string | null;
messageCode: string | null;
serviceCode: string | null;
}
export default function MessageSlidePanel({
isOpen,
onClose,
messageId,
messageCode,
serviceCode,
}: MessageSlidePanelProps) {
const detail = messageId ? MOCK_MESSAGE_DETAILS[messageId] : null;
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, messageId]);
}, [isOpen, messageCode]);
// ESC 닫기
useEffect(() => {
@ -47,6 +75,33 @@ export default function MessageSlidePanel({
};
}, [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 (
<>
{/* 오버레이 */}
@ -76,7 +131,17 @@ export default function MessageSlidePanel({
{/* 패널 본문 */}
<div ref={bodyRef} className="flex-1 overflow-y-auto p-6">
{detail ? (
{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">
@ -84,9 +149,9 @@ export default function MessageSlidePanel({
ID
</label>
<code className="text-sm font-medium text-[#2563EB] bg-[#2563EB]/5 px-2 py-0.5 rounded">
{detail.messageId}
{detail.message_code}
</code>
<CopyButton text={detail.messageId} />
<CopyButton text={detail.message_code ?? ""} />
</div>
<div className="h-px bg-gray-100" />
@ -97,7 +162,7 @@ export default function MessageSlidePanel({
</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}
{detail.service_name}
</p>
</div>
@ -128,12 +193,12 @@ export default function MessageSlidePanel({
</label>
<p
className={`w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded text-sm truncate ${
detail.imageUrl
detail.image_url
? "text-gray-500"
: "italic text-gray-400"
}`}
>
{detail.imageUrl || "등록된 이미지 없음"}
{detail.image_url || "등록된 이미지 없음"}
</p>
</div>
@ -145,16 +210,16 @@ export default function MessageSlidePanel({
</label>
<span
className={`text-[11px] font-medium px-1.5 py-0.5 rounded ${
detail.linkType === "deeplink"
detail.link_type === "deeplink"
? "bg-purple-100 text-purple-700"
: "bg-blue-100 text-blue-700"
}`}
>
{detail.linkType === "deeplink" ? "딥링크" : "웹 링크"}
{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.linkUrl || "—"}
{detail.link_url || "—"}
</p>
</div>
@ -164,7 +229,7 @@ export default function MessageSlidePanel({
</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 || "—"}
{extraText || "—"}
</div>
</div>
@ -176,10 +241,10 @@ export default function MessageSlidePanel({
</label>
<MessagePreview
title={detail.title}
body={detail.body}
hasImage={!!detail.imageUrl}
appName={detail.serviceName}
title={detail.title ?? ""}
body={detail.body ?? ""}
hasImage={!!detail.image_url}
appName={detail.service_name ?? ""}
variant="small"
/>
</div>
@ -220,7 +285,7 @@ export default function MessageSlidePanel({
<h3 className="text-lg font-bold text-[#0f172a]"> </h3>
</div>
<p className="text-sm text-[#0f172a] mb-2">
<strong>{detail.messageId}</strong> ?
<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">
@ -236,22 +301,20 @@ export default function MessageSlidePanel({
<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={() => {
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"
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></span>
<span>{deleting ? "삭제 중..." : "삭제"}</span>
</button>
</div>
</div>

View File

@ -1,4 +1,4 @@
import { useState, useMemo, useEffect } from "react";
import { useState, useCallback, useEffect } from "react";
import { Link, useSearchParams } from "react-router-dom";
import PageHeader from "@/components/common/PageHeader";
import SearchInput from "@/components/common/SearchInput";
@ -9,7 +9,9 @@ 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";
import { fetchMessages } from "@/api/message.api";
import { fetchServices } from "@/api/service.api";
import type { MessageListItem } from "../types";
const PAGE_SIZE = 10;
@ -26,6 +28,84 @@ export default function MessageListPage() {
const [appliedSearch, setAppliedSearch] = useState("");
const [appliedService, setAppliedService] = useState("전체 서비스");
// 데이터 상태
const [items, setItems] = useState<MessageListItem[]>([]);
const [totalItems, setTotalItems] = useState(0);
const [totalPages, setTotalPages] = useState(1);
// 서비스 필터 옵션
const [serviceFilterOptions, setServiceFilterOptions] = useState<string[]>([
"전체 서비스",
]);
const [serviceCodeMap, setServiceCodeMap] = useState<
Record<string, string>
>({});
// 슬라이드 패널 상태
const [panelOpen, setPanelOpen] = useState(false);
const [selectedMessageCode, setSelectedMessageCode] = useState<string | null>(
null,
);
const [selectedServiceCode, setSelectedServiceCode] = useState<string | null>(
null,
);
// 서비스 목록 로드
useEffect(() => {
(async () => {
try {
const res = await fetchServices({ page: 1, pageSize: 100 });
const svcItems = res.data.data.items ?? [];
const names = svcItems.map((s) => s.serviceName);
setServiceFilterOptions(["전체 서비스", ...names]);
const codeMap: Record<string, string> = {};
svcItems.forEach((s) => {
codeMap[s.serviceName] = s.serviceCode;
});
setServiceCodeMap(codeMap);
} catch {
// 서비스 목록 로드 실패 시 기본값 유지
}
})();
}, []);
// 데이터 로드
const loadData = useCallback(
async (page: number, keyword: string, serviceName: string) => {
setLoading(true);
try {
// 서비스명 → 서비스코드 변환
const serviceCode =
serviceName !== "전체 서비스"
? serviceCodeMap[serviceName] || undefined
: undefined;
const res = await fetchMessages({
page,
size: PAGE_SIZE,
keyword: keyword || undefined,
service_code: serviceCode,
});
const data = res.data.data;
setItems(data.items ?? []);
setTotalItems(data.totalCount ?? 0);
setTotalPages(data.totalPages ?? 1);
} catch {
setItems([]);
setTotalItems(0);
setTotalPages(1);
} finally {
setLoading(false);
}
},
[serviceCodeMap],
);
// 초기 로드
useEffect(() => {
loadData(1, "", "전체 서비스");
}, [loadData]);
// URL 쿼리 파라미터로 messageId가 넘어온 경우 자동 검색
useEffect(() => {
const messageId = searchParams.get("messageId");
@ -33,24 +113,16 @@ export default function MessageListPage() {
setSearch(messageId);
setAppliedSearch(messageId);
setSearchParams({}, { replace: true });
loadData(1, messageId, "전체 서비스");
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// 슬라이드 패널 상태
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);
setAppliedSearch(search);
setAppliedService(serviceFilter);
setCurrentPage(1);
loadData(1, search, serviceFilter);
};
// 필터 초기화
@ -60,41 +132,19 @@ export default function MessageListPage() {
setAppliedSearch("");
setAppliedService("전체 서비스");
setCurrentPage(1);
loadData(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 handlePageChange = (page: number) => {
setCurrentPage(page);
loadData(page, appliedSearch, appliedService);
};
// 행 클릭 → 슬라이드 패널
const handleRowClick = (messageId: string) => {
setSelectedMessageId(messageId);
const handleRowClick = (item: MessageListItem) => {
setSelectedMessageCode(item.message_code);
setSelectedServiceCode(item.service_code);
setPanelOpen(true);
};
@ -127,7 +177,7 @@ export default function MessageListPage() {
<FilterDropdown
label="서비스 구분"
value={serviceFilter}
options={SERVICE_FILTER_OPTIONS}
options={serviceFilterOptions}
onChange={setServiceFilter}
className="w-[140px] flex-shrink-0"
disabled={loading}
@ -187,7 +237,7 @@ export default function MessageListPage() {
</tbody>
</table>
</div>
) : paged.length > 0 ? (
) : items.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">
@ -208,11 +258,11 @@ export default function MessageListPage() {
</tr>
</thead>
<tbody>
{paged.map((msg, idx) => (
{items.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)}
key={msg.message_code ?? idx}
className={`${idx < items.length - 1 ? "border-b border-gray-100" : ""} hover:bg-gray-50/50 transition-colors cursor-pointer`}
onClick={() => handleRowClick(msg)}
>
{/* 메시지 ID */}
<td className="px-6 py-4 text-center">
@ -221,9 +271,9 @@ export default function MessageListPage() {
onClick={(e) => e.stopPropagation()}
>
<code className="text-sm text-gray-700 font-medium">
{msg.messageId}
{msg.message_code}
</code>
<CopyButton text={msg.messageId} />
<CopyButton text={msg.message_code ?? ""} />
</div>
</td>
{/* 메시지 제목 */}
@ -235,13 +285,13 @@ export default function MessageListPage() {
{/* 서비스 */}
<td className="px-6 py-4 text-center">
<span className="text-sm text-gray-600">
{msg.serviceName}
{msg.service_name}
</span>
</td>
{/* 작성일 */}
<td className="px-6 py-4 text-center">
<span className="text-sm text-gray-500">
{formatDate(msg.createdAt)}
{formatDate(msg.created_at ?? "")}
</span>
</td>
</tr>
@ -256,7 +306,7 @@ export default function MessageListPage() {
totalPages={totalPages}
totalItems={totalItems}
pageSize={PAGE_SIZE}
onPageChange={setCurrentPage}
onPageChange={handlePageChange}
/>
</div>
) : (
@ -270,8 +320,13 @@ export default function MessageListPage() {
{/* 슬라이드 패널 */}
<MessageSlidePanel
isOpen={panelOpen}
onClose={() => setPanelOpen(false)}
messageId={selectedMessageId}
onClose={() => {
setPanelOpen(false);
// 삭제 후 목록 새로고침
loadData(currentPage, appliedSearch, appliedService);
}}
messageCode={selectedMessageCode}
serviceCode={selectedServiceCode}
/>
</div>
);

View File

@ -1,16 +1,26 @@
import { useState, useRef } from "react";
import { useState, useRef, useEffect } 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 { LINK_TYPE } from "../types";
import type { LinkType } from "../types";
import { fetchServices } from "@/api/service.api";
import { validateMessage, saveMessage } from "@/api/message.api";
interface ServiceOption {
value: string;
label: string;
}
export default function MessageRegisterPage() {
const navigate = useNavigate();
const { triggerShake, cls } = useShake();
// 서비스 옵션 (API에서 로드)
const [serviceOptions, setServiceOptions] = useState<ServiceOption[]>([]);
// 폼 상태
const [service, setService] = useState("");
const [title, setTitle] = useState("");
@ -20,8 +30,12 @@ export default function MessageRegisterPage() {
const [linkType, setLinkType] = useState<LinkType>(LINK_TYPE.WEB);
const [extra, setExtra] = useState("");
// 서비스 드롭다운 상태
const [serviceOpen, setServiceOpen] = useState(false);
const serviceDropdownRef = useRef<HTMLDivElement>(null);
// 필드 ref (스크롤용)
const serviceRef = useRef<HTMLSelectElement>(null);
const serviceRef = useRef<HTMLButtonElement>(null);
const titleRef = useRef<HTMLInputElement>(null);
// 에러 메시지 상태 (shake와 별도로 유지)
@ -29,6 +43,36 @@ export default function MessageRegisterPage() {
// 확인 모달 상태
const [showConfirm, setShowConfirm] = useState(false);
const [saving, setSaving] = useState(false);
// 서비스 드롭다운 외부 클릭 닫기
useEffect(() => {
function handleClick(e: MouseEvent) {
if (
serviceDropdownRef.current &&
!serviceDropdownRef.current.contains(e.target as Node)
) {
setServiceOpen(false);
}
}
document.addEventListener("click", handleClick);
return () => document.removeEventListener("click", handleClick);
}, []);
// 서비스 옵션 로드
useEffect(() => {
(async () => {
try {
const res = await fetchServices({ page: 1, pageSize: 100 });
const items = res.data.data.items ?? [];
setServiceOptions(
items.map((s) => ({ value: s.serviceCode, label: s.serviceName })),
);
} catch {
// 로드 실패 시 빈 배열 유지
}
})();
}, []);
// 필수 필드 검증
const validate = (): boolean => {
@ -63,10 +107,44 @@ export default function MessageRegisterPage() {
};
// 모달 확인 → 저장 실행
const handleConfirmSave = () => {
setShowConfirm(false);
toast.success("저장이 완료되었습니다.");
setTimeout(() => navigate("/messages"), 600);
const handleConfirmSave = async () => {
setSaving(true);
try {
// 서버 검증
await validateMessage(
{
title,
body,
image_url: imageUrl || null,
link_url: linkUrl || null,
link_type: linkType || null,
data: extra || undefined,
},
service,
);
// 저장
await saveMessage(
{
title,
body: body || null,
image_url: imageUrl || null,
link_url: linkUrl || null,
link_type: linkType || null,
data: extra || null,
},
service,
);
setShowConfirm(false);
toast.success("저장이 완료되었습니다.");
setTimeout(() => navigate("/messages"), 600);
} catch {
setShowConfirm(false);
toast.error("저장에 실패했습니다.");
} finally {
setSaving(false);
}
};
// 입력 시 해당 필드 에러 제거
@ -107,33 +185,46 @@ export default function MessageRegisterPage() {
<label className="block text-sm font-medium text-[#0f172a] mb-2">
<span className="text-red-500">*</span>
</label>
<div className="relative">
<select
<div className="relative" ref={serviceDropdownRef}>
<button
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 ${
type="button"
onClick={() => setServiceOpen((v) => !v)}
className={`w-full h-[42px] border rounded px-4 text-sm flex items-center justify-between bg-white hover:border-gray-400 transition-colors cursor-pointer ${
!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>
<span className="truncate">
{service
? serviceOptions.find((o) => o.value === service)
?.label
: "서비스를 선택하세요"}
</span>
<span className="material-symbols-outlined text-gray-400 text-[18px] flex-shrink-0">
expand_more
</span>
</button>
{serviceOpen && (
<ul className="absolute left-0 top-full mt-1 w-full bg-white border border-gray-200 rounded-lg shadow-lg z-50 py-1 overflow-hidden">
{serviceOptions.map((opt) => (
<li
key={opt.value}
onClick={() => {
setService(opt.value);
clearError("service");
setServiceOpen(false);
}}
className={`px-4 py-2.5 text-sm hover:bg-gray-50 cursor-pointer transition-colors ${
opt.value === service
? "text-[#2563EB] font-medium"
: "text-[#0f172a]"
}`}
>
{opt.label}
</li>
))}
</ul>
)}
</div>
{errors.service && (
<p className="flex items-center gap-1 mt-1.5 text-red-500 text-xs">
@ -299,7 +390,7 @@ export default function MessageRegisterPage() {
title={title}
body={body}
hasImage={!!imageUrl.trim()}
appName={SERVICE_OPTIONS.find((o) => o.value === service)?.label}
appName={serviceOptions.find((o) => o.value === service)?.label}
variant="large"
/>
</div>
@ -340,18 +431,20 @@ export default function MessageRegisterPage() {
<div className="flex justify-end gap-3">
<button
onClick={() => setShowConfirm(false)}
disabled={saving}
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"
disabled={saving}
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 disabled:opacity-50"
>
<span className="material-symbols-outlined text-base">
check
</span>
<span></span>
<span>{saving ? "저장 중..." : "확인"}</span>
</button>
</div>
</div>

View File

@ -6,133 +6,88 @@ export const LINK_TYPE = {
export type LinkType = (typeof LINK_TYPE)[keyof typeof LINK_TYPE];
// 메시지 목록용 요약
export interface MessageSummary {
messageId: string;
title: string;
serviceName: string;
createdAt: string;
// ── 목록 ──
/** 목록 요청 */
export interface MessageListRequest {
page: number;
size: number;
keyword?: string | null;
is_active?: boolean | null;
service_code?: string | null;
send_status?: string | null;
}
// 메시지 상세
export interface MessageDetail extends MessageSummary {
body: string;
imageUrl: string;
linkUrl: string;
linkType: LinkType;
extra: string;
/** 목록 응답 아이템 */
export interface MessageListItem {
message_code: string | null;
title: string | null;
service_name: string | null;
service_code: string | null;
send_status: string | null;
created_at: string | null;
is_active: boolean;
}
// 메시지 작성 폼 데이터
export interface MessageFormData {
service: string;
/** 목록 응답 */
export interface MessageListResponse {
items: MessageListItem[] | null;
totalCount: number;
page: number;
size: number;
totalPages: number;
}
// ── 상세 ──
/** 상세 요청 */
export interface MessageInfoRequest {
message_code: string | null;
}
/** 상세 응답 */
export interface MessageInfoResponse {
message_code: string | null;
title: string | null;
body: string | null;
image_url: string | null;
link_url: string | null;
link_type: string | null;
data: unknown;
service_name: string | null;
service_code: string | null;
created_by_name: string | null;
latest_send_status: string | null;
created_at: string | null;
}
// ── 저장 ──
/** 저장 요청 */
export interface MessageSaveRequest {
title: string | null;
body: string | null;
image_url: string | null;
link_url: string | null;
link_type: string | null;
data: unknown;
}
// ── 삭제 ──
/** 삭제 요청 */
export interface MessageDeleteRequest {
message_code: string | null;
}
// ── 검증 ──
/** 검증 요청 */
export interface MessageValidateRequest {
title: string;
body: string;
imageUrl: string;
linkUrl: string;
linkType: LinkType;
extra: string;
image_url?: string | null;
link_url?: string | null;
link_type?: string | null;
data?: unknown;
}
// 서비스 옵션 (작성 폼 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재시도 예정: 수동 재발송 필요",
},
};