- 발송 통계 페이지 (StatisticsPage): 4개 통계 카드, 월간 추이 라인 차트, 플랫폼별 도넛 차트, 시간대별 바 차트, 최근 이력 테이블, 오픈율 Top5 - 발송 이력 페이지 (StatisticsHistoryPage): 검색/서비스/상태/날짜 필터, 발송 이력 테이블, 행 클릭 슬라이드 패널 (발송 상세), 페이지네이션 - 타입 정의 + 목 데이터 15건 (types.ts) - 브레드크럼 그룹 라벨 처리 (발송 관리 > 발송 통계/발송 이력) - 날짜 필터 기본값: 오늘 기준 1달 전 ~ 오늘 - 대시보드와 카드/차트 스타일 통일 - 메시지 목록 연동: 슬라이드 패널에서 messageId 쿼리 파라미터로 이동 Closes #23
279 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|