feat: unified Catalog page with Location→Category→Subcategory→Product tree

- New /catalog page with tree view: Location (🌍) → Category (📂) → Subcategory (📁) → Product
- Add/delete locations, categories, subcategories, products from one page
- JS-powered subcategory dropdown filtered by category
- Sticky sidebar with Add Location/Category/Product forms
- Responsive grid layout (tree + forms side by side, stacks on mobile)
- Navigation simplified: Catalog replaces separate Locations/Categories/Products
- Old routes still accessible for backward compatibility
- Subcategories table migration (006_subcategories.js)
- subcategory_id column added to products table
- Seed data includes subcategories (VPN, Accounts, Hardware, etc.)
This commit is contained in:
NW
2026-06-22 21:12:05 +01:00
parent 2012435370
commit c7bf3f132c
17 changed files with 693 additions and 69 deletions

View File

@@ -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; }
}

113
src/admin/routes/catalog.js Normal file
View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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');
});

View File

@@ -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;
export default router;

View File

@@ -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);

200
src/admin/views/catalog.js Normal file
View File

@@ -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 =>
`<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>`;
}
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>`;
}
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>`;
}
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>`;
}
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') + `
<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);
}
});
});
}
</script>`;
}
function esc(str) {
return String(str || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

View File

@@ -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 =>
`<option value="${l.id}">${escapeHtml(l.country)} / ${escapeHtml(l.city)} / ${escapeHtml(l.district || '')}</option>`
).join('');
const rows = categories.map(c => `<tr>
<td>${c.id}</td>
<td>${escapeHtml(c.name)}</td>
<td>${c.country ? escapeHtml(c.country) + ' / ' + escapeHtml(c.city) : '-'}</td>
<td>${c.product_count || 0}</td>
<td>
<form method="POST" action="/categories/${c.id}/update" style="display:inline" class="inline-form">
<input name="name" value="${escapeHtml(c.name)}" required>
<select name="location_id">
<option value="">-- None --</option>
${locations.map(l => `<option value="${l.id}" ${l.id === c.location_id ? 'selected' : ''}>${escapeHtml(l.country)} / ${escapeHtml(l.city)}</option>`).join('')}
</select>
<button class="btn-sm">Save</button>
</form>
<form method="POST" action="/categories/${c.id}/delete" style="display:inline" onsubmit="return confirm('Delete category?')">
<button class="btn-sm btn-danger">Delete</button>
</form>
</td>
</tr>`).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 => `<tr class="sub-row">
<td></td>
<td style="padding-left:2em">↳ ${escapeHtml(s.name)}</td>
<td></td>
<td>${s.product_count || 0}</td>
<td>
<form method="POST" action="/categories/subcategories/${s.id}/delete" style="display:inline" onsubmit="return confirm('Delete subcategory?')">
<button class="btn-sm btn-danger">Delete</button>
</form>
</td>
</tr>`).join('');
const addSubForm = `<tr class="sub-row">
<td></td>
<td style="padding-left:2em">
<form method="POST" action="/categories/${c.id}/subcategories" class="inline-form">
<input name="name" placeholder="Subcategory name" required size="15">
<button class="btn-sm">Add</button>
</form>
</td>
<td></td><td></td><td></td>
</tr>`;
return `<tr>
<td>${c.id}</td>
<td>${escapeHtml(c.name)}</td>
<td>${c.country ? escapeHtml(c.country) + ' / ' + escapeHtml(c.city) : '-'}</td>
<td>${c.product_count || 0}</td>
<td>
<form method="POST" action="/categories/${c.id}/update" style="display:inline" class="inline-form">
<input name="name" value="${escapeHtml(c.name)}" required size="12">
<select name="location_id">
<option value="">-- None --</option>
${locations.map(l => `<option value="${l.id}" ${l.id === c.location_id ? 'selected' : ''}>${escapeHtml(l.country)} / ${escapeHtml(l.city)}</option>`).join('')}
</select>
<button class="btn-sm">Save</button>
</form>
<form method="POST" action="/categories/${c.id}/delete" style="display:inline" onsubmit="return confirm('Delete category?')">
<button class="btn-sm btn-danger">Delete</button>
</form>
</td>
</tr>${subRows}${addSubForm}`;
}).join('');
const content = `
${flash('')}

View File

@@ -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('');

View File

@@ -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' },
];

View File

