feat: 메시지 관리 페이지 API 연동 (#35)
All checks were successful
SPMS_BO/pipeline/head This commit looks good
All checks were successful
SPMS_BO/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_WEB/pulls/36
This commit is contained in:
commit
22c6be0002
58
react/src/api/message.api.ts
Normal file
58
react/src/api/message.api.ts
Normal 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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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재시도 예정: 수동 재발송 필요",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user