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
86 changes: 86 additions & 0 deletions src/components/timeline/TimelineCanvas.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement | null>;
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 (
<div
ref={scrollRef}
className="flex min-h-0 w-full flex-1 flex-col overflow-auto"
>
<div
style={{ width: totalWidth, minHeight: "100%" }}
className="flex min-h-full flex-1 flex-col"
>
<TimelineAxis
columns={columns}
colWidth={colWidth}
className="sticky top-0"
/>
{hasNoVisibleBars ? (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 px-6 py-16 text-center">
<p className="font-heading4 text-text-title">
이 기간에 표시할 타임라인이 없어요
</p>
<p className="max-w-sm font-body2 text-text-muted">
다른 기간으로 이동하거나 보기 단위를 변경해 보세요
</p>
</div>
) : (
<TimelineGrid columns={columns} rowCount={maxRow} colWidth={colWidth}>
{bars.map((bar) => (
<TimelineBar
key={`${viewUnit}-${bar.id}`}
bar={bar}
colWidth={colWidth}
menuPlacement={bar.row === maxRow ? "top" : "auto"}
isSelected={selectedBarId === bar.id && isPanelOpen}
onBarClick={onBarClick}
onEdit={() => onEdit(bar.id)}
onDelete={() => onDelete({ id: bar.id, name: bar.title })}
/>
))}
</TimelineGrid>
)}
</div>
</div>
);
}
41 changes: 41 additions & 0 deletions src/components/timeline/TimelineDeleteModal.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Modal
isOpen={target != null}
onClose={onClose}
title="타임라인 삭제"
disableOverlayClick={isDeleting}
>
<ModalContent
icon={<TrashIcon className="h-7 w-7 text-info-red" />}
title="해당 타임라인을 삭제할까요?"
description={
target
? `"${target.name}" 타임라인을 삭제하면 복구할 수 없습니다`
: ""
}
buttonText="삭제하기"
onConfirm={onConfirm}
isLoading={isDeleting}
variant="danger"
/>
</Modal>
);
}
94 changes: 94 additions & 0 deletions src/components/timeline/TimelineToolbar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex shrink-0 flex-col gap-4 border-b border-surface-400/80 px-5 py-5 tablet:px-4 tablet:py-4 tablet:gap-3">
<div className="flex items-center justify-between gap-8 tablet:flex-col tablet:items-stretch tablet:gap-3">
<TimelineStatusLegend className="min-w-0 flex-1" />
<Button
type="button"
size="small"
variant="custom"
onClick={onCreate}
leftIcon={
<PlusIcon
className="h-4 w-4 shrink-0 text-primary-500"
aria-hidden
/>
}
className={twMerge(
"h-10 shrink-0 rounded-2xl px-4",
"bg-primary-400/20 text-primary-500",
"font-body2 shadow-Soft",
"transition-ui-smooth hover:bg-primary-500/30",
"tablet:w-full tablet:justify-center",
)}
>
타임라인 생성
</Button>
</div>

<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-4 tablet:grid-cols-1 tablet:gap-3">
<TimelineViewUnitSegment
viewUnit={viewUnit}
onViewUnitChange={onViewUnitChange}
className="justify-self-start tablet:justify-self-stretch tablet:w-full"
/>
<TimelinePeriodNav
periodLabel={periodLabel}
onPrevPeriod={onPrevPeriod}
onNextPeriod={onNextPeriod}
onGoToToday={onGoToToday}
className="justify-self-center"
/>
<TimelineFilterSortMenus
statusFilter={statusFilter}
sort={sort}
onStatusFilterChange={onStatusFilterChange}
onSortChange={onSortChange}
className="justify-self-end"
/>
</div>
</div>
);
}
51 changes: 51 additions & 0 deletions src/hooks/timeline/useTimelineEditModal.ts
Original file line number Diff line number Diff line change
@@ -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<number | null>(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,
};
}
54 changes: 54 additions & 0 deletions src/hooks/timeline/useTimelinePanel.ts
Original file line number Diff line number Diff line change
@@ -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<number | null>(null);
const [panelData, setPanelData] = useState<ITimelineSummaryPanelData | null>(
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,
};
}
49 changes: 49 additions & 0 deletions src/hooks/timeline/useTimelinePeriod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { type RefObject, useEffect, useState } from "react";

import type { TTimelineViewUnit } from "@/types/timeline/ui";

interface IUserTimelinePeriodParams {
scrollRef: RefObject<HTMLDivElement | null>;
hasNoTimelines: boolean;
}

export function useTimelinePeriod({
scrollRef,
hasNoTimelines,
}: IUserTimelinePeriodParams) {
const [viewUnit, setViewUnit] = useState<TTimelineViewUnit>("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,
};
}
Loading
Loading