DROP_OFF Tracker

Zastępuje stronę DROP_OFF własnym dashboardem. Pobiera dane z Tantei GraphQL co 45s i wyświetla paczki w 4 sekcjach: DROP_OFF GA SLAM 1-10, Problem Solve (condtion 13, Hot Pick, PS to SHIP KO, Zwrot PS->SHIP), Vendor Returns oraz SLAM Stations (dwell time 24min-10h). Pokazuje Sort Code, Process Path z Rodeo, CPT i countdown. Paczki z CPT przekroczonym ponad 2h są ukrywane.

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

Vous devrez installer une extension telle que Tampermonkey 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         DROP_OFF Tracker
// @namespace    http://tampermonkey.net/
// @version      3.2
// @description  Zastępuje stronę DROP_OFF własnym dashboardem. Pobiera dane z Tantei GraphQL co 45s i wyświetla paczki w 4 sekcjach: DROP_OFF GA SLAM 1-10, Problem Solve (condtion 13, Hot Pick, PS to SHIP KO, Zwrot PS->SHIP), Vendor Returns oraz SLAM Stations (dwell time 24min-10h). Pokazuje Sort Code, Process Path z Rodeo, CPT i countdown. Paczki z CPT przekroczonym ponad 2h są ukrywane.
// @author       @nowaratn
// @contributor  stefakac
// @match        https://trans-logistics-eu.amazon.com/sortcenter/tt?setNodeId=KTW1&dropoff
// @match        https://trans-logistics-eu.amazon.com/sortcenter/tantei?setNodeId=KTW1&dropoff
// @icon         https://www.google.com/s2/favicons?sz=64&domain=amazon.com
// @grant        GM_xmlhttpRequest
// @connect      rodeo-dub.amazon.com
// ==/UserScript==

