feat(admin): full CRUD + enable/disable for locations, categories, subcategories
- Migration 011: add is_active INTEGER NOT NULL DEFAULT 1 to locations, categories, subcategories (idempotent) - Admin routes: locations edit + toggle, categories toggle, subcategories edit + toggle (parallel endpoints on /catalog) - Bot-side services: filter is_active=1 on locationService/categoryService read methods (getLocationById/getCategoryById left unfiltered for purchase display) - Views: edit forms, toggle buttons, Active/Disabled badges, disabled row styling in locations.ejs, categories.ejs, catalog tree - Add Categories nav item in sidebar (folder icon) - Tests: 14 new tests for migration idempotency + service is_active filtering (23 total pass)
This commit is contained in:
@@ -21,6 +21,8 @@
|
|||||||
- **fix**: Added defensive guards in bot purchase display — description fallback and no-photo placeholder (userProductHandler.js)
|
- **fix**: Added defensive guards in bot purchase display — description fallback and no-photo placeholder (userProductHandler.js)
|
||||||
- **feat**: Added i18n keys `products.no_description` and `products.no_photo` in en/es/de
|
- **feat**: Added i18n keys `products.no_description` and `products.no_photo` in en/es/de
|
||||||
- **fix**: Marked description and photo fields as required in admin forms (product-edit.ejs, products.ejs, catalog modal)
|
- **fix**: Marked description and photo fields as required in admin forms (product-edit.ejs, products.ejs, catalog modal)
|
||||||
|
- **feat**: Added edit + enable/disable (toggle is_active) UI for locations, categories, and subcategories in admin views (locations.ejs, categories.ejs, catalog.js buildTreeHtml)
|
||||||
|
- **feat**: Added Categories nav item in admin sidebar (generated-navigation.ejs)
|
||||||
|
|
||||||
### v1.2.0 — 2026-07-08
|
### v1.2.0 — 2026-07-08
|
||||||
- **fix**: Disabled CSRF checks in admin panel for Tor / onion zone compatibility
|
- **fix**: Disabled CSRF checks in admin panel for Tor / onion zone compatibility
|
||||||
|
|||||||
252
src/__tests__/crudActiveFlags.test.js
Normal file
252
src/__tests__/crudActiveFlags.test.js
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
|
||||||
|
vi.mock('../config/config.js', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
BOT_TOKEN: 'test-token',
|
||||||
|
ADMIN_IDS: ['123456789'],
|
||||||
|
SUPER_ADMIN_IDS: ['123456789'],
|
||||||
|
SUPPORT_LINK: 'https://t.me/support',
|
||||||
|
DEFAULT_LANGUAGE: 'en',
|
||||||
|
ENCRYPTION_KEY: 'x'.repeat(64)
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../utils/logger.js', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
fatal: vi.fn()
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const recorded = {
|
||||||
|
calls: []
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('../config/database.js', () => {
|
||||||
|
const db = {
|
||||||
|
allAsync: vi.fn(async (sql, params = []) => {
|
||||||
|
recorded.calls.push({ method: 'allAsync', sql, params });
|
||||||
|
return [];
|
||||||
|
}),
|
||||||
|
getAsync: vi.fn(async (sql, params = []) => {
|
||||||
|
recorded.calls.push({ method: 'getAsync', sql, params });
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
|
runAsync: vi.fn(async (sql, params = []) => {
|
||||||
|
recorded.calls.push({ method: 'runAsync', sql, params });
|
||||||
|
return {};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
return { __esModule: true, default: db };
|
||||||
|
});
|
||||||
|
|
||||||
|
function getIsActiveCount(tableInfoRows) {
|
||||||
|
return tableInfoRows.filter(row => row.name === 'is_active').length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapDb(rawDb) {
|
||||||
|
return {
|
||||||
|
runAsync: (sql, params = []) => Promise.resolve(rawDb.prepare(sql).run(...params)),
|
||||||
|
allAsync: (sql, params = []) => Promise.resolve(rawDb.prepare(sql).all(...params)),
|
||||||
|
getAsync: (sql, params = []) => Promise.resolve(rawDb.prepare(sql).get(...params))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runMigration011OnDb(rawDb) {
|
||||||
|
const migration011 = (await import('../migrations/011_active_flags.js')).default;
|
||||||
|
const checkColumnExists = async (tableName, columnName) => {
|
||||||
|
const rows = rawDb.prepare(`PRAGMA table_info(${tableName})`).all();
|
||||||
|
return rows.some(row => row.name === columnName);
|
||||||
|
};
|
||||||
|
await migration011(wrapDb(rawDb), checkColumnExists);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBaseTables(db) {
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS locations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
country TEXT NOT NULL,
|
||||||
|
city TEXT NOT NULL,
|
||||||
|
district TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(country, city, district)
|
||||||
|
)`);
|
||||||
|
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS categories (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
location_id INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (location_id) REFERENCES locations(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(location_id, name)
|
||||||
|
)`);
|
||||||
|
|
||||||
|
db.exec(`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)
|
||||||
|
)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Migration 011: is_active flags', () => {
|
||||||
|
let db;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
db = new Database(':memory:');
|
||||||
|
createBaseTables(db);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds is_active column to locations with default 1', async () => {
|
||||||
|
await runMigration011OnDb(db);
|
||||||
|
const info = db.prepare('PRAGMA table_info(locations)').all();
|
||||||
|
expect(getIsActiveCount(info)).toBe(1);
|
||||||
|
const col = info.find(row => row.name === 'is_active');
|
||||||
|
expect(col).toBeDefined();
|
||||||
|
expect(col.notnull).toBe(1);
|
||||||
|
expect(col.dflt_value).toBe('1');
|
||||||
|
|
||||||
|
const insert = db.prepare('INSERT INTO locations (country, city, district) VALUES (?, ?, ?)');
|
||||||
|
const { lastInsertRowid } = insert.run('UA', 'Kyiv', 'Pechersk');
|
||||||
|
const row = db.prepare('SELECT is_active FROM locations WHERE id = ?').get(lastInsertRowid);
|
||||||
|
expect(row.is_active).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds is_active column to categories with default 1', async () => {
|
||||||
|
await runMigration011OnDb(db);
|
||||||
|
const info = db.prepare('PRAGMA table_info(categories)').all();
|
||||||
|
expect(getIsActiveCount(info)).toBe(1);
|
||||||
|
const col = info.find(row => row.name === 'is_active');
|
||||||
|
expect(col).toBeDefined();
|
||||||
|
|
||||||
|
const locInsert = db.prepare('INSERT INTO locations (country, city, district) VALUES (?, ?, ?)');
|
||||||
|
const { lastInsertRowid: locId } = locInsert.run('UA', 'Kyiv', 'Pechersk');
|
||||||
|
|
||||||
|
const catInsert = db.prepare('INSERT INTO categories (location_id, name) VALUES (?, ?)');
|
||||||
|
const { lastInsertRowid: catId } = catInsert.run(locId, 'Electronics');
|
||||||
|
|
||||||
|
const row = db.prepare('SELECT is_active FROM categories WHERE id = ?').get(catId);
|
||||||
|
expect(row.is_active).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds is_active column to subcategories with default 1', async () => {
|
||||||
|
await runMigration011OnDb(db);
|
||||||
|
const info = db.prepare('PRAGMA table_info(subcategories)').all();
|
||||||
|
expect(getIsActiveCount(info)).toBe(1);
|
||||||
|
const col = info.find(row => row.name === 'is_active');
|
||||||
|
expect(col).toBeDefined();
|
||||||
|
|
||||||
|
const locInsert = db.prepare('INSERT INTO locations (country, city, district) VALUES (?, ?, ?)');
|
||||||
|
const { lastInsertRowid: locId } = locInsert.run('UA', 'Kyiv', 'Pechersk');
|
||||||
|
|
||||||
|
const catInsert = db.prepare('INSERT INTO categories (location_id, name) VALUES (?, ?)');
|
||||||
|
const { lastInsertRowid: catId } = catInsert.run(locId, 'Electronics');
|
||||||
|
|
||||||
|
const subInsert = db.prepare('INSERT INTO subcategories (category_id, name) VALUES (?, ?)');
|
||||||
|
const { lastInsertRowid: subId } = subInsert.run(catId, 'Phones');
|
||||||
|
|
||||||
|
const row = db.prepare('SELECT is_active FROM subcategories WHERE id = ?').get(subId);
|
||||||
|
expect(row.is_active).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent: running migration 011 twice does not error or duplicate columns', async () => {
|
||||||
|
await runMigration011OnDb(db);
|
||||||
|
await expect(runMigration011OnDb(db)).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
for (const table of ['locations', 'categories', 'subcategories']) {
|
||||||
|
const info = db.prepare(`PRAGMA table_info(${table})`).all();
|
||||||
|
expect(getIsActiveCount(info)).toBe(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('LocationService is_active filtering', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
recorded.calls = [];
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getCountries filters is_active = 1', async () => {
|
||||||
|
const LocationService = (await import('../services/locationService.js')).default;
|
||||||
|
await LocationService.getCountries();
|
||||||
|
const sql = recorded.calls.find(c => c.method === 'allAsync')?.sql;
|
||||||
|
expect(sql).toContain('is_active = 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getCitiesByCountry filters is_active = 1', async () => {
|
||||||
|
const LocationService = (await import('../services/locationService.js')).default;
|
||||||
|
await LocationService.getCitiesByCountry('UA');
|
||||||
|
const sql = recorded.calls.find(c => c.method === 'allAsync')?.sql;
|
||||||
|
expect(sql).toContain('AND is_active = 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getDistrictsByCountryAndCity filters is_active = 1', async () => {
|
||||||
|
const LocationService = (await import('../services/locationService.js')).default;
|
||||||
|
await LocationService.getDistrictsByCountryAndCity('UA', 'Kyiv');
|
||||||
|
const sql = recorded.calls.find(c => c.method === 'allAsync')?.sql;
|
||||||
|
expect(sql).toContain('AND is_active = 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getLocationsByCountryAndCity filters is_active = 1', async () => {
|
||||||
|
const LocationService = (await import('../services/locationService.js')).default;
|
||||||
|
await LocationService.getLocationsByCountryAndCity('UA', 'Kyiv');
|
||||||
|
const sql = recorded.calls.find(c => c.method === 'allAsync')?.sql;
|
||||||
|
expect(sql).toContain('AND is_active = 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getLocation filters is_active = 1', async () => {
|
||||||
|
const LocationService = (await import('../services/locationService.js')).default;
|
||||||
|
await LocationService.getLocation('UA', 'Kyiv', 'Pechersk');
|
||||||
|
const sql = recorded.calls.find(c => c.method === 'getAsync')?.sql;
|
||||||
|
expect(sql).toContain('AND is_active = 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getLocationById does NOT filter is_active', async () => {
|
||||||
|
const LocationService = (await import('../services/locationService.js')).default;
|
||||||
|
await LocationService.getLocationById(1);
|
||||||
|
const sql = recorded.calls.find(c => c.method === 'getAsync')?.sql;
|
||||||
|
expect(sql).not.toContain('is_active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CategoryService is_active filtering', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
recorded.calls = [];
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getCategoriesByLocationId filters is_active = 1', async () => {
|
||||||
|
const CategoryService = (await import('../services/categoryService.js')).default;
|
||||||
|
await CategoryService.getCategoriesByLocationId(1);
|
||||||
|
const sql = recorded.calls.find(c => c.method === 'allAsync')?.sql;
|
||||||
|
expect(sql).toContain('AND is_active = 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getSubcategoriesByCategoryId filters is_active = 1', async () => {
|
||||||
|
const CategoryService = (await import('../services/categoryService.js')).default;
|
||||||
|
await CategoryService.getSubcategoriesByCategoryId(1);
|
||||||
|
const sql = recorded.calls.find(c => c.method === 'allAsync')?.sql;
|
||||||
|
expect(sql).toContain('AND is_active = 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getCategoryById does NOT filter is_active', async () => {
|
||||||
|
const CategoryService = (await import('../services/categoryService.js')).default;
|
||||||
|
await CategoryService.getCategoryById(1);
|
||||||
|
const sql = recorded.calls.find(c => c.method === 'getAsync')?.sql;
|
||||||
|
expect(sql).not.toContain('is_active');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getSubcategoryById does NOT filter is_active', async () => {
|
||||||
|
const CategoryService = (await import('../services/categoryService.js')).default;
|
||||||
|
await CategoryService.getSubcategoryById(1);
|
||||||
|
const sql = recorded.calls.find(c => c.method === 'getAsync')?.sql;
|
||||||
|
expect(sql).not.toContain('is_active');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,20 +26,25 @@ function buildTreeHtml(tree, csrf) {
|
|||||||
let subHtml = '';
|
let subHtml = '';
|
||||||
for (const s of (c.subs||[])) {
|
for (const s of (c.subs||[])) {
|
||||||
const subId = `sub-${s.id}`;
|
const subId = `sub-${s.id}`;
|
||||||
|
const subDisabled = s.is_active === 0;
|
||||||
subHtml += `
|
subHtml += `
|
||||||
<div class="accordion-item border-0">
|
<div class="accordion-item border-0">
|
||||||
<h2 class="accordion-header">
|
<h2 class="accordion-header">
|
||||||
<button class="accordion-button collapsed py-2" type="button" data-bs-toggle="collapse" data-bs-target="#${subId}">
|
<button class="accordion-button collapsed py-2" type="button" data-bs-toggle="collapse" data-bs-target="#${subId}">
|
||||||
<span class="node-label text-decoration-none text-dark" data-sub="${s.id}">${esc(s.name)}</span>
|
<span class="node-label text-decoration-none${subDisabled ? ' text-muted' : ' text-dark'}" data-sub="${s.id}">${esc(s.name)}${subDisabled ? ' <span class="badge bg-secondary ms-1">Disabled</span>' : ''}</span>
|
||||||
<span class="badge bg-secondary ms-2">${s.pc||0}</span>
|
<span class="badge bg-secondary ms-2">${s.pc||0}</span>
|
||||||
</button>
|
</button>
|
||||||
</h2>
|
</h2>
|
||||||
<div id="${subId}" class="accordion-collapse collapse" data-bs-parent="#${catId}">
|
<div id="${subId}" class="accordion-collapse collapse" data-bs-parent="#${catId}">
|
||||||
<div class="accordion-body py-2">
|
<div class="accordion-body py-2">
|
||||||
<form method="POST" action="/catalog/categories/${c.id}/subcategories" class="d-inline-flex gap-2">
|
<form method="POST" action="/catalog/subcategories/${s.id}/update" class="d-inline-flex gap-1 align-items-center mb-1">
|
||||||
${csrfInput}
|
${csrfInput}
|
||||||
<input name="name" class="form-control form-control-sm" placeholder="+ Subcategory" required size="12" style="width:auto">
|
<input name="name" class="form-control form-control-sm" value="${esc(s.name)}" required size="10" style="width:auto">
|
||||||
<button class="btn btn-sm btn-outline-primary">Add</button>
|
<button class="btn btn-sm btn-outline-primary">Save</button>
|
||||||
|
</form>
|
||||||
|
<form method="POST" action="/catalog/subcategories/${s.id}/toggle" class="d-inline ms-1"${subDisabled ? '' : ` onsubmit="return confirm('Disable?')"`}>
|
||||||
|
${csrfInput}
|
||||||
|
<button class="btn btn-sm ${subDisabled ? 'btn-outline-success' : 'btn-outline-warning'}" title="${subDisabled ? 'Enable' : 'Disable'}">⏻</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="POST" action="/catalog/subcategories/${s.id}/delete" class="d-inline ms-1" onsubmit="return confirm('Delete?')">
|
<form method="POST" action="/catalog/subcategories/${s.id}/delete" class="d-inline ms-1" onsubmit="return confirm('Delete?')">
|
||||||
${csrfInput}
|
${csrfInput}
|
||||||
@@ -49,17 +54,27 @@ function buildTreeHtml(tree, csrf) {
|
|||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
const catDisabled = c.is_active === 0;
|
||||||
catHtml += `
|
catHtml += `
|
||||||
<div class="accordion-item border-0">
|
<div class="accordion-item border-0">
|
||||||
<h2 class="accordion-header">
|
<h2 class="accordion-header">
|
||||||
<button class="accordion-button collapsed py-2" type="button" data-bs-toggle="collapse" data-bs-target="#${catId}">
|
<button class="accordion-button collapsed py-2" type="button" data-bs-toggle="collapse" data-bs-target="#${catId}">
|
||||||
<span class="node-label text-decoration-none text-dark" data-cat="${c.id}">${esc(c.name)}</span>
|
<span class="node-label text-decoration-none${catDisabled ? ' text-muted' : ' text-dark'}" data-cat="${c.id}">${esc(c.name)}${catDisabled ? ' <span class="badge bg-secondary ms-1">Disabled</span>' : ''}</span>
|
||||||
<span class="badge bg-secondary ms-2">${catCount}</span>
|
<span class="badge bg-secondary ms-2">${catCount}</span>
|
||||||
</button>
|
</button>
|
||||||
</h2>
|
</h2>
|
||||||
<div id="${catId}" class="accordion-collapse collapse" data-bs-parent="#${districtId}">
|
<div id="${catId}" class="accordion-collapse collapse" data-bs-parent="#${districtId}">
|
||||||
<div class="accordion-body py-2">
|
<div class="accordion-body py-2">
|
||||||
${subHtml}
|
${subHtml}
|
||||||
|
<form method="POST" action="/catalog/categories/${c.id}/update" class="d-inline-flex gap-1 align-items-center mt-2">
|
||||||
|
${csrfInput}
|
||||||
|
<input name="name" class="form-control form-control-sm" value="${esc(c.name)}" required size="10" style="width:auto">
|
||||||
|
<button class="btn btn-sm btn-outline-primary">Save</button>
|
||||||
|
</form>
|
||||||
|
<form method="POST" action="/catalog/categories/${c.id}/toggle" class="d-inline ms-1"${catDisabled ? '' : ` onsubmit="return confirm('Disable?')"`}>
|
||||||
|
${csrfInput}
|
||||||
|
<button class="btn btn-sm ${catDisabled ? 'btn-outline-success' : 'btn-outline-warning'}" title="${catDisabled ? 'Enable' : 'Disable'}">⏻</button>
|
||||||
|
</form>
|
||||||
<form method="POST" action="/catalog/categories/${c.id}/subcategories" class="d-inline-flex gap-2 mt-2">
|
<form method="POST" action="/catalog/categories/${c.id}/subcategories" class="d-inline-flex gap-2 mt-2">
|
||||||
${csrfInput}
|
${csrfInput}
|
||||||
<input name="name" class="form-control form-control-sm" placeholder="+ Subcategory" required size="12" style="width:auto">
|
<input name="name" class="form-control form-control-sm" placeholder="+ Subcategory" required size="12" style="width:auto">
|
||||||
@@ -69,17 +84,29 @@ function buildTreeHtml(tree, csrf) {
|
|||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
const locDisabled = ldata.is_active === 0;
|
||||||
districtHtml += `
|
districtHtml += `
|
||||||
<div class="accordion-item border-0">
|
<div class="accordion-item border-0">
|
||||||
<h2 class="accordion-header">
|
<h2 class="accordion-header">
|
||||||
<button class="accordion-button collapsed py-2" type="button" data-bs-toggle="collapse" data-bs-target="#${districtId}">
|
<button class="accordion-button collapsed py-2" type="button" data-bs-toggle="collapse" data-bs-target="#${districtId}">
|
||||||
<span class="node-label text-decoration-none text-dark" data-loc="${ldata.id}">${esc(district)}</span>
|
<span class="node-label text-decoration-none${locDisabled ? ' text-muted' : ' text-dark'}" data-loc="${ldata.id}">${esc(district)}${locDisabled ? ' <span class="badge bg-secondary ms-1">Disabled</span>' : ''}</span>
|
||||||
<span class="badge bg-secondary ms-2">${districtCount}</span>
|
<span class="badge bg-secondary ms-2">${districtCount}</span>
|
||||||
</button>
|
</button>
|
||||||
</h2>
|
</h2>
|
||||||
<div id="${districtId}" class="accordion-collapse collapse" data-bs-parent="#${cityId}">
|
<div id="${districtId}" class="accordion-collapse collapse" data-bs-parent="#${cityId}">
|
||||||
<div class="accordion-body py-2">
|
<div class="accordion-body py-2">
|
||||||
${catHtml}
|
${catHtml}
|
||||||
|
<form method="POST" action="/catalog/locations/${ldata.id}/update" class="d-inline-flex gap-1 align-items-center mt-2">
|
||||||
|
${csrfInput}
|
||||||
|
<input name="country" class="form-control form-control-sm" value="${esc(country)}" required size="8" style="width:auto">
|
||||||
|
<input name="city" class="form-control form-control-sm" value="${esc(city)}" required size="8" style="width:auto">
|
||||||
|
<input name="district" class="form-control form-control-sm" value="${esc(district)}" size="8" style="width:auto">
|
||||||
|
<button class="btn btn-sm btn-outline-primary">Save</button>
|
||||||
|
</form>
|
||||||
|
<form method="POST" action="/catalog/locations/${ldata.id}/toggle" class="d-inline ms-1"${locDisabled ? '' : ` onsubmit="return confirm('Disable?')"`}>
|
||||||
|
${csrfInput}
|
||||||
|
<button class="btn btn-sm ${locDisabled ? 'btn-outline-success' : 'btn-outline-warning'}" title="${locDisabled ? 'Enable' : 'Disable'}">⏻</button>
|
||||||
|
</form>
|
||||||
<form method="POST" action="/catalog/categories" class="d-inline-flex gap-2 mt-2">
|
<form method="POST" action="/catalog/categories" class="d-inline-flex gap-2 mt-2">
|
||||||
${csrfInput}
|
${csrfInput}
|
||||||
<input type="hidden" name="location_id" value="${ldata.id}">
|
<input type="hidden" name="location_id" value="${ldata.id}">
|
||||||
@@ -305,7 +332,7 @@ router.get('/', asyncHandler(async (req, res) => {
|
|||||||
for (const l of locations) {
|
for (const l of locations) {
|
||||||
(tree[l.country]??={cities:{}}).cities[l.city]??={districts:{}};
|
(tree[l.country]??={cities:{}}).cities[l.city]??={districts:{}};
|
||||||
tree[l.country].cities[l.city].districts[l.district] = {
|
tree[l.country].cities[l.city].districts[l.district] = {
|
||||||
id: l.id, cats: (cl[l.id]||[]).map(c=>({...c, subs: sc[c.id]||[]}))
|
id: l.id, is_active: l.is_active, cats: (cl[l.id]||[]).map(c=>({...c, subs: sc[c.id]||[]}))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,6 +364,23 @@ router.post('/locations', asyncHandler(async (req, res) => {
|
|||||||
res.redirect('/catalog?msg=Location+added&msg_type=success');
|
res.redirect('/catalog?msg=Location+added&msg_type=success');
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
router.post('/locations/:id/update', asyncHandler(async (req, res) => {
|
||||||
|
const { country, city, district } = req.body;
|
||||||
|
const tc = (country || '').trim();
|
||||||
|
const tci = (city || '').trim();
|
||||||
|
if (!tc || !tci) return res.redirect('/catalog?msg=Country+and+city+required&msg_type=error');
|
||||||
|
await db.runAsync('UPDATE locations SET country=?, city=?, district=? WHERE id=?',
|
||||||
|
[tc, tci, (district || '').trim(), req.params.id]);
|
||||||
|
res.redirect('/catalog?msg=Location+updated&msg_type=success');
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post('/locations/:id/toggle', asyncHandler(async (req, res) => {
|
||||||
|
const row = await db.getAsync('SELECT is_active FROM locations WHERE id=?', [req.params.id]);
|
||||||
|
if (!row) return res.redirect('/catalog?msg=Location+not+found&msg_type=error');
|
||||||
|
await db.runAsync('UPDATE locations SET is_active=? WHERE id=?', [row.is_active ? 0 : 1, req.params.id]);
|
||||||
|
res.redirect('/catalog?msg=Location+toggled&msg_type=success');
|
||||||
|
}));
|
||||||
|
|
||||||
router.post('/locations/:id/delete', asyncHandler(async (req, res) => {
|
router.post('/locations/:id/delete', asyncHandler(async (req, res) => {
|
||||||
const c = await db.getAsync('SELECT COUNT(*) as n FROM categories WHERE location_id=?', [req.params.id]);
|
const c = await db.getAsync('SELECT COUNT(*) as n FROM categories WHERE location_id=?', [req.params.id]);
|
||||||
if (c?.n > 0) return res.redirect('/catalog?msg=Cannot+delete+has+categories&msg_type=error');
|
if (c?.n > 0) return res.redirect('/catalog?msg=Cannot+delete+has+categories&msg_type=error');
|
||||||
@@ -377,6 +421,22 @@ router.post('/categories/json', asyncHandler(async (req, res) => {
|
|||||||
res.json(cat);
|
res.json(cat);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
router.post('/categories/:id/update', asyncHandler(async (req, res) => {
|
||||||
|
const { name, location_id } = req.body;
|
||||||
|
const tn = (name || '').trim();
|
||||||
|
if (!tn) return res.redirect('/catalog?msg=Name+required&msg_type=error');
|
||||||
|
await db.runAsync('UPDATE categories SET name=?, location_id=? WHERE id=?',
|
||||||
|
[tn, location_id || null, req.params.id]);
|
||||||
|
res.redirect('/catalog?msg=Category+updated&msg_type=success');
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post('/categories/:id/toggle', asyncHandler(async (req, res) => {
|
||||||
|
const row = await db.getAsync('SELECT is_active FROM categories WHERE id=?', [req.params.id]);
|
||||||
|
if (!row) return res.redirect('/catalog?msg=Category+not+found&msg_type=error');
|
||||||
|
await db.runAsync('UPDATE categories SET is_active=? WHERE id=?', [row.is_active ? 0 : 1, req.params.id]);
|
||||||
|
res.redirect('/catalog?msg=Category+toggled&msg_type=success');
|
||||||
|
}));
|
||||||
|
|
||||||
router.post('/categories/:id/delete', asyncHandler(async (req, res) => {
|
router.post('/categories/:id/delete', asyncHandler(async (req, res) => {
|
||||||
const c = await db.getAsync('SELECT COUNT(*) as n FROM products WHERE category_id=?', [req.params.id]);
|
const c = await db.getAsync('SELECT COUNT(*) as n FROM products WHERE category_id=?', [req.params.id]);
|
||||||
if (c?.n > 0) return res.redirect('/catalog?msg=Cannot+delete+has+products&msg_type=error');
|
if (c?.n > 0) return res.redirect('/catalog?msg=Cannot+delete+has+products&msg_type=error');
|
||||||
@@ -405,6 +465,21 @@ router.post('/categories/:id/subcategories/json', asyncHandler(async (req, res)
|
|||||||
res.json(sub);
|
res.json(sub);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
router.post('/subcategories/:id/update', asyncHandler(async (req, res) => {
|
||||||
|
const { name } = req.body;
|
||||||
|
const tn = (name || '').trim();
|
||||||
|
if (!tn) return res.redirect('/catalog?msg=Name+required&msg_type=error');
|
||||||
|
await db.runAsync('UPDATE subcategories SET name=? WHERE id=?', [tn, req.params.id]);
|
||||||
|
res.redirect('/catalog?msg=Subcategory+updated&msg_type=success');
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post('/subcategories/:id/toggle', asyncHandler(async (req, res) => {
|
||||||
|
const row = await db.getAsync('SELECT is_active FROM subcategories WHERE id=?', [req.params.id]);
|
||||||
|
if (!row) return res.redirect('/catalog?msg=Subcategory+not+found&msg_type=error');
|
||||||
|
await db.runAsync('UPDATE subcategories SET is_active=? WHERE id=?', [row.is_active ? 0 : 1, req.params.id]);
|
||||||
|
res.redirect('/catalog?msg=Subcategory+toggled&msg_type=success');
|
||||||
|
}));
|
||||||
|
|
||||||
router.post('/subcategories/:id/delete', asyncHandler(async (req, res) => {
|
router.post('/subcategories/:id/delete', asyncHandler(async (req, res) => {
|
||||||
const c = await db.getAsync('SELECT COUNT(*) as n FROM products WHERE subcategory_id=?', [req.params.id]);
|
const c = await db.getAsync('SELECT COUNT(*) as n FROM products WHERE subcategory_id=?', [req.params.id]);
|
||||||
if (c?.n > 0) return res.redirect('/catalog?msg=Cannot+delete+has+products&msg_type=error');
|
if (c?.n > 0) return res.redirect('/catalog?msg=Cannot+delete+has+products&msg_type=error');
|
||||||
|
|||||||
@@ -37,6 +37,14 @@ router.post('/:id/update', asyncHandler(async (req, res) => {
|
|||||||
res.redirect('/categories');
|
res.redirect('/categories');
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
router.post('/:id/toggle', asyncHandler(async (req, res) => {
|
||||||
|
const row = await db.getAsync('SELECT is_active FROM categories WHERE id = ?', [req.params.id]);
|
||||||
|
if (!row) return res.redirect('/categories');
|
||||||
|
const newVal = row.is_active ? 0 : 1;
|
||||||
|
await db.runAsync('UPDATE categories SET is_active = ? WHERE id = ?', [newVal, req.params.id]);
|
||||||
|
res.redirect('/categories');
|
||||||
|
}));
|
||||||
|
|
||||||
router.post('/:id/delete', asyncHandler(async (req, res) => {
|
router.post('/:id/delete', asyncHandler(async (req, res) => {
|
||||||
const count = await db.getAsync('SELECT COUNT(*) as cnt FROM products WHERE category_id = ?', [req.params.id]);
|
const count = await db.getAsync('SELECT COUNT(*) as cnt FROM products WHERE category_id = ?', [req.params.id]);
|
||||||
if (count && count.cnt > 0) {
|
if (count && count.cnt > 0) {
|
||||||
@@ -61,6 +69,24 @@ router.post('/:id/subcategories', asyncHandler(async (req, res) => {
|
|||||||
res.redirect('/categories');
|
res.redirect('/categories');
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
router.post('/subcategories/:id/update', asyncHandler(async (req, res) => {
|
||||||
|
const { name } = req.body;
|
||||||
|
const trimmedName = (name || '').trim();
|
||||||
|
if (!trimmedName) {
|
||||||
|
return res.redirect('/categories?error=Subcategory+name+is+required');
|
||||||
|
}
|
||||||
|
await db.runAsync('UPDATE subcategories SET name = ? WHERE id = ?', [trimmedName, req.params.id]);
|
||||||
|
res.redirect('/categories');
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post('/subcategories/:id/toggle', asyncHandler(async (req, res) => {
|
||||||
|
const row = await db.getAsync('SELECT is_active FROM subcategories WHERE id = ?', [req.params.id]);
|
||||||
|
if (!row) return res.redirect('/categories');
|
||||||
|
const newVal = row.is_active ? 0 : 1;
|
||||||
|
await db.runAsync('UPDATE subcategories SET is_active = ? WHERE id = ?', [newVal, req.params.id]);
|
||||||
|
res.redirect('/categories');
|
||||||
|
}));
|
||||||
|
|
||||||
router.post('/subcategories/:id/delete', asyncHandler(async (req, res) => {
|
router.post('/subcategories/:id/delete', asyncHandler(async (req, res) => {
|
||||||
const count = await db.getAsync(
|
const count = await db.getAsync(
|
||||||
'SELECT COUNT(*) as cnt FROM products WHERE subcategory_id = ?',
|
'SELECT COUNT(*) as cnt FROM products WHERE subcategory_id = ?',
|
||||||
|
|||||||
@@ -21,6 +21,28 @@ router.post('/', asyncHandler(async (req, res) => {
|
|||||||
res.redirect('/locations');
|
res.redirect('/locations');
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
router.post('/:id/update', asyncHandler(async (req, res) => {
|
||||||
|
const { country, city, district } = req.body;
|
||||||
|
const trimmedCountry = (country || '').trim();
|
||||||
|
const trimmedCity = (city || '').trim();
|
||||||
|
if (!trimmedCountry || !trimmedCity) {
|
||||||
|
return res.redirect('/locations?error=Country+and+city+are+required');
|
||||||
|
}
|
||||||
|
await db.runAsync(
|
||||||
|
'UPDATE locations SET country = ?, city = ?, district = ? WHERE id = ?',
|
||||||
|
[trimmedCountry, trimmedCity, (district || '').trim(), req.params.id]
|
||||||
|
);
|
||||||
|
res.redirect('/locations');
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post('/:id/toggle', asyncHandler(async (req, res) => {
|
||||||
|
const row = await db.getAsync('SELECT is_active FROM locations WHERE id = ?', [req.params.id]);
|
||||||
|
if (!row) return res.redirect('/locations');
|
||||||
|
const newVal = row.is_active ? 0 : 1;
|
||||||
|
await db.runAsync('UPDATE locations SET is_active = ? WHERE id = ?', [newVal, req.params.id]);
|
||||||
|
res.redirect('/locations');
|
||||||
|
}));
|
||||||
|
|
||||||
router.post('/:id/delete', asyncHandler(async (req, res) => {
|
router.post('/:id/delete', asyncHandler(async (req, res) => {
|
||||||
const count = await db.getAsync(
|
const count = await db.getAsync(
|
||||||
'SELECT COUNT(*) as cnt FROM categories WHERE location_id = ?',
|
'SELECT COUNT(*) as cnt FROM categories WHERE location_id = ?',
|
||||||
|
|||||||
@@ -21,19 +21,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<table class="table table-striped table-bordered table-hover">
|
<table class="table table-striped table-bordered table-hover">
|
||||||
<thead><tr><th>ID</th><th>Name</th><th>Location</th><th>Products</th><th>Actions</th></tr></thead>
|
<thead><tr><th>ID</th><th>Name</th><th>Location</th><th>Products</th><th>Status</th><th>Actions</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<% if (categories.length === 0) { %>
|
<% if (categories.length === 0) { %>
|
||||||
<tr><td colspan="5" class="text-center text-muted">No categories</td></tr>
|
<tr><td colspan="6" class="text-center text-muted">No categories</td></tr>
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
<% categories.forEach(function(c) { %>
|
<% categories.forEach(function(c) { %>
|
||||||
<tr>
|
<tr<%= c.is_active === 0 ? ' class="table-secondary"' : '' %>>
|
||||||
<td><%= c.id %></td>
|
<td><%= c.id %></td>
|
||||||
<td><%= c.name %></td>
|
<td>
|
||||||
|
<%= c.name %>
|
||||||
|
<% if (c.is_active === 0) { %>
|
||||||
|
<span class="badge bg-secondary ms-1">Disabled</span>
|
||||||
|
<% } %>
|
||||||
|
</td>
|
||||||
<td><%= c.country ? c.country + ' / ' + c.city : '-' %></td>
|
<td><%= c.country ? c.country + ' / ' + c.city : '-' %></td>
|
||||||
<td><%= c.product_count || 0 %></td>
|
<td><%= c.product_count || 0 %></td>
|
||||||
<td>
|
<td>
|
||||||
<form method="POST" action="/categories/<%= c.id %>/update" class="d-inline-flex gap-2 align-items-center">
|
<% if (c.is_active === 1) { %>
|
||||||
|
<span class="badge bg-success">Active</span>
|
||||||
|
<% } else { %>
|
||||||
|
<span class="badge bg-secondary">Disabled</span>
|
||||||
|
<% } %>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<form method="POST" action="/categories/<%= c.id %>/update" class="d-inline-flex gap-2 align-items-center mb-1">
|
||||||
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||||
<input name="name" class="form-control form-control-sm" value="<%= c.name %>" required>
|
<input name="name" class="form-control form-control-sm" value="<%= c.name %>" required>
|
||||||
<select name="location_id" class="form-select form-select-sm">
|
<select name="location_id" class="form-select form-select-sm">
|
||||||
@@ -44,7 +56,15 @@
|
|||||||
</select>
|
</select>
|
||||||
<button class="btn btn-sm btn-primary">Save</button>
|
<button class="btn btn-sm btn-primary">Save</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="POST" action="/categories/<%= c.id %>/delete" class="d-inline-flex gap-2" onsubmit="return confirm('Delete category?')">
|
<form method="POST" action="/categories/<%= c.id %>/toggle" class="d-inline" onsubmit="<%= c.is_active === 1 ? "return confirm('Disable this category?')" : '' %>">
|
||||||
|
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||||
|
<% if (c.is_active === 1) { %>
|
||||||
|
<button class="btn btn-sm btn-warning">Disable</button>
|
||||||
|
<% } else { %>
|
||||||
|
<button class="btn btn-sm btn-success">Enable</button>
|
||||||
|
<% } %>
|
||||||
|
</form>
|
||||||
|
<form method="POST" action="/categories/<%= c.id %>/delete" class="d-inline" onsubmit="return confirm('Delete category?')">
|
||||||
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||||
<button class="btn btn-sm btn-danger">Delete</button>
|
<button class="btn btn-sm btn-danger">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -52,13 +72,38 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<% var subs = subcategoriesByCategory[c.id] || []; %>
|
<% var subs = subcategoriesByCategory[c.id] || []; %>
|
||||||
<% subs.forEach(function(s) { %>
|
<% subs.forEach(function(s) { %>
|
||||||
<tr class="table-light">
|
<tr class="table-light<%= s.is_active === 0 ? ' table-secondary' : '' %>">
|
||||||
<td></td>
|
<td></td>
|
||||||
<td class="ps-4"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted me-1"><polyline points="9 18 15 12 9 6"/></svg> <%= s.name %></td>
|
<td class="ps-4">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted me-1"><polyline points="9 18 15 12 9 6"/></svg> <%= s.name %>
|
||||||
|
<% if (s.is_active === 0) { %>
|
||||||
|
<span class="badge bg-secondary ms-1">Disabled</span>
|
||||||
|
<% } %>
|
||||||
|
</td>
|
||||||
<td></td>
|
<td></td>
|
||||||
<td><%= s.product_count || 0 %></td>
|
<td><%= s.product_count || 0 %></td>
|
||||||
<td>
|
<td>
|
||||||
<form method="POST" action="/categories/subcategories/<%= s.id %>/delete" class="d-inline-flex gap-2" onsubmit="return confirm('Delete subcategory?')">
|
<% if (s.is_active === 1) { %>
|
||||||
|
<span class="badge bg-success">Active</span>
|
||||||
|
<% } else { %>
|
||||||
|
<span class="badge bg-secondary">Disabled</span>
|
||||||
|
<% } %>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<form method="POST" action="/categories/subcategories/<%= s.id %>/update" class="d-inline-flex gap-1 align-items-center mb-1">
|
||||||
|
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||||
|
<input name="name" class="form-control form-control-sm" value="<%= s.name %>" required size="12" style="width:120px">
|
||||||
|
<button class="btn btn-sm btn-primary">Save</button>
|
||||||
|
</form>
|
||||||
|
<form method="POST" action="/categories/subcategories/<%= s.id %>/toggle" class="d-inline" onsubmit="<%= s.is_active === 1 ? "return confirm('Disable this subcategory?')" : '' %>">
|
||||||
|
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||||
|
<% if (s.is_active === 1) { %>
|
||||||
|
<button class="btn btn-sm btn-warning">Disable</button>
|
||||||
|
<% } else { %>
|
||||||
|
<button class="btn btn-sm btn-success">Enable</button>
|
||||||
|
<% } %>
|
||||||
|
</form>
|
||||||
|
<form method="POST" action="/categories/subcategories/<%= s.id %>/delete" class="d-inline" onsubmit="return confirm('Delete subcategory?')">
|
||||||
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||||
<button class="btn btn-sm btn-danger">Delete</button>
|
<button class="btn btn-sm btn-danger">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -74,7 +119,7 @@
|
|||||||
<button class="btn btn-sm btn-primary">Add</button>
|
<button class="btn btn-sm btn-primary">Add</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
<td></td><td></td><td></td>
|
<td></td><td></td><td></td><td></td>
|
||||||
</tr>
|
</tr>
|
||||||
<% }); %>
|
<% }); %>
|
||||||
<% } %>
|
<% } %>
|
||||||
|
|||||||
@@ -30,13 +30,13 @@
|
|||||||
<div class="panel-container show">
|
<div class="panel-container show">
|
||||||
<div class="panel-content">
|
<div class="panel-content">
|
||||||
<table class="table table-striped table-bordered table-hover mb-0">
|
<table class="table table-striped table-bordered table-hover mb-0">
|
||||||
<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>
|
<thead><tr><th>ID</th><th>Country</th><th>City</th><th>District</th><th>Categories</th><th>Products</th><th>Status</th><th>Actions</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<% if (locations.length === 0) { %>
|
<% if (locations.length === 0) { %>
|
||||||
<tr><td colspan="7" class="text-muted">No locations</td></tr>
|
<tr><td colspan="8" class="text-muted">No locations</td></tr>
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
<% locations.forEach(function(l) { %>
|
<% locations.forEach(function(l) { %>
|
||||||
<tr>
|
<tr<%= l.is_active === 0 ? ' class="table-secondary"' : '' %>>
|
||||||
<td><%= l.id %></td>
|
<td><%= l.id %></td>
|
||||||
<td><%= l.country %></td>
|
<td><%= l.country %></td>
|
||||||
<td><%= l.city %></td>
|
<td><%= l.city %></td>
|
||||||
@@ -44,6 +44,28 @@
|
|||||||
<td><%= l.category_count || 0 %></td>
|
<td><%= l.category_count || 0 %></td>
|
||||||
<td><%= l.product_count || 0 %></td>
|
<td><%= l.product_count || 0 %></td>
|
||||||
<td>
|
<td>
|
||||||
|
<% if (l.is_active === 1) { %>
|
||||||
|
<span class="badge bg-success">Active</span>
|
||||||
|
<% } else { %>
|
||||||
|
<span class="badge bg-secondary">Disabled</span>
|
||||||
|
<% } %>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<form method="POST" action="/locations/<%= l.id %>/update" class="d-inline-flex gap-1 align-items-center mb-1">
|
||||||
|
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||||
|
<input name="country" class="form-control form-control-sm" value="<%= l.country %>" required size="8" style="width:80px">
|
||||||
|
<input name="city" class="form-control form-control-sm" value="<%= l.city %>" required size="8" style="width:80px">
|
||||||
|
<input name="district" class="form-control form-control-sm" value="<%= l.district || '' %>" size="8" style="width:80px">
|
||||||
|
<button class="btn btn-sm btn-primary">Save</button>
|
||||||
|
</form>
|
||||||
|
<form method="POST" action="/locations/<%= l.id %>/toggle" class="d-inline" onsubmit="<%= l.is_active === 1 ? "return confirm('Disable this location?')" : '' %>">
|
||||||
|
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||||
|
<% if (l.is_active === 1) { %>
|
||||||
|
<button class="btn btn-sm btn-warning">Disable</button>
|
||||||
|
<% } else { %>
|
||||||
|
<button class="btn btn-sm btn-success">Enable</button>
|
||||||
|
<% } %>
|
||||||
|
</form>
|
||||||
<form method="POST" action="/locations/<%= l.id %>/delete" class="d-inline" onsubmit="return confirm('Delete location?')">
|
<form method="POST" action="/locations/<%= l.id %>/delete" class="d-inline" onsubmit="return confirm('Delete location?')">
|
||||||
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||||
<button class="btn btn-sm btn-danger">Delete</button>
|
<button class="btn btn-sm btn-danger">Delete</button>
|
||||||
|
|||||||
@@ -16,6 +16,13 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
<li class="nav-item">
|
||||||
|
<a href="/categories" title="Categories" data-filter-tags="categories subcategories">
|
||||||
|
<svg class="sa-icon"><use href="/icons/sprite.svg#folder"></use></svg>
|
||||||
|
<span class="nav-link-text" data-i18n>Categories</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a href="/users" title="Users" data-filter-tags="users customers">
|
<a href="/users" title="Users" data-filter-tags="users customers">
|
||||||
<svg class="sa-icon"><use href="/icons/sprite.svg#users"></use></svg>
|
<svg class="sa-icon"><use href="/icons/sprite.svg#users"></use></svg>
|
||||||
|
|||||||
33
src/migrations/011_active_flags.js
Normal file
33
src/migrations/011_active_flags.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import logger from '../utils/logger.js';
|
||||||
|
|
||||||
|
const TABLES = ['locations', 'categories', 'subcategories'];
|
||||||
|
|
||||||
|
export default async function migration011(db, checkColumnExists) {
|
||||||
|
let allExist = true;
|
||||||
|
for (const table of TABLES) {
|
||||||
|
if (!(await checkColumnExists(table, 'is_active'))) {
|
||||||
|
allExist = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (allExist) {
|
||||||
|
logger.info('Migration 011: is_active columns already exist, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.runAsync('BEGIN TRANSACTION');
|
||||||
|
|
||||||
|
for (const table of TABLES) {
|
||||||
|
if (await checkColumnExists(table, 'is_active')) {
|
||||||
|
logger.info(`Migration 011: is_active already exists on ${table}, skipping`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await db.runAsync(
|
||||||
|
`ALTER TABLE ${table} ADD COLUMN is_active INTEGER NOT NULL DEFAULT 1`
|
||||||
|
);
|
||||||
|
logger.info(`Migration 011: Added is_active column to ${table} table`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.runAsync('COMMIT');
|
||||||
|
logger.info('Migration 011: Added is_active columns to locations, categories, subcategories');
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ export async function runMigrations() {
|
|||||||
(await import('./008_user_language.js')).default,
|
(await import('./008_user_language.js')).default,
|
||||||
(await import('./009_user_language_set.js')).default,
|
(await import('./009_user_language_set.js')).default,
|
||||||
(await import('./010_is_mono_product.js')).default,
|
(await import('./010_is_mono_product.js')).default,
|
||||||
|
(await import('./011_active_flags.js')).default,
|
||||||
];
|
];
|
||||||
|
|
||||||
for (let i = currentVersion; i < migrations.length; i++) {
|
for (let i = currentVersion; i < migrations.length; i++) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ class CategoryService {
|
|||||||
static async getCategoriesByLocationId(locationId) {
|
static async getCategoriesByLocationId(locationId) {
|
||||||
try {
|
try {
|
||||||
const categories = await db.allAsync(
|
const categories = await db.allAsync(
|
||||||
'SELECT * FROM categories WHERE location_id = ?',
|
'SELECT * FROM categories WHERE location_id = ? AND is_active = 1',
|
||||||
[locationId]
|
[locationId]
|
||||||
);
|
);
|
||||||
return categories;
|
return categories;
|
||||||
@@ -17,7 +17,7 @@ class CategoryService {
|
|||||||
|
|
||||||
static async getSubcategoriesByCategoryId(categoryId) {
|
static async getSubcategoriesByCategoryId(categoryId) {
|
||||||
return await db.allAsync(
|
return await db.allAsync(
|
||||||
'SELECT id, name FROM subcategories WHERE category_id = ? ORDER BY name',
|
'SELECT id, name FROM subcategories WHERE category_id = ? AND is_active = 1 ORDER BY name',
|
||||||
[categoryId]
|
[categoryId]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,26 +3,26 @@ import logger from "../utils/logger.js";
|
|||||||
|
|
||||||
class LocationService {
|
class LocationService {
|
||||||
static async getCountries() {
|
static async getCountries() {
|
||||||
return await db.allAsync('SELECT DISTINCT country FROM locations ORDER BY country');
|
return await db.allAsync('SELECT DISTINCT country FROM locations WHERE is_active = 1 ORDER BY country');
|
||||||
}
|
}
|
||||||
|
|
||||||
static async getCitiesByCountry(country) {
|
static async getCitiesByCountry(country) {
|
||||||
return await db.allAsync(
|
return await db.allAsync(
|
||||||
'SELECT DISTINCT city FROM locations WHERE country = ? ORDER BY city',
|
'SELECT DISTINCT city FROM locations WHERE country = ? AND is_active = 1 ORDER BY city',
|
||||||
[country]
|
[country]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static async getDistrictsByCountryAndCity(country, city) {
|
static async getDistrictsByCountryAndCity(country, city) {
|
||||||
return await db.allAsync(
|
return await db.allAsync(
|
||||||
'SELECT id, district FROM locations WHERE country = ? AND city = ? ORDER BY district',
|
'SELECT id, district FROM locations WHERE country = ? AND city = ? AND is_active = 1 ORDER BY district',
|
||||||
[country, city]
|
[country, city]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static async getLocationsByCountryAndCity(country, city) {
|
static async getLocationsByCountryAndCity(country, city) {
|
||||||
return await db.allAsync(
|
return await db.allAsync(
|
||||||
'SELECT id, country, city, district FROM locations WHERE country = ? AND city = ? ORDER BY district',
|
'SELECT id, country, city, district FROM locations WHERE country = ? AND city = ? AND is_active = 1 ORDER BY district',
|
||||||
[country, city]
|
[country, city]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -30,7 +30,7 @@ class LocationService {
|
|||||||
static async getLocation(country, city, district) {
|
static async getLocation(country, city, district) {
|
||||||
try {
|
try {
|
||||||
const location = await db.getAsync(
|
const location = await db.getAsync(
|
||||||
'SELECT * FROM locations WHERE country = ? AND city = ? AND district = ?',
|
'SELECT * FROM locations WHERE country = ? AND city = ? AND district = ? AND is_active = 1',
|
||||||
[country, city, district]
|
[country, city, district]
|
||||||
);
|
);
|
||||||
return location;
|
return location;
|
||||||
|
|||||||
Reference in New Issue
Block a user