vibe 2
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
dv 2026-03-18 18:35:03 +03:00
parent 159e23dccf
commit fc730fb64b
10 changed files with 1594 additions and 421 deletions

View file

@ -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:

View file

@ -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;
}

View file

@ -31,35 +31,107 @@
<button class="modal-close-btn" onclick="closeSettingsModal()">×</button>
</div>
<div class="settings-body">
<div class="settings-section-title">Подключение к IntraDesk API</div>
<div class="settings-tabs">
<button class="settings-tab-btn active" data-tab="api" onclick="openSettingsTab('api')">API / Refresh</button>
<button class="settings-tab-btn" data-tab="automation" onclick="openSettingsTab('automation')">Automation</button>
<button class="settings-tab-btn" data-tab="statuses" onclick="openSettingsTab('statuses')">Internal Statuses</button>
<button class="settings-tab-btn" data-tab="users" onclick="openSettingsTab('users')">Users</button>
</div>
<div class="settings-tab-pane" data-tab="api">
<div class="settings-section-title">IntraDesk API</div>
<div class="form-row">
<div class="form-field" style="flex:2;min-width:240px;">
<label>API Key <span id="apikey-status" style="color:var(--green);font-weight:500;text-transform:none;letter-spacing:0;"></span></label>
<input type="password" id="api-key" placeholder="Введите новый ключ для обновления">
<input type="password" id="api-key" placeholder="Enter new key to update">
</div>
<div class="form-field"><label>Статусы (ID через запятую)</label><input type="text" id="status-ids" placeholder="68051,68046,..."></div>
<div class="form-field"><label>Status IDs (CSV)</label><input type="text" id="status-ids" placeholder="68051,68046,..."></div>
</div>
<div class="form-row">
<div class="form-field"><label>Фильтр событий (пусто = все)</label><input type="text" id="event-types" placeholder="50,55,10,30"></div>
<div class="form-field"><label>Автообновление (сек, 0=выкл)</label><input type="number" id="server-refresh-interval" placeholder="300" min="0"></div>
<div class="form-field"><label>Event Types Filter (empty = all)</label><input type="text" id="event-types" placeholder="50,55,10,30"></div>
<div class="form-field"><label>Auto Refresh (sec, 0=off)</label><input type="number" id="server-refresh-interval" placeholder="300" min="0"></div>
</div>
<button class="btn btn-primary" onclick="saveSettings()">💾 Сохранить</button>
<button class="btn btn-default" onclick="loadAllTasks()" id="refresh-all-btn" style="margin-left:8px;">🔄 Обновить ВСЕ заявки</button>
<button class="btn btn-primary" onclick="saveSettings()">Save</button>
<button class="btn btn-default" onclick="loadAllTasks()" id="refresh-all-btn" style="margin-left:8px;">Refresh All Tasks</button>
<span id="refresh-all-status" style="font-size:12px;color:var(--text3);margin-left:10px;"></span>
<hr class="divider">
<div class="settings-section-title">Управление пользователями</div>
</div>
<div class="settings-tab-pane" data-tab="automation" style="display:none;">
<div class="settings-section-title">Status Automation</div>
<div class="form-row">
<div class="form-field"><label>Логин</label><input type="text" id="new-user-login" placeholder="username"></div>
<div class="form-field"><label>Пароль (мин. 6 символов)</label><input type="password" id="new-user-pass" placeholder="••••••"></div>
<div style="display:flex;align-items:flex-end;padding-bottom:1px;"><button class="btn btn-default" onclick="createUser()">+ Создать</button></div>
<label style="display:flex;align-items:center;gap:8px;font-size:13px;color:var(--text2);">
<input type="checkbox" id="setting-auto-reset-on-update" style="accent-color:var(--accent);">
Reset internal status on update (if no other rule matched)
</label>
</div>
<div class="form-row">
<label style="display:flex;align-items:center;gap:8px;font-size:13px;color:var(--text2);">
<input type="checkbox" id="setting-sync-reply-wait" style="accent-color:var(--accent);">
Sync reply/wait pair
</label>
</div>
<div class="form-row">
<label style="display:flex;align-items:center;gap:8px;font-size:13px;color:var(--text2);">
<input type="checkbox" id="setting-sync-done-intra" style="accent-color:var(--accent);">
Sync Done with Intra by status_id
</label>
</div>
<div class="form-row">
<div class="form-field" style="flex:1;">
<label>Done Intra IDs (CSV)</label>
<input type="text" id="setting-intra-done-status-ids" placeholder="68045,68099" oninput="syncDoneSelectFromInput()">
</div>
<div class="form-field" style="flex:1;">
<label>Intra Status Dictionary (multi-select)</label>
<select id="setting-intra-done-select" multiple style="min-height:130px;" onchange="syncDoneIdsFromSelect()"></select>
</div>
</div>
<button class="btn btn-primary" onclick="saveSettings()">Save</button>
</div>
<div class="settings-tab-pane" data-tab="statuses" style="display:none;">
<div class="settings-section-title">Add Status</div>
<div class="form-row">
<div class="form-field"><label>Label</label><input type="text" id="new-status-label" placeholder="New status"></div>
<div class="form-field"><label>Key (optional)</label><input type="text" id="new-status-key" placeholder="new_status"></div>
<div class="form-field"><label>Dot</label><input type="color" id="new-status-dot" value="#1677ff"></div>
<div class="form-field"><label>Bg</label><input type="color" id="new-status-bg" value="#e6f4ff"></div>
<div class="form-field"><label>Order</label><input type="number" id="new-status-order" placeholder="1000"></div>
<div style="display:flex;align-items:flex-end;"><button class="btn btn-default" onclick="createInternalStatus()">+ Add</button></div>
</div>
<div class="settings-section-title" style="margin-top:14px;">Status Catalog</div>
<table class="users-table">
<thead>
<tr>
<th>Key</th>
<th>Label</th>
<th>Dot</th>
<th>Bg</th>
<th>Order</th>
<th>Active</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody id="internal-statuses-tbody"></tbody>
</table>
</div>
<div class="settings-tab-pane" data-tab="users" style="display:none;">
<div class="settings-section-title">Users</div>
<div class="form-row">
<div class="form-field"><label>Username</label><input type="text" id="new-user-login" placeholder="username"></div>
<div class="form-field"><label>Password (min. 6 chars)</label><input type="password" id="new-user-pass" placeholder="******"></div>
<div style="display:flex;align-items:flex-end;padding-bottom:1px;"><button class="btn btn-default" onclick="createUser()">+ Create</button></div>
</div>
<div id="user-msg" style="font-size:12px;min-height:16px;margin-bottom:8px;"></div>
<table class="users-table">
<thead><tr><th>Логин</th><th>Создан</th><th>Новый пароль</th><th></th></tr></thead>
<thead><tr><th>Username</th><th>Created</th><th>New Password</th><th></th></tr></thead>
<tbody id="users-tbody"></tbody>
</table>
</div>
</div>
</div>
</div>
<!-- CONFIRM MODAL -->
@ -214,7 +286,18 @@
</div>
</div>
</div>
<input class="filter-search" type="text" id="search-input" placeholder="Поиск..." oninput="renderTable()">
<input class="filter-search" type="text" id="search-input" placeholder="Search..." oninput="renderTable()">
<div class="filter-row filter-row-advanced">
<select class="filter-select" id="filter-who-select" onchange="renderTable()">
<option value="all">Who answered: all</option>
<option value="internal">Internal</option>
<option value="external">External</option>
<option value="nocomment">No comment</option>
</select>
<select class="filter-select filter-multi" id="filter-executor-select" multiple onchange="renderTable()"></select>
<select class="filter-select filter-multi" id="filter-object-select" multiple onchange="renderTable()"></select>
<select class="filter-select filter-multi" id="filter-intra-select" multiple onchange="renderTable()"></select>
<select class="filter-select filter-multi" id="filter-internal-select" multiple onchange="renderTable()"></select>
</div>
</div>

