diff --git a/agent.md b/agent.md index 754eceb..809fe6a 100644 --- a/agent.md +++ b/agent.md @@ -33,7 +33,8 @@ Font: system font stack (`-apple-system`, `Segoe UI`, etc.) ## Webhook -**Base URL:** `https://brandize.app.n8n.cloud/webhook/e0268b0f-4935-49ba-bfdf-1c0ee01d3b9d` +**Base URL (scan):** `https://brandize.app.n8n.cloud/webhook/transfer` +**Base URL (batches):** `https://brandize.app.n8n.cloud/webhook/batch` ### GET — load users Called once on page load to populate the user dropdown. @@ -54,7 +55,7 @@ Authorization: Basic 3. `{ Name: "Oliver" }` — single object, Name is a string ### POST — submit scan -Called when the user presses Enter in the scan input. +Called automatically when the user types/stops typing for 100ms (debounced). ``` POST @@ -103,6 +104,28 @@ Two cases: Errors appear in the **persistent status banner** (red, no auto-dismiss) and the scan input is selected so the user can re-scan or correct the code. +### GET — load batches +Called on page load and when the Batches tab becomes visible. + +``` +GET +Authorization: Basic +``` + +**Response shape:** +```json +[ + { + "data": [ + { "id": 18599, "name": "BATCH/26-06-10/18599", "description": "mix dpd", "carrier": "DPD/AT", "state": "done" }, + ... + ] + } +] +``` + +The Batches tab shows a searchable, filterable table with German headers (Chargenname, Transport, Status). Default filter resets to `state != "done"` ("Active") each time the tab opens. Columns are sortable — click Chargenname or Status to toggle ascending/descending. Sorting resets to name-asc when switching tabs. + ### NDJSON / response format n8n sometimes returns multiple items as NDJSON (one JSON object per line) instead of a proper JSON array. `parseJson(res)` handles all three formats: 1. Standard JSON array `[...]` @@ -131,8 +154,9 @@ n8n sometimes returns multiple items as NDJSON (one JSON object per line) instea On mobile (< 620 px): scan input goes full-width on its own row; user dropdown and clear button share the row below. ## Key behaviours -- **On load**: cookie credentials restored → GET webhook fires → dropdown populated → scan input auto-focused -- **Enter key**: trims input → duplicate check → clears input **immediately** → adds pending row → pushed onto FIFO queue → `drainQueue()` starts if idle +- **On load**: cookie credentials restored → GET webhooks fire (users + batches) → dropdown populated → scan input auto-focused +- **Scan auto-submit**: scanner input is debounced at 100ms — typing a barcode and pausing auto-submits without needing Enter +- **Scan auto-switches tab**: submitting a scan automatically switches to the Scan Log tab if the Batches tab is active - **Clear button**: empties the table, clears the scan input, resets the scan counter - **No user selected**: red toast + highlighted dropdown; does not submit - **401 on any request**: modal prompts for credentials (pre-filled from cookie), saves to cookie, retries original call diff --git a/app.js b/app.js index 021ea7d..56708dd 100644 --- a/app.js +++ b/app.js @@ -1,8 +1,8 @@ "use strict"; // ── Config ────────────────────────────────────────────────────────────── -const WEBHOOK = - "https://brandize.app.n8n.cloud/webhook/e0268b0f-4935-49ba-bfdf-1c0ee01d3b9d"; +const WEBHOOK = "https://brandize.app.n8n.cloud/webhook/transfer"; +const BATCH_WEBHOOK = "https://brandize.app.n8n.cloud/webhook/batch"; // ── State ─────────────────────────────────────────────────────────────────── let creds = null; // { user, pass } @@ -11,6 +11,12 @@ let scanCount = 0; const scanQueue = []; // { code, user, rowEl } — FIFO let queueBusy = false; // true while drainQueue is running +// Batches +let allBatches = []; +let scanDebounceTimer = null; +let sortField = "name"; +let sortAsc = true; + // ── DOM refs ───────────────────────────────────────────────────────────── const scanInput = document.getElementById("scanInput"); const userSelect = document.getElementById("userSelect"); @@ -26,328 +32,340 @@ const authPass = document.getElementById("authPass"); const authBtn = document.getElementById("authBtn"); const modalError = document.getElementById("modalError"); const toast = document.getElementById("toast"); +const scanSearch = document.getElementById("scanSearch"); +const batchSearch = document.getElementById("batchSearch"); +const batchList = document.getElementById("batchList"); +const batchStateFilter = document.getElementById("batchStateFilter"); +const scanPanel = document.getElementById("scanPanel"); +const batchesPanel = document.getElementById("batchesPanel"); // ── Cookie helpers ──────────────────────────────────────────────────────── function saveCreds(user, pass) { - const val = btoa(encodeURIComponent(JSON.stringify({ user, pass }))); - document.cookie = `hr_auth=${val}; max-age=${30 * 24 * 3600}; SameSite=Strict; path=/`; + const val = btoa(encodeURIComponent(JSON.stringify({ user, pass }))); + document.cookie = `hr_auth=${val}; max-age=${30 * 24 * 3600}; SameSite=Strict; path=/`; } function loadCredsFromCookie() { - const match = document.cookie.match(/(?:^|;\s*)hr_auth=([^;]+)/); - if (match) { - try { - return JSON.parse(decodeURIComponent(atob(match[1]))); - } catch (_) {} - } - return null; + const match = document.cookie.match(/(?:^|;\s*)hr_auth=([^;]+)/); + if (match) { + try { + return JSON.parse(decodeURIComponent(atob(match[1]))); + } catch (_) {} + } + return null; } function clearCredsCookie() { - document.cookie = "hr_auth=; max-age=0; path=/"; + document.cookie = "hr_auth=; max-age=0; path=/"; } // ── JSON parser (handles array, single object, and NDJSON) ─────────────── async function parseJson(res) { - const text = await res.text(); - // Standard JSON (array or object) - try { - const parsed = JSON.parse(text); - return parsed; - } catch (_) {} - // NDJSON — n8n sometimes returns one object per line - try { - const lines = text - .trim() - .split("\n") - .filter((l) => l.trim()); - if (lines.length > 0) { - return lines.map((l) => JSON.parse(l)); - } - } catch (_) {} - throw new Error("Could not parse server response"); + const text = await res.text(); + // Standard JSON (array or object) + try { + const parsed = JSON.parse(text); + return parsed; + } catch (_) {} + // NDJSON — n8n sometimes returns one object per line + try { + const lines = text + .trim() + .split("\n") + .filter((l) => l.trim()); + if (lines.length > 0) { + return lines.map((l) => JSON.parse(l)); + } + } catch (_) {} + throw new Error("Could not parse server response"); } // ── Helpers ────────────────────────────────────────────────────────────── function esc(str) { - return String(str ?? "") - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """); + return String(str ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); } function getHeaders(json = true) { - const h = {}; - if (json) h["Content-Type"] = "application/json"; - if (creds) { - h["Authorization"] = "Basic " + btoa(creds.user + ":" + creds.pass); - } - return h; + const h = {}; + if (json) h["Content-Type"] = "application/json"; + if (creds) { + h["Authorization"] = "Basic " + btoa(creds.user + ":" + creds.pass); + } + return h; } // ── Toast ──────────────────────────────────────────────────────────────── let toastTimer = null; function showToast(msg, type = "", ms = 3200) { - clearTimeout(toastTimer); - toast.textContent = msg; - toast.className = "visible " + type; - toastTimer = setTimeout(() => { - toast.className = ""; - }, ms); + clearTimeout(toastTimer); + toast.textContent = msg; + toast.className = "visible " + type; + toastTimer = setTimeout(() => { + toast.className = ""; + }, ms); } // ── Status Banner ──────────────────────────────────────────────────────── let bannerTimer = null; function showBanner(msg, type, autohide = 0) { - clearTimeout(bannerTimer); - banner.textContent = ""; - if (type === "loading") { - const dots = document.createElement("div"); - dots.className = "dot-spinner"; - dots.innerHTML = ""; - banner.appendChild(dots); - const t = document.createTextNode(" " + msg); - banner.appendChild(t); - } else { - banner.textContent = msg; - } - banner.className = "active " + type; - if (autohide) bannerTimer = setTimeout(hideBanner, autohide); + clearTimeout(bannerTimer); + banner.textContent = ""; + if (type === "loading") { + const dots = document.createElement("div"); + dots.className = "dot-spinner"; + dots.innerHTML = ""; + banner.appendChild(dots); + const t = document.createTextNode(" " + msg); + banner.appendChild(t); + } else { + banner.textContent = msg; + } + banner.className = "active " + type; + if (autohide) bannerTimer = setTimeout(hideBanner, autohide); } function hideBanner() { - banner.className = ""; - banner.textContent = ""; + banner.className = ""; + banner.textContent = ""; } // ── Auth Modal ─────────────────────────────────────────────────────────── function needAuth(retryCb) { - pendingAuthCb = retryCb; - modalError.style.display = "none"; - // Pre-fill from saved cookie so user only needs to confirm - const saved = loadCredsFromCookie(); - authUser.value = saved?.user || ""; - authPass.value = saved?.pass || ""; - authModal.classList.remove("hidden"); - // Focus password if username already filled, otherwise username - setTimeout(() => (saved ? authPass.focus() : authUser.focus()), 50); + pendingAuthCb = retryCb; + modalError.style.display = "none"; + // Pre-fill from saved cookie so user only needs to confirm + const saved = loadCredsFromCookie(); + authUser.value = saved?.user || ""; + authPass.value = saved?.pass || ""; + authModal.classList.remove("hidden"); + // Focus password if username already filled, otherwise username + setTimeout(() => (saved ? authPass.focus() : authUser.focus()), 50); } function cancelAuth() { - authModal.classList.add("hidden"); - pendingAuthCb = null; - showBanner("Authentication cancelled.", "error", 4000); + authModal.classList.add("hidden"); + pendingAuthCb = null; + showBanner("Authentication cancelled.", "error", 4000); } async function submitAuth() { - const u = authUser.value.trim(); - const p = authPass.value; - if (!u || !p) { - modalError.textContent = "Please enter both username and password."; - modalError.style.display = "block"; - return; - } + const u = authUser.value.trim(); + const p = authPass.value; + if (!u || !p) { + modalError.textContent = "Please enter both username and password."; + modalError.style.display = "block"; + return; + } - creds = { user: u, pass: p }; - saveCreds(u, p); // persist to cookie - authBtn.classList.add("loading"); - authBtn.disabled = true; - modalError.style.display = "none"; + creds = { user: u, pass: p }; + saveCreds(u, p); // persist to cookie + authBtn.classList.add("loading"); + authBtn.disabled = true; + modalError.style.display = "none"; - // Close modal first, then retry - authModal.classList.add("hidden"); - authBtn.classList.remove("loading"); - authBtn.disabled = false; + // Close modal first, then retry + authModal.classList.add("hidden"); + authBtn.classList.remove("loading"); + authBtn.disabled = false; - if (pendingAuthCb) { - const cb = pendingAuthCb; - pendingAuthCb = null; - await cb(); - } + if (pendingAuthCb) { + const cb = pendingAuthCb; + pendingAuthCb = null; + await cb(); + } } authPass.addEventListener("keydown", (e) => { - if (e.key === "Enter") submitAuth(); + if (e.key === "Enter") submitAuth(); }); // ── Load Users ─────────────────────────────────────────────────────────── async function loadUsers() { - showBanner("Loading users…", "loading"); - try { - const res = await fetch(WEBHOOK, { - method: "GET", - headers: getHeaders(false), - }); + showBanner("Loading users…", "loading"); + try { + const res = await fetch(WEBHOOK, { + method: "GET", + headers: getHeaders(false), + }); - if (res.status === 401) { - hideBanner(); - needAuth(loadUsers); - return; - } - - if (!res.ok) { - showBanner(`Could not load users (HTTP ${res.status}).`, "error"); - userSelect.innerHTML = - ''; - return; - } - - const data = await parseJson(res); - console.log("[HR] Users response:", data); - buildDropdown(data); - hideBanner(); - } catch (err) { - console.error(err); - showBanner("Network error – could not reach the server.", "error"); - userSelect.innerHTML = - ''; + if (res.status === 401) { + hideBanner(); + needAuth(loadUsers); + return; } + + if (!res.ok) { + showBanner(`Could not load users (HTTP ${res.status}).`, "error"); + userSelect.innerHTML = ''; + return; + } + + const data = await parseJson(res); + console.log("[HR] Users response:", data); + buildDropdown(data); + hideBanner(); + } catch (err) { + console.error(err); + showBanner("Network error – could not reach the server.", "error"); + userSelect.innerHTML = + ''; + } } function buildDropdown(users) { - userSelect.innerHTML = ''; + userSelect.innerHTML = ''; - // Normalise all shapes n8n may return: - // { Name: ["Oliver", "Luka", "Benni"] } ← actual format - // [ { Name: "Oliver" }, ... ] ← standard array - // { Name: "Oliver" } ← single object - let names = []; - if (Array.isArray(users)) { - // Array of objects: pull .Name from each - names = users.map((u) => u.Name).filter(Boolean); - } else if (users && Array.isArray(users.Name)) { - // Single object with a Name array - names = users.Name.filter(Boolean); - } else if (users && users.Name) { - // Single object with a single Name string - names = [users.Name]; - } + // Normalise all shapes n8n may return: + // { Name: ["Oliver", "Luka", "Benni"] } ← actual format + // [ { Name: "Oliver" }, ... ] ← standard array + // { Name: "Oliver" } ← single object + let names = []; + if (Array.isArray(users)) { + // Array of objects: pull .Name from each + names = users.map((u) => u.Name).filter(Boolean); + } else if (users && Array.isArray(users.Name)) { + // Single object with a Name array + names = users.Name.filter(Boolean); + } else if (users && users.Name) { + // Single object with a single Name string + names = [users.Name]; + } - names.forEach((name) => { - userSelect.appendChild(new Option(name, name)); - }); + names.forEach((name) => { + userSelect.appendChild(new Option(name, name)); + }); } // ── Duplicate check ────────────────────────────────────────────────────── // Checks both resolved rows and pending rows (all have .col-code) function isDuplicate(code) { - for (const cell of tableBody.querySelectorAll(".col-code")) { - if (cell.textContent === code) return true; - } - return false; + for (const cell of tableBody.querySelectorAll(".col-code")) { + if (cell.textContent === code) return true; + } + return false; } // ── Queue pill ────────────────────────────────────────────────────────── function updateQueuePill() { - const n = scanQueue.length; - if (n > 0) { - queuePillText.textContent = n + (n === 1 ? " pending" : " pending"); - queuePill.classList.remove("hidden"); - } else { - queuePill.classList.add("hidden"); - } + const n = scanQueue.length; + if (n > 0) { + queuePillText.textContent = n + (n === 1 ? " pending" : " pending"); + queuePill.classList.remove("hidden"); + } else { + queuePill.classList.add("hidden"); + } } // ── Enqueue (called immediately on Enter) ────────────────────────────── function enqueue(raw) { - const code = raw.trim(); - if (!code) return; + const code = raw.trim(); + if (!code) return; - const user = userSelect.value; - if (!user) { - showToast("Please select a user first!", "fail"); - userSelect.style.outline = "2px solid rgba(255,80,80,0.8)"; - setTimeout(() => { - userSelect.style.outline = ""; - }, 1200); - return; - } + const user = userSelect.value; + if (!user) { + showToast("Please select a user first!", "fail"); + userSelect.style.outline = "2px solid rgba(255,80,80,0.8)"; + setTimeout(() => { + userSelect.style.outline = ""; + }, 1200); + return; + } - if (isDuplicate(code)) { - showToast("⚠️ Already scanned: " + code, "fail", 4000); - scanInput.select(); - return; - } + if (isDuplicate(code)) { + showToast("⚠️ Already scanned: " + code, "fail", 4000); + scanInput.select(); + return; + } - // Clear input immediately so the next scan can happen right away - scanInput.value = ""; - scanInput.focus(); - hideBanner(); + // Auto-switch to scan log tab + switchToScanTab(); - // Add a pending row to the table (also registers code for dupe detection) - const rowEl = addPendingRow(code); + // Clear input immediately so the next scan can happen right away + scanInput.value = ""; + scanInput.focus(); + hideBanner(); - // Push onto queue - scanQueue.push({ code, user, rowEl }); - updateQueuePill(); + // Add a pending row to the table (also registers code for dupe detection) + const rowEl = addPendingRow(code); - // Kick off the queue processor if it isn't running already - if (!queueBusy) drainQueue(); + // Push onto queue + scanQueue.push({ code, user, rowEl }); + updateQueuePill(); + + // Kick off the queue processor if it isn't running already + if (!queueBusy) drainQueue(); } +// ── Batch search/filter events ────────────────────────────────────────── +batchSearch.addEventListener("input", renderBatches); +batchStateFilter.addEventListener("change", renderBatches); + // ── Queue drain (processes items sequentially in the background) ──────── async function drainQueue() { - if (queueBusy) return; - queueBusy = true; - while (scanQueue.length > 0) { - const item = scanQueue[0]; // peek — only shift after handling - const result = await processItem(item); - if (result === "auth") { - // Credentials needed — pause queue, resume after login - queueBusy = false; - needAuth(drainQueue); - return; - } - scanQueue.shift(); - updateQueuePill(); + if (queueBusy) return; + queueBusy = true; + while (scanQueue.length > 0) { + const item = scanQueue[0]; // peek — only shift after handling + const result = await processItem(item); + if (result === "auth") { + // Credentials needed — pause queue, resume after login + queueBusy = false; + needAuth(drainQueue); + return; } - queueBusy = false; + scanQueue.shift(); updateQueuePill(); + } + queueBusy = false; + updateQueuePill(); } // ── Process one queued item ──────────────────────────────────────────── async function processItem({ code, user, rowEl }) { - try { - const res = await fetch(WEBHOOK, { - method: "POST", - headers: getHeaders(true), - body: JSON.stringify({ user, code }), - }); + try { + const res = await fetch(WEBHOOK, { + method: "POST", + headers: getHeaders(true), + body: JSON.stringify({ user, code }), + }); - if (res.status === 401) return "auth"; // signal caller to pause + if (res.status === 401) return "auth"; // signal caller to pause - if (!res.ok) { - let errMsg = `Scan failed (HTTP ${res.status})`; - try { - const errData = await parseJson(res); - if (errData?.error) errMsg = errData.error; - } catch (_) {} - rowError(rowEl, errMsg); - return "ok"; - } - - const item = await parseJson(res); - if (item?.error) { - rowError(rowEl, item.error); - return "ok"; - } - - resolveRow(rowEl, item); - return "ok"; - } catch (err) { - console.error(err); - rowError(rowEl, "Network error – could not reach server"); - return "ok"; + if (!res.ok) { + let errMsg = `Scan failed (HTTP ${res.status})`; + try { + const errData = await parseJson(res); + if (errData?.error) errMsg = errData.error; + } catch (_) {} + rowError(rowEl, errMsg); + return "ok"; } + + const item = await parseJson(res); + if (item?.error) { + rowError(rowEl, item.error); + return "ok"; + } + + resolveRow(rowEl, item); + return "ok"; + } catch (err) { + console.error(err); + rowError(rowEl, "Network error – could not reach server"); + return "ok"; + } } // ── Table rows ──────────────────────────────────────────────────────────────── function addPendingRow(code) { - emptyState.style.display = "none"; - const tr = document.createElement("tr"); - tr.className = "row-pending"; - tr.innerHTML = ` + emptyState.style.display = "none"; + const tr = document.createElement("tr"); + tr.className = "row-pending"; + tr.innerHTML = ` Processing… @@ -355,28 +373,28 @@ function addPendingRow(code) { ${esc(code)} `; - tableBody.insertBefore(tr, tableBody.firstChild); - return tr; + tableBody.insertBefore(tr, tableBody.firstChild); + return tr; } function resolveRow(rowEl, item) { - const code = rowEl.querySelector(".col-code").textContent; - rowEl.className = item.gepackt ? "row-already-packed" : ""; - rowEl.innerHTML = ` + const code = rowEl.querySelector(".col-code").textContent; + rowEl.className = item.gepackt ? "row-already-packed" : ""; + rowEl.innerHTML = ` ${esc(item.country || "—")} ${esc(item.name || "—")} ${esc(item.producttag || "—")} ${esc(code)} `; - scanCount++; - countPill.textContent = scanCount + (scanCount === 1 ? " scan" : " scans"); - countPill.classList.remove("zero"); + scanCount++; + countPill.textContent = scanCount + (scanCount === 1 ? " scan" : " scans"); + countPill.classList.remove("zero"); } function rowError(rowEl, msg) { - const code = rowEl.querySelector(".col-code").textContent; - rowEl.className = "row-error"; - rowEl.innerHTML = ` + const code = rowEl.querySelector(".col-code").textContent; + rowEl.className = "row-error"; + rowEl.innerHTML = ` ${esc(msg)} @@ -387,47 +405,195 @@ function rowError(rowEl, msg) { } // ── Clear ────────────────────────────────────────────────────────────────────── -function clearTable() { - const hasContent = scanCount > 0 || scanQueue.length > 0 || scanInput.value; - if (!hasContent) return; - // Cancel any pending queue items - scanQueue.length = 0; - queueBusy = false; - updateQueuePill(); - tableBody.innerHTML = ""; - scanInput.value = ""; - scanCount = 0; - countPill.textContent = "0 scans"; - countPill.classList.add("zero"); - emptyState.style.display = ""; - scanInput.focus(); - showToast("Table cleared", "", 2000); -} - -// ── Scan Input Enter ────────────────────────────────────────────────────────── -scanInput.addEventListener("keydown", (e) => { - if (e.key === "Enter") { - const v = scanInput.value.trim(); - if (v) enqueue(v); +// ── Scan search/filter ────────────────────────────────────────────────── +scanSearch.addEventListener("input", () => { + const q = scanSearch.value.toLowerCase().trim(); + let visible = 0; + for (const row of tableBody.querySelectorAll("tr")) { + const txt = row.textContent.toLowerCase(); + if (!q || txt.includes(q)) { + row.style.display = ""; + visible++; + } else { + row.style.display = "none"; } + } + // Update the count pill with filtered count + countPill.textContent = visible + (visible === 1 ? " scan" : " scans"); + countPill.classList.toggle("zero", visible === 0); }); -// Retry on clicking offline dropdown -userSelect.addEventListener("focus", () => { - if ( - userSelect.options.length <= 1 && - userSelect.options[0]?.value === "" && - /error|offline/i.test(userSelect.options[0]?.text) - ) { - loadUsers(); +function clearTable() { + const hasContent = scanCount > 0 || scanQueue.length > 0 || scanInput.value; + if (!hasContent) return; + // Cancel any pending queue items + scanQueue.length = 0; + queueBusy = false; + updateQueuePill(); + tableBody.innerHTML = ""; + scanInput.value = ""; + scanSearch.value = ""; + scanCount = 0; + countPill.textContent = "0 scans"; + countPill.classList.add("zero"); + emptyState.style.display = ""; + scanInput.focus(); + showToast("Table cleared", "", 2000); +} + +// ── Scan Input (debounced auto-submit) ────────────────────────────────── +scanInput.addEventListener("input", () => { + clearTimeout(scanDebounceTimer); + scanDebounceTimer = setTimeout(() => { + const v = scanInput.value.trim(); + if (v) enqueue(v); + }, 100); +}); + +// ── Tab switching ──────────────────────────────────────────────────────── +function switchTab(tab) { + document.body.className = "tab-" + tab; + document.querySelectorAll(".tab-btn").forEach((btn) => { + btn.classList.toggle("active", btn.dataset.tab === tab); + }); + if (tab === "batches") { + batchStateFilter.value = "!done"; + sortField = "name"; + sortAsc = true; + loadBatches(); + } +} + +// Autoswitch to scan tab when a scan is enqueued +function switchToScanTab() { + if (document.body.classList.contains("tab-batches")) { + switchTab("scan"); + } +} + +// ── Batch webhook ──────────────────────────────────────────────────────── +async function loadBatches() { + try { + const res = await fetch(BATCH_WEBHOOK, { + method: "GET", + headers: getHeaders(false), + }); + + if (res.status === 401) { + needAuth(loadBatches); + return; } + + if (!res.ok) { + batchList.innerHTML = + '
Could not load batches
'; + return; + } + + const data = await parseJson(res); + console.log("[HR] Batches response:", data); + + // Response can be { data: [...] } or an array wrapping it + if (data && Array.isArray(data.data)) { + allBatches = data.data; + } else if ( + Array.isArray(data) && + data.length > 0 && + Array.isArray(data[0].data) + ) { + allBatches = data[0].data; + } else { + allBatches = []; + } + + renderBatches(); + } catch (err) { + console.error(err); + batchList.innerHTML = + '
Network error
'; + } +} + +function sortBatches(field) { + if (sortField === field) { + sortAsc = !sortAsc; + } else { + sortField = field; + sortAsc = true; + } + renderBatches(); +} + +function renderBatches() { + const query = batchSearch.value.toLowerCase().trim(); + const stateFilter = batchStateFilter.value; + + let filtered = allBatches.filter((b) => { + // State filter + if (stateFilter === "!done") { + if (b.state === "done") return false; + } else if (stateFilter && b.state !== stateFilter) { + return false; + } + + // Text search + if (!query) return true; + return ( + (b.name && b.name.toLowerCase().includes(query)) || + (b.description && b.description.toLowerCase().includes(query)) || + (b.carrier && b.carrier.toLowerCase().includes(query)) + ); + }); + + // Sort + filtered.sort((a, b) => { + const aVal = (a[sortField] || "").toLowerCase(); + const bVal = (b[sortField] || "").toLowerCase(); + return sortAsc ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal); + }); + + // Update sort arrows + document.getElementById("sortArrowName").textContent = + sortField === "name" ? (sortAsc ? "▲" : "▼") : ""; + document.getElementById("sortArrowState").textContent = + sortField === "state" ? (sortAsc ? "▲" : "▼") : ""; + + if (filtered.length === 0) { + batchList.innerHTML = + 'No batches match'; + return; + } + + batchList.innerHTML = filtered + .map( + (b) => ` + + ${esc(b.name)} + ${esc(b.description || "")} + ${esc(b.carrier || "")} + ${esc(b.state || "open")} + `, + ) + .join(""); +} + +// ── Retry on clicking offline dropdown ────────────────────────────────── +userSelect.addEventListener("focus", () => { + if ( + userSelect.options.length <= 1 && + userSelect.options[0]?.value === "" && + /error|offline/i.test(userSelect.options[0]?.text) + ) { + loadUsers(); + } }); // ── Init ───────────────────────────────────────────────────────────────── window.addEventListener("DOMContentLoaded", () => { - // Restore saved credentials before the first network call - const saved = loadCredsFromCookie(); - if (saved) creds = saved; - loadUsers(); - scanInput.focus(); + // Restore saved credentials before the first network call + const saved = loadCredsFromCookie(); + if (saved) creds = saved; + loadUsers(); + loadBatches(); + scanInput.focus(); }); diff --git a/index.html b/index.html index 3485ecb..911481b 100644 --- a/index.html +++ b/index.html @@ -9,7 +9,7 @@ Health Routine – Package Scanner - +
@@ -73,34 +73,63 @@
+ +
+ + +
+
- -
-
- Scan Log -
- - 0 scans -
+ +
+
+ + + + + + 0 scans +
-
- + @@ -188,6 +217,73 @@ + +
CountryLand Name Tag Code
+ + + + + + + + + + + + + +
Charge + Name + + Transport + Status + +
+ Loading batches… +
+
+
+
diff --git a/styles.css b/styles.css index a13936b..8999886 100644 --- a/styles.css +++ b/styles.css @@ -97,6 +97,65 @@ body { font-weight: 400; } +/* ── Tab Bar ─────────────────────────────────────────── */ +.tab-bar { + background: var(--primary-dark); + display: flex; + gap: 0; + padding: 0 20px; + flex-shrink: 0; +} + +.tab-btn { + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.55); + font-size: 13px; + font-weight: 600; + padding: 10px 20px; + cursor: pointer; + position: relative; + transition: color 0.15s; + letter-spacing: 0.3px; +} + +.tab-btn:hover { + color: rgba(255, 255, 255, 0.85); +} + +.tab-btn.active { + color: #fff; +} + +.tab-btn.active::after { + content: ""; + position: absolute; + bottom: 0; + left: 8px; + right: 8px; + height: 3px; + background: #fff; + border-radius: 3px 3px 0 0; +} + +.tab-panel { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +body.tab-scan #batchesPanel { + display: none; +} +body.tab-batches main { + display: none; +} +body.tab-batches #batchesPanel { + display: flex !important; +} + /* ── Controls Bar ────────────────────────────────────── */ .controls-bar { background: var(--primary); @@ -295,14 +354,49 @@ body { } } +/* ── Scan search bar ─────────────────────────────────── */ +.scan-search-wrap { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 20px; + background: var(--white); + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.scan-search-icon { + flex-shrink: 0; + color: var(--text-muted); +} + +#scanSearch { + flex: 1; + border: 1px solid var(--border); + border-radius: var(--r-md); + padding: 8px 12px; + font-size: 14px; + font-family: inherit; + background: var(--bg); + outline: none; + transition: border-color 0.15s; +} + +#scanSearch:focus { + border-color: var(--primary); +} + +.scan-search-wrap .count-pill { + flex-shrink: 0; +} + /* ── Main / Table ────────────────────────────────────── */ main { flex: 1; display: flex; flex-direction: column; min-height: 0; - padding: 16px 20px 20px; - gap: 10px; + padding: 0; overflow: hidden; } @@ -338,6 +432,13 @@ main { } /* Table wrapper — this scrolls */ +.scan-header-row { + display: flex; + align-items: center; + gap: 10px; + padding: 2px 0; +} + .table-wrapper { flex: 1; min-height: 0; @@ -364,19 +465,19 @@ thead { } thead tr { - background: var(--primary); + background: var(--white); } thead th { - padding: 13px 18px; + padding: 14px 18px 6px; text-align: left; font-size: 11px; font-weight: 700; - letter-spacing: 1.2px; + letter-spacing: 0.8px; text-transform: uppercase; - color: rgba(255, 255, 255, 0.9); + color: var(--text-muted); white-space: nowrap; - border-bottom: 2px solid var(--primary-dark); + border-bottom: 2px solid var(--border); } thead th:first-child { @@ -884,7 +985,211 @@ tbody td { } } -/* Scrollbar styling */ +/* ── Batch List ──────────────────────────────────────── */ +.batch-search-wrap { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 20px; + background: var(--white); + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.batch-search-icon { + flex-shrink: 0; + color: var(--text-muted); +} + +#batchSearch { + flex: 1; + border: 1px solid var(--border); + border-radius: var(--r-md); + padding: 8px 12px; + font-size: 14px; + font-family: inherit; + background: var(--bg); + outline: none; + transition: border-color 0.15s; +} + +#batchSearch:focus { + border-color: var(--primary); +} + +#batchStateFilter { + border: 1px solid var(--border); + border-radius: var(--r-md); + padding: 8px 10px; + font-size: 13px; + font-family: inherit; + background: var(--bg); + color: var(--text); + outline: none; + cursor: pointer; +} + +#batchStateFilter:focus { + border-color: var(--primary); +} + +.batch-table-wrap { + flex: 1; + min-height: 0; + overflow: auto; + padding: 0 20px 20px; + background: var(--white); +} + +.batch-table { + width: 100%; + border-collapse: separate; + border-spacing: 0 4px; +} + +.batch-table thead { + position: sticky; + top: 0; + z-index: 5; +} + +.batch-table thead tr { + background: var(--white); +} + +.batch-table thead th { + padding: 14px 18px 6px; + text-align: left; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.8px; + text-transform: uppercase; + color: var(--text-muted); + white-space: nowrap; + border-bottom: 2px solid var(--border); +} + +.batch-th-sort { + cursor: pointer; + user-select: none; +} + +.batch-th-sort:hover { + color: var(--text); +} + +.sort-arrow { + font-size: 10px; + margin-left: 3px; + opacity: 0.7; +} + +.batch-table tbody td { + padding: 12px; + background: var(--bg); + border-radius: 0; + font-size: 14px; + vertical-align: middle; +} + +.batch-table tbody tr { + border-radius: var(--r-md); + overflow: hidden; + transition: opacity 0.15s; +} + +.batch-table tbody tr:hover td { + background: var(--primary-light); +} + +.batch-table tbody tr:first-child td:first-child { + border-radius: var(--r-md) 0 0 var(--r-md); +} + +.batch-table tbody tr:first-child td:last-child { + border-radius: 0 var(--r-md) var(--r-md) 0; +} + +.batch-row-done td { + opacity: 0.5; +} + +.batch-row-done .batch-name-cell { + text-decoration: line-through; +} + +.batch-charge-cell { + font-weight: 600; + color: var(--text); + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 200px; +} + +.batch-name-cell { + color: var(--text); + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 180px; +} + +.batch-carrier-cell { + font-size: 11px; + font-weight: 600; + color: var(--primary); + background: var(--primary-light); + padding: 2px 8px; + border-radius: 99px; + white-space: nowrap; + display: inline-block; +} + +.batch-state-cell { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 3px 10px; + border-radius: 99px; + white-space: nowrap; + display: inline-block; +} + +.batch-state-done { + background: #d1fae5; + color: #065f46; +} + +.batch-state-open { + background: #fef3c7; + color: #92400e; +} + +.batch-empty { + text-align: center; + color: var(--text-muted); + padding: 40px 0 !important; + font-size: 14px; +} + +@media (max-width: 620px) { + .batch-table tbody td { + padding: 8px 6px; + font-size: 12px; + } + .batch-charge-cell { + max-width: 120px; + } + .batch-name-cell { + max-width: 100px; + } +} + +/* ── Scrollbar styling ───────────────────────────────── */ .table-wrapper::-webkit-scrollbar { width: 6px; height: 6px;