release: v0.10.0
All checks were successful
ci / build (push) Successful in 42s
release / release (push) Successful in 1m5s

This commit is contained in:
Yun Chan 2026-08-29 21:19:26 +09:00
parent 2b7fa32b5b
commit 6930906583
61 changed files with 15165 additions and 82 deletions

View file

@ -0,0 +1,294 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#f3f4f3">
<meta name="description" content="가상 공인중개 쇼케이스의 매물 사실, 표시 항목, AI 분석 근거, 변경 이력, 게시 가능 여부를 한 화면에서 검수하는 상세 관리자 운영 데모입니다.">
<meta name="robots" content="noindex, nofollow">
<meta property="og:title" content="한남자리 관리 데모 — 매물 검수">
<meta property="og:description" content="예시 매물의 표시 항목, AI 근거와 게시 방어선을 살피는 운영 화면">
<meta property="og:image" content="../og.png">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="한남자리 관리 데모 — 매물 검수">
<meta name="twitter:image" content="../og.png">
<title>한남자리 관리 데모 — 매물 검수</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%23141413'/%3E%3Ctext x='32' y='39' text-anchor='middle' font-family='Arial' font-size='19' font-weight='700' fill='%23c6a15b'%3EHJ%3C/text%3E%3C/svg%3E">
<link rel="stylesheet" href="../styles/tokens.css?v=20260829c">
<link rel="stylesheet" href="../styles/admin.css?v=20260829c">
<script src="../scripts/admin.js?v=20260829c" defer></script>
</head>
<body class="admin-body">
<a class="skip-link" href="#admin-main">본문 바로가기</a>
<div class="demo-strip" role="region" aria-label="쇼케이스 고지">쇼케이스 · 가상 브랜드 · 모든 사무소, 매물, 고객과 기록은 합성 예시입니다</div>
<div class="admin-shell">
<aside class="admin-sidebar" data-sidebar aria-label="관리 메뉴">
<div class="sidebar-brand-shell">
<a href="../" aria-label="한남자리 공개 쇼케이스로 이동"><span class="sidebar-mark">HJ</span><span><strong class="sidebar-brand">한남자리</strong><small>운영 화면 데모</small></span></a>
<button type="button" class="sidebar-close" data-close-sidebar aria-label="관리 메뉴 닫기">×</button>
</div>
<nav class="sidebar-nav" aria-label="관리 화면 목록">
<p>운영</p>
<a href="#queue" class="is-current" aria-current="page"><span aria-hidden="true">⌁</span>검수 대기열 <small>6</small></a>
<a href="#queue"><span aria-hidden="true">▤</span>전체 매물</a>
<a href="#inspector-inquiries"><span aria-hidden="true">◌</span>문의와 고객</a>
<a href="#inspector-ai"><span aria-hidden="true">✦</span>AI 분석</a>
<a href="#inspector-inquiries"><span aria-hidden="true">□</span>일정</a>
<p>관리</p>
<a href="#office-gate"><span aria-hidden="true">◇</span>사무소와 법정 정보 <small class="nav-alert">5</small></a>
<a href="#roles"><span aria-hidden="true">◎</span>팀과 권한</a>
<a href="#inspector-history"><span aria-hidden="true">↺</span>감사 로그</a>
<a href="#settings"><span aria-hidden="true">⚙</span>설정</a>
</nav>
<div class="sidebar-foot">
<span class="status-badge status-demo">데모 환경</span>
<p>실제 광고 게시와 외부 연락은 연결되지 않습니다.</p>
<a href="../">공개 화면 보기 ↗</a>
</div>
</aside>
<div class="sidebar-backdrop" data-sidebar-backdrop hidden></div>
<main class="admin-main" id="admin-main">
<header class="admin-topbar">
<button type="button" class="mobile-sidebar-button" aria-expanded="false" data-open-sidebar><span aria-hidden="true">☰</span><span class="sr-only">관리 메뉴 열기</span></button>
<div class="breadcrumb"><span>운영</span><span aria-hidden="true">/</span><strong>매물 검수</strong></div>
<div class="topbar-actions">
<button type="button" class="icon-button" aria-label="명령 메뉴 열기" data-command-button><span aria-hidden="true">⌕</span><kbd>Ctrl K</kbd></button>
<button type="button" class="icon-button" aria-label="알림 보기"><span aria-hidden="true">○</span><i aria-hidden="true"></i></button>
<button type="button" class="account-button" aria-label="예시 계정 메뉴"><span>김</span><span><strong>김유진</strong><small>공인중개사 · 예시</small></span></button>
</div>
</header>
<div class="admin-content">
<header class="page-heading">
<div>
<div class="heading-badges"><span class="status-badge status-demo">쇼케이스 · 가상 브랜드</span><span>자료 기준 2026.08.29</span></div>
<h1>매물 검수 대기열</h1>
<p>게시 전에 매물 사실, 표시 항목, AI 근거와 변경 이력을 한 자리에서 확인합니다.</p>
</div>
<div class="heading-actions">
<button type="button" class="button button-ghost" data-open-preview>쇼케이스 미리보기</button>
<button type="button" class="button button-primary" data-new-draft data-inspector-trigger>새 예시 초안</button>
</div>
</header>
<section class="system-banner" role="status" aria-live="polite" hidden data-system-banner>
<div><strong data-banner-title>운영 상태</strong><p data-banner-copy></p></div>
<button type="button" data-dismiss-banner>닫기</button>
</section>
<section class="queue-toolbar" aria-label="대기열 도구">
<label class="search-field"><span class="sr-only">매물 검색</span><span aria-hidden="true">⌕</span><input type="search" placeholder="매물명, 거래 형태, 담당 검색" data-queue-search></label>
<label><span class="sr-only">상태 필터</span><select data-status-filter><option value="all">모든 상태</option><option value="review">검수 필요</option><option value="visible">쇼케이스 공개</option><option value="paused">보류</option><option value="contracted">계약 완료</option><option value="draft">초안</option></select></label>
<label><span class="sr-only">정렬 기준</span><select data-sort-select><option value="updated-desc">최근 수정순</option><option value="updated-asc">오래된 수정순</option><option value="title-asc">매물명순</option><option value="status-asc">상태순</option></select></label>
<label class="state-simulator"><span>상태 시연</span><select data-state-simulator><option value="normal">정상</option><option value="loading">불러오는 중</option><option value="empty">빈 대기열</option><option value="error">불러오기 실패</option><option value="save-error">저장 실패</option><option value="stale">자료 오래됨</option><option value="conflict">버전 충돌</option><option value="permission">권한 제한</option></select></label>
</section>
<div class="admin-workbench">
<section class="queue-panel" id="queue" aria-labelledby="queue-title" aria-busy="false" data-queue-panel>
<header class="panel-heading">
<div><h2 id="queue-title">현재 대기열</h2><span data-result-count>6개 예시</span></div>
<div><span class="queue-legend"><i class="legend-block" aria-hidden="true"></i>차단</span><span class="queue-legend"><i class="legend-warn" aria-hidden="true"></i>확인</span></div>
</header>
<div class="table-scroll">
<table class="queue-table">
<caption class="sr-only">쇼케이스용 예시 매물 검수 대기열</caption>
<thead>
<tr><th scope="col">매물</th><th scope="col">거래</th><th scope="col">예시 조건</th><th scope="col">마지막 확인</th><th scope="col">표시 검수</th><th scope="col">AI 검토</th><th scope="col">담당</th></tr>
</thead>
<tbody data-queue-body>
<tr class="is-selected" data-queue-row data-id="DEMO-HJ-101" data-status="review" data-updated="2026-08-29T10:42:00+09:00" data-title="한남 생활권 정원 주거" aria-selected="true">
<td class="row-cell-select"><button type="button" class="row-select-overlay" data-select-row aria-label="한남 생활권 정원 주거 조사관 열기"></button><span class="row-title" aria-hidden="true"><span class="row-thumb"><img src="../assets/hannam-evening-640.webp" width="80" height="54" alt="서울 언덕 생활권의 주거 외관을 표현한 연출 이미지"></span><span><strong>한남 생활권 정원 주거</strong><small>DEMO-HJ-101 · 예시 매물</small></span></span></td>
<td><span>매매</span><small>연립주택</small></td><td><strong>38억 원</strong><small>전용 118.42㎡</small></td><td><span>2026.08.27</span><small>2일 전 · 모의</small></td><td><span class="cell-status is-blocked"><i aria-hidden="true"></i>2개 차단</span></td><td><span class="cell-status is-warn"><i aria-hidden="true"></i>검토 전</span></td><td><span class="assignee"><i>김</i>김유진</span></td>
</tr>
<tr data-queue-row data-id="DEMO-HJ-102" data-status="visible" data-updated="2026-08-29T09:18:00+09:00" data-title="한강 가까운 복층 주거" aria-selected="false">
<td class="row-cell-select"><button type="button" class="row-select-overlay" data-select-row aria-label="한강 가까운 복층 주거 조사관 열기"></button><span class="row-title" aria-hidden="true"><span class="row-thumb row-thumb-plan"><i></i></span><span><strong>한강 가까운 복층 주거</strong><small>DEMO-HJ-102 · 예시 매물</small></span></span></td>
<td><span>전세</span><small>공동주택</small></td><td><strong>전세 22억</strong><small>전용 142.80㎡</small></td><td><span>2026.08.26</span><small>3일 전 · 모의</small></td><td><span class="cell-status is-pass"><i aria-hidden="true"></i>데모 통과</span></td><td><span class="cell-status is-pass"><i aria-hidden="true"></i>수정 승인</span></td><td><span class="assignee"><i>박</i>박소연</span></td>
</tr>
<tr data-queue-row data-id="DEMO-HJ-103" data-status="paused" data-updated="2026-08-28T17:25:00+09:00" data-title="조용한 골목의 테라스 주거" aria-selected="false">
<td class="row-cell-select"><button type="button" class="row-select-overlay" data-select-row aria-label="조용한 골목의 테라스 주거 조사관 열기"></button><span class="row-title" aria-hidden="true"><span class="row-thumb row-thumb-stale"><i></i></span><span><strong>조용한 골목의 테라스 주거</strong><small>DEMO-HJ-103 · 예시 매물</small></span></span></td>
<td><span>월세</span><small>다세대주택</small></td><td><strong>5억 · 월 1,200만</strong><small>전용 140.84㎡</small></td><td><span>2026.08.11</span><small>18일 전 · 오래됨</small></td><td><span class="cell-status is-warn"><i aria-hidden="true"></i>다시 확인</span></td><td><span class="cell-status is-stale"><i aria-hidden="true"></i>오래됨</span></td><td><span class="assignee"><i>김</i>김유진</span></td>
</tr>
<tr data-queue-row data-id="DEMO-HJ-104" data-status="contracted" data-updated="2026-08-28T12:40:00+09:00" data-title="언덕 위 작은 정원 주거" aria-selected="false">
<td class="row-cell-select"><button type="button" class="row-select-overlay" data-select-row aria-label="언덕 위 작은 정원 주거 조사관 열기"></button><span class="row-title" aria-hidden="true"><span class="row-thumb row-thumb-closed"><i></i></span><span><strong>언덕 위 작은 정원 주거</strong><small>DEMO-HJ-104 · 예시 매물</small></span></span></td>
<td><span>매매</span><small>단독주택</small></td><td><strong>상담 종료</strong><small>공개 목록 제외</small></td><td><span>2026.08.28</span><small>1일 전 · 모의</small></td><td><span class="cell-status is-muted"><i aria-hidden="true"></i>게시 안 함</span></td><td><span class="cell-status is-muted"><i aria-hidden="true"></i>종료</span></td><td><span class="assignee"><i>박</i>박소연</span></td>
</tr>
<tr data-queue-row data-id="DEMO-HJ-105" data-status="draft" data-updated="2026-08-27T15:05:00+09:00" data-title="남산 가까운 가족 주거" aria-selected="false">
<td class="row-cell-select"><button type="button" class="row-select-overlay" data-select-row aria-label="남산 가까운 가족 주거 조사관 열기"></button><span class="row-title" aria-hidden="true"><span class="row-thumb row-thumb-draft"><i></i></span><span><strong>남산 가까운 가족 주거</strong><small>DEMO-HJ-105 · 예시 매물</small></span></span></td>
<td><span>전세</span><small>공동주택</small></td><td><strong>조건 작성 중</strong><small>면적 입력됨</small></td><td><span>확인 전</span><small>모의 서류 없음</small></td><td><span class="cell-status is-blocked"><i aria-hidden="true"></i>5개 차단</span></td><td><span class="cell-status is-muted"><i aria-hidden="true"></i>실행 전</span></td><td><span class="assignee"><i>최</i>최민서</span></td>
</tr>
<tr data-queue-row data-id="DEMO-HJ-106" data-status="review" data-updated="2026-08-26T11:20:00+09:00" data-title="독서당길 안쪽 복층 주거" aria-selected="false">
<td class="row-cell-select"><button type="button" class="row-select-overlay" data-select-row aria-label="독서당길 안쪽 복층 주거 조사관 열기"></button><span class="row-title" aria-hidden="true"><span class="row-thumb row-thumb-review"><i></i></span><span><strong>독서당길 안쪽 복층 주거</strong><small>DEMO-HJ-106 · 예시 매물</small></span></span></td>
<td><span>매매</span><small>공동주택</small></td><td><strong>41억 원</strong><small>전용 151.20㎡</small></td><td><span>2026.08.25</span><small>4일 전 · 모의</small></td><td><span class="cell-status is-warn"><i aria-hidden="true"></i>동의 대기</span></td><td><span class="cell-status is-blocked"><i aria-hidden="true"></i>실행 실패</span></td><td><span class="assignee"><i>최</i>최민서</span></td>
</tr>
</tbody>
</table>
</div>
<div class="queue-loading" hidden data-queue-loading role="status">
<span></span><span></span><span></span><p>대기열을 불러오는 중입니다.</p>
</div>
<div class="queue-empty" hidden data-queue-empty>
<span aria-hidden="true">○</span><strong data-empty-title>현재 조건에 맞는 예시가 없습니다.</strong><p data-empty-copy>필터를 초기화하면 전체 대기열을 다시 볼 수 있습니다.</p><button type="button" class="button button-ghost" data-reset-filter>필터 초기화</button>
</div>
</section>
<div class="inspector-backdrop" data-inspector-backdrop hidden></div>
<aside class="inspector is-open" data-inspector aria-labelledby="inspector-title">
<header class="inspector-header">
<div><span class="status-badge status-warn" data-inspector-status>검수 필요</span><small data-inspector-id>DEMO-HJ-101 · 예시 매물</small><h2 id="inspector-title" data-inspector-title>한남 생활권 정원 주거</h2></div>
<button type="button" class="inspector-close" data-close-inspector aria-label="조사관과 선택 행 닫기">×</button>
</header>
<div class="inspector-media">
<img src="../assets/hannam-interior-720.webp" width="720" height="480" alt="큰 창과 목재 수납이 있는 거실의 연출 이미지" data-inspector-image>
<span>연출 이미지 · 실제 매물 사진 아님</span>
</div>
<div class="channel-gates" id="office-gate">
<button type="button" class="channel-gate is-pass" data-open-preview><span><i aria-hidden="true">✓</i><strong>쇼케이스</strong></span><small>미리보기 가능</small></button>
<button type="button" class="channel-gate is-blocked" data-block-publish><span><i aria-hidden="true">!</i><strong>실제 광고</strong></span><small>영구 차단 · 7개 이유</small></button>
</div>
<div class="inspector-tabs" role="tablist" aria-label="선택 매물 검수 항목">
<button type="button" role="tab" aria-selected="true" aria-controls="inspector-basic" id="tab-basic" data-tab="basic">기본</button>
<button type="button" role="tab" aria-selected="false" aria-controls="inspector-legal" id="tab-legal" tabindex="-1" data-tab="legal">표시·광고 <span>2</span></button>
<button type="button" role="tab" aria-selected="false" aria-controls="inspector-docs" id="tab-docs" tabindex="-1" data-tab="docs">서류</button>
<button type="button" role="tab" aria-selected="false" aria-controls="inspector-ai" id="tab-ai" tabindex="-1" data-tab="ai">AI</button>
<button type="button" role="tab" aria-selected="false" aria-controls="inspector-inquiries" id="tab-inquiries" tabindex="-1" data-tab="inquiries">문의·일정</button>
<button type="button" role="tab" aria-selected="false" aria-controls="inspector-history" id="tab-history" tabindex="-1" data-tab="history">변경 이력</button>
</div>
<div class="inspector-scroll">
<section class="tab-panel" role="tabpanel" id="inspector-basic" aria-labelledby="tab-basic" data-panel="basic">
<div class="panel-section-heading"><div><span>기본 정보</span><h3>예시 거래 조건</h3></div><button type="button" class="text-button" data-simulate-save>수정 시연</button></div>
<dl class="data-grid" data-basic-grid>
<div><dt>거래 형태</dt><dd>매매</dd></div><div><dt>예시 가격</dt><dd>38억 원</dd></div><div><dt>유형</dt><dd>연립주택</dd></div><div><dt>전용 면적</dt><dd>118.42㎡ · 35.8평</dd></div><div><dt>방과 욕실</dt><dd>방 3 · 욕실 2</dd></div><div><dt>입주 가능</dt><dd>2026.12.20 이후 협의</dd></div>
</dl>
<div class="inline-notice"><span aria-hidden="true">i</span><p><strong>가상 데이터</strong> 가격과 면적은 쇼케이스 동작 검증용이며 실제 거래 조건이 아닙니다.</p></div>
<div class="panel-section-heading"><div><span>담당과 상태</span><h3>현재 업무 흐름</h3></div></div>
<ol class="state-path">
<li class="is-done"><span></span><div><strong>초안</strong><small>2026.08.27 · 예시</small></div></li><li class="is-done"><span></span><div><strong>접수 확인</strong><small>2026.08.28 · 예시</small></div></li><li class="is-current"><span></span><div><strong>검수 필요</strong><small>현재 · 2개 차단</small></div></li><li><span></span><div><strong>게시 준비</strong><small>차단 해제 후</small></div></li>
</ol>
</section>
<section class="tab-panel" role="tabpanel" id="inspector-legal" aria-labelledby="tab-legal" data-panel="legal" hidden>
<div class="panel-section-heading"><div><span>이중 게시 게이트</span><h3>채널마다 다른 통과 조건</h3></div><button type="button" class="text-button" data-focus-missing>첫 누락으로 이동</button></div>
<article class="gate-detail is-pass">
<header><span aria-hidden="true">✓</span><div><strong>쇼케이스 미리보기</strong><small>6 / 6 통과</small></div></header>
<ul><li>상시 데모 표지</li><li>개별 예시 매물 배지</li><li>생성 이미지 출처</li><li>실제 연락 기능 비활성</li><li>정확한 주소와 등록번호 없음</li><li>AI 데모와 사람 검토 전 표시</li></ul>
</article>
<article class="gate-detail is-blocked" id="real-ad-gate">
<header><span aria-hidden="true">!</span><div><strong>실제 광고</strong><small>영구 차단 · 가상 브랜드</small></div></header>
<p>실제 사무소 등록정보와 중개의뢰 관계가 없으므로 이 데모에서 통과시킬 수 없습니다.</p>
<button type="button" class="button button-ghost button-small" data-block-publish>차단 이유 7개 보기</button>
</article>
<div class="check-list" data-legal-checks>
<div class="check-row is-pass"><span aria-hidden="true">✓</span><div><strong>소재지 · 면적 · 가격 · 유형 · 거래 형태</strong><small>쇼케이스 fixture에 구조화됨</small></div><button type="button">보기</button></div>
<div class="check-row is-pass"><span aria-hidden="true">✓</span><div><strong>총층수 · 사용승인일 · 방 · 욕실</strong><small>모의 서류 기준 · 실제 증빙 아님</small></div><button type="button">보기</button></div>
<div class="check-row is-blocked" id="missing-orientation" tabindex="-1"><span aria-hidden="true">!</span><div><strong>방향 판단 근거 미확인</strong><small>남동향 값은 있으나 현장 또는 실제 서류 근거 없음</small></div><button type="button" data-jump-field="missing-orientation">수정 위치</button></div>
<div class="check-row is-blocked"><span aria-hidden="true">!</span><div><strong>실제 중개의뢰 관계 없음</strong><small>가상 매물이므로 실제 광고 불가</small></div><button type="button" data-block-publish>이유</button></div>
<div class="check-row is-warn"><span aria-hidden="true">·</span><div><strong>관리비 포함·별도 항목</strong><small>모의 기준월과 항목을 표시함</small></div><button type="button">보기</button></div>
</div>
</section>
<section class="tab-panel" role="tabpanel" id="inspector-docs" aria-labelledby="tab-docs" data-panel="docs" hidden>
<div class="panel-section-heading"><div><span>모의 서류</span><h3>증빙과 예시를 구분</h3></div></div>
<div class="document-list">
<article><span class="doc-icon" aria-hidden="true">▧</span><div><strong>건축물대장 형식 예시</strong><small>2026.08.27 추가 · 실제 공문서 아님</small></div><span class="status-badge status-demo">모의</span></article>
<article><span class="doc-icon" aria-hidden="true">▧</span><div><strong>의뢰 조건 메모</strong><small>2026.08.27 추가 · 가상 입력</small></div><span class="status-badge status-demo">모의</span></article>
<article class="is-missing"><span class="doc-icon" aria-hidden="true">×</span><div><strong>실제 등기사항</strong><small>실주소와 실제 권리 자료를 연결하지 않음</small></div><span class="status-badge status-blocked">없음</span></article>
<article class="is-missing"><span class="doc-icon" aria-hidden="true">×</span><div><strong>현장 확인 기록</strong><small>소음, 일조, 누수와 마감 상태 확인 안 됨</small></div><span class="status-badge status-warn">대기</span></article>
</div>
<div class="media-provenance"><img src="../assets/hannam-evening-640.webp" width="640" height="360" alt="서울 언덕길의 석재 주거 건물 연출 이미지"><div><span class="status-badge status-demo">생성 이미지</span><strong>실제 매물 증빙으로 사용 불가</strong><p>외관 분위기 전달용 연출 자산입니다. 위치, 조망, 일조와 건물 상태를 증명하지 않습니다.</p></div></div>
</section>
<section class="tab-panel" role="tabpanel" id="inspector-ai" aria-labelledby="tab-ai" data-panel="ai" hidden>
<div class="panel-section-heading"><div><span>조건 비교 규칙 demo-0.3</span><h3>AI 참고 분석 데모</h3></div><span class="status-badge status-warn">사람 검토 전</span></div>
<dl class="run-meta"><div><dt>실행 번호</dt><dd>AIRUN-DEMO-0042</dd></div><div><dt>입력 버전</dt><dd>고객 3 · 매물 7</dd></div><div><dt>실행 방식</dt><dd>결정적 데모</dd></div><div><dt>공개 가능</dt><dd>아니요</dd></div></dl>
<div class="ai-summary"><span>현재 판단</span><strong>조건부 적합</strong><div><small>필수 충족 4 / 5</small><small>충돌 1</small><small>확인 필요 2</small></div></div>
<div class="claim-review">
<article class="is-fact"><header><span>사실</span><strong>예산 상한보다 2억 원 낮음</strong></header><p>가상 의뢰인 가격 입력 · 2026.08.27</p><footer><span>근거 강도 높음</span><button type="button" data-claim-action="approve">확인</button></footer></article>
<article class="is-fact"><header><span>사실</span><strong>전용면적이 희망 범위 안</strong></header><p>쇼케이스용 모의 서류 · 실제 공문서 아님</p><footer><span>근거 강도 높음</span><button type="button" data-claim-action="approve">확인</button></footer></article>
<article class="is-unknown"><header><span>확인 불가</span><strong>광화문 이동 시간</strong></header><p>현재 교통 자료가 연결되지 않아 추정하지 않음</p><footer><span>빈칸 유지</span><button type="button" data-claim-action="keep">유지</button></footer></article>
<article class="is-edited"><header><span>수정 필요</span><strong><s>조용한 주거 환경입니다.</s></strong></header><p>현장 근거가 없어 “소음 수준은 현장 확인이 필요합니다”로 바꿉니다.</p><footer><span>지원되지 않은 표현</span><button type="button" data-claim-action="edit">수정 승인</button></footer></article>
</div>
<button type="button" class="button button-primary inspector-wide-action" data-review-ai>사람 검토 완료 시연</button>
</section>
<section class="tab-panel" role="tabpanel" id="inspector-inquiries" aria-labelledby="tab-inquiries" data-panel="inquiries" hidden>
<div class="panel-section-heading"><div><span>고객 조건 3판</span><h3>김○○ · 예시 고객</h3></div><span class="status-badge status-demo">연락처 마스킹</span></div>
<dl class="data-grid customer-grid"><div><dt>거래 형태</dt><dd>매매</dd></div><div><dt>예산 상한</dt><dd>40억 원 · 예시</dd></div><div><dt>전용 면적</dt><dd>110~130㎡</dd></div><div><dt>주차</dt><dd>2대 필수</dd></div><div><dt>입주 희망</dt><dd>2026.12.15까지</dd></div><div><dt>AI 비교 동의</dt><dd>예시 동의 있음</dd></div></dl>
<div class="consent-note"><span aria-hidden="true">i</span><p>상담 동의와 AI 비교 동의는 예시입니다. 홍보 수신은 거부 상태로 두었습니다.</p></div>
<div class="panel-section-heading"><div><span>일정</span><h3>전화 상담 · 예시</h3></div></div>
<article class="appointment-card"><time datetime="2026-08-29T15:30:00+09:00"><strong>29</strong><span>8월 · 토</span></time><div><strong>오후 3:30~4:00</strong><p>맞춤 조건과 권리 확인 순서 · 외부 알림 없음</p><small>한국 표준시 · 데모 일정</small></div><button type="button" data-reschedule>시간 변경 시연</button></article>
</section>
<section class="tab-panel" role="tabpanel" id="inspector-history" aria-labelledby="tab-history" data-panel="history" hidden>
<div class="panel-section-heading"><div><span>수정 불가 기록</span><h3>변경 이력 8건</h3></div><button type="button" class="text-button" data-simulate-log-failure>실패 상태 보기</button></div>
<ol class="audit-list">
<li><span class="audit-dot is-blocked"></span><div><header><strong>실제 광고 게시 차단</strong><time datetime="2026-08-29T10:42:18+09:00">오늘 10:42</time></header><p>사무소 등록정보와 실제 중개의뢰 관계가 없음</p><small>김유진 · 예시 계정 · 버전 7</small></div></li>
<li><span class="audit-dot"></span><div><header><strong>AI 주장 수정 후 승인</strong><time datetime="2026-08-29T10:31:00+09:00">오늘 10:31</time></header><p>“조용함”을 “현장 확인 필요”로 변경</p><small>김유진 · 예시 계정 · AIRUN-DEMO-0041</small></div></li>
<li><span class="audit-dot"></span><div><header><strong>AI 분석 완료</strong><time datetime="2026-08-29T10:20:00+09:00">오늘 10:20</time></header><p>조건 비교 규칙 demo-0.3 · 입력 버전 고객 3 / 매물 7</p><small>데모 분석기</small></div></li>
<li><span class="audit-dot"></span><div><header><strong>쇼케이스 미리보기 공개</strong><time datetime="2026-08-29T09:54:00+09:00">오늘 09:54</time></header><p>데모 고지와 이미지 출처 6개 항목 통과</p><small>박소연 · 예시 계정</small></div></li>
<li><span class="audit-dot"></span><div><header><strong>모의 서류 추가</strong><time datetime="2026-08-28T17:22:00+09:00">어제 17:22</time></header><p>건축물대장 형식 예시 · 실제 공문서 아님</p><small>김유진 · 예시 계정</small></div></li>
<li><span class="audit-dot"></span><div><header><strong>예시 가격 변경</strong><time datetime="2026-08-28T16:40:00+09:00">어제 16:40</time></header><p>40억 원 → 38억 원 · 가상 의뢰 조건 수정</p><small>김유진 · 예시 계정 · 버전 6 → 7</small></div></li>
<li><span class="audit-dot is-warn"></span><div><header><strong>오래된 자료로 자동 보류</strong><time datetime="2026-08-28T08:00:00+09:00">어제 08:00</time></header><p>DEMO-HJ-103 · 내부 운영 기준일 경과</p><small>데모 일정 작업</small></div></li>
<li><span class="audit-dot is-blocked"></span><div><header><strong>권한 없는 게시 시도 거부</strong><time datetime="2026-08-27T15:16:00+09:00">8월 27일</time></header><p>중개보조 예시 계정은 법정 검수 승인과 게시를 할 수 없음</p><small>권한 검사 · 요청 DEMO-77B</small></div></li>
</ol>
</section>
</div>
<footer class="inspector-actions">
<button type="button" class="button button-ghost" data-focus-missing>누락 항목 보기</button>
<button type="button" class="button button-primary" data-block-publish>실제 광고 게시</button>
</footer>
</aside>
</div>
<section class="role-proof" id="roles" aria-labelledby="roles-title">
<header><div><span>역할에 따라 달라지는 행동</span><h2 id="roles-title">권한 방어선</h2></div><p>버튼을 숨기는 데서 끝내지 않고 게시 시점에 역할, 게이트와 객체 버전을 다시 확인하는 모델입니다.</p></header>
<div class="role-grid"><article><strong>사무소 관리자</strong><p>사무소 연결, 역할, 모든 게시 관리</p><small>감사 로그 수정 불가</small></article><article><strong>공인중개사</strong><p>법정 검수, AI 승인, 게시와 중단</p><small>역할 관리자 변경 불가</small></article><article><strong>중개보조 계정</strong><p>초안, 문의 정리, 일정 등록</p><small>검수 승인과 게시 불가</small></article><article><strong>콘텐츠 편집자</strong><p>설명, 이미지, 미리보기</p><small>가격, 법정 필드와 고객 정보 수정 불가</small></article></div>
</section>
<section class="settings-proof" id="settings" aria-labelledby="settings-title"><h2 id="settings-title">데모 설정 원칙</h2><p>실제 연락, 저장 서버, 광고 채널과 등록정보 연결을 비활성 상태로 유지합니다.</p></section>
</div>
</main>
</div>
<dialog class="admin-dialog" data-block-dialog aria-labelledby="block-dialog-title">
<form method="dialog" class="admin-dialog-shell">
<header><div><span class="dialog-symbol is-blocked" aria-hidden="true">!</span><div><small>PUBLIC_REAL_AD</small><h2 id="block-dialog-title">실제 광고를 게시할 수 없습니다.</h2></div></div><button type="button" data-close-dialog aria-label="게시 차단 안내 닫기">×</button></header>
<div class="admin-dialog-content">
<p>이 화면은 가상 사무소와 합성 예시 매물로 만든 쇼케이스입니다. 아래 항목은 더미값으로 채워 통과시키지 않습니다.</p>
<ol><li><strong>실제 사무소 명칭</strong><span>연결 안 됨</span></li><li><strong>실제 사무소 소재지</strong><span>연결 안 됨</span></li><li><strong>실제 연락처</strong><span>연결 안 됨</span></li><li><strong>실제 등록번호</strong><span>연결 안 됨</span></li><li><strong>개업공인중개사 성명</strong><span>연결 안 됨</span></li><li><strong>실제 중개의뢰 관계</strong><span>없음</span></li><li><strong>실제 거래 가능 상태</strong><span>확인 불가</span></li></ol>
<div class="inline-notice"><span aria-hidden="true">i</span><p>쇼케이스 미리보기 채널은 데모 고지와 생성 이미지 출처를 갖춰 별도로 열 수 있습니다.</p></div>
</div>
<footer><button type="submit" class="button button-primary" value="close">확인</button></footer>
</form>
</dialog>
<dialog class="admin-dialog preview-dialog" data-preview-dialog aria-labelledby="preview-dialog-title">
<form method="dialog" class="admin-dialog-shell">
<header><div><span class="dialog-symbol is-pass" aria-hidden="true">✓</span><div><small>SHOWCASE</small><h2 id="preview-dialog-title">미리보기 채널은 열 수 있습니다.</h2></div></div><button type="button" data-close-dialog aria-label="쇼케이스 미리보기 안내 닫기">×</button></header>
<div class="admin-dialog-content"><p>상시 데모 고지, 개별 예시 배지, 생성 이미지 출처, 연락 기능 비활성, 실제 주소와 등록번호 없음, AI 데모 표시를 확인했습니다.</p><a class="button button-primary" href="../#listing-detail">공개 상세 보기</a></div>
</form>
</dialog>
<noscript>
<section class="admin-noscript"><strong>읽기 전용 화면입니다.</strong><p>자바스크립트가 없어 필터, 정렬, 선택, 탭과 대화상자를 쓸 수 없습니다. 기본 예시 대기열과 선택 매물의 정보는 계속 읽을 수 있습니다.</p></section>
</noscript>
<div class="live-region" role="status" aria-live="polite" hidden data-admin-live></div>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View file

