fix: /plan 페이지에서 /report 대신 /plan 호출하도록 교체
useMarketingPlan 이 getReport(id) + transformReportToPlan 으로 우회하던 코드를 getPlan(id) 직접 호출로 변경. PlanOutput 에 없는 메타 필드(id/clinicName/ targetUrl/createdAt 등)는 nav state metadata 또는 빈 값으로 채움. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>main
parent
670535c112
commit
d85dc50bf3
|
|
@ -1,7 +1,6 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useLocation } from 'react-router';
|
import { useLocation } from 'react-router';
|
||||||
import type { MarketingPlan, ChannelStrategyCard, CalendarData, ContentStrategyData } from '@/features/plan/types/plan';
|
import type { MarketingPlan } from '@/features/plan/types/plan';
|
||||||
import { transformReportToPlan } from '../lib/transformPlan';
|
|
||||||
import { mockPlan } from '../data/mockPlan';
|
import { mockPlan } from '../data/mockPlan';
|
||||||
import { mockPlanBanobagi } from '../data/mockPlan_banobagi';
|
import { mockPlanBanobagi } from '../data/mockPlan_banobagi';
|
||||||
import { mockPlanGrand } from '../data/mockPlan_grand';
|
import { mockPlanGrand } from '../data/mockPlan_grand';
|
||||||
|
|
@ -9,13 +8,8 @@ import { mockPlanWonjin } from '../data/mockPlan_wonjin';
|
||||||
import { mockPlanTs } from '../data/mockPlan_ts';
|
import { mockPlanTs } from '../data/mockPlan_ts';
|
||||||
import { mockPlanIrum } from '../data/mockPlan_irum';
|
import { mockPlanIrum } from '../data/mockPlan_irum';
|
||||||
import { mockPlanO2O } from '../data/mockPlan_o2o';
|
import { mockPlanO2O } from '../data/mockPlan_o2o';
|
||||||
import { getReport } from '@/shared/api/generated/reports/reports';
|
|
||||||
import { getPlan } from '@/shared/api/generated/plans/plans';
|
import { getPlan } from '@/shared/api/generated/plans/plans';
|
||||||
|
|
||||||
// TODO(migration): 'analysis_runs' / 'clinics' / 'content_plans' 테이블 직접 조회는
|
|
||||||
// 현재 백엔드에 대응 엔드포인트 없음. 우선 reports/plans 엔드포인트만 활용하고,
|
|
||||||
// clinic 메타 일부 필드는 빈 값으로 둠.
|
|
||||||
|
|
||||||
const DEMO_PLANS: Record<string, MarketingPlan> = {
|
const DEMO_PLANS: Record<string, MarketingPlan> = {
|
||||||
'view-clinic': mockPlan,
|
'view-clinic': mockPlan,
|
||||||
'banobagi': mockPlanBanobagi,
|
'banobagi': mockPlanBanobagi,
|
||||||
|
|
@ -30,70 +24,20 @@ interface UseMarketingPlanResult {
|
||||||
data: MarketingPlan | null;
|
data: MarketingPlan | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
/** DB row 의 clinic_id — 게스트 페이지에서 워크스페이스 점프 버튼 노출에 사용 */
|
|
||||||
clinicId: string | null;
|
clinicId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LocationState {
|
interface LocationState {
|
||||||
report?: Record<string, unknown>;
|
metadata?: {
|
||||||
metadata?: Record<string, unknown>;
|
url?: string;
|
||||||
|
clinicName?: string;
|
||||||
|
clinicNameEn?: string;
|
||||||
|
generatedAt?: string;
|
||||||
|
};
|
||||||
reportId?: string;
|
reportId?: string;
|
||||||
clinicId?: string;
|
clinicId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* content_plans DB row으로부터 MarketingPlan을 빌드.
|
|
||||||
* content_plans는 AI 생성 전략을 JSONB 컬럼에 저장.
|
|
||||||
*/
|
|
||||||
function buildPlanFromContentPlans(
|
|
||||||
row: Record<string, unknown>,
|
|
||||||
clinicName: string,
|
|
||||||
clinicNameEn: string,
|
|
||||||
targetUrl: string,
|
|
||||||
): MarketingPlan {
|
|
||||||
const channelStrategies = (row.channel_strategies || []) as ChannelStrategyCard[];
|
|
||||||
const contentStrategy = (row.content_strategy || {}) as ContentStrategyData;
|
|
||||||
const calendar = (row.calendar || { weeks: [], monthlySummary: [] }) as CalendarData;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: row.id as string,
|
|
||||||
reportId: (row.run_id as string) || (row.id as string),
|
|
||||||
clinicName,
|
|
||||||
clinicNameEn,
|
|
||||||
createdAt: (row.created_at as string) || new Date().toISOString(),
|
|
||||||
targetUrl,
|
|
||||||
brandGuide: (row.brand_guide as MarketingPlan['brandGuide']) || {
|
|
||||||
colors: [],
|
|
||||||
fonts: [],
|
|
||||||
logoRules: [],
|
|
||||||
toneOfVoice: {
|
|
||||||
personality: ['전문적', '친근한', '신뢰할 수 있는'],
|
|
||||||
communicationStyle: '의료 전문 지식을 쉽고 친근하게 전달',
|
|
||||||
doExamples: [],
|
|
||||||
dontExamples: [],
|
|
||||||
},
|
|
||||||
channelBranding: [],
|
|
||||||
brandInconsistencies: [],
|
|
||||||
},
|
|
||||||
channelStrategies,
|
|
||||||
contentStrategy: {
|
|
||||||
pillars: contentStrategy.pillars || [],
|
|
||||||
typeMatrix: contentStrategy.typeMatrix || [],
|
|
||||||
workflow: contentStrategy.workflow || [
|
|
||||||
{ step: 1, name: '기획', description: 'AI 콘텐츠 주제 선정', owner: 'INFINITH AI', duration: '자동' },
|
|
||||||
{ step: 2, name: '제작', description: 'AI 초안 생성 + 의료진 감수', owner: 'INFINITH AI', duration: '1시간' },
|
|
||||||
{ step: 3, name: '편집', description: '영상/이미지 편집', owner: 'INFINITH Studio', duration: '30분' },
|
|
||||||
{ step: 4, name: '배포', description: '채널별 최적화 배포', owner: 'INFINITH Distribution', duration: '자동' },
|
|
||||||
{ step: 5, name: '분석', description: '성과 데이터 수집 + 전략 조정', owner: 'INFINITH Analytics', duration: '자동' },
|
|
||||||
],
|
|
||||||
repurposingSource: contentStrategy.repurposingSource || '1개 롱폼 영상',
|
|
||||||
repurposingOutputs: contentStrategy.repurposingOutputs || [],
|
|
||||||
},
|
|
||||||
calendar,
|
|
||||||
assetCollection: { assets: [], youtubeRepurpose: [] },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
||||||
|
|
@ -110,10 +54,10 @@ export function useMarketingPlan(id: string | undefined): UseMarketingPlanResult
|
||||||
|
|
||||||
const state = location.state as LocationState | undefined;
|
const state = location.state as LocationState | undefined;
|
||||||
const stateClinicId = state?.clinicId ?? null;
|
const stateClinicId = state?.clinicId ?? null;
|
||||||
|
const metadata = state?.metadata;
|
||||||
|
|
||||||
async function loadPlan() {
|
async function loadPlan() {
|
||||||
try {
|
try {
|
||||||
// ─── 개발 / 데모: mock 데이터를 즉시 반환 ───
|
|
||||||
if (id === 'demo') {
|
if (id === 'demo') {
|
||||||
setData(mockPlan);
|
setData(mockPlan);
|
||||||
setClinicId(null);
|
setClinicId(null);
|
||||||
|
|
@ -127,57 +71,30 @@ export function useMarketingPlan(id: string | undefined): UseMarketingPlanResult
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 소스 1: plan 엔드포인트 시도 (AI 생성 전략) ───
|
|
||||||
const clinicName = '';
|
|
||||||
const clinicNameEn = '';
|
|
||||||
const targetUrl = '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const planRes = await getPlan(id!);
|
const planRes = await getPlan(id!);
|
||||||
if (planRes.status === 200 && planRes.data) {
|
if (planRes.status !== 200) {
|
||||||
const planRow = planRes.data as unknown as Record<string, unknown>;
|
throw new Error('마케팅 기획 조회에 실패했습니다.');
|
||||||
const plan = buildPlanFromContentPlans(
|
|
||||||
planRow,
|
|
||||||
clinicName,
|
|
||||||
clinicNameEn,
|
|
||||||
targetUrl,
|
|
||||||
);
|
|
||||||
setData(plan);
|
|
||||||
setClinicId((planRow.clinic_id as string) || stateClinicId);
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
} catch {
|
const planOutput = planRes.data;
|
||||||
// report 기반 폴백으로 넘어감
|
if (!planOutput) {
|
||||||
|
throw new Error('마케팅 기획이 아직 생성되지 않았습니다. 분석을 다시 실행해주세요.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 소스 2: 네비게이션 state의 report 데이터 (폴백) ───
|
setData({
|
||||||
if (state?.report && state?.metadata) {
|
id: id!,
|
||||||
const plan = transformReportToPlan({
|
reportId: state?.reportId || id!,
|
||||||
id: (state.reportId || id),
|
clinicName: metadata?.clinicName || '',
|
||||||
url: (state.metadata.url as string) || '',
|
clinicNameEn: metadata?.clinicNameEn || '',
|
||||||
clinic_name: (state.metadata.clinicName as string) || '',
|
createdAt: metadata?.generatedAt || new Date().toISOString(),
|
||||||
report: state.report,
|
targetUrl: metadata?.url || '',
|
||||||
created_at: (state.metadata.generatedAt as string) || new Date().toISOString(),
|
brandGuide: planOutput.brandGuide as MarketingPlan['brandGuide'],
|
||||||
|
channelStrategies: planOutput.channelStrategies as MarketingPlan['channelStrategies'],
|
||||||
|
contentStrategy: planOutput.contentStrategy as MarketingPlan['contentStrategy'],
|
||||||
|
calendar: planOutput.calendar as MarketingPlan['calendar'],
|
||||||
|
assetCollection: planOutput.assetCollection as MarketingPlan['assetCollection'],
|
||||||
|
repurposingProposals: (planOutput.repurposingProposals ?? undefined) as MarketingPlan['repurposingProposals'],
|
||||||
});
|
});
|
||||||
setData(plan);
|
|
||||||
setClinicId(stateClinicId);
|
setClinicId(stateClinicId);
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 소스 3: report를 fetch해서 변환 ───
|
|
||||||
const reportRes = await getReport(id!);
|
|
||||||
const reportRow = reportRes.data as unknown as Record<string, unknown>;
|
|
||||||
const plan = transformReportToPlan({
|
|
||||||
id: (reportRow.id as string) || id!,
|
|
||||||
url: (reportRow.url as string) || '',
|
|
||||||
clinic_name: (reportRow.clinic_name as string) || '',
|
|
||||||
report: reportRow,
|
|
||||||
created_at: (reportRow.created_at as string) || new Date().toISOString(),
|
|
||||||
});
|
|
||||||
setData(plan);
|
|
||||||
setClinicId((reportRow.clinic_id as string) || 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 {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue