intra_monitor/public/js/auth.js
dv 79ea884dd5
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
фикс статистики 7
2026-04-15 11:27:07 +03:00

613 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

async function doLogin() {
const username = document.getElementById('login-username').value.trim();
const password = document.getElementById('login-password').value;
const errEl = document.getElementById('login-error');
errEl.textContent = '';
if (!username || !password) {
errEl.textContent = 'Заполните все поля';
return;
}
const r = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const data = await r.json();
if (!r.ok) {
errEl.textContent = data.error || 'Ошибка входа';
return;
}
authToken = data.token;
currentUser = { username: data.username, is_admin: data.is_admin };
localStorage.setItem('intradesk_token', authToken);
showApp();
init();
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !document.getElementById('login-overlay').classList.contains('hidden')) doLogin();
});
function doLogout() {
localStorage.removeItem('intradesk_token');
authToken = null;
currentUser = null;
reloadInFlight = false;
if (statusPollTimer) clearInterval(statusPollTimer);
if (clientCountdownTimer) clearInterval(clientCountdownTimer);
stopSSE();
document.getElementById('app-shell').style.display = 'none';
document.getElementById('login-overlay').classList.remove('hidden');
document.getElementById('login-password').value = '';
}
function showApp() {
document.getElementById('login-overlay').classList.add('hidden');
document.getElementById('app-shell').style.display = 'flex';
const u = currentUser.username;
const initials = avatarInitials(u);
const color = avatarColor(u);
const av = document.getElementById('sb-avatar');
if (av) {
av.textContent = initials;
av.style.background = color;
}
if (document.getElementById('sb-username')) document.getElementById('sb-username').textContent = u;
if (document.getElementById('sb-email')) document.getElementById('sb-email').textContent = `${u}@cleverparks.io`;
const ns = document.getElementById('nav-settings');
if (ns) ns.style.display = currentUser.is_admin ? 'flex' : 'none';
}
let lastLoadedSettings = {};
let internalStatusesAdminCache = [];
let missingCommentsFetchInFlight = false;
async function init() {
await loadInternalStatusesConfig(false, true);
await Promise.all([loadMarks(), loadNotes(), loadMentions(), loadUserList(), loadCommentCounts(), loadClientDict(), loadClientUsers()]);
await loadTasksFromServer();
if (currentUser.is_admin) {
const s = await apiGet('/api/settings');
if (s) {
applySettings(s);
await loadIntraStatusDictionary();
}
await loadInternalStatusesAdmin();
loadUsersTable();
}
const status = await apiGet('/api/status');
if (status) {
serverRefreshInterval = status.auto_refresh_interval || 300;
lastKnownUpdatedAt = status.last_updated;
if (status.cache_version !== undefined && status.cache_version !== null) {
lastKnownCacheVersion = Number(status.cache_version) || 0;
}
}
startSSE();
startStatusPolling();
}
async function loadInternalStatusesConfig(includeAll = false, applyToRuntime = true) {
const suffix = includeAll ? '?include_all=1' : '';
const d = await apiGet(`/api/internal-statuses${suffix}`);
const statuses = d?.statuses || [];
if (applyToRuntime && statuses.length && typeof setMarksConfig === 'function') {
const active = statuses.filter((s) => s.is_active !== false);
setMarksConfig(active);
if (typeof syncChipDropdownItems === 'function') syncChipDropdownItems();
if (typeof initMarkChips === 'function') initMarkChips();
}
return statuses;
}
async function loadMarks() { const d = await apiGet('/api/marks'); if (d) marksCache = d; }
async function loadNotes() { const d = await apiGet('/api/notes'); if (d) notesCache = d; }
async function loadUserList() { const d = await apiGet('/api/users/list'); if (d) knownUsers = d.map((u) => u.toLowerCase()); }
async function loadCommentCounts() { const d = await apiGet('/api/comments/counts'); if (d && typeof d === 'object') commentCounts = d; }
async function loadClientDict() { const d = await apiGet('/api/clients'); if (Array.isArray(d)) window._allClients = d; }
async function loadClientUsers() { const d = await apiGet('/api/client-users'); if (d && typeof d === 'object') window._clientUsers = d; }
async function syncIntraClients() {
const btn = document.getElementById('sync-clients-btn');
const statusEl = document.getElementById('sync-clients-status');
if (btn) { btn.disabled = true; btn.textContent = '⏳ Синхронизация...'; }
if (statusEl) statusEl.textContent = '';
try {
const r = await apiPost('/api/admin/sync-client-users', {});
if (r?.ok) {
if (statusEl) statusEl.textContent = `✓ Синхронизировано. Записей в базе: ${r.count}`;
await loadClientUsers();
renderIntraClientsList();
} else {
if (statusEl) statusEl.textContent = `${r?.error || 'Ошибка'}`;
}
} catch (e) {
if (statusEl) statusEl.textContent = `${e.message}`;
} finally {
if (btn) { btn.disabled = false; btn.textContent = '↻ Синхронизировать'; }
}
}
function renderIntraClientsList() {
const container = document.getElementById('intra-clients-list');
if (!container) return;
const clientUsers = window._clientUsers || {};
const allClients = window._allClients || [];
if (!allClients.length) {
container.innerHTML = '<div style="color:var(--text3);font-size:12px;">Нет данных. Нажмите «Синхронизировать».</div>';
return;
}
const sorted = [...allClients].sort((a, b) => a.name.localeCompare(b.name, 'ru'));
container.innerHTML = sorted.map((c) => {
const users = clientUsers[String(c.client_id)] || [];
const usersHtml = users.length
? users.map((u) => `<div style="font-size:11px;color:var(--text2);padding:2px 0 2px 10px;border-left:2px solid var(--border2);">${escHtml(u)}</div>`).join('')
: '<div style="font-size:11px;color:var(--text3);padding:2px 0 2px 10px;font-style:italic;">нет пользователей</div>';
return `<div style="border:1px solid var(--border);border-radius:6px;padding:8px 10px;">
<div style="font-size:12px;font-weight:600;color:var(--text);margin-bottom:4px;">${escHtml(c.name)} <span style="font-weight:400;color:var(--text3);font-size:11px;">(id: ${c.client_id}, ${users.length} польз.)</span></div>
${usersHtml}
</div>`;
}).join('');
}
async function loadTasksFromServer(silent = false) {
if (!silent && !allData.length) showLoading();
const data = await apiGet('/api/tasks');
if (!data) return;
allData = (data.tasks || []).map((task) => {
const lc = extractLastComment(task);
if (!lc && forceComment) {
const tid = String(task.id);
const cached = forcedCommentCache.get(tid);
if (cached && cached.updatedat === task.updatedat) return { task, lastComment: cached.lastComment };
}
return { task, lastComment: lc };
});
lastKnownUpdatedAt = data.updated_at;
if (data.cache_version !== undefined && data.cache_version !== null) {
lastKnownCacheVersion = Number(data.cache_version) || lastKnownCacheVersion;
}
const el = (id) => document.getElementById(id);
if (el('total-loaded')) el('total-loaded').textContent = allData.length;
if (el('total-count')) el('total-count').textContent = allData.length;
if (el('last-update')) el('last-update').textContent = data.updated_at ? new Date(data.updated_at).toLocaleTimeString('ru-RU') : '—';
document.getElementById('update-banner')?.classList.remove('visible');
onTasksLoaded();
if (typeof refreshAdvancedFilters === 'function') refreshAdvancedFilters();
renderTable();
if (forceComment) fetchMissingComments();
}
async function fetchMissingComments() {
if (missingCommentsFetchInFlight) return;
const toFetch = allData.filter((i) => {
if (i.lastComment) return false;
const tid = String(i.task.id);
const cached = forcedCommentCache.get(tid);
return !cached || cached.updatedat !== i.task.updatedat;
});
if (!toFetch.length) return;
missingCommentsFetchInFlight = true;
try {
const CONCURRENCY = 8;
let cursor = 0;
let loaded = 0;
async function worker() {
while (true) {
const idx = cursor;
cursor += 1;
if (idx >= toFetch.length) return;
const item = toFetch[idx];
const tid = String(item.task.id);
const upd = encodeURIComponent(item.task.updatedat || '');
let lc = null;
try {
const res = await apiGet(`/api/tasks/lastcomment/${tid}?upd=${upd}`);
if (res?.entries?.length) {
const entry = res.entries[0];
const ev = (entry.events?.data || []).find((e) => COMMENT_TYPES.has(e.type));
if (ev) {
lc = {
username: entry.username,
userid: entry.userid,
usertype: entry.usertype,
eventat: entry.eventat,
text: ev.stringvalue || '',
isPrivate: ev.type === 55,
eventType: ev.type,
};
}
}
} catch {}
item.lastComment = lc;
forcedCommentCache.set(tid, { updatedat: item.task.updatedat, lastComment: lc });
loaded += 1;
if (loaded % 25 === 0 && typeof renderTable === 'function') renderTable(true);
}
}
const workers = Array.from({ length: Math.min(CONCURRENCY, toFetch.length) }, () => worker());
await Promise.all(workers);
} finally {
missingCommentsFetchInFlight = false;
if (typeof renderTable === 'function') renderTable(true);
}
}
async function loadTasks() {
const btn = document.getElementById('load-btn');
btn.disabled = true;
btn.textContent = '⏳ Загрузка...';
try {
const r = await apiPost('/api/tasks/refresh', {});
if (r?.error) {
showToast(`${r.error}`, 'warn');
return;
}
await Promise.all([loadMarks(), loadNotes()]);
await loadTasksFromServer(true);
} finally {
btn.disabled = false;
btn.textContent = '↻ Обновить';
}
}
async function loadAllTasks() {
const btn = document.getElementById('refresh-all-btn');
const status = document.getElementById('refresh-all-status');
if (btn) {
btn.disabled = true;
btn.textContent = '⏳ Загружаю все...';
}
if (status) status.textContent = 'Может занять ~30 секунд...';
try {
const r = await apiPost('/api/tasks/refresh/full', {});
if (r?.error) {
showToast(`${r.error}`, 'warn');
if (status) status.textContent = `Ошибка: ${r.error}`;
return;
}
await Promise.all([loadMarks(), loadNotes()]);
await loadTasksFromServer(true);
if (status) status.textContent = `✓ Загружено ${r.count} заявок`;
showToast(`✓ Загружено ${r.count} заявок`, 'success');
} finally {
if (btn) {
btn.disabled = false;
btn.textContent = '🔄 Обновить ВСЕ заявки';
}
}
}
async function reloadData() {
if (reloadInFlight) return;
reloadInFlight = true;
try {
await Promise.all([loadMarks(), loadNotes()]);
await loadTasksFromServer(true);
} finally {
reloadInFlight = false;
}
}
function startStatusPolling() {
if (statusPollTimer) clearInterval(statusPollTimer);
statusPollTimer = setInterval(async () => {
const s = await apiGet('/api/status');
if (!s) return;
const serverVersion = Number(s.cache_version || 0);
if (serverVersion && serverVersion !== lastKnownCacheVersion) {
lastKnownCacheVersion = serverVersion;
if (s.last_updated) lastKnownUpdatedAt = s.last_updated;
await reloadData();
return;
}
if (s?.last_updated && s.last_updated !== lastKnownUpdatedAt) {
lastKnownUpdatedAt = s.last_updated;
await reloadData();
}
}, 30000);
}
function applySettings(s) {
lastLoadedSettings = { ...(s || {}) };
const f = (id) => document.getElementById(id);
if (f('status-ids')) f('status-ids').value = s.status_ids || '';
if (f('event-types')) f('event-types').value = s.event_types || '';
if (f('server-refresh-interval')) f('server-refresh-interval').value = s.auto_refresh_interval || '300';
if (f('setting-auto-reset-on-update')) f('setting-auto-reset-on-update').checked = String(s.auto_reset_on_update || '0') === '1';
if (f('setting-sync-reply-wait')) f('setting-sync-reply-wait').checked = String(s.sync_reply_wait || '0') === '1';
if (f('setting-sync-done-intra')) f('setting-sync-done-intra').checked = String(s.sync_done_with_intra || '0') === '1';
if (f('setting-sync-addfield-status')) f('setting-sync-addfield-status').checked = String(s.sync_addfield_status || '0') === '1';
if (f('setting-intra-done-status-ids')) f('setting-intra-done-status-ids').value = s.intra_done_status_ids || '';
if (f('apikey-status')) f('apikey-status').textContent = s.api_key_set ? '(ключ задан ✓)' : '(не задан!)';
syncDoneSelectFromInput();
}
function getMultiValues(selectId) {
const el = document.getElementById(selectId);
if (!el) return [];
return [...el.selectedOptions].map((o) => String(o.value).trim()).filter(Boolean);
}
function setMultiValues(selectId, values) {
const selected = new Set((values || []).map((v) => String(v)));
const el = document.getElementById(selectId);
if (!el) return;
[...el.options].forEach((opt) => { opt.selected = selected.has(String(opt.value)); });
}
async function loadIntraStatusDictionary() {
const d = await apiGet('/api/intra/status-dictionary');
const items = d?.items || [];
const sel = document.getElementById('setting-intra-done-select');
if (!sel) return items;
const selectedRaw = String(lastLoadedSettings.intra_done_status_ids || '')
.split(',')
.map((v) => String(v).trim())
.filter(Boolean);
const prevSelected = selectedRaw.length ? selectedRaw : getMultiValues('setting-intra-done-select');
sel.innerHTML = items.map((it) => `<option value="${esc(it.status_id)}">#${esc(it.status_id)} - ${esc(it.name)}</option>`).join('');
setMultiValues('setting-intra-done-select', prevSelected);
const idsInput = document.getElementById('setting-intra-done-status-ids');
if (idsInput) idsInput.value = prevSelected.join(',');
return items;
}
function syncDoneIdsFromSelect() {
const ids = getMultiValues('setting-intra-done-select');
const idsInput = document.getElementById('setting-intra-done-status-ids');
if (idsInput) idsInput.value = ids.join(',');
}
function syncDoneSelectFromInput() {
const idsInput = document.getElementById('setting-intra-done-status-ids');
if (!idsInput) return;
const values = String(idsInput.value || '').split(',').map((v) => String(v).trim()).filter(Boolean);
setMultiValues('setting-intra-done-select', values);
}
async function saveSettings() {
syncDoneIdsFromSelect();
const body = {
status_ids: document.getElementById('status-ids').value.trim(),
event_types: document.getElementById('event-types').value.trim(),
auto_refresh_interval: document.getElementById('server-refresh-interval').value,
auto_reset_on_update: document.getElementById('setting-auto-reset-on-update')?.checked ? '1' : '0',
sync_reply_wait: document.getElementById('setting-sync-reply-wait')?.checked ? '1' : '0',
sync_done_with_intra: document.getElementById('setting-sync-done-intra')?.checked ? '1' : '0',
sync_addfield_status: document.getElementById('setting-sync-addfield-status')?.checked ? '1' : '0',
intra_done_status_ids: document.getElementById('setting-intra-done-status-ids')?.value.trim() || '',
};
const apiKey = document.getElementById('api-key').value.trim();
if (apiKey) body.api_key = apiKey;
const r = await apiPost('/api/settings', body);
if (r?.ok) {
applySettings({ ...lastLoadedSettings, ...body, api_key_set: true });
document.getElementById('api-key').value = '';
showToast('✓ Настройки сохранены', 'success');
} else {
showToast(`${r?.error || 'Ошибка'}`, 'warn');
}
}
function openSettingsTab(tabName) {
document.querySelectorAll('.settings-tab-btn').forEach((btn) => {
btn.classList.toggle('active', btn.dataset.tab === tabName);
});
document.querySelectorAll('.settings-tab-pane').forEach((pane) => {
pane.style.display = pane.dataset.tab === tabName ? 'block' : 'none';
});
if (tabName === 'intra-clients') renderIntraClientsList();
}
async function loadInternalStatusesAdmin() {
const statuses = await loadInternalStatusesConfig(true, false);
internalStatusesAdminCache = statuses;
renderInternalStatusesAdmin();
}
function statusAdminRow(status) {
const delBtn = status.is_system
? '<span style="color:var(--text3);font-size:11px;">system</span>'
: `<button class="btn btn-danger" style="font-size:11px;padding:4px 8px;" onclick="deleteInternalStatus('${esc(status.key)}')">Удалить</button>`;
return `
<tr>
<td style="font-family:monospace;font-size:11px;">${esc(status.key)}</td>
<td><input class="inline-pass" type="text" id="st-label-${esc(status.key)}" value="${esc(status.label)}"></td>
<td><input type="color" id="st-dot-${esc(status.key)}" value="${toHex(status.colorDot || '#1677ff')}"></td>
<td><input type="color" id="st-bg-${esc(status.key)}" value="${toHex(status.colorBg || '#e6f4ff')}"></td>
<td><input type="number" id="st-order-${esc(status.key)}" value="${Number(status.sort_order || 0)}" style="width:72px;"></td>
<td style="text-align:center;"><input type="checkbox" id="st-active-${esc(status.key)}" ${status.is_active ? 'checked' : ''}></td>
<td><button class="btn btn-default" style="font-size:11px;padding:4px 8px;" onclick="saveInternalStatus('${esc(status.key)}')">Сохранить</button></td>
<td>${delBtn}</td>
</tr>`;
}
function toHex(color) {
if (!color) return '#1677ff';
const c = String(color).trim();
const cssVarMap = {
'var(--red)': '#ff4d4f',
'var(--red-light)': '#fff1f0',
'var(--red-border)': '#ffa39e',
'var(--orange)': '#fa8c16',
'var(--orange-light)': '#fff7e6',
'var(--orange-border)': '#ffd591',
'var(--green)': '#52c41a',
'var(--green-light)': '#f6ffed',
'var(--green-border)': '#b7eb8f',
'var(--purple)': '#722ed1',
'var(--purple-light)': '#f9f0ff',
};
if (cssVarMap[c]) return cssVarMap[c];
if (/^#[0-9a-fA-F]{6}$/.test(c)) return c;
if (/^#[0-9a-fA-F]{3}$/.test(c)) return `#${c[1]}${c[1]}${c[2]}${c[2]}${c[3]}${c[3]}`;
return '#1677ff';
}
function renderInternalStatusesAdmin() {
const tbody = document.getElementById('internal-statuses-tbody');
if (!tbody) return;
tbody.innerHTML = internalStatusesAdminCache.map(statusAdminRow).join('');
}
async function createInternalStatus() {
const label = document.getElementById('new-status-label')?.value.trim();
const key = document.getElementById('new-status-key')?.value.trim();
const dot = document.getElementById('new-status-dot')?.value || '#1677ff';
const bg = document.getElementById('new-status-bg')?.value || '#e6f4ff';
const order = document.getElementById('new-status-order')?.value || '';
if (!label) {
showToast('Введите название статуса', 'warn');
return;
}
const r = await apiPost('/api/internal-statuses', {
key,
label,
color_dot: dot,
color_bg: bg,
color_border: dot,
color_text: dot,
sort_order: order ? Number(order) : undefined,
is_active: true,
});
if (!r?.ok) {
showToast(`${r?.error || 'Ошибка'}`, 'warn');
return;
}
document.getElementById('new-status-label').value = '';
document.getElementById('new-status-key').value = '';
await loadInternalStatusesConfig(false, true);
await loadInternalStatusesAdmin();
await loadMarks();
if (typeof refreshAdvancedFilters === 'function') refreshAdvancedFilters();
renderTable();
showToast('✓ Статус добавлен', 'success');
}
async function saveInternalStatus(key) {
const payload = {
label: document.getElementById(`st-label-${key}`)?.value.trim(),
color_dot: document.getElementById(`st-dot-${key}`)?.value || '#1677ff',
color_bg: document.getElementById(`st-bg-${key}`)?.value || '#e6f4ff',
color_border: document.getElementById(`st-dot-${key}`)?.value || '#1677ff',
color_text: document.getElementById(`st-dot-${key}`)?.value || '#1677ff',
sort_order: Number(document.getElementById(`st-order-${key}`)?.value || 0),
is_active: !!document.getElementById(`st-active-${key}`)?.checked,
};
const r = await apiPost(`/api/internal-statuses/${encodeURIComponent(key)}`, payload, 'PUT');
if (!r?.ok) {
showToast(`${r?.error || 'Ошибка'}`, 'warn');
return;
}
await loadInternalStatusesConfig(false, true);
await loadInternalStatusesAdmin();
await loadMarks();
if (typeof refreshAdvancedFilters === 'function') refreshAdvancedFilters();
renderTable();
showToast('✓ Статус сохранен', 'success');
}
async function deleteInternalStatus(key) {
if (!confirm(`Удалить статус "${key}"?`)) return;
const r = await apiDelete(`/api/internal-statuses/${encodeURIComponent(key)}`);
if (!r?.ok) {
showToast(`${r?.error || 'Ошибка'}`, 'warn');
return;
}
await loadInternalStatusesConfig(false, true);
await loadInternalStatusesAdmin();
await loadMarks();
if (typeof refreshAdvancedFilters === 'function') refreshAdvancedFilters();
renderTable();
showToast('✓ Статус удален', 'success');
}
async function createUser() {
const username = document.getElementById('new-user-login').value.trim();
const password = document.getElementById('new-user-pass').value;
const msgEl = document.getElementById('user-msg');
const r = await apiPost('/api/auth/register', { username, password });
if (r?.ok) {
msgEl.style.color = 'var(--green)';
msgEl.textContent = `✓ Пользователь "${username}" создан`;
document.getElementById('new-user-login').value = '';
document.getElementById('new-user-pass').value = '';
loadUsersTable();
loadUserList();
} else {
msgEl.style.color = 'var(--red)';
msgEl.textContent = r?.error || 'Ошибка';
}
}
async function loadUsersTable() {
const users = await apiGet('/api/users');
if (!users) return;
const tbody = document.getElementById('users-tbody');
if (!tbody) return;
tbody.innerHTML = users.map((u) => `
<tr>
<td style="font-weight:600;">${esc(u.username)}</td>
<td>${u.created_at ? new Date(u.created_at).toLocaleDateString('ru-RU') : '—'}</td>
<td><input class="inline-pass" type="password" id="pass-${u.id}" placeholder="новый пароль">
<button class="btn btn-default" style="font-size:11px;padding:4px 10px;margin-left:6px;" onclick="changePassword(${u.id})">Сменить</button></td>
<td>${u.username !== 'admin' ? `<button class="btn btn-danger" style="font-size:11px;padding:4px 10px;" onclick="deleteUser(${u.id},'${esc(u.username)}')">Удалить</button>` : ''}</td>
</tr>`).join('');
}
async function changePassword(id) {
const inp = document.getElementById(`pass-${id}`);
if (!inp || inp.value.length < 6) {
showToast('Пароль минимум 6 символов', 'warn');
return;
}
const r = await apiPost(`/api/users/${id}/password`, { password: inp.value }, 'PUT');
if (r?.ok) {
inp.value = '';
showToast('✓ Пароль изменен', 'success');
} else {
showToast(r?.error || 'Ошибка', 'warn');
}
}
async function deleteUser(id, username) {
if (!confirm(`Удалить пользователя "${username}"?`)) return;
const r = await apiDelete(`/api/users/${id}`);
if (r?.ok) {
loadUsersTable();
loadUserList();
} else {
showToast(r?.error || 'Ошибка', 'warn');
}
}
window.addEventListener('DOMContentLoaded', () => {
if (authToken) {
try {
const p = JSON.parse(atob(authToken.split('.')[1]));
if (p.exp * 1000 > Date.now()) {
currentUser = { username: p.username, is_admin: p.username === 'admin' };
showApp();
init();
return;
}
} catch {}
localStorage.removeItem('intradesk_token');
authToken = null;
}
document.getElementById('login-overlay').classList.remove('hidden');
});