statistica vibe 3

This commit is contained in:
dv 2026-03-23 23:34:37 +03:00
parent 77d3ac94ad
commit de69bddcaf
3 changed files with 141 additions and 63 deletions

View file

@ -1,56 +1,91 @@
# Changelog # Changelog
## [sync2 vibe] — 2026-03-23 ## [statistica vibe 3] — 2026-03-23
### Новое: «Служебный статус в интре» — двусторонняя синхронизация > Правки и улучшения статистики после `statistica vibe 2`
#### Колонка в таблице заявок ### Улучшения
- Добавлена новая колонка **«Служ. статус в интре»** (`servstatus`) — отображает значение дополнительного поля `addfield_2f6eaa2vnutrennij_statusSingleSelect` из IntraDesk - **Тепловая карта по месяцам** переработана: вместо сетки месяц×день-недели теперь отображается одна строка из 12 ячеек (по одной на месяц), каждая окрашена по числу заявок. Значение показывается при наведении.
- Колонка интерактивная: при наведении открывается выпадающий пикер с полным списком внутренних статусов (аналогично колонке «Статус») - **Блок сравнения** («сейчас / неделю назад / начало месяца») теперь растягивается в высоту графика (`align-items: stretch`), элементы равномерно распределяются по высоте — цифры больше не выступают за пределы графика.
- Текущее значение подсвечивается цветом соответствующего внутреннего статуса (если совпадает по label) - **Повторные клиенты** — один счётчик «открытых» через `isTaskOpen` (в т.ч. «в работе», «новая», «назначена» и т.п.); одна акцентная полоса и бейдж `N откр.` без разделения цветов.
- Есть пункт «— очистить» для сброса значения в IntraDesk
#### Синхронизация IntraDesk → наш статус (входящая) ### Исправления
- При каждом обновлении задачи в ходе инкрементального или полного сканирования проверяется значение доп. поля - **Карточка «Клиенты» + «Только с открытыми»**: при включённом фильтре внутренняя полоса теперь заполняет внешнюю полностью (`opPct = 100%`), чтобы не было двойного масштабирования и визуально «другой» закраски относительно режима без фильтра.
- Если значение доп. поля совпадает с label одного из активных внутренних статусов — этот статус автоматически выставляется через `setExclusiveMark` (без участия пользователя)
- Управляется настройкой `sync_addfield_status` (по умолчанию выключена)
#### Синхронизация наш статус → IntraDesk (исходящая) ### Затронутые файлы
- При смене внутреннего статуса через `POST /api/marks` значение нового статуса (его `label`) автоматически записывается в доп. поле IntraDesk через `PUT /changes/v3/tasks/`
- Если статус снимается без замены — доп. поле очищается (`null`)
- Работает только при включённом `sync_addfield_status`
#### Прямое редактирование из UI | Файл | Изменения |
| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `public/js/statistics.js` | `MONTH_COUNTS` вместо `MONTH_HM`; новый HTML месячной тепловой карты; `renderClientsList` с единой логикой `open` и правкой `opPct` для `clientsOnlyOpen` |
| `public/css/app.css` | `.stats-month-hm`*; `align-items: stretch` в `.stats-open-wrap`; уменьшен `.soc-val`; стили бейджа клиентов без раздельных полос |
- Новый эндпоинт `POST /api/tasks/:taskId/intra-addfield` — позволяет записать произвольное значение в доп. поле IntraDesk напрямую, независимо от внутреннего статуса
- После успешной записи локальный кеш задачи обновляется немедленно (без ожидания следующего сканирования)
- SSE-событие `intra_addfield` рассылается всем подключённым клиентам — обновление отображается в реальном времени на других вкладках/у других пользователей
- Тост-уведомление показывается другим пользователям при изменении
#### Настройки ---
- Новая настройка **«Синхронизировать служебный статус с доп. полем IntraDesk (двусторонняя)»** в панели администратора → Status Automation ## [123 → statistica vibe 2] — 2026-03-23
- Ключ: `sync_addfield_status`, булева строка `'0'`/`'1'`
#### Запуск локально > Коммиты: `fc2b7f7 (123)` · `232a6b4 (sync2 vibe)` · `45773f1 (statistica vibe 1)` · `77d3ac9 (statistica vibe 2)`
- Добавлен `docker-compose.local.yml` — compose-файл для локальной сборки из исходников (вместо пулла готового образа из registry), данные хранятся в `./data` ---
- Добавлен `start-local.bat` — батник для запуска локального Docker одним двойным кликом
### Новые функции
#### Колонка «Служ. статус в интре» — двусторонняя синхронизация с IntraDesk
- Добавлена колонка **«Служ. статус в интре»** (`servstatus`) в таблице заявок — отображает значение дополнительного поля `addfield_2f6eaa2vnutrennij_statusSingleSelect` из IntraDesk; цвет подбирается по совпадению с label внутреннего статуса
- Колонка интерактивная: при наведении открывается пикер для смены значения поля напрямую в IntraDesk (независимо от внутреннего статуса), с пунктом «— очистить»
- **Исходящая синхронизация**: при смене внутреннего статуса через `POST /api/marks` значение автоматически записывается в доп. поле IntraDesk через `PUT /changes/v3/tasks/`; при снятии статуса — поле очищается (`null`)
- **Входящая синхронизация**: при каждом обновлении задачи значение доп. поля сравнивается с label активных внутренних статусов — при совпадении статус выставляется автоматически
- Новый эндпоинт `POST /api/tasks/:taskId/intra-addfield` для прямой записи значения поля без смены внутреннего статуса
- SSE-событие `intra_addfield` рассылается всем клиентам при изменении — обновление отображается в реальном времени, тост-уведомление для других пользователей
- Настройка **«Синхронизировать служебный статус с доп. полем IntraDesk»** в панели администратора → Status Automation (ключ `sync_addfield_status`, по умолчанию выключена)
#### Страница статистики — новые элементы
- **Тепловая карта по месяцам** (месяц × день недели) — 12×7 сетка, показывает распределение нагрузки по месяцам и дням недели; размещена в одном ряду с картой «час × день»
- **Блок сравнения** в карточке «Открытых заявок на конец недели» — три значения: сейчас / неделю назад / начало месяца; цветной бейдж роста (↑ красный) или снижения (↓ зелёный); расположен справа от графика как компактная боковая панель
- **Кликабельные клиенты** в карточке «Клиенты» — клик по строке клиента закрывает статистику и открывает главную страницу с автоматически выставленным фильтром «Объекты» по данному клиенту
#### Страница статистики — улучшения UX
- При наведении на столбик в графике «Создано / Закрыто по неделям»: столбики подсвечиваются (`brightness`), над ними появляется метка со значениями (создано / закрыто)
#### Фильтры на главной странице
- Поиск внутри выпадающего фильтра **«Объекты»** — текстовое поле для быстрой фильтрации длинного списка объектов; появляется при открытии дропдауна, автофокус
#### Локальный запуск
- `docker-compose.local.yml` — compose-файл для сборки образа из исходников (вместо пулла из registry), данные монтируются из `./data`
- `start-local.bat` — батник для Windows: проверяет наличие `.env`, создаёт `./data`, запускает `docker compose ... up -d --build` одним двойным кликом
---
### Исправления
- **Тепловая карта (час × день)**: CSS-селектор `[style*="--hi:0"]` некорректно совпадал с любым значением начинающимся с `0` (т.е. `0.200`, `0.400` и т.д.) — в результате окрашивался только один максимальный квадрат. Исправлено на `[style*="--hi:0.000"]`
- **Кнопка тёмной темы**: скрыта (`display:none`); логика `toggleTheme` и состояние темы сохранены
- **Клиенты с кавычками в названии** (напр. `БЦ "Луч"`): двойные кавычки разрушали HTML-атрибут `data-client`, из-за чего фильтр не применялся. Добавлен `escAttr` с экранированием `"``"`
--- ---
### Затронутые файлы ### Затронутые файлы
| Файл | Изменения |
|---|---| | Файл | Изменения |
| `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` | | `refresh-service.js` | Константа `INTRA_ADDFIELD_ALIAS`; извлечение `_addfield_status` при fetch; `updateIntraAdditionalField()`; inbound-синхронизация в `applyStatusAutomationForChangedTasks`; `sync_addfield_status` в `normalizeSettings` |
| `public/js/ui.js` | Колонка `servstatus` в `ALL_COLS`; функции `_addfieldBadgeHtml`, `intraAddfieldPickerHtml`, `setIntraAddfield` | | `server.js` | Outbound-вызов после `POST /api/marks`; новый эндпоинт `POST /api/tasks/:taskId/intra-addfield`; настройка `sync_addfield_status` в `ALLOWED_SETTINGS` и `GET/POST /api/settings` |
| `public/js/sse.js` | Обработчик события `intra_addfield` | | `public/js/ui.js` | Колонка `servstatus` в `ALL_COLS`; `intraAddfieldBadge`, `intraAddfieldPickerHtml`, `setIntraAddfield`; `hasSearch: true` для фильтра «Объекты»; поисковый блок в `buildAdvancedFilterPanel` |
| `public/js/auth.js` | Загрузка/сохранение чекбокса `sync_addfield_status` | | `public/js/sse.js` | Обработчик события `intra_addfield` |
| `public/index.html` | Чекбокс настройки в панели Status Automation | | `public/js/auth.js` | Загрузка и сохранение чекбокса `sync_addfield_status` |
| `docker-compose.local.yml` | Новый файл — compose для локальной разработки | | `public/js/statistics.js` | Блок сравнения открытых заявок (wDiffBadge, openAtMonthStart); тепловая карта по месяцам (MONTH_HM, MONTH_NAMES); кликабельные клиенты (`filterByClient`, `escAttr`); hover-label на столбиках активности |
| `start-local.bat` | Новый файл — скрипт запуска локального Docker | | `public/css/app.css` | Стили блока сравнения (`.stats-open-wrap`, `.stats-open-compare`, `.soc-`*, `.stats-wdiff`); hover-label (`.stats-act-hover-label`, `.sahl-*`); поиск фильтра (`.adv-filter-search-wrap`, `.adv-filter-search`); кликабельные строки клиентов; исправление тепловой карты (`--hi:0.000`) |
| `public/index.html` | Чекбокс настройки `sync_addfield_status`; `display:none` на кнопке темы |
| `docker-compose.local.yml` | Новый файл — compose для локальной разработки |
| `start-local.bat` | Новый файл — скрипт запуска локального Docker |