@ -0,0 +1,225 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#f3f0e8">
<meta name="description" content="서울 한남 생활권의 집을 예산, 면적, 입주, 주차 조건으로 비교하고 AI 브리핑 뒤 공인중개사가 사실을 다시 확인하는 스마트 중개 디자인 쇼케이스입니다.">
<meta property="og:title" content="한남자리 — 내 기준에 맞는 집부터">
<meta property="og:description" content="조건을 정리하고 후보를 비교한 뒤 공인중개사가 확인하는 개인 주거 브리핑 데스크">
<meta property="og:image" content="./og.png">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="한남자리 — 내 기준에 맞는 집부터">
<meta name="twitter:description" content="서울의 집을 내 조건으로 대조하는 개인 주거 브리핑 데스크">
<meta name="twitter:image" content="./og.png">
<title>한남자리 — 내 기준에 맞는 집부터</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='32' fill='%23121311'/%3E%3Ctext x='32' y='39' text-anchor='middle' font-family='Arial' font-size='19' font-weight='700' fill='%23f3f0e8'%3EHJ%3C/text%3E%3C/svg%3E">
<link rel="preload" href="./assets/hannam-evening-1600.webp" as="image" type="image/webp" media="(min-width: 961px)">
<link rel="stylesheet" href="./styles/tokens.css?v=20260829d">
<link rel="stylesheet" href="./styles/landing.css?v=20260829d">
<script src="./scripts/landing.js?v=20260829d" defer></script>
</head>
<body>
<a class="skip-link" href="#main-content">본문 바로가기</a>
<div class="demo-strip" role="region" aria-label="쇼케이스 고지">디자인 쇼케이스 · 가상 브랜드 · 상담은 접수되지 않습니다</div>
<header class="site-header" data-site-header>
<a class="brand-link" href="#top" aria-label="한남자리 처음으로">
<span class="brand-mark" aria-hidden="true">HJ</span>
<span class="brand-lockup"><strong class="brand">한남자리</strong><small>개인 주거 브리핑 데스크</small></span>
</a>
<button class="menu-button" type="button" aria-expanded="false" aria-controls="site-nav" data-menu-button>
<span class="sr-only">메뉴 열기</span><i aria-hidden="true"></i><i aria-hidden="true"></i>
</button>
<nav class="site-nav" id="site-nav" aria-label="주요 메뉴" data-site-nav>
<a href="#briefing">맞춤 브리핑</a>
<a href="#homes">세 집 비교</a>
<a href="#dossier">선택 서류철</a>
<a href="#handoff">중개사 인계</a>
<a class="nav-admin" href="./admin/">관리 화면</a>
</nav>
</header>
<main id="main-content">
<section class="brief-hero" id="top" aria-labelledby="hero-title">
<div class="masthead">
<p class="eyebrow">한남 생활권 맞춤 중개</p>
<h1 id="hero-title">한남에서, 내 기준에 맞는 집부터.</h1>
<p class="hero-copy">예산과 면적, 입주 계획을 먼저 정리합니다. AI가 후보를 비교하고 공인중개사가 다시 확인합니다.</p>
<div class="hero-actions">
<button class="button button-primary" type="button" data-focus-condition>내 조건 분석하기</button>
<a class="text-link" href="#homes">매물 보기 <span aria-hidden="true">→</span></a>
</div>
<dl class="masthead-note">
<div><dt>대조 기준</dt><dd>가격 · 면적 · 입주 · 주차</dd></div>
<div><dt>마지막 판단</dt><dd>공인중개사 확인</dd></div>
</dl>
</div>
<figure class="hero-scene" data-image-frame>
<figcaption>연출 이미지</figcaption>
<picture>
<source media="(max-width: 640px)" srcset="./assets/hannam-evening-mobile.webp">
<source media="(max-width: 960px)" srcset="./assets/hannam-evening-960.webp">
<img src="./assets/hannam-evening-1600.webp" width="1600" height="1000" alt="서울 언덕 생활권의 현대 주거를 표현한 이미지" data-fallback-image>
</picture>
<div class="image-fallback" hidden data-image-fallback role="img" aria-label="주거 연출 이미지를 불러오지 못했습니다"><span>이미지를 불러오지 못했습니다</span></div>
</figure>
<form class="brief-dock" data-condition-form aria-describedby="condition-note">
<div class="dock-heading"><span>내 기준</span><strong>먼저 다섯 가지만 정리해 보세요.</strong></div>
<label class="dock-field"><span>거래 형태</span><select name="trade" data-trade-input><option value="sale">매매</option><option value="jeonse">전세</option><option value="monthly">월세</option></select></label>
<label class="dock-field"><span>예산 상한</span><span class="input-suffix"><input name="budget" type="number" min="1" max="100" step="1" value="40" inputmode="decimal"><i>억 원</i></span></label>
<label class="dock-field"><span>최소 전용면적</span><span class="input-suffix"><input name="minArea" type="number" min="30" max="300" step="1" value="110" inputmode="decimal"><i>㎡</i></span></label>
<label class="dock-field"><span>입주 희망일</span><input name="moveIn" type="date" value="2026-12-20"></label>
<label class="dock-field"><span>필요 주차</span><select name="parking"><option value="0">상관없음</option><option value="1">1대 이상</option><option value="2" selected>2대 이상</option><option value="3">3대 이상</option></select></label>
<div class="dock-actions"><button class="button button-primary" type="submit">분석 결과 보기</button><button class="save-button" type="button" data-save-condition>이 브라우저에 저장</button></div>
<p class="condition-note" id="condition-note">입력은 전송되지 않으며, 저장을 선택하면 이 브라우저에만 보관됩니다.</p>
</form>
</section>
<section class="briefing-section" id="briefing" aria-labelledby="briefing-title">
<header class="section-heading briefing-heading">
<p class="eyebrow">내 조건 브리핑</p>
<h2 id="briefing-title">맞는 이유와<br>조정할 지점을 나눕니다.</h2>
<p>화면에 입력한 값과 후보 정보를 이 브라우저에서 대조합니다.</p>
</header>
<div class="briefing-sheet" data-analysis-result aria-live="polite" aria-atomic="false">
<header><span>예시 AI 브리핑 · 모의 조건 비교 · 공인중개사 확인 전</span><strong data-analysis-home>아직 분석하지 않았습니다</strong></header>
<div class="briefing-empty" data-analysis-empty>
<span aria-hidden="true">01</span>
<div><h3>내 조건을 먼저 알려 주세요.</h3><p>분석을 실행하면 세 후보 가운데 조건과 가까운 집을 고르고, 이유를 세 묶음으로 정리합니다.</p></div>
<button class="button button-light" type="button" data-focus-condition>조건 입력으로 이동</button>
</div>
<div class="briefing-groups" data-analysis-groups hidden>
<section><span class="group-number">01</span><h3>조건과 맞는 점</h3><ul data-fit-list></ul></section>
<section><span class="group-number">02</span><h3>조정이 필요한 점</h3><ul data-adjust-list></ul></section>
<section><span class="group-number">03</span><h3>중개사와 확인할 점</h3><ul data-check-list></ul></section>
</div>
</div>
</section>
<section class="compare-section" id="homes" aria-labelledby="compare-title">
<header class="section-heading compare-heading">
<div><p class="eyebrow">예시 매물 3곳</p><h2 id="compare-title">서울의 집을<br>한 장씩 대조합니다.</h2></div>
<p>후보를 바꾸면 가운데 장면과 오른쪽 다섯 조건이 함께 바뀝니다.</p>
<div class="unit-switches" role="group" aria-label="표시 단위">
<div role="group" aria-label="면적 단위"><button type="button" aria-pressed="true" data-area-unit="m2">㎡</button><button type="button" aria-pressed="false" data-area-unit="pyeong">평</button></div>
<div role="group" aria-label="가격 표시"><button type="button" aria-pressed="true" data-price-unit="total">총액</button><button type="button" aria-pressed="false" data-price-unit="unit">면적 단가</button></div>
</div>
</header>
<div class="compare-desk">
<div class="candidate-rail" data-candidate-rail role="group" aria-label="비교할 집 선택">
<div class="candidate-option is-selected"><button type="button" class="candidate-button" aria-label="정원형 연립주택 선택" aria-pressed="true" data-select-listing="home-1"></button><div class="candidate-content" aria-hidden="true"><span>01</span><strong>정원형 연립주택</strong><small>매매 · 118.42㎡</small></div></div>
<div class="candidate-option"><button type="button" class="candidate-button" aria-label="복층형 공동주택 선택" aria-pressed="false" data-select-listing="home-2"></button><div class="candidate-content" aria-hidden="true"><span>02</span><strong>복층형 공동주택</strong><small>전세 · 142.80㎡</small></div></div>
<div class="candidate-option"><button type="button" class="candidate-button" aria-label="테라스형 다세대주택 선택" aria-pressed="false" data-select-listing="home-3"></button><div class="candidate-content" aria-hidden="true"><span>03</span><strong>테라스형 다세대주택</strong><small>월세 · 140.84㎡</small></div></div>
</div>
<figure class="candidate-scene" data-image-frame>
<figcaption>연출 이미지</figcaption>
<img src="./assets/hannam-interior-1200.webp" width="1200" height="800" loading="lazy" alt="큰 창과 목재 수납이 있는 거실을 표현한 이미지" data-compare-image data-fallback-image>
<div class="scene-caption"><span data-compare-index>01</span><div><strong data-compare-name>정원형 연립주택</strong><small data-compare-life>한강진역 북쪽 생활권</small></div></div>
<div class="image-fallback" hidden data-image-fallback role="img" aria-label="후보 주거 연출 이미지를 불러오지 못했습니다"><span>이미지를 불러오지 못했습니다</span></div>
</figure>
<dl class="condition-matrix" data-condition-matrix>
<div><dt>거래</dt><dd data-matrix-trade>매매</dd><dd class="matrix-note" data-matrix-trade-note>분석 후 거래 조건 대조</dd></div>
<div><dt>가격</dt><dd data-matrix-price>38억 원</dd><dd class="matrix-note" data-matrix-price-note>분석 후 예산과 대조</dd></div>
<div><dt>전용면적</dt><dd data-matrix-area>118.42㎡</dd><dd class="matrix-note" data-matrix-area-note>분석 후 면적과 대조</dd></div>
<div><dt>주차</dt><dd data-matrix-parking>2대</dd><dd class="matrix-note" data-matrix-parking-note>분석 후 주차와 대조</dd></div>
<div><dt>입주</dt><dd data-matrix-move>2026.12.20 이후</dd><dd class="matrix-note" data-matrix-move-note>분석 후 입주일과 대조</dd></div>
</dl>
</div>
</section>
<section class="dossier-section" id="dossier" aria-labelledby="dossier-title">
<header class="section-heading dossier-heading">
<p class="eyebrow">선택한 예시 집 검토 서류철</p>
<h2 id="dossier-title" data-dossier-title>정원형 연립주택</h2>
<p><strong data-dossier-life>한강진역 북쪽 생활권</strong><span>거리·소요 시간 미제공</span></p>
</header>
<div class="dossier-shell">
<div class="dossier-tabs" role="tablist" aria-label="선택한 집 검토 항목">
<button type="button" role="tab" aria-selected="true" aria-controls="dossier-basic" id="dossier-tab-basic" data-dossier-tab="basic">기본</button>
<button type="button" role="tab" aria-selected="false" aria-controls="dossier-price" id="dossier-tab-price" tabindex="-1" data-dossier-tab="price">가격</button>
<button type="button" role="tab" aria-selected="false" aria-controls="dossier-rights" id="dossier-tab-rights" tabindex="-1" data-dossier-tab="rights">권리</button>
<button type="button" role="tab" aria-selected="false" aria-controls="dossier-location" id="dossier-tab-location" tabindex="-1" data-dossier-tab="location">입지 확인</button>
<button type="button" role="tab" aria-selected="false" aria-controls="dossier-facility" id="dossier-tab-facility" tabindex="-1" data-dossier-tab="facility">시설·관리</button>
</div>
<div class="dossier-pages">
<section class="dossier-panel" role="tabpanel" id="dossier-basic" aria-labelledby="dossier-tab-basic" data-dossier-panel="basic">
<div class="image-filmstrip">
<figure data-image-frame><figcaption>연출 이미지</figcaption><img src="./assets/hannam-evening-960.webp" width="960" height="540" loading="lazy" alt="서울 언덕 생활권의 주거 외관을 표현한 이미지" data-fallback-image><div class="image-fallback" hidden data-image-fallback role="img" aria-label="외관 연출 이미지를 불러오지 못했습니다"><span>이미지를 불러오지 못했습니다</span></div></figure>
<figure data-image-frame><figcaption>연출 이미지</figcaption><img src="./assets/hannam-interior-720.webp" width="720" height="480" loading="lazy" alt="큰 창과 목재 수납이 있는 거실을 표현한 이미지" data-fallback-image><div class="image-fallback" hidden data-image-fallback role="img" aria-label="실내 연출 이미지를 불러오지 못했습니다"><span>이미지를 불러오지 못했습니다</span></div></figure>
</div>
<dl class="spec-strip" data-spec-strip>
<div><dt>거래</dt><dd>매매</dd></div><div><dt>가격</dt><dd>38억 원</dd></div><div><dt>전용</dt><dd>118.42㎡</dd></div><div><dt>주차</dt><dd>2대</dd></div><div><dt>입주</dt><dd>2026.12.20 이후</dd></div>
</dl>
<div class="decision-columns">
<section><h3>내 조건과 맞는 점</h3><ul data-dossier-fit><li>브리핑을 실행하면 입력 조건과의 관계를 표시합니다.</li></ul></section>
<section><h3>확인하고 결정할 점</h3><ul data-dossier-risk><li>등기와 권리 관계를 확인해야 합니다.</li><li>소음과 마감 상태는 현장에서 살펴야 합니다.</li><li>입주 가능일은 중개사와 다시 맞춰야 합니다.</li></ul></section>
</div>
</section>
<section class="dossier-panel" role="tabpanel" id="dossier-price" aria-labelledby="dossier-tab-price" data-dossier-panel="price" hidden>
<div class="page-heading"><span>가격 메모</span><h3>입력한 예산과 집의 가격을 따로 봅니다.</h3></div>
<dl class="page-ledger"><div><dt>입력한 예산 상한</dt><dd data-page-budget>40억 원</dd></div><div><dt>집의 거래 조건</dt><dd data-page-price>38억 원</dd></div><div><dt>기준</dt><dd>화면에 표시된 매물 정보</dd></div></dl>
</section>
<section class="dossier-panel" role="tabpanel" id="dossier-rights" aria-labelledby="dossier-tab-rights" data-dossier-panel="rights" hidden>
<div class="page-heading"><span>권리 확인</span><h3>계약 전 공인중개사가 다시 볼 항목입니다.</h3></div>
<ul class="review-list"><li><span>01</span><div><strong>등기와 소유 관계</strong><p>서류를 통해 현재 권리 상태를 확인합니다.</p></div></li><li><span>02</span><div><strong>계약 조건과 특약</strong><p>거래 형태에 맞는 보호 조건을 검토합니다.</p></div></li><li><span>03</span><div><strong>현재 거래 가능 상태</strong><p>상담 시점에 다시 확인합니다.</p></div></li></ul>
</section>
<section class="dossier-panel" role="tabpanel" id="dossier-location" aria-labelledby="dossier-tab-location" data-dossier-panel="location" hidden>
<div class="page-heading"><span>입지 확인</span><h3 data-location-heading>한강진역 북쪽 생활권을 직접 살핍니다.</h3></div>
<div class="location-memo"><p>역과 생활 편의 시설의 이용 방식은 현장에서 확인합니다.</p><p>출퇴근 경로는 실제 출발지와 시간대에 맞춰 다시 봅니다.</p><p>이미지의 배경은 조망이나 채광 판단에 사용하지 않습니다.</p></div>
</section>
<section class="dossier-panel" role="tabpanel" id="dossier-facility" aria-labelledby="dossier-tab-facility" data-dossier-panel="facility" hidden>
<div class="page-heading"><span>시설·관리</span><h3>생활 비용과 이용 조건을 함께 봅니다.</h3></div>
<dl class="page-ledger"><div><dt>관리비</dt><dd data-page-fee>월 48만 원</dd></div><div><dt>포함 항목</dt><dd data-page-included>공용 관리, 청소, 승강기</dd></div><div><dt>별도 항목</dt><dd data-page-excluded>전기, 가스, 수도</dd></div><div><dt>방과 욕실</dt><dd data-page-rooms>방 3 · 욕실 2</dd></div></dl>
</section>
</div>
</div>
</section>
<section class="handoff-section" id="handoff" aria-labelledby="handoff-title">
<header><p class="eyebrow">사람에게 넘기는 마감</p><h2 id="handoff-title">AI가 좁히고,<br>공인중개사가 확인합니다.</h2></header>
<ol class="handoff-steps">
<li><span>01</span><div><strong>조건 정리</strong><p>가격, 면적, 입주, 주차를 한 장에 모읍니다.</p></div></li>
<li><span>02</span><div><strong>후보 비교</strong><p>서로 다른 집의 차이를 같은 기준으로 대조합니다.</p></div></li>
<li><span>03</span><div><strong>권리·현장 확인</strong><p>서류와 현장에서 확인한 뒤 최종 판단합니다.</p></div></li>
</ol>
<button class="button button-light handoff-cta" type="button" data-open-consult>상담 흐름 미리보기</button>
</section>
</main>
<footer class="site-footer">
<div><strong>한남자리</strong><span>개인 주거 브리핑 데스크</span></div>
<p>한남자리는 가상 브랜드이며 등록 공인중개사무소나 거래 서비스를 제공하지 않습니다.</p>
<nav aria-label="보조 메뉴"><a href="#top">처음으로</a><a href="./admin/">관리 화면</a></nav>
</footer>
<dialog class="consult-dialog" data-consult-dialog aria-labelledby="consult-title">
<form method="dialog" class="dialog-shell" data-consult-form>
<header><div><span>상담 순서 살펴보기</span><h2 id="consult-title">중개사에게 무엇을 넘길까요?</h2></div><button class="dialog-close" type="button" aria-label="상담 흐름 닫기" data-close-consult>×</button></header>
<p class="dialog-privacy">연락처를 받거나 입력 내용을 저장하지 않습니다.</p>
<div class="dialog-content" data-consult-content>
<p><strong data-consult-home>정원형 연립주택</strong>을 기준으로 준비할 항목을 골라 보세요.</p>
<label><input type="checkbox" name="agenda" value="rights" checked> 권리 관계와 계약 조건</label>
<label><input type="checkbox" name="agenda" value="visit" checked> 현장 방문 때 볼 항목</label>
<label><input type="checkbox" name="agenda" value="move"> 입주 가능일 조율</label>
</div>
<footer><button class="button button-primary" type="button" data-finish-consult>다음 순서 보기</button></footer>
</form>
</dialog>
<div class="live-region" role="status" aria-live="polite" data-live-region hidden></div>
<noscript><div class="no-script"><strong>자바스크립트 없이 보는 중입니다.</strong><p>집 정보는 읽을 수 있지만 조건 비교, 후보 변경과 상담 미리보기는 작동하지 않습니다.</p></div></noscript>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

