1404 lines
75 KiB
HTML
1404 lines
75 KiB
HTML
<!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",
|
||
staffTab:"patients",
|
||
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:"Срочно! Выполните рекомендации и вызовите скорую при необходимости."}
|
||
};
|
||
|
||
/* ============ ПРОТОКОЛ «СВЕТОФОР» (расширенные действия) ============ */
|
||
var ZONE_PROTOCOL = {
|
||
green:{
|
||
title:"🟢 Зелёная зона — стабилен",
|
||
crit:"SpO₂ ≥ 95% · ЧДД 12–20/мин · ЧСС 60–100 уд/мин · CAT не растёт · нет гнойной мокроты",
|
||
patient:["Продолжать базисную терапию строго по схеме","Вести дневник 2–3 раза в день","Принимать препараты по расписанию, не пропускать","Дыхательная гимнастика и регулярная физическая активность","Вакцинация (грипп, пневмококк) по графику"],
|
||
staff:["Рутинное наблюдение, просмотр динамики 1–2 раза в неделю","Контроль adherence и техники ингаляции","Плановый осмотр 1 раз в 1–3 месяца, спирометрия, CAT","Обучение и поддержка самопомощи"],
|
||
esc:"Эскалации не требуется"
|
||
},
|
||
yellow:{
|
||
title:"🟡 Жёлтая зона — внимание",
|
||
crit:"SpO₂ 90–94% · ЧДД 21–24/мин · ЧСС 101–110 уд/мин · CAT вырос на ≥2 балла · нарастание одышки",
|
||
patient:["Применить спасательный бронхолитик (сальбутамол 2–4 вдоха / небулайзер)","Проверить и при необходимости усилить О₂ по схеме","Контроль SpO₂ каждые 15–30 минут","Усилить контроль до 3–4 замеров/день","Связаться с медсестрой в течение дня","Начать резервный ГКС/антибиотик по плану врача при гнойной мокроте"],
|
||
staff:["Связаться с пациентом в течение дня","Оценить adherence и технику ингаляции","Коррекция терапии: усилить бронхолитик, рассмотреть ИГКС","Контроль через 24–48 часов","Информировать лечащего врача при отсутствии эффекта"],
|
||
esc:"Нет эффекта за 24–48ч → красная зона, рассмотреть госпитализацию"
|
||
},
|
||
red:{
|
||
title:"🔴 Красная зона — ухудшение",
|
||
crit:"SpO₂ < 90% · ЧДД > 24/мин · ЧСС > 110 уд/мин · резкая одышка в покое · гнойная мокрота · лихорадка",
|
||
patient:["Увеличить поток О₂ по схеме","Бронхолитик через небулайзер/ДАИ (сальбутамол ± ипратропий)","Положение ортопноэ, дыхание через сжатые губы","Старт системной терапии по плану: ГКС ± антибиотик","Вызвать скорую при SpO₂<88% >5мин или нарастании одышки"],
|
||
staff:["Срочный контакт с пациентом, решение о госпитализации/вызове бригады","Назначить/скорректировать системную терапию (ГКС, антибиотики, О₂)","Контроль через 1–4 часа","Передача информации лечащему врачу и оформление вмешательства","Подготовка к госпитализации при нарастании РДС"],
|
||
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:"Я постоянно кашляю"},
|
||
{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; 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()); }
|
||
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₂ снижена (90–94%)",body:[
|
||
"Проверьте подачу кислорода, при необходимости увеличьте по схеме",
|
||
"Примените спасательный бронхолитик",
|
||
"Контролируйте SpO₂ каждые 15–30 минут",
|
||
"Свяжитесь с медсестрой, если не растёт"
|
||
],call:"ℹ️ Сообщите врачу, если держится < 92% несколько часов"});
|
||
}
|
||
if(entry.mmrc>=3){
|
||
flags.push({type:"red",title:"😤 Резкая одышка в покое (mMRC ≥3)",body:[
|
||
"Примите спасательный бронхолитик (2–4 вдоха сальбутамола)",
|
||
"Примите положение ортопноэ — сидя с наклоном вперёд",
|
||
"Дышите медленно, удлинённым выдохом",
|
||
"Контролируйте SpO₂ каждые 5 минут"
|
||
],call:"🚑 Скорая, если одышка не уменьшается через 15–20 мин"});
|
||
}
|
||
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>Выполняйте 5–10 минут 3–4 раза в день, особенно при одышке.</li>"));
|
||
edu.appendChild(eduBlock("💊 Техника ингаляции",
|
||
"<li>Встряхните ДАИ, снимите колпачок, выдохните мимо ингалятора.</li>"+
|
||
"<li>Обхватите мундштук губами, начинайте вдох и нажмите на баллончик одновременно.</li>"+
|
||
"<li>Продолжайте медленный глубокий вдох 3–5 сек, задержите дыхание на 10 сек.</li>"+
|
||
"<li>После ингаляции ГКС — прополощите рот водой.</li>"));
|
||
edu.appendChild(eduBlock("🚦 План действий (светофор)",
|
||
"<li><b>🟢 Зелёная зона:</b> всё как обычно — базисная терапия, дневник.</li>"+
|
||
"<li><b>🟡 Жёлтая зона:</b> SpO₂ 90–94% — спасатель, контроль, связаться с медсестрой.</li>"+
|
||
"<li><b>🔴 Красная зона:</b> SpO₂ <90% — кислород, бронхолитик, положение, вызов скорой.</li>"));
|
||
edu.appendChild(eduBlock("🚑 Когда вызывать скорую",
|
||
"<li>SpO₂ < 88% держится более 5 минут или нарастает одышка.</li>"+
|
||
"<li>Одышка в покое не уменьшается через 15–20 минут после бронхолитика.</li>"+
|
||
"<li>Спутанность сознания, синюшность губ, боли в груди.</li>"+
|
||
"<li>Температура > 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); // 88–96
|
||
var hr = 64+Math.floor(Math.random()*42); // 64–105
|
||
$("#spo2").value=v; $("#hr").value=hr;
|
||
toast("Пульсоксиметр: SpO₂ "+v+"%, ЧСС "+hr);
|
||
} else {
|
||
var s=116+Math.floor(Math.random()*34); // 116–149
|
||
var d=74+Math.floor(Math.random()*20); // 74–93
|
||
$("#bpSys").value=s; $("#bpDia").value=d;
|
||
toast("Тонометр: АД "+s+"/"+d);
|
||
}
|
||
}
|
||
|
||
/* ============ ГОЛОСОВОЙ ВВОД (Web Speech API) ============ */
|
||
var RU_ONES={ноль:0,один:1,одна:1,два:2,две:2,три:3,четыре:4,пять:5,шесть:6,семь:7,восемь:8,девять:9};
|
||
var RU_TEENS={десять:10,одиннадцать:11,двенадцать:12,тринадцать:13,четырнадцать:14,пятнадцать:15,шестнадцать:16,семнадцать:17,восемнадцать:18,девятнадцать:19};
|
||
var RU_TENS={двадцать:20,тридцать:30,сорок:40,пятьдесят:50,шестьдесят:60,семьдесят:70,восемьдесят:80,девяносто:90};
|
||
var RU_HUND={сто:100,двести:200,триста:300,четыреста:400,пятьсот:500};
|
||
function isRuNum(w){ return RU_ONES[w]!=null||RU_TEENS[w]!=null||RU_TENS[w]!=null||RU_HUND[w]!=null; }
|
||
function parseRuNum(tokens, start){
|
||
var total=0, found=false, i=start;
|
||
while(i<tokens.length){
|
||
var w=tokens[i].toLowerCase().replace(/[.,!?;:]/g,"");
|
||
if(RU_HUND[w]!=null){ total+=RU_HUND[w]; found=true; i++; }
|
||
else if(RU_TENS[w]!=null){ total+=RU_TENS[w]; found=true; i++; }
|
||
else if(RU_TEENS[w]!=null){ total+=RU_TEENS[w]; found=true; i++; break; }
|
||
else if(RU_ONES[w]!=null){ total+=RU_ONES[w]; found=true; i++; break; }
|
||
else break;
|
||
}
|
||
return {num:total, next:i, found:found};
|
||
}
|
||
function wordsToDigits(text){
|
||
var tokens=text.split(/\s+/); var out=[], i=0;
|
||
while(i<tokens.length){
|
||
var w=tokens[i].toLowerCase().replace(/[.,!?;:]/g,"");
|
||
if(isRuNum(w)){ var r=parseRuNum(tokens,i); out.push(String(r.num)); i=r.next; }
|
||
else { out.push(tokens[i]); i++; }
|
||
}
|
||
return out.join(" ");
|
||
}
|
||
function parseVitals(transcript){
|
||
var text=wordsToDigits((transcript||"").toLowerCase());
|
||
var res={};
|
||
var m;
|
||
if(/(сатурац|кислород|spo|эспио|эспо|сату)/.test(text)){
|
||
m=text.match(/(?:сатурац\w*|кислород\w*|spo\w*|эспио\w*|эспо\w*|сату\w*)\D{0,6}(\d{2,3})/);
|
||
if(m) res.spo2=+m[1];
|
||
}
|
||
if(/(давлен|pressure|ад\b|давление)/.test(text)){
|
||
var bm=text.match(/(?:давлен\w*|pressure|ад)\D{0,8}(\d{2,3})\D{1,6}(\d{2,3})/);
|
||
if(bm){ res.sys=+bm[1]; res.dia=+bm[2]; }
|
||
else { var bm2=text.match(/(?:давлен\w*|pressure|ад)\D{0,6}(\d{2,3})/); if(bm2) res.sys=+bm2[1]; }
|
||
}
|
||
if(/(пульс|сердц|чсс|частота пульс)/.test(text)){
|
||
m=text.match(/(?:пульс\w*|сердц\w*|чсс)\D{0,6}(\d{2,3})/);
|
||
if(m) res.hr=+m[1];
|
||
}
|
||
if(/(дыхан|чдд|частота дых)/.test(text)){
|
||
m=text.match(/(?:дыхан\w*|чдд)\D{0,6}(\d{2})/);
|
||
if(m) res.rr=+m[1];
|
||
}
|
||
return res;
|
||
}
|
||
function setFieldIf(id,val){ var f=$("#"+id); if(f && val!=null && !isNaN(val)){ f.value=val; return true; } return false; }
|
||
function startVoiceInput(btn){
|
||
var SR=window.SpeechRecognition||window.webkitSpeechRecognition;
|
||
if(!SR){ toast("Голосовой ввод не поддерживается. Откройте в Chrome/Edge."); return; }
|
||
if(btn.dataset.listening==="1"){ window.__rec && window.__rec.stop(); return; }
|
||
var rec=new SR();
|
||
window.__rec=rec;
|
||
rec.lang="ru-RU"; rec.continuous=false; rec.interimResults=false;
|
||
var stat=$("#voiceStat");
|
||
btn.dataset.listening="1"; btn.textContent="⏹ Стоп"; btn.style.background="var(--red)"; btn.style.color="#fff";
|
||
if(stat){ stat.style.display="inline"; stat.textContent="🔴 Слушаю… скажите: «сатурация 95, давление 130 на 85, пульс 78, дыхание 18»"; }
|
||
rec.onresult=function(ev){
|
||
var tr=ev.results[0][0].transcript;
|
||
var v=parseVitals(tr);
|
||
var parts=[];
|
||
if(v.spo2!=null){ setFieldIf("spo2",v.spo2); parts.push("SpO₂ "+v.spo2); }
|
||
if(v.sys!=null){ setFieldIf("bpSys",v.sys); parts.push("АД сис "+v.sys); }
|
||
if(v.dia!=null){ setFieldIf("bpDia",v.dia); parts.push("АД диа "+v.dia); }
|
||
if(v.hr!=null){ setFieldIf("hr",v.hr); parts.push("ЧСС "+v.hr); }
|
||
if(v.rr!=null){ setFieldIf("rr",v.rr); parts.push("ЧДД "+v.rr); }
|
||
toast(parts.length?("Распознано: "+parts.join(", ")):"Не удалось распознать показатели. Скажите чётче.");
|
||
if(stat) stat.textContent="«"+tr+"»";
|
||
};
|
||
rec.onerror=function(ev){ toast("Ошибка распознавания: "+(ev.error||"")); };
|
||
rec.onend=function(){
|
||
btn.dataset.listening="0"; btn.textContent="🎤 Голосом"; btn.style.background=""; btn.style.color="";
|
||
if(stat){ setTimeout(function(){ stat.style.display="none"; }, 2500); }
|
||
};
|
||
try{ rec.start(); }catch(e){ toast("Не удалось запустить микрофон"); rec.onend(); }
|
||
}
|
||
|
||
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);
|
||
var bVoice = el("button","btn ghost sm","🎤 Голосом");
|
||
bVoice.id="voiceBtn";
|
||
bVoice.onclick=function(){ startVoiceInput(bVoice); };
|
||
devBar.appendChild(bVoice);
|
||
var voiceStat = el("span","devlbl","",{id:"voiceStat",style:"display:none"});
|
||
devBar.appendChild(voiceStat);
|
||
body.appendChild(devBar);
|
||
var fg = el("div","form-grid");
|
||
fg.appendChild(field('spo2','number','🩸 Сатурация (SpO₂)','90',true,"%, 50–100"));
|
||
fg.appendChild(bpField());
|
||
fg.appendChild(field('hr','number','❤️ ЧСС (уд/мин)','60–100',true,"уд/мин"));
|
||
fg.appendChild(field('rr','number','🌬 ЧДД (/мин)','12–20',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 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(){
|
||
// баннер
|
||
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 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)); });
|
||
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};
|
||
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 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));
|
||
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>
|