From f75c1cf0a5894a8c58c02c2650b5194a2ef23ccd Mon Sep 17 00:00:00 2001 From: oliver Date: Tue, 25 Aug 2026 14:32:02 -0300 Subject: [PATCH] INIT --- agent.md | 203 +++++++++ app.js | 634 +++++++++++++++++++++++++++ favicon.ico | Bin 0 -> 20109 bytes index.html | 293 +++++++++++++ logo.png | Bin 0 -> 20109 bytes start | 21 + styles.css | 1214 +++++++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 2365 insertions(+) create mode 100644 agent.md create mode 100644 app.js create mode 100644 favicon.ico create mode 100644 index.html create mode 100644 logo.png create mode 100755 start create mode 100644 styles.css 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 0000000000000000000000000000000000000000..b1655e7d6171e04ef931ad55c9ec6e32a41fab6f GIT binary patch literal 20109 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 A5C8xG literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b1655e7d6171e04ef931ad55c9ec6e32a41fab6f GIT binary patch literal 20109 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 A5C8xG literal 0 HcmV?d00001 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; +}