diff --git a/src/components/timeline/TimelineCanvas.tsx b/src/components/timeline/TimelineCanvas.tsx new file mode 100644 index 00000000..7bfdcfce --- /dev/null +++ b/src/components/timeline/TimelineCanvas.tsx @@ -0,0 +1,86 @@ +import type { RefObject } from "react"; + +import type { + ITimelineCampaignBar, + ITimelineGridColumn, + TTimelineViewUnit, +} from "@/types/timeline/ui"; + +import TimelineAxis from "./TimelineAxis"; +import TimelineBar from "./TimelineBar"; +import TimelineGrid from "./TimelineGrid"; + +interface ITimelineCanvasProps { + scrollRef: RefObject; + totalWidth: number; + columns: ITimelineGridColumn[]; + colWidth: number; + bars: ITimelineCampaignBar[]; + maxRow: number; + viewUnit: TTimelineViewUnit; + hasNoVisibleBars: boolean; + selectedBarId: number | null; + isPanelOpen: boolean; + onBarClick: (bar: ITimelineCampaignBar) => void; + onEdit: (id: number) => void; + onDelete: (target: { id: number; name: string }) => void; +} + +export default function TimelineCanvas({ + scrollRef, + totalWidth, + columns, + colWidth, + bars, + maxRow, + viewUnit, + hasNoVisibleBars, + selectedBarId, + isPanelOpen, + onBarClick, + onEdit, + onDelete, +}: ITimelineCanvasProps) { + return ( +
+
+ + {hasNoVisibleBars ? ( +
+

+ 이 기간에 표시할 타임라인이 없어요 +

+

+ 다른 기간으로 이동하거나 보기 단위를 변경해 보세요 +

+
+ ) : ( + + {bars.map((bar) => ( + onEdit(bar.id)} + onDelete={() => onDelete({ id: bar.id, name: bar.title })} + /> + ))} + + )} +
+
+ ); +} diff --git a/src/components/timeline/TimelineDeleteModal.tsx b/src/components/timeline/TimelineDeleteModal.tsx new file mode 100644 index 00000000..009e7bd3 --- /dev/null +++ b/src/components/timeline/TimelineDeleteModal.tsx @@ -0,0 +1,41 @@ +import Modal from "../common/modal/Modal"; +import ModalContent from "../common/modal/ModalContent"; + +import TrashIcon from "@/assets/icon/common/trash.svg?react"; + +interface ITimelineDeleteModalProps { + target: { id: number; name: string } | null; + isDeleting: boolean; + onClose: () => void; + onConfirm: () => void; +} + +export default function TimelineDeleteModal({ + target, + isDeleting, + onClose, + onConfirm, +}: ITimelineDeleteModalProps) { + return ( + + } + title="해당 타임라인을 삭제할까요?" + description={ + target + ? `"${target.name}" 타임라인을 삭제하면 복구할 수 없습니다` + : "" + } + buttonText="삭제하기" + onConfirm={onConfirm} + isLoading={isDeleting} + variant="danger" + /> + + ); +} diff --git a/src/components/timeline/TimelineToolbar.tsx b/src/components/timeline/TimelineToolbar.tsx new file mode 100644 index 00000000..cb2e5b1e --- /dev/null +++ b/src/components/timeline/TimelineToolbar.tsx @@ -0,0 +1,94 @@ +import { twMerge } from "tailwind-merge"; + +import type { TTimelineSort } from "@/types/timeline/api"; +import type { TTimelineViewUnit } from "@/types/timeline/ui"; +import type { TTimelineStatusFilter } from "@/constants/timeline/filterSort"; + +import TimelineFilterSortMenus from "./TimelineFilterSortMenus"; +import { + TimelinePeriodNav, + TimelineViewUnitSegment, +} from "./TimelinePeriodSelector"; +import TimelineStatusLegend from "./TimelineStatusLegend"; +import Button from "../common/button/Button"; + +import PlusIcon from "@/assets/icon/common/plus.svg?react"; + +interface ITimelineToolbarProps { + viewUnit: TTimelineViewUnit; + periodLabel: string; + onViewUnitChange: (unit: TTimelineViewUnit) => void; + onPrevPeriod: () => void; + onNextPeriod: () => void; + onGoToToday: () => void; + statusFilter: TTimelineStatusFilter; + sort: TTimelineSort; + onStatusFilterChange: (value: TTimelineStatusFilter) => void; + onSortChange: (value: TTimelineSort) => void; + onCreate: () => void; +} + +export default function TimelineToolbar({ + viewUnit, + periodLabel, + onViewUnitChange, + onPrevPeriod, + onNextPeriod, + onGoToToday, + statusFilter, + sort, + onStatusFilterChange, + onSortChange, + onCreate, +}: ITimelineToolbarProps) { + return ( +
+
+ + +
+ +
+ + + +
+
+ ); +} diff --git a/src/hooks/timeline/useTimelineEditModal.ts b/src/hooks/timeline/useTimelineEditModal.ts new file mode 100644 index 00000000..0e7d7d60 --- /dev/null +++ b/src/hooks/timeline/useTimelineEditModal.ts @@ -0,0 +1,51 @@ +import { useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; + +import { useTimelineDetail } from "./useTimelineDetail"; + +export function useTimelineEditModal() { + const [editTimelineId, setEditTimelineId] = useState(null); + + const { + data: editDetail, + isLoading: isEditDetailLoading, + isError: isEditDetailError, + error: editDetailError, + } = useTimelineDetail(editTimelineId); + + const editInitialValues = useMemo(() => { + if (!editDetail) return undefined; + return { + name: editDetail.name, + startDate: editDetail.startDate, + endDate: editDetail.endDate, + metrics: editDetail.metrics, + comparisonPeriodType: editDetail.comparisonPeriodType, + }; + }, [editDetail]); + + 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]); + + const openEditModal = (id: number) => setEditTimelineId(id); + const closeEditModal = () => setEditTimelineId(null); + + return { + editTimelineId, + editInitialValues, + isEditOpen: editTimelineId != null && editInitialValues != null, + openEditModal, + closeEditModal, + }; +} diff --git a/src/hooks/timeline/useTimelinePanel.ts b/src/hooks/timeline/useTimelinePanel.ts new file mode 100644 index 00000000..1536d5b6 --- /dev/null +++ b/src/hooks/timeline/useTimelinePanel.ts @@ -0,0 +1,54 @@ +import { useEffect, useState } from "react"; + +import type { ITimelineSummaryPanelData } from "@/types/timeline/summary"; +import type { ITimelineCampaignBar } from "@/types/timeline/ui"; + +import { buildTimelineSummaryPanel } from "@/utils/timeline/buildTimelineSummaryPanel"; + +import { useTimelineDetail } from "./useTimelineDetail"; + +interface IUseTimelinePanelParams { + bars: ITimelineCampaignBar[]; +} + +export default function useTimelinePanel({ bars }: IUseTimelinePanelParams) { + const [isPanelOpen, setIsPanelOpen] = useState(false); + const [selectedBarId, setSelectedBarId] = useState(null); + const [panelData, setPanelData] = useState( + null, + ); + + const { data: detail } = useTimelineDetail(selectedBarId); + + useEffect(() => { + if (!detail) return; + setPanelData(buildTimelineSummaryPanel(detail)); + }, [detail]); + + useEffect(() => { + if (selectedBarId === null) return; + if (!bars.some((bar) => bar.id === selectedBarId)) { + setIsPanelOpen(false); + setSelectedBarId(null); + } + }, [bars, selectedBarId]); + + const handleBarClick = (bar: ITimelineCampaignBar) => { + setSelectedBarId(bar.id); + setPanelData(null); + setIsPanelOpen(true); + }; + + const handlePanelClose = () => { + setIsPanelOpen(false); + setSelectedBarId(null); + }; + + return { + isPanelOpen, + selectedBarId, + panelData, + handleBarClick, + handlePanelClose, + }; +} diff --git a/src/hooks/timeline/useTimelinePeriod.ts b/src/hooks/timeline/useTimelinePeriod.ts new file mode 100644 index 00000000..38f9bbc9 --- /dev/null +++ b/src/hooks/timeline/useTimelinePeriod.ts @@ -0,0 +1,49 @@ +import { type RefObject, useEffect, useState } from "react"; + +import type { TTimelineViewUnit } from "@/types/timeline/ui"; + +interface IUserTimelinePeriodParams { + scrollRef: RefObject; + hasNoTimelines: boolean; +} + +export function useTimelinePeriod({ + scrollRef, + hasNoTimelines, +}: IUserTimelinePeriodParams) { + const [viewUnit, setViewUnit] = useState("WEEK"); + const [periodIndex, setPeriodIndex] = useState(0); + + useEffect(() => { + if (hasNoTimelines) return; + const el = scrollRef.current; + if (!el) return; + el.scrollLeft = viewUnit === "MONTH" ? 0 : el.scrollWidth - el.clientWidth; + }, [scrollRef, hasNoTimelines, viewUnit, periodIndex]); + + const handleViewUnitChange = (unit: TTimelineViewUnit) => { + setViewUnit(unit); + setPeriodIndex(0); + }; + + const handlePrevPeriod = () => { + setPeriodIndex((prev) => prev + 1); //더 과거 + }; + + const handleNextPeriod = () => { + setPeriodIndex((prev) => Math.max(0, prev - 1)); + }; + + const handleGoToToday = () => { + setPeriodIndex(0); + }; + + return { + viewUnit, + periodIndex, + handleViewUnitChange, + handlePrevPeriod, + handleNextPeriod, + handleGoToToday, + }; +} diff --git a/src/hooks/timeline/useTimelineSummaryPolling.ts b/src/hooks/timeline/useTimelineSummaryPolling.ts new file mode 100644 index 00000000..28f8687e --- /dev/null +++ b/src/hooks/timeline/useTimelineSummaryPolling.ts @@ -0,0 +1,69 @@ +import { useEffect, useState } from "react"; +import { toast } from "sonner"; + +import { useRequestTimelineSummary } from "./useRequestTimelineSummary"; +import { useTimelineDetail } from "./useTimelineDetail"; + +const SUMMARY_POLL_INTERVAL_MS = 1500; +const SUMMARY_POLL_TIMEOUT_MS = 90000; + +export function useTimelineSummaryPolling(selectedBarId: number | null) { + const [isAwaitingSummary, setIsAwaitingSummary] = useState(false); + const [summaryPollStartedAt, setSummaryPollStartedAt] = useState< + number | null + >(null); + + const { mutate: requestSummary, isPending: isSummaryPending } = + useRequestTimelineSummary(); + + const { data: detail } = useTimelineDetail(selectedBarId, { + refetchInterval: isAwaitingSummary ? SUMMARY_POLL_INTERVAL_MS : false, + }); + + useEffect(() => { + setIsAwaitingSummary(false); + setSummaryPollStartedAt(null); + }, [selectedBarId]); + + 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]); + + const resetSummaryPolling = () => { + setIsAwaitingSummary(false); + setSummaryPollStartedAt(null); + }; + + const handleRequestSummary = () => { + if (selectedBarId == null) return; + setIsAwaitingSummary(true); + setSummaryPollStartedAt(Date.now()); + requestSummary(selectedBarId, { + onError: () => { + resetSummaryPolling(); + }, + }); + }; + + return { + isAwaitingSummary, + isSummaryPending, + resetSummaryPolling, + handleRequestSummary, + }; +} diff --git a/src/pages/dashboard/timeline/Timeline.tsx b/src/pages/dashboard/timeline/Timeline.tsx index eb54d28a..7105d3fe 100644 --- a/src/pages/dashboard/timeline/Timeline.tsx +++ b/src/pages/dashboard/timeline/Timeline.tsx @@ -1,72 +1,41 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { toast } from "sonner"; -import { twMerge } from "tailwind-merge"; +import { useMemo, useRef, useState } from "react"; -import type { ITimelineSummaryPanelData } from "@/types/timeline/summary"; -import type { - ITimelineCampaignBar, - TTimelineViewUnit, -} from "@/types/timeline/ui"; +import type { ITimelineCampaignBar } from "@/types/timeline/ui"; import { TIMELINE_COL_WIDTH, TIMELINE_PAGE_HEIGHT, } from "@/constants/timeline/layout"; import { buildTimelineGrid } from "@/utils/timeline/buildTimelineGrid"; -import { buildTimelineSummaryPanel } from "@/utils/timeline/buildTimelineSummaryPanel"; import { useContainerWidth } from "@/hooks/timeline/useContainerWidth"; import { useDeleteTimeline } from "@/hooks/timeline/useDeleteTimeline"; -import { useRequestTimelineSummary } from "@/hooks/timeline/useRequestTimelineSummary"; -import { useTimelineDetail } from "@/hooks/timeline/useTimelineDetail"; +import { useTimelineEditModal } from "@/hooks/timeline/useTimelineEditModal"; import { useTimelineList } from "@/hooks/timeline/useTimelineList"; +import useTimelinePanel from "@/hooks/timeline/useTimelinePanel"; +import { useTimelinePeriod } from "@/hooks/timeline/useTimelinePeriod"; +import { useTimelineSummaryPolling } from "@/hooks/timeline/useTimelineSummaryPolling"; -import Button from "@/components/common/button/Button"; import AreaErrorFallback from "@/components/common/error/AreaErrorFallback"; import { ErrorBoundary } from "@/components/common/error/ErrorBoundary"; -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"; +import TimelineCanvas from "@/components/timeline/TimelineCanvas"; import TimelineCreateModal from "@/components/timeline/TimelineCreateModal"; +import TimelineDeleteModal from "@/components/timeline/TimelineDeleteModal"; import TimelineEmptyState from "@/components/timeline/TimelineEmptyState"; -import TimelineFilterSortMenus from "@/components/timeline/TimelineFilterSortMenus"; -import TimelineGrid from "@/components/timeline/TimelineGrid"; import TimelinePerformancePanel from "@/components/timeline/TimelinePerformancePanel"; -import { - TimelinePeriodNav, - TimelineViewUnitSegment, -} from "@/components/timeline/TimelinePeriodSelector"; -import TimelineStatusLegend from "@/components/timeline/TimelineStatusLegend"; +import TimelineToolbar from "@/components/timeline/TimelineToolbar"; -import PlusIcon from "@/assets/icon/common/plus.svg?react"; -import TrashIcon from "@/assets/icon/common/trash.svg?react"; import useTimelineStore from "@/store/useTimelineStore"; -const SUMMARY_POLL_INTERVAL_MS = 1500; -const SUMMARY_POLL_TIMEOUT_MS = 90000; - export default function Timeline() { const scrollRef = useRef(null); - const [viewUnit, setViewUnit] = useState("WEEK"); - const [periodIndex, setPeriodIndex] = useState(0); const [isCreateOpen, setIsCreateOpen] = useState(false); - const [isPanelOpen, setIsPanelOpen] = useState(false); - const [selectedBarId, setSelectedBarId] = useState(null); - 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 statusFilter = useTimelineStore((s) => s.statusFilter); const sort = useTimelineStore((s) => s.sort); @@ -74,29 +43,6 @@ export default function Timeline() { const setSort = useTimelineStore((s) => s.setSort); const { mutate: deleteTimeline, isPending: isDeleting } = useDeleteTimeline(); - const { mutate: requestSummary, isPending: isSummaryPending } = - useRequestTimelineSummary(); - const { - data: editDetail, - isLoading: isEditDetailLoading, - isError: isEditDetailError, - error: editDetailError, - } = useTimelineDetail(editTimelineId); - - const { data: detail } = useTimelineDetail(selectedBarId, { - refetchInterval: isAwaitingSummary ? SUMMARY_POLL_INTERVAL_MS : false, - }); - - const editInitialValues = useMemo(() => { - if (!editDetail) return undefined; - return { - name: editDetail.name, - startDate: editDetail.startDate, - endDate: editDetail.endDate, - metrics: editDetail.metrics, - comparisonPeriodType: editDetail.comparisonPeriodType, - }; - }, [editDetail]); const listParams = useMemo( () => ({ @@ -113,14 +59,29 @@ export default function Timeline() { error, } = useTimelineList(listParams); + const hasNoTimelines = timelineList.length === 0; + + const { + viewUnit, + periodIndex, + handleViewUnitChange, + handlePrevPeriod, + handleNextPeriod, + handleGoToToday, + } = useTimelinePeriod({ scrollRef, hasNoTimelines }); + const gridData = useMemo( - () => buildTimelineGrid({ items: timelineList, viewUnit, periodIndex }), + () => + buildTimelineGrid({ + items: timelineList, + viewUnit: viewUnit, + periodIndex: periodIndex, + }), [timelineList, viewUnit, periodIndex], ); - const { columns, bars } = gridData; - const hasNoTimelines = timelineList.length === 0; + + const { columns, bars, periodLabel } = gridData; const hasNoVisibleBars = !hasNoTimelines && bars.length === 0; - const periodLabel = gridData.periodLabel; const maxRow = useMemo( () => (bars.length > 0 ? Math.max(...bars.map((bar) => bar.row)) : 0), @@ -135,93 +96,33 @@ export default function Timeline() { : TIMELINE_COL_WIDTH; const totalWidth = columnCount * colWidth; - 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)); - }, [detail]); - - useEffect(() => { - if (hasNoTimelines) return; - const el = scrollRef.current; - if (!el) return; - el.scrollLeft = viewUnit === "MONTH" ? 0 : el.scrollWidth - el.clientWidth; - }, [columns, hasNoTimelines, viewUnit]); - - useEffect(() => { - if (selectedBarId === null) return; - if (!bars.some((bar) => bar.id === selectedBarId)) { - setIsPanelOpen(false); - setSelectedBarId(null); - } - }, [bars, selectedBarId]); - - const handleViewUnitChange = (unit: TTimelineViewUnit) => { - setViewUnit(unit); - setPeriodIndex(0); - }; - - const handlePrevPeriod = () => { - setPeriodIndex((prev) => prev + 1); //더 과거 - }; - - const handleNextPeriod = () => { - setPeriodIndex((prev) => Math.max(0, prev - 1)); - }; + const { + isPanelOpen, + selectedBarId, + panelData, + handleBarClick: selectBar, + handlePanelClose, + } = useTimelinePanel({ bars }); - const handleGoToToday = () => { - setPeriodIndex(0); - }; + const { + isAwaitingSummary, + isSummaryPending, + handleRequestSummary, + resetSummaryPolling, + } = useTimelineSummaryPolling(selectedBarId); const handleBarClick = (bar: ITimelineCampaignBar) => { - setSelectedBarId(bar.id); - setPanelData(null); - setIsPanelOpen(true); - setIsAwaitingSummary(false); - setSummaryPollStartedAt(null); - }; - - const handlePanelClose = () => { - setIsPanelOpen(false); - setSelectedBarId(null); + resetSummaryPolling(); + selectBar(bar); }; - const openEditModal = (id: number) => setEditTimelineId(id); - const closeEditModal = () => setEditTimelineId(null); + const { + editTimelineId, + editInitialValues, + isEditOpen, + openEditModal, + closeEditModal, + } = useTimelineEditModal(); const openDeleteModal = (target: { id: number; name: string }) => { setDeleteTarget(target); @@ -250,18 +151,6 @@ export default function Timeline() { }); }; - const handleRequestSummary = () => { - if (selectedBarId == null) return; - setIsAwaitingSummary(true); - setSummaryPollStartedAt(Date.now()); - requestSummary(selectedBarId, { - onError: () => { - setIsAwaitingSummary(false); - setSummaryPollStartedAt(null); - }, - }); - }; - if (isLoading) { return ; } @@ -296,107 +185,40 @@ export default function Timeline() { resetKeys={[timelineList, viewUnit, periodIndex]} >
-
-
- - -
- -
- - - -
-
+ setIsCreateOpen(true)} + /> {hasNoTimelines ? ( setIsCreateOpen(true)} /> ) : ( -
-
- - {hasNoVisibleBars ? ( -
-

- 이 기간에 표시할 타임라인이 없어요 -

-

- 다른 기간으로 이동하거나 보기 단위를 변경해 보세요 -

-
- ) : ( - - {bars.map((bar) => ( - handleEditTimeline(bar.id)} - onDelete={() => - handleDeleteTimeline({ id: bar.id, name: bar.title }) - } - /> - ))} - - )} -
-
+ )}
- {/* 생성 */} {/* 수정 */} ) : null} - - } - title="해당 타임라인을 삭제할까요?" - description={ - deleteTarget - ? `"${deleteTarget.name}" 타임라인을 삭제하면 복구할 수 없습니다` - : "" - } - buttonText="삭제하기" - onConfirm={handleConfirmDelete} - isLoading={isDeleting} - variant="danger" - /> - + onConfirm={handleConfirmDelete} + /> ); }