This commit is contained in:
2026-05-04 21:09:42 +05:00
parent e772d5418b
commit e20188c869
9 changed files with 2390 additions and 449 deletions

41
auth.js
View File

@@ -1,10 +1,10 @@
const session = require('express-session'); const session = require('express-session');
const SQLiteStore = require('connect-sqlite3')(session); const SQLiteStore = require('connect-sqlite3')(session);
const bcrypt = require('bcrypt'); const bcrypt = require('bcrypt');
const crypto = require('crypto');
const { db } = require('./db'); const { db } = require('./db');
const path = require('path'); const path = require('path');
// Директория для файла с сессиями (пусть будет data/)
const sessionDbPath = path.join(__dirname, 'data', 'sessions.sqlite'); const sessionDbPath = path.join(__dirname, 'data', 'sessions.sqlite');
const sessionMiddleware = session({ const sessionMiddleware = session({
@@ -12,21 +12,39 @@ const sessionMiddleware = session({
resave: false, resave: false,
saveUninitialized: false, saveUninitialized: false,
cookie: { cookie: {
secure: false, // для HTTPS нужно true, но у нас http secure: false,
httpOnly: true, httpOnly: true,
maxAge: 24 * 60 * 60 * 1000 // 24 часа sameSite: 'lax',
maxAge: 24 * 6 * 60 * 1000
}, },
store: new SQLiteStore({ store: new SQLiteStore({
db: 'sessions.sqlite', // имя файла в директории data db: 'sessions.sqlite',
dir: path.join(__dirname, 'data'), dir: path.join(__dirname, 'data'),
table: 'sessions' // имя таблицы table: 'sessions'
}) })
}); });
/** function generateCsrfToken(req) {
* Гарантирует существование администратора, заданного переменными окружения if (!req.session.csrfToken) {
* (ADMIN_LOGIN / ADMIN_PASSWORD). Пароль всегда хэшируется. req.session.csrfToken = crypto.randomBytes(32).toString('hex');
*/ }
return req.session.csrfToken;
}
function csrfProtection(req, res, next) {
if (req.method === 'GET') return next();
const token = req.headers['x-csrf-token'] || req.body._csrf;
if (!token || token !== req.session.csrfToken) {
return res.status(403).json({ error: 'CSRF token validation failed' });
}
next();
}
function injectCsrfToken(req, res, next) {
res.locals.csrfToken = generateCsrfToken(req);
next();
}
async function ensureAdmin() { async function ensureAdmin() {
const login = process.env.ADMIN_LOGIN || 'admin'; const login = process.env.ADMIN_LOGIN || 'admin';
const password = process.env.ADMIN_PASSWORD || 'admin'; const password = process.env.ADMIN_PASSWORD || 'admin';
@@ -44,9 +62,6 @@ async function ensureAdmin() {
} }
} }
/**
* Middleware для проверки прав администратора.
*/
function requireAdmin(req, res, next) { function requireAdmin(req, res, next) {
if (req.session && req.session.isAdmin) { if (req.session && req.session.isAdmin) {
return next(); return next();
@@ -54,4 +69,4 @@ function requireAdmin(req, res, next) {
res.status(401).json({ error: 'Не авторизован' }); res.status(401).json({ error: 'Не авторизован' });
} }
module.exports = { sessionMiddleware, ensureAdmin, requireAdmin }; module.exports = { sessionMiddleware, csrfProtection, injectCsrfToken, ensureAdmin, requireAdmin };

View File

@@ -4,128 +4,249 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
<title>Панель управления</title>
</head> </head>
<body> <body>
<header></header> <header></header>
<div class="toast-container" id="toastContainer"></div>
<main> <main>
<h1>Панель управления</h1> <h1>Панель управления</h1>
<p>Сервис: <span id="serviceName"></span></p>
<button id="syncBtn">Запустить синхронизацию заявок</button> <div class="card" style="margin-bottom: 2rem;">
<p id="syncStatus"></p> <h2 style="margin-top:0;">Синхронизация заявок</h2>
<p style="color: var(--text-muted); margin-bottom: 1rem; font-size: 0.9375rem;">Получение актуальных данных из внешней системы бронирования</p>
<button id="syncBtn" class="btn-success">
<span id="syncBtnText">Запустить синхронизацию</span>
<span id="syncBtnSpinner" class="loading-spinner" style="display:none;"></span>
</button>
<p id="syncStatus" style="margin-top: 0.75rem; color: var(--text-secondary); font-size: 0.875rem;"></p>
</div>
<hr style="margin: 2rem 0;"> <h2>Администраторы</h2>
<div class="table-container" style="margin-bottom: 2rem;">
<table id="adminsTable">
<thead>
<tr>
<th>ID</th>
<th>Логин</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="adminsBody"></tbody>
</table>
</div>
<h2>Управление администраторами</h2> <h3>Добавить администратора</h3>
<table id="adminsTable"> <div class="form-card">
<thead> <form id="addAdminForm">
<tr><th>ID</th><th>Логин</th><th>Действия</th></tr> <div class="form-group">
</thead> <label for="newLogin">Логин</label>
<tbody></tbody> <input type="text" id="newLogin" required placeholder="Минимум 3 символа" minlength="3">
</table> </div>
<div class="form-group">
<label for="newPassword">Пароль</label>
<input type="password" id="newPassword" required placeholder="Минимум 6 символов" minlength="6">
</div>
<button type="submit">Добавить</button>
</form>
</div>
<h3>Добавить нового администратора</h3> <div id="editPasswordModal" class="modal">
<form id="addAdminForm">
<label>Логин</label>
<input type="text" id="newLogin" required>
<label>Пароль</label>
<input type="password" id="newPassword" required>
<button type="submit">Добавить</button>
</form>
<!-- Модальное окно для смены пароля -->
<div id="editPasswordModal" class="modal" style="display:none;">
<div class="modal-content"> <div class="modal-content">
<h2>Смена пароля</h2> <h2>Смена пароля</h2>
<form id="editPasswordForm"> <form id="editPasswordForm">
<input type="hidden" id="editAdminId"> <input type="hidden" id="editAdminId">
<label>Новый пароль</label>
<input type="password" id="editPassword" required> <div class="form-group">
<button type="submit">Сохранить</button> <label>Администратор</label>
<button type="button" id="closePasswordModal">Отмена</button> <input type="text" id="editAdminLogin" disabled style="opacity:0.6;">
</div>
<div class="form-group">
<label for="editPassword">Новый пароль</label>
<input type="password" id="editPassword" required placeholder="Минимум 6 символов" minlength="6">
</div>
<div style="display:flex;gap:0.75rem;margin-top:1.5rem;">
<button type="submit" style="flex:1;">Сохранить</button>
<button type="button" id="closePasswordModal" class="btn-secondary">Отмена</button>
</div>
</form> </form>
</div> </div>
</div> </div>
</main> </main>
<script src="nav.js"></script> <script src="nav.js"></script>
<script src="seo.js"></script> <script src="seo.js"></script>
<script> <script>
// Проверка авторизации function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function showToast(message, type = 'info') {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.innerHTML = `<span>${escapeHtml(message)}</span>`;
container.appendChild(toast);
setTimeout(() => {
toast.classList.add('toast-exit');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
async function getCsrfToken() {
const stored = window.localStorage.getItem('csrfToken');
if (stored) return stored;
try {
const res = await fetch('/api/csrf-token');
const data = await res.json();
window.localStorage.setItem('csrfToken', data.csrfToken);
return data.csrfToken;
} catch (e) {
return null;
}
}
fetch('/api/me').then(r => r.json()).then(data => { fetch('/api/me').then(r => r.json()).then(data => {
if (!data.isAdmin) window.location.href = '/login.html'; if (!data.isAdmin) window.location.href = '/login.html';
}); });
// --- Синхронизация ---
document.getElementById('syncBtn').addEventListener('click', async () => { document.getElementById('syncBtn').addEventListener('click', async () => {
const btn = document.getElementById('syncBtn');
const btnText = document.getElementById('syncBtnText');
const btnSpinner = document.getElementById('syncBtnSpinner');
const status = document.getElementById('syncStatus'); const status = document.getElementById('syncStatus');
btn.disabled = true;
btnText.style.display = 'none';
btnSpinner.style.display = 'inline-block';
status.textContent = 'Синхронизация...'; status.textContent = 'Синхронизация...';
const res = await fetch('/api/admin/sync', { method: 'POST' });
if (res.ok) status.textContent = 'Синхронизация завершена'; try {
else status.textContent = 'Ошибка синхронизации'; const csrfToken = await getCsrfToken();
const res = await fetch('/api/admin/sync', {
method: 'POST',
headers: { 'X-CSRF-Token': csrfToken }
});
if (res.ok) {
status.textContent = 'Синхронизация завершена успешно';
status.style.color = 'var(--success)';
showToast('Синхронизация завершена', 'success');
} else {
status.textContent = 'Ошибка синхронизации';
status.style.color = 'var(--danger)';
showToast('Ошибка синхронизации', 'error');
}
} catch (err) {
status.textContent = 'Ошибка соединения';
status.style.color = 'var(--danger)';
showToast('Ошибка соединения', 'error');
} finally {
btn.disabled = false;
btnText.style.display = 'inline';
btnSpinner.style.display = 'none';
}
}); });
// --- Управление администраторами --- const adminsTbody = document.getElementById('adminsBody');
const adminsTbody = document.querySelector('#adminsTable tbody');
const addForm = document.getElementById('addAdminForm'); const addForm = document.getElementById('addAdminForm');
const modal = document.getElementById('editPasswordModal'); const passwordModal = document.getElementById('editPasswordModal');
const closeModalBtn = document.getElementById('closePasswordModal'); const closePasswordBtn = document.getElementById('closePasswordModal');
async function loadAdmins() { async function loadAdmins() {
const res = await fetch('/api/admins'); try {
const admins = await res.json(); const res = await fetch('/api/admins');
adminsTbody.innerHTML = ''; if (!res.ok) throw new Error('Ошибка загрузки');
for (const admin of admins) { const admins = await res.json();
const tr = document.createElement('tr');
tr.innerHTML = ` adminsTbody.innerHTML = '';
<td>${admin.id}</td> if (admins.length === 0) {
<td>${escapeHtml(admin.login)}</td> adminsTbody.innerHTML = '<tr><td colspan="3" style="text-align:center;color:var(--text-muted);padding:2rem;">Нет администраторов</td></tr>';
<td> return;
<button class="changePasswordBtn" data-id="${admin.id}" data-login="${escapeHtml(admin.login)}">Сменить пароль</button> }
<button class="deleteAdminBtn" data-id="${admin.id}">Удалить</button>
</td> admins.forEach(admin => {
`; const tr = document.createElement('tr');
adminsTbody.appendChild(tr); tr.innerHTML = `
} <td>${admin.id}</td>
// Привязываем обработчики к динамическим кнопкам <td><strong>${escapeHtml(admin.login)}</strong></td>
document.querySelectorAll('.changePasswordBtn').forEach(btn => { <td>
btn.addEventListener('click', () => { <div class="action-buttons">
document.getElementById('editAdminId').value = btn.dataset.id; <button class="changePasswordBtn btn-secondary btn-sm" data-id="${admin.id}" data-login="${escapeHtml(admin.login)}">Сменить пароль</button>
document.getElementById('editPassword').value = ''; <button class="deleteAdminBtn btn-danger btn-sm" data-id="${admin.id}">Удалить</button>
modal.style.display = 'flex'; </div>
</td>
`;
adminsTbody.appendChild(tr);
}); });
});
document.querySelectorAll('.deleteAdminBtn').forEach(btn => { document.querySelectorAll('.changePasswordBtn').forEach(btn => {
btn.addEventListener('click', async () => { btn.addEventListener('click', () => {
const id = btn.dataset.id; document.getElementById('editAdminId').value = btn.dataset.id;
if (confirm('Удалить администратора? Вы не сможете удалить самого себя и последнего админа.')) { document.getElementById('editAdminLogin').value = btn.dataset.login;
const res = await fetch(`/api/admins/${id}`, { method: 'DELETE' }); document.getElementById('editPassword').value = '';
if (res.ok) { passwordModal.classList.add('active');
loadAdmins(); });
} else { });
const err = await res.json();
alert('Ошибка: ' + (err.error || 'неизвестная')); document.querySelectorAll('.deleteAdminBtn').forEach(btn => {
btn.addEventListener('click', async () => {
if (confirm('Удалить администратора? Это действие нельзя отменить.')) {
try {
const csrfToken = await getCsrfToken();
const res = await fetch(`/api/admins/${btn.dataset.id}`, {
method: 'DELETE',
headers: { 'X-CSRF-Token': csrfToken }
});
if (res.ok) {
showToast('Администратор удалён', 'success');
loadAdmins();
} else {
const err = await res.json();
showToast(err.error || 'Ошибка удаления', 'error');
}
} catch (err) {
showToast('Ошибка соединения', 'error');
}
} }
} });
}); });
}); } catch (err) {
showToast('Ошибка загрузки', 'error');
}
} }
addForm.addEventListener('submit', async (e) => { addForm.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const login = document.getElementById('newLogin').value.trim(); const login = document.getElementById('newLogin').value.trim();
const password = document.getElementById('newPassword').value; const password = document.getElementById('newPassword').value;
const res = await fetch('/api/admins', {
method: 'POST', try {
headers: { 'Content-Type': 'application/json' }, const csrfToken = await getCsrfToken();
body: JSON.stringify({ login, password }) const res = await fetch('/api/admins', {
}); method: 'POST',
if (res.ok) { headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken },
alert('Администратор добавлен'); body: JSON.stringify({ login, password })
addForm.reset(); });
loadAdmins(); if (res.ok) {
} else { showToast('Администратор добавлен', 'success');
const err = await res.json(); addForm.reset();
alert('Ошибка: ' + (err.error || 'неизвестная')); loadAdmins();
} else {
const err = await res.json();
showToast(err.error || 'Ошибка', 'error');
}
} catch (err) {
showToast('Ошибка соединения', 'error');
} }
}); });
@@ -133,36 +254,32 @@
e.preventDefault(); e.preventDefault();
const id = document.getElementById('editAdminId').value; const id = document.getElementById('editAdminId').value;
const password = document.getElementById('editPassword').value; const password = document.getElementById('editPassword').value;
const res = await fetch(`/api/admins/${id}`, {
method: 'PUT', try {
headers: { 'Content-Type': 'application/json' }, const csrfToken = await getCsrfToken();
body: JSON.stringify({ password }) const res = await fetch(`/api/admins/${id}`, {
}); method: 'PUT',
if (res.ok) { headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken },
alert('Пароль изменён'); body: JSON.stringify({ password })
modal.style.display = 'none'; });
loadAdmins(); if (res.ok) {
} else { showToast('Пароль изменён', 'success');
const err = await res.json(); passwordModal.classList.remove('active');
alert('Ошибка: ' + (err.error || 'неизвестная')); loadAdmins();
} else {
const err = await res.json();
showToast(err.error || 'Ошибка', 'error');
}
} catch (err) {
showToast('Ошибка соединения', 'error');
} }
}); });
closeModalBtn.addEventListener('click', () => { closePasswordBtn.addEventListener('click', () => passwordModal.classList.remove('active'));
modal.style.display = 'none'; passwordModal.addEventListener('click', (e) => { if (e.target === passwordModal) passwordModal.classList.remove('active'); });
}); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && passwordModal.classList.contains('active')) passwordModal.classList.remove('active'); });
function escapeHtml(str) {
if (!str) return '';
return str.replace(/[&<>]/g, function(m) {
if (m === '&') return '&amp;';
if (m === '<') return '&lt;';
if (m === '>') return '&gt;';
return m;
});
}
loadAdmins(); loadAdmins();
</script> </script>
</body> </body>
</html> </html>

