oc
This commit is contained in:
17
db.js
17
db.js
@@ -23,6 +23,7 @@ db.exec(`
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
phone TEXT UNIQUE NOT NULL,
|
phone TEXT UNIQUE NOT NULL,
|
||||||
name TEXT DEFAULT '',
|
name TEXT DEFAULT '',
|
||||||
|
status TEXT DEFAULT 'Bronze',
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
@@ -67,4 +68,18 @@ function logAction(action, details = {}) {
|
|||||||
stmt.run(action, JSON.stringify(details));
|
stmt.run(action, JSON.stringify(details));
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { db, normalizePhone, logAction };
|
function ensureSchema() {
|
||||||
|
try {
|
||||||
|
const info = db.prepare("PRAGMA table_info(users)").all();
|
||||||
|
const hasStatus = info.some(col => col.name.toLowerCase() === 'status');
|
||||||
|
if (!hasStatus) {
|
||||||
|
db.exec("ALTER TABLE users ADD COLUMN status TEXT DEFAULT 'Bronze'");
|
||||||
|
db.prepare("UPDATE users SET status = 'Bronze' WHERE status IS NULL OR status = ''").run();
|
||||||
|
console.log('[MIGRATE] Added status column to users (Bronze default)');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[MIGRATE] Failed to ensure users.status column', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { db, normalizePhone, logAction, ensureSchema };
|
||||||
|
|||||||
@@ -96,6 +96,16 @@
|
|||||||
<h1>Профиль клиента</h1>
|
<h1>Профиль клиента</h1>
|
||||||
|
|
||||||
<div id="clientInfo" class="card" style="margin-bottom:2rem;"></div>
|
<div id="clientInfo" class="card" style="margin-bottom:2rem;"></div>
|
||||||
|
<div id="editNameRow" class="card" style="display:none; margin-bottom: 1rem;">
|
||||||
|
<div class="form-group" style="display:flex;gap:0.5rem;align-items:center;">
|
||||||
|
<label for="editNameInput" style="min-width:120px;">Имя клиента</label>
|
||||||
|
<input id="editNameInput" type="text" value="" style="flex:1;">
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:0.75rem;justify-content:flex-end; margin-top:0.5rem;">
|
||||||
|
<button id="saveNameBtn" class="btn-secondary">Сохранить</button>
|
||||||
|
<button id="cancelEditNameBtn" class="btn-secondary">Отмена</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h2>История заявок</h2>
|
<h2>История заявок</h2>
|
||||||
|
|
||||||
@@ -202,14 +212,22 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style="color:var(--text-muted);font-size:0.75rem;text-transform:uppercase;letter-spacing:0.05em;font-weight:600;margin-bottom:0.25rem;">Имя</div>
|
<div style="color:var(--text-muted);font-size:0.75rem;text-transform:uppercase;letter-spacing:0.05em;font-weight:600;margin-bottom:0.25rem;">Имя</div>
|
||||||
<div style="font-size:1.0625rem;font-weight:600;color:var(--text-primary);">${escapeHtml(data.client.name) || '<span style="color:var(--text-muted);">не указано</span>'}</div>
|
<div style="font-size:1.0625rem;font-weight:600;color:var(--text-primary);"><span data-current-name>${escapeHtml(data.client.name) || '<span style="color:var(--text-muted);">не указано</span>'}</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style="color:var(--text-muted);font-size:0.75rem;text-transform:uppercase;letter-spacing:0.05em;font-weight:600;margin-bottom:0.25rem;">Заявок</div>
|
<div style="color:var(--text-muted);font-size:0.75rem;text-transform:uppercase;letter-spacing:0.05em;font-weight:600;margin-bottom:0.25rem;">Заявок</div>
|
||||||
<div style="font-size:1.0625rem;font-weight:600;color:var(--text-primary);">${data.bookings.length}</div>
|
<div style="font-size:1.0625rem;font-weight:600;color:var(--text-primary);">${data.bookings.length}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="margin-top:0.5rem;">
|
||||||
|
<button id="startEditName" class="btn-secondary">Изменить имя</button>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
|
// Pre-fill edit field if present after rendering
|
||||||
|
const editNameInput = document.getElementById('editNameInput');
|
||||||
|
if (editNameInput) {
|
||||||
|
editNameInput.value = data.client.name || '';
|
||||||
|
}
|
||||||
|
|
||||||
tbody.innerHTML = '';
|
tbody.innerHTML = '';
|
||||||
bookingCards.innerHTML = '';
|
bookingCards.innerHTML = '';
|
||||||
@@ -268,6 +286,43 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadClient();
|
loadClient();
|
||||||
|
// Name editing UI for client
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (e.target && e.target.id === 'startEditName') {
|
||||||
|
document.getElementById('editNameRow').style.display = 'block';
|
||||||
|
// Pre-fill with current name from display
|
||||||
|
const nameEl = document.querySelector('#clientInfo [data-current-name]');
|
||||||
|
const currentName = nameEl ? nameEl.textContent.trim() : '';
|
||||||
|
document.getElementById('editNameInput').value = currentName;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('cancelEditNameBtn')?.addEventListener('click', () => {
|
||||||
|
document.getElementById('editNameRow').style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('saveNameBtn')?.addEventListener('click', async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
const newName = document.getElementById('editNameInput').value.trim();
|
||||||
|
if (newName.length === 0) return;
|
||||||
|
try {
|
||||||
|
const csrfToken = await getCsrfToken();
|
||||||
|
const res = await fetch(`/api/clients/${clientId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken },
|
||||||
|
body: JSON.stringify({ name: newName })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
document.getElementById('editNameRow').style.display = 'none';
|
||||||
|
loadClient();
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
showToast(err.error || 'Ошибка обновления', 'error');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Ошибка соединения', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -73,6 +73,7 @@
|
|||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>Телефон</th>
|
<th>Телефон</th>
|
||||||
<th>Имя</th>
|
<th>Имя</th>
|
||||||
|
<th>Статус</th>
|
||||||
<th>Действия</th>
|
<th>Действия</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -154,6 +155,7 @@
|
|||||||
<td>${c.id}</td>
|
<td>${c.id}</td>
|
||||||
<td>${escapeHtml(c.phone)}</td>
|
<td>${escapeHtml(c.phone)}</td>
|
||||||
<td>${escapeHtml(c.name) || '<span style="color:var(--text-muted);">—</span>'}</td>
|
<td>${escapeHtml(c.name) || '<span style="color:var(--text-muted);">—</span>'}</td>
|
||||||
|
<td>${escapeHtml(c.status)}</td>
|
||||||
<td><a href="client.html?id=${c.id}" class="btn btn-secondary btn-sm" style="text-decoration:none;display:inline-flex;align-items:center;">Профиль</a></td>
|
<td><a href="client.html?id=${c.id}" class="btn btn-secondary btn-sm" style="text-decoration:none;display:inline-flex;align-items:center;">Профиль</a></td>
|
||||||
`;
|
`;
|
||||||
tbody.appendChild(tr);
|
tbody.appendChild(tr);
|
||||||
|
|||||||
@@ -57,6 +57,8 @@
|
|||||||
<th>Телефон</th>
|
<th>Телефон</th>
|
||||||
<th>Даты проживания</th>
|
<th>Даты проживания</th>
|
||||||
<th>Статус</th>
|
<th>Статус</th>
|
||||||
|
<th>Комментарий</th>
|
||||||
|
<th>Профиль</th>
|
||||||
<th>Действия</th>
|
<th>Действия</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -122,13 +124,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getStatusClass(status) {
|
function getStatusClass(status) {
|
||||||
const map = {
|
const map = {
|
||||||
'Новая': 'status-new',
|
'Новая': 'status-new',
|
||||||
'В работе': 'status-in-progress',
|
'В работе': 'status-in-progress',
|
||||||
'Подтверждена': 'status-confirmed',
|
'Подтверждена': 'status-confirmed',
|
||||||
'Заселение': 'status-checkin',
|
'Заселение': 'status-checkin',
|
||||||
'Завершена': 'status-completed',
|
'Завершена': 'status-completed',
|
||||||
'Отменена': 'status-cancelled'
|
'Отменена': 'status-cancelled',
|
||||||
|
'Bronze': 'status-bronze',
|
||||||
|
'Silver': 'status-silver',
|
||||||
|
'Gold': 'status-gold',
|
||||||
|
'Diamond': 'status-diamond'
|
||||||
};
|
};
|
||||||
return map[status] || 'status-new';
|
return map[status] || 'status-new';
|
||||||
}
|
}
|
||||||
@@ -232,12 +238,17 @@
|
|||||||
currentBookings.forEach((b, i) => {
|
currentBookings.forEach((b, i) => {
|
||||||
const row = document.createElement('tr');
|
const row = document.createElement('tr');
|
||||||
row.style.animationDelay = `${i * 0.03}s`;
|
row.style.animationDelay = `${i * 0.03}s`;
|
||||||
row.innerHTML = `
|
const clientStatus = (b.client_status || 'Bronze').toString();
|
||||||
|
const clientStatusClass = getStatusClass(clientStatus);
|
||||||
|
const clientStatusDot = `<span class="status-diamond status-${clientStatus.toLowerCase()}" title="Статус клиента: ${escapeHtml(clientStatus)}">◆</span>`;
|
||||||
|
row.innerHTML = `
|
||||||
<td>${escapeHtml(b.external_id) || '—'}</td>
|
<td>${escapeHtml(b.external_id) || '—'}</td>
|
||||||
<td><strong>${escapeHtml(b.name)}</strong></td>
|
<td><strong>${escapeHtml(b.client_name || b.name)}</strong></td>
|
||||||
<td>${escapeHtml(b.phone_raw)}</td>
|
<td>${escapeHtml(b.client_phone || b.phone_raw)}</td>
|
||||||
<td>${escapeHtml(b.checkin_date)} – ${escapeHtml(b.checkout_date)}</td>
|
<td>${escapeHtml(b.checkin_date)} – ${escapeHtml(b.checkout_date)}</td>
|
||||||
<td><span class="status-badge ${getStatusClass(b.status)}">${escapeHtml(b.status)}</span></td>
|
<td><span class="status-badge ${clientStatusClass}">${escapeHtml(clientStatus)}</span></td>
|
||||||
|
<td>${escapeHtml(b.comments) || ''}</td>
|
||||||
|
<td><a href="/client.html?id=${b.user_id}">Профиль</a> ${clientStatusDot}</td>
|
||||||
<td><button class="editBtn btn-secondary btn-sm" data-id="${b.id}">Изменить</button></td>
|
<td><button class="editBtn btn-secondary btn-sm" data-id="${b.id}">Изменить</button></td>
|
||||||
`;
|
`;
|
||||||
tableBody.appendChild(row);
|
tableBody.appendChild(row);
|
||||||
|
|||||||
@@ -302,6 +302,19 @@ button:disabled {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-diamond {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.1em;
|
||||||
|
height: 1.1em;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-left: 0.25em;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.status-bronze { color: #cd7f32; }
|
||||||
|
.status-silver { color: #c0c0c0; }
|
||||||
|
.status-gold { color: #d4af37; }
|
||||||
.status-badge::before {
|
.status-badge::before {
|
||||||
content: '';
|
content: '';
|
||||||
width: 6px;
|
width: 6px;
|
||||||
@@ -1042,3 +1055,13 @@ hr {
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/* Diamond/bronze/silver/gold status colors for client profiles */
|
||||||
|
.status-diamond { color: #4b8af5; background: rgba(75, 142, 245, 0.15); padding: 0.15rem 0.5rem; border-radius: 6px; }
|
||||||
|
.status-bronze { color: #cd7f32; background: rgba(205, 127, 50, 0.15); padding: 0.15rem 0.5rem; border-radius: 6px; }
|
||||||
|
.status-silver { color: #c0c0c0; background: rgba(192, 192, 192, 0.25); padding: 0.15rem 0.5rem; border-radius: 6px; }
|
||||||
|
.status-gold { color: #d4af37; background: rgba(212, 175, 55, 0.25); padding: 0.15rem 0.5rem; border-radius: 6px; }
|
||||||
|
.status-bronze::before, .status-silver::before, .status-gold::before, .status-diamond::before { content:''; display:inline-block; width:6px; height:6px; margin-right:4px; border-radius:50%; vertical-align: middle; }
|
||||||
|
.status-diamond::before { background:#4b8af5; }
|
||||||
|
.status-bronze::before { background:#cd7f32; }
|
||||||
|
.status-silver::before { background:#c0c0c0; }
|
||||||
|
.status-gold::before { background:#d4af37; }
|
||||||
|
|||||||
40
server.js
40
server.js
@@ -4,7 +4,7 @@ const path = require('path');
|
|||||||
const bcrypt = require('bcrypt');
|
const bcrypt = require('bcrypt');
|
||||||
const cron = require('node-cron');
|
const cron = require('node-cron');
|
||||||
|
|
||||||
const { db, normalizePhone, logAction } = require('./db');
|
const { db, normalizePhone, logAction, ensureSchema } = require('./db');
|
||||||
const { sessionMiddleware, csrfProtection, injectCsrfToken, ensureAdmin, requireAdmin } = require('./auth');
|
const { sessionMiddleware, csrfProtection, injectCsrfToken, ensureAdmin, requireAdmin } = require('./auth');
|
||||||
const { syncBookings } = require('./sync');
|
const { syncBookings } = require('./sync');
|
||||||
const { notifyBookingUpdate, verifyEmailConnection } = require('./mailer');
|
const { notifyBookingUpdate, verifyEmailConnection } = require('./mailer');
|
||||||
@@ -15,6 +15,8 @@ const PORT = process.env.PORT || 3000;
|
|||||||
// Middleware
|
// Middleware
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(sessionMiddleware);
|
app.use(sessionMiddleware);
|
||||||
|
// Ensure schema on startup
|
||||||
|
ensureSchema();
|
||||||
app.use(injectCsrfToken);
|
app.use(injectCsrfToken);
|
||||||
|
|
||||||
// Статика из public
|
// Статика из public
|
||||||
@@ -159,26 +161,32 @@ app.delete('/api/admins/:id', requireAdmin, csrfProtection, (req, res) => {
|
|||||||
|
|
||||||
// --- Заявки (bookings) ---
|
// --- Заявки (bookings) ---
|
||||||
|
|
||||||
// Список заявок с фильтрами
|
// Список заявок с фильтрами (обогащён данными клиента)
|
||||||
app.get('/api/bookings', requireAdmin, (req, res) => {
|
app.get('/api/bookings', requireAdmin, (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { status, search, client_id } = req.query;
|
const { status, search, client_id } = req.query;
|
||||||
let query = 'SELECT * FROM bookings WHERE 1=1';
|
let query = `SELECT b.*,
|
||||||
|
COALESCE(u.name, b.name) AS client_name,
|
||||||
|
COALESCE(u.phone, b.phone_raw) AS client_phone,
|
||||||
|
COALESCE(u.status, 'Bronze') AS client_status
|
||||||
|
FROM bookings b
|
||||||
|
LEFT JOIN users u ON b.user_id = u.id
|
||||||
|
WHERE 1=1`;
|
||||||
const params = [];
|
const params = [];
|
||||||
|
|
||||||
if (status) {
|
if (status) {
|
||||||
query += ' AND status = ?';
|
query += ' AND b.status = ?';
|
||||||
params.push(status);
|
params.push(status);
|
||||||
}
|
}
|
||||||
if (client_id) {
|
if (client_id) {
|
||||||
query += ' AND user_id = ?';
|
query += ' AND b.user_id = ?';
|
||||||
params.push(client_id);
|
params.push(client_id);
|
||||||
}
|
}
|
||||||
if (search) {
|
if (search) {
|
||||||
query += ' AND (name LIKE ? OR phone_raw LIKE ? OR comments LIKE ?)';
|
query += ' AND (b.name LIKE ? OR b.phone_raw LIKE ? OR b.comments LIKE ? OR u.name LIKE ?)';
|
||||||
params.push(`%${search}%`, `%${search}%`, `%${search}%`);
|
params.push(`%${search}%`, `%${search}%`, `%${search}%`, `%${search}%`);
|
||||||
}
|
}
|
||||||
query += ' ORDER BY created_at DESC';
|
query += ' ORDER BY b.created_at DESC';
|
||||||
const bookings = db.prepare(query).all(...params);
|
const bookings = db.prepare(query).all(...params);
|
||||||
res.json(bookings);
|
res.json(bookings);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -283,13 +291,25 @@ app.get('/api/clients/:id', requireAdmin, (req, res) => {
|
|||||||
ORDER BY created_at DESC, status ASC
|
ORDER BY created_at DESC, status ASC
|
||||||
`).all(req.params.id);
|
`).all(req.params.id);
|
||||||
|
|
||||||
res.json({ client, bookings });
|
res.json({ client, bookings });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Ошибка получения профиля клиента:', err);
|
console.error('Ошибка получения профиля клиента:', err);
|
||||||
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
|
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Изменение имени клиента
|
||||||
|
app.put('/api/clients/:id', requireAdmin, csrfProtection, (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { name } = req.body;
|
||||||
|
if (name === undefined) return res.status(400).json({ error: 'Имя обязательно' });
|
||||||
|
const client = db.prepare('SELECT id FROM users WHERE id = ?').get(id);
|
||||||
|
if (!client) return res.status(404).json({ error: 'Клиент не найден' });
|
||||||
|
db.prepare('UPDATE users SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(name, id);
|
||||||
|
const updated = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
|
||||||
|
res.json(updated);
|
||||||
|
});
|
||||||
|
|
||||||
// --- Синхронизация ---
|
// --- Синхронизация ---
|
||||||
|
|
||||||
// Ручной запуск синхронизации
|
// Ручной запуск синхронизации
|
||||||
@@ -313,4 +333,4 @@ cron.schedule('*/5 * * * *', () => {
|
|||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`Сервис "${process.env.SERVICE_NAME}" запущен на порту ${PORT}`);
|
console.log(`Сервис "${process.env.SERVICE_NAME}" запущен на порту ${PORT}`);
|
||||||
console.log(`Ссылка: ${process.env.SERVICE_URL}`);
|
console.log(`Ссылка: ${process.env.SERVICE_URL}`);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user