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
This commit is contained in:
SEAN 2026-03-02 09:51:02 +09:00
parent ad6010320a
commit 2549930a5a
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 || "메시지 제목을 입력하세요"} {title || "메시지 제목을 입력하세요"}
</p> </p>
<p className="text-[10px] text-gray-600 mt-0.5 break-words"> <p className="text-[10px] text-gray-600 mt-0.5 break-words">
{body || "메시지 내용을 입력하세요"} {body}
</p> </p>
</div> </div>
<div className="flex flex-col items-end gap-1 flex-shrink-0 self-start"> <div className="flex flex-col items-end gap-1 flex-shrink-0 self-start">
@ -162,7 +162,7 @@ export default function MessagePreview({
{title || "메시지 제목을 입력하세요"} {title || "메시지 제목을 입력하세요"}
</p> </p>
<p className="text-[8px] text-gray-600 mt-0.5 break-words"> <p className="text-[8px] text-gray-600 mt-0.5 break-words">
{truncatedBody || "메시지 내용을 입력하세요"} {truncatedBody}
</p> </p>
</div> </div>
<p className="text-[8px] text-gray-400 flex-shrink-0 self-start"> <p className="text-[8px] text-gray-400 flex-shrink-0 self-start">
@ -221,7 +221,7 @@ export default function MessagePreview({
{title || "메시지 제목을 입력하세요"} {title || "메시지 제목을 입력하세요"}
</p> </p>
<p className="text-[10px] text-gray-600 mt-0.5 break-words"> <p className="text-[10px] text-gray-600 mt-0.5 break-words">
{body || "메시지 내용을 입력하세요"} {body}
</p> </p>
</div> </div>
{hasImage && ( {hasImage && (
@ -314,7 +314,7 @@ export default function MessagePreview({
{title || "메시지 제목을 입력하세요"} {title || "메시지 제목을 입력하세요"}
</p> </p>
<p className="text-[8px] text-gray-600 mt-0.5 break-words"> <p className="text-[8px] text-gray-600 mt-0.5 break-words">
{truncatedBody || "메시지 내용을 입력하세요"} {truncatedBody}
</p> </p>
</div> </div>
{hasImage && ( {hasImage && (

View File

@ -2,29 +2,57 @@ import { useEffect, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import CopyButton from "@/components/common/CopyButton"; import CopyButton from "@/components/common/CopyButton";
import MessagePreview from "./MessagePreview"; import MessagePreview from "./MessagePreview";
import { MOCK_MESSAGE_DETAILS } from "../types"; import { fetchMessageInfo, deleteMessage } from "@/api/message.api";
import type { MessageInfoResponse } from "../types";
interface MessageSlidePanelProps { interface MessageSlidePanelProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
messageId: string | null; messageCode: string | null;
serviceCode: string | null;
} }
export default function MessageSlidePanel({ export default function MessageSlidePanel({
isOpen, isOpen,
onClose, onClose,
messageId, messageCode,
serviceCode,
}: MessageSlidePanelProps) { }: 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 [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleting, setDeleting] = useState(false);
const bodyRef = useRef<HTMLDivElement>(null); 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(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
bodyRef.current?.scrollTo(0, 0); bodyRef.current?.scrollTo(0, 0);
} }
}, [isOpen, messageId]); }, [isOpen, messageCode]);
// ESC 닫기 // ESC 닫기
useEffect(() => { useEffect(() => {
@ -47,6 +75,33 @@ export default function MessageSlidePanel({
}; };
}, [isOpen]); }, [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 ( return (
<> <>
{/* 오버레이 */} {/* 오버레이 */}
@ -76,7 +131,17 @@ export default function MessageSlidePanel({
{/* 패널 본문 */} {/* 패널 본문 */}
<div ref={bodyRef} className="flex-1 overflow-y-auto p-6"> <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"> <div className="space-y-5">
{/* 메시지 ID */} {/* 메시지 ID */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@ -84,9 +149,9 @@ export default function MessageSlidePanel({
ID ID
</label> </label>
<code className="text-sm font-medium text-[#2563EB] bg-[#2563EB]/5 px-2 py-0.5 rounded"> <code className="text-sm font-medium text-[#2563EB] bg-[#2563EB]/5 px-2 py-0.5 rounded">
{detail.messageId} {detail.message_code}
</code> </code>
<CopyButton text={detail.messageId} /> <CopyButton text={detail.message_code ?? ""} />
</div> </div>
<div className="h-px bg-gray-100" /> <div className="h-px bg-gray-100" />
@ -97,7 +162,7 @@ export default function MessageSlidePanel({
</label> </label>
<p className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded text-sm text-gray-700"> <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> </p>
</div> </div>
@ -128,12 +193,12 @@ export default function MessageSlidePanel({
</label> </label>
<p <p
className={`w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded text-sm truncate ${ 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" ? "text-gray-500"
: "italic text-gray-400" : "italic text-gray-400"
}`} }`}
> >
{detail.imageUrl || "등록된 이미지 없음"} {detail.image_url || "등록된 이미지 없음"}
</p> </p>
</div> </div>
@ -145,16 +210,16 @@ export default function MessageSlidePanel({
</label> </label>
<span <span
className={`text-[11px] font-medium px-1.5 py-0.5 rounded ${ 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-purple-100 text-purple-700"
: "bg-blue-100 text-blue-700" : "bg-blue-100 text-blue-700"
}`} }`}
> >
{detail.linkType === "deeplink" ? "딥링크" : "웹 링크"} {detail.link_type === "deeplink" ? "딥링크" : "웹 링크"}
</span> </span>
</div> </div>
<p className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded text-sm text-gray-500 truncate"> <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> </p>
</div> </div>
@ -164,7 +229,7 @@ export default function MessageSlidePanel({
</label> </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"> <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>
</div> </div>
@ -176,10 +241,10 @@ export default function MessageSlidePanel({
</label> </label>
<MessagePreview <MessagePreview
title={detail.title} title={detail.title ?? ""}
body={detail.body} body={detail.body ?? ""}
hasImage={!!detail.imageUrl} hasImage={!!detail.image_url}
appName={detail.serviceName} appName={detail.service_name ?? ""}
variant="small" variant="small"
/> />
</div> </div>
@ -220,7 +285,7 @@ export default function MessageSlidePanel({
<h3 className="text-lg font-bold text-[#0f172a]"> </h3> <h3 className="text-lg font-bold text-[#0f172a]"> </h3>
</div> </div>
<p className="text-sm text-[#0f172a] mb-2"> <p className="text-sm text-[#0f172a] mb-2">
<strong>{detail.messageId}</strong> ? <strong>{detail.message_code}</strong> ?
</p> </p>
<div className="bg-red-50 border border-red-200 rounded-lg px-4 py-3 mb-5"> <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"> <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"> <div className="flex justify-end gap-3">
<button <button
onClick={() => setShowDeleteConfirm(false)} 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" 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>
<button <button
onClick={() => { onClick={handleDelete}
toast.success(`${detail.messageId} 메시지가 삭제되었습니다.`); disabled={deleting}
setShowDeleteConfirm(false); 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"
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"> <span className="material-symbols-outlined text-base">
delete delete
</span> </span>
<span></span> <span>{deleting ? "삭제 중..." : "삭제"}</span>
</button> </button>
</div> </div>
</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 { Link, useSearchParams } from "react-router-dom";
import PageHeader from "@/components/common/PageHeader"; import PageHeader from "@/components/common/PageHeader";
import SearchInput from "@/components/common/SearchInput"; import SearchInput from "@/components/common/SearchInput";
@ -9,7 +9,9 @@ import EmptyState from "@/components/common/EmptyState";
import CopyButton from "@/components/common/CopyButton"; import CopyButton from "@/components/common/CopyButton";
import MessageSlidePanel from "../components/MessageSlidePanel"; import MessageSlidePanel from "../components/MessageSlidePanel";
import { formatDate } from "@/utils/format"; 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; const PAGE_SIZE = 10;
@ -26,6 +28,84 @@ export default function MessageListPage() {
const [appliedSearch, setAppliedSearch] = useState(""); const [appliedSearch, setAppliedSearch] = useState("");
const [appliedService, setAppliedService] = 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가 넘어온 경우 자동 검색 // URL 쿼리 파라미터로 messageId가 넘어온 경우 자동 검색
useEffect(() => { useEffect(() => {
const messageId = searchParams.get("messageId"); const messageId = searchParams.get("messageId");
@ -33,24 +113,16 @@ export default function MessageListPage() {
setSearch(messageId); setSearch(messageId);
setAppliedSearch(messageId); setAppliedSearch(messageId);
setSearchParams({}, { replace: true }); setSearchParams({}, { replace: true });
loadData(1, messageId, "전체 서비스");
} }
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, []); // eslint-disable-line react-hooks/exhaustive-deps
// 슬라이드 패널 상태
const [panelOpen, setPanelOpen] = useState(false);
const [selectedMessageId, setSelectedMessageId] = useState<string | null>(
null
);
// 조회 버튼 // 조회 버튼
const handleQuery = () => { const handleQuery = () => {
setLoading(true); setAppliedSearch(search);
setTimeout(() => { setAppliedService(serviceFilter);
setAppliedSearch(search); setCurrentPage(1);
setAppliedService(serviceFilter); loadData(1, search, serviceFilter);
setCurrentPage(1);
setLoading(false);
}, 400);
}; };
// 필터 초기화 // 필터 초기화
@ -60,41 +132,19 @@ export default function MessageListPage() {
setAppliedSearch(""); setAppliedSearch("");
setAppliedService("전체 서비스"); setAppliedService("전체 서비스");
setCurrentPage(1); setCurrentPage(1);
loadData(1, "", "전체 서비스");
}; };
// 필터링된 데이터 // 페이지 변경
const filtered = useMemo(() => { const handlePageChange = (page: number) => {
return MOCK_MESSAGES.filter((msg) => { setCurrentPage(page);
// 검색 (메시지 ID / 제목) loadData(page, appliedSearch, appliedService);
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) => { const handleRowClick = (item: MessageListItem) => {
setSelectedMessageId(messageId); setSelectedMessageCode(item.message_code);
setSelectedServiceCode(item.service_code);
setPanelOpen(true); setPanelOpen(true);
}; };
@ -127,7 +177,7 @@ export default function MessageListPage() {
<FilterDropdown <FilterDropdown
label="서비스 구분" label="서비스 구분"
value={serviceFilter} value={serviceFilter}
options={SERVICE_FILTER_OPTIONS} options={serviceFilterOptions}
onChange={setServiceFilter} onChange={setServiceFilter}
className="w-[140px] flex-shrink-0" className="w-[140px] flex-shrink-0"
disabled={loading} disabled={loading}
@ -187,7 +237,7 @@ export default function MessageListPage() {
</tbody> </tbody>
</table> </table>
</div> </div>
) : paged.length > 0 ? ( ) : items.length > 0 ? (
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden shadow-sm"> <div className="bg-white border border-gray-200 rounded-lg overflow-hidden shadow-sm">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full"> <table className="w-full">
@ -208,11 +258,11 @@ export default function MessageListPage() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{paged.map((msg, idx) => ( {items.map((msg, idx) => (
<tr <tr
key={msg.messageId} key={msg.message_code ?? idx}
className={`${idx < paged.length - 1 ? "border-b border-gray-100" : ""} hover:bg-gray-50/50 transition-colors cursor-pointer`} className={`${idx < items.length - 1 ? "border-b border-gray-100" : ""} hover:bg-gray-50/50 transition-colors cursor-pointer`}
onClick={() => handleRowClick(msg.messageId)} onClick={() => handleRowClick(msg)}
> >
{/* 메시지 ID */} {/* 메시지 ID */}
<td className="px-6 py-4 text-center"> <td className="px-6 py-4 text-center">
@ -221,9 +271,9 @@ export default function MessageListPage() {
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<code className="text-sm text-gray-700 font-medium"> <code className="text-sm text-gray-700 font-medium">
{msg.messageId} {msg.message_code}
</code> </code>
<CopyButton text={msg.messageId} /> <CopyButton text={msg.message_code ?? ""} />
</div> </div>
</td> </td>
{/* 메시지 제목 */} {/* 메시지 제목 */}
@ -235,13 +285,13 @@ export default function MessageListPage() {
{/* 서비스 */} {/* 서비스 */}
<td className="px-6 py-4 text-center"> <td className="px-6 py-4 text-center">
<span className="text-sm text-gray-600"> <span className="text-sm text-gray-600">
{msg.serviceName} {msg.service_name}
</span> </span>
</td> </td>
{/* 작성일 */} {/* 작성일 */}
<td className="px-6 py-4 text-center"> <td className="px-6 py-4 text-center">
<span className="text-sm text-gray-500"> <span className="text-sm text-gray-500">
{formatDate(msg.createdAt)} {formatDate(msg.created_at ?? "")}
</span> </span>
</td> </td>
</tr> </tr>
@ -256,7 +306,7 @@ export default function MessageListPage() {
totalPages={totalPages} totalPages={totalPages}
totalItems={totalItems} totalItems={totalItems}
pageSize={PAGE_SIZE} pageSize={PAGE_SIZE}
onPageChange={setCurrentPage} onPageChange={handlePageChange}
/> />
</div> </div>
) : ( ) : (
@ -270,8 +320,13 @@ export default function MessageListPage() {
{/* 슬라이드 패널 */} {/* 슬라이드 패널 */}
<MessageSlidePanel <MessageSlidePanel
isOpen={panelOpen} isOpen={panelOpen}
onClose={() => setPanelOpen(false)} onClose={() => {
messageId={selectedMessageId} setPanelOpen(false);
// 삭제 후 목록 새로고침
loadData(currentPage, appliedSearch, appliedService);
}}
messageCode={selectedMessageCode}
serviceCode={selectedServiceCode}
/> />
</div> </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 { useNavigate } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import PageHeader from "@/components/common/PageHeader"; import PageHeader from "@/components/common/PageHeader";
import useShake from "@/hooks/useShake"; import useShake from "@/hooks/useShake";
import MessagePreview from "../components/MessagePreview"; import MessagePreview from "../components/MessagePreview";
import { SERVICE_OPTIONS, LINK_TYPE } from "../types"; import { LINK_TYPE } from "../types";
import type { LinkType } 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() { export default function MessageRegisterPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { triggerShake, cls } = useShake(); const { triggerShake, cls } = useShake();
// 서비스 옵션 (API에서 로드)
const [serviceOptions, setServiceOptions] = useState<ServiceOption[]>([]);
// 폼 상태 // 폼 상태
const [service, setService] = useState(""); const [service, setService] = useState("");
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
@ -20,8 +30,12 @@ export default function MessageRegisterPage() {
const [linkType, setLinkType] = useState<LinkType>(LINK_TYPE.WEB); const [linkType, setLinkType] = useState<LinkType>(LINK_TYPE.WEB);
const [extra, setExtra] = useState(""); const [extra, setExtra] = useState("");
// 서비스 드롭다운 상태
const [serviceOpen, setServiceOpen] = useState(false);
const serviceDropdownRef = useRef<HTMLDivElement>(null);
// 필드 ref (스크롤용) // 필드 ref (스크롤용)
const serviceRef = useRef<HTMLSelectElement>(null); const serviceRef = useRef<HTMLButtonElement>(null);
const titleRef = useRef<HTMLInputElement>(null); const titleRef = useRef<HTMLInputElement>(null);
// 에러 메시지 상태 (shake와 별도로 유지) // 에러 메시지 상태 (shake와 별도로 유지)
@ -29,6 +43,36 @@ export default function MessageRegisterPage() {
// 확인 모달 상태 // 확인 모달 상태
const [showConfirm, setShowConfirm] = useState(false); 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 => { const validate = (): boolean => {
@ -63,10 +107,44 @@ export default function MessageRegisterPage() {
}; };
// 모달 확인 → 저장 실행 // 모달 확인 → 저장 실행
const handleConfirmSave = () => { const handleConfirmSave = async () => {
setShowConfirm(false); setSaving(true);
toast.success("저장이 완료되었습니다."); try {
setTimeout(() => navigate("/messages"), 600); // 서버 검증
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"> <label className="block text-sm font-medium text-[#0f172a] mb-2">
<span className="text-red-500">*</span> <span className="text-red-500">*</span>
</label> </label>
<div className="relative"> <div className="relative" ref={serviceDropdownRef}>
<select <button
ref={serviceRef} ref={serviceRef}
value={service} type="button"
onChange={(e) => { onClick={() => setServiceOpen((v) => !v)}
setService(e.target.value); 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 ${
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]" !service ? "text-gray-400" : "text-[#0f172a]"
} ${errors.service ? "border-red-500 ring-2 ring-red-500/15" : "border-gray-300"} ${cls("service")}`} } ${errors.service ? "border-red-500 ring-2 ring-red-500/15" : "border-gray-300"} ${cls("service")}`}
> >
<option value="" disabled> <span className="truncate">
{service
</option> ? serviceOptions.find((o) => o.value === service)
{SERVICE_OPTIONS.map((opt) => ( ?.label
<option key={opt.value} value={opt.value}> : "서비스를 선택하세요"}
{opt.label} </span>
</option> <span className="material-symbols-outlined text-gray-400 text-[18px] flex-shrink-0">
))} expand_more
</select> </span>
<span </button>
className="material-symbols-outlined absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 pointer-events-none" {serviceOpen && (
style={{ fontSize: "20px" }} <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) => (
expand_more <li
</span> 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> </div>
{errors.service && ( {errors.service && (
<p className="flex items-center gap-1 mt-1.5 text-red-500 text-xs"> <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} title={title}
body={body} body={body}
hasImage={!!imageUrl.trim()} hasImage={!!imageUrl.trim()}
appName={SERVICE_OPTIONS.find((o) => o.value === service)?.label} appName={serviceOptions.find((o) => o.value === service)?.label}
variant="large" variant="large"
/> />
</div> </div>
@ -340,18 +431,20 @@ export default function MessageRegisterPage() {
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<button <button
onClick={() => setShowConfirm(false)} 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" 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>
<button <button
onClick={handleConfirmSave} 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"> <span className="material-symbols-outlined text-base">
check check
</span> </span>
<span></span> <span>{saving ? "저장 중..." : "확인"}</span>
</button> </button>
</div> </div>
</div> </div>

View File

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