oc
This commit is contained in:
41
auth.js
41
auth.js
@@ -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 };
|
||||||
@@ -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 '&';
|
|
||||||
if (m === '<') return '<';
|
|
||||||
if (m === '>') return '>';
|
|
||||||
return m;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
loadAdmins();
|
loadAdmins();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
1068
public/style.css
1068
public/style.css
File diff suppressed because it is too large
Load Diff
331
server.js
331
server.js
@@ -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 минут
|
||||||
|
|||||||
Reference in New Issue
Block a user