1152 lines
39 KiB
JavaScript
1152 lines
39 KiB
JavaScript
const fieldGrid = document.querySelector("#fieldGrid");
|
|
const variantHeader = document.querySelector("#variantHeader");
|
|
const manufacturerSelect = document.querySelector("#manufacturerSelect");
|
|
const languageSelect = document.querySelector("#languageSelect");
|
|
const recordIdElement = document.querySelector("#recordId");
|
|
const loadProductButton = document.querySelector("#loadProductButton");
|
|
const previousProductButton = document.querySelector("#previousProductButton");
|
|
const nextProductButton = document.querySelector("#nextProductButton");
|
|
const tableBody = document.querySelector(".variant-table tbody");
|
|
const variantTable = document.querySelector(".variant-table");
|
|
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 mappingList = document.querySelector("#mappingList");
|
|
const mappingDetails = document.querySelector("#mappingDetails");
|
|
const findSourcesButton = document.querySelector("#findSourcesButton");
|
|
const getPicturesButton = document.querySelector("#getPicturesButton");
|
|
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 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 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));
|
|
headers.set("X-Database-Mode", localStorage.getItem("catalog-maker:database-mode") || "local");
|
|
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();
|
|
renderFields();
|
|
renderTableHeader();
|
|
setTableWidth();
|
|
await loadManufacturers();
|
|
await loadLanguages();
|
|
|
|
manufacturerSelect.addEventListener("change", () => {
|
|
if (manufacturerSelect.value) {
|
|
loadMappingSources();
|
|
loadProductForSelectedManufacturer(1);
|
|
} else {
|
|
renderMappingSources({ mapped: false, items: [], message: "Select manufacturer." });
|
|
clearProductState("Select manufacturer to load data.");
|
|
}
|
|
});
|
|
|
|
loadProductButton.disabled = true;
|
|
loadProductButton.title = "Load existing product attribute info.";
|
|
loadProductButton.addEventListener("click", loadProductInfoForCurrentProduct);
|
|
findSourcesButton.addEventListener("click", findSourcesForCurrentProduct);
|
|
getPicturesButton.addEventListener("click", getPicturesForCurrentProduct);
|
|
|
|
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);
|
|
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);
|
|
|
|
loadDatabaseMode();
|
|
|
|
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 th = document.createElement("th");
|
|
th.textContent = column.label;
|
|
th.dataset.column = column.key;
|
|
th.style.width = `${column.width}px`;
|
|
th.style.textAlign = column.align || "";
|
|
return th;
|
|
}),
|
|
);
|
|
}
|
|
|
|
function setTableWidth() {
|
|
const totalWidth = variantColumns.reduce((sum, column) => sum + column.width, 0);
|
|
variantTable.style.minWidth = `${Math.max(totalWidth, 820)}px`;
|
|
}
|
|
|
|
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));
|
|
} catch (error) {
|
|
manufacturerSelect.append(createOption("", error.message || "Local DB is not configured."));
|
|
}
|
|
}
|
|
|
|
function createOption(value, label) {
|
|
const option = document.createElement("option");
|
|
option.value = value;
|
|
option.textContent = label;
|
|
return option;
|
|
}
|
|
|
|
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."));
|
|
}
|
|
}
|
|
|
|
async function loadMappingSources() {
|
|
const manufacturerId = manufacturerSelect.value;
|
|
if (!manufacturerId) {
|
|
renderMappingSources({ mapped: false, items: [], message: "Select manufacturer." });
|
|
return;
|
|
}
|
|
|
|
mappingState.textContent = "Loading mapping...";
|
|
mappingState.className = "mapping-state";
|
|
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) {
|
|
mappingState.textContent = data.message || "Mapping missing";
|
|
mappingState.className = "mapping-state 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;
|
|
mappingState.textContent = ready
|
|
? `Sources OK ✓ (${testedCount}/${enabledCount})`
|
|
: data.message || `Sources problem (${testedCount}/${enabledCount})`;
|
|
mappingState.className = ready ? "mapping-state is-ready" : "mapping-state 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 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;
|
|
findSourcesButton.textContent = "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;
|
|
getPicturesButton.textContent = "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;
|
|
getPicturesButton.textContent = "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);
|
|
findSourcesButton.textContent = 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 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 = event.target.value === "live" ? "live" : "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 isLive = mode === "live";
|
|
for (const input of permissionInputs) {
|
|
input.disabled = isLive;
|
|
if (isLive) 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 DB" : "Local 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 DB selected for preparation. The current project still stays read-only."
|
|
: "Local test database 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("tr");
|
|
|
|
row.append(
|
|
...variantColumns.map((column) => {
|
|
const value = item[column.key];
|
|
const text = formatTableValue(value, column);
|
|
const cell = document.createElement("td");
|
|
cell.textContent = text;
|
|
cell.title = text;
|
|
cell.dataset.column = column.key;
|
|
cell.style.width = `${column.width}px`;
|
|
cell.style.textAlign = column.align || "";
|
|
if ((column.problem && item.hasProblem) || (column.key === "eanStatus" && text)) {
|
|
cell.className = "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("tr");
|
|
const cell = document.createElement("td");
|
|
cell.className = "empty-row";
|
|
cell.colSpan = variantColumns.length;
|
|
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;
|
|
loadProductButton.textContent = "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 {
|
|
loadProductButton.textContent = "Get product info - 00";
|
|
updateNavigationState();
|
|
}
|
|
}
|