Pending barcodes

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

Version au 02/10/2025. Voir la dernière version.

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

// ==UserScript==
// @name         Pending barcodes
// @namespace    http://tampermonkey.net/
// @version      1.7
// @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==

// ==UserScript==
// @name         Barcode Collector
// @namespace    http://tampermonkey.net/
// @version      1.8
// @description  Collects barcodes, timestamps them, and adds a checkmark if you're viewing a matching patient page.
// @author       Your Coding Partner
// @match        *://*/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// ==/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;
                    updateViewWithCheckmark(barcodeOnPage); // Update modal if it's open
                }
            } 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()
        };
        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`;
    }

    // --- Function to add a checkmark to a barcode in the modal ---
    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 is on the current page.">&#10004;</span>`;
            }
        }
    }

    // --- NEW: 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"></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"></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();

})();