diff --git a/src/components/auth/common/CommonAuthInput.tsx b/src/components/auth/common/CommonAuthInput.tsx index c94eb0a2..0bcc6b7a 100644 --- a/src/components/auth/common/CommonAuthInput.tsx +++ b/src/components/auth/common/CommonAuthInput.tsx @@ -1,6 +1,6 @@ import React, { useState } from "react"; -import formatPhoneNumber from "@/utils/formatPhoneNumber"; +import formatPhoneNumber from "@/utils/auth/formatPhoneNumber"; import Input, { type IInputProps } from "@/components/common/input/Input"; diff --git a/src/components/auth/common/PasswordForm.tsx b/src/components/auth/common/PasswordForm.tsx index 1512c2d7..3a68da6e 100644 --- a/src/components/auth/common/PasswordForm.tsx +++ b/src/components/auth/common/PasswordForm.tsx @@ -3,7 +3,7 @@ import { type SubmitHandler, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import type { z } from "zod"; -import { signupPasswordSchema } from "@/utils/validation"; +import { signupPasswordSchema } from "@/utils/auth/validation"; import AuthFormShell from "@/components/auth/common/AuthFormShell"; import CommonAuthInput from "@/components/auth/common/CommonAuthInput"; diff --git a/src/components/auth/flows/find-email/EnterPhoneStep.tsx b/src/components/auth/flows/find-email/EnterPhoneStep.tsx index 45507432..7e49f7e5 100644 --- a/src/components/auth/flows/find-email/EnterPhoneStep.tsx +++ b/src/components/auth/flows/find-email/EnterPhoneStep.tsx @@ -5,8 +5,8 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { toast } from "sonner"; import type { z } from "zod"; -import { stripPhoneHyphens } from "@/utils/formatPhoneNumber"; -import { findEmailSchema } from "@/utils/validation"; +import { stripPhoneHyphens } from "@/utils/auth/formatPhoneNumber"; +import { findEmailSchema } from "@/utils/auth/validation"; import { useAuth } from "@/hooks/auth/useAuth"; import { useTimer } from "@/hooks/common/useTimer"; diff --git a/src/components/auth/flows/find-email/ShowEmailResultStep.tsx b/src/components/auth/flows/find-email/ShowEmailResultStep.tsx index 7abcba9c..d520bdd3 100644 --- a/src/components/auth/flows/find-email/ShowEmailResultStep.tsx +++ b/src/components/auth/flows/find-email/ShowEmailResultStep.tsx @@ -1,6 +1,6 @@ import { useNavigate } from "react-router-dom"; -import { maskEmail } from "@/utils/maskEmail"; +import { maskEmail } from "@/utils/auth/maskEmail"; import AuthFormShell from "@/components/auth/common/AuthFormShell"; import Button from "@/components/common/button/Button"; diff --git a/src/components/auth/flows/signup/ProfileSetupStep.tsx b/src/components/auth/flows/signup/ProfileSetupStep.tsx index 7146e309..bfd41e34 100644 --- a/src/components/auth/flows/signup/ProfileSetupStep.tsx +++ b/src/components/auth/flows/signup/ProfileSetupStep.tsx @@ -5,8 +5,8 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { toast } from "sonner"; import type { z } from "zod"; -import { stripPhoneHyphens } from "@/utils/formatPhoneNumber"; -import { signupProfileSchema } from "@/utils/validation"; +import { stripPhoneHyphens } from "@/utils/auth/formatPhoneNumber"; +import { signupProfileSchema } from "@/utils/auth/validation"; import { useAuth } from "@/hooks/auth/useAuth"; diff --git a/src/components/dashboard/charts/BudgetGaugeChart.tsx b/src/components/dashboard/charts/BudgetGaugeChart.tsx index 5d72104e..36511223 100644 --- a/src/components/dashboard/charts/BudgetGaugeChart.tsx +++ b/src/components/dashboard/charts/BudgetGaugeChart.tsx @@ -1,6 +1,8 @@ import { memo } from "react"; import { twMerge } from "tailwind-merge"; +import { METRIC_REGISTRY as M } from "@/utils/dashboard/metricRegistry"; + import { useIsMounted } from "@/hooks/common/useIsMounted"; import { type TBadgeVariant } from "@/components/common/badge/Badge"; @@ -118,8 +120,8 @@ const BudgetGaugeChart = memo(function BudgetGaugeChart({
- ₩{spent.toLocaleString()} - ₩{totalBudget.toLocaleString()} + {M.spend.format(spent)} + {M.spend.format(totalBudget)}
@@ -132,7 +134,8 @@ const BudgetGaugeChart = memo(function BudgetGaugeChart({ isOverBudget && "text-info-red", )} > - {isOverBudget ? "-" : ""}₩{remaining.toLocaleString()} + {isOverBudget ? "-" : ""} + {M.spend.format(remaining)} diff --git a/src/components/dashboard/charts/PerformanceEfficiencyChart.tsx b/src/components/dashboard/charts/PerformanceEfficiencyChart.tsx index 9268f76f..9d814efe 100644 --- a/src/components/dashboard/charts/PerformanceEfficiencyChart.tsx +++ b/src/components/dashboard/charts/PerformanceEfficiencyChart.tsx @@ -3,6 +3,8 @@ import { lazy, memo, Suspense, useMemo } from "react"; import type { IPlatformPerformance } from "@/types/dashboard/platform"; import { PLATFORM_MAP } from "@/types/dashboard/provider"; +import { METRIC_REGISTRY as M } from "@/utils/dashboard/metricRegistry"; + import { getMixedChartOptions } from "./performanceEfficiencyChart.config"; const Chart = lazy(() => import("react-apexcharts")); @@ -20,20 +22,20 @@ export const PerformanceEfficiencyChart = memo( const series = [ { - name: "클릭률(CTR)", - type: "column", // 세로 막대 + name: M.ctr.label, + type: "column", data: data.map((d) => d.impressions > 0 ? (d.clicks / d.impressions) * 100 : 0, ), }, { - name: "전환율(CVR)", - type: "column", // 세로 막대 + name: M.conversion.label, + type: "column", data: data.map((d) => d.conversion), }, { - name: "노출수", - type: "line", // 점 + name: M.impressions.label, + type: "line", data: data.map((d) => d.impressions), }, ]; diff --git a/src/components/dashboard/charts/TrafficChart.tsx b/src/components/dashboard/charts/TrafficChart.tsx index cbe0ab79..ef59a738 100644 --- a/src/components/dashboard/charts/TrafficChart.tsx +++ b/src/components/dashboard/charts/TrafficChart.tsx @@ -9,6 +9,7 @@ import { useState, } from "react"; +import { METRIC_REGISTRY as M } from "@/utils/dashboard/metricRegistry"; import { parseMinuteToTimestamp } from "@/utils/dashboard/parseMinuteToTimestamp"; import { useClickStream } from "@/hooks/dashboard/useClickStream"; @@ -198,7 +199,7 @@ const TrafficChart = memo(function TrafficChart() { return { series: [ { - name: "클릭수", + name: M.clicks.label, data: chartData.map(({ x, y }) => ({ x, y })), }, ], diff --git a/src/components/dashboard/charts/budgetGaugeChart.mock.ts b/src/components/dashboard/charts/budgetGaugeChart.mock.ts deleted file mode 100644 index f51e5fab..00000000 --- a/src/components/dashboard/charts/budgetGaugeChart.mock.ts +++ /dev/null @@ -1,14 +0,0 @@ -export interface IBudgetGaugeChartData { - totalBudget: number; // 총 예산 - spent: number; // 현재 소진액 - warningThreshold: number; // 주의 - dangerThreshold: number; // 위험 -} - -// 목업 데이터 -export const budgetGaugeChartMock: IBudgetGaugeChartData = { - totalBudget: 10000000, - spent: 6200000, - warningThreshold: 50, - dangerThreshold: 75, -}; diff --git a/src/components/dashboard/charts/performanceEfficiencyChart.config.ts b/src/components/dashboard/charts/performanceEfficiencyChart.config.ts index 431cda83..648244fe 100644 --- a/src/components/dashboard/charts/performanceEfficiencyChart.config.ts +++ b/src/components/dashboard/charts/performanceEfficiencyChart.config.ts @@ -1,5 +1,7 @@ import type { ApexOptions } from "apexcharts"; +import { METRIC_REGISTRY as M } from "@/utils/dashboard/metricRegistry"; + export const getMixedChartOptions = (categories: string[]): ApexOptions => ({ chart: { type: "line", @@ -44,9 +46,9 @@ export const getMixedChartOptions = (categories: string[]): ApexOptions => ({ }, yaxis: [ { - seriesName: "클릭률(CTR)", + seriesName: M.ctr.label, labels: { - formatter: (val) => `${val.toFixed(1)}%`, + formatter: (val) => M.ctr.format(val), style: { colors: "var(--color-text-muted)", fontSize: "12px", @@ -54,15 +56,15 @@ export const getMixedChartOptions = (categories: string[]): ApexOptions => ({ }, }, { - seriesName: "클릭률(CTR)", + seriesName: M.ctr.label, show: false, }, { opposite: true, - seriesName: "노출수", + seriesName: M.impressions.label, labels: { offsetX: -10, - formatter: (val) => val.toLocaleString("ko-KR"), + formatter: (val) => M.impressions.format(val), style: { colors: "var(--color-text-muted)", fontSize: "12px", @@ -75,10 +77,13 @@ export const getMixedChartOptions = (categories: string[]): ApexOptions => ({ intersect: false, y: { formatter: (val, { seriesIndex }) => { - if (seriesIndex === 0 || seriesIndex === 1) { - return `${val.toFixed(2)}%`; + if (seriesIndex === 0) { + return M.ctr.format(val); + } + if (seriesIndex === 1) { + return M.conversion.format(val); } - return val.toLocaleString("ko-KR"); + return M.impressions.format(val); }, }, }, diff --git a/src/components/dashboard/charts/trafficChart.config.ts b/src/components/dashboard/charts/trafficChart.config.ts index 34b4f72a..972548a9 100644 --- a/src/components/dashboard/charts/trafficChart.config.ts +++ b/src/components/dashboard/charts/trafficChart.config.ts @@ -4,7 +4,12 @@ import { downloadChartCsv, downloadChartPng, downloadChartSvg, -} from "@/utils/download"; +} from "@/utils/dashboard/downloadChart"; +import { + formatCountChartAxis, + formatCountChartTooltip, + METRIC_REGISTRY as M, +} from "@/utils/dashboard/metricRegistry"; // 차트 고유 ID export const CHART_ID = "traffic-chart"; @@ -81,7 +86,7 @@ export function buildChartOptions(params: { filename: `overview-traffic-data-${TODAY}`, columnDelimiter: ",", headerCategory: "시간", - headerValue: "클릭수", + headerValue: M.clicks.label, }, }, }, @@ -158,12 +163,7 @@ export function buildChartOptions(params: { max: yMax, tickAmount: 5, labels: { - formatter: (val: number) => { - if (val === 0) return ""; - const rounded = Math.round(val); - if (rounded < 1000) return rounded.toLocaleString(); - return `${Math.round(rounded / 1000)}K`; - }, + formatter: formatCountChartAxis, style: { colors: "var(--color-text-muted)", fontSize: "12px" }, }, }, @@ -178,7 +178,7 @@ export function buildChartOptions(params: { tooltip: { x: { show: false }, y: { - formatter: (val: number) => val.toLocaleString(), + formatter: (val: number) => formatCountChartTooltip(val), }, style: { fontFamily: "Pretendard" }, }, diff --git a/src/components/dashboard/platform/AllPlatformTrafficChart.tsx b/src/components/dashboard/platform/AllPlatformTrafficChart.tsx index 05ca96f8..a0637585 100644 --- a/src/components/dashboard/platform/AllPlatformTrafficChart.tsx +++ b/src/components/dashboard/platform/AllPlatformTrafficChart.tsx @@ -8,19 +8,16 @@ import { PROVIDER_TYPES, } from "@/types/dashboard/provider"; +import { + formatCountChartAxis, + formatCountChartTooltip, + METRIC_REGISTRY as M, +} from "@/utils/dashboard/metricRegistry"; import { parseMinuteToTimestamp } from "@/utils/dashboard/parseMinuteToTimestamp"; -import { Skeleton } from "@/components/common/skeleton/Skeleton"; - import { platformTrafficMock } from "@/pages/dashboard/platform/platformDashboard.mock"; -interface IAllPlatformTrafficChartProps { - isLoading?: boolean; -} - -const AllPlatformTrafficChart = memo(function AllPlatformTrafficChart({ - isLoading, -}: IAllPlatformTrafficChartProps) { +const AllPlatformTrafficChart = memo(function AllPlatformTrafficChart() { // 3개 플랫폼의 데이터를 모두 변환하여 series 구성 const seriesData = useMemo(() => { return PROVIDER_TYPES.map((platform) => { @@ -105,12 +102,7 @@ const AllPlatformTrafficChart = memo(function AllPlatformTrafficChart({ tickAmount: 5, labels: { style: { colors: "var(--color-text-muted)", fontSize: "12px" }, - formatter: (val) => { - const rounded = Math.round(val); - if (rounded <= 0) return ""; - if (rounded < 1000) return rounded.toLocaleString(); - return `${Math.round(rounded / 1000)}K`; - }, + formatter: formatCountChartAxis, }, }, grid: { @@ -126,16 +118,15 @@ const AllPlatformTrafficChart = memo(function AllPlatformTrafficChart({ shared: true, // 여러 플랫폼 동시 비교 가능 intersect: false, x: { show: false }, - y: { formatter: (val) => `${val.toLocaleString()} 클릭` }, + y: { + formatter: (val) => + formatCountChartTooltip(val, M.clicks.chartTooltipUnit), + }, theme: "light", }, legend: { show: false }, }; - if (isLoading) { - return ; - } - return (
({ color: PLATFORM_CHART_COLORS[provider], })); -interface IAllPlatformViewProps { - isLoading: boolean; -} - -export default function AllPlatformView({ isLoading }: IAllPlatformViewProps) { +export default function AllPlatformView() { const { data: roasRankings, isLoading: isRankingsLoading, @@ -59,17 +57,13 @@ export default function AllPlatformView({ isLoading }: IAllPlatformViewProps) { - ) : ( - - ROAS 기준 상위 3 - - ) + + ROAS 기준 상위 3 + } className="flex-1 min-h-67 flex flex-col" > - {isLoading || isRankingsLoading ? ( + {isRankingsLoading ? ( ) : isRankingsError || !roasRankings ? (
@@ -94,7 +88,7 @@ export default function AllPlatformView({ isLoading }: IAllPlatformViewProps) { /> } RightElement={ - isLoading || isAdStatusLoading ? ( + isAdStatusLoading ? ( ) : adStatus ? ( @@ -104,7 +98,7 @@ export default function AllPlatformView({ isLoading }: IAllPlatformViewProps) { } className="flex-1 min-h-67 flex flex-col" > - {isLoading || isAdStatusLoading ? ( + {isAdStatusLoading ? ( ) : isAdStatusError || !adStatus ? (
@@ -126,14 +120,14 @@ export default function AllPlatformView({ isLoading }: IAllPlatformViewProps) { description={ } > - {isLoading || isPerformanceLoading ? ( + {isPerformanceLoading ? ( ) : isPerformanceError || !platformPerformance ? (
@@ -156,13 +150,13 @@ export default function AllPlatformView({ isLoading }: IAllPlatformViewProps) { description={} >
- +
{/* 개별 플랫폼 상세 */}
- {isLoading || isPerformanceLoading ? ( + {isPerformanceLoading ? ( Array.from({ length: 3 }).map((_, i) => ( )) diff --git a/src/components/dashboard/platform/PlatformDetailCard.tsx b/src/components/dashboard/platform/PlatformDetailCard.tsx index 52462309..900cc2c8 100644 --- a/src/components/dashboard/platform/PlatformDetailCard.tsx +++ b/src/components/dashboard/platform/PlatformDetailCard.tsx @@ -1,8 +1,10 @@ -import React, { memo } from "react"; +import React, { memo, useMemo } from "react"; import type { IPlatformPerformance } from "@/types/dashboard/platform"; import { PLATFORM_MAP } from "@/types/dashboard/provider"; +import { metricsToKpis } from "@/utils/dashboard/metricsToKpis"; + import Card from "@/components/common/card/Card"; import StatCard from "@/components/common/card/StatCard"; @@ -18,85 +20,31 @@ const PLATFORM_LOGOS: Record = { export const PlatformDetailCard = memo( ({ data }: { data: IPlatformPerformance }) => { - const { - provider, - impressions, - clicks, - conversion, - ROAS, - impressionChangeRate, - clickChangeRate, - cvrChangeRate, - ROASChangeRate, - } = data; + const { provider } = data; + const kpis = useMemo(() => metricsToKpis(data), [data]); const innerCardClass = "shadow-none! hover:shadow-none! !rounded-2xl p-2! gap-2!"; return ( - {/* 로고 + 이름 */} -
+
{PLATFORM_LOGOS[provider]}
-

+

{PLATFORM_MAP[provider]}

- {/* 지표 */}
- 0 ? "up" : "down", - value: `${Math.abs(impressionChangeRate).toFixed(1)}%`, - } - : undefined - } - className={innerCardClass} - /> - 0 ? "up" : "down", - value: `${Math.abs(clickChangeRate).toFixed(1)}%`, - } - : undefined - } - className={innerCardClass} - /> - 0 ? "up" : "down", - value: `${Math.abs(cvrChangeRate).toFixed(1)}%`, - } - : undefined - } - className={innerCardClass} - /> - 0 ? "up" : "down", - value: `${Math.abs(ROASChangeRate).toFixed(1)}%`, - } - : undefined - } - className={innerCardClass} - /> + {kpis.map((kpi) => ( + + ))}
); diff --git a/src/components/dashboard/platform/PlatformDetailTable.tsx b/src/components/dashboard/platform/PlatformDetailTable.tsx index 5f3ddb54..8b714fd2 100644 --- a/src/components/dashboard/platform/PlatformDetailTable.tsx +++ b/src/components/dashboard/platform/PlatformDetailTable.tsx @@ -2,6 +2,8 @@ import { useMemo } from "react"; import type { IPlatformDailyPerformance } from "@/types/dashboard/platform"; +import { METRIC_REGISTRY as M } from "@/utils/dashboard/metricRegistry"; + interface IPlatformDetailTableProps { data: IPlatformDailyPerformance[]; total?: IPlatformDailyPerformance | null; @@ -51,25 +53,25 @@ function PlatformDetailTable({ 날짜 - 비용(지출) + {M.spend.label} - 노출 수 + {M.impressions.label} - 클릭 수 + {M.clicks.label} - CTR(클릭률) + {M.ctr.label} - CPA + {M.cpa.label} - 전환 수 + {M.conversions.label} - ROAS + {M.roas.label} @@ -79,25 +81,25 @@ function PlatformDetailTable({ 합계 - ₩{total.spend.toLocaleString()} + {M.spend.format(total.spend)} - {total.impressions.toLocaleString()} + {M.impressions.format(total.impressions)} - {total.clicks.toLocaleString()} + {M.clicks.format(total.clicks)} - {total.ctr.toFixed(2)}% + {M.ctr.format(total.ctr)} - ₩{Math.round(total.cpa).toLocaleString()} + {M.cpa.format(total.cpa)} - {total.conversions.toLocaleString()} + {M.conversions.format(total.conversions)} - {Math.round(total.roas)}% + {M.roas.format(total.roas)} )} @@ -111,25 +113,25 @@ function PlatformDetailTable({ {row.date} - ₩{row.spend.toLocaleString()} + {M.spend.format(row.spend)} - {row.impressions.toLocaleString()} + {M.impressions.format(row.impressions)} - {row.clicks.toLocaleString()} + {M.clicks.format(row.clicks)} - {row.ctr.toFixed(2)}% + {M.ctr.format(row.ctr)} - ₩{row.cpa.toLocaleString()} + {M.cpa.format(row.cpa)} - {row.conversions.toLocaleString()} + {M.conversions.format(row.conversions)} - {row.roas}% + {M.roas.format(row.roas)} ))} diff --git a/src/components/dashboard/platform/PlatformRoasTable.tsx b/src/components/dashboard/platform/PlatformRoasTable.tsx index bd91c85e..78a64f2a 100644 --- a/src/components/dashboard/platform/PlatformRoasTable.tsx +++ b/src/components/dashboard/platform/PlatformRoasTable.tsx @@ -7,6 +7,8 @@ import type { import { PLATFORM_MAP } from "@/types/dashboard/provider"; import { PLATFORM_CIRCLE_LOGO_MAP } from "@/constants/dashboard/platformLogos"; +import { METRIC_REGISTRY as M } from "@/utils/dashboard/metricRegistry"; + import { TrendBadge } from "@/components/common/card/StatCard"; function toProviderType(provider: string): TProviderType | null { @@ -42,7 +44,7 @@ const Delta = memo(function Delta({ value }: { value: number }) { return ( ); }); @@ -68,16 +70,16 @@ const PlatformRoasTable = memo(function PlatformRoasTable({ 플랫폼 - ROAS(%) + {M.roas.label}(%) - CTR(클릭률) + {M.clicks.label} - CVR(전환율) + {M.conversion.label} - 매출 / 광고비 + {M.revenue.label} / {M.adSpend.label}
@@ -112,20 +114,20 @@ const PlatformRoasTable = memo(function PlatformRoasTable({ {/* ROAS */}
- {item.roas.toLocaleString()}% + {M.roas.format(item.roas)}
- {/* CTR */} + {/* 클릭수 */}
- {item.clickRate !== undefined ? ( + {item.clicks !== undefined ? ( <> - {item.clickRate.toFixed(1)}% + {M.clicks.format(item.clicks)} - {item.ctrDelta !== undefined && ( + {item.clickDelta !== undefined && (
- +
)} @@ -139,7 +141,7 @@ const PlatformRoasTable = memo(function PlatformRoasTable({ {item.conversionRate !== undefined ? ( <> - {item.conversionRate.toFixed(1)}% + {M.conversion.format(item.conversionRate)} {item.conversionDelta !== undefined && (
@@ -155,12 +157,12 @@ const PlatformRoasTable = memo(function PlatformRoasTable({ {/* 매출/광고비 */}
- ₩{item.revenue.toLocaleString()} + {M.revenue.format(item.revenue)}
- 광고비 + {M.adSpend.label} - ₩{item.adSpend.toLocaleString()} + {M.adSpend.format(item.adSpend)}
diff --git a/src/components/dashboard/platform/PlatformTrafficChart.tsx b/src/components/dashboard/platform/PlatformTrafficChart.tsx index be5dfe52..f6120da6 100644 --- a/src/components/dashboard/platform/PlatformTrafficChart.tsx +++ b/src/components/dashboard/platform/PlatformTrafficChart.tsx @@ -5,6 +5,11 @@ import type { ApexOptions } from "apexcharts"; import type { TProviderType } from "@/types/dashboard/overview"; import { PLATFORM_CHART_COLORS } from "@/types/dashboard/provider"; +import { + formatCountChartAxis, + formatCountChartTooltip, + METRIC_REGISTRY as M, +} from "@/utils/dashboard/metricRegistry"; import { parseMinuteToTimestamp } from "@/utils/dashboard/parseMinuteToTimestamp"; import { Skeleton } from "@/components/common/skeleton/Skeleton"; @@ -14,13 +19,11 @@ import type { IClickStreamResponse } from "@/pages/dashboard/platform/platformDa interface IPlatformTrafficChartProps { data: IClickStreamResponse | null; platform: string; - isLoading?: boolean; } const PlatformTrafficChart = memo(function PlatformTrafficChart({ data, platform, - isLoading, }: IPlatformTrafficChartProps) { const seriesData = useMemo(() => { if (!data) return []; @@ -105,12 +108,7 @@ const PlatformTrafficChart = memo(function PlatformTrafficChart({ tickAmount: 5, labels: { style: { colors: "var(--color-text-muted)", fontSize: "12px" }, - formatter: (val) => { - const rounded = Math.round(val); - if (rounded <= 0) return ""; - if (rounded < 1000) return rounded.toLocaleString(); - return `${Math.round(rounded / 1000)}K`; - }, + formatter: formatCountChartAxis, }, }, grid: { @@ -124,19 +122,22 @@ const PlatformTrafficChart = memo(function PlatformTrafficChart({ }, tooltip: { x: { show: false }, - y: { formatter: (val) => `${val.toLocaleString()} 클릭` }, + y: { + formatter: (val) => + formatCountChartTooltip(val, M.clicks.chartTooltipUnit), + }, theme: "light", }, }; const series = [ { - name: "클릭수", + name: M.clicks.label, data: seriesData, }, ]; - if (isLoading || !data) { + if (!data) { return ; } diff --git a/src/components/dashboard/platform/SinglePlatformView.tsx b/src/components/dashboard/platform/SinglePlatformView.tsx index 6c650a23..81141bf9 100644 --- a/src/components/dashboard/platform/SinglePlatformView.tsx +++ b/src/components/dashboard/platform/SinglePlatformView.tsx @@ -4,6 +4,7 @@ import { twMerge } from "tailwind-merge"; import type { TProviderType } from "@/types/dashboard/overview"; import { PLATFORM_CHART_COLORS } from "@/types/dashboard/provider"; +import { METRIC_REGISTRY as M } from "@/utils/dashboard/metricRegistry"; import { metricsToKpis } from "@/utils/dashboard/metricsToKpis"; import { useBudget } from "@/hooks/dashboard/useBudget"; @@ -39,12 +40,10 @@ const PLATFORM_LOGOS: Record< interface ISinglePlatformViewProps { platform: TProviderType; - isLoading: boolean; } export default function SinglePlatformView({ platform, - isLoading, }: ISinglePlatformViewProps) { const [viewRange, setViewRange] = React.useState<7 | 30>(7); @@ -104,7 +103,7 @@ export default function SinglePlatformView({ {/* top */}
- {isLoading || isMetricsLoading ? ( + {isMetricsLoading ? ( Array.from({ length: 4 }).map((_, i) => (
@@ -152,7 +151,6 @@ export default function SinglePlatformView({ @@ -179,7 +177,7 @@ export default function SinglePlatformView({ ) } > - {isLoading || isBudgetLoading ? ( + {isBudgetLoading ? (
@@ -232,7 +230,7 @@ export default function SinglePlatformView({
} > - {isLoading || isMetricFactsLoading ? ( + {isMetricFactsLoading ? (
diff --git a/src/components/dashboard/platform/TopPerformanceList.tsx b/src/components/dashboard/platform/TopPerformanceList.tsx index 0c70bdf9..2b1d5916 100644 --- a/src/components/dashboard/platform/TopPerformanceList.tsx +++ b/src/components/dashboard/platform/TopPerformanceList.tsx @@ -4,6 +4,8 @@ import type { IRoasRanking } from "@/types/dashboard/platform"; import { PLATFORM_MAP, type TProviderType } from "@/types/dashboard/provider"; import { PLATFORM_CIRCLE_LOGO_MAP } from "@/constants/dashboard/platformLogos"; +import { METRIC_REGISTRY as M } from "@/utils/dashboard/metricRegistry"; + import { TrendBadge } from "@/components/common/card/StatCard"; function toProviderType(provider: string): TProviderType | null { @@ -41,12 +43,12 @@ export const TopPerformanceList = memo(function TopPerformanceList({
- {item.roas.toFixed(2)}% + {M.roas.format(item.roas)} {item.diffRate !== null && item.diffRate !== 0 && ( 0 ? "up" : "down"} - value={`${Math.abs(item.diffRate)}%`} + value={M.roas.formatDelta(item.diffRate)} /> )}
diff --git a/src/components/dashboard/platform/skeleton/PlatformSkeleton.tsx b/src/components/dashboard/platform/skeleton/PlatformSkeleton.tsx index d644fe8c..745de75f 100644 --- a/src/components/dashboard/platform/skeleton/PlatformSkeleton.tsx +++ b/src/components/dashboard/platform/skeleton/PlatformSkeleton.tsx @@ -62,11 +62,6 @@ export function PerformanceEfficiencyChartSkeleton() { ); } -// 실시간 트래픽 변화 -export function TrafficChartSkeleton() { - return ; -} - export function BadgeSkeleton({ className }: { className?: string }) { return ; } diff --git a/src/components/workspace/InviteMemberModal.tsx b/src/components/workspace/InviteMemberModal.tsx index 21eed47d..34256074 100644 --- a/src/components/workspace/InviteMemberModal.tsx +++ b/src/components/workspace/InviteMemberModal.tsx @@ -8,7 +8,7 @@ import type { TInviteMemberRequest, } from "@/types/workspace/workspace"; -import { emailSchema } from "@/utils/validation"; +import { emailSchema } from "@/utils/auth/validation"; import Badge from "../common/badge/Badge"; import Button from "../common/button/Button"; diff --git a/src/hooks/auth/useEmailVerification.ts b/src/hooks/auth/useEmailVerification.ts index b409f9bf..8bd57195 100644 --- a/src/hooks/auth/useEmailVerification.ts +++ b/src/hooks/auth/useEmailVerification.ts @@ -8,7 +8,7 @@ import type { z } from "zod"; import type { IEmailSendRequest, IEmailSendResponse } from "@/types/auth/auth"; import type { IApiErrorResponse, ICommonResponse } from "@/types/common/common"; -import { signupEmailSchema } from "@/utils/validation"; +import { signupEmailSchema } from "@/utils/auth/validation"; import { useAuth } from "@/hooks/auth/useAuth"; import { useTimer } from "@/hooks/common/useTimer"; diff --git a/src/hooks/dashboard/useOverviewRoasRankings.ts b/src/hooks/dashboard/useOverviewRoasRankings.ts index a3bcdc4d..fe565c23 100644 --- a/src/hooks/dashboard/useOverviewRoasRankings.ts +++ b/src/hooks/dashboard/useOverviewRoasRankings.ts @@ -1,46 +1,54 @@ import type { IPlatformRankingItem } from "@/types/dashboard/overview"; -import { PROVIDER_TYPES, type TProviderType } from "@/types/dashboard/provider"; +import { + PLATFORM_MAP, + PROVIDER_TYPES, + type TProviderType, +} from "@/types/dashboard/provider"; import { OVERVIEW_DAILY_METRICS_RANGE } from "@/constants/dashboard/overviewMetricsRange"; +import { fetchPlatformMetrics } from "@/utils/dashboard/platformMetricsQuery"; + import { useCoreQuery } from "@/hooks/customQuery"; -import { getOverview, getRoasRankings } from "@/api/dashboard/overview"; +import { getRoasRankings } from "@/api/dashboard/overview"; import { QUERY_KEYS } from "@/lib/queryKeys"; import useWorkspaceStore from "@/store/useWorkspaceStore"; const PROVIDERS: readonly TProviderType[] = PROVIDER_TYPES; +function toProviderType(provider: string): TProviderType | null { + const key = provider.toUpperCase(); + if (key in PLATFORM_MAP) return key as TProviderType; + return null; +} + export function useOverviewRoasRankings() { const orgId = useWorkspaceStore((s) => s.selectedOrgId); return useCoreQuery( QUERY_KEYS.overview.roasRankings(orgId), async (): Promise => { - // ROAS 순위 + 플랫폼별 지표 병렬 조회 const [rankingsRes, ...metricsResults] = await Promise.all([ getRoasRankings(orgId!, OVERVIEW_DAILY_METRICS_RANGE), - ...PROVIDERS.map((p) => getOverview(orgId!, p).catch(() => null)), + ...PROVIDERS.map((p) => + fetchPlatformMetrics(orgId!, p).catch(() => null), + ), ]); - // provider → metrics 매핑 const metricsMap = Object.fromEntries( PROVIDERS.map((p, i) => [p, metricsResults[i]]), ); return rankingsRes.rankings.map((item) => { - const metrics = metricsMap[item.provider.toUpperCase()]; - // CTR = 클릭수 ÷ 노출수 × 100 - const clickRate = - metrics && metrics.impressions > 0 - ? (metrics.clicks / metrics.impressions) * 100 - : undefined; + const providerKey = toProviderType(item.provider); + const metrics = providerKey ? metricsMap[providerKey] : undefined; return { ...item, - clickRate, - ctrDelta: metrics ? metrics.clickChangeRate : undefined, - conversionRate: metrics ? metrics.conversion : undefined, - conversionDelta: metrics ? metrics.cvrChangeRate : undefined, + clicks: metrics?.clicks, + clickDelta: metrics?.clickChangeRate, + conversionRate: metrics?.conversion, + conversionDelta: metrics?.cvrChangeRate, }; }); }, diff --git a/src/hooks/dashboard/usePlatformMetrics.ts b/src/hooks/dashboard/usePlatformMetrics.ts index 6dcc6267..1d88c0b1 100644 --- a/src/hooks/dashboard/usePlatformMetrics.ts +++ b/src/hooks/dashboard/usePlatformMetrics.ts @@ -3,9 +3,10 @@ import type { TProviderType, } from "@/types/dashboard/overview"; +import { platformMetricsQueryFn } from "@/utils/dashboard/platformMetricsQuery"; + import { useCoreQuery } from "@/hooks/customQuery"; -import { getOverview } from "@/api/dashboard/overview"; import { QUERY_KEYS } from "@/lib/queryKeys"; import useWorkspaceStore from "@/store/useWorkspaceStore"; @@ -15,7 +16,7 @@ export function usePlatformMetrics(provider: TProviderType) { return useCoreQuery( QUERY_KEYS.platform.metrics(orgId, provider), - () => getOverview(orgId!, provider), + () => platformMetricsQueryFn(orgId!, provider), { enabled: !!orgId && !!provider, }, diff --git a/src/hooks/dashboard/usePlatformPerformance.ts b/src/hooks/dashboard/usePlatformPerformance.ts index 0f0ea301..cd9f4e9f 100644 --- a/src/hooks/dashboard/usePlatformPerformance.ts +++ b/src/hooks/dashboard/usePlatformPerformance.ts @@ -1,9 +1,10 @@ import type { IPlatformPerformance } from "@/types/dashboard/platform"; import { PROVIDER_TYPES, type TProviderType } from "@/types/dashboard/provider"; +import { fetchPlatformMetrics } from "@/utils/dashboard/platformMetricsQuery"; + import { useCoreQuery } from "@/hooks/customQuery"; -import { getOverview } from "@/api/dashboard/overview"; import { QUERY_KEYS } from "@/lib/queryKeys"; import useWorkspaceStore from "@/store/useWorkspaceStore"; @@ -18,7 +19,7 @@ export function usePlatformPerformance() { async (): Promise => { const settled = await Promise.allSettled( PROVIDERS.map((provider) => - getOverview(orgId!, provider).then((metrics) => ({ + fetchPlatformMetrics(orgId!, provider).then((metrics) => ({ ...metrics, provider, })), diff --git a/src/utils/loadable.tsx b/src/lib/loadable.tsx similarity index 100% rename from src/utils/loadable.tsx rename to src/lib/loadable.tsx diff --git a/src/pages/auth/Login.tsx b/src/pages/auth/Login.tsx index d0a050af..e993f3ad 100644 --- a/src/pages/auth/Login.tsx +++ b/src/pages/auth/Login.tsx @@ -4,7 +4,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { toast } from "sonner"; import type { z } from "zod"; -import { loginSchema } from "@/utils/validation"; +import { loginSchema } from "@/utils/auth/validation"; import { useAuth } from "@/hooks/auth/useAuth"; import { useSocialLogin } from "@/hooks/auth/useSocialLogin"; diff --git a/src/pages/dashboard/overview/sections/OverviewKpiSection.tsx b/src/pages/dashboard/overview/sections/OverviewKpiSection.tsx index 098b6677..f8d08209 100644 --- a/src/pages/dashboard/overview/sections/OverviewKpiSection.tsx +++ b/src/pages/dashboard/overview/sections/OverviewKpiSection.tsx @@ -2,6 +2,8 @@ import { Suspense } from "react"; import type { IApiErrorResponse } from "@/types/common/common"; +import { METRIC_REGISTRY as M } from "@/utils/dashboard/metricRegistry"; + import type { useOverviewMetrics } from "@/hooks/dashboard/useOverviewMetrics"; import Card from "@/components/common/card/Card"; @@ -56,7 +58,7 @@ export function OverviewKpiSection({ description={ diff --git a/src/pages/dashboard/platform/PlatformDashboard.tsx b/src/pages/dashboard/platform/PlatformDashboard.tsx index 410a92a1..1eb72fe9 100644 --- a/src/pages/dashboard/platform/PlatformDashboard.tsx +++ b/src/pages/dashboard/platform/PlatformDashboard.tsx @@ -21,7 +21,6 @@ type TDashboardHeaderContext = { export default function PlatformDashboard() { const [selectedPlatform, setSelectedPlatform] = useState("전체"); - const [isLoading, setIsLoading] = useState(true); const { setHeaderRight } = useOutletContext(); const isAllView = selectedPlatform === "전체"; @@ -40,11 +39,6 @@ export default function PlatformDashboard() { ? "플랫폼 선택" : PLATFORM_MAP[selectedPlatform]; - useEffect(() => { - const timer = setTimeout(() => setIsLoading(false), 1600); - return () => clearTimeout(timer); - }, []); - useEffect(() => { if (!setHeaderRight) return; @@ -103,9 +97,9 @@ export default function PlatformDashboard() { return (
{isAllView ? ( - + ) : ( - + )}
); diff --git a/src/pages/dashboard/platform/platformDashboard.mock.ts b/src/pages/dashboard/platform/platformDashboard.mock.ts index 28d4ca28..8f6b380d 100644 --- a/src/pages/dashboard/platform/platformDashboard.mock.ts +++ b/src/pages/dashboard/platform/platformDashboard.mock.ts @@ -1,100 +1,3 @@ -import type { - IBudgetResponse, - IPlatformPerformance, - IRoasRanking, -} from "@/types/dashboard/platform"; - -// 성과 우수 플랫폼 -export const roasRankingMock: IRoasRanking[] = [ - { - rank: 1, - provider: "GOOGLE", - roas: 67.08, - diffRate: 12, - revenue: 12345678, - adSpend: 184000, - }, - { - rank: 2, - provider: "NAVER", - roas: 19.11, - diffRate: 12, - revenue: 8500000, - adSpend: 444000, - }, - { - rank: 3, - provider: "META", - roas: 10.98, - diffRate: 5.4, - revenue: 5200000, - adSpend: 472000, - }, -]; - -// 플랫폼별 성과 효율 비교 -export const performanceEfficiencyMock: IPlatformPerformance[] = [ - { - provider: "GOOGLE", - clicks: 12100, - clickChangeRate: 0.1, - impressions: 450000, - impressionChangeRate: 0.05, - conversion: 5.8, - cvrChangeRate: 0.02, - ROAS: 67.08, - ROASChangeRate: 0.12, - }, - { - provider: "NAVER", - clicks: 8500, - clickChangeRate: -0.05, - impressions: 580000, - impressionChangeRate: 0.1, - conversion: 3.2, - cvrChangeRate: 0.01, - ROAS: 19.11, - ROASChangeRate: -0.05, - }, - { - provider: "META", - clicks: 15600, - clickChangeRate: 0.15, - impressions: 320000, - impressionChangeRate: 0.2, - conversion: 8.5, - cvrChangeRate: 0.08, - ROAS: 10.98, - ROASChangeRate: 0.05, - }, -]; - -// 예산 소진 현황 -export const budgetStatusMock: IBudgetResponse[] = [ - { - providerType: "GOOGLE", - usagePercentage: 0.75, - totalBudget: 10000000, - totalSpend: 7500000, - remainingBudget: 2500000, - }, - { - providerType: "NAVER", - usagePercentage: 0.42, - totalBudget: 10000000, - totalSpend: 4200000, - remainingBudget: 5800000, - }, - { - providerType: "META", - usagePercentage: 0.92, - totalBudget: 10000000, - totalSpend: 9200000, - remainingBudget: 800000, - }, -]; - -// 실시간 트래픽 데이터 추가 export interface ITimeSeriesData { minute: string; // YYYYMMDDHHmm count: number; @@ -130,9 +33,8 @@ const generateRealTimeTraffic = ( targetDate.getHours().toString().padStart(2, "0") + targetDate.getMinutes().toString().padStart(2, "0"); - // 시간 흐름에 따른 파동 + 랜덤성 부여 - const wave = Math.sin(targetDate.getTime() / (1000 * 60 * 12)) * 0.4; // 12분 주기의 파동 - const random = (Math.random() - 0.5) * 0.3; // ±15% 랜덤 변동 + const wave = Math.sin(targetDate.getTime() / (1000 * 60 * 12)) * 0.4; + const random = (Math.random() - 0.5) * 0.3; const count = Math.max(10, Math.floor(baseCount * (1 + wave + random))); timeSeriesData.push({ @@ -141,7 +43,6 @@ const generateRealTimeTraffic = ( }); } - // 이상 징후 return { timeSeriesData, mode: "dummy", diff --git a/src/pages/integration/platformIntegrations.mock.ts b/src/pages/integration/platformIntegrations.mock.ts index 2017c7df..4ca011ea 100644 --- a/src/pages/integration/platformIntegrations.mock.ts +++ b/src/pages/integration/platformIntegrations.mock.ts @@ -1,7 +1,5 @@ import type { IPlatformAccountApi } from "@/types/integration/platformConnection"; -import { mapPlatformAccountsToConnections } from "@/utils/integration/mapPlatformAccounts"; - /** 목록 API `data.platformAccounts` mock */ export const platformAccountsApiMock: IPlatformAccountApi[] = [ { @@ -23,7 +21,3 @@ export const platformAccountsApiMock: IPlatformAccountApi[] = [ syncedAt: "2026-05-10T12:09:00", }, ]; - -export const platformConnectionsMock = mapPlatformAccountsToConnections( - platformAccountsApiMock, -); diff --git a/src/routes/AuthRoutes.tsx b/src/routes/AuthRoutes.tsx index 20085356..a997ce6e 100644 --- a/src/routes/AuthRoutes.tsx +++ b/src/routes/AuthRoutes.tsx @@ -1,13 +1,13 @@ import { lazy, Suspense } from "react"; import { type RouteObject, useLocation } from "react-router-dom"; -import { loadable } from "@/utils/loadable"; - import AuthFormSkeleton from "@/components/auth/skeleton/AuthFormSkeleton"; import LoginPageSkeleton from "@/components/auth/skeleton/LoginPageSkeleton"; import SignupEmailStepSkeleton from "@/components/auth/skeleton/SignupEmailStepSkeleton"; import SignupPageSkeleton from "@/components/auth/skeleton/SignupPageSkeleton"; +import { loadable } from "@/lib/loadable"; + const FindEmail = loadable( lazy(() => import("@/pages/auth/FindEmail")), , diff --git a/src/routes/MainRoutes.tsx b/src/routes/MainRoutes.tsx index 3101ee25..06de8d9f 100644 --- a/src/routes/MainRoutes.tsx +++ b/src/routes/MainRoutes.tsx @@ -1,12 +1,11 @@ import { lazy } from "react"; import { Navigate, type RouteObject } from "react-router-dom"; -import { loadable } from "@/utils/loadable"; - import WorkspaceListLoading from "@/components/workspace/WorkspaceListLoading"; import RoleGuard from "./RoleGuard"; +import { loadable } from "@/lib/loadable"; import WorkspaceBillingRedirect from "@/pages/workspace/WorkspaceBillingRedirect"; const OverviewDashboard = loadable( diff --git a/src/types/dashboard/overview.ts b/src/types/dashboard/overview.ts index ab084707..99ebc743 100644 --- a/src/types/dashboard/overview.ts +++ b/src/types/dashboard/overview.ts @@ -16,10 +16,10 @@ export interface IRoasRankingsResponse { rankings: IRoasRanking[]; } -// 플랫폼별 ROAS 순위 + 지표(CTR/CVR) 통합 항목 +// 플랫폼별 ROAS 순위 + 지표(클릭수/CVR) 통합 항목 export interface IPlatformRankingItem extends IRoasRanking { - clickRate?: number; // CTR (%) - ctrDelta?: number; // CTR 전기 대비 증감 (%) + clicks?: number; + clickDelta?: number; // 클릭수 전기 대비 증감 (%) conversionRate?: number; // CVR (%) conversionDelta?: number; // CVR 전기 대비 증감 (%) } diff --git a/src/utils/formatPhoneNumber.ts b/src/utils/auth/formatPhoneNumber.ts similarity index 100% rename from src/utils/formatPhoneNumber.ts rename to src/utils/auth/formatPhoneNumber.ts diff --git a/src/utils/maskEmail.ts b/src/utils/auth/maskEmail.ts similarity index 100% rename from src/utils/maskEmail.ts rename to src/utils/auth/maskEmail.ts diff --git a/src/utils/validation.ts b/src/utils/auth/validation.ts similarity index 100% rename from src/utils/validation.ts rename to src/utils/auth/validation.ts diff --git a/src/utils/download.ts b/src/utils/dashboard/downloadChart.ts similarity index 75% rename from src/utils/download.ts rename to src/utils/dashboard/downloadChart.ts index da848e96..65c17f31 100644 --- a/src/utils/download.ts +++ b/src/utils/dashboard/downloadChart.ts @@ -30,13 +30,3 @@ export function downloadChartSvg(containerId: string, filename: string) { export function downloadChartCsv(chartId: string) { ApexCharts.exec(chartId, "exportToCSV"); } - -export function printAsPdf(printClass: string) { - document.body.classList.add(printClass); - const cleanup = () => { - document.body.classList.remove(printClass); - window.removeEventListener("afterprint", cleanup); - }; - window.addEventListener("afterprint", cleanup); - setTimeout(() => window.print(), 150); -} diff --git a/src/utils/dashboard/metricRegistry.ts b/src/utils/dashboard/metricRegistry.ts new file mode 100644 index 00000000..9819d326 --- /dev/null +++ b/src/utils/dashboard/metricRegistry.ts @@ -0,0 +1,172 @@ +import type { IMetricsResponse } from "@/types/dashboard/common"; + +const METRIC_LOCALE = "ko-KR" as const; + +function formatNumber(v: number): string { + return v.toLocaleString(METRIC_LOCALE); +} + +function formatCurrencyRounded(v: number): string { + return `₩${formatNumber(Math.round(v))}`; +} + +const formatPercentDelta = (v: number) => `${Math.abs(v).toFixed(2)}%`; + +/* 트래픽 차트 Y축 — 1,000 미만 locale, 이상 K 축약 */ +export function formatCountChartAxis(val: number): string { + const rounded = Math.round(val); + if (rounded <= 0) return ""; + if (rounded < 1000) return formatNumber(rounded); + return `${Math.round(rounded / 1000)}K`; +} + +/* 트래픽 차트 툴팁 */ +export function formatCountChartTooltip(val: number, unit?: string): string { + const formatted = formatNumber(val); + return unit ? `${formatted} ${unit}` : formatted; +} + +/** 기본: 레이블 + 값 포맷 */ +interface IMetricMeta { + label: string; + format: (v: number) => string; +} + +/** 증감률(%) 포맷 포함 */ +interface IMetricMetaWithDelta extends IMetricMeta { + formatDelta: (v: number) => string; +} + +/** KPI 카드용 — conversion만 kpiLabel로 카드 제목 분리 */ +interface IKpiMetricMeta extends IMetricMetaWithDelta { + kpiLabel: string; +} + +interface IClicksMetricMeta extends IMetricMetaWithDelta { + chartTooltipUnit: string; +} + +const currencyFormat = { format: formatCurrencyRounded } satisfies Pick< + IMetricMeta, + "format" +>; + +/** + * Registry 키 = FE 내부 식별자. + * label = 화면 표시명, format = 값 표시, formatDelta = 증감률 표시 + * + * 포맷 기준: + * 정수형 카운트 → 천 단위 콤마 (toLocaleString) + * 비율/퍼센트 → toFixed(2)% + * 금액 (KRW) → Math.round + 천 단위 콤마 + ₩ + * 증감률 → toFixed(2)% + 절댓값 + */ +export const METRIC_REGISTRY = { + clicks: { + label: "클릭수", + chartTooltipUnit: "클릭", + format: formatNumber, + formatDelta: formatPercentDelta, + } satisfies IClicksMetricMeta, + + impressions: { + label: "노출수", + format: formatNumber, + formatDelta: formatPercentDelta, + } satisfies IMetricMetaWithDelta, + + conversion: { + label: "CVR(전환율)", + kpiLabel: "전환율", + format: (v) => `${v.toFixed(2)}%`, + formatDelta: formatPercentDelta, + } satisfies IKpiMetricMeta, + + roas: { + label: "ROAS", + format: (v) => `${v.toFixed(2)}%`, + formatDelta: formatPercentDelta, + } satisfies IMetricMetaWithDelta, + + spend: { + label: "비용(지출)", + ...currencyFormat, + } satisfies IMetricMeta, + + ctr: { + label: "CTR(클릭률)", + format: (v) => `${v.toFixed(2)}%`, + formatDelta: formatPercentDelta, + } satisfies IMetricMetaWithDelta, + + cpa: { + label: "CPA", + format: formatCurrencyRounded, + } satisfies IMetricMeta, + + conversions: { + label: "전환 수", + format: formatNumber, + } satisfies IMetricMeta, + + revenue: { + label: "매출", + ...currencyFormat, + } satisfies IMetricMeta, + + adSpend: { + label: "광고비", + ...currencyFormat, + } satisfies IMetricMeta, +} as const; + +/* KPI StatCard 4종 — metricsToKpis 전용 */ +type TKpiMetricKey = "clicks" | "impressions" | "conversion" | "roas"; + +export function getKpiMetric(key: TKpiMetricKey): IMetricMetaWithDelta { + return METRIC_REGISTRY[key]; +} + +type TMetricApiField = keyof IMetricsResponse; + +/** + * API 필드(getOverview) ↔ Registry 키 연결. + * registryKey: 포맷·레이블, valueField/deltaField: 응답 JSON 필드명. + */ +interface IOverviewKpiBinding { + registryKey: TKpiMetricKey; + valueField: TMetricApiField; + deltaField: TMetricApiField; +} + +/** metricsToKpis가 순회 — 통합·단일·전체 보기 KPI 공통 */ +export const OVERVIEW_KPI_BINDINGS: readonly IOverviewKpiBinding[] = [ + { + registryKey: "clicks", // 어느 레지스트리 항목에서 포맷·label을 가져올지 + valueField: "clicks", // 응답에서 "값"을 꺼낼 필드명 + deltaField: "clickChangeRate", // 응답에서 "증감률"을 꺼낼 필드명 + }, + { + registryKey: "impressions", + valueField: "impressions", + deltaField: "impressionChangeRate", + }, + { + registryKey: "conversion", + valueField: "conversion", + deltaField: "cvrChangeRate", + }, + { + registryKey: "roas", + valueField: "ROAS", + deltaField: "ROASChangeRate", + }, +]; + +/** KPI 카드 제목 — conversion만 kpiLabel 사용 */ +export function getMetricKpiTitle(key: TKpiMetricKey): string { + if (key === "conversion") { + return METRIC_REGISTRY.conversion.kpiLabel; + } + return METRIC_REGISTRY[key].label; +} diff --git a/src/utils/dashboard/metricsToKpis.ts b/src/utils/dashboard/metricsToKpis.ts index 20a233fc..701857f4 100644 --- a/src/utils/dashboard/metricsToKpis.ts +++ b/src/utils/dashboard/metricsToKpis.ts @@ -1,42 +1,31 @@ import type { IMetricsResponse } from "@/types/dashboard/common"; -import type { IStatCardProps } from "@/components/common/card/StatCard"; +import { + getKpiMetric, + getMetricKpiTitle, + OVERVIEW_KPI_BINDINGS, +} from "@/utils/dashboard/metricRegistry"; -const toRate = (rate: number) => `${Math.abs(rate).toFixed(2)}%`; +import type { IStatCardProps } from "@/components/common/card/StatCard"; +/** + * API 응답 → KPI StatCard Props 변환. + * OVERVIEW_KPI_BINDINGS를 순회하며 각 항목의 registryKey로 포맷 함수를 조회하고, + * valueField / deltaField로 응답 JSON에서 값을 꺼내 조합한다. + */ export function metricsToKpis(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.toFixed(2)}%`, - trend: { - direction: metrics.cvrChangeRate >= 0 ? "up" : "down", - value: toRate(metrics.cvrChangeRate), - }, - }, - { - title: "ROAS", - value: `${metrics.ROAS.toFixed(2)}%`, - trend: { - direction: metrics.ROASChangeRate >= 0 ? "up" : "down", - value: toRate(metrics.ROASChangeRate), - }, + return OVERVIEW_KPI_BINDINGS.map( + ({ registryKey, valueField, deltaField }) => { + const metric = getKpiMetric(registryKey); + + return { + title: getMetricKpiTitle(registryKey), + value: metric.format(metrics[valueField]), + trend: { + direction: metrics[deltaField] >= 0 ? "up" : "down", + value: metric.formatDelta(metrics[deltaField]), + }, + }; }, - ]; + ); } diff --git a/src/utils/dashboard/platformMetricsQuery.ts b/src/utils/dashboard/platformMetricsQuery.ts new file mode 100644 index 00000000..1bf0577d --- /dev/null +++ b/src/utils/dashboard/platformMetricsQuery.ts @@ -0,0 +1,21 @@ +import type { IMetricsResponse } from "@/types/dashboard/common"; +import type { TProviderType } from "@/types/dashboard/provider"; + +import { getOverview } from "@/api/dashboard/overview"; +import { queryClient } from "@/lib/queryClient"; +import { QUERY_KEYS } from "@/lib/queryKeys"; + +export function platformMetricsQueryFn( + orgId: number, + provider: TProviderType, +): Promise { + return getOverview(orgId, provider); +} + +/** QUERY_KEYS.platform.metrics 캐시를 공유하며 플랫폼 지표 조회 */ +export function fetchPlatformMetrics(orgId: number, provider: TProviderType) { + return queryClient.fetchQuery({ + queryKey: QUERY_KEYS.platform.metrics(orgId, provider), + queryFn: () => platformMetricsQueryFn(orgId, provider), + }); +}