From 260d66e6f27fa3dfacec96a45a412e11556bb3d2 Mon Sep 17 00:00:00 2001 From: kalugin66 Date: Mon, 4 May 2026 21:49:42 +0500 Subject: [PATCH] oc --- db.js | 17 +++++++++++++- public/client.html | 57 ++++++++++++++++++++++++++++++++++++++++++++- public/clients.html | 2 ++ public/index.html | 23 +++++++++++++----- public/style.css | 23 ++++++++++++++++++ server.js | 40 +++++++++++++++++++++++-------- 6 files changed, 144 insertions(+), 18 deletions(-) diff --git a/db.js b/db.js index 2047247..61d1924 100644 --- a/db.js +++ b/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 }; \ No newline at end of file +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 }; diff --git a/public/client.html b/public/client.html index 8e864e9..fa30e91 100644 --- a/public/client.html +++ b/public/client.html @@ -96,6 +96,16 @@

Профиль клиента

+

История заявок

@@ -202,14 +212,22 @@
Имя
-
${escapeHtml(data.client.name) || 'не указано'}
+
${escapeHtml(data.client.name) || 'не указано'}
Заявок
${data.bookings.length}
+
+ +
`; + // 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'); + } + }); diff --git a/public/clients.html b/public/clients.html index b5640ad..d417f4c 100644 --- a/public/clients.html +++ b/public/clients.html @@ -73,6 +73,7 @@ ID Телефон Имя + Статус Действия @@ -154,6 +155,7 @@ ${c.id} ${escapeHtml(c.phone)} ${escapeHtml(c.name) || ''} + ${escapeHtml(c.status)} Профиль `; tbody.appendChild(tr); diff --git a/public/index.html b/public/index.html index 82fbff8..cdf17a7 100644 --- a/public/index.html +++ b/public/index.html @@ -57,6 +57,8 @@ Телефон Даты проживания Статус + Комментарий + Профиль Действия @@ -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 = ``; + row.innerHTML = ` ${escapeHtml(b.external_id) || '—'} - ${escapeHtml(b.name)} - ${escapeHtml(b.phone_raw)} + ${escapeHtml(b.client_name || b.name)} + ${escapeHtml(b.client_phone || b.phone_raw)} ${escapeHtml(b.checkin_date)} – ${escapeHtml(b.checkout_date)} - ${escapeHtml(b.status)} + ${escapeHtml(clientStatus)} + ${escapeHtml(b.comments) || ''} + Профиль ${clientStatusDot} `; tableBody.appendChild(row); diff --git a/public/style.css b/public/style.css index babfa56..0adf2ed 100644 --- a/public/style.css +++ b/public/style.css @@ -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; } diff --git a/server.js b/server.js index f67fe66..f632dbc 100644 --- a/server.js +++ b/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); +}); + // --- Синхронизация --- // Ручной запуск синхронизации @@ -313,4 +333,4 @@ cron.schedule('*/5 * * * *', () => { app.listen(PORT, () => { console.log(`Сервис "${process.env.SERVICE_NAME}" запущен на порту ${PORT}`); console.log(`Ссылка: ${process.env.SERVICE_URL}`); -}); \ No newline at end of file +});