View file

@ -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 loadInternalStatusesConfig(includeAll = false, applyToRuntime = true) {
const suffix = includeAll ? '?include_all=1' : '';
const d = await apiGet(`/api/internal-statuses${suffix}`);
const statuses = d?.statuses || [];
if (applyToRuntime && statuses.length && typeof setMarksConfig === 'function') {
const active = statuses.filter((s) => s.is_active !== false);
setMarksConfig(active);
if (typeof syncChipDropdownItems === 'function') syncChipDropdownItems();
if (typeof initMarkChips === 'function') initMarkChips();
}
return statuses;
}
async function 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};
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};
return { task, lastComment: lc };
});
lastKnownUpdatedAt=data.updated_at;
if(data.cache_version!==undefined&&data.cache_version!==null){
lastKnownCacheVersion=Number(data.cache_version)||lastKnownCacheVersion;
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'):'—';
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) => `<option value="${esc(it.status_id)}">#${esc(it.status_id)} - ${esc(it.name)}</option>`).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
? '<span style="color:var(--text3);font-size:11px;">system</span>'
: `<button class="btn btn-danger" style="font-size:11px;padding:4px 8px;" onclick="deleteInternalStatus('${esc(status.key)}')">Удалить</button>`;
return `
<tr>
<td style="font-family:monospace;font-size:11px;">${esc(status.key)}</td>
<td><input class="inline-pass" type="text" id="st-label-${esc(status.key)}" value="${esc(status.label)}"></td>
<td><input type="color" id="st-dot-${esc(status.key)}" value="${toHex(status.colorDot || '#1677ff')}"></td>
<td><input type="color" id="st-bg-${esc(status.key)}" value="${toHex(status.colorBg || '#e6f4ff')}"></td>
<td><input type="number" id="st-order-${esc(status.key)}" value="${Number(status.sort_order || 0)}" style="width:72px;"></td>
<td style="text-align:center;"><input type="checkbox" id="st-active-${esc(status.key)}" ${status.is_active ? 'checked' : ''}></td>
<td><button class="btn btn-default" style="font-size:11px;padding:4px 8px;" onclick="saveInternalStatus('${esc(status.key)}')">Сохранить</button></td>
<td>${delBtn}</td>
</tr>`;
}
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=>`
<tr>
<td style="font-weight:600;">${esc(u.username)}</td>
<td>${u.created_at?new Date(u.created_at).toLocaleDateString('ru-RU'):'—'}</td>
<td><input class="inline-pass" type="password" id="pass-${u.id}" placeholder="новый пароль">
<button class="btn btn-default" style="font-size:11px;padding:4px 10px;margin-left:6px;" onclick="changePassword(${u.id})">Сменить</button></td>
<td>${u.username!=='admin'?`<button class="btn btn-danger" style="font-size:11px;padding:4px 10px;" onclick="deleteUser(${u.id},'${esc(u.username)}')">Удалить</button>`:''}</td>
</tr>`).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) => `
<tr>
<td style="font-weight:600;">${esc(u.username)}</td>
<td>${u.created_at ? new Date(u.created_at).toLocaleDateString('ru-RU') : '—'}</td>
<td><input class="inline-pass" type="password" id="pass-${u.id}" placeholder="новый пароль">
<button class="btn btn-default" style="font-size:11px;padding:4px 10px;margin-left:6px;" onclick="changePassword(${u.id})">Сменить</button></td>
<td>${u.username !== 'admin' ? `<button class="btn btn-danger" style="font-size:11px;padding:4px 10px;" onclick="deleteUser(${u.id},'${esc(u.username)}')">Удалить</button>` : ''}</td>
</tr>`).join('');
}
async function changePassword(id) {
const inp = document.getElementById(`pass-${id}`);
if (!inp || inp.value.length < 6) {
showToast('Пароль минимум 6 символов', 'warn');
return;
}
}catch{}
localStorage.removeItem('intradesk_token');authToken=null;
const r = await apiPost(`/api/users/${id}/password`, { password: inp.value }, 'PUT');
if (r?.ok) {
inp.value = '';
showToast('✓ Пароль изменен', 'success');
} else {
showToast(r?.error || 'Ошибка', 'warn');
}
}
async function deleteUser(id, username) {
if (!confirm(`Удалить пользователя "${username}"?`)) return;
const r = await apiDelete(`/api/users/${id}`);
if (r?.ok) {
loadUsersTable();
loadUserList();
} else {
showToast(r?.error || 'Ошибка', 'warn');
}
}
window.addEventListener('DOMContentLoaded', () => {
if (authToken) {
try {
const p = JSON.parse(atob(authToken.split('.')[1]));
if (p.exp * 1000 > Date.now()) {
currentUser = { username: p.username, is_admin: p.username === 'admin' };
showApp();
init();
return;
}
} catch {}
localStorage.removeItem('intradesk_token');
authToken = null;
}
document.getElementById('login-overlay').classList.remove('hidden');
});

