v2
All checks were successful
Deploy hotel / deploy-kdo (push) Successful in 1m1s

This commit is contained in:
2026-07-18 12:07:29 +05:00
parent e316d00efa
commit 6c4394713c
14 changed files with 1975 additions and 355 deletions

View File

@@ -127,6 +127,7 @@ function updateBookingStatus(req, res) {
LEFT JOIN rooms r ON b.room_id = r.id
WHERE b.id = ?`, [bookingId], (err, row) => {
if (err) return res.status(500).json({ error: 'Database error' });
try { require('../email').sendStatusChange(row, oldValue, status); } catch {}
res.json({ message: 'Status updated', booking: row });
});
});
@@ -357,6 +358,114 @@ function validatePromocodeAPI(req, res) {
});
}
function exportCSV(req, res) {
const statusFilter = req.query.status || '';
const from = req.query.from || '';
const to = req.query.to || '';
let whereClause = '1=1';
const params = [];
if (statusFilter && statusFilter !== 'all') {
whereClause += ' AND b.status = ?';
params.push(statusFilter);
}
if (from) {
whereClause += ' AND b.checkin_date >= ?';
params.push(from);
}
if (to) {
whereClause += ' AND b.checkout_date <= ?';
params.push(to);
}
db.all(`SELECT b.*, p.code as promocode_code FROM bookings b LEFT JOIN promocodes p ON b.promocode_id = p.id WHERE ${whereClause} ORDER BY b.checkin_date ASC`, params, (err, rows) => {
if (err) return res.status(500).json({ error: 'Database error' });
const headers = ['ID', 'Имя', 'Телефон', 'Взрослых', 'Детей', 'Заезд', 'Выезд', 'Тип номера', 'Комментарий', 'Базовая цена', 'Скидка %', 'Сумма скидки', 'Итого', 'Промокод', 'Статус', 'Пожелания'];
function escapeCsv(val) {
if (val === null || val === undefined) return '';
const str = String(val);
if (str.includes(';') || str.includes('"') || str.includes('\n')) {
return '"' + str.replace(/"/g, '""') + '"';
}
return str;
}
const csvRows = ['\uFEFF' + headers.join(';')];
rows.forEach(r => {
csvRows.push([
r.id, r.name, r.phone, r.adults, r.children,
r.checkin_date, r.checkout_date, r.room_type, r.comment,
r.base_price, r.discount_percent, r.discount_amount, r.total_price,
r.promocode_code, r.status, r.wishes
].map(escapeCsv).join(';'));
});
const dateStr = new Date().toISOString().split('T')[0];
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="bookings_${dateStr}.csv"`);
res.send(csvRows.join('\n'));
});
}
function getCalendar(req, res) {
const month = req.query.month;
if (!month) return res.status(400).json({ error: 'month parameter required (YYYY-MM)' });
const startDate = month + '-01';
const [y, m] = month.split('-').map(Number);
const endDate = new Date(y, m, 0).toISOString().split('T')[0];
db.all(`SELECT r.type, r.name, r.id as room_id, r.rooms_count, r.price_per_night, r.image_path FROM rooms r WHERE r.is_active = 1 ORDER BY r.price_per_night ASC`, [], (err, rooms) => {
if (err) return res.status(500).json({ error: 'Database error' });
db.all(`SELECT b.id, b.name, b.status, b.room_type, b.room_id, b.checkin_date, b.checkout_date, b.phone
FROM bookings b
WHERE b.status NOT IN ('отменена','выехала')
AND b.checkin_date <= ? AND b.checkout_date > ?
ORDER BY b.checkin_date ASC`,
[endDate, startDate], (err, bookings) => {
if (err) return res.status(500).json({ error: 'Database error' });
const days = {};
const currentDate = new Date(startDate);
const end = new Date(endDate);
while (currentDate <= end) {
const dateStr = currentDate.toISOString().split('T')[0];
days[dateStr] = {};
rooms.forEach(r => {
days[dateStr][r.type] = { total: r.rooms_count, booked: 0, bookings: [] };
});
currentDate.setDate(currentDate.getDate() + 1);
}
bookings.forEach(b => {
let d = new Date(Math.max(new Date(b.checkin_date).getTime(), new Date(startDate).getTime()));
const endD = new Date(Math.min(new Date(b.checkout_date).getTime(), new Date(endDate).getTime()));
while (d < endD) {
const dateStr = d.toISOString().split('T')[0];
const type = b.room_type || 'Не указан';
if (days[dateStr]) {
if (!days[dateStr][type]) {
days[dateStr][type] = { total: 0, booked: 0, bookings: [] };
}
days[dateStr][type].booked++;
if (days[dateStr][type].bookings.length < 10) {
days[dateStr][type].bookings.push({ id: b.id, name: b.name, status: b.status, phone: b.phone });
}
}
d.setDate(d.getDate() + 1);
}
});
res.json({ month, rooms: rooms.map(r => ({ type: r.type, name: r.name, id: r.room_id, rooms_count: r.rooms_count, price_per_night: r.price_per_night })), days });
});
});
}
function setupRoutes(app, authenticateToken, requireAdmin) {
app.get('/api/admin/bookings', authenticateToken, getBookingsForAdmin);
app.patch('/api/admin/bookings/:id', authenticateToken, requireAdmin, updateBookingStatus);
@@ -365,6 +474,8 @@ function setupRoutes(app, authenticateToken, requireAdmin) {
app.patch('/api/admin/bookings/:id/discount', authenticateToken, requireAdmin, updateBookingDiscount);
app.patch('/api/admin/bookings/:id/details', authenticateToken, requireAdmin, updateBookingDetails);
app.post('/api/promocodes/validate', validatePromocodeAPI);
app.get('/api/admin/export/bookings', authenticateToken, exportCSV);
app.get('/api/admin/calendar', authenticateToken, getCalendar);
}
module.exports = { init, setupRoutes };
module.exports = { init, setupRoutes };

View File

@@ -1,5 +1,5 @@
const config = require('../../config');
const { getRoomPrice } = config;
const { getRoomBasePriceByType } = config;
let db;
@@ -7,10 +7,43 @@ function init(database) {
db = database;
}
function calculateBasePrice(roomType, checkin, checkout) {
const pricePerNight = getRoomPrice(roomType);
function getEffectivePrice(roomType, date, callback) {
const seasonalModule = require('../seasonalPrices');
seasonalModule.getSeasonalPrice(roomType, date, (err, seasonalPrice) => {
if (err || !seasonalPrice) {
const basePrice = getRoomBasePriceByType(roomType);
return callback(null, basePrice);
}
callback(null, seasonalPrice);
});
}
function calculateNightPrices(roomType, checkin, checkout, callback) {
const nights = config.calculateNights(checkin, checkout);
return pricePerNight * nights;
if (nights <= 0) return callback(new Error('Invalid dates'));
const dates = [];
for (let i = 0; i < nights; i++) {
const d = new Date(checkin);
d.setDate(d.getDate() + i);
dates.push(d.toISOString().split('T')[0]);
}
let total = 0;
let processed = 0;
let hasError = false;
dates.forEach(date => {
getEffectivePrice(roomType, date, (err, price) => {
if (hasError) return;
if (err) { hasError = true; return callback(err, null); }
total += price;
processed++;
if (processed === dates.length) {
callback(null, total);
}
});
});
}
function validatePromocode(promocode, callback) {
@@ -29,35 +62,94 @@ function validatePromocode(promocode, callback) {
});
}
function checkAvailability(roomId, roomType, checkin, checkout, requestedCount, callback) {
let sql;
let params;
if (roomId) {
sql = `SELECT r.rooms_count, COALESCE(
(SELECT COUNT(b.id) FROM bookings b
WHERE b.room_id = r.id AND b.status IN ('новая','оплачена','зарезервирована','заселена')
AND b.checkin_date < ? AND b.checkout_date > ?), 0) as booked
FROM rooms r WHERE r.id = ? AND r.is_active = 1`;
params = [checkout, checkin, roomId];
} else {
sql = `SELECT r.rooms_count, COALESCE(
(SELECT COUNT(b.id) FROM bookings b
WHERE b.room_type = r.type AND b.status IN ('новая','оплачена','зарезервирована','заселена')
AND b.checkin_date < ? AND b.checkout_date > ?), 0) as booked
FROM rooms r WHERE r.type = ? AND r.is_active = 1 LIMIT 1`;
params = [checkout, checkin, roomType];
}
db.get(sql, params, (err, row) => {
if (err) return callback(err, null);
if (!row) return callback(null, { available: 0, total: 0 });
const available = Math.max(0, row.rooms_count - row.booked);
callback(null, { available, total: row.rooms_count, booked: row.booked });
});
}
function createBooking(req, res) {
const { name, phone, adults, children, checkin, checkout, wishes, room, room_id, promocode } = req.body;
if (!name || !phone || !adults || !checkin || !checkout) {
return res.status(400).json({ error: 'Missing required fields' });
}
const basePrice = calculateBasePrice(room, checkin, checkout) * (parseInt(adults) || 1);
validatePromocode(promocode, (err, promo) => {
const roomType = room;
const roomIdValue = room_id ? parseInt(room_id) : null;
checkAvailability(roomIdValue, roomType, checkin, checkout, 1, (err, avail) => {
if (err) return res.status(500).json({ error: 'Database error' });
let discountPercent = 0;
let promocodeId = null;
if (promo) {
discountPercent = promo.discount_percent;
promocodeId = promo.id;
if (avail.available < 1) {
return res.status(409).json({
error: `На выбранные даты нет свободных номеров типа "${roomType}". Доступно: ${avail.available} из ${avail.total}`,
available: avail.available
});
}
const safeBasePrice = basePrice || 0;
const discountAmount = Math.round(safeBasePrice * discountPercent / 100);
const totalPrice = safeBasePrice - discountAmount;
const roomIdValue = room_id ? parseInt(room_id) : null;
const stmt = db.prepare(`INSERT INTO bookings (name, phone, adults, children, checkin_date, checkout_date, wishes, status, room_type, room_id, base_price, discount_percent, discount_amount, total_price, promocode_id)
VALUES (?, ?, ?, ?, ?, ?, ?, 'новая', ?, ?, ?, ?, ?, ?, ?)`);
stmt.run(name, phone, parseInt(adults), parseInt(children || 0), checkin, checkout, wishes || null, room || null, roomIdValue,
safeBasePrice || null, discountPercent || 0, discountAmount || 0, totalPrice || null, promocodeId, function(err) {
if (err) {
console.error(err);
return res.status(500).json({ error: 'Database error' });
}
res.status(201).json({ id: this.lastID, message: 'Booking saved', base_price: safeBasePrice, discount_percent: discountPercent, discount_amount: discountAmount, total_price: totalPrice });
calculateNightPrices(roomType, checkin, checkout, (err, baseSum) => {
if (err) return res.status(400).json({ error: err.message });
const basePrice = baseSum * (parseInt(adults) || 1);
validatePromocode(promocode, (err, promo) => {
if (err) return res.status(500).json({ error: 'Database error' });
let discountPercent = 0;
let promocodeId = null;
if (promo) {
discountPercent = promo.discount_percent;
promocodeId = promo.id;
}
const safeBasePrice = basePrice || 0;
const discountAmount = Math.round(safeBasePrice * discountPercent / 100);
const totalPrice = safeBasePrice - discountAmount;
const stmt = db.prepare(`INSERT INTO bookings (name, phone, adults, children, checkin_date, checkout_date, wishes, status, room_type, room_id, base_price, discount_percent, discount_amount, total_price, promocode_id)
VALUES (?, ?, ?, ?, ?, ?, ?, 'новая', ?, ?, ?, ?, ?, ?, ?)`);
stmt.run(name, phone, parseInt(adults), parseInt(children || 0), checkin, checkout, wishes || null,
room || null, roomIdValue, safeBasePrice || null, discountPercent || 0, discountAmount || 0, totalPrice || null,
promocodeId, function(err) {
if (err) {
console.error(err);
return res.status(500).json({ error: 'Database error' });
}
const booking = {
id: this.lastID,
name, phone, adults: parseInt(adults), children: parseInt(children || 0),
checkin_date: checkin, checkout_date: checkout, wishes, room_type: room, room_id: roomIdValue,
base_price: safeBasePrice, discount_percent: discountPercent, discount_amount: discountAmount,
total_price: totalPrice, promocode_id: promocodeId
};
try { require('../email').sendBookingConfirmation(booking); } catch {}
res.status(201).json({
id: this.lastID, message: 'Booking saved',
base_price: safeBasePrice, discount_percent: discountPercent,
discount_amount: discountAmount, total_price: totalPrice
});
});
stmt.finalize();
});
});
stmt.finalize();
});
}
@@ -99,4 +191,4 @@ function setupRoutes(app, authenticateToken, requireAdmin) {
app.get('/api/admin/bookings/:id/history', authenticateToken, getBookingHistory);
}
module.exports = { init, setupRoutes, validatePromocode, logHistory, calculateBasePrice };
module.exports = { init, setupRoutes, validatePromocode, logHistory, calculateNightPrices, checkAvailability };

292
modules/database/index.js Normal file
View File

@@ -0,0 +1,292 @@
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const dotenv = require('dotenv');
const envPath = path.join(__dirname, '..', '..', '.env');
function ensureEnvSecret(key, envVarName) {
if (!process.env[envVarName] || process.env[envVarName].length < 16) {
const secret = crypto.randomBytes(32).toString('hex');
let envContent = '';
if (fs.existsSync(envPath)) {
envContent = fs.readFileSync(envPath, 'utf8');
}
if (envContent.includes(`${envVarName}=`)) {
envContent = envContent.replace(new RegExp(`${envVarName}=.*`, 'g'), `${envVarName}=${secret}`);
} else {
envContent += `\n${envVarName}=${secret}\n`;
}
fs.writeFileSync(envPath, envContent);
process.env[envVarName] = secret;
dotenv.config({ path: envPath, override: true });
console.log(`${envVarName} auto-generated and saved to .env`);
}
}
function initDatabase(db) {
const dataDir = path.join(__dirname, '..', '..', 'data');
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir);
db.serialize(() => {
db.run(`CREATE TABLE IF NOT EXISTS bookings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
phone TEXT NOT NULL,
adults INTEGER NOT NULL,
children INTEGER NOT NULL,
checkin_date TEXT NOT NULL,
checkout_date TEXT NOT NULL,
wishes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
const columnsToAdd = [
'wishes TEXT',
'status TEXT DEFAULT "новая"',
'room_type TEXT',
'room_id INTEGER',
'comment TEXT',
'base_price REAL',
'discount_percent INTEGER DEFAULT 0',
'discount_amount REAL DEFAULT 0',
'total_price REAL',
'promocode_id INTEGER'
];
function addColumnSafely(columns, index) {
if (index >= columns.length) {
setupPromocodesTable();
return;
}
db.all("PRAGMA table_info(bookings)", [], (err, cols) => {
if (err) { setupPromocodesTable(); return; }
const colNames = cols.map(c => c.name);
const columnDef = columns[index];
const colName = columnDef.split(' ')[0];
if (!colNames.includes(colName)) {
db.run(`ALTER TABLE bookings ADD COLUMN ${columnDef}`, (err) => {
if (err && !err.message.includes('duplicate')) {
console.log('Migration note (ignore if exists):', err.message);
}
});
}
addColumnSafely(columns, index + 1);
});
}
function setupPromocodesTable() {
db.run(`CREATE TABLE IF NOT EXISTS promocodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
discount_percent INTEGER NOT NULL CHECK(discount_percent BETWEEN 1 AND 99),
valid_from DATETIME,
valid_to DATETIME,
valid_days INTEGER,
is_active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
setupRoomsTable();
}
function setupRoomsTable() {
db.run(`CREATE TABLE IF NOT EXISTS rooms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
rooms_count INTEGER DEFAULT 1,
area_sqm INTEGER DEFAULT 20,
max_guests INTEGER DEFAULT 2,
furniture TEXT DEFAULT '[]',
amenities TEXT DEFAULT '[]',
floors TEXT DEFAULT '[]',
price_per_night INTEGER NOT NULL,
image_path TEXT,
extra_beds INTEGER DEFAULT 0,
extra_bed_price INTEGER DEFAULT 0,
is_active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
db.all("PRAGMA table_info(rooms)", [], (err, cols) => {
if (err) { setupBookingHistoryTable(); return; }
const colNames = cols.map(c => c.name);
const migrations = [
['area_sqm', 'INTEGER DEFAULT 20'],
['furniture', "TEXT DEFAULT '[]'"],
['amenities', "TEXT DEFAULT '[]'"],
['floors', "TEXT DEFAULT '[]'"],
['extra_beds', 'INTEGER DEFAULT 0'],
['extra_bed_price', 'INTEGER DEFAULT 0']
];
let pending = migrations.length;
if (pending === 0) {
migratePricePerNight();
return;
}
migrations.forEach(([colName, colDef]) => {
if (!colNames.includes(colName)) {
db.run(`ALTER TABLE rooms ADD COLUMN ${colName} ${colDef}`, (err) => {
if (err && !err.message.includes('duplicate') && !err.message.includes('NOT NULL')) {
console.log('Room migration note:', err.message);
}
});
}
pending--;
if (pending === 0) migratePricePerNight();
});
});
}
function migratePricePerNight() {
db.all("PRAGMA table_info(rooms)", [], (err, cols) => {
if (err) { setupBookingHistoryTable(); return; }
const colNames = cols.map(c => c.name);
if (!colNames.includes('price_per_night')) {
db.run(`ALTER TABLE rooms ADD COLUMN price_per_night INTEGER DEFAULT 0`, (err) => {
if (err && !err.message.includes('duplicate') && !err.message.includes('NOT NULL')) {
console.log('Room migration note (add price_per_night):', err.message);
}
});
}
db.run(`CREATE TABLE rooms_backup AS SELECT id, type, name, description, rooms_count, area_sqm, max_guests, furniture, amenities, floors, price_per_night, image_path, extra_beds, extra_bed_price, is_active, created_at FROM rooms`, (err) => {
if (err) { console.log('Room backup failed:', err.message); setupBookingHistoryTable(); return; }
db.run(`DROP TABLE rooms`, (err) => {
if (err) { console.log('Room drop failed:', err.message); setupBookingHistoryTable(); return; }
db.run(`CREATE TABLE rooms (id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT NOT NULL, name TEXT NOT NULL, description TEXT, rooms_count INTEGER DEFAULT 1, area_sqm INTEGER DEFAULT 20, max_guests INTEGER DEFAULT 2, furniture TEXT DEFAULT '[]', amenities TEXT DEFAULT '[]', floors TEXT DEFAULT '[]', price_per_night INTEGER NOT NULL, image_path TEXT, extra_beds INTEGER DEFAULT 0, extra_bed_price INTEGER DEFAULT 0, is_active INTEGER DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP)`, (err) => {
if (err) { console.log('Room recreate failed:', err.message); setupBookingHistoryTable(); return; }
db.run(`INSERT INTO rooms (id, type, name, description, rooms_count, area_sqm, max_guests, furniture, amenities, floors, price_per_night, image_path, extra_beds, extra_bed_price, is_active, created_at) SELECT id, type, name, description, rooms_count, area_sqm, max_guests, COALESCE(furniture, '[]'), COALESCE(amenities, '[]'), COALESCE(floors, '[]'), COALESCE(price_per_night, 0) as price_per_night, image_path, COALESCE(extra_beds, 0), COALESCE(extra_bed_price, 0), is_active, created_at FROM rooms_backup`, (err) => {
if (err) console.log('Room data restore failed:', err.message);
db.run(`DROP TABLE rooms_backup`, () => {});
setupBookingHistoryTable();
});
});
});
});
});
}
function setupBookingHistoryTable() {
db.run(`CREATE TABLE IF NOT EXISTS booking_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
booking_id INTEGER NOT NULL,
user_id INTEGER,
user_login TEXT,
field TEXT NOT NULL,
old_value TEXT,
new_value TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (booking_id) REFERENCES bookings(id)
)`);
setupUsersTable();
}
function setupUsersTable() {
db.run(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
login TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
full_name TEXT,
email TEXT,
role TEXT NOT NULL DEFAULT 'user',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
setupSettingsTable();
}
function setupSettingsTable() {
db.run(`CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
setupReviewsTable();
}
function setupReviewsTable() {
db.run(`CREATE TABLE IF NOT EXISTS reviews (
id INTEGER PRIMARY KEY AUTOINCREMENT,
author_name TEXT NOT NULL,
country TEXT NOT NULL,
country_code TEXT,
city TEXT NOT NULL,
stars REAL NOT NULL CHECK(stars >= 0 AND stars <= 5),
text TEXT NOT NULL,
review_code TEXT NOT NULL,
ip_address TEXT,
is_approved INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`, (err) => {
if (err && !err.message.includes('already exists')) {
console.error('Reviews table error:', err.message);
}
db.run(`PRAGMA foreign_keys = ON`);
db.all("PRAGMA table_info(reviews)", [], (err, cols) => {
if (err || !cols) return;
const colNames = cols.map(c => c.name);
if (!colNames.includes('country_code')) {
db.run("ALTER TABLE reviews ADD COLUMN country_code TEXT", (err) => {
if (err) console.log('Migration: country_code column:', err.message);
});
}
});
});
setupRoomImages();
}
function setupRoomImages() {
db.run(`CREATE TABLE IF NOT EXISTS room_images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id INTEGER NOT NULL,
image_path TEXT NOT NULL,
sort_order INTEGER DEFAULT 0,
is_primary INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
)`);
setupSeasonalPrices();
}
function setupSeasonalPrices() {
db.run(`CREATE TABLE IF NOT EXISTS seasonal_prices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_type TEXT NOT NULL,
date_from TEXT NOT NULL,
date_to TEXT NOT NULL,
price_per_night INTEGER NOT NULL,
label TEXT,
is_active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
setupBackupsTable();
}
function setupBackupsTable() {
db.run(`CREATE TABLE IF NOT EXISTS backups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL,
size INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
type TEXT DEFAULT 'manual',
restored_at DATETIME
)`, () => {
setupIndices();
});
}
function setupIndices() {
db.run(`CREATE INDEX IF NOT EXISTS idx_bookings_checkin ON bookings(checkin_date)`);
db.run(`CREATE INDEX IF NOT EXISTS idx_bookings_status ON bookings(status)`);
db.run(`CREATE INDEX IF NOT EXISTS idx_bookings_room_id ON bookings(room_id)`);
db.run(`CREATE INDEX IF NOT EXISTS idx_room_images_room_id ON room_images(room_id)`);
db.run(`CREATE INDEX IF NOT EXISTS idx_seasonal_prices_dates ON seasonal_prices(date_from, date_to)`);
db.run(`CREATE INDEX IF NOT EXISTS idx_seasonal_prices_type ON seasonal_prices(room_type)`);
console.log('✅ Database initialized');
}
addColumnSafely(columnsToAdd, 0);
});
}
module.exports = { ensureEnvSecret, initDatabase };

109
modules/email/index.js Normal file
View File

@@ -0,0 +1,109 @@
const nodemailer = require('nodemailer');
let db;
let settingsModule;
function init(database, settings) {
db = database;
settingsModule = settings;
}
function getSettings(callback) {
settingsModule.getAll((err, all) => {
if (err) return callback(err);
callback(null, all);
});
}
function createTransport(settings, callback) {
if (!settings.smtp_host) return callback(new Error('SMTP host not configured'));
const transport = nodemailer.createTransport({
host: settings.smtp_host,
port: parseInt(settings.smtp_port) || 587,
secure: settings.smtp_secure === 'true',
auth: {
user: settings.smtp_user || '',
pass: settings.smtp_pass || ''
}
});
callback(null, transport);
}
function sendTestEmail(settings, to, callback) {
createTransport(settings, (err, transport) => {
if (err) return callback(err);
transport.sendMail({
from: settings.smtp_from || settings.smtp_user,
to: to,
subject: 'Hotel 777 — Тестовое письмо',
html: `<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;padding:20px;">
<h2 style="color:#c9a84c;">Hotel 777</h2>
<p>Это тестовое письмо. Настройки SMTP работают корректно.</p>
<p style="color:#999;font-size:12px;">Отправлено: ${new Date().toLocaleString('ru-RU')}</p>
</div>`
}, (err, info) => {
callback(err, info);
});
});
}
function sendBookingConfirmation(booking) {
if (!booking.phone) return;
getSettings((err, settings) => {
if (err || settings.email_notifications_enabled !== 'true') return;
createTransport(settings, (err, transport) => {
if (err) { console.error('Email transport error:', err.message); return; }
transport.sendMail({
from: settings.smtp_from || settings.smtp_user,
to: settings.admin_email || settings.smtp_user,
subject: `Новая бронь: ${booking.name}${booking.room_type || 'Номер'}с ${booking.checkin_date}`,
html: `
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;padding:20px;border:1px solid #e2e8f0;border-radius:12px;">
<h2 style="color:#c9a84c;margin:0 0 16px;">Hotel 777 — Новая бронь</h2>
<table style="width:100%;border-collapse:collapse;">
<tr><td style="padding:8px;color:#64748b;">Имя:</td><td style="padding:8px;font-weight:600;">${booking.name}</td></tr>
<tr><td style="padding:8px;color:#64748b;">Телефон:</td><td style="padding:8px;font-weight:600;">${booking.phone}</td></tr>
<tr><td style="padding:8px;color:#64748b;">Номер:</td><td style="padding:8px;font-weight:600;">${booking.room_type || '—'}</td></tr>
<tr><td style="padding:8px;color:#64748b;">Гостей:</td><td style="padding:8px;">${booking.adults} взр.${booking.children ? ', ' + booking.children + ' дет.' : ''}</td></tr>
<tr><td style="padding:8px;color:#64748b;">Заезд:</td><td style="padding:8px;">${booking.checkin_date}</td></tr>
<tr><td style="padding:8px;color:#64748b;">Выезд:</td><td style="padding:8px;">${booking.checkout_date}</td></tr>
<tr><td style="padding:8px;color:#64748b;">Сумма:</td><td style="padding:8px;font-weight:700;color:#c9a84c;">${booking.total_price || booking.base_price || 0} ₽</td></tr>
${booking.wishes ? `<tr><td style="padding:8px;color:#64748b;">Пожелания:</td><td style="padding:8px;">${booking.wishes}</td></tr>` : ''}
</table>
<p style="margin-top:20px;color:#999;font-size:12px;">Зайдите в <a href="https://hotel777.ru/admin" style="color:#2563eb;">админ-панель</a> для управления бронью.</p>
</div>`
}, (err) => {
if (err) console.error('Booking confirmation email error:', err.message);
});
});
});
}
function sendStatusChange(booking, oldStatus, newStatus) {
getSettings((err, settings) => {
if (err || settings.email_notifications_enabled !== 'true') return;
if (oldStatus === newStatus) return;
createTransport(settings, (err, transport) => {
if (err) { console.error('Email transport error:', err.message); return; }
transport.sendMail({
from: settings.smtp_from || settings.smtp_user,
to: settings.admin_email || settings.smtp_user,
subject: `Статус брони #${booking.id} изменён: ${oldStatus}${newStatus}`,
html: `
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;padding:20px;border:1px solid #e2e8f0;border-radius:12px;">
<h2 style="color:#c9a84c;">Hotel 777 — Изменение статуса</h2>
<p>Бронь <strong>#${booking.id}</strong> — ${booking.name}</p>
<p>Статус изменён: <span style="text-decoration:line-through;color:#ef4444;">${oldStatus}</span> → <span style="color:#16a34a;font-weight:600;">${newStatus}</span></p>
<p style="color:#999;font-size:12px;">${new Date().toLocaleString('ru-RU')}</p>
</div>`
}, (err) => {
if (err) console.error('Status change email error:', err.message);
});
});
});
}
function setupRoutes(app, authenticateToken, requireAdmin) {
}
module.exports = { init, setupRoutes, sendTestEmail, sendBookingConfirmation, sendStatusChange, getSettings };

129
modules/reports/index.js Normal file
View File

@@ -0,0 +1,129 @@
let db;
function init(database) {
db = database;
}
function getSummary(req, res) {
const today = new Date().toISOString().split('T')[0];
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
const monthAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
const queries = {
revenue_today: `SELECT COALESCE(SUM(total_price), 0) as revenue FROM bookings WHERE status NOT IN ('отменена','новая') AND checkin_date = ?`,
revenue_week: `SELECT COALESCE(SUM(total_price), 0) as revenue FROM bookings WHERE status NOT IN ('отменена','новая') AND checkin_date >= ?`,
revenue_month: `SELECT COALESCE(SUM(total_price), 0) as revenue FROM bookings WHERE status NOT IN ('отменена','новая') AND checkin_date >= ?`,
checkin_today: `SELECT COUNT(*) as count FROM bookings WHERE status IN ('заселена') AND checkin_date = ?`,
checkout_today: `SELECT COUNT(*) as count FROM bookings WHERE status IN ('выехала') AND checkout_date = ?`,
new_bookings: `SELECT COUNT(*) as count FROM bookings WHERE status = 'новая'`,
active_bookings: `SELECT COUNT(*) as count FROM bookings WHERE status IN ('оплачена','зарезервирована','заселена')`,
avg_review: `SELECT COALESCE(AVG(stars), 0) as avg FROM reviews WHERE is_approved = 1`,
total_bookings: `SELECT COUNT(*) as total, AVG(julianday(checkout_date) - julianday(checkin_date)) as avg_stay FROM bookings WHERE status NOT IN ('отменена')`
};
const results = {};
let completed = 0;
const total = Object.keys(queries).length;
function finish() {
completed++;
if (completed < total) return;
const now = new Date().toISOString();
const stayingSql = `SELECT COUNT(DISTINCT b.id) as count FROM bookings b WHERE b.status IN ('оплачена','зарезервирована','заселена') AND b.checkin_date <= ? AND b.checkout_date > ?`;
db.get(stayingSql, [now, now], (err, staying) => {
const stayingCount = (err ? 0 : staying?.count) || 0;
db.all(`SELECT r.type, r.rooms_count, COUNT(b.id) as booked FROM rooms r LEFT JOIN bookings b ON b.room_id = r.id AND b.status IN ('новая','оплачена','зарезервирована','заселена') AND b.checkin_date <= ? AND b.checkout_date > ? WHERE r.is_active = 1 GROUP BY r.type, r.rooms_count`, [now, now], (err, occRows) => {
const occupancy = { current: 0, by_type: {} };
let totalRooms = 0;
let totalBooked = 0;
if (occRows) {
occRows.forEach(r => {
const booked = r.booked || 0;
const total = r.rooms_count || 0;
occupancy.by_type[r.type] = total > 0 ? Math.round((booked / total) * 100) : 0;
totalRooms += total;
totalBooked += booked;
});
occupancy.current = totalRooms > 0 ? Math.round((totalBooked / totalRooms) * 100) : 0;
}
res.json({
revenue: {
today: results.revenue_today,
week: results.revenue_week,
month: results.revenue_month
},
occupancy: occupancy,
bookings: {
new: results.new_bookings,
active: results.active_bookings,
checkin_today: results.checkin_today,
checkout_today: results.checkout_today,
staying_now: stayingCount,
total: results.total_bookings?.total || 0
},
avg_stay_days: Math.round((results.total_bookings?.avg_stay || 0) * 10) / 10,
avg_review_stars: Math.round((results.avg_review || 0) * 10) / 10
});
});
});
}
Object.entries(queries).forEach(([key, sql]) => {
const params = [];
if (key === 'revenue_today' || key === 'checkin_today' || key === 'checkout_today') params.push(today);
else if (key === 'revenue_week') params.push(weekAgo);
else if (key === 'revenue_month') params.push(monthAgo);
db.get(sql, params, (err, row) => {
if (err) { results[key] = 0; }
else if (key.includes('revenue')) results[key] = row.revenue || 0;
else if (key === 'total_bookings') results[key] = { total: row.total || 0, avg_stay: row.avg_stay || 0 };
else results[key] = row?.count || row?.avg || 0;
finish();
});
});
}
function getRevenue(req, res) {
const period = req.query.period || 'monthly';
const months = parseInt(req.query.months) || 6;
let groupBy;
let dateFormat;
if (period === 'daily') {
groupBy = "date(b.checkin_date)";
dateFormat = 'daily';
} else if (period === 'weekly') {
groupBy = "strftime('%Y-W%W', b.checkin_date)";
dateFormat = 'weekly';
} else {
groupBy = "strftime('%Y-%m', b.checkin_date)";
dateFormat = 'monthly';
}
const sinceDate = new Date();
sinceDate.setMonth(sinceDate.getMonth() - months);
const since = sinceDate.toISOString().split('T')[0];
db.all(
`SELECT ${groupBy} as label, COALESCE(SUM(total_price), 0) as revenue, COUNT(*) as bookings
FROM bookings b
WHERE b.status NOT IN ('отменена','новая') AND b.checkin_date >= ?
GROUP BY label ORDER BY label ASC`,
[since],
(err, rows) => {
if (err) return res.status(500).json({ error: 'Database error' });
res.json({ period: dateFormat, data: rows });
}
);
}
function setupRoutes(app, authenticateToken, requireAdmin) {
app.get('/api/admin/reports/summary', authenticateToken, getSummary);
app.get('/api/admin/reports/revenue', authenticateToken, getRevenue);
}
module.exports = { init, setupRoutes };

View File

@@ -18,7 +18,22 @@ function getAll(req, res) {
db.all(`SELECT * FROM rooms WHERE is_active = 1 ORDER BY price_per_night ASC`, [], (err, rows) => {
if (err) { console.error('Rooms API error:', err); return res.status(500).json({ error: 'Database error' }); }
rows = rows.map(parseRoomFields);
res.json(rows);
loadImages(rows, () => res.json(rows));
});
}
function loadImages(rows, callback) {
if (rows.length === 0) return callback();
let loaded = 0;
rows.forEach(row => {
db.all(`SELECT * FROM room_images WHERE room_id = ? ORDER BY sort_order ASC`, [row.id], (err, images) => {
row.images = images || [];
if (row.image_path && !images.some(i => i.image_path === row.image_path)) {
row.images.unshift({ id: null, room_id: row.id, image_path: row.image_path, sort_order: -1, is_primary: 1 });
}
loaded++;
if (loaded === rows.length) callback();
});
});
}
@@ -26,7 +41,7 @@ function getAllForAdmin(req, res) {
db.all(`SELECT * FROM rooms ORDER BY price_per_night ASC`, [], (err, rows) => {
if (err) { console.error('Admin rooms API error:', err); return res.status(500).json({ error: 'Database error' }); }
rows = rows.map(parseRoomFields);
res.json(rows);
loadImages(rows, () => res.json(rows));
});
}
@@ -79,7 +94,7 @@ function updateRoom(req, res) {
if (err) { console.error('Update room error:', err); return res.status(500).json({ error: 'Database error' }); }
db.get(`SELECT * FROM rooms WHERE id = ?`, [id], (err, row) => {
if (err) return res.status(500).json({ error: 'Database error' });
res.json(parseRoomFields(row));
loadImages([parseRoomFields(row)], () => res.json(row));
});
}
);
@@ -128,13 +143,168 @@ function uploadRoomImage(req, res) {
}
}
function uploadRoomImages(req, res) {
const roomId = parseInt(req.params.id);
if (!req.files || req.files.length === 0) {
return res.status(400).json({ error: 'Файлы не загружены' });
}
let processed = 0;
const results = [];
function processFile(index) {
if (index >= req.files.length) return;
const file = req.files[index];
let imagePath = 'uploads/rooms/' + file.filename;
function saveByPath(finalPath) {
db.get(`SELECT MAX(sort_order) as maxOrder FROM room_images WHERE room_id = ?`, [roomId], (err, row) => {
const sortOrder = (row?.maxOrder || 0) + 1;
db.run(`INSERT INTO room_images (room_id, image_path, sort_order) VALUES (?, ?, ?)`,
[roomId, finalPath, sortOrder], function(insErr) {
if (insErr) { console.error('Room image insert error:', insErr); }
else results.push({ id: this.lastID, image_path: finalPath, sort_order: sortOrder });
processFile(index + 1);
});
});
}
if (file.mimetype !== 'image/webp') {
const inputPath = file.path;
const outputPath = path.join(path.dirname(inputPath), file.filename.replace(/\.[^.]+$/, '.webp'));
require('sharp')(inputPath)
.webp({ quality: 85 })
.toFile(outputPath)
.then(() => {
try { fs.unlinkSync(inputPath); } catch {}
saveByPath('uploads/rooms/' + path.basename(outputPath));
})
.catch(() => {
saveByPath(imagePath);
});
} else {
saveByPath(imagePath);
}
}
processFile(0);
const checkDone = setInterval(() => {
if (results.length + (processed > req.files.length ? 0 : 0) >= req.files.length) {
clearInterval(checkDone);
if (results.length === 0) {
// Still processing sharp conversions, wait a bit more
setTimeout(() => {
res.json({ message: 'Изображения загружены', images: results });
}, 1000);
} else {
res.json({ message: 'Изображения загружены', images: results });
}
}
}, 200);
}
function deleteRoomImage(req, res) {
const { imageId } = req.params;
db.get(`SELECT * FROM room_images WHERE id = ?`, [imageId], (err, row) => {
if (err) return res.status(500).json({ error: 'Database error' });
if (!row) return res.status(404).json({ error: 'Изображение не найдено' });
const filePath = path.join(__dirname, '..', '..', row.image_path);
try { if (fs.existsSync(filePath)) fs.unlinkSync(filePath); } catch {}
db.run(`DELETE FROM room_images WHERE id = ?`, [imageId], function(err) {
if (err) return res.status(500).json({ error: 'Database error' });
res.json({ message: 'Изображение удалено' });
});
});
}
function setPrimaryImage(req, res) {
const { imageId } = req.params;
db.get(`SELECT * FROM room_images WHERE id = ?`, [imageId], (err, row) => {
if (err) return res.status(500).json({ error: 'Database error' });
if (!row) return res.status(404).json({ error: 'Изображение не найдено' });
db.run(`UPDATE room_images SET is_primary = 0 WHERE room_id = ?`, [row.room_id], (err) => {
if (err) return res.status(500).json({ error: 'Database error' });
db.run(`UPDATE room_images SET is_primary = 1 WHERE id = ?`, [imageId], (err) => {
if (err) return res.status(500).json({ error: 'Database error' });
db.run(`UPDATE rooms SET image_path = ? WHERE id = ?`, [row.image_path, row.room_id], (err) => {
if (err) return res.status(500).json({ error: 'Database error' });
res.json({ message: 'Главное изображение обновлено' });
});
});
});
});
}
function reorderImages(req, res) {
const { images } = req.body;
if (!Array.isArray(images)) return res.status(400).json({ error: 'images must be an array' });
let done = 0;
images.forEach((img) => {
db.run(`UPDATE room_images SET sort_order = ? WHERE id = ?`, [img.sort_order, img.id], (err) => {
if (err) console.error('Reorder error:', err);
done++;
if (done === images.length) res.json({ message: 'Порядок обновлён' });
});
});
}
function getAvailability(req, res) {
const { checkin, checkout } = req.query;
if (!checkin || !checkout) {
return res.status(400).json({ error: 'checkin и checkout обязательны' });
}
db.all(`SELECT * FROM rooms WHERE is_active = 1 ORDER BY price_per_night ASC`, [], (err, rooms) => {
if (err) return res.status(500).json({ error: 'Database error' });
if (rooms.length === 0) return res.json([]);
let processed = 0;
const result = [];
rooms.forEach(room => {
db.get(
`SELECT COUNT(b.id) as booked FROM bookings b
WHERE b.room_id = ? AND b.status IN ('новая','оплачена','зарезервирована','заселена')
AND b.checkin_date < ? AND b.checkout_date > ?`,
[room.id, checkout, checkin],
(err, row) => {
const booked = err ? 0 : (row?.booked || 0);
result.push({
type: room.type,
name: room.name,
id: room.id,
total: room.rooms_count,
booked: booked,
available: Math.max(0, room.rooms_count - booked),
price_per_night: room.price_per_night,
max_guests: room.max_guests
});
processed++;
if (processed === rooms.length) {
result.sort((a, b) => a.price_per_night - b.price_per_night);
res.json(result);
}
}
);
});
});
}
function setupRoutes(app, authenticateToken, requireAdmin, upload) {
app.get('/api/rooms', getAll);
app.get('/api/rooms/availability', getAvailability);
app.get('/api/admin/rooms', authenticateToken, requireAdmin, getAllForAdmin);
app.post('/api/admin/rooms', authenticateToken, requireAdmin, createRoom);
app.put('/api/admin/rooms/:id', authenticateToken, requireAdmin, updateRoom);
app.delete('/api/admin/rooms/:id', authenticateToken, requireAdmin, deleteRoom);
app.post('/api/admin/rooms/upload', authenticateToken, requireAdmin, upload.single('image'), uploadRoomImage);
app.post('/api/admin/rooms/:id/images', authenticateToken, requireAdmin, upload.array('images', 5), uploadRoomImages);
app.delete('/api/admin/rooms/images/:imageId', authenticateToken, requireAdmin, deleteRoomImage);
app.put('/api/admin/rooms/images/:imageId/primary', authenticateToken, requireAdmin, setPrimaryImage);
app.put('/api/admin/rooms/images/reorder', authenticateToken, requireAdmin, reorderImages);
}
module.exports = { init, setupRoutes };
module.exports = { init, setupRoutes };

