Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
6b40942
fix: 예산 밑 플랫폼 비교 차트 디자인 개선
Seojegyeong Mar 10, 2026
817d4f9
feat: AI 요약하기 버튼 애니메이션 추가
Seojegyeong Mar 10, 2026
7aaa2b3
feat: gitignore 수정
Seojegyeong Mar 11, 2026
ef7bbc4
feat: 클릭 이상 징후 마커 표시
Seojegyeong Mar 12, 2026
d8a855e
fix: xaxis numeric으로 수정하여 마커 안정적으로 표시
Seojegyeong Mar 12, 2026
bbf050e
feat: Custom Tooltip 적용
Seojegyeong Mar 12, 2026
0089961
feat: trafficChart 주석 설명 추가
Seojegyeong Mar 12, 2026
7bab009
feat: 이상 클릭 탐지 Tooltip 좌표 위 고정
Seojegyeong Mar 12, 2026
fb92770
fix: 플랫폼 비교 카드 디자인 및 정보 일부 수정
Seojegyeong Mar 12, 2026
e7ca8c8
fix: 예산 소진 및 플랫폼 비교 카드 디자인 수정(정보 축약)
Seojegyeong Mar 13, 2026
1c0c08d
feat: 예산, 플랫폼 카드 반응형 처리 및 이상클릭탐지 툴팁 호버 처리
Seojegyeong Mar 13, 2026
2c79a98
feat: tooltip.custom 이용해서 anomaly 포인트에서 빈값, 나머지 일반 툴팁 반환해 툴팁 기본 설정
Seojegyeong Mar 13, 2026
d41b8e0
fix: 이상 징후 point에서는 커스텀 툴팁만 보이도록 수정
Seojegyeong Mar 13, 2026
605b77f
fix: ApexCharts가 렌더링되기 전에 잠깐 보여주는 기본 플레이스홀더 제거
Seojegyeong Mar 13, 2026
461eea5
refactor: 중복 코드 개선
Seojegyeong Mar 13, 2026
e957079
fix: 예산 카드 2xl 이하에서 세로로 쌓이도록 반응형 수정
Seojegyeong Mar 13, 2026
32f99f1
fix: 마커 위치 계산 getBoundingClientRect로 교체 및 mounted 이벤트에서 풀백 텍스트 제거
Seojegyeong Mar 13, 2026
62fe03a
feat: useAnomalyMarkerPos 설명 주석 추가
Seojegyeong Mar 13, 2026
cf2dd1d
fix: chart 타이틀 요소 제거
Seojegyeong Mar 13, 2026
b38293c
fix: 코드 리뷰 일부 반영
Seojegyeong Mar 14, 2026
cefc866
fix: hidden -> invisible + pointer-events-none으로 변경
Seojegyeong Mar 14, 2026
d050a5f
fix: platform 타입 정의 추가
Seojegyeong Mar 14, 2026
f9e98c5
fix: codeRabbit 리뷰 수정
Seojegyeong Mar 14, 2026
63e5db9
fix: Card title, desc 간격 넓힘
Seojegyeong Mar 16, 2026
4d1c8dc
fix: coderabbit 리뷰 반영
Seojegyeong Mar 16, 2026
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
7 changes: 0 additions & 7 deletions .claude/settings.json

This file was deleted.

3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,6 @@ dist-ssr
node_modules/
*storybook.log
storybook-static

