SPMS_WEB/react/src/features/message/pages/MessageListPage.tsx
SEAN 530e92240a feat: 발송 관리 페이지 구현 (#23)
- 발송 통계 페이지 (StatisticsPage): 4개 통계 카드, 월간 추이 라인 차트, 플랫폼별 도넛 차트, 시간대별 바 차트, 최근 이력 테이블, 오픈율 Top5
- 발송 이력 페이지 (StatisticsHistoryPage): 검색/서비스/상태/날짜 필터, 발송 이력 테이블, 행 클릭 슬라이드 패널 (발송 상세), 페이지네이션
- 타입 정의 + 목 데이터 15건 (types.ts)
- 브레드크럼 그룹 라벨 처리 (발송 관리 > 발송 통계/발송 이력)
- 날짜 필터 기본값: 오늘 기준 1달 전 ~ 오늘
- 대시보드와 카드/차트 스타일 통일
- 메시지 목록 연동: 슬라이드 패널에서 messageId 쿼리 파라미터로 이동

Closes #23
2026-02-27 23:31:13 +09:00

279 lines
10 KiB
TypeScript

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