Skip to content
Merged
49 changes: 41 additions & 8 deletions src/api/timeline/timeline.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -44,10 +45,42 @@ export const createTimeline = async (
};

//타임라인 수정 API
//export const updateTimeline = async
export const updateTimeline = async (
orgId: number,
timelineId: number,
body: ITimelineUpsertRequest,
): Promise<ITimelineMutationResponse> => {
const { data } = await axiosInstance.put<
ICommonResponse<ITimelineMutationResponse>
>(`/api/org/${orgId}/timeline/${timelineId}`, body);
return data.data;
};

//타임라인 삭제 API
//export const deleteTimeline = async
export const deleteTimeline = async (
orgId: number,
timelineId: number,
): Promise<TTimelineEmptyResponse> => {
const { data } = await axiosInstance.delete<
ICommonResponse<TTimelineEmptyResponse>
>(`/api/org/${orgId}/timeline/${timelineId}`);
return data.data;
};

//타임라인 AI 요약 요청 API
//export const requestTimelineSummary = async
export const requestTimelineSummary = async (
orgId: number,
timelineId: number,
): Promise<TTimelineEmptyResponse> => {
const { data } = await axiosInstance.post<
ICommonResponse<TTimelineEmptyResponse>
>(
`/api/org/${orgId}/timeline/${timelineId}/summary`,
{},
{
validateStatus: (status) =>
status === 202 || (status >= 200 && status < 300),
},
);
return data.data;
};
54 changes: 40 additions & 14 deletions src/components/timeline/TimelineCreateModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -46,14 +50,20 @@ function openDatePickerFromField(event: MouseEvent<HTMLElement>) {
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,
Expand Down Expand Up @@ -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;
Expand All @@ -116,12 +128,16 @@ export default function TimelineCreateModal({
};

const onSubmit: SubmitHandler<TTimelineCreateFormValues> = (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 (
Expand All @@ -130,13 +146,17 @@ export default function TimelineCreateModal({
onClose={handleClose}
size="lg"
padding="lg"
title="타임라인 생성"
title={isEditMode ? "타임라인 수정" : "타임라인 생성"}
disableOverlayClick={isPending}
>
<div className="flex w-full flex-col items-start px-4 pr-8 tablet:pr-10">
<h2 className="mb-2 font-heading3 text-text-title">타임라인 생성</h2>
<h2 className="mb-2 font-heading3 text-text-title">
{isEditMode ? "타임라인 수정" : "타임라인 생성"}
</h2>
<p className="mb-5 text-start font-body2 text-text-muted">
분석할 기간과 성과 지표를 설정해 새 타임라인을 만들어보세요
{isEditMode
? "타임라인 정보를 수정하고 저장하세요"
: "분석할 기간과 성과 지표를 설정해 새 타임라인을 만들어보세요"}
</p>

<form
Expand Down Expand Up @@ -286,7 +306,13 @@ export default function TimelineCreateModal({
isLoading={isPending}
disabled={isPending}
>
{isPending ? "생성 중..." : "생성하기"}
{isPending
? isEditMode
? "저장 중..."
: "생성 중..."
: isEditMode
? "저장하기"
: "생성하기"}
</Button>
</div>
</form>
Expand Down
60 changes: 18 additions & 42 deletions src/components/timeline/TimelinePerformancePanel.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 =
Expand Down Expand Up @@ -65,6 +63,9 @@ interface ITimelinePerformancePanelProps {
onEdit?: () => void;
onDelete?: () => void;
className?: string;
onRequestSummary?: () => void;
isSummaryPending?: boolean;
isSummaryLoading?: boolean;
}

function formatMetricValue(value: number, unit?: string) {
Expand Down Expand Up @@ -119,50 +120,24 @@ export default function TimelinePerformancePanel({
onEdit,
onDelete,
className,
onRequestSummary,
isSummaryPending,
isSummaryLoading,
}: ITimelinePerformancePanelProps) {
const [aiState, setAiState] = useState<TAiSummaryUiState>("idle");
const [generatedSummary, setGeneratedSummary] = useState("");
const summaryTimerRef = useRef<number | null>(null);
const [viewUnit, setViewUnit] = useState<TTimelineViewUnit>("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,
Expand Down Expand Up @@ -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"
>
요약하기 생성
Expand All @@ -283,14 +259,14 @@ export default function TimelinePerformancePanel({
</div>
)}

{aiState === "done" && (data.aiSummary || generatedSummary) && (
{aiState === "done" && hasSummary && (
<p
className={twMerge(
SOFT_CARD_CLASS,
"px-5 py-4 font-body1 text-text-body break-keep leading-relaxed",
)}
>
{data.aiSummary || generatedSummary}
{data.aiSummary}
</p>
)}
</section>
Expand Down
34 changes: 34 additions & 0 deletions src/hooks/timeline/useDeleteTimeline.ts
Original file line number Diff line number Diff line change
@@ -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);
},
},
);
}
34 changes: 34 additions & 0 deletions src/hooks/timeline/useRequestTimelineSummary.ts
Original file line number Diff line number Diff line change
@@ -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);
},
},
);
}
10 changes: 8 additions & 2 deletions src/hooks/timeline/useTimelineDetail.ts
Original file line number Diff line number Diff line change
@@ -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<ITimelineDetail>,
) {
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 },
);
}
Loading
Loading