прибрался

This commit is contained in:
2026-05-07 22:11:01 +05:00
parent f421398787
commit ad237df23e
3 changed files with 184 additions and 21 deletions

View File

@@ -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 || '';

View File

@@ -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">

View File

@@ -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' });