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
18 changes: 18 additions & 0 deletions src/api/dashboard/overview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { ICommonResponse } from "@/types/common/common";
import type { IMetricsResponse } from "@/types/dashboard/overview";

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

type TProviderType = "KAKAO" | "NAVER" | "GOOGLE";

// 대시보드 - 전체 지표 집계 API
export const getOverview = async (
orgId: number,
providerType?: TProviderType,
): Promise<IMetricsResponse> => {
const { data } = await axiosInstance.get<ICommonResponse<IMetricsResponse>>(
`/api/dashboard/${orgId}/metrics`,
{ params: providerType ? { providerType } : undefined },
);
return data.data;
};
24 changes: 11 additions & 13 deletions src/api/workspace/org.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ICommonResponse } from "@/types/common/common";
import {
type TApiResult,
type TCreateOrgRequest,
type TCreateOrgResponse,
type TGetOrgResponse,
Expand All @@ -14,24 +14,23 @@ import { axiosInstance } from "@/lib/axiosInstance";

export const getMyWorkspaces = async (): Promise<TWorkspace[]> => {
const { data } =
await axiosInstance.get<TApiResult<TMyOrgsData>>("/api/org/my");
await axiosInstance.get<ICommonResponse<TMyOrgsData>>("/api/org/my");
return data.data.organizations;
};

export const createWorkspace = async (
body: TCreateOrgRequest,
): Promise<TCreateOrgResponse> => {
const { data } = await axiosInstance.post<TApiResult<TCreateOrgResponse>>(
"/api/org/create",
body,
);
const { data } = await axiosInstance.post<
ICommonResponse<TCreateOrgResponse>
>("/api/org/create", body);
return data.data;
};

export const getWorkspace = async (
orgId: number,
): Promise<TWorkspaceDetail> => {
const { data } = await axiosInstance.get<TApiResult<TGetOrgResponse>>(
const { data } = await axiosInstance.get<ICommonResponse<TGetOrgResponse>>(
`/api/org/${orgId}`,
);

Expand All @@ -42,19 +41,18 @@ export const updateWorkspace = async (
orgId: number,
body: TUpdateWorkspaceRequest,
): Promise<void> => {
await axiosInstance.patch<TApiResult<string>>(`/api/org/${orgId}`, body);
await axiosInstance.patch<ICommonResponse<string>>(`/api/org/${orgId}`, body);
};

export const deleteWorkspace = async (orgId: number): Promise<void> => {
await axiosInstance.delete<TApiResult<string>>(`/api/org/${orgId}`);
await axiosInstance.delete<ICommonResponse<string>>(`/api/org/${orgId}`);
};

export const uploadImage = async (file: File): Promise<string> => {
const formData = new FormData();
formData.append("image", file);
const { data } = await axiosInstance.post<TApiResult<TUploadImageResponse>>(
`/api/images/upload`,
formData,
);
const { data } = await axiosInstance.post<
ICommonResponse<TUploadImageResponse>
>(`/api/images/upload`, formData);
return data.data.url;
};
4 changes: 2 additions & 2 deletions src/components/workspace/InviteMemberModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export default function InviteMemberModal({
size="lg"
padding="none"
title="팀원 초대하기"
className="w-full max-w-[760px] overflow-hidden"
className="w-full max-w-190 overflow-hidden"
Comment thread
Seojegyeong marked this conversation as resolved.
>
<div className="flex flex-col h-full max-h-[80vh] text-center px-2 py-6">
<div className="flex justify-between items-center px-8 py-6 pb-3 shrink-0">
Expand Down Expand Up @@ -98,7 +98,7 @@ export default function InviteMemberModal({
size="big"
onClick={handleInvite}
disabled={isInviteDisabled}
className="min-w-[88px]"
className="min-w-22"
>
초대
</Button>
Expand Down
60 changes: 23 additions & 37 deletions src/hooks/ads/useCampaignDetail.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,29 @@
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { toast } from "sonner";

import type { ICampaignDetail } from "@/types/ads/campaign";
import { useCoreQuery } from "@/hooks/customQuery";

import { getCampaignDetail } from "@/api/ads/ads";

export const useCampaignDetail = (orgId: number | null) => {
const { projectId } = useParams<{ projectId: string }>();

const [data, setData] = useState<ICampaignDetail | null>(null);

const [isLoading, setIsLoading] = useState(true);

const fetchDetail = async () => {
if (!orgId || !projectId) {
console.log("데이터 부족");
setIsLoading(false);
return;
}

try {
setIsLoading(true);
const detailData = await getCampaignDetail(orgId, Number(projectId));
setData(detailData);
} catch {
toast.error(" 캠페인 상세 정보를 불러오지 못했습니다.");
} finally {
setIsLoading(false);
}
};

useEffect(() => {
fetchDetail();
}, [orgId, projectId]);

return {
data,
isLoading,
refetch: fetchDetail,
};
export const useCampaignDetail = () => {
const { orgId, projectId } = useParams<{
orgId: string;
projectId: string;
}>();

// URL 파라미터를 숫자로 변환
const parsedOrgId = Number(orgId);
const parsedProjectId = Number(projectId);

// 유효한 ID일 때만 요청
const isValid =
Number.isFinite(parsedOrgId) &&
parsedOrgId > 0 &&
Number.isFinite(parsedProjectId) &&
parsedProjectId > 0;

return useCoreQuery(
["campaignDetail", parsedOrgId, parsedProjectId],
() => getCampaignDetail(parsedOrgId, parsedProjectId),
{ enabled: isValid },
);
};
60 changes: 60 additions & 0 deletions src/hooks/dashboard/useOverviewMetrics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type { IMetricsResponse } from "@/types/dashboard/overview";

import { useCoreQuery } from "@/hooks/customQuery";

import type { IStatCardProps } from "@/components/common/card/StatCard";

import { getOverview } from "@/api/dashboard/overview";
import useWorkspaceStore from "@/store/useWorkspaceStore";

const toRate = (rate: number) => `${(Math.abs(rate) * 100).toFixed(1)}%`;

function toKpis(metrics: IMetricsResponse): IStatCardProps[] {
return [
{
title: "클릭수",
value: metrics.clicks.toLocaleString(),
trend: {
direction: metrics.clickChangeRate >= 0 ? "up" : "down",
value: toRate(metrics.clickChangeRate),
},
},
{
title: "노출수",
value: metrics.impressions.toLocaleString(),
trend: {
direction: metrics.impressionChangeRate >= 0 ? "up" : "down",
value: toRate(metrics.impressionChangeRate),
},
},
{
title: "전환율",
value: `${(metrics.conversion * 100).toFixed(1)}%`,
trend: {
direction: metrics.cvrChangeRate >= 0 ? "up" : "down",
value: toRate(metrics.cvrChangeRate),
},
},
{
title: "ROAS",
value: `${(metrics.ROAS * 100).toFixed(1)}%`,
trend: {
direction: metrics.ROASChangeRate >= 0 ? "up" : "down",
value: toRate(metrics.ROASChangeRate),
},
},
];
}

export function useOverviewMetrics() {
const orgId = useWorkspaceStore((s) => s.selectedOrgId);

return useCoreQuery(
["overview", "metrics", orgId],
() => getOverview(orgId!),
{
enabled: !!orgId,
select: toKpis,
},
);
}
12 changes: 12 additions & 0 deletions src/layout/main/MainLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,25 @@
import { useEffect } from "react";
import { Outlet } from "react-router-dom";

import { useCoreQuery } from "@/hooks/customQuery";

import Sidebar from "@/components/Sidebar/Sidebar";

import { getMyInfo } from "@/api/auth/auth";
import { getMyWorkspaces } from "@/api/workspace/org";
import useWorkspaceStore from "@/store/useWorkspaceStore";

export default function MainLayout() {
useCoreQuery(["myInfo"], getMyInfo);

const setSelectedOrgId = useWorkspaceStore((s) => s.setSelectedOrgId);
const { data: workspaces } = useCoreQuery(["workspaces"], getMyWorkspaces);

useEffect(() => {
if (workspaces && workspaces.length > 0) {
setSelectedOrgId(workspaces[0].orgId);
}
}, [workspaces, setSelectedOrgId]);
return (
<div className="fixed inset-0 box-border flex overflow-hidden p-5 bg-gray-50 tablet:p-3">
<Sidebar />
Expand Down
74 changes: 20 additions & 54 deletions src/pages/ads/list/AdsListPage.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { useQueryClient } from "@tanstack/react-query";

import type { ICampaign } from "@/types/ads/campaign";

import { useControlModal } from "@/hooks/ads/useControlModal";
import { useCoreQuery } from "@/hooks/customQuery";

import CampaignTable from "@/components/ads/CampaignTable";
import Card from "@/components/common/card/Card";
Expand All @@ -14,79 +14,45 @@ import ModalContent from "@/components/common/modal/ModalContent";
import PageHeader from "@/components/common/PageHeader";

import { getCampaignList, updateAllCampaignStatus } from "@/api/ads/ads";
import { getMyWorkspaces } from "@/api/workspace/org";
import WarnCircleIcon from "@/assets/icon/common/warn-circle.svg?react";
import useWorkspaceStore from "@/store/useWorkspaceStore";

export default function AdsListPage() {
const [campaigns, setCampaigns] = useState<ICampaign[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [currentOrgId, setCurrentOrgId] = useState<number | null>(null); //orgId

useEffect(() => {
const initData = async () => {
try {
setIsLoading(true);
const workspaces = await getMyWorkspaces();

if (workspaces && workspaces.length > 0) {
// 워크스페이스 ID 임시 지정 -> 추후 선택한 워크스페이스 api 연결 예정
const TemporaryOrg = workspaces[0];
const orgId = TemporaryOrg.orgId;

setCurrentOrgId(orgId);

// 캠페인 목록 API 호출
const campaignData = await getCampaignList(orgId);

setCampaigns(campaignData);
} else {
toast.error("조직이 없습니다.");
}
} catch {
toast.error("데이터를 불러오는 중 오류가 발생하였습니다.");
} finally {
setIsLoading(false);
}
};
initData();
}, []);

const navigate = useNavigate();
const queryClient = useQueryClient();
const orgId = useWorkspaceStore((s) => s.selectedOrgId);

const { data: campaigns = [], isLoading } = useCoreQuery<ICampaign[]>(
["campaigns", orgId],
() => getCampaignList(orgId!),
{ enabled: !!orgId },
);
Comment thread
Seojegyeong marked this conversation as resolved.

const invalidateCampaigns = () => {
queryClient.invalidateQueries({ queryKey: ["campaigns", orgId] });
};

const stopAll = useControlModal({
successMessage: "전체 캠페인의 모든 광고 노출이 중단되었습니다.",
errorMessage: "중단 처리에 실패하였습니다.",
onSuccess: () => {
setCampaigns((prev) =>
prev.map((c) =>
c.status === "ON_GOING" ? { ...c, status: "PAUSED" } : c,
),
);
},
onSuccess: invalidateCampaigns,
});

const resumeAll = useControlModal({
successMessage: "전체 캠페인의 광고 노출이 재개되었습니다.",
errorMessage: "재개 처리에 실패하였습니다.",
onSuccess: () => {
setCampaigns((prev) =>
prev.map((c) =>
c.status === "PAUSED" ? { ...c, status: "ON_GOING" } : c,
),
);
},
onSuccess: invalidateCampaigns,
});

const handleCampaignClick = (id: number) => {
navigate(`/ads/${currentOrgId}/${id}`);
navigate(`/ads/${orgId}/${id}`);
};

const handleCampaignGroupClick = () => {
navigate("/ads/campaignGroup");
};

const hasCampaigns = campaigns.length > 0;

const hasActiveCampaign = campaigns.some((c) => c.status === "ON_GOING");

if (isLoading) {
Expand Down Expand Up @@ -169,7 +135,7 @@ export default function AdsListPage() {
buttonText="중단하기"
onConfirm={() =>
stopAll.handleConfirm(() =>
updateAllCampaignStatus(currentOrgId!, "PAUSED"),
updateAllCampaignStatus(orgId!, "PAUSED"),
)
}
isLoading={stopAll.isLoading}
Expand All @@ -190,7 +156,7 @@ export default function AdsListPage() {
buttonText="시작하기"
onConfirm={() =>
resumeAll.handleConfirm(() =>
updateAllCampaignStatus(currentOrgId!, "ON_GOING"),
updateAllCampaignStatus(orgId!, "ON_GOING"),
)
}
isLoading={resumeAll.isLoading}
Expand Down
2 changes: 1 addition & 1 deletion src/pages/ads/list/CampaignDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export default function CampaignDetail() {
orgId: string;
projectId: string;
}>();
const { data, isLoading, refetch } = useCampaignDetail(Number(orgId));
const { data, isLoading, refetch } = useCampaignDetail();

const stopControl = useControlModal({
successMessage: "해당 캠페인의 모든 광고 운영이 중단되었습니다.",
Expand Down
Loading
Loading