/* ========================================================= Дашборд кафедры — логика Данные хранятся в 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 = ''+uniqueValues('academicDegree').map(v=>``).join(''); catSel.innerHTML = ''+uniqueValues('category').map(v=>``).join(''); baseSel.innerHTML = ''+uniqueValues('clinicalBase').map(v=>``).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 `
${label}
${value}
${sub||''}
`; } /* ---------- Таблица ---------- */ function renderTable(){ const head = document.getElementById('tableHead'); const body = document.getElementById('tableBody'); const empty = document.getElementById('emptyState'); // Колонки: ФИО, Степень, Категория, База, Сертификат, действия head.innerHTML = ` ФИОНаучная степеньКатегория Клиническая базаСертификатДействия`; 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=>` ${esc(t.fullName||'—')} ${degreeBadge(t.academicDegree)} ${t.category && t.category!=='Нет' ? `${esc(t.category)}` : ''} ${esc(t.clinicalBase||'—')} ${t.specialistCertificate ? `✓ есть` : ''}
`).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 'Доктор наук'; if(d==='Кандидат наук') return 'Кандидат наук'; if(d && d!=='Нет') return `${esc(d)}`; return 'нет'; } 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 = '
' + allFields().map(f=>fieldInput(f, t)).join('') + '
'; document.getElementById('editorModal').hidden=false; }; function fieldInput(f, t){ const val = t ? (t[f.key] ?? '') : ''; const req = f.required ? '*' : ''; const full = (f.type==='textarea') ? ' full' : ''; let control=''; if(f.type==='text' || f.type==='date' || f.type==='number'){ control = ``; } else if(f.type==='textarea'){ control = ``; } else if(f.type==='select'){ // Редактируемый комбобокс: можно выбрать из подсказок или ввести своё значение control = ` ${(f.options||[]).map(o=>``; } else if(f.type==='checkbox'){ control = ``; } return `
${control}
`; } 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=>`
  • ${esc(f.label)} ${f.required?'*':''} ${fieldTypeName(f.type)}${f.locked?' · базовое':''}
    ${f.locked ? '🔒' : ``}
  • `).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=>``).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='

    Нет данных для отчёта.

    '; 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 = `

    ${esc(f?f.label:key)} — распределение

    ${entries.map(([label,count])=>`
    ${esc(label)} ${count}
    `).join('')}
    `; // Сводка 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 = `

    Сводка

    Всего преподавателей: ${total}

    С научной степенью: ${withDegree} (${pct}%)

    Полей для параметров: ${allFields().length} (из них пользовательских: ${state.customFields.length})

    Выберите поле выше, чтобы сгруппировать данные. Отчёт можно скачать в CSV.

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