feat: 대시보드 조회 로딩 상태 및 필터 disabled 처리

- 조회 클릭 시 전체 필터 비활성화 (날짜/드롭다운/초기화/조회)
- 각 위젯 로딩 오버레이 및 스켈레톤 추가
- 조회 완료 시 랜덤 Mock 데이터로 갱신
- 공통 컴포넌트(FilterDropdown, DateRangeInput, FilterResetButton)에 disabled prop 추가
- StatsCards 뱃지 아이콘 크기 및 중앙 정렬 개선

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
SEAN 2026-02-27 09:59:59 +09:00
parent 61508e25f7
commit 59c206e0c2
9 changed files with 186 additions and 32 deletions

View File

@ -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>

View File

@ -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">

View File

@ -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

View File

@ -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>

View File

@ -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>
{/* 도넛 차트 */} {/* 도넛 차트 */}

View File

@ -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

View File

@ -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>
<div className="text-2xl font-bold text-gray-900"> {loading ? (
{card.value} <div className="h-8 w-24 rounded bg-gray-100 animate-pulse" />
{card.unit && ( ) : (
<span className="text-base text-gray-400 ml-0.5 font-normal">{card.unit}</span> <div className="text-2xl font-bold text-gray-900">
)} {card.value}
</div> {card.unit && (
<span className="text-base text-gray-400 ml-0.5 font-normal">{card.unit}</span>
)}
</div>
)}
</Link> </Link>
))} ))}
</div> </div>

View File

@ -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">

View File

@ -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>
</> </>
); );