From 45773f19e82aaf54befe968ae0a36026d5099800 Mon Sep 17 00:00:00 2001 From: dv Date: Mon, 23 Mar 2026 22:54:13 +0300 Subject: [PATCH] statistica vibe 1 --- CHANGELOG.md | 56 +++++++++++++++++++++++++++ public/css/app.css | 83 ++++++++++++++++++++++++++++++++++++++++- public/index.html | 2 +- public/js/statistics.js | 54 ++++++++++++++++++++++++++- public/js/ui.js | 23 ++++++++++++ 5 files changed, 214 insertions(+), 4 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..40039fc --- /dev/null +++ b/CHANGELOG.md @@ -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 | diff --git a/public/css/app.css b/public/css/app.css index 9ba5f0b..f6a4396 100644 --- a/public/css/app.css +++ b/public/css/app.css @@ -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)); 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; } /* show number only when there's activity */ .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); } +/* ── 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) ────────────────────────────────────── */ .stats-bar-chart { display:flex; flex-direction:column; gap:9px; } .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-list { display:flex; flex-direction:column; gap:7px; } .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 { font-size:12px; color:var(--text2); width:200px; flex-shrink:0; @@ -1430,6 +1492,25 @@ html.dark .stats-card { background:var(--surface); } color:var(--text3); 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-wrap { position: relative; flex-shrink: 0; } diff --git a/public/index.html b/public/index.html index 5593214..8a69f34 100644 --- a/public/index.html +++ b/public/index.html @@ -223,7 +223,7 @@ - diff --git a/public/js/statistics.js b/public/js/statistics.js index bfd162c..9196f51 100644 --- a/public/js/statistics.js +++ b/public/js/statistics.js @@ -96,6 +96,10 @@ function renderStatsPage() { const maxOpen = Math.max(...wOpen,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 = [ {label:'< 1 часа', max:60, color:'#0d9488'}, @@ -358,6 +362,24 @@ function renderStatsPage() {
📈 Открытых заявок на конец недели
+
+
+
${wOpen[WEEKS-1]}
+
сейчас
+
+
vs
+
+
${wOpen[WEEKS-2]}
+
неделю назад
+ ${wDiffBadge(wOpen[WEEKS-1]-wOpen[WEEKS-2])} +
+
·
+
+
${openAtMonthStart}
+
нач. месяца
+ ${wDiffBadge(wOpen[WEEKS-1]-openAtMonthStart)} +
+
@@ -376,7 +398,7 @@ function renderStatsPage() { закрыто
- ${wCreated.map((cr,i)=>{ const cl=wClosed[i],hCr=Math.round(cr/maxDual*100),hCl=Math.round(cl/maxDual*100); return'
'+wLabels[i]+'
'; }).join('')} + ${wCreated.map((cr,i)=>{ const cl=wClosed[i],hCr=Math.round(cr/maxDual*100),hCl=Math.round(cl/maxDual*100); return'
'+(cr||cl?''+cr+'':'')+'
'+wLabels[i]+'
'; }).join('')}
@@ -606,7 +628,7 @@ function renderClientsList() { if(visible.length===0){wrap.innerHTML=`
${clientsOnlyOpen?'Нет клиентов с открытыми заявками':'Нет повторных обращений'}
`;return;} wrap.innerHTML=`
- ${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'
'+escHtml(name)+'
'+v.count+''+(v.open?''+v.open+' откр.':'')+'
'; }).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'
'+escHtml(name)+'
'+v.count+''+(v.open?''+v.open+' откр.':'')+'
'; }).join('')}
${!clientsExpanded&&hidden>0?`
+ ещё ${hidden} — показать все
`:''}`; } @@ -635,8 +657,36 @@ function donutLegend(sc,uc,tot){ function escHtml(s){return String(s).replace(/&/g,'&').replace(//g,'>');} +function wDiffBadge(diff){ + if(diff===0) return '= 0'; + if(diff>0) return '↑ +'+diff+''; + return '↓ '+diff+''; +} + 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 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=`${escHtml(lbl)}
▲ Создано: ${cr}   ▼ Закрыто: ${cl}`; + 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 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); +} diff --git a/public/js/ui.js b/public/js/ui.js index f98dfcc..2f74e0f 100644 --- a/public/js/ui.js +++ b/public/js/ui.js @@ -38,6 +38,7 @@ const ADV_FILTER_CONFIG = { labelId: 'filter-object-label', allLabel: 'Объекты: все', shortLabel: 'Объекты', + hasSearch: true, }, 'filter-intra-select': { panelId: 'filter-intra-panel', @@ -145,6 +146,28 @@ function buildAdvancedFilterPanel(selectId){ 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 actions=document.createElement('div'); actions.className='adv-filter-actions';