(function () {
    'use strict';

    // ── CONFIG ──────────────────────────────────────────────
    const NODE_ID       = 'KTW1';
    const REFRESH_SEC   = 45;
    const PAGE_SIZE     = 100;
    const TANTEI_GQL    = 'https://trans-logistics-eu.amazon.com/sortcenter/tantei/graphql';
    const TANTEI_URL    = 'https://trans-logistics-eu.amazon.com/sortcenter/tantei';
    const RODEO_URL     = 'https://rodeo-dub.amazon.com';
    const EXPIRED_HIDE_MS          = 2 * 60 * 60 * 1000;
    const SLAM_DWELL_THRESHOLD_MS  = 0.40 * 60 * 60 * 1000;
    const SLAM_DWELL_MAX_MS        = 10 * 60 * 60 * 1000;
    const SLAM_CPT_MAX_MS          = 10 * 60 * 60 * 1000;

    const LOCATIONS = [
        'DROP_OFF GA SLAM 1',
        'DROP_OFF GA SLAM 2',
        'DROP_OFF GA SLAM 3',
        'DROP_OFF GA SLAM 4',
        'DROP_OFF GA SLAM 5',
        'DROP_OFF GA SLAM 6',
        'DROP_OFF GA SLAM 7',
        'DROP_OFF GA SLAM 8',
        'DROP_OFF GA SLAM 9',
        'DROP_OFF GA SLAM 10',
    ];

    const LOCATIONS_PS = [
        'Problem Solve - condtion 13',
        'Problem Solve Hot Pick',
        'PS to SHIP KO',
        'Zwrot _PS->SHIP',
    ];

    const LOCATIONS_VR = [
        'cefe6d1b-1aa6-4b5e-aae7-2d226adb8136',
    ];

    const LOCATIONS_SLAM = [
        '7ab91a76-bc8c-4552-b6ed-195815b2dd72',
        'ff1b8043-b488-480d-84c4-4a11fdd513ee',
        'd5f33711-d3a9-4233-9375-8caa0432fb44',
        'eedc629b-bba9-4c2f-a8b6-b945c7c2c233',
        'aab8cfff-ecac-4abb-bb24-048d8b940d5c',
        'c4d55a40-e428-4060-9dd0-7a1f8396e294',
        'f8fa4a79-71b4-49f2-a4e6-5b5a0a4a1850',
        '4109e312-c842-4686-9697-b4fe62514e1e',
        'c7c42551-d752-46fe-a4b5-87bb3b573d76',
        'bd110212-f6fe-4d67-90ee-0356b86c9a83',
        '758b5bcf-36a1-4b77-86e6-ecec12062cd1',
        '07c23d0c-ad1a-417c-9b2f-631dd78301b8',
        'ae1a8bb5-d6cd-4db2-a5ab-237be5a66e20',
        '85e12053-d452-4dc4-acbb-7a47d4cd8ffd',
        'ed34c603-74bc-432a-82e0-b06415c2091b',
        'bae2c71a-3a83-4931-b677-7b118eb31391',
    ];

    const DISPLAY_NAMES = {
        'cefe6d1b-1aa6-4b5e-aae7-2d226adb8136': 'SLAM STATION-705',
        '7ab91a76-bc8c-4552-b6ed-195815b2dd72': 'SLAM STATION-3011',
        'ff1b8043-b488-480d-84c4-4a11fdd513ee': 'SLAM STATION-3012',
        'd5f33711-d3a9-4233-9375-8caa0432fb44': 'SLAM STATION-3031',
        'eedc629b-bba9-4c2f-a8b6-b945c7c2c233': 'SLAM STATION-3032',
        'aab8cfff-ecac-4abb-bb24-048d8b940d5c': 'SLAM STATION-3041',
        'c4d55a40-e428-4060-9dd0-7a1f8396e294': 'SLAM STATION-3042',
        'f8fa4a79-71b4-49f2-a4e6-5b5a0a4a1850': 'SLAM STATION-3051',
        '4109e312-c842-4686-9697-b4fe62514e1e': 'SLAM STATION-3052',
        'c7c42551-d752-46fe-a4b5-87bb3b573d76': 'SLAM STATION-3061',
        'bd110212-f6fe-4d67-90ee-0356b86c9a83': 'SLAM STATION-3062',
        '758b5bcf-36a1-4b77-86e6-ecec12062cd1': 'SLAM STATION-906',
        '07c23d0c-ad1a-417c-9b2f-631dd78301b8': 'SLAM STATION-912',
        'ae1a8bb5-d6cd-4db2-a5ab-237be5a66e20': 'SLAM STATION-905',
        '85e12053-d452-4dc4-acbb-7a47d4cd8ffd': 'SLAM STATION-913',
        'ed34c603-74bc-432a-82e0-b06415c2091b': 'SLAM STATION-914',
        'bae2c71a-3a83-4931-b677-7b118eb31391': 'SLAM STATION-915',
    };

    const ALL_LOCATIONS = [...LOCATIONS, ...LOCATIONS_PS, ...LOCATIONS_VR, ...LOCATIONS_SLAM];
    const LEFT_LOCS    = LOCATIONS.slice(0, 5);
    const RIGHT_LOCS   = LOCATIONS.slice(5, 10);

    // ── PROCESS PATH CACHE ──────────────────────────────────
    // label -> { path: string, ts: number }
    const ppCache = {};
    const PP_CACHE_TTL = 10 * 60 * 1000; // 10 minut
    let ppLoaded = false;

    // ── GRAPHQL QUERY ───────────────────────────────────────
    const QUERY = `
    query ($queryInput: [SearchTermInput!]!, $startIndex: String) {
        searchEntities(searchTerms: $queryInput) {
            searchTerm { nodeId searchId resolvedIdType }
            contents(pageSize: ${PAGE_SIZE}, startIndex: $startIndex, forwardNavigate: true) {
                contents {
                    containerId
                    containerLabel
                    containerType
                    stackingFilter
                    criticalPullTime
                    isEmpty
                    isClosed
                    timeOfAssociation
                    shipmentId
                }
                endToken
            }
        }
    }`;

    // ── CSRF ────────────────────────────────────────────────
    let csrfToken = '';

    async function initCsrf() {
        const existing = document.querySelector("input[name='__token_']");
        if (existing && existing.value) {
            csrfToken = existing.value;
            console.log('DROP_OFF Tracker: CSRF z DOM ✓');
            return;
        }
        try {
            const resp = await fetch(`${TANTEI_URL}?nodeId=${NODE_ID}`, {
                credentials: 'include',
            });
            const html = await resp.text();
            const doc  = new DOMParser().parseFromString(html, 'text/html');
            const input = doc.querySelector("input[name='__token_']");
            if (input && input.value) {
                csrfToken = input.value;
                console.log('DROP_OFF Tracker: CSRF z fetch ✓');
                return;
            }
        } catch (e) {
            console.error('DROP_OFF Tracker: błąd pobierania CSRF:', e);
        }
        console.error('DROP_OFF Tracker: brak CSRF tokena!');
    }

    function getCsrf() {
        return csrfToken;
    }

    // ── FETCH PROCESS PATH Z RODEO ───────────────────────────
    function gmFetch(url) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: url,
                withCredentials: true,
                onload: (r) => resolve(r),
                onerror: (e) => reject(e),
            });
        });
    }

    async function fetchProcessPath(shipmentId) {
        if (!shipmentId) return '—';
        const now = Date.now();
        if (ppCache[shipmentId] && now - ppCache[shipmentId].ts < PP_CACHE_TTL) {
            return ppCache[shipmentId].path;
        }
        try {
            const url = `${RODEO_URL}/${NODE_ID}/Search?searchKey=${encodeURIComponent(shipmentId)}`;
            const resp = await gmFetch(url);
            if (resp.status < 200 || resp.status >= 300) {
                ppCache[shipmentId] = { path: '—', ts: now };
                return '—';
            }
            const doc = new DOMParser().parseFromString(resp.responseText, 'text/html');

            // Szukaj tabeli z nagłówkiem "Process Path"
            const allTables = doc.querySelectorAll('table');
            for (const table of allTables) {
                const ths = [...table.querySelectorAll('th')];
                const ppIndex = ths.findIndex(th => th.textContent.trim() === 'Process Path');
                if (ppIndex !== -1) {
                    const firstRow = table.querySelector('tbody tr');
                    if (firstRow) {
                        const cells = firstRow.querySelectorAll('td');
                        const path = cells[ppIndex] ? cells[ppIndex].textContent.trim() : '—';
                        ppCache[shipmentId] = { path: path || '—', ts: now };
                        return path || '—';
                    }
                }
            }

            ppCache[shipmentId] = { path: '—', ts: now };
            return '—';
        } catch (e) {
            ppCache[shipmentId] = { path: '—', ts: now };
            return '—';
        }
    }

    async function loadAllProcessPaths() {
        const btn      = document.getElementById('pp-btn');
        const statusEl = document.getElementById('pp-status');
        btn.disabled   = true;
        ppLoaded       = false;

        const shipments = new Set();
        document.querySelectorAll('[data-pp-shipment]').forEach(el => {
            if (el.dataset.ppShipment) shipments.add(el.dataset.ppShipment);
        });

        if (shipments.size === 0) {
            statusEl.textContent = 'Brak paczek';
            btn.disabled = false;
            return;
        }

        // Ustaw wszystkie na "…"
        document.querySelectorAll('[data-pp-shipment]').forEach(el => {
            el.innerHTML = '<span class="pp-badge pp-loading">…</span>';
        });

        let done = 0;
        statusEl.textContent = `0 / ${shipments.size}`;

        const arr = [...shipments];
        for (let i = 0; i < arr.length; i += 3) {
            const batch = arr.slice(i, i + 3);
            await Promise.all(batch.map(async sid => {
                const path = await fetchProcessPath(sid);
                document.querySelectorAll(`[data-pp-shipment="${CSS.escape(sid)}"]`).forEach(el => {
                    el.innerHTML = `<span class="pp-badge" title="${path}">${path}</span>`;
                });
                done++;
                statusEl.textContent = `${done} / ${shipments.size}`;
            }));
        }

        statusEl.textContent = `✓ ${done} załadowanych`;
        btn.disabled = false;
        ppLoaded = true;
    }

    // ── FETCH (1 BATCH) ─────────────────────────────────────
    async function fetchAllLocations() {
        const resp = await fetch(TANTEI_GQL, {
            method: 'POST',
            credentials: 'include',
            headers: {
                'Content-Type':       'application/json',
                'Accept':             '*/*',
                'anti-csrftoken-a2z': getCsrf(),
            },
            body: JSON.stringify({
                query: QUERY,
                variables: {
                    queryInput: ALL_LOCATIONS.map(loc => ({
                        nodeId:       NODE_ID,
                        searchId:     loc,
                        searchIdType: 'UNKNOWN',
                    })),
                    startIndex: '0',
                },
            }),
        });
        if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
        const json = await resp.json();
        if (json.errors) throw new Error(JSON.stringify(json.errors));
        return json.data.searchEntities;
    }

    // ── HELPERS ─────────────────────────────────────────────
    const pad = n => String(n).padStart(2, '0');

    function cptToDate(ms)  { return ms ? new Date(parseInt(ms)) : null; }
    function tsToDate(ms)   { return ms ? new Date(parseInt(ms)) : null; }

    function fmtDate(d) {
        if (!d) return '—';
        return `${pad(d.getDate())}/${pad(d.getMonth() + 1)} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
    }

    function fmtTime(d) {
        return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
    }

    function fmtCountdown(ms) {
        if (ms <= 0) {
            const abs = Math.abs(ms);
            const m = Math.floor(abs / 60000);
            const s = Math.floor((abs % 60000) / 1000);
            return `−${m}m ${String(s).padStart(2, '0')}s`;
        }
        const h = Math.floor(ms / 3600000);
        const m = Math.floor((ms % 3600000) / 60000);
        const s = Math.floor((ms % 60000) / 1000);
        if (h > 0) return `${h}h ${String(m).padStart(2, '0')}m`;
        return `${m}m ${String(s).padStart(2, '0')}s`;
    }

    function fmtDwell(ms) {
        const h = Math.floor(ms / 3600000);
        const m = Math.floor((ms % 3600000) / 60000);
        return `${h}h ${String(m).padStart(2, '0')}m`;
    }

    function urgencyClass(ms) {
        if (ms === null) return '';
        if (ms <= 0)          return 'cpt-expired';
        if (ms <= 20 * 60000) return 'cpt-critical';
        if (ms <= 30 * 60000) return 'cpt-warning';
        return 'cpt-ok';
    }

    function dwellClass(ms) {
        if (ms >= 6 * 3600000) return 'cpt-expired';
        if (ms >= 4 * 3600000) return 'cpt-critical';
        return 'cpt-warning';
    }

    function getDisplayName(locId) {
        return DISPLAY_NAMES[locId] || locId;
    }

    function tanteiLink(searchId, label) {
        const url = `${TANTEI_URL}?nodeId=${NODE_ID}&searchType=Container&searchId=${encodeURIComponent(searchId)}`;
        return `<a href="${url}" target="_blank">${label || searchId}</a>`;
    }

    function pkgLink(label) {
        const url = `${TANTEI_URL}?nodeId=${NODE_ID}&searchId=${encodeURIComponent(label)}`;
        return `<a href="${url}" target="_blank">${label}</a>`;
    }

    // ── BUILD UI ────────────────────────────────────────────
    function buildUI() {
        document.body.innerHTML = '';
        document.title = 'DROP_OFF Tracker — ' + NODE_ID;

        const style = document.createElement('style');
        style.textContent = `
        :root {
            --bg: #0f1923;
            --card: #1a2634;
            --border: #2a3a4a;
            --text: #c9d1d9;
            --text-dim: #6e7e8e;
            --accent: #58a6ff;
            --green: #3fb950;
            --yellow: #d29922;
            --orange: #db6d28;
            --red: #f85149;
            --white: #e6edf3;
            --row-h: 28px;
        }
        * { box-sizing: border-box; margin: 0; padding: 0; }
        body {
            font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
            background: var(--bg);
            color: var(--text);
            padding: 14px 18px;
            min-height: 100vh;
        }
        #app-header {
            display: flex;
            align-items: center;
            justify-content: space-between;
            margin-bottom: 14px;
            padding: 12px 16px;
            background: var(--card);
            border: 1px solid var(--border);
            border-radius: 8px;
        }
        #app-header h1 {
            font-size: 20px;
            font-weight: 600;
            color: var(--white);
        }
        #app-header h1 span { color: var(--accent); }
        .header-right {
            display: flex;
            align-items: center;
            gap: 6px;
            font-size: 13px;
            color: var(--text-dim);
        }
        .header-sep {
            color: var(--border);
            margin: 0 4px;
        }
        .status-dot {
            display: inline-block;
            width: 8px; height: 8px;
            border-radius: 50%;
            margin-right: 5px;
        }
        .status-dot.ok      { background: var(--green); }
        .status-dot.loading  { background: var(--yellow); animation: pulse 1s infinite; }
        .status-dot.error    { background: var(--red); }
        @keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:0.3; } }

        #tables-wrap {
            display: grid;
            grid-template-columns: 1fr 1fr;
            gap: 14px;
            align-items: start;
        }
        #bottom-wrap {
            display: grid;
            grid-template-columns: 1fr 1fr;
            gap: 14px;
            align-items: start;
        }
        #slam-wrap {
            margin-top: 14px;
        }
        .section-label {
            font-size: 15px;
            font-weight: 600;
            color: var(--text-dim);
            margin: 18px 0 8px 0;
            padding-left: 4px;
        }
        .section-label span { color: var(--accent); }

        .half table, .bottom-half table, #slam-wrap table {
            width: 100%;
            border-collapse: collapse;
            background: var(--card);
            border: 1px solid var(--border);
            border-radius: 8px;
            overflow: hidden;
        }
        thead th {
            text-align: left;
            padding: 8px 10px;
            font-size: 12px;
            text-transform: uppercase;
            letter-spacing: 0.4px;
            color: var(--text-dim);
            background: rgba(0,0,0,0.25);
            border-bottom: 1px solid var(--border);
            position: sticky;
            top: 0;
            z-index: 10;
            white-space: nowrap;
        }
        tbody tr {
            border-bottom: 1px solid var(--border);
            transition: background 0.1s;
        }
        tbody tr:hover { background: rgba(88,166,255,0.04); }
        tbody tr:last-child { border-bottom: none; }
        td {
            padding: 5px 10px;
            font-size: 14px;
            vertical-align: top;
            white-space: nowrap;
        }
        td.loc-name {
            font-weight: 600;
            color: var(--white);
        }
        td.loc-name a { color: var(--accent); text-decoration: none; font-size: 14px; }
        td.loc-name a:hover { text-decoration: underline; }
        .pkg-row {
            height: var(--row-h);
            line-height: var(--row-h);
            overflow: hidden;
            white-space: nowrap;
            text-overflow: ellipsis;
        }
        .pkg-row a {
            color: var(--accent);
            text-decoration: none;
            font-family: 'Consolas', 'SF Mono', monospace;
            font-size: 13px;
        }
        .pkg-row a:hover { text-decoration: underline; }
        .sort-badge {
            display: inline-block;
            padding: 0 7px;
            border-radius: 3px;
            font-size: 12px;
            font-weight: 500;
            line-height: var(--row-h);
            background: rgba(88,166,255,0.1);
            color: var(--accent);
            white-space: nowrap;
        }
        .pp-badge {
            display: inline-block;
            padding: 0 7px;
            border-radius: 3px;
            font-size: 11px;
            font-weight: 500;
            line-height: var(--row-h);
            background: rgba(63,185,80,0.1);
            color: var(--green);
            white-space: nowrap;
            max-width: 150px;
            overflow: hidden;
            text-overflow: ellipsis;
            vertical-align: middle;
        }
        .pp-loading {
            color: var(--text-dim);
            background: rgba(110,126,142,0.1);
            animation: pulse 1s infinite;
        }
        #pp-btn {
            padding: 4px 12px;
            border-radius: 6px;
            border: 1px solid var(--accent);
            background: rgba(88,166,255,0.1);
            color: var(--accent);
            font-size: 12px;
            font-weight: 600;
            cursor: pointer;
            transition: background 0.2s;
        }
        #pp-btn:hover { background: rgba(88,166,255,0.22); }
        #pp-btn:disabled { opacity: 0.45; cursor: not-allowed; }
        #pp-status {
            font-size: 12px;
            color: var(--text-dim);
        }
        .cpt-cell .pkg-row {
            font-variant-numeric: tabular-nums;
            font-size: 13px;
        }
        .cd-cell .pkg-row {
            font-weight: 700;
            font-variant-numeric: tabular-nums;
            font-size: 16px;
        }
        .dwell-cell .pkg-row {
            font-weight: 700;
            font-variant-numeric: tabular-nums;
            font-size: 16px;
        }
        .cpt-expired  { color: var(--red); }
        .cpt-critical { color: var(--red); }
        .cpt-warning  { color: var(--yellow); }
        .cpt-ok       { color: var(--green); }
        .empty-label {
            color: var(--text-dim);
            font-style: italic;
            font-size: 13px;
            line-height: var(--row-h);
        }
        .pkg-count-badge {
            display: inline-flex;
            align-items: center;
            justify-content: center;
            min-width: 22px;
            margin-right: 8px;
            padding: 0 6px;
            border-radius: 8px;
            font-size: 12px;
            font-weight: 700;
            line-height: 20px;
            vertical-align: middle;
            background: rgba(248,81,73,0.15);
            color: var(--red);
        }
        .pkg-count-badge.zero {
            background: rgba(63,185,80,0.1);
            color: var(--green);
        }
        .pkg-count-badge.warn {
            background: rgba(210,153,34,0.15);
            color: var(--yellow);
        }
        .progress-bar {
            height: 2px;
            background: var(--border);
            border-radius: 2px;
            overflow: hidden;
            margin-top: 12px;
        }
        .progress-bar .fill {
            height: 100%;
            background: var(--accent);
            border-radius: 2px;
            transition: width 1s linear;
        }
        .author-badge {
            position: fixed;
            bottom: 14px;
            right: 18px;
            font-size: 12px;
            font-weight: 600;
            color: var(--text-dim);
            letter-spacing: 0.3px;
            opacity: 0.5;
            transition: opacity 0.3s, transform 0.3s;
            cursor: default;
            user-select: none;
            z-index: 100;
        }
        .author-badge:hover {
            opacity: 1;
            transform: scale(1.05);
        }
        .author-badge span {
            background: linear-gradient(90deg, var(--accent), #a78bfa, var(--green), var(--yellow), var(--accent));
            background-size: 300% 100%;
            -webkit-background-clip: text;
            -webkit-text-fill-color: transparent;
            background-clip: text;
            animation: author-shine 6s linear infinite;
        }
        @keyframes author-shine {
            0%   { background-position: 0% 50%; }
            100% { background-position: 300% 50%; }
        }
        `;
        document.head.appendChild(style);

        const tableHTML = `
        <table>
            <thead>
                <tr>
                    <th>Lokacja</th>
                    <th>Paczki</th>
                    <th>Sort Code</th>
                    <th>Process Path</th>
                    <th>CPT</th>
                    <th>Do CPT</th>
                </tr>
            </thead>
            <tbody></tbody>
        </table>`;

        const slamTableHTML = `
        <table>
            <thead>
                <tr>
                    <th>Lokacja</th>
                    <th>Paczki</th>
                    <th>Sort Code</th>
                    <th>Process Path</th>
                    <th>CPT</th>
                    <th>Do CPT</th>
                    <th>Dwell Time</th>
                </tr>
            </thead>
            <tbody></tbody>
        </table>`;

        document.body.innerHTML = `
        <div id="app-header">
            <h1>\u{1F4E6} DROP_OFF Tracker — <span>${NODE_ID}</span></h1>
            <div class="header-right">
                <button id="pp-btn">\uD83D\uDD0D Process Path</button>
                <span id="pp-status"></span>
                <span class="header-sep">\u00B7</span>
                <span id="status"><span class="status-dot loading"></span>\u0141adowanie...</span>
                <span class="header-sep">\u00B7</span>
                <span id="last-refresh">\u2014</span>
                <span class="header-sep">\u00B7</span>
                <span id="next-refresh">\u2014</span>
            </div>
        </div>

        <div id="tables-wrap">
            <div class="half" id="table-left">${tableHTML}</div>
            <div class="half" id="table-right">${tableHTML}</div>
        </div>

        <div id="bottom-wrap">
            <div class="bottom-half">
                <div class="section-label">\u{1F527} <span>Problem Solve</span></div>
                <div id="table-ps">${tableHTML}</div>
            </div>
            <div class="bottom-half">
                <div class="section-label">\u{1F4E5} <span>Vendor Returns</span></div>
                <div id="table-vr">${tableHTML}</div>
            </div>
        </div>

        <div id="slam-wrap">
            <div class="section-label">\u{1F6A8} <span>SLAM Stations</span> <span style="font-size:12px;font-weight:400;color:var(--text-dim);">(dwell time 1h\u201310h, CPT &lt; 10h)</span></div>
            <div id="table-slam">${slamTableHTML}</div>
        </div>

        <div class="progress-bar">
            <div class="fill" id="refresh-bar" style="width:100%"></div>
        </div>

        <div class="author-badge">made by <span>@NOWARATN</span></div>
        `;

        document.getElementById('pp-btn').addEventListener('click', loadAllProcessPaths);
    }

    // ── RENDER (standard tables) ─────────────────────────────
    function renderTable(tbodyEl, entities, now, excludeSortSuffix = null) {
        let totalPkgs = 0, expiredCount = 0, criticalCount = 0;
        const rows = [];

        for (const entity of entities) {
            const locId    = entity.searchTerm.searchId;
            const locLabel = getDisplayName(locId);
            let contents   = entity.contents?.contents || [];

            contents = contents.filter(pkg => {
                const cptDate = cptToDate(pkg.criticalPullTime);
                if (!cptDate) return true;
                return cptDate.getTime() - now > -EXPIRED_HIDE_MS;
            });

            if (excludeSortSuffix) {
                contents = contents.filter(pkg =>
                    !(pkg.stackingFilter || '').endsWith(excludeSortSuffix)
                );
            }

            const pkgCount = contents.length;
            totalPkgs += pkgCount;

            contents.sort((a, b) => {
                const ca = parseInt(a.criticalPullTime) || Infinity;
                const cb = parseInt(b.criticalPullTime) || Infinity;
                return ca - cb;
            });

            const badgeClass = pkgCount === 0 ? 'zero' : pkgCount <= 7 ? 'warn' : '';
            const countBadge = `<span class="pkg-count-badge ${badgeClass}">${pkgCount}</span>`;

            let pkgHTML = '', sortHTML = '', ppHTML = '', cptHTML = '', countdownHTML = '';

            if (pkgCount === 0) {
                pkgHTML = '<span class="empty-label">\u2713 Pusto</span>';
            } else {
                const pkgParts = [], sortParts = [], ppParts = [], cptParts = [], cdParts = [];
                for (const pkg of contents) {
                    const label   = pkg.containerLabel || pkg.containerId;
                    const sid     = pkg.shipmentId || '';
                    const cptDate = cptToDate(pkg.criticalPullTime);
                    const diff    = cptDate ? cptDate.getTime() - now : null;
                    const urg     = urgencyClass(diff);
                    const cached  = sid ? ppCache[sid] : null;
                    const ppText  = cached ? cached.path : '—';

                    if (diff !== null && diff <= 0)               expiredCount++;
                    else if (diff !== null && diff <= 20 * 60000) criticalCount++;

                    pkgParts.push(`<div class="pkg-row">${pkgLink(label)}</div>`);
                    sortParts.push(`<div class="pkg-row"><span class="sort-badge">${pkg.stackingFilter || '\u2014'}</span></div>`);
                    ppParts.push(`<div class="pkg-row" data-pp-shipment="${sid}"><span class="pp-badge" title="${ppText}">${ppText}</span></div>`);
                    cptParts.push(`<div class="pkg-row ${urg}">${fmtDate(cptDate)}</div>`);
                    cdParts.push(`<div class="pkg-row ${urg}">${diff !== null ? fmtCountdown(diff) : '\u2014'}</div>`);
                }
                pkgHTML       = pkgParts.join('');
                sortHTML      = sortParts.join('');
                ppHTML        = ppParts.join('');
                cptHTML       = cptParts.join('');
                countdownHTML = cdParts.join('');
            }

            rows.push(`
            <tr>
                <td class="loc-name">${countBadge}${tanteiLink(locId, locLabel)}</td>
                <td>${pkgHTML}</td>
                <td>${sortHTML}</td>
                <td>${ppHTML}</td>
                <td class="cpt-cell">${cptHTML}</td>
                <td class="cd-cell">${countdownHTML}</td>
            </tr>`);
        }

        tbodyEl.innerHTML = rows.join('');
        return { totalPkgs, expiredCount, criticalCount };
    }

    // ── RENDER (SLAM table with dwell time) ──────────────────
    function renderSlamTable(tbodyEl, entities, now) {
        let totalPkgs = 0;
        const rows = [];

        for (const entity of entities) {
            const locId    = entity.searchTerm.searchId;
            const locLabel = getDisplayName(locId);
            let contents   = entity.contents?.contents || [];

            // Filter: dwell 1h-10h AND remaining CPT <= 10h
            contents = contents.filter(pkg => {
                const assocDate = tsToDate(pkg.timeOfAssociation);
                if (!assocDate) return false;
                const dwell = now - assocDate.getTime();
                if (dwell <= SLAM_DWELL_THRESHOLD_MS || dwell > SLAM_DWELL_MAX_MS) return false;

                const cptDate = cptToDate(pkg.criticalPullTime);
                if (!cptDate) return true;
                const remaining = cptDate.getTime() - now;
                if (remaining > SLAM_CPT_MAX_MS) return false;

                return true;
            });

            const pkgCount = contents.length;

            // Skip empty locations entirely
            if (pkgCount === 0) continue;

            totalPkgs += pkgCount;

            // Sort by dwell time descending (earliest association first)
            contents.sort((a, b) => {
                const da = parseInt(a.timeOfAssociation) || Infinity;
                const db = parseInt(b.timeOfAssociation) || Infinity;
                return da - db;
            });

            const badgeClass = pkgCount <= 7 ? 'warn' : '';
            const countBadge = `<span class="pkg-count-badge ${badgeClass}">${pkgCount}</span>`;

            const pkgParts = [], sortParts = [], cptParts = [], cdParts = [], dwParts = [], ppParts = [];
            for (const pkg of contents) {
                const label     = pkg.containerLabel || pkg.containerId;
                const sid       = pkg.shipmentId || '';
                const cptDate   = cptToDate(pkg.criticalPullTime);
                const diff      = cptDate ? cptDate.getTime() - now : null;
                const urg       = urgencyClass(diff);
                const assocDate = tsToDate(pkg.timeOfAssociation);
                const dwellMs   = assocDate ? now - assocDate.getTime() : null;
                const dwCls     = dwellMs !== null ? dwellClass(dwellMs) : '';
                const cached    = sid ? ppCache[sid] : null;
                const ppText    = cached ? cached.path : '—';

                pkgParts.push(`<div class="pkg-row">${pkgLink(label)}</div>`);
                sortParts.push(`<div class="pkg-row"><span class="sort-badge">${pkg.stackingFilter || '\u2014'}</span></div>`);
                ppParts.push(`<div class="pkg-row" data-pp-shipment="${sid}"><span class="pp-badge" title="${ppText}">${ppText}</span></div>`);
                cptParts.push(`<div class="pkg-row ${urg}">${fmtDate(cptDate)}</div>`);
                cdParts.push(`<div class="pkg-row ${urg}">${diff !== null ? fmtCountdown(diff) : '\u2014'}</div>`);
                dwParts.push(`<div class="pkg-row ${dwCls}">${dwellMs !== null ? fmtDwell(dwellMs) : '\u2014'}</div>`);
            }

            rows.push(`
            <tr>
                <td class="loc-name">${countBadge}${tanteiLink(locId, locLabel)}</td>
                <td>${pkgParts.join('')}</td>
                <td>${sortParts.join('')}</td>
                <td>${ppParts.join('')}</td>
                <td class="cpt-cell">${cptParts.join('')}</td>
                <td class="cd-cell">${cdParts.join('')}</td>
                <td class="dwell-cell">${dwParts.join('')}</td>
            </tr>`);
        }

        tbodyEl.innerHTML = rows.join('');
        return { totalPkgs };
    }

    // ── MAIN RENDER ──────────────────────────────────────────
    function render(allEntities) {
        const now = Date.now();

        const entitiesMap = {};
        for (const e of allEntities) {
            entitiesMap[e.searchTerm.searchId] = e;
        }

        const leftEntities  = LEFT_LOCS.map(l => entitiesMap[l]).filter(Boolean);
        const rightEntities = RIGHT_LOCS.map(l => entitiesMap[l]).filter(Boolean);
        const psEntities    = LOCATIONS_PS.map(l => entitiesMap[l]).filter(Boolean);
        const vrEntities    = LOCATIONS_VR.map(l => entitiesMap[l]).filter(Boolean);
        const slamEntities  = LOCATIONS_SLAM.map(l => entitiesMap[l]).filter(Boolean);

        const tbodyLeft  = document.querySelector('#table-left tbody');
        const tbodyRight = document.querySelector('#table-right tbody');
        const tbodyPS    = document.querySelector('#table-ps tbody');
        const tbodyVR    = document.querySelector('#table-vr tbody');
        const tbodySlam  = document.querySelector('#table-slam tbody');

        renderTable(tbodyLeft,  leftEntities,  now);
        renderTable(tbodyRight, rightEntities, now);
        renderTable(tbodyPS,    psEntities,    now);
        renderTable(tbodyVR,    vrEntities,    now, '-VR');
        renderSlamTable(tbodySlam, slamEntities, now);

        const allPkgs = [...LOCATIONS, ...LOCATIONS_PS, ...LOCATIONS_VR].reduce((sum, loc) => {
            const e = entitiesMap[loc];
            if (!e) return sum;
            const contents = (e.contents?.contents || []).filter(pkg => {
                const cptDate = cptToDate(pkg.criticalPullTime);
                if (!cptDate) return true;
                return cptDate.getTime() - now > -EXPIRED_HIDE_MS;
            });
            return sum + contents.length;
        }, 0);

        document.title = allPkgs > 0
            ? `(${allPkgs}) DROP_OFF \u2014 ${NODE_ID}`
            : `DROP_OFF \u2014 ${NODE_ID}`;
    }

    // ── STATUS ──────────────────────────────────────────────
    function setStatus(type, text) {
        document.getElementById('status').innerHTML =
            `<span class="status-dot ${type}"></span>${text}`;
    }

    // ── REFRESH LOOP ────────────────────────────────────────
    let countdown = REFRESH_SEC;

    async function refresh() {
    setStatus('loading', 'Pobieranie...');
    try {
        const entities = await fetchAllLocations();
        render(entities);
        const now = new Date();
        document.getElementById('last-refresh').textContent = `\u{1F550} ${fmtTime(now)}`;
        setStatus('ok', 'Live');

        // ── AUTO PROCESS PATH ──────────────────────────────
        // Automatycznie ładuj Process Path po każdym odświeżeniu
        const btn = document.getElementById('pp-btn');
        if (btn && !btn.disabled) {
            loadAllProcessPaths();
        }
        // ───────────────────────────────────────────────────

    } catch (err) {
        console.error('DROP_OFF Tracker error:', err);
        setStatus('error', err.message);
    }
    countdown = REFRESH_SEC;
}


    function tick() {
        countdown--;
        if (countdown <= 0) { refresh(); }
        document.getElementById('next-refresh').textContent = `\u27F3 ${countdown}s`;
        const pct = (countdown / REFRESH_SEC) * 100;
        document.getElementById('refresh-bar').style.width = pct + '%';
    }

    // ── INIT ────────────────────────────────────────────────
    async function init() {
        await initCsrf();
        buildUI();
        await refresh();
        setInterval(tick, 1000);
    }

    init();
})();