Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/api/dashboard/aiAnalysis.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { ICommonResponse } from "@/types/common/common";
import type {
IAiReportListParams,
IAiReportListResponse,
IAnalysisRequest,
IReportStatusResponse,
} from "@/types/dashboard/aiAnalysis";
Expand Down Expand Up @@ -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<IAiReportListResponse> => {
const { data } = await axiosInstance.get<
ICommonResponse<IAiReportListResponse>
>(`/api/ai/organizations/${orgId}/reports`, { params });
return data.data;
};
20 changes: 9 additions & 11 deletions src/components/dashboard/ai-report/components/AiSummaryCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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]);
Comment thread
Seojegyeong marked this conversation as resolved.

const handleRetry = useCallback(() => {
reset();
requestAnalysis();
}, [reset, requestAnalysis]);
}, [requestAnalysis]);

return (
<AiSummaryCard
Expand All @@ -62,7 +94,7 @@ export default function DashboardAiSummarySection({
title={title ?? getAiSummaryTitle(provider)}
idPrefix={idPrefix}
print={{ documentTitle: getAiSummaryDocumentTitle(provider) }}
periodLabel={formatAiAnalysisPeriodLabel()}
periodLabel={periodLabel}
/>
);
}
11 changes: 7 additions & 4 deletions src/constants/dashboard/overviewMetricsRange.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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;
Expand Down
65 changes: 62 additions & 3 deletions src/hooks/dashboard/useAiAnalysisReport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { useCoreMutation, useCoreQuery } from "@/hooks/customQuery";

import {
getAiReportByAccessToken,
getOrgAiReports,
requestAiAnalysis,
} from "@/api/dashboard/aiAnalysis";
import { QUERY_KEYS } from "@/lib/queryKeys";
Expand All @@ -38,26 +39,77 @@ export function useAiAnalysisReport(provider: TAiAnalysisProvider = "ALL") {
const [accessToken, setAccessToken] = useState<string | null>(null);
const [pollStartedAt, setPollStartedAt] = useState<number | null>(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) => {
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -195,6 +251,9 @@ export function useAiAnalysisReport(provider: TAiAnalysisProvider = "ALL") {
requestAnalysis,
reset,
isLoading,
isCheckingSharedReport,
hasUsableSharedReport,
sharedReportCreatedAt,
loadingMessage,
isError,
errorMessage,
Expand Down
3 changes: 3 additions & 0 deletions src/lib/queryKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
27 changes: 26 additions & 1 deletion src/types/dashboard/aiAnalysis.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { TAiAnalysisProvider } from "./provider";
import type { TAiAnalysisProvider, TProviderType } from "./provider";

export type { TAiAnalysisProvider };
export type TAiReportJobStatus = "PENDING" | "SUCCESS" | "FAILED";
Expand All @@ -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[];
}
Loading
Loading