View file

@ -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);

View file

@ -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 {} }

View file

@ -1,32 +1,12 @@
// ══════════════════════════════════════════════════════════════════
// ЕДИНЫЙ РЕЕСТР СТАТУСОВ
// Добавьте новый статус сюда — больше ничего менять не нужно.
// ══════════════════════════════════════════════════════════════════
//
// Поля объекта статуса:
// 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)',
chipDotColor: '#ff4d4f',
badgeClass: 'sb-reply',
label: 'Нужен ответ',
sseLabel: 'Нужно ответить',
@ -35,52 +15,76 @@ const MARKS_CONFIG = [
rowClass: 'row-reply',
btnIcon: '✉',
btnLabel: 'Нужно ответить',
ispColor: 'var(--red)',
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)',
chipLabel: 'Ждем клиента',
chipDotColor: '#fa8c16',
badgeClass: 'sb-wait',
label: 'Ждём клиента',
sseLabel: 'Ждём клиента',
label: 'Ждем клиента',
sseLabel: 'Ждем клиента',
exportLabel: 'Ждем клиента',
btnClass: 'mbtn-wait',
rowClass: 'row-wait',
btnIcon: '⏳',
btnLabel: 'Ждём клиента',
ispColor: 'var(--orange)',
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)',
chipLabel: 'Выполнена',
chipDotColor: '#52c41a',
badgeClass: 'sb-resolved',
label: 'Можно закрыть',
sseLabel: 'Можно закрыть',
exportLabel: 'Решено',
label: 'Выполнена',
sseLabel: 'Выполнена',
exportLabel: 'Выполнена',
btnClass: 'mbtn-resolve',
rowClass: 'row-resolved',
btnIcon: '✓',
btnLabel: 'Можно Закрыть',
ispColor: 'var(--green)',
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)',
chipDotColor: '#722ed1',
badgeClass: 'sb-vyezdnye',
label: 'Не наша',
sseLabel: 'Не наша',
@ -89,10 +93,18 @@ const MARKS_CONFIG = [
rowClass: 'row-vyezdnye',
btnIcon: '🚗',
btnLabel: 'Не наша',
ispColor: 'var(--purple)',
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',
@ -111,6 +123,14 @@ const MARKS_CONFIG = [
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',
@ -129,6 +149,14 @@ const MARKS_CONFIG = [
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',
@ -147,6 +175,14 @@ const MARKS_CONFIG = [
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',
@ -154,9 +190,9 @@ const MARKS_CONFIG = [
chipLabel: 'Ремонт окончен',
chipDotColor: '#52c41a',
badgeClass: 'sb-oborudovanie-repair-done',
label: 'Ремонт окончен — ждёт отправки',
sseLabel: 'Ремонт окончен — ждёт отправки',
exportLabel: 'Ремонт окончен — ждёт отправки',
label: 'Ремонт окончен - ждет отправки',
sseLabel: 'Ремонт окончен - ждет отправки',
exportLabel: 'Ремонт окончен - ждет отправки',
btnClass: 'mbtn-oborudovanie-repair-done',
rowClass: 'row-oborudovanie-repair-done',
btnIcon: '✅',
@ -165,6 +201,14 @@ const MARKS_CONFIG = [
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',
@ -183,6 +227,14 @@ const MARKS_CONFIG = [
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',
@ -201,8 +253,79 @@ const MARKS_CONFIG = [
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();
}

View file

@ -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=`<option value="" disabled>${emptyLabel}</option>`;
return;
}
const html=items.map(({value,label})=>`<option value="${esc(value)}">${esc(label)}</option>`).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='<option value="" disabled selected>Set status</option><option value="clear">Clear status</option>';
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`<span class="status-badge ${m.badgeClass}"><span class="dot"></span>${m.label}</span>`;
const m=MARKS_CONFIG.find((status)=>isMark(tid,status.key));
if(m){
const style=statusBadgeStyle(m);
return `<span class="status-badge ${m.badgeClass||'sb-custom'}" style="${style.outer}"><span class="dot" style="${style.dot}"></span>${esc(m.label)}</span>`;
}
return`<span class="status-badge sb-none"><span class="dot"></span>Без статуса</span>`;
}
// ── 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 `<span class="status-badge sb-intra-other"><span class="dot"></span>#${sid}</span>`;
return '<span style="color:var(--text3);font-size:11px;">—</span>';
}
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 `<span class="status-badge ${cls}"><span class="dot"></span>${esc(name)}</span>`;
}
// 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 ───────────────────────────────────────────────────

View file

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

393
server.js
View file

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