@@ -0,0 +1,38 @@
import { layout, flash } from './layout.js';
export function renderLocationList(locations) {
const rows = locations.map(l => `<tr>
<td>${l.id}</td>
<td>${escapeHtml(l.country)}</td>
<td>${escapeHtml(l.city)}</td>
<td>${escapeHtml(l.district || '')}</td>
<td>${l.category_count || 0}</td>
<td>${l.product_count || 0}</td>
<td>
<form method="POST" action="/locations/${l.id}/delete" style="display:inline" onsubmit="return confirm('Delete location?')">
<button class="btn-sm btn-danger">Delete</button>
</form>
</td>
</tr>`).join('');
const content = `
${flash('')}
<details class="form-section">
<summary>Add Location</summary>
<form method="POST" action="/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">Add</button>
</form>
</details>
<table>
<thead><tr><th>ID</th><th>Country</th><th>City</th><th>District</th><th>Categories</th><th>Products</th><th>Actions</th></tr></thead>
<tbody>${rows || '<tr><td colspan="7">No locations</td></tr>'}</tbody>
</table>`;
return layout('Locations', content, 'locations');
}
function escapeHtml(str) {
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

View File

@@ -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 =>
`<option value="${c.id}">${c.name}</option>`
).join('');
const subcatOptions = subcategories.map(s =>
`<option value="${s.id}" data-cat="${s.category_id}">${s.name}</option>`
).join('');
const rows = products.map(p => `<tr>
<td>${p.id}</td>
<td>${p.name}</td>
<td>${p.category_name || '-'}</td>
<td>${p.subcategory_name || '-'}</td>
<td>$${(p.price || 0).toFixed(2)}</td>
<td>${p.quantity_in_stock || 0}</td>
<td>
@@ -19,7 +24,7 @@ export function renderProductList(products, categories, message) {
</td>
</tr>`).join('');
const content = `${flash(message)}
const content = `${flash('')}
<details class="form-section">
<summary>Add Product</summary>
<form method="POST" action="/products" class="inline-form">
@@ -28,25 +33,43 @@ export function renderProductList(products, categories, message) {
<input name="quantity_in_stock" type="number" placeholder="Stock" value="0">
<input name="description" placeholder="Description">
<input name="photo_url" placeholder="Photo URL">
<select name="category_id" required>
<select name="category_id" required id="cat-select">
<option value="">-- Category --</option>
${catOptions}
</select>
<select name="subcategory_id" id="subcat-select">
<option value="">-- Subcategory --</option>
${subcatOptions}
</select>
<button type="submit" class="btn">Add</button>
</form>
</details>
<table>
<thead><tr><th>ID</th><th>Name</th><th>Category</th><th>Price</th><th>Stock</th><th>Actions</th></tr></thead>
<tbody>${rows || '<tr><td colspan="6">No products</td></tr>'}</tbody>
</table>`;
<thead><tr><th>ID</th><th>Name</th><th>Category</th><th>Subcategory</th><th>Price</th><th>Stock</th><th>Actions</th></tr></thead>
<tbody>${rows || '<tr><td colspan="7">No products</td></tr>'}</tbody>
</table>
<script>
const catSel = document.getElementById('cat-select');
const subSel = document.getElementById('subcat-select');
const allOpts = Array.from(subSel.options);
catSel.addEventListener('change', () => {
const catId = catSel.value;
subSel.innerHTML = '<option value="">-- Subcategory --</option>';
allOpts.forEach(o => { if (o.dataset.cat === catId || !o.dataset.cat) subSel.appendChild(o.cloneNode(true)); });
});
</script>`;
return layout('Products', content, 'products');
}
export function renderProductEdit(product, categories) {
export function renderProductEdit(product, categories, subcategories) {
const catOptions = categories.map(c =>
`<option value="${c.id}" ${c.id === product.category_id ? 'selected' : ''}>${c.name}</option>`
).join('');
const subcatOptions = subcategories.map(s =>
`<option value="${s.id}" data-cat="${s.category_id}" ${s.id === product.subcategory_id ? 'selected' : ''}>${s.name}</option>`
).join('');
const content = `
<form method="POST" action="/products/${product.id}/update" class="form">
<label>Name</label>
@@ -60,13 +83,28 @@ export function renderProductEdit(product, categories) {
<label>Photo URL</label>
<input name="photo_url" value="${escapeHtml(product.photo_url || '')}">
<label>Category</label>
<select name="category_id" required>${catOptions}</select>
<select name="category_id" required id="cat-select">${catOptions}</select>
<label>Subcategory</label>
<select name="subcategory_id" id="subcat-select">
<option value="">-- None --</option>
${subcatOptions}
</select>
<button type="submit" class="btn">Save</button>
<a href="/products" class="btn btn-secondary">Cancel</a>
</form>`;
</form>
<script>
const catSel = document.getElementById('cat-select');
const subSel = document.getElementById('subcat-select');
const allOpts = Array.from(subSel.options);
catSel.addEventListener('change', () => {
const catId = catSel.value;
subSel.innerHTML = '<option value="">-- None --</option>';
allOpts.forEach(o => { if (o.dataset.cat === catId || !o.dataset.cat) subSel.appendChild(o.cloneNode(true)); });
});
</script>`;
return layout('Edit Product', content, 'products');
}
function escapeHtml(str) {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

View File

@@ -4,7 +4,7 @@ export function renderSeedPage() {
const content = `
<div class="detail-card">
<h2>Seed Demo Data</h2>
<p>Insert sample data: 5 users, 3 locations, 5 categories, 10 products, 5 wallets, 5 purchases, 3 transactions.</p>
<p>Insert sample data: 5 users, 3 locations, 5 categories, 11 subcategories, 10 products, 5 wallets, 5 purchases, 3 transactions.</p>
<form method="POST" action="/seed/seed-demo" onsubmit="return confirm('Insert demo data? This will add records to existing tables.')">
<button type="submit" class="btn btn-success">Seed Demo Data</button>
</form>

View File

@@ -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');
}

View File

@@ -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++) {