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