/** * 이미지 확인 패널 — 서포터즈 사이트에 쓸 사진을 병원이 한 장씩 고른다. * - 읽기: supporter_builds(최신 1건).report.images · supporter_inputs(key='image_review') * - 쓰기: supporter_inputs insert (anon RLS). 저장하면 워커가 다음 빌드에서 제외 목록을 반영한다. * - 사진 파일은 미리보기 사이트(preview_url)에서 불러온다. 배포 전 빌드는 볼 수 없다. * - 대상은 병원 담당자다. 제작 과정이나 내부 규칙은 쓰지 않고, 무엇을 판단하면 되는지만 둔다. */ import { useEffect, useMemo, useState } from 'react'; import { motion, AnimatePresence } from 'motion/react'; import { supabase } from '../../lib/supabase'; import { CheckFilled, CrossFilled, EyeFilled, WarningFilled, LinkExternalFilled } from '../icons/FilledIcons'; type Role = 'hero' | 'used' | 'stored'; type ImageItem = { id: string; src: string; alt: string; category: 'clinic' | 'procedure' | 'equipment' | 'doctors'; width: number; height: number; sourceUrl: string; pages: string[]; role: Role; }; type Build = { id: string; status: string; preview_url: string | null; updated_at: string; report: { images?: { capturedAt: string; total: number; used: number; items: ImageItem[] } } | null; }; type Decision = 'use' | 'exclude'; const ROLE_LABEL: Record = { hero: '첫 화면 대표 사진', used: '사이트에 실립니다', stored: '아직 쓰이지 않습니다', }; const CATEGORY_LABEL: Record = { clinic: '병원 공간', procedure: '시술 설명', equipment: '장비', doctors: '의료진', }; /** 병원이 판단할 때 떠올릴 것. 법 조항이 아니라 눈으로 확인할 수 있는 말로 둔다. */ const CHECKPOINTS = [ '환자분의 얼굴이나 몸이 알아볼 수 있게 나오나요', '수술 전과 후를 나란히 놓고 비교한 사진인가요', '주사·절개처럼 시술하는 장면이 담겼나요', '효과나 성능을 숫자·화살표로 내세운 그림인가요', '상장·수상·인증 마크가 들어 있나요', ]; export function ImageReviewPanel({ clinicId }: { clinicId: string }) { const [build, setBuild] = useState(null); const [loadError, setLoadError] = useState(null); const [loading, setLoading] = useState(true); const [decisions, setDecisions] = useState>({}); const [cursor, setCursor] = useState(0); const [inputBy, setInputBy] = useState(''); const [saving, setSaving] = useState(false); const [saved, setSaved] = useState(null); const [saveError, setSaveError] = useState(null); useEffect(() => { let alive = true; void (async () => { const [b, inputs] = await Promise.all([ supabase.from('supporter_builds').select('id,status,preview_url,report,updated_at').eq('clinic_id', clinicId).order('created_at', { ascending: false }).limit(1), supabase.from('supporter_inputs').select('value,input_by,created_at').eq('clinic_id', clinicId).eq('key', 'image_review').order('created_at', { ascending: true }), ]); if (!alive) return; if (b.error) setLoadError(b.error.message); setBuild((b.data?.[0] as Build) ?? null); // 같은 key 는 최신 행이 유효값이다. const last = inputs.data?.[inputs.data.length - 1]?.value as { decisions?: Record } | undefined; if (last?.decisions) setDecisions(last.decisions); setLoading(false); })(); return () => { alive = false; }; }, [clinicId]); const items = useMemo(() => { const list = build?.report?.images?.items ?? []; // 첫 화면 대표 사진과 실제로 실리는 사진을 먼저 본다. const rank: Record = { hero: 0, used: 1, stored: 2 }; return [...list].sort((a, b) => rank[a.role] - rank[b.role]); }, [build]); const decidedCount = items.filter((i) => decisions[i.id]).length; const excluded = items.filter((i) => decisions[i.id] === 'exclude'); const done = items.length > 0 && decidedCount === items.length; const current = items[cursor]; const previewUrl = build?.preview_url ?? ''; function decide(id: string, d: Decision) { setDecisions((p) => ({ ...p, [id]: d })); setSaved(null); setCursor((c) => Math.min(c + 1, items.length - 1)); } async function save() { setSaving(true); setSaveError(null); const { error } = await supabase.from('supporter_inputs').insert({ clinic_id: clinicId, key: 'image_review', value: { decisions, excludeImages: excluded.map((i) => i.id), total: items.length, decidedAt: new Date().toISOString() }, input_by: inputBy.trim() || null, }); setSaving(false); if (error) { setSaveError(error.message); return; } setSaved(new Date().toISOString().slice(0, 16).replace('T', ' ')); } if (loading) { return (

사진을 불러오는 중입니다.

); } if (!items.length || !previewUrl) { return (

아직 확인하실 사진이 없습니다

{loadError ? `자료를 불러오지 못했습니다. ${loadError}` : !previewUrl ? '사이트 초안이 준비되면 이 화면에서 사진을 한 장씩 확인하실 수 있습니다.' : '홈페이지에서 모은 사진이 아직 없습니다.'}

); } return (
{/* 진행 */}

Photo Check

홈페이지에서 모은 사진 {items.length}장입니다.{' '} 서포터즈 사이트에 실어도 괜찮은 사진인지 한 장씩 봐 주세요.

{build?.report?.images?.used ? (

이 중 {build.report.images.used}장이 지금 사이트에 실립니다.

) : null}

확인

{decidedCount} / {items.length}

{/* 한 장씩 보기 */}
{current.alt}
{CATEGORY_LABEL[current.category]}

이 사진을 사이트에 써도 될까요?

{current.alt || '사진에 붙은 설명이 없습니다.'}

출처 {current.pages[0] ?? '홈페이지'}{current.pages.length > 1 ? ` 외 ${current.pages.length - 1}곳` : ''} {' · '} 원본 열기

{cursor + 1} / {items.length}
{/* 전체 훑기 */}

다른 사진으로 이동

{items.length - decidedCount > 0 ? <>아직 {items.length - decidedCount}장이 남았습니다 : '모두 확인하셨습니다'}

{items.map((i, idx) => { const d = decisions[i.id]; const isCur = idx === cursor; return ( ); })}
아직 안 정함 사용 빼기
{/* 판단 기준 + 제출 */}

이런 사진이면 빼 주세요

    {CHECKPOINTS.map((c) => (
  • {c}
  • ))}

판단이 서지 않으면 빼는 쪽으로 봐 주세요.{' '}나중에 다시 넣을 수 있습니다.

확인 결과

사용
{decidedCount - excluded.length}장
빼기
{excluded.length}장
남음
{items.length - decidedCount}장
{saved && (

{saved} 에 보냈습니다. 빼기로 하신 사진은 다음 초안부터 빠집니다.

)} {saveError &&

보내지 못했습니다. {saveError}

} {!done && decidedCount > 0 && !saved && (

지금 보내고 나중에 이어서 보셔도 됩니다.

)}
{previewUrl && ( 사이트 초안 열기 )}
); } function RoleBadge({ role }: { role: Role }) { const style: Record = { hero: 'bg-[#EFF0FF] text-[#3A3F7C] border-[#C5CBF5]', used: 'bg-[#F3F0FF] text-[#4A3A7C] border-[#D5CDF5]', stored: 'bg-slate-100 text-slate-500 border-slate-200', }; return {ROLE_LABEL[role]}; }