Pending barcodes (Networked)

Sends barcodes to a local sync server and displays the shared list.

As of 16. 10. 2025. See the latest version.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Pending barcodes (Networked)
// @namespace    http://tampermonkey.net/
// @version      3.2.0
// @description  Sends barcodes to a local sync server and displays the shared list.
// @author       Hamad AlShegifi
// @match        *://his.kaauh.org/lab/*
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @connect      localhost
// @license MIT
// ==/UserScript==

(function() {
    'use strict';

    // --- Configuration ---
    const PYTHON_SERVER_URL = 'http://localhost:5678';
    const TABLE_BODY_SELECTOR = 'tbody[formarrayname="TubeTypeList"]';
    const BARCODE_DISPLAY_SELECTOR = '#barcode-display-box';
    const IN_PAGE_TABLE_ID = 'barcode-inpage-container';
    const INJECTION_POINT_SELECTOR = '.row.labordertab';
    const GRID_CONTAINER_SELECTOR = '.ag-center-cols-container';

    // --- State Flags & Cache ---
    const collectedBarcodesThisSession = new Set();
    let lastCheckedPatientBarcode = null;
    let observerDebounceTimer = null;
    let isTableUpdating = false;
    let sortState = { key: 'timestamp', direction: 'desc' };
    let currentBarcodes = []; // Local cache of the list

    // --- API Communication ---
    function apiRequest(method, endpoint, data = null) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: method,
                url: `${PYTHON_SERVER_URL}${endpoint}`,
                data: data ? JSON.stringify(data) : null,
                headers: { "Content-Type": "application/json" },
                onload: function(response) {
                    if (response.status >= 200 && response.status < 300) {
                        resolve(JSON.parse(response.responseText));
                    } else {
                        reject(`API Error: ${response.status} ${response.statusText}`);
                    }
                },
                onerror: function(response) {
                    reject('Connection Error: Could not connect to the local Python server. Is it running?');
                }
            });
        });
    }

    // --- Data Collection Logic ---
    function startDataObserver() {
        const observer = new MutationObserver((mutations, obs) => {
            if (observerDebounceTimer) clearTimeout(observerDebounceTimer);
            observerDebounceTimer = setTimeout(() => {
                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 && barcodeOnPage !== lastCheckedPatientBarcode) {
                    lastCheckedPatientBarcode = barcodeOnPage;
                    markBarcodeAsFound(barcodeOnPage);
                }

                const allBarcodeRows = document.querySelectorAll(`${TABLE_BODY_SELECTOR} tr`);
                if (allBarcodeRows.length > 0) {
                    for (const row of allBarcodeRows) {
                        const barcodeInput = row.querySelector('input[formcontrolname="Barcode"]');
                        const workbenchInput = row.querySelector('input[formcontrolname="TestSection"]');
                        if (barcodeInput && barcodeInput.value) {
                            const barcode = barcodeInput.value.trim();
                            const workbench = workbenchInput && workbenchInput.value ? workbenchInput.value.trim() : 'N/A';
                            saveBarcode(barcode, workbench);
                        }
                    }
                }
            }, 350);
        });
        observer.observe(document.body, { childList: true, subtree: true });
    }

    // *** REWRITTEN to be safer and more reliable ***
    async function saveBarcode(barcode, workbench) {
        if (collectedBarcodesThisSession.has(barcode) || currentBarcodes.some(b => b.barcode === barcode)) {
            return;
        }
        collectedBarcodesThisSession.add(barcode); // Prevent multiple adds for the same barcode

        const newEntry = {
            barcode: barcode,
            workbench: workbench,
            timestamp: new Date().toISOString(),
            found: false
            // The server will assign the final 'count'
        };

        try {
            // Use the new, more reliable endpoint for adding single barcodes
            await apiRequest('POST', '/add_barcode', newEntry);
            console.log(`Barcode Collector: Sent new barcode to server - ${barcode}`);
            await updateOrInsertBarcodeTable({ forceRender: true }); // Force refresh
        } catch (error) {
            console.error(error);
            collectedBarcodesThisSession.delete(barcode); // Allow retry if API call failed
        }
    }

    async function markBarcodeAsFound(barcodeToMark) {
        let barcodes = [...currentBarcodes];
        const entry = barcodes.find(b => b.barcode === barcodeToMark);
        if (entry && !entry.found) {
            entry.found = true;
            try {
                // Use the bulk update endpoint for marking items as found
                await apiRequest('POST', '/update_barcodes', barcodes);
                console.log(`Barcode Collector: Marked ${barcodeToMark} as found and updated server.`);
                await updateOrInsertBarcodeTable({ forceRender: true });
            } catch(error) {
                console.error(error);
            }
        }
    }

    // --- AG-Grid Interaction (No changes needed here) ---
    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'));
        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];
        return filterCell ? filterCell.querySelector('input.ag-floating-filter-input') : null;
    }

    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."); }, 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 });
        });
    }

    function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }

    async function enterBarcodeInFilter(barcode) {
        const targetInput = findFloatingFilterInputByHeader('Barcode');
        if (!targetInput) { console.error('Barcode Collector: Could not find "Barcode" filter input.'); return; }
        try {
            console.log(`Barcode Collector: Filtering for barcode "${barcode}"...`);
            targetInput.focus(); await sleep(50);
            targetInput.value = barcode;
            targetInput.dispatchEvent(new Event('input', { bubbles: true, cancelable: true })); await sleep(100);
            targetInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true }));
            targetInput.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true }));
            await waitForGridUpdateAndClick();
            console.log("Barcode Collector: Clicked the first grid row.");
        } catch (error) { console.error("Barcode Collector: Error while filtering/clicking.", error); }
        finally { if (targetInput) targetInput.blur(); }
    }


    // --- UI Rendering and Management (No changes needed here) ---
    function handleSortClick() {
        sortState.direction = sortState.direction === 'desc' ? 'asc' : 'desc';
        updateOrInsertBarcodeTable({ forceRender: true });
    }

    async function updateOrInsertBarcodeTable({ forceRender = false } = {}) {
        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 (Synced)</h2>
                        <div class="bc-filter-container"><label for="workbench-filter">Workbench:</label><select id="workbench-filter"></select></div>
                        <div class="bc-button-group">
                             <button id="delete-completed-btn" class="bc-btn bc-btn-completed">Clear Completed</button>
                             <button id="clear-all-btn" class="bc-btn bc-btn-clear-all">Clear All</button>
                        </div>
                    </div>
                    <div class="bc-table-body">
                        <table>
                            <thead>
                                <tr>
                                    <th>#</th><th>Barcode</th><th>Workbench</th>
                                    <th id="sort-by-time-header" class="sortable-header">Added <span id="sort-indicator"></span></th>
                                    <th>Pending</th><th>Actions</th>
                                </tr>
                            </thead>
                            <tbody><tr><td colspan="6">Loading...</td></tr></tbody>
                        </table>
                    </div>`;
                injectionPoint.parentNode.insertBefore(container, injectionPoint.nextSibling);
                container.querySelector('#clear-all-btn').addEventListener('click', clearAllBarcodes);
                container.querySelector('#delete-completed-btn').addEventListener('click', deleteCompletedBarcodes);
                container.querySelector('#workbench-filter').addEventListener('change', () => updateOrInsertBarcodeTable({ forceRender: true }));
                container.querySelector('#sort-by-time-header').addEventListener('click', handleSortClick);
                container.querySelector('tbody').addEventListener('click', handleTableClick);
            }
            const barcodesFromServer = await apiRequest('GET', '/get_barcodes');
            if (!forceRender && JSON.stringify(barcodesFromServer) === JSON.stringify(currentBarcodes)) {
                isTableUpdating = false; return;
            }
            currentBarcodes = barcodesFromServer;
            if (sortState.key === 'timestamp') {
                currentBarcodes.sort((a, b) => {
                    const dateA = new Date(a.timestamp); const dateB = new Date(b.timestamp);
                    return sortState.direction === 'asc' ? dateA - dateB : dateB - dateA;
                });
            }
            const uniqueWorkbenches = ['All', ...new Set(currentBarcodes.map(b => b.workbench).filter(Boolean))];
            const filterDropdown = container.querySelector('#workbench-filter');
            const currentFilterValue = filterDropdown.value;
            filterDropdown.innerHTML = uniqueWorkbenches.map(wb => `<option value="${wb}">${wb}</option>`).join('');
            if (uniqueWorkbenches.includes(currentFilterValue)) filterDropdown.value = currentFilterValue;
            const selectedWorkbench = filterDropdown.value;
            const filteredBarcodes = selectedWorkbench === 'All' ? currentBarcodes : currentBarcodes.filter(b => b.workbench === selectedWorkbench);
            container.querySelector('#sort-indicator').textContent = sortState.direction === 'asc' ? '▲' : '▼';
            const tableBody = container.querySelector('tbody');
            const formatTimeSince = (iso) => { const d = new Date(iso), n = new Date(), m = Math.floor((n - d) / 6e4); return m < 1 ? "00:00 ago" : `${String(Math.floor(m/60)).padStart(2,'0')}:${String(m%60).padStart(2,'0')} ago`; };
            let tableRows = filteredBarcodes.map(entry => `
                <tr data-barcode-row="${entry.barcode}" class="${entry.found ? 'barcode-found' : ''}">
                    <td>${entry.count}</td><td>${entry.barcode}</td><td>${entry.workbench || 'N/A'}</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 (filteredBarcodes.length === 0) tableRows = '<tr><td colspan="6">No pending barcodes.</td></tr>';
            if (tableBody.innerHTML !== tableRows) tableBody.innerHTML = tableRows;
        } catch (error) {
            console.error(error);
            const container = document.getElementById(IN_PAGE_TABLE_ID);
            if(container) container.querySelector('tbody').innerHTML = `<tr><td colspan="6" style="color: red; text-align: center;">Error: ${error}</td></tr>`;
        } 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')) {
            await deleteBarcode(event.target.dataset.barcode);
        } else {
            await enterBarcodeInFilter(row.dataset.barcodeRow);
        }
    }

    // --- Data Modification Functions (No changes needed here) ---
    async function clearAllBarcodes() {
        if (confirm("Are you sure you want to delete ALL synced barcodes? This will affect all computers.")) {
            try {
                await apiRequest('POST', '/update_barcodes', []);
                await updateOrInsertBarcodeTable({ forceRender: true });
            } catch(error) { console.error(error); }
        }
    }

    async function deleteCompletedBarcodes() {
        if (confirm("Are you sure you want to delete all completed barcodes? This will affect all computers.")) {
            let updatedList = currentBarcodes.filter(entry => !entry.found);
            updatedList.forEach((entry, index) => { entry.count = index + 1; });
            try {
                await apiRequest('POST', '/update_barcodes', updatedList);
                await updateOrInsertBarcodeTable({ forceRender: true });
            } catch(error) { console.error(error); }
        }
    }

    async function deleteBarcode(barcodeToDelete) {
        let updatedList = currentBarcodes.filter(entry => entry.barcode !== barcodeToDelete);
        updatedList.forEach((entry, index) => { entry.count = index + 1; });
        try {
            await apiRequest('POST', '/update_barcodes', updatedList);
            await updateOrInsertBarcodeTable({ forceRender: true });
        } catch(error) { console.error(error); }
    }

    // --- Styles and Initialization ---
    function addStyles() {
        GM_addStyle(`
            #barcode-inpage-container { width: 30vw !important; float: left !important; 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: 90vh; }
            .ag-root-wrapper-body { width: 70vw !important; margin-left: auto !important; margin-right: 0 !important; }
            .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; gap: 16px; }
            .bc-table-header h2 { margin: 0; font-size: 1.1em; color: #333; flex-shrink: 0; }
            .bc-filter-container { display: flex; align-items: center; gap: 8px; margin-left: auto; }
            .bc-button-group { display: flex; gap: 8px; flex-shrink: 0; }
            .bc-btn { border: none; padding: 6px 12px; border-radius: 5px; cursor: pointer; font-weight: bold; font-size: 0.9em; color: white; }
            .bc-btn-clear-all { background-color: #ef5350; } .bc-btn-clear-all:hover { background-color: #d32f2f; }
            .bc-btn-completed { background-color: #0288d1; } .bc-btn-completed:hover { background-color: #0277bd; }
            .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.9em; }
            .bc-table-body th { background-color: #f2f2f2; }
            .bc-table-body .sortable-header { cursor: pointer; }
            .bc-table-body .sortable-header:hover { background-color: #e0e0e0; }
            #sort-indicator { font-size: 0.8em; margin-left: 4px; }
            .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: #a5d6a7 !important; color: #1b5e20; }
            .bc-table-body tbody tr.barcode-found:hover { background-color: #81c784 !important; }
            .delete-barcode-btn { cursor: pointer; font-weight: bold; font-size: 18px; color: #ef5350; padding: 0 4px; border-radius: 4px; }
            .delete-barcode-btn:hover { color: white; background-color: #d32f2f; }
        `);
    }

    // --- Start the script ---
    console.log("Barcode Collector (Networked): Script loading.");
    addStyles();
    startDataObserver();
    setInterval(updateOrInsertBarcodeTable, 3000);

})();