View File

@@ -4,62 +4,270 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
<title>Карточка клиента</title>
<style>
.booking-cards { display: none; }
.booking-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.25rem;
margin-bottom: 0.75rem;
box-shadow: var(--shadow-sm);
transition: all var(--transition-base);
animation: cardSlideIn 0.3s ease-out backwards;
}
.booking-card:hover { box-shadow: var(--shadow-md); }
.booking-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.75rem;
}
.booking-card-status-line {
margin-bottom: 0.5rem;
}
.booking-card-body {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.booking-card-field {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.booking-card-label {
font-size: 0.6875rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 600;
}
.booking-card-value {
font-size: 0.875rem;
color: var(--text-primary);
font-weight: 500;
}
.booking-card-comment {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border-light);
font-size: 0.875rem;
color: var(--text-secondary);
}
@keyframes cardSlideIn {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
@media (max-width: 768px) {
.table-container { display: none !important; }
.booking-cards { display: block; }
.booking-card-body {
grid-template-columns: 1fr;
gap: 0.5rem;
}
}
</style>
</head> </head>
<body> <body>
<header></header> <header></header>
<div class="toast-container" id="toastContainer"></div>
<main> <main>
<a href="clients.html" class="btn btn-secondary btn-sm" style="display:inline-flex;align-items:center;gap:0.375rem;margin-bottom:1.5rem;text-decoration:none;">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5"/></svg>
Назад
</a>
<h1>Профиль клиента</h1> <h1>Профиль клиента</h1>
<div id="clientInfo"></div>
<h2>Заявки (сортировка: сначала новые, потом по статусу А-Я)</h2> <div id="clientInfo" class="card" style="margin-bottom:2rem;"></div>
<table id="clientBookingsTable">
<thead> <h2>История заявок</h2>
<tr><th>ID внеш.</th><th>Имя</th><th>Даты</th><th>Статус</th><th>Комментарий</th></tr>
</thead> <div class="table-container">
<tbody></tbody> <table id="clientBookingsTable">
</table> <thead>
<p><a href="clients.html">← Назад к списку</a></p> <tr>
<th>ID</th>
<th>Имя</th>
<th>Даты</th>
<th>Статус</th>
<th>Комментарий</th>
</tr>
</thead>
<tbody id="bookingsBody"></tbody>
</table>
</div>
<div id="bookingCards" class="booking-cards"></div>
<div id="emptyState" class="empty-state" style="display:none;">
<div class="empty-state-icon">
<svg width="64" height="64" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/></svg>
</div>
<h3>Заявок нет</h3>
<p>У этого клиента пока нет заявок</p>
</div>
</main> </main>
<script src="nav.js"></script> <script src="nav.js"></script>
<script src="seo.js"></script> <script src="seo.js"></script>
<script> <script>
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function getStatusClass(status) {
const map = {
'Новая': 'status-new',
'В работе': 'status-in-progress',
'Подтверждена': 'status-confirmed',
'Заселение': 'status-checkin',
'Завершена': 'status-completed',
'Отменена': 'status-cancelled'
};
return map[status] || 'status-new';
}
function showToast(message, type = 'info') {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.innerHTML = `<span>${escapeHtml(message)}</span>`;
container.appendChild(toast);
setTimeout(() => {
toast.classList.add('toast-exit');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
fetch('/api/me').then(r => r.json()).then(data => { fetch('/api/me').then(r => r.json()).then(data => {
if (!data.isAdmin) window.location.href = '/login.html'; if (!data.isAdmin) window.location.href = '/login.html';
}); });
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const clientId = params.get('id'); const clientId = params.get('id');
if (!clientId) { if (!clientId) {
document.body.innerHTML = '<h1>Не указан ID клиента</h1>'; document.querySelector('main').innerHTML = '<h1>Не указан ID клиента</h1><a href="clients.html" class="btn">Вернуться к списку</a>';
throw new Error('No id'); throw new Error('No id');
} }
async function loadClient() { async function loadClient() {
const res = await fetch(`/api/clients/${clientId}`); const clientInfoEl = document.getElementById('clientInfo');
if (!res.ok) { const tbody = document.getElementById('bookingsBody');
document.getElementById('clientInfo').innerHTML = '<p>Клиент не найден</p>'; const bookingCards = document.getElementById('bookingCards');
return; const emptyState = document.getElementById('emptyState');
} const tableContainer = document.querySelector('#clientBookingsTable').parentElement;
const data = await res.json();
document.getElementById('clientInfo').innerHTML = ` clientInfoEl.innerHTML = '<div class="loading-spinner"></div>';
<p><strong>Телефон:</strong> ${data.client.phone}</p> tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;padding:2rem;"><div class="loading-spinner"></div></td></tr>';
<p><strong>Имя:</strong> ${data.client.name || 'не указано'}</p> bookingCards.innerHTML = '<div style="text-align:center;padding:2rem;"><div class="loading-spinner"></div></div>';
`;
const tbody = document.querySelector('#clientBookingsTable tbody'); try {
tbody.innerHTML = ''; const res = await fetch(`/api/clients/${clientId}`);
data.bookings.forEach(b => { if (!res.ok) {
const tr = document.createElement('tr'); clientInfoEl.innerHTML = '<p style="color:var(--danger);">Клиент не найден</p>';
tr.innerHTML = ` tbody.innerHTML = '';
<td>${b.external_id || '-'}</td> bookingCards.innerHTML = '';
<td>${b.name}</td> tableContainer.style.display = 'none';
<td>${b.checkin_date} ${b.checkout_date}</td> return;
<td>${b.status}</td> }
<td>${b.comments || ''}</td>
const data = await res.json();
clientInfoEl.innerHTML = `
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:1.5rem;">
<div>
<div style="color:var(--text-muted);font-size:0.75rem;text-transform:uppercase;letter-spacing:0.05em;font-weight:600;margin-bottom:0.25rem;">Телефон</div>
<div style="font-size:1.0625rem;font-weight:600;color:var(--text-primary);">${escapeHtml(data.client.phone)}</div>
</div>
<div>
<div style="color:var(--text-muted);font-size:0.75rem;text-transform:uppercase;letter-spacing:0.05em;font-weight:600;margin-bottom:0.25rem;">Имя</div>
<div style="font-size:1.0625rem;font-weight:600;color:var(--text-primary);">${escapeHtml(data.client.name) || '<span style="color:var(--text-muted);">не указано</span>'}</div>
</div>
<div>
<div style="color:var(--text-muted);font-size:0.75rem;text-transform:uppercase;letter-spacing:0.05em;font-weight:600;margin-bottom:0.25rem;">Заявок</div>
<div style="font-size:1.0625rem;font-weight:600;color:var(--text-primary);">${data.bookings.length}</div>
</div>
</div>
`; `;
tbody.appendChild(tr);
}); tbody.innerHTML = '';
bookingCards.innerHTML = '';
if (data.bookings.length === 0) {
tableContainer.style.display = 'none';
emptyState.style.display = 'block';
return;
}
tableContainer.style.display = 'block';
emptyState.style.display = 'none';
data.bookings.forEach((b, i) => {
const tr = document.createElement('tr');
tr.style.animationDelay = `${i * 0.03}s`;
tr.innerHTML = `
<td>${escapeHtml(b.external_id) || '—'}</td>
<td>${escapeHtml(b.name)}</td>
<td>${escapeHtml(b.checkin_date)} ${escapeHtml(b.checkout_date)}</td>
<td><span class="status-badge ${getStatusClass(b.status)}">${escapeHtml(b.status)}</span></td>
<td>${escapeHtml(b.comments) || '<span style="color:var(--text-muted);">—</span>'}</td>
`;
tbody.appendChild(tr);
const card = document.createElement('div');
card.className = 'booking-card';
card.style.animationDelay = `${i * 0.03}s`;
let html = `
<div class="booking-card-header">
<div>
<div style="font-weight:600;color:var(--text-primary);">${escapeHtml(b.name)}</div>
<div style="font-size:0.75rem;color:var(--text-muted);">ID: ${escapeHtml(b.external_id) || '—'}</div>
</div>
<span class="status-badge ${getStatusClass(b.status)}">${escapeHtml(b.status)}</span>
</div>
<div class="booking-card-body">
<div class="booking-card-field">
<span class="booking-card-label">Даты</span>
<span class="booking-card-value">${escapeHtml(b.checkin_date)} ${escapeHtml(b.checkout_date)}</span>
</div>
</div>
`;
if (b.comments) {
html += `<div class="booking-card-comment">${escapeHtml(b.comments)}</div>`;
}
card.innerHTML = html;
bookingCards.appendChild(card);
});
} catch (err) {
showToast('Ошибка загрузки', 'error');
clientInfoEl.innerHTML = '';
tbody.innerHTML = '';
bookingCards.innerHTML = '';
}
} }
loadClient(); loadClient();
</script> </script>
</body> </body>
</html> </html>

View File

@@ -4,50 +4,193 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
<title>Клиенты</title>
<style>
.client-cards { display: none; }
.client-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.25rem;
margin-bottom: 0.75rem;
box-shadow: var(--shadow-sm);
transition: all var(--transition-base);
animation: cardSlideIn 0.3s ease-out backwards;
}
.client-card:hover { box-shadow: var(--shadow-md); }
.client-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.client-card-name {
font-weight: 600;
font-size: 1rem;
color: var(--text-primary);
}
.client-card-phone {
font-size: 0.875rem;
color: var(--text-muted);
}
.client-card-footer {
display: flex;
justify-content: flex-end;
padding-top: 0.75rem;
border-top: 1px solid var(--border-light);
}
@media (max-width: 768px) {
.table-container { display: none !important; }
.client-cards { display: block; }
}
</style>
</head> </head>
<body> <body>
<header></header> <header></header>
<div class="toast-container" id="toastContainer"></div>
<main> <main>
<h1>Клиенты</h1> <h1>Клиенты</h1>
<input type="text" id="searchClient" placeholder="Поиск по имени или телефону">
<button id="searchClientBtn">Найти</button> <div class="filter-bar">
<table id="clientsTable"> <div class="search-input-wrapper">
<thead> <input type="text" id="searchClient" placeholder="Поиск по имени или телефону">
<tr><th>ID</th><th>Телефон</th><th>Имя</th><th>Действия</th></tr> </div>
</thead> </div>
<tbody></tbody>
</table> <div class="table-container">
<table id="clientsTable">
<thead>
<tr>
<th>ID</th>
<th>Телефон</th>
<th>Имя</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="clientsBody"></tbody>
</table>
</div>
<div id="clientCards" class="client-cards"></div>
<div id="emptyState" class="empty-state" style="display:none;">
<div class="empty-state-icon">
<svg width="64" height="64" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"/></svg>
</div>
<h3>Клиенты не найдены</h3>
<p>Попробуйте изменить параметры поиска</p>
</div>
</main> </main>
<script src="nav.js"></script> <script src="nav.js"></script>
<script src="seo.js"></script> <script src="seo.js"></script>
<script> <script>
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function showToast(message, type = 'info') {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.innerHTML = `<span>${escapeHtml(message)}</span>`;
container.appendChild(toast);
setTimeout(() => {
toast.classList.add('toast-exit');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
fetch('/api/me').then(r => r.json()).then(data => { fetch('/api/me').then(r => r.json()).then(data => {
if (!data.isAdmin) window.location.href = '/login.html'; if (!data.isAdmin) window.location.href = '/login.html';
}); });
const tbody = document.querySelector('#clientsTable tbody'); const tbody = document.getElementById('clientsBody');
const clientCards = document.getElementById('clientCards');
const emptyState = document.getElementById('emptyState');
const tableContainer = document.querySelector('.table-container');
async function loadClients() { async function loadClients() {
const search = document.getElementById('searchClient').value; const search = document.getElementById('searchClient').value;
let url = '/api/clients?'; let url = '/api/clients?';
if (search) url += `search=${encodeURIComponent(search)}`; if (search) url += `search=${encodeURIComponent(search)}`;
const res = await fetch(url);
const clients = await res.json(); tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;padding:2rem;"><div class="loading-spinner"></div></td></tr>';
tbody.innerHTML = ''; clientCards.innerHTML = '<div style="text-align:center;padding:2rem;"><div class="loading-spinner"></div></div>';
clients.forEach(c => {
const tr = document.createElement('tr'); try {
tr.innerHTML = ` const res = await fetch(url);
<td>${c.id}</td> if (!res.ok) throw new Error('Ошибка загрузки');
<td>${c.phone}</td> const clients = await res.json();
<td>${c.name || '—'}</td>
<td><a href="client.html?id=${c.id}">Профиль</a></td> tbody.innerHTML = '';
`; clientCards.innerHTML = '';
tbody.appendChild(tr);
}); if (clients.length === 0) {
tableContainer.style.display = 'none';
emptyState.style.display = 'block';
return;
}
tableContainer.style.display = 'block';
emptyState.style.display = 'none';
clients.forEach((c, i) => {
const tr = document.createElement('tr');
tr.style.animationDelay = `${i * 0.03}s`;
tr.innerHTML = `
<td>${c.id}</td>
<td>${escapeHtml(c.phone)}</td>
<td>${escapeHtml(c.name) || '<span style="color:var(--text-muted);">—</span>'}</td>
<td><a href="client.html?id=${c.id}" class="btn btn-secondary btn-sm" style="text-decoration:none;display:inline-flex;align-items:center;">Профиль</a></td>
`;
tbody.appendChild(tr);
const card = document.createElement('div');
card.className = 'client-card';
card.style.animationDelay = `${i * 0.03}s`;
card.innerHTML = `
<div class="client-card-header">
<div>
<div class="client-card-name">${escapeHtml(c.name) || 'Без имени'}</div>
<div class="client-card-phone">${escapeHtml(c.phone)}</div>
</div>
</div>
<div class="client-card-footer">
<a href="client.html?id=${c.id}" class="btn btn-secondary btn-sm" style="text-decoration:none;display:inline-flex;align-items:center;">Профиль</a>
</div>
`;
clientCards.appendChild(card);
});
} catch (err) {
showToast('Ошибка загрузки', 'error');
tbody.innerHTML = '';
clientCards.innerHTML = '';
}
} }
document.getElementById('searchClientBtn').addEventListener('click', loadClients); let searchTimeout;
document.getElementById('searchClient').addEventListener('input', (e) => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(loadClients, 400);
});
document.getElementById('searchClient').addEventListener('keypress', (e) => {
if (e.key === 'Enter') { clearTimeout(searchTimeout); loadClients(); }
});
loadClients(); loadClients();
</script> </script>
</body> </body>
</html> </html>

View File

@@ -4,108 +4,292 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
<title>Заявки на заселение</title>
</head> </head>
<body> <body>
<header></header> <header></header>
<div class="toast-container" id="toastContainer"></div>
<main> <main>
<h1>Заявки на заселение</h1> <h1>Заявки на заселение</h1>
<div>
<label>Фильтр по статусу: <div class="stats-grid" id="statsGrid">
<select id="statusFilter"> <div class="stat-card">
<option value="">Все</option> <div class="stat-value" id="statTotal">0</div>
<option value="Новая">Новая</option> <div class="stat-label">Всего</div>
<option value="В работе">В работе</option> </div>
<option value="Подтверждена">Подтверждена</option> <div class="stat-card">
<option value="Заселение">Заселение</option> <div class="stat-value" id="statNew">0</div>
<option value="Завершена">Завершена</option> <div class="stat-label">Новые</div>
<option value="Отменена">Отменена</option> </div>
</select> <div class="stat-card">
</label> <div class="stat-value" id="statInProgress">0</div>
<input type="text" id="searchInput" placeholder="Поиск по имени, телефону или комментарию"> <div class="stat-label">В работе</div>
<button id="searchBtn">Найти</button> </div>
<div class="stat-card">
<div class="stat-value" id="statConfirmed">0</div>
<div class="stat-label">Подтверждены</div>
</div>
</div>
<div class="filter-bar">
<div class="search-input-wrapper">
<input type="text" id="searchInput" placeholder="Поиск по имени, телефону или комментарию">
</div>
<select id="statusFilter">
<option value="">Все статусы</option>
<option value="Новая">Новая</option>
<option value="В работе">В работе</option>
<option value="Подтверждена">Подтверждена</option>
<option value="Заселение">Заселение</option>
<option value="Завершена">Завершена</option>
<option value="Отменена">Отменена</option>
</select>
</div>
<div id="tableContainer" class="table-container">
<table id="bookingsTable">
<thead>
<tr>
<th>ID</th>
<th>Имя гостя</th>
<th>Телефон</th>
<th>Даты проживания</th>
<th>Статус</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="bookingsBody"></tbody>
</table>
</div>
<div id="bookingCards" class="booking-cards"></div>
<div id="emptyState" class="empty-state" style="display: none;">
<div class="empty-state-icon">
<svg width="64" height="64" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/></svg>
</div>
<h3>Заявки не найдены</h3>
<p>Попробуйте изменить параметры поиска</p>
</div> </div>
<table id="bookingsTable">
<thead>
<tr>
<th>ID внеш.</th>
<th>Имя</th>
<th>Телефон</th>
<th>Даты</th>
<th>Статус</th>
<th>Действия</th>
</tr>
</thead>
<tbody></tbody>
</table>
<!-- Модальное окно редактирования --> <div id="editModal" class="modal">
<div id="editModal" class="modal" style="display:none;">
<div class="modal-content"> <div class="modal-content">
<h2>Редактировать заявку</h2> <h2>Редактировать заявку</h2>
<form id="editForm"> <form id="editForm">
<input type="hidden" id="editId"> <input type="hidden" id="editId">
<label>Статус</label>
<select id="editStatus"> <div class="form-group">
<option value="Новая">Новая</option> <label for="editStatus">Статус заявки</label>
<option value="В работе">В работе</option> <select id="editStatus">
<option value="Подтверждена">Подтверждена</option> <option value="Новая">Новая</option>
<option value="Заселение">Заселение</option> <option value="В работе">В работе</option>
<option value="Завершена">Завершена</option> <option value="Подтверждена">Подтверждена</option>
<option value="Отменена">Отменена</option> <option value="Заселение">Заселение</option>
</select> <option value="Завершена">Завершена</option>
<label>Комментарий</label> <option value="Отменена">Отменена</option>
<textarea id="editComments" rows="3"></textarea> </select>
<label>Переназначить на карточку клиента (по телефону)</label> </div>
<input type="text" id="editPhone" placeholder="Телефон (любой формат)">
<button type="submit">Сохранить</button> <div class="form-group">
<button type="button" id="closeModal">Отмена</button> <label for="editComments">Комментарий</label>
<textarea id="editComments" rows="3" placeholder="Добавьте комментарий к заявке..."></textarea>
</div>
<div class="form-group">
<label for="editPhone">Переназначить на клиента (по телефону)</label>
<input type="text" id="editPhone" placeholder="Введите номер телефона">
</div>
<div style="display: flex; gap: 0.75rem; margin-top: 1.5rem;">
<button type="submit" id="saveBtn" style="flex:1;">Сохранить</button>
<button type="button" id="closeModal" class="btn-secondary">Отмена</button>
</div>
</form> </form>
</div> </div>
</div> </div>
</main> </main>
<script src="nav.js"></script> <script src="nav.js"></script>
<script src="seo.js"></script> <script src="seo.js"></script>
<script> <script>
// Проверка авторизации function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function getStatusClass(status) {
const map = {
'Новая': 'status-new',
'В работе': 'status-in-progress',
'Подтверждена': 'status-confirmed',
'Заселение': 'status-checkin',
'Завершена': 'status-completed',
'Отменена': 'status-cancelled'
};
return map[status] || 'status-new';
}
function showToast(message, type = 'info') {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.innerHTML = `<span>${escapeHtml(message)}</span>`;
container.appendChild(toast);
setTimeout(() => {
toast.classList.add('toast-exit');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
async function getCsrfToken() {
const stored = window.localStorage.getItem('csrfToken');
if (stored) return stored;
try {
const res = await fetch('/api/csrf-token');
const data = await res.json();
window.localStorage.setItem('csrfToken', data.csrfToken);
return data.csrfToken;
} catch (e) {
return null;
}
}
fetch('/api/me').then(r => r.json()).then(data => { fetch('/api/me').then(r => r.json()).then(data => {
if (!data.isAdmin) window.location.href = '/login.html'; if (!data.isAdmin) window.location.href = '/login.html';
}); });
const tableBody = document.querySelector('#bookingsTable tbody'); const tableBody = document.getElementById('bookingsBody');
const bookingCards = document.getElementById('bookingCards');
const modal = document.getElementById('editModal'); const modal = document.getElementById('editModal');
const emptyState = document.getElementById('emptyState');
const tableContainer = document.getElementById('tableContainer');
let currentBookings = []; let currentBookings = [];
function updateStats(bookings) {
const total = bookings.length;
const stats = { 'Новая': 0, 'В работе': 0, 'Подтверждена': 0 };
bookings.forEach(b => {
if (stats[b.status] !== undefined) stats[b.status]++;
});
animateValue('statTotal', total);
animateValue('statNew', stats['Новая']);
animateValue('statInProgress', stats['В работе']);
animateValue('statConfirmed', stats['Подтверждена']);
}
function animateValue(id, value) {
const el = document.getElementById(id);
const start = parseInt(el.textContent) || 0;
const duration = 500;
const startTime = performance.now();
function update(currentTime) {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
el.textContent = Math.round(start + (value - start) * progress);
if (progress < 1) requestAnimationFrame(update);
}
requestAnimationFrame(update);
}
async function loadBookings() { async function loadBookings() {
const status = document.getElementById('statusFilter').value; const status = document.getElementById('statusFilter').value;
const search = document.getElementById('searchInput').value; const search = document.getElementById('searchInput').value;
let url = '/api/bookings?'; let url = '/api/bookings?';
if (status) url += `status=${encodeURIComponent(status)}&`; if (status) url += `status=${encodeURIComponent(status)}&`;
if (search) url += `search=${encodeURIComponent(search)}&`; if (search) url += `search=${encodeURIComponent(search)}&`;
const res = await fetch(url);
currentBookings = await res.json(); tableBody.innerHTML = '<tr><td colspan="6" style="text-align:center;padding:2rem;"><div class="loading-spinner"></div></td></tr>';
renderTable(); bookingCards.innerHTML = '<div style="text-align:center;padding:2rem;"><div class="loading-spinner"></div></div>';
try {
const res = await fetch(url);
if (!res.ok) throw new Error('Ошибка загрузки');
currentBookings = await res.json();
updateStats(currentBookings);
renderTable();
renderCards();
} catch (err) {
showToast('Ошибка загрузки заявок', 'error');
tableBody.innerHTML = '';
bookingCards.innerHTML = '';
}
} }
function renderTable() { function renderTable() {
tableBody.innerHTML = ''; tableBody.innerHTML = '';
currentBookings.forEach(b => { if (currentBookings.length === 0) {
tableContainer.style.display = 'none';
bookingCards.innerHTML = '';
emptyState.style.display = 'block';
return;
}
tableContainer.style.display = 'block';
emptyState.style.display = 'none';
currentBookings.forEach((b, i) => {
const row = document.createElement('tr'); const row = document.createElement('tr');
row.style.animationDelay = `${i * 0.03}s`;
row.innerHTML = ` row.innerHTML = `
<td>${b.external_id || '-'}</td> <td>${escapeHtml(b.external_id) || ''}</td>
<td>${b.name}</td> <td><strong>${escapeHtml(b.name)}</strong></td>
<td>${b.phone_raw}</td> <td>${escapeHtml(b.phone_raw)}</td>
<td>${b.checkin_date} ${b.checkout_date}</td> <td>${escapeHtml(b.checkin_date)} ${escapeHtml(b.checkout_date)}</td>
<td>${b.status}</td> <td><span class="status-badge ${getStatusClass(b.status)}">${escapeHtml(b.status)}</span></td>
<td><button class="editBtn" data-id="${b.id}">Изменить</button></td> <td><button class="editBtn btn-secondary btn-sm" data-id="${b.id}">Изменить</button></td>
`; `;
tableBody.appendChild(row); tableBody.appendChild(row);
}); });
} }
function renderCards() {
bookingCards.innerHTML = '';
if (currentBookings.length === 0) return;
emptyState.style.display = 'none';
currentBookings.forEach((b, i) => {
const card = document.createElement('div');
card.className = 'booking-card';
card.style.animationDelay = `${i * 0.03}s`;
card.innerHTML = `
<div class="booking-card-header">
<div>
<div class="booking-card-name">${escapeHtml(b.name)}</div>
<div class="booking-card-id">ID: ${escapeHtml(b.external_id) || '—'}</div>
</div>
<span class="status-badge ${getStatusClass(b.status)}">${escapeHtml(b.status)}</span>
</div>
<div class="booking-card-body">
<div class="booking-card-field">
<span class="booking-card-label">Телефон</span>
<span class="booking-card-value">${escapeHtml(b.phone_raw)}</span>
</div>
<div class="booking-card-field">
<span class="booking-card-label">Даты</span>
<span class="booking-card-value">${escapeHtml(b.checkin_date)} ${escapeHtml(b.checkout_date)}</span>
</div>
</div>
<div class="booking-card-footer">
<button class="editBtn btn-secondary btn-sm" data-id="${b.id}">Редактировать</button>
</div>
`;
bookingCards.appendChild(card);
});
}
document.getElementById('searchBtn').addEventListener('click', loadBookings);
document.getElementById('statusFilter').addEventListener('change', loadBookings); document.getElementById('statusFilter').addEventListener('change', loadBookings);
let searchTimeout;
document.getElementById('searchInput').addEventListener('input', (e) => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(loadBookings, 400);
});
document.getElementById('searchInput').addEventListener('keypress', (e) => {
if (e.key === 'Enter') { clearTimeout(searchTimeout); loadBookings(); }
});
// Редактирование
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
if (e.target.classList.contains('editBtn')) { if (e.target.classList.contains('editBtn')) {
const id = e.target.dataset.id; const id = e.target.dataset.id;
@@ -115,41 +299,54 @@
document.getElementById('editStatus').value = booking.status; document.getElementById('editStatus').value = booking.status;
document.getElementById('editComments').value = booking.comments || ''; document.getElementById('editComments').value = booking.comments || '';
document.getElementById('editPhone').value = ''; document.getElementById('editPhone').value = '';
modal.style.display = 'flex'; modal.classList.add('active');
} }
}); });
document.getElementById('closeModal').addEventListener('click', () => { document.getElementById('closeModal').addEventListener('click', () => modal.classList.remove('active'));
modal.style.display = 'none'; modal.addEventListener('click', (e) => { if (e.target === modal) modal.classList.remove('active'); });
}); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && modal.classList.contains('active')) modal.classList.remove('active'); });
document.getElementById('editForm').addEventListener('submit', async (e) => { document.getElementById('editForm').addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const saveBtn = document.getElementById('saveBtn');
saveBtn.disabled = true;
saveBtn.innerHTML = '<span class="loading-spinner"></span>';
const id = document.getElementById('editId').value; const id = document.getElementById('editId').value;
const status = document.getElementById('editStatus').value; const status = document.getElementById('editStatus').value;
const comments = document.getElementById('editComments').value; const comments = document.getElementById('editComments').value;
const phone = document.getElementById('editPhone').value.trim(); const phone = document.getElementById('editPhone').value.trim();
const body = {}; const body = { comments };
if (status) body.status = status; if (status) body.status = status;
body.comments = comments;
if (phone) body.phone = phone; if (phone) body.phone = phone;
const csrfToken = await getCsrfToken();
const res = await fetch(`/api/bookings/${id}`, { try {
method: 'PUT', const res = await fetch(`/api/bookings/${id}`, {
headers: { 'Content-Type': 'application/json' }, method: 'PUT',
body: JSON.stringify(body) headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken },
}); body: JSON.stringify(body)
if (res.ok) { });
modal.style.display = 'none'; if (res.ok) {
loadBookings(); modal.classList.remove('active');
} else { showToast('Заявка обновлена', 'success');
alert('Ошибка сохранения'); loadBookings();
} else {
const err = await res.json();
showToast(err.error || 'Ошибка сохранения', 'error');
}
} catch (err) {
showToast('Ошибка соединения', 'error');
} finally {
saveBtn.disabled = false;
saveBtn.innerHTML = 'Сохранить';
} }
}); });
// Первоначальная загрузка
loadBookings(); loadBookings();
</script> </script>
</body> </body>
</html> </html>

