1
This commit is contained in:
10
.gitignore
vendored
10
.gitignore
vendored
@@ -1,3 +1,13 @@
|
|||||||
|
|
||||||
|
promt
|
||||||
|
data
|
||||||
|
.env
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ---> Node
|
# ---> Node
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
|
|||||||
35
auth.js
Normal file
35
auth.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
const session = require('express-session');
|
||||||
|
const { db } = require('./db');
|
||||||
|
|
||||||
|
const sessionMiddleware = session({
|
||||||
|
secret: process.env.SESSION_SECRET || 'hotel-secret-key-change-me',
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: false,
|
||||||
|
cookie: { secure: false, httpOnly: true, maxAge: 24 * 60 * 60 * 1000 }
|
||||||
|
});
|
||||||
|
|
||||||
|
function ensureAdmin() {
|
||||||
|
const login = process.env.ADMIN_LOGIN || 'admin';
|
||||||
|
const password = process.env.ADMIN_PASSWORD || 'admin';
|
||||||
|
|
||||||
|
// Таблица admins уже создана в db.js, можно сразу искать
|
||||||
|
const admin = db.prepare('SELECT id FROM admins WHERE login = ?').get(login);
|
||||||
|
if (admin) {
|
||||||
|
// Обновить пароль при каждом старте
|
||||||
|
db.prepare('UPDATE admins SET password = ? WHERE login = ?').run(password, login);
|
||||||
|
console.log(`Пароль администратора "${login}" обновлён`);
|
||||||
|
} else {
|
||||||
|
// Создать запись
|
||||||
|
db.prepare('INSERT INTO admins (login, password) VALUES (?, ?)').run(login, password);
|
||||||
|
console.log(`Администратор "${login}" создан`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireAdmin(req, res, next) {
|
||||||
|
if (req.session && req.session.isAdmin) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
res.status(401).json({ error: 'Не авторизован' });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { sessionMiddleware, ensureAdmin, requireAdmin };
|
||||||
70
db.js
Normal file
70
db.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const dataDir = path.join(__dirname, 'data');
|
||||||
|
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir);
|
||||||
|
|
||||||
|
const db = new Database(path.join(dataDir, 'database.sqlite'));
|
||||||
|
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
db.pragma('foreign_keys = ON');
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
-- Таблица администраторов (логин/пароль)
|
||||||
|
CREATE TABLE IF NOT EXISTS admins (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
login TEXT UNIQUE NOT NULL,
|
||||||
|
password TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Карточки клиентов
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
phone TEXT UNIQUE NOT NULL,
|
||||||
|
name TEXT DEFAULT '',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Заявки на заселение
|
||||||
|
CREATE TABLE IF NOT EXISTS bookings (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
external_id INTEGER UNIQUE,
|
||||||
|
user_id INTEGER,
|
||||||
|
name TEXT,
|
||||||
|
phone_raw TEXT,
|
||||||
|
adults INTEGER DEFAULT 1,
|
||||||
|
children INTEGER DEFAULT 0,
|
||||||
|
checkin_date TEXT,
|
||||||
|
checkout_date TEXT,
|
||||||
|
status TEXT DEFAULT 'Новая',
|
||||||
|
comments TEXT DEFAULT '',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Журнал действий
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
details TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
function normalizePhone(phone) {
|
||||||
|
let digits = phone.replace(/\D/g, '');
|
||||||
|
if (/^[78]\d{10}$/.test(digits)) {
|
||||||
|
digits = '7' + digits.slice(1);
|
||||||
|
}
|
||||||
|
return digits;
|
||||||
|
}
|
||||||
|
|
||||||
|
function logAction(action, details = {}) {
|
||||||
|
const stmt = db.prepare('INSERT INTO audit_log (action, details) VALUES (?, ?)');
|
||||||
|
stmt.run(action, JSON.stringify(details));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { db, normalizePhone, logAction };
|
||||||
52
mailer.js
Normal file
52
mailer.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
const nodemailer = require('nodemailer');
|
||||||
|
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: process.env.SMTP_HOST,
|
||||||
|
port: parseInt(process.env.SMTP_PORT) || 587,
|
||||||
|
secure: false,
|
||||||
|
auth: {
|
||||||
|
user: process.env.SMTP_USER,
|
||||||
|
pass: process.env.SMTP_PASS
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function sendNotification(emails, subject, text) {
|
||||||
|
if (!emails) return;
|
||||||
|
const recipients = emails.split(',').map(e => e.trim()).filter(Boolean);
|
||||||
|
if (recipients.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: process.env.SMTP_USER,
|
||||||
|
to: recipients.join(','),
|
||||||
|
subject,
|
||||||
|
text
|
||||||
|
});
|
||||||
|
console.log(`Уведомление отправлено на ${recipients.join(', ')}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Ошибка отправки письма:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function notifyNewBooking(booking) {
|
||||||
|
const subject = `Новая заявка №${booking.external_id} (${process.env.SERVICE_NAME})`;
|
||||||
|
const text = `Поступила новая заявка на заселение.\n
|
||||||
|
Имя: ${booking.name}
|
||||||
|
Телефон: ${booking.phone_raw}
|
||||||
|
Даты: с ${booking.checkin_date} по ${booking.checkout_date}
|
||||||
|
Взрослых: ${booking.adults}, детей: ${booking.children}
|
||||||
|
Ссылка: ${process.env.SERVICE_URL}/admin/bookings`;
|
||||||
|
await sendNotification(process.env.EMAIL_NOTIFY_NEW, subject, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function notifyBookingUpdate(booking, changes) {
|
||||||
|
const subject = `Изменение заявки №${booking.external_id} (${process.env.SERVICE_NAME})`;
|
||||||
|
const text = `Заявка №${booking.external_id} была изменена.\n
|
||||||
|
Новый статус: ${booking.status}
|
||||||
|
Комментарий: ${booking.comments || 'нет'}
|
||||||
|
Изменения: ${JSON.stringify(changes, null, 2)}
|
||||||
|
Ссылка: ${process.env.SERVICE_URL}/admin/bookings`;
|
||||||
|
await sendNotification(process.env.EMAIL_NOTIFY_UPDATE, subject, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { notifyNewBooking, notifyBookingUpdate };
|
||||||
1771
package-lock.json
generated
Normal file
1771
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
12
package.json
Normal file
12
package.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.16.0",
|
||||||
|
"better-sqlite3": "^12.9.0",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"express-session": "^1.19.0",
|
||||||
|
"node-cron": "^4.2.1",
|
||||||
|
"nodemailer": "^8.0.7",
|
||||||
|
"sqlite3": "^6.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
33
public/admin.html
Normal file
33
public/admin.html
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru" data-title="Панель управления" data-description="Администрирование заявок гостиницы">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header></header>
|
||||||
|
<main>
|
||||||
|
<h1>Панель управления</h1>
|
||||||
|
<p>Сервис: <span id="serviceName"></span></p>
|
||||||
|
<button id="syncBtn">Запустить синхронизацию заявок</button>
|
||||||
|
<p id="syncStatus"></p>
|
||||||
|
</main>
|
||||||
|
<script src="nav.js"></script>
|
||||||
|
<script src="seo.js"></script>
|
||||||
|
<script>
|
||||||
|
// Проверка авторизации
|
||||||
|
fetch('/api/me').then(r => r.json()).then(data => {
|
||||||
|
if (!data.isAdmin) window.location.href = '/login.html';
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('syncBtn').addEventListener('click', async () => {
|
||||||
|
const status = document.getElementById('syncStatus');
|
||||||
|
status.textContent = 'Синхронизация...';
|
||||||
|
const res = await fetch('/api/admin/sync', { method: 'POST' });
|
||||||
|
if (res.ok) status.textContent = 'Синхронизация завершена';
|
||||||
|
else status.textContent = 'Ошибка синхронизации';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
155
public/bookings.html
Normal file
155
public/bookings.html
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru" data-title="Заявки на заселение" data-description="Управление заявками гостиницы">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header></header>
|
||||||
|
<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="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>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="nav.js"></script>
|
||||||
|
<script src="seo.js"></script>
|
||||||
|
<script>
|
||||||
|
// Проверка авторизации
|
||||||
|
fetch('/api/me').then(r => r.json()).then(data => {
|
||||||
|
if (!data.isAdmin) window.location.href = '/login.html';
|
||||||
|
});
|
||||||
|
|
||||||
|
const tableBody = document.querySelector('#bookingsTable tbody');
|
||||||
|
const modal = document.getElementById('editModal');
|
||||||
|
let currentBookings = [];
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable() {
|
||||||
|
tableBody.innerHTML = '';
|
||||||
|
currentBookings.forEach(b => {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
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>
|
||||||
|
`;
|
||||||
|
tableBody.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('searchBtn').addEventListener('click', loadBookings);
|
||||||
|
document.getElementById('statusFilter').addEventListener('change', loadBookings);
|
||||||
|
|
||||||
|
// Редактирование
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (e.target.classList.contains('editBtn')) {
|
||||||
|
const id = e.target.dataset.id;
|
||||||
|
const booking = currentBookings.find(b => b.id == id);
|
||||||
|
if (!booking) return;
|
||||||
|
document.getElementById('editId').value = booking.id;
|
||||||
|
document.getElementById('editStatus').value = booking.status;
|
||||||
|
document.getElementById('editComments').value = booking.comments || '';
|
||||||
|
document.getElementById('editPhone').value = '';
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('closeModal').addEventListener('click', () => {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('editForm').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
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 = {};
|
||||||
|
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('Ошибка сохранения');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Первоначальная загрузка
|
||||||
|
loadBookings();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
65
public/client.html
Normal file
65
public/client.html
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru" data-title="Карточка клиента" data-description="Профиль клиента и его заявки">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header></header>
|
||||||
|
<main>
|
||||||
|
<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>
|
||||||
|
</main>
|
||||||
|
<script src="nav.js"></script>
|
||||||
|
<script src="seo.js"></script>
|
||||||
|
<script>
|
||||||
|
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>';
|
||||||
|
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>
|
||||||
|
`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadClient();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
53
public/clients.html
Normal file
53
public/clients.html
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru" data-title="Клиенты" data-description="Список клиентов гостиницы">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header></header>
|
||||||
|
<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>
|
||||||
|
</main>
|
||||||
|
<script src="nav.js"></script>
|
||||||
|
<script src="seo.js"></script>
|
||||||
|
<script>
|
||||||
|
fetch('/api/me').then(r => r.json()).then(data => {
|
||||||
|
if (!data.isAdmin) window.location.href = '/login.html';
|
||||||
|
});
|
||||||
|
|
||||||
|
const tbody = document.querySelector('#clientsTable tbody');
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('searchClientBtn').addEventListener('click', loadClients);
|
||||||
|
loadClients();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
17
public/index.html
Normal file
17
public/index.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru" data-title="Добро пожаловать" data-description="Гостиница Солнечная – бронируйте номера онлайн">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header></header>
|
||||||
|
<main>
|
||||||
|
<h1>Добро пожаловать в гостиницу "Солнечная"</h1>
|
||||||
|
<p>Сервис управления заявками на заселение. Для доступа в панель управления <a href="/login.html">войдите</a>.</p>
|
||||||
|
</main>
|
||||||
|
<script src="nav.js"></script>
|
||||||
|
<script src="seo.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
46
public/login.html
Normal file
46
public/login.html
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru" data-title="Вход в панель" data-description="Авторизация администратора гостиницы">
|
||||||
|
<head>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<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';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
30
public/nav.js
Normal file
30
public/nav.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const navHTML = `
|
||||||
|
<nav>
|
||||||
|
<a href="/">Главная</a>
|
||||||
|
<a href="/admin.html">Панель управления</a>
|
||||||
|
<a href="/bookings.html">Заявки</a>
|
||||||
|
<a href="/clients.html">Клиенты</a>
|
||||||
|
<a id="logoutLink" href="#" style="display:none;">Выход</a>
|
||||||
|
</nav>
|
||||||
|
`;
|
||||||
|
const header = document.querySelector('header');
|
||||||
|
if (header) header.innerHTML += navHTML;
|
||||||
|
|
||||||
|
// Показать/скрыть выход, проверив сессию
|
||||||
|
fetch('/api/me')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.isAdmin) {
|
||||||
|
document.getElementById('logoutLink').style.display = 'inline';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (e.target.id === 'logoutLink') {
|
||||||
|
e.preventDefault();
|
||||||
|
fetch('/api/logout', { method: 'POST' })
|
||||||
|
.then(() => window.location.href = '/login.html');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
17
public/seo.js
Normal file
17
public/seo.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
(function() {
|
||||||
|
// Установка заголовка страницы из data-title или имени файла
|
||||||
|
const pageTitle = document.documentElement.getAttribute('data-title') ||
|
||||||
|
document.title ||
|
||||||
|
'Гостиница "Солнечная"';
|
||||||
|
document.title = pageTitle + ' | ' + (document.documentElement.getAttribute('data-service') || 'Отель');
|
||||||
|
|
||||||
|
// Meta description
|
||||||
|
let metaDesc = document.querySelector('meta[name="description"]');
|
||||||
|
if (!metaDesc) {
|
||||||
|
metaDesc = document.createElement('meta');
|
||||||
|
metaDesc.name = 'description';
|
||||||
|
document.head.appendChild(metaDesc);
|
||||||
|
}
|
||||||
|
metaDesc.content = document.documentElement.getAttribute('data-description') ||
|
||||||
|
'Управление заявками на заселение в гостинице. Современный сервис бронирования.';
|
||||||
|
})();
|
||||||
23
public/style.css
Normal file
23
public/style.css
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: 'Segoe UI', sans-serif; background: #f4f6f9; color: #333; min-height: 100vh; }
|
||||||
|
header { background: #fff; box-shadow: 0 2px 4px rgba(0,0,0,0.1); padding: 1rem 2rem; }
|
||||||
|
nav { display: flex; gap: 1rem; flex-wrap: wrap; }
|
||||||
|
nav a { text-decoration: none; color: #2c3e50; font-weight: 500; padding: 0.5rem 0; }
|
||||||
|
nav a:hover { color: #3498db; }
|
||||||
|
main { max-width: 1200px; margin: 2rem auto; padding: 0 1rem; }
|
||||||
|
h1, h2 { color: #2c3e50; margin-bottom: 1rem; }
|
||||||
|
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.05); }
|
||||||
|
th, td { padding: 0.75rem 1rem; text-align: left; border-bottom: 1px solid #eee; }
|
||||||
|
th { background: #fafbfc; font-weight: 600; }
|
||||||
|
tr:hover { background: #f1f4f8; }
|
||||||
|
button, .btn { padding: 0.5rem 1rem; background: #3498db; color: #fff; border: none; border-radius: 4px; cursor: pointer; }
|
||||||
|
button:hover { background: #2980b9; }
|
||||||
|
form { display: flex; flex-direction: column; gap: 1rem; max-width: 400px; margin: 0 auto; }
|
||||||
|
input, select, textarea { padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px; }
|
||||||
|
label { font-weight: 500; }
|
||||||
|
.modal { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.4); display: flex; justify-content: center; align-items: center; }
|
||||||
|
.modal-content { background: #fff; padding: 2rem; border-radius: 8px; width: 90%; max-width: 600px; max-height: 80vh; overflow-y: auto; }
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
header { padding: 0.5rem 1rem; }
|
||||||
|
nav { flex-direction: column; }
|
||||||
|
}
|
||||||
172
server.js
Normal file
172
server.js
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
require('dotenv').config();
|
||||||
|
const express = require('express');
|
||||||
|
const path = require('path');
|
||||||
|
const { db, normalizePhone, logAction } = require('./db');
|
||||||
|
const { sessionMiddleware, ensureAdmin, requireAdmin } = require('./auth');
|
||||||
|
const { syncBookings } = require('./sync');
|
||||||
|
const { notifyBookingUpdate } = require('./mailer');
|
||||||
|
const cron = require('node-cron');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(sessionMiddleware);
|
||||||
|
|
||||||
|
// Статика из public
|
||||||
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
|
// Инициализация администратора
|
||||||
|
ensureAdmin();
|
||||||
|
|
||||||
|
// === API ===
|
||||||
|
|
||||||
|
// Вход администратора
|
||||||
|
app.post('/api/login', (req, res) => {
|
||||||
|
const { login, password } = req.body;
|
||||||
|
const admin = db.prepare('SELECT * FROM admins WHERE login = ? AND password = ?').get(login, password);
|
||||||
|
if (admin) {
|
||||||
|
req.session.isAdmin = true;
|
||||||
|
res.json({ success: true });
|
||||||
|
} else {
|
||||||
|
res.status(401).json({ error: 'Неверный логин или пароль' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Выход
|
||||||
|
app.post('/api/logout', (req, res) => {
|
||||||
|
req.session.destroy();
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Проверка сессии
|
||||||
|
app.get('/api/me', (req, res) => {
|
||||||
|
res.json({ isAdmin: !!req.session.isAdmin });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Список заявок (с фильтрами, только для админа)
|
||||||
|
app.get('/api/bookings', requireAdmin, (req, res) => {
|
||||||
|
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 (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);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обновление заявки (статус, комментарии, перепривязка клиента)
|
||||||
|
app.put('/api/bookings/:id', requireAdmin, (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { status, comments, phone } = req.body; // phone для перепривязки карточки
|
||||||
|
|
||||||
|
const booking = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id);
|
||||||
|
if (!booking) return res.status(404).json({ error: 'Заявка не найдена' });
|
||||||
|
|
||||||
|
const changes = {};
|
||||||
|
|
||||||
|
// Обновление статуса
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление комментария
|
||||||
|
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 (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);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedBooking = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id);
|
||||||
|
res.json(updatedBooking);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Список карточек клиентов
|
||||||
|
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}%`);
|
||||||
|
}
|
||||||
|
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: 'Клиент не найден' });
|
||||||
|
|
||||||
|
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 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ручной запуск синхронизации
|
||||||
|
app.post('/api/admin/sync', requireAdmin, async (req, res) => {
|
||||||
|
await syncBookings();
|
||||||
|
res.json({ success: true, message: 'Синхронизация запущена' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Планировщик синхронизации каждые 5 минут (опционально)
|
||||||
|
cron.schedule('*/5 * * * *', () => {
|
||||||
|
console.log('Автосинхронизация...');
|
||||||
|
syncBookings().catch(console.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Запуск сервера
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Сервис "${process.env.SERVICE_NAME}" запущен на порту ${PORT}`);
|
||||||
|
console.log(`Ссылка: ${process.env.SERVICE_URL}`);
|
||||||
|
});
|
||||||
47
sync.js
Normal file
47
sync.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
const { db, normalizePhone, logAction } = require('./db');
|
||||||
|
const { notifyNewBooking } = require('./mailer');
|
||||||
|
|
||||||
|
async function syncBookings() {
|
||||||
|
const url = process.env.BOOKING_SERVICE_URL;
|
||||||
|
const token = process.env.BOOKING_SERVICE_TOKEN;
|
||||||
|
if (!url || !token) {
|
||||||
|
console.log('Синхронизация: не указаны BOOKING_SERVICE_URL или TOKEN');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${url}/api/bookings`, {
|
||||||
|
headers: { 'X-API-Key': token }
|
||||||
|
});
|
||||||
|
const bookings = response.data;
|
||||||
|
|
||||||
|
const insertStmt = db.prepare(`
|
||||||
|
INSERT OR IGNORE INTO bookings (external_id, user_id, name, phone_raw, adults, children, checkin_date, checkout_date, status)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'Новая')
|
||||||
|
`);
|
||||||
|
const selectUser = db.prepare('SELECT id FROM users WHERE phone = ?');
|
||||||
|
const insertUser = db.prepare('INSERT INTO users (phone, name) VALUES (?, ?)');
|
||||||
|
|
||||||
|
for (const b of bookings) {
|
||||||
|
const normPhone = normalizePhone(b.phone);
|
||||||
|
let user = selectUser.get(normPhone);
|
||||||
|
if (!user) {
|
||||||
|
// Создаём карточку клиента
|
||||||
|
const info = insertUser.run(normPhone, b.name);
|
||||||
|
user = { id: info.lastInsertRowid };
|
||||||
|
}
|
||||||
|
// Вставляем заявку, если external_id новый
|
||||||
|
const result = insertStmt.run(b.id, user.id, b.name, b.phone, b.adults, b.children, b.checkin_date, b.checkout_date);
|
||||||
|
if (result.changes > 0) {
|
||||||
|
logAction('Новая заявка из внешней системы', { external_id: b.id });
|
||||||
|
await notifyNewBooking({...b, external_id: b.id});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('Синхронизация завершена');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Ошибка синхронизации:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { syncBookings };
|
||||||
Reference in New Issue
Block a user