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

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

306 lines
10 KiB
TypeScript

import { useState, useMemo } from "react";
import PageHeader from "@/components/common/PageHeader";
import SearchInput from "@/components/common/SearchInput";
import FilterDropdown from "@/components/common/FilterDropdown";
import DateRangeInput from "@/components/common/DateRangeInput";
import FilterResetButton from "@/components/common/FilterResetButton";
import Pagination from "@/components/common/Pagination";
import EmptyState from "@/components/common/EmptyState";
import StatusBadge from "@/components/common/StatusBadge";
import CopyButton from "@/components/common/CopyButton";
import HistorySlidePanel from "../components/HistorySlidePanel";
import { formatNumber } from "@/utils/format";
import {
SERVICE_FILTER_OPTIONS,
STATUS_FILTER_OPTIONS,
MOCK_SEND_HISTORY,
} from "../types";
import type { SendHistory } from "../types";
const PAGE_SIZE = 10;
const COLUMNS = [
"발송 ID",
"템플릿명",
"발송 시간",
"대상 수",
"성공",
"실패",
"오픈율",
"상태",
];
/** 상태 → StatusBadge variant 매핑 */
function getStatusVariant(status: string): "success" | "info" | "error" {
if (status === "완료") return "success";
if (status === "진행") return "info";
return "error";
}
/** 오늘 날짜 YYYY-MM-DD */
function getToday() {
return new Date().toISOString().slice(0, 10);
}
/** 1달 전 날짜 */
function getOneMonthAgo() {
const d = new Date();
d.setMonth(d.getMonth() - 1);
return d.toISOString().slice(0, 10);
}
export default function StatisticsHistoryPage() {
// 필터 입력 상태
const [search, setSearch] = useState("");
const [serviceFilter, setServiceFilter] = useState("전체 서비스");
const [statusFilter, setStatusFilter] = useState("전체");
const [dateStart, setDateStart] = useState(getOneMonthAgo);
const [dateEnd, setDateEnd] = useState(getToday);
const [currentPage, setCurrentPage] = useState(1);
const [loading, setLoading] = useState(false);
// 적용된 필터
const [appliedSearch, setAppliedSearch] = useState("");
const [appliedService, setAppliedService] = useState("전체 서비스");
const [appliedStatus, setAppliedStatus] = useState("전체");
// 슬라이드 패널
const [panelOpen, setPanelOpen] = useState(false);
const [selectedHistory, setSelectedHistory] = useState<SendHistory | null>(null);
// 조회
const handleQuery = () => {
setLoading(true);
setTimeout(() => {
setAppliedSearch(search);
setAppliedService(serviceFilter);
setAppliedStatus(statusFilter);
setCurrentPage(1);
setLoading(false);
}, 400);
};
// 필터 초기화
const handleReset = () => {
setSearch("");
setServiceFilter("전체 서비스");
setStatusFilter("전체");
setDateStart(getOneMonthAgo());
setDateEnd(getToday());
setAppliedSearch("");
setAppliedService("전체 서비스");
setAppliedStatus("전체");
setCurrentPage(1);
};
// 필터링
const filtered = useMemo(() => {
return MOCK_SEND_HISTORY.filter((d) => {
if (appliedSearch) {
const q = appliedSearch.toLowerCase();
if (
!d.id.toLowerCase().includes(q) &&
!d.template.toLowerCase().includes(q)
)
return false;
}
if (appliedService !== "전체 서비스" && d.service !== appliedService) return false;
if (appliedStatus !== "전체" && d.status !== appliedStatus) return false;
return true;
});
}, [appliedSearch, appliedService, appliedStatus]);
// 페이지네이션
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 = (item: SendHistory) => {
setSelectedHistory(item);
setPanelOpen(true);
};
// 테이블 헤더 렌더링
const renderTableHead = () => (
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
{COLUMNS.map((col) => (
<th
key={col}
className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center"
>
{col}
</th>
))}
</tr>
</thead>
);
return (
<div>
<PageHeader
title="발송 이력"
description="서비스별 메시지 발송 상세 이력을 확인합니다."
action={
<button className="flex items-center justify-center gap-2 min-w-[154px] px-5 py-2.5 bg-white border border-gray-300 hover:bg-gray-50 text-[#0f172a] rounded-lg text-sm font-medium transition-colors">
<span className="material-symbols-outlined text-lg">download</span>
</button>
}
/>
{/* 필터바 */}
<div className="bg-white border border-gray-200 rounded-lg p-6 mb-6">
{/* 1줄: 검색어 / 서비스 / 상태 */}
<div className="flex items-end gap-4">
<SearchInput
value={search}
onChange={setSearch}
placeholder="발송 ID 또는 템플릿명 검색"
label="검색어"
disabled={loading}
/>
<FilterDropdown
label="서비스 분류"
value={serviceFilter}
options={SERVICE_FILTER_OPTIONS}
onChange={setServiceFilter}
className="w-[160px] flex-shrink-0"
disabled={loading}
/>
<FilterDropdown
label="상태"
value={statusFilter}
options={STATUS_FILTER_OPTIONS}
onChange={setStatusFilter}
className="w-[120px] flex-shrink-0"
disabled={loading}
/>
</div>
{/* 2줄: 날짜 범위 / 버튼 */}
<div className="flex items-end gap-4 mt-3">
<DateRangeInput
startDate={dateStart}
endDate={dateEnd}
onStartChange={setDateStart}
onEndChange={setDateEnd}
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">
{renderTableHead()}
<tbody>
{Array.from({ length: 5 }).map((_, i) => (
<tr
key={i}
className={i < 4 ? "border-b border-gray-100" : ""}
>
{Array.from({ length: 8 }).map((_, j) => (
<td key={j} className="px-6 py-4 text-center">
<div className="h-4 w-16 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 shadow-sm">
<div className="overflow-x-auto">
<table className="w-full text-sm">
{renderTableHead()}
<tbody>
{paged.map((item, idx) => (
<tr
key={item.id}
className={`${idx < paged.length - 1 ? "border-b border-gray-100" : ""} hover:bg-gray-50/50 transition-colors cursor-pointer`}
onClick={() => handleRowClick(item)}
>
{/* 발송 ID */}
<td className="px-6 py-4 text-center">
<span
className="inline-flex items-center gap-1.5 font-mono text-xs text-gray-500"
onClick={(e) => e.stopPropagation()}
>
{item.id}
<CopyButton text={item.id} />
</span>
</td>
{/* 템플릿명 */}
<td className="px-6 py-4 text-center text-sm font-medium text-gray-900">
{item.template}
</td>
{/* 발송 시간 */}
<td className="px-6 py-4 text-center text-sm text-gray-500">
{item.sentAt}
</td>
{/* 대상 수 */}
<td className="px-6 py-4 text-center text-sm text-gray-900">
{formatNumber(item.total)}
</td>
{/* 성공 */}
<td className="px-6 py-4 text-center text-sm font-semibold text-primary">
{formatNumber(item.success)}
</td>
{/* 실패 */}
<td className={`px-6 py-4 text-center text-sm font-semibold ${item.fail > 0 ? "text-red-500" : "text-gray-400"}`}>
{formatNumber(item.fail)}
</td>
{/* 오픈율 */}
<td className="px-6 py-4 text-center text-sm font-bold text-gray-900">
{item.openRate}%
</td>
{/* 상태 */}
<td className="px-6 py-4 text-center">
<StatusBadge variant={getStatusVariant(item.status)} label={item.status} />
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 페이지네이션 */}
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={totalItems}
pageSize={PAGE_SIZE}
onPageChange={setCurrentPage}
/>
</div>
) : (
<EmptyState
icon="search_off"
message="검색 결과가 없습니다"
description="다른 검색어를 입력하거나 필터를 변경해보세요."
/>
)}
{/* 슬라이드 패널 */}
<HistorySlidePanel
isOpen={panelOpen}
onClose={() => setPanelOpen(false)}
history={selectedHistory}
/>
</div>
);
}