Files
hotell777_260507/public/admin.html
kalugin66 d612febefb
All checks were successful
Deploy hotel / deploy-kdo (push) Successful in 35s
телеграм
2026-07-20 19:06:49 +05:00

3260 lines
178 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Panel — Hotel 777</title>
<link rel="stylesheet" href="css/bootstrap.min.css">
<link rel="stylesheet" href="css/fontawesome.min.css">
<link rel="stylesheet" href="css/inter-font.css">
<style>
:root { --primary: #2563eb; --gold: #c9a84c; --dark: #0f172a; --sidebar-width: 260px; }
* { box-sizing: border-box; }
body { font-family: 'Inter', sans-serif; background: #f1f5f9; margin: 0; font-size: clamp(0.85rem, 1.5vw, 0.95rem); }
#login-page { display: flex; align-items: center; justify-content: center; min-height: 100vh; background: linear-gradient(135deg, #0f172a, #1e293b); }
.login-card { background: #fff; border-radius: 16px; padding: clamp(24px, 6vw, 48px) clamp(16px, 5vw, 40px); width: 100%; max-width: min(90vw, 400px); box-shadow: 0 25px 60px rgba(0,0,0,0.3); }
.login-card h2 { font-family: 'Playfair Display', serif; text-align: center; margin-bottom: 8px; color: var(--dark); font-size: clamp(1.2rem, 2.5vw, 1.5rem); }
.login-card .subtitle { text-align: center; color: #64748b; margin-bottom: 32px; font-size: 0.9rem; }
.login-card .form-label { font-weight: 500; font-size: 0.85rem; color: #334155; }
.login-card .form-control { border-radius: 10px; padding: clamp(8px, 1.5vw, 12px) clamp(12px, 2vw, 16px); border: 1px solid #e2e8f0; font-size: clamp(0.85rem, 1.3vw, 0.95rem); }
.login-card .form-control:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(37,99,235,0.1); }
.login-btn { width: 100%; padding: clamp(10px, 2vw, 14px); border: none; border-radius: 10px; background: var(--primary); color: #fff; font-weight: 600; font-size: 1rem; cursor: pointer; margin-top: 8px; }
.login-btn:hover { background: #1d4ed8; }
.login-error { color: #ef4444; font-size: 0.85rem; text-align: center; margin-top: 12px; display: none; }
.login-logo { text-align: center; font-size: 1.5rem; font-weight: 700; color: var(--gold); margin-bottom: 24px; }
.login-logo span { color: var(--primary); }
#admin-panel { display: none; }
.hamburger { display: none; position: fixed; top: 12px; left: 12px; z-index: 300; width: 40px; height: 40px; border-radius: 10px; background: var(--dark); color: #fff; border: none; font-size: 1.25rem; cursor: pointer; align-items: center; justify-content: center; box-shadow: 0 2px 8px rgba(0,0,0,0.3); }
.sidebar { position: fixed; left: 0; top: 0; bottom: 0; width: var(--sidebar-width); background: var(--dark); color: #fff; padding: 24px 0; z-index: 100; overflow-y: auto; transition: transform 0.3s ease; }
.sidebar-brand { padding: 0 24px; font-size: 1.25rem; font-weight: 700; color: var(--gold); margin-bottom: 32px; }
.sidebar-brand span { color: #fff; }
.sidebar-nav a { display: flex; align-items: center; gap: 12px; padding: 12px 24px; color: #94a3b8; text-decoration: none; font-size: clamp(0.8rem, 1.2vw, 0.9rem); transition: 0.2s; }
.sidebar-nav a:hover, .sidebar-nav a.active { background: rgba(255,255,255,0.05); color: #fff; border-right: 3px solid var(--gold); }
.sidebar-nav a i { width: 20px; text-align: center; }
.sidebar-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 99; }
.sidebar-user { padding: 20px 24px; border-top: 1px solid rgba(255,255,255,0.1); background: rgba(0,0,0,0.2); }
.sidebar-user .name { font-weight: 600; font-size: 0.9rem; }
.sidebar-user .role { font-size: 0.75rem; color: #94a3b8; }
.main-content { margin-left: var(--sidebar-width); padding: clamp(16px, 3vw, 32px); }
.top-bar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 32px; gap: clamp(8px, 2vw, 16px); flex-wrap: wrap; }
.top-bar h1 { font-size: clamp(1.25rem, 2.5vw, 1.75rem); font-weight: 700; color: var(--dark); margin: 0; }
.card { background: #fff; border-radius: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); border: none; margin-bottom: 24px; }
.card-header-custom { padding: 20px 24px; border-bottom: 1px solid #e2e8f0; display: flex; justify-content: space-between; align-items: center; }
.card-header-custom h3 { margin: 0; font-size: 1.1rem; font-weight: 600; }
.card-body-custom { padding: 24px; }
.btn-primary-custom { background: var(--primary); color: #fff; border: none; padding: 10px 20px; border-radius: 10px; font-weight: 500; cursor: pointer; }
.btn-primary-custom:hover { background: #1d4ed8; }
.btn-gold { background: var(--gold); color: #fff; border: none; padding: 10px 20px; border-radius: 10px; font-weight: 500; cursor: pointer; }
.btn-gold:hover { background: #b8943f; }
.btn-danger-custom { background: #ef4444; color: #fff; border: none; padding: 8px 16px; border-radius: 8px; font-weight: 500; cursor: pointer; }
.btn-danger-custom:hover { background: #dc2626; }
.btn-sm { padding: 6px 12px; font-size: 0.8rem; }
.table th { font-weight: 600; color: #64748b; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 2px solid #e2e8f0; }
.table td { vertical-align: middle; }
.badge { padding: 4px 10px; border-radius: 20px; font-size: 0.75rem; font-weight: 600; }
.badge-admin { background: #fef3c7; color: #92400e; }
.badge-user { background: #dbeafe; color: #1e40af; }
.badge-status { padding: 4px 10px; border-radius: 20px; font-size: 0.7rem; font-weight: 600; white-space: nowrap; }
.badge-status-новая { background: #dbeafe; color: #1e40af; }
.badge-status-оплачена { background: #dcfce7; color: #15803d; }
.badge-status-зарезервирована { background: #fef3c7; color: #92400e; }
.badge-status-заселена { background: #f3e8ff; color: #7e22ce; }
.badge-status-выехала { background: #e2e8f0; color: #475569; }
.badge-status-отменена { background: #fee2e2; color: #b91c1c; }
.review-text-cell { max-width: 300px; }
.review-text-full {
max-height: 150px;
overflow-y: auto;
padding: 8px;
background: #f8fafc;
border-radius: 6px;
font-size: 0.85rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.review-text-full::-webkit-scrollbar { width: 6px; }
.review-text-full::-webkit-scrollbar-track { background: #e2e8f0; border-radius: 3px; }
.review-text-full::-webkit-scrollbar-thumb { background: #94a3b8; border-radius: 3px; }
tr.row-checkin-soon { background: #fffbeb !important; border-left: 4px solid #f59e0b; }
tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #ef4444; }
.filter-bar { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-bottom: 20px; }
.filter-bar select, .filter-bar button { padding: 8px 14px; border-radius: 10px; border: 1px solid #e2e8f0; font-size: 0.85rem; background: #fff; cursor: pointer; }
.filter-bar select:focus { border-color: var(--primary); outline: none; }
.filter-bar .filter-label { font-weight: 600; font-size: 0.85rem; color: #64748b; }
.filter-bar .sort-btn { background: var(--primary); color: #fff; border: none; }
.filter-bar .sort-btn:hover { background: #1d4ed8; }
.history-timeline { border-left: 2px solid #e2e8f0; padding-left: 20px; }
.history-item { position: relative; padding: 12px 0; border-bottom: 1px solid #f1f5f9; }
.history-item:last-child { border-bottom: none; }
.history-item::before { content: ''; position: absolute; left: -27px; top: 16px; width: 12px; height: 12px; border-radius: 50%; background: var(--primary); border: 2px solid #fff; }
.history-item .history-date { font-size: 0.75rem; color: #94a3b8; }
.history-item .history-user { font-size: 0.8rem; color: #64748b; font-weight: 500; }
.history-item .history-change { font-size: 0.85rem; color: var(--dark); margin-top: 4px; }
.history-item .history-change .old-val { text-decoration: line-through; color: #ef4444; background: #fee2e2; padding: 1px 6px; border-radius: 4px; }
.history-item .history-change .new-val { color: #16a34a; background: #dcfce7; padding: 1px 6px; border-radius: 4px; }
.modal-backdrop-custom { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 200; display: none; align-items: center; justify-content: center; }
.modal-backdrop-custom.show { display: flex; }
.modal-custom { background: #fff; border-radius: 16px; width: 100%; max-width: 480px; max-height: 90vh; overflow-y: auto; }
.modal-header-custom { padding: 20px 24px; border-bottom: 1px solid #e2e8f0; display: flex; justify-content: space-between; align-items: center; }
.modal-header-custom h3 { margin: 0; font-size: 1.1rem; }
.modal-close { background: none; border: none; font-size: 1.5rem; cursor: pointer; color: #64748b; }
.modal-body-custom { padding: 24px; }
.modal-body-custom .form-label { font-weight: 500; font-size: 0.85rem; }
.modal-body-custom .form-control { border-radius: 10px; padding: 10px 14px; }
.modal-footer-custom { padding: 16px 24px; border-top: 1px solid #e2e8f0; display: flex; justify-content: flex-end; gap: 12px; }
.profile-section { display: flex; align-items: center; gap: 16px; margin-bottom: 24px; }
.profile-avatar { width: 56px; height: 56px; border-radius: 50%; background: var(--primary); color: #fff; display: flex; align-items: center; justify-content: center; font-size: 1.25rem; font-weight: 700; }
.profile-info h4 { margin: 0; font-size: 1rem; }
.profile-info p { margin: 0; color: #64748b; font-size: 0.85rem; }
.tab-content { display: none; }
.tab-content.active { display: block; }
.stats-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(200px, 45vw), 1fr)); gap: clamp(10px, 2vw, 20px); margin-bottom: 32px; }
.stat-card { background: #fff; border-radius: 12px; padding: 24px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
.stat-card .stat-icon { width: 48px; height: 48px; border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 1.25rem; margin-bottom: 12px; }
.stat-card .stat-icon.blue { background: #dbeafe; color: #2563eb; }
.stat-card .stat-icon.gold { background: #fef3c7; color: #c9a84c; }
.stat-card .stat-icon.green { background: #dcfce7; color: #16a34a; }
.stat-card .stat-value { font-size: 1.75rem; font-weight: 700; color: var(--dark); }
.stat-card .stat-label { font-size: 0.8rem; color: #64748b; }
.toast-container { position: fixed; top: 24px; right: clamp(8px, 3vw, 24px); left: clamp(8px, 3vw, auto); z-index: 999; }
.toast { background: #fff; border-radius: 12px; padding: 16px 20px; box-shadow: 0 10px 30px rgba(0,0,0,0.15); margin-bottom: 12px; display: flex; align-items: center; gap: 12px; animation: slideIn 0.3s ease; min-width: min(90vw, 300px); max-width: min(95vw, 400px); }
.toast.success { border-left: 4px solid #16a34a; }
.toast.error { border-left: 4px solid #ef4444; }
.toast .toast-icon { font-size: 1.25rem; }
.toast.success .toast-icon { color: #16a34a; }
.toast.error .toast-icon { color: #ef4444; }
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
.loading-spinner { display: inline-block; width: 20px; height: 20px; border: 2px solid rgba(255,255,255,0.3); border-radius: 50%; border-top-color: #fff; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 768px) {
.sidebar { transform: translateX(-100%); }
.sidebar.open { transform: translateX(0); }
.sidebar-overlay.show { display: block; }
.hamburger { display: flex; }
.main-content { margin-left: 0; padding: 16px; padding-top: 60px; }
.top-bar { flex-direction: column; align-items: flex-start; }
.filter-bar { flex-direction: column; align-items: stretch; }
.filter-bar input, .filter-bar select { width: 100% !important; }
.modal-custom { max-width: 95vw !important; margin: 0 8px; }
#roomsGrid { grid-template-columns: repeat(auto-fill, minmax(min(250px, 100%), 1fr)) !important; }
.room-modal-grid-4col { grid-template-columns: 1fr 1fr !important; }
.room-modal-grid-3col { grid-template-columns: 1fr 1fr !important; }
.room-modal-grid-2col { grid-template-columns: 1fr !important; }
}
@media (max-width: 480px) {
.login-card { padding: 24px 16px; }
.stats-row { grid-template-columns: 1fr 1fr; }
.card-body-custom { padding: 12px; }
.table { font-size: 0.75rem; }
.table th, .table td { padding: 6px 4px; }
#roomForm .mb-3 { margin-bottom: 8px; }
.room-modal-grid-4col { grid-template-columns: 1fr !important; }
.room-modal-grid-3col { grid-template-columns: 1fr !important; }
}
</style>
</head>
<body>
<div id="login-page">
<div class="login-card">
<div class="login-logo">HOTEL <span>777</span></div>
<h2>Панель администратора</h2>
<p class="subtitle">Войдите для управления системой</p>
<form id="loginForm">
<div class="mb-3">
<label class="form-label">Логин</label>
<input type="text" class="form-control" id="loginInput" required placeholder="Введите логин">
</div>
<div class="mb-4">
<label class="form-label">Пароль</label>
<input type="password" class="form-control" id="passwordInput" required placeholder="Введите пароль">
</div>
<button type="submit" class="login-btn" id="loginBtn">Войти</button>
<div class="login-error" id="loginError"></div>
</form>
</div>
</div>
<div id="admin-panel">
<button class="hamburger" id="hamburgerBtn" onclick="toggleSidebar()" title="Меню"><i class="fas fa-bars"></i></button>
<div class="sidebar-overlay" id="sidebarOverlay" onclick="closeSidebar()"></div>
<div class="sidebar" id="sidebar">
<div class="sidebar-brand">HOTEL <span>777</span></div>
<nav class="sidebar-nav">
<a href="#" class="active" data-tab="dashboard"><i class="fas fa-chart-pie"></i> Дашборд</a>
<a href="#" data-tab="calendar"><i class="fas fa-calendar-alt"></i> Календарь</a>
<a href="#" data-tab="users"><i class="fas fa-users"></i> Пользователи</a>
<a href="#" data-tab="bookings"><i class="fas fa-calendar-check"></i> Бронирования</a>
<a href="#" data-tab="rooms"><i class="fas fa-door-open"></i> Номера</a>
<a href="#" data-tab="promocodes"><i class="fas fa-ticket-alt"></i> Промокоды</a>
<a href="#" data-tab="seasonal"><i class="fas fa-tags"></i> Сезонные цены</a>
<a href="#" data-tab="activities"><i class="fas fa-umbrella-beach"></i> Развлечения</a>
<a href="#" data-tab="hero"><i class="fas fa-image"></i> Главный экран</a>
<a href="#" data-tab="reviews"><i class="fas fa-star"></i> Отзывы</a>
<a href="#" data-tab="settings"><i class="fas fa-cog"></i> Настройки</a>
<a href="#" data-tab="profile"><i class="fas fa-user-circle"></i> Профиль</a>
<a href="/" style="margin-top: 8px; color: #94a3b8; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 16px;"><i class="fas fa-arrow-left"></i> На сайт</a>
</nav>
<div class="sidebar-user">
<div class="name" id="sidebarUserName"></div>
<div class="role" id="sidebarUserRole"></div>
</div>
</div>
<div class="main-content">
<div class="toast-container" id="toastContainer"></div>
<div id="tab-dashboard" class="tab-content active">
<div class="top-bar">
<h1>Дашборд</h1>
<button class="btn-danger-custom btn-sm" onclick="logout()"><i class="fas fa-sign-out-alt"></i> Выйти</button>
</div>
<div class="stats-row">
<div class="stat-card">
<div class="stat-icon gold"><i class="fas fa-ruble-sign"></i></div>
<div class="stat-value" id="statRevenueMonth"></div>
<div class="stat-label">Выручка за 30 дней</div>
</div>
<div class="stat-card">
<div class="stat-icon green"><i class="fas fa-chart-line"></i></div>
<div class="stat-value" id="statRevenueWeek"></div>
<div class="stat-label">Выручка за 7 дней</div>
</div>
<div class="stat-card">
<div class="stat-icon blue"><i class="fas fa-hotel"></i></div>
<div class="stat-value" id="statOccupancy"></div>
<div class="stat-label">Загрузка номеров</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: #fee2e2; color: #b91c1c;"><i class="fas fa-bell"></i></div>
<div class="stat-value" id="statNew"></div>
<div class="stat-label">Новых заявок</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: #fef3c7; color: #c9a84c;"><i class="fas fa-calendar-day"></i></div>
<div class="stat-value" id="statCheckins"></div>
<div class="stat-label">Заездов сегодня</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: #f3e8ff; color: #7e22ce;"><i class="fas fa-star"></i></div>
<div class="stat-value" id="statAvgReview"></div>
<div class="stat-label">Средняя оценка</div>
</div>
</div>
<div class="card" style="margin-bottom: 24px;">
<div class="card-header-custom">
<h3>Выручка по месяцам</h3>
<div style="display: flex; gap: 8px;">
<button class="btn btn-sm" style="background:var(--primary);color:#fff;border:none;border-radius:8px;padding:4px 10px;" onclick="loadRevenueChart('monthly')">Месяц</button>
<button class="btn btn-sm" style="background:#e2e8f0;color:#64748b;border:none;border-radius:8px;padding:4px 10px;" onclick="loadRevenueChart('weekly')" id="chartWeekBtn">Неделя</button>
<button class="btn btn-sm" style="background:#e2e8f0;color:#64748b;border:none;border-radius:8px;padding:4px 10px;" onclick="loadRevenueChart('daily')" id="chartDayBtn">День</button>
</div>
</div>
<div class="card-body-custom">
<canvas id="revenueChart" style="width:100%;height:280px;"></canvas>
</div>
</div>
<div class="card">
<div class="card-header-custom"><h3>Последние бронирования</h3></div>
<div class="card-body-custom">
<table class="table">
<thead><tr><th>Имя</th><th>Номер</th><th>Заезд</th><th>Выезд</th><th>Статус</th></tr></thead>
<tbody id="recentBookings"></tbody>
</table>
</div>
</div>
</div>
<div id="tab-users" class="tab-content">
<div class="top-bar">
<h1>Пользователи</h1>
<button class="btn-primary-custom admin-only" onclick="showUserModal()"><i class="fas fa-plus"></i> Добавить</button>
</div>
<div class="card">
<div class="card-body-custom">
<table class="table">
<thead><tr><th>Логин</th><th>ФИО</th><th>Email</th><th>Роль</th><th>Создан</th><th class="admin-only">Действия</th></tr></thead>
<tbody id="usersTable"></tbody>
</table>
</div>
</div>
</div>
<div id="tab-bookings" class="tab-content">
<div class="top-bar"><h1>Бронирования</h1><button class="btn-gold btn-sm" onclick="exportCSV()"><i class="fas fa-download me-1"></i> Экспорт CSV</button></div>
<div class="card">
<div class="card-header-custom">
<h3>Управление заявками</h3>
<div id="bookingStats" style="font-size: 0.8rem; color: #64748b;"></div>
</div>
<div class="card-body-custom">
<div class="filter-bar">
<input type="text" id="searchBookings" class="form-control" style="max-width: 100%; width: 100%; font-size: 0.85rem;" placeholder="Поиск по имени/телефону..." onkeyup="debounceSearch()">
<span class="filter-label">Фильтр:</span>
<select id="filterStatus"><option value="all">Все статусы</option><option value="новая">Новая</option><option value="оплачена">Оплачена</option><option value="зарезервирована">Зарезервирована</option><option value="заселена">Заселена</option><option value="выехала">Выехала</option><option value="отменена">Отменена</option></select>
<select id="filterUrgent"><option value="all">Все записи</option><option value="checkin-soon">Заезд ≤ 3 дней</option><option value="checkout-today">Выезд сегодня</option></select>
<span class="filter-label" style="margin-left: 12px;">Сортировка:</span>
<button class="sort-btn" id="sortAsc" onclick="setSort('asc')"><i class="fas fa-sort-up"></i> Заезд ↑</button>
<button class="sort-btn" id="sortDesc" onclick="setSort('desc')"><i class="fas fa-sort-down"></i> Заезд ↓</button>
</div>
<div style="overflow-x: auto;">
<table class="table">
<thead><tr><th>Имя</th><th>Телефон</th><th>Номер</th><th>Взр.</th><th>Дет.</th><th>Заезд</th><th>Выезд</th><th>Пожелания</th><th>Комментарий</th><th>База (₽)</th><th>Скидка (%)</th><th>Сумма скидки (₽)</th><th>Итого (₽)</th><th>Промокод</th><th>Статус</th><th>Действия</th></tr></thead>
<tbody id="allBookings"></tbody>
</table>
</div>
<div class="pagination-container" style="display: flex; justify-content: space-between; align-items: center; margin-top: 16px; padding-top: 16px; border-top: 1px solid #e2e8f0;">
<div id="paginationInfo" style="font-size: 0.85rem; color: #64748b;"></div>
<div class="pagination-controls" style="display: flex; gap: 8px;">
<button class="btn btn-outline-secondary btn-sm" id="prevPage" onclick="changePage(-1)" disabled>← Назад</button>
<span id="pageNumbers" style="display: flex; gap: 4px;"></span>
<button class="btn btn-outline-secondary btn-sm" id="nextPage" onclick="changePage(1)" disabled>Вперёд →</button>
</div>
</div>
</div>
</div>
</div>
<div id="tab-calendar" class="tab-content">
<div class="top-bar">
<h1>Календарь бронирований</h1>
<div style="display: flex; gap: 8px; align-items: center;">
<button class="btn btn-outline-secondary btn-sm" onclick="calendarPrevMonth()"><i class="fas fa-chevron-left"></i></button>
<select id="calendarMonthSelect" onchange="loadCalendar(this.value)" style="padding: 6px 10px; border-radius: 8px; border: 1px solid #e2e8f0; font-size: 0.9rem;">
</select>
<button class="btn btn-outline-secondary btn-sm" onclick="calendarNextMonth()"><i class="fas fa-chevron-right"></i></button>
</div>
</div>
<div class="card">
<div class="card-body-custom" style="overflow-x: auto;">
<div id="calendarGrid"></div>
</div>
</div>
</div>
<div id="tab-rooms" class="tab-content">
<div class="top-bar">
<h1>Номера</h1>
<button class="btn-primary-custom admin-only" onclick="showRoomModal()"><i class="fas fa-plus"></i> Добавить номер</button>
</div>
<div class="card">
<div class="card-body-custom">
<div id="roomsGrid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(min(280px, 100%), 1fr)); gap: clamp(10px, 2vw, 20px);"></div>
</div>
</div>
</div>
<div id="tab-promocodes" class="tab-content">
<div class="top-bar">
<h1>Промокоды</h1>
<button class="btn-primary-custom admin-only" onclick="showPromocodeModal()"><i class="fas fa-plus"></i> Добавить</button>
</div>
<div class="card">
<div class="card-body-custom">
<table class="table">
<thead><tr><th>Код</th><th>Скидка (%)</th><th>Действует с</th><th>Действует по</th><th>Период (дн)</th><th>Статус</th><th class="admin-only">Действия</th></tr></thead>
<tbody id="promocodesTable"></tbody>
</table>
</div>
</div>
</div>
<div id="tab-seasonal" class="tab-content">
<div class="top-bar">
<h1>Сезонные цены</h1>
<button class="btn-primary-custom admin-only" onclick="showSeasonalModal()"><i class="fas fa-plus"></i> Добавить</button>
</div>
<div class="card">
<div class="card-body-custom">
<table class="table">
<thead><tr><th>Тип номера</th><th>Дата с</th><th>Дата по</th><th>Цена/ночь (₽)</th><th>Метка</th><th>Активен</th><th class="admin-only">Действия</th></tr></thead>
<tbody id="seasonalTable"></tbody>
</table>
</div>
</div>
</div>
<div id="tab-reviews" class="tab-content">
<div class="top-bar">
<h1>Отзывы</h1>
<div class="filter-bar">
<select id="filterReviews" onchange="loadReviews()">
<option value="all">Все</option>
<option value="pending">На модерации</option>
<option value="approved">Одобренные</option>
<option value="rejected">Скрытые</option>
</select>
</div>
</div>
<div class="card">
<div class="card-body-custom">
<table class="table">
<thead><tr><th>Автор</th><th>Местоположение</th><th>Оценка</th><th>Текст</th><th>Статус</th><th>Дата</th><th>Действия</th></tr></thead>
<tbody id="reviewsTable"></tbody>
</table>
</div>
</div>
</div>
<div id="tab-settings" class="tab-content">
<div class="top-bar">
<h1>Настройки</h1>
</div>
<div class="card">
<div class="card-header-custom"><h3>Резервное копирование</h3></div>
<div class="card-body-custom">
<div class="row mb-4">
<div class="col-md-6">
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="backupAutoEnabled" style="width: 50px; height: 24px; cursor: pointer;">
<label class="form-check-label fw-500 ms-2" for="backupAutoEnabled">Автоматическое резервное копирование</label>
</div>
</div>
<div class="row g-3">
<div class="col-auto">
<label class="form-label">Время запуска</label>
<input type="time" class="form-control" id="backupAutoTime" value="03:00" style="width: 130px;">
</div>
<div class="col-auto">
<label class="form-label">Хранить (дней)</label>
<input type="number" class="form-control" id="backupRetentionDays" value="30" min="1" max="365" style="width: 100px;">
</div>
<div class="col-auto d-flex align-items-end">
<button class="btn-gold btn-sm" onclick="saveBackupSettings()"><i class="fas fa-save me-1"></i>Сохранить</button>
</div>
</div>
</div>
<div class="col-md-6 d-flex align-items-center justify-content-end">
<button class="btn-primary-custom" onclick="createManualBackup()" id="btnCreateBackup">
<i class="fas fa-download me-2"></i>Создать бекап сейчас
</button>
</div>
</div>
<div class="card mt-3" style="background: #f8fafc; border: 1px solid #e2e8f0;">
<div class="card-body-custom">
<h4 class="mb-3" style="font-size: 1rem;"><i class="fas fa-history me-2"></i>История бекапов</h4>
<div style="max-height: 300px; overflow-y: auto;">
<table class="table table-sm">
<thead>
<tr>
<th>Дата</th>
<th>Размер</th>
<th>Тип</th>
<th>Восстановлен</th>
<th class="text-end">Действия</th>
</tr>
</thead>
<tbody id="backupsTableBody">
<tr><td colspan="5" class="text-center text-muted">Загрузка...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="card" id="phoneCard">
<div class="card-header-custom"><h3><i class="fas fa-phone-alt me-2"></i>Телефоны и контакты</h3></div>
<div class="card-body-custom">
<p style="color: #64748b; margin-bottom: 20px;">
<i class="fas fa-info-circle me-2"></i>
Основной номер используется для звонков. Дополнительный отображается через запятую. Для мессенджеров можно указать отдельные номера или username.
</p>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Основной номер</label>
<input type="text" class="form-control" id="phonePrimary" placeholder="89409270080">
</div>
<div class="col-md-6">
<label class="form-label">Дополнительный номер</label>
<input type="text" class="form-control" id="phoneAdditional" placeholder="Необязательно">
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-md-4">
<label class="form-label">WhatsApp (номер)</label>
<div class="d-flex gap-2">
<input type="text" class="form-control" id="phoneWhatsappNum" placeholder="89409270080" style="flex:1">
<div class="form-check form-switch d-flex align-items-center" style="margin:0; padding-left:2.5em; white-space:nowrap;">
<input class="form-check-input" type="checkbox" id="phoneWhatsappActive" style="width:40px;height:20px;cursor:pointer;">
<label class="form-check-label ms-1" for="phoneWhatsappActive" style="font-size:0.75rem;">Активна</label>
</div>
</div>
</div>
<div class="col-md-4">
<label class="form-label">Telegram (username или +номер)</label>
<div class="d-flex gap-2">
<input type="text" class="form-control" id="phoneTelegramId" placeholder="+89409270080" style="flex:1">
<div class="form-check form-switch d-flex align-items-center" style="margin:0; padding-left:2.5em; white-space:nowrap;">
<input class="form-check-input" type="checkbox" id="phoneTelegramActive" style="width:40px;height:20px;cursor:pointer;">
<label class="form-check-label ms-1" for="phoneTelegramActive" style="font-size:0.75rem;">Активна</label>
</div>
</div>
</div>
<div class="col-md-4">
<label class="form-label">Max (username)</label>
<div class="d-flex gap-2">
<input type="text" class="form-control" id="phoneMaxLink" placeholder="kalugin66" style="flex:1">
<div class="form-check form-switch d-flex align-items-center" style="margin:0; padding-left:2.5em; white-space:nowrap;">
<input class="form-check-input" type="checkbox" id="phoneMaxActive" style="width:40px;height:20px;cursor:pointer;">
<label class="form-check-label ms-1" for="phoneMaxActive" style="font-size:0.75rem;">Активна</label>
</div>
</div>
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-md-4">
<label class="form-label">Instagram (username)</label>
<div class="d-flex gap-2">
<input type="text" class="form-control" id="phoneInstagramId" placeholder="hotel777abkhazia" style="flex:1">
<div class="form-check form-switch d-flex align-items-center" style="margin:0; padding-left:2.5em; white-space:nowrap;">
<input class="form-check-input" type="checkbox" id="phoneInstagramActive" style="width:40px;height:20px;cursor:pointer;">
<label class="form-check-label ms-1" for="phoneInstagramActive" style="font-size:0.75rem;">Активна</label>
</div>
</div>
</div>
</div>
<div class="mt-3">
<button class="btn-gold btn-sm" onclick="savePhoneSettings()"><i class="fas fa-save me-1"></i>Сохранить</button>
</div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header-custom"><h3>Кодовое слово для отзывов</h3></div>
<div class="card-body-custom">
<p style="color: #64748b; margin-bottom: 20px;">
<i class="fas fa-info-circle me-2"></i>
Это кодовое слово гости должны вводить при оставлении отзыва. Сообщите его гостям на ресепшене.
</p>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Текущий код</label>
<div class="d-flex align-items-center gap-2">
<input type="text" class="form-control" id="currentCodeDisplay" value="Загрузка..." readonly style="background: #f8fafc; font-weight: 600; color: #0f172a;">
</div>
</div>
<form id="settingsForm">
<div class="mb-3">
<label class="form-label">Новый код</label>
<input type="text" class="form-control" id="newReviewCode" placeholder="Минимум 3 символа" minlength="3">
</div>
<button type="submit" class="btn-gold">
<i class="fas fa-save me-2"></i>Сохранить
</button>
</form>
</div>
</div>
</div>
</div>
<div class="card" id="securityCard">
<div class="card-header-custom"><h3><i class="fas fa-shield-alt me-2"></i>Ключи безопасности</h3></div>
<div class="card-body-custom">
<p style="color: #64748b; margin-bottom: 20px;">
<i class="fas fa-info-circle me-2"></i>
Ключи автоматически генерируются при первом запуске. После регенерации требуется перезапуск сервера.
</p>
<div id="securityKeys"></div>
</div>
</div>
<div class="card" id="telegramCard">
<div class="card-header-custom"><h3><i class="fab fa-telegram me-2"></i>Telegram-уведомления о новых заявках</h3></div>
<div class="card-body-custom">
<p style="color: #64748b; margin-bottom: 20px;">
<i class="fas fa-info-circle me-2"></i>
Отправляет уведомления о новых бронях в Telegram. Бота можно создать через <a href="https://t.me/BotFather" target="_blank">@BotFather</a>.
Чтобы узнать свой chat_id — напишите боту любое сообщение, он ответит вашим ID.
</p>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Токен бота</label>
<input type="password" class="form-control" id="telegramBotToken" placeholder="1234567890:ABCdefGHIjklMNOpqrsTUVwxyz">
</div>
<div class="col-md-6">
<label class="form-label">Chat ID получателей (через запятую)</label>
<input type="text" class="form-control" id="telegramNotifyUsers" placeholder="123456789,987654321">
</div>
</div>
<div class="mt-3 d-flex align-items-center gap-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="telegramEnabled" style="width: 50px; height: 24px; cursor: pointer;">
<label class="form-check-label fw-500 ms-2" for="telegramEnabled">Включить уведомления</label>
</div>
<button class="btn-primary-custom btn-sm" onclick="saveTelegramSettings()"><i class="fas fa-save me-1"></i>Сохранить</button>
<button class="btn-gold btn-sm" id="btnTelegramTest" onclick="sendTestTelegram()"><i class="fab fa-telegram me-1"></i>Отправить тест</button>
</div>
</div>
</div>
<div class="card" id="emailCard">
<div class="card-header-custom"><h3><i class="fas fa-envelope me-2"></i>Email-уведомления</h3></div>
<div class="card-body-custom">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">SMTP хост</label>
<input type="text" class="form-control" id="smtpHost" placeholder="smtp.gmail.com">
</div>
<div class="col-md-3">
<label class="form-label">Порт</label>
<input type="number" class="form-control" id="smtpPort" placeholder="587">
</div>
<div class="col-md-3">
<label class="form-label">Secure (TLS)</label>
<select class="form-control" id="smtpSecure">
<option value="false">Выключен</option>
<option value="true">Включен</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Логин</label>
<input type="text" class="form-control" id="smtpUser" placeholder="user@gmail.com">
</div>
<div class="col-md-6">
<label class="form-label">Пароль</label>
<input type="password" class="form-control" id="smtpPass" placeholder="Пароль приложения">
</div>
<div class="col-md-6">
<label class="form-label">Отправитель (From)</label>
<input type="text" class="form-control" id="smtpFrom" placeholder="Hotel 777 <noreply@hotel777.ru>">
</div>
<div class="col-md-6">
<label class="form-label">Email для уведомлений</label>
<input type="email" class="form-control" id="adminEmail" placeholder="admin@example.com">
</div>
</div>
<div class="mt-3 d-flex align-items-center gap-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="emailEnabled" style="width: 50px; height: 24px; cursor: pointer;">
<label class="form-check-label fw-500 ms-2" for="emailEnabled">Включить уведомления</label>
</div>
<button class="btn-primary-custom btn-sm" onclick="saveEmailSettings()"><i class="fas fa-save me-1"></i>Сохранить</button>
<button class="btn-gold btn-sm" onclick="sendTestEmail()"><i class="fas fa-paper-plane me-1"></i>Отправить тест</button>
</div>
</div>
</div>
</div>
<div id="tab-profile" class="tab-content">
<div class="top-bar">
<h1>Профиль</h1>
</div>
<div class="card">
<div class="card-header-custom"><h3>Личные данные</h3></div>
<div class="card-body-custom">
<form id="profileForm">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">ФИО</label>
<input type="text" class="form-control" id="profileFullName" placeholder="Иванов Иван Иванович">
</div>
<div class="col-md-6">
<label class="form-label">Email для уведомлений</label>
<input type="email" class="form-control" id="profileEmail" placeholder="email@example.com">
</div>
</div>
<button type="submit" class="btn-primary-custom mt-3"><i class="fas fa-save"></i> Сохранить</button>
</form>
</div>
</div>
<div class="card">
<div class="card-header-custom"><h3>Сменить пароль</h3></div>
<div class="card-body-custom">
<form id="passwordForm">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Текущий пароль</label>
<input type="password" class="form-control" id="currentPassword" required>
</div>
<div class="col-md-4">
<label class="form-label">Новый пароль</label>
<input type="password" class="form-control" id="newPassword" required minlength="4">
</div>
<div class="col-md-4">
<label class="form-label">Повторите пароль</label>
<input type="password" class="form-control" id="confirmPassword" required minlength="4">
</div>
</div>
<button type="submit" class="btn-gold mt-3"><i class="fas fa-key"></i> Сменить пароль</button>
</form>
</div>
</div>
</div>
<div id="tab-activities" class="tab-content">
<div class="top-bar">
<h1>Развлечения — Чем заняться рядом</h1>
<button class="btn-gold" onclick="showActivityModal()"><i class="fas fa-plus"></i> Добавить</button>
</div>
<div class="card">
<div class="card-body-custom">
<div id="activitiesGrid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(min(280px, 100%), 1fr)); gap: clamp(10px, 2vw, 20px);"></div>
</div>
</div>
</div>
<div id="tab-hero" class="tab-content">
<div class="top-bar">
<h1>Главный экран</h1>
<button class="btn-gold" onclick="showHeroMediaModal()"><i class="fas fa-plus"></i> Добавить</button>
</div>
<div class="card">
<div class="card-body-custom">
<div id="heroMediaGrid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(min(280px, 100%), 1fr)); gap: clamp(10px, 2vw, 20px);"></div>
</div>
</div>
</div>
</div>
</div>
<div class="modal-backdrop-custom" id="userModal">
<div class="modal-custom">
<div class="modal-header-custom">
<h3 id="userModalTitle">Добавить пользователя</h3>
<button class="modal-close" onclick="hideUserModal()">&times;</button>
</div>
<form id="userForm">
<div class="modal-body-custom">
<input type="hidden" id="editUserId">
<div class="mb-3">
<label class="form-label">Логин *</label>
<input type="text" class="form-control" id="userLogin" required>
</div>
<div class="mb-3">
<label class="form-label">Пароль <span id="passwordHint">*</span></label>
<input type="password" class="form-control" id="userPassword" minlength="4">
</div>
<div class="mb-3">
<label class="form-label">ФИО</label>
<input type="text" class="form-control" id="userFullName" placeholder="Иванов Иван Иванович">
</div>
<div class="mb-3">
<label class="form-label">Email</label>
<input type="email" class="form-control" id="userEmail" placeholder="email@example.com">
</div>
<div class="mb-3 admin-only-field">
<label class="form-label">Роль</label>
<select class="form-control" id="userRole">
<option value="user">Пользователь</option>
<option value="admin">Администратор</option>
</select>
</div>
</div>
<div class="modal-footer-custom">
<button type="button" class="btn btn-secondary btn-sm" onclick="hideUserModal()">Отмена</button>
<button type="submit" class="btn-primary-custom btn-sm">Сохранить</button>
</div>
</form>
</div>
</div>
<div class="modal-backdrop-custom" id="historyModal">
<div class="modal-custom" style="max-width: 600px;">
<div class="modal-header-custom">
<h3 id="historyModalTitle">История изменений</h3>
<button class="modal-close" onclick="hideHistoryModal()">&times;</button>
</div>
<div class="modal-body-custom" style="max-height: 400px; overflow-y: auto;">
<div id="historyContent"></div>
</div>
</div>
</div>
<div class="modal-backdrop-custom" id="restoreModal">
<div class="modal-custom">
<div class="modal-header-custom">
<h3><i class="fas fa-exclamation-triangle text-warning me-2"></i>Восстановление из бекапа</h3>
<button class="modal-close" onclick="hideRestoreModal()">&times;</button>
</div>
<div class="modal-body-custom">
<div class="alert alert-warning" style="background: #fef3c7; border: 1px solid #f59e0b; border-radius: 10px;">
<p class="mb-2"><strong>Внимание!</strong></p>
<p class="mb-2">Текущие данные будут заменены данными из бекапа.</p>
<p class="mb-0"><strong>Перед восстановлением будет создан аварийный бекап текущего состояния.</strong></p>
</div>
<div class="mt-3">
<p class="mb-1"><strong>Бэкап:</strong> <span id="restoreBackupFilename"></span></p>
<p class="mb-1"><strong>Дата создания:</strong> <span id="restoreBackupDate"></span></p>
<p class="mb-0"><strong>Размер:</strong> <span id="restoreBackupSize"></span></p>
</div>
<input type="hidden" id="restoreBackupId">
</div>
<div class="modal-footer-custom">
<button type="button" class="btn btn-secondary btn-sm" onclick="hideRestoreModal()">Отмена</button>
<button type="button" class="btn-danger-custom" onclick="confirmRestore()" id="btnConfirmRestore">
<i class="fas fa-undo me-1"></i>Восстановить
</button>
</div>
</div>
</div>
<div class="modal-backdrop-custom" id="promocodeModal">
<div class="modal-custom">
<div class="modal-header-custom">
<h3 id="promocodeModalTitle">Добавить промокод</h3>
<button class="modal-close" onclick="hidePromocodeModal()">&times;</button>
</div>
<form id="promocodeForm">
<input type="hidden" id="editPromocodeId">
<div class="modal-body-custom">
<div class="mb-3">
<label class="form-label">Код *</label>
<input type="text" class="form-control" id="promocodeCode" required placeholder="SUMMER2026" style="text-transform: uppercase;">
</div>
<div class="mb-3">
<label class="form-label">Скидка (%) * от 1 до 99</label>
<input type="number" class="form-control" id="promocodeDiscount" required min="1" max="99" value="10">
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label">Действует с</label>
<input type="datetime-local" class="form-control" id="promocodeValidFrom">
</div>
<div class="col-md-6">
<label class="form-label">Действует по</label>
<input type="datetime-local" class="form-control" id="promocodeValidTo">
</div>
</div>
<div class="mb-3">
<label class="form-label">Или период (дней от сейчас)</label>
<input type="number" class="form-control" id="promocodeValidDays" min="1" placeholder="Например: 30">
<small class="text-muted">Если указано, отменяет даты "с" и "по"</small>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="promocodeIsActive" checked>
<label class="form-check-label" for="promocodeIsActive">Активен</label>
</div>
</div>
<div class="modal-footer-custom">
<button type="button" class="btn btn-secondary btn-sm" onclick="hidePromocodeModal()">Отмена</button>
<button type="submit" class="btn-primary-custom btn-sm">Сохранить</button>
</div>
</form>
</div>
</div>
<script>
const API = '';
let token = localStorage.getItem('token');
let currentUser = JSON.parse(localStorage.getItem('currentUser') || 'null');
let editingActivityId = null;
let tempActivityImagePath = null;
let editingHeroMediaId = null;
let tempHeroImagePath = null;
let tempHeroVideoPath = null;
function getHeaders() { return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }; }
function showToast(msg, type = 'success') {
const c = document.getElementById('toastContainer');
const t = document.createElement('div');
t.className = 'toast ' + type;
t.innerHTML = '<span class="toast-icon"><i class="fas fa-' + (type === 'success' ? 'check-circle' : 'exclamation-circle') + '"></i></span><span>' + msg + '</span>';
c.appendChild(t);
setTimeout(() => t.remove(), 3000);
}
async function api(url, opts = {}) {
const res = await fetch(API + url, { ...opts, headers: { ...getHeaders(), ...opts.headers } });
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Ошибка');
return data;
}
function initTabs() {
document.querySelectorAll('.sidebar-nav a[data-tab]').forEach(a => {
a.addEventListener('click', e => {
e.preventDefault();
const tab = a.dataset.tab;
document.querySelectorAll('.sidebar-nav a').forEach(x => x.classList.remove('active'));
a.classList.add('active');
document.querySelectorAll('.tab-content').forEach(x => x.classList.remove('active'));
document.getElementById('tab-' + tab).classList.add('active');
if (tab === 'dashboard') loadDashboard();
if (tab === 'calendar') { calendarYear = new Date().getFullYear(); calendarMonth = new Date().getMonth() + 1; loadCalendar(); }
if (tab === 'users') loadUsers();
if (tab === 'bookings') { bookingsLoaded = false; loadRoomsForBookingEdit(); loadBookings(); }
if (tab === 'rooms') loadRooms();
if (tab === 'promocodes') loadPromocodes();
if (tab === 'seasonal') loadSeasonalPrices();
if (tab === 'activities') loadActivitiesAdmin();
if (tab === 'hero') loadHeroMediaAdmin();
if (tab === 'reviews') loadReviews();
if (tab === 'settings') loadSettings();
if (tab === 'profile') loadProfile();
});
});
}
function toggleSidebar() {
document.getElementById('sidebar').classList.toggle('open');
document.getElementById('sidebarOverlay').classList.toggle('show');
}
function closeSidebar() {
document.getElementById('sidebar').classList.remove('open');
document.getElementById('sidebarOverlay').classList.remove('show');
}
function updateUI() {
const isAdmin = currentUser && currentUser.role === 'admin';
document.querySelectorAll('.admin-only, .admin-only-field').forEach(el => {
el.style.display = isAdmin ? '' : 'none';
});
document.getElementById('sidebarUserName').textContent = currentUser.full_name || currentUser.login;
document.getElementById('sidebarUserRole').textContent = currentUser.role === 'admin' ? 'Администратор' : 'Пользователь';
}
async function checkAuth() {
if (!token || !currentUser) return showLogin();
try {
const users = await api('/api/admin/users');
const me = users.find(u => u.id === currentUser.id);
if (me) { currentUser = me; localStorage.setItem('currentUser', JSON.stringify(me)); showAdmin(); }
else showLogin();
} catch (e) { showLogin(); }
}
function showLogin() {
document.getElementById('login-page').style.display = 'flex';
document.getElementById('admin-panel').style.display = 'none';
}
function showAdmin() {
document.getElementById('login-page').style.display = 'none';
document.getElementById('admin-panel').style.display = 'block';
updateUI();
loadDashboard();
initTabs();
}
function logout() {
token = null; currentUser = null;
localStorage.removeItem('token'); localStorage.removeItem('currentUser');
showLogin();
}
document.getElementById('loginForm').addEventListener('submit', async e => {
e.preventDefault();
const btn = document.getElementById('loginBtn');
btn.innerHTML = '<span class="loading-spinner"></span>';
try {
const data = await fetch(API + '/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ login: document.getElementById('loginInput').value, password: document.getElementById('passwordInput').value })
}).then(r => r.json());
if (data.error) throw new Error(data.error);
token = data.token; currentUser = data.user;
localStorage.setItem('token', token); localStorage.setItem('currentUser', JSON.stringify(currentUser));
showAdmin();
} catch (err) {
const el = document.getElementById('loginError'); el.textContent = err.message; el.style.display = 'block';
}
btn.innerHTML = 'Войти';
});
async function loadDashboard() {
try {
const summary = await api('/api/admin/reports/summary');
document.getElementById('statRevenueMonth').textContent = (summary.revenue.month || 0).toLocaleString() + ' ₽';
document.getElementById('statRevenueWeek').textContent = (summary.revenue.week || 0).toLocaleString() + ' ₽';
document.getElementById('statOccupancy').textContent = (summary.occupancy.current || 0) + '%';
document.getElementById('statNew').textContent = summary.bookings.new || 0;
document.getElementById('statCheckins').textContent = summary.bookings.checkin_today || 0;
document.getElementById('statAvgReview').textContent = (summary.avg_review_stars || 0).toFixed(1);
} catch(e) { console.error('Dashboard error:', e); }
try {
const bookings = await api('/api/admin/bookings?limit=5');
const rows = bookings.data || [];
document.getElementById('recentBookings').innerHTML = rows.filter(r => r.status !== 'отменена' && r.status !== 'выехала').slice(0, 5).map(r => '<tr>' +
'<td>' + esc(r.name) + '</td><td>' + esc(r.room_name ? r.room_type + ' — ' + r.room_name : r.room_type || '—') + '</td>' +
'<td>' + esc(r.checkin_date) + '</td><td>' + esc(r.checkout_date) + '</td>' +
'<td><span class="badge badge-status badge-status-' + r.status + '">' + r.status + '</span></td></tr>').join('');
if (!rows.filter(r => r.status !== 'отменена' && r.status !== 'выехала').length) {
document.getElementById('recentBookings').innerHTML = '<tr><td colspan="5" class="text-center text-muted">Нет данных</td></tr>';
}
} catch(e) {
document.getElementById('recentBookings').innerHTML = '<tr><td colspan="5" class="text-center text-muted">Нет данных</td></tr>';
}
try {
const reviewData = await api('/api/admin/reviews');
document.getElementById('statPendingReviews')?.textContent != null && (document.getElementById('statPendingReviews').textContent = reviewData.stats.pending);
} catch(e) {}
loadRevenueChart('monthly');
}
let currentChartPeriod = 'monthly';
async function loadRevenueChart(period) {
currentChartPeriod = period;
document.querySelectorAll('#chartWeekBtn, #chartDayBtn, [onclick*="loadRevenueChart"]').forEach(b => {
b.style.background = '#e2e8f0'; b.style.color = '#64748b';
});
const activeBtn = period === 'monthly' ? document.querySelector('[onclick*="monthly"]') : period === 'weekly' ? document.getElementById('chartWeekBtn') : document.getElementById('chartDayBtn');
if (activeBtn) { activeBtn.style.background = 'var(--primary)'; activeBtn.style.color = '#fff'; }
try {
const data = await api('/api/admin/reports/revenue?period=' + period + '&months=6');
drawRevenueChart(data.data || [], period);
} catch(e) { console.error('Chart error:', e); }
}
function drawRevenueChart(data, period) {
const canvas = document.getElementById('revenueChart');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const rect = canvas.parentElement.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = 280 * dpr;
canvas.style.width = rect.width + 'px';
canvas.style.height = '280px';
ctx.scale(dpr, dpr);
const W = rect.width - 60;
const H = 230;
const padLeft = 55;
const padTop = 10;
ctx.clearRect(0, 0, rect.width, 280);
if (data.length === 0) {
ctx.fillStyle = '#94a3b8';
ctx.font = '14px Inter, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('Нет данных', rect.width / 2, 150);
return;
}
const maxRev = Math.max(...data.map(d => d.revenue), 1);
const barW = Math.min(60, (W / data.length) - 10);
const colors = ['#2563eb', '#3b82f6', '#60a5fa', '#93c5fd'];
ctx.strokeStyle = '#e2e8f0';
ctx.lineWidth = 1;
for (let i = 0; i <= 4; i++) {
const y = padTop + (H * i / 4);
ctx.beginPath();
ctx.moveTo(padLeft, y);
ctx.lineTo(padLeft + W, y);
ctx.stroke();
ctx.fillStyle = '#64748b';
ctx.font = '11px Inter, sans-serif';
ctx.textAlign = 'right';
ctx.fillText(Math.round(maxRev * (4 - i) / 4).toLocaleString() + ' ₽', padLeft - 8, y + 4);
}
data.forEach((d, i) => {
const x = padLeft + i * (W / data.length) + (W / data.length - barW) / 2;
const h = (d.revenue / maxRev) * H;
const y = padTop + H - h;
ctx.fillStyle = colors[i % colors.length];
ctx.beginPath();
const radius = 4;
const bx = x, by = y, bw = barW, bh = h;
ctx.moveTo(bx + radius, by);
ctx.lineTo(bx + bw - radius, by);
ctx.quadraticCurveTo(bx + bw, by, bx + bw, by + radius);
ctx.lineTo(bx + bw, by + bh);
ctx.lineTo(bx, by + bh);
ctx.lineTo(bx, by + radius);
ctx.quadraticCurveTo(bx, by, bx + radius, by);
ctx.closePath();
ctx.fill();
ctx.fillStyle = '#0f172a';
ctx.font = '11px Inter, sans-serif';
ctx.textAlign = 'center';
const label = d.label.length > 10 ? d.label.slice(-7) : d.label;
ctx.fillText(label, x + barW / 2, padTop + H + 18);
});
}
async function loadUsers() {
try {
const users = await api('/api/admin/users');
const tbody = document.getElementById('usersTable');
tbody.innerHTML = users.map(u => '<tr>' +
'<td><strong>' + esc(u.login) + '</strong></td>' +
'<td>' + esc(u.full_name || '—') + '</td>' +
'<td>' + esc(u.email || '—') + '</td>' +
'<td><span class="badge ' + (u.role === 'admin' ? 'badge-admin' : 'badge-user') + '">' + (u.role === 'admin' ? 'Админ' : 'Пользователь') + '</span></td>' +
'<td>' + esc(u.created_at || '—') + '</td>' +
'<td class="admin-only">' +
'<button class="btn-primary-custom btn-sm me-1" onclick="showUserModal(' + u.id + ')"><i class="fas fa-edit"></i></button>' +
'<button class="btn-danger-custom btn-sm" onclick="deleteUser(' + u.id + ')"><i class="fas fa-trash"></i></button>' +
'</td></tr>').join('');
updateUI();
} catch(err) { showToast(err.message, 'error'); }
}
let allBookingsData = [];
let allRoomsData = [];
let sortDirection = 'asc';
let bookingsLoaded = false;
let currentPage = 1;
let totalPages = 1;
let searchTimeout = null;
async function loadRoomsForBookingEdit() {
try {
const rooms = await api('/api/admin/rooms');
allRoomsData = rooms;
} catch(e) {
console.error('Failed to load rooms:', e);
}
}
async function loadBookings(resetPage = true) {
if (resetPage) currentPage = 1;
try {
const search = document.getElementById('searchBookings').value;
const status = document.getElementById('filterStatus').value;
const limit = 20;
const params = new URLSearchParams({
page: currentPage,
limit: limit,
search: search,
status: status
});
const response = await api('/api/admin/bookings?' + params.toString());
allBookingsData = response.data;
totalPages = response.pagination.totalPages;
currentPage = response.pagination.page;
bookingsLoaded = true;
renderBookings();
updateBookingStats();
updatePagination();
} catch(err) { showToast('Нет доступа к бронированиям: ' + err.message, 'error'); }
}
function updatePagination() {
const info = document.getElementById('paginationInfo');
const prevBtn = document.getElementById('prevPage');
const nextBtn = document.getElementById('nextPage');
const pageNumbers = document.getElementById('pageNumbers');
const total = allBookingsData.length;
const from = (currentPage - 1) * 20 + 1;
const to = Math.min(currentPage * 20, total);
info.textContent = total > 0 ? `Показано ${from}-${to} из ${total}` : 'Нет записей';
prevBtn.disabled = currentPage <= 1;
nextBtn.disabled = currentPage >= totalPages;
// Page numbers
let pagesHtml = '';
const maxVisible = 5;
let start = Math.max(1, currentPage - Math.floor(maxVisible / 2));
let end = Math.min(totalPages, start + maxVisible - 1);
if (end - start < maxVisible - 1) start = Math.max(1, end - maxVisible + 1);
if (start > 1) {
pagesHtml += `<button class="btn btn-outline-secondary btn-sm" onclick="goToPage(1)">1</button>`;
if (start > 2) pagesHtml += `<span style="padding: 0 4px;">...</span>`;
}
for (let i = start; i <= end; i++) {
if (i === currentPage) {
pagesHtml += `<button class="btn btn-primary btn-sm" onclick="goToPage(${i})">${i}</button>`;
} else {
pagesHtml += `<button class="btn btn-outline-secondary btn-sm" onclick="goToPage(${i})">${i}</button>`;
}
}
if (end < totalPages) {
if (end < totalPages - 1) pagesHtml += `<span style="padding: 0 4px;">...</span>`;
pagesHtml += `<button class="btn btn-outline-secondary btn-sm" onclick="goToPage(${totalPages})">${totalPages}</button>`;
}
pageNumbers.innerHTML = pagesHtml;
}
function changePage(delta) {
const newPage = currentPage + delta;
if (newPage >= 1 && newPage <= totalPages) {
currentPage = newPage;
loadBookings(false);
}
}
function goToPage(page) {
if (page >= 1 && page <= totalPages) {
currentPage = page;
loadBookings(false);
}
}
function debounceSearch() {
if (searchTimeout) clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => loadBookings(true), 500);
}
function getDaysDiff(dateStr) {
const today = new Date(); today.setHours(0,0,0,0);
const d = new Date(dateStr); d.setHours(0,0,0,0);
return Math.round((d - today) / (1000 * 60 * 60 * 24));
}
function updateBookingStats() {
const stats = {};
allBookingsData.forEach(b => { stats[b.status] = (stats[b.status] || 0) + 1; });
document.getElementById('bookingStats').innerHTML = Object.entries(stats).map(([k,v]) => '<span class="badge badge-status badge-status-' + k + '" style="margin-right: 4px;">' + v + ' ' + k + '</span>').join('');
}
function renderBookings() {
let rows = [...allBookingsData];
const filterStatus = document.getElementById('filterStatus').value;
const filterUrgent = document.getElementById('filterUrgent').value;
rows.sort((a, b) => {
const da = new Date(a.checkin_date), db = new Date(b.checkin_date);
return sortDirection === 'asc' ? da - db : db - da;
});
const statuses = ['новая', 'оплачена', 'зарезервирована', 'заселена', 'выехала', 'отменена'];
const tbody = document.getElementById('allBookings');
if (rows.length === 0) {
tbody.innerHTML = '<tr><td colspan="10" class="text-center text-muted">Нет записей</td></tr>';
return;
}
tbody.innerHTML = rows.map(r => {
const checkinDiff = getDaysDiff(r.checkin_date);
const checkoutDiff = getDaysDiff(r.checkout_date);
let rowClass = '';
if (checkinDiff >= 0 && checkinDiff <= 3 && r.status !== 'отменена' && r.status !== 'выехала') rowClass = 'row-checkin-soon';
if (checkoutDiff === 0 && r.status !== 'отменена' && r.status !== 'выехала') rowClass = 'row-checkout-today';
const selectOpts = statuses.map(s => '<option value="' + s + '"' + (r.status === s ? ' selected' : '') + '>' + s.charAt(0).toUpperCase() + s.slice(1) + '</option>').join('');
const roomDisplay = r.room_name ? r.room_type + ' — ' + r.room_name : r.room_type || '—';
const roomOpts = '<option value="">—</option>' + allRoomsData.map(room =>
'<option value="' + room.id + '"' + (r.room_id === room.id ? ' selected' : '') + '>' + room.type + ' — ' + room.name + '</option>'
).join('');
const commentHtml = '<input type="text" class="form-control form-control-sm" style="max-width: 100%; min-width: 80px; font-size: 0.8rem;" value="' + esc(r.comment || '') + '" onchange="changeComment(' + r.id + ', this.value)" placeholder="Комментарий">';
const discountHtml = '<input type="number" min="0" max="99" class="form-control form-control-sm" style="max-width: 80px; min-width: 60px; font-size: 0.8rem;" value="' + (r.discount_percent || 0) + '" onchange="changeDiscount(' + r.id + ', this.value)">';
return '<tr class="' + rowClass + '">' +
'<td><strong>' + esc(r.name) + '</strong></td><td>' + esc(r.phone) + '</td>' +
'<td><select class="form-select form-select-sm room-select" style="max-width: 100%; min-width: 120px; font-size: 0.75rem;" data-booking-id="' + r.id + '" onchange="changeRoom(' + r.id + ', this.value)">' + roomOpts + '</select></td>' +
'<td><input type="number" min="0" class="form-control form-control-sm" style="max-width: 60px; font-size:0.8rem;" value="' + r.adults + '" onchange="changeDetails(' + r.id + ', \'adults\', this.value)"></td>' +
'<td><input type="number" min="0" class="form-control form-control-sm" style="max-width: 60px; font-size:0.8rem;" value="' + r.children + '" onchange="changeDetails(' + r.id + ', \'children\', this.value)"></td>' +
'<td><input type="date" class="form-control form-control-sm" style="max-width: 100%; min-width: 110px; font-size:0.8rem;" value="' + esc(r.checkin_date) + '" onchange="changeDetails(' + r.id + ', \'checkin_date\', this.value)"></td>' +
'<td><input type="date" class="form-control form-control-sm" style="max-width: 100%; min-width: 110px; font-size:0.8rem;" value="' + esc(r.checkout_date) + '" onchange="changeDetails(' + r.id + ', \'checkout_date\', this.value)"></td>' +
'<td>' + esc(r.wishes || '—') + '</td>' +
'<td>' + commentHtml + '</td>' +
'<td>' + (r.base_price || 0) + '</td>' +
'<td>' + discountHtml + '</td>' +
'<td>' + (r.discount_amount || 0) + '</td>' +
'<td><strong>' + (r.total_price || r.base_price || 0) + '</strong></td>' +
'<td><span class="badge bg-info" style="font-size: 0.7rem;">' + esc(r.promocode_code || '—') + '</span></td>' +
'<td><span class="badge badge-status badge-status-' + r.status + '">' + r.status + '</span></td>' +
'<td style="white-space: nowrap;"><select class="form-select form-select-sm" style="max-width: 100%; min-width: 120px; font-size: 0.8rem;" onchange="changeStatus(' + r.id + ', this.value)">' + selectOpts + '</select> <button class="btn btn-outline-secondary btn-sm" style="padding: 2px 8px; font-size: 0.7rem; margin-left: 4px;" onclick="showHistory(' + r.id + ')"><i class="fas fa-clock"></i></button></td>' +
'</tr>';
}).join('');
}
function setSort(dir) {
sortDirection = dir;
document.getElementById('sortAsc').style.opacity = dir === 'asc' ? '1' : '0.5';
document.getElementById('sortDesc').style.opacity = dir === 'desc' ? '1' : '0.5';
renderBookings();
}
async function changeStatus(id, status) {
try {
const data = await api('/api/admin/bookings/' + id, { method: 'PATCH', body: JSON.stringify({ status }) });
allBookingsData = allBookingsData.map(b => b.id === id ? data.booking : b);
renderBookings(); updateBookingStats(); loadDashboard();
showToast('Статус обновлён: ' + status);
} catch(err) { showToast(err.message, 'error'); }
}
async function changeRoom(id, room_id) {
try {
const data = await api('/api/admin/bookings/' + id + '/room', { method: 'PATCH', body: JSON.stringify({ room_id: room_id || null }) });
allBookingsData = allBookingsData.map(b => b.id === id ? data.booking : b);
renderBookings(); updateBookingStats(); loadDashboard();
showToast('Номер обновлён');
} catch(err) { showToast(err.message, 'error'); }
}
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('filterStatus').addEventListener('change', loadBookings);
document.getElementById('filterUrgent').addEventListener('change', loadBookings);
document.getElementById('sortAsc').style.opacity = '1';
document.getElementById('sortDesc').style.opacity = '0.5';
});
function loadProfile() {
document.getElementById('profileFullName').value = currentUser.full_name || '';
document.getElementById('profileEmail').value = currentUser.email || '';
document.getElementById('sidebarUserName').textContent = currentUser.full_name || currentUser.login;
document.getElementById('sidebarUserRole').textContent = currentUser.role === 'admin' ? 'Администратор' : 'Пользователь';
}
document.getElementById('profileForm').addEventListener('submit', async e => {
e.preventDefault();
try {
const data = await api('/api/auth/me', {
method: 'PUT',
body: JSON.stringify({ full_name: document.getElementById('profileFullName').value, email: document.getElementById('profileEmail').value })
});
currentUser = data.user; localStorage.setItem('currentUser', JSON.stringify(currentUser));
loadProfile(); updateUI(); showToast('Профиль обновлён');
} catch(err) { showToast(err.message, 'error'); }
});
document.getElementById('passwordForm').addEventListener('submit', async e => {
e.preventDefault();
const np = document.getElementById('newPassword').value;
if (np !== document.getElementById('confirmPassword').value) { showToast('Пароли не совпадают', 'error'); return; }
try {
await api('/api/auth/change-password', {
method: 'POST',
body: JSON.stringify({ current_password: document.getElementById('currentPassword').value, new_password: np })
});
showToast('Пароль изменён'); e.target.reset();
} catch(err) { showToast(err.message, 'error'); }
});
function showUserModal(id) {
document.getElementById('userModal').classList.add('show');
document.getElementById('userForm').reset();
document.getElementById('editUserId').value = '';
document.getElementById('userModalTitle').textContent = 'Добавить пользователя';
document.getElementById('userLogin').disabled = false;
document.getElementById('passwordHint').textContent = '*';
document.getElementById('userPassword').required = true;
if (id) {
api('/api/admin/users').then(users => {
const u = users.find(x => x.id === id);
if (u) {
document.getElementById('editUserId').value = u.id;
document.getElementById('userLogin').value = u.login;
document.getElementById('userLogin').disabled = true;
document.getElementById('userFullName').value = u.full_name || '';
document.getElementById('userEmail').value = u.email || '';
document.getElementById('userRole').value = u.role;
document.getElementById('userModalTitle').textContent = 'Редактировать пользователя';
document.getElementById('passwordHint').textContent = '(оставьте пустым, если не меняете)';
document.getElementById('userPassword').required = false;
}
});
}
}
function hideUserModal() { document.getElementById('userModal').classList.remove('show'); }
document.getElementById('userForm').addEventListener('submit', async e => {
e.preventDefault();
const id = document.getElementById('editUserId').value;
const body = {
login: document.getElementById('userLogin').value,
full_name: document.getElementById('userFullName').value,
email: document.getElementById('userEmail').value,
role: document.getElementById('userRole').value
};
const pw = document.getElementById('userPassword').value;
if (pw) body.password = pw;
try {
if (id) {
await api('/api/admin/users/' + id, { method: 'PUT', body: JSON.stringify(body) });
showToast('Пользователь обновлён');
} else {
if (!pw) { showToast('Пароль обязателен', 'error'); return; }
await api('/api/admin/users', { method: 'POST', body: JSON.stringify(body) });
showToast('Пользователь создан');
}
hideUserModal(); loadUsers();
} catch(err) { showToast(err.message, 'error'); }
});
async function deleteUser(id) {
if (!confirm('Удалить пользователя?')) return;
try { await api('/api/admin/users/' + id, { method: 'DELETE' }); showToast('Пользователь удалён'); loadUsers(); }
catch(err) { showToast(err.message, 'error'); }
}
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function escAttr(s) { if (s === null || s === undefined) return ''; return String(s).replace(/'/g, "\\'").replace(/"/g, '&quot;'); }
async function changeComment(id, comment) {
try {
const data = await api('/api/admin/bookings/' + id + '/comment', { method: 'PATCH', body: JSON.stringify({ comment }) });
allBookingsData = allBookingsData.map(b => b.id === id ? data.booking : b);
showToast('Комментарий обновлён');
} catch(err) { showToast(err.message, 'error'); }
}
async function changeDiscount(id, discount_percent) {
try {
const data = await api('/api/admin/bookings/' + id + '/discount', { method: 'PATCH', body: JSON.stringify({ discount_percent: parseInt(discount_percent) }) });
allBookingsData = allBookingsData.map(b => b.id === id ? data.booking : b);
renderBookings();
showToast('Скидка обновлена: ' + discount_percent + '%');
} catch(err) { showToast(err.message, 'error'); }
}
async function loadPromocodes() {
try {
const promocodes = await api('/api/admin/promocodes');
const tbody = document.getElementById('promocodesTable');
tbody.innerHTML = promocodes.map(p => '<tr>' +
'<td><strong>' + esc(p.code) + '</strong></td>' +
'<td><span class="badge bg-success" style="font-size: 0.8rem;">' + p.discount_percent + '%</span></td>' +
'<td>' + (p.valid_from || '—') + '</td>' +
'<td>' + (p.valid_to || '—') + '</td>' +
'<td>' + (p.valid_days ? p.valid_days + ' дн.' : 'Бессрочно') + '</td>' +
'<td><span class="badge ' + (p.is_active ? 'bg-success' : 'bg-secondary') + '">' + (p.is_active ? 'Активен' : 'Неактивен') + '</span></td>' +
'<td class="admin-only">' +
'<button class="btn-primary-custom btn-sm me-1" onclick="showPromocodeModal(' + p.id + ')"><i class="fas fa-edit"></i></button>' +
'<button class="btn-danger-custom btn-sm" onclick="deletePromocode(' + p.id + ')"><i class="fas fa-trash"></i></button>' +
'</td></tr>').join('');
updateUI();
} catch(err) { showToast(err.message, 'error'); }
}
function showPromocodeModal(id) {
document.getElementById('promocodeModal').classList.add('show');
document.getElementById('promocodeForm').reset();
document.getElementById('editPromocodeId').value = '';
document.getElementById('promocodeModalTitle').textContent = 'Добавить промокод';
document.getElementById('promocodeCode').disabled = false;
if (id) {
api('/api/admin/promocodes').then(promocodes => {
const p = promocodes.find(x => x.id === id);
if (p) {
document.getElementById('editPromocodeId').value = p.id;
document.getElementById('promocodeCode').value = p.code;
document.getElementById('promocodeCode').disabled = true;
document.getElementById('promocodeDiscount').value = p.discount_percent;
document.getElementById('promocodeValidFrom').value = p.valid_from ? p.valid_from.slice(0, 16) : '';
document.getElementById('promocodeValidTo').value = p.valid_to ? p.valid_to.slice(0, 16) : '';
document.getElementById('promocodeValidDays').value = p.valid_days || '';
document.getElementById('promocodeIsActive').checked = p.is_active === 1;
document.getElementById('promocodeModalTitle').textContent = 'Редактировать промокод';
}
});
}
}
function hidePromocodeModal() { document.getElementById('promocodeModal').classList.remove('show'); }
document.getElementById('promocodeForm').addEventListener('submit', async e => {
e.preventDefault();
const id = document.getElementById('editPromocodeId').value;
const body = {
code: document.getElementById('promocodeCode').value,
discount_percent: parseInt(document.getElementById('promocodeDiscount').value),
valid_from: document.getElementById('promocodeValidFrom').value || null,
valid_to: document.getElementById('promocodeValidTo').value || null,
valid_days: document.getElementById('promocodeValidDays').value ? parseInt(document.getElementById('promocodeValidDays').value) : null,
is_active: document.getElementById('promocodeIsActive').checked ? 1 : 0
};
try {
if (id) {
await api('/api/admin/promocodes/' + id, { method: 'PUT', body: JSON.stringify(body) });
showToast('Промокод обновлён');
} else {
await api('/api/admin/promocodes', { method: 'POST', body: JSON.stringify(body) });
showToast('Промокод создан');
}
hidePromocodeModal(); loadPromocodes();
} catch(err) { showToast(err.message, 'error'); }
});
async function deletePromocode(id) {
if (!confirm('Удалить промокод?')) return;
try { await api('/api/admin/promocodes/' + id, { method: 'DELETE' }); showToast('Промокод удалён'); loadPromocodes(); }
catch(err) { showToast(err.message, 'error'); }
}
async function showHistory(bookingId) {
try {
const booking = allBookingsData.find(b => b.id === bookingId);
document.getElementById('historyModalTitle').textContent = 'История: ' + (booking ? booking.name : '#'+bookingId);
const history = await api('/api/admin/bookings/' + bookingId + '/history');
const fieldNames = { status: 'Статус', room_type: 'Тип номера', name: 'Имя', phone: 'Телефон', checkin_date: 'Дата заезда', checkout_date: 'Дата выезда', wishes: 'Пожелания' };
if (history.length === 0) {
document.getElementById('historyContent').innerHTML = '<p class="text-center text-muted">Нет записей</p>';
} else {
document.getElementById('historyContent').innerHTML = '<div class="history-timeline">' + history.map(h => {
const fieldName = fieldNames[h.field] || h.field;
return '<div class="history-item">' +
'<div class="history-date">' + h.created_at + '</div>' +
'<div class="history-user">' + (h.user_login || '—') + '</div>' +
'<div class="history-change">' + fieldName + ': <span class="old-val">' + esc(h.old_value || '—') + '</span> → <span class="new-val">' + esc(h.new_value || '—') + '</span></div>' +
'</div>';
}).join('') + '</div>';
}
document.getElementById('historyModal').classList.add('show');
} catch(err) { showToast(err.message, 'error'); }
}
function hideHistoryModal() { document.getElementById('historyModal').classList.remove('show'); }
document.getElementById('historyModal').addEventListener('click', function(e) { if (e.target === this) hideHistoryModal(); });
document.getElementById('promocodeModal').addEventListener('click', function(e) { if (e.target === this) hidePromocodeModal(); });
async function changeDetails(id, field, value) {
try {
const body = {};
if (field === 'adults' || field === 'children') {
if (value === '' || isNaN(parseInt(value))) {
showToast('Введите корректное число', 'error');
return;
}
body[field] = parseInt(value);
} else {
body[field] = value;
}
const data = await api('/api/admin/bookings/' + id + '/details', { method: 'PATCH', body: JSON.stringify(body) });
allBookingsData = allBookingsData.map(b => b.id === id ? data.booking : b);
renderBookings(); updateBookingStats(); loadDashboard();
showToast('Данные обновлены');
} catch(err) {
showToast(err.message || 'Ошибка при сохранении', 'error');
loadBookings(false);
}
}
checkAuth();
// Reviews Tab Functions
async function loadReviews() {
try {
const data = await api('/api/admin/reviews');
renderReviews(data.reviews, data.stats);
document.getElementById('statPendingReviews').textContent = data.stats.pending;
} catch(err) { showToast(err.message, 'error'); }
}
function renderReviews(reviews, stats) {
const tbody = document.getElementById('reviewsTable');
if (reviews.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" class="text-center text-muted">Нет отзывов</td></tr>';
return;
}
tbody.innerHTML = reviews.map(r => {
const stars = renderStarsHTML(r.stars);
const statusClass = r.is_approved === 1 ? 'bg-success' : (r.is_approved === -1 ? 'bg-secondary' : 'bg-warning');
const statusText = r.is_approved === 1 ? 'Одобрен' : (r.is_approved === -1 ? 'Скрыт' : 'На модерации');
const date = r.created_at ? new Date(r.created_at).toLocaleDateString('ru-RU') : '—';
const location = [r.country, r.city].filter(Boolean).join(', ') || '—';
return '<tr>' +
'<td><strong>' + esc(r.author_name) + '</strong></td>' +
'<td>' + location + '</td>' +
'<td><span class="text-warning">' + stars + '</span> ' + r.stars.toFixed(1) + '</td>' +
'<td class="review-text-cell"><div class="review-text-full" id="reviewText' + r.id + '">' + esc(r.text).replace(/\n/g, '<br>') + '</div></td>' +
'<td><span class="badge ' + statusClass + '">' + statusText + '</span></td>' +
'<td>' + date + '</td>' +
'<td style="white-space: nowrap;">' +
(r.is_approved !== 1 ? '<button class="btn btn-success btn-sm me-1" onclick="approveReview(' + r.id + ', true)"><i class="fas fa-check"></i></button>' : '') +
(r.is_approved !== -1 && r.is_approved !== 0 ? '<button class="btn btn-warning btn-sm me-1" onclick="approveReview(' + r.id + ', false)"><i class="fas fa-eye-slash"></i></button>' : '') +
'<button class="btn btn-danger btn-sm" onclick="deleteReview(' + r.id + ')"><i class="fas fa-trash"></i></button>' +
'</td></tr>';
}).join('');
}
function renderStarsHTML(count) {
let html = '';
const fullStars = Math.floor(count);
for (let i = 0; i < 5; i++) {
html += i < fullStars ? '<i class="fas fa-star"></i>' : '<i class="far fa-star"></i>';
}
return html;
}
async function approveReview(id, approve) {
try {
await api('/api/admin/reviews/' + id + '/approve', { method: 'PATCH', body: JSON.stringify({ approved: approve }) });
showToast(approve ? 'Отзыв одобрен' : 'Отзыв скрыт');
loadReviews();
} catch(err) { showToast(err.message, 'error'); }
}
async function deleteReview(id) {
if (!confirm('Удалить этот отзыв?')) return;
try {
await api('/api/admin/reviews/' + id, { method: 'DELETE' });
showToast('Отзыв удалён');
loadReviews();
} catch(err) { showToast(err.message, 'error'); }
}
// Settings Tab Functions
async function loadSettings() {
try {
const data = await api('/api/admin/settings');
const display = document.getElementById('currentCodeDisplay');
if (display) {
display.value = data.review_code || 'Не установлен';
}
} catch(err) {
const display = document.getElementById('currentCodeDisplay');
if (display) display.value = 'Ошибка загрузки';
}
try {
await loadBackupSettings();
await loadBackupsList();
} catch(err) {
console.error('Failed to load backup module:', err);
}
}
document.getElementById('settingsForm').addEventListener('submit', async e => {
e.preventDefault();
const newCode = document.getElementById('newReviewCode').value.trim();
if (!newCode || newCode.length < 3) {
showToast('Код должен содержать минимум 3 символа', 'error');
return;
}
try {
await api('/api/admin/settings/review-code', { method: 'PUT', body: JSON.stringify({ code: newCode }) });
showToast('Кодовое слово обновлено');
document.getElementById('newReviewCode').value = '';
loadSettings();
} catch(err) { showToast(err.message, 'error'); }
});
function toggleCodeShow() {
const display = document.getElementById('currentCodeDisplay');
const isMasked = display.textContent.includes('*');
if (isMasked) {
api('/api/admin/settings/review-code').then(data => {
display.textContent = data.code;
});
} else {
display.textContent = '******';
}
}
// Backup Functions
async function loadBackupSettings() {
try {
const settings = await api('/api/admin/backup/settings');
document.getElementById('backupAutoEnabled').checked = settings.backup_auto_enabled === 'true';
document.getElementById('backupAutoTime').value = settings.backup_auto_time || '03:00';
document.getElementById('backupRetentionDays').value = settings.backup_retention_days || '30';
} catch(err) {
console.error('Failed to load backup settings:', err);
}
}
async function saveBackupSettings() {
const enabled = document.getElementById('backupAutoEnabled').checked;
const time = document.getElementById('backupAutoTime').value;
const retention = document.getElementById('backupRetentionDays').value;
try {
await api('/api/admin/backup/settings', {
method: 'PUT',
body: JSON.stringify({
backup_auto_enabled: enabled,
backup_auto_time: time,
backup_retention_days: retention
})
});
showToast('Настройки резервного копирования сохранены');
} catch(err) {
showToast('Ошибка сохранения настроек: ' + err.message, 'error');
}
}
async function createManualBackup() {
const btn = document.getElementById('btnCreateBackup');
const originalText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="loading-spinner"></span> Создание...';
try {
const result = await api('/api/admin/backup', { method: 'POST' });
showToast('Бекап создан: ' + result.backup.filename);
loadBackupsList();
} catch(err) {
showToast('Ошибка создания бекапа: ' + err.message, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = originalText;
}
}
async function loadBackupsList() {
try {
const result = await api('/api/admin/backups');
const tbody = document.getElementById('backupsTableBody');
if (!result.backups || result.backups.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted">Нет бекапов</td></tr>';
return;
}
tbody.innerHTML = result.backups.map(b => {
const date = new Date(b.created_at);
const dateStr = date.toLocaleDateString('ru-RU') + ' ' + date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
const sizeStr = formatBackupSize(b.size);
const typeLabel = b.type === 'auto' ? '<span class="badge" style="background: #dbeafe; color: #1e40af;">авто</span>' :
b.type === 'emergency_before_restore' ? '<span class="badge" style="background: #fef3c7; color: #92400e;">аварийный</span>' :
'<span class="badge" style="background: #dcfce7; color: #15803d;">ручной</span>';
const restoredStr = b.restored_at ? new Date(b.restored_at).toLocaleDateString('ru-RU') : '—';
return '<tr>' +
'<td style="white-space: nowrap;">' + esc(dateStr) + '</td>' +
'<td>' + sizeStr + '</td>' +
'<td>' + typeLabel + '</td>' +
'<td>' + esc(restoredStr) + '</td>' +
'<td class="text-end">' +
'<button class="btn btn-outline-secondary btn-sm me-1" onclick="downloadBackup(' + b.id + ')" title="Скачать"><i class="fas fa-download"></i></button>' +
'<button class="btn btn-outline-warning btn-sm me-1" onclick="showRestoreModal(' + b.id + ')" title="Восстановить"><i class="fas fa-undo"></i></button>' +
'<button class="btn-danger-custom btn-sm" onclick="deleteBackupConfirm(' + b.id + ')" title="Удалить"><i class="fas fa-trash"></i></button>' +
'</td></tr>';
}).join('');
} catch(err) {
const tbody = document.getElementById('backupsTableBody');
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-danger">Ошибка загрузки</td></tr>';
}
}
function formatBackupSize(bytes) {
if (!bytes) return '—';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
}
let backupToDelete = null;
function deleteBackupConfirm(id) {
backupToDelete = id;
if (confirm('Удалить этот бекап?')) {
doDeleteBackup();
}
}
async function doDeleteBackup() {
if (!backupToDelete) return;
try {
await api('/api/admin/backups/' + backupToDelete, { method: 'DELETE' });
showToast('Бекап удалён');
loadBackupsList();
} catch(err) {
showToast('Ошибка удаления: ' + err.message, 'error');
} finally {
backupToDelete = null;
}
}
async function downloadBackup(id) {
try {
const response = await fetch('/api/admin/backups/' + id + '/download', {
headers: { 'Authorization': 'Bearer ' + token }
});
if (!response.ok) throw new Error('Download failed');
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const contentDisposition = response.headers.get('Content-Disposition');
if (contentDisposition) {
const match = contentDisposition.match(/filename="?(.+)"?/);
if (match) a.download = match[1];
}
if (!a.download) a.download = 'backup.db';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
} catch(err) {
showToast('Ошибка скачивания: ' + err.message, 'error');
}
}
let backupToRestore = null;
async function showRestoreModal(id) {
try {
const result = await api('/api/admin/backups');
const backup = result.backups.find(b => b.id === id);
if (!backup) {
showToast('Бекап не найден', 'error');
return;
}
backupToRestore = backup;
document.getElementById('restoreBackupFilename').textContent = backup.filename;
document.getElementById('restoreBackupDate').textContent = new Date(backup.created_at).toLocaleString('ru-RU');
document.getElementById('restoreBackupSize').textContent = formatBackupSize(backup.size);
document.getElementById('restoreModal').classList.add('show');
} catch(err) {
showToast('Ошибка загрузки данных бекапа', 'error');
}
}
function hideRestoreModal() {
document.getElementById('restoreModal').classList.remove('show');
backupToRestore = null;
}
async function confirmRestore() {
if (!backupToRestore) return;
const btn = document.getElementById('btnConfirmRestore');
btn.disabled = true;
btn.innerHTML = '<span class="loading-spinner"></span> Восстановление...';
try {
const result = await api('/api/admin/backups/' + backupToRestore.id + '/restore', { method: 'POST' });
hideRestoreModal();
showToast('Восстановление завершено! Перезагрузите страницу.');
setTimeout(() => {
if (confirm('Данные были восстановлены. Перезагрузить страницу?')) {
window.location.reload();
}
}, 1000);
} catch(err) {
showToast('Ошибка восстановления: ' + err.message, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-undo me-1"></i>Восстановить';
}
}
// ===== ROOMS MODULE =====
const ROOM_TYPES_LIST = ['2x-местный', '3х-местный', 'Семейный', 'Люкс'];
const FURNITURE_OPTIONS = [
{ id: 'double_bed', name: 'Двуспальная кровать' },
{ id: 'single_beds', name: 'Односпальные кровати' },
{ id: 'sofa', name: 'Диван' },
{ id: 'wardrobe', name: 'Шкаф' },
{ id: 'table', name: 'Стол' },
{ id: 'chairs', name: 'Стулья' },
{ id: 'nightstands', name: 'Прикроватные тумбочки' },
{ id: 'hanger', name: 'Вешалка' },
{ id: 'mirror', name: 'Зеркало' }
];
const AMENITY_OPTIONS = [
{ id: 'has_ac', name: 'Кондиционер', icon: 'snowflake' },
{ id: 'has_tv', name: 'Телевизор', icon: 'tv' },
{ id: 'has_fridge', name: 'Холодильник', icon: 'sink' },
{ id: 'has_wifi', name: 'Wi-Fi интернет', icon: 'wifi' },
{ id: 'has_kettle', name: 'Электрический чайник', icon: 'mug-hot' },
{ id: 'has_hairdryer', name: 'Фен', icon: 'wind' },
{ id: 'has_shower', name: 'Душ в номере', icon: 'shower' },
{ id: 'has_sea_view', name: 'Вид на море', icon: 'water' }
];
function getAmenityIcon(id) {
const opt = AMENITY_OPTIONS.find(a => a.id === id);
return opt ? opt.icon : 'check';
}
async function loadRooms() {
try {
const rooms = await api('/api/admin/rooms');
currentRooms = rooms;
renderRooms(rooms);
} catch(err) { showToast('Ошибка загрузки номеров: ' + err.message, 'error'); }
}
function renderRooms(rooms) {
const grid = document.getElementById('roomsGrid');
if (!rooms || rooms.length === 0) {
grid.innerHTML = '<div class="text-center text-muted" style="padding: 40px;">Нет номеров. Нажмите "Добавить номер" для создания.</div>';
return;
}
grid.innerHTML = rooms.map(r => {
const furniture = Array.isArray(r.furniture) ? r.furniture : [];
const amenities = Array.isArray(r.amenities) ? r.amenities : [];
const floors = Array.isArray(r.floors) ? r.floors : [];
const images = Array.isArray(r.images) ? r.images : [];
const primaryImg = images.find(i => i.is_primary) || images[0];
let imageSrc = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 250"%3E%3Crect fill="%23274151" width="400" height="250"/%3E%3Ctext fill="%2364748b" font-family="sans-serif" font-size="16" x="50%25" y="50%25" text-anchor="middle" dy=".3em"%3EБез фото%3C/text%3E%3C/svg%3E';
if (primaryImg && primaryImg.image_path) {
imageSrc = (primaryImg.image_path.startsWith('uploads') || primaryImg.image_path.startsWith('data')) ? '/' + primaryImg.image_path : primaryImg.image_path;
} else if (r.image_path) {
imageSrc = (r.image_path.startsWith('uploads') || r.image_path.startsWith('data')) ? '/' + r.image_path : r.image_path;
}
const statusClass = r.is_active ? 'badge-status-оплачена' : 'badge-status-отменена';
const statusText = r.is_active ? 'Активен' : 'Скрыт';
const extraBedsText = r.extra_beds > 0 ? `+${r.extra_beds} доп. мест (${r.extra_bed_price}₽)` : '';
const imageBadge = images.length > 1 ? `<span style="position:absolute;bottom:8px;right:8px;background:rgba(0,0,0,0.7);color:#fff;font-size:0.65rem;padding:2px 6px;border-radius:10px;">${images.length} фото</span>` : '';
return '<div class="room-admin-card">' +
'<div class="room-admin-image">' +
'<img src="' + esc(imageSrc) + '" alt="' + esc(r.name) + '" onerror="this.src=\'data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 400 250%22%3E%3Crect fill=%22%23274151%22 width=%22400%22 height=%22250%22/%3E%3Ctext fill=%22%2364748b%22 font-family=%22sans-serif%22 font-size=%2216%22 x=%2250%25%22 y=%2250%25%22 text-anchor=%22middle%22 dy=%22.3em%22%3EБез фото%3C/text%3E%3C/svg%3E\'">' +
'<div class="room-admin-type">' + esc(r.type) + '</div>' +
'<span class="badge badge-status ' + statusClass + '" style="position:absolute;top:10px;right:10px;font-size:0.65rem;">' + statusText + '</span>' +
imageBadge +
'</div>' +
'<div class="room-admin-body">' +
'<h4 class="room-admin-name">' + esc(r.name) + '</h4>' +
'<div class="room-admin-price">' + r.price_per_night + ' ₽ <span>/ ночь</span></div>' +
'<div class="room-admin-meta">' +
'<span><i class="fas fa-door-open"></i> ' + (r.area_sqm || 0) + ' м²</span>' +
'<span><i class="fas fa-users"></i> до ' + r.max_guests + ' чел.</span>' +
'<span><i class="fas fa-bed"></i> ' + r.rooms_count + ' номеров</span>' +
'</div>' +
'<div class="room-admin-floors">Этажи: ' + floors.join(', ') + '</div>' +
'<div class="room-admin-amenities">' + amenities.map(a => '<span class="room-amenity-tag"><i class="fas fa-' + getAmenityIcon(a) + '"></i></span>').join('') + '</div>' +
(extraBedsText ? '<div class="room-admin-extra">' + extraBedsText + '</div>' : '') +
'<div class="room-admin-actions">' +
'<button class="btn-primary-custom btn-sm" onclick="showRoomModal(' + r.id + ')"><i class="fas fa-edit"></i> Редактировать</button>' +
'<button class="btn-' + (r.is_active ? 'warning' : 'success') + ' btn-sm" onclick="toggleRoomActive(' + r.id + ', ' + !r.is_active + ')"><i class="fas fa-' + (r.is_active ? 'eye-slash' : 'eye') + '"></i> ' + (r.is_active ? 'Скрыть' : 'Показать') + '</button>' +
'</div>' +
'</div>' +
'</div>';
}).join('');
}
let editingRoomId = null;
let currentRooms = [];
function showRoomModal(id) {
editingRoomId = id;
const modal = document.getElementById('roomModal');
const form = document.getElementById('roomForm');
const title = document.getElementById('roomModalTitle');
form.reset();
document.getElementById('roomImagePreview').src = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 120"%3E%3Crect fill="%23274151" width="200" height="120"/%3E%3Ctext fill="%2364748b" font-family="sans-serif" font-size="12" x="50%25" y="50%25" text-anchor="middle" dy=".3em"%3EВыберите фото%3C/text%3E%3C/svg%3E';
document.getElementById('roomImagePreview').removeAttribute('data-path');
document.querySelectorAll('#roomForm input[type="checkbox"]').forEach(cb => cb.checked = false);
if (id) {
title.textContent = 'Редактировать номер';
const room = currentRooms.find(r => r.id === id);
if (room) fillRoomForm(room);
} else {
title.textContent = 'Добавить номер';
}
modal.classList.add('show');
document.body.style.overflow = 'hidden';
}
function fillRoomForm(r) {
document.getElementById('roomName').value = r.name || '';
document.getElementById('roomType').value = r.type || '2x-местный';
document.getElementById('roomDescription').value = r.description || '';
document.getElementById('roomPrice').value = r.price_per_night || 0;
document.getElementById('roomArea').value = r.area_sqm || 20;
document.getElementById('roomMaxGuests').value = r.max_guests || 2;
document.getElementById('roomCount').value = r.rooms_count || 1;
document.getElementById('roomFloors').value = (Array.isArray(r.floors) ? r.floors.join(', ') : '');
document.getElementById('roomExtraBeds').value = r.extra_beds || 0;
document.getElementById('roomExtraBedPrice').value = r.extra_bed_price || 0;
document.getElementById('roomIsActive').checked = r.is_active !== 0;
if (r.image_path) {
const src = r.image_path.startsWith('uploads') ? '/' + r.image_path : r.image_path;
document.getElementById('roomImagePreview').src = src;
document.getElementById('roomImagePreview').dataset.path = r.image_path;
}
const furniture = Array.isArray(r.furniture) ? r.furniture : [];
furniture.forEach(f => {
const cb = document.querySelector('#roomForm input[name="furniture"][value="' + f + '"]');
if (cb) cb.checked = true;
});
const amenities = Array.isArray(r.amenities) ? r.amenities : [];
amenities.forEach(a => {
const cb = document.querySelector('#roomForm input[name="amenities"][value="' + a + '"]');
if (cb) cb.checked = true;
});
}
function closeRoomModal() {
document.getElementById('roomModal').classList.remove('show');
document.body.style.overflow = '';
editingRoomId = null;
}
async function saveRoom() {
const name = document.getElementById('roomName').value.trim();
const type = document.getElementById('roomType').value;
const description = document.getElementById('roomDescription').value.trim();
const price_per_night = parseInt(document.getElementById('roomPrice').value) || 0;
const area_sqm = parseInt(document.getElementById('roomArea').value) || 20;
const max_guests = parseInt(document.getElementById('roomMaxGuests').value) || 2;
const rooms_count = parseInt(document.getElementById('roomCount').value) || 1;
const extra_beds = parseInt(document.getElementById('roomExtraBeds').value) || 0;
const extra_bed_price = parseInt(document.getElementById('roomExtraBedPrice').value) || 0;
const is_active = document.getElementById('roomIsActive').checked ? 1 : 0;
const floorsInput = document.getElementById('roomFloors').value.trim();
const floors = floorsInput ? floorsInput.split(',').map(f => parseInt(f.trim())).filter(f => !isNaN(f)) : [];
const furniture = [];
document.querySelectorAll('#roomForm input[name="furniture"]:checked').forEach(cb => furniture.push(cb.value));
const amenities = [];
document.querySelectorAll('#roomForm input[name="amenities"]:checked').forEach(cb => amenities.push(cb.value));
if (!name || !price_per_night) {
showToast('Название и цена обязательны', 'error');
return;
}
try {
const data = {
type, name, description, price_per_night, area_sqm, max_guests, rooms_count,
floors, furniture, amenities, extra_beds, extra_bed_price, is_active
};
if (editingRoomId) {
await api('/api/admin/rooms/' + editingRoomId, { method: 'PUT', body: JSON.stringify(data) });
showToast('Номер обновлён');
} else {
const result = await api('/api/admin/rooms', { method: 'POST', body: JSON.stringify(data) });
editingRoomId = result.id;
showToast('Номер создан. Загрузите фото.');
}
closeRoomModal();
loadRooms();
} catch(err) { showToast(err.message, 'error'); }
}
async function toggleRoomActive(id, isActive) {
try {
await api('/api/admin/rooms/' + id, {
method: 'PATCH',
body: JSON.stringify({ is_active: isActive ? 1 : 0 })
});
showToast(isActive ? 'Номер показан на сайте' : 'Номер скрыт с сайта');
loadRooms();
} catch(err) { showToast(err.message, 'error'); }
}
async function uploadRoomImage() {
const fileInput = document.getElementById('roomImagesInput');
const files = fileInput.files;
if (!files || files.length === 0) { showToast('Выберите файлы', 'error'); return; }
const formData = new FormData();
for (let i = 0; i < Math.min(files.length, 5); i++) {
formData.append('images', files[i]);
}
try {
const roomId = editingRoomId;
const url = roomId ? '/api/admin/rooms/' + roomId + '/images' : '/api/admin/rooms/upload';
if (!roomId) {
formData.delete('images');
formData.append('image', files[0]);
}
const res = await fetch(API + url, {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token },
body: formData
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Ошибка загрузки');
if (roomId) {
showToast('Изображения загружены');
loadRoomImages(roomId);
} else {
showToast('Фото загружено. Сохраните номер.');
if (data.path) { editingRoomId = null; roomImagesToUpload.push(data.path); }
}
} catch(err) { showToast(err.message, 'error'); }
}
let roomImagesToUpload = [];
async function loadRoomImages(roomId) {
try {
const rooms = await api('/api/admin/rooms');
const room = rooms.find(r => r.id === roomId);
if (!room || !room.images) return;
renderRoomImages(room.images);
} catch(e) {}
}
function renderRoomImages(images) {
const container = document.getElementById('roomImagesContainer');
if (!container) return;
container.innerHTML = images.map((img, idx) => {
const src = img.image_path.startsWith('data') ? '/' + img.image_path : '/' + img.image_path;
const hasId = img.id !== null && img.id !== undefined;
return `
<div style="position:relative; width:80px; height:60px; border-radius:6px; overflow:hidden; border:1px solid #e2e8f0;">
<img src="${esc(src)}" style="width:100%;height:100%;object-fit:cover;">
<button type="button" onclick="${hasId ? `deleteRoomImage(${img.id})` : `clearLegacyImage(${editingRoomId})`}" style="position:absolute;top:2px;right:2px;background:rgba(239,68,68,0.9);color:#fff;border:none;border-radius:50%;width:18px;height:18px;font-size:10px;cursor:pointer;line-height:1;">×</button>
${img.is_primary ? '<span style="position:absolute;bottom:2px;left:2px;background:rgba(37,99,235,0.9);color:#fff;font-size:8px;padding:1px 4px;border-radius:3px;">Главное</span>' : ''}
${!img.is_primary && hasId ? `<button type="button" onclick="setPrimaryImage(${img.id})" style="position:absolute;bottom:2px;right:2px;background:rgba(0,0,0,0.7);color:#fff;border:none;border-radius:3px;font-size:8px;padding:1px 3px;cursor:pointer;">★</button>` : ''}
</div>
`}).join('');
}
async function deleteRoomImage(imageId) {
if (!confirm('Удалить изображение?')) return;
try {
await api('/api/admin/rooms/images/' + imageId, { method: 'DELETE' });
showToast('Изображение удалено');
if (editingRoomId) loadRoomImages(editingRoomId);
else loadRooms();
} catch(err) { showToast(err.message, 'error'); }
}
async function clearLegacyImage(roomId) {
if (!confirm('Удалить изображение номера?')) return;
try {
await api('/api/admin/rooms/' + roomId + '/image', { method: 'DELETE' });
showToast('Изображение удалено');
loadRoomImages(roomId);
loadRooms();
} catch(err) { showToast(err.message, 'error'); }
}
async function setPrimaryImage(imageId) {
try {
await api('/api/admin/rooms/images/' + imageId + '/primary', { method: 'PUT' });
showToast('Главное изображение обновлено');
if (editingRoomId) loadRoomImages(editingRoomId);
} catch(err) { showToast(err.message, 'error'); }
}
async function uploadRoomImages() {
await uploadRoomImage();
}
// Calendar
let calendarYear = new Date().getFullYear();
let calendarMonth = new Date().getMonth() + 1;
async function loadCalendar(monthStr) {
if (monthStr) {
const [y, m] = monthStr.split('-').map(Number);
calendarYear = y; calendarMonth = m;
}
const month = `${calendarYear}-${String(calendarMonth).padStart(2, '0')}`;
updateCalendarMonthSelect();
try {
const data = await api('/api/admin/calendar?month=' + month);
renderCalendar(data);
} catch(e) { showToast(e.message, 'error'); }
}
function calendarPrevMonth() {
calendarMonth--; if (calendarMonth < 1) { calendarMonth = 12; calendarYear--; }
loadCalendar();
}
function calendarNextMonth() {
calendarMonth++; if (calendarMonth > 12) { calendarMonth = 1; calendarYear++; }
loadCalendar();
}
function updateCalendarMonthSelect() {
const sel = document.getElementById('calendarMonthSelect');
if (!sel) return;
const now = new Date();
let options = '';
for (let i = -6; i <= 12; i++) {
const d = new Date(now.getFullYear(), now.getMonth() + i, 1);
const val = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}`;
const label = d.toLocaleString('ru', { month: 'long', year: 'numeric' });
const selected = (d.getFullYear() === calendarYear && d.getMonth()+1 === calendarMonth) ? ' selected' : '';
options += `<option value="${val}"${selected}>${label}</option>`;
}
sel.innerHTML = options;
}
function renderCalendar(data) {
const grid = document.getElementById('calendarGrid');
if (!grid) return;
const rooms = data.rooms || [];
const days = data.days || {};
const month = data.month;
const [y, m] = month.split('-').map(Number);
const firstDay = new Date(y, m-1, 1);
const lastDay = new Date(y, m, 0);
const startDayOfWeek = firstDay.getDay() || 7;
let html = '<table style="width:100%;min-width:700px;border-collapse:collapse;font-size:0.8rem;">';
html += '<thead><tr><th style="padding:8px;border:1px solid #e2e8f0;background:#f8fafc;">Месяц</th>';
const dayNames = ['Пн','Вт','Ср','Чт','Пт','Сб','Вс'];
for (let d = 1; d <= lastDay.getDate(); d++) {
html += `<th style="padding:6px 8px;border:1px solid #e2e8f0;background:#f8fafc;text-align:center;font-size:0.7rem;">${d}<br><small>${dayNames[(new Date(y,m-1,d).getDay()||7)-1]}</small></th>`;
}
html += '</tr></thead><tbody>';
rooms.forEach(room => {
html += '<tr><td style="padding:6px 8px;border:1px solid #e2e8f0;font-weight:600;white-space:nowrap;background:#f8fafc;">' + esc(room.type) + '</td>';
for (let d = 1; d <= lastDay.getDate(); d++) {
const dateStr = `${month}-${String(d).padStart(2,'0')}`;
const dayData = days[dateStr] ? days[dateStr][room.type] : null;
const booked = dayData ? dayData.booked : 0;
const total = dayData ? dayData.total : room.rooms_count;
const ratio = total > 0 ? booked / total : 0;
let bg = '#dcfce7';
if (ratio >= 1) bg = '#fee2e2';
else if (ratio >= 0.7) bg = '#fef3c7';
const title = room.type + ': ' + booked + '/' + total;
html += `<td style="padding:4px;border:1px solid #e2e8f0;background:${bg};text-align:center;cursor:pointer;" title="${title}" onclick="showCalendarDay('${dateStr}','${esc(room.type)}')">${booked > 0 ? '<span style="font-weight:600;font-size:0.75rem;">'+booked+'</span>' : ''}</td>`;
}
html += '</tr>';
});
html += '</tbody></table>';
grid.innerHTML = html;
}
function showCalendarDay(dateStr, roomType) {
showToast(dateStr + ' — ' + roomType + ': загрузка данных...', 'info');
}
// Seasonal Prices
async function loadSeasonalPrices() {
try {
const data = await api('/api/admin/seasonal-prices');
document.getElementById('seasonalTable').innerHTML = data.map(s => '<tr>' +
'<td>' + esc(s.room_type) + '</td>' +
'<td>' + esc(s.date_from) + '</td>' +
'<td>' + esc(s.date_to) + '</td>' +
'<td><strong>' + s.price_per_night + ' ₽</strong></td>' +
'<td>' + esc(s.label || '—') + '</td>' +
'<td>' + (s.is_active ? '<span class="badge badge-status badge-status-оплачена">Да</span>' : '<span class="badge badge-status badge-status-отменена">Нет</span>') + '</td>' +
'<td class="admin-only">' +
'<button class="btn-primary-custom btn-sm me-1" onclick="editSeasonal(' + s.id + ')"><i class="fas fa-edit"></i></button>' +
'<button class="btn-danger-custom btn-sm" onclick="deleteSeasonal(' + s.id + ')"><i class="fas fa-trash"></i></button>' +
'</td></tr>').join('');
if (data.length === 0) document.getElementById('seasonalTable').innerHTML = '<tr><td colspan="7" class="text-center text-muted">Нет данных</td></tr>';
} catch(e) { showToast(e.message, 'error'); }
}
function showSeasonalModal(id) {
document.getElementById('editSeasonalId').value = '';
document.getElementById('seasonalModalTitle').textContent = 'Добавить сезонную цену';
document.getElementById('seasonalForm').reset();
if (id) {
document.getElementById('editSeasonalId').value = id;
document.getElementById('seasonalModalTitle').textContent = 'Редактировать сезонную цену';
api('/api/admin/seasonal-prices').then(data => {
const s = data.find(x => x.id === id);
if (s) {
document.getElementById('seasonalRoomType').value = s.room_type;
document.getElementById('seasonalDateFrom').value = s.date_from;
document.getElementById('seasonalDateTo').value = s.date_to;
document.getElementById('seasonalPrice').value = s.price_per_night;
document.getElementById('seasonalLabel').value = s.label || '';
document.getElementById('seasonalIsActive').checked = s.is_active === 1;
}
});
}
document.getElementById('seasonalModal').classList.add('show');
}
function hideSeasonalModal() {
document.getElementById('seasonalModal').classList.remove('show');
}
document.addEventListener('DOMContentLoaded', function() {
const seasonalForm = document.getElementById('seasonalForm');
if (!seasonalForm) return;
seasonalForm.addEventListener('submit', async function(e) {
e.preventDefault();
const id = document.getElementById('editSeasonalId').value;
const data = {
room_type: document.getElementById('seasonalRoomType').value,
date_from: document.getElementById('seasonalDateFrom').value,
date_to: document.getElementById('seasonalDateTo').value,
price_per_night: parseInt(document.getElementById('seasonalPrice').value),
label: document.getElementById('seasonalLabel').value,
is_active: document.getElementById('seasonalIsActive').checked
};
try {
if (id) {
await api('/api/admin/seasonal-prices/' + id, { method: 'PUT', body: JSON.stringify(data) });
} else {
await api('/api/admin/seasonal-prices', { method: 'POST', body: JSON.stringify(data) });
}
hideSeasonalModal();
loadSeasonalPrices();
} catch(err) { showToast(err.message, 'error'); }
});
});
function editSeasonal(id) { showSeasonalModal(id); }
async function deleteSeasonal(id) {
if (!confirm('Удалить сезонную цену?')) return;
try {
await api('/api/admin/seasonal-prices/' + id, { method: 'DELETE' });
loadSeasonalPrices();
} catch(err) { showToast(err.message, 'error'); }
}
async function loadActivitiesAdmin() {
try {
const activities = await api('/api/admin/activities');
renderActivitiesCards(activities);
} catch(err) { showToast(err.message, 'error'); }
}
function renderActivitiesCards(activities) {
const grid = document.getElementById('activitiesGrid');
if (!activities || activities.length === 0) {
grid.innerHTML = '<div class="text-center text-muted" style="padding: 40px; grid-column: 1 / -1;">Нет развлечений. Нажмите "Добавить" для создания.</div>';
return;
}
grid.innerHTML = activities.map(a => {
const imageSrc = a.image_path || 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 250"%3E%3Crect fill="%23274151" width="400" height="250"/%3E%3Ctext fill="%2364748b" font-family="sans-serif" font-size="14" x="50%25" y="50%25" text-anchor="middle" dy=".3em"%3EБез фото%3C/text%3E%3C/svg%3E';
let langs = [];
if (a.title_ru || a.desc_ru) langs.push('RU');
if (a.title_en || a.desc_en) langs.push('EN');
if (a.title_ab || a.desc_ab) langs.push('AB');
const langsBadge = langs.length === 3
? '<span class="badge badge-admin" style="margin-left:6px;">RU+EN+AB</span>'
: `<span class="badge badge-user" style="margin-left:6px;">${langs.join('+')}</span>`;
const statusClass = a.is_active ? 'badge-status-оплачена' : 'badge-status-отменена';
const statusText = a.is_active ? 'Активен' : 'Скрыт';
const iconHtml = a.icon ? `<span style="color:#c9a84c;margin-right:6px;"><i class="${a.icon}"></i></span>` : '';
const title = a.title_ru || a.title_en || a.title_ab || 'Без названия';
return '<div class="room-admin-card">' +
'<div class="room-admin-image">' +
'<img src="' + esc(imageSrc) + '" alt="' + esc(title) + '" onerror="this.src=\'data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 400 250%22%3E%3Crect fill=%22%23274151%22 width=%22400%22 height=%22250%22/%3E%3Ctext fill=%22%2364748b%22 font-family=%22sans-serif%22 font-size=%2214%22 x=%2250%25%22 y=%2250%25%22 text-anchor=%22middle%22 dy=%22.3em%22%3EБез фото%3C/text%3E%3C/svg%3E\'">' +
'<span class="badge ' + statusClass + '" style="position:absolute;top:10px;left:10px;font-size:0.65rem;">' + statusText + '</span>' +
'<span style="position:absolute;top:10px;right:10px;font-size:0.65rem;">#' + a.sort_order + '</span>' +
'</div>' +
'<div class="room-admin-body">' +
'<h4 class="room-admin-name">' + iconHtml + esc(title) + langsBadge + '</h4>' +
'<div class="room-admin-meta">' +
'<span><i class="fas fa-globe"></i> ' + langs.join(' + ') + '</span>' +
'<span><i class="fas fa-sort-numeric-down"></i> Порядок: ' + a.sort_order + '</span>' +
'</div>' +
'<div class="room-admin-actions">' +
'<button class="btn-primary-custom btn-sm" onclick="showActivityModal(' + a.id + ')" style="flex:1;"><i class="fas fa-edit"></i> Редактировать</button>' +
'<button class="btn-' + (a.is_active ? 'warning' : 'success') + ' btn-sm" onclick="toggleActivityActive(' + a.id + ', ' + !a.is_active + ')" style="padding:8px 12px;"><i class="fas fa-' + (a.is_active ? 'eye-slash' : 'eye') + '"></i></button>' +
'<button class="btn-danger-custom btn-sm" onclick="deleteActivity(' + a.id + ')" style="padding:8px 12px;"><i class="fas fa-trash"></i></button>' +
'</div>' +
'</div>' +
'</div>';
}).join('');
}
async function toggleActivityActive(id, isActive) {
try {
await api('/api/admin/activities/' + id, { method: 'PUT', body: JSON.stringify({ is_active: isActive ? 1 : 0 }) });
loadActivitiesAdmin();
} catch(err) { showToast(err.message, 'error'); }
}
async function showActivityModal(id) {
document.getElementById('activityModalTitle').textContent = id ? 'Редактировать развлечение' : 'Добавить развлечение';
document.getElementById('editActivityId').value = id || '';
document.getElementById('actTitleRu').value = '';
document.getElementById('actTitleEn').value = '';
document.getElementById('actTitleAb').value = '';
document.getElementById('actDescRu').value = '';
document.getElementById('actDescEn').value = '';
document.getElementById('actDescAb').value = '';
document.getElementById('actIcon').value = '';
document.getElementById('actSortOrder').value = '0';
document.getElementById('actIsActive').checked = true;
document.getElementById('activityImageInput').value = '';
document.getElementById('activityImagePreview').innerHTML = '';
tempActivityImagePath = null;
editingActivityId = id || null;
if (id) {
try {
const activities = await api('/api/admin/activities');
const a = activities.find(x => x.id === id);
if (a) {
document.getElementById('actTitleRu').value = a.title_ru || '';
document.getElementById('actTitleEn').value = a.title_en || '';
document.getElementById('actTitleAb').value = a.title_ab || '';
document.getElementById('actDescRu').value = a.desc_ru || '';
document.getElementById('actDescEn').value = a.desc_en || '';
document.getElementById('actDescAb').value = a.desc_ab || '';
document.getElementById('actIcon').value = a.icon || '';
document.getElementById('actSortOrder').value = a.sort_order || 0;
document.getElementById('actIsActive').checked = a.is_active === 1;
tempActivityImagePath = a.image_path || null;
if (a.image_path) {
document.getElementById('activityImagePreview').innerHTML = `<img src="${a.image_path}" style="max-width:200px;max-height:120px;object-fit:cover;border-radius:8px;">`;
}
}
} catch(e) { showToast(e.message, 'error'); }
}
document.getElementById('activityModal').classList.add('show');
}
function closeActivityModal() {
document.getElementById('activityModal').classList.remove('show');
editingActivityId = null;
tempActivityImagePath = null;
}
async function saveActivity() {
const data = {
title_ru: document.getElementById('actTitleRu').value.trim() || null,
title_en: document.getElementById('actTitleEn').value.trim() || null,
title_ab: document.getElementById('actTitleAb').value.trim() || null,
desc_ru: document.getElementById('actDescRu').value.trim() || null,
desc_en: document.getElementById('actDescEn').value.trim() || null,
desc_ab: document.getElementById('actDescAb').value.trim() || null,
icon: document.getElementById('actIcon').value.trim() || null,
sort_order: parseInt(document.getElementById('actSortOrder').value) || 0,
is_active: document.getElementById('actIsActive').checked ? 1 : 0
};
const filled = [!!data.title_ru || !!data.desc_ru, !!data.title_en || !!data.desc_en, !!data.title_ab || !!data.desc_ab].filter(Boolean).length;
if (filled !== 0 && filled !== 1 && filled !== 3) {
showToast('Должен быть заполнен 1 язык или все 3', 'error');
return;
}
if (tempActivityImagePath) {
data.image_path = tempActivityImagePath;
}
try {
if (editingActivityId) {
await api('/api/admin/activities/' + editingActivityId, { method: 'PUT', body: JSON.stringify(data) });
showToast('Развлечение обновлено');
} else {
await api('/api/admin/activities', { method: 'POST', body: JSON.stringify(data) });
showToast('Развлечение добавлено');
}
closeActivityModal();
loadActivitiesAdmin();
} catch(err) { showToast(err.message, 'error'); }
}
async function deleteActivity(id) {
if (!confirm('Удалить это развлечение?')) return;
try {
await api('/api/admin/activities/' + id, { method: 'DELETE' });
showToast('Развлечение удалено');
loadActivitiesAdmin();
} catch(err) { showToast(err.message, 'error'); }
}
async function uploadActivityImage() {
const input = document.getElementById('activityImageInput');
const file = input.files[0];
if (!file) { showToast('Выберите файл', 'error'); return; }
const fd = new FormData();
fd.append('image', file);
try {
const res = await fetch(API + '/api/admin/activities/upload', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token },
body: fd
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Ошибка загрузки');
tempActivityImagePath = data.path;
document.getElementById('activityImagePreview').innerHTML = `<img src="${data.path}" style="max-width:200px;max-height:120px;object-fit:cover;border-radius:8px;">`;
showToast('Изображение загружено');
} catch(err) { showToast(err.message, 'error'); }
}
// Security keys
async function loadSecurityKeys() {
try {
const data = await api('/api/admin/security');
document.getElementById('securityKeys').innerHTML = `
<div style="margin-bottom:16px;padding:12px;background:#f8fafc;border-radius:8px;border:1px solid #e2e8f0;">
<strong>JWT_SECRET:</strong> <code>${esc(data.jwt_secret_masked)}</code>
<button class="btn btn-sm" style="margin-left:8px;background:#f59e0b;color:#fff;border:none;border-radius:6px;padding:2px 8px;" onclick="regenerateKey('jwt')">Перегенерировать</button>
</div>
<div style="padding:12px;background:#f8fafc;border-radius:8px;border:1px solid #e2e8f0;">
<strong>HOTEL777KEY:</strong> <code>${esc(data.hotel777key_masked)}</code>
<button class="btn btn-sm" style="margin-left:8px;background:#f59e0b;color:#fff;border:none;border-radius:6px;padding:2px 8px;" onclick="regenerateKey('hotel777key')">Перегенерировать</button>
</div>
`;
} catch(e) {}
}
async function regenerateKey(type) {
const label = type === 'jwt' ? 'JWT_SECRET' : 'HOTEL777KEY';
if (!confirm(`Перегенерировать ${label}? Это потребует перезапуска сервера.${type === 'jwt' ? ' Все пользователи будут вынуждены войти заново.' : ''}`)) return;
try {
const data = await api('/api/admin/security/regenerate', { method: 'POST', body: JSON.stringify({ type }) });
showToast(data.message, 'success');
if (data.key) {
const div = document.createElement('div');
div.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:#fff;padding:24px;border-radius:12px;box-shadow:0 20px 60px rgba(0,0,0,0.3);z-index:9999;max-width:500px;';
div.innerHTML = `<h3 style="color:#f59e0b;margin-bottom:12px;">Новый ключ (показан только раз!)</h3><pre style="background:#1e293b;color:#4ade80;padding:12px;border-radius:8px;overflow-x:auto;">${esc(data.key)}</pre><p style="color:#ef4444;font-size:0.85rem;">${esc(data.warning)}</p><button class="btn-gold btn-sm" onclick="this.parentElement.remove()">Я сохранил ключ</button>`;
document.body.appendChild(div);
}
} catch(err) { showToast(err.message, 'error'); }
}
// Email settings
async function loadEmailSettings() {
try {
const data = await api('/api/admin/settings/email');
document.getElementById('smtpHost').value = data.smtp_host || '';
document.getElementById('smtpPort').value = data.smtp_port || '587';
document.getElementById('smtpSecure').value = data.smtp_secure || 'false';
document.getElementById('smtpUser').value = data.smtp_user || '';
document.getElementById('smtpFrom').value = data.smtp_from || '';
document.getElementById('adminEmail').value = data.admin_email || '';
document.getElementById('emailEnabled').checked = data.email_notifications_enabled === 'true';
} catch(e) {}
}
async function saveEmailSettings() {
const data = {
smtp_host: document.getElementById('smtpHost').value,
smtp_port: document.getElementById('smtpPort').value,
smtp_secure: document.getElementById('smtpSecure').value,
smtp_user: document.getElementById('smtpUser').value,
smtp_pass: document.getElementById('smtpPass').value,
smtp_from: document.getElementById('smtpFrom').value,
admin_email: document.getElementById('adminEmail').value,
email_notifications_enabled: document.getElementById('emailEnabled').checked ? 'true' : 'false'
};
try {
await api('/api/admin/settings/email', { method: 'PUT', body: JSON.stringify(data) });
showToast('Настройки email сохранены');
} catch(err) { showToast(err.message, 'error'); }
}
async function sendTestEmail() {
try {
const result = await api('/api/admin/settings/email/test', { method: 'POST' });
showToast(result.message);
} catch(err) { showToast(err.message, 'error'); }
}
// Phone settings
async function loadPhoneSettings() {
try {
const data = await api('/api/admin/settings/phones');
document.getElementById('phonePrimary').value = data.primary || '';
document.getElementById('phoneAdditional').value = data.additional || '';
document.getElementById('phoneWhatsappNum').value = data.whatsappNum || '';
document.getElementById('phoneWhatsappActive').checked = data.whatsappActive === 'true';
document.getElementById('phoneTelegramId').value = data.telegramId || '';
document.getElementById('phoneTelegramActive').checked = data.telegramActive === 'true';
document.getElementById('phoneMaxLink').value = data.maxLink || '';
document.getElementById('phoneMaxActive').checked = data.maxActive === 'true';
document.getElementById('phoneInstagramId').value = data.instagramId || '';
document.getElementById('phoneInstagramActive').checked = data.instagramActive === 'true';
} catch(e) {}
}
async function savePhoneSettings() {
const data = {
primary: document.getElementById('phonePrimary').value.trim(),
additional: document.getElementById('phoneAdditional').value.trim(),
whatsappNum: document.getElementById('phoneWhatsappNum').value.trim(),
whatsappActive: document.getElementById('phoneWhatsappActive').checked ? 'true' : 'false',
telegramId: document.getElementById('phoneTelegramId').value.trim(),
telegramActive: document.getElementById('phoneTelegramActive').checked ? 'true' : 'false',
maxLink: document.getElementById('phoneMaxLink').value.trim(),
maxActive: document.getElementById('phoneMaxActive').checked ? 'true' : 'false',
instagramId: document.getElementById('phoneInstagramId').value.trim(),
instagramActive: document.getElementById('phoneInstagramActive').checked ? 'true' : 'false'
};
if (!data.primary) {
showToast('Основной номер обязателен', 'error');
return;
}
try {
await api('/api/admin/settings/phones', { method: 'PUT', body: JSON.stringify(data) });
showToast('Настройки телефонов сохранены');
} catch(err) { showToast(err.message, 'error'); }
}
// Telegram notification settings
async function loadTelegramSettings() {
try {
const data = await api('/api/admin/settings/telegram');
document.getElementById('telegramBotToken').value = data.telegram_bot_token_masked || '';
document.getElementById('telegramNotifyUsers').value = data.telegram_notify_users || '';
document.getElementById('telegramEnabled').checked = data.telegram_notify_enabled === 'true';
} catch(e) {}
}
async function saveTelegramSettings() {
const tokenVal = document.getElementById('telegramBotToken').value;
const token = tokenVal.startsWith('****') ? '' : tokenVal.trim();
const enabled = document.getElementById('telegramEnabled').checked;
const usersStr = document.getElementById('telegramNotifyUsers').value.trim();
const usersList = usersStr ? usersStr.split(',').map(s => s.trim()).filter(s => s) : [];
const data = {
telegram_bot_token: token,
telegram_notify_users: usersStr,
telegram_notify_enabled: enabled ? 'true' : 'false'
};
try {
await api('/api/admin/settings/telegram', { method: 'PUT', body: JSON.stringify(data) });
let msg = 'Настройки Telegram сохранены';
if (enabled && usersList.length > 0) {
msg += `. Уведомления включены, получателей: ${usersList.length}`;
} else if (!enabled) {
msg += '. Уведомления выключены';
} else {
msg += '. Не указаны получатели — уведомления не будут отправляться';
}
showToast(msg, 'success');
loadTelegramSettings();
} catch(err) { showToast('Ошибка при сохранении: ' + err.message, 'error'); }
}
async function sendTestTelegram() {
const btn = document.getElementById('btnTelegramTest');
const originalText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Отправка...';
try {
const result = await api('/api/admin/settings/telegram/test', { method: 'POST' });
if (result.errors && result.errors.length > 0) {
showToast(result.message, 'error');
} else {
showToast(result.message || 'Тест отправлен', 'success');
}
} catch(err) {
let errMsg = err.message;
if (errMsg.includes('chat not found') || errMsg.includes('chat_id')) {
errMsg = 'Ошибка: указанный chat_id не найден. Пользователь должен сначала написать боту любое сообщение.';
} else if (errMsg.includes('token') || errMsg.includes('unauthorized')) {
errMsg = 'Ошибка: неверный токен бота. Проверьте токен, полученный от @BotFather.';
}
showToast('Ошибка отправки: ' + errMsg, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = originalText;
}
}
// Export
async function exportCSV() {
const status = document.getElementById('filterStatus').value;
const params = new URLSearchParams();
if (status && status !== 'all') params.set('status', status);
window.location.href = API + '/api/admin/export/bookings?' + params.toString();
}
// Update loadSettings
async function loadSettings() {
try {
const data = await api('/api/admin/settings');
document.getElementById('currentCodeDisplay').value = data.review_code || '';
} catch(e) {}
try {
const backupSettings = await api('/api/admin/backup/settings');
if (backupSettings.backup_auto_enabled !== undefined) {
document.getElementById('backupAutoEnabled').checked = backupSettings.backup_auto_enabled === 'true';
}
if (backupSettings.backup_auto_time) {
document.getElementById('backupAutoTime').value = backupSettings.backup_auto_time;
}
if (backupSettings.backup_retention_days) {
document.getElementById('backupRetentionDays').value = backupSettings.backup_retention_days;
}
loadBackups();
} catch(e) {}
loadSecurityKeys();
loadEmailSettings();
loadPhoneSettings();
loadTelegramSettings();
}
// Show room modal with images
async function showRoomModal(id) {
editingRoomId = id || null;
roomImagesToUpload = [];
document.getElementById('roomModalTitle').textContent = id ? 'Редактировать номер' : 'Добавить номер';
document.getElementById('roomForm').reset();
document.getElementById('roomImagesContainer').innerHTML = '';
document.getElementById('roomIsActive').checked = true;
if (id) {
try {
const rooms = await api('/api/admin/rooms');
const room = rooms.find(r => r.id === id);
if (room) {
document.getElementById('roomName').value = room.name;
document.getElementById('roomType').value = room.type;
document.getElementById('roomDescription').value = room.description || '';
document.getElementById('roomPrice').value = room.price_per_night;
document.getElementById('roomArea').value = room.area_sqm || 20;
document.getElementById('roomMaxGuests').value = room.max_guests || 2;
document.getElementById('roomCount').value = room.rooms_count || 1;
document.getElementById('roomFloors').value = (room.floors || []).join(', ');
document.getElementById('roomExtraBeds').value = room.extra_beds || 0;
document.getElementById('roomExtraBedPrice').value = room.extra_bed_price || 0;
document.getElementById('roomIsActive').checked = room.is_active === 1;
document.querySelectorAll('[name="furniture"]').forEach(cb => { cb.checked = (room.furniture || []).includes(cb.value); });
document.querySelectorAll('[name="amenities"]').forEach(cb => { cb.checked = (room.amenities || []).includes(cb.value); });
if (room.images && room.images.length > 0) {
renderRoomImages(room.images);
}
}
} catch(e) { showToast(e.message, 'error'); }
}
document.getElementById('roomModal').classList.add('show');
}
// Hero Media Admin
function loadHeroMediaAdmin() {
api('/api/admin/hero').then(data => {
renderHeroMediaCards(data);
}).catch(err => showToast(err.message, 'error'));
}
function renderHeroMediaCards(items) {
const grid = document.getElementById('heroMediaGrid');
if (!items || items.length === 0) {
grid.innerHTML = '<div style="grid-column: 1/-1; text-align: center; padding: 40px; color: #94a3b8;"><i class="fas fa-image" style="font-size: 2rem; display: block; margin-bottom: 12px;"></i>Нет медиа. Добавьте картинку или видео для главного экрана.</div>';
return;
}
grid.innerHTML = items.map(item => {
const isVideo = item.type === 'video';
const isYouTube = isVideo && item.path && (item.path.includes('youtube') || item.path.includes('youtu.be'));
const thumbStyle = isVideo
? (isYouTube ? `background: #ff0000;` : `background: #1e293b;`)
: `background-image: url('${item.path}'); background-size: cover; background-position: center;`;
const icon = isVideo ? '<i class="fas fa-play" style="color: #fff; font-size: 1.5rem;"></i>' : '';
const typeLabel = isVideo ? (isYouTube ? 'YouTube' : 'Видео') : 'Картинка';
const activeClass = item.is_active ? 'hero-active-on' : 'hero-active-off';
const activeText = item.is_active ? 'Активно' : 'Скрыто';
return `
<div class="hero-media-card" style="background: #fff; border-radius: 12px; border: 1px solid #e2e8f0; overflow: hidden;">
<div style="height: 180px; ${thumbStyle} display: flex; align-items: center; justify-content: center; background-color: #f1f5f9;">
${icon}
</div>
<div style="padding: 12px 16px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<span class="badge" style="background: ${isVideo ? '#ef4444' : '#3b82f6'}; color: #fff; font-size: 0.7rem;">${typeLabel}</span>
<span style="font-size: 0.75rem; color: #94a3b8;">Порядок: ${item.sort_order}</span>
</div>
<div style="font-size: 0.75rem; color: #64748b; margin-bottom: 8px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${item.path}</div>
<div style="display: flex; gap: 4px; flex-wrap: wrap;">
<button class="btn-primary-custom btn-sm" style="padding: 4px 10px; font-size: 0.75rem;" onclick="editHeroMedia(${item.id})"><i class="fas fa-edit"></i></button>
<button class="btn-gold btn-sm" style="padding: 4px 10px; font-size: 0.75rem;" onclick="toggleHeroActive(${item.id}, ${item.is_active ? 0 : 1})">
<span class="${activeClass}" style="font-size: 0.7rem;">${activeText}</span>
</button>
<button class="btn btn-danger btn-sm" style="padding: 4px 10px; font-size: 0.75rem;" onclick="deleteHeroMedia(${item.id})"><i class="fas fa-trash"></i></button>
</div>
</div>
</div>`;
}).join('');
}
function showHeroMediaModal(id) {
editingHeroMediaId = id || null;
tempHeroImagePath = null;
tempHeroVideoPath = null;
document.getElementById('heroMediaModalTitle').textContent = id ? 'Редактировать медиа' : 'Добавить медиа';
document.getElementById('heroMediaType').value = 'image';
document.getElementById('heroMediaPath').value = '';
document.getElementById('heroMediaSortOrder').value = '';
document.getElementById('heroMediaImageInput').value = '';
document.getElementById('heroMediaVideoInput').value = '';
document.getElementById('heroMediaImagePreview').innerHTML = '';
document.getElementById('heroMediaUrlGroup').style.display = 'none';
document.getElementById('heroMediaImageGroup').style.display = 'block';
document.getElementById('heroMediaVideoGroup').style.display = 'none';
document.getElementById('heroMediaDeleteBtn').style.display = id ? 'inline-block' : 'none';
if (id) {
api('/api/admin/hero').then(data => {
const item = data.find(m => m.id === id);
if (!item) return;
document.getElementById('heroMediaType').value = item.type;
onHeroTypeChange();
if (item.type === 'video') {
if (item.path.startsWith('http')) {
document.getElementById('heroMediaPath').value = item.path;
document.getElementById('heroMediaUrlGroup').style.display = 'block';
} else {
tempHeroVideoPath = item.path;
document.getElementById('heroMediaVideoGroup').style.display = 'block';
}
} else {
tempHeroImagePath = item.path;
const img = document.createElement('img');
img.src = item.path;
img.style.maxWidth = '200px'; img.style.borderRadius = '8px';
document.getElementById('heroMediaImagePreview').appendChild(img);
}
document.getElementById('heroMediaSortOrder').value = item.sort_order;
}).catch(err => showToast(err.message, 'error'));
}
document.getElementById('heroMediaModal').classList.add('show');
}
function closeHeroMediaModal() {
document.getElementById('heroMediaModal').classList.remove('show');
editingHeroMediaId = null;
tempHeroImagePath = null;
tempHeroVideoPath = null;
}
function onHeroTypeChange() {
const type = document.getElementById('heroMediaType').value;
document.getElementById('heroMediaImageGroup').style.display = type === 'image' ? 'block' : 'none';
document.getElementById('heroMediaVideoGroup').style.display = type === 'video' ? 'block' : 'none';
document.getElementById('heroMediaUrlGroup').style.display = 'none';
}
function showHeroUrlField() {
document.getElementById('heroMediaUrlGroup').style.display = 'block';
}
function uploadHeroImage() {
const input = document.getElementById('heroMediaImageInput');
const file = input.files[0];
if (!file) return showToast('Выберите файл', 'error');
const formData = new FormData();
formData.append('image', file);
fetch(API + '/api/admin/hero/upload-image', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token },
body: formData
})
.then(r => r.json().then(d => ({ status: r.status, body: d })))
.then(({ status, body }) => {
if (status !== 200) throw new Error(body.error || 'Upload failed');
tempHeroImagePath = body.path;
const preview = document.getElementById('heroMediaImagePreview');
preview.innerHTML = '';
const img = document.createElement('img');
img.src = body.path;
img.style.maxWidth = '200px'; img.style.borderRadius = '8px';
preview.appendChild(img);
showToast('Картинка загружена');
})
.catch(err => showToast(err.message, 'error'));
}
function uploadHeroVideo() {
const input = document.getElementById('heroMediaVideoInput');
const file = input.files[0];
if (!file) return showToast('Выберите файл', 'error');
const formData = new FormData();
formData.append('video', file);
fetch(API + '/api/admin/hero/upload-video', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token },
body: formData
})
.then(r => r.json().then(d => ({ status: r.status, body: d })))
.then(({ status, body }) => {
if (status !== 200) throw new Error(body.error || 'Upload failed');
tempHeroVideoPath = body.path;
showToast('Видео загружено');
})
.catch(err => showToast(err.message, 'error'));
}
function saveHeroMedia() {
const type = document.getElementById('heroMediaType').value;
const urlPath = document.getElementById('heroMediaPath').value.trim();
const sortOrder = parseInt(document.getElementById('heroMediaSortOrder').value) || 0;
let mediaPath;
if (type === 'image') {
mediaPath = tempHeroImagePath || (editingHeroMediaId ? undefined : null);
} else {
mediaPath = urlPath || tempHeroVideoPath || (editingHeroMediaId ? undefined : null);
}
if (!editingHeroMediaId && !mediaPath) {
return showToast('Загрузите файл или укажите URL', 'error');
}
const body = { type, sort_order: sortOrder };
if (mediaPath) body.path = mediaPath;
const method = editingHeroMediaId ? 'PUT' : 'POST';
const url = editingHeroMediaId ? `/api/admin/hero/${editingHeroMediaId}` : '/api/admin/hero';
api(url, { method, body: JSON.stringify(body) })
.then(() => {
closeHeroMediaModal();
loadHeroMediaAdmin();
showToast(editingHeroMediaId ? 'Медиа обновлено' : 'Медиа добавлено');
})
.catch(err => showToast(err.message, 'error'));
}
function editHeroMedia(id) {
showHeroMediaModal(id);
}
function toggleHeroActive(id, isActive) {
api(`/api/admin/hero/${id}`, {
method: 'PUT',
body: JSON.stringify({ is_active: isActive })
})
.then(() => { loadHeroMediaAdmin(); showToast(isActive ? 'Медиа активировано' : 'Медиа скрыто'); })
.catch(err => showToast(err.message, 'error'));
}
function deleteHeroMedia(id) {
if (!confirm('Удалить это медиа?')) return;
api(`/api/admin/hero/${id}`, { method: 'DELETE' })
.then(() => { loadHeroMediaAdmin(); showToast('Медиа удалено'); })
.catch(err => showToast(err.message, 'error'));
}
</script>
<!-- Room Edit Modal -->
<div class="modal-backdrop-custom" id="roomModal">
<div class="modal-custom" style="max-width: min(95vw, 700px);">
<div class="modal-header-custom">
<h3 id="roomModalTitle">Добавить номер</h3>
<button class="modal-close" onclick="closeRoomModal()">&times;</button>
</div>
<form id="roomForm">
<div class="modal-body-custom" style="max-height: 70vh; overflow-y: auto;">
<div class="room-modal-grid-2col" style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px;">
<div class="mb-3">
<label class="form-label">Название *</label>
<input type="text" class="form-control" id="roomName" required placeholder="2x-местный 1">
</div>
<div class="mb-3">
<label class="form-label">Тип номера *</label>
<select class="form-control" id="roomType">
<option value="2x-местный">2x-местный</option>
<option value="3х-местный">3х-местный</option>
<option value="Семейный">Семейный</option>
<option value="Люкс">Люкс</option>
</select>
</div>
</div>
<div class="mb-3">
<label class="form-label">Описание</label>
<textarea class="form-control" id="roomDescription" rows="3" placeholder="Описание номера..."></textarea>
</div>
<div class="room-modal-grid-4col" style="display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 12px;">
<div class="mb-3">
<label class="form-label">Цена/ночь (₽) *</label>
<input type="number" class="form-control" id="roomPrice" required min="0" placeholder="1500">
</div>
<div class="mb-3">
<label class="form-label">Площадь (м²)</label>
<input type="number" class="form-control" id="roomArea" min="1" placeholder="20">
</div>
<div class="mb-3">
<label class="form-label">Макс. гостей</label>
<input type="number" class="form-control" id="roomMaxGuests" min="1" max="10" placeholder="2">
</div>
<div class="mb-3">
<label class="form-label">Кол-во номеров</label>
<input type="number" class="form-control" id="roomCount" min="1" max="50" placeholder="1">
</div>
</div>
<div class="room-modal-grid-3col" style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px;">
<div class="mb-3">
<label class="form-label">Этажи</label>
<input type="text" class="form-control" id="roomFloors" placeholder="1, 2, 3">
</div>
<div class="mb-3">
<label class="form-label">Доп. места (макс)</label>
<input type="number" class="form-control" id="roomExtraBeds" min="0" max="50" placeholder="0">
</div>
<div class="mb-3">
<label class="form-label">Цена доп. места (₽)</label>
<input type="number" class="form-control" id="roomExtraBedPrice" min="0" placeholder="1000">
</div>
</div>
<div class="mb-3">
<label class="form-label">Изображения</label>
<div id="roomImagesContainer" style="display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 8px;"></div>
<div style="display: flex; gap: 8px; align-items: center;">
<input type="file" class="form-control" id="roomImagesInput" accept="image/*" multiple style="font-size: 0.85rem; max-width: 300px;">
<button type="button" class="btn btn-primary btn-sm" onclick="uploadRoomImages()"><i class="fas fa-upload"></i> Загрузить</button>
</div>
<small class="text-muted" style="font-size: 0.75rem; display: block; margin-top: 4px;">До 5 файлов, JPG/PNG/WebP. Автоконвертация в WebP.</small>
</div>
<div class="mb-3">
<label class="form-label">Мебель</label>
<div class="room-modal-grid-3col" style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px;">
<div class="form-check"><input type="checkbox" class="form-check-input" id="furn_double_bed" name="furniture" value="double_bed"><label class="form-check-label" for="furn_double_bed">Двуспальная кровать</label></div>
<div class="form-check"><input type="checkbox" class="form-check-input" id="furn_single_beds" name="furniture" value="single_beds"><label class="form-check-label" for="furn_single_beds">Односпальные кровати</label></div>
<div class="form-check"><input type="checkbox" class="form-check-input" id="furn_sofa" name="furniture" value="sofa"><label class="form-check-label" for="furn_sofa">Диван</label></div>
<div class="form-check"><input type="checkbox" class="form-check-input" id="furn_wardrobe" name="furniture" value="wardrobe"><label class="form-check-label" for="furn_wardrobe">Шкаф</label></div>
<div class="form-check"><input type="checkbox" class="form-check-input" id="furn_table" name="furniture" value="table"><label class="form-check-label" for="furn_table">Стол</label></div>
<div class="form-check"><input type="checkbox" class="form-check-input" id="furn_chairs" name="furniture" value="chairs"><label class="form-check-label" for="furn_chairs">Стулья</label></div>
<div class="form-check"><input type="checkbox" class="form-check-input" id="furn_nightstands" name="furniture" value="nightstands"><label class="form-check-label" for="furn_nightstands">Прикроватные тумбочки</label></div>
<div class="form-check"><input type="checkbox" class="form-check-input" id="furn_hanger" name="furniture" value="hanger"><label class="form-check-label" for="furn_hanger">Вешалка</label></div>
<div class="form-check"><input type="checkbox" class="form-check-input" id="furn_mirror" name="furniture" value="mirror"><label class="form-check-label" for="furn_mirror">Зеркало</label></div>
</div>
</div>
<div class="mb-3">
<label class="form-label">Удобства</label>
<div class="room-modal-grid-4col" style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;">
<div class="form-check"><input type="checkbox" class="form-check-input" id="am_has_ac" name="amenities" value="has_ac"><label class="form-check-label" for="am_has_ac">🌡️ Кондиционер</label></div>
<div class="form-check"><input type="checkbox" class="form-check-input" id="am_has_tv" name="amenities" value="has_tv"><label class="form-check-label" for="am_has_tv">📺 Телевизор</label></div>
<div class="form-check"><input type="checkbox" class="form-check-input" id="am_has_fridge" name="amenities" value="has_fridge"><label class="form-check-label" for="am_has_fridge">🧊 Холодильник</label></div>
<div class="form-check"><input type="checkbox" class="form-check-input" id="am_has_wifi" name="amenities" value="has_wifi"><label class="form-check-label" for="am_has_wifi">📶 Wi-Fi</label></div>
<div class="form-check"><input type="checkbox" class="form-check-input" id="am_has_kettle" name="amenities" value="has_kettle"><label class="form-check-label" for="am_has_kettle">☕ Чайник</label></div>
<div class="form-check"><input type="checkbox" class="form-check-input" id="am_has_hairdryer" name="amenities" value="has_hairdryer"><label class="form-check-label" for="am_has_hairdryer">💨 Фен</label></div>
<div class="form-check"><input type="checkbox" class="form-check-input" id="am_has_shower" name="amenities" value="has_shower"><label class="form-check-label" for="am_has_shower">🚿 Душ в номере</label></div>
<div class="form-check"><input type="checkbox" class="form-check-input" id="am_has_sea_view" name="amenities" value="has_sea_view"><label class="form-check-label" for="am_has_sea_view">🌊 Вид на море</label></div>
</div>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="roomIsActive" checked>
<label class="form-check-label" for="roomIsActive">Номер активен (виден на сайте)</label>
</div>
</div>
<div class="modal-footer-custom">
<button type="button" class="btn btn-secondary btn-sm" onclick="closeRoomModal()">Отмена</button>
<button type="button" class="btn-gold btn-sm" onclick="saveRoom()"><i class="fas fa-save"></i> Сохранить</button>
</div>
</form>
</div>
</div>
<style>
.room-admin-card { background: #fff; border-radius: 16px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.08); border: 1px solid #e2e8f0; transition: all 0.2s; }
.room-admin-card:hover { box-shadow: 0 8px 24px rgba(0,0,0,0.12); transform: translateY(-2px); }
.room-admin-image { position: relative; height: 180px; background: #1e293b; overflow: hidden; }
.room-admin-image img { width: 100%; height: 100%; object-fit: cover; }
.room-admin-type { position: absolute; top: 10px; left: 10px; background: rgba(37,99,235,0.9); color: #fff; padding: 4px 10px; border-radius: 20px; font-size: 0.7rem; font-weight: 600; }
.room-admin-body { padding: 16px; }
.room-admin-name { font-size: 1.1rem; font-weight: 700; color: #0f172a; margin: 0 0 6px; }
.room-admin-price { font-size: 1.4rem; font-weight: 700; color: #c9a84c; font-family: 'Playfair Display', serif; }
.room-admin-price span { font-size: 0.8rem; color: #94a3b8; font-weight: 400; }
.room-admin-meta { display: flex; gap: 12px; font-size: 0.8rem; color: #64748b; margin: 8px 0; }
.room-admin-meta span { display: flex; align-items: center; gap: 4px; }
.room-admin-meta i { color: #94a3b8; width: 14px; }
.room-admin-floors { font-size: 0.75rem; color: #94a3b8; margin-bottom: 8px; }
.room-admin-amenities { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 10px; }
.room-amenity-tag { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; background: #f1f5f9; border-radius: 6px; font-size: 0.75rem; color: #64748b; }
.room-amenity-tag i { font-size: 0.7rem; }
.room-admin-extra { font-size: 0.75rem; color: #e67e22; background: #fef3e2; padding: 4px 8px; border-radius: 4px; margin-bottom: 10px; display: inline-block; }
.room-admin-actions { display: flex; gap: 8px; margin-top: 12px; border-top: 1px solid #f1f5f9; padding-top: 12px; }
.room-admin-actions .btn-primary-custom { flex: 1; }
.room-admin-actions .btn-danger-custom { padding: 8px 12px; }
</style>
<div class="modal-backdrop-custom" id="seasonalModal">
<div class="modal-custom">
<div class="modal-header-custom">
<h3 id="seasonalModalTitle">Добавить сезонную цену</h3>
<button class="modal-close" onclick="hideSeasonalModal()">&times;</button>
</div>
<form id="seasonalForm">
<input type="hidden" id="editSeasonalId">
<div class="modal-body-custom">
<div class="mb-3">
<label class="form-label">Тип номера *</label>
<select class="form-control" id="seasonalRoomType">
<option value="2x-местный">2x-местный</option>
<option value="3х-местный">3х-местный</option>
<option value="Семейный">Семейный</option>
<option value="Люкс">Люкс</option>
</select>
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label">Дата с *</label>
<input type="date" class="form-control" id="seasonalDateFrom" required>
</div>
<div class="col-md-6">
<label class="form-label">Дата по *</label>
<input type="date" class="form-control" id="seasonalDateTo" required>
</div>
</div>
<div class="mb-3">
<label class="form-label">Цена за ночь (₽) *</label>
<input type="number" class="form-control" id="seasonalPrice" required min="1" placeholder="2000">
</div>
<div class="mb-3">
<label class="form-label">Метка (например: Лето 2026)</label>
<input type="text" class="form-control" id="seasonalLabel" placeholder="Лето 2026">
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="seasonalIsActive" checked>
<label class="form-check-label" for="seasonalIsActive">Активна</label>
</div>
</div>
<div class="modal-footer-custom">
<button type="button" class="btn btn-secondary btn-sm" onclick="hideSeasonalModal()">Отмена</button>
<button type="submit" class="btn-gold btn-sm"><i class="fas fa-save"></i> Сохранить</button>
</div>
</form>
</div>
</div>
<div class="modal-backdrop-custom" id="activityModal">
<div class="modal-custom" style="max-width: min(95vw, 700px);">
<div class="modal-header-custom">
<h3 id="activityModalTitle">Добавить развлечение</h3>
<button class="modal-close" onclick="closeActivityModal()">&times;</button>
</div>
<form id="activityForm">
<input type="hidden" id="editActivityId">
<div class="modal-body-custom" style="max-height: 65vh; overflow-y: auto;">
<div class="row g-3 mb-3">
<div class="col-md-4">
<label class="form-label">Название (RU)</label>
<input type="text" class="form-control" id="actTitleRu" placeholder="На русском">
</div>
<div class="col-md-4">
<label class="form-label">Название (EN)</label>
<input type="text" class="form-control" id="actTitleEn" placeholder="На английском">
</div>
<div class="col-md-4">
<label class="form-label">Название (AB)</label>
<input type="text" class="form-control" id="actTitleAb" placeholder="На абхазском">
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-4">
<label class="form-label">Описание (RU)</label>
<textarea class="form-control" id="actDescRu" rows="2" placeholder="На русском"></textarea>
</div>
<div class="col-md-4">
<label class="form-label">Описание (EN)</label>
<textarea class="form-control" id="actDescEn" rows="2" placeholder="На английском"></textarea>
</div>
<div class="col-md-4">
<label class="form-label">Описание (AB)</label>
<textarea class="form-control" id="actDescAb" rows="2" placeholder="На абхазском"></textarea>
</div>
</div>
<small class="text-muted d-block mb-3" style="margin-top: -8px;">Заполните либо 1 язык, либо все 3. Промежуточные варианты не допускаются.</small>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label">Иконка (Font Awesome)</label>
<input type="text" class="form-control" id="actIcon" placeholder="fas fa-water">
<small class="text-muted">Например: fas fa-water, fas fa-coffee, fas fa-utensils</small>
</div>
<div class="col-md-3">
<label class="form-label">Порядок</label>
<input type="number" class="form-control" id="actSortOrder" min="0" value="0">
</div>
<div class="col-md-3 d-flex align-items-end">
<div class="form-check mb-2">
<input type="checkbox" class="form-check-input" id="actIsActive" checked>
<label class="form-check-label" for="actIsActive">Активно</label>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">Изображение</label>
<div id="activityImagePreview" style="margin-bottom: 8px;"></div>
<div style="display: flex; gap: 8px; align-items: center;">
<input type="file" class="form-control" id="activityImageInput" accept="image/*" style="font-size: 0.85rem; max-width: 300px;">
<button type="button" class="btn btn-primary btn-sm" onclick="uploadActivityImage()"><i class="fas fa-upload"></i> Загрузить</button>
</div>
<small class="text-muted" style="font-size: 0.75rem; display: block; margin-top: 4px;">JPG/PNG/WebP, до 5 MB</small>
</div>
</div>
<div class="modal-footer-custom">
<button type="button" class="btn btn-secondary btn-sm" onclick="closeActivityModal()">Отмена</button>
<button type="button" class="btn-gold btn-sm" onclick="saveActivity()"><i class="fas fa-save"></i> Сохранить</button>
</div>
</form>
</div>
</div>
<div class="modal-backdrop-custom" id="heroMediaModal">
<div class="modal-custom" style="max-width: min(95vw, 500px);">
<div class="modal-header-custom">
<h3 id="heroMediaModalTitle">Добавить медиа</h3>
<button class="modal-close" onclick="closeHeroMediaModal()">&times;</button>
</div>
<div class="modal-body-custom">
<div class="mb-3">
<label class="form-label">Тип</label>
<select class="form-control" id="heroMediaType" onchange="onHeroTypeChange()">
<option value="image">Картинка</option>
<option value="video">Видео</option>
</select>
</div>
<div id="heroMediaImageGroup" class="mb-3">
<label class="form-label">Картинка</label>
<div id="heroMediaImagePreview" style="margin-bottom: 8px;"></div>
<div style="display: flex; gap: 8px; align-items: center;">
<input type="file" class="form-control" id="heroMediaImageInput" accept="image/*" style="font-size: 0.85rem;">
<button type="button" class="btn btn-primary btn-sm" onclick="uploadHeroImage()"><i class="fas fa-upload"></i> Загрузить</button>
</div>
<small class="text-muted" style="font-size: 0.75rem; display: block; margin-top: 4px;">JPG/PNG/WebP, до 5 MB</small>
</div>
<div id="heroMediaVideoGroup" class="mb-3" style="display: none;">
<label class="form-label">Видео</label>
<div style="display: flex; gap: 8px; align-items: center; margin-bottom: 8px;">
<input type="file" class="form-control" id="heroMediaVideoInput" accept="video/*" style="font-size: 0.85rem;">
<button type="button" class="btn btn-primary btn-sm" onclick="uploadHeroVideo()"><i class="fas fa-upload"></i> Загрузить</button>
</div>
<small class="text-muted" style="font-size: 0.75rem; display: block; margin-bottom: 8px;">MP4/WebM/MOV, до 100 MB</small>
<button type="button" class="btn btn-secondary btn-sm" onclick="showHeroUrlField()" style="width: 100%;"><i class="fas fa-link"></i> Или вставить URL</button>
</div>
<div id="heroMediaUrlGroup" class="mb-3" style="display: none;">
<label class="form-label">URL видео</label>
<input type="text" class="form-control" id="heroMediaPath" placeholder="https://www.youtube.com/embed/... или https://.../video.mp4">
<small class="text-muted" style="font-size: 0.75rem; display: block; margin-top: 4px;">YouTube embed URL или прямая ссылка на видеофайл</small>
</div>
<div class="mb-3">
<label class="form-label">Порядок показа</label>
<input type="number" class="form-control" id="heroMediaSortOrder" min="0" value="0" placeholder="0">
</div>
</div>
<div class="modal-footer-custom">
<button type="button" id="heroMediaDeleteBtn" class="btn btn-danger btn-sm" onclick="deleteHeroMedia(editingHeroMediaId)" style="display: none; margin-right: auto;"><i class="fas fa-trash"></i> Удалить</button>
<button type="button" class="btn btn-secondary btn-sm" onclick="closeHeroMediaModal()">Отмена</button>
<button type="button" class="btn-gold btn-sm" onclick="saveHeroMedia()"><i class="fas fa-save"></i> Сохранить</button>
</div>
</div>
</div>
</body>
</html>