statistica vibe 4

This commit is contained in:
dv 2026-03-23 23:41:37 +03:00
parent de69bddcaf
commit bcd1c40783
2 changed files with 52 additions and 12 deletions

View file

@ -6,6 +6,7 @@
### Улучшения ### Улучшения
- **График открытых заявок** — переключатель **«По неделям»** / **«По дням»**: 16 недель (как раньше) или 112 дней с тем же смыслом точки (снимок «сколько было открыто» на момент); боковая панель сравнения в режиме дней подписывает вторую строку «день назад».
- **Тепловая карта по месяцам** переработана: вместо сетки месяц×день-недели теперь отображается одна строка из 12 ячеек (по одной на месяц), каждая окрашена по числу заявок. Значение показывается при наведении. - **Тепловая карта по месяцам** переработана: вместо сетки месяц×день-недели теперь отображается одна строка из 12 ячеек (по одной на месяц), каждая окрашена по числу заявок. Значение показывается при наведении.
- **Блок сравнения** («сейчас / неделю назад / начало месяца») теперь растягивается в высоту графика (`align-items: stretch`), элементы равномерно распределяются по высоте — цифры больше не выступают за пределы графика. - **Блок сравнения** («сейчас / неделю назад / начало месяца») теперь растягивается в высоту графика (`align-items: stretch`), элементы равномерно распределяются по высоте — цифры больше не выступают за пределы графика.
- **Повторные клиенты** — один счётчик «открытых» через `isTaskOpen` (в т.ч. «в работе», «новая», «назначена» и т.п.); одна акцентная полоса и бейдж `N откр.` без разделения цветов. - **Повторные клиенты** — один счётчик «открытых» через `isTaskOpen` (в т.ч. «в работе», «новая», «назначена» и т.п.); одна акцентная полоса и бейдж `N откр.` без разделения цветов.
@ -19,7 +20,7 @@
| Файл | Изменения | | Файл | Изменения |
| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `public/js/statistics.js` | `MONTH_COUNTS` вместо `MONTH_HM`; новый HTML месячной тепловой карты; `renderClientsList` с единой логикой `open` и правкой `opPct` для `clientsOnlyOpen` | | `public/js/statistics.js` | `MONTH_COUNTS` вместо `MONTH_HM`; новый HTML месячной тепловой карты; `renderClientsList` с единой логикой `open` и правкой `opPct` для `clientsOnlyOpen`; переключатель недели/дни для графика открытых (`openChartByDays`, `lineOpen` / `lineSlot`) |
| `public/css/app.css` | `.stats-month-hm`*; `align-items: stretch` в `.stats-open-wrap`; уменьшен `.soc-val`; стили бейджа клиентов без раздельных полос | | `public/css/app.css` | `.stats-month-hm`*; `align-items: stretch` в `.stats-open-wrap`; уменьшен `.soc-val`; стили бейджа клиентов без раздельных полос |

View file

@ -6,6 +6,8 @@ let statsPageOpen = false;
let clientsExpanded = false; let clientsExpanded = false;
let clientsOnlyOpen = false; let clientsOnlyOpen = false;
let timingPeriodIdx = 4; let timingPeriodIdx = 4;
/** false = по неделям (16 точек), true = по дням (112 дней) — график «открытых заявок» */
let openChartByDays = false;
const STATS_CACHE_KEY = 'intradesk_stats_html_v2'; const STATS_CACHE_KEY = 'intradesk_stats_html_v2';
function saveStatsCache() { function saveStatsCache() {
@ -93,9 +95,40 @@ function renderStatsPage() {
wCreated.push(data.filter(({task:t})=>{const c=new Date(t.createdat);return c>=wStart&&c<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); 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 maxOpen = Math.max(...wOpen,1); const maxOpenWeekly = Math.max(...wOpen,1);
const maxDual = Math.max(...wCreated,...wClosed,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 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 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;
@ -368,36 +401,42 @@ function renderStatsPage() {
</div> </div>
</div> </div>
<!-- Открытые на конец недели --> <!-- Открытых заявок (недели / дни) -->
<div class="stats-card"> <div class="stats-card">
<div class="stats-card-title">📈 Открытых заявок на конец недели</div> <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-wrap">
<div class="stats-open-chart"> <div class="stats-open-chart">
<div class="stats-line-chart" id="stats-line-chart"> <div class="stats-line-chart" id="stats-line-chart">
<div class="stats-line-tooltip" id="line-tooltip" style="display:none;"></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"> <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> <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(''); })()} ${(()=>{ 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> </svg>
<div class="stats-line-xlabels">${wLabels.map((l,i)=>i%2===0?`<span>${l}</span>`:'<span></span>').join('')}</div> <div class="stats-line-xlabels">${lineLabels.map((l,i)=>i%lineLabelStep===0?`<span>${l}</span>`:'<span></span>').join('')}</div>
</div> </div>
</div> </div>
<div class="stats-open-compare"> <div class="stats-open-compare">
<div class="soc-item"> <div class="soc-item">
<div class="soc-val">${wOpen[WEEKS-1]}</div> <div class="soc-val">${lineOpen[lineN-1]}</div>
<div class="soc-label">сейчас</div> <div class="soc-label">сейчас</div>
</div> </div>
<div class="soc-divider"></div> <div class="soc-divider"></div>
<div class="soc-item"> <div class="soc-item">
<div class="soc-val">${wOpen[WEEKS-2]}</div> <div class="soc-val">${lineN>=2?lineOpen[lineN-2]:'—'}</div>
<div class="soc-label">неделю назад</div> <div class="soc-label">${openChartByDays?'день назад':'неделю назад'}</div>
${wDiffBadge(wOpen[WEEKS-1]-wOpen[WEEKS-2])} ${lineN>=2?wDiffBadge(lineOpen[lineN-1]-lineOpen[lineN-2]):''}
</div> </div>
<div class="soc-divider"></div> <div class="soc-divider"></div>
<div class="soc-item"> <div class="soc-item">
<div class="soc-val">${openAtMonthStart}</div> <div class="soc-val">${openAtMonthStart}</div>
<div class="soc-label">нач. месяца</div> <div class="soc-label">нач. месяца</div>
${wDiffBadge(wOpen[WEEKS-1]-openAtMonthStart)} ${wDiffBadge(lineOpen[lineN-1]-openAtMonthStart)}
</div> </div>
</div> </div>
</div> </div>