diff --git a/src/components/common/dropdownmenu/DropdownMenu.tsx b/src/components/common/dropdownmenu/DropdownMenu.tsx index 20a815fd..8e196e53 100644 --- a/src/components/common/dropdownmenu/DropdownMenu.tsx +++ b/src/components/common/dropdownmenu/DropdownMenu.tsx @@ -77,6 +77,7 @@ export function DropdownMenu({ menuClassName, fullWidth = false, placement = "bottom", + inFlow = false, "aria-label": ariaLabel, onOpenChange, }: { @@ -88,6 +89,7 @@ export function DropdownMenu({ fullWidth?: boolean; /** bottom: 아래 / top: 위 / auto: 공간에 따라 자동 */ placement?: TDropdownPlacement; + inFlow?: boolean; "aria-label"?: string; onOpenChange?: (open: boolean) => void; }) { @@ -114,7 +116,10 @@ export function DropdownMenu({ }, []); useLayoutEffect(() => { - if (!open) return; + if (!open || inFlow) { + setResolvedPlacement("bottom"); + return; + } if (placement === "bottom") { setResolvedPlacement("bottom"); @@ -130,13 +135,13 @@ export function DropdownMenu({ if (!el) return; setResolvedPlacement(resolveAutoPlacement(el, items.length)); - }, [open, placement, items.length]); + }, [open, placement, items.length, inFlow]); useEffect(() => { onOpenChange?.(open); }, [open, onOpenChange]); - const isTopPlacement = resolvedPlacement === "top"; + const isTopPlacement = !inFlow && resolvedPlacement === "top"; return (
{items.map((it, idx) => ( diff --git a/src/components/timeline/TimelineCreateModal.tsx b/src/components/timeline/TimelineCreateModal.tsx index 2ce74790..089e9dad 100644 --- a/src/components/timeline/TimelineCreateModal.tsx +++ b/src/components/timeline/TimelineCreateModal.tsx @@ -229,7 +229,7 @@ export default function TimelineCreateModal({ aria-pressed={isSelected} onClick={() => toggleMetric(option.value)} className={twMerge( - "inline-flex h-8 items-center rounded-full border px-4 font-body2 transition-colors", + "inline-flex h-8 items-center rounded-full border px-4 font-body2 transition-all duration-300 ease-out active:scale-95", isSelected ? "border-info-blue/40 bg-info-blue/15 text-info-blue" : "border-text-placeholder/40 bg-surface-200 text-text-muted hover:bg-surface-300", @@ -260,6 +260,7 @@ export default function TimelineCreateModal({ render={() => ( ( diff --git a/src/components/timeline/TimelinePerformancePanel.tsx b/src/components/timeline/TimelinePerformancePanel.tsx index be74a429..bc9147d3 100644 --- a/src/components/timeline/TimelinePerformancePanel.tsx +++ b/src/components/timeline/TimelinePerformancePanel.tsx @@ -1,5 +1,5 @@ import type { FC, SVGProps } from "react"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { twMerge } from "tailwind-merge"; import type { TProviderType } from "@/types/dashboard/provider"; @@ -8,6 +8,7 @@ import type { TTimelineViewUnit } from "@/types/timeline/ui"; import { resolveTimelinePerformanceStatusStyle } from "@/constants/timeline/statusStyle"; import { getTimelineMetricLabel } from "@/utils/timeline/buildTimelineChartSeries"; +import { getPeriodIndexContainingDate } from "@/utils/timeline/period"; import { canGoToPrevChartPeriod, sliceDailyTrendByPeriod, @@ -150,6 +151,12 @@ export default function TimelinePerformancePanel({ const chartMetric = data.metrics[0]?.metric ?? "CLICK"; const chartMetricLabel = getTimelineMetricLabel(chartMetric); + useEffect(() => { + if (!isOpen) return; + setViewUnit("WEEK"); + setChartPeriodIndex(getPeriodIndexContainingDate("WEEK", data.startDate)); + }, [isOpen, data.startDate]); + const { periodLabel: chartPeriodLabel, slicedTrend, @@ -265,7 +272,7 @@ export default function TimelinePerformancePanel({ 성과 지표
{data.metrics.map((metric) => ( - + {metric.label} ))} @@ -275,56 +282,88 @@ export default function TimelinePerformancePanel({ {/* AI 요약 */} -
+
{aiState === "idle" && ( - +

+ AI 요약 +

+ +
)} {aiState === "loading" && ( -
- - - +
+

+ AI 요약 +

+
+ + + +
)} {aiState === "done" && hasSummary && ( -

- {data.aiSummary} -

+

+ AI 요약 +

+

+ {data.aiSummary} +

+
)} {/* KPI — 선택한 지표 수만큼 가로 균등 분할 */} -
+
{data.metrics.map((metric) => (
{metric.label} - + {formatMetricValue(metric.value, metric.unit)} {metric.changeRate !== undefined && ( diff --git a/src/constants/timeline/layout.ts b/src/constants/timeline/layout.ts index 711b91bc..1a21f267 100644 --- a/src/constants/timeline/layout.ts +++ b/src/constants/timeline/layout.ts @@ -2,7 +2,7 @@ // 열 최소 너비. 컨테이너가 더 넓으면 균등 분배로 커짐 export const TIMELINE_COL_WIDTH = 92; -export const TIMELINE_ROW_HEIGHT = 104; +export const TIMELINE_ROW_HEIGHT = 92; export const TIMELINE_ROW_OFFSET = 24; export const TIMELINE_AXIS_HEIGHT = 56; export const TIMELINE_BAR_HEIGHT = 80; diff --git a/src/utils/timeline/period.ts b/src/utils/timeline/period.ts index 5577903b..e0e00293 100644 --- a/src/utils/timeline/period.ts +++ b/src/utils/timeline/period.ts @@ -113,3 +113,35 @@ export function resolveVisiblePeriod( periodIndex === 0 ? formatMonthLabel(start) : formatMonthLabel(start), }; } + +export function getPeriodIndexContainingDate( + viewUnit: TTimelineViewUnit, + targetIso: string, + today = new Date(), +): number { + const target = startOfDay(parseIsoDate(targetIso)); + const normalizedToday = startOfDay(today); + const dayMs = 24 * 60 * 60 * 1000; + + if (viewUnit === "DAY") { + return Math.max( + 0, + Math.round((normalizedToday.getTime() - target.getTime()) / dayMs), + ); + } + if (viewUnit === "WEEK") { + const todayWeekStart = getWeekStart(normalizedToday); + const targetWeekStart = getWeekStart(target); + return Math.max( + 0, + Math.round( + (todayWeekStart.getTime() - targetWeekStart.getTime()) / (7 * dayMs), + ), + ); + } + + const monthDiff = + (normalizedToday.getFullYear() - target.getFullYear()) * 12 + + (normalizedToday.getMonth() - target.getMonth()); + return Math.max(0, monthDiff); +}