- {item.clickRate !== undefined ? (
+ {item.clicks !== undefined ? (
<>
- {item.ctrDelta !== undefined && (
+ {item.clickDelta !== undefined && (
)}
>
@@ -142,7 +141,7 @@ const PlatformRoasTable = memo(function PlatformRoasTable({
{item.conversionRate !== undefined ? (
<>
{item.conversionDelta !== undefined && (
@@ -158,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 80854cb7..f6120da6 100644
--- a/src/components/dashboard/platform/PlatformTrafficChart.tsx
+++ b/src/components/dashboard/platform/PlatformTrafficChart.tsx
@@ -5,6 +5,13 @@ 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";
import type { IClickStreamResponse } from "@/pages/dashboard/platform/platformDashboard.mock";
@@ -12,28 +19,18 @@ 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) {
- // 데이터 변환: minute 문자열 -> 타임스탬프
const seriesData = useMemo(() => {
if (!data) return [];
- return data.timeSeriesData.map((d) => {
- const year = parseInt(d.minute.slice(0, 4), 10);
- const month = parseInt(d.minute.slice(4, 6), 10) - 1;
- const day = parseInt(d.minute.slice(6, 8), 10);
- const hour = parseInt(d.minute.slice(8, 10), 10);
- const min = parseInt(d.minute.slice(10, 12), 10);
- return {
- x: new Date(year, month, day, hour, min).getTime(),
- y: d.count,
- };
- });
+ return data.timeSeriesData.map((d) => ({
+ x: parseMinuteToTimestamp(d.minute),
+ y: d.count,
+ }));
}, [data]);
// X축 범위 계산 (최근 60분)
@@ -111,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: {
@@ -130,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 af89a9ad..81141bf9 100644
--- a/src/components/dashboard/platform/SinglePlatformView.tsx
+++ b/src/components/dashboard/platform/SinglePlatformView.tsx
@@ -4,13 +4,16 @@ import { twMerge } from "tailwind-merge";
import type { TProviderType } from "@/types/dashboard/overview";
import { PLATFORM_CHART_COLORS } from "@/types/dashboard/provider";
-import { usePlatformBudget } from "@/hooks/dashboard/usePlatformBudget";
+import { METRIC_REGISTRY as M } from "@/utils/dashboard/metricRegistry";
+import { metricsToKpis } from "@/utils/dashboard/metricsToKpis";
+
+import { useBudget } from "@/hooks/dashboard/useBudget";
import { usePlatformMetricFacts } from "@/hooks/dashboard/usePlatformMetricFacts";
import { usePlatformMetrics } from "@/hooks/dashboard/usePlatformMetrics";
import Badge from "@/components/common/badge/Badge";
import Card from "@/components/common/card/Card";
-import StatCard, { type ITrend } from "@/components/common/card/StatCard";
+import StatCard from "@/components/common/card/StatCard";
import ChartLegend from "@/components/common/chart/ChartLegend";
import { Skeleton } from "@/components/common/skeleton/Skeleton";
import DashboardAiSummarySection from "@/components/dashboard/ai-report/components/DashboardAiSummarySection";
@@ -37,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);
@@ -52,37 +53,10 @@ export default function SinglePlatformView({
isError: isMetricsError,
} = usePlatformMetrics(platform);
- const toTrend = (changeRate: number): ITrend => ({
- direction: changeRate >= 0 ? "up" : "down",
- value: `${Math.abs(changeRate).toFixed(2)}%`,
- });
-
- const kpis = useMemo(() => {
- if (!platformData) return [];
-
- return [
- {
- title: "노출수",
- value: platformData.impressions.toLocaleString(),
- trend: toTrend(platformData.impressionChangeRate),
- },
- {
- title: "클릭수 (CTR)",
- value: platformData.clicks.toLocaleString(),
- trend: toTrend(platformData.clickChangeRate),
- },
- {
- title: "전환율 (CVR)",
- value: `${platformData.conversion}%`,
- trend: toTrend(platformData.cvrChangeRate),
- },
- {
- title: "광고비 대비 매출 (ROAS)",
- value: `${platformData.ROAS}%`,
- trend: toTrend(platformData.ROASChangeRate),
- },
- ];
- }, [platformData]);
+ const kpis = useMemo(
+ () => (platformData ? metricsToKpis(platformData) : []),
+ [platformData],
+ );
const logoInfo = PLATFORM_LOGOS[platform];
@@ -90,7 +64,7 @@ export default function SinglePlatformView({
data: budget,
isLoading: isBudgetLoading,
isError: isBudgetError,
- } = usePlatformBudget(platform);
+ } = useBudget(platform);
const {
data: metricFacts,
@@ -129,11 +103,11 @@ export default function SinglePlatformView({
{/* top */}
- {isLoading || isMetricsLoading ? (
+ {isMetricsLoading ? (
Array.from({ length: 4 }).map((_, i) => (
@@ -168,7 +142,7 @@ export default function SinglePlatformView({
description={
@@ -177,7 +151,6 @@ export default function SinglePlatformView({
@@ -204,7 +177,7 @@ export default function SinglePlatformView({
)
}
>
- {isLoading || isBudgetLoading ? (
+ {isBudgetLoading ? (
@@ -257,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 7950089a..2b1d5916 100644
--- a/src/components/dashboard/platform/TopPerformanceList.tsx
+++ b/src/components/dashboard/platform/TopPerformanceList.tsx
@@ -1,37 +1,32 @@
-import React, { memo } from "react";
+import { memo } from "react";
import type { IRoasRanking } from "@/types/dashboard/platform";
-import { PLATFORM_MAP } from "@/types/dashboard/provider";
+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";
-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";
+function toProviderType(provider: string): TProviderType | null {
+ const key = provider.toUpperCase();
+ if (key in PLATFORM_MAP) return key as TProviderType;
+ return null;
+}
interface ITopPerformanceListProps {
rankings: IRoasRanking[];
}
-const PlatformInfo: Record
= {
- GOOGLE: {
- name: PLATFORM_MAP.GOOGLE,
- logo: ,
- },
- NAVER: { name: PLATFORM_MAP.NAVER, logo: },
- META: { name: PLATFORM_MAP.META, logo: },
-};
-
export const TopPerformanceList = memo(function TopPerformanceList({
rankings,
}: ITopPerformanceListProps) {
return (
{rankings.map((item) => {
- const info = PlatformInfo[item.provider] || {
- name: item.provider,
- logo: null,
- };
+ const key = toProviderType(item.provider);
+ const Logo = key ? PLATFORM_CIRCLE_LOGO_MAP[key] : null;
+ const name = key ? PLATFORM_MAP[key] : item.provider;
return (
@@ -39,19 +34,21 @@ export const TopPerformanceList = memo(function TopPerformanceList({
{item.rank}
-
{info.logo}
+
+ {Logo && }
+
- {info.name}
+ {name}
- {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/integration/NaverConnectModal.tsx b/src/components/integration/NaverConnectModal.tsx
new file mode 100644
index 00000000..248760d9
--- /dev/null
+++ b/src/components/integration/NaverConnectModal.tsx
@@ -0,0 +1,155 @@
+import { useEffect } from "react";
+import { type SubmitHandler, useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+import type { z } from "zod";
+
+import type { IApiErrorResponse } from "@/types/common/common";
+
+import { naverConnectSchema } from "@/utils/auth/validation";
+
+import Button from "@/components/common/button/Button";
+import Input from "@/components/common/input/Input";
+import Modal from "@/components/common/modal/Modal";
+
+import { connectNaverAccount } from "@/api/integration/naver";
+
+type TNaverConnectFormValues = z.infer
;
+
+interface INaverConnectModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ orgId: number;
+}
+
+export default function NaverConnectModal({
+ isOpen,
+ onClose,
+ orgId,
+}: INaverConnectModalProps) {
+ const queryClient = useQueryClient();
+
+ const {
+ register,
+ handleSubmit,
+ reset,
+ formState: { errors, isValid },
+ } = useForm({
+ mode: "onChange",
+ resolver: zodResolver(naverConnectSchema),
+ defaultValues: {
+ customerId: "",
+ apiKey: "",
+ secretKey: "",
+ },
+ });
+
+ useEffect(() => {
+ if (!isOpen) {
+ reset();
+ }
+ }, [isOpen, reset]);
+
+ const connectMutation = useMutation<
+ void,
+ IApiErrorResponse,
+ TNaverConnectFormValues
+ >({
+ mutationFn: (body) => connectNaverAccount(orgId, body),
+ onSuccess: () => {
+ toast.success("네이버 광고 계정을 연동했습니다.");
+ reset();
+ onClose();
+ void queryClient.invalidateQueries({
+ queryKey: ["platform-connections", orgId],
+ });
+ },
+ onError: (error) => {
+ toast.error(error.message ?? "네이버 연동에 실패했습니다.");
+ },
+ });
+
+ const isSubmitting = connectMutation.isPending;
+
+ const handleClose = () => {
+ if (isSubmitting) return;
+ onClose();
+ };
+
+ const onSubmit: SubmitHandler = (values) => {
+ connectMutation.mutate(values);
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/src/components/integration/PlatformIntegrationCard.tsx b/src/components/integration/PlatformIntegrationCard.tsx
new file mode 100644
index 00000000..5190f520
--- /dev/null
+++ b/src/components/integration/PlatformIntegrationCard.tsx
@@ -0,0 +1,224 @@
+import { memo, type ReactNode } from "react";
+
+import { PLATFORM_MAP } from "@/types/dashboard/provider";
+import type {
+ IPlatformConnectionItem,
+ TIntegrationProvider,
+ TPlatformConnectionStatus,
+} from "@/types/integration/platformConnection";
+
+import {
+ formatConnectionDate,
+ formatConnectionDateTime,
+ getTokenExpireTone,
+} from "@/utils/integration/mapPlatformAccounts";
+
+import Badge, { type TBadgeVariant } from "@/components/common/badge/Badge";
+import Button from "@/components/common/button/Button";
+
+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";
+
+const PLATFORM_LOGOS: Record = {
+ GOOGLE: ,
+ NAVER: ,
+ META: ,
+};
+
+const STATUS_LABEL: Record = {
+ disconnected: "미연동",
+ connected: "연동됨",
+ error: "연동 오류",
+};
+
+const CONNECTION_STATUS_BADGE: Record<
+ TPlatformConnectionStatus,
+ TBadgeVariant
+> = {
+ connected: "infoBlue",
+ error: "infoRed",
+ disconnected: "surface",
+};
+
+const TOKEN_EXPIRE_TEXT: Record<
+ ReturnType,
+ string
+> = {
+ default: "text-text-title",
+ warning: "text-info-yellow",
+ expired: "text-info-red/80",
+};
+
+type TProps = IPlatformConnectionItem & {
+ onConnect?: () => void;
+ onReconnect?: () => void;
+ onDisconnect?: () => void;
+};
+
+function PlatformConnectionMeta({
+ status,
+ syncedAt,
+ externalAccountId,
+ tokenExpireAt,
+}: Pick<
+ IPlatformConnectionItem,
+ "status" | "syncedAt" | "externalAccountId" | "tokenExpireAt"
+>) {
+ const syncedLabel = formatConnectionDateTime(syncedAt);
+ const expireLabel = formatConnectionDate(tokenExpireAt);
+ const expireTone = getTokenExpireTone(tokenExpireAt);
+
+ return (
+
+ {status === "disconnected" ? (
+ <>
+
+ 마지막 동기화 ·
+ —
+
+
+ 연동 계정 ·
+ —
+
+
+ 토큰 만료 예정 ·
+ —
+
+ >
+ ) : (
+ <>
+ {syncedLabel ? (
+
+ 마지막 동기화 ·
+ {syncedLabel}
+
+ ) : null}
+
+ {externalAccountId ? (
+
+ 연동 계정 ·
+
+ {externalAccountId}
+
+
+ ) : null}
+
+ {expireLabel ? (
+
+ 토큰 만료 예정 ·
+
+ {expireLabel}
+
+
+ ) : null}
+ >
+ )}
+
+ );
+}
+
+function PlatformIntegrationCard({
+ provider,
+ status,
+ syncedAt,
+ externalAccountId,
+ tokenExpireAt,
+ errorMessage,
+ onConnect,
+ onReconnect,
+ onDisconnect,
+}: TProps) {
+ const label = PLATFORM_MAP[provider] ?? provider;
+
+ return (
+
+
+
+
{PLATFORM_LOGOS[provider]}
+
+ {label}
+
+
+
+ {STATUS_LABEL[status]}
+
+
+
+
+
+ {status === "error" && errorMessage ? (
+
+ {errorMessage}
+
+ ) : null}
+
+
+
+
+ {status === "disconnected" ? (
+
+ 광고 계정을 연동하면 대시보드와 캠페인에서 데이터를 확인할 수
+ 있습니다.
+
+ ) : null}
+
+
+ {status === "disconnected" ? (
+
+ ) : null}
+
+ {status === "connected" ? (
+ <>
+
+
+ >
+ ) : null}
+
+ {status === "error" ? (
+
+ ) : null}
+
+
+
+ );
+}
+
+export default memo(PlatformIntegrationCard);
diff --git a/src/components/integration/UpcomingPlatformCard.tsx b/src/components/integration/UpcomingPlatformCard.tsx
new file mode 100644
index 00000000..5ee3d3c5
--- /dev/null
+++ b/src/components/integration/UpcomingPlatformCard.tsx
@@ -0,0 +1,95 @@
+import type { ReactNode } from "react";
+
+import Badge from "@/components/common/badge/Badge";
+import Button from "@/components/common/button/Button";
+
+import KakaoLogo from "@/assets/logo/social-logo/circle/kakao-circle.svg?react";
+
+const UPCOMING_CARD_SHELL_CLASS =
+ "flex h-full min-h-70 w-full rounded-3xl bg-surface-100 p-8 shadow-Soft tablet:p-8";
+const UPCOMING_CARD_DISABLED_CLASS =
+ "pointer-events-none select-none opacity-70 grayscale";
+
+type TProps = {
+ title: string;
+ badgeText: string;
+ description: string;
+ icon?: ReactNode;
+ disabled?: boolean;
+};
+
+export default function UpcomingPlatformCard({
+ title,
+ badgeText,
+ description,
+ icon,
+ disabled = true,
+}: TProps) {
+ return (
+
+
+
+
+ {icon ?? (
+
+ ?
+
+ )}
+
+
+ {title}
+
+
+
+
+ {badgeText}
+
+
+
+
{description}
+
+
+
+
+
+ );
+}
+
+export function KakaoUpcomingCard() {
+ return (
+ }
+ />
+ );
+}
+
+export function ComingSoonUpcomingCard() {
+ return (
+
+ );
+}
diff --git a/src/components/integration/skeleton/PlatformIntegrationsSkeleton.tsx b/src/components/integration/skeleton/PlatformIntegrationsSkeleton.tsx
new file mode 100644
index 00000000..7699c2a5
--- /dev/null
+++ b/src/components/integration/skeleton/PlatformIntegrationsSkeleton.tsx
@@ -0,0 +1,51 @@
+import {
+ Skeleton,
+ SkeletonCircle,
+} from "@/components/common/skeleton/Skeleton";
+
+const SKELETON_COUNT = 3;
+
+export function PlatformIntegrationCardSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default function PlatformIntegrationsPageSkeleton() {
+ return (
+
+ {Array.from({ length: SKELETON_COUNT }, (_, i) => (
+ -
+
+
+ ))}
+
+ );
+}
diff --git a/src/components/sidebar/Sidebar.tsx b/src/components/sidebar/Sidebar.tsx
index 1ea18460..59726e19 100644
--- a/src/components/sidebar/Sidebar.tsx
+++ b/src/components/sidebar/Sidebar.tsx
@@ -1,5 +1,6 @@
import type { Dispatch, FocusEvent, SetStateAction } from "react";
import { useCallback, useMemo } from "react";
+import { useParams } from "react-router-dom";
import { AnimatePresence, motion } from "framer-motion";
import { twMerge } from "tailwind-merge";
@@ -12,12 +13,20 @@ import { isPathMatch } from "@/utils/navigation/pathMatch";
import { applyWorkspacePathsToNav } from "@/utils/navigation/workspaceNavPaths";
import { useComingSoon } from "@/hooks/common/useComingSoon";
+import { useCoreQuery } from "@/hooks/customQuery";
+import {
+ needsIntegrationAttention,
+ usePlatformConnections,
+} from "@/hooks/integration/usePlatformConnections";
import { useSidebar } from "@/hooks/sidebar/useSidebar";
+import Badge from "@/components/common/badge/Badge";
+
import { SidebarItem } from "./SidebarItem";
import { SubMenu } from "./SubMenu";
import { WorkspaceSwitcher } from "./WorkspaceSwitcher";
+import { getMyWorkspaces } from "@/api/workspace/org";
import CollapseIcon from "@/assets/icon/chevron/chervon-left.svg?react";
import ChevronIcon from "@/assets/icon/chevron/chevron-up.svg?react";
import useWorkspaceStore from "@/store/useWorkspaceStore";
@@ -87,7 +96,31 @@ export default function Sidebar() {
const { showComingSoon } = useComingSoon();
const selectedOrgId = useWorkspaceStore((s) => s.selectedOrgId);
- const myRole = useWorkspaceStore((s) => s.myRole);
+ const myRoleFromStore = useWorkspaceStore((s) => s.myRole);
+ const { workspaceId } = useParams<{ workspaceId: string }>();
+ const { data: workspaces } = useCoreQuery(["my-workspaces"], getMyWorkspaces);
+
+ const myRole = useMemo(() => {
+ if (!workspaces) return null;
+
+ const parsedWorkspaceId = workspaceId ? Number(workspaceId) : null;
+ const getRoleByOrgId = (orgId: number | null) => {
+ if (orgId == null || !Number.isFinite(orgId) || orgId <= 0) return null;
+ return workspaces.find((w) => w.orgId === orgId)?.myRole ?? null;
+ };
+
+ return (
+ getRoleByOrgId(parsedWorkspaceId) ??
+ getRoleByOrgId(selectedOrgId) ??
+ myRoleFromStore
+ );
+ }, [workspaceId, selectedOrgId, workspaces, myRoleFromStore]);
+
+ const { data: platformConnections } = usePlatformConnections();
+ const showIntegrationsAttention = useMemo(
+ () => needsIntegrationAttention(platformConnections),
+ [platformConnections],
+ );
const mainNavWithWorkspace = useMemo(
() =>
filterNavByRole(applyWorkspacePathsToNav(mainNav, selectedOrgId), myRole),
@@ -216,6 +249,13 @@ export default function Sidebar() {
isCollapsed={isCollapsed}
className="w-full h-full"
onClick={handleFooterItemClick}
+ trailing={
+ item.id === "integrations" &&
+ showIntegrationsAttention &&
+ !isCollapsed ? (
+ 연동 필요
+ ) : undefined
+ }
/>
);
@@ -227,8 +267,10 @@ export default function Sidebar() {
aria-label={isCollapsed ? "사이드바 펼치기" : "사이드바 접기"}
onClick={toggleSidebar}
className={twMerge(
- "inline-flex h-14 w-full items-center rounded-2xl font-body2 transition-all duration-200",
- isCollapsed ? "justify-center px-0" : "gap-4 px-3",
+ "flex h-[55px] items-center rounded-2xl font-body2 transition-all duration-200",
+ isCollapsed
+ ? "mx-auto w-[55px] justify-center px-0"
+ : "w-full gap-4 px-3",
"text-text-auth-sub hover:text-primary-400 hover:bg-surface-200",
)}
>
diff --git a/src/components/sidebar/SidebarItem.tsx b/src/components/sidebar/SidebarItem.tsx
index a416b570..4ec70844 100644
--- a/src/components/sidebar/SidebarItem.tsx
+++ b/src/components/sidebar/SidebarItem.tsx
@@ -1,4 +1,4 @@
-import { memo } from "react";
+import { memo, type ReactNode } from "react";
import { NavLink } from "react-router-dom";
import { twMerge } from "tailwind-merge";
@@ -10,6 +10,7 @@ interface ISidebarItemProps {
isOpen?: boolean;
className: string;
onClick: (id: string, hasChildren: boolean) => void;
+ trailing?: ReactNode;
}
export const SidebarItem = memo(function SidebarItem({
@@ -18,30 +19,28 @@ export const SidebarItem = memo(function SidebarItem({
isOpen,
className,
onClick,
+ trailing,
}: ISidebarItemProps) {
const hasChildren = !!item.children?.length;
const Icon = item.icon;
- const content = (
-
- {Icon && (
-
- )}
-
+ const itemClassName = twMerge(
+ className,
+ "flex items-center",
+ isCollapsed ? "justify-center" : "",
+ );
+
+ const content = isCollapsed ? (
+ Icon ? (
+
+ ) : null
+ ) : (
+
+ {Icon ? : null}
+
{item.label}
+ {trailing ? {trailing} : null}
);
@@ -49,7 +48,7 @@ export const SidebarItem = memo(function SidebarItem({
return (
{
if (e.defaultPrevented) return;
onClick(item.id, hasChildren);
@@ -65,7 +64,7 @@ export const SidebarItem = memo(function SidebarItem({
type="button"
aria-haspopup={hasChildren ? "menu" : undefined}
aria-expanded={hasChildren ? isOpen : undefined}
- className={twMerge(className, "flex items-center text-left")}
+ className={twMerge(itemClassName, "text-left")}
onClick={(e) => {
if (e.defaultPrevented) return;
onClick(item.id, hasChildren);
diff --git a/src/components/sidebar/WorkspaceSwitcher.tsx b/src/components/sidebar/WorkspaceSwitcher.tsx
index aa43a458..48d6cda4 100644
--- a/src/components/sidebar/WorkspaceSwitcher.tsx
+++ b/src/components/sidebar/WorkspaceSwitcher.tsx
@@ -11,6 +11,7 @@ import { useCoreQuery } from "@/hooks/customQuery";
import { getMyWorkspaces, saveSelectedWorkspace } from "@/api/workspace/org";
import ChevronIcon from "@/assets/icon/chevron/chevron-up.svg?react";
+import { QUERY_KEYS } from "@/lib/queryKeys";
import useWorkspaceStore from "@/store/useWorkspaceStore";
export function WorkspaceSwitcher({
@@ -30,7 +31,7 @@ export function WorkspaceSwitcher({
const setMyRole = useWorkspaceStore((s) => s.setMyRole);
const { data: workspaces, isPending } = useCoreQuery(
- ["my-workspaces"],
+ QUERY_KEYS.workspace.list(),
getMyWorkspaces,
);
@@ -62,11 +63,15 @@ export function WorkspaceSwitcher({
const workspace = workspaceList.find((w) => w.orgId === orgId);
setSelectedOrgId(orgId);
if (workspace) setMyRole(workspace.myRole);
- setIsOpen(false);
await Promise.all([
- queryClient.invalidateQueries({ queryKey: ["my-workspaces"] }),
- queryClient.invalidateQueries({ queryKey: ["savedWorkspace"] }),
+ queryClient.invalidateQueries({
+ queryKey: QUERY_KEYS.workspace.list(),
+ }),
+ queryClient.invalidateQueries({
+ queryKey: QUERY_KEYS.workspace.saved(),
+ }),
]);
+ setIsOpen(false);
},
onError: (error) => {
console.error("워크스페이스 저장 실패:", error);
diff --git a/src/components/timeline/TimelineAxis.tsx b/src/components/timeline/TimelineAxis.tsx
new file mode 100644
index 00000000..325b762a
--- /dev/null
+++ b/src/components/timeline/TimelineAxis.tsx
@@ -0,0 +1,54 @@
+import { twMerge } from "tailwind-merge";
+
+import type { ITimelineGridColumn } from "@/types/timeline/ui";
+import {
+ TIMELINE_AXIS_HEIGHT,
+ TIMELINE_COL_WIDTH,
+} from "@/constants/timeline/layout";
+
+interface ITimelineAxisProps {
+ columns: ITimelineGridColumn[];
+ colWidth?: number;
+ className?: string;
+}
+
+export default function TimelineAxis({
+ columns,
+ colWidth = TIMELINE_COL_WIDTH,
+ className,
+}: ITimelineAxisProps) {
+ return (
+
+ {columns.map((column, index) => (
+
+
+ {column.day}
+
+ {column.isToday ? (
+
+ {column.date}
+
+ ) : (
+ {column.date}
+ )}
+
+ ))}
+
+ );
+}
diff --git a/src/components/timeline/TimelineBar.tsx b/src/components/timeline/TimelineBar.tsx
new file mode 100644
index 00000000..5dd42f61
--- /dev/null
+++ b/src/components/timeline/TimelineBar.tsx
@@ -0,0 +1,83 @@
+import { twMerge } from "tailwind-merge";
+
+import type { ITimelineCampaignBar } from "@/types/timeline/ui";
+import {
+ TIMELINE_BAR_HEIGHT,
+ TIMELINE_COL_WIDTH,
+ TIMELINE_ROW_HEIGHT,
+ TIMELINE_ROW_OFFSET,
+} from "@/constants/timeline/layout";
+import { TIMELINE_PERFORMANCE_STATUS_STYLE } from "@/constants/timeline/statusStyle";
+
+import KebabIcon from "@/assets/icon/timeline/kebab.svg?react";
+
+interface ITimelineBarProps {
+ bar: ITimelineCampaignBar;
+ colWidth?: number;
+ rowHeight?: number;
+ rowOffset?: number;
+ className?: string;
+ onBarClick?: (bar: ITimelineCampaignBar) => void;
+ onMenuClick?: (bar: ITimelineCampaignBar) => void; //선택, 추후 이슈로 다룰 예정
+}
+
+export default function TimelineBar({
+ bar,
+ colWidth = TIMELINE_COL_WIDTH,
+ rowHeight = TIMELINE_ROW_HEIGHT,
+ rowOffset = TIMELINE_ROW_OFFSET,
+ className,
+ onBarClick,
+ onMenuClick,
+}: ITimelineBarProps) {
+ const status = TIMELINE_PERFORMANCE_STATUS_STYLE[bar.performanceStatus];
+ const left = (bar.colStart - 1) * colWidth;
+ const columnSpan = Math.max(bar.colEnd - bar.colStart, 1);
+ const width = columnSpan * colWidth;
+ const top =
+ rowOffset +
+ (bar.row - 1) * rowHeight +
+ (rowHeight - TIMELINE_BAR_HEIGHT) / 2;
+ return (
+ /*카드 클릭하면 성과요약 패널 나오도록 핸들러 구현 예정 */
+ onBarClick?.(bar)}
+ style={{ left, top, width, height: TIMELINE_BAR_HEIGHT }}
+ >
+
+
+ {bar.title}
+
+ {bar.subtitle}
+
+
+
+ {status.label}
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/timeline/TimelineCanvas.stories.tsx b/src/components/timeline/TimelineCanvas.stories.tsx
new file mode 100644
index 00000000..8a11b615
--- /dev/null
+++ b/src/components/timeline/TimelineCanvas.stories.tsx
@@ -0,0 +1,49 @@
+import { useEffect, useRef } from "react";
+import type { Meta, StoryObj } from "@storybook/react";
+
+import { TIMELINE_GRID_MOCK } from "@/types/timeline/timeline.mock";
+import { TIMELINE_COL_WIDTH } from "@/constants/timeline/layout";
+
+import TimelineAxis from "./TimelineAxis";
+import TimelineBar from "./TimelineBar";
+import TimelineGrid from "./TimelineGrid";
+
+const { columns, bars } = TIMELINE_GRID_MOCK;
+const maxRow = bars.length > 0 ? Math.max(...bars.map((bar) => bar.row)) : 0;
+const totalWidth = columns.length * TIMELINE_COL_WIDTH;
+
+function TimelineCanvasPreview() {
+ const scrollRef = useRef(null);
+
+ useEffect(() => {
+ const el = scrollRef.current;
+ if (!el) return;
+ el.scrollLeft = el.scrollWidth - el.clientWidth;
+ }, []);
+
+ return (
+ // wrapper width는 API + 레이아웃때 다시 맞추기
+
+
+
+
+
+ {bars.map((bar) => (
+
+ ))}
+
+
+
+
+ );
+}
+
+const meta: Meta = {
+ title: "Timeline/Canvas",
+ component: TimelineCanvasPreview,
+ parameters: { layout: "padded" },
+};
+export default meta;
+type TStory = StoryObj;
+
+export const Default: TStory = {};
diff --git a/src/components/timeline/TimelineGrid.tsx b/src/components/timeline/TimelineGrid.tsx
new file mode 100644
index 00000000..c66e5fdf
--- /dev/null
+++ b/src/components/timeline/TimelineGrid.tsx
@@ -0,0 +1,49 @@
+import type { ReactNode } from "react";
+import { twMerge } from "tailwind-merge";
+
+import type { ITimelineGridColumn } from "@/types/timeline/ui";
+import {
+ TIMELINE_COL_WIDTH,
+ TIMELINE_ROW_HEIGHT,
+ TIMELINE_ROW_OFFSET,
+} from "@/constants/timeline/layout";
+
+interface ITimelineGridProps {
+ columns: ITimelineGridColumn[];
+ rowCount: number;
+ colWidth?: number;
+ rowHeight?: number;
+ rowOffset?: number;
+ className?: string;
+ children?: ReactNode; //TimelineBar들
+}
+
+export default function TimelineGrid({
+ columns,
+ rowCount,
+ colWidth = TIMELINE_COL_WIDTH,
+ rowHeight = TIMELINE_ROW_HEIGHT,
+ rowOffset = TIMELINE_ROW_OFFSET,
+ className,
+ children,
+}: ITimelineGridProps) {
+ const bodyHeight = rowOffset + rowCount * rowHeight;
+
+ return (
+
+
+ {columns.map((column, i) => (
+
+ ))}
+ {children}
+
+
+ );
+}
diff --git a/src/components/timeline/TimelinePerformancePanel.stories.tsx b/src/components/timeline/TimelinePerformancePanel.stories.tsx
new file mode 100644
index 00000000..29d33c78
--- /dev/null
+++ b/src/components/timeline/TimelinePerformancePanel.stories.tsx
@@ -0,0 +1,84 @@
+import { useState } from "react";
+import type { Meta, StoryObj } from "@storybook/react";
+import { fn } from "@storybook/test";
+
+import {
+ TIMELINE_SUMMARY_PANEL_MOCK,
+ TIMELINE_SUMMARY_PANEL_NO_AI_MOCK,
+} from "@/types/timeline/timeline.mock";
+
+import TimelinePerformancePanel from "./TimelinePerformancePanel";
+
+const meta: Meta = {
+ title: "Timeline/PerformancePanel",
+ component: TimelinePerformancePanel,
+ parameters: { layout: "fullscreen" },
+ args: {
+ onClose: fn(),
+ onEdit: fn(),
+ onDelete: fn(),
+ },
+};
+
+export default meta;
+type TStory = StoryObj;
+
+function ClosedPreivew() {
+ const [open, setOpen] = useState(false);
+
+ return (
+ <>
+
+
+
+ setOpen(false)}
+ data={TIMELINE_SUMMARY_PANEL_NO_AI_MOCK}
+ onEdit={fn()}
+ onDelete={fn()}
+ />
+ >
+ );
+}
+
+export const Closed: TStory = {
+ render: () => ,
+};
+
+export const Open: TStory = {
+ args: {
+ isOpen: true,
+ data: TIMELINE_SUMMARY_PANEL_NO_AI_MOCK,
+ },
+};
+
+export const OpenWithSummary: TStory = {
+ args: {
+ isOpen: true,
+ data: TIMELINE_SUMMARY_PANEL_MOCK,
+ },
+};
+
+function InteractivePreview() {
+ const [open, setOpen] = useState(true);
+ return (
+ setOpen(false)}
+ data={TIMELINE_SUMMARY_PANEL_MOCK}
+ onEdit={fn()}
+ onDelete={fn()}
+ />
+ );
+}
+
+export const Interactive: TStory = {
+ render: () => ,
+};
diff --git a/src/components/timeline/TimelinePerformancePanel.tsx b/src/components/timeline/TimelinePerformancePanel.tsx
new file mode 100644
index 00000000..b7e856a8
--- /dev/null
+++ b/src/components/timeline/TimelinePerformancePanel.tsx
@@ -0,0 +1,371 @@
+import type { FC, SVGProps } from "react";
+import { useEffect, useRef, useState } from "react";
+import { twMerge } from "tailwind-merge";
+
+import type { TProviderType } from "@/types/dashboard/provider";
+import type { ITimelineSummaryPanelData } from "@/types/timeline/summary";
+import type { TTimelineViewUnit } from "@/types/timeline/ui";
+import { TIMELINE_PERFORMANCE_STATUS_STYLE } from "@/constants/timeline/statusStyle";
+
+import Badge from "@/components/common/badge/Badge";
+import Button from "@/components/common/button/Button";
+import ChartLegend from "@/components/common/chart/ChartLegend";
+import Drawer from "@/components/common/drawer/Drawer";
+import { DropdownMenu } from "@/components/common/dropdownmenu/DropdownMenu";
+import { Skeleton } from "@/components/common/skeleton/Skeleton";
+import TimelinePeriodSelector from "@/components/timeline/TimelinePeriodSelector";
+
+import ChevronRightIcon from "@/assets/icon/chevron/chevron-right.svg?react";
+import MoreIcon from "@/assets/icon/common/more.svg?react";
+import TrashIcon from "@/assets/icon/common/trash.svg?react";
+import GoogleWordmark from "@/assets/logo/social-logo/wordmark/google-wordmark.svg?react";
+import MetaWordmark from "@/assets/logo/social-logo/wordmark/meta-wordmark.svg?react";
+import NaverWordmark from "@/assets/logo/social-logo/wordmark/naver-wordmark.svg?react";
+
+type TAiSummaryUiState = "idle" | "loading" | "done";
+
+const AI_SUMMARY_LOADING_MS = 1500;
+
+const CHART_PERIOD_LABELS = ["오늘", "1월 21일 → 25일", "1월 14일 → 20일"];
+
+const SECTION_SHELL_CLASS =
+ "rounded-3xl border border-surface-300/70 bg-surface-100";
+
+const SECTION_INNER_CLASS = "flex flex-col gap-5 px-6 py-6";
+
+const SOFT_CARD_CLASS = "rounded-2xl bg-surface-100 shadow-Soft";
+
+const PLATFORM_WORDMARKS: Record<
+ TProviderType,
+ { Logo: FC>; className: string; label: string }
+> = {
+ GOOGLE: {
+ Logo: GoogleWordmark,
+ className: "h-5 w-auto",
+ label: "Google",
+ },
+ NAVER: {
+ Logo: NaverWordmark,
+ className: "h-4 w-auto",
+ label: "NAVER",
+ },
+ META: {
+ Logo: MetaWordmark,
+ className: "h-3.5 w-auto",
+ label: "Meta",
+ },
+};
+
+const SECTION_TITLE_CLASS = "font-heading4 text-text-title";
+
+interface ITimelinePerformancePanelProps {
+ isOpen: boolean;
+ onClose: () => void;
+ data: ITimelineSummaryPanelData;
+ onEdit?: () => void;
+ onDelete?: () => void;
+ className?: string;
+}
+
+function formatMetricValue(value: number, unit?: string) {
+ const formatted = Number.isInteger(value)
+ ? value.toLocaleString()
+ : value.toFixed(2);
+ return unit ? `${formatted}${unit}` : formatted;
+}
+
+function formatChangeRate(changeRate: number) {
+ return `${Math.abs(changeRate * 100).toFixed(1)}%`;
+}
+
+function PlatformContributionRow({
+ provider,
+ value,
+}: {
+ provider: TProviderType;
+ value: number;
+}) {
+ const {
+ Logo,
+ className: logoClassName,
+ label,
+ } = PLATFORM_WORDMARKS[provider];
+ const progress = Math.min(Math.max(value, 0), 100);
+
+ return (
+
+ );
+}
+
+export default function TimelinePerformancePanel({
+ isOpen,
+ onClose,
+ data,
+ onEdit,
+ onDelete,
+ className,
+}: ITimelinePerformancePanelProps) {
+ const [aiState, setAiState] = useState("idle");
+ const [generatedSummary, setGeneratedSummary] = useState("");
+ const summaryTimerRef = useRef(null);
+ const [viewUnit, setViewUnit] = useState("WEEK");
+ const [chartPeriodIndex, setChartPeriodIndex] = useState(0);
+
+ const statusStyle = TIMELINE_PERFORMANCE_STATUS_STYLE[data.performanceStatus];
+ const chartPeriodLabel =
+ CHART_PERIOD_LABELS[chartPeriodIndex] ?? CHART_PERIOD_LABELS[0];
+
+ useEffect(() => {
+ if (!isOpen) return;
+ setAiState(data.aiSummary.trim() ? "done" : "idle");
+ setGeneratedSummary("");
+ }, [isOpen, data.aiSummary]);
+
+ useEffect(() => {
+ return () => {
+ if (summaryTimerRef.current !== null) {
+ window.clearTimeout(summaryTimerRef.current);
+ }
+ };
+ }, []);
+ const handleGenerateSummary = () => {
+ setAiState("loading");
+ summaryTimerRef.current = window.setTimeout(() => {
+ setGeneratedSummary(
+ data.aiSummary.trim() || "AI 요약이 생성되었습니다.(API연동전 임시)",
+ );
+ setAiState("done");
+ }, AI_SUMMARY_LOADING_MS);
+ };
+
+ const handlePrevChartPeriod = () => {
+ setChartPeriodIndex((prev) =>
+ prev === 0 ? CHART_PERIOD_LABELS.length - 1 : prev - 1,
+ );
+ };
+
+ const handleNextChartPeriod = () => {
+ setChartPeriodIndex((prev) =>
+ prev === CHART_PERIOD_LABELS.length - 1 ? 0 : prev + 1,
+ );
+ };
+
+ const menuItems = [
+ {
+ label: "삭제하기",
+ icon: ,
+ danger: true,
+ labelClassName: "text-info-red",
+ onClick: () => onDelete?.(),
+ },
+ {
+ label: "수정하기",
+ onClick: () => onEdit?.(),
+ },
+ ];
+
+ return (
+
+
+ {/* 헤더 */}
+
+
+
+ }
+ aria-label="더보기"
+ items={menuItems}
+ className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg transition-colors hover:bg-surface-200"
+ />
+
+
+ {data.timelineName}
+
+
+
+ 기간
+
+ {data.periodLabel}
+
+
+
+
+ 성과 상태
+
+
+ {statusStyle.label}
+
+
+
+
+
성과 지표
+
+ {data.metrics.map((metric) => (
+
+ {metric.label}
+
+ ))}
+
+
+
+
+
+ {/* AI 요약 */}
+
+ {aiState === "idle" && (
+
+ )}
+
+ {aiState === "loading" && (
+
+
+
+
+
+ )}
+
+ {aiState === "done" && (data.aiSummary || generatedSummary) && (
+
+ {data.aiSummary || generatedSummary}
+
+ )}
+
+
+ {/* KPI — 선택한 지표 수만큼 가로 균등 분할 */}
+
+ {data.metrics.map((metric) => (
+
+
+
+ {metric.label}
+
+
+ {formatMetricValue(metric.value, metric.unit)}
+
+ {metric.changeRate !== undefined && (
+ = 0
+ ? "text-info-red"
+ : "text-info-blue",
+ )}
+ >
+ {metric.changeRate >= 0 ? "▲" : "▼"}{" "}
+ {formatChangeRate(metric.changeRate)}
+
+ )}
+
+
+ ))}
+
+
+ {/* 차트 placeholder */}
+
+
+
+
+
+ 차트 영역 (ApexChart 연동예정)
+
+
+
+
+
+ {/* 플랫폼 기여 */}
+
+
+
플랫폼 기여 정보
+
+ {data.platformShare.map(({ provider, contributionRate }) => (
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/src/components/timeline/TimelinePeriodSelector.stories.tsx b/src/components/timeline/TimelinePeriodSelector.stories.tsx
new file mode 100644
index 00000000..58aca955
--- /dev/null
+++ b/src/components/timeline/TimelinePeriodSelector.stories.tsx
@@ -0,0 +1,72 @@
+import { useState } from "react";
+import type { Meta, StoryObj } from "@storybook/react";
+import { fn } from "@storybook/test";
+
+import type { TTimelineViewUnit } from "@/types/timeline/ui";
+
+import TimelinePeriodSelector from "./TimelinePeriodSelector";
+
+const MOCK_PERIOD_LABELS: Record = {
+ DAY: ["오늘", "23 Jun", "24 Jun"],
+ WEEK: ["오늘", "28 June - 4 July", "5 July - 11 July"],
+ MONTH: ["오늘", "July 2026", "August 2026"],
+};
+
+const meta: Meta = {
+ title: "Timeline/PeriodSelector",
+ component: TimelinePeriodSelector,
+ parameters: { layout: "padded" },
+ args: {
+ viewUnit: "WEEK",
+ periodLabel: "27 Dec - 4 JAN",
+ onViewUnitChange: fn(),
+ onPrevPeriod: fn(),
+ onNextPeriod: fn(),
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+};
+
+export default meta;
+type TStory = StoryObj;
+export const Default: TStory = {};
+
+function InteractivePreview() {
+ const [viewUnit, setViewUnit] = useState("WEEK");
+ const [index, setIndex] = useState(0);
+
+ const labels = MOCK_PERIOD_LABELS[viewUnit];
+ const periodLabel = labels[index] ?? labels[0];
+
+ const handleViewUnitChange = (unit: TTimelineViewUnit) => {
+ setViewUnit(unit);
+ setIndex(0);
+ };
+
+ const handlePrev = () => {
+ setIndex((prev) => (prev === 0 ? labels.length - 1 : prev - 1));
+ };
+
+ const handleNext = () => {
+ setIndex((prev) => (prev === labels.length - 1 ? 0 : prev + 1));
+ };
+
+ return (
+
+ );
+}
+
+export const Interactive: TStory = {
+ render: () => ,
+};
diff --git a/src/components/timeline/TimelinePeriodSelector.tsx b/src/components/timeline/TimelinePeriodSelector.tsx
new file mode 100644
index 00000000..e53a215f
--- /dev/null
+++ b/src/components/timeline/TimelinePeriodSelector.tsx
@@ -0,0 +1,127 @@
+import { useEffect, useId, useRef, useState } from "react";
+import { twMerge } from "tailwind-merge";
+
+import type { TTimelineViewUnit } from "@/types/timeline/ui";
+import { TIMELINE_VIEW_UNIT_OPTIONS } from "@/constants/timeline/viewUnit";
+
+import ChevronLeftIcon from "@/assets/icon/chevron/chervon-left.svg?react";
+import ChevronDownIcon from "@/assets/icon/chevron/chevron-down.svg?react";
+import ChevronRightIcon from "@/assets/icon/chevron/chevron-right.svg?react";
+
+interface ITimelinePeriodSelectorProps {
+ viewUnit: TTimelineViewUnit;
+ periodLabel: string;
+ onViewUnitChange: (unit: TTimelineViewUnit) => void;
+ onPrevPeriod: () => void;
+ onNextPeriod: () => void;
+ className?: string;
+}
+
+export default function TimelinePeriodSelector({
+ viewUnit,
+ periodLabel,
+ onViewUnitChange,
+ onPrevPeriod,
+ onNextPeriod,
+ className,
+}: ITimelinePeriodSelectorProps) {
+ const [isOpen, setIsOpen] = useState(false);
+ const containerRef = useRef(null);
+ const menuId = useId();
+
+ const selectedLabel =
+ TIMELINE_VIEW_UNIT_OPTIONS.find((option) => option.value === viewUnit)
+ ?.label ?? "주";
+
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (!containerRef.current?.contains(event.target as Node)) {
+ setIsOpen(false);
+ }
+ };
+
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => {
+ document.removeEventListener("mousedown", handleClickOutside);
+ };
+ }, []);
+
+ const handleSelectViewUnit = (unit: TTimelineViewUnit) => {
+ onViewUnitChange(unit);
+ setIsOpen(false);
+ };
+
+ return (
+
+ {/* 기간 이동 */}
+
+
+ {periodLabel}
+
+
+
+
+ {isOpen ? (
+
+ ) : null}
+
+
+ );
+}
diff --git a/src/components/workspace/InviteMemberModal.tsx b/src/components/workspace/InviteMemberModal.tsx
index f66af0a2..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";
@@ -18,6 +18,7 @@ import Modal from "../common/modal/Modal";
import { postInviteEmail } from "@/api/workspace/org";
import CopyIcon from "@/assets/icon/common/link.svg?react";
import UserIcon from "@/assets/icon/common/user.svg?react";
+import { QUERY_KEYS } from "@/lib/queryKeys";
type TInviteMemberModalProps = {
isOpen: boolean;
@@ -49,13 +50,13 @@ export default function InviteMemberModal({
setForm({ email: "" });
void queryClient.invalidateQueries({
- queryKey: ["workspacePendingMembers", orgId],
+ queryKey: QUERY_KEYS.workspace.pendingMembers(orgId),
});
void queryClient.invalidateQueries({
- queryKey: ["workspaceMembers", orgId],
+ queryKey: QUERY_KEYS.workspace.members(orgId),
});
void queryClient.invalidateQueries({
- queryKey: ["workspaceMemberCount", orgId],
+ queryKey: QUERY_KEYS.workspace.memberCount(orgId),
});
},
onError: (error) => {
diff --git a/src/constants/dashboard/platformLogos.tsx b/src/constants/dashboard/platformLogos.tsx
new file mode 100644
index 00000000..b78a0593
--- /dev/null
+++ b/src/constants/dashboard/platformLogos.tsx
@@ -0,0 +1,16 @@
+import type React from "react";
+
+import type { TProviderType } from "@/types/dashboard/provider";
+
+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";
+
+export const PLATFORM_CIRCLE_LOGO_MAP: Record<
+ TProviderType,
+ React.FC>
+> = {
+ GOOGLE: GoogleLogo,
+ NAVER: NaverLogo,
+ META: MetaLogo,
+};
diff --git a/src/constants/sidebarNav.ts b/src/constants/sidebarNav.ts
index 3bbc9cfe..7f182109 100644
--- a/src/constants/sidebarNav.ts
+++ b/src/constants/sidebarNav.ts
@@ -1,6 +1,7 @@
import type { INavItem } from "@/types/navigation/navItem";
import AdsIcon from "@/assets/icon/sidebar/ads.svg?react";
+import ConnectIcon from "@/assets/icon/sidebar/connect.svg?react";
import DashboardIcon from "@/assets/icon/sidebar/dashboard.svg?react";
import SettingsIcon from "@/assets/icon/sidebar/setting.svg?react";
import WorkspaceIcon from "@/assets/icon/sidebar/workspace.svg?react";
@@ -82,6 +83,12 @@ export const mainNav: INavItem[] = [
];
export const footerNav: INavItem[] = [
+ {
+ id: "integrations",
+ label: "플랫폼 연동",
+ icon: ConnectIcon,
+ path: "/integrations",
+ },
{
id: "settings",
label: "설정",
diff --git a/src/constants/timeline/layout.ts b/src/constants/timeline/layout.ts
new file mode 100644
index 00000000..14dee2bf
--- /dev/null
+++ b/src/constants/timeline/layout.ts
@@ -0,0 +1,7 @@
+/*하드코딩 숫자 모아두기*/
+
+export const TIMELINE_COL_WIDTH = 72;
+export const TIMELINE_ROW_HEIGHT = 104;
+export const TIMELINE_ROW_OFFSET = 24;
+export const TIMELINE_AXIS_HEIGHT = 56;
+export const TIMELINE_BAR_HEIGHT = 80;
diff --git a/src/constants/timeline/statusStyle.ts b/src/constants/timeline/statusStyle.ts
new file mode 100644
index 00000000..04231ffa
--- /dev/null
+++ b/src/constants/timeline/statusStyle.ts
@@ -0,0 +1,36 @@
+import type { TTimelinePerformanceStatus } from "@/types/timeline/api";
+
+export interface ITimelinePerformanceStatusStyle {
+ label: string;
+ description: string;
+ barBg: string;
+ accent: string;
+ dot: string;
+}
+
+export const TIMELINE_PERFORMANCE_STATUS_STYLE: Record<
+ TTimelinePerformanceStatus,
+ ITimelinePerformanceStatusStyle
+> = {
+ ON_TRACK: {
+ label: "On Track",
+ description: "최근 추세와 비슷한 수준의 성과를 유지하고 있어요.",
+ barBg: "bg-primary-400/12",
+ accent: "bg-primary-400",
+ dot: "bg-primary-400",
+ },
+ ABOVE_AVERAGE: {
+ label: "Above Avg",
+ description: "최근 평균 대비 눈에 띄게 좋은 성과를 보이고 있어요.",
+ barBg: "bg-oauth-naver/12",
+ accent: "bg-oauth-naver",
+ dot: "bg-oauth-naver",
+ },
+ AT_RISK: {
+ label: "At Risk",
+ description: "최근 평균 대비 성과가 눈에 띄게 낮아요.",
+ barBg: "bg-info-red/10",
+ accent: "bg-info-red",
+ dot: "bg-info-red",
+ },
+};
diff --git a/src/constants/timeline/viewUnit.ts b/src/constants/timeline/viewUnit.ts
new file mode 100644
index 00000000..d7699323
--- /dev/null
+++ b/src/constants/timeline/viewUnit.ts
@@ -0,0 +1,12 @@
+import type { TTimelineViewUnit } from "@/types/timeline/ui";
+
+export interface ITimelineViewUnitOptions {
+ value: TTimelineViewUnit;
+ label: string;
+}
+
+export const TIMELINE_VIEW_UNIT_OPTIONS: ITimelineViewUnitOptions[] = [
+ { value: "DAY", label: "일" },
+ { value: "WEEK", label: "주" },
+ { value: "MONTH", label: "월" },
+];
diff --git a/src/hooks/ads/useCampaignDetail.ts b/src/hooks/ads/useCampaignDetail.ts
index edd607ea..d8bd9d6e 100644
--- a/src/hooks/ads/useCampaignDetail.ts
+++ b/src/hooks/ads/useCampaignDetail.ts
@@ -3,6 +3,7 @@ import { useParams } from "react-router-dom";
import { useCoreQuery } from "@/hooks/customQuery";
import { getCampaignDetail } from "@/api/ads/ads";
+import { QUERY_KEYS } from "@/lib/queryKeys";
export const useCampaignDetail = () => {
const { orgId, projectId } = useParams<{
@@ -22,7 +23,7 @@ export const useCampaignDetail = () => {
parsedProjectId > 0;
return useCoreQuery(
- ["campaignDetail", parsedOrgId, parsedProjectId],
+ QUERY_KEYS.campaign.detail(parsedOrgId, parsedProjectId),
() => getCampaignDetail(parsedOrgId, parsedProjectId),
{ enabled: isValid },
);
diff --git a/src/hooks/ads/useCampaignGroup.ts b/src/hooks/ads/useCampaignGroup.ts
index 99a9c29b..ca389c2f 100644
--- a/src/hooks/ads/useCampaignGroup.ts
+++ b/src/hooks/ads/useCampaignGroup.ts
@@ -7,6 +7,7 @@ import type { IPlatformCampaign } from "@/types/ads/campaign";
import type { IApiErrorResponse } from "@/types/common/common";
import { createCampaignGroup, getPlatformCampaigns } from "@/api/ads/ads";
+import { QUERY_KEYS } from "@/lib/queryKeys";
import useWorkspaceStore from "@/store/useWorkspaceStore";
const NONE_OPTION: IPlatformCampaign = {
@@ -36,7 +37,7 @@ export const useCampaignGroup = () => {
IPlatformCampaign[],
IApiErrorResponse
>({
- queryKey: ["platformCampaigns", orgId, "GOOGLE"],
+ queryKey: QUERY_KEYS.campaign.platformList(orgId, "GOOGLE"),
queryFn: () => getPlatformCampaigns(orgId!, "GOOGLE"),
enabled: !!orgId,
});
@@ -45,7 +46,7 @@ export const useCampaignGroup = () => {
IPlatformCampaign[],
IApiErrorResponse
>({
- queryKey: ["platformCampaigns", orgId, "NAVER"],
+ queryKey: QUERY_KEYS.campaign.platformList(orgId, "NAVER"),
queryFn: () => getPlatformCampaigns(orgId!, "NAVER"),
enabled: !!orgId,
});
@@ -54,7 +55,7 @@ export const useCampaignGroup = () => {
IPlatformCampaign[],
IApiErrorResponse
>({
- queryKey: ["platformCampaigns", orgId, "META"],
+ queryKey: QUERY_KEYS.campaign.platformList(orgId, "META"),
queryFn: () => getPlatformCampaigns(orgId!, "META"),
enabled: !!orgId,
});
@@ -91,7 +92,9 @@ export const useCampaignGroup = () => {
});
},
onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ["campaigns", orgId] });
+ void queryClient.invalidateQueries({
+ queryKey: QUERY_KEYS.campaign.list(orgId),
+ });
setIsSuccessModalOpen(true);
},
onError: (error) => {
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/auth/useIsAdmin.ts b/src/hooks/auth/useIsAdmin.ts
deleted file mode 100644
index 25e5c2aa..00000000
--- a/src/hooks/auth/useIsAdmin.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import useWorkspaceStore from "@/store/useWorkspaceStore";
-
-/*useWorkspaceStore에서 myRole을 읽어서 booelan으로 반환하는 훅*/
-function useIsAdmin(): boolean {
- const myRole = useWorkspaceStore((s) => s.myRole);
- return myRole === "ADMIN";
-}
-
-export default useIsAdmin;
diff --git a/src/hooks/customQuery.ts b/src/hooks/customQuery.ts
index a0ef0e16..6180b69d 100644
--- a/src/hooks/customQuery.ts
+++ b/src/hooks/customQuery.ts
@@ -4,6 +4,7 @@ import {
type QueryKey,
useMutation,
useQuery,
+ useQueryClient,
type UseQueryResult,
} from "@tanstack/react-query";
@@ -13,8 +14,6 @@ import type {
TUseQueryCustomOptions,
} from "@/types/common/common";
-import { queryClient } from "@/lib/queryClient";
-
export function useCoreQuery(
keyName: QueryKey,
query: QueryFunction,
@@ -43,6 +42,8 @@ export function useCoreMutation<
TCache
>,
) {
+ const queryClient = useQueryClient();
+
const {
optimisticUpdate,
invalidateKeys,
diff --git a/src/hooks/dashboard/useAiAnalysisReport.ts b/src/hooks/dashboard/useAiAnalysisReport.ts
index 93906d12..1ca67fd4 100644
--- a/src/hooks/dashboard/useAiAnalysisReport.ts
+++ b/src/hooks/dashboard/useAiAnalysisReport.ts
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react";
+import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import type { IApiErrorResponse } from "@/types/common/common";
@@ -15,7 +16,7 @@ import {
getAiReportByAccessToken,
requestAiAnalysis,
} from "@/api/dashboard/aiAnalysis";
-import { queryClient } from "@/lib/queryClient";
+import { QUERY_KEYS } from "@/lib/queryKeys";
import useWorkspaceStore from "@/store/useWorkspaceStore";
/** 폴링 간격 및 최대 대기(ms) */
@@ -30,12 +31,9 @@ const WORKSPACE_REQUIRED_MESSAGE =
/** 분석 요청 시 body 일부만 덮어쓸 때 */
export type TRequestAiAnalysisParams = Partial;
-function aiReportQueryKey(provider: TAiAnalysisProvider, accessToken: string) {
- return ["ai", "report", provider, accessToken] as const;
-}
-
/** AI 요약: POST 요청 → accessToken → GET 폴링 → reportData */
export function useAiAnalysisReport(provider: TAiAnalysisProvider = "ALL") {
+ const queryClient = useQueryClient();
const orgId = useWorkspaceStore((s) => s.selectedOrgId);
const [accessToken, setAccessToken] = useState(null);
const [pollStartedAt, setPollStartedAt] = useState(null);
@@ -53,15 +51,12 @@ export function useAiAnalysisReport(provider: TAiAnalysisProvider = "ALL") {
}, [provider, reset]);
useEffect(() => {
- const token = accessToken;
- if (!token) return;
-
return () => {
void queryClient.removeQueries({
- queryKey: aiReportQueryKey(provider, token),
+ queryKey: QUERY_KEYS.ai.report(provider, orgId),
});
};
- }, [accessToken, provider]);
+ }, [provider, orgId]);
/** POST /analysis — accessToken 발급 */
const requestMutation = useCoreMutation(
@@ -80,7 +75,7 @@ export function useAiAnalysisReport(provider: TAiAnalysisProvider = "ALL") {
setAccessToken(token);
setPollStartedAt(Date.now());
void queryClient.fetchQuery({
- queryKey: aiReportQueryKey(provider, token),
+ queryKey: QUERY_KEYS.ai.report(provider, orgId),
queryFn: () => getAiReportByAccessToken(token),
staleTime: 0,
});
@@ -93,7 +88,7 @@ export function useAiAnalysisReport(provider: TAiAnalysisProvider = "ALL") {
/** GET /reports/{token} — PENDING이면 주기적으로 재조회 */
const reportQuery = useCoreQuery(
- aiReportQueryKey(provider, accessToken ?? ""),
+ QUERY_KEYS.ai.report(provider, orgId),
() => getAiReportByAccessToken(accessToken!),
{
enabled: !!accessToken,
diff --git a/src/hooks/dashboard/useOverviewBudget.ts b/src/hooks/dashboard/useBudget.ts
similarity index 53%
rename from src/hooks/dashboard/useOverviewBudget.ts
rename to src/hooks/dashboard/useBudget.ts
index 239709f9..189c8ad9 100644
--- a/src/hooks/dashboard/useOverviewBudget.ts
+++ b/src/hooks/dashboard/useBudget.ts
@@ -1,18 +1,23 @@
+import type { TProviderType } from "@/types/dashboard/overview";
+
import { useCoreQuery } from "@/hooks/customQuery";
import { getBudget } from "@/api/dashboard/overview";
+import { QUERY_KEYS } from "@/lib/queryKeys";
import useWorkspaceStore from "@/store/useWorkspaceStore";
-// 예산 소진율 임계값
const WARNING_THRESHOLD = 50;
const DANGER_THRESHOLD = 75;
-// 예산 소진 현황 조회
-export function useOverviewBudget() {
+export function useBudget(provider?: TProviderType) {
const orgId = useWorkspaceStore((s) => s.selectedOrgId);
- return useCoreQuery(["overview", "budget", orgId], () => getBudget(orgId!), {
- enabled: !!orgId,
+ const queryKey = provider
+ ? QUERY_KEYS.platform.budget(orgId, provider)
+ : QUERY_KEYS.overview.budget(orgId);
+
+ return useCoreQuery(queryKey, () => getBudget(orgId!, provider), {
+ enabled: !!orgId && (provider ? !!provider : true),
select: (data) => ({
totalBudget: data.totalBudget,
spent: data.totalSpend,
diff --git a/src/hooks/dashboard/useOverviewCampaignList.ts b/src/hooks/dashboard/useOverviewCampaignList.ts
index ee36ea45..034f7abd 100644
--- a/src/hooks/dashboard/useOverviewCampaignList.ts
+++ b/src/hooks/dashboard/useOverviewCampaignList.ts
@@ -3,6 +3,7 @@ import type { ICampaign } from "@/types/ads/campaign";
import { useCoreQuery } from "@/hooks/customQuery";
import { getCampaignList } from "@/api/ads/ads";
+import { QUERY_KEYS } from "@/lib/queryKeys";
import useWorkspaceStore from "@/store/useWorkspaceStore";
/** 캠페인 목록과 동일 쿼리 키로 캐시 공유 */
@@ -10,7 +11,7 @@ export function useOverviewCampaignList() {
const orgId = useWorkspaceStore((s) => s.selectedOrgId);
return useCoreQuery(
- ["campaigns", orgId],
+ QUERY_KEYS.campaign.list(orgId),
() => getCampaignList(orgId!),
{ enabled: !!orgId },
);
diff --git a/src/hooks/dashboard/useOverviewMetrics.ts b/src/hooks/dashboard/useOverviewMetrics.ts
index f61c249b..573c0cbb 100644
--- a/src/hooks/dashboard/useOverviewMetrics.ts
+++ b/src/hooks/dashboard/useOverviewMetrics.ts
@@ -1,63 +1,20 @@
-import type { IMetricsResponse } from "@/types/dashboard/overview";
+import { metricsToKpis } from "@/utils/dashboard/metricsToKpis";
import { useCoreQuery } from "@/hooks/customQuery";
-import type { IStatCardProps } from "@/components/common/card/StatCard";
-
import { getOverview } from "@/api/dashboard/overview";
+import { QUERY_KEYS } from "@/lib/queryKeys";
import useWorkspaceStore from "@/store/useWorkspaceStore";
-// 변화율 퍼센트 문자열로 변환
-const toRate = (rate: number) => `${Math.abs(rate).toFixed(1)}%`;
-
-// API 응답 KPI 카드 형식으로 변환
-function toKpis(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(1)}%`,
- trend: {
- direction: metrics.cvrChangeRate >= 0 ? "up" : "down",
- value: toRate(metrics.cvrChangeRate),
- },
- },
- {
- title: "ROAS",
- value: `${metrics.ROAS.toFixed(1)}%`,
- trend: {
- direction: metrics.ROASChangeRate >= 0 ? "up" : "down",
- value: toRate(metrics.ROASChangeRate),
- },
- },
- ];
-}
-
-// 통합 지표 조회
export function useOverviewMetrics() {
const orgId = useWorkspaceStore((s) => s.selectedOrgId);
return useCoreQuery(
- ["overview", "metrics", orgId],
+ QUERY_KEYS.overview.metrics(orgId),
() => getOverview(orgId!),
{
enabled: !!orgId,
- select: toKpis,
+ select: metricsToKpis,
},
);
}
diff --git a/src/hooks/dashboard/useOverviewRoasRankings.ts b/src/hooks/dashboard/useOverviewRoasRankings.ts
index 51aa50d1..fe565c23 100644
--- a/src/hooks/dashboard/useOverviewRoasRankings.ts
+++ b/src/hooks/dashboard/useOverviewRoasRankings.ts
@@ -1,45 +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(
- ["overview", "roasRankings", orgId],
+ 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/usePlatformAdCount.ts b/src/hooks/dashboard/usePlatformAdCount.ts
index 64cb87b8..c53889bf 100644
--- a/src/hooks/dashboard/usePlatformAdCount.ts
+++ b/src/hooks/dashboard/usePlatformAdCount.ts
@@ -3,6 +3,7 @@ import type { IAdStatusData } from "@/types/dashboard/platform";
import { useCoreQuery } from "@/hooks/customQuery";
import { getAdCount } from "@/api/dashboard/platform";
+import { QUERY_KEYS } from "@/lib/queryKeys";
import useWorkspaceStore from "@/store/useWorkspaceStore";
// 광고 소재 현황
@@ -10,7 +11,7 @@ export function usePlatformAdCount() {
const orgId = useWorkspaceStore((s) => s.selectedOrgId);
return useCoreQuery(
- ["platform", "adCount", orgId],
+ QUERY_KEYS.platform.adCount(orgId),
() => getAdCount(orgId!),
{ enabled: !!orgId },
);
diff --git a/src/hooks/dashboard/usePlatformBudget.ts b/src/hooks/dashboard/usePlatformBudget.ts
deleted file mode 100644
index f8af08bb..00000000
--- a/src/hooks/dashboard/usePlatformBudget.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import type { TProviderType } from "@/types/dashboard/overview";
-
-import { useCoreQuery } from "@/hooks/customQuery";
-
-import { getBudget } from "@/api/dashboard/overview";
-import useWorkspaceStore from "@/store/useWorkspaceStore";
-
-const WARNING_THRESHOLD = 50;
-const DANGER_THRESHOLD = 75;
-
-// 단일 플랫폼 예산 소진 현황
-export function usePlatformBudget(provider: TProviderType) {
- const orgId = useWorkspaceStore((s) => s.selectedOrgId);
-
- return useCoreQuery(
- ["platform", "budget", orgId, provider],
- () => getBudget(orgId!, provider),
- {
- enabled: !!orgId && !!provider,
- select: (data) => ({
- totalBudget: data.totalBudget,
- spent: data.totalSpend,
- warningThreshold: WARNING_THRESHOLD,
- dangerThreshold: DANGER_THRESHOLD,
- }),
- },
- );
-}
diff --git a/src/hooks/dashboard/usePlatformMetricFacts.ts b/src/hooks/dashboard/usePlatformMetricFacts.ts
index 8d06661e..c37da15a 100644
--- a/src/hooks/dashboard/usePlatformMetricFacts.ts
+++ b/src/hooks/dashboard/usePlatformMetricFacts.ts
@@ -8,6 +8,7 @@ import type {
import { useCoreQuery } from "@/hooks/customQuery";
import { getMetricFacts } from "@/api/dashboard/platform";
+import { QUERY_KEYS } from "@/lib/queryKeys";
import useWorkspaceStore from "@/store/useWorkspaceStore";
const DAY_LABELS = ["일", "월", "화", "수", "목", "금", "토"] as const;
@@ -57,7 +58,7 @@ export function usePlatformMetricFacts(provider: TProviderType, days: 7 | 30) {
const orgId = useWorkspaceStore((s) => s.selectedOrgId);
return useCoreQuery(
- ["platform", "metricFacts", orgId, provider, days],
+ QUERY_KEYS.platform.metricFacts(orgId, provider, days),
() =>
getMetricFacts(orgId!, {
providerType: provider,
diff --git a/src/hooks/dashboard/usePlatformMetrics.ts b/src/hooks/dashboard/usePlatformMetrics.ts
index e6e56bfd..1d88c0b1 100644
--- a/src/hooks/dashboard/usePlatformMetrics.ts
+++ b/src/hooks/dashboard/usePlatformMetrics.ts
@@ -3,9 +3,11 @@ 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";
// 단일 플랫폼 지표 조회
@@ -13,8 +15,8 @@ export function usePlatformMetrics(provider: TProviderType) {
const orgId = useWorkspaceStore((s) => s.selectedOrgId);
return useCoreQuery(
- ["platform", "metrics", orgId, provider],
- () => getOverview(orgId!, provider),
+ QUERY_KEYS.platform.metrics(orgId, provider),
+ () => platformMetricsQueryFn(orgId!, provider),
{
enabled: !!orgId && !!provider,
},
diff --git a/src/hooks/dashboard/usePlatformPerformance.ts b/src/hooks/dashboard/usePlatformPerformance.ts
index c7f92383..cd9f4e9f 100644
--- a/src/hooks/dashboard/usePlatformPerformance.ts
+++ b/src/hooks/dashboard/usePlatformPerformance.ts
@@ -1,9 +1,11 @@
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";
const PROVIDERS: readonly TProviderType[] = PROVIDER_TYPES;
@@ -13,11 +15,11 @@ export function usePlatformPerformance() {
const orgId = useWorkspaceStore((s) => s.selectedOrgId);
return useCoreQuery(
- ["platform", "performance", orgId],
+ QUERY_KEYS.platform.performance(orgId),
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/hooks/dashboard/usePlatformRoasRankings.ts b/src/hooks/dashboard/usePlatformRoasRankings.ts
index a32b12ec..6d0ed0ba 100644
--- a/src/hooks/dashboard/usePlatformRoasRankings.ts
+++ b/src/hooks/dashboard/usePlatformRoasRankings.ts
@@ -1,20 +1,18 @@
import type { IRoasRanking } from "@/types/dashboard/overview";
+import { OVERVIEW_DAILY_METRICS_RANGE } from "@/constants/dashboard/overviewMetricsRange";
import { useCoreQuery } from "@/hooks/customQuery";
import { getRoasRankings } from "@/api/dashboard/overview";
+import { QUERY_KEYS } from "@/lib/queryKeys";
import useWorkspaceStore from "@/store/useWorkspaceStore";
// ROAS 성과 순위 (상위 3개)
export function usePlatformRoasRankings() {
const orgId = useWorkspaceStore((s) => s.selectedOrgId);
return useCoreQuery(
- ["platform", "roasRankings", orgId],
- () =>
- getRoasRankings(orgId!, {
- startDate: "2026-01-22",
- endDate: "2026-03-22",
- }),
+ QUERY_KEYS.platform.roasRankings(orgId),
+ () => getRoasRankings(orgId!, OVERVIEW_DAILY_METRICS_RANGE),
{
enabled: !!orgId,
select: (data): IRoasRanking[] => data.rankings,
diff --git a/src/hooks/integration/usePlatformConnections.ts b/src/hooks/integration/usePlatformConnections.ts
new file mode 100644
index 00000000..c8d84d19
--- /dev/null
+++ b/src/hooks/integration/usePlatformConnections.ts
@@ -0,0 +1,28 @@
+import type { IPlatformConnectionItem } from "@/types/integration/platformConnection";
+
+import { mapPlatformAccountsToConnections } from "@/utils/integration/mapPlatformAccounts";
+
+import { useCoreQuery } from "@/hooks/customQuery";
+
+import { getPlatformAccounts } from "@/api/integration/platformAccounts";
+import { QUERY_KEYS } from "@/lib/queryKeys";
+import useWorkspaceStore from "@/store/useWorkspaceStore";
+
+export function needsIntegrationAttention(
+ items: IPlatformConnectionItem[] | undefined,
+): boolean {
+ return items?.some((item) => item.status === "error") ?? false;
+}
+
+export function usePlatformConnections() {
+ const orgId = useWorkspaceStore((s) => s.selectedOrgId);
+
+ return useCoreQuery(
+ QUERY_KEYS.platform.connections(orgId),
+ async () => {
+ const { platformAccounts } = await getPlatformAccounts(orgId!);
+ return mapPlatformAccountsToConnections(platformAccounts);
+ },
+ { enabled: orgId != null },
+ );
+}
diff --git a/src/layout/main/MainLayout.tsx b/src/layout/main/MainLayout.tsx
index fca8f128..c88b76ac 100644
--- a/src/layout/main/MainLayout.tsx
+++ b/src/layout/main/MainLayout.tsx
@@ -22,6 +22,7 @@ import Sidebar from "@/components/sidebar/Sidebar";
import { getMyInfo } from "@/api/auth/auth";
import { getMyWorkspaces, getSavedWorkspace } from "@/api/workspace/org";
+import { QUERY_KEYS } from "@/lib/queryKeys";
import useWorkspaceStore from "@/store/useWorkspaceStore";
export type TMainLayoutOutletContext = {
@@ -30,7 +31,7 @@ export type TMainLayoutOutletContext = {
};
export default function MainLayout() {
- useCoreQuery(["myInfo"], getMyInfo);
+ useCoreQuery(QUERY_KEYS.auth.myInfo(), getMyInfo);
const location = useLocation();
const [headerRight, setHeaderRight] = useState(null);
const [campaignDetailHeaderTitle, setCampaignDetailHeaderTitle] = useState<
@@ -41,11 +42,14 @@ export default function MainLayout() {
const setMyRole = useWorkspaceStore((s) => s.setMyRole);
const savedWorkspaceQuery = useCoreQuery(
- ["savedWorkspace"],
+ QUERY_KEYS.workspace.saved(),
getSavedWorkspace,
);
const { data: savedData } = savedWorkspaceQuery;
- const { data: workspaces } = useCoreQuery(["my-workspaces"], getMyWorkspaces);
+ const { data: workspaces } = useCoreQuery(
+ QUERY_KEYS.workspace.list(),
+ getMyWorkspaces,
+ );
const selectedOrgId = useWorkspaceStore((s) => s.selectedOrgId);
const navForHeader = useMemo(
@@ -205,13 +209,15 @@ export default function MainLayout() {
{headerRight}
-
diff --git a/src/layout/workspace/WorkspaceManageLayout.tsx b/src/layout/workspace/WorkspaceManageLayout.tsx
index 77b49963..fa0af046 100644
--- a/src/layout/workspace/WorkspaceManageLayout.tsx
+++ b/src/layout/workspace/WorkspaceManageLayout.tsx
@@ -1,18 +1,50 @@
-import { useEffect } from "react";
+import { useEffect, useMemo } from "react";
import { Outlet, useParams } from "react-router-dom";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+import { useCoreQuery } from "@/hooks/customQuery";
+
+import { getMyWorkspaces, saveSelectedWorkspace } from "@/api/workspace/org";
import useWorkspaceStore from "@/store/useWorkspaceStore";
export default function WorkspaceManageLayout() {
const { workspaceId } = useParams();
const setSelectedOrgId = useWorkspaceStore((s) => s.setSelectedOrgId);
- useEffect(() => {
+ const queryClient = useQueryClient();
+ const setMyRole = useWorkspaceStore((s) => s.setMyRole);
+ const { data: workspaces } = useCoreQuery(["my-workspaces"], getMyWorkspaces);
+
+ const { mutate: saveWorkspace } = useMutation({
+ mutationFn: (orgId: number) => saveSelectedWorkspace(orgId),
+ onSuccess: async () => {
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: ["my-workspaces"] }),
+ queryClient.invalidateQueries({ queryKey: ["savedWorkspace"] }),
+ ]);
+ },
+ onError: () => {
+ toast.error("워크스페이스 변경에 실패했습니다. 다시 시도해 주세요");
+ },
+ });
+
+ const parsedWorkspaceId = useMemo(() => {
const id = workspaceId ? Number(workspaceId) : NaN;
- if (Number.isFinite(id) && id > 0) {
- setSelectedOrgId(id);
- }
- }, [workspaceId, setSelectedOrgId]);
+ return Number.isFinite(id) && id > 0 ? id : null;
+ }, [workspaceId]);
+
+ useEffect(() => {
+ if (parsedWorkspaceId === null) return;
+ setSelectedOrgId(parsedWorkspaceId);
+ saveWorkspace(parsedWorkspaceId);
+ }, [parsedWorkspaceId, setSelectedOrgId, saveWorkspace]);
+
+ useEffect(() => {
+ if (parsedWorkspaceId == null || !workspaces) return;
+ const workspace = workspaces.find((w) => w.orgId === parsedWorkspaceId);
+ if (workspace) setMyRole(workspace.myRole);
+ }, [parsedWorkspaceId, workspaces, setMyRole]);
return (
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/lib/queryClient.ts b/src/lib/queryClient.ts
index 18f647a1..a1808463 100644
--- a/src/lib/queryClient.ts
+++ b/src/lib/queryClient.ts
@@ -1,9 +1,20 @@
import { QueryClient } from "@tanstack/react-query";
+import type { IApiErrorResponse } from "@/types/common/common";
+
+// 4xx: 클라이언트 오류는 재시도해도 결과가 동일하므로 즉시 실패
+// 5xx: 서버 일시 오류는 1회 재시도 허용
+// axiosInstance 인터셉터가 401 재발급을 별도로 처리하므로 중복 재시도 방지
+const retryPolicy = (failureCount: number, error: unknown): boolean => {
+ const status = Number((error as IApiErrorResponse)?.status);
+ if (Number.isFinite(status) && status >= 400 && status < 500) return false;
+ return failureCount < 1;
+};
+
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
- retry: 1,
+ retry: retryPolicy,
staleTime: 1000 * 60 * 5,
gcTime: 1000 * 60 * 10,
refetchOnWindowFocus: false,
diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts
new file mode 100644
index 00000000..8b60c43d
--- /dev/null
+++ b/src/lib/queryKeys.ts
@@ -0,0 +1,75 @@
+// TanStack Query의 queryKey를 중앙 관리하는 상수 객체
+
+export const QUERY_KEYS = {
+ auth: {
+ /** 내 계정 정보 */
+ myInfo: () => ["myInfo"] as const,
+ },
+
+ workspace: {
+ /** 내 워크스페이스 목록 */
+ list: () => ["my-workspaces"] as const,
+ /** 마지막으로 선택한 워크스페이스 */
+ saved: () => ["savedWorkspace"] as const,
+ /** 워크스페이스 멤버 목록 (invalidate 전용) */
+ members: (orgId: number) => ["workspaceMembers", orgId] as const,
+ /** 워크스페이스 멤버 목록 (페이지 사이즈 포함) */
+ membersWithPageSize: (orgId: number, pageSize: number) =>
+ ["workspaceMembers", orgId, pageSize] as const,
+ /** 워크스페이스 전체 멤버 수 */
+ memberCount: (orgId: number) => ["workspaceMemberCount", orgId] as const,
+ /** 초대 수락 대기 중인 멤버 목록 */
+ pendingMembers: (orgId: number) =>
+ ["workspacePendingMembers", orgId] as const,
+ },
+
+ campaign: {
+ /** 캠페인 목록 (AdsListPage · useOverviewCampaignList 캐시 공유) */
+ list: (orgId: number | null) => ["campaigns", orgId] as const,
+ /** 캠페인 그룹 상세 */
+ detail: (orgId: number, projectId: number) =>
+ ["campaignDetail", orgId, projectId] as const,
+ /** 플랫폼별 연결 가능한 캠페인 목록 */
+ platformList: (orgId: number | null, platform: string) =>
+ ["platformCampaigns", orgId, platform] as const,
+ },
+
+ overview: {
+ /** 전체 플랫폼 통합 지표 */
+ metrics: (orgId: number | null) => ["overview", "metrics", orgId] as const,
+ /** 전체 플랫폼 ROAS 랭킹 */
+ roasRankings: (orgId: number | null) =>
+ ["overview", "roasRankings", orgId] as const,
+ /** 전체 플랫폼 예산 */
+ budget: (orgId: number | null) => ["overview", "budget", orgId] as const,
+ },
+
+ platform: {
+ /** 플랫폼별 지표 */
+ metrics: (orgId: number | null, provider: string) =>
+ ["platform", "metrics", orgId, provider] as const,
+ /** 플랫폼별 광고 상태 수 */
+ adCount: (orgId: number | null) => ["platform", "adCount", orgId] as const,
+ /** 플랫폼별 성과 목록 */
+ performance: (orgId: number | null) =>
+ ["platform", "performance", orgId] as const,
+ /** 플랫폼별 ROAS 랭킹 */
+ roasRankings: (orgId: number | null) =>
+ ["platform", "roasRankings", orgId] as const,
+ /** 플랫폼별 예산 */
+ budget: (orgId: number | null, provider: string) =>
+ ["platform", "budget", orgId, provider] as const,
+ /** 플랫폼별 일별 지표 상세 */
+ metricFacts: (orgId: number | null, provider: string, days: number) =>
+ ["platform", "metricFacts", orgId, provider, days] as const,
+ /** 플랫폼 연동 연결 상태 목록 */
+ connections: (orgId: number | null) =>
+ ["platform-connections", orgId] as const,
+ },
+
+ ai: {
+ /** AI 분석 리포트 폴링 쿼리 */
+ report: (provider: string, orgId: number | null) =>
+ ["ai", "report", provider, orgId] as const,
+ },
+} as const;
diff --git a/src/pages/ads/list/AdsListPage.tsx b/src/pages/ads/list/AdsListPage.tsx
index 7176317a..f08fa6fa 100644
--- a/src/pages/ads/list/AdsListPage.tsx
+++ b/src/pages/ads/list/AdsListPage.tsx
@@ -13,6 +13,7 @@ import ModalContent from "@/components/common/modal/ModalContent";
import { updateAllCampaignStatus, updateCampaignStatus } from "@/api/ads/ads";
import WarnCircleIcon from "@/assets/icon/common/warn-circle.svg?react";
+import { QUERY_KEYS } from "@/lib/queryKeys";
import useWorkspaceStore from "@/store/useWorkspaceStore";
export default function AdsListPage() {
@@ -30,7 +31,9 @@ export default function AdsListPage() {
const [resumeScope, setResumeScope] = useState<"selection" | "all">("all");
const invalidateCampaigns = useCallback(() => {
- queryClient.invalidateQueries({ queryKey: ["campaigns", orgId] });
+ void queryClient.invalidateQueries({
+ queryKey: QUERY_KEYS.campaign.list(orgId),
+ });
}, [queryClient, orgId]);
const clearSelection = useCallback(() => {
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/OverviewDashboard.tsx b/src/pages/dashboard/overview/OverviewDashboard.tsx
index fb28c120..80254208 100644
--- a/src/pages/dashboard/overview/OverviewDashboard.tsx
+++ b/src/pages/dashboard/overview/OverviewDashboard.tsx
@@ -1,6 +1,6 @@
import { useNavigate } from "react-router-dom";
-import { useOverviewBudget } from "@/hooks/dashboard/useOverviewBudget";
+import { useBudget } from "@/hooks/dashboard/useBudget";
import { useOverviewMetrics } from "@/hooks/dashboard/useOverviewMetrics";
import { useOverviewRoasRankings } from "@/hooks/dashboard/useOverviewRoasRankings";
@@ -25,7 +25,7 @@ export default function OverviewDashboard() {
isLoading: isBudgetLoading,
isError: isBudgetError,
error: budgetError,
- } = useOverviewBudget();
+ } = useBudget();
const {
data: roasRankingsData,
isLoading: isRankingsLoading,
diff --git a/src/pages/dashboard/overview/sections/OverviewBudgetSection.tsx b/src/pages/dashboard/overview/sections/OverviewBudgetSection.tsx
index 99e846c2..0953aabb 100644
--- a/src/pages/dashboard/overview/sections/OverviewBudgetSection.tsx
+++ b/src/pages/dashboard/overview/sections/OverviewBudgetSection.tsx
@@ -1,6 +1,6 @@
import type { IApiErrorResponse } from "@/types/common/common";
-import type { useOverviewBudget } from "@/hooks/dashboard/useOverviewBudget";
+import type { useBudget } from "@/hooks/dashboard/useBudget";
import Badge from "@/components/common/badge/Badge";
import Card from "@/components/common/card/Card";
@@ -28,7 +28,7 @@ export function OverviewBudgetSection({
budgetError,
budgetStatus,
}: {
- budget: ReturnType["data"];
+ budget: ReturnType["data"];
isBudgetLoading: boolean;
isBudgetError: boolean;
budgetError: IApiErrorResponse | null;
diff --git a/src/pages/dashboard/overview/sections/OverviewKpiSection.tsx b/src/pages/dashboard/overview/sections/OverviewKpiSection.tsx
index d238dbd2..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";
@@ -31,7 +33,7 @@ export function OverviewKpiSection({
return (
{isKpisError ? (
-
+
{kpisError?.message ??
"지표 데이터를 불러오지 못했습니다. 잠시 후 다시 시도해 주세요."}
@@ -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 d5a5f72d..8f6b380d 100644
--- a/src/pages/dashboard/platform/platformDashboard.mock.ts
+++ b/src/pages/dashboard/platform/platformDashboard.mock.ts
@@ -1,100 +1,3 @@
-import type {
- IBudgetStatus,
- 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: IBudgetStatus[] = [
- {
- 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/PlatformIntegrationsPage.tsx b/src/pages/integration/PlatformIntegrationsPage.tsx
new file mode 100644
index 00000000..95b24d8d
--- /dev/null
+++ b/src/pages/integration/PlatformIntegrationsPage.tsx
@@ -0,0 +1,118 @@
+import { useState } from "react";
+import { toast } from "sonner";
+
+import type { IApiErrorResponse } from "@/types/common/common";
+import type {
+ TIntegrationProvider,
+ TPlatformConnectionStatus,
+} from "@/types/integration/platformConnection";
+
+import { startPlatformConnect } from "@/utils/integration/startPlatformConnect";
+
+import { usePlatformConnections } from "@/hooks/integration/usePlatformConnections";
+
+import NaverConnectModal from "@/components/integration/NaverConnectModal";
+import PlatformIntegrationCard from "@/components/integration/PlatformIntegrationCard";
+import PlatformIntegrationsPageSkeleton from "@/components/integration/skeleton/PlatformIntegrationsSkeleton";
+import {
+ ComingSoonUpcomingCard,
+ KakaoUpcomingCard,
+} from "@/components/integration/UpcomingPlatformCard";
+
+import useWorkspaceStore from "@/store/useWorkspaceStore";
+
+export default function PlatformIntegrationsPage() {
+ const orgId = useWorkspaceStore((s) => s.selectedOrgId);
+ const [isNaverModalOpen, setIsNaverModalOpen] = useState(false);
+
+ const {
+ data: platformConnections = [],
+ isLoading,
+ isError,
+ error,
+ } = usePlatformConnections();
+
+ const handleConnect = async (
+ provider: TIntegrationProvider,
+ status: TPlatformConnectionStatus,
+ ) => {
+ if (orgId == null) {
+ toast.error("워크스페이스를 선택해 주세요.");
+ return;
+ }
+ if (provider === "NAVER") {
+ if (status !== "disconnected") {
+ toast.message("네이버 재연동 구현 예정");
+ return;
+ }
+ setIsNaverModalOpen(true);
+ return;
+ }
+ try {
+ await startPlatformConnect(provider, orgId);
+ } catch (err) {
+ const message =
+ err instanceof Error
+ ? err.message
+ : ((err as IApiErrorResponse)?.message ??
+ "플랫폼 연동을 시작하지 못했습니다. 다시 시도해 주세요.");
+ toast.error(message);
+ }
+ };
+
+ return (
+
+ {isLoading ? (
+
+ ) : isError ? (
+
+
+ {error?.message ??
+ "플랫폼 연동 정보를 불러오지 못했습니다. 잠시 후 다시 시도해 주세요."}
+
+
+ ) : (
+ <>
+
+ {platformConnections.map((item) => (
+ -
+ handleConnect(item.provider, item.status)}
+ onReconnect={() => handleConnect(item.provider, item.status)}
+ onDisconnect={() => toast.message("연결 해제")}
+ />
+
+ ))}
+
+
+
+
+ 더 많은 플랫폼 연동을 준비하고 있어요. 지원 범위는 변경될 수
+ 있습니다.
+
+
+
+
+ >
+ )}
+ {orgId != null ? (
+ setIsNaverModalOpen(false)}
+ orgId={orgId}
+ />
+ ) : null}
+
+ );
+}
diff --git a/src/pages/workspace/MemberManagement.tsx b/src/pages/workspace/MemberManagement.tsx
index 91c01cb0..9220364a 100644
--- a/src/pages/workspace/MemberManagement.tsx
+++ b/src/pages/workspace/MemberManagement.tsx
@@ -27,6 +27,7 @@ import {
getWorkspaceMembers,
updateWorkspaceMemberPermission,
} from "@/api/workspace/org";
+import { QUERY_KEYS } from "@/lib/queryKeys";
const PAGE_SIZE = 20;
@@ -45,12 +46,12 @@ export default function MemberManagement() {
Awaited>,
IApiErrorResponse
>({
- queryKey: ["workspaceMemberCount", orgId],
+ queryKey: QUERY_KEYS.workspace.memberCount(orgId),
queryFn: () => getWorkspaceMemberCount(orgId),
enabled: Number.isFinite(orgId) && orgId > 0,
});
const membersQuery = useInfiniteQuery({
- queryKey: ["workspaceMembers", orgId, PAGE_SIZE],
+ queryKey: QUERY_KEYS.workspace.membersWithPageSize(orgId, PAGE_SIZE),
queryFn: ({ pageParam }: { pageParam: string | null }) =>
getWorkspaceMembers(orgId, pageParam, PAGE_SIZE),
initialPageParam: null,
@@ -75,7 +76,7 @@ export default function MemberManagement() {
Awaited>,
IApiErrorResponse
>({
- queryKey: ["workspacePendingMembers", orgId],
+ queryKey: QUERY_KEYS.workspace.pendingMembers(orgId),
queryFn: () => getPendingMember(orgId),
enabled: Number.isFinite(orgId) && orgId > 0,
});
@@ -98,7 +99,7 @@ export default function MemberManagement() {
updateWorkspaceMemberPermission(orgId, memberId, body),
onSuccess: () => {
void queryClient.invalidateQueries({
- queryKey: ["workspaceMembers", orgId],
+ queryKey: QUERY_KEYS.workspace.members(orgId),
});
},
onError: (error) => {
@@ -108,13 +109,15 @@ export default function MemberManagement() {
const deleteMemberMutation = useMutation({
mutationFn: (memberId) => deleteWorkspaceMember(orgId, memberId),
- onSuccess: () => {
- void queryClient.invalidateQueries({
- queryKey: ["workspaceMembers", orgId],
- });
- void queryClient.invalidateQueries({
- queryKey: ["workspaceMemberCount", orgId],
- });
+ onSuccess: async () => {
+ await Promise.all([
+ queryClient.invalidateQueries({
+ queryKey: QUERY_KEYS.workspace.members(orgId),
+ }),
+ queryClient.invalidateQueries({
+ queryKey: QUERY_KEYS.workspace.memberCount(orgId),
+ }),
+ ]);
},
onError: (error) => {
toast.error(error.message ?? "멤버 삭제에 실패했습니다.");
diff --git a/src/pages/workspace/Workspace.tsx b/src/pages/workspace/Workspace.tsx
index 547b8a35..0b72a397 100644
--- a/src/pages/workspace/Workspace.tsx
+++ b/src/pages/workspace/Workspace.tsx
@@ -25,6 +25,7 @@ import { createWorkspace, getMyWorkspaces } from "@/api/workspace/org";
import PlusIcon from "@/assets/icon/common/plus.svg?react";
import SearchIcon from "@/assets/icon/common/search.svg?react";
import UpLoadImgIcon from "@/assets/icon/common/uploadImg.svg?react";
+import { QUERY_KEYS } from "@/lib/queryKeys";
import useWorkspaceStore from "@/store/useWorkspaceStore";
export default function WorkspacePage() {
@@ -42,7 +43,7 @@ export default function WorkspacePage() {
const queryClient = useQueryClient();
const workspacesQuery = useQuery({
- queryKey: ["my-workspaces"],
+ queryKey: QUERY_KEYS.workspace.list(),
queryFn: getMyWorkspaces,
});
@@ -53,8 +54,10 @@ export default function WorkspacePage() {
return createWorkspace({ name, description, imageFile: logoFile });
},
- onSuccess: () => {
- void queryClient.invalidateQueries({ queryKey: ["my-workspaces"] });
+ onSuccess: async () => {
+ await queryClient.invalidateQueries({
+ queryKey: QUERY_KEYS.workspace.list(),
+ });
setCreateOpen(false);
},
});
diff --git a/src/pages/workspace/WorkspaceSetting.tsx b/src/pages/workspace/WorkspaceSetting.tsx
index c1527591..82f550a3 100644
--- a/src/pages/workspace/WorkspaceSetting.tsx
+++ b/src/pages/workspace/WorkspaceSetting.tsx
@@ -5,7 +5,7 @@ import { toast } from "sonner";
import type { IApiErrorResponse } from "@/types/common/common";
-import useIsAdmin from "@/hooks/auth/useIsAdmin";
+import { useCoreQuery } from "@/hooks/customQuery";
import Button from "@/components/common/button/Button";
import Card from "@/components/common/card/Card";
@@ -17,17 +17,25 @@ import WorkspaceSettingLoading from "@/components/workspace/WorkspaceSettingLoad
import {
deleteWorkspace,
+ getMyWorkspaces,
getWorkspace,
updateWorkspace,
} from "@/api/workspace/org";
import BuildingIcon from "@/assets/icon/common/building.svg?react";
import WarnIcon from "@/assets/icon/common/warn-circle.svg?react";
import { getImageUrl } from "@/lib/getImageUrl";
+import { QUERY_KEYS } from "@/lib/queryKeys";
export default function WorkspaceSetting() {
- const isAdmin = useIsAdmin();
const navigate = useNavigate();
const { workspaceId } = useParams();
+
+ const { data: workspaces } = useCoreQuery(["my-workspaces"], getMyWorkspaces);
+ const isAdmin = useMemo(() => {
+ if (!workspaceId || !workspaces) return false;
+ const workspace = workspaces.find((w) => w.orgId === Number(workspaceId));
+ return workspace?.myRole === "ADMIN";
+ }, [workspaceId, workspaces]);
const queryClient = useQueryClient();
const orgId = useMemo(() => {
@@ -101,7 +109,9 @@ export default function WorkspaceSetting() {
imageFile: logoFile,
isImageDeleted,
});
- await queryClient.invalidateQueries({ queryKey: ["my-workspaces"] });
+ await queryClient.invalidateQueries({
+ queryKey: QUERY_KEYS.workspace.list(),
+ });
toast.success("변경사항이 저장되었습니다");
await fetchWorkspaceDetail();
} catch (e) {
@@ -124,7 +134,9 @@ export default function WorkspaceSetting() {
setDeleting(true);
try {
await deleteWorkspace(orgId);
- await queryClient.invalidateQueries({ queryKey: ["my-workspaces"] });
+ await queryClient.invalidateQueries({
+ queryKey: QUERY_KEYS.workspace.list(),
+ });
toast.success("워크스페이스가 삭제되었습니다");
setDeleteOpen(false);
navigate("/workspace", { replace: true });
@@ -254,7 +266,7 @@ export default function WorkspaceSetting() {