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('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.warning_body')}
-{$t('admin.no_backups')}
-{:else} -| {$t('admin.created_at')} | -{$t('admin.size')} | -- |
|---|---|---|
| {formatWhen(b.createdAt, $locale)} | -{formatSize(b.size)} | -- - {$t('admin.download')} - - - | -
{$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.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.intro')}
+ + + + 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.no_sales_yet')}
+{:else} +| {$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')} + | +
{$t('reports.no_sales_yet')}
+{:else} +| {$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')} | +