feat: Implement category and product filtering in UI, add category exclusion, and refine product synchronization with active status and improved rowversion
This commit is contained in:
345
index.html
345
index.html
@@ -1,5 +1,6 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
@@ -34,6 +35,8 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.tree {
|
||||
list-style: none;
|
||||
}
|
||||
@@ -50,6 +53,9 @@
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #667eea;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.category-header.has-children {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -117,6 +123,7 @@
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.category-products {
|
||||
margin-top: 0.5rem;
|
||||
padding-left: 4rem;
|
||||
@@ -143,11 +150,40 @@
|
||||
.product-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.product-item.more {
|
||||
cursor: pointer;
|
||||
color: #667eea;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.product-item.more:hover {
|
||||
color: #764ba2;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
#filter-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
transition: border-color 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
#filter-input:focus {
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2);
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>📦 Category Tree</h1>
|
||||
<input type="text" id="filter-input" placeholder="🔍 Filter articles (e.g. 'red shoe')..." autocomplete="off">
|
||||
<div id="tree-container">
|
||||
<div class="loading">Loading categories...</div>
|
||||
</div>
|
||||
@@ -159,6 +195,18 @@
|
||||
transports: ['websocket']
|
||||
});
|
||||
|
||||
// State management
|
||||
const state = {
|
||||
categories: [],
|
||||
filter: '',
|
||||
// We store expansion state directly on category objects
|
||||
};
|
||||
|
||||
// DOM Elements
|
||||
const container = document.getElementById('tree-container');
|
||||
const filterInput = document.getElementById('filter-input');
|
||||
|
||||
// Socket Events
|
||||
socket.on('connect', () => {
|
||||
console.log('🔌 Connected to server via WebSocket');
|
||||
});
|
||||
@@ -170,39 +218,200 @@
|
||||
|
||||
socket.on('categoryProductsUpdated', ({ id }) => {
|
||||
console.log(`🔄 Products for category ${id} updated, reloading...`);
|
||||
// Find the specific category element and reload its products
|
||||
// Since we don't have easy access to instances, we could reload the whole tree
|
||||
// or dispatch a custom event. For simplicity, we'll reload the tree for now
|
||||
// but ideally we'd target the specific DOM element.
|
||||
|
||||
// Better approach: emit event that specific components can listen to
|
||||
document.dispatchEvent(new CustomEvent('productsUpdated', { detail: { id } }));
|
||||
updateCategoryProducts(id);
|
||||
});
|
||||
|
||||
// Event Listeners
|
||||
filterInput.addEventListener('input', (e) => {
|
||||
const value = e.target.value;
|
||||
state.filter = value;
|
||||
|
||||
if (value.trim()) {
|
||||
// Reset expansion first
|
||||
resetExpansion(state.categories);
|
||||
|
||||
// Get filtered tree to find best match
|
||||
const filtered = filterTree(state.categories, value);
|
||||
autoExpandBestMatch(filtered);
|
||||
}
|
||||
|
||||
render();
|
||||
});
|
||||
|
||||
function resetExpansion(nodes) {
|
||||
nodes.forEach(node => {
|
||||
node.isChildrenExpanded = false;
|
||||
node.isExpanded = false;
|
||||
if (node.children) resetExpansion(node.children);
|
||||
});
|
||||
}
|
||||
|
||||
function autoExpandBestMatch(nodes) {
|
||||
let maxCount = -1;
|
||||
let bestNode = null;
|
||||
let bestPath = [];
|
||||
|
||||
const traverse = (currentNodes, path) => {
|
||||
currentNodes.forEach(node => {
|
||||
// Count direct matches
|
||||
const count = node.products ? node.products.length : 0;
|
||||
|
||||
if (count > maxCount) {
|
||||
maxCount = count;
|
||||
bestNode = node;
|
||||
bestPath = [...path];
|
||||
}
|
||||
|
||||
if (node.children) {
|
||||
traverse(node.children, [...path, node]);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
traverse(nodes, []);
|
||||
|
||||
if (bestNode) {
|
||||
// Expand ancestors
|
||||
bestPath.forEach(n => {
|
||||
const real = n._original || n;
|
||||
real.isChildrenExpanded = true;
|
||||
});
|
||||
// Expand the node itself
|
||||
const realBest = bestNode._original || bestNode;
|
||||
realBest.isChildrenExpanded = true;
|
||||
realBest.isExpanded = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Initial Load
|
||||
loadCategories();
|
||||
|
||||
async function loadCategories() {
|
||||
try {
|
||||
const response = await fetch('/api/categories');
|
||||
if (!response.ok) throw new Error('Failed to load categories');
|
||||
const categories = await response.json();
|
||||
renderTree(categories);
|
||||
state.categories = await response.json();
|
||||
|
||||
// Initialize and fetch products for all categories
|
||||
initCategories(state.categories);
|
||||
|
||||
render();
|
||||
} catch (err) {
|
||||
document.getElementById('tree-container').innerHTML =
|
||||
`<div class="error">❌ ${err.message}</div>`;
|
||||
container.innerHTML = `<div class="error">❌ ${err.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTree(categories) {
|
||||
const container = document.getElementById('tree-container');
|
||||
function initCategories(nodes) {
|
||||
nodes.forEach(node => {
|
||||
// Initialize state properties
|
||||
node.products = null; // null means loading
|
||||
node.isExpanded = false;
|
||||
node.isChildrenExpanded = false; // New state for subcategory expansion
|
||||
|
||||
// Fetch products
|
||||
fetchProducts(node);
|
||||
|
||||
if (node.children) {
|
||||
initCategories(node.children);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function fetchProducts(node) {
|
||||
fetch(`/api/categories/${node.kKategorie}/products`)
|
||||
.then(res => res.ok ? res.json() : [])
|
||||
.then(products => {
|
||||
node.products = products;
|
||||
render(); // Re-render on data arrival
|
||||
})
|
||||
.catch(() => {
|
||||
node.products = [];
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
function updateCategoryProducts(id) {
|
||||
const findAndReload = (nodes) => {
|
||||
for (const node of nodes) {
|
||||
if (node.kKategorie === id) {
|
||||
node.products = null; // Set to loading
|
||||
render();
|
||||
fetchProducts(node);
|
||||
return true;
|
||||
}
|
||||
if (node.children && findAndReload(node.children)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
findAndReload(state.categories);
|
||||
}
|
||||
|
||||
// Filtering Logic
|
||||
function filterTree(nodes, query) {
|
||||
if (!query.trim()) return nodes; // Return original structure if no filter
|
||||
|
||||
const words = query.toLowerCase().split(/\s+/).filter(w => w);
|
||||
|
||||
return nodes.map(node => {
|
||||
// Filter products
|
||||
// If products are loading (null), we treat as no match for now
|
||||
const matchingProducts = node.products
|
||||
? node.products.filter(p => {
|
||||
const name = p.cName.toLowerCase();
|
||||
return words.every(w => name.includes(w));
|
||||
})
|
||||
: [];
|
||||
|
||||
// Filter children
|
||||
const matchingChildren = node.children ? filterTree(node.children, query) : [];
|
||||
|
||||
// Keep node if it has matching products OR matching children
|
||||
if (matchingProducts.length > 0 || matchingChildren.length > 0) {
|
||||
// Return a shallow copy with filtered data
|
||||
// We preserve the original reference for updates, but render the copy
|
||||
return {
|
||||
...node,
|
||||
products: matchingProducts,
|
||||
children: matchingChildren,
|
||||
// Preserve expansion state from original node?
|
||||
// Actually we are copying properties, so isExpanded is copied.
|
||||
// But if we modify isExpanded on the copy, it won't affect original.
|
||||
// We need to handle interactions carefully.
|
||||
// For this implementation, we'll let the render function modify the ORIGINAL node
|
||||
// by looking it up or passing it through.
|
||||
// Simpler: The filtered tree is just for rendering.
|
||||
// Interaction handlers should target the ID and update the global state.
|
||||
_original: node
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}).filter(n => n !== null);
|
||||
}
|
||||
|
||||
// Rendering
|
||||
function render() {
|
||||
const filteredCategories = filterTree(state.categories, state.filter);
|
||||
|
||||
container.innerHTML = '';
|
||||
|
||||
if (state.filter && filteredCategories.length === 0) {
|
||||
container.innerHTML = '<div class="loading" style="color: #666">No matching articles found.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const ul = document.createElement('ul');
|
||||
ul.className = 'tree';
|
||||
categories.forEach(cat => {
|
||||
filteredCategories.forEach(cat => {
|
||||
ul.appendChild(renderCategory(cat));
|
||||
});
|
||||
container.appendChild(ul);
|
||||
}
|
||||
|
||||
function renderCategory(category) {
|
||||
// category might be a filtered copy or original.
|
||||
// If it's a copy, it has _original pointing to the real state node.
|
||||
const realNode = category._original || category;
|
||||
|
||||
const li = document.createElement('li');
|
||||
|
||||
const div = document.createElement('div');
|
||||
@@ -213,10 +422,24 @@
|
||||
|
||||
// Toggle for children
|
||||
if (category.children && category.children.length > 0) {
|
||||
header.classList.add('has-children');
|
||||
const toggle = document.createElement('span');
|
||||
toggle.className = 'toggle';
|
||||
toggle.textContent = '▶'; // We don't have collapse state for categories in requirements, only products
|
||||
// Wait, the original code had collapse for subcategories!
|
||||
// "childrenUl.classList.toggle('hidden')"
|
||||
// I should preserve this.
|
||||
// Let's add isChildrenExpanded to state.
|
||||
if (realNode.isChildrenExpanded) {
|
||||
toggle.textContent = '▼';
|
||||
}
|
||||
header.appendChild(toggle);
|
||||
|
||||
header.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
realNode.isChildrenExpanded = !realNode.isChildrenExpanded;
|
||||
render();
|
||||
};
|
||||
}
|
||||
|
||||
// Image
|
||||
@@ -240,7 +463,21 @@
|
||||
|
||||
const count = document.createElement('div');
|
||||
count.className = 'category-count';
|
||||
count.textContent = `${category.articleCount} articles`;
|
||||
// Use original counts or filtered counts?
|
||||
// "remove all articles ... with no match"
|
||||
// If filtered, we should probably show the count of MATCHING articles?
|
||||
// User didn't specify, but it makes sense to show matching count if filtered.
|
||||
// But articleCount comes from API.
|
||||
// We can calculate it from products array if loaded.
|
||||
const productCount = category.products ? category.products.length : category.articleCount;
|
||||
let countText = `${productCount} articles`;
|
||||
|
||||
// Always show subcategory count if children exist (User requirement)
|
||||
// Use filtered children length
|
||||
if (category.children && category.children.length > 0) {
|
||||
countText += `, ${category.children.length} subcategories`;
|
||||
}
|
||||
count.textContent = countText;
|
||||
info.appendChild(count);
|
||||
|
||||
header.appendChild(info);
|
||||
@@ -249,31 +486,21 @@
|
||||
// Products
|
||||
const productsDiv = document.createElement('div');
|
||||
productsDiv.className = 'category-products';
|
||||
productsDiv.innerHTML = '<small>Loading products...</small>';
|
||||
div.appendChild(productsDiv);
|
||||
|
||||
// Load products
|
||||
const loadProducts = () => {
|
||||
if (category.products === null) {
|
||||
productsDiv.innerHTML = '<small>Loading products...</small>';
|
||||
productsDiv.style.display = 'block';
|
||||
|
||||
fetch(`/api/categories/${category.kKategorie}/products`)
|
||||
.then(res => res.ok ? res.json() : [])
|
||||
.then(products => {
|
||||
productsDiv.innerHTML = '';
|
||||
if (products.length === 0) {
|
||||
productsDiv.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
} else if (category.products.length > 0) {
|
||||
productsDiv.style.display = 'block';
|
||||
const ul = document.createElement('ul');
|
||||
ul.style.listStyle = 'none';
|
||||
|
||||
products.slice(0, 3).forEach(p => {
|
||||
const limit = realNode.isExpanded ? category.products.length : 3;
|
||||
|
||||
category.products.slice(0, limit).forEach(p => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'product-item';
|
||||
|
||||
// Image
|
||||
if (p.images && p.images.length > 0) {
|
||||
const img = document.createElement('img');
|
||||
img.className = 'product-image';
|
||||
@@ -286,62 +513,56 @@
|
||||
const span = document.createElement('span');
|
||||
span.textContent = p.cName;
|
||||
li.appendChild(span);
|
||||
|
||||
ul.appendChild(li);
|
||||
});
|
||||
|
||||
if (products.length > 3) {
|
||||
if (!realNode.isExpanded && category.products.length > 3) {
|
||||
const more = document.createElement('li');
|
||||
more.className = 'product-item';
|
||||
more.className = 'product-item more';
|
||||
more.style.fontStyle = 'italic';
|
||||
more.textContent = `...and ${products.length - 3} more`;
|
||||
more.textContent = `...and ${category.products.length - 3} more`;
|
||||
more.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
// Collapse others
|
||||
collapseAllProducts(state.categories);
|
||||
realNode.isExpanded = true;
|
||||
render();
|
||||
};
|
||||
ul.appendChild(more);
|
||||
}
|
||||
|
||||
productsDiv.appendChild(ul);
|
||||
})
|
||||
.catch(() => {
|
||||
} else {
|
||||
productsDiv.style.display = 'none';
|
||||
});
|
||||
};
|
||||
|
||||
loadProducts();
|
||||
|
||||
// Listen for updates
|
||||
const updateHandler = (e) => {
|
||||
if (e.detail.id === category.kKategorie) {
|
||||
console.log(`✨ refreshing products for category ${category.kKategorie}`);
|
||||
loadProducts();
|
||||
}
|
||||
};
|
||||
document.addEventListener('productsUpdated', updateHandler);
|
||||
|
||||
div.appendChild(productsDiv);
|
||||
|
||||
// Children
|
||||
if (category.children && category.children.length > 0) {
|
||||
const childrenUl = document.createElement('ul');
|
||||
childrenUl.className = 'children';
|
||||
if (!realNode.isChildrenExpanded) {
|
||||
childrenUl.classList.add('hidden');
|
||||
}
|
||||
|
||||
category.children.forEach(child => {
|
||||
childrenUl.appendChild(renderCategory(child));
|
||||
});
|
||||
div.appendChild(childrenUl);
|
||||
|
||||
// Toggle functionality
|
||||
div.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.children')) return;
|
||||
e.stopPropagation();
|
||||
childrenUl.classList.toggle('hidden');
|
||||
const toggle = div.querySelector('.toggle');
|
||||
if (toggle) {
|
||||
toggle.textContent = childrenUl.classList.contains('hidden') ? '▶' : '▼';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
li.appendChild(div);
|
||||
return li;
|
||||
}
|
||||
|
||||
loadCategories();
|
||||
function collapseAllProducts(nodes) {
|
||||
nodes.forEach(node => {
|
||||
node.isExpanded = false;
|
||||
if (node.children) collapseAllProducts(node.children);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -30,7 +30,9 @@ export function registerImages(app, cacheDir) {
|
||||
}
|
||||
}, (err) => {
|
||||
if (err) {
|
||||
if (err.code !== 'ENOENT') {
|
||||
console.error(`❌ Error serving image ${resolvedPath}:`, err);
|
||||
}
|
||||
if (!res.headersSent) {
|
||||
res.status(404).send('Image not found');
|
||||
}
|
||||
|
||||
@@ -113,10 +113,17 @@ class CategoryProductsSyncer extends EventEmitter {
|
||||
JOIN tArtikelBeschreibung ab ON ka.kArtikel = ab.kArtikel
|
||||
JOIN tArtikel a ON ka.kArtikel = a.kArtikel
|
||||
WHERE ab.kSprache = ${process.env.JTL_SPRACHE_ID}
|
||||
AND a.cAktiv = 'Y'
|
||||
AND ab.kPlattform = ${process.env.JTL_PLATTFORM_ID}
|
||||
AND ab.kShop = ${process.env.JTL_SHOP_ID}
|
||||
AND ka.kKategorie IN (${list})
|
||||
ORDER BY (CASE WHEN a.bRowversion > ab.bRowversion THEN a.bRowversion ELSE ab.bRowversion END) DESC
|
||||
ORDER BY (
|
||||
CASE
|
||||
WHEN a.bRowversion >= ab.bRowversion AND a.bRowversion >= ka.bRowversion THEN a.bRowversion
|
||||
WHEN ab.bRowversion >= a.bRowversion AND ab.bRowversion >= ka.bRowversion THEN ab.bRowversion
|
||||
ELSE ka.bRowversion
|
||||
END
|
||||
) DESC
|
||||
`);
|
||||
|
||||
// Collect all kArtikel IDs to fetch images
|
||||
|
||||
@@ -88,6 +88,7 @@ class CategorySyncer extends EventEmitter {
|
||||
const categoriesResult = await pool.request().query(`
|
||||
SELECT kKategorie, kOberKategorie, nSort
|
||||
FROM tkategorie
|
||||
ORDER BY nSort, kKategorie
|
||||
`);
|
||||
|
||||
// Fetch names
|
||||
@@ -201,8 +202,19 @@ class CategorySyncer extends EventEmitter {
|
||||
|
||||
const rootNodes = [];
|
||||
|
||||
// Parse excluded IDs
|
||||
const excludedIds = new Set(
|
||||
(process.env.EXCLUDE_CATEGORY_IDS || '')
|
||||
.split(',')
|
||||
.map(id => parseInt(id.trim()))
|
||||
.filter(id => !isNaN(id))
|
||||
);
|
||||
|
||||
// Build hierarchy
|
||||
categories.forEach(cat => {
|
||||
// Skip if excluded
|
||||
if (excludedIds.has(cat.kKategorie)) return;
|
||||
|
||||
const node = categoryMap.get(cat.kKategorie);
|
||||
if (cat.kOberKategorie === 0) {
|
||||
rootNodes.push(node);
|
||||
@@ -221,18 +233,22 @@ class CategorySyncer extends EventEmitter {
|
||||
let resultNodes = rootNodes;
|
||||
|
||||
if (rootId && applyRootFilter) {
|
||||
if (excludedIds.has(rootId)) {
|
||||
resultNodes = [];
|
||||
} else {
|
||||
const specificRoot = categoryMap.get(rootId);
|
||||
// Return the children of the specified root, not the root itself
|
||||
resultNodes = specificRoot ? specificRoot.children : [];
|
||||
}
|
||||
}
|
||||
|
||||
// Sort children and remove nSort
|
||||
for (const node of categoryMap.values()) {
|
||||
node.children.sort((a, b) => a.nSort - b.nSort);
|
||||
node.children.sort((a, b) => a.nSort - b.nSort || a.kKategorie - b.kKategorie);
|
||||
}
|
||||
|
||||
// Sort root nodes if returning multiple
|
||||
resultNodes.sort((a, b) => a.nSort - b.nSort);
|
||||
resultNodes.sort((a, b) => a.nSort - b.nSort || a.kKategorie - b.kKategorie);
|
||||
|
||||
// Remove nSort property from all nodes
|
||||
for (const node of categoryMap.values()) {
|
||||
|
||||
Reference in New Issue
Block a user