diff --git a/src/components/common/dropdownmenu/DropdownMenu.tsx b/src/components/common/dropdownmenu/DropdownMenu.tsx
index 6ed9aa8f..265de431 100644
--- a/src/components/common/dropdownmenu/DropdownMenu.tsx
+++ b/src/components/common/dropdownmenu/DropdownMenu.tsx
@@ -7,6 +7,8 @@ export type TMenuItem = {
icon?: React.ReactNode;
onClick: () => void;
active?: boolean;
+ danger?: boolean;
+ labelClassName?: string;
};
const easeOut = [0, 0, 0.2, 1] as const;
@@ -114,9 +116,11 @@ export function DropdownMenu({
}}
className={twMerge(
"group flex w-full items-center justify-between rounded-2xl px-5 py-4 text-left font-body2 transition-ui-fast",
- it.active
- ? "bg-info-blue/10 text-info-blue"
- : "text-text-body hover:bg-primary-100/50 hover:text-info-blue",
+ it.danger
+ ? "text-info-red hover:bg-info-red/10 hover:text-info-red"
+ : it.active
+ ? "bg-info-blue/10 text-info-blue"
+ : "text-text-body hover:bg-primary-100/50 hover:text-info-blue",
)}
>
@@ -124,9 +128,11 @@ export function DropdownMenu({
@@ -137,6 +143,9 @@ export function DropdownMenu({
className={twMerge(
"min-w-0 truncate font-body2 text-left",
it.active && "font-label",
+ it.danger &&
+ "text-info-red group-hover:text-info-red",
+ it.labelClassName,
)}
>
{it.label}
diff --git a/src/components/timeline/TimelineBar.tsx b/src/components/timeline/TimelineBar.tsx
index 933eedd8..5d9b05e7 100644
--- a/src/components/timeline/TimelineBar.tsx
+++ b/src/components/timeline/TimelineBar.tsx
@@ -17,6 +17,7 @@ interface ITimelineBarProps {
rowHeight?: number;
rowOffset?: number;
className?: string;
+ onBarClick?: (bar: ITimelineCampaignBar) => void;
onMenuClick?: (bar: ITimelineCampaignBar) => void; //선택, 추후 이슈로 다룰 예정
}
@@ -26,6 +27,7 @@ export default function TimelineBar({
rowHeight = TIMELINE_ROW_HEIGHT,
rowOffset = TIMELINE_ROW_OFFSET,
className,
+ onBarClick,
onMenuClick,
}: ITimelineBarProps) {
const status = TIMELINE_PERFORMANCE_STATUS_STYLE[bar.performanceStatus];
@@ -43,6 +45,7 @@ export default function TimelineBar({
status.barBg,
className,
)}
+ onClick={() => onBarClick?.(bar)}
style={{ left, top, width, height: TIMELINE_BAR_HEIGHT }}
>
onMenuClick?.(bar)}
+ onClick={(event) => {
+ event.stopPropagation();
+ onMenuClick?.(bar);
+ }}
>
diff --git a/src/components/timeline/TimelinePerformancePanel.stories.tsx b/src/components/timeline/TimelinePerformancePanel.stories.tsx
new file mode 100644
index 00000000..29d33c78
--- /dev/null
+++ b/src/components/timeline/TimelinePerformancePanel.stories.tsx
@@ -0,0 +1,84 @@
+import { useState } from "react";
+import type { Meta, StoryObj } from "@storybook/react";
+import { fn } from "@storybook/test";
+
+import {
+ TIMELINE_SUMMARY_PANEL_MOCK,
+ TIMELINE_SUMMARY_PANEL_NO_AI_MOCK,
+} from "@/types/timeline/timeline.mock";
+
+import TimelinePerformancePanel from "./TimelinePerformancePanel";
+
+const meta: Meta
= {
+ title: "Timeline/PerformancePanel",
+ component: TimelinePerformancePanel,
+ parameters: { layout: "fullscreen" },
+ args: {
+ onClose: fn(),
+ onEdit: fn(),
+ onDelete: fn(),
+ },
+};
+
+export default meta;
+type TStory = StoryObj;
+
+function ClosedPreivew() {
+ const [open, setOpen] = useState(false);
+
+ return (
+ <>
+
+
+
+ setOpen(false)}
+ data={TIMELINE_SUMMARY_PANEL_NO_AI_MOCK}
+ onEdit={fn()}
+ onDelete={fn()}
+ />
+ >
+ );
+}
+
+export const Closed: TStory = {
+ render: () => ,
+};
+
+export const Open: TStory = {
+ args: {
+ isOpen: true,
+ data: TIMELINE_SUMMARY_PANEL_NO_AI_MOCK,
+ },
+};
+
+export const OpenWithSummary: TStory = {
+ args: {
+ isOpen: true,
+ data: TIMELINE_SUMMARY_PANEL_MOCK,
+ },
+};
+
+function InteractivePreview() {
+ const [open, setOpen] = useState(true);
+ return (
+ setOpen(false)}
+ data={TIMELINE_SUMMARY_PANEL_MOCK}
+ onEdit={fn()}
+ onDelete={fn()}
+ />
+ );
+}
+
+export const Interactive: TStory = {
+ render: () => ,
+};
diff --git a/src/components/timeline/TimelinePerformancePanel.tsx b/src/components/timeline/TimelinePerformancePanel.tsx
new file mode 100644
index 00000000..b7e856a8
--- /dev/null
+++ b/src/components/timeline/TimelinePerformancePanel.tsx
@@ -0,0 +1,371 @@
+import type { FC, SVGProps } from "react";
+import { useEffect, useRef, useState } from "react";
+import { twMerge } from "tailwind-merge";
+
+import type { TProviderType } from "@/types/dashboard/provider";
+import type { ITimelineSummaryPanelData } from "@/types/timeline/summary";
+import type { TTimelineViewUnit } from "@/types/timeline/ui";
+import { TIMELINE_PERFORMANCE_STATUS_STYLE } from "@/constants/timeline/statusStyle";
+
+import Badge from "@/components/common/badge/Badge";
+import Button from "@/components/common/button/Button";
+import ChartLegend from "@/components/common/chart/ChartLegend";
+import Drawer from "@/components/common/drawer/Drawer";
+import { DropdownMenu } from "@/components/common/dropdownmenu/DropdownMenu";
+import { Skeleton } from "@/components/common/skeleton/Skeleton";
+import TimelinePeriodSelector from "@/components/timeline/TimelinePeriodSelector";
+
+import ChevronRightIcon from "@/assets/icon/chevron/chevron-right.svg?react";
+import MoreIcon from "@/assets/icon/common/more.svg?react";
+import TrashIcon from "@/assets/icon/common/trash.svg?react";
+import GoogleWordmark from "@/assets/logo/social-logo/wordmark/google-wordmark.svg?react";
+import MetaWordmark from "@/assets/logo/social-logo/wordmark/meta-wordmark.svg?react";
+import NaverWordmark from "@/assets/logo/social-logo/wordmark/naver-wordmark.svg?react";
+
+type TAiSummaryUiState = "idle" | "loading" | "done";
+
+const AI_SUMMARY_LOADING_MS = 1500;
+
+const CHART_PERIOD_LABELS = ["오늘", "1월 21일 → 25일", "1월 14일 → 20일"];
+
+const SECTION_SHELL_CLASS =
+ "rounded-3xl border border-surface-300/70 bg-surface-100";
+
+const SECTION_INNER_CLASS = "flex flex-col gap-5 px-6 py-6";
+
+const SOFT_CARD_CLASS = "rounded-2xl bg-surface-100 shadow-Soft";
+
+const PLATFORM_WORDMARKS: Record<
+ TProviderType,
+ { Logo: FC>; className: string; label: string }
+> = {
+ GOOGLE: {
+ Logo: GoogleWordmark,
+ className: "h-5 w-auto",
+ label: "Google",
+ },
+ NAVER: {
+ Logo: NaverWordmark,
+ className: "h-4 w-auto",
+ label: "NAVER",
+ },
+ META: {
+ Logo: MetaWordmark,
+ className: "h-3.5 w-auto",
+ label: "Meta",
+ },
+};
+
+const SECTION_TITLE_CLASS = "font-heading4 text-text-title";
+
+interface ITimelinePerformancePanelProps {
+ isOpen: boolean;
+ onClose: () => void;
+ data: ITimelineSummaryPanelData;
+ onEdit?: () => void;
+ onDelete?: () => void;
+ className?: string;
+}
+
+function formatMetricValue(value: number, unit?: string) {
+ const formatted = Number.isInteger(value)
+ ? value.toLocaleString()
+ : value.toFixed(2);
+ return unit ? `${formatted}${unit}` : formatted;
+}
+
+function formatChangeRate(changeRate: number) {
+ return `${Math.abs(changeRate * 100).toFixed(1)}%`;
+}
+
+function PlatformContributionRow({
+ provider,
+ value,
+}: {
+ provider: TProviderType;
+ value: number;
+}) {
+ const {
+ Logo,
+ className: logoClassName,
+ label,
+ } = PLATFORM_WORDMARKS[provider];
+ const progress = Math.min(Math.max(value, 0), 100);
+
+ return (
+
+ );
+}
+
+export default function TimelinePerformancePanel({
+ isOpen,
+ onClose,
+ data,
+ onEdit,
+ onDelete,
+ className,
+}: ITimelinePerformancePanelProps) {
+ const [aiState, setAiState] = useState("idle");
+ const [generatedSummary, setGeneratedSummary] = useState("");
+ const summaryTimerRef = useRef(null);
+ const [viewUnit, setViewUnit] = useState("WEEK");
+ const [chartPeriodIndex, setChartPeriodIndex] = useState(0);
+
+ const statusStyle = TIMELINE_PERFORMANCE_STATUS_STYLE[data.performanceStatus];
+ const chartPeriodLabel =
+ CHART_PERIOD_LABELS[chartPeriodIndex] ?? CHART_PERIOD_LABELS[0];
+
+ useEffect(() => {
+ if (!isOpen) return;
+ setAiState(data.aiSummary.trim() ? "done" : "idle");
+ setGeneratedSummary("");
+ }, [isOpen, data.aiSummary]);
+
+ useEffect(() => {
+ return () => {
+ if (summaryTimerRef.current !== null) {
+ window.clearTimeout(summaryTimerRef.current);
+ }
+ };
+ }, []);
+ const handleGenerateSummary = () => {
+ setAiState("loading");
+ summaryTimerRef.current = window.setTimeout(() => {
+ setGeneratedSummary(
+ data.aiSummary.trim() || "AI 요약이 생성되었습니다.(API연동전 임시)",
+ );
+ setAiState("done");
+ }, AI_SUMMARY_LOADING_MS);
+ };
+
+ const handlePrevChartPeriod = () => {
+ setChartPeriodIndex((prev) =>
+ prev === 0 ? CHART_PERIOD_LABELS.length - 1 : prev - 1,
+ );
+ };
+
+ const handleNextChartPeriod = () => {
+ setChartPeriodIndex((prev) =>
+ prev === CHART_PERIOD_LABELS.length - 1 ? 0 : prev + 1,
+ );
+ };
+
+ const menuItems = [
+ {
+ label: "삭제하기",
+ icon: ,
+ danger: true,
+ labelClassName: "text-info-red",
+ onClick: () => onDelete?.(),
+ },
+ {
+ label: "수정하기",
+ onClick: () => onEdit?.(),
+ },
+ ];
+
+ return (
+
+
+ {/* 헤더 */}
+
+
+
+ }
+ aria-label="더보기"
+ items={menuItems}
+ className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg transition-colors hover:bg-surface-200"
+ />
+
+
+ {data.timelineName}
+
+
+
+ 기간
+
+ {data.periodLabel}
+
+
+
+
+ 성과 상태
+
+
+ {statusStyle.label}
+
+
+
+
+
성과 지표
+
+ {data.metrics.map((metric) => (
+
+ {metric.label}
+
+ ))}
+
+
+
+
+
+ {/* AI 요약 */}
+
+ {aiState === "idle" && (
+
+ )}
+
+ {aiState === "loading" && (
+
+
+
+
+
+ )}
+
+ {aiState === "done" && (data.aiSummary || generatedSummary) && (
+
+ {data.aiSummary || generatedSummary}
+
+ )}
+
+
+ {/* KPI — 선택한 지표 수만큼 가로 균등 분할 */}
+
+ {data.metrics.map((metric) => (
+
+
+
+ {metric.label}
+
+
+ {formatMetricValue(metric.value, metric.unit)}
+
+ {metric.changeRate !== undefined && (
+ = 0
+ ? "text-info-red"
+ : "text-info-blue",
+ )}
+ >
+ {metric.changeRate >= 0 ? "▲" : "▼"}{" "}
+ {formatChangeRate(metric.changeRate)}
+
+ )}
+
+
+ ))}
+
+
+ {/* 차트 placeholder */}
+
+
+
+
+
+ 차트 영역 (ApexChart 연동예정)
+
+
+
+
+
+ {/* 플랫폼 기여 */}
+
+
+
플랫폼 기여 정보
+
+ {data.platformShare.map(({ provider, contributionRate }) => (
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/src/types/timeline/summary.ts b/src/types/timeline/summary.ts
index 3684a106..ef6d8c80 100644
--- a/src/types/timeline/summary.ts
+++ b/src/types/timeline/summary.ts
@@ -1,5 +1,8 @@
import type { TProviderType } from "@/types/dashboard/provider";
-import type { TTimelineMetric } from "@/types/timeline/api";
+import type {
+ TTimelineMetric,
+ TTimelinePerformanceStatus,
+} from "@/types/timeline/api";
/*패널 상단 KPI 카드 한개*/
export interface ITimelineSummaryMetric {
@@ -20,6 +23,7 @@ export interface ITimelineSummaryPlatformShare {
export interface ITimelineSummaryPanelData {
timelineName: string;
periodLabel: string;
+ performanceStatus: TTimelinePerformanceStatus;
aiSummary: string;
metrics: ITimelineSummaryMetric[];
platformShare: ITimelineSummaryPlatformShare[];
diff --git a/src/types/timeline/timeline.mock.ts b/src/types/timeline/timeline.mock.ts
index 15c7b4e8..30dcc0c4 100644
--- a/src/types/timeline/timeline.mock.ts
+++ b/src/types/timeline/timeline.mock.ts
@@ -33,7 +33,7 @@ export const TIMELINE_DETAIL_MOCK: ITimelineDetail = {
performanceStatus: "ON_TRACK",
metrics: ["CLICK", "CONVERSION", "ROAS"],
summary:
- "Google Ads 전환이 전주 대비 12% 상승했습니다. Meta는 노출은 늘었으나 ROAS가 소폭 하락했습니다.",
+ "해당 기간 동안 클릭수는 전반적으로 증가하며 평균 이상의 성과를 보였습니다. 특히 1월 23일에 가장 높은 클릭수를 기록했으며, 이후에도 높은 수준을 유지했습니다. 다만 전환수는 후반부로 갈수록 소폭 감소하는 흐름을 보였습니다.",
dailyTrend: [
{
date: "2026-06-18",
@@ -118,6 +118,7 @@ export const TIMELINE_GRID_MOCK: ITimelineGridData = {
export const TIMELINE_SUMMARY_PANEL_MOCK: ITimelineSummaryPanelData = {
timelineName: TIMELINE_DETAIL_MOCK.name,
periodLabel: "2026.06.01 ~ 2026.06.30",
+ performanceStatus: "ABOVE_AVERAGE",
aiSummary: TIMELINE_DETAIL_MOCK.summary,
metrics: [
{ metric: "CLICK", label: "클릭", value: 3730, changeRate: 0.08 },
@@ -127,7 +128,7 @@ export const TIMELINE_SUMMARY_PANEL_MOCK: ITimelineSummaryPanelData = {
metric: "ROAS",
label: "ROAS",
value: 3.17,
- unit: "x",
+ unit: "배",
changeRate: -0.03,
},
],
@@ -137,3 +138,8 @@ export const TIMELINE_SUMMARY_PANEL_MOCK: ITimelineSummaryPanelData = {
{ provider: "NAVER", contributionRate: 0.17 },
],
};
+
+export const TIMELINE_SUMMARY_PANEL_NO_AI_MOCK: ITimelineSummaryPanelData = {
+ ...TIMELINE_SUMMARY_PANEL_MOCK,
+ aiSummary: "",
+};