Pending barcodes

Collects barcodes automatically, provides a viewing interface, and clicks the filtered result.

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

// ==UserScript==
// @name         Pending barcodes
// @namespace    http://tampermonkey.net/
// @version      1.9.4
// @description  Collects barcodes automatically, provides a viewing interface, and clicks the filtered result.
// @author       Hamad AlShegifi
// @match        *://his.kaauh.org/lab/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    GM_addStyle(`
        #barcode-inpage-container {
            width: 30vw !important;
            float: left !important;
        }
        .ag-root-wrapper-body {
            width: 70vw !important;
            margin-left: auto !important;
            margin-right: 0 !important;
        }
    `);

})();

(function() {
    'use strict';

    // --- Configuration ---
    const TABLE_BODY_SELECTOR = 'tbody[formarrayname="TubeTypeList"]';
    const BARCODE_DISPLAY_SELECTOR = '#barcode-display-box';
    const STORAGE_KEY = 'collectedBarcodes_storage';
    const IN_PAGE_TABLE_ID = 'barcode-inpage-container';
    const INJECTION_POINT_SELECTOR = '.row.labordertab';
    const GRID_CONTAINER_SELECTOR = '.ag-center-cols-container'; // Selector for the AG-Grid row container

    // --- State Flags & Cache ---
    const collectedBarcodesThisSession = new Set();
    let lastCheckedPatientBarcode = null;
    let timeSinceInterval = null;
    let observerDebounceTimer = null;
    let isTableUpdating = false;

    // --- Main Logic ---
    function initialize() {
        console.log("Barcode Collector: Script started. Observing for page changes...");

        const observer = new MutationObserver((mutations, obs) => {
            if (observerDebounceTimer) clearTimeout(observerDebounceTimer);
            observerDebounceTimer = setTimeout(() => {
                const injectionPoint = document.querySelector(INJECTION_POINT_SELECTOR);
                if (injectionPoint) {
                    updateOrInsertBarcodeTable();
                } else {
                    const existingTable = document.getElementById(IN_PAGE_TABLE_ID);
                    if (existingTable) {
                        if (timeSinceInterval) clearInterval(timeSinceInterval);
                        existingTable.remove();
                    }
                }

                const patientBarcodeBox = document.querySelector(BARCODE_DISPLAY_SELECTOR);
                const barcodeOnPage = patientBarcodeBox ?
                    Array.from(patientBarcodeBox.querySelectorAll('div')).find(div => div.textContent.includes('Sample Barcode:'))?.nextElementSibling?.textContent.trim() :
                    null;

                if (barcodeOnPage) {
                    if (barcodeOnPage !== lastCheckedPatientBarcode) {
                        console.log(`Barcode Collector: Detected patient barcode on page: ${barcodeOnPage}`);
                        lastCheckedPatientBarcode = barcodeOnPage;
                        markBarcodeAsFoundAndUpdateStorage(barcodeOnPage);
                    }
                } else {
                    if (lastCheckedPatientBarcode !== null) {
                        lastCheckedPatientBarcode = null;
                    }
                }

                const allBarcodeInputs = document.querySelectorAll(`${TABLE_BODY_SELECTOR} input[formcontrolname="Barcode"]`);
                if (allBarcodeInputs.length > 0) {
                    (async () => {
                        for (const input of allBarcodeInputs) {
                            if (input.value) {
                                await saveBarcode(input.value.trim());
                            }
                        }
                    })();
                }
            }, 250);
        });

        observer.observe(document.body, { childList: true, subtree: true });
    }

    async function saveBarcode(barcode) {
        if (collectedBarcodesThisSession.has(barcode)) return;
        let barcodes = await GM_getValue(STORAGE_KEY, []);
        if (barcodes.some(entry => entry.barcode === barcode)) {
            collectedBarcodesThisSession.add(barcode);
            return;
        }
        const newEntry = {
            count: barcodes.length + 1,
            barcode: barcode,
            timestamp: new Date().toISOString(),
            found: false
        };
        barcodes.push(newEntry);
        await GM_setValue(STORAGE_KEY, barcodes);
        collectedBarcodesThisSession.add(barcode);
        console.log(`Barcode Collector: Saved new barcode - ${barcode}`);
        updateOrInsertBarcodeTable();
    }

    function formatTimeSince(isoTimestamp) {
        const date = new Date(isoTimestamp);
        const now = new Date();
        const totalMinutes = Math.floor((now - date) / (1000 * 60));
        if (totalMinutes < 1) return "00:00 ago";
        const hours = Math.floor(totalMinutes / 60);
        const minutes = totalMinutes % 60;
        const formattedHours = String(hours).padStart(2, '0');
        const formattedMinutes = String(minutes).padStart(2, '0');
        return `${formattedHours}:${formattedMinutes} ago`;
    }

    async function markBarcodeAsFoundAndUpdateStorage(barcodeToMark) {
        let barcodes = await GM_getValue(STORAGE_KEY, []);
        const entry = barcodes.find(b => b.barcode === barcodeToMark);
        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.`);
        }
        updateViewWithHighlight(barcodeToMark);
    }

    function updateViewWithHighlight(barcode) {
        const table = document.getElementById(IN_PAGE_TABLE_ID);
        if (!table) return;
        const row = table.querySelector(`tr[data-barcode-row="${barcode}"]`);
        if (row) {
            row.classList.add('barcode-found');
        }
    }

    function findFloatingFilterInputByHeader(headerText) {
        const headerViewport = document.querySelector('.ag-header-viewport');
        if (!headerViewport) return null;
        const allTitleCells = Array.from(headerViewport.querySelectorAll('.ag-header-row[aria-rowindex="1"] .ag-header-cell'));
        if (allTitleCells.length === 0) 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) return null;
        const filterRow = headerViewport.querySelector('.ag-header-row[aria-rowindex="2"]');
        if (!filterRow) return null;
        const filterCell = filterRow.children[targetColumnIndex];
        if (!filterCell) return null;
        return filterCell.querySelector('input.ag-floating-filter-input');
    }

    // --- NEW: Function to wait for the grid to update and then click the first row ---
    function waitForGridUpdateAndClick() {
        return new Promise((resolve, reject) => {
            const gridContainer = document.querySelector(GRID_CONTAINER_SELECTOR);
            if (!gridContainer) {
                return reject("AG-Grid container not found.");
            }

            const timeout = setTimeout(() => {
                observer.disconnect();
                reject("Timeout: AG-Grid did not update within 2 seconds.");
            }, 2000);

            const observer = new MutationObserver((mutations, obs) => {
                const firstRow = gridContainer.querySelector('.ag-row[row-index="0"]');
                if (firstRow) {
                    firstRow.click();
                    clearTimeout(timeout);
                    obs.disconnect();
                    resolve();
                }
            });

            observer.observe(gridContainer, { childList: true, subtree: true });
        });
    }

    // --- CHANGED: Converted to async to handle waiting for the click ---
    async function enterBarcodeInFilter(barcode) {
        const targetInput = findFloatingFilterInputByHeader('Barcode');
        if (targetInput) {
            targetInput.focus();
            targetInput.value = barcode;
            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();
            console.log(`Barcode Collector: Programmatically entered barcode "${barcode}" into the filter.`);

            // --- NEW: Wait for the grid to update and then click the first row ---
            try {
                console.log("Barcode Collector: Waiting for AG-Grid to update...");
                await waitForGridUpdateAndClick();
                console.log("Barcode Collector: Successfully clicked the first grid row.");
            } catch (error) {
                console.error("Barcode Collector: Could not click grid row.", error);
            }

        } else {
            console.error('Barcode Collector: Could not find the "Barcode" filter input field on the page.');
        }
    }

    async function updateOrInsertBarcodeTable() {
        if (isTableUpdating) return;
        isTableUpdating = true;
        try {
            const injectionPoint = document.querySelector(INJECTION_POINT_SELECTOR);
            if (!injectionPoint) {
                isTableUpdating = false;
                return;
            }
            let container = document.getElementById(IN_PAGE_TABLE_ID);
            if (!container) {
                container = document.createElement('div');
                container.id = IN_PAGE_TABLE_ID;
                container.innerHTML = `
                    <div class="bc-table-header">
                        <h2>Pending Barcodes</h2>
                        <button id="clear-barcodes-btn-inline" class="bc-clear-btn">Clear All</button>
                    </div>
                    <div class="bc-table-body">
                        <table>
                            <thead>
                                <tr>
                                    <th>#</th>
                                    <th>Barcode</th>
                                    <th>Time Added</th>
                                    <th>Time Since Received</th>
                                    <th>Actions</th>
                                </tr>
                            </thead>
                            <tbody></tbody>
                        </table>
                    </div>`;
                injectionPoint.parentNode.insertBefore(container, injectionPoint.nextSibling);
                container.querySelector('#clear-barcodes-btn-inline').addEventListener('click', async () => {
                    if (confirm("Are you sure you want to delete all pending barcodes? This cannot be undone.")) {
                        await GM_setValue(STORAGE_KEY, []);
                        updateOrInsertBarcodeTable();
                    }
                });
            }

            const barcodes = await GM_getValue(STORAGE_KEY, []);
            barcodes.sort((a, b) => a.count - b.count);

            let tableRows = barcodes.map(entry => `
                <tr data-barcode-row="${entry.barcode}" class="${entry.found ? 'barcode-found' : ''}">
                    <td>${entry.count}</td>
                    <td>${entry.barcode}</td>
                    <td>${new Date(entry.timestamp).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true })}</td>
                    <td data-timestamp="${entry.timestamp}">${formatTimeSince(entry.timestamp)}</td>
                    <td class="action-cell-bc"><span class="delete-barcode-btn" data-barcode="${entry.barcode}" title="Delete">&times;</span></td>
                </tr>
            `).join('');

            if (barcodes.length === 0) {
                tableRows = '<tr><td colspan="5">No pending barcodes.</td></tr>';
            }

            const tableBody = container.querySelector('tbody');
            if (tableBody.innerHTML !== tableRows) {
                tableBody.innerHTML = tableRows;
            }
            tableBody.removeEventListener('click', handleTableClick);
            tableBody.addEventListener('click', handleTableClick);

            if (timeSinceInterval) clearInterval(timeSinceInterval);
            timeSinceInterval = setInterval(() => {
                container.querySelectorAll('td[data-timestamp]').forEach(cell => {
                    cell.textContent = formatTimeSince(cell.dataset.timestamp);
                });
            }, 5000);
        } finally {
            isTableUpdating = false;
        }
    }

    async function handleTableClick(event) {
        const row = event.target.closest('tr');
        if (!row || !row.dataset.barcodeRow) return;
        if (event.target.classList.contains('delete-barcode-btn')) {
            const barcodeToDelete = event.target.dataset.barcode;
            await deleteBarcode(barcodeToDelete);
        } else {
            const barcodeToEnter = row.dataset.barcodeRow;
            await enterBarcodeInFilter(barcodeToEnter); // Use await here
        }
    }

    async function deleteBarcode(barcodeToDelete) {
        let barcodes = await GM_getValue(STORAGE_KEY, []);
        let updatedBarcodes = barcodes.filter(entry => entry.barcode !== barcodeToDelete);
        updatedBarcodes.forEach((entry, index) => {
            entry.count = index + 1;
        });
        await GM_setValue(STORAGE_KEY, updatedBarcodes);
        console.log(`Barcode Collector: Deleted barcode - ${barcodeToDelete}`);
        updateOrInsertBarcodeTable();
    }

    GM_addStyle(`
        #${IN_PAGE_TABLE_ID} {
            margin: 15px 0; border: 1px solid #ccc; border-radius: 8px;
            box-shadow: 0 2px 5px rgba(0,0,0,0.1); font-family: Arial, sans-serif;
            background-color: #fff; display: flex; flex-direction: column; height: 85vh;
        }
        .bc-table-header {
            padding: 10px 16px; background-color: #f7f7f7; border-bottom: 1px solid #ccc;
            border-top-left-radius: 8px; border-top-right-radius: 8px;
            display: flex; justify-content: space-between; align-items: center;
        }
        .bc-table-header h2 { margin: 0; font-size: 1.1em; color: #333; }
        .bc-clear-btn {
            background-color: #ef5350; color: white; border: none; padding: 6px 12px;
            border-radius: 5px; cursor: pointer; font-weight: bold; font-size: 0.9em;
        }
        .bc-clear-btn:hover { background-color: #d32f2f; }
        .bc-table-body {
            padding: 8px; overflow-y: auto; flex-grow: 1; min-height: 0;
        }
        .bc-table-body table { width: 100%; border-collapse: collapse; }
        .bc-table-body th, .bc-table-body td {
            border: 1px solid #ddd; padding: 4px 8px; text-align: left; font-size: 0.85em;
        }
        .bc-table-body th { background-color: #f2f2f2; }
        .bc-table-body tbody tr { cursor: pointer; }
        .bc-table-body tbody tr:hover { background-color: #e8eaf6; }
        .bc-table-body tbody tr.barcode-found { background-color: #E8F5E9 !important; }
        .bc-table-body tbody tr.barcode-found:hover { background-color: #C8E6C9 !important; }
        .action-cell-bc { text-align: center !important; }
        .delete-barcode-btn {
            cursor: pointer; font-weight: bold; font-size: 18px; color: #ef5350;
            padding: 0 5px; border-radius: 4px;
        }
        .delete-barcode-btn:hover { color: white; background-color: #d32f2f; }
    `);

    initialize();
})();