feat: 대시보드 조회 로딩 상태 및 필터 disabled 처리
- 조회 클릭 시 전체 필터 비활성화 (날짜/드롭다운/초기화/조회) - 각 위젯 로딩 오버레이 및 스켈레톤 추가 - 조회 완료 시 랜덤 Mock 데이터로 갱신 - 공통 컴포넌트(FilterDropdown, DateRangeInput, FilterResetButton)에 disabled prop 추가 - StatsCards 뱃지 아이콘 크기 및 중앙 정렬 개선 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
61508e25f7
commit
59c206e0c2
|
|
@ -5,6 +5,7 @@ interface DateRangeInputProps {
|
||||||
onEndChange: (value: string) => void;
|
onEndChange: (value: string) => void;
|
||||||
startLabel?: string;
|
startLabel?: string;
|
||||||
endLabel?: string;
|
endLabel?: string;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 시작일~종료일 날짜 입력 (자동 보정) */
|
/** 시작일~종료일 날짜 입력 (자동 보정) */
|
||||||
|
|
@ -15,6 +16,7 @@ export default function DateRangeInput({
|
||||||
onEndChange,
|
onEndChange,
|
||||||
startLabel = "시작일",
|
startLabel = "시작일",
|
||||||
endLabel = "종료일",
|
endLabel = "종료일",
|
||||||
|
disabled,
|
||||||
}: DateRangeInputProps) {
|
}: DateRangeInputProps) {
|
||||||
const handleStartChange = (value: string) => {
|
const handleStartChange = (value: string) => {
|
||||||
onStartChange(value);
|
onStartChange(value);
|
||||||
|
|
@ -41,7 +43,8 @@ export default function DateRangeInput({
|
||||||
type="date"
|
type="date"
|
||||||
value={startDate}
|
value={startDate}
|
||||||
onChange={(e) => handleStartChange(e.target.value)}
|
onChange={(e) => handleStartChange(e.target.value)}
|
||||||
className="w-full h-[38px] pl-10 pr-3 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors"
|
disabled={disabled}
|
||||||
|
className="w-full h-[38px] pl-10 pr-3 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-gray-50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -61,7 +64,8 @@ export default function DateRangeInput({
|
||||||
type="date"
|
type="date"
|
||||||
value={endDate}
|
value={endDate}
|
||||||
onChange={(e) => handleEndChange(e.target.value)}
|
onChange={(e) => handleEndChange(e.target.value)}
|
||||||
className="w-full h-[38px] pl-10 pr-3 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors"
|
disabled={disabled}
|
||||||
|
className="w-full h-[38px] pl-10 pr-3 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-gray-50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ interface FilterDropdownProps {
|
||||||
options: string[];
|
options: string[];
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 커스텀 셀렉트 드롭다운 (외부클릭 닫기) */
|
/** 커스텀 셀렉트 드롭다운 (외부클릭 닫기) */
|
||||||
|
|
@ -15,6 +16,7 @@ export default function FilterDropdown({
|
||||||
options,
|
options,
|
||||||
onChange,
|
onChange,
|
||||||
className = "",
|
className = "",
|
||||||
|
disabled,
|
||||||
}: FilterDropdownProps) {
|
}: FilterDropdownProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -39,8 +41,9 @@ export default function FilterDropdown({
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => !disabled && setOpen((v) => !v)}
|
||||||
className="w-full h-[38px] border border-gray-300 rounded-lg px-3 text-sm text-left flex items-center justify-between bg-white hover:border-gray-400 transition-colors"
|
disabled={disabled}
|
||||||
|
className="w-full h-[38px] border border-gray-300 rounded-lg px-3 text-sm text-left flex items-center justify-between bg-white hover:border-gray-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:hover:border-gray-300"
|
||||||
>
|
>
|
||||||
<span className="truncate">{value}</span>
|
<span className="truncate">{value}</span>
|
||||||
<span className="material-symbols-outlined text-gray-400 text-[18px] flex-shrink-0">
|
<span className="material-symbols-outlined text-gray-400 text-[18px] flex-shrink-0">
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,11 @@ import { useState } from "react";
|
||||||
|
|
||||||
interface FilterResetButtonProps {
|
interface FilterResetButtonProps {
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 초기화 버튼 (스핀 애니메이션 + 툴팁) */
|
/** 초기화 버튼 (스핀 애니메이션 + 툴팁) */
|
||||||
export default function FilterResetButton({ onClick }: FilterResetButtonProps) {
|
export default function FilterResetButton({ onClick, disabled }: FilterResetButtonProps) {
|
||||||
const [spinning, setSpinning] = useState(false);
|
const [spinning, setSpinning] = useState(false);
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
|
|
@ -17,7 +18,8 @@ export default function FilterResetButton({ onClick }: FilterResetButtonProps) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
className="filter-reset-btn h-[38px] w-[38px] flex-shrink-0 flex items-center justify-center border border-gray-300 rounded-lg text-gray-500 hover:text-red-500 hover:border-red-300 hover:bg-red-50 transition-colors relative"
|
disabled={disabled}
|
||||||
|
className="filter-reset-btn h-[38px] w-[38px] flex-shrink-0 flex items-center justify-center border border-gray-300 rounded-lg text-gray-500 hover:text-red-500 hover:border-red-300 hover:bg-red-50 transition-colors relative disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:text-gray-500 disabled:hover:border-gray-300 disabled:hover:bg-transparent"
|
||||||
>
|
>
|
||||||
<span className="reset-tooltip">필터 초기화</span>
|
<span className="reset-tooltip">필터 초기화</span>
|
||||||
<span
|
<span
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import FilterResetButton from "@/components/common/FilterResetButton";
|
||||||
|
|
||||||
interface DashboardFilterProps {
|
interface DashboardFilterProps {
|
||||||
onSearch?: (filter: { dateStart: string; dateEnd: string; service: string }) => void;
|
onSearch?: (filter: { dateStart: string; dateEnd: string; service: string }) => void;
|
||||||
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SERVICES = ["전체 서비스", "쇼핑몰 앱", "배달 파트너"];
|
const SERVICES = ["전체 서비스", "쇼핑몰 앱", "배달 파트너"];
|
||||||
|
|
@ -20,7 +21,7 @@ function thirtyDaysAgo() {
|
||||||
return d.toISOString().slice(0, 10);
|
return d.toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DashboardFilter({ onSearch }: DashboardFilterProps) {
|
export default function DashboardFilter({ onSearch, loading }: DashboardFilterProps) {
|
||||||
const [dateStart, setDateStart] = useState(thirtyDaysAgo);
|
const [dateStart, setDateStart] = useState(thirtyDaysAgo);
|
||||||
const [dateEnd, setDateEnd] = useState(today);
|
const [dateEnd, setDateEnd] = useState(today);
|
||||||
const [selectedService, setSelectedService] = useState(SERVICES[0]);
|
const [selectedService, setSelectedService] = useState(SERVICES[0]);
|
||||||
|
|
@ -46,6 +47,7 @@ export default function DashboardFilter({ onSearch }: DashboardFilterProps) {
|
||||||
endDate={dateEnd}
|
endDate={dateEnd}
|
||||||
onStartChange={setDateStart}
|
onStartChange={setDateStart}
|
||||||
onEndChange={setDateEnd}
|
onEndChange={setDateEnd}
|
||||||
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 서비스 드롭다운 */}
|
{/* 서비스 드롭다운 */}
|
||||||
|
|
@ -55,15 +57,17 @@ export default function DashboardFilter({ onSearch }: DashboardFilterProps) {
|
||||||
options={SERVICES}
|
options={SERVICES}
|
||||||
onChange={setSelectedService}
|
onChange={setSelectedService}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 초기화 */}
|
{/* 초기화 */}
|
||||||
<FilterResetButton onClick={handleReset} />
|
<FilterResetButton onClick={handleReset} disabled={loading} />
|
||||||
|
|
||||||
{/* 조회 */}
|
{/* 조회 */}
|
||||||
<button
|
<button
|
||||||
onClick={handleSearch}
|
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"
|
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>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,24 @@ interface PlatformData {
|
||||||
|
|
||||||
interface PlatformDonutProps {
|
interface PlatformDonutProps {
|
||||||
data?: PlatformData;
|
data?: PlatformData;
|
||||||
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_DATA: PlatformData = { ios: 45, android: 55 };
|
const DEFAULT_DATA: PlatformData = { ios: 45, android: 55 };
|
||||||
|
|
||||||
export default function PlatformDonut({ data = DEFAULT_DATA }: PlatformDonutProps) {
|
export default function PlatformDonut({ data = DEFAULT_DATA, loading }: PlatformDonutProps) {
|
||||||
const { ios, android } = data;
|
const { ios, android } = data;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="lg:col-span-1 bg-white rounded-xl shadow-sm border border-gray-200 p-6 flex flex-col">
|
<div className="lg:col-span-1 bg-white rounded-xl shadow-sm border border-gray-200 p-6 flex flex-col relative">
|
||||||
|
{loading && (
|
||||||
|
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/70 rounded-xl">
|
||||||
|
<svg className="size-7 animate-spin text-primary" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<h2 className="text-base font-bold text-[#0f172a] mb-6">플랫폼 비율</h2>
|
<h2 className="text-base font-bold text-[#0f172a] mb-6">플랫폼 비율</h2>
|
||||||
|
|
||||||
{/* 도넛 차트 */}
|
{/* 도넛 차트 */}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ interface RecentMessage {
|
||||||
|
|
||||||
interface RecentMessagesProps {
|
interface RecentMessagesProps {
|
||||||
messages?: RecentMessage[];
|
messages?: RecentMessage[];
|
||||||
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_MESSAGES: RecentMessage[] = [
|
const DEFAULT_MESSAGES: RecentMessage[] = [
|
||||||
|
|
@ -31,9 +32,17 @@ const DEFAULT_MESSAGES: RecentMessage[] = [
|
||||||
{ template: "야간 푸시 마케팅", targetCount: "3,200", status: "예약", sentAt: "2026-02-15 20:00" },
|
{ template: "야간 푸시 마케팅", targetCount: "3,200", status: "예약", sentAt: "2026-02-15 20:00" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function RecentMessages({ messages = DEFAULT_MESSAGES }: RecentMessagesProps) {
|
export default function RecentMessages({ messages = DEFAULT_MESSAGES, loading }: RecentMessagesProps) {
|
||||||
return (
|
return (
|
||||||
<div className="lg:col-span-2 bg-white rounded-xl shadow-sm border border-gray-200 flex flex-col overflow-hidden">
|
<div className="lg:col-span-2 bg-white rounded-xl shadow-sm border border-gray-200 flex flex-col overflow-hidden relative">
|
||||||
|
{loading && (
|
||||||
|
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/70">
|
||||||
|
<svg className="size-7 animate-spin text-primary" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="p-5 border-b border-gray-100 flex items-center justify-between">
|
<div className="p-5 border-b border-gray-100 flex items-center justify-between">
|
||||||
<h2 className="text-base font-bold text-[#0f172a]">최근 발송 내역</h2>
|
<h2 className="text-base font-bold text-[#0f172a]">최근 발송 내역</h2>
|
||||||
<Link
|
<Link
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ interface StatCard {
|
||||||
|
|
||||||
interface StatsCardsProps {
|
interface StatsCardsProps {
|
||||||
cards?: StatCard[];
|
cards?: StatCard[];
|
||||||
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 하드코딩 기본 데이터 */
|
/** 하드코딩 기본 데이터 */
|
||||||
|
|
@ -43,7 +44,7 @@ const DEFAULT_CARDS: StatCard[] = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function StatsCards({ cards = DEFAULT_CARDS }: StatsCardsProps) {
|
export default function StatsCards({ cards = DEFAULT_CARDS, loading }: StatsCardsProps) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
|
||||||
{cards.map((card) => (
|
{cards.map((card) => (
|
||||||
|
|
@ -52,26 +53,30 @@ export default function StatsCards({ cards = DEFAULT_CARDS }: StatsCardsProps) {
|
||||||
to={card.link}
|
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"
|
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">
|
<div className="flex items-center justify-between">
|
||||||
<h4 className="text-xs font-medium text-gray-500">{card.label}</h4>
|
<h4 className="text-xs font-medium text-gray-500">{card.label}</h4>
|
||||||
{card.badge?.type === "trend" && (
|
{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">
|
<div className="bg-green-50 text-green-700 text-[10px] font-bold px-1.5 py-0.5 rounded-full inline-flex items-center gap-0.5">
|
||||||
<span className="material-symbols-outlined text-[10px]">trending_up</span>
|
<span className="material-symbols-outlined flex-shrink-0" style={{ fontSize: "10px" }}>trending_up</span>
|
||||||
{card.badge.value}
|
{card.badge.value}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{card.badge?.type === "icon" && (
|
{card.badge?.type === "icon" && (
|
||||||
<div className={`${card.badge.color} rounded-full p-0.5`}>
|
<div className={`${card.badge.color} rounded-full size-6 inline-flex items-center justify-center`}>
|
||||||
<span className="material-symbols-outlined text-sm">{card.badge.icon}</span>
|
<span className="material-symbols-outlined flex-shrink-0" style={{ fontSize: "14px" }}>{card.badge.icon}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{loading ? (
|
||||||
|
<div className="h-8 w-24 rounded bg-gray-100 animate-pulse" />
|
||||||
|
) : (
|
||||||
<div className="text-2xl font-bold text-gray-900">
|
<div className="text-2xl font-bold text-gray-900">
|
||||||
{card.value}
|
{card.value}
|
||||||
{card.unit && (
|
{card.unit && (
|
||||||
<span className="text-base text-gray-400 ml-0.5 font-normal">{card.unit}</span>
|
<span className="text-base text-gray-400 ml-0.5 font-normal">{card.unit}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ interface ChartDataPoint {
|
||||||
|
|
||||||
interface WeeklyChartProps {
|
interface WeeklyChartProps {
|
||||||
data?: ChartDataPoint[];
|
data?: ChartDataPoint[];
|
||||||
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 하드코딩 기본 데이터 (HTML 시안과 동일) */
|
/** 하드코딩 기본 데이터 (HTML 시안과 동일) */
|
||||||
|
|
@ -26,7 +27,7 @@ const DEFAULT_DATA: ChartDataPoint[] = [
|
||||||
|
|
||||||
const MARGIN = 0.02;
|
const MARGIN = 0.02;
|
||||||
|
|
||||||
export default function WeeklyChart({ data = DEFAULT_DATA }: WeeklyChartProps) {
|
export default function WeeklyChart({ data = DEFAULT_DATA, loading }: WeeklyChartProps) {
|
||||||
const areaRef = useRef<HTMLDivElement>(null);
|
const areaRef = useRef<HTMLDivElement>(null);
|
||||||
const animatedRef = useRef(false);
|
const animatedRef = useRef(false);
|
||||||
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
|
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
|
||||||
|
|
@ -134,7 +135,13 @@ export default function WeeklyChart({ data = DEFAULT_DATA }: WeeklyChartProps) {
|
||||||
renderLine(greenPath, greenPoints, "#22C55E");
|
renderLine(greenPath, greenPoints, "#22C55E");
|
||||||
|
|
||||||
animatedRef.current = true;
|
animatedRef.current = true;
|
||||||
}, [data, count, xRatios]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
/* data가 바뀌면 애니메이션 재생 */
|
||||||
|
useEffect(() => {
|
||||||
|
animatedRef.current = false;
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
drawChart();
|
drawChart();
|
||||||
|
|
@ -198,6 +205,14 @@ export default function WeeklyChart({ data = DEFAULT_DATA }: WeeklyChartProps) {
|
||||||
|
|
||||||
{/* 차트 영역 */}
|
{/* 차트 영역 */}
|
||||||
<div className="w-full h-72 relative">
|
<div className="w-full h-72 relative">
|
||||||
|
{loading && (
|
||||||
|
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/70 rounded-lg">
|
||||||
|
<svg className="size-7 animate-spin text-primary" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* Y축 라벨 + 그리드 */}
|
{/* 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="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">
|
<div className="flex items-center w-full">
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { useState, useCallback } from "react";
|
||||||
import PageHeader from "@/components/common/PageHeader";
|
import PageHeader from "@/components/common/PageHeader";
|
||||||
import DashboardFilter from "../components/DashboardFilter";
|
import DashboardFilter from "../components/DashboardFilter";
|
||||||
import StatsCards from "../components/StatsCards";
|
import StatsCards from "../components/StatsCards";
|
||||||
|
|
@ -5,7 +6,109 @@ import WeeklyChart from "../components/WeeklyChart";
|
||||||
import RecentMessages from "../components/RecentMessages";
|
import RecentMessages from "../components/RecentMessages";
|
||||||
import PlatformDonut from "../components/PlatformDonut";
|
import PlatformDonut from "../components/PlatformDonut";
|
||||||
|
|
||||||
|
/** 랜덤 정수 (min~max) */
|
||||||
|
function rand(min: number, max: number) {
|
||||||
|
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 숫자를 천단위 콤마로 포맷 */
|
||||||
|
function fmt(n: number) {
|
||||||
|
return n.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 통계 카드 랜덤 데이터 생성 */
|
||||||
|
function randomCards() {
|
||||||
|
const sent = rand(5000, 30000);
|
||||||
|
const rate = (95 + Math.random() * 4.9).toFixed(1);
|
||||||
|
const devices = rand(20000, 80000);
|
||||||
|
const services = rand(3, 15);
|
||||||
|
const trend = rand(1, 30);
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: "오늘 발송 수",
|
||||||
|
value: fmt(sent),
|
||||||
|
badge: { type: "trend" as const, value: `${trend}%` },
|
||||||
|
link: "/statistics",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "성공률",
|
||||||
|
value: rate,
|
||||||
|
unit: "%",
|
||||||
|
badge: { type: "icon" as const, icon: "check_circle", color: "bg-green-100 text-green-600" },
|
||||||
|
link: "/statistics",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "등록 기기 수",
|
||||||
|
value: fmt(devices),
|
||||||
|
badge: { type: "icon" as const, icon: "devices", color: "bg-blue-50 text-primary" },
|
||||||
|
link: "/devices",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "활성 서비스",
|
||||||
|
value: String(services),
|
||||||
|
badge: { type: "icon" as const, icon: "grid_view", color: "bg-purple-50 text-purple-600" },
|
||||||
|
link: "/services",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 주간 차트 랜덤 데이터 생성 */
|
||||||
|
function randomChart() {
|
||||||
|
const now = new Date();
|
||||||
|
return Array.from({ length: 7 }, (_, i) => {
|
||||||
|
const d = new Date(now);
|
||||||
|
d.setDate(d.getDate() - (6 - i));
|
||||||
|
const label = i === 6 ? "Today" : `${String(d.getMonth() + 1).padStart(2, "0")}.${String(d.getDate()).padStart(2, "0")}`;
|
||||||
|
const blue = 0.1 + Math.random() * 0.7;
|
||||||
|
const green = Math.min(blue + 0.05 + Math.random() * 0.1, 0.95);
|
||||||
|
const sent = Math.round((1 - blue) * 15000);
|
||||||
|
const reach = Math.round((1 - green) * 15000);
|
||||||
|
return { label, blue, green, sent: fmt(sent), reach: fmt(reach) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 최근 발송 내역 랜덤 데이터 생성 */
|
||||||
|
function randomMessages() {
|
||||||
|
const templates = [
|
||||||
|
"가을맞이 프로모션 알림", "정기 점검 안내", "비밀번호 변경 알림",
|
||||||
|
"신규 서비스 런칭", "야간 푸시 마케팅", "결제 완료 알림",
|
||||||
|
"이벤트 당첨 안내", "서비스 업데이트 공지", "보안 알림",
|
||||||
|
];
|
||||||
|
const statuses = ["완료", "완료", "완료", "실패", "진행", "예약"] as const;
|
||||||
|
const hours = ["09:00", "09:15", "10:30", "11:00", "14:00", "15:30", "18:00", "20:00"];
|
||||||
|
|
||||||
|
return Array.from({ length: 5 }, (_, i) => ({
|
||||||
|
template: templates[rand(0, templates.length - 1)],
|
||||||
|
targetCount: fmt(rand(1, 50000)),
|
||||||
|
status: statuses[rand(0, statuses.length - 1)],
|
||||||
|
sentAt: `2026-02-${String(rand(10, 27)).padStart(2, "0")} ${hours[rand(0, hours.length - 1)]}`,
|
||||||
|
})).sort((a, b) => (a.sentAt > b.sentAt ? -1 : 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 플랫폼 비율 랜덤 데이터 생성 */
|
||||||
|
function randomPlatform() {
|
||||||
|
const ios = rand(25, 75);
|
||||||
|
return { ios, android: 100 - ios };
|
||||||
|
}
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [cards, setCards] = useState<ReturnType<typeof randomCards> | undefined>();
|
||||||
|
const [chart, setChart] = useState<ReturnType<typeof randomChart> | undefined>();
|
||||||
|
const [messages, setMessages] = useState<ReturnType<typeof randomMessages> | undefined>();
|
||||||
|
const [platform, setPlatform] = useState<ReturnType<typeof randomPlatform> | undefined>();
|
||||||
|
|
||||||
|
const handleSearch = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setCards(randomCards());
|
||||||
|
setChart(randomChart());
|
||||||
|
setMessages(randomMessages());
|
||||||
|
setPlatform(randomPlatform());
|
||||||
|
setLoading(false);
|
||||||
|
}, 1200);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* 페이지 헤더 */}
|
{/* 페이지 헤더 */}
|
||||||
|
|
@ -15,18 +118,18 @@ export default function DashboardPage() {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 필터 */}
|
{/* 필터 */}
|
||||||
<DashboardFilter />
|
<DashboardFilter onSearch={handleSearch} loading={loading} />
|
||||||
|
|
||||||
{/* 통계 카드 */}
|
{/* 통계 카드 */}
|
||||||
<StatsCards />
|
<StatsCards cards={cards} loading={loading} />
|
||||||
|
|
||||||
{/* 7일 발송 추이 차트 */}
|
{/* 7일 발송 추이 차트 */}
|
||||||
<WeeklyChart />
|
<WeeklyChart data={chart} loading={loading} />
|
||||||
|
|
||||||
{/* 최근 발송 내역 + 플랫폼 도넛 */}
|
{/* 최근 발송 내역 + 플랫폼 도넛 */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<RecentMessages />
|
<RecentMessages messages={messages} loading={loading} />
|
||||||
<PlatformDonut />
|
<PlatformDonut data={platform} loading={loading} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user