579 lines
27 KiB
JavaScript
579 lines
27 KiB
JavaScript
/* =========================================================
|
||
Дашборд кафедры — логика
|
||
Данные хранятся в localStorage (без сервера/БД)
|
||
========================================================= */
|
||
|
||
const STORE_KEY = 'clinical_dashboard_v1';
|
||
|
||
// ---- Базовые ( неудаляемые ) поля ----
|
||
const BASE_FIELDS = [
|
||
{ key:'fullName', label:'ФИО', type:'text', required:true, locked:true },
|
||
{ key:'birthDate', label:'Дата рождения', type:'date', locked:true },
|
||
{ key:'education', label:'Образование', type:'textarea',locked:true },
|
||
{ key:'academicDegree', label:'Научная степень', type:'select', options:['Нет','Кандидат наук','Доктор наук'], locked:true },
|
||
{ key:'category', label:'Категория', type:'select', options:['Нет','Вторая','Первая','Высшая'], locked:true },
|
||
{ key:'specialistCertificate', label:'Сертификат специалиста', type:'text', locked:true },
|
||
{ key:'associations', label:'Членство в ассоциациях', type:'text', locked:true },
|
||
{ key:'clinicalBase', label:'Клиническая база', type:'text', locked:true },
|
||
];
|
||
|
||
// ---- Состояние ----
|
||
let state = {
|
||
teachers: [],
|
||
customFields: [],
|
||
editingId: null,
|
||
};
|
||
|
||
let filters = { search:'', degree:'', category:'', base:'' };
|
||
|
||
/* ---------- Инициализация ---------- */
|
||
function init(){
|
||
load();
|
||
if(state.teachers.length===0 && state.customFields.length===0){
|
||
seedDemo();
|
||
}
|
||
bindEvents();
|
||
buildFilterOptions();
|
||
renderAll();
|
||
}
|
||
|
||
function load(){
|
||
try{
|
||
const raw = localStorage.getItem(STORE_KEY);
|
||
if(raw){
|
||
const data = JSON.parse(raw);
|
||
state.teachers = data.teachers || [];
|
||
state.customFields = data.customFields || [];
|
||
}
|
||
}catch(e){ console.warn('load failed', e); }
|
||
}
|
||
|
||
function save(){
|
||
localStorage.setItem(STORE_KEY, JSON.stringify({
|
||
teachers: state.teachers,
|
||
customFields: state.customFields,
|
||
}));
|
||
}
|
||
|
||
function allFields(){ return BASE_FIELDS.concat(state.customFields); }
|
||
|
||
function uid(){ return 't_' + Math.random().toString(36).slice(2,10) + Date.now().toString(36).slice(-4); }
|
||
|
||
/* ---------- Демо-данные при первом запуске ---------- */
|
||
function seedDemo(){
|
||
state.teachers = [
|
||
{ id:uid(), fullName:'Ахметова Гульнара Сериковна', birthDate:'1975-03-12',
|
||
education:'КазНМУ им. С.Асфендиярова, лечебное дело, 1998; интернатура по кардиологии',
|
||
academicDegree:'Доктор наук', category:'Высшая',
|
||
specialistCertificate:'№АА1122334, 2022, Кардиология',
|
||
associations:'Ассоциация кардиологов РК, ESC',
|
||
clinicalBase:'Городской кардиологический центр' },
|
||
{ id:uid(), fullName:'Оспанов Тимур Бакытович', birthDate:'1982-11-05',
|
||
education:'КГМУ, педиатрия, 2005; резидентура по неонатологии',
|
||
academicDegree:'Кандидат наук', category:'Первая',
|
||
specialistCertificate:'№АА5566778, 2023, Неонатология',
|
||
associations:'Ассоциация неонатологов РК',
|
||
clinicalBase:'Перинатальный центр №1' },
|
||
{ id:uid(), fullName:'Жумабаева Айгерим Нурлановна', birthDate:'1989-07-21',
|
||
education:'КазНМУ, общая медицина, 2012; резидентура по терапии',
|
||
academicDegree:'Нет', category:'Вторая',
|
||
specialistCertificate:'№АА9988776, 2024, Терапия',
|
||
associations:'',
|
||
clinicalBase:'ГКП Поликлиника №12' },
|
||
];
|
||
save();
|
||
}
|
||
|
||
/* ---------- События ---------- */
|
||
function bindEvents(){
|
||
// Табы
|
||
document.querySelectorAll('.tab').forEach(t=>{
|
||
t.addEventListener('click', ()=>{
|
||
document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));
|
||
document.querySelectorAll('.tabpanel').forEach(x=>x.classList.remove('active'));
|
||
t.classList.add('active');
|
||
document.getElementById('tab-'+t.dataset.tab).classList.add('active');
|
||
if(t.dataset.tab==='reports') renderReports();
|
||
});
|
||
});
|
||
|
||
// Кнопки
|
||
document.getElementById('btnAdd').addEventListener('click', addRow);
|
||
document.getElementById('btnFields').addEventListener('click', openFields);
|
||
document.getElementById('btnExport').addEventListener('click', exportCSV);
|
||
document.getElementById('btnImport').addEventListener('click', ()=>document.getElementById('importFile').click());
|
||
document.getElementById('importFile').addEventListener('change', importJSON);
|
||
document.getElementById('btnAddField').addEventListener('click', addCustomField);
|
||
document.getElementById('newFieldType').addEventListener('change', e=>{
|
||
document.getElementById('newFieldOptions').hidden = e.target.value!=='select';
|
||
});
|
||
document.getElementById('btnReportCsv').addEventListener('click', exportReportCSV);
|
||
|
||
// Фильтры
|
||
document.getElementById('search').addEventListener('input', e=>{ filters.search=e.target.value.toLowerCase(); renderTable(); });
|
||
document.getElementById('filterDegree').addEventListener('change', e=>{ filters.degree=e.target.value; renderTable(); });
|
||
document.getElementById('filterCategory').addEventListener('change', e=>{ filters.category=e.target.value; renderTable(); });
|
||
document.getElementById('filterBase').addEventListener('change', e=>{ filters.base=e.target.value; renderTable(); });
|
||
|
||
// Инлайн-редактирование таблицы (делегирование событий)
|
||
document.getElementById('tableBody').addEventListener('input', onCellInput);
|
||
document.getElementById('tableBody').addEventListener('change', onCellInput);
|
||
|
||
// Закрытие модалок по клику на фон
|
||
document.querySelectorAll('.modal-overlay').forEach(ov=>{
|
||
ov.addEventListener('click', e=>{ if(e.target===ov) ov.hidden=true; });
|
||
});
|
||
document.addEventListener('keydown', e=>{ if(e.key==='Escape'){ closeFields(); } });
|
||
}
|
||
|
||
/* ---------- Выпуски фильтров ---------- */
|
||
function buildFilterOptions(){
|
||
const degreeSel = document.getElementById('filterDegree');
|
||
const catSel = document.getElementById('filterCategory');
|
||
const baseSel = document.getElementById('filterBase');
|
||
degreeSel.innerHTML = '<option value="">Все степени</option>'+uniqueValues('academicDegree').map(v=>`<option>${esc(v)}</option>`).join('');
|
||
catSel.innerHTML = '<option value="">Все категории</option>'+uniqueValues('category').map(v=>`<option>${esc(v)}</option>`).join('');
|
||
baseSel.innerHTML = '<option value="">Все базы</option>'+uniqueValues('clinicalBase').map(v=>`<option>${esc(v)}</option>`).join('');
|
||
}
|
||
function uniqueValues(key){
|
||
const set = new Set();
|
||
state.teachers.forEach(t=>{ if(t[key]) set.add(t[key]); });
|
||
return [...set].sort();
|
||
}
|
||
|
||
/* ---------- Рендер всего ---------- */
|
||
function renderAll(){ renderStats(); renderDatalists(); renderTable(); buildFilterOptions(); renderReportFieldsList(); }
|
||
|
||
/* ---------- Карточки статистики ---------- */
|
||
function renderStats(){
|
||
const total = state.teachers.length;
|
||
const doctors = state.teachers.filter(t=>t.academicDegree==='Доктор наук').length;
|
||
const candidates = state.teachers.filter(t=>t.academicDegree==='Кандидат наук').length;
|
||
const withDegree = doctors+candidates;
|
||
const withCert = state.teachers.filter(t=>t.specialistCertificate && t.specialistCertificate.trim()).length;
|
||
const inAssoc = state.teachers.filter(t=>t.associations && t.associations.trim()).length;
|
||
|
||
document.getElementById('statsGrid').innerHTML = `
|
||
${statCard('Всего преподавателей', total, 'всего на кафедре','accent')}
|
||
${statCard('С научной степенью', withDegree, `${candidates} кандидат · ${doctors} доктор`,'green')}
|
||
${statCard('Докторов наук', doctors, 'высшая квалификация','amber')}
|
||
${statCard('С сертификатом', withCert, 'специалиста','accent')}
|
||
${statCard('Члены ассоциаций', inAssoc, 'проф. членство','green')}
|
||
`;
|
||
}
|
||
function statCard(label,value,sub,cls){
|
||
return `<div class="stat-card ${cls||''}"><div class="label">${label}</div><div class="value">${value}</div><div class="sub">${sub||''}</div></div>`;
|
||
}
|
||
|
||
/* ---------- Таблица (инлайн-редактирование) ---------- */
|
||
function renderTable(){
|
||
const head = document.getElementById('tableHead');
|
||
const body = document.getElementById('tableBody');
|
||
const empty = document.getElementById('emptyState');
|
||
const fields = allFields();
|
||
|
||
// Заголовок: все поля + колонка действий
|
||
head.innerHTML = fields.map(f=>`<th>${esc(f.label)}${f.required?' <span class="req">*</span>':''}</th>`).join('') + '<th class="actions"></th>';
|
||
|
||
const rows = filtered();
|
||
if(rows.length===0){
|
||
document.getElementById('teacherTable').style.display = state.teachers.length===0 ? 'none':'table';
|
||
if(state.teachers.length!==0){
|
||
empty.hidden=false; empty.querySelector('p').textContent='Ничего не найдено по фильтрам.';
|
||
empty.querySelector('button').style.display='none';
|
||
} else {
|
||
empty.hidden=false; empty.querySelector('p').textContent='Пока нет ни одного преподавателя.';
|
||
empty.querySelector('button').style.display='';
|
||
}
|
||
document.getElementById('countPill').textContent='';
|
||
body.innerHTML='';
|
||
return;
|
||
}
|
||
document.getElementById('teacherTable').style.display='table';
|
||
empty.hidden=true;
|
||
|
||
body.innerHTML = rows.map(t=>`
|
||
<tr data-id="${t.id}">
|
||
${fields.map(f=>`<td>${cellInput(f,t)}</td>`).join('')}
|
||
<td class="actions">
|
||
<div class="row-actions">
|
||
<button class="icon-btn" title="Скачать анкету (JSON)" onclick="exportTeacher('${t.id}')">⤓</button>
|
||
<button class="icon-btn" title="Удалить" onclick="removeTeacher('${t.id}')">🗑</button>
|
||
</div>
|
||
</td>
|
||
</tr>`).join('');
|
||
|
||
document.getElementById('countPill').textContent = `Показано: ${rows.length} из ${state.teachers.length}`;
|
||
}
|
||
|
||
// Поле ввода прямо в ячейке таблицы
|
||
function cellInput(f, t){
|
||
const val = t[f.key] ?? '';
|
||
const name = f.key;
|
||
if(f.type==='textarea')
|
||
return `<textarea class="cell-input" name="${name}" rows="2" placeholder="—">${esc(val)}</textarea>`;
|
||
if(f.type==='date')
|
||
return `<input class="cell-input" type="date" name="${name}" value="${esc(val)}">`;
|
||
if(f.type==='number')
|
||
return `<input class="cell-input" type="number" name="${name}" value="${esc(val)}" placeholder="—">`;
|
||
if(f.type==='checkbox')
|
||
return `<input class="cell-input check" type="checkbox" name="${name}" ${val===true||val==='true'?'checked':''}>`;
|
||
if(f.type==='select')
|
||
return `<input class="cell-input" list="dl_${name}" name="${name}" value="${esc(val)}" placeholder="выберите или введите">`;
|
||
return `<input class="cell-input" type="text" name="${name}" value="${esc(val)}" placeholder="—">`;
|
||
}
|
||
|
||
// Один <datalist> на каждое поле-список (без дублей по строкам)
|
||
function renderDatalists(){
|
||
const box = document.getElementById('datalists');
|
||
box.innerHTML = allFields().filter(f=>f.type==='select')
|
||
.map(f=>`<datalist id="dl_${f.key}">${(f.options||[]).map(o=>`<option value="${esc(o)}">`).join('')}</datalist>`).join('');
|
||
}
|
||
|
||
// Автосохранение при вводе в любую ячейку
|
||
let _saveTimer=null, _statTimer=null;
|
||
function onCellInput(e){
|
||
const el = e.target;
|
||
if(!el.classList || !el.classList.contains('cell-input')) return;
|
||
const tr = el.closest('tr'); if(!tr) return;
|
||
const id = tr.dataset.id;
|
||
const key = el.name;
|
||
const t = state.teachers.find(x=>x.id===id); if(!t) return;
|
||
t[key] = (el.type==='checkbox') ? el.checked : el.value;
|
||
clearTimeout(_saveTimer); _saveTimer=setTimeout(save, 350);
|
||
clearTimeout(_statTimer); _statTimer=setTimeout(()=>{
|
||
renderStats();
|
||
if(document.getElementById('tab-reports').classList.contains('active')) renderReports();
|
||
}, 500);
|
||
}
|
||
|
||
// Добавить пустую строку-преподавателя
|
||
function addRow(){
|
||
const t = { id: uid() };
|
||
allFields().forEach(f=>{ t[f.key] = (f.type==='checkbox') ? false : ''; });
|
||
state.teachers.push(t);
|
||
// сбросить фильтры, чтобы новая строка была видна
|
||
filters = { search:'', degree:'', category:'', base:'' };
|
||
document.getElementById('search').value='';
|
||
['filterDegree','filterCategory','filterBase'].forEach(id=>document.getElementById(id).value='');
|
||
save(); buildFilterOptions(); renderTable(); renderStats();
|
||
const tr = document.querySelector(`tr[data-id="${t.id}"]`);
|
||
if(tr){ const inp = tr.querySelector('[name="fullName"]'); if(inp) inp.focus(); }
|
||
}
|
||
|
||
// Скачать анкету одного преподавателя (для отправки администратору)
|
||
window.exportTeacher = function(id){
|
||
const t = state.teachers.find(x=>x.id===id);
|
||
if(!t) return;
|
||
const data = { teachers:[{ ...t }], customFields: state.customFields };
|
||
download(JSON.stringify(data, null, 2), `anketa_${(t.fullName||'prepodavatel').replace(/\s+/g,'_')}.json`, 'application/json');
|
||
};
|
||
|
||
|
||
function filtered(){
|
||
return state.teachers.filter(t=>{
|
||
if(filters.degree && t.academicDegree!==filters.degree) return false;
|
||
if(filters.category && t.category!==filters.category) return false;
|
||
if(filters.base && t.clinicalBase!==filters.base) return false;
|
||
if(filters.search){
|
||
const hay = [t.fullName,t.education,t.clinicalBase,t.associations,t.specialistCertificate,t.academicDegree,t.category]
|
||
.filter(Boolean).join(' ').toLowerCase();
|
||
if(!hay.includes(filters.search)) return false;
|
||
}
|
||
return true;
|
||
});
|
||
}
|
||
|
||
window.removeTeacher = function(id){
|
||
const t = state.teachers.find(x=>x.id===id);
|
||
if(!t) return;
|
||
if(confirm(`Удалить преподавателя «${t.fullName}»?`)){
|
||
state.teachers = state.teachers.filter(x=>x.id!==id);
|
||
save(); renderAll();
|
||
}
|
||
};
|
||
|
||
/* ---------- Настройка полей ---------- */
|
||
function openFields(){
|
||
renderFieldList();
|
||
document.getElementById('fieldsModal').hidden=false;
|
||
}
|
||
function closeFields(){ document.getElementById('fieldsModal').hidden=true; }
|
||
|
||
function renderFieldList(){
|
||
const list = document.getElementById('fieldList');
|
||
list.innerHTML = allFields().map(f=>`
|
||
<li class="field-item ${f.locked?'locked':''}">
|
||
<div class="meta">
|
||
<span class="lbl">${esc(f.label)} ${f.required?'<span class="req">*</span>':''}</span>
|
||
<span class="typ">${fieldTypeName(f.type)}${f.locked?' · базовое':''}</span>
|
||
</div>
|
||
${f.locked
|
||
? '<span class="del icon-btn" title="Нельзя удалить">🔒</span>'
|
||
: `<button class="icon-btn del" title="Удалить" onclick="removeField('${f.key}')">🗑</button>`}
|
||
</li>`).join('');
|
||
}
|
||
|
||
function fieldTypeName(t){
|
||
return {text:'Текст',date:'Дата',number:'Число',select:'Список',textarea:'Длинный текст',checkbox:'Да/Нет'}[t]||t;
|
||
}
|
||
|
||
function addCustomField(){
|
||
const label = document.getElementById('newFieldLabel').value.trim();
|
||
const type = document.getElementById('newFieldType').value;
|
||
const optsRaw = document.getElementById('newFieldOptions').value.trim();
|
||
if(!label){ alert('Введите название поля'); return; }
|
||
const key = 'c_'+label.toLowerCase().replace(/[^a-zа-я0-9]+/gi,'_').slice(0,24)+'_'+Math.random().toString(36).slice(2,5);
|
||
const f = { key, label, type, locked:false };
|
||
if(type==='select'){
|
||
f.options = optsRaw ? optsRaw.split(',').map(s=>s.trim()).filter(Boolean) : [];
|
||
if(f.options.length===0){ alert('Укажите варианты через запятую'); return; }
|
||
}
|
||
state.customFields.push(f);
|
||
save();
|
||
document.getElementById('newFieldLabel').value='';
|
||
document.getElementById('newFieldOptions').value='';
|
||
document.getElementById('newFieldOptions').hidden=true;
|
||
renderFieldList();
|
||
renderAll();
|
||
}
|
||
|
||
window.removeField = function(key){
|
||
const f = state.customFields.find(x=>x.key===key);
|
||
if(!f) return;
|
||
if(!confirm(`Удалить поле «${f.label}»? Значения этого поля у всех преподавателей будут потеряны.`)) return;
|
||
state.customFields = state.customFields.filter(x=>x.key!==key);
|
||
state.teachers.forEach(t=>{ delete t[key]; });
|
||
save(); renderFieldList(); renderAll();
|
||
};
|
||
|
||
/* ---------- Отчёты (многопараметрические) ---------- */
|
||
let reportSelected = new Set(['academicDegree','category']); // выбранные по умолчанию
|
||
|
||
function renderReportFieldsList(){
|
||
const box = document.getElementById('reportFieldsList');
|
||
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(){
|
||
const grid = document.getElementById('reportGrid');
|
||
const cross = document.getElementById('crosstabWrap');
|
||
const summary = document.getElementById('reportSummary');
|
||
|
||
if(state.teachers.length===0){
|
||
grid.innerHTML=''; cross.innerHTML=''; summary.innerHTML='<p>Нет данных для отчёта.</p>'; return;
|
||
}
|
||
|
||
const fields = allFields();
|
||
const selected = fields.filter(f=>reportSelected.has(f.key) && f.type!=='textarea');
|
||
|
||
// 1. Карточки распределения по каждому выбранному параметру
|
||
grid.innerHTML = selected.length
|
||
? selected.map(distributionCard).join('')
|
||
: '<p class="hint">Отметьте параметры выше, чтобы построить отчёт.</p>';
|
||
|
||
// 2. Перекрёстная таблица по первым двум выбранным параметрам
|
||
cross.innerHTML = (selected.length>=2) ? crosstabHTML(selected[0], selected[1]) : '';
|
||
|
||
// 3. Сводка
|
||
const total = state.teachers.length;
|
||
const withDegree = state.teachers.filter(t=>t.academicDegree && t.academicDegree!=='Нет').length;
|
||
const pct = total?Math.round(withDegree/total*100):0;
|
||
summary.innerHTML = `
|
||
<h3>Сводка</h3>
|
||
<p>Всего преподавателей: <span class="big">${total}</span></p>
|
||
<p>С научной степенью: <b>${withDegree}</b> (${pct}%)</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>`;
|
||
}
|
||
|
||
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(){
|
||
if(state.teachers.length===0){ alert('Нет данных для экспорта'); return; }
|
||
const fields = allFields();
|
||
const header = fields.map(f=>f.label);
|
||
const rows = state.teachers.map(t=>{
|
||
return fields.map(f=>{
|
||
let v = t[f.key] ?? '';
|
||
if(f.type==='checkbox') v = v?'Да':'Нет';
|
||
return csvCell(v);
|
||
});
|
||
});
|
||
const csv = '\uFEFF' + [header.map(csvCell).join(';')].concat(rows.map(r=>r.join(';'))).join('\r\n');
|
||
download(csv, 'prepodavateli.csv', 'text/csv;charset=utf-8');
|
||
}
|
||
|
||
function exportReportCSV(){
|
||
const fields = allFields();
|
||
const selected = fields.filter(f=>reportSelected.has(f.key) && f.type!=='textarea');
|
||
if(selected.length===0){ alert('Отметьте хотя бы один параметр отчёта'); return; }
|
||
if(state.teachers.length===0){ alert('Нет данных для отчёта'); return; }
|
||
|
||
const lines = [];
|
||
selected.forEach(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]);
|
||
lines.push(`Отчёт по параметру: ${f.label}`);
|
||
lines.push(`${csvCell('Значение')};${csvCell('Количество')}`);
|
||
entries.forEach(([v,c])=>lines.push(`${csvCell(v)};${c}`));
|
||
lines.push(''); // пустая строка-разделитель
|
||
});
|
||
|
||
// Перекрёстная таблица по первым двум параметрам
|
||
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){
|
||
const s = String(v==null?'':v);
|
||
if(/[;"\n]/.test(s)) return '"'+s.replace(/"/g,'""')+'"';
|
||
return s;
|
||
}
|
||
|
||
function importJSON(e){
|
||
const file = e.target.files[0];
|
||
if(!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = ev=>{
|
||
try{
|
||
const parsed = JSON.parse(ev.target.result);
|
||
// Нормализация: принимаем {teachers:[...]}, [...] или один объект-преподаватель
|
||
let incoming = [], extraFields = [];
|
||
if(Array.isArray(parsed)) incoming = parsed;
|
||
else if(Array.isArray(parsed.teachers)) { incoming = parsed.teachers; extraFields = parsed.customFields || []; }
|
||
else if(parsed && typeof parsed==='object') incoming = [parsed];
|
||
else throw new Error('Не распознан формат файла');
|
||
incoming = incoming.filter(t=>t && typeof t==='object');
|
||
if(incoming.length===0) throw new Error('В файле нет данных преподавателей');
|
||
|
||
// Слияние пользовательских полей
|
||
extraFields.forEach(f=>{ if(f && f.key && !allFields().some(x=>x.key===f.key)) state.customFields.push(f); });
|
||
|
||
// Добавляем преподавателей, пропуская дубли по ФИО
|
||
const existing = new Set(state.teachers.map(t=>(t.fullName||'').trim().toLowerCase()));
|
||
let added = 0, skipped = 0;
|
||
incoming.forEach(t=>{
|
||
const name = (t.fullName||'').trim().toLowerCase();
|
||
if(name && existing.has(name)){ skipped++; return; }
|
||
const obj = { id: uid() };
|
||
allFields().forEach(f=>{ obj[f.key] = t[f.key] ?? (f.type==='checkbox'? false : ''); });
|
||
state.teachers.push(obj);
|
||
existing.add(name);
|
||
added++;
|
||
});
|
||
|
||
save(); renderAll();
|
||
alert(`Добавлено: ${added}${skipped?`, пропущено дублей по ФИО: ${skipped}`:''}.`);
|
||
}catch(err){ alert('Ошибка импорта: '+err.message); }
|
||
};
|
||
reader.readAsText(file);
|
||
e.target.value='';
|
||
}
|
||
|
||
function download(content, filename, type){
|
||
const blob = new Blob([content], {type});
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href=url; a.download=filename; a.click();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
// Кнопка экспорта всей БД в JSON (резервная копия) — добавим в экспорт-меню через длинное нажатие не нужно; сделаем просто
|
||
function exportBackup(){
|
||
download(JSON.stringify({teachers:state.teachers, customFields:state.customFields}, null, 2), 'backup_dashboard.json', 'application/json');
|
||
}
|
||
|
||
/* ---------- Утилиты ---------- */
|
||
function esc(s){
|
||
return String(s==null?'':s).replace(/[&<>"']/g, c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||
}
|
||
|
||
/* ---------- Старт ---------- */
|
||
// Резервная копия (JSON) — по двойному клику на кнопке "Экспорт CSV"
|
||
const _btnExport = document.getElementById('btnExport');
|
||
if(_btnExport){ _btnExport.title='Одинарный клик — CSV, двойной — резервная копия JSON'; _btnExport.addEventListener('dblclick', exportBackup); }
|
||
|
||
init();
|