Compare commits

..

88 Commits

Author SHA1 Message Date
46ef48b0ae More robust lab report diff count normalization 2025-01-14 20:46:15 -05:00
1d276e7bec Improved lab report edge case parsing 2025-01-14 20:44:45 -05:00
18d6b6f19c Improved measurement parsing 2025-01-05 21:52:42 -05:00
5d2a8f464f Flowsheet calculation for iron saturation 2025-01-03 23:57:17 -05:00
fcd8447658 Fix date range negative month 2025-01-02 22:52:19 -05:00
e648988b53 Fix flowsheet auto-scroll to end 2025-01-02 22:49:25 -05:00
4708e5e0eb Improved lab report edge case parsing 2024-12-30 23:04:09 -05:00
badb26c9fc Flowsheet calculation for corrected calcium 2024-12-09 23:25:22 -05:00
5db3091470 Fix missing out-of-range flag reporting for flowsheet calculations 2024-12-09 23:24:17 -05:00
ace1407715 Discharge summary report 2024-12-09 23:11:03 -05:00
32de0bdd56 Report line-collapsed formatting 2024-12-09 23:08:07 -05:00
fa25bf3c5c Fix lab report handling to correspond with VistA patch OR*3*610 2024-12-09 23:04:03 -05:00
053053825c Improved SSO workflow: use WS-Trust STS endpoint directly instead of relying on XUIAMSSOi.dll 2024-12-09 22:48:05 -05:00
86c18927e8 DICOM viewer 2023-08-29 21:29:15 -04:00
a9d138e749 TIFF viewer 2023-08-29 21:29:15 -04:00
3aa6a64f36 Access to VistA Imaging System 2023-08-10 18:30:12 -04:00
83c9f11c73 Fix handling of null ORWORB FASTUSER meta field 2023-08-09 23:37:14 -04:00
d7d716cc27 Consult viewer 2023-08-09 23:16:25 -04:00
5c67773287 Location lookup fixes: RPC null array handling, RPC caching, existing encounters empty resultset handling, default to existing encounters 2023-08-08 22:34:59 -04:00
eebda06c86 Document creation, edition, deletion, and signature 2023-08-08 22:08:08 -04:00
48a092432c Authentication error reporting 2023-05-29 18:33:34 -04:00
4a3136a766 Authentication result parsing 2023-05-29 18:32:13 -04:00
0f019ebc34 Fix SAML token recognition 2023-05-29 18:29:43 -04:00
770a9cfb2e Persistent tag display in schedule view 2023-06-13 16:14:57 -04:00
e8f1ff02fb Sensitive record flag in schedule view 2023-06-13 16:06:43 -04:00
56662efed2 Scroll position management 2023-05-29 18:23:01 -04:00
ffc2d4c6fa Non-anchoring bottom element 2023-05-28 21:15:56 -04:00
4b9d27b553 Dynamic layout for document display 2023-05-28 21:14:10 -04:00
f011b88bf4 Fix list group item styling 2023-05-28 20:51:40 -04:00
6fcd3825c8 Conservative lookahead in reportloader_chunk 2023-05-28 20:17:27 -04:00
cdbcc51927 More selective scroll detection 2023-05-27 09:39:56 -04:00
d08f76ec99 Null date_next at end of reports 2023-05-26 07:11:35 -04:00
e63e7100f0 Exponential expansion of reportloader_alpha 2023-05-26 06:50:59 -04:00
f6408e0188 Report multi-selection 2023-05-25 06:18:49 -04:00
baa8103167 Report loader abstraction 2023-05-24 22:05:03 -04:00
c7a541a9e3 Fix timestamp inconsistency 2023-05-24 21:39:07 -04:00
d3673fc826 Patient lookup query length minimum = 3 2023-05-18 23:29:20 -04:00
b74dcb7d24 Fix report viewer early truncation of loading 2023-05-17 23:33:07 -04:00
c74855b9c2 Links from planner to schedule 2023-05-17 14:20:13 -04:00
2bf0fb971a Linkable schedules 2023-05-17 14:15:19 -04:00
8c35651281 Fix erroneous date range adjustment 2023-05-17 13:45:57 -04:00
827a6596c7 Unfettered document loading 2023-05-17 11:18:52 -04:00
f14800445f Session cookie for viewsensitive 2023-05-17 11:13:24 -04:00
0c9e428186 Fullwidth toggle 2023-05-16 23:29:26 -04:00
1825edc637 Fix navbar nonfunctional search form by removing it 2023-05-16 22:15:45 -04:00
12a7c584a5 Settings page for clinic selection 2023-05-16 22:04:37 -04:00
15f5acbf52 Ellipses for snippets 2023-05-16 20:34:39 -04:00
adb05216fd Clean report state 2023-05-16 20:28:56 -04:00
7d45820c39 Unified report viewer with filter 2023-05-16 07:19:02 -04:00
0fe07e59af TIU-based document view 2023-05-10 22:02:36 -04:00
71db3a6186 Report viewer 2023-05-10 09:03:44 -04:00
38b13f9ad6 VistA alert display 2023-05-08 22:52:55 -04:00
98cd861b5b Multi-level navigation 2023-05-08 20:24:28 -04:00
45dda6f51d Fix sensitive record indicator inconsistency (always check) 2023-05-08 20:03:40 -04:00
4a25b2f823 Fix navbar style 2023-05-08 19:45:47 -04:00
cdfa3b2f04 RoutePatient nested routes 2023-05-08 18:48:29 -04:00
c15f7ed885 RouteLookup (refactored from RoutePatientLookup) 2023-05-08 17:58:34 -04:00
cc5ec1f69f Active link style 2023-05-06 12:35:44 -04:00
b57634f730 Reactive title 2023-05-06 12:26:09 -04:00
535302ce9d Link from planner to patient 2023-05-06 08:30:37 -04:00
6970c62276 Throbber class 2023-05-06 08:22:43 -04:00
39dba30d14 Appointment list summary 2023-05-05 02:05:23 -04:00
a64edff176 Manual differential calculations 2023-05-04 09:34:11 -04:00
666c917472 Schedule view datalist element 2023-05-04 09:33:57 -04:00
c3a99697b0 Assigned practitioner tag 2023-05-03 23:17:57 -04:00
3666378172 Simplified status tags: active and inactive 2023-05-03 23:17:13 -04:00
23ecad4f8f Functional pipeline 2023-05-03 22:50:13 -04:00
9fe8227c57 Default stale=false; specific staleness parameters 2023-05-03 20:32:16 -04:00
6a284e9b6b Fix stacked updates 2023-05-03 14:31:07 -04:00
85cefa1b7b Planner (refactored from schedule overview) 2023-05-03 01:41:34 -04:00
b1038fb577 Better schedule overview 2023-05-03 00:56:05 -04:00
f64527122e Backend caching only for vitals and labs 2023-05-02 16:47:30 -04:00
704942fd3a Throbber 2023-05-01 20:23:26 -04:00
a5b041ae9a Renamed connection status variable 2023-05-01 19:29:19 -04:00
525e23c790 Fixed navbar 2023-05-01 18:58:48 -04:00
73cfbd5bbd Fix persistent schedule view filter 2023-05-01 18:06:19 -04:00
c53a9654b5 Improved unmarked tag detection 2023-05-01 18:06:19 -04:00
ec841b9591 Fix broken connection detection; disallow stale XWB_IM_HERE 2023-05-01 18:04:53 -04:00
4bc854bf00 Tagged schedule view 2023-05-01 04:13:20 -04:00
ece8338ac8 Fix misspelled TIU_TEMPLATE_SET_ITEMS 2023-04-29 18:35:08 -04:00
fdc7ed4c05 Cache-aware schedule view 2023-04-29 18:35:08 -04:00
a83e8cb22c Cache API 2023-04-29 18:34:37 -04:00
a3413e382c Fix context memory 2023-04-29 17:51:39 -04:00
002be2be12 Fix inconsistent caching 2023-04-29 17:23:32 -04:00
132c85c1fd Better SSO login experience 2023-04-25 19:42:00 -04:00
24291804a2 RPC value typecasting method 2023-04-25 19:37:22 -04:00
eb5e861441 Icon from Harold Bien 2023-04-25 18:39:25 -04:00
72d14e4b2f Enhanced schedule viewer using SDEC CRSCHED from Harold Bien 2023-04-25 18:21:32 -04:00
53 changed files with 36237 additions and 836 deletions

201
XWBSSOi.py Normal file
View 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
View File

@ -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)

View File

