Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 118 additions & 51 deletions src/components/dashboard/charts/BudgetGaugeChart.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
import { memo } from "react";
import { twMerge } from "tailwind-merge";

import type {
IBudgetGaugeProps,
TBudgetGaugeLabel,
} from "@/types/dashboard/budget";

import {
getRemainingPercentage,
getSpentPercentage,
} from "@/utils/dashboard/budget";
import { METRIC_REGISTRY as M } from "@/utils/dashboard/metricRegistry";

import { useIsMounted } from "@/hooks/common/useIsMounted";

import { type TBadgeVariant } from "@/components/common/badge/Badge";
import Badge, { type TBadgeVariant } from "@/components/common/badge/Badge";

import WarnCircleIcon from "@/assets/icon/common/warn-circle.svg?react";

interface IBudgetGaugeChartProps {
totalBudget: number;
spent: number;
warningThreshold: number;
dangerThreshold: number;
}

export type TBudgetStatus = "안정" | "주의" | "위험";

export function getBudgetStatus(
Expand Down Expand Up @@ -47,29 +49,41 @@ function splitInsightHeadTail(text: string): { head: string; tail?: string } {
return { head: m[1], tail: m[3].trim() };
}

/** 게이지 label이 예산 종류면 금액을 label 옆에 표시 */
function isBudgetTypeLabel(label: TBudgetGaugeLabel): boolean {
return label === "전체 예산" || label === "일일 예산";
}

const budgetAmountClass = "font-heading4 text-text-title tabular-nums";

const BudgetGaugeChart = memo(function BudgetGaugeChart({
label,
totalBudget,
spent,
warningThreshold,
dangerThreshold,
}: IBudgetGaugeChartProps) {
compact = false,
showInsight = false,
}: IBudgetGaugeProps) {
const mounted = useIsMounted();

const percentage =
totalBudget > 0 ? Math.round((spent / totalBudget) * 100) : 0;
const slice = { totalBudget, spent };
const spentPct = getSpentPercentage(slice);
const remainingPct = getRemainingPercentage(slice);
const isOverBudget = spent > totalBudget;
const remaining = isOverBudget ? spent - totalBudget : totalBudget - spent;
const remainingAmount = isOverBudget
? spent - totalBudget
: totalBudget - spent;

const status = getBudgetStatus(percentage, warningThreshold, dangerThreshold);
const status = getBudgetStatus(spentPct, warningThreshold, dangerThreshold);

// 데이터 인사이트 메시지
let insightDesc = "";

if (isOverBudget) {
insightDesc = "예산을 초과했습니다. 캠페인 조정이 필요해요.";
} else if (percentage >= dangerThreshold) {
} else if (spentPct >= dangerThreshold) {
insightDesc = "예산 소진이 빨라요. 일일 한도 점검을 추천해요.";
} else if (percentage >= warningThreshold) {
} else if (spentPct >= warningThreshold) {
insightDesc = "예산 소진 속도가 다소 높아요. 매체 효율을 확인해 보세요.";
} else {
insightDesc = "계획된 예산 범위 내에서 잘 사용되고 있어요.";
Expand All @@ -78,68 +92,121 @@ const BudgetGaugeChart = memo(function BudgetGaugeChart({
const { head: insightHead, tail: insightTail } =
splitInsightHeadTail(insightDesc);

const showBudgetAmountOnLabelRow = isBudgetTypeLabel(label);
const headerRowGap = compact ? "mb-2" : "mb-3";

return (
<div className="flex h-full w-full flex-col pt-6">
<div className="flex flex-col mb-6">
<h3 className="mb-3 font-body2 text-text-body">사용 예산</h3>
<div className="flex items-baseline gap-2">
<div
className={twMerge(
"flex w-full flex-col",
compact && "h-full",
compact ? "pt-0" : "pt-3",
)}
>
<div className={twMerge("flex flex-col", compact ? "mb-3" : "mb-6")}>
<div
className={twMerge(
"flex items-center justify-between gap-2",
headerRowGap,
)}
>
<div className="flex min-w-0 items-baseline gap-2">
<h3 className="shrink-0 font-body2 text-text-body">{label}</h3>
{showBudgetAmountOnLabelRow && (
<span className={twMerge("truncate", budgetAmountClass)}>
{M.spend.format(totalBudget)}
</span>
)}
</div>
<Badge variant={statusBadgeVariant[status]} className="shrink-0 px-2">
{status}
</Badge>
</div>
{!showBudgetAmountOnLabelRow && (
<div className={twMerge("flex items-baseline gap-2", headerRowGap)}>
<span className="font-caption text-text-muted">전체 예산</span>
<span className={budgetAmountClass}>
{M.spend.format(totalBudget)}
</span>
</div>
)}
<div
className={twMerge(
"flex items-baseline gap-2",
compact ? "mt-2" : "mt-3",
)}
>
<span className="font-heading1 text-text-title tabular-nums leading-none">
{percentage}%
{remainingPct}%
</span>
<span className="font-body2 text-text-body tabular-nums">소진</span>
<span className="font-body2 text-text-body tabular-nums">남음</span>
</div>
</div>

<div className="relative mb-6 w-full">
<div className={twMerge("relative w-full", compact ? "mb-3" : "mb-6")}>
<div
role="progressbar"
aria-label="전체 예산 소진율"
aria-valuenow={Math.min(Math.max(percentage, 0), 100)}
aria-label={`${label} 남은 비율`}
aria-valuenow={remainingPct}
aria-valuemin={0}
aria-valuemax={100}
className="relative h-3 w-full bg-surface-200 rounded-full overflow-hidden"
className="relative h-3 w-full overflow-hidden rounded-full bg-surface-200"
>
<div
className="absolute top-0 bottom-0 w-0.5 bg-surface-100/60 z-10"
style={{ left: `${warningThreshold}%` }}
className="absolute top-0 bottom-0 z-10 w-0.5 bg-surface-100/60"
style={{ left: `${100 - dangerThreshold}%` }}
/>
<div
className="absolute top-0 bottom-0 w-0.5 bg-surface-100/60 z-10"
style={{ left: `${dangerThreshold}%` }}
className="absolute top-0 bottom-0 z-10 w-0.5 bg-surface-100/60"
style={{ left: `${100 - warningThreshold}%` }}
/>

<div
className={twMerge(
"absolute top-0 left-0 h-full w-full rounded-full transition-transform duration-1000 ease-smooth origin-left",
"absolute top-0 left-0 h-full w-full origin-left rounded-full transition-transform duration-1000 ease-smooth",
statusPointClasses[status],
)}
style={{
transform: `scaleX(${mounted ? Math.min(percentage, 100) / 100 : 0})`,
transform: `scaleX(${mounted ? remainingPct / 100 : 0})`,
}}
/>
</div>

<div className="mt-3 flex items-center justify-between font-body2 text-text-body">
<span className="tabular-nums">{M.spend.format(spent)}</span>
<span className="tabular-nums">{M.spend.format(totalBudget)}</span>
<div
className={twMerge(
"flex items-end justify-between",
compact ? "mt-2" : "mt-3",
)}
>
<div className="flex flex-col gap-0.5">
<span className="font-caption text-text-muted">사용</span>
<span className="font-body2 text-text-body tabular-nums">
{M.spend.format(spent)}
</span>
</div>
<div className="flex flex-col items-end gap-0.5">
<span className="font-caption text-text-muted">
{isOverBudget ? "초과 금액" : "남은 예산"}
</span>
<span
className={twMerge(
"font-body2 text-text-body tabular-nums",
isOverBudget && "text-info-red",
)}
>
{M.spend.format(remainingAmount)}
</span>
</div>
Comment thread
YermIm marked this conversation as resolved.
</div>
</div>

<div className="flex-1 flex flex-col gap-3">
<div className="flex flex-col gap-1 rounded-2xl border border-surface-400/25 bg-surface-200/50 p-4">
<span className="font-caption text-text-muted">남은 예산</span>
<span
className={twMerge(
"font-heading3 text-text-title tabular-nums",
isOverBudget && "text-info-red",
)}
>
{isOverBudget ? "-" : ""}
{M.spend.format(remaining)}
</span>
</div>

<div className="flex items-center gap-3 rounded-2xl bg-surface-300 px-5 py-4">
{showInsight && (
<div
className={twMerge(
"flex items-center gap-3 rounded-2xl bg-surface-300 px-5 py-4",
compact && "flex-1",
)}
>
<WarnCircleIcon
className="block size-5 shrink-0 text-text-muted"
aria-hidden="true"
Expand All @@ -153,7 +220,7 @@ const BudgetGaugeChart = memo(function BudgetGaugeChart({
) : null}
</p>
</div>
</div>
)}
</div>
);
});
Expand Down
23 changes: 18 additions & 5 deletions src/components/dashboard/charts/TrafficChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
useState,
} from "react";

import { OVERVIEW_TRAFFIC_CHART_HEIGHT } from "@/constants/dashboard/trafficChartHeights";

import { METRIC_REGISTRY as M } from "@/utils/dashboard/metricRegistry";
import { parseMinuteToTimestamp } from "@/utils/dashboard/parseMinuteToTimestamp";

Expand All @@ -29,6 +31,11 @@ import MoreIcon from "@/assets/icon/common/more.svg?react";

const ReactApexChart = lazy(() => import("react-apexcharts"));

interface ITrafficChartProps {
/** ApexCharts height — 통합 대시보드 기본 590 */
height?: number;
}

Comment thread
YermIm marked this conversation as resolved.
// 차트 우측 상단 다운로드 버튼 + 더미 토글
export function TrafficChartDownload() {
return (
Expand Down Expand Up @@ -100,9 +107,12 @@ const AnomalyBubble = memo(function AnomalyBubble({
);
});

const TrafficChart = memo(function TrafficChart() {
const TrafficChart = memo(function TrafficChart({
height = OVERVIEW_TRAFFIC_CHART_HEIGHT,
}: ITrafficChartProps) {
const containerRef = useRef<HTMLDivElement>(null);
const { data, suspectDetail, isError } = useClickStream({ mode: "dummy" });
const chartAreaStyle = { height: `${height}px` };

// timeSeriesData → datetime 기반 차트 series 변환
const DAY_MS = 24 * 60 * 60 * 1000;
Expand Down Expand Up @@ -262,15 +272,18 @@ const TrafficChart = memo(function TrafficChart() {

if (isError && !data) {
return (
<div className="flex flex-col items-center justify-center w-full h-100 gap-2 text-text-muted">
<div
style={chartAreaStyle}
className="flex w-full flex-col items-center justify-center gap-2 text-text-muted"
>
<p className="font-body2">실시간 데이터를 불러오지 못했습니다.</p>
<p className="font-caption">잠시 후 다시 시도해 주세요.</p>
</div>
);
}

if (!data) {
return <Skeleton className="w-full h-100" />;
return <Skeleton className="w-full rounded-xl" style={chartAreaStyle} />;
}

return (
Expand All @@ -283,12 +296,12 @@ const TrafficChart = memo(function TrafficChart() {
data-hide-tooltip={showBubble || undefined}
className="relative will-change-transform [&_.apexcharts-toolbar]:hidden [&[data-hide-tooltip]_.apexcharts-tooltip]:invisible [&[data-hide-tooltip]_.apexcharts-tooltip]:pointer-events-none"
>
<Suspense fallback={<div className="h-100" />}>
<Suspense fallback={<div style={chartAreaStyle} />}>
<ReactApexChart
type="area"
options={chartOptions}
series={series}
height={400}
height={height}
/>
</Suspense>
{markerPos && (
Expand Down
2 changes: 1 addition & 1 deletion src/components/dashboard/charts/trafficChart.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ export function buildChartOptions(params: {
borderColor: "var(--color-surface-200)",
xaxis: { lines: { show: false } },
yaxis: { lines: { show: true } },
padding: { left: 16, right: 24 },
padding: { top: 12, left: 12, right: 16 },
},

tooltip: {
Expand Down
42 changes: 11 additions & 31 deletions src/components/dashboard/overview/skeleton/OverviewSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { OVERVIEW_TRAFFIC_CHART_HEIGHT } from "@/constants/dashboard/trafficChartHeights";

import {
Skeleton,
SkeletonCircle,
} from "@/components/common/skeleton/Skeleton";
import { PLATFORM_ROAS_TABLE_COL } from "@/components/dashboard/platform/PlatformRoasTable";
import { PlatformDualBudgetGaugeSkeleton } from "@/components/dashboard/platform/skeleton/PlatformSkeleton";

/** KPI StatCard 1칸 */
export function OverviewKpiCardSkeleton() {
Expand All @@ -17,40 +20,17 @@ export function OverviewKpiCardSkeleton() {

/** 실시간 트래픽 차트 */
export function OverviewTrafficChartSkeleton() {
return <Skeleton className="min-h-60 w-full flex-1 rounded-2xl" />;
return (
<Skeleton
className="w-full rounded-2xl"
style={{ height: `${OVERVIEW_TRAFFIC_CHART_HEIGHT}px` }}
/>
);
}

/** 예산 게이지 카드 */
/** 예산 게이지 카드 — Google·Meta + NAVER compact 2개 */
export function OverviewBudgetGaugeSkeleton() {
return (
<div className="flex min-h-0 flex-1 flex-col gap-3 pt-1">
<div className="flex shrink-0 flex-col gap-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-8 w-24" />
<Skeleton className="h-2 w-full rounded-full" />
<div className="flex justify-between gap-3">
<div className="flex flex-col gap-1">
<Skeleton className="h-3 w-10" />
<Skeleton className="h-4 w-20" />
</div>
<div className="flex flex-col items-end gap-1">
<Skeleton className="h-3 w-14" />
<Skeleton className="h-4 w-20" />
</div>
</div>
</div>
<div className="flex shrink-0 flex-col gap-3">
<div className="flex flex-col gap-1 rounded-2xl border border-surface-400/25 bg-surface-200/40 p-4">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-7 w-36" />
</div>
<div className="flex items-center gap-3 rounded-2xl bg-surface-300 px-5 py-4">
<Skeleton className="size-5 shrink-0 rounded-full" />
<Skeleton className="h-10 flex-1 rounded-md" />
</div>
</div>
</div>
);
return <PlatformDualBudgetGaugeSkeleton mergedBudgetHeader={false} />;
}

/** 플랫폼별 ROAS 랭킹 테이블 */
Expand Down
Loading
Loading