Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 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
20 changes: 20 additions & 0 deletions src/api/ads/ads.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { ICampaign } from "@/types/ads/campaign";
import type { ICommonResponse } from "@/types/common/common";

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

export const getCampaignList = async (orgId: number): Promise<ICampaign[]> => {
const { data } = await axiosInstance.get<
ICommonResponse<{ projects: ICampaign[] }>
>(`/api/project/${orgId}`);
return data.data.projects;
};

export const updateAllCampaignStatus = async (
orgId: number,
status: "ON_GOING" | "PAUSED",
): Promise<void> => {
await axiosInstance.patch(`/api/project/${orgId}/status`, null, {
params: { status },
});
};
56 changes: 33 additions & 23 deletions src/components/ads/CampaignRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,18 @@ import type { ReactNode } from "react";

import type { TCampaignStatus, TPlatform } from "@/types/ads/campaign";

import Badge from "../common/badge/Badge";
import ProgressBar from "../common/progressbar/ProgressBar";

import GoogleLogo from "@/assets/icon/ads/google-circle.svg?react";
import KakaoLogo from "@/assets/icon/ads/kakao-circle.svg?react";
import NaverLogo from "@/assets/icon/ads/naver-circle.svg?react";

interface ICampaignRowProps {
platforms: TPlatform[];
projectId: number;
name: string;
providers: TPlatform[];
status: TCampaignStatus;
statusText: string;
progress: number;
budgetUsageRate: number;
onClick?: () => void;
}
Comment thread
YermIm marked this conversation as resolved.

Expand All @@ -25,16 +24,19 @@ const LogoMap: Record<TPlatform, ReactNode> = {
};

export default function CampaignRow({
platforms,
name,
providers,
status,
statusText,
progress,
budgetUsageRate,
onClick,
}: ICampaignRowProps) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const isPaused = status == "PAUSED";
return (
<li
className="flex items-center px-7 py-5 border-b border-bg-disabled hover:bg-bg-surface hover:cursor-pointer transition-colors list-none"
className={`flex items-center px-7 py-5 border-b border-bg-disabled hover:bg-bg-surface hover:cursor-pointer transition-colors list-none ${
isPaused ? "bg-bg-surface" : "bg-white"
}
`}
onClick={onClick}
role="button"
tabIndex={onClick ? 0 : undefined}
Expand All @@ -46,32 +48,40 @@ export default function CampaignRow({
}}
>
{/* 플랫폼 */}
<div className="flex w-[20%] shrink-0">
{platforms.map((p, idx) => (
<div
key={idx}
className="flex h-8 w-8 mr-3 items-center justify-center rounded-full shadow-sm overflow-hidden shrink-0"
>
{LogoMap[p]}
</div>
))}
<div className="flex w-[25%] shrink-0">
{providers && providers.length > 0 ? (
providers.map((p, idx) => (
<div
key={idx}
className="flex h-8 w-8 mr-3 items-center justify-center rounded-full shadow-sm overflow-hidden shrink-0"
>
{LogoMap[p.toLowerCase() as TPlatform]}
</div>
))
) : (
<div className="text-text-placeholder font-body2">미연결</div>
)}
</div>

{/* 캠페인 명 */}
<div className="w-[35%] min-w-0 pr-10 shrink-0">
<div className="font-body1 text-text-main truncate">{name}</div>
<div className="w-[50%] min-w-0 pr-10 shrink-0">
<div
className={`font-body1 truncate ${isPaused ? "text-text-sub" : "text-text-main"}`}
>
{name}
</div>
</div>

{/* 동기화 상태 */}
<div className="w-[15%] shrink-0 pr-10">
{/* <div className="w-[15%] shrink-0 pr-10">
<Badge variant={status} size="sm">
{statusText}
</Badge>
</div>
</div> */}

{/* 예산 소진 현황 */}
<div className="w-[30%] shrink-0">
<ProgressBar value={progress} />
<div className={`w-[25%] shrink-0 ${isPaused ? "opacity-80" : ""}`}>
<ProgressBar value={budgetUsageRate} />
</div>
</li>
);
Expand Down
42 changes: 29 additions & 13 deletions src/components/ads/CampaignTable.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,50 @@
import type { ICampaign } from "@/types/ads/campaign";

import CampaignRow from "./CampaignRow";

import { MOCK_CAMPAIGNS } from "@/pages/ads/list/campaign.mock";
//import { MOCK_CAMPAIGNS } from "@/pages/ads/list/campaign.mock";

interface ICampaignTableProps {
campaigns: ICampaign[];
onRowClick?: (id: number) => void;
}

