Files
product-import-ai/public/app.js
T

1248 lines
50 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.
// @ts-nocheck
const fieldGrid = document.querySelector("#fieldGrid");
const variantHeader = document.querySelector("#variantHeader");
const manufacturerSelect = document.querySelector("#manufacturerSelect");
const manufacturerTrigger = document.querySelector("#manufacturerTrigger");
const manufacturerMenu = document.querySelector("#manufacturerMenu");
const manufacturerSearch = document.querySelector("#manufacturerSearch");
const manufacturerOptions = document.querySelector("#manufacturerOptions");
const manufacturerValue = document.querySelector("#manufacturerValue");
const languageSelect = document.querySelector("#languageSelect");
const languageValue = document.querySelector("#languageValue");
const recordIdElement = document.querySelector("#recordId");
const loadProductButton = document.querySelector("#loadProductButton");
const previousProductButton = document.querySelector("#previousProductButton");
const nextProductButton = document.querySelector("#nextProductButton");
const tableBody = document.querySelector("#variantRows");
const variantTable = document.querySelector(".variant-grid");
const pictureSlot = document.querySelector("#pictureSlot");
const scanInput = document.querySelector("#scanInput");
const suggestedModal = document.querySelector("#suggestedModal");
const suggestedTitle = document.querySelector("#suggestedTitle");
const suggestedList = document.querySelector("#suggestedList");
const suggestedCloseButton = document.querySelector("#suggestedCloseButton");
const mappingState = document.querySelector("#mappingState");
const mappingStatusText = document.querySelector("#mappingStatusText");
const mappingList = document.querySelector("#mappingList");
const mappingDetails = document.querySelector("#mappingDetails");
const findSourcesButton = document.querySelector("#findSourcesButton");
const getPicturesButton = document.querySelector("#getPicturesButton");
const sidebarDrawer = document.querySelector("#sidebarDrawer");
const sidebarOpenButton = document.querySelector("#sidebarOpenButton");
const sidebarCloseButton = document.querySelector("#sidebarCloseButton");
const sidebarOverlay = document.querySelector("#sidebarOverlay");
const sourceModal = document.querySelector("#sourceModal");
const sourceTitle = document.querySelector("#sourceTitle");
const sourceList = document.querySelector("#sourceList");
const sourceCloseButton = document.querySelector("#sourceCloseButton");
const settingsButton = document.querySelector("#settingsButton");
const themeToggle = document.querySelector("#themeToggle");
const settingsModal = document.querySelector("#settingsModal");
const settingsCloseButton = document.querySelector("#settingsCloseButton");
const databaseModeNote = document.querySelector("#databaseModeNote");
const databaseModeInputs = document.querySelectorAll('input[name="databaseMode"]');
const permissionInputs = document.querySelectorAll("#allowInsertToggle, #allowUpdateToggle, #allowDeleteToggle");
const transactionModeInputs = document.querySelectorAll('input[name="transactionMode"]');
const databaseStatus = document.querySelector("#databaseStatus");
const localDbFields = document.querySelector("#localDbFields");
const localDbInputs = document.querySelectorAll("#localDbFields input");
const testLocalDbButton = document.querySelector("#testLocalDbButton");
const localDbTestResult = document.querySelector("#localDbTestResult");
const lightContrastSelect = document.querySelector("#lightContrastSelect");
const darkContrastSelect = document.querySelector("#darkContrastSelect");
const catalogScreen = document.querySelector(".catalog-screen");
const secondaryPanelToggle = document.querySelector("#secondaryPanelToggle");
const mainWorkspaceToggle = document.querySelector("#mainWorkspaceToggle");
let sidebarUserClosed = false;
const nativeFetch = window.fetch.bind(window);
window.fetch = (input, init = {}) => {
const url = typeof input === "string" ? input : input.url;
if (!url.startsWith("/api/"))
return nativeFetch(input, init);
const headers = new Headers(init.headers || (typeof input !== "string" ? input.headers : undefined));
const selectedMode = localStorage.getItem("catalog-maker:database-mode") || "local";
// Scrape storage is separate; existing catalog endpoints continue reading Local 9bplus.
headers.set("X-Database-Mode", selectedMode === "live" ? "live" : "local");
headers.set("X-Scrape-Database", selectedMode === "scrape" ? "1" : "0");
return nativeFetch(input, { ...init, headers });
};
let currentPosition = 1;
let totalPositions = 0;
let currentProduct = null;
let currentCombinations = [];
let loadRequestId = 0;
let applicationMode = "dry-run";
let databaseConnectionState = "unknown";
const localDraftKey = "catalog-maker:current-product-draft:v2";
let localDraftProductKey = "";
const fieldNames = [
"id_manufacturer",
"code_manufacture",
"voc_discount",
"max_moc_discount",
"supplier_reference",
"catalog_valid_until",
"name",
"price - moc",
"combinations",
"id_attribute_group",
"id_supplier",
"id_category_default",
"9b_category_name",
"manufacturer_name",
"catalog category",
"gender",
"position/total",
"Photos loaded",
"Reference separator",
];
const variantColumns = [
{ key: "status", label: "status", width: 92 },
{ key: "color", label: "color", width: 156 },
{ key: "combi", label: "combi", width: 60, align: "center" },
{ key: "refer", label: "refer", width: 64 },
{ key: "ean13", label: "ean13", width: 148, align: "center", problem: true },
{ key: "nineB", label: "9b", width: 58, align: "center" },
{ key: "supplierStock", label: "sup", width: 58, align: "center" },
{ key: "id", label: "id", width: 74, align: "center" },
{ key: "voc", label: "voc", width: 74, align: "center", amount: true },
{ key: "moc", label: "moc", width: 74, align: "center", amount: true },
{ key: "productAttribute", label: "pr.at", width: 70, align: "center" },
{ key: "catalogColor", label: "color", width: 110 },
{ key: "catalogCombi", label: "combi", width: 70, align: "center" },
{ key: "nineBVoc", label: "9b.voc", width: 74, align: "center", amount: true },
{ key: "nineBMoc", label: "9b.moc", width: 74, align: "center", amount: true },
{ key: "eanStatus", label: "", width: 84, align: "center" },
];
const productFieldMap = {
id_manufacturer: "idManufacturer",
code_manufacture: "codeManufacture",
voc_discount: "vocDiscount",
max_moc_discount: "maxMocDiscount",
supplier_reference: "supplierReference",
catalog_valid_until: "catalogValidUntil",
name: "name",
"price - moc": "priceMoc",
combinations: "combinations",
id_attribute_group: "idAttributeGroup",
id_supplier: "idSupplier",
id_category_default: "idCategoryDefault",
"9b_category_name": "category9bName",
manufacturer_name: "manufacturerName",
"catalog category": "catalogCategory",
gender: "gender",
"position/total": "positionTotal",
"Photos loaded": "photosLoaded",
"Reference separator": "referenceSeparator",
};
await loadStatus();
loadTheme();
loadAppearance();
renderFields();
renderTableHeader();
setTableWidth();
await loadManufacturers();
await loadLanguages();
languageSelect.addEventListener("change", syncLanguagePickerLabel);
manufacturerSelect.addEventListener("change", () => {
syncManufacturerPickerLabel();
closeManufacturerPicker();
if (manufacturerSelect.value) {
loadMappingSources();
loadProductForSelectedManufacturer(1);
}
else {
renderMappingSources({ mapped: false, items: [], message: "Select manufacturer." });
clearProductState("Select manufacturer to load data.");
}
});
manufacturerTrigger.addEventListener("click", toggleManufacturerPicker);
manufacturerSearch.addEventListener("input", renderManufacturerOptions);
manufacturerSearch.addEventListener("keydown", (event) => {
if (event.key === "Escape")
closeManufacturerPicker();
});
document.addEventListener("click", (event) => {
if (!event.target.closest("#manufacturerPicker"))
closeManufacturerPicker();
});
loadProductButton.disabled = true;
loadProductButton.title = "Load existing product attribute info.";
loadProductButton.addEventListener("click", loadProductInfoForCurrentProduct);
findSourcesButton.addEventListener("click", findSourcesForCurrentProduct);
getPicturesButton.addEventListener("click", getPicturesForCurrentProduct);
secondaryPanelToggle.addEventListener("click", toggleSecondaryPanel);
mainWorkspaceToggle.addEventListener("click", toggleMainWorkspace);
sidebarOpenButton.addEventListener("click", openSidebar);
sidebarCloseButton.addEventListener("click", closeSidebar);
sidebarOverlay.addEventListener("click", closeSidebar);
document.addEventListener("keydown", (event) => {
if (event.key === "Escape")
closeSidebar();
});
previousProductButton.addEventListener("click", () => {
if (!manufacturerSelect.value || currentPosition <= 1)
return;
loadProductForSelectedManufacturer(currentPosition - 1);
});
nextProductButton.addEventListener("click", () => {
if (!manufacturerSelect.value || (totalPositions && currentPosition >= totalPositions))
return;
loadProductForSelectedManufacturer(currentPosition + 1);
});
scanInput.addEventListener("keydown", (event) => {
if (event.key !== "Enter")
return;
event.preventDefault();
loadProductByScannedEan();
});
suggestedCloseButton.addEventListener("click", closeSuggestedProducts);
suggestedModal.addEventListener("click", (event) => {
if (event.target === suggestedModal)
closeSuggestedProducts();
});
sourceCloseButton.addEventListener("click", closeSourceCandidates);
sourceModal.addEventListener("click", (event) => {
if (event.target === sourceModal)
closeSourceCandidates();
});
settingsButton.addEventListener("click", openSettings);
themeToggle.addEventListener("click", toggleTheme);
settingsCloseButton.addEventListener("click", closeSettings);
settingsModal.addEventListener("click", (event) => {
if (event.target === settingsModal)
closeSettings();
});
for (const input of databaseModeInputs)
input.addEventListener("change", saveDatabaseMode);
for (const input of permissionInputs)
input.addEventListener("change", saveSettings);
for (const input of transactionModeInputs)
input.addEventListener("change", saveSettings);
for (const input of localDbInputs)
input.addEventListener("change", saveLocalDbSettings);
testLocalDbButton.addEventListener("click", testLocalDbConnection);
lightContrastSelect.addEventListener("change", saveAppearance);
darkContrastSelect.addEventListener("change", saveAppearance);
loadDatabaseMode();
syncSidebarForViewport();
setTimeout(() => {
if (manufacturerSelect.value) {
loadMappingSources();
loadProductForSelectedManufacturer(1);
}
else {
renderMappingSources({ mapped: false, items: [], message: "Select manufacturer." });
updateNavigationState();
}
}, 0);
async function loadStatus() {
const response = await fetch("/api/status");
const status = await response.json();
applicationMode = status.mode || "dry-run";
if (status.database) {
databaseStatus.dataset.driver = status.database.driver;
const selectedMode = localStorage.getItem("catalog-maker:database-mode") || "local";
databaseConnectionState = selectedMode === "local" && status.database.driver !== "mariadb"
? "error"
: "ready";
}
}
function renderFields() {
fieldGrid.replaceChildren(...fieldNames.flatMap((name) => {
const label = document.createElement("div");
label.className = "field-label";
label.textContent = name;
const value = document.createElement("div");
value.className = "field-value";
value.dataset.field = name;
value.textContent = "";
return [label, value];
}));
}
function renderTableHeader() {
variantHeader.replaceChildren(...variantColumns.map((column) => {
const cell = document.createElement("div");
cell.className = "variant-header-cell";
cell.setAttribute("role", "columnheader");
cell.textContent = column.label;
cell.dataset.column = column.key;
cell.style.textAlign = column.align || "";
return cell;
}));
}
function setTableWidth() {
const totalWidth = variantColumns.reduce((sum, column) => sum + column.width, 0);
variantTable.style.setProperty("--variant-min-width", `${Math.max(totalWidth, 820)}px`);
variantTable.style.setProperty("--variant-columns", variantColumns.map((column) => `${column.width}px`).join(" "));
}
async function loadManufacturers() {
manufacturerSelect.replaceChildren(createOption("", "Select manufacturer"));
try {
const response = await fetch("/api/manufacturers");
const data = await response.json();
if (!response.ok)
throw new Error(data.message || "Local DB is not configured.");
for (const manufacturer of data.items || []) {
manufacturerSelect.append(createOption(String(manufacturer.id), manufacturer.name));
}
if (data.message)
manufacturerSelect.append(createOption("", data.message));
syncManufacturerPickerLabel();
renderManufacturerOptions();
}
catch (error) {
manufacturerSelect.append(createOption("", error.message || "Local DB is not configured."));
syncManufacturerPickerLabel();
renderManufacturerOptions();
}
}
function toggleManufacturerPicker() {
if (manufacturerMenu.hidden) {
manufacturerMenu.hidden = false;
manufacturerTrigger.setAttribute("aria-expanded", "true");
manufacturerSearch.value = "";
renderManufacturerOptions();
if (manufacturerSelect.options.length > 11)
manufacturerSearch.focus();
}
else {
closeManufacturerPicker();
}
}
function closeManufacturerPicker() {
manufacturerMenu.hidden = true;
manufacturerTrigger.setAttribute("aria-expanded", "false");
}
function syncManufacturerPickerLabel() {
const option = manufacturerSelect.selectedOptions[0];
manufacturerValue.textContent = option?.textContent || "Select manufacturer";
}
function renderManufacturerOptions() {
const options = [...manufacturerSelect.options].filter((option) => option.value);
const query = manufacturerSearch.value.trim().toLocaleLowerCase();
const filtered = options.filter((option) => option.textContent.toLocaleLowerCase().includes(query));
const visible = filtered.slice(0, 10);
manufacturerOptions.replaceChildren(...visible.map((option) => {
const item = document.createElement("button");
item.className = "manufacturer-option";
item.type = "button";
item.setAttribute("role", "option");
item.textContent = option.textContent;
item.setAttribute("aria-selected", String(option.value === manufacturerSelect.value));
item.addEventListener("click", () => {
manufacturerSelect.value = option.value;
manufacturerSelect.dispatchEvent(new Event("change", { bubbles: true }));
});
return item;
}));
if (!visible.length) {
const empty = document.createElement("div");
empty.className = "manufacturer-empty";
empty.textContent = "No manufacturer found.";
manufacturerOptions.append(empty);
}
}
function createOption(value, label) {
const option = document.createElement("option");
option.value = value;
option.textContent = label;
return option;
}
function setButtonLabel(button, label) {
const labelElement = button.querySelector(":scope > span");
if (labelElement) {
labelElement.textContent = label;
}
else {
button.textContent = label;
}
}
async function loadLanguages() {
languageSelect.replaceChildren(createOption("", "Select language"));
try {
const response = await fetch("/api/languages");
const data = await response.json();
if (!response.ok)
throw new Error(data.message || "Local DB is not configured.");
for (const language of data.items || []) {
const label = language.isoCode ? `${language.name} (${language.isoCode})` : language.name;
languageSelect.append(createOption(String(language.id), label));
}
if (data.message)
languageSelect.append(createOption("", data.message));
}
catch (error) {
languageSelect.append(createOption("", error.message || "Local DB is not configured."));
}
syncLanguagePickerLabel();
}
function syncLanguagePickerLabel() {
const option = languageSelect.selectedOptions[0];
languageValue.textContent = option?.textContent || "Select language";
}
async function loadMappingSources() {
const manufacturerId = manufacturerSelect.value;
if (!manufacturerId) {
renderMappingSources({ mapped: false, items: [], message: "Select manufacturer." });
return;
}
setMappingStatus("Loading mapping...", "is-loading");
mappingList.replaceChildren();
try {
const params = new URLSearchParams({ manufacturerId, check: "1" });
const response = await fetch(`/api/catalog-maker/mapping-sources?${params.toString()}`);
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || "Mapping load failed.");
}
renderMappingSources(data);
}
catch (error) {
renderMappingSources({
mapped: false,
items: [],
message: error.message || "Mapping load failed.",
});
}
}
function renderMappingSources(data) {
const items = data.items || [];
if (!data.mapped || !items.length) {
setMappingStatus(data.message || "Mapping missing", "is-missing");
mappingList.replaceChildren();
mappingDetails.open = true;
return;
}
const enabledCount = items.filter((item) => item.enabled).length;
const failedCount = items.filter((item) => item.enabled && item.health && item.health !== "ok").length;
const testedCount = items.filter((item) => item.enabled && item.health === "ok").length;
const ready = enabledCount > 0 && failedCount === 0;
const statusText = ready
? `Sources OK ✓ (${testedCount}/${enabledCount})`
: data.message || `Sources problem (${testedCount}/${enabledCount})`;
setMappingStatus(statusText, ready ? "is-ready" : testedCount > 0 ? "is-warning" : "is-missing");
mappingDetails.open = !ready || failedCount > 0;
mappingList.replaceChildren(...items.map((item) => {
const row = document.createElement("div");
row.className = `mapping-source-row${item.enabled ? "" : " is-disabled"}`;
const name = document.createElement("span");
name.className = "mapping-source-name";
name.textContent = formatValue(item.name || item.key || item.url || "Source");
const status = document.createElement("span");
status.className = `mapping-source-status ${getMappingHealthClass(item)}`;
status.textContent = getMappingHealthText(item);
row.append(name, status);
return row;
}));
}
function setMappingStatus(text, stateClass) {
mappingState.textContent = "";
mappingState.className = `mapping-state-dot ${stateClass}`;
mappingState.setAttribute("aria-label", text);
mappingStatusText.textContent = text;
}
function getMappingHealthText(item) {
if (!item.enabled)
return "off";
if (item.health === "ok")
return `${item.httpStatus || 200} OK`;
if (item.health === "missing-url")
return "no URL";
if (item.httpStatus)
return `${item.httpStatus}`;
if (item.health)
return "error";
return "netest";
}
function getMappingHealthClass(item) {
if (!item.enabled)
return "is-muted";
if (item.health === "ok")
return "is-ok";
return "is-bad";
}
async function loadProductForSelectedManufacturer(position = currentPosition) {
const requestId = ++loadRequestId;
const manufacturerId = manufacturerSelect.value;
if (!manufacturerId) {
clearProductState("Select manufacturer to load data.");
return;
}
setNavigationEnabled(false);
setTableMessage("Loading product data...");
try {
const response = await fetch(`/api/catalog-maker/product?manufacturerId=${encodeURIComponent(manufacturerId)}&position=${encodeURIComponent(position)}`);
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || "Product load failed.");
}
if (!data.product) {
if (requestId !== loadRequestId)
return;
clearProductState(data.message || "No product to do for selected manufacturer.");
return;
}
if (requestId !== loadRequestId)
return;
renderProduct(data.product);
currentCombinations = data.combinations;
renderCombinations(currentCombinations);
}
catch (error) {
setTableMessage(error.message);
}
finally {
updateNavigationState();
}
}
async function loadProductByScannedEan() {
const requestId = ++loadRequestId;
const manufacturerId = manufacturerSelect.value;
const ean = normalizeScannedEan(scanInput.value);
if (!manufacturerId) {
setTableMessage("Select manufacturer before scanning EAN.");
return;
}
if (!ean) {
setTableMessage("Scan EAN first.");
return;
}
setNavigationEnabled(false);
scanInput.disabled = true;
setTableMessage(`Searching EAN ${ean}...`);
try {
const params = new URLSearchParams({ manufacturerId, ean });
const response = await fetch(`/api/catalog-maker/product-by-ean?${params.toString()}`);
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || "EAN search failed.");
}
if (!data.product) {
if (requestId !== loadRequestId)
return;
await showSuggestedProducts({ ean });
return;
}
if (requestId !== loadRequestId)
return;
renderProduct(data.product);
currentCombinations = data.combinations;
renderCombinations(currentCombinations);
scanInput.value = "";
if (!data.product.idProduct) {
await showSuggestedProducts({
ean,
supplierReference: data.product.supplierReference,
productName: data.product.name,
});
}
}
catch (error) {
setTableMessage(error.message);
}
finally {
scanInput.disabled = false;
scanInput.focus();
updateNavigationState();
}
}
async function showSuggestedProducts({ ean, supplierReference = "", productName = "" }) {
const params = new URLSearchParams({
manufacturerId: manufacturerSelect.value,
supplierReference: supplierReference || currentProduct?.supplierReference || scanInput.value || ean,
productName: productName || currentProduct?.name || "",
});
const response = await fetch(`/api/catalog-maker/suggested-products?${params.toString()}`);
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || "Suggested product lookup failed.");
}
suggestedTitle.textContent = `Sugested product for ${ean}`;
renderSuggestedProducts(data.items);
suggestedModal.hidden = false;
}
function renderSuggestedProducts(items) {
if (!items.length) {
const empty = document.createElement("div");
empty.className = "suggested-row";
empty.textContent = "No suggested products found.";
suggestedList.replaceChildren(empty);
return;
}
suggestedList.replaceChildren(...items.map((item) => {
const row = document.createElement("div");
row.className = "suggested-row";
const image = document.createElement("img");
image.alt = item.name || "Product";
if (item.imageUrl)
image.src = item.imageUrl;
const id = document.createElement("div");
id.className = "suggested-id";
id.textContent = formatValue(item.idProduct);
const name = document.createElement("div");
name.className = "suggested-name";
name.textContent = formatValue(item.name);
name.title = name.textContent;
const reference = document.createElement("div");
reference.className = "suggested-reference";
reference.textContent = formatValue(item.supplierReference);
reference.title = reference.textContent;
const active = document.createElement("div");
active.className = "suggested-active";
active.textContent = formatValue(item.active);
const pick = document.createElement("button");
pick.className = "suggested-pick";
pick.type = "button";
pick.textContent = "Use";
pick.addEventListener("click", () => selectSuggestedProduct(item));
row.append(image, id, name, reference, active, pick);
return row;
}));
}
function selectSuggestedProduct(item) {
if (!currentProduct)
return;
currentProduct = {
...currentProduct,
idProduct: item.idProduct,
imageUrl: item.imageUrl || currentProduct.imageUrl,
};
recordIdElement.textContent = formatRecordId(item.idProduct);
renderPicture(currentProduct.imageUrl);
closeSuggestedProducts();
updateNavigationState();
}
function closeSuggestedProducts() {
suggestedModal.hidden = true;
suggestedList.replaceChildren();
}
async function findSourcesForCurrentProduct() {
if (!manufacturerSelect.value || !currentProduct) {
setTableMessage("Select manufacturer and product first.");
return;
}
const savedDraft = readLocalDraft();
if (savedDraft?.sources) {
renderSourceCandidates(savedDraft.sources.items || []);
updateFindSourcesButtonState();
sourceTitle.textContent = "Source candidates (cached)";
sourceModal.hidden = false;
return;
}
const supplierColorGroup = getFirstSupplierColorGroup();
findSourcesButton.disabled = true;
setButtonLabel(findSourcesButton, "Finding sources...");
try {
const params = new URLSearchParams({
manufacturerId: manufacturerSelect.value,
productName: currentProduct.name || "",
supplierReference: currentProduct.supplierReference || "",
ean: supplierColorGroup?.supplierEan || currentCombinations[0]?.ean13 || "",
eanGroup: (supplierColorGroup?.supplierRows || []).map((row) => row.ean13).filter(Boolean).join(","),
color: supplierColorGroup?.color || "",
});
const response = await fetch(`/api/catalog-maker/find-sources?${params.toString()}`);
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || "Source search failed.");
}
saveLocalDraft({ sources: data });
updateFindSourcesButtonState();
renderSourceCandidates(data.items || []);
const targetLabel = supplierColorGroup
? `${supplierColorGroup.color} / EAN ${supplierColorGroup.supplierEan}`
: currentProduct.name || currentProduct.supplierReference;
sourceTitle.textContent = `Source candidates for ${targetLabel}`;
sourceModal.hidden = false;
}
catch (error) {
setTableMessage(error.message || "Source search failed.");
}
finally {
updateFindSourcesButtonState();
}
}
async function getPicturesForCurrentProduct(sourceKey = "") {
if (!manufacturerSelect.value || !currentProduct) {
setTableMessage("Select manufacturer and product first.");
return;
}
const savedDraft = readLocalDraft();
const cachedPictures = savedDraft?.picturesBySource?.[sourceKey || "all"];
if (cachedPictures) {
renderPictureCandidates(cachedPictures.items || []);
sourceTitle.textContent = `Picture candidates (cached${sourceKey ? `: ${sourceKey}` : ""})`;
sourceModal.hidden = false;
return;
}
const supplierColorGroup = getFirstSupplierColorGroup();
getPicturesButton.disabled = true;
setButtonLabel(getPicturesButton, "Loading pictures...");
try {
const params = new URLSearchParams({
manufacturerId: manufacturerSelect.value,
productName: currentProduct.name || "",
supplierReference: currentProduct.supplierReference || "",
ean: supplierColorGroup?.supplierEan || currentCombinations[0]?.ean13 || "",
eanGroup: (supplierColorGroup?.supplierRows || []).map((row) => row.ean13).filter(Boolean).join(","),
color: supplierColorGroup?.color || "",
});
if (sourceKey)
params.set("sourceKey", sourceKey);
const response = await fetch(`/api/catalog-maker/get-pictures?${params.toString()}`);
const data = await response.json();
if (!response.ok)
throw new Error(data.message || "Picture lookup failed.");
const picturesBySource = readLocalDraft()?.picturesBySource || {};
picturesBySource[sourceKey || "all"] = data;
saveLocalDraft({ picturesBySource });
renderPictureCandidates(data.items || []);
sourceTitle.textContent = `Picture candidates${sourceKey ? `: ${sourceKey}` : ""}`;
sourceModal.hidden = false;
}
catch (error) {
setTableMessage(error.message || "Picture lookup failed.");
}
finally {
getPicturesButton.disabled = false;
setButtonLabel(getPicturesButton, "Get pictures");
updateNavigationState();
}
}
function getFirstSupplierColorGroup() {
const groups = new Map();
for (const combination of currentCombinations) {
const color = formatValue(combination.color) || "Unknown color";
const group = groups.get(color) || {
color,
rows: [],
supplierRows: [],
supplierEan: "",
};
group.rows.push(combination);
if (Number(combination.supplierStock) === 1) {
group.supplierRows.push(combination);
if (!group.supplierEan)
group.supplierEan = combination.ean13;
}
groups.set(color, group);
}
return [...groups.values()].find((group) => group.supplierRows.length > 0) || null;
}
function renderSourceCandidates(items) {
if (!items.length) {
const empty = document.createElement("div");
empty.className = "source-row";
empty.textContent = "No source mapping found.";
sourceList.replaceChildren(empty);
return;
}
sourceList.replaceChildren(...items.map((item) => {
const section = document.createElement("section");
section.className = "source-section";
const header = document.createElement("div");
header.className = "source-section-header";
const name = document.createElement("strong");
name.textContent = formatValue(item.source?.name || item.source?.key || "Source");
const status = document.createElement("span");
status.className = `source-status ${item.status === "found" ? "is-ok" : "is-muted"}`;
status.textContent = item.status === "found" ? `${item.candidates.length} found` : item.status;
const actions = document.createElement("div");
actions.className = "source-section-actions";
const pictures = document.createElement("button");
pictures.type = "button";
pictures.className = "source-picture-action";
pictures.textContent = "Get pictures";
pictures.addEventListener("click", () => getPicturesForCurrentProduct(item.source?.key || ""));
actions.append(status, pictures);
header.append(name, actions);
const rows = item.candidates.length
? item.candidates.map(renderSourceCandidateRow)
: [renderManualSearchRow(item.searchUrl, item.error || "Open search")];
section.append(header, ...rows);
return section;
}));
}
function renderPictureCandidates(items) {
const rows = [];
for (const item of items) {
for (const candidate of item.candidates || []) {
for (const picture of candidate.pictures || []) {
const row = document.createElement("a");
row.className = "picture-candidate";
row.href = picture;
row.target = "_blank";
row.rel = "noreferrer";
const image = document.createElement("img");
image.src = picture;
image.alt = candidate.rawTitle || item.source?.name || "Product picture";
image.loading = "lazy";
const label = document.createElement("span");
label.textContent = item.source?.name || item.source?.key || "Source";
row.append(image, label);
rows.push(row);
}
}
}
if (!rows.length) {
const empty = document.createElement("div");
empty.className = "source-row";
empty.textContent = "No pictures found. Open the source manually to inspect it.";
sourceList.replaceChildren(empty);
return;
}
sourceList.replaceChildren(...rows);
}
function collectPictureUrls(items) {
return items.flatMap((item) => (item.candidates || []).flatMap((candidate) => candidate.pictures || [])).filter((url, index, all) => all.indexOf(url) === index);
}
function saveLocalDraft(patch) {
if (!localDraftProductKey)
return;
let draft = {};
try {
draft = JSON.parse(localStorage.getItem(localDraftKey) || "{}");
}
catch {
draft = {};
}
localStorage.setItem(localDraftKey, JSON.stringify({
...draft,
productKey: localDraftProductKey,
...patch,
}));
}
function readLocalDraft() {
if (!localDraftProductKey)
return null;
try {
const draft = JSON.parse(localStorage.getItem(localDraftKey) || "null");
return draft?.productKey === localDraftProductKey ? draft : null;
}
catch {
return null;
}
}
function clearLocalDraft() {
localStorage.removeItem(localDraftKey);
localDraftProductKey = "";
updateFindSourcesButtonState();
}
function updateFindSourcesButtonState() {
const hasSources = Boolean(readLocalDraft()?.sources);
setButtonLabel(findSourcesButton, hasSources ? "Find sources ✓" : "Find sources");
findSourcesButton.disabled = !manufacturerSelect.value || !currentProduct;
}
function renderSourceCandidateRow(candidate) {
const row = document.createElement("a");
row.className = "source-row";
row.href = candidate.url;
row.target = "_blank";
row.rel = "noreferrer";
const title = document.createElement("span");
title.className = "source-row-title";
title.textContent = formatValue(candidate.title || candidate.url);
title.title = formatValue(candidate.rawTitle || candidate.title || candidate.url);
const action = document.createElement("span");
action.className = "source-row-action";
action.textContent = "Open";
row.append(title, action);
return row;
}
function renderManualSearchRow(url, label) {
return renderSourceCandidateRow({
title: label,
url,
});
}
function closeSourceCandidates() {
sourceModal.hidden = true;
sourceList.replaceChildren();
}
function openSettings() {
settingsModal.hidden = false;
}
function syncSidebarForViewport() {
if (window.matchMedia("(min-width: 981px)").matches && !sidebarUserClosed)
openSidebar();
else
closeSidebar();
}
function openSidebar() {
sidebarUserClosed = false;
sidebarDrawer.classList.add("is-open");
sidebarOverlay.hidden = false;
sidebarOpenButton.hidden = true;
sidebarOpenButton.setAttribute("aria-expanded", "true");
sidebarDrawer.setAttribute("aria-hidden", "false");
catalogScreen.classList.remove("sidebar-collapsed");
}
function closeSidebar() {
sidebarUserClosed = true;
sidebarDrawer.classList.remove("is-open");
sidebarOverlay.hidden = true;
sidebarOpenButton.hidden = false;
sidebarOpenButton.setAttribute("aria-expanded", "false");
sidebarDrawer.setAttribute("aria-hidden", "true");
catalogScreen.classList.add("sidebar-collapsed");
}
function toggleSecondaryPanel() {
const isCollapsed = catalogScreen.classList.toggle("secondary-collapsed");
secondaryPanelToggle.setAttribute("aria-expanded", String(!isCollapsed));
secondaryPanelToggle.setAttribute("aria-label", isCollapsed ? "Expand product details" : "Collapse product details");
secondaryPanelToggle.title = isCollapsed ? "Expand product details" : "Collapse product details";
secondaryPanelToggle.textContent = isCollapsed ? "" : "";
}
function toggleMainWorkspace() {
const isCollapsed = catalogScreen.classList.toggle("workspace-collapsed");
mainWorkspaceToggle.setAttribute("aria-expanded", String(!isCollapsed));
mainWorkspaceToggle.setAttribute("aria-label", isCollapsed ? "Expand main workspace" : "Collapse main workspace");
mainWorkspaceToggle.title = isCollapsed ? "Expand main workspace" : "Collapse main workspace";
mainWorkspaceToggle.textContent = isCollapsed ? "" : "";
}
function loadTheme() {
const isDark = localStorage.getItem("catalog-maker:theme") === "dark";
document.documentElement.classList.toggle("dark", isDark);
themeToggle.querySelector("span").textContent = isDark ? "Light theme" : "Dark theme";
themeToggle.setAttribute("aria-pressed", String(isDark));
}
function loadAppearance() {
const settings = JSON.parse(localStorage.getItem("catalog-maker:appearance") || "{}");
lightContrastSelect.value = settings.lightContrast || "medium";
darkContrastSelect.value = settings.darkContrast || "medium";
applyAppearance();
}
function saveAppearance() {
localStorage.setItem("catalog-maker:appearance", JSON.stringify({
lightContrast: lightContrastSelect.value,
darkContrast: darkContrastSelect.value,
}));
applyAppearance();
}
function applyAppearance() {
const root = document.documentElement;
root.classList.remove("contrast-light-low", "contrast-light-medium", "contrast-light-high", "contrast-dark-low", "contrast-dark-medium", "contrast-dark-high");
root.classList.add(`contrast-light-${lightContrastSelect.value || "medium"}`);
root.classList.add(`contrast-dark-${darkContrastSelect.value || "medium"}`);
}
function toggleTheme() {
const isDark = !document.documentElement.classList.contains("dark");
localStorage.setItem("catalog-maker:theme", isDark ? "dark" : "light");
loadTheme();
}
function closeSettings() {
settingsModal.hidden = true;
}
function loadDatabaseMode() {
const mode = localStorage.getItem("catalog-maker:database-mode") || "local";
const input = document.querySelector(`input[name="databaseMode"][value="${mode}"]`);
if (input)
input.checked = true;
const permissions = JSON.parse(localStorage.getItem("catalog-maker:db-permissions") || "{}");
for (const input of permissionInputs)
input.checked = permissions[input.id] === true;
const transactionMode = localStorage.getItem("catalog-maker:transaction-mode") || "transaction";
const transactionInput = document.querySelector(`input[name="transactionMode"][value="${transactionMode}"]`);
if (transactionInput)
transactionInput.checked = true;
const localDbSettings = JSON.parse(localStorage.getItem("catalog-maker:local-db-settings") || "{}");
for (const input of localDbInputs) {
if (Object.prototype.hasOwnProperty.call(localDbSettings, input.id))
input.value = localDbSettings[input.id];
}
syncDatabasePermissions(mode);
syncLocalDbFields(mode);
renderDatabaseModeNote(mode);
refreshDatabaseStatusLabel();
}
function saveDatabaseMode(event) {
const mode = ["local", "live", "scrape"].includes(event.target.value) ? event.target.value : "local";
localStorage.setItem("catalog-maker:database-mode", mode);
syncDatabasePermissions(mode);
syncLocalDbFields(mode);
saveSettings();
renderDatabaseModeNote(mode);
refreshDatabaseStatusLabel();
}
function saveLocalDbSettings() {
const settings = {};
for (const input of localDbInputs)
settings[input.id] = input.value;
localStorage.setItem("catalog-maker:local-db-settings", JSON.stringify(settings));
}
async function testLocalDbConnection() {
saveLocalDbSettings();
testLocalDbButton.disabled = true;
localDbTestResult.textContent = "Testing connection...";
localDbTestResult.className = "settings-note";
const payload = Object.fromEntries([...localDbInputs].map((input) => [
input.id.replace("localDb", "").toLowerCase(),
input.value,
]));
try {
const response = await fetch("/api/database/test", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
});
const data = await response.json();
if (!response.ok || !data.connected)
throw new Error(data.message || "Connection failed.");
localDbTestResult.textContent = `Connected: ${data.version}`;
localDbTestResult.className = "settings-note connection-ok";
}
catch (error) {
localDbTestResult.textContent = `Connection error: ${error.message}`;
localDbTestResult.className = "settings-note connection-error";
}
finally {
testLocalDbButton.disabled = false;
}
}
function syncLocalDbFields(mode) {
localDbFields.hidden = mode !== "local";
}
function syncDatabasePermissions(mode) {
const isReadOnly = mode === "live" || mode === "scrape";
for (const input of permissionInputs) {
input.disabled = isReadOnly;
if (isReadOnly)
input.checked = false;
}
}
function saveSettings() {
const permissions = {};
for (const input of permissionInputs)
permissions[input.id] = input.checked;
localStorage.setItem("catalog-maker:db-permissions", JSON.stringify(permissions));
const transactionMode = [...transactionModeInputs].find((input) => input.checked)?.value || "transaction";
localStorage.setItem("catalog-maker:transaction-mode", transactionMode);
refreshDatabaseStatusLabel();
}
function refreshDatabaseStatusLabel() {
const mode = localStorage.getItem("catalog-maker:database-mode") || "local";
const transactionMode = localStorage.getItem("catalog-maker:transaction-mode") || "transaction";
const permissions = JSON.parse(localStorage.getItem("catalog-maker:db-permissions") || "{}");
const writesEnabled = mode === "local" && (permissions.allowInsertToggle || permissions.allowUpdateToggle || permissions.allowDeleteToggle);
const dbLabel = mode === "live" ? "Live 9bplus DB" : mode === "scrape" ? "Local scrape DB" : "Local 9bplus DB";
const configuredDriver = databaseStatus.dataset.driver;
const connectionError = (mode === "local" && configuredDriver !== "mariadb")
|| (mode === "live" && configuredDriver !== "endpoint");
const connectionLabel = connectionError ? "connection error" : "connected";
const accessLabel = writesEnabled ? "writes enabled" : "read-only";
const executionLabel = applicationMode === "dry-run"
? "dry-run"
: (transactionMode === "dry-run" ? "dry-run" : "transaction");
databaseStatus.textContent = `${dbLabel} / ${connectionLabel} / ${accessLabel} / ${executionLabel}`;
databaseStatus.classList.toggle("database-error", connectionError);
}
function renderDatabaseModeNote(mode) {
databaseModeNote.textContent = mode === "live"
? "Live 9bplus DB selected for read-only data."
: mode === "scrape"
? "Local scrape DB selected for scraper settings and scraped data. Catalog reads stay on Local 9bplus DB."
: "Local 9bplus DB selected.";
}
function renderProduct(product) {
const productKey = `${manufacturerSelect.value}:${product.idProductCatalog || product.supplierReference || product.name || ""}`;
if (localDraftProductKey && localDraftProductKey !== productKey)
clearLocalDraft();
localDraftProductKey = productKey;
currentProduct = product;
recordIdElement.textContent = formatRecordId(product.idProduct);
currentPosition = Number(product.position) || currentPosition;
totalPositions = Number(product.total) || 0;
const enrichedProduct = {
...product,
positionTotal: product.position && product.total ? `${product.position}/${product.total}` : "",
};
for (const [label, key] of Object.entries(productFieldMap)) {
const valueElement = document.querySelector(`[data-field="${CSS.escape(label)}"]`);
if (!valueElement)
continue;
valueElement.textContent = formatValue(enrichedProduct[key]);
}
renderPicture(product.imageUrl);
updateFindSourcesButtonState();
updateNavigationState();
}
function renderCombinations(combinations) {
if (!combinations.length) {
setTableMessage("No combinations found for selected product.");
return;
}
tableBody.replaceChildren(...combinations.map((item) => {
const row = document.createElement("div");
row.className = "variant-row";
row.setAttribute("role", "row");
row.append(...variantColumns.map((column) => {
const value = item[column.key];
const text = formatTableValue(value, column);
const cell = document.createElement("div");
cell.className = "variant-cell";
cell.setAttribute("role", "cell");
cell.textContent = text;
cell.title = text;
cell.dataset.column = column.key;
cell.dataset.label = column.label;
cell.style.textAlign = column.align || "";
if ((column.problem && item.hasProblem) || (column.key === "eanStatus" && text)) {
cell.classList.add("problem-ean");
}
return cell;
}));
return row;
}));
}
function clearProductState(message) {
clearLocalDraft();
recordIdElement.textContent = "";
currentProduct = null;
currentCombinations = [];
currentPosition = 1;
totalPositions = 0;
for (const valueElement of document.querySelectorAll(".field-value")) {
valueElement.textContent = "";
}
renderPicture();
setTableMessage(message);
updateNavigationState();
}
function setTableMessage(message) {
const row = document.createElement("div");
row.className = "variant-row variant-message-row";
row.setAttribute("role", "row");
const cell = document.createElement("div");
cell.className = "empty-row";
cell.setAttribute("role", "cell");
cell.style.gridColumn = "1 / -1";
cell.textContent = message;
row.append(cell);
tableBody.replaceChildren(row);
}
function formatValue(value) {
if (value === null || value === undefined)
return "";
if (typeof value === "number")
return String(value);
const text = String(value).trim();
if (/^\d{4}-\d{2}-\d{2}T/.test(text))
return formatDate(text);
if (/^-?\d+\.0+$/.test(text))
return String(Number.parseInt(text, 10));
if (/^-?\d+\.\d+$/.test(text))
return trimDecimal(text);
return text;
}
function formatTableValue(value, column) {
if (value === null || value === undefined || value === "")
return "";
if (column.amount) {
const amount = Number(value);
if (Number.isFinite(amount))
return String(Math.round(amount));
}
return formatValue(value);
}
function formatRecordId(value) {
const text = formatValue(value);
return text || "";
}
function formatDate(value) {
const date = new Date(value);
if (Number.isNaN(date.getTime()))
return value;
return new Intl.DateTimeFormat("cs-CZ", {
day: "2-digit",
month: "2-digit",
year: "numeric",
timeZone: "UTC",
})
.format(date)
.replaceAll(" ", "");
}
function trimDecimal(value) {
const asNumber = Number(value);
if (!Number.isFinite(asNumber))
return value;
return String(asNumber);
}
function setNavigationEnabled(enabled) {
previousProductButton.disabled = !enabled;
nextProductButton.disabled = !enabled;
}
function updateNavigationState() {
previousProductButton.disabled = !manufacturerSelect.value || currentPosition <= 1;
nextProductButton.disabled =
!manufacturerSelect.value || (totalPositions > 0 && currentPosition >= totalPositions);
loadProductButton.disabled = !currentProduct?.idProduct;
findSourcesButton.disabled = !manufacturerSelect.value || !currentProduct;
getPicturesButton.disabled = !manufacturerSelect.value || !currentProduct;
}
function normalizeScannedEan(value) {
const czechKeyboardDigits = new Map([
["+", "1"],
["ě", "2"],
["š", "3"],
["č", "4"],
["ř", "5"],
["ž", "6"],
["ý", "7"],
["á", "8"],
["í", "9"],
["é", "0"],
]);
return String(value ?? "")
.trim()
.toLowerCase()
.split("")
.map((char) => czechKeyboardDigits.get(char) || char)
.join("")
.replace(/\D/g, "");
}
function renderPicture(imageUrl) {
if (!imageUrl) {
pictureSlot.replaceChildren(Object.assign(document.createElement("span"), { textContent: "No picture" }));
return;
}
const image = document.createElement("img");
image.src = imageUrl;
image.alt = currentProduct?.name || "Product preview";
image.loading = "lazy";
pictureSlot.replaceChildren(image);
}
async function loadProductInfoForCurrentProduct() {
if (!currentProduct?.idProduct || !manufacturerSelect.value || !currentProduct.supplierReference) {
return;
}
loadProductButton.disabled = true;
setButtonLabel(loadProductButton, "Loading info...");
try {
const params = new URLSearchParams({
productId: currentProduct.idProduct,
manufacturerId: manufacturerSelect.value,
supplierReference: currentProduct.supplierReference,
});
const response = await fetch(`/api/catalog-maker/product-info?${params.toString()}`);
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || "Product info load failed.");
}
const infoByCatalogId = new Map(data.items.map((item) => [String(item.idProductCatalog), item]));
currentCombinations = currentCombinations.map((combination) => ({
...combination,
...(infoByCatalogId.get(String(combination.id)) || {}),
}));
renderCombinations(currentCombinations);
}
catch (error) {
setTableMessage(error.message);
}
finally {
setButtonLabel(loadProductButton, "Get product info - 00");
updateNavigationState();
}
}
export {};