commit f75c1cf0a5894a8c58c02c2650b5194a2ef23ccd Author: oliver Date: Tue Aug 25 14:32:02 2026 -0300 INIT diff --git a/agent.md b/agent.md new file mode 100644 index 0000000..809fe6a --- /dev/null +++ b/agent.md @@ -0,0 +1,203 @@ +# Health Routine – Package Scanner + +> ALWAYS UPDATE THIS FILE AFTER MAKING CHANGES + +## What this is +A single-page web app (`index.html`) used internally by Health Routine staff to scan **package** barcodes. +A user selects their name, scans/types a code, and the result is looked up via a webhook and appended to a table. +Runs in any browser — designed for phones, tablets, and desktop. + +## Files +``` +index.html — the entire app (HTML + CSS + JS, no build step) +logo.png — Health Routine brand logo (used in the header) +agent.md — this file +start — bash script that launches live-server on localhost:8080 +``` + +## Design +Colors are pulled directly from healthroutine.de's CSS variables: + +| Token | Hex | Usage | +|------------------|-----------|-------------------------------------| +| `--primary` | `#005f5a` | Controls bar, table header, buttons | +| `--primary-dark` | `#004845` | Hover states | +| `--primary-light`| `#e0f0ef` | Row hover, badge backgrounds | +| `--bg` | `#f7f7fa` | Page background | +| `--card` | `#eaeaf2` | Alternating table rows | +| `--sage` | `#82917d` | (brand footer green, available) | +| `--error` | `#c71b1b` | Error states | +| `--success` | `#15803d` | Success toasts | + +Font: system font stack (`-apple-system`, `Segoe UI`, etc.) + +## Webhook + +**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. + +``` +GET +Authorization: Basic +``` + +**Actual response shape** (confirmed via console): +```json +{ "Name": ["Oliver", "Luka", "Benni"] } +``` + +`buildDropdown()` handles all three shapes n8n may return: +1. `{ Name: ["Oliver", "Luka", "Benni"] }` — single object, Name is an array (current) +2. `[ { Name: "Oliver" }, ... ]` — array of objects +3. `{ Name: "Oliver" }` — single object, Name is a string + +### POST — submit scan +Called automatically when the user types/stops typing for 100ms (debounced). + +``` +POST +Authorization: Basic +Content-Type: application/json + +{ "user": "Oliver", "code": "SCANNED_BARCODE_STRING" } +``` + +Success response: +```json +{ + "name": "Christa Gierdahl, Christa Gierdahl", + "producttag": "0-HR-Nail-Care-5", + "batch": "BATCH/25-11-14/06173", + "country": "DE" +} +``` + +Error response (non-2xx **or** 200 with error field): +```json +{ "error": "Human-readable error description" } +``` + +Fields mapped to table columns: +- `country` → **Country** (teal pill badge) +- `name` → **Name** +- `producttag` → **Tag** (monospace pill, truncated with tooltip on hover) +- `batch` → received but not displayed in the table + +### Authentication +The webhook requires HTTP Basic Auth. + +- On `401` (GET or POST) a modal appears asking for username + password. +- Credentials are saved to a **cookie** (`hr_auth`, 30-day expiry, `SameSite=Strict`) so the user is not prompted again. +- On page load, the cookie is read and credentials restored before the first network call. +- If the modal is triggered again (e.g. wrong password), the fields are **pre-filled** from the cookie and focus lands on the password field for quick confirmation. +- `saveCreds(user, pass)` — JSON-stringifies, URI-encodes, base64-encodes, writes cookie. +- `loadCredsFromCookie()` — reverses that; returns `{ user, pass }` or `null`. +- `clearCredsCookie()` — available but not exposed in UI (call from browser console if needed). + +### Error handling (POST) +Two cases: +1. **Non-2xx response** — body is parsed; if `errData.error` exists it is shown verbatim; otherwise a generic `HTTP ` message is shown. +2. **200 response with `error` field** — treated as failure: `item.error` is displayed, row is *not* added to the table. + +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 `[...]` +2. Single JSON object `{...}` — passed through as-is +3. NDJSON — split on `\n`, each line parsed individually, returned as an array + +## UI layout + +``` +┌─────────────────────────────────────────────────────┐ +│ [HR logo] Health Routine | Package Scanner │ ← white brand bar +├─────────────────────────────────────────────────────┤ +│ [▥ SCAN HERE_____________] [User ▾] [✕ Clear] │ ← teal controls bar +├─────────────────────────────────────────────────────┤ +│ SCAN LOG 3 scans ● │ +│ ┌───────────────────────────────────────────────┐ │ +│ │ COUNTRY │ NAME │ TAG │ │ ← sticky header +│ ├──────────┼───────────────────────┼─────────────┤ │ +│ │ DE │ Christa Gierdahl │ 0-HR-Nail… │ │ ← newest row on top +│ │ AT │ ... │ ... │ │ +│ │ scrollable area │ │ │ +│ └───────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────┘ +``` + +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 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 +- **Webhook error**: `error` field shown verbatim in persistent red banner; input selected for re-scan +- **Network error**: dismissing error banner; input selected so user can retry +- **Dropdown error state**: tapping/focusing the dropdown while it shows an error re-triggers `loadUsers()` +- **NDJSON/array/object**: `parseJson()` normalises all response formats from n8n +- **Duplicate scan**: `isDuplicate(code)` queries `.col-code` cells (present on pending, resolved, and error rows) before enqueuing +- **Scan queue**: `scanQueue[]` is a FIFO array of `{ code, user, rowEl }`. `drainQueue()` runs sequentially in the background — one `processItem()` at a time. Scans can be added faster than the webhook responds; each gets its own pending row immediately +- **Pending row**: shows a spinner + `Processing… ` while the item is in-flight; replaced in-place by the resolved row or an error row +- **Error row**: red background, shows the error message and the raw code; code is still in `.col-code` so it cannot be re-scanned +- **Queue pill**: teal badge next to the scan counter showing `N pending`; hidden when queue is empty +- **Auth mid-queue**: if `processItem` gets 401, it returns `"auth"` to `drainQueue`, which pauses (`queueBusy = false`), shows the auth modal with `drainQueue` as the retry callback — queue resumes from the same item after login +- **Clear**: also drains `scanQueue` and resets `queueBusy` + +## Scan flow (sequence) +``` +Enter key + └─ enqueue(code) + ├─ validation (user selected? duplicate?) + ├─ scanInput.value = "" ← immediate clear + ├─ addPendingRow(code) ← spinner row in table + └─ scanQueue.push(...) ← add to FIFO + └─ drainQueue() if idle + └─ processItem() loop + ├─ fetch POST webhook + ├─ resolveRow() ← replace spinner with result + └─ rowError() ← replace spinner with error +``` + +## Table columns (DOM) +| Visible | Header | CSS class | Content | +|---------|--------|-----------|---------| +| ✓ | Country | — | `item.country` wrapped in teal badge | +| ✓ | Name | `name-cell` | `item.name` | +| ✓ | Tag | — | `item.producttag` in monospace pill | +| ✗ | Code | `col-code` | Raw scanned code (`display:none`), used for duplicate detection | + +## How to run +Open `index.html` directly in a browser (`file://`) — no server needed for local use. +Or run the `start` script for live-reload during development: +```bash +./start # requires Node.js; installs live-server globally if missing +``` diff --git a/app.js b/app.js new file mode 100644 index 0000000..23b9af9 --- /dev/null +++ b/app.js @@ -0,0 +1,634 @@ +"use strict"; + +// ── Config ────────────────────────────────────────────────────────────── +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 } +let pendingAuthCb = null; // function to retry after auth +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"); +const tableBody = document.getElementById("tableBody"); +const emptyState = document.getElementById("emptyState"); +const countPill = document.getElementById("countPill"); +const queuePill = document.getElementById("queuePill"); +const queuePillText = document.getElementById("queuePillText"); +const banner = document.getElementById("statusBanner"); +const authModal = document.getElementById("authModal"); +const authUser = document.getElementById("authUser"); +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=/`; +} + +function loadCredsFromCookie() { + 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=/"; +} + +// ── 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"); +} + +// ── Helpers ────────────────────────────────────────────────────────────── +function esc(str) { + 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; +} + +// ── Toast ──────────────────────────────────────────────────────────────── +let toastTimer = null; +function showToast(msg, type = "", ms = 3200) { + 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); +} + +function hideBanner() { + 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); +} + +function cancelAuth() { + 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; + } + + 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; + + if (pendingAuthCb) { + const cb = pendingAuthCb; + pendingAuthCb = null; + await cb(); + } +} + +authPass.addEventListener("keydown", (e) => { + 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), + }); + + 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 = ''; + + // 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)); + }); +} + +// ── 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; +} + +// ── 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"); + } +} + +// ── Barcode extraction ──────────────────────────────────────────────── +// Handles multiple formats: +// 1. DPD barcode starting with %: +// % + 7-char postcode + 14-digit tracking + 3-digit service + 3-digit country +// E.g. "%000460006265029820831328040" → "06265029820831" +// 2. Space-separated blocks: 3rd through 6th (0-indexed 2..5) joined +// E.g. "0003 580 0626 5027 3077 23 328 040" → "06265027307723" +function extractCode(raw) { + // Strip parentheses first + const cleaned = raw.trim().replace(/[()]/g, ""); + + // Handle %-prefixed DPD barcode + if (cleaned.startsWith("%")) { + const withoutPct = cleaned.slice(1); + if (withoutPct.length >= 21) { + // Skip 7-char postcode, take 14-digit tracking number, drop the rest + return withoutPct.slice(7, 21); + } + return withoutPct; // fallback: return everything after % + } + + // Handle space-separated format + const parts = cleaned.split(/\s+/); + if (parts.length >= 6) { + return parts.slice(2, 6).join(""); + } + + return cleaned; // fallback: use as-is +} + +// ── Enqueue (called immediately on Enter) ────────────────────────────── +function enqueue(raw) { + const code = extractCode(raw); + 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; + } + + if (isDuplicate(code)) { + showToast("⚠️ Already scanned: " + code, "fail", 4000); + scanInput.select(); + return; + } + + // Auto-switch to scan log tab + switchToScanTab(); + + // Clear input immediately so the next scan can happen right away + scanInput.value = ""; + scanInput.focus(); + hideBanner(); + + // Add a pending row to the table (also registers code for dupe detection) + const rowEl = addPendingRow(code); + + // 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(); + } + 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 }), + }); + + 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"; + } +} + +// ── Table rows ──────────────────────────────────────────────────────────────── +function addPendingRow(code) { + emptyState.style.display = "none"; + const tr = document.createElement("tr"); + tr.className = "row-pending"; + tr.innerHTML = ` + + + Processing… + ${esc(code)} + + ${esc(code)} + `; + 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 = ` + ${esc(item.country || "—")} + ${esc(item.name || "—")} + ${esc(item.producttag || "—")} + ${esc(code)} + `; + 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 = ` + + + ${esc(msg)} + ${esc(code)} + + ${esc(code)} + `; +} + +// ── Clear ────────────────────────────────────────────────────────────────────── +// ── 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); +}); + +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 = "batch_id"; + 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 is a flat array of batch objects + if (Array.isArray(data)) { + allBatches = data; + } else if (data && Array.isArray(data.data)) { + allBatches = data.data; + } else { + allBatches = []; + } + + renderBatches(); + } catch (err) { + console.error(err); + batchList.innerHTML = + '
Network error
'; + } +} + +// Description can be a string or an object like { en_US: "...", de_DE: "..." } +function getDescription(desc) { + if (!desc) return ""; + if (typeof desc === "string") return desc; + // Prefer de_DE, fall back to en_US, then any value + return desc.de_DE || desc.en_US || Object.values(desc)[0] || ""; +} + +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; + const desc = getDescription(b.description); + return ( + (b.batch_id && b.batch_id.toLowerCase().includes(query)) || + (desc && desc.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.batch_id)} + ${esc(getDescription(b.description))} + ${esc(b.state || "open")} + ${esc(String(b.gepackt ?? ""))} + ${esc(String(b.ungepackt ?? ""))} + `, + ) + .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(); + loadBatches(); + scanInput.focus(); +}); diff --git a/favicon.ico b/favicon.ico new file mode 100644 index 0000000..b1655e7 Binary files /dev/null and b/favicon.ico differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..0c439ea --- /dev/null +++ b/index.html @@ -0,0 +1,293 @@ + + + + + + Health Routine – Package Scanner + + + + +
+
+ Health Routine Logo +
+ Health Routine +
+ Package Scanner +
+ + +
+
+
+ + + + + + + + + + + + +
+ + + + +
+
+ + +
+ + +
+ + +
+ + +
+
+ + + + + + 0 scans + +
+
+ + + + + + + + + + +
LandNameTagCode
+ + +
+
+ + + + +
+
No scans yet
+
+ Select a user, then scan or type a package code and + press Enter +
+
+
+
+ + + + + + + + +
+ + + + diff --git a/logo.png b/logo.png new file mode 100644 index 0000000..b1655e7 Binary files /dev/null and b/logo.png differ diff --git a/start b/start new file mode 100755 index 0000000..a1db1d4 --- /dev/null +++ b/start @@ -0,0 +1,21 @@ +#!/bin/bash + + +#!/bin/bash + +# Simple static file server with live reload +# Requires Node.js and live-server + +# Check if live-server is installed +if ! command -v live-server &> /dev/null +then + echo "Installing live-server..." + npm install -g live-server +fi + +# Serve the current directory with auto-reload +echo "Starting live-server on http://localhost:8080 ..." +live-server . + + + diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..b04d2ba --- /dev/null +++ b/styles.css @@ -0,0 +1,1214 @@ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --primary: #005f5a; + --primary-dark: #004845; + --primary-light: #e0f0ef; + --bg: #f7f7fa; + --card: #eaeaf2; + --white: #ffffff; + --text: #0f0f0f; + --text-muted: rgba(15, 15, 15, 0.55); + --border: #d4d4d4; + --sage: #82917d; + --error: #c71b1b; + --error-bg: #fff0f0; + --success: #15803d; + --success-bg: #f0fdf4; + --shadow-sm: 0 1px 4px rgba(0, 0, 0, 0.08); + --shadow-md: 0 4px 20px rgba(0, 95, 90, 0.13); + --r-sm: 6px; + --r-md: 10px; + --r-lg: 16px; +} + +html, +body { + height: 100%; +} + +body { + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", + Arial, sans-serif; + background: var(--bg); + color: var(--text); + font-size: 16px; + line-height: 1.5; + display: flex; + flex-direction: column; + min-height: 100dvh; + overflow: hidden; +} + +/* ── Brand Bar ───────────────────────────────────────── */ +.brand-bar { + background: var(--white); + border-bottom: 1px solid var(--border); + padding: 10px 20px; + display: flex; + align-items: center; + gap: 12px; + box-shadow: var(--shadow-sm); + flex-shrink: 0; +} + +.brand-logo-wrap { + width: 38px; + height: 38px; + background: var(--primary); + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + padding: 5px; +} + +.brand-logo-wrap img { + width: 100%; + height: 100%; + object-fit: contain; + filter: invert(1) brightness(10); +} + +.brand-name { + font-size: 17px; + font-weight: 700; + color: var(--text); + letter-spacing: -0.3px; +} + +.brand-divider { + width: 1px; + height: 18px; + background: var(--border); +} + +.brand-subtitle { + font-size: 13px; + color: var(--text-muted); + 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); + padding: 12px 20px; + box-shadow: var(--shadow-md); + flex-shrink: 0; +} + +.controls-inner { + max-width: 1200px; + margin: 0 auto; + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +/* Scan input */ +.scan-wrap { + flex: 1 1 220px; + position: relative; + min-width: 0; +} + +.scan-icon { + position: absolute; + left: 13px; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + pointer-events: none; + opacity: 0.75; +} + +.scan-icon svg { + width: 18px; + height: 18px; + fill: white; +} + +#scanInput { + width: 100%; + padding: 11px 14px 11px 42px; + border: 2px solid rgba(255, 255, 255, 0.35); + border-radius: var(--r-md); + background: rgba(255, 255, 255, 0.15); + color: white; + font-size: 16px; + font-weight: 600; + letter-spacing: 1.5px; + outline: none; + transition: + border-color 0.2s, + background 0.2s, + box-shadow 0.2s; + -webkit-font-smoothing: antialiased; +} + +#scanInput::placeholder { + color: rgba(255, 255, 255, 0.65); + font-weight: 400; + letter-spacing: 2.5px; + font-size: 14px; +} + +#scanInput:focus { + border-color: rgba(255, 255, 255, 0.9); + background: rgba(255, 255, 255, 0.22); + box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.15); +} + +/* User dropdown */ +#userSelect { + flex: 0 1 175px; + padding: 11px 36px 11px 13px; + border: 2px solid rgba(255, 255, 255, 0.35); + border-radius: var(--r-md); + background: rgba(255, 255, 255, 0.15); + color: white; + font-size: 14px; + font-weight: 500; + outline: none; + cursor: pointer; + appearance: none; + -webkit-appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='rgba(255,255,255,0.75)'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 13px center; + transition: + border-color 0.2s, + background 0.2s; + min-width: 0; +} + +#userSelect:focus { + border-color: rgba(255, 255, 255, 0.9); + background-color: rgba(255, 255, 255, 0.22); +} + +#userSelect option { + background: var(--primary-dark); + color: white; + font-weight: 400; +} + +/* Clear button */ +.btn-clear { + flex-shrink: 0; + padding: 11px 18px; + border: 2px solid rgba(255, 255, 255, 0.45); + border-radius: var(--r-md); + background: transparent; + color: white; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: + background 0.2s, + border-color 0.2s, + transform 0.1s; + display: flex; + align-items: center; + gap: 6px; + white-space: nowrap; +} + +.btn-clear:hover { + background: rgba(255, 255, 255, 0.18); + border-color: rgba(255, 255, 255, 0.8); +} + +.btn-clear:active { + transform: scale(0.97); +} + +/* ── Status Banner ───────────────────────────────────── */ +#statusBanner { + flex-shrink: 0; + padding: 9px 20px; + font-size: 13px; + font-weight: 500; + text-align: center; + display: none; + align-items: center; + justify-content: center; + gap: 8px; +} + +#statusBanner.active { + display: flex; +} + +#statusBanner.error { + background: var(--error-bg); + color: var(--error); + border-bottom: 1px solid #fecaca; +} + +#statusBanner.loading { + background: var(--primary-light); + color: var(--primary); + border-bottom: 1px solid #a7d7d4; +} + +.dot-spinner { + display: inline-flex; + gap: 4px; +} + +.dot-spinner span { + width: 5px; + height: 5px; + background: var(--primary); + border-radius: 50%; + animation: dotPulse 1.2s ease-in-out infinite; +} + +.dot-spinner span:nth-child(2) { + animation-delay: 0.2s; +} +.dot-spinner span:nth-child(3) { + animation-delay: 0.4s; +} + +@keyframes dotPulse { + 0%, + 80%, + 100% { + transform: scale(0.6); + opacity: 0.4; + } + 40% { + transform: scale(1); + opacity: 1; + } +} + +/* ── 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: 0; + overflow: hidden; +} + +.table-meta { + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; +} + +.table-label { + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text-muted); +} + +.count-pill { + background: var(--primary); + color: white; + font-size: 12px; + font-weight: 700; + padding: 2px 11px; + border-radius: 99px; + letter-spacing: 0.2px; + transition: all 0.2s; +} + +.count-pill.zero { + background: var(--border); + color: var(--text-muted); +} + +/* Table wrapper — this scrolls */ +.scan-header-row { + display: flex; + align-items: center; + gap: 10px; + padding: 2px 0; +} + +.table-wrapper { + flex: 1; + min-height: 0; + overflow: auto; + border-radius: var(--r-lg); + box-shadow: var(--shadow-md); + background: var(--white); + border: 1px solid var(--border); + position: relative; + -webkit-overflow-scrolling: touch; +} + +table { + width: 100%; + border-collapse: collapse; + min-width: 380px; +} + +/* Sticky header */ +thead { + position: sticky; + top: 0; + z-index: 5; +} + +thead tr { + background: var(--white); +} + +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); +} + +thead th:first-child { + width: 90px; +} +thead th:last-child { + width: 200px; +} + +/* Hidden code column — stores raw scan code for duplicate checking */ +.col-code { + display: none; +} + +/* Queue status pill */ +.queue-pill { + background: var(--primary-light); + color: var(--primary); + font-size: 12px; + font-weight: 700; + padding: 2px 11px; + border-radius: 99px; + letter-spacing: 0.2px; + display: flex; + align-items: center; + gap: 5px; +} +.queue-pill.hidden { + display: none; +} + +/* Pending row */ +.row-pending td { + padding: 0; +} + +.pending-cell { + display: flex; + align-items: center; + gap: 10px; + padding: 11px 18px; + font-size: 13px; + color: var(--text-muted); +} + +.pending-spinner { + width: 14px; + height: 14px; + border: 2px solid rgba(0, 95, 90, 0.2); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 0.7s linear infinite; + flex-shrink: 0; +} + +.pending-label { + font-style: italic; +} + +.pending-code { + font-family: "Courier New", monospace; + font-size: 12px; + color: var(--text); + opacity: 0.6; +} + +/* Already-packed row */ +.row-already-packed td { + background: #fff0f0 !important; + color: #c71b1b; +} + +.row-already-packed .country-badge { + background: #fecaca; + color: #991b1b; +} + +.row-already-packed .name-cell { + color: #c71b1b; + font-weight: 600; +} + +.row-already-packed .tag-pill { + background: #fee2e2; + border-color: #fca5a5; + color: #991b1b; +} + +/* Error row */ +.row-error td { + padding: 0; +} + +.error-cell { + display: flex; + align-items: center; + gap: 8px; + padding: 11px 18px; + background: var(--error-bg); + font-size: 13px; +} + +.error-icon { + color: var(--error); + font-weight: 700; + flex-shrink: 0; +} + +.error-msg { + color: var(--error); + flex: 1; +} + +.error-code { + font-family: "Courier New", monospace; + font-size: 11px; + color: var(--error); + opacity: 0.55; +} + +tbody tr { + border-bottom: 1px solid var(--card); + animation: rowIn 0.28s ease-out; + transition: background 0.15s; +} + +@keyframes rowIn { + from { + opacity: 0; + transform: translateY(-6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +tbody tr:nth-child(odd) { + background: var(--white); +} +tbody tr:nth-child(even) { + background: #f4f4f8; +} +tbody tr:hover { + background: var(--primary-light) !important; +} +tbody tr:last-child { + border-bottom: none; +} + +tbody td { + padding: 12px 18px; + font-size: 14px; + color: var(--text); + vertical-align: middle; +} + +.country-badge { + display: inline-flex; + align-items: center; + gap: 4px; + background: var(--primary-light); + color: var(--primary); + font-weight: 800; + font-size: 12px; + padding: 3px 10px; + border-radius: 99px; + letter-spacing: 0.8px; +} + +.name-cell { + font-weight: 500; + font-size: 14px; +} + +.tag-pill { + display: inline-block; + background: #f3f4f6; + color: #374151; + font-family: "Courier New", "SF Mono", monospace; + font-size: 12px; + padding: 4px 10px; + border-radius: var(--r-sm); + border: 1px solid #e5e7eb; + max-width: 200px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: middle; +} + +/* Empty state */ +.empty-state { + padding: 64px 24px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + color: var(--text-muted); +} + +.empty-icon { + width: 56px; + height: 56px; + background: var(--card); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +.empty-icon svg { + width: 26px; + height: 26px; + stroke: var(--text-muted); + fill: none; + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; +} + +.empty-title { + font-size: 15px; + font-weight: 600; + color: var(--text-muted); +} + +.empty-hint { + font-size: 13px; + color: var(--border); + text-align: center; + max-width: 260px; +} + +/* ── Auth Modal ──────────────────────────────────────── */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + backdrop-filter: blur(5px); + -webkit-backdrop-filter: blur(5px); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; +} + +.modal-overlay.hidden { + display: none; +} + +.modal-card { + background: var(--white); + border-radius: var(--r-lg); + padding: 30px 28px 26px; + max-width: 420px; + width: 100%; + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.28); + animation: modalIn 0.22s ease-out; +} + +@keyframes modalIn { + from { + opacity: 0; + transform: scale(0.94) translateY(12px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +.modal-icon { + width: 44px; + height: 44px; + background: var(--primary-light); + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 16px; +} + +.modal-icon svg { + width: 22px; + height: 22px; + stroke: var(--primary); + fill: none; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.modal-title { + font-size: 19px; + font-weight: 700; + color: var(--text); + margin-bottom: 4px; +} + +.modal-sub { + font-size: 13px; + color: var(--text-muted); + margin-bottom: 22px; +} + +.field-label { + display: block; + font-size: 12px; + font-weight: 700; + color: var(--text); + letter-spacing: 0.5px; + text-transform: uppercase; + margin-bottom: 6px; +} + +.field-input { + display: block; + width: 100%; + padding: 11px 13px; + border: 2px solid var(--border); + border-radius: var(--r-sm); + font-size: 15px; + color: var(--text); + outline: none; + transition: + border-color 0.18s, + box-shadow 0.18s; + margin-bottom: 14px; + background: var(--white); +} + +.field-input:focus { + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(0, 95, 90, 0.1); +} + +.modal-error { + font-size: 13px; + color: var(--error); + display: none; + margin-bottom: 14px; +} + +.modal-actions { + display: flex; + gap: 10px; + margin-top: 6px; +} + +.btn-ghost { + padding: 11px 18px; + background: transparent; + color: var(--text-muted); + border: 2px solid var(--border); + border-radius: var(--r-sm); + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: + background 0.2s, + border-color 0.2s; +} + +.btn-ghost:hover { + background: var(--card); + border-color: #bbb; +} + +.btn-primary { + flex: 1; + padding: 11px 18px; + background: var(--primary); + color: white; + border: 2px solid transparent; + border-radius: var(--r-sm); + font-size: 14px; + font-weight: 700; + cursor: pointer; + transition: + background 0.2s, + transform 0.1s; + display: flex; + align-items: center; + justify-content: center; + gap: 7px; +} + +.btn-primary:hover { + background: var(--primary-dark); +} +.btn-primary:active { + transform: scale(0.98); +} +.btn-primary:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* Inline spinner for buttons */ +.btn-spinner { + width: 14px; + height: 14px; + border: 2px solid rgba(255, 255, 255, 0.4); + border-top-color: white; + border-radius: 50%; + animation: spin 0.55s linear infinite; + display: none; +} + +.btn-primary.loading .btn-spinner { + display: block; +} +.btn-primary.loading .btn-text { + opacity: 0.8; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* ── Toast ───────────────────────────────────────────── */ +#toast { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%) translateY(16px); + background: var(--text); + color: white; + padding: 10px 22px; + border-radius: 99px; + font-size: 14px; + font-weight: 500; + box-shadow: var(--shadow-md); + z-index: 2000; + opacity: 0; + transition: + opacity 0.25s, + transform 0.25s; + pointer-events: none; + white-space: nowrap; +} + +#toast.visible { + opacity: 1; + transform: translateX(-50%) translateY(0); +} + +#toast.ok { + background: var(--success); +} +#toast.fail { + background: var(--error); +} + +/* ── Responsive ──────────────────────────────────────── */ +@media (max-width: 620px) { + .brand-divider, + .brand-subtitle { + display: none; + } + + .controls-inner { + gap: 8px; + } + + .scan-wrap { + flex: 1 1 100%; + order: 1; + } + #userSelect { + flex: 1 1 auto; + order: 2; + min-width: 0; + } + .btn-clear { + order: 3; + } + + main { + padding: 12px; + } + + thead th, + tbody td { + padding: 11px 12px; + } + + .tag-pill { + max-width: 130px; + font-size: 11px; + } +} + +@media (min-width: 621px) and (max-width: 960px) { + .scan-wrap { + flex: 1 1 240px; + } + #userSelect { + flex: 0 1 150px; + } +} + +/* ── 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-num-cell { + font-size: 13px; + font-weight: 600; + color: var(--text); + text-align: center; + white-space: nowrap; +} + +.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; +} +.table-wrapper::-webkit-scrollbar-track { + background: transparent; +} +.table-wrapper::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 99px; +} +.table-wrapper::-webkit-scrollbar-thumb:hover { + background: #b4b4b4; +}