@ -1,39 +1,87 @@
<template>
<div class="container-fluid">
<Submenu :value="menu" />
<div class="container-fluid" style="padding-top: 5rem;">
<Navbar v-model:server="server" :user="user" />
<div class="container">
<Throbber :client="client" />
<div :class="localstate.fullwidth ? 'container-fluid' : 'container'">
<Login :secret="secret" v-model:client="client" v-model:server="server" v-model:user="user" />
<router-view v-if="user" :client="client"></router-view>
</div>
<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>
</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>
import { localstate } from './vistax.mjs';
import Submenu from './Submenu.vue';
import Navbar from './Navbar.vue';
import Throbber from './Throbber.vue';
import Login from './Login.vue';
import RouteSchedule from './RouteSchedule.vue';
import RoutePatientLookup from './RoutePatientLookup.vue';
import RouteLookup from './RouteLookup.vue';
import RoutePatient from './RoutePatient.vue';
import RoutePatientDetail from './RoutePatientDetail.vue';
import RoutePatientVisits from './RoutePatientVisits.vue';
import RoutePatientOrders from './RoutePatientOrders.vue';
import RouteScheduleOverview from './RouteScheduleOverview.vue';
import RoutePatientReports from './RoutePatientReports.vue';
import RoutePatientDocuments from './RoutePatientDocuments.vue';
import RoutePatientConsults from './RoutePatientConsults.vue';
import RoutePatientImaging from './RoutePatientImaging.vue';
import RoutePlanner from './RoutePlanner.vue';
import RouteRecall from './RouteRecall.vue';
import RouteInbox from './RouteInbox.vue';
import RouteSettings from './RouteSettings.vue';
export default {
components: {
Navbar, Login
Submenu, Navbar, Throbber, Login
},
props: {
secret: String
},
data() {
return {
localstate,
client: null,
server: null,
user: null,
heartbeat: null,
banner: '',
authenticated: false
authenticated: false,
menu: {
name: 'Main',
items: [
{ name: 'Schedule', href: '/' },
{ name: 'Lookup', href: '/lookup' },
{ name: 'Planner', href: '/planner' },
{ name: 'Recall', href: '/recall' },
{ name: 'Inbox', href: '/inbox' },
{ name: 'Settings', href: '/settings' },
]
}
};
},
watch: {
@ -42,12 +90,24 @@
else {
[
{ path: '/', component: RouteSchedule },
{ path: '/patient', component: RoutePatientLookup },
{ path: '/patient/:id', component: RoutePatientDetail },
{ path: '/patient/:id/visits', component: RoutePatientVisits },
{ path: '/patient/:id/orders', component: RoutePatientOrders },
{ path: '/overview', component: RouteScheduleOverview },
{ path: '/schedule/:from?/:to?', component: RouteSchedule },
{ path: '/lookup', component: RouteLookup },
{ path: '/patient/:id', component: RoutePatient, children: [
{ path: '', component: RoutePatientDetail },
{ path: 'visits', component: RoutePatientVisits },
{ path: 'orders', component: RoutePatientOrders },
{ path: 'reports', component: RoutePatientReports },
{ path: 'document', component: RoutePatientDocuments },
{ path: 'document/:tiu_da', component: RoutePatientDocuments },
{ path: 'consult', component: RoutePatientConsults },
{ path: 'consult/:ien', component: RoutePatientConsults },
{ path: 'imaging', component: RoutePatientImaging },
{ path: 'imaging/:ien', component: RoutePatientImaging },
] },
{ path: '/planner', component: RoutePlanner },
{ path: '/recall', component: RouteRecall },
{ path: '/inbox', component: RouteInbox },
{ path: '/settings', component: RouteSettings },
].forEach(route => this.$root.$router.addRoute(route));
await this.$root.$router.replace(this.$route);
}

View File

@ -53,8 +53,8 @@
}
function timeshift_month(date, diff) {
var month = date.getMonth() + diff;
return new Date(date.getFullYear() + Math.floor(month/12), month >= 0 ? (month%12) : (month%12 + 12), date.getDate());
var month = date.getMonth() + diff, month_mod12 = month%12;
return new Date(date.getFullYear() + Math.floor(month/12), month_mod12 >= 0 ? (month_mod12) : (month_mod12 + 12), date.getDate());
}
function datecalc(date, range, direction) {
@ -156,7 +156,7 @@
timeshift, timeshift_month
},
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>

View File

@ -7,7 +7,7 @@
<div class="accordion-body" v-else>
<div class="card">
<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>
<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">
@ -81,6 +81,7 @@
x_server: this.server,
x_user: this.user,
banner: null,
authinfo: null,
accesscode: null,
verifycode: null
};
@ -121,7 +122,7 @@
this.logout();
if(this.x_client = await (this.host ? vistax.Client.fromCookie(this.secret, this.host) : vistax.Client.fromCookie(this.secret))) {
this.banner = await this.x_client.XUS_INTRO_MSG();
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();
this.x_user = user[0] ? user : null
} catch(ex) {
@ -132,24 +133,36 @@
this.$emit('update:server', this.x_server = (await this.x_client.serverinfo()).result);
console.log('Backend secret', this.secret);
console.log(this.banner);
var stop = watchEffect(() => { if(!this.x_client.connected.value) { stop(); this.x_client = this.x_server = this.x_user = null; this.fail = true; } });
var stop = watchEffect(() => { if(!this.x_client.status.connected) { stop(); this.x_client = this.x_server = this.x_user = null; this.fail = true; } });
} else {
this.fail = true;
this.host = undefined;
this.authinfo = null;
}
},
async login(evt) {
if(this.x_client) {
var res = await ((this.accesscode && this.verifycode) ? this.x_client.authenticate(this.accesscode + ';' + this.verifycode) : this.x_client.authenticate());
if(!!res.result[0]) {
try {
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();
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.show = !this.x_user;
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) {
if(this.x_client) {

View File

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

View File

@ -1,47 +1,41 @@
<template>
<nav class="navbar navbar-expand-lg bg-dark">
<nav class="navbar navbar-expand-lg fixed-top navbar-dark bg-dark">
<div class="container-fluid">
<router-link class="navbar-brand" to="/"><template v-if="user">{{user[2]}}</template><template v-else>nuVistA</template></router-link>
<router-link class="navbar-brand" to="/"><img src="/icon.svg" style="height: 1.875rem;" /></router-link>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<router-link class="nav-link" to="/">Schedule</router-link>
<li v-if="menustate.length > 0" v-for="item in menustate[0].items" class="nav-item">
<router-link class="nav-link" :to="item.href">{{item.name}}</router-link>
</li>
<li class="nav-item">
<router-link class="nav-link" to="/patient">Patient</router-link>
</li>
<template v-if="($route.matched.length > 0) && ($route.matched[0].path == '/patient/:id')">
<li class="nav-item">
<router-link class="nav-link" :to="'/patient/' + $route.params.id + '/visits'">Visits</router-link>
</li>
<li class="nav-item">
<router-link class="nav-link" :to="'/patient/' + $route.params.id + '/orders'">Orders</router-link>
</li>
</template>
<li class="nav-item">
<router-link class="nav-link" to="/overview">Overview</router-link>
</li>
<li class="nav-item">
<router-link class="nav-link" to="/recall">Recall</router-link>
</li>
<li class="nav-item" v-if="server">
<a class="nav-link disabled">{{server.domain}}</a>
<li v-if="menustate.length > 1" v-for="menu in menustate.slice(1)" class="nav-item dropdown">
<button class="nav-link btn dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">{{menu.name}}</button>
<ul class="dropdown-menu dropdown-menu-dark">
<li v-for="item in menu.items" class="nav-item">
<router-link class="nav-link" :to="item.href">{{item.name}}</router-link>
</li>
</ul>
<form class="d-flex" role="search">
<input class="form-control me-2" type="search" placeholder="Search" aria-label="Search">
<button class="btn btn-outline-success" type="submit">Search</button>
</form>
</li>
</ul>
<div class="navbar-nav" v-if="server">
<a class="nav-link disabled"><template v-if="user">{{user[2]}} @ </template>{{server.domain}}</a>
</div>
</div>
</div>
</nav>
</template>
<style scoped>
.navbar-nav .nav-link.router-link-exact-active {
color: #fff;
}
</style>
<script>
import vistax from './vistax.mjs';
import { menustate } from './common.mjs';
export default {
props: {
@ -55,7 +49,9 @@
}
},
data() {
return {};
return {
menustate
};
}
};
</script>

67
htdocs/RouteInbox.vue Normal file
View File

@ -0,0 +1,67 @@
<template>
<Subtitle value="Inbox" />
<div>
<div class="card mb-3 shadow">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Alerts</span>
</div>
<div class="card-body">
<table class="table" style="font-family: monospace;" v-if="(resultset) && (resultset.length > 0)">
<thead>
<tr><th>I</th><th>Patient</th><th>Loc</th><th>Urg</th><th>Time</th><th>Message</th><th>XQAID</th><th>DUZ</th></tr>
</thead>
<tbody>
<tr v-for="row in resultset" :style="{ backgroundColor: strHashHSL(row.patient, '90%') }">
<td>{{row.info}}</td>
<td>{{row.patient}}</td>
<td>{{row.location}}</td>
<td>{{urgency[row.urgency]}}</td>
<td>{{datefmt(strptime_vista(row.meta_time))}}</td>
<td>{{row.message}}</td>
<td>{{row.meta_xqaid}}</td>
<td>{{row.meta_duz}}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script>
import { strHashHSL, strptime_vista } from './util.mjs';
import Subtitle from './Subtitle.vue';
export default {
components: {
Subtitle
},
props: {
client: Object
},
data() {
return {
resultset: [],
urgency: { 'n/a': 'n/a', 'low': 'L', 'Moderate': 'M', 'HIGH': 'H' }
};
},
methods: {
strHashHSL,
strptime_vista,
datefmt(date) {
return date ? date.toLocaleDateString('sv-SE') + ' ' + date.toLocaleTimeString('en-GB') : '';
}
},
created() {
this.$watch(
() => this.client,
async () => {
if(this.client) this.resultset = await this.client.ORWORB_FASTUSER();
else this.resultset = [];
},
{ immediate: true }
);
}
};
</script>

View File

@ -1,4 +1,5 @@
<template>
<Subtitle value="Lookup" />
<div>
<div class="card mb-3 shadow">
<div class="card-header">Patients</div>
@ -10,11 +11,12 @@
</template>
<script>
import Subtitle from './Subtitle.vue';
import ViewPatientLookup from './ViewPatientLookup.vue';
export default {
components: {
ViewPatientLookup
Subtitle, ViewPatientLookup
},
props: {
client: Object

124
htdocs/RoutePatient.vue Normal file
View File

@ -0,0 +1,124 @@
<template>
<Subtitle value="Patient" />
<div v-if="(sensitive) && (!viewsensitive)" class="alert alert-danger text-center mb-3 shadow" role="alert">
<Subtitle value="Restricted Record" />
<h1>Warning: Restricted Record</h1>
<p>This record is protected by the Privacy Act of 1974 and the Health Insurance Portability and Accountability Act of 1996. If you elect to proceed, you will be required to prove you have a need to know. Accessing this patient is tracked, and your station Security Officer will contact you for your justification.</p>
<button class="btn btn-danger" @click="viewsensitive = true">Proceed</button>
</div>
<template v-if="patient_info">
<Submenu :value="menu" />
<div class="card mb-3 shadow">
<div v-if="sensitive" class="card-header alert-danger d-flex justify-content-between align-items-center">
<span>{{patient_info.name}} <span :title="patient_info.pid">{{patient_info.pid.slice(-4)}}</span> #{{patient_dfn}}</span>
<button class="btn-close" @click="viewsensitive = false"></button>
</div>
<div v-else class="card-header">{{patient_info.name}} <span :title="patient_info.pid">{{patient_info.pid.slice(-4)}}</span> #{{patient_dfn}}</div>
<div class="card-body row" style="font-family: monospace;">
<div class="col" v-if="patient_info.dob"><strong>DOB:</strong> {{strptime_vista(patient_info.dob).toLocaleDateString('sv-SE')}}</div>
<div class="col" v-if="patient_info.age"><strong>Age:</strong> {{patient_info.age}}</div>
<div class="col" v-if="patient_info.sex"><strong>Sex:</strong> {{patient_info.sex}}</div>
<div class="col" v-if="patient_info.sc_percentage"><strong>SC%:</strong> {{patient_info.sc_percentage}}</div>
<div class="col" v-if="patient_info.type"><strong>Type:</strong> {{patient_info.type}}</div>
<div class="col" v-if="patient_info.ward"><strong>Ward:</strong> {{patient_info.ward}}</div>
<div class="col" v-if="patient_info.room_bed"><strong>Room/bed:</strong> {{patient_info.room_bed}}</div>
</div>
</div>
<router-view :client="client" :sensitive="sensitive" :patient_dfn="patient_dfn" :patient_info="patient_info"></router-view>
</template>
</template>
<script>
import cookie from './cookie.mjs';
import { strptime_vista } from './util.mjs';
import Subtitle from './Subtitle.vue';
import Submenu from './Submenu.vue';
export default {
components: {
Subtitle, Submenu
},
props: {
client: Object
},
data() {
return {
viewsensitive: false,
sensitive: false,
patient_dfn: null,
patient_info: null
};
},
computed: {
menu() {
return this.patient_info ? {
name: this.patient_info.name,
items: [
{ name: 'Patient', href: '/patient/' + this.patient_dfn },
{ name: 'Visits', href: '/patient/' + this.patient_dfn + '/visits' },
{ name: 'Orders', href: '/patient/' + this.patient_dfn + '/orders' },
{ name: 'Reports', href: '/patient/' + this.patient_dfn + '/reports' },
{ name: 'Documents', href: '/patient/' + this.patient_dfn + '/document' },
{ name: 'Consults', href: '/patient/' + this.patient_dfn + '/consult' },
{ name: 'Imaging', href: '/patient/' + this.patient_dfn + '/imaging' },
]
} : null;
}
},
methods: {
strptime_vista
},
watch: {
'$route.params.id': {
async handler(value) {
if(value.startsWith('$')) {
var id = value.substring(1);
if(id.length == 9) {
var patient = await this.client.ORWPT_FULLSSN(id);
this.$router.replace('/patient/' + patient[0].dfn);
} else if(id.length == 5) {
var name = this.$route.query.name.toUpperCase();
var patient = await this.client.ORWPT_LAST5(id);
for(var i = 0; i < patient.length; ++i) if(name == patient[i].name) {
this.$router.replace('/patient/' + patient[i].dfn);
break;
}
}
} else {
this.sensitive = await this.client.ORWPT_SELCHK(value);
this.patient_dfn = value;
var viewsensitive = cookie.get('viewsensitive');
this.viewsensitive = viewsensitive ? viewsensitive.split(',').indexOf(value) >= 0 : false;
}
}, immediate: true
},
viewsensitive(value) {
var viewsensitive = cookie.get('viewsensitive'), viewsensitive = viewsensitive !== null ? viewsensitive.split(',') : [], idx = viewsensitive.indexOf(this.patient_dfn);
if(value) {
if(idx < 0) {
viewsensitive.push(this.patient_dfn);
cookie.set('viewsensitive', viewsensitive.join(','));
}
} else {
if(idx >= 0) {
viewsensitive.splice(idx, 1);
cookie.set('viewsensitive', viewsensitive.join(','));
}
}
}
},
created() {
this.$watch(
() => (this.client, this.patient_dfn, this.sensitive, this.viewsensitive, {}),
async function() {
if(this.client) {
if(this.patient_dfn) this.patient_info = (this.sensitive) && (!this.viewsensitive) ? null : await this.client.ORWPT16_ID_INFO(this.patient_dfn);
else this.patient_info = null;
}
},
{ immediate: true }
);
}
};
</script>

View 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>

View File

@ -1,37 +1,19 @@
<template>
<div v-if="(sensitive) && (!info)" class="alert alert-danger text-center mb-3 shadow" role="alert">
<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>
<router-link class="btn btn-danger" :to="'/patient/' + dfn + '?viewsensitive'">Proceed</router-link>
</div>
<div v-if="info">
<div class="card mb-3 shadow">
<div class="card-header">{{info.name}} <span :title="info.pid">{{info.pid.slice(-4)}}</span> #{{dfn}}</div>
<div class="card-body row" style="font-family: monospace;">
<div class="col" v-if="info.dob"><strong>DOB:</strong> {{strptime_vista(info.dob).toLocaleDateString('sv-SE')}}</div>
<div class="col" v-if="info.age"><strong>Age:</strong> {{info.age}}</div>
<div class="col" v-if="info.sex"><strong>Sex:</strong> {{info.sex}}</div>
<div class="col" v-if="info.sc_percentage"><strong>SC%:</strong> {{info.sc_percentage}}</div>
<div class="col" v-if="info.type"><strong>Type:</strong> {{info.type}}</div>
<div class="col" v-if="info.ward"><strong>Ward:</strong> {{info.ward}}</div>
<div class="col" v-if="info.room_bed"><strong>Room/bed:</strong> {{info.room_bed}}</div>
</div>
</div>
<Subtitle value="Detail" />
<Subtitle :value="patient_info.name" />
<div class="card mb-3 shadow">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Data</span>
<DateRangePicker range="1M" direction="-1" v-model:date="report_date" v-model:date_end="report_date_begin" />
</div>
<div class="card-body">
<ViewVitalsLabs :client="client" :dfn="dfn" :date_begin="report_date_begin" :date_end="report_date" />
</div>
<ViewVitalsLabs :client="client" :dfn="patient_dfn" :date_begin="report_date_begin" :date_end="report_date" />
</div>
</div>
</template>
<script>
import { strptime_vista } from './util.mjs';
import Subtitle from './Subtitle.vue';
import DateRangePicker from './DateRangePicker.vue';
import ViewVitalsLabs from './ViewVitalsLabs.vue';
@ -39,55 +21,22 @@
export default {
components: {
DateRangePicker, ViewVitalsLabs
Subtitle, DateRangePicker, ViewVitalsLabs
},
props: {
client: Object
client: Object,
sensitive: Boolean,
patient_dfn: String,
patient_info: Object
},
data() {
return {
dfn: null,
sensitive: false,
info: null,
report_date: now,
report_date_begin: now,
orders_filter: 2,
orders_date: now,
orders_date_begin: now
};
},
watch: {
info(value) {
if((value) && (value.name)) document.title = value.name;
}
},
methods: {
strptime_vista,
async loadinfo(dfn, viewsensitive) {
this.dfn = dfn;
this.sensitive = viewsensitive ? false : await this.client.ORWPT_SELCHK(dfn);
this.info = this.sensitive ? null : await this.client.ORWPT16_ID_INFO(dfn);
}
},
async mounted() {
if(this.$route.params.id.startsWith('$')) {
var id = this.$route.params.id.substring(1);
if(id.length == 9) {
var patient = await this.client.ORWPT_FULLSSN(id);
this.$router.replace('/patient/' + patient[0].dfn);
} else if(id.length == 5) {
var name = this.$route.query.name.toUpperCase();
var patient = await this.client.ORWPT_LAST5(id);
for(var i = 0; i < patient.length; ++i) if(name == patient[i].name) {
this.$router.replace('/patient/' + patient[i].dfn);
break;
}
}
} else this.loadinfo(this.$route.params.id, this.$route.query.hasOwnProperty('viewsensitive'));
},
async beforeRouteUpdate(to, from, next) {
this.loadinfo(to.params.id, to.query.hasOwnProperty('viewsensitive'));
next();
}
};
</script>

View File

@ -0,0 +1,285 @@
<template>
<Subtitle value="Documents" />
<Subtitle :value="patient_info.name" />
<div class="row">
<div class="selector col-12" :class="{ 'col-xl-4': selection }">
<div class="card mb-3 shadow">
<div class="card-header d-flex justify-content-between align-items-center">
<span><template v-if="resultset.length > 0">{{resultset.length}}<template v-if="has_more">+</template></template><template v-else-if="is_loading">Loading</template><template v-else>No</template> record{{resultset.length == 1 ? '' : 's'}}</span>
<router-link :to="'/patient/' + patient_dfn + '/document/new'">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 16 16" style="width: 1.25em; height: 1.25em; vertical-align: text-bottom;">
<path d="M14 1a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h12zM2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2z"/>
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z"/>
</svg>
</router-link>
</div>
<ul class="scroller list-group list-group-flush" :class="{ 'list-skinny': selection }" ref="scroller">
<router-link v-for="item in resultset" :to="'/patient/' + patient_dfn + '/document/' + item.IEN" replace custom v-slot="{ navigate, href }">
<li :key="item" class="record list-group-item" :class="{ 'active': selection == item.IEN }" :title="datetimestring(strptime_vista(item.time)) + '\n' + item.title + '\n' + item.location + '\n' + item.author.byline" @click="navigate">
<div class="row">
<div class="cell col-4"><router-link :to="href" replace>{{datestring(strptime_vista(item.time))}}</router-link></div>
<div class="cell col-8"><template v-if="item.status == 'unsigned'"></template>{{item.title}}</div>
<div class="cell secondary col-7 col-lg-4 col-xl-7">{{item.location}}</div>
<div class="cell secondary col-5 col-lg-8 col-xl-5">{{item.author.byline}}</div>
</div>
</li>
</router-link>
<li class="bottom list-group-item" ref="bottom" />
</ul>
</div>
</div>
<div v-if="selection == 'new'" class="col-12 col-xl-8">
<ViewDocNew :client="client" :dfn="patient_dfn" :datetime="datetimestring(new Date())" @cancel="() => $router.replace({ path: '/patient/' + patient_dfn + '/document' })" @submit="doc_create" />
</div>
<div v-else-if="selection" class="detail col-12 col-xl-8" ref="detail">
<ViewDocView :client="client" :dfn="patient_dfn" :ien="selection" @sign="doc_sign_prompt" @delete="doc_delete" @cancel="() => $router.replace({ path: '/patient/' + patient_dfn + '/document' })" />
</div>
</div>
<ModalPromptSignatureCode :client="client" v-model:show="show_signature" @submit="doc_sign" label="Sign Document" />
</template>
<style scoped>
div.selector {
position: sticky;
top: 1.15rem;
z-index: 1;
}
ul.scroller.list-skinny {
max-height: 25vh;
overflow-y: auto;
}
li.record {
cursor: default;
border-top: 1px solid #dee2e6;
padding: 0.25rem 0.75rem;
scroll-margin-top: 3.6875rem;
}
li.record:nth-child(even) {
background-color: rgba(0, 0, 0, 0.05);
}
ul.scroller.list-skinny li.record {
scroll-margin-top: 0;
}
li.record a {
color: inherit;
}
li.record.active {
color: #fff;
background-color: #0d6efd;
}
li.bottom {
overflow-anchor: none;
}
div.cell {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
a.close {
cursor: default;
text-decoration: none;
}
div.detail /deep/ .card-body {
scroll-margin-top: calc(3.6875rem + 2.5625rem + 25vh);
font-family: monospace;
white-space: pre-wrap;
}
@media (min-width: 1200px) {
div.selector {
position: static;
}
ul.scroller.list-skinny {
max-height: 75vh;
}
div.cell.secondary {
font-size: 0.8em;
}
div.detail /deep/ .card-body {
max-height: 75vh;
scroll-margin-top: 0;
overflow-y: auto;
}
}
</style>
<script>
import { debounce, strptime_vista } from './util.mjs';
import Subtitle from './Subtitle.vue';
import ViewDocNew from './ViewDocNew.vue';
import ViewDocView from './ViewDocView.vue';
import ModalPromptSignatureCode from './ModalPromptSignatureCode.vue';
const SZ_WINDOW = 100;
export default {
components: {
Subtitle, ViewDocNew, ViewDocView, ModalPromptSignatureCode
},
props: {
client: Object,
sensitive: Boolean,
patient_dfn: String,
patient_info: Object
},
data() {
return {
dfn: null,
has_more: '',
is_loading: false,
rs_unsigned: [],
rs_signed: [],
selection: null,
show_signature: false,
observer_scroller: null,
observer_viewport: null
};
},
computed: {
resultset() {
return this.rs_unsigned.concat(this.rs_signed);
}
},
watch: {
'$route.params.tiu_da': {
async handler(value) {
this.selection = value;
}, immediate: true
}
},
methods: {
strptime_vista,
datestring(date) {
return date.toLocaleDateString('sv-SE');
},
datetimestring(date) {
return date.toLocaleDateString('sv-SE') + ' ' + date.toLocaleTimeString('en-GB');
},
async load_more() {
try {
this.is_loading = true;
if((this.client) && (this.patient_dfn)) {
if(this.dfn != this.patient_dfn) {
this.dfn = this.patient_dfn;
this.has_more = '';
this.rs_signed = [];
}
var res = await client.TIU_DOCUMENTS_BY_CONTEXT(3, 1, this.patient_dfn, -1, -1, 0, SZ_WINDOW, 'D', 1, 0, 1, this.has_more);
if((res) && (res.length > 0)) {
res = res.slice();
var last = res[res.length - 1];
if((last.title == 'SHOW MORE') && (!last.author) && (!last.visit)) {
this.has_more = last.IEN;
res.splice(res.length - 1, 1);
}
if(this.rs_signed.length > 0) Array.prototype.push.apply(this.rs_signed, res);
else this.rs_signed = res;
}
} else {
this.dfn = null;
this.has_more = '';
this.rs_signed = [];
}
} catch(ex) {
console.warn(ex);
} finally {
this.is_loading = false;
}
},
async load_unsigned() {
this.rs_unsigned = [];
this.rs_unsigned = await client.TIU_DOCUMENTS_BY_CONTEXT(3, 2, this.patient_dfn, 0, 0, 0, 0, 'D', 1, 0, 1, '');
},
async reload() {
this.dfn = null;
await client.TIU_DOCUMENTS_BY_CONTEXT_FLUSH(3, 2, this.patient_dfn, 0, 0, 0, 0, 'D', 1, 0, 1, '');
await this.load_unsigned();
await client.TIU_DOCUMENTS_BY_CONTEXT_FLUSH(3, 1, this.patient_dfn, -1, -1, 0, SZ_WINDOW, 'D', 1, 0, 1, '');
await this.load_more();
},
async doc_create(params) {
var vstr = params.location.datetime ? ('' + params.location.IEN + ';' + params.location.datetime + ';A') : ('' + params.location.IEN + ';' + params.datetime + ';E');
var res = await this.client.TIU_CREATE_RECORD(this.patient_dfn, params.title, '', '', '', { '".01"': params.title, '"1202"': params.author, '"1301"': params.datetime, '"1205"': params.location.IEN }, vstr, '1');
if(res) {
this.reload();
this.$router.replace({ path: '/patient/' + this.patient_dfn + '/document/' + res, query: { edit: '' } });
} else {
console.error('Unable to create document', params, res);
window.alert('Unable to create document.');
}
},
doc_sign_prompt(ien) {
this.show_signature = true;
},
async doc_sign(code) {
var selection = this.selection;
if((selection) & (code)) {
this.show_signature = false;
await this.client.TIU_SIGN_RECORD(selection, ' ' + code + ' ');
this.reload();
this.selection = null;
await this.$nextTick();
this.selection = selection;
}
},
async doc_delete(ien) {
if(window.confirm('Delete this document?')) {
var vstr = await this.client.ORWPCE_NOTEVSTR(ien);
if(vstr) await this.client.ORWPCE_DELETE(vstr, this.patient_dfn);
await this.client.TIU_DELETE_RECORD(ien);
this.reload();
if(this.selection == ien) {
this.selection = null;
this.$router.replace({ path: '/patient/' + this.patient_dfn + '/document' });
}
}
}
},
created() {
this.$watch(
() => (this.client, this.patient_dfn, {}),
debounce(() => { this.load_more(); this.load_unsigned(); }, 500),
{ immediate: true }
);
this.$watch(
() => (this.client, this.selection, {}),
async function() {
if(this.$refs.scroller) {
if(this.selection) { // scroll to selected item
if(this.selection != 'new') {
await this.$nextTick();
var active = this.$refs.scroller.querySelectorAll(':scope > .active');
if(active.length > 0) (Element.prototype.scrollIntoViewIfNeeded || Element.prototype.scrollIntoView).call(active[0]);
if(this.$refs.detail) { // scroll to top of detail panel
this.$refs.detail.scrollIntoView();
this.$refs.detail.scrollTop = 0;
}
}
} else { // scroll to topmost item
var offset = this.$refs.scroller.getBoundingClientRect().top;
for(var children = this.$refs.scroller.children, count = children.length, i = 0; i < count; ++i) if(children[i].getBoundingClientRect().top >= offset) {
await this.$nextTick();
var behavior = document.documentElement.style.scrollBehavior;
document.documentElement.style.scrollBehavior = 'auto'; // inhibit Bootstrap smooth scrolling
children[i].scrollIntoView();
document.documentElement.style.scrollBehavior = behavior;
break;
}
}
}
},
{ immediate: true }
);
},
mounted() {
this.observer_scroller = new IntersectionObserver(([entry]) => { if((entry.isIntersecting) && (this.selection) && (this.has_more) && (!this.is_loading)) this.load_more(); }, { root: this.$refs.scroller, rootMargin: '25%' });
this.observer_scroller.observe(this.$refs.bottom);
this.observer_viewport = new IntersectionObserver(([entry]) => { if((entry.isIntersecting) && (!this.selection) && (this.has_more) && (!this.is_loading)) this.load_more(); }, { rootMargin: '25%' });
this.observer_viewport.observe(this.$refs.bottom);
},
destroyed() {
if(this.observer_viewport) this.observer_viewport.disconnect();
if(this.observer_scroller) this.observer_scroller.disconnect();
}
};
</script>

View 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>

View File

@ -1,25 +1,9 @@
<template>
<div v-if="(sensitive) && (!info)" class="alert alert-danger text-center mb-3 shadow" role="alert">
<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>
<router-link class="btn btn-danger" :to="'/patient/' + dfn + '/orders?viewsensitive'">Proceed</router-link>
</div>
<div v-if="info">
<div class="card mb-3 shadow">
<div class="card-header">{{info.name}} <span :title="info.pid">{{info.pid.slice(-4)}}</span> #{{dfn}}</div>
<div class="card-body row" style="font-family: monospace;">
<div class="col" v-if="info.dob"><strong>DOB:</strong> {{strptime_vista(info.dob).toLocaleDateString('sv-SE')}}</div>
<div class="col" v-if="info.age"><strong>Age:</strong> {{info.age}}</div>
<div class="col" v-if="info.sex"><strong>Sex:</strong> {{info.sex}}</div>
<div class="col" v-if="info.sc_percentage"><strong>SC%:</strong> {{info.sc_percentage}}</div>
<div class="col" v-if="info.type"><strong>Type:</strong> {{info.type}}</div>
<div class="col" v-if="info.ward"><strong>Ward:</strong> {{info.ward}}</div>
<div class="col" v-if="info.room_bed"><strong>Room/bed:</strong> {{info.room_bed}}</div>
</div>
</div>
<Subtitle value="Orders" />
<Subtitle :value="patient_info.name" />
<div class="card mb-3 shadow">
<div class="card-header">Order entry</div>
<div class="card-body"><ViewOrderMenu :client="client" :dfn="dfn" /></div>
<div class="card-body"><ViewOrderMenu :client="client" :dfn="patient_dfn" /></div>
</div>
<div class="card mb-3 shadow">
<div class="card-header d-flex justify-content-between align-items-center">
@ -27,14 +11,12 @@
<OrderFilterPicker :client="client" v-model="orders_filter" />
<DateRangePicker range="6M" direction="-1" v-model:date="orders_date" v-model:date_end="orders_date_begin" />
</div>
<div class="card-body"><ViewOrders :client="client" :dfn="dfn" :filter="orders_filter" :date_begin="orders_date_begin" :date_end="orders_date" /></div>
</div>
<div class="card-body"><ViewOrders :client="client" :dfn="patient_dfn" :filter="orders_filter" :date_begin="orders_date_begin" :date_end="orders_date" /></div>
</div>
</template>
<script>
import { strptime_vista } from './util.mjs';
import Subtitle from './Subtitle.vue';
import DateRangePicker from './DateRangePicker.vue';
import OrderFilterPicker from './OrderFilterPicker.vue';
import ViewOrderMenu from './ViewOrderMenu.vue';
@ -44,40 +26,20 @@
export default {
components: {
DateRangePicker, OrderFilterPicker, ViewOrderMenu, ViewOrders
Subtitle, DateRangePicker, OrderFilterPicker, ViewOrderMenu, ViewOrders
},
props: {
client: Object
client: Object,
sensitive: Boolean,
patient_dfn: String,
patient_info: Object
},
data() {
return {
dfn: null,
sensitive: false,
info: null,
orders_filter: 2,
orders_date: now,
orders_date_begin: now
};
},
watch: {
info(value) {
if((value) && (value.name)) document.title = value.name;
}
},
methods: {
strptime_vista,
async loadinfo(dfn, viewsensitive) {
this.dfn = dfn;
this.sensitive = viewsensitive ? false : await this.client.ORWPT_SELCHK(dfn);
this.info = this.sensitive ? null : await this.client.ORWPT16_ID_INFO(dfn);
}
},
async mounted() {
this.loadinfo(this.$route.params.id, this.$route.query.hasOwnProperty('viewsensitive'));
},
async beforeRouteUpdate(to, from, next) {
this.loadinfo(to.params.id, to.query.hasOwnProperty('viewsensitive'));
next();
}
};
</script>

View File

@ -0,0 +1,601 @@
<template>
<Subtitle value="Reports" />
<Subtitle :value="patient_info.name" />
<div class="filter card mb-3 shadow" :class="{ 'list-wide': !selection }">
<ul class="list-group list-group-flush">
<li class="list-group-item">
<div class="input-group">
<span class="input-group-text">🔎</span>
<input type="text" class="form-control" placeholder="Filter" v-model="x_query" />
<button v-if="x_query" class="btn btn-outline-secondary" @click="x_query = ''"></button>
</div>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
<div class="btn-group">
<button v-for="report in reports" class="btn" :class="{ 'btn-primary': report.enabled, 'btn-outline-primary': !report.enabled }" @click="enable(report)">{{report.name}}<input type="checkbox" class="form-check-input" :checked="report.enabled" @click.stop="report.enabled = !report.enabled" /></button>
</div>
<DateRangePicker range="Range" direction="-1" v-model:date="date_end" v-model:date_end="date_begin" />
</li>
</ul>
</div>
<div class="row">
<div class="selector col-12" :class="{ 'col-xl-4': selection }">
<div class="card mb-3 shadow">
<div class="card-header"><template v-if="resultset.length > 0"><template v-if="resultset.length > rs_filtered.length">{{rs_filtered.length}} of </template>{{resultset.length}}</template><template v-else-if="is_loading">Loading</template><template v-else>No</template> record{{resultset.length == 1 ? '' : 's'}}</div>
<ul class="scroller list-group list-group-flush" :class="{ 'list-skinny': selection }" ref="scroller">
<li v-for="item in rs_filtered" :key="item" class="record list-group-item" :class="{ 'active': (selection) && (selection.id == item.id) }" @click="selection = item"><span class="badge emblem" :class="[item.emblem]" /> <span class="datetime date">{{datestring(item.time)}}</span> <span class="datetime time">{{timestring(item.time)}}</span> • <span class="title"><span v-for="title in item.title">{{title}}</span></span><template v-if="item.snippets"><div v-for="snippet in item.snippets" class="snippet" v-html="snippet" /></template></li>
<li class="bottom list-group-item" ref="bottom"><button v-if="date_next" class="btn btn-outline-primary" :disabled="is_loading" @click="date_begin = date_next"><template v-if="is_loading">Loading</template><template v-else>Load</template> back to {{datestring(date_next)}}<template v-if="is_loading"></template></button></li>
</ul>
</div>
</div>
<div v-if="selection" class="col-12 col-xl-8">
<div class="card mb-3 shadow">
<div class="card-header d-flex justify-content-between align-items-center">
<span>{{selection.title.join(' - ')}}</span>
<span class="close" @click="selection = null"></span>
</div>
<div class="detail card-body" v-html="selection.highlight || selection.detail" ref="detail" />
</div>
</div>
</div>
</template>
<style>
ul.scroller span.highlight, div.detail span.highlight {
background-color: #ff0;
}
li.record.active span.highlight {
color: #000;
}
</style>
<style scoped>
div.filter.list-wide {
position: sticky;
top: 3.65rem;
z-index: 2;
}
div.filter input.form-check-input {
position: absolute;
top: 0;
right: 0;
margin-top: 0;
}
div.selector {
position: sticky;
top: 1.15rem;
z-index: 1;
}
ul.scroller.list-skinny {
max-height: 25vh;
overflow-y: auto;
}
li.record {
cursor: default;
border-top: 1px solid #dee2e6;
padding: 0.25rem 0.75rem;
scroll-margin-top: 10.5rem;
}
li.record:nth-child(even) {
background-color: rgba(0, 0, 0, 0.05);
}
ul.scroller.list-skinny li.record {
scroll-margin-top: 0;
}
li.record.active {
color: #fff;
background-color: #0d6efd;
}
li.bottom {
overflow-anchor: none;
}
li.bottom button {
width: 100%;
}
span.badge.emblem:empty {
display: inline-block;
font-family: monospace;
}
span.badge.emblem-notes {
background-color: var(--bs-purple);
}
span.badge.emblem-notes::after {
content: 'N';
}
span.badge.emblem-labs {
background-color: var(--bs-pink);
}
span.badge.emblem-labs::after {
content: 'L';
}
span.badge.emblem-microbiology {
background-color: var(--bs-orange);
}
span.badge.emblem-microbiology::after {
content: 'M';
}
span.badge.emblem-bloodbank {
background-color: var(--bs-red);
}
span.badge.emblem-bloodbank::after {
content: 'B';
}
span.badge.emblem-pathology {
background-color: var(--bs-yellow);
}
span.badge.emblem-pathology::after {
content: 'P';
}
span.badge.emblem-radiology {
background-color: var(--bs-green);
}
span.badge.emblem-radiology::after {
content: 'R';
}
span.datetime, span.title span:first-child {
font-weight: bold;
}
span.title span:not(:first-child)::before {
content: ' - ';
}
ul.scroller.list-skinny span.datetime.time {
display: none;
}
div.snippet {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
div.snippet::before, div.snippet::after {
content: '…';
}
span.close {
cursor: default;
}
div.detail {
scroll-margin-top: calc(3.6875rem + 2.5625rem + 25vh);
font-family: monospace;
white-space: pre-wrap;
}
@media (min-width: 1200px) {
div.selector {
position: static;
}
ul.scroller.list-skinny {
max-height: 75vh;
}
div.detail {
max-height: 75vh;
scroll-margin-top: 0;
overflow-y: auto;
}
}
</style>
<script>
import { flow, uniq, debounce, strftime_vista, strptime_vista } from './util.mjs';
import Subtitle from './Subtitle.vue';
import DateRangePicker from './DateRangePicker.vue';
const SZ_WINDOW = 100;
const SZ_RANGE = 40000;
function f_parse_columns(item) {
var res = [], line, brk, sub;
for(var i = 0; i < item.length; ++i) {
brk = (line = item[i]).indexOf('^');
if(brk >= 0) {
if(res[sub = line.substring(0, brk)]) res[sub].push(line.substring(brk + 1));
else res[sub] = [line.substring(brk + 1)];
}
}
for(var k in res) if(res[k]) res[k] = res[k].join('\n');
return res;
}
const create_reports = () => [
{
name: 'Notes',
rpt_id: 'OR_PN:PROGRESS NOTES~TIUPRG;ORDV04;15;',
map: flow(f_parse_columns, function(x) {
var time = new Date(x[3]);
return {
time,
id: 'OR_PN:' + time.getTime() + ':' + x[2],
emblem: 'emblem-notes',
title: [x[4], x[5], '#' + x[2]],
detail: escape_html(collapse_lines(x[6]))
};
}),
loader: reportloader_chunk,
enabled: true
},
{
name: 'Discharge',
rpt_id: 'OR_DS:DISCHARGE SUMMARY~TIUDCS;ORDV04;57;',
map: flow(f_parse_columns, function(x) {
var time = new Date(x[3]);
return {
time,
id: 'OR_DS:' + time.getTime() + ':' + x[2],
emblem: 'emblem-notes',
title: ['DISCHARGE SUMMARY', x[4], x[2], x[3]],
detail: escape_html(collapse_lines(x[7]))
};
}),
loader: reportloader_chunk
},
{
name: 'Labs',
rpt_id: 'OR_OV_R:LAB OVERVIEW (COLLECTED SPECIMENS)~OV;ORDV02C;32;',
map: flow(f_parse_columns, function(x) {
var time = new Date(x[2]);
return {
time,
id: 'OR_OV_R:' + time.getTime() + ':' + x[12],
emblem: 'emblem-labs',
title: [x[3], x[6], x[8], x[10], '#' + x[12]],
detail: escape_html(x[15])
};
}),
loader: reportloader_chunk
},
{
name: 'Microbiology',
rpt_id: 'OR_MIC:MICROBIOLOGY~MI;ORDV05;38;',
map: flow(f_parse_columns, function(x) {
var time = new Date(x[2]);
return {
time,
id: 'OR_MIC:' + time.getTime() + ':' + x[6],
emblem: 'emblem-microbiology',
title: [x[3], x[4], x[5], '#' + x[6]],
detail: escape_html(x[7])
};
}),
loader: reportloader_chunk
},
{
name: 'Blood Bank',
rpt_id: '2:BLOOD BANK REPORT~;;0',
singleton: true,
map(x) {
var now = new Date();
return {
time: new Date(now.getFullYear(), now.getMonth(), now.getDate()),
id: 'BB',
emblem: 'emblem-bloodbank',
title: ['BLOOD BANK'],
detail: escape_html(x.join('\n'))
};
},
loader: reportloader_full
},
{
name: 'Pathology',
rpt_id: 'OR_APR:ANATOMIC PATHOLOGY~SP;ORDV02A;0;',
map: flow(f_parse_columns, function(x) {
var time = new Date(x[2]);
return {
time,
id: 'OR_APR:' + time.getTime() + ':' + x[4],
emblem: 'emblem-pathology',
title: [x[3], '#' + x[4]],
detail: escape_html(x[5])
};
}),
loader: reportloader_full
},
{
name: 'Radiology',
rpt_id: 'OR_R18:IMAGING~RIM;ORDV08;0;',
map: flow(f_parse_columns, function(x) {
var time = new Date(x[2]);
return {
time,
id: 'OR_R18:' + time.getTime() + ':' + x[9],
emblem: 'emblem-radiology',
title: [x[3], x[4], x[5], '#' + x[9]],
detail: escape_html(x[6])
};
}),
loader: reportloader_chunk
},
];
function data_limit(data, dt_alpha, dt_omega) {
for(var i = 0, x, none = true; i < data.length; ++i) if(((x = data[i]).time <= dt_omega) || (!x.time) || (isNaN(x.time))) {
data = data.slice(i);
none = false;
break;
}
if(none) return [];
for(var i = data.length - 1, x, none = true; i >= 0; --i) if(((x = data[i]).time >= dt_alpha) || (!x.time) || (isNaN(x.time))) return i < data.length - 1 ? data.slice(0, i + 1) : data;
return [];
}
function data_endtime(data) {
for(var i = data.length - 1, time; i >= 0; --i) if((time = data[i].time) && (!isNaN(time))) return time;
}
function data_endtime_conservative(data) {
var dt_end = data_endtime(data);
if(dt_end) for(var i = data.length - 1, time; i >= 0; --i) if((time = data[i].time) && (time > dt_end)) return time;
return dt_end;
}
function data_interval(data) {
data = data.slice(data.length - SZ_WINDOW - 1).filter(x => (x.time) && (!isNaN(x.time)));
return data.length > 1 ? (data[0].time - data[data.length - 1].time)/(data.length - 1)*SZ_WINDOW : 86400000*SZ_WINDOW;
}
function reportloader_full(dfn, rpt_id, fn_map, omega) {
var cachekey = dfn + ';' + rpt_id, dt_omega = strptime_vista(omega), data = null, dt_end;
async function fn(client, alpha) {
var dt_alpha = strptime_vista(alpha);
if(!data) {
data = (reportloader_full[cachekey] || (reportloader_full[cachekey] = await client.ORWRP_REPORT_TEXT(dfn, rpt_id, '', SZ_RANGE, '', -1, -1))).map(fn_map).sort((a, b) => b.time - a.time), dt_end = null;
dt_end = data_endtime(data);
}
var res = alpha !== undefined ? data_limit(data, dt_alpha, dt_omega) : [];
if((data.length > 0) && ((dt_alpha > dt_end) || (alpha === undefined)) && ((res.length < 1) || (res[res.length - 1] !== data[data.length - 1]))) res.next = strftime_vista(res.dt_next = dt_end); // lookahead
return res;
}
fn.omega = omega;
return fn;
}
function reportloader_alpha(dfn, rpt_id, fn_map, omega) {
var dt_omega = strptime_vista(omega), cursor = Math.floor(strftime_vista(new Date())) + 0.235959999, interval = 86400000*365*2, data = [], dt_end, hasmore = true;
async function fn(client, alpha) {
var dt_alpha = strptime_vista(alpha);
if(alpha !== undefined) {
if((hasmore) && (cursor >= alpha)) {
while(cursor >= alpha) cursor = Math.floor(strftime_vista(new Date(strptime_vista(cursor) - interval)));
data = (await client.ORWRP_REPORT_TEXT(dfn, rpt_id, '', SZ_RANGE, '', cursor, -1)).map(fn_map).sort((a, b) => b.time - a.time);
dt_end = data_endtime(data);
}
var res = data_limit(data, dt_alpha, dt_omega);
} else var res = [];
if((data.length > 0) && ((dt_alpha > dt_end) || (alpha === undefined)) && ((res.length < 1) || (res[res.length - 1] !== data[data.length - 1]))) res.next = strftime_vista(res.dt_next = dt_end); // lookahead
else if(hasmore) {
var count = data.length;
while(interval <= 86400000*365*8) {
cursor = Math.floor(strftime_vista(new Date(strptime_vista(cursor) - interval)));
data = (await client.ORWRP_REPORT_TEXT(dfn, rpt_id, '', SZ_RANGE, '', cursor, -1)).map(fn_map).sort((a, b) => b.time - a.time);
if(data.length > count) {
res.next = strftime_vista(res.dt_next = dt_end = data_endtime(data));
return res;
} else interval *= 2;
}
data = (await client.ORWRP_REPORT_TEXT(dfn, rpt_id, '', SZ_RANGE, '', -1, -1)).map(fn_map).sort((a, b) => b.time - a.time);
cursor = Math.floor(res.next = strftime_vista(res.dt_next = dt_end = data_endtime(data)));
hasmore = false;
}
return res;
}
fn.omega = omega;
return fn;
}
function reportloader_omega(dfn, rpt_id, fn_map, omega) {
var dt_omega = strptime_vista(omega), cursor = Math.floor(omega) + 0.235959999, data = [], idmap = {};
async function fn(client, alpha) {
var dt_alpha = strptime_vista(alpha), batch;
if(cursor >= alpha) {
batch = (await client.ORWRP_REPORT_TEXT(dfn, rpt_id, '', SZ_RANGE, '', Math.floor(alpha).toFixed(9), cursor.toFixed(9))).map(fn_map).sort((a, b) => b.time - a.time);
cursor = strftime_vista(new Date(strptime_vista(Math.floor(alpha)) - 86400000)) + 0.235959999;
batch = batch.filter(x => idmap[x.id] ? console.warn('Duplicate record', x) : true);
batch.reduce((acc, val) => (acc[val.id] = val, acc), idmap);
Array.prototype.push.apply(data, batch);
}
var dt_cursor = strptime_vista(cursor), interval = data_interval(data); // lookahead
while(((batch = (await client.ORWRP_REPORT_TEXT_LONGCACHE(dfn, rpt_id, '', SZ_RANGE, '', Math.floor(strftime_vista(new Date(dt_cursor - interval))).toFixed(9), cursor.toFixed(9))).map(fn_map)).length < SZ_WINDOW) && (interval <= 86400000*SZ_RANGE)) interval *= 2;
var res = alpha !== undefined ? data_limit(data, dt_alpha, dt_omega) : [];
res.next = strftime_vista(res.dt_next = new Date(dt_cursor - interval));
return res;
}
fn.omega = omega;
return fn;
}
function reportloader_chunk(dfn, rpt_id, fn_map, omega) {
var dt_omega = strptime_vista(omega), cursor = Math.floor(omega) + 0.235959999, data = [], idmap = {}, hasmore = true;
if(rpt_id.endsWith(';')) rpt_id += SZ_WINDOW;
async function fn(client, alpha) {
if(alpha !== undefined) {
var dt_alpha = strptime_vista(alpha), batch;
while((hasmore) && (cursor >= alpha)) {
batch = (await client.ORWRP_REPORT_TEXT(dfn, rpt_id, '', SZ_RANGE, '', -1, cursor.toFixed(9))).map(fn_map).sort((a, b) => b.time - a.time);
cursor = data_endtime(batch); if(cursor) cursor = strftime_vista(cursor);
hasmore = (cursor) && (batch.length >= SZ_WINDOW);
batch = batch.filter(x => idmap[x.id] ? console.warn('Duplicate record', x) : true);
batch.reduce((acc, val) => (acc[val.id] = val, acc), idmap);
Array.prototype.push.apply(data, batch);
}
var res = data_limit(data, dt_alpha, dt_omega);
} else var res = [];
if(hasmore) { // lookahead
var batch = (await client.ORWRP_REPORT_TEXT_LONGCACHE(dfn, rpt_id, '', SZ_RANGE, '', -1, cursor.toFixed(9))).map(fn_map).sort((a, b) => b.time - a.time);
if(res.dt_next = (batch.length >= SZ_WINDOW ? data_endtime_conservative : data_endtime)(batch)) res.next = strftime_vista(res.dt_next);
}
if((!res.dt_next) && (cursor) && (data.length > 0) && ((res.length < 1) || (res[res.length - 1] !== data[data.length - 1]))) res.dt_next = strptime_vista(res.next = cursor);
return res;
}
fn.omega = omega;
return fn;
}
function collapse_lines(s) {
return s.replace(/(\S)[^\S\r\n]+\r?\n([^\s\.,\/#!$%\^&\*;:=\-_`~])/g, '$1 $2').replace(/([\.,!;])\r?\n([^\s\.,\/#!$%\^&\*;:=\-_`~])/g, '$1 $2');
}
const escape_div = document.createElement('div');
function escape_html(s) {
escape_div.textContent = s;
return escape_div.innerHTML;
}
function snippets(text, regex, replacement) {
var res = [], context = new RegExp('(?:\\S+\\s+){0,3}\\S*(' + regex.source + ')\\S*(?:\\s+\\S+){0,3}', regex.flags), match;
if(context.global) while((match = context.exec(text)) !== null) res.push(match[0].replace(regex, replacement).replace(/\s+/g, ' ').replace(/([\W_])\1{2,}/g, '$1$1'));
else if((match = context.exec(text)) !== null) res.push(match[0].replace(regex, replacement).replace(/\s+/g, ' ').replace(/([\W_])\1{2,}/g, '$1$1'));
return uniq(res);
}
export default {
components: {
Subtitle, DateRangePicker
},
props: {
client: Object,
sensitive: Boolean,
patient_dfn: String,
patient_info: Object
},
data() {
var now = new Date();
return {
dfn: null,
is_loading: false,
date_end: now,
date_begin: now,
date_next: null,
query: '',
x_query: '',
reports: create_reports(),
loaders: {},
resultsets: {},
selection: null,
observer_scroller: null,
observer_viewport: null
};
},
computed: {
resultset() {
return this.reports.map((x, i) => x.enabled ? this.resultsets[i] : null).filter(x => x).reduce((acc, val) => (Array.prototype.push.apply(acc, val), acc), []).sort((a, b) => b.time - a.time);
},
rs_filtered() {
var query = this.query.replace(/^\s+|\s+$/g, '');
if(query.length > 0) {
if(query.startsWith('"')) {
query = query.substring(1, query.length - ((query.length > 1) && (query.endsWith('"')) ? 1 : 0));
if(query.length > 0) {
query = new RegExp(query.replace(/\s+/g, ' ').split(' ').map(x => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('\\s*'), 'gims');
return this.resultset.filter(x => (x.detail) && (query.test(x.detail))).map(x => Object.assign({ snippets: snippets(x.detail, query, '<span class="highlight">$&</span>'), highlight: x.detail.replace(query, '<span class="highlight">$&</span>') }, x));
}
} else if(query.startsWith('/')) {
if(query.length > 1) {
var m = /^\/(.*)\/([a-z]*)$/.exec(query);
query = m ? new RegExp(m[1], m[2]) : new RegExp(query.substring(1), 'gims');
return this.resultset.filter(x => (x.detail) && (query.test(x.detail))).map(x => Object.assign({ snippets: snippets(x.detail, query, '<span class="highlight">$&</span>'), highlight: x.detail.replace(query, '<span class="highlight">$&</span>') }, x));
}
} else {
query = new RegExp(query.replace(/\s+/g, ' ').split(' ').map(x => '\\b' + x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('\\S*(?:\\s+\\S+){0,5}\\s+'), 'gims');
return this.resultset.filter(x => (x.detail) && (query.test(x.detail))).map(x => Object.assign({ snippets: snippets(x.detail, query, '<span class="highlight">$&</span>'), highlight: x.detail.replace(query, '<span class="highlight">$&</span>') }, x));
}
}
return this.resultset;
}
},
watch: {
rs_filtered(value) {
if((value) && (this.selection)) {
var id = this.selection.id;
for(var i = 0; i < value.length; ++i) if(value[i].id == id) return this.selection = value[i];
this.selection = null;
}
},
async selection(value) {
if(this.$refs.scroller) {
if(value) { // scroll to selected item
await this.$nextTick();
var active = this.$refs.scroller.querySelectorAll(':scope > .active');
if(active.length > 0) (Element.prototype.scrollIntoViewIfNeeded || Element.prototype.scrollIntoView).call(active[0]);
if(this.$refs.detail) { // scroll to top of detail panel
this.$refs.detail.scrollIntoView();
this.$refs.detail.scrollTop = 0;
}
} else { // scroll to topmost item
var offset = this.$refs.scroller.getBoundingClientRect().top;
for(var children = this.$refs.scroller.children, count = children.length, i = 0; i < count; ++i) if(children[i].getBoundingClientRect().top >= offset) {
await this.$nextTick();
var behavior = document.documentElement.style.scrollBehavior;
document.documentElement.style.scrollBehavior = 'auto'; // inhibit Bootstrap smooth scrolling
children[i].scrollIntoView();
document.documentElement.style.scrollBehavior = behavior;
break;
}
}
}
}
},
methods: {
strftime_vista,
datestring(date) {
return date.toLocaleDateString('sv-SE');
},
timestring(date) {
return date.toLocaleTimeString('en-GB').substring(0, 5);
},
enable(report) {
if(!report.enabled) {
var reports = this.reports;
for(var i = reports.length - 1; i >= 0; --i) reports[i].enabled = false;
report.enabled = true;
}
},
async loader_setup() {
try {
this.is_loading = true;
if((this.client) && (this.patient_dfn)) {
if(this.dfn != this.patient_dfn) {
this.dfn = this.patient_dfn;
this.loaders = {};
this.resultsets = {};
}
var dfn = this.patient_dfn, loaders = this.loaders, resultsets = this.resultsets, reports = this.reports, report, omega = strftime_vista(this.date_end), alpha = this.date_begin != this.date_end ? strftime_vista(this.date_begin) : undefined, next = [];
for(var i = 0; i < reports.length; ++i) if((report = reports[i]).enabled) {
if((!loaders[i]) || (loaders[i].omega != omega)) loaders[i] = report.loader(dfn, report.rpt_id, report.map, omega);
resultsets[i] = await loaders[i](this.client, alpha);
if(resultsets[i].next) next.push(resultsets[i].next);
}
this.date_next = next.length > 0 ? strptime_vista(Math.floor(Math.max(...next))) : null;
if(!alpha) this.date_begin = this.date_next;
} else {
this.dfn = null;
this.loaders = {};
this.resultsets = {};
}
} catch(ex) {
console.warn(ex);
} finally {
this.is_loading = false;
}
}
},
created() {
this.$watch(
() => (this.client, this.patient_dfn, this.reports.map(x => x.enabled), this.date_begin, this.date_end, {}),
debounce(() => this.loader_setup(), 500),
{ immediate: true }
);
this.$watch(
() => this.x_query,
debounce(value => this.query = value, 500),
{ immediate: true }
);
},
mounted() {
this.observer_scroller = new IntersectionObserver(([entry]) => { if((entry.isIntersecting) && (this.selection) && (this.date_next) && (!this.is_loading) && (!this.query.replace(/^\s+|\s+$/g, ''))) this.date_begin = this.date_next; }, { root: this.$refs.scroller, rootMargin: '25%' });
this.observer_scroller.observe(this.$refs.bottom);
this.observer_viewport = new IntersectionObserver(([entry]) => { if((entry.isIntersecting) && (!this.selection) && (this.date_next) && (!this.is_loading) && (!this.query.replace(/^\s+|\s+$/g, ''))) this.date_begin = this.date_next; }, { rootMargin: '25%' });
this.observer_viewport.observe(this.$refs.bottom);
},
destroyed() {
if(this.observer_viewport) this.observer_viewport.disconnect();
if(this.observer_scroller) this.observer_scroller.disconnect();
}
};
</script>

View File

@ -1,35 +1,17 @@
<template>
<div v-if="(sensitive) && (!info)" class="alert alert-danger text-center mb-3 shadow" role="alert">
<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>
<router-link class="btn btn-danger" :to="'/patient/' + dfn + '/orders?viewsensitive'">Proceed</router-link>
</div>
<div v-if="info">
<div class="card mb-3 shadow">
<div class="card-header">{{info.name}} <span :title="info.pid">{{info.pid.slice(-4)}}</span> #{{dfn}}</div>
<div class="card-body row" style="font-family: monospace;">
<div class="col" v-if="info.dob"><strong>DOB:</strong> {{strptime_vista(info.dob).toLocaleDateString('sv-SE')}}</div>
<div class="col" v-if="info.age"><strong>Age:</strong> {{info.age}}</div>
<div class="col" v-if="info.sex"><strong>Sex:</strong> {{info.sex}}</div>
<div class="col" v-if="info.sc_percentage"><strong>SC%:</strong> {{info.sc_percentage}}</div>
<div class="col" v-if="info.type"><strong>Type:</strong> {{info.type}}</div>
<div class="col" v-if="info.ward"><strong>Ward:</strong> {{info.ward}}</div>
<div class="col" v-if="info.room_bed"><strong>Room/bed:</strong> {{info.room_bed}}</div>
</div>
</div>
<Subtitle value="Visits" />
<Subtitle :value="patient_info.name" />
<div class="card mb-3 shadow">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Visits</span>
<DateRangePicker range="6M" direction="-1" v-model:date="visits_date" v-model:date_end="visits_date_begin" />
</div>
<div class="card-body"><ViewVisits :client="client" :dfn="dfn" :date_begin="visits_date_begin" :date_end="visits_date" /></div>
</div>
<div class="card-body"><ViewVisits :client="client" :dfn="patient_dfn" :date_begin="visits_date_begin" :date_end="visits_date" /></div>
</div>
</template>
<script>
import { strptime_vista } from './util.mjs';
import Subtitle from './Subtitle.vue';
import DateRangePicker from './DateRangePicker.vue';
import OrderFilterPicker from './OrderFilterPicker.vue';
import ViewVisits from './ViewVisits.vue';
@ -38,40 +20,20 @@
export default {
components: {
DateRangePicker, OrderFilterPicker, ViewVisits
Subtitle, DateRangePicker, OrderFilterPicker, ViewVisits
},
props: {
client: Object
client: Object,
sensitive: Boolean,
patient_dfn: String,
patient_info: Object
},
data() {
return {
dfn: null,
sensitive: false,
info: null,
orders_filter: 2,
visits_date: now,
visits_date_begin: now
};
},
watch: {
info(value) {
if((value) && (value.name)) document.title = value.name;
}
},
methods: {
strptime_vista,
async loadinfo(dfn, viewsensitive) {
this.dfn = dfn;
this.sensitive = viewsensitive ? false : await this.client.ORWPT_SELCHK(dfn);
this.info = this.sensitive ? null : await this.client.ORWPT16_ID_INFO(dfn);
}
},
async mounted() {
this.loadinfo(this.$route.params.id, this.$route.query.hasOwnProperty('viewsensitive'));
},
async beforeRouteUpdate(to, from, next) {
this.loadinfo(to.params.id, to.query.hasOwnProperty('viewsensitive'));
next();
}
};
</script>

48
htdocs/RoutePlanner.vue Normal file
View File

@ -0,0 +1,48 @@
<template>
<Subtitle value="Planner" />
<div class="card mb-3 shadow">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Planner</span>
<select v-if="(resourcelist_selected) && (resourcelist_selected.length > 0)" class="form-select form-select-sm" style="width: auto;" v-model="resource"><option v-for="row in resourcelist_selected" :value="row.RESOURCEID">{{row.RESOURCE_NAME}} #{{row.RESOURCEID}}</option></select>
<DateRangePicker range="1M" direction="1" v-model:date="date_begin" v-model:date_end="date_end" />
</div>
<div class="card-body"><ViewPlanner :client="client" :resource="resource" :date_begin="date_begin" :date_end="date_end" /></div>
</div>
</template>
<script>
import Subtitle from './Subtitle.vue';
import DateRangePicker from './DateRangePicker.vue';
import ViewPlanner from './ViewPlanner.vue';
export default {
components: {
Subtitle, DateRangePicker, ViewPlanner
},
props: {
client: Object
},
data() {
var now = new Date();
return {
resource: null,
date_begin: now,
date_end: now,
resourcelist_all: []
};
},
computed: {
resourcelist_selected() {
if((this.client) && (this.client.remotestate.resources) && (this.resourcelist_all)) {
var resourcemap = this.client.remotestate.resources.split(',').filter(x => x).reduce((acc, val) => (acc[val] = true, acc), {});
var res = this.resourcelist_all.filter(x => resourcemap.hasOwnProperty(x.RESOURCEID));
if((res.length > 0) && (!this.resource)) this.resource = res[0].RESOURCEID;
return res;
} else return [];
}
},
async mounted() {
this.resourcelist_all = await this.client.SDEC_RESOURCE();
}
};
</script>

View File

@ -1,14 +1,9 @@
<template>
<div>
<div class="card mb-3 shadow">
<div class="card-header">Clinics</div>
<div class="card-body">
<ViewResourceLookup :client="client" v-model:selection="selection" />
</div>
</div>
<Subtitle value="Recall" />
<div class="card mb-3 shadow">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Recall list ({{patients_lost.length + patients_outstanding.length}})</span>
<router-link to="/settings">Select clinics<template v-if="selection.length > 0"> ({{selection.length}})</template></router-link>
</div>
<div class="card-body">
<table class="table" style="font-family: monospace;" v-if="patients_lost && patients_lost.length > 0">
@ -41,13 +36,12 @@
</table>
</div>
</div>
</div>
</template>
<script>
import { groupByArray, strtr_unscramble, strHashHSL, strftime_vista, debounce } from './util.mjs';
import ViewResourceLookup from './ViewResourceLookup.vue';
import Subtitle from './Subtitle.vue';
function dateonly(date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
@ -55,7 +49,7 @@
export default {
components: {
ViewResourceLookup
Subtitle
},
props: {
client: Object
@ -71,10 +65,7 @@
};
},
computed: {
selection: {
get() { return this.client.remotestate.resources ? (this.client.remotestate.resources.split(',').filter(x => x) || []) : [] },
set(value) { this.client.remotestate.resources = value.join(','); }
},
selection() { return (this.client) && (this.client.remotestate.resources) ? (this.client.remotestate.resources.split(',').filter(x => x) || []) : [] },
patients_lost() {
return this.patients.filter(x => x.TimeLastDiff >= 0).sort((a, b) => b.TimeLastDiff - a.TimeLastDiff);
},

View File

@ -1,25 +1,19 @@
<template>
<div>
<div class="card mb-3 shadow">
<div class="card-header">Clinics</div>
<div class="card-body">
<ViewResourceLookup :client="client" v-model:selection="selection" />
</div>
</div>
<Subtitle value="Schedule" />
<div class="card mb-3 shadow">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Schedule</span>
<DateRangePicker range="1D" direction="+1" v-model:date="date" v-model:date_end="date_end" />
<router-link to="/settings">Select clinics<template v-if="selection.length > 0"> ({{selection.length}})</template></router-link>
<DateRangePicker direction="+1" v-model:date="date" v-model:date_end="date_end" v-model:range="range" />
</div>
<div class="card-body">
<ViewSchedule :client="client" :selection="selection" :date_begin="date" :date_end="new Date(date_end.getTime() - 1)" />
</div>
</div>
</div>
</template>
<script>
import ViewResourceLookup from './ViewResourceLookup.vue';
import Subtitle from './Subtitle.vue';
import DateRangePicker from './DateRangePicker.vue';
import ViewSchedule from './ViewSchedule.vue';
@ -27,9 +21,15 @@
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 {
components: {
ViewResourceLookup, DateRangePicker, ViewSchedule
Subtitle, DateRangePicker, ViewSchedule
},
props: {
client: Object
@ -37,13 +37,30 @@
data() {
return {
date: dateonly(new Date()),
date_end: dateonly(new Date())
date_end: dateonly(new Date()),
range: '1D'
};
},
computed: {
selection: {
get() { return this.client.remotestate.resources ? (this.client.remotestate.resources.split(',').filter(x => x) || []) : [] },
set(value) { this.client.remotestate.resources = value.join(','); }
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
}
}
};

View File

@ -1,93 +0,0 @@
<template>
<div>
<div class="card mb-3 shadow">
<div class="card-header">Overview</div>
<div class="card-body">
<ViewResourceLookup :client="client" v-model:selection="selection" />
</div>
</div>
<div class="card mb-3 shadow">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Daily</span>
</div>
<div class="card-body">
<table class="table" style="font-family: monospace;" v-if="appointments_daily.length > 0">
<thead>
<tr><th>Date</th><th>Count</th></tr>
</thead>
<tbody>
<tr v-for="row in appointments_daily">
<td>{{row.key}}</td>
<td>{{row.values.length}}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script>
import { groupByArray, strtr_unscramble, strHashHSL, strftime_vista, debounce } from './util.mjs';
import ViewResourceLookup from './ViewResourceLookup.vue';
function dateonly(date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}
export default {
components: {
ViewResourceLookup
},
props: {
client: Object
},
data() {
var today = dateonly(new Date());
return {
appointments: [],
production: true,
date_begin: new Date(today.getFullYear(), today.getMonth(), today.getDate()),
date_end: new Date(today.getFullYear() + 1, today.getMonth(), today.getDate()),
dow: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
};
},
computed: {
selection: {
get() { return this.client.remotestate.resources ? (this.client.remotestate.resources.split(',').filter(x => x) || []) : [] },
set(value) { this.client.remotestate.resources = value.join(','); }
},
appointments_daily() {
return groupByArray(this.appointments, x => x.ApptDateDate);
}
},
watch: {
selection: {
handler(value) { this.$nextTick(() => this.debounced_selection(value)); },
immediate: true
}
},
methods: {
strHashHSL,
strtr_unscramble,
datefmt(date) {
return date ? date.toLocaleDateString('sv-SE') : '';
}
},
created() {
this.debounced_selection = debounce(async function(value) {
this.appointments = this.selection.length > 0 ? (await this.client.SDEC_CLINLET(this.selection.join('|') + '|', strftime_vista(this.date_begin), strftime_vista(this.date_end))) : [];
this.appointments.forEach(x => {
var obj = x.ApptDateObj = new Date(x.ApptDate);
var date = x.ApptDateDate = obj.toLocaleDateString('sv-SE');
//x.ApptDateWeek = obj.getFullYear() + '-' + Math.floor(((obj - new Date(obj.getFullYear(), 0, 1))/(24*60*60*1000) + obj.getDay())/7);
//x.ApptDateMonth = obj.getFullYear() + '-' + obj.getMonth();
});
}, 500);
},
async mounted() {
this.production = (await this.client.serverinfo()).result.production == '1';
}
};
</script>

32
htdocs/RouteSettings.vue Normal file
View File

@ -0,0 +1,32 @@
<template>
<Subtitle value="Settings" />
<div class="card mb-3 shadow">
<div class="card-header">Clinics</div>
<div class="card-body">
<ViewResourceLookup :client="client" v-model:selection="selection" />
</div>
</div>
</template>
<script>
import Subtitle from './Subtitle.vue';
import ViewResourceLookup from './ViewResourceLookup.vue';
export default {
components: {
Subtitle, ViewResourceLookup
},
props: {
client: Object
},
data() {
return {};
},
computed: {
selection: {
get() { return (this.client) && (this.client.remotestate.resources) ? (this.client.remotestate.resources.split(',').filter(x => x) || []) : [] },
set(value) { if(this.client) this.client.remotestate.resources = value.join(','); }
}
},
};
</script>

32
htdocs/Submenu.vue Normal file
View File

@ -0,0 +1,32 @@
<script>
import { menustate } from './common.mjs';
export default {
props: ['value'],
data() {
return { ptr: null };
},
watch: {
value: {
handler(value) {
this.update(value);
}, immediate: true
}
},
methods: {
update(value) {
var idx = this.ptr ? menustate.indexOf(this.ptr) : -1;
if(idx >= 0) menustate.splice(idx, 1);
if(value) {
this.ptr = value;
if(idx >= 0) menustate.splice(idx, 0, this.ptr);
else menustate.unshift(this.ptr);
} else this.ptr = null;
}
},
unmounted() {
this.update();
},
render() {}
};
</script>

41
htdocs/Subtitle.vue Normal file
View File

@ -0,0 +1,41 @@
<script>
import { debounce } from './util.mjs';
const base = 'nuVistA';
const state = [{ value: base }];
const settitle = debounce(s => document.title = s || base, 0);
export default {
props: ['value'],
data() {
return { ptr: null };
},
watch: {
value: {
handler(value) {
this.update(value);
}, immediate: true
}
},
methods: {
update(value) {
if(value) {
if(this.ptr) this.ptr.value = value;
else {
this.ptr = { value };
state.unshift(this.ptr);
}
} else if(this.ptr) {
var idx = state.indexOf(this.ptr);
if(idx >= 0) state.splice(idx, 1);
this.ptr = null;
}
settitle(state.map(x => x.value).join(' - '));
}
},
unmounted() {
this.update();
},
render() {}
};
</script>

57
htdocs/Throbber.vue Normal file
View File

@ -0,0 +1,57 @@
<template>
<div class="throbber" :class="{ connected, idle }" />
</template>
<style scoped>
div.throbber {
position: fixed;
top: 59px;
right: 0;
left: 0;
height: 0.5rem;
z-index: 1030;
background-color: #dc3545;
}
div.throbber.connected {
background-color: #0d6efd;
background-image: repeating-linear-gradient(
-45deg,
rgba(255, 255, 255, 0.1),
rgba(255, 255, 255, 0.1) 10px,
rgba(0, 0, 0, 0.1) 10px,
rgba(0, 0, 0, 0.1) 20px
);
background-size: 200% 200%;
animation: barberpole 60s linear infinite;
animation-direction: reverse;
}
div.throbber.idle {
visibility: hidden;
opacity: 0;
transition: visibility 0s 0.5s, opacity 0.5s linear;
}
@keyframes barberpole {
100% {
background-position: 100% 100%;
}
}
</style>
<script>
export default {
props: {
client: {
type: Object,
default: null
}
},
computed: {
connected() {
return (this.client) && (this.client.status) && (this.client.status.connected);
},
idle() {
return this.connected ? this.client.status.busy < 1 : false;
}
}
};
</script>

View File

@ -177,7 +177,7 @@
if((satisfied) && (updated)) {
item = calculation.calc(...calculation.deps.map(x => history[x].value), history[calculation.name] && history[calculation.name].value);
if((item !== undefined) && (item !== null) && (item === item) && (item != 'NaN')) { // item === item if not NaN
results.push(history[calculation.name] = update[calculation.name] = 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';
else if((item.hasOwnProperty('rangeH')) && (item.value > item.rangeH)) item.flag = 'H';
}
@ -226,7 +226,7 @@
},
watch: {
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: {

177
htdocs/ViewDocEdit.vue Normal file
View File

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

79
htdocs/ViewDocNew.vue Normal file
View File

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

View File

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

98
htdocs/ViewDocView.vue Normal file
View File

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

View File

@ -42,14 +42,14 @@
client: Object,
dfn: String,
label: String,
modelValue: String
modelValue: Object
},
emits: {
'update:modelValue': Object
},
data() {
return {
view_new: true,
view_new: false,
query: '',
visits_date_begin: now,
visits_date_end: now,

View File

@ -1,6 +1,6 @@
<template>
<table v-if="resultset.length > 0" class="table table-striped">
<tbody>
<table class="table table-striped">
<tbody v-if="resultset.length > 0">
<tr v-for="item in resultset" :class="{ 'table-active': (x_modelValue) && (x_modelValue.IEN) && (x_modelValue.datetime) && (item.location_ien == x_modelValue.IEN) && (item.datetime == x_modelValue.datetime) }" @click="x_modelValue = { IEN: item.location_ien, location: item.location, datetime: item.datetime, appointment_ien: item.IEN }">
<td>{{item.location}}</td>
<td>#{{item.location_ien}}</td>
@ -8,6 +8,9 @@
<td style="text-align: right;">{{item.datestr}} {{item.timestr}}</td>
</tr>
</tbody>
<tfoot v-else>
<tr><td style="text-align: center;">No encounters in range</td></tr>
</tfoot>
</table>
</template>

View File

@ -40,7 +40,7 @@
methods: {
async submit(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 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);
@ -52,8 +52,8 @@
() => (this.client, this.ien, {}),
async () => {
if((this.client) && (this.ien)) {
var userinfo = await this.client.userinfo();
var user_ien = userinfo && userinfo.result ? userinfo.result[0] : '';
var authinfo = await this.client.authinfo();
var user_ien = authinfo && authinfo.success ? authinfo.duz : '';
this.dlgname = await this.client.ORWDXM_DLGNAME(this.ien);
if(this.dlgname.BaseDialogIEN != this.ien) console.warn('IEN =', this.ien, '|', 'BaseDialogIEN =', this.dlgname.BaseDialogIEN);
this.dlgdef = await this.client.ORWDX_DLGDEF(this.dlgname.BaseDialogName);

View File

@ -48,7 +48,7 @@
created() {
this.query_sync = debounce(async function(value) {
this.query_view = value = value.replace(/^\s+|\s+$/g, '').replace(/\s+/g, ' ');
this.resultset = value ? (await this.client.ORWPT16_LOOKUP(value)) : [];
this.resultset = (value) && (value.length >= 3) ? (await this.client.ORWPT16_LOOKUP(value)) : [];
}, 500);
},
async mounted() {

134
htdocs/ViewPlanner.vue Normal file
View File

@ -0,0 +1,134 @@
<template>
<table v-if="(resultset) && (resultset.length > 0)" class="table">
<thead>
<tr><th v-for="x in dow">{{x}}</th></tr>
</thead>
<tbody>
<tr v-for="week in resultset">
<td v-for="day in [0, 1, 2, 3, 4, 5, 6]" class="datebox">
<template v-if="week.values[day]">
<router-link :to="'/schedule/' + week.values[day][0]._START_OBJ.toLocaleDateString('sv-SE')" custom v-slot="{ navigate }">
<div class="datebox linked" :style="{ backgroundColor: resultset.max > 0 ? 'rgba(220, 53, 69, ' + week.values[day].length/resultset.max + ')' : null }" @click="navigate"><span class="occupancy hidden">#{{week.values[day].length}}</span> {{day > 0 ? week.values[day][0]._START_OBJ.getDate() : week.key.toLocaleDateString('sv-SE')}} <span class="occupancy">#{{week.values[day].length}}</span></div>
</router-link>
<template v-for="appointment in week.values[day]">
<div v-if="appointment._BREAK" class="vacancy" :title="appointment._START_OBJ.toLocaleTimeString('en-GB').substring(0, 5) + '' + appointment._END_OBJ.toLocaleTimeString('en-GB').substring(0, 5)" />
<div v-else :title="appointment._START_OBJ.toLocaleTimeString('en-GB').substring(0, 5) + '' + appointment._END_OBJ.toLocaleTimeString('en-GB').substring(0, 5) + '\n' + appointment.PATIENTNAME + ' ' + appointment.HRN.slice(-4) + '\n' + appointment.NOTE"><span v-if="appointment._CONCURRENCY > 0" class="concurrency hidden">*<template v-if="appointment._CONCURRENCY > 1">{{appointment._CONCURRENCY}}</template></span>{{appointment._START_OBJ.toLocaleTimeString('en-GB').substring(0, 5)}} <router-link :to="'/patient/' + appointment.PATIENTID">{{appointment.PATIENTNAME.substring(0, 1)}}{{appointment.HRN.slice(-4)}}</router-link><span v-if="appointment._CONCURRENCY > 0" class="concurrency">*<template v-if="appointment._CONCURRENCY > 1">{{appointment._CONCURRENCY}}</template></span></div>
</template>
</template>
<div v-else class="datebox">{{day > 0 ? (new Date(week.key.getTime() + 1000*60*60*24*day)).getDate() : week.key.toLocaleDateString('sv-SE')}}</div>
</td>
</tr>
</tbody>
</table>
</template>
<style scoped>
table {
font-family: monospace;
text-align: center;
}
tbody, td {
border: 1px solid #dee2e6;
}
div.datebox {
cursor: default;
font-weight: bold;
}
div.datebox.linked {
cursor: pointer;
}
div.vacancy {
width: 2em;
margin: auto;
border-bottom: 0.25em dotted #bbb;
}
span.occupancy {
font-weight: normal;
}
span.concurrency {
font-weight: bold;
}
span.hidden {
visibility: hidden;
}
</style>
<script>
import { groupBy, groupByArray, strfdate_vista, debounce } from './util.mjs';
const C_DAY = 1000*60*60*24;
const C_WEEK = C_DAY*7;
function dateonly(date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}
function infill_weeks(res, date_begin, date_end) {
for(var i = res.length - 1; i > 0; --i) {
var cur = res[i], prev = res[i - 1];
while(cur.key - prev.key > C_WEEK) res.splice(i, 0, cur = { key: new Date(cur.key.getTime() - C_WEEK), values: [] });
}
var item;
if(res.length < 1) res.push({ key: date_begin, values: [] });
else {
item = res[0];
while(item.key - date_begin >= C_WEEK) res.splice(0, 0, item = { key: new Date(item.key.getTime() - C_WEEK), values: [] });
}
item = res[res.length - 1];
while(date_end - item.key >= C_WEEK) res.push(item = { key: new Date(item.key.getTime() + C_WEEK), values: [] });
return res;
}
function analyze_week(res) {
for(var k in res) if(res.hasOwnProperty(k)) analyze_day(res[k]);
return res;
}
function analyze_day(res) {
for(var i = res.length - 1; i > 0; --i) {
var item = res[i];
item._CONCURRENCY = 0;
for(var j = i - 1; j >= 0; --j) if(item._START_OBJ < res[j]._END_OBJ) item._CONCURRENCY++;
if((item._CONCURRENCY < 1) && (item._START_OBJ > res[i - 1]._END_OBJ)) res.splice(i, 0, { _BREAK: true, _START_OBJ: res[i - 1]._END_OBJ, _END_OBJ: item._START_OBJ });
}
return res;
}
export default {
props: {
client: Object,
resource: String,
date_begin: Date,
date_end: Date
},
data() {
var now = new Date();
return {
resultset: [],
dow: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
};
},
created() {
this.$watch(
() => (this.client, this.resource, this.date_begin, this.date_end, {}),
debounce(async () => {
if((this.client) && (this.resource) && (this.date_begin) && (this.date_end)) {
var date_begin = new Date(this.date_begin.getTime() - C_DAY*this.date_begin.getDay()), weekref = date_begin;
var date_end = new Date(this.date_end.getTime() + C_DAY*(6 - this.date_end.getDay()));
var resultset = (await this.client.SDEC_CRSCHED(this.resource, strfdate_vista(date_begin), strfdate_vista(date_end) + '@2359')).filter(x => (x.CANCELLED == '0') && (x.NOSHOW == '0')).map(x => {
var _START_OBJ = x._START_OBJ = new Date(x.START_TIME);
x._START_DATE = _START_OBJ.toLocaleDateString('sv-SE');
x._END_OBJ = new Date(x.END_TIME);
x._WEEK_DAY = _START_OBJ.getDay();
x._WEEK_NUM = Math.floor((x._START_OBJ - weekref)/(C_WEEK));
return x;
}).sort((a, b) => a._START_OBJ - b._START_OBJ);
this.resultset = infill_weeks(groupByArray(resultset, '_WEEK_NUM').map(week => ({ key: new Date(weekref.getTime() + C_WEEK*week.key), values: analyze_week(groupBy(week.values, '_WEEK_DAY')) })), date_begin, date_end);
this.resultset.max = Math.max.apply(null, groupByArray(resultset, '_START_DATE').map(x => x.values.length));
} else this.resultset = [];
}, 500),
{ immediate: true }
);
}
};
</script>

View File

@ -1,30 +1,77 @@
<template>
<template v-if="age !== undefined">
<div v-if="age == Infinity" class="alert alert-danger"> update error</div>
<div v-else-if="age >= 90000" class="alert alert-warning">⚠ last updated <template v-if="age < 3600000">{{ts.toLocaleString()}}</template><template v-else>{{ts.toLocaleString()}}</template></div>
</template>
<div v-if="tag_list.length > 0">
<span v-if="filter_array.length > 0" class="tag badge bg-danger" @click="filter = {}">🗑{{filter_array.length}}</span><span v-else class="tag badge">🏷</span>
<span v-for="key in tag_list" class="tag badge" :style="{ color: filter[key] ? null : 'rgba(var(--bs-dark-rgb), var(--bs-text-opacity))', backgroundColor: filter[key] ? strHashHSL(key, '50%') : null }" @click="filter[key] ? delete filter[key] : filter[key] = true">{{key.toUpperCase()}}</span>
</div>
<datalist :id="'datalist-' + uid"><option v-for="item in practitioner_list" :value="item" /></datalist>
<table class="table" style="font-family: monospace;" v-if="appointments && appointments.length > 0">
<thead>
<tr><th>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>
<tbody>
<tr v-for="row in appointments" :style="{ backgroundColor: strHashHSL(row.Clinic, '90%') }">
<td>{{row.ApptDate}}</td>
<td>{{row.Clinic}}</td>
<td v-if="production"><router-link :to="'/patient/$' + row.HRN">{{row.Name}} <span :title="row.HRN">{{row.HRN.slice(-4)}}</span></router-link></td>
<td v-else><router-link :title="strtr_unscramble(row.Name)" :to="'/patient/$' + row.Name.charAt(0) + row.HRN.slice(-4) + '?name=' + row.Name">{{row.Name}} ${{row.HRN}}</router-link></td>
<td>{{row.NOTE}} [{{row.APPT_MADE_BY}} on {{row.DATE_APPT_MADE}}]</td>
<td><Autocomplete :modelValue="practitioner[row.Name]" @update:modelValue="x => practitioner[row.Name] = x" :items="practitioner_list" /></td>
<tbody class="striped">
<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-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.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>{{row.RESOURCENAME}}</td>
<td><router-link :to="'/patient/' + row.PATIENTID">{{row.PATIENTNAME}} <span :title="row.HRN">{{row.HRN.slice(-4)}}</span></router-link> <span v-if="row.SENSITIVE != '0'" class="emoji"></span></td>
<td>{{row.NOTE}}<span v-for="(value, key) in tag_map[row.APPOINTMENTID]" class="tag badge" :style="{ backgroundColor: strHashHSL(key, '50%') }" @click="filter[key] = true">{{value}}</span></td>
<td><input class="form-control" :list="'datalist-' + uid" :value="practitioner[row.PATIENTNAME]" @input="e => practitioner[row.PATIENTNAME] = e.target.value" /></td>
</tr>
</tbody>
<caption style="text-align: center;">{{appointments.length || 'no'}} appointment{{appointments.length != 1 ? 's' : ''}} <template v-if="date_begin.toLocaleDateString('sv-SE') == date_end.toLocaleDateString('sv-SE')">on {{date_begin.toLocaleDateString('sv-SE')}}</template><template v-else>from {{date_begin.toLocaleDateString('sv-SE')}} to {{date_end.toLocaleDateString('sv-SE')}}</template></caption>
</table>
</template>
<script>
import { uniq, strtr_unscramble, strHashHSL, strfdate_vista, debounce } from './util.mjs';
<style scoped>
.striped {
background-image: repeating-linear-gradient(
-45deg,
rgba(255, 255, 255, 0.1),
rgba(255, 255, 255, 0.1) 10px,
rgba(0, 0, 0, 0.1) 10px,
rgba(0, 0, 0, 0.1) 20px
);
}
.voided {
opacity: 0.8;
}
.date {
font-size: 80%;
}
.emoji {
font-family:
"Twemoji Mozilla",
"Apple Color Emoji",
"Segoe UI Emoji",
"Segoe UI Symbol",
"Noto Color Emoji",
"EmojiOne Color",
"Android Emoji",
sans-serif;
}
.badge.tag {
cursor: pointer;
margin-right: 0.35em;
}
</style>
import Autocomplete from './Autocomplete.vue';
<script>
import { uniq, strHashHSL, strfdate_vista, debounce } from './util.mjs';
function clearTimeouts(timers) {
if(timers.length > 1) console.warn('Clearing multiple timeouts', timers.slice());
for(var i = 0; i < timers.length; ++i) window.clearTimeout(timers[i]);
timers.length = 0;
}
export default {
components: {
Autocomplete
},
props: {
client: Object,
selection: {
@ -36,13 +83,40 @@
},
data() {
return {
uid: Math.random()*0x7fffffff|0,
appointments: [],
production: true
timers: [],
ts: null,
age: undefined,
filter: {}
};
},
computed: {
params() {
return { selection: this.selection, date_begin: this.date_begin, date_end: this.date_end };
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() {
return Object.keys(this.filter).sort();
},
practitioner() {
return this.client.remotestate.practitioner || (this.client.remotestate.practitioner = {});
@ -51,20 +125,39 @@
return this.practitioner ? uniq(Object.values(this.practitioner).filter(x => x)).sort() : [];
}
},
watch: {
params(value) {
this.debounced_params(value);
}
},
methods: {
strHashHSL,
strtr_unscramble
filter_conj(tags) {
var filter_array = this.filter_array;
for(var i = this.filter_array.length - 1; i >= 0; --i) if(!tags[this.filter_array[i]]) return false;
return true;
},
created() {
this.debounced_params = debounce(async function(value) { this.appointments = value.selection.length > 0 ? (await this.client.SDEC_CLINLET(value.selection.join('|') + '|', strfdate_vista(value.date_begin), strfdate_vista(value.date_end))).sort((a, b) => (new Date(a.ApptDate)) - (new Date(b.ApptDate))) : []; }, 500);
async update() {
clearTimeouts(this.timers);
try {
this.appointments = (await this.client.SDEC_CRSCHED(this.selection.join('|') + '|', strfdate_vista(this.date_begin), strfdate_vista(this.date_end) + '@2359')).sort((a, b) => (new Date(a.START_TIME)) - (new Date(b.START_TIME)));
var now = new Date();
this.ts = this.appointments._ts ? new Date(1000*this.appointments._ts) : now;
this.age = now - this.ts;
this.timers.push(window.setTimeout(this.update, Math.max(60000 - this.age, 10000)));
} catch(ex) {
this.age = this.ts ? (new Date()) - this.ts : Infinity;
console.warn(ex);
this.timers.push(window.setTimeout(this.update, 30000));
}
}
},
async mounted() {
this.production = (await this.client.serverinfo()).result.production == '1';
mounted() {
this.$watch(
() => (this.client, this.selection, this.date_begin, this.date_end, {}),
debounce(async () => {
this.filter = {};
this.update();
}, 500)
);
},
unmounted() {
clearTimeouts(this.timers);
}
};
</script>

131
htdocs/ViewUserLookup.vue Normal file
View File

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

View File

@ -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: 'BSA', unit: 'm²', deps: ['Ht', 'Wt'], calc: (Ht, Wt) => (0.007184*Math.pow(Ht, 0.725)*Math.pow(Wt, 0.425)).toPrecision(3) },
{ name: 'CrCl', unit: 'mL/min', deps: ['Age', 'Sex', 'Wt', 'CREATININE'], calc: (Age, Sex, Wt, CREATININE) => (((140 - Age) * Wt)/(72*CREATININE)*(Sex == 'M' ? 1 : 0.85)).toPrecision(4) },
{ name: 'RETICYLOCYTE#', unit: 'K/cmm', rangeL: 50, rangeH: 100, range: '50 - 100', deps: ['RBC', 'RETICULOCYTES'], calc: (RBC, RETICULOCYTES) => (10*RBC*RETICULOCYTES).toPrecision(3) }
{ name: '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 = [
@ -30,9 +32,9 @@
{ 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: '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: '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: '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) {
return rs.map(function(x) {
var comment = x.comment && x.comment.replace(/^\s+|\s+$/g, '').replace(/\s+/g, ' ');
var res = {
time: x.datetime,
name: x.name,
unit: x.unit,
value: x.value,
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;
});

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

File diff suppressed because one or more lines are too long

View 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>

View File

@ -0,0 +1,59 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>TIFF</title>
<style type="text/css">
figure {
border: 1px solid #000;
}
figcaption {
border-bottom: 1px solid #000;
padding: 0.25em;
background-color: #ccc;
}
</style>
</head>
<body>
<script type="text/javascript" src="/adapter/UTIF.js"></script>
<script type="text/javascript">
function request(method, url, responseType) {
return new Promise(function(resolve, reject) {
var xhr = new XMLHttpRequest();
xhr.open(method, url);
if(responseType) xhr.responseType = responseType;
xhr.onload = resolve;
xhr.onerror = reject;
xhr.send();
});
}
function createElementX(tag, attrs) {
var res = document.createElement(tag);
if(attrs) for(var k in attrs) if(attrs.hasOwnProperty(k)) res.setAttribute(k, attrs[k]);
for(var i = 2, child; i < arguments.length; ++i) res.appendChild((typeof (child = arguments[i]) === 'string') || (child instanceof String) ? document.createTextNode(child) : child);
return res;
}
var pathname = window.location.pathname, filename = document.title = pathname.split('/').pop();
var body = document.body;
var header = document.createElement('div');
header.append(createElementX('a', { href: pathname }, 'Download ' + filename))
body.appendChild(header);
request('GET', pathname, 'arraybuffer').then(function(evt) {
var filedata = evt.target.response;
var cnv = document.createElement('canvas'), ctx = cnv.getContext('2d'), img;
var pages = UTIF.decode(filedata), page;
for(var i = 0; i < pages.length; ++i) {
UTIF.decodeImage(filedata, page = pages[i], pages);
var rgba = UTIF.toRGBA8(page);
ctx.putImageData(new ImageData(new Uint8ClampedArray(rgba.buffer), cnv.width = page.width, cnv.height = page.height), 0, 0);
body.appendChild(createElementX('figure', null, createElementX('figcaption', null, 'Page ' + (i + 1) + ' of ' + pages.length), img = createElementX('img', { src: cnv.toDataURL() })));
img.style.width = '100%';
}
})
</script>
</body>
</html>

3
htdocs/common.mjs Normal file
View File

@ -0,0 +1,3 @@
import { reactive } from 'vue';
export const menustate = reactive([]);

113
htdocs/icon.svg Normal file
View File

@ -0,0 +1,113 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="48mm"
height="48mm"
viewBox="0 0 48 48"
version="1.1"
id="svg5"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs2">
<filter
style="color-interpolation-filters:sRGB"
id="filter3763"
x="-0.78863951"
y="-0.88395267"
width="2.577279"
height="2.7679053">
<feFlood
flood-opacity="0.498039"
flood-color="rgb(0,0,0)"
result="flood"
id="feFlood3753" />
<feComposite
in="flood"
in2="SourceGraphic"
operator="in"
result="composite1"
id="feComposite3755" />
<feGaussianBlur
in="composite1"
stdDeviation="3.3734374"
result="blur"
id="feGaussianBlur3757" />
<feOffset
dx="0"
dy="0"
result="offset"
id="feOffset3759" />
<feComposite
in="SourceGraphic"
in2="offset"
operator="over"
result="fbSourceGraphic"
id="feComposite3761" />
<feColorMatrix
result="fbSourceGraphicAlpha"
in="fbSourceGraphic"
values="0 0 0 -1 0 0 0 0 -1 0 0 0 0 -1 0 0 0 0 1 0"
id="feColorMatrix3765" />
<feFlood
id="feFlood3767"
flood-opacity="0.498039"
flood-color="rgb(0,0,0)"
result="flood"
in="fbSourceGraphic" />
<feComposite
in2="fbSourceGraphic"
id="feComposite3769"
in="flood"
operator="in"
result="composite1" />
<feGaussianBlur
id="feGaussianBlur3771"
in="composite1"
stdDeviation="6"
result="blur" />
<feOffset
id="feOffset3773"
dx="0"
dy="0"
result="offset" />
<feComposite
in2="offset"
id="feComposite3775"
in="fbSourceGraphic"
operator="over"
result="composite2" />
</filter>
</defs>
<g
id="layer1"
transform="translate(-33.302987,-22.535447)">
<rect
style="fill:#003f72;fill-opacity:1;stroke-width:0.762"
id="rect325"
width="48"
height="48"
x="33.302986"
y="22.535448"
ry="8" />
<text
xml:space="preserve"
style="font-style:italic;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:50.8px;line-height:1.25;font-family:Georgia;-inkscape-font-specification:'Georgia Bold Italic';letter-spacing:0px;word-spacing:0px;stroke-width:0.264583"
x="55.709675"
y="62.096775"
id="text2458"><tspan
id="tspan2456"
style="font-style:italic;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:50.8px;font-family:Georgia;-inkscape-font-specification:'Georgia Bold Italic';stroke-width:0.264583"
x="55.709675"
y="62.096775" /></text>
<text
xml:space="preserve"
style="font-size:50.8px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#f3cf45;fill-opacity:1;stroke-width:0.264583;filter:url(#filter3763)"
x="42.581402"
y="58.962597"
id="text2466"><tspan
id="tspan2464"
style="font-style:italic;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:50.8px;font-family:Georgia;-inkscape-font-specification:'Georgia Bold Italic';fill:#f3cf45;fill-opacity:1;stroke-width:0.264583"
x="42.581402"
y="58.962597">ν</tspan></text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@ -7,6 +7,7 @@
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/bootstrap@5.1/dist/css/bootstrap.min.css" />
<link rel="stylesheet" type="text/css" href="/table-sticky.css" />
<link rel="stylesheet" type="text/css" href="/userstyle.css" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
</head>
<body><div id='root'></div></body>
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/vue@3.2"></script>

View File

@ -53,15 +53,15 @@ function lab_parse1default(data) {
else x.comment = [line.substring(12)];
} 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+)\])?)?$/)) {
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);
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.value = m.groups.value;
}
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);
for(var k in x) if(x[k]) x[k] = x[k] ? x[k].replace(/^\s+|\s+$/g, '') : undefined;
} else if(line.startsWith(' [')) {
@ -95,19 +95,55 @@ function lab_parse1default(data) {
value: x = (results.hasOwnProperty('SEGS') ? +results.SEGS.value : 0) + (results.hasOwnProperty('BANDS') ? +results.BANDS.value : 0),
flag: (x < 42.2 ? 'L' : x > 75.2 ? 'H' : undefined)
});
results.push(results['NEUTROPHIL#'] = {
if(results.WBC) results.push(results['NEUTROPHIL#'] = {
name: 'NEUTROPHIL#', unit: 'K/cmm', range: '1.4 - 6.5',
value: +(x = 0.01*x*results.WBC.value).toFixed(3),
flag: (x < 1.4 ? 'L' : x > 6.5 ? 'H' : undefined)
});
}
if(results.hasOwnProperty('EOSINO')) {
results.push(results['EOSINOPHIL%'] = {
name: 'EOSINOPHIL%', unit: '%', range: '0.0 - 10.0',
value: x = +results.EOSINO.value,
flag: (x < 0 ? 'L' : x > 10 ? 'H' : undefined)
});
if(results.WBC) results.push(results['EOSINOPHIL#'] = {
name: 'EOSINOPHIL#', unit: 'K/cmm', range: '0.0 - 0.7',
value: +(x = 0.01*x*results.WBC.value).toFixed(3),
flag: (x < 0 ? 'L' : x > 0.7 ? 'H' : undefined)
});
}
if(results.hasOwnProperty('BASO')) {
results.push(results['BASOPHIL%'] = {
name: 'BASOPHIL%', unit: '%', range: '0.0 - 2.0',
value: x = +results.BASO.value,
flag: (x < 0 ? 'L' : x > 2 ? 'H' : undefined)
});
if(results.WBC) results.push(results['BASOPHIL#'] = {
name: 'BASOPHIL#', unit: 'K/cmm', range: '0.0 - 0.2',
value: +(x = 0.01*x*results.WBC.value).toFixed(3),
flag: (x < 0 ? 'L' : x > 0.2 ? 'H' : undefined)
});
}
if(results.hasOwnProperty('MONOS')) {
results.push(results['MONOCYTE%'] = {
name: 'MONOCYTE%', unit: '%', range: '1.7 - 9.3',
value: x = +results.MONOS.value,
flag: (x < 1.7 ? 'L' : x > 9.3 ? 'H' : undefined)
});
if(results.WBC) results.push(results['MONOCYTE#'] = {
name: 'MONOCYTE#', unit: 'K/cmm', range: '0.11 - 0.59',
value: +(x = 0.01*x*results.WBC.value).toFixed(3),
flag: (x < 0.11 ? 'L' : x > 0.59 ? 'H' : undefined)
});
}
if((results.hasOwnProperty('LYMPHS')) || (results.hasOwnProperty('ATYPICAL LYMPHOCYTES'))) {
results.push(results['LYMPHOCYTE%'] = {
name: 'LYMPHOCYTE%', unit: '%', range: '15.0 - 41.0',
value: x = (results.hasOwnProperty('LYMPHS') ? +results.LYMPHS.value : 0) + (results.hasOwnProperty('ATYPICAL LYMPHOCYTES') ? +results['ATYPICAL LYMPHOCYTES'].value : 0),
flag: (x < 15 ? 'L' : x > 41 ? 'H' : undefined)
});
results.push(results['LYMPHOCYTE#'] = {
if(results.WBC) results.push(results['LYMPHOCYTE#'] = {
name: 'LYMPHOCYTE#', unit: 'K/cmm', range: '1.2 - 3.4',
value: +(x = 0.01*x*results.WBC.value).toFixed(3),
flag: (x < 1.2 ? 'L' : x > 3.4 ? 'H' : undefined)
@ -159,12 +195,27 @@ export function measurement_parse(data) {
res.name = row.substring(idx + 3, idx = row.indexOf(': ', idx));
value = row.substring(idx + 4, idx = row.indexOf(' _', idx));
res.user = row.substring(idx + 2);
m = value.match(/^(?:(.*?)(?: (\S+))?)(\*)?(?: \((?:(.*?)(?: (\S+))?)\))?\s*$/);
res.value = m[4] ? m[4] : m[1];
res.unit = m[4] ? m[5] : m[2];
res.flag = m[3];
res.value_american = m[4] ? m[1] : m[4];
res.unit_american = m[4] ? m[2] : m[5];
if(m = value.match(/(?:^(?<value>[\d\.\/%]+)(?: (?<unit>\w+) \((?<value2>[\d\.\/%]+) (?<unit2>\w+)\))?(?<flag>\*)? (?: (?<comment>.*))?$)|(?:^(?<value3>[\d\.\/%]+)(?<flag3>\*)?\s*(?<comment3>.*)$)/)) {
if(m.groups.value2) {
res.value = m.groups.value2;
res.unit = m.groups.unit2;
res.value_american = m.groups.value;
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) == '%') {
res.unit = '%';
res.value = res.value.substring(0, res.value.length - 1);
@ -174,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: 'DBP', range: '60 - 80', unit: 'mmHg', value: bpsplit[1] });
}
}
return res;
}
}).filter(x => x);

View File

@ -217,7 +217,7 @@ export function TplFS(client, parent, desc) {
}
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]);
};

View File

@ -1,3 +1,8 @@
export const comp = (...fs) => x0 => fs.reduceRight((x, f) => f(x), x0);
export const flow = (...fs) => x0 => fs.reduce((x, f) => f(x), x0);
export const pipe = (x0, ...fs) => fs.reduce((x, f) => f(x), x0);
export const aflow = (f0, ...fs) => async (...args) => fs.reduce((x, f) => f(x), await f0(...args));
export function uniq(xs) {
var seen = {};
return xs.filter(x => seen.hasOwnProperty(x) ? false : (seen[x] = true));

View File

@ -14,19 +14,11 @@ export async function close(cid) {
})).json();
}
export async function call(cid, method, ...params) {
export async function call(cid, body) {
return await (await fetch('/v1/vista/' + cid, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ method: method, params: params, id: Date.now() })
})).json();
}
export async function callctx(cid, context, method, ...params) {
return await (await fetch('/v1/vista/' + cid, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ method: method, params: params, context: context, id: Date.now() })
body: JSON.stringify(body)
})).json();
}
@ -38,8 +30,8 @@ export async function serverinfo(cid) {
})).json();
}
export async function userinfo(cid) {
return await (await fetch('/v1/vista/' + cid + '/userinfo', {
export async function authinfo(cid) {
return await (await fetch('/v1/vista/' + cid + '/authinfo', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}'
@ -55,5 +47,5 @@ export async function authenticate(cid, avcode=null) {
}
export default window.vista = {
connect, close, call, callctx, serverinfo, userinfo, authenticate
connect, close, call, serverinfo, authinfo, authenticate
};

View File

@ -2,7 +2,7 @@ import { reactive, watch } from 'vue';
import vista from './vista.mjs';
import cookie from './cookie.mjs';
import { debounce } from './util.mjs';
import { debounce, pipe, aflow } from './util.mjs';
import { lab_parse, lab_reparse_results, measurement_parse, orderinfo_parse, orderoverrides_parse, orderoptions_parse } from './reportparser.mjs';
import { TplFS, EncFS, randpassword as tplfs_randpassword } from './tplfs.mjs';
@ -21,21 +21,226 @@ function RPCError(type, ...args) {
}
RPCError.prototype = Object.create(Error.prototype);
export function logged(fn, name) {
return async function(...args) {
var res = await fn(...args);
console.log(name, ...args, res);
export const d_log = data => (console.log(data._request.method, ...(data._request.params || []), '=>', data), data);
export const d_unwrap = data => {
if(data.error) throw new RPCError(data.error.type, ...data.error.args);
if(data.ts) try {
data.result._ts = data.ts;
if(data.cached) data.result._cached = data.cached;
} catch(ex) {}
return data.result;
};
export const f_emit = (fn=console.log, ...args) => data => (fn(...args, data), data);
export const f_split = (delimiter='^', ...columns) => columns.length > 0 ? data => data.map(row => columns.reduce((acc, val, idx) => (acc[val] = acc[idx], acc), row.split(delimiter))) : data => data.map(row => row.split(delimiter));
export const f_split1 = (delimiter='^', ...columns) => columns.length > 0 ? data => columns.reduce((acc, val, idx) => (acc[val] = acc[idx], acc), data.split(delimiter)) : data => data.split(delimiter);
export const d_split = (data, delimiter='^', ...columns) => columns.length > 0 ? data.map(row => columns.reduce((acc, val, idx) => (acc[val] = acc[idx], acc), row.split(delimiter))) : data.map(row => row.split(delimiter));
export const d_split1 = (data, delimiter='^', ...columns) => columns.length > 0 ? columns.reduce((acc, val, idx) => (acc[val] = acc[idx], acc), data.split(delimiter)) : data.split(delimiter);
export const f_slice = (start, end) => data => data.slice(start, end);
export const f_key = (key='id') => typeof key === 'function' ? data => data.reduce((acc, val) => (acc[key(val)] = val, acc), data) : data => data.reduce((acc, val) => (acc[val[key]] = val, acc), data);
export const d_parse_boolean = data => data != '0';
export const d_parse_text = data => data !== '' ? data.join('\r\n') : data;
export const d_parse_array = data => data !== '' ? data : [];
export const d_parse_authinfo = data => data ? { duz: data[0] != '0' ? data[0] : null, device_lock: data[1] != '0', change_verify: data[2] != '0', message: data[3], reserved: data[4], greeting_lines: data[5], greeting: data.slice(6), success: (data[0] != '0') && (data[2] == '0') } : { success: false }
export const d_parse_imagelist = data => {
var descriptor = d_split1(data[0], '^', 'success', 'filter', 'more'), res;
if(descriptor.success == '1') {
var headers = d_split1(data[1], '^');
res = data.slice(2).map(function(row) {
row = row.split('|');
var values = headers.reduce((acc, val, idx) => (acc[val] = acc[idx], acc), row[0].split('^'))
values.Info = d_split1(row[1], '^', 'IEN', 'Image Path', 'Abstract Path', 'Short Desc', 'Procedure Time', 'Object Type', 'Procedure', 'Display Date', 'Pointer', 'Abs Type', 'Availability', 'DICOM Series', 'DICOM Image', 'Count', 'Site IEN', 'Site', 'Error', 'BIGPath', 'Patient DFN', 'Patient Name', 'Image Class', 'Cap Dt', 'Document Date', 'Group IEN', 'Group Ch1', 'RPC Server', 'RPC Port', 'Controlled Image', 'Viewable Status', 'Status', 'Image Annotated', 'Image TIU Note Completed', 'Annotation Operation Status', 'Annotation Operation Status Description', 'Package');
values.Info['Group Ch1'] = values.Info['Group Ch1'] ? d_split1(':', 'IEN', 'Type') : null
return values;
});
res.descriptor = descriptor;
} else {
res = data.slice(1);
res.descriptor = descriptor;
}
return res;
};
export const d_parse_imageinfo = data => {
var res = d_split1(data[0], '^', 'Code', 'IEN', 'Image Path', 'Abstract Path', 'Short Desc', 'Procedure Time', 'Object Type', 'Procedure', 'Display Date', 'Pointer', 'Abs Type', 'Availability', 'DICOM Series', 'DICOM Image', 'Count', 'Site IEN', 'Site', 'Error', 'BIGPath', 'Patient DFN', 'Patient Name', 'Image Class', 'Cap Dt', 'Document Date', 'Group IEN', 'Group Ch1', 'RPC Server', 'RPC Port', 'Controlled Image', 'Viewable Status', 'Status', 'Image Annotated', 'Image TIU Note Completed', 'Annotation Operation Status', 'Annotation Operation Status Description', 'Package');
res.Patient = d_split1(data[1], '^', 'IEN', 'name');
return res;
};
export const d_parse_imagegroup = data => {
var res = d_split(data.slice(1), '^', 'B2', 'IEN', 'Image Path', 'Abstract Path', 'Short Desc', 'Procedure Time', 'Object Type', 'Procedure', 'Display Date', 'Pointer', 'Abs Type', 'Availability', 'DICOM Series', 'DICOM Image', 'Count', 'Site IEN', 'Site', 'Error', 'BIGPath', 'Patient DFN', 'Patient Name', 'Image Class', 'Cap Dt', 'Document Date', 'Group IEN', 'Group Ch1', 'RPC Server', 'RPC Port', 'Controlled Image', 'Viewable Status', 'Status', 'Image Annotated', 'Image TIU Note Completed', 'Annotation Operation Status', 'Annotation Operation Status Description', 'Package');
res.Result = d_split1(data[0], '^', 'code', 'count');
return res;
};
export const d_parse_orderdialogs = (data, columns=['IEN', 'windowFormId', 'displayGroupId', 'type', 'displayText']) => data.map(function(row) {
row = row.split('^');
row = [...row[0].split(';'), row[1]];
return columns.reduce((acc, val, idx) => (acc[val] = acc[idx], acc), row);
});
export const d_parse_ordermenu = data => {
var res = d_split1(data[0], '^', 'name', 'columns', 'path_switch');
res.children = d_split(data.slice(1), '^', 'col', 'row', 'type', 'IEN', 'formid', 'autoaccept', 'display_text', 'mnemonic', 'displayonly');
return res;
};
export const f_parse_caretseparated_detail = (columns, detailcolumn) => {
if(!detailcolumn) detailcolumn = 'detail';
return columns ? data => {
var res = [], item = {};
for(var i = 0; i < data.length; ++i) {
var row = data[i], prefix = row.charAt(0);
if(prefix == '~') res.push(item = columns.reduce((acc, val, idx) => (acc[val] = acc[idx], acc), row.substring(1).split('^')));
else if(prefix == 't') {
if(item[detailcolumn]) item[detailcolumn] += '\r\n' + data[i].substring(1);
else item[detailcolumn] = data[i].substring(1);
}
}
return res;
} : data => {
var res = [], item = {};
for(var i = 0; i < data.length; ++i) {
var row = data[i], prefix = row.charAt(0);
if(prefix == '~') res.push(item = row.substring(1).split('^'));
else if(prefix == 't') {
if(item[detailcolumn]) item[detailcolumn] += '\r\n' + data[i].substring(1);
else item[detailcolumn] = data[i].substring(1);
}
}
return res;
}
}
};
export function unwrapped(fn) {
return async function(...args) {
var res = await fn(...args);
if(res.error) throw new RPCError(res.error.type, ...res.error.args);
else return res.result;
export const d_parse_orderoptions_scheduling = data => {
var res = orderoptions_parse(data);
for(var k in res) if(res.hasOwnProperty(k)) {
if(res[k].items) res[k].items = res[k].items.split('^');
res['~' + k.toUpperCase()] = res[k];
}
return res;
};
export const d_parse_orderoptions_labfacility = data => {
var res = orderoptions_parse(data), val, defaultvalue;
for(var k in res) if(res.hasOwnProperty(k)) {
val = res[k];
if(val.default) {
val.default = d_split1(val.default, '^', 'value', 'text');
defaultvalue = val.default.value;
} else defaultvalue = null;
if(val.items) val.items = val.items.split('\r\n').map(x => x ? (x = d_split1(x, '^', 'value', 'text'), x.default = x.value == defaultvalue, x) : null);
res['~' + k.toUpperCase()] = val;
}
return res;
};
export const d_parse_orderoptions_labtest = data => {
var res = orderoptions_parse(data), val, defaultvalue;
if(res.hasOwnProperty('Test Name')) res['Test Name'].default = res['Test Name'].default.replace(/^\s+|\s+$/, '');
if(res.hasOwnProperty('Item ID')) res['Item ID'].default = d_split1(res['Item ID'].default, '^', 'value', 'text');
if(res.hasOwnProperty('ReqCom')) res['ReqCom'].default = res['ReqCom'].default.replace(/^\s+|\s+$/, '');
if(res.hasOwnProperty('CollSamp')) res['CollSamp'].items = res['CollSamp'].items.split('\r\n').map(x => d_split1(x, '^', 'n', 'SampIEN', 'SampName', 'SpecPtr', 'TubeTop', 'unk_5', 'unk_6', 'LabCollect', 'unk_8', 'SpecName'));
res['Derived CollSamp'] = res['Unique CollSamp'] || res['Lab CollSamp'] || res['Default CollSamp'];
if(res.hasOwnProperty('Specimens')) res['Specimens'].items = res['Specimens'].items.split('\r\n').map(x => d_split1(x, '^', 'value', 'text'));
if(res.hasOwnProperty('Default Urgency')) res['Default Urgency'].default = res['Default Urgency'].default.split('\r\n').map(x => d_split1(x, '^', 'value', 'text', 'x'));
if(res.hasOwnProperty('Urgencies')) res['Urgencies'].items = res['Urgencies'].items.split('\r\n').map(x => d_split1(x, '^', 'value', 'text'));
return res;
};
export const d_parse_orderoptions_medfill = data => {
var res = orderoptions_parse(data);
if(res.hasOwnProperty('Pickup')) {
if(res['Pickup'].default) res['Pickup'].default = d_split1(res['Pickup'].default, '^', 'value', 'text');
if(res['Pickup'].items) res['Pickup'].items = d_split(res['Pickup'].items.split('\r\n'), '^', 'value', 'text');
}
if(res.hasOwnProperty('Priority')) {
if(res['Priority'].default) res['Priority'].default = d_split1(res['Priority'].default, '^', 'value', 'text');
if(res['Priority'].items) res['Priority'].items = d_split(res['Priority'].items.split('\r\n'), '^', 'value', 'text');
}
if(res.hasOwnProperty('Refills')) {
if(res['Refills'].default) res['Refills'].default = d_split1(res['Refills'].default, '^', 'value', 'text');
if(res['Refills'].items) res['Refills'].items = d_split(res['Refills'].items.split('\r\n'), '^', 'value', 'text');
}
return res;
};
export const d_parse_orderoptions_meddose = data => {
var res = orderoptions_parse(data);
if(res.hasOwnProperty('AllDoses')) res['AllDoses'].items = d_split(res['AllDoses'].items.split('\r\n'), '^', 'text', 'id', 'dosefields');
if(res.hasOwnProperty('Dispense')) res['Dispense'].items = d_split(res['Dispense'].items.split('\r\n'), '^', 'id', 'dose', 'unit', 'text', 'split');
if(res.hasOwnProperty('Dosage')) res['Dosage'].items = d_split(res['Dosage'].items.split('\r\n'), '^', 'medication', '_', '_', 'value', 'text', 'tier', '_', 'form');
if(res.hasOwnProperty('Indication')) res['Indication'].items = res['Indication'].items.split('\r\n');
if((res.hasOwnProperty('Medication')) && (res['Medication'].default)) res['Medication'].default = d_split1(res['Medication'].default, '^', 'value', 'text');
if(res.hasOwnProperty('Route')) {
if(res['Route'].default) res['Route'].default = d_split1(res['Route'].default, '^', 'value', 'abbr');
res['Route'].items = d_split(res['Route'].items.split('\r\n'), '^', 'value', 'text', 'abbr', 'sig', '_');
}
return res;
};
export const d_parse_notifications_fastuser = data => d_split(data, '^', 'info', 'patient', 'location', 'urgency', 'time', 'message', 'unk_6', 'meta', 'unk_7', 'unk_8').map(row => {
if(row.meta) {
var meta = row.meta.split(';');
var xqaid = row.meta_xqaid = meta[0];
row.meta_duz = meta[1];
row.meta_time = +meta[2];
if(xqaid.startsWith('OR,')) {
xqaid = xqaid.split(',');
row.meta_source = xqaid[0];
row.meta_dfn = xqaid[1];
row.meta_ien = xqaid[2]; // file 100.9 IEN
} else if(xqaid.startsWith('TIU')) {
row.meta_source = 'TIU';
row.meta_ien = xqaid.substring(3); // document IEN (DA)
}
}
return row;
});
export const d_parse_multireport = data => {
if(data.length < 1) return [];
var brk, max = 0, grp, _ts = data._ts;
for(var i = 0; i < data.length; ++i) {
brk = (grp = data[i]).indexOf('^');
if(brk >= 0) {
grp = +grp.substring(0, brk);
if(grp >= max) max = grp;
else break;
} else return (data = [data], data._ts = _ts, data);
}
var res = [], data = data.slice(), max = max + '^', grp = x => x.startsWith(max);
while(((brk = data.findIndex(grp)) >= 0) || (brk = data.length)) res.push(data.splice(0, brk + 1));
return (res._ts = _ts, res);
};
export const d_parse_tiurecordtiux = data => {
var res = {};
if(data.length < 1) return res;
var brk = data.indexOf('$TXT'), text = undefined;
if(brk >= 0) {
text = data.slice(brk + 1).join('\r\n');
data = data.slice(0, brk);
}
data = d_split(data, '^', 'field', 'value', 'description');
data = data.reduce((acc, val) => (acc['~' + val.field] = val, acc), data);
if(text) data.text = text;
return data;
};
export const d_parse_tiudocumentlist = data => d_split(data, '^', 'IEN', 'title', 'time', 'patient', 'author', 'location', 'status', 'visit').map(row => {
row.author = row.author ? d_split1(row.author, ';', 'IEN', 'byline', 'name') : null;
row.visit = row.visit ? d_split1(row.visit, ';', 'date', 'time') : null;
return row;
});
export function memoized(fn) {
var cache = {};
@ -45,259 +250,37 @@ export function memoized(fn) {
}
}
export function converted_boolean(fn, columns=null) {
return async function(...args) {
return await fn(...args) == '1';
}
}
export function parsed_nullarray(fn) {
return async function(...args) {
var res = await fn(...args);
return res !== '' ? res : [];
}
}
export function parsed_text(fn) {
return async function(...args) {
var res = await fn(...args);
return res !== '' ? res.join('\r\n') : res;
}
}
function parse_caretseparated(rows, columns) {
return rows.map(function(row) {
row = row.split('^');
for(var i = columns.length - 1; i >= 0; --i) if(columns[i]) row[columns[i]] = row[i];
return row;
});
}
function parse_caretseparated1(row, columns) {
var res = row.split('^');
if(columns) for(var i = columns.length - 1; i >= 0; --i) if(columns[i]) res[columns[i]] = res[i];
return res;
}
export function caretseparated(fn, columns=null) {
return async function(...args) {
if(columns) return (await fn(...args)).map(function(row) {
row = row.split('^');
for(var i = columns.length - 1; i >= 0; --i) if(columns[i]) row[columns[i]] = row[i];
return row;
});
else return (await fn(...args)).map(function(row) { return row.split('^'); });
}
}
export function caretseparated1(fn, columns=null) {
return async function(...args) {
var res = (await fn(...args)).split('^');
if(columns) for(var i = columns.length - 1; i >= 0; --i) if(columns[i]) res[columns[i]] = res[i];
return res;
}
}
function parsed_caretseparated_detail(fn, columns, detailcolumn) {
if(!columns) columns = [];
if(!detailcolumn) detailcolumn = 'detail';
return columns ? async function(...args) {
var res = [], item = {}, rows = await fn(...args);
for(var i = 0; i < rows.length; ++i) {
var row = rows[i], prefix = row.charAt(0);
if(prefix == '~') {
item = row.substring(1).split('^');
for(var j = columns.length - 1; j >= 0; --j) if(columns[j]) item[columns[j]] = item[j];
res.push(item);
} else if(prefix == 't') {
if(item[detailcolumn]) item[detailcolumn] += '\r\n' + rows[i].substring(1);
else item[detailcolumn] = rows[i].substring(1);
}
}
return res;
} : async function(...args) {
var res = [], item = {}, rows = await fn(...args);
for(var i = 0; i < rows.length; ++i) {
var row = rows[i], prefix = row.charAt(0);
if(prefix == '~') res.push(item = row.substring(1).split('^'));
else if(prefix == 't') {
if(item[detailcolumn]) item[detailcolumn] += '\r\n' + rows[i].substring(1);
else item[detailcolumn] = rows[i].substring(1);
}
}
return res;
}
}
export function sliced(fn, start, end) {
return async function(...args) {
return (await fn(...args)).slice(start, end);
}
}
export function mapped(fn, id='id') {
if(typeof id === 'function') return async function(...args) {
var res = await fn(...args);
for(var i = res.length - 1; i >= 0; --i) res[id(res[i])] = res[i];
return res;
};
else return async function(...args) {
var res = await fn(...args);
for(var i = res.length - 1; i >= 0; --i) res[res[i][id]] = res[i];
return res;
};
}
export function labreportparsed(fn) {
return async function(...args) {
return lab_parse(await fn(...args));
}
}
const parsed_orderdialogs_columns = ['IEN', 'windowFormId', 'displayGroupId', 'type', 'displayText'];
export function parsed_orderdialogs(fn, columns=parsed_orderdialogs_columns) {
return async function(...args) {
return (await fn(...args)).map(function(row) {
row = row.split('^');
row = [...row[0].split(';'), row[1]];
for(var i = columns.length - 1; i >= 0; --i) if(columns[i]) row[columns[i]] = row[i];
return row;
});
}
}
export function parsed_orderoverrides(fn) {
return async function(...args) {
return orderoverrides_parse(await fn(...args));
}
}
export function parsed_ordermenu(fn) {
return async function(...args) {
var resultset = await fn(...args);
var res = parse_caretseparated1(resultset[0], ['name', 'columns', 'path_switch']);
res.children = parse_caretseparated(resultset.slice(1), ['col', 'row', 'type', 'IEN', 'formid', 'autoaccept', 'display_text', 'mnemonic', 'displayonly']);
return res;
}
}
export function parsed_orderinfo(fn) {
return async function(...args) {
return orderinfo_parse(await fn(...args));
}
}
export function parsed_orderoptions_scheduling(fn) {
return async function(...args) {
var res = orderoptions_parse(await fn(...args));
for(var k in res) if(res.hasOwnProperty(k)) {
if(res[k].items) res[k].items = res[k].items.split('^');
res['~' + k.toUpperCase()] = res[k];
}
return res;
}
}
export function parsed_orderoptions_labfacility(fn) {
return async function(...args) {
var res = orderoptions_parse(await fn(...args)), val, defaultvalue;
for(var k in res) if(res.hasOwnProperty(k)) {
val = res[k];
if(val.default) {
val.default = parse_caretseparated1(val.default, ['value', 'text']);
defaultvalue = val.default.value;
} else defaultvalue = null;
if(val.items) val.items = val.items.split('\r\n').map(x => x ? (x = parse_caretseparated1(x, ['value', 'text']), x.default = x.value == defaultvalue, x) : null);
res['~' + k.toUpperCase()] = val;
}
return res;
}
}
export function parsed_orderoptions_labtest(fn) {
return async function(...args) {
var res = orderoptions_parse(await fn(...args)), val, defaultvalue;
if(res.hasOwnProperty('Test Name')) res['Test Name'].default = res['Test Name'].default.replace(/^\s+|\s+$/, '');
if(res.hasOwnProperty('Item ID')) res['Item ID'].default = parse_caretseparated1(res['Item ID'].default, ['value', 'text']);
if(res.hasOwnProperty('ReqCom')) res['ReqCom'].default = res['ReqCom'].default.replace(/^\s+|\s+$/, '');
if(res.hasOwnProperty('CollSamp')) res['CollSamp'].items = res['CollSamp'].items.split('\r\n').map(x => parse_caretseparated1(x, ['n', 'SampIEN', 'SampName', 'SpecPtr', 'TubeTop', 'unk_5', 'unk_6', 'LabCollect', 'unk_8', 'SpecName']));
res['Derived CollSamp'] = res['Unique CollSamp'] || res['Lab CollSamp'] || res['Default CollSamp'];
if(res.hasOwnProperty('Specimens')) res['Specimens'].items = res['Specimens'].items.split('\r\n').map(x => parse_caretseparated1(x, ['value', 'text']));
if(res.hasOwnProperty('Default Urgency')) res['Default Urgency'].default = res['Default Urgency'].default.split('\r\n').map(x => parse_caretseparated1(x, ['value', 'text', 'x']));
if(res.hasOwnProperty('Urgencies')) res['Urgencies'].items = res['Urgencies'].items.split('\r\n').map(x => parse_caretseparated1(x, ['value', 'text']));
return res;
}
}
export function parsed_orderoptions_medfill(fn) {
return async function(...args) {
var res = orderoptions_parse(await fn(...args));
if(res.hasOwnProperty('Pickup')) {
if(res['Pickup'].default) res['Pickup'].default = parse_caretseparated1(res['Pickup'].default, ['value', 'text']);
if(res['Pickup'].items) res['Pickup'].items = parse_caretseparated(res['Pickup'].items.split('\r\n'), ['value', 'text']);
}
if(res.hasOwnProperty('Priority')) {
if(res['Priority'].default) res['Priority'].default = parse_caretseparated1(res['Priority'].default, ['value', 'text']);
if(res['Priority'].items) res['Priority'].items = parse_caretseparated(res['Priority'].items.split('\r\n'), ['value', 'text']);
}
if(res.hasOwnProperty('Refills')) {
if(res['Refills'].default) res['Refills'].default = parse_caretseparated1(res['Refills'].default, ['value', 'text']);
if(res['Refills'].items) res['Refills'].items = parse_caretseparated(res['Refills'].items.split('\r\n'), ['value', 'text']);
}
return res;
}
}
export function parsed_orderoptions_meddose(fn) {
return async function(...args) {
var res = orderoptions_parse(await fn(...args));
if(res.hasOwnProperty('AllDoses')) res['AllDoses'].items = parse_caretseparated(res['AllDoses'].items.split('\r\n'), ['text', 'id', 'dosefields']);
if(res.hasOwnProperty('Dispense')) res['Dispense'].items = parse_caretseparated(res['Dispense'].items.split('\r\n'), ['id', 'dose', 'unit', 'text', 'split']);
if(res.hasOwnProperty('Dosage')) res['Dosage'].items = parse_caretseparated(res['Dosage'].items.split('\r\n'), ['medication', '_', '_', 'value', 'text', 'tier', '_', 'form']);
if(res.hasOwnProperty('Indication')) res['Indication'].items = res['Indication'].items.split('\r\n');
if((res.hasOwnProperty('Medication')) && (res['Medication'].default)) res['Medication'].default = parse_caretseparated1(res['Medication'].default, ['value', 'text']);
if(res.hasOwnProperty('Route')) {
if(res['Route'].default) res['Route'].default = parse_caretseparated1(res['Route'].default, ['value', 'abbr']);
res['Route'].items = parse_caretseparated(res['Route'].items.split('\r\n'), ['value', 'text', 'abbr', 'sig', '_']);
}
return res;
}
}
export function tabulated(fn, mapping) {
return async function(...args) {
var res = (await fn(...args)).map(function(row) { return row.slice(); }), nrow = res.length;
for(var i = 0; i < nrow; ++i) {
var row = res[i], ncol = row.length;
for(var j = 0; j < ncol; ++j) if(mapping.hasOwnProperty(j)) row[mapping[j]] = row[j];
res.push()
}
return res;
}
}
export function Client(cid, secret) {
var heartbeat = null;
this.secret = secret;
this.cid = cid;
this.connected = reactive({ value: true });
this.status = reactive({ connected: true, busy: 0 });
this.close = function() {
console.log('CLOSE', cid);
if(heartbeat) window.clearInterval(heartbeat);
this.connected.value = false;
this.status.connected = false;
return vista.close(cid);
};
this.call = async function(method, ...params) {
var res = await vista.call(cid, method, ...params);
this.call = async function(body, ...params) {
body = (typeof body === 'string') || (body instanceof String) ? { method: body, params, id: Date.now() } : Object.assign({ id: Date.now() }, body);
if(params.length > 0) {
if(body.params) Array.prototype.push.apply(body.params, params);
else body.params = params;
}
this.status.busy++;
try {
var res = await vista.call(cid, body);
} finally {
this.status.busy--;
}
if((res.error) && (res.error.type == 'ConnectionResetError')) this.close();
res._request = body;
return res;
};
this.callctx = async function(context, method, ...params) {
var res = vista.callctx(cid, context, method, ...params);
if((res.error) && (res.error.type == 'ConnectionResetError')) this.close();
return res;
this.callctx = function(context, method, ...params) {
return this.call({ context, method, params, id: Date.now() });
};
this.heartbeat = async function(interval=null) {
if(!interval) interval = 0.45*1000*(await this.XWB_GET_BROKER_INFO())[0];
@ -306,11 +289,11 @@ export function Client(cid, secret) {
return heartbeat = window.setInterval(this.XWB_IM_HERE, interval);
}
this.serverinfo = () => vista.serverinfo(cid);
this.userinfo = () => vista.userinfo(cid);
this.authenticate = (avcode=null) => vista.authenticate(cid, avcode);
this.authinfo = aflow(() => vista.authinfo(cid), d_unwrap, d_parse_authinfo);
this.authenticate = aflow((avcode=null) => vista.authenticate(cid, avcode), d_unwrap, d_parse_authinfo);
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.remotestate = reactive({});
@ -332,75 +315,105 @@ export function Client(cid, secret) {
if(localstate.practitioner) delete localstate.practitioner;
};
this.XWB_IM_HERE = unwrapped(logged(() => this.call('XWB_IM_HERE'), 'XWB_IM_HERE'));
this.XWB_IM_HERE = aflow(() => this.call({ method: 'XWB_IM_HERE', ttl: 30, stale: false }), d_log, d_unwrap);
this.XUS_INTRO_MSG = memoized(unwrapped(logged(() => this.callctx(['XUCOMMAND'], 'XUS_INTRO_MSG'), 'XUS_INTRO_MSG')));
this.XWB_GET_BROKER_INFO = memoized(unwrapped(logged(() => this.callctx(['XUCOMMAND'], 'XWB_GET_BROKER_INFO'), 'XWB_GET_BROKER_INFO')));
this.XUS_GET_USER_INFO = memoized(unwrapped(logged(() => this.call('XUS_GET_USER_INFO'), 'XUS_GET_USER_INFO')));
this.XUS_INTRO_MSG = memoized(aflow(() => this.callctx(['XUCOMMAND'], 'XUS_INTRO_MSG'), d_log, d_unwrap));
this.XWB_GET_BROKER_INFO = memoized(aflow(() => this.call({ method: 'XWB_GET_BROKER_INFO', context: ['XUCOMMAND'], ttl: 0, stale: false }), d_log, d_unwrap));
this.XUS_GET_USER_INFO = memoized(aflow(() => this.call({ method: 'XUS_GET_USER_INFO', ttl: 0, stale: false }), d_log, d_unwrap));
this.SDEC_RESOURCE = memoized(unwrapped(logged(() => this.callctx(['SDECRPC'], 'SDEC_RESOURCE'), 'SDEC_RESOURCE')));
this.SDEC_CLINLET = memoized(unwrapped(logged((...args) => this.callctx(['SDECRPC'], 'SDEC_CLINLET', ...args), 'SDEC_CLINLET')));
this.SDEC_RESOURCE = memoized(aflow(() => this.call({ method: 'SDEC_RESOURCE', context: ['SDECRPC'], ttl: 2592000, stale: true }), d_log, d_unwrap));
this.SDEC_CLINLET = aflow((...args) => this.call({ method: 'SDEC_CLINLET', context: ['SDECRPC'], ttl: 30, stale: false }, ...args), d_log, d_unwrap);
this.SDEC_CRSCHED = aflow((...args) => this.call({ method: 'SDEC_CRSCHED', context: ['SDECRPC'], ttl: 30, stale: false }, ...args), d_log, d_unwrap);
this.ORWPT_FULLSSN = memoized(caretseparated(unwrapped(logged((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWPT_FULLSSN', ...args), 'ORWPT_FULLSSN')), ['dfn', 'name', 'date', 'pid']));
this.ORWPT_LAST5 = memoized(caretseparated(unwrapped(logged((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWPT_LAST5', ...args), 'ORWPT_LAST5')), ['dfn', 'name', 'date', 'pid']));
this.ORWPT_ID_INFO = memoized(caretseparated1(unwrapped(logged((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWPT_ID_INFO', ...args), 'ORWPT_ID_INFO')), ['pid', 'dob', 'sex', 'vet', 'sc_percentage', 'ward', 'room_bed', 'name']));
this.ORWPT_SELCHK = memoized(converted_boolean(unwrapped(logged((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWPT_SELCHK', ...args), 'ORWPT_SELCHK'))));
this.ORWPT16_LOOKUP = memoized(caretseparated(unwrapped(logged((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWPT16_LOOKUP', ...args), 'ORWPT16_LOOKUP')), ['dfn', 'name', 'pid']));
this.ORWPT16_ID_INFO = memoized(caretseparated1(unwrapped(logged((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWPT16_ID_INFO', ...args), 'ORWPT16_ID_INFO')), ['pid', 'dob', 'age', 'sex', 'sc_percentage', 'type', 'ward', 'room_bed', 'name']));
this.ORWPT_FULLSSN = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWPT_FULLSSN', ...args), d_log, d_unwrap, f_split('^', 'dfn', 'name', 'date', 'pid')));
this.ORWPT_LAST5 = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWPT_LAST5', ...args), d_log, d_unwrap, f_split('^', 'dfn', 'name', 'date', 'pid')));
this.ORWPT_ID_INFO = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWPT_ID_INFO', ...args), d_log, d_unwrap, f_split1('^', 'pid', 'dob', 'sex', 'vet', 'sc_percentage', 'ward', 'room_bed', 'name')));
this.ORWPT_SELCHK = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWPT_SELCHK', ...args), d_log, d_unwrap, d_parse_boolean));
this.ORWPT16_LOOKUP = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWPT16_LOOKUP', ...args), d_log, d_unwrap, f_split('^', 'dfn', 'name', 'pid')));
this.ORWPT16_ID_INFO = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWPT16_ID_INFO', ...args), d_log, d_unwrap, f_split1('^', 'pid', 'dob', 'age', 'sex', 'sc_percentage', 'type', 'ward', 'room_bed', 'name')));
this.ORQQVI_VITALS = memoized(caretseparated(unwrapped(logged((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORQQVI_VITALS', ...args), 'ORQQVI_VITALS')), ['measurement_ien', 'type', 'value', 'datetime', 'value_american', 'value_metric']));
this.ORQQVI_VITALS_FOR_DATE_RANGE = memoized(caretseparated(unwrapped(logged((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORQQVI_VITALS_FOR_DATE_RANGE', ...args), 'ORQQVI_VITALS_FOR_DATE_RANGE')), ['measurement_ien', 'type', 'value', 'datetime']));
this.ORQQVI_VITALS = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORQQVI_VITALS', ...args), d_log, d_unwrap, f_split('^', 'measurement_ien', 'type', 'value', 'datetime', 'value_american', 'value_metric')));
this.ORQQVI_VITALS_FOR_DATE_RANGE = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORQQVI_VITALS_FOR_DATE_RANGE', ...args), d_log, d_unwrap, f_split('^', 'measurement_ien', 'type', 'value', 'datetime')));
this.GMV_EXTRACT_REC = memoized(async (dfn, oredt, orsdt) => measurement_parse(await unwrapped(logged((...args0) => this.callctx(['OR CPRS GUI CHART'], 'GMV_EXTRACT_REC', args0.join('^')), 'GMV_EXTRACT_REC'))(dfn, oredt, '', orsdt)));
this.GMV_EXTRACT_REC = async (dfn, oredt, orsdt) => pipe(await this.call({ method: 'GMV_EXTRACT_REC', context: ['OR CPRS GUI CHART'], ttl: 60, stale: false }, `${dfn}^${oredt}^^${orsdt}`), d_log, d_unwrap, measurement_parse);
this.ORWLRR_INTERIM = memoized(labreportparsed(unwrapped(logged((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWLRR_INTERIM', ...args), 'ORWLRR_INTERIM'))));
this.ORWLRR_INTERIM_RESULTS = memoized(async (...args) => lab_reparse_results(await this.ORWLRR_INTERIM(...args)));
this.ORWLRR_INTERIM = aflow((...args) => this.call({ method: 'ORWLRR_INTERIM', context: ['OR CPRS GUI CHART'], ttl: 60, stale: false }, ...args), d_log, d_unwrap, lab_parse);
this.ORWLRR_INTERIM_RESULTS = async (...args) => lab_reparse_results(await this.ORWLRR_INTERIM(...args));
this.ORWORDG_ALLTREE = memoized(caretseparated(unwrapped(logged(() => this.callctx(['OR CPRS GUI CHART'], 'ORWORDG_ALLTREE'), 'ORWORDG_ALLTREE')), ['ien', 'name', 'parent', 'has_children']));
this.ORWORDG_REVSTS = memoized(caretseparated(unwrapped(logged(() => this.callctx(['OR CPRS GUI CHART'], 'ORWORDG_REVSTS'), 'ORWORDG_REVSTS')), ['ien', 'name', 'parent', 'has_children']));
this.ORWORR_AGET = memoized(caretseparated(sliced(unwrapped(logged((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWORR_AGET', ...args), 'ORWORR_AGET')), 1), ['ifn', 'dgrp', 'time']));
this.ORWORR_GET4LST = memoized(parsed_orderinfo(unwrapped(logged((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWORR_GET4LST', ...args), 'ORWORR_GET4LST'))));
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.TIU_TEMPLATE_GETROOTS = caretseparated(unwrapped(logged((...args) => this.callctx(['OR CPRS GUI CHART'], 'TIU_TEMPLATE_GETROOTS', ...args), 'TIU_TEMPLATE_GETROOTS')), ['IEN', 'type', 'status', 'name', 'exclude_from_group_boilerplate', 'blank_lines', 'personal_owner', 'has_children_flag', 'dialog', 'display_only', 'first_line', 'one_item_only', 'hide_dialog_items', 'hide_tree_items', 'indent_items', 'reminder_dialog_ien', 'reminder_dialog_name', 'locked', 'com_object_pointer', 'com_object_parameter', 'link_pointer', 'reminder_dialog_patient_specific_value']);
this.TIU_TEMPLATE_GETPROOT = caretseparated(unwrapped(logged((...args) => this.callctx(['OR CPRS GUI CHART'], 'TIU_TEMPLATE_GETPROOT', ...args), 'TIU_TEMPLATE_GETPROOT')), ['IEN', 'type', 'status', 'name', 'exclude_from_group_boilerplate', 'blank_lines', 'personal_owner', 'has_children_flag', 'dialog', 'display_only', 'first_line', 'one_item_only', 'hide_dialog_items', 'hide_tree_items', 'indent_items', 'reminder_dialog_ien', 'reminder_dialog_name', 'locked', 'com_object_pointer', 'com_object_parameter', 'link_pointer', 'reminder_dialog_patient_specific_value']);
this.TIU_TEMPLATE_GETBOIL = parsed_text(unwrapped(logged((...args) => this.callctx(['OR CPRS GUI CHART'], 'TIU_TEMPLATE_GETBOIL', ...args), 'TIU_TEMPLATE_GETBOIL')));
this.TIU_TEMPLATE_GETITEMS = caretseparated(parsed_nullarray(unwrapped(logged((...args) => this.callctx(['OR CPRS GUI CHART'], 'TIU_TEMPLATE_GETITEMS', ...args), 'TIU_TEMPLATE_GETITEMS'))), ['IEN', 'type', 'status', 'name', 'exclude_from_group_boilerplate', 'blank_lines', 'personal_owner', 'has_children_flag', 'dialog', 'display_only', 'first_line', 'one_item_only', 'hide_dialog_items', 'hide_tree_items', 'indent_items', 'reminder_dialog_ien', 'reminder_dialog_name', 'locked', 'com_object_pointer', 'com_object_parameter', 'link_pointer', 'reminder_dialog_patient_specific_value']);
this.TIU_TEMPLATE_SET_ITEMS = unwrapped(logged((...args) => this.callctx(['OR CPRS GUI CHART'], 'TIU_TEMPLATE_SET_ITEMS', ...args), 'TIU_TEMPLATE_SET_ITEMS'));
this.TIU_TEMPLATE_CREATE_MODIFY = unwrapped(logged((...args) => this.callctx(['OR CPRS GUI CHART'], 'TIU_TEMPLATE_CREATE/MODIFY', ...args), 'TIU_TEMPLATE_CREATE/MODIFY'));
this.TIU_TEMPLATE_DELETE = unwrapped(logged((...args) => this.callctx(['OR CPRS GUI CHART'], 'TIU_TEMPLATE_DELETE', ...args), 'TIU_TEMPLATE_DELETE'));
this.TIU_TEMPLATE_LOCK = unwrapped(logged((...args) => this.callctx(['OR CPRS GUI CHART'], 'TIU_TEMPLATE_LOCK', ...args), 'TIU_TEMPLATE_LOCK'));
this.TIU_TEMPLATE_UNLOCK = unwrapped(logged((...args) => this.callctx(['OR CPRS GUI CHART'], 'TIU_TEMPLATE_UNLOCK', ...args), 'TIU_TEMPLATE_UNLOCK'));
this.ORWORDG_ALLTREE = memoized(aflow(() => this.callctx(['OR CPRS GUI CHART'], 'ORWORDG_ALLTREE'), d_log, d_unwrap, f_split('^', 'ien', 'name', 'parent', 'has_children')));
this.ORWORDG_REVSTS = memoized(aflow(() => this.callctx(['OR CPRS GUI CHART'], 'ORWORDG_REVSTS'), d_log, d_unwrap, f_split('^', 'ien', 'name', 'parent', 'has_children')));
this.ORWORR_AGET = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWORR_AGET', ...args), d_log, d_unwrap, f_slice(1), f_split('^', 'ifn', 'dgrp', 'time')));
this.ORWORR_GET4LST = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWORR_GET4LST', ...args), d_log, d_unwrap, orderinfo_parse));
this.ORWCV_VST = memoized(caretseparated(unwrapped(logged((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWCV_VST', ...args), 'ORWCV_VST')), ['apptinfo', 'datetime', 'location', 'status']));
this.TIU_TEMPLATE_GETROOTS = aflow((...args) => this.call({ method: 'TIU_TEMPLATE_GETROOTS', context: ['OR CPRS GUI CHART'], ttl: 86400, stale: true }, ...args), d_log, d_unwrap, f_split('^', 'IEN', 'type', 'status', 'name', 'exclude_from_group_boilerplate', 'blank_lines', 'personal_owner', 'has_children_flag', 'dialog', 'display_only', 'first_line', 'one_item_only', 'hide_dialog_items', 'hide_tree_items', 'indent_items', 'reminder_dialog_ien', 'reminder_dialog_name', 'locked', 'com_object_pointer', 'com_object_parameter', 'link_pointer', 'reminder_dialog_patient_specific_value'));
this.TIU_TEMPLATE_GETPROOT = aflow((...args) => this.call({ method: 'TIU_TEMPLATE_GETPROOT', context: ['OR CPRS GUI CHART'], ttl: 86400, stale: true }, ...args), d_log, d_unwrap, f_split('^', 'IEN', 'type', 'status', 'name', 'exclude_from_group_boilerplate', 'blank_lines', 'personal_owner', 'has_children_flag', 'dialog', 'display_only', 'first_line', 'one_item_only', 'hide_dialog_items', 'hide_tree_items', 'indent_items', 'reminder_dialog_ien', 'reminder_dialog_name', 'locked', 'com_object_pointer', 'com_object_parameter', 'link_pointer', 'reminder_dialog_patient_specific_value'));
this.TIU_TEMPLATE_GETBOIL = aflow((...args) => this.call({ method: 'TIU_TEMPLATE_GETBOIL', context: ['OR CPRS GUI CHART'], ttl: 0, stale: false }, ...args), d_log, d_unwrap, d_parse_text);
this.TIU_TEMPLATE_GETITEMS = aflow((...args) => this.call({ method: 'TIU_TEMPLATE_GETITEMS', context: ['OR CPRS GUI CHART'], ttl: 0, stale: false }, ...args), d_log, d_unwrap, d_parse_array, f_split('^', 'IEN', 'type', 'status', 'name', 'exclude_from_group_boilerplate', 'blank_lines', 'personal_owner', 'has_children_flag', 'dialog', 'display_only', 'first_line', 'one_item_only', 'hide_dialog_items', 'hide_tree_items', 'indent_items', 'reminder_dialog_ien', 'reminder_dialog_name', 'locked', 'com_object_pointer', 'com_object_parameter', 'link_pointer', 'reminder_dialog_patient_specific_value'));
this.TIU_TEMPLATE_SET_ITEMS = aflow((...args) => this.call({ method: 'TIU_TEMPLATE_SET_ITEMS', context: ['OR CPRS GUI CHART'], ttl: 0, stale: false }, ...args), d_log, d_unwrap);
this.TIU_TEMPLATE_CREATE_MODIFY = aflow((...args) => this.call({ method: 'TIU_TEMPLATE_CREATE/MODIFY', context: ['OR CPRS GUI CHART'], ttl: 0, stale: false }, ...args), d_log, d_unwrap);
this.TIU_TEMPLATE_DELETE = aflow((...args) => this.call({ method: 'TIU_TEMPLATE_DELETE', context: ['OR CPRS GUI CHART'], ttl: 0, stale: false }, ...args), d_log, d_unwrap);
this.TIU_TEMPLATE_LOCK = aflow((...args) => this.call({ method: 'TIU_TEMPLATE_LOCK', context: ['OR CPRS GUI CHART'], ttl: 0, stale: false }, ...args), d_log, d_unwrap);
this.TIU_TEMPLATE_UNLOCK = aflow((...args) => this.call({ method: 'TIU_TEMPLATE_UNLOCK', context: ['OR CPRS GUI CHART'], ttl: 0, stale: false }, ...args), d_log, d_unwrap);
this.TIU_DOCUMENTS_BY_CONTEXT = aflow((...args) => this.call({ method: 'TIU_DOCUMENTS_BY_CONTEXT', context: ['OR CPRS GUI CHART'], ttl: 60, stale: false }, ...args), d_log, d_unwrap, d_parse_array, d_parse_tiudocumentlist);
this.TIU_DOCUMENTS_BY_CONTEXT_FLUSH = aflow((...args) => this.call({ method: 'TIU_DOCUMENTS_BY_CONTEXT', context: ['OR CPRS GUI CHART'], ttl: 0, stale: false }, ...args), d_log, d_unwrap, d_parse_array, d_parse_tiudocumentlist);
this.TIU_GET_RECORD_TEXT = aflow((...args) => this.call({ method: 'TIU_GET_RECORD_TEXT', context: ['OR CPRS GUI CHART'], ttl: 60, stale: false }, ...args), d_log, d_unwrap, d_parse_text);
this.TIU_GET_RECORD_TEXT_FLUSH = aflow((...args) => this.call({ method: 'TIU_GET_RECORD_TEXT', context: ['OR CPRS GUI CHART'], ttl: 0, stale: false }, ...args), d_log, d_unwrap, d_parse_text);
this.TIU_LONG_LIST_OF_TITLES = memoized(aflow((...args) => this.call({ method: 'TIU_LONG_LIST_OF_TITLES', context: ['OR CPRS GUI CHART'], ttl: 86400, stale: true, ttl: 86400, stale: true }, ...args), d_log, d_unwrap, f_split('^', 'IEN', 'name')));
this.TIU_CREATE_RECORD = aflow((...args) => this.call({ method: 'TIU_CREATE_RECORD', context: ['OR CPRS GUI CHART'], ttl: 0, stale: false }, ...args), d_log, d_unwrap);
this.TIU_AUTHORIZATION = aflow((...args) => this.call({ method: 'TIU_AUTHORIZATION', context: ['OR CPRS GUI CHART'], ttl: 0, stale: false }, ...args), d_log, d_unwrap);
this.TIU_LOAD_RECORD_FOR_EDIT = aflow((...args) => this.call({ method: 'TIU_LOAD_RECORD_FOR_EDIT', context: ['OR CPRS GUI CHART'], ttl: 0, stale: false }, ...args), d_log, d_unwrap, d_parse_tiurecordtiux);
this.TIU_UPDATE_RECORD = aflow((...args) => this.call({ method: 'TIU_UPDATE_RECORD', context: ['OR CPRS GUI CHART'], ttl: 0, stale: false }, ...args), d_log, d_unwrap);
this.TIU_DELETE_RECORD = aflow((...args) => this.call({ method: 'TIU_DELETE_RECORD', context: ['OR CPRS GUI CHART'], ttl: 0, stale: false }, ...args), d_log, d_unwrap);
this.TIU_SIGN_RECORD = aflow((...args) => this.call({ method: 'TIU_SIGN_RECORD', context: ['OR CPRS GUI CHART'], ttl: 0, stale: false }, ...args), d_log, d_unwrap);
this.ORWU1_NEWLOC = memoized(caretseparated(unwrapped(logged((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWU1_NEWLOC', ...args), 'ORWU1_NEWLOC')), ['IEN', 'name']));
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.ORWDX_DGNM = memoized(unwrapped(logged((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDX_DGNM', ...args), 'ORWDX_DGNM')));
this.ORWDX_DGRP = memoized(unwrapped(logged((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDX_DGRP', ...args), 'ORWDX_DGRP')));
this.ORWDX_WRLST = memoized(parsed_orderdialogs(unwrapped(logged(() => this.callctx(['OR CPRS GUI CHART'], 'ORWDX_WRLST'), 'ORWDX_WRLST'))));
this.ORWDX_ORDITM = memoized(caretseparated(unwrapped(logged((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDX_ORDITM', ...args), 'ORWDX_ORDITM')), ['IEN', 'synonym', 'name']));
this.ORWDX_DLGID = memoized(unwrapped(logged((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDX_DLGID', ...args), 'ORWDX_DLGID')));
this.ORWDX_DLGDEF = memoized(mapped(caretseparated(unwrapped(logged((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDX_DLGDEF', ...args), 'ORWDX_DLGDEF')), ['promptID', 'promptIEN', 'fmtSeq', 'fmtCode', 'omit', 'lead', 'trail', 'newLine', 'wrap', 'children', 'isChild']), 'promptID'));
this.ORWDX_LOADRSP = memoized(mapped(parsed_orderoverrides(unwrapped(logged((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDX_LOADRSP', ...args), 'ORWDX_LOADRSP')), ['promptID', 'promptIEN', 'fmtSeq', 'fmtCode', 'omit', 'lead', 'trail', 'newLine', 'wrap', 'children', 'isChild']), 'promptID'));
this.ORWDX_SAVE = unwrapped(logged((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDX_SAVE', ...args), 'ORWDX_SAVE'));
this.ORWDXM_MENU = memoized(parsed_ordermenu(unwrapped(logged((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDXM_MENU', ...args), 'ORWDXM_MENU'))));
this.ORWDXM_DLGNAME = memoized(caretseparated1(unwrapped(logged((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDXM_DLGNAME', ...args), 'ORWDXM_DLGNAME')), ['InternalName', 'DisplayName', 'BaseDialogIEN', 'BaseDialogName']));
this.ORWDXM_PROMPTS = memoized(mapped(parsed_caretseparated_detail(unwrapped(logged((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDXM_PROMPTS', ...args), 'ORWDXM_PROMPTS')), ['id', 'req', 'hid', 'prompt', 'type', 'domain', 'default', 'idflt', 'help']), 'id'));
this.ORWDXM1_BLDQRSP = caretseparated(unwrapped(logged((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDXM1_BLDQRSP', ...args), 'ORWDXM1_BLDQRSP')), ['QuickLevel', 'ResponseID', 'Dialog', 'Type', 'FormID', 'DGrpLST']);
this.ORWUL_FV4DG = memoized(caretseparated1(unwrapped(logged((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWUL_FV4DG', ...args), 'ORWUL_FV4DG')), ['IEN', 'count']));
this.ORWUL_FVSUB = memoized(caretseparated(unwrapped(logged((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWUL_FVSUB', ...args), 'ORWUL_FVSUB')), ['IEN', 'description']));
this.ORWUL_FVIDX = memoized(caretseparated1(unwrapped(logged((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWUL_FVIDX', ...args), 'ORWUL_FVIDX')), ['index', 'description']));
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.ORWDSD1_ODSLCT = memoized(parsed_orderoptions_scheduling(unwrapped(logged((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDSD1_ODSLCT', ...args), 'ORWDSD1_ODSLCT'))));
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.ORWDLR32_DEF = memoized(parsed_orderoptions_labfacility(unwrapped(logged((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDLR32_DEF', ...args), 'ORWDLR32_DEF'))));
this.ORWDLR32_LOAD = memoized(parsed_orderoptions_labtest(unwrapped(logged((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDLR32_LOAD', ...args), 'ORWDLR32_LOAD'))));
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.ORWDPS1_SCHALL = memoized(mapped(caretseparated(unwrapped(logged((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDPS1_SCHALL', ...args), 'ORWDPS1_SCHALL')), ['value', 'text', '_', 'times']), 'value'));
this.ORWDPS1_ODSLCT = memoized(parsed_orderoptions_medfill(unwrapped(logged((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDPS1_ODSLCT', ...args), 'ORWDPS1_ODSLCT'))));
this.ORWDPS2_OISLCT = memoized(parsed_orderoptions_meddose(unwrapped(logged((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDPS2_OISLCT', ...args), 'ORWDPS2_OISLCT'))));
this.ORWDPS2_DAY2QTY = memoized(unwrapped(logged((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDPS2_DAY2QTY', ...args), 'ORWDPS2_DAY2QTY')));
this.ORWDPS2_QTY2DAY = memoized(unwrapped(logged((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDPS2_QTY2DAY', ...args), 'ORWDPS2_QTY2DAY')));
this.ORWU_NEWPERS = memoized(aflow((...args) => this.call({ method: 'ORWU_NEWPERS', context: ['OR CPRS GUI CHART'], ttl: 86400, stale: true }, ...args), d_log, d_unwrap, f_split('^', 'DUZ', 'name', 'description')));
this.ORWU_VALIDSIG = memoized(aflow((...args) => this.call({ method: 'ORWU_VALIDSIG', context: ['OR CPRS GUI CHART'], ttl: 0, stale: false }, ...args), d_log, d_unwrap));
this.ORWU1_NEWLOC = memoized(aflow((...args) => this.call({ method: 'ORWU1_NEWLOC', context: ['OR CPRS GUI CHART'], ttl: 86400, stale: true }, ...args), d_log, d_unwrap, f_split('^', 'IEN', 'name')));
this.ORWDX_DGNM = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDX_DGNM', ...args), d_log, d_unwrap));
this.ORWDX_DGRP = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDX_DGRP', ...args), d_log, d_unwrap));
this.ORWDX_WRLST = memoized(aflow(() => this.callctx(['OR CPRS GUI CHART'], 'ORWDX_WRLST'), d_log, d_unwrap, d_parse_orderdialogs));
this.ORWDX_ORDITM = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDX_ORDITM', ...args), d_log, d_unwrap, f_split('^', 'IEN', 'synonym', 'name')));
this.ORWDX_DLGID = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDX_DLGID', ...args), d_log, d_unwrap));
this.ORWDX_DLGDEF = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDX_DLGDEF', ...args), d_log, d_unwrap, f_split('^', 'promptID', 'promptIEN', 'fmtSeq', 'fmtCode', 'omit', 'lead', 'trail', 'newLine', 'wrap', 'children', 'isChild'), f_key('promptID')));
this.ORWDX_LOADRSP = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDX_LOADRSP', ...args), d_log, d_unwrap, orderoverrides_parse, f_key('promptID')));
this.ORWDX_SAVE = aflow((...args) => this.call({ method: 'ORWDX_SAVE', context: ['OR CPRS GUI CHART'], ttl: 0, stale: false }, ...args), d_log, d_unwrap);
this.ORWDXM_MENU = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDXM_MENU', ...args), d_log, d_unwrap, d_parse_ordermenu));
this.ORWDXM_DLGNAME = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDXM_DLGNAME', ...args), d_log, d_unwrap, f_split1('^', 'InternalName', 'DisplayName', 'BaseDialogIEN', 'BaseDialogName')));
this.ORWDXM_PROMPTS = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDXM_PROMPTS', ...args), d_log, d_unwrap, f_parse_caretseparated_detail(['id', 'req', 'hid', 'prompt', 'type', 'domain', 'default', 'idflt', 'help']), f_key('id')));
this.ORWDXM1_BLDQRSP = aflow((...args) => this.call({ method: 'ORWDXM1_BLDQRSP', context: ['OR CPRS GUI CHART'], ttl: 0, stale: false }, ...args), d_log, d_unwrap, f_split('^', 'QuickLevel', 'ResponseID', 'Dialog', 'Type', 'FormID', 'DGrpLST'));
this.ORWUL_FV4DG = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWUL_FV4DG', ...args), d_log, d_unwrap, f_split1('^', 'IEN', 'count')));
this.ORWUL_FVSUB = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWUL_FVSUB', ...args), d_log, d_unwrap, f_split('^', 'IEN', 'description')));
this.ORWUL_FVIDX = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWUL_FVIDX', ...args), d_log, d_unwrap, f_split1('^', 'index', 'description')));
this.ORWDSD1_ODSLCT = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDSD1_ODSLCT', ...args), d_log, d_unwrap, d_parse_orderoptions_scheduling));
this.ORWDLR32_DEF = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDLR32_DEF', ...args), d_log, d_unwrap, d_parse_orderoptions_labfacility));
this.ORWDLR32_LOAD = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDLR32_LOAD', ...args), d_log, d_unwrap, d_parse_orderoptions_labtest));
this.ORWDPS1_SCHALL = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDPS1_SCHALL', ...args), d_log, d_unwrap, f_split('^', 'value', 'text', '_', 'times'), f_key('value')));
this.ORWDPS1_ODSLCT = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDPS1_ODSLCT', ...args), d_log, d_unwrap, d_parse_orderoptions_medfill));
this.ORWDPS2_OISLCT = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDPS2_OISLCT', ...args), d_log, d_unwrap, d_parse_orderoptions_meddose));
this.ORWDPS2_DAY2QTY = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDPS2_DAY2QTY', ...args), d_log, d_unwrap));
this.ORWDPS2_QTY2DAY = memoized(aflow((...args) => this.callctx(['OR CPRS GUI CHART'], 'ORWDPS2_QTY2DAY', ...args), d_log, d_unwrap));
this.ORWORB_FASTUSER = aflow(() => this.call({ method: 'ORWORB_FASTUSER', context: ['OR CPRS GUI CHART'], ttl: 60, stale: false }), d_log, d_unwrap, d_parse_notifications_fastuser);
return this;
}
@ -455,7 +468,7 @@ Client.fromCookie = async function(secret, defaulthost='vista.northport.med.va.g
console.log('Using saved secret and connection', secret);
var cid = localstate.cid;
var client = Client.fromID(cid, secret);
if((await vista.call(cid, 'XWB_IM_HERE')).result == '1') {
if((await vista.call(cid, { method: 'XWB_IM_HERE', ttl: 0, stale: false, id: Date.now() })).result == '1') {
var server = await client.serverinfo();
if((host[0] == server.result.host) && (host[1] == server.result.port)) {
localstate.host = host.join(':');

96
main.py
View File

@ -1,8 +1,10 @@
#!/usr/bin/env python3
import os
import json
import secrets
import string
import time
from flask import Flask, request, send_from_directory
from flask.json import jsonify
from flask.json.provider import DefaultJSONProvider
@ -27,18 +29,22 @@ class JSONProviderX(DefaultJSONProvider):
class CacheProxyRPC(util.CacheProxy):
def __init__(self, obj, persistent=None, volatile=None, prefix=''):
util.CacheProxy.__init__(self, obj)
if persistent is None:
persistent = util.Store().memo
if volatile is None:
volatile = util.Store().memo
self._cache(('__call__', 'close', 'authenticate', 'keepalive', 'XWB_CREATE_CONTEXT', 'XWB_IM_HERE', 'TIU_TEMPLATE_GETROOTS', 'TIU_TEMPLATE_GETPROOT', 'TIU_TEMPLATE_GETBOIL', 'TIU_TEMPLATE_GET_DESCRIPTION', 'TIU_TEMPLATE_GETITEMS', 'TIU_TEMPLATE_SET ITEMS', 'TIU_TEMPLATE_CREATE/MODIFY', 'TIU_TEMPLATE_DELETE', 'TIU_TEMPLATE_LOCK', 'TIU_TEMPLATE_UNLOCK', 'ORWDXM1_BLDQRSP'), None)
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(('__call__', 'close', 'authenticate', 'keepalive', 'XWB_CREATE_CONTEXT', 'TIU_TEMPLATE_SET_ITEMS', 'TIU_TEMPLATE_CREATE/MODIFY', 'TIU_TEMPLATE_DELETE', 'TIU_TEMPLATE_LOCK', 'TIU_TEMPLATE_UNLOCK', 'ORWDX_SAVE', 'ORWDXM1_BLDQRSP'), None)
self._cache(('XWB_GET_BROKER_INFO', 'XUS_INTRO_MSG'), volatile, prefix=prefix, ttl=float('inf'))
self._cache(None, volatile, prefix=prefix, ttl=float('-inf'))
self._cache_persistent(persistent=persistent, prefix=prefix)
def _cache_persistent(self, persistent=None, prefix=''):
if persistent is None:
persistent = util.Store().memo
self._cache(('SDEC_RESOURCE', 'ORWLRR_ALLTESTS_ALL'), persistent, prefix=prefix, ttl=float('inf'))
self._cache(('SDEC_RESOURCE', 'ORWU1_NEWLOC', 'ORWLRR_ALLTESTS_ALL', 'ORWORDG_ALLTREE', 'ORWORDG_REVSTS', 'ORWDX_DGNM', 'ORWDX_ORDITM'), persistent, prefix=prefix, ttl=float('inf'))
def jsonify_result(value, id=None):
return jsonify({ 'result': value._base, 'error': None, 'id': request.json.get('id'), 'ts': value._ts, 'cached': True } if isinstance(value, util.Cached) else { 'result': value, 'error': None, 'id': request.json.get('id'), 'ts': time.time() })
def jsonify_error(ex, id=None):
return jsonify({ 'result': None, 'error': { 'type': ex.__class__.__name__, 'args': ex.args }, 'id': id })
def application():
app = Flask(__name__)
@ -60,12 +66,12 @@ def application():
while cid in clients:
cid = ''.join(secrets.choice(string.ascii_lowercase + string.digits) for i in range(64))
clients[cid] = client = CacheProxyRPC(rpc.ClientSync(host=params.get('host', 'test.northport.med.va.gov'), port=int(params.get('port', 19009))))
return jsonify({ 'result': cid, 'error': None, 'id': request.json.get('id') })
return jsonify_result(cid, id=request.json.get('id'))
else:
return jsonify({ 'result': None, 'error': { 'type': 'Unauthorized', 'args': [] }, 'id': request.json.get('id') })
except Exception as ex:
logger.exception(request.url)
return jsonify({ 'result': None, 'error': { 'type': ex.__class__.__name__, 'args': ex.args }, 'id': request.json.get('id') })
return jsonify_error(ex, id=request.json.get('id'))
@app.post('/v1/vista/<cid>/close')
def cb_close(cid):
@ -73,28 +79,28 @@ def application():
client = clients[cid]
res = client.close()
del clients[cid]
return jsonify({ 'result': res, 'error': None, 'id': request.json.get('id') })
return jsonify_result(res, id=request.json.get('id'))
except Exception as ex:
logger.exception(request.url)
return jsonify({ 'result': None, 'error': { 'type': ex.__class__.__name__, 'args': ex.args }, 'id': request.json.get('id') })
return jsonify_error(ex, id=request.json.get('id'))
@app.post('/v1/vista/<cid>/serverinfo')
def cb_serverinfo(cid):
try:
client = clients[cid]
return jsonify({ 'result': client._obj._server, 'error': None, 'id': request.json.get('id') })
return jsonify_result(client._obj._server, id=request.json.get('id'))
except Exception as ex:
logger.exception(request.url)
return jsonify({ 'result': None, 'error': { 'type': ex.__class__.__name__, 'args': ex.args }, 'id': request.json.get('id') })
return jsonify_error(ex, id=request.json.get('id'))
@app.post('/v1/vista/<cid>/userinfo')
def cb_userinfo(cid):
@app.post('/v1/vista/<cid>/authinfo')
def cb_authinfo(cid):
try:
client = clients[cid]
return jsonify({ 'result': client._obj._user, 'error': None, 'id': request.json.get('id') })
return jsonify_result(client._obj._auth, id=request.json.get('id'))
except Exception as ex:
logger.exception(request.url)
return jsonify({ 'result': None, 'error': { 'type': ex.__class__.__name__, 'args': ex.args }, 'id': request.json.get('id') })
return jsonify_error(ex, id=request.json.get('id'))
@app.post('/v1/vista/<cid>/authenticate')
def cb_authenticate(cid):
@ -104,41 +110,79 @@ def application():
if 'avcode' in params:
user = client.authenticate(params['avcode'])
client._cache_persistent(persistent=util.Store(f'cache.{client._server["volume"].lower()}.{client._server["uci"].lower()}.{user[0]}.db', journal_mode='WAL').memo)
return jsonify({ 'result': user, 'error': None, 'id': request.json.get('id') })
return jsonify_result(user, id=request.json.get('id'))
else:
from auth import XUIAMSSOi_MySsoTokenVBA
if token := XUIAMSSOi_MySsoTokenVBA():
import XWBSSOi
if token := XWBSSOi.get_sso_token(application='CPRSChart.exe'):
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)
return jsonify({ 'result': user, 'error': None, 'id': request.json.get('id') })
return jsonify_result(user, id=request.json.get('id'))
else:
return jsonify({ 'result': None, 'error': { 'type': 'Unauthorized', 'args': [] }, 'id': request.json.get('id') })
except Exception as ex:
logger.exception(request.url)
return jsonify({ 'result': None, 'error': { 'type': ex.__class__.__name__, 'args': ex.args }, 'id': request.json.get('id') })
return jsonify_error(ex, id=request.json.get('id'))
@app.post('/v1/vista/<cid>')
def cb_call1(cid):
try:
client = clients[cid]
data = request.json
kw = {}
if 'context' in data:
return jsonify({ 'result': getattr(client, data['method'].upper())(*data.get('params', ()), context=data['context']), 'error': None, 'id': data.get('id') })
else:
return jsonify({ 'result': getattr(client, data['method'].upper())(*data.get('params', ())), 'error': None, 'id': data.get('id') })
kw['context'] = data['context']
thunk = getattr(client, data['method'].upper())
if getattr(thunk, 'cached', False):
if 'ttl' in data:
kw['_cache_ttl'] = data['ttl']
if 'stale' in data:
kw['_cache_stale'] = data['stale']
return jsonify_result(thunk(*data.get('params', ()), **kw), id=data.get('id'))
except Exception as ex:
logger.exception(request.url)
return jsonify({ 'result': None, 'error': { 'type': ex.__class__.__name__, 'args': ex.args }, 'id': request.json.get('id') })
return jsonify_error(ex, id=request.json.get('id'))
@app.post('/v1/vista/<cid>/<method>')
def cb_call2(cid, method):
try:
client = clients[cid]
data = request.json
return jsonify({ 'result': getattr(client, method.upper())(*data.get('params', ())), 'error': None, 'id': data.get('id') })
kw = {}
if 'context' in data:
kw['context'] = data['context']
thunk = getattr(client, method.upper())
if getattr(thunk, 'cached', False):
if 'ttl' in data:
kw['_cache_ttl'] = data['ttl']
if 'stale' in data:
kw['_cache_stale'] = data['stale']
return jsonify_result(thunk(*data.get('params', ()), **kw), id=data.get('id'))
except Exception as ex:
logger.exception(request.url)
return jsonify({ 'result': None, 'error': { 'type': ex.__class__.__name__, 'args': ex.args }, 'id': request.json.get('id') })
return jsonify_error(ex, id=request.json.get('id'))
@app.get('/v1/vista/<cid>/imaging/<path:path>')
def cb_imaging(cid, path):
if 'view' in request.args:
adapter = 'view.' + path.rsplit('.', 1)[1].lower() + '.html'
if os.path.isfile('./htdocs/adapter/' + adapter):
return send_from_directory('./htdocs/adapter', adapter)
client = clients[cid]
frag = path.replace('\\', '/').strip('/').split('/')
winshare = '\\\\' + '\\'.join(frag[:2]).upper()
for item in client.MAG_GET_NETLOC('ALL', context=['MAG WINDOWS']):
if item.split('^')[1] == winshare:
break
else:
raise PermissionError(path)
try:
open('//' + '/'.join(frag)).close()
except PermissionError as ex:
credentials = client.MAGGUSER2(context=['MAG WINDOWS'])[2].split('^')
import subprocess, XWBHash
subprocess.run(['net', 'use', winshare, '/d'])
subprocess.run(['net', 'use', winshare, '/user:' + credentials[0], XWBHash.decrypt(credentials[1])])
return send_from_directory('//' + '/'.join(frag[:2]), '/'.join(frag[2:]), as_attachment=('dl' in request.args))
@app.get('/<path:path>')
def cb_static(path):

56
rpc.py
View File

@ -5,19 +5,31 @@ import socket
import threading
import asyncio
import warnings
import logging
from collections import namedtuple
from XWBHash import encrypt0 as XWBHash_encrypt
from typing import Any, Union, Sequence
logger = logging.getLogger(__name__)
class RPCExc(Exception): pass
class RPCExcFormat(ValueError, RPCExc): pass
class RPCExcAuth(RPCExc): pass
class RPCExcServerError(RPCExc): pass
class RPCExcInvalidResult(RPCExc): pass
class MReference(str): pass
class RPCType(object):
LITERAL = b'0'
REFERENCE = b'1'
LIST = b'2'
GLOBAL = b'3'
EMPTY = b'4'
STREAM = b'5'
def __init__(self, value, magic=None):
self.magic = magic
self.value = value.value if isinstance(value, RPCType) else value
RecordServerInfo = namedtuple('RecordServerInfo', ('server', 'volume', 'uci', 'device', 'attempts', 'skip_signon_screen', 'domain', 'production'))
@ -27,20 +39,20 @@ def s_pack(value: Any, encoding: str='latin-1'):
return bytes((len(encoded),)) + encoded
raise ValueError('cannot s-pack string longer than 255 bytes: ' + repr(value))
def l_pack(value: Any, envelope: int=3, wrapped: bool=True, basictype=b'0', encoding: str='latin-1'):
def l_pack(value: Any, envelope: int=3, wrapped: bool=True, magic=None, encoding: str='latin-1'):
if isinstance(value, dict):
bare = b't'.join(l_pack(k, envelope=envelope, wrapped=False, encoding=encoding) + l_pack(v, envelope=envelope, wrapped=False, encoding=encoding) for k, v in value.items())
return (b'2' + bare + b'f') if wrapped else bare
return ((magic or b'2') + bare + b'f') if wrapped else bare
elif not isinstance(value, str) and hasattr(value, '__iter__'):
bare = b't'.join(l_pack(k, envelope=envelope, wrapped=False, encoding=encoding) + l_pack(v, envelope=envelope, wrapped=False, encoding=encoding) for k, v in enumerate(value, start=1))
return (b'2' + bare + b'f') if wrapped else bare
elif isinstance(value, MReference):
return l_pack(str(value), envelope=envelope, basictype=b'1', encoding=encoding)
return ((magic or b'2') + bare + b'f') if wrapped else bare
elif isinstance(value, RPCType):
return l_pack(value.value, envelope=envelope, magic=value.magic, encoding=encoding)
else:
encoded = str(value).encode(encoding)
if len(encoded) <= 10**envelope - 1:
bare = str(len(encoded)).zfill(envelope).encode(encoding) + encoded
return (basictype + bare + b'f') if wrapped else bare
return ((magic or b'0') + bare + b'f') if wrapped else bare
raise ValueError(f'cannot l-pack string longer than {10**envelope - 1} bytes with an envelope of {envelope}: ' + repr(value))
def l_pack_maxlen(value: Any, encoding: str='latin-1'):
@ -100,18 +112,19 @@ class ClientSync(object):
self.recv_rpc_msg = recv_rpc_msg(self.sock)
self.lock = threading.Lock()
self._server = { 'host': host, 'port': port }
self._user = None
self._auth = None
self.context = 'XUS SIGNON'
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)
def __call__(self, name: str, *args: Any, command: bool=False, envelope: int=0, context: Union[bool, None]=None, encoding='latin-1'):
def __call__(self, name: str, *args: Any, command: bool=False, envelope: int=0, context: Union[Sequence, None]=None, encoding='latin-1'):
name = name.replace('_', ' ')
with self.lock:
if name != 'XWB CREATE CONTEXT' and context and len(context) > 0 and self.context not in context:
send_rpc_msg(self.sock, rpc_pack('XWB CREATE CONTEXT', XWBHash_encrypt(context[0]), envelope=envelope, encoding=encoding))
if (res := rpc_unpack_result(next(self.recv_rpc_msg), encoding=encoding)) != '1':
raise RPCExcInvalidResult('XWB CREATE CONTEXT', context[0], res)
self.context = context
self.context = context[0]
logger.warning(f'RPC: {name} [{self.context}] {args}' if context else f'{name} {args}')
send_rpc_msg(self.sock, rpc_pack(name, *args, command=command, envelope=envelope, encoding=encoding))
return rpc_unpack_result(next(self.recv_rpc_msg), encoding=encoding)
def __getattr__(self, key: str, commands: set={'TCPConnect'}):
@ -131,10 +144,11 @@ class ClientSync(object):
return res
def authenticate(self, identity: str, *, context=('XUS SIGNON',)):
self._server.update(RecordServerInfo(*self('XUS SIGNON SETUP', '', '1', context=context))._asdict())
if identity.startswith('<?xml version="1.0"'):
res = self('XUS ESSO VALIDATE', RPCType(tuple(identity[i:i+200] for i in range(0, len(identity), 200)), RPCType.GLOBAL))
else:
res = self('XUS AV CODE', XWBHash_encrypt(identity))
if res[0] == '0' or res[2] != '0':
raise RPCExcAuth(res[3], res)
self._user = res
self._auth = res if res[0] != '0' else None
return res
def keepalive(self, interval=None, *, context=('XUS SIGNON',)):
import time
@ -172,7 +186,7 @@ class ClientAsync(object):
self.arecv_rpc_msg = arecv_rpc_msg(self.reader)
self.lock = asyncio.Lock()
self._server = { 'host': host, 'port': port, 'info': None }
self._user = None
self._auth = None
self.context = 'XUS SIGNON'
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)
@ -183,7 +197,8 @@ class ClientAsync(object):
await asend_rpc_msg(self.writer, rpc_pack('XWB CREATE CONTEXT', XWBHash_encrypt(context[0]), envelope=envelope, encoding=encoding))
if (res := rpc_unpack_result(await self.arecv_rpc_msg.__anext__(), encoding=encoding)) != '1':
raise RPCExcInvalidResult('XWB CREATE CONTEXT', context[0], res)
self.context = context
self.context = context[0]
logger.warning(f'RPC: {name} [{self.context}] {args}' if context else f'{name} {args}')
await asend_rpc_msg(self.writer, rpc_pack(name, *args, command=command, envelope=envelope, encoding=encoding))
return rpc_unpack_result(await self.arecv_rpc_msg.__anext__(), encoding=encoding)
def __getattr__(self, key: str, commands: set={'TCPConnect'}):
@ -208,10 +223,11 @@ class ClientAsync(object):
return res
async def authenticate(self, identity: str, *, context=('XUS SIGNON',)):
self._server.update(RecordServerInfo(*await self('XUS SIGNON SETUP', '', '1', context=context))._asdict())
if identity.startswith('<?xml version="1.0"'):
res = await self('XUS ESSO VALIDATE', RPCType(tuple(identity[i:i+200] for i in range(0, len(identity), 200)), RPCType.GLOBAL))
else:
res = await self('XUS AV CODE', XWBHash_encrypt(identity))
if res[0] == '0' or res[2] != '0':
raise RPCExcAuth(res[3], res)
self._user = res
self._auth = res if res[0] != '0' else None
return res
async def keepalive(self, interval=None, *, context=('XUS SIGNON',)):
interval = interval or 0.45*float((await self.XWB_GET_BROKER_INFO(context=context))[0])
@ -221,13 +237,13 @@ class ClientAsync(object):
if __name__ == '__main__':
import getpass, code
from auth import XUIAMSSOi_MySsoTokenVBA
import XWBSSOi
client = ClientSync(host='test.northport.med.va.gov', port=19009)
#client = ClientSync(host='vista.northport.med.va.gov', port=19209)
threading.Thread(target=client.keepalive, daemon=True).start()
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)))
else:
print('authenticate', repr(client.authenticate(f"{getpass.getpass('ACCESS CODE: ')};{getpass.getpass('VERIFY CODE: ')}")))

18
util.py
View File

@ -16,6 +16,15 @@ except ImportError:
from typing import Any, Union, AsyncGenerator, Iterable, Tuple, Callable
class Cached(object):
def __init__(self, base, ts=None):
self._base = base
self._ts = ts
def __getattr__(self, key):
return getattr(self._base, key)
def __getitem__(self, key):
return getitem(self._base, key)
class Store(object):
def __init__(self, database: Union[sqlite3.Connection, str]=':memory:', synchronous: bool=None, journal_mode: bool=None, default_factory: Union[Callable, None]=None):
self._db = database if isinstance(database, sqlite3.Connection) else sqlite3.connect(database, check_same_thread=False)
@ -60,8 +69,8 @@ class Mapping(object):
def get(self, key: Union[str, slice], ttl: float=float('inf'), now: float=0, **kw) -> Any:
if isinstance(key, slice):
key, ttl, now = key.start, key.stop, key.step
for row in self._store.execute(f'SELECT value FROM "{self._tbl}" WHERE key = ? AND ts > ? LIMIT 1', (key, (now or time.time()) - ttl)):
return loads(row[0])
for row in self._store.execute(f'SELECT value, ts FROM "{self._tbl}" WHERE key = ? AND ts > ? LIMIT 1', (key, (now or time.time()) - ttl)):
return Cached(loads(row[0]), row[1])
if 'default' in kw:
return kw['default']
elif self._store._default_factory is not None:
@ -120,7 +129,7 @@ class CacheProxy(object):
with cache:
res = cache[_cache_key] = await value(*args, **kw)
return res
async def thunk(*args, _cache_ttl: float=ttl, _cache_stale: bool=True, **kw):
async def thunk(*args, _cache_ttl: float=ttl, _cache_stale: bool=False, **kw):
_cache_key = prefix + key + repr(args) + repr(kw)
try:
return cache[_cache_key:_cache_ttl]
@ -136,7 +145,7 @@ class CacheProxy(object):
with lock, cache:
res = cache[_cache_key] = value(*args, **kw)
return res
def thunk(*args, _cache_ttl: float=ttl, _cache_stale: bool=True, **kw):
def thunk(*args, _cache_ttl: float=ttl, _cache_stale: bool=False, **kw):
_cache_key = prefix + key + repr(args) + repr(kw)
try:
return cache[_cache_key:_cache_ttl]
@ -148,6 +157,7 @@ class CacheProxy(object):
return fetch(*args, **kw)
else:
return value
thunk.cached = True
setattr(self, key, thunk)
return thunk