clinical-dashboard/app.js

507 lines
24 KiB
JavaScript
Raw 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('reportField').addEventListener('change', renderReports);
// Фильтры
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(); buildReportFieldOptions(); }
/* ---------- Карточки статистики ---------- */
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();
};
/* ---------- Отчёты ---------- */
function buildReportFieldOptions(){
const sel = document.getElementById('reportField');
const prev = sel.value;
sel.innerHTML = allFields().filter(f=>f.type!=='textarea').map(f=>`<option value="${f.key}">${esc(f.label)}</option>`).join('');
if(prev && [...sel.options].some(o=>o.value===prev)) sel.value=prev;
}
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 summary = document.getElementById('reportSummary');
if(state.teachers.length===0){
grid.innerHTML=''; summary.innerHTML='<p>Нет данных для отчёта.</p>'; return;
}
// Группировка
const groups = {};
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]);
const max = Math.max(...entries.map(e=>e[1]));
grid.innerHTML = `
<div class="report-card">
<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>`;
// Сводка
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>${allFields().length}</b> (из них пользовательских: ${state.customFields.length})</p>
<p class="hint" style="margin-top:10px">Выберите поле выше, чтобы сгруппировать данные. Отчёт можно скачать в CSV.</p>`;
}
/* ---------- Экспорт / Импорт ---------- */
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 key = document.getElementById('reportField').value;
const f = allFields().find(x=>x.key===key);
if(!f){ alert('Выберите поле'); return; }
const groups = {};
state.teachers.forEach(t=>{
let v = t[key];
if(f.type==='checkbox') v = v?'Да':'Нет';
if(v===undefined||v===null||v==='') v='(не указано)';
groups[v] = (groups[v]||0)+1;
});
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');
}
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();