statistica vibe 3
This commit is contained in:
parent
77d3ac94ad
commit
de69bddcaf
3 changed files with 141 additions and 63 deletions
97
CHANGELOG.md
97
CHANGELOG.md
|
|
@ -1,56 +1,91 @@
|
|||
# Changelog
|
||||
|
||||
## [sync2 vibe] — 2026-03-23
|
||||
## [statistica vibe 3] — 2026-03-23
|
||||
|
||||
### Новое: «Служебный статус в интре» — двусторонняя синхронизация
|
||||
> Правки и улучшения статистики после `statistica vibe 2`
|
||||
|
||||
#### Колонка в таблице заявок
|
||||
### Улучшения
|
||||
|
||||
- Добавлена новая колонка **«Служ. статус в интре»** (`servstatus`) — отображает значение дополнительного поля `addfield_2f6eaa2vnutrennij_statusSingleSelect` из IntraDesk
|
||||
- Колонка интерактивная: при наведении открывается выпадающий пикер с полным списком внутренних статусов (аналогично колонке «Статус»)
|
||||
- Текущее значение подсвечивается цветом соответствующего внутреннего статуса (если совпадает по label)
|
||||
- Есть пункт «— очистить» для сброса значения в IntraDesk
|
||||
- **Тепловая карта по месяцам** переработана: вместо сетки месяц×день-недели теперь отображается одна строка из 12 ячеек (по одной на месяц), каждая окрашена по числу заявок. Значение показывается при наведении.
|
||||
- **Блок сравнения** («сейчас / неделю назад / начало месяца») теперь растягивается в высоту графика (`align-items: stretch`), элементы равномерно распределяются по высоте — цифры больше не выступают за пределы графика.
|
||||
- **Повторные клиенты** — один счётчик «открытых» через `isTaskOpen` (в т.ч. «в работе», «новая», «назначена» и т.п.); одна акцентная полоса и бейдж `N откр.` без разделения цветов.
|
||||
|
||||
#### Синхронизация IntraDesk → наш статус (входящая)
|
||||
### Исправления
|
||||
|
||||
- При каждом обновлении задачи в ходе инкрементального или полного сканирования проверяется значение доп. поля
|
||||
- Если значение доп. поля совпадает с label одного из активных внутренних статусов — этот статус автоматически выставляется через `setExclusiveMark` (без участия пользователя)
|
||||
- Управляется настройкой `sync_addfield_status` (по умолчанию выключена)
|
||||
- **Карточка «Клиенты» + «Только с открытыми»**: при включённом фильтре внутренняя полоса теперь заполняет внешнюю полностью (`opPct = 100%`), чтобы не было двойного масштабирования и визуально «другой» закраски относительно режима без фильтра.
|
||||
|
||||
#### Синхронизация наш статус → 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
|
||||
- Ключ: `sync_addfield_status`, булева строка `'0'`/`'1'`
|
||||
## [123 → statistica vibe 2] — 2026-03-23
|
||||
|
||||
#### Запуск локально
|
||||
> Коммиты: `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` |
|
||||
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `refresh-service.js` | Константа `INTRA_ADDFIELD_ALIAS`; извлечение `_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/ui.js` | Колонка `servstatus` в `ALL_COLS`; `intraAddfieldBadge`, `intraAddfieldPickerHtml`, `setIntraAddfield`; `hasSearch: true` для фильтра «Объекты»; поисковый блок в `buildAdvancedFilterPanel` |
|
||||
| `public/js/sse.js` | Обработчик события `intra_addfield` |
|
||||
| `public/js/auth.js` | Загрузка/сохранение чекбокса `sync_addfield_status` |
|
||||
| `public/index.html` | Чекбокс настройки в панели Status Automation |
|
||||
| `public/js/auth.js` | Загрузка и сохранение чекбокса `sync_addfield_status` |
|
||||
| `public/js/statistics.js` | Блок сравнения открытых заявок (wDiffBadge, openAtMonthStart); тепловая карта по месяцам (MONTH_HM, MONTH_NAMES); кликабельные клиенты (`filterByClient`, `escAttr`); hover-label на столбиках активности |
|
||||
| `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 |
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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-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 ─────────────────────────────────────────────────── */
|
||||
.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; }
|
||||
|
|
@ -1037,7 +1075,7 @@ html.dark .stats-hm-cell:hover span { color: rgba(255,255,255,.95); }
|
|||
}
|
||||
|
||||
/* ── 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-compare {
|
||||
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-radius: var(--radius);
|
||||
flex-shrink: 0;
|
||||
min-width: 108px;
|
||||
min-width: 100px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.soc-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 12px 16px;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
padding: 6px 14px;
|
||||
}
|
||||
.soc-divider { height:1px; background:var(--border); }
|
||||
.soc-divider { height:1px; background:var(--border); flex-shrink:0; }
|
||||
.soc-val {
|
||||
font-size: 24px;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: var(--text);
|
||||
line-height: 1;
|
||||
}
|
||||
.soc-label {
|
||||
font-size: 10px;
|
||||
font-size: 9px;
|
||||
color: var(--text3);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
|
|
|
|||
|
|
@ -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;}}});
|
||||
const maxAB=Math.max(...AB.map(b=>b.count),1);
|
||||
|
||||
// Клиенты
|
||||
// Клиенты: «открытые» = одна логика (открыта / в работе / новая / назначена — как isTaskOpen)
|
||||
const cMap = {};
|
||||
data.forEach(({task:t})=>{
|
||||
const n=t._clientname||t.clientname||'Неизвестно';
|
||||
|
|
@ -169,14 +169,13 @@ function renderStatsPage() {
|
|||
const HM_ORDER = [1,2,3,4,5,6,0];
|
||||
const HM_LABELS = ['Пн','Вт','Ср','Чт','Пт','Сб','Вс'];
|
||||
|
||||
// Тепловая карта по месяцам (месяц × день недели)
|
||||
const MONTH_HM = Array.from({length:12}, () => new Array(7).fill(0));
|
||||
// Тепловая карта по месяцам (только 12 ячеек)
|
||||
const MONTH_COUNTS = new Array(12).fill(0);
|
||||
data.forEach(({task:t}) => {
|
||||
if (!t.createdat) return;
|
||||
const d = new Date(t.createdat);
|
||||
MONTH_HM[d.getMonth()][d.getDay()]++;
|
||||
MONTH_COUNTS[new Date(t.createdat).getMonth()]++;
|
||||
});
|
||||
const monthHmMax = Math.max(...MONTH_HM.flat(), 1);
|
||||
const monthCountMax = Math.max(...MONTH_COUNTS, 1);
|
||||
const MONTH_NAMES = ['Янв','Фев','Мар','Апр','Май','Июн','Июл','Авг','Сен','Окт','Ноя','Дек'];
|
||||
|
||||
// 4. Без исполнителя
|
||||
|
|
@ -468,24 +467,22 @@ function renderStatsPage() {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats-card" style="flex:1;min-width:200px;">
|
||||
<div class="stats-card-title">🗓️ По месяцам (месяц × день нед.)</div>
|
||||
<div class="stats-heatmap-wrap">
|
||||
<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>
|
||||
<div class="stats-card" style="flex:1;min-width:220px;">
|
||||
<div class="stats-card-title">🗓️ Активность по месяцам</div>
|
||||
<div class="stats-month-hm">
|
||||
${MONTH_NAMES.map((ml,mi)=>{
|
||||
const row=MONTH_HM[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 v=MONTH_COUNTS[mi];
|
||||
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('')}
|
||||
<div class="stats-hm-legend" style="padding-left:38px;">
|
||||
</div>
|
||||
<div class="stats-hm-legend" style="margin-top:12px;">
|
||||
<span>меньше</span>
|
||||
${[0,.2,.4,.6,.8,1].map(hi=>`<div class="stats-hm-leg-cell" style="--hi:${hi};"></div>`).join('')}
|
||||
<span>больше</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 5. Тип проблемы + 11. Время жизни по типу + 15. Тип поддержки -->
|
||||
<div class="stats-charts-row">
|
||||
|
|
@ -661,7 +658,13 @@ function renderClientsList() {
|
|||
if(visible.length===0){wrap.innerHTML=`<div class="stats-empty">${clientsOnlyOpen?'Нет клиентов с открытыми заявками':'Нет повторных обращений'}</div>`;return;}
|
||||
wrap.innerHTML=`
|
||||
<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>
|
||||
${!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>`:''}`;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue