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>
This commit is contained in:
parent
b2caef9d02
commit
5d3ce40f77
206
scripts/fetch_medical_tourism.py
Normal file
206
scripts/fetch_medical_tourism.py
Normal file
@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env python3
|
||||
"""의료관광 연결 데이터 수집 (한국관광공사 TourAPI 다국어).
|
||||
|
||||
병원 좌표를 중심으로 숙박·음식점·관광지·문화시설·쇼핑을 모으고, 진행 중인 축제를
|
||||
거리와 함께 붙여 프론트가 쓸 JSON 하나로 낸다.
|
||||
|
||||
왜 사전 수집인가
|
||||
영문 서비스는 강남 일대 커버리지가 얇고(5km 음식점 18건, 국문은 338건) 관광지의
|
||||
79%가 의료기관이라 필터링이 많이 필요하다. 그 처리를 수집 시점에 끝내고 프론트는
|
||||
정제된 결과만 그린다. 미팅 데모 중 외부 API 상태에 화면이 종속되지도 않는다.
|
||||
|
||||
무엇을 만들지 않는가
|
||||
없는 값을 채우지 않는다. 평점·리뷰·영업시간은 TourAPI 가 주지 않으므로 비워 두고,
|
||||
Google Places 승인 후 별도 단계에서 붙인다. 식이 적합성은 병원 입력이며 여기서
|
||||
판정하지 않는다.
|
||||
|
||||
python3 scripts/fetch_medical_tourism.py
|
||||
python3 scripts/fetch_medical_tourism.py --radius 5000 --out src/data/medicalTourism.json
|
||||
"""
|
||||
import argparse, io, json, math, os, ssl, sys, time, urllib.parse, urllib.request
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# 영문 관광지(76)의 대부분은 관광지가 아니라 의료관광 등록업소다. 반경 5km 100건 중 92건이
|
||||
# 분류코드 A02020500 = "Medical Tourism Sites"(categoryCode2 로 확인)이며 병원과 유치 에이전시가
|
||||
# 여기 들어 있다. 제목 키워드로는 "하이안과", "닥파인더코리아" 같은 것을 놓치므로 코드로 거른다.
|
||||
CAT_MEDICAL_TOURISM = "A02020500"
|
||||
# 스파·찜질방은 수술 후 회복 여정에 맞으므로 관광이 아니라 별도 범주로 뺀다.
|
||||
CAT_WELLNESS = ("A02020300", "A02020400") # Hot Springs & Spa, Jjimjilbang
|
||||
|
||||
# 다국어 ContentTypeId. 국문과 코드가 다르다.
|
||||
# 반경은 목적에 따라 다르다. 회복기에 매일 가는 곳(숙박·식사·스파)은 가깝게, 관광은 넓게 본다.
|
||||
TYPES = {
|
||||
"76": ("attraction", "10000"),
|
||||
"78": ("culture", "10000"),
|
||||
"79": ("shopping", "10000"),
|
||||
"80": ("stay", "5000"),
|
||||
"82": ("restaurant", "5000"),
|
||||
}
|
||||
|
||||
|
||||
def ssl_ctx():
|
||||
try:
|
||||
import certifi; return ssl.create_default_context(cafile=certifi.where())
|
||||
except Exception:
|
||||
return ssl.create_default_context()
|
||||
|
||||
|
||||
def load_env(path):
|
||||
d = {}
|
||||
for line in io.open(path, encoding="utf-8"):
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
k, v = line.split("=", 1); d[k.strip()] = v.strip()
|
||||
return d
|
||||
|
||||
|
||||
def haversine_km(lat1, lng1, lat2, lng2):
|
||||
r = 6371.0
|
||||
p1, p2 = math.radians(lat1), math.radians(lat2)
|
||||
dp, dl = math.radians(lat2 - lat1), math.radians(lng2 - lng1)
|
||||
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
|
||||
return 2 * r * math.asin(math.sqrt(a))
|
||||
|
||||
|
||||
def call(env, op, extra, rows=100, retries=3, page=1):
|
||||
base, svc, key = env["TOUR_API_BASE"], env["TOUR_API_SERVICE"], env["TOUR_API_KEY"]
|
||||
p = {"MobileOS": "ETC", "MobileApp": "INFINITH", "_type": "json",
|
||||
"numOfRows": str(rows), "pageNo": str(page), **extra}
|
||||
url = f"{base}/{svc}/{op}?serviceKey={key}&" + urllib.parse.urlencode(p)
|
||||
for i in range(retries):
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=30, context=ssl_ctx()) as r:
|
||||
body = r.read().decode("utf-8", "replace")
|
||||
if body.lstrip().startswith("<"):
|
||||
raise RuntimeError(f"XML 응답(인증·한도 확인): {body[:160]}")
|
||||
j = json.loads(body)["response"]
|
||||
if j["header"].get("resultCode") != "0000":
|
||||
raise RuntimeError(f"{j['header'].get('resultCode')} {j['header'].get('resultMsg')}")
|
||||
b = j.get("body", {}); items = b.get("items")
|
||||
return b.get("totalCount", 0), ([] if not items or items == "" else items["item"])
|
||||
except Exception as e:
|
||||
if i == retries - 1: raise
|
||||
time.sleep(1.5 * (i + 1))
|
||||
|
||||
|
||||
def call_all(env, op, extra, max_pages=6, rows=100):
|
||||
"""전 페이지를 모은다. 관광지는 거리순 상위 100건이 거의 다 의료관광업소라 한 페이지로는 부족하다."""
|
||||
got, total = [], None
|
||||
for page in range(1, max_pages + 1):
|
||||
total, items = call(env, op, extra, rows=rows, page=page)
|
||||
got += items
|
||||
if not items or len(got) >= (total or 0): break
|
||||
time.sleep(0.2)
|
||||
return total, got
|
||||
|
||||
|
||||
def bucket(it):
|
||||
"""분류코드로 범주를 정한다. None 이면 버린다."""
|
||||
cat3 = (it.get("cat3") or "").strip()
|
||||
if cat3 == CAT_MEDICAL_TOURISM: return None
|
||||
if cat3 in CAT_WELLNESS: return "wellness"
|
||||
return "keep"
|
||||
|
||||
|
||||
def clean(it, lat, lng):
|
||||
"""TourAPI 원본에서 화면이 쓸 필드만 남긴다. mapx=경도, mapy=위도 (이름과 반대라 자주 틀린다)."""
|
||||
try:
|
||||
ilng, ilat = float(it.get("mapx") or 0), float(it.get("mapy") or 0)
|
||||
except ValueError:
|
||||
ilng = ilat = 0.0
|
||||
dist = it.get("dist")
|
||||
return {
|
||||
"id": it.get("contentid"),
|
||||
"title": (it.get("title") or "").strip(),
|
||||
"address": (it.get("addr1") or "").strip(),
|
||||
# TourAPI 는 이미지 URL 을 http 로 준다. https 사이트에서 혼합 콘텐츠로 차단되므로 올린다.
|
||||
# tong.visitkorea.or.kr 은 https 로도 같은 파일을 준다(실측 확인).
|
||||
"image": (lambda u: u.replace("http://", "https://", 1) if u else None)(
|
||||
it.get("firstimage") or it.get("firstimage2") or None),
|
||||
"tel": (it.get("tel") or "").strip() or None,
|
||||
"lat": ilat or None, "lng": ilng or None,
|
||||
"distanceM": round(float(dist)) if dist else (
|
||||
round(haversine_km(lat, lng, ilat, ilng) * 1000) if ilat and ilng else None),
|
||||
# TourAPI 가 주지 않는 값. Google Places 승인 후 채운다. 지금은 비워 둔다.
|
||||
"rating": None, "reviewCount": None, "openingHours": None,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--festival-km", type=float, default=60.0, help="이 거리 안의 축제만 담는다")
|
||||
ap.add_argument("--per-type", type=int, default=24, help="타입별 최대 건수")
|
||||
ap.add_argument("--out", default="src/data/medicalTourism.json")
|
||||
a = ap.parse_args()
|
||||
|
||||
env = load_env(os.path.join(ROOT, ".env"))
|
||||
for k in ("TOUR_API_KEY", "TOUR_API_BASE", "TOUR_API_SERVICE", "TOUR_API_ORIGIN_LAT", "TOUR_API_ORIGIN_LNG"):
|
||||
if not env.get(k): sys.exit(f".env 에 {k} 가 비어 있습니다")
|
||||
lat, lng = float(env["TOUR_API_ORIGIN_LAT"]), float(env["TOUR_API_ORIGIN_LNG"])
|
||||
|
||||
out = {"places": {}, "festivals": [], "meta": {}}
|
||||
filtered_log = {}
|
||||
|
||||
wellness = []
|
||||
for ct, (name, radius) in TYPES.items():
|
||||
total, rows = call_all(env, "locationBasedList2",
|
||||
{"mapX": str(lng), "mapY": str(lat), "radius": radius,
|
||||
"contentTypeId": ct, "arrange": "E"})
|
||||
kept, dropped = [], []
|
||||
for it in rows:
|
||||
b = bucket(it)
|
||||
if b is None:
|
||||
dropped.append((it.get("title") or "").strip()); continue
|
||||
(wellness if b == "wellness" else kept).append(clean(it, lat, lng))
|
||||
kept.sort(key=lambda x: x["distanceM"] if x["distanceM"] is not None else 10 ** 9)
|
||||
out["places"][name] = kept[:a.per_type]
|
||||
filtered_log[name] = {"apiTotal": total, "fetched": len(rows), "radiusM": int(radius),
|
||||
"droppedMedicalTourism": len(dropped), "kept": len(kept),
|
||||
"shown": len(out["places"][name]), "droppedSample": dropped[:5]}
|
||||
print(f" {name:<11} r={radius:>5}m · API {total:>4} · 받음 {len(rows):>3} · 의료관광업소 제외 {len(dropped):>3} · 담음 {len(out['places'][name])}")
|
||||
wellness.sort(key=lambda x: x["distanceM"] if x["distanceM"] is not None else 10 ** 9)
|
||||
out["places"]["wellness"] = wellness[:a.per_type]
|
||||
filtered_log["wellness"] = {"note": "관광지(76)에서 스파·찜질방 분류만 분리", "shown": len(out["places"]["wellness"])}
|
||||
print(f" {'wellness':<11} 스파·찜질방 분리 · 담음 {len(out['places']['wellness'])}")
|
||||
|
||||
# 영문 축제는 areacode 필드가 비어 있어 지역 필터를 쓸 수 없다. 좌표로 거리를 계산해 거른다.
|
||||
today = time.strftime("%Y%m%d")
|
||||
total, rows = call(env, "searchFestival2", {"eventStartDate": today, "arrange": "A"}, rows=300)
|
||||
near, nocoord = [], 0
|
||||
for it in rows:
|
||||
c = clean(it, lat, lng)
|
||||
if c["lat"] is None: nocoord += 1; continue
|
||||
km = haversine_km(lat, lng, c["lat"], c["lng"])
|
||||
if km > a.festival_km: continue
|
||||
c.update(startDate=it.get("eventstartdate"), endDate=it.get("eventenddate"),
|
||||
distanceKm=round(km, 1))
|
||||
near.append(c)
|
||||
near.sort(key=lambda x: x["startDate"] or "")
|
||||
out["festivals"] = near[:a.per_type]
|
||||
print(f" festival API {total:>4}건 · 받음 {len(rows):>3} · 좌표없음 {nocoord} · {a.festival_km:.0f}km 이내 {len(near)}")
|
||||
|
||||
out["meta"] = {
|
||||
"source": "한국관광공사 TourAPI " + env["TOUR_API_SERVICE"],
|
||||
"fetchedAt": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
"origin": {"lat": lat, "lng": lng, "label": "뷰성형외과의원 (봉은사로 107)",
|
||||
"coordSource": "Naver 지역검색 실측 2026-09-09"},
|
||||
"radiusByType": {n: int(r) for n, r in TYPES.values()}, "festivalRadiusKm": a.festival_km,
|
||||
"counts": filtered_log,
|
||||
# 화면이 한계를 그대로 표시할 수 있게 데이터에 적어 둔다. 없는 값을 채우지 않는다.
|
||||
"limits": [
|
||||
"영문 서비스는 강남 일대 숙박·음식점 커버리지가 얇다(반경 5km 숙박 4건·음식점 18건, 국문은 43건·338건).",
|
||||
"관광지 항목의 대부분(반경 5km 100건 중 92건)이 분류코드 A02020500 \"Medical Tourism Sites\" 였고 제외했다.",
|
||||
"평점·리뷰·영업시간은 TourAPI 가 제공하지 않는다. Google Places 승인 후 채운다.",
|
||||
"영문 축제는 areacode 필드가 비어 있어 좌표 거리로 걸렀다.",
|
||||
"수술 전후 식이 적합성은 병원이 입력한 가이드로만 표시한다. 이 파일에는 없다.",
|
||||
],
|
||||
}
|
||||
p = os.path.join(ROOT, a.out)
|
||||
os.makedirs(os.path.dirname(p), exist_ok=True)
|
||||
io.open(p, "w", encoding="utf-8").write(json.dumps(out, ensure_ascii=False, indent=2))
|
||||
print(f"\n→ {a.out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -40,6 +40,7 @@ const FIELDS: Array<{ key: string; title: string; hint: string; inputs: Array<{
|
||||
{ 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: '마취' }] },
|
||||
|
||||
449
src/components/discovery/MedicalTourismPanel.tsx
Normal file
449
src/components/discovery/MedicalTourismPanel.tsx
Normal file
@ -0,0 +1,449 @@
|
||||
/**
|
||||
* 의료관광 연결 패널 (/discovery/:id)
|
||||
*
|
||||
* 외국인 환자가 수술 전후에 쓸 주변 정보를 한 곳에 모은다.
|
||||
* 숙박 · 음식점 · 웰니스(스파) · 관광 · 쇼핑 · 축제, 그리고 이동 반경 기준 회복 동선.
|
||||
*
|
||||
* 데이터는 `scripts/fetch_medical_tourism.py` 가 한국관광공사 TourAPI 다국어(영문)에서
|
||||
* 미리 수집한 `src/data/medicalTourism.json` 이다. 사전 수집인 이유는 영문 커버리지가
|
||||
* 얇아 필터링이 많이 필요하고, 시연 중 외부 API 상태에 화면이 종속되지 않게 하기 위해서다.
|
||||
*
|
||||
* 만들지 않는 것
|
||||
* - 평점·리뷰·영업시간: TourAPI 미제공이므로 "미연동"으로 표시하고 비워 둔다.
|
||||
* - 수술 전후 식이 적합성: 병원이 supporter_inputs('diet_guide')로 입력한 값만 쓴다.
|
||||
* 입력 전에는 "병원 확인 대기"로 둔다. 개별 식당의 적합 여부를 추론하지 않는다.
|
||||
* - 회복 단계가 며칠째인지: 의학 판단이라 병원 입력('recovery')을 따른다.
|
||||
* 화면의 단계는 이동 반경으로만 정의한다.
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { SectionWrapper } from '../report/ui/SectionWrapper';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import data from '../../data/medicalTourism.json';
|
||||
import type {
|
||||
MedicalTourismData,
|
||||
PlaceCategory,
|
||||
RecoveryStage,
|
||||
TourPlace,
|
||||
} from '../../types/medicalTourism';
|
||||
import {
|
||||
BedFilled,
|
||||
ForkFilled,
|
||||
SpaFilled,
|
||||
MapPinFilled,
|
||||
TicketFilled,
|
||||
BagFilled,
|
||||
TheaterFilled,
|
||||
} from '../icons/FilledIcons';
|
||||
|
||||
const TOURISM = data as unknown as MedicalTourismData;
|
||||
|
||||
const CATEGORY_META: Record<
|
||||
PlaceCategory,
|
||||
{ label: string; en: string; Icon: typeof BedFilled }
|
||||
> = {
|
||||
stay: { label: '숙박', en: 'Stay', Icon: BedFilled },
|
||||
restaurant: { label: '음식점', en: 'Dining', Icon: ForkFilled },
|
||||
wellness: { label: '웰니스', en: 'Wellness', Icon: SpaFilled },
|
||||
attraction: { label: '관광', en: 'Attractions', Icon: MapPinFilled },
|
||||
culture: { label: '문화', en: 'Culture', Icon: TheaterFilled },
|
||||
shopping: { label: '쇼핑', en: 'Shopping', Icon: BagFilled },
|
||||
};
|
||||
|
||||
const CATEGORY_ORDER: PlaceCategory[] = [
|
||||
'stay',
|
||||
'restaurant',
|
||||
'wellness',
|
||||
'attraction',
|
||||
'culture',
|
||||
'shopping',
|
||||
];
|
||||
|
||||
/**
|
||||
* 회복 동선. 며칠째인지는 병원이 정한다(§ClinicInputsPanel 'recovery').
|
||||
* 여기서는 이동 반경만 정의하므로 의학적 판단을 담지 않는다.
|
||||
*/
|
||||
const STAGES: RecoveryStage[] = [
|
||||
{
|
||||
id: 'near',
|
||||
label: '병원 도보권',
|
||||
maxDistanceM: 1000,
|
||||
categories: ['stay', 'restaurant'],
|
||||
note: '내원이 잦은 기간에 머무는 범위입니다.',
|
||||
},
|
||||
{
|
||||
id: 'short',
|
||||
label: '근거리 이동',
|
||||
maxDistanceM: 3000,
|
||||
categories: ['restaurant', 'wellness'],
|
||||
note: '짧은 외출이 가능해지는 범위입니다.',
|
||||
},
|
||||
{
|
||||
id: 'city',
|
||||
label: '시내 이동',
|
||||
maxDistanceM: 10000,
|
||||
categories: ['attraction', 'culture', 'shopping'],
|
||||
note: '관광 일정을 넣을 수 있는 범위입니다.',
|
||||
},
|
||||
];
|
||||
|
||||
function fmtDistance(m: number | null) {
|
||||
if (m === null) return '거리 미상';
|
||||
return m < 1000 ? `${m}m` : `${(m / 1000).toFixed(1)}km`;
|
||||
}
|
||||
|
||||
function fmtDate(d: string | null) {
|
||||
if (!d || d.length !== 8) return '';
|
||||
return `${d.slice(0, 4)}.${d.slice(4, 6)}.${d.slice(6, 8)}`;
|
||||
}
|
||||
|
||||
export function MedicalTourismPanel({ clinicId }: { clinicId: string }) {
|
||||
const [tab, setTab] = useState<PlaceCategory | 'festival' | 'route'>('route');
|
||||
const [clinicInput, setClinicInput] = useState<{
|
||||
recovery?: string;
|
||||
diet?: Record<string, string>;
|
||||
}>({});
|
||||
|
||||
// 병원이 입력한 회복·식이 값을 읽는다. 같은 clinic_id 의 최신 행이 유효값이다.
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
(async () => {
|
||||
const { data: rows } = await supabase
|
||||
.from('supporter_inputs')
|
||||
.select('key,value,created_at')
|
||||
.eq('clinic_id', clinicId)
|
||||
.in('key', ['recovery', 'diet_guide'])
|
||||
.order('created_at', { ascending: true });
|
||||
if (!alive || !rows) return;
|
||||
const latest = new Map<string, Record<string, string>>();
|
||||
for (const r of rows) latest.set(r.key, r.value as Record<string, string>);
|
||||
setClinicInput({
|
||||
recovery: latest.get('recovery')?.answer,
|
||||
diet: latest.get('diet_guide'),
|
||||
});
|
||||
})();
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [clinicId]);
|
||||
|
||||
// 단계는 누적이 아니라 구간이다. 누적으로 하면 가까운 곳이 모든 단계에 중복으로 뜬다.
|
||||
const stageBuckets = useMemo(
|
||||
() =>
|
||||
STAGES.map((s, i) => {
|
||||
const minM = i === 0 ? 0 : STAGES[i - 1].maxDistanceM;
|
||||
return {
|
||||
stage: s,
|
||||
minM,
|
||||
items: s.categories
|
||||
.flatMap((c) =>
|
||||
(TOURISM.places[c] ?? []).map((p) => ({ ...p, category: c })),
|
||||
)
|
||||
.filter(
|
||||
(p) =>
|
||||
p.distanceM !== null &&
|
||||
p.distanceM > minM &&
|
||||
p.distanceM <= s.maxDistanceM,
|
||||
)
|
||||
.sort((a, b) => (a.distanceM ?? 0) - (b.distanceM ?? 0))
|
||||
.slice(0, 6),
|
||||
};
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const totalPlaces =
|
||||
CATEGORY_ORDER.reduce((n, c) => n + (TOURISM.places[c]?.length ?? 0), 0) +
|
||||
TOURISM.festivals.length;
|
||||
|
||||
return (
|
||||
<SectionWrapper
|
||||
id="medical-tourism"
|
||||
title="Medical Tourism Connect"
|
||||
subtitle={`외국인 환자의 수술 전후 동선을 병원 반경 기준으로 연결합니다. 한국관광공사 TourAPI 영문 데이터 ${totalPlaces}건`}
|
||||
dark
|
||||
>
|
||||
{/* 출처와 기준점 */}
|
||||
<div className="mb-8 rounded-2xl bg-white/10 backdrop-blur-sm border border-white/10 p-5">
|
||||
<div className="grid gap-4 md:grid-cols-3 text-sm">
|
||||
<div>
|
||||
<div className="text-xs font-medium text-purple-300 mb-1">기준 좌표</div>
|
||||
<div className="text-white/90">{TOURISM.meta.origin.label}</div>
|
||||
<div className="text-purple-200 text-xs mt-0.5">
|
||||
{TOURISM.meta.origin.lat}, {TOURISM.meta.origin.lng} · {TOURISM.meta.origin.coordSource}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-purple-300 mb-1">데이터 출처</div>
|
||||
<div className="text-white/90">{TOURISM.meta.source}</div>
|
||||
<div className="text-purple-200 text-xs mt-0.5">
|
||||
수집 {TOURISM.meta.fetchedAt.replace('T', ' ')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-purple-300 mb-1">수집 반경</div>
|
||||
<div className="text-white/90">
|
||||
숙박·식사 5km · 관광·문화·쇼핑 10km
|
||||
</div>
|
||||
<div className="text-purple-200 text-xs mt-0.5">
|
||||
축제 {TOURISM.meta.festivalRadiusKm}km 이내
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 탭 */}
|
||||
<div className="flex flex-wrap gap-2 mb-8" data-no-print>
|
||||
<TabButton active={tab === 'route'} onClick={() => setTab('route')}>
|
||||
회복 동선
|
||||
</TabButton>
|
||||
{CATEGORY_ORDER.map((c) => (
|
||||
<TabButton key={c} active={tab === c} onClick={() => setTab(c)}>
|
||||
{CATEGORY_META[c].label} {TOURISM.places[c]?.length ?? 0}
|
||||
</TabButton>
|
||||
))}
|
||||
<TabButton active={tab === 'festival'} onClick={() => setTab('festival')}>
|
||||
축제 {TOURISM.festivals.length}
|
||||
</TabButton>
|
||||
</div>
|
||||
|
||||
{tab === 'route' && (
|
||||
<div className="space-y-5">
|
||||
<ClinicGate
|
||||
title="회복 단계별 일수"
|
||||
filled={clinicInput.recovery}
|
||||
waiting="수술별로 각 단계가 며칠인지는 병원이 정합니다. 아래 '병원 확인 항목'의 회복 일정 칸에 입력하면 이 동선에 날짜가 붙습니다."
|
||||
/>
|
||||
{stageBuckets.map(({ stage, minM, items }, i) => (
|
||||
<motion.div
|
||||
key={stage.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
className="bg-white rounded-2xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6"
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-3 mb-1 flex-wrap">
|
||||
<h3 className="text-lg font-bold text-[#0A1128]">{stage.label}</h3>
|
||||
<span className="text-xs font-medium text-slate-500">
|
||||
병원 반경 {minM === 0 ? '' : `${fmtDistance(minM)} ~ `}
|
||||
{fmtDistance(stage.maxDistanceM)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 mb-4">{stage.note}</p>
|
||||
{items.length === 0 ? (
|
||||
<EmptyNote>이 반경에 담긴 영문 데이터가 없습니다.</EmptyNote>
|
||||
) : (
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((p) => (
|
||||
<PlaceCard key={`${p.category}-${p.id}`} place={p} category={p.category} compact />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab !== 'route' && tab !== 'festival' && (
|
||||
<>
|
||||
{tab === 'restaurant' && (
|
||||
<div className="mb-5">
|
||||
<ClinicGate
|
||||
title="수술 전후 식이 가이드"
|
||||
filled={clinicInput.diet?.recommended}
|
||||
waiting="어떤 음식이 회복에 맞는지는 의학 판단이라 병원이 정합니다. 병원이 권장·회피 분류를 입력하면 이 목록에 표시됩니다. 개별 식당의 적합 여부를 저희가 판정하지 않습니다."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<PlaceGrid items={TOURISM.places[tab] ?? []} category={tab} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'festival' && (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{TOURISM.festivals.map((f) => (
|
||||
<div
|
||||
key={f.id}
|
||||
className="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 overflow-hidden"
|
||||
>
|
||||
<div className="h-36 bg-slate-100 flex items-center justify-center overflow-hidden">
|
||||
{f.image ? (
|
||||
<img src={f.image} alt="" className="w-full h-36 object-cover" loading="lazy" />
|
||||
) : (
|
||||
<span className="text-xs text-slate-400">사진 없음</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<div className="flex items-center gap-1.5 mb-2 text-[#6C5CE7]">
|
||||
<TicketFilled size={16} />
|
||||
<span className="text-xs font-semibold">{f.distanceKm}km</span>
|
||||
</div>
|
||||
<h4 className="text-base font-bold text-[#0A1128] mb-1.5">{f.title}</h4>
|
||||
<p className="text-xs text-slate-500 mb-2">
|
||||
{fmtDate(f.startDate)} ~ {fmtDate(f.endDate)}
|
||||
</p>
|
||||
<p className="text-sm text-slate-600">{f.address}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 한계를 숨기지 않는다 */}
|
||||
<div className="mt-10 rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div className="text-xs font-semibold tracking-wider text-purple-300 mb-3">
|
||||
측정 가능 범위
|
||||
</div>
|
||||
<ul className="space-y-1.5">
|
||||
{TOURISM.meta.limits.map((l) => (
|
||||
<li key={l} className="text-sm text-white/80 flex gap-2">
|
||||
<span className="text-purple-300 shrink-0">·</span>
|
||||
<span>{l}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</SectionWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
/* ────────────────────────── 서브 컴포넌트 ────────────────────────── */
|
||||
|
||||
function TabButton({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
key?: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors ${
|
||||
active
|
||||
? 'bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white'
|
||||
: 'bg-white/10 text-white/80 hover:bg-white/20'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function PlaceGrid({ items, category }: { items: TourPlace[]; category: PlaceCategory }) {
|
||||
if (items.length === 0) return <EmptyNote>영문 데이터가 없습니다.</EmptyNote>;
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((p) => (
|
||||
<PlaceCard key={p.id} place={p} category={category} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlaceCard({
|
||||
place,
|
||||
category,
|
||||
compact = false,
|
||||
}: {
|
||||
key?: string;
|
||||
place: TourPlace;
|
||||
category: PlaceCategory;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { Icon, label } = CATEGORY_META[category];
|
||||
return (
|
||||
<div
|
||||
className={`bg-white rounded-2xl overflow-hidden transition-shadow h-full flex flex-col ${
|
||||
compact
|
||||
? 'border border-slate-100 shadow-sm hover:shadow-[3px_4px_12px_rgba(0,0,0,0.06)]'
|
||||
: 'shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:shadow-[4px_6px_16px_rgba(0,0,0,0.09)]'
|
||||
}`}
|
||||
>
|
||||
{!compact && (
|
||||
// 이미지가 없는 항목이 섞여 그리드가 어긋나지 않도록 자리를 항상 차지하게 둔다.
|
||||
<div className="h-36 bg-slate-100 flex items-center justify-center overflow-hidden">
|
||||
{place.image ? (
|
||||
<img src={place.image} alt="" className="w-full h-36 object-cover" loading="lazy" />
|
||||
) : (
|
||||
<span className="text-xs text-slate-400">사진 없음</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className={`flex-1 ${compact ? 'p-4' : 'p-5'}`}>
|
||||
<div className="flex items-center gap-1.5 mb-2 text-[#6C5CE7]">
|
||||
<Icon size={16} />
|
||||
<span className="text-xs font-semibold">{label}</span>
|
||||
<span className="text-xs text-slate-400">· {fmtDistance(place.distanceM)}</span>
|
||||
</div>
|
||||
<h4 className={`font-bold text-[#0A1128] mb-1.5 ${compact ? 'text-sm' : 'text-base'}`}>
|
||||
{place.title}
|
||||
</h4>
|
||||
<p className="text-xs text-slate-500 mb-2">{place.address}</p>
|
||||
{/* TourAPI 미제공 항목. 채우지 않고 미연동으로 둔다. */}
|
||||
<div className="text-xs text-slate-400">
|
||||
{place.rating === null ? '평점·영업시간 미연동' : `평점 ${place.rating}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 병원이 채워야 하는 값의 자리. 값이 없으면 비운 채로 무엇이 필요한지 밝힌다. */
|
||||
function ClinicGate({
|
||||
title,
|
||||
filled,
|
||||
waiting,
|
||||
}: {
|
||||
title: string;
|
||||
filled?: string;
|
||||
waiting: string;
|
||||
}) {
|
||||
const has = Boolean(filled && filled.trim());
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl border p-5"
|
||||
style={
|
||||
has
|
||||
? { background: '#F3F0FF', borderColor: '#D5CDF5' }
|
||||
: { background: '#FFF6ED', borderColor: '#F5E0C5' }
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span
|
||||
className="inline-block w-2.5 h-2.5 rounded-full"
|
||||
style={{ background: has ? '#9B8AD4' : '#D4A872' }}
|
||||
/>
|
||||
<span className="text-sm font-bold" style={{ color: has ? '#4A3A7C' : '#7C5C3A' }}>
|
||||
{title}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs font-semibold px-2 py-0.5 rounded-full"
|
||||
style={
|
||||
has
|
||||
? { background: '#fff', color: '#4A3A7C' }
|
||||
: { background: '#fff', color: '#7C5C3A' }
|
||||
}
|
||||
>
|
||||
{has ? '병원 입력됨' : '병원 확인 대기'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm" style={{ color: has ? '#4A3A7C' : '#7C5C3A' }}>
|
||||
{has ? filled : waiting}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyNote({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-xl bg-white/10 border border-white/10 px-4 py-3 text-sm text-white/70">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -324,3 +324,76 @@ export function PrismFilled({ size = 20, className = '' }: IconProps) {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 의료관광 연결 (숙박·식사·웰니스·관광·축제) ── */
|
||||
|
||||
export function BedFilled({ size = 20, className = '' }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<rect x="2" y="10" width="20" height="8" rx="2" fill="currentColor" opacity="0.25" />
|
||||
<rect x="4" y="6" width="16" height="5" rx="2" fill="currentColor" opacity="0.5" />
|
||||
<circle cx="7.5" cy="9" r="2" fill="currentColor" />
|
||||
<rect x="2" y="17" width="3" height="3" rx="1" fill="currentColor" />
|
||||
<rect x="19" y="17" width="3" height="3" rx="1" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ForkFilled({ size = 20, className = '' }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<circle cx="12" cy="12" r="10" fill="currentColor" opacity="0.25" />
|
||||
<path d="M8 4v6a2 2 0 0 0 2 2v8h1.5v-8a2 2 0 0 0 2-2V4H12v5h-1V4H9.5v5h-1V4H8z" fill="currentColor" />
|
||||
<path d="M17 4c-1.4 1-2 2.8-2 4.5 0 1.4.6 2.4 1.5 2.9V20H18V4h-1z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SpaFilled({ size = 20, className = '' }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<circle cx="12" cy="12" r="10" fill="currentColor" opacity="0.25" />
|
||||
<path d="M12 5c2.2 1.8 3.4 4 3.4 6.2 0 1.3-.4 2.4-1.1 3.2C13.4 13.2 12.7 11.4 12 9c-.7 2.4-1.4 4.2-2.3 5.4-.7-.8-1.1-1.9-1.1-3.2C8.6 9 9.8 6.8 12 5z" fill="currentColor" />
|
||||
<ellipse cx="12" cy="17.5" rx="5.5" ry="1.6" fill="currentColor" opacity="0.6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MapPinFilled({ size = 20, className = '' }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<path d="M12 2c-3.9 0-7 3.1-7 7 0 5.2 7 13 7 13s7-7.8 7-13c0-3.9-3.1-7-7-7z" fill="currentColor" opacity="0.25" />
|
||||
<circle cx="12" cy="9" r="3.2" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TicketFilled({ size = 20, className = '' }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<path d="M3 7a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v2.5a2.5 2.5 0 0 0 0 5V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-2.5a2.5 2.5 0 0 0 0-5V7z" fill="currentColor" opacity="0.25" />
|
||||
<rect x="11.2" y="7" width="1.6" height="3" rx="0.8" fill="currentColor" />
|
||||
<rect x="11.2" y="12" width="1.6" height="5" rx="0.8" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function BagFilled({ size = 20, className = '' }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<path d="M4 8h16l-1.2 12H5.2L4 8z" fill="currentColor" opacity="0.25" />
|
||||
<path d="M8.5 9V7a3.5 3.5 0 1 1 7 0v2h-2V7a1.5 1.5 0 1 0-3 0v2h-2z" fill="currentColor" />
|
||||
<rect x="4" y="7" width="16" height="2.4" rx="1.2" fill="currentColor" opacity="0.6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TheaterFilled({ size = 20, className = '' }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<rect x="3" y="4" width="18" height="14" rx="3" fill="currentColor" opacity="0.25" />
|
||||
<path d="M7 9.5h10v2H7zM7 13h6v2H7z" fill="currentColor" />
|
||||
<rect x="8" y="18" width="8" height="2" rx="1" fill="currentColor" opacity="0.6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
1797
src/data/medicalTourism.json
Normal file
1797
src/data/medicalTourism.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -7,6 +7,7 @@ import { AEO_GEO_RUBRIC_V2 } from '../data/aeoGeoRubricV2';
|
||||
import { scoreDiscoveryV2 } from '../lib/discoveryScoreV2';
|
||||
import { AeoGeoV2Panel } from '../components/discovery/AeoGeoV2Panel';
|
||||
import { ClinicInputsPanel } from '../components/discovery/ClinicInputsPanel';
|
||||
import { MedicalTourismPanel } from '../components/discovery/MedicalTourismPanel';
|
||||
import { DISCOVERY_RESULTS } from '../data/discoveryResults';
|
||||
import { scoreDiscovery, levelToSeverity } from '../lib/discoveryScore';
|
||||
import type {
|
||||
@ -358,6 +359,9 @@ export default function DiscoveryReportPage() {
|
||||
{/* ── 5b. 서포터즈 자동 빌드 · 병원 확인 항목 (light, v2 §7-5) ── */}
|
||||
<ClinicInputsPanel clinicId={result.id} />
|
||||
|
||||
{/* ── 5c. 의료관광 연결 (dark) ── */}
|
||||
<MedicalTourismPanel clinicId={result.id} />
|
||||
|
||||
{/* ── 6. 채점 기준표 (light) ── */}
|
||||
<SectionWrapper
|
||||
id="rubric"
|
||||
|
||||
88
src/types/medicalTourism.ts
Normal file
88
src/types/medicalTourism.ts
Normal file
@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 의료관광 연결 기능 타입.
|
||||
*
|
||||
* 데이터 출처는 한국관광공사 TourAPI 다국어(영문) 이며 `scripts/fetch_medical_tourism.py`
|
||||
* 가 수집·필터링해 `src/data/medicalTourism.json` 으로 낸다.
|
||||
*
|
||||
* 원칙: 없는 값을 만들지 않는다.
|
||||
* - rating/reviewCount/openingHours 는 TourAPI 가 주지 않는다. Google Places 승인 후 채운다.
|
||||
* 그때까지 null 이며 화면은 빈 값을 그대로 "미연동"으로 보여준다.
|
||||
* - 수술 전후 식이 적합성은 병원이 입력한 가이드로만 표시한다. 여기서 추론하지 않는다.
|
||||
*/
|
||||
|
||||
/** 병원 반경 안에서 찾은 장소 한 곳. */
|
||||
export interface TourPlace {
|
||||
id: string;
|
||||
title: string;
|
||||
address: string;
|
||||
image: string | null;
|
||||
tel: string | null;
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
/** 병원으로부터의 거리(m). TourAPI 가 준 값을 그대로 쓰고, 없으면 좌표로 계산한다. */
|
||||
distanceM: number | null;
|
||||
/** 아래 셋은 TourAPI 미제공. Google Places 승인 전까지 null 이다. */
|
||||
rating: number | null;
|
||||
reviewCount: number | null;
|
||||
openingHours: string | null;
|
||||
}
|
||||
|
||||
/** 축제·행사. 영문 서비스는 areacode 가 비어 있어 좌표 거리로 걸렀다. */
|
||||
export interface TourFestival extends TourPlace {
|
||||
startDate: string | null;
|
||||
endDate: string | null;
|
||||
distanceKm: number;
|
||||
}
|
||||
|
||||
export type PlaceCategory =
|
||||
| 'stay'
|
||||
| 'restaurant'
|
||||
| 'wellness'
|
||||
| 'attraction'
|
||||
| 'culture'
|
||||
| 'shopping';
|
||||
|
||||
export interface MedicalTourismData {
|
||||
places: Record<PlaceCategory, TourPlace[]>;
|
||||
festivals: TourFestival[];
|
||||
meta: {
|
||||
source: string;
|
||||
fetchedAt: string;
|
||||
origin: { lat: number; lng: number; label: string; coordSource: string };
|
||||
radiusByType: Record<string, number>;
|
||||
festivalRadiusKm: number;
|
||||
counts: Record<string, Record<string, unknown>>;
|
||||
/** 화면에 그대로 표시한다. 한계를 숨기지 않는다. */
|
||||
limits: string[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 회복 단계. 며칠째인지는 의학 판단이므로 여기서 정하지 않는다.
|
||||
* 이 단계는 "병원에서 얼마나 멀리 움직이는가"라는 이동 반경 기준이며,
|
||||
* 각 단계가 수술 후 며칠에 해당하는지는 병원이 supporter_inputs 로 입력한다.
|
||||
*/
|
||||
export interface RecoveryStage {
|
||||
id: string;
|
||||
label: string;
|
||||
/** 이 단계에서 권하는 이동 반경(m). 거리로만 정의하며 의학적 판단을 담지 않는다. */
|
||||
maxDistanceM: number;
|
||||
/** 이 단계에 배치할 장소 범주. */
|
||||
categories: PlaceCategory[];
|
||||
note: string;
|
||||
}
|
||||
|
||||
/** 병원이 입력하는 값. 없으면 화면은 "병원 확인 대기"로 둔다. */
|
||||
export interface ClinicRecoveryInput {
|
||||
/** supporter_inputs.key = 'recovery' */
|
||||
recoveryNote?: string;
|
||||
/** supporter_inputs.key = 'diet_guide' */
|
||||
dietGuide?: {
|
||||
/** 회복 단계별 권장 음식 분류. 병원이 적은 그대로 쓴다. */
|
||||
recommended?: string;
|
||||
/** 회복 단계별 피해야 할 음식 분류. */
|
||||
avoid?: string;
|
||||
/** 단계가 며칠씩인지. */
|
||||
stageDays?: string;
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user