прибрался
This commit is contained in:
@@ -59,6 +59,24 @@ body { font-family: 'Inter', sans-serif; background: #f1f5f9; margin: 0; }
|
||||
.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; }
|
||||
|
||||
tr.row-checkin-soon { background: #fffbeb !important; border-left: 4px solid #f59e0b; }
|
||||
tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #ef4444; }
|
||||
|
||||
.filter-bar { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-bottom: 20px; }
|
||||
.filter-bar select, .filter-bar button { padding: 8px 14px; border-radius: 10px; border: 1px solid #e2e8f0; font-size: 0.85rem; background: #fff; cursor: pointer; }
|
||||
.filter-bar select:focus { border-color: var(--primary); outline: none; }
|
||||
.filter-bar .filter-label { font-weight: 600; font-size: 0.85rem; color: #64748b; }
|
||||
.filter-bar .sort-btn { background: var(--primary); color: #fff; border: none; }
|
||||
.filter-bar .sort-btn:hover { background: #1d4ed8; }
|
||||
|
||||
.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; }
|
||||
@@ -134,6 +152,7 @@ body { font-family: 'Inter', sans-serif; background: #f1f5f9; margin: 0; }
|
||||
<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="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>
|
||||
@@ -165,12 +184,17 @@ body { font-family: 'Inter', sans-serif; background: #f1f5f9; margin: 0; }
|
||||
<div class="stat-value" id="statAdmins">—</div>
|
||||
<div class="stat-label">Администраторов</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon" style="background: #fee2e2; color: #b91c1c;"><i class="fas fa-bell"></i></div>
|
||||
<div class="stat-value" id="statNew">—</div>
|
||||
<div class="stat-label">Новых заявок</div>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<thead><tr><th>Имя</th><th>Телефон</th><th>Заезд</th><th>Выезд</th><th>Статус</th></tr></thead>
|
||||
<tbody id="recentBookings"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -195,14 +219,28 @@ body { font-family: 'Inter', sans-serif; background: #f1f5f9; margin: 0; }
|
||||
<div id="tab-bookings" class="tab-content">
|
||||
<div class="top-bar"><h1>Бронирования</h1></div>
|
||||
<div class="card">
|
||||
<div class="card-header-custom">
|
||||
<h3>Управление заявками</h3>
|
||||
<div id="bookingStats" style="font-size: 0.8rem; color: #64748b;"></div>
|
||||
</div>
|
||||
<div class="card-body-custom">
|
||||
<div class="filter-bar">
|
||||
<span class="filter-label">Фильтр:</span>
|
||||
<select id="filterStatus"><option value="all">Все статусы</option><option value="новая">Новая</option><option value="оплачена">Оплачена</option><option value="зарезервирована">Зарезервирована</option><option value="заселена">Заселена</option><option value="выехала">Выехала</option><option value="отменена">Отменена</option></select>
|
||||
<select id="filterUrgent"><option value="all">Все записи</option><option value="checkin-soon">Заезд ≤ 3 дней</option><option value="checkout-today">Выезд сегодня</option></select>
|
||||
<span class="filter-label" style="margin-left: 12px;">Сортировка:</span>
|
||||
<button class="sort-btn" id="sortAsc" onclick="setSort('asc')"><i class="fas fa-sort-up"></i> Заезд ↑</button>
|
||||
<button class="sort-btn" id="sortDesc" onclick="setSort('desc')"><i class="fas fa-sort-down"></i> Заезд ↓</button>
|
||||
</div>
|
||||
<div style="overflow-x: auto;">
|
||||
<table class="table">
|
||||
<thead><tr><th>Имя</th><th>Телефон</th><th>Взрослые</th><th>Дети</th><th>Заезд</th><th>Выезд</th><th>Пожелания</th><th>Создано</th></tr></thead>
|
||||
<thead><tr><th>Имя</th><th>Телефон</th><th>Взр.</th><th>Дет.</th><th>Заезд</th><th>Выезд</th><th>Пожелания</th><th>Статус</th><th>Действия</th></tr></thead>
|
||||
<tbody id="allBookings"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tab-profile" class="tab-content">
|
||||
<div class="top-bar"><h1>Мой профиль</h1></div>
|
||||
@@ -321,7 +359,7 @@ async function api(url, opts = {}) {
|
||||
}
|
||||
|
||||
function initTabs() {
|
||||
document.querySelectorAll('.sidebar-nav a').forEach(a => {
|
||||
document.querySelectorAll('.sidebar-nav a[data-tab]').forEach(a => {
|
||||
a.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
const tab = a.dataset.tab;
|
||||
@@ -331,7 +369,7 @@ function initTabs() {
|
||||
document.getElementById('tab-' + tab).classList.add('active');
|
||||
if (tab === 'dashboard') loadDashboard();
|
||||
if (tab === 'users') loadUsers();
|
||||
if (tab === 'bookings') loadBookings();
|
||||
if (tab === 'bookings') { bookingsLoaded = false; loadBookings(); }
|
||||
if (tab === 'profile') loadProfile();
|
||||
});
|
||||
});
|
||||
@@ -400,12 +438,21 @@ async function loadDashboard() {
|
||||
const users = await api('/api/admin/users');
|
||||
document.getElementById('statUsers').textContent = users.length;
|
||||
document.getElementById('statAdmins').textContent = users.filter(u => u.role === 'admin').length;
|
||||
document.getElementById('statBookings').textContent = '—';
|
||||
} catch(e) {}
|
||||
try {
|
||||
const bookings = await fetch(API + '/api/bookings', { headers: { 'X-API-Key': (await fetch('/api/bookings').then(() => '')).catch(() => '') } }).catch(() => null);
|
||||
} catch(e) {}
|
||||
document.getElementById('recentBookings').innerHTML = '<tr><td colspan="5" class="text-center text-muted">Данные бронирований доступны через API-ключ</td></tr>';
|
||||
const rows = await api('/api/admin/bookings');
|
||||
document.getElementById('statBookings').textContent = rows.length;
|
||||
document.getElementById('statNew').textContent = rows.filter(r => r.status === 'новая').length;
|
||||
document.getElementById('recentBookings').innerHTML = rows.filter(r => r.status !== 'отменена' && r.status !== 'выехала').slice(0, 5).map(r => '<tr>' +
|
||||
'<td>' + esc(r.name) + '</td><td>' + esc(r.phone) + '</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>';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
@@ -426,17 +473,98 @@ async function loadUsers() {
|
||||
} catch(err) { showToast(err.message, 'error'); }
|
||||
}
|
||||
|
||||
let allBookingsData = [];
|
||||
let sortDirection = 'asc';
|
||||
let bookingsLoaded = false;
|
||||
|
||||
async function loadBookings() {
|
||||
if (bookingsLoaded) { renderBookings(); return; }
|
||||
try {
|
||||
const rows = await api('/api/bookings');
|
||||
document.getElementById('allBookings').innerHTML = rows.map(r => '<tr>' +
|
||||
'<td>' + esc(r.name) + '</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>' + esc(r.created_at) + '</td></tr>').join('');
|
||||
document.getElementById('statBookings').textContent = rows.length;
|
||||
allBookingsData = await api('/api/admin/bookings');
|
||||
bookingsLoaded = true;
|
||||
renderBookings();
|
||||
updateBookingStats();
|
||||
} catch(err) { showToast('Нет доступа к бронированиям', 'error'); }
|
||||
}
|
||||
|
||||
function getDaysDiff(dateStr) {
|
||||
const today = new Date(); today.setHours(0,0,0,0);
|
||||
const d = new Date(dateStr); d.setHours(0,0,0,0);
|
||||
return Math.round((d - today) / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
function updateBookingStats() {
|
||||
const stats = {};
|
||||
allBookingsData.forEach(b => { stats[b.status] = (stats[b.status] || 0) + 1; });
|
||||
document.getElementById('bookingStats').innerHTML = Object.entries(stats).map(([k,v]) => '<span class="badge badge-status badge-status-' + k + '" style="margin-right: 4px;">' + v + ' ' + k + '</span>').join('');
|
||||
}
|
||||
|
||||
function renderBookings() {
|
||||
let rows = [...allBookingsData];
|
||||
const filterStatus = document.getElementById('filterStatus').value;
|
||||
const filterUrgent = document.getElementById('filterUrgent').value;
|
||||
|
||||
if (filterStatus !== 'all') rows = rows.filter(r => r.status === filterStatus);
|
||||
|
||||
if (filterUrgent === 'checkin-soon') {
|
||||
rows = rows.filter(r => { const diff = getDaysDiff(r.checkin_date); return diff >= 0 && diff <= 3 && r.status !== 'отменена' && r.status !== 'выехала'; });
|
||||
} else if (filterUrgent === 'checkout-today') {
|
||||
rows = rows.filter(r => getDaysDiff(r.checkout_date) === 0 && r.status !== 'отменена' && r.status !== 'выехала');
|
||||
}
|
||||
|
||||
rows.sort((a, b) => {
|
||||
const da = new Date(a.checkin_date), db = new Date(b.checkin_date);
|
||||
return sortDirection === 'asc' ? da - db : db - da;
|
||||
});
|
||||
|
||||
const statuses = ['новая', 'оплачена', 'зарезервирована', 'заселена', 'выехала', 'отменена'];
|
||||
const statusIcons = { 'новая': 'fa-file', 'оплачена': 'fa-check', 'зарезервирована': 'fa-calendar', 'заселена': 'fa-door-open', 'выехала': 'fa-door-closed', 'отменена': 'fa-ban' };
|
||||
|
||||
const tbody = document.getElementById('allBookings');
|
||||
if (rows.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="9" class="text-center text-muted">Нет записей</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = rows.map(r => {
|
||||
const checkinDiff = getDaysDiff(r.checkin_date);
|
||||
const checkoutDiff = getDaysDiff(r.checkout_date);
|
||||
let rowClass = '';
|
||||
if (checkinDiff >= 0 && checkinDiff <= 3 && r.status !== 'отменена' && r.status !== 'выехала') rowClass = 'row-checkin-soon';
|
||||
if (checkoutDiff === 0 && r.status !== 'отменена' && r.status !== 'выехала') rowClass = 'row-checkout-today';
|
||||
|
||||
const selectOpts = statuses.map(s => '<option value="' + s + '"' + (r.status === s ? ' selected' : '') + '>' + s.charAt(0).toUpperCase() + s.slice(1) + '</option>').join('');
|
||||
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><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>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function setSort(dir) {
|
||||
sortDirection = dir;
|
||||
document.getElementById('sortAsc').style.opacity = dir === 'asc' ? '1' : '0.5';
|
||||
document.getElementById('sortDesc').style.opacity = dir === 'desc' ? '1' : '0.5';
|
||||
renderBookings();
|
||||
}
|
||||
|
||||
async function changeStatus(id, status) {
|
||||
try {
|
||||
const data = await api('/api/admin/bookings/' + id, { method: 'PATCH', body: JSON.stringify({ status }) });
|
||||
allBookingsData = allBookingsData.map(b => b.id === id ? data.booking : b);
|
||||
renderBookings(); updateBookingStats(); loadDashboard();
|
||||
showToast('Статус обновлён: ' + status);
|
||||
} catch(err) { showToast(err.message, 'error'); }
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.getElementById('filterStatus').addEventListener('change', renderBookings);
|
||||
document.getElementById('filterUrgent').addEventListener('change', renderBookings);
|
||||
document.getElementById('sortAsc').style.opacity = '1';
|
||||
document.getElementById('sortDesc').style.opacity = '0.5';
|
||||
});
|
||||
|
||||
function loadProfile() {
|
||||
document.getElementById('profileFullName').value = currentUser.full_name || '';
|
||||
document.getElementById('profileEmail').value = currentUser.email || '';
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
<li class="nav-item"><a class="nav-link" href="#reviews">Отзывы</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="#host">Хозяин</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="#contact">Контакты</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="/admin" style="color: var(--gold);"><i class="fas fa-cog"></i> Управление</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -560,6 +561,7 @@
|
||||
<li><a href="#gallery">Галерея</a></li>
|
||||
<li><a href="#reviews">Отзывы</a></li>
|
||||
<li><a href="#host">Хозяин</a></li>
|
||||
<li><a href="/admin" style="color: var(--gold);"><i class="fas fa-cog"></i> Управление</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-4">
|
||||
|
||||
41
server.js
41
server.js
@@ -47,6 +47,12 @@ db.run(`ALTER TABLE bookings ADD COLUMN wishes TEXT`, (err) => {
|
||||
}
|
||||
});
|
||||
|
||||
db.run(`ALTER TABLE bookings ADD COLUMN status TEXT DEFAULT 'новая'`, (err) => {
|
||||
if (err && !err.message.includes('duplicate column name')) {
|
||||
console.error('Migration error:', err);
|
||||
}
|
||||
});
|
||||
|
||||
db.run(`CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
login TEXT NOT NULL UNIQUE,
|
||||
@@ -130,8 +136,8 @@ app.post('/api/bookings', (req, res) => {
|
||||
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)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`);
|
||||
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);
|
||||
@@ -142,13 +148,40 @@ app.post('/api/bookings', (req, res) => {
|
||||
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) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
return res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
res.json(rows);
|
||||
});
|
||||
});
|
||||
|
||||
app.patch('/api/admin/bookings/:id', authenticateToken, requireAdmin, (req, res) => {
|
||||
const bookingId = parseInt(req.params.id);
|
||||
const { status } = req.body;
|
||||
const validStatuses = ['новая', 'оплачена', 'зарезервирована', 'заселена', 'выехала', 'отменена'];
|
||||
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) => {
|
||||
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 (err) return res.status(500).json({ error: 'Database error' });
|
||||
res.json({ message: 'Status updated', booking: row });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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, created_at
|
||||
FROM bookings ORDER BY created_at DESC`, (err, rows) => {
|
||||
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) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
return res.status(500).json({ error: 'Database error' });
|
||||
|
||||
Reference in New Issue
Block a user