export default function CampaignTable({ onRowClick }: ICampaignTableProps) {
export default function CampaignTable({
campaigns,
onRowClick,
}: ICampaignTableProps) {
const visibleCampaigns = campaigns.filter(
(project) => project.status !== "OVER",
);

return (
<div className="w-full bg-white overflow-x-auto">
<div className="min-w-180">
{/* Table Header */}
<div className="flex items-center px-7 py-3 border-b border-bg-disabled">
<div className="w-[20%] font-body1 text-text-main">플랫폼</div>
<div className="w-[35%] font-body1 text-text-main">캠페인 명</div>
<div className="w-[15%] font-body1 text-text-main">동기화 상태</div>
<div className="w-[30%] font-body1 text-text-main">
<div className="w-[25%] font-body1 text-text-main">플랫폼</div>
<div className="w-[50%] font-body1 text-text-main">캠페인 명</div>
{/* <div className="w-[15%] font-body1 text-text-main">동기화 상태</div> */}
<div className="w-[25%] font-body1 text-text-main">
예산 소진 현황
</div>
</div>

{/* Row */}
<ul className="divide-y divide-bg-disabled">
{MOCK_CAMPAIGNS.map((campaign) => (
<CampaignRow
key={campaign.id}
{...campaign}
onClick={() => onRowClick?.(campaign.id)}
/>
))}
{visibleCampaigns.length > 0 ? (
visibleCampaigns.map((project) => (
<CampaignRow
key={project.projectId}
{...project}
onClick={() => onRowClick?.(project.projectId)}
/>
))
) : (
<li className="py-20 text-center font-body2 text-text-placeholder">
현재 표시할 캠페인이 없습니다.
</li>
)}
</ul>
</div>
</div>
Expand Down
121 changes: 105 additions & 16 deletions src/pages/ads/list/AdsListPage.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,52 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";

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

import CampaignTable from "@/components/ads/CampaignTable";
import ControlBox from "@/components/common/controlbox/ControlBox";
import Modal from "@/components/common/modal/Modal";
import ModalContent from "@/components/common/modal/ModalContent";

import { getCampaignList, updateAllCampaignStatus } from "@/api/ads/ads";
import { getMyWorkspaces } from "@/api/workspace/org";
import WarningIcon from "@/assets/icon/workspace/message-circle-warning.svg?react";

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();
}, []);
Comment thread
YermIm marked this conversation as resolved.
Comment thread
YermIm marked this conversation as resolved.

const navigate = useNavigate();

const [stopAllOpen, setStopAllOpen] = useState(false);
Expand All @@ -26,28 +63,61 @@ export default function AdsListPage() {
navigate("/ads/campaignGroup");
};

const onStopAll = () => {
const onStopAll = async () => {
if (!currentOrgId) return;
setIsStopping(true);

try {
await updateAllCampaignStatus(currentOrgId, "PAUSED");

setCampaigns((prev) =>
prev.map((c) =>
c.status === "ON_GOING" ? { ...c, status: "PAUSED" } : c,
),
);
toast.success("전체 캠페인의 모든 광고 노출이 중단되었습니다.");
setStopAllOpen(false);
} catch {
toast.error("중단 처리에 실패하였습니다.");
} finally {
setIsStopping(false);
}
};

const onResumeAll = () => {
const onResumeAll = async () => {
if (!currentOrgId) return;
setIsResuming(true);

try {
await updateAllCampaignStatus(currentOrgId, "ON_GOING");
setCampaigns((prev) =>
prev.map((c) =>
c.status === "PAUSED" ? { ...c, status: "ON_GOING" } : c,
),
);
toast.success("전체 캠페인의 광고 노출이 재개되었습니다.");
setResumeOpen(false);
} catch {
toast.error("재개 처리에 실패하였습니다.");
} finally {
setIsResuming(false);
}
};

const hasCampaigns = campaigns.length > 0;

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

if (isLoading) {
return (
<div className="flex h-[90vh] items-center justify-center">
<p className="font-body1 text-text-placeholder">
데이터를 불러오는 중입니다...
</p>
</div>
);
}

return (
<section className="flex flex-col overflow-hidden bg-white rounded-component-lg min-h-[90vh]">
<div className="flex-1 overflow-x-auto py-15 px-10 md:px-18 lg:px-25">
Expand All @@ -61,7 +131,10 @@ export default function AdsListPage() {
</header>
{/* 테이블 */}
<div className="w-full flex flex-col mb-10">
<CampaignTable onRowClick={(id) => handleCampaignClick(id)} />
<CampaignTable
campaigns={campaigns}
onRowClick={(id) => handleCampaignClick(id)}
/>
</div>
{/* 하단 배너 */}
<div className="w-full flex flex-col gap-7">
Comment thread
YermIm marked this conversation as resolved.
Outdated
Expand All @@ -77,18 +150,34 @@ export default function AdsListPage() {
buttonSize="big"
buttonClassName="font-body1"
/>
<ControlBox
title="전체 캠페인을 완전히 중단할 수 있어요"
description="모든 광고 노출이 즉시 멈추고, 연결된 플랫폼에서도 더 이상 광고가 집행되지 않아요."
buttonText="중단하기"
onButtonClick={() => setStopAllOpen(true)}
buttonDisabled={isStopping}
containerClassName="bg-status-red/7 border-status-red px-6 py-4 min-w-[650px] shrink-0"
titleClassName="text-status-red font-heading3"
descriptionClassName="font-body2 text-text-sub"
buttonSize="big"
buttonClassName="font-body1 bg-status-red"
/>
{hasCampaigns &&
(hasActiveCampaign ? (
<ControlBox
title="전체 캠페인을 완전히 중단할 수 있어요"
description="모든 광고 노출이 즉시 멈추고, 연결된 플랫폼에서도 더 이상 광고가 집행되지 않아요."
buttonText="중단하기"
onButtonClick={() => setStopAllOpen(true)}
buttonDisabled={isStopping}
containerClassName="bg-status-red/7 border-status-red px-6 py-4 min-w-[650px] shrink-0"
titleClassName="text-status-red font-heading3"
descriptionClassName="font-body2 text-text-sub"
buttonSize="big"
buttonClassName="font-body1 bg-status-red"
/>
) : (
<ControlBox
title="중단된 캠페인을 다시 시작할 수 있어요"
description="중단되었던 모든 캠페인의 광고 노출이 즉시 재개되며, 다시 활성화됩니다."
buttonText="시작하기"
onButtonClick={() => setResumeOpen(true)}
buttonDisabled={isResuming}
containerClassName="bg-status-blue/7 border-status-blue px-6 py-4 min-w-[650px] shrink-0"
titleClassName="text-status-blue font-heading3"
descriptionClassName="font-body2 text-text-sub"
buttonSize="big"
buttonClassName="font-body1 bg-status-blue"
/>
))}
</div>
</div>
</div>
Expand Down
Loading
Loading