прибрался

This commit is contained in:
2026-05-07 23:15:52 +05:00
parent ad237df23e
commit ca59175150
4 changed files with 653 additions and 25 deletions

View File

@@ -77,6 +77,16 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
.filter-bar .sort-btn { background: var(--primary); color: #fff; border: none; }
.filter-bar .sort-btn:hover { background: #1d4ed8; }
.history-timeline { border-left: 2px solid #e2e8f0; padding-left: 20px; }
.history-item { position: relative; padding: 12px 0; border-bottom: 1px solid #f1f5f9; }
.history-item:last-child { border-bottom: none; }
.history-item::before { content: ''; position: absolute; left: -27px; top: 16px; width: 12px; height: 12px; border-radius: 50%; background: var(--primary); border: 2px solid #fff; }
.history-item .history-date { font-size: 0.75rem; color: #94a3b8; }
.history-item .history-user { font-size: 0.8rem; color: #64748b; font-weight: 500; }
.history-item .history-change { font-size: 0.85rem; color: var(--dark); margin-top: 4px; }
.history-item .history-change .old-val { text-decoration: line-through; color: #ef4444; background: #fee2e2; padding: 1px 6px; border-radius: 4px; }
.history-item .history-change .new-val { color: #16a34a; background: #dcfce7; padding: 1px 6px; border-radius: 4px; }
.modal-backdrop-custom { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 200; display: none; align-items: center; justify-content: center; }
.modal-backdrop-custom.show { display: flex; }
.modal-custom { background: #fff; border-radius: 16px; width: 100%; max-width: 480px; max-height: 90vh; overflow-y: auto; }
@@ -151,6 +161,7 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
<a href="#" class="active" data-tab="dashboard"><i class="fas fa-chart-pie"></i> Дашборд</a>
<a href="#" data-tab="users"><i class="fas fa-users"></i> Пользователи</a>
<a href="#" data-tab="bookings"><i class="fas fa-calendar-check"></i> Бронирования</a>
<a href="#" data-tab="promocodes"><i class="fas fa-ticket-alt"></i> Промокоды</a>
<a href="#" data-tab="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>
@@ -194,7 +205,7 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
<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>
<thead><tr><th>Имя</th><th>Номер</th><th>Заезд</th><th>Выезд</th><th>Статус</th></tr></thead>
<tbody id="recentBookings"></tbody>
</table>
</div>
@@ -234,7 +245,7 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
</div>
<div style="overflow-x: auto;">
<table class="table">
<thead><tr><th>Имя</th><th>Телефон</th><th>Взр.</th><th>Дет.</th><th>Заезд</th><th>Выезд</th><th>Пожелания</th><th>Статус</th><th>Действия</th></tr></thead>
<thead><tr><th>Имя</th><th>Телефон</th><th>Номер</th><th>Взр.</th><th>Дет.</th><th>Заезд</th><th>Выезд</th><th>Пожелания</th><th>Комментарий</th><th>База (₽)</th><th>Скидка (%)</th><th>Сумма скидки (₽)</th><th>Итого (₽)</th><th>Промокод</th><th>Статус</th><th>Действия</th></tr></thead>
<tbody id="allBookings"></tbody>
</table>
</div>
@@ -242,6 +253,21 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
</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-profile" class="tab-content">
<div class="top-bar"><h1>Мой профиль</h1></div>
<div class="card">
@@ -335,6 +361,63 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
</div>
</div>
<div class="modal-backdrop-custom" id="historyModal">
<div class="modal-custom" style="max-width: 600px;">
<div class="modal-header-custom">
<h3 id="historyModalTitle">История изменений</h3>
<button class="modal-close" onclick="hideHistoryModal()">&times;</button>
</div>
<div class="modal-body-custom" style="max-height: 400px; overflow-y: auto;">
<div id="historyContent"></div>
</div>
</div>
</div>
<div class="modal-backdrop-custom" id="promocodeModal">
<div class="modal-custom">
<div class="modal-header-custom">
<h3 id="promocodeModalTitle">Добавить промокод</h3>
<button class="modal-close" onclick="hidePromocodeModal()">&times;</button>
</div>
<form id="promocodeForm">
<input type="hidden" id="editPromocodeId">
<div class="modal-body-custom">
<div class="mb-3">
<label class="form-label">Код *</label>
<input type="text" class="form-control" id="promocodeCode" required placeholder="SUMMER2026" style="text-transform: uppercase;">
</div>
<div class="mb-3">
<label class="form-label">Скидка (%) * от 1 до 99</label>
<input type="number" class="form-control" id="promocodeDiscount" required min="1" max="99" value="10">
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label">Действует с</label>
<input type="datetime-local" class="form-control" id="promocodeValidFrom">
</div>
<div class="col-md-6">
<label class="form-label">Действует по</label>
<input type="datetime-local" class="form-control" id="promocodeValidTo">
</div>
</div>
<div class="mb-3">
<label class="form-label">Или период (дней от сейчас)</label>
<input type="number" class="form-control" id="promocodeValidDays" min="1" placeholder="Например: 30">
<small class="text-muted">Если указано, отменяет даты "с" и "по"</small>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="promocodeIsActive" checked>
<label class="form-check-label" for="promocodeIsActive">Активен</label>
</div>
</div>
<div class="modal-footer-custom">
<button type="button" class="btn btn-secondary btn-sm" onclick="hidePromocodeModal()">Отмена</button>
<button type="submit" class="btn-primary-custom btn-sm">Сохранить</button>
</div>
</form>
</div>
</div>
<script>
const API = '';
let token = localStorage.getItem('token');
@@ -370,6 +453,7 @@ function initTabs() {
if (tab === 'dashboard') loadDashboard();
if (tab === 'users') loadUsers();
if (tab === 'bookings') { bookingsLoaded = false; loadBookings(); }
if (tab === 'promocodes') loadPromocodes();
if (tab === 'profile') loadProfile();
});
});
@@ -444,7 +528,7 @@ async function loadDashboard() {
document.getElementById('statBookings').textContent = rows.length;
document.getElementById('statNew').textContent = rows.filter(r => r.status === 'новая').length;
document.getElementById('recentBookings').innerHTML = rows.filter(r => r.status !== 'отменена' && r.status !== 'выехала').slice(0, 5).map(r => '<tr>' +
'<td>' + esc(r.name) + '</td><td>' + esc(r.phone) + '</td>' +
'<td>' + esc(r.name) + '</td><td>' + esc(r.room_type || '—') + '</td>' +
'<td>' + esc(r.checkin_date) + '</td><td>' + esc(r.checkout_date) + '</td>' +
'<td><span class="badge badge-status badge-status-' + r.status + '">' + r.status + '</span></td></tr>').join('');
if (!rows.filter(r => r.status !== 'отменена' && r.status !== 'выехала').length) {
@@ -518,11 +602,11 @@ function renderBookings() {
});
const statuses = ['новая', 'оплачена', 'зарезервирована', 'заселена', 'выехала', 'отменена'];
const statusIcons = { 'новая': 'fa-file', 'оплачена': 'fa-check', 'зарезервирована': 'fa-calendar', 'заселена': 'fa-door-open', 'выехала': 'fa-door-closed', 'отменена': 'fa-ban' };
const rooms = ['Эконом', 'Стандарт', 'VIP Люкс'];
const tbody = document.getElementById('allBookings');
if (rows.length === 0) {
tbody.innerHTML = '<tr><td colspan="9" class="text-center text-muted">Нет записей</td></tr>';
tbody.innerHTML = '<tr><td colspan="10" class="text-center text-muted">Нет записей</td></tr>';
return;
}
tbody.innerHTML = rows.map(r => {
@@ -533,11 +617,25 @@ function renderBookings() {
if (checkoutDiff === 0 && r.status !== 'отменена' && r.status !== 'выехала') rowClass = 'row-checkout-today';
const selectOpts = statuses.map(s => '<option value="' + s + '"' + (r.status === s ? ' selected' : '') + '>' + s.charAt(0).toUpperCase() + s.slice(1) + '</option>').join('');
const roomOpts = '<option value="">—</option>' + rooms.map(s => '<option value="' + s + '"' + (r.room_type === s ? ' selected' : '') + '>' + s + '</option>').join('');
const commentHtml = '<input type="text" class="form-control form-control-sm" style="width: 120px; font-size: 0.8rem;" value="' + esc(r.comment || '') + '" onchange="changeComment(' + r.id + ', this.value)" placeholder="Комментарий">';
const discountHtml = '<input type="number" min="0" max="99" class="form-control form-control-sm" style="width: 70px; font-size: 0.8rem;" value="' + (r.discount_percent || 0) + '" onchange="changeDiscount(' + r.id + ', this.value)">';
return '<tr class="' + rowClass + '">' +
'<td><strong>' + esc(r.name) + '</strong></td><td>' + esc(r.phone) + '</td><td>' + r.adults + '</td><td>' + r.children + '</td>' +
'<td>' + esc(r.checkin_date) + '</td><td>' + esc(r.checkout_date) + '</td><td>' + esc(r.wishes || '—') + '</td>' +
'<td><strong>' + esc(r.name) + '</strong></td><td>' + esc(r.phone) + '</td>' +
'<td><select class="form-select form-select-sm room-select" style="width: 120px; font-size: 0.8rem;" data-booking-id="' + r.id + '" onchange="changeRoom(' + r.id + ', this.value)">' + roomOpts + '</select></td>' +
'<td>' + r.adults + '</td><td>' + r.children + '</td>' +
'<td>' + esc(r.checkin_date) + '</td><td>' + esc(r.checkout_date) + '</td>' +
'<td>' + esc(r.wishes || '—') + '</td>' +
'<td>' + commentHtml + '</td>' +
'<td>' + (r.base_price || 0) + '</td>' +
'<td>' + discountHtml + '</td>' +
'<td>' + (r.discount_amount || 0) + '</td>' +
'<td><strong>' + (r.total_price || r.base_price || 0) + '</strong></td>' +
'<td><span class="badge bg-info" style="font-size: 0.7rem;">' + esc(r.promocode_code || '—') + '</span></td>' +
'<td><span class="badge badge-status badge-status-' + r.status + '">' + r.status + '</span></td>' +
'<td><select class="form-select form-select-sm" style="width: 140px; font-size: 0.8rem;" onchange="changeStatus(' + r.id + ', this.value)">' + selectOpts + '</select></td>' +
'<td style="white-space: nowrap;"><select class="form-select form-select-sm" style="width: 130px; font-size: 0.8rem;" onchange="changeStatus(' + r.id + ', this.value)">' + selectOpts + '</select> <button class="btn btn-outline-secondary btn-sm" style="padding: 2px 8px; font-size: 0.7rem; margin-left: 4px;" onclick="showHistory(' + r.id + ')"><i class="fas fa-clock"></i></button></td>' +
'</tr>';
}).join('');
}
@@ -558,6 +656,15 @@ async function changeStatus(id, status) {
} catch(err) { showToast(err.message, 'error'); }
}
async function changeRoom(id, room_type) {
try {
const data = await api('/api/admin/bookings/' + id + '/room', { method: 'PATCH', body: JSON.stringify({ room_type }) });
allBookingsData = allBookingsData.map(b => b.id === id ? data.booking : b);
renderBookings(); updateBookingStats(); loadDashboard();
showToast('Номер обновлён: ' + (room_type || 'Не указан'));
} catch(err) { showToast(err.message, 'error'); }
}
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('filterStatus').addEventListener('change', renderBookings);
document.getElementById('filterUrgent').addEventListener('change', renderBookings);
@@ -658,6 +765,124 @@ async function deleteUser(id) {
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
async function changeComment(id, comment) {
try {
const data = await api('/api/admin/bookings/' + id + '/comment', { method: 'PATCH', body: JSON.stringify({ comment }) });
allBookingsData = allBookingsData.map(b => b.id === id ? data.booking : b);
showToast('Комментарий обновлён');
} catch(err) { showToast(err.message, 'error'); }
}
async function changeDiscount(id, discount_percent) {
try {
const data = await api('/api/admin/bookings/' + id + '/discount', { method: 'PATCH', body: JSON.stringify({ discount_percent: parseInt(discount_percent) }) });
allBookingsData = allBookingsData.map(b => b.id === id ? data.booking : b);
renderBookings();
showToast('Скидка обновлена: ' + discount_percent + '%');
} catch(err) { showToast(err.message, 'error'); }
}
async function loadPromocodes() {
try {
const promocodes = await api('/api/admin/promocodes');
const tbody = document.getElementById('promocodesTable');
tbody.innerHTML = promocodes.map(p => '<tr>' +
'<td><strong>' + esc(p.code) + '</strong></td>' +
'<td><span class="badge bg-success" style="font-size: 0.8rem;">' + p.discount_percent + '%</span></td>' +
'<td>' + (p.valid_from || '—') + '</td>' +
'<td>' + (p.valid_to || '—') + '</td>' +
'<td>' + (p.valid_days ? p.valid_days + ' дн.' : 'Бессрочно') + '</td>' +
'<td><span class="badge ' + (p.is_active ? 'bg-success' : 'bg-secondary') + '">' + (p.is_active ? 'Активен' : 'Неактивен') + '</span></td>' +
'<td class="admin-only">' +
'<button class="btn-primary-custom btn-sm me-1" onclick="showPromocodeModal(' + p.id + ')"><i class="fas fa-edit"></i></button>' +
'<button class="btn-danger-custom btn-sm" onclick="deletePromocode(' + p.id + ')"><i class="fas fa-trash"></i></button>' +
'</td></tr>').join('');
updateUI();
} catch(err) { showToast(err.message, 'error'); }
}
function showPromocodeModal(id) {
document.getElementById('promocodeModal').classList.add('show');
document.getElementById('promocodeForm').reset();
document.getElementById('editPromocodeId').value = '';
document.getElementById('promocodeModalTitle').textContent = 'Добавить промокод';
document.getElementById('promocodeCode').disabled = false;
if (id) {
api('/api/admin/promocodes').then(promocodes => {
const p = promocodes.find(x => x.id === id);
if (p) {
document.getElementById('editPromocodeId').value = p.id;
document.getElementById('promocodeCode').value = p.code;
document.getElementById('promocodeCode').disabled = true;
document.getElementById('promocodeDiscount').value = p.discount_percent;
document.getElementById('promocodeValidFrom').value = p.valid_from ? p.valid_from.slice(0, 16) : '';
document.getElementById('promocodeValidTo').value = p.valid_to ? p.valid_to.slice(0, 16) : '';
document.getElementById('promocodeValidDays').value = p.valid_days || '';
document.getElementById('promocodeIsActive').checked = p.is_active === 1;
document.getElementById('promocodeModalTitle').textContent = 'Редактировать промокод';
}
});
}
}
function hidePromocodeModal() { document.getElementById('promocodeModal').classList.remove('show'); }
document.getElementById('promocodeForm').addEventListener('submit', async e => {
e.preventDefault();
const id = document.getElementById('editPromocodeId').value;
const body = {
code: document.getElementById('promocodeCode').value,
discount_percent: parseInt(document.getElementById('promocodeDiscount').value),
valid_from: document.getElementById('promocodeValidFrom').value || null,
valid_to: document.getElementById('promocodeValidTo').value || null,
valid_days: document.getElementById('promocodeValidDays').value ? parseInt(document.getElementById('promocodeValidDays').value) : null,
is_active: document.getElementById('promocodeIsActive').checked ? 1 : 0
};
try {
if (id) {
await api('/api/admin/promocodes/' + id, { method: 'PUT', body: JSON.stringify(body) });
showToast('Промокод обновлён');
} else {
await api('/api/admin/promocodes', { method: 'POST', body: JSON.stringify(body) });
showToast('Промокод создан');
}
hidePromocodeModal(); loadPromocodes();
} catch(err) { showToast(err.message, 'error'); }
});
async function deletePromocode(id) {
if (!confirm('Удалить промокод?')) return;
try { await api('/api/admin/promocodes/' + id, { method: 'DELETE' }); showToast('Промокод удалён'); loadPromocodes(); }
catch(err) { showToast(err.message, 'error'); }
}
async function showHistory(bookingId) {
try {
const booking = allBookingsData.find(b => b.id === bookingId);
document.getElementById('historyModalTitle').textContent = 'История: ' + (booking ? booking.name : '#'+bookingId);
const history = await api('/api/admin/bookings/' + bookingId + '/history');
const fieldNames = { status: 'Статус', room_type: 'Тип номера', name: 'Имя', phone: 'Телефон', checkin_date: 'Дата заезда', checkout_date: 'Дата выезда', wishes: 'Пожелания' };
if (history.length === 0) {
document.getElementById('historyContent').innerHTML = '<p class="text-center text-muted">Нет записей</p>';
} else {
document.getElementById('historyContent').innerHTML = '<div class="history-timeline">' + history.map(h => {
const fieldName = fieldNames[h.field] || h.field;
return '<div class="history-item">' +
'<div class="history-date">' + h.created_at + '</div>' +
'<div class="history-user">' + (h.user_login || '—') + '</div>' +
'<div class="history-change">' + fieldName + ': <span class="old-val">' + esc(h.old_value || '—') + '</span> → <span class="new-val">' + esc(h.new_value || '—') + '</span></div>' +
'</div>';
}).join('') + '</div>';
}
document.getElementById('historyModal').classList.add('show');
} catch(err) { showToast(err.message, 'error'); }
}
function hideHistoryModal() { document.getElementById('historyModal').classList.remove('show'); }
document.getElementById('historyModal').addEventListener('click', function(e) { if (e.target === this) hideHistoryModal(); });
document.getElementById('promocodeModal').addEventListener('click', function(e) { if (e.target === this) hidePromocodeModal(); });
checkAuth();
</script>
</body>

View File

@@ -647,6 +647,29 @@
<input type="text" class="form-control" name="wishes" placeholder="Например: вид на море">
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-md-6">
<label class="form-label">Промокод</label>
<input type="text" class="form-control" id="promocodeInput" name="promocode" placeholder="Введите промокод" style="text-transform: uppercase;">
</div>
<div class="col-md-6 d-flex align-items-end">
<button type="button" class="btn btn-outline-primary btn-sm" id="checkPromocodeBtn" style="width: 100%;">Проверить промокод</button>
</div>
</div>
<div id="priceInfo" class="mt-3" style="display: none; padding: 15px; background: #f8f9fa; border-radius: 10px;">
<div style="display: flex; justify-content: space-between; margin-bottom: 8px;">
<span>Базовая стоимость:</span>
<span id="basePriceDisplay"></span>
</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 8px; color: #16a34a;">
<span>Скидка (<span id="discountPercentDisplay">0</span>%):</span>
<span id="discountAmountDisplay"></span>
</div>
<div style="display: flex; justify-content: space-between; font-weight: 700; font-size: 1.1rem; border-top: 1px solid #dee2e6; padding-top: 8px;">
<span>Итого к оплате:</span>
<span id="totalPriceDisplay" style="color: var(--primary);"></span>
</div>
</div>
<div class="mt-4">
<button type="submit" class="btn-submit-booking">
<i class="fas fa-paper-plane me-2"></i>Отправить заявку

View File

@@ -66,9 +66,88 @@ document.querySelectorAll('.btn-book').forEach(btn => {
btn.addEventListener('click', function() {
const room = this.getAttribute('data-room');
document.getElementById('selectedRoom').value = room;
hidePriceInfo();
});
});
// Price calculation
const ROOM_PRICES = { 'Эконом': 2500, 'Стандарт': 4000, 'VIP Люкс': 8000 };
let currentPromocodeData = null;
function calculateNights(checkin, checkout) {
const ci = new Date(checkin);
const co = new Date(checkout);
return Math.ceil((co - ci) / (1000 * 60 * 60 * 24));
}
function updatePriceDisplay(basePrice, discountPercent, discountAmount, totalPrice) {
document.getElementById('basePriceDisplay').textContent = basePrice + ' ₽';
document.getElementById('discountPercentDisplay').textContent = discountPercent;
document.getElementById('discountAmountDisplay').textContent = '-' + discountAmount + ' ₽';
document.getElementById('totalPriceDisplay').textContent = totalPrice + ' ₽';
document.getElementById('priceInfo').style.display = 'block';
}
function hidePriceInfo() {
document.getElementById('priceInfo').style.display = 'none';
currentPromocodeData = null;
}
function getFormData() {
return {
room: document.getElementById('selectedRoom').value,
checkin: document.querySelector('[name="checkin"]').value,
checkout: document.querySelector('[name="checkout"]').value,
promocode: document.getElementById('promocodeInput').value.trim()
};
}
async function checkPromocode() {
const { room, checkin, checkout, promocode } = getFormData();
if (!room || !checkin || !checkout) {
hidePriceInfo();
return;
}
const basePrice = ROOM_PRICES[room] ? ROOM_PRICES[room] * calculateNights(checkin, checkout) : 0;
if (!promocode) {
updatePriceDisplay(basePrice, 0, 0, basePrice);
currentPromocodeData = null;
return;
}
try {
const response = await fetch('/api/promocodes/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: promocode, room_type: room, checkin, checkout })
});
const data = await response.json();
if (data.valid) {
currentPromocodeData = data;
updatePriceDisplay(data.base_price, data.discount_percent, data.discount_amount, data.total_price);
} else {
hidePriceInfo();
currentPromocodeData = null;
}
} catch (error) {
console.error('Promocode validation error:', error);
hidePriceInfo();
}
}
document.getElementById('checkPromocodeBtn').addEventListener('click', checkPromocode);
document.getElementById('promocodeInput').addEventListener('blur', checkPromocode);
document.querySelector('[name="checkin"]').addEventListener('change', checkPromocode);
document.querySelector('[name="checkout"]').addEventListener('change', checkPromocode);
document.querySelectorAll('.btn-book').forEach(btn => {
btn.addEventListener('click', function() {
setTimeout(checkPromocode, 100);
});
});
// Form submission
document.getElementById('bookingForm').addEventListener('submit', async function(e) {
e.preventDefault();
@@ -86,7 +165,8 @@ document.getElementById('bookingForm').addEventListener('submit', async function
checkin: this.querySelector('[name="checkin"]').value,
checkout: this.querySelector('[name="checkout"]').value,
wishes: this.querySelector('[name="wishes"]').value,
room: document.getElementById('selectedRoom').value
room: document.getElementById('selectedRoom').value,
promocode: document.getElementById('promocodeInput').value.trim() || null
};
try {
@@ -100,6 +180,7 @@ document.getElementById('bookingForm').addEventListener('submit', async function
btn.innerHTML = '<i class="fas fa-check me-2"></i>Заявка отправлена Рауфу Алексеевичу!';
btn.style.background = '#25d366';
this.reset();
hidePriceInfo();
setTimeout(() => {
btn.innerHTML = originalText;
btn.style.background = '';

331
server.js
View File

@@ -14,6 +14,40 @@ const ADMIN_LOGIN = process.env.ADMIN_LOGIN;
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD;
const JWT_SECRET = process.env.JWT_SECRET || 'fallback-secret-change-in-production';
const ROOM_PRICES = {
'Эконом': 2500,
'Стандарт': 4000,
'VIP Люкс': 8000
};
function calculateNights(checkin, checkout) {
const ci = new Date(checkin);
const co = new Date(checkout);
return Math.ceil((co - ci) / (1000 * 60 * 60 * 24));
}
function calculateBasePrice(roomType, checkin, checkout) {
const pricePerNight = ROOM_PRICES[roomType] || 0;
const nights = calculateNights(checkin, checkout);
return pricePerNight * nights;
}
function validatePromocode(promocode, callback) {
if (!promocode) return callback(null, null);
const now = new Date().toISOString();
db.get(`SELECT * FROM promocodes WHERE code = ? AND is_active = 1`, [promocode], (err, row) => {
if (err || !row) return callback(null, null);
if (row.valid_from && row.valid_from > now) return callback(null, null);
if (row.valid_to && row.valid_to < now) return callback(null, null);
if (row.valid_days) {
const createdDate = new Date(row.created_at);
const expireDate = new Date(createdDate.getTime() + row.valid_days * 24 * 60 * 60 * 1000);
if (expireDate < new Date()) return callback(null, null);
}
callback(null, row);
});
}
if (!API_KEY) {
console.error('FATAL: HOTEL777KEY environment variable not set');
process.exit(1);
@@ -53,6 +87,87 @@ db.run(`ALTER TABLE bookings ADD COLUMN status TEXT DEFAULT 'новая'`, (err)
}
});
db.run(`ALTER TABLE bookings ADD COLUMN room_type TEXT`, (err) => {
if (err && !err.message.includes('duplicate column name')) {
console.error('Migration error:', err);
}
});
db.run(`ALTER TABLE bookings ADD COLUMN comment TEXT`, (err) => {
if (err && !err.message.includes('duplicate column name')) console.error('Migration error:', err);
});
db.run(`ALTER TABLE bookings ADD COLUMN base_price REAL`, (err) => {
if (err && !err.message.includes('duplicate column name')) console.error('Migration error:', err);
});
db.run(`ALTER TABLE bookings ADD COLUMN discount_percent INTEGER DEFAULT 0`, (err) => {
if (err && !err.message.includes('duplicate column name')) console.error('Migration error:', err);
});
db.run(`ALTER TABLE bookings ADD COLUMN discount_amount REAL DEFAULT 0`, (err) => {
if (err && !err.message.includes('duplicate column name')) console.error('Migration error:', err);
});
db.run(`ALTER TABLE bookings ADD COLUMN total_price REAL`, (err) => {
if (err && !err.message.includes('duplicate column name')) console.error('Migration error:', err);
});
db.run(`ALTER TABLE bookings ADD COLUMN promocode_id INTEGER`, (err) => {
if (err && !err.message.includes('duplicate column name')) console.error('Migration error:', err);
});
db.run(`CREATE TABLE IF NOT EXISTS promocodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
discount_percent INTEGER NOT NULL CHECK(discount_percent BETWEEN 1 AND 99),
valid_from DATETIME,
valid_to DATETIME,
valid_days INTEGER,
is_active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`, (err) => {
if (err) console.error('Promocodes table creation error:', err);
});
db.run(`CREATE TABLE IF NOT EXISTS rooms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
rooms_count INTEGER DEFAULT 1,
single_beds INTEGER DEFAULT 0,
double_beds INTEGER DEFAULT 0,
has_sofa INTEGER DEFAULT 0,
has_ac INTEGER DEFAULT 0,
has_wifi INTEGER DEFAULT 0,
has_shower INTEGER DEFAULT 0,
max_guests INTEGER DEFAULT 2,
price_per_guest INTEGER NOT NULL,
image_path TEXT,
is_active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`, (err) => {
if (err) console.error('Rooms table creation error:', err);
else initDefaultRooms();
});
db.run(`CREATE TABLE IF NOT EXISTS booking_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
booking_id INTEGER NOT NULL,
user_id INTEGER,
user_login TEXT,
field TEXT NOT NULL,
old_value TEXT,
new_value TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (booking_id) REFERENCES bookings(id)
)`, (err) => {
if (err) console.error('Booking history table creation error:', err);
});
function logHistory(bookingId, userId, userLogin, field, oldValue, newValue) {
db.run(`INSERT INTO booking_history (booking_id, user_id, user_login, field, old_value, new_value) VALUES (?, ?, ?, ?, ?, ?)`,
[bookingId, userId, userLogin, field, oldValue, newValue], (err) => {
if (err) console.error('History log error:', err);
});
}
db.run(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
login TEXT NOT NULL UNIQUE,
@@ -132,25 +247,40 @@ async function convertImages() {
}
app.post('/api/bookings', (req, res) => {
const { name, phone, adults, children, checkin, checkout, wishes } = req.body;
const { name, phone, adults, children, checkin, checkout, wishes, room, promocode } = req.body;
if (!name || !phone || !adults || !checkin || !checkout) {
return res.status(400).json({ error: 'Missing required fields' });
}
const stmt = db.prepare(`INSERT INTO bookings (name, phone, adults, children, checkin_date, checkout_date, wishes, status)
VALUES (?, ?, ?, ?, ?, ?, ?, 'новая')`);
stmt.run(name, phone, parseInt(adults), parseInt(children || 0), checkin, checkout, wishes || null, function(err) {
if (err) {
console.error(err);
return res.status(500).json({ error: 'Database error' });
const basePrice = calculateBasePrice(room, checkin, checkout);
validatePromocode(promocode, (err, promo) => {
if (err) return res.status(500).json({ error: 'Database error' });
let discountPercent = 0;
let promocodeId = null;
if (promo) {
discountPercent = promo.discount_percent;
promocodeId = promo.id;
}
res.status(201).json({ id: this.lastID, message: 'Booking saved' });
const safeBasePrice = basePrice || 0;
const discountAmount = Math.round(safeBasePrice * discountPercent / 100);
const totalPrice = safeBasePrice - discountAmount;
const stmt = db.prepare(`INSERT INTO bookings (name, phone, adults, children, checkin_date, checkout_date, wishes, status, room_type, base_price, discount_percent, discount_amount, total_price, promocode_id)
VALUES (?, ?, ?, ?, ?, ?, ?, 'новая', ?, ?, ?, ?, ?, ?)`);
stmt.run(name, phone, parseInt(adults), parseInt(children || 0), checkin, checkout, wishes || null, room || null,
safeBasePrice || null, discountPercent || 0, discountAmount || 0, totalPrice || null, promocodeId, function(err) {
if (err) {
console.error(err);
return res.status(500).json({ error: 'Database error' });
}
res.status(201).json({ id: this.lastID, message: 'Booking saved', base_price: safeBasePrice, discount_percent: discountPercent, discount_amount: discountAmount, total_price: totalPrice });
});
stmt.finalize();
});
stmt.finalize();
});
app.get('/api/admin/bookings', authenticateToken, (req, res) => {
db.all(`SELECT id, name, phone, adults, children, checkin_date, checkout_date, wishes, status, created_at
FROM bookings ORDER BY checkin_date ASC`, [], (err, rows) => {
db.all(`SELECT b.*, p.code as promocode_code FROM bookings b
LEFT JOIN promocodes p ON b.promocode_id = p.id
ORDER BY b.checkin_date ASC`, [], (err, rows) => {
if (err) {
console.error(err);
return res.status(500).json({ error: 'Database error' });
@@ -166,22 +296,107 @@ app.patch('/api/admin/bookings/:id', authenticateToken, requireAdmin, (req, res)
if (!status || !validStatuses.includes(status)) {
return res.status(400).json({ error: 'Invalid status. Valid: ' + validStatuses.join(', ') });
}
db.run(`UPDATE bookings SET status = ? WHERE id = ?`, [status, bookingId], (err) => {
db.get(`SELECT status FROM bookings WHERE id = ?`, [bookingId], (err, row) => {
if (err) return res.status(500).json({ error: 'Database error' });
db.get(`SELECT id, name, phone, adults, children, checkin_date, checkout_date, wishes, status, created_at FROM bookings WHERE id = ?`, [bookingId], (err, row) => {
if (!row) return res.status(404).json({ error: 'Booking not found' });
const oldValue = row.status;
db.run(`UPDATE bookings SET status = ? WHERE id = ?`, [status, bookingId], (err) => {
if (err) return res.status(500).json({ error: 'Database error' });
res.json({ message: 'Status updated', booking: row });
logHistory(bookingId, req.user.id, req.user.login, 'status', oldValue, status);
db.get(`SELECT id, name, phone, adults, children, checkin_date, checkout_date, wishes, status, room_type, created_at FROM bookings WHERE id = ?`, [bookingId], (err, row) => {
if (err) return res.status(500).json({ error: 'Database error' });
res.json({ message: 'Status updated', booking: row });
});
});
});
});
app.patch('/api/admin/bookings/:id/room', authenticateToken, requireAdmin, (req, res) => {
const bookingId = parseInt(req.params.id);
const { room_type } = req.body;
const validRooms = ['Эконом', 'Стандарт', 'VIP Люкс'];
if (!room_type || !validRooms.includes(room_type)) {
return res.status(400).json({ error: 'Invalid room type. Valid: ' + validRooms.join(', ') });
}
db.get(`SELECT room_type, checkin_date, checkout_date, discount_percent FROM bookings WHERE id = ?`, [bookingId], (err, row) => {
if (err) return res.status(500).json({ error: 'Database error' });
if (!row) return res.status(404).json({ error: 'Booking not found' });
const oldValue = row.room_type || 'Не указан';
const basePrice = calculateBasePrice(room_type, row.checkin_date, row.checkout_date);
const discountAmount = Math.round(basePrice * (row.discount_percent || 0) / 100);
const totalPrice = basePrice - discountAmount;
db.run(`UPDATE bookings SET room_type = ?, base_price = ?, discount_amount = ?, total_price = ? WHERE id = ?`,
[room_type, basePrice, discountAmount, totalPrice, bookingId], (err) => {
if (err) return res.status(500).json({ error: 'Database error' });
logHistory(bookingId, req.user.id, req.user.login, 'room_type', oldValue, room_type);
db.get(`SELECT b.*, p.code as promocode_code FROM bookings b LEFT JOIN promocodes p ON b.promocode_id = p.id WHERE b.id = ?`, [bookingId], (err, row) => {
if (err) return res.status(500).json({ error: 'Database error' });
res.json({ message: 'Room updated', booking: row });
});
});
});
});
app.patch('/api/admin/bookings/:id/comment', authenticateToken, requireAdmin, (req, res) => {
const bookingId = parseInt(req.params.id);
const { comment } = req.body;
db.get(`SELECT comment FROM bookings WHERE id = ?`, [bookingId], (err, row) => {
if (err) return res.status(500).json({ error: 'Database error' });
if (!row) return res.status(404).json({ error: 'Booking not found' });
const oldValue = row.comment || 'Нет';
db.run(`UPDATE bookings SET comment = ? WHERE id = ?`, [comment || null, bookingId], (err) => {
if (err) return res.status(500).json({ error: 'Database error' });
logHistory(bookingId, req.user.id, req.user.login, 'comment', oldValue, comment || 'Нет');
db.get(`SELECT b.*, p.code as promocode_code FROM bookings b LEFT JOIN promocodes p ON b.promocode_id = p.id WHERE b.id = ?`, [bookingId], (err, row) => {
if (err) return res.status(500).json({ error: 'Database error' });
res.json({ message: 'Comment updated', booking: row });
});
});
});
});
app.patch('/api/admin/bookings/:id/discount', authenticateToken, requireAdmin, (req, res) => {
const bookingId = parseInt(req.params.id);
const { discount_percent } = req.body;
if (discount_percent === undefined || discount_percent < 0 || discount_percent > 99) {
return res.status(400).json({ error: 'Discount percent must be between 0 and 99' });
}
db.get(`SELECT base_price, discount_percent FROM bookings WHERE id = ?`, [bookingId], (err, row) => {
if (err) return res.status(500).json({ error: 'Database error' });
if (!row) return res.status(404).json({ error: 'Booking not found' });
const oldValue = row.discount_percent || 0;
const basePrice = row.base_price || 0;
const discountAmount = Math.round(basePrice * discount_percent / 100);
const totalPrice = basePrice - discountAmount;
db.run(`UPDATE bookings SET discount_percent = ?, discount_amount = ?, total_price = ? WHERE id = ?`,
[discount_percent, discountAmount, totalPrice, bookingId], (err) => {
if (err) return res.status(500).json({ error: 'Database error' });
logHistory(bookingId, req.user.id, req.user.login, 'discount_percent', oldValue.toString(), discount_percent.toString());
db.get(`SELECT b.*, p.code as promocode_code FROM bookings b LEFT JOIN promocodes p ON b.promocode_id = p.id WHERE b.id = ?`, [bookingId], (err, row) => {
if (err) return res.status(500).json({ error: 'Database error' });
res.json({ message: 'Discount updated', booking: row });
});
});
});
});
app.get('/api/admin/bookings/:id/history', authenticateToken, (req, res) => {
const bookingId = parseInt(req.params.id);
db.all(`SELECT id, booking_id, user_id, user_login, field, old_value, new_value, created_at
FROM booking_history WHERE booking_id = ? ORDER BY created_at DESC`, [bookingId], (err, rows) => {
if (err) return res.status(500).json({ error: 'Database error' });
res.json(rows);
});
});
app.get('/api/bookings', (req, res) => {
const providedKey = req.headers['x-api-key'];
if (!providedKey || providedKey !== API_KEY) {
return res.status(401).json({ error: 'Invalid or missing API key' });
}
db.all(`SELECT id, name, phone, adults, children, checkin_date, checkout_date, wishes, status, created_at
FROM bookings ORDER BY checkin_date ASC`, (err, rows) => {
db.all(`SELECT b.*, p.code as promocode_code FROM bookings b
LEFT JOIN promocodes p ON b.promocode_id = p.id
ORDER BY b.checkin_date ASC`, (err, rows) => {
if (err) {
console.error(err);
return res.status(500).json({ error: 'Database error' });
@@ -190,6 +405,90 @@ app.get('/api/bookings', (req, res) => {
});
});
app.post('/api/promocodes/validate', (req, res) => {
const { code, room_type, checkin, checkout } = req.body;
if (!code) return res.status(400).json({ error: 'Promocode required' });
validatePromocode(code, (err, promo) => {
if (err) return res.status(500).json({ error: 'Database error' });
if (!promo) return res.status(404).json({ error: 'Invalid or expired promocode' });
const basePrice = calculateBasePrice(room_type, checkin, checkout);
const discountAmount = Math.round(basePrice * promo.discount_percent / 100);
const totalPrice = basePrice - discountAmount;
res.json({
valid: true,
discount_percent: promo.discount_percent,
base_price: basePrice,
discount_amount: discountAmount,
total_price: totalPrice,
code: promo.code
});
});
});
app.get('/api/admin/promocodes', authenticateToken, requireAdmin, (req, res) => {
db.all(`SELECT * FROM promocodes ORDER BY created_at DESC`, [], (err, rows) => {
if (err) return res.status(500).json({ error: 'Database error' });
res.json(rows);
});
});
app.post('/api/admin/promocodes', authenticateToken, requireAdmin, (req, res) => {
const { code, discount_percent, valid_from, valid_to, valid_days, is_active } = req.body;
if (!code || !discount_percent) return res.status(400).json({ error: 'Code and discount percent required' });
if (discount_percent < 1 || discount_percent > 99) return res.status(400).json({ error: 'Discount must be between 1 and 99' });
db.run(`INSERT INTO promocodes (code, discount_percent, valid_from, valid_to, valid_days, is_active)
VALUES (?, ?, ?, ?, ?, ?)`,
[code, discount_percent, valid_from || null, valid_to || null, valid_days || null, is_active !== undefined ? is_active : 1], function(err) {
if (err) {
if (err.message.includes('UNIQUE constraint')) return res.status(409).json({ error: 'Promocode already exists' });
return res.status(500).json({ error: 'Database error' });
}
db.get(`SELECT * FROM promocodes WHERE id = ?`, [this.lastID], (err, row) => {
res.status(201).json({ message: 'Promocode created', promocode: row });
});
});
});
app.put('/api/admin/promocodes/:id', authenticateToken, requireAdmin, (req, res) => {
const promoId = parseInt(req.params.id);
const { code, discount_percent, valid_from, valid_to, valid_days, is_active } = req.body;
if (discount_percent !== undefined && (discount_percent < 1 || discount_percent > 99)) {
return res.status(400).json({ error: 'Discount must be between 1 and 99' });
}
db.get(`SELECT id FROM promocodes WHERE id = ?`, [promoId], (err, row) => {
if (err) return res.status(500).json({ error: 'Database error' });
if (!row) return res.status(404).json({ error: 'Promocode not found' });
let fields = [];
let values = [];
if (code !== undefined) { fields.push('code = ?'); values.push(code); }
if (discount_percent !== undefined) { fields.push('discount_percent = ?'); values.push(discount_percent); }
if (valid_from !== undefined) { fields.push('valid_from = ?'); values.push(valid_from || null); }
if (valid_to !== undefined) { fields.push('valid_to = ?'); values.push(valid_to || null); }
if (valid_days !== undefined) { fields.push('valid_days = ?'); values.push(valid_days || null); }
if (is_active !== undefined) { fields.push('is_active = ?'); values.push(is_active); }
if (fields.length === 0) return res.status(400).json({ error: 'No fields to update' });
values.push(promoId);
db.run(`UPDATE promocodes SET ${fields.join(', ')} WHERE id = ?`, values, (err) => {
if (err) return res.status(500).json({ error: 'Database error' });
db.get(`SELECT * FROM promocodes WHERE id = ?`, [promoId], (err, row) => {
res.json({ message: 'Promocode updated', promocode: row });
});
});
});
});
app.delete('/api/admin/promocodes/:id', authenticateToken, requireAdmin, (req, res) => {
const promoId = parseInt(req.params.id);
db.get(`SELECT id FROM promocodes WHERE id = ?`, [promoId], (err, row) => {
if (err) return res.status(500).json({ error: 'Database error' });
if (!row) return res.status(404).json({ error: 'Promocode not found' });
db.run(`DELETE FROM promocodes WHERE id = ?`, [promoId], (err) => {
if (err) return res.status(500).json({ error: 'Database error' });
res.json({ message: 'Promocode deleted' });
});
});
});
app.post('/api/auth/login', (req, res) => {
const { login, password } = req.body;
if (!login || !password) return res.status(400).json({ error: 'Login and password required' });