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
-
-
+
+
+
+
+
+
+
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 });
});