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에서 쓸 최종 형태 */