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()]); 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 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'; }); } 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'); });