commit
1a94d039c2
12 changed files with 617 additions and 27 deletions
34
CHANGELOG.md
Normal file
34
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-03-23
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ИнтраDesk: служебный статус и синхронизация
|
||||||
|
|
||||||
|
- Колонка **«Служ. статус в интре»** — значение доп. поля "Внутренний статус" в интре; цвет по совпадению label с внутренними статусами. Имена жестко привязаны в интре
|
||||||
|
- Интерактивный пикер: смена значения в IntraDesk напрямую, пункт «— очистить».
|
||||||
|
- **Исходящая синхронизация**: при смене внутреннего статуса; при снятии статуса — очистка поля.
|
||||||
|
- **Входящая синхронизация**: при обновлении задач — выставление внутреннего статуса по label доп. поля.
|
||||||
|
|
||||||
|
### Таблица заявок, фильтры, UI
|
||||||
|
|
||||||
|
- Поиск в выпадающем фильтре **«Объекты»** (поле при открытии панели, автофокус).
|
||||||
|
- Кнопка переключения **тёмной темы** скрыта
|
||||||
|
- Выпадающие списки **внутреннего статуса** и **служебного статуса в интре**: при нехватке места снизу список открывается **вверх**;
|
||||||
|
|
||||||
|
### Страница «Статистика»
|
||||||
|
|
||||||
|
- **Открытых заявок**: боковая панель «сейчас / неделю или день назад / начало месяца» с бейджами отклонения; панель **справа от графика**, по высоте выровнена с графиком.
|
||||||
|
- **Открытых заявок**: Переключатель **«По неделям» / «По дням»**
|
||||||
|
- **Тепловая карта**: «час × день недели» пофиксил раскрашивание
|
||||||
|
- **Тепловая карта**: добавлена «активность по месяцам»
|
||||||
|
- **Создано / Закрыто по неделям**: при наведении — подсветка столбиков и **метка** с числами создано/закрыто над колонкой.
|
||||||
|
- **Клиенты**: клик по строке — переход на заявки с фильтром **«Объекты»** по этому клиенту
|
||||||
|
- **Повторные клиенты**: пофиксил окрашивание строк
|
||||||
|
|
||||||
|
### Локальный запуск (для отладки)
|
||||||
|
|
||||||
|
- `**docker-compose.local.yml`** — сборка из исходников, данные в `./data`.
|
||||||
|
- `**start-local.bat**` — запуск локального Docker под Windows.
|
||||||
|
|
||||||
22
docker-compose.local.yml
Normal file
22
docker-compose.local.yml
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
services:
|
||||||
|
intradesk-monitor:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: intradesk-monitor-local
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
|
ports:
|
||||||
|
- "${APP_PORT:-3000}:3000"
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
PORT: 3000
|
||||||
|
DB_PATH: /data/intradesk.db
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "-e", "const http=require('http');const req=http.get('http://127.0.0.1:3000/healthz',res=>process.exit(res.statusCode===200?0:1));req.on('error',()=>process.exit(1));"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
Binary file not shown.
|
|
@ -843,6 +843,20 @@ body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);min-h
|
||||||
position: fixed; /* fixed so it escapes any overflow:hidden parent */
|
position: fixed; /* fixed so it escapes any overflow:hidden parent */
|
||||||
z-index: 500;
|
z-index: 500;
|
||||||
}
|
}
|
||||||
|
/* Невидимая зона между бейджем и списком — курсор не выходит из «цепочки» hover */
|
||||||
|
.inline-status-picker .isp-dropdown::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: -10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
.inline-status-picker .isp-dropdown.isp-drop-up::before {
|
||||||
|
top: auto;
|
||||||
|
bottom: -10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
.inline-status-picker:hover .isp-dropdown,
|
.inline-status-picker:hover .isp-dropdown,
|
||||||
.inline-status-picker.isp-open .isp-dropdown {
|
.inline-status-picker.isp-open .isp-dropdown {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
@ -974,6 +988,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; }
|
||||||
|
|
@ -993,7 +1045,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 +1088,99 @@ 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-wrap { display:flex; gap:16px; align-items: stretch; }
|
||||||
|
.stats-open-chart { flex:1; min-width:0; }
|
||||||
|
.stats-open-compare {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--surface2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 100px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.soc-item {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
}
|
||||||
|
.soc-divider { height:1px; background:var(--border); flex-shrink:0; }
|
||||||
|
.soc-val {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.soc-label {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text3);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.soc-sep { display: none; }
|
||||||
|
.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 hover label ────────────────────────────────────── */
|
||||||
|
.stats-act-hover-label {
|
||||||
|
position: absolute;
|
||||||
|
top: -20px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity .15s;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px 5px;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.stats-activity-col:hover .stats-act-hover-label { opacity: 1; }
|
||||||
|
.stats-activity-col:hover .stats-activity-bar { filter: brightness(1.2) saturate(1.15); }
|
||||||
|
.stats-activity-bar { transition: filter .15s, height .2s; }
|
||||||
|
.sahl-cr { color: var(--accent); }
|
||||||
|
.sahl-sep { color: var(--text3); margin: 0 1px; }
|
||||||
|
.sahl-cl { color: var(--green); }
|
||||||
|
|
||||||
|
/* ── Activity tooltip (floating, kept for other use) ─────────── */
|
||||||
|
.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 +1356,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 +1577,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; }
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,12 @@
|
||||||
Sync Done with Intra by status_id
|
Sync Done with Intra by status_id
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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-addfield-status" style="accent-color:var(--accent);">
|
||||||
|
Синхронизировать «Служебный статус» с доп. полем IntraDesk (двусторонняя)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-field" style="flex:1;">
|
<div class="form-field" style="flex:1;">
|
||||||
<label>Done Intra IDs (CSV)</label>
|
<label>Done Intra IDs (CSV)</label>
|
||||||
|
|
@ -217,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>
|
||||||
|
|
|
||||||
|
|
@ -282,6 +282,7 @@ function applySettings(s) {
|
||||||
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-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-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-sync-done-intra')) f('setting-sync-done-intra').checked = String(s.sync_done_with_intra || '0') === '1';
|
||||||
|
if (f('setting-sync-addfield-status')) f('setting-sync-addfield-status').checked = String(s.sync_addfield_status || '0') === '1';
|
||||||
if (f('setting-intra-done-status-ids')) f('setting-intra-done-status-ids').value = s.intra_done_status_ids || '';
|
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 ? '(ключ задан ✓)' : '(не задан!)';
|
if (f('apikey-status')) f('apikey-status').textContent = s.api_key_set ? '(ключ задан ✓)' : '(не задан!)';
|
||||||
syncDoneSelectFromInput();
|
syncDoneSelectFromInput();
|
||||||
|
|
@ -341,6 +342,7 @@ async function saveSettings() {
|
||||||
auto_reset_on_update: document.getElementById('setting-auto-reset-on-update')?.checked ? '1' : '0',
|
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_reply_wait: document.getElementById('setting-sync-reply-wait')?.checked ? '1' : '0',
|
||||||
sync_done_with_intra: document.getElementById('setting-sync-done-intra')?.checked ? '1' : '0',
|
sync_done_with_intra: document.getElementById('setting-sync-done-intra')?.checked ? '1' : '0',
|
||||||
|
sync_addfield_status: document.getElementById('setting-sync-addfield-status')?.checked ? '1' : '0',
|
||||||
intra_done_status_ids: document.getElementById('setting-intra-done-status-ids')?.value.trim() || '',
|
intra_done_status_ids: document.getElementById('setting-intra-done-status-ids')?.value.trim() || '',
|
||||||
};
|
};
|
||||||
const apiKey = document.getElementById('api-key').value.trim();
|
const apiKey = document.getElementById('api-key').value.trim();
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,17 @@ function startSSE() {
|
||||||
refreshDetailIfOpen(tid);
|
refreshDetailIfOpen(tid);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
sseSource.addEventListener('intra_addfield',e=>{
|
||||||
|
const d=JSON.parse(e.data);
|
||||||
|
const item=allData.find((x)=>String(x.task.id)===String(d.task_id));
|
||||||
|
if(item) item.task._addfield_status=d.value||null;
|
||||||
|
if(d.by!==currentUser?.username){
|
||||||
|
showToast(`🔗 ${d.by}: служ. статус → «${d.value||'—'}» #${taskNumFromId(d.task_id)}`,'info');
|
||||||
|
}
|
||||||
|
renderTable();
|
||||||
|
refreshDetailIfOpen(d.task_id);
|
||||||
|
});
|
||||||
|
|
||||||
sseSource.addEventListener('tasks_updated',e=>{
|
sseSource.addEventListener('tasks_updated',e=>{
|
||||||
const d=JSON.parse(e.data);
|
const d=JSON.parse(e.data);
|
||||||
const incomingVersion=Number(d.cache_version||0);
|
const incomingVersion=Number(d.cache_version||0);
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ let clientsOnlyOpen = false;
|
||||||
let timingPeriodIdx = 4;
|
let timingPeriodIdx = 4;
|
||||||
let clientLastTicketMode = 'created'; // 'created' | 'updated'
|
let clientLastTicketMode = 'created'; // 'created' | 'updated'
|
||||||
|
|
||||||
|
/** false = по неделям (16 точек), true = по дням (112 дней) — график «открытых заявок» */
|
||||||
|
let openChartByDays = false;
|
||||||
|
|
||||||
const STATS_CACHE_KEY = 'intradesk_stats_html_v2';
|
const STATS_CACHE_KEY = 'intradesk_stats_html_v2';
|
||||||
function saveStatsCache() {
|
function saveStatsCache() {
|
||||||
try { sessionStorage.setItem(STATS_CACHE_KEY, JSON.stringify({ ts: Date.now(), html: document.getElementById('stats-content')?.innerHTML||'' })); } catch(e){}
|
try { sessionStorage.setItem(STATS_CACHE_KEY, JSON.stringify({ ts: Date.now(), html: document.getElementById('stats-content')?.innerHTML||'' })); } catch(e){}
|
||||||
|
|
@ -94,9 +97,44 @@ function renderStatsPage() {
|
||||||
wCreated.push(data.filter(({task:t})=>{const c=new Date(t.createdat);return c>=wStart&&c<wEnd;}).length);
|
wCreated.push(data.filter(({task:t})=>{const c=new Date(t.createdat);return c>=wStart&&c<wEnd;}).length);
|
||||||
wClosed.push(data.filter(({task:t})=>{const c=new Date(t.resolutiondatefact||t.closedat||0);return c>=wStart&&c<wEnd&&isTaskDone({task:t});}).length);
|
wClosed.push(data.filter(({task:t})=>{const c=new Date(t.resolutiondatefact||t.closedat||0);return c>=wStart&&c<wEnd&&isTaskDone({task:t});}).length);
|
||||||
}
|
}
|
||||||
const maxOpen = Math.max(...wOpen,1);
|
const maxOpenWeekly = Math.max(...wOpen,1);
|
||||||
const maxDual = Math.max(...wCreated,...wClosed,1);
|
const maxDual = Math.max(...wCreated,...wClosed,1);
|
||||||
|
|
||||||
|
// Линия «открытых»: те же 16 недель или помесячно-эквивалентно 112 дням
|
||||||
|
const OPEN_CHART_DAYS = 112;
|
||||||
|
let lineLabels, lineOpen, lineN, lineSlot, maxOpen, lineDotR, lineLabelStep;
|
||||||
|
if (openChartByDays) {
|
||||||
|
lineN = OPEN_CHART_DAYS;
|
||||||
|
lineSlot = 6;
|
||||||
|
lineDotR = 3;
|
||||||
|
lineLabelStep = 14;
|
||||||
|
lineLabels = [];
|
||||||
|
lineOpen = [];
|
||||||
|
for (let i = lineN - 1; i >= 0; i--) {
|
||||||
|
const tEnd = new Date(now - i * 86400000);
|
||||||
|
lineLabels.push(tEnd.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' }));
|
||||||
|
lineOpen.push(data.filter(({ task: t }) => {
|
||||||
|
const cr = new Date(t.createdat);
|
||||||
|
if (cr >= tEnd) return false;
|
||||||
|
if (!isTaskDone({ task: t })) return true;
|
||||||
|
return new Date(t.resolutiondatefact || t.closedat || 0) > tEnd;
|
||||||
|
}).length);
|
||||||
|
}
|
||||||
|
maxOpen = Math.max(...lineOpen, 1);
|
||||||
|
} else {
|
||||||
|
lineLabels = wLabels;
|
||||||
|
lineOpen = wOpen;
|
||||||
|
lineN = WEEKS;
|
||||||
|
lineSlot = 60;
|
||||||
|
lineDotR = 5;
|
||||||
|
lineLabelStep = 2;
|
||||||
|
maxOpen = maxOpenWeekly;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Открытых на начало текущего месяца
|
||||||
|
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'},
|
||||||
|
|
@ -130,7 +168,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||'Неизвестно';
|
||||||
|
|
@ -166,6 +204,15 @@ 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_COUNTS = new Array(12).fill(0);
|
||||||
|
data.forEach(({task:t}) => {
|
||||||
|
if (!t.createdat) return;
|
||||||
|
MONTH_COUNTS[new Date(t.createdat).getMonth()]++;
|
||||||
|
});
|
||||||
|
const monthCountMax = Math.max(...MONTH_COUNTS, 1);
|
||||||
|
const MONTH_NAMES = ['Янв','Фев','Мар','Апр','Май','Июн','Июл','Авг','Сен','Окт','Ноя','Дек'];
|
||||||
|
|
||||||
// 4. Без исполнителя
|
// 4. Без исполнителя
|
||||||
const noExec = data.filter(({task:t}) => !t.executor).length;
|
const noExec = data.filter(({task:t}) => !t.executor).length;
|
||||||
const noExecPct = total ? Math.round(noExec/total*100) : 0;
|
const noExecPct = total ? Math.round(noExec/total*100) : 0;
|
||||||
|
|
@ -356,16 +403,44 @@ function renderStatsPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Открытые на конец недели -->
|
<!-- Открытых заявок (недели / дни) -->
|
||||||
<div class="stats-card">
|
<div class="stats-card">
|
||||||
<div class="stats-card-title">📈 Открытых заявок на конец недели</div>
|
<div class="stats-card-title" style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px;">
|
||||||
|
<span>📈 Открытых заявок ${openChartByDays ? 'по дням' : 'на конец недели'}</span>
|
||||||
|
<div class="stats-period-tabs">
|
||||||
|
<button type="button" class="stats-period-tab${openChartByDays?'':' active'}" onclick="openChartByDays=false;renderStatsPage()">По неделям</button>
|
||||||
|
<button type="button" class="stats-period-tab${openChartByDays?' active':''}" onclick="openChartByDays=true;renderStatsPage()">По дням</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stats-open-wrap">
|
||||||
|
<div class="stats-open-chart">
|
||||||
<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 ${lineN*lineSlot} 130" preserveAspectRatio="none">
|
||||||
<defs><linearGradient id="lineGrad" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="var(--accent)" stop-opacity="0.18"/><stop offset="100%" stop-color="var(--accent)" stop-opacity="0.01"/></linearGradient></defs>
|
<defs><linearGradient id="lineGrad" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="var(--accent)" stop-opacity="0.18"/><stop offset="100%" stop-color="var(--accent)" stop-opacity="0.01"/></linearGradient></defs>
|
||||||
${(()=>{ if(maxOpen===0)return''; const pts=wOpen.map((v,i)=>[i*60+30,115-v/maxOpen*95]); const area='M'+pts[0]+' '+pts.slice(1).map(p=>'L'+p).join(' ')+' L'+pts[pts.length-1][0]+',115 L'+pts[0][0]+',115 Z'; const line='M'+pts[0]+' '+pts.slice(1).map(p=>'L'+p).join(' '); return'<path d="'+area+'" fill="url(#lineGrad)"/><path d="'+line+'" fill="none" stroke="var(--accent)" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"/>'+pts.map(([x,y],i)=>'<circle class="line-dot" cx="'+x+'" cy="'+y+'" r="5" fill="var(--accent)" stroke="var(--surface)" stroke-width="2" data-val="'+wOpen[i]+'" data-lbl="'+wLabels[i]+'" onmouseenter="showLineTip(event,this)" onmouseleave="hideLineTip()"/>').join(''); })()}
|
${(()=>{ if(maxOpen===0)return''; const cx=i=>i*lineSlot+lineSlot/2; const pts=lineOpen.map((v,i)=>[cx(i),115-v/maxOpen*95]); const area='M'+pts[0]+' '+pts.slice(1).map(p=>'L'+p).join(' ')+' L'+pts[pts.length-1][0]+',115 L'+pts[0][0]+',115 Z'; const line='M'+pts[0]+' '+pts.slice(1).map(p=>'L'+p).join(' '); return'<path d="'+area+'" fill="url(#lineGrad)"/><path d="'+line+'" fill="none" stroke="var(--accent)" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"/>'+pts.map(([x,y],i)=>'<circle class="line-dot" cx="'+x+'" cy="'+y+'" r="'+lineDotR+'" fill="var(--accent)" stroke="var(--surface)" stroke-width="2" data-val="'+lineOpen[i]+'" data-lbl="'+lineLabels[i]+'" onmouseenter="showLineTip(event,this)" onmouseleave="hideLineTip()"/>').join(''); })()}
|
||||||
</svg>
|
</svg>
|
||||||
<div class="stats-line-xlabels">${wLabels.map((l,i)=>i%2===0?`<span>${l}</span>`:'<span></span>').join('')}</div>
|
<div class="stats-line-xlabels">${lineLabels.map((l,i)=>i%lineLabelStep===0?`<span>${l}</span>`:'<span></span>').join('')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stats-open-compare">
|
||||||
|
<div class="soc-item">
|
||||||
|
<div class="soc-val">${lineOpen[lineN-1]}</div>
|
||||||
|
<div class="soc-label">сейчас</div>
|
||||||
|
</div>
|
||||||
|
<div class="soc-divider"></div>
|
||||||
|
<div class="soc-item">
|
||||||
|
<div class="soc-val">${lineN>=2?lineOpen[lineN-2]:'—'}</div>
|
||||||
|
<div class="soc-label">${openChartByDays?'день назад':'неделю назад'}</div>
|
||||||
|
${lineN>=2?wDiffBadge(lineOpen[lineN-1]-lineOpen[lineN-2]):''}
|
||||||
|
</div>
|
||||||
|
<div class="soc-divider"></div>
|
||||||
|
<div class="soc-item">
|
||||||
|
<div class="soc-val">${openAtMonthStart}</div>
|
||||||
|
<div class="soc-label">нач. месяца</div>
|
||||||
|
${wDiffBadge(lineOpen[lineN-1]-openAtMonthStart)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -377,7 +452,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"><div class="stats-activity-bar-wrap" style="position:relative;overflow:visible;"><div class="stats-act-hover-label">'+(cr||cl?'<span class="sahl-cr">'+cr+'</span><span class="sahl-sep">/</span><span class="sahl-cl">'+cl+'</span>':'')+'</div><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-label">'+wLabels[i]+'</div></div>'; }).join('')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -426,8 +501,9 @@ function renderStatsPage() {
|
||||||
<!-- НОВЫЕ КАРТОЧКИ -->
|
<!-- НОВЫЕ КАРТОЧКИ -->
|
||||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||||
|
|
||||||
<!-- 1. Тепловая карта активности -->
|
<!-- 1. Тепловые карты -->
|
||||||
<div class="stats-card">
|
<div class="stats-charts-row" style="align-items:flex-start;">
|
||||||
|
<div class="stats-card" style="flex:3;min-width:0;overflow-x:auto;">
|
||||||
<div class="stats-card-title">🌡️ Тепловая карта активности (час × день недели)</div>
|
<div class="stats-card-title">🌡️ Тепловая карта активности (час × день недели)</div>
|
||||||
<div class="stats-heatmap-wrap">
|
<div class="stats-heatmap-wrap">
|
||||||
<div class="stats-heatmap-hours">
|
<div class="stats-heatmap-hours">
|
||||||
|
|
@ -438,6 +514,22 @@ function renderStatsPage() {
|
||||||
return'<div class="stats-hm-row"><div class="stats-hm-dlbl">'+HM_LABELS[i]+'</div>'+row.map((v,h)=>'<div class="stats-hm-cell" style="--hi:'+((v/hmMax)).toFixed(3)+';" title="'+HM_LABELS[i]+' '+h+':00 — '+v+' заявок"><span>'+(v||'')+'</span></div>').join('')+'</div>';
|
return'<div class="stats-hm-row"><div class="stats-hm-dlbl">'+HM_LABELS[i]+'</div>'+row.map((v,h)=>'<div class="stats-hm-cell" style="--hi:'+((v/hmMax)).toFixed(3)+';" title="'+HM_LABELS[i]+' '+h+':00 — '+v+' заявок"><span>'+(v||'')+'</span></div>').join('')+'</div>';
|
||||||
}).join('')}
|
}).join('')}
|
||||||
<div class="stats-hm-legend">
|
<div class="stats-hm-legend">
|
||||||
|
<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 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 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>
|
||||||
|
<div class="stats-hm-legend" style="margin-top:12px;">
|
||||||
<span>меньше</span>
|
<span>меньше</span>
|
||||||
${[0,.2,.4,.6,.8,1].map(hi=>`<div class="stats-hm-leg-cell" style="--hi:${hi};"></div>`).join('')}
|
${[0,.2,.4,.6,.8,1].map(hi=>`<div class="stats-hm-leg-cell" style="--hi:${hi};"></div>`).join('')}
|
||||||
<span>больше</span>
|
<span>больше</span>
|
||||||
|
|
@ -620,7 +712,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" style="cursor:pointer;" onclick="goToClientTasks(\''+escHtml(name).replace(/'/g,"\\'")+'\')" title="Показать заявки: '+escHtml(name)+'"><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"><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>`:''}`;
|
||||||
}
|
}
|
||||||
|
|
@ -648,10 +740,28 @@ function donutLegend(sc,uc,tot){
|
||||||
}
|
}
|
||||||
|
|
||||||
function escHtml(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
|
function escHtml(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
|
||||||
|
function escAttr(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');}
|
||||||
|
|
||||||
|
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> <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();}
|
||||||
|
|
||||||
|
|
|
||||||
121
public/js/ui.js
121
public/js/ui.js
|
|
@ -1,13 +1,14 @@
|
||||||
// ── Column visibility ─────────────────────────────────────────────
|
// ── Column visibility ─────────────────────────────────────────────
|
||||||
const ALL_COLS = [
|
const ALL_COLS = [
|
||||||
{ key:'num', label:'# Заявка', def:true },
|
{ key:'num', label:'# Заявка', def:true },
|
||||||
{ key:'title', label:'Тема', def:true },
|
{ key:'title', label:'Тема', def:true },
|
||||||
{ key:'intrastatus', label:'Статус в интре', def:true },
|
{ key:'intrastatus', label:'Статус в интре', def:true },
|
||||||
{ key:'client', label:'Клиент', def:true },
|
{ key:'servstatus', label:'Служ. статус в интре', def:true },
|
||||||
{ key:'date', label:'Время ответа', def:true },
|
{ key:'client', label:'Клиент', def:true },
|
||||||
{ key:'created', label:'Создана', def:true },
|
{ key:'date', label:'Время ответа', def:true },
|
||||||
{ key:'preview', label:'Превью/Заметка', def:true },
|
{ key:'created', label:'Создана', def:true },
|
||||||
{ key:'status', label:'Статус', def:true },
|
{ key:'preview', label:'Превью/Заметка', def:true },
|
||||||
|
{ key:'status', label:'Статус', def:true },
|
||||||
];
|
];
|
||||||
let visibleCols = new Set(ALL_COLS.filter(c=>c.def).map(c=>c.key));
|
let visibleCols = new Set(ALL_COLS.filter(c=>c.def).map(c=>c.key));
|
||||||
let colMenuOpen = false;
|
let colMenuOpen = false;
|
||||||
|
|
@ -37,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',
|
||||||
|
|
@ -144,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';
|
||||||
|
|
@ -455,6 +479,47 @@ function intraStatusBadge(task) {
|
||||||
else if (lc.includes('ожидан') || lc.includes('hold')) cls = 'sb-intra-hold';
|
else if (lc.includes('ожидан') || lc.includes('hold')) cls = 'sb-intra-hold';
|
||||||
return `<span class="status-badge ${cls}"><span class="dot"></span>${esc(name)}</span>`;
|
return `<span class="status-badge ${cls}"><span class="dot"></span>${esc(name)}</span>`;
|
||||||
}
|
}
|
||||||
|
// ── IntraDesk additional field — badge helper ─────────────────────
|
||||||
|
function _addfieldBadgeHtml(val) {
|
||||||
|
if (!val) return '<span style="color:var(--text3);font-size:11px;">—</span>';
|
||||||
|
const match = (typeof MARKS_CONFIG !== 'undefined' ? MARKS_CONFIG : [])
|
||||||
|
.find((m) => m.label && m.label.toLowerCase() === val.toLowerCase());
|
||||||
|
if (match) {
|
||||||
|
return `<span class="status-badge" style="background:${match.colorBg||'var(--surface2)'};border-color:${match.colorBorder||'var(--border2)'};color:${match.colorText||'var(--text2)'};">` +
|
||||||
|
`<span class="dot" style="background:${match.colorDot||match.ispColor||'#1677ff'};"></span>${esc(val)}</span>`;
|
||||||
|
}
|
||||||
|
return `<span class="status-badge sb-intra-other"><span class="dot"></span>${esc(val)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── IntraDesk additional field — interactive picker ───────────────
|
||||||
|
function intraAddfieldPickerHtml(task) {
|
||||||
|
const tid = String(task.id);
|
||||||
|
const val = task._addfield_status || null;
|
||||||
|
const cfg = typeof MARKS_CONFIG !== 'undefined' ? MARKS_CONFIG : [];
|
||||||
|
const items = cfg.map((m) => `
|
||||||
|
<div class="isp-item ${val && val.toLowerCase()===m.label.toLowerCase()?'isp-active':''}"
|
||||||
|
onclick="setIntraAddfield('${esc(tid)}','${esc(m.label)}',event)">
|
||||||
|
<span class="isp-dot" style="background:${m.ispColor||m.colorDot||'#1677ff'};"></span>${esc(m.label)}
|
||||||
|
</div>`).join('');
|
||||||
|
const clearItem = `<div class="isp-item${!val?' isp-active':''}" onclick="setIntraAddfield('${esc(tid)}',null,event)">
|
||||||
|
<span class="isp-dot" style="background:var(--text3);"></span><span style="color:var(--text3);">— очистить</span>
|
||||||
|
</div>`;
|
||||||
|
return `<div class="inline-status-picker" onmouseenter="ispShow(this)" onmouseleave="ispHide(this)">
|
||||||
|
${_addfieldBadgeHtml(val)}
|
||||||
|
<div class="isp-dropdown">
|
||||||
|
<div class="isp-dropdown-inner">${items}${clearItem}</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setIntraAddfield(taskId, value, e) {
|
||||||
|
if (e) e.stopPropagation();
|
||||||
|
const r = await apiPost(`/api/tasks/${taskId}/intra-addfield`, { value: value || null });
|
||||||
|
if (!r?.ok) { showToast(`❌ ${r?.error || 'Ошибка'}`, 'warn'); return; }
|
||||||
|
// Обновляем кеш локально до следующего SSE
|
||||||
|
const item = allData.find((d) => String(d.task.id) === String(taskId));
|
||||||
|
if (item) { item.task._addfield_status = value || null; renderTable(); }
|
||||||
|
}
|
||||||
// Who answered — ring class for avatar
|
// Who answered — ring class for avatar
|
||||||
function whoAnsweredRing(lc){
|
function whoAnsweredRing(lc){
|
||||||
if(!lc) return 'ring-none';
|
if(!lc) return 'ring-none';
|
||||||
|
|
@ -498,15 +563,45 @@ function statusPickerHtml(tid){
|
||||||
}
|
}
|
||||||
function ispShow(el){
|
function ispShow(el){
|
||||||
const dd=el.querySelector('.isp-dropdown');
|
const dd=el.querySelector('.isp-dropdown');
|
||||||
if(!dd) return;
|
const inner=dd?.querySelector('.isp-dropdown-inner');
|
||||||
|
if(!dd||!inner) return;
|
||||||
|
/** Список чуть заходит на бейдж — без «дыры», курсор не теряет hover */
|
||||||
|
const OVERLAP=4;
|
||||||
|
const edge=6;
|
||||||
const r=el.getBoundingClientRect();
|
const r=el.getBoundingClientRect();
|
||||||
dd.style.top=(r.bottom)+'px';
|
inner.style.maxHeight='';
|
||||||
dd.style.left=r.left+'px';
|
inner.style.overflowY='';
|
||||||
|
dd.classList.remove('isp-drop-up');
|
||||||
dd.style.display='block';
|
dd.style.display='block';
|
||||||
|
dd.style.left=r.left+'px';
|
||||||
|
dd.style.top=(r.bottom-OVERLAP)+'px';
|
||||||
|
let h=dd.getBoundingClientRect().height;
|
||||||
|
const spaceBelow=window.innerHeight-r.bottom+OVERLAP;
|
||||||
|
const spaceAbove=r.top+OVERLAP;
|
||||||
|
const flipUp=h>spaceBelow&&spaceAbove>=spaceBelow;
|
||||||
|
if(flipUp){
|
||||||
|
let top=r.top-h+OVERLAP;
|
||||||
|
if(top<edge){
|
||||||
|
inner.style.maxHeight=Math.max(80,spaceAbove-edge)+'px';
|
||||||
|
inner.style.overflowY='auto';
|
||||||
|
h=dd.getBoundingClientRect().height;
|
||||||
|
top=r.top-h+OVERLAP;
|
||||||
|
}
|
||||||
|
dd.style.top=Math.max(edge,top)+'px';
|
||||||
|
dd.classList.add('isp-drop-up');
|
||||||
|
}else if(h>spaceBelow){
|
||||||
|
inner.style.maxHeight=Math.max(80,spaceBelow-edge)+'px';
|
||||||
|
inner.style.overflowY='auto';
|
||||||
|
dd.style.top=(r.bottom-OVERLAP)+'px';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
function ispHide(el){
|
function ispHide(el){
|
||||||
const dd=el.querySelector('.isp-dropdown');
|
const dd=el.querySelector('.isp-dropdown');
|
||||||
if(dd) dd.style.display='none';
|
if(!dd) return;
|
||||||
|
dd.style.display='none';
|
||||||
|
dd.classList.remove('isp-drop-up');
|
||||||
|
const inner=dd.querySelector('.isp-dropdown-inner');
|
||||||
|
if(inner){ inner.style.maxHeight=''; inner.style.overflowY=''; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Column picker ─────────────────────────────────────────────────
|
// ── Column picker ─────────────────────────────────────────────────
|
||||||
|
|
@ -709,6 +804,7 @@ function renderTable(keepPage){
|
||||||
${sc('num') ?'<th style="width:70px;">#</th>' :''}
|
${sc('num') ?'<th style="width:70px;">#</th>' :''}
|
||||||
${sc('title') ?'<th>Тема</th>' :''}
|
${sc('title') ?'<th>Тема</th>' :''}
|
||||||
${sc('intrastatus')?'<th style="width:110px;">Статус в интре</th>':''}
|
${sc('intrastatus')?'<th style="width:110px;">Статус в интре</th>':''}
|
||||||
|
${sc('servstatus') ?'<th style="width:130px;">Служ. статус в интре</th>':''}
|
||||||
${sc('status') ?'<th style="width:130px;">Статус</th>' :''}
|
${sc('status') ?'<th style="width:130px;">Статус</th>' :''}
|
||||||
${sc('client') ?'<th style="width:170px;">Кто ответил</th>' :''}
|
${sc('client') ?'<th style="width:170px;">Кто ответил</th>' :''}
|
||||||
${sc('date') ?`<th style="width:105px;${sortStyle('date')}" onclick="toggleSort('date')">Время ответа<span style="font-size:10px;opacity:.6;">${sortArrow('date')}</span></th>` :''}
|
${sc('date') ?`<th style="width:105px;${sortStyle('date')}" onclick="toggleSort('date')">Время ответа<span style="font-size:10px;opacity:.6;">${sortArrow('date')}</span></th>` :''}
|
||||||
|
|
@ -747,6 +843,7 @@ function renderTable(keepPage){
|
||||||
${sc('num') ?`<td><span class="task-num">#${num}</span>${unreadDot}</td>`:''}
|
${sc('num') ?`<td><span class="task-num">#${num}</span>${unreadDot}</td>`:''}
|
||||||
${sc('title') ?`<td><div class="task-title-main" title="${esc(task.name)}">${esc(task.name)}</div>${orgName?`<div class="task-title-sub">${esc(orgName)}</div>`:''}</td>`:''}
|
${sc('title') ?`<td><div class="task-title-main" title="${esc(task.name)}">${esc(task.name)}</div>${orgName?`<div class="task-title-sub">${esc(orgName)}</div>`:''}</td>`:''}
|
||||||
${sc('intrastatus')?`<td>${intraStatusBadge(task)}</td>`:''}
|
${sc('intrastatus')?`<td>${intraStatusBadge(task)}</td>`:''}
|
||||||
|
${sc('servstatus') ?`<td onclick="event.stopPropagation()">${intraAddfieldPickerHtml(task)}</td>`:''}
|
||||||
${sc('status') ?`<td onclick="event.stopPropagation()">${statusPickerHtml(tid)}</td>`:''}
|
${sc('status') ?`<td onclick="event.stopPropagation()">${statusPickerHtml(tid)}</td>`:''}
|
||||||
${sc('client') ?`<td><div class="client-info">
|
${sc('client') ?`<td><div class="client-info">
|
||||||
<div class="client-avatar ${whoAnsweredRing(lastComment)}" style="background:${avatarColor(authorName)};color:#fff;">${avatarInitials(authorName)}</div>
|
<div class="client-avatar ${whoAnsweredRing(lastComment)}" style="background:${avatarColor(authorName)};color:#fff;">${avatarInitials(authorName)}</div>
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ const REMOVE_EVENT_TYPES = new Set([
|
||||||
|
|
||||||
const COMMENT_EVENT_TYPES = new Set([50, 55]);
|
const COMMENT_EVENT_TYPES = new Set([50, 55]);
|
||||||
|
|
||||||
|
const INTRA_ADDFIELD_ALIAS = 'addfield_2f6eaa2vnutrennij_statusSingleSelect';
|
||||||
|
|
||||||
function toBoolFlag(value) {
|
function toBoolFlag(value) {
|
||||||
return String(value || '0') === '1' || value === true;
|
return String(value || '0') === '1' || value === true;
|
||||||
}
|
}
|
||||||
|
|
@ -164,6 +166,9 @@ function createRefreshService({ db, fetch, getSettings, sseBroadcast, logger = c
|
||||||
INSERT INTO marks(task_id,mark_type,is_active,set_by,set_at) VALUES(?,?,?,?,?)
|
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
|
ON CONFLICT(task_id,mark_type) DO UPDATE SET is_active=excluded.is_active,set_by=excluded.set_by,set_at=excluded.set_at
|
||||||
`),
|
`),
|
||||||
|
getStatusKeyByLabel: db.prepare(
|
||||||
|
"SELECT key FROM internal_statuses WHERE is_active=1 AND lower(label)=lower(?) LIMIT 1"
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
function getSyncState() {
|
function getSyncState() {
|
||||||
|
|
@ -205,6 +210,7 @@ function createRefreshService({ db, fetch, getSettings, sseBroadcast, logger = c
|
||||||
auto_reset_on_update: toBoolFlag(s.auto_reset_on_update),
|
auto_reset_on_update: toBoolFlag(s.auto_reset_on_update),
|
||||||
sync_reply_wait: toBoolFlag(s.sync_reply_wait),
|
sync_reply_wait: toBoolFlag(s.sync_reply_wait),
|
||||||
sync_done_with_intra: toBoolFlag(s.sync_done_with_intra),
|
sync_done_with_intra: toBoolFlag(s.sync_done_with_intra),
|
||||||
|
sync_addfield_status: toBoolFlag(s.sync_addfield_status),
|
||||||
intra_done_status_ids: doneIds,
|
intra_done_status_ids: doneIds,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -277,10 +283,29 @@ function createRefreshService({ db, fetch, getSettings, sseBroadcast, logger = c
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateIntraAdditionalField(taskNumber, statusLabel, apiKey) {
|
||||||
|
if (!apiKey) throw new Error('API ключ не задан');
|
||||||
|
const url = `https://apigw.intradesk.ru/changes/v3/tasks/?ApiKey=${apiKey}`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
number: Number(taskNumber),
|
||||||
|
blocks: { [INTRA_ADDFIELD_ALIAS]: JSON.stringify({ value: statusLabel || null }) },
|
||||||
|
Channel: 'api',
|
||||||
|
}),
|
||||||
|
timeout: 15000,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text().catch(() => '');
|
||||||
|
throw new Error(`IntraDesk вернул ${response.status}: ${text.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function applyStatusAutomationForChangedTasks(changedTaskIds, tasksById, settings, reason = '') {
|
async function applyStatusAutomationForChangedTasks(changedTaskIds, tasksById, settings, reason = '') {
|
||||||
const changedIds = [...(changedTaskIds || [])].map((v) => String(v));
|
const changedIds = [...(changedTaskIds || [])].map((v) => String(v));
|
||||||
if (!changedIds.length) return;
|
if (!changedIds.length) return;
|
||||||
if (!settings.sync_done_with_intra && !settings.sync_reply_wait && !settings.auto_reset_on_update) return;
|
if (!settings.sync_done_with_intra && !settings.sync_reply_wait && !settings.auto_reset_on_update && !settings.sync_addfield_status) return;
|
||||||
|
|
||||||
const systemStatuses = getSystemStatusKeys();
|
const systemStatuses = getSystemStatusKeys();
|
||||||
const doneKey = systemStatuses.done || null;
|
const doneKey = systemStatuses.done || null;
|
||||||
|
|
@ -326,6 +351,18 @@ function createRefreshService({ db, fetch, getSettings, sseBroadcast, logger = c
|
||||||
|
|
||||||
if (!applied && settings.auto_reset_on_update && currentMark) {
|
if (!applied && settings.auto_reset_on_update && currentMark) {
|
||||||
clearTaskMarks(taskId, 'system');
|
clearTaskMarks(taskId, 'system');
|
||||||
|
applied = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.sync_addfield_status) {
|
||||||
|
const addfieldVal = task._addfield_status;
|
||||||
|
if (addfieldVal) {
|
||||||
|
const row = stmts.getStatusKeyByLabel.get(addfieldVal);
|
||||||
|
if (row && currentMark !== row.key) {
|
||||||
|
setExclusiveMark(taskId, row.key, 'system');
|
||||||
|
logger.log(`[Refresh] Inbound addfield sync: task ${taskId} → mark "${row.key}" (${reason})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -421,6 +458,8 @@ function createRefreshService({ db, fetch, getSettings, sseBroadcast, logger = c
|
||||||
if (task.executorgroup && dictMap[task.executorgroup]) task._executorgroupname = dictMap[task.executorgroup];
|
if (task.executorgroup && dictMap[task.executorgroup]) task._executorgroupname = dictMap[task.executorgroup];
|
||||||
const statusKey = task.statusid || task.status;
|
const statusKey = task.statusid || task.status;
|
||||||
if (statusKey && dictMap[statusKey]) task._statusname = dictMap[statusKey];
|
if (statusKey && dictMap[statusKey]) task._statusname = dictMap[statusKey];
|
||||||
|
const af = (task.additionalfields?.data || []).find((d) => d.alias === INTRA_ADDFIELD_ALIAS);
|
||||||
|
task._addfield_status = af?.value?.stringvalue || null;
|
||||||
});
|
});
|
||||||
|
|
||||||
upsertIntraStatusesFromTasks(tasks);
|
upsertIntraStatusesFromTasks(tasks);
|
||||||
|
|
@ -741,6 +780,7 @@ function createRefreshService({ db, fetch, getSettings, sseBroadcast, logger = c
|
||||||
requestRemoveTask,
|
requestRemoveTask,
|
||||||
parseWebhookAction,
|
parseWebhookAction,
|
||||||
extractWebhookTaskId,
|
extractWebhookTaskId,
|
||||||
|
updateIntraAdditionalField,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
56
server.js
56
server.js
|
|
@ -767,9 +767,61 @@ app.post('/api/marks', auth, (req, res) => {
|
||||||
task_id, mark_type: markType, value: !!value, by: req.user.username, at: now,
|
task_id, mark_type: markType, value: !!value, by: req.user.username, at: now,
|
||||||
cleared_marks: clearedMarks,
|
cleared_marks: clearedMarks,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Outbound sync: push mark label to IntraDesk additional field
|
||||||
|
const settings = getSettings();
|
||||||
|
if (settings.sync_addfield_status === '1' && settings.api_key) {
|
||||||
|
try {
|
||||||
|
const cacheRow = db.prepare('SELECT data FROM tasks_cache WHERE id=1').get();
|
||||||
|
const tasks = JSON.parse(cacheRow?.data || '[]');
|
||||||
|
const task = tasks.find((t) => String(t.id) === String(task_id));
|
||||||
|
if (task) {
|
||||||
|
const statusLabel = value
|
||||||
|
? db.prepare('SELECT label FROM internal_statuses WHERE key=?').get(markType)?.label || null
|
||||||
|
: null;
|
||||||
|
refreshService.updateIntraAdditionalField(task.tasknumber, statusLabel, settings.api_key)
|
||||||
|
.catch((e) => console.error('[Marks] IntraDesk addfield update error:', e.message));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Marks] Outbound sync error:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── IntraDesk additional field (direct set) ───────────────────────
|
||||||
|
app.post('/api/tasks/:taskId/intra-addfield', auth, async (req, res) => {
|
||||||
|
const taskId = String(req.params.taskId || '').trim();
|
||||||
|
const { value } = req.body || {};
|
||||||
|
const statusLabel = value ? String(value).trim() : null;
|
||||||
|
if (!taskId) return res.status(400).json({ error: 'task_id required' });
|
||||||
|
|
||||||
|
const s = getSettings();
|
||||||
|
if (!s.api_key) return res.status(400).json({ error: 'API ключ не задан' });
|
||||||
|
|
||||||
|
const cacheRow = db.prepare('SELECT data FROM tasks_cache WHERE id=1').get();
|
||||||
|
const tasks = JSON.parse(cacheRow?.data || '[]');
|
||||||
|
const task = tasks.find((t) => String(t.id) === taskId);
|
||||||
|
if (!task) return res.status(404).json({ error: 'Задача не найдена в кеше' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await refreshService.updateIntraAdditionalField(task.tasknumber, statusLabel, s.api_key);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[AddField] IntraDesk update error:', e.message);
|
||||||
|
return res.status(502).json({ error: `Ошибка IntraDesk: ${e.message}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем значение в локальном кеше немедленно
|
||||||
|
task._addfield_status = statusLabel;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
db.prepare('UPDATE tasks_cache SET data=?, updated_at=? WHERE id=1')
|
||||||
|
.run(JSON.stringify(tasks), now);
|
||||||
|
|
||||||
|
sseBroadcast('intra_addfield', { task_id: taskId, value: statusLabel, by: req.user.username });
|
||||||
|
res.json({ ok: true, value: statusLabel });
|
||||||
|
});
|
||||||
|
|
||||||
// ── Notes ─────────────────────────────────────────────────────────
|
// ── Notes ─────────────────────────────────────────────────────────
|
||||||
app.get('/api/notes', auth, (req, res) => {
|
app.get('/api/notes', auth, (req, res) => {
|
||||||
const r = {};
|
const r = {};
|
||||||
|
|
@ -904,6 +956,7 @@ const ALLOWED_SETTINGS = new Set([
|
||||||
'sync_reply_wait',
|
'sync_reply_wait',
|
||||||
'sync_done_with_intra',
|
'sync_done_with_intra',
|
||||||
'intra_done_status_ids',
|
'intra_done_status_ids',
|
||||||
|
'sync_addfield_status',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
app.get('/api/settings', auth, adminOnly, (req, res) => {
|
app.get('/api/settings', auth, adminOnly, (req, res) => {
|
||||||
|
|
@ -922,6 +975,7 @@ app.get('/api/settings', auth, adminOnly, (req, res) => {
|
||||||
sync_reply_wait: s.sync_reply_wait || '0',
|
sync_reply_wait: s.sync_reply_wait || '0',
|
||||||
sync_done_with_intra: s.sync_done_with_intra || '0',
|
sync_done_with_intra: s.sync_done_with_intra || '0',
|
||||||
intra_done_status_ids: s.intra_done_status_ids || '',
|
intra_done_status_ids: s.intra_done_status_ids || '',
|
||||||
|
sync_addfield_status: s.sync_addfield_status || '0',
|
||||||
api_key_set: !!s.api_key,
|
api_key_set: !!s.api_key,
|
||||||
webhook_secret_set: !!s.webhook_secret,
|
webhook_secret_set: !!s.webhook_secret,
|
||||||
});
|
});
|
||||||
|
|
@ -935,7 +989,7 @@ app.post('/api/settings', auth, adminOnly, (req, res) => {
|
||||||
setSetting(k, normalizeCsvIds(v));
|
setSetting(k, normalizeCsvIds(v));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (k === 'auto_reset_on_update' || k === 'sync_reply_wait' || k === 'sync_done_with_intra') {
|
if (k === 'auto_reset_on_update' || k === 'sync_reply_wait' || k === 'sync_done_with_intra' || k === 'sync_addfield_status') {
|
||||||
setSetting(k, String(v) === '1' || v === true ? '1' : '0');
|
setSetting(k, String(v) === '1' || v === true ? '1' : '0');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
48
start-local.bat
Normal file
48
start-local.bat
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
@echo off
|
||||||
|
setlocal enabledelayedexpansion
|
||||||
|
|
||||||
|
cd /d "%~dp0"
|
||||||
|
|
||||||
|
if not exist ".env" (
|
||||||
|
echo [ERROR] Файл .env не найден.
|
||||||
|
echo Скопируйте .env.example в .env и заполните переменные.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
if not exist "data" mkdir data
|
||||||
|
|
||||||
|
where docker >nul 2>&1
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [ERROR] Docker не найден. Установите Docker Desktop: https://www.docker.com/products/docker-desktop
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
:: Читаем PORT из .env для вывода в консоль
|
||||||
|
set PORT=3000
|
||||||
|
for /f "usebackq tokens=1,* delims==" %%A in (".env") do (
|
||||||
|
set "ln=%%A"
|
||||||
|
if /i "!ln!" == "APP_PORT" set PORT=%%B
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ================================================
|
||||||
|
echo Intra Monitor — локальный запуск в Docker
|
||||||
|
echo Сборка образа из исходников...
|
||||||
|
echo ================================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
docker compose -f docker-compose.local.yml up -d --build
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [ERROR] docker compose завершился с ошибкой.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo [OK] Контейнер запущен: http://localhost:%PORT%
|
||||||
|
echo Логи: docker compose -f docker-compose.local.yml logs -f
|
||||||
|
echo Стоп: docker compose -f docker-compose.local.yml down
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
Loading…
Add table
Reference in a new issue