View file

@ -0,0 +1,608 @@
(() => {
"use strict";
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => Array.from(root.querySelectorAll(selector));
const listingDetails = {
"DEMO-HJ-101": {
title: "한남 생활권 정원 주거",
status: "검수 필요",
statusClass: "status-warn",
trade: "매매",
type: "연립주택",
price: "38억 원",
area: "118.42㎡ · 35.8평",
rooms: "방 3 · 욕실 2",
moveIn: "2026.12.20 이후 협의",
image: "../assets/hannam-interior-720.webp",
imageAlt: "큰 창과 목재 수납이 있는 거실의 연출 이미지",
blockers: "2개 차단",
ai: "사람 검토 전"
},
"DEMO-HJ-102": {
title: "한강 가까운 복층 주거",
status: "쇼케이스 공개",
statusClass: "status-pass",
trade: "전세",
type: "공동주택",
price: "전세 22억 원",
area: "142.80㎡ · 43.2평",
rooms: "방 4 · 욕실 2",
moveIn: "2026.11.30 이후 협의",
image: "../assets/hannam-evening-640.webp",
imageAlt: "서울 언덕길의 석재 주거 건물 연출 이미지",
blockers: "데모 통과",
ai: "수정 후 승인"
},
"DEMO-HJ-103": {
title: "조용한 골목의 테라스 주거",
status: "자료 오래됨",
statusClass: "status-warn",
trade: "월세",
type: "다세대주택",
price: "보증금 5억 · 월 1,200만",
area: "140.84㎡ · 42.6평",
rooms: "방 3 · 욕실 2",
moveIn: "입주 시점 다시 확인",
image: "../assets/hannam-evening-640.webp",
imageAlt: "서울 언덕길의 석재 주거 건물 연출 이미지",
blockers: "다시 확인",
ai: "입력 변경으로 오래됨"
},
"DEMO-HJ-104": {
title: "언덕 위 작은 정원 주거",
status: "계약 완료",
statusClass: "status-demo",
trade: "매매",
type: "단독주택",
price: "상담 종료",
area: "예시 공개 종료",
rooms: "공개 목록 제외",
moveIn: "해당 없음",
image: "../assets/hannam-evening-640.webp",
imageAlt: "서울 언덕길의 석재 주거 건물 연출 이미지",
blockers: "게시 안 함",
ai: "종료"
},
"DEMO-HJ-105": {
title: "남산 가까운 가족 주거",
status: "초안",
statusClass: "status-demo",
trade: "전세",
type: "공동주택",
price: "조건 작성 중",
area: "132.00㎡ · 39.9평",
rooms: "입력 전",
moveIn: "입력 전",
image: "../assets/hannam-interior-720.webp",
imageAlt: "큰 창과 목재 수납이 있는 거실의 연출 이미지",
blockers: "5개 차단",
ai: "실행 전"
},
"DEMO-HJ-106": {
title: "독서당길 안쪽 복층 주거",
status: "검수 필요",
statusClass: "status-warn",
trade: "매매",
type: "공동주택",
price: "41억 원",
area: "151.20㎡ · 45.7평",
rooms: "방 4 · 욕실 3",
moveIn: "2027.01 이후 협의",
image: "../assets/hannam-evening-640.webp",
imageAlt: "서울 언덕길의 석재 주거 건물 연출 이미지",
blockers: "동의 대기",
ai: "직전 실행 실패"
}
};
const state = {
selectedId: "DEMO-HJ-101",
mode: "normal",
toastTimer: null,
draftCreated: false
};
const queuePanel = $("[data-queue-panel]");
const queueBody = $("[data-queue-body]");
const inspector = $("[data-inspector]");
const workbench = $(".admin-workbench");
const inspectorBackdrop = $("[data-inspector-backdrop]");
const sidebar = $("[data-sidebar]");
const sidebarBackdrop = $("[data-sidebar-backdrop]");
const announce = (message) => {
const region = $("[data-admin-live]");
if (!region) return;
region.textContent = message;
region.hidden = false;
window.clearTimeout(state.toastTimer);
state.toastTimer = window.setTimeout(() => {
region.hidden = true;
region.textContent = "";
}, 3600);
};
const showBanner = (title, copy, kind = "warn") => {
const banner = $("[data-system-banner]");
if (!banner) return;
banner.classList.remove("is-error", "is-info");
if (kind === "error") banner.classList.add("is-error");
if (kind === "info") banner.classList.add("is-info");
$("[data-banner-title]", banner).textContent = title;
$("[data-banner-copy]", banner).textContent = copy;
banner.hidden = false;
};
const hideBanner = () => {
const banner = $("[data-system-banner]");
if (banner) banner.hidden = true;
};
const getRows = () => $$('[data-queue-row]', queueBody);
const isCompactInspector = () => window.matchMedia("(max-width: 76rem)").matches;
const updateInspectorBackdrop = () => {
if (!inspectorBackdrop) return;
inspectorBackdrop.hidden = !(isCompactInspector() && inspector?.classList.contains("is-open"));
};
const openInspector = (id, focus = false) => {
const detail = listingDetails[id] || listingDetails["DEMO-HJ-101"];
state.selectedId = id;
getRows().forEach((row) => {
const active = row.dataset.id === id;
row.classList.toggle("is-selected", active);
row.setAttribute("aria-selected", String(active));
});
if (!inspector) return;
inspector.classList.add("is-open");
workbench?.classList.remove("is-inspector-closed");
$("[data-inspector-id]", inspector).textContent = `${id} · 예시 매물`;
$("[data-inspector-title]", inspector).textContent = detail.title;
const status = $("[data-inspector-status]", inspector);
status.textContent = detail.status;
status.className = `status-badge ${detail.statusClass}`;
const image = $("[data-inspector-image]", inspector);
if (image) {
image.hidden = false;
image.src = detail.image;
image.alt = detail.imageAlt;
image.parentElement?.classList.remove("is-image-error");
}
const basicGrid = $("[data-basic-grid]", inspector);
if (basicGrid) {
const pairs = [
["거래 형태", detail.trade],
["예시 가격", detail.price],
["유형", detail.type],
["전용 면적", detail.area],
["방과 욕실", detail.rooms],
["입주 가능", detail.moveIn]
];
basicGrid.replaceChildren(...pairs.map(([label, value]) => {
const wrapper = document.createElement("div");
const term = document.createElement("dt");
const description = document.createElement("dd");
term.textContent = label;
description.textContent = value;
wrapper.append(term, description);
return wrapper;
}));
}
updateInspectorBackdrop();
if (focus) $("[data-close-inspector]", inspector)?.focus();
announce(`${detail.title} 조사관을 열었습니다. ${detail.status} 상태입니다.`);
};
const closeInspector = ({ restoreFocus = false } = {}) => {
if (!inspector?.classList.contains("is-open")) return;
const previousId = state.selectedId;
inspector.classList.remove("is-open");
workbench?.classList.add("is-inspector-closed");
getRows().forEach((row) => {
row.classList.remove("is-selected");
row.setAttribute("aria-selected", "false");
});
if (inspectorBackdrop) inspectorBackdrop.hidden = true;
state.selectedId = null;
if (restoreFocus && previousId) {
$(`[data-queue-row][data-id="${previousId}"] [data-select-row]`)?.focus();
}
announce("조사관과 선택 행을 함께 닫았습니다.");
};
const activateTab = (name, focus = false) => {
$$('[role="tab"]', inspector).forEach((tab) => {
const active = tab.dataset.tab === name;
tab.setAttribute("aria-selected", String(active));
tab.tabIndex = active ? 0 : -1;
if (active && focus) tab.focus();
});
$$('[data-panel]', inspector).forEach((panel) => {
panel.hidden = panel.dataset.panel !== name;
});
};
const focusMissing = () => {
if (!state.selectedId) openInspector("DEMO-HJ-101");
activateTab("legal");
window.requestAnimationFrame(() => {
const missing = $("#missing-orientation");
missing?.scrollIntoView({ block: "center", behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" });
missing?.focus();
announce("첫 누락 항목인 방향 판단 근거로 이동했습니다.");
});
};
const closeSidebar = () => {
sidebar?.classList.remove("is-open");
if (sidebarBackdrop) sidebarBackdrop.hidden = true;
$("[data-open-sidebar]")?.setAttribute("aria-expanded", "false");
};
const openSidebar = () => {
sidebar?.classList.add("is-open");
if (sidebarBackdrop) sidebarBackdrop.hidden = false;
$("[data-open-sidebar]")?.setAttribute("aria-expanded", "true");
$("[data-close-sidebar]")?.focus();
};
const updateResultCount = () => {
const visible = getRows().filter((row) => !row.classList.contains("is-hidden"));
const result = $("[data-result-count]");
if (result) result.textContent = `${visible.length}개 예시`;
const empty = $("[data-queue-empty]");
if (state.mode === "normal") empty.hidden = visible.length !== 0;
return visible.length;
};
const applyFilters = () => {
const search = ($("[data-queue-search]")?.value || "").trim().toLocaleLowerCase("ko-KR");
const status = $("[data-status-filter]")?.value || "all";
getRows().forEach((row) => {
const matchesSearch = !search || row.textContent.toLocaleLowerCase("ko-KR").includes(search);
const matchesStatus = status === "all" || row.dataset.status === status;
row.classList.toggle("is-hidden", !(matchesSearch && matchesStatus));
});
const count = updateResultCount();
const emptyTitle = $("[data-empty-title]");
const emptyCopy = $("[data-empty-copy]");
if (count === 0 && state.mode === "normal") {
emptyTitle.textContent = "현재 조건에 맞는 예시가 없습니다.";
emptyCopy.textContent = "검색어와 필터를 초기화하면 전체 대기열을 다시 볼 수 있습니다.";
}
};
const sortRows = () => {
const sort = $("[data-sort-select]")?.value || "updated-desc";
const rows = getRows();
const compare = {
"updated-desc": (a, b) => b.dataset.updated.localeCompare(a.dataset.updated),
"updated-asc": (a, b) => a.dataset.updated.localeCompare(b.dataset.updated),
"title-asc": (a, b) => a.dataset.title.localeCompare(b.dataset.title, "ko"),
"status-asc": (a, b) => a.dataset.status.localeCompare(b.dataset.status, "ko")
}[sort];
rows.sort(compare).forEach((row) => queueBody.append(row));
announce("대기열 정렬을 바꿨습니다.");
};
const resetModes = () => {
queuePanel?.setAttribute("aria-busy", "false");
const loading = $("[data-queue-loading]");
const empty = $("[data-queue-empty]");
if (loading) loading.hidden = true;
if (empty) empty.hidden = true;
hideBanner();
document.body.classList.remove("is-permission-limited", "is-stale-demo");
$$('[data-review-ai], [data-simulate-save], [data-new-draft]').forEach((button) => button.disabled = false);
applyFilters();
};
const simulateState = (mode) => {
state.mode = mode;
resetModes();
if (mode === "loading") {
queuePanel?.setAttribute("aria-busy", "true");
$("[data-queue-loading]").hidden = false;
announce("대기열을 불러오는 중입니다.");
return;
}
if (mode === "empty") {
const empty = $("[data-queue-empty]");
empty.hidden = false;
$("[data-empty-title]").textContent = "아직 접수된 예시 매물이 없습니다.";
$("[data-empty-copy]").textContent = "새 예시 초안을 만들거나 다른 상태를 시연할 수 있습니다.";
announce("빈 대기열 상태를 시연합니다.");
return;
}
if (mode === "error") {
showBanner("대기열 일부를 불러오지 못했습니다.", "마지막으로 확인한 예시 자료를 유지합니다. 재시도 전에는 게시 상태를 바꿀 수 없습니다.", "error");
announce("불러오기 실패 상태입니다. 마지막 확인 자료를 보여 줍니다.");
return;
}
if (mode === "save-error") {
showBanner("저장 경로를 확인할 수 없습니다.", "입력은 화면에 남지만 저장 성공으로 표시하지 않습니다. 수정 시연을 누르면 실패 처리를 확인할 수 있습니다.", "error");
announce("저장 실패 상태를 시연합니다.");
return;
}
if (mode === "stale") {
document.body.classList.add("is-stale-demo");
showBanner("선택 매물의 자료가 오래됐습니다.", "가격, 거래 가능 상태와 현장 확인을 다시 보기 전에는 게시 준비로 이동할 수 없습니다.");
if (!state.selectedId) openInspector("DEMO-HJ-103");
const status = $("[data-inspector-status]");
if (status) {
status.textContent = "자료 오래됨";
status.className = "status-badge status-warn";
}
announce("자료 오래됨 상태를 시연합니다.");
return;
}
if (mode === "conflict") {
showBanner("다른 예시 담당자가 먼저 수정했습니다.", "현재 버전 7과 서버 예시 버전 8의 차이를 확인한 뒤 다시 저장해야 합니다.", "error");
announce("버전 충돌 상태를 시연합니다.");
return;
}
if (mode === "permission") {
document.body.classList.add("is-permission-limited");
$$('[data-review-ai], [data-simulate-save], [data-new-draft]').forEach((button) => button.disabled = true);
showBanner("중개보조 예시 권한으로 보는 중입니다.", "초안과 일정은 정리할 수 있지만 법정 검수 승인, AI 최종 승인과 게시를 할 수 없습니다.", "info");
announce("권한 제한 상태를 시연합니다.");
return;
}
announce("정상 데모 상태로 돌아왔습니다.");
};
const openDialog = (dialog) => {
if (!dialog) return;
dialog.showModal();
document.body.classList.add("is-locked");
};
const closeDialog = (dialog) => {
if (dialog?.open) dialog.close();
};
const simulateSave = () => {
if (state.mode === "permission") {
announce("현재 예시 권한으로는 매물 정보를 수정할 수 없습니다.");
return;
}
if (state.mode === "conflict") {
showBanner("버전 충돌로 저장하지 않았습니다.", "서버 예시 버전 8을 다시 불러오거나 현재 수정을 별도 초안으로 남겨야 합니다.", "error");
announce("버전 충돌로 저장하지 않았습니다.");
return;
}
if (state.mode === "save-error") {
showBanner("저장에 실패했습니다.", "변경 내용은 화면에 남아 있습니다. 성공 기록과 감사 로그는 만들지 않았습니다.", "error");
announce("저장에 실패했습니다. 성공으로 처리하지 않았습니다.");
return;
}
announce("수정 시연을 마쳤습니다. 서버가 없는 데모라 실제 저장하지 않았습니다.");
};
const createDraft = () => {
if (state.mode === "permission") {
announce("현재 예시 권한으로는 새 초안을 만들 수 없습니다.");
return;
}
if (state.draftCreated) {
openInspector("DEMO-HJ-NEW", true);
return;
}
state.draftCreated = true;
listingDetails["DEMO-HJ-NEW"] = {
title: "제목 없는 새 예시 초안",
status: "초안",
statusClass: "status-demo",
trade: "미선택",
type: "미선택",
price: "입력 전",
area: "입력 전",
rooms: "입력 전",
moveIn: "입력 전",
image: "../assets/hannam-interior-720.webp",
imageAlt: "큰 창과 목재 수납이 있는 거실의 연출 이미지",
blockers: "필수 입력 대기",
ai: "실행 전"
};
const row = document.createElement("tr");
row.dataset.queueRow = "";
row.dataset.id = "DEMO-HJ-NEW";
row.dataset.status = "draft";
row.dataset.updated = new Date().toISOString();
row.dataset.title = "제목 없는 새 예시 초안";
row.setAttribute("aria-selected", "false");
row.innerHTML = `
<td class="row-cell-select"><button type="button" class="row-select-overlay" data-select-row aria-label="제목 없는 새 예시 초안 조사관 열기"></button><span class="row-title" aria-hidden="true"><span class="row-thumb row-thumb-draft"><i></i></span><span><strong>제목 없는 새 예시 초안</strong><small>DEMO-HJ-NEW · 예시 매물</small></span></span></td>
<td><span>미선택</span><small>유형 미선택</small></td><td><strong>입력 전</strong><small>면적 입력 전</small></td><td><span>방금</span><small>데모 초안</small></td><td><span class="cell-status is-blocked"><i aria-hidden="true"></i>입력 대기</span></td><td><span class="cell-status is-muted"><i aria-hidden="true"></i>실행 전</span></td><td><span class="assignee"><i>김</i>김유진</span></td>`;
queueBody.prepend(row);
applyFilters();
openInspector("DEMO-HJ-NEW", true);
announce("새 예시 초안을 만들었습니다. 외부 서버에는 저장하지 않았습니다.");
};
const handleRowSelection = (button) => {
const row = button.closest("[data-queue-row]");
if (row) openInspector(row.dataset.id, true);
};
const initTabs = () => {
const tabs = $$('[role="tab"]', inspector);
tabs.forEach((tab, index) => {
tab.addEventListener("click", () => activateTab(tab.dataset.tab));
tab.addEventListener("keydown", (event) => {
let targetIndex = null;
if (event.key === "ArrowRight") targetIndex = (index + 1) % tabs.length;
if (event.key === "ArrowLeft") targetIndex = (index - 1 + tabs.length) % tabs.length;
if (event.key === "Home") targetIndex = 0;
if (event.key === "End") targetIndex = tabs.length - 1;
if (targetIndex !== null) {
event.preventDefault();
activateTab(tabs[targetIndex].dataset.tab, true);
}
});
});
};
const initDialogs = () => {
const blockDialog = $("[data-block-dialog]");
const previewDialog = $("[data-preview-dialog]");
$$('[data-block-publish]').forEach((button) => button.addEventListener("click", () => openDialog(blockDialog)));
$$('[data-open-preview]').forEach((button) => button.addEventListener("click", () => openDialog(previewDialog)));
$$('[data-close-dialog]').forEach((button) => button.addEventListener("click", () => closeDialog(button.closest("dialog"))));
$$('dialog').forEach((dialog) => {
dialog.addEventListener("click", (event) => {
if (event.target === dialog) closeDialog(dialog);
});
dialog.addEventListener("close", () => {
document.body.classList.remove("is-locked");
closeInspector();
});
dialog.addEventListener("cancel", () => document.body.classList.remove("is-locked"));
});
};
const initImageFallback = () => {
$$('img').forEach((image) => {
const handleError = () => {
image.hidden = true;
image.parentElement?.classList.add("is-image-error");
image.parentElement?.setAttribute("data-image-error", "연출 이미지를 불러오지 못했습니다");
};
image.addEventListener("error", handleError);
if (image.complete && image.naturalWidth === 0) handleError();
});
};
const initGlobalActions = () => {
$("[data-close-inspector]")?.addEventListener("click", () => closeInspector({ restoreFocus: true }));
inspectorBackdrop?.addEventListener("click", () => closeInspector({ restoreFocus: true }));
$("[data-open-sidebar]")?.addEventListener("click", openSidebar);
$("[data-close-sidebar]")?.addEventListener("click", closeSidebar);
sidebarBackdrop?.addEventListener("click", closeSidebar);
$("[data-dismiss-banner]")?.addEventListener("click", hideBanner);
$("[data-reset-filter]")?.addEventListener("click", () => {
$("[data-queue-search]").value = "";
$("[data-status-filter]").value = "all";
state.mode = "normal";
$("[data-state-simulator]").value = "normal";
resetModes();
announce("검색과 필터를 초기화했습니다.");
});
$("[data-queue-search]")?.addEventListener("input", applyFilters);
$("[data-status-filter]")?.addEventListener("change", applyFilters);
$("[data-sort-select]")?.addEventListener("change", sortRows);
$("[data-state-simulator]")?.addEventListener("change", (event) => simulateState(event.target.value));
$$('[data-focus-missing]').forEach((button) => button.addEventListener("click", focusMissing));
$$('[data-simulate-save]').forEach((button) => button.addEventListener("click", simulateSave));
$("[data-new-draft]")?.addEventListener("click", createDraft);
queueBody?.addEventListener("click", (event) => {
const button = event.target.closest("[data-select-row]");
if (button) handleRowSelection(button);
});
$("[data-review-ai]")?.addEventListener("click", (event) => {
if (state.mode === "permission") {
announce("현재 예시 권한으로는 AI 결과를 승인할 수 없습니다.");
return;
}
event.currentTarget.textContent = "수정 후 승인됨";
event.currentTarget.disabled = true;
const badge = $("#inspector-ai .status-badge");
if (badge) {
badge.textContent = "수정 후 승인";
badge.className = "status-badge status-pass";
}
announce("AI 참고 분석을 수정 후 승인 상태로 바꿨습니다. 데모 외부에는 게시하지 않았습니다.");
});
$$('[data-claim-action]').forEach((button) => button.addEventListener("click", () => {
button.textContent = button.dataset.claimAction === "edit" ? "수정 반영" : "확인됨";
button.disabled = true;
announce("주장 검토 상태를 데모 안에서만 바꿨습니다.");
}));
$("[data-reschedule]")?.addEventListener("click", (event) => {
const card = event.currentTarget.closest(".appointment-card");
const time = $("div > strong", card);
time.textContent = time.textContent.includes("3:30") ? "오후 5:00~5:30" : "오후 3:30~4:00";
announce("예시 상담 시간을 바꿨습니다. 외부 알림은 보내지 않았습니다.");
});
$("[data-simulate-log-failure]")?.addEventListener("click", () => {
showBanner("감사 로그를 불러오지 못했습니다.", "변경 기록을 확인할 수 없는 동안 저장과 게시를 성공으로 처리하지 않습니다.", "error");
announce("감사 로그 실패 상태입니다. 게시를 중지합니다.");
});
$("[data-command-button]")?.addEventListener("click", () => {
$("[data-queue-search]")?.focus();
announce("매물 검색으로 이동했습니다.");
});
document.addEventListener("keydown", (event) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLocaleLowerCase() === "k") {
event.preventDefault();
$("[data-queue-search]")?.focus();
}
if (event.key === "Escape" && !$('dialog[open]')) {
if (sidebar?.classList.contains("is-open")) closeSidebar();
else closeInspector({ restoreFocus: true });
}
});
document.addEventListener("click", (event) => {
if (!inspector?.classList.contains("is-open") || $('dialog[open]')) return;
const path = event.composedPath();
const matchesPath = (selector) => path.some((node) => node instanceof Element && node.matches(selector));
const keptOpen = matchesPath("[data-inspector], [data-select-row], [data-inspector-trigger], [data-inspector-backdrop], [data-open-preview], [data-block-publish], [data-focus-missing]");
if (!keptOpen && !matchesPath("[data-open-sidebar], [data-sidebar]")) closeInspector();
});
window.matchMedia("(max-width: 76rem)").addEventListener("change", updateInspectorBackdrop);
window.matchMedia("(min-width: 50.01rem)").addEventListener("change", (event) => {
if (event.matches) closeSidebar();
});
$$('.sidebar-nav a[href^="#inspector-"]').forEach((link) => link.addEventListener("click", (event) => {
event.preventDefault();
if (!state.selectedId) openInspector("DEMO-HJ-101");
activateTab(link.getAttribute("href").replace("#inspector-", ""), true);
closeSidebar();
}));
};
const init = () => {
initTabs();
initDialogs();
initImageFallback();
initGlobalActions();
openInspector("DEMO-HJ-101");
updateResultCount();
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init, { once: true });
} else {
init();
}
})();

