Document creation, edition, deletion, and signature

This commit is contained in:
Jiang Yio 2023-08-08 22:08:08 -04:00
parent 48a092432c
commit eebda06c86
8 changed files with 816 additions and 48 deletions

View File

@ -0,0 +1,84 @@
<template>
<TransitionGroup>
<div v-if="x_show" class="modal show" style="display: block;" tabindex="-1" @keydown.enter="submit" @keydown.esc="cancel">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{label || 'Sign'}}</h5>
<button type="button" class="btn-close" @click="cancel"></button>
</div>
<div class="modal-body">
<div class="input-group">
<span class="input-group-text">Code</span>
<input ref="input" type="password" class="form-control" :class="{ 'is-invalid': valid === false }" v-model="x_modelValue" @input="() => valid = null" />
<div v-if="valid === false" class="invalid-feedback">Invalid code.</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" :disabled="!x_modelValue" @click="submit">Submit</button>
</div>
</div>
</div>
</div>
<div v-if="x_show" class="modal-backdrop show"></div>
</TransitionGroup>
</template>
<style scoped>
.v-enter-active, .v-leave-active {
transition: opacity 0.25s ease;
}
.v-enter-from, .v-leave-to {
opacity: 0;
}
</style>
<script>
export default {
props: {
client: Object,
show: {
type: Boolean,
default: false
},
label: String,
modelValue: null
},
emits: {
'cancel': null,
'submit': String,
'update:show': String,
'update:modelValue': null
},
data() {
return {
valid: null,
x_show: this.show,
x_modelValue: this.modelValue
};
},
watch: {
show(value) { this.x_show = value; },
async x_show(value) {
this.x_modelValue = '';
this.$emit('update:show', value);
await this.$nextTick();
if((value) && (this.$refs.input)) this.$refs.input.focus();
},
modelValue(value) { this.x_modelValue = value; },
x_modelValue(value) { this.$emit('update:modelValue', value); }
},
methods: {
cancel() { this.x_show = false; },
async submit() {
var value = this.x_modelValue;
if((this.client) && (value)) {
if(this.valid = (await this.client.ORWU_VALIDSIG(' ' + value + ' ')) == '1') {
this.x_show = false;
this.$emit('submit', value);
}
}
}
}
};
</script>

View File

