This commit is contained in:
2026-05-04 21:49:42 +05:00
parent c992c4394a
commit 260d66e6f2
6 changed files with 144 additions and 18 deletions

17
db.js
View File

@@ -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 };

View File

@@ -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>

View File

@@ -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);

View File

@@ -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>&nbsp;${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);

View File

@@ -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; }

View File

@@ -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}`);
}); });