diff --git a/src/admin/public/style.css b/src/admin/public/style.css index c9dfb75..fd3a118 100644 --- a/src/admin/public/style.css +++ b/src/admin/public/style.css @@ -230,10 +230,98 @@ 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 { + display: grid; + grid-template-columns: 1fr 360px; + gap: 1.5rem; + align-items: start; +} + +@media (max-width: 900px) { + .catalog-grid { grid-template-columns: 1fr; } +} + +.catalog-tree h2 { margin-bottom: 0.75rem; } + +.catalog-forms { position: sticky; top: 1rem; } + +.tree-node { margin-left: 0; } +.tree-location > .tree-children { margin-left: 0; } + +.tree-children { + margin-left: 1.5rem; + border-left: 2px solid var(--border); + padding-left: 0.75rem; +} + +.tree-label { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.35rem 0; + flex-wrap: wrap; +} + +.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; +} + +.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; +} + +.form-row { + display: flex; + gap: 0.5rem; +} + +.form-row input { flex: 1; } + @media (max-width: 640px) { .topnav { flex-direction: column; align-items: flex-start; } .logout-btn { margin-left: 0; } .stats-grid { grid-template-columns: 1fr 1fr; } table { font-size: 0.8rem; } th, td { padding: 0.4rem; } + .catalog-grid { grid-template-columns: 1fr; } } diff --git a/src/admin/routes/catalog.js b/src/admin/routes/catalog.js new file mode 100644 index 0000000..589596b --- /dev/null +++ b/src/admin/routes/catalog.js @@ -0,0 +1,113 @@ +import { Router } from 'express'; +import db from '../../config/database.js'; +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`), + ]); + res.send(renderCatalog(locations, categories, subcategories, products, msg, msgType)); +}); + +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'); + } +}); + +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]); + res.redirect('/catalog?msg=Location+deleted&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'); +}); + +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]); + 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'); + } +}); + +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]); + 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 diff --git a/src/admin/routes/categories.js b/src/admin/routes/categories.js index eed4343..a5e336d 100644 --- a/src/admin/routes/categories.js +++ b/src/admin/routes/categories.js @@ -5,13 +5,16 @@ import { renderCategoryList } from '../views/categories.js'; const router = Router(); router.get('/', async (req, res) => { - const [categories, locations] = await Promise.all([ + const [categories, locations, subcategories] = await Promise.all([ db.allAsync(`SELECT c.*, l.country, l.city, l.district, (SELECT COUNT(*) FROM products WHERE category_id = c.id) as product_count FROM categories c LEFT JOIN locations l ON c.location_id = l.id ORDER BY c.id`), db.allAsync('SELECT id, country, city, district FROM locations ORDER BY country, city'), + db.allAsync(`SELECT s.*, + (SELECT COUNT(*) FROM products WHERE subcategory_id = s.id) as product_count + FROM subcategories s ORDER BY s.category_id, s.name`), ]); - res.send(renderCategoryList(categories, locations)); + res.send(renderCategoryList(categories, locations, subcategories)); }); router.post('/', async (req, res) => { @@ -36,4 +39,23 @@ router.post('/:id/delete', async (req, res) => { res.redirect('/categories'); }); +router.post('/:id/subcategories', async (req, res) => { + const { name } = req.body; + await db.runAsync('INSERT INTO subcategories (category_id, name) VALUES (?, ?)', + [req.params.id, name]); + res.redirect('/categories'); +}); + +router.post('/subcategories/:id/delete', async (req, res) => { + const count = await db.getAsync( + 'SELECT COUNT(*) as cnt FROM products WHERE subcategory_id = ?', + [req.params.id] + ); + if (count && count.cnt > 0) { + return res.redirect('/categories?error=Cannot+delete+subcategory+with+products'); + } + await db.runAsync('DELETE FROM subcategories WHERE id = ?', [req.params.id]); + res.redirect('/categories'); +}); + export default router; diff --git a/src/admin/routes/dashboard.js b/src/admin/routes/dashboard.js index 63aae0f..797022c 100644 --- a/src/admin/routes/dashboard.js +++ b/src/admin/routes/dashboard.js @@ -5,18 +5,19 @@ import { renderDashboard } from '../views/dashboard.js'; const router = Router(); router.get('/', async (req, res) => { - const [[{ totalUsers }], [{ totalProducts }], [{ totalPurchases }], [{ totalRevenue }]] = await Promise.all([ + const [[{ totalUsers }], [{ totalProducts }], [{ totalPurchases }], [{ totalRevenue }], [{ totalSubcategories }]] = await Promise.all([ db.allAsync('SELECT COUNT(*) as totalUsers FROM users'), db.allAsync('SELECT COUNT(*) as totalProducts FROM products'), db.allAsync('SELECT COUNT(*) as totalPurchases FROM purchases'), db.allAsync('SELECT COALESCE(SUM(total_price), 0) as totalRevenue FROM purchases WHERE status = ?', ['completed']), + db.allAsync('SELECT COUNT(*) as totalSubcategories FROM subcategories'), ]); let message = ''; if (req.query.seeded) message = 'Demo data seeded successfully!'; if (req.query.cleared) message = 'All data cleared successfully!'; - res.send(renderDashboard({ totalUsers, totalProducts, totalPurchases, totalRevenue }, message)); + res.send(renderDashboard({ totalUsers, totalProducts, totalPurchases, totalRevenue, totalSubcategories }, message)); }); export default router; diff --git a/src/admin/routes/locations.js b/src/admin/routes/locations.js new file mode 100644 index 0000000..7e6dd5f --- /dev/null +++ b/src/admin/routes/locations.js @@ -0,0 +1,36 @@ +import { Router } from 'express'; +import db from '../../config/database.js'; +import { renderLocationList } from '../views/locations.js'; + +const router = Router(); + +router.get('/', async (req, res) => { + const locations = await 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`); + res.send(renderLocationList(locations)); +}); + +router.post('/', async (req, res) => { + const { country, city, district } = req.body; + await db.runAsync( + 'INSERT INTO locations (country, city, district) VALUES (?, ?, ?)', + [country, city, district || ''] + ); + res.redirect('/locations'); +}); + +router.post('/:id/delete', async (req, res) => { + const count = await db.getAsync( + 'SELECT COUNT(*) as cnt FROM categories WHERE location_id = ?', + [req.params.id] + ); + if (count && count.cnt > 0) { + return res.redirect('/locations?error=Cannot+delete+location+with+categories'); + } + await db.runAsync('DELETE FROM locations WHERE id = ?', [req.params.id]); + res.redirect('/locations'); +}); + +export default router; diff --git a/src/admin/routes/products.js b/src/admin/routes/products.js index d91f118..5c73a95 100644 --- a/src/admin/routes/products.js +++ b/src/admin/routes/products.js @@ -5,39 +5,44 @@ import { renderProductList, renderProductEdit } from '../views/products.js'; const router = Router(); router.get('/', async (req, res) => { - const [products, categories] = await Promise.all([ - db.allAsync(`SELECT p.*, c.name as category_name FROM products p - LEFT JOIN categories c ON p.category_id = c.id ORDER BY p.id DESC LIMIT 100`), + const [products, categories, subcategories] = await Promise.all([ + db.allAsync(`SELECT p.*, c.name as category_name, s.name as subcategory_name + FROM products p + LEFT JOIN categories c ON p.category_id = c.id + LEFT JOIN subcategories s ON p.subcategory_id = s.id + ORDER BY p.id DESC LIMIT 100`), db.allAsync('SELECT id, name FROM categories ORDER BY name'), + db.allAsync('SELECT id, name, category_id FROM subcategories ORDER BY name'), ]); - res.send(renderProductList(products, categories)); + res.send(renderProductList(products, categories, subcategories)); }); router.post('/', async (req, res) => { - const { name, price, quantity_in_stock, description, photo_url, category_id } = req.body; + const { name, price, quantity_in_stock, description, photo_url, category_id, subcategory_id } = req.body; await db.runAsync( - `INSERT INTO products (name, price, quantity_in_stock, description, photo_url, category_id, location_id) - VALUES (?, ?, ?, ?, ?, ?, (SELECT location_id FROM categories WHERE id = ?))`, - [name, price, quantity_in_stock || 0, description || '', photo_url || '', category_id, category_id] + `INSERT INTO products (name, price, quantity_in_stock, description, photo_url, category_id, subcategory_id, location_id) + VALUES (?, ?, ?, ?, ?, ?, ?, (SELECT location_id FROM categories WHERE id = ?))`, + [name, price, quantity_in_stock || 0, description || '', photo_url || '', category_id, subcategory_id || null, category_id] ); res.redirect('/products'); }); router.get('/:id/edit', async (req, res) => { - const [product, categories] = await Promise.all([ + const [product, categories, subcategories] = await Promise.all([ db.getAsync('SELECT * FROM products WHERE id = ?', [req.params.id]), db.allAsync('SELECT id, name FROM categories ORDER BY name'), + db.allAsync('SELECT id, name, category_id FROM subcategories ORDER BY name'), ]); if (!product) return res.status(404).send('Product not found'); - res.send(renderProductEdit(product, categories)); + res.send(renderProductEdit(product, categories, subcategories)); }); router.post('/:id/update', async (req, res) => { - const { name, price, quantity_in_stock, description, photo_url, category_id } = req.body; + const { name, price, quantity_in_stock, description, photo_url, category_id, subcategory_id } = req.body; await db.runAsync( - `UPDATE products SET name=?, price=?, quantity_in_stock=?, description=?, photo_url=?, category_id=? + `UPDATE products SET name=?, price=?, quantity_in_stock=?, description=?, photo_url=?, category_id=?, subcategory_id=? WHERE id=?`, - [name, price, quantity_in_stock || 0, description || '', photo_url || '', category_id, req.params.id] + [name, price, quantity_in_stock || 0, description || '', photo_url || '', category_id, subcategory_id || null, req.params.id] ); res.redirect('/products'); }); diff --git a/src/admin/routes/seed.js b/src/admin/routes/seed.js index f46a237..72f2489 100644 --- a/src/admin/routes/seed.js +++ b/src/admin/routes/seed.js @@ -11,7 +11,7 @@ router.get('/', (req, res) => { router.post('/seed-demo', async (req, res) => { try { const delTables = ['purchases', 'transactions', 'crypto_wallets', 'audit_log', - 'user_states', 'products', 'categories', 'users', 'locations']; + 'user_states', 'products', 'subcategories', 'categories', 'users', 'locations']; for (const t of delTables) { await db.runAsync(`DELETE FROM ${t}`); } @@ -35,6 +35,29 @@ router.post('/seed-demo', async (req, res) => { const catVIP = cats.find(c => c.name === 'VIP').id; const catStandard = cats.find(c => c.name === 'Standard').id; + await db.runAsync(`INSERT INTO subcategories (category_id, name) VALUES + (?, 'VPN'), (?, 'Accounts'), (?, 'Software'), + (?, 'Hardware'), (?, 'Accessories'), + (?, 'Annual'), (?, 'Monthly'), + (?, 'Lifetime'), (?, 'Express'), + (?, 'Basic'), (?, 'Starter')`, + [catDigital, catDigital, catDigital, + catPhysical, catPhysical, + catPremium, catPremium, + catVIP, catVIP, + catStandard, catStandard]); + const subs = await db.allAsync('SELECT id, name, category_id FROM subcategories'); + const subVPN = subs.find(s => s.name === 'VPN' && s.category_id === catDigital).id; + const subAccounts = subs.find(s => s.name === 'Accounts' && s.category_id === catDigital).id; + const subHardware = subs.find(s => s.name === 'Hardware' && s.category_id === catPhysical).id; + const subAnnual = subs.find(s => s.name === 'Annual' && s.category_id === catPremium).id; + const subLifetime = subs.find(s => s.name === 'Lifetime' && s.category_id === catVIP).id; + const subMonthly = subs.find(s => s.name === 'Monthly' && s.category_id === catPremium).id; + const subBasic = subs.find(s => s.name === 'Basic' && s.category_id === catStandard).id; + const subExpress = subs.find(s => s.name === 'Express' && s.category_id === catVIP).id; + const subStarter = subs.find(s => s.name === 'Starter' && s.category_id === catStandard).id; + const subSoftware = subs.find(s => s.name === 'Software' && s.category_id === catDigital).id; + await db.runAsync(`INSERT INTO users (telegram_id, username, country, city, district, status, total_balance, bonus_balance) VALUES ('1001', 'alice', 'Russia', 'Moscow', 'Center', 1, 150.00, 25.00), ('1002', 'bob', 'Russia', 'Moscow', 'Center', 1, 85.50, 10.00), @@ -47,21 +70,21 @@ router.post('/seed-demo', async (req, res) => { const uCharlie = users.find(u => u.username === 'charlie').id; const uDiana = users.find(u => u.username === 'diana').id; - await db.runAsync(`INSERT INTO products (location_id, category_id, name, description, price, quantity_in_stock, photo_url) VALUES - (?, ?, 'VPN Subscription 30d', 'Premium VPN access for 30 days', 9.99, 100, ''), - (?, ?, 'VPN Subscription 90d', 'Premium VPN access for 90 days', 24.99, 50, ''), - (?, ?, 'USB Drive 64GB', 'Encrypted USB drive', 29.99, 25, ''), - (?, ?, 'Premium Account 1 Year', 'Full premium access 12 months', 99.99, 10, ''), - (?, ?, 'VIP Access Lifetime', 'Lifetime VIP membership', 199.99, 5, ''), - (?, ?, 'Premium Account 6 Months', 'Premium access 6 months', 59.99, 20, ''), - (?, ?, 'Standard Package', 'Basic package with essentials', 14.99, 200, ''), - (?, ?, 'Security Toolkit', 'Digital security tools', 49.99, 30, ''), - (?, ?, 'VIP Express Pass', 'Priority VIP 3 months', 39.99, 15, ''), - (?, ?, 'Starter Kit', 'Beginner-friendly package', 4.99, 500, '')`, - [locMoscow, catDigital, locMoscow, catDigital, locMoscow, catPhysical, - locSPb, catPremium, locSPb, catVIP, locSPb, catPremium, - locBerlin, catStandard, locMoscow, catPhysical, - locSPb, catVIP, locBerlin, catStandard]); + await db.runAsync(`INSERT INTO products (location_id, category_id, subcategory_id, name, description, price, quantity_in_stock, photo_url) VALUES + (?, ?, ?, 'VPN Subscription 30d', 'Premium VPN access for 30 days', 9.99, 100, ''), + (?, ?, ?, 'VPN Subscription 90d', 'Premium VPN access for 90 days', 24.99, 50, ''), + (?, ?, ?, 'USB Drive 64GB', 'Encrypted USB drive', 29.99, 25, ''), + (?, ?, ?, 'Premium Account 1 Year', 'Full premium access 12 months', 99.99, 10, ''), + (?, ?, ?, 'VIP Access Lifetime', 'Lifetime VIP membership', 199.99, 5, ''), + (?, ?, ?, 'Premium Account 6 Months', 'Premium access 6 months', 59.99, 20, ''), + (?, ?, ?, 'Standard Package', 'Basic package with essentials', 14.99, 200, ''), + (?, ?, ?, 'Security Toolkit', 'Digital security tools', 49.99, 30, ''), + (?, ?, ?, 'VIP Express Pass', 'Priority VIP 3 months', 39.99, 15, ''), + (?, ?, ?, 'Starter Kit', 'Beginner-friendly package', 4.99, 500, '')`, + [locMoscow, catDigital, subVPN, locMoscow, catDigital, subAccounts, locMoscow, catPhysical, subHardware, + locSPb, catPremium, subAnnual, locSPb, catVIP, subLifetime, locSPb, catPremium, subMonthly, + locBerlin, catStandard, subBasic, locMoscow, catDigital, subSoftware, + locSPb, catVIP, subExpress, locBerlin, catStandard, subStarter]); const prods = await db.allAsync('SELECT id, name FROM products'); const pVPN30 = prods.find(p => p.name.includes('30d')).id; @@ -102,7 +125,7 @@ router.post('/seed-demo', async (req, res) => { router.post('/clear-all', async (req, res) => { try { const tables = ['purchases', 'transactions', 'crypto_wallets', 'audit_log', - 'user_states', 'products', 'categories', 'users', 'locations']; + 'user_states', 'products', 'subcategories', 'categories', 'users', 'locations']; for (const t of tables) { await db.runAsync(`DELETE FROM ${t}`); } @@ -114,4 +137,4 @@ router.post('/clear-all', async (req, res) => { } }); -export default router; \ No newline at end of file +export default router; diff --git a/src/admin/server.js b/src/admin/server.js index 78539ae..c06b880 100644 --- a/src/admin/server.js +++ b/src/admin/server.js @@ -5,6 +5,7 @@ import { dirname, join } from 'path'; 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 usersRouter from './routes/users.js'; import productsRouter from './routes/products.js'; import walletsRouter from './routes/wallets.js'; @@ -13,6 +14,7 @@ import auditRouter from './routes/audit.js'; import settingsRouter from './routes/settings.js'; import categoriesRouter from './routes/categories.js'; import paymentWalletsRouter from './routes/paymentWallets.js'; +import locationsRouter from './routes/locations.js'; import seedRouter from './routes/seed.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -36,6 +38,7 @@ app.get('/logout', handleLogout); app.use(requireAuth); app.use('/', dashboardRouter); +app.use('/catalog', catalogRouter); app.use('/users', usersRouter); app.use('/products', productsRouter); app.use('/wallets', walletsRouter); @@ -43,6 +46,7 @@ app.use('/purchases', purchasesRouter); app.use('/audit', auditRouter); app.use('/settings', settingsRouter); app.use('/categories', categoriesRouter); +app.use('/locations', locationsRouter); app.use('/payment-wallets', paymentWalletsRouter); app.use('/seed', seedRouter); diff --git a/src/admin/views/catalog.js b/src/admin/views/catalog.js new file mode 100644 index 0000000..4087f23 --- /dev/null +++ b/src/admin/views/catalog.js @@ -0,0 +1,200 @@ +import { layout, flash } from './layout.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); + } + + 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 += `
+ ${esc(p.name)} + $${(p.price || 0).toFixed(2)} ยท ${p.quantity_in_stock || 0} in stock +
+ +
+
`; + } + subHtml += `
+
+ ๐Ÿ“ + ${esc(sub.name)} + ${subProds.length} products +
+ +
+
+ ${prodHtml} +
`; + } + + let directProds = ''; + const directP = catProds.filter(p => !p.subcategory_id); + for (const p of directP) { + directProds += `
+ ${esc(p.name)} + $${(p.price || 0).toFixed(2)} ยท ${p.quantity_in_stock || 0} in stock +
+ +
+
`; + } + + catHtml += `
+
+ ๐Ÿ“‚ + ${esc(cat.name)} + ${catProds.length} products ยท ${subs.length} subcats +
+ +
+
+
+ ${subHtml} + ${directProds} +
+ + +
+
+
`; + } + + treeHtml += `
+
+ ๐ŸŒ + ${esc(loc.country)} ยท ${esc(loc.city)}${loc.district ? ' ยท ' + esc(loc.district) : ''} + ${catCount} categories ยท ${prodCount} products +
+ +
+
+
${catHtml}
+
`; + } + + const content = `${flash(msg, msgType)} +
+
+

Catalog Tree

+ ${treeHtml} +
+
+
+ + Add Location +
+ + + + +
+
+
+ + Add Category +
+ + + +
+
+
+ + Add Product +
+ +
+ + +
+ + + + + +
+
+
+
`; + + const subcatData = JSON.stringify( + subcategories.map(s => ({ id: s.id, name: s.name, category_id: s.category_id })) + ); + + return layout('Catalog', content, 'catalog') + ` +`; +} + +function esc(str) { + return String(str || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} \ No newline at end of file diff --git a/src/admin/views/categories.js b/src/admin/views/categories.js index 4a46014..6ca15b9 100644 --- a/src/admin/views/categories.js +++ b/src/admin/views/categories.js @@ -1,29 +1,61 @@ import { layout, flash } from './layout.js'; -export function renderCategoryList(categories, locations) { +export function renderCategoryList(categories, locations, subcategories) { const locOptions = locations.map(l => `` ).join(''); - const rows = categories.map(c => ` - ${c.id} - ${escapeHtml(c.name)} - ${c.country ? escapeHtml(c.country) + ' / ' + escapeHtml(c.city) : '-'} - ${c.product_count || 0} - -
- - - -
-
- -
- - `).join(''); + const subcatsByCategory = {}; + for (const s of subcategories) { + if (!subcatsByCategory[s.category_id]) subcatsByCategory[s.category_id] = []; + subcatsByCategory[s.category_id].push(s); + } + + const rows = categories.map(c => { + const subs = subcatsByCategory[c.id] || []; + const subRows = subs.map(s => ` + + โ†ณ ${escapeHtml(s.name)} + + ${s.product_count || 0} + +
+ +
+ + `).join(''); + + const addSubForm = ` + + +
+ + +
+ + + `; + + return ` + ${c.id} + ${escapeHtml(c.name)} + ${c.country ? escapeHtml(c.country) + ' / ' + escapeHtml(c.city) : '-'} + ${c.product_count || 0} + +
+ + + +
+
+ +
+ + ${subRows}${addSubForm}`; + }).join(''); const content = ` ${flash('')} diff --git a/src/admin/views/dashboard.js b/src/admin/views/dashboard.js index ba568ad..535e055 100644 --- a/src/admin/views/dashboard.js +++ b/src/admin/views/dashboard.js @@ -4,6 +4,7 @@ export function renderDashboard(stats, message) { const cards = [ statCard('Total Users', stats.totalUsers), statCard('Total Products', stats.totalProducts), + statCard('Subcategories', stats.totalSubcategories), statCard('Total Purchases', stats.totalPurchases), statCard('Revenue', `$${(stats.totalRevenue || 0).toFixed(2)}`), ].join(''); diff --git a/src/admin/views/layout.js b/src/admin/views/layout.js index da26b9d..3f5f6c0 100644 --- a/src/admin/views/layout.js +++ b/src/admin/views/layout.js @@ -1,13 +1,12 @@ export function layout(title, content, activeTab = '') { const nav = [ { href: '/', label: 'Dashboard', id: 'dashboard' }, + { href: '/catalog', label: 'Catalog', id: 'catalog' }, { href: '/users', label: 'Users', id: 'users' }, - { href: '/products', label: 'Products', id: 'products' }, { href: '/wallets', label: 'Wallets', id: 'wallets' }, { href: '/purchases', label: 'Purchases', id: 'purchases' }, { href: '/audit', label: 'Audit Log', id: 'audit' }, { href: '/settings', label: 'Settings', id: 'settings' }, - { href: '/categories', label: 'Categories', id: 'categories' }, { href: '/payment-wallets', label: 'Payment Wallets', id: 'payment-wallets' }, { href: '/seed', label: 'Seed & Reset', id: 'seed' }, ]; diff --git a/src/admin/views/locations.js b/src/admin/views/locations.js new file mode 100644 index 0000000..80004ba --- /dev/null +++ b/src/admin/views/locations.js @@ -0,0 +1,38 @@ +import { layout, flash } from './layout.js'; + +export function renderLocationList(locations) { + const rows = locations.map(l => ` + ${l.id} + ${escapeHtml(l.country)} + ${escapeHtml(l.city)} + ${escapeHtml(l.district || '')} + ${l.category_count || 0} + ${l.product_count || 0} + +
+ +
+ + `).join(''); + + const content = ` + ${flash('')} +
+ Add Location +
+ + + + +
+
+ + + ${rows || ''} +
IDCountryCityDistrictCategoriesProductsActions
No locations
`; + return layout('Locations', content, 'locations'); +} + +function escapeHtml(str) { + return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} diff --git a/src/admin/views/products.js b/src/admin/views/products.js index 7ac7af3..0b0bff2 100644 --- a/src/admin/views/products.js +++ b/src/admin/views/products.js @@ -1,14 +1,19 @@ import { layout, flash } from './layout.js'; -export function renderProductList(products, categories, message) { +export function renderProductList(products, categories, subcategories) { const catOptions = categories.map(c => `` ).join(''); + const subcatOptions = subcategories.map(s => + `` + ).join(''); + const rows = products.map(p => ` ${p.id} ${p.name} ${p.category_name || '-'} + ${p.subcategory_name || '-'} $${(p.price || 0).toFixed(2)} ${p.quantity_in_stock || 0} @@ -19,7 +24,7 @@ export function renderProductList(products, categories, message) { `).join(''); - const content = `${flash(message)} + const content = `${flash('')}
Add Product
@@ -28,25 +33,43 @@ export function renderProductList(products, categories, message) { - ${catOptions} +
- - ${rows || ''} -
IDNameCategoryPriceStockActions
No products
`; + IDNameCategorySubcategoryPriceStockActions + ${rows || 'No products'} + + `; return layout('Products', content, 'products'); } -export function renderProductEdit(product, categories) { +export function renderProductEdit(product, categories, subcategories) { const catOptions = categories.map(c => `` ).join(''); + const subcatOptions = subcategories.map(s => + `` + ).join(''); + const content = `
@@ -60,13 +83,28 @@ export function renderProductEdit(product, categories) { - + + + Cancel -
`; + + `; return layout('Edit Product', content, 'products'); } function escapeHtml(str) { - return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } diff --git a/src/admin/views/seed.js b/src/admin/views/seed.js index 7a27d4f..1695184 100644 --- a/src/admin/views/seed.js +++ b/src/admin/views/seed.js @@ -4,7 +4,7 @@ export function renderSeedPage() { const content = `

Seed Demo Data

-

Insert sample data: 5 users, 3 locations, 5 categories, 10 products, 5 wallets, 5 purchases, 3 transactions.

+

Insert sample data: 5 users, 3 locations, 5 categories, 11 subcategories, 10 products, 5 wallets, 5 purchases, 3 transactions.

diff --git a/src/migrations/006_subcategories.js b/src/migrations/006_subcategories.js new file mode 100644 index 0000000..fe1eda3 --- /dev/null +++ b/src/migrations/006_subcategories.js @@ -0,0 +1,23 @@ +import logger from '../utils/logger.js'; + +export default async function migration006(db) { + await db.runAsync('BEGIN TRANSACTION'); + + await db.runAsync(`CREATE TABLE IF NOT EXISTS subcategories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + category_id INTEGER NOT NULL, + name TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE, + UNIQUE(category_id, name) + )`); + + const cols = await db.allAsync('PRAGMA table_info(products)'); + const hasSubcat = cols.some(c => c.name === 'subcategory_id'); + if (!hasSubcat) { + await db.runAsync(`ALTER TABLE products ADD COLUMN subcategory_id INTEGER REFERENCES subcategories(id) ON DELETE SET NULL`); + } + + await db.runAsync('COMMIT'); + logger.info('Migration 006: subcategories table + products.subcategory_id column'); +} diff --git a/src/migrations/runner.js b/src/migrations/runner.js index 271e950..0008a20 100644 --- a/src/migrations/runner.js +++ b/src/migrations/runner.js @@ -3,7 +3,7 @@ import logger from '../utils/logger.js'; const ALLOWED_TABLES = new Set([ 'users', 'crypto_wallets', 'transactions', 'products', - 'purchases', 'locations', 'categories' + 'purchases', 'locations', 'categories', 'subcategories' ]); export const checkColumnExists = async (tableName, columnName) => { @@ -40,6 +40,7 @@ export async function runMigrations() { (await import('./003_add_indexes.js')).default, (await import('./004_user_states.js')).default, (await import('./005_audit_log.js')).default, + (await import('./006_subcategories.js')).default, ]; for (let i = currentVersion; i < migrations.length; i++) {