statistica vibe 1

This commit is contained in:
dv 2026-03-23 22:54:13 +03:00
parent 232a6b4238
commit 45773f19e8
5 changed files with 214 additions and 4 deletions

56
CHANGELOG.md Normal file
View file

@ -0,0 +1,56 @@
# Changelog
## [sync2 vibe] — 2026-03-23
### Новое: «Служебный статус в интре» — двусторонняя синхронизация
#### Колонка в таблице заявок
- Добавлена новая колонка **«Служ. статус в интре»** (`servstatus`) — отображает значение дополнительного поля `addfield_2f6eaa2vnutrennij_statusSingleSelect` из IntraDesk
- Колонка интерактивная: при наведении открывается выпадающий пикер с полным списком внутренних статусов (аналогично колонке «Статус»)
- Текущее значение подсвечивается цветом соответствующего внутреннего статуса (если совпадает по label)
- Есть пункт «— очистить» для сброса значения в IntraDesk
#### Синхронизация IntraDesk → наш статус (входящая)
- При каждом обновлении задачи в ходе инкрементального или полного сканирования проверяется значение доп. поля
- Если значение доп. поля совпадает с label одного из активных внутренних статусов — этот статус автоматически выставляется через `setExclusiveMark` (без участия пользователя)
- Управляется настройкой `sync_addfield_status` (по умолчанию выключена)
#### Синхронизация наш статус → IntraDesk (исходящая)
- При смене внутреннего статуса через `POST /api/marks` значение нового статуса (его `label`) автоматически записывается в доп. поле IntraDesk через `PUT /changes/v3/tasks/`
- Если статус снимается без замены — доп. поле очищается (`null`)
- Работает только при включённом `sync_addfield_status`
#### Прямое редактирование из UI
- Новый эндпоинт `POST /api/tasks/:taskId/intra-addfield` — позволяет записать произвольное значение в доп. поле IntraDesk напрямую, независимо от внутреннего статуса
- После успешной записи локальный кеш задачи обновляется немедленно (без ожидания следующего сканирования)
- SSE-событие `intra_addfield` рассылается всем подключённым клиентам — обновление отображается в реальном времени на других вкладках/у других пользователей
- Тост-уведомление показывается другим пользователям при изменении
#### Настройки
- Новая настройка **«Синхронизировать служебный статус с доп. полем IntraDesk (двусторонняя)»** в панели администратора → Status Automation
- Ключ: `sync_addfield_status`, булева строка `'0'`/`'1'`
#### Запуск локально
- Добавлен `docker-compose.local.yml` — compose-файл для локальной сборки из исходников (вместо пулла готового образа из registry), данные хранятся в `./data`
- Добавлен `start-local.bat` — батник для запуска локального Docker одним двойным кликом
---
### Затронутые файлы
| Файл | Изменения |
|---|---|
| `refresh-service.js` | Константа алиаса доп. поля; извлечение `_addfield_status` при fetch; `updateIntraAdditionalField()`; inbound-синхронизация в `applyStatusAutomationForChangedTasks`; `sync_addfield_status` в `normalizeSettings` |
| `server.js` | Outbound-вызов после `POST /api/marks`; новый эндпоинт `POST /api/tasks/:taskId/intra-addfield`; настройка `sync_addfield_status` в `ALLOWED_SETTINGS` и `GET/POST /api/settings` |
| `public/js/ui.js` | Колонка `servstatus` в `ALL_COLS`; функции `_addfieldBadgeHtml`, `intraAddfieldPickerHtml`, `setIntraAddfield` |
| `public/js/sse.js` | Обработчик события `intra_addfield` |
| `public/js/auth.js` | Загрузка/сохранение чекбокса `sync_addfield_status` |
| `public/index.html` | Чекбокс настройки в панели Status Automation |
| `docker-compose.local.yml` | Новый файл — compose для локальной разработки |
| `start-local.bat` | Новый файл — скрипт запуска локального Docker |

View file