@ -2,15 +2,23 @@
<Subtitle value="Documents" />
<Subtitle :value="patient_info.name" />
<div class="row">
<div class="selector col-12" :class="{ 'col-xl-4': selection_text }">
<div class="selector col-12" :class="{ 'col-xl-4': selection }">
<div class="card mb-3 shadow">
<div class="card-header"><template v-if="resultset.length > 0">{{resultset.length}}<template v-if="has_more">+</template></template><template v-else-if="is_loading">Loading</template><template v-else>No</template> record{{resultset.length == 1 ? '' : 's'}}</div>
<ul class="scroller list-group list-group-flush" :class="{ 'list-skinny': selection_text }" ref="scroller">
<div class="card-header d-flex justify-content-between align-items-center">
<span><template v-if="resultset.length > 0">{{resultset.length}}<template v-if="has_more">+</template></template><template v-else-if="is_loading">Loading</template><template v-else>No</template> record{{resultset.length == 1 ? '' : 's'}}</span>
<router-link :to="'/patient/' + patient_dfn + '/document/new'">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 16 16" style="width: 1.25em; height: 1.25em; vertical-align: text-bottom;">
<path d="M14 1a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h12zM2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2z"/>
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z"/>
</svg>
</router-link>
</div>
<ul class="scroller list-group list-group-flush" :class="{ 'list-skinny': selection }" ref="scroller">
<router-link v-for="item in resultset" :to="'/patient/' + patient_dfn + '/document/' + item.IEN" replace custom v-slot="{ navigate, href }">
<li :key="item" class="record list-group-item" :class="{ 'active': selection == item.IEN }" :title="datetimestring(strptime_vista(item.time)) + '\n' + item.title + '\n' + item.location + '\n' + item.author.byline" @click="navigate">
<div class="row">
<div class="cell col-4"><router-link :to="href" replace>{{datestring(strptime_vista(item.time))}}</router-link></div>
<div class="cell col-8">{{item.title}}</div>
<div class="cell col-8"><template v-if="item.status == 'unsigned'"></template>{{item.title}}</div>
<div class="cell secondary col-7 col-lg-4 col-xl-7">{{item.location}}</div>
<div class="cell secondary col-5 col-lg-8 col-xl-5">{{item.author.byline}}</div>
</div>
@ -20,16 +28,14 @@
</ul>
</div>
</div>
<div v-if="selection_text" class="col-12 col-xl-8">
<div class="card mb-3 shadow">
<div class="card-header d-flex justify-content-between align-items-center">
<span>{{doctitle(selection_text) || 'Document'}}</span>
<router-link class="close" :to="'/patient/' + patient_dfn + '/document'" replace></router-link>
</div>
<div class="detail card-body" ref="detail">{{selection_text}}</div>
<div v-if="selection == 'new'" class="col-12 col-xl-8">
<ViewDocNew :client="client" :dfn="patient_dfn" :datetime="datetimestring(new Date())" @cancel="() => $router.replace({ path: '/patient/' + patient_dfn + '/document' })" @submit="doc_create" />
</div>
<div v-else-if="selection" class="detail col-12 col-xl-8" ref="detail">
<ViewDocView :client="client" :dfn="patient_dfn" :ien="selection" @sign="doc_sign_prompt" @delete="doc_delete" @cancel="() => $router.replace({ path: '/patient/' + patient_dfn + '/document' })" />
</div>
</div>
<ModalPromptSignatureCode :client="client" v-model:show="show_signature" @submit="doc_sign" label="Sign Document" />
</template>
<style scoped>
@ -73,7 +79,7 @@
cursor: default;
text-decoration: none;
}
div.detail {
div.detail /deep/ .card-body {
scroll-margin-top: calc(3.6875rem + 2.5625rem + 25vh);
font-family: monospace;
white-space: pre-wrap;
@ -88,7 +94,7 @@
div.cell.secondary {
font-size: 0.8em;
}
div.detail {
div.detail /deep/ .card-body {
max-height: 75vh;
scroll-margin-top: 0;
overflow-y: auto;
@ -100,12 +106,15 @@
import { debounce, strptime_vista } from './util.mjs';
import Subtitle from './Subtitle.vue';
import ViewDocNew from './ViewDocNew.vue';
import ViewDocView from './ViewDocView.vue';
import ModalPromptSignatureCode from './ModalPromptSignatureCode.vue';
const SZ_WINDOW = 100;
export default {
components: {
Subtitle
Subtitle, ViewDocNew, ViewDocView, ModalPromptSignatureCode
},
props: {
client: Object,
@ -118,13 +127,19 @@
dfn: null,
has_more: '',
is_loading: false,
resultset: [],
rs_unsigned: [],
rs_signed: [],
selection: null,
selection_text: null,
show_signature: false,
observer_scroller: null,
observer_viewport: null
};
},
computed: {
resultset() {
return this.rs_unsigned.concat(this.rs_signed);
}
},
watch: {
'$route.params.tiu_da': {
async handler(value) {
@ -140,17 +155,6 @@
datetimestring(date) {
return date.toLocaleDateString('sv-SE') + ' ' + date.toLocaleTimeString('en-GB');
},
doctitle(doc) {
if(doc) {
var brk = doc.indexOf('\r\n');
if(brk >= 0) {
doc = doc.substring(0, brk);
brk = doc.indexOf(': ');
if(brk >= 0) return doc.substring(brk + 2).replace(/^\s+|\s+$/g, '');
else return doc.replace(/^\s+|\s+$/g, '');
}
}
},
async load_more() {
try {
this.is_loading = true;
@ -158,7 +162,7 @@
if(this.dfn != this.patient_dfn) {
this.dfn = this.patient_dfn;
this.has_more = '';
this.resultset = [];
this.rs_signed = [];
}
var res = await client.TIU_DOCUMENTS_BY_CONTEXT(3, 1, this.patient_dfn, -1, -1, 0, SZ_WINDOW, 'D', 1, 0, 1, this.has_more);
if((res) && (res.length > 0)) {
@ -168,38 +172,81 @@
this.has_more = last.IEN;
res.splice(res.length - 1, 1);
}
if(this.resultset.length > 0) Array.prototype.push.apply(this.resultset, res);
else this.resultset = res;
if(this.rs_signed.length > 0) Array.prototype.push.apply(this.rs_signed, res);
else this.rs_signed = res;
}
} else {
this.dfn = null;
this.has_more = '';
this.resultset = [];
this.rs_signed = [];
}
} catch(ex) {
console.warn(ex);
} finally {
this.is_loading = false;
}
},
async load_unsigned() {
this.rs_unsigned = [];
this.rs_unsigned = await client.TIU_DOCUMENTS_BY_CONTEXT(3, 2, this.patient_dfn, 0, 0, 0, 0, 'D', 1, 0, 1, '');
},
async reload() {
this.dfn = null;
await client.TIU_DOCUMENTS_BY_CONTEXT_FLUSH(3, 2, this.patient_dfn, 0, 0, 0, 0, 'D', 1, 0, 1, '');
await this.load_unsigned();
await client.TIU_DOCUMENTS_BY_CONTEXT_FLUSH(3, 1, this.patient_dfn, -1, -1, 0, SZ_WINDOW, 'D', 1, 0, 1, '');
await this.load_more();
},
async doc_create(params) {
var vstr = params.location.datetime ? ('' + params.location.IEN + ';' + params.location.datetime + ';A') : ('' + params.location.IEN + ';' + params.datetime + ';E');
var res = await this.client.TIU_CREATE_RECORD(this.patient_dfn, params.title, '', '', '', { '".01"': params.title, '"1202"': params.author, '"1301"': params.datetime, '"1205"': params.location.IEN }, vstr, '1');
if(res) {
this.reload();
this.$router.replace({ path: '/patient/' + this.patient_dfn + '/document/' + res, query: { edit: '' } });
} else {
console.error('Unable to create document', params, res);
window.alert('Unable to create document.');
}
},
doc_sign_prompt(ien) {
this.show_signature = true;
},
async doc_sign(code) {
var selection = this.selection;
if((selection) & (code)) {
this.show_signature = false;
await this.client.TIU_SIGN_RECORD(selection, ' ' + code + ' ');
this.reload();
this.selection = null;
await this.$nextTick();
this.selection = selection;
}
},
async doc_delete(ien) {
if(window.confirm('Delete this document?')) {
var vstr = await this.client.ORWPCE_NOTEVSTR(ien);
if(vstr) await this.client.ORWPCE_DELETE(vstr, this.patient_dfn);
await this.client.TIU_DELETE_RECORD(ien);
this.reload();
if(this.selection == ien) {
this.selection = null;
this.$router.replace({ path: '/patient/' + this.patient_dfn + '/document' });
}
}
}
},
created() {
this.$watch(
() => (this.client, this.patient_dfn, {}),
debounce(this.load_more, 500),
debounce(() => { this.load_more(); this.load_unsigned(); }, 500),
{ immediate: true }
);
this.$watch(
() => (this.client, this.selection, {}),
async function() {
try {
this.selection_text = (this.client) && (this.selection) ? await this.client.TIU_GET_RECORD_TEXT(this.selection) : null;
} catch(ex) {
this.selection_text = null;
console.warn(ex);
}
if(this.$refs.scroller) {
if(this.selection_text) { // scroll to selected item
if(this.selection) { // scroll to selected item
if(this.selection != 'new') {
await this.$nextTick();
var active = this.$refs.scroller.querySelectorAll(':scope > .active');
if(active.length > 0) (Element.prototype.scrollIntoViewIfNeeded || Element.prototype.scrollIntoView).call(active[0]);
@ -207,6 +254,7 @@
this.$refs.detail.scrollIntoView();
this.$refs.detail.scrollTop = 0;
}
}
} else { // scroll to topmost item
var offset = this.$refs.scroller.getBoundingClientRect().top;
for(var children = this.$refs.scroller.children, count = children.length, i = 0; i < count; ++i) if(children[i].getBoundingClientRect().top >= offset) {
@ -224,9 +272,9 @@
);
},
mounted() {
this.observer_scroller = new IntersectionObserver(([entry]) => { if((entry.isIntersecting) && (this.selection_text) && (this.has_more) && (!this.is_loading)) this.load_more(); }, { root: this.$refs.scroller, rootMargin: '25%' });
this.observer_scroller = new IntersectionObserver(([entry]) => { if((entry.isIntersecting) && (this.selection) && (this.has_more) && (!this.is_loading)) this.load_more(); }, { root: this.$refs.scroller, rootMargin: '25%' });
this.observer_scroller.observe(this.$refs.bottom);
this.observer_viewport = new IntersectionObserver(([entry]) => { if((entry.isIntersecting) && (!this.selection_text) && (this.has_more) && (!this.is_loading)) this.load_more(); }, { rootMargin: '25%' });
this.observer_viewport = new IntersectionObserver(([entry]) => { if((entry.isIntersecting) && (!this.selection) && (this.has_more) && (!this.is_loading)) this.load_more(); }, { rootMargin: '25%' });
this.observer_viewport.observe(this.$refs.bottom);
},
destroyed() {

177
htdocs/ViewDocEdit.vue Normal file
View File

@ -0,0 +1,177 @@
<template>
<div v-if="record !== null" class="card mb-3 shadow">
<div class="card-header d-flex justify-content-between align-items-center">
<span>{{record['~.01'] && record['~.01'].description || 'Document'}}</span>
<a class="widget" @click="() => update(true)"></a>
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item"><DateTimePicker v-model="datetime" /></li>
<li class="list-group-item">
<div class="input-group">
<span class="input-group-text">Subject</span>
<input type="text" class="form-control" v-model="subject" />
</div>
</li>
<li class="list-group-item"><textarea ref="textarea" class="form-control" v-model="text" @keydown.tab.exact.prevent="tab" @keydown.shift.tab.exact.prevent="untab" /></li>
</ul>
<div v-if="saved" class="card-footer" style="text-align: right;">Saved at {{saved.toLocaleString()}}</div>
</div>
</template>
<style scoped>
a.widget {
cursor: default;
text-decoration: none;
}
textarea {
font-family: monospace;
tab-size: 8;
}
</style>
<script>
import { debounce } from './util.mjs';
import { strptime, strftime } from './fmdatetime.mjs';
import DateTimePicker from './DateTimePicker.vue';
function untab({ input, size=8, tab='\t', space=' ', join=true}={}) {
input = input.split('\n');
for(var i = input.length - 1; i >= 0; --i) input[i] = untab_line(input[i], size, tab, space);
return join ? input.join('\n') : input;
}
function untab_line(line, size=8, tab='\t', space=' ') {
var res = '', index = 0, offset = 0, next, count;
while((next = line.indexOf(tab, index)) >= 0) {
count = size - (next + offset)%size;
res += line.substring(index, next) + Array(count + 1).join(space);
offset += count - 1;
index = next + 1;
}
return res + line.substring(index);
}
function retab({ input, size=8, tab='\t', space=' ' }={}) {
var re_space = new RegExp(space.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '+', 'g');
return input.replace(re_space, function(m) { return Array(Math.ceil(m.length/size) + 1).join(tab); });
}
function wrap({ input, width=80, cut=false, tabsize=8, tab='\t', space=' ', untab=false, join=true }={}) {
var input = input.split('\n'), lines, res = [];
if(untab) {
for(var i = 0; i < input.length; ++i) {
lines = wrap_line_split(untab_line(input[i], tabsize, tab, space), width, cut);
for(var j = 0; j < lines.length; ++j) res.push(lines[j].replace(/(\s)\s+$/, '$1'));
}
return join ? res.join('\n') : res;
} else {
for(var i = 0; i < input.length; ++i) {
lines = wrap_line_split(untab_line(input[i], tabsize, tab, tab), width, cut); // replace tabs with placeholder tabs
for(var j = 0; j < lines.length; ++j) res.push(lines[j].replace(/(\s)\s+$/, '$1'));
}
res = retab({ input: res.join('\n'), size: tabsize, tab, space: tab }); // collapse placeholder tabs
return join ? res : res.split('\n');
}
}
function wrap_line(str, width=80, cut=false, brk='\n') {
if(!str) return str;
return str.match(new RegExp('.{1,' + width + '}(\\s+|$)' + (cut ? '|.{' + width + '}|.+$' : '|\\S+?(\\s+|$)'), 'g')).join(brk);
}
function wrap_line_split(str, width=80, cut=false) {
if(!str) return [str];
return str.match(new RegExp('.{1,' + width + '}(\\s+|$)' + (cut ? '|.{' + width + '}|.+$' : '|\\S+?(\\s+|$)'), 'g'));
}
export default {
components: {
DateTimePicker
},
props: {
client: Object,
dfn: String,
ien: String
},
emits: {
'accept': null,
'update': Object
},
data() {
return {
record: null,
saved: null
};
},
computed: {
datetime: {
get() { return this.record && this.record['~1301'] && this.record['~1301'].value; },
set(value) { this.record['~1301'].value = strftime(strptime(value)); this.autosave(); }
},
subject: {
get() { return this.record && this.record['~1701'] && this.record['~1701'].value; },
set(value) { this.record['~1701'].value = this.record['~.07'].value = value; this.autosave(); }
},
text: {
get() { return this.record && this.record.text; },
set(value) { this.record.text = value; this.autosave(); }
}
},
watch: {
text() {
this.resize();
}
},
methods: {
async tab(evt) {
var target = event.target, value = target.value, start = target.selectionStart, end = target.selectionEnd;
if(start == end) document.execCommand('insertText', false, '\t');
else {
start = target.selectionStart = value.lastIndexOf('\n', start - 1) + 1;
end = target.selectionEnd = value.indexOf('\n', end); if(end < 0) end = value.length;
var selection = value.substring(start, end);
document.execCommand('insertText', false, selection.replace(/^/gm, '\t'));
await this.$nextTick();
target.selectionStart = start;
target.selectionEnd = end + selection.split('\n').length;
}
},
async untab(evt) {
var target = event.target, value = target.value;
var start = target.selectionStart = value.lastIndexOf('\n', target.selectionStart - 1) + 1;
var end = target.selectionEnd = value.indexOf('\n', target.selectionEnd); if(end < 0) end = value.length;
var selection = value.substring(start, end);
document.execCommand('insertText', false, selection.replace(/^\t/gm, ''));
await this.$nextTick();
target.selectionStart = start;
target.selectionEnd = end - (selection.match(/^\t/gm) || []).length;
},
async update(accept) {
var res = this.record.reduce((acc, val) => (acc['"' + val.field + '"'] = val.value, acc), {});
var text = wrap({input: this.text || '\x01', width: 80, cut: true, untab: false, join: false});
for(var i = 0; i < text.length; ++i) res['"TEXT","' + (i + 1) + '","0"'] = text[i];
this.$emit('update', res);
if(await this.client.TIU_UPDATE_RECORD(this.ien, res, 0)) {
await this.client.TIU_GET_RECORD_TEXT_FLUSH(this.ien);
this.saved = new Date();
if(accept) this.$emit('accept');
}
}
},
created() {
this.resize = debounce(async function() {
var textarea = this.$refs.textarea;
textarea.style.height = 'auto';
await this.$nextTick();
textarea.style.height = textarea.scrollHeight + 4 + 'px';
}, 50);
this.autosave = debounce(this.update, 2000);
this.$watch(
() => (this.client, this.ien, {}),
async function() {
this.record = (this.client) && (this.ien) ? await this.client.TIU_LOAD_RECORD_FOR_EDIT(this.ien, '.01;.06;.07;1301;1204;1208;1701;1205;1405;2101;70201;70202') : [];
this.resize();
},
{ immediate: true }
);
}
};
</script>

79
htdocs/ViewDocNew.vue Normal file
View File

@ -0,0 +1,79 @@
<template>
<div class="card mb-3 shadow">
<div class="card-header d-flex justify-content-between align-items-center">
<span>New document</span>
<a class="close" @click="() => $emit('cancel')"></a>
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item"><ViewLocationLookup :client="client" :dfn="dfn" label="Visit" v-model="x_location" /></li>
<li class="list-group-item"><ViewDocTitleLookup :client="client" label="Title" v-model="x_title" /></li>
<li class="list-group-item"><ViewUserLookup :client="client" label="Author" v-model="x_author" /></li>
<li class="list-group-item"><DateTimePicker v-model="x_datetime" /></li>
</ul>
<div class="card-footer btn-group" role="group"><button class="btn btn-primary" :disabled="!((x_location) && (x_location.IEN) && (x_title) && (x_author))" @click="() => $emit('submit', { location: x_location, title: x_title, author: x_author, datetime: fmdatetime(x_datetime) })">Create</button></div>
</div>
</template>
<style scoped>
a.close {
cursor: default;
text-decoration: none;
}
</style>
<script>
import { strptime, strftime } from './fmdatetime.mjs';
import ViewLocationLookup from './ViewLocationLookup.vue';
import ViewDocTitleLookup from './ViewDocTitleLookup.vue';
import ViewUserLookup from './ViewUserLookup.vue';
import DateTimePicker from './DateTimePicker.vue';
export default {
components: {
ViewLocationLookup, ViewDocTitleLookup, ViewUserLookup, DateTimePicker
},
props: {
client: Object,
dfn: String,
location: String,
title: String,
author: String,
datetime: {
type: String,
default: 'N'
}
},
emits: {
'cancel': null,
'submit': Object,
'update:location': String,
'update:title': String,
'update:author': String,
'update:datetime': String
},
data() {
return {
x_location: this.location,
x_title: this.title,
x_author: this.author,
x_datetime: this.datetime
};
},
watch: {
location(value) { this.x_location = value; },
x_location(value) { this.$emit('update:location', value); },
title(value) { this.x_title = value; },
x_title(value) { this.$emit('update:title', value); },
author(value) { this.x_author = value; },
x_author(value) { this.$emit('update:author', value); },
datetime(value) { this.x_datetime = value; },
x_datetime(value) { this.$emit('update:datetime', value); }
},
methods: {
fmdatetime(datetime) {
return strftime(strptime(datetime));
}
}
};
</script>

View File

@ -0,0 +1,123 @@
<template>
<div v-if="label" class="input-group">
<span class="input-group-text">{{label}}</span>
<input class="form-control" placeholder="Filter..." v-model="x_query" />
</div>
<input v-else class="form-control" placeholder="Filter..." v-model="x_query" />
<div class="scroller" ref="scroller">
<table class="table table-striped">
<tbody>
<tr v-for="item in resultset" :class="{ 'table-active': item.IEN == x_modelValue }" @click="x_modelValue = item.IEN">
<td>{{item.name}}</td>
<td style="text-align: right;">#{{item.IEN}}</td>
</tr>
<tr ref="bottom" />
</tbody>
</table>
</div>
</template>
<style scoped>
div.scroller {
max-height: 25vh;
overflow-y: auto;
}
td {
cursor: default;
}
.table-active, .table-active:nth-of-type(odd) > * {
color: #fff;
background-color: #0d6efd;
}
</style>
<script>
import { debounce } from './util.mjs';
export default {
props: {
client: Object,
label: String,
query: String,
modelValue: String
},
emits: {
'update:query': String,
'update:modelValue': String
},
data() {
return {
resultset: [],
has_more: false,
is_loading: true,
observer_bottom: null,
x_query: this.query,
x_modelValue: this.modelValue
};
},
computed: {
query_view() {
return this.x_query ? this.x_query.replace(/^\s+|\s+$/g, '').replace(/\s+/g, ' ').toUpperCase() : '';
}
},
watch: {
modelValue(value) { this.x_modelValue = value; },
x_modelValue(value) { this.$emit('update:modelValue', value); },
query(value) { this.x_query = value; },
x_query(value) { this.$emit('update:query', value); }
},
methods: {
async handle_bottom([entry]) {
if((entry.isIntersecting) && (this.has_more) && (!this.is_loading)) {
this.is_loading = true;
this.has_more = false;
try {
var batch = await this.client.TIU_LONG_LIST_OF_TITLES(3, this.resultset[this.resultset.length - 1].name, 1);
if(this.query_view.length >= 1) batch = batch.filter(x => x.name.startsWith(this.query_view));
if(batch.length > 0) {
Array.prototype.push.apply(this.resultset, batch);
this.has_more = true;
} else this.has_more = false;
} catch(ex) {
this.has_more = false;
} finally {
this.is_loading = false;
}
}
}
},
created() {
this.$watch(
() => (this.client, this.query_view, {}),
debounce(async function() {
if(this.client) {
this.is_loading = true;
this.has_more = false;
try {
var query = this.query_view;
if(query.length >= 1) {
var batch = await this.client.TIU_LONG_LIST_OF_TITLES(3, query.slice(0, -1) + String.fromCharCode(query.charCodeAt(query.length - 1) - 1) + '~', 1);
this.resultset = batch.filter(x => x.name.startsWith(query));
} else this.resultset = await this.client.TIU_LONG_LIST_OF_TITLES(3, '', 1);
this.has_more = this.resultset.length > 0;
} catch(ex) {
this.resultset = [];
this.has_more = false;
} finally {
this.is_loading = false;
if(this.$refs.scroller) this.$refs.scroller.scrollTo(0, 0);
}
}
}, 500),
{ immediate: true }
);
},
mounted() {
this.observer_bottom = new IntersectionObserver(this.handle_bottom, { root: this.$refs.scroller, rootMargin: '25%' });
this.observer_bottom.observe(this.$refs.bottom);
},
destroyed() {
if(this.observer_bottom) this.observer_bottom.disconnect();
}
};
</script>

98
htdocs/ViewDocView.vue Normal file
View File

@ -0,0 +1,98 @@
<template>
<ViewDocEdit v-if="(can_edit) && (is_editing)" :client="client" :dfn="dfn" :ien="ien" @update="x => $emit('update', x)" @accept="doc_accept" />
<div v-else class="card mb-3 shadow">
<div class="card-header d-flex justify-content-between align-items-center">
<span>{{localtitle || 'Document'}}</span>
<span>
<a v-if="can_delete" class="widget" @click="() => $emit('delete', ien)">🗑</a>
<a v-if="can_edit" class="widget" @click="() => is_editing = true"></a>
<a v-if="can_sign" class="widget" @click="() => $emit('sign', ien)">🔏</a>
<a class="widget" @click="() => $emit('cancel')"></a>
</span>
</div>
<div class="card-body">{{text}}</div>
</div>
</template>
<style scoped>
a.widget {
cursor: default;
text-decoration: none;
}
div.card-body {
tab-size: 8;
}
</style>
<script>
import ViewDocEdit from './ViewDocEdit.vue';
export default {
components: {
ViewDocEdit
},
props: {
client: Object,
dfn: String,
ien: String,
},
emits: {
'cancel': null,
'sign': null,
'update': Object,
'delete': String
},
data() {
return {
text: null,
can_sign: null,
can_edit: null,
can_delete: null
};
},
computed: {
is_editing: {
get() { return this.$route.query.hasOwnProperty('edit'); },
set(value) {
var query = { ...this.$route.query };
if(value) query.edit = '';
else delete query.edit;
this.$router.replace({ query });
}
},
localtitle() {
var doc = this.text;
if(doc) {
var brk = doc.indexOf('\r\n');
if(brk >= 0) {
doc = doc.substring(0, brk);
brk = doc.indexOf(': ');
if(brk >= 0) return doc.substring(brk + 2).replace(/^\s+|\s+$/g, '');
else return doc.replace(/^\s+|\s+$/g, '');
}
}
}
},
methods: {
async doc_accept() {
this.text = await this.client.TIU_GET_RECORD_TEXT(this.ien);
this.is_editing = false;
}
},
created() {
this.$watch(
() => (this.client, this.ien, {}),
async function() {
this.text = this.can_edit = this.can_delete = null;
if((this.client) && (this.ien)) {
this.text = await this.client.TIU_GET_RECORD_TEXT(this.ien);
this.can_sign = (await this.client.TIU_AUTHORIZATION(this.ien, 'SIGNATURE') == '1') || (await this.client.TIU_AUTHORIZATION(this.ien, 'COSIGNATURE') == '1');
this.can_edit = await this.client.TIU_AUTHORIZATION(this.ien, 'EDIT RECORD') == '1';
this.can_delete = await this.client.TIU_AUTHORIZATION(this.ien, 'DELETE RECORD') == '1';
}
},
{ immediate: true }
);
}
};
</script>

131
htdocs/ViewUserLookup.vue Normal file
View File

@ -0,0 +1,131 @@
<template>
<div v-if="label" class="input-group">
<span class="input-group-text">{{label}}</span>
<input class="form-control" placeholder="Filter..." v-model="x_query" />
</div>
<input v-else class="form-control" placeholder="Filter..." v-model="x_query" />
<div class="scroller" ref="scroller">
<table class="table table-striped">
<tbody>
<tr v-for="item in resultset" :class="{ 'table-active': item.DUZ == x_modelValue }" @click="x_modelValue = item.DUZ">
<td>{{item.name}}</td>
<td>{{item.description.replace(/^-\s*/g, '')}}</td>
<td style="text-align: right;">#{{item.DUZ}}</td>
</tr>
<tr ref="bottom" />
</tbody>
</table>
</div>
</template>
<style scoped>
div.scroller {
max-height: 25vh;
overflow-y: auto;
}
td {
cursor: default;
}
.table-active, .table-active:nth-of-type(odd) > * {
color: #fff;
background-color: #0d6efd;
}
</style>
<script>
import { debounce } from './util.mjs';
export default {
props: {
client: Object,
label: String,
query: String,
modelValue: String
},
emits: {
'update:query': String,
'update:modelValue': String
},
data() {
return {
resultset: [],
has_more: false,
is_loading: true,
observer_bottom: null,
x_query: this.query,
x_modelValue: this.modelValue
};
},
computed: {
query_view() {
return this.x_query ? this.x_query.replace(/^\s+|\s+$/g, '').replace(/\s+/g, ' ').toUpperCase() : '';
}
},
watch: {
modelValue(value) { this.x_modelValue = value; },
x_modelValue(value) { this.$emit('update:modelValue', value); },
query(value) { this.x_query = value; },
x_query(value) { this.$emit('update:query', value); }
},
methods: {
async set_default() {
if(this.x_modelValue) return;
var userinfo = await this.client.XUS_GET_USER_INFO();
this.x_modelValue = userinfo[0];
this.x_query = userinfo[1]
},
async handle_bottom([entry]) {
if((entry.isIntersecting) && (this.has_more) && (!this.is_loading)) {
this.is_loading = true;
this.has_more = false;
try {
var batch = await this.client.ORWU_NEWPERS(this.resultset[this.resultset.length - 1].name, 1, '', '', '', '', '', '', 0);
if(this.query_view.length >= 1) batch = batch.filter(x => x.name.toUpperCase().startsWith(this.query_view));
if(batch.length > 0) {
Array.prototype.push.apply(this.resultset, batch);
this.has_more = true;
} else this.has_more = false;
} catch(ex) {
this.has_more = false;
} finally {
this.is_loading = false;
}
}
}
},
created() {
this.$watch(
() => (this.client, this.query_view, {}),
debounce(async function() {
if(this.client) {
this.is_loading = true;
this.has_more = false;
try {
var query = this.query_view;
if(query.length >= 1) {
var batch = await this.client.ORWU_NEWPERS(query.slice(0, -1) + String.fromCharCode(query.charCodeAt(query.length - 1) - 1) + '~', 1, '', '', '', '', '', '', 0);
this.resultset = batch.filter(x => x.name.toUpperCase().startsWith(query));
} else this.resultset = await this.client.ORWU_NEWPERS('', 1, '', '', '', '', '', '', 0);
this.has_more = this.resultset.length > 0;
} catch(ex) {
this.resultset = [];
this.has_more = false;
} finally {
this.is_loading = false;
if(this.$refs.scroller) this.$refs.scroller.scrollTo(0, 0);
}
}
}, 500),
{ immediate: true }
);
this.set_default();
},
mounted() {
this.observer_bottom = new IntersectionObserver(this.handle_bottom, { root: this.$refs.scroller, rootMargin: '25%' });
this.observer_bottom.observe(this.$refs.bottom);
},
destroyed() {
if(this.observer_bottom) this.observer_bottom.disconnect();
}
};
</script>

View File

@ -189,6 +189,20 @@ export const d_parse_multireport = data => {
return (res._ts = _ts, res);
};
export const d_parse_tiurecordtiux = data => {
var res = {};
if(data.length < 1) return res;
var brk = data.indexOf('$TXT'), text = undefined;
if(brk >= 0) {
text = data.slice(brk + 1).join('\r\n');
data = data.slice(0, brk);
}
data = d_split(data, '^', 'field', 'value', 'description');
data = data.reduce((acc, val) => (acc['~' + val.field] = val, acc), data);
if(text) data.text = text;
return data;
};
export const d_parse_tiudocumentlist = data => d_split(data, '^', 'IEN', 'title', 'time', 'patient', 'author', 'location', 'status', 'visit').map(row => {
row.author = row.author ? d_split1(row.author, ';', 'IEN', 'byline', 'name') : null;
row.visit = row.visit ? d_split1(row.visit, ';', 'date', 'time') : null;
@ -311,10 +325,24 @@ export function Client(cid, secret) {
this.TIU_TEMPLATE_LOCK = aflow((...args) => this.call({ method: 'TIU_TEMPLATE_LOCK', context: ['OR CPRS GUI CHART'], ttl: 0, stale: false }, ...args), d_log, d_unwrap);
this.TIU_TEMPLATE_UNLOCK = aflow((...args) => this.call({ method: 'TIU_TEMPLATE_UNLOCK', context: ['OR CPRS GUI CHART'], ttl: 0, stale: false }, ...args), d_log, d_unwrap);
this.TIU_DOCUMENTS_BY_CONTEXT = aflow((...args) => this.call({ method: 'TIU_DOCUMENTS_BY_CONTEXT', context: ['OR CPRS GUI CHART'], ttl: 60, stale: false }, ...args), d_log, d_unwrap, d_parse_array, d_parse_tiudocumentlist);
this.TIU_DOCUMENTS_BY_CONTEXT_FLUSH = aflow((...args) => this.call({ method: 'TIU_DOCUMENTS_BY_CONTEXT', context: ['OR CPRS GUI CHART'], ttl: 0, stale: false }, ...args), d_log, d_unwrap, d_parse_array, d_parse_tiudocumentlist);
this.TIU_GET_RECORD_TEXT = aflow((...args) => this.call({ method: 'TIU_GET_RECORD_TEXT', context: ['OR CPRS GUI CHART'], ttl: 60, stale: false }, ...args), d_log, d_unwrap, d_parse_text);
this.TIU_GET_RECORD_TEXT_FLUSH = aflow((...args) => this.call({ method: 'TIU_GET_RECORD_TEXT', context: ['OR CPRS GUI CHART'], ttl: 0, stale: false }, ...args), d_log, d_unwrap, d_parse_text);
this.TIU_LONG_LIST_OF_TITLES = memoized(aflow((...args) => this.call({ method: 'TIU_LONG_LIST_OF_TITLES', context: ['OR CPRS GUI CHART'], ttl: 86400, stale: true, ttl: 86400, stale: true }, ...args), d_log, d_unwrap, f_split('^', 'IEN', 'name')));
this.TIU_CREATE_RECORD = aflow((...args) => this.call({ method: 'TIU_CREATE_RECORD', context: ['OR CPRS GUI CHART'], ttl: 0, stale: false }, ...args), d_log, d_unwrap);
this.TIU_AUTHORIZATION = aflow((...args) => this.call({ method: 'TIU_AUTHORIZATION', context: ['OR CPRS GUI CHART'], ttl: 0, stale: false }, ...args), d_log, d_unwrap);
this.TIU_LOAD_RECORD_FOR_EDIT = aflow((...args) => this.call({ method: 'TIU_LOAD_RECORD_FOR_EDIT', context: ['OR CPRS GUI CHART'], ttl: 0, stale: false }, ...args), d_log, d_unwrap, d_parse_tiurecordtiux);
this.TIU_UPDATE_RECORD = aflow((...args) => this.call({ method: 'TIU_UPDATE_RECORD', context: ['OR CPRS GUI CHART'], ttl: 0, stale: false }, ...args), d_log, d_unwrap);
this.TIU_DELETE_RECORD = aflow((...args) => this.call({ method: 'TIU_DELETE_RECORD', context: ['OR CPRS GUI CHART'], ttl: 0, stale: false }, ...args), d_log, d_unwrap);
this.TIU_SIGN_RECORD = aflow((...args) => this.call({ method: 'TIU_SIGN_RECORD', context: ['OR CPRS GUI CHART'], ttl: 0, stale: false }, ...args), d_log, d_unwrap);
this.ORWPCE_NOTEVSTR = aflow((...args) => this.call({ method: 'ORWPCE_NOTEVSTR', context: ['OR CPRS GUI CHART'], ttl: 86400, stale: true }, ...args), d_log, d_unwrap);
this.ORWPCE_DELETE = aflow((...args) => this.call({ method: 'ORWPCE_DELETE', context: ['OR CPRS GUI CHART'], ttl: 0, stale: false }, ...args), d_log, d_unwrap);
this.ORWCV_VST = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWCV_VST', ...args), d_log, d_unwrap, f_split('^', 'apptinfo', 'datetime', 'location', 'status')));
this.ORWU_NEWPERS = memoized(aflow((...args) => this.call({ method: 'ORWU_NEWPERS', context: ['OR CPRS GUI CHART'], ttl: 86400, stale: true }, ...args), d_log, d_unwrap, f_split('^', 'DUZ', 'name', 'description')));
this.ORWU_VALIDSIG = memoized(aflow((...args) => this.call({ method: 'ORWU_VALIDSIG', context: ['OR CPRS GUI CHART'], ttl: 0, stale: false }, ...args), d_log, d_unwrap));
this.ORWU1_NEWLOC = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWU1_NEWLOC', ...args), d_log, d_unwrap, f_split('^', 'IEN', 'name')));
this.ORWDX_DGNM = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDX_DGNM', ...args), d_log, d_unwrap));