o2o-infinith-demo/src/components/discovery/ClinicInputsPanel.tsx
Haewon Kam 5d3ce40f77 feat(discovery): 의료관광 연결 섹션 — TourAPI 다국어 주변 정보 + 회복 동선
/discovery/:id 에 외국인 환자용 주변 정보를 붙였다. 숙박·음식점·웰니스·관광·문화·
쇼핑·축제 125건과 이동 반경 기준 회복 동선. 다음 뷰성형외과 미팅용 프로토타입이다.

데이터 (scripts/fetch_medical_tourism.py → src/data/medicalTourism.json)
한국관광공사 TourAPI 영문(EngService2) 사전 수집. 실시간 호출이 아닌 이유는 영문
커버리지가 얇아 필터링이 많이 필요하고, 시연 중 외부 API 상태에 화면이 종속되지
않게 하기 위해서다. 좌표는 Naver 지역검색 실측(뷰성형외과의원, 봉은사로 107).

실측으로 드러난 것과 대응
- 영문 관광지(76)의 대부분이 관광지가 아니다. 반경 5km 100건 중 92건이 분류코드
  A02020500 "Medical Tourism Sites"(categoryCode2 로 확인)이며 병원과 유치
  에이전시다. 제목 키워드로는 "하이안과", "닥파인더코리아" 를 놓쳐 코드로 걸렀다.
  거리순 상위 100건이 거의 다 여기라 페이징을 넣어야 실제 관광지가 나온다.
- 스파·찜질방(A02020300/400)은 회복 여정에 맞아 wellness 로 분리했다.
- 영문 축제는 areacode 필드가 비어 있어 areaCode 필터가 0건을 반환한다. 좌표
  거리로 걸렀다.
- TourAPI 이미지 URL 이 http 라 https 사이트에서 혼합 콘텐츠로 차단된다. 수집
  단계에서 https 로 올린다.
- 반경을 목적별로 나눴다. 회복기에 자주 가는 숙박·식사는 5km, 관광은 10km.

만들지 않은 것
- 평점·리뷰·영업시간: TourAPI 미제공이라 null 로 두고 화면에 "미연동" 으로 적었다.
  Google Places 키가 아직 비어 있다(신청 중).
- 수술 전후 식이 적합성: 의학 판단이라 병원 입력만 쓴다. ClinicInputsPanel 에
  diet_guide 항목을 더해 supporter_inputs 로 받고, 입력 전에는 "병원 확인 대기"
  로 둔다. 개별 식당의 적합 여부를 추론하지 않는다.
- 회복 단계가 며칠째인지: 병원 입력(recovery). 화면의 단계는 이동 반경으로만
  정의해 의학적 함의를 담지 않는다.
- 한계 4가지를 meta.limits 에 담아 섹션 하단 "측정 가능 범위" 에 그대로 띄운다.

FilledIcons 에 Bed·Fork·Spa·MapPin·Ticket·Bag·Theater 7종 추가(라인 아이콘·이모지
금지 규칙에 따라 채움 SVG).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 11:31:55 +09:00

237 lines
20 KiB
TypeScript

