toggleMetric(option.value)}
className={twMerge(
@@ -224,7 +213,7 @@ export default function TimelineCreateModal({
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",
- isSubmitting && "cursor-not-allowed opacity-60",
+ isPending && "cursor-not-allowed opacity-60",
)}
>
{option.label}
@@ -260,7 +249,7 @@ export default function TimelineCreateModal({
"flex h-14 w-full cursor-pointer items-center justify-between rounded-2xl bg-surface-100 px-5 text-left font-body1 ring-1 ring-surface-400 transition-colors duration-200 ease-out outline-none",
"hover:bg-surface-200 hover:ring-surface-400",
"focus-visible:ring-2 focus-visible:ring-surface-400",
- isSubmitting && "cursor-not-allowed opacity-60",
+ isPending && "cursor-not-allowed opacity-60",
errors.comparisonPeriodType
? "ring-2 ring-info-red bg-info-red/5"
: "",
@@ -294,10 +283,10 @@ export default function TimelineCreateModal({
size="big"
variant="primary"
fullWidth
- isLoading={isSubmitting}
- disabled={isSubmitting}
+ isLoading={isPending}
+ disabled={isPending}
>
- {isSubmitting ? "생성 중..." : "생성하기"}
+ {isPending ? "생성 중..." : "생성하기"}
diff --git a/src/components/timeline/skeleton/TimelineSkeleton.tsx b/src/components/timeline/skeleton/TimelineSkeleton.tsx
new file mode 100644
index 00000000..6d38d5ca
--- /dev/null
+++ b/src/components/timeline/skeleton/TimelineSkeleton.tsx
@@ -0,0 +1,24 @@
+import { TIMELINE_PAGE_HEIGHT } from "@/constants/timeline/layout";
+
+import { Skeleton } from "@/components/common/skeleton/Skeleton";
+
+export default function TimelineSkeleton() {
+ return (
+
+ );
+}
diff --git a/src/constants/timeline/formOptions.ts b/src/constants/timeline/formOptions.ts
index 592e6ccd..18cbcef2 100644
--- a/src/constants/timeline/formOptions.ts
+++ b/src/constants/timeline/formOptions.ts
@@ -21,7 +21,7 @@ export const TIMELINE_COMPARISON_PERIOD_OPTIONS: ReadonlyArray<{
}> = [
{ value: "LAST_WEEK", label: "지난주 대비" },
{ value: "LAST_MONTH", label: "지난달 대비" },
- { value: "PREVIOUS_PERIOD", label: "이전 동일 기간 대비" },
+ { value: "LAST_YEAR", label: "작년 동기간 대비" },
] as const;
/*zod enum 용 - 타입과 동기화*/
diff --git a/src/hooks/timeline/useCreateTimeline.ts b/src/hooks/timeline/useCreateTimeline.ts
new file mode 100644
index 00000000..14ae8504
--- /dev/null
+++ b/src/hooks/timeline/useCreateTimeline.ts
@@ -0,0 +1,37 @@
+import { toast } from "sonner";
+
+import type { IApiErrorResponse } from "@/types/common/common";
+import type { ITimelineUpsertRequest } from "@/types/timeline/api";
+
+import { useCoreMutation } from "@/hooks/customQuery";
+
+import { createTimeline } from "@/api/timeline/timeline";
+import { QUERY_KEYS } from "@/lib/queryKeys";
+import useWorkspaceStore from "@/store/useWorkspaceStore";
+
+export function useCreateTimeline() {
+ const orgId = useWorkspaceStore((s) => s.selectedOrgId);
+
+ return useCoreMutation(
+ (body: ITimelineUpsertRequest) => {
+ if (orgId == null) {
+ return Promise.reject(new Error("워크스페이스를 선택해주세요"));
+ }
+ return createTimeline(orgId, body);
+ },
+ {
+ invalidateKeys: orgId != null ? [QUERY_KEYS.timeline.list(orgId)] : [],
+ userOnSuccess: (data) => {
+ toast.success("타임라인이 생성되었습니다", {
+ description: `"${data.name}" 타임라인을 추가했습니다`,
+ });
+ },
+ userOnError: (error) => {
+ const message =
+ (error as IApiErrorResponse).message ??
+ "타임라인 생성에 실패했습니다. 다시 시도해주세요";
+ toast.error(message);
+ },
+ },
+ );
+}
diff --git a/src/hooks/timeline/useTimelineDetail.ts b/src/hooks/timeline/useTimelineDetail.ts
new file mode 100644
index 00000000..7fb1b16c
--- /dev/null
+++ b/src/hooks/timeline/useTimelineDetail.ts
@@ -0,0 +1,15 @@
+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) {
+ const orgId = useWorkspaceStore((s) => s.selectedOrgId);
+
+ return useCoreQuery(
+ QUERY_KEYS.timeline.detail(orgId, timelineId),
+ () => getTimelineDetail(orgId!, timelineId!),
+ { enabled: orgId != null && timelineId != null },
+ );
+}
diff --git a/src/hooks/timeline/useTimelineList.ts b/src/hooks/timeline/useTimelineList.ts
new file mode 100644
index 00000000..dcdd50f5
--- /dev/null
+++ b/src/hooks/timeline/useTimelineList.ts
@@ -0,0 +1,16 @@
+import { useCoreQuery } from "@/hooks/customQuery";
+
+import { getTimelineList } from "@/api/timeline/timeline";
+import { QUERY_KEYS } from "@/lib/queryKeys";
+import useWorkspaceStore from "@/store/useWorkspaceStore";
+
+export function useTimelineList() {
+ const orgId = useWorkspaceStore((s) => s.selectedOrgId);
+ return useCoreQuery(
+ QUERY_KEYS.timeline.list(orgId),
+ () => getTimelineList(orgId!),
+ {
+ enabled: orgId != null,
+ },
+ );
+}
diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts
index 8b60c43d..4cc8475b 100644
--- a/src/lib/queryKeys.ts
+++ b/src/lib/queryKeys.ts
@@ -72,4 +72,10 @@ export const QUERY_KEYS = {
report: (provider: string, orgId: number | null) =>
["ai", "report", provider, orgId] as const,
},
+
+ timeline: {
+ list: (orgId: number | null) => ["timeline", "list", orgId] as const,
+ detail: (orgId: number | null, timelineId: number | null) =>
+ ["timeline", "detail", orgId, timelineId] as const,
+ },
} as const;
diff --git a/src/pages/dashboard/timeline/Timeline.tsx b/src/pages/dashboard/timeline/Timeline.tsx
index ca4dd4c1..d106caf6 100644
--- a/src/pages/dashboard/timeline/Timeline.tsx
+++ b/src/pages/dashboard/timeline/Timeline.tsx
@@ -3,11 +3,6 @@ import { toast } from "sonner";
import { twMerge } from "tailwind-merge";
import type { ITimelineSummaryPanelData } from "@/types/timeline/summary";
-import {
- buildTimelineSummaryPanelDataForBar,
- TIMELINE_GRID_MOCK_BY_VIEW_UNIT,
- TIMELINE_GRID_MOCK_WEEK,
-} from "@/types/timeline/timeline.mock";
import type {
ITimelineCampaignBar,
TTimelineViewUnit,
@@ -17,7 +12,14 @@ import {
TIMELINE_PAGE_HEIGHT,
} from "@/constants/timeline/layout";
+import { buildTimelineGrid } from "@/utils/timeline/buildTimelineGrid";
+import { buildTimelineSummaryPanel } from "@/utils/timeline/buildTimelineSummaryPanel";
+
+import { useTimelineDetail } from "@/hooks/timeline/useTimelineDetail";
+import { useTimelineList } from "@/hooks/timeline/useTimelineList";
+
import Button from "@/components/common/button/Button";
+import TimelineSkeleton from "@/components/timeline/skeleton/TimelineSkeleton";
import TimelineAxis from "@/components/timeline/TimelineAxis";
import TimelineBar from "@/components/timeline/TimelineBar";
import TimelineCreateModal from "@/components/timeline/TimelineCreateModal";
@@ -34,21 +36,13 @@ import PlusIcon from "@/assets/icon/common/plus.svg?react";
import FilterIcon from "@/assets/icon/timeline/filter.svg?react";
import SortIcon from "@/assets/icon/timeline/sort.svg?react";
-const MOCK_PERIOD_LABELS: Record
= {
- DAY: ["오늘", "6월 23일", "6월 24일"],
- WEEK: ["오늘", TIMELINE_GRID_MOCK_WEEK.periodLabel, "6월 28일 - 7월 4일"],
- MONTH: ["오늘", "2026년 6월", "2026년 7월"],
-};
-
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";
export default function Timeline() {
const scrollRef = useRef(null);
- const [viewUnit, setViewUnit] = useState(
- TIMELINE_GRID_MOCK_WEEK.viewUnit,
- );
+ const [viewUnit, setViewUnit] = useState("WEEK");
const [periodIndex, setPeriodIndex] = useState(0);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [isPanelOpen, setIsPanelOpen] = useState(false);
@@ -57,9 +51,23 @@ export default function Timeline() {
null,
);
- const gridData = TIMELINE_GRID_MOCK_BY_VIEW_UNIT[viewUnit];
+ const {
+ data: timelineList = [],
+ isLoading,
+ isError,
+ error,
+ } = useTimelineList();
+
+ const { data: detail } = useTimelineDetail(selectedBarId);
+
+ const gridData = useMemo(
+ () => buildTimelineGrid({ items: timelineList, viewUnit, periodIndex }),
+ [timelineList, viewUnit, periodIndex],
+ );
const { columns, bars } = gridData;
- const isEmpty = bars.length === 0;
+ const hasNoTimelines = timelineList.length === 0;
+ const hasNoVisibleBars = !hasNoTimelines && bars.length === 0;
+ const periodLabel = gridData.periodLabel;
const maxRow = useMemo(
() => (bars.length > 0 ? Math.max(...bars.map((bar) => bar.row)) : 0),
@@ -68,31 +76,17 @@ export default function Timeline() {
const totalWidth = columns.length * TIMELINE_COL_WIDTH;
- const periodLabels = useMemo(
- () =>
- MOCK_PERIOD_LABELS[viewUnit].map((label, index) =>
- index === 1 ? gridData.periodLabel : label,
- ),
- [gridData.periodLabel, viewUnit],
- );
- const periodLabel = periodLabels[periodIndex] ?? periodLabels[0];
-
- const selectedBar = useMemo(
- () => bars.find((bar) => bar.id === selectedBarId) ?? null,
- [bars, selectedBarId],
- );
-
useEffect(() => {
- if (!selectedBar) return;
- setPanelData(buildTimelineSummaryPanelDataForBar(selectedBar));
- }, [selectedBar]);
+ if (!detail) return;
+ setPanelData(buildTimelineSummaryPanel(detail));
+ }, [detail]);
useEffect(() => {
- if (isEmpty) return;
+ if (hasNoTimelines) return;
const el = scrollRef.current;
if (!el) return;
el.scrollLeft = el.scrollWidth - el.clientWidth;
- }, [columns, isEmpty, viewUnit]);
+ }, [columns, hasNoTimelines, viewUnit]);
useEffect(() => {
if (selectedBarId === null) return;
@@ -108,11 +102,11 @@ export default function Timeline() {
};
const handlePrevPeriod = () => {
- setPeriodIndex((prev) => (prev === 0 ? periodLabels.length - 1 : prev - 1));
+ setPeriodIndex((prev) => prev + 1); //더 과거
};
const handleNextPeriod = () => {
- setPeriodIndex((prev) => (prev === periodLabels.length - 1 ? 0 : prev + 1));
+ setPeriodIndex((prev) => Math.max(0, prev - 1));
};
const handleGoToToday = () => {
@@ -121,7 +115,7 @@ export default function Timeline() {
const handleBarClick = (bar: ITimelineCampaignBar) => {
setSelectedBarId(bar.id);
- setPanelData(buildTimelineSummaryPanelDataForBar(bar));
+ setPanelData(null);
setIsPanelOpen(true);
};
@@ -130,6 +124,30 @@ export default function Timeline() {
setSelectedBarId(null);
};
+ if (isLoading) {
+ return ;
+ }
+
+ if (isError) {
+ return (
+
+
+
+ {error?.message ??
+ "타임라인을 불러오지 못했습니다. 잠시 후에 다시 시도해주세요"}
+
+
+
+ );
+ }
+
return (