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> = { access: ServerFilled, entity: DatabaseFilled, content: FileTextFilled, surface: ShareFilled, measure: EyeFilled, hygiene: ShieldFilled, }; const EVIDENCE_LABEL: Record = { measured: '실측', academic: '학술', vendor: '벤더', estimate: '추정', }; const CONTROL_LABEL: Record = { direct: '직접 통제', influence: '영향 가능', none: '통제 불가', }; const METHOD_LABEL: Record = { 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 ; } const resultById = new Map(result.results.map((r) => [r.criterionId, r])); return (
{/* ── 1. 헤더 (dark) ── */}
INFINITH AI Discovery · AEO / GEO Audit v{result.rubricVersion}

AI Answer Readiness

{result.clinicName}

{result.industry}

{result.url}

실측일 {result.auditedAt} · 실측 항목 {overall.categories.reduce((s, c) => s + c.verifiedCount, 0)} / {' '}{AEO_GEO_RUBRIC.criteria.length} · AEO·GEO 각 100점 독립 지표 · 미검증 항목은 분모 제외

{overallV2 && [overallV2.aeo, overallV2.geo].map((ax) => (
{ax.grade} {ax.axis.code} {ax.axis.name}
))}

v1.0 종합 {overall.score}/{overall.grade} · 실측 {overall.earned} / {overall.possible}점

{/* 카테고리 바 */}
{overall.categories.map((cs, i) => ( ))}
{/* ── 1b. v2.0 AEO / GEO 분리 점수 (light) ── */} {overallV2 && } {/* ── 2. 핵심 발견 (light) ── */}
{result.keyFindings.map((f, i) => (

{f.title}

{f.detail}

))}
{/* ── 3. 카테고리별 상세 (dark) ── */}
{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 (

{cs.category.code} · {cs.category.nameEn}

{cs.category.name}

배점 {cs.weight} · 검증 {cs.verifiedCount}/{cs.totalCount}

{cs.earned} / {cs.possible}점

{cs.category.description}

{criteria.map((c) => { const r = resultById.get(c.id); const level = r?.level ?? 'unverified'; const levelLabel = level === 'unverified' ? '미검증' : c.levels[level].label; return (
{c.id}

{c.name}

{c.weight}점

{r?.evidence}

{r?.source && (

{r.source}

)}
{EVIDENCE_LABEL[c.evidenceGrade]} {CONTROL_LABEL[c.control]} {METHOD_LABEL[c.method]}
{level !== 'unverified' && ( {Math.round((c.weight * level) / 3 * 10) / 10} / {c.weight} )}
); })}
); })}
{/* 개선 제안(Action Plan)·하지 않을 것·빌드 제안·확인 항목 패널은 /actions/:id 로 옮겼다. 증거(진단)와 처방(할 일)은 읽는 목적이 달라 여기 한 페이지에 두면 스크롤만 길어진다. 확인 항목 패널은 /supporters/:id 라는 전용 단계가 이미 있어 여기서 다시 보여줄 필요가 없다. */} {/* ── 4. 채점 기준표 (light) ── */}
{AEO_GEO_RUBRIC.categories.map((cat) => ( c.category === cat.id)} /> ))}
ID 항목 배점 근거 통제 채점 3점 기준
); } /* ────────────────────────── 서브 컴포넌트 ────────────────────────── */ /** 진단 데이터가 없는 병원. 빌드·사진 확인으로 잇고, 진단이 무엇인지 한 줄로 알린다. */ function NoReport({ clinicId }: { clinicId: string }) { return (
INFINITH AI Discovery

No Audit Yet

{clinicId}

이 병원은 아직 AEO/GEO 진단을 받지 않았습니다.{' '} 진단은 36개 항목을 실제로 물어보고 답변을 확인해 매기는 별도 작업이라, 사이트 빌드만으로는 만들어지지 않습니다.

진단 없이도 아래는 바로 보실 수 있습니다.

사이트 빌드 결과

만들어진 글과 안내 페이지를 봅니다.

사진 확인

사이트에 실을 사진을 고릅니다.

진단 리포트 예시는 뷰성형외과 리포트에서 보실 수 있습니다.

); } function CategoryBar({ cs, index }: { key?: string; cs: CategoryScore; index: number }) { const Icon = CATEGORY_ICON[cs.category.id]; return (
{cs.category.code}. {cs.category.name}
{cs.weight}점
{cs.pct}% {cs.verifiedCount < cs.totalCount ? `미검증 ${cs.totalCount - cs.verifiedCount}` : '전 항목 검증'}
); } function FindingIcon({ severity }: { severity: 'critical' | 'warning' | 'good' }) { if (severity === 'good') return ; if (severity === 'warning') return ; return ; } function RubricRows({ categoryName, criteria }: { key?: string; categoryName: string; criteria: RubricCriterion[] }) { return ( <> {categoryName} {criteria.map((c) => ( {c.id}

{c.name}

{c.rationale}

{c.howToCheck}

{c.weight} {EVIDENCE_LABEL[c.evidenceGrade]} {CONTROL_LABEL[c.control]} {METHOD_LABEL[c.method]} {c.levels[3].label} ))} ); }