From f1e4798c0573cd66cfec467b92d2fef4918c9ca4 Mon Sep 17 00:00:00 2001 From: JAESEON PARK Date: Mon, 6 Jul 2026 00:14:18 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20=ED=83=80=EC=9E=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=20=EC=88=98=EC=A0=95/=EC=82=AD=EC=A0=9C/=EC=9A=94?= =?UTF-8?q?=EC=95=BDAI=20=EC=9A=94=EC=B2=AD=20API=20=ED=95=A8=EC=88=98=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/timeline/timeline.ts | 49 ++++++++++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/src/api/timeline/timeline.ts b/src/api/timeline/timeline.ts index 2fb93217..78a1bac2 100644 --- a/src/api/timeline/timeline.ts +++ b/src/api/timeline/timeline.ts @@ -1,9 +1,10 @@ import type { ICommonResponse } from "@/types/common/common"; -import type { - ITimelineDetail, - ITimelineListItem, - ITimelineMutationResponse, - ITimelineUpsertRequest, +import { + type ITimelineDetail, + type ITimelineListItem, + type ITimelineMutationResponse, + type ITimelineUpsertRequest, + type TTimelineEmptyResponse, } from "@/types/timeline/api"; import { axiosInstance } from "@/lib/axiosInstance"; @@ -44,10 +45,42 @@ export const createTimeline = async ( }; //타임라인 수정 API -//export const updateTimeline = async +export const updateTimeline = async ( + orgId: number, + timelineId: number, + body: ITimelineUpsertRequest, +): Promise => { + const { data } = await axiosInstance.put< + ICommonResponse + >(`/api/org/${orgId}/timeline/${timelineId}`, body); + return data.data; +}; //타임라인 삭제 API -//export const deleteTimeline = async +export const deleteTimeline = async ( + orgId: number, + timelineId: number, +): Promise => { + const { data } = await axiosInstance.delete< + ICommonResponse + >(`/api/org/${orgId}/timeline/${timelineId}`); + return data.data; +}; //타임라인 AI 요약 요청 API -//export const requestTimelineSummary = async +export const requestTimelineSummary = async ( + orgId: number, + timelineId: number, +): Promise => { + const { data } = await axiosInstance.post< + ICommonResponse + >( + `/api/org/${orgId}/timeline/${timelineId}/summary`, + {}, + { + validateStatus: (status) => + status === 202 || (status >= 200 && status < 300), + }, + ); + return data.data; +}; From 76e7ccc66fa377d36514b97672b9c89bf64b2bf4 Mon Sep 17 00:00:00 2001 From: JAESEON PARK Date: Mon, 6 Jul 2026 20:20:22 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat:=20=ED=83=80=EC=9E=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=20=EC=88=98=EC=A0=95/=EC=82=AD=EC=A0=9C/AI=EC=9A=94?= =?UTF-8?q?=EC=95=BD=20=ED=9B=85=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/timeline/useDeleteTimeline.ts | 34 +++++++++++++++ .../timeline/useRequestTimelineSummary.ts | 34 +++++++++++++++ src/hooks/timeline/useUpdateTimeline.ts | 43 +++++++++++++++++++ src/types/timeline/api.ts | 5 +++ 4 files changed, 116 insertions(+) create mode 100644 src/hooks/timeline/useDeleteTimeline.ts create mode 100644 src/hooks/timeline/useRequestTimelineSummary.ts create mode 100644 src/hooks/timeline/useUpdateTimeline.ts diff --git a/src/hooks/timeline/useDeleteTimeline.ts b/src/hooks/timeline/useDeleteTimeline.ts new file mode 100644 index 00000000..5a8914c4 --- /dev/null +++ b/src/hooks/timeline/useDeleteTimeline.ts @@ -0,0 +1,34 @@ +import { toast } from "sonner"; + +import type { IApiErrorResponse } from "@/types/common/common"; + +import { useCoreMutation } from "../customQuery"; + +import { deleteTimeline } from "@/api/timeline/timeline"; +import { QUERY_KEYS } from "@/lib/queryKeys"; +import useWorkspaceStore from "@/store/useWorkspaceStore"; + +export function useDeleteTimeline() { + const orgId = useWorkspaceStore((s) => s.selectedOrgId); + + return useCoreMutation( + (timelineId: number) => { + if (orgId == null) { + return Promise.reject(new Error("삭제할 워크스페이스를 선택해주세요")); + } + return deleteTimeline(orgId, timelineId); + }, + { + invalidateKeys: orgId != null ? [QUERY_KEYS.timeline.list(orgId)] : [], + userOnSuccess: () => { + toast.success("타임라인이 삭제되었습니다."); + }, + userOnError: (error) => { + const message = + (error as IApiErrorResponse).message ?? + "타임라인 삭제에 실패했습니다. 다시 시도해주세요"; + toast.error(message); + }, + }, + ); +} diff --git a/src/hooks/timeline/useRequestTimelineSummary.ts b/src/hooks/timeline/useRequestTimelineSummary.ts new file mode 100644 index 00000000..923c4f28 --- /dev/null +++ b/src/hooks/timeline/useRequestTimelineSummary.ts @@ -0,0 +1,34 @@ +import { toast } from "sonner"; + +import type { IApiErrorResponse } from "@/types/common/common"; + +import { useCoreMutation } from "../customQuery"; + +import { requestTimelineSummary } from "@/api/timeline/timeline"; +import useWorkspaceStore from "@/store/useWorkspaceStore"; + +export function useRequestTimelineSummary() { + const orgId = useWorkspaceStore((s) => s.selectedOrgId); + + return useCoreMutation( + (timelineId: number) => { + if (orgId == null) { + return Promise.reject(new Error("성과 요약할 타임라인을 선택해주세요")); + } + return requestTimelineSummary(orgId, timelineId); + }, + { + userOnSuccess: () => { + toast.success("AI 요약을 생성하고 있어요", { + description: "더 자세한 분석을 위해 AI가 요약중입니다", + }); + }, + userOnError: (error) => { + const message = + (error as IApiErrorResponse).message ?? + "AI 요약 요청에 실패했습니다. 창을 닫고 다시 시도해주세요"; + toast.error(message); + }, + }, + ); +} diff --git a/src/hooks/timeline/useUpdateTimeline.ts b/src/hooks/timeline/useUpdateTimeline.ts new file mode 100644 index 00000000..97e89de9 --- /dev/null +++ b/src/hooks/timeline/useUpdateTimeline.ts @@ -0,0 +1,43 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; + +import type { IApiErrorResponse } from "@/types/common/common"; +import type { IUpdateTimelineVariables } from "@/types/timeline/api"; + +import { useCoreMutation } from "../customQuery"; + +import { updateTimeline } from "@/api/timeline/timeline"; +import { QUERY_KEYS } from "@/lib/queryKeys"; +import useWorkspaceStore from "@/store/useWorkspaceStore"; + +export function useUpdateTimeline() { + const orgId = useWorkspaceStore((s) => s.selectedOrgId); + const queryClient = useQueryClient(); + return useCoreMutation( + ({ timelineId, body }: IUpdateTimelineVariables) => { + if (orgId == null) { + return Promise.reject(new Error("수정할 워크스페이스를 선택해주세요")); + } + return updateTimeline(orgId, timelineId, body); + }, + { + invalidateKeys: orgId != null ? [QUERY_KEYS.timeline.list(orgId)] : [], + userOnSuccess: (data, { timelineId }) => { + if (orgId != null) { + void queryClient.invalidateQueries({ + queryKey: QUERY_KEYS.timeline.detail(orgId, timelineId), + }); + } + toast.success("타임라인이 수정되었습니다", { + description: `"${data.name}" 타임라인의 변경내용을 저장했습니다`, + }); + }, + userOnError: (error) => { + const message = + (error as IApiErrorResponse).message ?? + "타임라인 수정에 실패했습니다. 다시 시도해주세요"; + toast.error(message); + }, + }, + ); +} diff --git a/src/types/timeline/api.ts b/src/types/timeline/api.ts index 743e0009..d431174b 100644 --- a/src/types/timeline/api.ts +++ b/src/types/timeline/api.ts @@ -87,3 +87,8 @@ export interface ITimelineMutationResponse { /*DELETE / summary 요청 등 빈 data*/ export type TTimelineEmptyResponse = Record; + +export interface IUpdateTimelineVariables { + timelineId: number; + body: ITimelineUpsertRequest; +} From 4555b2ab888b291fdce306f954955e8f6ec87b16 Mon Sep 17 00:00:00 2001 From: JAESEON PARK Date: Mon, 6 Jul 2026 20:32:34 +0900 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20useTimelineDetail=EC=97=90=20query?= =?UTF-8?q?=20options=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/timeline/useTimelineDetail.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/hooks/timeline/useTimelineDetail.ts b/src/hooks/timeline/useTimelineDetail.ts index 7fb1b16c..f20cfb36 100644 --- a/src/hooks/timeline/useTimelineDetail.ts +++ b/src/hooks/timeline/useTimelineDetail.ts @@ -1,15 +1,21 @@ +import type { TUseQueryCustomOptions } from "@/types/common/common"; +import type { ITimelineDetail } from "@/types/timeline/api"; + import { useCoreQuery } from "@/hooks/customQuery"; import { getTimelineDetail } from "@/api/timeline/timeline"; import { QUERY_KEYS } from "@/lib/queryKeys"; import useWorkspaceStore from "@/store/useWorkspaceStore"; -export function useTimelineDetail(timelineId: number | null) { +export function useTimelineDetail( + timelineId: number | null, + options?: TUseQueryCustomOptions, +) { const orgId = useWorkspaceStore((s) => s.selectedOrgId); return useCoreQuery( QUERY_KEYS.timeline.detail(orgId, timelineId), () => getTimelineDetail(orgId!, timelineId!), - { enabled: orgId != null && timelineId != null }, + { enabled: orgId != null && timelineId != null, ...options }, ); } From 8717b5ed4f90e3f7f8f00ad0cc2ce3cd8d2eb055 Mon Sep 17 00:00:00 2001 From: JAESEON PARK Date: Mon, 6 Jul 2026 20:47:44 +0900 Subject: [PATCH 4/9] =?UTF-8?q?feat:=20=ED=83=80=EC=9E=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=20=EC=83=9D=EC=84=B1=20=EB=AA=A8=EB=8B=AC=20edit=20?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../timeline/TimelineCreateModal.tsx | 46 ++++++++++++++----- 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/src/components/timeline/TimelineCreateModal.tsx b/src/components/timeline/TimelineCreateModal.tsx index 9e17707f..288d039a 100644 --- a/src/components/timeline/TimelineCreateModal.tsx +++ b/src/components/timeline/TimelineCreateModal.tsx @@ -4,7 +4,10 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { twMerge } from "tailwind-merge"; import type { TTimelineMetric } from "@/types/timeline/api"; -import { TIMELINE_FORM_DEFAULT_VALUES } from "@/types/timeline/form"; +import { + type ITimelineFormValues, + TIMELINE_FORM_DEFAULT_VALUES, +} from "@/types/timeline/form"; import { TIMELINE_COMPARISON_PERIOD_OPTIONS, TIMELINE_METRIC_OPTIONS, @@ -16,6 +19,7 @@ import { } from "@/utils/timeline/timeline"; import { useCreateTimeline } from "@/hooks/timeline/useCreateTimeline"; +import { useUpdateTimeline } from "@/hooks/timeline/useUpdateTimeline"; import Button from "../common/button/Button"; import { @@ -46,14 +50,20 @@ function openDatePickerFromField(event: MouseEvent) { interface ITimelineCreateModalProps { isOpen: boolean; onClose: () => void; + timelineId?: number | null; + initialValues?: ITimelineFormValues; } export default function TimelineCreateModal({ isOpen, onClose, + timelineId, + initialValues, }: ITimelineCreateModalProps) { - // const [isSubmitting, setIsSubmitting] = useState(false); - const { mutate, isPending } = useCreateTimeline(); + const isEditMode = timelineId != null; + const { mutate: createMutate, isPending: isCreating } = useCreateTimeline(); + const { mutate: updateMutate, isPending: isUpdating } = useUpdateTimeline(); + const isPending = isCreating || isUpdating; const { register, @@ -98,8 +108,10 @@ export default function TimelineCreateModal({ useEffect(() => { if (!isOpen) { reset(TIMELINE_FORM_DEFAULT_VALUES); + return; } - }, [isOpen, reset]); + reset(initialValues ?? TIMELINE_FORM_DEFAULT_VALUES); + }, [isOpen, initialValues, reset]); const handleClose = () => { if (isPending) return; @@ -116,12 +128,16 @@ export default function TimelineCreateModal({ }; const onSubmit: SubmitHandler = (data) => { - mutate(data, { - onSuccess: () => { - reset(TIMELINE_FORM_DEFAULT_VALUES); - onClose(); - }, - }); + const handleSuccess = () => { + reset(TIMELINE_FORM_DEFAULT_VALUES); + onClose(); + }; + + if (isEditMode && timelineId != null) { + updateMutate({ timelineId, body: data }, { onSuccess: handleSuccess }); + return; + } + createMutate(data, { onSuccess: handleSuccess }); }; return ( @@ -130,7 +146,7 @@ export default function TimelineCreateModal({ onClose={handleClose} size="lg" padding="lg" - title="타임라인 생성" + title={isEditMode ? "타임라인 수정" : "타임라인 생성"} disableOverlayClick={isPending} >
@@ -286,7 +302,13 @@ export default function TimelineCreateModal({ isLoading={isPending} disabled={isPending} > - {isPending ? "생성 중..." : "생성하기"} + {isPending + ? isEditMode + ? "저장 중..." + : "생성 중..." + : isEditMode + ? "저장하기" + : "생성하기"}
From 688cd3313959bf8b9188d4f17c18d211ed1c4953 Mon Sep 17 00:00:00 2001 From: JAESEON PARK Date: Mon, 6 Jul 2026 20:59:31 +0900 Subject: [PATCH 5/9] =?UTF-8?q?feat:=20=ED=83=80=EC=9E=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=20=EC=84=B1=EA=B3=BC=20=ED=8C=A8=EB=84=90=20AI=20?= =?UTF-8?q?=EC=9A=94=EC=95=BD=20mock=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0,=20props=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../timeline/TimelinePerformancePanel.tsx | 60 ++++++------------- 1 file changed, 18 insertions(+), 42 deletions(-) diff --git a/src/components/timeline/TimelinePerformancePanel.tsx b/src/components/timeline/TimelinePerformancePanel.tsx index 66ace77e..878a4bb1 100644 --- a/src/components/timeline/TimelinePerformancePanel.tsx +++ b/src/components/timeline/TimelinePerformancePanel.tsx @@ -1,5 +1,5 @@ import type { FC, SVGProps } from "react"; -import { useEffect, useRef, useState } from "react"; +import { useState } from "react"; import { twMerge } from "tailwind-merge"; import type { TProviderType } from "@/types/dashboard/provider"; @@ -24,8 +24,6 @@ import NaverWordmark from "@/assets/logo/social-logo/wordmark/naver-wordmark.svg 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 = @@ -65,6 +63,9 @@ interface ITimelinePerformancePanelProps { onEdit?: () => void; onDelete?: () => void; className?: string; + onRequestSummary?: () => void; + isSummaryPending?: boolean; + isSummaryLoading?: boolean; } function formatMetricValue(value: number, unit?: string) { @@ -119,50 +120,24 @@ export default function TimelinePerformancePanel({ onEdit, onDelete, className, + onRequestSummary, + isSummaryPending, + isSummaryLoading, }: 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 hasSummary = data.aiSummary.trim().length > 0; + const aiState: TAiSummaryUiState = + isSummaryPending || isSummaryLoading + ? "loading" + : hasSummary + ? "done" + : "idle"; const statusStyle = TIMELINE_PERFORMANCE_STATUS_STYLE[data.performanceStatus]; const chartPeriodLabel = CHART_PERIOD_LABELS[chartPeriodIndex] ?? CHART_PERIOD_LABELS[0]; - useEffect(() => { - if (!isOpen) { - if (summaryTimerRef.current !== null) { - window.clearTimeout(summaryTimerRef.current); - summaryTimerRef.current = null; - } - return; - } - setAiState(data.aiSummary.trim() ? "done" : "idle"); - setGeneratedSummary(""); - }, [isOpen, data.aiSummary]); - - useEffect(() => { - return () => { - if (summaryTimerRef.current !== null) { - window.clearTimeout(summaryTimerRef.current); - } - }; - }, []); - 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); - }; - const handlePrevChartPeriod = () => { setChartPeriodIndex((prev) => prev === 0 ? CHART_PERIOD_LABELS.length - 1 : prev - 1, @@ -268,7 +243,8 @@ export default function TimelinePerformancePanel({ variant="gradient" size="big" fullWidth - onClick={handleGenerateSummary} + onClick={() => onRequestSummary?.()} + disabled={isSummaryPending} className="rounded-2xl px-6 py-4 shadow-Soft" > 요약하기 생성 @@ -283,14 +259,14 @@ export default function TimelinePerformancePanel({ )} - {aiState === "done" && (data.aiSummary || generatedSummary) && ( + {aiState === "done" && hasSummary && (

- {data.aiSummary || generatedSummary} + {data.aiSummary}

)} From 9992a51bbf9cef6076ee1662392c207ae41b2fd3 Mon Sep 17 00:00:00 2001 From: JAESEON PARK Date: Mon, 6 Jul 2026 21:27:56 +0900 Subject: [PATCH 6/9] =?UTF-8?q?feat:=20=ED=83=80=EC=9E=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=20=EC=88=98=EC=A0=95/=EC=82=AD=EC=A0=9C/AI=20?= =?UTF-8?q?=EC=9A=94=EC=95=BD=20UI=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/dashboard/timeline/Timeline.tsx | 136 ++++++++++++++++++++-- 1 file changed, 127 insertions(+), 9 deletions(-) diff --git a/src/pages/dashboard/timeline/Timeline.tsx b/src/pages/dashboard/timeline/Timeline.tsx index d106caf6..cc573bb9 100644 --- a/src/pages/dashboard/timeline/Timeline.tsx +++ b/src/pages/dashboard/timeline/Timeline.tsx @@ -15,10 +15,14 @@ import { import { buildTimelineGrid } from "@/utils/timeline/buildTimelineGrid"; import { buildTimelineSummaryPanel } from "@/utils/timeline/buildTimelineSummaryPanel"; +import { useDeleteTimeline } from "@/hooks/timeline/useDeleteTimeline"; +import { useRequestTimelineSummary } from "@/hooks/timeline/useRequestTimelineSummary"; import { useTimelineDetail } from "@/hooks/timeline/useTimelineDetail"; import { useTimelineList } from "@/hooks/timeline/useTimelineList"; import Button from "@/components/common/button/Button"; +import Modal from "@/components/common/modal/Modal"; +import ModalContent from "@/components/common/modal/ModalContent"; import TimelineSkeleton from "@/components/timeline/skeleton/TimelineSkeleton"; import TimelineAxis from "@/components/timeline/TimelineAxis"; import TimelineBar from "@/components/timeline/TimelineBar"; @@ -33,6 +37,7 @@ import { import TimelineStatusLegend from "@/components/timeline/TimelineStatusLegend"; import PlusIcon from "@/assets/icon/common/plus.svg?react"; +import TrashIcon from "@/assets/icon/common/trash.svg?react"; import FilterIcon from "@/assets/icon/timeline/filter.svg?react"; import SortIcon from "@/assets/icon/timeline/sort.svg?react"; @@ -50,6 +55,15 @@ export default function Timeline() { const [panelData, setPanelData] = useState( null, ); + const [editTimelineId, setEditTimelineId] = useState(null); + const [deleteTarget, setDeleteTarget] = useState<{ + id: number; + name: string; + } | null>(null); + const [isAwaitingSummary, setIsAwaitingSummary] = useState(false); + const [summaryPollStartedAt, setSummaryPollStartedAt] = useState< + number | null + >(null); const { data: timelineList = [], @@ -58,7 +72,45 @@ export default function Timeline() { error, } = useTimelineList(); - const { data: detail } = useTimelineDetail(selectedBarId); + const { mutate: deleteTimeline, isPending: isDeleting } = useDeleteTimeline(); + const { mutate: requestSummary, isPending: isSummaryPending } = + useRequestTimelineSummary(); + const { data: editDetail } = useTimelineDetail(editTimelineId); + + const { data: detail } = useTimelineDetail(selectedBarId, { + refetchInterval: (query) => { + if (!isAwaitingSummary) return false; + + const summary = query.state.data?.summary?.trim(); + if (summary) { + setIsAwaitingSummary(false); + setSummaryPollStartedAt(null); + return false; + } + + //90초 타임아웃 + if (summaryPollStartedAt && Date.now() - summaryPollStartedAt > 90000) { + setIsAwaitingSummary(false); + setSummaryPollStartedAt(null); + toast.error( + "더 상세한 요약을 위해 시간이 걸리고 있습니다. 잠시만 기다려주세요", + ); + return false; + } + return 1500; //1.5초 마다 refetch + }, + }); + + const editInitialValues = useMemo(() => { + if (!editDetail) return undefined; + return { + name: editDetail.name, + startDate: editDetail.startDate, + endDate: editDetail.endDate, + metrics: editDetail.metrics, + comparisonPeriodType: "LAST_WEEK" as const, + }; + }, [editDetail]); const gridData = useMemo( () => buildTimelineGrid({ items: timelineList, viewUnit, periodIndex }), @@ -117,6 +169,8 @@ export default function Timeline() { setSelectedBarId(bar.id); setPanelData(null); setIsPanelOpen(true); + setIsAwaitingSummary(false); + setSummaryPollStartedAt(null); }; const handlePanelClose = () => { @@ -124,6 +178,38 @@ export default function Timeline() { setSelectedBarId(null); }; + const openEditModal = (id: number) => setEditTimelineId(id); + const closeEditModal = () => setEditTimelineId(null); + + const openDeleteModal = (target: { id: number; name: string }) => { + setDeleteTarget(target); + }; + const closedDeleteModal = () => { + if (isDeleting) return; + setDeleteTarget(null); + }; + const handleConfirmDelete = () => { + if (!deleteTarget) return; + deleteTimeline(deleteTarget.id, { + onSuccess: () => { + if (selectedBarId === deleteTarget.id) handlePanelClose(); + setDeleteTarget(null); + }, + }); + }; + + const handleRequestSummary = () => { + if (selectedBarId == null) return; + setIsAwaitingSummary(true); + setSummaryPollStartedAt(Date.now()); + requestSummary(selectedBarId, { + onError: () => { + setIsAwaitingSummary(false); + setSummaryPollStartedAt(null); + }, + }); + }; + if (isLoading) { return ; } @@ -241,11 +327,9 @@ export default function Timeline() { bar={bar} isSelected={selectedBarId === bar.id && isPanelOpen} onBarClick={handleBarClick} - onEdit={() => - toast.info("수정기능은 다음 이슈에서 연동됩니다") - } + onEdit={() => openEditModal(bar.id)} onDelete={() => - toast.info("삭제기능은 다음 이슈에서 연동됩니다") + openDeleteModal({ id: bar.id, name: bar.title }) } /> ))} @@ -255,21 +339,55 @@ export default function Timeline() { )} - + {/* 생성 */} setIsCreateOpen(false)} /> - + {/* 수정 */} + {panelData ? ( toast.info("수정기능은 다음 이슈에서 연동예정")} - onDelete={() => toast.info("삭제기능은 다음 이슈에서 연동예정")} + onEdit={() => selectedBarId != null && openEditModal(selectedBarId)} + onDelete={() => + openDeleteModal({ + id: selectedBarId!, + name: panelData.timelineName, + }) + } data={panelData} + onRequestSummary={handleRequestSummary} + isSummaryLoading={isAwaitingSummary} + isSummaryPending={isSummaryPending} /> ) : null} + + } + title="해당 타임라인을 삭제할까요?" + description={ + deleteTarget + ? `"${deleteTarget.name}" 타임라인을 삭제하면 복구할 수 없습니다` + : "" + } + buttonText="삭제하기" + onConfirm={handleConfirmDelete} + isLoading={isDeleting} + variant="danger" + /> + ); } From 0476c709920ddc39209a7762ce8daf146b941a7f Mon Sep 17 00:00:00 2001 From: JAESEON PARK Date: Mon, 6 Jul 2026 21:30:00 +0900 Subject: [PATCH 7/9] =?UTF-8?q?chore:=20=ED=83=80=EC=9E=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=20title=EA=B3=BC=20=EC=84=A4=EB=AA=85=EC=9D=84=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=EB=B2=84=EC=A0=84/=EC=83=9D=EC=84=B1?= =?UTF-8?q?=EB=B2=84=EC=A0=84=20=EB=8B=A4=EB=A5=B4=EA=B2=8C=20=EB=B3=B4?= =?UTF-8?q?=EC=9D=B4=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/timeline/TimelineCreateModal.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/timeline/TimelineCreateModal.tsx b/src/components/timeline/TimelineCreateModal.tsx index 288d039a..59713ba8 100644 --- a/src/components/timeline/TimelineCreateModal.tsx +++ b/src/components/timeline/TimelineCreateModal.tsx @@ -150,9 +150,13 @@ export default function TimelineCreateModal({ disableOverlayClick={isPending} >
-

타임라인 생성

+

+ {isEditMode ? "타임라인 수정" : "타임라인 생성"} +

- 분석할 기간과 성과 지표를 설정해 새 타임라인을 만들어보세요 + {isEditMode + ? "타임라인 정보를 수정하고 저장하세요" + : "분석할 기간과 성과 지표를 설정해 새 타임라인을 만들어보세요"}

Date: Mon, 6 Jul 2026 21:33:43 +0900 Subject: [PATCH 8/9] =?UTF-8?q?fix:=20=EC=88=98=EC=A0=95=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20=EB=A1=9C=EB=94=A9=20=EC=A0=84=20=EC=97=B4=EB=A6=B4?= =?UTF-8?q?=EB=95=8C=20=ED=8A=80=EB=8A=94=20=ED=98=84=EC=83=81=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/dashboard/timeline/Timeline.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pages/dashboard/timeline/Timeline.tsx b/src/pages/dashboard/timeline/Timeline.tsx index cc573bb9..a360e2ec 100644 --- a/src/pages/dashboard/timeline/Timeline.tsx +++ b/src/pages/dashboard/timeline/Timeline.tsx @@ -184,7 +184,7 @@ export default function Timeline() { const openDeleteModal = (target: { id: number; name: string }) => { setDeleteTarget(target); }; - const closedDeleteModal = () => { + const closeDeleteModal = () => { if (isDeleting) return; setDeleteTarget(null); }; @@ -346,7 +346,7 @@ export default function Timeline() { /> {/* 수정 */} From 5357e42d4f5342a08837428d578c1f0c0177d17c Mon Sep 17 00:00:00 2001 From: JAESEON PARK Date: Tue, 7 Jul 2026 00:00:08 +0900 Subject: [PATCH 9/9] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=EB=9E=98=EB=B9=97?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=EC=82=AC=ED=95=AD=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/dashboard/timeline/Timeline.tsx | 68 +++++++++++++++-------- 1 file changed, 46 insertions(+), 22 deletions(-) diff --git a/src/pages/dashboard/timeline/Timeline.tsx b/src/pages/dashboard/timeline/Timeline.tsx index a360e2ec..d015d9fa 100644 --- a/src/pages/dashboard/timeline/Timeline.tsx +++ b/src/pages/dashboard/timeline/Timeline.tsx @@ -41,6 +41,9 @@ import TrashIcon from "@/assets/icon/common/trash.svg?react"; import FilterIcon from "@/assets/icon/timeline/filter.svg?react"; import SortIcon from "@/assets/icon/timeline/sort.svg?react"; +const SUMMARY_POLL_INTERVAL_MS = 1500; +const SUMMARY_POLL_TIMEOUT_MS = 90000; + const TOOLBAR_ACTION_CLASS = "flex items-center gap-1.5 rounded-lg px-2 py-1.5 font-caption text-text-muted opacity-50 cursor-not-allowed"; @@ -75,30 +78,15 @@ export default function Timeline() { const { mutate: deleteTimeline, isPending: isDeleting } = useDeleteTimeline(); const { mutate: requestSummary, isPending: isSummaryPending } = useRequestTimelineSummary(); - const { data: editDetail } = useTimelineDetail(editTimelineId); + const { + data: editDetail, + isLoading: isEditDetailLoading, + isError: isEditDetailError, + error: editDetailError, + } = useTimelineDetail(editTimelineId); const { data: detail } = useTimelineDetail(selectedBarId, { - refetchInterval: (query) => { - if (!isAwaitingSummary) return false; - - const summary = query.state.data?.summary?.trim(); - if (summary) { - setIsAwaitingSummary(false); - setSummaryPollStartedAt(null); - return false; - } - - //90초 타임아웃 - if (summaryPollStartedAt && Date.now() - summaryPollStartedAt > 90000) { - setIsAwaitingSummary(false); - setSummaryPollStartedAt(null); - toast.error( - "더 상세한 요약을 위해 시간이 걸리고 있습니다. 잠시만 기다려주세요", - ); - return false; - } - return 1500; //1.5초 마다 refetch - }, + refetchInterval: isAwaitingSummary ? SUMMARY_POLL_INTERVAL_MS : false, }); const editInitialValues = useMemo(() => { @@ -128,6 +116,41 @@ export default function Timeline() { const totalWidth = columns.length * TIMELINE_COL_WIDTH; + useEffect(() => { + if (!isAwaitingSummary) return; + if (!detail?.summary?.trim()) return; + + setIsAwaitingSummary(false); + setSummaryPollStartedAt(null); + }, [isAwaitingSummary, detail?.summary]); + + useEffect(() => { + if (!isAwaitingSummary || summaryPollStartedAt == null) return; + + const timer = window.setTimeout(() => { + setIsAwaitingSummary(false); + setSummaryPollStartedAt(null); + toast.error( + "더 상세한 요약을 위해 시간이 걸리고 있습니다. 잠시 후 다시 시도해주세요", + ); + }, SUMMARY_POLL_TIMEOUT_MS); + return () => window.clearTimeout(timer); + }, [isAwaitingSummary, summaryPollStartedAt]); + + useEffect(() => { + if (editTimelineId == null || !isEditDetailError) return; + toast.error( + editDetailError?.message ?? + "타임라인 정보를 불러오지 못했습니다. 다시 시도해주세요", + ); + setEditTimelineId(null); + }, [editTimelineId, isEditDetailError, editDetailError]); + + useEffect(() => { + if (editTimelineId == null || !isEditDetailLoading) return; + toast.info("타임라인 정보를 불러오는 중..."); + }, [editTimelineId, isEditDetailLoading]); + useEffect(() => { if (!detail) return; setPanelData(buildTimelineSummaryPanel(detail)); @@ -357,6 +380,7 @@ export default function Timeline() { onClose={handlePanelClose} onEdit={() => selectedBarId != null && openEditModal(selectedBarId)} onDelete={() => + selectedBarId != null && openDeleteModal({ id: selectedBarId!, name: panelData.timelineName,