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,
phone TEXT UNIQUE NOT NULL,
name TEXT DEFAULT '',
status TEXT DEFAULT 'Bronze',
created_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));
}
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>
<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>
@@ -202,14 +212,22 @@
</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="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 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>
</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 = '';
bookingCards.innerHTML = '';
@@ -268,6 +286,43 @@
}
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>
</body>
</html>

View File

@@ -73,6 +73,7 @@
<th>ID</th>
<th>Телефон</th>
<th>Имя</th>
<th>Статус</th>
<th>Действия</th>
</tr>
</thead>
@@ -154,6 +155,7 @@
<td>${c.id}</td>
<td>${escapeHtml(c.phone)}</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>
`;
tbody.appendChild(tr);

View File

@@ -57,6 +57,8 @@
<th>Телефон</th>
<th>Даты проживания</th>
<th>Статус</th>
<th>Комментарий</th>
<th>Профиль</th>
<th>Действия</th>
</tr>
</thead>
@@ -122,13 +124,17 @@
}
function getStatusClass(status) {
const map = {
const map = {
'Новая': 'status-new',
'В работе': 'status-in-progress',
'Подтверждена': 'status-confirmed',
'Заселение': 'status-checkin',
'Завершена': 'status-completed',
'Отменена': 'status-cancelled'
'Отменена': 'status-cancelled',
'Bronze': 'status-bronze',
'Silver': 'status-silver',
'Gold': 'status-gold',
'Diamond': 'status-diamond'
};
return map[status] || 'status-new';
}
@@ -232,12 +238,17 @@
currentBookings.forEach((b, i) => {
const row = document.createElement('tr');
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><strong>${escapeHtml(b.name)}</strong></td>
<td>${escapeHtml(b.phone_raw)}</td>
<td><strong>${escapeHtml(b.client_name || b.name)}</strong></td>
<td>${escapeHtml(b.client_phone || b.phone_raw)}</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>
`;
tableBody.appendChild(row);

View File

@@ -302,6 +302,19 @@ button:disabled {
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 {
content: '';
width: 6px;
@@ -1042,3 +1055,13 @@ hr {
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 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 { syncBookings } = require('./sync');
const { notifyBookingUpdate, verifyEmailConnection } = require('./mailer');
@@ -15,6 +15,8 @@ const PORT = process.env.PORT || 3000;
// Middleware
app.use(express.json());
app.use(sessionMiddleware);
// Ensure schema on startup
ensureSchema();
app.use(injectCsrfToken);
// Статика из public
@@ -159,26 +161,32 @@ app.delete('/api/admins/:id', requireAdmin, csrfProtection, (req, res) => {
// --- Заявки (bookings) ---
// Список заявок с фильтрами
// Список заявок с фильтрами (обогащён данными клиента)
app.get('/api/bookings', requireAdmin, (req, res) => {
try {
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 = [];
if (status) {
query += ' AND status = ?';
query += ' AND b.status = ?';
params.push(status);
}
if (client_id) {
query += ' AND user_id = ?';
query += ' AND b.user_id = ?';
params.push(client_id);
}
if (search) {
query += ' AND (name LIKE ? OR phone_raw LIKE ? OR comments LIKE ?)';
params.push(`%${search}%`, `%${search}%`, `%${search}%`);
query += ' AND (b.name LIKE ? OR b.phone_raw LIKE ? OR b.comments LIKE ? OR u.name LIKE ?)';
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);
res.json(bookings);
} catch (err) {
@@ -283,13 +291,25 @@ app.get('/api/clients/:id', requireAdmin, (req, res) => {
ORDER BY created_at DESC, status ASC
`).all(req.params.id);
res.json({ client, bookings });
res.json({ client, bookings });
} catch (err) {
console.error('Ошибка получения профиля клиента:', err);
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, () => {
console.log(`Сервис "${process.env.SERVICE_NAME}" запущен на порту ${PORT}`);
console.log(`Ссылка: ${process.env.SERVICE_URL}`);
});
});