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) <noreply@anthropic.com>
This commit is contained in:
@ -1,5 +1,12 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
import { getDb } from '$lib/server/db.js';
|
import { getDb } from '$lib/server/db.js';
|
||||||
import { startBackupScheduler } from '$lib/server/backup.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
|
// Open (and warm) the database on server startup so the first request
|
||||||
// doesn't pay the cost.
|
// doesn't pay the cost.
|
||||||
@ -8,5 +15,14 @@ startBackupScheduler();
|
|||||||
|
|
||||||
/** @type {import('@sveltejs/kit').Handle} */
|
/** @type {import('@sveltejs/kit').Handle} */
|
||||||
export async function handle({ event, resolve }) {
|
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);
|
return resolve(event);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
"new_sale": "New sale",
|
"new_sale": "New sale",
|
||||||
"movements": "Movements",
|
"movements": "Movements",
|
||||||
"suppliers": "Suppliers",
|
"suppliers": "Suppliers",
|
||||||
"admin": "Backups",
|
"admin": "Admin",
|
||||||
"new_part": "New part",
|
"new_part": "New part",
|
||||||
"new_movement": "Record movement"
|
"new_movement": "Record movement"
|
||||||
},
|
},
|
||||||
@ -101,7 +101,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"admin": {
|
"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_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.",
|
"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",
|
"backup_now": "Back up now",
|
||||||
@ -121,6 +134,29 @@
|
|||||||
"restore_failed": "Restore failed. See the server logs."
|
"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": {
|
"invoices": {
|
||||||
"title": "New sale",
|
"title": "New sale",
|
||||||
"saved_title": "Invoice",
|
"saved_title": "Invoice",
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
"new_sale": "Фурӯши нав",
|
"new_sale": "Фурӯши нав",
|
||||||
"movements": "Ҳаракатҳо",
|
"movements": "Ҳаракатҳо",
|
||||||
"suppliers": "Таъминкунандагон",
|
"suppliers": "Таъминкунандагон",
|
||||||
"admin": "Нусхаҳо",
|
"admin": "Идора",
|
||||||
"new_part": "Қисми нав",
|
"new_part": "Қисми нав",
|
||||||
"new_movement": "Сабти ҳаракат"
|
"new_movement": "Сабти ҳаракат"
|
||||||
},
|
},
|
||||||
@ -101,7 +101,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
"title": "Нусхабардорӣ ва барқарорсозӣ",
|
"title": "Идора",
|
||||||
|
"tabs": {
|
||||||
|
"backups": "Нусхаҳо",
|
||||||
|
"reports": "Ҳисоботҳо",
|
||||||
|
"categories": "Категорияҳо"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "Воридшавӣ ба идора",
|
||||||
|
"intro": "Барои идома додан, рамзи идораро ворид кунед.",
|
||||||
|
"password": "Рамз",
|
||||||
|
"submit": "Ворид шудан",
|
||||||
|
"wrong_password": "Рамз нодуруст. Аз нав кӯшиш кунед."
|
||||||
|
},
|
||||||
|
"backups_heading": "Нусхабардорӣ ва барқарорсозӣ",
|
||||||
"warning_title": "Муҳим: нусхаҳоро мунтазам ба USB-флешка нусхабардорӣ кунед!",
|
"warning_title": "Муҳим: нусхаҳоро мунтазам ба USB-флешка нусхабардорӣ кунед!",
|
||||||
"warning_body": "Нусхаҳо танҳо дар ин компютер нигоҳ дошта мешаванд. Агар диски сахт вайрон шавад, ҳамаи маълумот ва ҳамаи нусхаҳо нест мешаванд. Ҳафтае як маротиба USB-флешкаро пайваст кунед, тугмаи «Зеркашӣ»-ро дар сатри як нусхаи нав пахш кунед ва файлро ба флешка захира кунед.",
|
"warning_body": "Нусхаҳо танҳо дар ин компютер нигоҳ дошта мешаванд. Агар диски сахт вайрон шавад, ҳамаи маълумот ва ҳамаи нусхаҳо нест мешаванд. Ҳафтае як маротиба USB-флешкаро пайваст кунед, тугмаи «Зеркашӣ»-ро дар сатри як нусхаи нав пахш кунед ва файлро ба флешка захира кунед.",
|
||||||
"backup_now": "Ҳозир нусха гирифтан",
|
"backup_now": "Ҳозир нусха гирифтан",
|
||||||
@ -121,6 +134,29 @@
|
|||||||
"restore_failed": "Барқарорсозӣ ноком шуд. Логи серверро бинед."
|
"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": {
|
"invoices": {
|
||||||
"title": "Фурӯши нав",
|
"title": "Фурӯши нав",
|
||||||
"saved_title": "Фактура",
|
"saved_title": "Фактура",
|
||||||
|
|||||||
34
src/lib/server/admin-auth.js
Normal file
34
src/lib/server/admin-auth.js
Normal file
@ -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/');
|
||||||
|
}
|
||||||
@ -132,19 +132,6 @@ function toDirams(value) {
|
|||||||
return Math.round(num * 100);
|
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) {
|
export function lowStockParts(limit = 10) {
|
||||||
return getDb().prepare(`
|
return getDb().prepare(`
|
||||||
SELECT * FROM parts
|
SELECT * FROM parts
|
||||||
|
|||||||
94
src/lib/server/reports.js
Normal file
94
src/lib/server/reports.js
Normal file
@ -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);
|
||||||
|
}
|
||||||
@ -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';
|
import { recentMovements } from '$lib/server/movements.js';
|
||||||
|
|
||||||
export function load() {
|
export function load() {
|
||||||
return {
|
return {
|
||||||
stats: dashboardStats(),
|
|
||||||
lowStock: lowStockParts(10),
|
lowStock: lowStockParts(10),
|
||||||
movements: recentMovements(10)
|
movements: recentMovements(10)
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,31 +1,13 @@
|
|||||||
<script>
|
<script>
|
||||||
import { locale, t, localized, formatMoney } from '$lib/i18n/store.js';
|
import { locale, t, localized } from '$lib/i18n/store.js';
|
||||||
|
|
||||||
export let data;
|
export let data;
|
||||||
$: lang = $locale;
|
$: lang = $locale;
|
||||||
$: ({ stats, lowStock, movements } = data);
|
$: ({ lowStock, movements } = data);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<h1>{$t('dashboard.title')}</h1>
|
<h1>{$t('dashboard.title')}</h1>
|
||||||
|
|
||||||
<div class="grid">
|
|
||||||
<div class="card stat">
|
|
||||||
<div class="label">{$t('dashboard.total_skus')}</div>
|
|
||||||
<div class="value">{stats.total}</div>
|
|
||||||
</div>
|
|
||||||
<div class="card stat">
|
|
||||||
<div class="label">{$t('dashboard.low_stock')}</div>
|
|
||||||
<div class="value" class:warn={stats.lowStock > 0}>{stats.lowStock}</div>
|
|
||||||
</div>
|
|
||||||
<div class="card stat">
|
|
||||||
<div class="label">{$t('dashboard.inventory_value')}</div>
|
|
||||||
<div class="value">
|
|
||||||
{formatMoney(stats.inventoryValueDirams, lang)}
|
|
||||||
<span class="cur">{$t('common.currency_short')}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2>{$t('dashboard.low_stock_list')}</h2>
|
<h2>{$t('dashboard.low_stock_list')}</h2>
|
||||||
{#if lowStock.length === 0}
|
{#if lowStock.length === 0}
|
||||||
<p class="muted">{$t('common.none')}</p>
|
<p class="muted">{$t('common.none')}</p>
|
||||||
@ -80,20 +62,3 @@
|
|||||||
</table>
|
</table>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
|
||||||
.grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
||||||
gap: 1rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
.stat .label { color: #6b7388; font-size: 0.85rem; }
|
|
||||||
.stat .value {
|
|
||||||
font-size: 1.8rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
.stat .value.warn { color: #b8443f; }
|
|
||||||
.stat .cur { font-size: 0.85rem; color: #6b7388; margin-left: 0.25rem; }
|
|
||||||
</style>
|
|
||||||
|
|||||||
61
src/routes/admin/+layout.svelte
Normal file
61
src/routes/admin/+layout.svelte
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
<script>
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { t } from '$lib/i18n/store.js';
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ href: '/admin/reports', key: 'admin.tabs.reports' },
|
||||||
|
{ href: '/admin/categories', key: 'admin.tabs.categories' },
|
||||||
|
{ href: '/admin/backups', key: 'admin.tabs.backups' }
|
||||||
|
];
|
||||||
|
|
||||||
|
$: current = $page.url.pathname;
|
||||||
|
function isActive(href) {
|
||||||
|
return current === href || current.startsWith(href + '/');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h1>{$t('admin.title')}</h1>
|
||||||
|
|
||||||
|
<nav class="tabs" aria-label="Admin sections">
|
||||||
|
{#each tabs as tab}
|
||||||
|
<a href={tab.href} class:active={isActive(tab.href)} aria-current={isActive(tab.href) ? 'page' : undefined}>
|
||||||
|
{$t(tab.key)}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="tab-panel">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
h1 { margin-bottom: 0.75rem; }
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
border-bottom: 2px solid #e5e8ee;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.tabs a {
|
||||||
|
padding: 0.55rem 1rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #1d2330;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-bottom: none;
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
margin-bottom: -2px;
|
||||||
|
font-weight: 500;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.tabs a:hover {
|
||||||
|
background: #eef1f6;
|
||||||
|
color: #1d2330;
|
||||||
|
}
|
||||||
|
.tabs a.active {
|
||||||
|
background: #fff;
|
||||||
|
border-color: #e5e8ee;
|
||||||
|
border-bottom-color: #fff;
|
||||||
|
color: #006a4e;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -1,35 +1,5 @@
|
|||||||
import { fail } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import { listBackups, takeBackup, restoreBackup } from '$lib/server/backup.js';
|
|
||||||
|
|
||||||
export function load() {
|
export function load() {
|
||||||
return {
|
throw redirect(307, '/admin/reports');
|
||||||
backups: listBackups().map((b) => ({
|
|
||||||
name: b.name,
|
|
||||||
size: b.size,
|
|
||||||
createdAt: b.createdAt.toISOString()
|
|
||||||
}))
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
@ -1,118 +0,0 @@
|
|||||||
<script>
|
|
||||||
import { t, locale } from '$lib/i18n/store.js';
|
|
||||||
import { enhance } from '$app/forms';
|
|
||||||
import { invalidateAll } from '$app/navigation';
|
|
||||||
|
|
||||||
export let data;
|
|
||||||
export let form;
|
|
||||||
$: ({ backups } = data);
|
|
||||||
|
|
||||||
function formatSize(bytes) {
|
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
||||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatWhen(iso, lang) {
|
|
||||||
const d = new Date(iso);
|
|
||||||
const pad = (n) => String(n).padStart(2, '0');
|
|
||||||
const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
||||||
const time = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
||||||
return `${date} ${time}`;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<h1>{$t('admin.title')}</h1>
|
|
||||||
|
|
||||||
<div class="warning">
|
|
||||||
<strong>{$t('admin.warning_title')}</strong>
|
|
||||||
<p>{$t('admin.warning_body')}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if form?.message}
|
|
||||||
<div class={form.ok ? 'flash ok' : 'flash err'}>
|
|
||||||
{$t(form.message)}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="toolbar">
|
|
||||||
<form method="POST" action="?/backup" use:enhance={() => async ({ update }) => { await update(); }}>
|
|
||||||
<button type="submit">{$t('admin.backup_now')}</button>
|
|
||||||
</form>
|
|
||||||
<p class="muted">{$t('admin.auto_note')}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2>{$t('admin.list_title')}</h2>
|
|
||||||
|
|
||||||
{#if backups.length === 0}
|
|
||||||
<p class="muted">{$t('admin.no_backups')}</p>
|
|
||||||
{:else}
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>{$t('admin.created_at')}</th>
|
|
||||||
<th class="num">{$t('admin.size')}</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each backups as b}
|
|
||||||
<tr>
|
|
||||||
<td>{formatWhen(b.createdAt, $locale)}</td>
|
|
||||||
<td class="num">{formatSize(b.size)}</td>
|
|
||||||
<td class="actions">
|
|
||||||
<a class="btn-link" href="/admin/download/{b.name}" download={b.name}>
|
|
||||||
{$t('admin.download')}
|
|
||||||
</a>
|
|
||||||
<form method="POST" action="?/restore" use:enhance={() => async ({ update }) => { await update(); await invalidateAll(); }}
|
|
||||||
on:submit={(e) => { if (!confirm($t('admin.restore_confirm'))) e.preventDefault(); }}>
|
|
||||||
<input type="hidden" name="name" value={b.name} />
|
|
||||||
<button type="submit" class="secondary">{$t('admin.restore')}</button>
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<p class="muted small">{$t('admin.prune_note')}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.warning {
|
|
||||||
background: #fff7e6;
|
|
||||||
border: 2px solid #d9821a;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 1rem 1.25rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
.warning strong { font-size: 1.1rem; color: #8a4a00; display: block; margin-bottom: 0.4rem; }
|
|
||||||
.warning p { margin: 0.4rem 0; }
|
|
||||||
.toolbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
margin: 1rem 0 1.5rem;
|
|
||||||
}
|
|
||||||
.toolbar form { margin: 0; }
|
|
||||||
.flash {
|
|
||||||
padding: 0.6rem 0.85rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
.flash.ok { background: #e6f4ec; border: 1px solid #9bd1b1; color: #154d2a; }
|
|
||||||
.flash.err { background: #fdecea; border: 1px solid #f5c2c0; color: #8a1f1b; }
|
|
||||||
.small { font-size: 0.85rem; margin-top: 0.75rem; }
|
|
||||||
td.actions { display: flex; gap: 0.5rem; align-items: center; }
|
|
||||||
td.actions form { margin: 0; }
|
|
||||||
.btn-link {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 0.4rem 0.8rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid #006a4e;
|
|
||||||
background: #006a4e;
|
|
||||||
color: #fff;
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 0.92rem;
|
|
||||||
}
|
|
||||||
.btn-link:hover { background: #00553e; color: #fff; }
|
|
||||||
</style>
|
|
||||||
@ -24,7 +24,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<h1>{$t('categories.title')}</h1>
|
<h2>{$t('categories.title')}</h2>
|
||||||
|
|
||||||
<p class="muted">{$t('categories.intro')}</p>
|
<p class="muted">{$t('categories.intro')}</p>
|
||||||
|
|
||||||
32
src/routes/admin/login/+page.server.js
Normal file
32
src/routes/admin/login/+page.server.js
Normal file
@ -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')));
|
||||||
|
}
|
||||||
|
};
|
||||||
25
src/routes/admin/login/+page@.svelte
Normal file
25
src/routes/admin/login/+page@.svelte
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<script>
|
||||||
|
import { t } from '$lib/i18n/store.js';
|
||||||
|
export let form;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h1>{$t('admin.login.title')}</h1>
|
||||||
|
|
||||||
|
<p class="muted">{$t('admin.login.intro')}</p>
|
||||||
|
|
||||||
|
<form method="POST" class="stack" autocomplete="off">
|
||||||
|
{#if form?.error}
|
||||||
|
<div class="error">{$t(form.error)}</div>
|
||||||
|
{/if}
|
||||||
|
<label>
|
||||||
|
{$t('admin.login.password')}
|
||||||
|
<input name="password" type="password" autofocus required />
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<button type="submit">{$t('admin.login.submit')}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.stack { max-width: 360px; }
|
||||||
|
</style>
|
||||||
10
src/routes/admin/reports/+page.server.js
Normal file
10
src/routes/admin/reports/+page.server.js
Normal file
@ -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)
|
||||||
|
};
|
||||||
|
}
|
||||||
163
src/routes/admin/reports/+page.svelte
Normal file
163
src/routes/admin/reports/+page.svelte
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
<script>
|
||||||
|
import { locale, t, localized, formatMoney } from '$lib/i18n/store.js';
|
||||||
|
|
||||||
|
export let data;
|
||||||
|
$: lang = $locale;
|
||||||
|
$: ({ sales, topParts, inventory, recentSales } = data);
|
||||||
|
|
||||||
|
function formatWhen(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
const d = new Date(iso.replace(' ', 'T') + 'Z');
|
||||||
|
const pad = (n) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h2>{$t('reports.sales_heading')}</h2>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card stat">
|
||||||
|
<div class="label">{$t('reports.today')}</div>
|
||||||
|
<div class="value">
|
||||||
|
{formatMoney(sales.today.total_dirams, lang)}
|
||||||
|
<span class="cur">{$t('common.currency_short')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="sub">{sales.today.invoice_count} {$t('reports.invoices')}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card stat">
|
||||||
|
<div class="label">{$t('reports.last_7_days')}</div>
|
||||||
|
<div class="value">
|
||||||
|
{formatMoney(sales.week.total_dirams, lang)}
|
||||||
|
<span class="cur">{$t('common.currency_short')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="sub">{sales.week.invoice_count} {$t('reports.invoices')}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card stat">
|
||||||
|
<div class="label">{$t('reports.this_month')}</div>
|
||||||
|
<div class="value">
|
||||||
|
{formatMoney(sales.month.total_dirams, lang)}
|
||||||
|
<span class="cur">{$t('common.currency_short')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="sub">{sales.month.invoice_count} {$t('reports.invoices')}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card stat">
|
||||||
|
<div class="label">{$t('reports.all_time')}</div>
|
||||||
|
<div class="value">
|
||||||
|
{formatMoney(sales.all_time.total_dirams, lang)}
|
||||||
|
<span class="cur">{$t('common.currency_short')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="sub">{sales.all_time.invoice_count} {$t('reports.invoices')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>{$t('reports.inventory_heading')}</h2>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card stat">
|
||||||
|
<div class="label">{$t('reports.active_skus')}</div>
|
||||||
|
<div class="value">{inventory.sku_count}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card stat">
|
||||||
|
<div class="label">{$t('reports.units_on_hand')}</div>
|
||||||
|
<div class="value">{inventory.units_on_hand}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card stat">
|
||||||
|
<div class="label">{$t('reports.cost_value')}</div>
|
||||||
|
<div class="value">
|
||||||
|
{formatMoney(inventory.cost_value_dirams, lang)}
|
||||||
|
<span class="cur">{$t('common.currency_short')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card stat">
|
||||||
|
<div class="label">{$t('reports.sale_value')}</div>
|
||||||
|
<div class="value">
|
||||||
|
{formatMoney(inventory.sale_value_dirams, lang)}
|
||||||
|
<span class="cur">{$t('common.currency_short')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card stat">
|
||||||
|
<div class="label">{$t('reports.low_stock')}</div>
|
||||||
|
<div class="value" class:warn={inventory.lowStockCount > 0}>{inventory.lowStockCount}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card stat">
|
||||||
|
<div class="label">{$t('reports.out_of_stock')}</div>
|
||||||
|
<div class="value" class:warn={inventory.outOfStockCount > 0}>{inventory.outOfStockCount}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>{$t('reports.top_parts')}</h2>
|
||||||
|
{#if topParts.length === 0}
|
||||||
|
<p class="muted">{$t('reports.no_sales_yet')}</p>
|
||||||
|
{:else}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{$t('parts.sku')}</th>
|
||||||
|
<th>{$t('parts.name')}</th>
|
||||||
|
<th class="num">{$t('reports.units_sold')}</th>
|
||||||
|
<th class="num">{$t('reports.revenue')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each topParts as p}
|
||||||
|
<tr>
|
||||||
|
<td><a href="/parts/{p.id}">{p.sku}</a></td>
|
||||||
|
<td>{localized(p, 'name', lang)}</td>
|
||||||
|
<td class="num">{p.units_sold}</td>
|
||||||
|
<td class="num">
|
||||||
|
{formatMoney(p.revenue_dirams, lang)}
|
||||||
|
<span class="cur">{$t('common.currency_short')}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<h2>{$t('reports.recent_sales')}</h2>
|
||||||
|
{#if recentSales.length === 0}
|
||||||
|
<p class="muted">{$t('reports.no_sales_yet')}</p>
|
||||||
|
{:else}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{$t('reports.saved_at')}</th>
|
||||||
|
<th class="num">{$t('reports.lines')}</th>
|
||||||
|
<th class="num">{$t('common.total')}</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each recentSales as s}
|
||||||
|
<tr>
|
||||||
|
<td>{formatWhen(s.saved_at)}</td>
|
||||||
|
<td class="num">{s.line_count}</td>
|
||||||
|
<td class="num">
|
||||||
|
{formatMoney(s.total_dirams, lang)}
|
||||||
|
<span class="cur">{$t('common.currency_short')}</span>
|
||||||
|
</td>
|
||||||
|
<td><a href="/invoices/{s.id}">{$t('reports.view')}</a></td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.stat .label { color: #6b7388; font-size: 0.85rem; }
|
||||||
|
.stat .value {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.stat .value.warn { color: #b8443f; }
|
||||||
|
.stat .cur { font-size: 0.8rem; color: #6b7388; margin-left: 0.2rem; }
|
||||||
|
.stat .sub { color: #6b7388; font-size: 0.8rem; margin-top: 0.2rem; }
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user