View file

@ -974,6 +974,44 @@ body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);min-h
.stats-timing-lbl { font-size: 11px; color: var(--text3); margin-top: 3px; } .stats-timing-lbl { font-size: 11px; color: var(--text3); margin-top: 3px; }
.stats-timing-note { font-size: 11px; color: var(--text3); margin-top: 8px; } .stats-timing-note { font-size: 11px; color: var(--text3); margin-top: 8px; }
/* ── Month heatmap (12 cells) ───────────────────────────────── */
.stats-month-hm {
display: flex;
gap: 5px;
flex-wrap: wrap;
margin-top: 10px;
}
.stats-month-hm-cell {
flex: 1;
min-width: 40px;
height: 56px;
border-radius: 8px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 5px;
cursor: default;
transition: transform .12s;
background: color-mix(in srgb, var(--accent) calc(var(--hi) * 100%), var(--surface2));
}
.stats-month-hm-cell[style*="--hi:0.000"] { background: var(--surface2); }
.stats-month-hm-cell:hover { transform: scale(1.08); z-index: 5; position: relative; }
.stats-month-hm-val {
font-size: 14px;
font-weight: 800;
color: rgba(255,255,255,0);
line-height: 1;
transition: color .12s;
}
.stats-month-hm-cell:hover .stats-month-hm-val { color: white; }
.stats-month-hm-lbl {
font-size: 10px;
font-weight: 700;
color: var(--text2);
}
html.dark .stats-month-hm-cell:hover .stats-month-hm-val { color: rgba(255,255,255,.95); }
/* ── Heatmap ─────────────────────────────────────────────────── */ /* ── Heatmap ─────────────────────────────────────────────────── */
.stats-heatmap-wrap { display:flex; flex-direction:column; gap:2px; overflow-x:auto; } .stats-heatmap-wrap { display:flex; flex-direction:column; gap:2px; overflow-x:auto; }
.stats-heatmap-hours { display:flex; align-items:center; gap:2px; padding-left:34px; } .stats-heatmap-hours { display:flex; align-items:center; gap:2px; padding-left:34px; }
@ -1037,7 +1075,7 @@ html.dark .stats-hm-cell:hover span { color: rgba(255,255,255,.95); }
} }
/* ── Open-tickets week/month compare ────────────────────────── */ /* ── Open-tickets week/month compare ────────────────────────── */
.stats-open-wrap { display:flex; gap:16px; align-items:flex-start; } .stats-open-wrap { display:flex; gap:16px; align-items: stretch; }
.stats-open-chart { flex:1; min-width:0; } .stats-open-chart { flex:1; min-width:0; }
.stats-open-compare { .stats-open-compare {
display: flex; display: flex;
@ -1046,25 +1084,27 @@ html.dark .stats-hm-cell:hover span { color: rgba(255,255,255,.95); }
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
flex-shrink: 0; flex-shrink: 0;
min-width: 108px; min-width: 100px;
overflow: hidden; overflow: hidden;
} }
.soc-item { .soc-item {
flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 3px; justify-content: center;
padding: 12px 16px; gap: 2px;
padding: 6px 14px;
} }
.soc-divider { height:1px; background:var(--border); } .soc-divider { height:1px; background:var(--border); flex-shrink:0; }
.soc-val { .soc-val {
font-size: 24px; font-size: 18px;
font-weight: 800; font-weight: 800;
color: var(--text); color: var(--text);
line-height: 1; line-height: 1;
} }
.soc-label { .soc-label {
font-size: 10px; font-size: 9px;
color: var(--text3); color: var(--text3);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: .04em; letter-spacing: .04em;

View file

@ -133,7 +133,7 @@ function renderStatsPage() {
data.forEach(item=>{const d=(now-new Date(item.task.createdat||item.task.updatedat))/86400000;for(const b of AB){if(d<=b.max){b.count++;break;}}}); data.forEach(item=>{const d=(now-new Date(item.task.createdat||item.task.updatedat))/86400000;for(const b of AB){if(d<=b.max){b.count++;break;}}});
const maxAB=Math.max(...AB.map(b=>b.count),1); const maxAB=Math.max(...AB.map(b=>b.count),1);
// Клиенты // Клиенты: «открытые» = одна логика (открыта / в работе / новая / назначена — как isTaskOpen)
const cMap = {}; const cMap = {};
data.forEach(({task:t})=>{ data.forEach(({task:t})=>{
const n=t._clientname||t.clientname||'Неизвестно'; const n=t._clientname||t.clientname||'Неизвестно';
@ -169,14 +169,13 @@ function renderStatsPage() {
const HM_ORDER = [1,2,3,4,5,6,0]; const HM_ORDER = [1,2,3,4,5,6,0];
const HM_LABELS = ['Пн','Вт','Ср','Чт','Пт','Сб','Вс']; const HM_LABELS = ['Пн','Вт','Ср','Чт','Пт','Сб','Вс'];
// Тепловая карта по месяцам (месяц × день недели) // Тепловая карта по месяцам (только 12 ячеек)
const MONTH_HM = Array.from({length:12}, () => new Array(7).fill(0)); const MONTH_COUNTS = new Array(12).fill(0);
data.forEach(({task:t}) => { data.forEach(({task:t}) => {
if (!t.createdat) return; if (!t.createdat) return;
const d = new Date(t.createdat); MONTH_COUNTS[new Date(t.createdat).getMonth()]++;
MONTH_HM[d.getMonth()][d.getDay()]++;
}); });
const monthHmMax = Math.max(...MONTH_HM.flat(), 1); const monthCountMax = Math.max(...MONTH_COUNTS, 1);
const MONTH_NAMES = ['Янв','Фев','Мар','Апр','Май','Июн','Июл','Авг','Сен','Окт','Ноя','Дек']; const MONTH_NAMES = ['Янв','Фев','Мар','Апр','Май','Июн','Июл','Авг','Сен','Окт','Ноя','Дек'];
// 4. Без исполнителя // 4. Без исполнителя
@ -468,21 +467,19 @@ function renderStatsPage() {
</div> </div>
</div> </div>
</div> </div>
<div class="stats-card" style="flex:1;min-width:200px;"> <div class="stats-card" style="flex:1;min-width:220px;">
<div class="stats-card-title">🗓 По месяцам (месяц × день нед.)</div> <div class="stats-card-title">🗓 Активность по месяцам</div>
<div class="stats-heatmap-wrap"> <div class="stats-month-hm">
<div class="stats-heatmap-hours" style="padding-left:38px;">
${HM_ORDER.map((_,i)=>`<div class="stats-hm-hlbl" style="width:26px;">${HM_LABELS[i]}</div>`).join('')}
</div>
${MONTH_NAMES.map((ml,mi)=>{ ${MONTH_NAMES.map((ml,mi)=>{
const row=MONTH_HM[mi]; const v=MONTH_COUNTS[mi];
return'<div class="stats-hm-row"><div class="stats-hm-dlbl" style="width:36px;font-size:9px;">'+ml+'</div>'+HM_ORDER.map((di,dii)=>{const v=row[di];return'<div class="stats-hm-cell" style="--hi:'+((v/monthHmMax)).toFixed(3)+';width:26px;height:22px;" title="'+ml+' '+HM_LABELS[dii]+' — '+v+' заявок"><span>'+(v||'')+'</span></div>';}).join('')+'</div>'; const hi=(v/monthCountMax).toFixed(3);
return'<div class="stats-month-hm-cell" style="--hi:'+hi+';" title="'+ml+' — '+v+' заявок"><span class="stats-month-hm-val">'+v+'</span><span class="stats-month-hm-lbl">'+ml+'</span></div>';
}).join('')} }).join('')}
<div class="stats-hm-legend" style="padding-left:38px;"> </div>
<span>меньше</span> <div class="stats-hm-legend" style="margin-top:12px;">
${[0,.2,.4,.6,.8,1].map(hi=>`<div class="stats-hm-leg-cell" style="--hi:${hi};"></div>`).join('')} <span>меньше</span>
<span>больше</span> ${[0,.2,.4,.6,.8,1].map(hi=>`<div class="stats-hm-leg-cell" style="--hi:${hi};"></div>`).join('')}
</div> <span>больше</span>
</div> </div>
</div> </div>
</div> </div>
@ -661,7 +658,13 @@ 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 stats-clients-row-clickable" title="Открыть фильтр по клиенту" data-client="'+escAttr(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('')} ${visible.map(([name,v])=>{
const dv=clientsOnlyOpen?v.open:v.count;
const pct=dv/maxV*100;
const opPct=clientsOnlyOpen?100:(v.count?v.open/v.count*100:0);
const badge=v.open?'<span class="stats-clients-open-badge">'+v.open+' откр.</span>':'';
return'<div class="stats-clients-row stats-clients-row-clickable" title="Открыть фильтр по клиенту" data-client="'+escAttr(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>'+badge+'</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>`:''}`;
} }