diff --git a/src/api/dashboard/aiAnalysis.ts b/src/api/dashboard/aiAnalysis.ts index 26d15b73..571b9508 100644 --- a/src/api/dashboard/aiAnalysis.ts +++ b/src/api/dashboard/aiAnalysis.ts @@ -1,5 +1,7 @@ import type { ICommonResponse } from "@/types/common/common"; import type { + IAiReportListParams, + IAiReportListResponse, IAnalysisRequest, IReportStatusResponse, } from "@/types/dashboard/aiAnalysis"; @@ -31,3 +33,14 @@ export const getAiReportByAccessToken = async ( >(`/api/ai/reports/${encodeURIComponent(accessToken)}`); return data.data; }; + +/** 조직 단위 AI 분석 리포트 목록 조회 (최신순, 본문 제외) */ +export const getOrgAiReports = async ( + orgId: number, + params?: IAiReportListParams, +): Promise => { + const { data } = await axiosInstance.get< + ICommonResponse + >(`/api/ai/organizations/${orgId}/reports`, { params }); + return data.data; +}; diff --git a/src/components/dashboard/ai-report/components/AiSummaryCard.tsx b/src/components/dashboard/ai-report/components/AiSummaryCard.tsx index 93f3257f..9e59af0b 100644 --- a/src/components/dashboard/ai-report/components/AiSummaryCard.tsx +++ b/src/components/dashboard/ai-report/components/AiSummaryCard.tsx @@ -294,17 +294,15 @@ export default function AiSummaryCard({ ); const handleToggle = useCallback(() => { - setIsExpanded((prev) => { - const next = !prev; - if (next) { - onExpand?.(); - if (!data) autoExpandOnResultRef.current = true; - } else if (isLoading) { - autoExpandOnResultRef.current = false; - } - return next; - }); - }, [data, isLoading, onExpand]); + const next = !isExpanded; + setIsExpanded(next); + if (next) { + onExpand?.(); + if (!data) autoExpandOnResultRef.current = true; + } else if (isLoading) { + autoExpandOnResultRef.current = false; + } + }, [isExpanded, data, isLoading, onExpand]); const handleDownloadPdf = useCallback(() => { if (!printDocument) return; diff --git a/src/components/dashboard/ai-report/components/DashboardAiSummarySection.tsx b/src/components/dashboard/ai-report/components/DashboardAiSummarySection.tsx index a4d18adc..a4ecba54 100644 --- a/src/components/dashboard/ai-report/components/DashboardAiSummarySection.tsx +++ b/src/components/dashboard/ai-report/components/DashboardAiSummarySection.tsx @@ -1,4 +1,4 @@ -import { useCallback } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import type { TAiAnalysisProvider } from "@/types/dashboard/aiAnalysis"; import { PLATFORM_MAP } from "@/types/dashboard/provider"; @@ -32,23 +32,55 @@ export default function DashboardAiSummarySection({ const { reportData, requestAnalysis, - reset, isLoading, + isCheckingSharedReport, + hasUsableSharedReport, + sharedReportCreatedAt, loadingMessage, isError, errorMessage, } = useAiAnalysisReport(provider); + /** ref로 유지해 fallback effect deps에 포함하지 않음 */ + const hasUsableSharedReportRef = useRef(false); + hasUsableSharedReportRef.current = hasUsableSharedReport; + + const periodLabel = useMemo(() => { + const base = formatAiAnalysisPeriodLabel(); + if (!sharedReportCreatedAt) return base; + const date = sharedReportCreatedAt.slice(0, 10).replaceAll("-", "."); + return `${base} · 팀 공유 분석 (${date})`; + }, [sharedReportCreatedAt]); + + /** 공유 조회 중에 카드를 펼쳤을 때 POST를 보류했음을 기록 */ + const pendingExpandRef = useRef(false); + const handleExpand = useCallback(() => { if (!reportData && !isLoading && !isError) { requestAnalysis(); + } else if (isCheckingSharedReport) { + pendingExpandRef.current = true; + } + }, [reportData, isLoading, isError, isCheckingSharedReport, requestAnalysis]); + + /** 공유 조회가 끝났을 때 결과가 없으면 POST fallback 실행 */ + useEffect(() => { + if ( + pendingExpandRef.current && + !isCheckingSharedReport && + !reportData && + !isLoading && + !isError && + !hasUsableSharedReportRef.current + ) { + pendingExpandRef.current = false; + requestAnalysis(); } - }, [reportData, isLoading, isError, requestAnalysis]); + }, [isCheckingSharedReport, reportData, isLoading, isError, requestAnalysis]); const handleRetry = useCallback(() => { - reset(); requestAnalysis(); - }, [reset, requestAnalysis]); + }, [requestAnalysis]); return ( ); } diff --git a/src/constants/dashboard/overviewMetricsRange.ts b/src/constants/dashboard/overviewMetricsRange.ts index 604debca..330168a4 100644 --- a/src/constants/dashboard/overviewMetricsRange.ts +++ b/src/constants/dashboard/overviewMetricsRange.ts @@ -1,11 +1,14 @@ /** 통합 대시보드 — 일별 지표 ROAS 순위 등 */ export const OVERVIEW_DAILY_METRICS_RANGE = { startDate: "2026-06-15", - endDate: "2026-07-15", + endDate: "2026-07-07", } as const; -/** AI 분석 — ROAS 동일 구간 말일 기준 최근 30일(한 달), 구간 시작일 이전으로는 확장하지 않음 */ -const AI_ANALYSIS_LOOKBACK_DAYS = 30; +/** + * AI 분석 — ROAS 동일 구간 말일 기준 최근 14일(2주), 구간 시작일 이전으로는 확장하지 않음 + * 백엔드 확인 결과 조회 기간이 너무 길면(예: 30일) AI 처리 단계에서 실패해 1~2주 범위로 제한 + */ +const AI_ANALYSIS_LOOKBACK_DAYS = 14; function toApiDateString(date: Date): string { const y = date.getFullYear(); @@ -18,7 +21,7 @@ function parseApiDate(dateStr: string): Date { return new Date(`${dateStr}T12:00:00`); } -/** AI 요약 POST body용 — 통합 ROAS 말일 기준 최근 한 달 */ +/** AI 요약 POST body용 — 통합 ROAS 말일 기준 최근 2주 */ export function getAiAnalysisDateRange(): { startDate: string; endDate: string; diff --git a/src/hooks/dashboard/useAiAnalysisReport.ts b/src/hooks/dashboard/useAiAnalysisReport.ts index 1ca67fd4..765431e2 100644 --- a/src/hooks/dashboard/useAiAnalysisReport.ts +++ b/src/hooks/dashboard/useAiAnalysisReport.ts @@ -14,6 +14,7 @@ import { useCoreMutation, useCoreQuery } from "@/hooks/customQuery"; import { getAiReportByAccessToken, + getOrgAiReports, requestAiAnalysis, } from "@/api/dashboard/aiAnalysis"; import { QUERY_KEYS } from "@/lib/queryKeys"; @@ -38,26 +39,77 @@ export function useAiAnalysisReport(provider: TAiAnalysisProvider = "ALL") { const [accessToken, setAccessToken] = useState(null); const [pollStartedAt, setPollStartedAt] = useState(null); const [workspaceErrorShown, setWorkspaceErrorShown] = useState(false); + /** true면 조직 공유 리포트 조회를 건너뛰고 POST 플로우로 직행 (명시적 요청) */ + const [skipSharedLookup, setSkipSharedLookup] = useState(false); + /** 공유 리포트 채택 시 해당 리포트의 생성일 (ISO 문자열) */ + const [sharedReportCreatedAt, setSharedReportCreatedAt] = useState< + string | null + >(null); /** 재요청 전 상태 초기화 */ const reset = useCallback(() => { setAccessToken(null); setPollStartedAt(null); setWorkspaceErrorShown(false); + setSkipSharedLookup(false); + setSharedReportCreatedAt(null); }, []); useEffect(() => { reset(); - }, [provider, reset]); + }, [provider, orgId, reset]); useEffect(() => { return () => { void queryClient.removeQueries({ queryKey: QUERY_KEYS.ai.report(provider, orgId), }); + void queryClient.removeQueries({ + queryKey: QUERY_KEYS.ai.reportList(provider, orgId), + }); }; }, [provider, orgId]); + /* 조직 공유 최신 리포트 우선 조회 */ + const sharedReportListQuery = useCoreQuery( + QUERY_KEYS.ai.reportList(provider, orgId), + () => + getOrgAiReports(orgId!, { + reportType: provider === "ALL" ? undefined : provider, + size: 1, + }), + { + enabled: !!orgId && !skipSharedLookup && !accessToken, + staleTime: 0, + gcTime: AI_REPORT_GC_TIME_MS, + }, + ); + + /* 조회된 공유 리포트가 있으면 POST 없이 그 accessToken으로 바로 렌더링 */ + useEffect(() => { + if (skipSharedLookup || accessToken) return; + if (!sharedReportListQuery.isSuccess) return; + + const latest = sharedReportListQuery.data.reports[0]; + /** 과거에 실패한 리포트는 채택하지 않고 미조회 상태로 둔다 (펼치면 새 POST로 폴백) */ + if (!latest || latest.status === "FAILED") return; + + setAccessToken(latest.reportAccessToken); + setSharedReportCreatedAt(latest.createdAt); + setPollStartedAt(Date.now()); + }, [ + accessToken, + skipSharedLookup, + sharedReportListQuery.isSuccess, + sharedReportListQuery.data, + ]); + + /** 사용 가능한 공유 리포트가 있는지 여부 (query data 기반 — 렌더 시점에 즉시 반영) */ + const hasUsableSharedReport = + sharedReportListQuery.isSuccess && + !!sharedReportListQuery.data?.reports[0] && + sharedReportListQuery.data.reports[0].status !== "FAILED"; + /** POST /analysis — accessToken 발급 */ const requestMutation = useCoreMutation( (params: TRequestAiAnalysisParams) => { @@ -131,16 +183,19 @@ export function useAiAnalysisReport(provider: TAiAnalysisProvider = "ALL") { return; } reset(); + /** 명시적 POST 요청은 조직 공유 리포트 조회 결과로 덮어써지지 않도록 건너뜀 */ + setSkipSharedLookup(true); requestMutation.mutate(params ?? {}); }, [orgId, reset, requestMutation], ); const isSubmitting = requestMutation.isPending; + const isCheckingSharedReport = sharedReportListQuery.isLoading; const isPolling = !!accessToken && reportStatus === "PENDING" && !pollTimedOut; - const isLoading = isSubmitting || isPolling; + const isLoading = isCheckingSharedReport || isSubmitting || isPolling; const queryError = reportQuery.error as IApiErrorResponse | null; const isWorkspaceMissing = !orgId && workspaceErrorShown; @@ -156,11 +211,12 @@ export function useAiAnalysisReport(provider: TAiAnalysisProvider = "ALL") { pollTimedOut; const loadingMessage = useMemo(() => { + if (isCheckingSharedReport) return "이전 분석 결과를 확인하고 있어요…"; if (isSubmitting) return "분석을 요청하고 있어요…"; if (isPolling) return "AI가 광고 성과를 분석 중이에요. 보통 10~30초 걸려요."; return null; - }, [isSubmitting, isPolling]); + }, [isCheckingSharedReport, isSubmitting, isPolling]); const errorMessage = useMemo(() => { if (isWorkspaceMissing) return WORKSPACE_REQUIRED_MESSAGE; @@ -195,6 +251,9 @@ export function useAiAnalysisReport(provider: TAiAnalysisProvider = "ALL") { requestAnalysis, reset, isLoading, + isCheckingSharedReport, + hasUsableSharedReport, + sharedReportCreatedAt, loadingMessage, isError, errorMessage, diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts index 8f81b9f9..24f30332 100644 --- a/src/lib/queryKeys.ts +++ b/src/lib/queryKeys.ts @@ -73,6 +73,9 @@ export const QUERY_KEYS = { /** AI 분석 리포트 폴링 쿼리 */ report: (provider: string, orgId: number | null) => ["ai", "report", provider, orgId] as const, + /** 조직 단위 최신 AI 분석 리포트 목록 조회 (공유 결과 확인용) */ + reportList: (provider: string, orgId: number | null) => + ["ai", "reportList", provider, orgId] as const, }, timeline: { diff --git a/src/types/dashboard/aiAnalysis.ts b/src/types/dashboard/aiAnalysis.ts index 3e9b93b5..9a3fe76d 100644 --- a/src/types/dashboard/aiAnalysis.ts +++ b/src/types/dashboard/aiAnalysis.ts @@ -1,4 +1,4 @@ -import type { TAiAnalysisProvider } from "./provider"; +import type { TAiAnalysisProvider, TProviderType } from "./provider"; export type { TAiAnalysisProvider }; export type TAiReportJobStatus = "PENDING" | "SUCCESS" | "FAILED"; @@ -25,3 +25,28 @@ export interface IReportStatusResponse { status: TAiReportJobStatus; result: IAnalysisResponse | null; } + +/** GET /organizations/{orgId}/reports 조회 파라미터 */ +export interface IAiReportListParams { + /** 생략하면 전체 유형 조회 */ + reportType?: TProviderType; + cursor?: string; + size?: number; +} + +/** 리포트 목록 항목 (분석 결과 본문 제외) */ +export interface IAiReportListItem { + reportId: number; + reportAccessToken: string; + title: string; + status: TAiReportJobStatus; + isShared: boolean; + createdAt: string; +} + +/** GET /organizations/{orgId}/reports 응답 data */ +export interface IAiReportListResponse { + hasNext: boolean; + nextCursor: string | null; + reports: IAiReportListItem[]; +} diff --git a/tests/ai-analysis.spec.ts b/tests/ai-analysis.spec.ts index e392607e..2f5ccce8 100644 --- a/tests/ai-analysis.spec.ts +++ b/tests/ai-analysis.spec.ts @@ -1,4 +1,10 @@ -import { expect, type Page, test } from "./fixtures/auth"; +import { + expect, + loginAndReachDashboard, + type Page, + test, + waitForDashboardReady, +} from "./fixtures/auth"; const CARD_TITLE = "통합 광고 성과 AI 요약"; const MOCK_TOKEN = "mock-ai-token-e2e"; @@ -46,6 +52,70 @@ async function mockAiAnalysisResult(page: Page) { ); } +const SHARED_TOKEN = "shared-report-token"; + +/** + * /organizations/{orgId}/reports 응답을 지연시킨다. + * release()를 호출하면 지정한 status의 공유 리포트가 반환된다. + */ +async function setupDelayedSharedReport( + page: Page, + reportStatus: "PENDING" | "SUCCESS", +): Promise<{ release: () => void }> { + let release!: () => void; + const gate = new Promise((r) => { + release = r; + }); + + await page.route( + (url) => + url.pathname.includes("/api/ai/organizations/") && + url.pathname.endsWith("/reports"), + async (route) => { + await gate; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + status: "OK", + data: { + hasNext: false, + nextCursor: null, + reports: [ + { + reportId: 1, + reportAccessToken: SHARED_TOKEN, + title: "팀 공유 분석", + status: reportStatus, + isShared: true, + createdAt: "2026-08-01T00:00:00Z", + }, + ], + }, + }), + }); + }, + ); + + // 공유 토큰 폴링 — PENDING 고정으로 추가 POST 없이 유지 + await page.route( + (url) => + url.pathname.includes("/api/ai/reports/") && + !url.pathname.includes("/organizations/"), + (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + status: "OK", + data: { accessToken: SHARED_TOKEN, status: "PENDING", result: null }, + }), + }), + ); + + return { release }; +} + test.describe("AI 분석 요청 E2E", () => { test("통합 AI 요약 카드가 접힌 상태로 렌더된다", async ({ dashboardPage: page, @@ -103,3 +173,42 @@ test.describe("AI 분석 요청 E2E", () => { ).toBeVisible(); }); }); + +test.describe("공유 리포트 fallback 회귀", () => { + for (const reportStatus of ["PENDING", "SUCCESS"] as const) { + test(`공유 리포트(${reportStatus}) 조회 중 카드를 펼쳐도 POST를 보내지 않는다`, async ({ + page, + }) => { + test.skip( + !process.env.E2E_USER_EMAIL || !process.env.E2E_USER_PASSWORD, + "E2E_USER_EMAIL / E2E_USER_PASSWORD 가 .env 에 필요합니다.", + ); + + const { release } = await setupDelayedSharedReport(page, reportStatus); + + let analysisPostCount = 0; + await page.route( + (url) => + url.pathname.includes("/api/ai/organizations/") && + url.pathname.endsWith("/analysis"), + (route) => { + analysisPostCount++; + void route.abort(); + }, + ); + + await loginAndReachDashboard(page); + await waitForDashboardReady(page); + + // 공유 리포트 응답이 아직 오지 않은 상태(isCheckingSharedReport=true)에서 카드를 펼침 + await page.getByRole("button", { name: `${CARD_TITLE} 펼치기` }).click(); + + // 이제 공유 리포트 응답을 해제 — 사용 가능한 리포트가 있으므로 POST 없어야 함 + release(); + + await page.waitForTimeout(600); + + expect(analysisPostCount).toBe(0); + }); + } +});