intra_monitor/public/js/statistics.js
dv b88001909f
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
фикс статистики 3
2026-04-15 10:40:38 +03:00

889 lines
59 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ══════════════════════════════════════════════════════════════════
// СТРАНИЦА СТАТИСТИКИ
// ══════════════════════════════════════════════════════════════════
let statsPageOpen = false;
let clientsExpanded = false;
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){}
}
function tryLoadStatsCache() {
try {
const d = JSON.parse(sessionStorage.getItem(STATS_CACHE_KEY)||'null');
if (d && Date.now()-d.ts < 30*60*1000 && d.html) {
document.getElementById('stats-content').innerHTML = d.html;
return true;
}
} catch(e){}
return false;
}
function openStatsPage() {
statsPageOpen = true;
document.getElementById('stats-page').style.display = 'flex';
document.getElementById('nav-btn-tasks')?.classList.remove('nav-tab-active');
document.getElementById('nav-btn-stats')?.classList.add('nav-tab-active');
if (allData.length === 0) { tryLoadStatsCache(); return; }
renderStatsPage();
}
function closeStatsPage() {
statsPageOpen = false;
document.getElementById('stats-page').style.display = 'none';
document.getElementById('nav-btn-stats')?.classList.remove('nav-tab-active');
document.getElementById('nav-btn-tasks')?.classList.add('nav-tab-active');
}
function toggleStatsPage() { statsPageOpen ? closeStatsPage() : openStatsPage(); }
function renderStatsPage() {
if (!statsPageOpen) return;
const data = allData;
const now = Date.now();
const total = data.length;
const openCount = data.filter(i => isTaskOpen(i)).length;
const doneCount = data.filter(i => isTaskDone(i)).length;
// Метки
const sc = {}, scOpen = {};
MARKS_CONFIG.forEach(m => { sc[m.key] = 0; scOpen[m.key] = 0; });
let uc = 0, ucOpen = 0;
data.forEach(item => {
const m = MARKS_CONFIG.find(m => marksCache[String(item.task.id)]?.[m.key]?.active);
if (m) sc[m.key]++; else uc++;
if (isTaskOpen(item)) { if (m) scOpen[m.key]++; else ucOpen++; }
});
// Тайминги
const PERIODS = [
{ label:'30 дней', days:30 },
{ label:'90 дней', days:90 },
{ label:'180 дней', days:180 },
{ label:'1 год', days:365 },
{ label:'Всё время',days:Infinity },
];
const periodRows = PERIODS.map(p => {
const cutoff = p.days === Infinity ? 0 : now - p.days*86400000;
const reaction = [], resolution = [];
data.forEach(({ task: t }) => {
if (new Date(t.createdat).getTime() < cutoff) return;
if (t.reactiondatefact && t.createdat) {
const m = (new Date(t.reactiondatefact)-new Date(t.createdat))/60000;
if (m>0 && m<43200) reaction.push(m);
}
if (t.resolutiondatefact && t.createdat) {
const m = (new Date(t.resolutiondatefact)-new Date(t.createdat))/60000;
if (m>0) resolution.push(m);
}
});
return { ...p, reaction, resolution };
});
const AP = periodRows[timingPeriodIdx];
// 16 недель
const WEEKS = 16;
const wLabels = [], wOpen = [], wCreated = [], wClosed = [];
for (let i = WEEKS-1; i>=0; i--) {
const wEnd = new Date(now - i *7*86400000);
const wStart = new Date(now-(i+1)*7*86400000);
wLabels.push(wStart.toLocaleDateString('ru-RU',{day:'numeric',month:'short'}));
wOpen.push(data.filter(({task:t})=>{ const cr=new Date(t.createdat); if(cr>=wEnd)return false; if(!isTaskDone({task:t}))return true; return new Date(t.resolutiondatefact||t.closedat||0)>wEnd; }).length);
wCreated.push(data.filter(({task:t})=>{const c=new Date(t.createdat);return c>=wStart&&c<wEnd;}).length);
wClosed.push(data.filter(({task:t})=>{const c=new Date(t.resolutiondatefact||t.closedat||0);return c>=wStart&&c<wEnd&&isTaskDone({task:t});}).length);
}
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'},
{label:'< 4 часов',max:240, color:'#0284c7'},
{label:'< 1 дня', max:1440, color:'var(--accent)'},
{label:'< 3 дней', max:4320, color:'#d97706'},
{label:'< недели', max:10080, color:'var(--orange)'},
{label:'< месяца', max:43200, color:'var(--red)'},
{label:'≥ месяца', max:Infinity,color:'#820014'},
];
FB.forEach(b=>b.count=0); let fTotal=0;
data.forEach(({task:t})=>{
if(t.resolutiondatefact&&t.createdat){
const m=(new Date(t.resolutiondatefact)-new Date(t.createdat))/60000;
if(m>0){fTotal++;for(const b of FB){if(m<=b.max){b.count++;break;}}}
}
});
const maxF=Math.max(...FB.map(b=>b.count),1);
// Возраст
const AB = [
{label:'До недели', max:7, color:'#0d9488'},
{label:'До месяца', max:30, color:'var(--accent)'},
{label:'2 месяца', max:61, color:'#0284c7'},
{label:'3 месяца', max:92, color:'#d97706'},
{label:'6 месяцев', max:183, color:'var(--orange)'},
{label:'12 месяцев',max:365, color:'var(--red)'},
{label:'> 12 мес.', max:Infinity,color:'#820014'},
];
AB.forEach(b=>b.count=0);
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||'Неизвестно';
if(!cMap[n])cMap[n]={count:0,open:0};
cMap[n].count++;
if(isTaskOpen({task:t}))cMap[n].open++;
});
window._clientMap = cMap;
// Исполнители
const EXEC_KEYS=['высоких','абрамов','железняков','коваленко'];
const eMap={};
data.forEach(({task:t})=>{
if(!t.executor)return;
const raw=t._executorname||`#${t.executor}`;
if(!EXEC_KEYS.some(k=>raw.toLowerCase().includes(k)))return;
if(!eMap[raw])eMap[raw]={open:0,done:0};
if(isTaskDone({task:t}))eMap[raw].done++;else eMap[raw].open++;
});
const eList=Object.entries(eMap).sort((a,b)=>(b[1].open+b[1].done)-(a[1].open+a[1].done));
const maxE=eList[0]?eList[0][1].open+eList[0][1].done:1;
// ── НОВЫЕ ВЫЧИСЛЕНИЯ ────────────────────────────────────────────
// 1. Тепловая карта (час x день недели)
const HM = Array.from({length:7}, () => new Array(24).fill(0));
data.forEach(({task:t}) => {
if (!t.createdat) return;
const d = new Date(t.createdat);
HM[d.getDay()][d.getHours()]++;
});
const hmMax = Math.max(...HM.flat(), 1);
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;
// 5 + 11. Тип проблемы + время жизни по типу
const PROB_CATS = [
{re:/шлагбаум/, label:'Шлагбаумы', color:'#0284c7', count:0, times:[]},
{re:/принтер|билет|талон/, label:'Принтер / Билеты', color:'#0d9488', count:0, times:[]},
{re:/камер|распознав|грз/, label:'Камеры / Распознавание', color:'var(--accent)', count:0, times:[]},
{re:/оплат|qr|банк|терминал/, label:'Оплата / QR', color:'#d97706', count:0, times:[]},
{re:/тариф|настройк/, label:'Тарифы / Настройки', color:'#7c3aed', count:0, times:[]},
{re:/сет|связь|сервер/, label:'Сеть / Связь', color:'#db2777', count:0, times:[]},
];
let probOther = 0;
const probOtherTimes = [];
data.forEach(({task:t}) => {
const txt = ((t.name||'')+(t.description||'')).toLowerCase();
let hit = false;
for (const c of PROB_CATS) {
if (c.re.test(txt)) {
c.count++;
if (t.resolutiondatefact && t.createdat) {
const m = (new Date(t.resolutiondatefact)-new Date(t.createdat))/60000;
if (m>0) c.times.push(m);
}
hit = true; break;
}
}
if (!hit) {
probOther++;
if (t.resolutiondatefact && t.createdat) {
const m = (new Date(t.resolutiondatefact)-new Date(t.createdat))/60000;
if (m>0) probOtherTimes.push(m);
}
}
});
const allCats = [...PROB_CATS, {label:'Прочее', color:'var(--border2)', count:probOther, times:probOtherTimes}];
const maxCat = Math.max(...allCats.map(c=>c.count), 1);
const maxCatMed = Math.max(...allCats.map(c=>med(c.times)||0), 1);
// 8. Эмоциональная оценка
const emoMap = {};
data.forEach(({task:t}) => {
if (t.aiemotionalassessment) emoMap[t.aiemotionalassessment] = (emoMap[t.aiemotionalassessment]||0)+1;
});
const emoList = Object.entries(emoMap).sort((a,b)=>b[1]-a[1]);
const emoTotal = emoList.reduce((s,[,v])=>s+v, 0);
const maxEmo = Math.max(...emoList.map(([,v])=>v), 1);
const EMO_COLORS = {'Отлично':'#0d9488','Хорошо':'#0284c7','Нормально':'#d97706','Плохо':'var(--red)','Ужасно':'#820014'};
// 9. Конверсия в оценку
const evalCount = data.filter(({task:t}) => t.evaluation && t.evaluation>0).length;
const evalPct = doneCount ? Math.round(evalCount/doneCount*100) : 0;
const evalSum = data.reduce((s,{task:t}) => s+(t.evaluation&&t.evaluation>0?t.evaluation:0), 0);
const evalAvg = evalCount ? (evalSum/evalCount).toFixed(1) : null;
// 12. Закрытые в тот же день
let sameDayClose=0, sameDayBase=0;
data.forEach(({task:t}) => {
const cl = t.resolutiondatefact||t.closedat;
if (!cl) return;
sameDayBase++;
const ms = new Date(cl)-new Date(t.createdat);
if (ms>0 && ms<86400000) sameDayClose++;
});
const sameDayPct = sameDayBase ? Math.round(sameDayClose/sameDayBase*100) : 0;
// 15. Удалённая vs Выездная
const svcRemote = data.filter(({task:t}) => t.service===84683).length;
const svcField = data.filter(({task:t}) => t.service===84682).length;
const svcOther = total - svcRemote - svcField;
const svcMax = Math.max(svcRemote, svcField, svcOther, 1);
// 17. Просроченные дедлайны
const expiredTotal = data.filter(({task:t}) => t.isresolutionexpired===true).length;
const expiredPct = total ? Math.round(expiredTotal/total*100) : 0;
const EXP_WEEKS = 12;
const expWLabels=[], expWCounts=[], expWBase=[];
for (let i=EXP_WEEKS-1; i>=0; i--) {
const wEnd = new Date(now - i *7*86400000);
const wStart = new Date(now-(i+1)*7*86400000);
expWLabels.push(wStart.toLocaleDateString('ru-RU',{day:'numeric',month:'short'}));
const inWk = data.filter(({task:t})=>{const c=new Date(t.createdat);return c>=wStart&&c<wEnd;});
expWBase.push(inWk.length);
expWCounts.push(inWk.filter(({task:t})=>t.isresolutionexpired===true).length);
}
const maxExpW = Math.max(...expWCounts, 1);
// 18. Приоритетные клиенты (любой ненулевой priority)
const prioMap = {};
data.forEach(({task:t}) => {
if (!t.priority) return;
const n = t._clientname||t.clientname||'Неизвестно';
if (!prioMap[n]) prioMap[n]={count:0,open:0};
prioMap[n].count++;
if (isTaskOpen({task:t})) prioMap[n].open++;
});
const prioList = Object.entries(prioMap).sort((a,b)=>b[1].count-a[1].count).slice(0,10);
const maxPrio = Math.max(...prioList.map(([,v])=>v.count), 1);
// 19. Нерабочее время
let nightCount=0, weekendCount=0;
data.forEach(({task:t}) => {
if (!t.createdat) return;
const d=new Date(t.createdat), h=d.getHours(), day=d.getDay();
if (h>=22||h<7) nightCount++;
if (day===0||day===6) weekendCount++;
});
const nightPct = total ? Math.round(nightCount/total*100) : 0;
const weekendPct = total ? Math.round(weekendCount/total*100) : 0;
// 20. Вложения
const ATT_B = [
{label:'0', max:0, count:0, color:'var(--surface2)'},
{label:'1', max:1, count:0, color:'#0d9488'},
{label:'23', max:3, count:0, color:'var(--accent)'},
{label:'46', max:6, count:0, color:'#d97706'},
{label:'7+', max:Infinity, count:0, color:'var(--red)'},
];
data.forEach(({task:t}) => {
let cnt=0;
try { cnt=(t.attachments?JSON.parse(t.attachments):[]).length; } catch(e){}
for (const b of ATT_B) { if (cnt<=b.max) { b.count++; break; } }
});
const maxAtt = Math.max(...ATT_B.map(b=>b.count), 1);
// 3. Время первого ответа по исполнителю
const execRxMap = {};
data.forEach(({task:t}) => {
if (!t.reactiondatefact||!t.createdat||!t.executor) return;
const m=(new Date(t.reactiondatefact)-new Date(t.createdat))/60000;
if (m<=0||m>43200) return;
const name = t._executorname||`#${t.executor}`;
if (!execRxMap[name]) execRxMap[name]=[];
execRxMap[name].push(m);
});
const execRxList = Object.entries(execRxMap)
.filter(([,v])=>v.length>=1)
.map(([name,times])=>({name, count:times.length, med:med(times)}))
.sort((a,b)=>a.med-b.med)
.slice(0,8);
const maxRxMed = Math.max(...execRxList.map(e=>e.med), 1);
// 7. Повторные клиенты
const repeatClients = Object.values(cMap).filter(v=>v.count>1).length;
const totalClients = Object.keys(cMap).length;
const repeatPct = totalClients ? Math.round(repeatClients/totalClients*100) : 0;
// ── HTML ────────────────────────────────────────────────────────
document.getElementById('stats-content').innerHTML = `
<div class="stats-inner">
<!-- KPI -->
<div class="stats-kpi-row">
${kpi('blue', iconGrid(), total, 'Всего заявок')}
${kpi('accent',iconOpen(), openCount, 'Открытых')}
${kpi('green', iconDone(), doneCount, 'Выполненных')}
${kpi('orange',`<svg width="24" height="24" fill="none" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.8"/><path d="M12 7v5l3 3" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>`,
`${noExecPct}%`, 'Без исполнителя')}
${kpi('red',`<svg width="24" height="24" fill="none" viewBox="0 0 24 24"><path d="M12 8v4l3 3" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.8"/></svg>`,
`${expiredPct}%`, 'Просрочено')}
${kpi('teal',`<svg width="24" height="24" fill="none" viewBox="0 0 24 24"><path d="M5 12l5 5L20 7" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
`${sameDayPct}%`, 'Закрыто в день')}
${kpi('indigo',`<svg width="24" height="24" fill="none" viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" stroke="currentColor" stroke-width="1.8"/><circle cx="9" cy="7" r="4" stroke="currentColor" stroke-width="1.8"/><path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>`,
`${repeatPct}%`, 'Повторных клиентов')}
</div>
<!-- Два доната + Тайминги -->
<div class="stats-charts-row">
<div class="stats-card" style="flex:1;min-width:240px;">
<div class="stats-card-title">Метки — все заявки</div>
<div class="stats-donut-wrap">${renderDonut(sc,uc,total)}</div>
<div class="stats-legend">${donutLegend(sc,uc,total)}</div>
</div>
<div class="stats-card" style="flex:1;min-width:240px;">
<div class="stats-card-title">Метки — только открытые</div>
<div class="stats-donut-wrap">${renderDonut(scOpen,ucOpen,openCount)}</div>
<div class="stats-legend">${donutLegend(scOpen,ucOpen,openCount)}</div>
</div>
<div class="stats-card" style="flex:2;min-width:300px;">
<div class="stats-card-title" style="justify-content:space-between;">
<span>⏱ Время реагирования и закрытия</span>
<div class="stats-period-tabs">
${PERIODS.map((p,i)=>`<button class="stats-period-tab${i===timingPeriodIdx?' active':''}" onclick="timingPeriodIdx=${i};renderTimingCard()">${p.label}</button>`).join('')}
</div>
</div>
<div id="stats-timing-body">${renderTimingBody(AP)}</div>
</div>
</div>
<!-- Открытых заявок (недели / дни) -->
<div class="stats-card">
<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 ${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 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">${lineLabels.map((l,i)=>i%lineLabelStep===0?`<span>${l}</span>`:'<span></span>').join('')}</div>
</div>
</div>
<div class="stats-open-compare">
<div class="soc-item">
<div class="soc-val">${lineOpen[lineN-1]}</div>
<div class="soc-label">сейчас</div>
</div>
<div class="soc-divider"></div>
<div class="soc-item">
<div class="soc-val">${lineN>=2?lineOpen[lineN-2]:'—'}</div>
<div class="soc-label">${openChartByDays?'день назад':'неделю назад'}</div>
${lineN>=2?wDiffBadge(lineOpen[lineN-1]-lineOpen[lineN-2]):''}
</div>
<div class="soc-divider"></div>
<div class="soc-item">
<div class="soc-val">${openAtMonthStart}</div>
<div class="soc-label">нач. месяца</div>
${wDiffBadge(lineOpen[lineN-1]-openAtMonthStart)}
</div>
</div>
</div>
</div>
<!-- Создано / Закрыто -->
<div class="stats-card">
<div class="stats-card-title">
📊 Создано / Закрыто по неделям
<span class="stats-legend-inline"><span style="background:var(--accent);"></span> создано</span>
<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;overflow:visible;"><div class="stats-act-hover-label">'+(cr||cl?'<span class="sahl-cr">'+cr+'</span><span class="sahl-sep">/</span><span class="sahl-cl">'+cl+'</span>':'')+'</div><div class="stats-activity-bar" style="height:'+Math.max(hCr,cr?3:0)+'%;position:absolute;bottom:0;left:2%;width:44%;"></div><div class="stats-activity-bar stats-activity-bar-green" style="height:'+Math.max(hCl,cl?3:0)+'%;position:absolute;bottom:0;right:2%;width:44%;opacity:.8;"></div></div><div class="stats-activity-label">'+wLabels[i]+'</div></div>'; }).join('')}
</div>
</div>
<!-- Исполнители + Воронка + Возраст -->
<div class="stats-charts-row">
<div class="stats-card" style="flex:1;min-width:220px;">
<div class="stats-card-title">👤 Нагрузка исполнителей</div>
${eList.length===0?`<div class="stats-empty">Нет данных</div>`:`<div class="stats-bar-chart">${eList.map(([name,v])=>{ const tot=v.open+v.done,pct=tot/maxE*100,opPct=tot?v.open/tot*100:0; return'<div class="stats-bar-row"><div class="stats-bar-label" title="'+escHtml(name)+'">'+escHtml(name)+'</div><div class="stats-bar-track" style="position:relative;"><div class="stats-exec-bar-done" style="width:'+pct+'%;"></div><div class="stats-exec-bar-open" style="width:'+pct*opPct/100+'%;"></div></div><div style="display:flex;flex-direction:column;align-items:flex-end;gap:1px;flex-shrink:0;min-width:72px;"><span style="font-size:11px;font-weight:700;color:var(--accent);">'+v.open+' откр.</span><span style="font-size:10px;color:var(--text3);">'+v.done+' завер.</span></div></div>'; }).join('')}<div class="stats-exec-legend"><span class="stats-exec-leg-item"><span class="stats-exec-leg-dot" style="background:var(--accent);"></span>Открытые</span><span class="stats-exec-leg-item"><span class="stats-exec-leg-dot" style="background:var(--green);opacity:.45;"></span>Завершённые</span></div></div>`}
</div>
<div class="stats-card" style="flex:1;min-width:200px;">
<div class="stats-card-title">⏳ Воронка времени жизни</div>
${fTotal===0?`<div class="stats-empty">Нет закрытых заявок</div>`:`<div class="stats-bar-chart">${FB.map(b=>{const pct=b.count/maxF*100,share=fTotal?Math.round(b.count/fTotal*100):0;return'<div class="stats-bar-row"><div class="stats-bar-label">'+b.label+'</div><div class="stats-bar-track"><div class="stats-bar-fill" style="width:'+Math.max(pct,b.count?2:0)+'%;background:'+b.color+';"></div></div><div class="stats-bar-count">'+b.count+'</div><div class="stats-bar-pct">'+share+'%</div></div>';}).join('')}</div>`}
</div>
<div class="stats-card" style="flex:1;min-width:200px;">
<div class="stats-card-title">📅 Возраст заявок</div>
<div class="stats-bar-chart">${AB.map(b=>{const share=total?Math.round(b.count/total*100):0;return'<div class="stats-bar-row"><div class="stats-bar-label">'+b.label+'</div><div class="stats-bar-track"><div class="stats-bar-fill" style="width:'+Math.max(b.count/maxAB*100,b.count?2:0)+'%;background:'+b.color+';"></div></div><div class="stats-bar-count">'+b.count+'</div><div class="stats-bar-pct">'+share+'%</div></div>';}).join('')}</div>
</div>
</div>
<!-- Клиенты -->
<div class="stats-card">
<div class="stats-clients-header">
<span class="stats-card-title" style="margin:0;flex:1;">🔁 Клиенты</span>
<label class="stats-clients-check-wrap">
<input type="checkbox" id="clients-only-open" ${clientsOnlyOpen?'checked':''} onchange="clientsOnlyOpen=this.checked;renderClientsList()">
Только с открытыми
</label>
<button class="stats-clients-toggle" id="clients-expand-btn" onclick="clientsExpanded=!clientsExpanded;this.textContent=clientsExpanded?'▲ Свернуть':'▼ Все клиенты';renderClientsList()">${clientsExpanded?'▲ Свернуть':'▼ Все клиенты'}</button>
</div>
<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>
<!-- Заявки по клиентам + разбивка по пользователям -->
<div class="stats-card">
<div class="stats-card-title">👥 Заявки по клиентам (все, включая с нулём)</div>
<div id="stats-client-users-wrap"></div>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- НОВЫЕ КАРТОЧКИ -->
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- 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">
<span>меньше</span>
${[0,.2,.4,.6,.8,1].map(hi=>`<div class="stats-hm-leg-cell" style="--hi:${hi};"></div>`).join('')}
<span>больше</span>
</div>
</div>
</div>
<div class="stats-card" style="flex:1;min-width:220px;">
<div class="stats-card-title">🗓 Активность по месяцам</div>
<div class="stats-month-hm">
${MONTH_NAMES.map((ml,mi)=>{
const v=MONTH_COUNTS[mi];
const hi=(v/monthCountMax).toFixed(3);
return'<div class="stats-month-hm-cell" style="--hi:'+hi+';" title="'+ml+' — '+v+' заявок"><span class="stats-month-hm-val">'+v+'</span><span class="stats-month-hm-lbl">'+ml+'</span></div>';
}).join('')}
</div>
<div class="stats-hm-legend" style="margin-top:12px;">
<span>меньше</span>
${[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">
<div class="stats-card" style="flex:1;min-width:220px;">
<div class="stats-card-title">🏷 Тип проблемы</div>
<div class="stats-bar-chart">
${allCats.map(c=>{ const pct=c.count/maxCat*100, share=total?Math.round(c.count/total*100):0; return'<div class="stats-bar-row"><div class="stats-bar-label">'+c.label+'</div><div class="stats-bar-track"><div class="stats-bar-fill" style="width:'+Math.max(pct,c.count?2:0)+'%;background:'+c.color+';"></div></div><div class="stats-bar-count">'+c.count+'</div><div class="stats-bar-pct">'+share+'%</div></div>'; }).join('')}
</div>
</div>
<div class="stats-card" style="flex:1;min-width:220px;">
<div class="stats-card-title"> Время закрытия по типу (медиана)</div>
${allCats.filter(c=>c.times&&c.times.length>0).length===0
?`<div class="stats-empty">Нет закрытых заявок</div>`
:`<div class="stats-bar-chart">${allCats.map(c=>{ const m=med(c.times); if(!m)return''; const pct=m/maxCatMed*100; return'<div class="stats-bar-row"><div class="stats-bar-label">'+c.label+'</div><div class="stats-bar-track"><div class="stats-bar-fill" style="width:'+Math.max(pct,2)+'%;background:'+c.color+';"></div></div><div class="stats-bar-count" style="min-width:52px;font-size:10px;">'+fmtDur(m)+'</div></div>'; }).join('')}</div><div class="stats-timing-note">Медиана по закрытым заявкам каждого типа</div>`}
</div>
<div class="stats-card" style="flex:1;min-width:200px;">
<div class="stats-card-title">🔧 Тип поддержки</div>
<div class="stats-bar-chart">
<div class="stats-bar-row"><div class="stats-bar-label">Удалённая</div><div class="stats-bar-track"><div class="stats-bar-fill" style="width:${Math.max(svcRemote/svcMax*100,svcRemote?2:0)}%;background:#0284c7;"></div></div><div class="stats-bar-count">${svcRemote}</div><div class="stats-bar-pct">${total?Math.round(svcRemote/total*100):0}%</div></div>
<div class="stats-bar-row"><div class="stats-bar-label">Выездная</div><div class="stats-bar-track"><div class="stats-bar-fill" style="width:${Math.max(svcField/svcMax*100,svcField?2:0)}%;background:#d97706;"></div></div><div class="stats-bar-count">${svcField}</div><div class="stats-bar-pct">${total?Math.round(svcField/total*100):0}%</div></div>
${svcOther>0?`<div class="stats-bar-row"><div class="stats-bar-label">Прочие</div><div class="stats-bar-track"><div class="stats-bar-fill" style="width:${Math.max(svcOther/svcMax*100,2)}%;background:var(--border2);"></div></div><div class="stats-bar-count">${svcOther}</div><div class="stats-bar-pct">${total?Math.round(svcOther/total*100):0}%</div></div>`:''}
</div>
<div class="stats-timing-note">84683 удалённая · 84682 выездная</div>
</div>
</div>
<!-- 8. Эмоции + 9. Оценки + 20. Вложения -->
<div class="stats-charts-row">
<div class="stats-card" style="flex:1;min-width:220px;">
<div class="stats-card-title">😊 Эмоциональная оценка ИИ</div>
${emoList.length===0
?`<div class="stats-empty">Нет данных aiemotionalassessment</div>`
:`<div class="stats-bar-chart">${emoList.map(([label,cnt])=>{ const color=EMO_COLORS[label]||'var(--accent)'; const pct=cnt/maxEmo*100, share=emoTotal?Math.round(cnt/emoTotal*100):0; return'<div class="stats-bar-row"><div class="stats-bar-label">'+escHtml(label)+'</div><div class="stats-bar-track"><div class="stats-bar-fill" style="width:'+Math.max(pct,cnt?2:0)+'%;background:'+color+';"></div></div><div class="stats-bar-count">'+cnt+'</div><div class="stats-bar-pct">'+share+'%</div></div>'; }).join('')}</div><div class="stats-timing-note">${emoTotal} из ${total} заявок имеют оценку</div>`}
</div>
<div class="stats-card" style="flex:1;min-width:200px;">
<div class="stats-card-title"> Оценки от клиентов (evaluation)</div>
<div style="display:flex;flex-direction:column;gap:16px;margin-top:6px;">
<div>
<div style="font-size:11px;color:var(--text3);margin-bottom:6px;">Конверсия закрытых оценка</div>
<div style="display:flex;align-items:center;gap:10px;">
<div style="flex:1;height:10px;background:var(--surface2);border-radius:5px;overflow:hidden;">
<div style="width:${evalPct}%;height:100%;background:#d97706;border-radius:5px;transition:width .5s;"></div>
</div>
<div style="font-size:16px;font-weight:800;color:#d97706;min-width:44px;text-align:right;">${evalPct}%</div>
</div>
<div style="font-size:11px;color:var(--text3);margin-top:4px;">${evalCount} из ${doneCount} закрытых</div>
</div>
${evalAvg?`<div><div style="font-size:11px;color:var(--text3);margin-bottom:4px;">Средний балл</div><div style="font-size:32px;font-weight:800;color:#d97706;line-height:1;">${evalAvg}</div><div style="font-size:11px;color:var(--text3);">по ${evalCount} оценкам</div></div>`:`<div class="stats-empty" style="padding:8px 0;">Оценок пока нет</div>`}
</div>
</div>
<div class="stats-card" style="flex:1;min-width:200px;">
<div class="stats-card-title">📎 Вложения на заявку</div>
<div class="stats-bar-chart">
${ATT_B.map(b=>{ const pct=b.count/maxAtt*100, share=total?Math.round(b.count/total*100):0; return'<div class="stats-bar-row"><div class="stats-bar-label">'+b.label+'</div><div class="stats-bar-track"><div class="stats-bar-fill" style="width:'+Math.max(pct,b.count?2:0)+'%;background:'+b.color+';"></div></div><div class="stats-bar-count">'+b.count+'</div><div class="stats-bar-pct">'+share+'%</div></div>'; }).join('')}
</div>
<div class="stats-timing-note">Косвенный показатель сложности заявки</div>
</div>
</div>
<!-- 3. Время первого ответа по исполнителю + 17. Просроченные дедлайны -->
<div class="stats-charts-row">
<div class="stats-card" style="flex:1;min-width:220px;">
<div class="stats-card-title"> Время первого ответа по исполнителю</div>
${execRxList.length===0
?`<div class="stats-empty">Нет данных по реакции</div>`
:`<div class="stats-bar-chart">${execRxList.map(e=>{ const pct=e.med/maxRxMed*100; return'<div class="stats-bar-row"><div class="stats-bar-label" title="'+escHtml(e.name)+'">'+escHtml(e.name)+'</div><div class="stats-bar-track"><div class="stats-bar-fill" style="width:'+Math.max(pct,2)+'%;background:var(--accent);"></div></div><div class="stats-bar-count" style="min-width:52px;font-size:10px;">'+fmtDur(e.med)+'</div><div class="stats-bar-pct" style="font-size:10px;">'+e.count+'шт</div></div>'; }).join('')}</div><div class="stats-timing-note">Медиана времени реакции · сортировка от быстрых к медленным</div>`}
</div>
<div class="stats-card" style="flex:2;min-width:280px;">
<div class="stats-card-title" style="justify-content:space-between;">
🚨 Просроченные дедлайны по неделям
<span style="font-size:12px;font-weight:700;color:var(--red);">${expiredTotal} всего (${expiredPct}%)</span>
</div>
<div class="stats-activity-chart">
${expWCounts.map((cnt,i)=>{ const base=expWBase[i]||0, pct=base?Math.round(cnt/base*100):0; const h=Math.max(cnt/maxExpW*100,cnt?4:0); const color=pct>30?'var(--red)':pct>15?'var(--orange)':'var(--accent)'; return'<div class="stats-activity-col"><div class="stats-activity-bar-wrap"><div class="stats-activity-bar" style="height:'+h+'%;position:absolute;bottom:0;left:10%;width:80%;background:'+color+';"></div></div><div class="stats-activity-count" style="font-size:9px;color:'+(cnt?'var(--text2)':'transparent')+' ;">'+cnt+'</div><div class="stats-activity-label">'+expWLabels[i]+'</div></div>'; }).join('')}
</div>
<div class="stats-timing-note">isresolutionexpired = true · по дате создания заявки</div>
</div>
</div>
<!-- 18. Приоритетные клиенты + 19. Нерабочее время -->
<div class="stats-charts-row">
<div class="stats-card" style="flex:1;min-width:220px;">
<div class="stats-card-title">🔥 Клиенты с приоритетными заявками</div>
${prioList.length===0
?`<div class="stats-empty">Нет заявок с полем priority</div>`
:`<div class="stats-bar-chart">${prioList.map(([name,v])=>{ const pct=v.count/maxPrio*100, opPct=v.count?v.open/v.count*100:0; return'<div class="stats-bar-row"><div class="stats-bar-label" title="'+escHtml(name)+'">'+escHtml(name)+'</div><div class="stats-bar-track" style="position:relative;"><div style="position:absolute;left:0;top:0;height:100%;width:'+pct+'%;background:color-mix(in srgb,var(--red) 25%,var(--surface2));border-radius:5px;"></div><div style="position:absolute;left:0;top:0;height:100%;width:'+(pct*opPct/100)+'%;background:var(--red);border-radius:5px;transition:width .5s;"></div></div><div class="stats-bar-count">'+v.count+'</div>'+(v.open?'<span style="font-size:10px;color:var(--red);background:color-mix(in srgb,var(--red) 12%,transparent);padding:1px 5px;border-radius:8px;flex-shrink:0;">'+v.open+' откр.</span>':'')+'</div>'; }).join('')}</div><div class="stats-timing-note">Заявки с ненулевым полем priority</div>`}
</div>
<div class="stats-card" style="flex:1;min-width:220px;">
<div class="stats-card-title">🌙 Активность в нерабочее время</div>
<div style="display:flex;flex-direction:column;gap:18px;margin-top:8px;">
<div>
<div style="font-size:11px;color:var(--text3);margin-bottom:6px;">Ночные заявки (22:00 07:00)</div>
<div style="display:flex;align-items:center;gap:10px;">
<div style="flex:1;height:10px;background:var(--surface2);border-radius:5px;overflow:hidden;">
<div style="width:${nightPct}%;height:100%;background:#7c3aed;border-radius:5px;transition:width .5s;"></div>
</div>
<div style="font-size:16px;font-weight:800;color:#7c3aed;min-width:44px;text-align:right;">${nightPct}%</div>
</div>
<div style="font-size:11px;color:var(--text3);margin-top:3px;">${nightCount} заявок из ${total}</div>
</div>
<div>
<div style="font-size:11px;color:var(--text3);margin-bottom:6px;">Выходные дни (сб / вс)</div>
<div style="display:flex;align-items:center;gap:10px;">
<div style="flex:1;height:10px;background:var(--surface2);border-radius:5px;overflow:hidden;">
<div style="width:${weekendPct}%;height:100%;background:#0284c7;border-radius:5px;transition:width .5s;"></div>
</div>
<div style="font-size:16px;font-weight:800;color:#0284c7;min-width:44px;text-align:right;">${weekendPct}%</div>
</div>
<div style="font-size:11px;color:var(--text3);margin-top:3px;">${weekendCount} заявок из ${total}</div>
</div>
</div>
<div class="stats-timing-note">Аргумент для обоснования дежурства / доп. тарификации</div>
</div>
</div>
</div>`;
renderClientsList();
renderClientLastTicketCard();
renderClientUsersCard();
saveStatsCache();
}
// Тайминги — body
function renderTimingBody(p) {
if (!p.reaction.length && !p.resolution.length)
return `<div class="stats-empty">Нет данных за этот период</div>`;
return `
<div class="stats-timing-rows">
<div class="stats-timing-row-label">⚡ Время реакции</div>
<div class="stats-timing-grid" style="margin-bottom:10px;">
<div class="stats-timing-cell"><div class="stats-timing-val">${fmtDur(avg(p.reaction))}</div><div class="stats-timing-lbl">Среднее</div></div>
<div class="stats-timing-cell"><div class="stats-timing-val" style="color:#0d9488;">${fmtDur(med(p.reaction))}</div><div class="stats-timing-lbl">Медиана</div></div>
<div class="stats-timing-cell stats-timing-muted"><div class="stats-timing-val">${p.reaction.length}</div><div class="stats-timing-lbl">Заявок</div></div>
</div>
<div class="stats-timing-row-label">🏁 Время до закрытия</div>
<div class="stats-timing-grid">
<div class="stats-timing-cell"><div class="stats-timing-val">${fmtDur(avg(p.resolution))}</div><div class="stats-timing-lbl">Среднее</div></div>
<div class="stats-timing-cell"><div class="stats-timing-val" style="color:#0d9488;">${fmtDur(med(p.resolution))}</div><div class="stats-timing-lbl">Медиана</div></div>
<div class="stats-timing-cell stats-timing-muted"><div class="stats-timing-val">${p.resolution.length}</div><div class="stats-timing-lbl">Закрыто</div></div>
</div>
</div>
<div class="stats-timing-note">По полям «Дата реакции» и «Дата решения» · период: ${p.label}</div>`;
}
function renderTimingCard() {
const PERIODS=[{days:30},{days:90},{days:180},{days:365},{days:Infinity}];
const PLABELS=['30 дней','90 дней','180 дней','1 год','Всё время'];
const p_days = PERIODS[timingPeriodIdx].days;
const now = Date.now();
const cutoff = p_days===Infinity ? 0 : now - p_days*86400000;
const reaction=[], resolution=[];
allData.forEach(({task:t})=>{
if(new Date(t.createdat).getTime()<cutoff)return;
if(t.reactiondatefact&&t.createdat){const m=(new Date(t.reactiondatefact)-new Date(t.createdat))/60000;if(m>0&&m<43200)reaction.push(m);}
if(t.resolutiondatefact&&t.createdat){const m=(new Date(t.resolutiondatefact)-new Date(t.createdat))/60000;if(m>0)resolution.push(m);}
});
const AP={label:PLABELS[timingPeriodIdx],reaction,resolution};
document.getElementById('stats-timing-body').innerHTML = renderTimingBody(AP);
document.querySelectorAll('.stats-period-tab').forEach((b,i)=>{
b.classList.toggle('active', i===timingPeriodIdx);
});
}
function renderClientsList() {
const wrap = document.getElementById('stats-clients-list-wrap');
if (!wrap || !window._clientMap) return;
let entries = Object.entries(window._clientMap);
if (clientsOnlyOpen) entries=entries.filter(([,v])=>v.open>0).sort((a,b)=>b[1].open-a[1].open);
else entries=entries.filter(([,v])=>v.count>1).sort((a,b)=>b[1].count-a[1].count);
const showN=clientsExpanded?entries.length:Math.min(12,entries.length);
const visible=entries.slice(0,showN);
const hidden=entries.length-showN;
const maxV=visible[0]?(clientsOnlyOpen?visible[0][1].open:visible[0][1].count):1;
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;
const safeName=escHtml(name).replace(/'/g,'&#39;');
const openBadge=v.open
?`<span class="stats-clients-open-badge" style="min-width:52px;text-align:center;">${v.open} откр.</span>`
:`<span style="min-width:52px;display:inline-block;"></span>`;
return`<div class="stats-clients-row" style="cursor:pointer;" onclick="goToClientTasks('${safeName}')" title="Показать заявки: ${safeName}"><div class="stats-clients-name" title="${safeName}">${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" style="min-width:110px;justify-content:flex-end;"><span class="stats-clients-total" style="min-width:24px;text-align:right;">${v.count}</span>${openBadge}</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>`:''}`;
}
// Helpers
function avg(a){return a.length?a.reduce((x,y)=>x+y,0)/a.length:null;}
function med(a){if(!a.length)return null;const s=[...a].sort((x,y)=>x-y),m=Math.floor(s.length/2);return s.length%2===0?(s[m-1]+s[m])/2:s[m];}
function fmtDur(m){if(m===null)return'—';if(m<60)return`${Math.round(m)} мин`;if(m<1440)return`${(m/60).toFixed(1)} ч`;return`${(m/1440).toFixed(1)} дн`;}
function kpi(c,icon,val,lbl){return`<div class="stats-kpi-card stats-kpi-${c}"><div class="stats-kpi-icon">${icon}</div><div class="stats-kpi-val">${val}</div><div class="stats-kpi-lbl">${lbl}</div></div>`;}
function iconGrid(){return`<svg width="24" height="24" fill="none" viewBox="0 0 24 24"><rect x="3" y="3" width="8" height="8" rx="1.5" fill="currentColor"/><rect x="13" y="3" width="8" height="8" rx="1.5" fill="currentColor"/><rect x="3" y="13" width="8" height="8" rx="1.5" fill="currentColor"/><rect x="13" y="13" width="8" height="8" rx="1.5" fill="currentColor" opacity=".45"/></svg>`;}
function iconOpen(){return`<svg width="24" height="24" fill="none" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.8"/><path d="M8 12h8M12 8v8" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>`;}
function iconDone(){return`<svg width="24" height="24" fill="none" viewBox="0 0 24 24"><path d="M5 12l5 5L20 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`;}
function renderDonut(sc,uc,tot){
if(!tot)return'<div class="stats-empty">Нет данных</div>';
const segs=[...MARKS_CONFIG.map(m=>({color:m.ispColor,val:sc[m.key]||0})),{color:'var(--border2)',val:uc}].filter(s=>s.val>0);
const r=54,cx=70,cy=70,sw=22,circ=2*Math.PI*r;let off=0;
const paths=segs.map(s=>{const dash=s.val/tot*circ,gap=circ-dash,el=`<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${s.color}" stroke-width="${sw}" stroke-dasharray="${dash.toFixed(2)} ${gap.toFixed(2)}" stroke-dashoffset="${(-off).toFixed(2)}" style="transition:stroke-dasharray .5s;"/>`;off+=dash;return el;});
return`<svg viewBox="0 0 140 140" class="stats-donut-svg"><circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="var(--surface2)" stroke-width="${sw}"/>${paths.join('')}<text x="${cx}" y="${cy-6}" text-anchor="middle" font-size="20" font-weight="700" fill="var(--text)">${tot}</text><text x="${cx}" y="${cy+12}" text-anchor="middle" font-size="10" fill="var(--text3)">заявок</text></svg>`;
}
function donutLegend(sc,uc,tot){
return MARKS_CONFIG.map(m=>{const c=sc[m.key]||0,p=tot?Math.round(c/tot*100):0;return`<div class="stats-legend-item"><span class="stats-legend-dot" style="background:${m.ispColor};"></span><span class="stats-legend-lbl">${m.chipLabel}</span><span class="stats-legend-val">${c}</span><span class="stats-legend-pct">${p}%</span></div>`;}).join('')+
`<div class="stats-legend-item"><span class="stats-legend-dot" style="background:var(--border2);"></span><span class="stats-legend-lbl">Без статуса</span><span class="stats-legend-val">${uc}</span><span class="stats-legend-pct">${tot?Math.round(uc/tot*100):0}%</span></div>`;
}
function escHtml(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
function escAttr(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');}
function wDiffBadge(diff){
if(diff===0) return '<span class="stats-wdiff stats-wdiff-flat">= 0</span>';
if(diff>0) return '<span class="stats-wdiff stats-wdiff-up">↑ +'+diff+'</span>';
return '<span class="stats-wdiff stats-wdiff-down">↓ '+diff+'</span>';
}
function showLineTip(event,el){const tip=document.getElementById('line-tooltip');if(!tip)return;tip.textContent=`${el.dataset.lbl}: ${el.dataset.val} откр.`;tip.style.display='block';const wr=document.getElementById('stats-line-chart').getBoundingClientRect();tip.style.left=Math.min(event.clientX-wr.left+12,wr.width-130)+'px';tip.style.top=Math.max(event.clientY-wr.top-36,4)+'px';}
function hideLineTip(){const t=document.getElementById('line-tooltip');if(t)t.style.display='none';}
function showActTip(event,el){
let tip=document.getElementById('stats-act-tooltip');
if(!tip){tip=document.createElement('div');tip.id='stats-act-tooltip';tip.className='stats-act-tooltip';document.body.appendChild(tip);}
const cr=el.dataset.actCr||'0',cl=el.dataset.actCl||'0',lbl=el.dataset.actLbl||'';
tip.innerHTML=`<span style="font-weight:600;">${escHtml(lbl)}</span><br><span style="color:var(--accent);">▲ Создано: ${cr}</span> &nbsp; <span style="color:var(--green);">▼ Закрыто: ${cl}</span>`;
tip.style.display='block';
tip.style.left=(event.clientX+14)+'px';
tip.style.top=Math.max(event.clientY-48,8)+'px';
}
function hideActTip(){const t=document.getElementById('stats-act-tooltip');if(t)t.style.display='none';}
function openTaskById(id){const i=allData.findIndex(x=>x.task.id===id);if(i!==-1){closeStatsPage();setTimeout(()=>openDetail(i),100);}}
function onDataLoaded(){if(statsPageOpen)renderStatsPage();}
function goToClientTasks(clientName) {
const filterVal = (clientName === 'Неизвестно') ? '__UNKNOWN__' : clientName;
closeStatsPage();
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>`;
}
function renderClientUsersCard() {
const wrap = document.getElementById('stats-client-users-wrap');
if (!wrap) return;
// createdby — строка с именем создателя, приходит прямо из API
// Собираем: организация → { total, users: { createdby → count } }
const cMap = {};
allData.forEach((item) => {
const t = item.task;
const org = t._clientname || t.clientname || 'Неизвестно';
const creator = t.createdby || '—';
if (!cMap[org]) cMap[org] = { total: 0, users: {} };
cMap[org].total++;
cMap[org].users[creator] = (cMap[org].users[creator] || 0) + 1;
});
const entries = Object.entries(cMap).sort((a, b) => b[1].total - a[1].total);
if (!entries.length) { wrap.innerHTML = '<div class="stats-empty">Нет данных</div>'; return; }
const maxTotal = entries[0][1].total || 1;
wrap.innerHTML = `<div style="display:flex;flex-direction:column;gap:10px;max-height:500px;overflow-y:auto;">${entries.map(([name, v]) => {
const pct = v.total / maxTotal * 100;
const safeName = escHtml(name).replace(/'/g, '&#39;');
const sortedUsers = Object.entries(v.users).sort((a, b) => b[1] - a[1]);
const knownUsers = sortedUsers.filter(([u]) => u !== '—');
const unknownCount = v.users['—'] || 0;
const usersList = knownUsers.map(([uname, cnt]) => {
const uPct = v.total ? cnt / v.total * 100 : 0;
return `<div style="display:flex;align-items:center;gap:6px;margin-top:3px;padding-left:4px;">
<div style="width:10px;height:10px;border-radius:50%;background:${avatarColor(uname)};flex-shrink:0;"></div>
<span style="font-size:11px;color:var(--text2);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escHtml(uname)}">${escHtml(uname)}</span>
<span style="font-size:11px;font-weight:600;color:var(--text);min-width:24px;text-align:right;">${cnt}</span>
<span style="font-size:10px;color:var(--text3);min-width:36px;text-align:right;">${Math.round(uPct)}%</span>
</div>`;
}).join('') + (unknownCount > 0 ? `<div style="display:flex;align-items:center;gap:6px;margin-top:3px;padding-left:4px;">
<div style="width:10px;height:10px;border-radius:50%;background:var(--border2);flex-shrink:0;"></div>
<span style="font-size:11px;color:var(--text3);flex:1;">Неизвестен</span>
<span style="font-size:11px;font-weight:600;color:var(--text3);min-width:24px;text-align:right;">${unknownCount}</span>
<span style="font-size:10px;color:var(--text3);min-width:36px;text-align:right;">${Math.round(unknownCount/v.total*100)}%</span>
</div>` : '');
return `<div style="border:1px solid var(--border);border-radius:8px;padding:8px 10px;">
<div style="display:flex;align-items:center;gap:8px;cursor:pointer;" onclick="goToClientTasks('${safeName}')">
<div style="font-size:12px;font-weight:600;color:var(--text);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${safeName}">${escHtml(name)}</div>
<span style="font-size:13px;font-weight:800;color:var(--accent);flex-shrink:0;">${v.total}</span>
</div>
<div style="margin-top:5px;height:6px;background:var(--surface2);border-radius:3px;overflow:hidden;">
<div style="height:100%;width:${Math.max(pct,v.total?2:0)}%;background:var(--accent);border-radius:3px;transition:width .4s;"></div>
</div>
${usersList}
</div>`;
}).join('')}</div>`;
}