oc
This commit is contained in:
17
db.js
17
db.js
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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> ${clientStatusDot}</td>
|
||||
<td><button class="editBtn btn-secondary btn-sm" data-id="${b.id}">Изменить</button></td>
|
||||
`;
|
||||
tableBody.appendChild(row);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
38
server.js
38
server.js
@@ -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);
|
||||
});
|
||||
|
||||
// --- Синхронизация ---
|
||||
|
||||
// Ручной запуск синхронизации
|
||||
|
||||
Reference in New Issue
Block a user