/** * 병원 확인 항목 패널 (서포터즈 자동 빌드 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; totalCostUsd?: number; channels?: { inventory: Array<{ platform: string; state: string; confirmed: number; candidates: number; tried: string }>; confirmed: Record>; candidates: Record>; }; } | null; updated_at: string; }; type InputRow = { key: string; post_id: string | null; value: Record; input_by: string | null; created_at: string }; const STATUS_LABEL: Record = { 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-.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(null); const [rows, setRows] = useState([]); const [inputBy, setInputBy] = useState(''); const [form, setForm] = useState>>({}); const [postForm, setPostForm] = useState>({}); const [busy, setBusy] = useState(null); const [msg, setMsg] = useState(null); const [loadError, setLoadError] = useState(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(); 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, 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 (
SUPPORTERS BUILD · 병원 확인 항목

서포터즈 사이트 미리보기와 병원이 확인해 주실 것

병원 홈페이지 공개 자료만으로 만든 초안입니다. 아래 항목은 자료에 없어 병원만 답할 수 있는 값이며, 입력 전까지는 사이트에 "확인 대기"로 표시됩니다. 의학 검토와 게시 승인은 병원이 확정합니다.

{/* 빌드 상태 */}
{loadError &&

불러오기 실패: {loadError}

} {!loadError && !build &&

아직 자동 빌드 기록이 없습니다. URL 신청이 접수되면 여기에 진행 상태가 표시됩니다.

} {build && (

상태

{STATUS_LABEL[build.status] ?? build.status}{build.phase ? 단계 {build.phase} : null}

갱신 {build.updated_at.slice(0, 16).replace('T', ' ')} · 글 {posts.length}편{typeof build.report?.totalCostUsd === 'number' ? ` · 생성 비용 $${build.report.totalCostUsd}` : ''}

{build.preview_url && ( 미리보기 열기 (noindex) )}
)}
{/* 9 항목 폼 */}
{FIELDS.map((f) => { const cur = current(f.key)?.value as Record | undefined; const draft = form[f.key] ?? {}; return (

{f.title}

{cur ? `입력됨 ${String(current(f.key)?.created_at ?? '').slice(0, 10)}` : '확인 대기'}

{f.hint}

{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 (