Pending barcodes (Networked)

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

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

})();