Compare commits
8 commits
2467cc5fce
...
25506d2b6a
| Author | SHA1 | Date | |
|---|---|---|---|
| 25506d2b6a | |||
| ae4698718e | |||
| bcd1c40783 | |||
| de69bddcaf | |||
| 77d3ac94ad | |||
| 45773f19e8 | |||
| 232a6b4238 | |||
| fc2b7f762a |
12 changed files with 645 additions and 40 deletions
33
CHANGELOG.md
Normal file
33
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# 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 */
|
||||
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.isp-open .isp-dropdown {
|
||||
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-note { font-size: 11px; color: var(--text3); margin-top: 8px; }
|
||||
|
||||
/* ── Month heatmap (12 cells) ───────────────────────────────── */
|
||||
.stats-month-hm {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.stats-month-hm-cell {
|
||||
flex: 1;
|
||||
min-width: 40px;
|
||||
height: 56px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
cursor: default;
|
||||
transition: transform .12s;
|
||||
background: color-mix(in srgb, var(--accent) calc(var(--hi) * 100%), var(--surface2));
|
||||
}
|
||||
.stats-month-hm-cell[style*="--hi:0.000"] { background: var(--surface2); }
|
||||
.stats-month-hm-cell:hover { transform: scale(1.08); z-index: 5; position: relative; }
|
||||
.stats-month-hm-val {
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
color: rgba(255,255,255,0);
|
||||
line-height: 1;
|
||||
transition: color .12s;
|
||||
}
|
||||
.stats-month-hm-cell:hover .stats-month-hm-val { color: white; }
|
||||
.stats-month-hm-lbl {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--text2);
|
||||
}
|
||||
html.dark .stats-month-hm-cell:hover .stats-month-hm-val { color: rgba(255,255,255,.95); }
|
||||
|
||||
/* ── Heatmap ─────────────────────────────────────────────────── */
|
||||
.stats-heatmap-wrap { display:flex; flex-direction:column; gap:2px; overflow-x:auto; }
|
||||
.stats-heatmap-hours { display:flex; align-items:center; gap:2px; padding-left:34px; }
|
||||
|
|
@ -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));
|
||||
color: transparent; /* hide number by default, show on hover via title */
|
||||
}
|
||||
.stats-hm-cell[style*="--hi:0"] { background: var(--surface2); }
|
||||
.stats-hm-cell[style*="--hi:0.000"] { background: var(--surface2); }
|
||||
.stats-hm-cell:hover { transform: scale(1.35); z-index: 5; position: relative; }
|
||||
/* show number only when there's activity */
|
||||
.stats-hm-cell span { pointer-events: none; color: rgba(255,255,255,.0); }
|
||||
|
|
@ -1036,6 +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);
|
||||
}
|
||||
|
||||
/* ── 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) ────────────────────────────────────── */
|
||||
.stats-bar-chart { display:flex; flex-direction:column; gap:9px; }
|
||||
.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-list { display:flex; flex-direction:column; gap:7px; }
|
||||
.stats-clients-row { display:flex; align-items:center; gap:10px; }
|
||||
.stats-clients-row-clickable { cursor:pointer; border-radius:6px; transition:background .13s; padding:3px 4px; margin:-3px -4px; }
|
||||
.stats-clients-row-clickable:hover { background:var(--surface2); }
|
||||
.stats-clients-name {
|
||||
font-size:12px; color:var(--text2);
|
||||
width:200px; flex-shrink:0;
|
||||
|
|
@ -1430,6 +1577,25 @@ html.dark .stats-card { background:var(--surface); }
|
|||
color:var(--text3);
|
||||
font-size:12px;
|
||||
}
|
||||
.adv-filter-search-wrap{
|
||||
padding:4px 2px 6px;
|
||||
margin-bottom:4px;
|
||||
border-bottom:1px solid var(--border);
|
||||
}
|
||||
.adv-filter-search{
|
||||
width:100%;
|
||||
box-sizing:border-box;
|
||||
border:1px solid var(--border);
|
||||
border-radius:6px;
|
||||
background:var(--surface2);
|
||||
color:var(--text);
|
||||
font-family:inherit;
|
||||
font-size:12px;
|
||||
padding:5px 9px;
|
||||
outline:none;
|
||||
transition:border-color .15s;
|
||||
}
|
||||
.adv-filter-search:focus{border-color:var(--accent);}
|
||||
|
||||
/* ── Chip Dropdown ────────────────────────────────────── */
|
||||
.chip-dropdown-wrap { position: relative; flex-shrink: 0; }
|
||||
|
|
|
|||
|
|
@ -76,6 +76,12 @@
|
|||
Sync Done with Intra by status_id
|
||||
</label>
|
||||
</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-field" style="flex:1;">
|
||||
<label>Done Intra IDs (CSV)</label>
|
||||
|
|
@ -217,7 +223,7 @@
|
|||
</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-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>
|
||||
|
|
|
|||
|
|
@ -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-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-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('apikey-status')) f('apikey-status').textContent = s.api_key_set ? '(ключ задан ✓)' : '(не задан!)';
|
||||
syncDoneSelectFromInput();
|
||||
|
|
@ -341,6 +342,7 @@ async function saveSettings() {
|
|||
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_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() || '',
|
||||
};
|
||||
const apiKey = document.getElementById('api-key').value.trim();
|
||||
|
|
|
|||
|
|
@ -84,6 +84,17 @@ function startSSE() {
|
|||
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=>{
|
||||
const d=JSON.parse(e.data);
|
||||
const incomingVersion=Number(d.cache_version||0);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ let statsPageOpen = false;
|
|||
let clientsExpanded = false;
|
||||
let clientsOnlyOpen = false;
|
||||
let timingPeriodIdx = 4;
|
||||
/** false = по неделям (16 точек), true = по дням (112 дней) — график «открытых заявок» */
|
||||
let openChartByDays = false;
|
||||
|
||||
const STATS_CACHE_KEY = 'intradesk_stats_html_v2';
|
||||
function saveStatsCache() {
|
||||
|
|
@ -93,9 +95,44 @@ function renderStatsPage() {
|
|||
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);
|
||||
}
|
||||
const maxOpen = Math.max(...wOpen,1);
|
||||
const maxOpenWeekly = Math.max(...wOpen,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 = [
|
||||
{label:'< 1 часа', max:60, color:'#0d9488'},
|
||||
|
|
@ -129,7 +166,7 @@ function renderStatsPage() {
|
|||
data.forEach(item=>{const d=(now-new Date(item.task.createdat||item.task.updatedat))/86400000;for(const b of AB){if(d<=b.max){b.count++;break;}}});
|
||||
const maxAB=Math.max(...AB.map(b=>b.count),1);
|
||||
|
||||
// Клиенты
|
||||
// Клиенты: «открытые» = одна логика (открыта / в работе / новая / назначена — как isTaskOpen)
|
||||
const cMap = {};
|
||||
data.forEach(({task:t})=>{
|
||||
const n=t._clientname||t.clientname||'Неизвестно';
|
||||
|
|
@ -165,6 +202,15 @@ function renderStatsPage() {
|
|||
const HM_ORDER = [1,2,3,4,5,6,0];
|
||||
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. Без исполнителя
|
||||
const noExec = data.filter(({task:t}) => !t.executor).length;
|
||||
const noExecPct = total ? Math.round(noExec/total*100) : 0;
|
||||
|
|
@ -355,16 +401,44 @@ function renderStatsPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Открытые на конец недели -->
|
||||
<!-- Открытых заявок (недели / дни) -->
|
||||
<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-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>
|
||||
${(()=>{ 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>
|
||||
<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>
|
||||
|
||||
|
|
@ -376,7 +450,7 @@ function renderStatsPage() {
|
|||
<span class="stats-legend-inline"><span style="background:var(--green);"></span> закрыто</span>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
|
|
@ -413,8 +487,9 @@ function renderStatsPage() {
|
|||
<!-- НОВЫЕ КАРТОЧКИ -->
|
||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||
|
||||
<!-- 1. Тепловая карта активности -->
|
||||
<div class="stats-card">
|
||||
<!-- 1. Тепловые карты -->
|
||||
<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-heatmap-wrap">
|
||||
<div class="stats-heatmap-hours">
|
||||
|
|
@ -431,6 +506,22 @@ function renderStatsPage() {
|
|||
</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. Тип поддержки -->
|
||||
<div class="stats-charts-row">
|
||||
|
|
@ -606,7 +697,13 @@ function renderClientsList() {
|
|||
if(visible.length===0){wrap.innerHTML=`<div class="stats-empty">${clientsOnlyOpen?'Нет клиентов с открытыми заявками':'Нет повторных обращений'}</div>`;return;}
|
||||
wrap.innerHTML=`
|
||||
<div class="stats-clients-list" style="display:flex;flex-direction:column;gap:6px;">
|
||||
${visible.map(([name,v])=>{ const dv=clientsOnlyOpen?v.open:v.count,pct=dv/maxV*100,opPct=v.count?v.open/v.count*100:0; return'<div class="stats-clients-row"><div class="stats-clients-name" title="'+escHtml(name)+'">'+escHtml(name)+'</div><div class="stats-clients-track"><div class="stats-clients-bar-total" style="width:'+pct+'%;"></div><div class="stats-clients-bar-open" style="width:'+pct*opPct/100+'%;"></div></div><div class="stats-clients-counts"><span class="stats-clients-total">'+v.count+'</span>'+(v.open?'<span class="stats-clients-open-badge">'+v.open+' откр.</span>':'')+'</div></div>'; }).join('')}
|
||||
${visible.map(([name,v])=>{
|
||||
const dv=clientsOnlyOpen?v.open:v.count;
|
||||
const pct=dv/maxV*100;
|
||||
const opPct=clientsOnlyOpen?100:(v.count?v.open/v.count*100:0);
|
||||
const badge=v.open?'<span class="stats-clients-open-badge">'+v.open+' откр.</span>':'';
|
||||
return'<div class="stats-clients-row stats-clients-row-clickable" title="Открыть фильтр по клиенту" data-client="'+escAttr(name)+'" onclick="filterByClient(this.dataset.client)"><div class="stats-clients-name" title="'+escHtml(name)+'">'+escHtml(name)+'</div><div class="stats-clients-track"><div class="stats-clients-bar-total" style="width:'+pct+'%;"></div><div class="stats-clients-bar-open" style="width:'+pct*opPct/100+'%;"></div></div><div class="stats-clients-counts"><span class="stats-clients-total">'+v.count+'</span>'+badge+'</div></div>';
|
||||
}).join('')}
|
||||
</div>
|
||||
${!clientsExpanded&&hidden>0?`<div style="text-align:center;margin-top:10px;font-size:12px;color:var(--text3);">+ ещё ${hidden} — <span style="color:var(--accent);cursor:pointer;" onclick="clientsExpanded=true;document.getElementById('clients-expand-btn').textContent='▲ Свернуть';renderClientsList()">показать все</span></div>`:''}`;
|
||||
}
|
||||
|
|
@ -634,9 +731,38 @@ function donutLegend(sc,uc,tot){
|
|||
}
|
||||
|
||||
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 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 onDataLoaded(){if(statsPageOpen)renderStatsPage();}
|
||||
|
||||
function filterByClient(name) {
|
||||
closeStatsPage();
|
||||
setTimeout(() => {
|
||||
const sel = document.getElementById('filter-object-select');
|
||||
if (!sel) return;
|
||||
[...sel.options].forEach(o => { o.selected = String(o.value) === String(name); });
|
||||
if (typeof updateAdvancedFilterLabel === 'function') updateAdvancedFilterLabel('filter-object-select');
|
||||
if (typeof renderTable === 'function') renderTable();
|
||||
}, 150);
|
||||
}
|
||||
|
|
|
|||
105
public/js/ui.js
105
public/js/ui.js
|
|
@ -3,6 +3,7 @@ const ALL_COLS = [
|
|||
{ key:'num', label:'# Заявка', def:true },
|
||||
{ key:'title', label:'Тема', def:true },
|
||||
{ key:'intrastatus', label:'Статус в интре', def:true },
|
||||
{ key:'servstatus', label:'Служ. статус в интре', def:true },
|
||||
{ key:'client', label:'Клиент', def:true },
|
||||
{ key:'date', label:'Время ответа', def:true },
|
||||
{ key:'created', label:'Создана', def:true },
|
||||
|
|
@ -37,6 +38,7 @@ const ADV_FILTER_CONFIG = {
|
|||
labelId: 'filter-object-label',
|
||||
allLabel: 'Объекты: все',
|
||||
shortLabel: 'Объекты',
|
||||
hasSearch: true,
|
||||
},
|
||||
'filter-intra-select': {
|
||||
panelId: 'filter-intra-panel',
|
||||
|
|
@ -144,6 +146,28 @@ function buildAdvancedFilterPanel(selectId){
|
|||
return;
|
||||
}
|
||||
|
||||
if (cfg.hasSearch) {
|
||||
const searchWrap = document.createElement('div');
|
||||
searchWrap.className = 'adv-filter-search-wrap';
|
||||
const searchInput = document.createElement('input');
|
||||
searchInput.type = 'text';
|
||||
searchInput.className = 'adv-filter-search';
|
||||
searchInput.placeholder = 'Поиск…';
|
||||
searchInput.setAttribute('autocomplete', 'off');
|
||||
searchInput.addEventListener('input', () => {
|
||||
const q = searchInput.value.toLowerCase();
|
||||
panel.querySelectorAll('.adv-filter-item').forEach(item => {
|
||||
const text = item.querySelector('.adv-filter-text')?.textContent || '';
|
||||
item.style.display = text.toLowerCase().includes(q) ? '' : 'none';
|
||||
});
|
||||
});
|
||||
searchInput.addEventListener('click', e => e.stopPropagation());
|
||||
searchWrap.appendChild(searchInput);
|
||||
panel.appendChild(searchWrap);
|
||||
// Focus after render
|
||||
requestAnimationFrame(() => searchInput.focus());
|
||||
}
|
||||
|
||||
const selectedCount=opts.filter((o)=>o.selected).length;
|
||||
const actions=document.createElement('div');
|
||||
actions.className='adv-filter-actions';
|
||||
|
|
@ -454,6 +478,47 @@ function intraStatusBadge(task) {
|
|||
else if (lc.includes('ожидан') || lc.includes('hold')) cls = 'sb-intra-hold';
|
||||
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
|
||||
function whoAnsweredRing(lc){
|
||||
if(!lc) return 'ring-none';
|
||||
|
|
@ -497,15 +562,45 @@ function statusPickerHtml(tid){
|
|||
}
|
||||
function ispShow(el){
|
||||
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();
|
||||
dd.style.top=(r.bottom)+'px';
|
||||
dd.style.left=r.left+'px';
|
||||
inner.style.maxHeight='';
|
||||
inner.style.overflowY='';
|
||||
dd.classList.remove('isp-drop-up');
|
||||
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){
|
||||
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 ─────────────────────────────────────────────────
|
||||
|
|
@ -704,6 +799,7 @@ function renderTable(keepPage){
|
|||
${sc('num') ?'<th style="width:70px;">#</th>' :''}
|
||||
${sc('title') ?'<th>Тема</th>' :''}
|
||||
${sc('intrastatus')?'<th style="width:110px;">Статус в интре</th>':''}
|
||||
${sc('servstatus') ?'<th style="width:130px;">Служ. статус в интре</th>':''}
|
||||
${sc('status') ?'<th style="width:130px;">Статус</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>` :''}
|
||||
|
|
@ -742,6 +838,7 @@ function renderTable(keepPage){
|
|||
${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('intrastatus')?`<td>${intraStatusBadge(task)}</td>`:''}
|
||||
${sc('servstatus') ?`<td onclick="event.stopPropagation()">${intraAddfieldPickerHtml(task)}</td>`:''}
|
||||
${sc('status') ?`<td onclick="event.stopPropagation()">${statusPickerHtml(tid)}</td>`:''}
|
||||
${sc('client') ?`<td><div class="client-info">
|
||||
<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 INTRA_ADDFIELD_ALIAS = 'addfield_2f6eaa2vnutrennij_statusSingleSelect';
|
||||
|
||||
function toBoolFlag(value) {
|
||||
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(?,?,?,?,?)
|
||||
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() {
|
||||
|
|
@ -205,6 +210,7 @@ function createRefreshService({ db, fetch, getSettings, sseBroadcast, logger = c
|
|||
auto_reset_on_update: toBoolFlag(s.auto_reset_on_update),
|
||||
sync_reply_wait: toBoolFlag(s.sync_reply_wait),
|
||||
sync_done_with_intra: toBoolFlag(s.sync_done_with_intra),
|
||||
sync_addfield_status: toBoolFlag(s.sync_addfield_status),
|
||||
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 = '') {
|
||||
const changedIds = [...(changedTaskIds || [])].map((v) => String(v));
|
||||
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 doneKey = systemStatuses.done || null;
|
||||
|
|
@ -326,6 +351,18 @@ function createRefreshService({ db, fetch, getSettings, sseBroadcast, logger = c
|
|||
|
||||
if (!applied && settings.auto_reset_on_update && currentMark) {
|
||||
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];
|
||||
const statusKey = task.statusid || task.status;
|
||||
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);
|
||||
|
|
@ -741,6 +780,7 @@ function createRefreshService({ db, fetch, getSettings, sseBroadcast, logger = c
|
|||
requestRemoveTask,
|
||||
parseWebhookAction,
|
||||
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,
|
||||
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 });
|
||||
});
|
||||
|
||||
// ── 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 ─────────────────────────────────────────────────────────
|
||||
app.get('/api/notes', auth, (req, res) => {
|
||||
const r = {};
|
||||
|
|
@ -904,6 +956,7 @@ const ALLOWED_SETTINGS = new Set([
|
|||
'sync_reply_wait',
|
||||
'sync_done_with_intra',
|
||||
'intra_done_status_ids',
|
||||
'sync_addfield_status',
|
||||
]);
|
||||
|
||||
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_done_with_intra: s.sync_done_with_intra || '0',
|
||||
intra_done_status_ids: s.intra_done_status_ids || '',
|
||||
sync_addfield_status: s.sync_addfield_status || '0',
|
||||
api_key_set: !!s.api_key,
|
||||
webhook_secret_set: !!s.webhook_secret,
|
||||
});
|
||||
|
|
@ -935,7 +989,7 @@ app.post('/api/settings', auth, adminOnly, (req, res) => {
|
|||
setSetting(k, normalizeCsvIds(v));
|
||||
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');
|
||||
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