Pending barcodes

Collects barcodes automatically from a table, timestamps them, and provides a viewing interface.

当前为 2025-10-02 提交的版本,查看 最新版本

// ==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.">&#10004;</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.">&#10004;</span>` : ''}
                </td>
                <td class="action-cell-bc"><span class="delete-barcode-btn" data-barcode="${entry.barcode}" title="Delete this barcode">&times;</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">&times;</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.">&#10004;</span>` : ''}
                        </td>
                        <td class="action-cell-bc"><span class="delete-barcode-btn" data-barcode="${entry.barcode}" title="Delete this barcode">&times;</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();

})();