/**
* 병원 확인 항목 패널 (서포터즈 자동 빌드 v2 §6 · §7-5 · §12-3)
* - /discovery/:id 리포트 안에서, 자동 빌드 미리보기 상태와 병원이 입력해야 하는 값을 한 곳에 둔다.
* - 읽기: supporter_builds(최신 1건) · supporter_inputs(같은 clinic_id, 최신 행이 유효값)
* - 쓰기: supporter_inputs insert (anon RLS). 저장 후 워커가 apply_inputs → 재빌드한다. 여기서는 사이트를 직접 고치지 않는다.
* - 값이 없으면 사이트는 "확인 대기"로 발행된다. 이 폼은 그 자리를 채우는 곳이다.
* - 의학 검토·게시 승인은 사람 게이트다(§3-7). 후보만 보여주고 확정은 병원이 입력한다.
*/
import { useEffect, useMemo, useState } from 'react';
import { supabase } from '../../lib/supabase';
type Build = {
id: string;
status: string;
phase: string | null;
preview_url: string | null;
posts: Array<{ id: string; title: string; category: string; status?: string; reviewStatus?: string; pending?: string[] }>;
report: {
physicians?: Array<{ id: string; name: string; title: string }>;
generator?: Record<string, unknown>;
totalCostUsd?: number;
channels?: {
inventory: Array<{ platform: string; state: string; confirmed: number; candidates: number; tried: string }>;
confirmed: Record<string, Array<{ key: string; url: string; title?: string; subscribers?: number; evidence: string[] }>>;
candidates: Record<string, Array<{ key: string; url: string; title?: string; evidence: string[] }>>;
};
} | null;
updated_at: string;
};
type InputRow = { key: string; post_id: string | null; value: Record<string, unknown>; input_by: string | null; created_at: string };
const STATUS_LABEL: Record<string, string> = {
queued: '대기', running: '빌드 중', gate_failed: '발행 게이트 실패', failed: '실패', preview: '미리보기 준비됨', approved: '승인 완료', published: '색인 허용 발행',
};
// v2 §6 입력 항목. key 는 supporter_inputs.key 와 apply_inputs.mjs 가 같은 값을 쓴다.
const FIELDS: Array<{ key: string; title: string; hint: string; inputs: Array<{ name: string; label: string; type?: 'text' | 'textarea' | 'date' }> }> = [
{ key: 'author', title: '1. 작성자 실명·직함·공개 프로필', hint: '글 상단 "작성"과 Person 스키마에 들어갑니다.', inputs: [{ name: 'name', label: '이름' }, { name: 'role', label: '직함 (예: 편집 책임)' }, { name: 'sameAs', label: '공개 프로필 URL (인터뷰·기사, 쉼표 구분)' }, { name: 'email', label: '정정 요청 이메일' }] },
{ key: 'editor', title: '편집 책임자·정정 경로', hint: '정정 기록 페이지와 푸터에 표시됩니다.', inputs: [{ name: 'name', label: '편집 책임자 이름' }, { name: 'email', label: '정정 요청 이메일' }] },
{ key: 'sponsorship', title: '3. 지원 관계 문구', hint: '모든 글 첫 부분에 그대로 실립니다. 예: 이 글은 OO의원의 지원(광고비)을 받아 OO 서포터즈가 작성했습니다.', inputs: [{ name: 'notice', label: '고지 문장', type: 'textarea' }] },
{ key: 'quote_items', title: '4. 견적 기본 포함 항목과 결제 방식', hint: '금액은 받지 않습니다. 항목과 방식만 적어 주세요.', inputs: [{ name: 'answer', label: '내용', type: 'textarea' }] },
{ key: 'recovery', title: '5. 회복 일정표의 병원 확정값', hint: '출근 기준, 걷기 시작 시점 등 공개 자료에 없는 값.', inputs: [{ name: 'answer', label: '내용', type: 'textarea' }] },
{ key: 'diet_guide', title: '5b. 수술 전후 식이 가이드', hint: '의료관광 연결의 음식점 추천에 그대로 실립니다. 개별 식당의 적합 여부는 저희가 판정하지 않고, 여기 적힌 분류만 표시합니다.', inputs: [{ name: 'recommended', label: '권장 음식 분류 (회복 단계별)', type: 'textarea' }, { name: 'avoid', label: '피해야 할 음식 분류', type: 'textarea' }, { name: 'stageDays', label: '각 단계가 수술 후 며칠인지' }] },
{ key: 'revision_policy', title: '6. 재수술 정책', hint: '다른 병원 수술의 상담 가능 여부, 부위별 대기 기간.', inputs: [{ name: 'answer', label: '내용', type: 'textarea' }] },
{ key: 'discrepancy', title: '7. 페이지 간 표기 불일치의 정답', hint: '예: 코 실밥 제거일이 코성형 페이지 7일, 애프터케어 3일로 다릅니다.', inputs: [{ name: 'answer', label: '내용', type: 'textarea' }] },
{ key: 'specialty_doctors', title: '8. 진료 분야별 담당 원장', hint: '아래 의료진 목록의 ID 를 적습니다. 글별 검토자 후보가 됩니다.', inputs: [{ name: 'eye', label: '눈' }, { name: 'nose', label: '코' }, { name: 'contour', label: '윤곽' }, { name: 'lifting', label: '리프팅' }, { name: 'breast', label: '가슴' }, { name: 'anesthesia', label: '마취' }] },
{ key: 'domain', title: '9. 정식 도메인·색인 허용 시점·홈페이지 링크 위치', hint: '도메인이 정해지기 전에는 supporters-<id>.vercel.app 미리보기(noindex)로 둡니다.', inputs: [{ name: 'domain', label: '도메인' }, { name: 'indexableFrom', label: '색인 허용 시점', type: 'date' }, { name: 'homepageLinkPosition', label: '홈페이지에서의 링크 위치' }] },
];
export function ClinicInputsPanel({ clinicId }: { clinicId: string }) {
const [build, setBuild] = useState<Build | null>(null);
const [rows, setRows] = useState<InputRow[]>([]);
const [inputBy, setInputBy] = useState('');
const [form, setForm] = useState<Record<string, Record<string, string>>>({});
const [postForm, setPostForm] = useState<Record<string, { reviewer: string; reviewedAt: string; approvedBy: string; approvedAt: string }>>({});
const [busy, setBusy] = useState<string | null>(null);
const [msg, setMsg] = useState<string | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
useEffect(() => {
let alive = true;
(async () => {
const [b, i] = await Promise.all([
supabase.from('supporter_builds').select('id,status,phase,preview_url,posts,report,updated_at').eq('clinic_id', clinicId).order('created_at', { ascending: false }).limit(1),
supabase.from('supporter_inputs').select('key,post_id,value,input_by,created_at').eq('clinic_id', clinicId).order('created_at', { ascending: true }),
]);
if (!alive) return;
if (b.error || i.error) { setLoadError((b.error ?? i.error)?.message ?? '불러오기 실패'); return; }
setBuild((b.data?.[0] as Build | undefined) ?? null);
setRows((i.data as InputRow[]) ?? []);
})();
return () => { alive = false; };
}, [clinicId]);
// 최신 행이 유효값
const latest = useMemo(() => {
const m = new Map<string, InputRow>();
for (const r of rows) m.set(`${r.key}|${r.post_id ?? ''}`, r);
return m;
}, [rows]);
const current = (key: string, postId = '') => latest.get(`${key}|${postId}`);
async function save(key: string, value: Record<string, unknown>, postId?: string) {
setBusy(`${key}|${postId ?? ''}`); setMsg(null);
const { error } = await supabase.from('supporter_inputs').insert({ clinic_id: clinicId, key, post_id: postId ?? null, value, input_by: inputBy || null });
setBusy(null);
if (error) { setMsg(`저장 실패: ${error.message}`); return; }
setRows((prev) => [...prev, { key, post_id: postId ?? null, value, input_by: inputBy || null, created_at: new Date().toISOString() }]);
setMsg('저장했습니다. 다음 빌드에서 반영됩니다.');
}
const physicians = build?.report?.physicians ?? [];
const posts = build?.posts ?? [];
return (
<section className="bg-[#F4F6FB] px-6 py-20" id="clinic-inputs">
<div className="max-w-7xl mx-auto">
<div className="text-sm font-bold tracking-[0.24em] text-accent mb-4">SUPPORTERS BUILD · 병원 확인 항목</div>
<h2 className="font-serif text-3xl md:text-[40px] font-bold leading-tight text-primary-900 mb-3 break-keep">서포터즈 사이트 미리보기와 병원이 확인해 주실 것</h2>
<p className="text-[17px] leading-[1.7] text-slate-500 mb-10 break-keep">
병원 홈페이지 공개 자료만으로 만든 초안입니다. 아래 항목은 자료에 없어 병원만 답할 수 있는 값이며, 입력 전까지는 사이트에 "확인 대기"로 표시됩니다. 의학 검토와 게시 승인은 병원이 확정합니다.
</p>
{/* 빌드 상태 */}
<div className="bg-white border border-slate-200 rounded-2xl p-6 md:p-8 shadow-[0_8px_32px_rgba(31,38,135,0.07)] mb-8">
{loadError && <p className="text-sm text-[#B84C5F]">불러오기 실패: {loadError}</p>}
{!loadError && !build && <p className="text-slate-500">아직 자동 빌드 기록이 없습니다. URL 신청이 접수되면 여기에 진행 상태가 표시됩니다.</p>}
{build && (
<div className="grid md:grid-cols-[1fr_auto] gap-6 items-start">
<div>
<p className="text-sm text-slate-500 mb-1">상태</p>
<p className="text-xl font-bold text-primary-900">{STATUS_LABEL[build.status] ?? build.status}{build.phase ? <span className="text-sm font-medium text-slate-500 ml-2">단계 {build.phase}</span> : null}</p>
<p className="text-sm text-slate-400 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>
)}
</div>
)}
</div>
<div className="grid lg:grid-cols-[minmax(0,1fr)_380px] gap-6 items-start">
{/* 9 항목 폼 */}
<div className="flex flex-col gap-5">
<label className="bg-white border border-slate-200 rounded-2xl p-5 flex flex-col gap-2">
<span className="text-sm font-semibold text-slate-600">입력자 (이름·직함)</span>
<input value={inputBy} onChange={(e) => setInputBy(e.target.value)} placeholder="예: 홍길동 원장 / 김담당 실장" className="border border-slate-200 rounded-lg px-3 py-2 text-base" />
</label>
{FIELDS.map((f) => {
const cur = current(f.key)?.value as Record<string, unknown> | undefined;
const draft = form[f.key] ?? {};
return (
<div key={f.key} className="bg-white border border-slate-200 rounded-2xl p-6 shadow-[0_8px_32px_rgba(31,38,135,0.05)]">
<div className="flex items-start justify-between gap-4 mb-1">
<h3 className="font-bold text-lg text-primary-900 break-keep">{f.title}</h3>
<span className={`shrink-0 text-xs font-bold px-2.5 py-1 rounded-full ${cur ? 'bg-[#E9F7EF] text-[#2E7D4F]' : 'bg-[#FFF4E5] text-[#B26A00]'}`}>{cur ? `입력됨 ${String(current(f.key)?.created_at ?? '').slice(0, 10)}` : '확인 대기'}</span>
</div>
<p className="text-sm text-slate-500 mb-4 break-keep">{f.hint}</p>
<div className="grid sm:grid-cols-2 gap-3">
{f.inputs.map((inp) => {
const value = draft[inp.name] ?? (cur ? String(Array.isArray(cur[inp.name]) ? (cur[inp.name] as string[]).join(', ') : cur[inp.name] ?? '') : '');
const set = (v: string) => setForm((p) => ({ ...p, [f.key]: { ...(p[f.key] ?? {}), [inp.name]: v } }));
return (
<label key={inp.name} className={`flex flex-col gap-1 ${inp.type === 'textarea' ? 'sm:col-span-2' : ''}`}>
<span className="text-sm text-slate-600">{inp.label}</span>
{inp.type === 'textarea'
? <textarea value={value} onChange={(e) => set(e.target.value)} rows={3} className="border border-slate-200 rounded-lg px-3 py-2 text-base" />
: <input type={inp.type ?? 'text'} value={value} onChange={(e) => set(e.target.value)} className="border border-slate-200 rounded-lg px-3 py-2 text-base" />}
</label>
);
})}
</div>
<div className="mt-4 flex items-center gap-3">
<button
type="button"
disabled={busy === `${f.key}|`}
onClick={() => {
const value: Record<string, unknown> = {};
for (const inp of f.inputs) { const v = (draft[inp.name] ?? (cur ? String(Array.isArray(cur[inp.name]) ? (cur[inp.name] as string[]).join(', ') : cur[inp.name] ?? '') : '')).trim(); if (v) value[inp.name] = inp.name === 'sameAs' ? v.split(',').map((s) => s.trim()).filter(Boolean) : v; }
if (!Object.keys(value).length) { setMsg('입력값이 없습니다.'); return; }
void save(f.key, value);
}}
className="rounded-full bg-accent text-white font-semibold px-5 py-2.5 text-base disabled:opacity-50"
>
{busy === `${f.key}|` ? '저장 중' : '저장'}
</button>
</div>
</div>
);
})}
</div>
{/* 글별 검토·승인 */}
<div className="flex flex-col gap-5">
{build?.report?.channels && (
<div className="bg-white border border-slate-200 rounded-2xl p-6 shadow-[0_8px_32px_rgba(31,38,135,0.05)]">
<h3 className="font-bold text-lg text-primary-900 mb-1">발견한 채널</h3>
<p className="text-sm text-slate-500 mb-3 break-keep">홈페이지·웹검색·레지스트리에서 찾아 병원 것인지 확인한 채널입니다. "확인 필요"는 병원이 맞는지 알려 주셔야 사이트에 연결합니다.</p>
<ul className="text-sm flex flex-col gap-2">
{(['youtube', 'instagram', 'facebook', 'naverBlog', 'tiktok', 'kakao', 'gangnamunni', 'naverPlace', 'babitalk'] as const).map((p) => {
const ok = build.report?.channels?.confirmed[p] ?? [];
const cand = build.report?.channels?.candidates[p] ?? [];
const label: Record<string, string> = { youtube: '유튜브', instagram: '인스타그램', facebook: '페이스북', naverBlog: '네이버 블로그', tiktok: '틱톡', kakao: '카카오톡 채널', gangnamunni: '강남언니', naverPlace: '네이버 플레이스', babitalk: '바비톡' };
if (!ok.length && !cand.length) return <li key={p} className="text-slate-400">{label[p]} · 찾지 못함</li>;
return (
<li key={p}>
<span className="font-semibold text-slate-700">{label[p]}</span>
{ok.map((c) => <span key={c.url} className="ml-2 inline-flex items-center gap-1"><a href={c.url} target="_blank" rel="noreferrer" className="text-accent">{c.title || c.key}</a><span className="text-[11px] font-bold px-1.5 py-0.5 rounded-full bg-[#E9F7EF] text-[#2E7D4F]">확인됨</span></span>)}
{cand.map((c) => <span key={c.url} className="ml-2 inline-flex items-center gap-1"><a href={c.url} target="_blank" rel="noreferrer" className="text-slate-500">{c.title || c.key}</a><span className="text-[11px] font-bold px-1.5 py-0.5 rounded-full bg-[#FFF4E5] text-[#B26A00]">확인 필요</span></span>)}
</li>
);
})}
</ul>
</div>
)}
<div className="bg-white border border-slate-200 rounded-2xl p-6 shadow-[0_8px_32px_rgba(31,38,135,0.05)]">
<h3 className="font-bold text-lg text-primary-900 mb-1">의료진 ID</h3>
<p className="text-sm text-slate-500 mb-3 break-keep">담당 원장·검토자 입력에 쓰는 ID 입니다. 홈페이지 의료진 페이지에서 읽었습니다.</p>
{physicians.length ? (
<ul className="text-sm text-slate-700 flex flex-col gap-1">{physicians.map((p) => <li key={p.id}><span className="font-mono text-accent">{p.id}</span> · {p.name} {p.title}</li>)}</ul>
) : <p className="text-sm text-slate-400">빌드 후 표시됩니다.</p>}
</div>
<div className="bg-white border border-slate-200 rounded-2xl p-6 shadow-[0_8px_32px_rgba(31,38,135,0.05)]">
<h3 className="font-bold text-lg text-primary-900 mb-1">2. 글별 의학 검토 · 게시 승인</h3>
<p className="text-sm text-slate-500 mb-4 break-keep">검토가 끝난 글만 승인할 수 있고, 승인된 글만 색인 허용 빌드에 실립니다. 승인자는 의료기관 개설자(대표원장) 이름과 직함으로 적습니다.</p>
{posts.length === 0 && <p className="text-sm text-slate-400">빌드 후 글 목록이 표시됩니다.</p>}
<div className="flex flex-col gap-4">
{posts.map((p) => {
const rv = current('post_review', p.id)?.value as { reviewer?: string; reviewedAt?: string } | undefined;
const ap = current('post_approval', p.id)?.value as { approvedBy?: string; approvedAt?: string } | undefined;
const d = postForm[p.id] ?? { reviewer: rv?.reviewer ?? '', reviewedAt: rv?.reviewedAt ?? '', approvedBy: ap?.approvedBy ?? '', approvedAt: ap?.approvedAt ?? '' };
const setD = (patch: Partial<typeof d>) => setPostForm((prev) => ({ ...prev, [p.id]: { ...d, ...patch } }));
return (
<div key={p.id} className="border border-slate-100 rounded-xl p-4">
<p className="font-semibold text-primary-900 text-[15px] break-keep">{p.title}</p>
<p className="text-xs text-slate-400 mt-0.5 mb-3">{p.category} · {p.id}{p.pending?.length ? ` · 병원 확인 대기 ${p.pending.length}건` : ''} · {rv ? `검토 ${rv.reviewedAt}` : '검토 대기'} · {ap ? `승인 ${ap.approvedAt}` : '승인 대기'}</p>
<div className="grid grid-cols-2 gap-2 text-sm">
<input value={d.reviewer} onChange={(e) => setD({ reviewer: e.target.value })} placeholder="검토 원장 ID" className="border border-slate-200 rounded-lg px-2.5 py-1.5" />
<input type="date" value={d.reviewedAt} onChange={(e) => setD({ reviewedAt: e.target.value })} className="border border-slate-200 rounded-lg px-2.5 py-1.5" />
<button type="button" disabled={!d.reviewer || !d.reviewedAt || busy === `post_review|${p.id}`} onClick={() => void save('post_review', { reviewer: d.reviewer, reviewedAt: d.reviewedAt }, p.id)} className="col-span-2 rounded-full border border-accent text-accent font-semibold py-1.5 disabled:opacity-40">검토 완료 기록</button>
<input value={d.approvedBy} onChange={(e) => setD({ approvedBy: e.target.value })} placeholder="승인자 (개설자 이름·직함)" className="border border-slate-200 rounded-lg px-2.5 py-1.5" />
<input type="date" value={d.approvedAt} onChange={(e) => setD({ approvedAt: e.target.value })} className="border border-slate-200 rounded-lg px-2.5 py-1.5" />
<button type="button" disabled={!rv || !d.approvedBy || !d.approvedAt || busy === `post_approval|${p.id}`} onClick={() => void save('post_approval', { approvedBy: d.approvedBy, approvedAt: d.approvedAt }, p.id)} className="col-span-2 rounded-full bg-primary-900 text-white font-semibold py-1.5 disabled:opacity-40">게시 승인 기록{!rv ? ' (검토 먼저)' : ''}</button>
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
{msg && <p className="mt-6 text-sm font-semibold text-primary-900">{msg}</p>}
</div>
</section>
);
}