// ══════════════════════════════════════════════════════════════════ // СТРАНИЦА СТАТИСТИКИ // ══════════════════════════════════════════════════════════════════ 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{const c=new Date(t.resolutiondatefact||t.closedat||0);return c>=wStart&&c= 0; i--) { const tEnd = new Date(now - i * 86400000); lineLabels.push(tEnd.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })); lineOpen.push(data.filter(({ task: t }) => { const cr = new Date(t.createdat); if (cr >= tEnd) return false; if (!isTaskDone({ task: t })) return true; return new Date(t.resolutiondatefact || t.closedat || 0) > tEnd; }).length); } maxOpen = Math.max(...lineOpen, 1); } else { lineLabels = wLabels; lineOpen = wOpen; lineN = WEEKS; lineSlot = 60; lineDotR = 5; lineLabelStep = 2; maxOpen = maxOpenWeekly; } // Открытых на начало текущего месяца const firstDayOfMonth = new Date(now); firstDayOfMonth.setDate(1); firstDayOfMonth.setHours(0,0,0,0); const openAtMonthStart = data.filter(({task:t})=>{ const cr=new Date(t.createdat); if(cr>=firstDayOfMonth)return false; if(!isTaskDone({task:t}))return true; return new Date(t.resolutiondatefact||t.closedat||0)>firstDayOfMonth; }).length; // Воронка const FB = [ {label:'< 1 часа', max:60, color:'#0d9488'}, {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&&ct.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 = `
${kpi('blue', iconGrid(), total, 'Всего заявок')} ${kpi('accent',iconOpen(), openCount, 'Открытых')} ${kpi('green', iconDone(), doneCount, 'Выполненных')} ${kpi('orange',``, `${noExecPct}%`, 'Без исполнителя')} ${kpi('red',``, `${expiredPct}%`, 'Просрочено')} ${kpi('teal',``, `${sameDayPct}%`, 'Закрыто в день')} ${kpi('indigo',``, `${repeatPct}%`, 'Повторных клиентов')}
Метки — все заявки
${renderDonut(sc,uc,total)}
${donutLegend(sc,uc,total)}
Метки — только открытые
${renderDonut(scOpen,ucOpen,openCount)}
${donutLegend(scOpen,ucOpen,openCount)}
⏱ Время реагирования и закрытия
${PERIODS.map((p,i)=>``).join('')}
${renderTimingBody(AP)}
📈 Открытых заявок ${openChartByDays ? 'по дням' : 'на конец недели'}
${(()=>{ if(maxOpen===0)return''; const cx=i=>i*lineSlot+lineSlot/2; const pts=lineOpen.map((v,i)=>[cx(i),115-v/maxOpen*95]); const area='M'+pts[0]+' '+pts.slice(1).map(p=>'L'+p).join(' ')+' L'+pts[pts.length-1][0]+',115 L'+pts[0][0]+',115 Z'; const line='M'+pts[0]+' '+pts.slice(1).map(p=>'L'+p).join(' '); return''+pts.map(([x,y],i)=>'').join(''); })()}
${lineLabels.map((l,i)=>i%lineLabelStep===0?`${l}`:'').join('')}
${lineOpen[lineN-1]}
сейчас
${lineN>=2?lineOpen[lineN-2]:'—'}
${openChartByDays?'день назад':'неделю назад'}
${lineN>=2?wDiffBadge(lineOpen[lineN-1]-lineOpen[lineN-2]):''}
${openAtMonthStart}
нач. месяца
${wDiffBadge(lineOpen[lineN-1]-openAtMonthStart)}
📊 Создано / Закрыто по неделям создано закрыто
${wCreated.map((cr,i)=>{ const cl=wClosed[i],hCr=Math.round(cr/maxDual*100),hCl=Math.round(cl/maxDual*100); return'
'+(cr||cl?''+cr+'/'+cl+'':'')+'
'+wLabels[i]+'
'; }).join('')}
👤 Нагрузка исполнителей
${eList.length===0?`
Нет данных
`:`
${eList.map(([name,v])=>{ const tot=v.open+v.done,pct=tot/maxE*100,opPct=tot?v.open/tot*100:0; return'
'+escHtml(name)+'
'+v.open+' откр.'+v.done+' завер.
'; }).join('')}
ОткрытыеЗавершённые
`}
⏳ Воронка времени жизни
${fTotal===0?`
Нет закрытых заявок
`:`
${FB.map(b=>{const pct=b.count/maxF*100,share=fTotal?Math.round(b.count/fTotal*100):0;return'
'+b.label+'
'+b.count+'
'+share+'%
';}).join('')}
`}
📅 Возраст заявок
${AB.map(b=>{const share=total?Math.round(b.count/total*100):0;return'
'+b.label+'
'+b.count+'
'+share+'%
';}).join('')}
🔁 Клиенты
🕐 Последняя заявка по клиентам
🌡️ Тепловая карта активности (час × день недели)
${Array.from({length:24},(_,h)=>`
${h}
`).join('')}
${HM_ORDER.map((dayIdx,i)=>{ const row=HM[dayIdx]; return'
'+HM_LABELS[i]+'
'+row.map((v,h)=>'
'+(v||'')+'
').join('')+'
'; }).join('')}
меньше ${[0,.2,.4,.6,.8,1].map(hi=>`
`).join('')} больше
🗓️ Активность по месяцам
${MONTH_NAMES.map((ml,mi)=>{ const v=MONTH_COUNTS[mi]; const hi=(v/monthCountMax).toFixed(3); return'
'+v+''+ml+'
'; }).join('')}
меньше ${[0,.2,.4,.6,.8,1].map(hi=>`
`).join('')} больше
🏷️ Тип проблемы
${allCats.map(c=>{ const pct=c.count/maxCat*100, share=total?Math.round(c.count/total*100):0; return'
'+c.label+'
'+c.count+'
'+share+'%
'; }).join('')}
⏱️ Время закрытия по типу (медиана)
${allCats.filter(c=>c.times&&c.times.length>0).length===0 ?`
Нет закрытых заявок
` :`
${allCats.map(c=>{ const m=med(c.times); if(!m)return''; const pct=m/maxCatMed*100; return'
'+c.label+'
'+fmtDur(m)+'
'; }).join('')}
Медиана по закрытым заявкам каждого типа
`}
🔧 Тип поддержки
Удалённая
${svcRemote}
${total?Math.round(svcRemote/total*100):0}%
Выездная
${svcField}
${total?Math.round(svcField/total*100):0}%
${svcOther>0?`
Прочие
${svcOther}
${total?Math.round(svcOther/total*100):0}%
`:''}
84683 — удалённая · 84682 — выездная
😊 Эмоциональная оценка ИИ
${emoList.length===0 ?`
Нет данных aiemotionalassessment
` :`
${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'
'+escHtml(label)+'
'+cnt+'
'+share+'%
'; }).join('')}
${emoTotal} из ${total} заявок имеют оценку
`}
⭐ Оценки от клиентов (evaluation)
Конверсия закрытых → оценка
${evalPct}%
${evalCount} из ${doneCount} закрытых
${evalAvg?`
Средний балл
${evalAvg}
по ${evalCount} оценкам
`:`
Оценок пока нет
`}
📎 Вложения на заявку
${ATT_B.map(b=>{ const pct=b.count/maxAtt*100, share=total?Math.round(b.count/total*100):0; return'
'+b.label+'
'+b.count+'
'+share+'%
'; }).join('')}
Косвенный показатель сложности заявки
⚡ Время первого ответа по исполнителю
${execRxList.length===0 ?`
Нет данных по реакции
` :`
${execRxList.map(e=>{ const pct=e.med/maxRxMed*100; return'
'+escHtml(e.name)+'
'+fmtDur(e.med)+'
'+e.count+'шт
'; }).join('')}
Медиана времени реакции · сортировка от быстрых к медленным
`}
🚨 Просроченные дедлайны по неделям ${expiredTotal} всего (${expiredPct}%)
${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'
'+cnt+'
'+expWLabels[i]+'
'; }).join('')}
isresolutionexpired = true · по дате создания заявки
🔥 Клиенты с приоритетными заявками
${prioList.length===0 ?`
Нет заявок с полем priority
` :`
${prioList.map(([name,v])=>{ const pct=v.count/maxPrio*100, opPct=v.count?v.open/v.count*100:0; return'
'+escHtml(name)+'
'+v.count+'
'+(v.open?''+v.open+' откр.':'')+'
'; }).join('')}
Заявки с ненулевым полем priority
`}
🌙 Активность в нерабочее время
Ночные заявки (22:00 — 07:00)
${nightPct}%
${nightCount} заявок из ${total}
Выходные дни (сб / вс)
${weekendPct}%
${weekendCount} заявок из ${total}
Аргумент для обоснования дежурства / доп. тарификации
`; renderClientsList(); renderClientLastTicketCard(); saveStatsCache(); } // Тайминги — body function renderTimingBody(p) { if (!p.reaction.length && !p.resolution.length) return `
Нет данных за этот период
`; return `
⚡ Время реакции
${fmtDur(avg(p.reaction))}
Среднее
${fmtDur(med(p.reaction))}
Медиана
${p.reaction.length}
Заявок
🏁 Время до закрытия
${fmtDur(avg(p.resolution))}
Среднее
${fmtDur(med(p.resolution))}
Медиана
${p.resolution.length}
Закрыто
По полям «Дата реакции» и «Дата решения» · период: ${p.label}
`; } 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()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=`
${clientsOnlyOpen?'Нет клиентов с открытыми заявками':'Нет повторных обращений'}
`;return;} wrap.innerHTML=`
${visible.map(([name,v])=>{ const dv=clientsOnlyOpen?v.open:v.count,pct=dv/maxV*100,opPct=v.count?v.open/v.count*100:0; return'
'+escHtml(name)+'
'+v.count+''+(v.open?''+v.open+' откр.':'')+'
'; }).join('')}
${!clientsExpanded&&hidden>0?`
+ ещё ${hidden} — показать все
`:''}`; } // 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`
${icon}
${val}
${lbl}
`;} function iconGrid(){return``;} function iconOpen(){return``;} function iconDone(){return``;} function renderDonut(sc,uc,tot){ if(!tot)return'
Нет данных
'; 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=``;off+=dash;return el;}); return`${paths.join('')}${tot}заявок`; } 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`
${m.chipLabel}${c}${p}%
`;}).join('')+ `
Без статуса${uc}${tot?Math.round(uc/tot*100):0}%
`; } function escHtml(s){return String(s).replace(/&/g,'&').replace(//g,'>');} function escAttr(s){return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,''');} function wDiffBadge(diff){ if(diff===0) return '= 0'; if(diff>0) return '↑ +'+diff+''; return '↓ '+diff+''; } function showLineTip(event,el){const tip=document.getElementById('line-tooltip');if(!tip)return;tip.textContent=`${el.dataset.lbl}: ${el.dataset.val} откр.`;tip.style.display='block';const wr=document.getElementById('stats-line-chart').getBoundingClientRect();tip.style.left=Math.min(event.clientX-wr.left+12,wr.width-130)+'px';tip.style.top=Math.max(event.clientY-wr.top-36,4)+'px';} function hideLineTip(){const t=document.getElementById('line-tooltip');if(t)t.style.display='none';} function showActTip(event,el){ let tip=document.getElementById('stats-act-tooltip'); if(!tip){tip=document.createElement('div');tip.id='stats-act-tooltip';tip.className='stats-act-tooltip';document.body.appendChild(tip);} const cr=el.dataset.actCr||'0',cl=el.dataset.actCl||'0',lbl=el.dataset.actLbl||''; tip.innerHTML=`${escHtml(lbl)}
▲ Создано: ${cr}   ▼ Закрыто: ${cl}`; tip.style.display='block'; tip.style.left=(event.clientX+14)+'px'; tip.style.top=Math.max(event.clientY-48,8)+'px'; } function hideActTip(){const t=document.getElementById('stats-act-tooltip');if(t)t.style.display='none';} function openTaskById(id){const i=allData.findIndex(x=>x.task.id===id);if(i!==-1){closeStatsPage();setTimeout(()=>openDetail(i),100);}} function onDataLoaded(){if(statsPageOpen)renderStatsPage();} 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 = '
Нет данных
'; return; } const maxAge = now - entries[entries.length - 1][1][field]; wrap.innerHTML = `
${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 `
${escHtml(name)}
${fmtAgo(age)}
${dateStr}
`; }).join('')}
`; }