Files
hotell777_260507/public/admin.html
2026-05-11 19:41:49 +05:00

1835 lines
101 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; }
#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: 48px 40px; width: 100%; max-width: 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); }
.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: 12px 16px; border: 1px solid #e2e8f0; }
.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: 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; }
.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; }
.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: 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-user { position: absolute; bottom: 0; left: 0; right: 0; 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: 32px; }
.top-bar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 32px; }
.top-bar h1 { font-size: 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(200px, 1fr)); gap: 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: 24px; 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: 300px; }
.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 { display: none; }
.main-content { margin-left: 0; }
}
</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">
<div class="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="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="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 blue"><i class="fas fa-users"></i></div>
<div class="stat-value" id="statUsers"></div>
<div class="stat-label">Пользователей</div>
</div>
<div class="stat-card">
<div class="stat-icon gold"><i class="fas fa-calendar-check"></i></div>
<div class="stat-value" id="statBookings"></div>
<div class="stat-label">Бронирований</div>
</div>
<div class="stat-card">
<div class="stat-icon green"><i class="fas fa-shield-alt"></i></div>
<div class="stat-value" id="statAdmins"></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-star"></i></div>
<div class="stat-value" id="statPendingReviews"></div>
<div class="stat-label">Отзывов на модерации</div>
</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></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="width: 200px; 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-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(300px, 1fr)); gap: 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-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>
</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>
</div>
<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>
</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');
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 === 'users') loadUsers();
if (tab === 'bookings') { bookingsLoaded = false; loadBookings(); }
if (tab === 'rooms') loadRooms();
if (tab === 'promocodes') loadPromocodes();
if (tab === 'reviews') loadReviews();
if (tab === 'settings') loadSettings();
if (tab === 'profile') loadProfile();
});
});
}
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 users = await api('/api/admin/users');
document.getElementById('statUsers').textContent = users.length;
document.getElementById('statAdmins').textContent = users.filter(u => u.role === 'admin').length;
} catch(e) {}
try {
const rows = await api('/api/admin/bookings');
document.getElementById('statBookings').textContent = rows.length;
document.getElementById('statNew').textContent = rows.filter(r => r.status === 'новая').length;
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_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 = reviewData.stats.pending;
} catch(e) {}
}
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 sortDirection = 'asc';
let bookingsLoaded = false;
let currentPage = 1;
let totalPages = 1;
let searchTimeout = null;
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 rooms = ['2x-местный', '3х-местный', 'Семейный', 'Люкс'];
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 roomOpts = '<option value="">—</option>' + rooms.map(s => '<option value="' + s + '"' + (r.room_type === s ? ' selected' : '') + '>' + s + '</option>').join('');
const commentHtml = '<input type="text" class="form-control form-control-sm" style="width: 120px; 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="width: 70px; 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="width: 120px; font-size: 0.8rem;" 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="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="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="width:130px; 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="width:130px; 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="width: 130px; 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_type) {
try {
const data = await api('/api/admin/bookings/' + id + '/room', { method: 'PATCH', body: JSON.stringify({ room_type }) });
allBookingsData = allBookingsData.map(b => b.id === id ? data.booking : b);
renderBookings(); updateBookingStats(); loadDashboard();
showToast('Номер обновлён: ' + (room_type || 'Не указан'));
} 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('profileName').textContent = currentUser.full_name || currentUser.login;
document.getElementById('profileLogin').textContent = '@' + currentUser.login;
document.getElementById('profileAvatar').textContent = (currentUser.full_name || currentUser.login).charAt(0).toUpperCase();
}
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 imageSrc = r.image_path ? (r.image_path.startsWith('uploads') ? '/' + r.image_path : r.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="16" x="50%25" y="50%25" text-anchor="middle" dy=".3em"%3EБез фото%3C/text%3E%3C/svg%3E';
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}₽)` : '';
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>' +
'</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-danger-custom btn-sm" onclick="deleteRoom(' + r.id + ', \'' + escAttr(r.name) + '\')"><i class="fas fa-trash"></i></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;
}
const image_path = document.getElementById('roomImagePreview').dataset.path || '';
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, image_path }) });
showToast('Номер обновлён');
} else {
await api('/api/admin/rooms', { method: 'POST', body: JSON.stringify(data) });
showToast('Номер создан');
}
closeRoomModal();
loadRooms();
} catch(err) { showToast(err.message, 'error'); }
}
async function deleteRoom(id, name) {
if (!confirm('Удалить номер "' + name + '"? Это скроет номер из списка.')) return;
try {
await api('/api/admin/rooms/' + id, { method: 'DELETE' });
showToast('Номер удалён');
loadRooms();
} catch(err) { showToast(err.message, 'error'); }
}
async function uploadRoomImage() {
const fileInput = document.getElementById('roomImageInput');
const file = fileInput.files[0];
if (!file) { showToast('Выберите файл', 'error'); return; }
const formData = new FormData();
formData.append('image', file);
try {
const res = await fetch(API + '/api/admin/rooms/upload', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token },
body: formData
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Ошибка загрузки');
document.getElementById('roomImagePreview').src = '/' + data.path;
document.getElementById('roomImagePreview').dataset.path = data.path;
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: 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 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 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 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 style="display: flex; gap: 12px; align-items: flex-start;">
<img id="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" style="width: 160px; height: 96px; object-fit: cover; border-radius: 8px; border: 1px solid #e2e8f0;">
<div style="flex: 1;">
<input type="file" class="form-control" id="roomImageInput" accept="image/*" style="font-size: 0.85rem;">
<button type="button" class="btn btn-primary btn-sm mt-2" onclick="uploadRoomImage()" style="width: 100%;"><i class="fas fa-upload"></i> Загрузить</button>
<small class="text-muted" style="font-size: 0.75rem; display: block; margin-top: 4px;">JPG, PNG, WebP до 5 МБ. Автоконвертация в WebP.</small>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">Мебель</label>
<div 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 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>
</body>
</html>