@ -993,7 +993,7 @@ body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);min-h
background: color-mix(in srgb, var(--accent) calc(var(--hi) * 100%), var(--surface2)); background: color-mix(in srgb, var(--accent) calc(var(--hi) * 100%), var(--surface2));
color: transparent; /* hide number by default, show on hover via title */ color: transparent; /* hide number by default, show on hover via title */
} }
.stats-hm-cell[style*="--hi:0"] { background: var(--surface2); } .stats-hm-cell[style*="--hi:0.000"] { background: var(--surface2); }
.stats-hm-cell:hover { transform: scale(1.35); z-index: 5; position: relative; } .stats-hm-cell:hover { transform: scale(1.35); z-index: 5; position: relative; }
/* show number only when there's activity */ /* show number only when there's activity */
.stats-hm-cell span { pointer-events: none; color: rgba(255,255,255,.0); } .stats-hm-cell span { pointer-events: none; color: rgba(255,255,255,.0); }
@ -1036,6 +1036,66 @@ html.dark .stats-hm-cell:hover span { color: rgba(255,255,255,.95); }
box-shadow: 0 3px 10px rgba(0,0,0,.2); box-shadow: 0 3px 10px rgba(0,0,0,.2);
} }
/* ── Open-tickets week/month compare ────────────────────────── */
.stats-open-compare {
display: flex;
align-items: center;
gap: 12px;
margin: 6px 0 12px;
flex-wrap: wrap;
}
.soc-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.soc-val {
font-size: 22px;
font-weight: 800;
color: var(--text);
line-height: 1;
}
.soc-label {
font-size: 10px;
color: var(--text3);
text-transform: uppercase;
letter-spacing: .04em;
}
.soc-sep {
color: var(--text3);
font-size: 13px;
font-weight: 600;
align-self: center;
}
.stats-wdiff {
font-size: 11px;
font-weight: 700;
padding: 1px 6px;
border-radius: 8px;
margin-top: 3px;
white-space: nowrap;
}
.stats-wdiff-up { background: color-mix(in srgb,var(--red) 15%,transparent); color: var(--red); }
.stats-wdiff-down { background: color-mix(in srgb,var(--green) 15%,transparent); color: var(--green); }
.stats-wdiff-flat { background: var(--surface2); color: var(--text3); }
/* ── Activity tooltip ───────────────────────────────────────── */
.stats-act-tooltip {
display: none;
position: fixed;
background: var(--text);
color: var(--surface);
font-size: 12px;
line-height: 1.5;
padding: 6px 12px;
border-radius: 7px;
pointer-events: none;
white-space: nowrap;
z-index: 9999;
box-shadow: 0 3px 12px rgba(0,0,0,.22);
}
/* ── Bar chart (generic) ────────────────────────────────────── */ /* ── Bar chart (generic) ────────────────────────────────────── */
.stats-bar-chart { display:flex; flex-direction:column; gap:9px; } .stats-bar-chart { display:flex; flex-direction:column; gap:9px; }
.stats-bar-row { display:flex; align-items:center; gap:10px; } .stats-bar-row { display:flex; align-items:center; gap:10px; }
@ -1211,6 +1271,8 @@ html.dark .stats-card { background: var(--surface); }
.stats-clients-check-wrap input { cursor:pointer; accent-color:var(--accent); } .stats-clients-check-wrap input { cursor:pointer; accent-color:var(--accent); }
.stats-clients-list { display:flex; flex-direction:column; gap:7px; } .stats-clients-list { display:flex; flex-direction:column; gap:7px; }
.stats-clients-row { display:flex; align-items:center; gap:10px; } .stats-clients-row { display:flex; align-items:center; gap:10px; }
.stats-clients-row-clickable { cursor:pointer; border-radius:6px; transition:background .13s; padding:3px 4px; margin:-3px -4px; }
.stats-clients-row-clickable:hover { background:var(--surface2); }
.stats-clients-name { .stats-clients-name {
font-size:12px; color:var(--text2); font-size:12px; color:var(--text2);
width:200px; flex-shrink:0; width:200px; flex-shrink:0;
@ -1430,6 +1492,25 @@ html.dark .stats-card { background:var(--surface); }
color:var(--text3); color:var(--text3);
font-size:12px; font-size:12px;
} }
.adv-filter-search-wrap{
padding:4px 2px 6px;
margin-bottom:4px;
border-bottom:1px solid var(--border);
}
.adv-filter-search{
width:100%;
box-sizing:border-box;
border:1px solid var(--border);
border-radius:6px;
background:var(--surface2);
color:var(--text);
font-family:inherit;
font-size:12px;
padding:5px 9px;
outline:none;
transition:border-color .15s;
}
.adv-filter-search:focus{border-color:var(--accent);}
/* ── Chip Dropdown ────────────────────────────────────── */ /* ── Chip Dropdown ────────────────────────────────────── */
.chip-dropdown-wrap { position: relative; flex-shrink: 0; } .chip-dropdown-wrap { position: relative; flex-shrink: 0; }

