hm_ems_report/static/index.html
Vadym Samoilenko 36b12888b2 Add better error handling in init function
Check that response is ok and files is an array before processing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:55:08 +00:00

712 lines
26 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>H&M EMS Review Tool</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background: #f0f0f0; color: #333; font-size: 13px;
}
.header {
background: #333; color: #fff; padding: 0 20px; height: 44px;
display: flex; align-items: center; justify-content: space-between;
}
.header-left { display: flex; align-items: center; gap: 16px; }
.header-brand { font-size: 18px; font-weight: 700; color: #cc0000; letter-spacing: 1px; }
.header-campaign { font-size: 14px; font-weight: 500; }
.header-campaign span { color: #aaa; font-weight: 400; margin-left: 6px; }
.header-right { display: flex; align-items: center; gap: 10px; }
.header-meta { font-size: 10px; color: #999; text-align: right; line-height: 1.4; }
.file-selector {
padding: 4px 8px; font-size: 12px; border: 1px solid #666;
border-radius: 3px; background: #555; color: #fff; cursor: pointer;
}
.file-selector option { background: #444; color: #fff; }
.btn {
padding: 6px 14px; border: none; border-radius: 3px; cursor: pointer;
font-size: 11px; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.5px; transition: background 0.2s;
}
.btn-export { background: #cc0000; color: #fff; }
.btn-export:hover { background: #a30000; }
.btn-export:disabled { background: #888; cursor: default; }
.btn-logout { background: #555; color: #fff; }
.btn-logout:hover { background: #333; }
.toolbar {
background: #fff; border-bottom: 1px solid #ddd; padding: 0 20px; height: 38px;
display: flex; align-items: center; gap: 12px;
}
.toolbar .stats {
margin-left: auto; font-size: 11px; color: #888; white-space: nowrap;
}
.toolbar .stats strong { color: #cc0000; }
.approved-count { color: #4caf50; }
.sticky-top { position: sticky; top: 0; z-index: 100; }
.table-container { padding: 0 0 40px; overflow-x: auto; }
.loading-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(255,255,255,0.85); display: flex;
align-items: center; justify-content: center; z-index: 200;
font-size: 16px; color: #666;
}
.loading-overlay.hidden { display: none; }
.product-table {
border-collapse: collapse; background: #fff;
box-shadow: 0 1px 3px rgba(0,0,0,0.1); min-width: 100%;
}
.product-table thead th {
background: #333; color: #fff; font-size: 10px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.4px; padding: 6px 8px;
text-align: left; white-space: nowrap; border-right: 1px solid #444;
}
.product-table thead th:last-child { border-right: none; }
.product-table thead th.lang-group-header {
background: #3a3a3a; text-align: center; padding: 5px 6px;
border-left: 2px solid #666;
}
.product-table thead th.lang-group-header.empty-col { background: #2a2a2a; }
.product-table thead th.lang-sub-header {
background: #484848; font-size: 9px; padding: 4px 6px;
}
.product-table thead th.lang-sub-header-first { border-left: 2px solid #666; }
.product-table tbody td {
padding: 5px 8px; border-bottom: 1px solid #e5e5e5;
border-right: 1px solid #e5e5e5; vertical-align: middle; font-size: 12px;
}
.product-table tbody td:last-child { border-right: none; }
.product-table tbody td.lang-group-first { border-left: 2px solid #ddd; }
.product-table tbody td.empty-cell { background: #fafafa; }
.product-table tbody tr:nth-child(even) { background: #f5f5f5; }
.product-table tbody tr:nth-child(even) td.empty-cell { background: #f2f2f2; }
.product-table tbody tr:hover { background: #eef4ff; }
.col-visual { width: 90px; text-align: center; }
.col-article { width: 90px; font-family: 'SF Mono','Consolas',monospace; font-size: 11px; }
.col-gb-name { width: 160px; font-size: 12px; }
.image-group { display: flex; gap: 4px; justify-content: center; flex-wrap: wrap; }
.product-image { max-width: 80px; max-height: 80px; object-fit: contain; border-radius: 2px; cursor: pointer; }
.product-image:hover { opacity: 0.8; }
.image-placeholder {
width: 60px; height: 60px; background: #e0e0e0; border-radius: 2px;
display: inline-flex; align-items: center; justify-content: center;
color: #aaa; font-size: 9px;
}
.image-modal-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.7); z-index: 300;
display: flex; align-items: center; justify-content: center;
}
.image-modal-overlay.hidden { display: none; }
.image-modal {
background: #fff; border-radius: 8px; padding: 16px;
max-width: 90vw; max-height: 90vh; display: flex;
flex-direction: column; align-items: center; gap: 12px;
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
}
.image-modal img {
max-width: 80vw; max-height: 75vh; object-fit: contain; border-radius: 4px;
}
.image-modal-close {
padding: 8px 24px; border: none; border-radius: 4px; cursor: pointer;
font-size: 13px; font-weight: 600; background: #333; color: #fff;
transition: background 0.2s;
}
.image-modal-close:hover { background: #cc0000; }
.edit-input {
width: 100%; padding: 3px 6px; border: 1px solid #ddd; border-radius: 2px;
font-size: 12px; font-family: inherit; background: #fff;
transition: border-color 0.15s, background 0.15s;
}
.edit-input:focus {
outline: none; border-color: #cc0000;
box-shadow: 0 0 0 2px rgba(204,0,0,0.1);
}
.edit-input.changed { background: #fff8e1; border-color: #f5a623; }
.edit-input.differs-from-gb { background: #fffde7; }
.edit-input.changed.differs-from-gb { background: #fff3cd; border-color: #f5a623; }
.edit-input:disabled {
background: #f0f0f0; color: #999; border-color: #e0e0e0; cursor: not-allowed;
}
.approve-btn {
width: 24px; height: 24px; border-radius: 50%; border: 2px solid #ccc;
background: #fff; cursor: pointer; display: inline-flex;
align-items: center; justify-content: center; transition: all 0.15s;
font-size: 13px; color: #ccc; padding: 0; line-height: 1;
}
.approve-btn:hover { border-color: #4caf50; color: #4caf50; }
.approve-btn.approved { background: #4caf50; border-color: #4caf50; color: #fff; }
.approve-btn.saving { opacity: 0.5; pointer-events: none; }
.col-approve { width: 34px; text-align: center; }
.lang-header-controls { display: inline-flex; align-items: center; gap: 6px; }
.lang-header-controls select {
padding: 3px 4px; font-size: 11px; border: 1px solid #666;
border-radius: 2px; background: #555; color: #fff; max-width: 180px;
}
.lang-header-controls select option { background: #444; color: #fff; }
.lang-remove-btn {
width: 18px; height: 18px; border-radius: 50%; border: 1px solid #888;
background: transparent; color: #aaa; cursor: pointer; font-size: 14px;
display: inline-flex; align-items: center; justify-content: center;
padding: 0; line-height: 1; transition: all 0.15s;
}
.lang-remove-btn:hover { background: #cc0000; border-color: #cc0000; color: #fff; }
.change-count {
display: inline-block; background: #cc0000; color: #fff; border-radius: 10px;
padding: 1px 6px; font-size: 10px; font-weight: 600; margin-left: 4px;
min-width: 16px; text-align: center;
}
.change-count.hidden { display: none; }
.save-flash { animation: flashGreen 0.6s ease; }
@keyframes flashGreen {
0% { box-shadow: 0 0 0 0 rgba(76,175,80,0.6); }
50% { box-shadow: 0 0 0 6px rgba(76,175,80,0); }
100% { box-shadow: none; }
}
</style>
</head>
<body>
<div id="loadingOverlay" class="loading-overlay">Loading...</div>
<div id="imageModal" class="image-modal-overlay hidden" onclick="closeImageModal(event)">
<div class="image-modal">
<img id="imageModalImg" src="" alt="Preview">
<button class="image-modal-close" onclick="closeImageModal()">Close</button>
</div>
</div>
<div class="sticky-top">
<div class="header">
<div class="header-left">
<div class="header-brand">H&M</div>
<select class="file-selector" id="fileSelector" onchange="onFileChange()"></select>
<div class="header-campaign" id="campaignInfo"></div>
</div>
<div class="header-right">
<div class="header-meta" id="headerMeta"></div>
<button class="btn btn-export" id="exportBtn" onclick="exportChanges()" disabled>
Export Changes <span class="change-count hidden" id="changeBadge">0</span>
</button>
<a href="/logout" class="btn btn-logout" style="text-decoration: none;">Logout</a>
</div>
</div>
<div class="toolbar">
<div class="stats">
<span id="productCount">0</span> products &middot;
<span id="langCount">0</span> languages &middot;
<strong><span id="editCount">0</span> edits</strong> &middot;
<span class="approved-count"><span id="approvedCount">0</span> approved</span>
</div>
</div>
</div>
<div class="table-container">
<table class="product-table" id="productTable">
<thead id="tableHead"></thead>
<tbody id="tableBody"></tbody>
</table>
</div>
<script>
// ========== State ==========
var RAW_DATA = [];
var IMAGE_MAP = {};
var LANG_DISPLAY = {};
var currentFilename = "";
var MAX_LANG_COLS = 4;
var dataByArticle = {};
var articleOrder = [];
var langColumns = [];
var changes = {};
var approvals = {};
var ALL_LANGS = [];
function langOptionsHtml(selectedLang) {
var h = '<option value="">-- Select Language --</option>';
ALL_LANGS.forEach(function(l) {
var sel = (l === selectedLang) ? " selected" : "";
h += '<option value="' + l + '"' + sel + '>' + LANG_DISPLAY[l] + '</option>';
});
return h;
}
function filledCount() {
var n = 0;
langColumns.forEach(function(l) { if (l !== "") n++; });
return n;
}
function ensureTrailingEmpty() {
while (langColumns.length > 1 && langColumns[langColumns.length - 1] === "" && langColumns[langColumns.length - 2] === "") {
langColumns.pop();
}
if (langColumns.length < MAX_LANG_COLS && (langColumns.length === 0 || langColumns[langColumns.length - 1] !== "")) {
langColumns.push("");
}
}
// ========== Init ==========
async function init() {
try {
var res = await fetch("/hm-ems-report/api/files");
if (res.status === 401) {
window.location.href = "/login";
return;
}
if (!res.ok) {
throw new Error("Failed to load files");
}
var files = await res.json();
if (!Array.isArray(files)) {
throw new Error("Invalid response format");
}
var sel = document.getElementById("fileSelector");
sel.innerHTML = "";
files.forEach(function(f) {
var opt = document.createElement("option");
opt.value = f.filename;
opt.textContent = f.campaign + " (" + f.filename + ")";
sel.appendChild(opt);
});
if (files.length > 0) {
await loadCampaign(files[0].filename);
}
} catch (err) {
console.error("Init error:", err);
document.getElementById("loadingOverlay").textContent = "Error loading data: " + err.message;
}
}
async function loadCampaign(filename) {
showLoading(true);
try {
var res = await fetch("/hm-ems-report/api/load/" + encodeURIComponent(filename));
if (res.status === 401) {
window.location.href = "/login";
return;
}
var payload = await res.json();
RAW_DATA = payload.data;
IMAGE_MAP = payload.image_map;
LANG_DISPLAY = payload.lang_display;
currentFilename = payload.filename;
approvals = payload.approvals || {};
ALL_LANGS = Object.keys(LANG_DISPLAY).filter(function(l) { return l !== "en-gb"; }).sort();
// Update header
document.getElementById("campaignInfo").innerHTML =
"Campaign " + payload.campaign + '<span>Season ' + payload.season + '</span>';
document.getElementById("headerMeta").innerHTML =
"Source: " + payload.filename + "<br>Loaded: " + new Date().toLocaleString();
// Reset state
dataByArticle = {};
articleOrder = [];
langColumns = [""];
changes = {};
RAW_DATA.forEach(function(rec) {
var artId = rec["Article id"];
var lang = (rec["Language"] || "").toLowerCase();
if (!dataByArticle[artId]) {
dataByArticle[artId] = {};
articleOrder.push(artId);
}
dataByArticle[artId][lang] = rec;
});
document.getElementById("productCount").textContent = articleOrder.length;
document.getElementById("langCount").textContent = Object.keys(LANG_DISPLAY).length;
rebuildAll();
updateEditCount();
} catch (err) {
console.error("Load error:", err);
alert("Failed to load campaign: " + err.message);
}
showLoading(false);
}
function showLoading(show) {
document.getElementById("loadingOverlay").classList.toggle("hidden", !show);
}
async function onFileChange() {
var filename = document.getElementById("fileSelector").value;
if (filename) await loadCampaign(filename);
}
// ========== Full rebuild ==========
function rebuildAll() {
buildHead();
buildBody();
populateAllColumns();
updateApprovedCount();
}
function buildHead() {
var thead = document.getElementById("tableHead");
var r1 = '<tr>';
r1 += '<th class="col-visual" rowspan="2">Visual</th>';
r1 += '<th class="col-article" rowspan="2">Article ID</th>';
r1 += '<th class="col-gb-name" rowspan="2">Product Name (English)</th>';
langColumns.forEach(function(lang, idx) {
var isEmpty = (lang === "");
var cls = "lang-group-header" + (isEmpty ? " empty-col" : "");
var showRemove = !isEmpty && filledCount() > 1;
r1 += '<th class="' + cls + '" colspan="3">';
r1 += '<div class="lang-header-controls">';
r1 += '<select onchange="onLangColChange(' + idx + ', this.value)">' + langOptionsHtml(lang) + '</select>';
if (showRemove) {
r1 += '<button class="lang-remove-btn" onclick="removeLanguageColumn(' + idx + ')" title="Remove">&minus;</button>';
}
r1 += '</div></th>';
});
r1 += '</tr>';
var r2 = '<tr>';
langColumns.forEach(function(lang) {
var isEmpty = (lang === "");
r2 += '<th class="lang-sub-header lang-sub-header-first">' + (isEmpty ? "" : "Product Name") + '</th>';
r2 += '<th class="lang-sub-header">' + (isEmpty ? "" : "Price") + '</th>';
r2 += '<th class="lang-sub-header col-approve">' + (isEmpty ? "" : "OK") + '</th>';
});
r2 += '</tr>';
thead.innerHTML = r1 + r2;
}
function buildBody() {
var tbody = document.getElementById("tableBody");
var html = "";
articleOrder.forEach(function(artId) {
var gbRec = dataByArticle[artId]["en-gb"];
var gbName = gbRec ? (gbRec["Product name"] || "") : "";
var imgPaths = IMAGE_MAP[artId];
var imgHtml;
if (imgPaths && imgPaths.length > 0) {
imgHtml = '<div class="image-group">';
imgPaths.forEach(function(p) {
imgHtml += '<img class="product-image" src="' + p + '" alt="' + artId + '" loading="lazy" onclick="openImageModal(this.src)">';
});
imgHtml += '</div>';
} else {
imgHtml = '<div class="image-placeholder">No Image</div>';
}
html += '<tr data-article="' + artId + '" id="row_' + artId + '">';
html += '<td class="col-visual">' + imgHtml + '</td>';
html += '<td class="col-article">' + artId + '</td>';
html += '<td class="col-gb-name">' + escapeHtml(gbName) + '</td>';
langColumns.forEach(function(lang, idx) {
var cid = idx + '_' + artId;
if (lang === "") {
html += '<td class="lang-group-first empty-cell"></td>';
html += '<td class="empty-cell"></td>';
html += '<td class="col-approve empty-cell"></td>';
} else {
html += '<td class="lang-group-first"><input type="text" class="edit-input" id="name_' + cid + '" data-article="' + artId + '" data-field="Product name" data-colidx="' + idx + '" onchange="onEdit(this)" oninput="onInputChange(this)"></td>';
html += '<td><input type="text" class="edit-input" id="price_' + cid + '" data-article="' + artId + '" data-field="Price" data-colidx="' + idx + '" onchange="onEdit(this)" oninput="onInputChange(this)"></td>';
html += '<td class="col-approve"><button class="approve-btn" id="approve_' + cid + '" onclick="toggleApprove(\x27' + artId + '\x27,' + idx + ')" title="Approve">&#10003;</button></td>';
}
});
html += '</tr>';
});
tbody.innerHTML = html;
}
// ========== Populate ==========
function populateAllColumns() {
langColumns.forEach(function(lang, idx) {
if (lang !== "") populateColumn(idx);
});
}
function populateColumn(idx) {
var lang = langColumns[idx];
if (!lang) return;
articleOrder.forEach(function(artId) {
var rec = dataByArticle[artId][lang];
var gbRec = dataByArticle[artId]["en-gb"];
var cid = idx + '_' + artId;
var nameInput = document.getElementById("name_" + cid);
var priceInput = document.getElementById("price_" + cid);
var approveBtn = document.getElementById("approve_" + cid);
if (!nameInput || !priceInput) return;
var targetName = rec ? (rec["Product name"] || "") : "";
var targetPrice = rec ? (rec["Price"] || "") : "";
var gbName = gbRec ? (gbRec["Product name"] || "") : "";
var nameChangeKey = artId + "::" + lang + "::Product name";
var priceChangeKey = artId + "::" + lang + "::Price";
var approveKey = artId + "::" + lang;
var isApproved = !!approvals[approveKey];
nameInput.value = changes[nameChangeKey] ? changes[nameChangeKey]["New value"] : targetName;
priceInput.value = changes[priceChangeKey] ? changes[priceChangeKey]["New value"] : targetPrice;
nameInput.classList.toggle("changed", !!changes[nameChangeKey]);
priceInput.classList.toggle("changed", !!changes[priceChangeKey]);
nameInput.setAttribute("data-original", targetName);
priceInput.setAttribute("data-original", targetPrice);
nameInput.setAttribute("data-lang", lang);
priceInput.setAttribute("data-lang", lang);
var differs = targetName !== "" && targetName.toUpperCase() !== gbName.toUpperCase();
nameInput.classList.toggle("differs-from-gb", differs);
nameInput.disabled = isApproved;
priceInput.disabled = isApproved;
if (approveBtn) approveBtn.classList.toggle("approved", isApproved);
});
}
// ========== Language columns ==========
function onLangColChange(idx, newLang) {
langColumns[idx] = newLang;
ensureTrailingEmpty();
rebuildAll();
}
function removeLanguageColumn(idx) {
langColumns.splice(idx, 1);
if (langColumns.length === 0) langColumns.push("");
ensureTrailingEmpty();
rebuildAll();
}
// ========== Editing ==========
function onInputChange(el) {
var original = el.getAttribute("data-original");
el.classList.toggle("changed", el.value !== original);
}
function onEdit(el) {
var artId = el.getAttribute("data-article");
var field = el.getAttribute("data-field");
var lang = el.getAttribute("data-lang");
var original = el.getAttribute("data-original");
var newVal = el.value;
var changeKey = artId + "::" + lang + "::" + field;
if (newVal !== original) {
changes[changeKey] = {
"Article id": artId,
"Language": lang,
"Field": field,
"Original value": original,
"New value": newVal,
"Country": getCountryForArticleLang(artId, lang),
"Product id": getProductIdForArticle(artId),
};
el.classList.add("changed");
} else {
delete changes[changeKey];
el.classList.remove("changed");
}
updateEditCount();
}
// ========== Approve with server save ==========
async function toggleApprove(artId, colIdx) {
var lang = langColumns[colIdx];
if (!lang) return;
var approveKey = artId + "::" + lang;
var cid = colIdx + '_' + artId;
var btn = document.getElementById("approve_" + cid);
var nameInput = document.getElementById("name_" + cid);
var priceInput = document.getElementById("price_" + cid);
if (approvals[approveKey]) {
// Un-approve
btn.classList.add("saving");
try {
var res = await fetch("/hm-ems-report/api/approve", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
filename: currentFilename,
article_id: artId,
language: lang,
action: "unapprove",
edits: {}
})
});
if (!res.ok) throw new Error("Server error");
delete approvals[approveKey];
btn.classList.remove("approved");
if (nameInput) nameInput.disabled = false;
if (priceInput) priceInput.disabled = false;
} catch (err) {
alert("Failed to save: " + err.message);
}
btn.classList.remove("saving");
} else {
// Approve — collect any pending edits for this article+lang
var edits = {};
var nameChangeKey = artId + "::" + lang + "::Product name";
var priceChangeKey = artId + "::" + lang + "::Price";
if (nameInput) edits["Product name"] = nameInput.value;
if (priceInput) edits["Price"] = priceInput.value;
btn.classList.add("saving");
try {
var res = await fetch("/hm-ems-report/api/approve", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
filename: currentFilename,
article_id: artId,
language: lang,
action: "approve",
edits: edits
})
});
if (!res.ok) throw new Error("Server error");
approvals[approveKey] = true;
btn.classList.add("approved");
if (nameInput) nameInput.disabled = true;
if (priceInput) priceInput.disabled = true;
// Update the local data so the "original" reflects saved state
var rec = dataByArticle[artId] && dataByArticle[artId][lang];
if (rec) {
if (edits["Product name"] != null) rec["Product name"] = edits["Product name"];
if (edits["Price"] != null) rec["Price"] = edits["Price"];
}
if (nameInput) {
nameInput.setAttribute("data-original", nameInput.value);
nameInput.classList.remove("changed");
}
if (priceInput) {
priceInput.setAttribute("data-original", priceInput.value);
priceInput.classList.remove("changed");
}
// Clear changes for this article+lang since they're now saved
delete changes[nameChangeKey];
delete changes[priceChangeKey];
updateEditCount();
// Visual flash
btn.classList.add("save-flash");
setTimeout(function() { btn.classList.remove("save-flash"); }, 600);
} catch (err) {
alert("Failed to save: " + err.message);
}
btn.classList.remove("saving");
}
updateApprovedCount();
}
// ========== Helpers ==========
function getCountryForArticleLang(artId, lang) {
var rec = dataByArticle[artId] && dataByArticle[artId][lang];
return rec ? (rec["Country"] || "") : "";
}
function getProductIdForArticle(artId) {
var rec = dataByArticle[artId] && dataByArticle[artId]["en-gb"];
return rec ? (rec["Product id"] || "") : "";
}
function updateEditCount() {
var count = Object.keys(changes).length;
document.getElementById("editCount").textContent = count;
var badge = document.getElementById("changeBadge");
var btn = document.getElementById("exportBtn");
if (count > 0) {
badge.textContent = count;
badge.classList.remove("hidden");
btn.disabled = false;
} else {
badge.classList.add("hidden");
btn.disabled = true;
}
}
function updateApprovedCount() {
document.getElementById("approvedCount").textContent = Object.keys(approvals).length;
}
function exportChanges() {
var changeList = Object.values(changes);
if (changeList.length === 0) return;
changeList.forEach(function(c) {
c["Approved"] = !!approvals[c["Article id"] + "::" + c["Language"]];
});
var exportData = {
"export_date": new Date().toISOString(),
"source_file": currentFilename,
"total_changes": changeList.length,
"changes": changeList
};
var blob = new Blob([JSON.stringify(exportData, null, 2)], {type: "application/json"});
var url = URL.createObjectURL(blob);
var a = document.createElement("a");
a.href = url;
a.download = "ems_changes_" + new Date().toISOString().slice(0,19).replace(/[:T]/g,"_") + ".json";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function openImageModal(src) {
document.getElementById("imageModalImg").src = src;
document.getElementById("imageModal").classList.remove("hidden");
}
function closeImageModal(event) {
if (event && event.target !== document.getElementById("imageModal") && event.type === "click") return;
document.getElementById("imageModal").classList.add("hidden");
}
function escapeHtml(str) {
if (!str) return "";
return str.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
}
document.addEventListener("DOMContentLoaded", init);
</script>
</body>
</html>