fix: admin product form - cascading location selectors + inline category creation

- Country → City → District cascading dropdowns with filtering
- Categories filtered by selected location (show all when no location)
- 'No category? Create one' row appears when location selected
- POST /catalog/categories/json endpoint for AJAX category creation
- Product form uses location_id from dropdown (not category lookup)
- Categories populate in select after inline creation
- district fallback shows city name when district is empty
This commit is contained in:
NW
2026-06-25 08:21:41 +01:00
parent bbf49ec546
commit c55ec47ea0
4 changed files with 77 additions and 29 deletions

View File

@@ -70,6 +70,14 @@ router.post('/categories', async (req, res) => {
res.redirect('/catalog?msg=Category+added&msg_type=success');
});
router.post('/categories/json', async (req, res) => {
const { name, location_id } = req.body;
if (!name || !location_id) return res.status(400).json({ error: 'Name and location required' });
const result = await db.runAsync('INSERT INTO categories (name,location_id) VALUES (?,?)', [name.trim(), location_id]);
const cat = await db.getAsync('SELECT * FROM categories WHERE id=?', [result.lastInsertRowid]);
res.json(cat);
});
router.post('/categories/:id/delete', async (req, res) => {
const c = await db.getAsync('SELECT COUNT(*) as n FROM products WHERE category_id=?', [req.params.id]);
if (c?.n > 0) return res.redirect('/catalog?msg=Cannot+delete+has+products&msg_type=error');

View File

@@ -15,30 +15,30 @@ const router = Router();
router.post('/products', upload.fields([{ name: 'photo_file' }, { name: 'hidden_photo_file' }]), async (req, res) => {
const { name, price, quantity_in_stock, description, photo_url, hidden_photo_url,
hidden_coordinates, hidden_description, private_data, category_id, subcategory_id } = req.body;
hidden_coordinates, hidden_description, private_data, category_id, subcategory_id, location_id } = req.body;
if (!name || !price || !category_id) return res.redirect('/catalog?msg=Name+price+category+required&msg_type=error');
const pu = req.files?.photo_file?.[0] ? `/uploads/${req.files.photo_file[0].filename}` : (photo_url || '');
const hu = req.files?.hidden_photo_file?.[0] ? `/uploads/${req.files.hidden_photo_file[0].filename}` : (hidden_photo_url || '');
const locRow = await db.getAsync('SELECT location_id FROM categories WHERE id=?', [category_id]);
const locId = location_id || (await db.getAsync('SELECT location_id FROM categories WHERE id=?', [category_id]))?.location_id || null;
await db.runAsync(`INSERT INTO products (name,price,quantity_in_stock,description,photo_url,hidden_photo_url,
hidden_coordinates,hidden_description,private_data,category_id,subcategory_id,location_id)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
[name.trim(), parseFloat(price), parseInt(quantity_in_stock)||0, description||'', pu, hu,
hidden_coordinates||'', hidden_description||'', private_data||'', category_id, subcategory_id||null, locRow?.location_id||null]);
hidden_coordinates||'', hidden_description||'', private_data||'', category_id, subcategory_id||null, locId]);
res.redirect('/catalog?msg=Product+added&msg_type=success');
});
router.post('/products/:id/edit', upload.fields([{ name: 'photo_file' }, { name: 'hidden_photo_file' }]), async (req, res) => {
const { name, price, quantity_in_stock, description, photo_url, hidden_photo_url,
hidden_coordinates, hidden_description, private_data, category_id, subcategory_id } = req.body;
hidden_coordinates, hidden_description, private_data, category_id, subcategory_id, location_id } = req.body;
if (!name || !price || !category_id) return res.redirect('/catalog?msg=Name+price+category+required&msg_type=error');
const pu = req.files?.photo_file?.[0] ? `/uploads/${req.files.photo_file[0].filename}` : (photo_url || '');
const hu = req.files?.hidden_photo_file?.[0] ? `/uploads/${req.files.hidden_photo_file[0].filename}` : (hidden_photo_url || '');
const locRow = await db.getAsync('SELECT location_id FROM categories WHERE id=?', [category_id]);
const locId = location_id || (await db.getAsync('SELECT location_id FROM categories WHERE id=?', [category_id]))?.location_id || null;
await db.runAsync(`UPDATE products SET name=?,price=?,quantity_in_stock=?,description=?,photo_url=?,hidden_photo_url=?,
hidden_coordinates=?,hidden_description=?,private_data=?,category_id=?,subcategory_id=?,location_id=? WHERE id=?`,
[name.trim(), parseFloat(price), parseInt(quantity_in_stock)||0, description||'', pu, hu,
hidden_coordinates||'', hidden_description||'', private_data||'', category_id, subcategory_id||null, locRow?.location_id||null, req.params.id]);
hidden_coordinates||'', hidden_description||'', private_data||'', category_id, subcategory_id||null, locId, req.params.id]);
res.redirect('/catalog?msg=Product+updated&msg_type=success');
});

View File

@@ -3,9 +3,10 @@ import { renderProductEditForm } from './catalogProduct.js';
export function renderCatalog(tree, products, filter, categories, subcategories, locations, msg, msgType) {
const { loc, cat, sub } = filter;
const catOptions = categories.map(c => `<option value="${c.id}">${esc(c.name)}</option>`).join('');
const catOptions = categories.map(c => `<option value="${c.id}" data-loc="${c.location_id}">${esc(c.name)}</option>`).join('');
const subcatJson = JSON.stringify(subcategories.map(s => ({ id: s.id, name: s.name, category_id: s.category_id })));
const locJson = JSON.stringify(locations || []);
const catJson = JSON.stringify(categories.map(c => ({ id: c.id, name: c.name, location_id: c.location_id })));
const addFormHtml = renderProductEditForm('/catalog/products', catOptions, subcatJson, locations)
.replace(/`/g, '\\`').replace(/\$/g, '\\$');
const editFormHtml = renderProductEditForm('/catalog/products/__ID__/edit', catOptions, subcatJson, locations)
@@ -69,6 +70,7 @@ export function renderCatalog(tree, products, filter, categories, subcategories,
<script>
const subcats = ${subcatJson};
const allLocations = ${locJson};
const allCategories = ${catJson};
const addFormTpl = \`${addFormHtml}\`;
const editFormTpl = \`${editFormHtml}\`;
@@ -91,7 +93,10 @@ function initLocationSelects(selectedLocId) {
ci.value = sel.city;
locOnCityChange();
di.value = sel.id;
locOnDistrictChange();
}
} else {
updateCategoryOptions(null);
}
}
@@ -102,11 +107,11 @@ function locOnCountryChange() {
const country = cs.value;
ci.innerHTML = '<option value="">-- City --</option>';
di.innerHTML = '<option value="">-- District --</option>';
if (!country) { ci.disabled = true; return; }
if (!country) { ci.disabled = true; updateCategoryOptions(null); return; }
ci.disabled = false;
const cities = [...new Set(allLocations.filter(l => l.country === country).map(l => l.city))].sort();
ci.innerHTML = '<option value="">-- City --</option>' + cities.map(c => '<option value="'+escHtml(c)+'">'+escHtml(c)+'</option>').join('');
filterCategoriesByLocation();
updateCategoryOptions(null);
}
function locOnCityChange() {
@@ -116,34 +121,62 @@ function locOnCityChange() {
const country = cs.value;
const city = ci.value;
di.innerHTML = '<option value="">-- District --</option>';
if (!city) { filterCategoriesByLocation(); return; }
if (!city) { updateCategoryOptions(null); return; }
const locs = allLocations.filter(l => l.country === country && l.city === city);
di.innerHTML = '<option value="">-- District --</option>' + locs.map(l => '<option value="'+l.id+'">'+escHtml(l.district || l.city)+'</option>').join('');
filterCategoriesByLocation();
updateCategoryOptions(null);
}
function locOnDistrictChange() {
filterCategoriesByLocation();
const di = document.getElementById('loc-district');
const locId = di ? di.value : '';
updateCategoryOptions(locId || null);
}
function filterCategoriesByLocation() {
const di = document.getElementById('loc-district');
function updateCategoryOptions(locId) {
const catSel = document.getElementById('pf-category');
if (!catSel) return;
const locId = di ? di.value : '';
const allCats = catSel.querySelectorAll('option');
if (!locId) {
allCats.forEach(o => o.style.display = '');
return;
const currentVal = catSel.value;
const filtered = locId ? allCategories.filter(c => c.location_id == locId) : allCategories;
catSel.innerHTML = '<option value="">-- Select Category --</option>' + filtered.map(c => '<option value="'+c.id+'">'+escHtml(c.name)+'</option>').join('');
if (filtered.find(c => c.id == currentVal)) catSel.value = currentVal;
updateSubcats(catSel.value);
updateNewCatVisibility(locId);
}
function updateNewCatVisibility(locId) {
const newCatRow = document.getElementById('new-cat-row');
if (!newCatRow) return;
newCatRow.style.display = locId ? '' : 'none';
if (locId) {
const loc = allLocations.find(l => l.id == locId);
const locName = loc ? (loc.district ? loc.country + ', ' + loc.city + ', ' + loc.district : loc.country + ', ' + loc.city) : '';
document.getElementById('new-cat-loc-label').textContent = locName;
document.getElementById('new-cat-location-id').value = locId;
}
const loc = allLocations.find(l => l.id == locId);
allCats.forEach(o => {
if (!o.value) { o.style.display = ''; return; }
const cat = ${JSON.stringify(categories)}.find(c => c.id == o.value);
o.style.display = (cat && cat.location_id == locId) ? '' : 'none';
});
catSel.value = '';
updateSubcats('');
}
async function addCategoryInline() {
const nameInput = document.getElementById('new-cat-name');
const locIdInput = document.getElementById('new-cat-location-id');
const name = nameInput.value.trim();
const locId = locIdInput.value;
if (!name || !locId) return;
try {
const res = await fetch('/catalog/categories/json', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name, location_id: parseInt(locId)})
});
const cat = await res.json();
if (cat.error) { alert(cat.error); return; }
allCategories.push(cat);
nameInput.value = '';
updateCategoryOptions(parseInt(locId));
const catSel = document.getElementById('pf-category');
catSel.value = cat.id;
updateSubcats(cat.id);
} catch(e) { alert('Error adding category'); }
}
function escHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
@@ -163,8 +196,8 @@ function fillEditForm(p){ const f=document.getElementById('product-modal').query
f.querySelector('[name=photo_url]').value=p.photo_url||''; f.querySelector('[name=hidden_photo_url]').value=p.hidden_photo_url||'';
f.querySelector('[name=hidden_coordinates]').value=p.hidden_coordinates||''; f.querySelector('[name=hidden_description]').value=p.hidden_description||'';
f.querySelector('[name=private_data]').value=p.private_data||'';
if(p.category_id) { f.querySelector('[name=category_id]').value=p.category_id; updateSubcats(p.category_id, p.subcategory_id); }
initLocationSelects(p.location_id);
if(p.category_id) { setTimeout(()=>{ f.querySelector('[name=category_id]').value=p.category_id; updateSubcats(p.category_id, p.subcategory_id); },50); }
}
function updateSubcats(catId,selSub){ const ss=document.getElementById('product-modal').querySelector('[name=subcategory_id]'); if(!ss)return;
ss.innerHTML='<option value="">-- Subcategory --</option>'; subcats.forEach(s=>{if(s.category_id==catId){const o=document.createElement('option');o.value=s.id;o.textContent=s.name;if(s.id==selSub)o.selected=true;ss.appendChild(o)}}); }

View File

@@ -1,7 +1,6 @@
export function renderProductEditForm(action, catOptions, subcatJson, locations) {
const isEdit = action.includes('/edit');
const title = isEdit ? 'Edit Product' : 'Add Product';
const locJson = JSON.stringify(locations || []);
return `<h2>${title}</h2>
<form method="POST" action="${action}" enctype="multipart/form-data" class="product-form">
<div class="pf-group">
@@ -44,6 +43,14 @@ export function renderProductEditForm(action, catOptions, subcatJson, locations)
<select name="subcategory_id"><option value="">-- Subcategory --</option></select>
</div>
</div>
<div class="pf-group" id="new-cat-row" style="display:none">
<label>No category? Create one for: <strong id="new-cat-loc-label"></strong></label>
<div style="display:flex;gap:0.5rem">
<input id="new-cat-name" placeholder="Category name" style="flex:1">
<input type="hidden" id="new-cat-location-id" value="">
<button type="button" class="btn-sm" onclick="addCategoryInline()">+ Add Category</button>
</div>
</div>
<div class="pf-group">
<label>Description</label>
<textarea name="description" rows="3" placeholder="Public description"></textarea>