diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7ffa49a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,34 @@ +# Changelog + +## 2026-03-23 + +--- + +### ИнтраDesk: служебный статус и синхронизация + +- Колонка **«Служ. статус в интре»** — значение доп. поля "Внутренний статус" в интре; цвет по совпадению label с внутренними статусами. Имена жестко привязаны в интре +- Интерактивный пикер: смена значения в IntraDesk напрямую, пункт «— очистить». +- **Исходящая синхронизация**: при смене внутреннего статуса; при снятии статуса — очистка поля. +- **Входящая синхронизация**: при обновлении задач — выставление внутреннего статуса по label доп. поля. + +### Таблица заявок, фильтры, UI + +- Поиск в выпадающем фильтре **«Объекты»** (поле при открытии панели, автофокус). +- Кнопка переключения **тёмной темы** скрыта +- Выпадающие списки **внутреннего статуса** и **служебного статуса в интре**: при нехватке места снизу список открывается **вверх**; + +### Страница «Статистика» + +- **Открытых заявок**: боковая панель «сейчас / неделю или день назад / начало месяца» с бейджами отклонения; панель **справа от графика**, по высоте выровнена с графиком. +- **Открытых заявок**: Переключатель **«По неделям» / «По дням»** +- **Тепловая карта**: «час × день недели» пофиксил раскрашивание +- **Тепловая карта**: добавлена «активность по месяцам» +- **Создано / Закрыто по неделям**: при наведении — подсветка столбиков и **метка** с числами создано/закрыто над колонкой. +- **Клиенты**: клик по строке — переход на заявки с фильтром **«Объекты»** по этому клиенту +- **Повторные клиенты**: пофиксил окрашивание строк + +### Локальный запуск (для отладки) + +- `**docker-compose.local.yml`** — сборка из исходников, данные в `./data`. +- `**start-local.bat**` — запуск локального Docker под Windows. + diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 0000000..611a241 --- /dev/null +++ b/docker-compose.local.yml @@ -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 diff --git a/intradesk-monitor.zip b/intradesk-monitor.zip deleted file mode 100644 index 50cfa9f..0000000 Binary files a/intradesk-monitor.zip and /dev/null differ diff --git a/public/css/app.css b/public/css/app.css index 9ba5f0b..0044808 100644 --- a/public/css/app.css +++ b/public/css/app.css @@ -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; } diff --git a/public/index.html b/public/index.html index 0765aba..8a69f34 100644 --- a/public/index.html +++ b/public/index.html @@ -76,6 +76,12 @@ Sync Done with Intra by status_id +
+ +
@@ -217,7 +223,7 @@ - diff --git a/public/js/auth.js b/public/js/auth.js index 37c271f..5d75cec 100644 --- a/public/js/auth.js +++ b/public/js/auth.js @@ -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(); diff --git a/public/js/sse.js b/public/js/sse.js index e6416cb..0943c7f 100644 --- a/public/js/sse.js +++ b/public/js/sse.js @@ -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); diff --git a/public/js/statistics.js b/public/js/statistics.js index 50d325c..0d0be32 100644 --- a/public/js/statistics.js +++ b/public/js/statistics.js @@ -8,6 +8,9 @@ let clientsOnlyOpen = false; let timingPeriodIdx = 4; let clientLastTicketMode = 'created'; // 'created' | 'updated' +/** false = по неделям (16 точек), true = по дням (112 дней) — график «открытых заявок» */ +let openChartByDays = false; + const STATS_CACHE_KEY = 'intradesk_stats_html_v2'; function saveStatsCache() { 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{const c=new Date(t.resolutiondatefact||t.closedat||0);return c>=wStart&&c= 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'}, @@ -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;}}}); const maxAB=Math.max(...AB.map(b=>b.count),1); - // Клиенты + // Клиенты: «открытые» = одна логика (открыта / в работе / новая / назначена — как isTaskOpen) const cMap = {}; data.forEach(({task:t})=>{ const n=t._clientname||t.clientname||'Неизвестно'; @@ -166,6 +204,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; @@ -356,16 +403,44 @@ function renderStatsPage() {
- +
-
📈 Открытых заявок на конец недели
+
+ 📈 Открытых заявок ${openChartByDays ? 'по дням' : 'на конец недели'} +
+ + +
+
+
+
- + - ${(()=>{ 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''+pts.map(([x,y],i)=>'').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''+pts.map(([x,y],i)=>'').join(''); })()} -
${wLabels.map((l,i)=>i%2===0?`${l}`:'').join('')}
+
${lineLabels.map((l,i)=>i%lineLabelStep===0?`${l}`:'').join('')}
+
+
+
+
+
${lineOpen[lineN-1]}
+
сейчас
+
+
+
+
${lineN>=2?lineOpen[lineN-2]:'—'}
+
${openChartByDays?'день назад':'неделю назад'}
+ ${lineN>=2?wDiffBadge(lineOpen[lineN-1]-lineOpen[lineN-2]):''} +
+
+
+
${openAtMonthStart}
+
нач. месяца
+ ${wDiffBadge(lineOpen[lineN-1]-openAtMonthStart)} +
+
@@ -377,7 +452,7 @@ function renderStatsPage() { закрыто
- ${wCreated.map((cr,i)=>{ const cl=wClosed[i],hCr=Math.round(cr/maxDual*100),hCl=Math.round(cl/maxDual*100); return'
'+wLabels[i]+'
'; }).join('')} + ${wCreated.map((cr,i)=>{ const cl=wClosed[i],hCr=Math.round(cr/maxDual*100),hCl=Math.round(cl/maxDual*100); return'
'+(cr||cl?''+cr+'/'+cl+'':'')+'
'+wLabels[i]+'
'; }).join('')}
@@ -426,8 +501,9 @@ function renderStatsPage() { - -
+ +
+
🌡️ Тепловая карта активности (час × день недели)
@@ -438,6 +514,22 @@ function renderStatsPage() { return'
'+HM_LABELS[i]+'
'+row.map((v,h)=>'
'+(v||'')+'
').join('')+'
'; }).join('')}
+ меньше + ${[0,.2,.4,.6,.8,1].map(hi=>`
`).join('')} + больше +
+
+
+
+
🗓️ Активность по месяцам
+
+ ${MONTH_NAMES.map((ml,mi)=>{ + const v=MONTH_COUNTS[mi]; + const hi=(v/monthCountMax).toFixed(3); + return'
'+v+''+ml+'
'; + }).join('')} +
+
меньше ${[0,.2,.4,.6,.8,1].map(hi=>`
`).join('')} больше @@ -620,7 +712,7 @@ function renderClientsList() { if(visible.length===0){wrap.innerHTML=`
${clientsOnlyOpen?'Нет клиентов с открытыми заявками':'Нет повторных обращений'}
`;return;} wrap.innerHTML=`
- ${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'
'+escHtml(name)+'
'+v.count+''+(v.open?''+v.open+' откр.':'')+'
'; }).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'
'+escHtml(name)+'
'+v.count+''+(v.open?''+v.open+' откр.':'')+'
'; }).join('')}
${!clientsExpanded&&hidden>0?`
+ ещё ${hidden} — показать все
`:''}`; } @@ -648,10 +740,28 @@ function donutLegend(sc,uc,tot){ } function escHtml(s){return String(s).replace(/&/g,'&').replace(//g,'>');} +function escAttr(s){return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,''');} + +function wDiffBadge(diff){ + if(diff===0) return '= 0'; + if(diff>0) return '↑ +'+diff+''; + return '↓ '+diff+''; +} 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=`${escHtml(lbl)}
▲ Создано: ${cr}   ▼ Закрыто: ${cl}`; + 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();} diff --git a/public/js/ui.js b/public/js/ui.js index 0a4af68..be82eb0 100644 --- a/public/js/ui.js +++ b/public/js/ui.js @@ -1,13 +1,14 @@ // ── Column visibility ───────────────────────────────────────────── const ALL_COLS = [ - { key:'num', label:'# Заявка', def:true }, - { key:'title', label:'Тема', def:true }, - { key:'intrastatus', label:'Статус в интре', def:true }, - { key:'client', label:'Клиент', def:true }, - { key:'date', label:'Время ответа', def:true }, - { key:'created', label:'Создана', def:true }, - { key:'preview', label:'Превью/Заметка', def:true }, - { key:'status', label:'Статус', def:true }, + { 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 }, + { key:'preview', label:'Превью/Заметка', def:true }, + { key:'status', label:'Статус', def:true }, ]; let visibleCols = new Set(ALL_COLS.filter(c=>c.def).map(c=>c.key)); let colMenuOpen = false; @@ -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'; @@ -455,6 +479,47 @@ function intraStatusBadge(task) { else if (lc.includes('ожидан') || lc.includes('hold')) cls = 'sb-intra-hold'; return `${esc(name)}`; } +// ── IntraDesk additional field — badge helper ───────────────────── +function _addfieldBadgeHtml(val) { + if (!val) return ''; + const match = (typeof MARKS_CONFIG !== 'undefined' ? MARKS_CONFIG : []) + .find((m) => m.label && m.label.toLowerCase() === val.toLowerCase()); + if (match) { + return `` + + `${esc(val)}`; + } + return `${esc(val)}`; +} + +// ── 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) => ` +
+ ${esc(m.label)} +
`).join(''); + const clearItem = `
+ — очистить +
`; + return `
+ ${_addfieldBadgeHtml(val)} +
+
${items}${clearItem}
+
+
`; +} + +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'; @@ -498,15 +563,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(topspaceBelow){ + 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 ───────────────────────────────────────────────── @@ -709,6 +804,7 @@ function renderTable(keepPage){ ${sc('num') ?'#' :''} ${sc('title') ?'Тема' :''} ${sc('intrastatus')?'Статус в интре':''} + ${sc('servstatus') ?'Служ. статус в интре':''} ${sc('status') ?'Статус' :''} ${sc('client') ?'Кто ответил' :''} ${sc('date') ?`Время ответа${sortArrow('date')}` :''} @@ -747,6 +843,7 @@ function renderTable(keepPage){ ${sc('num') ?`#${num}${unreadDot}`:''} ${sc('title') ?`
${esc(task.name)}
${orgName?`
${esc(orgName)}
`:''}`:''} ${sc('intrastatus')?`${intraStatusBadge(task)}`:''} + ${sc('servstatus') ?`${intraAddfieldPickerHtml(task)}`:''} ${sc('status') ?`${statusPickerHtml(tid)}`:''} ${sc('client') ?`
${avatarInitials(authorName)}
diff --git a/refresh-service.js b/refresh-service.js index 25e2bb2..4ac9c4d 100644 --- a/refresh-service.js +++ b/refresh-service.js @@ -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, }; } diff --git a/server.js b/server.js index 45f900c..1249f49 100644 --- a/server.js +++ b/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; } diff --git a/start-local.bat b/start-local.bat new file mode 100644 index 0000000..b938cc6 --- /dev/null +++ b/start-local.bat @@ -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