From fc730fb64b68311dae7408ef8f907d4ede5f557a Mon Sep 17 00:00:00 2001 From: dv Date: Wed, 18 Mar 2026 18:35:03 +0300 Subject: [PATCH] vibe 2 --- .woodpecker.yml | 4 +- public/css/app.css | 32 ++- public/index.html | 131 +++++++-- public/js/auth.js | 643 ++++++++++++++++++++++++++++++------------ public/js/notes.js | 8 +- public/js/state.js | 26 +- public/js/statuses.js | 473 +++++++++++++++++++------------ public/js/ui.js | 132 +++++++-- refresh-service.js | 173 ++++++++++++ server.js | 393 +++++++++++++++++++++++++- 10 files changed, 1594 insertions(+), 421 deletions(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index 858b61b..e0ad424 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -94,8 +94,8 @@ steps: fi # Обновляем образ и перезапускаем - docker compose pull - docker compose up -d --remove-orphans + docker compose -p intradesk-monitor pull + docker compose -p intradesk-monitor up -d --remove-orphans echo "Deployed: ${CI_COMMIT_SHA:0:8} (${CI_COMMIT_MESSAGE})" labels: diff --git a/public/css/app.css b/public/css/app.css index 3c82c44..8e81dd5 100644 --- a/public/css/app.css +++ b/public/css/app.css @@ -349,6 +349,18 @@ body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);min-h .sb-external .dot{background:var(--red);} .sb-internal{background:var(--green-light);border-color:var(--green-border);color:var(--green);} .sb-internal .dot{background:var(--green);} +.sb-intra-open{background:#e6f4ff;border-color:#91caff;color:#0958d9;} +.sb-intra-open .dot{background:#1677ff;} +.sb-intra-work{background:#fff7e6;border-color:#ffd591;color:#d46b08;} +.sb-intra-work .dot{background:#fa8c16;} +.sb-intra-done{background:#f6ffed;border-color:#b7eb8f;color:#389e0d;} +.sb-intra-done .dot{background:#52c41a;} +.sb-intra-hold{background:#fffbe6;border-color:#ffe58f;color:#ad6800;} +.sb-intra-hold .dot{background:#faad14;} +.sb-intra-other{background:var(--surface2);border-color:var(--border2);color:var(--text2);} +.sb-intra-other .dot{background:var(--text3);} +.sb-custom{background:var(--surface2);border-color:var(--border2);color:var(--text2);} +.sb-custom .dot{background:currentColor;} /* ── MODAL ───────────────────────────────────────────────── */ .modal-overlay{display:none;position:fixed;inset:0;background:rgba(20,24,40,.5);backdrop-filter:blur(4px);z-index:200;align-items:center;justify-content:center;padding:24px;} @@ -511,16 +523,22 @@ body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);min-h /* ── SETTINGS MODAL ──────────────────────────────────────── */ .settings-overlay{display:none;position:fixed;inset:0;background:rgba(20,24,40,.5);backdrop-filter:blur(3px);z-index:200;align-items:center;justify-content:center;padding:24px;} .settings-overlay.open{display:flex;} -.settings-box{background:var(--surface);border-radius:14px;box-shadow:var(--shadow-md);max-width:700px;width:100%;max-height:88vh;overflow-y:auto;} +.settings-box{background:var(--surface);border-radius:14px;box-shadow:var(--shadow-md);max-width:980px;width:100%;max-height:88vh;overflow-y:auto;} .settings-header{display:flex;align-items:center;justify-content:space-between;padding:18px 24px;border-bottom:1px solid var(--border);} .settings-title{font-size:15px;font-weight:700;color:var(--text);} .settings-body{padding:20px 24px 24px;} +.settings-tabs{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;} +.settings-tab-btn{border:1px solid var(--border);background:var(--surface2);color:var(--text2);font-family:inherit;font-size:12px;font-weight:600;border-radius:var(--radius-sm);padding:6px 10px;cursor:pointer;} +.settings-tab-btn.active{background:var(--accent-light);border-color:var(--accent);color:var(--accent);} +.settings-tab-pane{display:block;} .settings-section-title{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--text3);margin-bottom:12px;} .form-row{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:12px;} .form-field{display:flex;flex-direction:column;gap:5px;flex:1;min-width:160px;} .form-field label{font-size:10px;font-weight:700;color:var(--text3);text-transform:uppercase;letter-spacing:.05em;} .form-field input{background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-family:'Inter',sans-serif;font-size:12px;padding:8px 11px;outline:none;transition:border-color .2s;} +.form-field select{background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-family:'Inter',sans-serif;font-size:12px;padding:8px 11px;outline:none;transition:border-color .2s;} .form-field input:focus{border-color:var(--accent);} +.form-field select:focus{border-color:var(--accent);} .users-table{width:100%;border-collapse:collapse;font-size:12px;margin-top:10px;} .users-table th{text-align:left;font-size:10px;text-transform:uppercase;letter-spacing:.05em;color:var(--text3);padding:6px 10px;border-bottom:1px solid var(--border);} .users-table td{padding:8px 10px;border-bottom:1px solid var(--border);color:var(--text2);vertical-align:middle;} @@ -1288,6 +1306,17 @@ html.dark .stats-card { background:var(--surface); } flex-wrap: wrap; padding: 10px 0 12px; } +.filter-row-advanced{ + border-top:1px dashed var(--border); + margin-top:-4px; + padding-top:10px; +} +.filter-multi{ + min-width:160px; + min-height:74px; + background-image:none; + padding-right:10px; +} /* ── Chip Dropdown ────────────────────────────────────── */ .chip-dropdown-wrap { position: relative; flex-shrink: 0; } @@ -1418,4 +1447,3 @@ html.dark .chip-dropdown-panel { box-shadow: 0 8px 24px rgba(0,0,0,.45); } position: absolute; left: 0; top: 0; height: 100%; background: var(--accent); border-radius: 4px; transition: width .5s; } - diff --git a/public/index.html b/public/index.html index eeeb97b..a0afe3c 100644 --- a/public/index.html +++ b/public/index.html @@ -31,33 +31,105 @@
-
Подключение к IntraDesk API
-
-
- - +
+ + + + +
+ +
+
IntraDesk API
+
+
+ + +
+
-
+
+
+
+
+ + +
-
-
-
+ + - - - -
-
Управление пользователями
-
-
-
-
+ + + + -
- - - -
ЛогинСозданНовый пароль
@@ -214,7 +286,18 @@
- + +
+ + + + +
diff --git a/public/js/auth.js b/public/js/auth.js index 2510b88..5df0e30 100644 --- a/public/js/auth.js +++ b/public/js/auth.js @@ -1,256 +1,539 @@ 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(); + 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(); + +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); + authToken = null; + currentUser = null; + reloadInFlight = false; + if (statusPollTimer) clearInterval(statusPollTimer); + if (clientCountdownTimer) clearInterval(clientCountdownTimer); stopSSE(); - document.getElementById('app-shell').style.display='none'; + document.getElementById('app-shell').style.display = 'none'; document.getElementById('login-overlay').classList.remove('hidden'); - document.getElementById('login-password').value=''; + 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 - + 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 = []; + async function init() { - await Promise.all([loadMarks(),loadNotes(),loadMentions(),loadUserList(),loadCommentCounts()]); + await loadInternalStatusesConfig(false, true); + await Promise.all([loadMarks(), loadNotes(), loadMentions(), loadUserList(), loadCommentCounts()]); await loadTasksFromServer(); - if(currentUser.is_admin){ - const s=await apiGet('/api/settings'); - if(s) applySettings(s); + + 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; + + 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; +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(); } - 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'):'—'; + 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 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(); + 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; + 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; + 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 - }; + 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}); + item.lastComment = lc; + forcedCommentCache.set(tid, { updatedat: item.task.updatedat, lastComment: lc }); } renderTable(); } - async function loadTasks() { - const btn=document.getElementById('load-btn'); - btn.disabled=true;btn.textContent='⏳ Загрузка...'; + 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()]); + 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='↻ Обновить';} + } 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 секунд...'; + 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()]); + 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='🔄 Обновить ВСЕ заявки';}} + 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; + if (reloadInFlight) return; + reloadInFlight = true; try { - await Promise.all([loadMarks(),loadNotes()]); + await Promise.all([loadMarks(), loadNotes()]); await loadTasksFromServer(true); } finally { - reloadInFlight=false; + 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; + 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; + if (s?.last_updated && s.last_updated !== lastKnownUpdatedAt) { + lastKnownUpdatedAt = s.last_updated; await reloadData(); } - },30000); + }, 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?'(ключ задан ✓)':'(не задан!)'; + 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-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() { - 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'); + 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', + 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'); + 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 || 'Ошибка'; + } } -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; +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; + } catch {} + localStorage.removeItem('intradesk_token'); + authToken = null; } document.getElementById('login-overlay').classList.remove('hidden'); }); diff --git a/public/js/notes.js b/public/js/notes.js index 297b5b2..1ea2ef1 100644 --- a/public/js/notes.js +++ b/public/js/notes.js @@ -3,7 +3,9 @@ function getNoteBy(tid) {return notesCache[tid]?.by||null;} function getNoteAt(tid) {return notesCache[tid]?.at||null;} // Status marks are mutually exclusive — only one at a time -const STATUS_MARKS = MARKS_CONFIG.map(m => m.key); +function getStatusMarks() { + return MARKS_CONFIG.map((m) => m.key); +} async function setMark(taskId, markType, e) { e&&e.stopPropagation(); @@ -13,7 +15,7 @@ async function setMark(taskId, markType, e) { const wasActive = marksCache[tid][markType]?.active; // Clear all status marks first - STATUS_MARKS.forEach(m=>{ + getStatusMarks().forEach(m=>{ marksCache[tid][m]={...(marksCache[tid][m]||{}),active:false}; }); @@ -33,7 +35,7 @@ async function setMark(taskId, markType, e) { btn.classList.toggle('mbtn-active', !!states[btn.dataset.mark]); }); // Also update sp-header badge - const badge=document.querySelector('.sp-header .sp-status-badge'); + const badge=document.querySelector('.sp-header .status-badge'); if(badge) badge.outerHTML=internalStatusBadge(tid2); } else { renderSidePanel(currentModalIdx); diff --git a/public/js/state.js b/public/js/state.js index 554c3bb..4b67f7c 100644 --- a/public/js/state.js +++ b/public/js/state.js @@ -11,7 +11,7 @@ const EVENT_TYPE_NAMES = { 170:'Изм. доп. поле',200:'Изменено название',205:'Изменено описание',210:'Изм. бизнес-процесс', 280:'Архивировано',300:'Выполнен макрос',400:'Эскалация' }; -const STATE_CHIP_KEYS = [...MARKS_CONFIG.map(m => m.key), 'unmarked']; +let STATE_CHIP_KEYS = []; const SEEN_KEY = 'intradesk_seen'; // ── App State ───────────────────────────────────────────────────── @@ -36,10 +36,7 @@ let openNoteIdx = null; let currentModalIdx = null, currentTab = 'last'; let mentionedFilterOn = false; let acState = { active:false, start:0, query:'' }; -const stateChips = Object.fromEntries([ - ...MARKS_CONFIG.map(m => [m.key, true]), - ['unmarked', true], -]); +const stateChips = { unmarked: true }; let unreadMode = 'highlight'; let exportFieldGroups = new Set(['note','status','comments']); let exportStatusMode = 'A'; @@ -47,6 +44,25 @@ const forceComment = true; // всегда включено — для зада // taskId -> { updatedat, lastComment } — не перезапрашиваем если updatedat не изменился const forcedCommentCache = new Map(); +function rebuildStateChipsFromConfig() { + const prev = { ...stateChips }; + STATE_CHIP_KEYS = [...MARKS_CONFIG.map((m) => m.key), 'unmarked']; + Object.keys(stateChips).forEach((k) => { delete stateChips[k]; }); + STATE_CHIP_KEYS.forEach((k) => { + stateChips[k] = Object.prototype.hasOwnProperty.call(prev, k) ? !!prev[k] : true; + }); +} + +function onMarksConfigChanged() { + rebuildStateChipsFromConfig(); + if (typeof initMarkChips === 'function') initMarkChips(); + if (typeof refreshAdvancedFilters === 'function') refreshAdvancedFilters(); + if (typeof syncChipDropdownItems === 'function') syncChipDropdownItems(); + if (typeof renderTable === 'function') renderTable(); +} + +rebuildStateChipsFromConfig(); + // ── Seen ────────────────────────────────────────────────────────── function loadSeen() { try { return JSON.parse(localStorage.getItem(SEEN_KEY)||'{}'); } catch { return {}; } } function saveSeen(s) { try { localStorage.setItem(SEEN_KEY, JSON.stringify(s)); } catch {} } diff --git a/public/js/statuses.js b/public/js/statuses.js index 3282d04..58c3956 100644 --- a/public/js/statuses.js +++ b/public/js/statuses.js @@ -1,208 +1,331 @@ -// ══════════════════════════════════════════════════════════════════ -// ЕДИНЫЙ РЕЕСТР СТАТУСОВ -// Добавьте новый статус сюда — больше ничего менять не нужно. -// ══════════════════════════════════════════════════════════════════ -// -// Поля объекта статуса: -// key — уникальный ключ (используется в БД, API, marksCache) -// chipClass — CSS-класс чипа фильтра (для цвета) -// chipLabel — текст на чипе фильтра -// chipDotColor — цвет точки на чипе (CSS-значение) -// badgeClass — CSS-класс бейджа статуса в таблице -// label — текст бейджа и тултипов -// sseLabel — текст в SSE-тосте -// exportLabel — текст в экспорте (CSV/TXT) -// btnClass — CSS-класс активной кнопки в боковой панели -// rowClass — CSS-класс строки таблицы -// btnIcon — иконка кнопки в боковой панели -// btnLabel — текст кнопки в боковой панели -// ispColor — цвет точки в inline-пикере статуса -// kbColor — цвет вспышки при нажатии клавиши -// kbShortcut — цифра-клавиша быстрого выбора (строка) -// clearOnUpdate — true: статус сбрасывается при обновлении задачи (режим сброса) +// Single source of status defaults. +// Runtime config is loaded from backend via /api/internal-statuses. -const MARKS_CONFIG = [ +const DEFAULT_MARKS_CONFIG = [ { - key: 'needs_reply', - chipClass: 'chip-reply', - chipLabel: 'Нужен ответ', - chipDotColor: 'var(--red)', - badgeClass: 'sb-reply', - label: 'Нужен ответ', - sseLabel: 'Нужно ответить', - exportLabel: 'Клиент ждет ответа', - btnClass: 'mbtn-reply', - rowClass: 'row-reply', - btnIcon: '✉', - btnLabel: 'Нужно ответить', - ispColor: 'var(--red)', - kbColor: '#ff4d4f', - kbShortcut: '1', + key: 'needs_reply', + chipClass: 'chip-reply', + chipLabel: 'Нужен ответ', + chipDotColor: '#ff4d4f', + badgeClass: 'sb-reply', + label: 'Нужен ответ', + sseLabel: 'Нужно ответить', + exportLabel: 'Клиент ждет ответа', + btnClass: 'mbtn-reply', + rowClass: 'row-reply', + btnIcon: '✉', + btnLabel: 'Нужно ответить', + ispColor: '#ff4d4f', + kbColor: '#ff4d4f', + kbShortcut: '1', clearOnUpdate: true, + colorBg: '#fff1f0', + colorBorder: '#ffa39e', + colorText: '#cf1322', + colorDot: '#ff4d4f', + is_system: true, + system_role: 'needs_reply', + sort_order: 10, + is_active: true, }, { - key: 'wait_client', - chipClass: 'chip-wait', - chipLabel: 'Ждём клиента', - chipDotColor: 'var(--orange)', - badgeClass: 'sb-wait', - label: 'Ждём клиента', - sseLabel: 'Ждём клиента', - exportLabel: 'Ждем клиента', - btnClass: 'mbtn-wait', - rowClass: 'row-wait', - btnIcon: '⏳', - btnLabel: 'Ждём клиента', - ispColor: 'var(--orange)', - kbColor: '#fa8c16', - kbShortcut: '2', + key: 'wait_client', + chipClass: 'chip-wait', + chipLabel: 'Ждем клиента', + chipDotColor: '#fa8c16', + badgeClass: 'sb-wait', + label: 'Ждем клиента', + sseLabel: 'Ждем клиента', + exportLabel: 'Ждем клиента', + btnClass: 'mbtn-wait', + rowClass: 'row-wait', + btnIcon: '⏳', + btnLabel: 'Ждем клиента', + ispColor: '#fa8c16', + kbColor: '#fa8c16', + kbShortcut: '2', clearOnUpdate: false, + colorBg: '#fff7e6', + colorBorder: '#ffd591', + colorText: '#d46b08', + colorDot: '#fa8c16', + is_system: true, + system_role: 'wait_client', + sort_order: 20, + is_active: true, }, { - key: 'resolved', - chipClass: 'chip-resolved', - chipLabel: 'Можно закрыть', - chipDotColor: 'var(--green)', - badgeClass: 'sb-resolved', - label: 'Можно закрыть', - sseLabel: 'Можно закрыть', - exportLabel: 'Решено', - btnClass: 'mbtn-resolve', - rowClass: 'row-resolved', - btnIcon: '✓', - btnLabel: 'Можно Закрыть', - ispColor: 'var(--green)', - kbColor: '#52c41a', - kbShortcut: '3', + key: 'resolved', + chipClass: 'chip-resolved', + chipLabel: 'Выполнена', + chipDotColor: '#52c41a', + badgeClass: 'sb-resolved', + label: 'Выполнена', + sseLabel: 'Выполнена', + exportLabel: 'Выполнена', + btnClass: 'mbtn-resolve', + rowClass: 'row-resolved', + btnIcon: '✓', + btnLabel: 'Выполнена', + ispColor: '#52c41a', + kbColor: '#52c41a', + kbShortcut: '3', clearOnUpdate: false, + colorBg: '#f6ffed', + colorBorder: '#b7eb8f', + colorText: '#389e0d', + colorDot: '#52c41a', + is_system: true, + system_role: 'done', + sort_order: 30, + is_active: true, }, { - key: 'vyezdnye', - chipClass: 'chip-vyezdnye', - chipLabel: 'Не наша', - chipDotColor: 'var(--purple)', - badgeClass: 'sb-vyezdnye', - label: 'Не наша', - sseLabel: 'Не наша', - exportLabel: 'Не наша', - btnClass: 'mbtn-vyezdnye', - rowClass: 'row-vyezdnye', - btnIcon: '🚗', - btnLabel: 'Не наша', - ispColor: 'var(--purple)', - kbColor: '#722ed1', - kbShortcut: '4', + key: 'vyezdnye', + chipClass: 'chip-vyezdnye', + chipLabel: 'Не наша', + chipDotColor: '#722ed1', + badgeClass: 'sb-vyezdnye', + label: 'Не наша', + sseLabel: 'Не наша', + exportLabel: 'Не наша', + btnClass: 'mbtn-vyezdnye', + rowClass: 'row-vyezdnye', + btnIcon: '🚗', + btnLabel: 'Не наша', + ispColor: '#722ed1', + kbColor: '#722ed1', + kbShortcut: '4', clearOnUpdate: false, + colorBg: '#f9f0ff', + colorBorder: '#d3adf7', + colorText: '#531dab', + colorDot: '#722ed1', + is_system: false, + system_role: null, + sort_order: 40, + is_active: true, }, { - key: 'hidden', - chipClass: 'chip-hidden', - chipLabel: 'Скрытые', - chipDotColor: 'var(--text2)', - badgeClass: 'sb-hidden', - label: 'Скрыто', - sseLabel: 'Скрыто', - exportLabel: 'Скрыто', - btnClass: 'mbtn-hidden', - rowClass: 'row-hidden', - btnIcon: '⊘', - btnLabel: 'Скрыть', - ispColor: '#bbb', - kbColor: '#bbb', - kbShortcut: '6', + key: 'hidden', + chipClass: 'chip-hidden', + chipLabel: 'Скрытые', + chipDotColor: 'var(--text2)', + badgeClass: 'sb-hidden', + label: 'Скрыто', + sseLabel: 'Скрыто', + exportLabel: 'Скрыто', + btnClass: 'mbtn-hidden', + rowClass: 'row-hidden', + btnIcon: '⊘', + btnLabel: 'Скрыть', + ispColor: '#bbb', + kbColor: '#bbb', + kbShortcut: '6', clearOnUpdate: false, + colorBg: '#f5f5f5', + colorBorder: '#d9d9d9', + colorText: '#888', + colorDot: '#bbb', + is_system: false, + system_role: null, + sort_order: 50, + is_active: true, }, { - key: 'oborudovanie-fix', - chipClass: 'chip-oborudovanie-fix', - chipLabel: 'Клиент отправил', - chipDotColor: '#fa541c', - badgeClass: 'sb-oborudovanie-fix', - label: 'Клиент отправил оборудование', - sseLabel: 'Клиент отправил оборудование', - exportLabel: 'Клиент отправил оборудование', - btnClass: 'mbtn-oborudovanie-fix', - rowClass: 'row-oborudovanie-fix', - btnIcon: '📤', - btnLabel: 'Клиент отправил', - ispColor: '#fa541c', - kbColor: '#fa541c', - kbShortcut: '7', + key: 'oborudovanie-fix', + chipClass: 'chip-oborudovanie-fix', + chipLabel: 'Клиент отправил', + chipDotColor: '#fa541c', + badgeClass: 'sb-oborudovanie-fix', + label: 'Клиент отправил оборудование', + sseLabel: 'Клиент отправил оборудование', + exportLabel: 'Клиент отправил оборудование', + btnClass: 'mbtn-oborudovanie-fix', + rowClass: 'row-oborudovanie-fix', + btnIcon: '📤', + btnLabel: 'Клиент отправил', + ispColor: '#fa541c', + kbColor: '#fa541c', + kbShortcut: '7', clearOnUpdate: false, + colorBg: '#fff2e8', + colorBorder: '#ffbb96', + colorText: '#d4380d', + colorDot: '#fa541c', + is_system: false, + system_role: null, + sort_order: 60, + is_active: true, }, { - key: 'oborudovanie-in-repair', - chipClass: 'chip-oborudovanie-in-repair', - chipLabel: 'В ремонте', - chipDotColor: '#13c2c2', - badgeClass: 'sb-oborudovanie-in-repair', - label: 'Оборудование в ремонте', - sseLabel: 'Оборудование в ремонте', - exportLabel: 'Оборудование в ремонте', - btnClass: 'mbtn-oborudovanie-in-repair', - rowClass: 'row-oborudovanie-in-repair', - btnIcon: '🔧', - btnLabel: 'В ремонте', - ispColor: '#13c2c2', - kbColor: '#13c2c2', - kbShortcut: '8', + key: 'oborudovanie-in-repair', + chipClass: 'chip-oborudovanie-in-repair', + chipLabel: 'В ремонте', + chipDotColor: '#13c2c2', + badgeClass: 'sb-oborudovanie-in-repair', + label: 'Оборудование в ремонте', + sseLabel: 'Оборудование в ремонте', + exportLabel: 'Оборудование в ремонте', + btnClass: 'mbtn-oborudovanie-in-repair', + rowClass: 'row-oborudovanie-in-repair', + btnIcon: '🔧', + btnLabel: 'В ремонте', + ispColor: '#13c2c2', + kbColor: '#13c2c2', + kbShortcut: '8', clearOnUpdate: false, + colorBg: '#e6fffb', + colorBorder: '#87e8de', + colorText: '#08979c', + colorDot: '#13c2c2', + is_system: false, + system_role: null, + sort_order: 70, + is_active: true, }, { - key: 'oborudovanie-repair-done', - chipClass: 'chip-oborudovanie-repair-done', - chipLabel: 'Ремонт окончен', - chipDotColor: '#52c41a', - badgeClass: 'sb-oborudovanie-repair-done', - label: 'Ремонт окончен — ждёт отправки', - sseLabel: 'Ремонт окончен — ждёт отправки', - exportLabel: 'Ремонт окончен — ждёт отправки', - btnClass: 'mbtn-oborudovanie-repair-done', - rowClass: 'row-oborudovanie-repair-done', - btnIcon: '✅', - btnLabel: 'Ремонт окончен', - ispColor: '#52c41a', - kbColor: '#52c41a', - kbShortcut: '9', + key: 'oborudovanie-repair-done', + chipClass: 'chip-oborudovanie-repair-done', + chipLabel: 'Ремонт окончен', + chipDotColor: '#52c41a', + badgeClass: 'sb-oborudovanie-repair-done', + label: 'Ремонт окончен - ждет отправки', + sseLabel: 'Ремонт окончен - ждет отправки', + exportLabel: 'Ремонт окончен - ждет отправки', + btnClass: 'mbtn-oborudovanie-repair-done', + rowClass: 'row-oborudovanie-repair-done', + btnIcon: '✅', + btnLabel: 'Ремонт окончен', + ispColor: '#52c41a', + kbColor: '#52c41a', + kbShortcut: '9', clearOnUpdate: false, + colorBg: '#f6ffed', + colorBorder: '#b7eb8f', + colorText: '#389e0d', + colorDot: '#52c41a', + is_system: false, + system_role: null, + sort_order: 80, + is_active: true, }, { - key: 'oborudovanie-send-back', - chipClass: 'chip-oborudovanie-send-back', - chipLabel: 'Отправлено клиенту', - chipDotColor: '#1677ff', - badgeClass: 'sb-oborudovanie-send-back', - label: 'Оборудование отправлено клиенту', - sseLabel: 'Оборудование отправлено клиенту', - exportLabel: 'Оборудование отправлено клиенту', - btnClass: 'mbtn-oborudovanie-send-back', - rowClass: 'row-oborudovanie-send-back', - btnIcon: '🚚', - btnLabel: 'Отправлено клиенту', - ispColor: '#1677ff', - kbColor: '#1677ff', - kbShortcut: '0', + key: 'oborudovanie-send-back', + chipClass: 'chip-oborudovanie-send-back', + chipLabel: 'Отправлено клиенту', + chipDotColor: '#1677ff', + badgeClass: 'sb-oborudovanie-send-back', + label: 'Оборудование отправлено клиенту', + sseLabel: 'Оборудование отправлено клиенту', + exportLabel: 'Оборудование отправлено клиенту', + btnClass: 'mbtn-oborudovanie-send-back', + rowClass: 'row-oborudovanie-send-back', + btnIcon: '🚚', + btnLabel: 'Отправлено клиенту', + ispColor: '#1677ff', + kbColor: '#1677ff', + kbShortcut: '0', clearOnUpdate: false, + colorBg: '#e6f4ff', + colorBorder: '#91caff', + colorText: '#0958d9', + colorDot: '#1677ff', + is_system: false, + system_role: null, + sort_order: 90, + is_active: true, }, { - key: 'oborudovanie-returned', - chipClass: 'chip-oborudovanie-returned', - chipLabel: 'Клиент получил', - chipDotColor: '#722ed1', - badgeClass: 'sb-oborudovanie-returned', - label: 'Клиент получил оборудование', - sseLabel: 'Клиент получил оборудование', - exportLabel: 'Клиент получил оборудование', - btnClass: 'mbtn-oborudovanie-returned', - rowClass: 'row-oborudovanie-returned', - btnIcon: '🏠', - btnLabel: 'Клиент получил', - ispColor: '#722ed1', - kbColor: '#722ed1', - kbShortcut: '', + key: 'oborudovanie-returned', + chipClass: 'chip-oborudovanie-returned', + chipLabel: 'Клиент получил', + chipDotColor: '#722ed1', + badgeClass: 'sb-oborudovanie-returned', + label: 'Клиент получил оборудование', + sseLabel: 'Клиент получил оборудование', + exportLabel: 'Клиент получил оборудование', + btnClass: 'mbtn-oborudovanie-returned', + rowClass: 'row-oborudovanie-returned', + btnIcon: '🏠', + btnLabel: 'Клиент получил', + ispColor: '#722ed1', + kbColor: '#722ed1', + kbShortcut: '', clearOnUpdate: false, + colorBg: '#f9f0ff', + colorBorder: '#d3adf7', + colorText: '#531dab', + colorDot: '#722ed1', + is_system: false, + system_role: null, + sort_order: 100, + is_active: true, }, ]; -// Совместимость с Node.js (server.js использует require) -if (typeof module !== 'undefined') module.exports = MARKS_CONFIG; +function cloneDefaultConfig() { + return DEFAULT_MARKS_CONFIG.map((item) => ({ ...item })); +} + +function normalizeStatusConfig(item, index) { + const key = String(item?.key || '').trim(); + const label = String(item?.label || item?.chipLabel || key || `Статус ${index + 1}`); + const colorDot = item?.colorDot || item?.chipDotColor || item?.ispColor || '#1677ff'; + const colorBg = item?.colorBg || 'var(--surface2)'; + const colorBorder = item?.colorBorder || 'var(--border2)'; + const colorText = item?.colorText || 'var(--text2)'; + const sortOrder = Number.isFinite(Number(item?.sort_order)) ? Number(item.sort_order) : index + 1; + + return { + key, + chipClass: item?.chipClass || `chip-${key}`, + chipLabel: item?.chipLabel || label, + chipDotColor: item?.chipDotColor || colorDot, + badgeClass: item?.badgeClass || 'sb-custom', + label, + sseLabel: item?.sseLabel || label, + exportLabel: item?.exportLabel || label, + btnClass: item?.btnClass || 'mbtn-custom', + rowClass: item?.rowClass || '', + btnIcon: item?.btnIcon || '•', + btnLabel: item?.btnLabel || label, + ispColor: item?.ispColor || colorDot, + kbColor: item?.kbColor || colorDot, + kbShortcut: item?.kbShortcut || '', + clearOnUpdate: !!item?.clearOnUpdate, + colorBg, + colorBorder, + colorText, + colorDot, + is_system: !!item?.is_system, + system_role: item?.system_role || null, + sort_order: sortOrder, + is_active: item?.is_active !== false, + }; +} + +let MARKS_CONFIG = cloneDefaultConfig().map(normalizeStatusConfig); + +function setMarksConfig(nextConfig) { + const sourceRaw = Array.isArray(nextConfig) && nextConfig.length ? nextConfig : cloneDefaultConfig(); + const source = sourceRaw.filter((item) => item?.is_active !== false); + MARKS_CONFIG = source.map(normalizeStatusConfig); + if (typeof window !== 'undefined') { + window.MARKS_CONFIG = MARKS_CONFIG; + if (typeof window.onMarksConfigChanged === 'function') { + window.onMarksConfigChanged(); + } + } + return MARKS_CONFIG; +} + +if (typeof window !== 'undefined') { + window.DEFAULT_MARKS_CONFIG = cloneDefaultConfig(); + window.MARKS_CONFIG = MARKS_CONFIG; + window.setMarksConfig = setMarksConfig; +} + +if (typeof module !== 'undefined') { + module.exports = cloneDefaultConfig(); +} diff --git a/public/js/ui.js b/public/js/ui.js index 87339b4..439cf0e 100644 --- a/public/js/ui.js +++ b/public/js/ui.js @@ -16,6 +16,60 @@ let statFilter = 'all'; let selectedRows = new Set(); let mainTab = 'all'; // 'all' | 'open' | 'done' | 'empty' +function getSelectedSet(selectId){ + const el=document.getElementById(selectId); + if(!el) return new Set(); + return new Set([...el.selectedOptions].map(o=>String(o.value)).filter(Boolean)); +} + +function fillMultiSelect(selectId, items, selectedSet, emptyLabel){ + const el=document.getElementById(selectId); + if(!el) return; + if(!items.length){ + el.innerHTML=``; + return; + } + const html=items.map(({value,label})=>``).join(''); + el.innerHTML=html; + [...el.options].forEach((opt)=>{ opt.selected=selectedSet.has(String(opt.value)); }); +} + +function refreshAdvancedFilters(){ + const who=document.getElementById('filter-who-select'); + if(who && !who.value) who.value='all'; + + const selectedExecutors=getSelectedSet('filter-executor-select'); + const selectedObjects=getSelectedSet('filter-object-select'); + const selectedIntra=getSelectedSet('filter-intra-select'); + const selectedInternal=getSelectedSet('filter-internal-select'); + + const execMap=new Map(); + const objectMap=new Map(); + const intraMap=new Map(); + allData.forEach(({task})=>{ + const execId=String(task.executor||'').trim(); + if(execId){ + const execName=String(task._executorname||task.executor||execId).trim(); + execMap.set(execId, execName); + } + const objectName=String(task._clientname||task.clientname||task.organization||'').trim(); + if(objectName) objectMap.set(objectName, objectName); + const intraId=String(task.statusid||task.status||'').trim(); + const intraName=String(task._statusname||task.statusname||intraId).trim(); + if(intraId) intraMap.set(intraId, intraName||intraId); + }); + + const execItems=[...execMap.entries()].map(([value,label])=>({value,label})).sort((a,b)=>a.label.localeCompare(b.label,'ru')); + const objectItems=[...objectMap.entries()].map(([value,label])=>({value,label})).sort((a,b)=>a.label.localeCompare(b.label,'ru')); + const intraItems=[...intraMap.entries()].map(([value,label])=>({value,label:`#${value} ${label}`})); + const internalItems=MARKS_CONFIG.map((m)=>({value:m.key,label:m.label})); + + fillMultiSelect('filter-executor-select', execItems, selectedExecutors, 'No executors'); + fillMultiSelect('filter-object-select', objectItems, selectedObjects, 'No objects'); + fillMultiSelect('filter-intra-select', intraItems, selectedIntra, 'No Intra statuses'); + fillMultiSelect('filter-internal-select', internalItems, selectedInternal, 'No statuses'); +} + function setMainTab(tab, el) { mainTab = tab; document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('tab-btn-active')); @@ -25,11 +79,11 @@ function setMainTab(tab, el) { function isTaskOpen(item) { const sn = (item.task._statusname || item.task.statusname || '').toLowerCase(); - return sn.includes('открыт') || sn.includes('в работе') || sn.includes('новая') || sn.includes('new') || sn.includes('назначен'); + return sn.includes('открыт') || sn.includes('в работе') || sn.includes('новая') || sn.includes('new') || sn.includes('назнач'); } function isTaskDone(item) { const sn = (item.task._statusname || item.task.statusname || '').toLowerCase(); - return sn.includes('выполнен') || sn.includes('закрыт') || sn.includes('решен'); + return sn.includes('выполн') || sn.includes('закрыт') || sn.includes('решен'); } function isTaskEmpty(item) { return !item.lastComment; @@ -67,12 +121,6 @@ function toggleStateChip(name){ setChip(document.getElementById('chip-'+name),stateChips[name]); syncAllChip(); renderTable(); } -function toggleAllChip(){ - const t=!STATE_CHIP_KEYS.every(k=>stateChips[k]); - STATE_CHIP_KEYS.forEach(k=>{stateChips[k]=t;setChip(document.getElementById('chip-'+k),t);}); - setChip(document.getElementById('chip-all'),t); - renderTable(); -} // ── Init mark chips + bulk-select from MARKS_CONFIG ─────────────── function initMarkChips(){ @@ -89,6 +137,7 @@ function initMarkChips(){ // Bulk-status select options const sel=document.getElementById('bulk-status-sel'); if(sel){ + sel.innerHTML=''; const clearOpt=sel.querySelector('option[value="clear"]'); MARKS_CONFIG.slice().reverse().forEach(m=>{ const opt=document.createElement('option'); @@ -96,6 +145,7 @@ function initMarkChips(){ sel.insertBefore(opt, clearOpt); }); } + if(typeof refreshAdvancedFilters==='function') refreshAdvancedFilters(); } function toggleMentionedFilter(){ mentionedFilterOn=!mentionedFilterOn; @@ -160,26 +210,39 @@ function taskMatchesDate(task){ } // ── Status badges ───────────────────────────────────────────────── +function statusBadgeStyle(m){ + const bg=m.colorBg||'var(--surface2)'; + const border=m.colorBorder||'var(--border2)'; + const text=m.colorText||'var(--text2)'; + const dot=m.colorDot||m.chipDotColor||m.ispColor||text; + return { + outer:`background:${bg};border-color:${border};color:${text};`, + dot:`background:${dot};`, + }; +} + function internalStatusBadge(tid){ - const m=MARKS_CONFIG.find(m=>isMark(tid,m.key)); - if(m) return`${m.label}`; + const m=MARKS_CONFIG.find((status)=>isMark(tid,status.key)); + if(m){ + const style=statusBadgeStyle(m); + return `${esc(m.label)}`; + } return`Без статуса`; } // ── IntraDesk status badge ──────────────────────────────────────── function intraStatusBadge(task) { const name = task._statusname || task.statusname || ''; if (!name) { - // Показываем числовой id статуса как fallback const sid = task.statusid || task.status; if (sid) return `#${sid}`; return ''; } - const lc = name.toLowerCase(); + const lc = String(name).toLowerCase(); let cls = 'sb-intra-other'; - if (lc.includes('открыт') || lc.includes('new') || lc.includes('открытый')) cls = 'sb-intra-open'; - else if (lc.includes('работ') || lc.includes('прогресс') || lc.includes('назначен')) cls = 'sb-intra-work'; - else if (lc.includes('выполнен') || lc.includes('закрыт') || lc.includes('решен')) cls = 'sb-intra-done'; - else if (lc.includes('ожидан') || lc.includes('ожидает') || lc.includes('hold')) cls = 'sb-intra-hold'; + if (lc.includes('открыт') || lc.includes('new')) cls = 'sb-intra-open'; + else if (lc.includes('работ') || lc.includes('прогресс') || lc.includes('назнач')) cls = 'sb-intra-work'; + else if (lc.includes('выполн') || lc.includes('закрыт') || lc.includes('решен')) cls = 'sb-intra-done'; + else if (lc.includes('ожидан') || lc.includes('hold')) cls = 'sb-intra-hold'; return `${esc(name)}`; } // Who answered — ring class for avatar @@ -299,17 +362,36 @@ function renderTable(keepPage){ if(!keepPage) currentPage = 1; const search =document.getElementById('search-input')?.value?.toLowerCase()||''; const remoteOnly =document.getElementById('opt-remote-only')?.checked; + const whoSelect =document.getElementById('filter-who-select')?.value||'all'; + const selectedExecutors=getSelectedSet('filter-executor-select'); + const selectedObjects=getSelectedSet('filter-object-select'); + const selectedIntra=getSelectedSet('filter-intra-select'); + const selectedInternal=getSelectedSet('filter-internal-select'); filteredData=allData.filter(item=>{ const tid=String(item.task.id); const marked=MARKS_CONFIG.some(m=>isMark(tid,m.key)); + const activeInternal=MARKS_CONFIG.find((m)=>isMark(tid,m.key))?.key||''; for(const m of MARKS_CONFIG){if(isMark(tid,m.key)&&!stateChips[m.key])return false;} if(!marked&&!stateChips.unmarked) return false; if(mentionedFilterOn&&!mentionsData.some(m=>String(m.task_id)===tid)) return false; if(search&&!(item.task.name?.toLowerCase().includes(search)||String(item.task.tasknumber).includes(search)||(item.task._clientname||item.task.clientname||'').toLowerCase().includes(search))) return false; if(remoteOnly&&item.task.service!==84683) return false; if(!taskMatchesDate(item.task)) return false; - if(statFilter!=='all'&&getAuthorType(item)!==statFilter) return false; + if(selectedExecutors.size&& !selectedExecutors.has(String(item.task.executor||''))) return false; + if(selectedObjects.size){ + const objectName=String(item.task._clientname||item.task.clientname||item.task.organization||'').trim(); + if(!objectName || !selectedObjects.has(objectName)) return false; + } + if(selectedIntra.size){ + const intraId=String(item.task.statusid||item.task.status||'').trim(); + if(!intraId || !selectedIntra.has(intraId)) return false; + } + if(selectedInternal.size){ + if(!activeInternal || !selectedInternal.has(activeInternal)) return false; + } + const whoFilter=whoSelect!=='all'?whoSelect:statFilter; + if(whoFilter!=='all'&&getAuthorType(item)!==whoFilter) return false; // Main tab filter if(mainTab==='open' && !isTaskOpen(item)) return false; if(mainTab==='done' && !isTaskDone(item)) return false; @@ -484,15 +566,16 @@ function bulkApplyStatusFromSelect(sel){ function bulkApplyStatus(markType){ if(!selectedRows.size)return; const now=new Date().toISOString(); + const statusKeys=MARKS_CONFIG.map((m)=>m.key); const tids=[...selectedRows].map(idx=>allData[idx]?String(allData[idx].task.id):null).filter(Boolean); for(const tid of tids){ if(!marksCache[tid])marksCache[tid]={}; if(!markType){ - const active=STATUS_MARKS.find(m=>marksCache[tid][m]?.active); - STATUS_MARKS.forEach(m=>{marksCache[tid][m]={...(marksCache[tid][m]||{}),active:false};}); + const active=statusKeys.find(m=>marksCache[tid][m]?.active); + statusKeys.forEach(m=>{marksCache[tid][m]={...(marksCache[tid][m]||{}),active:false};}); if(active) apiPost('/api/marks',{task_id:tid,mark_type:active,value:false,clear_others:false}); }else{ - STATUS_MARKS.forEach(m=>{marksCache[tid][m]={...(marksCache[tid][m]||{}),active:false};}); + statusKeys.forEach(m=>{marksCache[tid][m]={...(marksCache[tid][m]||{}),active:false};}); marksCache[tid][markType]={active:true,by:currentUser?.username,at:now}; apiPost('/api/marks',{task_id:tid,mark_type:markType,value:true,clear_others:true}); } @@ -843,7 +926,14 @@ async function saveNoteAndClose(idx,e){ } // ── Settings ────────────────────────────────────────────────────── -function openSettingsModal(){document.getElementById('settings-modal')?.classList.add('open');} +function openSettingsModal(){ + document.getElementById('settings-modal')?.classList.add('open'); + if(typeof openSettingsTab==='function') openSettingsTab('api'); + if(currentUser?.is_admin){ + if(typeof loadIntraStatusDictionary==='function') loadIntraStatusDictionary(); + if(typeof loadInternalStatusesAdmin==='function') loadInternalStatusesAdmin(); + } +} function closeSettingsModal(){document.getElementById('settings-modal')?.classList.remove('open');} // ── SSE refresh ─────────────────────────────────────────────────── diff --git a/refresh-service.js b/refresh-service.js index dece87a..25e2bb2 100644 --- a/refresh-service.js +++ b/refresh-service.js @@ -20,6 +20,12 @@ const REMOVE_EVENT_TYPES = new Set([ 'deleted', ]); +const COMMENT_EVENT_TYPES = new Set([50, 55]); + +function toBoolFlag(value) { + return String(value || '0') === '1' || value === true; +} + function safeIsoNow() { return new Date().toISOString(); } @@ -135,6 +141,29 @@ function createRefreshService({ db, fetch, getSettings, sseBroadcast, logger = c `), clearIndex: db.prepare('DELETE FROM tasks_index'), insertIndex: db.prepare('INSERT INTO tasks_index(task_id, task_number, updated_at) VALUES(?,?,?)'), + upsertIntraStatus: db.prepare(` + INSERT INTO intra_status_dict(status_id,name,updated_at) VALUES(?,?,?) + ON CONFLICT(status_id) DO UPDATE SET name=excluded.name,updated_at=excluded.updated_at + `), + getSystemStatuses: db.prepare(` + SELECT key, system_role + FROM internal_statuses + WHERE is_active=1 AND system_role IS NOT NULL + ORDER BY sort_order ASC, key ASC + `), + getTaskActiveMark: db.prepare(` + SELECT mark_type + FROM marks + WHERE task_id=? AND is_active=1 + ORDER BY set_at DESC, mark_type ASC + LIMIT 1 + `), + clearTaskMarks: db.prepare('UPDATE marks SET is_active=0,set_by=?,set_at=? WHERE task_id=? AND is_active=1'), + clearOtherTaskMarks: db.prepare('UPDATE marks SET is_active=0,set_by=?,set_at=? WHERE task_id=? AND mark_type<>? AND is_active=1'), + upsertTaskMark: db.prepare(` + INSERT INTO marks(task_id,mark_type,is_active,set_by,set_at) VALUES(?,?,?,?,?) + ON CONFLICT(task_id,mark_type) DO UPDATE SET is_active=excluded.is_active,set_by=excluded.set_by,set_at=excluded.set_at + `), }; function getSyncState() { @@ -164,14 +193,143 @@ function createRefreshService({ db, fetch, getSettings, sseBroadcast, logger = c function normalizeSettings() { const s = getSettings(); + const doneIds = String(s.intra_done_status_ids || '') + .split(',') + .map((v) => String(v).trim()) + .filter(Boolean); return { ...s, refresh_mode: normalizeRefreshMode(s.refresh_mode), scan_page_size: normalizePageSize(s.scan_page_size), daily_full_refresh_hour: normalizeDailyHour(s.daily_full_refresh_hour), + auto_reset_on_update: toBoolFlag(s.auto_reset_on_update), + sync_reply_wait: toBoolFlag(s.sync_reply_wait), + sync_done_with_intra: toBoolFlag(s.sync_done_with_intra), + intra_done_status_ids: doneIds, }; } + function upsertIntraStatusesFromTasks(tasks) { + const now = safeIsoNow(); + const statusMap = new Map(); + for (const task of tasks || []) { + const statusId = String(task?.statusid || task?.status || '').trim(); + const statusName = String(task?._statusname || task?.statusname || '').trim(); + if (!statusId || !statusName) continue; + statusMap.set(statusId, statusName); + } + if (!statusMap.size) return; + const tx = db.transaction(() => { + for (const [statusId, name] of statusMap.entries()) { + stmts.upsertIntraStatus.run(statusId, name, now); + } + }); + tx(); + } + + function getSystemStatusKeys() { + const rows = stmts.getSystemStatuses.all(); + const roles = {}; + for (const row of rows) { + const role = String(row.system_role || '').trim(); + if (!role) continue; + if (!roles[role]) roles[role] = String(row.key); + } + return roles; + } + + function getCurrentActiveMark(taskId) { + return stmts.getTaskActiveMark.get(String(taskId))?.mark_type || null; + } + + function setExclusiveMark(taskId, markType, setBy = 'system') { + const now = safeIsoNow(); + const id = String(taskId); + stmts.clearOtherTaskMarks.run(setBy, now, id, markType); + stmts.upsertTaskMark.run(id, markType, 1, setBy, now); + } + + function clearTaskMarks(taskId, setBy = 'system') { + const now = safeIsoNow(); + stmts.clearTaskMarks.run(setBy, now, String(taskId)); + } + + async function fetchLatestCommentAuthorType(taskId, apiKey) { + if (!apiKey) return null; + try { + const url = `https://apigw.intradesk.ru/taskhistory/api/v2.0/lifetime/${taskId}/full` + + `?ApiKey=${apiKey}&sortDirection=Desc&top=30&events=50&events=55`; + const response = await fetch(url, { timeout: 15000 }); + if (!response.ok) return null; + const json = await response.json(); + const entries = json?.data || []; + for (const entry of entries) { + const events = entry?.events?.data || []; + const hasComment = events.some((ev) => COMMENT_EVENT_TYPES.has(ev?.type)); + if (!hasComment) continue; + if (entry?.usertype === 10) return 'internal'; + if (entry?.usertype === 20) return 'external'; + return null; + } + return null; + } catch { + return null; + } + } + + async function applyStatusAutomationForChangedTasks(changedTaskIds, tasksById, settings, reason = '') { + const changedIds = [...(changedTaskIds || [])].map((v) => String(v)); + if (!changedIds.length) return; + if (!settings.sync_done_with_intra && !settings.sync_reply_wait && !settings.auto_reset_on_update) return; + + const systemStatuses = getSystemStatusKeys(); + const doneKey = systemStatuses.done || null; + const needsReplyKey = systemStatuses.needs_reply || null; + const waitClientKey = systemStatuses.wait_client || null; + const doneIds = new Set((settings.intra_done_status_ids || []).map((v) => String(v))); + + for (const taskId of changedIds) { + const task = tasksById.get(taskId); + if (!task) continue; + let currentMark = getCurrentActiveMark(taskId); + let applied = false; + + if (settings.sync_done_with_intra && doneKey && doneIds.size) { + const statusId = String(task?.statusid || task?.status || '').trim(); + if (statusId && doneIds.has(statusId)) { + if (currentMark !== doneKey) { + setExclusiveMark(taskId, doneKey, 'system'); + } + applied = true; + currentMark = doneKey; + } + } + + if (!applied && settings.sync_reply_wait && currentMark && needsReplyKey && waitClientKey) { + const inPair = currentMark === needsReplyKey || currentMark === waitClientKey; + if (inPair) { + const authorType = await fetchLatestCommentAuthorType(taskId, settings.api_key); + const target = authorType === 'internal' + ? waitClientKey + : (authorType === 'external' ? needsReplyKey : null); + if (target) { + if (currentMark !== target) { + setExclusiveMark(taskId, target, 'system'); + } + applied = true; + currentMark = target; + } else { + logger.log(`[Refresh] Pair sync skipped: no author type for task ${taskId} (${reason})`); + } + } + } + + if (!applied && settings.auto_reset_on_update && currentMark) { + clearTaskMarks(taskId, 'system'); + } + } + } + function writeCacheAndIndexes(tasks, { fullReconcile = false } = {}) { const now = safeIsoNow(); const state = getSyncState(); @@ -265,6 +423,8 @@ function createRefreshService({ db, fetch, getSettings, sseBroadcast, logger = c if (statusKey && dictMap[statusKey]) task._statusname = dictMap[statusKey]; }); + upsertIntraStatusesFromTasks(tasks); + return tasks; } @@ -290,6 +450,7 @@ function createRefreshService({ db, fetch, getSettings, sseBroadcast, logger = c const pageSize = settings.scan_page_size; const { tasks: existing } = readCachedTasks(); const byId = new Map(existing.map((t) => [extractTaskId(t), t])); + const changedTaskIds = new Set(); let changed = false; let skip = 0; @@ -309,6 +470,7 @@ function createRefreshService({ db, fetch, getSettings, sseBroadcast, logger = c byId.set(taskId, task); changed = true; pageChanged = true; + if (taskId) changedTaskIds.add(taskId); } } @@ -323,6 +485,7 @@ function createRefreshService({ db, fetch, getSettings, sseBroadcast, logger = c } logger.log(`[Refresh] Incremental scan: changed (${reason})`); + await applyStatusAutomationForChangedTasks(changedTaskIds, byId, settings, reason); return writeCacheAndIndexes([...byId.values()], { fullReconcile: false }); } @@ -331,6 +494,15 @@ function createRefreshService({ db, fetch, getSettings, sseBroadcast, logger = c const pageSize = settings.scan_page_size; const nextTasks = await fetchAllTasksPaged(pageSize); const currentTasks = readCachedTasks().tasks; + const currentById = new Map(currentTasks.map((t) => [extractTaskId(t), extractUpdatedAt(t)])); + const nextById = new Map(nextTasks.map((t) => [extractTaskId(t), t])); + const changedTaskIds = new Set(); + for (const task of nextTasks) { + const taskId = extractTaskId(task); + if (!taskId) continue; + const prevUpdated = currentById.get(taskId); + if (prevUpdated !== extractUpdatedAt(task)) changedTaskIds.add(taskId); + } if (!hasChangesByIdAndUpdated(currentTasks, nextTasks)) { logger.log(`[Refresh] Full reconcile: no changes (${reason})`); @@ -338,6 +510,7 @@ function createRefreshService({ db, fetch, getSettings, sseBroadcast, logger = c } logger.log(`[Refresh] Full reconcile: changed (${reason})`); + await applyStatusAutomationForChangedTasks(changedTaskIds, nextById, settings, reason); return writeCacheAndIndexes(nextTasks, { fullReconcile: true }); } diff --git a/server.js b/server.js index cce5e48..45f900c 100644 --- a/server.js +++ b/server.js @@ -8,6 +8,7 @@ const jwt = require('jsonwebtoken'); const fetch = require('node-fetch'); const path = require('path'); const crypto = require('crypto'); +const DEFAULT_INTERNAL_STATUSES = require('./public/js/statuses.js'); const { createRefreshService } = require('./refresh-service'); const DEFAULT_JWT_SECRET = 'CHANGE_ME_IN_PRODUCTION_PLEASE'; @@ -98,6 +99,31 @@ db.exec(` data TEXT, fetched_at TEXT NOT NULL ); + + CREATE TABLE IF NOT EXISTS internal_statuses ( + key TEXT PRIMARY KEY, + label TEXT NOT NULL, + color_bg TEXT NOT NULL, + color_border TEXT NOT NULL, + color_text TEXT NOT NULL, + color_dot TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + is_system INTEGER NOT NULL DEFAULT 0, + system_role TEXT, + is_active INTEGER NOT NULL DEFAULT 1, + clear_on_update INTEGER NOT NULL DEFAULT 0, + config_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_internal_statuses_sort ON internal_statuses(sort_order, key); + CREATE INDEX IF NOT EXISTS idx_internal_statuses_role ON internal_statuses(system_role); + + CREATE TABLE IF NOT EXISTS intra_status_dict ( + status_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + updated_at TEXT NOT NULL + ); `); // ── Middleware ──────────────────────────────────────────────────── @@ -129,6 +155,199 @@ function getSettings() { function setSetting(k, v) { db.prepare('INSERT OR REPLACE INTO settings(key,value) VALUES(?,?)').run(k, v); } + +function safeJsonParse(raw, fallback = {}) { + try { + const parsed = JSON.parse(raw); + return parsed && typeof parsed === 'object' ? parsed : fallback; + } catch { + return fallback; + } +} + +function normalizeColor(value, fallback) { + const v = String(value || '').trim(); + return v || fallback; +} + +function normalizeStatusKey(value) { + return String(value || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); +} + +function normalizeStatusConfigForDb(item = {}, index = 0) { + const key = normalizeStatusKey(item.key); + const label = String(item.label || item.chipLabel || key || `status_${index + 1}`); + const colorDot = normalizeColor(item.colorDot || item.chipDotColor || item.ispColor, '#1677ff'); + return { + key, + label, + color_bg: normalizeColor(item.colorBg, 'var(--surface2)'), + color_border: normalizeColor(item.colorBorder, 'var(--border2)'), + color_text: normalizeColor(item.colorText, 'var(--text2)'), + color_dot: colorDot, + sort_order: Number.isFinite(Number(item.sort_order)) ? Number(item.sort_order) : (index + 1) * 10, + is_system: item.is_system ? 1 : 0, + system_role: item.system_role || null, + is_active: item.is_active === false ? 0 : 1, + clear_on_update: item.clearOnUpdate ? 1 : 0, + config_json: JSON.stringify(item), + }; +} + +const seedInternalStatusesTx = db.transaction(() => { + const insertStmt = db.prepare(` + INSERT OR IGNORE INTO internal_statuses( + key,label,color_bg,color_border,color_text,color_dot,sort_order,is_system,system_role,is_active,clear_on_update,config_json,created_at,updated_at + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,datetime('now'),datetime('now')) + `); + const patchSystemStmt = db.prepare(` + UPDATE internal_statuses + SET is_system=?, + system_role=CASE WHEN system_role IS NULL OR system_role='' THEN ? ELSE system_role END, + updated_at=datetime('now') + WHERE key=? + `); + const patchConfigStmt = db.prepare(` + UPDATE internal_statuses + SET config_json=?, updated_at=datetime('now') + WHERE key=? AND (config_json IS NULL OR config_json='{}' OR config_json='') + `); + const patchColorsStmt = db.prepare(` + UPDATE internal_statuses + SET color_bg=COALESCE(NULLIF(color_bg,''), ?), + color_border=COALESCE(NULLIF(color_border,''), ?), + color_text=COALESCE(NULLIF(color_text,''), ?), + color_dot=COALESCE(NULLIF(color_dot,''), ?), + updated_at=datetime('now') + WHERE key=? + `); + const patchLabelStmt = db.prepare(` + UPDATE internal_statuses + SET label=?, updated_at=datetime('now') + WHERE key=? AND (label IS NULL OR label='' OR label='Можно закрыть') + `); + + DEFAULT_INTERNAL_STATUSES.forEach((item, index) => { + const row = normalizeStatusConfigForDb(item, index); + if (!row.key) return; + insertStmt.run( + row.key, + row.label, + row.color_bg, + row.color_border, + row.color_text, + row.color_dot, + row.sort_order, + row.is_system, + row.system_role, + row.is_active, + row.clear_on_update, + row.config_json, + ); + patchSystemStmt.run(row.is_system, row.system_role, row.key); + patchConfigStmt.run(row.config_json, row.key); + patchColorsStmt.run(row.color_bg, row.color_border, row.color_text, row.color_dot, row.key); + }); + + patchLabelStmt.run('Выполнена', 'resolved'); +}); + +seedInternalStatusesTx(); + +function hydrateIntraStatusDictFromCache() { + const cacheRow = db.prepare('SELECT data FROM tasks_cache WHERE id=1').get(); + if (!cacheRow?.data) return; + try { + const tasks = JSON.parse(cacheRow.data || '[]'); + const now = new Date().toISOString(); + const upsert = db.prepare(` + INSERT INTO intra_status_dict(status_id,name,updated_at) VALUES(?,?,?) + ON CONFLICT(status_id) DO UPDATE SET name=excluded.name,updated_at=excluded.updated_at + `); + const tx = db.transaction(() => { + tasks.forEach((task) => { + const statusId = String(task.statusid || task.status || '').trim(); + const statusName = String(task._statusname || task.statusname || '').trim(); + if (!statusId || !statusName) return; + upsert.run(statusId, statusName, now); + }); + }); + tx(); + } catch {} +} + +hydrateIntraStatusDictFromCache(); + +function mapInternalStatusRowToConfig(row) { + const cfg = safeJsonParse(row.config_json, {}); + const key = String(row.key); + const label = String(row.label || cfg.label || key); + const dot = normalizeColor(row.color_dot || cfg.colorDot || cfg.chipDotColor || cfg.ispColor, '#1677ff'); + return { + ...cfg, + key, + label, + chipLabel: label, + sseLabel: cfg.sseLabel || label, + exportLabel: cfg.exportLabel || label, + btnLabel: cfg.btnLabel || label, + chipClass: cfg.chipClass || `chip-${key}`, + badgeClass: cfg.badgeClass || 'sb-custom', + btnClass: cfg.btnClass || 'mbtn-custom', + rowClass: cfg.rowClass || '', + btnIcon: cfg.btnIcon || '•', + chipDotColor: dot, + ispColor: cfg.ispColor || dot, + kbColor: cfg.kbColor || dot, + kbShortcut: cfg.kbShortcut || '', + clearOnUpdate: !!row.clear_on_update, + colorBg: normalizeColor(row.color_bg, 'var(--surface2)'), + colorBorder: normalizeColor(row.color_border, 'var(--border2)'), + colorText: normalizeColor(row.color_text, 'var(--text2)'), + colorDot: dot, + is_system: !!row.is_system, + system_role: row.system_role || null, + sort_order: Number(row.sort_order || 0), + is_active: !!row.is_active, + }; +} + +function getInternalStatuses({ includeInactive = true } = {}) { + const where = includeInactive ? '' : 'WHERE is_active=1'; + const rows = db.prepare(` + SELECT key,label,color_bg,color_border,color_text,color_dot,sort_order,is_system,system_role,is_active,clear_on_update,config_json + FROM internal_statuses + ${where} + ORDER BY sort_order ASC, key ASC + `).all(); + return rows.map(mapInternalStatusRowToConfig); +} + +function getActiveStatusKeys() { + return db.prepare('SELECT key FROM internal_statuses WHERE is_active=1').all().map((r) => String(r.key)); +} + +function getInternalStatusByKey(key) { + return db.prepare(` + SELECT key,label,color_bg,color_border,color_text,color_dot,sort_order,is_system,system_role,is_active,clear_on_update,config_json + FROM internal_statuses WHERE key=? + `).get(String(key)); +} + +function normalizeCsvIds(raw) { + return String(raw || '') + .split(',') + .map((v) => String(v).trim()) + .filter(Boolean) + .filter((v, idx, arr) => arr.indexOf(v) === idx) + .join(','); +} + function parseMentions(text) { return [...new Set((text.match(/@([\w\u0400-\u04ff]+)/g) || []).map(m => m.slice(1).toLowerCase()))]; } @@ -370,8 +589,143 @@ app.post('/api/webhooks/intradesk', async (req, res) => { }); // ── Marks ───────────────────────────────────────────────────────── -const _marksConfig = require('./public/js/statuses.js'); -const ALLOWED_MARKS = new Set(_marksConfig.map(m => m.key)); + +app.get('/api/internal-statuses', auth, (req, res) => { + const includeInactive = req.user.username === 'admin' && String(req.query.include_all || '') === '1'; + const statuses = getInternalStatuses({ includeInactive }); + res.json({ statuses }); +}); + +app.post('/api/internal-statuses', auth, adminOnly, (req, res) => { + const body = req.body || {}; + const label = String(body.label || '').trim(); + if (!label) return res.status(400).json({ error: 'label required' }); + + let key = normalizeStatusKey(body.key || label); + if (!key) key = `status_${Date.now()}`; + const exists = getInternalStatusByKey(key); + if (exists) return res.status(400).json({ error: 'Статус с таким key уже существует' }); + + const sortOrder = Number.isFinite(Number(body.sort_order)) ? Number(body.sort_order) : Date.now(); + const row = normalizeStatusConfigForDb({ + key, + label, + colorBg: body.color_bg || body.colorBg || '#e6f4ff', + colorBorder: body.color_border || body.colorBorder || '#91caff', + colorText: body.color_text || body.colorText || '#0958d9', + colorDot: body.color_dot || body.colorDot || '#1677ff', + chipLabel: label, + sseLabel: label, + exportLabel: label, + btnLabel: label, + btnIcon: String(body.btn_icon || '•'), + sort_order: sortOrder, + is_system: false, + system_role: null, + is_active: body.is_active !== false, + clearOnUpdate: !!body.clear_on_update, + }, 0); + + db.prepare(` + INSERT INTO internal_statuses( + key,label,color_bg,color_border,color_text,color_dot,sort_order,is_system,system_role,is_active,clear_on_update,config_json,created_at,updated_at + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,datetime('now'),datetime('now')) + `).run( + row.key, + row.label, + row.color_bg, + row.color_border, + row.color_text, + row.color_dot, + row.sort_order, + 0, + null, + row.is_active, + row.clear_on_update, + row.config_json, + ); + + res.json({ ok: true, status: mapInternalStatusRowToConfig(getInternalStatusByKey(key)) }); +}); + +app.put('/api/internal-statuses/:key', auth, adminOnly, (req, res) => { + const key = normalizeStatusKey(req.params.key); + const existing = getInternalStatusByKey(key); + if (!existing) return res.status(404).json({ error: 'Статус не найден' }); + + const body = req.body || {}; + const cfg = safeJsonParse(existing.config_json, {}); + const nextLabel = String(body.label ?? existing.label).trim() || existing.label; + const nextRow = { + color_bg: normalizeColor(body.color_bg || body.colorBg, existing.color_bg), + color_border: normalizeColor(body.color_border || body.colorBorder, existing.color_border), + color_text: normalizeColor(body.color_text || body.colorText, existing.color_text), + color_dot: normalizeColor(body.color_dot || body.colorDot, existing.color_dot), + sort_order: Number.isFinite(Number(body.sort_order)) ? Number(body.sort_order) : Number(existing.sort_order || 0), + is_active: body.is_active === undefined ? Number(existing.is_active) : (body.is_active ? 1 : 0), + clear_on_update: body.clear_on_update === undefined ? Number(existing.clear_on_update) : (body.clear_on_update ? 1 : 0), + }; + + const mergedCfg = { + ...cfg, + key: existing.key, + label: nextLabel, + chipLabel: nextLabel, + sseLabel: nextLabel, + exportLabel: nextLabel, + btnLabel: nextLabel, + chipDotColor: nextRow.color_dot, + ispColor: nextRow.color_dot, + colorBg: nextRow.color_bg, + colorBorder: nextRow.color_border, + colorText: nextRow.color_text, + colorDot: nextRow.color_dot, + sort_order: nextRow.sort_order, + is_active: !!nextRow.is_active, + clearOnUpdate: !!nextRow.clear_on_update, + is_system: !!existing.is_system, + system_role: existing.system_role || null, + }; + + db.prepare(` + UPDATE internal_statuses + SET label=?, color_bg=?, color_border=?, color_text=?, color_dot=?, sort_order=?, is_active=?, clear_on_update=?, config_json=?, updated_at=datetime('now') + WHERE key=? + `).run( + nextLabel, + nextRow.color_bg, + nextRow.color_border, + nextRow.color_text, + nextRow.color_dot, + nextRow.sort_order, + nextRow.is_active, + nextRow.clear_on_update, + JSON.stringify(mergedCfg), + existing.key, + ); + + res.json({ ok: true, status: mapInternalStatusRowToConfig(getInternalStatusByKey(existing.key)) }); +}); + +app.delete('/api/internal-statuses/:key', auth, adminOnly, (req, res) => { + const key = normalizeStatusKey(req.params.key); + const existing = getInternalStatusByKey(key); + if (!existing) return res.status(404).json({ error: 'Статус не найден' }); + if (existing.is_system) return res.status(400).json({ error: 'Системный статус удалять нельзя' }); + + const tx = db.transaction(() => { + db.prepare('DELETE FROM marks WHERE mark_type=?').run(existing.key); + db.prepare('DELETE FROM internal_statuses WHERE key=?').run(existing.key); + }); + tx(); + + res.json({ ok: true }); +}); + +app.get('/api/intra/status-dictionary', auth, adminOnly, (req, res) => { + const items = db.prepare('SELECT status_id, name, updated_at FROM intra_status_dict ORDER BY name ASC, status_id ASC').all(); + res.json({ items }); +}); app.get('/api/marks', auth, (req, res) => { const r = {}; @@ -382,17 +736,19 @@ app.get('/api/marks', auth, (req, res) => { res.json(r); }); -const ALL_STATUS_MARKS = _marksConfig.map(m => m.key); - app.post('/api/marks', auth, (req, res) => { const { task_id, mark_type, value, clear_others } = req.body || {}; - if (!task_id || !ALLOWED_MARKS.has(mark_type)) return res.status(400).json({ error: 'Неверные параметры' }); + const markType = String(mark_type || '').trim(); + if (!task_id || !markType) return res.status(400).json({ error: 'Неверные параметры' }); + const activeStatuses = new Set(getActiveStatusKeys()); + if (!activeStatuses.has(markType)) return res.status(400).json({ error: 'Статус отключен или не найден' }); const now = new Date().toISOString(); + const allStatusMarks = [...activeStatuses]; // Если clear_others=true — сбрасываем все остальные статусы одним проходом const clearedMarks = []; if (value && clear_others) { - ALL_STATUS_MARKS.filter(t => t !== mark_type).forEach(t => { + allStatusMarks.filter(t => t !== markType).forEach(t => { const existing = db.prepare('SELECT is_active FROM marks WHERE task_id=? AND mark_type=?').get(task_id, t); if (existing?.is_active) { db.prepare('UPDATE marks SET is_active=0,set_by=?,set_at=? WHERE task_id=? AND mark_type=?') @@ -404,11 +760,11 @@ app.post('/api/marks', auth, (req, res) => { db.prepare(`INSERT INTO marks(task_id,mark_type,is_active,set_by,set_at) VALUES(?,?,?,?,?) ON CONFLICT(task_id,mark_type) DO UPDATE SET is_active=excluded.is_active,set_by=excluded.set_by,set_at=excluded.set_at`) - .run(task_id, mark_type, value ? 1 : 0, req.user.username, now); + .run(task_id, markType, value ? 1 : 0, req.user.username, now); // Одно SSE-событие со списком сброшенных меток sseBroadcast('mark', { - task_id, mark_type, value: !!value, by: req.user.username, at: now, + task_id, mark_type: markType, value: !!value, by: req.user.username, at: now, cleared_marks: clearedMarks, }); res.json({ ok: true }); @@ -544,6 +900,10 @@ const ALLOWED_SETTINGS = new Set([ 'unread_mode', 'export_fields', 'export_status_mode', + 'auto_reset_on_update', + 'sync_reply_wait', + 'sync_done_with_intra', + 'intra_done_status_ids', ]); app.get('/api/settings', auth, adminOnly, (req, res) => { @@ -558,6 +918,10 @@ app.get('/api/settings', auth, adminOnly, (req, res) => { unread_mode: s.unread_mode || 'highlight', export_fields: s.export_fields || '', export_status_mode: s.export_status_mode || 'B', + auto_reset_on_update: s.auto_reset_on_update || '0', + sync_reply_wait: s.sync_reply_wait || '0', + sync_done_with_intra: s.sync_done_with_intra || '0', + intra_done_status_ids: s.intra_done_status_ids || '', api_key_set: !!s.api_key, webhook_secret_set: !!s.webhook_secret, }); @@ -565,7 +929,18 @@ app.get('/api/settings', auth, adminOnly, (req, res) => { app.post('/api/settings', auth, adminOnly, (req, res) => { const body = req.body || {}; - Object.entries(body).forEach(([k, v]) => { if (ALLOWED_SETTINGS.has(k) && v !== undefined) setSetting(k, String(v)); }); + Object.entries(body).forEach(([k, v]) => { + if (!ALLOWED_SETTINGS.has(k) || v === undefined) return; + if (k === 'intra_done_status_ids') { + setSetting(k, normalizeCsvIds(v)); + return; + } + if (k === 'auto_reset_on_update' || k === 'sync_reply_wait' || k === 'sync_done_with_intra') { + setSetting(k, String(v) === '1' || v === true ? '1' : '0'); + return; + } + setSetting(k, String(v)); + }); res.json({ ok: true }); });