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:
David Beccue
2026-05-16 15:47:42 +05:00
parent df64de4255
commit c882ab5d43
17 changed files with 517 additions and 207 deletions

View File

@ -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);
} }

View File

@ -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",

View File

@ -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": "Фактура",

View 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/');
}

View File

@ -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
View 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);
}

View File

@ -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)
}; };

View File

@ -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>

View 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>

View File

@ -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' });
}
}
};

View File

@ -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>

View File

@ -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>

View 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')));
}
};

View 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>

View 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)
};
}

View 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>