Pending barcodes (Networked)

Collects barcodes with the reliability of v2.2.0 and syncs them via a local server.

2025-10-16 يوللانغان نەشرى. ئەڭ يېڭى نەشرىنى كۆرۈش.

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.5.1
// @description  Collects barcodes with the reliability of v2.2.0 and syncs them via a local server.
// @author       Hamad AlShegifi
// @match        *://his.kaauh.org/lab/*
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @connect      localhost
// ==/UserScript==

(function() {
    'use strict';

    // --- Configuration ---
    const PYTHON_SERVER_URL = 'http://localhost:5678';
    const DATA_TABLE_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 timeSinceInterval = null;

    // --- 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" },
                timeout: 5000,
                onload: (response) => {
                    if (response.status >= 200 && response.status < 300) resolve(JSON.parse(response.responseText));
                    else reject(`API Error: ${response.status}`);
                },
                onerror: () => reject('Connection Error: Is Python server running?'),
                ontimeout: () => reject('Connection Timeout')
            });
        });
    }

    // --- Core Logic (Based on v2.2.0) ---
    function initialize() {
        console.log("Barcode Collector (Networked v3.5.1): Script started. Observing for page changes...");
        const observer = new MutationObserver((mutations, obs) => {
            if (observerDebounceTimer) clearTimeout(observerDebounceTimer);
            observerDebounceTimer = setTimeout(async () => {
                const injectionPoint = document.querySelector(INJECTION_POINT_SELECTOR);
                if (injectionPoint) {
                    await 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 && barcodeOnPage !== lastCheckedPatientBarcode) {
                    lastCheckedPatientBarcode = barcodeOnPage;
                    await markBarcodeAsFound(barcodeOnPage);
                }
                const allBarcodeRows = document.querySelectorAll(`${DATA_TABLE_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';
                            await saveBarcode(barcode, workbench);
                        }
                    }
                }
            }, 250);
        });
        observer.observe(document.body, { childList: true, subtree: true });
    }

    async function saveBarcode(barcode, workbench) {
        if (collectedBarcodesThisSession.has(barcode)) return;
        try {
            const newEntry = { barcode, workbench };
            const result = await apiRequest('POST', '/add_barcode', newEntry);
            collectedBarcodesThisSession.add(barcode);
            if (result.status === 'added') {
                console.log(`Barcode Collector: Sent new barcode - ${barcode}`);
                await updateOrInsertBarcodeTable({ forceRender: true });
            }
        } catch (error) {
            console.error(error);
        }
    }

    async function markBarcodeAsFound(barcodeToMark) {
        try {
            const barcodes = await apiRequest('GET', '/get_barcodes');
            const entry = barcodes.find(b => b.barcode === barcodeToMark);
            if (entry && !entry.found) {
                entry.found = true;
                await apiRequest('POST', '/update_barcodes', barcodes);
                console.log(`Barcode Collector: Marked ${barcodeToMark} as found.`);
                await updateOrInsertBarcodeTable({ forceRender: true });
            }
        } catch (error) {
             console.error(error);
        }
    }

    // *** RESTORED: This is the trusted time formatting function from v2.2.0 ***
    function formatTimeSince(isoTimestamp) {
        // A safety check is still included for robustness
        const date = new Date(isoTimestamp);
        if (isNaN(date.getTime())) return 'Invalid Time';

        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;
        return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')} ago`;
    }

    // --- UI Rendering and Management (Based on v2.2.0) ---
    async function updateOrInsertBarcodeTable({ forceRender = false } = {}) {
        if (isTableUpdating && !forceRender) 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 barcodes = await apiRequest('GET', '/get_barcodes');

            if (sortState.key === 'timestamp') {
                barcodes.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(barcodes.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' ? barcodes : barcodes.filter(b => b.workbench === selectedWorkbench);

            container.querySelector('#sort-indicator').textContent = sortState.direction === 'asc' ? '▲' : '▼';
            const tableBody = container.querySelector('tbody');

            // *** FIXED: This block now contains bulletproof checks to prevent invalid date errors ***
            let tableRows = filteredBarcodes.map(entry => {
                const addedDate = entry.timestamp ? new Date(entry.timestamp) : null;
                const isValidDate = addedDate && !isNaN(addedDate.getTime());

                const addedTime = isValidDate
                    ? addedDate.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true })
                    : 'Invalid Time';

                const pendingTime = isValidDate
                    ? formatTimeSince(entry.timestamp)
                    : 'Invalid Time';

                return `
                <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>${addedTime}</td>
                    <td data-timestamp="${entry.timestamp}">${pendingTime}</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;

            if (timeSinceInterval) clearInterval(timeSinceInterval);
            timeSinceInterval = setInterval(() => {
                container.querySelectorAll('td[data-timestamp]').forEach(cell => {
                    // Also use the safe check inside the interval
                    if (cell.dataset.timestamp && new Date(cell.dataset.timestamp).getTime()) {
                         cell.textContent = formatTimeSince(cell.dataset.timestamp);
                    }
                });
            }, 5000);

        } 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;
        }
    }

    function handleSortClick() {
        sortState.direction = sortState.direction === 'desc' ? 'asc' : 'desc';
        updateOrInsertBarcodeTable({ forceRender: true });
    }

    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 & AG-Grid Interaction ---
    async function clearAllBarcodes() {
        if (confirm("Are you sure you want to delete ALL synced barcodes?")) {
            try { await apiRequest('POST', '/update_barcodes', []); await updateOrInsertBarcodeTable({ forceRender: true }); }
            catch(e) { console.error(e); }
        }
    }
    async function deleteCompletedBarcodes() {
        if (confirm("Are you sure you want to delete all completed barcodes?")) {
            try {
                const barcodes = await apiRequest('GET', '/get_barcodes');
                let updatedList = barcodes.filter(entry => !entry.found);
                updatedList.forEach((entry, index) => { entry.count = index + 1; });
                await apiRequest('POST', '/update_barcodes', updatedList);
                await updateOrInsertBarcodeTable({ forceRender: true });
            } catch(e) { console.error(e); }
        }
    }
    async function deleteBarcode(barcodeToDelete) {
        try {
            const barcodes = await apiRequest('GET', '/get_barcodes');
            let updatedList = barcodes.filter(entry => entry.barcode !== barcodeToDelete);
            updatedList.forEach((entry, index) => { entry.count = index + 1; });
            await apiRequest('POST', '/update_barcodes', updatedList);
            await updateOrInsertBarcodeTable({ forceRender: true });
        } catch(e) { console.error(e); }
    }

    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('Could not find "Barcode" filter input.'); return; }
        try {
            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();
        } catch (error) { console.error("Error while filtering/clicking.", error); }
        finally { if (targetInput) targetInput.blur(); }
    }

    // --- Styles and Initialization ---
    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 ---
    initialize();
    setInterval(() => updateOrInsertBarcodeTable(), 4000);

})();