This commit is contained in:
@@ -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 };
|
||||
|
||||
@@ -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
292
modules/database/index.js
Normal 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
109
modules/email/index.js
Normal 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
129
modules/reports/index.js
Normal 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 };
|
||||
@@ -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 };
|
||||
|
||||
96
modules/seasonalPrices/index.js
Normal file
96
modules/seasonalPrices/index.js
Normal 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 };
|
||||
@@ -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 };
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()">×</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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 + ' ₽';
|
||||
|
||||
@@ -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
271
server.js
@@ -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'));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user