diff --git a/src/components/timeline/TimelineCreateModal.stories.tsx b/src/components/timeline/TimelineCreateModal.stories.tsx new file mode 100644 index 0000000..b7548f8 --- /dev/null +++ b/src/components/timeline/TimelineCreateModal.stories.tsx @@ -0,0 +1,127 @@ +import { useEffect, useRef } from "react"; +import type { Meta, StoryObj } from "@storybook/react"; +import { fn } from "@storybook/test"; + +import TimelineCreateModal from "./TimelineCreateModal"; + +const meta: Meta = { + title: "Timeline/CreateModal", + component: TimelineCreateModal, + parameters: { layout: "fullscreen" }, + args: { + onClose: fn(), + }, +}; + +export default meta; +type TStory = StoryObj; + +function EnsureModalRoot() { + useEffect(() => { + let el = document.getElementById("modal-root"); + let created = false; + + if (!el) { + created = true; + el = document.createElement("div"); + el.id = "modal-root"; + document.body.appendChild(el); + } + + return () => { + if (created) el?.remove(); + }; + }, []); + return null; +} + +function ValidationErrorsPreview() { + const submittedRef = useRef(false); + + useEffect(() => { + if (submittedRef.current) return; + submittedRef.current = true; + + const timer = window.setTimeout(() => { + const submitButton = document.querySelector( + 'button[type="submit"]', + ); + submitButton?.click(); + }, 150); + return () => window.clearTimeout(timer); + }, []); + + return ( + <> + + + + ); +} + +function SubmittingPreview() { + const submittedRef = useRef(false); + + useEffect(() => { + if (submittedRef.current) return; + submittedRef.current = true; + + const timer = window.setTimeout(() => { + const nameInput = document.querySelector( + 'input[placeholder="ex. 6월 봄 프로모션"]', + ); + const startInput = + document.querySelector('input[type="date"]'); + const endInputs = + document.querySelectorAll('input[type="date"]'); + + if (nameInput) { + nameInput.value = "스토리북 테스트"; + nameInput.dispatchEvent(new Event("input", { bubbles: true })); + } + if (startInput) { + startInput.value = "2026-06-01"; + startInput.dispatchEvent(new Event("input", { bubbles: true })); + } + if (endInputs) { + endInputs[1].value = "2026-06-30"; + endInputs[1].dispatchEvent(new Event("input", { bubbles: true })); + } + + const metricButton = document.querySelector( + 'button[aria-pressed="false"]', + ); + metricButton?.click(); + + const submitButton = document.querySelector( + 'button[type="submit"]', + ); + submitButton?.click(); + }, 200); + return () => window.clearTimeout(timer); + }, []); + + return ( + <> + + + + ); +} + +export const Default: TStory = { + render: () => ( + <> + + + + ), +}; + +export const ValidationErrors: TStory = { + render: () => , +}; + +export const Submitting: TStory = { + render: () => , +}; diff --git a/src/components/timeline/TimelineCreateModal.tsx b/src/components/timeline/TimelineCreateModal.tsx new file mode 100644 index 0000000..e344c32 --- /dev/null +++ b/src/components/timeline/TimelineCreateModal.tsx @@ -0,0 +1,306 @@ +import { type MouseEvent, useEffect, useMemo, useState } from "react"; +import { Controller, type SubmitHandler, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { toast } from "sonner"; +import { twMerge } from "tailwind-merge"; + +import type { TTimelineMetric } from "@/types/timeline/api"; +import { TIMELINE_FORM_DEFAULT_VALUES } from "@/types/timeline/form"; +import { + TIMELINE_COMPARISON_PERIOD_OPTIONS, + TIMELINE_METRIC_OPTIONS, +} from "@/constants/timeline/formOptions"; + +import { + timelineCreateSchema, + type TTimelineCreateFormValues, +} from "@/utils/timeline/timeline"; + +import Button from "../common/button/Button"; +import { + DropdownMenu, + type TMenuItem, +} from "../common/dropdownmenu/DropdownMenu"; +import Input from "../common/input/Input"; +import Modal from "../common/modal/Modal"; + +import ChevronIcon from "@/assets/icon/chevron/chevron-up.svg?react"; + +const MOCK_SUBMIT_DELAY_MS = 800; + +function openDatePickerFromField(event: MouseEvent) { + const input = + event.currentTarget.querySelector('input[type="date"]'); + if (!input || input.disabled) return; + + input.focus(); + + if (typeof input.showPicker !== "function") return; + + try { + input.showPicker(); + } catch { + /* 일부 브라우저나 보안 컨텍스트에서 showPicker 호출 제한될 수 있음 */ + } +} + +interface ITimelineCreateModalProps { + isOpen: boolean; + onClose: () => void; +} + +export default function TimelineCreateModal({ + isOpen, + onClose, +}: ITimelineCreateModalProps) { + const [isSubmitting, setIsSubmitting] = useState(false); + + const { + register, + control, + handleSubmit, + reset, + watch, + setValue, + formState: { errors }, + } = useForm({ + mode: "onChange", + resolver: zodResolver(timelineCreateSchema), + defaultValues: TIMELINE_FORM_DEFAULT_VALUES, + }); + + const selectedMetrics = watch("metrics"); + const comparisonPeriodType = watch("comparisonPeriodType"); + + const comparisonLabel = useMemo(() => { + return ( + TIMELINE_COMPARISON_PERIOD_OPTIONS.find( + (option) => option.value === comparisonPeriodType, + )?.label ?? "비교 기간 선택" + ); + }, [comparisonPeriodType]); + + const comparisonMenuItems: TMenuItem[] = useMemo( + () => + TIMELINE_COMPARISON_PERIOD_OPTIONS.map((option) => ({ + label: option.label, + active: option.value === comparisonPeriodType, + onClick: () => { + setValue("comparisonPeriodType", option.value, { + shouldValidate: true, + }); + }, + })), + [comparisonPeriodType, setValue], + ); + + useEffect(() => { + if (!isOpen) { + reset(TIMELINE_FORM_DEFAULT_VALUES); + setIsSubmitting(false); + } + }, [isOpen, reset]); + + const handleClose = () => { + if (isSubmitting) return; + onClose(); + }; + + const toggleMetric = (metric: TTimelineMetric) => { + const current = selectedMetrics ?? []; + const next = current.includes(metric) + ? current.filter((item) => item !== metric) + : [...current, metric]; + + setValue("metrics", next, { shouldValidate: true }); + }; + + const onSubmit: SubmitHandler = async (data) => { + setIsSubmitting(true); + + try { + await new Promise((resolve) => { + window.setTimeout(resolve, MOCK_SUBMIT_DELAY_MS); + }); + toast.success("타임라인이 생성되었습니다", { + description: `"${data.name}" 타임라인을 추가했습니다`, + }); + reset(TIMELINE_FORM_DEFAULT_VALUES); + onClose(); + } catch { + toast.error("타임라인 생성에 실패했습니다. 다시 시도해주세요"); + } finally { + setIsSubmitting(false); + } + }; + + return ( + +
+

타임라인 생성

+

+ 분석할 기간과 성과 지표를 설정해 새 타임라인을 만들어보세요 +

+ +
+
+ + +
+
+ +
+
+ +
+
+ +
+ 성과 지표 +

+ 타임라인에서 추적할 지표를 1개 이상 선택하세요 +

+ ( +
+ {TIMELINE_METRIC_OPTIONS.map((option) => { + const isSelected = selectedMetrics?.includes( + option.value, + ); + return ( + + ); + })} +
+ )} + /> + {errors.metrics ? ( +

+ {errors.metrics.message} +

+ ) : null} +
+
+ 기간 설정 +

+ 성과를 비교할 기준 기간을 선택하세요 +

+ ( + ( +
+ + {comparisonLabel} + + +
+ )} + /> + )} + /> + {errors.comparisonPeriodType ? ( +

+ {errors.comparisonPeriodType.message} +

+ ) : null} +
+
+
+ +
+
+
+
+ ); +} diff --git a/src/components/timeline/TimelinePerformancePanel.tsx b/src/components/timeline/TimelinePerformancePanel.tsx index b7e856a..66ace77 100644 --- a/src/components/timeline/TimelinePerformancePanel.tsx +++ b/src/components/timeline/TimelinePerformancePanel.tsx @@ -131,7 +131,13 @@ export default function TimelinePerformancePanel({ CHART_PERIOD_LABELS[chartPeriodIndex] ?? CHART_PERIOD_LABELS[0]; useEffect(() => { - if (!isOpen) return; + if (!isOpen) { + if (summaryTimerRef.current !== null) { + window.clearTimeout(summaryTimerRef.current); + summaryTimerRef.current = null; + } + return; + } setAiState(data.aiSummary.trim() ? "done" : "idle"); setGeneratedSummary(""); }, [isOpen, data.aiSummary]); @@ -144,12 +150,16 @@ export default function TimelinePerformancePanel({ }; }, []); const handleGenerateSummary = () => { + if (summaryTimerRef.current !== null) { + window.clearTimeout(summaryTimerRef.current); + } setAiState("loading"); summaryTimerRef.current = window.setTimeout(() => { setGeneratedSummary( data.aiSummary.trim() || "AI 요약이 생성되었습니다.(API연동전 임시)", ); setAiState("done"); + summaryTimerRef.current = null; }, AI_SUMMARY_LOADING_MS); }; diff --git a/src/constants/timeline/formOptions.ts b/src/constants/timeline/formOptions.ts new file mode 100644 index 0000000..592e6cc --- /dev/null +++ b/src/constants/timeline/formOptions.ts @@ -0,0 +1,30 @@ +import { + TIMELINE_COMPARISON_PERIOD_TYPES, + TIMELINE_METRICS, + type TTimelineComparisonPeriodType, + type TTimelineMetric, +} from "@/types/timeline/api"; + +export const TIMELINE_METRIC_OPTIONS: ReadonlyArray<{ + value: TTimelineMetric; + label: string; +}> = [ + { value: "CLICK", label: "클릭" }, + { value: "CONVERSION", label: "전환" }, + { value: "IMPRESSION", label: "노출" }, + { value: "ROAS", label: "ROAS" }, +] as const; + +export const TIMELINE_COMPARISON_PERIOD_OPTIONS: ReadonlyArray<{ + value: TTimelineComparisonPeriodType; + label: string; +}> = [ + { value: "LAST_WEEK", label: "지난주 대비" }, + { value: "LAST_MONTH", label: "지난달 대비" }, + { value: "PREVIOUS_PERIOD", label: "이전 동일 기간 대비" }, +] as const; + +/*zod enum 용 - 타입과 동기화*/ +export const TIMELINE_METRIC_VALUES = TIMELINE_METRICS; +export const TIMELINE_COMPARISON_PERIOD_VALUES = + TIMELINE_COMPARISON_PERIOD_TYPES; diff --git a/src/types/timeline/timeline.mock.ts b/src/types/timeline/timeline.mock.ts index 30dcc0c..bd79ce7 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: - "해당 기간 동안 클릭수는 전반적으로 증가하며 평균 이상의 성과를 보였습니다. 특히 1월 23일에 가장 높은 클릭수를 기록했으며, 이후에도 높은 수준을 유지했습니다. 다만 전환수는 후반부로 갈수록 소폭 감소하는 흐름을 보였습니다.", + "해당 기간 동안 클릭수는 전반적으로 증가하며 평균 이상의 성과를 보였습니다. 특히 6월 23일에 가장 높은 클릭수를 기록했으며, 이후에도 높은 수준을 유지했습니다. 다만 전환수는 후반부로 갈수록 소폭 감소하는 흐름을 보였습니다.", dailyTrend: [ { date: "2026-06-18", diff --git a/src/utils/timeline/timeline.ts b/src/utils/timeline/timeline.ts new file mode 100644 index 0000000..08e7fd1 --- /dev/null +++ b/src/utils/timeline/timeline.ts @@ -0,0 +1,29 @@ +import z from "zod"; + +import { + TIMELINE_COMPARISON_PERIOD_VALUES, + TIMELINE_METRIC_VALUES, +} from "@/constants/timeline/formOptions"; + +const dataFieldSchema = z.string().trim().min(1, "날짜를 선택해 주세요"); + +export const timelineCreateSchema = z + .object({ + name: z + .string() + .trim() + .min(1, "타임라인 이름을 입력해주세요") + .max(50, "이름은 50자 이내로 입력해주세요"), + startDate: dataFieldSchema, + endDate: dataFieldSchema, + metrics: z + .array(z.enum(TIMELINE_METRIC_VALUES)) + .min(1, "성과 지표를 1개 이상 선택해주세요"), + comparisonPeriodType: z.enum(TIMELINE_COMPARISON_PERIOD_VALUES), + }) + .refine((data) => data.startDate <= data.endDate, { + path: ["endDate"], + message: "종료일은 시작일 이전이 불가합니다", + }); + +export type TTimelineCreateFormValues = z.infer;