View file

@ -223,7 +223,7 @@
</button> </button>
<!-- Тёмная тема --> <!-- Тёмная тема -->
<button class="topbar-icon-btn" id="theme-toggle-btn" onclick="toggleTheme()" title="Переключить тему"> <button class="topbar-icon-btn" id="theme-toggle-btn" onclick="toggleTheme()" title="Переключить тему" style="display:none;">
<svg id="theme-icon-light" width="15" height="15" fill="none" viewBox="0 0 16 16" style="display:none;"><circle cx="8" cy="8" r="3" stroke="currentColor" stroke-width="1.4"/><path d="M8 1v1.5M8 13.5V15M1 8h1.5M13.5 8H15M3.05 3.05l1.06 1.06M11.89 11.89l1.06 1.06M3.05 12.95l1.06-1.06M11.89 4.11l1.06-1.06" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg> <svg id="theme-icon-light" width="15" height="15" fill="none" viewBox="0 0 16 16" style="display:none;"><circle cx="8" cy="8" r="3" stroke="currentColor" stroke-width="1.4"/><path d="M8 1v1.5M8 13.5V15M1 8h1.5M13.5 8H15M3.05 3.05l1.06 1.06M11.89 11.89l1.06 1.06M3.05 12.95l1.06-1.06M11.89 4.11l1.06-1.06" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
<svg id="theme-icon-dark" width="15" height="15" fill="none" viewBox="0 0 16 16"><path d="M13.5 10A6 6 0 016 2.5a6 6 0 100 11 6 6 0 007.5-3.5z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/></svg> <svg id="theme-icon-dark" width="15" height="15" fill="none" viewBox="0 0 16 16"><path d="M13.5 10A6 6 0 016 2.5a6 6 0 100 11 6 6 0 007.5-3.5z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/></svg>
</button> </button>

View file

