oc
This commit is contained in:
41
auth.js
41
auth.js
@@ -1,10 +1,10 @@
|
||||
const session = require('express-session');
|
||||
const SQLiteStore = require('connect-sqlite3')(session);
|
||||
const bcrypt = require('bcrypt');
|
||||
const crypto = require('crypto');
|
||||
const { db } = require('./db');
|
||||
const path = require('path');
|
||||
|
||||
// Директория для файла с сессиями (пусть будет data/)
|
||||
const sessionDbPath = path.join(__dirname, 'data', 'sessions.sqlite');
|
||||
|
||||
const sessionMiddleware = session({
|
||||
@@ -12,21 +12,39 @@ const sessionMiddleware = session({
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
secure: false, // для HTTPS нужно true, но у нас http
|
||||
secure: false,
|
||||
httpOnly: true,
|
||||
maxAge: 24 * 60 * 60 * 1000 // 24 часа
|
||||
sameSite: 'lax',
|
||||
maxAge: 24 * 6 * 60 * 1000
|
||||
},
|
||||
store: new SQLiteStore({
|
||||
db: 'sessions.sqlite', // имя файла в директории data
|
||||
db: 'sessions.sqlite',
|
||||
dir: path.join(__dirname, 'data'),
|
||||
table: 'sessions' // имя таблицы
|
||||
table: 'sessions'
|
||||
})
|
||||
});
|
||||
|
||||
/**
|
||||
* Гарантирует существование администратора, заданного переменными окружения
|
||||
* (ADMIN_LOGIN / ADMIN_PASSWORD). Пароль всегда хэшируется.
|
||||
*/
|
||||
function generateCsrfToken(req) {
|
||||
if (!req.session.csrfToken) {
|
||||
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() {
|
||||
const login = process.env.ADMIN_LOGIN || 'admin';
|
||||
const password = process.env.ADMIN_PASSWORD || 'admin';
|
||||
@@ -44,9 +62,6 @@ async function ensureAdmin() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware для проверки прав администратора.
|
||||
*/
|
||||
function requireAdmin(req, res, next) {
|
||||
if (req.session && req.session.isAdmin) {
|
||||
return next();
|
||||
@@ -54,4 +69,4 @@ function requireAdmin(req, res, next) {
|
||||
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 name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<title>Панель управления</title>
|
||||
</head>
|
||||
<body>
|
||||
<header></header>
|
||||
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
|
||||
<main>
|
||||
<h1>Панель управления</h1>
|
||||
<p>Сервис: <span id="serviceName"></span></p>
|
||||
<button id="syncBtn">Запустить синхронизацию заявок</button>
|
||||
<p id="syncStatus"></p>
|
||||
|
||||
<hr style="margin: 2rem 0;">
|
||||
<div class="card" style="margin-bottom: 2rem;">
|
||||
<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>
|
||||
|
||||
<h2>Управление администраторами</h2>
|
||||
<table id="adminsTable">
|
||||
<thead>
|
||||
<tr><th>ID</th><th>Логин</th><th>Действия</th></tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<h2>Администраторы</h2>
|
||||
|
||||
<h3>Добавить нового администратора</h3>
|
||||
<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 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>
|
||||
|
||||
<!-- Модальное окно для смены пароля -->
|
||||
<div id="editPasswordModal" class="modal" style="display:none;">
|
||||
<h3>Добавить администратора</h3>
|
||||
<div class="form-card">
|
||||
<form id="addAdminForm">
|
||||
<div class="form-group">
|
||||
<label for="newLogin">Логин</label>
|
||||
<input type="text" id="newLogin" required placeholder="Минимум 3 символа" minlength="3">
|
||||
</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>
|
||||
|
||||
<div id="editPasswordModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2>Смена пароля</h2>
|
||||
<form id="editPasswordForm">
|
||||
<input type="hidden" id="editAdminId">
|
||||
<label>Новый пароль</label>
|
||||
<input type="password" id="editPassword" required>
|
||||
<button type="submit">Сохранить</button>
|
||||
<button type="button" id="closePasswordModal">Отмена</button>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Администратор</label>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<script src="nav.js"></script>
|
||||
<script src="seo.js"></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 => {
|
||||
if (!data.isAdmin) window.location.href = '/login.html';
|
||||
});
|
||||
|
||||
// --- Синхронизация ---
|
||||
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');
|
||||
|
||||
btn.disabled = true;
|
||||
btnText.style.display = 'none';
|
||||
btnSpinner.style.display = 'inline-block';
|
||||
status.textContent = 'Синхронизация...';
|
||||
const res = await fetch('/api/admin/sync', { method: 'POST' });
|
||||
if (res.ok) status.textContent = 'Синхронизация завершена';
|
||||
else status.textContent = 'Ошибка синхронизации';
|
||||
|
||||
try {
|
||||
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.querySelector('#adminsTable tbody');
|
||||
const adminsTbody = document.getElementById('adminsBody');
|
||||
const addForm = document.getElementById('addAdminForm');
|
||||
const modal = document.getElementById('editPasswordModal');
|
||||
const closeModalBtn = document.getElementById('closePasswordModal');
|
||||
const passwordModal = document.getElementById('editPasswordModal');
|
||||
const closePasswordBtn = document.getElementById('closePasswordModal');
|
||||
|
||||
async function loadAdmins() {
|
||||
const res = await fetch('/api/admins');
|
||||
const admins = await res.json();
|
||||
adminsTbody.innerHTML = '';
|
||||
for (const admin of admins) {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${admin.id}</td>
|
||||
<td>${escapeHtml(admin.login)}</td>
|
||||
<td>
|
||||
<button class="changePasswordBtn" data-id="${admin.id}" data-login="${escapeHtml(admin.login)}">Сменить пароль</button>
|
||||
<button class="deleteAdminBtn" data-id="${admin.id}">Удалить</button>
|
||||
</td>
|
||||
`;
|
||||
adminsTbody.appendChild(tr);
|
||||
}
|
||||
// Привязываем обработчики к динамическим кнопкам
|
||||
document.querySelectorAll('.changePasswordBtn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.getElementById('editAdminId').value = btn.dataset.id;
|
||||
document.getElementById('editPassword').value = '';
|
||||
modal.style.display = 'flex';
|
||||
try {
|
||||
const res = await fetch('/api/admins');
|
||||
if (!res.ok) throw new Error('Ошибка загрузки');
|
||||
const admins = await res.json();
|
||||
|
||||
adminsTbody.innerHTML = '';
|
||||
if (admins.length === 0) {
|
||||
adminsTbody.innerHTML = '<tr><td colspan="3" style="text-align:center;color:var(--text-muted);padding:2rem;">Нет администраторов</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
admins.forEach(admin => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${admin.id}</td>
|
||||
<td><strong>${escapeHtml(admin.login)}</strong></td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
<button class="changePasswordBtn btn-secondary btn-sm" data-id="${admin.id}" data-login="${escapeHtml(admin.login)}">Сменить пароль</button>
|
||||
<button class="deleteAdminBtn btn-danger btn-sm" data-id="${admin.id}">Удалить</button>
|
||||
</div>
|
||||
</td>
|
||||
`;
|
||||
adminsTbody.appendChild(tr);
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('.deleteAdminBtn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const id = btn.dataset.id;
|
||||
if (confirm('Удалить администратора? Вы не сможете удалить самого себя и последнего админа.')) {
|
||||
const res = await fetch(`/api/admins/${id}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
loadAdmins();
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert('Ошибка: ' + (err.error || 'неизвестная'));
|
||||
|
||||
document.querySelectorAll('.changePasswordBtn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.getElementById('editAdminId').value = btn.dataset.id;
|
||||
document.getElementById('editAdminLogin').value = btn.dataset.login;
|
||||
document.getElementById('editPassword').value = '';
|
||||
passwordModal.classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
const login = document.getElementById('newLogin').value.trim();
|
||||
const password = document.getElementById('newPassword').value;
|
||||
const res = await fetch('/api/admins', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ login, password })
|
||||
});
|
||||
if (res.ok) {
|
||||
alert('Администратор добавлен');
|
||||
addForm.reset();
|
||||
loadAdmins();
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert('Ошибка: ' + (err.error || 'неизвестная'));
|
||||
|
||||
try {
|
||||
const csrfToken = await getCsrfToken();
|
||||
const res = await fetch('/api/admins', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken },
|
||||
body: JSON.stringify({ login, password })
|
||||
});
|
||||
if (res.ok) {
|
||||
showToast('Администратор добавлен', 'success');
|
||||
addForm.reset();
|
||||
loadAdmins();
|
||||
} else {
|
||||
const err = await res.json();
|
||||
showToast(err.error || 'Ошибка', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('Ошибка соединения', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -133,34 +254,30 @@
|
||||
e.preventDefault();
|
||||
const id = document.getElementById('editAdminId').value;
|
||||
const password = document.getElementById('editPassword').value;
|
||||
const res = await fetch(`/api/admins/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password })
|
||||
});
|
||||
if (res.ok) {
|
||||
alert('Пароль изменён');
|
||||
modal.style.display = 'none';
|
||||
loadAdmins();
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert('Ошибка: ' + (err.error || 'неизвестная'));
|
||||
|
||||
try {
|
||||
const csrfToken = await getCsrfToken();
|
||||
const res = await fetch(`/api/admins/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken },
|
||||
body: JSON.stringify({ password })
|
||||
});
|
||||
if (res.ok) {
|
||||
showToast('Пароль изменён', 'success');
|
||||
passwordModal.classList.remove('active');
|
||||
loadAdmins();
|
||||
} else {
|
||||
const err = await res.json();
|
||||
showToast(err.error || 'Ошибка', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('Ошибка соединения', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
closeModalBtn.addEventListener('click', () => {
|
||||
modal.style.display = 'none';
|
||||
});
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/[&<>]/g, function(m) {
|
||||
if (m === '&') return '&';
|
||||
if (m === '<') return '<';
|
||||
if (m === '>') return '>';
|
||||
return m;
|
||||
});
|
||||
}
|
||||
closePasswordBtn.addEventListener('click', () => passwordModal.classList.remove('active'));
|
||||
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'); });
|
||||
|
||||
loadAdmins();
|
||||
</script>
|
||||
|
||||
@@ -4,59 +4,267 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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>
|
||||
<body>
|
||||
<header></header>
|
||||
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
|
||||
<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>
|
||||
<div id="clientInfo"></div>
|
||||
<h2>Заявки (сортировка: сначала новые, потом по статусу А-Я)</h2>
|
||||
<table id="clientBookingsTable">
|
||||
<thead>
|
||||
<tr><th>ID внеш.</th><th>Имя</th><th>Даты</th><th>Статус</th><th>Комментарий</th></tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<p><a href="clients.html">← Назад к списку</a></p>
|
||||
|
||||
<div id="clientInfo" class="card" style="margin-bottom:2rem;"></div>
|
||||
|
||||
<h2>История заявок</h2>
|
||||
|
||||
<div class="table-container">
|
||||
<table id="clientBookingsTable">
|
||||
<thead>
|
||||
<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>
|
||||
|
||||
<script src="nav.js"></script>
|
||||
<script src="seo.js"></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 => {
|
||||
if (!data.isAdmin) window.location.href = '/login.html';
|
||||
});
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const clientId = params.get('id');
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
async function loadClient() {
|
||||
const res = await fetch(`/api/clients/${clientId}`);
|
||||
if (!res.ok) {
|
||||
document.getElementById('clientInfo').innerHTML = '<p>Клиент не найден</p>';
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
document.getElementById('clientInfo').innerHTML = `
|
||||
<p><strong>Телефон:</strong> ${data.client.phone}</p>
|
||||
<p><strong>Имя:</strong> ${data.client.name || 'не указано'}</p>
|
||||
`;
|
||||
const tbody = document.querySelector('#clientBookingsTable tbody');
|
||||
tbody.innerHTML = '';
|
||||
data.bookings.forEach(b => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${b.external_id || '-'}</td>
|
||||
<td>${b.name}</td>
|
||||
<td>${b.checkin_date} – ${b.checkout_date}</td>
|
||||
<td>${b.status}</td>
|
||||
<td>${b.comments || ''}</td>
|
||||
const clientInfoEl = document.getElementById('clientInfo');
|
||||
const tbody = document.getElementById('bookingsBody');
|
||||
const bookingCards = document.getElementById('bookingCards');
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
const tableContainer = document.querySelector('#clientBookingsTable').parentElement;
|
||||
|
||||
clientInfoEl.innerHTML = '<div class="loading-spinner"></div>';
|
||||
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;padding:2rem;"><div class="loading-spinner"></div></td></tr>';
|
||||
bookingCards.innerHTML = '<div style="text-align:center;padding:2rem;"><div class="loading-spinner"></div></div>';
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/clients/${clientId}`);
|
||||
if (!res.ok) {
|
||||
clientInfoEl.innerHTML = '<p style="color:var(--danger);">Клиент не найден</p>';
|
||||
tbody.innerHTML = '';
|
||||
bookingCards.innerHTML = '';
|
||||
tableContainer.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -4,49 +4,192 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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>
|
||||
<body>
|
||||
<header></header>
|
||||
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
|
||||
<main>
|
||||
<h1>Клиенты</h1>
|
||||
<input type="text" id="searchClient" placeholder="Поиск по имени или телефону">
|
||||
<button id="searchClientBtn">Найти</button>
|
||||
<table id="clientsTable">
|
||||
<thead>
|
||||
<tr><th>ID</th><th>Телефон</th><th>Имя</th><th>Действия</th></tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
|
||||
<div class="filter-bar">
|
||||
<div class="search-input-wrapper">
|
||||
<input type="text" id="searchClient" placeholder="Поиск по имени или телефону">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<script src="nav.js"></script>
|
||||
<script src="seo.js"></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 => {
|
||||
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() {
|
||||
const search = document.getElementById('searchClient').value;
|
||||
let url = '/api/clients?';
|
||||
if (search) url += `search=${encodeURIComponent(search)}`;
|
||||
const res = await fetch(url);
|
||||
const clients = await res.json();
|
||||
tbody.innerHTML = '';
|
||||
clients.forEach(c => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${c.id}</td>
|
||||
<td>${c.phone}</td>
|
||||
<td>${c.name || '—'}</td>
|
||||
<td><a href="client.html?id=${c.id}">Профиль</a></td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
|
||||
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;padding:2rem;"><div class="loading-spinner"></div></td></tr>';
|
||||
clientCards.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('Ошибка загрузки');
|
||||
const clients = await res.json();
|
||||
|
||||
tbody.innerHTML = '';
|
||||
clientCards.innerHTML = '';
|
||||
|
||||
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();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -4,108 +4,292 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<title>Заявки на заселение</title>
|
||||
</head>
|
||||
<body>
|
||||
<header></header>
|
||||
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
|
||||
<main>
|
||||
<h1>Заявки на заселение</h1>
|
||||
<div>
|
||||
<label>Фильтр по статусу:
|
||||
<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>
|
||||
</label>
|
||||
<input type="text" id="searchInput" placeholder="Поиск по имени, телефону или комментарию">
|
||||
<button id="searchBtn">Найти</button>
|
||||
</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" style="display:none;">
|
||||
<div class="stats-grid" id="statsGrid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="statTotal">0</div>
|
||||
<div class="stat-label">Всего</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="statNew">0</div>
|
||||
<div class="stat-label">Новые</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="statInProgress">0</div>
|
||||
<div class="stat-label">В работе</div>
|
||||
</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 id="editModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2>Редактировать заявку</h2>
|
||||
<form id="editForm">
|
||||
<input type="hidden" id="editId">
|
||||
<label>Статус</label>
|
||||
<select id="editStatus">
|
||||
<option value="Новая">Новая</option>
|
||||
<option value="В работе">В работе</option>
|
||||
<option value="Подтверждена">Подтверждена</option>
|
||||
<option value="Заселение">Заселение</option>
|
||||
<option value="Завершена">Завершена</option>
|
||||
<option value="Отменена">Отменена</option>
|
||||
</select>
|
||||
<label>Комментарий</label>
|
||||
<textarea id="editComments" rows="3"></textarea>
|
||||
<label>Переназначить на карточку клиента (по телефону)</label>
|
||||
<input type="text" id="editPhone" placeholder="Телефон (любой формат)">
|
||||
<button type="submit">Сохранить</button>
|
||||
<button type="button" id="closeModal">Отмена</button>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="editStatus">Статус заявки</label>
|
||||
<select id="editStatus">
|
||||
<option value="Новая">Новая</option>
|
||||
<option value="В работе">В работе</option>
|
||||
<option value="Подтверждена">Подтверждена</option>
|
||||
<option value="Заселение">Заселение</option>
|
||||
<option value="Завершена">Завершена</option>
|
||||
<option value="Отменена">Отменена</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="nav.js"></script>
|
||||
<script src="seo.js"></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 => {
|
||||
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 emptyState = document.getElementById('emptyState');
|
||||
const tableContainer = document.getElementById('tableContainer');
|
||||
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() {
|
||||
const status = document.getElementById('statusFilter').value;
|
||||
const search = document.getElementById('searchInput').value;
|
||||
let url = '/api/bookings?';
|
||||
if (status) url += `status=${encodeURIComponent(status)}&`;
|
||||
if (search) url += `search=${encodeURIComponent(search)}&`;
|
||||
const res = await fetch(url);
|
||||
currentBookings = await res.json();
|
||||
renderTable();
|
||||
|
||||
tableBody.innerHTML = '<tr><td colspan="6" style="text-align:center;padding:2rem;"><div class="loading-spinner"></div></td></tr>';
|
||||
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() {
|
||||
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');
|
||||
row.style.animationDelay = `${i * 0.03}s`;
|
||||
row.innerHTML = `
|
||||
<td>${b.external_id || '-'}</td>
|
||||
<td>${b.name}</td>
|
||||
<td>${b.phone_raw}</td>
|
||||
<td>${b.checkin_date} – ${b.checkout_date}</td>
|
||||
<td>${b.status}</td>
|
||||
<td><button class="editBtn" data-id="${b.id}">Изменить</button></td>
|
||||
<td>${escapeHtml(b.external_id) || '—'}</td>
|
||||
<td><strong>${escapeHtml(b.name)}</strong></td>
|
||||
<td>${escapeHtml(b.phone_raw)}</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><button class="editBtn btn-secondary btn-sm" data-id="${b.id}">Изменить</button></td>
|
||||
`;
|
||||
tableBody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('searchBtn').addEventListener('click', loadBookings);
|
||||
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('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) => {
|
||||
if (e.target.classList.contains('editBtn')) {
|
||||
const id = e.target.dataset.id;
|
||||
@@ -115,40 +299,53 @@
|
||||
document.getElementById('editStatus').value = booking.status;
|
||||
document.getElementById('editComments').value = booking.comments || '';
|
||||
document.getElementById('editPhone').value = '';
|
||||
modal.style.display = 'flex';
|
||||
modal.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('closeModal').addEventListener('click', () => {
|
||||
modal.style.display = 'none';
|
||||
});
|
||||
document.getElementById('closeModal').addEventListener('click', () => modal.classList.remove('active'));
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
const saveBtn = document.getElementById('saveBtn');
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.innerHTML = '<span class="loading-spinner"></span>';
|
||||
|
||||
const id = document.getElementById('editId').value;
|
||||
const status = document.getElementById('editStatus').value;
|
||||
const comments = document.getElementById('editComments').value;
|
||||
const phone = document.getElementById('editPhone').value.trim();
|
||||
|
||||
const body = {};
|
||||
const body = { comments };
|
||||
if (status) body.status = status;
|
||||
body.comments = comments;
|
||||
if (phone) body.phone = phone;
|
||||
|
||||
const res = await fetch(`/api/bookings/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (res.ok) {
|
||||
modal.style.display = 'none';
|
||||
loadBookings();
|
||||
} else {
|
||||
alert('Ошибка сохранения');
|
||||
const csrfToken = await getCsrfToken();
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/bookings/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (res.ok) {
|
||||
modal.classList.remove('active');
|
||||
showToast('Заявка обновлена', 'success');
|
||||
loadBookings();
|
||||
} else {
|
||||
const err = await res.json();
|
||||
showToast(err.error || 'Ошибка сохранения', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('Ошибка соединения', 'error');
|
||||
} finally {
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.innerHTML = 'Сохранить';
|
||||
}
|
||||
});
|
||||
|
||||
// Первоначальная загрузка
|
||||
loadBookings();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -4,41 +4,197 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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>
|
||||
<body>
|
||||
<header></header>
|
||||
<main>
|
||||
<h1>Вход в систему управления</h1>
|
||||
<form id="loginForm">
|
||||
<label>Логин</label>
|
||||
<input type="text" name="login" required>
|
||||
<label>Пароль</label>
|
||||
<input type="password" name="password" required>
|
||||
<button type="submit">Войти</button>
|
||||
<p id="error" style="color: red; display: none;"></p>
|
||||
</form>
|
||||
</main>
|
||||
<div class="login-page">
|
||||
<header></header>
|
||||
|
||||
<main class="login-main">
|
||||
<div class="login-card">
|
||||
<div class="login-icon">
|
||||
<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>
|
||||
</div>
|
||||
<h1 class="login-title">Отель 777</h1>
|
||||
<p class="login-subtitle">Панель управления</p>
|
||||
|
||||
<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="seo.js"></script>
|
||||
<script>
|
||||
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const res = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
login: form.login.value,
|
||||
password: form.password.value
|
||||
})
|
||||
});
|
||||
if (res.ok) {
|
||||
window.location.href = '/admin.html';
|
||||
} else {
|
||||
const err = document.getElementById('error');
|
||||
err.textContent = 'Неверный логин или пароль';
|
||||
err.style.display = 'block';
|
||||
const loginBtn = document.getElementById('loginBtn');
|
||||
const loginBtnText = document.getElementById('loginBtnText');
|
||||
const loginBtnSpinner = document.getElementById('loginBtnSpinner');
|
||||
const errorEl = document.getElementById('error');
|
||||
|
||||
loginBtn.disabled = true;
|
||||
loginBtnText.style.display = 'none';
|
||||
loginBtnSpinner.style.display = 'inline-block';
|
||||
errorEl.style.display = 'none';
|
||||
|
||||
try {
|
||||
const csrfRes = await fetch('/api/csrf-token');
|
||||
const csrfData = await csrfRes.json();
|
||||
|
||||
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>
|
||||
|
||||
@@ -1,16 +1,35 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const currentPage = window.location.pathname.split('/').pop() || 'index.html';
|
||||
|
||||
const navHTML = `
|
||||
<nav>
|
||||
<a href="/">Главная</a>
|
||||
<a href="/clients.html">Клиенты</a>
|
||||
<a href="/admin.html">Панель управления</a>
|
||||
<a href="/index.html" data-page="index.html">Заявки</a>
|
||||
<a href="/clients.html" data-page="clients.html">Клиенты</a>
|
||||
<a href="/admin.html" data-page="admin.html">Панель управления</a>
|
||||
<a id="logoutLink" href="#" style="display:none;">Выход</a>
|
||||
</nav>
|
||||
`;
|
||||
|
||||
const header = document.querySelector('header');
|
||||
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')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
@@ -23,7 +42,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (e.target.id === 'logoutLink') {
|
||||
e.preventDefault();
|
||||
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 { db, normalizePhone, logAction } = require('./db');
|
||||
const { sessionMiddleware, ensureAdmin, requireAdmin } = require('./auth');
|
||||
const { sessionMiddleware, csrfProtection, injectCsrfToken, ensureAdmin, requireAdmin } = require('./auth');
|
||||
const { syncBookings } = require('./sync');
|
||||
const { notifyBookingUpdate, verifyEmailConnection } = require('./mailer');
|
||||
|
||||
@@ -15,10 +15,16 @@ const PORT = process.env.PORT || 3000;
|
||||
// Middleware
|
||||
app.use(express.json());
|
||||
app.use(sessionMiddleware);
|
||||
app.use(injectCsrfToken);
|
||||
|
||||
// Статика из 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
|
||||
(async () => {
|
||||
@@ -33,20 +39,29 @@ verifyEmailConnection().catch(err => {
|
||||
// === API ===
|
||||
|
||||
// Вход администратора (с хэшированием и сохранением adminId)
|
||||
app.post('/api/login', async (req, res) => {
|
||||
const { login, password } = req.body;
|
||||
const admin = db.prepare('SELECT id, login, password FROM admins WHERE login = ?').get(login);
|
||||
if (admin && await bcrypt.compare(password, admin.password)) {
|
||||
req.session.isAdmin = true;
|
||||
req.session.adminId = admin.id;
|
||||
res.json({ success: true });
|
||||
} else {
|
||||
res.status(401).json({ error: 'Неверный логин или пароль' });
|
||||
app.post('/api/login', csrfProtection, async (req, res) => {
|
||||
try {
|
||||
const { login, password } = req.body;
|
||||
if (!login || !password) {
|
||||
return res.status(400).json({ error: 'Логин и пароль обязательны' });
|
||||
}
|
||||
const admin = db.prepare('SELECT id, login, password FROM admins WHERE login = ?').get(login);
|
||||
if (admin && await bcrypt.compare(password, admin.password)) {
|
||||
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();
|
||||
res.json({ success: true });
|
||||
});
|
||||
@@ -60,182 +75,232 @@ app.get('/api/me', (req, res) => {
|
||||
|
||||
// Получить список всех администраторов (только id и login)
|
||||
app.get('/api/admins', requireAdmin, (req, res) => {
|
||||
const admins = db.prepare('SELECT id, login FROM admins ORDER BY id').all();
|
||||
res.json(admins);
|
||||
try {
|
||||
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) => {
|
||||
const { login, password } = req.body;
|
||||
if (!login || !password) {
|
||||
return res.status(400).json({ error: 'Логин и пароль обязательны' });
|
||||
app.post('/api/admins', requireAdmin, csrfProtection, async (req, res) => {
|
||||
try {
|
||||
const { login, password } = req.body;
|
||||
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) => {
|
||||
const { id } = req.params;
|
||||
const { password } = req.body;
|
||||
if (!password) {
|
||||
return res.status(400).json({ error: 'Новый пароль обязателен' });
|
||||
app.put('/api/admins/:id', requireAdmin, csrfProtection, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { password } = req.body;
|
||||
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) => {
|
||||
const { id } = req.params;
|
||||
// Проверка: нельзя удалить последнего администратора
|
||||
const count = db.prepare('SELECT COUNT(*) as cnt FROM admins').get().cnt;
|
||||
if (count <= 1) {
|
||||
return res.status(400).json({ error: 'Нельзя удалить последнего администратора' });
|
||||
app.delete('/api/admins/:id', requireAdmin, csrfProtection, (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const count = db.prepare('SELECT COUNT(*) as cnt FROM admins').get().cnt;
|
||||
if (count <= 1) {
|
||||
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) ---
|
||||
|
||||
// Список заявок с фильтрами
|
||||
app.get('/api/bookings', requireAdmin, (req, res) => {
|
||||
const { status, search, client_id } = req.query;
|
||||
let query = 'SELECT * FROM bookings WHERE 1=1';
|
||||
const params = [];
|
||||
try {
|
||||
const { status, search, client_id } = req.query;
|
||||
let query = 'SELECT * FROM bookings WHERE 1=1';
|
||||
const params = [];
|
||||
|
||||
if (status) {
|
||||
query += ' AND status = ?';
|
||||
params.push(status);
|
||||
if (status) {
|
||||
query += ' AND 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) => {
|
||||
const booking = db.prepare('SELECT * FROM bookings WHERE id = ?').get(req.params.id);
|
||||
if (!booking) return res.status(404).json({ error: 'Заявка не найдена' });
|
||||
res.json(booking);
|
||||
try {
|
||||
const booking = db.prepare('SELECT * FROM bookings WHERE id = ?').get(req.params.id);
|
||||
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) => {
|
||||
const { id } = req.params;
|
||||
const { status, comments, phone } = req.body;
|
||||
app.put('/api/bookings/:id', requireAdmin, csrfProtection, (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { status, comments, phone } = req.body;
|
||||
|
||||
const booking = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id);
|
||||
if (!booking) return res.status(404).json({ error: 'Заявка не найдена' });
|
||||
const booking = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id);
|
||||
if (!booking) return res.status(404).json({ error: 'Заявка не найдена' });
|
||||
|
||||
const changes = {};
|
||||
const changes = {};
|
||||
|
||||
// Обновление статуса
|
||||
if (status && status !== booking.status) {
|
||||
const allowed = ['Новая', 'В работе', 'Подтверждена', 'Заселение', 'Завершена', 'Отменена'];
|
||||
if (!allowed.includes(status)) {
|
||||
return res.status(400).json({ error: 'Недопустимый статус' });
|
||||
if (status && status !== booking.status) {
|
||||
const allowed = ['Новая', 'В работе', 'Подтверждена', 'Заселение', 'Завершена', 'Отменена'];
|
||||
if (!allowed.includes(status)) {
|
||||
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) {
|
||||
db.prepare('UPDATE bookings SET comments = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(comments, id);
|
||||
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 (comments !== undefined && comments !== booking.comments) {
|
||||
db.prepare('UPDATE bookings SET comments = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(comments, id);
|
||||
changes.comments = { from: booking.comments, to: comments };
|
||||
}
|
||||
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 (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);
|
||||
changes.user_id = { from: booking.user_id, to: user.id };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Если были изменения – логируем и шлём уведомление
|
||||
if (Object.keys(changes).length > 0) {
|
||||
logAction(`Изменение заявки #${id}`, changes);
|
||||
const updated = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id);
|
||||
notifyBookingUpdate(updated, changes);
|
||||
}
|
||||
if (Object.keys(changes).length > 0) {
|
||||
logAction(`Изменение заявки #${id}`, changes);
|
||||
const updated = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id);
|
||||
notifyBookingUpdate(updated, changes);
|
||||
}
|
||||
|
||||
const updatedBooking = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id);
|
||||
res.json(updatedBooking);
|
||||
const updatedBooking = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id);
|
||||
res.json(updatedBooking);
|
||||
} catch (err) {
|
||||
console.error('Ошибка обновления заявки:', err);
|
||||
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Карточки клиентов ---
|
||||
|
||||
// Список клиентов
|
||||
app.get('/api/clients', requireAdmin, (req, res) => {
|
||||
const { search } = req.query;
|
||||
let query = 'SELECT * FROM users WHERE 1=1';
|
||||
const params = [];
|
||||
if (search) {
|
||||
query += ' AND (phone LIKE ? OR name LIKE ?)';
|
||||
params.push(`%${search}%`, `%${search}%`);
|
||||
try {
|
||||
const { search } = req.query;
|
||||
let query = 'SELECT * FROM users WHERE 1=1';
|
||||
const params = [];
|
||||
if (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) => {
|
||||
const client = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
|
||||
if (!client) return res.status(404).json({ error: 'Клиент не найден' });
|
||||
try {
|
||||
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(`
|
||||
SELECT * FROM bookings
|
||||
WHERE user_id = ?
|
||||
ORDER BY created_at DESC, status ASC
|
||||
`).all(req.params.id);
|
||||
const bookings = db.prepare(`
|
||||
SELECT * FROM bookings
|
||||
WHERE user_id = ?
|
||||
ORDER BY created_at DESC, status ASC
|
||||
`).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) => {
|
||||
await syncBookings();
|
||||
res.json({ success: true, message: 'Синхронизация запущена' });
|
||||
app.post('/api/admin/sync', requireAdmin, csrfProtection, async (req, res) => {
|
||||
try {
|
||||
await syncBookings();
|
||||
res.json({ success: true, message: 'Синхронизация запущена' });
|
||||
} catch (err) {
|
||||
console.error('Ошибка синхронизации:', err);
|
||||
res.status(500).json({ error: 'Ошибка синхронизации' });
|
||||
}
|
||||
});
|
||||
|
||||
// Планировщик синхронизации каждые 5 минут
|
||||
|
||||
Reference in New Issue
Block a user