diff --git a/src/components/ads/CampaignPlatformSection.tsx b/src/components/ads/CampaignPlatformSection.tsx
index bd4c798f..d76b69ae 100644
--- a/src/components/ads/CampaignPlatformSection.tsx
+++ b/src/components/ads/CampaignPlatformSection.tsx
@@ -1,13 +1,13 @@
import type { ReactNode } from "react";
import { twMerge } from "tailwind-merge";
-import type { IPlatformProjectBudget, TPlatform } from "@/types/ads/campaign";
+import type { IPlatformBudgetSummary, TPlatform } from "@/types/ads/campaign";
+import { canSubmitPlatformBudgetEdit } from "@/utils/ads/budgetEdit";
import {
- BUDGET_EDIT_BLOCK_MESSAGES,
- canSubmitPlatformBudgetEdit,
-} from "@/utils/ads/budgetEdit";
-import { mapPlatformProjectBudgetToGauges } from "@/utils/ads/projectBudget";
+ mapPlatformBudgetSummariesToGauges,
+ pickEditablePlatformBudget,
+} from "@/utils/ads/projectBudget";
import PlatformBudgetItem from "@/components/ads/PlatformBudgetItem";
import Button from "@/components/common/button/Button";
@@ -30,55 +30,42 @@ const PLATFORM_LABEL: Record = {
interface ICampaignPlatformSectionProps {
platform: TPlatform;
- platformBudget?: IPlatformProjectBudget;
- onEditBudget?: () => void;
+ platformBudgets?: IPlatformBudgetSummary[];
+ onEditBudget?: (budget: IPlatformBudgetSummary) => void;
}
export default function CampaignPlatformSection({
platform,
- platformBudget,
+ platformBudgets = [],
onEditBudget,
}: ICampaignPlatformSectionProps) {
- const gauges = platformBudget
- ? mapPlatformProjectBudgetToGauges(platformBudget)
- : [];
+ const gauges = mapPlatformBudgetSummariesToGauges(platformBudgets);
+ const editTarget = pickEditablePlatformBudget(platformBudgets);
- const editCheck = canSubmitPlatformBudgetEdit(platformBudget);
- const isBudgetEditDisabled = !onEditBudget || !editCheck.ok;
- const budgetEditDisabledReason = !onEditBudget
- ? undefined
- : editCheck.ok
- ? undefined
- : BUDGET_EDIT_BLOCK_MESSAGES[editCheck.reason];
- const budgetEditHintId = `budget-edit-hint-${platform}`;
+ const editCheck = canSubmitPlatformBudgetEdit(editTarget ?? undefined);
+ const isBudgetEditDisabled = !onEditBudget || !editTarget || !editCheck.ok;
+
+ const campaignName = platformBudgets.find(
+ (row) => row.adCampaignName,
+ )?.adCampaignName;
const budgetEditAction = (
-
-
- {budgetEditDisabledReason ? (
-
- {budgetEditDisabledReason}
-
- ) : null}
-
+
);
return (
@@ -100,9 +87,9 @@ export default function CampaignPlatformSection({
{PLATFORM_LABEL[platform]}
- {platformBudget?.adCampaignName ? (
+ {campaignName ? (
- {platformBudget.adCampaignName}
+ {campaignName}
) : null}
@@ -115,12 +102,12 @@ export default function CampaignPlatformSection({
예산 정보가 없습니다.
) : (
-
- {gauges.map((gauge) => (
+
+ {gauges.map((gauge, index) => (
))}
diff --git a/src/components/ads/CampaignRow.tsx b/src/components/ads/CampaignRow.tsx
index e02b37b0..7fd0033a 100644
--- a/src/components/ads/CampaignRow.tsx
+++ b/src/components/ads/CampaignRow.tsx
@@ -3,8 +3,6 @@ import { twMerge } from "tailwind-merge";
import type { TPlatform, TStatus } from "@/types/ads/campaign";
-import ProgressBar from "../common/progressbar/ProgressBar";
-
import GoogleLogo from "@/assets/logo/social-logo/circle/google-circle.svg?react";
import MetaLogo from "@/assets/logo/social-logo/circle/meta-circle.svg?react";
import NaverLogo from "@/assets/logo/social-logo/circle/naver-circle.svg?react";
@@ -14,7 +12,6 @@ interface ICampaignRowProps {
name: string;
providers: TPlatform[];
status: TStatus;
- budgetUsageRate: number;
isSelected: boolean;
onToggleSelect: () => void;
onClick?: () => void;
@@ -22,11 +19,7 @@ interface ICampaignRowProps {
/** 헤더·행·스켈레톤 플랫폼 열 — 모바일에서 로고 최대 3개 폭 예약 */
export const CAMPAIGN_PLATFORM_COL_CLASS =
- "mr-24 w-28 shrink-0 tablet:mr-20 tablet:w-24 mobile:mr-2 mobile:w-16";
-
-/** 헤더·행·스켈레톤 예산 열 */
-export const CAMPAIGN_BUDGET_COL_CLASS =
- "w-[28%] shrink-0 tablet:w-[26%] mobile:w-[30%] mobile:max-w-28";
+ "w-28 shrink-0 tablet:w-24 mobile:w-16";
const LogoMap: Record
= {
meta: (
@@ -44,7 +37,6 @@ export default function CampaignRow({
name,
providers,
status,
- budgetUsageRate,
isSelected,
onToggleSelect,
onClick,
@@ -99,11 +91,12 @@ export default function CampaignRow({
{providers && providers.length > 0 ? (
-
+
{providers.map((p, idx) => (
{LogoMap[p.toLowerCase() as TPlatform] ?? (
@@ -116,12 +109,6 @@ export default function CampaignRow({
미연결
)}
-
-
);
}
diff --git a/src/components/ads/CampaignTable.tsx b/src/components/ads/CampaignTable.tsx
index ed908553..0443dee4 100644
--- a/src/components/ads/CampaignTable.tsx
+++ b/src/components/ads/CampaignTable.tsx
@@ -3,10 +3,7 @@ import { twMerge } from "tailwind-merge";
import type { ICampaign } from "@/types/ads/campaign";
-import CampaignRow, {
- CAMPAIGN_BUDGET_COL_CLASS,
- CAMPAIGN_PLATFORM_COL_CLASS,
-} from "./CampaignRow";
+import CampaignRow, { CAMPAIGN_PLATFORM_COL_CLASS } from "./CampaignRow";
interface ICampaignTableProps {
campaigns: ICampaign[];
@@ -77,20 +74,11 @@ export default function CampaignTable({
플랫폼
-
- 예산 소진 현황
- 예산
-
diff --git a/src/components/ads/EditPlatformBudgetModal.tsx b/src/components/ads/EditPlatformBudgetModal.tsx
index 28a72a7a..dd64b3d5 100644
--- a/src/components/ads/EditPlatformBudgetModal.tsx
+++ b/src/components/ads/EditPlatformBudgetModal.tsx
@@ -8,7 +8,7 @@ import {
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "sonner";
-import type { IPlatformProjectBudget } from "@/types/ads/campaign";
+import type { IPlatformBudgetSummary } from "@/types/ads/campaign";
import {
buildUpdatePlatformBudgetVariables,
@@ -89,7 +89,7 @@ const BudgetAmountInput = forwardRef(
},
);
-const PROVIDER_LABEL: Record = {
+const PROVIDER_LABEL: Record = {
META: "Meta",
GOOGLE: "Google",
NAVER: "NAVER",
@@ -99,7 +99,7 @@ interface IEditPlatformBudgetModalProps {
isOpen: boolean;
onClose: () => void;
onClosed?: () => void;
- budget: IPlatformProjectBudget | null;
+ budget: IPlatformBudgetSummary | null;
orgId: number;
projectId: number;
}
@@ -112,7 +112,7 @@ export default function EditPlatformBudgetModal({
orgId,
projectId,
}: IEditPlatformBudgetModalProps) {
- const budgetRef = useRef(null);
+ const budgetRef = useRef(null);
if (budget) budgetRef.current = budget;
const activeBudget = budget ?? budgetRef.current;
@@ -184,12 +184,12 @@ export default function EditPlatformBudgetModal({
onExitComplete={onClosed}
size="md"
padding="lg"
- title={`${PROVIDER_LABEL[activeBudget.providerType]} 예산 수정`}
+ title={`${PROVIDER_LABEL[activeBudget.provider]} 예산 수정`}
disableOverlayClick={isPending}
>
- {PROVIDER_LABEL[activeBudget.providerType]} 예산 수정
+ {PROVIDER_LABEL[activeBudget.provider]} 예산 수정
{activeBudget.adCampaignName ? (
@@ -201,7 +201,7 @@ export default function EditPlatformBudgetModal({
-
-
-
);
}
@@ -60,9 +54,6 @@ export function CampaignTableSkeleton() {
-
-
-
diff --git a/src/hooks/ads/useUpdatePlatformBudget.ts b/src/hooks/ads/useUpdatePlatformBudget.ts
index 98bd697a..7f8eebb8 100644
--- a/src/hooks/ads/useUpdatePlatformBudget.ts
+++ b/src/hooks/ads/useUpdatePlatformBudget.ts
@@ -41,7 +41,7 @@ export function useUpdatePlatformBudget(orgId: number, projectId: number) {
throw new Error("일일 예산을 입력해 주세요.");
}
if (
- vars.activeBudgetType === "LIFETIME" &&
+ vars.activeBudgetType === "TOTAL" &&
vars.lifetimeBudget === undefined
) {
throw new Error("전체 예산을 입력해 주세요.");
@@ -63,7 +63,7 @@ export function useUpdatePlatformBudget(orgId: number, projectId: number) {
throw new Error("일일 예산을 입력해 주세요.");
}
if (
- vars.activeBudgetType === "LIFETIME" &&
+ vars.activeBudgetType === "TOTAL" &&
vars.lifetimeBudget === undefined
) {
throw new Error("전체 예산을 입력해 주세요.");
diff --git a/src/hooks/setting/useSettingNotifications.ts b/src/hooks/setting/useSettingNotifications.ts
index f532a6ec..a30cebab 100644
--- a/src/hooks/setting/useSettingNotifications.ts
+++ b/src/hooks/setting/useSettingNotifications.ts
@@ -86,6 +86,7 @@ export default function useSettingNotifications() {
(isNotificationLoading || isNotificationRefetching);
const buildOrgBody = (
+ alerts: { alertClicks: boolean; alertReport: boolean },
overrides: Partial = {},
): IUpdateOrgNotificationSettingsRequest => ({
isSlackEnabled: savedOrgNotif.slackEnabled,
@@ -94,8 +95,8 @@ export default function useSettingNotifications() {
isDiscordEnabled: savedOrgNotif.discordEnabled,
discordWebhookUrl: "",
disconnectDiscord: false,
- alertClicks: savedWorkspaceNotif.clickAlarm ?? false,
- alertReport: savedWorkspaceNotif.weeklyReport ?? false,
+ alertClicks: alerts.alertClicks,
+ alertReport: alerts.alertReport,
...overrides,
});
@@ -121,11 +122,17 @@ export default function useSettingNotifications() {
setPendingOrgAction("slack");
try {
await updateOrg.mutateAsync(
- buildOrgBody({
- isSlackEnabled: true,
- slackWebhookUrl: url,
- disconnectSlack: false,
- }),
+ buildOrgBody(
+ {
+ alertClicks: draftWorkspaceNotif.clickAlarm,
+ alertReport: draftWorkspaceNotif.weeklyReport,
+ },
+ {
+ isSlackEnabled: true,
+ slackWebhookUrl: url,
+ disconnectSlack: false,
+ },
+ ),
);
toast.success("슬랙이 연동되었습니다");
setSlackWebhookUrl("");
@@ -142,11 +149,17 @@ export default function useSettingNotifications() {
setPendingOrgAction("slack");
try {
await updateOrg.mutateAsync(
- buildOrgBody({
- isSlackEnabled: false,
- slackWebhookUrl: "",
- disconnectSlack: true,
- }),
+ buildOrgBody(
+ {
+ alertClicks: draftWorkspaceNotif.clickAlarm,
+ alertReport: draftWorkspaceNotif.weeklyReport,
+ },
+ {
+ isSlackEnabled: false,
+ slackWebhookUrl: "",
+ disconnectSlack: true,
+ },
+ ),
);
toast.success("슬랙 연동이 해제되었습니다");
} catch (e) {
@@ -163,19 +176,35 @@ export default function useSettingNotifications() {
setDiscordWebhookError("Webhook URL을 입력해주세요");
return;
}
- if (!url.startsWith("https://")) {
+ let parsed: URL;
+ try {
+ parsed = new URL(url);
+ } catch {
setDiscordWebhookError("올바른 URL 형식으로 입력해주세요");
return;
}
-
+ const DISCORD_HOSTS = ["discord.com", "discordapp.com"];
+ if (
+ parsed.protocol !== "https:" ||
+ !DISCORD_HOSTS.includes(parsed.hostname)
+ ) {
+ setDiscordWebhookError("디스코드 Webhook URL을 입력해주세요");
+ return;
+ }
setPendingOrgAction("discord");
try {
await updateOrg.mutateAsync(
- buildOrgBody({
- isDiscordEnabled: true,
- discordWebhookUrl: url,
- disconnectDiscord: false,
- }),
+ buildOrgBody(
+ {
+ alertClicks: draftWorkspaceNotif.clickAlarm,
+ alertReport: draftWorkspaceNotif.weeklyReport,
+ },
+ {
+ isDiscordEnabled: true,
+ discordWebhookUrl: url,
+ disconnectDiscord: false,
+ },
+ ),
);
toast.success("디스코드가 연동되었습니다");
setDiscordWebhookUrl("");
@@ -192,11 +221,17 @@ export default function useSettingNotifications() {
setPendingOrgAction("discord");
try {
await updateOrg.mutateAsync(
- buildOrgBody({
- isDiscordEnabled: false,
- discordWebhookUrl: "",
- disconnectDiscord: true,
- }),
+ buildOrgBody(
+ {
+ alertClicks: draftWorkspaceNotif.clickAlarm,
+ alertReport: draftWorkspaceNotif.weeklyReport,
+ },
+ {
+ isDiscordEnabled: false,
+ discordWebhookUrl: "",
+ disconnectDiscord: true,
+ },
+ ),
);
toast.success("디스코드 연동이 해제되었습니다");
} catch (e) {
diff --git a/src/hooks/setting/useSettingSave.ts b/src/hooks/setting/useSettingSave.ts
index a1fa0ca3..f661fa43 100644
--- a/src/hooks/setting/useSettingSave.ts
+++ b/src/hooks/setting/useSettingSave.ts
@@ -131,14 +131,20 @@ export default function useSettingSave({
}
if (shouldSaveOrg) {
await notifications.updateOrg.mutateAsync(
- notifications.buildOrgBody({
- isSlackEnabled: notifications.draftOrgNotif.slackEnabled,
- slackWebhookUrl: "",
- disconnectSlack: false,
- isDiscordEnabled: notifications.draftOrgNotif.discordEnabled,
- discordWebhookUrl: "",
- disconnectDiscord: false,
- }),
+ notifications.buildOrgBody(
+ {
+ alertClicks: notifications.draftWorkspaceNotif.clickAlarm,
+ alertReport: notifications.draftWorkspaceNotif.weeklyReport,
+ },
+ {
+ isSlackEnabled: notifications.draftOrgNotif.slackEnabled,
+ slackWebhookUrl: "",
+ disconnectSlack: false,
+ isDiscordEnabled: notifications.draftOrgNotif.discordEnabled,
+ discordWebhookUrl: "",
+ disconnectDiscord: false,
+ },
+ ),
);
notifications.setSavedOrgNotif((prev) => ({
...prev,
diff --git a/src/pages/ads/list/CampaignDetail.tsx b/src/pages/ads/list/CampaignDetail.tsx
index c6c2ab6a..cc5a464e 100644
--- a/src/pages/ads/list/CampaignDetail.tsx
+++ b/src/pages/ads/list/CampaignDetail.tsx
@@ -1,10 +1,11 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useOutletContext, useParams } from "react-router-dom";
-import type { IPlatformProjectBudget, TPlatform } from "@/types/ads/campaign";
+import type { IPlatformBudgetSummary, TPlatform } from "@/types/ads/campaign";
import { AD_PLATFORM_ORDER, groupAdsByPlatform } from "@/utils/ads/adPlatform";
import {
+ groupPlatformBudgetsByPlatform,
providerTypeToPlatform,
resolvePlatformBudgets,
} from "@/utils/ads/projectBudget";
@@ -79,7 +80,7 @@ export default function CampaignDetail() {
const [resumeScope, setResumeScope] = useState<"selection" | "all">("all");
const [budgetEditTarget, setBudgetEditTarget] =
- useState(null);
+ useState(null);
const [isBudgetEditOpen, setIsBudgetEditOpen] = useState(false);
const clearAdSelection = useCallback(() => {
@@ -193,13 +194,10 @@ export default function CampaignDetail() {
[data?.providers, data?.platformBudgets],
);
- const budgetByPlatform = useMemo(() => {
- const map = new Map();
- for (const budget of platformBudgets) {
- map.set(providerTypeToPlatform(budget.providerType), budget);
- }
- return map;
- }, [platformBudgets]);
+ const budgetsByPlatform = useMemo(
+ () => groupPlatformBudgetsByPlatform(platformBudgets),
+ [platformBudgets],
+ );
const platformSections = useMemo(() => {
if (!data) return [];
@@ -207,9 +205,9 @@ export default function CampaignDetail() {
const fromAds = groupAdsByPlatform(adsList, data.providers);
const seen = new Set(fromAds.map((section) => section.platform));
- // 광고 0개여도 예산 mock/API 있으면 섹션 표시
+ // 광고 0개여도 예산 API 있으면 섹션 표시
for (const budget of platformBudgets) {
- const platform = providerTypeToPlatform(budget.providerType);
+ const platform = providerTypeToPlatform(budget.provider);
if (!seen.has(platform)) {
fromAds.push({ platform, ads: [] });
seen.add(platform);
@@ -365,13 +363,10 @@ export default function CampaignDetail() {
>
{
- const budget = budgetByPlatform.get(platform);
- if (budget) {
- setBudgetEditTarget(budget);
- setIsBudgetEditOpen(true);
- }
+ platformBudgets={budgetsByPlatform.get(platform)}
+ onEditBudget={(budget) => {
+ setBudgetEditTarget(budget);
+ setIsBudgetEditOpen(true);
}}
/>
{platformAds.length > 0 ? (
diff --git a/src/types/ads/budget.ts b/src/types/ads/budget.ts
index 4019609b..c3aa8880 100644
--- a/src/types/ads/budget.ts
+++ b/src/types/ads/budget.ts
@@ -19,10 +19,10 @@ export interface INaverBudgetUpdateRequest {
dailyBudget: number;
}
-/** 일일 / 총 예산 구분 — 상세 API·수정 응답 공통 */
-export type TPlatformBudgetType = "DAILY" | "LIFETIME";
+/** 일일 / 총 예산 구분 — 상세·대시보드·수정 응답 공통 (OpenAPI: DAILY | TOTAL) */
+export type TPlatformBudgetType = "DAILY" | "TOTAL";
-/** Meta 상세 platformBudgets.activeBudgetType */
+/** @deprecated TPlatformBudgetType 사용 */
export type TMetaActiveBudgetType = TPlatformBudgetType;
/**
diff --git a/src/types/ads/campaign.ts b/src/types/ads/campaign.ts
index c35bb128..449df11c 100644
--- a/src/types/ads/campaign.ts
+++ b/src/types/ads/campaign.ts
@@ -1,26 +1,30 @@
import type { TPlatformBudgetType } from "@/types/ads/budget";
-import type { IBudgetAmountSlice } from "@/types/dashboard/common";
export type TPlatform = "meta" | "google" | "naver"; //UI
export type TProvider = "META" | "GOOGLE" | "NAVER"; //API
export type TStatus = "ON_GOING" | "PAUSED" | "OVER";
-/** project 상세 — 플랫폼(매체 캠페인) 단위 예산 */
-export interface IPlatformProjectBudget {
- providerType: TProvider;
- /** Meta / Google 예산 수정 path param */
- adCampaignId?: number;
+/**
+ * GET /api/project/{orgId}/{projectId} — platformBudgets[] 항목
+ * OpenAPI: PlatformBudgetSummary
+ */
+export interface INaverBudgetTarget {
+ connectionId: number;
+ campaignId: string;
+}
+
+export interface IPlatformBudgetSummary {
+ provider: TProvider;
+ budgetType: TPlatformBudgetType;
+ budget: number;
+ spend: number;
+ remainingBudget: number;
+ remainingPercentage: number;
+ /** Meta / Google 예산 수정 path */
+ adCampaignId?: number | null;
adCampaignName?: string;
- lifetime: IBudgetAmountSlice;
- /** Meta / Google — activeBudgetType에 따라 표시
- * Naver — 일일 예산 */
- daily?: IBudgetAmountSlice | null;
- /** Meta / Google — 기존 daily / lifetime 중 어떤 유형인지 */
- activeBudgetType?: TPlatformBudgetType;
- /** Naver 일일 예산 수정 — /api/naver/{connectionId}/campaigns/{campaignId}/budget */
- naverConnectionId?: number;
- naverCampaignId?: string;
- /** 소유자 등 수정 가능 여부 */
+ /** Naver 예산 수정 path */
+ naverBudgetTarget?: INaverBudgetTarget | null;
canEditBudget?: boolean;
}
@@ -60,7 +64,7 @@ export interface ICampaignDetail extends ICampaign {
budget: number;
createdAt: string;
ads: IAd[];
- platformBudgets?: IPlatformProjectBudget[];
+ platformBudgets?: IPlatformBudgetSummary[];
}
export interface IPlatformCampaign {
diff --git a/src/types/dashboard/budget.ts b/src/types/dashboard/budget.ts
index 6bca3904..d443c306 100644
--- a/src/types/dashboard/budget.ts
+++ b/src/types/dashboard/budget.ts
@@ -1,9 +1,5 @@
-/** 게이지 라벨 */
-export type TBudgetGaugeLabel =
- | "전체 예산"
- | "일일 예산"
- | "Google·Meta"
- | "NAVER";
+/** 게이지 라벨 — groups.budgetType(TOTAL/DAILY) 매핑 */
+export type TBudgetGaugeLabel = "전체 예산" | "일일 예산";
/** 게이지 1개 분량 */
export interface IBudgetSlice {
diff --git a/src/types/dashboard/common.ts b/src/types/dashboard/common.ts
index f8d861a6..7f7fead9 100644
--- a/src/types/dashboard/common.ts
+++ b/src/types/dashboard/common.ts
@@ -1,3 +1,5 @@
+import type { TProviderType } from "./provider";
+
// 공통 지표 응답 (overview/platform 공유)
export interface IMetricsResponse {
clicks: number;
@@ -10,25 +12,37 @@ export interface IMetricsResponse {
ROASChangeRate: number;
}
-// 예산 금액 단위 (API 분리 응답)
+// 예산 금액 단위
export interface IBudgetAmountSlice {
totalBudget: number;
totalSpend: number;
}
-// 예산 소진 현황
-export interface IBudgetResponse {
- providerType: string;
- usagePercentage: number;
- totalBudget: number;
- totalSpend: number;
+/** 대시보드 예산 group.budgetType */
+export type TDashboardBudgetType = "TOTAL" | "DAILY";
+
+/** 대시보드 예산 group.detail */
+export interface IBudgetGroupDetail {
+ budget: number;
+ spend: number;
remainingBudget: number;
- /** 통합 대시보드 — Google·Meta / Naver 분리 */
- googleMeta?: IBudgetAmountSlice;
- naver?: IBudgetAmountSlice;
- /** 플랫폼 Google/Meta — 전체 / 일일 분리 */
- lifetime?: IBudgetAmountSlice;
- daily?: IBudgetAmountSlice;
+ /** 0~100 */
+ remainingPercentage: number;
+ estimated: boolean;
+}
+
+/** 대시보드 예산 groups[] 항목 */
+export interface IBudgetGroup {
+ budgetType: TDashboardBudgetType;
+ providers: TProviderType[];
+ detail: IBudgetGroupDetail;
+}
+
+/** GET /api/dashboard/budgets 응답 data */
+export interface IBudgetResponse {
+ /** 통합: "ALL", 플랫폼: GOOGLE | META | NAVER */
+ providerType: TProviderType | "ALL";
+ groups: IBudgetGroup[];
}
// ROAS 순위 항목
diff --git a/src/utils/ads/budgetEdit.ts b/src/utils/ads/budgetEdit.ts
index 9f6a27e6..f459e560 100644
--- a/src/utils/ads/budgetEdit.ts
+++ b/src/utils/ads/budgetEdit.ts
@@ -5,7 +5,7 @@ import type {
TMetaGoogleBudgetUpdateRequest,
TPlatformBudgetType,
} from "@/types/ads/budget";
-import type { IPlatformProjectBudget } from "@/types/ads/campaign";
+import type { IPlatformBudgetSummary } from "@/types/ads/campaign";
/** 예산 수정 불가 사유 — canSubmitPlatformBudgetEdit 반환값 */
export type TBudgetEditBlockReason =
@@ -36,7 +36,7 @@ export const dailyBudgetFormSchema = z.object({
dailyBudget: positiveBudget,
});
-/** Meta / Google LIFETIME */
+/** Meta / Google TOTAL — 요청 body 필드명은 lifetimeBudget */
export const lifetimeBudgetFormSchema = z.object({
lifetimeBudget: positiveBudget,
});
@@ -72,51 +72,47 @@ export interface IEffectivePlatformBudget {
}
/**
- * activeBudgetType + daily/lifetime 데이터 존재 여부로 실제 활성 예산 결정
- * — DAILY인데 daily 없으면 LIFETIME fallback
+ * PlatformBudgetSummary row → 수정/표시용 effective 예산
+ * — TOTAL 요청 body는 Meta/Google lifetimeBudget 필드 유지
*/
export function resolveEffectivePlatformBudget(
- budget: IPlatformProjectBudget,
+ budget: IPlatformBudgetSummary,
): IEffectivePlatformBudget {
- if (budget.providerType === "NAVER") {
- const daily = budget.daily ?? { totalBudget: 0, totalSpend: 0 };
+ if (budget.provider === "NAVER") {
return {
- activeBudgetType: "DAILY",
+ activeBudgetType: budget.budgetType,
fieldName: "dailyBudget",
- label: "일일 예산",
- totalBudget: daily.totalBudget,
- totalSpend: daily.totalSpend,
+ label: budget.budgetType === "TOTAL" ? "전체 예산" : "일일 예산",
+ totalBudget: budget.budget,
+ totalSpend: budget.spend,
};
}
- const declaredType =
- budget.activeBudgetType ?? (budget.daily ? "DAILY" : "LIFETIME");
-
- if (declaredType === "DAILY" && budget.daily) {
+ if (budget.budgetType === "DAILY") {
return {
activeBudgetType: "DAILY",
fieldName: "dailyBudget",
label: "일일 예산",
- totalBudget: budget.daily.totalBudget,
- totalSpend: budget.daily.totalSpend,
+ totalBudget: budget.budget,
+ totalSpend: budget.spend,
};
}
return {
- activeBudgetType: "LIFETIME",
+ activeBudgetType: "TOTAL",
fieldName: "lifetimeBudget",
label: "전체 예산",
- totalBudget: budget.lifetime.totalBudget,
- totalSpend: budget.lifetime.totalSpend,
+ totalBudget: budget.budget,
+ totalSpend: budget.spend,
};
}
/**
* platformBudget → API 호출 가능 여부
- * BE 필드 없거나 mock이면 ok: false → 수정 버튼 disabled
+ * OpenAPI PlatformBudgetSummary에 수정용 ID가 없으면 disabled
*/
export function canSubmitPlatformBudgetEdit(
- budget: IPlatformProjectBudget | undefined,
+ budget: IPlatformBudgetSummary | undefined,
): { ok: true } | { ok: false; reason: TBudgetEditBlockReason } {
if (!budget) {
return { ok: false, reason: "MISSING_PLATFORM_BUDGET" };
@@ -126,34 +122,33 @@ export function canSubmitPlatformBudgetEdit(
return { ok: false, reason: "NOT_EDITABLE" };
}
- switch (budget.providerType) {
+ switch (budget.provider) {
case "META":
- if (!budget.adCampaignId || !budget.activeBudgetType) {
+ if (!budget.adCampaignId) {
return { ok: false, reason: "MISSING_META_CONTEXT" };
}
return { ok: true };
case "GOOGLE":
- if (!budget.adCampaignId || !budget.activeBudgetType) {
+ if (!budget.adCampaignId) {
return { ok: false, reason: "MISSING_GOOGLE_CONTEXT" };
}
return { ok: true };
- case "NAVER":
- if (!budget.naverConnectionId || !budget.naverCampaignId) {
+ case "NAVER": {
+ const target = budget.naverBudgetTarget;
+ if (!target?.connectionId || !target.campaignId) {
return { ok: false, reason: "MISSING_NAVER_CONTEXT" };
}
- if (!budget.daily) {
- return { ok: false, reason: "MISSING_PLATFORM_BUDGET" };
- }
return { ok: true };
+ }
default:
return { ok: false, reason: "MISSING_PLATFORM_BUDGET" };
}
}
-/** Meta / Google — activeBudgetType에 맞는 필드 하나만 body에 */
+/** Meta / Google — budgetType에 맞는 필드 하나만 body에 */
export function buildMetaGoogleBudgetPayload(
activeBudgetType: TPlatformBudgetType,
values: { dailyBudget?: number; lifetimeBudget?: number },
@@ -179,10 +174,10 @@ export function buildNaverBudgetPayload(
}
/**
- * 모달 — provider + activeBudgetType에 맞는 zod schema
+ * 모달 — provider + budgetType에 맞는 zod schema
*/
-export function resolveBudgetEditFormSchema(budget: IPlatformProjectBudget) {
- if (budget.providerType === "NAVER") {
+export function resolveBudgetEditFormSchema(budget: IPlatformBudgetSummary) {
+ if (budget.provider === "NAVER") {
return naverBudgetFormSchema;
}
@@ -193,7 +188,7 @@ export function resolveBudgetEditFormSchema(budget: IPlatformProjectBudget) {
}
/** 모달 input — 필드명·라벨 (게이지 라벨과 동일) */
-export function resolveBudgetEditFieldMeta(budget: IPlatformProjectBudget): {
+export function resolveBudgetEditFieldMeta(budget: IPlatformBudgetSummary): {
fieldName: "dailyBudget" | "lifetimeBudget";
label: string;
} {
@@ -203,7 +198,7 @@ export function resolveBudgetEditFieldMeta(budget: IPlatformProjectBudget): {
/** platformBudget → 폼 defaultValues */
export function resolveBudgetEditDefaultValues(
- budget: IPlatformProjectBudget,
+ budget: IPlatformBudgetSummary,
): TBudgetEditModalFormValues {
const { fieldName, totalBudget } = resolveEffectivePlatformBudget(budget);
@@ -216,10 +211,10 @@ export function resolveBudgetEditDefaultValues(
/** budget + form values → mutation variables */
export function buildUpdatePlatformBudgetVariables(
- budget: IPlatformProjectBudget,
+ budget: IPlatformBudgetSummary,
values: TBudgetEditModalFormValues,
): {
- providerType: IPlatformProjectBudget["providerType"];
+ providerType: IPlatformBudgetSummary["provider"];
adCampaignId?: number;
activeBudgetType?: TPlatformBudgetType;
naverConnectionId?: number;
@@ -228,13 +223,14 @@ export function buildUpdatePlatformBudgetVariables(
lifetimeBudget?: number;
} {
const { activeBudgetType } = resolveEffectivePlatformBudget(budget);
+ const naverTarget = budget.naverBudgetTarget;
const base = {
- providerType: budget.providerType,
- adCampaignId: budget.adCampaignId,
+ providerType: budget.provider,
+ adCampaignId: budget.adCampaignId ?? undefined,
activeBudgetType,
- naverConnectionId: budget.naverConnectionId,
- naverCampaignId: budget.naverCampaignId,
+ naverConnectionId: naverTarget?.connectionId,
+ naverCampaignId: naverTarget?.campaignId,
};
if (values.lifetimeBudget !== undefined) {
diff --git a/src/utils/ads/projectBudget.ts b/src/utils/ads/projectBudget.ts
index b5e87542..855fa436 100644
--- a/src/utils/ads/projectBudget.ts
+++ b/src/utils/ads/projectBudget.ts
@@ -1,16 +1,24 @@
+import type { TPlatformBudgetType } from "@/types/ads/budget";
import type {
- IPlatformProjectBudget,
+ IPlatformBudgetSummary,
TPlatform,
TProvider,
} from "@/types/ads/campaign";
import type { IBudgetGaugeProps } from "@/types/dashboard/budget";
-import { resolveEffectivePlatformBudget } from "@/utils/ads/budgetEdit";
import {
buildBudgetGaugesFromSlices,
supportsDailyBudget,
} from "@/utils/dashboard/budget";
+const BUDGET_TYPE_LABEL: Record<
+ TPlatformBudgetType,
+ "전체 예산" | "일일 예산"
+> = {
+ TOTAL: "전체 예산",
+ DAILY: "일일 예산",
+};
+
export function providerTypeToPlatform(
provider: TProvider | string,
): TPlatform {
@@ -25,67 +33,139 @@ export function platformToProviderType(platform: TPlatform): TProvider {
return platform.toUpperCase() as TProvider;
}
-/** project detail — 플랫폼별 BudgetGaugeChart props */
-export function mapPlatformProjectBudgetToGauges(
- budget: IPlatformProjectBudget,
+/**
+ * 플랫폼별 표시할 budget rows
+ * — Naver: TOTAL 우선, TOTAL 없고 DAILY만 있으면 DAILY 표시
+ */
+export function filterPlatformBudgetSummariesForDisplay(
+ summaries: IPlatformBudgetSummary[],
+): IPlatformBudgetSummary[] {
+ const hasNaverTotal = summaries.some(
+ (row) => row.provider === "NAVER" && row.budgetType === "TOTAL",
+ );
+
+ return summaries.filter((row) => {
+ if (row.provider !== "NAVER") return true;
+ if (row.budgetType === "TOTAL") return true;
+ // TOTAL이 있을 때만 DAILY 숨김 (둘 다 오면 전체만)
+ if (row.budgetType === "DAILY") return !hasNaverTotal;
+ return true;
+ });
+}
+
+/** project detail — 플랫폼별 게이지 props */
+export function mapPlatformBudgetSummariesToGauges(
+ summaries: IPlatformBudgetSummary[],
): IBudgetGaugeProps[] {
- const effective = resolveEffectivePlatformBudget(budget);
+ const visible = filterPlatformBudgetSummariesForDisplay(summaries);
return buildBudgetGaugesFromSlices(
- [
- {
- label: effective.label,
- totalBudget: effective.totalBudget,
- spent: effective.totalSpend,
- },
- ],
+ visible.map((row) => ({
+ label: BUDGET_TYPE_LABEL[row.budgetType],
+ totalBudget: row.budget,
+ spent: row.spend,
+ })),
{ showInsight: true },
);
}
-/** API 미준비 시 dev placeholder */
+/** 수정 모달용 — 플랫폼에서 대표 row 선택 */
+export function pickEditablePlatformBudget(
+ summaries: IPlatformBudgetSummary[],
+): IPlatformBudgetSummary | null {
+ if (summaries.length === 0) return null;
+
+ const provider = summaries[0]?.provider;
+ if (provider === "NAVER") {
+ return (
+ summaries.find((row) => row.budgetType === "TOTAL") ??
+ summaries[0] ??
+ null
+ );
+ }
+
+ return (
+ summaries.find((row) => row.budgetType === "DAILY") ??
+ summaries.find((row) => row.budgetType === "TOTAL") ??
+ summaries[0] ??
+ null
+ );
+}
+
+/** API 미준비 시(platformBudgets 필드 자체 없음) dev placeholder */
export function buildPlaceholderPlatformBudgets(
providers: TPlatform[],
-): IPlatformProjectBudget[] {
- return providers.map((platform, index) => {
- const providerType = platformToProviderType(platform);
+): IPlatformBudgetSummary[] {
+ const rows: IPlatformBudgetSummary[] = [];
+
+ providers.forEach((platform, index) => {
+ const provider = platformToProviderType(platform);
const totalBudget = 1_000_000 + index * 200_000;
const totalSpend = Math.round(totalBudget * (0.2 + index * 0.15));
- const item: IPlatformProjectBudget = {
- providerType,
- adCampaignId: 1000 + index,
- adCampaignName: `${providerType} 매체 캠페인 (mock)`,
- lifetime: { totalBudget, totalSpend },
- };
-
- if (providerType === "NAVER") {
- item.daily = {
- totalBudget: 50_000,
- totalSpend: 12_000 + index * 2_000,
- };
- item.naverConnectionId = 1;
- item.naverCampaignId = `mock-campaign-${index}`;
- item.canEditBudget = true;
- } else if (supportsDailyBudget(providerType)) {
- item.daily = {
- totalBudget: 50_000,
- totalSpend: 12_000 + index * 2_000,
- };
- item.activeBudgetType = index % 2 === 0 ? "DAILY" : "LIFETIME";
- item.canEditBudget = true;
- }
+ rows.push({
+ provider,
+ budgetType: "TOTAL",
+ budget: totalBudget,
+ spend: totalSpend,
+ remainingBudget: Math.max(0, totalBudget - totalSpend),
+ remainingPercentage:
+ totalBudget > 0
+ ? Math.round(((totalBudget - totalSpend) / totalBudget) * 100)
+ : 100,
+ ...(provider === "NAVER"
+ ? {
+ naverBudgetTarget: {
+ connectionId: 1,
+ campaignId: `mock-campaign-${index}`,
+ },
+ }
+ : { adCampaignId: 1000 + index }),
+ adCampaignName: `${provider} 매체 캠페인 (mock)`,
+ canEditBudget: false,
+ });
- return item;
+ if (supportsDailyBudget(provider)) {
+ const dailyBudget = 50_000;
+ const dailySpend = 12_000 + index * 2_000;
+ rows.push({
+ provider,
+ budgetType: "DAILY",
+ budget: dailyBudget,
+ spend: dailySpend,
+ remainingBudget: Math.max(0, dailyBudget - dailySpend),
+ remainingPercentage: Math.round(
+ ((dailyBudget - dailySpend) / dailyBudget) * 100,
+ ),
+ adCampaignId: 1000 + index,
+ adCampaignName: `${provider} 매체 캠페인 (mock)`,
+ canEditBudget: false,
+ });
+ }
});
+
+ return rows;
}
-/** API 응답 + fallback (platformBudgets 없으면 mock) */
+/** API 응답 우선 — 필드가 있으면(빈 배열 포함) 그대로 사용 */
export function resolvePlatformBudgets(input: {
providers: TPlatform[];
- platformBudgets?: IPlatformProjectBudget[];
-}): IPlatformProjectBudget[] {
- if (input.platformBudgets?.length) return input.platformBudgets;
+ platformBudgets?: IPlatformBudgetSummary[];
+}): IPlatformBudgetSummary[] {
+ if (input.platformBudgets !== undefined) return input.platformBudgets;
if (input.providers.length === 0) return [];
return buildPlaceholderPlatformBudgets(input.providers);
}
+
+export function groupPlatformBudgetsByPlatform(
+ budgets: IPlatformBudgetSummary[],
+): Map {
+ const map = new Map();
+ for (const budget of budgets) {
+ const platform = providerTypeToPlatform(budget.provider);
+ const list = map.get(platform) ?? [];
+ list.push(budget);
+ map.set(platform, list);
+ }
+ return map;
+}
diff --git a/src/utils/dashboard/budget.ts b/src/utils/dashboard/budget.ts
index 9b2cd5b8..8d233ca9 100644
--- a/src/utils/dashboard/budget.ts
+++ b/src/utils/dashboard/budget.ts
@@ -4,8 +4,9 @@ import type {
IBudgetViewModel,
} from "@/types/dashboard/budget";
import type {
- IBudgetAmountSlice,
+ IBudgetGroup,
IBudgetResponse,
+ TDashboardBudgetType,
} from "@/types/dashboard/common";
import type { TProviderType } from "@/types/dashboard/provider";
@@ -15,7 +16,12 @@ const DANGER_THRESHOLD = 75;
/** BudgetGaugeChart showInsight 기본값 */
export const SHOW_BUDGET_GAUGE_INSIGHT = true;
-/** Google/Meta 플랫폼 — 일일 예산 게이지 */
+const BUDGET_TYPE_LABEL: Record = {
+ TOTAL: "전체 예산",
+ DAILY: "일일 예산",
+};
+
+/** Google/Meta 플랫폼 — 일일 예산 게이지 (skeleton·ads용) */
export function supportsDailyBudget(provider?: TProviderType): boolean {
return provider === "GOOGLE" || provider === "META";
}
@@ -54,26 +60,22 @@ export const statusPointClasses: Record = {
위험: "bg-info-red",
};
-function toAmountSlice(
- data: IBudgetResponse,
- slice?: IBudgetAmountSlice,
-): IBudgetAmountSlice {
- return (
- slice ?? {
- totalBudget: data.totalBudget,
- totalSpend: data.totalSpend,
- }
- );
+/** Naver 플랫폼만 DAILY 숨김 (통합 ALL은 TOTAL+DAILY 그대로) */
+function filterBudgetGroups(
+ groups: IBudgetGroup[],
+ provider?: TProviderType,
+): IBudgetGroup[] {
+ if (provider === "NAVER") {
+ return groups.filter((group) => group.budgetType === "TOTAL");
+ }
+ return groups;
}
-function toBudgetSlice(
- label: IBudgetSlice["label"],
- amount: IBudgetAmountSlice,
-): IBudgetSlice {
+function toBudgetSliceFromGroup(group: IBudgetGroup): IBudgetSlice {
return {
- label,
- totalBudget: amount.totalBudget,
- spent: amount.totalSpend,
+ label: BUDGET_TYPE_LABEL[group.budgetType],
+ totalBudget: group.detail.budget,
+ spent: group.detail.spend,
};
}
@@ -90,53 +92,21 @@ function toGaugeProps(
};
}
-/** 통합: Google·Meta + Naver (전체 예산 각 1개) */
-function mapOverviewBudgetViewModel(data: IBudgetResponse): IBudgetViewModel {
- const googleMeta = toAmountSlice(data, data.googleMeta);
- const naver = data.naver ?? { totalBudget: 0, totalSpend: 0 };
-
- return {
- slices: [
- toBudgetSlice("Google·Meta", googleMeta),
- toBudgetSlice("NAVER", naver),
- ],
- };
-}
-
-/** 플랫폼: Google/Meta → 전체+일일, Naver → 전체만 */
-function mapPlatformBudgetViewModel(
- data: IBudgetResponse,
- provider: TProviderType,
-): IBudgetViewModel {
- const lifetime = toBudgetSlice(
- "전체 예산",
- toAmountSlice(data, data.lifetime),
- );
-
- if (!supportsDailyBudget(provider)) {
- return { slices: [lifetime] };
- }
-
- const daily = toBudgetSlice("일일 예산", toAmountSlice(data, data.daily));
-
- return { slices: [lifetime, daily] };
-}
-
/**
- * API → UI 변환 (API 스펙 변경 시 여기만 수정)
+ * API → UI 변환
*
- * [통합] googleMeta + naver (legacy: googleMeta만 totalBudget/totalSpend fallback)
- * [Google/Meta] lifetime + daily
- * [Naver] lifetime만
+ * [통합] groups 그대로 (TOTAL + DAILY)
+ * [Google/Meta] groups 그대로
+ * [Naver] TOTAL만
*/
export function mapBudgetResponseToViewModel(
data: IBudgetResponse,
provider?: TProviderType,
): IBudgetViewModel {
- if (provider === undefined) {
- return mapOverviewBudgetViewModel(data);
- }
- return mapPlatformBudgetViewModel(data, provider);
+ const groups = filterBudgetGroups(data.groups, provider);
+ return {
+ slices: groups.map(toBudgetSliceFromGroup),
+ };
}
/** useBudget select에서 쓸 최종 형태 */