Skip to content

Commit de38b5c

Browse files
authored
Merge pull request #158 from WhereYouAd/feature/#145
[Feature/#145] 플랫폼별 대시보드 상단/하단 지표 UI 구현
2 parents 994b9f7 + bd4362e commit de38b5c

File tree

12 files changed

+804
-3
lines changed

12 files changed

+804
-3
lines changed

src/assets/logo/social-logo/circle/meta-circle.svg

Lines changed: 31 additions & 0 deletions
Loading

src/components/common/chart/ChartLegend.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import { twMerge } from "tailwind-merge";
33

44
export interface IChartLegendItem {
55
label: string;
6-
colorClass: string;
6+
colorClass?: string;
7+
color?: string;
78
}
89

910
export interface IChartLegendProps {
@@ -21,6 +22,7 @@ const ChartLegend = memo(function ChartLegend({
2122
<div key={item.label} className="flex items-center gap-1.5">
2223
<div
2324
className={twMerge("w-1.5 h-1.5 rounded-full", item.colorClass)}
25+
style={item.color ? { backgroundColor: item.color } : undefined}
2426
/>
2527
<span className="font-caption font-bold text-text-sub">
2628
{item.label}

src/components/common/dropdownmenu/DropdownMenu.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@ export function DropdownMenu({
1212
trigger,
1313
items,
1414
className,
15+
menuClassName,
1516
"aria-label": ariaLabel,
1617
}: {
1718
trigger: React.ReactNode | ((open: boolean) => React.ReactNode);
1819
items: TMenuItem[];
1920
className?: string;
21+
menuClassName?: string;
2022
"aria-label"?: string;
2123
}) {
2224
const [open, setOpen] = useState(false);
@@ -57,7 +59,10 @@ export function DropdownMenu({
5759
<div
5860
id={menuId}
5961
role="menu"
60-
className="absolute right-0 top-full mt-2 w-56 max-w-[calc(100vw-40px)] rounded-component-md bg-brand-200 border border-gray-100 py-3 px-1 shadow-Medium z-50 animate-modal-content origin-top-right"
62+
className={twMerge(
63+
"absolute right-0 top-full mt-2 w-56 max-w-[calc(100vw-40px)] rounded-component-md bg-brand-200 border border-gray-100 py-3 px-1 shadow-Medium z-50 animate-modal-content origin-top-right",
64+
menuClassName,
65+
)}
6166
>
6267
<div className="space-y-1">
6368
{items.map((it, idx) => (
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { memo, useMemo } from "react";
2+
3+
import type { IAdCount } from "@/types/dashboard/platform";
4+
5+
const PLATFORM_COLORS: Record<string, string> = {
6+
GOOGLE: "#f9ab00",
7+
NAVER: "#03c75a",
8+
META: "#1877f2",
9+
};
10+
11+
export const AdStatusChart = memo(({ data }: { data: IAdCount[] }) => {
12+
const total = useMemo(
13+
() => data.reduce((acc, curr) => acc + curr.count, 0),
14+
[data],
15+
);
16+
17+
const getWidth = (count: number) => (total > 0 ? (count / total) * 100 : 0);
18+
19+
return (
20+
<div className="flex flex-1 flex-col gap-1 justify-center px-3">
21+
{/* 개수 라벨 */}
22+
<div className="flex w-full h-6 relative">
23+
{data.map((item) => (
24+
<div
25+
key={item.provider}
26+
style={{ width: `${getWidth(item.count)}%` }}
27+
className="flex justify-center text-caption text-text-sub tabular-nums"
28+
>
29+
{item.count}
30+
</div>
31+
))}
32+
</div>
33+
34+
{/* 세그먼트 바 */}
35+
<div className="flex w-full h-10 rounded-component-sm gap-1.5">
36+
{data.map((item) => (
37+
<div
38+
key={item.provider}
39+
style={{
40+
width: `${getWidth(item.count)}%`,
41+
backgroundColor: PLATFORM_COLORS[item.provider],
42+
}}
43+
className="h-full rounded-sm"
44+
/>
45+
))}
46+
</div>
47+
</div>
48+
);
49+
});
50+
51+
export default AdStatusChart;
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { lazy, memo, Suspense, useMemo } from "react";
2+
3+
import type { IPlatformPerformance } from "@/types/dashboard/platform";
4+
import { PLATFORM_MAP } from "@/types/dashboard/platform";
5+
6+
import { getMixedChartOptions } from "./performanceEfficiencyChart.config";
7+
8+
const Chart = lazy(() => import("react-apexcharts"));
9+
10+
export const PerformanceEfficiencyChart = memo(
11+
({ data }: { data: IPlatformPerformance[] }) => {
12+
const categories = useMemo(
13+
() => data.map((d) => PLATFORM_MAP[d.provider] || d.provider),
14+
[data],
15+
);
16+
const options = useMemo(
17+
() => getMixedChartOptions(categories),
18+
[categories],
19+
);
20+
21+
const series = [
22+
{
23+
name: "클릭률(CTR)",
24+
type: "column", // 세로 막대
25+
data: data.map((d) =>
26+
d.impressions > 0 ? (d.clicks / d.impressions) * 100 : 0,
27+
),
28+
},
29+
{
30+
name: "전환율(CVR)",
31+
type: "column", // 세로 막대
32+
data: data.map((d) => d.conversion),
33+
},
34+
{
35+
name: "노출수",
36+
type: "line", // 점
37+
data: data.map((d) => d.impressions),
38+
},
39+
];
40+
41+
return (
42+
<Suspense fallback={<div className="h-40" />}>
43+
<Chart options={options} series={series} height={150} />
44+
</Suspense>
45+
);
46+
},
47+
);
48+
49+
export default PerformanceEfficiencyChart;
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import type { ApexOptions } from "apexcharts";
2+
3+
export const getMixedChartOptions = (categories: string[]): ApexOptions => ({
4+
chart: {
5+
type: "line",
6+
toolbar: { show: false },
7+
fontFamily: "Pretendard",
8+
zoom: { enabled: false },
9+
selection: { enabled: false },
10+
},
11+
stroke: {
12+
show: true,
13+
width: [0, 0, 0.01],
14+
},
15+
markers: {
16+
size: [0, 0, 6], // 막대 = 0, 점 = 6
17+
strokeWidth: 2,
18+
strokeColors: "#fff",
19+
hover: { sizeOffset: 2 },
20+
},
21+
plotOptions: {
22+
bar: {
23+
columnWidth: "60%",
24+
borderRadius: 4,
25+
borderRadiusApplication: "end",
26+
},
27+
},
28+
colors: ["#0084fe", "#0a3d91", "#4fc3f7"],
29+
xaxis: {
30+
categories: categories,
31+
labels: {
32+
style: {
33+
fontSize: "14px",
34+
fontWeight: 500,
35+
colors: "#212121",
36+
},
37+
},
38+
axisBorder: { show: false },
39+
axisTicks: { show: false },
40+
},
41+
yaxis: [
42+
{
43+
seriesName: "클릭률",
44+
labels: {
45+
formatter: (val) => `${val.toFixed(1)}%`,
46+
style: {
47+
colors: "#8B8B8F",
48+
fontSize: "12px",
49+
},
50+
},
51+
},
52+
{ show: false, seriesName: "전환율" },
53+
{
54+
opposite: true,
55+
seriesName: "노출수",
56+
labels: {
57+
offsetX: -10,
58+
formatter: (val) => val.toLocaleString("ko-KR"),
59+
style: {
60+
colors: "#8B8B8F",
61+
fontSize: "12px",
62+
},
63+
},
64+
},
65+
],
66+
tooltip: {
67+
shared: true,
68+
intersect: false,
69+
y: {
70+
formatter: (val, { seriesIndex }) => {
71+
if (seriesIndex === 0 || seriesIndex === 1) {
72+
return `${val.toFixed(2)}%`;
73+
}
74+
return val.toLocaleString("ko-KR");
75+
},
76+
},
77+
},
78+
grid: {
79+
borderColor: "#f1f1f1",
80+
yaxis: { lines: { show: true } },
81+
padding: {
82+
bottom: -15,
83+
top: -15,
84+
},
85+
},
86+
legend: { show: false },
87+
});
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import React, { memo } from "react";
2+
3+
import type { IPlatformPerformance } from "@/types/dashboard/platform";
4+
import { PLATFORM_MAP } from "@/types/dashboard/platform";
5+
6+
import Card from "@/components/common/card/Card";
7+
import StatCard from "@/components/common/card/StatCard";
8+
9+
import GoogleLogo from "@/assets/logo/social-logo/circle/google-circle.svg?react";
10+
import MetaLogo from "@/assets/logo/social-logo/circle/meta-circle.svg?react";
11+
import NaverLogo from "@/assets/logo/social-logo/circle/naver-circle.svg?react";
12+
13+
const PLATFORM_LOGOS: Record<string, React.ReactNode> = {
14+
GOOGLE: <GoogleLogo className="w-10 h-8" />,
15+
NAVER: <NaverLogo className="w-10 h-8" />,
16+
META: <MetaLogo className="w-10 h-8" />,
17+
};
18+
19+
export const PlatformDetailCard = memo(
20+
({ data }: { data: IPlatformPerformance }) => {
21+
const {
22+
provider,
23+
impressions,
24+
clicks,
25+
conversion,
26+
ROAS,
27+
impressionChangeRate,
28+
clickChangeRate,
29+
cvrChangeRate,
30+
ROASChangeRate,
31+
} = data;
32+
33+
const innerCardClass =
34+
"shadow-none! hover:shadow-none! rounded-component-md! p-2! gap-2!";
35+
36+
return (
37+
<Card className="flex-1 p-7 bg-white/80 backdrop-blur-sm">
38+
{/* 로고 + 이름 */}
39+
<div className="flex items-center gap-2 mb-8">
40+
<div className="shrink-0">{PLATFORM_LOGOS[provider]}</div>
41+
<h3 className="font-heading4 font-semibold! text-text-main truncate">
42+
{PLATFORM_MAP[provider]}
43+
</h3>
44+
</div>
45+
46+
{/* 지표 */}
47+
<div className="grid grid-cols-2 gap-2">
48+
<StatCard
49+
title="노출수"
50+
value={impressions.toLocaleString()}
51+
trend={
52+
impressionChangeRate !== 0
53+
? {
54+
direction: impressionChangeRate > 0 ? "up" : "down",
55+
value: `${Math.abs(impressionChangeRate * 100).toFixed(1)}%`,
56+
}
57+
: undefined
58+
}
59+
className={innerCardClass}
60+
/>
61+
<StatCard
62+
title="클릭수"
63+
value={clicks.toLocaleString()}
64+
trend={
65+
clickChangeRate !== 0
66+
? {
67+
direction: clickChangeRate > 0 ? "up" : "down",
68+
value: `${Math.abs(clickChangeRate * 100).toFixed(1)}%`,
69+
}
70+
: undefined
71+
}
72+
className={innerCardClass}
73+
/>
74+
<StatCard
75+
title="전환율"
76+
value={`${conversion}%`}
77+
trend={
78+
cvrChangeRate !== 0
79+
? {
80+
direction: cvrChangeRate > 0 ? "up" : "down",
81+
value: `${Math.abs(cvrChangeRate * 100).toFixed(1)}%`,
82+
}
83+
: undefined
84+
}
85+
className={innerCardClass}
86+
/>
87+
<StatCard
88+
title="ROAS"
89+
value={`${ROAS.toLocaleString()}%`}
90+
trend={
91+
ROASChangeRate !== 0
92+
? {
93+
direction: ROASChangeRate > 0 ? "up" : "down",
94+
value: `${Math.abs(ROASChangeRate * 100).toFixed(1)}%`,
95+
}
96+
: undefined
97+
}
98+
className={innerCardClass}
99+
/>
100+
</div>
101+
</Card>
102+
);
103+
},
104+
);
105+
106+
export default PlatformDetailCard;

0 commit comments

Comments
 (0)