From 59c206e0c20b2ee4b8cfc140ce0e91a879762969 Mon Sep 17 00:00:00 2001 From: SEAN Date: Fri, 27 Feb 2026 09:59:59 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EB=A1=9C=EB=94=A9=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EB=B0=8F=20=ED=95=84=ED=84=B0=20disabled=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 조회 클릭 시 전체 필터 비활성화 (날짜/드롭다운/초기화/조회) - 각 위젯 로딩 오버레이 및 스켈레톤 추가 - 조회 완료 시 랜덤 Mock 데이터로 갱신 - 공통 컴포넌트(FilterDropdown, DateRangeInput, FilterResetButton)에 disabled prop 추가 - StatsCards 뱃지 아이콘 크기 및 중앙 정렬 개선 Co-Authored-By: Claude Opus 4.6 --- .../src/components/common/DateRangeInput.tsx | 8 +- .../src/components/common/FilterDropdown.tsx | 7 +- .../components/common/FilterResetButton.tsx | 6 +- .../dashboard/components/DashboardFilter.tsx | 10 +- .../dashboard/components/PlatformDonut.tsx | 13 +- .../dashboard/components/RecentMessages.tsx | 13 +- .../dashboard/components/StatsCards.tsx | 29 +++-- .../dashboard/components/WeeklyChart.tsx | 19 ++- .../dashboard/pages/DashboardPage.tsx | 113 +++++++++++++++++- 9 files changed, 186 insertions(+), 32 deletions(-) diff --git a/react/src/components/common/DateRangeInput.tsx b/react/src/components/common/DateRangeInput.tsx index a965b7f..94d03a7 100644 --- a/react/src/components/common/DateRangeInput.tsx +++ b/react/src/components/common/DateRangeInput.tsx @@ -5,6 +5,7 @@ interface DateRangeInputProps { onEndChange: (value: string) => void; startLabel?: string; endLabel?: string; + disabled?: boolean; } /** 시작일~종료일 날짜 입력 (자동 보정) */ @@ -15,6 +16,7 @@ export default function DateRangeInput({ onEndChange, startLabel = "시작일", endLabel = "종료일", + disabled, }: DateRangeInputProps) { const handleStartChange = (value: string) => { onStartChange(value); @@ -41,7 +43,8 @@ export default function DateRangeInput({ type="date" value={startDate} 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" /> @@ -61,7 +64,8 @@ export default function DateRangeInput({ type="date" value={endDate} 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" /> diff --git a/react/src/components/common/FilterDropdown.tsx b/react/src/components/common/FilterDropdown.tsx index 82ecbd5..73ea4ff 100644 --- a/react/src/components/common/FilterDropdown.tsx +++ b/react/src/components/common/FilterDropdown.tsx @@ -6,6 +6,7 @@ interface FilterDropdownProps { options: string[]; onChange: (value: string) => void; className?: string; + disabled?: boolean; } /** 커스텀 셀렉트 드롭다운 (외부클릭 닫기) */ @@ -15,6 +16,7 @@ export default function FilterDropdown({ options, onChange, className = "", + disabled, }: FilterDropdownProps) { const [open, setOpen] = useState(false); const ref = useRef(null); @@ -39,8 +41,9 @@ export default function FilterDropdown({ )} diff --git a/react/src/features/dashboard/components/PlatformDonut.tsx b/react/src/features/dashboard/components/PlatformDonut.tsx index 436ca5b..31c7605 100644 --- a/react/src/features/dashboard/components/PlatformDonut.tsx +++ b/react/src/features/dashboard/components/PlatformDonut.tsx @@ -5,15 +5,24 @@ interface PlatformData { interface PlatformDonutProps { data?: PlatformData; + loading?: boolean; } 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; return ( -
+
+ {loading && ( +
+ + + + +
+ )}

플랫폼 비율

{/* 도넛 차트 */} diff --git a/react/src/features/dashboard/components/RecentMessages.tsx b/react/src/features/dashboard/components/RecentMessages.tsx index c9edf6c..009d85a 100644 --- a/react/src/features/dashboard/components/RecentMessages.tsx +++ b/react/src/features/dashboard/components/RecentMessages.tsx @@ -21,6 +21,7 @@ interface RecentMessage { interface RecentMessagesProps { messages?: RecentMessage[]; + loading?: boolean; } const DEFAULT_MESSAGES: RecentMessage[] = [ @@ -31,9 +32,17 @@ const DEFAULT_MESSAGES: RecentMessage[] = [ { 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 ( -
+
+ {loading && ( +
+ + + + +
+ )}

최근 발송 내역

{cards.map((card) => ( @@ -52,26 +53,30 @@ export default function StatsCards({ cards = DEFAULT_CARDS }: StatsCardsProps) { 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" > -
+

{card.label}

{card.badge?.type === "trend" && ( -
- trending_up +
+ trending_up {card.badge.value}
)} {card.badge?.type === "icon" && ( -
- {card.badge.icon} +
+ {card.badge.icon}
)}
-
- {card.value} - {card.unit && ( - {card.unit} - )} -
+ {loading ? ( +
+ ) : ( +
+ {card.value} + {card.unit && ( + {card.unit} + )} +
+ )} ))}
diff --git a/react/src/features/dashboard/components/WeeklyChart.tsx b/react/src/features/dashboard/components/WeeklyChart.tsx index 1ea6c38..6b0d0c7 100644 --- a/react/src/features/dashboard/components/WeeklyChart.tsx +++ b/react/src/features/dashboard/components/WeeklyChart.tsx @@ -11,6 +11,7 @@ interface ChartDataPoint { interface WeeklyChartProps { data?: ChartDataPoint[]; + loading?: boolean; } /** 하드코딩 기본 데이터 (HTML 시안과 동일) */ @@ -26,7 +27,7 @@ const DEFAULT_DATA: ChartDataPoint[] = [ const MARGIN = 0.02; -export default function WeeklyChart({ data = DEFAULT_DATA }: WeeklyChartProps) { +export default function WeeklyChart({ data = DEFAULT_DATA, loading }: WeeklyChartProps) { const areaRef = useRef(null); const animatedRef = useRef(false); const [hoverIndex, setHoverIndex] = useState(null); @@ -134,7 +135,13 @@ export default function WeeklyChart({ data = DEFAULT_DATA }: WeeklyChartProps) { renderLine(greenPath, greenPoints, "#22C55E"); animatedRef.current = true; - }, [data, count, xRatios]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data]); + + /* data가 바뀌면 애니메이션 재생 */ + useEffect(() => { + animatedRef.current = false; + }, [data]); useEffect(() => { drawChart(); @@ -198,6 +205,14 @@ export default function WeeklyChart({ data = DEFAULT_DATA }: WeeklyChartProps) { {/* 차트 영역 */}
+ {loading && ( +
+ + + + +
+ )} {/* Y축 라벨 + 그리드 */}
diff --git a/react/src/features/dashboard/pages/DashboardPage.tsx b/react/src/features/dashboard/pages/DashboardPage.tsx index a35271e..843cece 100644 --- a/react/src/features/dashboard/pages/DashboardPage.tsx +++ b/react/src/features/dashboard/pages/DashboardPage.tsx @@ -1,3 +1,4 @@ +import { useState, useCallback } from "react"; import PageHeader from "@/components/common/PageHeader"; import DashboardFilter from "../components/DashboardFilter"; import StatsCards from "../components/StatsCards"; @@ -5,7 +6,109 @@ import WeeklyChart from "../components/WeeklyChart"; import RecentMessages from "../components/RecentMessages"; 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() { + const [loading, setLoading] = useState(false); + const [cards, setCards] = useState | undefined>(); + const [chart, setChart] = useState | undefined>(); + const [messages, setMessages] = useState | undefined>(); + const [platform, setPlatform] = useState | undefined>(); + + const handleSearch = useCallback(() => { + setLoading(true); + setTimeout(() => { + setCards(randomCards()); + setChart(randomChart()); + setMessages(randomMessages()); + setPlatform(randomPlatform()); + setLoading(false); + }, 1200); + }, []); + return ( <> {/* 페이지 헤더 */} @@ -15,18 +118,18 @@ export default function DashboardPage() { /> {/* 필터 */} - + {/* 통계 카드 */} - + {/* 7일 발송 추이 차트 */} - + {/* 최근 발송 내역 + 플랫폼 도넛 */}
- - + +
);