View File

@@ -4,43 +4,199 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
<title>Вход</title> <title>Вход в систему</title>
<style>
.login-page {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.login-main {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
}
.login-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
padding: clamp(1.5rem, 5vw, 3rem);
width: 100%;
max-width: 420px;
box-shadow: var(--shadow-lg);
animation: fadeInUp 0.5s ease-out;
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.login-icon {
width: 56px;
height: 56px;
background: var(--primary-light);
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 1.5rem;
}
.login-icon svg {
width: 28px;
height: 28px;
color: var(--primary);
}
.login-title {
font-size: clamp(1.375rem, 4vw, 1.75rem);
font-weight: 700;
text-align: center;
margin-bottom: 0.375rem;
color: var(--text-primary);
letter-spacing: -0.025em;
}
.login-subtitle {
color: var(--text-muted);
text-align: center;
font-size: 0.875rem;
margin-bottom: 2rem;
}
.login-btn {
width: 100%;
padding: 0.75rem;
font-size: 0.9375rem;
font-weight: 600;
margin-top: 0.5rem;
justify-content: center;
}
.error-message {
background: var(--danger-light);
border: 1px solid #fca5a5;
color: var(--danger);
padding: 0.75rem 1rem;
border-radius: var(--radius-sm);
font-size: 0.875rem;
margin-top: 1rem;
display: none;
animation: shake 0.4s ease-in-out;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-6px); }
75% { transform: translateX(6px); }
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.375rem;
color: var(--text-secondary);
font-size: 0.875rem;
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 0.75rem 0.875rem;
font-size: 0.9375rem;
}
</style>
</head> </head>
<body> <body>
<header></header> <div class="login-page">
<main> <header></header>
<h1>Вход в систему управления</h1>
<form id="loginForm"> <main class="login-main">
<label>Логин</label> <div class="login-card">
<input type="text" name="login" required> <div class="login-icon">
<label>Пароль</label> <svg fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 21v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21m0 0h4.5V3.545L12.75 2.25 5.25 3.545V21"/></svg>
<input type="password" name="password" required> </div>
<button type="submit">Войти</button> <h1 class="login-title">Отель 777</h1>
<p id="error" style="color: red; display: none;"></p> <p class="login-subtitle">Панель управления</p>
</form>
</main> <form id="loginForm">
<div class="form-group">
<label for="login">Логин</label>
<input type="text" id="login" name="login" required autocomplete="username" placeholder="Введите логин">
</div>
<div class="form-group">
<label for="password">Пароль</label>
<input type="password" id="password" name="password" required autocomplete="current-password" placeholder="Введите пароль">
</div>
<button type="submit" class="login-btn" id="loginBtn">
<span id="loginBtnText">Войти</span>
<span id="loginBtnSpinner" class="loading-spinner" style="display:none;"></span>
</button>
<p id="error" class="error-message"></p>
</form>
</div>
</main>
</div>
<script src="nav.js"></script> <script src="nav.js"></script>
<script src="seo.js"></script>
<script> <script>
document.getElementById('loginForm').addEventListener('submit', async (e) => { document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const form = e.target; const loginBtn = document.getElementById('loginBtn');
const res = await fetch('/api/login', { const loginBtnText = document.getElementById('loginBtnText');
method: 'POST', const loginBtnSpinner = document.getElementById('loginBtnSpinner');
headers: { 'Content-Type': 'application/json' }, const errorEl = document.getElementById('error');
body: JSON.stringify({
login: form.login.value, loginBtn.disabled = true;
password: form.password.value loginBtnText.style.display = 'none';
}) loginBtnSpinner.style.display = 'inline-block';
}); errorEl.style.display = 'none';
if (res.ok) {
window.location.href = '/admin.html'; try {
} else { const csrfRes = await fetch('/api/csrf-token');
const err = document.getElementById('error'); const csrfData = await csrfRes.json();
err.textContent = 'Неверный логин или пароль';
err.style.display = 'block'; const res = await fetch('/api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfData.csrfToken
},
body: JSON.stringify({
login: document.getElementById('login').value,
password: document.getElementById('password').value
})
});
if (res.ok) {
const data = await res.json();
window.localStorage.setItem('csrfToken', data.csrfToken);
window.location.href = '/index.html';
} else {
const err = await res.json();
errorEl.textContent = err.error || 'Неверный логин или пароль';
errorEl.style.display = 'block';
}
} catch (err) {
errorEl.textContent = 'Ошибка соединения с сервером';
errorEl.style.display = 'block';
} finally {
loginBtn.disabled = false;
loginBtnText.style.display = 'inline';
loginBtnSpinner.style.display = 'none';
} }
}); });
</script> </script>
</body> </body>
</html> </html>

