statistica vibe 2
This commit is contained in:
parent
45773f19e8
commit
77d3ac94ad
2 changed files with 117 additions and 52 deletions
|
|
@ -1037,21 +1037,28 @@ html.dark .stats-hm-cell:hover span { color: rgba(255,255,255,.95); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Open-tickets week/month compare ────────────────────────── */
|
/* ── Open-tickets week/month compare ────────────────────────── */
|
||||||
|
.stats-open-wrap { display:flex; gap:16px; align-items:flex-start; }
|
||||||
|
.stats-open-chart { flex:1; min-width:0; }
|
||||||
.stats-open-compare {
|
.stats-open-compare {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
gap: 12px;
|
background: var(--surface2);
|
||||||
margin: 6px 0 12px;
|
border: 1px solid var(--border);
|
||||||
flex-wrap: wrap;
|
border-radius: var(--radius);
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 108px;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.soc-item {
|
.soc-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 2px;
|
gap: 3px;
|
||||||
|
padding: 12px 16px;
|
||||||
}
|
}
|
||||||
|
.soc-divider { height:1px; background:var(--border); }
|
||||||
.soc-val {
|
.soc-val {
|
||||||
font-size: 22px;
|
font-size: 24px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
|
@ -1061,13 +1068,9 @@ html.dark .stats-hm-cell:hover span { color: rgba(255,255,255,.95); }
|
||||||
color: var(--text3);
|
color: var(--text3);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: .04em;
|
letter-spacing: .04em;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.soc-sep {
|
.soc-sep { display: none; }
|
||||||
color: var(--text3);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
align-self: center;
|
|
||||||
}
|
|
||||||
.stats-wdiff {
|
.stats-wdiff {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|
@ -1080,7 +1083,35 @@ html.dark .stats-hm-cell:hover span { color: rgba(255,255,255,.95); }
|
||||||
.stats-wdiff-down { background: color-mix(in srgb,var(--green) 15%,transparent); color: var(--green); }
|
.stats-wdiff-down { background: color-mix(in srgb,var(--green) 15%,transparent); color: var(--green); }
|
||||||
.stats-wdiff-flat { background: var(--surface2); color: var(--text3); }
|
.stats-wdiff-flat { background: var(--surface2); color: var(--text3); }
|
||||||
|
|
||||||
/* ── Activity tooltip ───────────────────────────────────────── */
|
/* ── Activity hover label ────────────────────────────────────── */
|
||||||
|
.stats-act-hover-label {
|
||||||
|
position: absolute;
|
||||||
|
top: -20px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity .15s;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px 5px;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.stats-activity-col:hover .stats-act-hover-label { opacity: 1; }
|
||||||
|
.stats-activity-col:hover .stats-activity-bar { filter: brightness(1.2) saturate(1.15); }
|
||||||
|
.stats-activity-bar { transition: filter .15s, height .2s; }
|
||||||
|
.sahl-cr { color: var(--accent); }
|
||||||
|
.sahl-sep { color: var(--text3); margin: 0 1px; }
|
||||||
|
.sahl-cl { color: var(--green); }
|
||||||
|
|
||||||
|
/* ── Activity tooltip (floating, kept for other use) ─────────── */
|
||||||
.stats-act-tooltip {
|
.stats-act-tooltip {
|
||||||
display: none;
|
display: none;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,16 @@ function renderStatsPage() {
|
||||||
const HM_ORDER = [1,2,3,4,5,6,0];
|
const HM_ORDER = [1,2,3,4,5,6,0];
|
||||||
const HM_LABELS = ['Пн','Вт','Ср','Чт','Пт','Сб','Вс'];
|
const HM_LABELS = ['Пн','Вт','Ср','Чт','Пт','Сб','Вс'];
|
||||||
|
|
||||||
|
// Тепловая карта по месяцам (месяц × день недели)
|
||||||
|
const MONTH_HM = Array.from({length:12}, () => new Array(7).fill(0));
|
||||||
|
data.forEach(({task:t}) => {
|
||||||
|
if (!t.createdat) return;
|
||||||
|
const d = new Date(t.createdat);
|
||||||
|
MONTH_HM[d.getMonth()][d.getDay()]++;
|
||||||
|
});
|
||||||
|
const monthHmMax = Math.max(...MONTH_HM.flat(), 1);
|
||||||
|
const MONTH_NAMES = ['Янв','Фев','Мар','Апр','Май','Июн','Июл','Авг','Сен','Окт','Ноя','Дек'];
|
||||||
|
|
||||||
// 4. Без исполнителя
|
// 4. Без исполнителя
|
||||||
const noExec = data.filter(({task:t}) => !t.executor).length;
|
const noExec = data.filter(({task:t}) => !t.executor).length;
|
||||||
const noExecPct = total ? Math.round(noExec/total*100) : 0;
|
const noExecPct = total ? Math.round(noExec/total*100) : 0;
|
||||||
|
|
@ -362,31 +372,35 @@ function renderStatsPage() {
|
||||||
<!-- Открытые на конец недели -->
|
<!-- Открытые на конец недели -->
|
||||||
<div class="stats-card">
|
<div class="stats-card">
|
||||||
<div class="stats-card-title">📈 Открытых заявок на конец недели</div>
|
<div class="stats-card-title">📈 Открытых заявок на конец недели</div>
|
||||||
<div class="stats-open-compare">
|
<div class="stats-open-wrap">
|
||||||
<div class="soc-item">
|
<div class="stats-open-chart">
|
||||||
<div class="soc-val">${wOpen[WEEKS-1]}</div>
|
<div class="stats-line-chart" id="stats-line-chart">
|
||||||
<div class="soc-label">сейчас</div>
|
<div class="stats-line-tooltip" id="line-tooltip" style="display:none;"></div>
|
||||||
|
<svg class="stats-svg" viewBox="0 0 ${WEEKS*60} 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 pts=wOpen.map((v,i)=>[i*60+30,115-v/maxOpen*95]); const area='M'+pts[0]+' '+pts.slice(1).map(p=>'L'+p).join(' ')+' L'+pts[pts.length-1][0]+',115 L'+pts[0][0]+',115 Z'; const line='M'+pts[0]+' '+pts.slice(1).map(p=>'L'+p).join(' '); return'<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="5" fill="var(--accent)" stroke="var(--surface)" stroke-width="2" data-val="'+wOpen[i]+'" data-lbl="'+wLabels[i]+'" onmouseenter="showLineTip(event,this)" onmouseleave="hideLineTip()"/>').join(''); })()}
|
||||||
|
</svg>
|
||||||
|
<div class="stats-line-xlabels">${wLabels.map((l,i)=>i%2===0?`<span>${l}</span>`:'<span></span>').join('')}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="soc-sep">vs</div>
|
<div class="stats-open-compare">
|
||||||
<div class="soc-item">
|
<div class="soc-item">
|
||||||
<div class="soc-val">${wOpen[WEEKS-2]}</div>
|
<div class="soc-val">${wOpen[WEEKS-1]}</div>
|
||||||
<div class="soc-label">неделю назад</div>
|
<div class="soc-label">сейчас</div>
|
||||||
${wDiffBadge(wOpen[WEEKS-1]-wOpen[WEEKS-2])}
|
</div>
|
||||||
|
<div class="soc-divider"></div>
|
||||||
|
<div class="soc-item">
|
||||||
|
<div class="soc-val">${wOpen[WEEKS-2]}</div>
|
||||||
|
<div class="soc-label">неделю назад</div>
|
||||||
|
${wDiffBadge(wOpen[WEEKS-1]-wOpen[WEEKS-2])}
|
||||||
|
</div>
|
||||||
|
<div class="soc-divider"></div>
|
||||||
|
<div class="soc-item">
|
||||||
|
<div class="soc-val">${openAtMonthStart}</div>
|
||||||
|
<div class="soc-label">нач. месяца</div>
|
||||||
|
${wDiffBadge(wOpen[WEEKS-1]-openAtMonthStart)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="soc-sep">·</div>
|
|
||||||
<div class="soc-item">
|
|
||||||
<div class="soc-val">${openAtMonthStart}</div>
|
|
||||||
<div class="soc-label">нач. месяца</div>
|
|
||||||
${wDiffBadge(wOpen[WEEKS-1]-openAtMonthStart)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="stats-line-chart" id="stats-line-chart">
|
|
||||||
<div class="stats-line-tooltip" id="line-tooltip" style="display:none;"></div>
|
|
||||||
<svg class="stats-svg" viewBox="0 0 ${WEEKS*60} 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 pts=wOpen.map((v,i)=>[i*60+30,115-v/maxOpen*95]); const area='M'+pts[0]+' '+pts.slice(1).map(p=>'L'+p).join(' ')+' L'+pts[pts.length-1][0]+',115 L'+pts[0][0]+',115 Z'; const line='M'+pts[0]+' '+pts.slice(1).map(p=>'L'+p).join(' '); return'<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="5" fill="var(--accent)" stroke="var(--surface)" stroke-width="2" data-val="'+wOpen[i]+'" data-lbl="'+wLabels[i]+'" onmouseenter="showLineTip(event,this)" onmouseleave="hideLineTip()"/>').join(''); })()}
|
|
||||||
</svg>
|
|
||||||
<div class="stats-line-xlabels">${wLabels.map((l,i)=>i%2===0?`<span>${l}</span>`:'<span></span>').join('')}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -398,7 +412,7 @@ function renderStatsPage() {
|
||||||
<span class="stats-legend-inline"><span style="background:var(--green);"></span> закрыто</span>
|
<span class="stats-legend-inline"><span style="background:var(--green);"></span> закрыто</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stats-activity-chart">
|
<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" data-act-cr="'+cr+'" data-act-cl="'+cl+'" data-act-lbl="'+escHtml(wLabels[i])+'" onmouseenter="showActTip(event,this)" onmouseleave="hideActTip()"><div class="stats-activity-bar-wrap" style="position:relative;"><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-count">'+(cr||cl?'<span style="color:var(--accent);font-size:9px;">'+cr+'</span>':'')+'</div><div class="stats-activity-label">'+wLabels[i]+'</div></div>'; }).join('')}
|
${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>
|
</div>
|
||||||
|
|
||||||
|
|
@ -435,21 +449,40 @@ function renderStatsPage() {
|
||||||
<!-- НОВЫЕ КАРТОЧКИ -->
|
<!-- НОВЫЕ КАРТОЧКИ -->
|
||||||
<!-- ═══════════════════════════════════════════════════════════ -->
|
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||||
|
|
||||||
<!-- 1. Тепловая карта активности -->
|
<!-- 1. Тепловые карты -->
|
||||||
<div class="stats-card">
|
<div class="stats-charts-row" style="align-items:flex-start;">
|
||||||
<div class="stats-card-title">🌡️ Тепловая карта активности (час × день недели)</div>
|
<div class="stats-card" style="flex:3;min-width:0;overflow-x:auto;">
|
||||||
<div class="stats-heatmap-wrap">
|
<div class="stats-card-title">🌡️ Тепловая карта активности (час × день недели)</div>
|
||||||
<div class="stats-heatmap-hours">
|
<div class="stats-heatmap-wrap">
|
||||||
${Array.from({length:24},(_,h)=>`<div class="stats-hm-hlbl">${h}</div>`).join('')}
|
<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>
|
||||||
${HM_ORDER.map((dayIdx,i)=>{
|
</div>
|
||||||
const row=HM[dayIdx];
|
<div class="stats-card" style="flex:1;min-width:200px;">
|
||||||
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>';
|
<div class="stats-card-title">🗓️ По месяцам (месяц × день нед.)</div>
|
||||||
}).join('')}
|
<div class="stats-heatmap-wrap">
|
||||||
<div class="stats-hm-legend">
|
<div class="stats-heatmap-hours" style="padding-left:38px;">
|
||||||
<span>меньше</span>
|
${HM_ORDER.map((_,i)=>`<div class="stats-hm-hlbl" style="width:26px;">${HM_LABELS[i]}</div>`).join('')}
|
||||||
${[0,.2,.4,.6,.8,1].map(hi=>`<div class="stats-hm-leg-cell" style="--hi:${hi};"></div>`).join('')}
|
</div>
|
||||||
<span>больше</span>
|
${MONTH_NAMES.map((ml,mi)=>{
|
||||||
|
const row=MONTH_HM[mi];
|
||||||
|
return'<div class="stats-hm-row"><div class="stats-hm-dlbl" style="width:36px;font-size:9px;">'+ml+'</div>'+HM_ORDER.map((di,dii)=>{const v=row[di];return'<div class="stats-hm-cell" style="--hi:'+((v/monthHmMax)).toFixed(3)+';width:26px;height:22px;" title="'+ml+' '+HM_LABELS[dii]+' — '+v+' заявок"><span>'+(v||'')+'</span></div>';}).join('')+'</div>';
|
||||||
|
}).join('')}
|
||||||
|
<div class="stats-hm-legend" style="padding-left:38px;">
|
||||||
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -628,7 +661,7 @@ function renderClientsList() {
|
||||||
if(visible.length===0){wrap.innerHTML=`<div class="stats-empty">${clientsOnlyOpen?'Нет клиентов с открытыми заявками':'Нет повторных обращений'}</div>`;return;}
|
if(visible.length===0){wrap.innerHTML=`<div class="stats-empty">${clientsOnlyOpen?'Нет клиентов с открытыми заявками':'Нет повторных обращений'}</div>`;return;}
|
||||||
wrap.innerHTML=`
|
wrap.innerHTML=`
|
||||||
<div class="stats-clients-list" style="display:flex;flex-direction:column;gap:6px;">
|
<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; return'<div class="stats-clients-row stats-clients-row-clickable" title="Открыть фильтр по клиенту" data-client="'+escHtml(name)+'" onclick="filterByClient(this.dataset.client)"><div class="stats-clients-name" title="'+escHtml(name)+'">'+escHtml(name)+'</div><div class="stats-clients-track"><div class="stats-clients-bar-total" style="width:'+pct+'%;"></div><div class="stats-clients-bar-open" style="width:'+pct*opPct/100+'%;"></div></div><div class="stats-clients-counts"><span class="stats-clients-total">'+v.count+'</span>'+(v.open?'<span class="stats-clients-open-badge">'+v.open+' откр.</span>':'')+'</div></div>'; }).join('')}
|
${visible.map(([name,v])=>{ const dv=clientsOnlyOpen?v.open:v.count,pct=dv/maxV*100,opPct=v.count?v.open/v.count*100:0; return'<div class="stats-clients-row stats-clients-row-clickable" title="Открыть фильтр по клиенту" data-client="'+escAttr(name)+'" onclick="filterByClient(this.dataset.client)"><div class="stats-clients-name" title="'+escHtml(name)+'">'+escHtml(name)+'</div><div class="stats-clients-track"><div class="stats-clients-bar-total" style="width:'+pct+'%;"></div><div class="stats-clients-bar-open" style="width:'+pct*opPct/100+'%;"></div></div><div class="stats-clients-counts"><span class="stats-clients-total">'+v.count+'</span>'+(v.open?'<span class="stats-clients-open-badge">'+v.open+' откр.</span>':'')+'</div></div>'; }).join('')}
|
||||||
</div>
|
</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>`:''}`;
|
${!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>`:''}`;
|
||||||
}
|
}
|
||||||
|
|
@ -656,6 +689,7 @@ function donutLegend(sc,uc,tot){
|
||||||
}
|
}
|
||||||
|
|
||||||
function escHtml(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
|
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){
|
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-flat">= 0</span>';
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue