Pending barcodes (Networked)

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

Versión del día 16/10/2025. Echa un vistazo a la versión más reciente.

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Necesitará instalar una extensión como Tampermonkey para instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==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);

})();