// ==UserScript==
// @name Pending barcodes
// @namespace http://tampermonkey.net/
// @version 1.9
// @description Collects barcodes automatically from a table, timestamps them, and provides a viewing interface.
// @author Hamad AlShegifi
// @match *://his.kaauh.org/lab/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addStyle
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// --- Configuration ---
const TABLE_BODY_SELECTOR = 'tbody[formarrayname="TubeTypeList"]';
const MENU_INJECTION_SELECTOR = 'span.csi-menu-text[title="Documents"]';
const BARCODE_DISPLAY_SELECTOR = '#barcode-display-box'; // The ID of the box displaying the current patient's barcode
const STORAGE_KEY = 'collectedBarcodes_storage';
// --- State Flags & Cache ---
let isMenuViewAdded = false;
const collectedBarcodesThisSession = new Set();
let lastCheckedPatientBarcode = null; // Caches the barcode currently displayed on the page
// --- Main Logic ---
// Function to initialize the script and set up a single, persistent observer
function initialize() {
console.log("Barcode Collector: Script started. Observing for page changes...");
// Use a single MutationObserver to watch for all dynamically loaded elements
const observer = new MutationObserver((mutations, obs) => {
const documentsMenu = document.querySelector(MENU_INJECTION_SELECTOR);
const patientBarcodeBox = document.querySelector(BARCODE_DISPLAY_SELECTOR);
// Add the custom menu item (runs only once)
if (documentsMenu && !isMenuViewAdded) {
console.log("Barcode Collector: 'Documents' menu found. Adding custom view menu.");
addMenuView(documentsMenu);
isMenuViewAdded = true;
}
// --- Check for the patient barcode display ---
const barcodeOnPage = patientBarcodeBox ?
Array.from(patientBarcodeBox.querySelectorAll('div')).find(div => div.textContent.includes('Sample Barcode:'))?.nextElementSibling?.textContent.trim()
: null;
if (barcodeOnPage) {
// If a new barcode is detected on the page, update the cache and the view
if (barcodeOnPage !== lastCheckedPatientBarcode) {
console.log(`Barcode Collector: Detected patient barcode on page: ${barcodeOnPage}`);
lastCheckedPatientBarcode = barcodeOnPage;
markBarcodeAsFoundAndUpdateStorage(barcodeOnPage);
}
} else {
// Reset when the user navigates away from the page
if (lastCheckedPatientBarcode !== null) {
lastCheckedPatientBarcode = null;
}
}
// --- New Robust Method: Scan all barcodes in the table on every change ---
const allBarcodeInputs = document.querySelectorAll(`${TABLE_BODY_SELECTOR} input[formcontrolname="Barcode"]`);
if (allBarcodeInputs.length > 0) {
// Use an async IIFE with a for...of loop to handle the async saveBarcode function correctly.
// This prevents a race condition where multiple saves happen at once and only the last one succeeds.
(async () => {
for (const input of allBarcodeInputs) {
if (input.value) {
await saveBarcode(input.value.trim());
}
}
})();
}
});
// Observe the entire document body for any changes
observer.observe(document.body, {
childList: true,
subtree: true
});
}
// Function to save a new barcode to storage, with duplicate checking
async function saveBarcode(barcode) {
// First, check our in-memory set for this session. This is the fastest way to reject duplicates.
if (collectedBarcodesThisSession.has(barcode)) {
return;
}
// If not in memory, check persistent storage to avoid adding duplicates from previous sessions.
let barcodes = await GM_getValue(STORAGE_KEY, []);
if (barcodes.some(entry => entry.barcode === barcode)) {
collectedBarcodesThisSession.add(barcode); // Add to session cache to prevent future storage checks
return;
}
// If the barcode is truly new, add it.
const newEntry = {
count: barcodes.length + 1,
barcode: barcode,
timestamp: new Date().toISOString(),
found: false // Initialize the found status
};
barcodes.push(newEntry);
await GM_setValue(STORAGE_KEY, barcodes); // Save the updated array to storage
collectedBarcodesThisSession.add(barcode); // Add to session cache
console.log(`Barcode Collector: Saved new barcode - ${barcode}`);
}
// Function to create and inject the "View Barcodes" menu item
function addMenuView(targetMenuElement) {
// Find the main container of the "Documents" menu item to ensure proper alignment
const documentsContainer = targetMenuElement.closest('csi-main-menu')?.parentElement?.parentElement;
if (!documentsContainer) {
console.error("Barcode Collector: Could not find the main menu item container to inject into. The page structure may have changed.");
return;
}
// Create a new container that is the same type as the one we found.
const newContainer = document.createElement(documentsContainer.tagName);
// Set the inner HTML to replicate the structure of the existing menu items,
// ensuring it has an icon holder and a text wrapper.
newContainer.innerHTML = `
<div class="${documentsContainer.firstElementChild.className}">
<csi-main-menu class="${targetMenuElement.closest('csi-main-menu').className}">
<a style="cursor: pointer;">
<span class="icon-holder csi-menu-icon">
<i class="fa fa-list-ul" aria-hidden="true"></i>
</span>
<div class="csi-menu-text-wrapper">
<span class="csi-menu-text sidemenu-title" title="Pending Barcodes">Pending Barcodes</span>
</div>
</a>
</csi-main-menu>
</div>
`;
// Add a click listener to the `<a>` tag within our newly created element.
newContainer.querySelector('a').addEventListener('click', showBarcodesModal);
// Insert the new menu item into the DOM right after the "Documents" menu item.
documentsContainer.parentNode.insertBefore(newContainer, documentsContainer.nextSibling);
}
// --- Helper Function for Time Formatting ---
function formatTimeSince(isoTimestamp) {
const date = new Date(isoTimestamp);
const now = new Date();
const seconds = Math.floor((now - date) / 1000);
if (seconds < 60) return `${seconds} seconds ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes} minutes ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} hours ago`;
const days = Math.floor(hours / 24);
return `${days} days ago`;
}
// --- NEW: Function to update storage and UI when a barcode is found ---
async function markBarcodeAsFoundAndUpdateStorage(barcodeToMark) {
let barcodes = await GM_getValue(STORAGE_KEY, []);
const entry = barcodes.find(b => b.barcode === barcodeToMark);
// Only update if the entry exists and is not already marked as found
if (entry && !entry.found) {
entry.found = true;
await GM_setValue(STORAGE_KEY, barcodes);
console.log(`Barcode Collector: Marked barcode ${barcodeToMark} as found and saved status.`);
}
// Always try to update the UI in case the modal is open
updateViewWithCheckmark(barcodeToMark);
}
// --- Function to add a checkmark to the modal UI ---
function updateViewWithCheckmark(barcode) {
const modal = document.getElementById('barcode-viewer-modal');
if (!modal) return; // Don't do anything if the modal isn't open
const row = modal.querySelector(`tr[data-barcode-row="${barcode}"]`);
if (row) {
const statusCell = row.querySelector('.status-cell-bc');
if (statusCell) {
statusCell.innerHTML = `<span style="color: #4CAF50; font-weight: bold; font-size: 1.2em;" title="This barcode has been found on a patient page.">✔</span>`;
}
}
}
// --- Robust method to find the correct filter input ---
function findFloatingFilterInputByHeader(headerText) {
const headerViewport = document.querySelector('.ag-header-viewport');
if (!headerViewport) {
console.error("[Barcode Collector] Could not find the ag-grid header viewport.");
return null;
}
const allTitleCells = Array.from(headerViewport.querySelectorAll('.ag-header-row[aria-rowindex="1"] .ag-header-cell'));
if (allTitleCells.length === 0) {
console.error("[Barcode Collector] Could not find any header cells in the title row.");
return null;
}
let targetColumnIndex = -1;
allTitleCells.forEach((cell, index) => {
const cellTextElement = cell.querySelector('.ag-header-cell-text');
if (cellTextElement && cellTextElement.textContent.trim().toLowerCase() === headerText.toLowerCase()) {
targetColumnIndex = index;
}
});
if (targetColumnIndex === -1) {
console.error(`[Barcode Collector] Could not find header cell with text: "${headerText}"`);
return null;
}
const filterRow = headerViewport.querySelector('.ag-header-row[aria-rowindex="2"]');
if (!filterRow) {
console.error(`[Barcode Collector] Could not find the filter row.`);
return null;
}
const filterCell = filterRow.children[targetColumnIndex];
if (!filterCell) {
console.error(`[Barcode Collector] Could not find filter cell at index: ${targetColumnIndex}`);
return null;
}
return filterCell.querySelector('input.ag-floating-filter-input');
}
// --- REVISED: Function to enter a barcode into the ag-grid filter ---
function enterBarcodeInFilter(barcode) {
const targetInput = findFloatingFilterInputByHeader('Barcode');
if (targetInput) {
// --- More Robust Event Simulation for Angular/ag-grid ---
targetInput.focus();
targetInput.value = barcode;
// Dispatch events in a sequence that frameworks usually pick up on
targetInput.dispatchEvent(new Event('input', { bubbles: true, cancelable: true }));
targetInput.dispatchEvent(new Event('change', { bubbles: true, cancelable: true }));
targetInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true }));
targetInput.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true }));
targetInput.blur(); // Trigger change detection
console.log(`Barcode Collector: Programmatically entered barcode "${barcode}" into the filter.`);
} else {
console.error('Barcode Collector: Could not find the "Barcode" filter input field on the page.');
}
}
// Function to display the modal with collected barcodes
async function showBarcodesModal() {
const existingModal = document.getElementById('barcode-viewer-modal');
if (existingModal) existingModal.remove();
const barcodes = await GM_getValue(STORAGE_KEY, []);
// Sort by count ascending, so the earliest collected are first
barcodes.sort((a, b) => a.count - b.count);
const modal = document.createElement('div');
modal.id = 'barcode-viewer-modal';
let tableRows = barcodes.map(entry => `
<tr data-barcode-row="${entry.barcode}">
<td>${entry.count}</td>
<td>${entry.barcode}</td>
<td>${new Date(entry.timestamp).toLocaleString()}</td>
<td data-timestamp="${entry.timestamp}">${formatTimeSince(entry.timestamp)}</td>
<td class="status-cell-bc">
${entry.found ? `<span style="color: #4CAF50; font-weight: bold; font-size: 1.2em;" title="This barcode has been found on a patient page.">✔</span>` : ''}
</td>
<td class="action-cell-bc"><span class="delete-barcode-btn" data-barcode="${entry.barcode}" title="Delete this barcode">×</span></td>
</tr>
`).join('');
if (barcodes.length === 0) {
tableRows = '<tr><td colspan="6">No barcodes have been collected yet.</td></tr>';
}
modal.innerHTML = `
<div class="modal-content-bc">
<div class="modal-header-bc">
<h2>Pending Barcodes</h2>
<span class="close-button-bc">×</span>
</div>
<div class="modal-body-bc">
<table>
<thead>
<tr>
<th>Count</th>
<th>Barcode</th>
<th>Timestamp</th>
<th>Time Since Added</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>${tableRows}</tbody>
</table>
</div>
<div class="modal-footer-bc">
<button id="clear-barcodes-btn">Clear All Barcodes</button>
</div>
</div>
`;
document.body.appendChild(modal);
// --- Immediately check for a barcode on the current page when modal opens ---
if (lastCheckedPatientBarcode) {
updateViewWithCheckmark(lastCheckedPatientBarcode);
}
// Auto-update the "Time Since Added" column every 5 seconds
const timeSinceInterval = setInterval(() => {
modal.querySelectorAll('td[data-timestamp]').forEach(cell => {
cell.textContent = formatTimeSince(cell.dataset.timestamp);
});
}, 5000);
// --- Event Listener for Clicks Inside the Table Body ---
const tableBody = modal.querySelector('tbody');
tableBody.addEventListener('click', async function(event) {
const row = event.target.closest('tr');
// Do nothing if the click is not on a row with a barcode
if (!row || !row.dataset.barcodeRow) {
return;
}
// Check if the delete button was clicked
if (event.target.classList.contains('delete-barcode-btn')) {
const barcodeToDelete = event.target.dataset.barcode;
await deleteBarcode(barcodeToDelete);
// Refresh the modal content without closing it
const updatedBarcodes = await GM_getValue(STORAGE_KEY, []);
updatedBarcodes.sort((a, b) => a.count - b.count); // Re-sort
let newTableRows = updatedBarcodes.map(entry => `
<tr data-barcode-row="${entry.barcode}">
<td>${entry.count}</td>
<td>${entry.barcode}</td>
<td>${new Date(entry.timestamp).toLocaleString()}</td>
<td data-timestamp="${entry.timestamp}">${formatTimeSince(entry.timestamp)}</td>
<td class="status-cell-bc">
${entry.found ? `<span style="color: #4CAF50; font-weight: bold; font-size: 1.2em;" title="This barcode has been found on a patient page.">✔</span>` : ''}
</td>
<td class="action-cell-bc"><span class="delete-barcode-btn" data-barcode="${entry.barcode}" title="Delete this barcode">×</span></td>
</tr>
`).join('');
if (updatedBarcodes.length === 0) {
newTableRows = '<tr><td colspan="6">No barcodes have been collected yet.</td></tr>';
}
tableBody.innerHTML = newTableRows;
// After rebuilding the table, re-apply the checkmark if necessary
if (lastCheckedPatientBarcode) {
updateViewWithCheckmark(lastCheckedPatientBarcode);
}
}
// Otherwise, it was a row click to enter the barcode
else {
const barcodeToEnter = row.dataset.barcodeRow;
enterBarcodeInFilter(barcodeToEnter);
// Stop the auto-update timer and close the modal
clearInterval(timeSinceInterval);
modal.remove();
}
});
modal.querySelector('.close-button-bc').addEventListener('click', () => {
clearInterval(timeSinceInterval); // Stop the auto-update
modal.remove();
});
const clearButton = modal.querySelector('#clear-barcodes-btn');
clearButton.addEventListener('click', () => {
clearInterval(timeSinceInterval); // Stop the auto-update
showClearConfirmation(modal);
});
}
// --- Function to Delete a Single Barcode ---
async function deleteBarcode(barcodeToDelete) {
let barcodes = await GM_getValue(STORAGE_KEY, []);
// Filter out the barcode to delete
let updatedBarcodes = barcodes.filter(entry => entry.barcode !== barcodeToDelete);
// Re-calculate the 'count' for the remaining items
updatedBarcodes.forEach((entry, index) => {
entry.count = index + 1;
});
await GM_setValue(STORAGE_KEY, updatedBarcodes);
console.log(`Barcode Collector: Deleted barcode - ${barcodeToDelete}`);
}
// Function to show a confirmation dialog inside the modal
function showClearConfirmation(modal) {
const modalBody = modal.querySelector('.modal-body-bc');
const modalFooter = modal.querySelector('.modal-footer-bc');
modalBody.innerHTML = `
<p class="confirm-text-bc">
Are you sure you want to delete all pending barcodes?<br>This action cannot be undone.
</p>`;
modalFooter.innerHTML = `
<button id="confirm-clear-btn" class="modal-btn-bc confirm-btn-bc">Yes, Clear All</button>
<button id="cancel-clear-btn" class="modal-btn-bc cancel-btn-bc">Cancel</button>`;
modal.querySelector('#confirm-clear-btn').addEventListener('click', async () => {
await GM_setValue(STORAGE_KEY, []);
modal.remove();
showBarcodesModal();
});
modal.querySelector('#cancel-clear-btn').addEventListener('click', () => {
modal.remove();
showBarcodesModal();
});
}
// --- Styling for the Modal ---
GM_addStyle(`
#barcode-viewer-modal {
position: fixed; z-index: 10000; left: 0; top: 0; width: 100%; height: 100%;
background-color: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center;
font-family: Arial, sans-serif;
}
.modal-content-bc {
background-color: #fefefe; border-radius: 8px; box-shadow: 0 4px 8px 0 rgba(0,0,0,0.2);
width: 80%; max-width: 700px; max-height: 80vh; display: flex; flex-direction: column;
}
.modal-header-bc {
padding: 10px 16px; background-color: #5c6bc0; color: white; border-top-left-radius: 8px;
border-top-right-radius: 8px; display: flex; justify-content: space-between; align-items: center;
}
.modal-header-bc h2 { margin: 0; font-size: 1.25em; }
.close-button-bc { color: #fff; font-size: 28px; font-weight: bold; cursor: pointer; }
.close-button-bc:hover { color: #f1f1f1; }
.modal-body-bc { padding: 16px; overflow-y: auto; }
.modal-body-bc table { width: 100%; border-collapse: collapse; }
.modal-body-bc th, .modal-body-bc td { border: 1px solid #ddd; padding: 8px; text-align: left; }
.modal-body-bc th { background-color: #f2f2f2; }
.modal-body-bc tbody tr {
cursor: pointer;
}
.modal-body-bc tbody tr:hover {
background-color: #e8eaf6; /* Light indigo for hover */
}
.modal-footer-bc {
padding: 10px 16px; text-align: right; border-top: 1px solid #ddd;
display: flex; justify-content: flex-end; gap: 10px;
}
#clear-barcodes-btn {
background-color: #ef5350; color: white; border: none; padding: 10px 15px;
border-radius: 5px; cursor: pointer; font-weight: bold;
}
#clear-barcodes-btn:hover { background-color: #d32f2f; }
.confirm-text-bc { text-align: center; font-size: 1.1em; margin: 20px; }
.modal-btn-bc { border: none; padding: 10px 15px; border-radius: 5px; cursor: pointer; font-weight: bold; }
.confirm-btn-bc { background-color: #ef5350; color: white; }
.confirm-btn-bc:hover { background-color: #d32f2f; }
.cancel-btn-bc { background-color: #e0e0e0; color: #333; }
.cancel-btn-bc:hover { background-color: #bdbdbd; }
.action-cell-bc, .status-cell-bc { text-align: center !important; }
.delete-barcode-btn {
cursor: pointer;
font-weight: bold;
font-size: 20px;
color: #ef5350;
padding: 0 5px;
border-radius: 4px;
}
.delete-barcode-btn:hover {
color: white;
background-color: #d32f2f;
}
`);
// --- Start the script ---
initialize();
})();