# Claude Code
.claude/
3 changes: 3 additions & 0 deletions src/assets/icon/common/alert-circle.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions src/components/common/card/Card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,14 @@ export default function Card({
return (
<div
className={twMerge(
"bg-white/80 backdrop-blur-sm rounded-[24px] shadow-[0_4px_20px_rgba(0,0,0,0.03)] p-7 border border-white/40 transition-all duration-300 hover:shadow-[0_12px_45px_rgba(0,0,0,0.06)]",
"bg-white/80 backdrop-blur-sm rounded-[24px] shadow-[0_4px_20px_rgba(0,0,0,0.03)] p-7 border border-white/40 transition-all duration-300 hover:shadow-[0_12px_45px_rgba(0,0,0,0.06)] relative",
className,
)}
{...rest}
>
{hasHeader && (
<div className="flex flex-wrap items-start justify-between gap-2 mb-4">
<div className="flex flex-col gap-0.5">
<div className="flex flex-col gap-1">
{title && (
<h3 className="font-heading4 font-semibold! text-text-main">
{title}
Expand Down
36 changes: 20 additions & 16 deletions src/components/common/card/StatCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,25 @@ const trendClasses: Record<ITrend["direction"], string> = {
down: "bg-status-blue/[0.08] text-status-blue font-bold",
};

export function TrendBadge({ direction, value }: ITrend) {
return (
<span
aria-label={`${value} ${direction === "up" ? "상승" : "하락"}`}
className={twMerge(
"inline-flex items-center gap-1 px-2 py-1 rounded-full font-caption w-fit",
trendClasses[direction],
)}
>
{direction === "up" ? (
<TrendUpIcon aria-hidden className="w-4 h-4" />
) : (
<TrendDownIcon aria-hidden className="w-4 h-4" />
)}
{value}
</span>
);
}

export default function StatCard({
title,
value,
Expand All @@ -39,22 +58,7 @@ export default function StatCard({
<p className="font-heading1 text-text-main font-extrabold tracking-tight">
{value}
</p>
{trend && (
<span
aria-label={`${trend.value} ${trend.direction === "up" ? "상승" : "하락"}`}
className={twMerge(
"inline-flex items-center gap-1 px-2 py-1 rounded-full font-caption w-fit",
trendClasses[trend.direction],
)}
>
{trend.direction === "up" ? (
<TrendUpIcon aria-hidden className="w-4 h-4" />
) : (
<TrendDownIcon aria-hidden className="w-4 h-4" />
)}
{trend.value}
</span>
)}
{trend && <TrendBadge {...trend} />}
</div>
);
}
234 changes: 119 additions & 115 deletions src/components/dashboard/charts/BudgetGaugeChart.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -10,13 +13,25 @@ interface IBudgetGaugeChartProps {
dangerThreshold: number;
}

const statusBadgeVariant: Record<string, TBadgeVariant> = {
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<TBudgetStatus, TBadgeVariant> = {
안정: "success",
주의: "syncing",
위험: "inactive",
};

const statusPointClasses: Record<string, string> = {
const statusPointClasses: Record<TBudgetStatus, string> = {
안정: "bg-status-green",
주의: "bg-status-yellow",
위험: "bg-status-red",
Expand All @@ -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;
Expand All @@ -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 (
<div className="flex flex-col w-full h-full justify-between font-pretendard">
<div className="flex flex-col gap-4 mb-6">
<div className="flex items-baseline justify-between">
<div className="flex items-baseline gap-1">
<span className="font-heading1 text-text-main font-extrabold tracking-tight leading-none">
{percentage}%
</span>
<span className="font-caption text-text-sub font-semibold">
소진
</span>
</div>

<Badge variant={statusBadgeVariant[status]} size="sm">
{status}
</Badge>
<div className="flex flex-col w-full h-full font-pretendard pt-6">
<div className="flex flex-col mb-8">
<div className="flex items-center mb-3">
<span className="font-body2 font-semibold text-text-auth-sub">
이번 달 사용 예산
</span>
Comment thread
Seojegyeong marked this conversation as resolved.
</div>

<div className="relative py-1.5">
<div
role="progressbar"
aria-valuenow={Math.min(Math.max(percentage, 0), 100)}
aria-valuemin={0}
aria-valuemax={100}
aria-label="예산 소진율"
aria-valuetext={
percentage > 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)]"
>
<div
className={twMerge(
"absolute top-0 left-0 h-full w-full rounded-full transition-transform duration-1000 ease-out origin-left",
status === "안정"
? "bg-linear-to-r from-status-green/50 to-status-green"
: status === "주의"
? "bg-linear-to-r from-status-yellow/50 to-status-yellow"
: "bg-linear-to-r from-status-red/50 to-status-red",
)}
style={{
transform: `scaleX(${mounted ? Math.min(percentage, 100) / 100 : 0})`,
}}
>
<div className="absolute inset-0 rounded-full bg-linear-to-b from-white/30 to-transparent" />
</div>
</div>
<div className="flex items-baseline gap-2">
<span className="font-heading1 font-extrabold text-text-main tracking-tight leading-none tabular-nums">
{percentage}%
</span>
<span className="font-body2 font-bold text-text-sub tracking-tight tabular-nums">
소진
</span>
</div>
</div>

<div className="flex flex-col gap-3 py-5 border-y border-bg-surface mb-6">
<div className="flex justify-between items-center">
<span className="font-body2 text-text-sub">총 목표 예산</span>
<span className="font-body2 text-text-main tracking-tight">
₩{totalBudget.toLocaleString()}
</span>
</div>
<div className="flex justify-between items-center">
<span className="font-body2 text-text-sub font-medium opacity-80">
현재 사용액
</span>
<span className="font-body2 text-text-main font-bold font-mono tracking-tight">
₩{spent.toLocaleString()}
</span>
<div className="relative mb-10 w-full">
<div
role="progressbar"
aria-label="이번 달 사용 예산 소진율"
aria-valuenow={Math.min(Math.max(percentage, 0), 100)}
aria-valuemin={0}
aria-valuemax={100}
className="relative h-3 w-full bg-bg-surface rounded-full overflow-hidden"
>
<div
className="absolute top-0 bottom-0 w-0.5 bg-white/60 z-10"
style={{ left: `${warningThreshold}%` }}
/>
<div
className="absolute top-0 bottom-0 w-0.5 bg-white/60 z-10"
style={{ left: `${dangerThreshold}%` }}
/>

<div
className={twMerge(
"absolute top-0 left-0 h-full w-full rounded-full transition-transform duration-1000 ease-smooth origin-left",
statusPointClasses[status],
)}
style={{
transform: `scaleX(${mounted ? Math.min(percentage, 100) / 100 : 0})`,
}}
/>
</div>
<div className="flex justify-between items-center">
<span className="font-body2 text-text-sub">기간 진행률</span>
<span className="font-body2 text-text-main tracking-tight">
{periodElapsedDays}/{periodTotalDays}일{" "}
<span className="text-text-sub font-medium">
({periodElapsedRate}%)
</span>
</span>

<div className="flex justify-between items-center mt-3 font-body2 text-text-sub">
<span className="tabular-nums">₩{spent.toLocaleString()}</span>
<span className="tabular-nums">₩{totalBudget.toLocaleString()}</span>
</div>
</div>

<div className="bg-bg-surface/40 rounded-component-lg p-6 flex flex-col gap-4 border border-white/40 mt-auto">
<div className="flex flex-col gap-1">
<span className="font-body2 text-text-sub">
{isOverBudget ? "초과 지출" : "이번 달 잔액"}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-5">
<div className="flex flex-col gap-1.5 p-4 rounded-2xl bg-bg-surface/50">
<span className="font-caption font-medium text-text-auth-sub">
남은 예산
</span>
<span
className={twMerge(
"font-heading2 font-extrabold tracking-tight leading-none pt-1",
"font-body1 font-bold tracking-tight tabular-nums",
isOverBudget ? "text-status-red" : "text-text-main",
)}
>
₩{remaining.toLocaleString()}
{isOverBudget ? "-" : ""}₩{remaining.toLocaleString()}
</span>
</div>

<div className="h-px bg-white/60 w-full" />

<div className="flex items-start gap-1.5">
<div
className={twMerge(
"w-1.5 h-1.5 rounded-full mt-1.5 shrink-0 motion-safe:animate-pulse",
statusPointClasses[status],
)}
/>
<p className="font-caption text-text-sub leading-relaxed">
기간의 {periodElapsedRate}%가 경과했으며, 예산은{" "}
<span
className={twMerge(
"font-bold",
status === "위험"
? "text-status-red"
: status === "주의"
? "text-status-yellow"
: "text-status-green",
)}
>
{status}
</span>{" "}
수준으로 운용되고 있습니다.
</p>
<div className="flex flex-col gap-1 p-4 rounded-2xl bg-bg-surface/50">
<div className="flex items-center justify-between">
<span className="font-caption font-medium text-text-auth-sub">
Comment thread
Seojegyeong marked this conversation as resolved.
기간 진행률
</span>
<span className="font-caption font-semibold text-text-sub tabular-nums">
{periodElapsedRate}%
</span>
</div>
<span className="font-body1 font-bold text-text-main tabular-nums text-right mt-0.5">
{periodElapsedDays}일
<span className="font-caption font-medium text-text-auth-sub">
{" "}
/ {periodTotalDays}일
</span>
</span>
</div>
</div>

<div className="mt-auto px-5 py-4 flex items-center gap-3 rounded-[16px] bg-[#F2F4F6]">
Comment thread
Seojegyeong marked this conversation as resolved.
<AlertCircleIcon
className="w-5 h-5 text-[#8B95A1] shrink-0"
aria-hidden="true"
/>
<p className="font-body2 text-[#4E5968] font-medium leading-snug break-keep">
{insightDesc}
</p>
</div>
</div>
);
}
Loading
Loading