Show sale, COG, and profit on /admin/reports
Each summary card now leads with profit (large, green, bold), with sale and COG shown underneath as a breakdown. The "Top selling parts" and "Recent sales" tables get Sale/COG/Profit columns, with profit emphasized; negative profit flips both styles to red. Top parts is now ordered by profit. COG is computed from each part's current cost_price for inventory-affecting lines only — custom lines contribute to sale with zero COG. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -151,6 +151,9 @@
|
|||||||
"top_parts": "Top selling parts",
|
"top_parts": "Top selling parts",
|
||||||
"units_sold": "Units sold",
|
"units_sold": "Units sold",
|
||||||
"revenue": "Revenue",
|
"revenue": "Revenue",
|
||||||
|
"sale": "Sale",
|
||||||
|
"cog": "COG",
|
||||||
|
"profit": "Profit",
|
||||||
"recent_sales": "Recent sales",
|
"recent_sales": "Recent sales",
|
||||||
"saved_at": "Saved",
|
"saved_at": "Saved",
|
||||||
"lines": "Lines",
|
"lines": "Lines",
|
||||||
|
|||||||
@ -151,6 +151,9 @@
|
|||||||
"top_parts": "Қисмҳои серфурӯш",
|
"top_parts": "Қисмҳои серфурӯш",
|
||||||
"units_sold": "Фурӯхта шуд",
|
"units_sold": "Фурӯхта шуд",
|
||||||
"revenue": "Даромад",
|
"revenue": "Даромад",
|
||||||
|
"sale": "Фурӯш",
|
||||||
|
"cog": "Арзиши мол",
|
||||||
|
"profit": "Фоида",
|
||||||
"recent_sales": "Фурӯшҳои охирин",
|
"recent_sales": "Фурӯшҳои охирин",
|
||||||
"saved_at": "Сабт шуд",
|
"saved_at": "Сабт шуд",
|
||||||
"lines": "Сатрҳо",
|
"lines": "Сатрҳо",
|
||||||
|
|||||||
@ -1,45 +1,38 @@
|
|||||||
import { getDb } from './db.js';
|
import { getDb } from './db.js';
|
||||||
|
|
||||||
// All time windows are computed in local time using SQLite's `datetime('now', 'localtime')`.
|
// All time windows are computed in local time using SQLite's `datetime('now', 'localtime')`.
|
||||||
|
// Cost of goods (COG) and profit are computed against each part's current cost_price —
|
||||||
|
// the schema does not snapshot cost at sale time, so historical cost changes are not
|
||||||
|
// reflected. Custom (non-inventory) lines contribute to sale revenue but have zero COG.
|
||||||
|
|
||||||
|
const COG_SUBQUERY = `
|
||||||
|
COALESCE((
|
||||||
|
SELECT SUM(l.quantity * p.cost_price)
|
||||||
|
FROM invoice_lines l
|
||||||
|
JOIN parts p ON p.id = l.part_id
|
||||||
|
WHERE l.invoice_id = invoices.id AND l.affects_inventory = 1
|
||||||
|
), 0)
|
||||||
|
`;
|
||||||
|
|
||||||
|
function windowStats(dateClause) {
|
||||||
|
return getDb().prepare(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) AS invoice_count,
|
||||||
|
COALESCE(SUM(total_dirams), 0) AS sale_dirams,
|
||||||
|
COALESCE(SUM(${COG_SUBQUERY}), 0) AS cog_dirams,
|
||||||
|
COALESCE(SUM(total_dirams - ${COG_SUBQUERY}), 0) AS profit_dirams
|
||||||
|
FROM invoices
|
||||||
|
WHERE status = 'saved'${dateClause ? ' AND ' + dateClause : ''}
|
||||||
|
`).get();
|
||||||
|
}
|
||||||
|
|
||||||
export function salesSummary() {
|
export function salesSummary() {
|
||||||
const db = getDb();
|
return {
|
||||||
const row = db.prepare(`
|
all_time: windowStats(''),
|
||||||
SELECT
|
today: windowStats(`date(saved_at, 'localtime') = date('now', 'localtime')`),
|
||||||
COUNT(*) AS invoice_count,
|
week: windowStats(`date(saved_at, 'localtime') >= date('now', 'localtime', '-6 days')`),
|
||||||
COALESCE(SUM(total_dirams), 0) AS total_dirams
|
month: windowStats(`strftime('%Y-%m', saved_at, 'localtime') = strftime('%Y-%m', 'now', 'localtime')`)
|
||||||
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) {
|
export function topSellingParts(limit = 10) {
|
||||||
@ -47,13 +40,15 @@ export function topSellingParts(limit = 10) {
|
|||||||
SELECT
|
SELECT
|
||||||
p.id, p.sku, p.name_en, p.name_tg,
|
p.id, p.sku, p.name_en, p.name_tg,
|
||||||
SUM(l.quantity) AS units_sold,
|
SUM(l.quantity) AS units_sold,
|
||||||
SUM(l.quantity * l.unit_price_dirams) AS revenue_dirams
|
SUM(l.quantity * l.unit_price_dirams) AS sale_dirams,
|
||||||
|
SUM(l.quantity * p.cost_price) AS cog_dirams,
|
||||||
|
SUM(l.quantity * (l.unit_price_dirams - p.cost_price)) AS profit_dirams
|
||||||
FROM invoice_lines l
|
FROM invoice_lines l
|
||||||
JOIN invoices i ON i.id = l.invoice_id
|
JOIN invoices i ON i.id = l.invoice_id
|
||||||
JOIN parts p ON p.id = l.part_id
|
JOIN parts p ON p.id = l.part_id
|
||||||
WHERE i.status = 'saved' AND l.affects_inventory = 1
|
WHERE i.status = 'saved' AND l.affects_inventory = 1
|
||||||
GROUP BY p.id
|
GROUP BY p.id
|
||||||
ORDER BY units_sold DESC, revenue_dirams DESC
|
ORDER BY profit_dirams DESC, units_sold DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
`).all(limit);
|
`).all(limit);
|
||||||
}
|
}
|
||||||
@ -84,7 +79,12 @@ export function inventorySummary() {
|
|||||||
|
|
||||||
export function recentSales(limit = 10) {
|
export function recentSales(limit = 10) {
|
||||||
return getDb().prepare(`
|
return getDb().prepare(`
|
||||||
SELECT id, total_dirams, saved_at,
|
SELECT
|
||||||
|
id,
|
||||||
|
total_dirams AS sale_dirams,
|
||||||
|
${COG_SUBQUERY} AS cog_dirams,
|
||||||
|
total_dirams - ${COG_SUBQUERY} AS profit_dirams,
|
||||||
|
saved_at,
|
||||||
(SELECT COUNT(*) FROM invoice_lines WHERE invoice_id = invoices.id) AS line_count
|
(SELECT COUNT(*) FROM invoice_lines WHERE invoice_id = invoices.id) AS line_count
|
||||||
FROM invoices
|
FROM invoices
|
||||||
WHERE status = 'saved'
|
WHERE status = 'saved'
|
||||||
|
|||||||
@ -16,38 +16,26 @@
|
|||||||
<h2>{$t('reports.sales_heading')}</h2>
|
<h2>{$t('reports.sales_heading')}</h2>
|
||||||
|
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
|
{#each [
|
||||||
|
{ label: $t('reports.today'), row: sales.today },
|
||||||
|
{ label: $t('reports.last_7_days'), row: sales.week },
|
||||||
|
{ label: $t('reports.this_month'), row: sales.month },
|
||||||
|
{ label: $t('reports.all_time'), row: sales.all_time }
|
||||||
|
] as card}
|
||||||
<div class="card stat">
|
<div class="card stat">
|
||||||
<div class="label">{$t('reports.today')}</div>
|
<div class="label">{card.label}</div>
|
||||||
<div class="value">
|
<div class="profit-label">{$t('reports.profit')}</div>
|
||||||
{formatMoney(sales.today.total_dirams, lang)}
|
<div class="value profit" class:negative={card.row.profit_dirams < 0}>
|
||||||
|
{formatMoney(card.row.profit_dirams, lang)}
|
||||||
<span class="cur">{$t('common.currency_short')}</span>
|
<span class="cur">{$t('common.currency_short')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="sub">{sales.today.invoice_count} {$t('reports.invoices')}</div>
|
<div class="breakdown">
|
||||||
|
<div><span class="bk-label">{$t('reports.sale')}</span> {formatMoney(card.row.sale_dirams, lang)}</div>
|
||||||
|
<div><span class="bk-label">{$t('reports.cog')}</span> {formatMoney(card.row.cog_dirams, lang)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card stat">
|
<div class="sub">{card.row.invoice_count} {$t('reports.invoices')}</div>
|
||||||
<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>
|
||||||
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>{$t('reports.inventory_heading')}</h2>
|
<h2>{$t('reports.inventory_heading')}</h2>
|
||||||
@ -95,7 +83,9 @@
|
|||||||
<th>{$t('parts.sku')}</th>
|
<th>{$t('parts.sku')}</th>
|
||||||
<th>{$t('parts.name')}</th>
|
<th>{$t('parts.name')}</th>
|
||||||
<th class="num">{$t('reports.units_sold')}</th>
|
<th class="num">{$t('reports.units_sold')}</th>
|
||||||
<th class="num">{$t('reports.revenue')}</th>
|
<th class="num">{$t('reports.sale')}</th>
|
||||||
|
<th class="num">{$t('reports.cog')}</th>
|
||||||
|
<th class="num profit-col">{$t('reports.profit')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -105,7 +95,15 @@
|
|||||||
<td>{localized(p, 'name', lang)}</td>
|
<td>{localized(p, 'name', lang)}</td>
|
||||||
<td class="num">{p.units_sold}</td>
|
<td class="num">{p.units_sold}</td>
|
||||||
<td class="num">
|
<td class="num">
|
||||||
{formatMoney(p.revenue_dirams, lang)}
|
{formatMoney(p.sale_dirams, lang)}
|
||||||
|
<span class="cur">{$t('common.currency_short')}</span>
|
||||||
|
</td>
|
||||||
|
<td class="num">
|
||||||
|
{formatMoney(p.cog_dirams, lang)}
|
||||||
|
<span class="cur">{$t('common.currency_short')}</span>
|
||||||
|
</td>
|
||||||
|
<td class="num profit-col" class:negative={p.profit_dirams < 0}>
|
||||||
|
{formatMoney(p.profit_dirams, lang)}
|
||||||
<span class="cur">{$t('common.currency_short')}</span>
|
<span class="cur">{$t('common.currency_short')}</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -123,7 +121,9 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>{$t('reports.saved_at')}</th>
|
<th>{$t('reports.saved_at')}</th>
|
||||||
<th class="num">{$t('reports.lines')}</th>
|
<th class="num">{$t('reports.lines')}</th>
|
||||||
<th class="num">{$t('common.total')}</th>
|
<th class="num">{$t('reports.sale')}</th>
|
||||||
|
<th class="num">{$t('reports.cog')}</th>
|
||||||
|
<th class="num profit-col">{$t('reports.profit')}</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -133,7 +133,15 @@
|
|||||||
<td>{formatWhen(s.saved_at)}</td>
|
<td>{formatWhen(s.saved_at)}</td>
|
||||||
<td class="num">{s.line_count}</td>
|
<td class="num">{s.line_count}</td>
|
||||||
<td class="num">
|
<td class="num">
|
||||||
{formatMoney(s.total_dirams, lang)}
|
{formatMoney(s.sale_dirams, lang)}
|
||||||
|
<span class="cur">{$t('common.currency_short')}</span>
|
||||||
|
</td>
|
||||||
|
<td class="num">
|
||||||
|
{formatMoney(s.cog_dirams, lang)}
|
||||||
|
<span class="cur">{$t('common.currency_short')}</span>
|
||||||
|
</td>
|
||||||
|
<td class="num profit-col" class:negative={s.profit_dirams < 0}>
|
||||||
|
{formatMoney(s.profit_dirams, lang)}
|
||||||
<span class="cur">{$t('common.currency_short')}</span>
|
<span class="cur">{$t('common.currency_short')}</span>
|
||||||
</td>
|
</td>
|
||||||
<td><a href="/invoices/{s.id}">{$t('reports.view')}</a></td>
|
<td><a href="/invoices/{s.id}">{$t('reports.view')}</a></td>
|
||||||
@ -146,7 +154,7 @@
|
|||||||
<style>
|
<style>
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
@ -159,5 +167,49 @@
|
|||||||
}
|
}
|
||||||
.stat .value.warn { color: #b8443f; }
|
.stat .value.warn { color: #b8443f; }
|
||||||
.stat .cur { font-size: 0.8rem; color: #6b7388; margin-left: 0.2rem; }
|
.stat .cur { font-size: 0.8rem; color: #6b7388; margin-left: 0.2rem; }
|
||||||
.stat .sub { color: #6b7388; font-size: 0.8rem; margin-top: 0.2rem; }
|
.stat .sub { color: #6b7388; font-size: 0.8rem; margin-top: 0.4rem; }
|
||||||
|
|
||||||
|
.profit-label {
|
||||||
|
color: #2f7d4f;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
}
|
||||||
|
.value.profit {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f6b40;
|
||||||
|
line-height: 1.1;
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
}
|
||||||
|
.value.profit.negative { color: #b8443f; }
|
||||||
|
.value.profit .cur { color: #1f6b40; }
|
||||||
|
.value.profit.negative .cur { color: #b8443f; }
|
||||||
|
|
||||||
|
.breakdown {
|
||||||
|
margin-top: 0.55rem;
|
||||||
|
color: #4a5060;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.1rem 0.9rem;
|
||||||
|
}
|
||||||
|
.breakdown .bk-label {
|
||||||
|
color: #6b7388;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
margin-right: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
th.profit-col, td.profit-col {
|
||||||
|
color: #1f6b40;
|
||||||
|
font-weight: 700;
|
||||||
|
background: #f1f8f3;
|
||||||
|
}
|
||||||
|
td.profit-col.negative { color: #b8443f; background: #fbf1f0; }
|
||||||
|
td.profit-col .cur { color: inherit; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user