Compare commits
3 commits
8e1b4fc1e2
...
1a94d039c2
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a94d039c2 | |||
| 377c106ebc | |||
| 0189a4ab98 |
2 changed files with 93 additions and 34 deletions
|
|
@ -2,10 +2,12 @@
|
|||
// СТРАНИЦА СТАТИСТИКИ
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
|
||||
let statsPageOpen = false;
|
||||
let clientsExpanded = false;
|
||||
let clientsOnlyOpen = false;
|
||||
let timingPeriodIdx = 4;
|
||||
let statsPageOpen = false;
|
||||
let clientsExpanded = false;
|
||||
let clientsOnlyOpen = false;
|
||||
let timingPeriodIdx = 4;
|
||||
let clientLastTicketMode = 'created'; // 'created' | 'updated'
|
||||
|
||||
/** false = по неделям (16 точек), true = по дням (112 дней) — график «открытых заявок» */
|
||||
let openChartByDays = false;
|
||||
|
||||
|
|
@ -412,12 +414,12 @@ function renderStatsPage() {
|
|||
</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>
|
||||
<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 ${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 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">${lineLabels.map((l,i)=>i%lineLabelStep===0?`<span>${l}</span>`:'<span></span>').join('')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -483,6 +485,18 @@ function renderStatsPage() {
|
|||
<div id="stats-clients-list-wrap"></div>
|
||||
</div>
|
||||
|
||||
<!-- Последняя заявка по клиентам -->
|
||||
<div class="stats-card">
|
||||
<div class="stats-card-title" style="justify-content:space-between;">
|
||||
<span>🕐 Последняя заявка по клиентам</span>
|
||||
<div class="stats-period-tabs">
|
||||
<button class="stats-period-tab${clientLastTicketMode==='created'?' active':''}" onclick="clientLastTicketMode='created';this.parentNode.querySelectorAll('.stats-period-tab').forEach((b,i)=>b.classList.toggle('active',i===0));renderClientLastTicketCard()">Создана</button>
|
||||
<button class="stats-period-tab${clientLastTicketMode==='updated'?' active':''}" onclick="clientLastTicketMode='updated';this.parentNode.querySelectorAll('.stats-period-tab').forEach((b,i)=>b.classList.toggle('active',i===1));renderClientLastTicketCard()">Обновлена</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="stats-client-last-ticket-wrap"></div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||
<!-- НОВЫЕ КАРТОЧКИ -->
|
||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||
|
|
@ -490,16 +504,16 @@ function renderStatsPage() {
|
|||
<!-- 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">
|
||||
${Array.from({length:24},(_,h)=>`<div class="stats-hm-hlbl">${h}</div>`).join('')}
|
||||
</div>
|
||||
${HM_ORDER.map((dayIdx,i)=>{
|
||||
const row=HM[dayIdx];
|
||||
return'<div class="stats-hm-row"><div class="stats-hm-dlbl">'+HM_LABELS[i]+'</div>'+row.map((v,h)=>'<div class="stats-hm-cell" style="--hi:'+((v/hmMax)).toFixed(3)+';" title="'+HM_LABELS[i]+' '+h+':00 — '+v+' заявок"><span>'+(v||'')+'</span></div>').join('')+'</div>';
|
||||
}).join('')}
|
||||
<div class="stats-hm-legend">
|
||||
<div class="stats-card-title">🌡️ Тепловая карта активности (час × день недели)</div>
|
||||
<div class="stats-heatmap-wrap">
|
||||
<div class="stats-heatmap-hours">
|
||||
${Array.from({length:24},(_,h)=>`<div class="stats-hm-hlbl">${h}</div>`).join('')}
|
||||
</div>
|
||||
${HM_ORDER.map((dayIdx,i)=>{
|
||||
const row=HM[dayIdx];
|
||||
return'<div class="stats-hm-row"><div class="stats-hm-dlbl">'+HM_LABELS[i]+'</div>'+row.map((v,h)=>'<div class="stats-hm-cell" style="--hi:'+((v/hmMax)).toFixed(3)+';" title="'+HM_LABELS[i]+' '+h+':00 — '+v+' заявок"><span>'+(v||'')+'</span></div>').join('')+'</div>';
|
||||
}).join('')}
|
||||
<div class="stats-hm-legend">
|
||||
<span>меньше</span>
|
||||
${[0,.2,.4,.6,.8,1].map(hi=>`<div class="stats-hm-leg-cell" style="--hi:${hi};"></div>`).join('')}
|
||||
<span>больше</span>
|
||||
|
|
@ -640,6 +654,7 @@ function renderStatsPage() {
|
|||
</div>`;
|
||||
|
||||
renderClientsList();
|
||||
renderClientLastTicketCard();
|
||||
saveStatsCache();
|
||||
}
|
||||
|
||||
|
|
@ -697,13 +712,7 @@ 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;
|
||||
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('')}
|
||||
${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>
|
||||
${!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>`:''}`;
|
||||
}
|
||||
|
|
@ -756,13 +765,58 @@ function hideActTip(){const t=document.getElementById('stats-act-tooltip');if(t)
|
|||
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) {
|
||||
function goToClientTasks(clientName) {
|
||||
const filterVal = (clientName === 'Неизвестно') ? '__UNKNOWN__' : clientName;
|
||||
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);
|
||||
refreshAdvancedFilters();
|
||||
const sel = document.getElementById('filter-object-select');
|
||||
if (sel) {
|
||||
[...sel.options].forEach(opt => { opt.selected = String(opt.value) === filterVal; });
|
||||
updateAdvancedFilterLabel('filter-object-select');
|
||||
buildAdvancedFilterPanel('filter-object-select');
|
||||
}
|
||||
renderTable();
|
||||
}
|
||||
|
||||
function fmtAgo(ms) {
|
||||
const s = Math.round(ms / 1000);
|
||||
if (s < 60) return `${s} сек назад`;
|
||||
const m = Math.round(s / 60);
|
||||
if (m < 60) return `${m} мин назад`;
|
||||
const h = Math.round(m / 60);
|
||||
if (h < 24) return `${h} ч назад`;
|
||||
const d = Math.round(h / 24);
|
||||
if (d < 30) return `${d} дн назад`;
|
||||
const mo = Math.round(d / 30);
|
||||
if (mo < 12) return `${mo} мес назад`;
|
||||
return `${Math.round(mo / 12)} г назад`;
|
||||
}
|
||||
|
||||
function renderClientLastTicketCard() {
|
||||
const wrap = document.getElementById('stats-client-last-ticket-wrap');
|
||||
if (!wrap) return;
|
||||
const now = Date.now();
|
||||
const cMap = {};
|
||||
allData.forEach(({task: t}) => {
|
||||
const name = t._clientname || t.clientname || 'Неизвестно';
|
||||
const cr = t.createdat ? new Date(t.createdat).getTime() : 0;
|
||||
const upd = t.updatedat ? new Date(t.updatedat).getTime() : 0;
|
||||
if (!cMap[name]) cMap[name] = { created: 0, updated: 0 };
|
||||
if (cr > cMap[name].created) cMap[name].created = cr;
|
||||
if (upd > cMap[name].updated) cMap[name].updated = upd;
|
||||
});
|
||||
const field = clientLastTicketMode;
|
||||
const entries = Object.entries(cMap)
|
||||
.filter(([, v]) => v[field] > 0)
|
||||
.sort((a, b) => b[1][field] - a[1][field]);
|
||||
if (!entries.length) { wrap.innerHTML = '<div class="stats-empty">Нет данных</div>'; return; }
|
||||
const maxAge = now - entries[entries.length - 1][1][field];
|
||||
wrap.innerHTML = `<div class="stats-bar-chart" style="max-height:340px;overflow-y:auto;">${entries.map(([name, v]) => {
|
||||
const ts = v[field];
|
||||
const age = now - ts;
|
||||
const pct = maxAge > 0 ? Math.max((1 - age / maxAge) * 100, 2) : 100;
|
||||
const color = age < 86400000 ? '#0d9488' : age < 7 * 86400000 ? 'var(--accent)' : age < 30 * 86400000 ? '#d97706' : 'var(--red)';
|
||||
const dateStr = new Date(ts).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
return `<div class="stats-bar-row" style="cursor:pointer;" onclick="goToClientTasks('${escHtml(name).replace(/'/g,"\\'")}')"><div class="stats-bar-label" title="${escHtml(name)}">${escHtml(name)}</div><div class="stats-bar-track"><div class="stats-bar-fill" style="width:${pct}%;background:${color};"></div></div><div class="stats-bar-count" style="min-width:90px;font-size:10px;color:var(--text2);">${fmtAgo(age)}</div><div class="stats-bar-pct" style="min-width:80px;font-size:10px;color:var(--text3);">${dateStr}</div></div>`;
|
||||
}).join('')}</div>`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -280,6 +280,7 @@ function refreshAdvancedFilters(){
|
|||
}
|
||||
const objectName=String(task._clientname||task.clientname||task.organization||'').trim();
|
||||
if(objectName) objectMap.set(objectName, objectName);
|
||||
else objectMap.set('__UNKNOWN__', 'Неизвестно');
|
||||
const intraId=String(task.statusid||task.status||'').trim();
|
||||
const intraName=String(task._statusname||task.statusname||intraId).trim();
|
||||
if(intraId) intraMap.set(intraId, intraName||intraId);
|
||||
|
|
@ -688,7 +689,11 @@ function renderTable(keepPage){
|
|||
if(selectedExecutors.size&& !selectedExecutors.has(String(item.task.executor||''))) return false;
|
||||
if(selectedObjects.size){
|
||||
const objectName=String(item.task._clientname||item.task.clientname||item.task.organization||'').trim();
|
||||
if(!objectName || !selectedObjects.has(objectName)) return false;
|
||||
if(!objectName){
|
||||
if(!selectedObjects.has('__UNKNOWN__')) return false;
|
||||
} else {
|
||||
if(!selectedObjects.has(objectName)) return false;
|
||||
}
|
||||
}
|
||||
if(selectedIntra.size){
|
||||
const intraId=String(item.task.statusid||item.task.status||'').trim();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue