+
{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.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),
+ });
+}