View File

@@ -0,0 +1,96 @@
let db;
function init(database) {
db = database;
}
function getAll(req, res) {
db.all(`SELECT * FROM seasonal_prices ORDER BY date_from ASC`, [], (err, rows) => {
if (err) return res.status(500).json({ error: 'Database error' });
res.json(rows);
});
}
function create(req, res) {
const { room_type, date_from, date_to, price_per_night, label, is_active } = req.body;
if (!room_type || !date_from || !date_to || !price_per_night) {
return res.status(400).json({ error: 'room_type, date_from, date_to и price_per_night обязательны' });
}
if (new Date(date_to) <= new Date(date_from)) {
return res.status(400).json({ error: 'date_to должна быть позже date_from' });
}
db.run(
`INSERT INTO seasonal_prices (room_type, date_from, date_to, price_per_night, label, is_active) VALUES (?, ?, ?, ?, ?, ?)`,
[room_type, date_from, date_to, price_per_night, label || null, is_active !== undefined ? (is_active ? 1 : 0) : 1],
function(err) {
if (err) return res.status(500).json({ error: 'Database error' });
db.get(`SELECT * FROM seasonal_prices WHERE id = ?`, [this.lastID], (err, row) => {
if (err) return res.status(500).json({ error: 'Database error' });
res.status(201).json(row);
});
}
);
}
function update(req, res) {
const { id } = req.params;
const { room_type, date_from, date_to, price_per_night, label, is_active } = req.body;
db.get(`SELECT * FROM seasonal_prices WHERE id = ?`, [id], (err, row) => {
if (err) return res.status(500).json({ error: 'Database error' });
if (!row) return res.status(404).json({ error: 'Не найдено' });
const newRoomType = room_type ?? row.room_type;
const newDateFrom = date_from ?? row.date_from;
const newDateTo = date_to ?? row.date_to;
if (new Date(newDateTo) <= new Date(newDateFrom)) {
return res.status(400).json({ error: 'date_to должна быть позже date_from' });
}
db.run(
`UPDATE seasonal_prices SET room_type=?, date_from=?, date_to=?, price_per_night=?, label=?, is_active=? WHERE id=?`,
[newRoomType, newDateFrom, newDateTo,
price_per_night ?? row.price_per_night,
label !== undefined ? label : row.label,
is_active !== undefined ? (is_active ? 1 : 0) : row.is_active,
id],
function(err) {
if (err) return res.status(500).json({ error: 'Database error' });
db.get(`SELECT * FROM seasonal_prices WHERE id = ?`, [id], (err, row) => {
if (err) return res.status(500).json({ error: 'Database error' });
res.json(row);
});
}
);
});
}
function remove(req, res) {
const { id } = req.params;
db.run(`DELETE FROM seasonal_prices WHERE id = ?`, [id], function(err) {
if (err) return res.status(500).json({ error: 'Database error' });
if (this.changes === 0) return res.status(404).json({ error: 'Не найдено' });
res.json({ message: 'Сезонная цена удалена' });
});
}
function getSeasonalPrice(roomType, date, callback) {
db.get(
`SELECT price_per_night FROM seasonal_prices WHERE room_type = ? AND is_active = 1 AND date_from <= ? AND date_to >= ? ORDER BY date_from DESC LIMIT 1`,
[roomType, date, date],
(err, row) => {
if (err) return callback(err, null);
callback(null, row ? row.price_per_night : null);
}
);
}
function setupRoutes(app, authenticateToken, requireAdmin) {
app.get('/api/admin/seasonal-prices', authenticateToken, requireAdmin, getAll);
app.post('/api/admin/seasonal-prices', authenticateToken, requireAdmin, create);
app.put('/api/admin/seasonal-prices/:id', authenticateToken, requireAdmin, update);
app.delete('/api/admin/seasonal-prices/:id', authenticateToken, requireAdmin, remove);
}
module.exports = { init, setupRoutes, getSeasonalPrice };