View File

@@ -1,16 +1,35 @@
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const currentPage = window.location.pathname.split('/').pop() || 'index.html';
const navHTML = ` const navHTML = `
<nav> <nav>
<a href="/">Главная</a> <a href="/index.html" data-page="index.html">Заявки</a>
<a href="/clients.html">Клиенты</a> <a href="/clients.html" data-page="clients.html">Клиенты</a>
<a href="/admin.html">Панель управления</a> <a href="/admin.html" data-page="admin.html">Панель управления</a>
<a id="logoutLink" href="#" style="display:none;">Выход</a> <a id="logoutLink" href="#" style="display:none;">Выход</a>
</nav> </nav>
`; `;
const header = document.querySelector('header'); const header = document.querySelector('header');
if (header) header.innerHTML += navHTML; if (header) header.innerHTML += navHTML;
// Показать/скрыть выход, проверив сессию const pageMap = {
'': 'index.html',
'index.html': 'index.html',
'clients.html': 'clients.html',
'client.html': 'clients.html',
'admin.html': 'admin.html',
'login.html': 'login.html'
};
const activePage = pageMap[currentPage] || '';
document.querySelectorAll('nav a[data-page]').forEach(link => {
if (link.dataset.page === activePage) {
link.classList.add('active');
}
});
fetch('/api/me') fetch('/api/me')
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
@@ -23,7 +42,10 @@ document.addEventListener('DOMContentLoaded', () => {
if (e.target.id === 'logoutLink') { if (e.target.id === 'logoutLink') {
e.preventDefault(); e.preventDefault();
fetch('/api/logout', { method: 'POST' }) fetch('/api/logout', { method: 'POST' })
.then(() => window.location.href = '/login.html'); .then(() => {
window.localStorage.removeItem('csrfToken');
window.location.href = '/login.html';
});
} }
}); });
}); });

