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:
@@ -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
113
src/admin/routes/catalog.js
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
36
src/admin/routes/locations.js
Normal file
36
src/admin/routes/locations.js
Normal 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;
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
200
src/admin/views/catalog.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
@@ -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('')}
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
38
src/admin/views/locations.js
Normal file
38
src/admin/views/locations.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
23
src/migrations/006_subcategories.js
Normal file
23
src/migrations/006_subcategories.js
Normal 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');
|
||||
}
|
||||
@@ -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++) {
|
||||
|
||||
Reference in New Issue
Block a user