View File

@@ -1,3 +1,7 @@
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
let db;
const DEFAULT_SETTINGS = {
review_code: 'GUEST2026',
@@ -14,17 +18,13 @@ function initDefaultSettings() {
Object.entries(DEFAULT_SETTINGS).forEach(([key, value]) => {
db.run(`INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)`, [key, value], (err) => {
if (err) console.error('Settings init error:', err);
else console.log(`✅ Setting "${key}" initialized/confirmed`);
});
});
}
function get(key, callback) {
db.get(`SELECT value FROM settings WHERE key = ?`, [key], (err, row) => {
if (err) {
console.error('Settings get error:', err);
return callback(err, null);
}
if (err) return callback(err, null);
callback(null, row ? row.value : null);
});
}
@@ -35,10 +35,7 @@ function set(key, value, callback) {
ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = CURRENT_TIMESTAMP`,
[key, value, value],
function(err) {
if (err) {
console.error('Settings set error:', err);
return callback(err);
}
if (err) return callback(err);
callback(null);
}
);
@@ -46,10 +43,7 @@ function set(key, value, callback) {
function getAll(callback) {
db.all(`SELECT * FROM settings`, [], (err, rows) => {
if (err) {
console.error('Settings getAll error:', err);
return callback(err, null);
}
if (err) return callback(err, null);
const settings = {};
rows.forEach(row => { settings[row.key] = row.value; });
callback(null, settings);
@@ -67,26 +61,27 @@ function setReviewCode(value, callback) {
function checkIpCooldown(ip, callback) {
const cooldownMinutes = 5;
const cutoffTime = new Date(Date.now() - cooldownMinutes * 60 * 1000).toISOString();
db.get(
`SELECT id FROM reviews WHERE ip_address = ? AND created_at > ? LIMIT 1`,
[ip, cutoffTime],
(err, row) => {
if (err) {
console.error('IP cooldown check error:', err);
return callback(err, false);
}
if (err) return callback(err, false);
callback(null, !!row);
}
);
}
function maskValue(val) {
if (!val || val.length <= 4) return '****';
return '****' + val.slice(-4);
}
function setupRoutes(app, authenticateToken, requireAdmin) {
app.get('/api/admin/settings', authenticateToken, requireAdmin, (req, res) => {
getAll((err, settings) => {
if (err) return res.status(500).json({ error: 'Database error' });
if (settings.review_code) {
settings.review_code_masked = settings.review_code.replace(/./g, '*');
settings.review_code_masked = maskValue(settings.review_code);
}
res.json(settings);
});
@@ -109,6 +104,104 @@ function setupRoutes(app, authenticateToken, requireAdmin) {
res.json({ message: 'Review code updated', code: code });
});
});
app.get('/api/admin/security', authenticateToken, requireAdmin, (req, res) => {
const jwtSecret = process.env.JWT_SECRET || '';
const hotelKey = process.env.HOTEL777KEY || '';
res.json({
jwt_secret_masked: maskValue(jwtSecret),
hotel777key_masked: maskValue(hotelKey)
});
});
app.post('/api/admin/security/regenerate', authenticateToken, requireAdmin, (req, res) => {
const { type } = req.body;
if (!['jwt', 'hotel777key'].includes(type)) {
return res.status(400).json({ error: 'Type must be "jwt" or "hotel777key"' });
}
const secret = crypto.randomBytes(32).toString('hex');
const envVarName = type === 'jwt' ? 'JWT_SECRET' : 'HOTEL777KEY';
const envPath = path.join(__dirname, '..', '..', '.env');
let envContent = '';
if (fs.existsSync(envPath)) {
envContent = fs.readFileSync(envPath, 'utf8');
}
if (envContent.includes(`${envVarName}=`)) {
envContent = envContent.replace(new RegExp(`${envVarName}=.*`, 'g'), `${envVarName}=${secret}`);
} else {
envContent += `\n${envVarName}=${secret}\n`;
}
fs.writeFileSync(envPath, envContent);
process.env[envVarName] = secret;
console.log(`🔐 ${envVarName} regenerated by admin`);
const jwtWarning = type === 'jwt' ? ' Все ранее выданные токены доступа стали недействительными. Пользователям потребуется войти заново.' : '';
res.json({
message: `${envVarName} перегенерирован. НЕОБХОДИМО перезапустить сервер для применения нового ключа.${jwtWarning}`,
key: secret,
warning: 'Сохраните этот ключ. Он будет показан только один раз. После перезапуска сервера старый ключ перестанет работать.'
});
});
app.get('/api/admin/settings/email', authenticateToken, requireAdmin, (req, res) => {
getAll((err, settings) => {
if (err) return res.status(500).json({ error: 'Database error' });
res.json({
smtp_host: settings.smtp_host || '',
smtp_port: settings.smtp_port || '587',
smtp_secure: settings.smtp_secure || 'false',
smtp_user: settings.smtp_user || '',
smtp_pass_masked: settings.smtp_pass ? maskValue(settings.smtp_pass) : '',
smtp_from: settings.smtp_from || '',
email_notifications_enabled: settings.email_notifications_enabled || 'false',
admin_email: settings.admin_email || ''
});
});
});
app.put('/api/admin/settings/email', authenticateToken, requireAdmin, (req, res) => {
const { smtp_host, smtp_port, smtp_secure, smtp_user, smtp_pass, smtp_from, email_notifications_enabled, admin_email } = req.body;
const updates = [];
if (smtp_host !== undefined) updates.push(['smtp_host', smtp_host]);
if (smtp_port !== undefined) updates.push(['smtp_port', smtp_port]);
if (smtp_secure !== undefined) updates.push(['smtp_secure', smtp_secure]);
if (smtp_user !== undefined) updates.push(['smtp_user', smtp_user]);
if (smtp_pass !== undefined && smtp_pass !== '' && !smtp_pass.startsWith('****')) updates.push(['smtp_pass', smtp_pass]);
if (smtp_from !== undefined) updates.push(['smtp_from', smtp_from]);
if (email_notifications_enabled !== undefined) updates.push(['email_notifications_enabled', email_notifications_enabled]);
if (admin_email !== undefined) updates.push(['admin_email', admin_email]);
let processed = 0;
if (updates.length === 0) return res.json({ message: 'No changes' });
updates.forEach(([key, value]) => {
set(key, value, (err) => {
if (err) console.error('Email settings update error:', err);
processed++;
if (processed === updates.length) {
res.json({ message: 'Email settings updated' });
}
});
});
});
app.post('/api/admin/settings/email/test', authenticateToken, requireAdmin, (req, res) => {
const emailModule = require('../email');
getAll((err, settings) => {
if (err) return res.status(500).json({ error: 'Database error' });
const to = settings.admin_email || settings.smtp_user || '';
if (!to) return res.status(400).json({ error: 'Email получателя не настроен. Укажите admin_email в настройках.' });
if (!settings.smtp_host) return res.status(400).json({ error: 'SMTP хост не настроен.' });
emailModule.sendTestEmail(settings, to, (sendErr, info) => {
if (sendErr) return res.status(500).json({ error: 'Ошибка отправки: ' + sendErr.message });
res.json({ message: 'Тестовое письмо отправлено на ' + to });
});
});
});
}
module.exports = { init, get, set, getAll, getReviewCode, setReviewCode, checkIpCooldown, setupRoutes };
module.exports = { init, get, set, getAll, getReviewCode, setReviewCode, checkIpCooldown, setupRoutes };

View File

@@ -6,6 +6,7 @@
"jsonwebtoken": "^9.0.3",
"multer": "^1.4.5-lts.1",
"node-cron": "^4.2.1",
"nodemailer": "^9.0.3",
"prom-client": "^15.1.3",
"sharp": "^0.34.5",
"sqlite3": "^6.0.1"

View File

@@ -200,10 +200,12 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
<div class="sidebar-brand">HOTEL <span>777</span></div>
<nav class="sidebar-nav">
<a href="#" class="active" data-tab="dashboard"><i class="fas fa-chart-pie"></i> Дашборд</a>
<a href="#" data-tab="calendar"><i class="fas fa-calendar-alt"></i> Календарь</a>
<a href="#" data-tab="users"><i class="fas fa-users"></i> Пользователи</a>
<a href="#" data-tab="bookings"><i class="fas fa-calendar-check"></i> Бронирования</a>
<a href="#" data-tab="rooms"><i class="fas fa-door-open"></i> Номера</a>
<a href="#" data-tab="promocodes"><i class="fas fa-ticket-alt"></i> Промокоды</a>
<a href="#" data-tab="seasonal"><i class="fas fa-tags"></i> Сезонные цены</a>
<a href="#" data-tab="reviews"><i class="fas fa-star"></i> Отзывы</a>
<a href="#" data-tab="settings"><i class="fas fa-cog"></i> Настройки</a>
<a href="#" data-tab="profile"><i class="fas fa-user-circle"></i> Профиль</a>
@@ -225,19 +227,19 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
</div>
<div class="stats-row">
<div class="stat-card">
<div class="stat-icon blue"><i class="fas fa-users"></i></div>
<div class="stat-value" id="statUsers"></div>
<div class="stat-label">Пользователей</div>
<div class="stat-icon gold"><i class="fas fa-ruble-sign"></i></div>
<div class="stat-value" id="statRevenueMonth"></div>
<div class="stat-label">Выручка за 30 дней</div>
</div>
<div class="stat-card">
<div class="stat-icon gold"><i class="fas fa-calendar-check"></i></div>
<div class="stat-value" id="statBookings"></div>
<div class="stat-label">Бронирований</div>
<div class="stat-icon green"><i class="fas fa-chart-line"></i></div>
<div class="stat-value" id="statRevenueWeek"></div>
<div class="stat-label">Выручка за 7 дней</div>
</div>
<div class="stat-card">
<div class="stat-icon green"><i class="fas fa-shield-alt"></i></div>
<div class="stat-value" id="statAdmins"></div>
<div class="stat-label">Администраторов</div>
<div class="stat-icon blue"><i class="fas fa-hotel"></i></div>
<div class="stat-value" id="statOccupancy"></div>
<div class="stat-label">Загрузка номеров</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: #fee2e2; color: #b91c1c;"><i class="fas fa-bell"></i></div>
@@ -245,9 +247,27 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
<div class="stat-label">Новых заявок</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: #fef3c7; color: #c9a84c;"><i class="fas fa-star"></i></div>
<div class="stat-value" id="statPendingReviews"></div>
<div class="stat-label">Отзывов на модерации</div>
<div class="stat-icon" style="background: #fef3c7; color: #c9a84c;"><i class="fas fa-calendar-day"></i></div>
<div class="stat-value" id="statCheckins"></div>
<div class="stat-label">Заездов сегодня</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: #f3e8ff; color: #7e22ce;"><i class="fas fa-star"></i></div>
<div class="stat-value" id="statAvgReview"></div>
<div class="stat-label">Средняя оценка</div>
</div>
</div>
<div class="card" style="margin-bottom: 24px;">
<div class="card-header-custom">
<h3>Выручка по месяцам</h3>
<div style="display: flex; gap: 8px;">
<button class="btn btn-sm" style="background:var(--primary);color:#fff;border:none;border-radius:8px;padding:4px 10px;" onclick="loadRevenueChart('monthly')">Месяц</button>
<button class="btn btn-sm" style="background:#e2e8f0;color:#64748b;border:none;border-radius:8px;padding:4px 10px;" onclick="loadRevenueChart('weekly')" id="chartWeekBtn">Неделя</button>
<button class="btn btn-sm" style="background:#e2e8f0;color:#64748b;border:none;border-radius:8px;padding:4px 10px;" onclick="loadRevenueChart('daily')" id="chartDayBtn">День</button>
</div>
</div>
<div class="card-body-custom">
<canvas id="revenueChart" style="width:100%;height:280px;"></canvas>
</div>
</div>
<div class="card">
@@ -277,7 +297,7 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
</div>
<div id="tab-bookings" class="tab-content">
<div class="top-bar"><h1>Бронирования</h1></div>
<div class="top-bar"><h1>Бронирования</h1><button class="btn-gold btn-sm" onclick="exportCSV()"><i class="fas fa-download me-1"></i> Экспорт CSV</button></div>
<div class="card">
<div class="card-header-custom">
<h3>Управление заявками</h3>
@@ -311,6 +331,23 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
</div>
</div>
<div id="tab-calendar" class="tab-content">
<div class="top-bar">
<h1>Календарь бронирований</h1>
<div style="display: flex; gap: 8px; align-items: center;">
<button class="btn btn-outline-secondary btn-sm" onclick="calendarPrevMonth()"><i class="fas fa-chevron-left"></i></button>
<select id="calendarMonthSelect" onchange="loadCalendar(this.value)" style="padding: 6px 10px; border-radius: 8px; border: 1px solid #e2e8f0; font-size: 0.9rem;">
</select>
<button class="btn btn-outline-secondary btn-sm" onclick="calendarNextMonth()"><i class="fas fa-chevron-right"></i></button>
</div>
</div>
<div class="card">
<div class="card-body-custom" style="overflow-x: auto;">
<div id="calendarGrid"></div>
</div>
</div>
</div>
<div id="tab-rooms" class="tab-content">
<div class="top-bar">
<h1>Номера</h1>
@@ -338,6 +375,21 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
</div>
</div>
<div id="tab-seasonal" class="tab-content">
<div class="top-bar">
<h1>Сезонные цены</h1>
<button class="btn-primary-custom admin-only" onclick="showSeasonalModal()"><i class="fas fa-plus"></i> Добавить</button>
</div>
<div class="card">
<div class="card-body-custom">
<table class="table">
<thead><tr><th>Тип номера</th><th>Дата с</th><th>Дата по</th><th>Цена/ночь (₽)</th><th>Метка</th><th>Активен</th><th class="admin-only">Действия</th></tr></thead>
<tbody id="seasonalTable"></tbody>
</table>
</div>
</div>
</div>
<div id="tab-reviews" class="tab-content">
<div class="top-bar">
<h1>Отзывы</h1>
@@ -448,6 +500,64 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
</div>
</div>
</div>
<div class="card" id="securityCard">
<div class="card-header-custom"><h3><i class="fas fa-shield-alt me-2"></i>Ключи безопасности</h3></div>
<div class="card-body-custom">
<p style="color: #64748b; margin-bottom: 20px;">
<i class="fas fa-info-circle me-2"></i>
Ключи автоматически генерируются при первом запуске. После регенерации требуется перезапуск сервера.
</p>
<div id="securityKeys"></div>
</div>
</div>
<div class="card" id="emailCard">
<div class="card-header-custom"><h3><i class="fas fa-envelope me-2"></i>Email-уведомления</h3></div>
<div class="card-body-custom">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">SMTP хост</label>
<input type="text" class="form-control" id="smtpHost" placeholder="smtp.gmail.com">
</div>
<div class="col-md-3">
<label class="form-label">Порт</label>
<input type="number" class="form-control" id="smtpPort" placeholder="587">
</div>
<div class="col-md-3">
<label class="form-label">Secure (TLS)</label>
<select class="form-control" id="smtpSecure">
<option value="false">Выключен</option>
<option value="true">Включен</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Логин</label>
<input type="text" class="form-control" id="smtpUser" placeholder="user@gmail.com">
</div>
<div class="col-md-6">
<label class="form-label">Пароль</label>
<input type="password" class="form-control" id="smtpPass" placeholder="Пароль приложения">
</div>
<div class="col-md-6">
<label class="form-label">Отправитель (From)</label>
<input type="text" class="form-control" id="smtpFrom" placeholder="Hotel 777 <noreply@hotel777.ru>">
</div>
<div class="col-md-6">
<label class="form-label">Email для уведомлений</label>
<input type="email" class="form-control" id="adminEmail" placeholder="admin@example.com">
</div>
</div>
<div class="mt-3 d-flex align-items-center gap-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="emailEnabled" style="width: 50px; height: 24px; cursor: pointer;">
<label class="form-check-label fw-500 ms-2" for="emailEnabled">Включить уведомления</label>
</div>
<button class="btn-primary-custom btn-sm" onclick="saveEmailSettings()"><i class="fas fa-save me-1"></i>Сохранить</button>
<button class="btn-gold btn-sm" onclick="sendTestEmail()"><i class="fas fa-paper-plane me-1"></i>Отправить тест</button>
</div>
</div>
</div>
</div>
</div>
<form id="profileForm">
@@ -650,10 +760,12 @@ function initTabs() {
document.querySelectorAll('.tab-content').forEach(x => x.classList.remove('active'));
document.getElementById('tab-' + tab).classList.add('active');
if (tab === 'dashboard') loadDashboard();
if (tab === 'calendar') { calendarYear = new Date().getFullYear(); calendarMonth = new Date().getMonth() + 1; loadCalendar(); }
if (tab === 'users') loadUsers();
if (tab === 'bookings') { bookingsLoaded = false; loadRoomsForBookingEdit(); loadBookings(); }
if (tab === 'rooms') loadRooms();
if (tab === 'promocodes') loadPromocodes();
if (tab === 'seasonal') loadSeasonalPrices();
if (tab === 'reviews') loadReviews();
if (tab === 'settings') loadSettings();
if (tab === 'profile') loadProfile();
@@ -731,19 +843,22 @@ document.getElementById('loginForm').addEventListener('submit', async e => {
async function loadDashboard() {
try {
const users = await api('/api/admin/users');
document.getElementById('statUsers').textContent = users.length;
document.getElementById('statAdmins').textContent = users.filter(u => u.role === 'admin').length;
} catch(e) {}
const summary = await api('/api/admin/reports/summary');
document.getElementById('statRevenueMonth').textContent = (summary.revenue.month || 0).toLocaleString() + ' ₽';
document.getElementById('statRevenueWeek').textContent = (summary.revenue.week || 0).toLocaleString() + ' ₽';
document.getElementById('statOccupancy').textContent = (summary.occupancy.current || 0) + '%';
document.getElementById('statNew').textContent = summary.bookings.new || 0;
document.getElementById('statCheckins').textContent = summary.bookings.checkin_today || 0;
document.getElementById('statAvgReview').textContent = (summary.avg_review_stars || 0).toFixed(1);
} catch(e) { console.error('Dashboard error:', e); }
try {
const rows = await api('/api/admin/bookings?limit=100');
document.getElementById('statBookings').textContent = rows.total || rows.data.length;
document.getElementById('statNew').textContent = rows.data.filter(r => r.status === 'новая').length;
document.getElementById('recentBookings').innerHTML = rows.data.filter(r => r.status !== 'отменена' && r.status !== 'выехала').slice(0, 5).map(r => '<tr>' +
const bookings = await api('/api/admin/bookings?limit=5');
const rows = bookings.data || [];
document.getElementById('recentBookings').innerHTML = rows.filter(r => r.status !== 'отменена' && r.status !== 'выехала').slice(0, 5).map(r => '<tr>' +
'<td>' + esc(r.name) + '</td><td>' + esc(r.room_name ? r.room_type + ' — ' + r.room_name : r.room_type || '—') + '</td>' +
'<td>' + esc(r.checkin_date) + '</td><td>' + esc(r.checkout_date) + '</td>' +
'<td><span class="badge badge-status badge-status-' + r.status + '">' + r.status + '</span></td></tr>').join('');
if (!rows.data.filter(r => r.status !== 'отменена' && r.status !== 'выехала').length) {
if (!rows.filter(r => r.status !== 'отменена' && r.status !== 'выехала').length) {
document.getElementById('recentBookings').innerHTML = '<tr><td colspan="5" class="text-center text-muted">Нет данных</td></tr>';
}
} catch(e) {
@@ -751,8 +866,95 @@ async function loadDashboard() {
}
try {
const reviewData = await api('/api/admin/reviews');
document.getElementById('statPendingReviews').textContent = reviewData.stats.pending;
document.getElementById('statPendingReviews')?.textContent != null && (document.getElementById('statPendingReviews').textContent = reviewData.stats.pending);
} catch(e) {}
loadRevenueChart('monthly');
}
let currentChartPeriod = 'monthly';
async function loadRevenueChart(period) {
currentChartPeriod = period;
document.querySelectorAll('#chartWeekBtn, #chartDayBtn, [onclick*="loadRevenueChart"]').forEach(b => {
b.style.background = '#e2e8f0'; b.style.color = '#64748b';
});
const activeBtn = period === 'monthly' ? document.querySelector('[onclick*="monthly"]') : period === 'weekly' ? document.getElementById('chartWeekBtn') : document.getElementById('chartDayBtn');
if (activeBtn) { activeBtn.style.background = 'var(--primary)'; activeBtn.style.color = '#fff'; }
try {
const data = await api('/api/admin/reports/revenue?period=' + period + '&months=6');
drawRevenueChart(data.data || [], period);
} catch(e) { console.error('Chart error:', e); }
}
function drawRevenueChart(data, period) {
const canvas = document.getElementById('revenueChart');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const rect = canvas.parentElement.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = 280 * dpr;
canvas.style.width = rect.width + 'px';
canvas.style.height = '280px';
ctx.scale(dpr, dpr);
const W = rect.width - 60;
const H = 230;
const padLeft = 55;
const padTop = 10;
ctx.clearRect(0, 0, rect.width, 280);
if (data.length === 0) {
ctx.fillStyle = '#94a3b8';
ctx.font = '14px Inter, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('Нет данных', rect.width / 2, 150);
return;
}
const maxRev = Math.max(...data.map(d => d.revenue), 1);
const barW = Math.min(60, (W / data.length) - 10);
const colors = ['#2563eb', '#3b82f6', '#60a5fa', '#93c5fd'];
ctx.strokeStyle = '#e2e8f0';
ctx.lineWidth = 1;
for (let i = 0; i <= 4; i++) {
const y = padTop + (H * i / 4);
ctx.beginPath();
ctx.moveTo(padLeft, y);
ctx.lineTo(padLeft + W, y);
ctx.stroke();
ctx.fillStyle = '#64748b';
ctx.font = '11px Inter, sans-serif';
ctx.textAlign = 'right';
ctx.fillText(Math.round(maxRev * (4 - i) / 4).toLocaleString() + ' ₽', padLeft - 8, y + 4);
}
data.forEach((d, i) => {
const x = padLeft + i * (W / data.length) + (W / data.length - barW) / 2;
const h = (d.revenue / maxRev) * H;
const y = padTop + H - h;
ctx.fillStyle = colors[i % colors.length];
ctx.beginPath();
const radius = 4;
const bx = x, by = y, bw = barW, bh = h;
ctx.moveTo(bx + radius, by);
ctx.lineTo(bx + bw - radius, by);
ctx.quadraticCurveTo(bx + bw, by, bx + bw, by + radius);
ctx.lineTo(bx + bw, by + bh);
ctx.lineTo(bx, by + bh);
ctx.lineTo(bx, by + radius);
ctx.quadraticCurveTo(bx, by, bx + radius, by);
ctx.closePath();
ctx.fill();
ctx.fillStyle = '#0f172a';
ctx.font = '11px Inter, sans-serif';
ctx.textAlign = 'center';
const label = d.label.length > 10 ? d.label.slice(-7) : d.label;
ctx.fillText(label, x + barW / 2, padTop + H + 18);
});
}
async function loadUsers() {
@@ -1568,16 +1770,25 @@ function renderRooms(rooms) {
const furniture = Array.isArray(r.furniture) ? r.furniture : [];
const amenities = Array.isArray(r.amenities) ? r.amenities : [];
const floors = Array.isArray(r.floors) ? r.floors : [];
const imageSrc = r.image_path ? (r.image_path.startsWith('uploads') ? '/' + r.image_path : r.image_path) : 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 250"%3E%3Crect fill="%23274151" width="400" height="250"/%3E%3Ctext fill="%2364748b" font-family="sans-serif" font-size="16" x="50%25" y="50%25" text-anchor="middle" dy=".3em"%3EБез фото%3C/text%3E%3C/svg%3E';
const images = Array.isArray(r.images) ? r.images : [];
const primaryImg = images.find(i => i.is_primary) || images[0];
let imageSrc = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 250"%3E%3Crect fill="%23274151" width="400" height="250"/%3E%3Ctext fill="%2364748b" font-family="sans-serif" font-size="16" x="50%25" y="50%25" text-anchor="middle" dy=".3em"%3EБез фото%3C/text%3E%3C/svg%3E';
if (primaryImg && primaryImg.image_path) {
imageSrc = primaryImg.image_path.startsWith('uploads') ? '/' + primaryImg.image_path : primaryImg.image_path;
} else if (r.image_path) {
imageSrc = r.image_path.startsWith('uploads') ? '/' + r.image_path : r.image_path;
}
const statusClass = r.is_active ? 'badge-status-оплачена' : 'badge-status-отменена';
const statusText = r.is_active ? 'Активен' : 'Скрыт';
const extraBedsText = r.extra_beds > 0 ? `+${r.extra_beds} доп. мест (${r.extra_bed_price}₽)` : '';
const imageBadge = images.length > 1 ? `<span style="position:absolute;bottom:8px;right:8px;background:rgba(0,0,0,0.7);color:#fff;font-size:0.65rem;padding:2px 6px;border-radius:10px;">${images.length} фото</span>` : '';
return '<div class="room-admin-card">' +
'<div class="room-admin-image">' +
'<img src="' + esc(imageSrc) + '" alt="' + esc(r.name) + '" onerror="this.src=\'data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 400 250%22%3E%3Crect fill=%22%23274151%22 width=%22400%22 height=%22250%22/%3E%3Ctext fill=%22%2364748b%22 font-family=%22sans-serif%22 font-size=%2216%22 x=%2250%25%22 y=%2250%25%22 text-anchor=%22middle%22 dy=%22.3em%22%3EБез фото%3C/text%3E%3C/svg%3E\'">' +
'<div class="room-admin-type">' + esc(r.type) + '</div>' +
'<span class="badge badge-status ' + statusClass + '" style="position:absolute;top:10px;right:10px;font-size:0.65rem;">' + statusText + '</span>' +
imageBadge +
'</div>' +
'<div class="room-admin-body">' +
'<h4 class="room-admin-name">' + esc(r.name) + '</h4>' +
@@ -1690,8 +1901,6 @@ async function saveRoom() {
return;
}
const image_path = document.getElementById('roomImagePreview').dataset.path || '';
try {
const data = {
type, name, description, price_per_night, area_sqm, max_guests, rooms_count,
@@ -1699,11 +1908,12 @@ async function saveRoom() {
};
if (editingRoomId) {
await api('/api/admin/rooms/' + editingRoomId, { method: 'PUT', body: JSON.stringify({ ...data, image_path }) });
await api('/api/admin/rooms/' + editingRoomId, { method: 'PUT', body: JSON.stringify(data) });
showToast('Номер обновлён');
} else {
await api('/api/admin/rooms', { method: 'POST', body: JSON.stringify(data) });
showToast('Номер создан');
const result = await api('/api/admin/rooms', { method: 'POST', body: JSON.stringify(data) });
editingRoomId = result.id;
showToast('Номер создан. Загрузите фото.');
}
closeRoomModal();
@@ -1723,15 +1933,23 @@ async function toggleRoomActive(id, isActive) {
}
async function uploadRoomImage() {
const fileInput = document.getElementById('roomImageInput');
const file = fileInput.files[0];
if (!file) { showToast('Выберите файл', 'error'); return; }
const fileInput = document.getElementById('roomImagesInput');
const files = fileInput.files;
if (!files || files.length === 0) { showToast('Выберите файлы', 'error'); return; }
const formData = new FormData();
formData.append('image', file);
for (let i = 0; i < Math.min(files.length, 5); i++) {
formData.append('images', files[i]);
}
try {
const res = await fetch(API + '/api/admin/rooms/upload', {
const roomId = editingRoomId;
const url = roomId ? '/api/admin/rooms/' + roomId + '/images' : '/api/admin/rooms/upload';
if (!roomId) {
formData.delete('images');
formData.append('image', files[0]);
}
const res = await fetch(API + url, {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token },
body: formData
@@ -1739,11 +1957,362 @@ async function uploadRoomImage() {
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Ошибка загрузки');
document.getElementById('roomImagePreview').src = '/' + data.path;
document.getElementById('roomImagePreview').dataset.path = data.path;
showToast('Фото загружено');
if (roomId) {
showToast('Изображения загружены');
loadRoomImages(roomId);
} else {
showToast('Фото загружено. Сохраните номер.');
if (data.path) { editingRoomId = null; roomImagesToUpload.push(data.path); }
}
} catch(err) { showToast(err.message, 'error'); }
}
let roomImagesToUpload = [];
async function loadRoomImages(roomId) {
try {
const rooms = await api('/api/admin/rooms');
const room = rooms.find(r => r.id === roomId);
if (!room || !room.images) return;
renderRoomImages(room.images);
} catch(e) {}
}
function renderRoomImages(images) {
const container = document.getElementById('roomImagesContainer');
if (!container) return;
container.innerHTML = images.map((img, idx) => `
<div style="position:relative; width:80px; height:60px; border-radius:6px; overflow:hidden; border:1px solid #e2e8f0;">
<img src="/${img.image_path}" style="width:100%;height:100%;object-fit:cover;">
<button type="button" onclick="deleteRoomImage(${img.id})" style="position:absolute;top:2px;right:2px;background:rgba(239,68,68,0.9);color:#fff;border:none;border-radius:50%;width:18px;height:18px;font-size:10px;cursor:pointer;line-height:1;">×</button>
${img.is_primary ? '<span style="position:absolute;bottom:2px;left:2px;background:rgba(37,99,235,0.9);color:#fff;font-size:8px;padding:1px 4px;border-radius:3px;">Главное</span>' : ''}
${!img.is_primary && img.id ? `<button type="button" onclick="setPrimaryImage(${img.id})" style="position:absolute;bottom:2px;right:2px;background:rgba(0,0,0,0.7);color:#fff;border:none;border-radius:3px;font-size:8px;padding:1px 3px;cursor:pointer;">★</button>` : ''}
</div>
`).join('');
}
async function deleteRoomImage(imageId) {
if (!confirm('Удалить изображение?')) return;
try {
await api('/api/admin/rooms/images/' + imageId, { method: 'DELETE' });
showToast('Изображение удалено');
if (editingRoomId) loadRoomImages(editingRoomId);
} catch(err) { showToast(err.message, 'error'); }
}
async function setPrimaryImage(imageId) {
try {
await api('/api/admin/rooms/images/' + imageId + '/primary', { method: 'PUT' });
showToast('Главное изображение обновлено');
if (editingRoomId) loadRoomImages(editingRoomId);
} catch(err) { showToast(err.message, 'error'); }
}
async function uploadRoomImages() {
await uploadRoomImage();
}
// Calendar
let calendarYear = new Date().getFullYear();
let calendarMonth = new Date().getMonth() + 1;
async function loadCalendar(monthStr) {
if (monthStr) {
const [y, m] = monthStr.split('-').map(Number);
calendarYear = y; calendarMonth = m;
}
const month = `${calendarYear}-${String(calendarMonth).padStart(2, '0')}`;
updateCalendarMonthSelect();
try {
const data = await api('/api/admin/calendar?month=' + month);
renderCalendar(data);
} catch(e) { showToast(e.message, 'error'); }
}
function calendarPrevMonth() {
calendarMonth--; if (calendarMonth < 1) { calendarMonth = 12; calendarYear--; }
loadCalendar();
}
function calendarNextMonth() {
calendarMonth++; if (calendarMonth > 12) { calendarMonth = 1; calendarYear++; }
loadCalendar();
}
function updateCalendarMonthSelect() {
const sel = document.getElementById('calendarMonthSelect');
if (!sel) return;
const now = new Date();
let options = '';
for (let i = -6; i <= 12; i++) {
const d = new Date(now.getFullYear(), now.getMonth() + i, 1);
const val = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}`;
const label = d.toLocaleString('ru', { month: 'long', year: 'numeric' });
const selected = (d.getFullYear() === calendarYear && d.getMonth()+1 === calendarMonth) ? ' selected' : '';
options += `<option value="${val}"${selected}>${label}</option>`;
}
sel.innerHTML = options;
}
function renderCalendar(data) {
const grid = document.getElementById('calendarGrid');
if (!grid) return;
const rooms = data.rooms || [];
const days = data.days || {};
const month = data.month;
const [y, m] = month.split('-').map(Number);
const firstDay = new Date(y, m-1, 1);
const lastDay = new Date(y, m, 0);
const startDayOfWeek = firstDay.getDay() || 7;
let html = '<table style="width:100%;min-width:700px;border-collapse:collapse;font-size:0.8rem;">';
html += '<thead><tr><th style="padding:8px;border:1px solid #e2e8f0;background:#f8fafc;">Месяц</th>';
const dayNames = ['Пн','Вт','Ср','Чт','Пт','Сб','Вс'];
for (let d = 1; d <= lastDay.getDate(); d++) {
html += `<th style="padding:6px 8px;border:1px solid #e2e8f0;background:#f8fafc;text-align:center;font-size:0.7rem;">${d}<br><small>${dayNames[(new Date(y,m-1,d).getDay()||7)-1]}</small></th>`;
}
html += '</tr></thead><tbody>';
rooms.forEach(room => {
html += '<tr><td style="padding:6px 8px;border:1px solid #e2e8f0;font-weight:600;white-space:nowrap;background:#f8fafc;">' + esc(room.type) + '</td>';
for (let d = 1; d <= lastDay.getDate(); d++) {
const dateStr = `${month}-${String(d).padStart(2,'0')}`;
const dayData = days[dateStr] ? days[dateStr][room.type] : null;
const booked = dayData ? dayData.booked : 0;
const total = dayData ? dayData.total : room.rooms_count;
const ratio = total > 0 ? booked / total : 0;
let bg = '#dcfce7';
if (ratio >= 1) bg = '#fee2e2';
else if (ratio >= 0.7) bg = '#fef3c7';
const title = room.type + ': ' + booked + '/' + total;
html += `<td style="padding:4px;border:1px solid #e2e8f0;background:${bg};text-align:center;cursor:pointer;" title="${title}" onclick="showCalendarDay('${dateStr}','${esc(room.type)}')">${booked > 0 ? '<span style="font-weight:600;font-size:0.75rem;">'+booked+'</span>' : ''}</td>`;
}
html += '</tr>';
});
html += '</tbody></table>';
grid.innerHTML = html;
}
function showCalendarDay(dateStr, roomType) {
showToast(dateStr + ' — ' + roomType + ': загрузка данных...', 'info');
}
// Seasonal Prices
async function loadSeasonalPrices() {
try {
const data = await api('/api/admin/seasonal-prices');
document.getElementById('seasonalTable').innerHTML = data.map(s => '<tr>' +
'<td>' + esc(s.room_type) + '</td>' +
'<td>' + esc(s.date_from) + '</td>' +
'<td>' + esc(s.date_to) + '</td>' +
'<td><strong>' + s.price_per_night + ' ₽</strong></td>' +
'<td>' + esc(s.label || '—') + '</td>' +
'<td>' + (s.is_active ? '<span class="badge badge-status badge-status-оплачена">Да</span>' : '<span class="badge badge-status badge-status-отменена">Нет</span>') + '</td>' +
'<td class="admin-only">' +
'<button class="btn-primary-custom btn-sm me-1" onclick="editSeasonal(' + s.id + ')"><i class="fas fa-edit"></i></button>' +
'<button class="btn-danger-custom btn-sm" onclick="deleteSeasonal(' + s.id + ')"><i class="fas fa-trash"></i></button>' +
'</td></tr>').join('');
if (data.length === 0) document.getElementById('seasonalTable').innerHTML = '<tr><td colspan="7" class="text-center text-muted">Нет данных</td></tr>';
} catch(e) { showToast(e.message, 'error'); }
}
function showSeasonalModal(id) {
document.getElementById('editSeasonalId').value = '';
document.getElementById('seasonalModalTitle').textContent = 'Добавить сезонную цену';
document.getElementById('seasonalForm').reset();
if (id) {
document.getElementById('editSeasonalId').value = id;
document.getElementById('seasonalModalTitle').textContent = 'Редактировать сезонную цену';
api('/api/admin/seasonal-prices').then(data => {
const s = data.find(x => x.id === id);
if (s) {
document.getElementById('seasonalRoomType').value = s.room_type;
document.getElementById('seasonalDateFrom').value = s.date_from;
document.getElementById('seasonalDateTo').value = s.date_to;
document.getElementById('seasonalPrice').value = s.price_per_night;
document.getElementById('seasonalLabel').value = s.label || '';
document.getElementById('seasonalIsActive').checked = s.is_active === 1;
}
});
}
document.getElementById('seasonalModal').classList.add('show');
}
function hideSeasonalModal() {
document.getElementById('seasonalModal').classList.remove('show');
}
document.getElementById('seasonalForm').addEventListener('submit', async function(e) {
e.preventDefault();
const id = document.getElementById('editSeasonalId').value;
const data = {
room_type: document.getElementById('seasonalRoomType').value,
date_from: document.getElementById('seasonalDateFrom').value,
date_to: document.getElementById('seasonalDateTo').value,
price_per_night: parseInt(document.getElementById('seasonalPrice').value),
label: document.getElementById('seasonalLabel').value,
is_active: document.getElementById('seasonalIsActive').checked
};
try {
if (id) {
await api('/api/admin/seasonal-prices/' + id, { method: 'PUT', body: JSON.stringify(data) });
} else {
await api('/api/admin/seasonal-prices', { method: 'POST', body: JSON.stringify(data) });
}
hideSeasonalModal();
loadSeasonalPrices();
} catch(err) { showToast(err.message, 'error'); }
});
function editSeasonal(id) { showSeasonalModal(id); }
async function deleteSeasonal(id) {
if (!confirm('Удалить сезонную цену?')) return;
try {
await api('/api/admin/seasonal-prices/' + id, { method: 'DELETE' });
loadSeasonalPrices();
} catch(err) { showToast(err.message, 'error'); }
}
// Security keys
async function loadSecurityKeys() {
try {
const data = await api('/api/admin/security');
document.getElementById('securityKeys').innerHTML = `
<div style="margin-bottom:16px;padding:12px;background:#f8fafc;border-radius:8px;border:1px solid #e2e8f0;">
<strong>JWT_SECRET:</strong> <code>${esc(data.jwt_secret_masked)}</code>
<button class="btn btn-sm" style="margin-left:8px;background:#f59e0b;color:#fff;border:none;border-radius:6px;padding:2px 8px;" onclick="regenerateKey('jwt')">Перегенерировать</button>
</div>
<div style="padding:12px;background:#f8fafc;border-radius:8px;border:1px solid #e2e8f0;">
<strong>HOTEL777KEY:</strong> <code>${esc(data.hotel777key_masked)}</code>
<button class="btn btn-sm" style="margin-left:8px;background:#f59e0b;color:#fff;border:none;border-radius:6px;padding:2px 8px;" onclick="regenerateKey('hotel777key')">Перегенерировать</button>
</div>
`;
} catch(e) {}
}
async function regenerateKey(type) {
const label = type === 'jwt' ? 'JWT_SECRET' : 'HOTEL777KEY';
if (!confirm(`Перегенерировать ${label}? Это потребует перезапуска сервера.${type === 'jwt' ? ' Все пользователи будут вынуждены войти заново.' : ''}`)) return;
try {
const data = await api('/api/admin/security/regenerate', { method: 'POST', body: JSON.stringify({ type }) });
showToast(data.message, 'success');
if (data.key) {
const div = document.createElement('div');
div.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:#fff;padding:24px;border-radius:12px;box-shadow:0 20px 60px rgba(0,0,0,0.3);z-index:9999;max-width:500px;';
div.innerHTML = `<h3 style="color:#f59e0b;margin-bottom:12px;">Новый ключ (показан только раз!)</h3><pre style="background:#1e293b;color:#4ade80;padding:12px;border-radius:8px;overflow-x:auto;">${esc(data.key)}</pre><p style="color:#ef4444;font-size:0.85rem;">${esc(data.warning)}</p><button class="btn-gold btn-sm" onclick="this.parentElement.remove()">Я сохранил ключ</button>`;
document.body.appendChild(div);
}
} catch(err) { showToast(err.message, 'error'); }
}
// Email settings
async function loadEmailSettings() {
try {
const data = await api('/api/admin/settings/email');
document.getElementById('smtpHost').value = data.smtp_host || '';
document.getElementById('smtpPort').value = data.smtp_port || '587';
document.getElementById('smtpSecure').value = data.smtp_secure || 'false';
document.getElementById('smtpUser').value = data.smtp_user || '';
document.getElementById('smtpFrom').value = data.smtp_from || '';
document.getElementById('adminEmail').value = data.admin_email || '';
document.getElementById('emailEnabled').checked = data.email_notifications_enabled === 'true';
} catch(e) {}
}
async function saveEmailSettings() {
const data = {
smtp_host: document.getElementById('smtpHost').value,
smtp_port: document.getElementById('smtpPort').value,
smtp_secure: document.getElementById('smtpSecure').value,
smtp_user: document.getElementById('smtpUser').value,
smtp_pass: document.getElementById('smtpPass').value,
smtp_from: document.getElementById('smtpFrom').value,
admin_email: document.getElementById('adminEmail').value,
email_notifications_enabled: document.getElementById('emailEnabled').checked ? 'true' : 'false'
};
try {
await api('/api/admin/settings/email', { method: 'PUT', body: JSON.stringify(data) });
showToast('Настройки email сохранены');
} catch(err) { showToast(err.message, 'error'); }
}
async function sendTestEmail() {
try {
const result = await api('/api/admin/settings/email/test', { method: 'POST' });
showToast(result.message);
} catch(err) { showToast(err.message, 'error'); }
}
// Export
async function exportCSV() {
const status = document.getElementById('filterStatus').value;
const params = new URLSearchParams();
if (status && status !== 'all') params.set('status', status);
window.location.href = API + '/api/admin/export/bookings?' + params.toString();
}
// Update loadSettings
async function loadSettings() {
try {
const data = await api('/api/admin/settings');
document.getElementById('currentCodeDisplay').value = data.review_code || '';
} catch(e) {}
try {
const backupSettings = await api('/api/admin/backup/settings');
if (backupSettings.backup_auto_enabled !== undefined) {
document.getElementById('backupAutoEnabled').checked = backupSettings.backup_auto_enabled === 'true';
}
if (backupSettings.backup_auto_time) {
document.getElementById('backupAutoTime').value = backupSettings.backup_auto_time;
}
if (backupSettings.backup_retention_days) {
document.getElementById('backupRetentionDays').value = backupSettings.backup_retention_days;
}
loadBackups();
} catch(e) {}
loadSecurityKeys();
loadEmailSettings();
}
// Show room modal with images
async function showRoomModal(id) {
editingRoomId = id || null;
roomImagesToUpload = [];
document.getElementById('roomModalTitle').textContent = id ? 'Редактировать номер' : 'Добавить номер';
document.getElementById('roomForm').reset();
document.getElementById('roomImagesContainer').innerHTML = '';
document.getElementById('roomIsActive').checked = true;
if (id) {
try {
const rooms = await api('/api/admin/rooms');
const room = rooms.find(r => r.id === id);
if (room) {
document.getElementById('roomName').value = room.name;
document.getElementById('roomType').value = room.type;
document.getElementById('roomDescription').value = room.description || '';
document.getElementById('roomPrice').value = room.price_per_night;
document.getElementById('roomArea').value = room.area_sqm || 20;
document.getElementById('roomMaxGuests').value = room.max_guests || 2;
document.getElementById('roomCount').value = room.rooms_count || 1;
document.getElementById('roomFloors').value = (room.floors || []).join(', ');
document.getElementById('roomExtraBeds').value = room.extra_beds || 0;
document.getElementById('roomExtraBedPrice').value = room.extra_bed_price || 0;
document.getElementById('roomIsActive').checked = room.is_active === 1;
document.querySelectorAll('[name="furniture"]').forEach(cb => { cb.checked = (room.furniture || []).includes(cb.value); });
document.querySelectorAll('[name="amenities"]').forEach(cb => { cb.checked = (room.amenities || []).includes(cb.value); });
if (room.images && room.images.length > 0) {
renderRoomImages(room.images);
}
}
} catch(e) { showToast(e.message, 'error'); }
}
document.getElementById('roomModal').classList.add('show');
}
</script>
<!-- Room Edit Modal -->
@@ -1807,15 +2376,13 @@ async function uploadRoomImage() {
</div>
</div>
<div class="mb-3">
<label class="form-label">Изображение</label>
<div style="display: flex; gap: 12px; align-items: flex-start;">
<img id="roomImagePreview" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 120'%3E%3Crect fill='%23274151' width='200' height='120'/%3E%3Ctext fill='%2364748b' font-family='sans-serif' font-size='12' x='50%25' y='50%25' text-anchor='middle' dy='.3em'%3EВыберите фото%3C/text%3E%3C/svg%3E" style="width: min(160px, 40vw); height: auto; aspect-ratio: 5/3; object-fit: cover; border-radius: 8px; border: 1px solid #e2e8f0;">
<div style="flex: 1;">
<input type="file" class="form-control" id="roomImageInput" accept="image/*" style="font-size: 0.85rem;">
<button type="button" class="btn btn-primary btn-sm mt-2" onclick="uploadRoomImage()" style="width: 100%;"><i class="fas fa-upload"></i> Загрузить</button>
<small class="text-muted" style="font-size: 0.75rem; display: block; margin-top: 4px;">JPG, PNG, WebP до 5 МБ. Автоконвертация в WebP.</small>
</div>
<label class="form-label">Изображения</label>
<div id="roomImagesContainer" style="display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 8px;"></div>
<div style="display: flex; gap: 8px; align-items: center;">
<input type="file" class="form-control" id="roomImagesInput" accept="image/*" multiple style="font-size: 0.85rem; max-width: 300px;">
<button type="button" class="btn btn-primary btn-sm" onclick="uploadRoomImages()"><i class="fas fa-upload"></i> Загрузить</button>
</div>
<small class="text-muted" style="font-size: 0.75rem; display: block; margin-top: 4px;">До 5 файлов, JPG/PNG/WebP. Автоконвертация в WebP.</small>
</div>
<div class="mb-3">
<label class="form-label">Мебель</label>
@@ -1880,5 +2447,54 @@ async function uploadRoomImage() {
.room-admin-actions .btn-danger-custom { padding: 8px 12px; }
</style>
<div class="modal-backdrop-custom" id="seasonalModal">
<div class="modal-custom">
<div class="modal-header-custom">
<h3 id="seasonalModalTitle">Добавить сезонную цену</h3>
<button class="modal-close" onclick="hideSeasonalModal()">&times;</button>
</div>
<form id="seasonalForm">
<input type="hidden" id="editSeasonalId">
<div class="modal-body-custom">
<div class="mb-3">
<label class="form-label">Тип номера *</label>
<select class="form-control" id="seasonalRoomType">
<option value="2x-местный">2x-местный</option>
<option value="3х-местный">3х-местный</option>
<option value="Семейный">Семейный</option>
<option value="Люкс">Люкс</option>
</select>
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label">Дата с *</label>
<input type="date" class="form-control" id="seasonalDateFrom" required>
</div>
<div class="col-md-6">
<label class="form-label">Дата по *</label>
<input type="date" class="form-control" id="seasonalDateTo" required>
</div>
</div>
<div class="mb-3">
<label class="form-label">Цена за ночь (₽) *</label>
<input type="number" class="form-control" id="seasonalPrice" required min="1" placeholder="2000">
</div>
<div class="mb-3">
<label class="form-label">Метка (например: Лето 2026)</label>
<input type="text" class="form-control" id="seasonalLabel" placeholder="Лето 2026">
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="seasonalIsActive" checked>
<label class="form-check-label" for="seasonalIsActive">Активна</label>
</div>
</div>
<div class="modal-footer-custom">
<button type="button" class="btn btn-secondary btn-sm" onclick="hideSeasonalModal()">Отмена</button>
<button type="submit" class="btn-gold btn-sm"><i class="fas fa-save"></i> Сохранить</button>
</div>
</form>
</div>
</div>
</body>
</html>

View File

@@ -1775,3 +1775,59 @@ h1, h2, h3, h4 {
0%, 100% { box-shadow: 0 0 0 0 rgba(212,168,67,0.3); }
50% { box-shadow: 0 0 0 10px rgba(212,168,67,0); }
}
/* Room image carousel */
.room-img-carousel {
position: relative;
width: 100%;
height: 100%;
}
.room-img-carousel .room-carousel-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.room-carousel-prev, .room-carousel-next {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(0,0,0,0.5);
color: #fff;
border: none;
width: 30px;
height: 30px;
border-radius: 50%;
font-size: 18px;
cursor: pointer;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s;
}
.room-image:hover .room-carousel-prev, .room-image:hover .room-carousel-next {
opacity: 1;
}
.room-carousel-prev { left: 8px; }
.room-carousel-next { right: 8px; }
.room-img-dots {
position: absolute;
bottom: 8px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 6px;
z-index: 2;
}
.room-img-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(255,255,255,0.5);
cursor: pointer;
transition: background 0.3s;
}
.room-img-dot.active {
background: #fff;
}

View File

@@ -180,8 +180,8 @@ function validateBookingDates() {
return checkinInput.checkValidity() && checkoutInput.checkValidity();
}
document.querySelector('[name="checkin"]').addEventListener('change', validateBookingDates);
document.querySelector('[name="checkout"]').addEventListener('change', validateBookingDates);
document.querySelector('[name="checkin"]').addEventListener('change', function() { validateBookingDates(); if (typeof checkRoomAvailability === 'function') checkRoomAvailability(); });
document.querySelector('[name="checkout"]').addEventListener('change', function() { validateBookingDates(); if (typeof checkRoomAvailability === 'function') checkRoomAvailability(); });
function updatePriceDisplay(basePrice, discountPercent, discountAmount, totalPrice) {
document.getElementById('basePriceDisplay').textContent = basePrice + ' ₽';

View File

@@ -39,9 +39,34 @@ function renderRoomsPublic(rooms) {
grid.innerHTML = rooms.map((room, index) => {
const amenities = Array.isArray(room.amenities) ? room.amenities : [];
const floors = Array.isArray(room.floors) ? room.floors : [];
const images = Array.isArray(room.images) ? room.images : [];
const imageSrc = room.image_path || 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 250"%3E%3Crect fill="%23274151" width="400" height="250"/%3E%3Ctext fill="%2364748b" font-family="sans-serif" font-size="16" x="50%25" y="50%25" text-anchor="middle" dy=".3em"%3EФото%3C/text%3E%3C/svg%3E';
const primaryImg = images.find(i => i.is_primary) || images[0];
let imageSrc = room.image_path || 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 250"%3E%3Crect fill="%23274151" width="400" height="250"/%3E%3Ctext fill="%2364748b" font-family="sans-serif" font-size="16" x="50%25" y="50%25" text-anchor="middle" dy=".3em"%3EФото%3C/text%3E%3C/svg%3E';
if (primaryImg && primaryImg.image_path) {
imageSrc = primaryImg.image_path;
}
const fullImageSrc = imageSrc.startsWith('uploads') ? '/' + imageSrc : imageSrc;
const allImages = images.length > 0 ? images : (room.image_path ? [{ image_path: room.image_path, is_primary: 1 }] : []);
let imagesHtml = '';
if (allImages.length > 1) {
const dotsHtml = allImages.map((img, i) => {
const src = img.image_path.startsWith('uploads') ? '/' + img.image_path : img.image_path;
return `<span class="room-img-dot ${i === 0 ? 'active' : ''}" data-index="${i}" data-src="${escapeHtml(src)}"></span>`;
}).join('');
imagesHtml = `<div class="room-img-carousel">
<img src="${fullImageSrc}" alt="${room.name}" class="room-carousel-img" id="roomCarouselImg${index}"
onerror="this.src='data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 400 250%22%3E%3Crect fill=%22%23274151%22 width=%22400%22 height=%22250%22/%3E%3Ctext fill=%22%2364748b%22 font-family=%22sans-serif%22 font-size=%2216%22 x=%2250%25%22 y=%2250%25%22 text-anchor=%22middle%22 dy=%22.3em%22%3EФото%3C/text%3E%3C/svg%3E'">
<button class="room-carousel-prev" onclick="roomCarouselPrev(${index})"></button>
<button class="room-carousel-next" onclick="roomCarouselNext(${index})"></button>
<div class="room-img-dots">${dotsHtml}</div>
</div>`;
} else {
imagesHtml = `<img src="${fullImageSrc}" alt="${room.name}" class="room-carousel-img"
onerror="this.src='data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 400 250%22%3E%3Crect fill=%22%23274151%22 width=%22400%22 height=%22250%22/%3E%3Ctext fill=%22%2364748b%22 font-family=%22sans-serif%22 font-size=%2216%22 x=%2250%25%22 y=%2250%25%22 text-anchor=%22middle%22 dy=%22.3em%22%3EФото%3C/text%3E%3C/svg%3E'">`;
}
const amenitiesHtml = amenities.map(a => {
const info = AMENITY_ICONS[a];
@@ -61,9 +86,9 @@ function renderRoomsPublic(rooms) {
<div class="col-lg-4">
<div class="room-card${featuredClass}">
<div class="room-image">
<img src="${fullImageSrc}" alt="${room.name}"
onerror="this.src='data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 400 250%22%3E%3Crect fill=%22%23274151%22 width=%22400%22 height=%22250%22/%3E%3Ctext fill=%22%2364748b%22 font-family=%22sans-serif%22 font-size=%2216%22 x=%2250%25%22 y=%2250%25%22 text-anchor=%22middle%22 dy=%22.3em%22%3EФото%3C/text%3E%3C/svg%3E'">
${imagesHtml}
<div class="room-category">${room.type}</div>
<div class="room-availability" id="roomAvail${index}" style="display:none;position:absolute;bottom:8px;left:8px;background:rgba(0,0,0,0.75);color:#fff;font-size:0.75rem;padding:3px 8px;border-radius:10px;"></div>
</div>
<div class="room-body">
<h3 class="room-name">${escapeHtml(room.name)}</h3>
@@ -93,6 +118,42 @@ function renderRoomsPublic(rooms) {
initRoomBookingHandlers();
}
let roomCarouselState = {};
let roomCarouselTimers = {};
function roomCarouselPrev(index) {
if (!roomCarouselState[index]) roomCarouselState[index] = 0;
const imgs = getRoomImagesForIndex(index);
if (roomCarouselState[index] > 0) roomCarouselState[index]--;
else roomCarouselState[index] = imgs.length - 1;
applyCarouselFrame(index, imgs);
}
function roomCarouselNext(index) {
if (!roomCarouselState[index]) roomCarouselState[index] = 0;
const imgs = getRoomImagesForIndex(index);
if (roomCarouselState[index] < imgs.length - 1) roomCarouselState[index]++;
else roomCarouselState[index] = 0;
applyCarouselFrame(index, imgs);
}
function getRoomImagesForIndex(index) {
const room = cachedRooms[index];
if (!room) return [];
const images = Array.isArray(room.images) && room.images.length > 0 ? room.images : (room.image_path ? [{ image_path: room.image_path, is_primary: 1 }] : []);
return images;
}
function applyCarouselFrame(index, images) {
const img = document.getElementById('roomCarouselImg' + index);
const dots = document.querySelectorAll(`#roomCarouselImg${index} ~ .room-img-dots .room-img-dot`);
if (!img || images.length === 0) return;
const state = roomCarouselState[index];
const src = images[state].image_path.startsWith('uploads') ? '/' + images[state].image_path : images[state].image_path;
img.src = src;
dots.forEach((d, i) => d.classList.toggle('active', i === state));
}
function updateRoomPrices(rooms) {
const prices = {};
const maxGuests = {};
@@ -133,6 +194,35 @@ function updateGuestOptionsDynamic(roomType, maxGuests) {
guestsSelect.innerHTML = options.join('');
}
async function checkRoomAvailability() {
const checkin = document.querySelector('[name="checkin"]').value;
const checkout = document.querySelector('[name="checkout"]').value;
if (!checkin || !checkout) return;
try {
const res = await fetch(`/api/rooms/availability?checkin=${checkin}&checkout=${checkout}`);
if (!res.ok) return;
const data = await res.json();
cachedRooms.forEach((room, i) => {
const avail = data.find(a => a.id === room.id);
const el = document.getElementById('roomAvail' + i);
if (el && avail) {
el.style.display = 'block';
if (avail.available === 0) {
el.textContent = 'Нет мест';
el.style.background = 'rgba(239,68,68,0.85)';
} else if (avail.available <= 2) {
el.textContent = 'Мест: ' + avail.available;
el.style.background = 'rgba(245,158,11,0.85)';
} else {
el.textContent = 'Свободно: ' + avail.available;
el.style.background = 'rgba(22,163,74,0.85)';
}
}
});
} catch (e) {}
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
@@ -142,4 +232,4 @@ function escapeHtml(text) {
document.addEventListener('DOMContentLoaded', () => {
loadRoomsPublic();
});
});

271
server.js
View File

@@ -10,6 +10,10 @@ const multer = require('multer');
require('dotenv').config();
const config = require('./config');
const { ensureEnvSecret, initDatabase } = require('./modules/database');
ensureEnvSecret('HOTEL777KEY', 'HOTEL777KEY');
ensureEnvSecret('JWT_SECRET', 'JWT_SECRET');
const app = express();
const PORT = process.env.PORT || 3000;
@@ -178,258 +182,7 @@ if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir);
const dbPath = path.join(dataDir, 'bookings.db');
const db = new sqlite3.Database(dbPath);
db.serialize(() => {
db.run(`CREATE TABLE IF NOT EXISTS bookings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
phone TEXT NOT NULL,
adults INTEGER NOT NULL,
children INTEGER NOT NULL,
checkin_date TEXT NOT NULL,
checkout_date TEXT NOT NULL,
wishes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
const columnsToAdd = [
'wishes TEXT',
'status TEXT DEFAULT "новая"',
'room_type TEXT',
'room_id INTEGER',
'comment TEXT',
'base_price REAL',
'discount_percent INTEGER DEFAULT 0',
'discount_amount REAL DEFAULT 0',
'total_price REAL',
'promocode_id INTEGER'
];
function addColumnSafely(columns, index) {
if (index >= columns.length) {
setupPromocodesTable();
return;
}
db.all("PRAGMA table_info(bookings)", [], (err, cols) => {
if (err) {
setupPromocodesTable();
return;
}
const colNames = cols.map(c => c.name);
const columnDef = columns[index];
const colName = columnDef.split(' ')[0];
if (!colNames.includes(colName)) {
db.run(`ALTER TABLE bookings ADD COLUMN ${columnDef}`, (err) => {
if (err && !err.message.includes('duplicate')) {
console.log('Migration note (ignore if exists):', err.message);
}
});
}
addColumnSafely(columns, index + 1);
});
}
function setupPromocodesTable() {
db.run(`CREATE TABLE IF NOT EXISTS promocodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
discount_percent INTEGER NOT NULL CHECK(discount_percent BETWEEN 1 AND 99),
valid_from DATETIME,
valid_to DATETIME,
valid_days INTEGER,
is_active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
setupRoomsTable();
}
function setupRoomsTable() {
db.run(`CREATE TABLE IF NOT EXISTS rooms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
rooms_count INTEGER DEFAULT 1,
area_sqm INTEGER DEFAULT 20,
max_guests INTEGER DEFAULT 2,
furniture TEXT DEFAULT '[]',
amenities TEXT DEFAULT '[]',
floors TEXT DEFAULT '[]',
price_per_night INTEGER NOT NULL,
image_path TEXT,
extra_beds INTEGER DEFAULT 0,
extra_bed_price INTEGER DEFAULT 0,
is_active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
db.all("PRAGMA table_info(rooms)", [], (err, cols) => {
if (err) { setupBookingHistoryTable(); return; }
const colNames = cols.map(c => c.name);
const migrations = [
['area_sqm', 'INTEGER DEFAULT 20'],
['furniture', "TEXT DEFAULT '[]'"],
['amenities', "TEXT DEFAULT '[]'"],
['floors', "TEXT DEFAULT '[]'"],
['extra_beds', 'INTEGER DEFAULT 0'],
['extra_bed_price', 'INTEGER DEFAULT 0']
];
let pending = migrations.length;
if (pending === 0) {
migratePricePerNight();
return;
}
migrations.forEach(([colName, colDef], i) => {
if (!colNames.includes(colName)) {
db.run(`ALTER TABLE rooms ADD COLUMN ${colName} ${colDef}`, (err) => {
if (err && !err.message.includes('duplicate') && !err.message.includes('NOT NULL')) {
console.log('Room migration note:', err.message);
}
});
}
if (--pending === 0) migratePricePerNight();
});
});
}
function migratePricePerNight() {
db.all("PRAGMA table_info(rooms)", [], (err, cols) => {
if (err) { setupBookingHistoryTable(); return; }
const colNames = cols.map(c => c.name);
if (!colNames.includes('price_per_night')) {
db.run(`ALTER TABLE rooms ADD COLUMN price_per_night INTEGER DEFAULT 0`, (err) => {
if (err && !err.message.includes('duplicate') && !err.message.includes('NOT NULL')) {
console.log('Room migration note (add price_per_night):', err.message);
}
});
}
db.run(`CREATE TABLE rooms_backup AS SELECT id, type, name, description, rooms_count, area_sqm, max_guests, furniture, amenities, floors, price_per_night, image_path, extra_beds, extra_bed_price, is_active, created_at FROM rooms`, (err) => {
if (err) {
console.log('Room backup failed:', err.message);
setupBookingHistoryTable();
return;
}
db.run(`DROP TABLE rooms`, (err) => {
if (err) {
console.log('Room drop failed:', err.message);
setupBookingHistoryTable();
return;
}
db.run(`CREATE TABLE rooms (id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT NOT NULL, name TEXT NOT NULL, description TEXT, rooms_count INTEGER DEFAULT 1, area_sqm INTEGER DEFAULT 20, max_guests INTEGER DEFAULT 2, furniture TEXT DEFAULT '[]', amenities TEXT DEFAULT '[]', floors TEXT DEFAULT '[]', price_per_night INTEGER NOT NULL, image_path TEXT, extra_beds INTEGER DEFAULT 0, extra_bed_price INTEGER DEFAULT 0, is_active INTEGER DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP)`, (err) => {
if (err) {
console.log('Room recreate failed:', err.message);
setupBookingHistoryTable();
return;
}
db.run(`INSERT INTO rooms (id, type, name, description, rooms_count, area_sqm, max_guests, furniture, amenities, floors, price_per_night, image_path, extra_beds, extra_bed_price, is_active, created_at) SELECT id, type, name, description, rooms_count, area_sqm, max_guests, COALESCE(furniture, '[]'), COALESCE(amenities, '[]'), COALESCE(floors, '[]'), COALESCE(price_per_night, 0) as price_per_night, image_path, COALESCE(extra_beds, 0), COALESCE(extra_bed_price, 0), is_active, created_at FROM rooms_backup`, (err) => {
if (err) console.log('Room data restore failed:', err.message);
db.run(`DROP TABLE rooms_backup`, (err) => {});
setupBookingHistoryTable();
});
});
});
});
});
}
function setupBookingHistoryTable() {
db.run(`CREATE TABLE IF NOT EXISTS booking_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
booking_id INTEGER NOT NULL,
user_id INTEGER,
user_login TEXT,
field TEXT NOT NULL,
old_value TEXT,
new_value TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (booking_id) REFERENCES bookings(id)
)`);
setupUsersTable();
}
function setupUsersTable() {
db.run(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
login TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
full_name TEXT,
email TEXT,
role TEXT NOT NULL DEFAULT 'user',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
setupSettingsTable();
}
function setupSettingsTable() {
db.run(`CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`, (err) => {
if (err) {
console.error('CREATE TABLE settings FAILED:', err.message);
} else {
console.log('CREATE TABLE settings SUCCESS');
}
});
setupReviewsTable();
}
function setupReviewsTable() {
db.run(`CREATE TABLE IF NOT EXISTS reviews (
id INTEGER PRIMARY KEY AUTOINCREMENT,
author_name TEXT NOT NULL,
country TEXT NOT NULL,
country_code TEXT,
city TEXT NOT NULL,
stars REAL NOT NULL CHECK(stars >= 0 AND stars <= 5),
text TEXT NOT NULL,
review_code TEXT NOT NULL,
ip_address TEXT,
is_approved INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`, (err) => {
if (err && !err.message.includes('already exists')) {
console.error('Reviews table error:', err.message);
}
db.run(`PRAGMA foreign_keys = ON`, (err) => {
if (err) console.error('Foreign keys error:', err.message);
});
db.all("PRAGMA table_info(reviews)", [], (err, cols) => {
if (err || !cols) return;
const colNames = cols.map(c => c.name);
if (!colNames.includes('country_code')) {
db.run("ALTER TABLE reviews ADD COLUMN country_code TEXT", (err) => {
if (err) console.log('Migration: country_code column (ignore if exists):', err.message);
else console.log('Migration: country_code column added');
});
}
});
});
}
addColumnSafely(columnsToAdd, 0);
db.run(`CREATE INDEX IF NOT EXISTS idx_bookings_checkin ON bookings(checkin_date)`, (err) => {
if (err) console.log('Index idx_bookings_checkin:', err.message);
});
db.run(`CREATE INDEX IF NOT EXISTS idx_bookings_status ON bookings(status)`, (err) => {
if (err) console.log('Index idx_bookings_status:', err.message);
});
});
initDatabase(db);
const modules = {};
@@ -443,6 +196,9 @@ const settingsModule = require('./modules/settings');
const reviewsModule = require('./modules/reviews');
const translationsModule = require('./modules/translations');
const backupModule = require('./modules/backup');
const seasonalPricesModule = require('./modules/seasonalPrices');
const emailModule = require('./modules/email');
const reportsModule = require('./modules/reports');
const { runStartupTests } = require('./tests/runStartupTests');
modules.auth = authModule;
@@ -455,6 +211,9 @@ modules.settings = settingsModule;
modules.reviews = reviewsModule;
modules.translations = translationsModule;
modules.backup = backupModule;
modules.seasonalPrices = seasonalPricesModule;
modules.email = emailModule;
modules.reports = reportsModule;
authModule.init(db, JWT_SECRET);
bookingsModule.init(db);
@@ -465,6 +224,9 @@ usersModule.init(db, bcrypt);
settingsModule.init(db);
reviewsModule.init(db, settingsModule);
backupModule.init(db, dbPath);
seasonalPricesModule.init(db);
emailModule.init(db, settingsModule);
reportsModule.init(db);
function initDefaultRooms() {
db.get("SELECT COUNT(*) as count FROM rooms", (err, row) => {
@@ -533,6 +295,9 @@ usersModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdm
settingsModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin);
reviewsModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin);
backupModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin);
seasonalPricesModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin);
emailModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin);
reportsModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin);
app.get('/api/translations/:lang', (req, res) => {
const lang = req.params.lang;
@@ -682,4 +447,4 @@ convertImages().then(() => {
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
});
});