const STATE_META = { open: { label: '예약가능', className: 'open' }, closed: { label: '마감', className: 'closed' }, hardblock: { label: '하드블락', className: 'hardblock' }, scheduled: { label: '오픈예정', className: 'scheduled' }, }; const WEEKDAY_LABELS = ['일', '월', '화', '수', '목', '금', '토']; const CALENDAR_POLL_MS = 5 * 60 * 1000; const SYNC_FRESH_MS = 15 * 60 * 1000; const SYNC_STALE_MS = 30 * 60 * 1000; const CHANNEL_LABELS = { ddona_integrated: '떠나요', naver_integrated: '네이버', yeogi_integrated: '여기어때', yanolja_integrated: '야놀자', onda_integrated: '온다', }; const syncState = { phase: 'idle', lastAt: null, dryRun: false, propertyGroupId: null, result: null, errorMessage: null, }; let calendarRequestSeq = 0; const state = { rooms: [], propertyGroups: [], roomFilterIds: new Set(), companyId: null, propertyGroupId: null, view: 'week', focusDate: (() => { const d = new Date(); const m = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); return `${d.getFullYear()}-${m}-${day}`; })(), calendar: { rooms: [], cells: [] }, checkInSmsFilterOnly: false, selectedCellKey: null, selectedRuleCellKeys: new Set(), ruleSetupMode: 'hardblock', loading: false, expandedPastWeeks: new Set(), }; const roomCombobox = { open: false, query: '', }; let guestMemoModalRoomId = null; function escapeHtml(s) { return String(s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function parseDate(s) { return new Date(`${s}T00:00:00`); } function formatDate(d) { 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 formatKoreanDate(d) { 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 todayYmd() { return formatDate(new Date()); } function isPastDate(date) { return date < todayYmd(); } function addDays(d, n) { const x = new Date(d); x.setDate(x.getDate() + n); return x; } function startOfWeek(d) { const x = new Date(d); x.setDate(x.getDate() - x.getDay()); return x; } function monthWeeks(year, month) { const weeks = []; let start = startOfWeek(new Date(year, month, 1)); const lastDay = new Date(year, month + 1, 0); while (true) { const dates = Array.from({ length: 7 }, (_, i) => { const d = addDays(start, i); return { date: formatDate(d), inMonth: d.getMonth() === month }; }); weeks.push(dates); const weekEnd = addDays(start, 6); if (weekEnd >= lastDay) break; start = addDays(start, 7); } return weeks; } function dateRange(start, end) { const out = []; let cur = parseDate(start); const last = parseDate(end); while (cur <= last) { out.push(formatDate(cur)); cur = addDays(cur, 1); } return out; } function viewDateRange() { const focus = parseDate(state.focusDate); if (state.view === 'day') { return { start: state.focusDate, end: state.focusDate }; } if (state.view === 'week') { const start = startOfWeek(focus); const end = addDays(start, 6); return { start: formatDate(start), end: formatDate(end) }; } const start = new Date(focus.getFullYear(), focus.getMonth(), 1); const end = new Date(focus.getFullYear(), focus.getMonth() + 1, 0); return { start: formatDate(start), end: formatDate(end) }; } /** 일별 보기 연박 판별용 — 화면은 하루만, 데이터는 전후 포함 */ function calendarFetchDateRange() { if (state.view !== 'day') { return viewDateRange(); } const focus = parseDate(state.focusDate); return { start: formatDate(addDays(focus, -30)), end: formatDate(addDays(focus, 30)), }; } function formatPrice(n) { if (n == null) return '—'; return `${Number(n).toLocaleString('ko-KR')}원`; } function renderStateDot(stateKey, { label } = {}) { const meta = STATE_META[stateKey] || STATE_META.closed; const aria = label || meta.label; return ``; } function initCalendarPolling() { document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && state.propertyGroupId && !state.loading) { loadCalendar(); } }); setInterval(() => { if (document.visibilityState !== 'visible' || !state.propertyGroupId || state.loading) return; loadCalendar(); }, CALENDAR_POLL_MS); } function formatSyncTime(iso) { if (!iso) return '—'; return new Date(iso).toLocaleString('ko-KR'); } function syncStorageKey(propertyGroupId) { return `staysync_sync_${propertyGroupId}`; } function saveSyncState() { if (!syncState.propertyGroupId) return; try { sessionStorage.setItem(syncStorageKey(syncState.propertyGroupId), JSON.stringify({ phase: syncState.phase, lastAt: syncState.lastAt, dryRun: syncState.dryRun, result: syncState.result, errorMessage: syncState.errorMessage, })); } catch (_) { /* ignore quota */ } } function loadSyncState(propertyGroupId) { syncState.phase = 'idle'; syncState.lastAt = null; syncState.dryRun = false; syncState.propertyGroupId = propertyGroupId; syncState.result = null; syncState.errorMessage = null; if (!propertyGroupId) { renderSyncStatus(); return; } try { const raw = sessionStorage.getItem(syncStorageKey(propertyGroupId)); if (raw) { const saved = JSON.parse(raw); Object.assign(syncState, saved, { propertyGroupId }); syncState.phase = resolveSyncPhase(); } } catch (_) { /* ignore */ } renderSyncStatus(); } function channelOutcome(result) { const channels = result?.channel_results || []; if (!channels.length) return { allOk: true, anyFail: false, anyOk: false }; const anyFail = channels.some((c) => !c.success); const anyOk = channels.some((c) => c.success); const allOk = channels.every((c) => c.success); return { allOk, anyFail, anyOk }; } function resolveSyncPhase() { if (syncState.phase === 'running') return 'running'; if (syncState.errorMessage) return 'error'; if (!syncState.lastAt) return 'idle'; const age = Date.now() - new Date(syncState.lastAt).getTime(); const errors = syncState.result?.errors || []; const { allOk, anyFail } = channelOutcome(syncState.result); if (errors.length) return 'error'; if (anyFail && !allOk) return 'error'; if (anyFail) return 'warn'; if (syncState.dryRun) return 'warn'; if (age > SYNC_STALE_MS) return 'warn'; if (age <= SYNC_FRESH_MS && allOk) return 'ok'; if (age <= SYNC_FRESH_MS) return 'ok'; return 'warn'; } function syncPhaseMeta(phase) { const map = { idle: { dot: 'sync-dot-idle', text: '대기' }, running: { dot: 'sync-dot-running', text: '진행 중' }, ok: { dot: 'sync-dot-ok', text: '정상' }, warn: { dot: 'sync-dot-warn', text: '주의' }, error: { dot: 'sync-dot-error', text: '오류' }, }; return map[phase] || map.idle; } function buildSyncSummary() { const phase = resolveSyncPhase(); if (phase === 'idle') { return '아직 동기화 기록이 없습니다. 수동 동기화 또는 Dry-run을 실행하세요.'; } if (phase === 'running') { return syncState.dryRun ? 'Dry-run 실행 중…' : '동기화 실행 중…'; } const when = formatSyncTime(syncState.lastAt); const mode = syncState.dryRun ? 'Dry-run' : '실제 동기화'; if (syncState.errorMessage) { return `${mode} 실패 · ${when}\n${syncState.errorMessage}`; } const r = syncState.result; if (!r) return `${mode} · ${when}`; const changes = r.changes_detected ?? 0; const writes = r.writes_count ?? 0; const errors = r.errors?.length || 0; if (phase === 'error') { return `${mode} 오류 · ${when}\n변경 ${changes}건 · Write ${writes}건${errors ? ` · 오류 ${errors}건` : ''}`; } if (phase === 'warn' && syncState.dryRun) { return `Dry-run 완료 · ${when}\n변경 ${changes}건 (Write 미실행)`; } if (phase === 'warn') { const ageMin = Math.round((Date.now() - new Date(syncState.lastAt).getTime()) / 60000); return `${mode} · ${when} (${ageMin}분 전)\n최근 동기화가 오래되었거나 일부 채널에 주의가 필요합니다.`; } return `${mode} 성공 · ${when}\n변경 ${changes}건 · Write ${writes}건`; } function renderSyncStatus() { const dot = document.getElementById('syncStatusDot'); const summary = document.getElementById('syncStatusSummary'); const channelList = document.getElementById('syncChannelList'); const rawDetails = document.getElementById('syncRawDetails'); const rawJson = document.getElementById('syncRawJson'); const btn = document.getElementById('syncStatusBtn'); if (!dot || !summary) return; const phase = resolveSyncPhase(); const meta = syncPhaseMeta(phase); dot.className = `sync-dot ${meta.dot}`; btn?.setAttribute('title', `동기화: ${meta.text}`); summary.textContent = buildSyncSummary(); const channels = syncState.result?.channel_results || []; if (channelList) { if (channels.length && phase !== 'idle' && phase !== 'running') { channelList.classList.remove('hidden'); channelList.innerHTML = channels.map((ch) => { const label = CHANNEL_LABELS[ch.channel] || ch.channel; const ok = ch.success; const detail = ok ? `${ch.latency_ms ?? '—'}ms` : (ch.error || '실패'); return `
  • ${label} ${ok ? '성공' : '실패'}
  • `; }).join(''); } else { channelList.classList.add('hidden'); channelList.innerHTML = ''; } } if (rawDetails && rawJson) { if (syncState.result && phase !== 'running') { rawDetails.classList.remove('hidden'); rawJson.textContent = JSON.stringify(syncState.result, null, 2); } else { rawDetails.classList.add('hidden'); rawJson.textContent = ''; } } } function toggleSyncPanel(open) { const panel = document.getElementById('syncStatusPanel'); const btn = document.getElementById('syncStatusBtn'); if (!panel || !btn) return; const show = open ?? panel.classList.contains('hidden'); panel.classList.toggle('hidden', !show); btn.setAttribute('aria-expanded', show ? 'true' : 'false'); } function setSyncRunning(dryRun) { syncState.phase = 'running'; syncState.dryRun = dryRun; syncState.errorMessage = null; syncState.propertyGroupId = state.propertyGroupId; renderSyncStatus(); } function applySyncResult(result, dryRun) { syncState.phase = 'done'; syncState.lastAt = new Date().toISOString(); syncState.dryRun = dryRun; syncState.result = result; syncState.errorMessage = null; syncState.propertyGroupId = state.propertyGroupId; syncState.phase = resolveSyncPhase(); saveSyncState(); renderSyncStatus(); } function applySyncError(err, dryRun) { syncState.lastAt = new Date().toISOString(); syncState.dryRun = dryRun; syncState.result = null; syncState.errorMessage = err?.message || String(err); syncState.propertyGroupId = state.propertyGroupId; syncState.phase = 'error'; saveSyncState(); renderSyncStatus(); } async function triggerSync(dryRun) { if (!state.propertyGroupId) return; setSyncRunning(dryRun); const syncBtn = document.getElementById('syncBtn'); const dryRunBtn = document.getElementById('dryRunBtn'); if (syncBtn) syncBtn.disabled = true; if (dryRunBtn) dryRunBtn.disabled = true; try { const result = await API.post(dryRun ? '/api/v1/sync/dry-run' : '/api/v1/sync/trigger', { property_group_id: state.propertyGroupId, dry_run: dryRun, }); applySyncResult(result, dryRun); await loadCalendar(); } catch (err) { applySyncError(err, dryRun); } finally { if (syncBtn) syncBtn.disabled = false; if (dryRunBtn) dryRunBtn.disabled = false; } } function initSyncStatusUI() { const btn = document.getElementById('syncStatusBtn'); const panel = document.getElementById('syncStatusPanel'); const root = document.getElementById('syncStatus'); if (!btn || !panel) return; btn.addEventListener('click', (e) => { e.stopPropagation(); toggleSyncPanel(); }); document.addEventListener('click', (e) => { if (!root?.contains(e.target)) toggleSyncPanel(false); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') toggleSyncPanel(false); }); document.getElementById('syncBtn')?.addEventListener('click', () => triggerSync(false)); document.getElementById('dryRunBtn')?.addEventListener('click', () => triggerSync(true)); renderSyncStatus(); } function normalizeRoomId(roomId) { if (roomId == null) return ''; return String(roomId).trim().toLowerCase(); } function cellKey(date, roomId) { return `${date}|${normalizeRoomId(roomId)}`; } function normalizeCellKey(key) { if (!key) return ''; const idx = key.indexOf('|'); if (idx < 0) return String(key).trim(); const date = key.slice(0, idx).trim(); const roomId = normalizeRoomId(key.slice(idx + 1)); return date && roomId ? `${date}|${roomId}` : ''; } function findCell(date, roomId) { const cells = state.calendar?.cells ?? []; const rid = normalizeRoomId(roomId); return cells.find( (c) => c.target_date === date && normalizeRoomId(c.room_mapping_id) === rid, ); } function shiftDateYmd(ymd, delta) { return formatDate(addDays(parseDate(ymd), delta)); } function sameReservation(a, b) { if (!a?.reservation_no || !b?.reservation_no) return false; return a.reservation_no === b.reservation_no; } function getStayRunPosition(date, roomId, reservation) { if (!reservation?.reservation_no) return null; const prevCell = findCell(shiftDateYmd(date, -1), roomId); const nextCell = findCell(shiftDateYmd(date, 1), roomId); const hasPrev = sameReservation(reservation, prevCell?.reservation); const hasNext = sameReservation(reservation, nextCell?.reservation); if (!hasPrev && !hasNext) return 'single'; if (!hasPrev && hasNext) return 'start'; if (hasPrev && hasNext) return 'mid'; if (hasPrev && !hasNext) return 'end'; return 'single'; } function stayRunClass(date, roomId, cell) { const pos = getStayRunPosition(date, roomId, cell?.reservation); if (!pos || pos === 'single') return ''; return ` stay-run stay-run--${pos}`; } function stayNightCount(date, roomId, cell) { const pos = getStayRunPosition(date, roomId, cell?.reservation); if (pos !== 'start' && pos !== 'single') return 0; let nights = 1; let d = date; while (sameReservation(cell?.reservation, findCell(shiftDateYmd(d, 1), roomId)?.reservation)) { nights += 1; d = shiftDateYmd(d, 1); } return nights; } function stayNightProgress(date, roomId, reservation) { if (!reservation?.reservation_no) return null; let index = 1; let d = date; while (sameReservation(reservation, findCell(shiftDateYmd(d, -1), roomId)?.reservation)) { index += 1; d = shiftDateYmd(d, -1); } const startDate = d; let total = 1; let forward = startDate; while (sameReservation(reservation, findCell(shiftDateYmd(forward, 1), roomId)?.reservation)) { total += 1; forward = shiftDateYmd(forward, 1); } if (total <= 1) return null; return { index, total, startDate }; } function isFirstNightOfStay(date, roomId, reservation) { if (!reservation) return false; const pos = getStayRunPosition(date, roomId, reservation); return pos === 'start' || pos === 'single'; } function currentPropertyGroup() { if (!state.propertyGroupId) return null; const pid = normalizeRoomId(state.propertyGroupId); return state.propertyGroups.find((g) => normalizeRoomId(g.id) === pid) || null; } function openRangeEndDate() { const pg = currentPropertyGroup(); if (pg?.open_until) return pg.open_until; const days = Number(pg?.open_days) || 0; if (days <= 0) return null; return formatDate(addDays(new Date(), days)); } function isWithinOpenRange(dateStr) { const end = openRangeEndDate(); if (!end) return true; return dateStr <= end; } function updateOpenRangeHint() { const el = document.getElementById('calOpenRangeHint'); if (!el) return; const end = openRangeEndDate(); if (!end) { el.classList.add('hidden'); el.textContent = ''; return; } el.classList.remove('hidden'); el.textContent = `채널 오픈: 오늘 ~ ${formatKoreanDate(parseDate(end))}`; } function filteredRooms() { if (!state.rooms.length) return []; let rooms = state.rooms.filter( (r) => state.roomFilterIds.has(r.id) && r.is_exposed !== false, ); if (state.checkInSmsFilterOnly) { rooms = rooms.filter((r) => r.check_in_sms_enabled); } return rooms; } function visibleRooms() { const rooms = filteredRooms(); if (state.view === 'month') { return roomsWithCellsInView(rooms); } return rooms; } function roomsWithCellsInView(rooms) { if (!rooms.length) return []; const cells = state.calendar?.cells; if (!cells?.length) return rooms; const { start, end } = viewDateRange(); const ids = new Set(); for (const cell of cells) { if (cell.target_date < start || cell.target_date > end) continue; ids.add(normalizeRoomId(cell.room_mapping_id)); } if (!ids.size) return rooms; return rooms.filter((r) => ids.has(normalizeRoomId(r.id))); } function activeRoomIdsForApi() { return filteredRooms().map((r) => r.id); } function buildDates(days = 7) { const out = []; const base = parseDate(state.focusDate); for (let i = 0; i < days; i++) { out.push(formatDate(addDays(base, i))); } return out; } function openModal(id) { document.getElementById(id)?.classList.remove('hidden'); document.body.classList.add('dash-modal-open'); } function closeModal(id) { document.getElementById(id)?.classList.add('hidden'); if (!document.querySelector('.dash-modal:not(.hidden), .dash-drawer:not(.hidden)')) { document.body.classList.remove('dash-modal-open'); } } function bindModalClosers() { document.querySelectorAll('[data-close]').forEach((el) => { el.addEventListener('click', () => closeModal(el.dataset.close)); }); document.addEventListener('keydown', (e) => { if (e.key !== 'Escape') return; ['cellDetailModal', 'ruleSetupModal', 'guestMemoModal'].forEach(closeModal); }); } function updateSetupHint() { const el = document.getElementById('setupHint'); const show = !state.propertyGroups.length || !state.rooms.length; el.classList.toggle('hidden', !show); } function parseCellKey(key) { const idx = key.indexOf('|'); return { date: key.slice(0, idx), roomId: key.slice(idx + 1) }; } function findRoom(roomId) { return state.rooms.find((r) => r.id === roomId); } function getGuestMemo(roomId) { const memo = findRoom(roomId)?.guest_check_in_memo; return memo?.trim() || ''; } function cellNeedsCheckInSms(cell) { if (!cell?.reservation?.needs_check_in_sms) return false; return isFirstNightOfStay(cell.target_date, cell.room_mapping_id, cell.reservation); } function cellCheckInSmsPending(cell) { return cellNeedsCheckInSms(cell) && !cell.reservation.check_in_sms_sent; } function cellCheckInSmsSent(cell) { return cellNeedsCheckInSms(cell) && cell.reservation.check_in_sms_sent; } function renderGuestMemoIcon(roomId) { if (!getGuestMemo(roomId)) return ''; return '📝'; } function renderCheckInSmsBadge(cell) { if (!cellNeedsCheckInSms(cell)) return ''; const sent = cell.reservation.check_in_sms_sent; const cls = sent ? ' cal-checkin-sms-badge--sent' : ' cal-checkin-sms-badge--pending'; const title = sent ? '입실 안내 문자 발송 완료' : '입실 안내 문자 발송 필요'; const label = sent ? '✓' : '안내'; return `${label}`; } function roomHasPendingCheckInSmsInView(roomId) { const { start, end } = viewDateRange(); const rid = normalizeRoomId(roomId); return (state.calendar?.cells ?? []).some((c) => { if (normalizeRoomId(c.room_mapping_id) !== rid) return false; if (c.target_date < start || c.target_date > end) return false; return cellCheckInSmsPending(c); }); } function buildCheckInSmsQueue({ pendingOnly = true } = {}) { const { start, end } = viewDateRange(); const items = []; for (const cell of state.calendar?.cells ?? []) { if (cell.target_date < start || cell.target_date > end) continue; if (!cellNeedsCheckInSms(cell)) continue; if (pendingOnly && cell.reservation.check_in_sms_sent) continue; const room = findRoom(cell.room_mapping_id); items.push({ slotId: cell.reservation.slot_id, date: cell.target_date, roomId: cell.room_mapping_id, roomName: room?.display_name || cell.display_name, guestName: cell.reservation.guest_name, guestPhone: cell.reservation.guest_phone, reservationNo: cell.reservation.reservation_no, sent: Boolean(cell.reservation.check_in_sms_sent), key: cellKey(cell.target_date, cell.room_mapping_id), }); } items.sort((a, b) => a.date.localeCompare(b.date) || a.roomName.localeCompare(b.roomName, 'ko')); return items; } function updateCellCheckInSmsState(cellKey, sent, sentAt = null) { const { date, roomId } = parseCellKey(cellKey); const cell = findCell(date, roomId); if (!cell?.reservation) return; cell.reservation.check_in_sms_sent = sent; cell.reservation.check_in_sms_sent_at = sentAt; } async function markCheckInSms(slotId, sent, cellKey) { const result = await API.patch(`/api/v1/reservation-slots/${slotId}/check-in-sms`, { sent }); updateCellCheckInSmsState(cellKey, result.check_in_sms_sent, result.check_in_sms_sent_at); renderCalendar(); updateCheckInSmsUI(); renderCheckInSmsModal(); } function smsEnabledRoomCount() { return state.rooms.filter((r) => r.check_in_sms_enabled).length; } function updateCheckInSmsUI() { const btn = document.getElementById('checkInSmsBtn'); const countEl = document.getElementById('checkInSmsBtnCount'); if (!btn || !countEl) return; const pending = buildCheckInSmsQueue({ pendingOnly: true }); const enabledRooms = smsEnabledRoomCount(); btn.disabled = pending.length === 0; countEl.textContent = pending.length ? `(${pending.length})` : ''; if (enabledRooms > 0) { btn.title = pending.length ? `미발송 ${pending.length}건 · 입실 안내 설정 객실 ${enabledRooms}개` : `입실 안내 설정 객실 ${enabledRooms}개 (표시 기간에 예약이 있는 날만 목록에 나타납니다)`; } else { btn.title = '설정 페이지 채널 매칭 「안내」열에서 발송 대상 객실을 체크하세요'; } } function renderCheckInSmsModal() { const list = document.getElementById('checkInSmsModalList'); const summary = document.getElementById('checkInSmsModalSummary'); if (!list) return; const pending = buildCheckInSmsQueue({ pendingOnly: true }); const all = buildCheckInSmsQueue({ pendingOnly: false }); if (summary) { const { start, end } = viewDateRange(); summary.textContent = `${start} ~ ${end} · 미발송 ${pending.length}건 / 전체 ${all.length}건`; } if (!all.length) { list.innerHTML = '

    표시 기간에 입실 안내 대상이 없습니다.

    '; return; } list.innerHTML = all.map((item) => ` `).join(''); list.querySelectorAll('.checkin-sms-check').forEach((input) => { input.addEventListener('change', async () => { const slotId = input.dataset.slotId; const key = input.dataset.cellKey; input.disabled = true; try { await markCheckInSms(slotId, input.checked, key); } catch (err) { input.checked = !input.checked; alert(err.message); } finally { input.disabled = false; } }); }); list.querySelectorAll('.checkin-sms-memo-btn').forEach((btn) => { btn.addEventListener('click', (e) => { e.preventDefault(); openGuestMemoModal(btn.dataset.roomId); }); }); } function openCheckInSmsModal() { renderCheckInSmsModal(); openModal('checkInSmsModal'); } function initCheckInSms() { document.getElementById('checkInSmsBtn')?.addEventListener('click', openCheckInSmsModal); document.getElementById('checkInSmsFilterOnly')?.addEventListener('change', (e) => { state.checkInSmsFilterOnly = e.target.checked; renderCalendar(); updateCheckInSmsUI(); }); } function renderDayCheckInSmsRow(cell, date, roomId) { if (!cellNeedsCheckInSms(cell)) return ''; const sent = cell.reservation.check_in_sms_sent; const slotId = cell.reservation.slot_id; const key = cellKey(date, roomId); return ` `; } function renderCompactCheckInSms(cell, date, roomId) { if (!cellNeedsCheckInSms(cell)) return ''; const sent = cell.reservation.check_in_sms_sent; const slotId = cell.reservation.slot_id; const key = cellKey(date, roomId); const label = sent ? '발송완료' : '입실안내'; return ` `; } function bindCalCheckInSmsChecks(root) { root.querySelectorAll('.cal-cell-checkin-sms, .cal-day-checkin-sms').forEach((label) => { label.addEventListener('click', (e) => e.stopPropagation()); }); root.querySelectorAll('.cal-cell-checkin-sms-check, .cal-day-checkin-sms-check').forEach((input) => { input.addEventListener('change', async (e) => { e.stopPropagation(); const slotId = input.dataset.slotId; const key = input.dataset.cellKey; input.disabled = true; try { await markCheckInSms(slotId, input.checked, key); } catch (err) { input.checked = !input.checked; alert(err.message); } finally { input.disabled = false; } }); input.addEventListener('click', (e) => e.stopPropagation()); }); } function updateGuestMemoToolbar() { const btn = document.getElementById('guestMemoBtn'); if (!btn) return; if (!state.selectedCellKey) { btn.disabled = true; return; } const { roomId } = parseCellKey(state.selectedCellKey); btn.disabled = !getGuestMemo(roomId); } function openGuestMemoModal(roomId) { const memo = getGuestMemo(roomId); if (!memo) return; guestMemoModalRoomId = roomId; const room = findRoom(roomId); document.getElementById('guestMemoModalRoom').textContent = room?.display_name || '—'; document.getElementById('guestMemoModalBody').textContent = memo; const status = document.getElementById('guestMemoModalStatus'); if (status) status.classList.add('hidden'); openModal('guestMemoModal'); } async function copyGuestMemoModal() { const roomId = guestMemoModalRoomId; if (!roomId) return; const memo = getGuestMemo(roomId); if (!memo) return; const status = document.getElementById('guestMemoModalStatus'); try { await navigator.clipboard.writeText(memo); if (status) { status.textContent = '클립보드에 복사했습니다.'; status.classList.remove('hidden'); setTimeout(() => status.classList.add('hidden'), 2000); } } catch { if (status) { status.textContent = '복사에 실패했습니다.'; status.classList.remove('hidden'); } } } function initGuestMemoModal() { document.getElementById('guestMemoBtn')?.addEventListener('click', () => { if (!state.selectedCellKey) return; const { roomId } = parseCellKey(state.selectedCellKey); openGuestMemoModal(roomId); }); document.getElementById('guestMemoModalCopyBtn')?.addEventListener('click', copyGuestMemoModal); } function renderGuestMemoDetailBlock(roomId) { const memo = getGuestMemo(roomId); if (!memo) return ''; return `
    체크인 안내
    ${escapeHtml(memo)}
    `; } function ruleCellSelectionSummary() { const keys = [...state.selectedRuleCellKeys]; const rooms = new Set(); const dates = new Set(); keys.forEach((k) => { const { date, roomId } = parseCellKey(k); rooms.add(roomId); dates.add(date); }); return { count: keys.length, roomCount: rooms.size, dateCount: dates.size }; } function updateRuleSelectionBar() { const bar = document.getElementById('ruleSelectionBar'); const label = document.getElementById('selectedCellLabel'); const { count, roomCount, dateCount } = ruleCellSelectionSummary(); if (!bar || !label) return; bar.classList.toggle('hidden', count === 0); label.textContent = count ? `${count}개 셀 · ${roomCount}개 객실 · ${dateCount}일` : '0개 셀 선택'; const info = document.getElementById('ruleSelectionInfo'); if (info) info.textContent = `선택: ${count}개 셀 · ${roomCount}개 객실 · ${dateCount}일`; updateCalStickyOffsets(); } function clearMonthStickyHead() { const el = document.getElementById('calMonthStickyHead'); if (!el) return; el.classList.add('hidden'); el.setAttribute('aria-hidden', 'true'); el.innerHTML = ''; } function renderMonthStickyWeekdayRow() { const el = document.getElementById('calMonthStickyHead'); if (!el) return; let html = ''; el.innerHTML = html; el.classList.remove('hidden'); el.setAttribute('aria-hidden', 'false'); } function updateCalStickyOffsets() { const appHeader = document.querySelector('.app-header'); const chrome = document.querySelector('.cal-sticky-chrome'); const monthHead = document.getElementById('calMonthStickyHead'); const appTop = appHeader ? Math.ceil(appHeader.getBoundingClientRect().height) : 60; document.documentElement.style.setProperty('--app-sticky-top', `${appTop}px`); const chromeH = chrome ? Math.ceil(chrome.getBoundingClientRect().height) : 0; const monthHeadH = monthHead && !monthHead.classList.contains('hidden') ? Math.ceil(monthHead.getBoundingClientRect().height) : 0; document.documentElement.style.setProperty('--cal-month-weekday-height', `${monthHeadH}px`); document.documentElement.style.setProperty('--cal-sticky-below-top', `${appTop + chromeH + monthHeadH}px`); } function initCalStickyObserver() { const chrome = document.querySelector('.cal-sticky-chrome'); if (!chrome || typeof ResizeObserver === 'undefined') return; if (initCalStickyObserver._observer) return; initCalStickyObserver._observer = new ResizeObserver(() => { updateCalStickyOffsets(); }); initCalStickyObserver._observer.observe(chrome); const ruleBar = document.getElementById('ruleSelectionBar'); if (ruleBar) initCalStickyObserver._observer.observe(ruleBar); const monthHead = document.getElementById('calMonthStickyHead'); if (monthHead) initCalStickyObserver._observer.observe(monthHead); } function onCalendarCellClick(rawKey) { const key = normalizeCellKey(rawKey); if (!key) return; if (state.selectedRuleCellKeys.has(key)) { state.selectedRuleCellKeys.delete(key); if (normalizeCellKey(state.selectedCellKey) === key) { state.selectedCellKey = null; } } else { state.selectedRuleCellKeys.add(key); state.selectedCellKey = key; } updateRuleSelectionUI(); renderCellDetail(); updateGuestMemoToolbar(); } function toggleRuleDateColumn(date) { if (!date) return; const keys = visibleRooms().map((room) => cellKey(date, room.id)); if (!keys.length) return; const allSelected = keys.every((k) => state.selectedRuleCellKeys.has(k)); keys.forEach((k) => { if (allSelected) { state.selectedRuleCellKeys.delete(k); } else { state.selectedRuleCellKeys.add(k); } }); if (allSelected) { const activeKey = normalizeCellKey(state.selectedCellKey); if (keys.some((k) => k === activeKey)) { state.selectedCellKey = null; } } updateRuleSelectionUI(); renderCellDetail(); updateGuestMemoToolbar(); } function clearRuleCellSelection() { state.selectedRuleCellKeys.clear(); state.selectedCellKey = null; updateRuleSelectionUI(); renderCellDetail(); updateGuestMemoToolbar(); } function isRuleCellSelected(key) { return state.selectedRuleCellKeys.has(normalizeCellKey(key)); } function updateRuleSelectionUI() { updateRuleSelectionBar(); const activeKey = normalizeCellKey(state.selectedCellKey); document.querySelectorAll('[data-key]').forEach((el) => { const key = normalizeCellKey(el.getAttribute('data-key')); if (!key) return; el.classList.toggle('rule-selected', state.selectedRuleCellKeys.has(key)); el.classList.toggle('selected', activeKey === key); }); } function updateSelectionInfo() { const count = visibleRooms().length; document.getElementById('calRoomCount').textContent = `${count}개 객실 표시`; } function renderLegend() { const el = document.getElementById('calLegend'); const stateLegendKeys = ['open', 'closed']; el.innerHTML = stateLegendKeys.map((key) => { const meta = STATE_META[key]; return ` ${meta.label} `; }).join('') + ` 안내입실 안내 필요 입실 안내 완료 예약확정 예약 HB하드블락 M마크업 오픈 기간 외 셀 클릭 → 규칙 대상 · 날짜 헤더 → 해당일 전체`; } function renderCalTitle() { const focus = parseDate(state.focusDate); const title = document.getElementById('calTitle'); if (state.view === 'day') { title.textContent = formatKoreanDate(focus); } else if (state.view === 'week') { const { start, end } = viewDateRange(); const s = parseDate(start); const e = parseDate(end); title.textContent = `${formatKoreanDate(s)} — ${formatKoreanDate(e)}`; } else { const m = String(focus.getMonth() + 1).padStart(2, '0'); title.textContent = `${focus.getFullYear()}년 ${m}월`; } document.getElementById('calFocusDate').value = state.focusDate; } function roomFilterLabel() { if (!state.rooms.length) return '객실 없음'; const n = state.roomFilterIds.size; const total = state.rooms.length; if (n === 0) return '객실 미선택'; if (n === total) return `전체 (${total}개)`; if (n === 1) { const id = [...state.roomFilterIds][0]; const room = state.rooms.find((r) => r.id === id); return room?.display_name || '1개 선택'; } return `${n}개 객실 선택`; } function syncRoomFilterIdsWithRooms() { if (!state.rooms.length) { state.roomFilterIds.clear(); return; } const valid = new Set(state.rooms.map((r) => r.id)); state.roomFilterIds = new Set([...state.roomFilterIds].filter((id) => valid.has(id))); if (!state.roomFilterIds.size) { state.roomFilterIds = new Set(valid); } } function updateRoomFilterTrigger() { const btn = document.getElementById('roomComboboxBtn'); if (btn) btn.textContent = roomFilterLabel(); updateSelectionInfo(); } function toggleRoomFilterId(roomId, checked) { if (checked) { state.roomFilterIds.add(roomId); } else { state.roomFilterIds.delete(roomId); } state.selectedCellKey = null; updateRoomFilterTrigger(); renderRoomFilterList(); loadCalendar(); } function selectAllRoomFilter() { state.roomFilterIds = new Set(state.rooms.map((r) => r.id)); state.selectedCellKey = null; updateRoomFilterTrigger(); renderRoomFilterList(); loadCalendar(); } function clearAllRoomFilter() { state.roomFilterIds.clear(); state.selectedCellKey = null; state.selectedRuleCellKeys.clear(); updateRuleSelectionUI(); updateRoomFilterTrigger(); renderRoomFilterList(); loadCalendar(); } function selectFilteredRoomFilter() { filteredRooms().forEach((r) => state.roomFilterIds.add(r.id)); state.selectedCellKey = null; updateRoomFilterTrigger(); renderRoomFilterList(); loadCalendar(); } function filteredRooms() { const q = roomCombobox.query.trim().toLowerCase(); if (!q) return state.rooms; return state.rooms.filter((r) => { const name = (r.display_name || '').toLowerCase(); const part = (r.part_name || '').toLowerCase(); return name.includes(q) || part.includes(q); }); } function toggleRoomCombobox(open) { const panel = document.getElementById('roomComboboxPanel'); const btn = document.getElementById('roomComboboxBtn'); if (!panel || !btn) return; const show = open ?? panel.classList.contains('hidden'); roomCombobox.open = show; panel.classList.toggle('hidden', !show); btn.setAttribute('aria-expanded', show ? 'true' : 'false'); if (show) { roomCombobox.query = ''; const input = document.getElementById('roomSearchInput'); if (input) { input.value = ''; renderRoomFilterList(); input.focus(); } } } function closeRoomCombobox() { if (!roomCombobox.open) return; toggleRoomCombobox(false); } function renderRoomFilterList() { const list = document.getElementById('roomComboboxList'); if (!list) return; if (!state.rooms.length) { list.innerHTML = '
  • 객실 없음
  • '; return; } const rooms = filteredRooms(); const allSelected = state.roomFilterIds.size === state.rooms.length; const filteredAllSelected = rooms.length > 0 && rooms.every((r) => state.roomFilterIds.has(r.id)); let html = `
  • `; if (roomCombobox.query && rooms.length) { html += `
  • `; } if (!rooms.length && roomCombobox.query) { html += '
  • 검색 결과 없음
  • '; } else { rooms.forEach((r) => { const checked = state.roomFilterIds.has(r.id); html += `
  • `; }); } list.innerHTML = html; list.querySelectorAll('.room-filter-check').forEach((input) => { input.addEventListener('change', (e) => { e.stopPropagation(); const mode = input.dataset.roomFilter; if (mode === 'all') { if (input.checked) selectAllRoomFilter(); else clearAllRoomFilter(); return; } if (mode === 'filtered') { if (input.checked) selectFilteredRoomFilter(); else { rooms.forEach((r) => state.roomFilterIds.delete(r.id)); state.selectedCellKey = null; updateRoomFilterTrigger(); renderRoomFilterList(); loadCalendar(); } return; } toggleRoomFilterId(input.dataset.roomId, input.checked); }); }); } function renderRoomFilter() { const btn = document.getElementById('roomComboboxBtn'); const root = document.getElementById('roomCombobox'); if (!btn || !root) return; if (!state.rooms.length) { btn.textContent = '객실 없음'; btn.disabled = true; root.classList.add('disabled'); closeRoomCombobox(); renderRoomFilterList(); updateSelectionInfo(); return; } btn.disabled = false; root.classList.remove('disabled'); syncRoomFilterIdsWithRooms(); updateRoomFilterTrigger(); renderRoomFilterList(); } function initRoomCombobox() { const btn = document.getElementById('roomComboboxBtn'); const root = document.getElementById('roomCombobox'); const search = document.getElementById('roomSearchInput'); if (!btn || !root) return; document.getElementById('roomFilterSelectAllBtn')?.addEventListener('click', (e) => { e.stopPropagation(); selectAllRoomFilter(); }); document.getElementById('roomFilterClearAllBtn')?.addEventListener('click', (e) => { e.stopPropagation(); clearAllRoomFilter(); }); btn.addEventListener('click', (e) => { e.stopPropagation(); if (btn.disabled) return; toggleRoomCombobox(); }); search?.addEventListener('input', (e) => { roomCombobox.query = e.target.value; renderRoomFilterList(); }); search?.addEventListener('keydown', (e) => { if (e.key === 'Escape') { closeRoomCombobox(); btn.focus(); } }); document.addEventListener('click', (e) => { if (!root.contains(e.target)) closeRoomCombobox(); }); } function buildHardBlockTooltip(cell) { if (!cell?.is_hard_block) return '하드블락'; const parts = ['하드블락']; if (cell.open_channels?.length) { parts.push(`오픈 ${cell.open_channels.join(', ')}`); } cell.scheduled_channels?.forEach((s) => { const when = formatUtcDateTime(s.open_at, { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit', }); parts.push(`${s.label} ${when}`); }); if (parts.length === 1) parts.push('채널 오픈 대기'); return parts.join(' · '); } function buildMarkupTooltip(cell) { if (!cell?.is_markup_enabled && cell?.rule_kind !== 'markup' && cell?.rule_kind !== 'hardblock_markup') { return '마크업'; } const parts = ['마크업']; if (cell.markup_amount != null && cell.markup_amount !== 0) { parts.push(`+${formatPrice(cell.markup_amount)}`); } else if (cell.final_price != null && cell.base_price != null && cell.final_price !== cell.base_price) { parts.push(`판매 ${formatPrice(cell.final_price)}`); } return parts.join(' · '); } function markupBadgeLabel(cell, compact) { const amount = cell?.markup_amount; if (amount != null && amount !== 0) { if (compact && Math.abs(amount) >= 10000) { return `+${Math.round(amount / 10000)}만`; } if (compact) return 'M'; return `+${Number(amount).toLocaleString('ko-KR')}`; } return 'M'; } function renderRuleBadges(cell, { compact = false } = {}) { if (!cell?.rule_kind) return ''; const badges = []; if (cell.rule_kind === 'hardblock' || cell.rule_kind === 'hardblock_markup') { badges.push( `HB`, ); } if (cell.rule_kind === 'markup' || cell.rule_kind === 'hardblock_markup') { const label = markupBadgeLabel(cell, compact); badges.push( `${escapeHtml(label)}`, ); } return `${badges.join('')}`; } function cellRuleClasses(cell) { if (!cell?.rule_kind) return ''; if (cell.rule_kind === 'hardblock_markup') return 'rule-hardblock rule-markup'; if (cell.rule_kind === 'hardblock') return 'rule-hardblock'; if (cell.rule_kind === 'markup') return 'rule-markup'; return ''; } function buildCellAriaLabel(cell, meta) { const parts = []; if (meta?.label) parts.push(meta.label); if (cell?.reservation?.guest_name) { parts.push(`예약 ${cell.reservation.guest_name}`); } if (cell?.is_hard_block) parts.push(buildHardBlockTooltip(cell)); if (cell?.rule_kind === 'markup' || cell?.rule_kind === 'hardblock_markup') { parts.push(buildMarkupTooltip(cell)); } const price = cell?.final_price ?? cell?.base_price; if (price != null) parts.push(formatPrice(price)); return parts.filter(Boolean).join(' · '); } function formatGuestLabel(reservation, { compact = false } = {}) { if (!reservation?.guest_name) return '예약'; const name = String(reservation.guest_name).trim(); if (!name) return '예약'; return name; } function renderReservationLine(cell, { compact = false, date = null, roomId = null, weekBridge = false } = {}) { if (!cell?.reservation) return ''; const name = String(cell.reservation.guest_name || '').trim() || '예약'; const maskedCls = cell.reservation.masked ? ' masked' : ''; const pos = date && roomId ? getStayRunPosition(date, roomId, cell.reservation) : null; if (weekBridge && (pos === 'mid' || pos === 'end')) { return ''; } if (!weekBridge && (pos === 'mid' || pos === 'end')) { return `
    `; } const nights = date && roomId ? stayNightCount(date, roomId, cell) : 0; const nightsHtml = nights > 1 && !weekBridge ? `${nights}박` : ''; const displayName = compact ? formatGuestLabel(cell.reservation, { compact }) : name; return `
    ${escapeHtml(displayName)}${nightsHtml}
    `; } function renderReservationDetailBlock(cell) { if (!cell?.reservation) return ''; const { reservation_no, guest_name, guest_phone, masked } = cell.reservation; return `

    확정 예약

    예약자 ${escapeHtml(guest_name || '—')}${masked ? ' (마스킹)' : ''}
    ${guest_phone ? `
    연락처 ${escapeHtml(guest_phone)}
    ` : ''} ${reservation_no ? `
    예약번호 ${escapeHtml(reservation_no)}
    ` : ''}
    `; } function renderDayReservationBlock(cell, date, roomId) { const hasReservation = Boolean(cell?.reservation); const res = cell?.reservation; const masked = Boolean(res?.masked); const progress = hasReservation ? stayNightProgress(date, roomId, res) : null; const placeholder = ''; let stayValue = placeholder; if (hasReservation) { if (progress) { const rangeEnd = shiftDateYmd(progress.startDate, progress.total - 1); stayValue = `${escapeHtml(progress.startDate)} ~ ${escapeHtml(rangeEnd)} ${progress.index}/${progress.total}`; } else { stayValue = escapeHtml(date); } } const reservationNo = hasReservation ? escapeHtml(res.reservation_no || '—') : placeholder; const guestName = hasReservation ? `${escapeHtml(res.guest_name || '—')}${masked ? ' (마스킹)' : ''}` : placeholder; const guestPhone = hasReservation ? escapeHtml(res.guest_phone || '—') : placeholder; const smsRow = hasReservation ? renderDayCheckInSmsRow(cell, date, roomId) : ''; return `
    ${smsRow}
    숙박 ${stayValue}
    예약번호 ${reservationNo}
    예약자 ${guestName}
    연락처 ${guestPhone}
    `; } function renderReservationSlot(cell, { compact = false, date = null, roomId = null, weekBridge = false } = {}) { if (!cell?.reservation) { return `
    `; } const pos = date && roomId ? getStayRunPosition(date, roomId, cell.reservation) : null; const name = String(cell.reservation.guest_name || '').trim() || '예약'; const maskedCls = cell.reservation.masked ? ' masked' : ''; if (weekBridge && pos && pos !== 'single') { const nights = stayNightCount(date, roomId, cell); const nightsHtml = pos === 'start' && nights > 1 ? `${nights}박` : ''; const label = pos === 'start' ? `${escapeHtml(formatGuestLabel(cell.reservation))}${nightsHtml}` : ''; const title = nights > 1 ? `${name} · ${nights}박` : name; return `
    ${label}
    `; } if (weekBridge && pos === 'single') { const displayName = formatGuestLabel(cell.reservation); return `
    ${escapeHtml(displayName)}
    `; } const inner = renderReservationLine(cell, { compact, date, roomId, weekBridge }); const hasGuest = Boolean(inner); return `
    ${inner}
    `; } function renderCompactGuestRow(cell, date, roomId) { if (!cell?.reservation) { return ''; } const name = String(cell.reservation.guest_name || '').trim() || '예약'; const maskedCls = cell.reservation.masked ? ' masked' : ''; const pos = getStayRunPosition(date, roomId, cell.reservation); const nights = stayNightCount(date, roomId, cell); const nightsHtml = nights > 1 && pos === 'start' ? `${nights}박` : ''; if (pos === 'mid') { return ``; } if (pos === 'end') { return ``; } if (pos === 'start') { return `
    ${escapeHtml(formatGuestLabel(cell.reservation))}${nightsHtml}
    `; } return `${escapeHtml(formatGuestLabel(cell.reservation))}${nightsHtml}`; } function renderCompactCellLayout(cell, date, roomId, room) { const roomName = room?.display_name || cell?.display_name || ''; const price = cell?.final_price ?? cell?.base_price; const smsRow = renderCompactCheckInSms(cell, date, roomId); const badges = cell ? renderRuleBadges(cell, { compact: true }) : ''; const stateDot = cell ? renderStateDot(cell.state) : ''; const priceHtml = price != null ? `${formatPrice(price)}` : ''; return `
    ${smsRow || ''}
    ${stateDot} ${badges}
    ${escapeHtml(roomName)} ${priceHtml}
    ${renderCompactGuestRow(cell, date, roomId)}
    `; } function renderCalCellBottom(roomPriceRow, reservationSlot, checkInSmsRow, { compact = false } = {}) { const roomBlock = roomPriceRow || ''; const reservationBlock = reservationSlot || ''; const smsBlock = checkInSmsRow || ''; if (!compact) return `${roomBlock}${reservationBlock}${smsBlock}`; return `
    ${roomBlock}${reservationBlock}${smsBlock}
    `; } function renderRoomPriceRow(roomName, price, { compact = false, priceOnly = false } = {}) { const priceHtml = price != null ? `${formatPrice(price)}` : ''; if (priceOnly || !roomName) { return priceHtml ? `
    ${priceHtml}
    ` : ''; } return `
    ${escapeHtml(roomName)} ${priceHtml}
    `; } function renderCalCell(cell, compact, detailSelected, ruleSelected, date, roomId, room) { const key = cellKey(date, roomId); const ruleCls = cell?.rule_kind ? cellRuleClasses(cell) : ''; const reservationCls = cell?.reservation ? ' has-reservation' : ''; const runCls = stayRunClass(date, roomId, cell); if (!cell) { if (!compact) { return ``; } const roomName = room?.display_name || ''; return ``; } const meta = STATE_META[cell.state] || STATE_META.closed; const checkInSmsPending = cellCheckInSmsPending(cell); const checkInSmsSent = cellCheckInSmsSent(cell); const smsCls = checkInSmsPending ? ' needs-checkin-sms' : (checkInSmsSent ? ' checkin-sms-sent' : ''); const sched = cell.scheduled_channels?.[0]; const schedLine = sched && !compact ? `
    오픈 ${formatUtcDateTime(sched.open_at, { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
    ` : ''; const ariaLabel = buildCellAriaLabel(cell, meta); const compactBody = compact ? renderCompactCellLayout(cell, date, roomId, room) : ''; return ` `; } function initCalGridClicks() { const grid = document.getElementById('calGrid'); if (!grid || grid.dataset.calClickBound === '1') return; grid.dataset.calClickBound = '1'; grid.addEventListener('click', (e) => { if (e.target.closest('.cal-cell-checkin-sms-check, .cal-day-checkin-sms-check, .cal-detail-checkin-sms-check')) { return; } closeRoomCombobox(); const dateHead = e.target.closest('[data-select-date]'); if (dateHead && grid.contains(dateHead)) { e.preventDefault(); e.stopPropagation(); toggleRuleDateColumn(dateHead.getAttribute('data-select-date')); return; } const cellBtn = e.target.closest('[data-key]'); if (!cellBtn || !grid.contains(cellBtn)) return; const key = cellBtn.getAttribute('data-key'); if (!key) return; onCalendarCellClick(key); }); grid.addEventListener('keydown', (e) => { if (e.key !== 'Enter' && e.key !== ' ') return; const cellBtn = e.target.closest('[data-key]'); if (!cellBtn || !grid.contains(cellBtn)) return; e.preventDefault(); const key = cellBtn.getAttribute('data-key'); if (!key) return; onCalendarCellClick(key); }); } function bindRoomRowClicks(root) { root.querySelectorAll('[data-room-select]').forEach((btn) => { btn.addEventListener('click', (e) => { e.stopPropagation(); }); }); } function bindCellClicks(root) { root.querySelectorAll('.cal-cell[data-key], .cal-month-room-chip[data-key], .cal-day-card[data-key]').forEach((btn) => { btn.addEventListener('click', () => { state.selectedCellKey = btn.dataset.key; renderCalendar(); }); }); } function renderMonthRoomChip(room, date, cell, detailSelected) { const key = cellKey(date, room.id); const ruleSelected = isRuleCellSelected(key); const ruleCls = cell?.rule_kind ? cellRuleClasses(cell) : ''; const reservationCls = cell?.reservation ? ' has-reservation' : ''; const runCls = stayRunClass(date, room.id, cell); const checkInSmsPending = cellCheckInSmsPending(cell); const checkInSmsSent = cellCheckInSmsSent(cell); const smsCls = checkInSmsPending ? ' needs-checkin-sms' : (checkInSmsSent ? ' checkin-sms-sent' : ''); const meta = cell ? (STATE_META[cell.state] || STATE_META.closed) : null; const ariaLabel = cell ? buildCellAriaLabel(cell, meta) : `${room.display_name} · 데이터 없음`; return ` `; } function renderWeekDateHeadButton(date) { const d = parseDate(date); const wd = WEEKDAY_LABELS[d.getDay()]; const isWeekend = d.getDay() === 0 || d.getDay() === 6; const isToday = date === todayYmd(); const isPast = isPastDate(date); return ``; } function renderMonthDateHeadButton(date, inMonth) { const d = parseDate(date); const isWeekend = d.getDay() === 0 || d.getDay() === 6; const isToday = date === todayYmd(); const isPast = inMonth && isPastDate(date); return ``; } function renderRoomDateGrid(grid, rooms, dates, { monthMode = false, dateMeta = null } = {}) { const dayEntries = dateMeta || dates.map((date) => ({ date, inMonth: true })); let html = '
    '; html += '
    '; dayEntries.forEach(({ date, inMonth }) => { if (inMonth === false) { html += ''; } else { html += renderWeekDateHeadButton(date); } }); html += '
    '; html += `
    `; dayEntries.forEach(({ date, inMonth }) => { const d = parseDate(date); const isWeekend = d.getDay() === 0 || d.getDay() === 6; const isToday = date === todayYmd(); const isPast = inMonth !== false && isPastDate(date); html += `
    `; if (inMonth === false) { rooms.forEach(() => { html += ''; }); } else { rooms.forEach((room) => { const key = cellKey(date, room.id); const cell = findCell(date, room.id); const detailSelected = state.selectedCellKey === key; const ruleSelected = isRuleCellSelected(key); html += renderCalCell(cell, true, detailSelected, ruleSelected, date, room.id, room); }); } html += '
    '; }); html += '
    '; grid.innerHTML = html; bindCalCheckInSmsChecks(grid); updateRuleSelectionUI(); return; } function renderWeekView(grid, rooms, dates) { renderRoomDateGrid(grid, rooms, dates); } function renderDayView(grid, rooms) { const date = state.focusDate; const pastCls = isPastDate(date) ? ' past' : ''; let html = `

    ${date}

    `; if (!rooms.length) { html += '

    표시할 객실이 없습니다.

    '; } rooms.forEach((room) => { const cell = findCell(date, room.id); const key = cellKey(date, room.id); const selected = state.selectedCellKey === key; const price = cell?.final_price ?? cell?.base_price; const sched = cell?.scheduled_channels?.[0]; const schedLine = sched ? `

    오픈 ${formatUtcDateTime(sched.open_at)}

    ` : ''; const hasReservation = Boolean(cell?.reservation); const reservationCls = hasReservation ? ' has-reservation' : ''; const runCls = stayRunClass(date, room.id, cell); const checkInSmsPending = cellCheckInSmsPending(cell); const checkInSmsSent = cellCheckInSmsSent(cell); const smsCls = checkInSmsPending ? ' needs-checkin-sms' : (checkInSmsSent ? ' checkin-sms-sent' : ''); html += ` `; }); html += '
    '; grid.innerHTML = html; bindCellClicks(grid); bindCalCheckInSmsChecks(grid); updateRuleSelectionUI(); } function weekSectionKey(weekDates) { return weekDates.map((d) => d.date).join('|'); } function isPastWeek(weekDates) { const today = todayYmd(); const inMonth = weekDates.filter((d) => d.inMonth); if (!inMonth.length) return false; return inMonth[inMonth.length - 1].date < today; } function weekSectionLabel(weekDates) { const inMonth = weekDates.filter((d) => d.inMonth); if (!inMonth.length) return '—'; const start = parseDate(inMonth[0].date); const end = parseDate(inMonth[inMonth.length - 1].date); return `${start.getMonth() + 1}/${start.getDate()} — ${end.getMonth() + 1}/${end.getDate()}`; } function isWeekCollapsed(weekDates) { if (!isPastWeek(weekDates)) return false; return !state.expandedPastWeeks.has(weekSectionKey(weekDates)); } function bindMonthWeekToggles(root) { root.querySelectorAll('[data-week-toggle]').forEach((btn) => { btn.addEventListener('click', (e) => { e.stopPropagation(); const key = btn.dataset.weekToggle; if (state.expandedPastWeeks.has(key)) { state.expandedPastWeeks.delete(key); } else { state.expandedPastWeeks.add(key); } renderCalendar(); }); }); } function renderMonthWeekBody(weekDates, rooms, { past = false, label = '', weekKey = '' } = {}) { let html = '
    '; if (past && label) { html += `
    ${label}
    `; } html += '
    '; weekDates.forEach(({ date, inMonth }) => { const d = parseDate(date); const isWeekend = d.getDay() === 0 || d.getDay() === 6; const isToday = date === todayYmd(); const isPast = inMonth && isPastDate(date); html += ``; }); html += '
    '; html += '
    '; weekDates.forEach(({ date, inMonth }) => { const d = parseDate(date); const isWeekend = d.getDay() === 0 || d.getDay() === 6; const isToday = date === todayYmd(); const isPast = inMonth && isPastDate(date); html += `
    `; if (inMonth && rooms.length) { html += '
    '; rooms.forEach((room) => { const cell = findCell(date, room.id); if (!cell) return; const selected = state.selectedCellKey === cellKey(date, room.id); html += renderMonthRoomChip(room, date, cell, selected); }); html += '
    '; } html += '
    '; }); html += '
    '; return html; } function renderMonthView(grid, rooms) { const focus = parseDate(state.focusDate); const year = focus.getFullYear(); const month = focus.getMonth(); const weeks = monthWeeks(year, month); renderMonthStickyWeekdayRow(); let html = '
    '; html += '
    '; const roomCount = Math.max(rooms.length, 1); weeks.forEach((weekDates) => { const key = weekSectionKey(weekDates); const collapsed = isWeekCollapsed(weekDates); const past = isPastWeek(weekDates); const label = weekSectionLabel(weekDates); html += `
    `; if (collapsed) { html += ` `; } else { html += renderMonthWeekBody(weekDates, rooms, { past, label, weekKey: key }); } html += '
    '; }); html += '
    '; grid.innerHTML = html; bindMonthWeekToggles(grid); bindCalCheckInSmsChecks(grid); updateRuleSelectionUI(); requestAnimationFrame(updateCalStickyOffsets); } function bindCellDetailActions() { document.querySelectorAll('.cal-guest-memo-copy').forEach((btn) => { btn.addEventListener('click', async (e) => { e.preventDefault(); e.stopPropagation(); const memo = getGuestMemo(btn.dataset.copyMemo); if (!memo) return; try { await navigator.clipboard.writeText(memo); btn.textContent = '복사됨'; setTimeout(() => { btn.textContent = '복사'; }, 1500); } catch { btn.textContent = '실패'; setTimeout(() => { btn.textContent = '복사'; }, 1500); } }); }); } function renderCellDetail() { const el = document.getElementById('cellDetail'); if (!el) return; if (!state.selectedCellKey) { el.innerHTML = '

    달력에서 날짜·객실 셀을 선택하세요. 체크인 안내가 있는 객실은 📝 표시됩니다.

    '; return; } const { date, roomId } = parseCellKey(state.selectedCellKey); const cell = findCell(date, roomId); const room = findRoom(roomId); const memoBlock = renderGuestMemoDetailBlock(roomId); if (!cell) { el.innerHTML = `

    ${date}

    ${room?.display_name || ''}

    스냅샷 데이터 없음

    ${memoBlock}
    `; bindCellDetailActions(); return; } const meta = STATE_META[cell.state] || STATE_META.closed; el.innerHTML = `

    ${date}

    ${room?.display_name || cell.display_name}

    개별 Read ${meta.label}
    ${cell.rule_kind ? `
    적용 규칙 ${renderRuleBadges(cell)}
    ` : ''}
    기준가 ${formatPrice(cell.base_price)}
    ${cell.final_price != null && cell.final_price !== cell.base_price ? `
    판매가 ${formatPrice(cell.final_price)}
    ` : ''} ${cell.open_channels?.length ? `

    오픈 채널

    ${cell.open_channels.map((ch) => `${ch}`).join('')}
    ` : ''} ${cell.scheduled_channels?.length ? `

    오픈 예정

    ${cell.scheduled_channels.map((s) => `
    ${s.label}: ${formatUtcDateTime(s.open_at)}
    `).join('')}
    ` : ''} ${cell.fetched_at ? `

    스냅샷: ${formatUtcDateTime(cell.fetched_at)}

    ` : ''} ${renderReservationDetailBlock(cell)} ${memoBlock}
    `; bindCellDetailActions(); document.querySelectorAll('.cal-detail-checkin-sms-check').forEach((input) => { input.addEventListener('change', async () => { const slotId = input.dataset.slotId; const key = input.dataset.cellKey; input.disabled = true; try { await markCheckInSms(slotId, input.checked, key); renderCellDetail(); } catch (err) { input.checked = !input.checked; alert(err.message); } finally { input.disabled = false; } }); }); } function renderCalendar() { renderCalTitle(); const grid = document.getElementById('calGrid'); const rooms = visibleRooms(); if (state.view !== 'month') { clearMonthStickyHead(); } if (!rooms.length) { clearMonthStickyHead(); grid.innerHTML = '

    객실을 선택하거나 등록하세요.

    '; renderCellDetail(); requestAnimationFrame(updateCalStickyOffsets); return; } if (state.view === 'week') { const { start, end } = viewDateRange(); renderWeekView(grid, rooms, dateRange(start, end)); } else if (state.view === 'day') { renderDayView(grid, rooms); } else { renderMonthView(grid, rooms); } renderCellDetail(); updateGuestMemoToolbar(); requestAnimationFrame(updateCalStickyOffsets); } async function loadCalendar() { if (!state.propertyGroupId) { state.calendar = { rooms: [], cells: [] }; renderCalendar(); return; } const roomIds = activeRoomIdsForApi(); if (!roomIds.length) { state.calendar = { rooms: [], cells: [] }; renderCalendar(); return; } const { start, end } = calendarFetchDateRange(); const params = new URLSearchParams({ property_group_id: state.propertyGroupId, start_date: start, end_date: end, }); roomIds.forEach((id) => params.append('room_mapping_id', id)); const loading = document.getElementById('calLoading'); loading?.classList.remove('hidden'); state.loading = true; const reqSeq = ++calendarRequestSeq; try { const data = await API.get(`/api/v1/calendar?${params}`); if (reqSeq !== calendarRequestSeq) return; state.calendar = { ...data, cells: Array.isArray(data?.cells) ? data.cells : [], }; renderCalendar(); updateCheckInSmsUI(); } catch (err) { if (reqSeq !== calendarRequestSeq) return; document.getElementById('calGrid').innerHTML = `

    달력 로드 실패: ${err.message}

    `; } finally { if (reqSeq === calendarRequestSeq) { loading?.classList.add('hidden'); state.loading = false; } } } async function loadRooms() { if (!state.propertyGroupId) { state.rooms = []; state.roomFilterIds.clear(); renderRoomFilter(); renderCalendar(); updateSetupHint(); return; } state.rooms = await API.get( `/api/v1/room-mappings?property_group_id=${state.propertyGroupId}&exposed_only=true`, ); syncRoomFilterIdsWithRooms(); renderRoomFilter(); await loadCalendar(); updateSetupHint(); } function channelSchedulesFromUI() { const schedules = {}; ['ddona_integrated', 'naver_integrated', 'yeogi_integrated', 'yanolja_integrated', 'onda_integrated'].forEach((ch) => { const el = document.querySelector(`#ruleSetupModal .ch-channel[value="${ch}"]`); schedules[ch] = { enabled: el?.checked || false, open_at: null }; }); return schedules; } function allChannelsEnabledSchedules() { const schedules = {}; ['ddona_integrated', 'naver_integrated', 'yeogi_integrated', 'yanolja_integrated', 'onda_integrated'].forEach((ch) => { schedules[ch] = { enabled: true, open_at: null }; }); return schedules; } function openRuleSetupModal(mode) { const { count, roomCount, dateCount } = ruleCellSelectionSummary(); if (!count) return; state.ruleSetupMode = mode; const isHardBlock = mode === 'hardblock'; document.getElementById('ruleSetupTitle').textContent = isHardBlock ? '하드블락 설정' : '마크업 설정'; document.getElementById('ruleSetupDesc').textContent = isHardBlock ? '선택한 날짜·객실 셀에만 하드블락을 적용합니다. 떠나요 개별상품 가예약은 운영자가 수동으로 생성하세요.' : '선택한 날짜·객실 셀에만 할증 가격을 적용합니다. 활성 통합 채널에 반영됩니다.'; document.getElementById('ruleChannelSection').classList.toggle('hidden', !isHardBlock); document.getElementById('ruleSelectionInfo').textContent = `선택: ${count}개 셀 · ${roomCount}개 객실 · ${dateCount}일`; document.getElementById('applyRuleSetupBtn').textContent = isHardBlock ? '하드블락 적용' : '마크업 적용'; openModal('ruleSetupModal'); } async function applyRuleSetup() { const keys = [...state.selectedRuleCellKeys]; if (!state.companyId || !keys.length) return; const markupValue = Number(document.getElementById('ruleMarkupPrice').value) || 0; const isHardBlock = state.ruleSetupMode === 'hardblock'; if (!isHardBlock && markupValue <= 0) { alert('마크업 금액을 입력해 주세요.'); return; } const targets = keys.map((key) => { const { date, roomId } = parseCellKey(key); return { room_mapping_id: roomId, target_date: date }; }); const body = { company_id: state.companyId, targets, markup_type: 'fixed', markup_value: markupValue, is_hard_block: isHardBlock, channel_schedules: isHardBlock ? channelSchedulesFromUI() : allChannelsEnabledSchedules(), }; await API.post('/api/v1/selling-override-rules/bulk-markup', body); closeModal('ruleSetupModal'); clearRuleCellSelection(); await loadCalendar(); } async function clearRuleSetup(mode) { const keys = [...state.selectedRuleCellKeys]; if (!state.companyId || !keys.length) return; const isHardBlock = mode === 'hardblock'; const label = isHardBlock ? '하드블락' : '마크업'; const { count } = ruleCellSelectionSummary(); if (!confirm(`선택한 ${count}개 셀에서 ${label}을 해제하시겠습니까?`)) return; const targets = keys.map((key) => { const { date, roomId } = parseCellKey(key); return { room_mapping_id: roomId, target_date: date }; }); const result = await API.post('/api/v1/selling-override-rules/bulk-clear', { company_id: state.companyId, targets, clear_hardblock: isHardBlock, clear_markup: !isHardBlock, }); if (result.skipped === targets.length) { alert(`선택한 셀에 적용된 ${label} 규칙이 없습니다.`); } else if (result.skipped > 0) { alert(`${label} 해제 ${result.updated}건 · 규칙 없음 ${result.skipped}건`); } clearRuleCellSelection(); await loadCalendar(); } async function refreshSyncStatus() { const line = document.getElementById('syncStatusLine'); if (!line || !state.propertyGroupId) { if (line) line.classList.add('hidden'); return; } try { const data = await API.get(`/api/v1/sync/status?property_group_id=${state.propertyGroupId}`); const last = data.last; if (!last) { line.textContent = '채널 동기화 이력 없음'; line.classList.remove('hidden'); return; } const when = last.finished_at || last.started_at; const label = last.cycle_type === 'reconcile' ? '정합' : '동기화'; line.textContent = `마지막 ${label}: ${formatUtcDateTime(when)} · 변경 ${last.changes_detected ?? 0} · Write ${last.writes_succeeded ?? 0}/${last.writes_attempted ?? 0} (${last.result || '—'})`; line.classList.remove('hidden'); } catch { line.classList.add('hidden'); } } async function triggerChannelAction(action) { if (!state.propertyGroupId) return; const reconcileBtn = document.getElementById('channelReconcileBtn'); const writeBtn = document.getElementById('channelWriteBtn'); reconcileBtn.disabled = true; writeBtn.disabled = true; const line = document.getElementById('syncStatusLine'); if (line) { line.textContent = action === 'reconcile' ? '채널 정합 실행 중…' : '채널 동기화 실행 중…'; line.classList.remove('hidden'); } try { const path = action === 'reconcile' ? 'reconcile' : 'write'; const result = await API.post(`/api/v1/sync/${path}?property_group_id=${state.propertyGroupId}`); const msg = `완료 — 변경 ${result.changes_detected ?? 0}건, Write ${result.writes_attempted ?? 0}건`; if (line) line.textContent = msg; await refreshSyncStatus(); await loadCalendar(); } catch (err) { if (line) line.textContent = `실패: ${err.message}`; alert(err.message); } finally { reconcileBtn.disabled = false; writeBtn.disabled = false; } } document.getElementById('applyRuleSetupBtn').addEventListener('click', applyRuleSetup); document.getElementById('hardBlockSetupBtn').addEventListener('click', () => openRuleSetupModal('hardblock')); document.getElementById('markupSetupBtn').addEventListener('click', () => openRuleSetupModal('markup')); document.getElementById('hardBlockClearBtn').addEventListener('click', () => clearRuleSetup('hardblock')); document.getElementById('markupClearBtn').addEventListener('click', () => clearRuleSetup('markup')); document.getElementById('clearRuleSelectionBtn').addEventListener('click', clearRuleCellSelection); document.getElementById('channelReconcileBtn')?.addEventListener('click', () => triggerChannelAction('reconcile')); document.getElementById('channelWriteBtn')?.addEventListener('click', () => triggerChannelAction('write')); function shiftFocus(delta) { const focus = parseDate(state.focusDate); if (state.view === 'month') { focus.setMonth(focus.getMonth() + delta); } else if (state.view === 'week') { focus.setDate(focus.getDate() + delta * 7); } else { focus.setDate(focus.getDate() + delta); } state.focusDate = formatDate(focus); renderCalTitle(); renderCalendar(); loadCalendar(); } document.getElementById('cellDetailBtn').addEventListener('click', () => { renderCellDetail(); openModal('cellDetailModal'); }); initRoomCombobox(); initGuestMemoModal(); initCheckInSms(); initCalGridClicks(); initSyncStatusUI(); document.querySelectorAll('.cal-view-btn').forEach((btn) => { btn.addEventListener('click', () => { state.view = btn.dataset.view; document.querySelectorAll('.cal-view-btn').forEach((b) => b.classList.toggle('active', b === btn)); renderCalTitle(); renderCalendar(); loadCalendar(); }); }); document.getElementById('calPrev').addEventListener('click', () => shiftFocus(-1)); document.getElementById('calNext').addEventListener('click', () => shiftFocus(1)); document.getElementById('calFocusDate').addEventListener('change', (e) => { state.focusDate = e.target.value; renderCalTitle(); renderCalendar(); loadCalendar(); }); bindModalClosers(); window.addEventListener('resize', updateCalStickyOffsets); (async function init() { if (!requireAuth()) return; await applyAdminNav(); renderLegend(); initCalStickyObserver(); updateCalStickyOffsets(); initCalendarPolling(); window.addEventListener('pageshow', (e) => { if (e.persisted && state.propertyGroupId) { loadRooms(); } }); try { await initCatalogSelectors({ onPropertyChange: async (propertyGroupId, groups) => { state.propertyGroupId = propertyGroupId; state.companyId = document.getElementById('companySelect').value; state.roomFilterIds.clear(); if (groups) state.propertyGroups = groups; loadSyncState(propertyGroupId); await loadRooms(); }, }); } catch (err) { console.error('대시보드 초기화 실패:', err); document.getElementById('calGrid').innerHTML = `

    초기화 실패: ${err.message}

    `; updateSetupHint(); } })();