From c882ab5d4353928ff35fcdfa2bb2de7440ff6f9b Mon Sep 17 00:00:00 2001 From: David Beccue Date: Sat, 16 May 2026 15:47:42 +0500 Subject: [PATCH] Restructure /admin into tabbed area with password gate Backups moves under /admin/backups; new Reports and Categories tabs join it (categories migrated from the top-level /categories route). The dashboard's SKU/low-stock/inventory-value cards move into Reports, which also adds sales totals and a top-selling parts list. A 5-minute sliding-cookie password gate (27182818) now wraps every /admin request, including the backup download endpoint, via a hooks.server.js auth check. The login page sits at /admin/login and escapes the admin tab chrome via a layout reset. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/hooks.server.js | 16 ++ src/lib/i18n/en.json | 40 ++++- src/lib/i18n/tg.json | 40 ++++- src/lib/server/admin-auth.js | 34 ++++ src/lib/server/parts.js | 13 -- src/lib/server/reports.js | 94 ++++++++++ src/routes/+page.server.js | 3 +- src/routes/+page.svelte | 39 +---- src/routes/admin/+layout.svelte | 61 +++++++ src/routes/admin/+page.server.js | 34 +--- src/routes/admin/+page.svelte | 118 ------------- .../{ => admin}/categories/+page.server.js | 0 .../{ => admin}/categories/+page.svelte | 2 +- src/routes/admin/login/+page.server.js | 32 ++++ src/routes/admin/login/+page@.svelte | 25 +++ src/routes/admin/reports/+page.server.js | 10 ++ src/routes/admin/reports/+page.svelte | 163 ++++++++++++++++++ 17 files changed, 517 insertions(+), 207 deletions(-) create mode 100644 src/lib/server/admin-auth.js create mode 100644 src/lib/server/reports.js create mode 100644 src/routes/admin/+layout.svelte delete mode 100644 src/routes/admin/+page.svelte rename src/routes/{ => admin}/categories/+page.server.js (100%) rename src/routes/{ => admin}/categories/+page.svelte (99%) create mode 100644 src/routes/admin/login/+page.server.js create mode 100644 src/routes/admin/login/+page@.svelte create mode 100644 src/routes/admin/reports/+page.server.js create mode 100644 src/routes/admin/reports/+page.svelte diff --git a/src/hooks.server.js b/src/hooks.server.js index 7c70148..fc1e08b 100644 --- a/src/hooks.server.js +++ b/src/hooks.server.js @@ -1,5 +1,12 @@ +import { redirect } from '@sveltejs/kit'; import { getDb } from '$lib/server/db.js'; import { startBackupScheduler } from '$lib/server/backup.js'; +import { + isAdminAuthed, + isAdminPath, + isLoginPath, + refreshAdminCookie +} from '$lib/server/admin-auth.js'; // Open (and warm) the database on server startup so the first request // doesn't pay the cost. @@ -8,5 +15,14 @@ startBackupScheduler(); /** @type {import('@sveltejs/kit').Handle} */ export async function handle({ event, resolve }) { + const path = event.url.pathname; + if (isAdminPath(path) && !isLoginPath(path)) { + if (!isAdminAuthed(event)) { + const next = path + event.url.search; + throw redirect(303, `/admin/login?next=${encodeURIComponent(next)}`); + } + // Sliding 5-minute expiry: any request under /admin extends the session. + refreshAdminCookie(event); + } return resolve(event); } diff --git a/src/lib/i18n/en.json b/src/lib/i18n/en.json index 8c95654..2b85472 100644 --- a/src/lib/i18n/en.json +++ b/src/lib/i18n/en.json @@ -9,7 +9,7 @@ "new_sale": "New sale", "movements": "Movements", "suppliers": "Suppliers", - "admin": "Backups", + "admin": "Admin", "new_part": "New part", "new_movement": "Record movement" }, @@ -101,7 +101,20 @@ } }, "admin": { - "title": "Backups & Restore", + "title": "Admin", + "tabs": { + "backups": "Backups", + "reports": "Reports", + "categories": "Categories" + }, + "login": { + "title": "Admin sign-in", + "intro": "Enter the admin password to continue.", + "password": "Password", + "submit": "Sign in", + "wrong_password": "Wrong password. Try again." + }, + "backups_heading": "Backups & Restore", "warning_title": "Important: copy backups to a USB stick regularly!", "warning_body": "Backups are kept on this computer only. If the hard drive fails, all of your data and all backups will be lost. At least once a week, plug in a USB stick and click the Download button next to a recent backup, then save the file onto the stick.", "backup_now": "Back up now", @@ -121,6 +134,29 @@ "restore_failed": "Restore failed. See the server logs." } }, + "reports": { + "sales_heading": "Sales", + "inventory_heading": "Inventory", + "today": "Today", + "last_7_days": "Last 7 days", + "this_month": "This month", + "all_time": "All time", + "invoices": "invoices", + "active_skus": "Active SKUs", + "units_on_hand": "Units on hand", + "cost_value": "Value (at cost)", + "sale_value": "Value (at sale)", + "low_stock": "Low stock", + "out_of_stock": "Out of stock", + "top_parts": "Top selling parts", + "units_sold": "Units sold", + "revenue": "Revenue", + "recent_sales": "Recent sales", + "saved_at": "Saved", + "lines": "Lines", + "view": "View", + "no_sales_yet": "No sales recorded yet." + }, "invoices": { "title": "New sale", "saved_title": "Invoice", diff --git a/src/lib/i18n/tg.json b/src/lib/i18n/tg.json index 57e1ffc..0de152d 100644 --- a/src/lib/i18n/tg.json +++ b/src/lib/i18n/tg.json @@ -9,7 +9,7 @@ "new_sale": "Фурӯши нав", "movements": "Ҳаракатҳо", "suppliers": "Таъминкунандагон", - "admin": "Нусхаҳо", + "admin": "Идора", "new_part": "Қисми нав", "new_movement": "Сабти ҳаракат" }, @@ -101,7 +101,20 @@ } }, "admin": { - "title": "Нусхабардорӣ ва барқарорсозӣ", + "title": "Идора", + "tabs": { + "backups": "Нусхаҳо", + "reports": "Ҳисоботҳо", + "categories": "Категорияҳо" + }, + "login": { + "title": "Воридшавӣ ба идора", + "intro": "Барои идома додан, рамзи идораро ворид кунед.", + "password": "Рамз", + "submit": "Ворид шудан", + "wrong_password": "Рамз нодуруст. Аз нав кӯшиш кунед." + }, + "backups_heading": "Нусхабардорӣ ва барқарорсозӣ", "warning_title": "Муҳим: нусхаҳоро мунтазам ба USB-флешка нусхабардорӣ кунед!", "warning_body": "Нусхаҳо танҳо дар ин компютер нигоҳ дошта мешаванд. Агар диски сахт вайрон шавад, ҳамаи маълумот ва ҳамаи нусхаҳо нест мешаванд. Ҳафтае як маротиба USB-флешкаро пайваст кунед, тугмаи «Зеркашӣ»-ро дар сатри як нусхаи нав пахш кунед ва файлро ба флешка захира кунед.", "backup_now": "Ҳозир нусха гирифтан", @@ -121,6 +134,29 @@ "restore_failed": "Барқарорсозӣ ноком шуд. Логи серверро бинед." } }, + "reports": { + "sales_heading": "Фурӯш", + "inventory_heading": "Захира", + "today": "Имрӯз", + "last_7_days": "7 рӯзи охир", + "this_month": "Ин моҳ", + "all_time": "Тамоми давра", + "invoices": "фактура", + "active_skus": "SKU-ҳои фаъол", + "units_on_hand": "Дар анбор", + "cost_value": "Арзиш (бо нархи харид)", + "sale_value": "Арзиш (бо нархи фурӯш)", + "low_stock": "Захираи кам", + "out_of_stock": "Тамом шуд", + "top_parts": "Қисмҳои серфурӯш", + "units_sold": "Фурӯхта шуд", + "revenue": "Даромад", + "recent_sales": "Фурӯшҳои охирин", + "saved_at": "Сабт шуд", + "lines": "Сатрҳо", + "view": "Дидан", + "no_sales_yet": "Ҳоло фурӯше сабт нашудааст." + }, "invoices": { "title": "Фурӯши нав", "saved_title": "Фактура", diff --git a/src/lib/server/admin-auth.js b/src/lib/server/admin-auth.js new file mode 100644 index 0000000..3217d2e --- /dev/null +++ b/src/lib/server/admin-auth.js @@ -0,0 +1,34 @@ +import { randomBytes } from 'node:crypto'; + +export const ADMIN_PASSWORD = '27182818'; +export const ADMIN_COOKIE = 'admin_session'; +export const ADMIN_TTL_SECONDS = 5 * 60; + +// A fresh token is minted at server startup, so any cookies that survived a +// restart are invalidated. There's no shared cookie value to forge. +export const ADMIN_TOKEN = randomBytes(32).toString('hex'); + +export function adminCookieOptions() { + return { + path: '/admin', + httpOnly: true, + sameSite: 'lax', + maxAge: ADMIN_TTL_SECONDS + }; +} + +export function isAdminAuthed(event) { + return event.cookies.get(ADMIN_COOKIE) === ADMIN_TOKEN; +} + +export function refreshAdminCookie(event) { + event.cookies.set(ADMIN_COOKIE, ADMIN_TOKEN, adminCookieOptions()); +} + +export function isAdminPath(pathname) { + return pathname === '/admin' || pathname.startsWith('/admin/'); +} + +export function isLoginPath(pathname) { + return pathname === '/admin/login' || pathname.startsWith('/admin/login/'); +} diff --git a/src/lib/server/parts.js b/src/lib/server/parts.js index 22827ae..a861e19 100644 --- a/src/lib/server/parts.js +++ b/src/lib/server/parts.js @@ -132,19 +132,6 @@ function toDirams(value) { return Math.round(num * 100); } -export function dashboardStats() { - const db = getDb(); - const total = db.prepare(`SELECT COUNT(*) AS n FROM parts WHERE active = 1`).get().n; - const lowStock = db.prepare(` - SELECT COUNT(*) AS n FROM parts - WHERE active = 1 AND quantity_on_hand <= reorder_level - `).get().n; - const value = db.prepare(` - SELECT COALESCE(SUM(quantity_on_hand * cost_price), 0) AS v FROM parts WHERE active = 1 - `).get().v; - return { total, lowStock, inventoryValueDirams: value }; -} - export function lowStockParts(limit = 10) { return getDb().prepare(` SELECT * FROM parts diff --git a/src/lib/server/reports.js b/src/lib/server/reports.js new file mode 100644 index 0000000..e5d92bd --- /dev/null +++ b/src/lib/server/reports.js @@ -0,0 +1,94 @@ +import { getDb } from './db.js'; + +// All time windows are computed in local time using SQLite's `datetime('now', 'localtime')`. + +export function salesSummary() { + const db = getDb(); + const row = db.prepare(` + SELECT + COUNT(*) AS invoice_count, + COALESCE(SUM(total_dirams), 0) AS total_dirams + FROM invoices + WHERE status = 'saved' + `).get(); + + const today = db.prepare(` + SELECT + COUNT(*) AS invoice_count, + COALESCE(SUM(total_dirams), 0) AS total_dirams + FROM invoices + WHERE status = 'saved' + AND date(saved_at, 'localtime') = date('now', 'localtime') + `).get(); + + const week = db.prepare(` + SELECT + COUNT(*) AS invoice_count, + COALESCE(SUM(total_dirams), 0) AS total_dirams + FROM invoices + WHERE status = 'saved' + AND date(saved_at, 'localtime') >= date('now', 'localtime', '-6 days') + `).get(); + + const month = db.prepare(` + SELECT + COUNT(*) AS invoice_count, + COALESCE(SUM(total_dirams), 0) AS total_dirams + FROM invoices + WHERE status = 'saved' + AND strftime('%Y-%m', saved_at, 'localtime') = strftime('%Y-%m', 'now', 'localtime') + `).get(); + + return { all_time: row, today, week, month }; +} + +export function topSellingParts(limit = 10) { + return getDb().prepare(` + SELECT + p.id, p.sku, p.name_en, p.name_tg, + SUM(l.quantity) AS units_sold, + SUM(l.quantity * l.unit_price_dirams) AS revenue_dirams + FROM invoice_lines l + JOIN invoices i ON i.id = l.invoice_id + JOIN parts p ON p.id = l.part_id + WHERE i.status = 'saved' AND l.affects_inventory = 1 + GROUP BY p.id + ORDER BY units_sold DESC, revenue_dirams DESC + LIMIT ? + `).all(limit); +} + +export function inventorySummary() { + const db = getDb(); + const all = db.prepare(` + SELECT + COUNT(*) AS sku_count, + COALESCE(SUM(quantity_on_hand), 0) AS units_on_hand, + COALESCE(SUM(quantity_on_hand * cost_price), 0) AS cost_value_dirams, + COALESCE(SUM(quantity_on_hand * sale_price), 0) AS sale_value_dirams + FROM parts WHERE active = 1 + `).get(); + + const lowStockCount = db.prepare(` + SELECT COUNT(*) AS n FROM parts + WHERE active = 1 AND quantity_on_hand <= reorder_level + `).get().n; + + const outOfStockCount = db.prepare(` + SELECT COUNT(*) AS n FROM parts + WHERE active = 1 AND quantity_on_hand <= 0 + `).get().n; + + return { ...all, lowStockCount, outOfStockCount }; +} + +export function recentSales(limit = 10) { + return getDb().prepare(` + SELECT id, total_dirams, saved_at, + (SELECT COUNT(*) FROM invoice_lines WHERE invoice_id = invoices.id) AS line_count + FROM invoices + WHERE status = 'saved' + ORDER BY saved_at DESC, id DESC + LIMIT ? + `).all(limit); +} diff --git a/src/routes/+page.server.js b/src/routes/+page.server.js index 801db64..2c9b7ba 100644 --- a/src/routes/+page.server.js +++ b/src/routes/+page.server.js @@ -1,9 +1,8 @@ -import { dashboardStats, lowStockParts } from '$lib/server/parts.js'; +import { lowStockParts } from '$lib/server/parts.js'; import { recentMovements } from '$lib/server/movements.js'; export function load() { return { - stats: dashboardStats(), lowStock: lowStockParts(10), movements: recentMovements(10) }; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index ca9659c..89ba308 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,31 +1,13 @@

{$t('dashboard.title')}

-
-
-
{$t('dashboard.total_skus')}
-
{stats.total}
-
-
-
{$t('dashboard.low_stock')}
-
0}>{stats.lowStock}
-
-
-
{$t('dashboard.inventory_value')}
-
- {formatMoney(stats.inventoryValueDirams, lang)} - {$t('common.currency_short')} -
-
-
-

{$t('dashboard.low_stock_list')}

{#if lowStock.length === 0}

{$t('common.none')}

@@ -80,20 +62,3 @@ {/if} - diff --git a/src/routes/admin/+layout.svelte b/src/routes/admin/+layout.svelte new file mode 100644 index 0000000..a8c2205 --- /dev/null +++ b/src/routes/admin/+layout.svelte @@ -0,0 +1,61 @@ + + +

{$t('admin.title')}

+ + + +
+ +
+ + diff --git a/src/routes/admin/+page.server.js b/src/routes/admin/+page.server.js index ac950ed..4148396 100644 --- a/src/routes/admin/+page.server.js +++ b/src/routes/admin/+page.server.js @@ -1,35 +1,5 @@ -import { fail } from '@sveltejs/kit'; -import { listBackups, takeBackup, restoreBackup } from '$lib/server/backup.js'; +import { redirect } from '@sveltejs/kit'; export function load() { - return { - backups: listBackups().map((b) => ({ - name: b.name, - size: b.size, - createdAt: b.createdAt.toISOString() - })) - }; + throw redirect(307, '/admin/reports'); } - -export const actions = { - backup: async () => { - try { - const b = await takeBackup(); - return { ok: true, message: 'admin.flash.backup_taken', name: b.name }; - } catch (e) { - console.error('[admin] manual backup failed', e); - return fail(500, { message: 'admin.flash.backup_failed' }); - } - }, - restore: async ({ request }) => { - const form = await request.formData(); - const name = String(form.get('name') ?? ''); - try { - await restoreBackup(name); - return { ok: true, message: 'admin.flash.restored' }; - } catch (e) { - console.error('[admin] restore failed', e); - return fail(500, { message: 'admin.flash.restore_failed' }); - } - } -}; diff --git a/src/routes/admin/+page.svelte b/src/routes/admin/+page.svelte deleted file mode 100644 index 145632f..0000000 --- a/src/routes/admin/+page.svelte +++ /dev/null @@ -1,118 +0,0 @@ - - -

{$t('admin.title')}

- -
- {$t('admin.warning_title')} -

{$t('admin.warning_body')}

-
- -{#if form?.message} -
- {$t(form.message)} -
-{/if} - -
-
async ({ update }) => { await update(); }}> - -
-

{$t('admin.auto_note')}

-
- -

{$t('admin.list_title')}

- -{#if backups.length === 0} -

{$t('admin.no_backups')}

-{:else} - - - - - - - - - - {#each backups as b} - - - - - - {/each} - -
{$t('admin.created_at')}{$t('admin.size')}
{formatWhen(b.createdAt, $locale)}{formatSize(b.size)} - - {$t('admin.download')} - -
async ({ update }) => { await update(); await invalidateAll(); }} - on:submit={(e) => { if (!confirm($t('admin.restore_confirm'))) e.preventDefault(); }}> - - -
-
-

{$t('admin.prune_note')}

-{/if} - - diff --git a/src/routes/categories/+page.server.js b/src/routes/admin/categories/+page.server.js similarity index 100% rename from src/routes/categories/+page.server.js rename to src/routes/admin/categories/+page.server.js diff --git a/src/routes/categories/+page.svelte b/src/routes/admin/categories/+page.svelte similarity index 99% rename from src/routes/categories/+page.svelte rename to src/routes/admin/categories/+page.svelte index 270cd84..836da6e 100644 --- a/src/routes/categories/+page.svelte +++ b/src/routes/admin/categories/+page.svelte @@ -24,7 +24,7 @@ } -

{$t('categories.title')}

+

{$t('categories.title')}

{$t('categories.intro')}

diff --git a/src/routes/admin/login/+page.server.js b/src/routes/admin/login/+page.server.js new file mode 100644 index 0000000..553516d --- /dev/null +++ b/src/routes/admin/login/+page.server.js @@ -0,0 +1,32 @@ +import { fail, redirect } from '@sveltejs/kit'; +import { + ADMIN_PASSWORD, + isAdminAuthed, + refreshAdminCookie +} from '$lib/server/admin-auth.js'; + +function safeNext(raw) { + if (!raw) return '/admin'; + if (!raw.startsWith('/admin')) return '/admin'; + if (raw === '/admin/login' || raw.startsWith('/admin/login/')) return '/admin'; + return raw; +} + +export function load(event) { + if (isAdminAuthed(event)) { + throw redirect(303, safeNext(event.url.searchParams.get('next'))); + } + return {}; +} + +export const actions = { + default: async (event) => { + const data = await event.request.formData(); + const password = String(data.get('password') ?? ''); + if (password !== ADMIN_PASSWORD) { + return fail(401, { error: 'admin.login.wrong_password' }); + } + refreshAdminCookie(event); + throw redirect(303, safeNext(event.url.searchParams.get('next'))); + } +}; diff --git a/src/routes/admin/login/+page@.svelte b/src/routes/admin/login/+page@.svelte new file mode 100644 index 0000000..70b2873 --- /dev/null +++ b/src/routes/admin/login/+page@.svelte @@ -0,0 +1,25 @@ + + +

{$t('admin.login.title')}

+ +

{$t('admin.login.intro')}

+ +
+ {#if form?.error} +
{$t(form.error)}
+ {/if} + +
+ +
+
+ + diff --git a/src/routes/admin/reports/+page.server.js b/src/routes/admin/reports/+page.server.js new file mode 100644 index 0000000..388ec6a --- /dev/null +++ b/src/routes/admin/reports/+page.server.js @@ -0,0 +1,10 @@ +import { salesSummary, topSellingParts, inventorySummary, recentSales } from '$lib/server/reports.js'; + +export function load() { + return { + sales: salesSummary(), + topParts: topSellingParts(10), + inventory: inventorySummary(), + recentSales: recentSales(10) + }; +} diff --git a/src/routes/admin/reports/+page.svelte b/src/routes/admin/reports/+page.svelte new file mode 100644 index 0000000..d79de7b --- /dev/null +++ b/src/routes/admin/reports/+page.svelte @@ -0,0 +1,163 @@ + + +

{$t('reports.sales_heading')}

+ +
+
+
{$t('reports.today')}
+
+ {formatMoney(sales.today.total_dirams, lang)} + {$t('common.currency_short')} +
+
{sales.today.invoice_count} {$t('reports.invoices')}
+
+
+
{$t('reports.last_7_days')}
+
+ {formatMoney(sales.week.total_dirams, lang)} + {$t('common.currency_short')} +
+
{sales.week.invoice_count} {$t('reports.invoices')}
+
+
+
{$t('reports.this_month')}
+
+ {formatMoney(sales.month.total_dirams, lang)} + {$t('common.currency_short')} +
+
{sales.month.invoice_count} {$t('reports.invoices')}
+
+
+
{$t('reports.all_time')}
+
+ {formatMoney(sales.all_time.total_dirams, lang)} + {$t('common.currency_short')} +
+
{sales.all_time.invoice_count} {$t('reports.invoices')}
+
+
+ +

{$t('reports.inventory_heading')}

+ +
+
+
{$t('reports.active_skus')}
+
{inventory.sku_count}
+
+
+
{$t('reports.units_on_hand')}
+
{inventory.units_on_hand}
+
+
+
{$t('reports.cost_value')}
+
+ {formatMoney(inventory.cost_value_dirams, lang)} + {$t('common.currency_short')} +
+
+
+
{$t('reports.sale_value')}
+
+ {formatMoney(inventory.sale_value_dirams, lang)} + {$t('common.currency_short')} +
+
+
+
{$t('reports.low_stock')}
+
0}>{inventory.lowStockCount}
+
+
+
{$t('reports.out_of_stock')}
+
0}>{inventory.outOfStockCount}
+
+
+ +

{$t('reports.top_parts')}

+{#if topParts.length === 0} +

{$t('reports.no_sales_yet')}

+{:else} + + + + + + + + + + + {#each topParts as p} + + + + + + + {/each} + +
{$t('parts.sku')}{$t('parts.name')}{$t('reports.units_sold')}{$t('reports.revenue')}
{p.sku}{localized(p, 'name', lang)}{p.units_sold} + {formatMoney(p.revenue_dirams, lang)} + {$t('common.currency_short')} +
+{/if} + +

{$t('reports.recent_sales')}

+{#if recentSales.length === 0} +

{$t('reports.no_sales_yet')}

+{:else} + + + + + + + + + + + {#each recentSales as s} + + + + + + + {/each} + +
{$t('reports.saved_at')}{$t('reports.lines')}{$t('common.total')}
{formatWhen(s.saved_at)}{s.line_count} + {formatMoney(s.total_dirams, lang)} + {$t('common.currency_short')} + {$t('reports.view')}
+{/if} + +