900 lines
59 KiB
JavaScript
900 lines
59 KiB
JavaScript
// ══════════════════════════════════════════════════════════════════
|
||
// СТРАНИЦА СТАТИСТИКИ
|
||
// ══════════════════════════════════════════════════════════════════
|
||
|
||
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:'2–3', max:3, count:0, color:'var(--accent)'},
|
||
{label:'4–6', 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,''');
|
||
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,'&').replace(/</g,'<').replace(/>/g,'>');}
|
||
function escAttr(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');}
|
||
|
||
function wDiffBadge(diff){
|
||
if(diff===0) return '<span class="stats-wdiff stats-wdiff-flat">= 0</span>';
|
||
if(diff>0) return '<span class="stats-wdiff stats-wdiff-up">↑ +'+diff+'</span>';
|
||
return '<span class="stats-wdiff stats-wdiff-down">↓ '+diff+'</span>';
|
||
}
|
||
|
||
function showLineTip(event,el){const tip=document.getElementById('line-tooltip');if(!tip)return;tip.textContent=`${el.dataset.lbl}: ${el.dataset.val} откр.`;tip.style.display='block';const wr=document.getElementById('stats-line-chart').getBoundingClientRect();tip.style.left=Math.min(event.clientX-wr.left+12,wr.width-130)+'px';tip.style.top=Math.max(event.clientY-wr.top-36,4)+'px';}
|
||
function hideLineTip(){const t=document.getElementById('line-tooltip');if(t)t.style.display='none';}
|
||
|
||
function showActTip(event,el){
|
||
let tip=document.getElementById('stats-act-tooltip');
|
||
if(!tip){tip=document.createElement('div');tip.id='stats-act-tooltip';tip.className='stats-act-tooltip';document.body.appendChild(tip);}
|
||
const cr=el.dataset.actCr||'0',cl=el.dataset.actCl||'0',lbl=el.dataset.actLbl||'';
|
||
tip.innerHTML=`<span style="font-weight:600;">${escHtml(lbl)}</span><br><span style="color:var(--accent);">▲ Создано: ${cr}</span> <span style="color:var(--green);">▼ Закрыто: ${cl}</span>`;
|
||
tip.style.display='block';
|
||
tip.style.left=(event.clientX+14)+'px';
|
||
tip.style.top=Math.max(event.clientY-48,8)+'px';
|
||
}
|
||
function hideActTip(){const t=document.getElementById('stats-act-tooltip');if(t)t.style.display='none';}
|
||
|
||
function openTaskById(id){const i=allData.findIndex(x=>x.task.id===id);if(i!==-1){closeStatsPage();setTimeout(()=>openDetail(i),100);}}
|
||
function onDataLoaded(){if(statsPageOpen)renderStatsPage();}
|
||
|
||
function 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
|
||
// Базовый список клиентов берём из _allClients (все usergroup из dictionaries),
|
||
// чтобы показывать клиентов с нулём заявок тоже.
|
||
const cMap = {};
|
||
const clientUsers = window._clientUsers || {};
|
||
|
||
// Сначала заполняем всех известных клиентов с нулями, включая всех пользователей группы
|
||
(window._allClients || []).forEach((c) => {
|
||
const users = {};
|
||
(clientUsers[String(c.client_id)] || []).forEach((u) => { users[u] = 0; });
|
||
cMap[c.name] = { total: 0, users };
|
||
});
|
||
|
||
// Затем считаем реальные заявки
|
||
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, ''');
|
||
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>`;
|
||
}
|