const state = { companyId: null, propertyGroupId: null, currentPgStatus: 'draft', parts: [], rooms: [], lastDiscover: null, suggestionsByRoom: {}, filterIncompleteOnly: false, filterHiddenOnly: false, filterChannelKey: null, editingRoomId: null, partsInlineOpen: true, partsExpandedGroups: new Set(), partsSearchQuery: '', partsModalSearchQuery: '', }; const PART_GROUP_ORDER = ['Active', 'Class', 'Division', 'Episode', 'Part', 'Unit', 'Season', '기타']; const PARTS_INLINE_COLLAPSE_THRESHOLD = 6; function normalizeRoomId(roomId) { if (roomId == null) return ''; return String(roomId).trim().toLowerCase(); } function findRoomById(roomId) { const id = normalizeRoomId(roomId); return state.rooms.find((r) => normalizeRoomId(r.id) === id); } function setSetupStatus(msg, isError = false) { const el = document.getElementById('setupStatus'); el.textContent = msg; el.className = isError ? 'text-xs text-red-400 ml-auto' : 'text-xs text-slate-400 ml-auto'; } function setEditorStatus(msg, isError = false) { const el = document.getElementById('mappingEditorStatus'); el.textContent = msg; el.className = isError ? 'text-xs text-red-400 mt-2' : 'text-xs text-slate-400 mt-2'; } function showSetupResult(data) { const pre = document.getElementById('setupResult'); pre.classList.remove('hidden'); pre.textContent = typeof data === 'string' ? data : JSON.stringify(data, null, 2); } function buildSuggestionIndex(discover) { const map = {}; if (!discover) return map; discover.parts.forEach((part) => { part.suggestions.forEach((s) => { if (s.room_mapping_id) map[s.room_mapping_id] = s; }); }); return map; } const INTEGRATED_SKIP_LABELS = { part_suffix_missing: 'Parts 업체명에 Part/Class/Episode 접미사 없음', no_integrated_list: '통합 D_KEY 목록 없음', no_integrated_name_match: '통합 객실명 규칙 불일치 (Part 접미사 또는 타입명)', no_same_type_match: '개별 roomTypeName ↔ 통합 객실명 불일치', no_active_type_match: '개별 roomTypeName ↔ 통합 객실명 불일치 (케렌시아·하버블루 등)', }; const YEOGI_ERROR_LABELS = { yeogi_credentials_missing: 'Login_Info.json HOTELTIME 계정 없음', no_yeogi_property_id: '통합 업체에 여기어때 ID 미등록', yeogi_login_failed: '로그인 실패 — .env YEOGI_LOGIN_ID_SUFFIX 확인 후 discover 재실행', }; const NAVER_ERROR_LABELS = { naver_credentials_missing: 'Login_Info.json NAVER 계정 없음', no_naver_property_id: '통합 업체에 네이버 ID 미등록', naver_login_failed: '로그인 실패 — 콘솔·Chrome에서 캡차 완료 (NAVER_LOGIN_HEADLESS=false)', subprocess_no_result: '로그인 프로세스 즉시 종료 — venv Python 미사용 가능성', }; function formatYeogiError(error) { if (!error) return ''; if (YEOGI_ERROR_LABELS[error]) return YEOGI_ERROR_LABELS[error]; if (error.startsWith('yeogi_login_failed:')) { return `로그인 실패 (${error.replace('yeogi_login_failed:', '')})`; } if (error.startsWith('yeogi_fetch_failed:')) { return `객실 API 실패 (${error.replace('yeogi_fetch_failed:', '')})`; } return error; } const YANOLJA_ERROR_LABELS = { yanolja_credentials_missing: 'Login_Info.json YANOLJA 계정 없음', no_yanolja_property_id: '통합 업체에 야놀자 ID 미등록', yanolja_login_failed: '로그인 실패 — Login_Info YANOLJA ID/PW 확인', }; function formatYanoljaError(error) { if (!error) return ''; if (YANOLJA_ERROR_LABELS[error]) return YANOLJA_ERROR_LABELS[error]; if (error.startsWith('yanolja_login_failed:')) { return `로그인 실패 (${error.replace('yanolja_login_failed:', '')})`; } if (error.startsWith('yanolja_fetch_failed:')) { return `객실 API 실패 (${error.replace('yanolja_fetch_failed:', '')})`; } return error; } const ONDA_ERROR_LABELS = { onda_credentials_missing: 'Login_Info.json TPORT1(온다) 계정 없음', no_onda_property_id: '통합 업체에 온다 ID 미등록', onda_login_failed: '로그인 실패 — Login_Info TPORT1 ID/PW 확인', }; function formatOndaError(error) { if (!error) return ''; if (ONDA_ERROR_LABELS[error]) return ONDA_ERROR_LABELS[error]; if (error.startsWith('onda_login_failed:')) { return `로그인 실패 (${error.replace('onda_login_failed:', '')})`; } if (error.startsWith('onda_fetch_failed:')) { return `객실 API 실패 (${error.replace('onda_fetch_failed:', '')})`; } return error; } function formatNaverError(error) { if (!error) return ''; if (NAVER_ERROR_LABELS[error]) return NAVER_ERROR_LABELS[error]; if (error.startsWith('naver_login_failed:')) { return `로그인 실패 (${error.replace('naver_login_failed:', '')})`; } if (error.startsWith('naver_fetch_failed:')) { return `객실 API 실패 (${error.replace('naver_fetch_failed:', '')})`; } return error; } function saveDiscoverDiagnostics(discover) { if (!discover) return; try { sessionStorage.setItem( 'staysync_last_discover', JSON.stringify({ ...discover, saved_at: new Date().toISOString(), property_group_id: state.propertyGroupId, }), ); } catch { /* ignore quota */ } } function renderDiscoverDiagnostics(discover) { const el = document.getElementById('discoverDiagnostics'); if (!discover?.parts?.length) { el.innerHTML = 'discover 결과가 없습니다. 「객실 목록 가져오기」를 실행하세요.'; el.className = 'mt-3 text-xs text-slate-500 border border-dashed border-slate-800 rounded p-3'; return; } try { saveDiscoverDiagnostics(discover); } catch { /* ignore quota */ } let totalSuggestions = 0; let withIntegrated = 0; const skipCounts = {}; const unmappedSamples = []; discover.parts.forEach((part) => { part.suggestions.forEach((s) => { totalSuggestions += 1; if (s.suggested_integrated_room_type_id) { withIntegrated += 1; } else { const reason = s.match_reason || 'unknown'; skipCounts[reason] = (skipCounts[reason] || 0) + 1; if (unmappedSamples.length < 15) { unmappedSamples.push({ part: part.part_name, name: s.individual_display_name, reason, }); } } }); }); const withoutIntegrated = totalSuggestions - withIntegrated; const skipLines = Object.entries(skipCounts) .map(([k, n]) => `
  • ${INTEGRATED_SKIP_LABELS[k] || k}: ${n}
  • `) .join(''); el.className = 'mt-3 text-xs border border-slate-800 rounded p-3 bg-slate-900/50'; el.innerHTML = `

    떠나요 통합 매칭 진단

    discover 객실 ${totalSuggestions}건 중 통합 ID 제안 ${withIntegrated}건 · 미제안 ${withoutIntegrated}건 · 통합 API 목록 ${discover.integrated_rows_fetched}

    ${discover.yeogi_rooms_fetched != null ? `

    여기어때: API ${discover.yeogi_rooms_fetched}건 · 매칭 ${discover.yeogi_rooms_matched || 0}건 ${discover.yeogi_error ? `(${formatYeogiError(discover.yeogi_error)})` : ''}

    ` : ''}

    네이버: API ${discover.naver_rooms_fetched ?? '—'}건 · 매칭 ${discover.naver_rooms_matched ?? '—'}건 ${discover.naver_error ? `(${formatNaverError(discover.naver_error)})` : ''} ${discover.naver_rooms_fetched == null && !discover.naver_error ? '(서버 재시작 후 discover 재실행)' : ''}

    야놀자: API ${discover.yanolja_rooms_fetched ?? '—'}건 · 매칭 ${discover.yanolja_rooms_matched ?? '—'}건 ${discover.yanolja_error ? `(${formatYanoljaError(discover.yanolja_error)})` : ''}

    온다: API ${discover.onda_rooms_fetched ?? '—'}건 · 매칭 ${discover.onda_rooms_matched ?? '—'}건 ${discover.onda_error ? `(${formatOndaError(discover.onda_error)})` : ''}

    ${skipLines ? `` : ''} ${unmappedSamples.length ? `

    미제안 예시 (최대 15건) — 아래 「4. 채널 매칭」에서 수동 입력

    ` : ''}

    「통합 ID 일괄 적용」은 신뢰도 90% 이상만 자동 반영됩니다. 나머지는 4단계 표에서 셀 클릭 → 수동 매핑.

    `; } function restoreDiscoverDiagnostics() { const el = document.getElementById('discoverDiagnostics'); if (!el) return; try { const raw = sessionStorage.getItem('staysync_last_discover'); if (!raw) return; const discover = JSON.parse(raw); if ( discover.property_group_id && state.propertyGroupId && discover.property_group_id !== state.propertyGroupId ) { el.innerHTML = '다른 업체의 discover 결과입니다. 「객실 목록 가져오기」를 다시 실행하세요.'; el.className = 'mt-3 text-xs text-slate-500 border border-dashed border-slate-800 rounded p-3'; return; } if (discover.yeogi_error === 'yeogi_login_failed') { el.innerHTML = `

    이전 discover — 여기어때 로그인 실패 (캐시)

    현재 서버 설정(.env YEOGI_LOGIN_ID_SUFFIX=20)으로는 로그인이 됩니다. 「객실 목록 가져오기」를 다시 실행하면 갱신됩니다.

    `; el.className = 'mt-3 text-xs border border-amber-900/50 rounded p-3 bg-amber-950/20'; return; } renderDiscoverDiagnostics(discover); } catch { /* ignore */ } } function isRoomExposed(room) { return room.is_exposed !== false; } function exposedRooms(rooms = state.rooms) { return rooms.filter(isRoomExposed); } function visibleRooms() { let rooms = state.rooms; if (state.filterChannelKey) { const spec = CHANNEL_SPECS.find((s) => s.key === state.filterChannelKey); if (spec) { rooms = rooms.filter((r) => evaluateChannel(r, spec).status !== 'ok'); } } if (state.filterIncompleteOnly) { rooms = rooms.filter((r) => !roomIsFullyMapped(r)); } if (state.filterHiddenOnly) { rooms = rooms.filter((r) => r.is_exposed === false); } return rooms; } function partLabel(room) { if (room.part_name && room.ddona_individual_property_id) { return `${room.part_name} · D_KEY ${room.ddona_individual_property_id}`; } if (room.ddona_individual_property_id) { return `D_KEY ${room.ddona_individual_property_id}`; } if (room.part_name) { return room.part_name; } return '—'; } function partLabelShort(room) { if (room.part_name) { return room.part_name; } if (room.ddona_individual_property_id) { const id = String(room.ddona_individual_property_id); const shortId = id.length > 10 ? `…${id.slice(-8)}` : id; return `D_KEY ${shortId}`; } return '—'; } function partBadgeHtml(room) { const fullPart = partLabel(room); if (!room.part_name && !room.ddona_individual_property_id) { return '개별 미연결'; } const shortPart = escapeHtml(partLabelShort(room)); const dKey = room.ddona_individual_property_id ? `${escapeHtml(String(room.ddona_individual_property_id))}` : ''; return `${shortPart}${dKey}`; } function escapeHtml(s) { return String(s ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function addDaysYmd(baseDate, days) { const d = new Date(baseDate); d.setDate(d.getDate() + days); const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); return `${y}-${m}-${day}`; } function resolveOpenUntilFromPg(pg) { if (!pg) return ''; if (pg.open_until) return pg.open_until; const days = Number(pg.open_days) || 0; if (days > 0) return addDaysYmd(new Date(), days); return ''; } function matchActionBtnHtml(fullyMapped, roomId) { const label = fullyMapped ? '보기' : '매핑'; const icon = fullyMapped ? '' : ''; return ``; } function exposePillHtml(isExposed, roomId) { const cls = isExposed ? 'expose-pill--on' : 'expose-pill--off'; const label = isExposed ? '노출' : '미노출'; return ``; } function checkInSmsCheckboxHtml(room) { const checked = Boolean(room.check_in_sms_enabled); const name = escapeHtml(room.display_name || ''); return ``; } function renderMappingView() { if (typeof renderChannelSummaryCards !== 'function') { const msg = '

    mapping.js 로드 실패 — 페이지를 새로고침하세요.

    '; ['channelSummary', 'matchLegend', 'mappingGrid'].forEach((id) => { const el = document.getElementById(id); if (el) el.innerHTML = msg; }); return; } const rooms = visibleRooms(); renderChannelSummaryCards(exposedRooms(), 'channelSummary', { activeChannelKey: state.filterChannelKey, onCardClick: (key) => { state.filterChannelKey = state.filterChannelKey === key ? null : key; renderMappingView(); }, }); renderMatchLegend('matchLegend', { showFilterHint: true, filterIncompleteOnly: state.filterIncompleteOnly, filterHiddenOnly: state.filterHiddenOnly, }); const grid = document.getElementById('mappingGrid'); if (!state.rooms.length) { grid.innerHTML = '

    객실 없음 — discover를 실행하세요.

    '; return; } if (!rooms.length) { grid.innerHTML = '

    미완료 객실이 없습니다.

    '; return; } let html = `${CHANNEL_SPECS.map(() => '').join('')} `; CHANNEL_SPECS.forEach((spec) => { html += ``; }); html += ''; rooms.forEach((room) => { const fullyMapped = roomIsFullyMapped(room); const isExposed = room.is_exposed !== false; const fullPart = escapeHtml(partLabel(room)); html += ``; CHANNEL_SPECS.forEach((spec) => { const ev = evaluateChannel(room, spec); html += ``; }); html += ``; html += ''; }); html += '
    객실 · 개별 업체 노출 안내 채널 매칭
    ${channelIconHtml(spec, { size: 'sm', label: false })}
    ${escapeHtml(room.display_name)} ${partBadgeHtml(room)} ${exposePillHtml(isExposed, room.id)} ${checkInSmsCheckboxHtml(room)} ${matchCellHtml(ev, spec)} ${matchActionBtnHtml(fullyMapped, room.id)}
    '; grid.innerHTML = html; grid.querySelectorAll('.mapping-cell-clickable').forEach((cell) => { cell.addEventListener('click', () => openMappingEditor(cell.dataset.roomId, cell.dataset.channel)); }); grid.querySelectorAll('.mapping-edit-btn').forEach((btn) => { btn.addEventListener('click', () => openMappingEditor(btn.dataset.roomId)); }); grid.querySelectorAll('.expose-toggle-btn').forEach((btn) => { btn.addEventListener('click', () => toggleRoomExposure(btn.dataset.roomId)); }); grid.querySelectorAll('.match-sms-check').forEach((label) => { label.addEventListener('click', (e) => e.stopPropagation()); }); grid.querySelectorAll('.checkin-sms-toggle').forEach((input) => { input.addEventListener('click', (e) => e.stopPropagation()); input.addEventListener('change', async () => { if (input.disabled) return; input.disabled = true; try { await toggleRoomCheckInSms(input.dataset.roomId, input.checked); } finally { input.disabled = false; } }); }); } async function toggleRoomCheckInSms(roomId, enabled) { const room = findRoomById(roomId); if (!room) { renderMappingView(); setSetupStatus('객실을 찾을 수 없습니다. 페이지를 새로고침하세요.', true); return; } const prev = Boolean(room.check_in_sms_enabled); if (prev === enabled) return; room.check_in_sms_enabled = enabled; const successMsg = enabled ? '입실 안내 문자 발송 대상으로 설정했습니다.' : '입실 안내 발송 대상에서 제외했습니다.'; const slowTimer = setTimeout(() => setSetupStatus('입실 안내 설정 저장 중…'), 500); try { const updated = await API.patch(`/api/v1/room-mappings/${room.id}`, { check_in_sms_enabled: enabled }); const idx = state.rooms.findIndex((r) => normalizeRoomId(r.id) === normalizeRoomId(room.id)); if (idx >= 0) state.rooms[idx] = updated; setSetupStatus(successMsg); } catch (e) { room.check_in_sms_enabled = prev; renderMappingView(); setSetupStatus(e.message || '입실 안내 설정 변경 실패', true); throw e; } finally { clearTimeout(slowTimer); } } async function toggleRoomExposure(roomId) { const room = findRoomById(roomId); if (!room) return; const nextExposed = room.is_exposed === false; try { const updated = await API.patch(`/api/v1/room-mappings/${room.id}`, { is_exposed: nextExposed }); const idx = state.rooms.findIndex((r) => normalizeRoomId(r.id) === normalizeRoomId(room.id)); if (idx >= 0) state.rooms[idx] = updated; renderMappingView(); setSetupStatus(nextExposed ? '객실을 노출로 변경했습니다.' : '객실을 미노출로 변경했습니다 (sync 제외).'); } catch (e) { setSetupStatus(e.message || '노출 상태 변경 실패', true); } } async function loadRooms() { if (!state.propertyGroupId) { state.rooms = []; renderMappingView(); return; } state.rooms = await API.get(`/api/v1/room-mappings?property_group_id=${state.propertyGroupId}`); renderMappingView(); } async function loadParts() { if (!state.propertyGroupId) { state.parts = []; renderPartsList(); return; } state.parts = await API.get(`/api/v1/property-groups/${state.propertyGroupId}/ddonayo-parts`); if (state.parts.length > PARTS_INLINE_COLLAPSE_THRESHOLD) { state.partsInlineOpen = false; } else { state.partsInlineOpen = true; } if (!state.partsExpandedGroups.size) { const grouped = groupPartsByCategory(state.parts); const first = PART_GROUP_ORDER.find((key) => grouped.get(key)?.length); if (first) state.partsExpandedGroups.add(first); } renderPartsList(); } function currentPropertyGroupName() { return document.getElementById('pgName')?.value?.trim() || ''; } function partIntegratedTag(part) { if (part.integrated_suffix) return part.integrated_suffix; if (/\bActive\d*\b/i.test(part.name || '')) return 'Active'; return ''; } function partCategory(part) { const name = part.name || ''; if (/\bActive\d*\b/i.test(name)) return 'Active'; if (/\bClass\d*\b/i.test(name)) return 'Class'; if (/\bDivision\d*\b/i.test(name)) return 'Division'; if (/\bEpisode\d*\b/i.test(name)) return 'Episode'; if (/\bPart\d*\b/i.test(name)) return 'Part'; if (/\bUnit\d*\b/i.test(name)) return 'Unit'; if (/\bSeason\d*\b/i.test(name)) return 'Season'; return '기타'; } function inferPartsNamePrefix(parts) { if (!parts.length) return ''; const pgName = currentPropertyGroupName(); const names = parts.map((p) => p.name).filter(Boolean); if (pgName && names.every((n) => n.startsWith(pgName))) { return pgName; } let prefix = names[0]; for (const name of names.slice(1)) { let i = 0; while (i < prefix.length && i < name.length && prefix[i] === name[i]) i += 1; prefix = prefix.slice(0, i); } prefix = prefix.replace(/\s+\S*$/, '').trim(); return prefix.length >= 4 ? prefix : ''; } function partShortLabel(part, prefix) { const name = String(part.name || '').trim(); if (!name) return '—'; if (prefix && name.startsWith(prefix)) { const short = name.slice(prefix.length).trim(); if (short) return short; } return name; } function groupPartsByCategory(parts) { const grouped = new Map(); parts.forEach((part) => { const key = partCategory(part); if (!grouped.has(key)) grouped.set(key, []); grouped.get(key).push(part); }); grouped.forEach((items) => { items.sort((a, b) => String(a.name).localeCompare(String(b.name), 'ko')); }); return grouped; } function filterPartsList(parts, query) { const q = (query || '').trim().toLowerCase(); if (!q) return parts; return parts.filter((p) => { const name = (p.name || '').toLowerCase(); const dkey = String(p.ddona_individual_property_id || '').toLowerCase(); const tag = partIntegratedTag(p).toLowerCase(); return name.includes(q) || dkey.includes(q) || tag.includes(q); }); } function buildPartsSummaryHtml(parts) { if (!parts.length) return ''; const grouped = groupPartsByCategory(parts); const chips = PART_GROUP_ORDER .filter((key) => grouped.get(key)?.length) .map((key) => `${key} ${grouped.get(key).length}`); const prefix = inferPartsNamePrefix(parts); const prefixHint = prefix ? `접두어 생략 표시` : ''; return ` 등록 ${parts.length} ${chips.join('')} ${prefixHint}`; } function renderPartsTableRows(parts, prefix) { if (!parts.length) { return '표시할 Parts 없음'; } return parts.map((part) => { const short = escapeHtml(partShortLabel(part, prefix)); const full = escapeHtml(part.name || ''); const dkey = escapeHtml(String(part.ddona_individual_property_id || '—')); const tag = partIntegratedTag(part); const tagHtml = tag ? `${escapeHtml(tag)}` : ''; return ` ${short} ${dkey} ${tagHtml} `; }).join(''); } function renderPartsGroupedHtml(parts, { expandedGroups, prefix }) { const grouped = groupPartsByCategory(parts); const groups = PART_GROUP_ORDER.filter((key) => grouped.get(key)?.length); if (!groups.length) { return '

    표시할 Parts 없음

    '; } return groups.map((key) => { const items = grouped.get(key); const open = expandedGroups.has(key); return `
    ${renderPartsTableRows(items, prefix)}
    이름 D_KEY 접미사
    `; }).join(''); } function bindPartsGroupToggles(root, { modal = false } = {}) { root.querySelectorAll('.setup-parts-group-toggle').forEach((btn) => { btn.addEventListener('click', () => { const key = btn.dataset.partsGroup; if (!key) return; const section = btn.closest('.setup-parts-group'); const body = section?.querySelector('.setup-parts-group-body'); const open = section?.classList.toggle('is-open'); if (open) { state.partsExpandedGroups.add(key); body?.removeAttribute('hidden'); btn.setAttribute('aria-expanded', 'true'); } else { state.partsExpandedGroups.delete(key); body?.setAttribute('hidden', ''); btn.setAttribute('aria-expanded', 'false'); } const chevron = btn.querySelector('.setup-parts-group-chevron'); if (chevron) chevron.textContent = open ? '▾' : '▸'; if (modal) renderPartsModal(); }); }); } function updatePartsToolbar(parts) { const toggleBtn = document.getElementById('partsToggleInlineBtn'); const modalBtn = document.getElementById('partsOpenModalBtn'); const search = document.getElementById('partsSearch'); const many = parts.length > PARTS_INLINE_COLLAPSE_THRESHOLD; if (toggleBtn) { toggleBtn.classList.toggle('hidden', !many); toggleBtn.textContent = state.partsInlineOpen ? '목록 접기' : '목록 펼치기'; } if (modalBtn) { modalBtn.classList.toggle('hidden', parts.length === 0); } if (search) { const showSearch = parts.length > 0 && (state.partsInlineOpen || !many); search.classList.toggle('hidden', !showSearch); if (showSearch && search.value !== state.partsSearchQuery) { search.value = state.partsSearchQuery; } } } function renderPartsList() { const summaryEl = document.getElementById('partsSummary'); const listEl = document.getElementById('partsList'); if (!listEl) return; if (!state.parts.length) { if (summaryEl) summaryEl.innerHTML = ''; listEl.innerHTML = '

    등록된 Parts 없음 — 통합 업체를 선택하거나 추가하세요.

    '; updatePartsToolbar([]); return; } const prefix = inferPartsNamePrefix(state.parts); if (summaryEl) { summaryEl.innerHTML = buildPartsSummaryHtml(state.parts); } updatePartsToolbar(state.parts); const many = state.parts.length > PARTS_INLINE_COLLAPSE_THRESHOLD; const showInline = !many || state.partsInlineOpen; if (!showInline) { listEl.innerHTML = '

    목록이 접혀 있습니다. 「목록 펼치기」 또는 「전체 검색」을 사용하세요. 상세는 4단계 채널 매칭의 객실 배지에서도 확인할 수 있습니다.

    '; return; } const filtered = filterPartsList(state.parts, state.partsSearchQuery); listEl.innerHTML = renderPartsGroupedHtml(filtered, { expandedGroups: state.partsExpandedGroups, prefix, }); bindPartsGroupToggles(listEl); } function openPartsModal() { state.partsModalSearchQuery = state.partsSearchQuery; renderPartsModal(); document.getElementById('partsListModal')?.classList.remove('hidden'); document.body.classList.add('dash-modal-open'); document.getElementById('partsModalSearch')?.focus(); } function closePartsModal() { document.getElementById('partsListModal')?.classList.add('hidden'); document.body.classList.remove('dash-modal-open'); } function renderPartsModal() { const summary = document.getElementById('partsModalSummary'); const body = document.getElementById('partsModalBody'); if (!body) return; const prefix = inferPartsNamePrefix(state.parts); const filtered = filterPartsList(state.parts, state.partsModalSearchQuery); if (summary) { summary.textContent = filtered.length === state.parts.length ? `전체 ${state.parts.length}개` : `검색 결과 ${filtered.length}개 / 전체 ${state.parts.length}개`; } body.innerHTML = renderPartsGroupedHtml(filtered, { expandedGroups: state.partsExpandedGroups, prefix, }); bindPartsGroupToggles(body, { modal: true }); const search = document.getElementById('partsModalSearch'); if (search && search.value !== state.partsModalSearchQuery) { search.value = state.partsModalSearchQuery; } } function initPartsPanel() { document.getElementById('partsToggleInlineBtn')?.addEventListener('click', () => { state.partsInlineOpen = !state.partsInlineOpen; renderPartsList(); }); document.getElementById('partsOpenModalBtn')?.addEventListener('click', openPartsModal); document.getElementById('partsSearch')?.addEventListener('input', (e) => { state.partsSearchQuery = e.target.value; renderPartsList(); }); document.getElementById('partsModalSearch')?.addEventListener('input', (e) => { state.partsModalSearchQuery = e.target.value; renderPartsModal(); }); document.querySelectorAll('[data-close="partsListModal"]').forEach((el) => { el.addEventListener('click', closePartsModal); }); } function pgOptionLabel(g) { const suffix = g.status && g.status !== 'active' ? ` [${g.status}]` : ''; return `${g.name}${suffix}`; } function pgStatusLabel(status) { if (status === 'active') return '운영 중'; if (status === 'inactive') return '중지'; return '준비 중'; } function updatePgStatusUI(pg) { const status = pg?.status || 'draft'; state.currentPgStatus = status; const badge = document.getElementById('pgStatusBadge'); const card = document.getElementById('pgActiveCard'); const toggle = document.getElementById('pgActiveToggle'); const banner = document.getElementById('pgStatusBanner'); const headerBadge = document.getElementById('headerPgStatus'); if (badge) { badge.textContent = pgStatusLabel(status); badge.className = `pg-status-badge ${status}`; } if (card) { card.classList.toggle('is-active', status === 'active'); } if (toggle) { toggle.checked = status === 'active'; } if (banner) { banner.classList.toggle('hidden', status === 'active'); const textEl = banner?.querySelector('[data-banner-text]'); if (textEl) { textEl.textContent = status === 'inactive' ? '운영이 중지된 상태입니다. 토글을 켜면 다시 active로 전환됩니다.' : '아직 draft 상태입니다. 채널 매핑 완료 후 운영 활성화를 켜면 백그라운드 자동 동기화가 시작됩니다.'; } } if (headerBadge) { if (pg) { headerBadge.textContent = pgStatusLabel(status); headerBadge.className = `header-pg-status ${status}`; } else { headerBadge.textContent = ''; headerBadge.className = 'header-pg-status'; } } } async function refreshPropertyGroups(selectId) { if (!state.companyId) return []; const groups = await API.get(`/api/v1/property-groups?company_id=${state.companyId}`); const sel = document.getElementById(selectId); sel.innerHTML = groups.map((g) => ``).join(''); return groups; } function openMappingEditor(roomId, focusChannel) { const room = state.rooms.find((r) => r.id === roomId); if (!room) return; state.editingRoomId = roomId; document.getElementById('mappingEditorRoom').textContent = room.display_name; const partEl = document.getElementById('mappingEditorPart'); if (partEl) { partEl.textContent = `소속: ${partLabel(room)}`; } const exposedEl = document.getElementById('mappingIsExposed'); if (exposedEl) { exposedEl.checked = room.is_exposed !== false; } const memoEl = document.getElementById('mappingGuestMemo'); if (memoEl) { memoEl.value = room.guest_check_in_memo || ''; } const smsEl = document.getElementById('mappingCheckInSms'); if (smsEl) { smsEl.checked = Boolean(room.check_in_sms_enabled); } setEditorStatus(''); const form = document.getElementById('mappingForm'); form.innerHTML = CHANNEL_SPECS.map((spec) => { const fields = spec.fields.map((f) => ` `).join(''); return `
    ${channelIconHtml(spec, { size: 'sm', label: true })}
    ${fields}
    `; }).join(''); const suggestion = state.suggestionsByRoom[roomId]; const suggestionEl = document.getElementById('mappingSuggestion'); const applyBtn = document.getElementById('applySuggestionBtn'); if (suggestion?.suggested_integrated_room_type_id) { suggestionEl.classList.remove('hidden'); suggestionEl.innerHTML = ` 떠나요 통합 제안 (${Math.round(suggestion.confidence * 100)}%) · type ${suggestion.suggested_integrated_room_type_id} ${suggestion.suggested_integrated_room_id ? `· room ${suggestion.suggested_integrated_room_id}` : ''} — ${suggestion.match_reason || ''}`; applyBtn.classList.remove('hidden'); } else { suggestionEl.classList.add('hidden'); suggestionEl.innerHTML = ''; applyBtn.classList.add('hidden'); } document.getElementById('mappingEditorBackdrop').classList.remove('hidden'); if (focusChannel) { const fieldset = form.querySelector(`fieldset[data-channel="${focusChannel}"]`); fieldset?.scrollIntoView({ block: 'nearest' }); fieldset?.querySelector('input')?.focus(); } } function closeMappingEditor() { state.editingRoomId = null; document.getElementById('mappingEditorBackdrop').classList.add('hidden'); } function collectMappingFormData() { const form = document.getElementById('mappingForm'); const data = {}; form.querySelectorAll('input[name]').forEach((input) => { data[input.name] = input.value.trim(); }); const exposedEl = document.getElementById('mappingIsExposed'); if (exposedEl) { data.is_exposed = exposedEl.checked; } const memoEl = document.getElementById('mappingGuestMemo'); if (memoEl) { data.guest_check_in_memo = memoEl.value.trim() || null; } const smsEl = document.getElementById('mappingCheckInSms'); if (smsEl) { data.check_in_sms_enabled = smsEl.checked; } return data; } async function saveMapping() { if (!state.editingRoomId) return; setEditorStatus('저장 중…'); try { const updated = await API.patch(`/api/v1/room-mappings/${state.editingRoomId}`, collectMappingFormData()); const idx = state.rooms.findIndex((r) => r.id === state.editingRoomId); if (idx >= 0) state.rooms[idx] = updated; renderMappingView(); setEditorStatus('저장되었습니다.'); setSetupStatus('채널 매핑이 저장되었습니다.'); setTimeout(closeMappingEditor, 400); } catch (e) { setEditorStatus(e.message || '저장 실패', true); } } async function applySuggestionForEditingRoom() { if (!state.editingRoomId || !state.propertyGroupId) return; const suggestion = state.suggestionsByRoom[state.editingRoomId]; if (!suggestion?.suggested_integrated_room_type_id) return; setEditorStatus('제안 적용 중…'); try { const result = await API.post( `/api/v1/property-groups/${state.propertyGroupId}/apply-discover-suggestions`, { room_mapping_ids: [state.editingRoomId], min_confidence: 0 }, ); await loadRooms(); const room = state.rooms.find((r) => r.id === state.editingRoomId); if (room) openMappingEditor(state.editingRoomId, 'ddona_integrated'); setEditorStatus(`제안 ${result.applied}건 적용됨`); setSetupStatus('떠나요 통합 ID가 적용되었습니다.'); } catch (e) { setEditorStatus('제안 적용 실패', true); } } function fillPropertyGroupForm(pg) { if (!pg) { document.getElementById('pgName').value = ''; document.getElementById('pgCatalogKey').value = ''; document.getElementById('pgIntegratedId').value = ''; document.getElementById('pgYeogi').value = ''; document.getElementById('pgNaver').value = ''; document.getElementById('pgYanolja').value = ''; document.getElementById('pgOnda').value = ''; document.getElementById('pgOpenUntil').value = ''; updatePgStatusUI(null); return; } document.getElementById('pgName').value = pg.name || ''; document.getElementById('pgCatalogKey').value = pg.catalog_key || ''; document.getElementById('pgIntegratedId').value = pg.ddona_integrated_property_id || ''; document.getElementById('pgOpenUntil').value = resolveOpenUntilFromPg(pg); document.getElementById('pgYeogi').value = pg.yeogi_property_id || ''; document.getElementById('pgNaver').value = pg.naver_property_id || ''; document.getElementById('pgYanolja').value = pg.yanolja_property_id || ''; document.getElementById('pgOnda').value = pg.onda_property_id || ''; updatePgStatusUI(pg); } function resolvePgToggleStatus(activeOn, priorStatus) { if (activeOn) return 'active'; if (priorStatus === 'inactive') return 'inactive'; return 'draft'; } function readPropertyGroupFormBody() { const activeOn = document.getElementById('pgActiveToggle')?.checked; const nextStatus = resolvePgToggleStatus(activeOn, state.currentPgStatus); const openUntilRaw = document.getElementById('pgOpenUntil')?.value?.trim(); return { name: document.getElementById('pgName').value.trim(), catalog_key: document.getElementById('pgCatalogKey').value.trim(), status: nextStatus, ddona_integrated_property_id: document.getElementById('pgIntegratedId').value.trim() || null, open_until: openUntilRaw || null, open_days: 0, yeogi_property_id: document.getElementById('pgYeogi').value.trim() || null, naver_property_id: document.getElementById('pgNaver').value.trim() || null, yanolja_property_id: document.getElementById('pgYanolja').value.trim() || null, onda_property_id: document.getElementById('pgOnda').value.trim() || null, }; } async function savePropertyGroup() { if (!state.propertyGroupId) { setSetupStatus('통합 업체를 선택하세요.', true); return; } const body = readPropertyGroupFormBody(); if (!body.name || !body.catalog_key) { setSetupStatus('표시명과 catalog_key를 입력하세요.', true); return; } try { const updated = await API.patch(`/api/v1/property-groups/${state.propertyGroupId}`, body); const statusMsg = updated.status === 'active' ? ' · 운영 활성화됨' : ''; setSetupStatus(`설정 저장됨: ${updated.name}${statusMsg}`); showSetupResult(updated); await refreshPropertyGroups('propertySelect'); document.getElementById('propertySelect').value = updated.id; state.propertyGroupId = updated.id; fillPropertyGroupForm(updated); } catch (e) { setSetupStatus('채널 ID 저장 실패', true); console.error(e); } } async function addPropertyGroup() { if (!state.companyId) { setSetupStatus('회사를 먼저 선택하세요.', true); return; } const name = document.getElementById('pgName').value.trim(); const catalogKey = document.getElementById('pgCatalogKey').value.trim(); if (!name || !catalogKey) { setSetupStatus('표시명과 catalog_key를 입력하세요.', true); return; } const body = { company_id: state.companyId, ...readPropertyGroupFormBody(), }; try { const created = await API.post('/api/v1/property-groups', body); setSetupStatus(`통합 업체 추가됨: ${created.name}`); showSetupResult(created); await refreshPropertyGroups('propertySelect'); document.getElementById('propertySelect').value = created.id; state.propertyGroupId = created.id; CatalogStore.save(state.companyId, created.id); fillPropertyGroupForm(created); await loadParts(); await loadRooms(); } catch (e) { setSetupStatus('통합 업체 추가 실패 — API 응답 확인', true); console.error(e); } } async function addPart() { if (!state.propertyGroupId) { setSetupStatus('통합 업체를 먼저 선택·추가하세요.', true); return; } const name = document.getElementById('partName').value.trim(); const dkey = document.getElementById('partDkey').value.trim(); if (!name || !dkey) { setSetupStatus('Parts 업체명과 D_KEY를 입력하세요.', true); return; } try { const created = await API.post(`/api/v1/property-groups/${state.propertyGroupId}/ddonayo-parts`, { name, ddona_individual_property_id: dkey, }); setSetupStatus(`Parts 추가됨 (접미사: ${created.integrated_suffix || '없음'})`); showSetupResult(created); await loadParts(); } catch (e) { setSetupStatus('Parts 추가 실패', true); console.error(e); } } function renderIntegratedMatchDebug(data) { const el = document.getElementById('discoverDiagnostics'); if (!el || !data) return; const summary = data.summary || {}; const pg = data.property_group || {}; if (!summary.integrated_fetch_ok) { el.className = 'mt-3 text-xs border border-amber-900/50 rounded p-3 bg-amber-950/20'; el.innerHTML = `

    떠나요 통합 매칭 진단

    통합 D_KEY: ${escapeHtml(pg.ddona_integrated_property_id || '(미등록)')}

    통합 객실 조회 실패: ${escapeHtml(summary.integrated_error || 'unknown')}

    통합 업체 설정에서 떠나요 통합 D_KEY를 확인한 뒤 다시 시도하세요.

    `; return; } const integratedLines = (data.integrated_rooms || []) .slice(0, 40) .map((row) => ( `
  • ${escapeHtml(row.room_id)} ` + `${escapeHtml(row.display_room_name)} ` + `key=${escapeHtml(row.normalized_type_key || row.normalized_display_key)}
  • ` )) .join(''); const integratedMore = (data.integrated_rooms || []).length > 40 ? `
  • … 외 ${data.integrated_rooms.length - 40}건
  • ` : ''; const partBlocks = (data.parts || []).map((part) => { const unmatched = (part.rows || []).filter((r) => !r.matched); const sample = unmatched.slice(0, 8).map((row) => { const closest = (row.closest_integrated || []).slice(0, 3).map((c) => ( `${c.integrated_display_name} (key=${c.normalized_type_key || c.normalized_display_key})` )).join(' · '); return `
  • ${escapeHtml(row.room_type_name || row.display_room_name)} ` + `key=${escapeHtml(row.normalized_type_key)} ` + `${INTEGRATED_SKIP_LABELS[row.failure_reason] || row.failure_reason}` + (closest ? `
    가까운 통합: ${escapeHtml(closest)}` : '') + '
  • '; }).join(''); return `

    ${escapeHtml(part.part_name)} ` + `(D_KEY ${escapeHtml(part.part_dkey)}) · ` + `매칭 ${part.matched_count}/${part.individual_rows_fetched} · policy=${escapeHtml(part.naming_policy)}

    ${unmatched.length ? `` : '

    전부 매칭됨

    '} ${unmatched.length > 8 ? `

    … 미매칭 ${unmatched.length - 8}건 더 있음

    ` : ''}
    `; }).join(''); el.className = 'mt-3 text-xs border border-slate-800 rounded p-3 bg-slate-900/50 max-h-[28rem] overflow-y-auto'; el.innerHTML = `

    떠나요 통합 매칭 진단 (DB 미변경)

    ${escapeHtml(pg.name)} · catalog=${escapeHtml(pg.catalog_key || '—')} · policy=${escapeHtml(pg.naming_policy)}

    통합 ${summary.integrated_rows_fetched}건 · ` + `개별 ${summary.individual_rows_total}건 · ` + `매칭 ${summary.integrated_matched_total} · ` + `미매칭 ${summary.integrated_unmatched_total}

    통합상품 객실 (떠나요 API)

    ${partBlocks}

    로컬 상세: python scripts/check_integrated_match.py --name "${escapeHtml(pg.name)}"

    `; } async function runIntegratedMatchDebug() { if (!state.propertyGroupId) { setSetupStatus('통합 업체를 선택하세요.', true); return; } setSetupStatus('통합 매칭 진단 중… (떠나요 통합·개별 API 조회)'); try { const data = await API.get( `/api/v1/property-groups/${state.propertyGroupId}/integrated-match-debug`, ); renderIntegratedMatchDebug(data); setSetupStatus('통합 매칭 진단 완료'); } catch (e) { if (e.message === 'auth') return; setSetupStatus(e.message || '통합 매칭 진단 실패', true); console.error(e); } } async function discoverRooms() { if (!state.propertyGroupId) { setSetupStatus('통합 업체를 선택하세요.', true); return; } setSetupStatus('discover 진행 중… (떠나요 → 여기어때 → 네이버 → 야놀자). 네이버 단계에서 콘솔·Chrome 창이 뜹니다.'); try { const result = await API.post(`/api/v1/property-groups/${state.propertyGroupId}/discover-rooms`, {}); state.lastDiscover = result; state.suggestionsByRoom = buildSuggestionIndex(result); const totalUpdated = result.parts.reduce((n, p) => n + p.mappings_updated, 0); const totalSkipped = result.parts.reduce((n, p) => n + (p.rows_skipped || 0), 0); const totalSuggestions = result.parts.reduce((n, p) => n + p.suggestions.length, 0); const skipMsg = totalSkipped ? `, 제외(배정 등) ${totalSkipped}` : ''; const yeogiMsg = result.yeogi_rooms_fetched ? `, 여기어때 ${result.yeogi_rooms_matched}/${result.yeogi_rooms_fetched}` : result.yeogi_error ? `, 여기어때: ${formatYeogiError(result.yeogi_error)}` : ''; const naverMsg = result.naver_rooms_fetched != null ? (result.naver_rooms_fetched > 0 || !result.naver_error ? `, 네이버 ${result.naver_rooms_matched ?? 0}/${result.naver_rooms_fetched}` : `, 네이버: ${formatNaverError(result.naver_error)}`) : result.naver_error ? `, 네이버: ${formatNaverError(result.naver_error)}` : ''; const yanoljaMsg = result.yanolja_rooms_fetched != null ? (result.yanolja_rooms_fetched > 0 || !result.yanolja_error ? `, 야놀자 ${result.yanolja_rooms_matched ?? 0}/${result.yanolja_rooms_fetched}` : `, 야놀자: ${formatYanoljaError(result.yanolja_error)}`) : result.yanolja_error ? `, 야놀자: ${formatYanoljaError(result.yanolja_error)}` : ''; const ondaMsg = result.onda_rooms_fetched != null ? (result.onda_rooms_fetched > 0 || !result.onda_error ? `, 온다 ${result.onda_rooms_matched ?? 0}/${result.onda_rooms_fetched}` : `, 온다: ${formatOndaError(result.onda_error)}`) : result.onda_error ? `, 온다: ${formatOndaError(result.onda_error)}` : ''; setSetupStatus(`완료: Parts ${result.parts.length}개, 갱신 ${totalUpdated}${skipMsg}, 제안 ${totalSuggestions}${yeogiMsg}${naverMsg}${yanoljaMsg}${ondaMsg}`); showSetupResult(result); renderDiscoverDiagnostics(result); await loadRooms(); } catch (e) { setSetupStatus('discover 실패 — 떠나요 로그인·D_KEY 확인', true); console.error(e); } } async function applyDiscoverSuggestions() { if (!state.propertyGroupId || !state.lastDiscover) { setSetupStatus('먼저 discover를 실행하세요.', true); return; } const roomIds = []; state.lastDiscover.parts.forEach((part) => { part.suggestions.forEach((s) => { if (s.room_mapping_id && s.confidence >= 0.9 && s.suggested_integrated_room_type_id) { roomIds.push(s.room_mapping_id); } }); }); if (!roomIds.length) { setSetupStatus('자동 적용할 고신뢰도 제안이 없습니다.', true); return; } try { const result = await API.post( `/api/v1/property-groups/${state.propertyGroupId}/apply-discover-suggestions`, { room_mapping_ids: roomIds, min_confidence: 0.9 }, ); setSetupStatus(`통합 ID ${result.applied}건 적용`); showSetupResult(result); await loadRooms(); } catch (e) { setSetupStatus('통합 ID 적용 실패', true); console.error(e); } } const BULK_IMPORT_TEMPLATE = '구분,표시명,catalog_key,떠나요통합D_KEY,여기어때,네이버,야놀자,개별D_KEY\n' + 'group,더강릉오션스테이,더강릉오션스테이,16369,27837,850591,10057059,\n' + 'part,강릉 더강릉오션스테이 Part1,더강릉오션스테이,,,,,100044\n' + 'part,강릉 더강릉오션스테이 Unit2,더강릉오션스테이,,,,,100045\n'; function setBulkImportStatus(msg, isError = false) { const el = document.getElementById('bulkImportStatus'); el.textContent = msg; el.className = isError ? 'text-xs text-red-400 ml-auto' : 'text-xs text-slate-400 ml-auto'; } async function runBulkImport() { if (!state.companyId) { setBulkImportStatus('회사를 선택하세요.', true); return; } const csvText = document.getElementById('bulkImportText').value.trim(); if (!csvText) { setBulkImportStatus('CSV 데이터를 붙여넣거나 파일을 선택하세요.', true); return; } setBulkImportStatus('등록 중…'); try { const result = await API.post('/api/v1/catalog/bulk-import', { company_id: state.companyId, csv_text: csvText, }); const pre = document.getElementById('bulkImportResult'); pre.classList.remove('hidden'); pre.textContent = JSON.stringify(result, null, 2); const summary = `통합 ${result.property_groups_created}건 · Parts ${result.parts_created}건` + (result.property_groups_skipped ? ` (통합 스킵 ${result.property_groups_skipped})` : '') + (result.parts_skipped ? ` (Parts 스킵 ${result.parts_skipped})` : ''); setBulkImportStatus(result.errors?.length ? `${summary} — 오류 ${result.errors.length}건` : summary); setSetupStatus(`일괄 등록: ${summary}`); await refreshPropertyGroups('propertySelect'); if (result.property_group_ids?.length) { document.getElementById('propertySelect').value = result.property_group_ids[0]; state.propertyGroupId = result.property_group_ids[0]; CatalogStore.save(state.companyId, result.property_group_ids[0]); await loadParts(); await loadRooms(); } else if (state.propertyGroupId) { await loadParts(); await loadRooms(); } } catch (e) { setBulkImportStatus('일괄 등록 실패', true); console.error(e); } } document.getElementById('pgActiveToggle')?.addEventListener('change', async (e) => { const toggle = e.target; const priorStatus = state.currentPgStatus; const nextStatus = resolvePgToggleStatus(toggle.checked, priorStatus); updatePgStatusUI({ status: nextStatus }); if (!state.propertyGroupId) return; toggle.disabled = true; try { const updated = await API.patch(`/api/v1/property-groups/${state.propertyGroupId}`, { status: nextStatus }); fillPropertyGroupForm(updated); await refreshPropertyGroups('propertySelect'); document.getElementById('propertySelect').value = updated.id; setSetupStatus(updated.status === 'active' ? '운영 활성화됨' : '운영 상태가 저장되었습니다.'); } catch (err) { updatePgStatusUI({ status: priorStatus }); setSetupStatus('운영 상태 저장 실패', true); console.error(err); } finally { toggle.disabled = false; } }); document.getElementById('addPropertyGroupBtn').addEventListener('click', addPropertyGroup); document.getElementById('savePropertyGroupBtn').addEventListener('click', savePropertyGroup); document.getElementById('addPartBtn').addEventListener('click', addPart); document.getElementById('discoverRoomsBtn').addEventListener('click', discoverRooms); document.getElementById('integratedMatchDebugBtn')?.addEventListener('click', runIntegratedMatchDebug); document.getElementById('applySuggestionsBtn').addEventListener('click', applyDiscoverSuggestions); document.getElementById('fillTemplateBtn').addEventListener('click', () => { document.getElementById('bulkImportText').value = BULK_IMPORT_TEMPLATE; }); document.getElementById('downloadImportTemplateBtn')?.addEventListener('click', async () => { try { await API.download('/api/v1/catalog/import-template', 'staysync_catalog_import.csv'); setBulkImportStatus('양식 CSV 다운로드 완료'); } catch (e) { if (e.message === 'auth') return; setBulkImportStatus(e.message || '다운로드 실패', true); } }); document.getElementById('bulkImportBtn').addEventListener('click', runBulkImport); document.getElementById('bulkImportFile').addEventListener('change', (e) => { const file = e.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => { document.getElementById('bulkImportText').value = String(reader.result || ''); setBulkImportStatus(`파일 로드: ${file.name}`); }; reader.readAsText(file, 'UTF-8'); }); async function resetDatabase() { const ok = confirm( '모든 업체·객실·매핑 데이터가 삭제됩니다.\n' + 'admin + 기본 회사만 남고 처음부터 다시 세팅합니다.\n\n' + '계속하시겠습니까?', ); if (!ok) return; if (!confirm('정말 DB를 초기화할까요? 이 작업은 되돌릴 수 없습니다.')) return; const statusEl = document.getElementById('resetDbStatus'); statusEl.textContent = '초기화 중…'; try { const result = await API.post('/api/v1/admin/reset-database', {}); sessionStorage.removeItem('staysync_last_discover'); sessionStorage.removeItem('staysync_catalog'); statusEl.textContent = result.message || '완료'; setSetupStatus('DB 초기화 완료 — 페이지를 새로고침합니다.'); setTimeout(() => window.location.reload(), 800); } catch (e) { statusEl.textContent = e.message || '초기화 실패 (admin 권한 필요)'; console.error(e); } } document.getElementById('resetDbBtn')?.addEventListener('click', resetDatabase); document.getElementById('matchLegend')?.addEventListener('change', (e) => { if (e.target.id === 'filterIncompleteOnly') { state.filterIncompleteOnly = e.target.checked; renderMappingView(); } else if (e.target.id === 'filterHiddenOnly') { state.filterHiddenOnly = e.target.checked; renderMappingView(); } }); document.getElementById('closeMappingEditor').addEventListener('click', closeMappingEditor); document.getElementById('mappingEditorBackdrop').addEventListener('click', (e) => { if (e.target.id === 'mappingEditorBackdrop') closeMappingEditor(); }); document.getElementById('saveMappingBtn').addEventListener('click', saveMapping); document.getElementById('applySuggestionBtn').addEventListener('click', applySuggestionForEditingRoom); (async function init() { if (!requireAuth()) return; await applyAdminNav(); const catalog = await initCatalogSelectors({ onPropertyChange: async (propertyGroupId, groups) => { state.propertyGroupId = propertyGroupId; state.companyId = document.getElementById('companySelect').value; state.partsExpandedGroups = new Set(); state.partsSearchQuery = ''; state.partsModalSearchQuery = ''; const pg = groups?.find((g) => g.id === propertyGroupId); fillPropertyGroupForm(pg); await loadParts(); await loadRooms(); restoreDiscoverDiagnostics(); if (!groups?.length) { setSetupStatus('통합 업체를 추가한 뒤 Parts · discover를 진행하세요.'); } }, }); if (catalog) { state.companyId = catalog.companyId; state.propertyGroupId = catalog.propertyGroupId; const pg = catalog.groups?.find((g) => g.id === catalog.propertyGroupId); fillPropertyGroupForm(pg); } if (!catalog?.companies?.length) { setSetupStatus('회사가 없습니다. API로 Company를 먼저 등록하세요.', true); } restoreDiscoverDiagnostics(); initPartsPanel(); window.addEventListener('pageshow', (e) => { if (e.persisted && state.propertyGroupId) { loadRooms(); } }); })();