o2o-infinith-demo/src/pages/DiscoveryReportPage.tsx
Haewon Kam 890da0ecdf 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>
2026-09-21 10:27:12 +09:00

413 lines
19 KiB
TypeScript

import { useMemo } 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 { Tag } from '../components/discovery/Tag';
import { DISCOVERY_RESULTS } from '../data/discoveryResults';
import { scoreDiscovery, levelToSeverity } from '../lib/discoveryScore';
import type {
CategoryId,
CategoryScore,
ControlLevel,
EvidenceGrade,
EvidenceMethod,
RubricCriterion,
} from '../types/discovery';
import { SectionWrapper } from '../components/report/ui/SectionWrapper';
import { ScoreRing } from '../components/report/ui/ScoreRing';
import { SeverityBadge } from '../components/report/ui/SeverityBadge';
import {
ServerFilled,
DatabaseFilled,
FileTextFilled,
ShareFilled,
EyeFilled,
ShieldFilled,
PrismFilled,
CheckFilled,
CrossFilled,
WarningFilled,
} from '../components/icons/FilledIcons';
/* ────────────────────────── 라벨 맵 ────────────────────────── */
const CATEGORY_ICON: Record<CategoryId, ComponentType<{ size?: number; className?: string }>> = {
access: ServerFilled,
entity: DatabaseFilled,
content: FileTextFilled,
surface: ShareFilled,
measure: EyeFilled,
hygiene: ShieldFilled,
};
const EVIDENCE_LABEL: Record<EvidenceGrade, string> = {
measured: '실측',
academic: '학술',
vendor: '벤더',
estimate: '추정',
};
const CONTROL_LABEL: Record<ControlLevel, string> = {
direct: '직접 통제',
influence: '영향 가능',
none: '통제 불가',
};
const METHOD_LABEL: Record<EvidenceMethod, string> = {
auto: '자동',
semi: '반자동',
manual: '수동',
};
const scoreColor = (pct: number) =>
pct <= 40 ? '#C084CF' : pct <= 60 ? '#8B9CF7' : pct <= 80 ? '#7C6DD8' : '#6C5CE7';
/* ────────────────────────── 페이지 ────────────────────────── */
export default function DiscoveryReportPage() {
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],
);
// 진단은 AEO/GEO 36항목 실측 결과가 있어야 그린다. 사이트 빌드와는 별개 산출물이라
// 빌드만 끝난 병원은 여기에 들어올 수 있다. 막다른 길로 두지 않고 다음 단계로 잇는다.
if (!result || !overall) {
return <NoReport clinicId={id ?? ''} />;
}
const resultById = new Map(result.results.map((r) => [r.criterionId, r]));
return (
<div data-report-content className="[word-break:keep-all]">
{/* ── 1. 헤더 (dark) ── */}
<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 grid lg:grid-cols-[1fr_auto] gap-12 items-center">
<div>
<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 · AEO / GEO Audit v{result.rubricVersion}
</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">
AI Answer Readiness
</span>
</h1>
<p className="text-3xl font-bold mb-2">{result.clinicName}</p>
<p className="text-purple-200 text-base mb-1">{result.industry}</p>
<a
href={result.url}
target="_blank"
rel="noreferrer"
className="text-purple-300 text-base underline underline-offset-4"
>
{result.url}
</a>
<p className="text-white/60 text-sm mt-4">
실측일 {result.auditedAt} · 실측 항목 {overall.categories.reduce((s, c) => s + c.verifiedCount, 0)} /
{' '}{AEO_GEO_RUBRIC.criteria.length} · AEO·GEO 각 100점 독립 지표 · 미검증 항목은 분모 제외
</p>
</div>
<div className="on-light bg-white rounded-2xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-8 flex flex-col items-center gap-4 min-w-[260px]">
<div className="flex items-start gap-8">
{overallV2 && [overallV2.aeo, overallV2.geo].map((ax) => (
<div key={ax.axis.id} className="flex flex-col items-center gap-2">
<ScoreRing score={ax.score} size={130} label={ax.axis.nameEn} />
<div className="flex items-baseline gap-2">
<span className="font-serif text-3xl font-black text-[#0A1128]">{ax.grade}</span>
<span className="ui-note">{ax.axis.code} {ax.axis.name}</span>
</div>
</div>
))}
</div>
<p className="ui-note">
v1.0 종합 {overall.score}/{overall.grade} · 실측 {overall.earned} / {overall.possible}점
</p>
</div>
</div>
{/* 카테고리 바 */}
<div className="relative max-w-7xl mx-auto mt-14 grid md:grid-cols-3 gap-4">
{overall.categories.map((cs, i) => (
<CategoryBar key={cs.category.id} cs={cs} index={i} />
))}
</div>
</section>
{/* ── 1b. v2.0 AEO / GEO 분리 점수 (light) ── */}
{overallV2 && <AeoGeoV2Panel rubric={AEO_GEO_RUBRIC_V2} score={overallV2} />}
{/* ── 2. 핵심 발견 (light) ── */}
<SectionWrapper
id="findings"
title="What AI Sees"
subtitle="AI 답변엔진이 지금 이 업체를 어떻게 읽는지. 진단의 결론"
>
<div className="grid md:grid-cols-2 gap-5">
{result.keyFindings.map((f, i) => (
<motion.div
key={f.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-[3px_4px_12px_rgba(0,0,0,0.06)] p-6"
>
<div className="flex items-start justify-between gap-4 mb-3">
<h3 className="text-xl font-bold text-[#0A1128]">{f.title}</h3>
<FindingIcon severity={f.severity} />
</div>
<p className="ui-body">{f.detail}</p>
</motion.div>
))}
</div>
</SectionWrapper>
{/* ── 3. 카테고리별 상세 (dark) ── */}
<SectionWrapper
id="breakdown"
title="Scorecard by Category"
subtitle="v1.0 실측 항목 36개 · AEO/GEO 점수의 파생 신호. 각 항목의 실측 근거와 근거 등급"
dark
>
<div className="space-y-6">
{overall.categories.map((cs) => {
const Icon = CATEGORY_ICON[cs.category.id];
const criteria = AEO_GEO_RUBRIC.criteria.filter((c) => c.category === cs.category.id);
return (
<div
key={cs.category.id}
className="on-light bg-white rounded-2xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 text-[#0A1128]"
>
<div className="flex flex-wrap items-center justify-between gap-4 mb-5">
<div>
<div className="flex items-center gap-3">
<div className="w-10 h-10 shrink-0 rounded-xl bg-[#F3F0FF] flex items-center justify-center text-[#6C5CE7]">
<Icon size={22} />
</div>
<p className="font-serif text-[28px] font-bold text-[#1D0024] tracking-[-0.01em] leading-10">
{cs.category.code} · {cs.category.nameEn}
</p>
</div>
<h3 className="text-xl font-bold mt-1 pl-[52px] font-sans text-[#0A1128]!">{cs.category.name}</h3>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<p className="ui-note">배점 {cs.weight} · 검증 {cs.verifiedCount}/{cs.totalCount}</p>
<p className="text-base font-semibold">{cs.earned} / {cs.possible}점</p>
</div>
<ScoreRing score={cs.pct} size={64} />
</div>
</div>
<p className="text-lg text-[#4F1DA1] mb-4">{cs.category.description}</p>
<div className="divide-y divide-slate-100">
{criteria.map((c) => {
const r = resultById.get(c.id);
const level = r?.level ?? 'unverified';
const levelLabel =
level === 'unverified' ? '미검증' : c.levels[level].label;
return (
<div key={c.id} className="py-4 grid md:grid-cols-[170px_1fr_auto] gap-3 items-start">
<div>
<span className="ui-note font-semibold">{c.id}</span>
<p className="text-base font-bold">{c.name}</p>
<p className="ui-note mt-0.5">{c.weight}점</p>
</div>
<div>
<p className="ui-body">{r?.evidence}</p>
{r?.source && (
<p className="ui-note mt-1 font-mono break-all">{r.source}</p>
)}
<div className="flex flex-wrap gap-1.5 mt-2">
<Tag>{EVIDENCE_LABEL[c.evidenceGrade]}</Tag>
<Tag>{CONTROL_LABEL[c.control]}</Tag>
<Tag>{METHOD_LABEL[c.method]}</Tag>
</div>
</div>
<div className="flex flex-col items-end gap-1">
<SeverityBadge severity={levelToSeverity(level)} label={levelLabel} className="text-base font-bold px-4 py-1.5" />
{level !== 'unverified' && (
<span className="ui-note">
{Math.round((c.weight * level) / 3 * 10) / 10} / {c.weight}
</span>
)}
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
</SectionWrapper>
{/* 개선 제안(Action Plan)·하지 않을 것·빌드 제안·확인 항목 패널은 /actions/:id 로 옮겼다.
증거(진단)와 처방(할 일)은 읽는 목적이 달라 여기 한 페이지에 두면 스크롤만 길어진다.
확인 항목 패널은 /supporters/:id 라는 전용 단계가 이미 있어 여기서 다시 보여줄 필요가 없다. */}
{/* ── 4. 채점 기준표 (light) ── */}
<SectionWrapper
id="rubric"
title="Scoring Rubric"
subtitle={`실측 기준표 v${AEO_GEO_RUBRIC.version} · ${AEO_GEO_RUBRIC.criteria.length}개 항목. v2.0 AEO/GEO 점수는 이 항목들을 파생 신호로 쓴다`}
>
<div className="on-light overflow-x-auto rounded-2xl border border-slate-100 shadow-sm bg-white">
<table className="w-full text-base min-w-[860px]">
<thead className="on-dark bg-[#0A1128] text-white">
<tr>
<th className="text-left px-4 py-3 font-semibold w-16">ID</th>
<th className="text-left px-4 py-3 font-semibold">항목</th>
<th className="text-left px-4 py-3 font-semibold w-14">배점</th>
<th className="text-left px-4 py-3 font-semibold w-16">근거</th>
<th className="text-left px-4 py-3 font-semibold w-20">통제</th>
<th className="text-left px-4 py-3 font-semibold w-16">채점</th>
<th className="text-left px-4 py-3 font-semibold">3점 기준</th>
</tr>
</thead>
<tbody>
{AEO_GEO_RUBRIC.categories.map((cat) => (
<RubricRows
key={cat.id}
categoryName={`${cat.code}. ${cat.name} (${cat.weight})`}
criteria={AEO_GEO_RUBRIC.criteria.filter((c) => c.category === cat.id)}
/>
))}
</tbody>
</table>
</div>
</SectionWrapper>
</div>
);
}
/* ────────────────────────── 서브 컴포넌트 ────────────────────────── */
/** 진단 데이터가 없는 병원. 빌드·사진 확인으로 잇고, 진단이 무엇인지 한 줄로 알린다. */
function NoReport({ clinicId }: { clinicId: string }) {
return (
<div 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-3xl 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</span>
</div>
<h1 className="font-serif text-4xl md:text-5xl font-bold tracking-[-0.02em] mb-4">
<span className="bg-gradient-to-r from-purple-300 to-blue-300 bg-clip-text text-transparent">No Audit Yet</span>
</h1>
<p className="text-2xl font-bold mb-3">{clinicId}</p>
<p className="ui-body">
이 병원은 아직 AEO/GEO 진단을 받지 않았습니다.{' '}
진단은 36개 항목을 실제로 물어보고 답변을 확인해 매기는 별도 작업이라, 사이트 빌드만으로는 만들어지지 않습니다.
</p>
</div>
</section>
<section className="bg-white py-16 md:py-20 px-6">
<div className="max-w-3xl mx-auto">
<p className="ui-ask ui-ask-block mb-8">진단 없이도 아래는 바로 보실 수 있습니다.</p>
<div className="grid sm:grid-cols-2 gap-4">
<Link to={`/build/${clinicId}`} className="rounded-2xl border border-slate-200 p-5 hover:shadow-[4px_6px_16px_rgba(0,0,0,0.09)] transition-shadow">
<p className="font-bold text-[#1D0024] mb-1">사이트 빌드 결과</p>
<p className="ui-note">만들어진 글과 안내 페이지를 봅니다.</p>
</Link>
<Link to={`/images/${clinicId}`} className="rounded-2xl border border-slate-200 p-5 hover:shadow-[4px_6px_16px_rgba(0,0,0,0.09)] transition-shadow">
<p className="font-bold text-[#1D0024] mb-1">사진 확인</p>
<p className="ui-note">사이트에 실을 사진을 고릅니다.</p>
</Link>
</div>
<p className="ui-note mt-8">
진단 리포트 예시는 <Link to="/discovery/viewclinic" className="underline underline-offset-4 text-[#4A3A7C]">뷰성형외과 리포트</Link>에서 보실 수 있습니다.
</p>
</div>
</section>
</div>
);
}
function CategoryBar({ cs, index }: { key?: string; cs: CategoryScore; index: number }) {
const Icon = CATEGORY_ICON[cs.category.id];
return (
<motion.a
href="#breakdown"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 + index * 0.06 }}
className="on-light bg-white rounded-2xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:shadow-[4px_6px_16px_rgba(0,0,0,0.09)] transition-shadow p-5 text-[#0A1128]"
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="text-[#6C5CE7]"><Icon size={18} /></span>
<span className="text-base font-bold">{cs.category.code}. {cs.category.name}</span>
</div>
<span className="ui-note">{cs.weight}점</span>
</div>
<div className="h-2 rounded-full bg-slate-100 overflow-hidden">
<motion.div
className="h-full rounded-full"
style={{ background: scoreColor(cs.pct) }}
initial={{ width: 0 }}
animate={{ width: `${cs.pct}%` }}
transition={{ duration: 0.9, ease: 'easeOut', delay: 0.3 + index * 0.06 }}
/>
</div>
<div className="flex justify-between mt-2 text-sm">
<span className="font-semibold">{cs.pct}%</span>
<span className="text-slate-400">
{cs.verifiedCount < cs.totalCount ? `미검증 ${cs.totalCount - cs.verifiedCount}` : '전 항목 검증'}
</span>
</div>
</motion.a>
);
}
function FindingIcon({ severity }: { severity: 'critical' | 'warning' | 'good' }) {
if (severity === 'good') return <span className="text-[#9B8AD4] shrink-0"><CheckFilled size={22} /></span>;
if (severity === 'warning') return <span className="text-[#D4A872] shrink-0"><WarningFilled size={22} /></span>;
return <span className="text-[#D4889A] shrink-0"><CrossFilled size={22} /></span>;
}
function RubricRows({ categoryName, criteria }: { key?: string; categoryName: string; criteria: RubricCriterion[] }) {
return (
<>
<tr className="bg-[#F4F6FB]">
<td colSpan={7} className="px-4 py-2 text-sm font-bold text-[#4A3A7C] tracking-wide">
{categoryName}
</td>
</tr>
{criteria.map((c) => (
<tr key={c.id} className="border-t border-slate-100 align-top">
<td className="px-4 py-3 font-semibold text-slate-500">{c.id}</td>
<td className="px-4 py-3">
<p className="font-semibold text-[#0A1128]">{c.name}</p>
<p className="ui-note mt-1">{c.rationale}</p>
<p className="ui-note mt-1 font-mono">{c.howToCheck}</p>
</td>
<td className="px-4 py-3 font-semibold">{c.weight}</td>
<td className="px-4 py-3 text-slate-600">{EVIDENCE_LABEL[c.evidenceGrade]}</td>
<td className="px-4 py-3 text-slate-600">{CONTROL_LABEL[c.control]}</td>
<td className="px-4 py-3 text-slate-600">{METHOD_LABEL[c.method]}</td>
<td className="px-4 py-3 text-slate-600">{c.levels[3].label}</td>
</tr>
))}
</>
);
}