feat: 대시보드 페이지 구현 (통계카드/차트/필터/최근발송/플랫폼도넛)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
af6ecab428
commit
61508e25f7
73
react/src/features/dashboard/components/DashboardFilter.tsx
Normal file
73
react/src/features/dashboard/components/DashboardFilter.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import FilterDropdown from "@/components/common/FilterDropdown";
|
||||||
|
import DateRangeInput from "@/components/common/DateRangeInput";
|
||||||
|
import FilterResetButton from "@/components/common/FilterResetButton";
|
||||||
|
|
||||||
|
interface DashboardFilterProps {
|
||||||
|
onSearch?: (filter: { dateStart: string; dateEnd: string; service: string }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SERVICES = ["전체 서비스", "쇼핑몰 앱", "배달 파트너"];
|
||||||
|
|
||||||
|
/** 오늘 날짜 YYYY-MM-DD */
|
||||||
|
function today() {
|
||||||
|
return new Date().toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
/** 30일 전 */
|
||||||
|
function thirtyDaysAgo() {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() - 30);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardFilter({ onSearch }: DashboardFilterProps) {
|
||||||
|
const [dateStart, setDateStart] = useState(thirtyDaysAgo);
|
||||||
|
const [dateEnd, setDateEnd] = useState(today);
|
||||||
|
const [selectedService, setSelectedService] = useState(SERVICES[0]);
|
||||||
|
|
||||||
|
// 초기화
|
||||||
|
const handleReset = () => {
|
||||||
|
setDateStart(thirtyDaysAgo());
|
||||||
|
setDateEnd(today());
|
||||||
|
setSelectedService(SERVICES[0]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 조회
|
||||||
|
const handleSearch = () => {
|
||||||
|
onSearch?.({ dateStart, dateEnd, service: selectedService });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-6">
|
||||||
|
<div className="flex items-end gap-4">
|
||||||
|
{/* 날짜 범위 */}
|
||||||
|
<DateRangeInput
|
||||||
|
startDate={dateStart}
|
||||||
|
endDate={dateEnd}
|
||||||
|
onStartChange={setDateStart}
|
||||||
|
onEndChange={setDateEnd}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 서비스 드롭다운 */}
|
||||||
|
<FilterDropdown
|
||||||
|
label="서비스"
|
||||||
|
value={selectedService}
|
||||||
|
options={SERVICES}
|
||||||
|
onChange={setSelectedService}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 초기화 */}
|
||||||
|
<FilterResetButton onClick={handleReset} />
|
||||||
|
|
||||||
|
{/* 조회 */}
|
||||||
|
<button
|
||||||
|
onClick={handleSearch}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
조회
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
react/src/features/dashboard/components/PlatformDonut.tsx
Normal file
78
react/src/features/dashboard/components/PlatformDonut.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
interface PlatformData {
|
||||||
|
ios: number;
|
||||||
|
android: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlatformDonutProps {
|
||||||
|
data?: PlatformData;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_DATA: PlatformData = { ios: 45, android: 55 };
|
||||||
|
|
||||||
|
export default function PlatformDonut({ data = DEFAULT_DATA }: PlatformDonutProps) {
|
||||||
|
const { ios, android } = data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lg:col-span-1 bg-white rounded-xl shadow-sm border border-gray-200 p-6 flex flex-col">
|
||||||
|
<h2 className="text-base font-bold text-[#0f172a] mb-6">플랫폼 비율</h2>
|
||||||
|
|
||||||
|
{/* 도넛 차트 */}
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center relative">
|
||||||
|
<div className="relative size-48">
|
||||||
|
<svg className="size-full" viewBox="0 0 36 36">
|
||||||
|
{/* 배경 원 */}
|
||||||
|
<path
|
||||||
|
className="text-gray-100"
|
||||||
|
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="3"
|
||||||
|
/>
|
||||||
|
{/* Android (teal) */}
|
||||||
|
<path
|
||||||
|
className="text-teal-400"
|
||||||
|
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeDasharray={`${android}, 100`}
|
||||||
|
strokeWidth="3"
|
||||||
|
/>
|
||||||
|
{/* iOS (primary blue) */}
|
||||||
|
<path
|
||||||
|
className="text-primary"
|
||||||
|
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeDasharray={`${ios}, 100`}
|
||||||
|
strokeDashoffset={`${-android}`}
|
||||||
|
strokeWidth="3"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/* 중앙 텍스트 */}
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||||
|
<span className="text-3xl font-bold text-gray-900">Total</span>
|
||||||
|
<span className="text-sm text-gray-500">Devices</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 범례 */}
|
||||||
|
<div className="mt-8 flex flex-col gap-3 w-full px-4">
|
||||||
|
<div className="flex items-center justify-between p-3 rounded-lg bg-gray-50">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="size-3 rounded-full bg-primary flex-shrink-0" />
|
||||||
|
<span className="text-sm font-medium text-gray-600">iOS</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-lg font-bold text-gray-900">{ios}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-3 rounded-lg bg-gray-50">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="size-3 rounded-full bg-teal-400 flex-shrink-0" />
|
||||||
|
<span className="text-sm font-medium text-gray-600">Android</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-lg font-bold text-gray-900">{android}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
react/src/features/dashboard/components/RecentMessages.tsx
Normal file
85
react/src/features/dashboard/components/RecentMessages.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import StatusBadge from "@/components/common/StatusBadge";
|
||||||
|
|
||||||
|
type StatusVariant = "success" | "error" | "warning" | "default";
|
||||||
|
|
||||||
|
const STATUS_MAP: Record<string, StatusVariant> = {
|
||||||
|
완료: "success",
|
||||||
|
실패: "error",
|
||||||
|
진행: "warning",
|
||||||
|
예약: "default",
|
||||||
|
};
|
||||||
|
|
||||||
|
type MessageStatus = keyof typeof STATUS_MAP;
|
||||||
|
|
||||||
|
interface RecentMessage {
|
||||||
|
template: string;
|
||||||
|
targetCount: string;
|
||||||
|
status: MessageStatus;
|
||||||
|
sentAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecentMessagesProps {
|
||||||
|
messages?: RecentMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_MESSAGES: RecentMessage[] = [
|
||||||
|
{ template: "가을맞이 프로모션 알림", targetCount: "12,405", status: "완료", sentAt: "2026-02-15 14:00" },
|
||||||
|
{ template: "정기 점검 안내", targetCount: "45,100", status: "완료", sentAt: "2026-02-15 10:30" },
|
||||||
|
{ template: "비밀번호 변경 알림", targetCount: "1", status: "실패", sentAt: "2026-02-15 09:15" },
|
||||||
|
{ template: "신규 서비스 런칭", targetCount: "8,500", status: "진행", sentAt: "2026-02-15 09:00" },
|
||||||
|
{ template: "야간 푸시 마케팅", targetCount: "3,200", status: "예약", sentAt: "2026-02-15 20:00" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function RecentMessages({ messages = DEFAULT_MESSAGES }: RecentMessagesProps) {
|
||||||
|
return (
|
||||||
|
<div className="lg:col-span-2 bg-white rounded-xl shadow-sm border border-gray-200 flex flex-col overflow-hidden">
|
||||||
|
<div className="p-5 border-b border-gray-100 flex items-center justify-between">
|
||||||
|
<h2 className="text-base font-bold text-[#0f172a]">최근 발송 내역</h2>
|
||||||
|
<Link
|
||||||
|
to="/statistics/history"
|
||||||
|
className="text-primary hover:text-[#1d4ed8] text-sm font-medium"
|
||||||
|
>
|
||||||
|
전체 보기
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto flex-1">
|
||||||
|
<table className="w-full text-sm text-left h-full">
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
|
||||||
|
템플릿명
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
|
||||||
|
타겟 수
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
|
||||||
|
상태
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-xs font-semibold uppercase text-gray-700 text-center">
|
||||||
|
발송 시간
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{messages.map((msg, i) => (
|
||||||
|
<tr key={i} className="hover:bg-gray-50 transition-colors">
|
||||||
|
<td className="px-6 py-3.5 font-medium text-gray-900 text-center">
|
||||||
|
{msg.template}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3.5 text-center text-gray-600">{msg.targetCount}</td>
|
||||||
|
<td className="px-6 py-3.5 text-center">
|
||||||
|
<StatusBadge
|
||||||
|
variant={STATUS_MAP[msg.status]}
|
||||||
|
label={msg.status}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3.5 text-center text-gray-500">{msg.sentAt}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
react/src/features/dashboard/components/StatsCards.tsx
Normal file
79
react/src/features/dashboard/components/StatsCards.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
interface StatCard {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
/** 값 뒤에 붙는 단위 (예: "%") */
|
||||||
|
unit?: string;
|
||||||
|
/** 우상단 뱃지 */
|
||||||
|
badge?: { type: "trend"; value: string } | { type: "icon"; icon: string; color: string };
|
||||||
|
link: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatsCardsProps {
|
||||||
|
cards?: StatCard[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 하드코딩 기본 데이터 */
|
||||||
|
const DEFAULT_CARDS: StatCard[] = [
|
||||||
|
{
|
||||||
|
label: "오늘 발송 수",
|
||||||
|
value: "12,847",
|
||||||
|
badge: { type: "trend", value: "15%" },
|
||||||
|
link: "/statistics",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "성공률",
|
||||||
|
value: "98.7",
|
||||||
|
unit: "%",
|
||||||
|
badge: { type: "icon", icon: "check_circle", color: "bg-green-100 text-green-600" },
|
||||||
|
link: "/statistics",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "등록 기기 수",
|
||||||
|
value: "45,230",
|
||||||
|
badge: { type: "icon", icon: "devices", color: "bg-blue-50 text-primary" },
|
||||||
|
link: "/devices",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "활성 서비스",
|
||||||
|
value: "8",
|
||||||
|
badge: { type: "icon", icon: "grid_view", color: "bg-purple-50 text-purple-600" },
|
||||||
|
link: "/services",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function StatsCards({ cards = DEFAULT_CARDS }: StatsCardsProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
|
||||||
|
{cards.map((card) => (
|
||||||
|
<Link
|
||||||
|
key={card.label}
|
||||||
|
to={card.link}
|
||||||
|
className="bg-white rounded-xl shadow-sm border border-gray-200 p-5 flex flex-col justify-between h-28 hover:shadow-md hover:border-primary/30 transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<h4 className="text-xs font-medium text-gray-500">{card.label}</h4>
|
||||||
|
{card.badge?.type === "trend" && (
|
||||||
|
<div className="bg-green-50 text-green-700 text-[10px] font-bold px-1.5 py-0.5 rounded-full flex items-center gap-0.5">
|
||||||
|
<span className="material-symbols-outlined text-[10px]">trending_up</span>
|
||||||
|
{card.badge.value}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{card.badge?.type === "icon" && (
|
||||||
|
<div className={`${card.badge.color} rounded-full p-0.5`}>
|
||||||
|
<span className="material-symbols-outlined text-sm">{card.badge.icon}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-gray-900">
|
||||||
|
{card.value}
|
||||||
|
{card.unit && (
|
||||||
|
<span className="text-base text-gray-400 ml-0.5 font-normal">{card.unit}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
285
react/src/features/dashboard/components/WeeklyChart.tsx
Normal file
285
react/src/features/dashboard/components/WeeklyChart.tsx
Normal file
|
|
@ -0,0 +1,285 @@
|
||||||
|
import { useRef, useEffect, useCallback, useState } from "react";
|
||||||
|
|
||||||
|
interface ChartDataPoint {
|
||||||
|
label: string;
|
||||||
|
/** Y축 비율 (0=최상단, 1=최하단) — 0이 최대값 */
|
||||||
|
blue: number;
|
||||||
|
green: number;
|
||||||
|
sent: string;
|
||||||
|
reach: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WeeklyChartProps {
|
||||||
|
data?: ChartDataPoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 하드코딩 기본 데이터 (HTML 시안과 동일) */
|
||||||
|
const DEFAULT_DATA: ChartDataPoint[] = [
|
||||||
|
{ label: "02.09", blue: 0.75, green: 0.8, sent: "3,750", reach: "3,000" },
|
||||||
|
{ label: "02.10", blue: 0.6, green: 0.675, sent: "6,000", reach: "4,875" },
|
||||||
|
{ label: "02.11", blue: 0.4, green: 0.475, sent: "9,000", reach: "7,875" },
|
||||||
|
{ label: "02.12", blue: 0.5, green: 0.575, sent: "7,500", reach: "6,375" },
|
||||||
|
{ label: "02.13", blue: 0.2, green: 0.275, sent: "12,000", reach: "10,875" },
|
||||||
|
{ label: "02.14", blue: 0.3, green: 0.35, sent: "10,500", reach: "9,750" },
|
||||||
|
{ label: "Today", blue: 0.1, green: 0.175, sent: "13,500", reach: "12,375" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MARGIN = 0.02;
|
||||||
|
|
||||||
|
export default function WeeklyChart({ data = DEFAULT_DATA }: WeeklyChartProps) {
|
||||||
|
const areaRef = useRef<HTMLDivElement>(null);
|
||||||
|
const animatedRef = useRef(false);
|
||||||
|
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const count = data.length;
|
||||||
|
const xRatios =
|
||||||
|
count === 1
|
||||||
|
? [MARGIN]
|
||||||
|
: data.map((_, i) => MARGIN + (i / (count - 1)) * (1 - MARGIN * 2));
|
||||||
|
|
||||||
|
const drawChart = useCallback(() => {
|
||||||
|
const area = areaRef.current;
|
||||||
|
if (!area || count === 0) return;
|
||||||
|
|
||||||
|
// 기존 SVG + 점 제거
|
||||||
|
area.querySelectorAll("svg, .chart-dot").forEach((el) => el.remove());
|
||||||
|
|
||||||
|
const W = area.offsetWidth;
|
||||||
|
const H = area.offsetHeight;
|
||||||
|
if (W === 0 || H === 0) return;
|
||||||
|
|
||||||
|
const toPixel = (xr: number, yr: number): [number, number] => [
|
||||||
|
Math.round(xr * W * 10) / 10,
|
||||||
|
Math.round(yr * H * 10) / 10,
|
||||||
|
];
|
||||||
|
|
||||||
|
const bluePoints = xRatios.map((x, i) => toPixel(x, data[i].blue));
|
||||||
|
const greenPoints = xRatios.map((x, i) => toPixel(x, data[i].green));
|
||||||
|
|
||||||
|
const NS = "http://www.w3.org/2000/svg";
|
||||||
|
const svg = document.createElementNS(NS, "svg");
|
||||||
|
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
|
||||||
|
svg.style.cssText = "position:absolute;inset:0;width:100%;height:100%;";
|
||||||
|
|
||||||
|
function makePath(points: [number, number][], color: string) {
|
||||||
|
if (points.length < 2) return null;
|
||||||
|
const path = document.createElementNS(NS, "path");
|
||||||
|
path.setAttribute(
|
||||||
|
"d",
|
||||||
|
points.map((p, i) => `${i ? "L" : "M"}${p[0]},${p[1]}`).join(" "),
|
||||||
|
);
|
||||||
|
path.setAttribute("fill", "none");
|
||||||
|
path.setAttribute("stroke", color);
|
||||||
|
path.setAttribute("stroke-width", "2.5");
|
||||||
|
path.setAttribute("stroke-linecap", "round");
|
||||||
|
path.setAttribute("stroke-linejoin", "round");
|
||||||
|
svg.appendChild(path);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bluePath = makePath(bluePoints, "#2563EB");
|
||||||
|
const greenPath = makePath(greenPoints, "#22C55E");
|
||||||
|
area.appendChild(svg);
|
||||||
|
|
||||||
|
// 애니메이션 (최초 1회만)
|
||||||
|
const DURATION = 2000;
|
||||||
|
|
||||||
|
function getCumulativeRatios(pts: [number, number][]) {
|
||||||
|
const d = [0];
|
||||||
|
for (let i = 1; i < pts.length; i++) {
|
||||||
|
const dx = pts[i][0] - pts[i - 1][0];
|
||||||
|
const dy = pts[i][1] - pts[i - 1][1];
|
||||||
|
d.push(d[i - 1] + Math.sqrt(dx * dx + dy * dy));
|
||||||
|
}
|
||||||
|
const t = d[d.length - 1];
|
||||||
|
return t > 0 ? d.map((v) => v / t) : d.map(() => 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function easeOutTime(r: number) {
|
||||||
|
return 1 - Math.sqrt(1 - r);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLine(
|
||||||
|
path: SVGPathElement | null,
|
||||||
|
points: [number, number][],
|
||||||
|
color: string,
|
||||||
|
) {
|
||||||
|
if (path && !animatedRef.current) {
|
||||||
|
const len = path.getTotalLength();
|
||||||
|
path.style.strokeDasharray = String(len);
|
||||||
|
path.style.strokeDashoffset = String(len);
|
||||||
|
path.getBoundingClientRect(); // 강제 리플로
|
||||||
|
path.style.transition = `stroke-dashoffset ${DURATION}ms ease-out`;
|
||||||
|
path.style.strokeDashoffset = "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
const ratios = getCumulativeRatios(points);
|
||||||
|
points.forEach(([px, py], i) => {
|
||||||
|
const dot = document.createElement("div");
|
||||||
|
dot.className = "chart-dot";
|
||||||
|
dot.style.left = px + "px";
|
||||||
|
dot.style.top = py + "px";
|
||||||
|
dot.style.borderColor = color;
|
||||||
|
area!.appendChild(dot);
|
||||||
|
|
||||||
|
if (!animatedRef.current && path) {
|
||||||
|
setTimeout(() => dot.classList.add("show"), easeOutTime(ratios[i]) * DURATION);
|
||||||
|
} else {
|
||||||
|
dot.classList.add("show");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
renderLine(bluePath, bluePoints, "#2563EB");
|
||||||
|
renderLine(greenPath, greenPoints, "#22C55E");
|
||||||
|
|
||||||
|
animatedRef.current = true;
|
||||||
|
}, [data, count, xRatios]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
drawChart();
|
||||||
|
|
||||||
|
let resizeTimer: ReturnType<typeof setTimeout>;
|
||||||
|
const handleResize = () => {
|
||||||
|
clearTimeout(resizeTimer);
|
||||||
|
resizeTimer = setTimeout(drawChart, 150);
|
||||||
|
};
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", handleResize);
|
||||||
|
clearTimeout(resizeTimer);
|
||||||
|
};
|
||||||
|
}, [drawChart]);
|
||||||
|
|
||||||
|
// 호버존 계산 (CSS 기반)
|
||||||
|
const getHoverZoneStyle = (i: number): React.CSSProperties => {
|
||||||
|
if (count <= 1) return { left: 0, width: "100%" };
|
||||||
|
const halfGap =
|
||||||
|
i === 0
|
||||||
|
? ((xRatios[1] - xRatios[0]) / 2) * 100
|
||||||
|
: i === count - 1
|
||||||
|
? ((xRatios[count - 1] - xRatios[count - 2]) / 2) * 100
|
||||||
|
: ((xRatios[i + 1] - xRatios[i - 1]) / 4) * 100;
|
||||||
|
return {
|
||||||
|
left: `${(xRatios[i] * 100 - halfGap).toFixed(2)}%`,
|
||||||
|
width: `${(halfGap * 2).toFixed(2)}%`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
if (count === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-base font-bold text-[#0f172a]">최근 7일 발송 추이</h2>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-72 flex items-center justify-center">
|
||||||
|
<p className="text-sm text-gray-400">발송 내역이 없습니다</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-base font-bold text-[#0f172a]">최근 7일 발송 추이</h2>
|
||||||
|
<div className="flex items-center gap-4 text-xs font-medium">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="size-3 rounded-full bg-primary" />
|
||||||
|
<span className="text-gray-600">발송</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="size-3 rounded-full bg-green-500" />
|
||||||
|
<span className="text-gray-600">도달</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 차트 영역 */}
|
||||||
|
<div className="w-full h-72 relative">
|
||||||
|
{/* Y축 라벨 + 그리드 */}
|
||||||
|
<div className="absolute inset-0 flex flex-col justify-between text-xs text-gray-400 pb-6 pr-4 pointer-events-none">
|
||||||
|
<div className="flex items-center w-full">
|
||||||
|
<span className="w-8 text-right mr-2">15k</span>
|
||||||
|
<div className="flex-1 h-px bg-gray-100" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center w-full">
|
||||||
|
<span className="w-8 text-right mr-2">10k</span>
|
||||||
|
<div className="flex-1 h-px bg-gray-100" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center w-full">
|
||||||
|
<span className="w-8 text-right mr-2">5k</span>
|
||||||
|
<div className="flex-1 h-px bg-gray-100" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center w-full">
|
||||||
|
<span className="w-8 text-right mr-2">0</span>
|
||||||
|
<div className="flex-1 h-px bg-gray-200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SVG 차트 영역 */}
|
||||||
|
<div ref={areaRef} className="absolute top-1 bottom-6 left-10 right-4">
|
||||||
|
{/* 호버존 */}
|
||||||
|
{data.map((d, i) => (
|
||||||
|
<div
|
||||||
|
key={d.label}
|
||||||
|
className="absolute top-0 bottom-0 cursor-pointer z-[5]"
|
||||||
|
style={getHoverZoneStyle(i)}
|
||||||
|
onMouseEnter={() => setHoverIndex(i)}
|
||||||
|
onMouseLeave={() => setHoverIndex(null)}
|
||||||
|
>
|
||||||
|
{/* 가이드라인 */}
|
||||||
|
<div
|
||||||
|
className="absolute top-0 bottom-0 w-px left-1/2 bg-gray-300 pointer-events-none transition-opacity duration-150"
|
||||||
|
style={{ opacity: hoverIndex === i ? 1 : 0 }}
|
||||||
|
/>
|
||||||
|
{/* 툴팁 */}
|
||||||
|
<div
|
||||||
|
className="absolute left-1/2 pointer-events-none z-10 transition-all duration-150"
|
||||||
|
style={{
|
||||||
|
top: `${Math.max(0, Math.min(d.blue, d.green) * 100 - 5)}%`,
|
||||||
|
transform: `translateX(-50%) translateY(${hoverIndex === i ? 0 : 4}px)`,
|
||||||
|
opacity: hoverIndex === i ? 1 : 0,
|
||||||
|
visibility: hoverIndex === i ? "visible" : "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg shadow-lg px-4 py-3 min-w-[140px]">
|
||||||
|
<p className="text-xs font-bold text-gray-900 mb-2 pb-1.5 border-b border-gray-100">
|
||||||
|
{d.label} 발송 추이
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
<span className="size-2.5 rounded-full bg-[#2563EB] flex-shrink-0" />
|
||||||
|
<span className="text-xs text-gray-500 flex-1">발송</span>
|
||||||
|
<span className="text-xs font-semibold text-gray-900">{d.sent}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="size-2.5 rounded-full bg-[#22C55E] flex-shrink-0" />
|
||||||
|
<span className="text-xs text-gray-500 flex-1">도달</span>
|
||||||
|
<span className="text-xs font-semibold text-gray-900">{d.reach}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* X축 날짜 라벨 */}
|
||||||
|
<div className="absolute bottom-0 left-10 right-4 h-5 text-xs text-gray-400">
|
||||||
|
{data.map((d, i) => (
|
||||||
|
<span
|
||||||
|
key={d.label}
|
||||||
|
className="absolute"
|
||||||
|
style={{
|
||||||
|
left: `${xRatios[i] * 100}%`,
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{d.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,33 @@
|
||||||
|
import PageHeader from "@/components/common/PageHeader";
|
||||||
|
import DashboardFilter from "../components/DashboardFilter";
|
||||||
|
import StatsCards from "../components/StatsCards";
|
||||||
|
import WeeklyChart from "../components/WeeklyChart";
|
||||||
|
import RecentMessages from "../components/RecentMessages";
|
||||||
|
import PlatformDonut from "../components/PlatformDonut";
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<>
|
||||||
<h1 className="text-2xl font-bold">대시보드</h1>
|
{/* 페이지 헤더 */}
|
||||||
</div>
|
<PageHeader
|
||||||
|
title="대시보드"
|
||||||
|
description="서비스 발송 현황과 주요 지표를 한눈에 확인할 수 있습니다."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 필터 */}
|
||||||
|
<DashboardFilter />
|
||||||
|
|
||||||
|
{/* 통계 카드 */}
|
||||||
|
<StatsCards />
|
||||||
|
|
||||||
|
{/* 7일 발송 추이 차트 */}
|
||||||
|
<WeeklyChart />
|
||||||
|
|
||||||
|
{/* 최근 발송 내역 + 플랫폼 도넛 */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<RecentMessages />
|
||||||
|
<PlatformDonut />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user