diff --git a/docker-compose.yml b/docker-compose.yml index 69363af..232fc4b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/package-lock.json b/package-lock.json index 845cf9f..adac72b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index ac8906d..4a34ac2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/admin/public/style.css b/src/admin/public/style.css index fd3a118..c20484d 100644 --- a/src/admin/public/style.css +++ b/src/admin/public/style.css @@ -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; } } diff --git a/src/admin/routes/catalog.js b/src/admin/routes/catalog.js index 589596b..8c40971 100644 --- a/src/admin/routes/catalog.js +++ b/src/admin/routes/catalog.js @@ -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; \ No newline at end of file +export default router; diff --git a/src/admin/routes/catalogProducts.js b/src/admin/routes/catalogProducts.js new file mode 100644 index 0000000..718ddeb --- /dev/null +++ b/src/admin/routes/catalogProducts.js @@ -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; diff --git a/src/admin/server.js b/src/admin/server.js index c06b880..9e97ded 100644 --- a/src/admin/server.js +++ b/src/admin/server.js @@ -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); diff --git a/src/admin/views/catalog.js b/src/admin/views/catalog.js index 4087f23..a1d23b8 100644 --- a/src/admin/views/catalog.js +++ b/src/admin/views/catalog.js @@ -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 => ``).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 => - `` - ).join(''); - - const catOptions = categories.map(c => - `` - ).join(''); - - let treeHtml = ''; - if (locations.length === 0) { - treeHtml = '
No locations yet. Add one above.
'; - } - - 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 = 'No categories
'; - } - 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 += `No products found.
'; + if (products.length) { + tableHtml = '| ID | Photo | Name | Category | Subcategory | Price | Stock | Actions |
|---|---|---|---|---|---|---|---|
| ${p.id} | ${img} | ${esc(p.name)} | ${esc(p.cn||'')} | ${esc(p.sn||'')} | $${(p.price||0).toFixed(2)} | ${p.quantity_in_stock||0} |