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
11 changes: 11 additions & 0 deletions .storybook/main.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
import type { StorybookConfig } from "@storybook/react-vite";
import type { InlineConfig } from "vite";

const config: StorybookConfig = {
stories: ["../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
addons: ["@storybook/addon-essentials"],
framework: "@storybook/react-vite",
async viteFinal(viteConfig: InlineConfig) {
viteConfig.define = {
...(viteConfig.define ?? {}),
"import.meta.env.VITE_API_BASE_URL": JSON.stringify(
"http://localhost:8080",
),
};
return viteConfig;
},
};

export default config;
16 changes: 13 additions & 3 deletions .storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,23 @@ import "../src/index.css";

import { MemoryRouter } from "react-router-dom";
import type { Preview } from "@storybook/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});

const preview: Preview = {
decorators: [
(Story) => (
<MemoryRouter>
<Story />
</MemoryRouter>
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<Story />
</MemoryRouter>
</QueryClientProvider>
),
],
parameters: {
Expand Down
53 changes: 53 additions & 0 deletions src/api/timeline/timeline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { ICommonResponse } from "@/types/common/common";
import type {
ITimelineDetail,
ITimelineListItem,
ITimelineMutationResponse,
ITimelineUpsertRequest,
} from "@/types/timeline/api";

import { axiosInstance } from "@/lib/axiosInstance";

//타임라인 상세 조회 API
export const getTimelineDetail = async (
orgId: number,
timelineId: number,
): Promise<ITimelineDetail> => {
const { data } = await axiosInstance.get<ICommonResponse<ITimelineDetail>>(
`/api/org/${orgId}/timeline/${timelineId}`,
);
return data.data;
};

//타임라인 목록 조회 API
export const getTimelineList = async (
orgId: number,
): Promise<ITimelineListItem[]> => {
const { data } = await axiosInstance.get<
ICommonResponse<ITimelineListItem[]>
>(`/api/org/${orgId}/timeline`);
return data.data;
};

//타임라인 생성 API
export const createTimeline = async (
orgId: number,
body: ITimelineUpsertRequest,
): Promise<ITimelineMutationResponse> => {
const { data } = await axiosInstance.post<
ICommonResponse<ITimelineMutationResponse>
>(`/api/org/${orgId}/timeline`, body, {
validateStatus: (status) =>
status === 201 || (status >= 200 && status < 300),
});
return data.data;
};

//타임라인 수정 API
//export const updateTimeline = async

//타임라인 삭제 API
//export const deleteTimeline = async

//타임라인 AI 요약 요청 API
//export const requestTimelineSummary = async
65 changes: 27 additions & 38 deletions src/components/timeline/TimelineCreateModal.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { type MouseEvent, useEffect, useMemo, useState } from "react";
import { type MouseEvent, useEffect, useMemo } from "react";
import { Controller, type SubmitHandler, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "sonner";
import { twMerge } from "tailwind-merge";

import type { TTimelineMetric } from "@/types/timeline/api";
Expand All @@ -16,6 +15,8 @@ import {
type TTimelineCreateFormValues,
} from "@/utils/timeline/timeline";

import { useCreateTimeline } from "@/hooks/timeline/useCreateTimeline";

import Button from "../common/button/Button";
import {
DropdownMenu,
Expand All @@ -26,8 +27,6 @@ import Modal from "../common/modal/Modal";

import ChevronIcon from "@/assets/icon/chevron/chevron-up.svg?react";

const MOCK_SUBMIT_DELAY_MS = 800;

function openDatePickerFromField(event: MouseEvent<HTMLElement>) {
const input =
event.currentTarget.querySelector<HTMLInputElement>('input[type="date"]');
Expand All @@ -53,7 +52,8 @@ export default function TimelineCreateModal({
isOpen,
onClose,
}: ITimelineCreateModalProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
// const [isSubmitting, setIsSubmitting] = useState(false);
const { mutate, isPending } = useCreateTimeline();

const {
register,
Expand Down Expand Up @@ -86,24 +86,23 @@ export default function TimelineCreateModal({
label: option.label,
active: option.value === comparisonPeriodType,
onClick: () => {
if (isSubmitting) return;
if (isPending) return;
setValue("comparisonPeriodType", option.value, {
shouldValidate: true,
});
},
})),
[comparisonPeriodType, isSubmitting, setValue],
[comparisonPeriodType, isPending, setValue],
);

useEffect(() => {
if (!isOpen) {
reset(TIMELINE_FORM_DEFAULT_VALUES);
setIsSubmitting(false);
}
}, [isOpen, reset]);

const handleClose = () => {
if (isSubmitting) return;
if (isPending) return;
onClose();
};

Expand All @@ -116,23 +115,13 @@ export default function TimelineCreateModal({
setValue("metrics", next, { shouldValidate: true });
};

const onSubmit: SubmitHandler<TTimelineCreateFormValues> = async (data) => {
setIsSubmitting(true);

try {
await new Promise<void>((resolve) => {
window.setTimeout(resolve, MOCK_SUBMIT_DELAY_MS);
});
toast.success("타임라인이 생성되었습니다", {
description: `"${data.name}" 타임라인을 추가했습니다`,
});
reset(TIMELINE_FORM_DEFAULT_VALUES);
onClose();
} catch {
toast.error("타임라인 생성에 실패했습니다. 다시 시도해주세요");
} finally {
setIsSubmitting(false);
}
const onSubmit: SubmitHandler<TTimelineCreateFormValues> = (data) => {
mutate(data, {
onSuccess: () => {
reset(TIMELINE_FORM_DEFAULT_VALUES);
onClose();
},
});
};

return (
Expand All @@ -142,7 +131,7 @@ export default function TimelineCreateModal({
size="lg"
padding="lg"
title="타임라인 생성"
disableOverlayClick={isSubmitting}
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>
Expand All @@ -159,7 +148,7 @@ export default function TimelineCreateModal({
<Input
label="타임라인 이름"
placeholder="ex. 6월 봄 프로모션"
disabled={isSubmitting}
disabled={isPending}
error={!!errors.name}
helperText={errors.name?.message}
{...register("name")}
Expand All @@ -168,13 +157,13 @@ export default function TimelineCreateModal({
<div className="grid grid-cols-1 gap-7 tablet:grid-cols-2">
<div
role="presentation"
className={!isSubmitting ? "cursor-pointer" : undefined}
className={!isPending ? "cursor-pointer" : undefined}
onClick={openDatePickerFromField}
>
<Input
label="시작일"
type="date"
disabled={isSubmitting}
disabled={isPending}
error={!!errors.startDate}
helperText={errors.startDate?.message}
inputClassName="cursor-pointer"
Expand All @@ -183,13 +172,13 @@ export default function TimelineCreateModal({
</div>
<div
role="presentation"
className={!isSubmitting ? "cursor-pointer" : undefined}
className={!isPending ? "cursor-pointer" : undefined}
onClick={openDatePickerFromField}
>
<Input
label="종료일"
type="date"
disabled={isSubmitting}
disabled={isPending}
error={!!errors.endDate}
helperText={errors.endDate?.message}
inputClassName="cursor-pointer"
Expand All @@ -216,15 +205,15 @@ export default function TimelineCreateModal({
<button
type="button"
key={option.value}
disabled={isSubmitting}
disabled={isPending}
aria-pressed={isSelected}
onClick={() => toggleMetric(option.value)}
className={twMerge(
"inline-flex h-8 items-center rounded-full border px-4 font-body2 transition-colors",
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}
Expand Down Expand Up @@ -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"
: "",
Expand Down Expand Up @@ -294,10 +283,10 @@ export default function TimelineCreateModal({
size="big"
variant="primary"
fullWidth
isLoading={isSubmitting}
disabled={isSubmitting}
isLoading={isPending}
disabled={isPending}
>
{isSubmitting ? "생성 중..." : "생성하기"}
{isPending ? "생성 중..." : "생성하기"}
</Button>
</div>
</form>
Expand Down
24 changes: 24 additions & 0 deletions src/components/timeline/skeleton/TimelineSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { TIMELINE_PAGE_HEIGHT } from "@/constants/timeline/layout";

import { Skeleton } from "@/components/common/skeleton/Skeleton";

export default function TimelineSkeleton() {
return (
<section
className="flex flex-col w-full min-w-0"
style={{ height: TIMELINE_PAGE_HEIGHT }}
>
<div className="flex min-h-0 flex-col flex-1 rounded-2xl border border-surface-400/70 bg-surface-100">
<div className="flex flex-col gap-4 shrink-0 border-b border-surface-400/80 px-5 py-5">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-10 w-full" />
</div>
<div className="flex flex-1 flex-col gap-3 p-5">
<Skeleton className="h-14 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
</div>
</div>
</section>
);
}
2 changes: 1 addition & 1 deletion src/constants/timeline/formOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "작년 동기간 대비" },
Comment thread
jjjsun marked this conversation as resolved.
] as const;

/*zod enum 용 - 타입과 동기화*/
Expand Down
37 changes: 37 additions & 0 deletions src/hooks/timeline/useCreateTimeline.ts
Original file line number Diff line number Diff line change
@@ -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);
},
},
);
}
15 changes: 15 additions & 0 deletions src/hooks/timeline/useTimelineDetail.ts
Original file line number Diff line number Diff line change
@@ -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 },
);
}
16 changes: 16 additions & 0 deletions src/hooks/timeline/useTimelineList.ts
Original file line number Diff line number Diff line change
@@ -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,
},
);
}
Loading
Loading