feat: rebuild Catalog with collapsible tree + product table + photo upload
- Left panel: collapsible tree (Country → City → District → Category → Subcategory) - Quick-add buttons: + City, + District, + Category, + Subcategory - Delete buttons with confirmation on all nodes - Product count badges on each node - Click node to filter right panel - Right panel: Product table with Photo, Name, Category, Subcategory, Price, Stock - Edit (✎) and Delete (✕) buttons per row - Add Product modal with all fields - Product edit form: name, price, stock, description, category, subcategory (JS filtered), photo_url/hidden_photo_url (URL or file upload), hidden_coordinates, hidden_description, private_data - Multer file upload for photos stored in /uploads/ - Routes: add-city, add-district, product CRUD with photo upload - Product JSON API for modal editing - Responsive grid: tree (320px) + table (1fr)
This commit is contained in:
@@ -14,6 +14,7 @@ services:
|
||||
- .env
|
||||
volumes:
|
||||
- ./db:/app/db/ # Синхронизация базы данных (persistence)
|
||||
- ./uploads:/app/uploads/ # Uploaded product photos
|
||||
- ./wg/start.sh:/app/start.sh # Монтируем start.sh (генерирует wg0.conf из env)
|
||||
cap_add: # Минимальные привилегии, необходимые только для WireGuard
|
||||
- NET_ADMIN
|
||||
|
||||
86
package-lock.json
generated
86
package-lock.json
generated
@@ -21,6 +21,7 @@
|
||||
"ethereumjs-util": "^7.1.5",
|
||||
"express": "^4.21.0",
|
||||
"hdkey": "^2.1.0",
|
||||
"multer": "^2.2.0",
|
||||
"node-telegram-bot-api": "^0.64.0",
|
||||
"pino": "^8.21.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
@@ -275,6 +276,12 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/append-field": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/archiver": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz",
|
||||
@@ -887,11 +894,28 @@
|
||||
"resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz",
|
||||
"integrity": "sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ=="
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/buffer-xor": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz",
|
||||
"integrity": "sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ=="
|
||||
},
|
||||
"node_modules/busboy": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
||||
"dependencies": {
|
||||
"streamsearch": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -1096,6 +1120,35 @@
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||
"engines": [
|
||||
"node >= 6.0"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.0.2",
|
||||
"typedarray": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-stream/node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
@@ -3179,6 +3232,25 @@
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz",
|
||||
"integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
"busboy": "^1.6.0",
|
||||
"concat-stream": "^2.0.0",
|
||||
"type-is": "^1.6.18"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/napi-build-utils": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz",
|
||||
@@ -4500,6 +4572,14 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/streamsearch": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/streamx": {
|
||||
"version": "2.20.2",
|
||||
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.20.2.tgz",
|
||||
@@ -4898,6 +4978,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/typedarray": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typeforce": {
|
||||
"version": "1.18.0",
|
||||
"resolved": "https://registry.npmjs.org/typeforce/-/typeforce-1.18.0.tgz",
|
||||
|
||||
@@ -10,16 +10,17 @@
|
||||
"archiver": "^7.0.1",
|
||||
"axios": "^1.7.7",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"express": "^4.21.0",
|
||||
"bip39": "^3.1.0",
|
||||
"bitcoinjs-lib": "^6.1.6",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"csv-writer": "^1.6.0",
|
||||
"decompress": "^4.2.1",
|
||||
"dotenv": "^16.3.1",
|
||||
"ecpair": "^2.1.0",
|
||||
"ethereumjs-util": "^7.1.5",
|
||||
"express": "^4.21.0",
|
||||
"hdkey": "^2.1.0",
|
||||
"multer": "^2.2.0",
|
||||
"node-telegram-bot-api": "^0.64.0",
|
||||
"pino": "^8.21.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
|
||||
@@ -230,23 +230,48 @@ pre { font-size: 0.8rem; white-space: pre-wrap; word-break: break-all; max-width
|
||||
.seed-card h2 { margin-bottom: 0.5rem; }
|
||||
.seed-card p { margin-bottom: 1rem; color: var(--muted); }
|
||||
|
||||
.catalog-grid {
|
||||
.catalog-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 360px;
|
||||
grid-template-columns: 320px 1fr;
|
||||
gap: 1.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.catalog-grid { grid-template-columns: 1fr; }
|
||||
.catalog-layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.catalog-tree h2 { margin-bottom: 0.75rem; }
|
||||
|
||||
.catalog-forms { position: sticky; top: 1rem; }
|
||||
.catalog-main { min-width: 0; }
|
||||
|
||||
.tree-node { margin-left: 0; }
|
||||
.tree-location > .tree-children { margin-left: 0; }
|
||||
|
||||
.tree-toggle {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
padding: 0.4rem 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tree-toggle:hover { background: #f0f4ff; }
|
||||
|
||||
.tree-toggle .arrow {
|
||||
display: inline-block;
|
||||
font-size: 0.7rem;
|
||||
transition: transform 0.15s;
|
||||
width: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tree-toggle .arrow.open { transform: rotate(90deg); }
|
||||
|
||||
.tree-children { display: none; }
|
||||
|
||||
.tree-children.open { display: block; }
|
||||
|
||||
.tree-children {
|
||||
margin-left: 1.5rem;
|
||||
@@ -254,60 +279,40 @@ pre { font-size: 0.8rem; white-space: pre-wrap; word-break: break-all; max-width
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
|
||||
.tree-label {
|
||||
.tree-count {
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
margin-left: 0.3rem;
|
||||
}
|
||||
|
||||
.tree-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.35rem 0;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.tree-label strong { font-size: 0.95rem; }
|
||||
|
||||
.tree-icon { font-size: 1rem; }
|
||||
|
||||
.tree-meta {
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted);
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.tree-empty {
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
margin: 0.3rem 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.tree-product {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.3rem 0;
|
||||
margin-left: 1.5rem;
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.tree-product .tree-name { font-weight: 500; font-size: 0.9rem; }
|
||||
|
||||
.tree-add {
|
||||
margin: 0.3rem 0 0.3rem 1.5rem;
|
||||
}
|
||||
|
||||
.tree-location {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.tree-location > .tree-children { margin-left: 0; border-left: none; padding-left: 0; }
|
||||
|
||||
.tree-node:not(.tree-location) .tree-children {
|
||||
margin-left: 1rem;
|
||||
border-left: 2px solid var(--border);
|
||||
padding-left: 0.5rem;
|
||||
.modal-content {
|
||||
background: var(--card);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.5rem;
|
||||
max-width: 560px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
@@ -323,5 +328,5 @@ pre { font-size: 0.8rem; white-space: pre-wrap; word-break: break-all; max-width
|
||||
.stats-grid { grid-template-columns: 1fr 1fr; }
|
||||
table { font-size: 0.8rem; }
|
||||
th, td { padding: 0.4rem; }
|
||||
.catalog-grid { grid-template-columns: 1fr; }
|
||||
.catalog-layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@@ -5,109 +5,91 @@ import { renderCatalog } from '../views/catalog.js';
|
||||
const router = Router();
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const msg = req.query.msg || '';
|
||||
const msgType = req.query.msg_type || 'info';
|
||||
const [locations, categories, subcategories, products] = await Promise.all([
|
||||
db.allAsync(`SELECT l.*,
|
||||
(SELECT COUNT(*) FROM categories WHERE location_id = l.id) as category_count,
|
||||
(SELECT COUNT(*) FROM products WHERE location_id = l.id) as product_count
|
||||
FROM locations l ORDER BY l.country, l.city, l.district`),
|
||||
db.allAsync(`SELECT c.*, l.country, l.city, l.district
|
||||
FROM categories c LEFT JOIN locations l ON c.location_id = l.id ORDER BY c.id`),
|
||||
db.allAsync(`SELECT s.*, c.name as category_name
|
||||
FROM subcategories s LEFT JOIN categories c ON s.category_id = c.id ORDER BY s.id`),
|
||||
db.allAsync(`SELECT p.*, c.name as category_name, s.name as subcategory_name, l.city
|
||||
FROM products p
|
||||
LEFT JOIN categories c ON p.category_id = c.id
|
||||
LEFT JOIN subcategories s ON p.subcategory_id = s.id
|
||||
LEFT JOIN locations l ON p.location_id = l.id
|
||||
ORDER BY p.id DESC LIMIT 200`),
|
||||
const { loc, cat, sub, msg, msg_type } = req.query;
|
||||
const [locations, categories, subcategories] = await Promise.all([
|
||||
db.allAsync('SELECT * FROM locations ORDER BY country, city, district'),
|
||||
db.allAsync(`SELECT c.*, (SELECT COUNT(*) FROM products WHERE category_id=c.id) as pc,
|
||||
(SELECT COUNT(*) FROM subcategories WHERE category_id=c.id) as sc FROM categories c`),
|
||||
db.allAsync(`SELECT s.*, (SELECT COUNT(*) FROM products WHERE subcategory_id=s.id) as pc
|
||||
FROM subcategories s`)
|
||||
]);
|
||||
res.send(renderCatalog(locations, categories, subcategories, products, msg, msgType));
|
||||
let psql = `SELECT p.*, c.name as cn, s.name as sn FROM products p
|
||||
LEFT JOIN categories c ON p.category_id=c.id LEFT JOIN subcategories s ON p.subcategory_id=s.id WHERE 1=1`;
|
||||
const params = [];
|
||||
if (loc) { psql += ' AND p.location_id=?'; params.push(loc); }
|
||||
if (cat) { psql += ' AND p.category_id=?'; params.push(cat); }
|
||||
if (sub) { psql += ' AND p.subcategory_id=?'; params.push(sub); }
|
||||
const products = await db.allAsync(psql + ' ORDER BY p.id DESC LIMIT 200', params);
|
||||
const cl = {}; for (const c of categories) (cl[c.location_id]??=[]).push(c);
|
||||
const sc = {}; for (const s of subcategories) (sc[s.category_id]??=[]).push(s);
|
||||
const tree = {};
|
||||
for (const l of locations) {
|
||||
(tree[l.country]??={cities:{}}).cities[l.city]??={districts:{}};
|
||||
tree[l.country].cities[l.city].districts[l.district] = {
|
||||
id: l.id, cats: (cl[l.id]||[]).map(c=>({...c, subs: sc[c.id]||[]}))
|
||||
};
|
||||
}
|
||||
res.send(renderCatalog(tree, products, { loc, cat, sub }, categories, subcategories, msg||'', msg_type||'info'));
|
||||
});
|
||||
|
||||
router.post('/locations', async (req, res) => {
|
||||
const { country, city, district } = req.body;
|
||||
if (!country || !city) return res.redirect('/catalog?msg=Country+and+city+required&msg_type=error');
|
||||
try {
|
||||
await db.runAsync('INSERT INTO locations (country, city, district) VALUES (?, ?, ?)',
|
||||
[country.trim(), city.trim(), (district || '').trim()]);
|
||||
res.redirect('/catalog?msg=Location+added&msg_type=success');
|
||||
} catch (e) {
|
||||
res.redirect('/catalog?msg=' + encodeURIComponent(e.message) + '&msg_type=error');
|
||||
}
|
||||
await db.runAsync('INSERT INTO locations (country,city,district) VALUES (?,?,?)',
|
||||
[country.trim(), city.trim(), (district||'').trim()]);
|
||||
res.redirect('/catalog?msg=Location+added&msg_type=success');
|
||||
});
|
||||
|
||||
router.post('/locations/:id/delete', async (req, res) => {
|
||||
const cnt = await db.getAsync('SELECT COUNT(*) as c FROM categories WHERE location_id = ?', [req.params.id]);
|
||||
if (cnt && cnt.c > 0) return res.redirect('/catalog?msg=Has+categories&msg_type=error');
|
||||
await db.runAsync('DELETE FROM locations WHERE id = ?', [req.params.id]);
|
||||
const c = await db.getAsync('SELECT COUNT(*) as n FROM categories WHERE location_id=?', [req.params.id]);
|
||||
if (c?.n > 0) return res.redirect('/catalog?msg=Cannot+delete+has+categories&msg_type=error');
|
||||
await db.runAsync('DELETE FROM locations WHERE id=?', [req.params.id]);
|
||||
res.redirect('/catalog?msg=Location+deleted&msg_type=success');
|
||||
});
|
||||
|
||||
router.post('/locations/add-city', async (req, res) => {
|
||||
const { country, city } = req.body;
|
||||
if (!country || !city) return res.redirect('/catalog?msg=Country+and+city+required&msg_type=error');
|
||||
await db.runAsync('INSERT INTO locations (country,city,district) VALUES (?,?,?)',
|
||||
[country.trim(), city.trim(), '']);
|
||||
res.redirect('/catalog?msg=City+added&msg_type=success');
|
||||
});
|
||||
|
||||
router.post('/locations/add-district', async (req, res) => {
|
||||
const { country, city, district } = req.body;
|
||||
if (!country || !city || !district) return res.redirect('/catalog?msg=All+fields+required&msg_type=error');
|
||||
await db.runAsync('INSERT INTO locations (country,city,district) VALUES (?,?,?)',
|
||||
[country.trim(), city.trim(), district.trim()]);
|
||||
res.redirect('/catalog?msg=District+added&msg_type=success');
|
||||
});
|
||||
|
||||
router.post('/categories', async (req, res) => {
|
||||
const { name, location_id } = req.body;
|
||||
if (!name) return res.redirect('/catalog?msg=Name+required&msg_type=error');
|
||||
try {
|
||||
await db.runAsync('INSERT INTO categories (name, location_id) VALUES (?, ?)', [name.trim(), location_id || null]);
|
||||
res.redirect('/catalog?msg=Category+added&msg_type=success');
|
||||
} catch (e) {
|
||||
res.redirect('/catalog?msg=' + encodeURIComponent(e.message) + '&msg_type=error');
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/categories/:id/update', async (req, res) => {
|
||||
const { name, location_id } = req.body;
|
||||
await db.runAsync('UPDATE categories SET name = ?, location_id = ? WHERE id = ?',
|
||||
[name.trim(), location_id || null, req.params.id]);
|
||||
res.redirect('/catalog?msg=Category+updated&msg_type=success');
|
||||
if (!name || !location_id) return res.redirect('/catalog?msg=Name+and+location+required&msg_type=error');
|
||||
await db.runAsync('INSERT INTO categories (name,location_id) VALUES (?,?)', [name.trim(), location_id]);
|
||||
res.redirect('/catalog?msg=Category+added&msg_type=success');
|
||||
});
|
||||
|
||||
router.post('/categories/:id/delete', async (req, res) => {
|
||||
const cnt = await db.getAsync('SELECT COUNT(*) as c FROM products WHERE category_id = ?', [req.params.id]);
|
||||
if (cnt && cnt.c > 0) return res.redirect('/catalog?msg=Has+products&msg_type=error');
|
||||
await db.runAsync('DELETE FROM subcategories WHERE category_id = ?', [req.params.id]);
|
||||
await db.runAsync('DELETE FROM categories WHERE id = ?', [req.params.id]);
|
||||
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');
|
||||
await db.runAsync('DELETE FROM subcategories WHERE category_id=?', [req.params.id]);
|
||||
await db.runAsync('DELETE FROM categories WHERE id=?', [req.params.id]);
|
||||
res.redirect('/catalog?msg=Category+deleted&msg_type=success');
|
||||
});
|
||||
|
||||
router.post('/categories/:id/subcategories', async (req, res) => {
|
||||
const { name } = req.body;
|
||||
if (!name) return res.redirect('/catalog?msg=Name+required&msg_type=error');
|
||||
try {
|
||||
await db.runAsync('INSERT INTO subcategories (category_id, name) VALUES (?, ?)', [req.params.id, name.trim()]);
|
||||
res.redirect('/catalog?msg=Subcategory+added&msg_type=success');
|
||||
} catch (e) {
|
||||
res.redirect('/catalog?msg=' + encodeURIComponent(e.message) + '&msg_type=error');
|
||||
}
|
||||
await db.runAsync('INSERT INTO subcategories (category_id,name) VALUES (?,?)', [req.params.id, name.trim()]);
|
||||
res.redirect('/catalog?msg=Subcategory+added&msg_type=success');
|
||||
});
|
||||
|
||||
router.post('/subcategories/:id/delete', async (req, res) => {
|
||||
const cnt = await db.getAsync('SELECT COUNT(*) as c FROM products WHERE subcategory_id = ?', [req.params.id]);
|
||||
if (cnt && cnt.c > 0) return res.redirect('/catalog?msg=Has+products&msg_type=error');
|
||||
await db.runAsync('DELETE FROM subcategories WHERE id = ?', [req.params.id]);
|
||||
const c = await db.getAsync('SELECT COUNT(*) as n FROM products WHERE subcategory_id=?', [req.params.id]);
|
||||
if (c?.n > 0) return res.redirect('/catalog?msg=Cannot+delete+has+products&msg_type=error');
|
||||
await db.runAsync('DELETE FROM subcategories WHERE id=?', [req.params.id]);
|
||||
res.redirect('/catalog?msg=Subcategory+deleted&msg_type=success');
|
||||
});
|
||||
|
||||
router.post('/products', async (req, res) => {
|
||||
const { name, price, quantity_in_stock, description, photo_url, category_id, subcategory_id } = req.body;
|
||||
if (!name || !price || !category_id) return res.redirect('/catalog?msg=Name+price+category+required&msg_type=error');
|
||||
try {
|
||||
const locRow = await db.getAsync('SELECT location_id FROM categories WHERE id = ?', [category_id]);
|
||||
await db.runAsync(
|
||||
`INSERT INTO products (name, price, quantity_in_stock, description, photo_url, category_id, subcategory_id, location_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[name.trim(), parseFloat(price), parseInt(quantity_in_stock) || 0,
|
||||
description || '', photo_url || '', category_id, subcategory_id || null, locRow?.location_id || null]);
|
||||
res.redirect('/catalog?msg=Product+added&msg_type=success');
|
||||
} catch (e) {
|
||||
res.redirect('/catalog?msg=' + encodeURIComponent(e.message) + '&msg_type=error');
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/products/:id/delete', async (req, res) => {
|
||||
await db.runAsync('DELETE FROM products WHERE id = ?', [req.params.id]);
|
||||
res.redirect('/catalog?msg=Product+deleted&msg_type=success');
|
||||
});
|
||||
|
||||
export default router;
|
||||
export default router;
|
||||
|
||||
56
src/admin/routes/catalogProducts.js
Normal file
56
src/admin/routes/catalogProducts.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Router } from 'express';
|
||||
import multer from 'multer';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import db from '../../config/database.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const uploadsDir = join(__dirname, '..', '..', '..', 'uploads');
|
||||
const storage = multer.diskStorage({
|
||||
destination: uploadsDir,
|
||||
filename: (req, file, cb) => cb(null, `${Date.now()}-${file.originalname}`)
|
||||
});
|
||||
const upload = multer({ storage, limits: { fileSize: 10 * 1024 * 1024 } });
|
||||
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;
|
||||
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]);
|
||||
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]);
|
||||
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;
|
||||
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]);
|
||||
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]);
|
||||
res.redirect('/catalog?msg=Product+updated&msg_type=success');
|
||||
});
|
||||
|
||||
router.post('/products/:id/delete', async (req, res) => {
|
||||
await db.runAsync('DELETE FROM products WHERE id=?', [req.params.id]);
|
||||
res.redirect('/catalog?msg=Product+deleted&msg_type=success');
|
||||
});
|
||||
|
||||
router.get('/products/:id/json', async (req, res) => {
|
||||
const p = await db.getAsync('SELECT * FROM products WHERE id=?', [req.params.id]);
|
||||
if (!p) return res.status(404).json({ error: 'Not found' });
|
||||
res.json(p);
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -6,6 +6,7 @@ import logger from '../utils/logger.js';
|
||||
import { requireAuth, handleLogin, handleLogout, renderLogin } from './auth.js';
|
||||
import dashboardRouter from './routes/dashboard.js';
|
||||
import catalogRouter from './routes/catalog.js';
|
||||
import catalogProductsRouter from './routes/catalogProducts.js';
|
||||
import usersRouter from './routes/users.js';
|
||||
import productsRouter from './routes/products.js';
|
||||
import walletsRouter from './routes/wallets.js';
|
||||
@@ -23,6 +24,7 @@ const app = express();
|
||||
app.use(cookieParser());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use('/admin/style.css', express.static(join(__dirname, 'public', 'style.css')));
|
||||
app.use('/uploads', express.static(join(__dirname, '..', '..', 'uploads')));
|
||||
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', uptime: process.uptime() });
|
||||
@@ -39,6 +41,7 @@ app.use(requireAuth);
|
||||
|
||||
app.use('/', dashboardRouter);
|
||||
app.use('/catalog', catalogRouter);
|
||||
app.use('/catalog', catalogProductsRouter);
|
||||
app.use('/users', usersRouter);
|
||||
app.use('/products', productsRouter);
|
||||
app.use('/wallets', walletsRouter);
|
||||
|
||||
@@ -1,200 +1,96 @@
|
||||
import { layout, flash } from './layout.js';
|
||||
import { renderProductEditForm } from './catalogProduct.js';
|
||||
|
||||
export function renderCatalog(locations, categories, subcategories, products, msg, msgType) {
|
||||
const subByCat = {};
|
||||
for (const s of subcategories) {
|
||||
if (!subByCat[s.category_id]) subByCat[s.category_id] = [];
|
||||
subByCat[s.category_id].push(s);
|
||||
}
|
||||
const prodsBySubcat = {};
|
||||
const prodsByCat = {};
|
||||
for (const p of products) {
|
||||
const key = p.subcategory_id || p.category_id;
|
||||
if (p.subcategory_id) {
|
||||
if (!prodsBySubcat[p.subcategory_id]) prodsBySubcat[p.subcategory_id] = [];
|
||||
prodsBySubcat[p.subcategory_id].push(p);
|
||||
}
|
||||
if (!prodsByCat[p.category_id]) prodsByCat[p.category_id] = [];
|
||||
prodsByCat[p.category_id].push(p);
|
||||
}
|
||||
export function renderCatalog(tree, products, filter, categories, subcategories, msg, msgType) {
|
||||
const { loc, cat, sub } = filter;
|
||||
const catOptions = categories.map(c => `<option value="${c.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 addFormHtml = renderProductEditForm('/catalog/products', catOptions, subcatJson)
|
||||
.replace(/`/g, '\\`').replace(/\$/g, '\\$');
|
||||
const editFormHtml = renderProductEditForm('/catalog/products/__ID__/edit', catOptions, subcatJson)
|
||||
.replace(/`/g, '\\`').replace(/\$/g, '\\$');
|
||||
|
||||
const catsByLoc = {};
|
||||
for (const c of categories) {
|
||||
if (!catsByLoc[c.location_id]) catsByLoc[c.location_id] = [];
|
||||
catsByLoc[c.location_id].push(c);
|
||||
}
|
||||
|
||||
const locOptions = locations.map(l =>
|
||||
`<option value="${l.id}">${esc(l.country)} / ${esc(l.city)}${l.district ? ' / ' + esc(l.district) : ''}</option>`
|
||||
).join('');
|
||||
|
||||
const catOptions = categories.map(c =>
|
||||
`<option value="${c.id}">${esc(c.name)} (${esc(c.city || 'No location')})</option>`
|
||||
).join('');
|
||||
|
||||
let treeHtml = '';
|
||||
if (locations.length === 0) {
|
||||
treeHtml = '<p class="muted">No locations yet. Add one above.</p>';
|
||||
}
|
||||
|
||||
for (const loc of locations) {
|
||||
const locCats = catsByLoc[loc.id] || [];
|
||||
const catCount = locCats.length;
|
||||
const prodCount = loc.product_count || 0;
|
||||
|
||||
let catHtml = '';
|
||||
if (catCount === 0) {
|
||||
catHtml = '<p class="muted tree-empty">No categories</p>';
|
||||
}
|
||||
for (const cat of locCats) {
|
||||
const subs = subByCat[cat.id] || [];
|
||||
const catProds = prodsByCat[cat.id] || [];
|
||||
let subHtml = '';
|
||||
for (const sub of subs) {
|
||||
const subProds = prodsBySubcat[sub.id] || [];
|
||||
let prodHtml = '';
|
||||
for (const p of subProds) {
|
||||
prodHtml += `<div class="tree-item tree-product">
|
||||
<span class="tree-name">${esc(p.name)}</span>
|
||||
<span class="tree-meta">$${(p.price || 0).toFixed(2)} · ${p.quantity_in_stock || 0} in stock</span>
|
||||
<form method="POST" action="/catalog/products/${p.id}/delete" style="display:inline" onsubmit="return confirm('Delete product?')">
|
||||
<button class="btn-sm btn-danger">✕</button>
|
||||
</form>
|
||||
</div>`;
|
||||
let treeHtml = '<div class="tree-node"><div class="tree-toggle" data-all="1"><span class="arrow">▶</span> <strong>All Products</strong><span class="tree-count">(' + products.length + ')</span></div></div>';
|
||||
for (const [country, cdata] of Object.entries(tree)) {
|
||||
let countryCount = 0, cityHtml = '';
|
||||
for (const [city, ddata] of Object.entries(cdata.cities)) {
|
||||
let cityCount = 0, districtHtml = '';
|
||||
for (const [district, ldata] of Object.entries(ddata.districts)) {
|
||||
let districtCount = 0, catHtml = '';
|
||||
for (const c of ldata.cats) {
|
||||
const catCount = (c.pc||0) + (c.subs||[]).reduce((a,s)=>a+(s.pc||0),0);
|
||||
districtCount += catCount;
|
||||
let subHtml = '';
|
||||
for (const s of (c.subs||[])) {
|
||||
subHtml += `<div class="tree-node"><div class="tree-toggle" data-sub="${s.id}"><span class="arrow">▶</span> ${esc(s.name)}<span class="tree-count">(${s.pc||0})</span><span class="tree-actions"><form method="POST" action="/catalog/subcategories/${s.id}/delete" onsubmit="return confirm('Delete?')"><button class="btn-sm btn-danger">✕</button></form></span></div><div class="tree-children"><form method="POST" action="/catalog/categories/${c.id}/subcategories" class="inline-form tree-add"><input name="name" placeholder="+ Subcategory" required size="12"><button class="btn-sm">Add</button></form></div></div>`;
|
||||
}
|
||||
catHtml += `<div class="tree-node"><div class="tree-toggle" data-cat="${c.id}"><span class="arrow">▶</span> ${esc(c.name)}<span class="tree-count">(${catCount})</span><span class="tree-actions"><form method="POST" action="/catalog/categories/${c.id}/delete" onsubmit="return confirm('Delete?')"><button class="btn-sm btn-danger">✕</button></form></span></div><div class="tree-children">${subHtml}<form method="POST" action="/catalog/categories/${c.id}/subcategories" class="inline-form tree-add"><input name="name" placeholder="+ Subcategory" required size="12"><button class="btn-sm">Add</button></form></div></div>`;
|
||||
}
|
||||
subHtml += `<div class="tree-node">
|
||||
<div class="tree-label">
|
||||
<span class="tree-icon">📁</span>
|
||||
<span>${esc(sub.name)}</span>
|
||||
<span class="tree-meta">${subProds.length} products</span>
|
||||
<form method="POST" action="/catalog/subcategories/${sub.id}/delete" style="display:inline" onsubmit="return confirm('Delete subcategory?')">
|
||||
<button class="btn-sm btn-danger">✕</button>
|
||||
</form>
|
||||
</div>
|
||||
${prodHtml}
|
||||
</div>`;
|
||||
districtHtml += `<div class="tree-node"><div class="tree-toggle" data-loc="${ldata.id}"><span class="arrow">▶</span> ${esc(district)}<span class="tree-count">(${districtCount})</span><span class="tree-actions"><form method="POST" action="/catalog/locations/${ldata.id}/delete" onsubmit="return confirm('Delete?')"><button class="btn-sm btn-danger">✕</button></form></span></div><div class="tree-children">${catHtml}<form method="POST" action="/catalog/categories" class="inline-form tree-add"><input type="hidden" name="location_id" value="${ldata.id}"><input name="name" placeholder="+ Category" required size="12"><button class="btn-sm">Add</button></form></div></div>`;
|
||||
cityCount += districtCount;
|
||||
}
|
||||
|
||||
let directProds = '';
|
||||
const directP = catProds.filter(p => !p.subcategory_id);
|
||||
for (const p of directP) {
|
||||
directProds += `<div class="tree-item tree-product">
|
||||
<span class="tree-name">${esc(p.name)}</span>
|
||||
<span class="tree-meta">$${(p.price || 0).toFixed(2)} · ${p.quantity_in_stock || 0} in stock</span>
|
||||
<form method="POST" action="/catalog/products/${p.id}/delete" style="display:inline" onsubmit="return confirm('Delete product?')">
|
||||
<button class="btn-sm btn-danger">✕</button>
|
||||
</form>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
catHtml += `<div class="tree-node">
|
||||
<div class="tree-label">
|
||||
<span class="tree-icon">📂</span>
|
||||
<span>${esc(cat.name)}</span>
|
||||
<span class="tree-meta">${catProds.length} products · ${subs.length} subcats</span>
|
||||
<form method="POST" action="/catalog/categories/${cat.id}/delete" style="display:inline" onsubmit="return confirm('Delete category and all subcategories?')">
|
||||
<button class="btn-sm btn-danger">✕</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="tree-children">
|
||||
${subHtml}
|
||||
${directProds}
|
||||
<form method="POST" action="/catalog/categories/${cat.id}/subcategories" class="inline-form tree-add">
|
||||
<input name="name" placeholder="+ Subcategory" required size="12">
|
||||
<button class="btn-sm">Add</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>`;
|
||||
cityHtml += `<div class="tree-node"><div class="tree-toggle" data-city="${city}" data-country="${esc(country)}"><span class="arrow">▶</span> ${esc(city)}<span class="tree-count">(${cityCount})</span><span class="tree-actions"><form method="POST" action="/catalog/locations/add-district" class="inline-form tree-add"><input type="hidden" name="country" value="${esc(country)}"><input type="hidden" name="city" value="${esc(city)}"><input name="district" placeholder="+ District" required size="12"><button class="btn-sm">Add</button></form></span></div><div class="tree-children">${districtHtml}</div></div>`;
|
||||
countryCount += cityCount;
|
||||
}
|
||||
treeHtml += `<div class="tree-node"><div class="tree-toggle" data-country="${esc(country)}"><span class="arrow">▶</span> <strong>${esc(country)}</strong><span class="tree-count">(${countryCount})</span><span class="tree-actions"><form method="POST" action="/catalog/locations/add-city" class="inline-form tree-add"><input type="hidden" name="country" value="${esc(country)}"><input name="city" placeholder="+ City" required size="10"><button class="btn-sm">Add</button></form></span></div><div class="tree-children">${cityHtml}</div></div>`;
|
||||
}
|
||||
|
||||
treeHtml += `<div class="tree-node tree-location">
|
||||
<div class="tree-label">
|
||||
<span class="tree-icon">🌍</span>
|
||||
<span><strong>${esc(loc.country)}</strong> · ${esc(loc.city)}${loc.district ? ' · ' + esc(loc.district) : ''}</span>
|
||||
<span class="tree-meta">${catCount} categories · ${prodCount} products</span>
|
||||
<form method="POST" action="/catalog/locations/${loc.id}/delete" style="display:inline" onsubmit="return confirm('Delete location?')">
|
||||
<button class="btn-sm btn-danger">✕</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="tree-children">${catHtml}</div>
|
||||
</div>`;
|
||||
let tableHtml = '<p class="muted">No products found.</p>';
|
||||
if (products.length) {
|
||||
tableHtml = '<table><thead><tr><th>ID</th><th>Photo</th><th>Name</th><th>Category</th><th>Subcategory</th><th>Price</th><th>Stock</th><th>Actions</th></tr></thead><tbody>';
|
||||
for (const p of products) {
|
||||
const img = p.photo_url ? `<img src="${esc(p.photo_url)}" width="40" height="40" style="object-fit:cover;border-radius:4px">` : '<span class="muted">—</span>';
|
||||
tableHtml += `<tr><td>${p.id}</td><td>${img}</td><td>${esc(p.name)}</td><td>${esc(p.cn||'')}</td><td>${esc(p.sn||'')}</td><td>$${(p.price||0).toFixed(2)}</td><td>${p.quantity_in_stock||0}</td><td><button class="btn-sm" onclick="openEdit(${p.id})">✎</button><form method="POST" action="/catalog/products/${p.id}/delete" style="display:inline" onsubmit="return confirm('Delete?')"><button class="btn-sm btn-danger">✕</button></form></td></tr>`;
|
||||
}
|
||||
tableHtml += '</tbody></table>';
|
||||
}
|
||||
|
||||
const content = `${flash(msg, msgType)}
|
||||
<div class="catalog-grid">
|
||||
<div class="catalog-tree">
|
||||
<h2>Catalog Tree</h2>
|
||||
${treeHtml}
|
||||
</div>
|
||||
<div class="catalog-forms">
|
||||
<details class="form-section" open>
|
||||
<summary>+ Add Location</summary>
|
||||
<form method="POST" action="/catalog/locations" class="inline-form">
|
||||
<input name="country" placeholder="Country" required>
|
||||
<input name="city" placeholder="City" required>
|
||||
<input name="district" placeholder="District">
|
||||
<button type="submit" class="btn btn-sm">Add</button>
|
||||
</form>
|
||||
</details>
|
||||
<details class="form-section">
|
||||
<summary>+ Add Category</summary>
|
||||
<form method="POST" action="/catalog/categories" class="inline-form">
|
||||
<input name="name" placeholder="Category name" required>
|
||||
<select name="location_id" required>
|
||||
<option value="">-- Location --</option>
|
||||
${locOptions}
|
||||
</select>
|
||||
<button type="submit" class="btn btn-sm">Add</button>
|
||||
</form>
|
||||
</details>
|
||||
<details class="form-section">
|
||||
<summary>+ Add Product</summary>
|
||||
<form method="POST" action="/catalog/products" class="form">
|
||||
<input name="name" placeholder="Product name" required>
|
||||
<div class="form-row">
|
||||
<input name="price" type="number" step="0.01" placeholder="Price" required>
|
||||
<input name="quantity_in_stock" type="number" placeholder="Stock" value="0">
|
||||
</div>
|
||||
<input name="description" placeholder="Description">
|
||||
<input name="photo_url" placeholder="Photo URL">
|
||||
<select name="category_id" required id="cat-select-catalog">
|
||||
<option value="">-- Category --</option>
|
||||
${catOptions}
|
||||
</select>
|
||||
<select name="subcategory_id" id="subcat-select-catalog">
|
||||
<option value="">-- Subcategory (optional) --</option>
|
||||
</select>
|
||||
<button type="submit" class="btn">Add Product</button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const subcatData = JSON.stringify(
|
||||
subcategories.map(s => ({ id: s.id, name: s.name, category_id: s.category_id }))
|
||||
);
|
||||
|
||||
return layout('Catalog', content, 'catalog') + `
|
||||
<div class="catalog-layout">
|
||||
<div class="catalog-tree">
|
||||
<h2>Catalog</h2>
|
||||
<details class="form-section"><summary>+ Add Location</summary>
|
||||
<form method="POST" action="/catalog/locations" class="inline-form">
|
||||
<input name="country" placeholder="Country" required><input name="city" placeholder="City" required><input name="district" placeholder="District"><button class="btn-sm">Add</button>
|
||||
</form>
|
||||
</details>
|
||||
${treeHtml}
|
||||
</div>
|
||||
<div class="catalog-main">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
|
||||
<h2>Products</h2>
|
||||
<button class="btn" onclick="openAdd()">+ Add Product</button>
|
||||
</div>
|
||||
${tableHtml}
|
||||
</div>
|
||||
</div>
|
||||
<div id="product-modal" class="modal" style="display:none"><div class="modal-content" id="modal-body"></div></div>
|
||||
<script>
|
||||
const allSubcats = ${subcatData};
|
||||
const catSel = document.getElementById('cat-select-catalog');
|
||||
const subSel = document.getElementById('subcat-select-catalog');
|
||||
if (catSel && subSel) {
|
||||
catSel.addEventListener('change', () => {
|
||||
const catId = catSel.value;
|
||||
subSel.innerHTML = '<option value="">-- Subcategory (optional) --</option>';
|
||||
allSubcats.forEach(s => {
|
||||
if (s.category_id == catId) {
|
||||
const o = document.createElement('option');
|
||||
o.value = s.id; o.textContent = s.name;
|
||||
subSel.appendChild(o);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
const subcats = ${subcatJson};
|
||||
const addFormTpl = \`${addFormHtml}\`;
|
||||
const editFormTpl = \`${editFormHtml}\`;
|
||||
document.querySelectorAll('.tree-toggle').forEach(el=>{el.addEventListener('click',()=>{
|
||||
const ch=el.nextElementSibling; if(!ch||!ch.classList.contains('tree-children')) return;
|
||||
ch.classList.toggle('open'); el.querySelector('.arrow').classList.toggle('open');
|
||||
const loc=el.dataset.loc, cat=el.dataset.cat, sub=el.dataset.sub, all=el.dataset.all;
|
||||
if(loc||cat||sub||all){ let u='/catalog?'; if(loc) u+='loc='+loc; if(cat) u+='cat='+cat; if(sub) u+='sub='+sub; location.href=u; }
|
||||
})});
|
||||
function openAdd(){ document.getElementById('modal-body').innerHTML=addFormTpl; document.getElementById('product-modal').style.display='flex'; }
|
||||
async function openEdit(id){ const r=await fetch('/catalog/products/'+id+'/json'); const p=await r.json();
|
||||
document.getElementById('modal-body').innerHTML=editFormTpl.replace('/__ID__/','/'+p.id+'/'); fillEditForm(p); document.getElementById('product-modal').style.display='flex'; }
|
||||
function fillEditForm(p){ const f=document.getElementById('product-modal').querySelector('form'); if(!f)return;
|
||||
f.querySelector('[name=name]').value=p.name||''; f.querySelector('[name=price]').value=p.price||'';
|
||||
f.querySelector('[name=quantity_in_stock]').value=p.quantity_in_stock||''; f.querySelector('[name=description]').value=p.description||'';
|
||||
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||''; f.querySelector('[name=category_id]').value=p.category_id||'';
|
||||
updateSubcats(p.category_id,p.subcategory_id); }
|
||||
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)}}); }
|
||||
document.getElementById('product-modal').addEventListener('click',e=>{if(e.target===document.getElementById('product-modal'))document.getElementById('product-modal').style.display='none'});
|
||||
document.addEventListener('change',e=>{if(e.target.name==='category_id'&&e.target.closest('#product-modal'))updateSubcats(e.target.value);});
|
||||
</script>`;
|
||||
return layout('Catalog', content, 'catalog');
|
||||
}
|
||||
|
||||
function esc(str) {
|
||||
return String(str || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
function esc(str) { return String(str||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
|
||||
28
src/admin/views/catalogProduct.js
Normal file
28
src/admin/views/catalogProduct.js
Normal file
@@ -0,0 +1,28 @@
|
||||
export function renderProductEditForm(action, catOptions, subcatJson) {
|
||||
const isEdit = action.includes('/edit');
|
||||
const title = isEdit ? 'Edit Product' : 'Add Product';
|
||||
return `<h2>${title}</h2>
|
||||
<form method="POST" action="${action}" enctype="multipart/form-data" class="form">
|
||||
<label>Name <input name="name" required></label>
|
||||
<div class="form-row">
|
||||
<label>Price <input name="price" type="number" step="0.01" required></label>
|
||||
<label>Stock <input name="quantity_in_stock" type="number" value="0"></label>
|
||||
</div>
|
||||
<label>Description <textarea name="description"></textarea></label>
|
||||
<label>Category <select name="category_id" required onchange="updateSubcats(this.value)">
|
||||
<option value="">-- Select --</option>${catOptions}
|
||||
</select></label>
|
||||
<label>Subcategory <select name="subcategory_id"><option value="">-- Subcategory --</option></select></label>
|
||||
<label>Photo URL <input name="photo_url" placeholder="https://... or upload below"></label>
|
||||
<label>Photo File <input type="file" name="photo_file" accept="image/*"></label>
|
||||
<label>Hidden Photo URL <input name="hidden_photo_url" placeholder="https://... or upload below"></label>
|
||||
<label>Hidden Photo File <input type="file" name="hidden_photo_file" accept="image/*"></label>
|
||||
<label>Hidden Coordinates <input name="hidden_coordinates" placeholder="lat,lng or address"></label>
|
||||
<label>Hidden Description <textarea name="hidden_description"></textarea></label>
|
||||
<label>Private Data <textarea name="private_data"></textarea></label>
|
||||
<div style="display:flex;gap:0.5rem">
|
||||
<button type="submit" class="btn">Save</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="document.getElementById('product-modal').style.display='none'">Cancel</button>
|
||||
</div>
|
||||
</form>`;
|
||||
}
|
||||
Reference in New Issue
Block a user