Files
hotell777_260507/public/admin.html
2026-05-10 21:42:31 +05:00

1189 lines
64 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="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-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">
<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 id="tab-profile" class="tab-content">
<div class="top-bar"><h1>Мой профиль</h1></div>
<div class="card">
<div class="card-body-custom">
<div class="profile-section">
<div class="profile-avatar" id="profileAvatar"></div>
<div class="profile-info">
<h4 id="profileName"></h4>
<p id="profileLogin"></p>
</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="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 === '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 = ['Эконом', 'Стандарт', 'VIP Люкс'];
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; }
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 = 'Ошибка загрузки';
}
}
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 = '******';
}
}
</script>
</body>
</html>