1
This commit is contained in:
10
.gitignore
vendored
10
.gitignore
vendored
@@ -1,3 +1,13 @@
|
||||
|
||||
promt
|
||||
data
|
||||
.env
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ---> Node
|
||||
# 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