Files
brandize-barcode/app.js
T
2026-06-10 06:44:34 -03:00

600 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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 = "<span></span><span></span><span></span>";
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 = '<option value="">Error tap to retry</option>';
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 =
'<option value="">Offline check connection</option>';
}
}
function buildDropdown(users) {
userSelect.innerHTML = '<option value="">— Select User —</option>';
// 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");
}
}
// ── Enqueue (called immediately on Enter) ──────────────────────────────
function enqueue(raw) {
const code = raw.trim();
if (!code) return;
const user = userSelect.value;
if (!user) {
showToast("Please select a user first!", "fail");
userSelect.style.outline = "2px solid rgba(255,80,80,0.8)";
setTimeout(() => {
userSelect.style.outline = "";
}, 1200);
return;
}
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 = `
<td colspan="3" class="pending-cell">
<span class="pending-spinner"></span>
<span class="pending-label">Processing…</span>
<span class="pending-code">${esc(code)}</span>
</td>
<td class="col-code">${esc(code)}</td>
`;
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 = `
<td><span class="country-badge">${esc(item.country || "—")}</span></td>
<td class="name-cell">${esc(item.name || "—")}</td>
<td><span class="tag-pill" title="${esc(item.producttag || "")}">${esc(item.producttag || "—")}</span></td>
<td class="col-code">${esc(code)}</td>
`;
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 = `
<td colspan="3" class="error-cell">
<span class="error-icon">✕</span>
<span class="error-msg">${esc(msg)}</span>
<span class="error-code">${esc(code)}</span>
</td>
<td class="col-code">${esc(code)}</td>
`;
}
// ── 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 = "name";
sortAsc = true;
loadBatches();
}
}
// Autoswitch to scan tab when a scan is enqueued
function switchToScanTab() {
if (document.body.classList.contains("tab-batches")) {
switchTab("scan");
}
}
// ── Batch webhook ────────────────────────────────────────────────────────
async function loadBatches() {
try {
const res = await fetch(BATCH_WEBHOOK, {
method: "GET",
headers: getHeaders(false),
});
if (res.status === 401) {
needAuth(loadBatches);
return;
}
if (!res.ok) {
batchList.innerHTML =
'<div class="empty-state" style="margin:40px 0"><div class="empty-title">Could not load batches</div></div>';
return;
}
const data = await parseJson(res);
console.log("[HR] Batches response:", data);
// Response can be { data: [...] } or an array wrapping it
if (data && Array.isArray(data.data)) {
allBatches = data.data;
} else if (
Array.isArray(data) &&
data.length > 0 &&
Array.isArray(data[0].data)
) {
allBatches = data[0].data;
} else {
allBatches = [];
}
renderBatches();
} catch (err) {
console.error(err);
batchList.innerHTML =
'<div class="empty-state" style="margin:40px 0"><div class="empty-title">Network error</div></div>';
}
}
function sortBatches(field) {
if (sortField === field) {
sortAsc = !sortAsc;
} else {
sortField = field;
sortAsc = true;
}
renderBatches();
}
function renderBatches() {
const query = batchSearch.value.toLowerCase().trim();
const stateFilter = batchStateFilter.value;
let filtered = allBatches.filter((b) => {
// State filter
if (stateFilter === "!done") {
if (b.state === "done") return false;
} else if (stateFilter && b.state !== stateFilter) {
return false;
}
// Text search
if (!query) return true;
return (
(b.name && b.name.toLowerCase().includes(query)) ||
(b.description && b.description.toLowerCase().includes(query)) ||
(b.carrier && b.carrier.toLowerCase().includes(query))
);
});
// Sort
filtered.sort((a, b) => {
const aVal = (a[sortField] || "").toLowerCase();
const bVal = (b[sortField] || "").toLowerCase();
return sortAsc ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
});
// Update sort arrows
document.getElementById("sortArrowName").textContent =
sortField === "name" ? (sortAsc ? "▲" : "▼") : "";
document.getElementById("sortArrowState").textContent =
sortField === "state" ? (sortAsc ? "▲" : "▼") : "";
if (filtered.length === 0) {
batchList.innerHTML =
'<tr><td colspan="4" class="batch-empty">No batches match</td></tr>';
return;
}
batchList.innerHTML = filtered
.map(
(b) => `
<tr class="${b.state === "done" ? "batch-row-done" : ""}">
<td><span class="batch-charge-cell">${esc(b.name)}</span></td>
<td><span class="batch-name-cell">${esc(b.description || "")}</span></td>
<td><span class="batch-carrier-cell">${esc(b.carrier || "")}</span></td>
<td><span class="batch-state-cell batch-state-${esc(b.state || "open")}">${esc(b.state || "open")}</span></td>
</tr>`,
)
.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();
});