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