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 = '
Нет данных. Нажмите «Синхронизировать».
';
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) => `${escHtml(u)}
`).join('')
: 'нет пользователей
';
return `
${escHtml(c.name)} (id: ${c.client_id}, ${users.length} польз.)
${usersHtml}
`;
}).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) => ``).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
? 'system'
: ``;
return `
| ${esc(status.key)} |
|
|
|
|
|
|
${delBtn} |
`;
}
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) => `
| ${esc(u.username)} |
${u.created_at ? new Date(u.created_at).toLocaleDateString('ru-RU') : '—'} |
|
${u.username !== 'admin' ? `` : ''} |
`).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');
});