@ -96,6 +96,10 @@ function renderStatsPage() {
const maxOpen = Math.max(...wOpen,1); const maxOpen = Math.max(...wOpen,1);
const maxDual = Math.max(...wCreated,...wClosed,1); const maxDual = Math.max(...wCreated,...wClosed,1);
// Открытых на начало текущего месяца
const firstDayOfMonth = new Date(now); firstDayOfMonth.setDate(1); firstDayOfMonth.setHours(0,0,0,0);
const openAtMonthStart = data.filter(({task:t})=>{ const cr=new Date(t.createdat); if(cr>=firstDayOfMonth)return false; if(!isTaskDone({task:t}))return true; return new Date(t.resolutiondatefact||t.closedat||0)>firstDayOfMonth; }).length;
// Воронка // Воронка
const FB = [ const FB = [
{label:'< 1 часа', max:60, color:'#0d9488'}, {label:'< 1 часа', max:60, color:'#0d9488'},
@ -358,6 +362,24 @@ function renderStatsPage() {
<!-- Открытые на конец недели --> <!-- Открытые на конец недели -->
<div class="stats-card"> <div class="stats-card">
<div class="stats-card-title">📈 Открытых заявок на конец недели</div> <div class="stats-card-title">📈 Открытых заявок на конец недели</div>
<div class="stats-open-compare">
<div class="soc-item">
<div class="soc-val">${wOpen[WEEKS-1]}</div>
<div class="soc-label">сейчас</div>
</div>
<div class="soc-sep">vs</div>
<div class="soc-item">
<div class="soc-val">${wOpen[WEEKS-2]}</div>
<div class="soc-label">неделю назад</div>
${wDiffBadge(wOpen[WEEKS-1]-wOpen[WEEKS-2])}
</div>
<div class="soc-sep">·</div>
<div class="soc-item">
<div class="soc-val">${openAtMonthStart}</div>
<div class="soc-label">нач. месяца</div>
${wDiffBadge(wOpen[WEEKS-1]-openAtMonthStart)}
</div>
</div>
<div class="stats-line-chart" id="stats-line-chart"> <div class="stats-line-chart" id="stats-line-chart">
<div class="stats-line-tooltip" id="line-tooltip" style="display:none;"></div> <div class="stats-line-tooltip" id="line-tooltip" style="display:none;"></div>
<svg class="stats-svg" viewBox="0 0 ${WEEKS*60} 130" preserveAspectRatio="none"> <svg class="stats-svg" viewBox="0 0 ${WEEKS*60} 130" preserveAspectRatio="none">
@ -376,7 +398,7 @@ function renderStatsPage() {
<span class="stats-legend-inline"><span style="background:var(--green);"></span> закрыто</span> <span class="stats-legend-inline"><span style="background:var(--green);"></span> закрыто</span>
</div> </div>
<div class="stats-activity-chart"> <div class="stats-activity-chart">
${wCreated.map((cr,i)=>{ const cl=wClosed[i],hCr=Math.round(cr/maxDual*100),hCl=Math.round(cl/maxDual*100); return'<div class="stats-activity-col"><div class="stats-activity-bar-wrap" style="position:relative;"><div class="stats-activity-bar" style="height:'+Math.max(hCr,cr?3:0)+'%;position:absolute;bottom:0;left:2%;width:44%;"></div><div class="stats-activity-bar stats-activity-bar-green" style="height:'+Math.max(hCl,cl?3:0)+'%;position:absolute;bottom:0;right:2%;width:44%;opacity:.8;"></div></div><div class="stats-activity-count"></div><div class="stats-activity-label">'+wLabels[i]+'</div></div>'; }).join('')} ${wCreated.map((cr,i)=>{ const cl=wClosed[i],hCr=Math.round(cr/maxDual*100),hCl=Math.round(cl/maxDual*100); return'<div class="stats-activity-col" data-act-cr="'+cr+'" data-act-cl="'+cl+'" data-act-lbl="'+escHtml(wLabels[i])+'" onmouseenter="showActTip(event,this)" onmouseleave="hideActTip()"><div class="stats-activity-bar-wrap" style="position:relative;"><div class="stats-activity-bar" style="height:'+Math.max(hCr,cr?3:0)+'%;position:absolute;bottom:0;left:2%;width:44%;"></div><div class="stats-activity-bar stats-activity-bar-green" style="height:'+Math.max(hCl,cl?3:0)+'%;position:absolute;bottom:0;right:2%;width:44%;opacity:.8;"></div></div><div class="stats-activity-count">'+(cr||cl?'<span style="color:var(--accent);font-size:9px;">'+cr+'</span>':'')+'</div><div class="stats-activity-label">'+wLabels[i]+'</div></div>'; }).join('')}
</div> </div>
</div> </div>
@ -606,7 +628,7 @@ function renderClientsList() {
if(visible.length===0){wrap.innerHTML=`<div class="stats-empty">${clientsOnlyOpen?'Нет клиентов с открытыми заявками':'Нет повторных обращений'}</div>`;return;} if(visible.length===0){wrap.innerHTML=`<div class="stats-empty">${clientsOnlyOpen?'Нет клиентов с открытыми заявками':'Нет повторных обращений'}</div>`;return;}
wrap.innerHTML=` wrap.innerHTML=`
<div class="stats-clients-list" style="display:flex;flex-direction:column;gap:6px;"> <div class="stats-clients-list" style="display:flex;flex-direction:column;gap:6px;">
${visible.map(([name,v])=>{ const dv=clientsOnlyOpen?v.open:v.count,pct=dv/maxV*100,opPct=v.count?v.open/v.count*100:0; return'<div class="stats-clients-row"><div class="stats-clients-name" title="'+escHtml(name)+'">'+escHtml(name)+'</div><div class="stats-clients-track"><div class="stats-clients-bar-total" style="width:'+pct+'%;"></div><div class="stats-clients-bar-open" style="width:'+pct*opPct/100+'%;"></div></div><div class="stats-clients-counts"><span class="stats-clients-total">'+v.count+'</span>'+(v.open?'<span class="stats-clients-open-badge">'+v.open+' откр.</span>':'')+'</div></div>'; }).join('')} ${visible.map(([name,v])=>{ const dv=clientsOnlyOpen?v.open:v.count,pct=dv/maxV*100,opPct=v.count?v.open/v.count*100:0; return'<div class="stats-clients-row stats-clients-row-clickable" title="Открыть фильтр по клиенту" data-client="'+escHtml(name)+'" onclick="filterByClient(this.dataset.client)"><div class="stats-clients-name" title="'+escHtml(name)+'">'+escHtml(name)+'</div><div class="stats-clients-track"><div class="stats-clients-bar-total" style="width:'+pct+'%;"></div><div class="stats-clients-bar-open" style="width:'+pct*opPct/100+'%;"></div></div><div class="stats-clients-counts"><span class="stats-clients-total">'+v.count+'</span>'+(v.open?'<span class="stats-clients-open-badge">'+v.open+' откр.</span>':'')+'</div></div>'; }).join('')}
</div> </div>
${!clientsExpanded&&hidden>0?`<div style="text-align:center;margin-top:10px;font-size:12px;color:var(--text3);">+ ещё ${hidden} — <span style="color:var(--accent);cursor:pointer;" onclick="clientsExpanded=true;document.getElementById('clients-expand-btn').textContent='▲ Свернуть';renderClientsList()">показать все</span></div>`:''}`; ${!clientsExpanded&&hidden>0?`<div style="text-align:center;margin-top:10px;font-size:12px;color:var(--text3);">+ ещё ${hidden} — <span style="color:var(--accent);cursor:pointer;" onclick="clientsExpanded=true;document.getElementById('clients-expand-btn').textContent='▲ Свернуть';renderClientsList()">показать все</span></div>`:''}`;
} }
@ -635,8 +657,36 @@ function donutLegend(sc,uc,tot){
function escHtml(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');} function escHtml(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
function wDiffBadge(diff){
if(diff===0) return '<span class="stats-wdiff stats-wdiff-flat">= 0</span>';
if(diff>0) return '<span class="stats-wdiff stats-wdiff-up">↑ +'+diff+'</span>';
return '<span class="stats-wdiff stats-wdiff-down">↓ '+diff+'</span>';
}
function showLineTip(event,el){const tip=document.getElementById('line-tooltip');if(!tip)return;tip.textContent=`${el.dataset.lbl}: ${el.dataset.val} откр.`;tip.style.display='block';const wr=document.getElementById('stats-line-chart').getBoundingClientRect();tip.style.left=Math.min(event.clientX-wr.left+12,wr.width-130)+'px';tip.style.top=Math.max(event.clientY-wr.top-36,4)+'px';} function showLineTip(event,el){const tip=document.getElementById('line-tooltip');if(!tip)return;tip.textContent=`${el.dataset.lbl}: ${el.dataset.val} откр.`;tip.style.display='block';const wr=document.getElementById('stats-line-chart').getBoundingClientRect();tip.style.left=Math.min(event.clientX-wr.left+12,wr.width-130)+'px';tip.style.top=Math.max(event.clientY-wr.top-36,4)+'px';}
function hideLineTip(){const t=document.getElementById('line-tooltip');if(t)t.style.display='none';} function hideLineTip(){const t=document.getElementById('line-tooltip');if(t)t.style.display='none';}
function showActTip(event,el){
let tip=document.getElementById('stats-act-tooltip');
if(!tip){tip=document.createElement('div');tip.id='stats-act-tooltip';tip.className='stats-act-tooltip';document.body.appendChild(tip);}
const cr=el.dataset.actCr||'0',cl=el.dataset.actCl||'0',lbl=el.dataset.actLbl||'';
tip.innerHTML=`<span style="font-weight:600;">${escHtml(lbl)}</span><br><span style="color:var(--accent);">▲ Создано: ${cr}</span> &nbsp; <span style="color:var(--green);">▼ Закрыто: ${cl}</span>`;
tip.style.display='block';
tip.style.left=(event.clientX+14)+'px';
tip.style.top=Math.max(event.clientY-48,8)+'px';
}
function hideActTip(){const t=document.getElementById('stats-act-tooltip');if(t)t.style.display='none';}
function openTaskById(id){const i=allData.findIndex(x=>x.task.id===id);if(i!==-1){closeStatsPage();setTimeout(()=>openDetail(i),100);}} function openTaskById(id){const i=allData.findIndex(x=>x.task.id===id);if(i!==-1){closeStatsPage();setTimeout(()=>openDetail(i),100);}}
function onDataLoaded(){if(statsPageOpen)renderStatsPage();} function onDataLoaded(){if(statsPageOpen)renderStatsPage();}
function filterByClient(name) {
closeStatsPage();
setTimeout(() => {
const sel = document.getElementById('filter-object-select');
if (!sel) return;
[...sel.options].forEach(o => { o.selected = String(o.value) === String(name); });
if (typeof updateAdvancedFilterLabel === 'function') updateAdvancedFilterLabel('filter-object-select');
if (typeof renderTable === 'function') renderTable();
}, 150);
}

View file

@ -38,6 +38,7 @@ const ADV_FILTER_CONFIG = {
labelId: 'filter-object-label', labelId: 'filter-object-label',
allLabel: 'Объекты: все', allLabel: 'Объекты: все',
shortLabel: 'Объекты', shortLabel: 'Объекты',
hasSearch: true,
}, },
'filter-intra-select': { 'filter-intra-select': {
panelId: 'filter-intra-panel', panelId: 'filter-intra-panel',
@ -145,6 +146,28 @@ function buildAdvancedFilterPanel(selectId){
return; return;
} }
if (cfg.hasSearch) {
const searchWrap = document.createElement('div');
searchWrap.className = 'adv-filter-search-wrap';
const searchInput = document.createElement('input');
searchInput.type = 'text';
searchInput.className = 'adv-filter-search';
searchInput.placeholder = 'Поиск…';
searchInput.setAttribute('autocomplete', 'off');
searchInput.addEventListener('input', () => {
const q = searchInput.value.toLowerCase();
panel.querySelectorAll('.adv-filter-item').forEach(item => {
const text = item.querySelector('.adv-filter-text')?.textContent || '';
item.style.display = text.toLowerCase().includes(q) ? '' : 'none';
});
});
searchInput.addEventListener('click', e => e.stopPropagation());
searchWrap.appendChild(searchInput);
panel.appendChild(searchWrap);
// Focus after render
requestAnimationFrame(() => searchInput.focus());
}
const selectedCount=opts.filter((o)=>o.selected).length; const selectedCount=opts.filter((o)=>o.selected).length;
const actions=document.createElement('div'); const actions=document.createElement('div');
actions.className='adv-filter-actions'; actions.className='adv-filter-actions';