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'; // flex = topbar-icon-btn } async function init() { await Promise.all([loadMarks(),loadNotes(),loadMentions(),loadUserList(),loadCommentCounts()]); await loadTasksFromServer(); if(currentUser.is_admin){ const s=await apiGet('/api/settings'); if(s) applySettings(s); 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 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 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); // Восстанавливаем форсированный комментарий из кеша, если updatedat не изменился 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(); renderTable(); if(forceComment) fetchMissingComments(); } async function fetchMissingComments() { // Только задачи без комментария, для которых кеш отсутствует или устарел 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; for(const item of toFetch) { 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; // Кешируем результат (даже null) — повторный запрос только при изменении updatedat forcedCommentCache.set(tid,{updatedat:item.task.updatedat,lastComment:lc}); } renderTable(); } 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) { 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('apikey-status')) f('apikey-status').textContent=s.api_key_set?'(ключ задан ✓)':'(не задан!)'; } async function saveSettings() { 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}; 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(body);document.getElementById('api-key').value='';showToast('✓ Настройки сохранены','success');} else showToast('❌ '+(r?.error||'Ошибка'),'warn'); } 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'); });