мерж
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

This commit is contained in:
dv 2026-04-14 17:11:05 +03:00
commit 1a94d039c2
12 changed files with 617 additions and 27 deletions

34
CHANGELOG.md Normal file
View 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
View 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.

View file

@ -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; }

View file

@ -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>

View file

@ -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();

View file

@ -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);

View file

@ -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">
@ -444,6 +520,22 @@ function renderStatsPage() {
</div> </div>
</div> </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>
${[0,.2,.4,.6,.8,1].map(hi=>`<div class="stats-hm-leg-cell" style="--hi:${hi};"></div>`).join('')}
<span>больше</span>
</div>
</div>
</div>
<!-- 5. Тип проблемы + 11. Время жизни по типу + 15. Тип поддержки --> <!-- 5. Тип проблемы + 11. Время жизни по типу + 15. Тип поддержки -->
<div class="stats-charts-row"> <div class="stats-charts-row">
@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');} function escHtml(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
function escAttr(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');}
function wDiffBadge(diff){
if(diff===0) return '<span class="stats-wdiff stats-wdiff-flat">= 0</span>';
if(diff>0) return '<span class="stats-wdiff stats-wdiff-up">↑ +'+diff+'</span>';
return '<span class="stats-wdiff stats-wdiff-down">↓ '+diff+'</span>';
}
function showLineTip(event,el){const tip=document.getElementById('line-tooltip');if(!tip)return;tip.textContent=`${el.dataset.lbl}: ${el.dataset.val} откр.`;tip.style.display='block';const wr=document.getElementById('stats-line-chart').getBoundingClientRect();tip.style.left=Math.min(event.clientX-wr.left+12,wr.width-130)+'px';tip.style.top=Math.max(event.clientY-wr.top-36,4)+'px';} function showLineTip(event,el){const tip=document.getElementById('line-tooltip');if(!tip)return;tip.textContent=`${el.dataset.lbl}: ${el.dataset.val} откр.`;tip.style.display='block';const wr=document.getElementById('stats-line-chart').getBoundingClientRect();tip.style.left=Math.min(event.clientX-wr.left+12,wr.width-130)+'px';tip.style.top=Math.max(event.clientY-wr.top-36,4)+'px';}
function hideLineTip(){const t=document.getElementById('line-tooltip');if(t)t.style.display='none';} function hideLineTip(){const t=document.getElementById('line-tooltip');if(t)t.style.display='none';}
function showActTip(event,el){
let tip=document.getElementById('stats-act-tooltip');
if(!tip){tip=document.createElement('div');tip.id='stats-act-tooltip';tip.className='stats-act-tooltip';document.body.appendChild(tip);}
const cr=el.dataset.actCr||'0',cl=el.dataset.actCl||'0',lbl=el.dataset.actLbl||'';
tip.innerHTML=`<span style="font-weight:600;">${escHtml(lbl)}</span><br><span style="color:var(--accent);">▲ Создано: ${cr}</span> &nbsp; <span style="color:var(--green);">▼ Закрыто: ${cl}</span>`;
tip.style.display='block';
tip.style.left=(event.clientX+14)+'px';
tip.style.top=Math.max(event.clientY-48,8)+'px';
}
function hideActTip(){const t=document.getElementById('stats-act-tooltip');if(t)t.style.display='none';}
function openTaskById(id){const i=allData.findIndex(x=>x.task.id===id);if(i!==-1){closeStatsPage();setTimeout(()=>openDetail(i),100);}} function openTaskById(id){const i=allData.findIndex(x=>x.task.id===id);if(i!==-1){closeStatsPage();setTimeout(()=>openDetail(i),100);}}
function onDataLoaded(){if(statsPageOpen)renderStatsPage();} function onDataLoaded(){if(statsPageOpen)renderStatsPage();}

View file

@ -3,6 +3,7 @@ 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:'servstatus', label:'Служ. статус в интре', def:true },
{ key:'client', label:'Клиент', def:true }, { key:'client', label:'Клиент', def:true },
{ key:'date', label:'Время ответа', def:true }, { key:'date', label:'Время ответа', def:true },
{ key:'created', label:'Создана', def:true }, { key:'created', label:'Создана', def:true },
@ -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>

View file

@ -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,
}; };
} }

View file

@ -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
View 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