v4: многопараметрический отчёт — распределения по нескольким полям + перекрёстная таблица
This commit is contained in:
parent
f585e67006
commit
3ec2a9a8d3
164
app.js
164
app.js
@ -108,7 +108,6 @@ function bindEvents(){
|
|||||||
document.getElementById('newFieldOptions').hidden = e.target.value!=='select';
|
document.getElementById('newFieldOptions').hidden = e.target.value!=='select';
|
||||||
});
|
});
|
||||||
document.getElementById('btnReportCsv').addEventListener('click', exportReportCSV);
|
document.getElementById('btnReportCsv').addEventListener('click', exportReportCSV);
|
||||||
document.getElementById('reportField').addEventListener('change', renderReports);
|
|
||||||
|
|
||||||
// Фильтры
|
// Фильтры
|
||||||
document.getElementById('search').addEventListener('input', e=>{ filters.search=e.target.value.toLowerCase(); renderTable(); });
|
document.getElementById('search').addEventListener('input', e=>{ filters.search=e.target.value.toLowerCase(); renderTable(); });
|
||||||
@ -143,7 +142,7 @@ function uniqueValues(key){
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Рендер всего ---------- */
|
/* ---------- Рендер всего ---------- */
|
||||||
function renderAll(){ renderStats(); renderDatalists(); renderTable(); buildFilterOptions(); buildReportFieldOptions(); }
|
function renderAll(){ renderStats(); renderDatalists(); renderTable(); buildFilterOptions(); renderReportFieldsList(); }
|
||||||
|
|
||||||
/* ---------- Карточки статистики ---------- */
|
/* ---------- Карточки статистики ---------- */
|
||||||
function renderStats(){
|
function renderStats(){
|
||||||
@ -348,49 +347,52 @@ window.removeField = function(key){
|
|||||||
save(); renderFieldList(); renderAll();
|
save(); renderFieldList(); renderAll();
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- Отчёты ---------- */
|
/* ---------- Отчёты (многопараметрические) ---------- */
|
||||||
function buildReportFieldOptions(){
|
let reportSelected = new Set(['academicDegree','category']); // выбранные по умолчанию
|
||||||
const sel = document.getElementById('reportField');
|
|
||||||
const prev = sel.value;
|
function renderReportFieldsList(){
|
||||||
sel.innerHTML = allFields().filter(f=>f.type!=='textarea').map(f=>`<option value="${f.key}">${esc(f.label)}</option>`).join('');
|
const box = document.getElementById('reportFieldsList');
|
||||||
if(prev && [...sel.options].some(o=>o.value===prev)) sel.value=prev;
|
const fields = allFields().filter(f=>f.type!=='textarea');
|
||||||
|
box.innerHTML = fields.map(f=>`
|
||||||
|
<label class="rf-item">
|
||||||
|
<input type="checkbox" ${reportSelected.has(f.key)?'checked':''} onchange="toggleReportField('${f.key}', this.checked)">
|
||||||
|
${esc(f.label)}
|
||||||
|
</label>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.toggleReportField = function(key, on){
|
||||||
|
if(on) reportSelected.add(key); else reportSelected.delete(key);
|
||||||
|
renderReports();
|
||||||
|
};
|
||||||
|
|
||||||
|
function valNorm(t, f){
|
||||||
|
let v = t[f.key];
|
||||||
|
if(f.type==='checkbox') v = v ? 'Да':'Нет';
|
||||||
|
if(v===undefined||v===null||v==='') v='(не указано)';
|
||||||
|
return v;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderReports(){
|
function renderReports(){
|
||||||
const sel = document.getElementById('reportField');
|
|
||||||
const key = sel.value;
|
|
||||||
const f = allFields().find(x=>x.key===key);
|
|
||||||
const grid = document.getElementById('reportGrid');
|
const grid = document.getElementById('reportGrid');
|
||||||
|
const cross = document.getElementById('crosstabWrap');
|
||||||
const summary = document.getElementById('reportSummary');
|
const summary = document.getElementById('reportSummary');
|
||||||
|
|
||||||
if(state.teachers.length===0){
|
if(state.teachers.length===0){
|
||||||
grid.innerHTML=''; summary.innerHTML='<p>Нет данных для отчёта.</p>'; return;
|
grid.innerHTML=''; cross.innerHTML=''; summary.innerHTML='<p>Нет данных для отчёта.</p>'; return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Группировка
|
const fields = allFields();
|
||||||
const groups = {};
|
const selected = fields.filter(f=>reportSelected.has(f.key) && f.type!=='textarea');
|
||||||
state.teachers.forEach(t=>{
|
|
||||||
let v = t[key];
|
|
||||||
if(f && f.type==='checkbox') v = v ? 'Да':'Нет';
|
|
||||||
if(v===undefined||v===null||v==='') v='(не указано)';
|
|
||||||
groups[v] = (groups[v]||0)+1;
|
|
||||||
});
|
|
||||||
|
|
||||||
const entries = Object.entries(groups).sort((a,b)=>b[1]-a[1]);
|
// 1. Карточки распределения по каждому выбранному параметру
|
||||||
const max = Math.max(...entries.map(e=>e[1]));
|
grid.innerHTML = selected.length
|
||||||
|
? selected.map(distributionCard).join('')
|
||||||
|
: '<p class="hint">Отметьте параметры выше, чтобы построить отчёт.</p>';
|
||||||
|
|
||||||
grid.innerHTML = `
|
// 2. Перекрёстная таблица по первым двум выбранным параметрам
|
||||||
<div class="report-card">
|
cross.innerHTML = (selected.length>=2) ? crosstabHTML(selected[0], selected[1]) : '';
|
||||||
<h3>${esc(f?f.label:key)} — распределение</h3>
|
|
||||||
${entries.map(([label,count])=>`
|
|
||||||
<div class="report-bar">
|
|
||||||
<span class="bar-label">${esc(label)}</span>
|
|
||||||
<span class="bar-track"><span class="bar-fill" style="width:${max?Math.round(count/max*100):0}%"></span></span>
|
|
||||||
<span class="bar-count">${count}</span>
|
|
||||||
</div>`).join('')}
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
// Сводка
|
// 3. Сводка
|
||||||
const total = state.teachers.length;
|
const total = state.teachers.length;
|
||||||
const withDegree = state.teachers.filter(t=>t.academicDegree && t.academicDegree!=='Нет').length;
|
const withDegree = state.teachers.filter(t=>t.academicDegree && t.academicDegree!=='Нет').length;
|
||||||
const pct = total?Math.round(withDegree/total*100):0;
|
const pct = total?Math.round(withDegree/total*100):0;
|
||||||
@ -398,10 +400,54 @@ function renderReports(){
|
|||||||
<h3>Сводка</h3>
|
<h3>Сводка</h3>
|
||||||
<p>Всего преподавателей: <span class="big">${total}</span></p>
|
<p>Всего преподавателей: <span class="big">${total}</span></p>
|
||||||
<p>С научной степенью: <b>${withDegree}</b> (${pct}%)</p>
|
<p>С научной степенью: <b>${withDegree}</b> (${pct}%)</p>
|
||||||
<p>Полей для параметров: <b>${allFields().length}</b> (из них пользовательских: ${state.customFields.length})</p>
|
<p>Параметров в отчёте: <b>${selected.length}</b>${selected.length>=2?` · перекрёстная таблица: ${esc(selected[0].label)} × ${esc(selected[1].label)}`:''}</p>
|
||||||
<p class="hint" style="margin-top:10px">Выберите поле выше, чтобы сгруппировать данные. Отчёт можно скачать в CSV.</p>`;
|
<p class="hint" style="margin-top:10px">Отметьте несколько параметров выше — по каждому будет свой график, а по первым двум — перекрёстная таблица. Отчёт скачивается в CSV целиком.</p>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function distributionCard(f){
|
||||||
|
const groups = {};
|
||||||
|
state.teachers.forEach(t=>{ const v=valNorm(t,f); groups[v]=(groups[v]||0)+1; });
|
||||||
|
const entries = Object.entries(groups).sort((a,b)=>b[1]-a[1]);
|
||||||
|
const max = Math.max(1, ...entries.map(e=>e[1]));
|
||||||
|
return `<div class="report-card">
|
||||||
|
<h3>${esc(f.label)} — распределение</h3>
|
||||||
|
${entries.map(([label,count])=>`
|
||||||
|
<div class="report-bar">
|
||||||
|
<span class="bar-label">${esc(label)}</span>
|
||||||
|
<span class="bar-track"><span class="bar-fill" style="width:${Math.round(count/max*100)}%"></span></span>
|
||||||
|
<span class="bar-count">${count}</span>
|
||||||
|
</div>`).join('')}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function crosstabHTML(fa, fb){
|
||||||
|
const rows=[], cols=[], m={};
|
||||||
|
state.teachers.forEach(t=>{
|
||||||
|
const a=valNorm(t,fa), b=valNorm(t,fb);
|
||||||
|
if(!rows.includes(a)) rows.push(a);
|
||||||
|
if(!cols.includes(b)) cols.push(b);
|
||||||
|
m[a]=m[a]||{}; m[a][b]=(m[a][b]||0)+1;
|
||||||
|
});
|
||||||
|
const colTotals = cols.map(c=>rows.reduce((s,r)=>s+(m[r][c]||0),0));
|
||||||
|
const grand = colTotals.reduce((s,x)=>s+x,0);
|
||||||
|
return `<div class="crosstab">
|
||||||
|
<h3>Перекрёстная таблица: ${esc(fa.label)} × ${esc(fb.label)}</h3>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="pivot">
|
||||||
|
<thead><tr><th>${esc(fa.label)} \\ ${esc(fb.label)}</th>${cols.map(c=>`<th>${esc(c)}</th>`).join('')}<th>Всего</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
${rows.map(r=>{
|
||||||
|
const rowTotal=cols.reduce((s,c)=>s+(m[r][c]||0),0);
|
||||||
|
return `<tr><td>${esc(r)}</td>${cols.map(c=>`<td>${m[r][c]||0}</td>`).join('')}<td class="tot">${rowTotal}</td></tr>`;
|
||||||
|
}).join('')}
|
||||||
|
<tr class="total-row"><td>Всего</td>${colTotals.map(c=>`<td class="tot">${c}</td>`).join('')}<td class="tot">${grand}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* ---------- Экспорт / Импорт ---------- */
|
/* ---------- Экспорт / Импорт ---------- */
|
||||||
function exportCSV(){
|
function exportCSV(){
|
||||||
if(state.teachers.length===0){ alert('Нет данных для экспорта'); return; }
|
if(state.teachers.length===0){ alert('Нет данных для экспорта'); return; }
|
||||||
@ -419,19 +465,45 @@ function exportCSV(){
|
|||||||
}
|
}
|
||||||
|
|
||||||
function exportReportCSV(){
|
function exportReportCSV(){
|
||||||
const key = document.getElementById('reportField').value;
|
const fields = allFields();
|
||||||
const f = allFields().find(x=>x.key===key);
|
const selected = fields.filter(f=>reportSelected.has(f.key) && f.type!=='textarea');
|
||||||
if(!f){ alert('Выберите поле'); return; }
|
if(selected.length===0){ alert('Отметьте хотя бы один параметр отчёта'); return; }
|
||||||
|
if(state.teachers.length===0){ alert('Нет данных для отчёта'); return; }
|
||||||
|
|
||||||
|
const lines = [];
|
||||||
|
selected.forEach(f=>{
|
||||||
const groups = {};
|
const groups = {};
|
||||||
state.teachers.forEach(t=>{
|
state.teachers.forEach(t=>{ const v=valNorm(t,f); groups[v]=(groups[v]||0)+1; });
|
||||||
let v = t[key];
|
const entries = Object.entries(groups).sort((a,b)=>b[1]-a[1]);
|
||||||
if(f.type==='checkbox') v = v?'Да':'Нет';
|
lines.push(`Отчёт по параметру: ${f.label}`);
|
||||||
if(v===undefined||v===null||v==='') v='(не указано)';
|
lines.push(`${csvCell('Значение')};${csvCell('Количество')}`);
|
||||||
groups[v] = (groups[v]||0)+1;
|
entries.forEach(([v,c])=>lines.push(`${csvCell(v)};${c}`));
|
||||||
|
lines.push(''); // пустая строка-разделитель
|
||||||
});
|
});
|
||||||
const rows = Object.entries(groups).sort((a,b)=>b[1]-a[1]);
|
|
||||||
const csv = '\uFEFF' + [`${f.label};Количество`].concat(rows.map(r=>`${csvCell(r[0])};${r[1]}`)).join('\r\n');
|
// Перекрёстная таблица по первым двум параметрам
|
||||||
download(csv, `otchet_${f.label}.csv`, 'text/csv;charset=utf-8');
|
if(selected.length>=2){
|
||||||
|
const fa=selected[0], fb=selected[1];
|
||||||
|
const rows=[], cols=[], m={};
|
||||||
|
state.teachers.forEach(t=>{
|
||||||
|
const a=valNorm(t,fa), b=valNorm(t,fb);
|
||||||
|
if(!rows.includes(a)) rows.push(a);
|
||||||
|
if(!cols.includes(b)) cols.push(b);
|
||||||
|
m[a]=m[a]||{}; m[a][b]=(m[a][b]||0)+1;
|
||||||
|
});
|
||||||
|
lines.push(`Перекрёстная таблица: ${fa.label} × ${fb.label}`);
|
||||||
|
lines.push([csvCell(fa.label+' \\ '+fb.label)].concat(cols.map(csvCell)).concat([csvCell('Всего')]).join(';'));
|
||||||
|
rows.forEach(r=>{
|
||||||
|
const rowTotal=cols.reduce((s,c)=>s+(m[r][c]||0),0);
|
||||||
|
lines.push([csvCell(r)].concat(cols.map(c=>m[r][c]||0)).concat([rowTotal]).join(';'));
|
||||||
|
});
|
||||||
|
const colTotals=cols.map(c=>rows.reduce((s,r)=>s+(m[r][c]||0),0));
|
||||||
|
const grand=colTotals.reduce((s,x)=>s+x,0);
|
||||||
|
lines.push([csvCell('Всего')].concat(colTotals).concat([grand]).join(';'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const csv = '\uFEFF' + lines.join('\r\n');
|
||||||
|
download(csv, 'otchet.csv', 'text/csv;charset=utf-8');
|
||||||
}
|
}
|
||||||
|
|
||||||
function csvCell(v){
|
function csvCell(v){
|
||||||
|
|||||||
@ -62,12 +62,14 @@
|
|||||||
<!-- ===== Вкладка: Отчёты ===== -->
|
<!-- ===== Вкладка: Отчёты ===== -->
|
||||||
<section id="tab-reports" class="tabpanel">
|
<section id="tab-reports" class="tabpanel">
|
||||||
<div class="report-controls">
|
<div class="report-controls">
|
||||||
<label>Сгруппировать по:
|
<div class="report-fields">
|
||||||
<select id="reportField" class="input"></select>
|
<span class="rf-title">Параметры отчёта — отметьте несколько:</span>
|
||||||
</label>
|
<div class="rf-list" id="reportFieldsList"></div>
|
||||||
|
</div>
|
||||||
<button class="btn outline" id="btnReportCsv">⤓ Скачать отчёт (CSV)</button>
|
<button class="btn outline" id="btnReportCsv">⤓ Скачать отчёт (CSV)</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="report-grid" id="reportGrid"></div>
|
<div class="report-grid" id="reportGrid"></div>
|
||||||
|
<div class="crosstab-wrap" id="crosstabWrap"></div>
|
||||||
<div class="report-summary" id="reportSummary"></div>
|
<div class="report-summary" id="reportSummary"></div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
19
styles.css
19
styles.css
@ -165,9 +165,13 @@ textarea.cell-input{resize:vertical;min-height:38px;line-height:1.4}
|
|||||||
.field-adder .btn{margin-top:6px}
|
.field-adder .btn{margin-top:6px}
|
||||||
|
|
||||||
/* ===== Reports ===== */
|
/* ===== Reports ===== */
|
||||||
.report-controls{display:flex;gap:14px;align-items:center;flex-wrap:wrap;margin-bottom:22px;background:#fff;border:1px solid var(--border);border-radius:var(--radius);padding:16px 20px;box-shadow:var(--shadow)}
|
.report-controls{display:flex;flex-direction:column;gap:14px;margin-bottom:22px;background:#fff;border:1px solid var(--border);border-radius:var(--radius);padding:16px 20px;box-shadow:var(--shadow)}
|
||||||
.report-controls label{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:600;color:var(--gray-700)}
|
.report-fields{display:flex;flex-direction:column;gap:8px}
|
||||||
.report-controls select.input{min-width:240px}
|
.rf-title{font-size:14px;font-weight:600;color:var(--gray-700)}
|
||||||
|
.rf-list{display:flex;flex-wrap:wrap;gap:8px}
|
||||||
|
.rf-item{display:inline-flex;align-items:center;gap:7px;font-size:14px;cursor:pointer;background:var(--gray-50);border:1px solid var(--border);padding:6px 12px;border-radius:8px;user-select:none}
|
||||||
|
.rf-item input{cursor:pointer;accent-color:var(--cyan-strong)}
|
||||||
|
.rf-item:has(input:checked){background:var(--cyan-50);border-color:var(--cyan-strong)}
|
||||||
.report-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:14px;margin-bottom:24px}
|
.report-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:14px;margin-bottom:24px}
|
||||||
.report-card{background:#fff;border:1px solid var(--border);border-radius:var(--radius);padding:18px 20px;box-shadow:var(--shadow)}
|
.report-card{background:#fff;border:1px solid var(--border);border-radius:var(--radius);padding:18px 20px;box-shadow:var(--shadow)}
|
||||||
.report-card h3{font-size:13px;text-transform:uppercase;letter-spacing:.04em;color:var(--gray-500);margin-bottom:14px}
|
.report-card h3{font-size:13px;text-transform:uppercase;letter-spacing:.04em;color:var(--gray-500);margin-bottom:14px}
|
||||||
@ -180,6 +184,15 @@ textarea.cell-input{resize:vertical;min-height:38px;line-height:1.4}
|
|||||||
.report-summary h3{font-size:13px;text-transform:uppercase;letter-spacing:.04em;color:var(--gray-500);margin-bottom:12px}
|
.report-summary h3{font-size:13px;text-transform:uppercase;letter-spacing:.04em;color:var(--gray-500);margin-bottom:12px}
|
||||||
.report-summary p{font-size:15px;line-height:1.7}
|
.report-summary p{font-size:15px;line-height:1.7}
|
||||||
.report-summary .big{font-size:22px;font-weight:800;color:var(--cyan-strong)}
|
.report-summary .big{font-size:22px;font-weight:800;color:var(--cyan-strong)}
|
||||||
|
.crosstab-wrap{margin-bottom:24px}
|
||||||
|
.crosstab{background:#fff;border:1px solid var(--border);border-radius:var(--radius);padding:18px 20px;box-shadow:var(--shadow)}
|
||||||
|
.crosstab h3{font-size:13px;text-transform:uppercase;letter-spacing:.04em;color:var(--gray-500);margin-bottom:14px}
|
||||||
|
.pivot{width:100%;border-collapse:collapse;font-size:13px}
|
||||||
|
.pivot th,.pivot td{padding:8px 12px;border:1px solid var(--border);text-align:center;white-space:nowrap}
|
||||||
|
.pivot th:first-child,.pivot td:first-child{text-align:left;font-weight:600}
|
||||||
|
.pivot thead th{background:var(--gray-50);font-size:12px;text-transform:uppercase;letter-spacing:.03em;color:var(--gray-500)}
|
||||||
|
.pivot .tot{font-weight:700;background:var(--gray-50)}
|
||||||
|
.pivot tr.total-row td{font-weight:700;background:var(--gray-100)}
|
||||||
|
|
||||||
@media (max-width:760px){
|
@media (max-width:760px){
|
||||||
.topbar h1{font-size:17px}
|
.topbar h1{font-size:17px}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user