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 ( ); } 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<HTMLDivElement | null>, +) { + // 컨테이너 기준 마커 중심 좌표 (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<SVGCircleElement>( + ".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 ( - <div className="grid grid-cols-1 xl:grid-cols-2 gap-12 lg:gap-16"> - <div className="flex flex-col"> - <PlatformComparisonChart /> - </div> - <div className="flex flex-col"> - <PlatformRoasTable /> - </div> - </div> - ); -} 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 ( - <div className="flex flex-col h-full font-pretendard"> - <div className="flex flex-col gap-1 mb-4"> - <h4 className="font-body2 text-text-main font-extrabold tracking-tight"> - 플랫폼 순위 - </h4> - <div className="flex items-center gap-3"> - {LEGEND_ITEMS.map(({ label, color }) => ( - <div key={label} className="flex items-center gap-1.5"> - <span - className="w-1.5 h-1.5 rounded-full" - style={{ background: color }} - /> - <span className="font-caption font-bold text-text-sub"> - {label} - </span> - </div> - ))} - </div> - </div> - - <div className="flex-1 min-h-55" style={{ willChange: "transform" }}> - <ReactApexChart - type="bar" - options={options} - series={series} - height="100%" - /> - </div> - - <div - className="grid grid-cols-3 pt-2" - style={{ paddingLeft: `${YAXIS_LABEL_WIDTH}px` }} - > - {PLATFORM_WORDMARKS.map(({ name, src, height }) => ( - <div - key={name} - className="flex justify-center items-center opacity-80 hover:opacity-100 transition-opacity" - > - <img - src={src} - alt={name} - style={{ - height: `${height}px`, - width: "auto", - filter: "grayscale(0.2)", - }} - /> - </div> - ))} - </div> - </div> - ); -} 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: <GoogleLogo className="h-7 w-auto" />, NAVER: <NaverLogo className="h-7 w-auto" />, - kakao: <KakaoLogo className="h-7 w-auto" />, + Kakao: <KakaoLogo className="h-7 w-auto" />, }; -export default function PlatformRoasTable() { +type TPlatformName = keyof typeof platformLogoMap; + +function getPlatformLogo(name: string) { + if (name in platformLogoMap) { + return platformLogoMap[name as TPlatformName]; + } return ( - <div className="flex flex-col h-full font-pretendard"> - <div className="flex flex-col gap-1 mb-8"> - <h4 className="font-body2 text-text-main font-extrabold tracking-tight"> - 성과 순위 - </h4> - <p className="font-body2 text-text-placeholder font-medium"> - ROAS(%) = 매출 ÷ 광고비 × 100 - </p> - </div> + <span className="h-7 w-7 rounded-full bg-bg-disabled flex items-center justify-center text-xs font-bold"> + {name[0]} + </span> + ); +} - <div className="flex flex-col"> - <div className="grid grid-cols-[2.5rem_9rem_5rem_1fr] gap-4 px-3 mb-3 font-body2 text-text-placeholder uppercase tracking-wider"> +// 바 차트 비율 계산용 최대 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 ( + <TrendBadge + direction={isPos ? "up" : "down"} + value={`${Math.abs(value).toFixed(1)}%`} + /> + ); +} + +// 테이블 그리드 컬럼 레이아웃 (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 ( + <div className="@container flex flex-col h-full font-pretendard w-full"> + <div className="flex flex-col flex-1 min-w-0"> + {/* 헤더 */} + <div + className={`grid ${COL} gap-x-4 px-4 pt-2 pb-4 font-caption text-[#8B95A1] font-medium tracking-wider uppercase border-b border-[#F2F4F6]`} + > <span className="text-center">순위</span> - <span>플랫폼</span> - <span className="text-right">ROAS(%)</span> + <span className="text-left">플랫폼</span> + <span className="text-left">ROAS(%)</span> + <span className="hidden @2xl:block text-center">CTR(클릭률)</span> + <span className="hidden @2xl:block text-center">CVR(전환율)</span> <span className="text-right whitespace-nowrap">매출 / 광고비</span> </div> - <div className="flex flex-col gap-1.5 overflow-visible"> + <div className="flex flex-col pb-2 divide-y divide-[#F2F4F6]"> {platformRoasRanking.map((platform, index) => ( <div key={platform.name} - className="grid grid-cols-[2.5rem_9rem_5rem_1fr] gap-4 items-center px-3 py-4 rounded-component-lg cursor-default hover:bg-bg-surface transition-colors duration-200" + className={`group grid ${COL} gap-x-4 items-center px-4 py-4 min-h-20 cursor-default rounded-component-sm transition-all duration-300 hover:bg-[#F2F4F6]`} > - <span className="text-center font-extrabold text-text-auth-sub font-body1"> + {/* 순위 */} + <span className="text-center font-caption font-bold text-text-disabled group-hover:text-text-sub transition-colors tabular-nums"> {index + 1} </span> + + {/* 플랫폼 이름 */} <div className="flex items-center gap-3"> - <div className="shrink-0">{platformLogoMap[platform.name]}</div> - <span className="text-text-main font-body1 tracking-tight"> + <div className="shrink-0 p-1.5 group-hover:scale-105"> + {getPlatformLogo(platform.name)} + </div> + <span className="font-body1 font-bold text-text-main tracking-tight"> {platform.name} </span> </div> - <span className="text-right text-text-main font-body1 font-bold! tracking-tighter"> - {platform.roas}% - </span> - <div className="flex flex-col items-end text-right"> - <span className="font-body2 font-extrabold text-text-main leading-tight"> - ₩{platform.revenue.toLocaleString()} + + {/* ROAS 수치 + 가로 바 */} + <div className="flex flex-col justify-center gap-2 pr-4"> + <span className="font-heading4 font-extrabold text-text-main tracking-tight tabular-nums leading-none"> + {platform.roas.toLocaleString()}% + </span> + <div className="w-full h-1.5 rounded-full overflow-hidden bg-bg-disabled/40 group-hover:bg-[#E5E8EB] transition-colors duration-300"> + <div + className={`h-full rounded-full transition-all duration-700 ease-out ${roasStatusClass(platform.roas)}`} + style={{ + width: `${Math.min((platform.roas / maxRoas) * 100, 100)}%`, + }} + /> + </div> + </div> + + {/* CTR + 전기 대비 증감 */} + <div className="hidden @2xl:flex flex-col items-center justify-center gap-1.5 w-full"> + <span className="font-body1 font-bold text-text-main tracking-tight leading-none tabular-nums"> + {platform.clickRate.toLocaleString()}% + </span> + <div className="scale-[0.85] opacity-80 group-hover:opacity-100 transition-opacity duration-300"> + <Delta value={platform.ctrDelta} /> + </div> + </div> + + {/* 전환율 + 전기 대비 증감 */} + <div className="hidden @2xl:flex flex-col items-center justify-center gap-1.5 w-full"> + <span className="font-body1 font-bold text-text-main tracking-tight leading-none tabular-nums"> + {platform.conversionRate.toLocaleString()}% </span> - <span className="font-caption font-medium text-text-sub opacity-70 tracking-tight"> - ₩{platform.adCost.toLocaleString()} + <div className="scale-[0.85] opacity-80 group-hover:opacity-100 transition-opacity duration-300"> + <Delta value={platform.conversionDelta} /> + </div> + </div> + + {/* 매출/광고비 */} + <div className="flex flex-col items-end justify-center gap-2 text-right w-full"> + <span className="font-heading4 font-bold text-text-main tracking-tight leading-none tabular-nums truncate w-full"> + ₩{platform.revenue.toLocaleString()} </span> + <div className="flex items-center justify-end gap-1.5 text-[#8B95A1] font-caption w-full transition-colors group-hover:text-text-sub"> + <span className="font-medium whitespace-nowrap">광고비</span> + <span className="tabular-nums font-bold truncate"> + ₩{platform.adCost.toLocaleString()} + </span> + </div> </div> </div> ))} 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 ( + <Badge variant={statusBadgeVariant[status]} size="sm" className="px-2"> + {status} + </Badge> + ); + }, []); + return ( <div className="flex flex-col gap-8 p-6 lg:p-8 w-full min-w-0"> <div className="flex items-center justify-between"> @@ -51,23 +66,27 @@ export default function OverviewDashboard() { </p> </div> <button + type="button" onClick={() => setIsAiPanelOpen(true)} - className="p-3 rounded-full hover:bg-bg-surface-hover active:scale-95 transition-all outline-none" + className="group relative p-2 -mr-2 rounded-2xl outline-none cursor-pointer overflow-hidden" aria-label="AI 요약하기" > - <AiButtonSvg /> + <div className="absolute inset-0 z-20 pointer-events-none -translate-x-full animate-[shimmer_2.5s_infinite_linear] bg-linear-to-r from-transparent via-white/80 to-transparent skew-x-12 mix-blend-overlay" /> + <div className="relative z-10 transition-all duration-200"> + <AiButtonSvg className="[&>path:nth-of-type(4)]:transition-transform [&>path:nth-of-type(4)]:duration-300 group-hover:[&>path:nth-of-type(4)]:translate-x-0.5 [&>path:nth-of-type(5)]:transition-transform [&>path:nth-of-type(5)]:duration-300 group-hover:[&>path:nth-of-type(5)]:translate-x-1" /> + </div> </button> </div> - <div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> + <div className="grid grid-cols-2 xl:grid-cols-4 gap-4"> {overviewMockData.kpis.map((kpi) => ( <StatCard key={kpi.title} {...kpi} /> ))} </div> - <div className="grid grid-cols-1 xl:grid-cols-7 gap-6"> + <div className="grid grid-cols-1 2xl:grid-cols-7 gap-6"> <Card - className="xl:col-span-5 flex flex-col min-h-120" + className="2xl:col-span-5 flex flex-col min-h-120" title="실시간 트래픽 변화" description={ <ChartLegend @@ -82,7 +101,7 @@ export default function OverviewDashboard() { <TrafficChart /> </Card> <Card - className="xl:col-span-2 flex flex-col min-h-120" + className="2xl:col-span-2 flex flex-col 2xl:min-h-120" title="예산 소진 현황" description={ <ChartLegend @@ -93,6 +112,7 @@ export default function OverviewDashboard() { ]} /> } + RightElement={budgetStatusBadge} > <BudgetGaugeChart {...budgetGaugeChartMock} /> </Card> @@ -112,8 +132,17 @@ export default function OverviewDashboard() { <ChevronDoubleRightIcon className="w-2.5 h-auto" /> </Button> } + description={ + <div className="flex items-center gap-1.5 font-caption text-text-placeholder select-none"> + <AlertCircleIcon + className="w-3.5 h-3.5 mt-px shrink-0" + aria-hidden="true" + /> + <span>ROAS 산출: 매출 ÷ 광고비 × 100</span> + </div> + } > - <PlatformComparison /> + <PlatformRoasTable /> </Card> <Drawer