1189 lines
64 KiB
HTML
1189 lines
64 KiB
HTML
<!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()">×</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()">×</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()">×</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>
|