Compare commits
44 Commits
1825edc637
...
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 |
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)
|
|
@ -3,14 +3,39 @@
|
|||||||
<div class="container-fluid" style="padding-top: 5rem;">
|
<div class="container-fluid" style="padding-top: 5rem;">
|
||||||
<Navbar v-model:server="server" :user="user" />
|
<Navbar v-model:server="server" :user="user" />
|
||||||
<Throbber :client="client" />
|
<Throbber :client="client" />
|
||||||
<div class="container">
|
<div :class="localstate.fullwidth ? 'container-fluid' : 'container'">
|
||||||
<Login :secret="secret" v-model:client="client" v-model:server="server" v-model:user="user" />
|
<Login :secret="secret" v-model:client="client" v-model:server="server" v-model:user="user" />
|
||||||
<router-view v-if="user" :client="client"></router-view>
|
<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 Submenu from './Submenu.vue';
|
||||||
import Navbar from './Navbar.vue';
|
import Navbar from './Navbar.vue';
|
||||||
import Throbber from './Throbber.vue';
|
import Throbber from './Throbber.vue';
|
||||||
@ -23,6 +48,8 @@
|
|||||||
import RoutePatientOrders from './RoutePatientOrders.vue';
|
import RoutePatientOrders from './RoutePatientOrders.vue';
|
||||||
import RoutePatientReports from './RoutePatientReports.vue';
|
import RoutePatientReports from './RoutePatientReports.vue';
|
||||||
import RoutePatientDocuments from './RoutePatientDocuments.vue';
|
import RoutePatientDocuments from './RoutePatientDocuments.vue';
|
||||||
|
import RoutePatientConsults from './RoutePatientConsults.vue';
|
||||||
|
import RoutePatientImaging from './RoutePatientImaging.vue';
|
||||||
import RoutePlanner from './RoutePlanner.vue';
|
import RoutePlanner from './RoutePlanner.vue';
|
||||||
import RouteRecall from './RouteRecall.vue';
|
import RouteRecall from './RouteRecall.vue';
|
||||||
import RouteInbox from './RouteInbox.vue';
|
import RouteInbox from './RouteInbox.vue';
|
||||||
@ -37,6 +64,7 @@
|
|||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
localstate,
|
||||||
client: null,
|
client: null,
|
||||||
server: null,
|
server: null,
|
||||||
user: null,
|
user: null,
|
||||||
@ -62,6 +90,7 @@
|
|||||||
else {
|
else {
|
||||||
[
|
[
|
||||||
{ path: '/', component: RouteSchedule },
|
{ path: '/', component: RouteSchedule },
|
||||||
|
{ path: '/schedule/:from?/:to?', component: RouteSchedule },
|
||||||
{ path: '/lookup', component: RouteLookup },
|
{ path: '/lookup', component: RouteLookup },
|
||||||
{ path: '/patient/:id', component: RoutePatient, children: [
|
{ path: '/patient/:id', component: RoutePatient, children: [
|
||||||
{ path: '', component: RoutePatientDetail },
|
{ path: '', component: RoutePatientDetail },
|
||||||
@ -70,6 +99,10 @@
|
|||||||
{ path: 'reports', component: RoutePatientReports },
|
{ path: 'reports', component: RoutePatientReports },
|
||||||
{ path: 'document', component: RoutePatientDocuments },
|
{ path: 'document', component: RoutePatientDocuments },
|
||||||
{ path: 'document/:tiu_da', 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: '/planner', component: RoutePlanner },
|
||||||
{ path: '/recall', component: RouteRecall },
|
{ path: '/recall', component: RouteRecall },
|
||||||
|
@ -53,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) {
|
||||||
@ -156,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>
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
<div class="accordion-body" v-else>
|
<div class="accordion-body" v-else>
|
||||||
<div class="card">
|
<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>
|
<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">
|
||||||
@ -81,6 +81,7 @@
|
|||||||
x_server: this.server,
|
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
|
||||||
};
|
};
|
||||||
@ -121,7 +122,7 @@
|
|||||||
this.logout();
|
this.logout();
|
||||||
if(this.x_client = await (this.host ? vistax.Client.fromCookie(this.secret, this.host) : vistax.Client.fromCookie(this.secret))) {
|
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) {
|
||||||
@ -136,20 +137,32 @@
|
|||||||
} else {
|
} else {
|
||||||
this.fail = true;
|
this.fail = true;
|
||||||
this.host = undefined;
|
this.host = undefined;
|
||||||
|
this.authinfo = null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async login(evt) {
|
async login(evt) {
|
||||||
if(this.x_client) {
|
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);
|
||||||
this.show = !this.x_user;
|
this.show = !this.x_user;
|
||||||
this.$emit('update:server', this.x_server = (await this.x_client.serverinfo()).result);
|
this.$emit('update:server', this.x_server = (await this.x_client.serverinfo()).result);
|
||||||
console.log('Authenticate', res);
|
console.log('Authenticate', this.authinfo);
|
||||||
}
|
} else this.authinfo = null;
|
||||||
},
|
},
|
||||||
async logout(evt) {
|
async logout(evt) {
|
||||||
if(this.x_client) {
|
if(this.x_client) {
|
||||||
|
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,15 +1,19 @@
|
|||||||
<template>
|
<template>
|
||||||
<Subtitle value="Patient" />
|
<Subtitle value="Patient" />
|
||||||
<div v-if="(sensitive) && (!patient_info)" class="alert alert-danger text-center mb-3 shadow" role="alert">
|
<div v-if="(sensitive) && (!viewsensitive)" class="alert alert-danger text-center mb-3 shadow" role="alert">
|
||||||
<Subtitle value="Restricted Record" />
|
<Subtitle value="Restricted Record" />
|
||||||
<h1>Warning: Restricted Record</h1>
|
<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>
|
<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>
|
||||||
<router-link class="btn btn-danger" :to="'/patient/' + patient_dfn + '?viewsensitive'">Proceed</router-link>
|
<button class="btn btn-danger" @click="viewsensitive = true">Proceed</button>
|
||||||
</div>
|
</div>
|
||||||
<template v-if="patient_info">
|
<template v-if="patient_info">
|
||||||
<Submenu :value="menu" />
|
<Submenu :value="menu" />
|
||||||
<div class="card mb-3 shadow">
|
<div class="card mb-3 shadow">
|
||||||
<div class="card-header">{{patient_info.name}} <span :title="patient_info.pid">{{patient_info.pid.slice(-4)}}</span> #{{patient_dfn}}</div>
|
<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="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.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.age"><strong>Age:</strong> {{patient_info.age}}</div>
|
||||||
@ -25,6 +29,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import cookie from './cookie.mjs';
|
||||||
import { strptime_vista } from './util.mjs';
|
import { strptime_vista } from './util.mjs';
|
||||||
|
|
||||||
import Subtitle from './Subtitle.vue';
|
import Subtitle from './Subtitle.vue';
|
||||||
@ -39,6 +44,7 @@
|
|||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
viewsensitive: false,
|
||||||
sensitive: false,
|
sensitive: false,
|
||||||
patient_dfn: null,
|
patient_dfn: null,
|
||||||
patient_info: null
|
patient_info: null
|
||||||
@ -49,26 +55,25 @@
|
|||||||
return this.patient_info ? {
|
return this.patient_info ? {
|
||||||
name: this.patient_info.name,
|
name: this.patient_info.name,
|
||||||
items: [
|
items: [
|
||||||
{ name: 'Patient', href: '/patient/' + this.patient_dfn + (this.sensitive && '?viewsensitive' || '') },
|
{ name: 'Patient', href: '/patient/' + this.patient_dfn },
|
||||||
{ name: 'Visits', href: '/patient/' + this.patient_dfn + '/visits' + (this.sensitive && '?viewsensitive' || '') },
|
{ name: 'Visits', href: '/patient/' + this.patient_dfn + '/visits' },
|
||||||
{ name: 'Orders', href: '/patient/' + this.patient_dfn + '/orders' + (this.sensitive && '?viewsensitive' || '') },
|
{ name: 'Orders', href: '/patient/' + this.patient_dfn + '/orders' },
|
||||||
{ name: 'Reports', href: '/patient/' + this.patient_dfn + '/reports' + (this.sensitive && '?viewsensitive' || '') },
|
{ name: 'Reports', href: '/patient/' + this.patient_dfn + '/reports' },
|
||||||
{ name: 'Documents', href: '/patient/' + this.patient_dfn + '/document' + (this.sensitive && '?viewsensitive' || '') },
|
{ 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;
|
} : null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
strptime_vista,
|
strptime_vista
|
||||||
async loadinfo(dfn, viewsensitive) {
|
|
||||||
this.patient_dfn = dfn;
|
|
||||||
this.sensitive = await this.client.ORWPT_SELCHK(dfn);
|
|
||||||
this.patient_info = (this.sensitive) && (!viewsensitive) ? null : await this.client.ORWPT16_ID_INFO(dfn);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
async mounted() {
|
watch: {
|
||||||
if(this.$route.params.id.startsWith('$')) {
|
'$route.params.id': {
|
||||||
var id = this.$route.params.id.substring(1);
|
async handler(value) {
|
||||||
|
if(value.startsWith('$')) {
|
||||||
|
var id = value.substring(1);
|
||||||
if(id.length == 9) {
|
if(id.length == 9) {
|
||||||
var patient = await this.client.ORWPT_FULLSSN(id);
|
var patient = await this.client.ORWPT_FULLSSN(id);
|
||||||
this.$router.replace('/patient/' + patient[0].dfn);
|
this.$router.replace('/patient/' + patient[0].dfn);
|
||||||
@ -80,11 +85,40 @@
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else this.loadinfo(this.$route.params.id, this.$route.query.hasOwnProperty('viewsensitive'));
|
} 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
|
||||||
},
|
},
|
||||||
async beforeRouteUpdate(to, from, next) {
|
viewsensitive(value) {
|
||||||
this.loadinfo(to.params.id, to.query.hasOwnProperty('viewsensitive'));
|
var viewsensitive = cookie.get('viewsensitive'), viewsensitive = viewsensitive !== null ? viewsensitive.split(',') : [], idx = viewsensitive.indexOf(this.patient_dfn);
|
||||||
next();
|
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>
|
</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>
|
@ -2,31 +2,40 @@
|
|||||||
<Subtitle value="Documents" />
|
<Subtitle value="Documents" />
|
||||||
<Subtitle :value="patient_info.name" />
|
<Subtitle :value="patient_info.name" />
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="selector col-12 col-xl-4">
|
<div class="selector col-12" :class="{ 'col-xl-4': selection }">
|
||||||
<div class="card mb-3 shadow">
|
<div class="card mb-3 shadow">
|
||||||
<div class="card-header"><template v-if="resultset.length > 0">{{resultset.length}}<template v-if="has_more">+</template></template><template v-else-if="is_loading">Loading</template><template v-else>No</template> record{{resultset.length == 1 ? '' : 's'}}</div>
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<ul class="scroller list-group list-group-flush" ref="scroller">
|
<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 v-for="item in resultset" :to="'/patient/' + patient_dfn + '/document/' + item.IEN + (sensitive ? '?viewsensitive' : '')" replace custom v-slot="{ navigate, href }">
|
<router-link :to="'/patient/' + patient_dfn + '/document/new'">
|
||||||
<li :key="item" class="record" :class="{ 'active': selection == item.IEN }" :title="datetimestring(strptime_vista(item.time)) + '\n' + item.title + '\n' + item.location + '\n' + item.author.byline" @click="navigate">
|
<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="row">
|
||||||
<div class="cell col-4"><router-link :to="href" replace>{{datestring(strptime_vista(item.time))}}</router-link></div>
|
<div class="cell col-4"><router-link :to="href" replace>{{datestring(strptime_vista(item.time))}}</router-link></div>
|
||||||
<div class="cell col-8">{{item.title}}</div>
|
<div class="cell col-8"><template v-if="item.status == 'unsigned'">✱</template>{{item.title}}</div>
|
||||||
<div class="cell secondary col-7 col-lg-4 col-xl-7">{{item.location}}</div>
|
<div class="cell secondary col-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 class="cell secondary col-5 col-lg-8 col-xl-5">{{item.author.byline}}</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</router-link>
|
</router-link>
|
||||||
<li ref="bottom" />
|
<li class="bottom list-group-item" ref="bottom" />
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="selection_text" class="col-12 col-xl-8">
|
<div v-if="selection == 'new'" class="col-12 col-xl-8">
|
||||||
<div class="card mb-3 shadow">
|
<ViewDocNew :client="client" :dfn="patient_dfn" :datetime="datetimestring(new Date())" @cancel="() => $router.replace({ path: '/patient/' + patient_dfn + '/document' })" @submit="doc_create" />
|
||||||
<div class="card-header">{{doctitle(selection_text) || 'Document'}}</div>
|
</div>
|
||||||
<div class="detail card-body">{{selection_text}}</div>
|
<div v-else-if="selection" class="detail col-12 col-xl-8" ref="detail">
|
||||||
</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
<ModalPromptSignatureCode :client="client" v-model:show="show_signature" @submit="doc_sign" label="Sign Document" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@ -35,7 +44,7 @@
|
|||||||
top: 1.15rem;
|
top: 1.15rem;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
ul.scroller {
|
ul.scroller.list-skinny {
|
||||||
max-height: 25vh;
|
max-height: 25vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
@ -43,10 +52,14 @@
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
border-top: 1px solid #dee2e6;
|
border-top: 1px solid #dee2e6;
|
||||||
padding: 0.25rem 0.75rem;
|
padding: 0.25rem 0.75rem;
|
||||||
|
scroll-margin-top: 3.6875rem;
|
||||||
}
|
}
|
||||||
li.record:nth-child(even) {
|
li.record:nth-child(even) {
|
||||||
background-color: rgba(0, 0, 0, 0.05);
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
|
ul.scroller.list-skinny li.record {
|
||||||
|
scroll-margin-top: 0;
|
||||||
|
}
|
||||||
li.record a {
|
li.record a {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
@ -54,12 +67,20 @@
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
background-color: #0d6efd;
|
background-color: #0d6efd;
|
||||||
}
|
}
|
||||||
|
li.bottom {
|
||||||
|
overflow-anchor: none;
|
||||||
|
}
|
||||||
div.cell {
|
div.cell {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
div.detail {
|
a.close {
|
||||||
|
cursor: default;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
div.detail /deep/ .card-body {
|
||||||
|
scroll-margin-top: calc(3.6875rem + 2.5625rem + 25vh);
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
@ -67,14 +88,15 @@
|
|||||||
div.selector {
|
div.selector {
|
||||||
position: static;
|
position: static;
|
||||||
}
|
}
|
||||||
ul.scroller {
|
ul.scroller.list-skinny {
|
||||||
max-height: 75vh;
|
max-height: 75vh;
|
||||||
}
|
}
|
||||||
div.cell.secondary {
|
div.cell.secondary {
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
}
|
}
|
||||||
div.detail {
|
div.detail /deep/ .card-body {
|
||||||
max-height: 75vh;
|
max-height: 75vh;
|
||||||
|
scroll-margin-top: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -84,12 +106,15 @@
|
|||||||
import { debounce, strptime_vista } from './util.mjs';
|
import { debounce, strptime_vista } from './util.mjs';
|
||||||
|
|
||||||
import Subtitle from './Subtitle.vue';
|
import Subtitle from './Subtitle.vue';
|
||||||
|
import ViewDocNew from './ViewDocNew.vue';
|
||||||
|
import ViewDocView from './ViewDocView.vue';
|
||||||
|
import ModalPromptSignatureCode from './ModalPromptSignatureCode.vue';
|
||||||
|
|
||||||
const SZ_WINDOW = 100;
|
const SZ_WINDOW = 100;
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
Subtitle
|
Subtitle, ViewDocNew, ViewDocView, ModalPromptSignatureCode
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
client: Object,
|
client: Object,
|
||||||
@ -102,12 +127,19 @@
|
|||||||
dfn: null,
|
dfn: null,
|
||||||
has_more: '',
|
has_more: '',
|
||||||
is_loading: false,
|
is_loading: false,
|
||||||
resultset: [],
|
rs_unsigned: [],
|
||||||
|
rs_signed: [],
|
||||||
selection: null,
|
selection: null,
|
||||||
selection_text: null,
|
show_signature: false,
|
||||||
observer_bottom: null
|
observer_scroller: null,
|
||||||
|
observer_viewport: null
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
computed: {
|
||||||
|
resultset() {
|
||||||
|
return this.rs_unsigned.concat(this.rs_signed);
|
||||||
|
}
|
||||||
|
},
|
||||||
watch: {
|
watch: {
|
||||||
'$route.params.tiu_da': {
|
'$route.params.tiu_da': {
|
||||||
async handler(value) {
|
async handler(value) {
|
||||||
@ -123,17 +155,6 @@
|
|||||||
datetimestring(date) {
|
datetimestring(date) {
|
||||||
return date.toLocaleDateString('sv-SE') + ' ' + date.toLocaleTimeString('en-GB');
|
return date.toLocaleDateString('sv-SE') + ' ' + date.toLocaleTimeString('en-GB');
|
||||||
},
|
},
|
||||||
doctitle(doc) {
|
|
||||||
if(doc) {
|
|
||||||
var brk = doc.indexOf('\r\n');
|
|
||||||
if(brk >= 0) {
|
|
||||||
doc = doc.substring(0, brk);
|
|
||||||
brk = doc.indexOf(': ');
|
|
||||||
if(brk >= 0) return doc.substring(brk + 2).replace(/^\s+|\s+$/g, '');
|
|
||||||
else return doc.replace(/^\s+|\s+$/g, '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async load_more() {
|
async load_more() {
|
||||||
try {
|
try {
|
||||||
this.is_loading = true;
|
this.is_loading = true;
|
||||||
@ -141,7 +162,7 @@
|
|||||||
if(this.dfn != this.patient_dfn) {
|
if(this.dfn != this.patient_dfn) {
|
||||||
this.dfn = this.patient_dfn;
|
this.dfn = this.patient_dfn;
|
||||||
this.has_more = '';
|
this.has_more = '';
|
||||||
this.resultset = [];
|
this.rs_signed = [];
|
||||||
}
|
}
|
||||||
var res = await client.TIU_DOCUMENTS_BY_CONTEXT(3, 1, this.patient_dfn, -1, -1, 0, SZ_WINDOW, 'D', 1, 0, 1, this.has_more);
|
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)) {
|
if((res) && (res.length > 0)) {
|
||||||
@ -151,13 +172,13 @@
|
|||||||
this.has_more = last.IEN;
|
this.has_more = last.IEN;
|
||||||
res.splice(res.length - 1, 1);
|
res.splice(res.length - 1, 1);
|
||||||
}
|
}
|
||||||
if(this.resultset.length > 0) Array.prototype.push.apply(this.resultset, res);
|
if(this.rs_signed.length > 0) Array.prototype.push.apply(this.rs_signed, res);
|
||||||
else this.resultset = res;
|
else this.rs_signed = res;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.dfn = null;
|
this.dfn = null;
|
||||||
this.has_more = '';
|
this.has_more = '';
|
||||||
this.resultset = [];
|
this.rs_signed = [];
|
||||||
}
|
}
|
||||||
} catch(ex) {
|
} catch(ex) {
|
||||||
console.warn(ex);
|
console.warn(ex);
|
||||||
@ -165,35 +186,100 @@
|
|||||||
this.is_loading = false;
|
this.is_loading = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
handle_bottom([entry]) {
|
async load_unsigned() {
|
||||||
if((entry.isIntersecting) && (this.has_more) && (!this.is_loading)) this.load_more();
|
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() {
|
created() {
|
||||||
this.$watch(
|
this.$watch(
|
||||||
() => (this.client, this.patient_dfn, {}),
|
() => (this.client, this.patient_dfn, {}),
|
||||||
debounce(this.load_more, 500),
|
debounce(() => { this.load_more(); this.load_unsigned(); }, 500),
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
this.$watch(
|
this.$watch(
|
||||||
() => (this.client, this.selection, {}),
|
() => (this.client, this.selection, {}),
|
||||||
debounce(async function() {
|
async function() {
|
||||||
try {
|
if(this.$refs.scroller) {
|
||||||
this.selection_text = (this.client) && (this.selection) ? await this.client.TIU_GET_RECORD_TEXT(this.selection) : null;
|
if(this.selection) { // scroll to selected item
|
||||||
} catch(ex) {
|
if(this.selection != 'new') {
|
||||||
this.selection_text = null;
|
await this.$nextTick();
|
||||||
console.warn(ex);
|
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;
|
||||||
}
|
}
|
||||||
}, 500),
|
}
|
||||||
|
} 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 }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.observer_bottom = new IntersectionObserver(this.handle_bottom, { root: this.$refs.scroller, rootMargin: '25%' });
|
this.observer_scroller = new IntersectionObserver(([entry]) => { if((entry.isIntersecting) && (this.selection) && (this.has_more) && (!this.is_loading)) this.load_more(); }, { root: this.$refs.scroller, rootMargin: '25%' });
|
||||||
this.observer_bottom.observe(this.$refs.bottom);
|
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() {
|
destroyed() {
|
||||||
if(this.observer_bottom) this.observer_bottom.disconnect();
|
if(this.observer_viewport) this.observer_viewport.disconnect();
|
||||||
|
if(this.observer_scroller) this.observer_scroller.disconnect();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</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>
|
@ -12,20 +12,19 @@
|
|||||||
</li>
|
</li>
|
||||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<button v-for="report in reports" class="btn" :class="{ 'btn-primary': report.enabled, 'btn-outline-primary': !report.enabled }" @click="toggle(report)">{{report.name}}</button>
|
<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>
|
</div>
|
||||||
<button class="btn" :class="{ 'btn-success': unify, 'btn-outline-success': !unify }" @click="unify = !unify">Unify</button>
|
<DateRangePicker range="Range" direction="-1" v-model:date="date_end" v-model:date_end="date_begin" />
|
||||||
<DateRangePicker range="1M" direction="-1" v-model:date="date_end" v-model:date_end="date_begin" />
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="resultset.length > 0" class="row">
|
<div class="row">
|
||||||
<div class="selector col-12" :class="{ 'col-xl-4': selection }">
|
<div class="selector col-12" :class="{ 'col-xl-4': selection }">
|
||||||
<div class="card mb-3 shadow">
|
<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>
|
<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">
|
<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 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" />
|
<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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -35,7 +34,7 @@
|
|||||||
<span>{{selection.title.join(' - ')}}</span>
|
<span>{{selection.title.join(' - ')}}</span>
|
||||||
<span class="close" @click="selection = null">❌</span>
|
<span class="close" @click="selection = null">❌</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail card-body" v-html="selection.highlight || selection.detail" />
|
<div class="detail card-body" v-html="selection.highlight || selection.detail" ref="detail" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -56,6 +55,12 @@
|
|||||||
top: 3.65rem;
|
top: 3.65rem;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
div.filter input.form-check-input {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
div.selector {
|
div.selector {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 1.15rem;
|
top: 1.15rem;
|
||||||
@ -69,16 +74,23 @@
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
border-top: 1px solid #dee2e6;
|
border-top: 1px solid #dee2e6;
|
||||||
padding: 0.25rem 0.75rem;
|
padding: 0.25rem 0.75rem;
|
||||||
|
scroll-margin-top: 10.5rem;
|
||||||
}
|
}
|
||||||
li.record:nth-child(even) {
|
li.record:nth-child(even) {
|
||||||
background-color: rgba(0, 0, 0, 0.05);
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
|
ul.scroller.list-skinny li.record {
|
||||||
|
scroll-margin-top: 0;
|
||||||
|
}
|
||||||
li.record.active {
|
li.record.active {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background-color: #0d6efd;
|
background-color: #0d6efd;
|
||||||
}
|
}
|
||||||
li.bottom {
|
li.bottom {
|
||||||
padding: 0;
|
overflow-anchor: none;
|
||||||
|
}
|
||||||
|
li.bottom button {
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
span.badge.emblem:empty {
|
span.badge.emblem:empty {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@ -141,6 +153,7 @@
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
div.detail {
|
div.detail {
|
||||||
|
scroll-margin-top: calc(3.6875rem + 2.5625rem + 25vh);
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
@ -153,13 +166,14 @@
|
|||||||
}
|
}
|
||||||
div.detail {
|
div.detail {
|
||||||
max-height: 75vh;
|
max-height: 75vh;
|
||||||
|
scroll-margin-top: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { uniq, debounce, strftime_vista } from './util.mjs';
|
import { flow, uniq, debounce, strftime_vista, strptime_vista } from './util.mjs';
|
||||||
|
|
||||||
import Subtitle from './Subtitle.vue';
|
import Subtitle from './Subtitle.vue';
|
||||||
import DateRangePicker from './DateRangePicker.vue';
|
import DateRangePicker from './DateRangePicker.vue';
|
||||||
@ -167,26 +181,55 @@
|
|||||||
const SZ_WINDOW = 100;
|
const SZ_WINDOW = 100;
|
||||||
const SZ_RANGE = 40000;
|
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 = () => [
|
const create_reports = () => [
|
||||||
{
|
{
|
||||||
name: 'Notes',
|
name: 'Notes',
|
||||||
rpt_id: 'OR_PN:PROGRESS NOTES~TIUPRG;ORDV04;15;',
|
rpt_id: 'OR_PN:PROGRESS NOTES~TIUPRG;ORDV04;15;',
|
||||||
map(x) {
|
map: flow(f_parse_columns, function(x) {
|
||||||
var time = new Date(x[3]);
|
var time = new Date(x[3]);
|
||||||
return {
|
return {
|
||||||
time,
|
time,
|
||||||
id: 'OR_PN:' + time.getTime() + ':' + x[2],
|
id: 'OR_PN:' + time.getTime() + ':' + x[2],
|
||||||
emblem: 'emblem-notes',
|
emblem: 'emblem-notes',
|
||||||
title: [x[4], x[5], '#' + x[2]],
|
title: [x[4], x[5], '#' + x[2]],
|
||||||
detail: escape_html(x[6])
|
detail: escape_html(collapse_lines(x[6]))
|
||||||
};
|
};
|
||||||
},
|
}),
|
||||||
|
loader: reportloader_chunk,
|
||||||
enabled: true
|
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',
|
name: 'Labs',
|
||||||
rpt_id: 'OR_OV_R:LAB OVERVIEW (COLLECTED SPECIMENS)~OV;ORDV02C;32;',
|
rpt_id: 'OR_OV_R:LAB OVERVIEW (COLLECTED SPECIMENS)~OV;ORDV02C;32;',
|
||||||
map(x) {
|
map: flow(f_parse_columns, function(x) {
|
||||||
var time = new Date(x[2]);
|
var time = new Date(x[2]);
|
||||||
return {
|
return {
|
||||||
time,
|
time,
|
||||||
@ -195,12 +238,13 @@
|
|||||||
title: [x[3], x[6], x[8], x[10], '#' + x[12]],
|
title: [x[3], x[6], x[8], x[10], '#' + x[12]],
|
||||||
detail: escape_html(x[15])
|
detail: escape_html(x[15])
|
||||||
};
|
};
|
||||||
}
|
}),
|
||||||
|
loader: reportloader_chunk
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Microbiology',
|
name: 'Microbiology',
|
||||||
rpt_id: 'OR_MIC:MICROBIOLOGY~MI;ORDV05;38;',
|
rpt_id: 'OR_MIC:MICROBIOLOGY~MI;ORDV05;38;',
|
||||||
map(x) {
|
map: flow(f_parse_columns, function(x) {
|
||||||
var time = new Date(x[2]);
|
var time = new Date(x[2]);
|
||||||
return {
|
return {
|
||||||
time,
|
time,
|
||||||
@ -209,27 +253,29 @@
|
|||||||
title: [x[3], x[4], x[5], '#' + x[6]],
|
title: [x[3], x[4], x[5], '#' + x[6]],
|
||||||
detail: escape_html(x[7])
|
detail: escape_html(x[7])
|
||||||
};
|
};
|
||||||
}
|
}),
|
||||||
|
loader: reportloader_chunk
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Blood Bank',
|
name: 'Blood Bank',
|
||||||
rpt_id: '2:BLOOD BANK REPORT~;;0',
|
rpt_id: '2:BLOOD BANK REPORT~;;0',
|
||||||
singleton: true,
|
singleton: true,
|
||||||
map(x) {
|
map(x) {
|
||||||
var time = new Date();
|
var now = new Date();
|
||||||
return {
|
return {
|
||||||
time,
|
time: new Date(now.getFullYear(), now.getMonth(), now.getDate()),
|
||||||
id: 'BB',
|
id: 'BB',
|
||||||
emblem: 'emblem-bloodbank',
|
emblem: 'emblem-bloodbank',
|
||||||
title: ['BLOOD BANK'],
|
title: ['BLOOD BANK'],
|
||||||
detail: escape_html(x)
|
detail: escape_html(x.join('\n'))
|
||||||
};
|
};
|
||||||
}
|
},
|
||||||
|
loader: reportloader_full
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Pathology',
|
name: 'Pathology',
|
||||||
rpt_id: 'OR_APR:ANATOMIC PATHOLOGY~SP;ORDV02A;0;',
|
rpt_id: 'OR_APR:ANATOMIC PATHOLOGY~SP;ORDV02A;0;',
|
||||||
map(x) {
|
map: flow(f_parse_columns, function(x) {
|
||||||
var time = new Date(x[2]);
|
var time = new Date(x[2]);
|
||||||
return {
|
return {
|
||||||
time,
|
time,
|
||||||
@ -238,12 +284,13 @@
|
|||||||
title: [x[3], '#' + x[4]],
|
title: [x[3], '#' + x[4]],
|
||||||
detail: escape_html(x[5])
|
detail: escape_html(x[5])
|
||||||
};
|
};
|
||||||
}
|
}),
|
||||||
|
loader: reportloader_full
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Radiology',
|
name: 'Radiology',
|
||||||
rpt_id: 'OR_R18:IMAGING~RIM;ORDV08;0;',
|
rpt_id: 'OR_R18:IMAGING~RIM;ORDV08;0;',
|
||||||
map(x) {
|
map: flow(f_parse_columns, function(x) {
|
||||||
var time = new Date(x[2]);
|
var time = new Date(x[2]);
|
||||||
return {
|
return {
|
||||||
time,
|
time,
|
||||||
@ -252,10 +299,138 @@
|
|||||||
title: [x[3], x[4], x[5], '#' + x[9]],
|
title: [x[3], x[4], x[5], '#' + x[9]],
|
||||||
detail: escape_html(x[6])
|
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');
|
const escape_div = document.createElement('div');
|
||||||
function escape_html(s) {
|
function escape_html(s) {
|
||||||
escape_div.textContent = s;
|
escape_div.textContent = s;
|
||||||
@ -286,12 +461,15 @@
|
|||||||
is_loading: false,
|
is_loading: false,
|
||||||
date_end: now,
|
date_end: now,
|
||||||
date_begin: now,
|
date_begin: now,
|
||||||
|
date_next: null,
|
||||||
query: '',
|
query: '',
|
||||||
x_query: '',
|
x_query: '',
|
||||||
unify: false,
|
|
||||||
reports: create_reports(),
|
reports: create_reports(),
|
||||||
|
loaders: {},
|
||||||
resultsets: {},
|
resultsets: {},
|
||||||
selection: null
|
selection: null,
|
||||||
|
observer_scroller: null,
|
||||||
|
observer_viewport: null
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@ -328,6 +506,29 @@
|
|||||||
for(var i = 0; i < value.length; ++i) if(value[i].id == id) return this.selection = value[i];
|
for(var i = 0; i < value.length; ++i) if(value[i].id == id) return this.selection = value[i];
|
||||||
this.selection = null;
|
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: {
|
methods: {
|
||||||
@ -338,53 +539,33 @@
|
|||||||
timestring(date) {
|
timestring(date) {
|
||||||
return date.toLocaleTimeString('en-GB').substring(0, 5);
|
return date.toLocaleTimeString('en-GB').substring(0, 5);
|
||||||
},
|
},
|
||||||
toggle(report) {
|
enable(report) {
|
||||||
if(report.enabled) report.enabled = false;
|
if(!report.enabled) {
|
||||||
else if(this.unify) report.enabled = true;
|
|
||||||
else {
|
|
||||||
var reports = this.reports;
|
var reports = this.reports;
|
||||||
for(var i = reports.length - 1; i >= 0; --i) reports[i].enabled = false;
|
for(var i = reports.length - 1; i >= 0; --i) reports[i].enabled = false;
|
||||||
report.enabled = true;
|
report.enabled = true;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async load_more() {
|
async loader_setup() {
|
||||||
try {
|
try {
|
||||||
this.is_loading = true;
|
this.is_loading = true;
|
||||||
if((this.client) && (this.patient_dfn)) {
|
if((this.client) && (this.patient_dfn)) {
|
||||||
if(this.dfn != this.patient_dfn) {
|
if(this.dfn != this.patient_dfn) {
|
||||||
this.dfn = this.patient_dfn;
|
this.dfn = this.patient_dfn;
|
||||||
|
this.loaders = {};
|
||||||
this.resultsets = {};
|
this.resultsets = {};
|
||||||
}
|
}
|
||||||
var dfn = this.patient_dfn, resultsets = this.resultsets, resultset, reports = this.reports, report, alpha = strftime_vista(this.date_begin).toFixed(4), omega = strftime_vista(this.date_end).toFixed(4);
|
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) && (!((resultset = resultsets[i]) && (alpha == resultset.alpha) && (omega == resultset.omega)))) {
|
for(var i = 0; i < reports.length; ++i) if((report = reports[i]).enabled) {
|
||||||
var data = [], batch, idmap = {}, omega0 = omega;
|
if((!loaders[i]) || (loaders[i].omega != omega)) loaders[i] = report.loader(dfn, report.rpt_id, report.map, omega);
|
||||||
do {
|
resultsets[i] = await loaders[i](this.client, alpha);
|
||||||
batch = await this.client.ORWRP_REPORT_TEXT(dfn, report.rpt_id + (report.rpt_id.endsWith(';') ? SZ_WINDOW : ''), '', SZ_RANGE, '', alpha, omega0);
|
if(resultsets[i].next) next.push(resultsets[i].next);
|
||||||
if(report.singleton) data = [report.map(batch[0].join('\n'))];
|
|
||||||
else if((batch = batch.map(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');
|
|
||||||
res = report.map(res);
|
|
||||||
return idmap[res.id] ? console.warn('Duplicate record', res) : res;
|
|
||||||
}).filter(x => x)).length > 0) {
|
|
||||||
Array.prototype.push.apply(data, batch.sort((a, b) => b.time - a.time));
|
|
||||||
batch.map(x => x.id).reduce((acc, val) => (acc[val] = true, acc), idmap);
|
|
||||||
omega0 = strftime_vista(data[data.length - 1].time);
|
|
||||||
};
|
|
||||||
} while(batch.length >= SZ_WINDOW);
|
|
||||||
data.alpha = alpha;
|
|
||||||
data.omega = omega;
|
|
||||||
resultsets[i] = data;
|
|
||||||
}
|
}
|
||||||
|
this.date_next = next.length > 0 ? strptime_vista(Math.floor(Math.max(...next))) : null;
|
||||||
|
if(!alpha) this.date_begin = this.date_next;
|
||||||
} else {
|
} else {
|
||||||
this.dfn = null;
|
this.dfn = null;
|
||||||
|
this.loaders = {};
|
||||||
this.resultsets = {};
|
this.resultsets = {};
|
||||||
}
|
}
|
||||||
} catch(ex) {
|
} catch(ex) {
|
||||||
@ -397,7 +578,7 @@
|
|||||||
created() {
|
created() {
|
||||||
this.$watch(
|
this.$watch(
|
||||||
() => (this.client, this.patient_dfn, this.reports.map(x => x.enabled), this.date_begin, this.date_end, {}),
|
() => (this.client, this.patient_dfn, this.reports.map(x => x.enabled), this.date_begin, this.date_end, {}),
|
||||||
debounce(() => this.load_more(), 500),
|
debounce(() => this.loader_setup(), 500),
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
this.$watch(
|
this.$watch(
|
||||||
@ -405,6 +586,16 @@
|
|||||||
debounce(value => this.query = value, 500),
|
debounce(value => this.query = value, 500),
|
||||||
{ immediate: true }
|
{ 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>
|
</script>
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
<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>
|
||||||
<router-link to="/settings">Select clinics<template v-if="selection.length > 0"> ({{selection.length}})</template></router-link>
|
<router-link to="/settings">Select clinics<template v-if="selection.length > 0"> ({{selection.length}})</template></router-link>
|
||||||
<DateRangePicker range="1D" direction="+1" v-model:date="date" v-model:date_end="date_end" />
|
<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="date" :date_end="new Date(date_end.getTime() - 1)" />
|
<ViewSchedule :client="client" :selection="selection" :date_begin="date" :date_end="new Date(date_end.getTime() - 1)" />
|
||||||
@ -21,6 +21,12 @@
|
|||||||
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: {
|
||||||
Subtitle, DateRangePicker, ViewSchedule
|
Subtitle, DateRangePicker, ViewSchedule
|
||||||
@ -31,11 +37,31 @@
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
date: dateonly(new Date()),
|
date: dateonly(new Date()),
|
||||||
date_end: dateonly(new Date())
|
date_end: dateonly(new Date()),
|
||||||
|
range: '1D'
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
selection() { return (this.client) && (this.client.remotestate.resources) ? (this.client.remotestate.resources.split(',').filter(x => x) || []) : [] }
|
selection() { return (this.client) && (this.client.remotestate.resources) ? (this.client.remotestate.resources.split(',').filter(x => x) || []) : [] }
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
'$route.params.from': {
|
||||||
|
handler(value) {
|
||||||
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
@ -177,7 +177,7 @@
|
|||||||
if((satisfied) && (updated)) {
|
if((satisfied) && (updated)) {
|
||||||
item = calculation.calc(...calculation.deps.map(x => history[x].value), history[calculation.name] && history[calculation.name].value);
|
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
|
if((item !== undefined) && (item !== null) && (item === item) && (item != 'NaN')) { // item === item if not NaN
|
||||||
results.push(history[calculation.name] = update[calculation.name] = Object.assign({ time: group.key, value: item }, calculation));
|
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';
|
if((item.hasOwnProperty('rangeL')) && (item.value < item.rangeL)) item.flag = 'L';
|
||||||
else if((item.hasOwnProperty('rangeH')) && (item.value > item.rangeH)) item.flag = 'H';
|
else if((item.hasOwnProperty('rangeH')) && (item.value > item.rangeH)) item.flag = 'H';
|
||||||
}
|
}
|
||||||
@ -226,7 +226,7 @@
|
|||||||
},
|
},
|
||||||
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: {
|
||||||
|
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>
|
@ -42,14 +42,14 @@
|
|||||||
client: Object,
|
client: Object,
|
||||||
dfn: String,
|
dfn: String,
|
||||||
label: String,
|
label: String,
|
||||||
modelValue: String
|
modelValue: Object
|
||||||
},
|
},
|
||||||
emits: {
|
emits: {
|
||||||
'update:modelValue': Object
|
'update:modelValue': Object
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
view_new: true,
|
view_new: false,
|
||||||
query: '',
|
query: '',
|
||||||
visits_date_begin: now,
|
visits_date_begin: now,
|
||||||
visits_date_end: now,
|
visits_date_end: now,
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<table v-if="resultset.length > 0" class="table table-striped">
|
<table class="table table-striped">
|
||||||
<tbody>
|
<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 }">
|
<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}}</td>
|
||||||
<td>#{{item.location_ien}}</td>
|
<td>#{{item.location_ien}}</td>
|
||||||
@ -8,6 +8,9 @@
|
|||||||
<td style="text-align: right;">{{item.datestr}} {{item.timestr}}</td>
|
<td style="text-align: right;">{{item.datestr}} {{item.timestr}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
<tfoot v-else>
|
||||||
|
<tr><td style="text-align: center;">No encounters in range</td></tr>
|
||||||
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -40,7 +40,7 @@
|
|||||||
methods: {
|
methods: {
|
||||||
async submit(payload) {
|
async submit(payload) {
|
||||||
if((this.ien) && (this.dfn) && (payload)) {
|
if((this.ien) && (this.dfn) && (payload)) {
|
||||||
var user_ien = (await this.client.userinfo()).result[0];
|
var user_ien = (await this.client.authinfo()).duz;
|
||||||
var dgrp = await this.client.ORWDX_DGRP(this.dlgname.BaseDialogName);
|
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);
|
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);
|
console.log(res);
|
||||||
@ -52,8 +52,8 @@
|
|||||||
() => (this.client, this.ien, {}),
|
() => (this.client, this.ien, {}),
|
||||||
async () => {
|
async () => {
|
||||||
if((this.client) && (this.ien)) {
|
if((this.client) && (this.ien)) {
|
||||||
var userinfo = await this.client.userinfo();
|
var authinfo = await this.client.authinfo();
|
||||||
var user_ien = userinfo && userinfo.result ? userinfo.result[0] : '';
|
var user_ien = authinfo && authinfo.success ? authinfo.duz : '';
|
||||||
this.dlgname = await this.client.ORWDXM_DLGNAME(this.ien);
|
this.dlgname = await this.client.ORWDXM_DLGNAME(this.ien);
|
||||||
if(this.dlgname.BaseDialogIEN != this.ien) console.warn('IEN =', this.ien, '|', 'BaseDialogIEN =', this.dlgname.BaseDialogIEN);
|
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);
|
this.dlgdef = await this.client.ORWDX_DLGDEF(this.dlgname.BaseDialogName);
|
||||||
|
@ -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() {
|
||||||
|
@ -7,7 +7,9 @@
|
|||||||
<tr v-for="week in resultset">
|
<tr v-for="week in resultset">
|
||||||
<td v-for="day in [0, 1, 2, 3, 4, 5, 6]" class="datebox">
|
<td v-for="day in [0, 1, 2, 3, 4, 5, 6]" class="datebox">
|
||||||
<template v-if="week.values[day]">
|
<template v-if="week.values[day]">
|
||||||
<div class="datebox" :style="{ backgroundColor: resultset.max > 0 ? 'rgba(220, 53, 69, ' + week.values[day].length/resultset.max + ')' : null }"><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 :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]">
|
<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-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>
|
<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>
|
||||||
@ -29,8 +31,12 @@
|
|||||||
border: 1px solid #dee2e6;
|
border: 1px solid #dee2e6;
|
||||||
}
|
}
|
||||||
div.datebox {
|
div.datebox {
|
||||||
|
cursor: default;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
div.datebox.linked {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
div.vacancy {
|
div.vacancy {
|
||||||
width: 2em;
|
width: 2em;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
|
@ -3,22 +3,25 @@
|
|||||||
<div v-if="age == Infinity" class="alert alert-danger">❌ update error</div>
|
<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>
|
<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>
|
</template>
|
||||||
<div v-if="filter_array.length > 0"><span class="tag badge bg-primary" @click="filter = {}">CLEAR {{filter_array.length}} TAG{{filter_array.length > 1 ? 'S' : ''}}</span><span v-for="key in filter_array" class="tag badge" :style="{ backgroundColor: strHashHSL(key, '50%') }" @click="delete filter[key]">❌ {{key.toUpperCase()}}</span></div>
|
<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>
|
<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 style="width: 7rem;">Time</th><th>Clinic</th><th>Patient</th><th>Note</th><th style="width: 16rem;">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 class="striped">
|
<tbody class="striped">
|
||||||
<tr v-for="row in appointments" v-show="(filter_array.length < 1) || (filter_conj(gettags(row)))" :class="{ voided: (row.CANCELLED != '0') || (row.NOSHOW != '0') }" :style="{ backgroundColor: strHashHSL(row.RESOURCENAME, '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 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 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 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-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-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-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-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 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 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 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>{{row.RESOURCENAME}}</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></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 gettags(row)" class="tag badge" :style="{ backgroundColor: strHashHSL(key, '50%') }" @click="filter[key] = true">{{value}}</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>
|
<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>
|
||||||
@ -89,6 +92,29 @@
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
tag_map() {
|
||||||
|
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() {
|
filter_array() {
|
||||||
return Object.keys(this.filter).sort();
|
return Object.keys(this.filter).sort();
|
||||||
},
|
},
|
||||||
@ -101,21 +127,6 @@
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
strHashHSL,
|
strHashHSL,
|
||||||
gettags(row) {
|
|
||||||
var res = {}, re, matches;
|
|
||||||
if((row.RESOURCENAME) && (matches = row.RESOURCENAME.replace(/\W+/g, '-').replace(/^-+|-+$/g, ''))) res[matches.toLowerCase()] = matches;
|
|
||||||
if(row.WALKIN != '0') res['walkin'] = 'WALKIN';
|
|
||||||
if((row.CANCELLED != '0') || (row.NOSHOW != '0')) res['inactive'] = 'INACTIVE';
|
|
||||||
else res['active'] = 'ACTIVE';
|
|
||||||
if(row.NOTE) {
|
|
||||||
re = /#([0-9a-z][\w-]*)/gi;
|
|
||||||
while(matches = re.exec(row.NOTE)) res[matches[1].toLowerCase()] = matches[1];
|
|
||||||
re = /Dr[\.\s]*\b([a-z][\w-]*)/gi;
|
|
||||||
while(matches = re.exec(row.NOTE)) res[matches[1].toLowerCase()] = matches[1];
|
|
||||||
}
|
|
||||||
if((matches = this.practitioner[row.PATIENTNAME]) && (matches = matches.replace(/\W+/g, '-').replace(/^-+|-+$/g, ''))) res[matches.toLowerCase()] = matches.toUpperCase();
|
|
||||||
return res;
|
|
||||||
},
|
|
||||||
filter_conj(tags) {
|
filter_conj(tags) {
|
||||||
var filter_array = this.filter_array;
|
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;
|
for(var i = this.filter_array.length - 1; i >= 0; --i) if(!tags[this.filter_array[i]]) return false;
|
||||||
|
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>
|
@ -22,7 +22,9 @@
|
|||||||
{ 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: '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: '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: '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: '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 = [
|
||||||
@ -30,9 +32,9 @@
|
|||||||
{ name: 'CBC', value: ['HGB', 'MCV', 'RETICYLOCYTE#', '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', 'CrCl'], 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 }
|
{ 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 }
|
||||||
];
|
];
|
||||||
@ -47,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;
|
||||||
});
|
});
|
||||||
|
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>
|
@ -53,15 +53,15 @@ function lab_parse1default(data) {
|
|||||||
else x.comment = [line.substring(12)];
|
else x.comment = [line.substring(12)];
|
||||||
} else console.log('DANGLING:', line);
|
} else console.log('DANGLING:', line);
|
||||||
} else if(m = line.match(/^\b(?<name>.*?)\s{2,}(?<value>.*?)(?: (?<flag>L\*|L|H\*|H))?\s+(?:(?<unit>.{10}) (?<range>.*?)(?: \[(?<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|not detected|collected - specimen in lab|test not performed))(?: (?<flag>L\*|L|H\*|H))?\s+(?:(?<unit>.{10}) (?<range>.*?)(?: \[(?<site>\d+)\])?)?$/i)) m = x;
|
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|not detected|collected - specimen in lab|test not performed))\s*$/i))) {
|
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.name = m.groups.name;
|
||||||
x.value = m.groups.value;
|
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;
|
||||||
} else if(m = line.match(/^\b(?<name>.*?)(?<value>(?:[\d\.]+|positive|negative|reactive|nonreactive|not detected|collected - specimen in lab|test not performed))\s*$/i)) {
|
} 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)) {
|
||||||
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(' [')) {
|
||||||
@ -95,7 +95,7 @@ function lab_parse1default(data) {
|
|||||||
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)
|
||||||
@ -107,7 +107,7 @@ function lab_parse1default(data) {
|
|||||||
value: x = +results.EOSINO.value,
|
value: x = +results.EOSINO.value,
|
||||||
flag: (x < 0 ? 'L' : x > 10 ? 'H' : undefined)
|
flag: (x < 0 ? 'L' : x > 10 ? 'H' : undefined)
|
||||||
});
|
});
|
||||||
results.push(results['EOSINOPHIL#'] = {
|
if(results.WBC) results.push(results['EOSINOPHIL#'] = {
|
||||||
name: 'EOSINOPHIL#', unit: 'K/cmm', range: '0.0 - 0.7',
|
name: 'EOSINOPHIL#', unit: 'K/cmm', range: '0.0 - 0.7',
|
||||||
value: +(x = 0.01*x*results.WBC.value).toFixed(3),
|
value: +(x = 0.01*x*results.WBC.value).toFixed(3),
|
||||||
flag: (x < 0 ? 'L' : x > 0.7 ? 'H' : undefined)
|
flag: (x < 0 ? 'L' : x > 0.7 ? 'H' : undefined)
|
||||||
@ -119,7 +119,7 @@ function lab_parse1default(data) {
|
|||||||
value: x = +results.BASO.value,
|
value: x = +results.BASO.value,
|
||||||
flag: (x < 0 ? 'L' : x > 2 ? 'H' : undefined)
|
flag: (x < 0 ? 'L' : x > 2 ? 'H' : undefined)
|
||||||
});
|
});
|
||||||
results.push(results['BASOPHIL#'] = {
|
if(results.WBC) results.push(results['BASOPHIL#'] = {
|
||||||
name: 'BASOPHIL#', unit: 'K/cmm', range: '0.0 - 0.2',
|
name: 'BASOPHIL#', unit: 'K/cmm', range: '0.0 - 0.2',
|
||||||
value: +(x = 0.01*x*results.WBC.value).toFixed(3),
|
value: +(x = 0.01*x*results.WBC.value).toFixed(3),
|
||||||
flag: (x < 0 ? 'L' : x > 0.2 ? 'H' : undefined)
|
flag: (x < 0 ? 'L' : x > 0.2 ? 'H' : undefined)
|
||||||
@ -131,7 +131,7 @@ function lab_parse1default(data) {
|
|||||||
value: x = +results.MONOS.value,
|
value: x = +results.MONOS.value,
|
||||||
flag: (x < 1.7 ? 'L' : x > 9.3 ? 'H' : undefined)
|
flag: (x < 1.7 ? 'L' : x > 9.3 ? 'H' : undefined)
|
||||||
});
|
});
|
||||||
results.push(results['MONOCYTE#'] = {
|
if(results.WBC) results.push(results['MONOCYTE#'] = {
|
||||||
name: 'MONOCYTE#', unit: 'K/cmm', range: '0.11 - 0.59',
|
name: 'MONOCYTE#', unit: 'K/cmm', range: '0.11 - 0.59',
|
||||||
value: +(x = 0.01*x*results.WBC.value).toFixed(3),
|
value: +(x = 0.01*x*results.WBC.value).toFixed(3),
|
||||||
flag: (x < 0.11 ? 'L' : x > 0.59 ? 'H' : undefined)
|
flag: (x < 0.11 ? 'L' : x > 0.59 ? 'H' : undefined)
|
||||||
@ -143,7 +143,7 @@ function lab_parse1default(data) {
|
|||||||
value: x = (results.hasOwnProperty('LYMPHS') ? +results.LYMPHS.value : 0) + (results.hasOwnProperty('ATYPICAL LYMPHOCYTES') ? +results['ATYPICAL LYMPHOCYTES'].value : 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)
|
flag: (x < 15 ? 'L' : x > 41 ? 'H' : undefined)
|
||||||
});
|
});
|
||||||
results.push(results['LYMPHOCYTE#'] = {
|
if(results.WBC) results.push(results['LYMPHOCYTE#'] = {
|
||||||
name: 'LYMPHOCYTE#', unit: 'K/cmm', range: '1.2 - 3.4',
|
name: 'LYMPHOCYTE#', unit: 'K/cmm', range: '1.2 - 3.4',
|
||||||
value: +(x = 0.01*x*results.WBC.value).toFixed(3),
|
value: +(x = 0.01*x*results.WBC.value).toFixed(3),
|
||||||
flag: (x < 1.2 ? 'L' : x > 3.4 ? 'H' : undefined)
|
flag: (x < 1.2 ? 'L' : x > 3.4 ? 'H' : undefined)
|
||||||
@ -195,12 +195,27 @@ export function measurement_parse(data) {
|
|||||||
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 + 2);
|
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);
|
||||||
@ -210,6 +225,7 @@ 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);
|
||||||
|
@ -217,7 +217,7 @@ export function TplFS(client, parent, desc) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
TplFS.fromUser = async function(client, user_ien=null) {
|
TplFS.fromUser = async function(client, user_ien=null) {
|
||||||
if(!user_ien) user_ien = (await client.userinfo()).result[0];
|
if(!user_ien) user_ien = (await client.authinfo()).duz;
|
||||||
return new TplFS(client, null, (await client.TIU_TEMPLATE_GETPROOT(user_ien))[0]);
|
return new TplFS(client, null, (await client.TIU_TEMPLATE_GETPROOT(user_ien))[0]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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, close, call, serverinfo, userinfo, authenticate
|
connect, close, call, serverinfo, authinfo, authenticate
|
||||||
};
|
};
|
||||||
|
@ -27,6 +27,7 @@ export const d_unwrap = data => {
|
|||||||
if(data.error) throw new RPCError(data.error.type, ...data.error.args);
|
if(data.error) throw new RPCError(data.error.type, ...data.error.args);
|
||||||
if(data.ts) try {
|
if(data.ts) try {
|
||||||
data.result._ts = data.ts;
|
data.result._ts = data.ts;
|
||||||
|
if(data.cached) data.result._cached = data.cached;
|
||||||
} catch(ex) {}
|
} catch(ex) {}
|
||||||
return data.result;
|
return data.result;
|
||||||
};
|
};
|
||||||
@ -47,6 +48,39 @@ export const d_parse_boolean = data => data != '0';
|
|||||||
export const d_parse_text = data => data !== '' ? data.join('\r\n') : data;
|
export const d_parse_text = data => data !== '' ? data.join('\r\n') : data;
|
||||||
export const d_parse_array = data => data !== '' ? 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) {
|
export const d_parse_orderdialogs = (data, columns=['IEN', 'windowFormId', 'displayGroupId', 'type', 'displayText']) => data.map(function(row) {
|
||||||
row = row.split('^');
|
row = row.split('^');
|
||||||
row = [...row[0].split(';'), row[1]];
|
row = [...row[0].split(';'), row[1]];
|
||||||
@ -154,6 +188,7 @@ export const d_parse_orderoptions_meddose = data => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
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 => {
|
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 meta = row.meta.split(';');
|
||||||
var xqaid = row.meta_xqaid = meta[0];
|
var xqaid = row.meta_xqaid = meta[0];
|
||||||
row.meta_duz = meta[1];
|
row.meta_duz = meta[1];
|
||||||
@ -167,23 +202,38 @@ export const d_parse_notifications_fastuser = data => d_split(data, '^', 'info',
|
|||||||
row.meta_source = 'TIU';
|
row.meta_source = 'TIU';
|
||||||
row.meta_ien = xqaid.substring(3); // document IEN (DA)
|
row.meta_ien = xqaid.substring(3); // document IEN (DA)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return row;
|
return row;
|
||||||
});
|
});
|
||||||
|
|
||||||
export const d_parse_multireport = data => {
|
export const d_parse_multireport = data => {
|
||||||
if(data.length < 1) return [];
|
if(data.length < 1) return [];
|
||||||
var brk, max = 0, grp;
|
var brk, max = 0, grp, _ts = data._ts;
|
||||||
for(var i = 0; i < data.length; ++i) {
|
for(var i = 0; i < data.length; ++i) {
|
||||||
brk = (grp = data[i]).indexOf('^');
|
brk = (grp = data[i]).indexOf('^');
|
||||||
if(brk >= 0) {
|
if(brk >= 0) {
|
||||||
grp = +grp.substring(0, brk);
|
grp = +grp.substring(0, brk);
|
||||||
if(grp >= max) max = grp;
|
if(grp >= max) max = grp;
|
||||||
else break;
|
else break;
|
||||||
} else return [data];
|
} else return (data = [data], data._ts = _ts, data);
|
||||||
}
|
}
|
||||||
var res = [], data = data.slice(), max = max + '^', grp = x => x.startsWith(max);
|
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));
|
while(((brk = data.findIndex(grp)) >= 0) || (brk = data.length)) res.push(data.splice(0, brk + 1));
|
||||||
return res;
|
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 => {
|
export const d_parse_tiudocumentlist = data => d_split(data, '^', 'IEN', 'title', 'time', 'patient', 'author', 'location', 'status', 'visit').map(row => {
|
||||||
@ -239,11 +289,11 @@ 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);
|
||||||
|
|
||||||
if(!localstate.encfs) localstate.encfs = tplfs_randpassword();
|
if(!localstate.encfs) localstate.encfs = tplfs_randpassword();
|
||||||
this.tplfs = async () => this._tplfs ? this._tplfs : (this._tplfs = await TplFS.fromUser(this, (await this.userinfo()).result[0]));
|
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.encfs = async () => this._encfs ? this._encfs : (this._encfs = await EncFS.fromPassword(await this.tplfs(), localstate.encfs));
|
||||||
|
|
||||||
this.remotestate = reactive({});
|
this.remotestate = reactive({});
|
||||||
@ -291,6 +341,7 @@ export function Client(cid, secret) {
|
|||||||
this.ORWLRR_INTERIM_RESULTS = async (...args) => lab_reparse_results(await this.ORWLRR_INTERIM(...args));
|
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 = 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_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.ORWORDG_REVSTS = memoized(aflow(() => this.callctx(['OR CPRS GUI CHART'], 'ORWORDG_REVSTS'), d_log, d_unwrap, f_split('^', 'ien', 'name', 'parent', 'has_children')));
|
||||||
@ -307,11 +358,33 @@ export function Client(cid, secret) {
|
|||||||
this.TIU_TEMPLATE_LOCK = aflow((...args) => this.call({ method: 'TIU_TEMPLATE_LOCK', context: ['OR CPRS GUI CHART'], ttl: 0, stale: false }, ...args), d_log, d_unwrap);
|
this.TIU_TEMPLATE_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_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 = 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 = 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.ORWCV_VST = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWCV_VST', ...args), d_log, d_unwrap, f_split('^', 'apptinfo', 'datetime', 'location', 'status')));
|
this.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.ORWU1_NEWLOC = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWU1_NEWLOC', ...args), d_log, d_unwrap, f_split('^', 'IEN', 'name')));
|
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_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_DGRP = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDX_DGRP', ...args), d_log, d_unwrap));
|
||||||
|
37
main.py
37
main.py
@ -1,8 +1,10 @@
|
|||||||
#!/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
|
||||||
@ -39,7 +41,7 @@ class CacheProxyRPC(util.CacheProxy):
|
|||||||
self._cache(('SDEC_RESOURCE', 'ORWU1_NEWLOC', 'ORWLRR_ALLTESTS_ALL', 'ORWORDG_ALLTREE', 'ORWORDG_REVSTS', 'ORWDX_DGNM', 'ORWDX_ORDITM'), 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):
|
def jsonify_result(value, id=None):
|
||||||
return jsonify({ 'result': value._base, 'error': None, 'id': request.json.get('id'), 'ts': value._ts } if isinstance(value, util.Cached) else { 'result': value, 'error': None, 'id': request.json.get('id') })
|
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):
|
def jsonify_error(ex, id=None):
|
||||||
return jsonify({ 'result': None, 'error': { 'type': ex.__class__.__name__, 'args': ex.args }, 'id': id })
|
return jsonify({ 'result': None, 'error': { 'type': ex.__class__.__name__, 'args': ex.args }, 'id': id })
|
||||||
@ -91,11 +93,11 @@ def application():
|
|||||||
logger.exception(request.url)
|
logger.exception(request.url)
|
||||||
return jsonify_error(ex, 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, 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_error(ex, id=request.json.get('id'))
|
return jsonify_error(ex, id=request.json.get('id'))
|
||||||
@ -110,8 +112,8 @@ def application():
|
|||||||
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, 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, id=request.json.get('id'))
|
return jsonify_result(user, id=request.json.get('id'))
|
||||||
@ -159,6 +161,29 @@ def application():
|
|||||||
logger.exception(request.url)
|
logger.exception(request.url)
|
||||||
return jsonify_error(ex, 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):
|
||||||
return send_from_directory('./htdocs', path if '.' in path.rsplit('/', 1)[-1] else 'index.html')
|
return send_from_directory('./htdocs', path if '.' in path.rsplit('/', 1)[-1] else 'index.html')
|
||||||
|
20
rpc.py
20
rpc.py
@ -112,7 +112,7 @@ class ClientSync(object):
|
|||||||
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 = { 'host': host, 'port': port }
|
self._server = { 'host': host, 'port': port }
|
||||||
self._user = None
|
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)
|
||||||
@ -144,13 +144,11 @@ class ClientSync(object):
|
|||||||
return res
|
return res
|
||||||
def authenticate(self, identity: str, *, context=('XUS SIGNON',)):
|
def authenticate(self, identity: str, *, context=('XUS SIGNON',)):
|
||||||
self._server.update(RecordServerInfo(*self('XUS SIGNON SETUP', '', '1', context=context))._asdict())
|
self._server.update(RecordServerInfo(*self('XUS SIGNON SETUP', '', '1', context=context))._asdict())
|
||||||
if identity.startswith('<?xml version="1.0" encoding="UTF-8"?>'):
|
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))
|
res = self('XUS ESSO VALIDATE', RPCType(tuple(identity[i:i+200] for i in range(0, len(identity), 200)), RPCType.GLOBAL))
|
||||||
else:
|
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
|
||||||
@ -188,7 +186,7 @@ class ClientAsync(object):
|
|||||||
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 = { 'host': host, 'port': port, 'info': None }
|
self._server = { 'host': host, 'port': port, 'info': None }
|
||||||
self._user = 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)
|
||||||
@ -225,13 +223,11 @@ class ClientAsync(object):
|
|||||||
return res
|
return res
|
||||||
async def authenticate(self, identity: str, *, context=('XUS SIGNON',)):
|
async def authenticate(self, identity: str, *, context=('XUS SIGNON',)):
|
||||||
self._server.update(RecordServerInfo(*await self('XUS SIGNON SETUP', '', '1', context=context))._asdict())
|
self._server.update(RecordServerInfo(*await self('XUS SIGNON SETUP', '', '1', context=context))._asdict())
|
||||||
if identity.startswith('<?xml version="1.0" encoding="UTF-8"?>'):
|
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))
|
res = await self('XUS ESSO VALIDATE', RPCType(tuple(identity[i:i+200] for i in range(0, len(identity), 200)), RPCType.GLOBAL))
|
||||||
else:
|
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])
|
||||||
@ -241,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: ')}")))
|
||||||
|
Reference in New Issue
Block a user