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:
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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)}}); }
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user