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
22 changes: 17 additions & 5 deletions src/components/common/dropdownmenu/DropdownMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export function DropdownMenu({
menuClassName,
fullWidth = false,
placement = "bottom",
inFlow = false,
"aria-label": ariaLabel,
onOpenChange,
}: {
Expand All @@ -88,6 +89,7 @@ export function DropdownMenu({
fullWidth?: boolean;
/** bottom: 아래 / top: 위 / auto: 공간에 따라 자동 */
placement?: TDropdownPlacement;
inFlow?: boolean;
"aria-label"?: string;
onOpenChange?: (open: boolean) => void;
}) {
Expand All @@ -114,7 +116,10 @@ export function DropdownMenu({
}, []);

useLayoutEffect(() => {
if (!open) return;
if (!open || inFlow) {
setResolvedPlacement("bottom");
return;
}

if (placement === "bottom") {
setResolvedPlacement("bottom");
Expand All @@ -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 (
<div
Expand Down Expand Up @@ -180,10 +185,15 @@ export function DropdownMenu({
}}
className={twMerge(
"absolute z-50 rounded-2xl border border-surface-300 bg-surface-100 py-3 px-1 shadow-Soft",
inFlow
? "relative mt-2"
: twMerge(
"absolute",
isTopPlacement ? "bottom-full mb-2" : "top-full mt-2",
),
fullWidth
? "left-0 right-0 w-full"
: "right-0 w-56 max-w-[calc(100vw-40px)]",
Comment thread
jjjsun marked this conversation as resolved.
Resolved
isTopPlacement ? "bottom-full mb-2" : "top-full mt-2",
menuClassName,
)}
initial={{ opacity: 0, scale: reduceMotion ? 1 : 0.96 }}
Expand All @@ -198,7 +208,9 @@ export function DropdownMenu({
<div
className={twMerge(
"space-y-1",
fullWidth && "max-h-60 overflow-y-auto overscroll-contain",
fullWidth &&
!inFlow &&
"max-h-60 overflow-y-auto overscroll-contain",
)}
>
{items.map((it, idx) => (
Expand Down
3 changes: 2 additions & 1 deletion src/components/timeline/TimelineCreateModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -260,6 +260,7 @@ export default function TimelineCreateModal({
render={() => (
<DropdownMenu
fullWidth
inFlow
aria-label="비교 기간 선택"
items={comparisonMenuItems}
trigger={(open) => (
Expand Down
87 changes: 63 additions & 24 deletions src/components/timeline/TimelinePerformancePanel.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -265,7 +272,7 @@ export default function TimelinePerformancePanel({
<span className="font-body2 text-text-muted">성과 지표</span>
<div className="flex flex-wrap gap-2">
{data.metrics.map((metric) => (
<Badge key={metric.metric} variant="surface">
<Badge key={metric.metric} variant="infoBlue">
{metric.label}
</Badge>
))}
Expand All @@ -275,56 +282,88 @@ export default function TimelinePerformancePanel({
</header>

{/* AI 요약 */}
<section className="flex flex-col gap-4 py-3">
<section className="flex flex-col">
{aiState === "idle" && (
<Button
type="button"
variant="gradient"
size="big"
fullWidth
onClick={() => onRequestSummary?.()}
disabled={isSummaryPending}
className="rounded-2xl px-6 py-4 shadow-Soft"
<div
className={twMerge(
SOFT_CARD_CLASS,
"flex flex-col gap-3 px-5 py-4",
)}
>
요약하기 생성
</Button>
<h3
className={twMerge(SECTION_TITLE_CLASS, "text-text-auth-sub")}
>
AI 요약
</h3>
<Button
type="button"
variant="gradient"
size="big"
fullWidth
onClick={() => onRequestSummary?.()}
disabled={isSummaryPending}
className="rounded-2xl px-6 py-4 shadow-Soft"
>
AI 요약 생성하기
</Button>
Comment thread
jjjsun marked this conversation as resolved.
Resolved
</div>
)}

{aiState === "loading" && (
<div aria-busy="true" className="flex flex-col gap-2 px-1">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-4/6" />
<div
aria-busy="true"
className={twMerge(
SOFT_CARD_CLASS,
"flex flex-col gap-3 px-5 py-4",
)}
>
<h3
className={twMerge(SECTION_TITLE_CLASS, "text-text-auth-sub")}
>
AI 요약
</h3>
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-4/6" />
</div>
</div>
)}

{aiState === "done" && hasSummary && (
<p
<div
className={twMerge(
SOFT_CARD_CLASS,
"px-5 py-4 font-body1 text-text-body break-keep leading-relaxed",
"flex flex-col gap-2 px-5 py-4",
)}
>
{data.aiSummary}
</p>
<h3
className={twMerge(SECTION_TITLE_CLASS, "text-text-auth-sub")}
>
AI 요약
</h3>
<p className="font-body1 text-text-body break-keep leading-relaxed">
{data.aiSummary}
</p>
</div>
)}
</section>

{/* KPI — 선택한 지표 수만큼 가로 균등 분할 */}
<section className="flex w-full gap-2 tablet:flex-wrap">
<section className="grid w-full grid-cols-4 gap-3 tablet:grid-cols-4 tablet:gap-5">
{data.metrics.map((metric) => (
<div
key={metric.metric}
className={twMerge(
SOFT_CARD_CLASS,
"flex min-w-0 flex-1 flex-col gap-1 px-4 py-3.5 tablet:min-w-[calc(50%-0.25rem)]",
"flex min-w-0 flex-col gap-1 border border-primary-200/40 px-4 py-3.5",
)}
>
<span className="ml-1 flex flex-col">
<span className="truncate font-body2 text-text-muted">
{metric.label}
</span>
<span className="truncate text-text-title tracking-tight font-heading4">
<span className="truncate text-primary-400 tracking-tight font-heading4">
{formatMetricValue(metric.value, metric.unit)}
</span>
{metric.changeRate !== undefined && (
Expand Down
2 changes: 1 addition & 1 deletion src/constants/timeline/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
32 changes: 32 additions & 0 deletions src/utils/timeline/period.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Loading