Интерфейс врача: отчёты (сегментация+охват), протокол светофор, каталог базисной терапии и назначение пациенту

This commit is contained in:
Dauren777 2026-06-26 06:53:00 +00:00
parent 175b6181b5
commit 54b9bf8816

View File

@ -255,6 +255,7 @@ function seed(){
}
return {
role:"patient",
staffTab:"patients",
currentPatient:"p1",
patients:[
{
@ -339,6 +340,75 @@ var ZONE_TXT = {
red:{t:"Ухудшение", d:"Срочно! Выполните рекомендации и вызовите скорую при необходимости."}
};
/* ============ ПРОТОКОЛ «СВЕТОФОР» (расширенные действия) ============ */
var ZONE_PROTOCOL = {
green:{
title:"🟢 Зелёная зона — стабилен",
crit:"SpO₂ ≥ 95% · ЧДД 1220/мин · ЧСС 60100 уд/мин · CAT не растёт · нет гнойной мокроты",
patient:["Продолжать базисную терапию строго по схеме","Вести дневник 23 раза в день","Принимать препараты по расписанию, не пропускать","Дыхательная гимнастика и регулярная физическая активность","Вакцинация (грипп, пневмококк) по графику"],
staff:["Рутинное наблюдение, просмотр динамики 12 раза в неделю","Контроль adherence и техники ингаляции","Плановый осмотр 1 раз в 13 месяца, спирометрия, CAT","Обучение и поддержка самопомощи"],
esc:"Эскалации не требуется"
},
yellow:{
title:"🟡 Жёлтая зона — внимание",
crit:"SpO₂ 9094% · ЧДД 2124/мин · ЧСС 101110 уд/мин · CAT вырос на ≥2 балла · нарастание одышки",
patient:["Применить спасательный бронхолитик (сальбутамол 24 вдоха / небулайзер)","Проверить и при необходимости усилить О₂ по схеме","Контроль SpO₂ каждые 1530 минут","Усилить контроль до 34 замеров/день","Связаться с медсестрой в течение дня","Начать резервный ГКС/антибиотик по плану врача при гнойной мокроте"],
staff:["Связаться с пациентом в течение дня","Оценить adherence и технику ингаляции","Коррекция терапии: усилить бронхолитик, рассмотреть ИГКС","Контроль через 2448 часов","Информировать лечащего врача при отсутствии эффекта"],
esc:"Нет эффекта за 2448ч → красная зона, рассмотреть госпитализацию"
},
red:{
title:"🔴 Красная зона — ухудшение",
crit:"SpO₂ < 90% · ЧДД > 24/мин · ЧСС > 110 уд/мин · резкая одышка в покое · гнойная мокрота · лихорадка",
patient:["Увеличить поток О₂ по схеме","Бронхолитик через небулайзер/ДАИ (сальбутамол ± ипратропий)","Положение ортопноэ, дыхание через сжатые губы","Старт системной терапии по плану: ГКС ± антибиотик","Вызвать скорую при SpO₂<88% >5мин или нарастании одышки"],
staff:["Срочный контакт с пациентом, решение о госпитализации/вызове бригады","Назначить/скорректировать системную терапию (ГКС, антибиотики, О₂)","Контроль через 14 часа","Передача информации лечащему врачу и оформление вмешательства","Подготовка к госпитализации при нарастании РДС"],
esc:"Экстренная госпитализация: SpO₂<88%, ЧДД>30, спутанность сознания, цианоз, загрудинные боли"
}
};
/* ============ КАТАЛОГ БАЗИСНОЙ ТЕРАПИИ ХОБЛ (для назначения врачом) ============ */
var MED_CATALOG = [
{grp:"Купирование (короткодействующие)", items:[
{id:"salbutamol", name:"Сальбутамол (ДАИ/небулайзер)", dt:["по потребности"]},
{id:"fenoterol", name:"Фенотерол (Беротек)", dt:["по потребности"]},
{id:"ipratropium", name:"Ипратропия бромид (Атровент)", dt:["по потребности"]},
{id:"ipra_feno", name:"Ипратропий/Фенотерол (Беродуал)", dt:["по потребности"]}
]},
{grp:"ЛАМА (длиннодействующие М-холинолитики)", items:[
{id:"tiotropium", name:"Тиотропий (Спирива)", dt:["08:00"]},
{id:"glycopyrronium", name:"Гликопирроний (Сибри)", dt:["08:00"]},
{id:"aclidinium", name:"Аклидиний (Эклира)", dt:["08:00","20:00"]},
{id:"umeclidinium", name:"Умеклидиний (Инкруз)", dt:["08:00"]}
]},
{grp:"ЛАБА (длиннодействующие β2-агонисты)", items:[
{id:"indacaterol", name:"Индакатерол (Онбрез)", dt:["08:00"]},
{id:"olodaterol", name:"Олодатерол (Стриворди)", dt:["08:00"]},
{id:"salmeterol", name:"Сальметерол (Серевент)", dt:["08:00","20:00"]},
{id:"formoterol", name:"Формотерол (Форадил)", dt:["08:00","20:00"]}
]},
{grp:"ЛАМА/ЛАБА (комбинированные)", items:[
{id:"gly_ind", name:"Гликопирроний/Индакатерол (Ультибро)", dt:["08:00"]},
{id:"tio_olo", name:"Тиотропий/Олодатерол (Спиольто)", dt:["08:00"]},
{id:"ume_vil", name:"Умеклидиний/Вилинтерол (Аноро)", dt:["08:00"]}
]},
{grp:"ИГКС/ЛАБА (комбинированные)", items:[
{id:"bud_fm", name:"Будесонид/Формотерол (Симбикорт)", dt:["08:00","20:00"]},
{id:"flu_sal", name:"Флутиказон/Сальметерол (Серетид)", dt:["08:00","20:00"]},
{id:"flu_vil", name:"Флутиказон/Вилантерол (Релвар)", dt:["08:00"]},
{id:"bec_fm", name:"Беклометазон/Формотерол (Фостер)", dt:["08:00","20:00"]}
]},
{grp:"Тройная терапия (ИГКС/ЛАБА/ЛАМА)", items:[
{id:"flu_ume_vil", name:"Флутиказон/Умеклидиний/Вилантерол (Трео)", dt:["08:00"]},
{id:"bec_fm_gly", name:"Беклометазон/Формотерол/Гликопирроний (Trimbow)", dt:["08:00","20:00"]}
]},
{grp:"Прочее (ФДЭ-4, муколитики, макролиды, О₂)", items:[
{id:"roflumilast", name:"Рофлумиласт (Даксас) — ФДЭ-4", dt:["21:00"]},
{id:"nac", name:"N-ацетилцистеин (муколитик)", dt:["08:00","20:00"]},
{id:"erdosteine", name:"Эрдостеин (муколитик)", dt:["08:00","20:00"]},
{id:"azithromycin", name:"Азитромицин (профилактика обострений)", dt:["08:00"]},
{id:"o2_therapy", name:"Длительная О₂-терапия", dt:["15ч/сут 2л/мин"]}
]}
];
/* ============ CAT ВОПРОСЫ ============ */
var CAT_Q = [
{q:"Кашель", a:"Я никогда не кашляю", b:"Я постоянно кашляю"},
@ -354,7 +424,7 @@ function catLevel(s){ if(s<=9) return {t:"низкое влияние",c:"green"
/* ============ УТИЛИТЫ ============ */
function $(sel,root){ return (root||document).querySelector(sel); }
function el(tag,cls,html,opts){ var e=document.createElement(tag); if(cls)e.className=cls; if(html!=null && typeof html==="string")e.innerHTML=html; if(opts){ if(opts.style)e.setAttribute("style",opts.style); if(opts.text!=null)e.textContent=opts.text; } return e; }
function el(tag,cls,html,opts){ var e=document.createElement(tag); if(cls)e.className=cls; if(html!=null && typeof html==="string")e.innerHTML=html; else if(html!=null && typeof html==="object" && !opts){opts=html;} if(opts){ if(opts.style)e.setAttribute("style",opts.style); if(opts.text!=null)e.textContent=opts.text; } return e; }
function nowHHMM(){ var d=new Date(); return pad(d.getHours())+":"+pad(d.getMinutes()); }
function pad(n){ return (n<10?"0":"")+n; }
function fmtTime(ts){ var d=new Date(ts); return pad(d.getDate())+"."+pad(d.getMonth()+1)+" "+pad(d.getHours())+":"+pad(d.getMinutes()); }
@ -838,6 +908,68 @@ function saveEntry(p){
toast("Запись сохранена. Зона: "+ZONE_TXT[z].t);
}
/* ============ МОДАЛКА: НАЗНАЧЕНИЕ БАЗИСНОЙ ТЕРАПИИ ============ */
function openPrescribeModal(p){
var m=$("#modal"); m.innerHTML="";
var head=el("div","modal-head");
head.innerHTML='<h3>Назначить базисную терапию · '+p.name+'</h3>';
var close=el("button","modal-close","×"); close.onclick=closeModal;
head.appendChild(close); m.appendChild(head);
var body=el("div","modal-body");
body.appendChild(el("div","muted","Выберите препараты из каталога. Время приёма можно изменить. Уже назначенные отмечены.",{"style":"margin-bottom:14px"}));
MED_CATALOG.forEach(function(grp){
body.appendChild(el("div","section-title",grp.grp));
grp.items.forEach(function(it){
var exists=p.meds.some(function(mm){return mm.id===it.id;});
var row=el("div","med");
row.dataset.medId=it.id; row.dataset.medName=it.name;
var left=el("div","med-info");
left.innerHTML='<div class="med-name">'+it.name+(exists?' <span class="med-state taken" style="margin-left:6px">назначен</span>':'')+'</div>';
var te=el("div","med-time");
var ti=el("input","input"); ti.value=it.dt.join(", "); ti.style.maxWidth="220px"; ti.style.padding="7px 10px"; ti.style.fontSize="13px";
te.appendChild(el("span","med-pill","время"));
te.appendChild(ti);
left.appendChild(te);
row.appendChild(left);
var cb=el("input"); cb.type="checkbox"; cb.style.width="20px"; cb.style.height="20px";
if(exists){ cb.checked=true; cb.disabled=true; }
var lab=el("label","",{"style":"display:flex;align-items:center;gap:8px;font-size:14px;font-weight:600;cursor:pointer"});
lab.appendChild(cb); lab.appendChild(el("span","","Назначить"));
row.appendChild(lab);
body.appendChild(row);
});
});
m.appendChild(body);
var foot=el("div","modal-foot");
var cancel=el("button","btn ghost","Отмена"); cancel.onclick=closeModal;
var saveBtn=el("button","btn","Сохранить назначения");
saveBtn.onclick=function(){ savePrescription(p, body); };
foot.appendChild(cancel); foot.appendChild(saveBtn);
m.appendChild(foot);
openModal();
}
function savePrescription(p, body){
var rows=body.querySelectorAll(".med");
var added=0;
for(var i=0;i<rows.length;i++){
var row=rows[i];
var cb=row.querySelector("input[type=checkbox]");
if(!cb || !cb.checked) continue;
var id=row.dataset.medId, name=row.dataset.medName;
if(p.meds.some(function(mm){return mm.id===id;})) continue;
var ti=row.querySelector(".med-time input");
var times=(ti&&ti.value||"").split(",").map(function(s){return s.trim();}).filter(Boolean);
if(!times.length) times=["08:00"];
p.meds.push({id:id, name:name, times:times});
added++;
}
save(state); closeModal(); openPatientDetail(p.id);
toast(added? "Добавлено препаратов: "+added : "Изменений нет");
}
/* ============ РЕНДЕР: МЕДРАБОТНИК ============ */
function renderStaff(){
// баннер
@ -852,19 +984,136 @@ function renderStaff(){
'</div>';
app.appendChild(banner);
// список пациентов по зонам
// вкладки
var tabs=el("div","tabs");
[["patients","🩺 Пациенты"],["reports","📊 Отчёты"],["protocol","🚦 Протокол (светофор)"]].forEach(function(t){
var b=el("button","",t[1]);
if(state.staffTab===t[0]) b.classList.add("active");
b.onclick=function(){ state.staffTab=t[0]; save(state); render(); };
tabs.appendChild(b);
});
app.appendChild(tabs);
if(state.staffTab==="reports") renderStaffReports();
else if(state.staffTab==="protocol") renderStaffProtocol();
else renderStaffList();
}
function renderStaffList(){
var list=el("div","card");
list.appendChild(el("h3","","Пациенты по степени риска",{"style":"margin-bottom:14px"}));
var sorted=state.patients.slice().sort(function(a,b){
var za=entryZone(a), zb=entryZone(b);
return zoneRank(zb)-zoneRank(za);
});
sorted.forEach(function(p){
list.appendChild(patientRow(p));
});
sorted.forEach(function(p){ list.appendChild(patientRow(p)); });
app.appendChild(list);
}
/* ---- Отчёт: сегментация + охват ---- */
function renderStaffReports(){
var c=zoneCounts(); var n=state.patients.length;
// 1. Сегментация
var seg=el("div","card");
seg.appendChild(el("h3","","Отчёт по сегментации пациентов",{"style":"margin-bottom:14px"}));
var bars=el("div","",{"style":"margin-bottom:16px"});
[["red","Красная",c.red,"#EA3943"],["yellow","Жёлтая",c.yellow,"#F5A623"],["green","Зелёная",c.green,"#16C784"]].forEach(function(z){
var pct=n?Math.round(z[2]/n*100):0;
var row=el("div","",{"style":"margin-bottom:10px"});
row.innerHTML='<div style="display:flex;justify-content:space-between;font-size:14px;margin-bottom:4px"><span>'+z[1]+'</span><span><b>'+z[2]+'</b> ('+pct+'%)</span></div>';
var track=el("div","",{"style":"background:var(--gray-100);border-radius:6px;height:12px;overflow:hidden"});
var fill=el("div","",{"style":"background:"+z[3]+";height:100%;width:"+pct+"%"});
track.appendChild(fill); row.appendChild(track); bars.appendChild(row);
});
seg.appendChild(bars);
// таблица по зонам
["red","yellow","green"].forEach(function(z){
var ps=state.patients.filter(function(p){return entryZone(p)===z;});
if(!ps.length) return;
seg.appendChild(el("div","zone-chip "+z,'<span class="d"></span>'+ZONE_TXT[z].t+" — "+ps.length,{"style":"margin:10px 0 6px"}));
var lst=el("div","",{"style":"font-size:14px;color:var(--gray-500)"});
lst.innerHTML=ps.map(function(p){return p.name;}).join(" · ");
seg.appendChild(lst);
});
app.appendChild(seg);
// 2. Охват мониторингом
var cov=el("div","card");
cov.appendChild(el("h3","","Отчёт по охвату мониторингом",{"style":"margin-bottom:14px"}));
var now=Date.now(), H24=3600000*24, H48=3600000*48;
var active24=0, stale=0, noData=0, doseTaken=0, doseTotal=0, sumCat=0, catN=0;
var staleList=[];
state.patients.forEach(function(p){
var e=lastEntry(p);
if(!e){ noData++; staleList.push({p:p,d:"нет данных"}); return; }
var dt=now-e.ts;
if(dt<=H24) active24++; else { stale++; staleList.push({p:p,d:"последний замер "+fmtTime(e.ts)}); }
sumCat+=e.catTotal; catN++;
p.meds.forEach(function(m){ medStatus(m,e).forEach(function(s){ if(s.status!=="upcoming"){ doseTotal++; if(s.status==="taken") doseTaken++; } }); });
});
var tiles=el("div","grid4");
tiles.appendChild(statTile("Всего под наблюдением", n));
tiles.appendChild(statTile("Активны (замер ≤24ч)", active24, active24>=Math.ceil(n*0.6)?"green":"yellow"));
tiles.appendChild(statTile("Нет замеров >24ч", stale+noData, (stale+noData)?"red":"green"));
tiles.appendChild(statTile("Adherence препаратов", (doseTotal?Math.round(doseTaken/doseTotal*100):0)+"%", doseTotal&&doseTaken/doseTotal>=0.8?"green":"yellow"));
cov.appendChild(tiles);
var tiles2=el("div","grid4");
tiles2.appendChild(statTile("Средний CAT", catN?Math.round(sumCat/catN*10)/10:"—"));
tiles2.appendChild(statTile("В красной зоне", c.red, c.red?"red":"green"));
tiles2.appendChild(statTile("В жёлтой зоне", c.yellow, c.yellow?"yellow":"green"));
tiles2.appendChild(statTile("В зелёной зоне", c.green, "green"));
cov.appendChild(tiles2);
// список требующих внимания
if(staleList.length){
cov.appendChild(el("div","section-title","Требуют внимания (нет свежих замеров)"));
staleList.forEach(function(it){
var row=el("div","patient-row yellow");
row.onclick=function(){ openPatientDetail(it.p.id); };
row.innerHTML='<div class="avatar"></div><div class="info"><div class="name">'+it.p.name+'</div><div class="meta">'+it.d+'</div></div><span class="alert-flag amber">контакт</span>';
cov.appendChild(row);
});
}
app.appendChild(cov);
}
function statTile(lbl,val,zone){
var cls=zone==="red"?"danger":zone==="yellow"?"warn":zone==="green"?"ok":"";
var t=el("div","vital-tile "+cls);
t.innerHTML='<div class="lbl">'+lbl+'</div><div class="val" style="font-size:22px">'+val+'</div>';
return t;
}
/* ---- Протокол «светофор» (расширенные действия) ---- */
function renderStaffProtocol(){
["red","yellow","green"].forEach(function(zkey){
var p=ZONE_PROTOCOL[zkey];
var cls=zkey==="red"?"r":zkey==="yellow"?"y":"g";
var card=el("div","zone "+cls);
card.style.marginBottom="18px";
var badge=zkey==="red"?"Красная зона":zkey==="yellow"?"Жёлтая зона":"Зелёная зона";
card.innerHTML='<span class="badge">'+badge+'</span><h3>'+p.title+'</h3>';
card.appendChild(el("div","thr","<b>Критерии:</b> "+p.crit));
card.appendChild(protoCol("Действия пациента", p.patient, "→", "var(--"+(zkey==="green"?"green":zkey==="yellow"?"amber":"red")+")"));
card.appendChild(protoCol("Действия медперсонала", p.staff, "✓", "var(--cyan)"));
var esc=el("div","act","<b>Эскалация:</b> "+p.esc);
card.appendChild(esc);
app.appendChild(card);
});
}
function protoCol(title, items, mark, color){
var wrap=el("div","",{"style":"margin-bottom:12px"});
wrap.appendChild(el("div","",{style:"font-size:13px;font-weight:700;color:var(--gray-500);text-transform:uppercase;letter-spacing:.04em;margin-bottom:6px","text":title}));
var ul=el("ul","",{"style":"list-style:none;padding:0;margin:0"});
items.forEach(function(it){
var li=el("li","",{"style":"font-size:14px;padding:5px 0 5px 22px;position:relative","text":it});
li.style.setProperty("--mk",color);
li.innerHTML='<span style="position:absolute;left:0;color:'+color+';font-weight:800">'+mark+'</span>'+it;
ul.appendChild(li);
});
wrap.appendChild(ul);
return wrap;
}
function entryZone(p){ var e=lastEntry(p); return e?overallZone(e.spo2,e.hr,e.rr):"green"; }
function zoneCounts(){
var c={green:0,yellow:0,red:0};
@ -933,8 +1182,15 @@ function openPatientDetail(id){
if(e.note){ pc.appendChild(el("div","muted","Заметка пациента: "+e.note,{"style":"margin-top:12px"})); }
app.appendChild(pc);
// препараты
// препараты + назначение терапии
app.appendChild(renderMedsCard(p, e, false));
var presc=el("div","card");
presc.style.display="flex";presc.style.alignItems="center";presc.style.justifyContent="space-between";presc.style.gap="12px";presc.style.flexWrap="wrap";
presc.innerHTML='<div><h3 style="font-size:17px">Базисная терапия</h3><div class="sub">Назначьте препараты из каталога — пациент увидит схему в своём кабинете</div></div>';
var pb=el("button","btn dark","+ Назначить терапию");
pb.onclick=function(){ openPrescribeModal(p); };
presc.appendChild(pb);
app.appendChild(presc);
// флаги / рекомендации
var flags=buildFlags(e).concat(buildMedFlags(p));