View file

@ -0,0 +1,590 @@
(() => {
"use strict";
const homes = [
{
id: "home-1",
index: "01",
name: "정원형 연립주택",
trade: "sale",
tradeLabel: "매매",
priceEok: 38,
areaM2: 118.42,
areaPyeong: 35.8,
parking: 2,
moveDate: "2026-12-20",
moveLabel: "2026.12.20 이후 협의",
life: "한강진역 북쪽 생활권",
fee: "월 48만 원",
included: "공용 관리, 청소, 승강기",
excluded: "전기, 가스, 수도",
rooms: "방 3 · 욕실 2",
image: "./assets/hannam-interior-1200.webp",
imageAlt: "큰 창과 목재 수납이 있는 거실을 표현한 이미지"
},
{
id: "home-2",
index: "02",
name: "복층형 공동주택",
trade: "jeonse",
tradeLabel: "전세",
priceEok: 22,
areaM2: 142.8,
areaPyeong: 43.2,
parking: 2,
moveDate: "2027-01-15",
moveLabel: "2027.01.15 이후 협의",
life: "독서당길 남쪽 생활권",
fee: "월 56만 원",
included: "공용 관리, 보안, 승강기",
excluded: "전기, 가스, 수도, 냉난방",
rooms: "방 4 · 욕실 2",
image: "./assets/hannam-evening-1600.webp",
imageAlt: "서울 언덕 생활권의 현대 주거 외관을 표현한 이미지"
},
{
id: "home-3",
index: "03",
name: "테라스형 다세대주택",
trade: "monthly",
tradeLabel: "월세",
priceEok: 5,
monthlyManwon: 1200,
areaM2: 140.84,
areaPyeong: 42.6,
parking: 1,
moveDate: "2026-11-01",
moveLabel: "2026.11.01 이후 협의",
life: "이태원로 동쪽 생활권",
fee: "월 52만 원",
included: "공용 관리, 청소",
excluded: "전기, 가스, 수도, 승강기 점검",
rooms: "방 3 · 욕실 2",
image: "./assets/hannam-interior-1200.webp",
imageAlt: "큰 창과 목재 수납이 있는 거실을 표현한 이미지"
}
];
const STORAGE_KEY = "hannam-jari-conditions-v2";
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => Array.from(root.querySelectorAll(selector));
const state = {
selectedId: "home-1",
areaUnit: "m2",
priceUnit: "total",
analyzed: false,
conditions: null,
toastTimer: null,
consultationComplete: false
};
const form = $("[data-condition-form]");
const dialog = $("[data-consult-dialog]");
const formatNumber = (value, digits = 0) => new Intl.NumberFormat("ko-KR", {
minimumFractionDigits: digits,
maximumFractionDigits: digits
}).format(value);
const getHome = (id = state.selectedId) => homes.find((home) => home.id === id) || homes[0];
const formatArea = (home) => state.areaUnit === "m2"
? `${formatNumber(home.areaM2, 2)}㎡`
: `${formatNumber(home.areaPyeong, 1)}평`;
const unitPriceText = (home) => {
const perPyeong = Math.round(((home.priceEok * 10000) / home.areaPyeong) / 10) * 10;
if (home.trade === "monthly") return `보증금 평당 약 ${formatNumber(perPyeong)}만 · 월 ${formatNumber(home.monthlyManwon)}만`;
return `평당 약 ${formatNumber(perPyeong)}만 원`;
};
const totalPriceText = (home) => home.trade === "monthly"
? `보증금 ${formatNumber(home.priceEok)}억 · 월 ${formatNumber(home.monthlyManwon)}만`
: `${formatNumber(home.priceEok)}억 원`;
const formatPrice = (home) => state.priceUnit === "total" ? totalPriceText(home) : unitPriceText(home);
const readConditions = () => {
const data = new FormData(form);
return {
trade: String(data.get("trade") || "sale"),
budget: Number(data.get("budget") || 0),
minArea: Number(data.get("minArea") || 0),
moveIn: String(data.get("moveIn") || ""),
parking: Number(data.get("parking") || 0)
};
};
const announce = (message) => {
const region = $("[data-live-region]");
if (!region) return;
region.textContent = message;
region.hidden = false;
window.clearTimeout(state.toastTimer);
state.toastTimer = window.setTimeout(() => {
region.hidden = true;
region.textContent = "";
}, 3200);
};
const claim = (title, text, source) => ({ title, text, source });
const evaluateHome = (home, conditions) => {
const fit = [];
const adjust = [];
const check = [];
if (home.trade === conditions.trade) {
fit.push(claim("거래 형태", `${home.tradeLabel} 조건과 같습니다.`, "입력한 조건 · 매물 정보"));
} else {
adjust.push(claim("거래 형태", `입력은 ${tradeLabel(conditions.trade)}, 선택한 집은 ${home.tradeLabel}입니다.`, "입력한 조건 · 매물 정보"));
}
if (home.trade === "monthly") {
if (home.priceEok > conditions.budget) {
adjust.push(claim("보증금 상한", `보증금이 입력한 상한보다 ${formatNumber(home.priceEok - conditions.budget)}억 원 높습니다.`, "입력한 조건 · 매물 정보"));
} else {
adjust.push(claim("월 납부 조건", `보증금은 상한 안쪽이지만 월 ${formatNumber(home.monthlyManwon)}만 원을 별도로 살펴야 합니다.`, "입력한 조건 · 매물 정보"));
}
} else if (home.priceEok <= conditions.budget) {
fit.push(claim("가격 상한", `입력한 상한보다 ${formatNumber(conditions.budget - home.priceEok)}억 원 낮습니다.`, "입력한 조건 · 매물 정보"));
} else {
adjust.push(claim("가격 상한", `입력한 상한보다 ${formatNumber(home.priceEok - conditions.budget)}억 원 높습니다.`, "입력한 조건 · 매물 정보"));
}
if (home.areaM2 >= conditions.minArea) {
fit.push(claim("전용면적", `최소 면적보다 ${formatNumber(home.areaM2 - conditions.minArea, 2)}㎡ 넓습니다.`, "입력한 조건 · 매물 정보"));
} else {
adjust.push(claim("전용면적", `최소 면적보다 ${formatNumber(conditions.minArea - home.areaM2, 2)}㎡ 작습니다.`, "입력한 조건 · 매물 정보"));
}
if (home.parking >= conditions.parking) {
fit.push(claim("주차", `${home.parking}대로 필요한 수를 충족합니다.`, "입력한 조건 · 매물 정보"));
} else {
adjust.push(claim("주차", `필요한 수보다 ${conditions.parking - home.parking}대 부족합니다.`, "입력한 조건 · 매물 정보"));
}
if (!conditions.moveIn) {
adjust.push(claim("입주 희망일", "날짜를 정하면 입주 가능 시점과 대조할 수 있습니다.", "입력한 조건"));
} else if (home.moveDate <= conditions.moveIn) {
fit.push(claim("입주 시점", `${home.moveLabel}으로 희망일 전후 협의가 가능합니다.`, "입력한 조건 · 매물 정보"));
} else {
adjust.push(claim("입주 시점", `${home.moveLabel}으로 입력한 희망일보다 늦습니다.`, "입력한 조건 · 매물 정보"));
}
check.push(claim("권리 관계", "등기와 계약 조건은 서류를 통해 다시 살펴야 합니다.", "공인중개사 확인"));
check.push(claim("현장 상태", "소음, 마감과 채광은 방문해서 확인해야 합니다.", "현장 확인"));
return { fit, adjust, check };
};
const tradeLabel = (trade) => ({ sale: "매매", jeonse: "전세", monthly: "월세" })[trade] || "미선택";
const scoreHome = (home, conditions) => {
let score = home.trade === conditions.trade ? 4 : 0;
if (home.trade === "monthly") score += home.priceEok <= conditions.budget ? 0.5 : 0;
else score += home.priceEok <= conditions.budget ? 1 : 0;
score += home.areaM2 >= conditions.minArea ? 1 : 0;
score += home.parking >= conditions.parking ? 1 : 0;
score += conditions.moveIn && home.moveDate <= conditions.moveIn ? 1 : 0;
return score;
};
const createClaimItem = ({ title, text, source }) => {
const item = document.createElement("li");
const strong = document.createElement("strong");
const span = document.createElement("span");
const small = document.createElement("small");
strong.textContent = title;
span.textContent = text;
small.textContent = source;
item.append(strong, span, small);
return item;
};
const fillClaimList = (selector, items, emptyText) => {
const list = $(selector);
if (!list) return;
const nextItems = items.length ? items : [claim(emptyText, "현재 입력에서 바로 조정할 항목이 없습니다.", "입력한 조건 · 매물 정보")];
list.replaceChildren(...nextItems.map(createClaimItem));
};
const renderBriefing = (home, conditions) => {
const result = evaluateHome(home, conditions);
$("[data-analysis-empty]").hidden = true;
$("[data-analysis-groups]").hidden = false;
$("[data-analysis-home]").textContent = `${home.name} 브리핑`;
fillClaimList("[data-fit-list]", result.fit, "바로 맞는 조건 없음");
fillClaimList("[data-adjust-list]", result.adjust, "바로 조정할 조건 없음");
fillClaimList("[data-check-list]", result.check, "추가 확인 항목 없음");
return result;
};
const resetBriefing = () => {
state.analyzed = false;
state.conditions = null;
$("[data-analysis-empty]").hidden = false;
$("[data-analysis-groups]").hidden = true;
$("[data-analysis-home]").textContent = "아직 분석하지 않았습니다";
};
const setMatrixNote = (selector, text, tone = "") => {
const node = $(selector);
if (!node) return;
node.textContent = text;
node.className = ["matrix-note", tone].filter(Boolean).join(" ");
};
const renderMatrix = (home, conditions = readConditions()) => {
$("[data-matrix-trade]").textContent = home.tradeLabel;
$("[data-matrix-price]").textContent = formatPrice(home);
$("[data-matrix-area]").textContent = formatArea(home);
$("[data-matrix-parking]").textContent = `${home.parking}대`;
$("[data-matrix-move]").textContent = home.moveLabel;
if (!state.analyzed) {
setMatrixNote("[data-matrix-trade-note]", "분석 후 거래 조건 대조");
setMatrixNote("[data-matrix-price-note]", "분석 후 예산과 대조");
setMatrixNote("[data-matrix-area-note]", "분석 후 면적과 대조");
setMatrixNote("[data-matrix-parking-note]", "분석 후 주차와 대조");
setMatrixNote("[data-matrix-move-note]", "분석 후 입주일과 대조");
return;
}
setMatrixNote("[data-matrix-trade-note]", home.trade === conditions.trade ? "입력과 같음" : `${tradeLabel(conditions.trade)}에서 조정 필요`, home.trade === conditions.trade ? "" : "is-adjust");
if (home.trade === "monthly") {
const depositNote = home.priceEok <= conditions.budget ? "보증금은 상한 안쪽 · 월 납부 별도" : `보증금이 상한보다 ${formatNumber(home.priceEok - conditions.budget)}억 높음`;
setMatrixNote("[data-matrix-price-note]", depositNote, "is-check");
} else {
const pricePass = home.priceEok <= conditions.budget;
setMatrixNote("[data-matrix-price-note]", pricePass ? `예산 ${formatNumber(conditions.budget)}억 안쪽` : `예산보다 ${formatNumber(home.priceEok - conditions.budget)}억 높음`, pricePass ? "" : "is-adjust");
}
const areaPass = home.areaM2 >= conditions.minArea;
setMatrixNote("[data-matrix-area-note]", areaPass ? `최소 ${formatNumber(conditions.minArea)}㎡ 충족` : `최소보다 ${formatNumber(conditions.minArea - home.areaM2, 2)}㎡ 작음`, areaPass ? "" : "is-adjust");
const parkingPass = home.parking >= conditions.parking;
setMatrixNote("[data-matrix-parking-note]", parkingPass ? `필요 ${conditions.parking}대 충족` : `${conditions.parking - home.parking}대 부족`, parkingPass ? "" : "is-adjust");
if (!conditions.moveIn) {
setMatrixNote("[data-matrix-move-note]", "희망일 입력 필요", "is-check");
} else {
const movePass = home.moveDate <= conditions.moveIn;
setMatrixNote("[data-matrix-move-note]", movePass ? "희망일 전후 협의 가능" : "희망일보다 늦음", movePass ? "" : "is-adjust");
}
};
const renderCandidateRail = () => {
$$('[data-select-listing]').forEach((button) => {
const home = getHome(button.dataset.selectListing);
const active = home.id === state.selectedId;
button.setAttribute("aria-pressed", String(active));
button.closest(".candidate-option")?.classList.toggle("is-selected", active);
const content = button.nextElementSibling;
const small = content?.querySelector("small");
if (small) small.textContent = `${home.tradeLabel} · ${formatArea(home)}`;
});
};
const renderCompareScene = (home) => {
const image = $("[data-compare-image]");
if (image) {
image.hidden = false;
image.src = home.image;
image.alt = home.imageAlt;
image.parentElement?.classList.remove("is-image-error");
$("[data-image-fallback]", image.parentElement).hidden = true;
}
$("[data-compare-index]").textContent = home.index;
$("[data-compare-name]").textContent = home.name;
$("[data-compare-life]").textContent = home.life;
};
const setSimpleList = (selector, items) => {
const list = $(selector);
if (!list) return;
list.replaceChildren(...items.map((text) => {
const item = document.createElement("li");
item.textContent = text;
return item;
}));
};
const renderSpecs = (home) => {
const root = $("[data-spec-strip]");
if (!root) return;
const pairs = [
["거래", home.tradeLabel],
["가격", formatPrice(home)],
["전용", formatArea(home)],
["주차", `${home.parking}대`],
["입주", home.moveLabel]
];
root.replaceChildren(...pairs.map(([label, value]) => {
const wrapper = document.createElement("div");
const term = document.createElement("dt");
const description = document.createElement("dd");
term.textContent = label;
description.textContent = value;
wrapper.append(term, description);
return wrapper;
}));
};
const renderDossier = (home, conditions = readConditions()) => {
$("[data-dossier-title]").textContent = home.name;
$("[data-dossier-life]").textContent = home.life;
const consultHome = $("[data-consult-home]");
if (consultHome) consultHome.textContent = home.name;
$("[data-location-heading]").textContent = `${home.life}을 직접 살핍니다.`;
$("[data-page-budget]").textContent = `${formatNumber(conditions.budget)}억 원`;
$("[data-page-price]").textContent = totalPriceText(home);
$("[data-page-fee]").textContent = home.fee;
$("[data-page-included]").textContent = home.included;
$("[data-page-excluded]").textContent = home.excluded;
$("[data-page-rooms]").textContent = home.rooms;
renderSpecs(home);
if (!state.analyzed) {
setSimpleList("[data-dossier-fit]", ["브리핑을 실행하면 입력 조건과의 관계를 표시합니다."]);
setSimpleList("[data-dossier-risk]", ["등기와 권리 관계를 확인해야 합니다.", "소음과 마감 상태는 현장에서 살펴야 합니다.", "입주 가능일은 중개사와 다시 맞춰야 합니다."]);
return;
}
const result = evaluateHome(home, conditions);
const fits = result.fit.length ? result.fit.map((item) => item.text) : ["현재 입력과 바로 맞는 항목은 없습니다."];
const risks = [...result.adjust, ...result.check].map((item) => item.text).slice(0, 4);
setSimpleList("[data-dossier-fit]", fits);
setSimpleList("[data-dossier-risk]", risks);
};
const renderSelection = () => {
const home = getHome();
const conditions = state.conditions || readConditions();
renderCandidateRail();
renderCompareScene(home);
renderMatrix(home, conditions);
renderDossier(home, conditions);
if (state.analyzed) renderBriefing(home, conditions);
};
const selectListing = (id, announceChange = true) => {
const home = getHome(id);
state.selectedId = home.id;
renderSelection();
if (announceChange) announce(`${home.name}을 선택했습니다. 비교표와 서류철을 바꿨습니다.`);
};
const runAnalysis = () => {
if (!form.reportValidity()) return;
const conditions = readConditions();
state.conditions = conditions;
state.analyzed = true;
const best = homes.reduce((current, home) => scoreHome(home, conditions) > scoreHome(current, conditions) ? home : current, homes[0]);
state.selectedId = best.id;
renderSelection();
$("#briefing")?.scrollIntoView({ behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" });
announce(`${best.name}을 가장 가까운 후보로 골라 세 묶음의 브리핑을 만들었습니다.`);
};
const markConditionsChanged = () => {
if (!state.analyzed) return;
resetBriefing();
renderSelection();
announce("조건이 바뀌어 이전 브리핑을 비웠습니다. 다시 분석해 주세요.");
};
const saveConditions = () => {
if (!form.reportValidity()) return;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(readConditions()));
announce("현재 조건을 이 브라우저에 저장했습니다.");
} catch {
announce("브라우저 저장소를 사용할 수 없어 조건을 저장하지 않았습니다.");
}
};
const restoreConditions = () => {
try {
const saved = JSON.parse(window.localStorage.getItem(STORAGE_KEY) || "null");
if (!saved || typeof saved !== "object") return;
if (["sale", "jeonse", "monthly"].includes(saved.trade)) form.elements.trade.value = saved.trade;
if (Number.isFinite(saved.budget)) form.elements.budget.value = String(saved.budget);
if (Number.isFinite(saved.minArea)) form.elements.minArea.value = String(saved.minArea);
if (typeof saved.moveIn === "string") form.elements.moveIn.value = saved.moveIn;
if (Number.isFinite(saved.parking)) form.elements.parking.value = String(saved.parking);
} catch {
window.localStorage.removeItem(STORAGE_KEY);
}
};
const focusConditions = () => {
form.scrollIntoView({ behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth", block: "center" });
window.setTimeout(() => form.elements.trade?.focus(), 180);
};
const activateDossierTab = (name, focus = false) => {
$$('[data-dossier-tab]').forEach((tab) => {
const active = tab.dataset.dossierTab === name;
tab.setAttribute("aria-selected", String(active));
tab.tabIndex = active ? 0 : -1;
if (active && focus) tab.focus();
});
$$('[data-dossier-panel]').forEach((panel) => {
panel.hidden = panel.dataset.dossierPanel !== name;
});
};
const initDossierTabs = () => {
const tabs = $$('[data-dossier-tab]');
tabs.forEach((tab, index) => {
tab.addEventListener("click", () => activateDossierTab(tab.dataset.dossierTab));
tab.addEventListener("keydown", (event) => {
let target = null;
if (event.key === "ArrowRight" || event.key === "ArrowDown") target = (index + 1) % tabs.length;
if (event.key === "ArrowLeft" || event.key === "ArrowUp") target = (index - 1 + tabs.length) % tabs.length;
if (event.key === "Home") target = 0;
if (event.key === "End") target = tabs.length - 1;
if (target !== null) {
event.preventDefault();
activateDossierTab(tabs[target].dataset.dossierTab, true);
}
});
});
};
const resetConsultation = () => {
state.consultationComplete = false;
const content = $("[data-consult-content]", dialog);
content.replaceChildren();
const copy = document.createElement("p");
const strong = document.createElement("strong");
strong.dataset.consultHome = "";
strong.textContent = getHome().name;
copy.append(strong, "을 기준으로 준비할 항목을 골라 보세요.");
content.append(copy);
[["rights", "권리 관계와 계약 조건", true], ["visit", "현장 방문 때 볼 항목", true], ["move", "입주 가능일 조율", false]].forEach(([value, label, checked]) => {
const wrapper = document.createElement("label");
const input = document.createElement("input");
input.type = "checkbox";
input.name = "agenda";
input.value = value;
input.checked = checked;
wrapper.append(input, ` ${label}`);
content.append(wrapper);
});
$("[data-finish-consult]", dialog).textContent = "다음 순서 보기";
};
const openConsultation = () => {
resetConsultation();
dialog.showModal();
document.body.classList.add("is-locked");
};
const closeConsultation = () => {
if (dialog.open) dialog.close();
};
const finishConsultation = () => {
if (state.consultationComplete) {
resetConsultation();
return;
}
state.consultationComplete = true;
const content = $("[data-consult-content]", dialog);
const selected = $$('input[name="agenda"]:checked', content).map((input) => input.closest("label")?.textContent.trim()).filter(Boolean);
content.replaceChildren();
const heading = document.createElement("p");
const strong = document.createElement("strong");
strong.textContent = "준비할 순서";
heading.append(strong);
const list = document.createElement("ol");
const items = selected.length ? selected : ["집의 기본 조건 다시 보기"];
items.forEach((text) => {
const item = document.createElement("li");
item.textContent = text;
list.append(item);
});
content.append(heading, list);
$("[data-finish-consult]", dialog).textContent = "처음부터 보기";
};
const initImageFallback = () => {
$$('[data-fallback-image]').forEach((image) => {
const handleError = () => {
image.hidden = true;
const frame = image.closest("[data-image-frame]");
frame?.classList.add("is-image-error");
const fallback = $("[data-image-fallback]", frame);
if (fallback) fallback.hidden = false;
};
image.addEventListener("error", handleError);
if (image.complete && image.naturalWidth === 0) handleError();
});
};
const initMenu = () => {
const button = $("[data-menu-button]");
const nav = $("[data-site-nav]");
const close = () => {
nav.classList.remove("is-open");
button.setAttribute("aria-expanded", "false");
};
button?.addEventListener("click", () => {
const open = nav.classList.toggle("is-open");
button.setAttribute("aria-expanded", String(open));
});
$$("a", nav).forEach((link) => link.addEventListener("click", close));
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") close();
});
};
const init = () => {
restoreConditions();
initMenu();
initDossierTabs();
initImageFallback();
resetBriefing();
renderSelection();
form.addEventListener("submit", (event) => {
event.preventDefault();
runAnalysis();
});
form.addEventListener("input", markConditionsChanged);
form.addEventListener("change", markConditionsChanged);
$$('[data-save-condition]').forEach((button) => button.addEventListener("click", saveConditions));
$$('[data-focus-condition]').forEach((button) => button.addEventListener("click", focusConditions));
$$('[data-select-listing]').forEach((button) => button.addEventListener("click", () => selectListing(button.dataset.selectListing)));
$$('[data-area-unit]').forEach((button) => button.addEventListener("click", () => {
state.areaUnit = button.dataset.areaUnit;
$$('[data-area-unit]').forEach((item) => item.setAttribute("aria-pressed", String(item === button)));
renderSelection();
announce(`면적을 ${state.areaUnit === "m2" ? "제곱미터" : "평"}으로 표시합니다.`);
}));
$$('[data-price-unit]').forEach((button) => button.addEventListener("click", () => {
state.priceUnit = button.dataset.priceUnit;
$$('[data-price-unit]').forEach((item) => item.setAttribute("aria-pressed", String(item === button)));
renderSelection();
announce(`가격을 ${state.priceUnit === "total" ? "총액" : "면적 단가"}으로 표시합니다.`);
}));
$$('[data-open-consult]').forEach((button) => button.addEventListener("click", openConsultation));
$("[data-close-consult]")?.addEventListener("click", closeConsultation);
$("[data-finish-consult]")?.addEventListener("click", finishConsultation);
dialog?.addEventListener("click", (event) => {
if (event.target === dialog) closeConsultation();
});
dialog?.addEventListener("close", () => {
document.body.classList.remove("is-locked");
resetConsultation();
});
dialog?.addEventListener("cancel", () => document.body.classList.remove("is-locked"));
};
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init, { once: true });
else init();
})();

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,312 @@
:root {
color-scheme: light;
--font-sans: "Pretendard Variable", Pretendard, SUIT, "Noto Sans KR", "Apple SD Gothic Neo", system-ui, sans-serif;
--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
--ink-950: #0a0a09;
--ink-900: #121210;
--ink-800: #24231f;
--ink-700: #3e3c35;
--ink-600: #5d5a50;
--ink-500: #69655d;
--paper-50: #f8f7f3;
--paper-100: #f2f0e9;
--paper-200: #e8e4da;
--paper-300: #d8d2c5;
--gold-dark: #6f5420;
--gold: #9b762d;
--gold-light: #c6a15b;
--blue: #345d8c;
--blue-pale: #e7eef6;
--green: #2e684d;
--green-pale: #e5f0e9;
--red: #9d3f34;
--red-pale: #f6e8e5;
--amber: #7f5d18;
--amber-pale: #f4ecd8;
--white: #ffffff;
--line-light: rgba(10, 10, 9, 0.14);
--line-dark: rgba(255, 255, 255, 0.18);
--focus: #2e6ba8;
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.25rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-10: 2.5rem;
--space-12: 3rem;
--space-16: 4rem;
--space-20: 5rem;
--space-24: 6rem;
--space-32: 8rem;
--content: min(100% - 2rem, 88rem);
--reading: 44rem;
--header-height: 4.5rem;
--radius-sm: 0.25rem;
--radius-md: 0.75rem;
--radius-lg: 1.25rem;
--transition: 180ms cubic-bezier(0.22, 1, 0.36, 1);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
text-size-adjust: 100%;
}
body {
margin: 0;
font-family: var(--font-sans);
font-size: 1rem;
line-height: 1.6;
letter-spacing: -0.015em;
color: var(--ink-950);
background: var(--paper-50);
text-rendering: optimizeLegibility;
}
body.is-locked {
overflow: hidden;
}
img,
svg {
display: block;
max-width: 100%;
}
button,
input,
select,
textarea {
font: inherit;
}
button,
select,
input[type="range"],
input[type="checkbox"],
input[type="radio"] {
cursor: pointer;
}
button:disabled,
input:disabled,
select:disabled {
cursor: not-allowed;
}
a {
color: inherit;
}
:focus-visible {
outline: 3px solid var(--focus);
outline-offset: 3px;
}
[hidden] {
display: none !important;
}
.skip-link {
position: fixed;
z-index: 200;
top: 0.75rem;
left: 0.75rem;
padding: 0.75rem 1rem;
color: var(--white);
background: var(--ink-950);
transform: translateY(-180%);
transition: transform var(--transition);
}
.skip-link:focus {
transform: translateY(0);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.demo-strip {
min-height: 2rem;
padding: 0.35rem 1rem;
color: #f5ead5;
background: var(--ink-950);
border-bottom: 1px solid rgba(198, 161, 91, 0.4);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.04em;
text-align: center;
}
.button {
display: inline-flex;
min-height: 2.875rem;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.7rem 1.05rem;
border: 1px solid transparent;
border-radius: var(--radius-sm);
font-weight: 720;
line-height: 1.2;
text-decoration: none;
transition: color var(--transition), background-color var(--transition), border-color var(--transition), transform var(--transition);
}
.button:hover {
transform: translateY(-1px);
}
.button-primary {
color: var(--paper-50);
background: var(--ink-950);
border-color: var(--ink-950);
}
.button-primary:hover {
color: var(--ink-950);
background: var(--gold-light);
border-color: var(--gold-light);
}
.button-light {
color: var(--ink-950);
background: var(--paper-50);
border-color: var(--paper-50);
}
.button-outline {
color: inherit;
background: transparent;
border-color: currentColor;
}
.button-ghost {
color: inherit;
background: transparent;
border-color: var(--line-light);
}
.button-small {
min-height: 2.25rem;
padding: 0.45rem 0.7rem;
font-size: 0.8125rem;
}
.status-badge {
display: inline-flex;
min-height: 1.6rem;
align-items: center;
gap: 0.35rem;
width: max-content;
padding: 0.25rem 0.5rem;
border: 1px solid currentColor;
border-radius: 999px;
font-size: 0.7rem;
font-weight: 760;
line-height: 1;
letter-spacing: 0.01em;
}
.status-badge::before {
width: 0.38rem;
height: 0.38rem;
border-radius: 50%;
background: currentColor;
content: "";
}
.status-demo {
color: var(--gold-dark);
background: #f7efdc;
}
.status-pass {
color: var(--green);
background: var(--green-pale);
}
.status-warn {
color: var(--amber);
background: var(--amber-pale);
}
.status-blocked {
color: var(--red);
background: var(--red-pale);
}
.field-label {
display: block;
margin-bottom: 0.45rem;
color: var(--ink-700);
font-size: 0.75rem;
font-weight: 760;
}
.text-input,
.select-input {
width: 100%;
min-height: 2.75rem;
padding: 0.65rem 0.75rem;
color: var(--ink-950);
background: var(--white);
border: 1px solid var(--line-light);
border-radius: var(--radius-sm);
}
.live-region {
position: fixed;
z-index: 300;
right: 1rem;
bottom: 1rem;
max-width: min(24rem, calc(100% - 2rem));
padding: 0.85rem 1rem;
color: var(--white);
background: var(--ink-950);
border-left: 3px solid var(--gold-light);
border-radius: var(--radius-sm);
box-shadow: 0 1rem 2.5rem rgba(0, 0, 0, 0.22);
}
@media (min-width: 48rem) {
:root {
--content: min(100% - 4rem, 88rem);
}
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}