/* ============================================================================ * 서명전에 — iOS Result Screen * ========================================================================== */ /* ──────────────────────────────────────────────────────────────────────────── * 핵심 조건 포맷터 * 백엔드가 추출한 원본 텍스트(예: "금 사억오천만원정", "₩450,000,000", * "2026년 5월 30일 시작 / 2028년 5월 29일 종료")를 화면용으로 라운딩해서 * 표시. 원본 데이터는 변형하지 않고, 렌더링 직전에만 변환한다. * ──────────────────────────────────────────────────────────────────────────── */ /* ──────────────────────────────────────────────────────────────────────────── * 하이라이트 마커 변환 * LLM 이 verdict_title (또는 다른 자유 텍스트) 안에서 핵심 키워드를 * `[[K]]강조할 부분[[/K]]` 마커로 감싸서 보낸다. 프런트는 이 마커를 * 로 변환해서 렌더한다. 마커가 없으면 그대로. * * 왜 [[K]] 마커인가: * - 태그를 LLM 이 직접 출력하면 dangerouslySetInnerHTML 이 필요해 * XSS 위험 + sanitize 비용. 마커 방식은 평문이라 안전. * - [[ ]] 는 일반 한국어/계약서 텍스트에 자연 등장 거의 없음 → 충돌 적음. * ──────────────────────────────────────────────────────────────────────────── */ function _renderHighlighted(text) { if (text == null || text === "") return text; const parts = String(text).split(/(\[\[K\]\][^\[]*?\[\[\/K\]\])/g); return parts.map((part, i) => { if (part.startsWith("[[K]]") && part.endsWith("[[/K]]")) { return {part.slice(5, -6)}; } return part; }); } const _KF_MONEY_LABELS = new Set([ "보증금/전세금", "보증금", "전세금", "계약금", "중도금", "잔금", "월 차임", "차임", "월세", "월 임대료", "임대료", "관리비", ]); /* ──────────────────────────────────────────────────────────────────────────── * PDF 내보내기용 작은 유틸들 — ResultScreen 의 exportPdf 핸들러가 사용. * - _bufToBase64: ArrayBuffer → prefix 없는 base64 (saveBase64Data 스펙) * - _parseDispositionFilename: Content-Disposition 헤더에서 파일명 추출 * - _stampNow: 파일명 폴백용 타임스탬프 * ──────────────────────────────────────────────────────────────────────────── */ function _bufToBase64(buf) { const bytes = new Uint8Array(buf); // 대용량 PDF 일 수도 있으므로 fromCharCode 에 한 번에 큰 배열을 넘기지 않는다. let binary = ""; const chunk = 0x8000; for (let i = 0; i < bytes.length; i += chunk) { binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk)); } return btoa(binary); } function _parseDispositionFilename(header) { if (!header) return null; // RFC 5987: filename*=UTF-8'' const star = header.match(/filename\*=UTF-8''([^;]+)/i); if (star && star[1]) { try { return decodeURIComponent(star[1].trim()); } catch (_) { /* fall through */ } } const plain = header.match(/filename="?([^";]+)"?/i); if (plain && plain[1]) return plain[1].trim(); return null; } function _stampNow() { const d = new Date(); const pad = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}`; } function _parseKoreanNumerals(raw) { if (!raw) return null; const s = String(raw).replace(/\s+/g, "").replace(/[정원()]/g, ""); if (!s) return null; const digit = { "영":0,"공":0,"일":1,"이":2,"삼":3,"사":4,"오":5,"육":6,"칠":7,"팔":8,"구":9 }; const unit1 = { "십":10,"백":100,"천":1000 }; const unit2 = { "만":10000,"억":100000000,"조":1000000000000 }; let total = 0, block = 0, subBlock = 0, lastDigit = 0; for (const ch of s) { if (ch in digit) { lastDigit = digit[ch]; } else if (ch in unit1) { subBlock += (lastDigit || 1) * unit1[ch]; lastDigit = 0; } else if (ch in unit2) { let chunk = block + subBlock + lastDigit; if (chunk === 0) chunk = 1; total += chunk * unit2[ch]; block = 0; subBlock = 0; lastDigit = 0; } else { return null; } } total += block + subBlock + lastDigit; return total > 0 ? total : null; } function _toKoreanCurrency(n) { if (!Number.isFinite(n) || n <= 0) return null; const 억 = Math.floor(n / 100000000); const rem = n % 100000000; const 만 = Math.floor(rem / 10000); const 원 = rem % 10000; const parts = []; if (억 > 0) parts.push(`${억.toLocaleString("ko-KR")}억`); if (만 > 0) parts.push(`${만.toLocaleString("ko-KR")}만원`); if (원 > 0) parts.push(`${원.toLocaleString("ko-KR")}원`); if (parts.length === 0) return `${n.toLocaleString("ko-KR")}원`; if (parts.length === 1 && 억 > 0 && 만 === 0 && 원 === 0) return `${억}억원`; return parts.join(" "); } function _extractKRW(raw) { if (!raw) return null; // ₩ / 금 + 숫자 + 원 형태 const digitMatch = String(raw).match(/(?:₩|금)?\s*([\d]{1,3}(?:,[\d]{3})+|[\d]+)\s*원?/); if (digitMatch) { const n = parseInt(digitMatch[1].replace(/,/g, ""), 10); if (!isNaN(n) && n >= 10000) return n; } // 한글 수사 형태 (사억오천만원정) const koreanMatch = String(raw).match(/금?\s*([영공일이삼사오육칠팔구십백천만억조\s]+)\s*원/); if (koreanMatch) { const n = _parseKoreanNumerals(koreanMatch[1]); if (n) return n; } return null; } function _formatCompactDate(text) { return String(text).replace( /(\d{4})\s*년\s*(\d{1,2})\s*월\s*(\d{1,2})\s*일/g, (_, y, m, d) => `${y}.${parseInt(m, 10)}.${parseInt(d, 10)}` ); } function _formatMoneyValue(raw) { if (!raw) return raw; const parts = String(raw).split("·").map(s => s.trim()); const amountPart = parts[0]; const extraParts = parts.slice(1); const n = _extractKRW(amountPart); const formattedAmount = n ? _toKoreanCurrency(n) : amountPart; if (extraParts.length === 0) return formattedAmount; const extras = extraParts.map(_formatCompactDate).join(" · "); return `${formattedAmount} · ${extras}`; } function _formatLeasePeriod(raw) { if (!raw) return raw; const m = String(raw).match( /(\d{4})\s*년\s*(\d{1,2})\s*월\s*(\d{1,2})\s*일[\s\S]+?(\d{4})\s*년\s*(\d{1,2})\s*월\s*(\d{1,2})\s*일/ ); if (!m) return _formatCompactDate(raw); const [, sy, smo, sd, ey, emo, ed] = m; const sY = parseInt(sy, 10), sM = parseInt(smo, 10), sD = parseInt(sd, 10); const eY = parseInt(ey, 10), eM = parseInt(emo, 10), eD = parseInt(ed, 10); // 전세/임대차 관행: 종료일이 시작일의 "전날"이면 만 N개월로 간주 (end-date-inclusive). // 2026.5.30 ~ 2028.5.29 → 24개월 // 2026.5.1 ~ 2028.4.30 → 24개월 // 종료일에 하루를 더해 "다음 계약 시작일"로 환산한 뒤 월 수를 계산한다. // Date 생성자가 월 롤오버(4/31 → 5/1)를 자동 처리한다. const endPlus1 = new Date(eY, eM - 1, eD + 1); const pY = endPlus1.getFullYear(); const pM = endPlus1.getMonth() + 1; const pD = endPlus1.getDate(); const months = (pY - sY) * 12 + (pM - sM) + (pD >= sD ? 0 : -1); const start = `${sY}.${sM}.${sD}`; const end = `${eY}.${eM}.${eD}`; if (months >= 1) return `${start} ~ ${end} · ${months}개월`; return `${start} ~ ${end}`; } function formatKeyFactValue(label, raw) { if (!raw) return raw; if (label === "계약기간") return _formatLeasePeriod(raw); if (_KF_MONEY_LABELS.has(label)) return _formatMoneyValue(raw); // 소재지·임대부분 등은 그대로 return raw; } window.formatKeyFactValue = formatKeyFactValue; function ResultScreen({ scenario = "jeonse", analysis = null, onBack, onOpenIssue, onOpenPreview }) { /* 실제 분석 결과가 있으면 그것을 사용, 없으면 시나리오 목업으로 폴백 */ const data = useMemo(() => { if (analysis && typeof window.toResultData === "function") { const mapped = window.toResultData(analysis); if (mapped) return mapped; } return window.SCENARIOS[scenario] || window.SCENARIOS.jeonse; }, [analysis, scenario]); const scrollRef = useRef(null); const [scrolled, setScrolled] = useState(false); const [pdfBusy, setPdfBusy] = useState(false); /** * "PDF 리포트로 저장하기" 핸들러. * * 흐름: * 1) `POST /pdf/export` 로 분석 객체를 보내 PDF 바이트를 받는다. * - 저장된 분석이면 `GET /account/history/{id}/pdf` 도 쓸 수 있지만, * 방금 분석한(=아직 저장 전) 결과도 지원하려면 POST 모드가 필요하다. * 2) 응답을 base64 로 인코딩한다. * 3) `TossAdapter.data.saveBase64(...)` 호출. * - 앱인토스: 네이티브 파일 저장 대화상자 (saveBase64Data) * - 웹: a[download] 로 브라우저 다운로드 (TossAdapter 웹 폴백) * 4) 실패 시 사용자에게 즉시 피드백. * * 주의: * - 분석 결과가 없을 땐(=목업 시나리오 화면) 버튼이 아무 일도 안 하도록 둔다. * (원래 placeholder 였고, 실제 분석이 있어야만 유의미한 문서가 나옴) * - Supabase 로그인 토큰이 필요하다(엔드포인트가 require_account). */ const exportPdf = async () => { if (!analysis) { alert("분석 결과가 없어 저장할 수 없어요."); return; } if (pdfBusy) return; setPdfBusy(true); try { // 인증 토큰 자동 주입 + 기본 Accept 헤더 세팅. // apiFetch 가 아직 안 올라왔다면(드문 로드 순서 이슈) 기본 fetch 로 폴백. const doFetch = (typeof window.apiFetch === "function") ? window.apiFetch : fetch; const res = await doFetch("/pdf/export", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(analysis), }); if (!res.ok) { let msg = "PDF 를 만드는 데 실패했어요."; try { const j = await res.json(); if (j?.detail) msg = j.detail; } catch (_) { /* noop */ } if (res.status === 401) msg = "로그인이 필요해요. 다시 로그인해 주세요."; throw new Error(msg); } const blob = await res.blob(); // Blob → base64 (saveBase64Data 는 prefix 없는 base64 문자열을 받음) const buf = await blob.arrayBuffer(); const b64 = _bufToBase64(buf); // 서버가 Content-Disposition 으로 보내준 파일명 사용, 없으면 폴백 const fileName = _parseDispositionFilename(res.headers.get("Content-Disposition")) || `서명전에_분석결과_${_stampNow()}.pdf`; const adapter = (typeof window !== "undefined" && window.TossAdapter) || null; if (!adapter?.data?.saveBase64) { throw new Error("파일 저장을 지원하지 않는 환경이에요."); } await adapter.data.saveBase64({ data: b64, fileName, mimeType: "application/pdf", }); // 앱인토스에서는 네이티브 성공 UI 가 뜨지만, 폴백 웹에서는 별도 토스트가 없어 살짝 알려준다. if (!adapter.isInAppsInToss || !adapter.isInAppsInToss()) { // noop — 자체로 충분히 인지 가능. } } catch (err) { console.error("[PDF export] failed", err); const adapter = (typeof window !== "undefined" && window.TossAdapter) || null; if (adapter?.TossPermissionError && err instanceof adapter.TossPermissionError) { alert("파일 저장 권한이 필요해요. 토스 앱에서 권한을 허용해 주세요."); } else { alert(err?.message || "PDF 저장 중 오류가 발생했어요."); } } finally { setPdfBusy(false); } }; /* 헤드라인 어휘 + 색 톤은 legal_status 우선순위(void > unfair > negotiable) 로 결정. 사용자가 "1건의 고위험 조항" 보다 "1건의 불공정 조항" 이 더 와닿는다고 함 — 하단 legal_totals 의 어휘 체계와 헤드라인을 일치시켜서 인지 부담을 줄인다. 색도 같이 통일: void=빨간(alert), unfair=노란(warn), negotiable=회색(safe). ⚠️ numeric_audits 의 status="mismatch" (보증금 합산 불일치 같은 계약서 자체 모순) 는 사실 issue 보다 더 치명적 — Hero 카운트에 합산하고 톤도 alert 로 트리거. 이전엔 "꼭 알아두세요" 보조 영역에만 표시되어 사용자가 헤드만 봤을 때 "0건의 확인할 조항" 으로 안심시켜 버렸음. legal 분류가 모두 0인 (heuristic-only) 케이스는 risk severity 로 폴백. */ const mismatchCount = Array.isArray(data.numeric_audits) ? data.numeric_audits.filter((na) => (na?.status || "").toLowerCase() === "mismatch").length : 0; /* 참고하면 좋은 항목 카운트 — missing_items + numeric_audits(status=warning). warning 인 numeric audits 는 결국 "법으로 보호되는 정보성 항목" 이라 missing_items 와 같은 회색 섹션에서 함께 보여줌. hero 카운트도 합산. */ const lawProtectedAuditCount = Array.isArray(data.numeric_audits) ? data.numeric_audits.filter((na) => (na?.status || "").toLowerCase() === "warning").length : 0; const missingCount = (Array.isArray(data.missing_items) ? data.missing_items.length : 0) + lawProtectedAuditCount; /* heroKind — Result hero 의 카운트/톤 결정. 톤은 **라벨이 말하는 것과 색이 1:1 매칭** 되게 단순화. numericMismatch 는 별도 "꼭 알아두세요" 섹션에 이미 표시되므로 hero tone 엘리베이션에 섞지 않음. 홈 목록 historyItemSev 와도 동일 매핑. ① void → alert (빨강) ② unfair → warn (주황) ③ negotiable → safe (초록) ④ risk.high → alert ⑤ risk.medium → warn ⑥ numericMismatch 만 → warn ⑦ missingCount 만 → warn ⑧ 그 외 → safe parts: [{ count, label }, ...] — 법 위반 + 불공정 둘 다 > 0 이면 length=2. */ const heroKind = (() => { const lt = data.legal_totals || {}; const v = lt.void || 0; const u = lt.unfair || 0; const n = lt.negotiable || 0; if (v > 0 && u > 0) { return { parts: [ { count: v, label: "법 위반 조항" }, { count: u, label: "불공정 조항" }, ], tone: "alert", }; } if (v > 0) return { parts: [{ count: v, label: "법 위반 조항" }], tone: "alert" }; if (u > 0) return { parts: [{ count: u, label: "불공정 조항" }], tone: "warn" }; if (n > 0) return { parts: [{ count: n, label: "협상 대상" }], tone: "safe" }; if ((data.risk_totals?.high || 0) > 0) { return { parts: [{ count: data.risk_totals.high, label: "고위험 조항" }], tone: "alert" }; } if ((data.risk_totals?.medium || 0) > 0) { return { parts: [{ count: data.risk_totals.medium, label: "주의 조항" }], tone: "warn" }; } if (mismatchCount > 0) { return { parts: [{ count: mismatchCount, label: "꼭 확인할 항목" }], tone: "warn" }; } /* missing_items 만 있는 케이스 — "위험 조항 0건 + 체크 N건" 2-part 로 표시하고 톤은 safe(초록). 실제 위험 조항은 0건이라 주황(warn) 으로 올리면 과경고. "위험은 없고, 보완만 필요한 상태" 를 시각적으로 안심 시그널(초록) + 숫자로 명확히 전달. 사용자 요청. "0건의 위험 조항 및 8건의 체크해야 할 항목" 형태. */ if (missingCount > 0) { return { parts: [ { count: 0, label: "위험 조항" }, { count: missingCount, label: "참고하면 좋은 항목" }, ], tone: "safe", }; } return { parts: [{ count: 0, label: "확인할 조항" }], tone: "safe" }; })(); const sev = heroKind.tone; // hero 색 톤은 헤드라인 분류와 동일하게 가져간다 (위계 통일) /* safe 케이스에 res-hero--safe 를 꼭 붙여야 함. 안 붙이면 .res-hero__count-n 기본 color 가 var(--brand)(파랑) 으로 fallback 되는데, 홈 썸네일의 .recent__thumb--safe 는 var(--safe)(초록) 이라 "협상 대상" 이 홈에선 초록 → 상세에선 파랑으로 보이는 색 엇갈림이 생김. 토큰 수준에서 safe=초록 이 이미 정답이므로 hero 에도 똑같이 safe 클래스 적용. */ const heroMod = sev === "alert" ? "res-hero--alert" : sev === "warn" ? "res-hero--warn" : sev === "safe" ? "res-hero--safe" : ""; // 과거엔 히어로가 짙은 컬러 배경이어서 상태표시줄 글자를 흰색으로 덮어썼지만, // 지금은 히어로가 밝은 카드라 기본(검정) 상태표시줄 그대로 두면 가독성이 더 좋다. useEffect(() => { const island = document.querySelector(".iphone__status"); if (!island) return; island.style.color = ""; }, []); const contractTypeLabel = data.contract_type_label; const icon = data.contract_type === "employment" ? "briefcase" : data.contract_type === "freelancer" ? "briefcase" : scenario === "wolsae" ? "building" : "home-key"; const sortedIssues = useMemo(() => { const order = { high: 0, medium: 1, low: 2 }; return [...(data.issues || [])].sort((a, b) => (order[a.severity] ?? 3) - (order[b.severity] ?? 3)); }, [data]); const redflagCount = Array.isArray(data.redflags) ? data.redflags.filter((r) => r?.detected).length : 0; const hasRedflags = Array.isArray(data.redflags) && data.redflags.length > 0; return (
분석 결과
{/* 앱인토스 미니앱 정책 4(외부 링크 차단): 외부 공유 인텐트는 지원하지 않음. 링크 복사(내부 공유)만 허용. TossAdapter.clipboard 로 앱인토스 ↔ 웹 양쪽에서 동일 API 를 사용한다 — 앱인토스에서는 setClipboardText, 웹에서는 navigator.clipboard(→ execCommand) 순으로 폴백. */}
setScrolled(e.currentTarget.scrollTop > 20)}> {/* Hero */}
{contractTypeLabel}
{/* 단일 part: "3건의 협상 대상" 처럼 한 줄. 두 part: "0건의 위험 조항, 7건의 체크해야 할 항목" 형태. ⚠️ 이전엔 가운데 " 및 " 분리자를 썼는데, 좁은 모바일 화면에서 wrap 되면 두 번째 줄 맨 앞에 "및" 만 외롭게 남아 어색했음. 사용자 요청: 콤마(,) 로 바꿔 첫 번째 part 끝에 붙이고, 두 번째 part 가 자연스럽게 다음 줄로 흘러가도록. 숫자 크기는 두 part 동일 (56px). */}
{heroKind.parts.map((p, i) => { const isLast = i === heroKind.parts.length - 1; return ( 0 ? " res-hero__count-part--secondary" : "")} > {p.count} 건의 {p.label}{!isLast ? "," : ""} ); })}

