clinical-dashboard/app.js

510 lines
23 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', ()=>openEditor());
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('btnSave').addEventListener('click', saveEditor);
document.getElementById('btnDelete').addEventListener('click', deleteEditor);
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.querySelectorAll('.modal-overlay').forEach(ov=>{
ov.addEventListener('click', e=>{ if(e.target===ov) ov.hidden=true; });
});
document.addEventListener('keydown', e=>{ if(e.key==='Escape'){ closeEditor(); 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(); renderTable(); buildFilterOptions(); }
/* ---------- Карточки статистики ---------- */
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');
// Колонки: ФИО, Степень, Категория, База, Сертификат, действия
head.innerHTML = `
<th>ФИО</th><th>Научная степень</th><th>Категория</th>
<th>Клиническая база</th><th>Сертификат</th><th class="actions">Действия</th>`;
const rows = filtered();
if(rows.length===0){
document.getElementById('teacherTable').style.display = state.teachers.length===0 ? 'none':'table';
empty.hidden = state.teachers.length!==0;
if(state.teachers.length!==0){
empty.hidden=false; empty.querySelector('p').textContent='Ничего не найдено по фильтрам.';
empty.querySelector('button').style.display='none';
} else {
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>
<td class="name-cell">${esc(t.fullName||'—')}</td>
<td>${degreeBadge(t.academicDegree)}</td>
<td>${t.category && t.category!=='Нет' ? `<span class="badge cyan">${esc(t.category)}</span>` : '<span class="badge">—</span>'}</td>
<td>${esc(t.clinicalBase||'—')}</td>
<td>${t.specialistCertificate ? `<span class="badge green">✓ есть</span>` : '<span class="badge">—</span>'}</td>
<td class="actions">
<div class="row-actions">
<button class="icon-btn" title="Редактировать" onclick="openEditor('${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 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;
});
}
function degreeBadge(d){
if(d==='Доктор наук') return '<span class="badge amber">Доктор наук</span>';
if(d==='Кандидат наук') return '<span class="badge green">Кандидат наук</span>';
if(d && d!=='Нет') return `<span class="badge">${esc(d)}</span>`;
return '<span class="badge">нет</span>';
}
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();
}
};
/* ---------- Редактор (модалка) ---------- */
window.openEditor = function(id){
state.editingId = id || null;
const t = id ? state.teachers.find(x=>x.id===id) : null;
document.getElementById('editorTitle').textContent = t ? 'Редактировать преподавателя' : 'Новый преподаватель';
document.getElementById('btnDelete').hidden = !t;
const form = document.getElementById('editorForm');
form.innerHTML = '<div class="form-grid">' + allFields().map(f=>fieldInput(f, t)).join('') + '</div>';
document.getElementById('editorModal').hidden=false;
};
function fieldInput(f, t){
const val = t ? (t[f.key] ?? '') : '';
const req = f.required ? '<span class="req">*</span>' : '';
const full = (f.type==='textarea') ? ' full' : '';
let control='';
if(f.type==='text' || f.type==='date' || f.type==='number'){
control = `<input class="input" name="${f.key}" type="${f.type}" value="${esc(val)}">`;
} else if(f.type==='textarea'){
control = `<textarea class="input" name="${f.key}">${esc(val)}</textarea>`;
} else if(f.type==='select'){
control = `<select class="input" name="${f.key}">
<option value="">— выберите —</option>
${(f.options||[]).map(o=>`<option ${o===val?'selected':''}>${esc(o)}</option>`).join('')}
</select>`;
} else if(f.type==='checkbox'){
control = `<label class="check"><input type="checkbox" name="${f.key}" ${val===true||val==='true'?'checked':''}> Да</label>`;
}
return `<div class="form-field${full}">
<label>${esc(f.label)}${req}</label>
${control}
</div>`;
}
function closeEditor(){ document.getElementById('editorModal').hidden=true; state.editingId=null; }
function saveEditor(){
const form = document.getElementById('editorForm');
const data = {};
let firstError=null;
for(const f of allFields()){
const el = form.elements[f.key];
if(!el){ data[f.key]=''; continue; }
if(f.type==='checkbox'){
data[f.key] = el.checked;
} else {
data[f.key] = el.value.trim();
}
if(f.required && !data[f.key] && data[f.key]!==false){
if(!firstError) firstError=f.label;
}
}
if(firstError){
alert('Заполните обязательное поле: '+firstError);
return;
}
if(state.editingId){
const idx = state.teachers.findIndex(t=>t.id===state.editingId);
state.teachers[idx] = { ...state.teachers[idx], ...data };
} else {
data.id = uid();
state.teachers.push(data);
}
save(); closeEditor(); renderAll();
}
function deleteEditor(){
if(!state.editingId) return;
const t = state.teachers.find(x=>x.id===state.editingId);
if(t && confirm(`Удалить преподавателя «${t.fullName}»?`)){
state.teachers = state.teachers.filter(x=>x.id!==state.editingId);
save(); closeEditor(); 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();
buildFilterOptions();
}
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 renderReports(){
const sel = document.getElementById('reportField');
// Заполняем список полей один раз
if(sel.options.length===0){
sel.innerHTML = allFields().filter(f=>f.type!=='textarea').map(f=>`<option value="${f.key}">${esc(f.label)}</option>`).join('');
}
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 = ['ФИО','Дата рождения','Образование','Научная степень','Категория','Сертификат специалиста','Членство в ассоциациях','Клиническая база']
.concat(state.customFields.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 data = JSON.parse(ev.target.result);
if(!Array.isArray(data.teachers)) throw new Error('Нет массива teachers');
if(!confirm(`Импортировать ${data.teachers.length} преподавателей? Текущие данные будут заменены.`)) return;
state.teachers = data.teachers;
state.customFields = data.customFields || [];
save(); renderAll(); buildFilterOptions();
// сбросить список полей отчёта
document.getElementById('reportField').innerHTML='';
alert('Импорт выполнен');
}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();