File diff suppressed because it is too large Load Diff

331
server.js
View File

@@ -5,7 +5,7 @@ const bcrypt = require('bcrypt');
const cron = require('node-cron'); const cron = require('node-cron');
const { db, normalizePhone, logAction } = require('./db'); const { db, normalizePhone, logAction } = require('./db');
const { sessionMiddleware, ensureAdmin, requireAdmin } = require('./auth'); const { sessionMiddleware, csrfProtection, injectCsrfToken, ensureAdmin, requireAdmin } = require('./auth');
const { syncBookings } = require('./sync'); const { syncBookings } = require('./sync');
const { notifyBookingUpdate, verifyEmailConnection } = require('./mailer'); const { notifyBookingUpdate, verifyEmailConnection } = require('./mailer');
@@ -15,10 +15,16 @@ const PORT = process.env.PORT || 3000;
// Middleware // Middleware
app.use(express.json()); app.use(express.json());
app.use(sessionMiddleware); app.use(sessionMiddleware);
app.use(injectCsrfToken);
// Статика из public // Статика из public
app.use(express.static(path.join(__dirname, 'public'))); app.use(express.static(path.join(__dirname, 'public')));
// CSRF token endpoint
app.get('/api/csrf-token', (req, res) => {
res.json({ csrfToken: req.session.csrfToken });
});
// === Инициализация === // === Инициализация ===
// Асинхронное создание/обновление администратора из .env // Асинхронное создание/обновление администратора из .env
(async () => { (async () => {
@@ -33,20 +39,29 @@ verifyEmailConnection().catch(err => {
// === API === // === API ===
// Вход администратора (с хэшированием и сохранением adminId) // Вход администратора (с хэшированием и сохранением adminId)
app.post('/api/login', async (req, res) => { app.post('/api/login', csrfProtection, async (req, res) => {
const { login, password } = req.body; try {
const admin = db.prepare('SELECT id, login, password FROM admins WHERE login = ?').get(login); const { login, password } = req.body;
if (admin && await bcrypt.compare(password, admin.password)) { if (!login || !password) {
req.session.isAdmin = true; return res.status(400).json({ error: 'Логин и пароль обязательны' });
req.session.adminId = admin.id; }
res.json({ success: true }); const admin = db.prepare('SELECT id, login, password FROM admins WHERE login = ?').get(login);
} else { if (admin && await bcrypt.compare(password, admin.password)) {
res.status(401).json({ error: 'Неверный логин или пароль' }); req.session.isAdmin = true;
req.session.adminId = admin.id;
req.session.csrfToken = req.session.csrfToken;
res.json({ success: true, csrfToken: req.session.csrfToken });
} else {
res.status(401).json({ error: 'Неверный логин или пароль' });
}
} catch (err) {
console.error('Ошибка входа:', err);
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
} }
}); });
// Выход // Выход
app.post('/api/logout', (req, res) => { app.post('/api/logout', csrfProtection, (req, res) => {
req.session.destroy(); req.session.destroy();
res.json({ success: true }); res.json({ success: true });
}); });
@@ -60,182 +75,232 @@ app.get('/api/me', (req, res) => {
// Получить список всех администраторов (только id и login) // Получить список всех администраторов (только id и login)
app.get('/api/admins', requireAdmin, (req, res) => { app.get('/api/admins', requireAdmin, (req, res) => {
const admins = db.prepare('SELECT id, login FROM admins ORDER BY id').all(); try {
res.json(admins); const admins = db.prepare('SELECT id, login FROM admins ORDER BY id').all();
res.json(admins);
} catch (err) {
console.error('Ошибка получения администраторов:', err);
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
}
}); });
// Добавить нового администратора // Добавить нового администратора
app.post('/api/admins', requireAdmin, async (req, res) => { app.post('/api/admins', requireAdmin, csrfProtection, async (req, res) => {
const { login, password } = req.body; try {
if (!login || !password) { const { login, password } = req.body;
return res.status(400).json({ error: 'Логин и пароль обязательны' }); if (!login || !password) {
return res.status(400).json({ error: 'Логин и пароль обязательны' });
}
if (login.length < 3 || password.length < 6) {
return res.status(400).json({ error: 'Логин минимум 3 символа, пароль минимум 6 символов' });
}
const existing = db.prepare('SELECT id FROM admins WHERE login = ?').get(login);
if (existing) {
return res.status(409).json({ error: 'Администратор с таким логином уже существует' });
}
const saltRounds = 10;
const hashed = await bcrypt.hash(password, saltRounds);
const stmt = db.prepare('INSERT INTO admins (login, password) VALUES (?, ?)');
const info = stmt.run(login, hashed);
res.status(201).json({ id: info.lastInsertRowid, login });
} catch (err) {
console.error('Ошибка создания администратора:', err);
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
} }
const existing = db.prepare('SELECT id FROM admins WHERE login = ?').get(login);
if (existing) {
return res.status(409).json({ error: 'Администратор с таким логином уже существует' });
}
const saltRounds = 10;
const hashed = await bcrypt.hash(password, saltRounds);
const stmt = db.prepare('INSERT INTO admins (login, password) VALUES (?, ?)');
const info = stmt.run(login, hashed);
res.status(201).json({ id: info.lastInsertRowid, login });
}); });
// Изменить пароль администратора // Изменить пароль администратора
app.put('/api/admins/:id', requireAdmin, async (req, res) => { app.put('/api/admins/:id', requireAdmin, csrfProtection, async (req, res) => {
const { id } = req.params; try {
const { password } = req.body; const { id } = req.params;
if (!password) { const { password } = req.body;
return res.status(400).json({ error: 'Новый пароль обязателен' }); if (!password) {
return res.status(400).json({ error: 'Новый пароль обязателен' });
}
if (password.length < 6) {
return res.status(400).json({ error: 'Пароль минимум 6 символов' });
}
const admin = db.prepare('SELECT id, login FROM admins WHERE id = ?').get(id);
if (!admin) {
return res.status(404).json({ error: 'Администратор не найден' });
}
const saltRounds = 10;
const hashed = await bcrypt.hash(password, saltRounds);
db.prepare('UPDATE admins SET password = ? WHERE id = ?').run(hashed, id);
res.json({ id: admin.id, login: admin.login });
} catch (err) {
console.error('Ошибка обновления пароля:', err);
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
} }
const admin = db.prepare('SELECT id, login FROM admins WHERE id = ?').get(id);
if (!admin) {
return res.status(404).json({ error: 'Администратор не найден' });
}
const saltRounds = 10;
const hashed = await bcrypt.hash(password, saltRounds);
db.prepare('UPDATE admins SET password = ? WHERE id = ?').run(hashed, id);
res.json({ id: admin.id, login: admin.login });
}); });
// Удалить администратора // Удалить администратора
app.delete('/api/admins/:id', requireAdmin, (req, res) => { app.delete('/api/admins/:id', requireAdmin, csrfProtection, (req, res) => {
const { id } = req.params; try {
// Проверка: нельзя удалить последнего администратора const { id } = req.params;
const count = db.prepare('SELECT COUNT(*) as cnt FROM admins').get().cnt; const count = db.prepare('SELECT COUNT(*) as cnt FROM admins').get().cnt;
if (count <= 1) { if (count <= 1) {
return res.status(400).json({ error: 'Нельзя удалить последнего администратора' }); return res.status(400).json({ error: 'Нельзя удалить последнего администратора' });
}
if (req.session.adminId && req.session.adminId == id) {
return res.status(400).json({ error: 'Нельзя удалить свою учётную запись' });
}
const stmt = db.prepare('DELETE FROM admins WHERE id = ?');
const result = stmt.run(id);
if (result.changes === 0) {
return res.status(404).json({ error: 'Администратор не найден' });
}
res.json({ success: true });
} catch (err) {
console.error('Ошибка удаления администратора:', err);
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
} }
// Нельзя удалить самого себя
if (req.session.adminId && req.session.adminId == id) {
return res.status(400).json({ error: 'Нельзя удалить свою учётную запись' });
}
const stmt = db.prepare('DELETE FROM admins WHERE id = ?');
const result = stmt.run(id);
if (result.changes === 0) {
return res.status(404).json({ error: 'Администратор не найден' });
}
res.json({ success: true });
}); });
// --- Заявки (bookings) --- // --- Заявки (bookings) ---
// Список заявок с фильтрами // Список заявок с фильтрами
app.get('/api/bookings', requireAdmin, (req, res) => { app.get('/api/bookings', requireAdmin, (req, res) => {
const { status, search, client_id } = req.query; try {
let query = 'SELECT * FROM bookings WHERE 1=1'; const { status, search, client_id } = req.query;
const params = []; let query = 'SELECT * FROM bookings WHERE 1=1';
const params = [];
if (status) { if (status) {
query += ' AND status = ?'; query += ' AND status = ?';
params.push(status); params.push(status);
}
if (client_id) {
query += ' AND user_id = ?';
params.push(client_id);
}
if (search) {
query += ' AND (name LIKE ? OR phone_raw LIKE ? OR comments LIKE ?)';
params.push(`%${search}%`, `%${search}%`, `%${search}%`);
}
query += ' ORDER BY created_at DESC';
const bookings = db.prepare(query).all(...params);
res.json(bookings);
} catch (err) {
console.error('Ошибка получения заявок:', err);
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
} }
if (client_id) {
query += ' AND user_id = ?';
params.push(client_id);
}
if (search) {
query += ' AND (name LIKE ? OR phone_raw LIKE ? OR comments LIKE ?)';
params.push(`%${search}%`, `%${search}%`, `%${search}%`);
}
query += ' ORDER BY created_at DESC';
const bookings = db.prepare(query).all(...params);
res.json(bookings);
}); });
// Детали заявки // Детали заявки
app.get('/api/bookings/:id', requireAdmin, (req, res) => { app.get('/api/bookings/:id', requireAdmin, (req, res) => {
const booking = db.prepare('SELECT * FROM bookings WHERE id = ?').get(req.params.id); try {
if (!booking) return res.status(404).json({ error: 'Заявка не найдена' }); const booking = db.prepare('SELECT * FROM bookings WHERE id = ?').get(req.params.id);
res.json(booking); if (!booking) return res.status(404).json({ error: 'Заявка не найдена' });
res.json(booking);
} catch (err) {
console.error('Ошибка получения заявки:', err);
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
}
}); });
// Обновление заявки (статус, комментарий, перепривязка клиента) // Обновление заявки (статус, комментарий, перепривязка клиента)
app.put('/api/bookings/:id', requireAdmin, (req, res) => { app.put('/api/bookings/:id', requireAdmin, csrfProtection, (req, res) => {
const { id } = req.params; try {
const { status, comments, phone } = req.body; const { id } = req.params;
const { status, comments, phone } = req.body;
const booking = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id); const booking = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id);
if (!booking) return res.status(404).json({ error: 'Заявка не найдена' }); if (!booking) return res.status(404).json({ error: 'Заявка не найдена' });
const changes = {}; const changes = {};
// Обновление статуса if (status && status !== booking.status) {
if (status && status !== booking.status) { const allowed = ['Новая', 'В работе', 'Подтверждена', 'Заселение', 'Завершена', 'Отменена'];
const allowed = ['Новая', 'В работе', 'Подтверждена', 'Заселение', 'Завершена', 'Отменена']; if (!allowed.includes(status)) {
if (!allowed.includes(status)) { return res.status(400).json({ error: 'Недопустимый статус' });
return res.status(400).json({ error: 'Недопустимый статус' }); }
db.prepare('UPDATE bookings SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(status, id);
changes.status = { from: booking.status, to: status };
} }
db.prepare('UPDATE bookings SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(status, id);
changes.status = { from: booking.status, to: status };
}
// Обновление комментария if (comments !== undefined && comments !== booking.comments) {
if (comments !== undefined && comments !== booking.comments) { db.prepare('UPDATE bookings SET comments = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(comments, id);
db.prepare('UPDATE bookings SET comments = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(comments, id); changes.comments = { from: booking.comments, to: comments };
changes.comments = { from: booking.comments, to: comments };
}
// Перепривязка к другой карточке по номеру телефона
if (phone) {
const normPhone = normalizePhone(phone);
const user = db.prepare('SELECT id FROM users WHERE phone = ?').get(normPhone);
if (!user) {
return res.status(400).json({ error: 'Карточка клиента с таким телефоном не найдена' });
} }
if (user.id !== booking.user_id) {
db.prepare('UPDATE bookings SET user_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(user.id, id); if (phone) {
changes.user_id = { from: booking.user_id, to: user.id }; const normPhone = normalizePhone(phone);
const user = db.prepare('SELECT id FROM users WHERE phone = ?').get(normPhone);
if (!user) {
return res.status(400).json({ error: 'Карточка клиента с таким телефоном не найдена' });
}
if (user.id !== booking.user_id) {
db.prepare('UPDATE bookings SET user_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(user.id, id);
changes.user_id = { from: booking.user_id, to: user.id };
}
} }
}
// Если были изменения логируем и шлём уведомление if (Object.keys(changes).length > 0) {
if (Object.keys(changes).length > 0) { logAction(`Изменение заявки #${id}`, changes);
logAction(`Изменение заявки #${id}`, changes); const updated = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id);
const updated = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id); notifyBookingUpdate(updated, changes);
notifyBookingUpdate(updated, changes); }
}
const updatedBooking = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id); const updatedBooking = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id);
res.json(updatedBooking); res.json(updatedBooking);
} catch (err) {
console.error('Ошибка обновления заявки:', err);
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
}
}); });
// --- Карточки клиентов --- // --- Карточки клиентов ---
// Список клиентов // Список клиентов
app.get('/api/clients', requireAdmin, (req, res) => { app.get('/api/clients', requireAdmin, (req, res) => {
const { search } = req.query; try {
let query = 'SELECT * FROM users WHERE 1=1'; const { search } = req.query;
const params = []; let query = 'SELECT * FROM users WHERE 1=1';
if (search) { const params = [];
query += ' AND (phone LIKE ? OR name LIKE ?)'; if (search) {
params.push(`%${search}%`, `%${search}%`); query += ' AND (phone LIKE ? OR name LIKE ?)';
params.push(`%${search}%`, `%${search}%`);
}
query += ' ORDER BY name ASC';
const clients = db.prepare(query).all(...params);
res.json(clients);
} catch (err) {
console.error('Ошибка получения клиентов:', err);
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
} }
query += ' ORDER BY name ASC';
const clients = db.prepare(query).all(...params);
res.json(clients);
}); });
// Профиль клиента с его заявками // Профиль клиента с его заявками
app.get('/api/clients/:id', requireAdmin, (req, res) => { app.get('/api/clients/:id', requireAdmin, (req, res) => {
const client = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id); try {
if (!client) return res.status(404).json({ error: 'Клиент не найден' }); const client = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
if (!client) return res.status(404).json({ error: 'Клиент не найден' });
const bookings = db.prepare(` const bookings = db.prepare(`
SELECT * FROM bookings SELECT * FROM bookings
WHERE user_id = ? WHERE user_id = ?
ORDER BY created_at DESC, status ASC ORDER BY created_at DESC, status ASC
`).all(req.params.id); `).all(req.params.id);
res.json({ client, bookings }); res.json({ client, bookings });
} catch (err) {
console.error('Ошибка получения профиля клиента:', err);
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
}
}); });
// --- Синхронизация --- // --- Синхронизация ---
// Ручной запуск синхронизации // Ручной запуск синхронизации
app.post('/api/admin/sync', requireAdmin, async (req, res) => { app.post('/api/admin/sync', requireAdmin, csrfProtection, async (req, res) => {
await syncBookings(); try {
res.json({ success: true, message: 'Синхронизация запущена' }); await syncBookings();
res.json({ success: true, message: 'Синхронизация запущена' });
} catch (err) {
console.error('Ошибка синхронизации:', err);
res.status(500).json({ error: 'Ошибка синхронизации' });
}
}); });
// Планировщик синхронизации каждые 5 минут // Планировщик синхронизации каждые 5 минут