diff --git a/.claude/settings.json b/.claude/settings.json
deleted file mode 100644
index fad08561..00000000
--- a/.claude/settings.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- "permissions": {
- "allow": [
- "Bash(grep -E \"\\\\.\\(ts|tsx\\)$\")"
- ]
- }
-}
diff --git a/.gitignore b/.gitignore
index da5a9bee..82d1c5e1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,3 +26,6 @@ dist-ssr
node_modules/
*storybook.log
storybook-static
+
+# Claude Code
+.claude/
diff --git a/src/assets/icon/common/alert-circle.svg b/src/assets/icon/common/alert-circle.svg
new file mode 100644
index 00000000..9bcf8f87
--- /dev/null
+++ b/src/assets/icon/common/alert-circle.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/components/common/card/Card.tsx b/src/components/common/card/Card.tsx
index 11159927..9385a354 100644
--- a/src/components/common/card/Card.tsx
+++ b/src/components/common/card/Card.tsx
@@ -20,14 +20,14 @@ export default function Card({
return (
{hasHeader && (
-
+
{title && (
{title}
diff --git a/src/components/common/card/StatCard.tsx b/src/components/common/card/StatCard.tsx
index 8d8fcd26..bb8f6917 100644
--- a/src/components/common/card/StatCard.tsx
+++ b/src/components/common/card/StatCard.tsx
@@ -20,6 +20,25 @@ const trendClasses: Record = {
down: "bg-status-blue/[0.08] text-status-blue font-bold",
};
+export function TrendBadge({ direction, value }: ITrend) {
+ return (
+
+ {direction === "up" ? (
+
+ ) : (
+
+ )}
+ {value}
+
+ );
+}
+
export default function StatCard({
title,
value,
@@ -39,22 +58,7 @@ export default function StatCard({
{value}
- {trend && (
-
- {trend.direction === "up" ? (
-
- ) : (
-
- )}
- {trend.value}
-
- )}
+ {trend && }
);
}
diff --git a/src/components/dashboard/charts/BudgetGaugeChart.tsx b/src/components/dashboard/charts/BudgetGaugeChart.tsx
index a36d2ac8..1f56bcbc 100644
--- a/src/components/dashboard/charts/BudgetGaugeChart.tsx
+++ b/src/components/dashboard/charts/BudgetGaugeChart.tsx
@@ -1,7 +1,10 @@
-import { useEffect, useState } from "react";
import { twMerge } from "tailwind-merge";
-import Badge, { type TBadgeVariant } from "@/components/common/badge/Badge";
+import { useIsMounted } from "@/hooks/common/useIsMounted";
+
+import { type TBadgeVariant } from "@/components/common/badge/Badge";
+
+import AlertCircleIcon from "@/assets/icon/common/alert-circle.svg?react";
interface IBudgetGaugeChartProps {
totalBudget: number;
@@ -10,13 +13,25 @@ interface IBudgetGaugeChartProps {
dangerThreshold: number;
}
-const statusBadgeVariant: Record
= {
+export type TBudgetStatus = "안정" | "주의" | "위험";
+
+export function getBudgetStatus(
+ percentage: number,
+ warningThreshold: number,
+ dangerThreshold: number,
+): TBudgetStatus {
+ if (percentage >= dangerThreshold) return "위험";
+ if (percentage >= warningThreshold) return "주의";
+ return "안정";
+}
+
+export const statusBadgeVariant: Record = {
안정: "success",
주의: "syncing",
위험: "inactive",
};
-const statusPointClasses: Record = {
+const statusPointClasses: Record = {
안정: "bg-status-green",
주의: "bg-status-yellow",
위험: "bg-status-red",
@@ -28,6 +43,8 @@ export default function BudgetGaugeChart({
warningThreshold,
dangerThreshold,
}: IBudgetGaugeChartProps) {
+ const mounted = useIsMounted();
+
const percentage =
totalBudget > 0 ? Math.round((spent / totalBudget) * 100) : 0;
const isOverBudget = spent > totalBudget;
@@ -44,139 +61,126 @@ export default function BudgetGaugeChart({
(periodElapsedDays / periodTotalDays) * 100,
);
- const getStatus = () => {
- if (percentage >= dangerThreshold) return "위험";
- if (percentage >= warningThreshold) return "주의";
- return "안정";
- };
-
- const status = getStatus();
-
- const [mounted, setMounted] = useState(false);
- useEffect(() => {
- const raf = requestAnimationFrame(() => setMounted(true));
- return () => cancelAnimationFrame(raf);
- }, []);
+ const status = getBudgetStatus(percentage, warningThreshold, dangerThreshold);
+
+ // 데이터 인사이트 메시지
+ let insightDesc = "";
+
+ if (isOverBudget) {
+ insightDesc = "예산을 초과했습니다. 캠페인 조정이 필요해요.";
+ } else if (
+ percentage >= dangerThreshold ||
+ percentage > periodElapsedRate + 15
+ ) {
+ insightDesc = "예산 소진이 빨라요. 일일 한도 점검을 추천해요.";
+ } else if (
+ percentage >= warningThreshold ||
+ percentage > periodElapsedRate + 5
+ ) {
+ insightDesc = "예산 소진 속도가 다소 높아요. 매체 효율을 확인해 보세요.";
+ } else if (percentage < periodElapsedRate - 5) {
+ insightDesc = "예산이 여유로워요. 효율이 좋은 매체에 더 투자해 보세요.";
+ } else {
+ insightDesc = "계획된 일정에 맞게 예산이 잘 사용되고 있어요.";
+ }
return (
-
-
-
-
-
- {percentage}%
-
-
- 소진
-
-
-
-
- {status}
-
+
+
+
+
+ 이번 달 사용 예산
+
-
-
100
- ? `예산 소진율 ${percentage}%, 예산을 초과했습니다`
- : `예산 소진율 ${Math.max(percentage, 0)}%`
- }
- className="relative h-3 w-full bg-bg-disabled/40 rounded-full overflow-hidden shadow-[inset_0_1px_3px_rgba(0,0,0,0.08)]"
- >
-
-
+
+
+ {percentage}%
+
+
+ 소진
+
-
-
- 총 목표 예산
-
- ₩{totalBudget.toLocaleString()}
-
-
-
-
- 현재 사용액
-
-
- ₩{spent.toLocaleString()}
-
+
+
-
-
기간 진행률
-
- {periodElapsedDays}/{periodTotalDays}일{" "}
-
- ({periodElapsedRate}%)
-
-
+
+
+ ₩{spent.toLocaleString()}
+ ₩{totalBudget.toLocaleString()}
-
-
-
- {isOverBudget ? "초과 지출" : "이번 달 잔액"}
+
+
+
+ 남은 예산
- ₩{remaining.toLocaleString()}
+ {isOverBudget ? "-" : ""}₩{remaining.toLocaleString()}
-
-
-
-
-
- 기간의 {periodElapsedRate}%가 경과했으며, 예산은{" "}
-
- {status}
- {" "}
- 수준으로 운용되고 있습니다.
-
+
+
+
+ 기간 진행률
+
+
+ {periodElapsedRate}%
+
+
+
+ {periodElapsedDays}일
+
+ {" "}
+ / {periodTotalDays}일
+
+
+
+
);
}
diff --git a/src/components/dashboard/charts/TrafficChart.tsx b/src/components/dashboard/charts/TrafficChart.tsx
index 3a21c6c1..6561abfb 100644
--- a/src/components/dashboard/charts/TrafficChart.tsx
+++ b/src/components/dashboard/charts/TrafficChart.tsx
@@ -1,136 +1,21 @@
-import { useMemo } from "react";
+import { useRef, useState } from "react";
import ReactApexChart from "react-apexcharts";
-import type { ApexOptions } from "apexcharts";
-import {
- downloadChartCsv,
- downloadChartPng,
- downloadChartSvg,
-} from "@/utils/download";
+import { useIsMounted } from "@/hooks/common/useIsMounted";
import { DropdownMenu } from "@/components/common/dropdownmenu/DropdownMenu";
+import {
+ BASE_OPTIONS,
+ CHART_CONTAINER_ID,
+ DOWNLOAD_ITEMS,
+} from "./trafficChart.config";
import { trafficChartMock } from "./trafficChart.mock";
+import { useAnomalyMarkerPos } from "./useAnomalyMarkerPos";
import MoreIcon from "@/assets/icon/ai-report/more.svg?react";
-const CHART_ID = "traffic-chart";
-const TODAY = new Date().toISOString().slice(0, 10);
-
-// x축 시간대
-const LABEL_HOURS = new Set(["00:00", "06:00", "12:00", "18:00", "24:00"]);
-
-const BASE_OPTIONS: ApexOptions = {
- chart: {
- id: CHART_ID,
- type: "area",
- events: {
- mounted: (chartContext: { el: Element }) => {
- chartContext.el.querySelector("svg > title")?.remove();
- },
- },
- toolbar: {
- show: true,
- tools: {
- download: false,
- selection: false,
- zoom: false,
- zoomin: false,
- zoomout: false,
- pan: false,
- reset: false,
- },
- export: {
- csv: {
- filename: `overview-traffic-data-${TODAY}`,
- columnDelimiter: ",",
- headerCategory: "시간",
- headerValue: "클릭수",
- },
- },
- },
- zoom: { enabled: false },
- fontFamily: "Pretendard",
- animations: { enabled: true, dynamicAnimation: { enabled: false } },
- },
- dataLabels: { enabled: false }, // 각 포인트 위 숫자 레이블 숨김
- stroke: { curve: "monotoneCubic", width: 1.5 },
- // 라인 아래 그라데이션
- fill: {
- type: "gradient",
- gradient: {
- shadeIntensity: 1,
- opacityFrom: 0.5,
- stops: [0, 90, 100],
- },
- },
- colors: ["#0084fe"], // --color-status-blue
- markers: { size: 0 }, // 데이터 포인트 마커 숨김
- xaxis: {
- type: "category",
- categories: trafficChartMock.labels, // 00:00 ~ 24:00
- tickAmount: 24, // 1시간 간격 tick 생성
- labels: {
- // LABEL_HOURS에 해당하는 시간대만 표시, 나머지는 빈 문자열
- formatter: (val: string) => (LABEL_HOURS.has(val) ? val : ""),
- style: { colors: "#8b8b8f", fontSize: "12px" }, // --color-text-sub
- rotate: 0, // 레이블 수평 고정
- rotateAlways: false, // 자동 회전 방지
- },
- axisBorder: { show: false }, // x축 하단 경계선 숨김
- axisTicks: { show: false }, // tick 눈금 숨김
- tooltip: { enabled: false }, // 호버 시 x축 tooltip 숨김
- },
- yaxis: {
- min: 0,
- tickAmount: 6, // 1만 단위 눈금
- labels: {
- // 0은 숨기고, 나머지는 K 단위로 변환
- formatter: (val: number) =>
- val === 0 ? "" : `${(val / 1000).toFixed(0)}K`,
- style: { colors: "#8b8b8f", fontSize: "12px" }, // --color-text-sub
- },
- },
- grid: {
- borderColor: "#f2f4f6", // --color-chart-inactive
- xaxis: { lines: { show: false } }, // 세로 그리드선 숨김
- yaxis: { lines: { show: true } }, // 가로 그리드선 표시
- padding: { left: 16, right: 8 }, // y축 레이블와 차트 사이 여백 줌
- },
- tooltip: {
- x: { show: false }, // 상단 시간 헤더 숨김
- style: { fontFamily: "Pretendard" },
- },
-};
-
-// TODO: 실시간 연동 시 useState로 전환
-const series = [
- {
- name: "클릭수",
- data: trafficChartMock.labels.map((label, i) => ({
- x: label,
- y: trafficChartMock.clicks[i],
- })),
- },
-];
-
-const CHART_CONTAINER_ID = `${CHART_ID}-container`;
-const FILENAME = `overview-traffic-chart-${TODAY}`;
-const DOWNLOAD_ITEMS = [
- {
- label: "PNG 저장",
- onClick: () => downloadChartPng(CHART_ID, FILENAME),
- },
- {
- label: "SVG 저장",
- onClick: () => downloadChartSvg(CHART_CONTAINER_ID, FILENAME),
- },
- {
- label: "CSV 다운로드",
- onClick: () => downloadChartCsv(CHART_ID),
- },
-];
-
+// 차트 우측 상단 다운로드 버튼
export function TrafficChartDownload() {
return (
+
+
+ 클릭 이상 징후 감지
+
+
+ 구글-캠페인 A-광고 1
+
+ 부정 클릭 의심
+
+
+
+
+ );
+}
+
export default function TrafficChart() {
- const options = useMemo
(() => {
- const values =
- series[0]?.data.map((d: { x: string; y: number }) => d.y) ?? [];
- const yAxisMax =
- values.length > 0
- ? Math.ceil(Math.max(...values) / 10000) * 10000
- : 10000;
+ const isMounted = useIsMounted();
+ const containerRef = useRef(null);
- return { ...BASE_OPTIONS, yaxis: { ...BASE_OPTIONS.yaxis, max: yAxisMax } };
- }, [series]);
+ // 빨간 점의 컨테이너 기준 좌표
+ const markerPos = useAnomalyMarkerPos(containerRef);
+ const [isAnomalyHovered, setIsAnomalyHovered] = useState(false);
+ const [isAnomalyFocused, setIsAnomalyFocused] = useState(false);
return (
-
+ {isMounted && (
+
({ x: i, y })),
+ },
+ ]}
+ height={400}
+ />
+ )}
+ {markerPos && (
+ <>
+
);
}
diff --git a/src/components/dashboard/charts/trafficChart.config.ts b/src/components/dashboard/charts/trafficChart.config.ts
new file mode 100644
index 00000000..8adcb897
--- /dev/null
+++ b/src/components/dashboard/charts/trafficChart.config.ts
@@ -0,0 +1,171 @@
+import type { ApexOptions } from "apexcharts";
+
+import {
+ downloadChartCsv,
+ downloadChartPng,
+ downloadChartSvg,
+} from "@/utils/download";
+
+import { trafficChartMock } from "./trafficChart.mock";
+
+// 차트 고유 ID
+export const CHART_ID = "traffic-chart";
+
+// 이상 징후 포인트 인덱스 (ex.11시)
+export const ANOMALY_INDEX = 11;
+const ANOMALY_Y = trafficChartMock.clicks[ANOMALY_INDEX];
+
+// 파일 저장 시 이름에 포함
+const TODAY = new Date().toISOString().slice(0, 10);
+
+// x축에 표시할 시간 목록
+const LABEL_HOURS = new Set([0, 6, 12, 18, 24]);
+
+export const BASE_OPTIONS: ApexOptions = {
+ chart: {
+ id: CHART_ID,
+ type: "area",
+ events: {
+ // SVG 및 canvas 폴백 텍스트 제거
+ mounted: (chartContext: { el: Element }) => {
+ chartContext.el.querySelectorAll("title").forEach((el) => {
+ el.remove();
+ });
+ const canvas = chartContext.el.querySelector("canvas");
+ if (canvas) canvas.textContent = "";
+ },
+ },
+ toolbar: {
+ show: true,
+ // 기본 툴바 버튼 모두 숨김
+ tools: {
+ download: false,
+ selection: false,
+ zoom: false,
+ zoomin: false,
+ zoomout: false,
+ pan: false,
+ reset: false,
+ },
+ export: {
+ csv: {
+ filename: `overview-traffic-data-${TODAY}`,
+ columnDelimiter: ",",
+ headerCategory: "시간",
+ headerValue: "클릭수",
+ },
+ },
+ },
+ zoom: { enabled: false },
+ fontFamily: "Pretendard",
+ animations: {
+ enabled: true,
+ dynamicAnimation: { enabled: false }, // 데이터 업데이트 시 애니메이션 비활성화
+ },
+ },
+
+ dataLabels: { enabled: false },
+
+ stroke: {
+ curve: "monotoneCubic", // 부드러운 곡선
+ width: 1.5,
+ },
+
+ // 라인 아래 그라데이션 채우기
+ fill: {
+ type: "gradient",
+ gradient: {
+ shadeIntensity: 1,
+ opacityFrom: 0.5,
+ stops: [0, 90, 100],
+ },
+ },
+
+ colors: ["#0084fe"],
+
+ markers: { size: 0 }, // 기본 마커 숨김
+
+ // 이상 징후 위치에 빨간 점 표시
+ annotations: {
+ points:
+ ANOMALY_Y === undefined
+ ? []
+ : [
+ {
+ x: ANOMALY_INDEX,
+ y: ANOMALY_Y,
+ marker: {
+ size: 5,
+ fillColor: "#ff4560",
+ strokeColor: "#ff4560",
+ strokeWidth: 1,
+ },
+ },
+ ],
+ },
+
+ xaxis: {
+ type: "numeric",
+ min: 0,
+ max: 24,
+ tickAmount: 24, // 1시간 간격
+ labels: {
+ // 0, 6, 12, 18, 24시만 표시
+ formatter: (val: string) => {
+ const n = Number(val);
+ return LABEL_HOURS.has(n) ? `${String(n).padStart(2, "0")}:00` : "";
+ },
+ style: { colors: "#8b8b8f", fontSize: "12px" },
+ rotate: 0,
+ rotateAlways: false,
+ },
+ axisBorder: { show: false },
+ axisTicks: { show: false },
+ tooltip: { enabled: false }, // x축 기본 툴팁 숨김
+ },
+
+ yaxis: {
+ min: 0,
+ max: Math.ceil(Math.max(...trafficChartMock.clicks) / 10000) * 10000,
+ tickAmount: 6,
+ labels: {
+ // 0 숨김, 나머지는 K 단위로 표시
+ formatter: (val: number) =>
+ val === 0 ? "" : `${(val / 1000).toFixed(0)}K`,
+ style: { colors: "#8b8b8f", fontSize: "12px" },
+ },
+ },
+
+ grid: {
+ borderColor: "#f2f4f6",
+ xaxis: { lines: { show: false } }, // 세로선 숨김
+ yaxis: { lines: { show: true } }, // 가로선 표시
+ padding: { left: 16, right: 8 },
+ },
+
+ tooltip: {
+ x: { show: false },
+ style: { fontFamily: "Pretendard" },
+ },
+};
+
+// SVG 다운로드 시 컨테이너 요소를 직접 참조하기 위한 ID
+export const CHART_CONTAINER_ID = `${CHART_ID}-container`;
+
+const FILENAME = `overview-traffic-chart-${TODAY}`;
+
+// 다운로드 드롭다운 항목
+export const DOWNLOAD_ITEMS = [
+ {
+ label: "PNG 저장",
+ onClick: () => downloadChartPng(CHART_ID, FILENAME),
+ },
+ {
+ label: "SVG 저장",
+ onClick: () => downloadChartSvg(CHART_CONTAINER_ID, FILENAME),
+ },
+ {
+ label: "CSV 다운로드",
+ onClick: () => downloadChartCsv(CHART_ID),
+ },
+];
diff --git a/src/components/dashboard/charts/useAnomalyMarkerPos.ts b/src/components/dashboard/charts/useAnomalyMarkerPos.ts
new file mode 100644
index 00000000..de8a7d7c
--- /dev/null
+++ b/src/components/dashboard/charts/useAnomalyMarkerPos.ts
@@ -0,0 +1,41 @@
+import { type RefObject, useCallback, useEffect, useState } from "react";
+
+// 이상 징후 빨간 점(annotation marker)의 컨테이너 기준 좌표를 반환하는 훅
+export function useAnomalyMarkerPos(
+ containerRef: RefObject,
+) {
+ // 컨테이너 기준 마커 중심 좌표 (null = 아직 못 찾음)
+ const [markerPos, setMarkerPos] = useState<{ x: number; y: number } | null>(
+ null,
+ );
+
+ const updateMarkerPos = useCallback(() => {
+ if (!containerRef.current) return;
+ // DOM에서 ApexCharts annotation 마커 요소 탐색
+ const marker = containerRef.current.querySelector(
+ ".apexcharts-point-annotation-marker",
+ );
+ if (!marker) return;
+
+ // getBoundingClientRect으로 뷰포트 기준 위치 계산 후 컨테이너 기준으로 변환
+ const markerRect = marker.getBoundingClientRect();
+ const containerRect = containerRef.current.getBoundingClientRect();
+ setMarkerPos({
+ x: markerRect.left + markerRect.width / 2 - containerRect.left,
+ y: markerRect.top + markerRect.height / 2 - containerRect.top,
+ });
+ }, [containerRef]);
+
+ // 차트 마운트 후(300ms 대기) 및 리사이즈 시 좌표 갱신
+ useEffect(() => {
+ const timer = setTimeout(updateMarkerPos, 300);
+ const observer = new ResizeObserver(updateMarkerPos);
+ if (containerRef.current) observer.observe(containerRef.current);
+ return () => {
+ clearTimeout(timer);
+ observer.disconnect();
+ };
+ }, [updateMarkerPos, containerRef]);
+
+ return markerPos;
+}
diff --git a/src/components/dashboard/platform/PlatformComparison.tsx b/src/components/dashboard/platform/PlatformComparison.tsx
deleted file mode 100644
index 0d792cfa..00000000
--- a/src/components/dashboard/platform/PlatformComparison.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-import PlatformComparisonChart from "./PlatformComparisonChart";
-import PlatformRoasTable from "./PlatformRoasTable";
-
-export default function PlatformComparison() {
- return (
-
- );
-}
diff --git a/src/components/dashboard/platform/PlatformComparisonChart.tsx b/src/components/dashboard/platform/PlatformComparisonChart.tsx
deleted file mode 100644
index b868aa92..00000000
--- a/src/components/dashboard/platform/PlatformComparisonChart.tsx
+++ /dev/null
@@ -1,151 +0,0 @@
-import ReactApexChart from "react-apexcharts";
-import type { ApexOptions } from "apexcharts";
-
-import { platformComparisonMock } from "./platformComparison.mock";
-
-import googleWordmarkUrl from "@/assets/icon/ads/google-wordmark.svg?url";
-import kakaoWordmarkUrl from "@/assets/icon/ads/kakao-wordmark.svg?url";
-import naverWordmarkUrl from "@/assets/icon/ads/naver-wordmark.svg?url";
-
-const PLATFORM_WORDMARKS = [
- { name: "Google", src: googleWordmarkUrl, height: 20 },
- { name: "NAVER", src: naverWordmarkUrl, height: 13 },
- { name: "kakao", src: kakaoWordmarkUrl, height: 16 },
-];
-
-const YAXIS_LABEL_WIDTH = 42;
-
-const LEGEND_ITEMS = [
- { label: "클릭률", color: "#0084fe" },
- { label: "전환률", color: "#22c55e" },
- { label: "노출수", color: "#CBD5E1" },
-];
-
-const options: ApexOptions = {
- chart: {
- type: "bar",
- events: {
- mounted: (chartContext: { el: Element }) => {
- chartContext.el.querySelector("svg > title")?.remove();
- },
- },
- toolbar: { show: false },
- fontFamily: "Pretendard",
- animations: {
- enabled: true,
- speed: 800,
- dynamicAnimation: { enabled: true, speed: 350 },
- },
- },
- plotOptions: {
- bar: {
- horizontal: false,
- columnWidth: "75%",
- borderRadius: 8,
- borderRadiusApplication: "end",
- },
- },
- dataLabels: { enabled: false },
- colors: ["#0084fe", "#22c55e", "#CBD5E1"],
- stroke: { show: true, width: 2, colors: ["transparent"] },
- xaxis: {
- categories: platformComparisonMock.map((p) => p.name),
- axisBorder: { show: false },
- axisTicks: { show: false },
- labels: { show: false },
- },
- yaxis: {
- min: 0,
- max: 100,
- tickAmount: 4,
- labels: {
- minWidth: YAXIS_LABEL_WIDTH,
- maxWidth: YAXIS_LABEL_WIDTH,
- formatter: (val: number) => `${val}%`,
- style: { colors: "#b0b8c1", fontSize: "11px", fontWeight: 500 },
- },
- },
- fill: { opacity: 1 },
- grid: {
- borderColor: "#F2F4F6",
- strokeDashArray: 4,
- xaxis: { lines: { show: false } },
- yaxis: { lines: { show: true } },
- padding: { left: 0, right: 0, top: -10 },
- },
- legend: { show: false },
- tooltip: {
- shared: true,
- intersect: false,
- y: { formatter: (val: number) => `${val}%` },
- style: { fontFamily: "Pretendard" },
- theme: "light",
- },
- states: {
- hover: {
- filter: { type: "none" },
- },
- },
-};
-
-const series = [
- { name: "클릭률", data: platformComparisonMock.map((p) => p.clickRate) },
- { name: "전환률", data: platformComparisonMock.map((p) => p.conversionRate) },
- { name: "노출수", data: platformComparisonMock.map((p) => p.impressionRate) },
-];
-
-export default function PlatformComparisonChart() {
- return (
-
-
-
- 플랫폼 순위
-
-
- {LEGEND_ITEMS.map(({ label, color }) => (
-
-
-
- {label}
-
-
- ))}
-
-
-
-
-
-
-
-
- {PLATFORM_WORDMARKS.map(({ name, src, height }) => (
-
-

-
- ))}
-
-
- );
-}
diff --git a/src/components/dashboard/platform/PlatformRoasTable.tsx b/src/components/dashboard/platform/PlatformRoasTable.tsx
index 0d827aed..b8d4fbab 100644
--- a/src/components/dashboard/platform/PlatformRoasTable.tsx
+++ b/src/components/dashboard/platform/PlatformRoasTable.tsx
@@ -1,60 +1,139 @@
+import { TrendBadge } from "@/components/common/card/StatCard";
+
import { platformRoasRanking } from "./platformComparison.mock";
import GoogleLogo from "@/assets/icon/ads/google-circle.svg?react";
import KakaoLogo from "@/assets/icon/ads/kakao-circle.svg?react";
import NaverLogo from "@/assets/icon/ads/naver-circle.svg?react";
+// 플랫폼 로고 컴포넌트 매핑
const platformLogoMap = {
Google: ,
NAVER: ,
- kakao: ,
+ Kakao: ,
};
-export default function PlatformRoasTable() {
+type TPlatformName = keyof typeof platformLogoMap;
+
+function getPlatformLogo(name: string) {
+ if (name in platformLogoMap) {
+ return platformLogoMap[name as TPlatformName];
+ }
return (
-
-
-
- 성과 순위
-
-
- ROAS(%) = 매출 ÷ 광고비 × 100
-
-
+
+ {name[0]}
+
+ );
+}
-
-
+// 바 차트 비율 계산용 최대 ROAS
+const maxRoas = Math.max(...platformRoasRanking.map((p) => p.roas));
+
+// ROAS 수치에 따라 배경색 유틸리티 클래스 반환
+function roasStatusClass(roas: number) {
+ if (roas >= 200) return "bg-status-green";
+ if (roas >= 100) return "bg-status-blue";
+ return "bg-status-red";
+}
+
+// 증감률 뱃지 컴포넌트
+function Delta({ value }: { value: number }) {
+ const isPos = value >= 0;
+ return (
+
+ );
+}
+
+// 테이블 그리드 컬럼 레이아웃 (full: 6열, compact: CTR/CVR 숨김 4열)
+const COL =
+ "grid-cols-[32px_1.5fr_2.5fr_1.5fr] @2xl:grid-cols-[32px_1.5fr_2.5fr_1fr_1fr_1.5fr]";
+
+export default function PlatformRoasTable() {
+ return (
+
+
+ {/* 헤더 */}
+
순위
- 플랫폼
- ROAS(%)
+ 플랫폼
+ ROAS(%)
+ CTR(클릭률)
+ CVR(전환율)
매출 / 광고비
-
+
{platformRoasRanking.map((platform, index) => (
-
+ {/* 순위 */}
+
{index + 1}
+
+ {/* 플랫폼 이름 */}
-
{platformLogoMap[platform.name]}
-
+
+ {getPlatformLogo(platform.name)}
+
+
{platform.name}
-
- {platform.roas}%
-
-
-
- ₩{platform.revenue.toLocaleString()}
+
+ {/* ROAS 수치 + 가로 바 */}
+
+
+ {platform.roas.toLocaleString()}%
+
+
+
+
+ {/* CTR + 전기 대비 증감 */}
+
+
+ {platform.clickRate.toLocaleString()}%
+
+
+
+
+
+
+ {/* 전환율 + 전기 대비 증감 */}
+
+
+ {platform.conversionRate.toLocaleString()}%
-
- ₩{platform.adCost.toLocaleString()}
+
+
+
+
+
+ {/* 매출/광고비 */}
+
+
+ ₩{platform.revenue.toLocaleString()}
+
+ 광고비
+
+ ₩{platform.adCost.toLocaleString()}
+
+
))}
diff --git a/src/components/dashboard/platform/platformComparison.mock.ts b/src/components/dashboard/platform/platformComparison.mock.ts
index a8f99508..2fcad992 100644
--- a/src/components/dashboard/platform/platformComparison.mock.ts
+++ b/src/components/dashboard/platform/platformComparison.mock.ts
@@ -1,8 +1,9 @@
export interface IPlatformStats {
- name: "Google" | "NAVER" | "kakao";
+ name: "Google" | "NAVER" | "Kakao";
clickRate: number; // 클릭률 (%)
+ ctrDelta: number; // 전일 대비 CTR 증감 (%)
conversionRate: number; // 전환률 (%)
- impressionRate: number; // 노출수 (%)
+ conversionDelta: number; // 전일 대비 전환율 증감 (%)
roas: number; // ROAS (%)
revenue: number; // 매출 (원)
adCost: number; // 광고비 (원)
@@ -12,8 +13,9 @@ export const platformComparisonMock: IPlatformStats[] = [
{
name: "Google",
clickRate: 45,
+ ctrDelta: 2.3,
conversionRate: 50,
- impressionRate: 62,
+ conversionDelta: 1.1,
roas: 320,
revenue: 12300000,
adCost: 3840000,
@@ -21,17 +23,19 @@ export const platformComparisonMock: IPlatformStats[] = [
{
name: "NAVER",
clickRate: 42,
+ ctrDelta: -1.2,
conversionRate: 55,
- impressionRate: 65,
+ conversionDelta: 0.8,
roas: 210,
revenue: 9100000,
adCost: 4330000,
},
{
- name: "kakao",
+ name: "Kakao",
clickRate: 40,
+ ctrDelta: 0.5,
conversionRate: 48,
- impressionRate: 60,
+ conversionDelta: -0.3,
roas: 95,
revenue: 2300000,
adCost: 2420000,
diff --git a/src/hooks/common/useIsMounted.ts b/src/hooks/common/useIsMounted.ts
new file mode 100644
index 00000000..1b861818
--- /dev/null
+++ b/src/hooks/common/useIsMounted.ts
@@ -0,0 +1,13 @@
+import { useEffect, useState } from "react";
+
+// 클라이언트 마운트 여부 반환 (SSR 환경에서 클라이언트 전용 렌더링 제어용)
+export function useIsMounted() {
+ const [isMounted, setIsMounted] = useState(false);
+
+ useEffect(() => {
+ const raf = requestAnimationFrame(() => setIsMounted(true));
+ return () => cancelAnimationFrame(raf);
+ }, []);
+
+ return isMounted;
+}
diff --git a/src/pages/dashboard/overview/OverviewDashboard.tsx b/src/pages/dashboard/overview/OverviewDashboard.tsx
index a0969b30..75a72b4b 100644
--- a/src/pages/dashboard/overview/OverviewDashboard.tsx
+++ b/src/pages/dashboard/overview/OverviewDashboard.tsx
@@ -4,41 +4,56 @@ import { toast } from "sonner";
import { printAsPdf } from "@/utils/download";
+import Badge from "@/components/common/badge/Badge";
import Button from "@/components/common/button/Button";
import Card from "@/components/common/card/Card";
import StatCard from "@/components/common/card/StatCard";
import ChartLegend from "@/components/common/chart/ChartLegend";
import Drawer from "@/components/common/drawer/Drawer";
-import BudgetGaugeChart from "@/components/dashboard/charts/BudgetGaugeChart";
+import BudgetGaugeChart, {
+ getBudgetStatus,
+ statusBadgeVariant,
+} from "@/components/dashboard/charts/BudgetGaugeChart";
import { budgetGaugeChartMock } from "@/components/dashboard/charts/budgetGaugeChart.mock";
import TrafficChart, {
TrafficChartDownload,
} from "@/components/dashboard/charts/TrafficChart";
-import PlatformComparison from "@/components/dashboard/platform/PlatformComparison";
+import PlatformRoasTable from "@/components/dashboard/platform/PlatformRoasTable";
import { overviewMockData } from "./overview.mock";
import OverviewAiReportPanel from "./OverviewAiReportPanel";
import DownloadIcon from "@/assets/icon/ai-report/download.svg?react";
import LinkIcon from "@/assets/icon/ai-report/link.svg?react";
+import AlertCircleIcon from "@/assets/icon/common/alert-circle.svg?react";
import ChevronDoubleRightIcon from "@/assets/icon/common/chevron-double-right.svg?react";
import AiButtonSvg from "@/assets/logo/ai-요약버튼.svg?react";
export default function OverviewDashboard() {
const navigate = useNavigate();
const [isAiPanelOpen, setIsAiPanelOpen] = useState(false);
- const currentDate = useMemo(
- () =>
- new Date().toLocaleString("ko-KR", {
- year: "numeric",
- month: "2-digit",
- day: "2-digit",
- hour: "2-digit",
- minute: "2-digit",
- }),
- [],
+ const [currentDate] = useState(() =>
+ new Date().toLocaleString("ko-KR", {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ }),
);
+ const budgetStatusBadge = useMemo(() => {
+ const { totalBudget, spent, warningThreshold, dangerThreshold } =
+ budgetGaugeChartMock;
+ const pct = totalBudget > 0 ? Math.round((spent / totalBudget) * 100) : 0;
+ const status = getBudgetStatus(pct, warningThreshold, dangerThreshold);
+ return (
+
+ {status}
+
+ );
+ }, []);
+
return (
@@ -51,23 +66,27 @@ export default function OverviewDashboard() {
-
+
{overviewMockData.kpis.map((kpi) => (
))}
-
+
}
+ RightElement={budgetStatusBadge}
>
@@ -112,8 +132,17 @@ export default function OverviewDashboard() {
}
+ description={
+
+
+
ROAS 산출: 매출 ÷ 광고비 × 100
+
+ }
>
-
+