Compare commits
151 Commits
7f5ed98386
...
main
Author | SHA1 | Date | |
---|---|---|---|
46ef48b0ae | |||
1d276e7bec | |||
18d6b6f19c | |||
5d2a8f464f | |||
fcd8447658 | |||
e648988b53 | |||
4708e5e0eb | |||
badb26c9fc | |||
5db3091470 | |||
ace1407715 | |||
32de0bdd56 | |||
fa25bf3c5c | |||
053053825c | |||
86c18927e8 | |||
a9d138e749 | |||
3aa6a64f36 | |||
83c9f11c73 | |||
d7d716cc27 | |||
5c67773287 | |||
eebda06c86 | |||
48a092432c | |||
4a3136a766 | |||
0f019ebc34 | |||
770a9cfb2e | |||
e8f1ff02fb | |||
56662efed2 | |||
ffc2d4c6fa | |||
4b9d27b553 | |||
f011b88bf4 | |||
6fcd3825c8 | |||
cdbcc51927 | |||
d08f76ec99 | |||
e63e7100f0 | |||
f6408e0188 | |||
baa8103167 | |||
c7a541a9e3 | |||
d3673fc826 | |||
b74dcb7d24 | |||
c74855b9c2 | |||
2bf0fb971a | |||
8c35651281 | |||
827a6596c7 | |||
f14800445f | |||
0c9e428186 | |||
1825edc637 | |||
12a7c584a5 | |||
15f5acbf52 | |||
adb05216fd | |||
7d45820c39 | |||
0fe07e59af | |||
71db3a6186 | |||
38b13f9ad6 | |||
98cd861b5b | |||
45dda6f51d | |||
4a25b2f823 | |||
cdfa3b2f04 | |||
c15f7ed885 | |||
cc5ec1f69f | |||
b57634f730 | |||
535302ce9d | |||
6970c62276 | |||
39dba30d14 | |||
a64edff176 | |||
666c917472 | |||
c3a99697b0 | |||
3666378172 | |||
23ecad4f8f | |||
9fe8227c57 | |||
6a284e9b6b | |||
85cefa1b7b | |||
b1038fb577 | |||
f64527122e | |||
704942fd3a | |||
a5b041ae9a | |||
525e23c790 | |||
73cfbd5bbd | |||
c53a9654b5 | |||
ec841b9591 | |||
4bc854bf00 | |||
ece8338ac8 | |||
fdc7ed4c05 | |||
a83e8cb22c | |||
a3413e382c | |||
002be2be12 | |||
132c85c1fd | |||
24291804a2 | |||
eb5e861441 | |||
72d14e4b2f | |||
539f804d2e | |||
4edf50aa82 | |||
16cd1ef4e0 | |||
8794d27d08 | |||
5c75028a2a | |||
6c263b00dc | |||
1d68749525 | |||
2ecce6be8c | |||
57028906b4 | |||
2b3f23740b | |||
f8a50c31b6 | |||
53812f7c85 | |||
dac1add279 | |||
3821cef116 | |||
7c0e1bfbcf | |||
3beb008969 | |||
a30ee4a3da | |||
24da5347f4 | |||
976a96e5e0 | |||
87a57a545f | |||
85fabfcc92 | |||
4ccb4ca3db | |||
555308e97f | |||
eee1d3b38c | |||
7b4b9ae099 | |||
c1ca4ea414 | |||
a01fa834ac | |||
17cb40f46a | |||
26b4a116ba | |||
b3195bcd82 | |||
f831f905a5 | |||
4477f4b5c7 | |||
93994d5dd7 | |||
aa3a3b06a7 | |||
406e96ce39 | |||
b460313bc8 | |||
4c270d5e38 | |||
95e6d8e54e | |||
9785af43a0 | |||
8b76181b4d | |||
7819e531e3 | |||
6ae57cfd79 | |||
030487d728 | |||
976276e2e0 | |||
0712eccf15 | |||
01644ef384 | |||
6e6f27bb5c | |||
6c7050b0f4 | |||
2d931f3c90 | |||
1a08acdc7c | |||
31ffadbae3 | |||
5035ff2dd1 | |||
05601e0adb | |||
f2dcb718c7 | |||
426d50d5f7 | |||
7f42be2e64 | |||
9687d9638e | |||
91f2c45e4f | |||
25772419a0 | |||
57e285108e | |||
2c28b85a10 | |||
cc1b8341d6 | |||
4196581a95 |
201
XWBSSOi.py
Normal file
201
XWBSSOi.py
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import ctypes
|
||||||
|
import ctypes.wintypes
|
||||||
|
import winreg
|
||||||
|
import contextlib
|
||||||
|
from typing import Any, Optional, Generator
|
||||||
|
|
||||||
|
DEFAULT_USER_AGENT = 'Borland SOAP 1.2'
|
||||||
|
DEFAULT_ISSUER = 'https://ssoi.sts.va.gov/Issuer/smtoken/SAML2'
|
||||||
|
|
||||||
|
HCERTSTORE = ctypes.c_void_p
|
||||||
|
PCERT_INFO = ctypes.c_void_p
|
||||||
|
HCRYPTPROV_LEGACY = ctypes.c_void_p
|
||||||
|
|
||||||
|
CERT_STORE_PROV_MEMORY = b'Memory'
|
||||||
|
X509_ASN_ENCODING = 0x00000001
|
||||||
|
PKCS_7_ASN_ENCODING = 0x00010000
|
||||||
|
CERT_COMPARE_ANY = 0
|
||||||
|
CERT_COMPARE_SHIFT = 16
|
||||||
|
CERT_FIND_ANY = CERT_COMPARE_ANY<<CERT_COMPARE_SHIFT
|
||||||
|
CERT_NAME_FRIENDLY_DISPLAY_TYPE = 5
|
||||||
|
CERT_DIGITAL_SIGNATURE_KEY_USAGE = 0x80
|
||||||
|
CERT_STORE_ADD_ALWAYS = 4
|
||||||
|
CERT_HASH_PROP_ID = CERT_SHA1_HASH_PROP_ID = 3
|
||||||
|
|
||||||
|
class CERT_CONTEXT(ctypes.Structure):
|
||||||
|
_fields_ = [
|
||||||
|
('dwCertEncodingType', ctypes.wintypes.DWORD),
|
||||||
|
('pbCertEncoded', ctypes.POINTER(ctypes.wintypes.BYTE)),
|
||||||
|
('cbCertEncoded', ctypes.wintypes.DWORD),
|
||||||
|
('pCertInfo', PCERT_INFO),
|
||||||
|
('hCertStore', HCERTSTORE),
|
||||||
|
]
|
||||||
|
PCCERT_CONTEXT = ctypes.POINTER(CERT_CONTEXT)
|
||||||
|
|
||||||
|
crypt32 = ctypes.WinDLL('crypt32')
|
||||||
|
|
||||||
|
CertOpenStore = crypt32.CertOpenStore
|
||||||
|
CertOpenStore.restype = HCERTSTORE
|
||||||
|
CertOpenStore.argtypes = (ctypes.wintypes.LPCSTR, ctypes.wintypes.DWORD, HCRYPTPROV_LEGACY, ctypes.wintypes.DWORD, ctypes.c_void_p)
|
||||||
|
|
||||||
|
CertOpenSystemStoreW = crypt32.CertOpenSystemStoreW
|
||||||
|
CertOpenSystemStoreW.restype = HCERTSTORE
|
||||||
|
CertOpenSystemStoreW.argtypes = (HCRYPTPROV_LEGACY, ctypes.wintypes.LPCWSTR)
|
||||||
|
|
||||||
|
CertCloseStore = crypt32.CertCloseStore
|
||||||
|
CertCloseStore.restype = ctypes.wintypes.BOOL
|
||||||
|
CertCloseStore.argtypes = (HCERTSTORE, ctypes.wintypes.DWORD)
|
||||||
|
|
||||||
|
CertEnumCertificatesInStore = crypt32.CertEnumCertificatesInStore
|
||||||
|
CertEnumCertificatesInStore.restype = PCCERT_CONTEXT
|
||||||
|
CertEnumCertificatesInStore.argtypes = (HCERTSTORE, PCCERT_CONTEXT)
|
||||||
|
|
||||||
|
CertFindCertificateInStore = crypt32.CertFindCertificateInStore
|
||||||
|
CertFindCertificateInStore.restype = PCCERT_CONTEXT
|
||||||
|
CertFindCertificateInStore.argtypes = (HCERTSTORE, ctypes.wintypes.DWORD, ctypes.wintypes.DWORD, ctypes.wintypes.DWORD, ctypes.c_void_p, PCCERT_CONTEXT)
|
||||||
|
|
||||||
|
CertAddCertificateContextToStore = crypt32.CertAddCertificateContextToStore
|
||||||
|
CertAddCertificateContextToStore.restype = ctypes.wintypes.BOOL
|
||||||
|
CertAddCertificateContextToStore.argtypes = (HCERTSTORE, PCCERT_CONTEXT, ctypes.wintypes.DWORD, ctypes.POINTER(PCCERT_CONTEXT))
|
||||||
|
|
||||||
|
CertGetNameStringW = crypt32.CertGetNameStringW
|
||||||
|
CertGetNameStringW.restype = ctypes.wintypes.DWORD
|
||||||
|
CertGetNameStringW.argtypes = (PCCERT_CONTEXT, ctypes.wintypes.DWORD, ctypes.wintypes.DWORD, ctypes.c_void_p, ctypes.wintypes.LPWSTR, ctypes.wintypes.DWORD)
|
||||||
|
|
||||||
|
CertGetIntendedKeyUsage = crypt32.CertGetIntendedKeyUsage
|
||||||
|
CertGetIntendedKeyUsage.restype = ctypes.wintypes.BOOL
|
||||||
|
CertGetIntendedKeyUsage.argtypes = (ctypes.wintypes.DWORD, PCERT_INFO, ctypes.POINTER(ctypes.wintypes.BYTE), ctypes.wintypes.DWORD)
|
||||||
|
|
||||||
|
CertGetCertificateContextProperty = crypt32.CertGetCertificateContextProperty
|
||||||
|
CertGetCertificateContextProperty.restype = ctypes.wintypes.BOOL
|
||||||
|
CertGetCertificateContextProperty.argtypes = (PCCERT_CONTEXT, ctypes.wintypes.DWORD, ctypes.c_void_p, ctypes.POINTER(ctypes.wintypes.DWORD))
|
||||||
|
|
||||||
|
CertVerifyTimeValidity = crypt32.CertVerifyTimeValidity
|
||||||
|
CertVerifyTimeValidity.restype = ctypes.wintypes.LONG
|
||||||
|
CertVerifyTimeValidity.argtypes = (ctypes.wintypes.LPFILETIME, PCERT_INFO)
|
||||||
|
|
||||||
|
cryptui = ctypes.WinDLL('cryptui')
|
||||||
|
|
||||||
|
CryptUIDlgSelectCertificateFromStore = cryptui.CryptUIDlgSelectCertificateFromStore
|
||||||
|
CryptUIDlgSelectCertificateFromStore.restype = PCCERT_CONTEXT
|
||||||
|
CryptUIDlgSelectCertificateFromStore.argtypes = (HCERTSTORE, ctypes.wintypes.HWND, ctypes.wintypes.LPCWSTR, ctypes.wintypes.LPCWSTR, ctypes.wintypes.DWORD, ctypes.wintypes.DWORD, ctypes.c_void_p)
|
||||||
|
|
||||||
|
GetConsoleWindow = ctypes.windll.kernel32.GetConsoleWindow
|
||||||
|
GetConsoleWindow.restype = ctypes.wintypes.HWND
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def ManagedCertOpenStore(lpszStoreProvider: ctypes.wintypes.LPCSTR, dwEncodingType: ctypes.wintypes.DWORD, hCryptProv: HCRYPTPROV_LEGACY, dwFlags: ctypes.wintypes.DWORD, pvPara: ctypes.c_void_p) -> Generator[HCERTSTORE, None, None]:
|
||||||
|
res = CertOpenStore(lpszStoreProvider, dwEncodingType, hCryptProv, dwFlags, pvPara)
|
||||||
|
try:
|
||||||
|
yield res
|
||||||
|
finally:
|
||||||
|
CertCloseStore(res, 0)
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def ManagedCertOpenSystemStore(hProv: HCRYPTPROV_LEGACY, szSubsystemProtocol: ctypes.wintypes.LPCWSTR) -> Generator[HCERTSTORE, None, None]:
|
||||||
|
res = CertOpenSystemStoreW(hProv, szSubsystemProtocol)
|
||||||
|
try:
|
||||||
|
yield res
|
||||||
|
finally:
|
||||||
|
CertCloseStore(res, 0)
|
||||||
|
|
||||||
|
def get_vista_certificate(show_cert_dialog: bool=True, hwnd: Optional[int]=0) -> PCCERT_CONTEXT:
|
||||||
|
with ManagedCertOpenSystemStore(0, 'MY') as store_system, ManagedCertOpenStore(CERT_STORE_PROV_MEMORY, 0, None, 0, None) as store_memory:
|
||||||
|
cert_selection = cert_iter = None
|
||||||
|
while cert_iter := CertEnumCertificatesInStore(store_system, cert_iter):
|
||||||
|
if cert_valid := CertFindCertificateInStore(store_system, X509_ASN_ENCODING|PKCS_7_ASN_ENCODING, 0, CERT_FIND_ANY, None, None):
|
||||||
|
name_bufsz = CertGetNameStringW(cert_iter, CERT_NAME_FRIENDLY_DISPLAY_TYPE, 0, None, None, 0)
|
||||||
|
buf = ctypes.create_unicode_buffer(name_bufsz)
|
||||||
|
CertGetNameStringW(cert_iter, CERT_NAME_FRIENDLY_DISPLAY_TYPE, 0, None, buf, name_bufsz)
|
||||||
|
name_string = buf.value
|
||||||
|
key_usage_bits = ctypes.wintypes.BYTE()
|
||||||
|
CertGetIntendedKeyUsage(X509_ASN_ENCODING|PKCS_7_ASN_ENCODING, cert_iter.contents.pCertInfo, ctypes.byref(key_usage_bits), ctypes.sizeof(key_usage_bits))
|
||||||
|
valid_date = CertVerifyTimeValidity(None, cert_iter.contents.pCertInfo)
|
||||||
|
if ((key_usage_bits.value&CERT_DIGITAL_SIGNATURE_KEY_USAGE) == CERT_DIGITAL_SIGNATURE_KEY_USAGE) and (valid_date == 0) and ('Card Authentication' not in name_string) and ('0,' not in name_string) and ('Signature' not in name_string):
|
||||||
|
CertAddCertificateContextToStore(store_memory, cert_iter, CERT_STORE_ADD_ALWAYS, ctypes.byref(cert_valid))
|
||||||
|
cert_selection = cert_valid
|
||||||
|
return CryptUIDlgSelectCertificateFromStore(store_memory, hwnd if hwnd is not None else GetConsoleWindow(), 'VistA Logon - Certificate Selection', 'Select a certificate for VistA authentication', 0, 0, None) if show_cert_dialog else cert_selection
|
||||||
|
|
||||||
|
def get_certificate_thumbprint(certificate: PCCERT_CONTEXT) -> str:
|
||||||
|
bufsz = ctypes.wintypes.DWORD()
|
||||||
|
CertGetCertificateContextProperty(certificate, CERT_HASH_PROP_ID, None, ctypes.byref(bufsz))
|
||||||
|
buffer = ctypes.create_string_buffer(bufsz.value)
|
||||||
|
CertGetCertificateContextProperty(certificate, CERT_HASH_PROP_ID, buffer, ctypes.byref(bufsz))
|
||||||
|
return buffer.value
|
||||||
|
|
||||||
|
def get_certificate_friendly_display_name(certificate: PCCERT_CONTEXT) -> str:
|
||||||
|
name_bufsz = CertGetNameStringW(certificate, CERT_NAME_FRIENDLY_DISPLAY_TYPE, 0, None, None, 0)
|
||||||
|
buffer = ctypes.create_unicode_buffer(name_bufsz)
|
||||||
|
CertGetNameStringW(certificate, CERT_NAME_FRIENDLY_DISPLAY_TYPE, 0, None, buffer, name_bufsz)
|
||||||
|
return buffer.value
|
||||||
|
|
||||||
|
# WS-Trust STS endpoints from VDL documentation
|
||||||
|
# https://www.va.gov/vdl/documents/Infrastructure/KAAJEE/kaajee_ssowap_8_791_depg_r.pdf
|
||||||
|
# https://www.va.gov/vdl/documents/Financial_Admin/Bed_Management_Solution_(BMS)/bms_4_0_tm.pdf
|
||||||
|
# https://www.va.gov/vdl/documents/VistA_GUI_Hybrids/National_Utilization_Management_Integration_Archive/numi_server_setup_guide_v15_9.pdf
|
||||||
|
get_registry_iam = lambda: get_registry_value(winreg.HKEY_LOCAL_MACHINE, 'SOFTWARE\\Vista\\Common\\IAM', default='https://services.eauth.va.gov:9301/STS/RequestSecurityToken')
|
||||||
|
get_registry_iam_ad = lambda: get_registry_value(winreg.HKEY_LOCAL_MACHINE, 'SOFTWARE\\Vista\\Common\\IAM_AD', default='https://services.eauth.va.gov:9201/STS/RequestSecurityToken')
|
||||||
|
get_registry_rioserver = lambda: get_registry_value(winreg.HKEY_LOCAL_MACHINE, 'SOFTWARE\\Vista\\Common\\RIOSERVER', default='SecurityTokenService')
|
||||||
|
get_registry_rioport = lambda: get_registry_value(winreg.HKEY_LOCAL_MACHINE, 'SOFTWARE\\Vista\\Common\\RIOPORT', default='RequestSecurityToken')
|
||||||
|
def get_registry_value(hkey: int, subkey: str, value: Optional[str]=None, default: Any=None) -> Any:
|
||||||
|
try:
|
||||||
|
with winreg.OpenKey(hkey, subkey) as key:
|
||||||
|
return winreg.QueryValueEx(key, value)[0]
|
||||||
|
except FileNotFoundError:
|
||||||
|
return default
|
||||||
|
|
||||||
|
def get_iam_request(application: str, issuer: str) -> str:
|
||||||
|
return f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:ns="http://docs.oasis-open.org/ws-sx/ws-trust/200512">
|
||||||
|
<soapenv:Header/>
|
||||||
|
<soapenv:Body>
|
||||||
|
<ns:RequestSecurityToken>
|
||||||
|
<ns:Base>
|
||||||
|
<wss:TLS xmlns:wss="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd"/>
|
||||||
|
</ns:Base>
|
||||||
|
<wsp:AppliesTo xmlns:wsp="http://schemas.xmlsoap.org/ws/2004/09/policy">
|
||||||
|
<wsa:EndpointReference xmlns:wsa="http://schemas.xmlsoap.org/ws/2004/08/addressing">
|
||||||
|
<wsa:Address>{application}</wsa:Address>
|
||||||
|
</wsa:EndpointReference>
|
||||||
|
</wsp:AppliesTo>
|
||||||
|
<ns:Issuer>
|
||||||
|
<wsa:Address xmlns:wsa="http://schemas.xmlsoap.org/ws/2004/08/addressing">{issuer}</wsa:Address>
|
||||||
|
</ns:Issuer>
|
||||||
|
<ns:RequestType>http://schemas.xmlsoap.org/ws/2005/02/trust/Validate</ns:RequestType>
|
||||||
|
</ns:RequestSecurityToken>
|
||||||
|
</soapenv:Body>
|
||||||
|
</soapenv:Envelope>'''
|
||||||
|
|
||||||
|
def get_local_computer_name() -> str:
|
||||||
|
import socket
|
||||||
|
return socket.getfqdn()
|
||||||
|
|
||||||
|
def get_app_name() -> str:
|
||||||
|
import sys, os
|
||||||
|
return os.path.basename(sys.argv[0])
|
||||||
|
|
||||||
|
def get_sso_token(iam: Optional[str]=None, ua: Optional[str]=None, certificate: Optional[str]=None, issuer: Optional[str]=None, hostname: Optional[str]=None, application: Optional[str]=None) -> str:
|
||||||
|
import sys, subprocess
|
||||||
|
if certificate is None:
|
||||||
|
if choice := get_vista_certificate():
|
||||||
|
certificate = get_certificate_thumbprint(choice).hex()
|
||||||
|
if certificate is not None:
|
||||||
|
res = subprocess.run(['curl', '-fsSL', '-X', 'POST', iam or get_registry_iam(), '--ca-native', '--cert', 'CurrentUser\\MY\\' + certificate, '-A', ua or DEFAULT_USER_AGENT, '-H', 'Content-Type: application/xml', '-H', 'Accept: application/xml', '-d', get_iam_request(f"https://{hostname or get_local_computer_name()}/Delphi_RPC_Broker/{application or get_app_name()}", issuer or DEFAULT_ISSUER)], capture_output=True)
|
||||||
|
print(res.stderr.decode('utf8'), end='', file=sys.stderr)
|
||||||
|
return res.stdout.decode('utf8')
|
||||||
|
|
||||||
|
async def get_sso_token_async(iam: Optional[str]=None, ua: Optional[str]=None, certificate: Optional[str]=None, issuer: Optional[str]=None, hostname: Optional[str]=None, application: Optional[str]=None) -> str:
|
||||||
|
import sys, asyncio
|
||||||
|
if certificate is None:
|
||||||
|
certificate = get_certificate_thumbprint(get_vista_certificate()).hex()
|
||||||
|
res = await (await asyncio.create_subprocess_exec('curl', '-fsSL', '-X', 'POST', iam or get_registry_iam(), '--ca-native', '--cert', 'CurrentUser\\MY\\' + certificate, '-A', ua or DEFAULT_USER_AGENT, '-H', 'Content-Type: application/xml', '-H', 'Accept: application/xml', '-d', get_iam_request(f"https://{hostname or get_local_computer_name()}/Delphi_RPC_Broker/{application or get_app_name()}", issuer or DEFAULT_ISSUER), stdin=None, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)).communicate()
|
||||||
|
print(res[1].decode('utf8'), end='', file=sys.stderr)
|
||||||
|
return res[0].decode('utf8')
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
try:
|
||||||
|
print(get_sso_token())
|
||||||
|
except OSError:
|
||||||
|
exit(1)
|
17
auth.py
17
auth.py
@ -1,17 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
|
|
||||||
import ctypes
|
|
||||||
|
|
||||||
# Load DLL
|
|
||||||
XUIAMSSOi = ctypes.WinDLL('C:\\Program Files (x86)\\Micro Focus\\Reflection\\XUIAMSSOi.dll')
|
|
||||||
XUIAMSSOi.MySsoTokenVBA.restype = ctypes.c_long
|
|
||||||
XUIAMSSOi.MySsoTokenVBA.argtypes = (ctypes.c_wchar_p, ctypes.c_long)
|
|
||||||
|
|
||||||
# Authenticate against smartcard
|
|
||||||
def XUIAMSSOi_MySsoTokenVBA(bufsize=15000):
|
|
||||||
buf = ctypes.create_unicode_buffer(bufsize)
|
|
||||||
sz = XUIAMSSOi.MySsoTokenVBA(buf, bufsize)
|
|
||||||
if sz <= bufsize:
|
|
||||||
return buf.value.encode('utf-16')[2:].decode('latin-1')
|
|
||||||
else:
|
|
||||||
return XUIAMSSOi_MySsoTokenVBA(sz)
|
|
@ -1,51 +1,117 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="container-fluid">
|
<Submenu :value="menu" />
|
||||||
<Navbar :user="user" />
|
<div class="container-fluid" style="padding-top: 5rem;">
|
||||||
<div class="container">
|
<Navbar v-model:server="server" :user="user" />
|
||||||
<router-view v-if="user"></router-view>
|
<Throbber :client="client" />
|
||||||
<Login :secret="secret" v-model:client="client" v-model:user="user" />
|
<div :class="localstate.fullwidth ? 'container-fluid' : 'container'">
|
||||||
|
<Login :secret="secret" v-model:client="client" v-model:server="server" v-model:user="user" />
|
||||||
|
<router-view v-if="user" :client="client"></router-view>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="control-fullwidth btn btn-light" @click="localstate.fullwidth = !localstate.fullwidth"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi" viewBox="0 0 16 16"><path v-if="localstate.fullwidth" d="M5.5 0a.5.5 0 0 1 .5.5v4A1.5 1.5 0 0 1 4.5 6h-4a.5.5 0 0 1 0-1h4a.5.5 0 0 0 .5-.5v-4a.5.5 0 0 1 .5-.5zm5 0a.5.5 0 0 1 .5.5v4a.5.5 0 0 0 .5.5h4a.5.5 0 0 1 0 1h-4A1.5 1.5 0 0 1 10 4.5v-4a.5.5 0 0 1 .5-.5zM0 10.5a.5.5 0 0 1 .5-.5h4A1.5 1.5 0 0 1 6 11.5v4a.5.5 0 0 1-1 0v-4a.5.5 0 0 0-.5-.5h-4a.5.5 0 0 1-.5-.5zm10 1a1.5 1.5 0 0 1 1.5-1.5h4a.5.5 0 0 1 0 1h-4a.5.5 0 0 0-.5.5v4a.5.5 0 0 1-1 0v-4z" /><path v-else d="M1.5 1a.5.5 0 0 0-.5.5v4a.5.5 0 0 1-1 0v-4A1.5 1.5 0 0 1 1.5 0h4a.5.5 0 0 1 0 1h-4zM10 .5a.5.5 0 0 1 .5-.5h4A1.5 1.5 0 0 1 16 1.5v4a.5.5 0 0 1-1 0v-4a.5.5 0 0 0-.5-.5h-4a.5.5 0 0 1-.5-.5zM.5 10a.5.5 0 0 1 .5.5v4a.5.5 0 0 0 .5.5h4a.5.5 0 0 1 0 1h-4A1.5 1.5 0 0 1 0 14.5v-4a.5.5 0 0 1 .5-.5zm15 0a.5.5 0 0 1 .5.5v4a1.5 1.5 0 0 1-1.5 1.5h-4a.5.5 0 0 1 0-1h4a.5.5 0 0 0 .5-.5v-4a.5.5 0 0 1 .5-.5z" /></svg></button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bi {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: -.125em;
|
||||||
|
fill: currentcolor;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
button.control-fullwidth {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
@media (min-width: 576px) {
|
||||||
|
button.control-fullwidth {
|
||||||
|
display: inline-block;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import { localstate } from './vistax.mjs';
|
||||||
|
|
||||||
|
import Submenu from './Submenu.vue';
|
||||||
import Navbar from './Navbar.vue';
|
import Navbar from './Navbar.vue';
|
||||||
|
import Throbber from './Throbber.vue';
|
||||||
import Login from './Login.vue';
|
import Login from './Login.vue';
|
||||||
import RouteSchedule from './RouteSchedule.vue';
|
import RouteSchedule from './RouteSchedule.vue';
|
||||||
import RoutePatientLookup from './RoutePatientLookup.vue';
|
import RouteLookup from './RouteLookup.vue';
|
||||||
|
import RoutePatient from './RoutePatient.vue';
|
||||||
import RoutePatientDetail from './RoutePatientDetail.vue';
|
import RoutePatientDetail from './RoutePatientDetail.vue';
|
||||||
|
import RoutePatientVisits from './RoutePatientVisits.vue';
|
||||||
|
import RoutePatientOrders from './RoutePatientOrders.vue';
|
||||||
|
import RoutePatientReports from './RoutePatientReports.vue';
|
||||||
|
import RoutePatientDocuments from './RoutePatientDocuments.vue';
|
||||||
|
import RoutePatientConsults from './RoutePatientConsults.vue';
|
||||||
|
import RoutePatientImaging from './RoutePatientImaging.vue';
|
||||||
|
import RoutePlanner from './RoutePlanner.vue';
|
||||||
|
import RouteRecall from './RouteRecall.vue';
|
||||||
|
import RouteInbox from './RouteInbox.vue';
|
||||||
|
import RouteSettings from './RouteSettings.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
Navbar, Login
|
Submenu, Navbar, Throbber, Login
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
secret: String
|
secret: String
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
localstate,
|
||||||
client: null,
|
client: null,
|
||||||
|
server: null,
|
||||||
user: null,
|
user: null,
|
||||||
heartbeat: null,
|
heartbeat: null,
|
||||||
banner: '',
|
banner: '',
|
||||||
authenticated: false
|
authenticated: false,
|
||||||
|
menu: {
|
||||||
|
name: 'Main',
|
||||||
|
items: [
|
||||||
|
{ name: 'Schedule', href: '/' },
|
||||||
|
{ name: 'Lookup', href: '/lookup' },
|
||||||
|
{ name: 'Planner', href: '/planner' },
|
||||||
|
{ name: 'Recall', href: '/recall' },
|
||||||
|
{ name: 'Inbox', href: '/inbox' },
|
||||||
|
{ name: 'Settings', href: '/settings' },
|
||||||
|
]
|
||||||
|
}
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
|
||||||
store: () => store
|
|
||||||
},
|
|
||||||
watch: {
|
watch: {
|
||||||
async client(value) {
|
async client(value, oldvalue) {
|
||||||
if(this.heartbeat) window.clearInterval(this.heartbeat);
|
if(this.heartbeat) window.clearInterval(this.heartbeat);
|
||||||
else {
|
else {
|
||||||
[
|
[
|
||||||
{ path: '/', component: RouteSchedule, props: { client: this.client } },
|
{ path: '/', component: RouteSchedule },
|
||||||
{ path: '/patient', component: RoutePatientLookup, props: { client: this.client } },
|
{ path: '/schedule/:from?/:to?', component: RouteSchedule },
|
||||||
{ path: '/patient/:id', component: RoutePatientDetail, props: { client: this.client } },
|
{ path: '/lookup', component: RouteLookup },
|
||||||
|
{ path: '/patient/:id', component: RoutePatient, children: [
|
||||||
|
{ path: '', component: RoutePatientDetail },
|
||||||
|
{ path: 'visits', component: RoutePatientVisits },
|
||||||
|
{ path: 'orders', component: RoutePatientOrders },
|
||||||
|
{ path: 'reports', component: RoutePatientReports },
|
||||||
|
{ path: 'document', component: RoutePatientDocuments },
|
||||||
|
{ path: 'document/:tiu_da', component: RoutePatientDocuments },
|
||||||
|
{ path: 'consult', component: RoutePatientConsults },
|
||||||
|
{ path: 'consult/:ien', component: RoutePatientConsults },
|
||||||
|
{ path: 'imaging', component: RoutePatientImaging },
|
||||||
|
{ path: 'imaging/:ien', component: RoutePatientImaging },
|
||||||
|
] },
|
||||||
|
{ path: '/planner', component: RoutePlanner },
|
||||||
|
{ path: '/recall', component: RouteRecall },
|
||||||
|
{ path: '/inbox', component: RouteInbox },
|
||||||
|
{ path: '/settings', component: RouteSettings },
|
||||||
].forEach(route => this.$root.$router.addRoute(route));
|
].forEach(route => this.$root.$router.addRoute(route));
|
||||||
await this.$root.$router.replace(this.$route);
|
await this.$root.$router.replace(this.$route);
|
||||||
}
|
}
|
||||||
this.heartbeat = await value.heartbeat();
|
if(value) this.heartbeat = await value.heartbeat();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -1,44 +1,36 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="autocomplete">
|
<div class="dropdown" :class="{ 'form-floating': label }">
|
||||||
<input type="text" @input="option_open" v-model="xvalue" @keydown.down="option_down" @keydown.up="option_up" @keydown.enter="option_enter" />
|
<input type="text" class="form-control" placeholder=" " @focus="option_open" @input="option_filter" v-model="x_modelValue" @keydown.down="option_down" @keydown.up="option_up" @keydown.enter="option_enter" />
|
||||||
<ul id="autocomplete-results" v-show="open" class="autocomplete-results">
|
<ul class="dropdown-menu shadow" :class="{ show: (open) && (results.length > 0) }">
|
||||||
<li class="loading" v-if="!items">Loading results...</li>
|
<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>
|
<li v-else v-for="(result, i) in results" :key="i" @click="option_click(result)" class="dropdown-item" :class="{ 'is-active': i === index }">{{ result }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<label v-if="label">{{label}}</label>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.autocomplete {
|
.dropdown-menu {
|
||||||
position: relative;
|
width: 100%;
|
||||||
}
|
max-height: 10rem;
|
||||||
|
|
||||||
.autocomplete-results {
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
border: 1px solid #eeeeee;
|
|
||||||
height: 120px;
|
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.autocomplete-result {
|
.dropdown-item {
|
||||||
list-style: none;
|
cursor: default;
|
||||||
text-align: left;
|
|
||||||
padding: 4px 2px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.autocomplete-result.is-active,
|
.dropdown-item.is-active,
|
||||||
.autocomplete-result:hover {
|
.dropdown-item:hover {
|
||||||
background-color: #4AAE9B;
|
background-color: var(--bs-primary);
|
||||||
color: white;
|
color: var(--bs-body-bg);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
value: {
|
modelValue: {
|
||||||
type: String,
|
type: String,
|
||||||
default: ''
|
default: ''
|
||||||
},
|
},
|
||||||
@ -46,26 +38,30 @@
|
|||||||
type: Array,
|
type: Array,
|
||||||
required: false,
|
required: false,
|
||||||
default: () => [],
|
default: () => [],
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
label: String
|
||||||
|
},
|
||||||
|
emits: [
|
||||||
|
'update:modelValue'
|
||||||
|
],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
xvalue: '',
|
x_modelValue: this.modelValue,
|
||||||
results: [],
|
results: [],
|
||||||
open: false,
|
open: false,
|
||||||
index: -1,
|
index: -1,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
value(val) {
|
modelValue(val) {
|
||||||
this.xvalue = val;
|
this.x_modelValue = val;
|
||||||
},
|
},
|
||||||
xvalue(val) {
|
x_modelValue(val) {
|
||||||
this.$emit('update:value', val);
|
this.$emit('update:modelValue', val);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.xvalue = this.value;
|
this.x_modelValue = this.modelValue;
|
||||||
document.addEventListener('click', this.option_close)
|
document.addEventListener('click', this.option_close)
|
||||||
},
|
},
|
||||||
destroyed() {
|
destroyed() {
|
||||||
@ -74,7 +70,16 @@
|
|||||||
methods: {
|
methods: {
|
||||||
option_open() {
|
option_open() {
|
||||||
if(this.items) {
|
if(this.items) {
|
||||||
this.results = this.items.filter((item) => item.toLowerCase().indexOf(this.xvalue.toLowerCase()) > -1);
|
this.results = this.items;
|
||||||
|
this.open = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
option_filter() {
|
||||||
|
if(this.items) {
|
||||||
|
if(this.x_modelValue) {
|
||||||
|
var selection = this.x_modelValue.toLowerCase();
|
||||||
|
this.results = this.items.filter((item) => item.toLowerCase().indexOf(selection) >= 0);
|
||||||
|
} else this.results = this.items;
|
||||||
this.open = true;
|
this.open = true;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -85,12 +90,12 @@
|
|||||||
if(this.index > 0) this.index--;
|
if(this.index > 0) this.index--;
|
||||||
},
|
},
|
||||||
option_enter() {
|
option_enter() {
|
||||||
this.xvalue = this.results[this.index];
|
this.x_modelValue = this.results[this.index];
|
||||||
this.open = false;
|
this.open = false;
|
||||||
this.index = -1;
|
this.index = -1;
|
||||||
},
|
},
|
||||||
option_click(result) {
|
option_click(result) {
|
||||||
this.xvalue = result;
|
this.x_modelValue = result;
|
||||||
this.open = false;
|
this.open = false;
|
||||||
},
|
},
|
||||||
option_close(evt) {
|
option_close(evt) {
|
||||||
|
@ -37,8 +37,9 @@
|
|||||||
<button type="button" class="btn btn-sm btn-primary" @click="x_date = timeshift_month(x_date, 24*(reversed ? -1 : +1))">🡢</button>
|
<button type="button" class="btn btn-sm btn-primary" @click="x_date = timeshift_month(x_date, 24*(reversed ? -1 : +1))">🡢</button>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="x_range == 'Range'">
|
<template v-if="x_range == 'Range'">
|
||||||
|
<input type="date" class="form-control" v-model="disp_date_end" v-if="direction < 0" />
|
||||||
<input type="date" class="form-control" v-model="disp_date" />
|
<input type="date" class="form-control" v-model="disp_date" />
|
||||||
<input type="date" class="form-control" v-model="disp_date_end" />
|
<input type="date" class="form-control" v-model="disp_date_end" v-if="direction >= 0" />
|
||||||
<DateRangePickerRange v-model="x_range" :direction="direction" />
|
<DateRangePickerRange v-model="x_range" :direction="direction" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@ -52,8 +53,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function timeshift_month(date, diff) {
|
function timeshift_month(date, diff) {
|
||||||
var month = date.getMonth() + diff;
|
var month = date.getMonth() + diff, month_mod12 = month%12;
|
||||||
return new Date(date.getFullYear() + Math.floor(month/12), month >= 0 ? (month%12) : (month%12 + 12), date.getDate());
|
return new Date(date.getFullYear() + Math.floor(month/12), month_mod12 >= 0 ? (month_mod12) : (month_mod12 + 12), date.getDate());
|
||||||
}
|
}
|
||||||
|
|
||||||
function datecalc(date, range, direction) {
|
function datecalc(date, range, direction) {
|
||||||
@ -103,26 +104,31 @@
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
x_range: this.range,
|
x_range: this.range,
|
||||||
x_date: this.date
|
x_date: this.date,
|
||||||
|
x_date_end: this.date_end
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
disp_date: {
|
disp_date: {
|
||||||
get() {
|
get() {
|
||||||
return this.x_date.toLocaleDateString('en-CA');
|
return this.x_date.toLocaleDateString('sv-SE');
|
||||||
},
|
},
|
||||||
set(value) {
|
set(value) {
|
||||||
value = value.split('-')
|
if(value.length > 0) {
|
||||||
this.x_date = new Date(value[0], value[1] - 1, value[2]);
|
value = value.split('-');
|
||||||
|
if(value[0] >= 1700) this.x_date = new Date(value[0], value[1] - 1, value[2]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
disp_date_end: {
|
disp_date_end: {
|
||||||
get() {
|
get() {
|
||||||
return this.x_date_end.toLocaleDateString('en-CA');
|
return this.x_date_end.toLocaleDateString('sv-SE');
|
||||||
},
|
},
|
||||||
set(value) {
|
set(value) {
|
||||||
value = value.split('-')
|
if(value.length > 0) {
|
||||||
this.x_date_end = new Date(value[0], value[1] - 1, value[2]);
|
value = value.split('-');
|
||||||
|
if(value[0] >= 1700) this.x_date_end = new Date(value[0], value[1] - 1, value[2]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
params() {
|
params() {
|
||||||
@ -141,6 +147,8 @@
|
|||||||
},
|
},
|
||||||
date(value) { this.x_date = value; },
|
date(value) { this.x_date = value; },
|
||||||
x_date(value) { this.$emit('update:date', value); },
|
x_date(value) { this.$emit('update:date', value); },
|
||||||
|
date_end(value) { this.x_date_end = value; },
|
||||||
|
x_date_end(value) { this.$emit('update:date_end', value); },
|
||||||
range(value) { this.x_range = value; },
|
range(value) { this.x_range = value; },
|
||||||
x_range(value) { this.$emit('update:range', value); }
|
x_range(value) { this.$emit('update:range', value); }
|
||||||
},
|
},
|
||||||
@ -148,7 +156,7 @@
|
|||||||
timeshift, timeshift_month
|
timeshift, timeshift_month
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.$emit('update:date_end', this.x_date_end = datecalc(this.x_date, this.x_range, this.direction));
|
if(this.x_range != 'Range') this.$emit('update:date_end', this.x_date_end = datecalc(this.x_date, this.x_range, this.direction));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
84
htdocs/DateTimePicker.vue
Normal file
84
htdocs/DateTimePicker.vue
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="structured" class="input-group">
|
||||||
|
<input type="date" class="form-control" placeholder="Date" v-model="datestr" />
|
||||||
|
<input v-if="!dateonly" type="time" class="form-control" placeholder="Time" v-model="timestr" />
|
||||||
|
<div class="input-group-append">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" @click="structured = false">✏</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="input-group">
|
||||||
|
<input type="text" class="form-control" placeholder="Date & Time" v-model="x_modelValue" />
|
||||||
|
<div class="input-group-append">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" @click="structured = true;">📅</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { validtime, strptime } from './fmdatetime.mjs';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
dateonly: Boolean,
|
||||||
|
modelValue: String
|
||||||
|
},
|
||||||
|
emits: [
|
||||||
|
'update:modelValue'
|
||||||
|
],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
structured: this.modelValue ? /^(\d{4})-(\d{1,2})-(\d{1,2})(?: (\d{1,2})\:(\d{1,2})(?:\:(\d{1,2}))?)?$/i.test(this.modelValue) : true,
|
||||||
|
x_modelValue: this.modelValue
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
datetime: {
|
||||||
|
get() {
|
||||||
|
var m = /^(\d{4})-(\d{1,2})-(\d{1,2})(?: (\d{1,2})\:(\d{1,2})(?:\:(\d{1,2}))?)?$/i.exec(this.x_modelValue);
|
||||||
|
return m ? new Date(m[1], m[2] - 1, m[3], m[4], m[5], m[6]) : null;
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
this.x_modelValue = ('0000' + value.getFullYear()).slice(-4) + '-' + ('00' + (value.getMonth() + 1)).slice(-2) + '-' + ('00' + value.getDate()).slice(-2) + ' ' + ('00' + value.getHours()).slice(-2) + ':' + ('00' + value.getMinutes()).slice(-2) + ':' + ('00' + value.getSeconds()).slice(-2);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
datestr: {
|
||||||
|
get() {
|
||||||
|
var res;
|
||||||
|
return this.datetime ? (res = new Date(this.datetime), res.setHours(0, 0, 0, 0), res.toLocaleDateString('sv-SE')) : null;
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
var m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
||||||
|
if(m) {
|
||||||
|
var res = this.datetime ? new Date(this.datetime) : new Date();
|
||||||
|
res.setFullYear(m[1], m[2] - 1, m[3]);
|
||||||
|
this.datetime = res;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
timestr: {
|
||||||
|
get() {
|
||||||
|
var res;
|
||||||
|
return this.datetime ? (res = new Date(this.datetime), res.setFullYear(0, 0, 0), res.toLocaleTimeString('en-GB')) : null;
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
var m = /^(\d{2})\:(\d{2})(?:\:(\d{2}))?$/.exec(value);
|
||||||
|
if(m) {
|
||||||
|
var res = this.datetime ? new Date(this.datetime) : new Date();
|
||||||
|
res.setHours(m[1], m[2], m[3] || 0);
|
||||||
|
this.datetime = res;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
modelValue(value) { this.x_modelValue = value; },
|
||||||
|
x_modelValue(value) { if(validtime(value)) this.$emit('update:modelValue', value); },
|
||||||
|
structured(value) {
|
||||||
|
if(value) this.datetime = strptime(this.x_modelValue);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
strptime
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
140
htdocs/Login.vue
140
htdocs/Login.vue
@ -1,25 +1,62 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="card mb-3 shadow">
|
<div class="accordion mb-3 shadow">
|
||||||
<div class="card-header"><template v-if="user">{{user[2]}}</template><template v-else>Login</template></div>
|
<div class="accordion-item">
|
||||||
|
<h2 class="accordion-header"><button class="accordion-button" :class="{ testing: (x_server) && (x_server.production) && (x_server.production != '1') }" type="button" @click="() => show = !show"><template v-if="user">{{user[2]}}<template v-if="server"> @ {{server.domain}}</template></template><template v-else>Login</template></button></h2>
|
||||||
|
<div class="accordion-collapse collapse" :class="{ show: (show) || (!x_client) }">
|
||||||
|
<div class="accordion-body" v-if="!x_client"><button v-if="fail" class="btn btn-primary btn-lg" style="width: 100%;" @click="connect">Reconnect</button><button v-else class="btn btn-outline-primary btn-lg" style="width: 100%;" @click="connect">Connect</button></div>
|
||||||
|
<div class="accordion-body" v-else>
|
||||||
|
<div class="card">
|
||||||
<div class="card-body">
|
<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>
|
<p class="card-text row"><code class="col" v-if="banner"><pre>{{banner.join('\n')}}</pre></code><code class="col" v-if="authinfo"><pre v-if="(authinfo.greeting) && (authinfo.greeting.length > 0)">{{authinfo.greeting.join('\n').replace(/^\n+|\s+$/gm, '')}}</pre><pre v-if="user">{{user.join('\n')}}</pre></code></p>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="btn btn-danger" style="width: 100%;" type="button" v-if="user" v-on:click="logout">Logout</button>
|
||||||
<div class="input-group flex-nowrap" v-if="!user">
|
<div class="input-group flex-nowrap" v-if="!user">
|
||||||
<span class="input-group-text">🔑</span>
|
<span class="input-group-text">🔑</span>
|
||||||
|
<select class="form-control" v-model="host">
|
||||||
|
<option value="vista.bronx.med.va.gov:19201">Bronx BRX</option>
|
||||||
|
<option value="test.bronx.med.va.gov:19001">Bronx BRX-TEST</option>
|
||||||
|
<option value="vista.east-orange.med.va.gov:19203">East Orange NJH</option>
|
||||||
|
<option value="test.east-orange.med.va.gov:19003">East Orange NJH-TEST</option>
|
||||||
|
<option value="vista.hudson-valley.med.va.gov:19205">Hudson Valley NVH</option>
|
||||||
|
<option value="test.hudson-valley.med.va.gov:19005">Hudson Valley NVH-TEST</option>
|
||||||
|
<option value="vista.brooklyn.med.va.gov:19208">NY Harbor NYH</option>
|
||||||
|
<option value="test.brooklyn.med.va.gov:19008">NY Harbor NYH-TEST</option>
|
||||||
|
<option value="vista.northport.med.va.gov:19209">Northport NOP</option>
|
||||||
|
<option value="test.northport.med.va.gov:19009">Northport NOP-TEST</option>
|
||||||
|
<option value="vista.v02.med.va.gov:19224">VISN 2 V02</option>
|
||||||
|
<option value="test.v02.med.va.gov:19024">VISN 2 V02-TEST</option>
|
||||||
|
</select>
|
||||||
<input type="password" class="form-control" placeholder="Access Code" v-model="accesscode" />
|
<input type="password" class="form-control" placeholder="Access Code" v-model="accesscode" />
|
||||||
<input type="password" class="form-control" placeholder="Verify Code" v-model="verifycode" />
|
<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>
|
<button class="btn btn-primary" type="button" v-on:click="login">Login<template v-if="!(accesscode || verifycode)"> (omit AV codes for SAML)</template></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.accordion-button.testing:not(.collapsed) {
|
||||||
|
color: #e40c63;
|
||||||
|
background-color: #ffe7f1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import { watchEffect } from 'vue';
|
||||||
|
|
||||||
import vistax from './vistax.mjs';
|
import vistax from './vistax.mjs';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
secret: String,
|
secret: String,
|
||||||
client: Object,
|
client: Object,
|
||||||
|
server: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
user: {
|
user: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: null
|
default: null
|
||||||
@ -27,6 +64,10 @@
|
|||||||
},
|
},
|
||||||
emits: {
|
emits: {
|
||||||
'update:client': Object,
|
'update:client': Object,
|
||||||
|
'update:server': {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
'update:user': {
|
'update:user': {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: null
|
default: null
|
||||||
@ -34,41 +75,104 @@
|
|||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
show: false,
|
||||||
|
fail: false,
|
||||||
x_client: this.client,
|
x_client: this.client,
|
||||||
|
x_server: this.server,
|
||||||
x_user: this.user,
|
x_user: this.user,
|
||||||
banner: null,
|
banner: null,
|
||||||
|
authinfo: null,
|
||||||
accesscode: null,
|
accesscode: null,
|
||||||
verifycode: null
|
verifycode: null
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
watch: {
|
computed: {
|
||||||
client(value) { this.x_client = value; },
|
host: {
|
||||||
x_client(value) { this.$emit('update:client', value); },
|
get() {
|
||||||
user(value) { this.x_user = value; },
|
return vistax.localstate.host;
|
||||||
x_user(value) { this.$emit('update:user', value); }
|
|
||||||
},
|
},
|
||||||
async mounted() {
|
set(value) {
|
||||||
this.x_client = await vistax.Client.fromCookie(this.secret);
|
vistax.localstate.host = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
host: {
|
||||||
|
handler(value) {
|
||||||
|
this.connect();
|
||||||
|
}, immediate: true
|
||||||
|
},
|
||||||
|
client(value) { this.x_client = value; },
|
||||||
|
x_client(value) {
|
||||||
|
this.$emit('update:client', value);
|
||||||
|
window.client = value;
|
||||||
|
window.call = value.call;
|
||||||
|
window.callctx = value.callctx;
|
||||||
|
},
|
||||||
|
server(value) { this.x_server = value; },
|
||||||
|
x_server(value) { this.$emit('update:server', value); },
|
||||||
|
user(value) { this.x_user = value; },
|
||||||
|
async x_user(value) {
|
||||||
|
if(value) await this.x_client.setup_remotestate();
|
||||||
|
this.$emit('update:user', value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async connect() {
|
||||||
|
this.logout();
|
||||||
|
if(this.x_client = await (this.host ? vistax.Client.fromCookie(this.secret, this.host) : vistax.Client.fromCookie(this.secret))) {
|
||||||
this.banner = await this.x_client.XUS_INTRO_MSG();
|
this.banner = await this.x_client.XUS_INTRO_MSG();
|
||||||
if((await this.x_client.userinfo()).result) try {
|
if((this.authinfo = await this.x_client.authinfo()).success) try {
|
||||||
var user = await this.x_client.XUS_GET_USER_INFO();
|
var user = await this.x_client.XUS_GET_USER_INFO();
|
||||||
this.x_user = user[0] ? user : null
|
this.x_user = user[0] ? user : null
|
||||||
} catch(ex) {
|
} catch(ex) {
|
||||||
this.x_user = null;
|
this.x_user = null;
|
||||||
}
|
}
|
||||||
this.$emit('update:user', this.x_user);
|
this.$emit('update:user', this.x_user);
|
||||||
|
this.show = !this.x_user;
|
||||||
|
this.$emit('update:server', this.x_server = (await this.x_client.serverinfo()).result);
|
||||||
console.log('Backend secret', this.secret);
|
console.log('Backend secret', this.secret);
|
||||||
console.log(this.banner);
|
console.log(this.banner);
|
||||||
|
var stop = watchEffect(() => { if(!this.x_client.status.connected) { stop(); this.x_client = this.x_server = this.x_user = null; this.fail = true; } });
|
||||||
|
} else {
|
||||||
|
this.fail = true;
|
||||||
|
this.host = undefined;
|
||||||
|
this.authinfo = null;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
async login(evt) {
|
||||||
async submit(evt) {
|
if(this.x_client) {
|
||||||
var res = await ((this.accesscode && this.verifycode) ? this.x_client.authenticate(this.accesscode + ';' + this.verifycode) : this.x_client.authenticate());
|
try {
|
||||||
if(!!res.result[0]) {
|
this.authinfo = await ((this.accesscode && this.verifycode) ? this.x_client.authenticate(this.accesscode + ';' + this.verifycode) : this.x_client.authenticate());
|
||||||
|
if(this.authinfo.duz) {
|
||||||
var user = await this.x_client.XUS_GET_USER_INFO();
|
var user = await this.x_client.XUS_GET_USER_INFO();
|
||||||
this.x_user = user[0] ? user : null
|
this.x_user = user[0] ? user : null
|
||||||
} else this.x_user = null;
|
} else {
|
||||||
|
this.x_user = null;
|
||||||
|
if(this.authinfo.message) window.alert(this.authinfo.message);
|
||||||
|
else window.alert('Authentication failed.');
|
||||||
|
}
|
||||||
|
} catch(ex) {
|
||||||
|
this.authinfo = this.x_user = null;
|
||||||
|
console.warn(ex);
|
||||||
|
if(ex.name) window.alert(ex.name);
|
||||||
|
else window.alert('Authentication failed.');
|
||||||
|
}
|
||||||
this.$emit('update:user', this.x_user);
|
this.$emit('update:user', this.x_user);
|
||||||
console.log('Authenticate', res);
|
this.show = !this.x_user;
|
||||||
|
this.$emit('update:server', this.x_server = (await this.x_client.serverinfo()).result);
|
||||||
|
console.log('Authenticate', this.authinfo);
|
||||||
|
} else this.authinfo = null;
|
||||||
|
},
|
||||||
|
async logout(evt) {
|
||||||
|
if(this.x_client) {
|
||||||
|
console.log('Close', await this.x_client.close());
|
||||||
|
this.$emit('update:client', this.x_client = null);
|
||||||
|
this.$emit('update:server', this.x_server = null);
|
||||||
|
this.$emit('update:user', this.x_user = null);
|
||||||
|
}
|
||||||
|
this.banner = null;
|
||||||
|
this.show = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
84
htdocs/ModalPromptSignatureCode.vue
Normal file
84
htdocs/ModalPromptSignatureCode.vue
Normal 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>
|
@ -1,43 +1,57 @@
|
|||||||
<template>
|
<template>
|
||||||
<nav class="navbar navbar-expand-lg bg-dark">
|
<nav class="navbar navbar-expand-lg fixed-top navbar-dark bg-dark">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<a class="navbar-brand" href="/"><template v-if="user">{{user[2]}}</template><template v-else>nuVistA</template></a>
|
<router-link class="navbar-brand" to="/"><img src="/icon.svg" style="height: 1.875rem;" /></router-link>
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
<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>
|
<span class="navbar-toggler-icon"></span>
|
||||||
</button>
|
</button>
|
||||||
<div class="collapse navbar-collapse" id="navbarSupportedContent">
|
<div class="collapse navbar-collapse" id="navbarSupportedContent">
|
||||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||||
<li class="nav-item">
|
<li v-if="menustate.length > 0" v-for="item in menustate[0].items" class="nav-item">
|
||||||
<a class="nav-link" href="/">Schedule</a>
|
<router-link class="nav-link" :to="item.href">{{item.name}}</router-link>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li v-if="menustate.length > 1" v-for="menu in menustate.slice(1)" class="nav-item dropdown">
|
||||||
<a class="nav-link" href="/patient">Patient</a>
|
<button class="nav-link btn dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">{{menu.name}}</button>
|
||||||
</li>
|
<ul class="dropdown-menu dropdown-menu-dark">
|
||||||
<li class="nav-item" v-if="user">
|
<li v-for="item in menu.items" class="nav-item">
|
||||||
<a class="nav-link disabled">{{user[3]}}</a>
|
<router-link class="nav-link" :to="item.href">{{item.name}}</router-link>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<form class="d-flex" role="search">
|
</li>
|
||||||
<input class="form-control me-2" type="search" placeholder="Search" aria-label="Search">
|
</ul>
|
||||||
<button class="btn btn-outline-success" type="submit">Search</button>
|
<div class="navbar-nav" v-if="server">
|
||||||
</form>
|
<a class="nav-link disabled"><template v-if="user">{{user[2]}} @ </template>{{server.domain}}</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.navbar-nav .nav-link.router-link-exact-active {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import vistax from './vistax.mjs';
|
import vistax from './vistax.mjs';
|
||||||
|
import { menustate } from './common.mjs';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
|
server: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
user: {
|
user: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: null
|
default: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {};
|
return {
|
||||||
|
menustate
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
28
htdocs/OrderFilterPicker.vue
Normal file
28
htdocs/OrderFilterPicker.vue
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<template>
|
||||||
|
<select class="form-select form-select-sm" style="width: auto;" v-model="x_value">
|
||||||
|
<option v-for="item in options" :value="item.ien">{{item.name}}</option>
|
||||||
|
</select>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
client: Object,
|
||||||
|
modelValue: { default: 2 }
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
options: [],
|
||||||
|
x_value: this.modelValue
|
||||||
|
};
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
client: {
|
||||||
|
async handler(value) { if(value) this.options = await value.ORWORDG_REVSTS(); },
|
||||||
|
immediate: true
|
||||||
|
},
|
||||||
|
modelValue(value) { this.x_value = value; },
|
||||||
|
x_value(value) { this.$emit('update:modelValue', value); }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
67
htdocs/RouteInbox.vue
Normal file
67
htdocs/RouteInbox.vue
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
<template>
|
||||||
|
<Subtitle value="Inbox" />
|
||||||
|
<div>
|
||||||
|
<div class="card mb-3 shadow">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span>Alerts</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<table class="table" style="font-family: monospace;" v-if="(resultset) && (resultset.length > 0)">
|
||||||
|
<thead>
|
||||||
|
<tr><th>I</th><th>Patient</th><th>Loc</th><th>Urg</th><th>Time</th><th>Message</th><th>XQAID</th><th>DUZ</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in resultset" :style="{ backgroundColor: strHashHSL(row.patient, '90%') }">
|
||||||
|
<td>{{row.info}}</td>
|
||||||
|
<td>{{row.patient}}</td>
|
||||||
|
<td>{{row.location}}</td>
|
||||||
|
<td>{{urgency[row.urgency]}}</td>
|
||||||
|
<td>{{datefmt(strptime_vista(row.meta_time))}}</td>
|
||||||
|
<td>{{row.message}}</td>
|
||||||
|
<td>{{row.meta_xqaid}}</td>
|
||||||
|
<td>{{row.meta_duz}}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { strHashHSL, strptime_vista } from './util.mjs';
|
||||||
|
|
||||||
|
import Subtitle from './Subtitle.vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
Subtitle
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
client: Object
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
resultset: [],
|
||||||
|
urgency: { 'n/a': 'n/a', 'low': 'L', 'Moderate': 'M', 'HIGH': 'H' }
|
||||||
|
};
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
strHashHSL,
|
||||||
|
strptime_vista,
|
||||||
|
datefmt(date) {
|
||||||
|
return date ? date.toLocaleDateString('sv-SE') + ' ' + date.toLocaleTimeString('en-GB') : '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.$watch(
|
||||||
|
() => this.client,
|
||||||
|
async () => {
|
||||||
|
if(this.client) this.resultset = await this.client.ORWORB_FASTUSER();
|
||||||
|
else this.resultset = [];
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
@ -1,4 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<Subtitle value="Lookup" />
|
||||||
<div>
|
<div>
|
||||||
<div class="card mb-3 shadow">
|
<div class="card mb-3 shadow">
|
||||||
<div class="card-header">Patients</div>
|
<div class="card-header">Patients</div>
|
||||||
@ -10,11 +11,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import Subtitle from './Subtitle.vue';
|
||||||
import ViewPatientLookup from './ViewPatientLookup.vue';
|
import ViewPatientLookup from './ViewPatientLookup.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
ViewPatientLookup
|
Subtitle, ViewPatientLookup
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
client: Object
|
client: Object
|
||||||
@ -23,10 +25,6 @@
|
|||||||
return {
|
return {
|
||||||
selection: null
|
selection: null
|
||||||
};
|
};
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
124
htdocs/RoutePatient.vue
Normal file
124
htdocs/RoutePatient.vue
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
<template>
|
||||||
|
<Subtitle value="Patient" />
|
||||||
|
<div v-if="(sensitive) && (!viewsensitive)" class="alert alert-danger text-center mb-3 shadow" role="alert">
|
||||||
|
<Subtitle value="Restricted Record" />
|
||||||
|
<h1>Warning: Restricted Record</h1>
|
||||||
|
<p>This record is protected by the Privacy Act of 1974 and the Health Insurance Portability and Accountability Act of 1996. If you elect to proceed, you will be required to prove you have a need to know. Accessing this patient is tracked, and your station Security Officer will contact you for your justification.</p>
|
||||||
|
<button class="btn btn-danger" @click="viewsensitive = true">Proceed</button>
|
||||||
|
</div>
|
||||||
|
<template v-if="patient_info">
|
||||||
|
<Submenu :value="menu" />
|
||||||
|
<div class="card mb-3 shadow">
|
||||||
|
<div v-if="sensitive" class="card-header alert-danger d-flex justify-content-between align-items-center">
|
||||||
|
<span>{{patient_info.name}} <span :title="patient_info.pid">{{patient_info.pid.slice(-4)}}</span> #{{patient_dfn}}</span>
|
||||||
|
<button class="btn-close" @click="viewsensitive = false"></button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="card-header">{{patient_info.name}} <span :title="patient_info.pid">{{patient_info.pid.slice(-4)}}</span> #{{patient_dfn}}</div>
|
||||||
|
<div class="card-body row" style="font-family: monospace;">
|
||||||
|
<div class="col" v-if="patient_info.dob"><strong>DOB:</strong> {{strptime_vista(patient_info.dob).toLocaleDateString('sv-SE')}}</div>
|
||||||
|
<div class="col" v-if="patient_info.age"><strong>Age:</strong> {{patient_info.age}}</div>
|
||||||
|
<div class="col" v-if="patient_info.sex"><strong>Sex:</strong> {{patient_info.sex}}</div>
|
||||||
|
<div class="col" v-if="patient_info.sc_percentage"><strong>SC%:</strong> {{patient_info.sc_percentage}}</div>
|
||||||
|
<div class="col" v-if="patient_info.type"><strong>Type:</strong> {{patient_info.type}}</div>
|
||||||
|
<div class="col" v-if="patient_info.ward"><strong>Ward:</strong> {{patient_info.ward}}</div>
|
||||||
|
<div class="col" v-if="patient_info.room_bed"><strong>Room/bed:</strong> {{patient_info.room_bed}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<router-view :client="client" :sensitive="sensitive" :patient_dfn="patient_dfn" :patient_info="patient_info"></router-view>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import cookie from './cookie.mjs';
|
||||||
|
import { strptime_vista } from './util.mjs';
|
||||||
|
|
||||||
|
import Subtitle from './Subtitle.vue';
|
||||||
|
import Submenu from './Submenu.vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
Subtitle, Submenu
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
client: Object
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
viewsensitive: false,
|
||||||
|
sensitive: false,
|
||||||
|
patient_dfn: null,
|
||||||
|
patient_info: null
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
menu() {
|
||||||
|
return this.patient_info ? {
|
||||||
|
name: this.patient_info.name,
|
||||||
|
items: [
|
||||||
|
{ name: 'Patient', href: '/patient/' + this.patient_dfn },
|
||||||
|
{ name: 'Visits', href: '/patient/' + this.patient_dfn + '/visits' },
|
||||||
|
{ name: 'Orders', href: '/patient/' + this.patient_dfn + '/orders' },
|
||||||
|
{ name: 'Reports', href: '/patient/' + this.patient_dfn + '/reports' },
|
||||||
|
{ name: 'Documents', href: '/patient/' + this.patient_dfn + '/document' },
|
||||||
|
{ name: 'Consults', href: '/patient/' + this.patient_dfn + '/consult' },
|
||||||
|
{ name: 'Imaging', href: '/patient/' + this.patient_dfn + '/imaging' },
|
||||||
|
]
|
||||||
|
} : null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
strptime_vista
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
'$route.params.id': {
|
||||||
|
async handler(value) {
|
||||||
|
if(value.startsWith('$')) {
|
||||||
|
var id = value.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[i].dfn);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.sensitive = await this.client.ORWPT_SELCHK(value);
|
||||||
|
this.patient_dfn = value;
|
||||||
|
var viewsensitive = cookie.get('viewsensitive');
|
||||||
|
this.viewsensitive = viewsensitive ? viewsensitive.split(',').indexOf(value) >= 0 : false;
|
||||||
|
}
|
||||||
|
}, immediate: true
|
||||||
|
},
|
||||||
|
viewsensitive(value) {
|
||||||
|
var viewsensitive = cookie.get('viewsensitive'), viewsensitive = viewsensitive !== null ? viewsensitive.split(',') : [], idx = viewsensitive.indexOf(this.patient_dfn);
|
||||||
|
if(value) {
|
||||||
|
if(idx < 0) {
|
||||||
|
viewsensitive.push(this.patient_dfn);
|
||||||
|
cookie.set('viewsensitive', viewsensitive.join(','));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if(idx >= 0) {
|
||||||
|
viewsensitive.splice(idx, 1);
|
||||||
|
cookie.set('viewsensitive', viewsensitive.join(','));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.$watch(
|
||||||
|
() => (this.client, this.patient_dfn, this.sensitive, this.viewsensitive, {}),
|
||||||
|
async function() {
|
||||||
|
if(this.client) {
|
||||||
|
if(this.patient_dfn) this.patient_info = (this.sensitive) && (!this.viewsensitive) ? null : await this.client.ORWPT16_ID_INFO(this.patient_dfn);
|
||||||
|
else this.patient_info = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
195
htdocs/RoutePatientConsults.vue
Normal file
195
htdocs/RoutePatientConsults.vue
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
<template>
|
||||||
|
<Subtitle value="Consults" />
|
||||||
|
<Subtitle :value="patient_info.name" />
|
||||||
|
<div class="row">
|
||||||
|
<div class="selector col-12" :class="{ 'col-xl-4': selection_text }">
|
||||||
|
<div class="card mb-3 shadow">
|
||||||
|
<div class="card-header">{{resultset.length > 0 ? resultset.length : 'No'}} record{{resultset.length == 1 ? '' : 's'}}</div>
|
||||||
|
<ul class="scroller list-group list-group-flush" :class="{ 'list-skinny': selection_text }" ref="scroller">
|
||||||
|
<router-link v-for="item in resultset" :to="'/patient/' + patient_dfn + '/consult/' + 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)) + ' #' + item.IEN + '\n(' + item.status + ') ' + item.text" @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">
|
||||||
|
<template v-if="item.status == 'p'">⏳</template>
|
||||||
|
<template v-else-if="item.status == 'a'">👍</template>
|
||||||
|
<template v-else-if="item.status == 's'">📆</template>
|
||||||
|
<template v-else-if="item.status == 'c'">✔</template>
|
||||||
|
<template v-else-if="item.status == 'x'">❌</template>
|
||||||
|
<template v-else-if="item.status == 'dc'">🗑</template>
|
||||||
|
<template v-else>({{item.status}})</template>
|
||||||
|
{{item.text}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</router-link>
|
||||||
|
</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) || 'Consult'}} #{{selection}}</span>
|
||||||
|
<router-link class="close" :to="'/patient/' + patient_dfn + '/consult'" replace>❌</router-link>
|
||||||
|
</div>
|
||||||
|
<div class="detail card-body" ref="detail">{{selection_text}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
div.selector {
|
||||||
|
position: sticky;
|
||||||
|
top: 1.15rem;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
ul.scroller.list-skinny {
|
||||||
|
max-height: 25vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
li.record {
|
||||||
|
cursor: default;
|
||||||
|
border-top: 1px solid #dee2e6;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
scroll-margin-top: 3.6875rem;
|
||||||
|
}
|
||||||
|
li.record:nth-child(even) {
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
ul.scroller.list-skinny li.record {
|
||||||
|
scroll-margin-top: 0;
|
||||||
|
}
|
||||||
|
li.record a {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
li.record.active {
|
||||||
|
color: #fff;
|
||||||
|
background-color: #0d6efd;
|
||||||
|
}
|
||||||
|
li.bottom {
|
||||||
|
overflow-anchor: none;
|
||||||
|
}
|
||||||
|
div.cell {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
a.close {
|
||||||
|
cursor: default;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
div.detail {
|
||||||
|
scroll-margin-top: calc(3.6875rem + 2.5625rem + 25vh);
|
||||||
|
font-family: monospace;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
div.selector {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
ul.scroller.list-skinny {
|
||||||
|
max-height: 75vh;
|
||||||
|
}
|
||||||
|
div.detail {
|
||||||
|
max-height: 75vh;
|
||||||
|
scroll-margin-top: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { debounce, strptime_vista } from './util.mjs';
|
||||||
|
|
||||||
|
import Subtitle from './Subtitle.vue';
|
||||||
|
|
||||||
|
const SZ_WINDOW = 100;
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
Subtitle
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
client: Object,
|
||||||
|
sensitive: Boolean,
|
||||||
|
patient_dfn: String,
|
||||||
|
patient_info: Object
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
dfn: null,
|
||||||
|
has_more: '',
|
||||||
|
is_loading: false,
|
||||||
|
resultset: [],
|
||||||
|
selection: null,
|
||||||
|
selection_text: null
|
||||||
|
};
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
'$route.params.ien': {
|
||||||
|
async handler(value) {
|
||||||
|
this.selection = value;
|
||||||
|
}, immediate: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
strptime_vista,
|
||||||
|
datestring(date) {
|
||||||
|
return date.toLocaleDateString('sv-SE');
|
||||||
|
},
|
||||||
|
datetimestring(date) {
|
||||||
|
return date.toLocaleDateString('sv-SE') + ' ' + date.toLocaleTimeString('en-GB');
|
||||||
|
},
|
||||||
|
doctitle(doc) {
|
||||||
|
if(doc) {
|
||||||
|
var m = doc.match(/^Orderable Item:\s*(.*)$/m);
|
||||||
|
if(m) return m[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.$watch(
|
||||||
|
() => (this.client, this.patient_dfn, {}),
|
||||||
|
debounce(async () => {
|
||||||
|
if((this.client) && (this.patient_dfn)) this.resultset = await this.client.ORQQCN_LIST(this.patient_dfn);
|
||||||
|
else this.resultset = [];
|
||||||
|
}, 500),
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
this.$watch(
|
||||||
|
() => (this.client, this.selection, {}),
|
||||||
|
async function() {
|
||||||
|
try {
|
||||||
|
this.selection_text = (this.client) && (this.selection) ? await this.client.ORQQCN_DETAIL(this.selection) : null;
|
||||||
|
} catch(ex) {
|
||||||
|
this.selection_text = null;
|
||||||
|
console.warn(ex);
|
||||||
|
}
|
||||||
|
if(this.$refs.scroller) {
|
||||||
|
if(this.selection_text) { // scroll to selected item
|
||||||
|
await this.$nextTick();
|
||||||
|
var active = this.$refs.scroller.querySelectorAll(':scope > .active');
|
||||||
|
if(active.length > 0) (Element.prototype.scrollIntoViewIfNeeded || Element.prototype.scrollIntoView).call(active[0]);
|
||||||
|
if(this.$refs.detail) { // scroll to top of detail panel
|
||||||
|
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) {
|
||||||
|
await this.$nextTick();
|
||||||
|
var behavior = document.documentElement.style.scrollBehavior;
|
||||||
|
document.documentElement.style.scrollBehavior = 'auto'; // inhibit Bootstrap smooth scrolling
|
||||||
|
children[i].scrollIntoView();
|
||||||
|
document.documentElement.style.scrollBehavior = behavior;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
@ -1,32 +1,19 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="info">
|
<Subtitle value="Detail" />
|
||||||
<div class="card mb-3 shadow">
|
<Subtitle :value="patient_info.name" />
|
||||||
<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 mb-3 shadow">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<span>Vitals</span>
|
<span>Data</span>
|
||||||
<DateRangePicker range="1M" direction="-1" v-model:date="vitals_date" v-model:date_end="vitals_date_begin" />
|
<DateRangePicker range="1M" direction="-1" v-model:date="report_date" v-model:date_end="report_date_begin" />
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<ViewVitalsLabs :client="client" :dfn="$route.params.id" :date_begin="vitals_date_begin" :date_end="vitals_date" />
|
<ViewVitalsLabs :client="client" :dfn="patient_dfn" :date_begin="report_date_begin" :date_end="report_date" />
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { uniq, groupByArray, strptime_vista } from './util.mjs';
|
import Subtitle from './Subtitle.vue';
|
||||||
|
|
||||||
import DateRangePicker from './DateRangePicker.vue';
|
import DateRangePicker from './DateRangePicker.vue';
|
||||||
import ViewVitalsLabs from './ViewVitalsLabs.vue';
|
import ViewVitalsLabs from './ViewVitalsLabs.vue';
|
||||||
|
|
||||||
@ -34,47 +21,22 @@
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
DateRangePicker, ViewVitalsLabs
|
Subtitle, DateRangePicker, ViewVitalsLabs
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
client: Object
|
client: Object,
|
||||||
|
sensitive: Boolean,
|
||||||
|
patient_dfn: String,
|
||||||
|
patient_info: Object
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
info: null,
|
report_date: now,
|
||||||
vitals_date: now,
|
report_date_begin: now,
|
||||||
vitals_date_begin: now,
|
orders_filter: 2,
|
||||||
labs_date: now,
|
orders_date: now,
|
||||||
labs_date_begin: now
|
orders_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>
|
</script>
|
||||||
|
285
htdocs/RoutePatientDocuments.vue
Normal file
285
htdocs/RoutePatientDocuments.vue
Normal file
@ -0,0 +1,285 @@
|
|||||||
|
<template>
|
||||||
|
<Subtitle value="Documents" />
|
||||||
|
<Subtitle :value="patient_info.name" />
|
||||||
|
<div class="row">
|
||||||
|
<div class="selector col-12" :class="{ 'col-xl-4': selection }">
|
||||||
|
<div class="card mb-3 shadow">
|
||||||
|
<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"><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>
|
||||||
|
</li>
|
||||||
|
</router-link>
|
||||||
|
<li class="bottom list-group-item" ref="bottom" />
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
div.selector {
|
||||||
|
position: sticky;
|
||||||
|
top: 1.15rem;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
ul.scroller.list-skinny {
|
||||||
|
max-height: 25vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
li.record {
|
||||||
|
cursor: default;
|
||||||
|
border-top: 1px solid #dee2e6;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
scroll-margin-top: 3.6875rem;
|
||||||
|
}
|
||||||
|
li.record:nth-child(even) {
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
ul.scroller.list-skinny li.record {
|
||||||
|
scroll-margin-top: 0;
|
||||||
|
}
|
||||||
|
li.record a {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
li.record.active {
|
||||||
|
color: #fff;
|
||||||
|
background-color: #0d6efd;
|
||||||
|
}
|
||||||
|
li.bottom {
|
||||||
|
overflow-anchor: none;
|
||||||
|
}
|
||||||
|
div.cell {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
a.close {
|
||||||
|
cursor: default;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
div.detail /deep/ .card-body {
|
||||||
|
scroll-margin-top: calc(3.6875rem + 2.5625rem + 25vh);
|
||||||
|
font-family: monospace;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
div.selector {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
ul.scroller.list-skinny {
|
||||||
|
max-height: 75vh;
|
||||||
|
}
|
||||||
|
div.cell.secondary {
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
div.detail /deep/ .card-body {
|
||||||
|
max-height: 75vh;
|
||||||
|
scroll-margin-top: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
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, ViewDocNew, ViewDocView, ModalPromptSignatureCode
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
client: Object,
|
||||||
|
sensitive: Boolean,
|
||||||
|
patient_dfn: String,
|
||||||
|
patient_info: Object
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
dfn: null,
|
||||||
|
has_more: '',
|
||||||
|
is_loading: false,
|
||||||
|
rs_unsigned: [],
|
||||||
|
rs_signed: [],
|
||||||
|
selection: 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) {
|
||||||
|
this.selection = value;
|
||||||
|
}, immediate: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
strptime_vista,
|
||||||
|
datestring(date) {
|
||||||
|
return date.toLocaleDateString('sv-SE');
|
||||||
|
},
|
||||||
|
datetimestring(date) {
|
||||||
|
return date.toLocaleDateString('sv-SE') + ' ' + date.toLocaleTimeString('en-GB');
|
||||||
|
},
|
||||||
|
async load_more() {
|
||||||
|
try {
|
||||||
|
this.is_loading = true;
|
||||||
|
if((this.client) && (this.patient_dfn)) {
|
||||||
|
if(this.dfn != this.patient_dfn) {
|
||||||
|
this.dfn = this.patient_dfn;
|
||||||
|
this.has_more = '';
|
||||||
|
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)) {
|
||||||
|
res = res.slice();
|
||||||
|
var last = res[res.length - 1];
|
||||||
|
if((last.title == 'SHOW MORE') && (!last.author) && (!last.visit)) {
|
||||||
|
this.has_more = last.IEN;
|
||||||
|
res.splice(res.length - 1, 1);
|
||||||
|
}
|
||||||
|
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.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(); this.load_unsigned(); }, 500),
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
this.$watch(
|
||||||
|
() => (this.client, this.selection, {}),
|
||||||
|
async function() {
|
||||||
|
if(this.$refs.scroller) {
|
||||||
|
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]);
|
||||||
|
if(this.$refs.detail) { // scroll to top of detail panel
|
||||||
|
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) {
|
||||||
|
await this.$nextTick();
|
||||||
|
var behavior = document.documentElement.style.scrollBehavior;
|
||||||
|
document.documentElement.style.scrollBehavior = 'auto'; // inhibit Bootstrap smooth scrolling
|
||||||
|
children[i].scrollIntoView();
|
||||||
|
document.documentElement.style.scrollBehavior = behavior;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
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) && (this.has_more) && (!this.is_loading)) this.load_more(); }, { rootMargin: '25%' });
|
||||||
|
this.observer_viewport.observe(this.$refs.bottom);
|
||||||
|
},
|
||||||
|
destroyed() {
|
||||||
|
if(this.observer_viewport) this.observer_viewport.disconnect();
|
||||||
|
if(this.observer_scroller) this.observer_scroller.disconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
228
htdocs/RoutePatientImaging.vue
Normal file
228
htdocs/RoutePatientImaging.vue
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
<template>
|
||||||
|
<Subtitle value="Imaging" />
|
||||||
|
<Subtitle :value="patient_info.name" />
|
||||||
|
<div class="row">
|
||||||
|
<div class="selector col-12" :class="{ 'col-xl-4': selection_data }">
|
||||||
|
<div class="card mb-3 shadow">
|
||||||
|
<div class="card-header">{{resultset.length > 0 ? resultset.length : 'No'}} record{{resultset.length == 1 ? '' : 's'}}</div>
|
||||||
|
<ul class="scroller list-group list-group-flush" :class="{ 'list-skinny': selection_data }" ref="scroller">
|
||||||
|
<router-link v-for="item in resultset" :to="'/patient/' + patient_dfn + '/imaging/' + item.Info.IEN" replace custom v-slot="{ navigate, href }">
|
||||||
|
<li :key="item" class="record list-group-item" :class="{ 'active': selection == item.Info.IEN }" :title="'Site: ' + item['Site'] + '\nNote Title: ' + item['Note Title~~W0'] + '\nProc DT: ' + item['Proc DT~S1'] + '\nProcedure: ' + item['Procedure'] + '\n# Img: ' + item['# Img~S2'] + '\nShort Desc: ' + item['Short Desc'] + '\nPkg: ' + item['Pkg'] + '\nClass: ' + item['Class'] + '\nType: ' + item['Type'] + '\nSpecialty: ' + item['Specialty'] + '\nOrigin: ' + item['Origin'] + '\nCap Dt: ' + item['Cap Dt~S1~W0'] + '\nCap by: ' + item['Cap by~~W0'] + '\nImage ID: ' + item['Image ID~S2~W0'] + '\nCreation Date: ' + item.Info['Document Date']" @click="navigate">
|
||||||
|
<div class="row">
|
||||||
|
<div class="cell col-4"><router-link :to="href" replace>{{item['Proc DT~S1']}}</router-link></div>
|
||||||
|
<div class="cell col-7">{{doctitledesc(item)}}</div>
|
||||||
|
<div class="cell col-1">#{{item['# Img~S2']}}</div>
|
||||||
|
<div class="cell col-3">{{doctypespec(item)}}</div>
|
||||||
|
<div class="cell col-3">{{docclspkgproc(item)}}</div>
|
||||||
|
<div class="cell col-3">{{item['Site']}} {{item['Origin']}}</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</router-link>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="selection_data" 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>{{doctitledesc(selection_data) || 'Image'}} #{{selection}}</span>
|
||||||
|
<router-link class="close" :to="'/patient/' + patient_dfn + '/imaging'" replace>❌</router-link>
|
||||||
|
</div>
|
||||||
|
<div class="detail card-body" ref="detail">
|
||||||
|
<p v-if="selection_info">{{selection_info}}</p>
|
||||||
|
<ul v-if="(selection_images) && (selection_images.length > 0)" class="list-group list-group-flush">
|
||||||
|
<li v-for="info in selection_images" class="list-group-item"><router-link :to="'/v1/vista/' + client.cid + '/imaging/' + info['Image Path'].replace(/\\/g, '/').replace(/^\/+/, '') + '?view'" target="_blank">{{info['Short Desc']}}</router-link></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
div.selector {
|
||||||
|
position: sticky;
|
||||||
|
top: 1.15rem;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
ul.scroller.list-skinny {
|
||||||
|
max-height: 25vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
li.record {
|
||||||
|
cursor: default;
|
||||||
|
border-top: 1px solid #dee2e6;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
scroll-margin-top: 3.6875rem;
|
||||||
|
}
|
||||||
|
li.record:nth-child(even) {
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
ul.scroller.list-skinny li.record {
|
||||||
|
scroll-margin-top: 0;
|
||||||
|
}
|
||||||
|
li.record a {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
li.record.active {
|
||||||
|
color: #fff;
|
||||||
|
background-color: #0d6efd;
|
||||||
|
}
|
||||||
|
li.bottom {
|
||||||
|
overflow-anchor: none;
|
||||||
|
}
|
||||||
|
div.cell {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
a.close {
|
||||||
|
cursor: default;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
div.detail {
|
||||||
|
scroll-margin-top: calc(3.6875rem + 2.5625rem + 25vh);
|
||||||
|
font-family: monospace;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
div.selector {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
ul.scroller.list-skinny {
|
||||||
|
max-height: 75vh;
|
||||||
|
}
|
||||||
|
div.detail {
|
||||||
|
max-height: 75vh;
|
||||||
|
scroll-margin-top: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { debounce, strptime_vista } from './util.mjs';
|
||||||
|
|
||||||
|
import Subtitle from './Subtitle.vue';
|
||||||
|
|
||||||
|
const SZ_WINDOW = 100;
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
Subtitle
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
client: Object,
|
||||||
|
sensitive: Boolean,
|
||||||
|
patient_dfn: String,
|
||||||
|
patient_info: Object
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
dfn: null,
|
||||||
|
has_more: '',
|
||||||
|
is_loading: false,
|
||||||
|
resultset: [],
|
||||||
|
selection: null,
|
||||||
|
selection_info: null,
|
||||||
|
selection_images: null
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
selection_data() {
|
||||||
|
var selection = this.selection, resultset = this.resultset;
|
||||||
|
if((selection) && (resultset) && (resultset.length > 0)) return resultset.find(item => item['Image ID~S2~W0'] == selection);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
'$route.params.ien': {
|
||||||
|
async handler(value) {
|
||||||
|
this.selection = value;
|
||||||
|
}, immediate: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
strptime_vista,
|
||||||
|
datestring(date) {
|
||||||
|
return date.toLocaleDateString('sv-SE');
|
||||||
|
},
|
||||||
|
datetimestring(date) {
|
||||||
|
return date.toLocaleDateString('sv-SE') + ' ' + date.toLocaleTimeString('en-GB');
|
||||||
|
},
|
||||||
|
docclspkgproc(item) {
|
||||||
|
var res = [], cls = item['Class'], pkg = item['Pkg'], proc = item['Procedure'];
|
||||||
|
if(cls) res.push(cls);
|
||||||
|
if((pkg) && (pkg != 'NONE') && (pkg != cls)) res.push(pkg);
|
||||||
|
if((proc) && (proc != cls) && (proc != pkg)) res.push(proc);
|
||||||
|
return res.join(' • ');
|
||||||
|
},
|
||||||
|
doctypespec(item) {
|
||||||
|
var res = [], type = item['Type'], spec = item['Specialty'];
|
||||||
|
if(type) res.push(type);
|
||||||
|
if(spec) res.push(spec);
|
||||||
|
return res.join(' • ');
|
||||||
|
},
|
||||||
|
doctitledesc(item) {
|
||||||
|
var title = item['Note Title~~W0'].replace(/^\s+|\s+$/g, ''), desc = item['Short Desc'].replace(/^\s+|\s+$/g, '');
|
||||||
|
if(title) {
|
||||||
|
if((desc == title) || (desc == '+' + title)) return title;
|
||||||
|
else return title + ' • ' + desc;
|
||||||
|
} else return desc;
|
||||||
|
},
|
||||||
|
doctitle(doc) {
|
||||||
|
if(doc) {
|
||||||
|
var m = doc.match(/^Orderable Item:\s*(.*)$/m);
|
||||||
|
if(m) return m[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.$watch(
|
||||||
|
() => (this.client, this.patient_dfn, {}),
|
||||||
|
debounce(async () => {
|
||||||
|
if((this.client) && (this.patient_dfn)) this.resultset = await this.client.MAG4_IMAGE_LIST('E', '', '', '', ['IXCLASS^^CLIN^CLIN/ADMIN^ADMIN/CLIN', 'IDFN^^' + this.patient_dfn]);
|
||||||
|
else this.resultset = [];
|
||||||
|
}, 500),
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
this.$watch(
|
||||||
|
() => (this.client, this.selection, {}),
|
||||||
|
async function() {
|
||||||
|
try {
|
||||||
|
this.selection_info = (this.client) && (this.selection) ? await this.client.MAG4_GET_IMAGE_INFO(this.selection) : null;
|
||||||
|
} catch(ex) {
|
||||||
|
this.selection_info = null;
|
||||||
|
console.warn(ex);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.selection_images = (this.client) && (this.selection) && (this.selection_data) ? (this.selection_data['# Img~S2'] == '1' ? [await this.client.MAGG_IMAGE_INFO(this.selection, '1')] : await this.client.MAGG_GROUP_IMAGES(this.selection, '1')) : null;
|
||||||
|
} catch(ex) {
|
||||||
|
this.selection_images = null;
|
||||||
|
console.warn(ex);
|
||||||
|
}
|
||||||
|
if(this.$refs.scroller) {
|
||||||
|
if(this.selection_data) { // scroll to selected item
|
||||||
|
await this.$nextTick();
|
||||||
|
var active = this.$refs.scroller.querySelectorAll(':scope > .active');
|
||||||
|
if(active.length > 0) (Element.prototype.scrollIntoViewIfNeeded || Element.prototype.scrollIntoView).call(active[0]);
|
||||||
|
if(this.$refs.detail) { // scroll to top of detail panel
|
||||||
|
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) {
|
||||||
|
await this.$nextTick();
|
||||||
|
var behavior = document.documentElement.style.scrollBehavior;
|
||||||
|
document.documentElement.style.scrollBehavior = 'auto'; // inhibit Bootstrap smooth scrolling
|
||||||
|
children[i].scrollIntoView();
|
||||||
|
document.documentElement.style.scrollBehavior = behavior;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
45
htdocs/RoutePatientOrders.vue
Normal file
45
htdocs/RoutePatientOrders.vue
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
<template>
|
||||||
|
<Subtitle value="Orders" />
|
||||||
|
<Subtitle :value="patient_info.name" />
|
||||||
|
<div class="card mb-3 shadow">
|
||||||
|
<div class="card-header">Order entry</div>
|
||||||
|
<div class="card-body"><ViewOrderMenu :client="client" :dfn="patient_dfn" /></div>
|
||||||
|
</div>
|
||||||
|
<div class="card mb-3 shadow">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span>Order view</span>
|
||||||
|
<OrderFilterPicker :client="client" v-model="orders_filter" />
|
||||||
|
<DateRangePicker range="6M" direction="-1" v-model:date="orders_date" v-model:date_end="orders_date_begin" />
|
||||||
|
</div>
|
||||||
|
<div class="card-body"><ViewOrders :client="client" :dfn="patient_dfn" :filter="orders_filter" :date_begin="orders_date_begin" :date_end="orders_date" /></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Subtitle from './Subtitle.vue';
|
||||||
|
import DateRangePicker from './DateRangePicker.vue';
|
||||||
|
import OrderFilterPicker from './OrderFilterPicker.vue';
|
||||||
|
import ViewOrderMenu from './ViewOrderMenu.vue';
|
||||||
|
import ViewOrders from './ViewOrders.vue';
|
||||||
|
|
||||||
|
var now = new Date();
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
Subtitle, DateRangePicker, OrderFilterPicker, ViewOrderMenu, ViewOrders
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
client: Object,
|
||||||
|
sensitive: Boolean,
|
||||||
|
patient_dfn: String,
|
||||||
|
patient_info: Object
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
orders_filter: 2,
|
||||||
|
orders_date: now,
|
||||||
|
orders_date_begin: now
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
601
htdocs/RoutePatientReports.vue
Normal file
601
htdocs/RoutePatientReports.vue
Normal file
@ -0,0 +1,601 @@
|
|||||||
|
<template>
|
||||||
|
<Subtitle value="Reports" />
|
||||||
|
<Subtitle :value="patient_info.name" />
|
||||||
|
<div class="filter card mb-3 shadow" :class="{ 'list-wide': !selection }">
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
<li class="list-group-item">
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text">🔎</span>
|
||||||
|
<input type="text" class="form-control" placeholder="Filter" v-model="x_query" />
|
||||||
|
<button v-if="x_query" class="btn btn-outline-secondary" @click="x_query = ''">❌</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<div class="btn-group">
|
||||||
|
<button v-for="report in reports" class="btn" :class="{ 'btn-primary': report.enabled, 'btn-outline-primary': !report.enabled }" @click="enable(report)">{{report.name}}<input type="checkbox" class="form-check-input" :checked="report.enabled" @click.stop="report.enabled = !report.enabled" /></button>
|
||||||
|
</div>
|
||||||
|
<DateRangePicker range="Range" direction="-1" v-model:date="date_end" v-model:date_end="date_begin" />
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<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"><template v-if="resultset.length > rs_filtered.length">{{rs_filtered.length}} of </template>{{resultset.length}}</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 }" ref="scroller">
|
||||||
|
<li v-for="item in rs_filtered" :key="item" class="record list-group-item" :class="{ 'active': (selection) && (selection.id == item.id) }" @click="selection = item"><span class="badge emblem" :class="[item.emblem]" /> <span class="datetime date">{{datestring(item.time)}}</span> <span class="datetime time">{{timestring(item.time)}}</span> • <span class="title"><span v-for="title in item.title">{{title}}</span></span><template v-if="item.snippets"><div v-for="snippet in item.snippets" class="snippet" v-html="snippet" /></template></li>
|
||||||
|
<li class="bottom list-group-item" ref="bottom"><button v-if="date_next" class="btn btn-outline-primary" :disabled="is_loading" @click="date_begin = date_next"><template v-if="is_loading">Loading</template><template v-else>Load</template> back to {{datestring(date_next)}}<template v-if="is_loading">…</template></button></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="selection" 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>{{selection.title.join(' - ')}}</span>
|
||||||
|
<span class="close" @click="selection = null">❌</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail card-body" v-html="selection.highlight || selection.detail" ref="detail" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
ul.scroller span.highlight, div.detail span.highlight {
|
||||||
|
background-color: #ff0;
|
||||||
|
}
|
||||||
|
li.record.active span.highlight {
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
div.filter.list-wide {
|
||||||
|
position: sticky;
|
||||||
|
top: 3.65rem;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
div.filter input.form-check-input {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
div.selector {
|
||||||
|
position: sticky;
|
||||||
|
top: 1.15rem;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
ul.scroller.list-skinny {
|
||||||
|
max-height: 25vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
li.record {
|
||||||
|
cursor: default;
|
||||||
|
border-top: 1px solid #dee2e6;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
scroll-margin-top: 10.5rem;
|
||||||
|
}
|
||||||
|
li.record:nth-child(even) {
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
ul.scroller.list-skinny li.record {
|
||||||
|
scroll-margin-top: 0;
|
||||||
|
}
|
||||||
|
li.record.active {
|
||||||
|
color: #fff;
|
||||||
|
background-color: #0d6efd;
|
||||||
|
}
|
||||||
|
li.bottom {
|
||||||
|
overflow-anchor: none;
|
||||||
|
}
|
||||||
|
li.bottom button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
span.badge.emblem:empty {
|
||||||
|
display: inline-block;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
span.badge.emblem-notes {
|
||||||
|
background-color: var(--bs-purple);
|
||||||
|
}
|
||||||
|
span.badge.emblem-notes::after {
|
||||||
|
content: 'N';
|
||||||
|
}
|
||||||
|
span.badge.emblem-labs {
|
||||||
|
background-color: var(--bs-pink);
|
||||||
|
}
|
||||||
|
span.badge.emblem-labs::after {
|
||||||
|
content: 'L';
|
||||||
|
}
|
||||||
|
span.badge.emblem-microbiology {
|
||||||
|
background-color: var(--bs-orange);
|
||||||
|
}
|
||||||
|
span.badge.emblem-microbiology::after {
|
||||||
|
content: 'M';
|
||||||
|
}
|
||||||
|
span.badge.emblem-bloodbank {
|
||||||
|
background-color: var(--bs-red);
|
||||||
|
}
|
||||||
|
span.badge.emblem-bloodbank::after {
|
||||||
|
content: 'B';
|
||||||
|
}
|
||||||
|
span.badge.emblem-pathology {
|
||||||
|
background-color: var(--bs-yellow);
|
||||||
|
}
|
||||||
|
span.badge.emblem-pathology::after {
|
||||||
|
content: 'P';
|
||||||
|
}
|
||||||
|
span.badge.emblem-radiology {
|
||||||
|
background-color: var(--bs-green);
|
||||||
|
}
|
||||||
|
span.badge.emblem-radiology::after {
|
||||||
|
content: 'R';
|
||||||
|
}
|
||||||
|
span.datetime, span.title span:first-child {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
span.title span:not(:first-child)::before {
|
||||||
|
content: ' - ';
|
||||||
|
}
|
||||||
|
ul.scroller.list-skinny span.datetime.time {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
div.snippet {
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
div.snippet::before, div.snippet::after {
|
||||||
|
content: '…';
|
||||||
|
}
|
||||||
|
span.close {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
div.detail {
|
||||||
|
scroll-margin-top: calc(3.6875rem + 2.5625rem + 25vh);
|
||||||
|
font-family: monospace;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
div.selector {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
ul.scroller.list-skinny {
|
||||||
|
max-height: 75vh;
|
||||||
|
}
|
||||||
|
div.detail {
|
||||||
|
max-height: 75vh;
|
||||||
|
scroll-margin-top: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { flow, uniq, debounce, strftime_vista, strptime_vista } from './util.mjs';
|
||||||
|
|
||||||
|
import Subtitle from './Subtitle.vue';
|
||||||
|
import DateRangePicker from './DateRangePicker.vue';
|
||||||
|
|
||||||
|
const SZ_WINDOW = 100;
|
||||||
|
const SZ_RANGE = 40000;
|
||||||
|
|
||||||
|
function f_parse_columns(item) {
|
||||||
|
var res = [], line, brk, sub;
|
||||||
|
for(var i = 0; i < item.length; ++i) {
|
||||||
|
brk = (line = item[i]).indexOf('^');
|
||||||
|
if(brk >= 0) {
|
||||||
|
if(res[sub = line.substring(0, brk)]) res[sub].push(line.substring(brk + 1));
|
||||||
|
else res[sub] = [line.substring(brk + 1)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for(var k in res) if(res[k]) res[k] = res[k].join('\n');
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
const create_reports = () => [
|
||||||
|
{
|
||||||
|
name: 'Notes',
|
||||||
|
rpt_id: 'OR_PN:PROGRESS NOTES~TIUPRG;ORDV04;15;',
|
||||||
|
map: flow(f_parse_columns, function(x) {
|
||||||
|
var time = new Date(x[3]);
|
||||||
|
return {
|
||||||
|
time,
|
||||||
|
id: 'OR_PN:' + time.getTime() + ':' + x[2],
|
||||||
|
emblem: 'emblem-notes',
|
||||||
|
title: [x[4], x[5], '#' + x[2]],
|
||||||
|
detail: escape_html(collapse_lines(x[6]))
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
loader: reportloader_chunk,
|
||||||
|
enabled: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Discharge',
|
||||||
|
rpt_id: 'OR_DS:DISCHARGE SUMMARY~TIUDCS;ORDV04;57;',
|
||||||
|
map: flow(f_parse_columns, function(x) {
|
||||||
|
var time = new Date(x[3]);
|
||||||
|
return {
|
||||||
|
time,
|
||||||
|
id: 'OR_DS:' + time.getTime() + ':' + x[2],
|
||||||
|
emblem: 'emblem-notes',
|
||||||
|
title: ['DISCHARGE SUMMARY', x[4], x[2], x[3]],
|
||||||
|
detail: escape_html(collapse_lines(x[7]))
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
loader: reportloader_chunk
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Labs',
|
||||||
|
rpt_id: 'OR_OV_R:LAB OVERVIEW (COLLECTED SPECIMENS)~OV;ORDV02C;32;',
|
||||||
|
map: flow(f_parse_columns, function(x) {
|
||||||
|
var time = new Date(x[2]);
|
||||||
|
return {
|
||||||
|
time,
|
||||||
|
id: 'OR_OV_R:' + time.getTime() + ':' + x[12],
|
||||||
|
emblem: 'emblem-labs',
|
||||||
|
title: [x[3], x[6], x[8], x[10], '#' + x[12]],
|
||||||
|
detail: escape_html(x[15])
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
loader: reportloader_chunk
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Microbiology',
|
||||||
|
rpt_id: 'OR_MIC:MICROBIOLOGY~MI;ORDV05;38;',
|
||||||
|
map: flow(f_parse_columns, function(x) {
|
||||||
|
var time = new Date(x[2]);
|
||||||
|
return {
|
||||||
|
time,
|
||||||
|
id: 'OR_MIC:' + time.getTime() + ':' + x[6],
|
||||||
|
emblem: 'emblem-microbiology',
|
||||||
|
title: [x[3], x[4], x[5], '#' + x[6]],
|
||||||
|
detail: escape_html(x[7])
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
loader: reportloader_chunk
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Blood Bank',
|
||||||
|
rpt_id: '2:BLOOD BANK REPORT~;;0',
|
||||||
|
singleton: true,
|
||||||
|
map(x) {
|
||||||
|
var now = new Date();
|
||||||
|
return {
|
||||||
|
time: new Date(now.getFullYear(), now.getMonth(), now.getDate()),
|
||||||
|
id: 'BB',
|
||||||
|
emblem: 'emblem-bloodbank',
|
||||||
|
title: ['BLOOD BANK'],
|
||||||
|
detail: escape_html(x.join('\n'))
|
||||||
|
};
|
||||||
|
},
|
||||||
|
loader: reportloader_full
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Pathology',
|
||||||
|
rpt_id: 'OR_APR:ANATOMIC PATHOLOGY~SP;ORDV02A;0;',
|
||||||
|
map: flow(f_parse_columns, function(x) {
|
||||||
|
var time = new Date(x[2]);
|
||||||
|
return {
|
||||||
|
time,
|
||||||
|
id: 'OR_APR:' + time.getTime() + ':' + x[4],
|
||||||
|
emblem: 'emblem-pathology',
|
||||||
|
title: [x[3], '#' + x[4]],
|
||||||
|
detail: escape_html(x[5])
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
loader: reportloader_full
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Radiology',
|
||||||
|
rpt_id: 'OR_R18:IMAGING~RIM;ORDV08;0;',
|
||||||
|
map: flow(f_parse_columns, function(x) {
|
||||||
|
var time = new Date(x[2]);
|
||||||
|
return {
|
||||||
|
time,
|
||||||
|
id: 'OR_R18:' + time.getTime() + ':' + x[9],
|
||||||
|
emblem: 'emblem-radiology',
|
||||||
|
title: [x[3], x[4], x[5], '#' + x[9]],
|
||||||
|
detail: escape_html(x[6])
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
loader: reportloader_chunk
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function data_limit(data, dt_alpha, dt_omega) {
|
||||||
|
for(var i = 0, x, none = true; i < data.length; ++i) if(((x = data[i]).time <= dt_omega) || (!x.time) || (isNaN(x.time))) {
|
||||||
|
data = data.slice(i);
|
||||||
|
none = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if(none) return [];
|
||||||
|
for(var i = data.length - 1, x, none = true; i >= 0; --i) if(((x = data[i]).time >= dt_alpha) || (!x.time) || (isNaN(x.time))) return i < data.length - 1 ? data.slice(0, i + 1) : data;
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function data_endtime(data) {
|
||||||
|
for(var i = data.length - 1, time; i >= 0; --i) if((time = data[i].time) && (!isNaN(time))) return time;
|
||||||
|
}
|
||||||
|
|
||||||
|
function data_endtime_conservative(data) {
|
||||||
|
var dt_end = data_endtime(data);
|
||||||
|
if(dt_end) for(var i = data.length - 1, time; i >= 0; --i) if((time = data[i].time) && (time > dt_end)) return time;
|
||||||
|
return dt_end;
|
||||||
|
}
|
||||||
|
|
||||||
|
function data_interval(data) {
|
||||||
|
data = data.slice(data.length - SZ_WINDOW - 1).filter(x => (x.time) && (!isNaN(x.time)));
|
||||||
|
return data.length > 1 ? (data[0].time - data[data.length - 1].time)/(data.length - 1)*SZ_WINDOW : 86400000*SZ_WINDOW;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportloader_full(dfn, rpt_id, fn_map, omega) {
|
||||||
|
var cachekey = dfn + ';' + rpt_id, dt_omega = strptime_vista(omega), data = null, dt_end;
|
||||||
|
async function fn(client, alpha) {
|
||||||
|
var dt_alpha = strptime_vista(alpha);
|
||||||
|
if(!data) {
|
||||||
|
data = (reportloader_full[cachekey] || (reportloader_full[cachekey] = await client.ORWRP_REPORT_TEXT(dfn, rpt_id, '', SZ_RANGE, '', -1, -1))).map(fn_map).sort((a, b) => b.time - a.time), dt_end = null;
|
||||||
|
dt_end = data_endtime(data);
|
||||||
|
}
|
||||||
|
var res = alpha !== undefined ? data_limit(data, dt_alpha, dt_omega) : [];
|
||||||
|
if((data.length > 0) && ((dt_alpha > dt_end) || (alpha === undefined)) && ((res.length < 1) || (res[res.length - 1] !== data[data.length - 1]))) res.next = strftime_vista(res.dt_next = dt_end); // lookahead
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
fn.omega = omega;
|
||||||
|
return fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportloader_alpha(dfn, rpt_id, fn_map, omega) {
|
||||||
|
var dt_omega = strptime_vista(omega), cursor = Math.floor(strftime_vista(new Date())) + 0.235959999, interval = 86400000*365*2, data = [], dt_end, hasmore = true;
|
||||||
|
async function fn(client, alpha) {
|
||||||
|
var dt_alpha = strptime_vista(alpha);
|
||||||
|
if(alpha !== undefined) {
|
||||||
|
if((hasmore) && (cursor >= alpha)) {
|
||||||
|
while(cursor >= alpha) cursor = Math.floor(strftime_vista(new Date(strptime_vista(cursor) - interval)));
|
||||||
|
data = (await client.ORWRP_REPORT_TEXT(dfn, rpt_id, '', SZ_RANGE, '', cursor, -1)).map(fn_map).sort((a, b) => b.time - a.time);
|
||||||
|
dt_end = data_endtime(data);
|
||||||
|
}
|
||||||
|
var res = data_limit(data, dt_alpha, dt_omega);
|
||||||
|
} else var res = [];
|
||||||
|
if((data.length > 0) && ((dt_alpha > dt_end) || (alpha === undefined)) && ((res.length < 1) || (res[res.length - 1] !== data[data.length - 1]))) res.next = strftime_vista(res.dt_next = dt_end); // lookahead
|
||||||
|
else if(hasmore) {
|
||||||
|
var count = data.length;
|
||||||
|
while(interval <= 86400000*365*8) {
|
||||||
|
cursor = Math.floor(strftime_vista(new Date(strptime_vista(cursor) - interval)));
|
||||||
|
data = (await client.ORWRP_REPORT_TEXT(dfn, rpt_id, '', SZ_RANGE, '', cursor, -1)).map(fn_map).sort((a, b) => b.time - a.time);
|
||||||
|
if(data.length > count) {
|
||||||
|
res.next = strftime_vista(res.dt_next = dt_end = data_endtime(data));
|
||||||
|
return res;
|
||||||
|
} else interval *= 2;
|
||||||
|
}
|
||||||
|
data = (await client.ORWRP_REPORT_TEXT(dfn, rpt_id, '', SZ_RANGE, '', -1, -1)).map(fn_map).sort((a, b) => b.time - a.time);
|
||||||
|
cursor = Math.floor(res.next = strftime_vista(res.dt_next = dt_end = data_endtime(data)));
|
||||||
|
hasmore = false;
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
fn.omega = omega;
|
||||||
|
return fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportloader_omega(dfn, rpt_id, fn_map, omega) {
|
||||||
|
var dt_omega = strptime_vista(omega), cursor = Math.floor(omega) + 0.235959999, data = [], idmap = {};
|
||||||
|
async function fn(client, alpha) {
|
||||||
|
var dt_alpha = strptime_vista(alpha), batch;
|
||||||
|
if(cursor >= alpha) {
|
||||||
|
batch = (await client.ORWRP_REPORT_TEXT(dfn, rpt_id, '', SZ_RANGE, '', Math.floor(alpha).toFixed(9), cursor.toFixed(9))).map(fn_map).sort((a, b) => b.time - a.time);
|
||||||
|
cursor = strftime_vista(new Date(strptime_vista(Math.floor(alpha)) - 86400000)) + 0.235959999;
|
||||||
|
batch = batch.filter(x => idmap[x.id] ? console.warn('Duplicate record', x) : true);
|
||||||
|
batch.reduce((acc, val) => (acc[val.id] = val, acc), idmap);
|
||||||
|
Array.prototype.push.apply(data, batch);
|
||||||
|
}
|
||||||
|
var dt_cursor = strptime_vista(cursor), interval = data_interval(data); // lookahead
|
||||||
|
while(((batch = (await client.ORWRP_REPORT_TEXT_LONGCACHE(dfn, rpt_id, '', SZ_RANGE, '', Math.floor(strftime_vista(new Date(dt_cursor - interval))).toFixed(9), cursor.toFixed(9))).map(fn_map)).length < SZ_WINDOW) && (interval <= 86400000*SZ_RANGE)) interval *= 2;
|
||||||
|
var res = alpha !== undefined ? data_limit(data, dt_alpha, dt_omega) : [];
|
||||||
|
res.next = strftime_vista(res.dt_next = new Date(dt_cursor - interval));
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
fn.omega = omega;
|
||||||
|
return fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportloader_chunk(dfn, rpt_id, fn_map, omega) {
|
||||||
|
var dt_omega = strptime_vista(omega), cursor = Math.floor(omega) + 0.235959999, data = [], idmap = {}, hasmore = true;
|
||||||
|
if(rpt_id.endsWith(';')) rpt_id += SZ_WINDOW;
|
||||||
|
async function fn(client, alpha) {
|
||||||
|
if(alpha !== undefined) {
|
||||||
|
var dt_alpha = strptime_vista(alpha), batch;
|
||||||
|
while((hasmore) && (cursor >= alpha)) {
|
||||||
|
batch = (await client.ORWRP_REPORT_TEXT(dfn, rpt_id, '', SZ_RANGE, '', -1, cursor.toFixed(9))).map(fn_map).sort((a, b) => b.time - a.time);
|
||||||
|
cursor = data_endtime(batch); if(cursor) cursor = strftime_vista(cursor);
|
||||||
|
hasmore = (cursor) && (batch.length >= SZ_WINDOW);
|
||||||
|
batch = batch.filter(x => idmap[x.id] ? console.warn('Duplicate record', x) : true);
|
||||||
|
batch.reduce((acc, val) => (acc[val.id] = val, acc), idmap);
|
||||||
|
Array.prototype.push.apply(data, batch);
|
||||||
|
}
|
||||||
|
var res = data_limit(data, dt_alpha, dt_omega);
|
||||||
|
} else var res = [];
|
||||||
|
if(hasmore) { // lookahead
|
||||||
|
var batch = (await client.ORWRP_REPORT_TEXT_LONGCACHE(dfn, rpt_id, '', SZ_RANGE, '', -1, cursor.toFixed(9))).map(fn_map).sort((a, b) => b.time - a.time);
|
||||||
|
if(res.dt_next = (batch.length >= SZ_WINDOW ? data_endtime_conservative : data_endtime)(batch)) res.next = strftime_vista(res.dt_next);
|
||||||
|
}
|
||||||
|
if((!res.dt_next) && (cursor) && (data.length > 0) && ((res.length < 1) || (res[res.length - 1] !== data[data.length - 1]))) res.dt_next = strptime_vista(res.next = cursor);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
fn.omega = omega;
|
||||||
|
return fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapse_lines(s) {
|
||||||
|
return s.replace(/(\S)[^\S\r\n]+\r?\n([^\s\.,\/#!$%\^&\*;:=\-_`~])/g, '$1 $2').replace(/([\.,!;])\r?\n([^\s\.,\/#!$%\^&\*;:=\-_`~])/g, '$1 $2');
|
||||||
|
}
|
||||||
|
|
||||||
|
const escape_div = document.createElement('div');
|
||||||
|
function escape_html(s) {
|
||||||
|
escape_div.textContent = s;
|
||||||
|
return escape_div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function snippets(text, regex, replacement) {
|
||||||
|
var res = [], context = new RegExp('(?:\\S+\\s+){0,3}\\S*(' + regex.source + ')\\S*(?:\\s+\\S+){0,3}', regex.flags), match;
|
||||||
|
if(context.global) while((match = context.exec(text)) !== null) res.push(match[0].replace(regex, replacement).replace(/\s+/g, ' ').replace(/([\W_])\1{2,}/g, '$1$1'));
|
||||||
|
else if((match = context.exec(text)) !== null) res.push(match[0].replace(regex, replacement).replace(/\s+/g, ' ').replace(/([\W_])\1{2,}/g, '$1$1'));
|
||||||
|
return uniq(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
Subtitle, DateRangePicker
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
client: Object,
|
||||||
|
sensitive: Boolean,
|
||||||
|
patient_dfn: String,
|
||||||
|
patient_info: Object
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
var now = new Date();
|
||||||
|
return {
|
||||||
|
dfn: null,
|
||||||
|
is_loading: false,
|
||||||
|
date_end: now,
|
||||||
|
date_begin: now,
|
||||||
|
date_next: null,
|
||||||
|
query: '',
|
||||||
|
x_query: '',
|
||||||
|
reports: create_reports(),
|
||||||
|
loaders: {},
|
||||||
|
resultsets: {},
|
||||||
|
selection: null,
|
||||||
|
observer_scroller: null,
|
||||||
|
observer_viewport: null
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
resultset() {
|
||||||
|
return this.reports.map((x, i) => x.enabled ? this.resultsets[i] : null).filter(x => x).reduce((acc, val) => (Array.prototype.push.apply(acc, val), acc), []).sort((a, b) => b.time - a.time);
|
||||||
|
},
|
||||||
|
rs_filtered() {
|
||||||
|
var query = this.query.replace(/^\s+|\s+$/g, '');
|
||||||
|
if(query.length > 0) {
|
||||||
|
if(query.startsWith('"')) {
|
||||||
|
query = query.substring(1, query.length - ((query.length > 1) && (query.endsWith('"')) ? 1 : 0));
|
||||||
|
if(query.length > 0) {
|
||||||
|
query = new RegExp(query.replace(/\s+/g, ' ').split(' ').map(x => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('\\s*'), 'gims');
|
||||||
|
return this.resultset.filter(x => (x.detail) && (query.test(x.detail))).map(x => Object.assign({ snippets: snippets(x.detail, query, '<span class="highlight">$&</span>'), highlight: x.detail.replace(query, '<span class="highlight">$&</span>') }, x));
|
||||||
|
}
|
||||||
|
} else if(query.startsWith('/')) {
|
||||||
|
if(query.length > 1) {
|
||||||
|
var m = /^\/(.*)\/([a-z]*)$/.exec(query);
|
||||||
|
query = m ? new RegExp(m[1], m[2]) : new RegExp(query.substring(1), 'gims');
|
||||||
|
return this.resultset.filter(x => (x.detail) && (query.test(x.detail))).map(x => Object.assign({ snippets: snippets(x.detail, query, '<span class="highlight">$&</span>'), highlight: x.detail.replace(query, '<span class="highlight">$&</span>') }, x));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
query = new RegExp(query.replace(/\s+/g, ' ').split(' ').map(x => '\\b' + x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('\\S*(?:\\s+\\S+){0,5}\\s+'), 'gims');
|
||||||
|
return this.resultset.filter(x => (x.detail) && (query.test(x.detail))).map(x => Object.assign({ snippets: snippets(x.detail, query, '<span class="highlight">$&</span>'), highlight: x.detail.replace(query, '<span class="highlight">$&</span>') }, x));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.resultset;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
rs_filtered(value) {
|
||||||
|
if((value) && (this.selection)) {
|
||||||
|
var id = this.selection.id;
|
||||||
|
for(var i = 0; i < value.length; ++i) if(value[i].id == id) return this.selection = value[i];
|
||||||
|
this.selection = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async selection(value) {
|
||||||
|
if(this.$refs.scroller) {
|
||||||
|
if(value) { // scroll to selected item
|
||||||
|
await this.$nextTick();
|
||||||
|
var active = this.$refs.scroller.querySelectorAll(':scope > .active');
|
||||||
|
if(active.length > 0) (Element.prototype.scrollIntoViewIfNeeded || Element.prototype.scrollIntoView).call(active[0]);
|
||||||
|
if(this.$refs.detail) { // scroll to top of detail panel
|
||||||
|
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) {
|
||||||
|
await this.$nextTick();
|
||||||
|
var behavior = document.documentElement.style.scrollBehavior;
|
||||||
|
document.documentElement.style.scrollBehavior = 'auto'; // inhibit Bootstrap smooth scrolling
|
||||||
|
children[i].scrollIntoView();
|
||||||
|
document.documentElement.style.scrollBehavior = behavior;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
strftime_vista,
|
||||||
|
datestring(date) {
|
||||||
|
return date.toLocaleDateString('sv-SE');
|
||||||
|
},
|
||||||
|
timestring(date) {
|
||||||
|
return date.toLocaleTimeString('en-GB').substring(0, 5);
|
||||||
|
},
|
||||||
|
enable(report) {
|
||||||
|
if(!report.enabled) {
|
||||||
|
var reports = this.reports;
|
||||||
|
for(var i = reports.length - 1; i >= 0; --i) reports[i].enabled = false;
|
||||||
|
report.enabled = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async loader_setup() {
|
||||||
|
try {
|
||||||
|
this.is_loading = true;
|
||||||
|
if((this.client) && (this.patient_dfn)) {
|
||||||
|
if(this.dfn != this.patient_dfn) {
|
||||||
|
this.dfn = this.patient_dfn;
|
||||||
|
this.loaders = {};
|
||||||
|
this.resultsets = {};
|
||||||
|
}
|
||||||
|
var dfn = this.patient_dfn, loaders = this.loaders, resultsets = this.resultsets, reports = this.reports, report, omega = strftime_vista(this.date_end), alpha = this.date_begin != this.date_end ? strftime_vista(this.date_begin) : undefined, next = [];
|
||||||
|
for(var i = 0; i < reports.length; ++i) if((report = reports[i]).enabled) {
|
||||||
|
if((!loaders[i]) || (loaders[i].omega != omega)) loaders[i] = report.loader(dfn, report.rpt_id, report.map, omega);
|
||||||
|
resultsets[i] = await loaders[i](this.client, alpha);
|
||||||
|
if(resultsets[i].next) next.push(resultsets[i].next);
|
||||||
|
}
|
||||||
|
this.date_next = next.length > 0 ? strptime_vista(Math.floor(Math.max(...next))) : null;
|
||||||
|
if(!alpha) this.date_begin = this.date_next;
|
||||||
|
} else {
|
||||||
|
this.dfn = null;
|
||||||
|
this.loaders = {};
|
||||||
|
this.resultsets = {};
|
||||||
|
}
|
||||||
|
} catch(ex) {
|
||||||
|
console.warn(ex);
|
||||||
|
} finally {
|
||||||
|
this.is_loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.$watch(
|
||||||
|
() => (this.client, this.patient_dfn, this.reports.map(x => x.enabled), this.date_begin, this.date_end, {}),
|
||||||
|
debounce(() => this.loader_setup(), 500),
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
this.$watch(
|
||||||
|
() => this.x_query,
|
||||||
|
debounce(value => this.query = value, 500),
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.observer_scroller = new IntersectionObserver(([entry]) => { if((entry.isIntersecting) && (this.selection) && (this.date_next) && (!this.is_loading) && (!this.query.replace(/^\s+|\s+$/g, ''))) this.date_begin = this.date_next; }, { root: this.$refs.scroller, rootMargin: '25%' });
|
||||||
|
this.observer_scroller.observe(this.$refs.bottom);
|
||||||
|
this.observer_viewport = new IntersectionObserver(([entry]) => { if((entry.isIntersecting) && (!this.selection) && (this.date_next) && (!this.is_loading) && (!this.query.replace(/^\s+|\s+$/g, ''))) this.date_begin = this.date_next; }, { rootMargin: '25%' });
|
||||||
|
this.observer_viewport.observe(this.$refs.bottom);
|
||||||
|
},
|
||||||
|
destroyed() {
|
||||||
|
if(this.observer_viewport) this.observer_viewport.disconnect();
|
||||||
|
if(this.observer_scroller) this.observer_scroller.disconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
39
htdocs/RoutePatientVisits.vue
Normal file
39
htdocs/RoutePatientVisits.vue
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
<template>
|
||||||
|
<Subtitle value="Visits" />
|
||||||
|
<Subtitle :value="patient_info.name" />
|
||||||
|
<div class="card mb-3 shadow">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span>Visits</span>
|
||||||
|
<DateRangePicker range="6M" direction="-1" v-model:date="visits_date" v-model:date_end="visits_date_begin" />
|
||||||
|
</div>
|
||||||
|
<div class="card-body"><ViewVisits :client="client" :dfn="patient_dfn" :date_begin="visits_date_begin" :date_end="visits_date" /></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Subtitle from './Subtitle.vue';
|
||||||
|
import DateRangePicker from './DateRangePicker.vue';
|
||||||
|
import OrderFilterPicker from './OrderFilterPicker.vue';
|
||||||
|
import ViewVisits from './ViewVisits.vue';
|
||||||
|
|
||||||
|
var now = new Date();
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
Subtitle, DateRangePicker, OrderFilterPicker, ViewVisits
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
client: Object,
|
||||||
|
sensitive: Boolean,
|
||||||
|
patient_dfn: String,
|
||||||
|
patient_info: Object
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
orders_filter: 2,
|
||||||
|
visits_date: now,
|
||||||
|
visits_date_begin: now
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
48
htdocs/RoutePlanner.vue
Normal file
48
htdocs/RoutePlanner.vue
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
<template>
|
||||||
|
<Subtitle value="Planner" />
|
||||||
|
<div class="card mb-3 shadow">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span>Planner</span>
|
||||||
|
<select v-if="(resourcelist_selected) && (resourcelist_selected.length > 0)" class="form-select form-select-sm" style="width: auto;" v-model="resource"><option v-for="row in resourcelist_selected" :value="row.RESOURCEID">{{row.RESOURCE_NAME}} #{{row.RESOURCEID}}</option></select>
|
||||||
|
<DateRangePicker range="1M" direction="1" v-model:date="date_begin" v-model:date_end="date_end" />
|
||||||
|
</div>
|
||||||
|
<div class="card-body"><ViewPlanner :client="client" :resource="resource" :date_begin="date_begin" :date_end="date_end" /></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Subtitle from './Subtitle.vue';
|
||||||
|
import DateRangePicker from './DateRangePicker.vue';
|
||||||
|
import ViewPlanner from './ViewPlanner.vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
Subtitle, DateRangePicker, ViewPlanner
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
client: Object
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
var now = new Date();
|
||||||
|
return {
|
||||||
|
resource: null,
|
||||||
|
date_begin: now,
|
||||||
|
date_end: now,
|
||||||
|
resourcelist_all: []
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
resourcelist_selected() {
|
||||||
|
if((this.client) && (this.client.remotestate.resources) && (this.resourcelist_all)) {
|
||||||
|
var resourcemap = this.client.remotestate.resources.split(',').filter(x => x).reduce((acc, val) => (acc[val] = true, acc), {});
|
||||||
|
var res = this.resourcelist_all.filter(x => resourcemap.hasOwnProperty(x.RESOURCEID));
|
||||||
|
if((res.length > 0) && (!this.resource)) this.resource = res[0].RESOURCEID;
|
||||||
|
return res;
|
||||||
|
} else return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async mounted() {
|
||||||
|
this.resourcelist_all = await this.client.SDEC_RESOURCE();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
113
htdocs/RouteRecall.vue
Normal file
113
htdocs/RouteRecall.vue
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
<template>
|
||||||
|
<Subtitle value="Recall" />
|
||||||
|
<div class="card mb-3 shadow">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span>Recall list ({{patients_lost.length + patients_outstanding.length}})</span>
|
||||||
|
<router-link to="/settings">Select clinics<template v-if="selection.length > 0"> ({{selection.length}})</template></router-link>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<table class="table" style="font-family: monospace;" v-if="patients_lost && patients_lost.length > 0">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Lost ({{patients_lost.length}})</th><th>Last appt</th><th>Clinic</th><th>Days</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in patients_lost" :style="{ backgroundColor: strHashHSL(row.Clinic, '90%') }">
|
||||||
|
<td v-if="production"><router-link :to="'/patient/$' + row.key">{{row.Name}} <span :title="row.key">{{row.key.slice(-4)}}</span></router-link></td>
|
||||||
|
<td v-else><router-link :title="strtr_unscramble(row.Name)" :to="'/patient/$' + row.Name.charAt(0) + row.key.slice(-4) + '?name=' + row.Name">{{row.Name}} <span :title="row.key">{{row.key.slice(-4)}}</span></router-link></td>
|
||||||
|
<td>{{datefmt(row.TimeLast)}} {{dow[row.TimeLast.getDay()]}}</td>
|
||||||
|
<td>{{row.Clinic}}</td>
|
||||||
|
<td>{{Math.round(row.TimeLastDiff/86400000)}}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<table class="table" style="font-family: monospace;" v-if="patients_outstanding && patients_outstanding.length > 0">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Outstanding ({{patients_outstanding.length}})</th><th>Next appt</th><th>Clinic</th><th>Days</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in patients_outstanding" :style="{ backgroundColor: strHashHSL(row.Clinic, '90%') }">
|
||||||
|
<td v-if="production"><router-link :to="'/patient/$' + row.key">{{row.Name}} <span :title="row.key">{{row.key.slice(-4)}}</span></router-link></td>
|
||||||
|
<td v-else><router-link :title="strtr_unscramble(row.Name)" :to="'/patient/$' + row.Name.charAt(0) + row.key.slice(-4) + '?name=' + row.Name">{{row.Name}} <span :title="row.key">{{row.key.slice(-4)}}</span></router-link></td>
|
||||||
|
<td>{{datefmt(row.TimeNext)}} {{dow[row.TimeNext.getDay()]}}</td>
|
||||||
|
<td>{{row.Clinic}}</td>
|
||||||
|
<td>{{Math.round(row.TimeNextDiff/86400000)}}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { groupByArray, strtr_unscramble, strHashHSL, strftime_vista, debounce } from './util.mjs';
|
||||||
|
|
||||||
|
import Subtitle from './Subtitle.vue';
|
||||||
|
|
||||||
|
function dateonly(date) {
|
||||||
|
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
Subtitle
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
client: Object
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
var today = dateonly(new Date());
|
||||||
|
return {
|
||||||
|
patients: [],
|
||||||
|
production: true,
|
||||||
|
date_begin: new Date(today.getFullYear() - 1, today.getMonth(), today.getDate()),
|
||||||
|
date_end: new Date(today.getFullYear() + 1, today.getMonth(), today.getDate()),
|
||||||
|
dow: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
selection() { return (this.client) && (this.client.remotestate.resources) ? (this.client.remotestate.resources.split(',').filter(x => x) || []) : [] },
|
||||||
|
patients_lost() {
|
||||||
|
return this.patients.filter(x => x.TimeLastDiff >= 0).sort((a, b) => b.TimeLastDiff - a.TimeLastDiff);
|
||||||
|
},
|
||||||
|
patients_outstanding() {
|
||||||
|
return this.patients.filter(x => x.TimeNextDiff >= 0).sort((a, b) => b.TimeNextDiff - a.TimeNextDiff);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
selection: {
|
||||||
|
handler(value) { this.$nextTick(() => this.debounced_selection(value)); },
|
||||||
|
immediate: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
strHashHSL,
|
||||||
|
strtr_unscramble,
|
||||||
|
datefmt(date) {
|
||||||
|
return date ? date.toLocaleDateString('sv-SE') : '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.debounced_selection = debounce(async function(value) {
|
||||||
|
var patients = this.selection.length > 0 ? groupByArray(await this.client.SDEC_CLINLET(this.selection.join('|') + '|', strftime_vista(this.date_begin), strftime_vista(this.date_end)), x => x.HRN) : [], now = new Date(), group, values, appt;
|
||||||
|
for(var i = patients.length - 1; i >= 0; --i) {
|
||||||
|
group = patients[i];
|
||||||
|
group.Name = group.values[0].Name;
|
||||||
|
group.DOB = group.values[0].DOB;
|
||||||
|
group.values = values = group.values.map(function(x) { return { Time: new Date(x.ApptDate), Clinic: x.Clinic }; }).sort((a, b) => a.Time - b.Time);
|
||||||
|
group.TimeLast = (appt = group.values[group.values.length - 1]).Time;
|
||||||
|
group.TimeLastDiff = now - group.TimeLast;
|
||||||
|
group.Clinic = appt.Clinic;
|
||||||
|
if(group.TimeLastDiff < 0) for(var j = 0; j < values.length; ++j) if(values[j].Time - now > 0) {
|
||||||
|
group.TimeNext = values[j].Time;
|
||||||
|
group.TimeNextDiff = group.TimeNext - now;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.patients = patients.sort((a, b) => a.Time - b.Time);
|
||||||
|
}, 500);
|
||||||
|
},
|
||||||
|
async mounted() {
|
||||||
|
this.production = (await this.client.serverinfo()).result.production == '1';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
@ -1,27 +1,19 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<Subtitle value="Schedule" />
|
||||||
<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 mb-3 shadow">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<span>Schedule</span>
|
<span>Schedule</span>
|
||||||
<DateRangePicker range="1D" direction="+1" v-model:date="date" v-model:date_end="date_end" />
|
<router-link to="/settings">Select clinics<template v-if="selection.length > 0"> ({{selection.length}})</template></router-link>
|
||||||
|
<DateRangePicker direction="+1" v-model:date="date" v-model:date_end="date_end" v-model:range="range" />
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<ViewSchedule :client="client" :selection="selection" :date_begin="datefmt(date)" :date_end="datefmt(new Date(date_end.getTime() - 1))" />
|
<ViewSchedule :client="client" :selection="selection" :date_begin="date" :date_end="new Date(date_end.getTime() - 1)" />
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import cookie from './cookie.mjs';
|
import Subtitle from './Subtitle.vue';
|
||||||
|
|
||||||
import ViewResourceLookup from './ViewResourceLookup.vue';
|
|
||||||
import DateRangePicker from './DateRangePicker.vue';
|
import DateRangePicker from './DateRangePicker.vue';
|
||||||
import ViewSchedule from './ViewSchedule.vue';
|
import ViewSchedule from './ViewSchedule.vue';
|
||||||
|
|
||||||
@ -29,30 +21,46 @@
|
|||||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function localtime(s) {
|
||||||
|
var date = new Date(s);
|
||||||
|
date.setTime(date.getTime() + 60000*date.getTimezoneOffset());
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
ViewResourceLookup, DateRangePicker, ViewSchedule
|
Subtitle, DateRangePicker, ViewSchedule
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
client: Object
|
client: Object
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
var resources = cookie.get('vista.resources');
|
|
||||||
return {
|
return {
|
||||||
selection: resources ? (resources.split(',').filter(x => x) || []) : [],
|
|
||||||
date: dateonly(new Date()),
|
date: dateonly(new Date()),
|
||||||
date_end: dateonly(new Date())
|
date_end: dateonly(new Date()),
|
||||||
|
range: '1D'
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
watch: {
|
computed: {
|
||||||
selection(value, oldvalue) {
|
selection() { return (this.client) && (this.client.remotestate.resources) ? (this.client.remotestate.resources.split(',').filter(x => x) || []) : [] }
|
||||||
cookie.set('vista.resources', value.join(','), 7);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
methods: {
|
watch: {
|
||||||
datefmt(date) {
|
'$route.params.from': {
|
||||||
return date ? date.toLocaleDateString('en-CA') : '';
|
handler(value) {
|
||||||
//return (new Date(date.getTime() + date.getTimezoneOffset()*60000)).toLocaleDateString('en-CA');
|
this.date = dateonly(value ? localtime(value) : new Date());
|
||||||
|
}, immediate: true
|
||||||
|
},
|
||||||
|
'$route.params.to': {
|
||||||
|
handler(value) {
|
||||||
|
if(value) {
|
||||||
|
var date = localtime(value);
|
||||||
|
if(isNaN(date)) this.range = value;
|
||||||
|
else {
|
||||||
|
this.range = 'Range';
|
||||||
|
this.date_end = dateonly(date);
|
||||||
|
}
|
||||||
|
} else this.range = '1D';
|
||||||
|
}, immediate: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
32
htdocs/RouteSettings.vue
Normal file
32
htdocs/RouteSettings.vue
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<template>
|
||||||
|
<Subtitle value="Settings" />
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Subtitle from './Subtitle.vue';
|
||||||
|
import ViewResourceLookup from './ViewResourceLookup.vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
Subtitle, ViewResourceLookup
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
client: Object
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
selection: {
|
||||||
|
get() { return (this.client) && (this.client.remotestate.resources) ? (this.client.remotestate.resources.split(',').filter(x => x) || []) : [] },
|
||||||
|
set(value) { if(this.client) this.client.remotestate.resources = value.join(','); }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
32
htdocs/Submenu.vue
Normal file
32
htdocs/Submenu.vue
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<script>
|
||||||
|
import { menustate } from './common.mjs';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: ['value'],
|
||||||
|
data() {
|
||||||
|
return { ptr: null };
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
value: {
|
||||||
|
handler(value) {
|
||||||
|
this.update(value);
|
||||||
|
}, immediate: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
update(value) {
|
||||||
|
var idx = this.ptr ? menustate.indexOf(this.ptr) : -1;
|
||||||
|
if(idx >= 0) menustate.splice(idx, 1);
|
||||||
|
if(value) {
|
||||||
|
this.ptr = value;
|
||||||
|
if(idx >= 0) menustate.splice(idx, 0, this.ptr);
|
||||||
|
else menustate.unshift(this.ptr);
|
||||||
|
} else this.ptr = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
unmounted() {
|
||||||
|
this.update();
|
||||||
|
},
|
||||||
|
render() {}
|
||||||
|
};
|
||||||
|
</script>
|
41
htdocs/Subtitle.vue
Normal file
41
htdocs/Subtitle.vue
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<script>
|
||||||
|
import { debounce } from './util.mjs';
|
||||||
|
|
||||||
|
const base = 'nuVistA';
|
||||||
|
const state = [{ value: base }];
|
||||||
|
const settitle = debounce(s => document.title = s || base, 0);
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: ['value'],
|
||||||
|
data() {
|
||||||
|
return { ptr: null };
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
value: {
|
||||||
|
handler(value) {
|
||||||
|
this.update(value);
|
||||||
|
}, immediate: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
update(value) {
|
||||||
|
if(value) {
|
||||||
|
if(this.ptr) this.ptr.value = value;
|
||||||
|
else {
|
||||||
|
this.ptr = { value };
|
||||||
|
state.unshift(this.ptr);
|
||||||
|
}
|
||||||
|
} else if(this.ptr) {
|
||||||
|
var idx = state.indexOf(this.ptr);
|
||||||
|
if(idx >= 0) state.splice(idx, 1);
|
||||||
|
this.ptr = null;
|
||||||
|
}
|
||||||
|
settitle(state.map(x => x.value).join(' - '));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
unmounted() {
|
||||||
|
this.update();
|
||||||
|
},
|
||||||
|
render() {}
|
||||||
|
};
|
||||||
|
</script>
|
57
htdocs/Throbber.vue
Normal file
57
htdocs/Throbber.vue
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
<template>
|
||||||
|
<div class="throbber" :class="{ connected, idle }" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
div.throbber {
|
||||||
|
position: fixed;
|
||||||
|
top: 59px;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 0.5rem;
|
||||||
|
z-index: 1030;
|
||||||
|
background-color: #dc3545;
|
||||||
|
}
|
||||||
|
div.throbber.connected {
|
||||||
|
background-color: #0d6efd;
|
||||||
|
background-image: repeating-linear-gradient(
|
||||||
|
-45deg,
|
||||||
|
rgba(255, 255, 255, 0.1),
|
||||||
|
rgba(255, 255, 255, 0.1) 10px,
|
||||||
|
rgba(0, 0, 0, 0.1) 10px,
|
||||||
|
rgba(0, 0, 0, 0.1) 20px
|
||||||
|
);
|
||||||
|
background-size: 200% 200%;
|
||||||
|
animation: barberpole 60s linear infinite;
|
||||||
|
animation-direction: reverse;
|
||||||
|
}
|
||||||
|
div.throbber.idle {
|
||||||
|
visibility: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
transition: visibility 0s 0.5s, opacity 0.5s linear;
|
||||||
|
}
|
||||||
|
@keyframes barberpole {
|
||||||
|
100% {
|
||||||
|
background-position: 100% 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
client: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
connected() {
|
||||||
|
return (this.client) && (this.client.status) && (this.client.status.connected);
|
||||||
|
},
|
||||||
|
idle() {
|
||||||
|
return this.connected ? this.client.status.busy < 1 : false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
@ -6,16 +6,16 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody v-for="report in reports">
|
<tbody v-for="report in reports">
|
||||||
<template v-for="name in report">
|
<template v-for="name in report">
|
||||||
<tr v-if="names[name]">
|
<tr v-if="names[name]" @dblclick="toggle_filter_name(name)">
|
||||||
<th>{{name}}</th>
|
<th :class="{ filtered: name == filter_name }">{{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>
|
<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>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
</tbody>
|
</tbody>
|
||||||
<tbody>
|
<tbody>
|
||||||
<template v-for="name in names">
|
<template v-for="name in names">
|
||||||
<tr v-if="!names_excluded[name]">
|
<tr v-if="!names_excluded[name]" @dblclick="toggle_filter_name(name)">
|
||||||
<th>{{name}}</th>
|
<th :class="{ filtered: name == filter_name }">{{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>
|
<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>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
@ -37,16 +37,25 @@
|
|||||||
table.table-sticky tbody tr {
|
table.table-sticky tbody tr {
|
||||||
border-top: 1px dashed #dee2e6;
|
border-top: 1px dashed #dee2e6;
|
||||||
}
|
}
|
||||||
table.table-sticky td:nth-of-type(odd) {
|
table.table-sticky tbody tr:hover {
|
||||||
|
border: 1px solid #6c757d;
|
||||||
|
}
|
||||||
|
td:nth-of-type(odd) {
|
||||||
background-color: rgba(0, 0, 0, 0.05);
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
table.table-sticky tbody th, table.table-sticky th.name {
|
table.table-sticky tbody th, table.table-sticky th.name {
|
||||||
|
cursor: default;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding-left: 1rem;
|
padding-left: 1rem;
|
||||||
padding-right: 1rem;
|
padding-right: 1rem;
|
||||||
}
|
}
|
||||||
|
table.table-sticky th.filtered {
|
||||||
|
background-color: #6c757d;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
table.table-sticky th.date {
|
table.table-sticky th.date {
|
||||||
|
cursor: default;
|
||||||
font-size: 80%;
|
font-size: 80%;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@ -79,7 +88,7 @@
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { uniq, groupByArray, quantile_sorted } from './util.mjs';
|
import { uniq, groupByArray, quantile_sorted, inherit } from './util.mjs';
|
||||||
|
|
||||||
function isNumeric(x) {
|
function isNumeric(x) {
|
||||||
return (x !== '') && (x !== false) && (x !== null) && (!isNaN(x));
|
return (x !== '') && (x !== false) && (x !== null) && (!isNaN(x));
|
||||||
@ -108,7 +117,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function date_header(date) {
|
function date_header(date) {
|
||||||
var datestr = date.toLocaleDateString('en-CA');
|
var datestr = date.toLocaleDateString('sv-SE');
|
||||||
var timestr = date.toLocaleTimeString('en-GB');
|
var timestr = date.toLocaleTimeString('en-GB');
|
||||||
return {
|
return {
|
||||||
datestr, timestr,
|
datestr, timestr,
|
||||||
@ -133,23 +142,58 @@
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
},
|
},
|
||||||
|
constants: {
|
||||||
|
type: Object,
|
||||||
|
default: {}
|
||||||
|
},
|
||||||
|
calculations: {
|
||||||
|
type: Array,
|
||||||
|
default: []
|
||||||
|
},
|
||||||
reports: {
|
reports: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: []
|
default: []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {};
|
return {
|
||||||
|
filter_name: null
|
||||||
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
resultset_all() {
|
||||||
|
var res = this.resultset_calculated.length > 0 ? this.resultset.concat(this.resultset_calculated) : this.resultset;
|
||||||
|
return this.filter_name ? res.filter(x => x.name == this.filter_name) : res;
|
||||||
|
},
|
||||||
|
resultset_calculated() {
|
||||||
|
var self = this, snapshots = [], results = [], history, update, item;
|
||||||
|
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)).forEach(function(group) {
|
||||||
|
snapshots.push({ key: group.key, values: history = Object.assign(snapshots.length > 0 ? inherit(snapshots[snapshots.length - 1].values) : inherit(self.constants), update = group.values) });
|
||||||
|
history['Time'] = update['Time'] = { time: group.key, value: group.key };
|
||||||
|
for(var i = 0; i < self.calculations.length; ++i) {
|
||||||
|
var calculation = self.calculations[i], deps = calculation.deps;
|
||||||
|
for(var j = deps.length - 1, satisfied = true, updated = false; j >= 0; --j) if(!history[deps[j]]) { satisfied = false; break; }
|
||||||
|
else if(update[deps[j]]) updated = true;
|
||||||
|
if((satisfied) && (updated)) {
|
||||||
|
item = calculation.calc(...calculation.deps.map(x => history[x].value), history[calculation.name] && history[calculation.name].value);
|
||||||
|
if((item !== undefined) && (item !== null) && (item === item) && (item != 'NaN')) { // item === item if not NaN
|
||||||
|
results.push(history[calculation.name] = update[calculation.name] = item = Object.assign({ time: group.key, value: item }, calculation));
|
||||||
|
if((item.hasOwnProperty('rangeL')) && (item.value < item.rangeL)) item.flag = 'L';
|
||||||
|
else if((item.hasOwnProperty('rangeH')) && (item.value > item.rangeH)) item.flag = 'H';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return results;
|
||||||
|
},
|
||||||
groups() {
|
groups() {
|
||||||
if(this.daily) return groupByArray(this.resultset, x => new Date(x.time.getFullYear(), x.time.getMonth(), x.time.getDate())).map(function(group) {
|
if(this.daily) return groupByArray(this.resultset_all, 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: {}});
|
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)) {
|
for(var k in group.values) if(group.values.hasOwnProperty(k)) {
|
||||||
var items = group.values[k].sort((a, b) => a.time - b.time);
|
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 strings = items.map(item => item.time.toLocaleTimeString('en-GB') + ' • ' + item.value + (item.unit ? ' ' + item.unit : '') + (item.flag ? ' [' + item.flag + ']' : '') + (item.comment && item.comment.indexOf('\n') < 0 ? ' • ' + item.comment : ''));
|
||||||
var flags = uniq(items.map(item => item.flag).filter(x => x).map(x => x.charAt(0)));
|
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 comments = uniq(items.map(item => item.comment).filter(x => x && x.indexOf('\n') >= 0));
|
||||||
var numbers = uniq(items.map(item => item.value).filter(x => isNumeric(x)));
|
var numbers = uniq(items.map(item => item.value).filter(x => isNumeric(x)));
|
||||||
var min = Math.min.apply(null, numbers);
|
var min = Math.min.apply(null, numbers);
|
||||||
var max = Math.max.apply(null, numbers);
|
var max = Math.max.apply(null, numbers);
|
||||||
@ -165,10 +209,10 @@
|
|||||||
}
|
}
|
||||||
return group;
|
return group;
|
||||||
}).sort((a, b) => (a.key > b.key) - (a.key < b.key));
|
}).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));
|
else return groupByArray(this.resultset_all, 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() {
|
names() {
|
||||||
var res = uniq(this.resultset.map(x => x.name));
|
var res = uniq(this.resultset_all.map(x => x.name));
|
||||||
return res.reduce((acc, x) => (acc[x] = true, acc), res);
|
return res.reduce((acc, x) => (acc[x] = true, acc), res);
|
||||||
},
|
},
|
||||||
names_excluded() {
|
names_excluded() {
|
||||||
@ -177,12 +221,12 @@
|
|||||||
return res;
|
return res;
|
||||||
},
|
},
|
||||||
statistics() {
|
statistics() {
|
||||||
return statistics(this.resultset);
|
return statistics(this.resultset_all);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
async resultset(value) {
|
async resultset(value) {
|
||||||
this.$nextTick(() => this.$refs.headers ? this.$refs.headers.scrollIntoView({ block: 'nearest', inline: 'end' }) : null);
|
this.$nextTick(() => (this.$refs.headers) && (this.$refs.headers.length > 0) ? this.$refs.headers[this.$refs.headers.length - 1].scrollIntoView({ block: 'nearest', inline: 'end' }) : null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@ -198,6 +242,9 @@
|
|||||||
return res.join('\n');
|
return res.join('\n');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
toggle_filter_name(name) {
|
||||||
|
this.filter_name = this.filter_name != name ? name : null;
|
||||||
|
},
|
||||||
abnormal_ref(item) {
|
abnormal_ref(item) {
|
||||||
return (item) && (item.flag);
|
return (item) && (item.flag);
|
||||||
},
|
},
|
||||||
|
177
htdocs/ViewDocEdit.vue
Normal file
177
htdocs/ViewDocEdit.vue
Normal 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
79
htdocs/ViewDocNew.vue
Normal 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>
|
123
htdocs/ViewDocTitleLookup.vue
Normal file
123
htdocs/ViewDocTitleLookup.vue
Normal 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
98
htdocs/ViewDocView.vue
Normal 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>
|
64
htdocs/ViewLocationLookup.vue
Normal file
64
htdocs/ViewLocationLookup.vue
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
<template>
|
||||||
|
<ul class="nav nav-tabs">
|
||||||
|
<li v-if="label" class="nav-link disabled">{{label}}</li>
|
||||||
|
<li class="nav-item"><span class="nav-link" :class="{ active: view_new }" @click="view_new = true">Location</span></li>
|
||||||
|
<li v-if="view_new" class="nav-item d-flex justify-content-between align-items-center">
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text">🔎</span>
|
||||||
|
<input class="form-control" placeholder="Filter..." v-model="query" />
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item"><span class="nav-link" :class="{ active: !view_new }" @click="view_new = false">Encounter</span></li>
|
||||||
|
<li v-if="!view_new" class="nav-item d-flex justify-content-between align-items-center"><DateRangePicker v-else range="6M" direction="-1" v-model:date="visits_date_end" v-model:date_end="visits_date_begin" /></li>
|
||||||
|
</ul>
|
||||||
|
<div class="scroller">
|
||||||
|
<ViewLocationLookupNew v-if="view_new" :client="client" v-model:query="query" :modelValue="x_modelValue ? x_modelValue.IEN : null" @update:modelValue="x_modelValue = { IEN: $event }" />
|
||||||
|
<ViewLocationLookupExisting v-else :client="client" :dfn="dfn" :date_begin="visits_date_begin" :date_end="visits_date_end" v-model="x_modelValue" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
span.nav-link {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
div.scroller {
|
||||||
|
max-height: 25vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import DateRangePicker from './DateRangePicker.vue';
|
||||||
|
import ViewLocationLookupNew from './ViewLocationLookupNew.vue';
|
||||||
|
import ViewLocationLookupExisting from './ViewLocationLookupExisting.vue';
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
DateRangePicker, ViewLocationLookupNew, ViewLocationLookupExisting
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
client: Object,
|
||||||
|
dfn: String,
|
||||||
|
label: String,
|
||||||
|
modelValue: Object
|
||||||
|
},
|
||||||
|
emits: {
|
||||||
|
'update:modelValue': Object
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
view_new: false,
|
||||||
|
query: '',
|
||||||
|
visits_date_begin: now,
|
||||||
|
visits_date_end: now,
|
||||||
|
x_modelValue: this.modelValue
|
||||||
|
};
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
modelValue(value) { this.x_modelValue = value; },
|
||||||
|
x_modelValue(value) { this.$emit('update:modelValue', value); }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
80
htdocs/ViewLocationLookupExisting.vue
Normal file
80
htdocs/ViewLocationLookupExisting.vue
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
<template>
|
||||||
|
<table class="table table-striped">
|
||||||
|
<tbody v-if="resultset.length > 0">
|
||||||
|
<tr v-for="item in resultset" :class="{ 'table-active': (x_modelValue) && (x_modelValue.IEN) && (x_modelValue.datetime) && (item.location_ien == x_modelValue.IEN) && (item.datetime == x_modelValue.datetime) }" @click="x_modelValue = { IEN: item.location_ien, location: item.location, datetime: item.datetime, appointment_ien: item.IEN }">
|
||||||
|
<td>{{item.location}}</td>
|
||||||
|
<td>#{{item.location_ien}}</td>
|
||||||
|
<td style="text-align: right;">{{item.status}}</td>
|
||||||
|
<td style="text-align: right;">{{item.datestr}} {{item.timestr}}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
<tfoot v-else>
|
||||||
|
<tr><td style="text-align: center;">No encounters in range</td></tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
td {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.table-active, .table-active:nth-of-type(odd) > * {
|
||||||
|
color: #fff;
|
||||||
|
background-color: #0d6efd;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { strftime_vista, strptime_vista, debounce } from './util.mjs';
|
||||||
|
|
||||||
|
function date_down(d) {
|
||||||
|
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
function date_up(d) {
|
||||||
|
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59, 999);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
client: Object,
|
||||||
|
dfn: String,
|
||||||
|
date_begin: Date,
|
||||||
|
date_end: Date,
|
||||||
|
modelValue: Object
|
||||||
|
},
|
||||||
|
emits: {
|
||||||
|
'update:modelValue': Object
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
resultset: [],
|
||||||
|
x_modelValue: this.modelValue
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
params() {
|
||||||
|
return { dfn: this.dfn, date_begin: strftime_vista(date_down(this.date_begin)), date_end: strftime_vista(date_up(this.date_end)) };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
modelValue(value) { this.x_modelValue = value; },
|
||||||
|
x_modelValue(value) { this.$emit('update:modelValue', value); },
|
||||||
|
params: {
|
||||||
|
async handler(value) {
|
||||||
|
console.log(value);
|
||||||
|
try {
|
||||||
|
this.resultset = (await this.client.ORWCV_VST(value.dfn, value.date_begin, value.date_end, '')).map(item => Object.assign({
|
||||||
|
datestr: strptime_vista(item.datetime).toLocaleDateString('sv-SE'),
|
||||||
|
timestr: strptime_vista(item.datetime).toLocaleTimeString('en-GB'),
|
||||||
|
location_ien: item.apptinfo.split(';')[2]
|
||||||
|
}, item)).reverse();
|
||||||
|
} catch(ex) {
|
||||||
|
this.resultset = [];
|
||||||
|
console.warn(ex);
|
||||||
|
}
|
||||||
|
}, immediate: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
124
htdocs/ViewLocationLookupNew.vue
Normal file
124
htdocs/ViewLocationLookupNew.vue
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="input" class="input-group">
|
||||||
|
<span class="input-group-text">🔎</span>
|
||||||
|
<input class="form-control" placeholder="Filter..." v-model="query_raw" />
|
||||||
|
</div>
|
||||||
|
<div class:scroller="input" 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,
|
||||||
|
query: String,
|
||||||
|
input: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
modelValue: String
|
||||||
|
},
|
||||||
|
emits: {
|
||||||
|
'update:modelValue': String
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
resultset: [],
|
||||||
|
query_raw: this.query,
|
||||||
|
query_view: '',
|
||||||
|
has_more: false,
|
||||||
|
is_loading: true,
|
||||||
|
observer_bottom: null,
|
||||||
|
x_modelValue: this.modelValue
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
params() {
|
||||||
|
return { query: this.query_view.length >= 3 ? this.query_view : '' };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
modelValue(value) { this.x_modelValue = value; },
|
||||||
|
x_modelValue(value) { this.$emit('update:modelValue', value); },
|
||||||
|
query(value) { this.query_raw = value; },
|
||||||
|
query_raw(value) {
|
||||||
|
this.$emit('update:query', value);
|
||||||
|
this.query_sync(value);
|
||||||
|
},
|
||||||
|
query_view: {
|
||||||
|
async handler(value) {
|
||||||
|
this.is_loading = true;
|
||||||
|
this.has_more = false;
|
||||||
|
try {
|
||||||
|
if(value.length >= 3) {
|
||||||
|
var batch = await this.client.ORWU1_NEWLOC(value.slice(0, -1) + String.fromCharCode(value.charCodeAt(value.length - 1) - 1), 1);
|
||||||
|
this.resultset = batch.filter(x => x.name.startsWith(value));
|
||||||
|
} else this.resultset = await this.client.ORWU1_NEWLOC('', 1);
|
||||||
|
this.has_more = this.resultset.length > 0;
|
||||||
|
} catch(ex) {
|
||||||
|
this.resultset = [];
|
||||||
|
this.has_more = false;
|
||||||
|
} finally {
|
||||||
|
this.is_loading = false;
|
||||||
|
this.$refs.scroller.scrollTo(0, 0);
|
||||||
|
}
|
||||||
|
}, immediate: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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.ORWU1_NEWLOC(this.resultset[this.resultset.length - 1].name, 1);
|
||||||
|
if(this.query_view.length >= 3) 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.query_sync = debounce(function(value) { this.query_view = value.replace(/^\s+|\s+$/g, '').replace(/\s+/g, ' ').toUpperCase(); }, 500);
|
||||||
|
},
|
||||||
|
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>
|
71
htdocs/ViewOrderDialog.vue
Normal file
71
htdocs/ViewOrderDialog.vue
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
<template>
|
||||||
|
<ViewOrderDialogLab v-if="x_form_id == constants.OD_LAB" :client="client" :ien="ien" :dlgdef="dlgdef" :bldqrsp="bldqrsp" @submit="submit" />
|
||||||
|
<ViewOrderDialogMedOutpt v-else-if="x_form_id == constants.OD_MEDOUTPT" :client="client" :ien="ien" :dlgdef="dlgdef" :bldqrsp="bldqrsp" @submit="submit" />
|
||||||
|
<ViewOrderDialogGeneric v-else-if="x_form_id == constants.OD_RTC" :client="client" :ien="ien" :dfn="dfn" :location_ien="location_ien" :dlgname="dlgname" :dlgdef="dlgdef" @submit="submit" />
|
||||||
|
<ViewOrderDialogGeneric v-else-if="x_form_id" :client="client" :ien="ien" :dfn="dfn" :location_ien="location_ien" :dlgname="dlgname" :dlgdef="dlgdef" @submit="submit" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import * as constants from './constants.mjs';
|
||||||
|
import ViewOrderDialogLab from './ViewOrderDialogLab.vue';
|
||||||
|
import ViewOrderDialogMedOutpt from './ViewOrderDialogMedOutpt.vue';
|
||||||
|
import ViewOrderDialogGeneric from './ViewOrderDialogGeneric.vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
ViewOrderDialogLab, ViewOrderDialogMedOutpt, ViewOrderDialogGeneric
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
client: Object,
|
||||||
|
ien: String,
|
||||||
|
form_id: String,
|
||||||
|
dfn: String,
|
||||||
|
location_ien: String
|
||||||
|
},
|
||||||
|
emits: {
|
||||||
|
'update:form_id': Number
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
constants,
|
||||||
|
dlgname: null,
|
||||||
|
dlgdef: null,
|
||||||
|
prompts: null,
|
||||||
|
inputs: null,
|
||||||
|
bldqrsp: null,
|
||||||
|
odslct: null,
|
||||||
|
x_form_id: this.form_id
|
||||||
|
};
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async submit(payload) {
|
||||||
|
if((this.ien) && (this.dfn) && (payload)) {
|
||||||
|
var user_ien = (await this.client.authinfo()).duz;
|
||||||
|
var dgrp = await this.client.ORWDX_DGRP(this.dlgname.BaseDialogName);
|
||||||
|
var res = await client.ORWDX_SAVE(this.dfn, 0/*user_ien*/, 0/*location_ien*/, this.dlgname.BaseDialogName, dgrp, this.dlgname.BaseDialogIEN, ''/*order_ifn*/, payload, '', '', '', 0);
|
||||||
|
console.log(res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.$watch(
|
||||||
|
() => (this.client, this.ien, {}),
|
||||||
|
async () => {
|
||||||
|
if((this.client) && (this.ien)) {
|
||||||
|
var authinfo = await this.client.authinfo();
|
||||||
|
var user_ien = authinfo && authinfo.success ? authinfo.duz : '';
|
||||||
|
this.dlgname = await this.client.ORWDXM_DLGNAME(this.ien);
|
||||||
|
if(this.dlgname.BaseDialogIEN != this.ien) console.warn('IEN =', this.ien, '|', 'BaseDialogIEN =', this.dlgname.BaseDialogIEN);
|
||||||
|
this.dlgdef = await this.client.ORWDX_DLGDEF(this.dlgname.BaseDialogName);
|
||||||
|
if(this.bldqrsp = (await this.client.ORWDXM1_BLDQRSP(this.ien, '^^' + user_ien + '^^^^;;;^^^^', 0, 0))[0]) this.$emit('update:form_id', this.x_form_id = +this.bldqrsp.FormID);
|
||||||
|
} else {
|
||||||
|
this.dlgname = this.dlgdef = this.bldqrsp = null;
|
||||||
|
this.x_form_id = +this.form_id;
|
||||||
|
}
|
||||||
|
if(this.x_form_id == constants.OD_RTC) this.odslct = await client.ORWDSD1_ODSLCT(0, 0);
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
118
htdocs/ViewOrderDialogGeneric.vue
Normal file
118
htdocs/ViewOrderDialogGeneric.vue
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="prompts">
|
||||||
|
<div class="row">
|
||||||
|
<template v-for="item in prompts">
|
||||||
|
<template v-if="(item.hid == '1') || (item.type == 'H')"></template>
|
||||||
|
<div v-else-if="(odslct) && (odslct[item.id]) && (odslct[item.id].items)" class="col-12">
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">{{item.prompt.replace(/\:\s*$/, '')}}</div>
|
||||||
|
<ul class="list-group list-group-flush" style="max-height: 25vh; overflow-y: auto;"><li v-for="item in odslct[item.id].items" class="list-group-item"><input class="form-check-input me-1" type="checkbox" /> {{item}}</li></ul>
|
||||||
|
<div v-if="item.help" class="card-footer form-text">{{item.help}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="item.type == 'P'" class="col-12">
|
||||||
|
<div class="card mb-3">
|
||||||
|
<ViewLocationLookup :client="client" :dfn="dfn" :label="item.prompt.replace(/\:\s*$/, '')" />
|
||||||
|
<div v-if="item.help" class="form-text">{{item.help}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="item.type == 'Y'" class="col col-12 col-md-6 col-lg-4 col-xl-3">
|
||||||
|
<div class="form-check form-switch mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" role="switch" v-model="inputs[item.id]" />
|
||||||
|
<label class="form-check-label">{{item.prompt.replace(/\:\s*$/, '')}}</label>
|
||||||
|
<div v-if="item.help" class="form-text">{{item.help}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="item.type == 'R'" class="col col-12 col-md-6 col-lg-4 col-xl-3">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">{{item.prompt.replace(/\:\s*$/, '')}}</label>
|
||||||
|
<DateTimePicker dateonly v-model="inputs[item.id]" />
|
||||||
|
<div v-if="item.help" class="form-text">{{item.help}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="item.type == 'N'" class="col col-12 col-md-6 col-lg-4 col-xl-3">
|
||||||
|
<div class="form-floating mb-3">
|
||||||
|
<input type="number" class="form-control" :placeholder="item.default || ' '" v-model="inputs[item.id]" />
|
||||||
|
<label>{{item.prompt.replace(/\:\s*$/, '')}}</label>
|
||||||
|
<div v-if="item.help" class="form-text">{{item.help}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="item.type == 'W'" class="col-12">
|
||||||
|
<div class="card mb-3" style="background-color: #fdfbd7;">
|
||||||
|
<div class="card-header">{{item.prompt.replace(/\:\s*$/, '')}}</div>
|
||||||
|
<div class="card-body" style="font-family: monospace; white-space: pre;">{{item.detail}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="col col-12">
|
||||||
|
<div class="form-floating mb-3">
|
||||||
|
<input type="text" class="form-control" :placeholder="item.default || ' '" :disabled="item.type == 'P'" v-model="inputs[item.id]" />
|
||||||
|
<label>{{item.prompt.replace(/\:\s*$/, '')}}</label>
|
||||||
|
<div v-if="item.help" class="form-text">{{item.help}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div v-if="(odslct) && (odslct.Info) && (odslct.Info.text)" class="card mb-3" style="background-color: #fdfbd7;"><div class="card-body" style="font-family: monospace; white-space: pre;">{{odslct.Info.text}}</div></div>
|
||||||
|
<div class="btn-group mb-3">
|
||||||
|
<button type="button" class="btn btn-primary" @click="e => $emit('submit', output)">Submit</button>
|
||||||
|
<button type="button" class="btn btn-danger" @click="e => $emit('cancel')">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import ViewLocationLookup from './ViewLocationLookup.vue';
|
||||||
|
import DateTimePicker from './DateTimePicker.vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
ViewLocationLookup, DateTimePicker
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
client: Object,
|
||||||
|
ien: String,
|
||||||
|
dfn: String,
|
||||||
|
location_ien: String,
|
||||||
|
dlgname: Object,
|
||||||
|
dlgdef: Object,
|
||||||
|
modelValue: Object
|
||||||
|
},
|
||||||
|
emits: [
|
||||||
|
'submit',
|
||||||
|
'cancel',
|
||||||
|
'update:modelValue'
|
||||||
|
],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
prompts: null,
|
||||||
|
inputs: null,
|
||||||
|
odslct: null
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
output() {
|
||||||
|
if((this.dlgdef) && (this.prompts) && (this.inputs)) {
|
||||||
|
return this.dlgdef.reduce((acc, val) => (acc['"' + val.promptIEN + '","1"'] = this.inputs[val.promptID] || this.prompts[val.promptID].idflt, acc), {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.$watch(
|
||||||
|
() => (this.client, this.ien, this.dlgdef, {}),
|
||||||
|
async () => {
|
||||||
|
var bldqrsp, loadrsp;
|
||||||
|
if((this.client) && (this.ien) && (this.dlgdef) && (bldqrsp = (await this.client.ORWDXM1_BLDQRSP(this.ien, '^^^^^^;;;^^^^', 0, 0))[0])) {
|
||||||
|
this.inputs = this.dlgdef.reduce((acc, val) => (acc[val.promptID] = null, acc), {});
|
||||||
|
this.prompts = await client.ORWDXM_PROMPTS(this.dlgname.BaseDialogIEN);
|
||||||
|
if(this.prompts) Object.assign(this.inputs, this.prompts.reduce((acc, val) => (acc[val.id] = val.default, acc), {}));
|
||||||
|
if((bldqrsp.ResponseID) && (bldqrsp.ResponseID != '0')) {
|
||||||
|
loadrsp = await this.client.ORWDX_LOADRSP(bldqrsp.ResponseID, 0, 0);
|
||||||
|
if(loadrsp) Object.assign(this.inputs, loadrsp.reduce((acc, val) => (acc[val.promptID] = val.iValue, acc), {}));
|
||||||
|
}
|
||||||
|
} else this.prompts = this.inputs = null;
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
161
htdocs/ViewOrderDialogLab.vue
Normal file
161
htdocs/ViewOrderDialogLab.vue
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="options_facility && inputs" class="row">
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<ViewOrderableLookup :client="client" label="Lab tests" xref="S.LAB" :qocall="ien" v-model="inputs.ORDERABLE" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<template v-if="options_test">
|
||||||
|
<div v-if="(options_test['CollSamp']) && (options_test['CollSamp'].items)" class="form-floating mb-3">
|
||||||
|
<select class="form-select" v-model="inputs.SAMPLE"><option v-for="item in options_test['CollSamp'].items" :value="item.SampIEN">{{item.SampName}}</option></select>
|
||||||
|
<label>Collect Sample</label>
|
||||||
|
</div>
|
||||||
|
<div v-if="(options_test['Specimens']) && (options_test['Specimens'].items)" class="form-floating mb-3">
|
||||||
|
<select class="form-select" v-model="inputs.SPECIMEN"><option v-for="item in options_test['Specimens'].items" :value="item.value">{{item.text}}</option></select>
|
||||||
|
<label>Specimen</label>
|
||||||
|
</div>
|
||||||
|
<div v-if="(options_test['Urgencies']) && (options_test['Urgencies'].items)" class="form-floating mb-3">
|
||||||
|
<select class="form-select" v-model="inputs.URGENCY"><option v-for="item in options_test['Urgencies'].items" :value="item.value">{{item.text}}</option></select>
|
||||||
|
<label>Urgency</label>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
<div class="form-floating mb-3">
|
||||||
|
<select class="form-select" v-model="inputs.COLLECT"><option v-for="item in options_facility['Collection Types'].items" :value="item.value" :selected="item.default">{{item.text}}</option></select>
|
||||||
|
<label>Collection Type</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
<div class="mb-3">
|
||||||
|
<template v-if="(inputs.COLLECT == 'LC') && (options_facility['Lab Collection Times'])">
|
||||||
|
<Autocomplete label="Lab Collection Time" :items="Object.keys(mapping_START)" v-model="inputs.START" />
|
||||||
|
</template>
|
||||||
|
<template v-else-if="(inputs.COLLECT == 'WC') && (options_facility['Ward Collection Times'])">
|
||||||
|
<Autocomplete label="Ward Collection Time" :items="Object.keys(mapping_START)" v-model="inputs.START" />
|
||||||
|
</template>
|
||||||
|
<template v-else-if="(inputs.COLLECT == 'SP') && (options_facility['Send Patient Times'])">
|
||||||
|
<Autocomplete label="Send Patient Time" :items="Object.keys(mapping_START)" v-model="inputs.START" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<label>Collection Date/Time</label>
|
||||||
|
<DateTimePicker v-model="inputs.START" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
<div class="form-floating mb-3">
|
||||||
|
<select class="form-select" v-model="inputs.SCHEDULE"><option v-for="item in options_facility['Schedules'].items" :value="item.value" :selected="item.default">{{item.text}}</option></select>
|
||||||
|
<label>How Often?</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
<div class="form-floating mb-3" title="Enter a number of days, or an 'X' followed by a number of times.">
|
||||||
|
<input type="text" class="form-control" placeholder=" " v-model="inputs.DAYS" />
|
||||||
|
<label>How Long?</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-9">
|
||||||
|
<div v-if="preview" class="card mb-3" style="background-color: #fdfbd7;"><div class="card-body" style="font-family: monospace; white-space: pre-wrap;">{{preview}}</div></div>
|
||||||
|
<div v-if="(options_test) && (options_test['CollSamp']) && (options_test['CollSamp'].text)" class="card mb-3" style="background-color: #fdfbd7;"><div class="card-body" style="font-family: monospace; white-space: pre-wrap;">{{options_test['CollSamp'].text}}</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
<div class="btn-group-vertical mb-3" style="width: 100%;">
|
||||||
|
<button type="button" class="btn btn-primary" @click="e => $emit('submit', output)">Submit</button>
|
||||||
|
<button type="button" class="btn btn-danger" @click="e => $emit('cancel')">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { debounce } from './util.mjs';
|
||||||
|
|
||||||
|
import ViewOrderableLookup from './ViewOrderableLookup.vue';
|
||||||
|
import Autocomplete from './Autocomplete.vue';
|
||||||
|
import DateTimePicker from './DateTimePicker.vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
ViewOrderableLookup, Autocomplete, DateTimePicker
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
client: Object,
|
||||||
|
ien: String,
|
||||||
|
dlgdef: Object,
|
||||||
|
bldqrsp: Object,
|
||||||
|
modelValue: Object
|
||||||
|
},
|
||||||
|
emits: [
|
||||||
|
'submit',
|
||||||
|
'cancel',
|
||||||
|
'update:modelValue'
|
||||||
|
],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
inputs: null,
|
||||||
|
options_facility: null,
|
||||||
|
options_test: null
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
mapping_START() {
|
||||||
|
if((this.inputs) && (this.inputs.COLLECT) && (this.options_facility)) {
|
||||||
|
var mapping = this.inputs.COLLECT == 'LC' ? this.options_facility['Lab Collection Times'] : this.inputs.COLLECT == 'WC' ? this.options_facility['Ward Collection Times'] : this.inputs.COLLECT == 'SP' ? this.options_facility['Send Patient Times'] : null;
|
||||||
|
if((mapping) && (mapping.items)) return mapping.items.reduce((acc, val) => (acc[val.text] = val.value.substring(1), acc), {});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
preview() {
|
||||||
|
if((this.options_test) && (this.inputs)) {
|
||||||
|
var res = [], inputs = this.inputs, x;
|
||||||
|
if(this.options_test['Test Name']) res.push(this.options_test['Test Name'].default);
|
||||||
|
if((inputs.SAMPLE) && (this.options_test['CollSamp']) && (x = this.options_test['CollSamp'].items.find(x => x.SampIEN == inputs.SAMPLE))) res.push(x.SampName.replace(/^\s+|\s+$/g, ''));
|
||||||
|
if((inputs.SPECIMEN) && (this.options_test['Specimens']) && (x = this.options_test['Specimens'].items.find(x => x.value == inputs.SPECIMEN))) res.push(x.text.replace(/^\s+|\s+$/g, ''));
|
||||||
|
if(inputs.COLLECT) res.push(inputs.COLLECT);
|
||||||
|
return res.join(' ');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
output() {
|
||||||
|
if((this.dlgdef) && (this.inputs)) {
|
||||||
|
var inputs = Object.assign({}, this.inputs);
|
||||||
|
if((this.mapping_START) && (inputs.START) && (this.mapping_START.hasOwnProperty(inputs.START))) inputs.START = this.mapping_START[inputs.START];
|
||||||
|
return this.dlgdef.reduce((acc, val) => (acc['"' + val.promptIEN + '","1"'] = inputs[val.promptID], acc), {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.$watch(
|
||||||
|
() => this.client,
|
||||||
|
async () => {
|
||||||
|
this.options_facility = this.client ? await this.client.ORWDLR32_DEF(0, 0) : null;
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
this.$watch(
|
||||||
|
() => (this.client, this.ien, this.dlgdef, {}),
|
||||||
|
async () => {
|
||||||
|
if((this.client) && (this.ien) && (this.dlgdef) && (this.bldqrsp)) {
|
||||||
|
this.inputs = this.dlgdef.reduce((acc, val) => (acc[val.promptID] = null, acc), {});
|
||||||
|
if((this.bldqrsp.ResponseID) && (this.bldqrsp.ResponseID != '0')) {
|
||||||
|
var loadrsp = await this.client.ORWDX_LOADRSP(this.bldqrsp.ResponseID, 0, 0);
|
||||||
|
if(loadrsp) Object.assign(this.inputs, loadrsp.reduce((acc, val) => (acc[val.promptID] = val.iValue, acc), {}));
|
||||||
|
}
|
||||||
|
} else this.inputs = null;
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
this.$watch(
|
||||||
|
() => (this.client, this.inputs && this.inputs.ORDERABLE, {}),
|
||||||
|
async () => {
|
||||||
|
this.options_test = (this.client) && (this.inputs) && (this.inputs.ORDERABLE) ? await this.client.ORWDLR32_LOAD(this.inputs.ORDERABLE) : null;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
this.$watch(
|
||||||
|
() => { return { dlgdef: this.dlgdef, inputs: this.inputs }; },
|
||||||
|
debounce(() => this.$emit('update:modelValue', this.output), 100),
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
295
htdocs/ViewOrderDialogMedOutpt.vue
Normal file
295
htdocs/ViewOrderDialogMedOutpt.vue
Normal file
@ -0,0 +1,295 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="options_med && inputs" class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="mb-3">
|
||||||
|
<ViewOrderableRawLookup :client="client" label="Orderable" dgnm="O RX" :query="options_med ? options_med.Medication.default.text : null" v-model="common.ORDERABLE" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template v-for="(instance, idx) in inputs" :key="instance">
|
||||||
|
<ViewOrderDialogMedOutptInstance :multi="inputs.length > 1" :more="idx + 1 < inputs.length" :dlgdef="dlgdef" :options_med="options_med" :options_schedule="options_schedule" :loadrsp_group="loadrsp_groups[idx]" v-model="inputs[idx]" @add="e => inputs.splice(idx + 1, 0, Object.assign({}, instance))" @remove="e => inputs.splice(idx, 1)" />
|
||||||
|
</template>
|
||||||
|
<div class="col-4">
|
||||||
|
<div class="mb-3">
|
||||||
|
<Autocomplete label="Indication" :items="options_med['Indication'].items" v-model="common.INDICATION" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-8">
|
||||||
|
<div class="form-floating mb-3">
|
||||||
|
<input type="text" class="form-control" placeholder=" " v-model="common.COMMENT" />
|
||||||
|
<label>Comment</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-2">
|
||||||
|
<div class="form-floating mb-3">
|
||||||
|
<input type="number" class="form-control" placeholder=" " v-model="common.SUPPLY" />
|
||||||
|
<label>Days Supply</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-2">
|
||||||
|
<div class="form-floating mb-3">
|
||||||
|
<input type="number" class="form-control" placeholder=" " v-model="common.QTY" />
|
||||||
|
<label>{{base_shortform ? 'Qty (in ' + base_shortform + ')' : 'Quantity'}}</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-2">
|
||||||
|
<div class="form-floating mb-3">
|
||||||
|
<input type="number" class="form-control" placeholder=" " v-model="common.REFILLS" />
|
||||||
|
<label>Refills</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
<div class="form-floating mb-3">
|
||||||
|
<select class="form-select" v-model="common.PICKUP">
|
||||||
|
<option value="M">Mail</option>
|
||||||
|
<option value="W">Window</option>
|
||||||
|
</select>
|
||||||
|
<label>Pickup</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
<div v-if="options_fill && options_fill.Priority && options_fill.Priority.items" class="form-floating mb-3">
|
||||||
|
<select class="form-select" v-model="common.URGENCY"><option v-for="item in options_fill.Priority.items" :value="item.value">{{item.text}}</option></select>
|
||||||
|
<label>Priority</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-10">
|
||||||
|
<div class="card mb-3" style="background-color: #fdfbd7;"><div class="card-body" style="font-family: monospace; white-space: pre-wrap;">{{preview}}</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-2">
|
||||||
|
<div class="btn-group-vertical mb-3" style="width: 100%;">
|
||||||
|
<button type="button" class="btn btn-primary" @click="e => $emit('submit', output)">Submit</button>
|
||||||
|
<button type="button" class="btn btn-danger" @click="e => $emit('cancel')">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { debounce, groupByArray } from './util.mjs';
|
||||||
|
import { translate } from './numberwords.mjs';
|
||||||
|
|
||||||
|
import ViewOrderableRawLookup from './ViewOrderableRawLookup.vue';
|
||||||
|
import Autocomplete from './Autocomplete.vue';
|
||||||
|
import DateTimePicker from './DateTimePicker.vue';
|
||||||
|
import ViewOrderDialogMedOutptInstance from './ViewOrderDialogMedOutptInstance.vue';
|
||||||
|
|
||||||
|
const C_COMMON = ['ORDERABLE', 'INDICATION', 'COMMENT', 'SUPPLY', 'QTY', 'REFILLS', 'PICKUP', 'URGENCY'];
|
||||||
|
|
||||||
|
const C_DAYS = {
|
||||||
|
'': 1,
|
||||||
|
'MONTH': 30,
|
||||||
|
'WEEK': 7,
|
||||||
|
'DAY': 1,
|
||||||
|
'HOUR': 1/24,
|
||||||
|
'MINUTE': 1/24/60
|
||||||
|
};
|
||||||
|
|
||||||
|
const C_WP = { '15': true, '758': true, '763': true };
|
||||||
|
|
||||||
|
function input_dose_parse(value) {
|
||||||
|
value = (value) && (value.indexOf('&') >= 0) ? value.split('&') : [parseFloat(value) || null, value ? value.replace(/^\s*([+-]?(?:\d+|\d+\.\d+))\s*|\s+$/g, '').toUpperCase() || null : null, null, null, value, null, null, null];
|
||||||
|
value.dose = value[0];
|
||||||
|
value.unit = value[1];
|
||||||
|
value.count = value[2];
|
||||||
|
value.form = value[3];
|
||||||
|
value.text = value[4];
|
||||||
|
value.id = value[5];
|
||||||
|
value.base_dose = value[6];
|
||||||
|
value.base_unit = value[7];
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function greatest_common_base(values, products) {
|
||||||
|
for(var i = 0; i < values.length; ++i) {
|
||||||
|
var value = values[i];
|
||||||
|
if((isNaN(value.dose)) || (isNaN(parseFloat(value.dose)))) return;
|
||||||
|
products = products.filter(x => (x.dose) && (x.unit == value.unit) && ((Math.abs((value.dose/x.dose)%1) < 0.001) || ((x.split == '1') && (Math.abs((value.dose/x.dose)%1 - 0.5) < 0.001))));
|
||||||
|
if(products.length < 1) return;
|
||||||
|
}
|
||||||
|
return products.sort((a, b) => b.dose - a.dose)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
ViewOrderableRawLookup, Autocomplete, DateTimePicker, ViewOrderDialogMedOutptInstance
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
client: Object,
|
||||||
|
ien: String,
|
||||||
|
dlgdef: Object,
|
||||||
|
bldqrsp: Object,
|
||||||
|
modelValue: Object
|
||||||
|
},
|
||||||
|
emits: [
|
||||||
|
'submit',
|
||||||
|
'cancel',
|
||||||
|
'update:modelValue'
|
||||||
|
],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
loadrsp: null,
|
||||||
|
loadrsp_groups: [],
|
||||||
|
inputs: null,
|
||||||
|
options_schedule: null,
|
||||||
|
options_med: null,
|
||||||
|
lastorderable: null,
|
||||||
|
common: {}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
options_med_route() {
|
||||||
|
if((this.options_med) && (this.options_med['Route'])) {
|
||||||
|
var opt, rsp, res = this.options_med['Route'].items.slice();
|
||||||
|
res.mapping = res.reduce((acc, val) => (acc[val.value] = val, acc), {});
|
||||||
|
if((this.loadrsp) && (rsp = this.loadrsp['ROUTE']) && (!res.find(x => x.value == rsp.iValue))) res.push({ value: rsp.iValue, text: rsp.eValue, abbr: rsp.eValue, sig: rsp.eValue });
|
||||||
|
return res;
|
||||||
|
} else return [];
|
||||||
|
},
|
||||||
|
input_doses() {
|
||||||
|
return this.inputs ? this.inputs.map(x => input_dose_parse(x.DOSE)) : [];
|
||||||
|
},
|
||||||
|
base_product() {
|
||||||
|
return this.options_med ? greatest_common_base(this.input_doses, this.options_med['Dispense'].items) : undefined;
|
||||||
|
},
|
||||||
|
base_form() {
|
||||||
|
try {
|
||||||
|
return this.options_med['AllDoses'].items.find(x => x.id == this.base_product.id).dosefields.split('&')[3];
|
||||||
|
} catch(ex) {}
|
||||||
|
},
|
||||||
|
base_shortform() {
|
||||||
|
try {
|
||||||
|
var dose_unit = this.base_product.dose + this.base_product.unit;
|
||||||
|
return this.options_med['Dosage'].items.find(x => x.text == dose_unit).form;
|
||||||
|
} catch(ex) {
|
||||||
|
return this.base_form;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sig() {
|
||||||
|
if((this.options_med) && (this.inputs)) {
|
||||||
|
var res = [];
|
||||||
|
var verb = this.options_med['Verb'].default;
|
||||||
|
var input_doses = this.input_doses;
|
||||||
|
var base_product = this.base_product;
|
||||||
|
var base_form = this.base_form;
|
||||||
|
for(var i = 0; i < this.inputs.length; ++i) {
|
||||||
|
var input = this.inputs[i], input_route = input.ROUTE, input_dose = input_doses[i];
|
||||||
|
var route = (this.options_med_route.find(x => x.value == input_route) || { sig: input_route }).sig;
|
||||||
|
var prep = this.options_med_route.mapping[input_route] ? this.options_med['Preposition'].default : null;
|
||||||
|
var schedule = (this.options_schedule) && (this.options_schedule[input.SCHEDULE]) ? this.options_schedule[input.SCHEDULE].text : input.SCHEDULE;
|
||||||
|
res.push((input_dose ? verb + ' ' + (base_product ? translate(input_dose.dose/base_product.dose).toUpperCase() + ' ' + (base_form || 'UNIT') + (input_dose.dose/base_product.dose >= 2 ? 'S' : '') : input_dose.text) : '') + (route ? ' ' + (prep ? prep + ' ' : '') + route : '') + (schedule ? ' ' + schedule : '') + (input.SCHTYPE == 'P' ? ' AS NEEDED' : '') + (input.DAYS ? ' FOR ' + input.DAYS : '') + (i + 1 >= this.inputs.length ? '' : (' ' + (input.CONJ == 'T' ? 'THEN' : input.CONJ == 'A' ? 'AND' : 'OR'))));
|
||||||
|
}
|
||||||
|
return res.join(' ');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
preview() {
|
||||||
|
var base_product = this.base_product;
|
||||||
|
var product = base_product ? base_product.text + '\n' : this.options_med ? this.options_med['Medication'].default.text + '\n' : '';
|
||||||
|
return product + (this.sig ? this.sig + (this.common.COMMENT ? ' ' + this.common.COMMENT : '') + '\n' : '') + 'Quantity: ' + (+this.common.QTY) + ' Refills: ' + (+this.common.REFILLS) + (this.common.INDICATION ? '\nIndication: ' + this.common.INDICATION : '');
|
||||||
|
},
|
||||||
|
output() {
|
||||||
|
if((this.dlgdef) && (this.inputs)) {
|
||||||
|
var res = {};
|
||||||
|
var inputs = this.inputs.length > 0 ? this.inputs.slice() : [{}];
|
||||||
|
inputs[0] = this.common ? Object.assign({}, inputs[0], this.common) : Object.assign({}, inputs[0]);
|
||||||
|
inputs[0].SIG = this.sig;
|
||||||
|
if(this.base_product) {
|
||||||
|
inputs[0].STRENGTH = this.base_product.dose + this.base_product.unit;
|
||||||
|
inputs[0].DRUG = this.base_product.id;
|
||||||
|
}
|
||||||
|
for(var i = 0; i < inputs.length; ++i) {
|
||||||
|
var seq = i + 1, instance = inputs[i];
|
||||||
|
for(var j = 0, desc, val; j < this.dlgdef.length; ++j) {
|
||||||
|
desc = this.dlgdef[j];
|
||||||
|
if(val = instance[desc.promptID]) {
|
||||||
|
if(C_WP[desc.promptIEN]) {
|
||||||
|
res['"' + desc.promptIEN + '","' + seq + '"'] = 'ORDIALOG("WP",' + desc.promptIEN + ',' + seq + ')';
|
||||||
|
res['"WP","' + desc.promptIEN + '","' + seq + '","1","0"'] = instance[desc.promptID];
|
||||||
|
} else res['"' + desc.promptIEN + '","' + seq + '"'] = instance[desc.promptID];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.$watch(
|
||||||
|
() => this.client,
|
||||||
|
async () => {
|
||||||
|
if(this.client) {
|
||||||
|
this.options_schedule = await this.client.ORWDPS1_SCHALL('', 0);
|
||||||
|
this.options_fill = await this.client.ORWDPS1_ODSLCT('O', '', '');
|
||||||
|
} else this.options_schedule = this.options_fill = null;
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
this.$watch(
|
||||||
|
() => (this.client, this.dlgdef, this.bldqrsp, this.options_fill, {}),
|
||||||
|
async () => {
|
||||||
|
if((this.client) && (this.dlgdef) && (this.bldqrsp)) {
|
||||||
|
this.inputs = [this.dlgdef.reduce((acc, val) => (acc[val.promptID] = acc[val.promptID], acc), { ORDERABLE: this.common ? this.common.ORDERABLE : null })];
|
||||||
|
if(this.options_fill) {
|
||||||
|
if((this.options_fill.Pickup) && (this.options_fill.Pickup.default)) this.inputs.PICKUP = this.options_fill.Pickup.default.value;
|
||||||
|
if((this.options_fill.Refills) && (this.options_fill.Refills.default)) this.inputs.REFILLS = this.options_fill.Refills.default.value;
|
||||||
|
if((this.options_fill.Priority) && (this.options_fill.Priority.default)) this.inputs.URGENCY = this.options_fill.Priority.default.value;
|
||||||
|
}
|
||||||
|
this.loadrsp_groups = [];
|
||||||
|
if((this.bldqrsp.ResponseID) && (this.bldqrsp.ResponseID != '0')) {
|
||||||
|
this.loadrsp = await this.client.ORWDX_LOADRSP(this.bldqrsp.ResponseID, 0, 0);
|
||||||
|
if((this.loadrsp) && (this.loadrsp.length > 0)) {
|
||||||
|
this.loadrsp_groups = groupByArray(this.loadrsp, 'instance').sort((a, b) => a.key - b.key).map(x => x.values.reduce((acc, val) => (acc[val.promptID] = val, acc), x.values));
|
||||||
|
Object.assign(this.inputs[0], this.loadrsp_groups[0].reduce((acc, val) => (acc[val.promptID] = val.iValue != '^WP^' ? val.iValue : val.eValue, acc), {}));
|
||||||
|
for(var i = 1; i < this.loadrsp_groups.length; ++i) this.inputs.push(this.loadrsp_groups[i].reduce((acc, val) => (acc[val.promptID] = val.iValue != '^WP^' ? val.iValue : val.eValue, acc), {}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.common = C_COMMON.reduce((acc, val) => (acc[val] = this.inputs[0][val], acc), {});
|
||||||
|
} else {
|
||||||
|
this.inputs = this.loadrsp = null;
|
||||||
|
this.loadrsp_groups = [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
this.$watch(
|
||||||
|
() => (this.client, (this.common) && (this.common.ORDERABLE), {}),
|
||||||
|
async () => {
|
||||||
|
this.options_med = (this.client) && (this.common) && (this.common.ORDERABLE) ? await this.client.ORWDPS2_OISLCT(this.common.ORDERABLE, '', '', 'Y', '') : null;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
this.$watch(
|
||||||
|
() => ((this.common) && (this.common.ORDERABLE), {}),
|
||||||
|
() => {
|
||||||
|
if((this.common) && (this.common.ORDERABLE)) {
|
||||||
|
if((this.lastorderable) && (this.lastorderable != this.common.ORDERABLE)) {
|
||||||
|
for(var k in this.common) if((k != 'ORDERABLE') && (this.common.hasOwnProperty(k))) this.common[k] = null;
|
||||||
|
this.inputs = [{}];
|
||||||
|
this.loadrsp_groups = [];
|
||||||
|
}
|
||||||
|
this.lastorderable = this.common.ORDERABLE;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
this.$watch(
|
||||||
|
() => this.inputs ? this.inputs.map(x => x.DAYS ? parseFloat(x.DAYS)*C_DAYS[x.DAYS.replace(/^\s*([+-]?(?:\d+|\d+\.\d+))\s*|S\b|\s+$/g, '')] : 0).reduce((acc, val) => acc + val, 0) : 0,
|
||||||
|
(value) => { this.common.SUPPLY = value; },
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
this.$watch(
|
||||||
|
() => (this.inputs) && (this.inputs.length > 0) && (this.base_product) && (this.common.SUPPLY > 0) ? this.inputs.map(x => [(x.DOSE) && (x.DOSE).indexOf('&') >= 0 ? x.DOSE.split('&')[2] : '~', x.SCHEDULE || '~', x.DAYS || '~']).reduce((acc, val) => (acc[0] += val[0] + '^', acc[1] += val[1] + '^', acc[2] += val[2] + '^', acc), ['', '', '']) : null,
|
||||||
|
debounce(async (value) => {
|
||||||
|
if(value) {
|
||||||
|
var res = await this.client.ORWDPS2_DAY2QTY(this.common.SUPPLY, value[0], value[1], value[2], '', '');
|
||||||
|
if(!isNaN(parseFloat(res))) this.common.QTY = res;
|
||||||
|
};
|
||||||
|
}, 500),
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
this.$watch(
|
||||||
|
() => { return { dlgdef: this.dlgdef, inputs: this.inputs, common: this.common, sig: this.sig }; },
|
||||||
|
debounce(() => this.$emit('update:modelValue', this.output), 100),
|
||||||
|
{ deep: true, immediate: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
164
htdocs/ViewOrderDialogMedOutptInstance.vue
Normal file
164
htdocs/ViewOrderDialogMedOutptInstance.vue
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
<template>
|
||||||
|
<template v-if="(options_schedule) && (x_modelValue)">
|
||||||
|
<div :class="multi ? 'col-4' : 'col-5'">
|
||||||
|
<div class="mb-3">
|
||||||
|
<Autocomplete label="Dosage" :items="options_med_dosage.map(x => x.text)" v-model="x_dose" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-2">
|
||||||
|
<div class="mb-3">
|
||||||
|
<Autocomplete label="Route" :items="options_med_route.map(x => x.text)" v-model="x_route" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
<div v-if="options_schedule" class="mb-3">
|
||||||
|
<Autocomplete label="Schedule" :items="options_schedule.map(x => x.value)" v-model="x_modelValue.SCHEDULE" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div :class="multi ? 'col-3' : 'col-2'">
|
||||||
|
<div v-if="multi" class="input-group mb-1">
|
||||||
|
<input type="number" class="form-control" placeholder="Duration" v-model="duration_num">
|
||||||
|
<select v-if="duration_num" class="form-select" v-model="duration_unit">
|
||||||
|
<option value="MONTH">month{{duration_num == 1 ? '' : 's'}}</option>
|
||||||
|
<option value="WEEK">week{{duration_num == 1 ? '' : 's'}}</option>
|
||||||
|
<option value="DAY" selected>day{{duration_num == 1 ? '' : 's'}}</option>
|
||||||
|
<option value="HOUR">hour{{duration_num == 1 ? '' : 's'}}</option>
|
||||||
|
<option value="MINUTE">minute{{duration_num == 1 ? '' : 's'}}</option>
|
||||||
|
</select>
|
||||||
|
<select v-if="more" class="form-select" v-model="x_modelValue.CONJ">
|
||||||
|
<option value="A">and</option>
|
||||||
|
<option value="T">then</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="widgets mb-3">
|
||||||
|
<button type="button" class="btn" :class="{ 'btn-primary': x_modelValue.SCHTYPE == 'P', 'btn-outline-secondary': x_modelValue.SCHTYPE != 'P' }" @click="e => x_modelValue.SCHTYPE = x_modelValue.SCHTYPE == 'P' ? '' : 'P'">PRN</button>
|
||||||
|
<button v-if="multi" type="button" class="btn btn-outline-danger" @click="e => $emit('remove')">🗑</button>
|
||||||
|
<button type="button" class="btn btn-outline-success" @click="e => $emit('add')">➕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.widgets {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: row wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.widgets > button {
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-shrink: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { debounce, groupByArray } from './util.mjs';
|
||||||
|
|
||||||
|
import Autocomplete from './Autocomplete.vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
Autocomplete
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
dlgdef: Object,
|
||||||
|
options_med: Object,
|
||||||
|
options_schedule: Object,
|
||||||
|
loadrsp_group: Object,
|
||||||
|
multi: Boolean,
|
||||||
|
more: Boolean,
|
||||||
|
modelValue: Object
|
||||||
|
},
|
||||||
|
emits: [
|
||||||
|
'add',
|
||||||
|
'remove',
|
||||||
|
'update:modelValue'
|
||||||
|
],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
duration_num: (this.modelValue) && (this.modelValue.DAYS) ? parseFloat(this.modelValue.DAYS) : null,
|
||||||
|
duration_unit: (this.modelValue) && (this.modelValue.DAYS) ? this.modelValue.DAYS.replace(/^\s*([+-]?(?:\d+|\d+\.\d+))\s*|S\b|\s+$/g, '') : 'DAY',
|
||||||
|
x_modelValue: this.modelValue ? Object.assign({}, this.modelValue) : {}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
options_med_dosage() {
|
||||||
|
return (this.options_med) && (this.options_med['Dosage']) ? this.options_med['Dosage'].items : [];
|
||||||
|
},
|
||||||
|
options_med_route() {
|
||||||
|
if((this.options_med) && (this.options_med['Route'])) {
|
||||||
|
var opt, rsp, res = this.options_med['Route'].items.slice();
|
||||||
|
res.mapping = res.reduce((acc, val) => (acc[val.value] = val, acc), {});
|
||||||
|
if((this.loadrsp_group) && (rsp = this.loadrsp_group['ROUTE']) && (!res.find(x => x.value == rsp.iValue))) res.push({ value: rsp.iValue, text: rsp.eValue, abbr: rsp.eValue, sig: rsp.eValue });
|
||||||
|
return res;
|
||||||
|
} else return [];
|
||||||
|
},
|
||||||
|
x_dose: {
|
||||||
|
get() {
|
||||||
|
var text = this.x_modelValue.DOSE;
|
||||||
|
return this.findopt('Dosage', (x => x.value == text), 'text') || text;
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
this.x_modelValue.DOSE = this.findopt('Dosage', (x => x.text == value), 'value') || value;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
x_route: {
|
||||||
|
get() {
|
||||||
|
var text = this.x_modelValue.ROUTE;
|
||||||
|
return (this.options_med_route.find(x => x.value == text) || { text }).text;
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
this.x_modelValue.ROUTE = (this.options_med_route.find(x => x.text == value) || { value }).value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
x_modelValue: {
|
||||||
|
handler(value) { this.$emit('update:modelValue', value); }, deep: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
findopt(section, predicate, key) {
|
||||||
|
try {
|
||||||
|
return this.options_med[section].items.find(predicate)[key];
|
||||||
|
} catch(ex) {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.$watch(
|
||||||
|
() => (this.dlgdef, this.modelValue, {}),
|
||||||
|
() => {
|
||||||
|
if(this.dlgdef) {
|
||||||
|
var res = this.dlgdef.reduce((acc, val) => (acc[val.promptID] = acc[val.promptID], acc), {});
|
||||||
|
if(this.loadrsp_group) Object.assign(res, this.loadrsp_group.reduce((acc, val) => (acc[val.promptID] = val.iValue != '^WP^' ? val.iValue : val.eValue, acc), {}));
|
||||||
|
if(this.modelValue) Object.assign(res, this.modelValue);
|
||||||
|
this.x_modelValue = this.x_modelValue ? Object.assign(this.x_modelValue, res) : res;
|
||||||
|
} else this.x_modelValue = null;
|
||||||
|
},
|
||||||
|
{ deep: true, immediate: true }
|
||||||
|
);
|
||||||
|
this.$watch(
|
||||||
|
() => (this.more, this.x_modelValue, {}),
|
||||||
|
() => {
|
||||||
|
if(this.x_modelValue) {
|
||||||
|
if(this.more) {
|
||||||
|
if(!this.x_modelValue.CONJ) this.x_modelValue.CONJ = 'T';
|
||||||
|
} else {
|
||||||
|
if(this.x_modelValue.CONJ) this.x_modelValue.CONJ = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
this.$watch(
|
||||||
|
() => (this.duration_num, this.duration_unit, this.x_modelValue, {}),
|
||||||
|
debounce(() => {
|
||||||
|
var value = this.duration_num ? this.duration_num + ' ' + this.duration_unit + (this.duration_num == 1 ? '' : 'S') : '';
|
||||||
|
if((this.x_modelValue) && (this.x_modelValue.DAYS != value)) this.x_modelValue.DAYS = value;
|
||||||
|
}, 250),
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
97
htdocs/ViewOrderMenu.vue
Normal file
97
htdocs/ViewOrderMenu.vue
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
<template>
|
||||||
|
<template v-if="bare">
|
||||||
|
<template v-if="selection">
|
||||||
|
<button v-if="selection.display_text" class="list-group-item list-group-item-action" @click="reselect">{{selection.display_text}} [ {{selection.type}} | {{selection.IEN}} ] ({{selection.col}}, {{selection.row}})</button>
|
||||||
|
<button v-if="selection.displayText" class="list-group-item list-group-item-action" @click="reselect">{{selection.displayText}} [ {{selection.type}} | {{selection.IEN}} ] | {{selection.windowFormId}} | {{selection.displayGroupId}}</button>
|
||||||
|
<ViewOrderDialog v-if="(dfn) && (selection.type) && ((selection.type == 'D') || (selection.type == 'Q'))" :client="client" :ien="selection.IEN" :form_id="selection.windowFormId" :dfn="dfn" />
|
||||||
|
<ViewOrderMenu v-else :key="selkey" :client="client" :ien="selection.IEN" :dfn="dfn" :bare="true" />
|
||||||
|
</template>
|
||||||
|
<div v-else-if="(columns) && (columns.length > 0)" class="list-group-item container">
|
||||||
|
<div class="row">
|
||||||
|
<div v-for="column in columns" class="col">
|
||||||
|
<div class="list-group">
|
||||||
|
<template v-for="item in column.values">
|
||||||
|
<button v-if="!item.displayonly" class="list-group-item list-group-item-action" @click="selection = item">{{item.display_text}} [{{item.type}} {{item.IEN}}] ({{item.col}}, {{item.row}})</button>
|
||||||
|
<div v-else class="list-group-item bg-light displayonly" :class="{ italic: item.displayonly == '1', bold: item.displayonly == '2' }">{{item.display_text}}</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template v-else-if="(topmenu) && (topmenu.length > 0)">
|
||||||
|
<template v-for="item in topmenu">
|
||||||
|
<button v-if="item.displayText" class="list-group-item list-group-item-action" @click="selection = item">{{item.displayText}} [{{item.type}} {{item.IEN}}] | {{item.windowFormId}} | {{item.displayGroupId}}</button>
|
||||||
|
<hr v-else />
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
<div v-else class="list-group">
|
||||||
|
<button class="list-group-item list-group-item-action active" @click="reselect">Order entry</button>
|
||||||
|
<ViewOrderMenu :key="selkey" :client="client" :ien="ien" :dfn="dfn" :bare="true" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.displayonly {
|
||||||
|
cursor: default;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
.displayonly.italic {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.displayonly.bold {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
hr + hr {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { groupByArray } from './util.mjs';
|
||||||
|
|
||||||
|
import ViewOrderDialog from './ViewOrderDialog.vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'ViewOrderMenu',
|
||||||
|
components: {
|
||||||
|
ViewOrderDialog
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
client: Object,
|
||||||
|
ien: String,
|
||||||
|
dfn: String,
|
||||||
|
bare: {
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
topmenu: null,
|
||||||
|
submenu: null,
|
||||||
|
selection: null,
|
||||||
|
selkey: 0
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
columns() {
|
||||||
|
return (this.submenu) && (this.submenu.children.length) ? groupByArray(this.submenu.children, 'col').sort((a, b) => a.col - b.col).map(col => (col.values.sort((a, b) => a.row - b.row), col)) : [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
ien: {
|
||||||
|
async handler(value) {
|
||||||
|
if(this.bare) {
|
||||||
|
if(value) this.submenu = await client.ORWDXM_MENU(value);
|
||||||
|
else this.topmenu = await this.client.ORWDX_WRLST();
|
||||||
|
}
|
||||||
|
}, immediate: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
reselect() {
|
||||||
|
this.selkey++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
154
htdocs/ViewOrderableLookup.vue
Normal file
154
htdocs/ViewOrderableLookup.vue
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
<template>
|
||||||
|
<div class="input-group">
|
||||||
|
<span v-if="label" class="input-group-text">{{label}}</span>
|
||||||
|
<select v-if="selectable_xref" class="form-select" style="flex: 0.15 1 auto;" v-model="x_xref">
|
||||||
|
<option v-for="item in ORDERABLE_XREF" :value="item.xref">{{item.name}}</option>
|
||||||
|
</select>
|
||||||
|
<input class="form-control" placeholder="Filter..." v-model="x_query" />
|
||||||
|
</div>
|
||||||
|
<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.IEN}}</td>
|
||||||
|
<td>{{item.synonym}}</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';
|
||||||
|
|
||||||
|
import { ORDERABLE_XREF } from './constants.mjs';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
client: Object,
|
||||||
|
xref: String,
|
||||||
|
label: String,
|
||||||
|
query: String,
|
||||||
|
qocall: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
modelValue: String
|
||||||
|
},
|
||||||
|
emits: {
|
||||||
|
'update:xref': String,
|
||||||
|
'update:query': String,
|
||||||
|
'update:modelValue': String
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
ORDERABLE_XREF,
|
||||||
|
selectable_xref: !this.xref,
|
||||||
|
x_xref: this.xref || 'S.O RX',
|
||||||
|
x_query: this.query,
|
||||||
|
resultset: [],
|
||||||
|
has_more: false,
|
||||||
|
is_loading: true,
|
||||||
|
observer_bottom: null,
|
||||||
|
x_modelValue: this.modelValue
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
query_view() {
|
||||||
|
return this.x_query ? this.x_query.replace(/^\s+|\s+$/g, '').replace(/\s+/g, ' ').toUpperCase() : '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
xref(value) { this.x_xref = value; },
|
||||||
|
x_xref(value) {
|
||||||
|
this.$emit('update:xref', value);
|
||||||
|
this.$emit('update:query', this.x_query = '');
|
||||||
|
},
|
||||||
|
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.ORWDX_ORDITM(this.resultset[this.resultset.length - 1].synonym, 1, this.x_xref, this.qocall);
|
||||||
|
if(this.query_view.length >= 3) batch = batch.filter(x => x.synonym.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.modelValue, {}),
|
||||||
|
async () => {
|
||||||
|
if((this.client) && (!this.x_query) && (this.modelValue) && (this.modelValue == parseInt(this.modelValue))) {
|
||||||
|
var item = await this.client.ORWDLR32_LOAD(this.modelValue);
|
||||||
|
this.x_query = item['Test Name'].default;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
this.$watch(
|
||||||
|
() => (this.client, this.xref, this.query_view, this.qocall, {}),
|
||||||
|
debounce(async function() {
|
||||||
|
if((this.client) && (this.xref)) {
|
||||||
|
this.is_loading = true;
|
||||||
|
this.has_more = false;
|
||||||
|
try {
|
||||||
|
var query = this.query_view;
|
||||||
|
if(query.length >= 3) {
|
||||||
|
var batch = await this.client.ORWDX_ORDITM(query.slice(0, -1) + String.fromCharCode(query.charCodeAt(query.length - 1) - 1), 1, this.xref, this.qocall);
|
||||||
|
this.resultset = batch.filter(x => x.synonym.startsWith(query));
|
||||||
|
if((this.resultset.length > 0) && ((this.resultset[0].name == query) || (this.resultset[0].synonym == query))) this.x_modelValue = this.resultset[0].IEN;
|
||||||
|
} else this.resultset = await this.client.ORWDX_ORDITM('', 1, this.xref, this.qocall);
|
||||||
|
this.has_more = this.resultset.length > 0;
|
||||||
|
} catch(ex) {
|
||||||
|
this.resultset = [];
|
||||||
|
this.has_more = false;
|
||||||
|
} finally {
|
||||||
|
this.is_loading = false;
|
||||||
|
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>
|
157
htdocs/ViewOrderableRawLookup.vue
Normal file
157
htdocs/ViewOrderableRawLookup.vue
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
<template>
|
||||||
|
<div class="input-group">
|
||||||
|
<span v-if="label" class="input-group-text">{{label}}</span>
|
||||||
|
<select v-if="selectable_dgnm" class="form-select" style="flex: 0.15 1 auto;" v-model="x_dgnm">
|
||||||
|
<option v-for="item in ORDERABLE_DGNM" :value="item.dgnm">{{item.name}}</option>
|
||||||
|
</select>
|
||||||
|
<input class="form-control" placeholder="Filter..." v-model="x_query" />
|
||||||
|
</div>
|
||||||
|
<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.IEN}}</td>
|
||||||
|
<td>{{item.description}}</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';
|
||||||
|
|
||||||
|
import { ORDERABLE_DGNM } from './constants.mjs';
|
||||||
|
|
||||||
|
const SZ_WINDOW = 100;
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
client: Object,
|
||||||
|
dgnm: String,
|
||||||
|
label: String,
|
||||||
|
query: String,
|
||||||
|
modelValue: String
|
||||||
|
},
|
||||||
|
emits: {
|
||||||
|
'update:dgnm': String,
|
||||||
|
'update:query': String,
|
||||||
|
'update:modelValue': String
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
ORDERABLE_DGNM,
|
||||||
|
selectable_dgnm: !this.dgnm,
|
||||||
|
x_dgnm: this.dgnm || 'O RX',
|
||||||
|
IEN: null,
|
||||||
|
count: null,
|
||||||
|
x_query: this.query,
|
||||||
|
resultset: [],
|
||||||
|
end: null,
|
||||||
|
has_more: false,
|
||||||
|
is_loading: true,
|
||||||
|
observer_bottom: null,
|
||||||
|
x_modelValue: this.modelValue
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
query_view() {
|
||||||
|
return this.x_query ? this.x_query.replace(/^\s+$/g, '').replace(/\s+/g, ' ').toUpperCase() : ''; // allow trailing space
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
dgnm(value) { this.x_dgnm = value; },
|
||||||
|
x_dgnm(value) {
|
||||||
|
this.$emit('update:dgnm', value);
|
||||||
|
this.$emit('update:query', this.x_query = '');
|
||||||
|
},
|
||||||
|
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.end < this.count) && (!this.is_loading)) {
|
||||||
|
this.is_loading = true;
|
||||||
|
this.has_more = false;
|
||||||
|
try {
|
||||||
|
var batch = await this.client.ORWUL_FVSUB(this.IEN, this.end + 1, this.end = Math.min(this.end + SZ_WINDOW, this.count));
|
||||||
|
if(this.query_view.length >= 3) batch = batch.filter(x => x.description.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.dgnm, {}),
|
||||||
|
async () => {
|
||||||
|
if((this.client) && (this.dgnm)) {
|
||||||
|
var res = await this.client.ORWUL_FV4DG(this.dgnm);
|
||||||
|
this.IEN = res.IEN;
|
||||||
|
this.count = res.count;
|
||||||
|
} else this.IEN = this.count = null;
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
this.$watch(
|
||||||
|
() => (this.client, this.IEN, this.query_view, {}),
|
||||||
|
debounce(async function() {
|
||||||
|
if((this.client) && (this.IEN)) {
|
||||||
|
this.is_loading = true;
|
||||||
|
this.has_more = false;
|
||||||
|
try {
|
||||||
|
var query = this.query_view;
|
||||||
|
if(query.length >= 3) {
|
||||||
|
var row = await this.client.ORWUL_FVIDX(this.IEN, query.slice(0, -1) + String.fromCharCode(query.charCodeAt(query.length - 1) - 1) + '~');
|
||||||
|
var pos = Math.floor((row.index - 1)/SZ_WINDOW)*SZ_WINDOW;
|
||||||
|
var batch = await this.client.ORWUL_FVSUB(this.IEN, pos + 1, this.end = Math.min(pos + SZ_WINDOW, this.count));
|
||||||
|
this.resultset = batch.filter(x => x.description.startsWith(query));
|
||||||
|
} else this.resultset = await this.client.ORWUL_FVSUB(this.IEN, 1, this.end = Math.min(SZ_WINDOW, this.count));
|
||||||
|
this.has_more = this.resultset.length > 0;
|
||||||
|
} catch(ex) {
|
||||||
|
this.resultset = [];
|
||||||
|
this.has_more = false;
|
||||||
|
} finally {
|
||||||
|
this.is_loading = false;
|
||||||
|
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>
|
107
htdocs/ViewOrders.vue
Normal file
107
htdocs/ViewOrders.vue
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
<template>
|
||||||
|
<table v-if="details.length > 0" class="table table-striped">
|
||||||
|
<template v-for="group in details">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{map_groups[group.key].name}}</th>
|
||||||
|
<th>Practitioner</th>
|
||||||
|
<th style="text-align: right;">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="item in group.values">
|
||||||
|
<td><div v-for="line in item.text">{{line}}</div><div>{{item.IFN}}</div></td>
|
||||||
|
<td>
|
||||||
|
<div v-if="item.PrvNam" :title="item.PrvID">{{item.PrvNam}}</div>
|
||||||
|
<div v-if="item.Nrs">Nurse: {{item.Nrs}}</div>
|
||||||
|
<div v-if="item.Clk">Clerk: {{item.Clk}}</div>
|
||||||
|
<div v-if="item.ChartRev">Chart: {{item.ChartRev}}</div>
|
||||||
|
<div v-if="item.LOC">{{item.LOC.split(':')[0]}}</div>
|
||||||
|
</td>
|
||||||
|
<td style="text-align: right;">
|
||||||
|
<div v-if="item.Sts">{{name_of_status[item.Sts]}}</div>
|
||||||
|
<div v-if="item.OrdTm">Ordered: {{strptime_vista(item.OrdTm).toLocaleDateString('sv-SE')}}</div>
|
||||||
|
<div v-if="item.StrtTm">Start: {{strptime_vista(item.StrtTm).toLocaleDateString('sv-SE')}}</div>
|
||||||
|
<div v-if="item.StopTm">Stop: {{strptime_vista(item.StopTm).toLocaleDateString('sv-SE')}}</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</template>
|
||||||
|
</table>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
th {
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
th::first-letter {
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
td:first-child {
|
||||||
|
max-width: 30rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { groupByArray, strftime_vista, strptime_vista } from './util.mjs';
|
||||||
|
|
||||||
|
const name_of_status = {
|
||||||
|
0: 'Error ⚠',
|
||||||
|
1: 'Discontinued ❌',
|
||||||
|
2: 'Complete ✔️',
|
||||||
|
3: 'Hold ✋',
|
||||||
|
4: 'Flagged 🚩',
|
||||||
|
5: 'Pending ⏳',
|
||||||
|
6: 'Active ⭐',
|
||||||
|
7: 'Expired ❌',
|
||||||
|
8: 'Scheduled ✔️',
|
||||||
|
9: 'Partial results ⏳',
|
||||||
|
10: 'Delayed 📅',
|
||||||
|
11: 'Unreleased 🚧',
|
||||||
|
12: 'DC/edit ✎',
|
||||||
|
13: 'Cancelled ❌',
|
||||||
|
14: 'Lapsed ❌',
|
||||||
|
15: 'Renewed ⭐',
|
||||||
|
97: '',
|
||||||
|
98: 'New ✨',
|
||||||
|
99: 'No status'
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
client: Object,
|
||||||
|
dfn: String,
|
||||||
|
filter: { default: 2 },
|
||||||
|
group: { default: 1 },
|
||||||
|
date_begin: Date,
|
||||||
|
date_end: Date
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
orders: [],
|
||||||
|
details: [],
|
||||||
|
map_groups: {},
|
||||||
|
name_of_status
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
params() {
|
||||||
|
return { dfn: this.dfn, filter: this.filter, group: this.group, date_begin: strftime_vista(this.date_begin), date_end: strftime_vista(this.date_end) };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
async params(value) {
|
||||||
|
this.orders = await this.client.ORWORR_AGET(value.dfn, value.filter, value.group, value.date_begin, value.date_end);
|
||||||
|
},
|
||||||
|
async orders(value) {
|
||||||
|
this.details = this.orders.length > 0 ? groupByArray(await this.client.ORWORR_GET4LST(0, 0, value.map(x => x.ifn)), x => x.Grp) : [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
strptime_vista
|
||||||
|
},
|
||||||
|
async mounted() {
|
||||||
|
this.map_groups = (await this.client.ORWORDG_ALLTREE()).reduce((acc, x) => (acc[x.ien] = x, acc), {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
@ -48,7 +48,7 @@
|
|||||||
created() {
|
created() {
|
||||||
this.query_sync = debounce(async function(value) {
|
this.query_sync = debounce(async function(value) {
|
||||||
this.query_view = value = value.replace(/^\s+|\s+$/g, '').replace(/\s+/g, ' ');
|
this.query_view = value = value.replace(/^\s+|\s+$/g, '').replace(/\s+/g, ' ');
|
||||||
this.resultset = value ? (await this.client.ORWPT16_LOOKUP(value)) : [];
|
this.resultset = (value) && (value.length >= 3) ? (await this.client.ORWPT16_LOOKUP(value)) : [];
|
||||||
}, 500);
|
}, 500);
|
||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
|
134
htdocs/ViewPlanner.vue
Normal file
134
htdocs/ViewPlanner.vue
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
<template>
|
||||||
|
<table v-if="(resultset) && (resultset.length > 0)" class="table">
|
||||||
|
<thead>
|
||||||
|
<tr><th v-for="x in dow">{{x}}</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="week in resultset">
|
||||||
|
<td v-for="day in [0, 1, 2, 3, 4, 5, 6]" class="datebox">
|
||||||
|
<template v-if="week.values[day]">
|
||||||
|
<router-link :to="'/schedule/' + week.values[day][0]._START_OBJ.toLocaleDateString('sv-SE')" custom v-slot="{ navigate }">
|
||||||
|
<div class="datebox linked" :style="{ backgroundColor: resultset.max > 0 ? 'rgba(220, 53, 69, ' + week.values[day].length/resultset.max + ')' : null }" @click="navigate"><span class="occupancy hidden">#{{week.values[day].length}}</span> {{day > 0 ? week.values[day][0]._START_OBJ.getDate() : week.key.toLocaleDateString('sv-SE')}} <span class="occupancy">#{{week.values[day].length}}</span></div>
|
||||||
|
</router-link>
|
||||||
|
<template v-for="appointment in week.values[day]">
|
||||||
|
<div v-if="appointment._BREAK" class="vacancy" :title="appointment._START_OBJ.toLocaleTimeString('en-GB').substring(0, 5) + '–' + appointment._END_OBJ.toLocaleTimeString('en-GB').substring(0, 5)" />
|
||||||
|
<div v-else :title="appointment._START_OBJ.toLocaleTimeString('en-GB').substring(0, 5) + '–' + appointment._END_OBJ.toLocaleTimeString('en-GB').substring(0, 5) + '\n' + appointment.PATIENTNAME + ' ' + appointment.HRN.slice(-4) + '\n' + appointment.NOTE"><span v-if="appointment._CONCURRENCY > 0" class="concurrency hidden">*<template v-if="appointment._CONCURRENCY > 1">{{appointment._CONCURRENCY}}</template></span>{{appointment._START_OBJ.toLocaleTimeString('en-GB').substring(0, 5)}} <router-link :to="'/patient/' + appointment.PATIENTID">{{appointment.PATIENTNAME.substring(0, 1)}}{{appointment.HRN.slice(-4)}}</router-link><span v-if="appointment._CONCURRENCY > 0" class="concurrency">*<template v-if="appointment._CONCURRENCY > 1">{{appointment._CONCURRENCY}}</template></span></div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
<div v-else class="datebox">{{day > 0 ? (new Date(week.key.getTime() + 1000*60*60*24*day)).getDate() : week.key.toLocaleDateString('sv-SE')}}</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
table {
|
||||||
|
font-family: monospace;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
tbody, td {
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
div.datebox {
|
||||||
|
cursor: default;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
div.datebox.linked {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
div.vacancy {
|
||||||
|
width: 2em;
|
||||||
|
margin: auto;
|
||||||
|
border-bottom: 0.25em dotted #bbb;
|
||||||
|
}
|
||||||
|
span.occupancy {
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
span.concurrency {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
span.hidden {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { groupBy, groupByArray, strfdate_vista, debounce } from './util.mjs';
|
||||||
|
|
||||||
|
const C_DAY = 1000*60*60*24;
|
||||||
|
const C_WEEK = C_DAY*7;
|
||||||
|
|
||||||
|
function dateonly(date) {
|
||||||
|
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
function infill_weeks(res, date_begin, date_end) {
|
||||||
|
for(var i = res.length - 1; i > 0; --i) {
|
||||||
|
var cur = res[i], prev = res[i - 1];
|
||||||
|
while(cur.key - prev.key > C_WEEK) res.splice(i, 0, cur = { key: new Date(cur.key.getTime() - C_WEEK), values: [] });
|
||||||
|
}
|
||||||
|
var item;
|
||||||
|
if(res.length < 1) res.push({ key: date_begin, values: [] });
|
||||||
|
else {
|
||||||
|
item = res[0];
|
||||||
|
while(item.key - date_begin >= C_WEEK) res.splice(0, 0, item = { key: new Date(item.key.getTime() - C_WEEK), values: [] });
|
||||||
|
}
|
||||||
|
item = res[res.length - 1];
|
||||||
|
while(date_end - item.key >= C_WEEK) res.push(item = { key: new Date(item.key.getTime() + C_WEEK), values: [] });
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
function analyze_week(res) {
|
||||||
|
for(var k in res) if(res.hasOwnProperty(k)) analyze_day(res[k]);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
function analyze_day(res) {
|
||||||
|
for(var i = res.length - 1; i > 0; --i) {
|
||||||
|
var item = res[i];
|
||||||
|
item._CONCURRENCY = 0;
|
||||||
|
for(var j = i - 1; j >= 0; --j) if(item._START_OBJ < res[j]._END_OBJ) item._CONCURRENCY++;
|
||||||
|
if((item._CONCURRENCY < 1) && (item._START_OBJ > res[i - 1]._END_OBJ)) res.splice(i, 0, { _BREAK: true, _START_OBJ: res[i - 1]._END_OBJ, _END_OBJ: item._START_OBJ });
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
client: Object,
|
||||||
|
resource: String,
|
||||||
|
date_begin: Date,
|
||||||
|
date_end: Date
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
var now = new Date();
|
||||||
|
return {
|
||||||
|
resultset: [],
|
||||||
|
dow: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||||
|
};
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.$watch(
|
||||||
|
() => (this.client, this.resource, this.date_begin, this.date_end, {}),
|
||||||
|
debounce(async () => {
|
||||||
|
if((this.client) && (this.resource) && (this.date_begin) && (this.date_end)) {
|
||||||
|
var date_begin = new Date(this.date_begin.getTime() - C_DAY*this.date_begin.getDay()), weekref = date_begin;
|
||||||
|
var date_end = new Date(this.date_end.getTime() + C_DAY*(6 - this.date_end.getDay()));
|
||||||
|
var resultset = (await this.client.SDEC_CRSCHED(this.resource, strfdate_vista(date_begin), strfdate_vista(date_end) + '@2359')).filter(x => (x.CANCELLED == '0') && (x.NOSHOW == '0')).map(x => {
|
||||||
|
var _START_OBJ = x._START_OBJ = new Date(x.START_TIME);
|
||||||
|
x._START_DATE = _START_OBJ.toLocaleDateString('sv-SE');
|
||||||
|
x._END_OBJ = new Date(x.END_TIME);
|
||||||
|
x._WEEK_DAY = _START_OBJ.getDay();
|
||||||
|
x._WEEK_NUM = Math.floor((x._START_OBJ - weekref)/(C_WEEK));
|
||||||
|
return x;
|
||||||
|
}).sort((a, b) => a._START_OBJ - b._START_OBJ);
|
||||||
|
this.resultset = infill_weeks(groupByArray(resultset, '_WEEK_NUM').map(week => ({ key: new Date(weekref.getTime() + C_WEEK*week.key), values: analyze_week(groupBy(week.values, '_WEEK_DAY')) })), date_begin, date_end);
|
||||||
|
this.resultset.max = Math.max.apply(null, groupByArray(resultset, '_START_DATE').map(x => x.values.length));
|
||||||
|
} else this.resultset = [];
|
||||||
|
}, 500),
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
@ -1,9 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<div>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text">🔎</span>
|
<span class="input-group-text">🔎</span>
|
||||||
<input class="form-control" v-model="query_raw" />
|
<input class="form-control" v-model="query_raw" @focus="collapsed = false" />
|
||||||
</div>
|
</div>
|
||||||
<div style="max-height: 30em; overflow-y: auto;">
|
<div :class="{ collapsed }" style="max-height: 30em; overflow-y: auto;">
|
||||||
<table class="table table-striped" style="font-family: monospace;" v-if="resultset_raw && resultset_raw.length > 0">
|
<table class="table table-striped" style="font-family: monospace;" v-if="resultset_raw && resultset_raw.length > 0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th></th><th>ID</th><th>Name</th><th>Type</th><th>User</th></tr>
|
<tr><th></th><th>ID</th><th>Name</th><th>Type</th><th>User</th></tr>
|
||||||
@ -25,8 +26,15 @@
|
|||||||
<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-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>
|
<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>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.collapsed {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { debounce } from './util.mjs';
|
import { debounce } from './util.mjs';
|
||||||
|
|
||||||
@ -57,7 +65,8 @@
|
|||||||
return {
|
return {
|
||||||
resultset_raw: [],
|
resultset_raw: [],
|
||||||
query_raw: '',
|
query_raw: '',
|
||||||
query_view: ''
|
query_view: '',
|
||||||
|
collapsed: true
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@ -84,6 +93,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
collapse(evt) { if(!this.$el.contains(evt.target)) this.collapsed = true; },
|
||||||
reset(evt) {
|
reset(evt) {
|
||||||
var selection = this.resultset_selected.slice();
|
var selection = this.resultset_selected.slice();
|
||||||
for(var i = selection.length - 1; i >= 0; --i) selection[i].selected = false;
|
for(var i = selection.length - 1; i >= 0; --i) selection[i].selected = false;
|
||||||
@ -96,6 +106,10 @@
|
|||||||
var rs = (await this.client.SDEC_RESOURCE()).slice();
|
var rs = (await this.client.SDEC_RESOURCE()).slice();
|
||||||
update_selection(rs, this.selection);
|
update_selection(rs, this.selection);
|
||||||
this.resultset_raw = rs;
|
this.resultset_raw = rs;
|
||||||
|
document.addEventListener('click', this.collapse);
|
||||||
|
},
|
||||||
|
destroyed() {
|
||||||
|
document.removeEventListener('click', this.collapse);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
@ -1,89 +1,163 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<template v-if="age !== undefined">
|
||||||
|
<div v-if="age == Infinity" class="alert alert-danger">❌ update error</div>
|
||||||
|
<div v-else-if="age >= 90000" class="alert alert-warning">⚠ last updated <template v-if="age < 3600000">{{ts.toLocaleString()}}</template><template v-else>{{ts.toLocaleString()}}</template></div>
|
||||||
|
</template>
|
||||||
|
<div v-if="tag_list.length > 0">
|
||||||
|
<span v-if="filter_array.length > 0" class="tag badge bg-danger" @click="filter = {}">🗑{{filter_array.length}}</span><span v-else class="tag badge">🏷</span>
|
||||||
|
<span v-for="key in tag_list" class="tag badge" :style="{ color: filter[key] ? null : 'rgba(var(--bs-dark-rgb), var(--bs-text-opacity))', backgroundColor: filter[key] ? strHashHSL(key, '50%') : null }" @click="filter[key] ? delete filter[key] : filter[key] = true">{{key.toUpperCase()}}</span>
|
||||||
|
</div>
|
||||||
|
<datalist :id="'datalist-' + uid"><option v-for="item in practitioner_list" :value="item" /></datalist>
|
||||||
<table class="table" style="font-family: monospace;" v-if="appointments && appointments.length > 0">
|
<table class="table" style="font-family: monospace;" v-if="appointments && appointments.length > 0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>Time</th><th>Clinic</th><th>Patient</th><th>Note</th><th>Assignee</th></tr>
|
<tr><th style="width: 7rem;">Time</th><th>Clinic</th><th>Patient</th><th>Note</th><th style="width: 16rem;">Assignee</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody class="striped">
|
||||||
<tr v-for="row in appointments" :style="{ backgroundColor: strHashHSL(row.Clinic, '90%') }">
|
<tr v-for="row in appointments" v-show="(filter_array.length < 1) || (filter_conj(tag_map[row.APPOINTMENTID]))" :class="{ voided: (row.CANCELLED != '0') || (row.NOSHOW != '0') }" :style="{ backgroundColor: strHashHSL(row.RESOURCENAME, '90%') }">
|
||||||
<td>{{row.ApptDate}}</td>
|
<td v-if="row.CANCELLED != '0'" title="Cancelled"><div><span class="emoji">❌</span> {{row.START_TIME.match(/\d\d:\d\d/)[0]}}</div><div class="date">{{row.START_TIME.match(/\w{3} \d+, \d{4}/)[0]}}</div></td>
|
||||||
<td>{{row.Clinic}}</td>
|
<td v-else-if="row.NOSHOW != '0'" title="No show"><div><span class="emoji">❓</span> {{row.START_TIME.match(/\d\d:\d\d/)[0]}}</div><div class="date">{{row.START_TIME.match(/\w{3} \d+, \d{4}/)[0]}}</div></td>
|
||||||
<td v-if="production"><router-link :to="'/patient/$' + row.HRN">{{row.Name}} ${{row.HRN}}</router-link></td>
|
<td v-else-if="row.CHECKOUT" :title="'Checked out ' + row.CHECKOUT"><div><span class="emoji">✅</span> {{row.START_TIME.match(/\d\d:\d\d/)[0]}}</div><div class="date">{{row.START_TIME.match(/\w{3} \d+, \d{4}/)[0]}}</div></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 v-else-if="row.CHECKIN" :title="'Checked in ' + row.CHECKIN"><div><span class="emoji">✔</span> {{row.START_TIME.match(/\d\d:\d\d/)[0]}}</div><div class="date">{{row.START_TIME.match(/\w{3} \d+, \d{4}/)[0]}}</div></td>
|
||||||
<td>{{row.NOTE}} [{{row.APPT_MADE_BY}} on {{row.DATE_APPT_MADE}}]</td>
|
<td v-else title="Scheduled"><div><span class="emoji">⌛</span> {{row.START_TIME.match(/\d\d:\d\d/)[0]}}</div><div class="date">{{row.START_TIME.match(/\w{3} \d+, \d{4}/)[0]}}</div></td>
|
||||||
<td><Autocomplete :value="practitioner[row.Name]" @update:value="x => set_practitioner(row.Name, x)" :items="practitioner_list" /></td>
|
<td>{{row.RESOURCENAME}}</td>
|
||||||
|
<td><router-link :to="'/patient/' + row.PATIENTID">{{row.PATIENTNAME}} <span :title="row.HRN">{{row.HRN.slice(-4)}}</span></router-link> <span v-if="row.SENSITIVE != '0'" class="emoji">⚠</span></td>
|
||||||
|
<td>{{row.NOTE}}<span v-for="(value, key) in tag_map[row.APPOINTMENTID]" class="tag badge" :style="{ backgroundColor: strHashHSL(key, '50%') }" @click="filter[key] = true">{{value}}</span></td>
|
||||||
|
<td><input class="form-control" :list="'datalist-' + uid" :value="practitioner[row.PATIENTNAME]" @input="e => practitioner[row.PATIENTNAME] = e.target.value" /></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
<caption style="text-align: center;">{{appointments.length || 'no'}} appointment{{appointments.length != 1 ? 's' : ''}} <template v-if="date_begin.toLocaleDateString('sv-SE') == date_end.toLocaleDateString('sv-SE')">on {{date_begin.toLocaleDateString('sv-SE')}}</template><template v-else>from {{date_begin.toLocaleDateString('sv-SE')}} to {{date_end.toLocaleDateString('sv-SE')}}</template></caption>
|
||||||
</table>
|
</table>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.striped {
|
||||||
|
background-image: repeating-linear-gradient(
|
||||||
|
-45deg,
|
||||||
|
rgba(255, 255, 255, 0.1),
|
||||||
|
rgba(255, 255, 255, 0.1) 10px,
|
||||||
|
rgba(0, 0, 0, 0.1) 10px,
|
||||||
|
rgba(0, 0, 0, 0.1) 20px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.voided {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.date {
|
||||||
|
font-size: 80%;
|
||||||
|
}
|
||||||
|
.emoji {
|
||||||
|
font-family:
|
||||||
|
"Twemoji Mozilla",
|
||||||
|
"Apple Color Emoji",
|
||||||
|
"Segoe UI Emoji",
|
||||||
|
"Segoe UI Symbol",
|
||||||
|
"Noto Color Emoji",
|
||||||
|
"EmojiOne Color",
|
||||||
|
"Android Emoji",
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
.badge.tag {
|
||||||
|
cursor: pointer;
|
||||||
|
margin-right: 0.35em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import cookie from './cookie.mjs';
|
import { uniq, strHashHSL, strfdate_vista, debounce } from './util.mjs';
|
||||||
import { uniq, strHashHSL } from './util.mjs';
|
|
||||||
|
|
||||||
import Autocomplete from './Autocomplete.vue';
|
function clearTimeouts(timers) {
|
||||||
|
if(timers.length > 1) console.warn('Clearing multiple timeouts', timers.slice());
|
||||||
function datefm(datestr) {
|
for(var i = 0; i < timers.length; ++i) window.clearTimeout(timers[i]);
|
||||||
var date = datestr ? new Date(datestr) : new Date();
|
timers.length = 0;
|
||||||
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 {
|
export default {
|
||||||
components: {
|
|
||||||
Autocomplete
|
|
||||||
},
|
|
||||||
props: {
|
props: {
|
||||||
client: Object,
|
client: Object,
|
||||||
selection: {
|
selection: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: []
|
default: []
|
||||||
},
|
},
|
||||||
date_begin: String,
|
date_begin: Date,
|
||||||
date_end: String
|
date_end: Date
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
uid: Math.random()*0x7fffffff|0,
|
||||||
appointments: [],
|
appointments: [],
|
||||||
practitioner: {},
|
timers: [],
|
||||||
production: true
|
ts: null,
|
||||||
|
age: undefined,
|
||||||
|
filter: {}
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
params() {
|
tag_map() {
|
||||||
return { selection: this.selection, date_begin: this.date_begin, date_end: this.date_end };
|
var res0 = {}, practitioner = this.practitioner;
|
||||||
|
if(this.appointments) this.appointments.forEach(function(row) {
|
||||||
|
var res1 = res0[row.APPOINTMENTID] = {}, re, matches;
|
||||||
|
if((row.RESOURCENAME) && (matches = row.RESOURCENAME.replace(/\W+/g, '-').replace(/^-+|-+$/g, ''))) res1[matches.toLowerCase()] = matches;
|
||||||
|
if(row.WALKIN != '0') res1['walkin'] = 'WALKIN';
|
||||||
|
if((row.CANCELLED != '0') || (row.NOSHOW != '0')) res1['inactive'] = 'INACTIVE';
|
||||||
|
else res1['active'] = 'ACTIVE';
|
||||||
|
if(row.NOTE) {
|
||||||
|
re = /#([0-9a-z][\w-]*)/gi;
|
||||||
|
while(matches = re.exec(row.NOTE)) res1[matches[1].toLowerCase()] = matches[1];
|
||||||
|
re = /Dr[\.\s]*\b([a-z][\w-]*)/gi;
|
||||||
|
while(matches = re.exec(row.NOTE)) res1[matches[1].toLowerCase()] = matches[1];
|
||||||
|
}
|
||||||
|
if((matches = practitioner[row.PATIENTNAME]) && (matches = matches.replace(/\W+/g, '-').replace(/^-+|-+$/g, ''))) res1[matches.toLowerCase()] = matches.toUpperCase();
|
||||||
|
});
|
||||||
|
return res0;
|
||||||
|
},
|
||||||
|
tag_list() {
|
||||||
|
var res = {}, tag_map = this.tag_map;
|
||||||
|
if(tag_map) for(var k in tag_map) if(tag_map.hasOwnProperty(k)) Object.assign(res, tag_map[k]);
|
||||||
|
return Object.keys(res).sort();
|
||||||
|
},
|
||||||
|
filter_array() {
|
||||||
|
return Object.keys(this.filter).sort();
|
||||||
|
},
|
||||||
|
practitioner() {
|
||||||
|
return this.client.remotestate.practitioner || (this.client.remotestate.practitioner = {});
|
||||||
},
|
},
|
||||||
practitioner_list() {
|
practitioner_list() {
|
||||||
return this.practitioner ? uniq(Object.values(this.practitioner)).sort() : [];
|
return this.practitioner ? uniq(Object.values(this.practitioner).filter(x => x)).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: {
|
methods: {
|
||||||
strHashHSL,
|
strHashHSL,
|
||||||
unscramble(name) {
|
filter_conj(tags) {
|
||||||
return name.length > 0 ? (name.charAt(0) + strtr(name.substring(1), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'LKJIHGFEDCBAZYXWVUTSRQPONM')) : name;
|
var filter_array = this.filter_array;
|
||||||
|
for(var i = this.filter_array.length - 1; i >= 0; --i) if(!tags[this.filter_array[i]]) return false;
|
||||||
|
return true;
|
||||||
},
|
},
|
||||||
set_practitioner(patient, practitioner) {
|
async update() {
|
||||||
this.practitioner[patient] = practitioner;
|
clearTimeouts(this.timers);
|
||||||
cookie.set('vista.practitioner', JSON.stringify(this.practitioner), 1);
|
try {
|
||||||
|
this.appointments = (await this.client.SDEC_CRSCHED(this.selection.join('|') + '|', strfdate_vista(this.date_begin), strfdate_vista(this.date_end) + '@2359')).sort((a, b) => (new Date(a.START_TIME)) - (new Date(b.START_TIME)));
|
||||||
|
var now = new Date();
|
||||||
|
this.ts = this.appointments._ts ? new Date(1000*this.appointments._ts) : now;
|
||||||
|
this.age = now - this.ts;
|
||||||
|
this.timers.push(window.setTimeout(this.update, Math.max(60000 - this.age, 10000)));
|
||||||
|
} catch(ex) {
|
||||||
|
this.age = this.ts ? (new Date()) - this.ts : Infinity;
|
||||||
|
console.warn(ex);
|
||||||
|
this.timers.push(window.setTimeout(this.update, 30000));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async mounted() {
|
mounted() {
|
||||||
var practitioner = cookie.get('vista.practitioner');
|
this.$watch(
|
||||||
if(practitioner) this.practitioner = JSON.parse(practitioner);
|
() => (this.client, this.selection, this.date_begin, this.date_end, {}),
|
||||||
this.production = (await this.client.serverinfo()).result.production == '1';
|
debounce(async () => {
|
||||||
|
this.filter = {};
|
||||||
|
this.update();
|
||||||
|
}, 500)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
unmounted() {
|
||||||
|
clearTimeouts(this.timers);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
131
htdocs/ViewUserLookup.vue
Normal file
131
htdocs/ViewUserLookup.vue
Normal 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>
|
54
htdocs/ViewVisits.vue
Normal file
54
htdocs/ViewVisits.vue
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
<template>
|
||||||
|
<table v-if="resultset.length > 0" class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date/Time</th>
|
||||||
|
<th>Location</th>
|
||||||
|
<th style="text-align: right;">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="item in resultset">
|
||||||
|
<td>{{item.timestr}} {{item.datestr}}</td>
|
||||||
|
<td>{{item.location}}</td>
|
||||||
|
<td style="text-align: right;">{{item.status}}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { strftime_vista, strptime_vista } from './util.mjs';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
client: Object,
|
||||||
|
dfn: String,
|
||||||
|
date_begin: Date,
|
||||||
|
date_end: Date
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
resultset: []
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
params() {
|
||||||
|
return { dfn: this.dfn, date_begin: strftime_vista(this.date_begin), date_end: strftime_vista(this.date_end) };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
async params(value) {
|
||||||
|
try {
|
||||||
|
this.resultset = (await this.client.ORWCV_VST(value.dfn, value.date_begin, value.date_end, '')).map(item => Object.assign({
|
||||||
|
datestr: strptime_vista(item.datetime).toLocaleDateString('sv-SE'),
|
||||||
|
timestr: strptime_vista(item.datetime).toLocaleTimeString('en-GB')
|
||||||
|
}, item));
|
||||||
|
} catch(ex) {
|
||||||
|
this.resultset = [];
|
||||||
|
console.warn(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
@ -3,7 +3,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { strftime_vista, strptime_vista } from './util.mjs';
|
import { strftime_vista } from './util.mjs';
|
||||||
|
|
||||||
import ViewData from './ViewData.vue';
|
import ViewData from './ViewData.vue';
|
||||||
|
|
||||||
|
@ -3,8 +3,11 @@
|
|||||||
<label class="form-check form-check-inline form-switch form-check-label" v-for="report in reports">
|
<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}}
|
<input class="form-check-input" type="checkbox" v-model="report.selected" /> {{report.name}}
|
||||||
</label>
|
</label>
|
||||||
|
<label class="form-check form-check-inline form-switch form-check-label">
|
||||||
|
<input class="form-check-input" type="checkbox" v-model="calculate" /> Calculate
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<ViewData :resultset="resultset" :daily="true" :reports="reports_selected" />
|
<ViewData :resultset="resultset" :daily="true" :constants="calculate ? constants : []" :calculations="calculate ? calculations : []" :reports="reports_selected" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@ -12,15 +15,28 @@
|
|||||||
|
|
||||||
import ViewData from './ViewData.vue';
|
import ViewData from './ViewData.vue';
|
||||||
|
|
||||||
|
const time_min = new Date(1700, 0, 1);
|
||||||
|
|
||||||
|
const calculations = [
|
||||||
|
{ name: 'Age', unit: 'yr', deps: ['Time', 'DOB'], calc(Time, DOB, prev) { var x = Math.floor((Time - DOB.getTime())/3.15576e10); return x != prev ? x : undefined; } },
|
||||||
|
{ name: 'BMI', unit: 'kg/m²', rangeL: 18.5, rangeH: 24.9, range: '18.5 - 24.9', deps: ['Ht', 'Wt'], calc: (Ht, Wt) => (10000*Wt/(Ht*Ht)).toPrecision(3) },
|
||||||
|
{ name: 'BSA', unit: 'm²', deps: ['Ht', 'Wt'], calc: (Ht, Wt) => (0.007184*Math.pow(Ht, 0.725)*Math.pow(Wt, 0.425)).toPrecision(3) },
|
||||||
|
{ name: 'CrCl', unit: 'mL/min', deps: ['Age', 'Sex', 'Wt', 'CREATININE'], calc: (Age, Sex, Wt, CREATININE) => (((140 - Age) * Wt)/(72*CREATININE)*(Sex == 'M' ? 1 : 0.85)).toPrecision(4) },
|
||||||
|
{ name: 'RETICYLOCYTE#', unit: 'K/cmm', rangeL: 50, rangeH: 100, range: '50 - 100', deps: ['RBC', 'RETICULOCYTES'], calc: (RBC, RETICULOCYTES) => (10*RBC*RETICULOCYTES).toPrecision(3) },
|
||||||
|
{ name: 'CALCIUM CORRECTED', unit: 'mg/dL', rangeL: 8.9, rangeH: 10.3, range: '8.9 - 10.3', deps: ['CALCIUM', 'ALBUMIN'], calc: (CALCIUM, ALBUMIN) => ALBUMIN < 4 ? (+CALCIUM + 0.8*(4 - ALBUMIN)).toPrecision(3) : undefined },
|
||||||
|
{ name: 'IRON SATURATION', unit: '%', rangeL: 15, rangeH: 55, range: '15 - 55', comment: 'IRON/TIBC', deps: ['IRON', 'TIBC'], calc: (IRON, TIBC) => (100*IRON/TIBC).toPrecision(3) },
|
||||||
|
];
|
||||||
|
|
||||||
const reports = [
|
const reports = [
|
||||||
{ name: 'Vitals', value: ['T', 'P', 'R', 'SBP', 'DBP', 'Pulse Oximetry', 'Wt', 'Ht', 'Pain'], selected: true },
|
{ name: 'Vitals', value: ['T', 'P', 'R', 'SBP', 'DBP', 'Pulse Oximetry', 'Wt', 'Ht', 'BMI', 'BSA', 'Pain'], selected: true },
|
||||||
{ name: 'CBC', value: ['HGB', 'MCV', 'PLT', 'WBC', 'NEUTROPHIL#'], selected: false },
|
{ name: 'CBC', value: ['HGB', 'MCV', 'RETICYLOCYTE#', '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: 'Renal', value: ['CREATININE', 'UREA NITROGEN', 'EGFR CKD-EPI 2021', 'Estimated GFR dc\'d 3/30/2022', 'CrCl'], selected: false },
|
||||||
{ name: 'Hepatic', value: ['SGOT', 'SGPT', 'LDH', 'ALKALINE PHOSPHATASE', 'GAMMA-GTP', 'TOT. BILIRUBIN', 'DIR. BILIRUBIN', 'ALBUMIN'], 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: 'Electrolytes', value: ['SODIUM', 'CHLORIDE', 'CO2', 'CALCIUM', 'CALCIUM CORRECTED', 'IONIZED CALCIUM (LABCORP)', 'POTASSIUM', 'MAGNESIUM', 'PO4', 'ANION GAP', 'OSMOBLD'], selected: false },
|
||||||
{ name: 'Coagulation', value: ['PT', 'INR', 'PTT'], 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: 'Vitamins', value: ['FERRITIN', 'IRON', 'TIBC', 'IRON SATURATION', 'B 12', 'FOLATE', 'VITAMIN D TOTAL 25-OH'], selected: false },
|
||||||
{ name: 'Thyroid', value: ['TSH', 'T4 (THYROXINE)'], selected: false }
|
{ name: 'Thyroid', value: ['TSH', 'T4 (THYROXINE)'], selected: false },
|
||||||
|
{ name: 'Myeloma', value: ['PROTEIN,TOT SER (LC)', 'ALBUMIN [for SPEP](LC)', 'ALPHA-1 GLOBULIN S (LC)', 'ALPHA-2 GLOBULIN S (LC)', 'BETA GLOBULIN S (LC)', 'GAMMA GLOBULIN S (LC)', 'GLOBULIN,TOTAL S (LC)', 'A/G RATIO S (LC)', 'M-SPIKE S (LC)', 'IMMUNOFIXATION SERUM (LC)', 'FREE KAPPA LT CHAIN, S (LC)', 'FREE LAMBDA LT CHAIN, S (LC)', 'KAPPA/LAMBDA RATIO, S (LC)', 'KLRATIO', 'IMMUNOGLOBULIN G,QN (LC)', 'IMMUNOGLOBULIN A,QN (LC)', 'IMMUNOGLOBULIN M,QN (LC)', 'IGG', 'IGA', 'IGM', 'ALBUMIN [for RAND UR](LC):U', 'ALPHA-1 GLOB RAND UR(LC):U', 'ALPHA-2 GLOB RAND UR(LC):U', 'BETA GLOB RAND UR(LC):U', 'GAMMA GLOB RAND UR(LC):U', 'M-SPIKE% RAND UR(LC):U', 'PROTEIN,TOT UR(LC):U', 'FKLCUR:U', 'FLLCUR:U', 'KAPPA/LAMBDA RATIO, UR (LC):U', 'KLRATIO:U', 'PROTEIN,24H CALC(LC):U', 'ALBUMIN [for 24UPEP](LC):U', 'ALPHA-1 GLOBULIN 24H(LC):U', 'ALPHA-2 GLOBULIN 24H(LC):U', 'BETA GLOBULIN 24H(LC):U', 'GAMMA GLOBULIN 24H(LC):U', 'M-SPIKE% 24H(LC):U', 'M-SPIKE mg/24hr(LC):U', 'FR KAPPA LTCH:U', 'FR LAMBDA LTCH:U'], selected: false }
|
||||||
];
|
];
|
||||||
reports.reduce((acc, x) => acc[x] = x, reports);
|
reports.reduce((acc, x) => acc[x] = x, reports);
|
||||||
|
|
||||||
@ -33,13 +49,14 @@
|
|||||||
|
|
||||||
function vitals_normalize(rs) {
|
function vitals_normalize(rs) {
|
||||||
return rs.map(function(x) {
|
return rs.map(function(x) {
|
||||||
|
var comment = x.comment && x.comment.replace(/^\s+|\s+$/g, '').replace(/\s+/g, ' ');
|
||||||
var res = {
|
var res = {
|
||||||
time: x.datetime,
|
time: x.datetime,
|
||||||
name: x.name,
|
name: x.name,
|
||||||
unit: x.unit,
|
unit: x.unit,
|
||||||
value: x.value,
|
value: x.value,
|
||||||
flag: x.flag,
|
flag: x.flag,
|
||||||
comment: x.user
|
comment: comment ? x.user + ' • ' + comment : x.user
|
||||||
};
|
};
|
||||||
return vitals_mapping[x.name] ? Object.assign(res, vitals_mapping[x.name]) : res;
|
return vitals_mapping[x.name] ? Object.assign(res, vitals_mapping[x.name]) : res;
|
||||||
});
|
});
|
||||||
@ -47,9 +64,10 @@
|
|||||||
|
|
||||||
function labs_normalize(rs) {
|
function labs_normalize(rs) {
|
||||||
return rs.map(function(x) {
|
return rs.map(function(x) {
|
||||||
|
var specimen = x.specimen;
|
||||||
return {
|
return {
|
||||||
time: x.time_collected,
|
time: x.time_collected,
|
||||||
name: x.name,
|
name: (specimen == 'BLOOD') || (specimen == 'SERUM') || (specimen == 'PLASMA') ? x.name : (specimen == 'RANDOM URINE') ? x.name + ':UR' : specimen ? x.name + ':' + specimen.charAt(0) : x.name,
|
||||||
unit: x.unit,
|
unit: x.unit,
|
||||||
range: x.range,
|
range: x.range,
|
||||||
value: x.value,
|
value: x.value,
|
||||||
@ -71,20 +89,29 @@
|
|||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
patientinfo: null,
|
||||||
resultset: null,
|
resultset: null,
|
||||||
reports
|
calculate: true,
|
||||||
|
calculations, reports
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
params() {
|
params() {
|
||||||
return { dfn: this.dfn, date_begin: strftime_vista(this.date_begin), date_end: strftime_vista(this.date_end) };
|
return { dfn: this.dfn, date_begin: strftime_vista(this.date_begin), date_end: strftime_vista(this.date_end) };
|
||||||
},
|
},
|
||||||
|
constants() {
|
||||||
|
return this.patientinfo ? {
|
||||||
|
DOB: { time: time_min, value: strptime_vista(this.patientinfo.dob) },
|
||||||
|
Sex: { time: time_min, value: this.patientinfo.sex }
|
||||||
|
} : {};
|
||||||
|
},
|
||||||
reports_selected() {
|
reports_selected() {
|
||||||
return this.reports.filter(x => x.selected).map(x => x.value);
|
return this.reports.filter(x => x.selected).map(x => x.value);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
async params(value, oldvalue) {
|
async params(value, oldvalue) {
|
||||||
|
this.patientinfo = await this.client.ORWPT16_ID_INFO(value.dfn);
|
||||||
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)));
|
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)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
1704
htdocs/adapter/UTIF.js
Normal file
1704
htdocs/adapter/UTIF.js
Normal file
File diff suppressed because it is too large
Load Diff
30647
htdocs/adapter/dicom.ts.js
Normal file
30647
htdocs/adapter/dicom.ts.js
Normal file
File diff suppressed because one or more lines are too long
64
htdocs/adapter/view.dcm.html
Normal file
64
htdocs/adapter/view.dcm.html
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>DICOM</title>
|
||||||
|
<style type="text/css">
|
||||||
|
figure {
|
||||||
|
border: 1px solid #000;
|
||||||
|
}
|
||||||
|
figcaption {
|
||||||
|
border-bottom: 1px solid #000;
|
||||||
|
padding: 0.25em;
|
||||||
|
background-color: #ccc;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script type="text/javascript" src="/adapter/dicom.ts.js"></script>
|
||||||
|
<script type="text/javascript">
|
||||||
|
function request(method, url, responseType) {
|
||||||
|
return new Promise(function(resolve, reject) {
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open(method, url);
|
||||||
|
if(responseType) xhr.responseType = responseType;
|
||||||
|
xhr.onload = resolve;
|
||||||
|
xhr.onerror = reject;
|
||||||
|
xhr.send();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createElementX(tag, attrs) {
|
||||||
|
var res = document.createElement(tag);
|
||||||
|
if(attrs) for(var k in attrs) if(attrs.hasOwnProperty(k)) res.setAttribute(k, attrs[k]);
|
||||||
|
for(var i = 2, child; i < arguments.length; ++i) res.appendChild((typeof (child = arguments[i]) === 'string') || (child instanceof String) ? document.createTextNode(child) : child);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pathname = window.location.pathname, filename = document.title = pathname.split('/').pop();
|
||||||
|
|
||||||
|
var body = document.body;
|
||||||
|
var header = document.createElement('div');
|
||||||
|
header.append(createElementX('a', { href: pathname }, 'Download ' + filename))
|
||||||
|
body.appendChild(header);
|
||||||
|
|
||||||
|
request('GET', pathname, 'arraybuffer').then(function(evt) {
|
||||||
|
var filedata = evt.target.response;
|
||||||
|
var cnv = document.createElement('canvas'), renderer = new dicom.ts.Renderer(cnv), img;
|
||||||
|
var dataset = dicom.ts.parseImage(filedata), count = dataset.numberOfFrames;
|
||||||
|
header.appendChild(createElementX('div', null, dataset.patientName + ' #' + dataset.patientID));
|
||||||
|
header.appendChild(createElementX('div', null, dataset.imageDescription));
|
||||||
|
header.appendChild(createElementX('div', null, dataset.studyDate.toLocaleDateString('sv-SE') + ' @ ' + dataset.studyTime));
|
||||||
|
header.appendChild(createElementX('div', null, dataset.modality + ' series #' + dataset.seriesNumber));
|
||||||
|
(function renderall(i) {
|
||||||
|
if((i = i || 0) >= count) return;
|
||||||
|
renderer.render(dataset, i).then(function() {
|
||||||
|
body.appendChild(createElementX('figure', null, createElementX('figcaption', null, 'Image ' + (i + 1) + ' of ' + count), img = createElementX('img', { src: cnv.toDataURL() })));
|
||||||
|
img.style.width = '100%';
|
||||||
|
renderall(i + 1);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
59
htdocs/adapter/view.tif.html
Normal file
59
htdocs/adapter/view.tif.html
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>TIFF</title>
|
||||||
|
<style type="text/css">
|
||||||
|
figure {
|
||||||
|
border: 1px solid #000;
|
||||||
|
}
|
||||||
|
figcaption {
|
||||||
|
border-bottom: 1px solid #000;
|
||||||
|
padding: 0.25em;
|
||||||
|
background-color: #ccc;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script type="text/javascript" src="/adapter/UTIF.js"></script>
|
||||||
|
<script type="text/javascript">
|
||||||
|
function request(method, url, responseType) {
|
||||||
|
return new Promise(function(resolve, reject) {
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open(method, url);
|
||||||
|
if(responseType) xhr.responseType = responseType;
|
||||||
|
xhr.onload = resolve;
|
||||||
|
xhr.onerror = reject;
|
||||||
|
xhr.send();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createElementX(tag, attrs) {
|
||||||
|
var res = document.createElement(tag);
|
||||||
|
if(attrs) for(var k in attrs) if(attrs.hasOwnProperty(k)) res.setAttribute(k, attrs[k]);
|
||||||
|
for(var i = 2, child; i < arguments.length; ++i) res.appendChild((typeof (child = arguments[i]) === 'string') || (child instanceof String) ? document.createTextNode(child) : child);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pathname = window.location.pathname, filename = document.title = pathname.split('/').pop();
|
||||||
|
|
||||||
|
var body = document.body;
|
||||||
|
var header = document.createElement('div');
|
||||||
|
header.append(createElementX('a', { href: pathname }, 'Download ' + filename))
|
||||||
|
body.appendChild(header);
|
||||||
|
|
||||||
|
request('GET', pathname, 'arraybuffer').then(function(evt) {
|
||||||
|
var filedata = evt.target.response;
|
||||||
|
var cnv = document.createElement('canvas'), ctx = cnv.getContext('2d'), img;
|
||||||
|
var pages = UTIF.decode(filedata), page;
|
||||||
|
for(var i = 0; i < pages.length; ++i) {
|
||||||
|
UTIF.decodeImage(filedata, page = pages[i], pages);
|
||||||
|
var rgba = UTIF.toRGBA8(page);
|
||||||
|
ctx.putImageData(new ImageData(new Uint8ClampedArray(rgba.buffer), cnv.width = page.width, cnv.height = page.height), 0, 0);
|
||||||
|
body.appendChild(createElementX('figure', null, createElementX('figcaption', null, 'Page ' + (i + 1) + ' of ' + pages.length), img = createElementX('img', { src: cnv.toDataURL() })));
|
||||||
|
img.style.width = '100%';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
3
htdocs/common.mjs
Normal file
3
htdocs/common.mjs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { reactive } from 'vue';
|
||||||
|
|
||||||
|
export const menustate = reactive([]);
|
46
htdocs/constants.mjs
Normal file
46
htdocs/constants.mjs
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
// Ordering Dialog Form IDs
|
||||||
|
export const OD_ACTIVITY = 100;
|
||||||
|
export const OD_ALLERGY = 105;
|
||||||
|
export const OD_CONSULT = 110;
|
||||||
|
export const OD_PROCEDURE = 112;
|
||||||
|
export const OD_DIET_TXT = 115;
|
||||||
|
export const OD_DIET = 117;
|
||||||
|
export const OD_LAB = 120;
|
||||||
|
export const OD_AP = 121;
|
||||||
|
export const OD_BB = 125;
|
||||||
|
export const OD_MEDINPT = 130;
|
||||||
|
export const OD_MEDS = 135;
|
||||||
|
export const OD_MEDOUTPT = 140;
|
||||||
|
export const OD_MEDNONVA = 145;
|
||||||
|
export const OD_NURSING = 150;
|
||||||
|
export const OD_MISC = 151;
|
||||||
|
export const OD_GENERIC = 152;
|
||||||
|
export const OD_IMAGING = 160;
|
||||||
|
export const OD_VITALS = 171; // use 170 for ORWD GENERIC VITALS, 171 for GMRVOR
|
||||||
|
export const OD_RTC = 175;
|
||||||
|
export const OD_MEDIV = 180;
|
||||||
|
export const OD_TEXTONLY = 999;
|
||||||
|
export const OM_NAV = 1001;
|
||||||
|
export const OM_QUICK = 1002;
|
||||||
|
export const OM_TABBED = 1003;
|
||||||
|
export const OM_TREE = 1004;
|
||||||
|
export const OM_ALLERGY = 1105;
|
||||||
|
export const OM_HTML = 1200;
|
||||||
|
export const OD_AUTOACK = 9999;
|
||||||
|
export const OD_CLINICMED = 1444;
|
||||||
|
export const OD_CLINICINF = 1555;
|
||||||
|
|
||||||
|
export const ORDERABLE_DGNM = [
|
||||||
|
{ dgnm: 'RX', name: 'Medication' },
|
||||||
|
{ dgnm: 'UD RX', name: 'Inpatient' },
|
||||||
|
{ dgnm: 'O RX', name: 'Outpatient' },
|
||||||
|
{ dgnm: 'NURS', name: 'Nursing' },
|
||||||
|
{ dgnm: 'DO', name: 'Diet' },
|
||||||
|
{ dgnm: 'LAB', name: 'Laboratory' },
|
||||||
|
{ dgnm: 'XRAY', name: 'Imaging' },
|
||||||
|
{ dgnm: 'CSLT', name: 'Consult' },
|
||||||
|
{ dgnm: 'PROC', name: 'Procedure' },
|
||||||
|
{ dgnm: 'NV RX', name: 'Non-VA' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ORDERABLE_XREF = ORDERABLE_DGNM.map(x => { return { xref: 'S.' + x.dgnm, name: x.name }; });
|
86
htdocs/fmdatetime.mjs
Normal file
86
htdocs/fmdatetime.mjs
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
const re_dt_fileman = /(?<dt_fileman>(\d{3})(\d{2})(\d{2})(?:\.(\d{2})?(\d{2})?(\d{2})?)?)/i; // George Timson's format
|
||||||
|
const re_dt_today = /(?<dt_today>TODAY|T)/i; // today
|
||||||
|
const re_dt_now = /(?<dt_now>NOW|N)/i; // now
|
||||||
|
const re_dt_mdy = /(?<dt_mdy>(\d{1,2})[^\w@?]+(\d{1,2})[^\w@?]+(\d{4}|\d{2})\s*)/i; // m/d/yy, m/d/yyyy
|
||||||
|
const re_dt_ymd = /(?<dt_ymd>(\d{4})[^\w@?]+(\d{1,2})[^\w@?]+(\d{1,2})\s*)/i; // yyyy/m/d
|
||||||
|
const re_dt_yyyymmdd = /(?<dt_yyyymmdd>(\d{4})(\d{2})(\d{2}))/i; // yyyymmdd
|
||||||
|
const re_dt_Mdy = /(?<dt_Mdy>([A-Z]{3,})[^\w@?]+(\d{1,2})[^\w@?]+(\d{4}|\d{2})\s*)/i; // M/d/yy, M/d/yyyy
|
||||||
|
const re_dt_dMy = /(?<dt_dMy>(\d{1,2})[^\w@?]+([A-Z]{3,})[^\w@?]+(\d{4}|\d{2})\s*)/i; // d/M/yy, d/M/yyyy
|
||||||
|
const re_dt_md = /(?<dt_md>(\d{1,2})[^\w@?]+(\d{1,2})\s*)/i; // m/d
|
||||||
|
const re_dt_offset = /(?<offset>([-+]\d+)(H|W|M)?)/i; // +#U
|
||||||
|
const re_dt_time = /(?:\s?@?(?<time>(\d{1,2})\:?(\d{1,2})(?:\:?(\d{1,2}))?))/i; // time
|
||||||
|
const re_dt_ext = /(?<ext>[<>])/i; // (nonstandard extension)
|
||||||
|
const rx_dt = new RegExp(`^${re_dt_fileman.source}\$|^(?:(?:${re_dt_today.source}|${re_dt_now.source}|${re_dt_mdy.source}|${re_dt_ymd.source}|${re_dt_yyyymmdd.source}|${re_dt_Mdy.source}|${re_dt_dMy.source}|${re_dt_md.source})?${re_dt_offset.source}?${re_dt_time.source}?${re_dt_ext.source}?)\$`, 'i');
|
||||||
|
|
||||||
|
export function validtime(s) {
|
||||||
|
return rx_dt.test(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function strptime(s) {
|
||||||
|
// Parse VistA-style datetime strings into Date objects
|
||||||
|
var m, m1;
|
||||||
|
if(m = rx_dt.exec(s.replace(/^\s+|\s+$/g, '').toLowerCase())) {
|
||||||
|
m = m.groups;
|
||||||
|
if(m.dt_fileman) {
|
||||||
|
m1 = re_dt_fileman.exec(m.dt_fileman);
|
||||||
|
return new Date(1700 + +m1[2], +m1[3] - 1, +m1[4], +m1[5] || 0, +m1[6] || 0, +m1[7] || 0);
|
||||||
|
}
|
||||||
|
var date = new Date(); date.setHours(0, 0, 0, 0);
|
||||||
|
if(m.dt_today) ;
|
||||||
|
else if(m.dt_now) date = new Date();
|
||||||
|
else if(m.dt_mdy) date.setFullYear(strptime_year(+(m1 = re_dt_mdy.exec(m.dt_mdy))[4], date), +m1[2] - 1, +m1[3]);
|
||||||
|
else if(m.dt_ymd) date.setFullYear(+(m1 = re_dt_ymd.exec(m.dt_ymd))[2], +m1[3] - 1, +m1[4]);
|
||||||
|
else if(m.dt_yyyymmdd) date.setFullYear(+(m1 = re_dt_yyyymmdd.exec(m.dt_yyyymmdd))[2], +m1[3] - 1, +m1[4]);
|
||||||
|
else if(m.dt_Mdy) date.setFullYear(strptime_year(+(m1 = re_dt_Mdy.exec(m.dt_Mdy))[4], date), strptime_month(m1[2]) - 1, +m1[3]);
|
||||||
|
else if(m.dt_dMy) date.setFullYear(strptime_year(+(m1 = re_dt_dMy.exec(m.dt_dMy))[4], date), strptime_month(m1[3]) - 1, +m1[2]);
|
||||||
|
else if(m.dt_md) { date.setMonth(+(m1 = re_dt_md.exec(m.dt_md))[2] - 1); date.setDate(+m1[3]); }
|
||||||
|
if(m.time) {
|
||||||
|
if(m.dt_now) throw 'cannot specify NOW with time';
|
||||||
|
date.setHours(+(m1 = re_dt_time.exec(m.time))[2], +m1[3], +m1[4] || 0);
|
||||||
|
}
|
||||||
|
if(m.offset) {
|
||||||
|
m1 = re_dt_offset.exec(m.offset);
|
||||||
|
if((m1[3] == 'h') && ((m.time) || (m.dt_today))) throw 'cannot specify time or TODAY with H offset'
|
||||||
|
date = strptime_offset(date, +m1[2], m1[3] || 'd');
|
||||||
|
}
|
||||||
|
if(m.ext) {
|
||||||
|
if(m.ext == '<') date.setHours(0, 0, 0, 0);
|
||||||
|
else if(m.ext == '>') date.setHours(23, 59, 59, 999);
|
||||||
|
}
|
||||||
|
if(date.year < 1800) throw 'cannot specify year before 1800';
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function strptime_year(y, today) {
|
||||||
|
// Promote years to 4 digits
|
||||||
|
return y >= 1000 ? y : (y + 2000) < (today.getFullYear() + 20) ? (y + 2000) : (y + 1900);
|
||||||
|
}
|
||||||
|
|
||||||
|
const strptime_month_mapping = {'jan': 1, 'feb': 2, 'mar': 3, 'apr': 4, 'may': 5, 'jun': 6, 'jul': 7, 'aug': 8, 'sep': 9, 'oct': 10, 'nov': 11, 'dec': 12};
|
||||||
|
function strptime_month(m) {
|
||||||
|
// Convert en-US month names to integers
|
||||||
|
return strptime_month_mapping[m.substring(0, 3)];
|
||||||
|
}
|
||||||
|
|
||||||
|
const strptime_offset_mapping = {'h': 60*60*1000, 'd': 24*60*60*1000, 'w': 7*24*60*60*1000, 'm': null};
|
||||||
|
function strptime_offset(base, offset, suffix) {
|
||||||
|
// Apply datetime offset
|
||||||
|
return suffix != 'm' ? new Date(base.getTime() + offset*strptime_offset_mapping[suffix]) : (base = new Date(base), base.setMonth(base.getMonth() + offset), base);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function strftime(date) {
|
||||||
|
// Convert Date objects into conventional FileMan/Timson format
|
||||||
|
var d = '' + (date.getFullYear() - 1700) + ('00' + (date.getMonth() + 1)).slice(-2) + ('00' + date.getDate()).slice(-2);
|
||||||
|
var t = '' + ('00' + date.getHours()).slice(-2) + ('00' + date.getMinutes()).slice(-2) + ('00' + date.getSeconds()).slice(-2);
|
||||||
|
return t == '000000' ? d : (d + '.' + t);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function datefloat(date) {
|
||||||
|
// Convert Python datetime.datetime objects into floating point FileMan/Timson format
|
||||||
|
var d = 10000*(date.getFullYear() - 1700) + 100*(date.getMonth() + 1) + date.getDate();
|
||||||
|
var t = date.getHours()/100 + date.getMinutes()/10000 + date.getSeconds()/1000000 + date.getMilliseconds()/1000000000;
|
||||||
|
return t == 0.0 ? d : (d + t);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default window.fmdatetime = { validtime, strptime, strftime, datefloat };
|
113
htdocs/icon.svg
Normal file
113
htdocs/icon.svg
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
width="48mm"
|
||||||
|
height="48mm"
|
||||||
|
viewBox="0 0 48 48"
|
||||||
|
version="1.1"
|
||||||
|
id="svg5"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<defs
|
||||||
|
id="defs2">
|
||||||
|
<filter
|
||||||
|
style="color-interpolation-filters:sRGB"
|
||||||
|
id="filter3763"
|
||||||
|
x="-0.78863951"
|
||||||
|
y="-0.88395267"
|
||||||
|
width="2.577279"
|
||||||
|
height="2.7679053">
|
||||||
|
<feFlood
|
||||||
|
flood-opacity="0.498039"
|
||||||
|
flood-color="rgb(0,0,0)"
|
||||||
|
result="flood"
|
||||||
|
id="feFlood3753" />
|
||||||
|
<feComposite
|
||||||
|
in="flood"
|
||||||
|
in2="SourceGraphic"
|
||||||
|
operator="in"
|
||||||
|
result="composite1"
|
||||||
|
id="feComposite3755" />
|
||||||
|
<feGaussianBlur
|
||||||
|
in="composite1"
|
||||||
|
stdDeviation="3.3734374"
|
||||||
|
result="blur"
|
||||||
|
id="feGaussianBlur3757" />
|
||||||
|
<feOffset
|
||||||
|
dx="0"
|
||||||
|
dy="0"
|
||||||
|
result="offset"
|
||||||
|
id="feOffset3759" />
|
||||||
|
<feComposite
|
||||||
|
in="SourceGraphic"
|
||||||
|
in2="offset"
|
||||||
|
operator="over"
|
||||||
|
result="fbSourceGraphic"
|
||||||
|
id="feComposite3761" />
|
||||||
|
<feColorMatrix
|
||||||
|
result="fbSourceGraphicAlpha"
|
||||||
|
in="fbSourceGraphic"
|
||||||
|
values="0 0 0 -1 0 0 0 0 -1 0 0 0 0 -1 0 0 0 0 1 0"
|
||||||
|
id="feColorMatrix3765" />
|
||||||
|
<feFlood
|
||||||
|
id="feFlood3767"
|
||||||
|
flood-opacity="0.498039"
|
||||||
|
flood-color="rgb(0,0,0)"
|
||||||
|
result="flood"
|
||||||
|
in="fbSourceGraphic" />
|
||||||
|
<feComposite
|
||||||
|
in2="fbSourceGraphic"
|
||||||
|
id="feComposite3769"
|
||||||
|
in="flood"
|
||||||
|
operator="in"
|
||||||
|
result="composite1" />
|
||||||
|
<feGaussianBlur
|
||||||
|
id="feGaussianBlur3771"
|
||||||
|
in="composite1"
|
||||||
|
stdDeviation="6"
|
||||||
|
result="blur" />
|
||||||
|
<feOffset
|
||||||
|
id="feOffset3773"
|
||||||
|
dx="0"
|
||||||
|
dy="0"
|
||||||
|
result="offset" />
|
||||||
|
<feComposite
|
||||||
|
in2="offset"
|
||||||
|
id="feComposite3775"
|
||||||
|
in="fbSourceGraphic"
|
||||||
|
operator="over"
|
||||||
|
result="composite2" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<g
|
||||||
|
id="layer1"
|
||||||
|
transform="translate(-33.302987,-22.535447)">
|
||||||
|
<rect
|
||||||
|
style="fill:#003f72;fill-opacity:1;stroke-width:0.762"
|
||||||
|
id="rect325"
|
||||||
|
width="48"
|
||||||
|
height="48"
|
||||||
|
x="33.302986"
|
||||||
|
y="22.535448"
|
||||||
|
ry="8" />
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-style:italic;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:50.8px;line-height:1.25;font-family:Georgia;-inkscape-font-specification:'Georgia Bold Italic';letter-spacing:0px;word-spacing:0px;stroke-width:0.264583"
|
||||||
|
x="55.709675"
|
||||||
|
y="62.096775"
|
||||||
|
id="text2458"><tspan
|
||||||
|
id="tspan2456"
|
||||||
|
style="font-style:italic;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:50.8px;font-family:Georgia;-inkscape-font-specification:'Georgia Bold Italic';stroke-width:0.264583"
|
||||||
|
x="55.709675"
|
||||||
|
y="62.096775" /></text>
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-size:50.8px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#f3cf45;fill-opacity:1;stroke-width:0.264583;filter:url(#filter3763)"
|
||||||
|
x="42.581402"
|
||||||
|
y="58.962597"
|
||||||
|
id="text2466"><tspan
|
||||||
|
id="tspan2464"
|
||||||
|
style="font-style:italic;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:50.8px;font-family:Georgia;-inkscape-font-specification:'Georgia Bold Italic';fill:#f3cf45;fill-opacity:1;stroke-width:0.264583"
|
||||||
|
x="42.581402"
|
||||||
|
y="58.962597">ν</tspan></text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 3.6 KiB |
@ -7,6 +7,7 @@
|
|||||||
<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="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="/table-sticky.css" />
|
||||||
<link rel="stylesheet" type="text/css" href="/userstyle.css" />
|
<link rel="stylesheet" type="text/css" href="/userstyle.css" />
|
||||||
|
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||||
</head>
|
</head>
|
||||||
<body><div id='root'></div></body>
|
<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@3.2"></script>
|
||||||
|
56
htdocs/numberwords.mjs
Normal file
56
htdocs/numberwords.mjs
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
function pos_int2words(n,
|
||||||
|
ones=['' ,'one' ,'two' ,'three' ,'four' ,'five' ,'six' ,'seven' ,'eight' ,'nine'],
|
||||||
|
teens=['ten', 'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen', 'sixteen', 'seventeen', 'eighteen', 'nineteen'],
|
||||||
|
tens=['', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', 'eighty', 'ninety']) {
|
||||||
|
if(n < 10) return ones[n];
|
||||||
|
else if(n < 20) return teens[n - 10];
|
||||||
|
else if(n < 100) return tens[Math.floor(n/10)] + (n%10 > 0 ? '-' + ones[n%10] : '');
|
||||||
|
else if(n < 1000) return ones[Math.floor(n/100)] + ' hundred ' + pos_int2words(n%100);
|
||||||
|
else if(n < 1000000) return pos_int2words(Math.floor(n/1000)) + ' thousand ' + pos_int2words(n%1000);
|
||||||
|
else return pos_int2words(Math.floor(n/1000000)) + ' million ' + pos_int2words(n%1000000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function int2words(n,
|
||||||
|
ones=['' ,'one' ,'two' ,'three' ,'four' ,'five' ,'six' ,'seven' ,'eight' ,'nine'],
|
||||||
|
teens=['ten', 'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen', 'sixteen', 'seventeen', 'eighteen', 'nineteen'],
|
||||||
|
tens=['', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', 'eighty', 'ninety']) {
|
||||||
|
if(n == 0) return 'zero';
|
||||||
|
else if(n < 0) return 'negative ' + pos_int2words(n, ones, teens, tens).replace(/\s+$/, '');
|
||||||
|
else return pos_int2words(n, ones, teens, tens).replace(/\s+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cardinal2ordinal(s, re_magnitudes=/(hundred|thousand|(m|b|tr|quadr)illion)$/, re_low=/(zero|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)$/, ordinals={ zero: 'zeroth', one: 'first', two: 'second', three: 'third', four: 'fourth', five: 'fifth', six: 'sixth', seven: 'seventh', eight: 'eighth', nine: 'ninth', ten: 'tenth', eleven: 'eleventh', twelve: 'twelfth' }) {
|
||||||
|
if((s.slice(-4) == 'teen') || (re_magnitudes.test(s))) return s + 'th';
|
||||||
|
else if(s.charAt(s.length - 1) == 'y') return s.substring(0, s.length - 1) + 'ieth';
|
||||||
|
else return s.replace(re_low, (m, w) => ordinals[w]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function float2fraction(x, epsilon=0.0001) {
|
||||||
|
if(x == 0) return [0, 1];
|
||||||
|
const a = Math.abs(x);
|
||||||
|
let n = 0, d = 1, r;
|
||||||
|
while(Math.abs((r = n/d) - a)/a >= epsilon) if(r < a) n++;
|
||||||
|
else d++;
|
||||||
|
return [x < 0 ? -n : n, d];
|
||||||
|
}
|
||||||
|
|
||||||
|
function pos_fraction2words(x, epsilon=0.0001) {
|
||||||
|
const frac = float2fraction(x, epsilon), n = frac[0], d = frac[1];
|
||||||
|
if(d == 1) return int2words(x);
|
||||||
|
else if(d == 2) return int2words(n) + (n >= 2 ? ' halves' : ' half');
|
||||||
|
else if(d == 4) return int2words(n) + (n >= 2 ? ' quarters' : ' quarter');
|
||||||
|
else return int2words(n) + ' ' + cardinal2ordinal(int2words(d)) + (n >= 2 ? 's' : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function num2words(n, epsilon=0.0001) {
|
||||||
|
if(n == 0) return 'zero';
|
||||||
|
else if(n < 0) return 'negative ' + num2words(-n);
|
||||||
|
var intpart = n|0;
|
||||||
|
if(n == intpart) return int2words(n);
|
||||||
|
else if(intpart == 0) return pos_fraction2words(n, epsilon);
|
||||||
|
else return num2words(intpart) + ' and ' + pos_fraction2words(n%1, epsilon);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function translate(s, epsilon=0.0001) {
|
||||||
|
return ('' + s).replace(/([+-]?(?:\d+|\d+\.\d+))/g, (m, w) => num2words(w, epsilon));
|
||||||
|
}
|
@ -52,13 +52,16 @@ function lab_parse1default(data) {
|
|||||||
if(x.comment) x.comment.push(line.substring(12));
|
if(x.comment) x.comment.push(line.substring(12));
|
||||||
else x.comment = [line.substring(12)];
|
else x.comment = [line.substring(12)];
|
||||||
} else console.log('DANGLING:', line);
|
} 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+)\])?$/))) {
|
} else if(m = line.match(/^\b(?<name>.*?)\s{2,}(?<value>.*?)(?: (?<flag>L\*|L|H\*|H))?\s+(?:(?<unit>.{10}) (?<range>.*?)(?: \[(?<site>\d+)\])?)?$/)) {
|
||||||
|
if(x = line.match(/^\b(?<name>.*?)(?<value>(?:positive|negative|reactive|nonreactive|detected|not detected|comment|collected - specimen in lab|test not performed))(?: (?<flag>L\*|L|H\*|H))?\s+(?:(?<unit>.{10}) (?<range>.*?)(?: \[(?<site>\d+)\])?)?$/i)) m = x;
|
||||||
if((m.groups.range) && (m.groups.range.startsWith('Ref: '))) m.groups.range = m.groups.range.substring(5);
|
if((m.groups.range) && (m.groups.range.startsWith('Ref: '))) m.groups.range = m.groups.range.substring(5);
|
||||||
results.push(x = m.groups);
|
results.push(x = m.groups);
|
||||||
|
if((x.value === '') && (m = x.name.match(/^(?<name>.*?)(?<value>(?:[\d\.]+|positive|negative|reactive|detected|not detected|comment|collected - specimen in lab|test not performed))\s*$/i))) {
|
||||||
|
x.name = m.groups.name;
|
||||||
|
x.value = m.groups.value;
|
||||||
|
}
|
||||||
for(var k in x) if(x[k]) x[k] = x[k] ? x[k].replace(/^\s+|\s+$/g, '') : undefined;
|
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>.*?)(?<value>(?:[\d\.]+|positive|negative|reactive|nonreactive|detected|not detected|comment|collected - specimen in lab|test not performed))(?: (?<flag>L\*|L|H\*|H))?\s*$/i)) {
|
||||||
} 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);
|
results.push(x = m.groups);
|
||||||
for(var k in x) if(x[k]) x[k] = x[k] ? x[k].replace(/^\s+|\s+$/g, '') : undefined;
|
for(var k in x) if(x[k]) x[k] = x[k] ? x[k].replace(/^\s+|\s+$/g, '') : undefined;
|
||||||
} else if(line.startsWith(' [')) {
|
} else if(line.startsWith(' [')) {
|
||||||
@ -72,24 +75,81 @@ function lab_parse1default(data) {
|
|||||||
x.site = line.split('[')[1].split(']')[0];
|
x.site = line.split('[')[1].split(']')[0];
|
||||||
} else x.range = line.replace(/^\s+|\s+$/g, '');
|
} else x.range = line.replace(/^\s+|\s+$/g, '');
|
||||||
} else console.log('DANGLING:', line);
|
} else console.log('DANGLING:', line);
|
||||||
|
} else if(m = line.match(/^\s{40}\b(?:(?<unit>.{10}) (?<range>.*?)(?: \[(?<site>\d+)\])?)?$/)) {
|
||||||
|
if(results.length > 0) {
|
||||||
|
x = results[results.length - 1];
|
||||||
|
if(m.groups.unit) x.unit = m.groups.unit.replace(/^\s+|\s+$/g, '');
|
||||||
|
if(m.groups.range) x.range = m.groups.range.replace(/^\s+|\s+$/g, '');
|
||||||
|
if(m.groups.site) x.site = m.groups.site.replace(/^\s+|\s+$/g, '');
|
||||||
|
} else console.log('DANGLING:', line, m.groups);
|
||||||
} else console.log('INVALID:', line);
|
} else console.log('INVALID:', line);
|
||||||
}
|
}
|
||||||
for(var i = results.length - 1; i >= 0; --i) {
|
for(var i = results.length - 1; i >= 0; --i) {
|
||||||
results[(x = results[i]).name] = x;
|
results[(x = results[i]).name] = x;
|
||||||
if(x.comment) x.comment = x.comment.join('\n');
|
if(x.comment) x.comment = x.comment.join('\n');
|
||||||
}
|
}
|
||||||
if((res.accession.startsWith('HE ')) && ((results.hasOwnProperty('SEGS')) || (results.hasOwnProperty('BANDS')))) {
|
if((res.accession) && (res.accession.startsWith('HE '))) {
|
||||||
|
if((results.hasOwnProperty('SEGS')) || (results.hasOwnProperty('BANDS'))) {
|
||||||
results.push(results['NEUTROPHIL%'] = {
|
results.push(results['NEUTROPHIL%'] = {
|
||||||
name: 'NEUTROPHIL%', unit: '%', range: '42.2 - 75.2',
|
name: 'NEUTROPHIL%', unit: '%', range: '42.2 - 75.2',
|
||||||
value: x = (results.hasOwnProperty('SEGS') ? +results.SEGS.value : 0) + (results.hasOwnProperty('BANDS') ? +results.BANDS.value : 0),
|
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)
|
flag: (x < 42.2 ? 'L' : x > 75.2 ? 'H' : undefined)
|
||||||
});
|
});
|
||||||
results.push(results['NEUTROPHIL#'] = {
|
if(results.WBC) results.push(results['NEUTROPHIL#'] = {
|
||||||
name: 'NEUTROPHIL#', unit: 'K/cmm', range: '1.4 - 6.5',
|
name: 'NEUTROPHIL#', unit: 'K/cmm', range: '1.4 - 6.5',
|
||||||
value: +(x = 0.01*x*results.WBC.value).toFixed(3),
|
value: +(x = 0.01*x*results.WBC.value).toFixed(3),
|
||||||
flag: (x < 1.4 ? 'L' : x > 6.5 ? 'H' : undefined)
|
flag: (x < 1.4 ? 'L' : x > 6.5 ? 'H' : undefined)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if(results.hasOwnProperty('EOSINO')) {
|
||||||
|
results.push(results['EOSINOPHIL%'] = {
|
||||||
|
name: 'EOSINOPHIL%', unit: '%', range: '0.0 - 10.0',
|
||||||
|
value: x = +results.EOSINO.value,
|
||||||
|
flag: (x < 0 ? 'L' : x > 10 ? 'H' : undefined)
|
||||||
|
});
|
||||||
|
if(results.WBC) results.push(results['EOSINOPHIL#'] = {
|
||||||
|
name: 'EOSINOPHIL#', unit: 'K/cmm', range: '0.0 - 0.7',
|
||||||
|
value: +(x = 0.01*x*results.WBC.value).toFixed(3),
|
||||||
|
flag: (x < 0 ? 'L' : x > 0.7 ? 'H' : undefined)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if(results.hasOwnProperty('BASO')) {
|
||||||
|
results.push(results['BASOPHIL%'] = {
|
||||||
|
name: 'BASOPHIL%', unit: '%', range: '0.0 - 2.0',
|
||||||
|
value: x = +results.BASO.value,
|
||||||
|
flag: (x < 0 ? 'L' : x > 2 ? 'H' : undefined)
|
||||||
|
});
|
||||||
|
if(results.WBC) results.push(results['BASOPHIL#'] = {
|
||||||
|
name: 'BASOPHIL#', unit: 'K/cmm', range: '0.0 - 0.2',
|
||||||
|
value: +(x = 0.01*x*results.WBC.value).toFixed(3),
|
||||||
|
flag: (x < 0 ? 'L' : x > 0.2 ? 'H' : undefined)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if(results.hasOwnProperty('MONOS')) {
|
||||||
|
results.push(results['MONOCYTE%'] = {
|
||||||
|
name: 'MONOCYTE%', unit: '%', range: '1.7 - 9.3',
|
||||||
|
value: x = +results.MONOS.value,
|
||||||
|
flag: (x < 1.7 ? 'L' : x > 9.3 ? 'H' : undefined)
|
||||||
|
});
|
||||||
|
if(results.WBC) results.push(results['MONOCYTE#'] = {
|
||||||
|
name: 'MONOCYTE#', unit: 'K/cmm', range: '0.11 - 0.59',
|
||||||
|
value: +(x = 0.01*x*results.WBC.value).toFixed(3),
|
||||||
|
flag: (x < 0.11 ? 'L' : x > 0.59 ? 'H' : undefined)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if((results.hasOwnProperty('LYMPHS')) || (results.hasOwnProperty('ATYPICAL LYMPHOCYTES'))) {
|
||||||
|
results.push(results['LYMPHOCYTE%'] = {
|
||||||
|
name: 'LYMPHOCYTE%', unit: '%', range: '15.0 - 41.0',
|
||||||
|
value: x = (results.hasOwnProperty('LYMPHS') ? +results.LYMPHS.value : 0) + (results.hasOwnProperty('ATYPICAL LYMPHOCYTES') ? +results['ATYPICAL LYMPHOCYTES'].value : 0),
|
||||||
|
flag: (x < 15 ? 'L' : x > 41 ? 'H' : undefined)
|
||||||
|
});
|
||||||
|
if(results.WBC) results.push(results['LYMPHOCYTE#'] = {
|
||||||
|
name: 'LYMPHOCYTE#', unit: 'K/cmm', range: '1.2 - 3.4',
|
||||||
|
value: +(x = 0.01*x*results.WBC.value).toFixed(3),
|
||||||
|
flag: (x < 1.2 ? 'L' : x > 3.4 ? 'H' : undefined)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -134,13 +194,28 @@ export function measurement_parse(data) {
|
|||||||
res.datetime = new Date(row.substring(idx + 1, idx = row.indexOf(' ', idx)));
|
res.datetime = new Date(row.substring(idx + 1, idx = row.indexOf(' ', idx)));
|
||||||
res.name = row.substring(idx + 3, idx = row.indexOf(': ', idx));
|
res.name = row.substring(idx + 3, idx = row.indexOf(': ', idx));
|
||||||
value = row.substring(idx + 4, idx = row.indexOf(' _', idx));
|
value = row.substring(idx + 4, idx = row.indexOf(' _', idx));
|
||||||
res.user = row.substring(idx + 3);
|
res.user = row.substring(idx + 2);
|
||||||
m = value.match(/^(?:(.*?)(?: (\S+))?)(\*)?(?: \((?:(.*?)(?: (\S+))?)\))?\s*$/);
|
if(m = value.match(/(?:^(?<value>[\d\.\/%]+)(?: (?<unit>\w+) \((?<value2>[\d\.\/%]+) (?<unit2>\w+)\))?(?<flag>\*)? (?: (?<comment>.*))?$)|(?:^(?<value3>[\d\.\/%]+)(?<flag3>\*)?\s*(?<comment3>.*)$)/)) {
|
||||||
res.value = m[4] ? m[4] : m[1];
|
if(m.groups.value2) {
|
||||||
res.unit = m[4] ? m[5] : m[2];
|
res.value = m.groups.value2;
|
||||||
res.flag = m[3];
|
res.unit = m.groups.unit2;
|
||||||
res.value_american = m[4] ? m[1] : m[4];
|
res.value_american = m.groups.value;
|
||||||
res.unit_american = m[4] ? m[2] : m[5];
|
res.unit_american = m.groups.unit;
|
||||||
|
res.flag = m.groups.flag;
|
||||||
|
res.comment = m.groups.comment;
|
||||||
|
} else if(m.groups.value) {
|
||||||
|
res.value = m.groups.value;
|
||||||
|
res.unit = m.groups.unit;
|
||||||
|
res.flag = m.groups.flag;
|
||||||
|
res.comment = m.groups.comment;
|
||||||
|
} else if(m.groups.value3) {
|
||||||
|
res.value = m.groups.value3;
|
||||||
|
res.flag = m.groups.flag3;
|
||||||
|
res.comment = m.groups.comment3;
|
||||||
|
} else res.comment = value;
|
||||||
|
if(res.comment) res.comment = res.comment.replace(/^\s+|\s+$/g, '').replace(/\s+/g, ' ');
|
||||||
|
}
|
||||||
|
if(res.value) {
|
||||||
if(res.value.charAt(res.value.length - 1) == '%') {
|
if(res.value.charAt(res.value.length - 1) == '%') {
|
||||||
res.unit = '%';
|
res.unit = '%';
|
||||||
res.value = res.value.substring(0, res.value.length - 1);
|
res.value = res.value.substring(0, res.value.length - 1);
|
||||||
@ -150,9 +225,75 @@ export function measurement_parse(data) {
|
|||||||
extras.push({...res, name: 'SBP', range: '90 - 120', unit: 'mmHg', value: bpsplit[0] });
|
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] });
|
extras.push({...res, name: 'DBP', range: '60 - 80', unit: 'mmHg', value: bpsplit[1] });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
}).filter(x => x);
|
}).filter(x => x);
|
||||||
res.push(...extras);
|
res.push(...extras);
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function orderinfo_parse(data) {
|
||||||
|
var res = [], item, line;
|
||||||
|
for(var i = 0; i < data.length; ++i) {
|
||||||
|
if((line = data[i]).startsWith('~')) {
|
||||||
|
res.push(item = line.slice(1).split('^'));
|
||||||
|
item.IFN = item[0];
|
||||||
|
item.Grp = item[1];
|
||||||
|
item.OrdTm = item[2];
|
||||||
|
item.StrtTm = item[3];
|
||||||
|
item.StopTm = item[4];
|
||||||
|
item.Sts = item[5];
|
||||||
|
item.Sig = item[6];
|
||||||
|
item.Nrs = item[7];
|
||||||
|
item.Clk = item[8];
|
||||||
|
item.PrvID = item[9];
|
||||||
|
item.PrvNam = item[10];
|
||||||
|
item.Act = item[11];
|
||||||
|
item.Flagged = item[12];
|
||||||
|
item.DCType = item[13];
|
||||||
|
item.ChartRev = item[14];
|
||||||
|
item.DEA = item[15];
|
||||||
|
item.DigSig = item[17];
|
||||||
|
item.LOC = item[18];
|
||||||
|
item.DCORIGINAL = item[19];
|
||||||
|
item.IsPendingDCorder = item[20];
|
||||||
|
item.IsDelayOrder = item[21];
|
||||||
|
item.text = [];
|
||||||
|
} else if((item) && (line.startsWith('t'))) item.text.push(line.slice(1));
|
||||||
|
else console.log('INVALID:', line);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function orderoverrides_parse(data) {
|
||||||
|
var res = [], item, line;
|
||||||
|
for(var i = 0; i < data.length; ++i) {
|
||||||
|
if((line = data[i]).startsWith('~')) {
|
||||||
|
res.push(item = line.substring(1).split('^'));
|
||||||
|
item.promptIEN = item[0];
|
||||||
|
item.instance = item[1];
|
||||||
|
item.promptID = item[2];
|
||||||
|
} else if(item) {
|
||||||
|
if(line.startsWith('i')) item.iValue = line.substring(1);
|
||||||
|
else if(line.startsWith('e')) item.eValue = line.substring(1);
|
||||||
|
else if(line.startsWith('t')) {
|
||||||
|
item.eValue = (item.hasOwnProperty('eValue')) && (item.eValue.length > 0) ? item.eValue + '\r\n' + line.substring(1) : line.substring(1);
|
||||||
|
item.iValue = '^WP^';
|
||||||
|
} else console.log('INVALID:', line);
|
||||||
|
} else console.log('INVALID:', line);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function orderoptions_parse(data) {
|
||||||
|
var res = {}, item, type, line;
|
||||||
|
for(var i = 0; i < data.length; ++i) {
|
||||||
|
if((line = data[i]).startsWith('~')) item = res[line.substring(1)] = {};
|
||||||
|
else if(item) {
|
||||||
|
type = { d: 'default', t: 'text', i: 'items' }[line.charAt(0)];
|
||||||
|
item[type] = (item.hasOwnProperty(type)) && (item[type].length > 0) ? item[type] + '\r\n' + line.substring(1) : line.substring(1);
|
||||||
|
} else console.log('INVALID:', line);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
307
htdocs/tplfs.mjs
Normal file
307
htdocs/tplfs.mjs
Normal file
@ -0,0 +1,307 @@
|
|||||||
|
function randstr(length, charset='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789') {
|
||||||
|
var res = '', i;
|
||||||
|
if((window.crypto) && (window.crypto.getRandomValues)) {
|
||||||
|
var values = new Uint32Array(length);
|
||||||
|
window.crypto.getRandomValues(values);
|
||||||
|
for(var i = 0; i < length; ++i) res += charset[values[i] % charset.length];
|
||||||
|
} else for(var i = 0; i < length; ++i) res += charset[Math.floor(Math.random()*charset.length)];
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
function str_to_ab(str) {
|
||||||
|
var buf = new ArrayBuffer(str.length);
|
||||||
|
var bufview = new Uint8Array(buf);
|
||||||
|
for(var i = str.length - 1; i >= 0; --i) bufview[i] = str.charCodeAt(i);
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uint8array_to_b64(arr) {
|
||||||
|
return btoa(String.fromCharCode.apply(null, arr)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function b64_to_uint8array(b64) {
|
||||||
|
return Uint8Array.from(atob(b64.replace(/\-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function digest(algorithm, value) {
|
||||||
|
var buffer = str_to_ab(value);
|
||||||
|
return await window.crypto.subtle.digest(algorithm, str_to_ab(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function randpassword() {
|
||||||
|
return randstr(64) + ':' + randstr(16);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TplFSError(...args) {
|
||||||
|
this.message = args;
|
||||||
|
}
|
||||||
|
TplFSError.prototype = Object.create(Error.prototype);
|
||||||
|
|
||||||
|
function TplFSErrorNotFound(...args) {
|
||||||
|
this.message = args;
|
||||||
|
}
|
||||||
|
TplFSErrorNotFound.prototype = Object.create(TplFSError.prototype);
|
||||||
|
|
||||||
|
function TplFSErrorPerm(...args) {
|
||||||
|
this.message = args;
|
||||||
|
}
|
||||||
|
TplFSErrorPerm.prototype = Object.create(TplFSError.prototype);
|
||||||
|
|
||||||
|
function TplFSErrorInvalid(...args) {
|
||||||
|
this.message = args;
|
||||||
|
}
|
||||||
|
TplFSErrorInvalid.prototype = Object.create(TplFSError.prototype);
|
||||||
|
|
||||||
|
export function TplFS(client, parent, desc) {
|
||||||
|
this.parent = parent;
|
||||||
|
this.desc = desc;
|
||||||
|
this.path = parent ? parent.path + '/' + desc.name : '';
|
||||||
|
|
||||||
|
async function lock(tries=5, delay=1000) {
|
||||||
|
var res = null;
|
||||||
|
for(var i = 1; i <= tries; ++i) {
|
||||||
|
if((res = await client.TIU_TEMPLATE_LOCK(desc.IEN)) == '1') break;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, Math.pow(delay, i)));
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enumerate(ien) {
|
||||||
|
var res = [ien];
|
||||||
|
var items = await client.TIU_TEMPLATE_GETITEMS(ien);
|
||||||
|
for(var i = items.length - 1; i >= 0; --i) Array.prototype.push.apply(res, await enumerate(items[i].IEN));
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.chroot = () => new TplFS(client, null, desc);
|
||||||
|
|
||||||
|
this.list = async (filter=null) => {
|
||||||
|
var items = await client.TIU_TEMPLATE_GETITEMS(desc.IEN);
|
||||||
|
if(filter) items = items.filter(filter);
|
||||||
|
return items.map(x => new TplFS(client, this, x));
|
||||||
|
};
|
||||||
|
|
||||||
|
this.open = async (path) => {
|
||||||
|
if((typeof path === 'string') || (path instanceof String)) path = path.split('/');
|
||||||
|
if(((desc.type == 'C') || (desc.type == 'P')) && (path.length > 0)) {
|
||||||
|
var items = await client.TIU_TEMPLATE_GETITEMS(desc.IEN), name = path[0];
|
||||||
|
var item = items.find(x => x.name == name);
|
||||||
|
if(item) {
|
||||||
|
if(path.length > 1) return (new TplFS(client, this, item)).open(path.slice(1));
|
||||||
|
else return new TplFS(client, this, item);
|
||||||
|
} else throw new TplFSErrorNotFound('open', (this.path != '' ? this.path + '/' : '') + name, 'Directory not found');
|
||||||
|
} else throw new TplFSErrorInvalid('open', (this.path != '' ? this.path + '/' : '') + path.join('/'), 'Invalid path');
|
||||||
|
};
|
||||||
|
|
||||||
|
this.mkdir = async (path, optimistic=true, tries=5, delay=1000) => {
|
||||||
|
if((typeof path === 'string') || (path instanceof String)) path = path.split('/');
|
||||||
|
var node = this;
|
||||||
|
for(var i = 0; i < path.length; ++i) node = await node.raw_mkdir(path[i], optimistic, tries, delay);
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
|
||||||
|
this.create = async (path, data, optimistic=true, mkdir=false, tries=5, delay=1000) => {
|
||||||
|
if((typeof path === 'string') || (path instanceof String)) path = path.split('/');
|
||||||
|
if(path.length > 1) return await (mkdir ? await this.mkdir(path.slice(0, -1), optimistic, tries, delay) : await this.open(path.slice(0, -1))).create(path.slice(-1), data, optimistic, mkdir, tries, delay);
|
||||||
|
else if(path.length == 1) return await this.raw_create(path[0], data, optimistic, tries, delay);
|
||||||
|
else throw new TplFSErrorInvalid('create', (this.path != '' ? this.path + '/' : '') + path.join('/'), 'Invalid path');
|
||||||
|
};
|
||||||
|
|
||||||
|
this.remove = async (path, tries=5, delay=1000) => {
|
||||||
|
if((typeof path === 'string') || (path instanceof String)) path = path.split('/');
|
||||||
|
if(path.length > 1) return await (await this.open(path.slice(0, -1))).remove(path.slice(-1), tries, delay);
|
||||||
|
else if(path.length == 1) return await this.raw_remove(path[0], tries, delay);
|
||||||
|
else throw new TplFSErrorInvalid('remove', (this.path != '' ? this.path + '/' : '') + path.join('/'), 'Invalid path');
|
||||||
|
};
|
||||||
|
|
||||||
|
this.update = async (path, data) => {
|
||||||
|
if((typeof path === 'string') || (path instanceof String)) path = path.split('/');
|
||||||
|
if((path) && (path.length > 0)) return await (await this.open(path)).update([], data);
|
||||||
|
else return await this.raw_update(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.cat = async (path) => {
|
||||||
|
if((typeof path === 'string') || (path instanceof String)) path = path.split('/');
|
||||||
|
if((path) && (path.length > 0)) return await (await this.open(path)).raw_cat();
|
||||||
|
else return await this.raw_cat();
|
||||||
|
};
|
||||||
|
|
||||||
|
this.raw_mkdir = async (name, optimistic=true, tries=5, delay=1000) => {
|
||||||
|
if((desc.type == 'C') || (desc.type == 'P')) {
|
||||||
|
if(name == '.') return this;
|
||||||
|
else if(name == '..') return this.parent ? this.parent : this;
|
||||||
|
if(optimistic) {
|
||||||
|
var items = await client.TIU_TEMPLATE_GETITEMS(desc.IEN);
|
||||||
|
var item = items.find(x => x.name == name);
|
||||||
|
if(item) return new TplFS(client, this, item);
|
||||||
|
}
|
||||||
|
if(await lock(tries, delay) == '1') try {
|
||||||
|
var items = await client.TIU_TEMPLATE_GETITEMS(desc.IEN);
|
||||||
|
var item = items.find(x => x.name == name);
|
||||||
|
if(item) return new TplFS(client, this, item);
|
||||||
|
var nodeid = await client.TIU_TEMPLATE_CREATE_MODIFY(0, { '.01': name, '.02': '@', '.03': 'C', '.04': 'I', '.05': '0', '.08': '0', '.09': '0', '.1': '0', '.11': '0', '.12': '0', '.13': '0', '.14': '0', '.15': '@', '.16': '0', '.17': '@', '.18': '@', '.19': '@', '.06': desc.personal_owner, '2,1': '@', '5,1': '@' });
|
||||||
|
var nodes = items.map(x => x.IEN);
|
||||||
|
nodes.push(nodeid);
|
||||||
|
await client.TIU_TEMPLATE_SET_ITEMS(desc.IEN, nodes);
|
||||||
|
item = (await client.TIU_TEMPLATE_GETITEMS(desc.IEN)).find(x => (x.type == 'C') && (x.name == name));
|
||||||
|
if(item) return new TplFS(client, this, item);
|
||||||
|
else throw new TplFSErrorPerm('mkdir', (this.path != '' ? this.path + '/' : '') + name, 'Failed to create directory');
|
||||||
|
} finally {
|
||||||
|
await client.TIU_TEMPLATE_UNLOCK(desc.IEN);
|
||||||
|
} else throw new TplFSErrorPerm('mkdir', this.path, 'Failed to obtain lock');
|
||||||
|
} else throw new TplFSErrorInvalid('mkdir', this.path, 'Not a directory');
|
||||||
|
};
|
||||||
|
|
||||||
|
this.raw_create = async (name, data, optimistic=true, tries=5, delay=1000) => {
|
||||||
|
if((desc.type == 'C') || (desc.type == 'P')) {
|
||||||
|
if((name == '.') || (name == '..')) throw new TplFSErrorInvalid('create', (this.path != '' ? this.path + '/' : '') + name, 'Invalid path');
|
||||||
|
if(optimistic) {
|
||||||
|
var items = await client.TIU_TEMPLATE_GETITEMS(desc.IEN);
|
||||||
|
var item = items.find(x => x.name == name);
|
||||||
|
if(item) return (new TplFS(client, this, item)).raw_update(data);
|
||||||
|
}
|
||||||
|
if(await lock(tries, delay) == '1') try {
|
||||||
|
var items = await client.TIU_TEMPLATE_GETITEMS(desc.IEN);
|
||||||
|
var item = items.find(x => x.name == name);
|
||||||
|
if((item) && (item.type != 'T')) throw new TplFSErrorInvalid('create', (this.path != '' ? this.path + '/' : '') + name, 'Not a file');
|
||||||
|
var nodeid = await client.TIU_TEMPLATE_CREATE_MODIFY(item && item.IEN ? item.IEN : 0, { '.01': name, '.02': '@', '.03': 'T', '.04': 'I', '.05': '0', '.08': '0', '.09': '0', '.1': '0', '.11': '0', '.12': '0', '.13': '0', '.14': '0', '.15': '@', '.16': '0', '.17': '@', '.18': '@', '.19': '@', '.06': desc.personal_owner, '2,1,0': data, '5,1,0': 'TplFS' });
|
||||||
|
if((item) && (item.IEN == nodeid)) return new TplFS(client, this, item);
|
||||||
|
var nodes = items.map(x => x.IEN);
|
||||||
|
nodes.push(nodeid);
|
||||||
|
await client.TIU_TEMPLATE_SET_ITEMS(desc.IEN, nodes);
|
||||||
|
item = (await client.TIU_TEMPLATE_GETITEMS(desc.IEN)).find(x => (x.type == 'T') && (x.name == name));
|
||||||
|
if(item) return new TplFS(client, this, item);
|
||||||
|
else throw new TplFSErrorPerm('create', (this.path != '' ? this.path + '/' : '') + name, 'Failed to create file');
|
||||||
|
} finally {
|
||||||
|
await client.TIU_TEMPLATE_UNLOCK(desc.IEN);
|
||||||
|
} else throw new TplFSErrorPerm('create', this.path, 'Failed to obtain lock');
|
||||||
|
} else throw new TplFSErrorInvalid('create', this.path, 'Not a directory');
|
||||||
|
};
|
||||||
|
|
||||||
|
this.raw_remove = async (name, tries=5, delay=1000) => {
|
||||||
|
if((desc.type == 'C') || (desc.type == 'P')) {
|
||||||
|
if((name == '.') || (name == '..')) throw new TplFSErrorInvalid('remove', (this.path != '' ? this.path + '/' : '') + name, 'Invalid path');
|
||||||
|
var garbage = null;
|
||||||
|
if(await lock(tries, delay) == '1') try {
|
||||||
|
var items = await client.TIU_TEMPLATE_GETITEMS(desc.IEN);
|
||||||
|
var item = items.find(x => x.name == name);
|
||||||
|
if(item) {
|
||||||
|
garbage = await enumerate(item.IEN);
|
||||||
|
items.splice(items.indexOf(item), 1);
|
||||||
|
await client.TIU_TEMPLATE_SET_ITEMS(desc.IEN, items.map(x => x.IEN));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await client.TIU_TEMPLATE_UNLOCK(desc.IEN);
|
||||||
|
if(garbage) return await client.TIU_TEMPLATE_DELETE(garbage);
|
||||||
|
} else throw new TplFSErrorPerm('remove', this.path, 'Failed to obtain lock');
|
||||||
|
} else throw new TplFSErrorInvalid('remove', this.path, 'Not a directory');
|
||||||
|
};
|
||||||
|
|
||||||
|
this.raw_update = async (data) => {
|
||||||
|
if(desc.type == 'T') {
|
||||||
|
await client.TIU_TEMPLATE_CREATE_MODIFY(desc.IEN, { '.01': desc.name, '.02': '@', '.03': 'T', '.04': 'I', '.05': '0', '.08': '0', '.09': '0', '.1': '0', '.11': '0', '.12': '0', '.13': '0', '.14': '0', '.15': '@', '.16': '0', '.17': '@', '.18': '@', '.19': '@', '.06': desc.personal_owner, '2,1,0': data, '5,1,0': 'TplFS' });
|
||||||
|
return this;
|
||||||
|
} else throw new TplFSErrorInvalid('update', this.path, 'Not a file');
|
||||||
|
};
|
||||||
|
|
||||||
|
this.raw_cat = async () => {
|
||||||
|
if(desc.type == 'T') return await client.TIU_TEMPLATE_GETBOIL(desc.IEN);
|
||||||
|
else throw new TplFSErrorInvalid('retrieve', this.path, 'Not a file');
|
||||||
|
};
|
||||||
|
|
||||||
|
this.raw_list = () => client.TIU_TEMPLATE_GETITEMS(desc.IEN);
|
||||||
|
|
||||||
|
this.raw_open = (child) => new TplFS(client, this, child);
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
TplFS.fromUser = async function(client, user_ien=null) {
|
||||||
|
if(!user_ien) user_ien = (await client.authinfo()).duz;
|
||||||
|
return new TplFS(client, null, (await client.TIU_TEMPLATE_GETPROOT(user_ien))[0]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EncFS(fs, data_encrypt, data_decrypt, path_encrypt, path_decrypt) {
|
||||||
|
this.fs = fs;
|
||||||
|
if(fs.path) path_decrypt(fs.path).then(value => this.path = value);
|
||||||
|
|
||||||
|
this.list = async (filter=null) => (await fs.list(filter)).map(x => new EncFS(x, data_encrypt, data_decrypt, path_encrypt, path_decrypt));
|
||||||
|
this.open = async (path) => new EncFS(await fs.open(await path_encrypt(path)), data_encrypt, data_decrypt, path_encrypt, path_decrypt);
|
||||||
|
this.mkdir = async (path, optimistic=true, tries=5, delay=1000) => new EncFS(await fs.mkdir(await path_encrypt(path), optimistic, tries, delay), data_encrypt, data_decrypt, path_encrypt, path_decrypt);
|
||||||
|
this.create = async (path, data, optimistic=true, mkdir=false, tries=5, delay=1000) => new EncFS(await fs.create(await path_encrypt(path), await data_encrypt(data), optimistic, mkdir, tries, delay), data_encrypt, data_decrypt, path_encrypt, path_decrypt);
|
||||||
|
this.remove = async (path, tries=5, delay=1000) => await fs.remove(await path_encrypt(path), tries, delay);
|
||||||
|
this.update = async (path, data) => new EncFS(await fs.update(await path_encrypt(path), await data_encrypt(data)), data_encrypt, data_decrypt, path_encrypt, path_decrypt);
|
||||||
|
this.cat = async (path) => await data_decrypt(await fs.cat(await path_encrypt(path)));
|
||||||
|
}
|
||||||
|
EncFS.fromPassword = async function(fs, password) {
|
||||||
|
var password_salt = password.split(':');
|
||||||
|
var key_pbkdf2 = await window.crypto.subtle.importKey('raw', str_to_ab(password_salt[0]), 'PBKDF2', false, ['deriveKey']);
|
||||||
|
var salt = new Uint8Array(str_to_ab(password_salt[1]));
|
||||||
|
var key_aes_gcm = await window.crypto.subtle.deriveKey(
|
||||||
|
{
|
||||||
|
name: 'PBKDF2',
|
||||||
|
salt: salt,
|
||||||
|
iterations: 250000,
|
||||||
|
hash: 'SHA-256',
|
||||||
|
},
|
||||||
|
key_pbkdf2,
|
||||||
|
{ name: 'AES-GCM', length: 256 },
|
||||||
|
false,
|
||||||
|
['encrypt', 'decrypt']
|
||||||
|
);
|
||||||
|
async function data_encrypt(data) {
|
||||||
|
var iv = window.crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
var ciphertextbuf = await window.crypto.subtle.encrypt({ name: 'AES-GCM', iv: iv }, key_aes_gcm, str_to_ab(data));
|
||||||
|
var res = new Uint8Array(iv.byteLength + ciphertextbuf.byteLength);
|
||||||
|
res.set(iv, 0);
|
||||||
|
res.set(new Uint8Array(ciphertextbuf), iv.byteLength);
|
||||||
|
return uint8array_to_b64(res);
|
||||||
|
}
|
||||||
|
async function data_decrypt(data) {
|
||||||
|
var ciphertextarr = b64_to_uint8array(data);
|
||||||
|
var res = await window.crypto.subtle.decrypt({ name: 'AES-GCM', iv: ciphertextarr.slice(0, 12) }, key_aes_gcm, ciphertextarr.slice(12));
|
||||||
|
return String.fromCharCode.apply(null, new Uint8Array(res));
|
||||||
|
}
|
||||||
|
var key_aes_ctr = await window.crypto.subtle.deriveKey(
|
||||||
|
{
|
||||||
|
name: 'PBKDF2',
|
||||||
|
salt: salt,
|
||||||
|
iterations: 250000,
|
||||||
|
hash: 'SHA-256',
|
||||||
|
},
|
||||||
|
key_pbkdf2,
|
||||||
|
{ name: 'AES-CTR', length: 256 },
|
||||||
|
false,
|
||||||
|
['encrypt', 'decrypt']
|
||||||
|
);
|
||||||
|
var key_hmac = await window.crypto.subtle.importKey('raw', str_to_ab(password_salt[0]), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
|
||||||
|
async function path_component_encrypt(data) {
|
||||||
|
var counter = (await window.crypto.subtle.sign({ name: 'HMAC' }, key_hmac, str_to_ab(data))).slice(0, 16);
|
||||||
|
var ciphertextbuf = await window.crypto.subtle.encrypt({ name: 'AES-CTR', counter: counter, length: 128 }, key_aes_ctr, str_to_ab(data));
|
||||||
|
var res = new Uint8Array(counter.byteLength + ciphertextbuf.byteLength);
|
||||||
|
res.set(new Uint8Array(counter), 0);
|
||||||
|
res.set(new Uint8Array(ciphertextbuf), counter.byteLength);
|
||||||
|
return uint8array_to_b64(res);
|
||||||
|
}
|
||||||
|
async function path_component_decrypt(data) {
|
||||||
|
var ciphertextarr = b64_to_uint8array(data);
|
||||||
|
var res = await window.crypto.subtle.decrypt({ name: 'AES-CTR', counter: ciphertextarr.slice(0, 16), length: 128 }, key_aes_ctr, ciphertextarr.slice(16));
|
||||||
|
return String.fromCharCode.apply(null, new Uint8Array(res));
|
||||||
|
}
|
||||||
|
async function path_encrypt(data) {
|
||||||
|
if(data) {
|
||||||
|
if(data.constructor === Array) return await Promise.all(data.map(x => path_component_encrypt(x)));
|
||||||
|
else return (await Promise.all(data.split('/').map(x => path_component_encrypt(x)))).join('/');
|
||||||
|
} else return data;
|
||||||
|
}
|
||||||
|
async function path_decrypt(data) {
|
||||||
|
if(data) {
|
||||||
|
if(data.constructor === Array) return await Promise.all(data.map(x => path_component_decrypt(x)));
|
||||||
|
else if(data.startsWith('/')) return '/' + await path_decrypt(data.replace(/^\/+/g, ''));
|
||||||
|
else return (await Promise.all(data.split('/').map(x => path_component_decrypt(x)))).join('/');
|
||||||
|
} else return data;
|
||||||
|
}
|
||||||
|
return new EncFS((await fs.mkdir('ZZZE ' + uint8array_to_b64(new Uint8Array(await digest('SHA-1', password))))).chroot(), data_encrypt, data_decrypt, path_encrypt, path_decrypt);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default window.tplfs = { TplFS, EncFS, randpassword };
|
@ -1,3 +1,8 @@
|
|||||||
|
export const comp = (...fs) => x0 => fs.reduceRight((x, f) => f(x), x0);
|
||||||
|
export const flow = (...fs) => x0 => fs.reduce((x, f) => f(x), x0);
|
||||||
|
export const pipe = (x0, ...fs) => fs.reduce((x, f) => f(x), x0);
|
||||||
|
export const aflow = (f0, ...fs) => async (...args) => fs.reduce((x, f) => f(x), await f0(...args));
|
||||||
|
|
||||||
export function uniq(xs) {
|
export function uniq(xs) {
|
||||||
var seen = {};
|
var seen = {};
|
||||||
return xs.filter(x => seen.hasOwnProperty(x) ? false : (seen[x] = true));
|
return xs.filter(x => seen.hasOwnProperty(x) ? false : (seen[x] = true));
|
||||||
@ -35,6 +40,19 @@ export function quantile_sorted(arr_sorted, quantile) {
|
|||||||
return arr_sorted[base + 1] !== undefined ? arr_sorted[base] + rest * (arr_sorted[base + 1] - arr_sorted[base]) : arr_sorted[base];
|
return arr_sorted[base + 1] !== undefined ? arr_sorted[base] + rest * (arr_sorted[base + 1] - arr_sorted[base]) : arr_sorted[base];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export 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 function strtr_unscramble(name) {
|
||||||
|
return name.length > 0 ? (name.charAt(0) + strtr(name.substring(1), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'LKJIHGFEDCBAZYXWVUTSRQPONM')) : name;
|
||||||
|
}
|
||||||
|
|
||||||
export function strHashCode(str) {
|
export function strHashCode(str) {
|
||||||
var hash = 0;
|
var hash = 0;
|
||||||
for(var i = 0; i < str.length; ++i) hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
for(var i = 0; i < str.length; ++i) hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
@ -63,6 +81,10 @@ 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;
|
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 strfdate_vista(date) {
|
||||||
|
return 10000*(date.getFullYear() - 1700) + 100*(date.getMonth() + 1) + date.getDate();
|
||||||
|
}
|
||||||
|
|
||||||
export function strptime_vista(s) {
|
export function strptime_vista(s) {
|
||||||
s = +s;
|
s = +s;
|
||||||
var date = Math.floor(s), time = s - date;
|
var date = Math.floor(s), time = s - date;
|
||||||
@ -77,3 +99,9 @@ export function debounce(fn, delay) {
|
|||||||
clock = setTimeout(function() { fn.apply(self, args) }, delay);
|
clock = setTimeout(function() { fn.apply(self, args) }, delay);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Descendant() {}
|
||||||
|
export function inherit(obj) {
|
||||||
|
Descendant.prototype = obj;
|
||||||
|
return new Descendant();
|
||||||
|
}
|
||||||
|
@ -6,19 +6,19 @@ export async function connect(secret, host='vista.northport.med.va.gov', port=19
|
|||||||
})).json();
|
})).json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function call(cid, method, ...params) {
|
export async function close(cid) {
|
||||||
return await (await fetch('/v1/vista/' + cid, {
|
return await (await fetch('/v1/vista/' + cid + '/close', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ method: method, params: params, id: Date.now() })
|
body: JSON.stringify({ id: Date.now() })
|
||||||
})).json();
|
})).json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function callctx(cid, context, method, ...params) {
|
export async function call(cid, body) {
|
||||||
return await (await fetch('/v1/vista/' + cid, {
|
return await (await fetch('/v1/vista/' + cid, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ method: method, params: params, context: context, id: Date.now() })
|
body: JSON.stringify(body)
|
||||||
})).json();
|
})).json();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -30,8 +30,8 @@ export async function serverinfo(cid) {
|
|||||||
})).json();
|
})).json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function userinfo(cid) {
|
export async function authinfo(cid) {
|
||||||
return await (await fetch('/v1/vista/' + cid + '/userinfo', {
|
return await (await fetch('/v1/vista/' + cid + '/authinfo', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: '{}'
|
body: '{}'
|
||||||
@ -47,5 +47,5 @@ export async function authenticate(cid, avcode=null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default window.vista = {
|
export default window.vista = {
|
||||||
connect, call, callctx, serverinfo, userinfo, authenticate
|
connect, close, call, serverinfo, authinfo, authenticate
|
||||||
};
|
};
|
||||||
|
@ -1,6 +1,19 @@
|
|||||||
|
import { reactive, watch } from 'vue';
|
||||||
|
|
||||||
import vista from './vista.mjs';
|
import vista from './vista.mjs';
|
||||||
import cookie from './cookie.mjs';
|
import cookie from './cookie.mjs';
|
||||||
import { lab_parse, lab_reparse_results, measurement_parse } from './reportparser.mjs';
|
import { debounce, pipe, aflow } from './util.mjs';
|
||||||
|
import { lab_parse, lab_reparse_results, measurement_parse, orderinfo_parse, orderoverrides_parse, orderoptions_parse } from './reportparser.mjs';
|
||||||
|
import { TplFS, EncFS, randpassword as tplfs_randpassword } from './tplfs.mjs';
|
||||||
|
|
||||||
|
export const localstate = reactive(cookie.get('state') ? JSON.parse(cookie.get('state')) : {});
|
||||||
|
window.addEventListener('storage', function(evt) {
|
||||||
|
if((evt.storageArea == window.localStorage) && (evt.key == 'state') && (evt.newValue)) Object.assign(localstate, JSON.parse(evt.newValue));
|
||||||
|
});
|
||||||
|
watch(localstate, function(value) {
|
||||||
|
cookie.set('state', value = JSON.stringify(value), 45);
|
||||||
|
window.localStorage.setItem('state', value);
|
||||||
|
}, { immediate: true, deep: true });
|
||||||
|
|
||||||
function RPCError(type, ...args) {
|
function RPCError(type, ...args) {
|
||||||
this.name = type;
|
this.name = type;
|
||||||
@ -8,21 +21,226 @@ function RPCError(type, ...args) {
|
|||||||
}
|
}
|
||||||
RPCError.prototype = Object.create(Error.prototype);
|
RPCError.prototype = Object.create(Error.prototype);
|
||||||
|
|
||||||
export function logged(fn, name) {
|
export const d_log = data => (console.log(data._request.method, ...(data._request.params || []), '=>', data), data);
|
||||||
return async function(...args) {
|
|
||||||
var res = await fn(...args);
|
export const d_unwrap = data => {
|
||||||
console.log(name, ...args, res);
|
if(data.error) throw new RPCError(data.error.type, ...data.error.args);
|
||||||
|
if(data.ts) try {
|
||||||
|
data.result._ts = data.ts;
|
||||||
|
if(data.cached) data.result._cached = data.cached;
|
||||||
|
} catch(ex) {}
|
||||||
|
return data.result;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const f_emit = (fn=console.log, ...args) => data => (fn(...args, data), data);
|
||||||
|
|
||||||
|
export const f_split = (delimiter='^', ...columns) => columns.length > 0 ? data => data.map(row => columns.reduce((acc, val, idx) => (acc[val] = acc[idx], acc), row.split(delimiter))) : data => data.map(row => row.split(delimiter));
|
||||||
|
export const f_split1 = (delimiter='^', ...columns) => columns.length > 0 ? data => columns.reduce((acc, val, idx) => (acc[val] = acc[idx], acc), data.split(delimiter)) : data => data.split(delimiter);
|
||||||
|
|
||||||
|
export const d_split = (data, delimiter='^', ...columns) => columns.length > 0 ? data.map(row => columns.reduce((acc, val, idx) => (acc[val] = acc[idx], acc), row.split(delimiter))) : data.map(row => row.split(delimiter));
|
||||||
|
export const d_split1 = (data, delimiter='^', ...columns) => columns.length > 0 ? columns.reduce((acc, val, idx) => (acc[val] = acc[idx], acc), data.split(delimiter)) : data.split(delimiter);
|
||||||
|
|
||||||
|
export const f_slice = (start, end) => data => data.slice(start, end);
|
||||||
|
|
||||||
|
export const f_key = (key='id') => typeof key === 'function' ? data => data.reduce((acc, val) => (acc[key(val)] = val, acc), data) : data => data.reduce((acc, val) => (acc[val[key]] = val, acc), data);
|
||||||
|
|
||||||
|
export const d_parse_boolean = data => data != '0';
|
||||||
|
export const d_parse_text = data => data !== '' ? data.join('\r\n') : data;
|
||||||
|
export const d_parse_array = data => data !== '' ? data : [];
|
||||||
|
|
||||||
|
export const d_parse_authinfo = data => data ? { duz: data[0] != '0' ? data[0] : null, device_lock: data[1] != '0', change_verify: data[2] != '0', message: data[3], reserved: data[4], greeting_lines: data[5], greeting: data.slice(6), success: (data[0] != '0') && (data[2] == '0') } : { success: false }
|
||||||
|
|
||||||
|
export const d_parse_imagelist = data => {
|
||||||
|
var descriptor = d_split1(data[0], '^', 'success', 'filter', 'more'), res;
|
||||||
|
if(descriptor.success == '1') {
|
||||||
|
var headers = d_split1(data[1], '^');
|
||||||
|
res = data.slice(2).map(function(row) {
|
||||||
|
row = row.split('|');
|
||||||
|
var values = headers.reduce((acc, val, idx) => (acc[val] = acc[idx], acc), row[0].split('^'))
|
||||||
|
values.Info = d_split1(row[1], '^', 'IEN', 'Image Path', 'Abstract Path', 'Short Desc', 'Procedure Time', 'Object Type', 'Procedure', 'Display Date', 'Pointer', 'Abs Type', 'Availability', 'DICOM Series', 'DICOM Image', 'Count', 'Site IEN', 'Site', 'Error', 'BIGPath', 'Patient DFN', 'Patient Name', 'Image Class', 'Cap Dt', 'Document Date', 'Group IEN', 'Group Ch1', 'RPC Server', 'RPC Port', 'Controlled Image', 'Viewable Status', 'Status', 'Image Annotated', 'Image TIU Note Completed', 'Annotation Operation Status', 'Annotation Operation Status Description', 'Package');
|
||||||
|
values.Info['Group Ch1'] = values.Info['Group Ch1'] ? d_split1(':', 'IEN', 'Type') : null
|
||||||
|
return values;
|
||||||
|
});
|
||||||
|
res.descriptor = descriptor;
|
||||||
|
} else {
|
||||||
|
res = data.slice(1);
|
||||||
|
res.descriptor = descriptor;
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const d_parse_imageinfo = data => {
|
||||||
|
var res = d_split1(data[0], '^', 'Code', 'IEN', 'Image Path', 'Abstract Path', 'Short Desc', 'Procedure Time', 'Object Type', 'Procedure', 'Display Date', 'Pointer', 'Abs Type', 'Availability', 'DICOM Series', 'DICOM Image', 'Count', 'Site IEN', 'Site', 'Error', 'BIGPath', 'Patient DFN', 'Patient Name', 'Image Class', 'Cap Dt', 'Document Date', 'Group IEN', 'Group Ch1', 'RPC Server', 'RPC Port', 'Controlled Image', 'Viewable Status', 'Status', 'Image Annotated', 'Image TIU Note Completed', 'Annotation Operation Status', 'Annotation Operation Status Description', 'Package');
|
||||||
|
res.Patient = d_split1(data[1], '^', 'IEN', 'name');
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const d_parse_imagegroup = data => {
|
||||||
|
var res = d_split(data.slice(1), '^', 'B2', 'IEN', 'Image Path', 'Abstract Path', 'Short Desc', 'Procedure Time', 'Object Type', 'Procedure', 'Display Date', 'Pointer', 'Abs Type', 'Availability', 'DICOM Series', 'DICOM Image', 'Count', 'Site IEN', 'Site', 'Error', 'BIGPath', 'Patient DFN', 'Patient Name', 'Image Class', 'Cap Dt', 'Document Date', 'Group IEN', 'Group Ch1', 'RPC Server', 'RPC Port', 'Controlled Image', 'Viewable Status', 'Status', 'Image Annotated', 'Image TIU Note Completed', 'Annotation Operation Status', 'Annotation Operation Status Description', 'Package');
|
||||||
|
res.Result = d_split1(data[0], '^', 'code', 'count');
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const d_parse_orderdialogs = (data, columns=['IEN', 'windowFormId', 'displayGroupId', 'type', 'displayText']) => data.map(function(row) {
|
||||||
|
row = row.split('^');
|
||||||
|
row = [...row[0].split(';'), row[1]];
|
||||||
|
return columns.reduce((acc, val, idx) => (acc[val] = acc[idx], acc), row);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const d_parse_ordermenu = data => {
|
||||||
|
var res = d_split1(data[0], '^', 'name', 'columns', 'path_switch');
|
||||||
|
res.children = d_split(data.slice(1), '^', 'col', 'row', 'type', 'IEN', 'formid', 'autoaccept', 'display_text', 'mnemonic', 'displayonly');
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const f_parse_caretseparated_detail = (columns, detailcolumn) => {
|
||||||
|
if(!detailcolumn) detailcolumn = 'detail';
|
||||||
|
return columns ? data => {
|
||||||
|
var res = [], item = {};
|
||||||
|
for(var i = 0; i < data.length; ++i) {
|
||||||
|
var row = data[i], prefix = row.charAt(0);
|
||||||
|
if(prefix == '~') res.push(item = columns.reduce((acc, val, idx) => (acc[val] = acc[idx], acc), row.substring(1).split('^')));
|
||||||
|
else if(prefix == 't') {
|
||||||
|
if(item[detailcolumn]) item[detailcolumn] += '\r\n' + data[i].substring(1);
|
||||||
|
else item[detailcolumn] = data[i].substring(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
} : data => {
|
||||||
|
var res = [], item = {};
|
||||||
|
for(var i = 0; i < data.length; ++i) {
|
||||||
|
var row = data[i], prefix = row.charAt(0);
|
||||||
|
if(prefix == '~') res.push(item = row.substring(1).split('^'));
|
||||||
|
else if(prefix == 't') {
|
||||||
|
if(item[detailcolumn]) item[detailcolumn] += '\r\n' + data[i].substring(1);
|
||||||
|
else item[detailcolumn] = data[i].substring(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
export function unwrapped(fn) {
|
export const d_parse_orderoptions_scheduling = data => {
|
||||||
return async function(...args) {
|
var res = orderoptions_parse(data);
|
||||||
var res = await fn(...args);
|
for(var k in res) if(res.hasOwnProperty(k)) {
|
||||||
if(res.error) throw new RPCError(res.error.type, ...res.error.args);
|
if(res[k].items) res[k].items = res[k].items.split('^');
|
||||||
else return res.result;
|
res['~' + k.toUpperCase()] = res[k];
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const d_parse_orderoptions_labfacility = data => {
|
||||||
|
var res = orderoptions_parse(data), val, defaultvalue;
|
||||||
|
for(var k in res) if(res.hasOwnProperty(k)) {
|
||||||
|
val = res[k];
|
||||||
|
if(val.default) {
|
||||||
|
val.default = d_split1(val.default, '^', 'value', 'text');
|
||||||
|
defaultvalue = val.default.value;
|
||||||
|
} else defaultvalue = null;
|
||||||
|
if(val.items) val.items = val.items.split('\r\n').map(x => x ? (x = d_split1(x, '^', 'value', 'text'), x.default = x.value == defaultvalue, x) : null);
|
||||||
|
res['~' + k.toUpperCase()] = val;
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const d_parse_orderoptions_labtest = data => {
|
||||||
|
var res = orderoptions_parse(data), val, defaultvalue;
|
||||||
|
if(res.hasOwnProperty('Test Name')) res['Test Name'].default = res['Test Name'].default.replace(/^\s+|\s+$/, '');
|
||||||
|
if(res.hasOwnProperty('Item ID')) res['Item ID'].default = d_split1(res['Item ID'].default, '^', 'value', 'text');
|
||||||
|
if(res.hasOwnProperty('ReqCom')) res['ReqCom'].default = res['ReqCom'].default.replace(/^\s+|\s+$/, '');
|
||||||
|
if(res.hasOwnProperty('CollSamp')) res['CollSamp'].items = res['CollSamp'].items.split('\r\n').map(x => d_split1(x, '^', 'n', 'SampIEN', 'SampName', 'SpecPtr', 'TubeTop', 'unk_5', 'unk_6', 'LabCollect', 'unk_8', 'SpecName'));
|
||||||
|
res['Derived CollSamp'] = res['Unique CollSamp'] || res['Lab CollSamp'] || res['Default CollSamp'];
|
||||||
|
if(res.hasOwnProperty('Specimens')) res['Specimens'].items = res['Specimens'].items.split('\r\n').map(x => d_split1(x, '^', 'value', 'text'));
|
||||||
|
if(res.hasOwnProperty('Default Urgency')) res['Default Urgency'].default = res['Default Urgency'].default.split('\r\n').map(x => d_split1(x, '^', 'value', 'text', 'x'));
|
||||||
|
if(res.hasOwnProperty('Urgencies')) res['Urgencies'].items = res['Urgencies'].items.split('\r\n').map(x => d_split1(x, '^', 'value', 'text'));
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const d_parse_orderoptions_medfill = data => {
|
||||||
|
var res = orderoptions_parse(data);
|
||||||
|
if(res.hasOwnProperty('Pickup')) {
|
||||||
|
if(res['Pickup'].default) res['Pickup'].default = d_split1(res['Pickup'].default, '^', 'value', 'text');
|
||||||
|
if(res['Pickup'].items) res['Pickup'].items = d_split(res['Pickup'].items.split('\r\n'), '^', 'value', 'text');
|
||||||
|
}
|
||||||
|
if(res.hasOwnProperty('Priority')) {
|
||||||
|
if(res['Priority'].default) res['Priority'].default = d_split1(res['Priority'].default, '^', 'value', 'text');
|
||||||
|
if(res['Priority'].items) res['Priority'].items = d_split(res['Priority'].items.split('\r\n'), '^', 'value', 'text');
|
||||||
|
}
|
||||||
|
if(res.hasOwnProperty('Refills')) {
|
||||||
|
if(res['Refills'].default) res['Refills'].default = d_split1(res['Refills'].default, '^', 'value', 'text');
|
||||||
|
if(res['Refills'].items) res['Refills'].items = d_split(res['Refills'].items.split('\r\n'), '^', 'value', 'text');
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const d_parse_orderoptions_meddose = data => {
|
||||||
|
var res = orderoptions_parse(data);
|
||||||
|
if(res.hasOwnProperty('AllDoses')) res['AllDoses'].items = d_split(res['AllDoses'].items.split('\r\n'), '^', 'text', 'id', 'dosefields');
|
||||||
|
if(res.hasOwnProperty('Dispense')) res['Dispense'].items = d_split(res['Dispense'].items.split('\r\n'), '^', 'id', 'dose', 'unit', 'text', 'split');
|
||||||
|
if(res.hasOwnProperty('Dosage')) res['Dosage'].items = d_split(res['Dosage'].items.split('\r\n'), '^', 'medication', '_', '_', 'value', 'text', 'tier', '_', 'form');
|
||||||
|
if(res.hasOwnProperty('Indication')) res['Indication'].items = res['Indication'].items.split('\r\n');
|
||||||
|
if((res.hasOwnProperty('Medication')) && (res['Medication'].default)) res['Medication'].default = d_split1(res['Medication'].default, '^', 'value', 'text');
|
||||||
|
if(res.hasOwnProperty('Route')) {
|
||||||
|
if(res['Route'].default) res['Route'].default = d_split1(res['Route'].default, '^', 'value', 'abbr');
|
||||||
|
res['Route'].items = d_split(res['Route'].items.split('\r\n'), '^', 'value', 'text', 'abbr', 'sig', '_');
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const d_parse_notifications_fastuser = data => d_split(data, '^', 'info', 'patient', 'location', 'urgency', 'time', 'message', 'unk_6', 'meta', 'unk_7', 'unk_8').map(row => {
|
||||||
|
if(row.meta) {
|
||||||
|
var meta = row.meta.split(';');
|
||||||
|
var xqaid = row.meta_xqaid = meta[0];
|
||||||
|
row.meta_duz = meta[1];
|
||||||
|
row.meta_time = +meta[2];
|
||||||
|
if(xqaid.startsWith('OR,')) {
|
||||||
|
xqaid = xqaid.split(',');
|
||||||
|
row.meta_source = xqaid[0];
|
||||||
|
row.meta_dfn = xqaid[1];
|
||||||
|
row.meta_ien = xqaid[2]; // file 100.9 IEN
|
||||||
|
} else if(xqaid.startsWith('TIU')) {
|
||||||
|
row.meta_source = 'TIU';
|
||||||
|
row.meta_ien = xqaid.substring(3); // document IEN (DA)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const d_parse_multireport = data => {
|
||||||
|
if(data.length < 1) return [];
|
||||||
|
var brk, max = 0, grp, _ts = data._ts;
|
||||||
|
for(var i = 0; i < data.length; ++i) {
|
||||||
|
brk = (grp = data[i]).indexOf('^');
|
||||||
|
if(brk >= 0) {
|
||||||
|
grp = +grp.substring(0, brk);
|
||||||
|
if(grp >= max) max = grp;
|
||||||
|
else break;
|
||||||
|
} else return (data = [data], data._ts = _ts, data);
|
||||||
|
}
|
||||||
|
var res = [], data = data.slice(), max = max + '^', grp = x => x.startsWith(max);
|
||||||
|
while(((brk = data.findIndex(grp)) >= 0) || (brk = data.length)) res.push(data.splice(0, brk + 1));
|
||||||
|
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;
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
|
||||||
export function memoized(fn) {
|
export function memoized(fn) {
|
||||||
var cache = {};
|
var cache = {};
|
||||||
@ -32,51 +250,38 @@ export function memoized(fn) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
export function Client(cid, secret) {
|
||||||
var heartbeat = null;
|
var heartbeat = null;
|
||||||
|
|
||||||
this.secret = secret;
|
this.secret = secret;
|
||||||
this.cid = cid;
|
this.cid = cid;
|
||||||
|
this.status = reactive({ connected: true, busy: 0 });
|
||||||
|
|
||||||
this.call = (method, ...params) => vista.call(cid, method, ...params);
|
this.close = function() {
|
||||||
this.callctx = (context, method, ...params) => vista.callctx(cid, context, method, ...params);
|
console.log('CLOSE', cid);
|
||||||
|
if(heartbeat) window.clearInterval(heartbeat);
|
||||||
|
this.status.connected = false;
|
||||||
|
return vista.close(cid);
|
||||||
|
};
|
||||||
|
this.call = async function(body, ...params) {
|
||||||
|
body = (typeof body === 'string') || (body instanceof String) ? { method: body, params, id: Date.now() } : Object.assign({ id: Date.now() }, body);
|
||||||
|
if(params.length > 0) {
|
||||||
|
if(body.params) Array.prototype.push.apply(body.params, params);
|
||||||
|
else body.params = params;
|
||||||
|
}
|
||||||
|
this.status.busy++;
|
||||||
|
try {
|
||||||
|
var res = await vista.call(cid, body);
|
||||||
|
} finally {
|
||||||
|
this.status.busy--;
|
||||||
|
}
|
||||||
|
if((res.error) && (res.error.type == 'ConnectionResetError')) this.close();
|
||||||
|
res._request = body;
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
this.callctx = function(context, method, ...params) {
|
||||||
|
return this.call({ context, method, params, id: Date.now() });
|
||||||
|
};
|
||||||
this.heartbeat = async function(interval=null) {
|
this.heartbeat = async function(interval=null) {
|
||||||
if(!interval) interval = 0.45*1000*(await this.XWB_GET_BROKER_INFO())[0];
|
if(!interval) interval = 0.45*1000*(await this.XWB_GET_BROKER_INFO())[0];
|
||||||
if(heartbeat) window.clearInterval(heartbeat);
|
if(heartbeat) window.clearInterval(heartbeat);
|
||||||
@ -84,31 +289,131 @@ export function Client(cid, secret) {
|
|||||||
return heartbeat = window.setInterval(this.XWB_IM_HERE, interval);
|
return heartbeat = window.setInterval(this.XWB_IM_HERE, interval);
|
||||||
}
|
}
|
||||||
this.serverinfo = () => vista.serverinfo(cid);
|
this.serverinfo = () => vista.serverinfo(cid);
|
||||||
this.userinfo = () => vista.userinfo(cid);
|
this.authinfo = aflow(() => vista.authinfo(cid), d_unwrap, d_parse_authinfo);
|
||||||
this.authenticate = (avcode=null) => vista.authenticate(cid, avcode);
|
this.authenticate = aflow((avcode=null) => vista.authenticate(cid, avcode), d_unwrap, d_parse_authinfo);
|
||||||
|
|
||||||
this.XWB_IM_HERE = unwrapped(logged(() => vista.call(cid, 'XWB_IM_HERE'), 'XWB_IM_HERE'));
|
if(!localstate.encfs) localstate.encfs = tplfs_randpassword();
|
||||||
|
this.tplfs = async () => this._tplfs ? this._tplfs : (this._tplfs = await TplFS.fromUser(this, (await this.authinfo()).duz));
|
||||||
|
this.encfs = async () => this._encfs ? this._encfs : (this._encfs = await EncFS.fromPassword(await this.tplfs(), localstate.encfs));
|
||||||
|
|
||||||
this.XUS_INTRO_MSG = memoized(unwrapped(logged(() => vista.callctx(cid, ['XUCOMMAND'], 'XUS_INTRO_MSG'), 'XUS_INTRO_MSG')));
|
this.remotestate = reactive({});
|
||||||
this.XWB_GET_BROKER_INFO = memoized(unwrapped(logged(() => vista.callctx(cid, ['XUCOMMAND'], 'XWB_GET_BROKER_INFO'), 'XWB_GET_BROKER_INFO')));
|
this.setup_remotestate = async () => {
|
||||||
this.XUS_GET_USER_INFO = memoized(unwrapped(logged(() => vista.call(cid, 'XUS_GET_USER_INFO'), 'XUS_GET_USER_INFO')));
|
var fs = await this.encfs(), file;
|
||||||
|
try {
|
||||||
|
file = await fs.open('state');
|
||||||
|
Object.assign(this.remotestate, JSON.parse(await file.cat(null)));
|
||||||
|
} catch(ex) {
|
||||||
|
console.error(ex);
|
||||||
|
file = await fs.create('state', JSON.stringify(this.remotestate));
|
||||||
|
}
|
||||||
|
watch(this.remotestate, debounce(function(value) {
|
||||||
|
file.update(null, JSON.stringify(value));
|
||||||
|
}, 1000), { deep: true });
|
||||||
|
if((!this.remotestate.resources) && (localstate.resources)) this.remotestate.resources = localstate.resources;
|
||||||
|
if((!this.remotestate.practitioner) && (localstate.practitioner)) this.remotestate.practitioner = localstate.practitioner;
|
||||||
|
if(localstate.resources) delete localstate.resources;
|
||||||
|
if(localstate.practitioner) delete localstate.practitioner;
|
||||||
|
};
|
||||||
|
|
||||||
this.SDEC_RESOURCE = memoized(unwrapped(logged(() => vista.callctx(cid, ['SDECRPC'], 'SDEC_RESOURCE'), 'SDEC_RESOURCE')));
|
this.XWB_IM_HERE = aflow(() => this.call({ method: 'XWB_IM_HERE', ttl: 30, stale: false }), d_log, d_unwrap);
|
||||||
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.XUS_INTRO_MSG = memoized(aflow(() => this.callctx(['XUCOMMAND'], 'XUS_INTRO_MSG'), d_log, d_unwrap));
|
||||||
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.XWB_GET_BROKER_INFO = memoized(aflow(() => this.call({ method: 'XWB_GET_BROKER_INFO', context: ['XUCOMMAND'], ttl: 0, stale: false }), d_log, d_unwrap));
|
||||||
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.XUS_GET_USER_INFO = memoized(aflow(() => this.call({ method: 'XUS_GET_USER_INFO', ttl: 0, stale: false }), d_log, d_unwrap));
|
||||||
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.SDEC_RESOURCE = memoized(aflow(() => this.call({ method: 'SDEC_RESOURCE', context: ['SDECRPC'], ttl: 2592000, stale: true }), d_log, d_unwrap));
|
||||||
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.SDEC_CLINLET = aflow((...args) => this.call({ method: 'SDEC_CLINLET', context: ['SDECRPC'], ttl: 30, stale: false }, ...args), d_log, d_unwrap);
|
||||||
|
this.SDEC_CRSCHED = aflow((...args) => this.call({ method: 'SDEC_CRSCHED', context: ['SDECRPC'], ttl: 30, stale: false }, ...args), d_log, d_unwrap);
|
||||||
|
|
||||||
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.ORWPT_FULLSSN = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWPT_FULLSSN', ...args), d_log, d_unwrap, f_split('^', 'dfn', 'name', 'date', 'pid')));
|
||||||
|
this.ORWPT_LAST5 = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWPT_LAST5', ...args), d_log, d_unwrap, f_split('^', 'dfn', 'name', 'date', 'pid')));
|
||||||
|
this.ORWPT_ID_INFO = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWPT_ID_INFO', ...args), d_log, d_unwrap, f_split1('^', 'pid', 'dob', 'sex', 'vet', 'sc_percentage', 'ward', 'room_bed', 'name')));
|
||||||
|
this.ORWPT_SELCHK = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWPT_SELCHK', ...args), d_log, d_unwrap, d_parse_boolean));
|
||||||
|
this.ORWPT16_LOOKUP = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWPT16_LOOKUP', ...args), d_log, d_unwrap, f_split('^', 'dfn', 'name', 'pid')));
|
||||||
|
this.ORWPT16_ID_INFO = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWPT16_ID_INFO', ...args), d_log, d_unwrap, f_split1('^', 'pid', 'dob', 'age', 'sex', 'sc_percentage', 'type', 'ward', 'room_bed', 'name')));
|
||||||
|
|
||||||
this.ORWLRR_INTERIM = memoized(labreportparsed(unwrapped(logged((...args) => vista.callctx(cid, ['OR CPRS GUI CHART'], 'ORWLRR_INTERIM', ...args), 'ORWLRR_INTERIM'))));
|
this.ORQQVI_VITALS = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORQQVI_VITALS', ...args), d_log, d_unwrap, f_split('^', 'measurement_ien', 'type', 'value', 'datetime', 'value_american', 'value_metric')));
|
||||||
this.ORWLRR_INTERIM_RESULTS = memoized(async (...args) => lab_reparse_results(await this.ORWLRR_INTERIM(...args)));
|
this.ORQQVI_VITALS_FOR_DATE_RANGE = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORQQVI_VITALS_FOR_DATE_RANGE', ...args), d_log, d_unwrap, f_split('^', 'measurement_ien', 'type', 'value', 'datetime')));
|
||||||
|
|
||||||
|
this.GMV_EXTRACT_REC = async (dfn, oredt, orsdt) => pipe(await this.call({ method: 'GMV_EXTRACT_REC', context: ['OR CPRS GUI CHART'], ttl: 60, stale: false }, `${dfn}^${oredt}^^${orsdt}`), d_log, d_unwrap, measurement_parse);
|
||||||
|
|
||||||
|
this.ORWLRR_INTERIM = aflow((...args) => this.call({ method: 'ORWLRR_INTERIM', context: ['OR CPRS GUI CHART'], ttl: 60, stale: false }, ...args), d_log, d_unwrap, lab_parse);
|
||||||
|
this.ORWLRR_INTERIM_RESULTS = async (...args) => lab_reparse_results(await this.ORWLRR_INTERIM(...args));
|
||||||
|
|
||||||
|
this.ORWRP_REPORT_TEXT = aflow((...args) => this.call({ method: 'ORWRP_REPORT_TEXT', context: ['OR CPRS GUI CHART'], ttl: 60, stale: false }, ...args), d_log, d_unwrap, d_parse_array, d_parse_multireport);
|
||||||
|
this.ORWRP_REPORT_TEXT_LONGCACHE = aflow((...args) => this.call({ method: 'ORWRP_REPORT_TEXT', context: ['OR CPRS GUI CHART'], ttl: 86400, stale: true }, ...args), d_log, d_unwrap, d_parse_array, d_parse_multireport);
|
||||||
|
|
||||||
|
this.ORWORDG_ALLTREE = memoized(aflow(() => this.callctx(['OR CPRS GUI CHART'], 'ORWORDG_ALLTREE'), d_log, d_unwrap, f_split('^', 'ien', 'name', 'parent', 'has_children')));
|
||||||
|
this.ORWORDG_REVSTS = memoized(aflow(() => this.callctx(['OR CPRS GUI CHART'], 'ORWORDG_REVSTS'), d_log, d_unwrap, f_split('^', 'ien', 'name', 'parent', 'has_children')));
|
||||||
|
this.ORWORR_AGET = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWORR_AGET', ...args), d_log, d_unwrap, f_slice(1), f_split('^', 'ifn', 'dgrp', 'time')));
|
||||||
|
this.ORWORR_GET4LST = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWORR_GET4LST', ...args), d_log, d_unwrap, orderinfo_parse));
|
||||||
|
|
||||||
|
this.TIU_TEMPLATE_GETROOTS = aflow((...args) => this.call({ method: 'TIU_TEMPLATE_GETROOTS', context: ['OR CPRS GUI CHART'], ttl: 86400, stale: true }, ...args), d_log, d_unwrap, f_split('^', 'IEN', 'type', 'status', 'name', 'exclude_from_group_boilerplate', 'blank_lines', 'personal_owner', 'has_children_flag', 'dialog', 'display_only', 'first_line', 'one_item_only', 'hide_dialog_items', 'hide_tree_items', 'indent_items', 'reminder_dialog_ien', 'reminder_dialog_name', 'locked', 'com_object_pointer', 'com_object_parameter', 'link_pointer', 'reminder_dialog_patient_specific_value'));
|
||||||
|
this.TIU_TEMPLATE_GETPROOT = aflow((...args) => this.call({ method: 'TIU_TEMPLATE_GETPROOT', context: ['OR CPRS GUI CHART'], ttl: 86400, stale: true }, ...args), d_log, d_unwrap, f_split('^', 'IEN', 'type', 'status', 'name', 'exclude_from_group_boilerplate', 'blank_lines', 'personal_owner', 'has_children_flag', 'dialog', 'display_only', 'first_line', 'one_item_only', 'hide_dialog_items', 'hide_tree_items', 'indent_items', 'reminder_dialog_ien', 'reminder_dialog_name', 'locked', 'com_object_pointer', 'com_object_parameter', 'link_pointer', 'reminder_dialog_patient_specific_value'));
|
||||||
|
this.TIU_TEMPLATE_GETBOIL = aflow((...args) => this.call({ method: 'TIU_TEMPLATE_GETBOIL', context: ['OR CPRS GUI CHART'], ttl: 0, stale: false }, ...args), d_log, d_unwrap, d_parse_text);
|
||||||
|
this.TIU_TEMPLATE_GETITEMS = aflow((...args) => this.call({ method: 'TIU_TEMPLATE_GETITEMS', context: ['OR CPRS GUI CHART'], ttl: 0, stale: false }, ...args), d_log, d_unwrap, d_parse_array, f_split('^', 'IEN', 'type', 'status', 'name', 'exclude_from_group_boilerplate', 'blank_lines', 'personal_owner', 'has_children_flag', 'dialog', 'display_only', 'first_line', 'one_item_only', 'hide_dialog_items', 'hide_tree_items', 'indent_items', 'reminder_dialog_ien', 'reminder_dialog_name', 'locked', 'com_object_pointer', 'com_object_parameter', 'link_pointer', 'reminder_dialog_patient_specific_value'));
|
||||||
|
this.TIU_TEMPLATE_SET_ITEMS = aflow((...args) => this.call({ method: 'TIU_TEMPLATE_SET_ITEMS', context: ['OR CPRS GUI CHART'], ttl: 0, stale: false }, ...args), d_log, d_unwrap);
|
||||||
|
this.TIU_TEMPLATE_CREATE_MODIFY = aflow((...args) => this.call({ method: 'TIU_TEMPLATE_CREATE/MODIFY', context: ['OR CPRS GUI CHART'], ttl: 0, stale: false }, ...args), d_log, d_unwrap);
|
||||||
|
this.TIU_TEMPLATE_DELETE = aflow((...args) => this.call({ method: 'TIU_TEMPLATE_DELETE', context: ['OR CPRS GUI CHART'], ttl: 0, stale: false }, ...args), d_log, d_unwrap);
|
||||||
|
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.ORQQCN_LIST = aflow((...args) => this.call({ method: 'ORQQCN_LIST', context: ['OR CPRS GUI CHART'], ttl: 60, stale: false }, ...args), d_log, d_unwrap, d_parse_array, f_split('^', 'IEN', 'time', 'status', 'orderable', 'type', 'has_children', 'text', 'order_ifn', 'type_abbr'));
|
||||||
|
this.ORQQCN_DETAIL = aflow((...args) => this.call({ method: 'ORQQCN_DETAIL', context: ['OR CPRS GUI CHART'], ttl: 60, stale: false }, ...args), d_log, d_unwrap, d_parse_text);
|
||||||
|
|
||||||
|
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.MAG4_IMAGE_LIST = memoized(aflow((...args) => this.call({ method: 'MAG4_IMAGE_LIST', context: ['MAG WINDOWS'], ttl: 60, stale: false }, ...args), d_log, d_unwrap, d_parse_array, d_parse_imagelist));
|
||||||
|
this.MAG4_GET_IMAGE_INFO = memoized(aflow((...args) => this.call({ method: 'MAG4_GET_IMAGE_INFO', context: ['MAG WINDOWS'], ttl: 60, stale: false }, ...args), d_log, d_unwrap, d_parse_text));
|
||||||
|
this.MAGG_IMAGE_INFO = memoized(aflow((...args) => this.call({ method: 'MAGG_IMAGE_INFO', context: ['MAG WINDOWS'], ttl: 60, stale: false }, ...args), d_log, d_unwrap, d_parse_array, d_parse_imageinfo));
|
||||||
|
this.MAGG_GROUP_IMAGES = memoized(aflow((...args) => this.call({ method: 'MAGG_GROUP_IMAGES', context: ['MAG WINDOWS'], ttl: 60, stale: false }, ...args), d_log, d_unwrap, d_parse_array, d_parse_imagegroup));
|
||||||
|
|
||||||
|
this.ORWCV_VST = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWCV_VST', ...args), d_log, d_unwrap, d_parse_array, 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.call({ method: 'ORWU1_NEWLOC', context: ['OR CPRS GUI CHART'], ttl: 86400, stale: true }, ...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));
|
||||||
|
this.ORWDX_DGRP = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDX_DGRP', ...args), d_log, d_unwrap));
|
||||||
|
this.ORWDX_WRLST = memoized(aflow(() => this.callctx(['OR CPRS GUI CHART'], 'ORWDX_WRLST'), d_log, d_unwrap, d_parse_orderdialogs));
|
||||||
|
this.ORWDX_ORDITM = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDX_ORDITM', ...args), d_log, d_unwrap, f_split('^', 'IEN', 'synonym', 'name')));
|
||||||
|
this.ORWDX_DLGID = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDX_DLGID', ...args), d_log, d_unwrap));
|
||||||
|
this.ORWDX_DLGDEF = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDX_DLGDEF', ...args), d_log, d_unwrap, f_split('^', 'promptID', 'promptIEN', 'fmtSeq', 'fmtCode', 'omit', 'lead', 'trail', 'newLine', 'wrap', 'children', 'isChild'), f_key('promptID')));
|
||||||
|
this.ORWDX_LOADRSP = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDX_LOADRSP', ...args), d_log, d_unwrap, orderoverrides_parse, f_key('promptID')));
|
||||||
|
this.ORWDX_SAVE = aflow((...args) => this.call({ method: 'ORWDX_SAVE', context: ['OR CPRS GUI CHART'], ttl: 0, stale: false }, ...args), d_log, d_unwrap);
|
||||||
|
this.ORWDXM_MENU = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDXM_MENU', ...args), d_log, d_unwrap, d_parse_ordermenu));
|
||||||
|
this.ORWDXM_DLGNAME = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDXM_DLGNAME', ...args), d_log, d_unwrap, f_split1('^', 'InternalName', 'DisplayName', 'BaseDialogIEN', 'BaseDialogName')));
|
||||||
|
this.ORWDXM_PROMPTS = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDXM_PROMPTS', ...args), d_log, d_unwrap, f_parse_caretseparated_detail(['id', 'req', 'hid', 'prompt', 'type', 'domain', 'default', 'idflt', 'help']), f_key('id')));
|
||||||
|
this.ORWDXM1_BLDQRSP = aflow((...args) => this.call({ method: 'ORWDXM1_BLDQRSP', context: ['OR CPRS GUI CHART'], ttl: 0, stale: false }, ...args), d_log, d_unwrap, f_split('^', 'QuickLevel', 'ResponseID', 'Dialog', 'Type', 'FormID', 'DGrpLST'));
|
||||||
|
this.ORWUL_FV4DG = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWUL_FV4DG', ...args), d_log, d_unwrap, f_split1('^', 'IEN', 'count')));
|
||||||
|
this.ORWUL_FVSUB = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWUL_FVSUB', ...args), d_log, d_unwrap, f_split('^', 'IEN', 'description')));
|
||||||
|
this.ORWUL_FVIDX = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWUL_FVIDX', ...args), d_log, d_unwrap, f_split1('^', 'index', 'description')));
|
||||||
|
|
||||||
|
this.ORWDSD1_ODSLCT = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDSD1_ODSLCT', ...args), d_log, d_unwrap, d_parse_orderoptions_scheduling));
|
||||||
|
|
||||||
|
this.ORWDLR32_DEF = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDLR32_DEF', ...args), d_log, d_unwrap, d_parse_orderoptions_labfacility));
|
||||||
|
this.ORWDLR32_LOAD = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDLR32_LOAD', ...args), d_log, d_unwrap, d_parse_orderoptions_labtest));
|
||||||
|
|
||||||
|
this.ORWDPS1_SCHALL = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDPS1_SCHALL', ...args), d_log, d_unwrap, f_split('^', 'value', 'text', '_', 'times'), f_key('value')));
|
||||||
|
this.ORWDPS1_ODSLCT = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDPS1_ODSLCT', ...args), d_log, d_unwrap, d_parse_orderoptions_medfill));
|
||||||
|
this.ORWDPS2_OISLCT = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDPS2_OISLCT', ...args), d_log, d_unwrap, d_parse_orderoptions_meddose));
|
||||||
|
this.ORWDPS2_DAY2QTY = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDPS2_DAY2QTY', ...args), d_log, d_unwrap));
|
||||||
|
this.ORWDPS2_QTY2DAY = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDPS2_QTY2DAY', ...args), d_log, d_unwrap));
|
||||||
|
|
||||||
|
this.ORWORB_FASTUSER = aflow(() => this.call({ method: 'ORWORB_FASTUSER', context: ['OR CPRS GUI CHART'], ttl: 60, stale: false }), d_log, d_unwrap, d_parse_notifications_fastuser);
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
@ -124,48 +429,58 @@ Client.fromScratch = async function(secret, host='vista.northport.med.va.gov', p
|
|||||||
if(data.result) return Client.fromID(data.result, secret);
|
if(data.result) return Client.fromID(data.result, secret);
|
||||||
};
|
};
|
||||||
|
|
||||||
Client.fromCookie = async function(secret, host='vista.northport.med.va.gov', port=19209) {
|
Client.fromCookie = async function(secret, defaulthost='vista.northport.med.va.gov:19209') {
|
||||||
if(!secret) secret = cookie.get('secret');
|
if(!secret) secret = localstate.secret;
|
||||||
if(secret) {
|
if(secret) {
|
||||||
if(secret != cookie.get('secret')) {
|
var host = localstate.host;
|
||||||
|
host = (host || defaulthost).split(':');
|
||||||
|
if(secret != localstate.secret) {
|
||||||
console.log('Using new secret', secret);
|
console.log('Using new secret', secret);
|
||||||
var client = await Client.fromScratch(secret, host, port);
|
var client = await Client.fromScratch(secret, host[0], host[1]);
|
||||||
if(client) {
|
if(client) {
|
||||||
cookie.set('secret', secret);
|
localstate.host = host.join(':');
|
||||||
cookie.set('cid', client.cid);
|
localstate.secret = secret;
|
||||||
|
localstate.cid = client.cid;
|
||||||
console.log('Established connection', client.cid);
|
console.log('Established connection', client.cid);
|
||||||
return client;
|
return client;
|
||||||
} else {
|
} else {
|
||||||
cookie.reset('secret');
|
delete localstate.secret;
|
||||||
cookie.reset('cid');
|
delete localstate.cid;
|
||||||
console.log('Failed to connect');
|
console.log('Failed to connect');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
} else if(!cookie.get('cid')) {
|
} else if(!localstate.cid) {
|
||||||
console.log('Using saved secret', secret);
|
console.log('Using saved secret', secret);
|
||||||
var client = await Client.fromScratch(secret, host, port);
|
var client = await Client.fromScratch(secret, host[0], host[1]);
|
||||||
if(client) {
|
if(client) {
|
||||||
cookie.set('secret', secret);
|
localstate.host = host.join(':');
|
||||||
cookie.set('cid', client.cid);
|
localstate.secret = secret;
|
||||||
|
localstate.cid = client.cid;
|
||||||
console.log('Established connection', client.cid);
|
console.log('Established connection', client.cid);
|
||||||
return client;
|
return client;
|
||||||
} else {
|
} else {
|
||||||
cookie.reset('secret');
|
delete localstate.secret;
|
||||||
cookie.reset('cid');
|
delete localstate.cid;
|
||||||
console.log('Failed connection');
|
console.log('Failed connection');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log('Using saved secret and connection', secret);
|
console.log('Using saved secret and connection', secret);
|
||||||
var cid = cookie.get('cid');
|
var cid = localstate.cid;
|
||||||
var client = Client.fromID(cid, secret);
|
var client = Client.fromID(cid, secret);
|
||||||
if((await vista.call(cid, 'XWB_IM_HERE')).result == '1') return client;
|
if((await vista.call(cid, { method: 'XWB_IM_HERE', ttl: 0, stale: false, id: Date.now() })).result == '1') {
|
||||||
cookie.reset('cid');
|
var server = await client.serverinfo();
|
||||||
return await Client.fromCookie(secret, host, port);
|
if((host[0] == server.result.host) && (host[1] == server.result.port)) {
|
||||||
|
localstate.host = host.join(':');
|
||||||
|
return client;
|
||||||
|
} else console.log('Rejecting previous connection to different server', server);
|
||||||
|
}
|
||||||
|
delete localstate.cid;
|
||||||
|
return await Client.fromCookie(secret, host.join(':'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default window.vistax = {
|
export default window.vistax = {
|
||||||
RPCError, Client, connect: Client.fromCookie
|
localstate, RPCError, Client, connect: Client.fromCookie
|
||||||
};
|
};
|
||||||
|
113
main.py
113
main.py
@ -1,14 +1,17 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import os
|
||||||
import json
|
import json
|
||||||
import secrets
|
import secrets
|
||||||
import string
|
import string
|
||||||
|
import time
|
||||||
from flask import Flask, request, send_from_directory
|
from flask import Flask, request, send_from_directory
|
||||||
from flask.json import jsonify
|
from flask.json import jsonify
|
||||||
from flask.json.provider import DefaultJSONProvider
|
from flask.json.provider import DefaultJSONProvider
|
||||||
|
|
||||||
import rpc
|
import rpc
|
||||||
import util
|
import util
|
||||||
|
import protoswitch
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@ -26,18 +29,22 @@ class JSONProviderX(DefaultJSONProvider):
|
|||||||
class CacheProxyRPC(util.CacheProxy):
|
class CacheProxyRPC(util.CacheProxy):
|
||||||
def __init__(self, obj, persistent=None, volatile=None, prefix=''):
|
def __init__(self, obj, persistent=None, volatile=None, prefix=''):
|
||||||
util.CacheProxy.__init__(self, obj)
|
util.CacheProxy.__init__(self, obj)
|
||||||
if persistent is None:
|
|
||||||
persistent = util.Store().memo
|
|
||||||
if volatile is None:
|
if volatile is None:
|
||||||
volatile = util.Store().memo
|
volatile = util.Store().memo
|
||||||
self._cache(('__call__', 'close', 'authenticate', 'keepalive', 'XWB_CREATE_CONTEXT', 'XWB_IM_HERE'), None)
|
self._cache(('__call__', 'close', 'authenticate', 'keepalive', 'XWB_CREATE_CONTEXT', 'TIU_TEMPLATE_SET_ITEMS', 'TIU_TEMPLATE_CREATE/MODIFY', 'TIU_TEMPLATE_DELETE', 'TIU_TEMPLATE_LOCK', 'TIU_TEMPLATE_UNLOCK', 'ORWDX_SAVE', 'ORWDXM1_BLDQRSP'), None)
|
||||||
self._cache(('SDEC_RESOURCE', 'ORWLRR_ALLTESTS_ALL'), persistent, prefix=prefix, ttl=float('inf'))
|
|
||||||
self._cache(('XWB_GET_BROKER_INFO', 'XUS_INTRO_MSG'), volatile, prefix=prefix, ttl=float('inf'))
|
self._cache(('XWB_GET_BROKER_INFO', 'XUS_INTRO_MSG'), volatile, prefix=prefix, ttl=float('inf'))
|
||||||
self._cache(None, volatile, prefix=prefix, ttl=float('-inf'))
|
self._cache(None, volatile, prefix=prefix, ttl=float('-inf'))
|
||||||
|
self._cache_persistent(persistent=persistent, prefix=prefix)
|
||||||
def _cache_persistent(self, persistent=None, prefix=''):
|
def _cache_persistent(self, persistent=None, prefix=''):
|
||||||
if persistent is None:
|
if persistent is None:
|
||||||
persistent = util.Store().memo
|
persistent = util.Store().memo
|
||||||
self._cache(('SDEC_RESOURCE', 'ORWLRR_ALLTESTS_ALL'), persistent, prefix=prefix, ttl=float('inf'))
|
self._cache(('SDEC_RESOURCE', 'ORWU1_NEWLOC', 'ORWLRR_ALLTESTS_ALL', 'ORWORDG_ALLTREE', 'ORWORDG_REVSTS', 'ORWDX_DGNM', 'ORWDX_ORDITM'), persistent, prefix=prefix, ttl=float('inf'))
|
||||||
|
|
||||||
|
def jsonify_result(value, id=None):
|
||||||
|
return jsonify({ 'result': value._base, 'error': None, 'id': request.json.get('id'), 'ts': value._ts, 'cached': True } if isinstance(value, util.Cached) else { 'result': value, 'error': None, 'id': request.json.get('id'), 'ts': time.time() })
|
||||||
|
|
||||||
|
def jsonify_error(ex, id=None):
|
||||||
|
return jsonify({ 'result': None, 'error': { 'type': ex.__class__.__name__, 'args': ex.args }, 'id': id })
|
||||||
|
|
||||||
def application():
|
def application():
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
@ -53,32 +60,47 @@ def application():
|
|||||||
@app.post('/v1/vista')
|
@app.post('/v1/vista')
|
||||||
def cb_connect():
|
def cb_connect():
|
||||||
params = request.json['params']
|
params = request.json['params']
|
||||||
|
try:
|
||||||
if params.get('secret') == secret:
|
if params.get('secret') == secret:
|
||||||
cid = ''.join(secrets.choice(string.ascii_lowercase + string.digits) for i in range(64))
|
cid = ''.join(secrets.choice(string.ascii_lowercase + string.digits) for i in range(64))
|
||||||
while cid in clients:
|
while cid in clients:
|
||||||
cid = ''.join(secrets.choice(string.ascii_lowercase + string.digits) for i in range(64))
|
cid = ''.join(secrets.choice(string.ascii_lowercase + string.digits) for i in range(64))
|
||||||
clients[cid] = client = CacheProxyRPC(rpc.ClientSync(host=params.get('host', 'test.northport.med.va.gov'), port=int(params.get('port', 19009))))
|
clients[cid] = client = CacheProxyRPC(rpc.ClientSync(host=params.get('host', 'test.northport.med.va.gov'), port=int(params.get('port', 19009))))
|
||||||
return jsonify({ 'result': cid, 'error': None, 'id': request.json.get('id') })
|
return jsonify_result(cid, id=request.json.get('id'))
|
||||||
else:
|
else:
|
||||||
return jsonify({ 'result': None, 'error': { 'type': 'Unauthorized', 'args': [] }, 'id': request.json.get('id') })
|
return jsonify({ 'result': None, 'error': { 'type': 'Unauthorized', 'args': [] }, 'id': request.json.get('id') })
|
||||||
|
except Exception as ex:
|
||||||
|
logger.exception(request.url)
|
||||||
|
return jsonify_error(ex, id=request.json.get('id'))
|
||||||
|
|
||||||
|
@app.post('/v1/vista/<cid>/close')
|
||||||
|
def cb_close(cid):
|
||||||
|
try:
|
||||||
|
client = clients[cid]
|
||||||
|
res = client.close()
|
||||||
|
del clients[cid]
|
||||||
|
return jsonify_result(res, id=request.json.get('id'))
|
||||||
|
except Exception as ex:
|
||||||
|
logger.exception(request.url)
|
||||||
|
return jsonify_error(ex, id=request.json.get('id'))
|
||||||
|
|
||||||
@app.post('/v1/vista/<cid>/serverinfo')
|
@app.post('/v1/vista/<cid>/serverinfo')
|
||||||
def cb_serverinfo(cid):
|
def cb_serverinfo(cid):
|
||||||
try:
|
try:
|
||||||
client = clients[cid]
|
client = clients[cid]
|
||||||
return jsonify({ 'result': client._obj._server._asdict() if client._obj._server else None, 'error': None, 'id': request.json.get('id') })
|
return jsonify_result(client._obj._server, id=request.json.get('id'))
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.exception(request.url)
|
logger.exception(request.url)
|
||||||
return jsonify({ 'result': None, 'error': { 'type': ex.__class__.__name__, 'args': ex.args }, 'id': request.json.get('id') })
|
return jsonify_error(ex, id=request.json.get('id'))
|
||||||
|
|
||||||
@app.post('/v1/vista/<cid>/userinfo')
|
@app.post('/v1/vista/<cid>/authinfo')
|
||||||
def cb_userinfo(cid):
|
def cb_authinfo(cid):
|
||||||
try:
|
try:
|
||||||
client = clients[cid]
|
client = clients[cid]
|
||||||
return jsonify({ 'result': client._obj._user, 'error': None, 'id': request.json.get('id') })
|
return jsonify_result(client._obj._auth, id=request.json.get('id'))
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.exception(request.url)
|
logger.exception(request.url)
|
||||||
return jsonify({ 'result': None, 'error': { 'type': ex.__class__.__name__, 'args': ex.args }, 'id': request.json.get('id') })
|
return jsonify_error(ex, id=request.json.get('id'))
|
||||||
|
|
||||||
@app.post('/v1/vista/<cid>/authenticate')
|
@app.post('/v1/vista/<cid>/authenticate')
|
||||||
def cb_authenticate(cid):
|
def cb_authenticate(cid):
|
||||||
@ -87,42 +109,80 @@ def application():
|
|||||||
client = clients[cid]
|
client = clients[cid]
|
||||||
if 'avcode' in params:
|
if 'avcode' in params:
|
||||||
user = client.authenticate(params['avcode'])
|
user = client.authenticate(params['avcode'])
|
||||||
client._cache_persistent(persistent=util.Store(f'cache.{client._server.volume.lower()}.{client._server.uci.lower()}.{user[0]}.db', journal_mode='WAL').memo)
|
client._cache_persistent(persistent=util.Store(f'cache.{client._server["volume"].lower()}.{client._server["uci"].lower()}.{user[0]}.db', journal_mode='WAL').memo)
|
||||||
return jsonify({ 'result': user, 'error': None, 'id': request.json.get('id') })
|
return jsonify_result(user, id=request.json.get('id'))
|
||||||
else:
|
else:
|
||||||
from auth import XUIAMSSOi_MySsoTokenVBA
|
import XWBSSOi
|
||||||
if token := XUIAMSSOi_MySsoTokenVBA():
|
if token := XWBSSOi.get_sso_token(application='CPRSChart.exe'):
|
||||||
user = client.authenticate(token)
|
user = client.authenticate(token)
|
||||||
client._cache_persistent(persistent=util.Store(f'cache.{client._server.volume.lower()}.{client._server.uci.lower()}.{user[0]}.db', journal_mode='WAL').memo)
|
client._cache_persistent(persistent=util.Store(f'cache.{client._server["volume"].lower()}.{client._server["uci"].lower()}.{user[0]}.db', journal_mode='WAL').memo)
|
||||||
return jsonify({ 'result': user, 'error': None, 'id': request.json.get('id') })
|
return jsonify_result(user, id=request.json.get('id'))
|
||||||
else:
|
else:
|
||||||
return jsonify({ 'result': None, 'error': { 'type': 'Unauthorized', 'args': [] }, 'id': request.json.get('id') })
|
return jsonify({ 'result': None, 'error': { 'type': 'Unauthorized', 'args': [] }, 'id': request.json.get('id') })
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.exception(request.url)
|
logger.exception(request.url)
|
||||||
return jsonify({ 'result': None, 'error': { 'type': ex.__class__.__name__, 'args': ex.args }, 'id': request.json.get('id') })
|
return jsonify_error(ex, id=request.json.get('id'))
|
||||||
|
|
||||||
@app.post('/v1/vista/<cid>')
|
@app.post('/v1/vista/<cid>')
|
||||||
def cb_call1(cid):
|
def cb_call1(cid):
|
||||||
try:
|
try:
|
||||||
client = clients[cid]
|
client = clients[cid]
|
||||||
data = request.json
|
data = request.json
|
||||||
|
kw = {}
|
||||||
if 'context' in data:
|
if 'context' in data:
|
||||||
return jsonify({ 'result': getattr(client, data['method'].upper())(*data.get('params', ()), context=data['context']), 'error': None, 'id': data.get('id') })
|
kw['context'] = data['context']
|
||||||
else:
|
thunk = getattr(client, data['method'].upper())
|
||||||
return jsonify({ 'result': getattr(client, data['method'].upper())(*data.get('params', ())), 'error': None, 'id': data.get('id') })
|
if getattr(thunk, 'cached', False):
|
||||||
|
if 'ttl' in data:
|
||||||
|
kw['_cache_ttl'] = data['ttl']
|
||||||
|
if 'stale' in data:
|
||||||
|
kw['_cache_stale'] = data['stale']
|
||||||
|
return jsonify_result(thunk(*data.get('params', ()), **kw), id=data.get('id'))
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.exception(request.url)
|
logger.exception(request.url)
|
||||||
return jsonify({ 'result': None, 'error': { 'type': ex.__class__.__name__, 'args': ex.args }, 'id': request.json.get('id') })
|
return jsonify_error(ex, id=request.json.get('id'))
|
||||||
|
|
||||||
@app.post('/v1/vista/<cid>/<method>')
|
@app.post('/v1/vista/<cid>/<method>')
|
||||||
def cb_call2(cid, method):
|
def cb_call2(cid, method):
|
||||||
try:
|
try:
|
||||||
client = clients[cid]
|
client = clients[cid]
|
||||||
data = request.json
|
data = request.json
|
||||||
return jsonify({ 'result': getattr(client, method.upper())(*data.get('params', ())), 'error': None, 'id': data.get('id') })
|
kw = {}
|
||||||
|
if 'context' in data:
|
||||||
|
kw['context'] = data['context']
|
||||||
|
thunk = getattr(client, method.upper())
|
||||||
|
if getattr(thunk, 'cached', False):
|
||||||
|
if 'ttl' in data:
|
||||||
|
kw['_cache_ttl'] = data['ttl']
|
||||||
|
if 'stale' in data:
|
||||||
|
kw['_cache_stale'] = data['stale']
|
||||||
|
return jsonify_result(thunk(*data.get('params', ()), **kw), id=data.get('id'))
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.exception(request.url)
|
logger.exception(request.url)
|
||||||
return jsonify({ 'result': None, 'error': { 'type': ex.__class__.__name__, 'args': ex.args }, 'id': request.json.get('id') })
|
return jsonify_error(ex, id=request.json.get('id'))
|
||||||
|
|
||||||
|
@app.get('/v1/vista/<cid>/imaging/<path:path>')
|
||||||
|
def cb_imaging(cid, path):
|
||||||
|
if 'view' in request.args:
|
||||||
|
adapter = 'view.' + path.rsplit('.', 1)[1].lower() + '.html'
|
||||||
|
if os.path.isfile('./htdocs/adapter/' + adapter):
|
||||||
|
return send_from_directory('./htdocs/adapter', adapter)
|
||||||
|
client = clients[cid]
|
||||||
|
frag = path.replace('\\', '/').strip('/').split('/')
|
||||||
|
winshare = '\\\\' + '\\'.join(frag[:2]).upper()
|
||||||
|
for item in client.MAG_GET_NETLOC('ALL', context=['MAG WINDOWS']):
|
||||||
|
if item.split('^')[1] == winshare:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise PermissionError(path)
|
||||||
|
try:
|
||||||
|
open('//' + '/'.join(frag)).close()
|
||||||
|
except PermissionError as ex:
|
||||||
|
credentials = client.MAGGUSER2(context=['MAG WINDOWS'])[2].split('^')
|
||||||
|
import subprocess, XWBHash
|
||||||
|
subprocess.run(['net', 'use', winshare, '/d'])
|
||||||
|
subprocess.run(['net', 'use', winshare, '/user:' + credentials[0], XWBHash.decrypt(credentials[1])])
|
||||||
|
return send_from_directory('//' + '/'.join(frag[:2]), '/'.join(frag[2:]), as_attachment=('dl' in request.args))
|
||||||
|
|
||||||
@app.get('/<path:path>')
|
@app.get('/<path:path>')
|
||||||
def cb_static(path):
|
def cb_static(path):
|
||||||
@ -140,8 +200,9 @@ def get_port():
|
|||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
import webbrowser
|
import webbrowser
|
||||||
|
|
||||||
app = application()
|
app = application()
|
||||||
port = get_port()
|
port = get_port()
|
||||||
print(f'http://localhost:{port}/#{app.secret}')
|
print(f'http://localhost:{port}/#{app.secret}')
|
||||||
webbrowser.open(f'http://localhost:{port}/#{app.secret}')
|
webbrowser.open(f'http://localhost:{port}/#{app.secret}')
|
||||||
app.run(port=port)
|
app.run(port=port, request_handler=protoswitch.SwitchingRequestHandler)
|
||||||
|
71
protoswitch.py
Normal file
71
protoswitch.py
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import socket
|
||||||
|
import io
|
||||||
|
from werkzeug.serving import WSGIRequestHandler
|
||||||
|
|
||||||
|
from typing import Iterator
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
handler = logging.StreamHandler()
|
||||||
|
handler.setFormatter(logging.Formatter('%(asctime)s:%(levelname)s\t%(message)s'))
|
||||||
|
logger.addHandler(handler)
|
||||||
|
logger.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
bEOT = b'\x04'
|
||||||
|
|
||||||
|
class SwitchingRequestHandler(WSGIRequestHandler):
|
||||||
|
def parse_request(self):
|
||||||
|
if self.raw_requestline.startswith(b'[XWB]'):
|
||||||
|
logger.info(f"{self.client_address[0]}:{self.client_address[1]} VistA OPEN")
|
||||||
|
proxy_vista(read_from_file(self.rfile, self.raw_requestline), self.wfile, self.client_address, ('test.northport.med.va.gov', 19009))
|
||||||
|
logger.info(f"{self.client_address[0]}:{self.client_address[1]} VistA CLOSE")
|
||||||
|
return False
|
||||||
|
return WSGIRequestHandler.parse_request(self)
|
||||||
|
|
||||||
|
def proxy_vista(rfilegen: Iterator[bytes], wfile: io.BufferedWriter, localaddr: tuple, remoteaddr: tuple) -> None:
|
||||||
|
remotesock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
remotesock.connect(remoteaddr)
|
||||||
|
recipient = recv_from_socket(remotesock)
|
||||||
|
for n, req in enumerate(rfilegen):
|
||||||
|
logger.debug(f"{localaddr[0]}:{localaddr[1]} #{n} → {req.decode('latin-1').encode('unicode-escape').decode()}")
|
||||||
|
remotesock.send(req + bEOT)
|
||||||
|
res = next(recipient)
|
||||||
|
for line in res.decode('latin-1').splitlines(keepends=True):
|
||||||
|
logger.debug(f"{localaddr[0]}:{localaddr[1]} #{n} ← {line.encode('unicode-escape').decode()}")
|
||||||
|
wfile.write(res + bEOT)
|
||||||
|
remotesock.shutdown(socket.SHUT_RDWR)
|
||||||
|
remotesock.close()
|
||||||
|
|
||||||
|
def read_from_file(rfile: io.BufferedReader, buf: bytes=b'', end: bytes=bEOT, minsz: int=1024, maxsz: int=32768) -> Iterator[bytes]:
|
||||||
|
if len(buf) > 0:
|
||||||
|
while (idx := buf.find(end)) >= 0:
|
||||||
|
if idx > 0:
|
||||||
|
yield buf[:idx]
|
||||||
|
buf = buf[idx + 1:]
|
||||||
|
bufsz = minsz
|
||||||
|
while len(data := rfile.read1(bufsz)) > 0:
|
||||||
|
buf += data
|
||||||
|
while (idx := buf.find(end)) >= 0:
|
||||||
|
if idx > 0:
|
||||||
|
yield buf[:idx]
|
||||||
|
bufsz = minsz
|
||||||
|
elif bufsz < maxsz:
|
||||||
|
bufsz = _x if (_x := bufsz << 1) < maxsz else maxsz
|
||||||
|
buf = buf[idx + 1:]
|
||||||
|
|
||||||
|
def recv_from_socket(sock: socket.socket, end: bytes=bEOT, minsz: int=1024, maxsz: int=32768) -> Iterator[bytes]:
|
||||||
|
buf = b''
|
||||||
|
bufsz = minsz
|
||||||
|
while True:
|
||||||
|
if len(data := sock.recv(bufsz)) > 0:
|
||||||
|
buf += data
|
||||||
|
while (idx := buf.find(end)) >= 0:
|
||||||
|
if idx > 0:
|
||||||
|
yield buf[:idx]
|
||||||
|
bufsz = minsz
|
||||||
|
elif bufsz < maxsz:
|
||||||
|
bufsz = _x if (_x := bufsz << 1) < maxsz else maxsz
|
||||||
|
buf = buf[idx + 1:]
|
68
rpc.py
68
rpc.py
@ -5,19 +5,31 @@ import socket
|
|||||||
import threading
|
import threading
|
||||||
import asyncio
|
import asyncio
|
||||||
import warnings
|
import warnings
|
||||||
|
import logging
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
|
|
||||||
from XWBHash import encrypt0 as XWBHash_encrypt
|
from XWBHash import encrypt0 as XWBHash_encrypt
|
||||||
|
|
||||||
from typing import Any, Union, Sequence
|
from typing import Any, Union, Sequence
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class RPCExc(Exception): pass
|
class RPCExc(Exception): pass
|
||||||
class RPCExcFormat(ValueError, RPCExc): pass
|
class RPCExcFormat(ValueError, RPCExc): pass
|
||||||
class RPCExcAuth(RPCExc): pass
|
class RPCExcAuth(RPCExc): pass
|
||||||
class RPCExcServerError(RPCExc): pass
|
class RPCExcServerError(RPCExc): pass
|
||||||
class RPCExcInvalidResult(RPCExc): pass
|
class RPCExcInvalidResult(RPCExc): pass
|
||||||
|
|
||||||
class MReference(str): pass
|
class RPCType(object):
|
||||||
|
LITERAL = b'0'
|
||||||
|
REFERENCE = b'1'
|
||||||
|
LIST = b'2'
|
||||||
|
GLOBAL = b'3'
|
||||||
|
EMPTY = b'4'
|
||||||
|
STREAM = b'5'
|
||||||
|
def __init__(self, value, magic=None):
|
||||||
|
self.magic = magic
|
||||||
|
self.value = value.value if isinstance(value, RPCType) else value
|
||||||
|
|
||||||
RecordServerInfo = namedtuple('RecordServerInfo', ('server', 'volume', 'uci', 'device', 'attempts', 'skip_signon_screen', 'domain', 'production'))
|
RecordServerInfo = namedtuple('RecordServerInfo', ('server', 'volume', 'uci', 'device', 'attempts', 'skip_signon_screen', 'domain', 'production'))
|
||||||
|
|
||||||
@ -27,27 +39,27 @@ def s_pack(value: Any, encoding: str='latin-1'):
|
|||||||
return bytes((len(encoded),)) + encoded
|
return bytes((len(encoded),)) + encoded
|
||||||
raise ValueError('cannot s-pack string longer than 255 bytes: ' + repr(value))
|
raise ValueError('cannot s-pack string longer than 255 bytes: ' + repr(value))
|
||||||
|
|
||||||
def l_pack(value: Any, envelope: int=3, wrapped: bool=True, basictype=b'0', encoding: str='latin-1'):
|
def l_pack(value: Any, envelope: int=3, wrapped: bool=True, magic=None, encoding: str='latin-1'):
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
bare = b't'.join(l_pack(k, envelope=envelope, wrapped=False, encoding=encoding) + l_pack(v, envelope=envelope, wrapped=False, encoding=encoding) for k, v in value.items())
|
bare = b't'.join(l_pack(k, envelope=envelope, wrapped=False, encoding=encoding) + l_pack(v, envelope=envelope, wrapped=False, encoding=encoding) for k, v in value.items())
|
||||||
return (b'2' + bare + b'f') if wrapped else bare
|
return ((magic or b'2') + bare + b'f') if wrapped else bare
|
||||||
elif not isinstance(value, str) and hasattr(value, '__iter__'):
|
elif not isinstance(value, str) and hasattr(value, '__iter__'):
|
||||||
bare = b't'.join(l_pack(k, envelope=envelope, wrapped=False, encoding=encoding) + l_pack(v, envelope=envelope, wrapped=False, encoding=encoding) for k, v in enumerate(value))
|
bare = b't'.join(l_pack(k, envelope=envelope, wrapped=False, encoding=encoding) + l_pack(v, envelope=envelope, wrapped=False, encoding=encoding) for k, v in enumerate(value, start=1))
|
||||||
return (b'2' + bare + b'f') if wrapped else bare
|
return ((magic or b'2') + bare + b'f') if wrapped else bare
|
||||||
elif isinstance(value, MReference):
|
elif isinstance(value, RPCType):
|
||||||
return l_pack(str(value), envelope=envelope, basictype=b'1', encoding=encoding)
|
return l_pack(value.value, envelope=envelope, magic=value.magic, encoding=encoding)
|
||||||
else:
|
else:
|
||||||
encoded = str(value).encode(encoding)
|
encoded = str(value).encode(encoding)
|
||||||
if len(encoded) <= 10**envelope - 1:
|
if len(encoded) <= 10**envelope - 1:
|
||||||
bare = str(len(encoded)).zfill(envelope).encode(encoding) + encoded
|
bare = str(len(encoded)).zfill(envelope).encode(encoding) + encoded
|
||||||
return (basictype + bare + b'f') if wrapped else bare
|
return ((magic or b'0') + bare + b'f') if wrapped else bare
|
||||||
raise ValueError(f'cannot l-pack string longer than {10**envelope - 1} bytes with an envelope of {envelope}: ' + repr(value))
|
raise ValueError(f'cannot l-pack string longer than {10**envelope - 1} bytes with an envelope of {envelope}: ' + repr(value))
|
||||||
|
|
||||||
def l_pack_maxlen(value: Any, encoding: str='latin-1'):
|
def l_pack_maxlen(value: Any, encoding: str='latin-1'):
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
return max(max(l_pack_maxlen(k, encoding=encoding) for k in value.keys()), max(l_pack_maxlen(v, encoding=encoding) for v in value.values()))
|
return max(max(l_pack_maxlen(k, encoding=encoding) for k in value.keys()), max(l_pack_maxlen(v, encoding=encoding) for v in value.values())) if len(value) > 0 else 0
|
||||||
elif not isinstance(value, str) and hasattr(value, '__iter__'):
|
elif not isinstance(value, str) and hasattr(value, '__iter__'):
|
||||||
return max(len(str(max(0, len(value) - 1))), max(l_pack_maxlen(v, encoding=encoding) for v in value))
|
return max(len(str(len(value))), max(l_pack_maxlen(v, encoding=encoding) for v in value)) if len(value) > 0 else 0
|
||||||
else:
|
else:
|
||||||
return len(str(value).encode(encoding))
|
return len(str(value).encode(encoding))
|
||||||
|
|
||||||
@ -99,18 +111,20 @@ class ClientSync(object):
|
|||||||
self.sock.connect((host, port))
|
self.sock.connect((host, port))
|
||||||
self.recv_rpc_msg = recv_rpc_msg(self.sock)
|
self.recv_rpc_msg = recv_rpc_msg(self.sock)
|
||||||
self.lock = threading.Lock()
|
self.lock = threading.Lock()
|
||||||
self._server = self._user = None
|
self._server = { 'host': host, 'port': port }
|
||||||
|
self._auth = None
|
||||||
self.context = 'XUS SIGNON'
|
self.context = 'XUS SIGNON'
|
||||||
if TCPConnect and (res := self.TCPConnect(self.sock.getsockname()[0], '0', socket.gethostname())) != 'accept':
|
if TCPConnect and (res := self.TCPConnect(self.sock.getsockname()[0], '0', socket.gethostname())) != 'accept':
|
||||||
raise RPCExcInvalidResult('TCPConnect', self.sock.getsockname()[0], '0', socket.gethostname(), res)
|
raise RPCExcInvalidResult('TCPConnect', self.sock.getsockname()[0], '0', socket.gethostname(), res)
|
||||||
def __call__(self, name: str, *args: Any, command: bool=False, envelope: int=0, context: Union[bool, None]=None, encoding='latin-1'):
|
def __call__(self, name: str, *args: Any, command: bool=False, envelope: int=0, context: Union[Sequence, None]=None, encoding='latin-1'):
|
||||||
name = name.replace('_', ' ')
|
name = name.replace('_', ' ')
|
||||||
with self.lock:
|
with self.lock:
|
||||||
if name != 'XWB CREATE CONTEXT' and context and len(context) > 0 and self.context not in context:
|
if name != 'XWB CREATE CONTEXT' and context and len(context) > 0 and self.context not in context:
|
||||||
send_rpc_msg(self.sock, rpc_pack('XWB CREATE CONTEXT', XWBHash_encrypt(context[0]), envelope=envelope, encoding=encoding))
|
send_rpc_msg(self.sock, rpc_pack('XWB CREATE CONTEXT', XWBHash_encrypt(context[0]), envelope=envelope, encoding=encoding))
|
||||||
if (res := rpc_unpack_result(next(self.recv_rpc_msg), encoding=encoding)) != '1':
|
if (res := rpc_unpack_result(next(self.recv_rpc_msg), encoding=encoding)) != '1':
|
||||||
raise RPCExcInvalidResult('XWB CREATE CONTEXT', context[0], res)
|
raise RPCExcInvalidResult('XWB CREATE CONTEXT', context[0], res)
|
||||||
self.context = context
|
self.context = context[0]
|
||||||
|
logger.warning(f'RPC: {name} [{self.context}] {args}' if context else f'{name} {args}')
|
||||||
send_rpc_msg(self.sock, rpc_pack(name, *args, command=command, envelope=envelope, encoding=encoding))
|
send_rpc_msg(self.sock, rpc_pack(name, *args, command=command, envelope=envelope, encoding=encoding))
|
||||||
return rpc_unpack_result(next(self.recv_rpc_msg), encoding=encoding)
|
return rpc_unpack_result(next(self.recv_rpc_msg), encoding=encoding)
|
||||||
def __getattr__(self, key: str, commands: set={'TCPConnect'}):
|
def __getattr__(self, key: str, commands: set={'TCPConnect'}):
|
||||||
@ -129,11 +143,12 @@ class ClientSync(object):
|
|||||||
self.sock = self.recv_rpc_msg = None
|
self.sock = self.recv_rpc_msg = None
|
||||||
return res
|
return res
|
||||||
def authenticate(self, identity: str, *, context=('XUS SIGNON',)):
|
def authenticate(self, identity: str, *, context=('XUS SIGNON',)):
|
||||||
self._server = RecordServerInfo(*self('XUS SIGNON SETUP', '', '1', context=context))
|
self._server.update(RecordServerInfo(*self('XUS SIGNON SETUP', '', '1', context=context))._asdict())
|
||||||
|
if identity.startswith('<?xml version="1.0"'):
|
||||||
|
res = self('XUS ESSO VALIDATE', RPCType(tuple(identity[i:i+200] for i in range(0, len(identity), 200)), RPCType.GLOBAL))
|
||||||
|
else:
|
||||||
res = self('XUS AV CODE', XWBHash_encrypt(identity))
|
res = self('XUS AV CODE', XWBHash_encrypt(identity))
|
||||||
if res[0] == '0' or res[2] != '0':
|
self._auth = res if res[0] != '0' else None
|
||||||
raise RPCExcAuth(res[3], res)
|
|
||||||
self._user = res
|
|
||||||
return res
|
return res
|
||||||
def keepalive(self, interval=None, *, context=('XUS SIGNON',)):
|
def keepalive(self, interval=None, *, context=('XUS SIGNON',)):
|
||||||
import time
|
import time
|
||||||
@ -170,7 +185,8 @@ class ClientAsync(object):
|
|||||||
self.reader, self.writer = await asyncio.open_connection(host, port)
|
self.reader, self.writer = await asyncio.open_connection(host, port)
|
||||||
self.arecv_rpc_msg = arecv_rpc_msg(self.reader)
|
self.arecv_rpc_msg = arecv_rpc_msg(self.reader)
|
||||||
self.lock = asyncio.Lock()
|
self.lock = asyncio.Lock()
|
||||||
self._server = self._user = None
|
self._server = { 'host': host, 'port': port, 'info': None }
|
||||||
|
self._auth = None
|
||||||
self.context = 'XUS SIGNON'
|
self.context = 'XUS SIGNON'
|
||||||
if TCPConnect and (res := await self.TCPConnect(self.writer.get_extra_info('sockname')[0], '0', socket.gethostname())) != 'accept':
|
if TCPConnect and (res := await self.TCPConnect(self.writer.get_extra_info('sockname')[0], '0', socket.gethostname())) != 'accept':
|
||||||
raise RPCExcInvalidResult('TCPConnect', self.writer.get_extra_info('sockname')[0], '0', socket.gethostname(), res)
|
raise RPCExcInvalidResult('TCPConnect', self.writer.get_extra_info('sockname')[0], '0', socket.gethostname(), res)
|
||||||
@ -181,7 +197,8 @@ class ClientAsync(object):
|
|||||||
await asend_rpc_msg(self.writer, rpc_pack('XWB CREATE CONTEXT', XWBHash_encrypt(context[0]), envelope=envelope, encoding=encoding))
|
await asend_rpc_msg(self.writer, rpc_pack('XWB CREATE CONTEXT', XWBHash_encrypt(context[0]), envelope=envelope, encoding=encoding))
|
||||||
if (res := rpc_unpack_result(await self.arecv_rpc_msg.__anext__(), encoding=encoding)) != '1':
|
if (res := rpc_unpack_result(await self.arecv_rpc_msg.__anext__(), encoding=encoding)) != '1':
|
||||||
raise RPCExcInvalidResult('XWB CREATE CONTEXT', context[0], res)
|
raise RPCExcInvalidResult('XWB CREATE CONTEXT', context[0], res)
|
||||||
self.context = context
|
self.context = context[0]
|
||||||
|
logger.warning(f'RPC: {name} [{self.context}] {args}' if context else f'{name} {args}')
|
||||||
await asend_rpc_msg(self.writer, rpc_pack(name, *args, command=command, envelope=envelope, encoding=encoding))
|
await asend_rpc_msg(self.writer, rpc_pack(name, *args, command=command, envelope=envelope, encoding=encoding))
|
||||||
return rpc_unpack_result(await self.arecv_rpc_msg.__anext__(), encoding=encoding)
|
return rpc_unpack_result(await self.arecv_rpc_msg.__anext__(), encoding=encoding)
|
||||||
def __getattr__(self, key: str, commands: set={'TCPConnect'}):
|
def __getattr__(self, key: str, commands: set={'TCPConnect'}):
|
||||||
@ -205,11 +222,12 @@ class ClientAsync(object):
|
|||||||
self.reader = self.writer = None
|
self.reader = self.writer = None
|
||||||
return res
|
return res
|
||||||
async def authenticate(self, identity: str, *, context=('XUS SIGNON',)):
|
async def authenticate(self, identity: str, *, context=('XUS SIGNON',)):
|
||||||
self._server = RecordServerInfo(*await self('XUS SIGNON SETUP', '', '1', context=context))
|
self._server.update(RecordServerInfo(*await self('XUS SIGNON SETUP', '', '1', context=context))._asdict())
|
||||||
|
if identity.startswith('<?xml version="1.0"'):
|
||||||
|
res = await self('XUS ESSO VALIDATE', RPCType(tuple(identity[i:i+200] for i in range(0, len(identity), 200)), RPCType.GLOBAL))
|
||||||
|
else:
|
||||||
res = await self('XUS AV CODE', XWBHash_encrypt(identity))
|
res = await self('XUS AV CODE', XWBHash_encrypt(identity))
|
||||||
if res[0] == '0' or res[2] != '0':
|
self._auth = res if res[0] != '0' else None
|
||||||
raise RPCExcAuth(res[3], res)
|
|
||||||
self._user = res
|
|
||||||
return res
|
return res
|
||||||
async def keepalive(self, interval=None, *, context=('XUS SIGNON',)):
|
async def keepalive(self, interval=None, *, context=('XUS SIGNON',)):
|
||||||
interval = interval or 0.45*float((await self.XWB_GET_BROKER_INFO(context=context))[0])
|
interval = interval or 0.45*float((await self.XWB_GET_BROKER_INFO(context=context))[0])
|
||||||
@ -219,13 +237,13 @@ class ClientAsync(object):
|
|||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
import getpass, code
|
import getpass, code
|
||||||
from auth import XUIAMSSOi_MySsoTokenVBA
|
import XWBSSOi
|
||||||
|
|
||||||
client = ClientSync(host='test.northport.med.va.gov', port=19009)
|
client = ClientSync(host='test.northport.med.va.gov', port=19009)
|
||||||
#client = ClientSync(host='vista.northport.med.va.gov', port=19209)
|
#client = ClientSync(host='vista.northport.med.va.gov', port=19209)
|
||||||
threading.Thread(target=client.keepalive, daemon=True).start()
|
threading.Thread(target=client.keepalive, daemon=True).start()
|
||||||
print('\r\n'.join(client.XUS_INTRO_MSG()))
|
print('\r\n'.join(client.XUS_INTRO_MSG()))
|
||||||
if token := XUIAMSSOi_MySsoTokenVBA():
|
if token := XWBSSOi.get_sso_token(application='CPRSChart.exe'):
|
||||||
print('authenticate', repr(client.authenticate(token)))
|
print('authenticate', repr(client.authenticate(token)))
|
||||||
else:
|
else:
|
||||||
print('authenticate', repr(client.authenticate(f"{getpass.getpass('ACCESS CODE: ')};{getpass.getpass('VERIFY CODE: ')}")))
|
print('authenticate', repr(client.authenticate(f"{getpass.getpass('ACCESS CODE: ')};{getpass.getpass('VERIFY CODE: ')}")))
|
||||||
|
28
util.py
28
util.py
@ -16,6 +16,15 @@ except ImportError:
|
|||||||
|
|
||||||
from typing import Any, Union, AsyncGenerator, Iterable, Tuple, Callable
|
from typing import Any, Union, AsyncGenerator, Iterable, Tuple, Callable
|
||||||
|
|
||||||
|
class Cached(object):
|
||||||
|
def __init__(self, base, ts=None):
|
||||||
|
self._base = base
|
||||||
|
self._ts = ts
|
||||||
|
def __getattr__(self, key):
|
||||||
|
return getattr(self._base, key)
|
||||||
|
def __getitem__(self, key):
|
||||||
|
return getitem(self._base, key)
|
||||||
|
|
||||||
class Store(object):
|
class Store(object):
|
||||||
def __init__(self, database: Union[sqlite3.Connection, str]=':memory:', synchronous: bool=None, journal_mode: bool=None, default_factory: Union[Callable, None]=None):
|
def __init__(self, database: Union[sqlite3.Connection, str]=':memory:', synchronous: bool=None, journal_mode: bool=None, default_factory: Union[Callable, None]=None):
|
||||||
self._db = database if isinstance(database, sqlite3.Connection) else sqlite3.connect(database, check_same_thread=False)
|
self._db = database if isinstance(database, sqlite3.Connection) else sqlite3.connect(database, check_same_thread=False)
|
||||||
@ -60,8 +69,8 @@ class Mapping(object):
|
|||||||
def get(self, key: Union[str, slice], ttl: float=float('inf'), now: float=0, **kw) -> Any:
|
def get(self, key: Union[str, slice], ttl: float=float('inf'), now: float=0, **kw) -> Any:
|
||||||
if isinstance(key, slice):
|
if isinstance(key, slice):
|
||||||
key, ttl, now = key.start, key.stop, key.step
|
key, ttl, now = key.start, key.stop, key.step
|
||||||
for row in self._store.execute(f'SELECT value FROM "{self._tbl}" WHERE key = ? AND ts > ? LIMIT 1', (key, (now or time.time()) - ttl)):
|
for row in self._store.execute(f'SELECT value, ts FROM "{self._tbl}" WHERE key = ? AND ts > ? LIMIT 1', (key, (now or time.time()) - ttl)):
|
||||||
return loads(row[0])
|
return Cached(loads(row[0]), row[1])
|
||||||
if 'default' in kw:
|
if 'default' in kw:
|
||||||
return kw['default']
|
return kw['default']
|
||||||
elif self._store._default_factory is not None:
|
elif self._store._default_factory is not None:
|
||||||
@ -120,7 +129,7 @@ class CacheProxy(object):
|
|||||||
with cache:
|
with cache:
|
||||||
res = cache[_cache_key] = await value(*args, **kw)
|
res = cache[_cache_key] = await value(*args, **kw)
|
||||||
return res
|
return res
|
||||||
async def thunk(*args, _cache_ttl: float=ttl, _cache_stale: bool=True, **kw):
|
async def thunk(*args, _cache_ttl: float=ttl, _cache_stale: bool=False, **kw):
|
||||||
_cache_key = prefix + key + repr(args) + repr(kw)
|
_cache_key = prefix + key + repr(args) + repr(kw)
|
||||||
try:
|
try:
|
||||||
return cache[_cache_key:_cache_ttl]
|
return cache[_cache_key:_cache_ttl]
|
||||||
@ -136,7 +145,7 @@ class CacheProxy(object):
|
|||||||
with lock, cache:
|
with lock, cache:
|
||||||
res = cache[_cache_key] = value(*args, **kw)
|
res = cache[_cache_key] = value(*args, **kw)
|
||||||
return res
|
return res
|
||||||
def thunk(*args, _cache_ttl: float=ttl, _cache_stale: bool=True, **kw):
|
def thunk(*args, _cache_ttl: float=ttl, _cache_stale: bool=False, **kw):
|
||||||
_cache_key = prefix + key + repr(args) + repr(kw)
|
_cache_key = prefix + key + repr(args) + repr(kw)
|
||||||
try:
|
try:
|
||||||
return cache[_cache_key:_cache_ttl]
|
return cache[_cache_key:_cache_ttl]
|
||||||
@ -148,6 +157,7 @@ class CacheProxy(object):
|
|||||||
return fetch(*args, **kw)
|
return fetch(*args, **kw)
|
||||||
else:
|
else:
|
||||||
return value
|
return value
|
||||||
|
thunk.cached = True
|
||||||
setattr(self, key, thunk)
|
setattr(self, key, thunk)
|
||||||
return thunk
|
return thunk
|
||||||
|
|
||||||
@ -166,13 +176,13 @@ class SyncProxy(object):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
re_dt_fileman = r'(?P<dt_fileman>(\d{3})(\d{2})(\d{2})(?:\.(\d{2})?(\d{2})?(\d{2})?)?)' # George Timson's format
|
re_dt_fileman = r'(?P<dt_fileman>(\d{3})(\d{2})(\d{2})(?:\.(\d{2})?(\d{2})?(\d{2})?)?)' # George Timson's format
|
||||||
re_dt_today = r'(?P<dt_today>T)' # today
|
re_dt_today = r'(?P<dt_today>TODAY|T)' # today
|
||||||
re_dt_now = r'(?P<dt_now>N)' # now
|
re_dt_now = r'(?P<dt_now>NOW|N)' # now
|
||||||
re_dt_mdy = r'(?P<dt_mdy>(\d{1,2})[^\w@?]+(\d{1,2})[^\w@?]+(\d{4}|\d{2})\s*)' # m/d/yy, m/d/yyyy
|
re_dt_mdy = r'(?P<dt_mdy>(\d{1,2})[^\w@?]+(\d{1,2})[^\w@?]+(\d{4}|\d{2})\s*)' # m/d/yy, m/d/yyyy
|
||||||
re_dt_ymd = r'(?P<dt_ymd>(\d{4})[^\w@?]+(\d{1,2})[^\w@?]+(\d{1,2})\s*)' # yyyy/m/d
|
re_dt_ymd = r'(?P<dt_ymd>(\d{4})[^\w@?]+(\d{1,2})[^\w@?]+(\d{1,2})\s*)' # yyyy/m/d
|
||||||
re_dt_yyyymmdd = r'(?P<dt_yyyymmdd>(\d{4})(\d{2})(\d{2}))' # yyyymmdd
|
re_dt_yyyymmdd = r'(?P<dt_yyyymmdd>(\d{4})(\d{2})(\d{2}))' # yyyymmdd
|
||||||
re_dt_Mdy = r'(?P<dt_Mdy>([A-Z]{3,})[^\w@?]+(\d{1,2})[^\w@?]+(\d{4}|\d{2})\s*)' # M/d/yy, M/d/yyyy
|
re_dt_Mdy = r'(?P<dt_Mdy>([A-Z]{3,})[^\w@?]+(\d{1,2})[^\w@?]+(\d{4}|\d{2})\s*)' # M/d/yy, M/d/yyyy
|
||||||
re_dt_dMy = r'(?P<dt_dMy>((\d{1,2})[^\w@?]+[A-Z]{3,})[^\w@?]+(\d{4}|\d{2})\s*)' # d/M/yy, d/M/yyyy
|
re_dt_dMy = r'(?P<dt_dMy>(\d{1,2})[^\w@?]+([A-Z]{3,})[^\w@?]+(\d{4}|\d{2})\s*)' # d/M/yy, d/M/yyyy
|
||||||
re_dt_md = r'(?P<dt_md>(\d{1,2})[^\w@?]+(\d{1,2})\s*)' # m/d
|
re_dt_md = r'(?P<dt_md>(\d{1,2})[^\w@?]+(\d{1,2})\s*)' # m/d
|
||||||
re_dt_offset = r'(?P<offset>([-+]\d+)(H|W|M)?)' # +#U
|
re_dt_offset = r'(?P<offset>([-+]\d+)(H|W|M)?)' # +#U
|
||||||
re_dt_time = r'(?:@?(?P<time>(\d{1,2}):?(\d{1,2})))' # time
|
re_dt_time = r'(?:@?(?P<time>(\d{1,2}):?(\d{1,2})))' # time
|
||||||
@ -197,13 +207,13 @@ def vista_strptime(s: str) -> datetime.datetime:
|
|||||||
time = datetime.time()
|
time = datetime.time()
|
||||||
if m['time']:
|
if m['time']:
|
||||||
if m['dt_now']:
|
if m['dt_now']:
|
||||||
raise ValueError('cannot specify time with N or H offset')
|
raise ValueError('cannot specify NOW with time')
|
||||||
m1 = re.match(re_dt_time, m['time'], flags=re.I)
|
m1 = re.match(re_dt_time, m['time'], flags=re.I)
|
||||||
date = date.replace(hour=int(m1.group(2)), minute=int(m1.group(3)))
|
date = date.replace(hour=int(m1.group(2)), minute=int(m1.group(3)))
|
||||||
if m['offset']:
|
if m['offset']:
|
||||||
m1 = re.match(re_dt_offset, m['offset'], flags=re.I)
|
m1 = re.match(re_dt_offset, m['offset'], flags=re.I)
|
||||||
if (offset_unit := m1.group(3)) == 'h' and (m['time'] or m['dt_today']):
|
if (offset_unit := m1.group(3)) == 'h' and (m['time'] or m['dt_today']):
|
||||||
raise ValueError('cannot specify time or T with H offset')
|
raise ValueError('cannot specify time or TODAY with H offset')
|
||||||
date = vista_strptime_offset(date, int(m1.group(2)), offset_unit or 'd')
|
date = vista_strptime_offset(date, int(m1.group(2)), offset_unit or 'd')
|
||||||
if m['ext']:
|
if m['ext']:
|
||||||
if m['ext'] == '<':
|
if m['ext'] == '<':
|
||||||
|
Reference in New Issue
Block a user