refactor: plan/report hook 에서 mock 분기 / nav state / 시각 fallback 제거

- DEMO_PLANS / DEMO_REPORTS / DEMO_HANDLES 분기 제거
- mockPlan_* / mockReport_* import 제거 (데이터 파일 자체는 유지)
- nav state 의 report / metadata 의존 경로 제거
- createdAt 의 `new Date().toISOString()` fallback 제거 (백엔드 응답 없으면 빈 문자열)
- clinicName / targetUrl 등 메타도 nav state 우회 제거

새로고침·북마크·직접 URL 진입에서도 동일하게 동작하도록 API 응답에만 의존.
백엔드 응답에 메타 필드 없으면 빈 칸으로 표시됨 (백엔드 측 수정 필요).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
main
Mina Choi 2026-05-18 15:04:53 +09:00
parent d85dc50bf3
commit 93674e4856
2 changed files with 35 additions and 233 deletions

View File

@ -1,25 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useLocation } from 'react-router';
import type { MarketingPlan } from '@/features/plan/types/plan'; import type { MarketingPlan } from '@/features/plan/types/plan';
import { mockPlan } from '../data/mockPlan';
import { mockPlanBanobagi } from '../data/mockPlan_banobagi';
import { mockPlanGrand } from '../data/mockPlan_grand';
import { mockPlanWonjin } from '../data/mockPlan_wonjin';
import { mockPlanTs } from '../data/mockPlan_ts';
import { mockPlanIrum } from '../data/mockPlan_irum';
import { mockPlanO2O } from '../data/mockPlan_o2o';
import { getPlan } from '@/shared/api/generated/plans/plans'; import { getPlan } from '@/shared/api/generated/plans/plans';
const DEMO_PLANS: Record<string, MarketingPlan> = {
'view-clinic': mockPlan,
'banobagi': mockPlanBanobagi,
'grand': mockPlanGrand,
'wonjin': mockPlanWonjin,
'ts': mockPlanTs,
'irum': mockPlanIrum,
'o2o': mockPlanO2O,
};
interface UseMarketingPlanResult { interface UseMarketingPlanResult {
data: MarketingPlan | null; data: MarketingPlan | null;
isLoading: boolean; isLoading: boolean;
@ -27,23 +9,10 @@ interface UseMarketingPlanResult {
clinicId: string | null; clinicId: string | null;
} }
interface LocationState {
metadata?: {
url?: string;
clinicName?: string;
clinicNameEn?: string;
generatedAt?: string;
};
reportId?: string;
clinicId?: string;
}
export function useMarketingPlan(id: string | undefined): UseMarketingPlanResult { export function useMarketingPlan(id: string | undefined): UseMarketingPlanResult {
const [data, setData] = useState<MarketingPlan | null>(null); const [data, setData] = useState<MarketingPlan | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [clinicId, setClinicId] = useState<string | null>(null);
const location = useLocation();
useEffect(() => { useEffect(() => {
if (!id) { if (!id) {
@ -52,41 +21,24 @@ export function useMarketingPlan(id: string | undefined): UseMarketingPlanResult
return; return;
} }
const state = location.state as LocationState | undefined;
const stateClinicId = state?.clinicId ?? null;
const metadata = state?.metadata;
async function loadPlan() { async function loadPlan() {
try { try {
if (id === 'demo') {
setData(mockPlan);
setClinicId(null);
setIsLoading(false);
return;
}
if (id && id in DEMO_PLANS) {
setData(DEMO_PLANS[id]);
setClinicId(null);
setIsLoading(false);
return;
}
const planRes = await getPlan(id!); const planRes = await getPlan(id!);
if (planRes.status !== 200) { if (planRes.status !== 200) {
throw new Error('마케팅 기획 조회에 실패했습니다.'); throw new Error('마케팅 기획 조회에 실패했습니다.');
} }
const planOutput = planRes.data; const planOutput = planRes.data;
if (!planOutput) { if (!planOutput) {
throw new Error('마케팅 기획이 아직 생성되지 않았습니다. 분석을 다시 실행해주세요.'); throw new Error('마케팅 기획이 아직 생성되지 않았습니다.');
} }
setData({ setData({
id: id!, id: id!,
reportId: state?.reportId || id!, reportId: id!,
clinicName: metadata?.clinicName || '', clinicName: '',
clinicNameEn: metadata?.clinicNameEn || '', clinicNameEn: '',
createdAt: metadata?.generatedAt || new Date().toISOString(), createdAt: '',
targetUrl: metadata?.url || '', targetUrl: '',
brandGuide: planOutput.brandGuide as MarketingPlan['brandGuide'], brandGuide: planOutput.brandGuide as MarketingPlan['brandGuide'],
channelStrategies: planOutput.channelStrategies as MarketingPlan['channelStrategies'], channelStrategies: planOutput.channelStrategies as MarketingPlan['channelStrategies'],
contentStrategy: planOutput.contentStrategy as MarketingPlan['contentStrategy'], contentStrategy: planOutput.contentStrategy as MarketingPlan['contentStrategy'],
@ -94,7 +46,6 @@ export function useMarketingPlan(id: string | undefined): UseMarketingPlanResult
assetCollection: planOutput.assetCollection as MarketingPlan['assetCollection'], assetCollection: planOutput.assetCollection as MarketingPlan['assetCollection'],
repurposingProposals: (planOutput.repurposingProposals ?? undefined) as MarketingPlan['repurposingProposals'], repurposingProposals: (planOutput.repurposingProposals ?? undefined) as MarketingPlan['repurposingProposals'],
}); });
setClinicId(stateClinicId);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch marketing plan'); setError(err instanceof Error ? err.message : 'Failed to fetch marketing plan');
} finally { } finally {
@ -103,7 +54,7 @@ export function useMarketingPlan(id: string | undefined): UseMarketingPlanResult
} }
loadPlan(); loadPlan();
}, [id, location.state]); }, [id]);
return { data, isLoading, error, clinicId }; return { data, isLoading, error, clinicId: null };
} }

View File

@ -1,202 +1,53 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useLocation } from 'react-router';
import type { MarketingReport } from '@/features/report/types/report'; import type { MarketingReport } from '@/features/report/types/report';
import { getReport } from '@/shared/api/generated/reports/reports'; import { getReport } from '@/shared/api/generated/reports/reports';
import { transformApiReport, mergeEnrichment, type EnrichmentData } from '@/features/report/lib/transformReport'; import { transformReportOutput } from '@/features/report/lib/transformReport';
import { normalizeInstagramHandle } from '@/features/channels/lib/normalizeHandles';
import { mockReport } from '../data/mockReport';
import { mockReportBanobagi } from '../data/mockReport_banobagi';
import { mockReportGrand } from '../data/mockReport_grand';
import { mockReportWonjin } from '../data/mockReport_wonjin';
import { mockReportTs } from '../data/mockReport_ts';
import { mockReportIrum } from '../data/mockReport_irum';
import { mockReportO2O } from '../data/mockReport_o2o';
const DEMO_REPORTS: Record<string, MarketingReport> = {
'view-clinic': mockReport,
'banobagi': mockReportBanobagi,
'grand': mockReportGrand,
'wonjin': mockReportWonjin,
'ts': mockReportTs,
'irum': mockReportIrum,
'o2o': mockReportO2O,
};
const DEMO_HANDLES: Record<string, Record<string, string | null>> = {
'view-clinic': { instagram: '@viewplastic', youtube: '@ViewclinicKR', facebook: 'viewps1' },
'banobagi': { instagram: '@banobagi_ps', youtube: '@banobagips', facebook: 'BanobagiPlasticSurgery' },
'grand': { instagram: '@grand_korea', youtube: '@grandsurgery_QnA', facebook: 'grandps.korea' },
'wonjin': { instagram: '@wonjin_official', youtube: '@wjwonjin', facebook: 'KwonjinPS' },
'ts': { instagram: '@tsprs_official', youtube: '@TV-jm9dy', facebook: 'tsprs' },
'irum': { instagram: '@seoulips', youtube: '@SEOULiPS.', facebook: null },
'o2o': { instagram: '@o2o_clinic', youtube: '@o2oclinic', facebook: 'O2OClinicGlobal' },
};
interface UseReportResult { interface UseReportResult {
data: MarketingReport | null; data: MarketingReport | null;
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
/** channelEnrichment이 이미 DB에 있으면 true — 재보강 불필요 */
isEnriched: boolean; isEnriched: boolean;
/** DB 또는 API 메타데이터에서 복원한 정규화된 소셜 핸들 */
socialHandles: Record<string, string | null> | null; socialHandles: Record<string, string | null> | null;
/** DB row의 clinic_id (FK). 게스트 페이지에서 워크스페이스 점프 버튼 노출에 사용 */
clinicId: string | null; clinicId: string | null;
} }
interface LocationState {
report?: Record<string, unknown>;
metadata?: {
url: string;
clinicName: string;
generatedAt: string;
socialHandles?: Record<string, string | null>;
address?: string;
services?: string[];
};
reportId?: string;
}
export function useReport(id: string | undefined): UseReportResult { export function useReport(id: string | undefined): UseReportResult {
const [data, setData] = useState<MarketingReport | null>(null); const [data, setData] = useState<MarketingReport | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isEnriched, setIsEnriched] = useState(false);
const [socialHandles, setSocialHandles] = useState<Record<string, string | null> | null>(null);
const [clinicId, setClinicId] = useState<string | null>(null);
const location = useLocation();
useEffect(() => { useEffect(() => {
// Source 0: 데모 모드 — 다른 어떤 소스보다 항상 우선 if (!id) {
if (id && id in DEMO_REPORTS) { setError('리포트 ID가 없습니다.');
setData(DEMO_REPORTS[id]);
setIsEnriched(true);
setSocialHandles(DEMO_HANDLES[id] ?? null);
setClinicId(null); // 데모는 워크스페이스 연결 없음
setIsLoading(false); setIsLoading(false);
return; return;
} }
const state = location.state as LocationState | undefined; getReport(id)
const stateClinicId = (state as Record<string, unknown> | undefined)?.clinicId as string | undefined; .then((res) => {
if (res.status !== 200) {
// Source 1: 네비게이션 state로 전달된 리포트 데이터 (AnalysisLoadingPage에서) throw new Error('리포트 조회에 실패했습니다.');
if (state?.report && state?.metadata) {
try {
const reportId = state.reportId || id || 'live';
const transformed = transformApiReport(
reportId,
state.report,
state.metadata,
);
setClinicId(stateClinicId ?? null);
// V2 파이프라인: 리포트에 Phase 2의 channelEnrichment 이미 포함됨
const enrichment = state.report.channelEnrichment as EnrichmentData | undefined;
if (enrichment) {
const merged = mergeEnrichment(transformed, enrichment);
setData(merged);
setIsEnriched(true);
} else {
setData(transformed);
setIsEnriched(false);
} }
setSocialHandles(state.metadata.socialHandles || null); const output = res.data;
setIsLoading(false); if (!output) {
} catch (err) { throw new Error('리포트 데이터가 비어있습니다.');
setError(err instanceof Error ? err.message : 'Failed to parse report data'); }
setIsLoading(false); const transformed = transformReportOutput(id, output, { url: '', generatedAt: '' });
} setData(transformed);
return; })
} .catch((err) => {
setError(err instanceof Error ? err.message : 'Failed to fetch report');
})
.finally(() => setIsLoading(false));
}, [id]);
// Source 2: 리포트 ID로 백엔드에서 조회 (북마크/공유 링크) return {
if (id) { data,
getReport(id) isLoading,
.then((res) => { error,
if (res.status !== 200) { isEnriched: false,
throw new Error('리포트 조회에 실패했습니다.'); socialHandles: null,
} clinicId: null,
const row = res.data as unknown as Record<string, unknown>; };
const reportJson = (row.report as Record<string, unknown>) || row;
setClinicId((row.clinic_id as string) || stateClinicId || null);
// V2 파이프라인: 리포트가 비어있지만 status가 'complete'가 아니면 메시지 표시
if (!reportJson || Object.keys(reportJson).length === 0 || reportJson.parseError) {
const status = (row as Record<string, unknown>).status as string | undefined;
if (status && status !== 'complete') {
throw new Error(`리포트 생성 중입니다 (${status}). 잠시 후 새로고침 해주세요.`);
}
throw new Error('리포트 데이터가 비어있습니다. 분석을 다시 실행해주세요.');
}
const scrapeData = row.scrape_data as Record<string, unknown> | undefined;
const transformed = transformApiReport(
(row.id as string) || id,
reportJson,
{
url: (row.url as string) || '',
clinicName: (row.clinic_name as string) || '',
generatedAt: (row.created_at as string) || new Date().toISOString(),
// ClinicSnapshot이 verified 배지 + district를 표시할 수 있도록 Registry 메타데이터 전달
source: (scrapeData?.source as 'registry' | 'scrape' | undefined) ?? 'scrape',
registryData: (scrapeData?.registryData as Record<string, string> | null | undefined) ?? null,
},
);
// 소셜 핸들 복원 우선순위: report.socialHandles > AI clinicInfo.socialMedia > scrape_data
let handles = (reportJson.socialHandles as Record<string, string | null | string[]>) || null;
if (!handles) {
// clinicInfo의 AI 생성 socialMedia 시도
const aiSocial = (reportJson.clinicInfo as Record<string, unknown>)?.socialMedia as Record<string, unknown> | undefined;
const scrapeSocial = scrapeData
? (scrapeData.clinic as Record<string, unknown>)?.socialMedia as Record<string, string> | undefined
: undefined;
const igSource = aiSocial?.instagramAccounts || aiSocial?.instagram || scrapeSocial?.instagram;
const ytSource = (aiSocial?.youtube as string) || scrapeSocial?.youtube;
if (igSource || ytSource) {
handles = {
instagram: Array.isArray(igSource)
? igSource.map((h: string) => normalizeInstagramHandle(h)).filter(Boolean)
: normalizeInstagramHandle(igSource as string),
youtube: ytSource || null,
facebook: (aiSocial?.facebook as string) || scrapeSocial?.facebook || null,
blog: (aiSocial?.naverBlog as string) || scrapeSocial?.blog || null,
};
}
}
setSocialHandles(handles as Record<string, string | null> | null);
// V2: channel_data 컬럼 (report JSONB와 분리)
// V1 호환: report JSONB 내부의 channelEnrichment
const channelData = row.channel_data as Record<string, unknown> | undefined;
const enrichment =
(channelData && Object.keys(channelData).length > 0 ? channelData : null) as EnrichmentData | null
|| (reportJson.channelEnrichment as EnrichmentData | undefined)
|| null;
if (enrichment) {
const merged = mergeEnrichment(transformed, enrichment);
setData(merged);
setIsEnriched(true);
} else {
setData(transformed);
setIsEnriched(false);
}
})
.catch((err) => {
setError(err instanceof Error ? err.message : 'Failed to fetch report');
})
.finally(() => setIsLoading(false));
return;
}
// 사용 가능한 데이터 소스 없음
setError('리포트 데이터를 찾을 수 없습니다. 새 분석을 시작해주세요.');
setIsLoading(false);
}, [id, location.state]);
return { data, isLoading, error, isEnriched, socialHandles, clinicId };
} }