First
This commit is contained in:
52
htdocs/App.vue
Normal file
52
htdocs/App.vue
Normal file
@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div class="container-fluid">
|
||||
<Navbar :user="user" />
|
||||
<div class="container">
|
||||
<router-view v-if="user"></router-view>
|
||||
<Login :secret="secret" v-model:client="client" v-model:user="user" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Navbar from './Navbar.vue';
|
||||
import Login from './Login.vue';
|
||||
import RouteSchedule from './RouteSchedule.vue';
|
||||
import RoutePatientLookup from './RoutePatientLookup.vue';
|
||||
import RoutePatientDetail from './RoutePatientDetail.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Navbar, Login
|
||||
},
|
||||
props: {
|
||||
secret: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
client: null,
|
||||
user: null,
|
||||
heartbeat: null,
|
||||
banner: '',
|
||||
authenticated: false
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
store: () => store
|
||||
},
|
||||
watch: {
|
||||
async client(value) {
|
||||
if(this.heartbeat) window.clearInterval(this.heartbeat);
|
||||
else {
|
||||
[
|
||||
{ path: '/', component: RouteSchedule, props: { client: this.client } },
|
||||
{ path: '/patient', component: RoutePatientLookup, props: { client: this.client } },
|
||||
{ path: '/patient/:id', component: RoutePatientDetail, props: { client: this.client } },
|
||||
].forEach(route => this.$root.$router.addRoute(route));
|
||||
await this.$root.$router.replace(this.$route);
|
||||
}
|
||||
this.heartbeat = await value.heartbeat();
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
104
htdocs/Autocomplete.vue
Normal file
104
htdocs/Autocomplete.vue
Normal file
@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div class="autocomplete">
|
||||
<input type="text" @input="option_open" v-model="xvalue" @keydown.down="option_down" @keydown.up="option_up" @keydown.enter="option_enter" />
|
||||
<ul id="autocomplete-results" v-show="open" class="autocomplete-results">
|
||||
<li class="loading" v-if="!items">Loading results...</li>
|
||||
<li v-else v-for="(result, i) in results" :key="i" @click="option_click(result)" class="autocomplete-result" :class="{ 'is-active': i === index }">{{ result }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.autocomplete {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.autocomplete-results {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 1px solid #eeeeee;
|
||||
height: 120px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.autocomplete-result {
|
||||
list-style: none;
|
||||
text-align: left;
|
||||
padding: 4px 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.autocomplete-result.is-active,
|
||||
.autocomplete-result:hover {
|
||||
background-color: #4AAE9B;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => [],
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
xvalue: '',
|
||||
results: [],
|
||||
open: false,
|
||||
index: -1,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
value(val) {
|
||||
this.xvalue = val;
|
||||
},
|
||||
xvalue(val) {
|
||||
this.$emit('update:value', val);
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.xvalue = this.value;
|
||||
document.addEventListener('click', this.option_close)
|
||||
},
|
||||
destroyed() {
|
||||
document.removeEventListener('click', this.option_close)
|
||||
},
|
||||
methods: {
|
||||
option_open() {
|
||||
if(this.items) {
|
||||
this.results = this.items.filter((item) => item.toLowerCase().indexOf(this.xvalue.toLowerCase()) > -1);
|
||||
this.open = true;
|
||||
}
|
||||
},
|
||||
option_down() {
|
||||
if(this.index < this.results.length) this.index++;
|
||||
},
|
||||
option_up() {
|
||||
if(this.index > 0) this.index--;
|
||||
},
|
||||
option_enter() {
|
||||
this.xvalue = this.results[this.index];
|
||||
this.open = false;
|
||||
this.index = -1;
|
||||
},
|
||||
option_click(result) {
|
||||
this.xvalue = result;
|
||||
this.open = false;
|
||||
},
|
||||
option_close(evt) {
|
||||
if(!this.$el.contains(evt.target)) {
|
||||
this.open = false;
|
||||
this.index = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
154
htdocs/DateRangePicker.vue
Normal file
154
htdocs/DateRangePicker.vue
Normal file
@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div class="btn-group" role="group">
|
||||
<template v-if="x_range == '1D'">
|
||||
<button type="button" class="btn btn-sm btn-primary" @click="x_date = timeshift(x_date, -24*60*60*1000*(reversed ? -1 : +1))">🡠</button>
|
||||
<input type="date" class="form-control" v-model="disp_date" />
|
||||
<DateRangePickerRange v-model="x_range" :direction="direction" />
|
||||
<button type="button" class="btn btn-sm btn-primary" @click="x_date = timeshift(x_date, 24*60*60*1000*(reversed ? -1 : +1))">🡢</button>
|
||||
</template>
|
||||
<template v-if="x_range == '1W'">
|
||||
<button type="button" class="btn btn-sm btn-primary" @click="x_date = timeshift(x_date, -7*24*60*60*1000*(reversed ? -1 : +1))">🡠</button>
|
||||
<input type="date" class="form-control" v-model="disp_date" />
|
||||
<DateRangePickerRange v-model="x_range" :direction="direction" />
|
||||
<button type="button" class="btn btn-sm btn-primary" @click="x_date = timeshift(x_date, 7*24*60*60*1000*(reversed ? -1 : +1))">🡢</button>
|
||||
</template>
|
||||
<template v-if="x_range == '1M'">
|
||||
<button type="button" class="btn btn-sm btn-primary" @click="x_date = timeshift_month(x_date, -1*(reversed ? -1 : +1))">🡠</button>
|
||||
<input type="date" class="form-control" v-model="disp_date" />
|
||||
<DateRangePickerRange v-model="x_range" :direction="direction" />
|
||||
<button type="button" class="btn btn-sm btn-primary" @click="x_date = timeshift_month(x_date, 1*(reversed ? -1 : +1))">🡢</button>
|
||||
</template>
|
||||
<template v-if="x_range == '6M'">
|
||||
<button type="button" class="btn btn-sm btn-primary" @click="x_date = timeshift_month(x_date, -6*(reversed ? -1 : +1))">🡠</button>
|
||||
<input type="date" class="form-control" v-model="disp_date" />
|
||||
<DateRangePickerRange v-model="x_range" :direction="direction" />
|
||||
<button type="button" class="btn btn-sm btn-primary" @click="x_date = timeshift_month(x_date, 6*(reversed ? -1 : +1))">🡢</button>
|
||||
</template>
|
||||
<template v-if="x_range == '1Y'">
|
||||
<button type="button" class="btn btn-sm btn-primary" @click="x_date = timeshift_month(x_date, -12*(reversed ? -1 : +1))">🡠</button>
|
||||
<input type="date" class="form-control" v-model="disp_date" />
|
||||
<DateRangePickerRange v-model="x_range" :direction="direction" />
|
||||
<button type="button" class="btn btn-sm btn-primary" @click="x_date = timeshift_month(x_date, 12*(reversed ? -1 : +1))">🡢</button>
|
||||
</template>
|
||||
<template v-if="x_range == '2Y'">
|
||||
<button type="button" class="btn btn-sm btn-primary" @click="x_date = timeshift_month(x_date, -24*(reversed ? -1 : +1))">🡠</button>
|
||||
<input type="date" class="form-control" v-model="disp_date" />
|
||||
<DateRangePickerRange v-model="x_range" :direction="direction" />
|
||||
<button type="button" class="btn btn-sm btn-primary" @click="x_date = timeshift_month(x_date, 24*(reversed ? -1 : +1))">🡢</button>
|
||||
</template>
|
||||
<template v-if="x_range == 'Range'">
|
||||
<input type="date" class="form-control" v-model="disp_date" />
|
||||
<input type="date" class="form-control" v-model="disp_date_end" />
|
||||
<DateRangePickerRange v-model="x_range" :direction="direction" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DateRangePickerRange from './DateRangePickerRange.vue';
|
||||
|
||||
function timeshift(date, ms) {
|
||||
return new Date(date.getTime() + ms);
|
||||
}
|
||||
|
||||
function timeshift_month(date, diff) {
|
||||
var month = date.getMonth() + diff;
|
||||
return new Date(date.getFullYear() + Math.floor(month/12), month >= 0 ? (month%12) : (month%12 + 12), date.getDate());
|
||||
}
|
||||
|
||||
function datecalc(date, range, direction) {
|
||||
switch(range) {
|
||||
case '1D':
|
||||
return timeshift(date, Math.sign(direction)*24*60*60*1000);
|
||||
case '1W':
|
||||
return timeshift(date, Math.sign(direction)*7*24*60*60*1000);
|
||||
case '1M':
|
||||
return timeshift_month(date, Math.sign(direction)*1);
|
||||
case '6M':
|
||||
return timeshift_month(date, Math.sign(direction)*6);
|
||||
case '1Y':
|
||||
return timeshift_month(date, Math.sign(direction)*12);
|
||||
case '2Y':
|
||||
return timeshift_month(date, Math.sign(direction)*24);
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
export default {
|
||||
components: {
|
||||
DateRangePickerRange
|
||||
},
|
||||
props: {
|
||||
range: {
|
||||
type: String,
|
||||
default: '1D'
|
||||
},
|
||||
direction: {
|
||||
type: String,
|
||||
default: '-1'
|
||||
},
|
||||
date: {
|
||||
type: Date,
|
||||
default: new Date()
|
||||
},
|
||||
date_end: {
|
||||
type: Date,
|
||||
default: new Date()
|
||||
},
|
||||
reversed: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
x_range: this.range,
|
||||
x_date: this.date
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
disp_date: {
|
||||
get() {
|
||||
return this.x_date.toLocaleDateString('en-CA');
|
||||
},
|
||||
set(value) {
|
||||
value = value.split('-')
|
||||
this.x_date = new Date(value[0], value[1] - 1, value[2]);
|
||||
}
|
||||
},
|
||||
disp_date_end: {
|
||||
get() {
|
||||
return this.x_date_end.toLocaleDateString('en-CA');
|
||||
},
|
||||
set(value) {
|
||||
value = value.split('-')
|
||||
this.x_date_end = new Date(value[0], value[1] - 1, value[2]);
|
||||
}
|
||||
},
|
||||
params() {
|
||||
return {
|
||||
x_date: this.x_date,
|
||||
x_range: this.x_range,
|
||||
direction: this.direction
|
||||
};
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
params(value) {
|
||||
if(value.x_range != 'Range') this.x_date_end = datecalc(value.x_date, value.x_range, value.direction);
|
||||
this.$emit('update:date', value.x_date);
|
||||
this.$emit('update:date_end', this.x_date_end);
|
||||
},
|
||||
date(value) { this.x_date = value; },
|
||||
x_date(value) { this.$emit('update:date', value); },
|
||||
range(value) { this.x_range = value; },
|
||||
x_range(value) { this.$emit('update:range', value); }
|
||||
},
|
||||
methods: {
|
||||
timeshift, timeshift_month
|
||||
},
|
||||
mounted() {
|
||||
this.$emit('update:date_end', this.x_date_end = datecalc(this.x_date, this.x_range, this.direction));
|
||||
}
|
||||
};
|
||||
</script>
|
39
htdocs/DateRangePickerRange.vue
Normal file
39
htdocs/DateRangePickerRange.vue
Normal file
@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<select class="form-select form-select-sm" style="width: auto;" v-model="x_modelValue">
|
||||
<option value="1D">{{disp_direction}}1D</option>
|
||||
<option value="1W">{{disp_direction}}1W</option>
|
||||
<option value="1M">{{disp_direction}}1M</option>
|
||||
<option value="6M">{{disp_direction}}6M</option>
|
||||
<option value="1Y">{{disp_direction}}1Y</option>
|
||||
<option value="2Y">{{disp_direction}}2Y</option>
|
||||
<option value="Range">Range</option>
|
||||
</select>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '1D'
|
||||
},
|
||||
direction: {
|
||||
default: '-1'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
x_modelValue: this.modelValue
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
disp_direction() {
|
||||
return this.direction > 0 ? '+' : this.direction < 0 ? '−' : '';
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
modelValue(value) { this.x_modelValue = value; },
|
||||
x_modelValue(value) { this.$emit('update:modelValue', this.x_modelValue); }
|
||||
}
|
||||
};
|
||||
</script>
|
75
htdocs/Login.vue
Normal file
75
htdocs/Login.vue
Normal file
@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div class="card mb-3 shadow">
|
||||
<div class="card-header"><template v-if="user">{{user[2]}}</template><template v-else>Login</template></div>
|
||||
<div class="card-body">
|
||||
<p class="card-text row"><code class="col" v-if="banner"><pre>{{banner.join('\n')}}</pre></code><code class="col" v-if="user"><pre>{{user.join('\n')}}</pre></code></p>
|
||||
</div>
|
||||
<div class="input-group flex-nowrap" v-if="!user">
|
||||
<span class="input-group-text">🔑</span>
|
||||
<input type="password" class="form-control" placeholder="Access Code" v-model="accesscode" />
|
||||
<input type="password" class="form-control" placeholder="Verify Code" v-model="verifycode" />
|
||||
<button class="btn btn-primary" type="button" v-on:click="submit">Login<template v-if="!(accesscode || verifycode)"> (omit AV codes for SAML)</template></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import vistax from './vistax.mjs';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
secret: String,
|
||||
client: Object,
|
||||
user: {
|
||||
type: Array,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
emits: {
|
||||
'update:client': Object,
|
||||
'update:user': {
|
||||
type: Array,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
x_client: this.client,
|
||||
x_user: this.user,
|
||||
banner: null,
|
||||
accesscode: null,
|
||||
verifycode: null
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
client(value) { this.x_client = value; },
|
||||
x_client(value) { this.$emit('update:client', value); },
|
||||
user(value) { this.x_user = value; },
|
||||
x_user(value) { this.$emit('update:user', value); }
|
||||
},
|
||||
async mounted() {
|
||||
this.x_client = await vistax.Client.fromCookie(this.secret);
|
||||
this.banner = await this.x_client.XUS_INTRO_MSG();
|
||||
if((await this.x_client.userinfo()).result) try {
|
||||
var user = await this.x_client.XUS_GET_USER_INFO();
|
||||
this.x_user = user[0] ? user : null
|
||||
} catch(ex) {
|
||||
this.x_user = null;
|
||||
}
|
||||
this.$emit('update:user', this.x_user);
|
||||
console.log('Backend secret', this.secret);
|
||||
console.log(this.banner);
|
||||
},
|
||||
methods: {
|
||||
async submit(evt) {
|
||||
var res = await ((this.accesscode && this.verifycode) ? this.x_client.authenticate(this.accesscode + ';' + this.verifycode) : this.x_client.authenticate());
|
||||
if(!!res.result[0]) {
|
||||
var user = await this.x_client.XUS_GET_USER_INFO();
|
||||
this.x_user = user[0] ? user : null
|
||||
} else this.x_user = null;
|
||||
this.$emit('update:user', this.x_user);
|
||||
console.log('Authenticate', res);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
43
htdocs/Navbar.vue
Normal file
43
htdocs/Navbar.vue
Normal file
@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<nav class="navbar navbar-expand-lg bg-dark">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="/"><template v-if="user">{{user[2]}}</template><template v-else>nuVistA</template></a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarSupportedContent">
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/">Schedule</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/patient">Patient</a>
|
||||
</li>
|
||||
<li class="nav-item" v-if="user">
|
||||
<a class="nav-link disabled">{{user[3]}}</a>
|
||||
</li>
|
||||
</ul>
|
||||
<form class="d-flex" role="search">
|
||||
<input class="form-control me-2" type="search" placeholder="Search" aria-label="Search">
|
||||
<button class="btn btn-outline-success" type="submit">Search</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import vistax from './vistax.mjs';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
user: {
|
||||
type: Array,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
</script>
|
80
htdocs/RoutePatientDetail.vue
Normal file
80
htdocs/RoutePatientDetail.vue
Normal file
@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<div v-if="info">
|
||||
<div class="card mb-3 shadow">
|
||||
<div class="card-header">{{info.name}} #{{$route.params.id}} ${{info.pid}}</div>
|
||||
<div class="card-body row" style="font-family: monospace;">
|
||||
<div class="col" v-if="info.dob"><strong>DOB:</strong> {{strptime_vista(info.dob).toLocaleDateString('en-CA')}}</div>
|
||||
<div class="col" v-if="info.age"><strong>Age:</strong> {{info.age}}</div>
|
||||
<div class="col" v-if="info.sex"><strong>Sex:</strong> {{info.sex}}</div>
|
||||
<div class="col" v-if="info.sc_percentage"><strong>SC%:</strong> {{info.sc_percentage}}</div>
|
||||
<div class="col" v-if="info.type"><strong>Type:</strong> {{info.type}}</div>
|
||||
<div class="col" v-if="info.ward"><strong>Ward:</strong> {{info.ward}}</div>
|
||||
<div class="col" v-if="info.room_bed"><strong>Room/bed:</strong> {{info.room_bed}}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mb-3 shadow">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>Vitals</span>
|
||||
<DateRangePicker range="1M" direction="-1" v-model:date="vitals_date" v-model:date_end="vitals_date_begin" />
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ViewVitalsLabs :client="client" :dfn="$route.params.id" :date_begin="vitals_date_begin" :date_end="vitals_date" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { uniq, groupByArray, strptime_vista } from './util.mjs';
|
||||
|
||||
import DateRangePicker from './DateRangePicker.vue';
|
||||
import ViewVitalsLabs from './ViewVitalsLabs.vue';
|
||||
|
||||
var now = new Date();
|
||||
|
||||
export default {
|
||||
components: {
|
||||
DateRangePicker, ViewVitalsLabs
|
||||
},
|
||||
props: {
|
||||
client: Object
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
info: null,
|
||||
vitals_date: now,
|
||||
vitals_date_begin: now,
|
||||
labs_date: now,
|
||||
labs_date_begin: now
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
info(value) {
|
||||
if((value) && (value.name)) document.title = value.name;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
strptime_vista
|
||||
},
|
||||
async mounted() {
|
||||
if(this.$route.params.id.startsWith('$')) {
|
||||
var id = this.$route.params.id.substring(1);
|
||||
if(id.length == 9) {
|
||||
var patient = await this.client.ORWPT_FULLSSN(id);
|
||||
this.$router.replace('/patient/' + patient[0].dfn);
|
||||
} else if(id.length == 5) {
|
||||
var name = this.$route.query.name.toUpperCase();
|
||||
var patient = await this.client.ORWPT_LAST5(id);
|
||||
for(var i = 0; i < patient.length; ++i) if(name == patient[i].name) {
|
||||
this.$router.replace('/patient/' + patient[0].dfn);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else this.info = await this.client.ORWPT16_ID_INFO(this.$route.params.id);
|
||||
},
|
||||
async beforeRouteUpdate(to, from, next) {
|
||||
this.info = await this.client.ORWPT16_ID_INFO(to.params.id);
|
||||
next();
|
||||
}
|
||||
};
|
||||
</script>
|
32
htdocs/RoutePatientLookup.vue
Normal file
32
htdocs/RoutePatientLookup.vue
Normal file
@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="card mb-3 shadow">
|
||||
<div class="card-header">Patients</div>
|
||||
<div class="card-body">
|
||||
<ViewPatientLookup :client="client" v-model:selection="selection" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ViewPatientLookup from './ViewPatientLookup.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ViewPatientLookup
|
||||
},
|
||||
props: {
|
||||
client: Object
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selection: null
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
},
|
||||
methods: {
|
||||
}
|
||||
};
|
||||
</script>
|
59
htdocs/RouteSchedule.vue
Normal file
59
htdocs/RouteSchedule.vue
Normal file
@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="card mb-3 shadow">
|
||||
<div class="card-header">Clinics</div>
|
||||
<div class="card-body">
|
||||
<ViewResourceLookup :client="client" v-model:selection="selection" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mb-3 shadow">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>Schedule</span>
|
||||
<DateRangePicker range="1D" direction="+1" v-model:date="date" v-model:date_end="date_end" />
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ViewSchedule :client="client" :selection="selection" :date_begin="datefmt(date)" :date_end="datefmt(new Date(date_end.getTime() - 1))" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import cookie from './cookie.mjs';
|
||||
|
||||
import ViewResourceLookup from './ViewResourceLookup.vue';
|
||||
import DateRangePicker from './DateRangePicker.vue';
|
||||
import ViewSchedule from './ViewSchedule.vue';
|
||||
|
||||
function dateonly(date) {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
}
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ViewResourceLookup, DateRangePicker, ViewSchedule
|
||||
},
|
||||
props: {
|
||||
client: Object
|
||||
},
|
||||
data() {
|
||||
var resources = cookie.get('vista.resources');
|
||||
return {
|
||||
selection: resources ? (resources.split(',').filter(x => x) || []) : [],
|
||||
date: dateonly(new Date()),
|
||||
date_end: dateonly(new Date())
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
selection(value, oldvalue) {
|
||||
cookie.set('vista.resources', value.join(','), 7);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
datefmt(date) {
|
||||
return date ? date.toLocaleDateString('en-CA') : '';
|
||||
//return (new Date(date.getTime() + date.getTimezoneOffset()*60000)).toLocaleDateString('en-CA');
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
233
htdocs/ViewData.vue
Normal file
233
htdocs/ViewData.vue
Normal file
@ -0,0 +1,233 @@
|
||||
<template>
|
||||
<div style="font-family: monospace;" role="region" tabindex="0" v-if="(resultset) && (resultset.length > 0)">
|
||||
<table class="table-sticky table-data">
|
||||
<thead>
|
||||
<tr><th class="name">{{name}}</th><th class="date" v-for="(group, idx) in groups" :class="{ first: (idx == 0) || (group.datehdr.datestr != groups[idx - 1].datehdr.datestr) }" ref="headers"><div class="year">{{group.datehdr.year}}</div><div class="monthdate">{{group.datehdr.monthdate}}</div><div class="hourminute" :class="{ daily }">{{group.datehdr.hourminute}}</div></th></tr>
|
||||
</thead>
|
||||
<tbody v-for="report in reports">
|
||||
<template v-for="name in report">
|
||||
<tr v-if="names[name]">
|
||||
<th>{{name}}</th>
|
||||
<td v-for="(group, idx) in groups" :class="{ first: (idx == 0) || (group.datehdr.datestr != groups[idx - 1].datehdr.datestr), abnormal_ref: abnormal_ref(group.values[name]), abnormal_ref_low: abnormal_ref_low(group.values[name]), abnormal_ref_high: abnormal_ref_high(group.values[name]), abnormal_iqr: abnormal_iqr(group.values[name]), abnormal_iqr_low: abnormal_iqr_low(group.values[name]), abnormal_iqr_high: abnormal_iqr_high(group.values[name]) }" :title="tooltip(group.values[name])">{{group.values[name] ? group.values[name].value : ''}}</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
<tbody>
|
||||
<template v-for="name in names">
|
||||
<tr v-if="!names_excluded[name]">
|
||||
<th>{{name}}</th>
|
||||
<td v-for="(group, idx) in groups" :class="{ first: (idx == 0) || (group.datehdr.datestr != groups[idx - 1].datehdr.datestr), abnormal_ref: abnormal_ref(group.values[name]), abnormal_ref_low: abnormal_ref_low(group.values[name]), abnormal_ref_high: abnormal_ref_high(group.values[name]), abnormal_iqr: abnormal_iqr(group.values[name]), abnormal_iqr_low: abnormal_iqr_low(group.values[name]), abnormal_iqr_high: abnormal_iqr_high(group.values[name]) }" :title="tooltip(group.values[name])">{{group.values[name] ? group.values[name].value : ''}}</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
table.table-sticky {
|
||||
border: 2px solid #dee2e6;
|
||||
}
|
||||
table.table-sticky th:first-child {
|
||||
border-right: 2px solid #dee2e6;
|
||||
}
|
||||
table.table-sticky tbody {
|
||||
border-top: 2px solid #dee2e6;
|
||||
}
|
||||
table.table-sticky tbody tr {
|
||||
border-top: 1px dashed #dee2e6;
|
||||
}
|
||||
table.table-sticky td:nth-of-type(odd) {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
table.table-sticky tbody th, table.table-sticky th.name {
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
table.table-sticky th.date {
|
||||
font-size: 80%;
|
||||
font-weight: normal;
|
||||
text-align: center;
|
||||
}
|
||||
table.table-sticky th.date .monthdate {
|
||||
font-size: 125%;
|
||||
font-weight: bold;
|
||||
}
|
||||
table.table-sticky th.date .hourminute.daily {
|
||||
display: none;
|
||||
}
|
||||
table.table-sticky tbody td {
|
||||
padding: 0 0.5rem;
|
||||
max-width: 12rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.first {
|
||||
border-left: 1px solid #dee2e6;
|
||||
}
|
||||
.year, .monthdate {
|
||||
visibility: hidden;
|
||||
}
|
||||
.first .year, .first .monthdate {
|
||||
visibility: visible;
|
||||
}
|
||||
[role="region"][tabindex] {
|
||||
max-height: 75vh;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { uniq, groupByArray, quantile_sorted } from './util.mjs';
|
||||
|
||||
function isNumeric(x) {
|
||||
return (x !== '') && (x !== false) && (x !== null) && (!isNaN(x));
|
||||
}
|
||||
|
||||
function statistics(resultset) {
|
||||
var res = {}, group, item;
|
||||
for(var i = resultset.length - 1; i >= 0; --i) {
|
||||
item = resultset[i];
|
||||
if(isNumeric(item.value)) {
|
||||
if(res[item.name]) res[item.name].push(+item.value);
|
||||
else res[item.name] = [+item.value];
|
||||
}
|
||||
}
|
||||
for(var k in res) if(res.hasOwnProperty(k)) {
|
||||
item = res[k].sort((a, b) => a - b);
|
||||
item = res[k] = {
|
||||
n: item.length,
|
||||
q25: quantile_sorted(item, 0.25),
|
||||
q50: quantile_sorted(item, 0.50),
|
||||
q75: quantile_sorted(item, 0.75)
|
||||
}
|
||||
item.range = item.q25 != item.q75 ? ('IQR: ' + item.q25 + ' - ' + item.q75) : ('Median: ' + item.q50);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
function date_header(date) {
|
||||
var datestr = date.toLocaleDateString('en-CA');
|
||||
var timestr = date.toLocaleTimeString('en-GB');
|
||||
return {
|
||||
datestr, timestr,
|
||||
year: datestr.substring(0, 4),
|
||||
monthdate: datestr.substring(5),
|
||||
hourminute: timestr.substring(0, 5),
|
||||
second: timestr.substring(6)
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
props: {
|
||||
name: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
resultset: {
|
||||
type: Array,
|
||||
default: []
|
||||
},
|
||||
daily: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
reports: {
|
||||
type: Array,
|
||||
default: []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {};
|
||||
},
|
||||
computed: {
|
||||
groups() {
|
||||
if(this.daily) return groupByArray(this.resultset, x => new Date(x.time.getFullYear(), x.time.getMonth(), x.time.getDate())).map(function(group) {
|
||||
group = group.values.reduce(((acc, x) => ((acc.values[x.name] || (acc.values[x.name] = [])).push(x), acc)), { key: group.key, datehdr: date_header(group.key), values: {}});
|
||||
for(var k in group.values) if(group.values.hasOwnProperty(k)) {
|
||||
var items = group.values[k].sort((a, b) => a.time - b.time);
|
||||
var strings = items.map(item => item.time.toLocaleTimeString('en-GB') + ' • ' + item.value + (item.unit ? ' ' + item.unit : '') + (item.flag ? ' [' + item.flag + ']' : ''));
|
||||
var flags = uniq(items.map(item => item.flag).filter(x => x).map(x => x.charAt(0)));
|
||||
var comments = uniq(items.map(item => item.comment).filter(x => x));
|
||||
var numbers = uniq(items.map(item => item.value).filter(x => isNumeric(x)));
|
||||
var min = Math.min.apply(null, numbers);
|
||||
var max = Math.max.apply(null, numbers);
|
||||
group.values[k] = {
|
||||
time: group.key,
|
||||
name: k,
|
||||
unit: items[0].unit,
|
||||
range: items[0].range,
|
||||
value: numbers.length > 1 ? min + ' - ' + max : numbers.length == 1 ? numbers[0] : items.length == 1 ? items[0].value : 'MULTIPLE', min: min, max: max,
|
||||
flag: flags.length > 1 ? '*' : flags.length == 1 ? flags[0] : null,
|
||||
comment: (strings.join('\n') + '\n\n' + comments.join('\n\n')).replace(/^\s+|\s+$/g, '')
|
||||
};
|
||||
}
|
||||
return group;
|
||||
}).sort((a, b) => (a.key > b.key) - (a.key < b.key));
|
||||
else return groupByArray(this.resultset, x => x.time).map(group => group.values.reduce(((acc, x) => (acc.values[x.name] = x, acc)), { key: group.key, datehdr: date_header(group.key), values: {}})).sort((a, b) => (a.key > b.key) - (a.key < b.key));
|
||||
},
|
||||
names() {
|
||||
var res = uniq(this.resultset.map(x => x.name));
|
||||
return res.reduce((acc, x) => (acc[x] = true, acc), res);
|
||||
},
|
||||
names_excluded() {
|
||||
var res = {};
|
||||
this.reports.forEach(report => report.reduce((acc, x) => (acc[x] = true, acc), res));
|
||||
return res;
|
||||
},
|
||||
statistics() {
|
||||
return statistics(this.resultset);
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
async resultset(value) {
|
||||
this.$nextTick(() => this.$refs.headers ? this.$refs.headers.scrollIntoView({ block: 'nearest', inline: 'end' }) : null);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
tooltip(item) {
|
||||
if(item) {
|
||||
var res = [], stat;
|
||||
if(item.range) res.push('Ref: ' + item.range + ' ' + item.unit + (item.flag ? ' [' + item.flag + ']' : ''));
|
||||
if(stat = this.statistics[item.name]) res.push(stat.range + (item.range ? ' ' + item.unit : '') + (isNaN(parseFloat(item.value)) ? '' : item.value < stat.q25 ? ' [L]' : item.value > stat.q75 ? ' [H]' : ''));
|
||||
if(item.comment) {
|
||||
if(res.length > 0) res.push('');
|
||||
res.push(item.comment);
|
||||
}
|
||||
return res.join('\n');
|
||||
}
|
||||
},
|
||||
abnormal_ref(item) {
|
||||
return (item) && (item.flag);
|
||||
},
|
||||
abnormal_ref_low(item) {
|
||||
return (item) && (item.flag) && (item.flag.indexOf('L') >= 0);
|
||||
},
|
||||
abnormal_ref_high(item) {
|
||||
return (item) && (item.flag) && (item.flag.indexOf('H') >= 0);
|
||||
},
|
||||
abnormal_iqr(item) {
|
||||
var stat;
|
||||
if((item) && (stat = this.statistics[item.name]) && (stat.n > 2)) {
|
||||
if((item.hasOwnProperty('min')) && (item.hasOwnProperty('max'))) return (item.min < stat.q25) || (item.max > stat.q75);
|
||||
else if(isNumeric(item.value)) return (item.value < stat.q25) || (item.value > stat.q75);
|
||||
}
|
||||
},
|
||||
abnormal_iqr_low(item) {
|
||||
var stat;
|
||||
if((item) && (stat = this.statistics[item.name]) && (stat.n > 2)) {
|
||||
if((item.hasOwnProperty('min')) && (item.hasOwnProperty('max'))) return item.min < stat.q25;
|
||||
else if(isNumeric(item.value)) return item.value < stat.q25;
|
||||
}
|
||||
},
|
||||
abnormal_iqr_high(item) {
|
||||
var stat;
|
||||
if((item) && (stat = this.statistics[item.name]) && (stat.n > 2)) {
|
||||
if((item.hasOwnProperty('min')) && (item.hasOwnProperty('max'))) return item.max > stat.q75;
|
||||
else if(isNumeric(item.value)) return item.value > stat.q75;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
50
htdocs/ViewLabs.vue
Normal file
50
htdocs/ViewLabs.vue
Normal file
@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<ViewData name="Labs" :resultset="resultset" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { strftime_vista } from './util.mjs';
|
||||
|
||||
import ViewData from './ViewData.vue';
|
||||
|
||||
function normalize(rs) {
|
||||
return rs.map(function(x) {
|
||||
return {
|
||||
time: x.time_collected,
|
||||
name: x.name,
|
||||
unit: x.unit,
|
||||
range: x.range,
|
||||
value: x.value,
|
||||
flag: x.flag,
|
||||
comment: x.comment
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ViewData
|
||||
},
|
||||
props: {
|
||||
client: Object,
|
||||
dfn: String,
|
||||
date_begin: Date,
|
||||
date_end: Date
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
resultset: null
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
params() {
|
||||
return { dfn: this.dfn, date_begin: strftime_vista(this.date_begin), date_end: strftime_vista(this.date_end) };
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
async params(value) {
|
||||
this.resultset = normalize(await this.client.ORWLRR_INTERIM_RESULTS(value.dfn, value.date_end, value.date_begin));
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
57
htdocs/ViewPatientLookup.vue
Normal file
57
htdocs/ViewPatientLookup.vue
Normal file
@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">🔎</span>
|
||||
<input class="form-control" v-model="query_raw" />
|
||||
</div>
|
||||
<div style="max-height: 30em; overflow-y: auto;">
|
||||
<table class="table table-striped" style="font-family: monospace;" v-if="(resultset) && (resultset.length > 0)">
|
||||
<thead>
|
||||
<tr><th>DFN</th><th>Name</th><th>PID</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in resultset">
|
||||
<td>{{row.dfn}}</td>
|
||||
<td><router-link :to="'/patient/' + row.dfn">{{row.name}}</router-link></td>
|
||||
<td>{{row.pid}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { debounce } from './util.mjs';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
client: Object,
|
||||
selection: {}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
resultset: [],
|
||||
query_raw: '',
|
||||
query_view: ''
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
},
|
||||
watch: {
|
||||
query_raw(value) {
|
||||
this.query_sync(value);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
},
|
||||
created() {
|
||||
this.query_sync = debounce(async function(value) {
|
||||
this.query_view = value = value.replace(/^\s+|\s+$/g, '').replace(/\s+/g, ' ');
|
||||
this.resultset = value ? (await this.client.ORWPT16_LOOKUP(value)) : [];
|
||||
}, 500);
|
||||
},
|
||||
async mounted() {
|
||||
}
|
||||
};
|
||||
</script>
|
101
htdocs/ViewResourceLookup.vue
Normal file
101
htdocs/ViewResourceLookup.vue
Normal file
@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">🔎</span>
|
||||
<input class="form-control" v-model="query_raw" />
|
||||
</div>
|
||||
<div style="max-height: 30em; overflow-y: auto;">
|
||||
<table class="table table-striped" style="font-family: monospace;" v-if="resultset_raw && resultset_raw.length > 0">
|
||||
<thead>
|
||||
<tr><th></th><th>ID</th><th>Name</th><th>Type</th><th>User</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="row in resultset_filtered">
|
||||
<tr :class="{ 'table-active': row.selected }" v-if="row.INACTIVE != 'YES'">
|
||||
<td><input type="checkbox" v-model="row.selected" /></td>
|
||||
<td>{{row.RESOURCEID}}</td>
|
||||
<td>{{row.RESOURCE_NAME}}</td>
|
||||
<td>{{row.RESOURCETYPE}}</td>
|
||||
<td>{{row.USERNAME}}</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style="font-family: monospace;" v-if="resultset_selected.length">
|
||||
<span class="badge bg-primary" style="cursor: default; margin-right: 0.35em;" v-on:click="reset">CLEAR {{resultset_selected.length}}</span>
|
||||
<span class="badge bg-secondary" style="cursor: default; margin-right: 0.35em;" v-for="row in resultset_selected" v-on:click="row.selected = false;">❌ {{row.RESOURCEID}} {{row.RESOURCE_NAME}}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { debounce } from './util.mjs';
|
||||
|
||||
function arrayeq1(a, b) {
|
||||
if(a.length == b.length) {
|
||||
for(var i = a.length - 1; i >= 0; --i) if(a[i] != b[i]) return false;
|
||||
return true;
|
||||
} else return false;
|
||||
}
|
||||
|
||||
function update_selection(resultset, selection) {
|
||||
var mapping = selection.reduce((obj, key) => (obj[key] = true, obj), {});
|
||||
for(var i = resultset.length -1; i >= 0; --i) resultset[i].selected = resultset[i].RESOURCEID in mapping;
|
||||
}
|
||||
|
||||
export default {
|
||||
props: {
|
||||
client: Object,
|
||||
selection: {
|
||||
type: Array,
|
||||
default: []
|
||||
}
|
||||
},
|
||||
emits: {
|
||||
'update:selection': Object
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
resultset_raw: [],
|
||||
query_raw: '',
|
||||
query_view: ''
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
resultset_filtered() {
|
||||
var query_view = this.query_view.replace(/^\s+|\s+$/g, '').replace(/\s+/g, ' ').toUpperCase();
|
||||
return query_view ? this.resultset_raw.filter(row => (query_view == row.RESOURCEID) || (row.RESOURCE_NAME.indexOf(query_view) >= 0) || (row.USERNAME.indexOf(query_view) >= 0)) : this.resultset_raw;
|
||||
},
|
||||
resultset_selected() {
|
||||
return this.resultset_raw.filter(row => row.selected);
|
||||
},
|
||||
resultset_selection() {
|
||||
return this.resultset_selected.map(x => x.RESOURCEID);
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
selection(value) {
|
||||
if(!arrayeq1(value, this.resultset_selection)) update_selection(this.resultset_raw, value);
|
||||
},
|
||||
resultset_selection(value) {
|
||||
this.$emit('update:selection', value);
|
||||
},
|
||||
query_raw(value) {
|
||||
this.query_sync(value);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
reset(evt) {
|
||||
var selection = this.resultset_selected.slice();
|
||||
for(var i = selection.length - 1; i >= 0; --i) selection[i].selected = false;
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.query_sync = debounce(function(value) { this.query_view = value.replace(/^\s+|\s+$/g, '').replace(/\s+/g, ' '); }, 500);
|
||||
},
|
||||
async mounted() {
|
||||
var rs = (await this.client.SDEC_RESOURCE()).slice();
|
||||
update_selection(rs, this.selection);
|
||||
this.resultset_raw = rs;
|
||||
}
|
||||
};
|
||||
</script>
|
89
htdocs/ViewSchedule.vue
Normal file
89
htdocs/ViewSchedule.vue
Normal file
@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<table class="table" style="font-family: monospace;" v-if="appointments && appointments.length > 0">
|
||||
<thead>
|
||||
<tr><th>Time</th><th>Clinic</th><th>Patient</th><th>Note</th><th>Assignee</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in appointments" :style="{ backgroundColor: strHashHSL(row.Clinic, '90%') }">
|
||||
<td>{{row.ApptDate}}</td>
|
||||
<td>{{row.Clinic}}</td>
|
||||
<td v-if="production"><router-link :to="'/patient/$' + row.HRN">{{row.Name}} ${{row.HRN}}</router-link></td>
|
||||
<td v-else><router-link :title="unscramble(row.Name)" :to="'/patient/$' + row.Name.charAt(0) + row.HRN.slice(-4) + '?name=' + row.Name">{{row.Name}} ${{row.HRN}}</router-link></td>
|
||||
<td>{{row.NOTE}} [{{row.APPT_MADE_BY}} on {{row.DATE_APPT_MADE}}]</td>
|
||||
<td><Autocomplete :value="practitioner[row.Name]" @update:value="x => set_practitioner(row.Name, x)" :items="practitioner_list" /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import cookie from './cookie.mjs';
|
||||
import { uniq, strHashHSL } from './util.mjs';
|
||||
|
||||
import Autocomplete from './Autocomplete.vue';
|
||||
|
||||
function datefm(datestr) {
|
||||
var date = datestr ? new Date(datestr) : new Date();
|
||||
date = new Date(date.getTime() + date.getTimezoneOffset()*60000);
|
||||
return 10000*(date.getFullYear() - 1700) + 100*(date.getMonth() + 1) + date.getDate();
|
||||
}
|
||||
|
||||
function strtr(s, a, b) {
|
||||
var res = '';
|
||||
for(var i = 0; i < s.length; ++i) {
|
||||
var j = a.indexOf(s.charAt(i));
|
||||
res += j >= 0 ? b.charAt(j) : s.charAt(i);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Autocomplete
|
||||
},
|
||||
props: {
|
||||
client: Object,
|
||||
selection: {
|
||||
type: Array,
|
||||
default: []
|
||||
},
|
||||
date_begin: String,
|
||||
date_end: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
appointments: [],
|
||||
practitioner: {},
|
||||
production: true
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
params() {
|
||||
return { selection: this.selection, date_begin: this.date_begin, date_end: this.date_end };
|
||||
},
|
||||
practitioner_list() {
|
||||
return this.practitioner ? uniq(Object.values(this.practitioner)).sort() : [];
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
async params(value) {
|
||||
this.appointments = value.selection.length > 0 ? (await this.client.SDEC_CLINLET(value.selection.join('|') + '|', datefm(value.date_begin), datefm(value.date_end))).sort((a, b) => (new Date(a.ApptDate)) - (new Date(b.ApptDate))) : [];
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
strHashHSL,
|
||||
unscramble(name) {
|
||||
return name.length > 0 ? (name.charAt(0) + strtr(name.substring(1), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'LKJIHGFEDCBAZYXWVUTSRQPONM')) : name;
|
||||
},
|
||||
set_practitioner(patient, practitioner) {
|
||||
this.practitioner[patient] = practitioner;
|
||||
cookie.set('vista.practitioner', JSON.stringify(this.practitioner), 1);
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
var practitioner = cookie.get('vista.practitioner');
|
||||
if(practitioner) this.practitioner = JSON.parse(practitioner);
|
||||
this.production = (await this.client.serverinfo()).result.production == '1';
|
||||
}
|
||||
};
|
||||
</script>
|
57
htdocs/ViewVitals.vue
Normal file
57
htdocs/ViewVitals.vue
Normal file
@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<ViewData name="Vitals" :resultset="resultset" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { strftime_vista, strptime_vista } from './util.mjs';
|
||||
|
||||
import ViewData from './ViewData.vue';
|
||||
|
||||
const mapping = {
|
||||
'T': { range: '35 - 38' },
|
||||
'P': { range: '60 - 100', unit: 'bpm' },
|
||||
'R': { range: '12 - 19', unit: 'bpm' },
|
||||
'Pulse Oximetry': { range: '95 - 100' }
|
||||
};
|
||||
|
||||
function normalize(rs) {
|
||||
return rs.map(function(x) {
|
||||
var res = {
|
||||
time: x.datetime,
|
||||
name: x.name,
|
||||
unit: x.unit,
|
||||
value: x.value,
|
||||
flag: x.flag,
|
||||
comment: x.user
|
||||
};
|
||||
return mapping[x.name] ? Object.assign(res, mapping[x.name]) : res;
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ViewData
|
||||
},
|
||||
props: {
|
||||
client: Object,
|
||||
dfn: String,
|
||||
date_begin: Date,
|
||||
date_end: Date
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
resultset: null
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
params() {
|
||||
return { dfn: this.dfn, date_begin: strftime_vista(this.date_begin), date_end: strftime_vista(this.date_end) };
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
async params(value) {
|
||||
this.resultset = normalize(await this.client.GMV_EXTRACT_REC(value.dfn, value.date_end, value.date_begin));
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
92
htdocs/ViewVitalsLabs.vue
Normal file
92
htdocs/ViewVitalsLabs.vue
Normal file
@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<div>
|
||||
<label class="form-check form-check-inline form-switch form-check-label" v-for="report in reports">
|
||||
<input class="form-check-input" type="checkbox" v-model="report.selected" /> {{report.name}}
|
||||
</label>
|
||||
</div>
|
||||
<ViewData :resultset="resultset" :daily="true" :reports="reports_selected" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { strftime_vista, strptime_vista } from './util.mjs';
|
||||
|
||||
import ViewData from './ViewData.vue';
|
||||
|
||||
const reports = [
|
||||
{ name: 'Vitals', value: ['T', 'P', 'R', 'SBP', 'DBP', 'Pulse Oximetry', 'Wt', 'Ht', 'Pain'], selected: true },
|
||||
{ name: 'CBC', value: ['HGB', 'MCV', 'PLT', 'WBC', 'NEUTROPHIL#'], selected: false },
|
||||
{ name: 'Renal', value: ['CREATININE', 'UREA NITROGEN', 'EGFR CKD-EPI 2021', 'Estimated GFR dc\'d 3/30/2022'], selected: false },
|
||||
{ name: 'Hepatic', value: ['SGOT', 'SGPT', 'LDH', 'ALKALINE PHOSPHATASE', 'GAMMA-GTP', 'TOT. BILIRUBIN', 'DIR. BILIRUBIN', 'ALBUMIN'], selected: false },
|
||||
{ name: 'Electrolytes', value: ['SODIUM', 'CHLORIDE', 'CO2', 'CALCIUM', 'IONIZED CALCIUM (LABCORP)', 'POTASSIUM', 'MAGNESIUM', 'PO4', 'ANION GAP', 'OSMOBLD'], selected: false },
|
||||
{ name: 'Coagulation', value: ['PT', 'INR', 'PTT'], selected: false },
|
||||
{ name: 'Vitamins', value: ['FERRITIN', 'IRON', 'TIBC', 'B 12', 'FOLATE', 'VITAMIN D TOTAL 25-OH'], selected: false },
|
||||
{ name: 'Thyroid', value: ['TSH', 'T4 (THYROXINE)'], selected: false }
|
||||
];
|
||||
reports.reduce((acc, x) => acc[x] = x, reports);
|
||||
|
||||
const vitals_mapping = {
|
||||
'T': { range: '35 - 38' },
|
||||
'P': { range: '60 - 100', unit: 'bpm' },
|
||||
'R': { range: '12 - 19', unit: 'bpm' },
|
||||
'Pulse Oximetry': { range: '95 - 100' }
|
||||
};
|
||||
|
||||
function vitals_normalize(rs) {
|
||||
return rs.map(function(x) {
|
||||
var res = {
|
||||
time: x.datetime,
|
||||
name: x.name,
|
||||
unit: x.unit,
|
||||
value: x.value,
|
||||
flag: x.flag,
|
||||
comment: x.user
|
||||
};
|
||||
return vitals_mapping[x.name] ? Object.assign(res, vitals_mapping[x.name]) : res;
|
||||
});
|
||||
}
|
||||
|
||||
function labs_normalize(rs) {
|
||||
return rs.map(function(x) {
|
||||
return {
|
||||
time: x.time_collected,
|
||||
name: x.name,
|
||||
unit: x.unit,
|
||||
range: x.range,
|
||||
value: x.value,
|
||||
flag: x.flag,
|
||||
comment: x.comment
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ViewData
|
||||
},
|
||||
props: {
|
||||
client: Object,
|
||||
dfn: String,
|
||||
date_begin: Date,
|
||||
date_end: Date
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
resultset: null,
|
||||
reports
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
params() {
|
||||
return { dfn: this.dfn, date_begin: strftime_vista(this.date_begin), date_end: strftime_vista(this.date_end) };
|
||||
},
|
||||
reports_selected() {
|
||||
return this.reports.filter(x => x.selected).map(x => x.value);
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
async params(value, oldvalue) {
|
||||
this.resultset = vitals_normalize(await this.client.GMV_EXTRACT_REC(value.dfn, value.date_end, value.date_begin)).concat(labs_normalize(await this.client.ORWLRR_INTERIM_RESULTS(value.dfn, value.date_end, value.date_begin)));
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
29
htdocs/cookie.mjs
Normal file
29
htdocs/cookie.mjs
Normal file
@ -0,0 +1,29 @@
|
||||
// https://stackoverflow.com/a/24103596
|
||||
// https://www.quirksmode.org/js/cookies.html
|
||||
|
||||
export function set(name, value, days) {
|
||||
var expires = '';
|
||||
if(days) {
|
||||
var date = new Date();
|
||||
date.setTime(date.getTime() + (days*24*60*60*1000));
|
||||
expires = '; expires=' + date.toUTCString();
|
||||
}
|
||||
document.cookie = name + '=' + (value || '') + expires + '; path=/';
|
||||
}
|
||||
|
||||
export function get(name) {
|
||||
var nameEQ = name + '=';
|
||||
var ca = document.cookie.split(';');
|
||||
for(var i = 0; i < ca.length; i++) {
|
||||
var c = ca[i];
|
||||
while(c.charAt(0)==' ') c = c.substring(1, c.length);
|
||||
if(c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length, c.length);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function reset(name) {
|
||||
document.cookie = name +'=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;';
|
||||
}
|
||||
|
||||
export default { set, get, reset };
|
46
htdocs/index.html
Normal file
46
htdocs/index.html
Normal file
@ -0,0 +1,46 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>nuVistA</title>
|
||||
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/bootstrap@5.1/dist/css/bootstrap.min.css" />
|
||||
<link rel="stylesheet" type="text/css" href="/table-sticky.css" />
|
||||
<link rel="stylesheet" type="text/css" href="/userstyle.css" />
|
||||
</head>
|
||||
<body><div id='root'></div></body>
|
||||
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/vue@3.2"></script>
|
||||
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/vue-router@4"></script>
|
||||
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/bootstrap@5.1/dist/js/bootstrap.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/vue3-sfc-loader/dist/vue3-sfc-loader.js"></script>
|
||||
<script type="text/javascript">
|
||||
var loadModule = window['vue3-sfc-loader'].loadModule;
|
||||
var options = {
|
||||
moduleCache: {
|
||||
vue: Vue
|
||||
},
|
||||
async getFile(url) {
|
||||
const res = await fetch(url);
|
||||
if(res.ok) return {
|
||||
getContentData: asBinary => asBinary ? res.arrayBuffer() : res.text(),
|
||||
}
|
||||
else throw Object.assign(new Error(res.statusText + ' ' + url), { res });
|
||||
},
|
||||
addStyle(textContent) {
|
||||
const style = Object.assign(document.createElement('style'), { textContent });
|
||||
const ref = document.head.getElementsByTagName('style')[0] || null;
|
||||
document.head.insertBefore(style, ref);
|
||||
},
|
||||
};
|
||||
var secret = window.location.hash.substring(1);
|
||||
var app = Vue.createApp({
|
||||
components: {
|
||||
'app': Vue.defineAsyncComponent(function() { return loadModule('/App.vue', options); })
|
||||
},
|
||||
data: function() { return { secret: secret }; },
|
||||
template: '<app :secret="secret"></app>'
|
||||
});
|
||||
app.use(VueRouter.createRouter({ history: VueRouter.createWebHistory(), routes: [] }));
|
||||
app.mount('#root');
|
||||
</script>
|
||||
</html>
|
158
htdocs/reportparser.mjs
Normal file
158
htdocs/reportparser.mjs
Normal file
@ -0,0 +1,158 @@
|
||||
function isEqualArray(a, b) {
|
||||
if(a.length == b.length) {
|
||||
for(var i = a.length - 1; i >= 0; --i) if(a[i] != b[i]) return false;
|
||||
return true;
|
||||
} else return false;
|
||||
}
|
||||
|
||||
export function lab_parse(data) {
|
||||
data = data.join('\n');
|
||||
if(data == '\nNo Data Found') return [];
|
||||
return data.split('\n===============================================================================\n \n').map(lab_parse1).filter(x => x);
|
||||
}
|
||||
|
||||
export function lab_reparse_results(reports) {
|
||||
var res = [], report, result;
|
||||
for(var i = 0; i < reports.length; ++i) {
|
||||
if((report = reports[i]).hasOwnProperty('results')) {
|
||||
report = Object.assign({}, report);
|
||||
var results = report.results;
|
||||
delete report.results;
|
||||
if(report.hasOwnProperty('comment')) delete report.comment;
|
||||
for(var j = 0; j < results.length; ++j) res.push(result = Object.assign({}, report, results[j]));
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
function lab_parse1(data) {
|
||||
if(data.startsWith('\n')) return lab_parse1default(data);
|
||||
if(data.startsWith(' ---- MICROBIOLOGY ----\n')) return lab_parse1microbiology(data);
|
||||
if(data.startsWith('Performing Lab Sites\n')) return null;
|
||||
}
|
||||
|
||||
function lab_parse1default(data) {
|
||||
var res = {}, m, x, line;
|
||||
if(m = data.match(/^Report Released Date\/Time: (.*)/m)) res.time_released = new Date(m[1]); // 'Aug 24, 2022@07:15'
|
||||
if(m = data.match(/^Provider: (.*)/m)) res.practitioner = m[1]; // 'BARGNES,VINCENT HARRY III'
|
||||
if(m = data.match(/^ Specimen: (.*?)\.\s*(.*)/m)) {
|
||||
res.specimen = m[1]; // 'SERUM'
|
||||
res.accession = m[2]; // 'CH 0800 6706'
|
||||
}
|
||||
if(m = data.match(/^ Specimen Collection Date: (.*)/m)) res.time_collected = new Date(m[1]); // 'Aug 24, 2022'
|
||||
data = data.split('\n Test name Result units Ref. range Site Code\n')[1].split('\n');
|
||||
var results = res.results = [];
|
||||
for(var i = 0; i < data.length; ++i) {
|
||||
if((line = data[i]).startsWith('Comment: ')) {
|
||||
res.comment = data.slice(i).join('\n').substring(9);
|
||||
break;
|
||||
} else if(line.startsWith(' Eval: ')) {
|
||||
if(results.length > 0) {
|
||||
x = results[results.length - 1];
|
||||
if(x.comment) x.comment.push(line.substring(12));
|
||||
else x.comment = [line.substring(12)];
|
||||
} else console.log('DANGLING:', line);
|
||||
} else if((line.startsWith('COVID-19 SCR (CEPHEID-RAPID)')) && (m = line.substring(28).match(/^(?<value>.*?)(?: (?<flag>L\*|L|H\*|H))?\s+(?:(?<unit>.{10}) (?<range>.{16}) \[(?<site>\d+)\])?$/))) {
|
||||
if((m.groups.range) && (m.groups.range.startsWith('Ref: '))) m.groups.range = m.groups.range.substring(5);
|
||||
results.push(x = m.groups);
|
||||
for(var k in x) if(x[k]) x[k] = x[k] ? x[k].replace(/^\s+|\s+$/g, '') : undefined;
|
||||
x.name = 'COVID-19 SCR (CEPHEID-RAPID)';
|
||||
} else if(m = line.match(/^\b(?<name>.*?)\s{2,}(?<value>.*?)(?: (?<flag>L\*|L|H\*|H))?\s+(?:(?<unit>.{10}) (?<range>.{16}) \[(?<site>\d+)\])?$/)) {
|
||||
if((m.groups.range) && (m.groups.range.startsWith('Ref: '))) m.groups.range = m.groups.range.substring(5);
|
||||
results.push(x = m.groups);
|
||||
for(var k in x) if(x[k]) x[k] = x[k] ? x[k].replace(/^\s+|\s+$/g, '') : undefined;
|
||||
} else if(line.startsWith(' [')) {
|
||||
if(results.length > 0) results[results.length - 1].site = line.split('[')[1].split(']')[0]
|
||||
else console.log('DANGLING:', line);
|
||||
} else if(line.startsWith(' ')) {
|
||||
if(results.length > 0) {
|
||||
x = results[results.length - 1];
|
||||
if(line.endsWith(']')) {
|
||||
x.range = line.split('[')[0].replace(/^\s+|\s+$/g, '');
|
||||
x.site = line.split('[')[1].split(']')[0];
|
||||
} else x.range = line.replace(/^\s+|\s+$/g, '');
|
||||
} else console.log('DANGLING:', line);
|
||||
} else console.log('INVALID:', line);
|
||||
}
|
||||
for(var i = results.length - 1; i >= 0; --i) {
|
||||
results[(x = results[i]).name] = x;
|
||||
if(x.comment) x.comment = x.comment.join('\n');
|
||||
}
|
||||
if((res.accession.startsWith('HE ')) && ((results.hasOwnProperty('SEGS')) || (results.hasOwnProperty('BANDS')))) {
|
||||
results.push(results['NEUTROPHIL%'] = {
|
||||
name: 'NEUTROPHIL%', unit: '%', range: '42.2 - 75.2',
|
||||
value: x = (results.hasOwnProperty('SEGS') ? +results.SEGS.value : 0) + (results.hasOwnProperty('BANDS') ? +results.BANDS.value : 0),
|
||||
flag: (x < 42.2 ? 'L' : x > 75.2 ? 'H' : undefined)
|
||||
});
|
||||
results.push(results['NEUTROPHIL#'] = {
|
||||
name: 'NEUTROPHIL#', unit: 'K/cmm', range: '1.4 - 6.5',
|
||||
value: +(x = 0.01*x*results.WBC.value).toFixed(3),
|
||||
flag: (x < 1.4 ? 'L' : x > 6.5 ? 'H' : undefined)
|
||||
});
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
function lab_parse1microbiology(data) {
|
||||
var res = {}, lines = data.split('\n'), line, m;
|
||||
var idx_body = lines.indexOf(' ');
|
||||
for(var i = 0; i < lines.length; ++i) {
|
||||
line = lines[i];
|
||||
if(line.startsWith('Accession [UID]: ')) {
|
||||
if(m = line.match(/^Accession \[UID\]: (?<accession>.*?) \[(?<accession_uid>\d+)\]/)) { // 'BCUL 22 819 [3922000819]'
|
||||
res.accession = m.groups.accession;
|
||||
res.accession_uid = m.groups.accession_uid;
|
||||
}
|
||||
if(m = line.match(/Received: (.*)$/)) res.time_received = new Date(m[1]); // 'Aug 01, 2022@11:57'
|
||||
} else if(line.startsWith('Collection sample: ')) {
|
||||
res.sample = line.substring(0, 39).substring(19).replace(/^\s+|\s+$/g, '');
|
||||
res.time_collected = new Date(line.substring(39).split('Collection date: ')[1].replace(/^\s+|\s+$/g, ''));
|
||||
} else if(line.startsWith('Site/Specimen: ')) {
|
||||
res.specimen = line.substring(15).replace(/^\s+|\s+$/g, '');
|
||||
} else if(line.startsWith('Provider: ')) {
|
||||
res.practitioner = line.substring(10).replace(/^\s+|\s+$/g, '');
|
||||
} else if(line.startsWith('Comment on specimen:')) {
|
||||
res.comment = lines.slice(i, idx_body).join('\n').substring(20).replace(/^\s+|\s+$/g, '');
|
||||
break
|
||||
}
|
||||
}
|
||||
var idx_footer = lines.indexOf('=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--=--')
|
||||
if(idx_footer > idx_body) {
|
||||
res.body = lines.slice(idx_body, idx_footer).join('\n').replace(/^\s+|\s+$/g, '');
|
||||
res.footer = lines.slice(idx_footer + 1).join('\n').replace(/^\s+|\s+$/g, '');
|
||||
} else res.body = lines.slice(idx_body).join('\n').replace(/^\s+|\s+$/g, '');
|
||||
return res;
|
||||
}
|
||||
|
||||
export function measurement_parse(data) {
|
||||
var extras = [];
|
||||
var res = data.map(function(row) {
|
||||
if(row.charAt(0) != ' ') {
|
||||
var res = {}, idx = 0, value, m;
|
||||
res.measurement_ien = row.substring(0, idx = row.indexOf('^'));
|
||||
if(res.measurement_ien == '0') return; // '0^NO VITALS/MEASUREMENTS ENTERED WITHIN THIS PERIOD'
|
||||
res.datetime = new Date(row.substring(idx + 1, idx = row.indexOf(' ', idx)));
|
||||
res.name = row.substring(idx + 3, idx = row.indexOf(': ', idx));
|
||||
value = row.substring(idx + 4, idx = row.indexOf(' _', idx));
|
||||
res.user = row.substring(idx + 3);
|
||||
m = value.match(/^(?:(.*?)(?: (\S+))?)(\*)?(?: \((?:(.*?)(?: (\S+))?)\))?\s*$/);
|
||||
res.value = m[4] ? m[4] : m[1];
|
||||
res.unit = m[4] ? m[5] : m[2];
|
||||
res.flag = m[3];
|
||||
res.value_american = m[4] ? m[1] : m[4];
|
||||
res.unit_american = m[4] ? m[2] : m[5];
|
||||
if(res.value.charAt(res.value.length - 1) == '%') {
|
||||
res.unit = '%';
|
||||
res.value = res.value.substring(0, res.value.length - 1);
|
||||
}
|
||||
if(res.name == 'B/P') {
|
||||
var bpsplit = res.value.split('/');
|
||||
extras.push({...res, name: 'SBP', range: '90 - 120', unit: 'mmHg', value: bpsplit[0] });
|
||||
extras.push({...res, name: 'DBP', range: '60 - 80', unit: 'mmHg', value: bpsplit[1] });
|
||||
}
|
||||
return res;
|
||||
}
|
||||
}).filter(x => x);
|
||||
res.push(...extras);
|
||||
return res;
|
||||
}
|
45
htdocs/table-sticky.css
Normal file
45
htdocs/table-sticky.css
Normal file
@ -0,0 +1,45 @@
|
||||
table.table-sticky {
|
||||
white-space: nowrap;
|
||||
table-layout: fixed;
|
||||
}
|
||||
table.table-sticky thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
width: 25vw;
|
||||
background: white;
|
||||
}
|
||||
table.table-sticky td {
|
||||
background: #fff;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
table.table-sticky tbody th {
|
||||
position: relative;
|
||||
}
|
||||
table.table-sticky thead th:first-child {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
table.table-sticky tbody th {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
background: white;
|
||||
z-index: 1;
|
||||
}
|
||||
table.table-sticky caption {
|
||||
text-align: left;
|
||||
position: sticky;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
[role="region"][tabindex] {
|
||||
width: 100%;
|
||||
max-height: 98vh;
|
||||
overflow: auto;
|
||||
}
|
||||
[role="region"][tabindex]:focus {
|
||||
box-shadow: 0 0 0.5em rgba(0, 0, 0, 0.5);
|
||||
outline: 0;
|
||||
}
|
25
htdocs/userstyle.css
Normal file
25
htdocs/userstyle.css
Normal file
@ -0,0 +1,25 @@
|
||||
table.table-data .abnormal_ref::after {
|
||||
content: ' *';
|
||||
}
|
||||
table.table-data .abnormal_ref_low::after {
|
||||
content: ' L';
|
||||
}
|
||||
table.table-data .abnormal_ref_high::after {
|
||||
content: ' H';
|
||||
}
|
||||
table.table-data .abnormal_iqr, table.table-data .abnormal_iqr_low.abnormal_iqr_high {
|
||||
color: #f39a27;
|
||||
}
|
||||
table.table-data .abnormal_iqr_low {
|
||||
color: #976ed7;
|
||||
}
|
||||
table.table-data .abnormal_iqr_high {
|
||||
color: #c23b23;
|
||||
}
|
||||
table.table-data .abnormal_ref, table.table-data .abnormal_iqr {
|
||||
background-color: #fbffde;
|
||||
}
|
||||
table.table-data .abnormal_ref_low.abnormal_iqr_low, table.table-data .abnormal_ref_high.abnormal_iqr_high {
|
||||
font-weight: bold;
|
||||
background-color: #ffd1d1;
|
||||
}
|
79
htdocs/util.mjs
Normal file
79
htdocs/util.mjs
Normal file
@ -0,0 +1,79 @@
|
||||
export function uniq(xs) {
|
||||
var seen = {};
|
||||
return xs.filter(x => seen.hasOwnProperty(x) ? false : (seen[x] = true));
|
||||
}
|
||||
|
||||
export function groupBy(xs, key) {
|
||||
return xs.reduce(function(rv, x) {
|
||||
var v = key instanceof Function ? key(x) : x[key];
|
||||
(rv[v] = rv[v] || []).push(x);
|
||||
return rv;
|
||||
}, {});
|
||||
}
|
||||
|
||||
export function groupByArray(xs, key) {
|
||||
var mapping = {};
|
||||
return xs.reduce(function(rv, x) {
|
||||
var v = key instanceof Function ? key(x) : x[key];
|
||||
var el = mapping[v];
|
||||
if(el) el.values.push(x);
|
||||
else rv.push(mapping[v] = { key: v, values: [x] });
|
||||
return rv;
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function pivotByArray(xs, key, reducer) {
|
||||
var groups = groupByArray(xs, key);
|
||||
groups.forEach(function(group) {
|
||||
group.aggregate = group.values.reduce(reducer, {});
|
||||
});
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function quantile_sorted(arr_sorted, quantile) {
|
||||
var pos = (arr_sorted.length - 1) * quantile, base = Math.floor(pos), rest = pos - base;
|
||||
return arr_sorted[base + 1] !== undefined ? arr_sorted[base] + rest * (arr_sorted[base + 1] - arr_sorted[base]) : arr_sorted[base];
|
||||
}
|
||||
|
||||
export function strHashCode(str) {
|
||||
var hash = 0;
|
||||
for(var i = 0; i < str.length; ++i) hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
return hash & hash; // convert to 32 bit
|
||||
}
|
||||
|
||||
export function strHashJenkins(str) {
|
||||
for(var hash = 0, i = str.length; i--;) hash += str.charCodeAt(i), hash += hash << 10, hash ^= hash >> 6;
|
||||
hash += hash << 3;
|
||||
hash ^= hash >> 11;
|
||||
return (hash + (hash << 15) & 4294967295) >>> 0
|
||||
}
|
||||
|
||||
export function strHashHex(str) {
|
||||
var hash = strHashJenkins(str), color = '#';
|
||||
for(var i = 0; i < 3; ++i) color += ('00' + ((hash >> (i * 8)) & 0xFF).toString(16)).slice(-2);
|
||||
return color;
|
||||
}
|
||||
|
||||
export function strHashHSL(str, lightness='50%') {
|
||||
var hash = strHashJenkins(str);
|
||||
return 'hsl(' + (hash%360) + ',' + (hash%100) + '%,' + lightness + ')';
|
||||
}
|
||||
|
||||
export function strftime_vista(date) {
|
||||
return 10000*(date.getFullYear() - 1700) + 100*(date.getMonth() + 1) + date.getDate() + date.getHours()/100 + date.getMinutes()/10000 + date.getSeconds()/1000000 + date.getMilliseconds()/1000000000;
|
||||
}
|
||||
|
||||
export function strptime_vista(s) {
|
||||
s = +s;
|
||||
var date = Math.floor(s), time = s - date;
|
||||
return new Date(Math.floor(date/10000) + 1700, (Math.floor(date/100) + '').slice(-2) - 1, (date + '').slice(-2), Math.floor(time*100), (Math.floor(time*10000) + '').slice(-2), (Math.floor(time*1000000) + '').slice(-2), (Math.floor(time*1000000000) + '').slice(-3));
|
||||
}
|
||||
|
||||
export function debounce(fn, delay) {
|
||||
var clock = null;
|
||||
return function() {
|
||||
clearTimeout(clock);
|
||||
var self = this, args = arguments;
|
||||
clock = setTimeout(function() { fn.apply(self, args) }, delay);
|
||||
}
|
||||
}
|
51
htdocs/vista.mjs
Normal file
51
htdocs/vista.mjs
Normal file
@ -0,0 +1,51 @@
|
||||
export async function connect(secret, host='vista.northport.med.va.gov', port=19209) {
|
||||
return await (await fetch('/v1/vista', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ params: { secret: secret, host: host, port: port }, id: Date.now() })
|
||||
})).json();
|
||||
}
|
||||
|
||||
export async function call(cid, method, ...params) {
|
||||
return await (await fetch('/v1/vista/' + cid, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ method: method, params: params, id: Date.now() })
|
||||
})).json();
|
||||
}
|
||||
|
||||
export async function callctx(cid, context, method, ...params) {
|
||||
return await (await fetch('/v1/vista/' + cid, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ method: method, params: params, context: context, id: Date.now() })
|
||||
})).json();
|
||||
}
|
||||
|
||||
export async function serverinfo(cid) {
|
||||
return await (await fetch('/v1/vista/' + cid + '/serverinfo', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{}'
|
||||
})).json();
|
||||
}
|
||||
|
||||
export async function userinfo(cid) {
|
||||
return await (await fetch('/v1/vista/' + cid + '/userinfo', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{}'
|
||||
})).json();
|
||||
}
|
||||
|
||||
export async function authenticate(cid, avcode=null) {
|
||||
return await (await fetch('/v1/vista/' + cid + '/authenticate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ params: avcode ? { avcode } : {} })
|
||||
})).json();
|
||||
}
|
||||
|
||||
export default window.vista = {
|
||||
connect, call, callctx, serverinfo, userinfo, authenticate
|
||||
};
|
171
htdocs/vistax.mjs
Normal file
171
htdocs/vistax.mjs
Normal file
@ -0,0 +1,171 @@
|
||||
import vista from './vista.mjs';
|
||||
import cookie from './cookie.mjs';
|
||||
import { lab_parse, lab_reparse_results, measurement_parse } from './reportparser.mjs';
|
||||
|
||||
function RPCError(type, ...args) {
|
||||
this.name = type;
|
||||
this.message = args;
|
||||
}
|
||||
RPCError.prototype = Object.create(Error.prototype);
|
||||
|
||||
export function logged(fn, name) {
|
||||
return async function(...args) {
|
||||
var res = await fn(...args);
|
||||
console.log(name, ...args, res);
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
export function unwrapped(fn) {
|
||||
return async function(...args) {
|
||||
var res = await fn(...args);
|
||||
if(res.error) throw new RPCError(res.error.type, ...res.error.args);
|
||||
else return res.result;
|
||||
}
|
||||
}
|
||||
|
||||
export function memoized(fn) {
|
||||
var cache = {};
|
||||
return async function(...args) {
|
||||
var key = JSON.stringify(args);
|
||||
return cache.hasOwnProperty(key) ? cache[key] : (cache[key] = await fn(...args));
|
||||
}
|
||||
}
|
||||
|
||||
export function caretseparated(fn, columns=null) {
|
||||
return async function(...args) {
|
||||
if(columns) return (await fn(...args)).map(function(row) {
|
||||
row = row.split('^');
|
||||
for(var i = columns.length - 1; i >= 0; --i) if(columns[i]) row[columns[i]] = row[i];
|
||||
return row;
|
||||
});
|
||||
else return (await fn(...args)).map(function(row) { return row.split('^'); });
|
||||
}
|
||||
}
|
||||
|
||||
export function caretseparated1(fn, columns=null) {
|
||||
return async function(...args) {
|
||||
var res = (await fn(...args)).split('^');
|
||||
if(columns) for(var i = columns.length - 1; i >= 0; --i) if(columns[i]) res[columns[i]] = res[i];
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
export function labreportparsed(fn) {
|
||||
return async function(...args) {
|
||||
return lab_parse(await fn(...args));
|
||||
}
|
||||
}
|
||||
|
||||
export function tabulated(fn, mapping) {
|
||||
return async function(...args) {
|
||||
var res = (await fn(...args)).map(function(row) { return row.slice(); }), nrow = res.length;
|
||||
for(var i = 0; i < nrow; ++i) {
|
||||
var row = res[i], ncol = row.length;
|
||||
for(var j = 0; j < ncol; ++j) if(mapping.hasOwnProperty(j)) row[mapping[j]] = row[j];
|
||||
res.push()
|
||||
}
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
export function Client(cid, secret) {
|
||||
var heartbeat = null;
|
||||
|
||||
this.secret = secret;
|
||||
this.cid = cid;
|
||||
|
||||
this.call = (method, ...params) => vista.call(cid, method, ...params);
|
||||
this.callctx = (context, method, ...params) => vista.callctx(cid, context, method, ...params);
|
||||
this.heartbeat = async function(interval=null) {
|
||||
if(!interval) interval = 0.45*1000*(await this.XWB_GET_BROKER_INFO())[0];
|
||||
if(heartbeat) window.clearInterval(heartbeat);
|
||||
this.XWB_IM_HERE();
|
||||
return heartbeat = window.setInterval(this.XWB_IM_HERE, interval);
|
||||
}
|
||||
this.serverinfo = () => vista.serverinfo(cid);
|
||||
this.userinfo = () => vista.userinfo(cid);
|
||||
this.authenticate = (avcode=null) => vista.authenticate(cid, avcode);
|
||||
|
||||
this.XWB_IM_HERE = unwrapped(logged(() => vista.call(cid, 'XWB_IM_HERE'), 'XWB_IM_HERE'));
|
||||
|
||||
this.XUS_INTRO_MSG = memoized(unwrapped(logged(() => vista.callctx(cid, ['XUCOMMAND'], 'XUS_INTRO_MSG'), 'XUS_INTRO_MSG')));
|
||||
this.XWB_GET_BROKER_INFO = memoized(unwrapped(logged(() => vista.callctx(cid, ['XUCOMMAND'], 'XWB_GET_BROKER_INFO'), 'XWB_GET_BROKER_INFO')));
|
||||
this.XUS_GET_USER_INFO = memoized(unwrapped(logged(() => vista.call(cid, 'XUS_GET_USER_INFO'), 'XUS_GET_USER_INFO')));
|
||||
|
||||
this.SDEC_RESOURCE = memoized(unwrapped(logged(() => vista.callctx(cid, ['SDECRPC'], 'SDEC_RESOURCE'), 'SDEC_RESOURCE')));
|
||||
this.SDEC_CLINLET = memoized(unwrapped(logged((...args) => vista.callctx(cid, ['SDECRPC'], 'SDEC_CLINLET', ...args), 'SDEC_CLINLET')));
|
||||
|
||||
this.ORWPT_FULLSSN = memoized(caretseparated(unwrapped(logged((...args) => vista.callctx(cid, ['OR CPRS GUI CHART'], 'ORWPT_FULLSSN', ...args), 'ORWPT_FULLSSN')), ['dfn', 'name', 'date', 'pid']));
|
||||
this.ORWPT_LAST5 = memoized(caretseparated(unwrapped(logged((...args) => vista.callctx(cid, ['OR CPRS GUI CHART'], 'ORWPT_LAST5', ...args), 'ORWPT_LAST5')), ['dfn', 'name', 'date', 'pid']));
|
||||
this.ORWPT_ID_INFO = memoized(caretseparated1(unwrapped(logged((...args) => vista.callctx(cid, ['OR CPRS GUI CHART'], 'ORWPT_ID_INFO', ...args), 'ORWPT_ID_INFO')), ['pid', 'dob', 'sex', 'vet', 'sc_percentage', 'ward', 'room_bed', 'name']));
|
||||
this.ORWPT16_LOOKUP = memoized(caretseparated(unwrapped(logged((...args) => vista.callctx(cid, ['OR CPRS GUI CHART'], 'ORWPT16_LOOKUP', ...args), 'ORWPT16_LOOKUP')), ['dfn', 'name', 'pid']));
|
||||
this.ORWPT16_ID_INFO = memoized(caretseparated1(unwrapped(logged((...args) => vista.callctx(cid, ['OR CPRS GUI CHART'], 'ORWPT16_ID_INFO', ...args), 'ORWPT16_ID_INFO')), ['pid', 'dob', 'age', 'sex', 'sc_percentage', 'type', 'ward', 'room_bed', 'name']));
|
||||
|
||||
this.ORQQVI_VITALS = memoized(caretseparated(unwrapped(logged((...args) => vista.callctx(cid, ['OR CPRS GUI CHART'], 'ORQQVI_VITALS', ...args), 'ORQQVI_VITALS')), ['measurement_ien', 'type', 'value', 'datetime', 'value_american', 'value_metric']));
|
||||
this.ORQQVI_VITALS_FOR_DATE_RANGE = memoized(caretseparated(unwrapped(logged((...args) => vista.callctx(cid, ['OR CPRS GUI CHART'], 'ORQQVI_VITALS_FOR_DATE_RANGE', ...args), 'ORQQVI_VITALS_FOR_DATE_RANGE')), ['measurement_ien', 'type', 'value', 'datetime']));
|
||||
|
||||
this.GMV_EXTRACT_REC = memoized(async (dfn, oredt, orsdt) => measurement_parse(await unwrapped(logged((...args0) => vista.callctx(cid, ['OR CPRS GUI CHART'], 'GMV_EXTRACT_REC', args0.join('^')), 'GMV_EXTRACT_REC'))(dfn, oredt, '', orsdt)));
|
||||
|
||||
this.ORWLRR_INTERIM = memoized(labreportparsed(unwrapped(logged((...args) => vista.callctx(cid, ['OR CPRS GUI CHART'], 'ORWLRR_INTERIM', ...args), 'ORWLRR_INTERIM'))));
|
||||
this.ORWLRR_INTERIM_RESULTS = memoized(async (...args) => lab_reparse_results(await this.ORWLRR_INTERIM(...args)));
|
||||
|
||||
return this;
|
||||
}
|
||||
Client._registry = {};
|
||||
|
||||
Client.fromID = function(cid, secret) {
|
||||
if(Client._registry[cid]) return Client._registry[cid];
|
||||
return Client._registry[cid] = new Client(cid, secret);
|
||||
};
|
||||
|
||||
Client.fromScratch = async function(secret, host='vista.northport.med.va.gov', port=19209) {
|
||||
var data = await vista.connect(secret, host, port);
|
||||
if(data.result) return Client.fromID(data.result, secret);
|
||||
};
|
||||
|
||||
Client.fromCookie = async function(secret, host='vista.northport.med.va.gov', port=19209) {
|
||||
if(!secret) secret = cookie.get('secret');
|
||||
if(secret) {
|
||||
if(secret != cookie.get('secret')) {
|
||||
console.log('Using new secret', secret);
|
||||
var client = await Client.fromScratch(secret, host, port);
|
||||
if(client) {
|
||||
cookie.set('secret', secret);
|
||||
cookie.set('cid', client.cid);
|
||||
console.log('Established connection', client.cid);
|
||||
return client;
|
||||
} else {
|
||||
cookie.reset('secret');
|
||||
cookie.reset('cid');
|
||||
console.log('Failed to connect');
|
||||
return null;
|
||||
}
|
||||
} else if(!cookie.get('cid')) {
|
||||
console.log('Using saved secret', secret);
|
||||
var client = await Client.fromScratch(secret, host, port);
|
||||
if(client) {
|
||||
cookie.set('secret', secret);
|
||||
cookie.set('cid', client.cid);
|
||||
console.log('Established connection', client.cid);
|
||||
return client;
|
||||
} else {
|
||||
cookie.reset('secret');
|
||||
cookie.reset('cid');
|
||||
console.log('Failed connection');
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
console.log('Using saved secret and connection', secret);
|
||||
var cid = cookie.get('cid');
|
||||
var client = Client.fromID(cid, secret);
|
||||
if((await vista.call(cid, 'XWB_IM_HERE')).result == '1') return client;
|
||||
cookie.reset('cid');
|
||||
return await Client.fromCookie(secret, host, port);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default window.vistax = {
|
||||
RPCError, Client, connect: Client.fromCookie
|
||||
};
|
Reference in New Issue
Block a user