feat(discovery): 진단 리포트에서 개선 제안(Action Plan) 단계 분리

증거(진단)와 처방(할 일)을 한 페이지에 두면 스크롤이 너무 길어져
둘 다 읽기 힘들다는 지적에 따라 단계를 나눴다.

- /actions/:id 신규: Action Plan(P0~P2) + What We Don't Sell + 빌드 제안
  (ActionPlanSection·IntegritySection 신규 컴포넌트, Tag는 두 페이지가
  같이 써서 공용 컴포넌트로 뺌)
- /discovery/🆔 위 섹션 제거, 진단(점수·근거·Scorecard·Rubric)만 남김.
  페이지 맨 아래 중복으로 박혀 있던 확인 항목 패널(ClinicInputsPanel)도
  제거 — /supporters/:id 전용 단계가 이미 있어 두 번 보여줄 필요가 없었다
- 진단 데이터가 없는 병원이 /actions/:id 로 오면 /discovery/:id 로 되돌림
- ClinicNav: 5단계로 확장(진단 리포트→개선 제안→사이트 빌드→사진 확인→
  확인 항목), 다음 단계 플로팅 버튼 추가(기존엔 이전 단계만 있었다)
- App.tsx: 라우트 전환 시 스크롤을 맨 위로 되돌림. 스크롤을 내린 채
  다음 단계를 누르면 새 페이지가 같은 위치(짧으면 빈 하단)에서 시작하던
  문제 수정. 인앱 해시 앵커(#breakdown)가 있으면 건드리지 않음
- 사이트 빌드/확인 항목 화면의 미리보기 버튼을 "초안 사이트 열기"로
  통일(공개 후에는 "공개본 사이트 열기")

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Haewon Kam 2026-09-21 10:27:12 +09:00
parent 36731d3ad1
commit 890da0ecdf
10 changed files with 267 additions and 127 deletions

View File

@ -1,3 +1,4 @@
import { useEffect } from 'react';
import { Outlet, useLocation } from 'react-router';
import Navbar from './components/Navbar';
import Footer from './components/Footer';
@ -8,6 +9,15 @@ export default function App() {
const location = useLocation();
const isLoadingPage = location.pathname.startsWith('/report/loading');
// 라우트가 바뀌어도 브라우저가 스크롤 위치를 그대로 들고 온다. 진단 리포트처럼 긴 페이지를
// 내려 보다가 "다음 단계"를 누르면, 새로 연 페이지가 그 위치(짧으면 하단 빈 공간)에서 시작해
// 항상 위부터 보게 되지 않는다. 라우트 전환마다 맨 위로 되돌린다.
// 해시(#breakdown 같은 인앱 앵커)가 있으면 그 앵커로 가려는 의도이므로 건드리지 않는다.
useEffect(() => {
if (location.hash) return;
window.scrollTo(0, 0);
}, [location.pathname, location.hash]);
return (
<div className="min-h-screen bg-slate-50 selection:bg-purple-200 selection:text-primary-900">
{!isLoadingPage && <Navbar />}

View File

@ -15,6 +15,7 @@ type Step = { key: string; label: string; to: (id: string) => string; match: Reg
const STEPS: Step[] = [
{ key: 'report', label: '진단 리포트', to: (id) => `/discovery/${id}`, match: /^\/discovery\/[^/]+/ },
{ key: 'actions', label: '개선 제안', to: (id) => `/actions/${id}`, match: /^\/actions\/[^/]+/ },
{ key: 'build', label: '사이트 빌드', to: (id) => `/build/${id}`, match: /^\/build\/[^/]+/ },
{ key: 'images', label: '사진 확인', to: (id) => `/images/${id}`, match: /^\/images\/[^/]+/ },
{ key: 'inputs', label: '확인 항목', to: (id) => `/supporters/${id}`, match: /^\/supporters\/[^/]+/ },
@ -32,6 +33,7 @@ export default function ClinicNav({ clinicId }: { clinicId: string }) {
const { pathname } = useLocation();
const currentIndex = STEPS.findIndex((s) => s.match.test(pathname));
const prev = currentIndex > 0 ? STEPS[currentIndex - 1] : null;
const next = currentIndex > -1 && currentIndex < STEPS.length - 1 ? STEPS[currentIndex + 1] : null;
return (
<>
@ -87,7 +89,11 @@ export default function ClinicNav({ clinicId }: { clinicId: string }) {
</nav>
{/* . .
. . */}
. .
. .
,
. . */}
{prev && (
<motion.div
initial={{ opacity: 0, x: -12 }}
@ -114,6 +120,33 @@ export default function ClinicNav({ clinicId }: { clinicId: string }) {
</Link>
</motion.div>
)}
{next && (
<motion.div
initial={{ opacity: 0, x: 12 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.3, duration: 0.3 }}
className="fixed right-4 bottom-4 md:right-6 md:bottom-6 z-40"
data-no-print
>
<Link
to={next.to(clinicId)}
className="group inline-flex items-center gap-2.5 rounded-full bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white
shadow-[0_4px_20px_rgba(29,0,36,0.24)] pl-5 pr-3 py-3 transition-shadow hover:shadow-[0_6px_26px_rgba(29,0,36,0.32)]"
>
<span className="text-right leading-tight">
<span className="block text-[11px] font-semibold text-white/60"> </span>
<span className="block text-sm font-bold whitespace-nowrap">{next.label}</span>
</span>
<span className="w-7 h-7 rounded-full bg-white/15 flex items-center justify-center
transition-colors group-hover:bg-white/25">
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
<path d="M5.1 1.4 10.7 7l-5.6 5.6a1.1 1.1 0 0 1-1.6-1.6L7.5 7l-4-4A1.1 1.1 0 0 1 5.1 1.4Z" fill="currentColor" />
</svg>
</span>
</Link>
</motion.div>
)}
</>
);
}

View File

@ -0,0 +1,67 @@
/**
* (/actions/:id) 1부: 우선순위별 .
* "Action Plan" . () ( )
* . .
*/
import { motion } from 'motion/react';
import { SectionWrapper } from '../report/ui/SectionWrapper';
import { Tag } from './Tag';
import type { ActionPriority, DiscoveryResult } from '../../types/discovery';
const PRIORITY_STYLE: Record<ActionPriority, string> = {
P0: 'bg-[#FFF0F0] text-[#7C3A4B] border-[#F5D5DC]',
P1: 'bg-[#FFF6ED] text-[#7C5C3A] border-[#F5E0C5]',
P2: 'bg-[#F3F0FF] text-[#4A3A7C] border-[#D5CDF5]',
};
const PRIORITY_HEADING: Record<ActionPriority, string> = {
P0: '즉시: 크롤러 접근·엔티티·측정',
P1: '4주 내: 콘텐츠 구조',
P2: '지속: 신선도·시딩',
};
export function ActionPlanSection({ result }: { result: DiscoveryResult }) {
return (
<SectionWrapper
id="actions"
title="Action Plan"
subtitle="우선순위별 실행 항목. 각 항목이 어느 기준을 올리는지 연결"
>
<div className="space-y-4">
{(['P0', 'P1', 'P2'] as ActionPriority[]).map((p) => (
<div key={p}>
<div className="flex items-center gap-3 mb-3 mt-6 first:mt-0">
<span className={`inline-flex items-center rounded-full text-sm font-semibold px-3 py-1 border ${PRIORITY_STYLE[p]}`}>
{p}
</span>
<span className="text-lg font-bold text-[#0A1128]">{PRIORITY_HEADING[p]}</span>
</div>
<div className="grid md:grid-cols-2 gap-4">
{result.actions.filter((a) => a.priority === p).map((a, i) => (
<motion.div
key={a.title}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.08 }}
className="on-light bg-white rounded-2xl border border-slate-100 shadow-sm p-5"
>
<div className="flex items-start justify-between gap-3 mb-2">
<h4 className="font-bold text-[#0A1128]">{a.title}</h4>
<span className="ui-note shrink-0">{a.effort}</span>
</div>
<p className="ui-body mb-3">{a.detail}</p>
<div className="flex flex-wrap gap-1.5">
{a.criterionIds.map((cid) => (
<Tag key={cid}>{cid}</Tag>
))}
</div>
</motion.div>
))}
</div>
</div>
))}
</div>
</SectionWrapper>
);
}

View File

@ -117,7 +117,7 @@ export function ClinicInputsPanel({ clinicId }: { clinicId: string }) {
<p className="ui-note mt-1"> {build.updated_at.slice(0, 16).replace('T', ' ')} · {posts.length}{typeof build.report?.totalCostUsd === 'number' ? ` · 생성 비용 $${build.report.totalCostUsd}` : ''}</p>
</div>
{build.preview_url && (
<a href={build.preview_url} target="_blank" rel="noreferrer" className="inline-flex items-center justify-center rounded-full bg-primary-900 text-white font-semibold px-6 py-3 text-base"> (noindex)</a>
<a href={build.preview_url} target="_blank" rel="noreferrer" className="inline-flex items-center justify-center rounded-full bg-primary-900 text-white font-semibold px-6 py-3 text-base">{build.status === 'published' || build.status === 'approved' ? '공개본 사이트 열기' : '초안 사이트 열기'}</a>
)}
</div>
)}

View File

@ -0,0 +1,53 @@
/**
* (/actions/:id) 2부: 하지 + .
* "이 개선안이 무엇을 안 파는지" . .
*/
import { SectionWrapper } from '../report/ui/SectionWrapper';
import { CheckFilled, CrossFilled } from '../icons/FilledIcons';
import type { DiscoveryResult } from '../../types/discovery';
export function IntegritySection({ result }: { result: DiscoveryResult }) {
return (
<SectionWrapper
id="integrity"
title="What We Don't Sell"
subtitle="근거 없는 것은 팔지 않는다. 이 리포트의 정직성 규약"
dark
>
<div className="grid lg:grid-cols-2 gap-6">
<div className="on-light bg-white rounded-2xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 text-[#0A1128]">
<h3 className="text-xl font-bold mb-4 font-sans text-[#0A1128]!"> </h3>
<ul className="space-y-4">
{result.notDoing.map((n) => (
<li key={n.item} className="flex gap-3">
<span className="shrink-0 text-[#D4889A] mt-0.5"><CrossFilled size={18} /></span>
<div>
<p className="text-base font-semibold">{n.item}</p>
<p className="ui-note mt-0.5">{n.reason}</p>
</div>
</li>
))}
</ul>
</div>
<div className="on-light bg-white rounded-2xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 text-[#0A1128]">
<h3 className="text-xl font-bold mb-4 font-sans text-[#0A1128]!"> </h3>
<ul className="space-y-3">
{result.conditions.map((c) => (
<li key={c} className="flex gap-3">
<span className="shrink-0 text-[#9B8AD4] mt-0.5"><CheckFilled size={18} /></span>
<p className="ui-body">{c}</p>
</li>
))}
</ul>
<div className="mt-6 rounded-xl p-4 bg-[linear-gradient(to_right,#fff3eb,#e4cfff,#f5f9ff)]">
<p className="text-sm text-slate-600 leading-relaxed">
<strong> </strong> . (·GBP·GA4·)
, . ,
·· <strong></strong> .
</p>
</div>
</div>
</div>
</SectionWrapper>
);
}

View File

@ -0,0 +1,13 @@
/**
* . (Scorecard의 // ) ( ID )
* .
*/
import type { ReactNode } from 'react';
export function Tag({ children }: { key?: string; children: ReactNode }) {
return (
<span className="inline-flex items-center rounded-full bg-slate-100 text-slate-600 text-sm font-medium px-2 py-0.5">
{children}
</span>
);
}

View File

@ -17,6 +17,7 @@ import ApiDashboardPage from './pages/ApiDashboardPage.tsx';
import LoginPage from './pages/LoginPage.tsx';
import DiscoveryLandingPage from './pages/DiscoveryLandingPage.tsx';
import DiscoveryReportPage from './pages/DiscoveryReportPage.tsx';
import ActionPlanPage from './pages/ActionPlanPage.tsx';
import SupportersBuildPage from './pages/SupportersBuildPage.tsx';
import ImageReviewPage from './pages/ImageReviewPage.tsx';
import SiteBuildPage from './pages/SiteBuildPage.tsx';
@ -49,6 +50,7 @@ createRoot(document.getElementById('root')!).render(
<Route path="api-dashboard" element={<ApiDashboardPage />} />
<Route path="discovery" element={<DiscoveryLandingPage />} />
<Route path="discovery/:id" element={<DiscoveryReportPage />} />
<Route path="actions/:id" element={<ActionPlanPage />} />
<Route path="supporters/:id" element={<SupportersBuildPage />} />
<Route path="images/:id" element={<ImageReviewPage />} />
<Route path="build/:id" element={<SiteBuildPage />} />

View File

@ -0,0 +1,80 @@
/**
* /actions/:id (Action Plan). (/discovery/:id) .
*
* "지금 무엇이 문제인가"(), "그래서 무엇을 하는가"().
* , .
* .
*/
import { useMemo } from 'react';
import { useParams, Navigate } from 'react-router';
import { AEO_GEO_RUBRIC } from '../data/aeoGeoRubric';
import { AEO_GEO_RUBRIC_V2 } from '../data/aeoGeoRubricV2';
import { DISCOVERY_RESULTS } from '../data/discoveryResults';
import { scoreDiscovery } from '../lib/discoveryScore';
import { scoreDiscoveryV2 } from '../lib/discoveryScoreV2';
import { ActionPlanSection } from '../components/discovery/ActionPlanSection';
import { IntegritySection } from '../components/discovery/IntegritySection';
import { BuildPlanSection } from '../components/discovery/BuildPlanSection';
import { PrismFilled } from '../components/icons/FilledIcons';
export default function ActionPlanPage() {
const { id } = useParams<{ id: string }>();
const result = id ? DISCOVERY_RESULTS[id] : undefined;
const overall = useMemo(
() => (result ? scoreDiscovery(AEO_GEO_RUBRIC, result) : null),
[result],
);
const overallV2 = useMemo(
() => (result ? scoreDiscoveryV2(AEO_GEO_RUBRIC_V2, result) : null),
[result],
);
if (!id) return null;
if (!result || !overall) {
// 개선 제안은 진단 항목 위에 세운다. 진단이 없으면 여기서 보여줄 근거가 없으니
// 진단 리포트로 보낸다. 그 페이지가 "아직 진단 전" 안내와 다음 링크를 갖고 있다.
return <Navigate to={`/discovery/${id}`} replace />;
}
return (
<div data-report-content className="[word-break:keep-all]">
<section className="on-dark bg-[#0A1128] text-white relative overflow-hidden pt-28 pb-16 md:pt-36 md:pb-20 px-6">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,rgba(108,92,231,0.18),transparent_60%)]" />
<div className="relative max-w-7xl mx-auto">
<div className="inline-flex items-center gap-2 rounded-full bg-white/10 border border-white/10 px-4 py-1.5 mb-6">
<PrismFilled size={16} className="text-purple-300" />
<span className="text-sm font-semibold tracking-wide text-purple-200">
INFINITH AI Discovery · Action Plan
</span>
</div>
<h1 className="font-serif text-4xl md:text-6xl font-bold tracking-[-0.02em] mb-4">
<span className="bg-gradient-to-r from-purple-300 to-blue-300 bg-clip-text text-transparent">
</span>
</h1>
<p className="text-3xl font-bold mb-2">{result.clinicName}</p>
<p className="ui-body text-white/70 max-w-2xl">
(v{result.rubricVersion} · {overall.score}/{overall.grade}) ,
.
</p>
</div>
</section>
<ActionPlanSection result={result} />
<IntegritySection result={result} />
<BuildPlanSection
result={result}
overall={overall}
summary={{
rubricVersion: result.rubricVersion,
grade: overall.grade,
score: overall.score,
aeo: overallV2?.aeo.score,
geo: overallV2?.geo.score,
verified: overall.categories.reduce((n, c) => n + c.verifiedCount, 0),
total: AEO_GEO_RUBRIC.criteria.length,
}}
/>
</div>
);
}

View File

@ -1,17 +1,15 @@
import { useMemo } from 'react';
import type { ComponentType, ReactNode } from 'react';
import type { ComponentType } from 'react';
import { useParams, Link } from 'react-router';
import { motion } from 'motion/react';
import { AEO_GEO_RUBRIC } from '../data/aeoGeoRubric';
import { AEO_GEO_RUBRIC_V2 } from '../data/aeoGeoRubricV2';
import { scoreDiscoveryV2 } from '../lib/discoveryScoreV2';
import { AeoGeoV2Panel } from '../components/discovery/AeoGeoV2Panel';
import { ClinicInputsPanel } from '../components/discovery/ClinicInputsPanel';
import { BuildPlanSection } from '../components/discovery/BuildPlanSection';
import { Tag } from '../components/discovery/Tag';
import { DISCOVERY_RESULTS } from '../data/discoveryResults';
import { scoreDiscovery, levelToSeverity } from '../lib/discoveryScore';
import type {
ActionPriority,
CategoryId,
CategoryScore,
ControlLevel,
@ -65,12 +63,6 @@ const METHOD_LABEL: Record<EvidenceMethod, string> = {
manual: '수동',
};
const PRIORITY_STYLE: Record<ActionPriority, string> = {
P0: 'bg-[#FFF0F0] text-[#7C3A4B] border-[#F5D5DC]',
P1: 'bg-[#FFF6ED] text-[#7C5C3A] border-[#F5E0C5]',
P2: 'bg-[#F3F0FF] text-[#4A3A7C] border-[#D5CDF5]',
};
const scoreColor = (pct: number) =>
pct <= 40 ? '#C084CF' : pct <= 60 ? '#8B9CF7' : pct <= 80 ? '#7C6DD8' : '#6C5CE7';
@ -265,113 +257,11 @@ export default function DiscoveryReportPage() {
</div>
</SectionWrapper>
{/* ── 4. 실행 계획 (light) ── */}
<SectionWrapper
id="actions"
title="Action Plan"
subtitle="우선순위별 실행 항목. 각 항목이 어느 기준을 올리는지 연결"
>
<div className="space-y-4">
{(['P0', 'P1', 'P2'] as ActionPriority[]).map((p) => (
<div key={p}>
<div className="flex items-center gap-3 mb-3 mt-6 first:mt-0">
<span className={`inline-flex items-center rounded-full text-sm font-semibold px-3 py-1 border ${PRIORITY_STYLE[p]}`}>
{p}
</span>
<span className="text-lg font-bold text-[#0A1128]">
{p === 'P0' ? '즉시: 크롤러 접근·엔티티·측정' : p === 'P1' ? '4주 내: 콘텐츠 구조' : '지속: 신선도·시딩'}
</span>
</div>
<div className="grid md:grid-cols-2 gap-4">
{result.actions.filter((a) => a.priority === p).map((a, i) => (
<motion.div
key={a.title}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.08 }}
className="on-light bg-white rounded-2xl border border-slate-100 shadow-sm p-5"
>
<div className="flex items-start justify-between gap-3 mb-2">
<h4 className="font-bold text-[#0A1128]">{a.title}</h4>
<span className="ui-note shrink-0">{a.effort}</span>
</div>
<p className="ui-body mb-3">{a.detail}</p>
<div className="flex flex-wrap gap-1.5">
{a.criterionIds.map((cid) => (
<Tag key={cid}>{cid}</Tag>
))}
</div>
</motion.div>
))}
</div>
</div>
))}
</div>
</SectionWrapper>
{/* (Action Plan)· · · /actions/:id .
() ( ) .
/supporters/:id . */}
{/* ── 4b. 진단 요약 → 개선 계획 → 빌드 (dark) ── */}
<BuildPlanSection
result={result}
overall={overall}
summary={{
rubricVersion: result.rubricVersion,
grade: overall.grade,
score: overall.score,
aeo: overallV2?.aeo.score,
geo: overallV2?.geo.score,
verified: overall.categories.reduce((n, c) => n + c.verifiedCount, 0),
total: AEO_GEO_RUBRIC.criteria.length,
}}
/>
{/* ── 5. 하지 않을 것 + 측정 조건 (dark) ── */}
<SectionWrapper
id="integrity"
title="What We Don't Sell"
subtitle="근거 없는 것은 팔지 않는다. 이 리포트의 정직성 규약"
dark
>
<div className="grid lg:grid-cols-2 gap-6">
<div className="on-light bg-white rounded-2xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 text-[#0A1128]">
<h3 className="text-xl font-bold mb-4 font-sans text-[#0A1128]!"> </h3>
<ul className="space-y-4">
{result.notDoing.map((n) => (
<li key={n.item} className="flex gap-3">
<span className="shrink-0 text-[#D4889A] mt-0.5"><CrossFilled size={18} /></span>
<div>
<p className="text-base font-semibold">{n.item}</p>
<p className="ui-note mt-0.5">{n.reason}</p>
</div>
</li>
))}
</ul>
</div>
<div className="on-light bg-white rounded-2xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 text-[#0A1128]">
<h3 className="text-xl font-bold mb-4 font-sans text-[#0A1128]!"> </h3>
<ul className="space-y-3">
{result.conditions.map((c) => (
<li key={c} className="flex gap-3">
<span className="shrink-0 text-[#9B8AD4] mt-0.5"><CheckFilled size={18} /></span>
<p className="ui-body">{c}</p>
</li>
))}
</ul>
<div className="mt-6 rounded-xl p-4 bg-[linear-gradient(to_right,#fff3eb,#e4cfff,#f5f9ff)]">
<p className="text-sm text-slate-600 leading-relaxed">
<strong> </strong> . (·GBP·GA4·)
, . ,
·· <strong></strong> .
</p>
</div>
</div>
</div>
</SectionWrapper>
{/* ── 5b. 서포터즈 자동 빌드 · 병원 확인 항목 (light, v2 §7-5) ── */}
<ClinicInputsPanel clinicId={result.id} />
{/* ── 6. 채점 기준표 (light) ── */}
{/* ── 4. 채점 기준표 (light) ── */}
<SectionWrapper
id="rubric"
title="Scoring Rubric"
@ -494,14 +384,6 @@ function FindingIcon({ severity }: { severity: 'critical' | 'warning' | 'good' }
return <span className="text-[#D4889A] shrink-0"><CrossFilled size={22} /></span>;
}
function Tag({ children }: { key?: string; children: ReactNode }) {
return (
<span className="inline-flex items-center rounded-full bg-slate-100 text-slate-600 text-sm font-medium px-2 py-0.5">
{children}
</span>
);
}
function RubricRows({ categoryName, criteria }: { key?: string; categoryName: string; criteria: RubricCriterion[] }) {
return (
<>

View File

@ -205,7 +205,7 @@ export default function SiteBuildPage() {
rel="noreferrer"
className="btn-on-dark justify-center"
>
<EyeFilled size={16} />
<EyeFilled size={16} /> {state === 'published' || state === 'approved' ? '공개본 사이트 열기' : '초안 사이트 열기'}
</a>
)}