copd-monitor/index.html

1061 lines
52 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>ПульсоMonitor — мониторинг ХОБЛ</title>
<style>
:root{--ink:#0F1218;--cyan:#00E5FF;--cyan-50:#E8FCFF;--white:#fff;--gray-500:#5B6573;--gray-100:#F2F4F7;--gray-200:#E4E8EE;--green:#16C784;--green-50:#E6F9F0;--amber:#F5A623;--amber-50:#FDF3E2;--red:#EA3943;--red-50:#FCEAEC}
*{box-sizing:border-box;margin:0;padding:0}
html{scroll-behavior:smooth}
body{font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,system-ui,sans-serif;color:var(--ink);background:#f7f8fa}
button{font-family:inherit;cursor:pointer}
input,select,textarea{font-family:inherit}
a{color:inherit}
/* Topbar */
.topbar{background:var(--ink);color:#fff;position:sticky;top:0;z-index:50;box-shadow:0 1px 0 rgba(255,255,255,.06)}
.topbar-inner{max-width:1240px;margin:0 auto;padding:0 20px;height:60px;display:flex;align-items:center;justify-content:space-between;gap:16px}
.logo{display:flex;align-items:center;gap:10px;font-weight:800;font-size:18px;text-decoration:none;color:#fff}
.logo-mark{width:32px;height:32px;border-radius:8px;background:rgba(0,229,255,.14);display:grid;place-items:center;color:var(--cyan);font-size:17px;border:1px solid rgba(0,229,255,.3)}
.role-switch{display:flex;background:#1a1f2a;border-radius:9px;padding:4px;gap:2px}
.role-switch button{background:transparent;border:none;color:#9aa3b2;font-size:14px;font-weight:600;padding:7px 14px;border-radius:6px}
.role-switch button.active{background:var(--cyan);color:var(--ink)}
.topbar-right{display:flex;align-items:center;gap:14px}
.live-dot{display:flex;align-items:center;gap:7px;font-size:13px;color:#9aa3b2}
.live-dot i{width:7px;height:7px;border-radius:50%;background:var(--green);box-shadow:0 0 0 3px rgba(22,199,132,.2)}
.wrap{max-width:1240px;margin:0 auto;padding:24px 20px 64px}
/* Zone chips */
.zone-chip{display:inline-flex;align-items:center;gap:7px;font-size:13px;font-weight:700;padding:5px 11px;border-radius:999px}
.zone-chip.green{background:var(--green-50);color:#0c7a4f}
.zone-chip.yellow{background:var(--amber-50);color:#9a6a00}
.zone-chip.red{background:var(--red-50);color:#a51d26}
.zone-chip .d{width:8px;height:8px;border-radius:50%}
.zone-chip.green .d{background:var(--green)}
.zone-chip.yellow .d{background:var(--amber)}
.zone-chip.red .d{background:var(--red)}
/* Cards */
.card{background:#fff;border:1px solid var(--gray-200);border-radius:14px;padding:22px;margin-bottom:18px}
.card h2,.card h3{font-weight:700;letter-spacing:-.01em}
.card-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px;flex-wrap:wrap}
.card-head h2{font-size:20px}
.card-head h3{font-size:17px}
.muted{color:var(--gray-500);font-size:14px}
.sub{color:var(--gray-500);font-size:14px;margin-top:2px}
/* Buttons */
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;background:var(--cyan);color:var(--ink);padding:11px 20px;border-radius:9px;font-weight:700;border:none;font-size:15px;text-decoration:none}
.btn:hover{background:#1bf0ff}
.btn.dark{background:var(--ink);color:#fff}
.btn.dark:hover{background:#222834}
.btn.ghost{background:#fff;color:var(--ink);border:1.5px solid var(--gray-200)}
.btn.ghost:hover{border-color:var(--ink)}
.btn.sm{padding:8px 14px;font-size:13px}
.btn.block{width:100%}
.btn:disabled{opacity:.5;cursor:not-allowed}
/* Hero banner */
.banner{background:var(--ink);color:#fff;border-radius:18px;padding:28px 26px;margin-bottom:22px;display:flex;align-items:center;justify-content:space-between;gap:20px;flex-wrap:wrap;position:relative;overflow:hidden}
.banner::before{content:"";position:absolute;inset:0;background:radial-gradient(600px 260px at 90% -20%,rgba(0,229,255,.18),transparent 60%);pointer-events:none}
.banner>div{position:relative}
.banner h1{font-size:26px;font-weight:800;letter-spacing:-.01em;margin-bottom:4px}
.banner p{color:#9aa3b2;font-size:15px}
.banner .big-zone{font-size:15px;font-weight:700}
/* Patient selector (staff) */
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:18px}
.grid3{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}
.grid4{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}
/* Vitals tiles */
.vital-tile{background:#fff;border:1px solid var(--gray-200);border-radius:12px;padding:16px}
.vital-tile .lbl{font-size:13px;color:var(--gray-500);margin-bottom:6px;display:flex;align-items:center;gap:7px}
.vital-tile .val{font-size:26px;font-weight:800;line-height:1;letter-spacing:-.02em}
.vital-tile .unit{font-size:13px;color:var(--gray-500);font-weight:600;margin-left:3px}
.vital-tile.warn{border-color:var(--amber);background:var(--amber-50)}
.vital-tile.danger{border-color:var(--red);background:var(--red-50)}
.vital-tile.ok{border-color:var(--green);background:var(--green-50)}
/* Patient list rows */
.patient-row{display:flex;align-items:center;gap:16px;padding:16px;border:1px solid var(--gray-200);border-radius:12px;margin-bottom:12px;background:#fff;cursor:pointer;transition:border-color .12s,transform .05s}
.patient-row:hover{border-color:var(--ink)}
.patient-row:active{transform:translateY(1px)}
.patient-row.red{border-left:5px solid var(--red)}
.patient-row.yellow{border-left:5px solid var(--amber)}
.patient-row.green{border-left:5px solid var(--green)}
.patient-row .avatar{width:44px;height:44px;border-radius:50%;background:var(--gray-100);display:grid;place-items:center;font-weight:800;color:var(--gray-500);flex-shrink:0}
.patient-row .info{flex:1;min-width:0}
.patient-row .name{font-weight:700;font-size:16px}
.patient-row .meta{font-size:13px;color:var(--gray-500)}
.patient-row .mini-vitals{display:flex;gap:14px;font-size:13px;color:var(--gray-500);flex-wrap:wrap}
.patient-row .mini-vitals b{color:var(--ink)}
.alert-flag{font-size:12px;font-weight:700;padding:4px 9px;border-radius:7px;white-space:nowrap}
.alert-flag.red{background:var(--red-50);color:#a51d26}
.alert-flag.amber{background:var(--amber-50);color:#9a6a00}
/* Forms */
.form-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:16px}
.field{display:flex;flex-direction:column;gap:6px}
.field label{font-size:13px;font-weight:600;color:var(--gray-500)}
.field label .req{color:var(--red)}
.input{padding:11px 13px;border:1.5px solid var(--gray-200);border-radius:9px;font-size:15px;background:#fff;width:100%}
.input:focus{outline:none;border-color:var(--cyan)}
.hint{font-size:12px;color:var(--gray-500)}
.bp-row{display:flex;gap:8px;align-items:center}
.bp-row .input{flex:1}
/* CAT questionnaire */
.cat-q{padding:14px 0;border-bottom:1px solid var(--gray-100)}
.cat-q:last-child{border-bottom:none}
.cat-q .qtxt{font-size:14px;font-weight:600;margin-bottom:8px}
.cat-q .qtxt small{font-weight:400;color:var(--gray-500);display:block;margin-top:2px}
.cat-scale{display:flex;gap:6px;flex-wrap:wrap}
.cat-scale label{display:flex;flex-direction:column;align-items:center;gap:3px;font-size:11px;color:var(--gray-500);cursor:pointer}
.cat-scale input{display:none}
.cat-scale .ball{width:34px;height:34px;border-radius:8px;border:1.5px solid var(--gray-200);display:grid;place-items:center;font-weight:700;font-size:14px;color:var(--gray-500)}
.cat-scale input:checked + .ball{background:var(--ink);color:#fff;border-color:var(--ink)}
.cat-total{margin-top:16px;padding:14px;border-radius:10px;font-weight:700;text-align:center;font-size:15px}
/* Medications */
.med{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:13px 0;border-bottom:1px solid var(--gray-100);flex-wrap:wrap}
.med:last-child{border-bottom:none}
.med-info{flex:1;min-width:0}
.med-name{font-weight:600;font-size:15px}
.med-time{font-size:13px;color:var(--gray-500);display:flex;gap:8px;flex-wrap:wrap;align-items:center}
.med-pill{font-size:12px;background:var(--gray-100);padding:3px 8px;border-radius:6px;font-weight:600}
.med-state{font-size:12px;font-weight:700;padding:5px 10px;border-radius:7px}
.med-state.taken{background:var(--green-50);color:#0c7a4f}
.med-state.missed{background:var(--red-50);color:#a51d26}
.med-state.due{background:var(--amber-50);color:#9a6a00}
.med-state.upcoming{background:var(--gray-100);color:var(--gray-500)}
.med-act{display:flex;gap:8px;flex-wrap:wrap}
/* Flags */
.flag{border-left:5px solid var(--red);background:var(--red-50);border-radius:12px;padding:16px;margin-bottom:12px}
.flag.amber{border-left-color:var(--amber);background:var(--amber-50)}
.flag h4{font-size:15px;font-weight:700;margin-bottom:8px;display:flex;align-items:center;gap:8px}
.flag ul{list-style:none;margin-bottom:8px}
.flag li{font-size:14px;padding:4px 0 4px 20px;position:relative;color:var(--ink)}
.flag li::before{content:"→";position:absolute;left:0;font-weight:800;color:var(--red)}
.flag.amber li::before{color:var(--amber)}
.flag .call{font-size:13px;font-weight:700;padding:9px 12px;background:#fff;border-radius:8px;color:#a51d26}
.flag.amber .call{color:#9a6a00}
/* Modal */
.modal-bg{position:fixed;inset:0;background:rgba(15,18,24,.55);backdrop-filter:blur(3px);z-index:100;display:none;align-items:flex-start;justify-content:center;padding:24px;overflow-y:auto}
.modal-bg.open{display:flex}
.modal{background:#fff;border-radius:16px;max-width:560px;width:100%;margin:auto;overflow:hidden}
.modal-head{padding:18px 22px;border-bottom:1px solid var(--gray-100);display:flex;justify-content:space-between;align-items:center}
.modal-head h3{font-size:18px;font-weight:700}
.modal-close{background:none;border:none;font-size:24px;color:var(--gray-500);width:32px;height:32px;border-radius:8px}
.modal-close:hover{background:var(--gray-100)}
.modal-body{padding:22px;max-height:70vh;overflow-y:auto}
.modal-foot{padding:16px 22px;border-top:1px solid var(--gray-100);display:flex;gap:10px;justify-content:flex-end}
/* Tabs */
.tabs{display:flex;gap:4px;border-bottom:1px solid var(--gray-200);margin-bottom:18px;overflow-x:auto}
.tabs button{background:none;border:none;font-size:15px;font-weight:600;color:var(--gray-500);padding:11px 16px;border-bottom:2px solid transparent;white-space:nowrap}
.tabs button.active{color:var(--ink);border-bottom-color:var(--cyan)}
/* History table */
.hist{width:100%;border-collapse:collapse;font-size:14px}
.hist th{text-align:left;font-size:12px;text-transform:uppercase;letter-spacing:.03em;color:var(--gray-500);padding:0 10px 10px;border-bottom:1px solid var(--gray-200)}
.hist td{padding:11px 10px;border-bottom:1px solid var(--gray-100)}
.hist tr:last-child td{border-bottom:none}
/* Sparkline */
.spark{display:flex;align-items:flex-end;gap:2px;height:30px}
.spark i{width:6px;background:var(--cyan);border-radius:2px;display:block;opacity:.85;min-height:2px}
.spark i.r{background:var(--red)}
.spark i.y{background:var(--amber)}
.empty{text-align:center;padding:48px 20px;color:var(--gray-500)}
.empty .ic{font-size:40px;margin-bottom:10px}
.back-link{display:inline-flex;align-items:center;gap:6px;font-size:14px;font-weight:600;color:var(--gray-500);text-decoration:none;margin-bottom:14px;cursor:pointer}
.back-link:hover{color:var(--ink)}
.section-title{font-size:13px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--gray-500);margin:6px 0 12px}
.devbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;padding:12px;background:var(--cyan-50);border-radius:10px}
.devlbl{font-size:13px;color:var(--gray-500);font-weight:600}
details.edu{border:1px solid var(--gray-200);border-radius:10px;padding:0;margin-bottom:10px;overflow:hidden}
details.edu summary{padding:13px 16px;font-weight:700;font-size:15px;cursor:pointer;list-style:none;background:var(--gray-100)}
details.edu summary::-webkit-details-marker{display:none}
details.edu[open] summary{border-bottom:1px solid var(--gray-200)}
details.edu .edu-body{padding:14px 16px;font-size:14px;color:var(--gray-500)}
details.edu .edu-body b{color:var(--ink)}
details.edu .edu-body li{margin-bottom:6px}
.iv-line{padding:11px 0;border-bottom:1px solid var(--gray-100)}
.iv-line:last-child{border-bottom:none}
.iv-line b{font-size:14px}
.iv-line .iv-meta{font-size:12px;color:var(--gray-500);margin-top:2px}
.stat-line{display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid var(--gray-100);font-size:14px}
.stat-line:last-child{border-bottom:none}
.stat-line .v{font-weight:700}
.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--ink);color:#fff;padding:13px 22px;border-radius:10px;font-size:14px;font-weight:600;z-index:200;box-shadow:0 12px 30px rgba(0,0,0,.3);opacity:0;transition:opacity .2s,transform .2s;pointer-events:none}
.toast.show{opacity:1;transform:translateX(-50%) translateY(-4px)}
.hidden{display:none!important}
@media(max-width:760px){
.grid2,.grid3,.grid4{grid-template-columns:1fr}
.form-grid{grid-template-columns:1fr}
.banner h1{font-size:22px}
.role-switch button{padding:7px 10px;font-size:13px}
.topbar-right .live-dot{display:none}
}
</style>
</head>
<body>
<div class="topbar">
<div class="topbar-inner">
<a class="logo" href="#"><span class="logo-mark"></span>ПульсоMonitor</a>
<div class="role-switch">
<button id="rolePatient" class="active">🧑 Я пациент</button>
<button id="roleStaff">🏥 Я медработник</button>
</div>
<div class="topbar-right">
<span class="live-dot"><i></i>Демо-режим</span>
</div>
</div>
</div>
<div class="wrap" id="app"></div>
<div class="modal-bg" id="modalBg">
<div class="modal" id="modal"></div>
</div>
<div class="toast" id="toast"></div>
<script>
"use strict";
/* ============ ДАННЫЕ (localStorage как «БД») ============ */
var STORE_KEY = "copd_monitor_v2";
function seed(){
var now = Date.now();
function entry(p, daysAgo, hour, v){
var t = new Date(); t.setDate(t.getDate()-daysAgo); t.setHours(hour,5,0,0);
return {
id: p+"-"+daysAgo+"-"+hour,
ts: t.getTime(),
spo2: v.spo2, sys: v.sys, dia: v.dia, hr: v.hr, rr: v.rr, mmrc: v.mmrc,
cat: v.cat, catTotal: v.cat.reduce(function(a,b){return a+b;},0),
meds: v.meds||{},
note: v.note||""
};
}
return {
role:"patient",
currentPatient:"p1",
patients:[
{
id:"p1", name:"Ахметов Болат", age:64, diag:"ХОБЛ, GOLD D",
meds:[
{id:"m1", name:"Тиотропий (Спирива)", times:["08:00"]},
{id:"m2", name:"Сальметерол/Флутиказон", times:["08:00","20:00"]},
{id:"m3", name:"Рофлумиласт", times:["21:00"]}
],
entries:[
entry("p1",2,8,{spo2:96,sys:128,dia:82,hr:78,rr:18,mmrc:1,cat:[1,1,0,1,1,1,0,1],meds:{"m1@08:00":{taken:true},"m2@08:00":{taken:true}},note:""}),
entry("p1",1,8,{spo2:95,sys:134,dia:86,hr:84,rr:20,mmrc:1,cat:[1,1,1,1,2,1,1,1],meds:{"m1@08:00":{taken:true},"m2@08:00":{taken:true}}}),
entry("p1",1,20,{spo2:94,sys:130,dia:84,hr:88,rr:20,mmrc:2,cat:[1,1,1,1,2,1,1,1],meds:{"m1@08:00":{taken:true},"m2@08:00":{taken:true},"m2@20:00":{taken:false}}})
]
},
{
id:"p2", name:"Омарова Гульнар", age:58, diag:"ХОБЛ, GOLD C",
meds:[
{id:"m1", name:"Тиотропий", times:["09:00"]},
{id:"m2", name:"Будесонид/Формотерол", times:["09:00","21:00"]}
],
entries:[
entry("p2",1,9,{spo2:97,sys:120,dia:78,hr:72,rr:16,mmrc:0,cat:[0,1,0,0,1,0,0,1],meds:{"m1@09:00":{taken:true},"m2@09:00":{taken:true}}})
]
},
{
id:"p3", name:"Сериков Ермек", age:71, diag:"ХОБЛ, GOLD D, О₂-терапия",
meds:[
{id:"m1", name:"Тиотропий", times:["08:00"]},
{id:"m2", name:"Сальбутамол (спасатель)", times:["по потребности"]},
{id:"m3", name:"О₂-терапия", times:["15ч/сут 2л/мин"]}
],
entries:[
entry("p3",1,8,{spo2:88,sys:142,dia:90,hr:112,rr:26,mmrc:3,cat:[3,3,2,4,4,3,2,3],meds:{"m1@08:00":{taken:false}},note:"усилилась одышка, мокрота гнойная"})
],
interventions:[
{ts:Date.now()-3600000*3, text:"Связался с пациентом. Усилить О₂ до 3 л/мин, начать преднизолон 30 мг/сут 5 дней, резервный амоксиклав."},
{ts:Date.now()-3600000*26, text:"Пациент в красной зоне. Повторить SpO₂ через 30 мин, при <88% — госпитализация."}
]
},
{
id:"p4", name:"Ибраева Динара", age:49, diag:"ХОБЛ, GOLD B",
meds:[
{id:"m1", name:"Индакатерол", times:["08:00"]}
],
entries:[
entry("p4",1,8,{spo2:96,sys:118,dia:76,hr:70,rr:17,mmrc:1,cat:[1,0,0,1,1,0,0,1],meds:{"m1@08:00":{taken:true}}})
]
}
]
};
}
function load(){
try{
var raw = localStorage.getItem(STORE_KEY);
if(raw) return JSON.parse(raw);
}catch(e){}
var s = seed();
save(s);
return s;
}
function save(state){
localStorage.setItem(STORE_KEY, JSON.stringify(state));
}
var state = load();
/* ============ ЛОГИКА СЕГМЕНТАЦИИ ============ */
function zoneSpo2(v){ if(v>=95) return "green"; if(v>=90) return "yellow"; return "red"; }
function zoneHr(v){ if(v>=60&&v<=100) return "green"; if((v>100&&v<=110)||(v>=50&&v<60)) return "yellow"; return "red"; }
function zoneRr(v){ if(v>=12&&v<=20) return "green"; if(v>20&&v<=24) return "yellow"; return "red"; }
function zoneRank(z){ return z==="red"?3:z==="yellow"?2:1; }
function overallZone(spo2,hr,rr){
var z = [zoneSpo2(spo2),zoneHr(hr),zoneRr(rr)];
if(z.indexOf("red")>=0) return "red";
if(z.indexOf("yellow")>=0) return "yellow";
return "green";
}
var ZONE_TXT = {
green:{t:"Стабилен", d:"Показатели в норме. Продолжайте базисную терапию и ведите дневник."},
yellow:{t:"Внимание", d:"Есть отклонения. Следуйте рекомендациям ниже и свяжитесь с врачом при ухудшении."},
red:{t:"Ухудшение", d:"Срочно! Выполните рекомендации и вызовите скорую при необходимости."}
};
/* ============ CAT ВОПРОСЫ ============ */
var CAT_Q = [
{q:"Кашель", a:"Я никогда не кашляю", b:"Я постоянно кашляю"},
{q:"Мокрота", a:"Вообще нет мокроты", b:"Максимально много мокроты"},
{q:"Стеснение в груди", a:"Нет стеснения", b:"Сильнейшее стеснение"},
{q:"Одышка при нагрузке", a:"Дышится без труда", b:"Совсем не дышится"},
{q:"Активность дома", a:"Могу выполнять любую активность", b:"Не могу выйти из дома"},
{q:"Уверенность выходить из дома", a:"Уверен(а)", b:"Совсем не уверен(а)"},
{q:"Сон", a:"Сплю отлично", b:"Из-за ХОБЛ не сплю"},
{q:"Энергия", a:"Полностью энергичен(а)", b:"Совсем нет энергии"}
];
function catLevel(s){ if(s<=9) return {t:"низкое влияние",c:"green"}; if(s<=20) return {t:"среднее влияние",c:"yellow"}; if(s<=30) return {t:"высокое влияние",c:"yellow"}; return {t:"очень высокое",c:"red"}; }
/* ============ УТИЛИТЫ ============ */
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 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()); }
function fmtDay(ts){ var d=new Date(ts); return pad(d.getDate())+"."+pad(d.getMonth()+1); }
function lastEntry(p){ if(!p.entries.length) return null; return p.entries[p.entries.length-1]; }
function getPatient(id){ for(var i=0;i<state.patients.length;i++) if(state.patients[i].id===id) return state.patients[i]; return null; }
function toast(msg){ var t=$("#toast"); t.textContent=msg; t.classList.add("show"); setTimeout(function(){t.classList.remove("show");},2200); }
/* meds status: taken / missed / due / upcoming. taken:false => missed (явно) */
function medStatus(med, entry){
var h = nowHHMM();
var hasEntry = !!entry;
var out = [];
for(var i=0;i<med.times.length;i++){
var t = med.times[i];
var rec = hasEntry && entry.meds ? entry.meds[med.id+"@"+t] : null;
var status;
if(rec && rec.taken) status="taken";
else if(rec && rec.taken===false) status="missed";
else if(t==="по потребности" || t.indexOf("ч/сут")>=0) status="upcoming";
else if(h>t) status="missed";
else status="upcoming";
out.push({time:t, status:status});
}
return out;
}
/* ============ КРАСНЫЕ ФЛАГИ (динамически) ============ */
function buildFlags(entry){
if(!entry) return [];
var flags=[];
if(entry.spo2<90){
flags.push({type:"red",title:"🩸 SpO₂ упала ниже 90%",body:[
"Увеличьте поток кислорода по назначенной схеме",
"Примите бронхолитик (сальбутамол) через ДАИ или небулайзер",
"Сядьте, наклонитесь вперёд с опорой на руки",
"Дышите через сжатые губы (удлинённый выдох)"
],call:"🚑 Вызывайте скорую, если SpO₂ < 88% держится > 5 мин или одышка нарастает"});
} else if(entry.spo2<95 && zoneSpo2(entry.spo2)==="yellow"){
flags.push({type:"amber",title:"🩸 SpO₂ снижена (9094%)",body:[
"Проверьте подачу кислорода, при необходимости увеличьте по схеме",
"Примените спасательный бронхолитик",
"Контролируйте SpO₂ каждые 1530 минут",
"Свяжитесь с медсестрой, если не растёт"
],call:" Сообщите врачу, если держится < 92% несколько часов"});
}
if(entry.mmrc>=3){
flags.push({type:"red",title:"😤 Резкая одышка в покое (mMRC ≥3)",body:[
"Примите спасательный бронхолитик (24 вдоха сальбутамола)",
"Примите положение ортопноэ — сидя с наклоном вперёд",
"Дышите медленно, удлинённым выдохом",
"Контролируйте SpO₂ каждые 5 минут"
],call:"🚑 Скорая, если одышка не уменьшается через 1520 мин"});
}
if(entry.rr>24){
flags.push({type:"red",title:"🌬 ЧДД выше 24/мин",body:[
"Сядьте удобно, расслабьте плечи",
"Дышите через сжатые губы, удлиняя выдох",
"Примените бронхолитик по схеме"
],call:"🚑 Скорая при ЧДД > 30 или признаках утомления дыхательных мышц"});
}
if(entry.hr>110){
flags.push({type:"amber",title:"❤️ ЧСС выше 110 уд/мин",body:[
"Присядьте и отдохните 10 минут",
"Измерьте пульс повторно в покое",
"Исключите кофеин, физическую нагрузку"
],call:" Сообщите врачу, если ЧСС > 120 в покое или есть перебои"});
}
return flags;
}
function buildMedFlags(patient){
var e = lastEntry(patient); var flags=[];
if(!e) return flags;
patient.meds.forEach(function(m){
var st = medStatus(m,e);
st.forEach(function(s){
if(s.status==="missed"){
flags.push({type:"amber",title:"💊 Пропущен приём: "+m.name+" ("+s.time+")",body:[
"Примите препарат сразу, как вспомнили",
"Не удваивайте дозу, если близко следующее время приёма",
"Отметьте приём в дневнике",
"Поставьте напоминание на каждый день"
],call:" Частые пропуски (≥2 за неделю) — сообщите врачу"});
}
});
});
return flags;
}
/* ============ РЕНДЕР: ОБЩИЕ ============ */
var app = $("#app");
function render(){
app.innerHTML = "";
if(state.role==="patient") renderPatient();
else renderStaff();
// обновлять напоминания каждые 60сек
}
/* ============ РЕНДЕР: ПАЦИЕНТ ============ */
function renderPatient(){
var p = getPatient(state.currentPatient);
if(!p){ app.appendChild(emptyState("Пациент не выбран")); return; }
var e = lastEntry(p);
var zone = e?overallZone(e.spo2,e.hr,e.rr):"green";
// Banner
var banner = el("div","banner");
banner.innerHTML =
'<div>'+
'<h1>Кабинет пациента</h1>'+
'<p>'+p.name+', '+p.age+' лет · '+p.diag+'</p>'+
'</div>'+
'<div class="big-zone">'+
'<span class="zone-chip '+zone+'"><span class="d"></span>'+(e?ZONE_TXT[zone].t:"Нет данных")+'</span>'+
'<div style="margin-top:6px;font-size:12px;color:#9aa3b2">'+(e?'Обновлено '+fmtTime(e.ts):'Сделайте первую запись')+'</div>'+
'</div>';
app.appendChild(banner);
// Переключатель демо-пациентов
var sw = el("div","card");
sw.style.padding="14px 18px";
sw.style.display="flex";sw.style.alignItems="center";sw.style.gap="12px";sw.style.flexWrap="wrap";
sw.innerHTML='<span style="font-size:13px;color:var(--gray-500);font-weight:600">Демо: посмотреть кабинет пациента</span>';
var sel = el("select","input");
sel.style.maxWidth="320px";
state.patients.forEach(function(pp){
var o=el("option"); o.value=pp.id; o.textContent=pp.name+" — "+pp.diag;
if(pp.id===state.currentPatient) o.selected=true;
sel.appendChild(o);
});
sel.onchange=function(ev){ state.currentPatient=ev.target.value; save(state); render(); };
sw.appendChild(sel);
app.appendChild(sw);
// Кнопка новой записи
var act = el("div","card");
act.style.display="flex";act.style.alignItems="center";act.style.justifyContent="space-between";act.style.gap="12px";act.style.flexWrap="wrap";
act.innerHTML = '<div><h3 style="font-size:17px">Дневник самомонаблюдения</h3><div class="sub">Запишите показатели, анкету CAT и отметьте приём препаратов</div></div>';
var b = el("button","btn","+ Новая запись");
b.onclick=function(){ openDiaryModal(p); };
act.appendChild(b);
app.appendChild(act);
// Зона + красные флаги
var flags = e?buildFlags(e):[];
flags = flags.concat(buildMedFlags(p));
if(flags.length){
var fc = el("div","card");
fc.appendChild(el("h3","","🚩 Рекомендации по состоянию"));
fc.appendChild(el("div","muted","Что можно сделать самостоятельно. При красных флагах — действуйте немедленно.",{"style":"margin-bottom:14px"}));
flags.forEach(function(f){
var d = el("div","flag"+(f.type==="amber"?" amber":""));
var h = el("h4","",f.title);
d.appendChild(h);
var ul = el("ul");
f.body.forEach(function(li){ ul.appendChild(el("li","",li)); });
d.appendChild(ul);
d.appendChild(el("div","call",f.call));
fc.appendChild(d);
});
app.appendChild(fc);
} else if(e){
var ok = el("div","card");
ok.style.borderColor="var(--green)";ok.style.background="var(--green-50)";
ok.innerHTML='<div style="display:flex;align-items:center;gap:10px;font-weight:700;color:#0c7a4f">✓ Отклонений нет. Продолжайте базисную терапию и ведите дневник.</div>';
app.appendChild(ok);
}
// Последние показатели
if(e){
var pc = el("div","card");
pc.appendChild(el("h3","","Последние показатели",{"style":"margin-bottom:14px"}));
var g = el("div","grid4");
g.appendChild(vitalTile("🩸 SpO₂", e.spo2, "%", zoneSpo2(e.spo2)));
g.appendChild(vitalTile("🩺 АД", e.sys+"/"+e.dia, "мм рт.ст.", bpZone(e.sys,e.dia)));
g.appendChild(vitalTile("❤️ ЧСС", e.hr, "уд/мин", zoneHr(e.hr)));
g.appendChild(vitalTile("🌬 ЧДД", e.rr, "/мин", zoneRr(e.rr)));
pc.appendChild(g);
// доп: одышка + CAT
var g2 = el("div","grid2");
g2.appendChild(infoTile("😤 Одышка (mMRC)", e.mmrc+" балл(ов)", mmrcTxt(e.mmrc)));
var cl = catLevel(e.catTotal);
g2.appendChild(infoTile("📋 Анкета CAT", e.catTotal+" баллов", cl.t, cl.c));
pc.appendChild(g2);
app.appendChild(pc);
// Препараты сегодня
app.appendChild(renderMedsCard(p, e, true));
} else {
app.appendChild(emptyCard("Пока нет записей. Нажмите «Новая запись», чтобы начать."));
}
// Образование и самопомощь
var edu = el("div","card");
edu.appendChild(el("h3","","📚 Образование и самопомощь",{"style":"margin-bottom:14px"}));
edu.appendChild(el("div","muted","Краткие рекомендации при ХОБЛ. Нажмите, чтобы раскрыть.",{"style":"margin-bottom:12px"}));
edu.appendChild(eduBlock("🌬 Дыхательная гимнастика",
"<li><b>Диафрагмальное дыхание:</b> одна рука на животе, вдох через нос — живот надувается; выдох через рот медленно.</li>"+
"<li><b>Дыхание через сжатые губы:</b> вдох носом 2 сек, выдох через губы трубочкой 4 сек. Удлиняйте выдох.</li>"+
"<li>Выполняйте 510 минут 34 раза в день, особенно при одышке.</li>"));
edu.appendChild(eduBlock("💊 Техника ингаляции",
"<li>Встряхните ДАИ, снимите колпачок, выдохните мимо ингалятора.</li>"+
"<li>Обхватите мундштук губами, начинайте вдох и нажмите на баллончик одновременно.</li>"+
"<li>Продолжайте медленный глубокий вдох 35 сек, задержите дыхание на 10 сек.</li>"+
"<li>После ингаляции ГКС — прополощите рот водой.</li>"));
edu.appendChild(eduBlock("🚦 План действий (светофор)",
"<li><b>🟢 Зелёная зона:</b> всё как обычно — базисная терапия, дневник.</li>"+
"<li><b>🟡 Жёлтая зона:</b> SpO₂ 9094% — спасатель, контроль, связаться с медсестрой.</li>"+
"<li><b>🔴 Красная зона:</b> SpO₂ &lt;90% — кислород, бронхолитик, положение, вызов скорой.</li>"));
edu.appendChild(eduBlock("🚑 Когда вызывать скорую",
"<li>SpO₂ &lt; 88% держится более 5 минут или нарастает одышка.</li>"+
"<li>Одышка в покое не уменьшается через 1520 минут после бронхолитика.</li>"+
"<li>Спутанность сознания, синюшность губ, боли в груди.</li>"+
"<li>Температура &gt; 38,5 °C с нарастающей одышкой.</li>"));
app.appendChild(edu);
// История
if(p.entries.length){
var hc = el("div","card");
hc.appendChild(el("h3","","История записей",{"style":"margin-bottom:14px"}));
hc.appendChild(historyTable(p));
app.appendChild(hc);
}
}
function vitalTile(lbl,val,unit,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">'+val+'<span class="unit">'+unit+'</span></div>';
return t;
}
function infoTile(lbl,val,sub,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:20px">'+val+'</div><div class="hint">'+sub+'</div>';
return t;
}
function bpZone(s,d){
if(s>=140||d>=90) return "yellow";
if(s>=180||d>=110) return "red";
return "green";
}
function mmrcTxt(m){ return ["одышки нет","при быстрой ходьбе","иду медленнее","останавливаюсь ~100м","не могу выйти из дома"][m]||"—"; }
function eduBlock(title, html){
var d=el("details","edu");
d.innerHTML='<summary>'+title+'</summary><div class="edu-body"><ul style="padding-left:18px;margin:0">'+html+'</ul></div>';
return d;
}
function renderMedsCard(p, entry, showActions){
var c = el("div","card");
c.appendChild(el("h3","","💊 Базисные препараты",{"style":"margin-bottom:6px"}));
c.appendChild(el("div","sub","Отметьте приём. Система напомнит, если пропустили время.",{"style":"margin-bottom:8px"}));
p.meds.forEach(function(m){
var st = medStatus(m, entry);
st.forEach(function(s){
var row = el("div","med");
var info = el("div","med-info");
info.innerHTML='<div class="med-name">'+m.name+'</div>';
var times = el("div","med-time");
times.innerHTML='<span class="med-pill">'+s.time+'</span>';
info.appendChild(times);
row.appendChild(info);
var stateLbl = {taken:"принят",missed:"пропущен",due:"пора принять",upcoming:"запланирован"}[s.status];
row.appendChild(el("span","med-state "+s.status, stateLbl));
if(showActions && s.status!=="taken"){
var btn = el("button","btn sm","Отметить приём");
btn.onclick=function(){ markMed(p,m,s.time); };
row.appendChild(btn);
}
c.appendChild(row);
});
});
return c;
}
function markMed(p, med, time){
var e = lastEntry(p);
if(!e){ toast("Сначала создайте запись дневника"); return; }
if(!e.meds) e.meds={};
e.meds[med.id+"@"+time] = {taken:true, at:Date.now()};
save(state); render(); toast("Приём отмечен: "+med.name);
}
/* mock-чтение с прибора (имитация Bluetooth) */
function readDevice(kind){
if(kind==="oxy"){
var v = 88+Math.floor(Math.random()*9); // 8896
var hr = 64+Math.floor(Math.random()*42); // 64105
$("#spo2").value=v; $("#hr").value=hr;
toast("Пульсоксиметр: SpO₂ "+v+"%, ЧСС "+hr);
} else {
var s=116+Math.floor(Math.random()*34); // 116149
var d=74+Math.floor(Math.random()*20); // 7493
$("#bpSys").value=s; $("#bpDia").value=d;
toast("Тонометр: АД "+s+"/"+d);
}
}
function historyTable(p){
var t = el("table","hist");
var thead = el("thead"); thead.innerHTML='<tr><th>Дата</th><th>SpO₂</th><th>АД</th><th>ЧСС</th><th>ЧДД</th><th>mMRC</th><th>CAT</th><th>Зона</th></tr>';
t.appendChild(thead);
var tbody = el("tbody");
for(var i=p.entries.length-1;i>=0;i--){
var e=p.entries[i]; var z=overallZone(e.spo2,e.hr,e.rr);
var tr=el("tr");
tr.innerHTML='<td>'+fmtTime(e.ts)+'</td>'+
'<td>'+e.spo2+'%</td>'+
'<td>'+e.sys+'/'+e.dia+'</td>'+
'<td>'+e.hr+'</td>'+
'<td>'+e.rr+'</td>'+
'<td>'+e.mmrc+'</td>'+
'<td>'+e.catTotal+'</td>'+
'<td><span class="zone-chip '+z+'"><span class="d"></span>'+ZONE_TXT[z].t+'</span></td>';
tbody.appendChild(tr);
}
t.appendChild(tbody);
return t;
}
/* ============ МОДАЛКА: НОВАЯ ЗАПИСЬ ДНЕВНИКА ============ */
var catAnswers = [0,0,0,0,0,0,0,0];
function openDiaryModal(p){
catAnswers = [0,0,0,0,0,0,0,0];
var m = $("#modal");
m.innerHTML = "";
var head = el("div","modal-head");
head.innerHTML='<h3>Новая запись дневника</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","section-title","Показатели"));
// приборы (mock Bluetooth)
var devBar = el("div","devbar");
devBar.appendChild(el("span","devlbl","📡 Считать с прибора:"));
var bOxy = el("button","btn ghost sm","🩸 Пульсоксиметр");
bOxy.onclick=function(){ readDevice("oxy"); };
var bBp = el("button","btn ghost sm","🩺 Тонометр");
bBp.onclick=function(){ readDevice("bp"); };
devBar.appendChild(bOxy); devBar.appendChild(bBp);
body.appendChild(devBar);
var fg = el("div","form-grid");
fg.appendChild(field('spo2','number','🩸 Сатурация (SpO₂)','90',true,"%, 50100"));
fg.appendChild(bpField());
fg.appendChild(field('hr','number','❤️ ЧСС (уд/мин)','60100',true,"уд/мин"));
fg.appendChild(field('rr','number','🌬 ЧДД (/мин)','1220',true,"дыханий/мин"));
// одышка
var mmrcWrap = el("div","field");
mmrcWrap.appendChild(el("label","",'😤 Одышка (шкала mMRC) <span class="req">*</span>'));
var sel = el("select","input"); sel.id="mmrc";
[["0","0 — одышки нет"],["1","1 — при быстрой ходьбе"],["2","2 — иду медленнее ровных"],["3","3 — останавливаюсь ~100м"],["4","4 — не могу выйти из дома"]].forEach(function(o){
var op=el("option"); op.value=o[0]; op.textContent=o[1]; sel.appendChild(op);
});
mmrcWrap.appendChild(sel);
mmrcWrap.appendChild(el("div","hint","Выберите степень одышки сегодня"));
fg.appendChild(mmrcWrap);
body.appendChild(fg);
// CAT
body.appendChild(el("div","section-title","Анкета CAT (8 вопросов)",""));
CAT_Q.forEach(function(q,i){
var div=el("div","cat-q");
div.innerHTML='<div class="qtxt">'+q.q+' <small>'+q.a+' (0) → '+q.b+' (5)</small></div>';
var scale=el("div","cat-scale");
for(var b=0;b<=5;b++){
var lab=el("label");
var inp=el("input"); inp.type="radio"; inp.name="cat-"+i; inp.value=b;
inp.onchange=function(ev){ catAnswers[i]=parseInt(ev.target.value,10); updateCatTotal(); };
if(b===0) inp.checked=true;
lab.appendChild(inp);
lab.appendChild(el("span","ball",String(b)));
scale.appendChild(lab);
}
div.appendChild(scale);
body.appendChild(div);
});
var total=el("div","cat-total");
total.id="catTotal"; total.textContent="Сумма CAT: 0 баллов";
body.appendChild(total);
// Препараты
if(p.meds.length){
body.appendChild(el("div","section-title","Приём базисных препаратов"));
p.meds.forEach(function(med){
med.times.forEach(function(t){
var div=el("div","med");
div.innerHTML='<div class="med-info"><div class="med-name">'+med.name+'</div><div class="med-time"><span class="med-pill">'+t+'</span></div></div>';
var cb=el("input"); cb.type="checkbox"; cb.id="med-"+med.id+"-"+t; cb.style.width="20px";cb.style.height="20px";
var lab=el("label","","",{"style":"display:flex;align-items:center;gap:8px;font-size:14px;font-weight:600;cursor:pointer"});
lab.htmlFor=cb.id; lab.appendChild(cb); lab.appendChild(el("span","","Принят"));
div.appendChild(lab);
body.appendChild(div);
});
});
}
body.appendChild(el("div","section-title","Заметка"));
var note=el("textarea","input"); note.id="note"; note.rows=2; note.placeholder="Самочувствие, жалобы…";
body.appendChild(note);
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(){ saveEntry(p); };
foot.appendChild(cancel); foot.appendChild(saveBtn);
m.appendChild(foot);
openModal();
updateCatTotal();
}
function updateCatTotal(){
var s=catAnswers.reduce(function(a,b){return a+b;},0);
var t=$("#catTotal");
var c=catLevel(s);
t.textContent="Сумма CAT: "+s+" баллов — "+c.t;
t.style.background = c.c==="red"?"var(--red-50)":c.c==="yellow"?"var(--amber-50)":"var(--green-50)";
t.style.color = c.c==="red"?"#a51d26":c.c==="yellow"?"#9a6a00":"#0c7a4f";
}
function field(id,type,lbl,ph,req,hint){
var f=el("div","field");
f.innerHTML='<label for="'+id+'">'+lbl+(req?' <span class="req">*</span>':'')+'</label>';
var inp=el("input","input"); inp.id=id; inp.type=type; if(ph)inp.placeholder=ph;
f.appendChild(inp);
if(hint) f.appendChild(el("div","hint",hint));
return f;
}
function bpField(){
var f=el("div","field");
f.innerHTML='<label>🩺 Артериальное давление <span class="req">*</span></label>';
var row=el("div","bp-row");
var s=el("input","input"); s.id="bpSys"; s.type="number"; s.placeholder="систол.";
var slash=el("span","","/",{style:"color:var(--gray-500)"});
var d=el("input","input"); d.id="bpDia"; d.type="number"; d.placeholder="диастол.";
row.appendChild(s); row.appendChild(slash); row.appendChild(d);
f.appendChild(row);
f.appendChild(el("div","hint","мм рт.ст."));
return f;
}
function saveEntry(p){
var spo2=parseInt($("#spo2").value,10);
var sys=parseInt($("#bpSys").value,10);
var dia=parseInt($("#bpDia").value,10);
var hr=parseInt($("#hr").value,10);
var rr=parseInt($("#rr").value,10);
var mmrc=parseInt($("#mmrc").value,10);
var note=$("#note").value;
if([spo2,sys,dia,hr,rr].some(function(v){return isNaN(v);})){
toast("Заполните все обязательные поля"); return;
}
var meds={};
p.meds.forEach(function(med){
med.times.forEach(function(t){
var cb=$("#med-"+med.id+"-"+t);
meds[med.id+"@"+t] = {taken: !!(cb&&cb.checked)};
});
});
var e={
id:"e-"+Date.now(),
ts:Date.now(),
spo2:spo2, sys:sys, dia:dia, hr:hr, rr:rr, mmrc:mmrc,
cat:catAnswers.slice(),
catTotal:catAnswers.reduce(function(a,b){return a+b;},0),
meds:meds,
note:note
};
p.entries.push(e);
p.entries.sort(function(a,b){return a.ts-b.ts;});
save(state); closeModal(); render();
var z=overallZone(spo2,hr,rr);
toast("Запись сохранена. Зона: "+ZONE_TXT[z].t);
}
/* ============ РЕНДЕР: МЕДРАБОТНИК ============ */
function renderStaff(){
// баннер
var banner=el("div","banner");
var counts=zoneCounts();
banner.innerHTML=
'<div><h1>Панель медработника</h1><p>Мониторинг пациентов с ХОБЛ · всего '+state.patients.length+'</p></div>'+
'<div style="display:flex;gap:10px;flex-wrap:wrap">'+
'<span class="zone-chip red"><span class="d"></span>Красная: '+counts.red+'</span>'+
'<span class="zone-chip yellow"><span class="d"></span>Жёлтая: '+counts.yellow+'</span>'+
'<span class="zone-chip green"><span class="d"></span>Зелёная: '+counts.green+'</span>'+
'</div>';
app.appendChild(banner);
// список пациентов по зонам
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));
});
app.appendChild(list);
}
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};
state.patients.forEach(function(p){ c[entryZone(p)]++; });
return c;
}
function patientRow(p){
var e=lastEntry(p); var z=entryZone(p);
var row=el("div","patient-row "+z);
row.onclick=function(){ openPatientDetail(p.id); };
var av=el("div","avatar","",p.name.split(" ").map(function(x){return x[0];}).slice(0,2).join(""));
row.appendChild(av);
var info=el("div","info");
var alerts=[];
if(z==="red") alerts.push('<span class="alert-flag red">⚠ Ухудшение</span>');
else if(z==="yellow") alerts.push('<span class="alert-flag amber">Внимание</span>');
// missed meds
if(e){
p.meds.forEach(function(m){ medStatus(m,e).forEach(function(s){ if(s.status==="missed") alerts.push('<span class="alert-flag red">💊 пропуск</span>'); }); });
if(e.catTotal>20) alerts.push('<span class="alert-flag amber">CAT '+e.catTotal+'</span>');
}
info.innerHTML='<div class="name">'+p.name+', '+p.age+' <span style="font-weight:400;color:var(--gray-500)">· '+p.diag+'</span></div>'+
'<div class="meta">'+(e?'Обновлено '+fmtTime(e.ts):'нет данных')+'</div>'+
(alerts.length?'<div style="margin-top:6px;display:flex;gap:6px;flex-wrap:wrap">'+alerts.join("")+'</div>':'');
row.appendChild(info);
if(e){
var mv=el("div","mini-vitals");
mv.innerHTML='<span>SpO₂ <b>'+e.spo2+'%</b></span><span>АД <b>'+e.sys+'/'+e.dia+'</b></span><span>ЧСС <b>'+e.hr+'</b></span><span>ЧДД <b>'+e.rr+'</b></span><span>CAT <b>'+e.catTotal+'</b></span>';
row.appendChild(mv);
}
var chip=el("span","zone-chip "+z,'<span class="d"></span>'+ZONE_TXT[z].t);
row.appendChild(chip);
return row;
}
function openPatientDetail(id){
var p=getPatient(id);
app.innerHTML="";
var back=el("div","back-link","← Назад к списку");
back.onclick=function(){ render(); };
app.appendChild(back);
var e=lastEntry(p); var z=entryZone(p);
// шапка пациента
var head=el("div","banner");
head.innerHTML='<div><h1>'+p.name+'</h1><p>'+p.age+' лет · '+p.diag+' · '+(e?'обновлено '+fmtTime(e.ts):'нет данных')+'</p></div>'+
'<div><span class="zone-chip '+z+'"><span class="d"></span>'+ZONE_TXT[z].t+'</span></div>';
app.appendChild(head);
if(e){
// показатели
var pc=el("div","card");
pc.appendChild(el("h3","","Последние показатели",{"style":"margin-bottom:14px"}));
var g=el("div","grid4");
g.appendChild(vitalTile("🩸 SpO₂", e.spo2, "%", zoneSpo2(e.spo2)));
g.appendChild(vitalTile("🩺 АД", e.sys+"/"+e.dia, "мм рт.ст.", bpZone(e.sys,e.dia)));
g.appendChild(vitalTile("❤️ ЧСС", e.hr, "уд/мин", zoneHr(e.hr)));
g.appendChild(vitalTile("🌬 ЧДД", e.rr, "/мин", zoneRr(e.rr)));
pc.appendChild(g);
var g2=el("div","grid2");
g2.appendChild(infoTile("😤 Одышка (mMRC)", e.mmrc+" балл(ов)", mmrcTxt(e.mmrc)));
var cl=catLevel(e.catTotal);
g2.appendChild(infoTile("📋 Анкета CAT", e.catTotal+" баллов", cl.t, cl.c));
pc.appendChild(g2);
if(e.note){ pc.appendChild(el("div","muted","Заметка пациента: "+e.note,{"style":"margin-top:12px"})); }
app.appendChild(pc);
// препараты
app.appendChild(renderMedsCard(p, e, false));
// флаги / рекомендации
var flags=buildFlags(e).concat(buildMedFlags(p));
if(flags.length){
var fc=el("div","card");
fc.appendChild(el("h3","","🚩 Текущие флаги и рекомендации",{"style":"margin-bottom:14px"}));
flags.forEach(function(f){
var d=el("div","flag"+(f.type==="amber"?" amber":""));
d.appendChild(el("h4","",f.title));
var ul=el("ul");
f.body.forEach(function(li){ ul.appendChild(el("li","",li)); });
d.appendChild(ul); d.appendChild(el("div","call",f.call));
fc.appendChild(d);
});
app.appendChild(fc);
}
// история + тренд
var hc=el("div","card");
hc.appendChild(el("h3","","Динамика и история",{"style":"margin-bottom:14px"}));
hc.appendChild(trendCard(p));
hc.appendChild(historyTable(p));
app.appendChild(hc);
} else {
app.appendChild(emptyCard("Нет записей по пациенту."));
}
// вмешательства и назначения врача
app.appendChild(interventionsCard(p));
}
function interventionsCard(p){
var c=el("div","card");
c.appendChild(el("h3","","📝 Вмешательства и назначения врача",{"style":"margin-bottom:14px"}));
if(!p.interventions) p.interventions=[];
if(p.interventions.length){
p.interventions.slice().reverse().forEach(function(iv){
var row=el("div","iv-line");
row.innerHTML='<b>'+iv.text+'</b><div class="iv-meta">'+fmtTime(iv.ts)+'</div>';
c.appendChild(row);
});
} else {
c.appendChild(el("div","muted","Пока нет записей.",{"style":"margin-bottom:12px"}));
}
var ta=el("textarea","input"); ta.id="ivText"; ta.rows=2; ta.placeholder="Записать вмешательство/назначение (напр.: «Усилить О₂ до 3 л/мин, добавить преднизолон 30 мг»)"; ta.style.marginBottom="10px"; ta.style.width="100%";
c.appendChild(ta);
var btn=el("button","btn sm","Добавить запись");
btn.onclick=function(){ addIntervention(p); };
c.appendChild(btn);
return c;
}
function addIntervention(p){
var ta=$("#ivText"); var t=(ta&&ta.value||"").trim();
if(!t){ toast("Введите текст вмешательства"); return; }
if(!p.interventions) p.interventions=[];
p.interventions.push({ts:Date.now(), text:t});
save(state); openPatientDetail(p.id); toast("Вмешательство записано");
}
function trendCard(p){
var c=el("div");
c.style.marginBottom="16px";
var rows=[{k:"spo2",lbl:"SpO₂ %",max:100,min:80},{k:"hr",lbl:"ЧСС",max:130,min:50},{k:"rr",lbl:"ЧДД",max:30,min:10}];
rows.forEach(function(r){
var line=el("div","","",{"style":"display:flex;align-items:center;gap:12px;margin-bottom:8px"});
line.appendChild(el("div","","",{"style":"width:60px;font-size:13px;color:var(--gray-500);font-weight:600","text":r.lbl}));
var sp=el("div","spark");
var vals=p.entries.slice(-12).map(function(e){return e[r.k];});
vals.forEach(function(v){
var i=el("i");
var pct=Math.max(8,Math.min(100,((v-r.min)/(r.max-r.min))*100));
i.style.height=pct+"%";
var z=r.k==="spo2"?zoneSpo2(v):r.k==="hr"?zoneHr(v):zoneRr(v);
if(z==="red") i.className="r"; else if(z==="yellow") i.className="y";
sp.appendChild(i);
});
line.appendChild(sp);
c.appendChild(line);
});
return c;
}
/* ============ ХЕЛПЕРЫ UI ============ */
function emptyCard(msg){
var c=el("div","card");
c.style.textAlign="center";c.style.color="var(--gray-500)";
c.textContent=msg;
return c;
}
function emptyState(msg){
var c=el("div","empty");
c.innerHTML='<div class="ic">📭</div>'+msg;
return c;
}
function openModal(){ $("#modalBg").classList.add("open"); }
function closeModal(){ $("#modalBg").classList.remove("open"); }
$("#modalBg").addEventListener("click",function(e){ if(e.target===$("#modalBg")) closeModal(); });
/* ============ РОЛИ ============ */
$("#rolePatient").onclick=function(){ state.role="patient"; save(state); render(); setActiveRole(); };
$("#roleStaff").onclick=function(){ state.role="staff"; save(state); render(); setActiveRole(); };
function setActiveRole(){
$("#rolePatient").classList.toggle("active", state.role==="patient");
$("#roleStaff").classList.toggle("active", state.role==="staff");
}
/* выбор пациента (для демо) — переключатель в баннере пациента */
document.addEventListener("click",function(e){
if(e.target && e.target.dataset && e.target.dataset.patSwitch){
state.currentPatient=e.target.dataset.patSwitch; save(state); render();
}
});
/* первый рендер */
setActiveRole();
render();
/* автообновление напоминаний о препаратах каждые 60сек */
setInterval(function(){ if(state.role==="patient") render(); }, 60000);
</script>
</body>
</html>