+статистика клиентов
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
dv 2026-04-15 10:13:45 +03:00
parent 8cb0c58004
commit 621c6407fe
2 changed files with 69 additions and 1 deletions

View file

@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(node -e ':*)"
]
}
}

View file

@ -497,6 +497,12 @@ function renderStatsPage() {
<div id="stats-client-last-ticket-wrap"></div> <div id="stats-client-last-ticket-wrap"></div>
</div> </div>
<!-- Заявки по клиентам + разбивка по пользователям -->
<div class="stats-card">
<div class="stats-card-title">👥 Заявки по клиентам (все, включая с нулём)</div>
<div id="stats-client-users-wrap"></div>
</div>
<!-- --> <!-- -->
<!-- НОВЫЕ КАРТОЧКИ --> <!-- НОВЫЕ КАРТОЧКИ -->
<!-- --> <!-- -->
@ -655,6 +661,7 @@ function renderStatsPage() {
renderClientsList(); renderClientsList();
renderClientLastTicketCard(); renderClientLastTicketCard();
renderClientUsersCard();
saveStatsCache(); saveStatsCache();
} }
@ -712,7 +719,14 @@ 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"><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;
const safeName=escHtml(name).replace(/'/g,'&#39;');
const openBadge=v.open
?`<span class="stats-clients-open-badge" style="min-width:52px;text-align:center;">${v.open} откр.</span>`
:`<span style="min-width:52px;display:inline-block;"></span>`;
return`<div class="stats-clients-row" style="cursor:pointer;" onclick="goToClientTasks('${safeName}')" title="Показать заявки: ${safeName}"><div class="stats-clients-name" title="${safeName}">${escHtml(name)}</div><div class="stats-clients-track"><div class="stats-clients-bar-total" style="width:${pct}%;"></div><div class="stats-clients-bar-open" style="width:${pct*opPct/100}%;"></div></div><div class="stats-clients-counts" style="min-width:110px;justify-content:flex-end;"><span class="stats-clients-total" style="min-width:24px;text-align:right;">${v.count}</span>${openBadge}</div></div>`;
}).join('')}
</div> </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>`:''}`;
} }
@ -820,3 +834,50 @@ function renderClientLastTicketCard() {
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>`; 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>`; }).join('')}</div>`;
} }
function renderClientUsersCard() {
const wrap = document.getElementById('stats-client-users-wrap');
if (!wrap) return;
// Собираем: клиент → { total, users: { username → count } }
const cMap = {};
// Сначала добавляем всех клиентов, которые есть в allData, с нулями
allData.forEach(({task: t}) => {
const name = t._clientname || t.clientname || 'Неизвестно';
if (!cMap[name]) cMap[name] = { total: 0, users: {} };
});
allData.forEach(({task: t}) => {
const name = t._clientname || t.clientname || 'Неизвестно';
cMap[name].total++;
const user = t.username || '—';
cMap[name].users[user] = (cMap[name].users[user] || 0) + 1;
});
const entries = Object.entries(cMap).sort((a, b) => b[1].total - a[1].total);
if (!entries.length) { wrap.innerHTML = '<div class="stats-empty">Нет данных</div>'; return; }
const maxTotal = entries[0][1].total || 1;
wrap.innerHTML = `<div style="display:flex;flex-direction:column;gap:10px;max-height:500px;overflow-y:auto;">${entries.map(([name, v]) => {
const pct = v.total / maxTotal * 100;
const safeName = escHtml(name).replace(/'/g, '&#39;');
const usersList = Object.entries(v.users).sort((a, b) => b[1] - a[1]).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:20px;text-align:right;">${cnt}</span>
<span style="font-size:10px;color:var(--text3);min-width:34px;text-align:right;">${Math.round(uPct)}%</span>
</div>`;
}).join('');
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>
${v.total > 0 ? usersList : ''}
</div>`;
}).join('')}</div>`;
}