diff --git a/src/assets/logo/social-logo/wordmark/meta-wordmark.svg b/src/assets/logo/social-logo/wordmark/meta-wordmark.svg new file mode 100644 index 00000000..cae85183 --- /dev/null +++ b/src/assets/logo/social-logo/wordmark/meta-wordmark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/logo/social-logo/wordmark/naver-wordmark.svg b/src/assets/logo/social-logo/wordmark/naver-wordmark.svg new file mode 100644 index 00000000..a97c4b27 --- /dev/null +++ b/src/assets/logo/social-logo/wordmark/naver-wordmark.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/components/dashboard/charts/BudgetGaugeChart.tsx b/src/components/dashboard/charts/BudgetGaugeChart.tsx index 5e99ac75..ffbd6f45 100644 --- a/src/components/dashboard/charts/BudgetGaugeChart.tsx +++ b/src/components/dashboard/charts/BudgetGaugeChart.tsx @@ -127,7 +127,7 @@ const BudgetGaugeChart = memo(function BudgetGaugeChart({ -
+
남은 예산 @@ -142,7 +142,7 @@ const BudgetGaugeChart = memo(function BudgetGaugeChart({
-
+
+ ); +} diff --git a/src/components/dashboard/platform/PlatformDetailTable.tsx b/src/components/dashboard/platform/PlatformDetailTable.tsx new file mode 100644 index 00000000..7291f782 --- /dev/null +++ b/src/components/dashboard/platform/PlatformDetailTable.tsx @@ -0,0 +1,157 @@ +import { useMemo } from "react"; + +import type { IPlatformDailyPerformance } from "@/pages/dashboard/platform/platformDashboard.mock"; + +interface IPlatformDetailTableProps { + data: IPlatformDailyPerformance[]; +} + +function PlatformDetailTable({ data }: IPlatformDetailTableProps) { + // 합계 계산 + const total = useMemo(() => { + if (!data.length) return null; + const totalSpend = data.reduce((acc, curr) => acc + curr.spend, 0); + const totalImpressions = data.reduce( + (acc, curr) => acc + curr.impressions, + 0, + ); + const totalClicks = data.reduce((acc, curr) => acc + curr.clicks, 0); + const totalConversions = data.reduce( + (acc, curr) => acc + curr.conversions, + 0, + ); + return { + spend: totalSpend, + impressions: totalImpressions, + clicks: totalClicks, + ctr: totalImpressions > 0 ? (totalClicks / totalImpressions) * 100 : 0, + cpc: totalClicks > 0 ? totalSpend / totalClicks : 0, + conversions: totalConversions, + roas: + totalSpend > 0 + ? data.reduce((acc, curr) => acc + curr.roas * curr.spend, 0) / + totalSpend + : 0, + }; + }, [data]); + + return ( +
+ + +
+ + + + + + + + + + + + + + + {/* 합계 행 */} + {total && ( + + + + + + + + + + + )} + {/* 일별 데이터 */} + {data.map((row, idx) => ( + + + + + + + + + + + ))} + +
+ 날짜 + + 비용(지출) + + 노출 수 + + 클릭 수 + + CTR(클릭률) + + CPC + + 전환 수 + + ROAS +
합계 + ₩{total.spend.toLocaleString()} + + {total.impressions.toLocaleString()} + + {total.clicks.toLocaleString()} + + {total.ctr.toFixed(2)}% + + ₩{Math.round(total.cpc).toLocaleString()} + + {total.conversions.toLocaleString()} + + {Math.round(total.roas)}% +
+ {row.date} + + ₩{row.spend.toLocaleString()} + + {row.impressions.toLocaleString()} + + {row.clicks.toLocaleString()} + + {row.ctr.toFixed(2)}% + + ₩{row.cpc.toLocaleString()} + + {row.conversions.toLocaleString()} + + {row.roas}% +
+
+
+ ); +} + +export default PlatformDetailTable; diff --git a/src/components/dashboard/platform/SinglePlatformView.tsx b/src/components/dashboard/platform/SinglePlatformView.tsx new file mode 100644 index 00000000..2cd89011 --- /dev/null +++ b/src/components/dashboard/platform/SinglePlatformView.tsx @@ -0,0 +1,252 @@ +import React, { useMemo } from "react"; +import { twMerge } from "tailwind-merge"; + +import Badge from "@/components/common/badge/Badge"; +import Card from "@/components/common/card/Card"; +import StatCard from "@/components/common/card/StatCard"; +import ChartLegend from "@/components/common/chart/ChartLegend"; +import { Skeleton } from "@/components/common/skeleton/Skeleton"; +import BudgetGaugeChart, { + getBudgetStatus, + statusBadgeVariant, +} from "@/components/dashboard/charts/BudgetGaugeChart"; +import PlatformDetailTable from "@/components/dashboard/platform/PlatformDetailTable"; + +import AiButtonSvg from "@/assets/logo/service-logo/ai-요약버튼.svg?react"; +import GoogleLogo from "@/assets/logo/social-logo/wordmark/google-wordmark.svg?react"; +import MetaLogo from "@/assets/logo/social-logo/wordmark/meta-wordmark.svg?react"; +import NaverLogo from "@/assets/logo/social-logo/wordmark/naver-wordmark.svg?react"; +import { + budgetStatusMock, + performanceEfficiencyMock, + platformDailyPerformanceMock, +} from "@/pages/dashboard/platform/platformDashboard.mock"; + +const PLATFORM_LOGOS: Record< + string, + { component: React.FC>; className: string } +> = { + GOOGLE: { component: GoogleLogo, className: "h-10" }, + NAVER: { component: NaverLogo, className: "h-6 ml-2" }, + META: { component: MetaLogo, className: "h-6 ml-2" }, +}; + +interface ISinglePlatformViewProps { + platform: string; + isLoading: boolean; +} + +export default function SinglePlatformView({ + platform, + isLoading, +}: ISinglePlatformViewProps) { + const [viewRange, setViewRange] = React.useState<7 | 30>(7); + + const platformData = useMemo(() => { + return performanceEfficiencyMock.find( + (p) => p.provider.toUpperCase() === platform.toUpperCase(), + ); + }, [platform]); + + const kpis = useMemo(() => { + if (!platformData) return []; + + return [ + { + title: "클릭수(CTR)", + value: platformData.clicks.toLocaleString(), + trend: { + direction: platformData.clickChangeRate >= 0 ? "up" : "down", + value: `${Math.abs(platformData.clickChangeRate * 100).toFixed(2)}%`, + }, + }, + { + title: "노출수", + value: platformData.impressions.toLocaleString(), + trend: { + direction: platformData.impressionChangeRate >= 0 ? "up" : "down", + value: `${Math.abs(platformData.impressionChangeRate * 100).toFixed(2)}%`, + }, + }, + { + title: "전환율(CVR)", + value: `${platformData.conversion}%`, + trend: { + direction: platformData.cvrChangeRate >= 0 ? "up" : "down", + value: `${Math.abs(platformData.cvrChangeRate * 100).toFixed(2)}%`, + }, + }, + { + title: "광고비 대비 매출(ROAS)", + value: `${platformData.ROAS}%`, + trend: { + direction: platformData.ROASChangeRate >= 0 ? "up" : "down", + value: `${Math.abs(platformData.ROASChangeRate * 100).toFixed(2)}%`, + }, + }, + ]; + }, [platformData]); + + const logoInfo = PLATFORM_LOGOS[platform.toUpperCase()]; + + const budget = useMemo(() => { + const data = budgetStatusMock.find( + (b) => b.providerType.toUpperCase() === platform.toUpperCase(), + ); + if (!data) return null; + + return { + totalBudget: data.totalBudget, + spent: data.totalSpend, + warningThreshold: 50, + dangerThreshold: 75, + }; + }, [platform]); + + const budgetPct = budget + ? Math.round((budget.spent / budget.totalBudget) * 100) + : 0; + + const budgetStatus = budget + ? getBudgetStatus( + budgetPct, + budget.warningThreshold, + budget.dangerThreshold, + ) + : null; + + const dailyData = useMemo(() => { + const key = platform?.toUpperCase(); + const allData = key ? platformDailyPerformanceMock[key] || [] : []; + return allData.slice(0, viewRange); + }, [platform, viewRange]); + + return ( +
+ {/* platform header */} +
+
+ {logoInfo ? ( + + ) : ( +

{platform}

+ )} +
+ + +
+ + {/* top */} +
+ {isLoading + ? Array.from({ length: 4 }).map((_, i) => ( +
+ + + +
+ )) + : kpis.map((kpi) => ( + + ))} +
+ + {/* mid */} +
+ +
+ + + + } + RightElement={ + budgetStatus && ( + + {budgetStatus} + + ) + } + > + {budget ? ( +
+ +
+ ) : ( +
+ 데이터가 없습니다. +
+ )} +
+
+ + {/* bottom */} + + + +
+ } + > + + +
+ ); +} diff --git a/src/components/sidebar/Sidebar.tsx b/src/components/sidebar/Sidebar.tsx index bcf36de0..b0567c44 100644 --- a/src/components/sidebar/Sidebar.tsx +++ b/src/components/sidebar/Sidebar.tsx @@ -95,7 +95,10 @@ export default function Sidebar() {