// ==UserScript==
// @name KAAUH Lab Suite - Verification, Alerts & Enhancements
// @namespace Violentmonkey Scripts
// @version 7.1
// @description Combines verification buttons (F7/F8), dynamic alerts (>, NO RESULT, X-NORESULT, CL/CH) showing alerts once per trigger per page visit, checkbox automation, toggle back-nav, and inline sample counters.
// @match *://his.kaauh.org/lab/*
// @grant none
// @author Hamad AlShegifi
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// --- Styling Adjustments (Global) ---
function applySingleLineStyles() {
const anchor = document.querySelector('li > a[href="#/lab-orders/doctor-request"]');
if (anchor) {
anchor.style.setProperty('white-space', 'nowrap', 'important');
anchor.style.setProperty('overflow', 'visible', 'important');
anchor.style.setProperty('text-overflow', 'unset', 'important');
const spans = anchor.querySelectorAll('span');
spans.forEach(span => {
span.style.setProperty('display', 'inline', 'important');
span.style.setProperty('font-size', '13px', 'important');
span.style.setProperty('white-space', 'nowrap', 'important');
});
}
const simplifySpan = (selector) => {
const span = document.querySelector(selector);
if (span) {
span.style.setProperty('display', 'inline', 'important');
span.style.setProperty('font-size', '20px', 'important');
span.style.setProperty('white-space', 'nowrap', 'important');
span.style.setProperty('overflow', 'visible', 'important');
span.style.setProperty('text-overflow', 'unset', 'important');
span.textContent = span.textContent.replace(/\s+/g, '');
}
};
simplifySpan('span.to-do');
simplifySpan('span.pending-orders');
}
function debounce(func, wait) {
let timeout;
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
}
const debouncedStyleUpdater = debounce(applySingleLineStyles, 300);
const styleObserver = new MutationObserver(debouncedStyleUpdater);
styleObserver.observe(document.body, { childList: true, subtree: true });
applySingleLineStyles(); // Initial application
})();
// Main Userscript Logic (Alerts, F7/F8 buttons, etc.)
(function () {
'use strict';
const CONFIG_MAIN = {
URLS: {
EDIT_PAGE_PREFIX: 'https://his.kaauh.org/lab/#/lab-orders/edit-lab-order/',
},
SELECTORS: {
VERIFY1_BTN: '#custom-script-buttons button.verify1-btn',
VERIFY2_BTN: '#custom-script-buttons button.verify2-btn',
COMPLETE_TECH: 'button.dropdown-item[translateid="test-results.CompleteTechnicalVerification"]',
COMPLETE_MED: 'button.dropdown-item[translateid="test-results.CompleteMedicalVerification"]',
FINAL_VERIFY: 'button.btn-success.btn-sm.min-width[translateid="test-verification.Verify"]',
NEXT_BTN: 'button#btnNext',
UNCHECKED_BOX: 'span.ag-icon-checkbox-unchecked[unselectable="on"]',
CHECKBOX_ROW: '.ag-row',
TEST_DESC_CELL: '[col-id="TestDesc"]',
ORDERED_STATUS_CELL: 'div[col-id="ResultStatus"]',
TOAST: {
CONTAINER: '#toast-container',
CLOSE_BTN: 'button.toast-close-button',
SUCCESS: '.toast-success',
},
SAMPLE_RECEIVE_MODAL: 'modal-container.show', // Used by the sample counter part
},
CHECK_INTERVALS: {
UNDEFINED_URL: 200,
ORDERED_SCAN: 500,
DISABLED_BTN_CHECK: 1000,
},
EXCLUDE_WORDS: [ // Words to exclude from auto-checkbox selection
'culture', "gram's stain", 'stain', 'bacterial', 'fungal',
'pcr', 'meningitis', 'mrsa', 'mid', 'stream', 'cryptococcus'
]
};
let verify1Clicked = false;
let verify2Clicked = false;
let verify1Toggle = localStorage.getItem('verify1Toggle') === 'true';
let verify2Toggle = localStorage.getItem('verify2Toggle') === 'true';
let hasScrolledToOrderedRow = false;
let lastDisabledButtonAlertTime = 0;
const DISABLED_ALERT_COOLDOWN = 30000; // 30 seconds
const logDebugMain = msg => console.debug(`[LabScript Main] ${msg}`);
const isCorrectPage = () => window.location.href.startsWith(CONFIG_MAIN.URLS.EDIT_PAGE_PREFIX);
function showDisabledButtonAlert(message) {
const now = Date.now();
if (now - lastDisabledButtonAlertTime < DISABLED_ALERT_COOLDOWN) return;
lastDisabledButtonAlertTime = now;
const modalOverlay = document.createElement('div');
modalOverlay.id = 'disabled-button-alert-overlay';
Object.assign(modalOverlay.style, {
position: 'fixed', top: '0', left: '0', width: '100vw', height: '100vh',
backgroundColor: 'rgba(0, 0, 0, 0.7)', zIndex: '10000', display: 'flex',
justifyContent: 'center', alignItems: 'center'
});
const modalBox = document.createElement('div');
Object.assign(modalBox.style, {
backgroundColor: '#fff', padding: '25px', borderRadius: '8px',
boxShadow: '0 5px 15px rgba(0,0,0,0.3)', width: 'auto', maxWidth: '80%',
textAlign: 'center', borderTop: '5px solid #f0ad4e' // Warning color
});
const title = document.createElement('h3');
title.textContent = 'Button Disabled';
title.style.color = '#d9534f'; // Danger color for title
title.style.marginTop = '0';
modalBox.appendChild(title);
const messageElem = document.createElement('p');
messageElem.textContent = message;
messageElem.style.fontSize = '16px';
messageElem.style.marginBottom = '20px';
modalBox.appendChild(messageElem);
const okButton = document.createElement('button');
okButton.textContent = 'OK';
Object.assign(okButton.style, {
padding: '8px 20px', borderRadius: '5px', backgroundColor: '#5cb85c', // Success color for button
color: '#fff', border: 'none', cursor: 'pointer', fontSize: '16px'
});
okButton.onclick = () => document.body.removeChild(modalOverlay);
modalBox.appendChild(okButton);
modalOverlay.appendChild(modalBox);
document.body.appendChild(modalOverlay);
okButton.focus(); // Focus on OK button for accessibility
}
function addFontAwesome() {
const fontAwesomeLink = 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css';
if (!document.querySelector(`link[href="${fontAwesomeLink}"]`)) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = fontAwesomeLink;
const head = document.head || document.body; // Prefer document.head
if (head) head.appendChild(link);
}
}
function createVerifyButton(label, className, onClick, id) {
const button = document.createElement('button');
button.type = 'button';
button.className = className; // Base classes like 'btn btn-sm'
button.classList.add(id); // Specific ID class like 'verify1-btn'
button.innerText = label;
// Common styles, ensuring they override existing ones if necessary
const styles = {
'font-family': 'Arial, sans-serif',
'font-size': '14px',
'font-weight': 'normal', // Bootstrap buttons often have bolder weight
'color': '#ffffff',
'background-color': className.includes('success') ? '#28a745' : '#2594d9', // Green for success, blue for primary
'padding': '8px 16px',
'border': 'none',
'border-radius': '5px',
'text-shadow': 'none', // Remove any default text shadow
'cursor': 'pointer',
'margin-right': '5px',
'line-height': '1.5', // Standard line height
'vertical-align': 'middle', // Align with other inline elements
};
for (const [prop, value] of Object.entries(styles)) {
button.style.setProperty(prop, value, 'important');
}
button.onclick = onClick;
return button;
}
function createToggleIcon(id, isActive, onClick) {
const icon = document.createElement('span');
icon.id = id;
// Using FontAwesome icon for toggle
icon.innerHTML = `<i class="fas fa-arrow-circle-left" style="color: ${isActive ? '#008000' : '#d1cfcf'}; font-size: 1.3em; vertical-align: middle;"></i>`;
icon.style.cursor = 'pointer';
icon.style.marginRight = '10px';
icon.style.marginLeft = '-1px'; // Slight adjustment for alignment
icon.title = "Toggle: Go back automatically after this verification success toast is clicked";
icon.onclick = onClick;
return icon;
}
function handleVerifyToggle(type) {
const toggle = type === 'verify1' ? !verify1Toggle : !verify2Toggle;
localStorage.setItem(type + 'Toggle', toggle); // Save preference
const icon = document.querySelector(`#${type}Icon i`);
if (icon) icon.style.setProperty('color', toggle ? '#008000' : '#d1cfcf', 'important'); // Green for active, grey for inactive
if (type === 'verify1') verify1Toggle = toggle;
else verify2Toggle = toggle;
}
function addButtons() {
if (document.getElementById('custom-script-buttons') || !isCorrectPage()) return;
const nextButton = document.querySelector(CONFIG_MAIN.SELECTORS.NEXT_BTN);
if (!nextButton || !nextButton.parentNode) {
setTimeout(addButtons, 500); // Retry if next button not found yet
return;
}
const buttonDiv = document.createElement('div');
buttonDiv.id = 'custom-script-buttons';
buttonDiv.style.setProperty('display', 'inline-block', 'important');
buttonDiv.style.setProperty('margin-left', '10px', 'important');
buttonDiv.style.setProperty('vertical-align', 'middle', 'important');
const verify1Button = createVerifyButton('VERIFY1 (F7)', 'btn btn-success btn-sm', () => {
verify1Clicked = true;
verify2Clicked = false;
checkAllVisibleBoxesWithoutDuplicates();
setTimeout(clickCompleteTechnicalVerificationButton, 500); // Delay to allow checkboxes to register
}, 'verify1-btn');
const verify2Button = createVerifyButton('VERIFY2 (F8)', 'btn btn-primary btn-sm', () => {
verify2Clicked = true;
verify1Clicked = false;
checkAllVisibleBoxesWithoutDuplicates();
setTimeout(clickCompleteMedicalVerificationButton, 500); // Delay
}, 'verify2-btn');
const verify1Icon = createToggleIcon('verify1Icon', verify1Toggle, () => handleVerifyToggle('verify1'));
const verify2Icon = createToggleIcon('verify2Icon', verify2Toggle, () => handleVerifyToggle('verify2'));
// Credit span
const credit = document.createElement('span');
credit.textContent = "Modded by: Hamad AlShegifi";
credit.style.cssText =
`font-size: 11px !important;
font-weight: bold !important;
color: #e60000 !important; /* Bright red for visibility */
margin-left: 15px !important;
border: 1px solid #e60000 !important;
border-radius: 5px !important;
padding: 3px 6px !important;
background-color: #ffffff !important; /* White background for contrast */
vertical-align: middle !important;
`;
buttonDiv.append(verify1Button, verify1Icon, verify2Button, verify2Icon, credit);
nextButton.parentNode.insertBefore(buttonDiv, nextButton.nextSibling);
logDebugMain("Custom buttons added.");
}
function checkAllVisibleBoxesWithoutDuplicates() {
const selectedTests = new Set(); // To track selected test descriptions and avoid duplicates
const boxes = document.querySelectorAll(CONFIG_MAIN.SELECTORS.UNCHECKED_BOX);
boxes.forEach(box => {
const row = box.closest(CONFIG_MAIN.SELECTORS.CHECKBOX_ROW);
// Check if the row is visible (offsetParent is not null)
if (row && row.offsetParent !== null) {
const testDescElement = row.querySelector(CONFIG_MAIN.SELECTORS.TEST_DESC_CELL);
const descText = testDescElement?.textContent.trim().toLowerCase();
if (descText) {
// Check if any part of the description text includes an excluded word
const isExcluded = CONFIG_MAIN.EXCLUDE_WORDS.some(word => descText.includes(word));
if (!selectedTests.has(descText) && !isExcluded) {
selectedTests.add(descText); // Add to set to prevent re-checking if description is identical
box.click();
}
}
}
});
logDebugMain(`Checked ${selectedTests.size} unique, non-excluded, visible boxes.`);
}
const clickButton = (selector, callback) => {
const btn = document.querySelector(selector);
if (btn && !btn.disabled) {
btn.click();
if (callback) setTimeout(callback, 500); // Short delay for UI updates
return true;
} else if (btn && btn.disabled) {
const buttonName = selector.includes('CompleteTechnicalVerification') ?
'Complete Technical Verification' :
selector.includes('CompleteMedicalVerification') ?
'Complete Medical Verification' :
selector.includes('Verify') ?
'Final Verify' : 'Button';
showDisabledButtonAlert(`${buttonName} button is disabled. Please check if you have selected all required tests or if verification is already done.`);
}
return false;
};
const clickCompleteTechnicalVerificationButton = () => clickButton(CONFIG_MAIN.SELECTORS.COMPLETE_TECH, clickFinalVerifyButton);
const clickCompleteMedicalVerificationButton = () => clickButton(CONFIG_MAIN.SELECTORS.COMPLETE_MED, clickFinalVerifyButton);
const clickFinalVerifyButton = () => clickButton(CONFIG_MAIN.SELECTORS.FINAL_VERIFY);
function checkForDisabledButtons() {
if (!isCorrectPage()) return;
const verify1Btn = document.querySelector(CONFIG_MAIN.SELECTORS.VERIFY1_BTN);
const verify2Btn = document.querySelector(CONFIG_MAIN.SELECTORS.VERIFY2_BTN);
// This function is more for proactive checks if needed, showDisabledButtonAlert is called on actual click attempt.
// Could be used to visually indicate disabled state if desired.
if (verify1Btn && verify1Btn.disabled) {
// logDebugMain("VERIFY1 button is currently disabled on page.");
}
if (verify2Btn && verify2Btn.disabled) {
// logDebugMain("VERIFY2 button is currently disabled on page.");
}
}
function checkUrlAndTriggerClickForUndefined() {
if (window.location.href.endsWith('/undefined')) {
const closeBtn = document.querySelector(`${CONFIG_MAIN.SELECTORS.TOAST.CONTAINER} ${CONFIG_MAIN.SELECTORS.TOAST.CLOSE_BTN}`);
if (closeBtn) {
logDebugMain("URL ends with /undefined, attempting to close toast.");
closeBtn.click();
}
}
}
function monitorOrderedStatus() {
if (!isCorrectPage()) return;
const rows = document.querySelectorAll('div[role="row"]'); // More generic row selector
let hasOrdered = false;
let firstOrderedRow = null;
rows.forEach(row => {
// Ensure row is visible
if (row.offsetParent !== null) { // Check if element is visible on the page
const cell = row.querySelector(CONFIG_MAIN.SELECTORS.ORDERED_STATUS_CELL);
if (cell?.textContent.includes('Ordered')) {
if (!firstOrderedRow) firstOrderedRow = row; // Get the first visible ordered row
hasOrdered = true;
}
}
});
const btn = document.querySelector(CONFIG_MAIN.SELECTORS.VERIFY1_BTN);
if (btn) {
if (hasOrdered) {
if (!btn.classList.contains('btn-warning')) { // Check if already warning
btn.className = 'btn btn-warning verify1-btn'; // Change class for styling
btn.innerText = 'VERIFY1 (F7)'; // Keep text consistent
btn.style.setProperty('background-color', '#fab641', 'important'); // Orange/yellow
btn.style.setProperty('color', '#050505', 'important'); // Dark text for contrast
logDebugMain("Ordered status detected. VERIFY1 button changed to warning.");
}
if (firstOrderedRow && !hasScrolledToOrderedRow) {
document.documentElement.style.scrollBehavior = 'smooth'; // Smooth scroll
firstOrderedRow.scrollIntoView({ behavior: 'auto', block: 'center' });
hasScrolledToOrderedRow = true;
setTimeout(() => document.documentElement.style.scrollBehavior = 'auto', 1000); // Reset scroll behavior
logDebugMain("Scrolled to first 'Ordered' row.");
}
} else { // No 'Ordered' status found
if (btn.classList.contains('btn-warning')) { // Check if it was warning
btn.className = 'btn btn-success btn-sm verify1-btn'; // Revert to success
btn.innerText = 'VERIFY1 (F7)';
btn.style.setProperty('background-color', '#28a745', 'important'); // Green
btn.style.setProperty('color', '#ffffff', 'important'); // White text
logDebugMain("No ordered status. VERIFY1 button reverted to success.");
}
hasScrolledToOrderedRow = false; // Reset scroll flag
}
}
}
setInterval(monitorOrderedStatus, CONFIG_MAIN.CHECK_INTERVALS.ORDERED_SCAN);
// --- Toast Observer for Auto Back Navigation ---
const toastObserver = new MutationObserver(mutations => {
mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE) { // Check if it's an element node
let successToast = null;
if (node.matches(CONFIG_MAIN.SELECTORS.TOAST.SUCCESS)) {
successToast = node;
} else if (node.querySelector) { // Check if it's a container with the toast inside
successToast = node.querySelector(CONFIG_MAIN.SELECTORS.TOAST.SUCCESS);
}
if (successToast) {
logDebugMain("Success toast detected.");
successToast.addEventListener('click', () => {
if ((verify1Clicked && verify1Toggle) || (verify2Clicked && verify2Toggle)) {
logDebugMain("Navigating back due to toast click and toggle state.");
window.history.back();
}
// Reset click flags after any toast interaction
verify1Clicked = false;
verify2Clicked = false;
}, { once: true }); // Ensure listener is added only once
}
}
});
});
});
// Observe the body for toast container additions, common for many toast libraries
toastObserver.observe(document.body, { childList: true, subtree: true });
// --- Keyboard Shortcuts (F7/F8) ---
document.addEventListener('keydown', e => {
// Ignore keydown events if focus is on an input, textarea, or select element
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') {
return;
}
if (e.key === 'F7') {
e.preventDefault(); // Prevent default F7 browser action
document.querySelector(CONFIG_MAIN.SELECTORS.VERIFY1_BTN)?.click();
} else if (e.key === 'F8') {
e.preventDefault(); // Prevent default F8 browser action
document.querySelector(CONFIG_MAIN.SELECTORS.VERIFY2_BTN)?.click();
}
});
// --- Integrated Alerts Scanner Code (Critical, Dilution, No-Result) ---
// Ensure SheetJS (XLSX) is available if local saving is intended (not included in this script)
if (typeof XLSX === 'undefined') {
console.warn("[Alerts Scanner] SheetJS library (XLSX) not found. Local XLSX saving will not work.");
}
const CONFIG_ALERTS = {
SCAN_INTERVAL: 750, // ms
FLASH_COLOR: "pink",
FLASH_INTERVAL: 700, // ms for flashing
RESULT_CELL_SELECTOR: 'div[role="gridcell"][col-id="TestResult"] app-result-value-render div',
UOM_CELL_SELECTOR: 'div[col-id="UomValue"]',
CRITICAL_FLAG_SELECTOR: 'div[role="gridcell"][col-id="LTFlag"] app-ref-high-low div span.critial-alret-indication',
TEST_DESC_PINNED_SELECTOR: '.ag-pinned-left-cols-container .ag-row div[col-id="TestDesc"]', // For AG-Grid pinned columns
NO_RESULT_MESSAGE: "NO-RESULT DETECTED!",
DILUTION_MESSAGE: "DILUTION REQUIRED!",
PATIENT_NAME_SELECTOR: 'div.patient-name-full',
PATIENT_MRN_SELECTOR: 'div.mid.renal-demography span', // More specific MRN selector
PATIENT_LOCATION_SELECTOR: 'span[title*="UNIT/"]', // Assuming location is in a title attribute
USER_NAME_SELECTOR: 'div.profile-wrapper span.csi-dropdown-btn-text', // User ID/Name
SAMPLE_BARCODE_SELECTOR: 'div[style*="font-size: 13px; color: rgb(68, 68, 68);"]', // Example selector, adjust if needed
LOCAL_SERVER_SAVE_URL: 'http://localhost:5000/save-alerts' // Example URL for local server
};
let isScanningActive = false;
let issueScanIntervalId = null;
let isAlertModalOpen = false; // Flag to prevent multiple modals
const logAlertDebug = msg => console.debug(`[Alerts Scanner DEBUG] ${msg}`);
const logAlertError = msg => console.error(`[Alerts Scanner ERROR] ${msg}`);
function applyFlashingEffect(row) {
if (!row || row.dataset.flashing === 'true') return;
row.dataset.flashing = 'true';
const originalBg = row.style.backgroundColor || 'transparent'; // Store original
row.dataset.originalBg = originalBg;
row.style.transition = "background-color 0.5s ease"; // Smooth transition
let isPink = false;
const intervalId = setInterval(() => {
if (!document.body.contains(row) || row.dataset.flashing === 'false') { // Stop if row removed or flashing stopped
clearInterval(intervalId);
row.style.transition = ''; // Remove transition
row.style.setProperty("background-color", row.dataset.originalBg || 'transparent', "important");
delete row.dataset.flashing;
delete row.dataset.originalBg;
delete row.dataset.flashIntervalId;
return;
}
isPink = !isPink;
row.style.setProperty("background-color", isPink ? CONFIG_ALERTS.FLASH_COLOR : originalBg, "important");
}, CONFIG_ALERTS.FLASH_INTERVAL);
row.dataset.flashIntervalId = intervalId.toString(); // Store interval ID to clear it
}
function stopFlashingEffect(row) {
if (row && row.dataset.flashing === 'true') {
row.dataset.flashing = 'false'; // Signal to stop flashing
const intervalId = parseInt(row.dataset.flashIntervalId, 10);
if (!isNaN(intervalId)) {
clearInterval(intervalId);
}
// The interval itself will handle reverting the style
}
}
function getNotificationSessionKey(type, identifier = 'general') {
// Sanitize identifier to prevent issues with special characters in sessionStorage keys
const safeIdentifier = String(identifier).replace(/[^a-zA-Z0-9_-]/g, '');
return `labAlertNotified_${window.location.pathname}${window.location.hash}_${type}_${safeIdentifier}`;
}
function hasAlreadyNotified(type, identifier = 'general') {
const key = getNotificationSessionKey(type, identifier);
return sessionStorage.getItem(key) === 'true';
}
function setNotificationFlag(type, identifier = 'general') {
const key = getNotificationSessionKey(type, identifier);
logAlertDebug(`Setting notification flag for key: "${key}"`);
sessionStorage.setItem(key, 'true');
}
function getNextEntryID() {
const counterKey = 'labAlertEntryCounter';
let currentID = parseInt(localStorage.getItem(counterKey), 10) || 0;
currentID++;
localStorage.setItem(counterKey, String(currentID));
logAlertDebug(`Next Entry ID: ${currentID}`);
return currentID;
}
async function sendAlertDataToServer(alertData) {
logAlertDebug("Attempting to send data to local server.");
if (!CONFIG_ALERTS.LOCAL_SERVER_SAVE_URL) {
logAlertError("LOCAL_SERVER_SAVE_URL is not configured.");
console.error("Local server save URL is not configured for alerts.");
return false;
}
// Get values from the modal inputs
const notifiedPersonNameInput = document.getElementById('notifiedPersonNameInput');
const notifiedPersonTelExtInput = document.getElementById('notifiedPersonTelExtInput');
const readBackCheckbox = document.getElementById('readBackCheckbox');
const userIdInput = document.getElementById('userIdInput'); // User ID from modal
const now = new Date();
const date = `${String(now.getDate()).padStart(2, '0')}/${String(now.getMonth() + 1).padStart(2, '0')}/${now.getFullYear()}`;
const hours = now.getHours();
const minutes = String(now.getMinutes()).padStart(2, '0');
const ampm = hours >= 12 ? 'pm' : 'am';
const formattedHours = hours % 12 || 12; // Convert 0 to 12 for 12 AM/PM
const time = `${formattedHours}:${minutes} ${ampm}`;
const entryID = getNextEntryID(); // Get a unique ID for this entry
const dataToSend = {
entryID: entryID,
date: date,
time: time,
patientName: alertData.patientName || 'N/A',
patientMRN: alertData.patientId || 'N/A',
patientLocation: alertData.patientLocation || 'N/A',
sampleBarcode: alertData.sampleBarcode || 'N/A',
userName: userIdInput ? userIdInput.value : (alertData.userId || 'N/A'), // Use modal input if available
notifiedPersonName: notifiedPersonNameInput ? notifiedPersonNameInput.value : '',
notifiedPersonTelExt: notifiedPersonTelExtInput ? notifiedPersonTelExtInput.value : '',
readBack: readBackCheckbox ? readBackCheckbox.checked : false,
alerts: alertData.alertsToList.map(alert => ({
testName: alert.testName,
result: alert.result,
uom: alert.uom || '',
flag: alert.flag,
type: alert.type, // e.g., 'critical', 'noresult'
comment: alert.comment || ''
})),
};
try {
const response = await fetch(CONFIG_ALERTS.LOCAL_SERVER_SAVE_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(dataToSend)
});
if (response.ok) {
logAlertDebug("Data successfully sent to local server.");
return true;
} else {
const errorText = await response.text();
logAlertError(`Failed to send data to local server: ${response.status} ${response.statusText} - ${errorText}`);
console.error(`Failed to save alert data to local server. Status: ${response.status}. Details: ${errorText}`);
return false;
}
} catch (error) {
logAlertError("Error sending data to local server:", error);
// This usually means the server is not running or not accessible (CORS, network issue)
console.error("An error occurred while trying to send data to the local server. Ensure the server is running and accessible.");
return false;
}
}
function closeAlertModalAction() {
logAlertDebug("closeAlertModalAction triggered.");
const overlay = document.getElementById('custom-alert-modal-overlay');
if (overlay) {
const modalContent = overlay.querySelector('#custom-alert-modal-content');
if (modalContent && modalContent.dataset.alerts) {
try {
const alertsToMark = JSON.parse(modalContent.dataset.alerts);
alertsToMark.forEach(alert => {
if (alert.type && alert.rowId) { // Ensure type and rowId exist
// The rowId is already prefixed like 'critical_ag-row-id-123'
// We need to extract the type and the base identifier
const parts = alert.rowId.split('_'); // e.g., ['critical', 'ag-row-id-123'] or ['noresult', 'idx', '0']
const type = parts[0];
const baseIdentifier = parts.slice(1).join('_'); // Reconstruct identifier if it had underscores
setNotificationFlag(type, baseIdentifier);
logAlertDebug(`Marked alert as notified: Type=${type}, Identifier=${baseIdentifier} (from full key ${alert.rowId})`);
}
});
} catch (e) {
console.error("Error parsing alerts data from modal dataset:", e);
}
}
// Smooth fade out
overlay.style.opacity = '0';
if (modalContent) {
modalContent.style.opacity = '0';
modalContent.style.transform = 'translateY(-30px) scale(0.95)';
}
// Remove after transition
overlay.addEventListener('transitionend', () => {
if (overlay.parentNode) {
overlay.parentNode.removeChild(overlay);
isAlertModalOpen = false; // Reset flag
document.body.style.overflow = ''; // Restore body scroll
logAlertDebug("Alert modal overlay removed, isAlertModalOpen = false");
}
}, { once: true });
// Fallback removal if transitionend doesn't fire (e.g., element removed abruptly)
setTimeout(() => {
if (overlay && overlay.parentNode) {
overlay.parentNode.removeChild(overlay);
isAlertModalOpen = false;
document.body.style.overflow = '';
logAlertDebug("Alert modal overlay removed by fallback, isAlertModalOpen = false");
}
}, 400); // Slightly longer than transition
} else {
isAlertModalOpen = false; // Ensure flag is reset even if overlay wasn't found
document.body.style.overflow = '';
logAlertDebug("closeAlertModalAction called but overlay not found, isAlertModalOpen = false");
}
}
function createCustomAlert(alertData, alertsDisplayedInModal) {
logAlertDebug(`Attempting to create alert modal. isAlertModalOpen: ${isAlertModalOpen}, Existing overlay: ${document.getElementById('custom-alert-modal-overlay')}`);
if (document.getElementById('custom-alert-modal-overlay') || isAlertModalOpen) {
logAlertDebug("Alert modal already open or overlay element exists. Aborting creation.");
return;
}
isAlertModalOpen = true;
logDebugMain("Alert modal creation initiated. isAlertModalOpen set to true.");
const overlay = document.createElement('div');
overlay.id = 'custom-alert-modal-overlay';
Object.assign(overlay.style, {
position: 'fixed', top: '0', left: '0', width: '100%', height: '100%',
backgroundColor: 'rgba(0, 0, 0, 0.65)', // Darker overlay
zIndex: '10001', // Ensure it's on top
display: 'flex', justifyContent: 'center', alignItems: 'center',
opacity: '0', transition: 'opacity 0.3s ease-out', // Fade-in transition
padding: '20px' // Padding for smaller screens so modal isn't edge-to-edge
});
document.body.style.overflow = 'hidden'; // Prevent background scrolling
const modalBox = document.createElement('div');
modalBox.id = 'custom-alert-modal-content';
Object.assign(modalBox.style, {
backgroundColor: '#ffffff',
borderRadius: '12px', // Softer corners
boxShadow: '0 16px 32px rgba(0, 0, 0, 0.1), 0 8px 16px rgba(0, 0, 0, 0.08)', // Enhanced shadow
width: 'calc(100% - 40px)', // Responsive width with padding
maxWidth: '1100px', // Max width for large screens
maxHeight: '90vh', // Max height to prevent overflow on short screens
overflowY: 'auto', // Scroll for content overflow
padding: '32px', // Generous padding
position: 'relative', // For absolute positioning of close button
opacity: '0', transform: 'translateY(-20px) scale(0.98)', // Entry animation
transition: 'opacity 0.3s ease-out, transform 0.3s ease-out',
fontFamily: "'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif",
color: '#343a40', // Dark grey for text
display: 'flex', flexDirection: 'column', gap: '16px' // Use flex for layout
});
// Close Button (X)
const closeButton = document.createElement('button');
closeButton.innerHTML = '×'; // HTML entity for 'X'
Object.assign(closeButton.style, {
position: 'absolute', top: '18px', right: '18px',
fontSize: '30px', lineHeight: '1', cursor: 'pointer', color: '#adb5bd', // Light grey
background: 'transparent', border: 'none', padding: '0', width: '30px', height: '30px',
display: 'flex', alignItems: 'center', justifyContent: 'center'
});
closeButton.onmouseover = () => closeButton.style.color = '#495057'; // Darken on hover
closeButton.onmouseout = () => closeButton.style.color = '#adb5bd';
closeButton.onclick = closeAlertModalAction;
modalBox.appendChild(closeButton);
// Header Section (Icon, Title, DateTime)
const headerDiv = document.createElement('div');
Object.assign(headerDiv.style, {
display: 'flex', alignItems: 'center',
gap: '12px', // Space between icon and title
paddingBottom: '18px', borderBottom: '1px solid #dee2e6', marginBottom: '16px'
});
const alertIcon = document.createElement('i'); // FontAwesome icon
alertIcon.className = 'fas fa-exclamation-triangle'; // Default icon
let iconColor = '#17a2b8'; // Info blue as default
let alertTitleText = 'Lab Alert Detected';
if (alertData.overallSeverity === 'critical') {
alertTitleText = 'Critical Lab Alert!'; iconColor = '#d9534f'; // Red for critical
} else if (alertData.overallSeverity === 'noresult') {
alertTitleText = 'No Result Detected'; iconColor = '#ffc107'; // Yellow for warning/no-result
} else if (alertData.overallSeverity === 'greaterThan') {
alertTitleText = 'Dilution Required'; iconColor = '#ffc107'; // Yellow for dilution
}
alertIcon.style.fontSize = '26px';
alertIcon.style.color = iconColor;
headerDiv.appendChild(alertIcon);
const title = document.createElement('h2');
title.textContent = alertTitleText;
Object.assign(title.style, { fontSize: '26px', fontWeight: '600', color: iconColor, margin: '0' });
headerDiv.appendChild(title);
const dateTime = document.createElement('p');
dateTime.id = 'currentDateTime';
const now = new Date();
dateTime.textContent = now.toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' });
Object.assign(dateTime.style, { fontSize: '14px', color: '#6c757d', margin: '0', marginLeft: 'auto' }); // Align to right
headerDiv.appendChild(dateTime);
modalBox.appendChild(headerDiv);
// Patient Information Section
const allPatientInfoContainer = document.createElement('div');
Object.assign(allPatientInfoContainer.style, {
display: 'flex',
flexDirection: 'column', // Stack items vertically
gap: '18px', // Space between info groups
paddingBottom: '18px',
borderBottom: '1px solid #dee2e6',
marginBottom: '16px'
});
const createInfoItem = (label, value, iconClass) => {
if (!value) value = 'N/A'; // Handle undefined/empty values
const itemDiv = document.createElement('div');
Object.assign(itemDiv.style, {
backgroundColor: '#f8f9fa', borderRadius: '8px', padding: '16px',
border: '1px solid #e9ecef', boxShadow: '0 3px 6px rgba(0,0,0,0.06)',
display: 'flex', flexDirection: 'column', gap: '10px'
});
const header = document.createElement('div');
Object.assign(header.style, { display: 'flex', alignItems: 'center', gap: '12px', color: '#495057' });
if (iconClass) {
const iconElem = document.createElement('i');
iconElem.className = `fas ${iconClass}`;
Object.assign(iconElem.style, { fontSize: '1.4em', color: '#007bff' }); // Blue icon
header.appendChild(iconElem);
}
const labelElem = document.createElement('strong');
labelElem.textContent = `${label}:`;
labelElem.style.fontSize = '15px'; labelElem.style.fontWeight = '600';
header.appendChild(labelElem);
itemDiv.appendChild(header);
const valueElem = document.createElement('span');
valueElem.textContent = value;
Object.assign(valueElem.style, {
fontSize: '16px', color: '#212529', wordBreak: 'break-word',
paddingLeft: iconClass ? '32px' : '0' // Indent value if icon present
});
itemDiv.appendChild(valueElem);
return itemDiv;
};
// Patient Name (full width)
const pNameItem = createInfoItem('Patient Name', alertData.patientName, 'fa-user');
if (pNameItem) {
allPatientInfoContainer.appendChild(pNameItem);
}
// Other Patient Info (Grid for MRN, Location, Barcode)
const otherPatientInfoRow = document.createElement('div');
Object.assign(otherPatientInfoRow.style, {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', // Responsive grid
gap: '18px',
});
const pIdElem = createInfoItem('MRN', alertData.patientId, 'fa-id-card');
if (pIdElem) { otherPatientInfoRow.appendChild(pIdElem); }
const pLocElem = createInfoItem('Location', alertData.patientLocation, 'fa-hospital-alt');
if (pLocElem) { otherPatientInfoRow.appendChild(pLocElem); }
const pBarcodeElem = createInfoItem('Sample Barcode', alertData.sampleBarcode, 'fa-barcode');
if (pBarcodeElem) { otherPatientInfoRow.appendChild(pBarcodeElem); }
if (otherPatientInfoRow.hasChildNodes()){
allPatientInfoContainer.appendChild(otherPatientInfoRow);
}
if (allPatientInfoContainer.hasChildNodes()){
modalBox.appendChild(allPatientInfoContainer);
}
// Alerts List Section
if (alertData.alertsToList && alertData.alertsToList.length > 0) {
const alertsListTitle = document.createElement('h3');
alertsListTitle.textContent = 'Alert Details:';
Object.assign(alertsListTitle.style, {
fontSize: '19px', fontWeight: '600', color: '#343a40',
margin: '0 0 12px 0', // Spacing
});
modalBox.appendChild(alertsListTitle);
const alertsContainer = document.createElement('div');
Object.assign(alertsContainer.style, {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', // Responsive grid for alert cards
gap: '18px',
padding: '5px 0' // Small padding for internal spacing
});
alertData.alertsToList.forEach(alert => {
const alertCard = document.createElement('div');
let borderColor = '#6c757d', bgColor = '#f8f9fa', textColor = '#343a40'; // Default styling
// Customize card style based on alert type/flag
if (alert.type === 'critical') {
if (alert.flag === 'CL') { // Critical Low
borderColor = '#007bff'; bgColor = '#e7f3ff'; // Blue theme
} else if (alert.flag === 'CH') { // Critical High
borderColor = '#dc3545'; bgColor = '#f8d7da'; // Red theme
} else { // General critical
borderColor = '#d9534f'; bgColor = '#fdf7f7';
}
} else if (alert.type === 'greaterThan') { // Dilution
borderColor = '#ffc107'; bgColor = '#fff9e6'; // Yellow theme
} else if (alert.type === 'noresult') { // No Result
borderColor = '#6c757d'; bgColor = '#f8f9fa'; // Grey theme
}
Object.assign(alertCard.style, {
borderLeft: `6px solid ${borderColor}`, padding: '18px', borderRadius: '10px',
backgroundColor: bgColor, color: textColor, boxShadow: '0 5px 10px rgba(0,0,0,0.08)',
display: 'flex', flexDirection: 'column', justifyContent: 'space-between', minHeight: '130px'
});
const testNameElem = document.createElement('p');
testNameElem.innerHTML = `<strong style="font-size: 17px; color: #212529;">${alert.testName}</strong>`;
testNameElem.style.margin = '0 0 10px 0';
alertCard.appendChild(testNameElem);
const resultElem = document.createElement('p');
const resultText = alert.result || 'N/A';
const uomText = alert.uom ? ` ${alert.uom}` : '';
resultElem.textContent = `Result: ${resultText}${uomText}`;
resultElem.style.margin = '0 0 10px 0';
resultElem.style.fontSize = '15px';
alertCard.appendChild(resultElem);
if (alert.flag) {
const flagElem = document.createElement('p');
let flagDescription = `Flag: ${alert.flag}`;
if (alert.flag === 'CL') flagDescription = 'Flag: CL (Critical Low)';
else if (alert.flag === 'CH') flagDescription = 'Flag: CH (Critical High)';
flagElem.textContent = flagDescription;
Object.assign(flagElem.style, {margin: '0', fontSize: '15px', fontWeight: '600', color: borderColor });
alertCard.appendChild(flagElem);
}
if (alert.comment) { // Display comment if present
const commentElem = document.createElement('p');
commentElem.textContent = alert.comment;
Object.assign(commentElem.style, {
fontSize: '13px', fontStyle: 'italic', color: '#555',
marginTop: '8px', paddingTop: '8px', borderTop: '1px dashed #ccc'
});
alertCard.appendChild(commentElem);
}
alertsContainer.appendChild(alertCard);
});
modalBox.appendChild(alertsContainer);
} else if (alertData.primaryMessage) { // Fallback for single message if no list
const primaryMessageElem = document.createElement('p');
primaryMessageElem.textContent = alertData.primaryMessage;
Object.assign(primaryMessageElem.style, { fontSize: '16px', fontWeight: '500', margin: '12px 0', color: '#555' });
modalBox.appendChild(primaryMessageElem);
}
// Notification Details Form (for Critical Alerts)
if (alertData.overallSeverity === 'critical') {
const notifiedDetailsDiv = document.createElement('div');
Object.assign(notifiedDetailsDiv.style, {
marginTop: '24px', paddingTop: '20px', borderTop: '1px solid #dee2e6'
});
const h3 = document.createElement('h3');
h3.textContent = 'Notification Details (Critical)';
Object.assign(h3.style, { fontSize: '17px', fontWeight: '600', marginBottom: '16px', color: '#343a40'});
notifiedDetailsDiv.appendChild(h3);
const formGrid = document.createElement('div');
Object.assign(formGrid.style, { display: 'grid', gridTemplateColumns: '1fr', gap: '14px' }); // Single column for simplicity
const createFormGroup = (label, inputId, placeholder, type = 'text') => {
const formGroup = document.createElement('div');
const formLabel = document.createElement('label');
formLabel.textContent = label;
formLabel.htmlFor = inputId;
Object.assign(formLabel.style, { display: 'block', fontSize: '14px', fontWeight: '500', marginBottom: '6px', color: '#495057' });
formGroup.appendChild(formLabel);
const formInput = document.createElement('input');
formInput.type = type;
formInput.id = inputId;
formInput.placeholder = placeholder;
Object.assign(formInput.style, {
width: '100%', padding: '12px', border: '1px solid #ced4da',
borderRadius: '6px', fontSize: '14px', boxSizing: 'border-box',
transition: 'border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out'
});
formInput.onfocus = () => { formInput.style.borderColor = '#80bdff'; formInput.style.boxShadow = '0 0 0 0.2rem rgba(0,123,255,.25)';};
formInput.onblur = () => { formInput.style.borderColor = '#ced4da'; formInput.style.boxShadow = 'none';};
formGroup.appendChild(formInput);
return formGroup;
};
const userIdGroup = createFormGroup('User ID', 'userIdInput', 'Enter your User ID');
formGrid.appendChild(userIdGroup);
const userIdInputElem = userIdGroup.querySelector('#userIdInput'); // Get the input element
if (userIdInputElem && alertData.userId && alertData.userId !== 'N/A') { // Pre-fill if available
userIdInputElem.value = alertData.userId;
}
formGrid.appendChild(createFormGroup('Notified Person Name', 'notifiedPersonNameInput', 'Name of person notified'));
formGrid.appendChild(createFormGroup('Extension / Contact', 'notifiedPersonTelExtInput', 'Phone or extension'));
const readBackGroup = document.createElement('div');
Object.assign(readBackGroup.style, { display: 'flex', alignItems: 'center', marginTop: '10px'});
const readBackCheckbox = document.createElement('input');
readBackCheckbox.type = 'checkbox'; readBackCheckbox.id = 'readBackCheckbox';
Object.assign(readBackCheckbox.style, { marginRight: '10px', height: '18px', width: '18px', cursor: 'pointer', accentColor: '#007bff'});
const readBackLabel = document.createElement('label');
readBackLabel.textContent = 'Read-Back Confirmed'; readBackLabel.htmlFor = 'readBackCheckbox';
Object.assign(readBackLabel.style, { fontSize: '14px', fontWeight: '500', color: '#495057', cursor: 'pointer'});
readBackGroup.appendChild(readBackCheckbox); readBackGroup.appendChild(readBackLabel);
formGrid.appendChild(readBackGroup);
notifiedDetailsDiv.appendChild(formGrid);
modalBox.appendChild(notifiedDetailsDiv);
}
// Button Container
const buttonContainer = document.createElement('div');
Object.assign(buttonContainer.style, {
display: 'flex', justifyContent: 'flex-end', // Align buttons to the right
paddingTop: '24px', marginTop: 'auto', // Push to bottom if content is short
gap: '12px', borderTop: '1px solid #dee2e6'
});
const createModalButton = (text, styleType = 'primary') => {
const button = document.createElement('button');
button.textContent = text;
Object.assign(button.style, {
padding: '12px 24px', borderRadius: '8px', fontWeight: '600', fontSize: '15px',
border: 'none', cursor: 'pointer',
transition: 'background-color 0.2s ease, box-shadow 0.2s ease, transform 0.1s ease'
});
if (styleType === 'primary') {
button.style.backgroundColor = '#007bff'; button.style.color = 'white';
button.onmouseover = () => button.style.backgroundColor = '#0069d9';
button.onmouseout = () => button.style.backgroundColor = '#007bff';
} else if (styleType === 'success') { // For Acknowledge & Save
button.style.backgroundColor = '#28a745'; button.style.color = 'white';
button.onmouseover = () => button.style.backgroundColor = '#218838';
button.onmouseout = () => button.style.backgroundColor = '#28a745';
} else { // Default/secondary
button.style.backgroundColor = '#6c757d'; button.style.color = 'white';
button.onmouseover = () => button.style.backgroundColor = '#5a6268';
button.onmouseout = () => button.style.backgroundColor = '#6c757d';
}
button.onmousedown = () => button.style.transform = 'scale(0.98)'; // Click effect
button.onmouseup = () => button.style.transform = 'scale(1)';
return button;
};
if (alertData.overallSeverity === 'critical') {
const acknowledgeButton = createModalButton('Acknowledge & Save', 'success');
acknowledgeButton.onclick = async () => {
logAlertDebug("Acknowledge & Save button clicked.");
const success = await sendAlertDataToServer(alertData);
if (success) {
logAlertDebug("Data sent successfully. Closing modal.");
closeAlertModalAction();
} else {
logAlertDebug("Data send failed. Modal remains open.");
const errorMsgElem = document.getElementById('sendErrorMsg');
if (errorMsgElem) errorMsgElem.textContent = 'Failed to save. Check server or try again.';
}
};
buttonContainer.appendChild(acknowledgeButton);
// Error message placeholder for send failure
const sendErrorMsg = document.createElement('p');
sendErrorMsg.id = 'sendErrorMsg';
Object.assign(sendErrorMsg.style, {color: 'red', fontSize: '13px', margin: '0 auto 0 0', flexGrow: 1, alignSelf: 'center'});
buttonContainer.insertBefore(sendErrorMsg, acknowledgeButton); // Place error message before buttons
} else { // For non-critical, just an OK button
const okButton = createModalButton('OK', 'primary');
okButton.onclick = closeAlertModalAction;
buttonContainer.appendChild(okButton);
}
modalBox.appendChild(buttonContainer);
// Store a list of alerts that were shown in this modal instance for later marking as notified
modalBox.dataset.alerts = JSON.stringify(alertsDisplayedInModal.map(a => ({ type: a.type, rowId: a.rowId })));
overlay.appendChild(modalBox);
document.body.appendChild(overlay);
// Trigger fade-in and animation
setTimeout(() => {
overlay.style.opacity = '1';
modalBox.style.opacity = '1';
modalBox.style.transform = 'translateY(0) scale(1)';
logAlertDebug("Alert modal fade-in and animation triggered.");
}, 10); // Small delay to ensure styles are applied before transition starts
// Close modal on Escape key or click outside
overlay.addEventListener('click', (event) => {
if (event.target === overlay) { // Clicked on overlay itself
closeAlertModalAction();
}
});
const escapeKeyListener = (event) => {
if (event.key === 'Escape' && document.body.contains(overlay)) { // Check if modal is still in DOM
closeAlertModalAction();
document.removeEventListener('keydown', escapeKeyListener); // Clean up listener
}
};
document.addEventListener('keydown', escapeKeyListener);
}
function scrollToRowNearest(row) {
if (!row) return false;
let rowToScroll = row;
// If the row is in the center container, try to find its corresponding pinned row for scrolling
// This helps if the test description (usually pinned) is what needs to be visible
if (row.closest('.ag-center-cols-container')) {
const rowIndex = row.getAttribute('row-index'); // AG-Grid often uses row-index
if (rowIndex) {
const pinnedRow = document.querySelector(`.ag-pinned-left-cols-container .ag-row[row-index="${rowIndex}"]`);
if (pinnedRow) rowToScroll = pinnedRow;
}
}
document.documentElement.style.scrollBehavior = 'smooth';
rowToScroll.scrollIntoView({ behavior: 'auto', block: 'center' }); // 'auto' for behavior if smooth is too slow
setTimeout(() => document.documentElement.style.scrollBehavior = 'auto', 500); // Reset scroll behavior
return true;
}
async function getPatientDataWithRetry(selectors, maxWaitTime = 2000, checkInterval = 100) {
let elapsedTime = 0;
let patientData = { patientName: 'N/A', patientId: 'N/A', patientLocation: 'N/A', sampleBarcode: 'N/A' };
// Helper to check if essential elements are present
function elementsPresent() {
return document.querySelector(selectors.name) &&
document.querySelector(selectors.mrn) &&
document.querySelector(selectors.location); // Barcode is optional for this check
}
// Wait for essential elements to be available
while (elapsedTime < maxWaitTime) {
if (elementsPresent()) {
logAlertDebug("Essential patient data elements detected.");
break;
}
await new Promise(resolve => setTimeout(resolve, checkInterval));
elapsedTime += checkInterval;
if (elapsedTime % 500 === 0) { // Log progress occasionally
logAlertDebug(`Waiting for patient data elements... ${elapsedTime}ms`);
}
}
if (!elementsPresent() && elapsedTime >= maxWaitTime) {
logAlertDebug(`Essential patient data elements not found after ${maxWaitTime}ms. Using N/A for some fields.`);
}
// Retrieve data, providing 'N/A' if elements are not found or empty
const nameEl = document.querySelector(selectors.name);
patientData.patientName = nameEl ? nameEl.textContent.trim().replace(/\s+/g, ' ') : 'N/A'; // Normalize whitespace
const mrnEl = document.querySelector(selectors.mrn);
patientData.patientId = mrnEl ? mrnEl.textContent.trim() : 'N/A';
const locElement = document.querySelector(selectors.location);
// Prefer title attribute for location if available, otherwise textContent
patientData.patientLocation = locElement ? (locElement.title?.trim() || locElement.textContent?.trim()) : 'N/A';
if (patientData.patientLocation === '') patientData.patientLocation = 'N/A'; // Ensure not empty string
const barcodeEl = document.querySelector(selectors.barcode);
patientData.sampleBarcode = barcodeEl ? barcodeEl.textContent.trim() : 'N/A';
if (patientData.sampleBarcode === '') patientData.sampleBarcode = 'N/A';
return patientData;
}
async function checkForIssues() {
if (!isScanningActive || isAlertModalOpen) return false; // Don't scan if paused or modal is already open
const patientDataSelectors = {
name: CONFIG_ALERTS.PATIENT_NAME_SELECTOR,
mrn: CONFIG_ALERTS.PATIENT_MRN_SELECTOR,
location: CONFIG_ALERTS.PATIENT_LOCATION_SELECTOR,
barcode: CONFIG_ALERTS.SAMPLE_BARCODE_SELECTOR
};
const patientDataSource = await getPatientDataWithRetry(patientDataSelectors);
const patientName = patientDataSource.patientName;
const patientId = patientDataSource.patientId;
const patientLocation = patientDataSource.patientLocation;
const sampleBarcode = patientDataSource.sampleBarcode;
const userIdElement = document.querySelector(CONFIG_ALERTS.USER_NAME_SELECTOR);
const currentUserId = userIdElement ? userIdElement.textContent.trim() : 'N/A';
const centerRows = document.querySelectorAll('.ag-center-cols-container .ag-row');
const pinnedRows = document.querySelectorAll('.ag-pinned-left-cols-container .ag-row');
const potentialAlerts = [];
centerRows.forEach((centerRow, index) => {
const pinnedRow = pinnedRows[index]; // Assumes matching indices for pinned and center rows
if (!centerRow || !pinnedRow || centerRow.offsetParent === null) return; // Skip if not visible or no matching pinned row
const testDescElement = pinnedRow.querySelector('div[col-id="TestDesc"]');
const testDesc = testDescElement?.textContent?.trim() || 'Unknown Test';
const resultDiv = centerRow.querySelector(CONFIG_ALERTS.RESULT_CELL_SELECTOR);
const flagSpan = centerRow.querySelector(CONFIG_ALERTS.CRITICAL_FLAG_SELECTOR);
const uomCell = centerRow.querySelector(CONFIG_ALERTS.UOM_CELL_SELECTOR);
// Create a unique identifier for the row for sessionStorage tracking
const baseRowIdentifier = centerRow.getAttribute('row-id') || centerRow.getAttribute('row-index') || `idx_${index}`;
const resultText = resultDiv?.textContent?.trim() || '';
const uomText = uomCell?.textContent?.trim() || '';
const normalizedResultText = resultText.toLowerCase().replace(/[- ]/g, ''); // Normalize for "no-result" checks
const flagText = flagSpan?.textContent?.trim()?.toUpperCase() || ''; // CL, CH
// Determine alert type
const isNoResult = ["noresult", "noxresult", "x-noresult"].includes(normalizedResultText);
const isDilution = resultText.includes(">"); // Simple check for dilution needed
const isCritical = (flagSpan && (flagText === "CL" || flagText === "CH"));
let alertType = null;
let alertMessage = "";
let alertComment = null; // For additional context like "Sample needs dilution"
if (isCritical) {
alertType = "critical";
alertMessage = `CRITICAL ${flagText === "CL" ? "LOW" : "HIGH"} RESULT!`;
} else if (isNoResult) {
alertType = "noresult";
alertMessage = CONFIG_ALERTS.NO_RESULT_MESSAGE;
} else if (isDilution) {
alertType = "greaterThan";
alertMessage = CONFIG_ALERTS.DILUTION_MESSAGE;
alertComment = "Sample needs dilution"; // Add specific comment for dilution
}
if (alertType) {
if (!hasAlreadyNotified(alertType, baseRowIdentifier)) {
potentialAlerts.push({
rowElement: centerRow, type: alertType,
rowId: `${alertType}_${baseRowIdentifier}`, // Composite key for session storage
baseIdentifier: baseRowIdentifier, // Store base for easier reference
message: alertMessage,
comment: alertComment,
testName: testDesc, result: resultText, uom: uomText, flag: flagText
});
}
applyFlashingEffect(centerRow); // Flash the center row
applyFlashingEffect(pinnedRow); // Flash the corresponding pinned row
} else {
stopFlashingEffect(centerRow);
stopFlashingEffect(pinnedRow);
}
});
if (potentialAlerts.length > 0 && !isAlertModalOpen && !document.getElementById('custom-alert-modal-overlay')) {
// Prioritize showing critical alerts first if multiple types are present
const firstSignificantAlert =
potentialAlerts.find(a => a.type === 'critical') ||
potentialAlerts.find(a => a.type === 'noresult') ||
potentialAlerts.find(a => a.type === 'greaterThan') ||
potentialAlerts[0]; // Fallback to the first alert
scrollToRowNearest(firstSignificantAlert.rowElement); // Scroll to the most significant alert
const modalData = {
alertsToList: potentialAlerts.map(a => ({ // Pass all details for display
testName: a.testName, result: a.result, uom: a.uom, flag: a.flag, type: a.type,
rowId: a.rowId, // Pass the full rowId used for notification tracking
comment: a.comment
})),
overallSeverity: firstSignificantAlert.type, // Used for modal title and icon
primaryMessage: firstSignificantAlert.message, // Main message for the modal
patientName: patientName,
patientId: patientId,
patientLocation: patientLocation,
sampleBarcode: sampleBarcode,
userId: currentUserId
};
createCustomAlert(modalData, potentialAlerts); // Pass all potential alerts for marking
// Pause scanner after showing alerts for this patient to avoid repeated modals for same data
logAlertDebug("Alerts shown for patient. Pausing scanner.");
if (window.stopAlertsScanner) window.stopAlertsScanner(); // Call the global pause function
return true; // Alerts were found and processed
} else if (potentialAlerts.length === 0) {
// If no alerts, ensure any residual flashing is stopped
document.querySelectorAll('.ag-center-cols-container .ag-row[data-flashing="true"]').forEach(stopFlashingEffect);
document.querySelectorAll('.ag-pinned-left-cols-container .ag-row[data-flashing="true"]').forEach(stopFlashingEffect);
}
return false; // No new alerts to show
}
function startAlertsScannerInternal() {
logAlertDebug("startAlertsScannerInternal called.");
if (!isScanningActive && issueScanIntervalId === null) { // Only start if not already active and no interval ID
logAlertDebug("Alert Scanner starting/resuming...");
isScanningActive = true;
if (issueScanIntervalId) clearInterval(issueScanIntervalId); // Clear any old interval just in case
checkForIssues(); // Initial check
issueScanIntervalId = setInterval(checkForIssues, CONFIG_ALERTS.SCAN_INTERVAL);
logAlertDebug(`Alert Scanner interval set: ${issueScanIntervalId}`);
} else {
logAlertDebug(`Alert Scanner already active or interval set. Active: ${isScanningActive}, IntervalID: ${issueScanIntervalId}`);
}
}
function stopAlertsScannerInternal() { // This acts more like a "pause"
logAlertDebug("stopAlertsScannerInternal called (pause).");
if (isScanningActive) { // Only act if it was active
logAlertDebug("Alert Scanner pausing...");
clearInterval(issueScanIntervalId);
issueScanIntervalId = null; // Clear interval ID, but keep isScanningActive = true
// This indicates it's paused but should resume on new patient/page
// Flashing rows will stop naturally when modal is closed or page changes.
logAlertDebug("Scanner interval cleared for pause. isScanningActive remains true (paused state).");
} else {
logAlertDebug("Alert Scanner not active, no action taken by stop/pause.");
}
}
// Expose scanner controls globally for external management (e.g., on page change)
window.startAlertsScanner = startAlertsScannerInternal;
window.stopAlertsScanner = stopAlertsScannerInternal; // This is now a "pause"
// --- Page Navigation Observer (for starting/stopping scanner and features) ---
let previousObservedUrl = window.location.href;
let currentPatientProcessed = false; // Flag to manage scanner pause/resume per patient view
const pageUrlObserver = new MutationObserver(() => {
const newObservedUrl = window.location.href;
if (newObservedUrl !== previousObservedUrl) {
logDebugMain(`URL changed from: ${previousObservedUrl} to: ${newObservedUrl}.`);
currentPatientProcessed = false; // Reset flag, indicating a new patient/context
const wasOnEditPage = previousObservedUrl.startsWith(CONFIG_MAIN.URLS.EDIT_PAGE_PREFIX);
const nowOnEditPage = newObservedUrl.startsWith(CONFIG_MAIN.URLS.EDIT_PAGE_PREFIX);
if (wasOnEditPage && !nowOnEditPage) { // Navigated AWAY from an edit page
logDebugMain(`Left an edit page (${previousObservedUrl}). Fully stopping scanner and cleaning UI.`);
isScanningActive = false; // Truly stop the scanner
if (window.stopAlertsScanner) window.stopAlertsScanner(); // Call the pause, but isScanningActive=false prevents restart
// Clear session storage flags for the *specific page* being left
try {
const oldUrlObject = new URL(previousObservedUrl, window.location.origin); // Ensure base URL for relative paths
const oldPageKeyPrefix = `labAlertNotified_${oldUrlObject.pathname}${oldUrlObject.hash}_`;
logAlertDebug(`Attempting to clear session flags with prefix: "${oldPageKeyPrefix}"`);
Object.keys(sessionStorage)
.filter(key => key.startsWith(oldPageKeyPrefix))
.forEach(key => {
sessionStorage.removeItem(key);
logAlertDebug(`Cleared session flag: ${key}`);
});
logAlertDebug(`Session flags for prefix "${oldPageKeyPrefix}" cleared.`);
} catch (e) {
logAlertError(`Error processing old URL (${previousObservedUrl}) for flag clearing: ${e}`);
}
// Remove buttons if they exist
const customButtons = document.getElementById('custom-script-buttons');
if (customButtons) customButtons.remove();
}
if (nowOnEditPage) {
logDebugMain(`Now on a new edit page (${newObservedUrl}). Setting up features and (re)starting scanner.`);
hasScrolledToOrderedRow = false; // Reset for new page
isAlertModalOpen = false; // Reset modal flag
// Close any existing alert modal forcefully if navigation happened while it was open
const existingAlertOverlay = document.getElementById('custom-alert-modal-overlay');
if (existingAlertOverlay) existingAlertOverlay.remove();
setTimeout(() => {
// Start scanner only if not already "processed" (i.e., paused after showing alerts for this patient)
if (!currentPatientProcessed && window.startAlertsScanner) {
window.startAlertsScanner();
}
addButtons(); // Add F7/F8 buttons
addFontAwesome(); // Ensure icons are available
monitorOrderedStatus(); // Initial check for 'Ordered' status
}, 700); // Delay to allow page elements to load
} else { // Not on an edit page
logDebugMain(`Not on an edit page (${newObservedUrl}). Ensuring UI cleanup.`);
const customButtons = document.getElementById('custom-script-buttons');
if (customButtons) customButtons.remove();
// If truly leaving edit pages, ensure scanner is fully stopped
if (isScanningActive && window.stopAlertsScanner) {
isScanningActive = false; // Mark as not active
window.stopAlertsScanner(); // Call the pause/clear interval
}
}
previousObservedUrl = newObservedUrl;
} else { // URL has not changed, but mutations occurred
const stillOnEditPage = newObservedUrl.startsWith(CONFIG_MAIN.URLS.EDIT_PAGE_PREFIX);
if (stillOnEditPage) {
// Check if buttons are missing (e.g., due to SPA re-render without URL change)
if (!document.getElementById('custom-script-buttons')) {
logDebugMain("Buttons missing on edit page (no URL change), re-adding.");
addButtons();
}
addFontAwesome(); // Ensure icons are still there
// If scanner was paused (isScanningActive=true, but intervalId=null) and it's still the same patient page,
// it should remain paused.
// If it's a page refresh for a new patient (somehow URL didn't change initially),
// startAlertsScanner will handle it if currentPatientProcessed is false.
if (!isScanningActive && !currentPatientProcessed && window.startAlertsScanner) {
// This case might be rare, but handles if scanner was fully stopped and needs restart on same URL
logDebugMain("Scanner not active on current edit page (no URL change), attempting restart.");
window.startAlertsScanner();
}
}
}
});
pageUrlObserver.observe(document.body, { childList: true, subtree: true });
// --- Initial Setup on Load ---
if (isCorrectPage()) {
logDebugMain("Initial page load on correct page. Setting up features.");
currentPatientProcessed = false; // Reset for initial load
setTimeout(() => {
if (window.startAlertsScanner) window.startAlertsScanner();
addFontAwesome();
addButtons();
monitorOrderedStatus();
}, 700); // Delay for page elements
} else {
logDebugMain("Initial page load not on correct page. No features started.");
}
// --- Periodic Checks ---
setInterval(checkUrlAndTriggerClickForUndefined, CONFIG_MAIN.CHECK_INTERVALS.UNDEFINED_URL);
setInterval(checkForDisabledButtons, CONFIG_MAIN.CHECK_INTERVALS.DISABLED_BTN_CHECK); // Less frequent check for disabled buttons
})();
// User's SAMPLES COUNT Logic (IIFE) - Integrated and Styles Updated
// This is the logic from the original "1.js" script, integrated here.
(function () {
'use strict';
const SCRIPT_PREFIX_SAMPLES = "[SAMPLES COUNT 통합]"; // Changed prefix for clarity
const logSampleCountDebug = msg => console.debug(`${SCRIPT_PREFIX_SAMPLES} ${msg}`);
// const logSampleCountError = msg => console.error(`${SCRIPT_PREFIX_SAMPLES} ${msg}`); // Already defined in main script
function createCounterElementSamples(id) { // Renamed to avoid conflict if any global function existed
const wrapper = document.createElement('span');
wrapper.id = id;
Object.assign(wrapper.style, {
display: 'inline-flex',
alignItems: 'center',
padding: '6px 12px',
backgroundColor: '#e9ecef', // Light grey background
borderRadius: '20px', // Rounded corners
boxShadow: '0 2px 5px rgba(0,0,0,0.1)', // Subtle shadow
marginRight: 'auto', // Pushes to the left if in a flex container
marginLeft: '10px', // Margin from other elements
fontSize: '14px',
fontWeight: '500',
color: '#495057', // Dark grey text
verticalAlign: 'middle'
});
const label = document.createElement('span');
label.textContent = 'SAMPLES COUNT: ';
label.style.marginRight = '8px';
const badge = document.createElement('span');
badge.className = 'sample-count-badge'; // For potential specific styling via CSS
badge.textContent = '0'; // Initialize with 0
Object.assign(badge.style, {
backgroundColor: '#6c757d', // Default grey for 0
color: '#ffffff', // White text
padding: '4px 10px',
borderRadius: '12px', // Rounded badge
fontSize: '14px',
fontWeight: 'bold',
minWidth: '26px', // Ensure badge has some width even for single digit
textAlign: 'center',
lineHeight: '1' // Ensure text is centered vertically
});
wrapper.appendChild(label);
wrapper.appendChild(badge);
return wrapper;
}
function updateSpecificCounterSamples(modalElementForInputs, counterElement, inputSelector) {
const badge = counterElement.querySelector('.sample-count-badge');
// Ensure all elements are valid and connected to the DOM
if (!badge || !modalElementForInputs || !document.body.contains(modalElementForInputs) || !counterElement.isConnected) {
return;
}
const inputs = modalElementForInputs.querySelectorAll(inputSelector);
const count = inputs.length;
badge.textContent = count;
badge.style.backgroundColor = count > 0 ? '#28a745' : '#6c757d'; // Green if > 0, else grey
}
// This function sets up a counter for a specific modal type
function setupModalCounterSamples(modalConfig) {
const { modalKeyElement, targetFooter, counterId, inputSelector, activeIntervalsMap, buttonIdForLog } = modalConfig;
logSampleCountDebug(`Setup for ${buttonIdForLog}: ModalKeyElement found: ${!!modalKeyElement}, TargetFooter found: ${!!targetFooter}`);
if (targetFooter.querySelector('#' + counterId)) {
logSampleCountDebug(`Setup for ${buttonIdForLog}: Counter (ID: ${counterId}) already exists in footer. Skipping.`);
return false; // Counter already exists
}
logSampleCountDebug(`Setup for ${buttonIdForLog}: Creating counter (ID: ${counterId}).`);
const counter = createCounterElementSamples(counterId);
targetFooter.insertBefore(counter, targetFooter.firstChild); // Insert at the beginning of the footer
logSampleCountDebug(`Setup for ${buttonIdForLog}: Counter (ID: ${counterId}) CREATED and INSERTED.`);
// Determine the element within the modal to search for inputs
const modalElementForInputs = modalKeyElement.querySelector('.modal-body') || modalKeyElement.querySelector('.modal-content') || modalKeyElement;
const interval = setInterval(() => {
const modalStillTracked = document.body.contains(modalKeyElement);
const counterStillPresent = counter.isConnected;
// Check modal visibility (common classes and style checks)
const modalVisible = modalStillTracked && (
modalKeyElement.classList.contains('show') ||
modalKeyElement.classList.contains('modal-open') ||
(modalKeyElement.style.display && modalKeyElement.style.display !== 'none') ||
(!modalKeyElement.style.display && modalKeyElement.offsetParent !== null) // Check if rendered
);
if (!modalStillTracked || !counterStillPresent || !modalVisible) {
logSampleCountDebug(`Interval for ${buttonIdForLog} (Counter ID: ${counterId}): Cleaning up. ModalTracked: ${modalStillTracked}, CounterPresent: ${counterStillPresent}, ModalVisible: ${modalVisible}`);
clearInterval(interval);
activeIntervalsMap.delete(modalKeyElement); // Remove from active tracking
if (counter.isConnected) { // Remove counter from DOM if it's still there
counter.remove();
}
return;
}
updateSpecificCounterSamples(modalElementForInputs, counter, inputSelector);
}, 500); // Update interval
activeIntervalsMap.set(modalKeyElement, { interval, counterId }); // Track active interval
return true;
}
function observeModalsSamples() { // Renamed to avoid conflict
const activeModalIntervals = new Map(); // Tracks modals and their counter intervals
logSampleCountDebug("Sample Count Modal observer callback function defined.");
const getModalKeyDetails = (el) => { // Helper for logging
if (!el) return "null";
const tagName = el.tagName || "UNKNOWN_TAG";
let className = "";
if (el.className && typeof el.className === 'string') {
className = el.className.trim().replace(/\s+/g, '.');
} else if (el.className && typeof el.className.baseVal === 'string') { // For SVG elements
className = el.className.baseVal.trim().replace(/\s+/g, '.');
}
return `${tagName}${className ? '.' + className : ''}`;
};
const observer = new MutationObserver((mutationsList) => {
// --- Target Modal Type 1 (Sample Collection / CPL Receptionist Modal) ---
// Usually identified by a button like 'btnclose-smplcollection' or specific app component
const buttonSmplCollection = document.querySelector('button#btnclose-smplcollection'); // Or other unique element in this modal
if (buttonSmplCollection) {
const footerSmplCollection = buttonSmplCollection.closest('.modal-footer');
if (footerSmplCollection) {
// The modalKeyElement should be the main modal container that stays in the DOM while the modal is open
// and is removed or hidden when closed. '.modal' or '.modal-dialog' are common.
const modalKeyElement = footerSmplCollection.closest('.modal.show, .modal-dialog, modal-container.show'); // More robust selector
if (modalKeyElement && !activeModalIntervals.has(modalKeyElement)) {
logSampleCountDebug(`Found potential modal for 'btnclose-smplcollection' (modal key: ${getModalKeyDetails(modalKeyElement)}). Attempting setup.`);
setupModalCounterSamples({
buttonIdForLog: 'btnclose-smplcollection',
counterId: 'inline-counter-smplcollection',
inputSelector: 'tbody[formarrayname="TubeTypeList"] input[formcontrolname="PatientID"]', // Specific to this modal
modalKeyElement: modalKeyElement,
targetFooter: footerSmplCollection,
activeIntervalsMap: activeModalIntervals
});
}
}
}
// --- Target Modal Type 2 (Sample Receive / WB Modal) ---
// Usually identified by a button like 'closebtn-smplrecieve' or specific app component
const buttonSmplRecieve = document.querySelector('button#closebtn-smplrecieve'); // Or other unique element
if (buttonSmplRecieve) {
const footerSmplRecieve = buttonSmplRecieve.closest('.modal-footer');
if (footerSmplRecieve) {
const modalKeyElement = footerSmplRecieve.closest('.modal.show, .modal-dialog, modal-container.show');
if (modalKeyElement && !activeModalIntervals.has(modalKeyElement)) {
logSampleCountDebug(`Found potential modal for 'closebtn-smplrecieve' (modal key: ${getModalKeyDetails(modalKeyElement)}). Attempting setup.`);
setupModalCounterSamples({
buttonIdForLog: 'closebtn-smplrecieve',
counterId: 'inline-counter-smplrecieve',
inputSelector: 'td input[formcontrolname="PatientID"]', // Specific to this modal's table structure
modalKeyElement: modalKeyElement,
targetFooter: footerSmplRecieve,
activeIntervalsMap: activeModalIntervals
});
}
}
}
// Fallback cleanup for modals that might have been missed by interval cleanup
activeModalIntervals.forEach((data, modalElemKey) => {
const modalStillPresent = document.body.contains(modalElemKey);
const counterElem = document.getElementById(data.counterId);
const modalStillVisible = modalStillPresent && (
modalElemKey.classList.contains('show') ||
modalElemKey.classList.contains('modal-open') ||
(modalElemKey.style.display && modalElemKey.style.display !== 'none') ||
(!modalElemKey.style.display && modalElemKey.offsetParent !== null)
);
if (!modalStillPresent || !modalStillVisible) {
logSampleCountDebug(`Fallback Cleanup: Modal for counter ID ${data.counterId} (key: ${getModalKeyDetails(modalElemKey)}) no longer present/visible. Cleaning up.`);
clearInterval(data.interval);
if (counterElem && counterElem.isConnected) {
counterElem.remove();
}
activeModalIntervals.delete(modalElemKey);
} else if (counterElem && !counterElem.isConnected) { // Counter removed but modal still there (e.g. by other script)
logSampleCountDebug(`Fallback Cleanup: Counter ID ${data.counterId} (key: ${getModalKeyDetails(modalElemKey)}) is disconnected but modal still present. Cleaning interval.`);
clearInterval(data.interval);
activeModalIntervals.delete(modalElemKey);
}
});
});
observer.observe(document.body, { childList: true, subtree: true });
logSampleCountDebug("Sample Count Modal observer INSTANCE started watching document.body.");
}
window.addEventListener('load', () => {
logSampleCountDebug("Window 'load' event fired for SAMPLES COUNT. Initializing modal observer.");
try {
observeModalsSamples();
} catch (e) {
console.error(`${SCRIPT_PREFIX_SAMPLES} Error during observeModalsSamples initialization: ${e.message} \n${e.stack}`);
}
});
logSampleCountDebug("UserScript SAMPLES COUNT logic loaded and IIFE executed.");
})();