{_renderHighlighted(data.verdict_title)}

{/* 앱인토스 미니앱 정책 5.2(AI 사용 고지): 생성형 AI가 만든 결과임을 명시. */}
이 분석은 생성형 AI가 작성한 참고 의견이에요. 법적 효력은 없고, 중요한 결정 전에는 전문가 상담을 권장해요.
{/* 법적 판단 바 — 실제 분석에서 감지 깊이가 한눈에 보이도록 */} {data.is_real && data.legal_totals && (
{data.legal_totals.void || 0} 법 위반
{data.legal_totals.unfair || 0} 불공정
{data.legal_totals.negotiable || 0} 협상 대상
)} {/* hero 하단 chips ("중 N건·경미 N건·1p·OCR N자") 는 사용자에게 의미가 모호하고 (특히 OCR 글자수는 디버그성 정보) 시각적 노이즈만 남겨서 제거. 중·경미 카운트는 res-stats 로 별도 표시되고, 페이지 수 / OCR 통계는 필요하면 issue 시트 메타로 옮길 수 있음. */}
{/* Quick stats — 실제 분석은 법적 지위 분포로, 목업은 기존 스탯 그대로 */}
{data.is_real ? ( <>
{data.issues.length}
총 이슈
0 ? " res-stat--alert" : "")}>
{data.legal_totals?.void || 0}
법 위반
0 ? " res-stat--warn" : "")}>
{data.legal_totals?.unfair || 0}
불공정
) : ( <>
{data.issues.length}
총 이슈
{redflagCount}
레드플래그
{data.key_facts.length}
핵심 조건
)}
{/* Key facts — 비어 있으면 아예 숨김 (실제 분석에서 추출 실패 케이스) */} {data.key_facts && data.key_facts.length > 0 && (
핵심 조건
{data.key_facts.map((kf, i) => (
{kf.label} {(() => { const formatted = formatKeyFactValue(kf.label, kf.value); // "금액 · 날짜 지급" 같은 합성 값은 두 줄로 분리 — 사용자가 헷갈림 방지. if (typeof formatted === "string" && formatted.includes(" · ")) { const [head, ...rest] = formatted.split(" · "); const tail = rest.join(" · "); return ( <> {head} {tail} ); } return formatted; })()}
))}
)} {/* Issues */}

조항별 이슈

{sortedIssues.map((iss) => { /* sKey/sevLabel 은 legal_status 를 우선해서 결정한다. 이렇게 하면 hero("1건의 불공정 조항", 노란) 와 카드 색이 일치해서 사용자가 "왜 위쪽은 노란데 카드는 빨간이지?" 라고 느끼지 않는다. legal_status 가 비어 있을 때만 severity(high/medium/low) 로 폴백. */ const lstat = iss.legal_status || ""; const sKey = ( lstat === "void" ? "high" : lstat === "unfair" ? "mid" : lstat === "negotiable" ? "low" : iss.severity === "high" ? "high" : iss.severity === "medium" ? "mid" : "low" ); const sMod = "issue__sev--" + sKey; const iconName = sKey === "high" ? "exclam" : sKey === "mid" ? "warn-tri" : "info"; /* sevLabel 도 legal_status 어휘로 통일 ("법 위반/불공정/협상 대상"). legal 분류 없을 때만 severity 어휘 폴백. */ const sevLabel = ( lstat === "void" ? "법 위반" : lstat === "unfair" ? "불공정" : lstat === "negotiable" ? "협상 대상" : iss.severity === "high" ? "고위험" : iss.severity === "medium" ? "주의" : "경미" ); const statusLabel = iss.legal_status === "void" ? "효력 없을 수도" : iss.legal_status === "unfair" ? "한쪽에 불리" : iss.legal_status === "negotiable" ? "조율할 부분" : ""; const statusBadgeColor = iss.legal_status === "void" ? "red" : iss.legal_status === "unfair" ? "yellow" : iss.legal_status === "negotiable" ? "elephant" : null; // 카드 preview — 하이라이트 마커는 시트에서만 보이면 충분하고, // 카드에선 평문이 깔끔. 회귀: "[[K]]별도 협의 없이[[/K]]" 가 raw // 로 렌더되던 문제 (slice 가 마커 중간을 자르면 더 지저분해짐). // 먼저 모든 마커(`[[K]]...[[/K]]` 와 `**...**`) 를 벗긴 뒤 slice. const _strip = (s) => String(s || "") .replace(/\[\[K\]\]/g, "") .replace(/\[\[\/K\]\]/g, "") .replace(/\*\*([^*]+)\*\*/g, "$1"); const _clean = _strip(iss.preview || iss.reason || ""); const preview = _clean.length > 110 ? _clean.slice(0, 108) + "…" : _clean; return ( ); })}
{/* Redflags checklist — 목업 시나리오에만 노출, 실제 분석 결과는 생략 */} {hasRedflags && (
{data.contract_type === "employment" ? "근로계약 위험 신호 (추후 업데이트)" : "전세사기 체크리스트 16종"}
{data.redflags.slice(0, 8).map((rf, i) => (
{rf.label} {rf.detected ? "감지됨" : "정상"}
))}
16개 체크 포인트 중 {redflagCount}개가 걸렸어요. 나머지는 괜찮아요.
)} {/* 실제 분석 결과일 때만 보이는 보조 섹션들 */} {analysis && data.next_steps && data.next_steps.length > 0 && (
지금 바로 해 둘 일
{data.next_steps.slice(0, 6).map((step, i) => (
{i + 1} {step}
))}
)} {/* numeric_audits 분기 ─ 주요 숫자 분석 섹션: status === ok 또는 mismatch 만 (실제 계약서에 있는 숫자를 검증한 결과 — 통과 / 불일치). 참고 섹션: status === warning (조항이 계약서에 없는 케이스 — 연체이자율/위약금 등 법으로 자동 보호되는 항목). 사용자 피드백: "warning 인 숫자 항목들은 결국 법으로 보호되니 아래 회색 섹션에 두는 게 낫다". */} {(() => { const audits = Array.isArray(data.numeric_audits) ? data.numeric_audits : []; const _statusOf = (na) => (na?.status || "warning").toLowerCase(); const inContract = audits.filter((na) => { const s = _statusOf(na); return s === "ok" || s === "mismatch"; }); const lawProtected = audits.filter((na) => _statusOf(na) === "warning"); /* — 1) 주요 숫자 분석 (ok / mismatch) — */ const renderInContract = analysis && inContract.length > 0 && (
주요 숫자 분석 · {inContract.length}건
{_renderHighlighted("보증금·계약금·잔금·연체이자·위약금 같은 [[K]]계약서 핵심 숫자[[/K]] 를 자동으로 따져봤어요. ✓ 는 통과, ⚠️ 는 확인 필요.")}
{inContract.map((na, i) => { const status = _statusOf(na); const isOk = status === "ok"; const isMismatch = status === "mismatch"; const iconName = isOk ? "check" : "exclam"; /* TDS yellow-700 은 실제 orange 라 직접 색 지정. */ const iconBg = isOk ? "var(--safe-soft)" : "var(--alert-soft)"; const iconFg = isOk ? "var(--safe)" : "var(--alert)"; const borderColor = isOk ? "var(--safe)" : "var(--alert)"; return (
{_renderHighlighted(na.check)} {na.detail && {_renderHighlighted(na.detail)}}
); })}
); /* — 2) 참고하면 좋은 항목 (missing_items + warning numeric_audits) — */ const missingArr = (analysis && Array.isArray(data.missing_items)) ? data.missing_items : []; const totalReference = missingArr.length + lawProtected.length; const renderReference = totalReference > 0 && (
참고하면 좋은 항목 · {totalReference}건
아래 항목들은 대부분 법으로 보호돼요. 그래도 불안하다면 계약서에 한 줄 적어두면 마음이 더 편해져요.
{/* numeric_audits warning 먼저 (연체이자율/위약금 등), 그 다음 missing_items */} {lawProtected.map((na, i) => (
{_renderHighlighted(na.check)} {na.detail && {_renderHighlighted(na.detail)}}
))} {missingArr.map((m, i) => (
{_renderHighlighted(m.title)} {m.reason && {_renderHighlighted(m.reason)}}
))}
); return ( <> {renderInContract} {renderReference} ); })()} {analysis && data.disclaimer && (

{data.disclaimer}

)}
); } window.ResultScreen = ResultScreen; Object.assign(window, { ResultScreen });