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 = '';
WEEKDAY_LABELS.forEach((wd, i) => {
const isWeekend = i === 0 || i === 6;
html += `
${wd}
`;
});
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 ``;
}
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();
}
})();