Files
hotell777_260507/public/admin.html
kalugin66 52d5b65fa3
All checks were successful
Deploy hotel / deploy-kdo (push) Successful in 49s
Бронирования
2026-07-27 01:17:40 +05:00

3590 lines
204 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Panel — Hotel 777</title>
<link rel="stylesheet" href="css/bootstrap.min.css">
<link rel="stylesheet" href="css/fontawesome.min.css">
<link rel="stylesheet" href="css/inter-font.css">
<style>
:root { --primary: #2563eb; --gold: #c9a84c; --dark: #0f172a; --sidebar-width: 260px; }
* { box-sizing: border-box; }
body { font-family: 'Inter', sans-serif; background: #f1f5f9; margin: 0; font-size: clamp(0.85rem, 1.5vw, 0.95rem); }
#login-page { display: flex; align-items: center; justify-content: center; min-height: 100vh; background: linear-gradient(135deg, #0f172a, #1e293b); }
.login-card { background: #fff; border-radius: 16px; padding: clamp(24px, 6vw, 48px) clamp(16px, 5vw, 40px); width: 100%; max-width: min(90vw, 400px); box-shadow: 0 25px 60px rgba(0,0,0,0.3); }
.login-card h2 { font-family: 'Playfair Display', serif; text-align: center; margin-bottom: 8px; color: var(--dark); font-size: clamp(1.2rem, 2.5vw, 1.5rem); }
.login-card .subtitle { text-align: center; color: #64748b; margin-bottom: 32px; font-size: 0.9rem; }
.login-card .form-label { font-weight: 500; font-size: 0.85rem; color: #334155; }
.login-card .form-control { border-radius: 10px; padding: clamp(8px, 1.5vw, 12px) clamp(12px, 2vw, 16px); border: 1px solid #e2e8f0; font-size: clamp(0.85rem, 1.3vw, 0.95rem); }
.login-card .form-control:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(37,99,235,0.1); }
.login-btn { width: 100%; padding: clamp(10px, 2vw, 14px); border: none; border-radius: 10px; background: var(--primary); color: #fff; font-weight: 600; font-size: 1rem; cursor: pointer; margin-top: 8px; }
.login-btn:hover { background: #1d4ed8; }
.login-error { color: #ef4444; font-size: 0.85rem; text-align: center; margin-top: 12px; display: none; }
.login-logo { text-align: center; font-size: 1.5rem; font-weight: 700; color: var(--gold); margin-bottom: 24px; }
.login-logo span { color: var(--primary); }
#admin-panel { display: none; }
.hamburger { display: none; position: fixed; top: 12px; left: 12px; z-index: 300; width: 40px; height: 40px; border-radius: 10px; background: var(--dark); color: #fff; border: none; font-size: 1.25rem; cursor: pointer; align-items: center; justify-content: center; box-shadow: 0 2px 8px rgba(0,0,0,0.3); }
.sidebar { position: fixed; left: 0; top: 0; bottom: 0; width: var(--sidebar-width); background: var(--dark); color: #fff; padding: 24px 0; z-index: 100; overflow-y: auto; transition: transform 0.3s ease; }
.sidebar-brand { padding: 0 24px; font-size: 1.25rem; font-weight: 700; color: var(--gold); margin-bottom: 32px; }
.sidebar-brand span { color: #fff; }
.sidebar-nav a { display: flex; align-items: center; gap: 12px; padding: 12px 24px; color: #94a3b8; text-decoration: none; font-size: clamp(0.8rem, 1.2vw, 0.9rem); transition: 0.2s; }
.sidebar-nav a:hover, .sidebar-nav a.active { background: rgba(255,255,255,0.05); color: #fff; border-right: 3px solid var(--gold); }
.sidebar-nav a i { width: 20px; text-align: center; }
.sidebar-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 99; }
.sidebar-user { padding: 20px 24px; border-top: 1px solid rgba(255,255,255,0.1); background: rgba(0,0,0,0.2); }
.sidebar-user .name { font-weight: 600; font-size: 0.9rem; }
.sidebar-user .role { font-size: 0.75rem; color: #94a3b8; }
.main-content { margin-left: var(--sidebar-width); padding: clamp(16px, 3vw, 32px); }
.top-bar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 32px; gap: clamp(8px, 2vw, 16px); flex-wrap: wrap; }
.top-bar h1 { font-size: clamp(1.25rem, 2.5vw, 1.75rem); font-weight: 700; color: var(--dark); margin: 0; }
.card { background: #fff; border-radius: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); border: none; margin-bottom: 24px; }
.card-header-custom { padding: 20px 24px; border-bottom: 1px solid #e2e8f0; display: flex; justify-content: space-between; align-items: center; }
.card-header-custom h3 { margin: 0; font-size: 1.1rem; font-weight: 600; }
.card-body-custom { padding: 24px; }
.btn-primary-custom { background: var(--primary); color: #fff; border: none; padding: 10px 20px; border-radius: 10px; font-weight: 500; cursor: pointer; }
.btn-primary-custom:hover { background: #1d4ed8; }
.btn-gold { background: var(--gold); color: #fff; border: none; padding: 10px 20px; border-radius: 10px; font-weight: 500; cursor: pointer; }
.btn-gold:hover { background: #b8943f; }
.btn-danger-custom { background: #ef4444; color: #fff; border: none; padding: 8px 16px; border-radius: 8px; font-weight: 500; cursor: pointer; }
.btn-danger-custom:hover { background: #dc2626; }
.btn-sm { padding: 6px 12px; font-size: 0.8rem; }
.table th { font-weight: 600; color: #64748b; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 2px solid #e2e8f0; }
.table td { vertical-align: middle; }
.badge { padding: 4px 10px; border-radius: 20px; font-size: 0.75rem; font-weight: 600; }
.badge-admin { background: #fef3c7; color: #92400e; }
.badge-user { background: #dbeafe; color: #1e40af; }
.badge-status { padding: 4px 10px; border-radius: 20px; font-size: 0.7rem; font-weight: 600; white-space: nowrap; }
.badge-status-новая { background: #dbeafe; color: #1e40af; }
.badge-status-оплачена { background: #dcfce7; color: #15803d; }
.badge-status-зарезервирована { background: #fef3c7; color: #92400e; }
.badge-status-заселена { background: #f3e8ff; color: #7e22ce; }
.badge-status-выехала { background: #e2e8f0; color: #475569; }
.badge-status-отменена { background: #fee2e2; color: #b91c1c; }
.review-text-cell { max-width: 300px; }
.review-text-full {
max-height: 150px;
overflow-y: auto;
padding: 8px;
background: #f8fafc;
border-radius: 6px;
font-size: 0.85rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.review-text-full::-webkit-scrollbar { width: 6px; }
.review-text-full::-webkit-scrollbar-track { background: #e2e8f0; border-radius: 3px; }
.review-text-full::-webkit-scrollbar-thumb { background: #94a3b8; border-radius: 3px; }
/* Booking cards */
.bookings-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
.booking-card { background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; overflow: hidden; cursor: pointer; transition: transform 0.2s ease, box-shadow 0.2s ease; display: flex; }
.booking-card:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0,0,0,0.1); }
.card-bar { width: 4px; flex-shrink: 0; }
.card-bar-новая { background: #3b82f6; }
.card-bar-оплачена { background: #22c55e; }
.card-bar-зарезервирована { background: #f59e0b; }
.card-bar-заселена { background: #a855f7; }
.card-bar-выехала { background: #94a3b8; }
.card-bar-отменена { background: #ef4444; }
.card-inner { flex: 1; padding: 14px; min-width: 0; }
.card-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.card-id { font-size: 0.7rem; color: #94a3b8; }
.card-name { font-size: 1rem; font-weight: 700; color: #0f172a; margin-bottom: 4px; }
.card-phone { font-size: 0.78rem; color: #64748b; margin-bottom: 8px; }
.card-meta { display: flex; gap: 14px; font-size: 0.78rem; color: #475569; margin-bottom: 6px; }
.card-dates { font-size: 0.82rem; color: #0f172a; margin-bottom: 8px; }
.card-nights { font-size: 0.72rem; color: #94a3b8; }
.card-price-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.card-disc-badge { background: #fef3c7; color: #92400e; padding: 1px 7px; border-radius: 10px; font-size: 0.72rem; font-weight: 600; }
.card-base-price { font-size: 0.78rem; color: #94a3b8; text-decoration: line-through; }
.card-total { font-size: 0.95rem; font-weight: 700; color: #0f172a; }
.card-promo { font-size: 0.72rem; color: #6366f1; margin-bottom: 4px; }
.card-comment { font-size: 0.72rem; color: #64748b; background: #f8fafc; padding: 3px 8px; border-radius: 6px; margin-bottom: 8px; }
.card-actions { display: flex; gap: 6px; align-items: center; padding-top: 8px; border-top: 1px solid #f1f5f9; }
.card-soon { background: #fffbeb; }
.card-today { background: #fef2f2; }
.status-tabs { display: flex; gap: 8px; margin-bottom: 16px; }
.status-tab { padding: 7px 18px; border-radius: 20px; border: 1px solid #e2e8f0; background: #fff; font-size: 0.82rem; font-weight: 500; cursor: pointer; transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease; }
.status-tab:hover { border-color: var(--primary); color: var(--primary); }
.status-tab.active { background: var(--primary); color: #fff; border-color: var(--primary); }
.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; }
.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="visitors"><i class="fas fa-chart-bar"></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">
<div style="display:flex;gap:8px;width:100%;flex-wrap:wrap;align-items:center;">
<form autocomplete="off" style="display:flex;gap:8px;flex:1;min-width:250px;" onsubmit="return false">
<input type="text" id="searchBookings" class="form-control" style="flex:1;font-size:0.85rem;" placeholder="Поиск по имени/телефону...">
<button type="button" class="btn" style="background:var(--primary);color:#fff;border:none;border-radius:8px;padding:6px 16px;font-size:0.85rem;white-space:nowrap;" onclick="applySearch()"><i class="fas fa-search"></i> Применить</button>
<button type="button" class="btn" style="background:#ef4444;color:#fff;border:none;border-radius:8px;padding:6px 16px;font-size:0.85rem;white-space:nowrap;" onclick="resetSearch()"><i class="fas fa-times"></i> Сброс</button>
</form>
<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>
<div class="status-tabs" style="margin-bottom:0;">
<button class="status-tab active" data-status="all">Все</button>
<button class="status-tab" data-status="новая">Новые</button>
<button class="status-tab" data-status="оплачена,зарезервирована,заселена">Активные</button>
<button class="status-tab" data-status="выехала,отменена">Завершённые</button>
</div>
</div>
</div>
<div id="bookingsGrid" class="bookings-grid"></div>
<div class="pagination-container" style="display: flex; justify-content: space-between; align-items: center; margin-top: 16px; padding-top: 16px; border-top: 1px solid #e2e8f0;">
<div id="paginationInfo" style="font-size: 0.85rem; color: #64748b;"></div>
<div class="pagination-controls" style="display: flex; gap: 8px;">
<button class="btn btn-outline-secondary btn-sm" id="prevPage" onclick="changePage(-1)" disabled>← Назад</button>
<span id="pageNumbers" style="display: flex; gap: 4px;"></span>
<button class="btn btn-outline-secondary btn-sm" id="nextPage" onclick="changePage(1)" disabled>Вперёд →</button>
</div>
</div>
</div>
</div>
</div>
<div id="tab-calendar" class="tab-content">
<div class="top-bar">
<h1>Календарь бронирований</h1>
<div style="display: flex; gap: 8px; align-items: center;">
<button class="btn btn-outline-secondary btn-sm" onclick="calendarPrevMonth()"><i class="fas fa-chevron-left"></i></button>
<select id="calendarMonthSelect" onchange="loadCalendar(this.value)" style="padding: 6px 10px; border-radius: 8px; border: 1px solid #e2e8f0; font-size: 0.9rem;">
</select>
<button class="btn btn-outline-secondary btn-sm" onclick="calendarNextMonth()"><i class="fas fa-chevron-right"></i></button>
</div>
</div>
<div class="card">
<div class="card-body-custom" style="overflow-x: auto;">
<div id="calendarGrid"></div>
</div>
</div>
</div>
<div id="tab-rooms" class="tab-content">
<div class="top-bar">
<h1>Номера</h1>
<button class="btn-primary-custom admin-only" onclick="showRoomModal()"><i class="fas fa-plus"></i> Добавить номер</button>
</div>
<div class="card">
<div class="card-body-custom">
<div id="roomsGrid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(min(280px, 100%), 1fr)); gap: clamp(10px, 2vw, 20px);"></div>
</div>
</div>
</div>
<div id="tab-promocodes" class="tab-content">
<div class="top-bar">
<h1>Промокоды</h1>
<button class="btn-primary-custom admin-only" onclick="showPromocodeModal()"><i class="fas fa-plus"></i> Добавить</button>
</div>
<div class="card">
<div class="card-body-custom">
<table class="table">
<thead><tr><th>Код</th><th>Скидка (%)</th><th>Действует с</th><th>Действует по</th><th>Период (дн)</th><th>Статус</th><th class="admin-only">Действия</th></tr></thead>
<tbody id="promocodesTable"></tbody>
</table>
</div>
</div>
</div>
<div id="tab-seasonal" class="tab-content">
<div class="top-bar">
<h1>Сезонные цены</h1>
<button class="btn-primary-custom admin-only" onclick="showSeasonalModal()"><i class="fas fa-plus"></i> Добавить</button>
</div>
<div class="card">
<div class="card-body-custom">
<table class="table">
<thead><tr><th>Тип номера</th><th>Дата с</th><th>Дата по</th><th>Цена/ночь (₽)</th><th>Метка</th><th>Активен</th><th class="admin-only">Действия</th></tr></thead>
<tbody id="seasonalTable"></tbody>
</table>
</div>
</div>
</div>
<div id="tab-reviews" class="tab-content">
<div class="top-bar">
<h1>Отзывы</h1>
<div class="filter-bar">
<select id="filterReviews" onchange="loadReviews()">
<option value="all">Все</option>
<option value="pending">На модерации</option>
<option value="approved">Одобренные</option>
<option value="rejected">Скрытые</option>
</select>
</div>
</div>
<div class="card">
<div class="card-body-custom">
<table class="table">
<thead><tr><th>Автор</th><th>Местоположение</th><th>Оценка</th><th>Текст</th><th>Статус</th><th>Дата</th><th>Действия</th></tr></thead>
<tbody id="reviewsTable"></tbody>
</table>
</div>
</div>
</div>
<div id="tab-settings" class="tab-content">
<div class="top-bar">
<h1>Настройки</h1>
</div>
<div class="card">
<div class="card-header-custom"><h3>Резервное копирование</h3></div>
<div class="card-body-custom">
<div class="row mb-4">
<div class="col-md-6">
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="backupAutoEnabled" style="width: 50px; height: 24px; cursor: pointer;">
<label class="form-check-label fw-500 ms-2" for="backupAutoEnabled">Автоматическое резервное копирование</label>
</div>
</div>
<div class="row g-3">
<div class="col-auto">
<label class="form-label">Время запуска</label>
<input type="time" class="form-control" id="backupAutoTime" value="03:00" style="width: 130px;">
</div>
<div class="col-auto">
<label class="form-label">Хранить (дней)</label>
<input type="number" class="form-control" id="backupRetentionDays" value="30" min="1" max="365" style="width: 100px;">
</div>
<div class="col-auto d-flex align-items-end">
<button class="btn-gold btn-sm" onclick="saveBackupSettings()"><i class="fas fa-save me-1"></i>Сохранить</button>
</div>
</div>
</div>
<div class="col-md-6 d-flex align-items-center justify-content-end">
<button class="btn-primary-custom" onclick="createManualBackup()" id="btnCreateBackup">
<i class="fas fa-download me-2"></i>Создать бекап сейчас
</button>
</div>
</div>
<div class="card mt-3" style="background: #f8fafc; border: 1px solid #e2e8f0;">
<div class="card-body-custom">
<h4 class="mb-3" style="font-size: 1rem;"><i class="fas fa-history me-2"></i>История бекапов</h4>
<div style="max-height: 300px; overflow-y: auto;">
<table class="table table-sm">
<thead>
<tr>
<th>Дата</th>
<th>Размер</th>
<th>Тип</th>
<th>Восстановлен</th>
<th class="text-end">Действия</th>
</tr>
</thead>
<tbody id="backupsTableBody">
<tr><td colspan="5" class="text-center text-muted">Загрузка...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="card" id="phoneCard">
<div class="card-header-custom"><h3><i class="fas fa-phone-alt me-2"></i>Телефоны и контакты</h3></div>
<div class="card-body-custom">
<p style="color: #64748b; margin-bottom: 20px;">
<i class="fas fa-info-circle me-2"></i>
Основной номер используется для звонков. Дополнительный отображается через запятую. Для мессенджеров можно указать отдельные номера или username.
</p>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Основной номер</label>
<input type="text" class="form-control" id="phonePrimary" placeholder="89409270080">
</div>
<div class="col-md-6">
<label class="form-label">Дополнительный номер</label>
<input type="text" class="form-control" id="phoneAdditional" placeholder="Необязательно">
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-md-4">
<label class="form-label">WhatsApp (номер)</label>
<div class="d-flex gap-2">
<input type="text" class="form-control" id="phoneWhatsappNum" placeholder="89409270080" style="flex:1">
<div class="form-check form-switch d-flex align-items-center" style="margin:0; padding-left:2.5em; white-space:nowrap;">
<input class="form-check-input" type="checkbox" id="phoneWhatsappActive" style="width:40px;height:20px;cursor:pointer;">
<label class="form-check-label ms-1" for="phoneWhatsappActive" style="font-size:0.75rem;">Активна</label>
</div>
</div>
</div>
<div class="col-md-4">
<label class="form-label">Telegram (username или +номер)</label>
<div class="d-flex gap-2">
<input type="text" class="form-control" id="phoneTelegramId" placeholder="+89409270080" style="flex:1">
<div class="form-check form-switch d-flex align-items-center" style="margin:0; padding-left:2.5em; white-space:nowrap;">
<input class="form-check-input" type="checkbox" id="phoneTelegramActive" style="width:40px;height:20px;cursor:pointer;">
<label class="form-check-label ms-1" for="phoneTelegramActive" style="font-size:0.75rem;">Активна</label>
</div>
</div>
</div>
<div class="col-md-4">
<label class="form-label">Max (username)</label>
<div class="d-flex gap-2">
<input type="text" class="form-control" id="phoneMaxLink" placeholder="kalugin66" style="flex:1">
<div class="form-check form-switch d-flex align-items-center" style="margin:0; padding-left:2.5em; white-space:nowrap;">
<input class="form-check-input" type="checkbox" id="phoneMaxActive" style="width:40px;height:20px;cursor:pointer;">
<label class="form-check-label ms-1" for="phoneMaxActive" style="font-size:0.75rem;">Активна</label>
</div>
</div>
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-md-4">
<label class="form-label">Instagram (username)</label>
<div class="d-flex gap-2">
<input type="text" class="form-control" id="phoneInstagramId" placeholder="hotel777abkhazia" style="flex:1">
<div class="form-check form-switch d-flex align-items-center" style="margin:0; padding-left:2.5em; white-space:nowrap;">
<input class="form-check-input" type="checkbox" id="phoneInstagramActive" style="width:40px;height:20px;cursor:pointer;">
<label class="form-check-label ms-1" for="phoneInstagramActive" style="font-size:0.75rem;">Активна</label>
</div>
</div>
</div>
</div>
<div class="mt-3">
<button class="btn-gold btn-sm" onclick="savePhoneSettings()"><i class="fas fa-save me-1"></i>Сохранить</button>
</div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header-custom"><h3>Кодовое слово для отзывов</h3></div>
<div class="card-body-custom">
<p style="color: #64748b; margin-bottom: 20px;">
<i class="fas fa-info-circle me-2"></i>
Это кодовое слово гости должны вводить при оставлении отзыва. Сообщите его гостям на ресепшене.
</p>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Текущий код</label>
<div class="d-flex align-items-center gap-2">
<input type="text" class="form-control" id="currentCodeDisplay" value="Загрузка..." readonly style="background: #f8fafc; font-weight: 600; color: #0f172a;">
</div>
</div>
<form id="settingsForm">
<div class="mb-3">
<label class="form-label">Новый код</label>
<input type="text" class="form-control" id="newReviewCode" placeholder="Минимум 3 символа" minlength="3">
</div>
<button type="submit" class="btn-gold">
<i class="fas fa-save me-2"></i>Сохранить
</button>
</form>
</div>
</div>
</div>
</div>
<div class="card" id="securityCard">
<div class="card-header-custom"><h3><i class="fas fa-shield-alt me-2"></i>Ключи безопасности</h3></div>
<div class="card-body-custom">
<p style="color: #64748b; margin-bottom: 20px;">
<i class="fas fa-info-circle me-2"></i>
Ключи автоматически генерируются при первом запуске. После регенерации требуется перезапуск сервера.
</p>
<div id="securityKeys"></div>
</div>
</div>
<div class="card" id="telegramCard">
<div class="card-header-custom"><h3><i class="fab fa-telegram me-2"></i>Telegram-уведомления о новых заявках</h3></div>
<div class="card-body-custom">
<p style="color: #64748b; margin-bottom: 20px;">
<i class="fas fa-info-circle me-2"></i>
Отправляет уведомления о новых бронях в Telegram. Бота можно создать через <a href="https://t.me/BotFather" target="_blank">@BotFather</a>.
Чтобы узнать свой chat_id — напишите боту любое сообщение, он ответит вашим ID.
</p>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Токен бота</label>
<input type="password" class="form-control" id="telegramBotToken" placeholder="1234567890:ABCdefGHIjklMNOpqrsTUVwxyz">
</div>
<div class="col-md-6">
<label class="form-label">Chat ID получателей (через запятую)</label>
<input type="text" class="form-control" id="telegramNotifyUsers" placeholder="123456789,987654321">
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-md-6">
<label class="form-label">API URL (обход блокировок)</label>
<input type="text" class="form-control" id="telegramApiUrl" placeholder="https://api.telegram.org">
<small style="color: #94a3b8;">Если Telegram заблокирован, укажите адрес прокси-сервера (nginx, socat и т.д.)</small>
</div>
<div class="col-md-6 d-flex align-items-end">
<span id="telegramStatus" class="badge bg-secondary">Подключение: —</span>
</div>
</div>
<div class="mt-3 d-flex align-items-center gap-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="telegramEnabled" style="width: 50px; height: 24px; cursor: pointer;">
<label class="form-check-label fw-500 ms-2" for="telegramEnabled">Включить уведомления</label>
</div>
<button class="btn-primary-custom btn-sm" onclick="saveTelegramSettings()"><i class="fas fa-save me-1"></i>Сохранить</button>
<button class="btn-gold btn-sm" id="btnTelegramTest" onclick="sendTestTelegram()"><i class="fab fa-telegram me-1"></i>Отправить тест</button>
</div>
</div>
</div>
<div class="card" id="emailCard">
<div class="card-header-custom"><h3><i class="fas fa-envelope me-2"></i>Email-уведомления</h3></div>
<div class="card-body-custom">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">SMTP хост</label>
<input type="text" class="form-control" id="smtpHost" placeholder="smtp.gmail.com">
</div>
<div class="col-md-3">
<label class="form-label">Порт</label>
<input type="number" class="form-control" id="smtpPort" placeholder="587">
</div>
<div class="col-md-3">
<label class="form-label">Secure (TLS)</label>
<select class="form-control" id="smtpSecure">
<option value="false">Выключен</option>
<option value="true">Включен</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Логин</label>
<input type="text" class="form-control" id="smtpUser" placeholder="user@gmail.com">
</div>
<div class="col-md-6">
<label class="form-label">Пароль</label>
<input type="password" class="form-control" id="smtpPass" placeholder="Пароль приложения">
</div>
<div class="col-md-6">
<label class="form-label">Отправитель (From)</label>
<input type="text" class="form-control" id="smtpFrom" placeholder="Hotel 777 <noreply@hotel777.ru>">
</div>
<div class="col-md-6">
<label class="form-label">Email для уведомлений</label>
<input type="email" class="form-control" id="adminEmail" placeholder="admin@example.com">
</div>
</div>
<div class="mt-3 d-flex align-items-center gap-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="emailEnabled" style="width: 50px; height: 24px; cursor: pointer;">
<label class="form-check-label fw-500 ms-2" for="emailEnabled">Включить уведомления</label>
</div>
<button class="btn-primary-custom btn-sm" onclick="saveEmailSettings()"><i class="fas fa-save me-1"></i>Сохранить</button>
<button class="btn-gold btn-sm" onclick="sendTestEmail()"><i class="fas fa-paper-plane me-1"></i>Отправить тест</button>
</div>
</div>
</div>
</div>
<div id="tab-profile" class="tab-content">
<div class="top-bar">
<h1>Профиль</h1>
</div>
<div class="card">
<div class="card-header-custom"><h3>Личные данные</h3></div>
<div class="card-body-custom">
<form id="profileForm">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">ФИО</label>
<input type="text" class="form-control" id="profileFullName" placeholder="Иванов Иван Иванович">
</div>
<div class="col-md-6">
<label class="form-label">Email для уведомлений</label>
<input type="email" class="form-control" id="profileEmail" placeholder="email@example.com">
</div>
</div>
<button type="submit" class="btn-primary-custom mt-3"><i class="fas fa-save"></i> Сохранить</button>
</form>
</div>
</div>
<div class="card">
<div class="card-header-custom"><h3>Сменить пароль</h3></div>
<div class="card-body-custom">
<form id="passwordForm">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Текущий пароль</label>
<input type="password" class="form-control" id="currentPassword" required>
</div>
<div class="col-md-4">
<label class="form-label">Новый пароль</label>
<input type="password" class="form-control" id="newPassword" required minlength="4">
</div>
<div class="col-md-4">
<label class="form-label">Повторите пароль</label>
<input type="password" class="form-control" id="confirmPassword" required minlength="4">
</div>
</div>
<button type="submit" class="btn-gold mt-3"><i class="fas fa-key"></i> Сменить пароль</button>
</form>
</div>
</div>
</div>
<div id="tab-activities" class="tab-content">
<div class="top-bar">
<h1>Развлечения — Чем заняться рядом</h1>
<button class="btn-gold" onclick="showActivityModal()"><i class="fas fa-plus"></i> Добавить</button>
</div>
<div class="card">
<div class="card-body-custom">
<div id="activitiesGrid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(min(280px, 100%), 1fr)); gap: clamp(10px, 2vw, 20px);"></div>
</div>
</div>
</div>
<div id="tab-hero" class="tab-content">
<div class="top-bar">
<h1>Главный экран</h1>
<button class="btn-gold" onclick="showHeroMediaModal()"><i class="fas fa-plus"></i> Добавить</button>
</div>
<div class="card">
<div class="card-body-custom">
<div id="heroMediaGrid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(min(280px, 100%), 1fr)); gap: clamp(10px, 2vw, 20px);"></div>
</div>
</div>
</div>
<div id="tab-visitors" class="tab-content">
<div class="top-bar">
<h1>Посетители</h1>
</div>
<div class="card" style="margin-bottom: 20px;">
<div class="card-body-custom">
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(min(160px, 100%), 1fr)); gap: 16px;">
<div class="stat-card" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color:#fff; padding: clamp(12px,2vw,20px); border-radius:12px; text-align:center;">
<div style="font-size:0.85rem;opacity:0.9;margin-bottom:6px;">Всего посетителей</div>
<div style="font-size:clamp(1.5rem,3vw,2.2rem);font-weight:700;" id="visTotal">-</div>
</div>
<div class="stat-card" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); color:#fff; padding: clamp(12px,2vw,20px); border-radius:12px; text-align:center;">
<div style="font-size:0.85rem;opacity:0.9;margin-bottom:6px;">Сегодня</div>
<div style="font-size:clamp(1.5rem,3vw,2.2rem);font-weight:700;" id="visToday">-</div>
</div>
<div class="stat-card" style="background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); color:#fff; padding: clamp(12px,2vw,20px); border-radius:12px; text-align:center;">
<div style="font-size:0.85rem;opacity:0.9;margin-bottom:6px;">За 7 дней</div>
<div style="font-size:clamp(1.5rem,3vw,2.2rem);font-weight:700;" id="visWeek">-</div>
</div>
<div class="stat-card" style="background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%); color:#fff; padding: clamp(12px,2vw,20px); border-radius:12px; text-align:center;">
<div style="font-size:0.85rem;opacity:0.9;margin-bottom:6px;">За месяц</div>
<div style="font-size:clamp(1.5rem,3vw,2.2rem);font-weight:700;" id="visMonth">-</div>
</div>
<div class="stat-card" style="background: linear-gradient(135deg, #fa709a 0%, #fee140 100%); color:#fff; padding: clamp(12px,2vw,20px); border-radius:12px; text-align:center;">
<div style="font-size:0.85rem;opacity:0.9;margin-bottom:6px;">Просмотров страниц</div>
<div style="font-size:clamp(1.5rem,3vw,2.2rem);font-weight:700;" id="visPageViews">-</div>
</div>
<div class="stat-card" style="background: linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%); color:#fff; padding: clamp(12px,2vw,20px); border-radius:12px; text-align:center;">
<div style="font-size:0.85rem;opacity:0.9;margin-bottom:6px;">Уникальных IP</div>
<div style="font-size:clamp(1.5rem,3vw,2.2rem);font-weight:700;" id="visUniqueIps">-</div>
</div>
</div>
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
<div class="card">
<div class="card-body-custom">
<h3 style="margin-bottom: 16px;">По странам</h3>
<div id="visCountriesTable"></div>
</div>
</div>
<div class="card">
<div class="card-body-custom">
<h3 style="margin-bottom: 16px;">Языки браузеров</h3>
<div id="visLanguagesTable"></div>
</div>
</div>
</div>
<div class="card" style="margin-top: 20px;">
<div class="card-body-custom">
<h3 style="margin-bottom: 16px;">Посещения за 30 дней</h3>
<div id="visDailyChart" style="overflow-x: auto;">
<canvas id="visitorsChart" style="width:100%;max-height:300px;"></canvas>
</div>
</div>
</div>
<div class="card" style="margin-top: 20px;">
<div class="card-body-custom">
<h3 style="margin-bottom: 16px;">IP-адреса и даты</h3>
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(min(150px, 100%), 1fr)); gap: 10px; margin-bottom: 14px;">
<div><label style="font-size:0.75rem;color:#64748b;display:block;margin-bottom:2px;">ID</label><input type="number" id="visFiltId" placeholder="123" style="width:100%;padding:6px 8px;border:1px solid #e5e7eb;border-radius:6px;font-size:0.85rem;" onchange="loadIPs(0)"></div>
<div><label style="font-size:0.75rem;color:#64748b;display:block;margin-bottom:2px;">IP / маска</label><input type="text" id="visFiltIp" placeholder="192.168." style="width:100%;padding:6px 8px;border:1px solid #e5e7eb;border-radius:6px;font-size:0.85rem;" onchange="loadIPs(0)"></div>
<div><label style="font-size:0.75rem;color:#64748b;display:block;margin-bottom:2px;">Страна</label><select id="visFiltCountry" style="width:100%;padding:6px 8px;border:1px solid #e5e7eb;border-radius:6px;font-size:0.85rem;" onchange="loadIPs(0)"><option value="">Все</option></select></div>
<div><label style="font-size:0.75rem;color:#64748b;display:block;margin-bottom:2px;">Язык</label><select id="visFiltLang" style="width:100%;padding:6px 8px;border:1px solid #e5e7eb;border-radius:6px;font-size:0.85rem;" onchange="loadIPs(0)"><option value="">Все</option></select></div>
<div><label style="font-size:0.75rem;color:#64748b;display:block;margin-bottom:2px;">Дата от</label><input type="date" id="visFiltDateFrom" style="width:100%;padding:6px 8px;border:1px solid #e5e7eb;border-radius:6px;font-size:0.85rem;" onchange="loadIPs(0)"></div>
<div><label style="font-size:0.75rem;color:#64748b;display:block;margin-bottom:2px;">Дата до</label><input type="date" id="visFiltDateTo" style="width:100%;padding:6px 8px;border:1px solid #e5e7eb;border-radius:6px;font-size:0.85rem;" onchange="loadIPs(0)"></div>
<div><label style="font-size:0.75rem;color:#64748b;display:block;margin-bottom:2px;">Страница</label><select id="visFiltPage" style="width:100%;padding:6px 8px;border:1px solid #e5e7eb;border-radius:6px;font-size:0.85rem;" onchange="loadIPs(0)"><option value="">Все</option></select></div>
<div style="display:flex;align-items:flex-end;"><button onclick="loadIPs(0)" style="padding:6px 16px;background:#c9a84c;color:#fff;border:none;border-radius:6px;font-size:0.85rem;cursor:pointer;">Найти</button></div>
</div>
<div id="visIPsTable" style="overflow-x: auto;"></div>
<div id="visIPsPagination" style="text-align:center;margin-top:12px;"></div>
</div>
</div>
</div>
</div>
</div>
<div class="modal-backdrop-custom" id="userModal">
<div class="modal-custom">
<div class="modal-header-custom">
<h3 id="userModalTitle">Добавить пользователя</h3>
<button class="modal-close" onclick="hideUserModal()">&times;</button>
</div>
<form id="userForm">
<div class="modal-body-custom">
<input type="hidden" id="editUserId">
<div class="mb-3">
<label class="form-label">Логин *</label>
<input type="text" class="form-control" id="userLogin" required>
</div>
<div class="mb-3">
<label class="form-label">Пароль <span id="passwordHint">*</span></label>
<input type="password" class="form-control" id="userPassword" minlength="4">
</div>
<div class="mb-3">
<label class="form-label">ФИО</label>
<input type="text" class="form-control" id="userFullName" placeholder="Иванов Иван Иванович">
</div>
<div class="mb-3">
<label class="form-label">Email</label>
<input type="email" class="form-control" id="userEmail" placeholder="email@example.com">
</div>
<div class="mb-3 admin-only-field">
<label class="form-label">Роль</label>
<select class="form-control" id="userRole">
<option value="user">Пользователь</option>
<option value="admin">Администратор</option>
</select>
</div>
</div>
<div class="modal-footer-custom">
<button type="button" class="btn btn-secondary btn-sm" onclick="hideUserModal()">Отмена</button>
<button type="submit" class="btn-primary-custom btn-sm">Сохранить</button>
</div>
</form>
</div>
</div>
<div class="modal-backdrop-custom" id="historyModal">
<div class="modal-custom" style="max-width: 600px;">
<div class="modal-header-custom">
<h3 id="historyModalTitle">История изменений</h3>
<button class="modal-close" onclick="hideHistoryModal()">&times;</button>
</div>
<div class="modal-body-custom" style="max-height: 400px; overflow-y: auto;">
<div id="historyContent"></div>
</div>
</div>
</div>
<div class="modal-backdrop-custom" id="editBookingModal">
<div class="modal-custom" style="max-width: 650px;">
<div class="modal-header-custom">
<h3>Заявка #<span id="editBookingNum"></span></h3>
<button class="modal-close" onclick="hideEditBookingModal()">&times;</button>
</div>
<div class="modal-body-custom">
<form id="editBookingForm">
<input type="hidden" id="editBookingId">
<div class="row g-2">
<div class="col-6"><label class="form-label">Имя</label><input type="text" class="form-control" id="editName" readonly></div>
<div class="col-6"><label class="form-label">Телефон</label><input type="text" class="form-control" id="editPhone" readonly></div>
</div>
<div class="row g-2 mt-2">
<div class="col-8"><label class="form-label">Номер</label><select class="form-select" id="editRoom"></select></div>
<div class="col-4"><label class="form-label">Статус</label><select class="form-select" id="editStatus"></select></div>
</div>
<div class="row g-2 mt-2">
<div class="col-3"><label class="form-label">Взр.</label><input type="number" class="form-control" id="editAdults" min="1"></div>
<div class="col-3"><label class="form-label">Дет.</label><input type="number" class="form-control" id="editChildren" min="0"></div>
<div class="col-6"><label class="form-label">Скидка %</label><input type="number" class="form-control" id="editDiscount" min="0" max="99"></div>
</div>
<div class="row g-2 mt-2">
<div class="col-5"><label class="form-label">Заезд</label><input type="date" class="form-control" id="editCheckin"></div>
<div class="col-2 d-flex align-items-end"><span id="editNights" style="display:block;text-align:center;background:#f0f4f8;padding:6px 8px;border-radius:12px;font-size:0.8rem;"></span></div>
<div class="col-5"><label class="form-label">Выезд</label><input type="date" class="form-control" id="editCheckout"></div>
</div>
<div class="mt-2"><label class="form-label">Пожелания</label><input type="text" class="form-control" id="editWishes" readonly></div>
<div class="mt-2"><label class="form-label">Комментарий</label><input type="text" class="form-control" id="editComment"></div>
<div class="mt-2" style="background:#f8fafc;border-radius:8px;padding:12px;">
<div style="display:flex;justify-content:space-between;font-size:0.85rem;"><span>Базовая цена:</span><strong id="editBasePrice"></strong></div>
<div style="display:flex;justify-content:space-between;font-size:0.85rem;color:#16a34a;"><span>Скидка:</span><strong id="editDiscountAmount"></strong></div>
<div style="display:flex;justify-content:space-between;font-size:1.1rem;font-weight:700;border-top:1px solid #e2e8f0;margin-top:8px;padding-top:8px;"><span>Итого:</span><strong id="editTotalPrice" style="color:var(--primary);"></strong></div>
</div>
<div class="mt-3 d-flex gap-2">
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="showHistoryForEdit()"><i class="fas fa-clock"></i> История</button>
<button type="submit" class="btn-primary-custom btn-sm ms-auto"><i class="fas fa-save"></i> Сохранить</button>
</div>
</form>
</div>
</div>
</div>
<div class="modal-backdrop-custom" id="restoreModal">
<div class="modal-custom">
<div class="modal-header-custom">
<h3><i class="fas fa-exclamation-triangle text-warning me-2"></i>Восстановление из бекапа</h3>
<button class="modal-close" onclick="hideRestoreModal()">&times;</button>
</div>
<div class="modal-body-custom">
<div class="alert alert-warning" style="background: #fef3c7; border: 1px solid #f59e0b; border-radius: 10px;">
<p class="mb-2"><strong>Внимание!</strong></p>
<p class="mb-2">Текущие данные будут заменены данными из бекапа.</p>
<p class="mb-0"><strong>Перед восстановлением будет создан аварийный бекап текущего состояния.</strong></p>
</div>
<div class="mt-3">
<p class="mb-1"><strong>Бэкап:</strong> <span id="restoreBackupFilename"></span></p>
<p class="mb-1"><strong>Дата создания:</strong> <span id="restoreBackupDate"></span></p>
<p class="mb-0"><strong>Размер:</strong> <span id="restoreBackupSize"></span></p>
</div>
<input type="hidden" id="restoreBackupId">
</div>
<div class="modal-footer-custom">
<button type="button" class="btn btn-secondary btn-sm" onclick="hideRestoreModal()">Отмена</button>
<button type="button" class="btn-danger-custom" onclick="confirmRestore()" id="btnConfirmRestore">
<i class="fas fa-undo me-1"></i>Восстановить
</button>
</div>
</div>
</div>
<div class="modal-backdrop-custom" id="promocodeModal">
<div class="modal-custom">
<div class="modal-header-custom">
<h3 id="promocodeModalTitle">Добавить промокод</h3>
<button class="modal-close" onclick="hidePromocodeModal()">&times;</button>
</div>
<form id="promocodeForm">
<input type="hidden" id="editPromocodeId">
<div class="modal-body-custom">
<div class="mb-3">
<label class="form-label">Код *</label>
<input type="text" class="form-control" id="promocodeCode" required placeholder="SUMMER2026" style="text-transform: uppercase;">
</div>
<div class="mb-3">
<label class="form-label">Скидка (%) * от 1 до 99</label>
<input type="number" class="form-control" id="promocodeDiscount" required min="1" max="99" value="10">
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label">Действует с</label>
<input type="datetime-local" class="form-control" id="promocodeValidFrom">
</div>
<div class="col-md-6">
<label class="form-label">Действует по</label>
<input type="datetime-local" class="form-control" id="promocodeValidTo">
</div>
</div>
<div class="mb-3">
<label class="form-label">Или период (дней от сейчас)</label>
<input type="number" class="form-control" id="promocodeValidDays" min="1" placeholder="Например: 30">
<small class="text-muted">Если указано, отменяет даты "с" и "по"</small>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="promocodeIsActive" checked>
<label class="form-check-label" for="promocodeIsActive">Активен</label>
</div>
</div>
<div class="modal-footer-custom">
<button type="button" class="btn btn-secondary btn-sm" onclick="hidePromocodeModal()">Отмена</button>
<button type="submit" class="btn-primary-custom btn-sm">Сохранить</button>
</div>
</form>
</div>
</div>
<script>
const API = '';
let token = localStorage.getItem('token');
let currentUser = JSON.parse(localStorage.getItem('currentUser') || 'null');
let editingActivityId = null;
let tempActivityImagePath = null;
let editingHeroMediaId = null;
let tempHeroImagePath = null;
let tempHeroVideoPath = null;
function getHeaders() { return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }; }
function showToast(msg, type = 'success') {
const c = document.getElementById('toastContainer');
const t = document.createElement('div');
t.className = 'toast ' + type;
t.innerHTML = '<span class="toast-icon"><i class="fas fa-' + (type === 'success' ? 'check-circle' : 'exclamation-circle') + '"></i></span><span>' + esc(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 === 'visitors') loadVisitors();
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) { console.error('loadDashboard reviews failed:', e); }
loadRevenueChart('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 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;
renderBookingCards();
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 applySearch() {
if (searchTimeout) { clearTimeout(searchTimeout); searchTimeout = null; }
loadBookings(true);
}
function resetSearch() {
document.getElementById('searchBookings').value = '';
loadBookings(true);
}
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 renderBookingCards() {
const grid = document.getElementById('bookingsGrid');
const rows = [...allBookingsData];
if (rows.length === 0) {
grid.innerHTML = '<div class="text-center text-muted" style="padding:40px;">Нет заявок</div>';
return;
}
const statuses = ['новая', 'оплачена', 'зарезервирована', 'заселена', 'выехала', 'отменена'];
grid.innerHTML = rows.map(r => {
const ciDiff = getDaysDiff(r.checkin_date);
const coDiff = getDaysDiff(r.checkout_date);
let cls = 'booking-card';
if (ciDiff >= 0 && ciDiff <= 3 && r.status !== 'отменена' && r.status !== 'выехала') cls += ' card-soon';
if (coDiff === 0 && r.status !== 'отменена' && r.status !== 'выехала') cls += ' card-today';
const n = calcNights(r.checkin_date, r.checkout_date);
const room = r.room_name ? r.room_type + ' — ' + r.room_name : (r.room_type || '—');
const created = r.created_at ? r.created_at.substring(0, 10) : '';
return '<div class="' + cls + '" onclick="editBooking(' + r.id + ')">' +
'<div class="card-bar card-bar-' + r.status + '"></div>' +
'<div class="card-inner">' +
'<div class="card-head">' +
'<span class="badge badge-status-' + r.status + '">' + r.status + '</span>' +
'<span class="card-id">#' + r.id + ' · ' + created + '</span>' +
'</div>' +
'<div class="card-name">' + esc(r.name) + '</div>' +
'<div class="card-phone"><i class="fas fa-phone fa-xs"></i> ' + esc(r.phone) + '</div>' +
'<div class="card-meta">' +
'<span><i class="fas fa-door-open fa-xs"></i> ' + esc(room) + '</span>' +
'<span><i class="fas fa-users fa-xs"></i> ' + r.adults + ' взр' + (r.children ? ' + ' + r.children + ' дет' : '') + '</span>' +
'</div>' +
'<div class="card-dates"><i class="fas fa-calendar-alt fa-xs"></i> ' + r.checkin_date + ' → ' + r.checkout_date + ' <span class="card-nights">· ' + n + ' ноч.</span></div>' +
'<div class="card-price-row">' +
(r.discount_percent ? '<span class="card-disc-badge">-' + r.discount_percent + '%</span>' : '') +
(r.base_price !== r.total_price ? '<span class="card-base-price">' + (r.base_price || 0) + ' ₽</span>' : '') +
'<span class="card-total">' + (r.total_price || r.base_price || 0) + ' ₽</span>' +
'</div>' +
(r.promocode_code ? '<div class="card-promo">🎫 ' + esc(r.promocode_code) + '</div>' : '') +
(r.comment ? '<div class="card-comment">💬 ' + esc(r.comment) + '</div>' : '') +
'<div class="card-actions" onclick="event.stopPropagation()">' +
'<select class="form-select form-select-sm card-status-select" onchange="changeStatus(' + r.id + ', this.value); event.stopPropagation();" style="max-width:120px;font-size:0.75rem;">' +
statuses.map(s => '<option value="' + s + '"' + (r.status === s ? ' selected' : '') + '>' + s + '</option>').join('') +
'</select>' +
'<button class="btn btn-outline-secondary btn-sm" onclick="showHistory(' + r.id + '); event.stopPropagation();" title="История"><i class="fas fa-clock"></i></button>' +
'</div>' +
'</div>' +
'</div>';
}).join('');
}
function calcNights(ci, co) { return Math.ceil((new Date(co) - new Date(ci)) / 86400000); }
let currentEditBooking = null;
function editBooking(id) {
currentEditBooking = allBookingsData.find(b => b.id === id);
var b = currentEditBooking;
if (!b) return;
document.getElementById('editBookingId').value = b.id;
document.getElementById('editBookingNum').textContent = b.id;
document.getElementById('editName').value = b.name;
document.getElementById('editPhone').value = b.phone;
document.getElementById('editAdults').value = b.adults;
document.getElementById('editChildren').value = b.children || 0;
document.getElementById('editCheckin').value = b.checkin_date;
document.getElementById('editCheckout').value = b.checkout_date;
document.getElementById('editNights').textContent = calcNights(b.checkin_date, b.checkout_date) + ' ноч.';
document.getElementById('editWishes').value = b.wishes || '';
document.getElementById('editComment').value = b.comment || '';
document.getElementById('editDiscount').value = b.discount_percent || 0;
document.getElementById('editBasePrice').textContent = (b.base_price || 0) + ' ₽';
document.getElementById('editDiscountAmount').textContent = (b.discount_amount || 0) + ' ₽';
document.getElementById('editTotalPrice').textContent = b.total_price || b.base_price || 0;
document.getElementById('editRoom').innerHTML = allRoomsData.map(r => '<option value="' + r.id + '"' + (b.room_id === r.id ? ' selected' : '') + '>' + r.type + ' — ' + r.name + '</option>').join('');
document.getElementById('editStatus').innerHTML = ['новая', 'оплачена', 'зарезервирована', 'заселена', 'выехала', 'отменена']
.map(s => '<option value="' + s + '"' + (b.status === s ? ' selected' : '') + '>' + s + '</option>').join('');
document.getElementById('editBookingModal').classList.add('show');
document.body.style.overflow = 'hidden';
}
function hideEditBookingModal() {
document.getElementById('editBookingModal').classList.remove('show');
document.body.style.overflow = '';
}
function showHistoryForEdit() {
if (currentEditBooking) showHistory(currentEditBooking.id);
}
document.getElementById('editBookingModal').addEventListener('click', function(e) { if (e.target === this) hideEditBookingModal(); });
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);
renderBookingCards(); 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);
renderBookingCards(); 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.querySelectorAll('.status-tab').forEach(tab => {
tab.addEventListener('click', function() {
document.querySelectorAll('.status-tab').forEach(t => t.classList.remove('active'));
this.classList.add('active');
document.getElementById('filterStatus').value = this.dataset.status;
loadBookings(true);
});
});
document.getElementById('editBookingForm').addEventListener('submit', async function(e) {
e.preventDefault();
var id = parseInt(document.getElementById('editBookingId').value);
var orig = currentEditBooking;
if (!orig) return;
try {
var roomId = parseInt(document.getElementById('editRoom').value) || null;
var status = document.getElementById('editStatus').value;
var comment = document.getElementById('editComment').value.trim();
var discount = parseInt(document.getElementById('editDiscount').value) || 0;
var adults = parseInt(document.getElementById('editAdults').value);
var children = parseInt(document.getElementById('editChildren').value);
var checkin = document.getElementById('editCheckin').value;
var checkout = document.getElementById('editCheckout').value;
if (roomId !== orig.room_id) {
var d = await api('/api/admin/bookings/' + id + '/room', { method: 'PATCH', body: JSON.stringify({ room_id: roomId }) });
orig = d.booking;
}
if (status !== orig.status) {
var d = await api('/api/admin/bookings/' + id, { method: 'PATCH', body: JSON.stringify({ status: status }) });
orig = d.booking;
}
if (comment !== (orig.comment || '')) {
var d = await api('/api/admin/bookings/' + id + '/comment', { method: 'PATCH', body: JSON.stringify({ comment: comment }) });
orig = d.booking;
}
if (discount !== (orig.discount_percent || 0)) {
var d = await api('/api/admin/bookings/' + id + '/discount', { method: 'PATCH', body: JSON.stringify({ discount_percent: discount }) });
orig = d.booking;
}
if (adults !== orig.adults || children !== orig.children || checkin !== orig.checkin_date || checkout !== orig.checkout_date) {
var d = await api('/api/admin/bookings/' + id + '/details', { method: 'PATCH', body: JSON.stringify({ adults: adults, children: children, checkin_date: checkin, checkout_date: checkout }) });
orig = d.booking;
}
allBookingsData = allBookingsData.map(b => b.id === id ? orig : b);
hideEditBookingModal();
renderBookingCards();
updateBookingStats();
showToast('Заявка обновлена');
} catch(err) { showToast(err.message, 'error'); }
});
});
function loadProfile() {
document.getElementById('profileFullName').value = currentUser.full_name || '';
document.getElementById('profileEmail').value = currentUser.email || '';
document.getElementById('sidebarUserName').textContent = currentUser.full_name || currentUser.login;
document.getElementById('sidebarUserRole').textContent = currentUser.role === 'admin' ? 'Администратор' : 'Пользователь';
}
document.getElementById('profileForm').addEventListener('submit', async e => {
e.preventDefault();
try {
const data = await api('/api/auth/me', {
method: 'PUT',
body: JSON.stringify({ full_name: document.getElementById('profileFullName').value, email: document.getElementById('profileEmail').value })
});
currentUser = data.user; localStorage.setItem('currentUser', JSON.stringify(currentUser));
loadProfile(); updateUI(); showToast('Профиль обновлён');
} catch(err) { showToast(err.message, 'error'); }
});
document.getElementById('passwordForm').addEventListener('submit', async e => {
e.preventDefault();
const np = document.getElementById('newPassword').value;
if (np !== document.getElementById('confirmPassword').value) { showToast('Пароли не совпадают', 'error'); return; }
try {
await api('/api/auth/change-password', {
method: 'POST',
body: JSON.stringify({ current_password: document.getElementById('currentPassword').value, new_password: np })
});
showToast('Пароль изменён'); e.target.reset();
} catch(err) { showToast(err.message, 'error'); }
});
function showUserModal(id) {
document.getElementById('userModal').classList.add('show');
document.getElementById('userForm').reset();
document.getElementById('editUserId').value = '';
document.getElementById('userModalTitle').textContent = 'Добавить пользователя';
document.getElementById('userLogin').disabled = false;
document.getElementById('passwordHint').textContent = '*';
document.getElementById('userPassword').required = true;
if (id) {
api('/api/admin/users').then(users => {
const u = users.find(x => x.id === id);
if (u) {
document.getElementById('editUserId').value = u.id;
document.getElementById('userLogin').value = u.login;
document.getElementById('userLogin').disabled = true;
document.getElementById('userFullName').value = u.full_name || '';
document.getElementById('userEmail').value = u.email || '';
document.getElementById('userRole').value = u.role;
document.getElementById('userModalTitle').textContent = 'Редактировать пользователя';
document.getElementById('passwordHint').textContent = '(оставьте пустым, если не меняете)';
document.getElementById('userPassword').required = false;
}
});
}
}
function hideUserModal() { document.getElementById('userModal').classList.remove('show'); }
document.getElementById('userForm').addEventListener('submit', async e => {
e.preventDefault();
const id = document.getElementById('editUserId').value;
const body = {
login: document.getElementById('userLogin').value,
full_name: document.getElementById('userFullName').value,
email: document.getElementById('userEmail').value,
role: document.getElementById('userRole').value
};
const pw = document.getElementById('userPassword').value;
if (pw) body.password = pw;
try {
if (id) {
await api('/api/admin/users/' + id, { method: 'PUT', body: JSON.stringify(body) });
showToast('Пользователь обновлён');
} else {
if (!pw) { showToast('Пароль обязателен', 'error'); return; }
await api('/api/admin/users', { method: 'POST', body: JSON.stringify(body) });
showToast('Пользователь создан');
}
hideUserModal(); loadUsers();
} catch(err) { showToast(err.message, 'error'); }
});
async function deleteUser(id) {
if (!confirm('Удалить пользователя?')) return;
try { await api('/api/admin/users/' + id, { method: 'DELETE' }); showToast('Пользователь удалён'); loadUsers(); }
catch(err) { showToast(err.message, 'error'); }
}
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function escAttr(s) { if (s === null || s === undefined) return ''; return String(s).replace(/'/g, "\\'").replace(/"/g, '&quot;'); }
async function changeComment(id, comment) {
try {
const data = await api('/api/admin/bookings/' + id + '/comment', { method: 'PATCH', body: JSON.stringify({ comment }) });
allBookingsData = allBookingsData.map(b => b.id === id ? data.booking : b);
showToast('Комментарий обновлён');
} catch(err) { showToast(err.message, 'error'); }
}
async function changeDiscount(id, discount_percent) {
try {
const data = await api('/api/admin/bookings/' + id + '/discount', { method: 'PATCH', body: JSON.stringify({ discount_percent: parseInt(discount_percent) }) });
allBookingsData = allBookingsData.map(b => b.id === id ? data.booking : b);
renderBookingCards();
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);
renderBookingCards(); 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
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 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 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) { console.error('loadRoomImages failed:', 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 lastDay = new Date(y, m, 0);
const dayNames = ['Вс','Пн','Вт','Ср','Чт','Пт','Сб'];
let html = '<div class="calendar-cards">';
for (let d = 1; d <= lastDay.getDate(); d++) {
const dateStr = `${month}-${String(d).padStart(2,'0')}`;
const dow = new Date(y, m-1, d).getDay();
const dayName = dayNames[dow];
let hasData = false;
let roomsHtml = '';
rooms.forEach(room => {
const dayData = days[dateStr] ? days[dateStr][room.type] : null;
const booked = dayData ? dayData.booked : 0;
const total = room.rooms_count || 1;
const ratio = total > 0 ? booked / total : 0;
let bg = '#dcfce7';
if (ratio >= 1) bg = '#fee2e2';
else if (ratio >= 0.7) bg = '#fef3c7';
const pct = Math.round(ratio * 100);
if (booked > 0) hasData = true;
roomsHtml += `<div class="cal-room-row" style="background:${bg};cursor:pointer;" onclick="showCalendarDay('${dateStr}','${esc(room.type)}')">
<span class="cal-room-type">${esc(room.type)}</span>
<span class="cal-room-bar"><span class="cal-room-fill" style="width:${pct}%;background:${ratio >= 1 ? '#ef4444' : ratio >= 0.7 ? '#f59e0b' : '#22c55e'}"></span></span>
<span class="cal-room-count">${booked}/${total}</span>
</div>`;
});
html += `<div class="cal-card">
<div class="cal-card-header">${d} ${dayName}</div>
<div class="cal-card-rooms">${roomsHtml}</div>
</div>`;
}
html += '</div>';
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) { console.error('loadSecurityKeys failed:', 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) { console.error('loadEmailSettings failed:', e); }
}
async function saveEmailSettings() {
const data = {
smtp_host: document.getElementById('smtpHost').value,
smtp_port: document.getElementById('smtpPort').value,
smtp_secure: document.getElementById('smtpSecure').value,
smtp_user: document.getElementById('smtpUser').value,
smtp_pass: document.getElementById('smtpPass').value,
smtp_from: document.getElementById('smtpFrom').value,
admin_email: document.getElementById('adminEmail').value,
email_notifications_enabled: document.getElementById('emailEnabled').checked ? 'true' : 'false'
};
try {
await api('/api/admin/settings/email', { method: 'PUT', body: JSON.stringify(data) });
showToast('Настройки email сохранены');
} catch(err) { showToast(err.message, 'error'); }
}
async function sendTestEmail() {
try {
const result = await api('/api/admin/settings/email/test', { method: 'POST' });
showToast(result.message);
} catch(err) { showToast(err.message, 'error'); }
}
// Phone settings
async function loadPhoneSettings() {
try {
const data = await api('/api/admin/settings/phones');
document.getElementById('phonePrimary').value = data.primary || '';
document.getElementById('phoneAdditional').value = data.additional || '';
document.getElementById('phoneWhatsappNum').value = data.whatsappNum || '';
document.getElementById('phoneWhatsappActive').checked = data.whatsappActive === 'true';
document.getElementById('phoneTelegramId').value = data.telegramId || '';
document.getElementById('phoneTelegramActive').checked = data.telegramActive === 'true';
document.getElementById('phoneMaxLink').value = data.maxLink || '';
document.getElementById('phoneMaxActive').checked = data.maxActive === 'true';
document.getElementById('phoneInstagramId').value = data.instagramId || '';
document.getElementById('phoneInstagramActive').checked = data.instagramActive === 'true';
} catch(e) { console.error('loadPhoneSettings failed:', e); }
}
async function savePhoneSettings() {
const data = {
primary: document.getElementById('phonePrimary').value.trim(),
additional: document.getElementById('phoneAdditional').value.trim(),
whatsappNum: document.getElementById('phoneWhatsappNum').value.trim(),
whatsappActive: document.getElementById('phoneWhatsappActive').checked ? 'true' : 'false',
telegramId: document.getElementById('phoneTelegramId').value.trim(),
telegramActive: document.getElementById('phoneTelegramActive').checked ? 'true' : 'false',
maxLink: document.getElementById('phoneMaxLink').value.trim(),
maxActive: document.getElementById('phoneMaxActive').checked ? 'true' : 'false',
instagramId: document.getElementById('phoneInstagramId').value.trim(),
instagramActive: document.getElementById('phoneInstagramActive').checked ? 'true' : 'false'
};
if (!data.primary) {
showToast('Основной номер обязателен', 'error');
return;
}
try {
await api('/api/admin/settings/phones', { method: 'PUT', body: JSON.stringify(data) });
showToast('Настройки телефонов сохранены');
} catch(err) { showToast(err.message, 'error'); }
}
// Telegram notification settings
async function loadTelegramSettings() {
try {
const data = await api('/api/admin/settings/telegram');
document.getElementById('telegramBotToken').value = data.telegram_bot_token_masked || '';
document.getElementById('telegramNotifyUsers').value = data.telegram_notify_users || '';
document.getElementById('telegramEnabled').checked = data.telegram_notify_enabled === 'true';
document.getElementById('telegramApiUrl').value = data.telegram_api_url || 'https://api.telegram.org';
updateTelegramStatus(data.telegram_proxy_status || 'unknown');
} catch(e) { console.error('loadTelegramSettings failed:', e); }
}
function updateTelegramStatus(status) {
const el = document.getElementById('telegramStatus');
if (!el) return;
const map = {
direct: { cls: 'bg-success', txt: 'Подключение: напрямую' },
proxied: { cls: 'bg-warning text-dark', txt: 'Подключение: через прокси' },
blocked: { cls: 'bg-danger', txt: 'Подключение: заблокирован' },
unknown: { cls: 'bg-secondary', txt: 'Подключение: —' }
};
const info = map[status] || map.unknown;
el.className = 'badge ' + info.cls;
el.textContent = info.txt;
}
async function saveTelegramSettings() {
const tokenVal = document.getElementById('telegramBotToken').value;
const token = tokenVal.startsWith('****') ? '' : tokenVal.trim();
const enabled = document.getElementById('telegramEnabled').checked;
const usersStr = document.getElementById('telegramNotifyUsers').value.trim();
const usersList = usersStr ? usersStr.split(',').map(s => s.trim()).filter(s => s) : [];
const data = {
telegram_bot_token: token,
telegram_notify_users: usersStr,
telegram_notify_enabled: enabled ? 'true' : 'false',
telegram_api_url: document.getElementById('telegramApiUrl').value.trim()
};
try {
await api('/api/admin/settings/telegram', { method: 'PUT', body: JSON.stringify(data) });
let msg = 'Настройки Telegram сохранены';
if (enabled && usersList.length > 0) {
msg += `. Уведомления включены, получателей: ${usersList.length}`;
} else if (!enabled) {
msg += '. Уведомления выключены';
} else {
msg += '. Не указаны получатели — уведомления не будут отправляться';
}
showToast(msg, 'success');
loadTelegramSettings();
} catch(err) { showToast('Ошибка при сохранении: ' + err.message, 'error'); }
}
async function sendTestTelegram() {
const btn = document.getElementById('btnTelegramTest');
const originalText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Отправка...';
try {
const result = await api('/api/admin/settings/telegram/test', { method: 'POST' });
if (result.errors && result.errors.length > 0) {
showToast(result.message, 'error');
} else {
showToast(result.message || 'Тест отправлен', 'success');
}
} catch(err) {
let errMsg = err.message;
if (errMsg.includes('chat not found') || errMsg.includes('chat_id')) {
errMsg = 'Ошибка: указанный chat_id не найден. Пользователь должен сначала написать боту любое сообщение.';
} else if (errMsg.includes('token') || errMsg.includes('unauthorized')) {
errMsg = 'Ошибка: неверный токен бота. Проверьте токен, полученный от @BotFather.';
}
showToast('Ошибка отправки: ' + errMsg, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = originalText;
}
}
// Export
async function exportCSV() {
const status = document.getElementById('filterStatus').value;
const params = new URLSearchParams();
if (status && status !== 'all') params.set('status', status);
window.location.href = API + '/api/admin/export/bookings?' + params.toString();
}
// Update loadSettings
async function loadSettings() {
try {
const data = await api('/api/admin/settings');
document.getElementById('currentCodeDisplay').value = data.review_code || '';
} catch(e) { console.error('loadSettings review_code failed:', 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;
}
loadBackupsList();
} catch(e) { console.error('loadSettings backup failed:', e); }
loadSecurityKeys();
loadEmailSettings();
loadPhoneSettings();
loadTelegramSettings();
}
// Show room modal with images
async function showRoomModal(id) {
editingRoomId = id || null;
roomImagesToUpload = [];
document.getElementById('roomModalTitle').textContent = id ? 'Редактировать номер' : 'Добавить номер';
document.getElementById('roomForm').reset();
document.getElementById('roomImagesContainer').innerHTML = '';
document.getElementById('roomIsActive').checked = true;
if (id) {
try {
const rooms = await api('/api/admin/rooms');
const room = rooms.find(r => r.id === id);
if (room) {
document.getElementById('roomName').value = room.name;
document.getElementById('roomType').value = room.type;
document.getElementById('roomDescription').value = room.description || '';
document.getElementById('roomPrice').value = room.price_per_night;
document.getElementById('roomArea').value = room.area_sqm || 20;
document.getElementById('roomMaxGuests').value = room.max_guests || 2;
document.getElementById('roomCount').value = room.rooms_count || 1;
document.getElementById('roomFloors').value = (room.floors || []).join(', ');
document.getElementById('roomExtraBeds').value = room.extra_beds || 0;
document.getElementById('roomExtraBedPrice').value = room.extra_bed_price || 0;
document.getElementById('roomIsActive').checked = room.is_active === 1;
document.querySelectorAll('[name="furniture"]').forEach(cb => { cb.checked = (room.furniture || []).includes(cb.value); });
document.querySelectorAll('[name="amenities"]').forEach(cb => { cb.checked = (room.amenities || []).includes(cb.value); });
if (room.images && room.images.length > 0) {
renderRoomImages(room.images);
}
}
} catch(e) { showToast(e.message, 'error'); }
}
document.getElementById('roomModal').classList.add('show');
}
// Hero Media Admin
function loadHeroMediaAdmin() {
api('/api/admin/hero').then(data => {
renderHeroMediaCards(data);
}).catch(err => showToast(err.message, 'error'));
}
function renderHeroMediaCards(items) {
const grid = document.getElementById('heroMediaGrid');
if (!items || items.length === 0) {
grid.innerHTML = '<div style="grid-column: 1/-1; text-align: center; padding: 40px; color: #94a3b8;"><i class="fas fa-image" style="font-size: 2rem; display: block; margin-bottom: 12px;"></i>Нет медиа. Добавьте картинку или видео для главного экрана.</div>';
return;
}
grid.innerHTML = items.map(item => {
const isVideo = item.type === 'video';
const isYouTube = isVideo && item.path && (item.path.includes('youtube') || item.path.includes('youtu.be'));
const thumbStyle = isVideo
? (isYouTube ? `background: #ff0000;` : `background: #1e293b;`)
: `background-image: url('${item.path}'); background-size: cover; background-position: center;`;
const icon = isVideo ? '<i class="fas fa-play" style="color: #fff; font-size: 1.5rem;"></i>' : '';
const typeLabel = isVideo ? (isYouTube ? 'YouTube' : 'Видео') : 'Картинка';
const activeClass = item.is_active ? 'hero-active-on' : 'hero-active-off';
const activeText = item.is_active ? 'Активно' : 'Скрыто';
return `
<div class="hero-media-card" style="background: #fff; border-radius: 12px; border: 1px solid #e2e8f0; overflow: hidden;">
<div style="height: 180px; ${thumbStyle} display: flex; align-items: center; justify-content: center; background-color: #f1f5f9;">
${icon}
</div>
<div style="padding: 12px 16px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<span class="badge" style="background: ${isVideo ? '#ef4444' : '#3b82f6'}; color: #fff; font-size: 0.7rem;">${typeLabel}</span>
<span style="font-size: 0.75rem; color: #94a3b8;">Порядок: ${item.sort_order}</span>
</div>
<div style="font-size: 0.75rem; color: #64748b; margin-bottom: 8px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${item.path}</div>
<div style="display: flex; gap: 4px; flex-wrap: wrap;">
<button class="btn-primary-custom btn-sm" style="padding: 4px 10px; font-size: 0.75rem;" onclick="editHeroMedia(${item.id})"><i class="fas fa-edit"></i></button>
<button class="btn-gold btn-sm" style="padding: 4px 10px; font-size: 0.75rem;" onclick="toggleHeroActive(${item.id}, ${item.is_active ? 0 : 1})">
<span class="${activeClass}" style="font-size: 0.7rem;">${activeText}</span>
</button>
<button class="btn btn-danger btn-sm" style="padding: 4px 10px; font-size: 0.75rem;" onclick="deleteHeroMedia(${item.id})"><i class="fas fa-trash"></i></button>
</div>
</div>
</div>`;
}).join('');
}
function showHeroMediaModal(id) {
editingHeroMediaId = id || null;
tempHeroImagePath = null;
tempHeroVideoPath = null;
document.getElementById('heroMediaModalTitle').textContent = id ? 'Редактировать медиа' : 'Добавить медиа';
document.getElementById('heroMediaType').value = 'image';
document.getElementById('heroMediaPath').value = '';
document.getElementById('heroMediaSortOrder').value = '';
document.getElementById('heroMediaImageInput').value = '';
document.getElementById('heroMediaVideoInput').value = '';
document.getElementById('heroMediaImagePreview').innerHTML = '';
document.getElementById('heroMediaUrlGroup').style.display = 'none';
document.getElementById('heroMediaImageGroup').style.display = 'block';
document.getElementById('heroMediaVideoGroup').style.display = 'none';
document.getElementById('heroMediaDeleteBtn').style.display = id ? 'inline-block' : 'none';
if (id) {
api('/api/admin/hero').then(data => {
const item = data.find(m => m.id === id);
if (!item) return;
document.getElementById('heroMediaType').value = item.type;
onHeroTypeChange();
if (item.type === 'video') {
if (item.path.startsWith('http')) {
document.getElementById('heroMediaPath').value = item.path;
document.getElementById('heroMediaUrlGroup').style.display = 'block';
} else {
tempHeroVideoPath = item.path;
document.getElementById('heroMediaVideoGroup').style.display = 'block';
}
} else {
tempHeroImagePath = item.path;
const img = document.createElement('img');
img.src = item.path;
img.style.maxWidth = '200px'; img.style.borderRadius = '8px';
document.getElementById('heroMediaImagePreview').appendChild(img);
}
document.getElementById('heroMediaSortOrder').value = item.sort_order;
}).catch(err => showToast(err.message, 'error'));
}
document.getElementById('heroMediaModal').classList.add('show');
}
function closeHeroMediaModal() {
document.getElementById('heroMediaModal').classList.remove('show');
editingHeroMediaId = null;
tempHeroImagePath = null;
tempHeroVideoPath = null;
}
function onHeroTypeChange() {
const type = document.getElementById('heroMediaType').value;
document.getElementById('heroMediaImageGroup').style.display = type === 'image' ? 'block' : 'none';
document.getElementById('heroMediaVideoGroup').style.display = type === 'video' ? 'block' : 'none';
document.getElementById('heroMediaUrlGroup').style.display = 'none';
}
function showHeroUrlField() {
document.getElementById('heroMediaUrlGroup').style.display = 'block';
}
function uploadHeroImage() {
const input = document.getElementById('heroMediaImageInput');
const file = input.files[0];
if (!file) return showToast('Выберите файл', 'error');
const formData = new FormData();
formData.append('image', file);
fetch(API + '/api/admin/hero/upload-image', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token },
body: formData
})
.then(r => r.json().then(d => ({ status: r.status, body: d })))
.then(({ status, body }) => {
if (status !== 200) throw new Error(body.error || 'Upload failed');
tempHeroImagePath = body.path;
const preview = document.getElementById('heroMediaImagePreview');
preview.innerHTML = '';
const img = document.createElement('img');
img.src = body.path;
img.style.maxWidth = '200px'; img.style.borderRadius = '8px';
preview.appendChild(img);
showToast('Картинка загружена');
})
.catch(err => showToast(err.message, 'error'));
}
function uploadHeroVideo() {
const input = document.getElementById('heroMediaVideoInput');
const file = input.files[0];
if (!file) return showToast('Выберите файл', 'error');
const formData = new FormData();
formData.append('video', file);
fetch(API + '/api/admin/hero/upload-video', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token },
body: formData
})
.then(r => r.json().then(d => ({ status: r.status, body: d })))
.then(({ status, body }) => {
if (status !== 200) throw new Error(body.error || 'Upload failed');
tempHeroVideoPath = body.path;
showToast('Видео загружено');
})
.catch(err => showToast(err.message, 'error'));
}
function saveHeroMedia() {
const type = document.getElementById('heroMediaType').value;
const urlPath = document.getElementById('heroMediaPath').value.trim();
const sortOrder = parseInt(document.getElementById('heroMediaSortOrder').value) || 0;
let mediaPath;
if (type === 'image') {
mediaPath = tempHeroImagePath || (editingHeroMediaId ? undefined : null);
} else {
mediaPath = urlPath || tempHeroVideoPath || (editingHeroMediaId ? undefined : null);
}
if (!editingHeroMediaId && !mediaPath) {
return showToast('Загрузите файл или укажите URL', 'error');
}
const body = { type, sort_order: sortOrder };
if (mediaPath) body.path = mediaPath;
const method = editingHeroMediaId ? 'PUT' : 'POST';
const url = editingHeroMediaId ? `/api/admin/hero/${editingHeroMediaId}` : '/api/admin/hero';
api(url, { method, body: JSON.stringify(body) })
.then(() => {
closeHeroMediaModal();
loadHeroMediaAdmin();
showToast(editingHeroMediaId ? 'Медиа обновлено' : 'Медиа добавлено');
})
.catch(err => showToast(err.message, 'error'));
}
function editHeroMedia(id) {
showHeroMediaModal(id);
}
function toggleHeroActive(id, isActive) {
api(`/api/admin/hero/${id}`, {
method: 'PUT',
body: JSON.stringify({ is_active: isActive })
})
.then(() => { loadHeroMediaAdmin(); showToast(isActive ? 'Медиа активировано' : 'Медиа скрыто'); })
.catch(err => showToast(err.message, 'error'));
}
function deleteHeroMedia(id) {
if (!confirm('Удалить это медиа?')) return;
api(`/api/admin/hero/${id}`, { method: 'DELETE' })
.then(() => { loadHeroMediaAdmin(); showToast('Медиа удалено'); })
.catch(err => showToast(err.message, 'error'));
}
async function loadVisitors() {
try {
const stats = await api('/api/admin/visitors/stats');
document.getElementById('visTotal').textContent = stats.total;
document.getElementById('visToday').textContent = stats.today;
document.getElementById('visWeek').textContent = stats.week;
document.getElementById('visMonth').textContent = stats.month;
document.getElementById('visPageViews').textContent = stats.pageViews;
document.getElementById('visUniqueIps').textContent = stats.uniqueIps;
const countries = await api('/api/admin/visitors/countries');
const ct = document.getElementById('visCountriesTable');
if (countries.length === 0) {
ct.innerHTML = '<div style="text-align:center;color:#94a3b8;padding:20px;">Нет данных</div>';
} else {
ct.innerHTML = '<table style="width:100%;border-collapse:collapse;"><thead><tr><th style="text-align:left;padding:8px;border-bottom:2px solid #e5e7eb;"><3E><>трана</th><th style="text-align:right;padding:8px;border-bottom:2px solid #e5e7eb;"><3E><>осетителей</th></tr></thead><tbody>' +
countries.map(c => `<tr><td style="padding:6px 8px;border-bottom:1px solid #f1f5f9;">${c.country || '—'} <span style="color:#94a3b8;font-size:0.75rem;">${c.country_code || ''}</span></td><td style="text-align:right;font-weight:600;padding:6px 8px;border-bottom:1px solid #f1f5f9;">${c.count}</td></tr>`).join('') +
'</tbody></table>';
}
const langs = await api('/api/admin/visitors/languages');
const lt = document.getElementById('visLanguagesTable');
if (langs.length === 0) {
lt.innerHTML = '<div style="text-align:center;color:#94a3b8;padding:20px;">Нет данных</div>';
} else {
lt.innerHTML = '<table style="width:100%;border-collapse:collapse;"><thead><tr><th style="text-align:left;padding:8px;border-bottom:2px solid #e5e7eb;">Язык</th><th style="text-align:right;padding:8px;border-bottom:2px solid #e5e7eb;">Посетителей</th></tr></thead><tbody>' +
langs.map(l => `<tr><td style="padding:6px 8px;border-bottom:1px solid #f1f5f9;">${l.raw || l.lang || '—'}</td><td style="text-align:right;font-weight:600;padding:6px 8px;border-bottom:1px solid #f1f5f9;">${l.count}</td></tr>`).join('') +
'</tbody></table>';
}
const daily = await api('/api/admin/visitors/daily');
if (daily.length > 0) {
let html = '<table style="width:100%;border-collapse:collapse;font-size:0.9rem;"><thead><tr><th style="text-align:left;padding:8px;border-bottom:2px solid #e5e7eb;">Дата</th><th style="text-align:right;padding:8px;border-bottom:2px solid #e5e7eb;">Посетителей</th><th style="text-align:right;padding:8px;border-bottom:2px solid #e5e7eb;">Просмотров</th></tr></thead><tbody>';
daily.reverse().forEach(d => {
html += `<tr><td style="padding:5px 8px;border-bottom:1px solid #f1f5f9;">${d.visit_date}</td><td style="text-align:right;font-weight:600;padding:5px 8px;border-bottom:1px solid #f1f5f9;">${d.count}</td><td style="text-align:right;padding:5px 8px;border-bottom:1px solid #f1f5f9;">${d.page_views}</td></tr>`;
});
html += '</tbody></table>';
document.getElementById('visDailyChart').innerHTML = html;
}
await loadFilterOptions();
loadIPs(0);
} catch (err) {
showToast(err.message, 'error');
}
}
function fillSelect(id, items) {
const sel = document.getElementById(id);
if (!sel) return;
const val = sel.value;
sel.innerHTML = '<option value="">Все</option>';
items.forEach(item => {
const opt = document.createElement('option');
opt.value = item;
opt.textContent = item;
sel.appendChild(opt);
});
if (val) sel.value = val;
}
async function loadFilterOptions() {
try {
const data = await api('/api/admin/visitors/filters');
fillSelect('visFiltCountry', data.countries || []);
fillSelect('visFiltLang', data.languages || []);
fillSelect('visFiltPage', data.pages || []);
} catch (e) {
// silently fail, filters stay as "Все"
}
}
async function loadIPs(offset) {
const limit = 50;
document.getElementById('visIPsTable').innerHTML = '<div style="text-align:center;color:#94a3b8;padding:20px;">Загрузка...</div>';
try {
const filters = {
id: document.getElementById('visFiltId').value.trim(),
ip: document.getElementById('visFiltIp').value.trim(),
country: document.getElementById('visFiltCountry').value.trim(),
lang: document.getElementById('visFiltLang').value.trim(),
date_from: document.getElementById('visFiltDateFrom').value,
date_to: document.getElementById('visFiltDateTo').value,
page: document.getElementById('visFiltPage').value.trim()
};
let qs = `limit=${limit}&offset=${offset}`;
Object.keys(filters).forEach(k => {
if (filters[k]) qs += '&' + k + '=' + encodeURIComponent(filters[k]);
});
const data = await api('/api/admin/visitors/ips?' + qs);
if (data.entries.length === 0) {
document.getElementById('visIPsTable').innerHTML = '<div style="text-align:center;color:#94a3b8;padding:20px;">Нет данных</div>';
document.getElementById('visIPsPagination').innerHTML = '';
return;
}
let html = '<table style="width:100%;border-collapse:collapse;font-size:0.85rem;"><thead><tr>' +
'<th style="text-align:left;padding:6px 8px;border-bottom:2px solid #e5e7eb;">IP</th>' +
'<th style="text-align:left;padding:6px 8px;border-bottom:2px solid #e5e7eb;">Страна</th>' +
'<th style="text-align:left;padding:6px 8px;border-bottom:2px solid #e5e7eb;">Язык</th>' +
'<th style="text-align:left;padding:6px 8px;border-bottom:2px solid #e5e7eb;">Дата</th>' +
'<th style="text-align:left;padding:6px 8px;border-bottom:2px solid #e5e7eb;">Страница</th>' +
'</tr></thead><tbody>';
data.entries.forEach(e => {
html += `<tr>` +
`<td style="padding:4px 8px;border-bottom:1px solid #f1f5f9;font-family:monospace;">${esc(e.ip)}</td>` +
`<td style="padding:4px 8px;border-bottom:1px solid #f1f5f9;">${esc(e.country || '-')} <span style="color:#94a3b8;font-size:0.7rem;">${esc(e.country_code || '')}</span></td>` +
`<td style="padding:4px 8px;border-bottom:1px solid #f1f5f9;">${esc(e.accept_language || '-')}</td>` +
`<td style="padding:4px 8px;border-bottom:1px solid #f1f5f9;white-space:nowrap;">${e.created_at ? e.created_at.substring(0, 16) : e.visit_date}</td>` +
`<td style="padding:4px 8px;border-bottom:1px solid #f1f5f9;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:0.8rem;color:#64748b;" title="${esc(e.page_path)}">${esc(e.page_path)}</td>` +
`</tr>`;
});
html += '</tbody></table>';
document.getElementById('visIPsTable').innerHTML = html;
const totalPages = Math.ceil(data.total / limit);
const currentPage = Math.floor(offset / limit) + 1;
let paginationHtml = `<span style="color:#94a3b8;font-size:0.85rem;">Строк: ${data.total} · Стр. ${currentPage} из ${totalPages}</span> `;
if (offset > 0) {
paginationHtml += `<button onclick="loadIPs(${Math.max(0, offset - limit)})" style="padding:4px 12px;border:1px solid #e5e7eb;border-radius:6px;background:#fff;cursor:pointer;margin:0 4px;">← Назад</button>`;
}
if (offset + limit < data.total) {
paginationHtml += `<button onclick="loadIPs(${offset + limit})" style="padding:4px 12px;border:1px solid #e5e7eb;border-radius:6px;background:#fff;cursor:pointer;margin:0 4px;">Вперёд →</button>`;
}
document.getElementById('visIPsPagination').innerHTML = paginationHtml;
} catch (err) {
document.getElementById('visIPsTable').innerHTML = '<div style="text-align:center;color:#ef4444;padding:20px;">Ошибка загрузки</div>';
}
}
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
var modals = document.querySelectorAll('.modal-backdrop-custom.show');
modals.forEach(function(m) { m.classList.remove('show'); });
if (modals.length > 0) document.body.style.overflow = '';
}
});
</script>
<!-- Room Edit Modal -->
<div class="modal-backdrop-custom" id="roomModal">
<div class="modal-custom" style="max-width: min(95vw, 700px);">
<div class="modal-header-custom">
<h3 id="roomModalTitle">Добавить номер</h3>
<button class="modal-close" onclick="closeRoomModal()">&times;</button>
</div>
<form id="roomForm">
<div class="modal-body-custom" style="max-height: 70vh; overflow-y: auto;">
<div class="room-modal-grid-2col" style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px;">
<div class="mb-3">
<label class="form-label">Название *</label>
<input type="text" class="form-control" id="roomName" required placeholder="2x-местный 1">
</div>
<div class="mb-3">
<label class="form-label">Тип номера *</label>
<select class="form-control" id="roomType">
<option value="2x-местный">2x-местный</option>
<option value="3х-местный">3х-местный</option>
<option value="Семейный">Семейный</option>
<option value="Люкс">Люкс</option>
</select>
</div>
</div>
<div class="mb-3">
<label class="form-label">Описание</label>
<textarea class="form-control" id="roomDescription" rows="3" placeholder="Описание номера..."></textarea>
</div>
<div class="room-modal-grid-4col" style="display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 12px;">
<div class="mb-3">
<label class="form-label">Цена/ночь (₽) *</label>
<input type="number" class="form-control" id="roomPrice" required min="0" placeholder="1500">
</div>
<div class="mb-3">
<label class="form-label">Площадь (м²)</label>
<input type="number" class="form-control" id="roomArea" min="1" placeholder="20">
</div>
<div class="mb-3">
<label class="form-label">Макс. гостей</label>
<input type="number" class="form-control" id="roomMaxGuests" min="1" max="10" placeholder="2">
</div>
<div class="mb-3">
<label class="form-label">Кол-во номеров</label>
<input type="number" class="form-control" id="roomCount" min="1" max="50" placeholder="1">
</div>
</div>
<div class="room-modal-grid-3col" style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px;">
<div class="mb-3">
<label class="form-label">Этажи</label>
<input type="text" class="form-control" id="roomFloors" placeholder="1, 2, 3">
</div>
<div class="mb-3">
<label class="form-label">Доп. места (макс)</label>
<input type="number" class="form-control" id="roomExtraBeds" min="0" max="50" placeholder="0">
</div>
<div class="mb-3">
<label class="form-label">Цена доп. места (₽)</label>
<input type="number" class="form-control" id="roomExtraBedPrice" min="0" placeholder="1000">
</div>
</div>
<div class="mb-3">
<label class="form-label">Изображения</label>
<div id="roomImagesContainer" style="display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 8px;"></div>
<div style="display: flex; gap: 8px; align-items: center;">
<input type="file" class="form-control" id="roomImagesInput" accept="image/*" multiple style="font-size: 0.85rem; max-width: 300px;">
<button type="button" class="btn btn-primary btn-sm" onclick="uploadRoomImages()"><i class="fas fa-upload"></i> Загрузить</button>
</div>
<small class="text-muted" style="font-size: 0.75rem; display: block; margin-top: 4px;">До 5 файлов, JPG/PNG/WebP. Автоконвертация в WebP.</small>
</div>
<div class="mb-3">
<label class="form-label">Мебель</label>
<div class="room-modal-grid-3col" style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px;">
<div class="form-check"><input type="checkbox" class="form-check-input" id="furn_double_bed" name="furniture" value="double_bed"><label class="form-check-label" for="furn_double_bed">Двуспальная кровать</label></div>
<div class="form-check"><input type="checkbox" class="form-check-input" id="furn_single_beds" name="furniture" value="single_beds"><label class="form-check-label" for="furn_single_beds">Односпальные кровати</label></div>
<div class="form-check"><input type="checkbox" class="form-check-input" id="furn_sofa" name="furniture" value="sofa"><label class="form-check-label" for="furn_sofa">Диван</label></div>
<div class="form-check"><input type="checkbox" class="form-check-input" id="furn_wardrobe" name="furniture" value="wardrobe"><label class="form-check-label" for="furn_wardrobe">Шкаф</label></div>
<div class="form-check"><input type="checkbox" class="form-check-input" id="furn_table" name="furniture" value="table"><label class="form-check-label" for="furn_table">Стол</label></div>
<div class="form-check"><input type="checkbox" class="form-check-input" id="furn_chairs" name="furniture" value="chairs"><label class="form-check-label" for="furn_chairs">Стулья</label></div>
<div class="form-check"><input type="checkbox" class="form-check-input" id="furn_nightstands" name="furniture" value="nightstands"><label class="form-check-label" for="furn_nightstands">Прикроватные тумбочки</label></div>
<div class="form-check"><input type="checkbox" class="form-check-input" id="furn_hanger" name="furniture" value="hanger"><label class="form-check-label" for="furn_hanger">Вешалка</label></div>
<div class="form-check"><input type="checkbox" class="form-check-input" id="furn_mirror" name="furniture" value="mirror"><label class="form-check-label" for="furn_mirror">Зеркало</label></div>
</div>
</div>
<div class="mb-3">
<label class="form-label">Удобства</label>
<div class="room-modal-grid-4col" style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;">
<div class="form-check"><input type="checkbox" class="form-check-input" id="am_has_ac" name="amenities" value="has_ac"><label class="form-check-label" for="am_has_ac">🌡️ Кондиционер</label></div>
<div class="form-check"><input type="checkbox" class="form-check-input" id="am_has_tv" name="amenities" value="has_tv"><label class="form-check-label" for="am_has_tv">📺 Телевизор</label></div>
<div class="form-check"><input type="checkbox" class="form-check-input" id="am_has_fridge" name="amenities" value="has_fridge"><label class="form-check-label" for="am_has_fridge">🧊 Холодильник</label></div>
<div class="form-check"><input type="checkbox" class="form-check-input" id="am_has_wifi" name="amenities" value="has_wifi"><label class="form-check-label" for="am_has_wifi">📶 Wi-Fi</label></div>
<div class="form-check"><input type="checkbox" class="form-check-input" id="am_has_kettle" name="amenities" value="has_kettle"><label class="form-check-label" for="am_has_kettle">☕ Чайник</label></div>
<div class="form-check"><input type="checkbox" class="form-check-input" id="am_has_hairdryer" name="amenities" value="has_hairdryer"><label class="form-check-label" for="am_has_hairdryer">💨 Фен</label></div>
<div class="form-check"><input type="checkbox" class="form-check-input" id="am_has_shower" name="amenities" value="has_shower"><label class="form-check-label" for="am_has_shower">🚿 Душ в номере</label></div>
<div class="form-check"><input type="checkbox" class="form-check-input" id="am_has_sea_view" name="amenities" value="has_sea_view"><label class="form-check-label" for="am_has_sea_view">🌊 Вид на море</label></div>
</div>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="roomIsActive" checked>
<label class="form-check-label" for="roomIsActive">Номер активен (виден на сайте)</label>
</div>
</div>
<div class="modal-footer-custom">
<button type="button" class="btn btn-secondary btn-sm" onclick="closeRoomModal()">Отмена</button>
<button type="button" class="btn-gold btn-sm" onclick="saveRoom()"><i class="fas fa-save"></i> Сохранить</button>
</div>
</form>
</div>
</div>
<style>
.room-admin-card { background: #fff; border-radius: 16px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.08); border: 1px solid #e2e8f0; transition: all 0.2s; }
.room-admin-card:hover { box-shadow: 0 8px 24px rgba(0,0,0,0.12); transform: translateY(-2px); }
.room-admin-image { position: relative; height: 180px; background: #1e293b; overflow: hidden; }
.room-admin-image img { width: 100%; height: 100%; object-fit: cover; }
.room-admin-type { position: absolute; top: 10px; left: 10px; background: rgba(37,99,235,0.9); color: #fff; padding: 4px 10px; border-radius: 20px; font-size: 0.7rem; font-weight: 600; }
.room-admin-body { padding: 16px; }
.room-admin-name { font-size: 1.1rem; font-weight: 700; color: #0f172a; margin: 0 0 6px; }
.room-admin-price { font-size: 1.4rem; font-weight: 700; color: #c9a84c; font-family: 'Playfair Display', serif; }
.room-admin-price span { font-size: 0.8rem; color: #94a3b8; font-weight: 400; }
.room-admin-meta { display: flex; gap: 12px; font-size: 0.8rem; color: #64748b; margin: 8px 0; }
.room-admin-meta span { display: flex; align-items: center; gap: 4px; }
.room-admin-meta i { color: #94a3b8; width: 14px; }
.room-admin-floors { font-size: 0.75rem; color: #94a3b8; margin-bottom: 8px; }
.room-admin-amenities { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 10px; }
.room-amenity-tag { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; background: #f1f5f9; border-radius: 6px; font-size: 0.75rem; color: #64748b; }
.room-amenity-tag i { font-size: 0.7rem; }
.room-admin-extra { font-size: 0.75rem; color: #e67e22; background: #fef3e2; padding: 4px 8px; border-radius: 4px; margin-bottom: 10px; display: inline-block; }
.room-admin-actions { display: flex; gap: 8px; margin-top: 12px; border-top: 1px solid #f1f5f9; padding-top: 12px; }
.room-admin-actions .btn-primary-custom { flex: 1; }
.room-admin-actions .btn-danger-custom { padding: 8px 12px; }
.calendar-cards { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
@media (min-width: 768px) { .calendar-cards { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); } }
.cal-card { background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden; }
.cal-card-header { padding: 8px 12px; background: #f8fafc; font-weight: 600; font-size: 0.85rem; color: #0f172a; border-bottom: 1px solid #e2e8f0; }
.cal-card-rooms { padding: 4px 0; }
.cal-room-row { display: flex; align-items: center; gap: 6px; padding: 4px 10px; font-size: 0.75rem; border-bottom: 1px solid #f1f5f9; }
.cal-room-row:last-child { border-bottom: none; }
.cal-room-type { width: 80px; flex-shrink: 0; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cal-room-bar { flex: 1; height: 6px; background: rgba(0,0,0,0.1); border-radius: 3px; overflow: hidden; }
.cal-room-fill { display: block; height: 100%; border-radius: 3px; transition: width 0.3s ease; }
.cal-room-count { width: 36px; flex-shrink: 0; text-align: right; font-weight: 600; font-size: 0.7rem; }
</style>
<div class="modal-backdrop-custom" id="seasonalModal">
<div class="modal-custom">
<div class="modal-header-custom">
<h3 id="seasonalModalTitle">Добавить сезонную цену</h3>
<button class="modal-close" onclick="hideSeasonalModal()">&times;</button>
</div>
<form id="seasonalForm">
<input type="hidden" id="editSeasonalId">
<div class="modal-body-custom">
<div class="mb-3">
<label class="form-label">Тип номера *</label>
<select class="form-control" id="seasonalRoomType">
<option value="2x-местный">2x-местный</option>
<option value="3х-местный">3х-местный</option>
<option value="Семейный">Семейный</option>
<option value="Люкс">Люкс</option>
</select>
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label">Дата с *</label>
<input type="date" class="form-control" id="seasonalDateFrom" required>
</div>
<div class="col-md-6">
<label class="form-label">Дата по *</label>
<input type="date" class="form-control" id="seasonalDateTo" required>
</div>
</div>
<div class="mb-3">
<label class="form-label">Цена за ночь (₽) *</label>
<input type="number" class="form-control" id="seasonalPrice" required min="1" placeholder="2000">
</div>
<div class="mb-3">
<label class="form-label">Метка (например: Лето 2026)</label>
<input type="text" class="form-control" id="seasonalLabel" placeholder="Лето 2026">
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="seasonalIsActive" checked>
<label class="form-check-label" for="seasonalIsActive">Активна</label>
</div>
</div>
<div class="modal-footer-custom">
<button type="button" class="btn btn-secondary btn-sm" onclick="hideSeasonalModal()">Отмена</button>
<button type="submit" class="btn-gold btn-sm"><i class="fas fa-save"></i> Сохранить</button>
</div>
</form>
</div>
</div>
<div class="modal-backdrop-custom" id="activityModal">
<div class="modal-custom" style="max-width: min(95vw, 700px);">
<div class="modal-header-custom">
<h3 id="activityModalTitle">Добавить развлечение</h3>
<button class="modal-close" onclick="closeActivityModal()">&times;</button>
</div>
<form id="activityForm">
<input type="hidden" id="editActivityId">
<div class="modal-body-custom" style="max-height: 65vh; overflow-y: auto;">
<div class="row g-3 mb-3">
<div class="col-md-4">
<label class="form-label">Название (RU)</label>
<input type="text" class="form-control" id="actTitleRu" placeholder="На русском">
</div>
<div class="col-md-4">
<label class="form-label">Название (EN)</label>
<input type="text" class="form-control" id="actTitleEn" placeholder="На английском">
</div>
<div class="col-md-4">
<label class="form-label">Название (AB)</label>
<input type="text" class="form-control" id="actTitleAb" placeholder="На абхазском">
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-4">
<label class="form-label">Описание (RU)</label>
<textarea class="form-control" id="actDescRu" rows="2" placeholder="На русском"></textarea>
</div>
<div class="col-md-4">
<label class="form-label">Описание (EN)</label>
<textarea class="form-control" id="actDescEn" rows="2" placeholder="На английском"></textarea>
</div>
<div class="col-md-4">
<label class="form-label">Описание (AB)</label>
<textarea class="form-control" id="actDescAb" rows="2" placeholder="На абхазском"></textarea>
</div>
</div>
<small class="text-muted d-block mb-3" style="margin-top: -8px;">Заполните либо 1 язык, либо все 3. Промежуточные варианты не допускаются.</small>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label">Иконка (Font Awesome)</label>
<input type="text" class="form-control" id="actIcon" placeholder="fas fa-water">
<small class="text-muted">Например: fas fa-water, fas fa-coffee, fas fa-utensils</small>
</div>
<div class="col-md-3">
<label class="form-label">Порядок</label>
<input type="number" class="form-control" id="actSortOrder" min="0" value="0">
</div>
<div class="col-md-3 d-flex align-items-end">
<div class="form-check mb-2">
<input type="checkbox" class="form-check-input" id="actIsActive" checked>
<label class="form-check-label" for="actIsActive">Активно</label>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">Изображение</label>
<div id="activityImagePreview" style="margin-bottom: 8px;"></div>
<div style="display: flex; gap: 8px; align-items: center;">
<input type="file" class="form-control" id="activityImageInput" accept="image/*" style="font-size: 0.85rem; max-width: 300px;">
<button type="button" class="btn btn-primary btn-sm" onclick="uploadActivityImage()"><i class="fas fa-upload"></i> Загрузить</button>
</div>
<small class="text-muted" style="font-size: 0.75rem; display: block; margin-top: 4px;">JPG/PNG/WebP, до 5 MB</small>
</div>
</div>
<div class="modal-footer-custom">
<button type="button" class="btn btn-secondary btn-sm" onclick="closeActivityModal()">Отмена</button>
<button type="button" class="btn-gold btn-sm" onclick="saveActivity()"><i class="fas fa-save"></i> Сохранить</button>
</div>
</form>
</div>
</div>
<div class="modal-backdrop-custom" id="heroMediaModal">
<div class="modal-custom" style="max-width: min(95vw, 500px);">
<div class="modal-header-custom">
<h3 id="heroMediaModalTitle">Добавить медиа</h3>
<button class="modal-close" onclick="closeHeroMediaModal()">&times;</button>
</div>
<div class="modal-body-custom">
<div class="mb-3">
<label class="form-label">Тип</label>
<select class="form-control" id="heroMediaType" onchange="onHeroTypeChange()">
<option value="image">Картинка</option>
<option value="video">Видео</option>
</select>
</div>
<div id="heroMediaImageGroup" class="mb-3">
<label class="form-label">Картинка</label>
<div id="heroMediaImagePreview" style="margin-bottom: 8px;"></div>
<div style="display: flex; gap: 8px; align-items: center;">
<input type="file" class="form-control" id="heroMediaImageInput" accept="image/*" style="font-size: 0.85rem;">
<button type="button" class="btn btn-primary btn-sm" onclick="uploadHeroImage()"><i class="fas fa-upload"></i> Загрузить</button>
</div>
<small class="text-muted" style="font-size: 0.75rem; display: block; margin-top: 4px;">JPG/PNG/WebP, до 5 MB</small>
</div>
<div id="heroMediaVideoGroup" class="mb-3" style="display: none;">
<label class="form-label">Видео</label>
<div style="display: flex; gap: 8px; align-items: center; margin-bottom: 8px;">
<input type="file" class="form-control" id="heroMediaVideoInput" accept="video/*" style="font-size: 0.85rem;">
<button type="button" class="btn btn-primary btn-sm" onclick="uploadHeroVideo()"><i class="fas fa-upload"></i> Загрузить</button>
</div>
<small class="text-muted" style="font-size: 0.75rem; display: block; margin-bottom: 8px;">MP4/WebM/MOV, до 100 MB</small>
<button type="button" class="btn btn-secondary btn-sm" onclick="showHeroUrlField()" style="width: 100%;"><i class="fas fa-link"></i> Или вставить URL</button>
</div>
<div id="heroMediaUrlGroup" class="mb-3" style="display: none;">
<label class="form-label">URL видео</label>
<input type="text" class="form-control" id="heroMediaPath" placeholder="https://www.youtube.com/embed/... или https://.../video.mp4">
<small class="text-muted" style="font-size: 0.75rem; display: block; margin-top: 4px;">YouTube embed URL или прямая ссылка на видеофайл</small>
</div>
<div class="mb-3">
<label class="form-label">Порядок показа</label>
<input type="number" class="form-control" id="heroMediaSortOrder" min="0" value="0" placeholder="0">
</div>
</div>
<div class="modal-footer-custom">
<button type="button" id="heroMediaDeleteBtn" class="btn btn-danger btn-sm" onclick="deleteHeroMedia(editingHeroMediaId)" style="display: none; margin-right: auto;"><i class="fas fa-trash"></i> Удалить</button>
<button type="button" class="btn btn-secondary btn-sm" onclick="closeHeroMediaModal()">Отмена</button>
<button type="button" class="btn-gold btn-sm" onclick="saveHeroMedia()"><i class="fas fa-save"></i> Сохранить</button>
</div>
</div>
</div>
</body>
</html>