v1.7.0: refactor max_bot to flat structure, add VCF+UserModel+NLP history context, portal pages and proxy fixes

- Refactored max_bot from nested packages to flat module structure
- Q2: Extended BotUser model (patronymic, email, org, address, vcf_raw, contact_hash, phone_verified, email_verified, last_interaction, total_conversations, total_tickets)
- Q2: VCF parser (FN, N, TEL, EMAIL, ORG, ADR), upsert on re-contact, NLP history context (_get_user_history_context -> YandexGPT)
- Q1: Broadcast preview modal with 10s confirmation timer
- Q3: CSS var(--white)->var(--bg-card), var(--text)->var(--text-primary)
- Q4: bot_settings showNotification(), editable max_bot_id
- Q5: Webhook secret passthrough via X-Max-Bot-Api-Secret
- Masking sensitive keys, dialog_cleared handler, migrate via _add_column_if_not_exists()
- Rate limit (asyncio.sleep 0.5 per 10), dead code removed, conv.intent context in contact.py
- Portal pages: bot_consent, bot_kb (edit), bot_settings, bot_test, bot_tickets, portal_settings
- Tests: 21/21 passing, added test_yandex_gpt.py, test_email_sender.py
- Deploy: deploy_full.sh, schema.sql, seed_knowledge_base.sql
This commit is contained in:
2026-05-29 02:30:30 +03:00
parent 493e0b37a1
commit 72b6879f4b
234 changed files with 26768 additions and 6240 deletions
@@ -1,241 +0,0 @@
{% extends "page.html" %}
{% block page_content %}
<style>
.broadcast-layout { display:flex; gap:24px; margin-top:16px; flex-wrap:wrap; }
.broadcast-left { flex:1; min-width:300px; }
.broadcast-right { flex:1; min-width:300px; }
.msg-preview { background:#e3f2fd; border-radius:12px; padding:12px 16px; margin-top:8px;
white-space:pre-wrap; word-break:break-word; line-height:1.5; font-size:14px; }
.msg-preview.empty { color:#999; font-style:italic; }
.recipient-item { display:flex; align-items:center; gap:8px; padding:6px 8px;
border-bottom:1px solid #eee; cursor:pointer; }
.recipient-item:hover { background:#f8f9fa; }
.recipient-item .name { flex:1; }
.recipient-item .phone { color:#666; font-size:13px; }
.recipient-list { max-height:300px; overflow-y:auto; border:1px solid #ddd; border-radius:8px;
margin-top:8px; }
.recipient-list .select-all { padding:8px; border-bottom:1px solid #eee; background:#f8f9fa;
font-weight:600; cursor:pointer; user-select:none; }
.modal-overlay { display:none; position:fixed; top:0; left:0; right:0; bottom:0;
background:rgba(0,0,0,0.5); z-index:1000;
align-items:center; justify-content:center; }
.modal-overlay.active { display:flex; }
.modal-box { background:#fff; border-radius:12px; padding:24px; max-width:600px;
width:90%; max-height:80vh; overflow-y:auto; }
.modal-box h3 { margin:0 0 16px; }
.modal-box .actions { display:flex; gap:12px; justify-content:flex-end; margin-top:20px; }
.broadcast-history { margin-top:24px; }
.broadcast-history table { width:100%; }
.broadcast-history td, .broadcast-history th { padding:8px 12px; text-align:left; border-bottom:1px solid #eee; }
.broadcast-history .text-col { max-width:300px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
</style>
<div class="card">
<div class="card-header"><h2>Рассылки</h2></div>
<div class="broadcast-layout">
<div class="broadcast-left">
<div class="form-group">
<label>Текст сообщения</label>
<textarea id="broadcast-text" rows="5" style="width:100%;" oninput="updatePreview()" placeholder="Введите текст рассылки... Markdown поддерживается"></textarea>
</div>
<div>
<label>📱 Предпросмотр сообщения</label>
<div id="live-preview" class="msg-preview empty">Текст сообщения появится здесь...</div>
</div>
</div>
<div class="broadcast-right">
<div class="form-group">
<label>Получатели</label>
<input type="text" id="recipient-search" placeholder="Поиск..." style="width:100%;margin-bottom:8px;" oninput="loadRecipients()">
</div>
<div class="recipient-list" id="recipient-list">
<div class="select-all" onclick="toggleSelectAll()">
<span id="select-all-checkbox"></span> Выбрать всех согласных
<span style="float:right;color:#666;font-weight:400;" id="recipient-count"></span>
</div>
<div id="recipient-items"></div>
</div>
<div style="display:flex;gap:12px;margin-top:12px;">
<button class="btn btn-primary" onclick="previewBroadcast()">👁 Предпросмотр</button>
<button class="btn btn-primary" onclick="sendBroadcast()">📨 Отправить</button>
</div>
</div>
</div>
</div>
<div class="card broadcast-history">
<div class="card-header"><h3>История рассылок</h3></div>
<div class="table-wrap"><table>
<thead><tr><th>ID</th><th>Текст</th><th>Получателей</th><th>Дата</th></tr></thead>
<tbody id="history-list"></tbody>
</table></div>
</div>
<div class="modal-overlay" id="preview-modal">
<div class="modal-box">
<h3>👁 Предпросмотр рассылки</h3>
<div style="background:#e3f2fd;border-radius:12px;padding:16px;white-space:pre-wrap;line-height:1.5;" id="preview-text"></div>
<div style="margin-top:16px;padding:12px;background:#f8f9fa;border-radius:8px;">
<strong>Получателей:</strong> <span id="preview-count"></span>
<div style="margin-top:8px;max-height:150px;overflow-y:auto;" id="preview-recipients"></div>
</div>
<div class="actions">
<button class="btn btn-secondary" onclick="closePreview()">Отмена</button>
<button class="btn btn-primary" id="preview-send-btn" onclick="confirmSend()">📨 Отправить</button>
</div>
</div>
</div>
<script>
var selectedUserIds = new Set();
var allRecipients = [];
var currentDraftId = null;
function updatePreview(){
var text = document.getElementById('broadcast-text').value.trim();
var preview = document.getElementById('live-preview');
if(text){
preview.textContent = text;
preview.className = 'msg-preview';
} else {
preview.textContent = 'Текст сообщения появится здесь...';
preview.className = 'msg-preview empty';
}
}
function loadRecipients(){
var q = document.getElementById('recipient-search').value.trim();
var url = '/service/api/bot/broadcast/recipients?limit=200';
if(q) url += '&q=' + encodeURIComponent(q);
fetch(url).then(function(r){ return r.json() }).then(function(d){
allRecipients = d.users || [];
renderRecipients();
});
}
function renderRecipients(){
var container = document.getElementById('recipient-items');
var countEl = document.getElementById('recipient-count');
container.innerHTML = '';
countEl.textContent = allRecipients.length + ' чел.';
allRecipients.forEach(function(u){
var div = document.createElement('div');
div.className = 'recipient-item';
var checked = selectedUserIds.has(u.max_user_id) ? '☑' : '☐';
var name = (u.first_name || '') + ' ' + (u.last_name || '');
if(!name.trim()) name = 'ID ' + u.max_user_id;
div.innerHTML = '<span class="cb">' + checked + '</span>' +
'<span class="name">' + name + '</span>' +
'<span class="phone">' + (u.phone || '') + '</span>';
div.onclick = function(){ toggleRecipient(u.max_user_id); };
container.appendChild(div);
});
updateSelectAllCheckbox();
}
function toggleRecipient(id){
if(selectedUserIds.has(id)){
selectedUserIds['delete'](id);
} else {
selectedUserIds.add(id);
}
renderRecipients();
}
function toggleSelectAll(){
if(selectedUserIds.size === allRecipients.length){
selectedUserIds.clear();
} else {
selectedUserIds = new Set(allRecipients.map(function(u){ return u.max_user_id; }));
}
renderRecipients();
}
function updateSelectAllCheckbox(){
var cb = document.getElementById('select-all-checkbox');
if(allRecipients.length > 0 && selectedUserIds.size === allRecipients.length){
cb.textContent = '☑';
} else {
cb.textContent = '☐';
}
}
function previewBroadcast(){
var text = document.getElementById('broadcast-text').value.trim();
if(!text){ alert('Введите текст рассылки'); return; }
var userIds = Array.from(selectedUserIds);
fetch('/service/api/bot/broadcast', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({text: text, user_ids: userIds, action: 'preview', draft_id: currentDraftId})
}).then(function(r){ return r.json() }).then(function(d){
if(d.error){ alert(d.error); return; }
currentDraftId = d.draft_id;
document.getElementById('preview-text').textContent = d.text;
document.getElementById('preview-count').textContent = d.recipient_count + ' чел.';
var recEl = document.getElementById('preview-recipients');
recEl.innerHTML = d.recipients.join(', ');
document.getElementById('preview-modal').className = 'modal-overlay active';
});
}
function closePreview(){
document.getElementById('preview-modal').className = 'modal-overlay';
}
function confirmSend(){
var text = document.getElementById('broadcast-text').value.trim();
if(!currentDraftId){ alert('Сначала сделайте предпросмотр'); return; }
if(!confirm('Отправить рассылку?')) return;
var userIds = Array.from(selectedUserIds);
document.getElementById('preview-send-btn').disabled = true;
fetch('/service/api/bot/broadcast', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({text: text, user_ids: userIds, action: 'send', draft_id: currentDraftId})
}).then(function(r){ return r.json() }).then(function(d){
closePreview();
if(d.ok){
alert('Рассылка отправлена: ' + d.sent_to + ' из ' + d.total_recipients + ' получателей');
document.getElementById('broadcast-text').value = '';
document.getElementById('live-preview').textContent = 'Текст сообщения появится здесь...';
document.getElementById('live-preview').className = 'msg-preview empty';
selectedUserIds.clear();
renderRecipients();
currentDraftId = null;
loadHistory();
} else {
alert('Ошибка: ' + (d.error || 'неизвестная'));
}
document.getElementById('preview-send-btn').disabled = false;
});
}
function sendBroadcast(){
var text = document.getElementById('broadcast-text').value.trim();
if(!text){ alert('Введите текст рассылки'); return; }
var userIds = Array.from(selectedUserIds);
if(userIds.length === 0 && !confirm('Не выбрано ни одного получателя. Отправить всем согласным?')) return;
previewBroadcast();
}
function loadHistory(){
fetch('/service/api/bot/broadcast/history').then(function(r){ return r.json() }).then(function(d){
var tbody = document.getElementById('history-list');
tbody.innerHTML = '';
(d.broadcasts || []).forEach(function(b){
var tr = document.createElement('tr');
tr.innerHTML = '<td>' + b.id + '</td>' +
'<td class="text-col" title="' + b.text.replace(/"/g,'&quot;') + '">' + b.text + '</td>' +
'<td>' + b.recipient_count + '</td>' +
'<td>' + b.sent_at + '</td>';
tbody.appendChild(tr);
});
if(!d.broadcasts || d.broadcasts.length === 0){
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:#999;">Рассылок ещё не было</td></tr>';
}
});
}
loadRecipients();
loadHistory();
</script>
{% endblock %}
@@ -0,0 +1,220 @@
{% extends "page.html" %}
{% block page_content %}
<style>
.consent-table { width:100%; border-collapse:collapse; }
.consent-table th, .consent-table td { padding:10px 14px; text-align:left; border-bottom:1px solid var(--border); font-size:13px; }
.consent-table th { font-weight:600; color:var(--text-muted); background:var(--light-bg); }
.consent-table td { vertical-align:middle; }
.consent-yes { color:#155724; font-weight:600; }
.consent-no { color:#721c24; font-weight:600; }
.filter-bar { display:flex; gap:12px; margin-bottom:16px; align-items:center; flex-wrap:wrap; }
.filter-bar input, .filter-bar select { padding:8px 12px; border:1px solid var(--border); border-radius:6px; font-size:13px; }
.broadcast-card { background:var(--bg-card); border:1px solid var(--border); border-radius:12px; padding:20px; margin-top:16px; }
.broadcast-card h3 { margin-bottom:12px; }
.broadcast-card textarea { width:100%; padding:8px 12px; border:1px solid var(--border); border-radius:6px; font-size:14px; min-height:100px; resize:vertical; margin-bottom:10px; box-sizing:border-box; }
.broadcast-actions { display:flex; gap:10px; flex-wrap:wrap; }
.recipient-count { font-size:12px; color:var(--text-muted); margin-bottom:8px; }
.preview-area { display:none; margin-top:12px; padding:12px; border:1px solid var(--border); border-radius:8px; background:var(--bg-input); white-space:pre-wrap; font-size:14px; line-height:1.5; }
.preview-area.open { display:block; }
.preview-area .preview-text { margin-bottom:10px; }
.preview-area .preview-actions { display:flex; gap:10px; }
.confirm-overlay { display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.5); z-index:1000; align-items:center; justify-content:center; }
.confirm-overlay.open { display:flex; }
.confirm-modal { background:var(--bg-card); border:1px solid var(--border); border-radius:12px; padding:24px; width:500px; max-width:90vw; }
.confirm-modal h3 { margin-bottom:8px; }
.confirm-modal .confirm-text { padding:10px; border:1px solid var(--border); border-radius:6px; background:var(--bg-input); max-height:200px; overflow-y:auto; margin:12px 0; white-space:pre-wrap; font-size:13px; }
.confirm-modal .confirm-count { font-size:12px; color:var(--text-muted); margin-bottom:12px; }
.confirm-modal .confirm-timer { font-size:24px; font-weight:700; text-align:center; color:var(--accent); margin:12px 0; }
.confirm-modal .confirm-actions { display:flex; gap:10px; justify-content:center; }
.confirm-modal .confirm-actions .btn { min-width:140px; }
.timer-danger { color:var(--danger) !important; }
</style>
<div class="card">
<div class="card-header" style="display:flex;justify-content:space-between;align-items:center;">
<h2>Согласия ФЗ-152</h2>
<span id="consent-count" style="font-size:13px;color:var(--text-muted);">0 пользователей</span>
</div>
<div class="filter-bar">
<input type="text" id="consent-search" placeholder="Поиск по имени, телефону..." oninput="filterConsents()">
</div>
<table class="consent-table">
<thead>
<tr>
<th>ID</th>
<th>Имя</th>
<th>Телефон</th>
<th>Email</th>
<th>Организация</th>
<th>Username</th>
<th>Согласие</th>
<th>Дата</th>
</tr>
</thead>
<tbody id="consent-table-body">
<tr><td colspan="8" style="text-align:center;padding:30px;color:var(--text-muted);">Загрузка...</td></tr>
</tbody>
</table>
</div>
<div class="broadcast-card">
<h3>Рассылка пользователям MAX</h3>
<div class="recipient-count" id="recipient-count">Получателей: 0</div>
<textarea id="broadcast-text" placeholder="Введите текст сообщения..." oninput="clearPreview()"></textarea>
<div class="broadcast-actions">
<button class="btn" onclick="showPreview()">Предпросмотр</button>
<button class="btn btn-primary" id="send-btn" onclick="openConfirm()" disabled>Отправить всем</button>
</div>
<div class="preview-area" id="preview-area">
<div class="preview-text" id="preview-text"></div>
<div class="preview-actions">
<button class="btn btn-primary" onclick="openConfirm()">Отправить</button>
<button class="btn" onclick="hidePreview()">Редактировать</button>
</div>
</div>
</div>
<div class="confirm-overlay" id="confirm-overlay">
<div class="confirm-modal">
<h3>Подтверждение рассылки</h3>
<div class="confirm-count" id="confirm-count">Получателей: 0</div>
<div class="confirm-text" id="confirm-text"></div>
<div class="confirm-timer" id="confirm-timer">10</div>
<div class="confirm-actions">
<button class="btn btn-primary" id="confirm-yes" onclick="executeBroadcast()">Подтвердить</button>
<button class="btn" onclick="closeConfirm()">Отмена</button>
</div>
</div>
</div>
<script>
var allConsents = [];
var confirmTimer = null;
var confirmSeconds = 10;
function loadConsents(){
var tbody = document.getElementById('consent-table-body');
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;padding:30px;color:var(--text-muted);">Загрузка...</td></tr>';
fetch('/api/bot/users/consented').then(function(r){ return r.json() }).then(function(data){
allConsents = data;
document.getElementById('consent-count').textContent = data.length + ' пользователей';
document.getElementById('recipient-count').textContent = 'Получателей: ' + data.length;
document.getElementById('send-btn').disabled = data.length === 0;
renderConsents(data);
}).catch(function(err){
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;padding:30px;color:var(--text-muted);">Ошибка загрузки</td></tr>';
showNotification('Ошибка загрузки согласий', 'error');
});
}
function renderConsents(list){
var tbody = document.getElementById('consent-table-body');
if(!list.length){
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;padding:30px;color:var(--text-muted);">Нет данных</td></tr>';
return;
}
var html = '';
list.forEach(function(u){
var name = [u.first_name, u.last_name].filter(Boolean).join(' ') || '—';
var dateStr = u.consent_date ? new Date(u.consent_date).toLocaleString() : '—';
html += '<tr>' +
'<td>' + u.id + '</td>' +
'<td>' + escapeHtml(name) + '</td>' +
'<td>' + escapeHtml(u.phone || '—') + '</td>' +
'<td>' + escapeHtml(u.email || '—') + '</td>' +
'<td>' + escapeHtml(u.organization || '—') + '</td>' +
'<td>' + escapeHtml(u.username || '—') + '</td>' +
'<td class="consent-yes">Да</td>' +
'<td style="font-size:12px;color:var(--text-muted);">' + dateStr + '</td>' +
'</tr>';
});
tbody.innerHTML = html;
}
function filterConsents(){
var search = document.getElementById('consent-search').value.toLowerCase();
var filtered = allConsents.filter(function(u){
if(!search) return true;
var name = [u.first_name, u.last_name].filter(Boolean).join(' ').toLowerCase();
var phone = (u.phone || '').toLowerCase();
var email = (u.email || '').toLowerCase();
var username = (u.username || '').toLowerCase();
return name.includes(search) || phone.includes(search) || email.includes(search) || username.includes(search);
});
renderConsents(filtered);
}
function clearPreview(){
document.getElementById('preview-area').classList.remove('open');
}
function showPreview(){
var text = document.getElementById('broadcast-text').value;
if(!text.trim()){ showNotification('Введите текст сообщения', 'warning'); return; }
document.getElementById('preview-text').textContent = text;
document.getElementById('preview-area').classList.add('open');
}
function hidePreview(){
document.getElementById('preview-area').classList.remove('open');
}
function openConfirm(){
if(confirmTimer) return;
var text = document.getElementById('broadcast-text').value.trim();
if(!text){ showNotification('Введите текст сообщения', 'warning'); return; }
closeConfirm();
document.getElementById('confirm-text').textContent = text;
document.getElementById('confirm-count').textContent = 'Получателей: ' + allConsents.length;
document.getElementById('confirm-overlay').classList.add('open');
confirmSeconds = 10;
document.getElementById('confirm-timer').textContent = confirmSeconds;
document.getElementById('confirm-timer').className = 'confirm-timer';
document.getElementById('confirm-yes').disabled = false;
confirmTimer = setInterval(function(){
confirmSeconds--;
document.getElementById('confirm-timer').textContent = confirmSeconds;
if(confirmSeconds <= 3) document.getElementById('confirm-timer').className = 'confirm-timer timer-danger';
if(confirmSeconds <= 0){
clearInterval(confirmTimer);
confirmTimer = null;
closeConfirm();
showNotification('Время подтверждения истекло. Нажмите «Отправить всем» повторно.', 'warning');
}
}, 1000);
}
function closeConfirm(){
if(confirmTimer){ clearInterval(confirmTimer); confirmTimer = null; }
document.getElementById('confirm-overlay').classList.remove('open');
}
function executeBroadcast(){
if(confirmTimer){ clearInterval(confirmTimer); confirmTimer = null; }
var text = document.getElementById('confirm-text').textContent;
document.getElementById('confirm-yes').disabled = true;
fetch('/api/bot/broadcast', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({text: text, all: true})
}).then(function(r){ return r.json() }).then(function(d){
closeConfirm();
if(d.ok) showNotification('Отправлено: ' + d.sent + ', ошибок: ' + d.errors, d.errors ? 'warning' : 'success');
else showNotification('Ошибка: ' + (d.error || 'неизвестная'), 'error');
}).catch(function(){
closeConfirm();
showNotification('Ошибка соединения', 'error');
});
}
function escapeHtml(s){
if(!s) return '';
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
loadConsents();
</script>
{% endblock %}
+239
View File
@@ -0,0 +1,239 @@
{% extends "page.html" %}
{% block page_content %}
<style>
.kb-table { width:100%; border-collapse:collapse; }
.kb-table th, .kb-table td { padding:10px 14px; text-align:left; border-bottom:1px solid var(--border); font-size:13px; }
.kb-table th { font-weight:600; color:var(--text-muted); background:var(--light-bg); }
.kb-table td { vertical-align:top; }
.kb-table .active-badge { display:inline-block; padding:2px 8px; border-radius:4px; font-size:11px; font-weight:600; }
.kb-table .active-badge.yes { background:#d4edda; color:#155724; }
.kb-table .active-badge.no { background:#f8d7da; color:#721c24; }
.kb-modal { display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.4);
z-index:1000; align-items:center; justify-content:center; }
.kb-modal.open { display:flex; }
.kb-modal-content { background:var(--bg-card); border-radius:12px; padding:30px; width:600px; max-width:90vw;
max-height:80vh; overflow-y:auto; }
.kb-modal-content h3 { margin-bottom:20px; }
.form-group { margin-bottom:14px; }
.form-group label { display:block; font-weight:600; margin-bottom:4px; font-size:13px; color:var(--text-muted); }
.form-group input, .form-group textarea, .form-group select {
width:100%; padding:8px 12px; border:1px solid var(--border); border-radius:6px; font-size:14px;
}
.form-group textarea { min-height:100px; resize:vertical; }
.modal-actions { display:flex; gap:12px; margin-top:16px; }
.filter-bar { display:flex; gap:12px; margin-bottom:16px; align-items:center; flex-wrap:wrap; }
.filter-bar input, .filter-bar select { padding:8px 12px; border:1px solid var(--border); border-radius:6px; font-size:13px; }
</style>
<div class="card">
<div class="card-header" style="display:flex;justify-content:space-between;align-items:center;">
<h2>База знаний чат-бота</h2>
<button class="btn btn-primary" onclick="openModal()">+ Добавить карточку</button>
</div>
<div class="filter-bar">
<input type="text" id="kb-search" placeholder="Поиск по вопросу..." oninput="filterKB()">
<select id="kb-category-filter" onchange="filterKB()">
<option value="">Все категории</option>
</select>
<select id="kb-active-filter" onchange="filterKB()">
<option value="">Все статусы</option>
<option value="true">Активные</option>
<option value="false">Неактивные</option>
</select>
</div>
<table class="kb-table">
<thead>
<tr>
<th>ID</th>
<th>Вопрос</th>
<th>Категория</th>
<th>Статус</th>
<th>Источник</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="kb-table-body">
<tr><td colspan="6" style="text-align:center;padding:30px;color:var(--text-muted);">Загрузка...</td></tr>
</tbody>
</table>
</div>
<div class="kb-modal" id="kb-modal">
<div class="kb-modal-content">
<h3 id="modal-title">Новая карточка</h3>
<form id="kb-form">
<div class="form-group">
<label>Категория</label>
<select name="category_id" id="kb-category-id"></select>
</div>
<div class="form-group">
<label>Вопрос *</label>
<input type="text" name="question" id="kb-question" required>
</div>
<div class="form-group">
<label>Ответ (оставьте пустым, если нет ответа)</label>
<textarea name="answer" id="kb-answer"></textarea>
</div>
<div class="form-group">
<label>Ключевые слова (через запятую)</label>
<input type="text" name="keywords" id="kb-keywords" placeholder="слово1, слово2, слово3">
</div>
<div class="form-group">
<label>URL источника</label>
<input type="url" name="source_url" id="kb-source-url" placeholder="https://aegisone.ru/...">
</div>
<div class="form-group">
<label><input type="checkbox" name="is_active" id="kb-is-active" checked> Активная карточка</label>
</div>
<div class="modal-actions">
<button type="submit" class="btn btn-primary">Сохранить</button>
<button type="button" class="btn" onclick="closeModal()">Отмена</button>
</div>
</form>
</div>
</div>
<script>
var categories = {};
var allCards = [];
fetch('/service/api/bot/settings').then(function(r){ return r.json() }).then(function(d){});
function loadKB(){
fetch('/api/bot/kb').then(function(r){ return r.json() }).then(function(data){
allCards = data;
renderKB(data);
});
}
function renderKB(cards){
var tbody = document.getElementById('kb-table-body');
if(!cards.length){
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;padding:30px;color:var(--text-muted);">Нет карточек</td></tr>';
return;
}
var html = '';
cards.forEach(function(c){
var catName = categories[c.category_id] || 'Без категории';
var activeClass = c.is_active ? 'yes' : 'no';
var activeLabel = c.is_active ? 'Активна' : 'Неактивна';
var answerPreview = c.answer ? c.answer.substring(0, 50) + '...' : '<em style="color:var(--text-muted)">Нет ответа</em>';
html += '<tr>' +
'<td>' + c.id + '</td>' +
'<td><strong>' + escapeHtml(c.question) + '</strong><br><small style="color:var(--text-muted)">' + answerPreview + '</small></td>' +
'<td>' + catName + '</td>' +
'<td><span class="active-badge ' + activeClass + '">' + activeLabel + '</span></td>' +
'<td style="font-size:12px;">' + (c.source_url ? '<a href="' + c.source_url + '" target="_blank">ссылка</a>' : '—') + '</td>' +
'<td><button class="btn btn-sm" onclick="editCard(' + c.id + ')" title="Редактировать">✏️</button> <button class="btn btn-sm" onclick="deleteCard(' + c.id + ')" style="color:#dc3545;" title="Удалить">🗑</button></td>' +
'</tr>';
});
tbody.innerHTML = html;
}
function filterKB(){
var search = document.getElementById('kb-search').value.toLowerCase();
var catFilter = document.getElementById('kb-category-filter').value;
var activeFilter = document.getElementById('kb-active-filter').value;
var filtered = allCards.filter(function(c){
if(search && !c.question.toLowerCase().includes(search)) return false;
if(catFilter && String(c.category_id) !== catFilter) return false;
if(activeFilter !== '' && String(c.is_active) !== activeFilter) return false;
return true;
});
renderKB(filtered);
}
function openModal(){
editingId = null;
document.getElementById('kb-modal').classList.add('open');
document.getElementById('modal-title').textContent = 'Новая карточка';
document.getElementById('kb-form').reset();
document.getElementById('kb-is-active').checked = true;
}
function closeModal(){
document.getElementById('kb-modal').classList.remove('open');
}
var editingId = null;
function editCard(id){
var card = allCards.find(function(c){ return c.id === id; });
if(!card) return;
editingId = id;
document.getElementById('modal-title').textContent = 'Редактировать карточку #' + id;
document.getElementById('kb-question').value = card.question || '';
document.getElementById('kb-answer').value = card.answer || '';
document.getElementById('kb-keywords').value = (card.keywords || []).join(', ');
document.getElementById('kb-source-url').value = card.source_url || '';
document.getElementById('kb-is-active').checked = card.is_active;
document.getElementById('kb-category-id').value = card.category_id || '';
document.getElementById('kb-modal').classList.add('open');
}
function deleteCard(id){
if(!confirm('Удалить карточку?')) return;
fetch('/api/bot/kb/' + id, { method: 'DELETE' }).then(function(r){ return r.json() }).then(function(d){
if(d.ok) { showNotification('Карточка удалена', 'success'); loadKB(); }
else showNotification('Ошибка удаления', 'error');
});
}
document.getElementById('kb-form').addEventListener('submit', function(e){
e.preventDefault();
var data = {
category_id: parseInt(document.getElementById('kb-category-id').value) || null,
question: document.getElementById('kb-question').value,
answer: document.getElementById('kb-answer').value || null,
keywords: document.getElementById('kb-keywords').value.split(',').map(function(s){ return s.trim(); }).filter(Boolean),
source_url: document.getElementById('kb-source-url').value || null,
is_active: document.getElementById('kb-is-active').checked,
sort_order: 0,
};
var url = '/api/bot/kb';
var method = 'POST';
if(editingId) { url += '/' + editingId; method = 'PUT'; }
fetch(url, {
method: method, headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
}).then(function(r){ return r.json() }).then(function(d){
if(d.ok) { closeModal(); loadKB(); showNotification(editingId ? 'Карточка обновлена' : 'Карточка создана', 'success'); }
else showNotification('Ошибка сохранения', 'error');
});
});
function escapeHtml(s){
if(!s) return '';
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// Load categories
fetch('/api/bot/kb').then(function(r){ return r.json() }).then(function(data){
// Extract unique categories
var catSet = {};
data.forEach(function(c){
if(c.category_id) catSet[c.category_id] = true;
});
var catSelect = document.getElementById('kb-category-id');
var catFilter = document.getElementById('kb-category-filter');
var catOptions = {
1: 'О компании', 2: 'Аудит безопасности', 3: 'Сервисное обслуживание (SLA)',
4: 'Реагирование на инциденты', 5: 'Технический надзор',
6: 'Документация и нормативы', 7: 'Риск-инжиниринг', 8: 'Отрасли и кейсы'
};
categories = catOptions;
Object.keys(catOptions).forEach(function(id){
var opt1 = document.createElement('option');
opt1.value = id; opt1.textContent = catOptions[id];
catSelect.appendChild(opt1);
var opt2 = document.createElement('option');
opt2.value = id; opt2.textContent = catOptions[id];
catFilter.appendChild(opt2);
});
});
loadKB();
</script>
{% endblock %}
@@ -0,0 +1,122 @@
{% extends "page.html" %}
{% block page_content %}
<style>
.tab-bar { display:flex; gap:0; margin-bottom:20px; border-bottom:2px solid var(--border); }
.tab-bar .tab { padding:10px 20px; cursor:pointer; border-bottom:2px solid transparent; margin-bottom:-2px; color:var(--text-muted); font-weight:500; }
.tab-bar .tab.active { color:var(--accent); border-bottom-color:var(--accent); }
.tab-content { display:none; }
.tab-content.active { display:block; }
.bot-settings-grid { display:grid; grid-template-columns:1fr 1fr; gap:16px; }
.bot-settings-grid .full-width { grid-column:1/-1; }
.form-group { margin-bottom:16px; }
.form-group label { display:block; font-weight:600; margin-bottom:6px; font-size:13px; color:var(--text-muted); }
.form-group input, .form-group textarea, .form-group select {
width:100%; padding:10px 14px; border:1px solid var(--border);
border-radius:8px; font-size:14px; background:var(--bg-input); color:var(--text-primary);
transition:border-color 0.2s;
}
.form-group input:focus, .form-group textarea:focus {
outline:none; border-color:var(--accent); box-shadow:0 0 0 3px rgba(0,150,200,0.1);
}
.form-group textarea { min-height:100px; resize:vertical; }
.form-actions { grid-column:1/-1; display:flex; gap:12px; margin-top:8px; }
.form-actions .btn { min-width:160px; }
.hint { font-size:12px; color:var(--text-muted); margin-top:4px; }
.test-iframe { width:100%; height:650px; border:none; border-radius:8px; }
</style>
<div class="card">
<div class="card-header"><h2>Настройки чат-бота MAX — «София»</h2></div>
<div class="tab-bar">
<div class="tab active" onclick="switchTab('main')">Основные</div>
<div class="tab" onclick="switchTab('test')">Тестовый прогон</div>
</div>
<div class="tab-content active" id="tab-main">
<form id="bot-settings-form">
<div class="bot-settings-grid">
<div class="form-group full-width">
<label>Имя ассистента</label>
<input type="text" name="assistant_name" id="assistant_name" placeholder="Например: София">
</div>
<div class="form-group full-width">
<label>Роль ассистента</label>
<textarea name="assistant_role" id="assistant_role" placeholder="Описание роли бота..."></textarea>
<div class="hint">Будет использована для формирования системного промпта YandexGPT</div>
</div>
<div class="form-group">
<label>MAX-бот ID</label>
<input type="text" name="max_bot_id" id="max_bot_id" placeholder="Токен бота">
<div class="hint">Токен чат-бота из платформы MAX</div>
</div>
<div class="form-group">
<label>Email поддержки</label>
<input type="email" name="support_email" id="support_email" placeholder="zakaz@aegisone.ru">
<div class="hint">На этот email приходят уведомления о новых заявках</div>
</div>
<div class="form-group full-width">
<label>Тема письма</label>
<input type="text" name="email_subject" id="email_subject" placeholder="Обращение через Виртуального помощника">
<div class="hint">Тема email-уведомления об эскалации</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Сохранить настройки</button>
</div>
</div>
</form>
</div>
<div class="tab-content" id="tab-test">
<p style="margin-bottom:12px;color:var(--text-muted);font-size:13px;">Симуляция диалога с ботом. Отправляет запросы напрямую на вебхук бота.</p>
<iframe src="/service/bot-test-content" class="test-iframe" id="test-iframe"></iframe>
</div>
</div>
<div class="card" style="margin-top:20px;">
<div class="card-header"><h2>Общие настройки портала</h2></div>
<div class="hint" style="margin-bottom:16px;">
Yandex GPT и Yandex Disk настраиваются в разделе
<a href="/service/portal-settings">Настройка портала</a>.
Бот использует эти данные автоматически.
</div>
</div>
<script>
function switchTab(name){
document.querySelectorAll('.tab-content').forEach(function(t){ t.classList.remove('active'); });
document.querySelectorAll('.tab-bar .tab').forEach(function(t){ t.classList.remove('active'); });
document.getElementById('tab-' + name).classList.add('active');
document.querySelector('.tab-bar .tab[onclick*="' + name + '"]').classList.add('active');
if(name === 'test'){
document.getElementById('test-iframe').src = '/service/bot-test-content';
}
}
document.getElementById('bot-settings-form').addEventListener('submit', function(e){
e.preventDefault();
var data = {};
this.querySelectorAll('input, textarea').forEach(function(el){
if(el.name) data[el.name] = el.value;
});
fetch('/service/api/bot/settings/save', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
}).then(function(r){ return r.json() }).then(function(d){
if(d.ok) showNotification('Настройки сохранены', 'success');
else showNotification('Ошибка: ' + (d.error || 'сохранения'), 'error');
}).catch(function(){
showNotification('Ошибка соединения', 'error');
});
});
fetch('/service/api/bot/settings').then(function(r){ return r.json() }).then(function(d){
['assistant_name','assistant_role','support_email','email_subject','max_bot_id'].forEach(function(k){
var el = document.getElementById(k);
if(el) el.value = d[k] || '';
});
if(document.getElementById('max_bot_id') && !document.getElementById('max_bot_id').value){
document.getElementById('max_bot_id').value = 'f9LHodD0cOKkk03DYXj906MdIeRs...';
}
});
</script>
{% endblock %}
@@ -0,0 +1,139 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:'Inter','Segoe UI',sans-serif; background:#1B2838; color:#E8EDF2; font-size:13px; height:100vh; display:flex; flex-direction:column; }
.test-container { display:flex; flex:1; gap:12px; padding:12px; overflow:hidden; }
.test-chat { flex:1; border:1px solid #253248; border-radius:10px; display:flex; flex-direction:column; overflow:hidden; }
.test-chat-header { background:#0A1628; padding:10px 14px; font-weight:600; border-bottom:1px solid #253248; font-size:12px; }
.test-chat-messages { flex:1; overflow-y:auto; padding:10px 14px; }
.test-chat-input { display:flex; border-top:1px solid #253248; }
.test-chat-input input { flex:1; border:none; padding:10px 14px; font-size:13px; outline:none; background:#1B2838; color:#E8EDF2; }
.test-chat-input button { padding:10px 16px; border:none; background:#00ADEF; color:#fff; cursor:pointer; font-weight:600; }
.test-log { width:300px; border:1px solid #253248; border-radius:10px; padding:10px; overflow-y:auto; font-family:monospace; font-size:11px; line-height:1.5; background:#0A1628; }
.test-log .log-entry { margin-bottom:4px; padding:3px 0; border-bottom:1px solid #1e3044; }
.test-log .log-entry .time { color:#6B8299; }
.test-log .log-entry .event { color:#00ADEF; font-weight:600; }
.msg-bot { background:#1e3044; padding:7px 12px; border-radius:8px 8px 8px 0; margin-bottom:6px; max-width:85%; }
.msg-user { background:#00ADEF; color:#fff; padding:7px 12px; border-radius:8px 8px 0 8px; margin-bottom:6px; max-width:85%; margin-left:auto; }
.test-scenarios { display:flex; gap:6px; padding:8px 12px; flex-wrap:wrap; border-bottom:1px solid #253248; }
.test-scenarios button { font-size:11px; padding:4px 10px; background:#253248; color:#E8EDF2; border:1px solid #3a4a60; border-radius:4px; cursor:pointer; }
.test-scenarios button:hover { background:#00ADEF; color:#fff; }
</style>
</head>
<body>
<div class="test-scenarios">
<button onclick="runScenario('greeting')">Приветствие</button>
<button onclick="runScenario('consent_yes')">Согласие → заявка</button>
<button onclick="runScenario('consent_no')">Отказ согласия</button>
<button onclick="runScenario('question')">Общий вопрос</button>
<button onclick="clearTest()">Очистить</button>
</div>
<div class="test-container">
<div class="test-chat">
<div class="test-chat-header">Чат с ботом (симуляция)</div>
<div class="test-chat-messages" id="test-messages"></div>
<div class="test-chat-input">
<input type="text" id="test-input" placeholder="Введите сообщение..." onkeypress="if(event.key==='Enter') sendTestMessage()">
<button onclick="sendTestMessage()">Отправить</button>
</div>
</div>
<div class="test-log" id="test-log"></div>
</div>
<script>
var testUserId = 999999001;
function log(event, detail){
var logDiv = document.getElementById('test-log');
var time = new Date().toLocaleTimeString();
var entry = document.createElement('div');
entry.className = 'log-entry';
entry.innerHTML = '<span class="time">[' + time + ']</span> <span class="event">' + event + '</span> ' + (detail || '');
logDiv.appendChild(entry);
logDiv.scrollTop = logDiv.scrollHeight;
}
function addMessage(text, from){
var msgs = document.getElementById('test-messages');
var div = document.createElement('div');
div.className = from === 'bot' ? 'msg-bot' : 'msg-user';
div.textContent = text;
msgs.appendChild(div);
msgs.scrollTop = msgs.scrollHeight;
}
function sendTestMessage(){
var input = document.getElementById('test-input');
var text = input.value.trim();
if(!text) return;
input.value = '';
addMessage(text, 'user');
log('message_created', 'Пользователь: ' + text);
simulateWebhook('message_created', text);
}
function runScenario(scenario){
clearTest();
switch(scenario){
case 'greeting':
log('bot_started', 'Пользователь начал диалог');
simulateWebhook('bot_started', null);
break;
case 'consent_yes':
log('bot_started', 'Пользователь начал диалог');
simulateWebhook('bot_started', null);
setTimeout(function(){
addMessage('Здравствуйте! Я хочу оставить заявку.', 'user');
log('message_created', 'Пользователь: хочу оставить заявку');
simulateWebhook('message_created', 'Здравствуйте! Я хочу оставить заявку.');
}, 1000);
break;
case 'consent_no':
log('bot_started', 'Пользователь начал диалог');
simulateWebhook('bot_started', null);
break;
case 'question':
log('bot_started', 'Пользователь начал диалог');
simulateWebhook('bot_started', null);
setTimeout(function(){
addMessage('Какие услуги вы предоставляете?', 'user');
log('message_created', 'Пользователь: какие услуги?');
simulateWebhook('message_created', 'Какие услуги вы предоставляете?');
}, 1000);
break;
}
}
function simulateWebhook(type, text){
var payload = {update_type: type};
if(type === 'bot_started'){
payload.user = {user_id: testUserId, first_name: 'Тест', last_name: 'Пользователь'};
payload.chat_id = testUserId;
} else if(type === 'message_created'){
payload.sender = {user_id: testUserId};
payload.chat_id = testUserId;
payload.body = {text: text, attachments: []};
}
log('webhook', 'Отправка на /webhook (' + type + ')');
fetch('/api/bot/webhook', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Max-Bot-Api-Secret': 'aegisone_bot_secret_2026',
},
body: JSON.stringify(payload)
}).then(function(r){ return r.json() }).then(function(d){
log('response', JSON.stringify(d));
if(d && d.ok) log('success', 'Событие обработано');
else log('error', 'Ошибка: ' + JSON.stringify(d));
}).catch(function(err){
log('error', 'Сетевая ошибка: ' + err);
});
}
function clearTest(){
document.getElementById('test-messages').innerHTML = '';
document.getElementById('test-log').innerHTML = '';
document.getElementById('test-input').value = '';
log('info', 'Тест очищен');
}
</script>
</body>
</html>
@@ -0,0 +1,179 @@
{% extends "page.html" %}
{% block page_content %}
<style>
.tickets-table { width:100%; border-collapse:collapse; }
.tickets-table th, .tickets-table td { padding:10px 14px; text-align:left; border-bottom:1px solid var(--border); font-size:13px; }
.tickets-table th { font-weight:600; color:var(--text-muted); background:var(--light-bg); }
.tickets-table td { vertical-align:middle; }
.status-badge { display:inline-block; padding:3px 10px; border-radius:12px; font-size:12px; font-weight:600; }
.status-nova { background:#cce5ff; color:#004085; }
.status-v-rabote { background:#fff3cd; color:#856404; }
.status-zakryta { background:#d4edda; color:#155724; }
.status-select { padding:4px 8px; border-radius:6px; border:1px solid var(--border); font-size:12px; }
.ticket-detail-card { background:var(--bg-card); border:1px solid var(--border); border-radius:12px; padding:20px; margin-top:16px; display:none; }
.ticket-detail-card.open { display:block; }
.ticket-detail-card h3 { margin-bottom:12px; }
.ticket-detail-card .field { margin-bottom:8px; }
.ticket-detail-card .field label { font-weight:600; font-size:12px; color:var(--text-muted); display:block; }
.ticket-detail-card .field .value { font-size:14px; }
.filter-bar { display:flex; gap:12px; margin-bottom:16px; align-items:center; flex-wrap:wrap; }
.filter-bar select, .filter-bar input { padding:8px 12px; border:1px solid var(--border); border-radius:6px; font-size:13px; }
</style>
<div class="card">
<div class="card-header" style="display:flex;justify-content:space-between;align-items:center;">
<h2>Заявки от чат-бота</h2>
<span id="ticket-count" style="font-size:13px;color:var(--text-muted);">0 заявок</span>
</div>
<div class="filter-bar">
<select id="status-filter" onchange="filterTickets()">
<option value="">Все статусы</option>
<option value="Новая">Новая</option>
<option value="В работе">В работе</option>
<option value="Закрыта">Закрыта</option>
</select>
<input type="text" id="ticket-search" placeholder="Поиск..." oninput="filterTickets()">
<button class="btn btn-sm" onclick="loadTickets()">Обновить</button>
</div>
<table class="tickets-table">
<thead>
<tr>
<th>ID</th>
<th>Заголовок</th>
<th>Приоритет</th>
<th>Статус</th>
<th>Дата</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="tickets-body">
<tr><td colspan="6" style="text-align:center;padding:30px;color:var(--text-muted);">Загрузка...</td></tr>
</tbody>
</table>
</div>
<div class="ticket-detail-card" id="ticket-detail">
<h3 id="ticket-detail-title">Заявка #</h3>
<div class="field"><label>Описание</label><div class="value" id="ticket-detail-desc"></div></div>
<div class="field"><label>Приоритет</label><div class="value" id="ticket-detail-priority"></div></div>
<div class="field"><label>Статус</label>
<select id="ticket-detail-status" onchange="updateTicketStatus()">
<option value="Новая">Новая</option>
<option value="В работе">В работе</option>
<option value="Закрыта">Закрыта</option>
</select>
</div>
<div class="field"><label>Дата</label><div class="value" id="ticket-detail-date"></div></div>
<button class="btn" onclick="closeDetail()">Закрыть</button>
</div>
<script>
var allTickets = [];
var currentTicketId = null;
function loadTickets(){
var tbody = document.getElementById('tickets-body');
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;padding:30px;color:var(--text-muted);">Загрузка...</td></tr>';
fetch('/api/bot/tickets').then(function(r){ return r.json() }).then(function(data){
allTickets = data;
document.getElementById('ticket-count').textContent = data.length + ' заявок';
renderTickets(data);
});
}
function renderTickets(tickets){
var tbody = document.getElementById('tickets-body');
if(!tickets.length){
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;padding:30px;color:var(--text-muted);">Нет заявок</td></tr>';
return;
}
var html = '';
tickets.forEach(function(t){
var statusClass = 'status-nova';
if(t.status === 'В работе') statusClass = 'status-v-rabote';
else if(t.status === 'Закрыта') statusClass = 'status-zakryta';
var dateStr = t.created_at ? new Date(t.created_at).toLocaleString() : '';
html += '<tr>' +
'<td>' + t.id + '</td>' +
'<td><a href="javascript:void(0)" onclick="showDetail(' + t.id + ')">' + escapeHtml(t.title) + '</a></td>' +
'<td>' + t.priority + '</td>' +
'<td><span class="status-badge ' + statusClass + '">' + t.status + '</span></td>' +
'<td style="font-size:12px;color:var(--text-muted);">' + dateStr + '</td>' +
'<td>' +
' <select class="status-select" onchange="changeStatus(' + t.id + ', this.value)">' +
' <option value="">Сменить статус</option>' +
' <option value="Новая">Новая</option>' +
' <option value="В работе">В работе</option>' +
' <option value="Закрыта">Закрыта</option>' +
' </select>' +
'</td>' +
'</tr>';
});
tbody.innerHTML = html;
}
function filterTickets(){
var statusFilter = document.getElementById('status-filter').value;
var search = document.getElementById('ticket-search').value.toLowerCase();
var filtered = allTickets.filter(function(t){
if(statusFilter && t.status !== statusFilter) return false;
if(search && !t.title.toLowerCase().includes(search) && !(t.description || '').toLowerCase().includes(search)) return false;
return true;
});
renderTickets(filtered);
}
function showDetail(id){
currentTicketId = id;
var ticket = allTickets.find(function(t){ return t.id === id; });
if(!ticket) return;
document.getElementById('ticket-detail-title').textContent = 'Заявка #' + ticket.id;
document.getElementById('ticket-detail-desc').textContent = ticket.description || '—';
document.getElementById('ticket-detail-priority').textContent = ticket.priority;
document.getElementById('ticket-detail-status').value = ticket.status;
document.getElementById('ticket-detail-date').textContent = ticket.created_at ? new Date(ticket.created_at).toLocaleString() : '—';
document.getElementById('ticket-detail').classList.add('open');
}
function closeDetail(){
document.getElementById('ticket-detail').classList.remove('open');
currentTicketId = null;
}
function changeStatus(id, status){
if(!status) return;
updateTicketStatusApi(id, status);
}
function updateTicketStatus(){
if(currentTicketId){
var status = document.getElementById('ticket-detail-status').value;
updateTicketStatusApi(currentTicketId, status);
}
}
function updateTicketStatusApi(id, status){
fetch('/api/bot/tickets/' + id, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({status: status})
}).then(function(r){ return r.json() }).then(function(d){
if(d.ok){
loadTickets();
if(currentTicketId === id) showDetail(id);
} else {
showNotification('Ошибка: ' + (d.error || 'неизвестная'), 'error');
}
});
}
function escapeHtml(s){
if(!s) return '';
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
loadTickets();
</script>
{% endblock %}
@@ -0,0 +1,281 @@
{% extends "page.html" %}
{% block page_content %}
<style>
.tab-bar { display:flex; gap:0; margin-bottom:24px; border-bottom:2px solid var(--border); }
.tab-bar .tab { padding:10px 20px; cursor:pointer; border-bottom:2px solid transparent;
margin-bottom:-2px; color:var(--text-muted); font-weight:500; }
.tab-bar .tab.active { color:var(--accent); border-bottom-color:var(--accent); }
.tab-content { display:none; }
.tab-content.active { display:block; }
.formula-test-panel { margin-top: 16px; }
.formula-description {
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
margin-bottom: 12px;
}
.formula-code {
background: rgba(0,0,0,0.3);
padding: 12px;
border-radius: 8px;
font-family: 'Consolas', 'Courier New', monospace;
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
color: var(--accent);
}
.formula-inputs {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.formula-result {
margin-top: 12px;
padding: 12px 16px;
border-radius: var(--radius);
font-weight: 600;
font-size: 14px;
}
.formula-result-success {
background: rgba(34, 197, 94, 0.1);
border: 1px solid var(--success);
color: var(--success);
}
.formula-result-error {
background: rgba(239, 68, 68, 0.1);
border: 1px solid var(--danger);
color: var(--danger);
}
.accordion { margin-bottom:8px; }
.accordion-header {
display:flex; justify-content:space-between; align-items:center;
padding:10px 14px; background:var(--bg-input); border:1px solid var(--border);
border-radius:var(--radius); cursor:pointer; user-select:none;
}
.accordion-header:hover { background:var(--bg-secondary); }
.accordion-header-left { display:flex; align-items:center; gap:8px; }
.accordion-arrow { font-size:10px; color:var(--text-muted); width:12px; }
.accordion-title { font-weight:600; font-size:13px; }
.accordion-badge {
font-size:11px; background:var(--accent); color:#fff;
border-radius:10px; padding:1px 8px;
}
.accordion-header-right { display:flex; align-items:center; gap:8px; }
.accordion-formula, .accordion-action {
cursor:pointer; font-size:16px; opacity:0.7;
}
.accordion-formula:hover, .accordion-action:hover { opacity:1; }
.accordion-body { display:none; padding:12px 0 4px; }
</style>
<div class="card">
<div class="card-header"><h2>Формулы</h2></div>
<div class="tab-bar">
<div class="tab active" onclick="switchTab('test')">Тест формул</div>
<div class="tab" onclick="switchTab('coeffs')">Коэффициенты</div>
</div>
<div class="tab-content active" id="tab-test">
<div class="form-group">
<label>Выберите формулу для тестирования</label>
<select id="formula-select" onchange="switchFormula(this.value)" style="max-width:400px">
{% for f in formulas %}
<option value="{{ f.key }}" {% if f.key == selected %}selected{% endif %}>{{ f.name }}</option>
{% endfor %}
</select>
</div>
{% for f in formulas %}
<div id="formula-{{ f.key }}" class="formula-test-panel" {% if f.key != selected %}style="display:none"{% endif %}>
<div class="formula-description">
<div style="margin-bottom:8px"><strong>Формула:</strong></div>
<div class="formula-code">{{ f.formula }}</div>
<div style="margin-top:8px"><strong>Результат:</strong> {{ f.result }}</div>
</div>
{% if f.sandbox %}
<form class="formula-sandbox-form" onsubmit="return testFormula(event, '{{ f.key }}')">
<h3 style="margin:16px 0 12px;font-size:14px;">Параметры расчёта</h3>
<div class="formula-inputs">
{% for inp in f.sandbox.inputs %}
<div class="form-group">
<label>{{ inp.label }}</label>
{% if inp.type == "checkbox" %}
<input type="checkbox" name="{{ inp.key }}" value="1" style="width:20px;height:20px;accent-color:var(--accent)">
{% elif inp.type == "select" %}
<select name="{{ inp.key }}">
{% for ok, ol in inp.options.items() %}
<option value="{{ ok }}" {% if ok == inp.default %}selected{% endif %}>{{ ol }}</option>
{% endfor %}
</select>
{% else %}
<input type="number" name="{{ inp.key }}" value="{{ inp.default|default(0) }}" step="any">
{% endif %}
</div>
{% endfor %}
</div>
<button type="submit" class="btn btn-primary">Рассчитать</button>
<div class="formula-result" id="result-{{ f.key }}" style="display:none"></div>
</form>
{% else %}
<div class="empty-state" style="padding:24px 0">
<p>Для этой формулы пока нет тестовой песочницы.</p>
</div>
{% endif %}
<h3 style="margin:20px 0 10px;font-size:13px;color:var(--text-muted)">Связанные коэффициенты</h3>
<div class="table-wrap"><table><thead><tr><th>Ключ</th><th>Значение</th><th>Описание</th></tr></thead>
<tbody>{% for c in f.coeffs %}<tr>
<td><code>{{ c.key }}</code></td>
<td><strong>{{ c.value }}</strong></td>
<td><small>{{ c.description }}</small></td>
</tr>{% endfor %}</tbody></table>
</div>
</div>
{% endfor %}
</div>
<div class="tab-content" id="tab-coeffs">
{% if groups|length == 0 %}
<div class="card-body"><p class="empty">Нет коэффициентов</p></div>
{% else %}
{% for gkey, gdata in groups.items() %}
<div class="accordion">
<div class="accordion-header" onclick="toggleAccordion(this)">
<div class="accordion-header-left">
<span class="accordion-arrow"></span>
<span class="accordion-title">{{ gdata.info.name }}</span>
<span class="accordion-badge">{{ gdata.coeffs|length }}</span>
</div>
<div class="accordion-header-right">
<span class="accordion-formula" title="Формула" onclick="event.stopPropagation(); showFormulaPopover(this, '{{ gkey }}')" data-formula="{{ gdata.info.formula }}" data-result="{{ gdata.info.result }}">📐</span>
</div>
</div>
<div class="accordion-body">
<div class="table-wrap"><table><thead><tr><th>Ключ</th><th>Значение</th><th>Описание</th><th>Формула</th><th>Активен</th><th class="actions"></th></tr></thead>
<tbody>{% for c in gdata.coeffs %}<tr>
<td><code>{{ c.key }}</code></td>
<td><strong>{{ c.value }}</strong></td>
<td><small>{{ c.description }}</small></td>
<td><small>{{ c.formula_ref }}</small></td>
<td><form method="post" action="/service/coefficients/toggle"><input type="hidden" name="id" value="{{ c.id }}"><button class="btn btn-{{ 'success' if c.is_active else 'secondary' }} btn-sm">{{ '✓' if c.is_active else '✕' }}</button></form></td>
<td class="actions"><button class="btn btn-secondary btn-sm" onclick="editCoeff({{ c.id }},'{{ c.key|e }}','{{ c.value }}','{{ c.description|e }}','{{ c.formula_ref|e }}')"></button>
<form method="post" action="/service/coefficients/delete" style="display:inline" onsubmit="return confirm('Удалить коэффициент?')"><input type="hidden" name="id" value="{{ c.id }}"><button class="btn btn-danger btn-sm"></button></form>
</td>
</tr>{% endfor %}</tbody></table>
</div>
</div>
</div>
{% endfor %}
<div style="margin-top:16px;">
<button class="btn btn-primary btn-sm" onclick="document.getElementById('modal-coeff').classList.add('open')">+ Добавить коэффициент</button>
</div>
{% endif %}
</div>
</div>
<!-- Coefficient edit modal -->
<div class="modal-overlay" id="modal-coeff"><div class="modal">
<button type="button" class="modal-close" onclick="this.closest('.modal-overlay').classList.remove('open')"></button>
<h2 id="modal-coeff-title">Новый коэффициент</h2>
<form method="post" id="modal-coeff-form" action="/service/coefficients/create">
<input type="hidden" name="id" id="coeff-id">
<div class="form-row">
<div class="form-group"><label>Ключ</label><input type="text" name="key" id="coeff-key" required></div>
<div class="form-group"><label>Значение</label><input type="text" name="value" id="coeff-value" required></div>
</div>
<div class="form-group"><label>Описание</label><input type="text" name="description" id="coeff-description"></div>
<div class="form-group"><label>Формула</label><input type="text" name="formula_ref" id="coeff-formula_ref"></div>
<div class="form-actions"><button type="submit" class="btn btn-primary">Сохранить</button>
<button type="button" class="btn btn-secondary" onclick="this.closest('.modal-overlay').classList.remove('open')">Отмена</button></div>
</form>
</div></div>
<!-- Formula info modal -->
<div class="modal-overlay" id="modal-formula-info"><div class="modal modal-sm">
<button type="button" class="modal-close" onclick="document.getElementById('modal-formula-info').classList.remove('open')"></button>
<h2 id="formula-info-title">Формула</h2>
<div id="formula-info-content" style="margin-top:12px"></div>
</div></div>
<script>
function switchTab(name){
document.querySelectorAll('.tab-content').forEach(function(t){ t.classList.remove('active'); });
document.querySelectorAll('.tab').forEach(function(t){ t.classList.remove('active'); });
document.getElementById('tab-' + name).classList.add('active');
document.querySelector('.tab[onclick*="' + name + '"]').classList.add('active');
}
function switchFormula(key) {
document.querySelectorAll('.formula-test-panel').forEach(function(el) {
el.style.display = 'none';
});
var panel = document.getElementById('formula-' + key);
if (panel) panel.style.display = 'block';
}
function testFormula(event, key) {
event.preventDefault();
var form = event.target;
var data = new URLSearchParams(new FormData(form));
var resultBox = document.getElementById('result-' + key);
resultBox.style.display = 'block';
resultBox.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
resultBox.className = 'formula-result';
fetch('/service/coefficients/test/' + key, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: data.toString()
}).then(function(r) { return r.json() }).then(function(resp) {
resultBox.textContent = 'Результат: ' + (resp.result || 'Ошибка');
resultBox.className = 'formula-result formula-result-success';
}).catch(function(err) {
resultBox.textContent = 'Ошибка: ' + err.message;
resultBox.className = 'formula-result formula-result-error';
});
return false;
}
function toggleAccordion(el) {
var arrow = el.querySelector('.accordion-arrow');
var body = el.nextElementSibling;
if (body.style.display === 'none' || body.style.display === '') {
body.style.display = 'block';
arrow.textContent = '▼';
} else {
body.style.display = 'none';
arrow.textContent = '▶';
}
}
function editCoeff(id,key,value,desc,ref){
document.getElementById('modal-coeff').classList.add('open');
document.getElementById('modal-coeff-title').textContent = 'Редактировать коэффициент';
document.getElementById('modal-coeff-form').action = '/service/coefficients/edit';
document.getElementById('coeff-id').value = id;
document.getElementById('coeff-key').value = key;
document.getElementById('coeff-value').value = value;
document.getElementById('coeff-description').value = desc;
document.getElementById('coeff-formula_ref').value = ref;
}
function showFormulaPopover(el, gkey) {
var formula = el.getAttribute('data-formula');
var result = el.getAttribute('data-result');
var title = el.closest('.accordion-header').querySelector('.accordion-title').textContent;
document.getElementById('formula-info-title').textContent = title;
document.getElementById('formula-info-content').innerHTML =
'<div style="margin-bottom:12px"><strong>Формула:</strong></div>' +
'<div style="background:var(--bg-input);padding:12px;border-radius:8px;font-family:monospace;font-size:13px;margin-bottom:12px;white-space:pre-wrap">' + formula + '</div>' +
'<div><strong>Результат:</strong> ' + result + '</div>';
document.getElementById('modal-formula-info').classList.add('open');
}
// Open first accordion by default
document.addEventListener('DOMContentLoaded', function() {
var first = document.querySelector('.accordion-body');
if (first) { first.style.display = 'block'; first.previousElementSibling.querySelector('.accordion-arrow').textContent = '▼'; }
});
</script>
{% endblock %}
@@ -0,0 +1,75 @@
{% extends "page.html" %}
{% block page_content %}
<style>
.tab-bar { display:flex; gap:0; margin-bottom:24px; border-bottom:2px solid var(--border); }
.tab-bar .tab { padding:10px 20px; cursor:pointer; border-bottom:2px solid transparent;
margin-bottom:-2px; color:var(--text-muted); font-weight:500; }
.tab-bar .tab.active { color:var(--accent); border-bottom-color:var(--accent); }
.tab-content { display:none; }
.tab-content.active { display:block; }
</style>
<div class="card">
<div class="card-header"><h2>Настройка портала</h2></div>
<div class="tab-bar">
<div class="tab active" onclick="switchTab('general')">Общие</div>
<div class="tab" onclick="switchTab('documents')">Управление документами</div>
</div>
<div class="tab-content active" id="tab-general">
<form id="portal-settings-form" class="form-grid">
<h3 style="grid-column:1/-1;margin:0 0 8px;">Yandex GPT</h3>
<div class="form-group"><label>Ключ YandexGPT (пусто = ключевые слова)</label>
<input type="text" name="yandex_gpt_key" id="yandex_gpt_key" placeholder="Api-Key ..."></div>
<div class="form-group"><label>Yandex Folder ID</label>
<input type="text" name="yandex_folder_id" id="yandex_folder_id" placeholder="b1g..."></div>
<h3 style="grid-column:1/-1;margin:16px 0 8px;">Yandex Disk</h3>
<div class="form-group"><label>OAuth-токен Яндекс.Диска</label>
<input type="text" name="yandex_disk_token" id="yandex_disk_token"></div>
<div class="form-group"><label>Корневая папка</label>
<input type="text" name="yandex_disk_root" id="yandex_disk_root" placeholder="/AegisOne_Service"></div>
<div class="form-group"><label>Client ID</label>
<input type="text" name="yandex_disk_client_id" id="yandex_disk_client_id"></div>
<div class="form-group"><label>Client Secret</label>
<input type="text" name="yandex_disk_client_secret" id="yandex_disk_client_secret"></div>
<div class="form-actions" style="grid-column:1/-1;">
<button type="submit" class="btn btn-primary">Сохранить</button>
</div>
</form>
</div>
<div class="tab-content" id="tab-documents">
<p>Управление доступом к документам:</p>
<a href="/service/documents/admin/permissions" class="btn btn-primary">Перейти к управлению документами</a>
</div>
</div>
<script>
function switchTab(name){
document.querySelectorAll('.tab-content').forEach(function(t){ t.classList.remove('active'); });
document.querySelectorAll('.tab').forEach(function(t){ t.classList.remove('active'); });
document.getElementById('tab-' + name).classList.add('active');
document.querySelector('.tab[onclick*="' + name + '"]').classList.add('active');
}
document.getElementById('portal-settings-form').addEventListener('submit', function(e){
e.preventDefault();
var data = {};
this.querySelectorAll('input').forEach(function(el){
if(el.name) data[el.name] = el.value;
});
fetch('/service/api/bot/settings/save', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
}).then(function(r){ return r.json() }).then(function(d){
if(d.ok) showNotification('Настройки сохранены', 'success');
else showNotification('Ошибка сохранения', 'error');
});
});
fetch('/service/api/bot/settings').then(function(r){ return r.json() }).then(function(d){
['yandex_gpt_key','yandex_folder_id','yandex_disk_token','yandex_disk_root',
'yandex_disk_client_id','yandex_disk_client_secret'].forEach(function(k){
var el = document.getElementById(k);
if(el) el.value = d[k] || '';
});
});
</script>
{% endblock %}
@@ -0,0 +1,126 @@
{% extends "page.html" %}
{% block page_content %}
<style>
.tab-bar { display:flex; gap:0; margin-bottom:24px; border-bottom:2px solid var(--border); }
.tab-bar .tab { padding:10px 20px; cursor:pointer; border-bottom:2px solid transparent;
margin-bottom:-2px; color:var(--text-muted); font-weight:500; }
.tab-bar .tab.active { color:var(--accent); border-bottom-color:var(--accent); }
.tab-content { display:none; }
.tab-content.active { display:block; }
.menu-group { margin-bottom:16px; }
.menu-group h4 { margin:0 0 8px; color:var(--text-muted); font-size:13px; text-transform:uppercase; }
.menu-item { display:flex; align-items:center; gap:8px; padding:8px 12px;
border:1px solid var(--border); border-radius:6px; margin-bottom:4px; cursor:pointer; }
.menu-item:hover { background:var(--bg-secondary); }
.menu-item input[type="checkbox"] { width:18px; height:18px; cursor:pointer; }
.menu-item .label { flex:1; }
</style>
<div class="card">
<div class="card-header"><h2>Настройка Ролей</h2></div>
<div class="tab-bar">
<div class="tab active" onclick="switchTab('engineer')">Инженер</div>
<div class="tab" onclick="switchTab('technician')">Техник</div>
</div>
<div class="tab-content active" id="tab-engineer">
<form id="role-form-engineer" class="menu-group">
<p style="margin:0 0 12px;color:var(--text-muted);">Отметьте пункты меню, доступные инженеру:</p>
<div id="menu-engineer"></div>
<div class="form-actions" style="margin-top:16px;">
<button type="submit" class="btn btn-primary">Сохранить</button>
</div>
</form>
</div>
<div class="tab-content" id="tab-technician">
<form id="role-form-technician" class="menu-group">
<p style="margin:0 0 12px;color:var(--text-muted);">Отметьте пункты меню, доступные технику:</p>
<div id="menu-technician"></div>
<div class="form-actions" style="margin-top:16px;">
<button type="submit" class="btn btn-primary">Сохранить</button>
</div>
</form>
</div>
</div>
<script>
var MENU_ITEMS = [
// Панель
{key: 'charts', label: 'Графики', section: 'Панель'},
{key: 'ceo', label: 'CEO дашборд', section: 'Панель'},
// Управление
{key: 'customers', label: 'Клиенты', section: 'Управление'},
{key: 'objects', label: 'Объекты', section: 'Управление'},
{key: 'sla', label: 'SLA контракты', section: 'Управление'},
{key: 'questionnaire', label: 'Опросник', section: 'Управление'},
{key: 'passports', label: 'Паспорта объектов', section: 'Управление'},
// Работа
{key: 'users', label: 'Сотрудники', section: 'Работа'},
{key: 'assignments', label: 'Назначение сотрудников', section: 'Работа'},
{key: 'docs', label: 'Просмотр документации', section: 'Работа'},
{key: 'tasks', label: 'Задачи', section: 'Работа'},
{key: 'reports', label: 'Отчёты', section: 'Работа'},
{key: 'incidents', label: 'Инциденты', section: 'Работа'},
{key: 'checklist', label: 'Чек-лист', section: 'Работа'},
{key: 'tech_access', label: 'Доступ техников к документам', section: 'Работа'},
// Контент
{key: 'blog', label: 'Управление блогом', section: 'Контент'},
{key: 'cases', label: 'Примеры из практики', section: 'Контент'},
];
function switchTab(name){
document.querySelectorAll('.tab-content').forEach(function(t){ t.classList.remove('active'); });
document.querySelectorAll('.tab').forEach(function(t){ t.classList.remove('active'); });
document.getElementById('tab-' + name).classList.add('active');
document.querySelector('.tab[onclick*="' + name + '"]').classList.add('active');
}
function loadPermissions(){
fetch('/service/api/role-permissions').then(function(r){ return r.json() }).then(function(data){
['engineer','technician'].forEach(function(role){
var container = document.getElementById('menu-' + role);
container.innerHTML = '';
var permMap = {};
(data[role] || []).forEach(function(p){ permMap[p.menu_key] = p.visible; });
var currentSection = '';
MENU_ITEMS.forEach(function(item){
if(item.section !== currentSection){
currentSection = item.section;
var hdr = document.createElement('h4');
hdr.textContent = item.section;
container.appendChild(hdr);
}
var div = document.createElement('div');
div.className = 'menu-item';
var checked = permMap[item.key] !== false;
div.innerHTML = '<input type="checkbox" name="' + item.key + '" ' + (checked?'checked':'') + '>' +
'<span class="label">' + item.label + '</span>';
div.querySelector('input[type="checkbox"]').addEventListener('change', function(){
div.style.opacity = this.checked ? '1' : '0.5';
});
div.style.opacity = checked ? '1' : '0.5';
container.appendChild(div);
});
});
});
}
['engineer','technician'].forEach(function(role){
document.getElementById('role-form-' + role).addEventListener('submit', function(e){
e.preventDefault();
var perms = [];
this.querySelectorAll('input[type="checkbox"]').forEach(function(cb){
perms.push({menu_key: cb.name, visible: cb.checked});
});
fetch('/service/api/role-permissions/save', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({role: role, permissions: perms})
}).then(function(r){ return r.json() }).then(function(d){
if(d.ok) alert('Настройки роли "' + role + '" сохранены');
});
});
});
loadPermissions();
</script>
{% endblock %}
+8 -3
View File
@@ -5,15 +5,16 @@
<button class="btn btn-primary btn-sm" onclick="document.getElementById('modal-user').classList.add('open')">+ Добавить</button>
</div>
<div class="table-wrap">
<table><thead><tr><th>Логин</th><th>ФИО</th><th>Роль</th><th>Телефон</th><th>Email</th><th>Статус</th><th>Создан</th><th class="actions"></th></tr></thead>
<table><thead><tr><th>Логин</th><th>ФИО</th><th>Роль</th><th>Телефон</th><th>Email</th><th>MAX ID</th><th>Статус</th><th>Создан</th><th class="actions"></th></tr></thead>
<tbody>{% for u in users %}<tr>
<td>{{ u.login }}</td><td>{{ u.full_name }}</td>
<td><span class="badge badge-{{ u.role }}">{{ {'owner':'Владелец','engineer':'Инженер','technician':'Техник'}[u.role] }}</span></td>
<td>{{ u.phone }}</td><td>{{ u.email }}</td>
<td>{{ u.max_user_id or '—' }}</td>
<td><span class="badge badge-{{ 'active' if u.is_active else 'inactive' }}">{{ 'Активен' if u.is_active else 'Неактивен' }}</span></td>
<td>{{ u.created_at.strftime('%d.%m.%Y') if u.created_at else '' }}</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" onclick="editUser({{ u.id }},'{{ u.login }}','{{ u.full_name }}','{{ u.role }}','{{ u.phone }}','{{ u.email }}',{{ 'true' if u.is_active else 'false' }})"></button>
<button class="btn btn-secondary btn-sm" onclick="editUser({{ u.id }},'{{ u.login }}','{{ u.full_name }}','{{ u.role }}','{{ u.phone }}','{{ u.email }}','{{ u.max_user_id or '' }}',{{ 'true' if u.is_active else 'false' }})"></button>
{% if u.role != 'owner' %}
<form method="post" action="/service/users/delete" style="display:inline" onsubmit="return confirm('Удалить пользователя?')">
<input type="hidden" name="id" value="{{ u.id }}">
@@ -41,6 +42,9 @@
<div class="form-group"><label>Телефон</label><input type="text" name="phone" id="user-phone"></div>
<div class="form-group"><label>Email</label><input type="email" name="email" id="user-email"></div>
</div>
<div class="form-row">
<div class="form-group"><label>MAX ID (мессенджер)</label><input type="number" name="max_user_id" id="user-max_id" placeholder="ID пользователя в MAX"></div>
</div>
<div class="form-check"><input type="checkbox" name="is_active" id="user-is_active" value="1" checked><label for="user-is_active">Активен</label></div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Сохранить</button>
@@ -50,7 +54,7 @@
</div>
</div>
<script>
function editUser(id,login,name,role,phone,email,active){
function editUser(id,login,name,role,phone,email,maxId,active){
document.getElementById('modal-user').classList.add('open');
document.getElementById('modal-user-title').textContent = 'Редактировать сотрудника';
document.getElementById('modal-user-form').action = '/service/users/edit';
@@ -59,6 +63,7 @@ function editUser(id,login,name,role,phone,email,active){
document.getElementById('user-full_name').value = name;
document.getElementById('user-role').value = role;
document.getElementById('user-phone').value = phone; document.getElementById('user-email').value = email;
document.getElementById('user-max_id').value = maxId || '';
document.getElementById('user-is_active').checked = active;
document.getElementById('user-password').required = false;
}