diff --git a/.cursor/rules/always.mdc b/.cursor/rules/always.mdc new file mode 100644 index 00000000..e131b3e4 --- /dev/null +++ b/.cursor/rules/always.mdc @@ -0,0 +1,83 @@ +--- +description: WhereYouAd 팀 공용 Cursor 규칙 — 레포를 열면 자동 적용 +alwaysApply: true +--- + +# WhereYouAd Frontend — 팀 공용 Cursor 규칙 + +## 강제 규칙 (에이전트 최우선) + +1. **색**: `src/styles/tokens.css` `@theme` 토큰만. 토큰 **이름 변경·임의 색 추가 금지**. +2. **스타일링**: `var(--*)`·인라인 hex·임의 `bg-[#...]` **금지** → `bg-*` `text-*` `border-*` 유틸만. 조건부 클래스는 `twMerge`. +3. **SVG**: `fill`/`stroke="currentColor"`, 색은 `text-*`. React는 `*.svg?react` + `@/` import. +4. **"디자인 변경 금지"** 요청 시: **색상 클래스만** (spacing·타이포·DOM 변경 금지). +5. **상태**: UI → Zustand (`src/store/`) · 서버 → react-query (`src/hooks/`) · 폼 → RHF. 스토어에 서버 데이터·전역 폼값 금지. +6. **범위**: 요청·연 파일 기준 최소 diff. 요청 없는 파일 수정·리팩터·README 등 문서 생성 금지. +7. **커밋**: 사용자 요청 시만. 제목 **1줄** `type: subject` (commitlint). `push`·PR·`commit --amend`·`push --force`는 요청 없으면 금지. +8. **머지·리베이스 충돌**: UI·레이아웃·간격·타이포·`rounded` 등 **디자인은 현재 브랜치 우선**. API·타입·버그픽스는 합리적으로 합침. 색·토큰은 1–2 유지. + +```tsx +// ❌ bg-[#4b74f5] style={{ color: "var(--color-primary-400)" }} +// ✅ twMerge("bg-primary-400", disabled && "opacity-50") + +// ❌ // ❌ useQuery를 컴포넌트에서 직접 정의 +// ✅ // ✅ useCoreQuery (customQuery.ts) +``` + +--- + +## 코딩 패턴 + +### 데이터·API + +- **API 호출**: `src/api/`만. `axiosInstance`(일반) / `authInstance`(쿠키·인증) 사용. +- **서버 상태 훅**: `useCoreQuery` · `useCoreMutation` (`src/hooks/customQuery.ts`). `useQuery`/`useMutation` 직접 사용 지양. +- **환경 변수**: `VITE_API_BASE_URL` 등 `import.meta.env.VITE_*`만. `.env` 커밋 금지. +- **목 데이터**: `*.mock.ts`로 분리. 프로덕션 API·훅에 mock 혼입 금지. + +### 타입·import + +- API·요청/응답: `I*` (예: `ILoginRequest`) · 유니온·옵션: `T*` · props: `I*Props`. +- 공통 타입 `src/types/` · 도메인별 `src/types/{도메인}/` · props 등 로컬 타입은 파일 상단. +- import는 `@/` 별칭. 저장 시 `eslint-plugin-simple-import-sort` 자동 정렬 (수동 정렬 불필요). + +### UI·컴포넌트 + +- 버튼·입력·모달·카드 등은 `components/common/` **재사용 우선**. 없을 때만 도메인 폴더에 추가. +- 페이지 `pages/` · 조각 UI `components/{도메인}/` · 라우트·가드 `routes/`. +- 로딩: `components/common/skeleton/Skeleton` + 도메인 `*Skeleton.tsx`. +- 사용자 알림: `sonner` `toast`. 차트·외부 라이브러리 색도 `@theme` 토큰에 맞출 것. + +--- + +## 프로젝트·환경 + +광고 성과·워크스페이스 관리 FE (2026 Capstone) · 팀: 서제경, 박재선, 임예림 + +| Core | React 19, TS, Vite, react-router-dom v7 | +| State | zustand · react-query · RHF + zod | +| Style | Tailwind v4 — `tokens.css` · `utilities.css` · `base.css` → `index.css` | +| Quality | ESLint, Prettier, husky, lint-staged, commitlint, Playwright | + +**Node** v20 LTS · **pnpm** · `dev` / `build` / `lint` / `test:e2e` / `storybook` + +**브랜치**: `main` · `develop` · `feature/#N` · `fix/#N` · `refactor/#N` + +``` +src/api assets components constants hooks layout lib pages routes store types utils +``` + +--- + +## Git·PR·Cursor 활용 + +**commit type**: `feat` `fix` `docs` `style` `refactor` `test` `chore` `ci` `setting` + +**PR**: `[Type/#이슈번호] 작업 요약` · P1 필수 · P2 권장 · P3 제안 · P4 Nit + +**팀원 프롬프트 팁** (규칙 대체 아님, 요청 품질용): + +- 이슈 번호·브랜치·**관련 파일 @참조**를 함께 적기 (`@LandingGuide.tsx` 등). +- UI 작업 시 **"디자인 변경 금지"** / **"색만 변경"** 등 제약을 명시. +- API·타입 변경 시 백엔드 스펙·에러 케이스를 함께 전달. +- 작업 후 `pnpm run lint` 통과 확인. E2E는 `E2E_USER_EMAIL` / `E2E_USER_PASSWORD` 필요. diff --git a/.cursor/rules/private-always.mdc b/.cursor/rules/private-always.mdc deleted file mode 100644 index 081681eb..00000000 --- a/.cursor/rules/private-always.mdc +++ /dev/null @@ -1,117 +0,0 @@ ---- -description: Personal always-on rules + project context (local only) -alwaysApply: true ---- - -# WhereYouAd Frontend — Cursor 규칙 - -**로컬 전용.** 팀 규칙·레포와 **동기화하지 않음** (본인 Cursor에만 적용). - -## 강제 규칙 (에이전트 최우선) - -1. **색**: `tokens.css`의 `@theme` 토큰만. 토큰 **이름 변경·임의 색 추가 금지**. -2. **컴포넌트/페이지**: `var(--*)`·인라인 hex **금지** → `bg-*` `text-*` `border-*` 유틸만. -3. **SVG**: `fill`/`stroke="currentColor"`, 색은 `text-*`. -4. **“디자인 변경 금지”** 요청 시: **색상 클래스만** (spacing·타이포·DOM 변경 금지). -5. **상태**: UI → Zustand · 서버 → react-query(스토어에 서버 데이터 금지) · 폼 → RHF(전역에 폼값 금지). -6. **커밋**: 제목 **1줄만** `type: subject`. 커밋 전 `eslint --fix`. -7. **Git**: 요청 없으면 `push`·PR 생성·`commit --amend`·`push --force` 금지. -8. **머지·리베이스**: 원격과 충돌 시 **디자인(UI·레이아웃·간격·타이포·`rounded` 등)은 로컬(현재 작업 브랜치) 우선**. API·타입·버그픽스 등은 합리적으로 합친다. 색·토큰은 규칙 1–2 유지. - ---- - -## `#project-overview` — 프로젝트 개요 - -- **프로젝트명**: WhereYouAd -- **한 줄**: 광고 성과 및 워크스페이스 관리를 지원하는 웹 서비스 **프론트엔드**. -- **성격**: 2026 Capstone · Graduation Project. -- **목표**: 대시보드 UI, 안정적인 상태 관리, 확장 가능한 구조. -- **팀 (Frontend)**: 서제경, 박재선, 임예림. -- **기술 방향**: 빠른 렌더링·효율적 데이터 패칭, **UI State / Server State 분리**, 린트·컨벤션으로 품질 유지. - -### 기술 스택 (요약) - -| 구분 | 내용 | -| ------------ | --------------------------------------------------- | -| Core | React 19, TypeScript, Vite | -| Routing | react-router-dom v7 | -| Client state | zustand | -| Server state | @tanstack/react-query | -| Styling | Tailwind CSS v4, clsx, vite-plugin-svgr | -| Form | react-hook-form, zod, @hookform/resolvers, sonner | -| 품질 | ESLint v9, Prettier, husky, lint-staged, commitlint | - -### 개발 환경 - -- **Node**: v20.x LTS 권장. **패키지 매니저**: pnpm. -- **스크립트**: `pnpm run dev` / `build` / `preview` / `lint` / `prepare`(Husky). - -### 브랜치 전략 - -- `main`: 배포 가능. `develop`: 다음 버전 개발. -- `feature/#[이슈번호]`: 기능 브랜치 (예: `feature/#1`). - ---- - -## `#feature-requirements` — 기능 요구사항 - -- 광고 **성과 지표 대시보드**. -- **워크스페이스** 단위 광고·캠페인 관리. -- 성과 데이터 **시각화·요약 리포트**. -- **인증** 및 **권한** 기반 접근 제어. - ---- - -## `#relevant-codes` — 경로·역할 - -``` -src/ -├── api/ API 통신 -├── assets/ 정적 리소스 -├── components/ UI 컴포넌트 -├── constants/ 상수 -├── layout/ 레이아웃 -├── pages/ 라우트 단위 페이지 -├── routes/ 라우터 -├── store/ Zustand -├── types/ 공통 타입 -└── utils/ 유틸 -``` - -- 스타일 토큰: `src/styles/tokens.css`, `utilities.css`, `base.css` · 엔트리: `src/index.css`. -- 외부 차트 등 라이브러리 옵션 색은 `@theme`과 맞출 것(임의 hex 추가 지양). - ---- - -## `#Current-file-instruction` — 현재 파일·작업 시 - -- 연 파일·커서 위치를 기준으로 수정 범위를 좁힌다. -- 색·디자인 제약은 **상단 강제 규칙 1–4**를 따른다. - ---- - -## `#rules` — 보강·참고 - -### 색상·스타일 - -- **상단 강제 규칙 1–4**와 동일. 토큰 경로는 `#relevant-codes`. - -### 타입 - -- 공통 타입(`Workspace`, `Campaign` 등): `src/types/`. -- 컴포넌트 로컬 타입(props 등): 해당 파일 상단. -- API 응답 타입: `src/types/api.ts` 또는 도메인별 파일. - -### 상태 관리 - -- **상단 강제 규칙 5**와 동일. - -### 커밋 - -- **상단 강제 규칙 6**과 동일. -- 허용 `type`: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`, `ci`, `setting`. - -### Git·PR - -- **상단 강제 규칙 7–8**과 동일. -- PR 제목 예: `[Type/#이슈번호] 작업 요약`. 리뷰 라벨: P1 필수 · P2 권장 · P3 제안 · P4 Nit. diff --git a/src/api/dashboard/overview.ts b/src/api/dashboard/overview.ts index d5208be3..67900223 100644 --- a/src/api/dashboard/overview.ts +++ b/src/api/dashboard/overview.ts @@ -1,6 +1,6 @@ import type { ICommonResponse } from "@/types/common/common"; import type { - IBudgetsResponse, + IBudgetResponse, IMetricsResponse, IRoasRankingsParams, IRoasRankingsResponse, @@ -25,8 +25,8 @@ export const getOverview = async ( export const getBudget = async ( orgId: number, providerType?: TProviderType, -): Promise => { - const { data } = await axiosInstance.get>( +): Promise => { + const { data } = await axiosInstance.get>( `/api/dashboard/budgets`, { params: { orgId, ...(providerType ? { providerType } : {}) } }, ); diff --git a/src/api/integration/google.ts b/src/api/integration/google.ts new file mode 100644 index 00000000..6dc997cc --- /dev/null +++ b/src/api/integration/google.ts @@ -0,0 +1,32 @@ +import type { ICommonResponse } from "@/types/common/common"; + +import { axiosInstance } from "@/lib/axiosInstance"; + +interface IGoogleLoginResponse { + redirectUrl: string; +} + +/** + * Google Ads OAuth 시작. + * + * Accept: application/json → 302 Location을 JSON redirectUrl로 받은 뒤 + * window.location으로 이동 (XHR이 Google에 직접 가지 않음). + * + * 로컬: vite dev middleware가 302 → JSON 변환 + * 운영: 백엔드가 동일 JSON 응답 필요 + */ +export async function startGoogleOAuthLogin(orgId: number): Promise { + const { data } = await axiosInstance.get< + ICommonResponse + >("/api/google/login", { + params: { orgId }, + headers: { Accept: "application/json" }, + }); + + const redirectUrl = data.data?.redirectUrl; + if (!redirectUrl) { + throw new Error("Google 연동 URL을 받지 못했습니다."); + } + + window.location.assign(redirectUrl); +} diff --git a/src/api/integration/meta.ts b/src/api/integration/meta.ts new file mode 100644 index 00000000..e236faf4 --- /dev/null +++ b/src/api/integration/meta.ts @@ -0,0 +1,32 @@ +import type { ICommonResponse } from "@/types/common/common"; + +import { axiosInstance } from "@/lib/axiosInstance"; + +interface IMetaAuthUrlResponse { + authUrl: string; +} + +/** + * Meta Ads OAuth 시작. + * + * Accept: application/json → authUrl(JSON) 또는 302 Location을 받은 뒤 + * window.location으로 이동 (XHR이 Meta에 직접 가지 않음). + * + * 로컬: vite dev middleware가 302 → JSON 변환 + * 운영: 백엔드가 동일 JSON 응답 필요 + */ +export async function startMetaOAuthLogin(orgId: number): Promise { + const { data } = await axiosInstance.get< + ICommonResponse + >("/api/meta/auth-url", { + params: { orgId }, + headers: { Accept: "application/json" }, + }); + + const authUrl = data.data?.authUrl; + if (!authUrl) { + throw new Error("Meta 연동 URL을 받지 못했습니다."); + } + + window.location.assign(authUrl); +} diff --git a/src/api/integration/naver.ts b/src/api/integration/naver.ts new file mode 100644 index 00000000..fac088bf --- /dev/null +++ b/src/api/integration/naver.ts @@ -0,0 +1,17 @@ +import type { ICommonResponse } from "@/types/common/common"; +import type { + INaverConnectResponseData, + INaverCredentialsRequest, +} from "@/types/integration/naver"; + +import { axiosInstance } from "@/lib/axiosInstance"; + +export async function connectNaverAccount( + orgId: number, + body: INaverCredentialsRequest, +): Promise { + await axiosInstance.post>( + `/api/platform/${orgId}/accounts/naver`, + body, + ); +} diff --git a/src/api/integration/platformAccounts.ts b/src/api/integration/platformAccounts.ts new file mode 100644 index 00000000..f591f373 --- /dev/null +++ b/src/api/integration/platformAccounts.ts @@ -0,0 +1,13 @@ +import type { ICommonResponse } from "@/types/common/common"; +import type { IPlatformAccountsResponseData } from "@/types/integration/platformConnection"; + +import { axiosInstance } from "@/lib/axiosInstance"; + +export const getPlatformAccounts = async ( + orgId: number, +): Promise => { + const { data } = await axiosInstance.get< + ICommonResponse + >(`/api/platform/${orgId}/accounts`); + return data.data; +}; diff --git a/src/assets/icon/sidebar/connect.svg b/src/assets/icon/sidebar/connect.svg new file mode 100644 index 00000000..94b046cc --- /dev/null +++ b/src/assets/icon/sidebar/connect.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icon/sidebar/dashboard.svg b/src/assets/icon/sidebar/dashboard.svg index f5c0ef37..f6a2c150 100644 --- a/src/assets/icon/sidebar/dashboard.svg +++ b/src/assets/icon/sidebar/dashboard.svg @@ -1,6 +1,6 @@ - - - - + + + + diff --git a/src/assets/icon/sidebar/workspace.svg b/src/assets/icon/sidebar/workspace.svg index c1d416fb..c7449932 100644 --- a/src/assets/icon/sidebar/workspace.svg +++ b/src/assets/icon/sidebar/workspace.svg @@ -1,3 +1,3 @@ - - + + diff --git a/src/components/auth/common/CommonAuthInput.tsx b/src/components/auth/common/CommonAuthInput.tsx index c94eb0a2..0bcc6b7a 100644 --- a/src/components/auth/common/CommonAuthInput.tsx +++ b/src/components/auth/common/CommonAuthInput.tsx @@ -1,6 +1,6 @@ import React, { useState } from "react"; -import formatPhoneNumber from "@/utils/formatPhoneNumber"; +import formatPhoneNumber from "@/utils/auth/formatPhoneNumber"; import Input, { type IInputProps } from "@/components/common/input/Input"; diff --git a/src/components/auth/common/PasswordForm.tsx b/src/components/auth/common/PasswordForm.tsx index 1512c2d7..3a68da6e 100644 --- a/src/components/auth/common/PasswordForm.tsx +++ b/src/components/auth/common/PasswordForm.tsx @@ -3,7 +3,7 @@ import { type SubmitHandler, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import type { z } from "zod"; -import { signupPasswordSchema } from "@/utils/validation"; +import { signupPasswordSchema } from "@/utils/auth/validation"; import AuthFormShell from "@/components/auth/common/AuthFormShell"; import CommonAuthInput from "@/components/auth/common/CommonAuthInput"; diff --git a/src/components/auth/flows/find-email/EnterPhoneStep.tsx b/src/components/auth/flows/find-email/EnterPhoneStep.tsx index 45507432..7e49f7e5 100644 --- a/src/components/auth/flows/find-email/EnterPhoneStep.tsx +++ b/src/components/auth/flows/find-email/EnterPhoneStep.tsx @@ -5,8 +5,8 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { toast } from "sonner"; import type { z } from "zod"; -import { stripPhoneHyphens } from "@/utils/formatPhoneNumber"; -import { findEmailSchema } from "@/utils/validation"; +import { stripPhoneHyphens } from "@/utils/auth/formatPhoneNumber"; +import { findEmailSchema } from "@/utils/auth/validation"; import { useAuth } from "@/hooks/auth/useAuth"; import { useTimer } from "@/hooks/common/useTimer"; diff --git a/src/components/auth/flows/find-email/ShowEmailResultStep.tsx b/src/components/auth/flows/find-email/ShowEmailResultStep.tsx index 7abcba9c..d520bdd3 100644 --- a/src/components/auth/flows/find-email/ShowEmailResultStep.tsx +++ b/src/components/auth/flows/find-email/ShowEmailResultStep.tsx @@ -1,6 +1,6 @@ import { useNavigate } from "react-router-dom"; -import { maskEmail } from "@/utils/maskEmail"; +import { maskEmail } from "@/utils/auth/maskEmail"; import AuthFormShell from "@/components/auth/common/AuthFormShell"; import Button from "@/components/common/button/Button"; diff --git a/src/components/auth/flows/signup/ProfileSetupStep.tsx b/src/components/auth/flows/signup/ProfileSetupStep.tsx index 7146e309..bfd41e34 100644 --- a/src/components/auth/flows/signup/ProfileSetupStep.tsx +++ b/src/components/auth/flows/signup/ProfileSetupStep.tsx @@ -5,8 +5,8 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { toast } from "sonner"; import type { z } from "zod"; -import { stripPhoneHyphens } from "@/utils/formatPhoneNumber"; -import { signupProfileSchema } from "@/utils/validation"; +import { stripPhoneHyphens } from "@/utils/auth/formatPhoneNumber"; +import { signupProfileSchema } from "@/utils/auth/validation"; import { useAuth } from "@/hooks/auth/useAuth"; diff --git a/src/components/common/dropdownmenu/DropdownMenu.tsx b/src/components/common/dropdownmenu/DropdownMenu.tsx index 6ed9aa8f..265de431 100644 --- a/src/components/common/dropdownmenu/DropdownMenu.tsx +++ b/src/components/common/dropdownmenu/DropdownMenu.tsx @@ -7,6 +7,8 @@ export type TMenuItem = { icon?: React.ReactNode; onClick: () => void; active?: boolean; + danger?: boolean; + labelClassName?: string; }; const easeOut = [0, 0, 0.2, 1] as const; @@ -114,9 +116,11 @@ export function DropdownMenu({ }} className={twMerge( "group flex w-full items-center justify-between rounded-2xl px-5 py-4 text-left font-body2 transition-ui-fast", - it.active - ? "bg-info-blue/10 text-info-blue" - : "text-text-body hover:bg-primary-100/50 hover:text-info-blue", + it.danger + ? "text-info-red hover:bg-info-red/10 hover:text-info-red" + : it.active + ? "bg-info-blue/10 text-info-blue" + : "text-text-body hover:bg-primary-100/50 hover:text-info-blue", )} >
@@ -124,9 +128,11 @@ export function DropdownMenu({
- ₩{spent.toLocaleString()} - ₩{totalBudget.toLocaleString()} + {M.spend.format(spent)} + {M.spend.format(totalBudget)}
@@ -132,7 +134,8 @@ const BudgetGaugeChart = memo(function BudgetGaugeChart({ isOverBudget && "text-info-red", )} > - {isOverBudget ? "-" : ""}₩{remaining.toLocaleString()} + {isOverBudget ? "-" : ""} + {M.spend.format(remaining)} diff --git a/src/components/dashboard/charts/PerformanceEfficiencyChart.tsx b/src/components/dashboard/charts/PerformanceEfficiencyChart.tsx index 9268f76f..9d814efe 100644 --- a/src/components/dashboard/charts/PerformanceEfficiencyChart.tsx +++ b/src/components/dashboard/charts/PerformanceEfficiencyChart.tsx @@ -3,6 +3,8 @@ import { lazy, memo, Suspense, useMemo } from "react"; import type { IPlatformPerformance } from "@/types/dashboard/platform"; import { PLATFORM_MAP } from "@/types/dashboard/provider"; +import { METRIC_REGISTRY as M } from "@/utils/dashboard/metricRegistry"; + import { getMixedChartOptions } from "./performanceEfficiencyChart.config"; const Chart = lazy(() => import("react-apexcharts")); @@ -20,20 +22,20 @@ export const PerformanceEfficiencyChart = memo( const series = [ { - name: "클릭률(CTR)", - type: "column", // 세로 막대 + name: M.ctr.label, + type: "column", data: data.map((d) => d.impressions > 0 ? (d.clicks / d.impressions) * 100 : 0, ), }, { - name: "전환율(CVR)", - type: "column", // 세로 막대 + name: M.conversion.label, + type: "column", data: data.map((d) => d.conversion), }, { - name: "노출수", - type: "line", // 점 + name: M.impressions.label, + type: "line", data: data.map((d) => d.impressions), }, ]; diff --git a/src/components/dashboard/charts/TrafficChart.tsx b/src/components/dashboard/charts/TrafficChart.tsx index 54676bbd..ef59a738 100644 --- a/src/components/dashboard/charts/TrafficChart.tsx +++ b/src/components/dashboard/charts/TrafficChart.tsx @@ -9,6 +9,9 @@ import { useState, } from "react"; +import { METRIC_REGISTRY as M } from "@/utils/dashboard/metricRegistry"; +import { parseMinuteToTimestamp } from "@/utils/dashboard/parseMinuteToTimestamp"; + import { useClickStream } from "@/hooks/dashboard/useClickStream"; import { DropdownMenu } from "@/components/common/dropdownmenu/DropdownMenu"; @@ -122,15 +125,11 @@ const TrafficChart = memo(function TrafficChart() { const chartData = items .filter((d) => (d.minute?.length ?? 0) >= 12) - .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); - const x = new Date(year, month, day, hour, min).getTime(); - return { x, y: d.count, minute: d.minute }; - }) + .map((d) => ({ + x: parseMinuteToTimestamp(d.minute), + y: d.count, + minute: d.minute, + })) .filter((p) => !Number.isNaN(p.x)); // 해당 일 00:00 (데이터 없으면 오늘) @@ -200,7 +199,7 @@ const TrafficChart = memo(function TrafficChart() { return { series: [ { - name: "클릭수", + name: M.clicks.label, data: chartData.map(({ x, y }) => ({ x, y })), }, ], diff --git a/src/components/dashboard/charts/budgetGaugeChart.mock.ts b/src/components/dashboard/charts/budgetGaugeChart.mock.ts deleted file mode 100644 index f51e5fab..00000000 --- a/src/components/dashboard/charts/budgetGaugeChart.mock.ts +++ /dev/null @@ -1,14 +0,0 @@ -export interface IBudgetGaugeChartData { - totalBudget: number; // 총 예산 - spent: number; // 현재 소진액 - warningThreshold: number; // 주의 - dangerThreshold: number; // 위험 -} - -// 목업 데이터 -export const budgetGaugeChartMock: IBudgetGaugeChartData = { - totalBudget: 10000000, - spent: 6200000, - warningThreshold: 50, - dangerThreshold: 75, -}; diff --git a/src/components/dashboard/charts/performanceEfficiencyChart.config.ts b/src/components/dashboard/charts/performanceEfficiencyChart.config.ts index 431cda83..648244fe 100644 --- a/src/components/dashboard/charts/performanceEfficiencyChart.config.ts +++ b/src/components/dashboard/charts/performanceEfficiencyChart.config.ts @@ -1,5 +1,7 @@ import type { ApexOptions } from "apexcharts"; +import { METRIC_REGISTRY as M } from "@/utils/dashboard/metricRegistry"; + export const getMixedChartOptions = (categories: string[]): ApexOptions => ({ chart: { type: "line", @@ -44,9 +46,9 @@ export const getMixedChartOptions = (categories: string[]): ApexOptions => ({ }, yaxis: [ { - seriesName: "클릭률(CTR)", + seriesName: M.ctr.label, labels: { - formatter: (val) => `${val.toFixed(1)}%`, + formatter: (val) => M.ctr.format(val), style: { colors: "var(--color-text-muted)", fontSize: "12px", @@ -54,15 +56,15 @@ export const getMixedChartOptions = (categories: string[]): ApexOptions => ({ }, }, { - seriesName: "클릭률(CTR)", + seriesName: M.ctr.label, show: false, }, { opposite: true, - seriesName: "노출수", + seriesName: M.impressions.label, labels: { offsetX: -10, - formatter: (val) => val.toLocaleString("ko-KR"), + formatter: (val) => M.impressions.format(val), style: { colors: "var(--color-text-muted)", fontSize: "12px", @@ -75,10 +77,13 @@ export const getMixedChartOptions = (categories: string[]): ApexOptions => ({ intersect: false, y: { formatter: (val, { seriesIndex }) => { - if (seriesIndex === 0 || seriesIndex === 1) { - return `${val.toFixed(2)}%`; + if (seriesIndex === 0) { + return M.ctr.format(val); + } + if (seriesIndex === 1) { + return M.conversion.format(val); } - return val.toLocaleString("ko-KR"); + return M.impressions.format(val); }, }, }, diff --git a/src/components/dashboard/charts/trafficChart.config.ts b/src/components/dashboard/charts/trafficChart.config.ts index 34b4f72a..972548a9 100644 --- a/src/components/dashboard/charts/trafficChart.config.ts +++ b/src/components/dashboard/charts/trafficChart.config.ts @@ -4,7 +4,12 @@ import { downloadChartCsv, downloadChartPng, downloadChartSvg, -} from "@/utils/download"; +} from "@/utils/dashboard/downloadChart"; +import { + formatCountChartAxis, + formatCountChartTooltip, + METRIC_REGISTRY as M, +} from "@/utils/dashboard/metricRegistry"; // 차트 고유 ID export const CHART_ID = "traffic-chart"; @@ -81,7 +86,7 @@ export function buildChartOptions(params: { filename: `overview-traffic-data-${TODAY}`, columnDelimiter: ",", headerCategory: "시간", - headerValue: "클릭수", + headerValue: M.clicks.label, }, }, }, @@ -158,12 +163,7 @@ export function buildChartOptions(params: { max: yMax, tickAmount: 5, labels: { - formatter: (val: number) => { - if (val === 0) return ""; - const rounded = Math.round(val); - if (rounded < 1000) return rounded.toLocaleString(); - return `${Math.round(rounded / 1000)}K`; - }, + formatter: formatCountChartAxis, style: { colors: "var(--color-text-muted)", fontSize: "12px" }, }, }, @@ -178,7 +178,7 @@ export function buildChartOptions(params: { tooltip: { x: { show: false }, y: { - formatter: (val: number) => val.toLocaleString(), + formatter: (val: number) => formatCountChartTooltip(val), }, style: { fontFamily: "Pretendard" }, }, diff --git a/src/components/dashboard/platform/AllPlatformTrafficChart.tsx b/src/components/dashboard/platform/AllPlatformTrafficChart.tsx index 0fd8f9e1..a0637585 100644 --- a/src/components/dashboard/platform/AllPlatformTrafficChart.tsx +++ b/src/components/dashboard/platform/AllPlatformTrafficChart.tsx @@ -8,17 +8,16 @@ import { PROVIDER_TYPES, } from "@/types/dashboard/provider"; -import { Skeleton } from "@/components/common/skeleton/Skeleton"; +import { + formatCountChartAxis, + formatCountChartTooltip, + METRIC_REGISTRY as M, +} from "@/utils/dashboard/metricRegistry"; +import { parseMinuteToTimestamp } from "@/utils/dashboard/parseMinuteToTimestamp"; import { platformTrafficMock } from "@/pages/dashboard/platform/platformDashboard.mock"; -interface IAllPlatformTrafficChartProps { - isLoading?: boolean; -} - -const AllPlatformTrafficChart = memo(function AllPlatformTrafficChart({ - isLoading, -}: IAllPlatformTrafficChartProps) { +const AllPlatformTrafficChart = memo(function AllPlatformTrafficChart() { // 3개 플랫폼의 데이터를 모두 변환하여 series 구성 const seriesData = useMemo(() => { return PROVIDER_TYPES.map((platform) => { @@ -26,17 +25,10 @@ const AllPlatformTrafficChart = memo(function AllPlatformTrafficChart({ return { name: PLATFORM_MAP[platform], color: PLATFORM_CHART_COLORS[platform], - data: 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, - }; - }), + data: data.timeSeriesData.map((d) => ({ + x: parseMinuteToTimestamp(d.minute), + y: d.count, + })), }; }); }, []); @@ -110,12 +102,7 @@ const AllPlatformTrafficChart = memo(function AllPlatformTrafficChart({ tickAmount: 5, labels: { style: { colors: "var(--color-text-muted)", fontSize: "12px" }, - formatter: (val) => { - const rounded = Math.round(val); - if (rounded <= 0) return ""; - if (rounded < 1000) return rounded.toLocaleString(); - return `${Math.round(rounded / 1000)}K`; - }, + formatter: formatCountChartAxis, }, }, grid: { @@ -131,16 +118,15 @@ const AllPlatformTrafficChart = memo(function AllPlatformTrafficChart({ shared: true, // 여러 플랫폼 동시 비교 가능 intersect: false, x: { show: false }, - y: { formatter: (val) => `${val.toLocaleString()} 클릭` }, + y: { + formatter: (val) => + formatCountChartTooltip(val, M.clicks.chartTooltipUnit), + }, theme: "light", }, legend: { show: false }, }; - if (isLoading) { - return ; - } - return (
({ color: PLATFORM_CHART_COLORS[provider], })); -interface IAllPlatformViewProps { - isLoading: boolean; -} - -export default function AllPlatformView({ isLoading }: IAllPlatformViewProps) { +export default function AllPlatformView() { const { data: roasRankings, isLoading: isRankingsLoading, @@ -59,17 +57,13 @@ export default function AllPlatformView({ isLoading }: IAllPlatformViewProps) { - ) : ( - - ROAS 기준 상위 3 - - ) + + ROAS 기준 상위 3 + } className="flex-1 min-h-67 flex flex-col" > - {isLoading || isRankingsLoading ? ( + {isRankingsLoading ? ( ) : isRankingsError || !roasRankings ? (
@@ -94,7 +88,7 @@ export default function AllPlatformView({ isLoading }: IAllPlatformViewProps) { /> } RightElement={ - isLoading || isAdStatusLoading ? ( + isAdStatusLoading ? ( ) : adStatus ? ( @@ -104,7 +98,7 @@ export default function AllPlatformView({ isLoading }: IAllPlatformViewProps) { } className="flex-1 min-h-67 flex flex-col" > - {isLoading || isAdStatusLoading ? ( + {isAdStatusLoading ? ( ) : isAdStatusError || !adStatus ? (
@@ -126,14 +120,14 @@ export default function AllPlatformView({ isLoading }: IAllPlatformViewProps) { description={ } > - {isLoading || isPerformanceLoading ? ( + {isPerformanceLoading ? ( ) : isPerformanceError || !platformPerformance ? (
@@ -156,13 +150,13 @@ export default function AllPlatformView({ isLoading }: IAllPlatformViewProps) { description={} >
- +
{/* 개별 플랫폼 상세 */}
- {isLoading || isPerformanceLoading ? ( + {isPerformanceLoading ? ( Array.from({ length: 3 }).map((_, i) => ( )) diff --git a/src/components/dashboard/platform/PlatformDetailCard.tsx b/src/components/dashboard/platform/PlatformDetailCard.tsx index 52462309..900cc2c8 100644 --- a/src/components/dashboard/platform/PlatformDetailCard.tsx +++ b/src/components/dashboard/platform/PlatformDetailCard.tsx @@ -1,8 +1,10 @@ -import React, { memo } from "react"; +import React, { memo, useMemo } from "react"; import type { IPlatformPerformance } from "@/types/dashboard/platform"; import { PLATFORM_MAP } from "@/types/dashboard/provider"; +import { metricsToKpis } from "@/utils/dashboard/metricsToKpis"; + import Card from "@/components/common/card/Card"; import StatCard from "@/components/common/card/StatCard"; @@ -18,85 +20,31 @@ const PLATFORM_LOGOS: Record = { export const PlatformDetailCard = memo( ({ data }: { data: IPlatformPerformance }) => { - const { - provider, - impressions, - clicks, - conversion, - ROAS, - impressionChangeRate, - clickChangeRate, - cvrChangeRate, - ROASChangeRate, - } = data; + const { provider } = data; + const kpis = useMemo(() => metricsToKpis(data), [data]); const innerCardClass = "shadow-none! hover:shadow-none! !rounded-2xl p-2! gap-2!"; return ( - {/* 로고 + 이름 */} -
+
{PLATFORM_LOGOS[provider]}
-

+

{PLATFORM_MAP[provider]}

- {/* 지표 */}
- 0 ? "up" : "down", - value: `${Math.abs(impressionChangeRate).toFixed(1)}%`, - } - : undefined - } - className={innerCardClass} - /> - 0 ? "up" : "down", - value: `${Math.abs(clickChangeRate).toFixed(1)}%`, - } - : undefined - } - className={innerCardClass} - /> - 0 ? "up" : "down", - value: `${Math.abs(cvrChangeRate).toFixed(1)}%`, - } - : undefined - } - className={innerCardClass} - /> - 0 ? "up" : "down", - value: `${Math.abs(ROASChangeRate).toFixed(1)}%`, - } - : undefined - } - className={innerCardClass} - /> + {kpis.map((kpi) => ( + + ))}
); diff --git a/src/components/dashboard/platform/PlatformDetailTable.tsx b/src/components/dashboard/platform/PlatformDetailTable.tsx index 5f3ddb54..8b714fd2 100644 --- a/src/components/dashboard/platform/PlatformDetailTable.tsx +++ b/src/components/dashboard/platform/PlatformDetailTable.tsx @@ -2,6 +2,8 @@ import { useMemo } from "react"; import type { IPlatformDailyPerformance } from "@/types/dashboard/platform"; +import { METRIC_REGISTRY as M } from "@/utils/dashboard/metricRegistry"; + interface IPlatformDetailTableProps { data: IPlatformDailyPerformance[]; total?: IPlatformDailyPerformance | null; @@ -51,25 +53,25 @@ function PlatformDetailTable({ 날짜 - 비용(지출) + {M.spend.label} - 노출 수 + {M.impressions.label} - 클릭 수 + {M.clicks.label} - CTR(클릭률) + {M.ctr.label} - CPA + {M.cpa.label} - 전환 수 + {M.conversions.label} - ROAS + {M.roas.label} @@ -79,25 +81,25 @@ function PlatformDetailTable({ 합계 - ₩{total.spend.toLocaleString()} + {M.spend.format(total.spend)} - {total.impressions.toLocaleString()} + {M.impressions.format(total.impressions)} - {total.clicks.toLocaleString()} + {M.clicks.format(total.clicks)} - {total.ctr.toFixed(2)}% + {M.ctr.format(total.ctr)} - ₩{Math.round(total.cpa).toLocaleString()} + {M.cpa.format(total.cpa)} - {total.conversions.toLocaleString()} + {M.conversions.format(total.conversions)} - {Math.round(total.roas)}% + {M.roas.format(total.roas)} )} @@ -111,25 +113,25 @@ function PlatformDetailTable({ {row.date} - ₩{row.spend.toLocaleString()} + {M.spend.format(row.spend)} - {row.impressions.toLocaleString()} + {M.impressions.format(row.impressions)} - {row.clicks.toLocaleString()} + {M.clicks.format(row.clicks)} - {row.ctr.toFixed(2)}% + {M.ctr.format(row.ctr)} - ₩{row.cpa.toLocaleString()} + {M.cpa.format(row.cpa)} - {row.conversions.toLocaleString()} + {M.conversions.format(row.conversions)} - {row.roas}% + {M.roas.format(row.roas)} ))} diff --git a/src/components/dashboard/platform/PlatformRoasTable.tsx b/src/components/dashboard/platform/PlatformRoasTable.tsx index bab1ab84..78a64f2a 100644 --- a/src/components/dashboard/platform/PlatformRoasTable.tsx +++ b/src/components/dashboard/platform/PlatformRoasTable.tsx @@ -1,22 +1,15 @@ -import { memo, type ReactNode } from "react"; +import { memo } from "react"; import type { IPlatformRankingItem, TProviderType, } from "@/types/dashboard/overview"; import { PLATFORM_MAP } from "@/types/dashboard/provider"; +import { PLATFORM_CIRCLE_LOGO_MAP } from "@/constants/dashboard/platformLogos"; -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"; +import { METRIC_REGISTRY as M } from "@/utils/dashboard/metricRegistry"; -const platformLogoMap: Record = { - GOOGLE: , - NAVER: , - META: , -}; +import { TrendBadge } from "@/components/common/card/StatCard"; function toProviderType(provider: string): TProviderType | null { const key = provider.toUpperCase(); @@ -31,10 +24,16 @@ function getDisplayName(provider: string): string { function getPlatformLogo(provider: string) { const key = toProviderType(provider); - if (key) return platformLogoMap[key]; + if (key) { + const Logo = PLATFORM_CIRCLE_LOGO_MAP[key]; + return
@@ -115,20 +114,20 @@ const PlatformRoasTable = memo(function PlatformRoasTable({ {/* ROAS */}
- {item.roas.toLocaleString()}% + {M.roas.format(item.roas)}
- {/* CTR */} + {/* 클릭수 */}
- {item.clickRate !== undefined ? ( + {item.clicks !== undefined ? ( <> - {item.clickRate.toFixed(1)}% + {M.clicks.format(item.clicks)} - {item.ctrDelta !== undefined && ( + {item.clickDelta !== undefined && (
- +
)} @@ -142,7 +141,7 @@ const PlatformRoasTable = memo(function PlatformRoasTable({ {item.conversionRate !== undefined ? ( <> - {item.conversionRate.toFixed(1)}% + {M.conversion.format(item.conversionRate)} {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}
+ - {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 ( + +
+

+ 네이버 검색 광고 계정의 고객 ID, API Key, Secret Key를 입력해 주세요. +

+ + + + + + + +
+ + +
+
+
+ ); +} 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 ( +
+
+

Coming soon…

+
+
+ ); +} 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 ( +
+
+ +
+
+
+
+
+ + {progress}% + +
+
+ ); +} + +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() {