clinical-dashboard/app.js

579 lines
27 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* =========================================================
Дашборд кафедры — логика
Данные хранятся в 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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
/* ---------- Старт ---------- */
// Резервная копия (JSON) — по двойному клику на кнопке "Экспорт CSV"
const _btnExport = document.getElementById('btnExport');
if(_btnExport){ _btnExport.title='Одинарный клик — CSV, двойной — резервная копия JSON'; _btnExport.addEventListener('dblclick', exportBackup); }
init();