feat(admin): comprehensive lazy dashboard + reports workbench (Chart.js)
Dashboard: fast KPI tiles (instant /stats) + lazy 30-day revenue line + stock-by-format
donut. Reports rebuilt into a parameterized workbench — date presets (7d/30d/90d/12mo/all
+ custom from/to) × dimension (trend/genre/style/format/condition/payment/weekday/hour +
top releases/artists/labels/customers) × metric (revenue/count), each chart fetched ON
DEMAND. New /admin/report/{kpis,series,breakdown,top,stock} (whitelisted dims, bound dates).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
4ab9c09899
commit
336d525585
@ -494,6 +494,116 @@ async def reports(ident=Depends(require_token), db=Depends(get_db)):
|
||||
return {"ok": True, "summary": summary, "by_month": by_month, "top": top}
|
||||
|
||||
|
||||
# ── Reports workbench — parameterized + lazy (each chart fetched on demand) ───────────────────
|
||||
_DONE = "s.status = 'completed'"
|
||||
|
||||
|
||||
def _period(days: int, frm, to):
|
||||
if frm and to:
|
||||
return "s.sale_date >= :p_from AND s.sale_date < (:p_to::date + 1)", {"p_from": frm, "p_to": to}
|
||||
if days and days > 0:
|
||||
return "s.sale_date >= now() - make_interval(days => :p_days)", {"p_days": days}
|
||||
return "TRUE", {} # days<=0 = all time
|
||||
|
||||
|
||||
@router.get("/report/kpis")
|
||||
async def report_kpis(days: int = Query(30), frm: str | None = Query(None, alias="from"),
|
||||
to: str | None = None, ident=Depends(require_token), db=Depends(get_db)):
|
||||
where, p = _period(days, frm, to)
|
||||
row = dict((await db.execute(text(f"""
|
||||
SELECT count(*) AS sales, coalesce(sum(s.total),0)::float AS revenue,
|
||||
coalesce(sum(s.discount_amount),0)::float AS discounts,
|
||||
coalesce(sum(s.tax_amount),0)::float AS tax,
|
||||
coalesce(avg(s.total),0)::float AS avg_sale,
|
||||
count(DISTINCT s.customer_id) FILTER (WHERE s.customer_id IS NOT NULL AND s.customer_id<>0) AS customers
|
||||
FROM sales s WHERE {where} AND {_DONE}"""), p)).mappings().first())
|
||||
row["units"] = (await db.execute(text(
|
||||
f"SELECT coalesce(sum(si.qty),0) FROM sale_items si JOIN sales s ON s.id=si.sale_id WHERE {where} AND {_DONE}"
|
||||
), p)).scalar()
|
||||
snap = dict((await db.execute(text("""
|
||||
SELECT count(*) FILTER (WHERE in_stock) AS in_stock,
|
||||
coalesce(sum(price) FILTER (WHERE in_stock),0)::float AS stock_value,
|
||||
count(*) FILTER (WHERE in_stock AND crate_id IS NOT NULL) AS located
|
||||
FROM inventory WHERE store_id=1"""))).mappings().first())
|
||||
row["in_stock"] = snap["in_stock"]; row["stock_value"] = snap["stock_value"]; row["located"] = snap["located"]
|
||||
row["open_laybys"] = (await db.execute(text("SELECT count(*) FROM sales WHERE payment_status='hold'"))).scalar()
|
||||
return row
|
||||
|
||||
|
||||
@router.get("/report/series")
|
||||
async def report_series(days: int = Query(30), bucket: str = Query("day"),
|
||||
frm: str | None = Query(None, alias="from"), to: str | None = None,
|
||||
ident=Depends(require_token), db=Depends(get_db)):
|
||||
where, p = _period(days, frm, to)
|
||||
bucket = bucket if bucket in ("day", "week", "month") else "day"
|
||||
fmt = {"day": "YYYY-MM-DD", "week": 'IYYY-"W"IW', "month": "YYYY-MM"}[bucket]
|
||||
rows = [dict(r) for r in (await db.execute(text(f"""
|
||||
SELECT to_char(date_trunc('{bucket}', s.sale_date), '{fmt}') AS bucket,
|
||||
count(*) AS sales, coalesce(sum(s.total),0)::float AS revenue
|
||||
FROM sales s WHERE {where} AND {_DONE} AND s.sale_date IS NOT NULL
|
||||
GROUP BY 1 ORDER BY 1"""), p)).mappings()]
|
||||
return {"series": rows, "bucket": bucket}
|
||||
|
||||
|
||||
_BREAKDOWN = {
|
||||
"payment": "SELECT coalesce(nullif(s.payment_method,''),'?') AS name, count(*) AS count, coalesce(sum(s.total),0)::float AS revenue FROM sales s WHERE {where} AND {done} GROUP BY 1 ORDER BY {order} DESC LIMIT {lim}",
|
||||
"weekday": "SELECT trim(to_char(s.sale_date,'Dy')) AS name, extract(dow from s.sale_date) AS _o, count(*) AS count, coalesce(sum(s.total),0)::float AS revenue FROM sales s WHERE {where} AND {done} GROUP BY 1,2 ORDER BY _o",
|
||||
"hour": "SELECT lpad(extract(hour from s.sale_date)::text,2,'0')||':00' AS name, count(*) AS count, coalesce(sum(s.total),0)::float AS revenue FROM sales s WHERE {where} AND {done} GROUP BY 1 ORDER BY 1",
|
||||
"condition": "SELECT coalesce(nullif(i.condition,''),'—') AS name, count(*) AS count, coalesce(sum(si.line_total),0)::float AS revenue FROM sales s JOIN sale_items si ON si.sale_id=s.id JOIN inventory i ON i.sku=si.sku WHERE {where} AND {done} GROUP BY 1 ORDER BY {order} DESC LIMIT {lim}",
|
||||
"kind": "SELECT coalesce(nullif(i.kind,''),'—') AS name, count(*) AS count, coalesce(sum(si.line_total),0)::float AS revenue FROM sales s JOIN sale_items si ON si.sale_id=s.id JOIN inventory i ON i.sku=si.sku WHERE {where} AND {done} GROUP BY 1 ORDER BY {order} DESC LIMIT {lim}",
|
||||
"genre": "SELECT g.genre_name AS name, count(*) AS count, coalesce(sum(si.line_total),0)::float AS revenue FROM sales s JOIN sale_items si ON si.sale_id=s.id JOIN inventory i ON i.sku=si.sku JOIN disc_release_genre g ON g.release_id=i.release_id WHERE {where} AND {done} AND g.genre_name<>'' GROUP BY 1 ORDER BY {order} DESC LIMIT {lim}",
|
||||
"style": "SELECT st.style_name AS name, count(*) AS count, coalesce(sum(si.line_total),0)::float AS revenue FROM sales s JOIN sale_items si ON si.sale_id=s.id JOIN inventory i ON i.sku=si.sku JOIN disc_release_style st ON st.release_id=i.release_id WHERE {where} AND {done} AND st.style_name<>'' GROUP BY 1 ORDER BY {order} DESC LIMIT {lim}",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/report/breakdown")
|
||||
async def report_breakdown(dim: str, days: int = Query(30), metric: str = Query("revenue"),
|
||||
limit: int = Query(12), frm: str | None = Query(None, alias="from"),
|
||||
to: str | None = None, ident=Depends(require_token), db=Depends(get_db)):
|
||||
if dim not in _BREAKDOWN:
|
||||
raise HTTPException(400, "bad dim")
|
||||
where, p = _period(days, frm, to)
|
||||
sql = _BREAKDOWN[dim].format(where=where, done=_DONE,
|
||||
order=("revenue" if metric == "revenue" else "count"),
|
||||
lim=max(1, min(limit, 50)))
|
||||
return {"dim": dim, "metric": metric, "rows": [dict(r) for r in (await db.execute(text(sql), p)).mappings()]}
|
||||
|
||||
|
||||
_TOP = {
|
||||
"release": "SELECT coalesce(min(si.item_name),'Release '||i.release_id::text) AS name, count(*) AS count, coalesce(sum(si.line_total),0)::float AS revenue FROM sales s JOIN sale_items si ON si.sale_id=s.id JOIN inventory i ON i.sku=si.sku WHERE {where} AND {done} AND i.release_id IS NOT NULL GROUP BY i.release_id ORDER BY revenue DESC LIMIT {lim}",
|
||||
"artist": "SELECT da.name AS name, count(*) AS count, coalesce(sum(si.line_total),0)::float AS revenue FROM sales s JOIN sale_items si ON si.sale_id=s.id JOIN inventory i ON i.sku=si.sku JOIN disc_release_artist ra ON ra.release_id=i.release_id JOIN disc_artist da ON da.id=ra.artist_id WHERE {where} AND {done} AND da.name<>'' GROUP BY da.name ORDER BY revenue DESC LIMIT {lim}",
|
||||
"label": "SELECT rl.label_name AS name, count(*) AS count, coalesce(sum(si.line_total),0)::float AS revenue FROM sales s JOIN sale_items si ON si.sale_id=s.id JOIN inventory i ON i.sku=si.sku JOIN disc_release_label rl ON rl.release_id=i.release_id WHERE {where} AND {done} AND rl.label_name<>'' GROUP BY rl.label_name ORDER BY revenue DESC LIMIT {lim}",
|
||||
"customer": "SELECT trim(c.first_name||' '||coalesce(c.last_name,'')) AS name, count(*) AS count, coalesce(sum(s.total),0)::float AS revenue FROM sales s JOIN customer c ON c.id=s.customer_id WHERE {where} AND {done} AND s.customer_id IS NOT NULL AND s.customer_id<>0 GROUP BY c.id ORDER BY revenue DESC LIMIT {lim}",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/report/top")
|
||||
async def report_top(dim: str, days: int = Query(30), limit: int = Query(10),
|
||||
frm: str | None = Query(None, alias="from"), to: str | None = None,
|
||||
ident=Depends(require_token), db=Depends(get_db)):
|
||||
if dim not in _TOP:
|
||||
raise HTTPException(400, "bad dim")
|
||||
where, p = _period(days, frm, to)
|
||||
sql = _TOP[dim].format(where=where, done=_DONE, lim=max(1, min(limit, 50)))
|
||||
return {"dim": dim, "rows": [dict(r) for r in (await db.execute(text(sql), p)).mappings()]}
|
||||
|
||||
|
||||
_STOCK = {
|
||||
"format": "SELECT coalesce(nullif(f.name,''),'—') AS name, count(DISTINCT i.sku) AS count, coalesce(sum(i.price),0)::float AS value FROM inventory i JOIN disc_release_format f ON f.release_id=i.release_id WHERE i.in_stock AND i.store_id=1 GROUP BY 1 ORDER BY count DESC LIMIT 12",
|
||||
"genre": "SELECT g.genre_name AS name, count(DISTINCT i.sku) AS count, coalesce(sum(i.price),0)::float AS value FROM inventory i JOIN disc_release_genre g ON g.release_id=i.release_id WHERE i.in_stock AND i.store_id=1 AND g.genre_name<>'' GROUP BY 1 ORDER BY count DESC LIMIT 12",
|
||||
"condition": "SELECT coalesce(nullif(condition,''),'—') AS name, count(*) AS count, coalesce(sum(price),0)::float AS value FROM inventory WHERE in_stock AND store_id=1 GROUP BY 1 ORDER BY count DESC",
|
||||
"kind": "SELECT coalesce(nullif(kind,''),'—') AS name, count(*) AS count, coalesce(sum(price),0)::float AS value FROM inventory WHERE in_stock AND store_id=1 GROUP BY 1 ORDER BY count DESC",
|
||||
"price": "SELECT CASE WHEN price<10 THEN '< $10' WHEN price<20 THEN '$10–20' WHEN price<35 THEN '$20–35' WHEN price<60 THEN '$35–60' WHEN price<100 THEN '$60–100' ELSE '$100+' END AS name, min(price) AS _o, count(*) AS count, coalesce(sum(price),0)::float AS value FROM inventory WHERE in_stock AND store_id=1 AND price IS NOT NULL GROUP BY 1 ORDER BY _o",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/report/stock")
|
||||
async def report_stock(dim: str, ident=Depends(require_token), db=Depends(get_db)):
|
||||
if dim not in _STOCK:
|
||||
raise HTTPException(400, "bad dim")
|
||||
return {"dim": dim, "rows": [dict(r) for r in (await db.execute(text(_STOCK[dim]))).mappings()]}
|
||||
|
||||
|
||||
@router.get("/orders")
|
||||
async def orders(ident=Depends(require_token), db=Depends(get_db)):
|
||||
base = await vault.get_secret(db, "woo_base_url")
|
||||
|
||||
116
site/admin.html
116
site/admin.html
@ -5,6 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>RecordGod — admin</title>
|
||||
<script defer src="/nav.js?v=5"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
|
||||
<style>
|
||||
:root{--pink:#d10f7a;--ink:#f4f5f7;--panel:#ffffff;--line:#e2e2ea;--mut:#5f5f6c;--ok:#1a8f54;--warn:#b06a00}
|
||||
*{box-sizing:border-box}
|
||||
@ -109,29 +110,40 @@ function go(v){
|
||||
({dashboard:vDash, inventory:vInv, orders:vOrders, customers:vCustomers, wantlist:vWantlist, intake:vIntake, storemap:vStoreMap, crates:vCrates, reports:vReports, connections:vConn, staff:vStaff}[v])();
|
||||
}
|
||||
|
||||
// ---------- Dashboard ----------
|
||||
// ---------- Dashboard (fast tiles first, charts lazy) ----------
|
||||
async function vDash(){
|
||||
const m = $('#content'); m.innerHTML = '<h2>Dashboard</h2><div class="muted">loading…</div>';
|
||||
const s = await api('/stats');
|
||||
m.innerHTML = '<h2>Dashboard</h2><div class="kpis">'
|
||||
+ kpi(s.items.toLocaleString(),'items in catalog', true)
|
||||
+ kpi(s.in_stock.toLocaleString(),'in stock')
|
||||
+ kpi(money(s.stock_value),'stock value', true)
|
||||
+ kpi(s.vinyl.toLocaleString(),'vinyl')
|
||||
+ kpi(s.other_goods.toLocaleString(),'other goods')
|
||||
+ kpi(s.crates.toLocaleString(),'crates')
|
||||
+ kpi(s.sales.toLocaleString(),'sales')
|
||||
+ kpi(money(s.sales_total),'sales total')
|
||||
+ (s.staged ? kpi(s.staged.toLocaleString(),'staged (un-published)') : '')
|
||||
+ '</div>'
|
||||
+ '<div class="card"><h3>Quick actions</h3><div class="bar">'
|
||||
$('#content').innerHTML = '<h2>Dashboard</h2>'
|
||||
+ '<div class="kpis" id="dashKpis"><div class="muted">loading…</div></div>'
|
||||
+ '<div style="display:grid;grid-template-columns:2fr 1fr;gap:14px;margin-top:4px">'
|
||||
+ '<div class="card"><h3>Revenue — last 30 days</h3><div style="height:150px"><canvas id="dashRev"></canvas></div></div>'
|
||||
+ '<div class="card"><h3>Stock by format</h3><div style="height:150px"><canvas id="dashFmt"></canvas></div></div></div>'
|
||||
+ '<div class="card" style="margin-top:14px"><h3>Quick actions</h3><div class="bar">'
|
||||
+ '<button class="ghost" onclick="go(\'inventory\')">Browse inventory</button>'
|
||||
+ '<button class="ghost" onclick="go(\'orders\')">Live orders</button>'
|
||||
+ '<button class="ghost" onclick="go(\'reports\')">Full reports</button>'
|
||||
+ '<button class="ghost" onclick="window.open(\'/store/\')">Open the 3D store</button>'
|
||||
+ '</div></div>';
|
||||
const s = await api('/stats');
|
||||
$('#dashKpis').innerHTML = kpi(s.in_stock.toLocaleString(),'in stock',true) + kpi(money(s.stock_value),'stock value',true)
|
||||
+ kpi(s.sales.toLocaleString(),'sales') + kpi(money(s.sales_total),'sales total')
|
||||
+ kpi(s.items.toLocaleString(),'catalog') + kpi(s.vinyl.toLocaleString(),'vinyl') + kpi(s.crates.toLocaleString(),'crates')
|
||||
+ (s.staged ? kpi(s.staged.toLocaleString(),'staged') : '');
|
||||
api('/report/series?days=30&bucket=day').then(d => lineChart('dashRev', d.series.map(x=>x.bucket), d.series.map(x=>x.revenue), 'Revenue', true));
|
||||
api('/report/stock?dim=format').then(d => donutChart('dashFmt', d.rows.map(x=>x.name), d.rows.map(x=>x.count)));
|
||||
}
|
||||
const kpi = (n,l,pink) => `<div class="kpi"><div class="n${pink?' pink':''}">${n}</div><div class="l">${l}</div></div>`;
|
||||
|
||||
// ---------- Chart.js helpers ----------
|
||||
const CHARTS = {};
|
||||
const PINK = '#d10f7a';
|
||||
const PALETTE = ['#d10f7a','#46d18a','#1e90ff','#ffb020','#9b59b6','#ff6b6b','#19c3c3','#e67e22','#2ecc71','#8e44ad','#34495e','#888'];
|
||||
function mkChart(id, cfg){ if(CHARTS[id]) CHARTS[id].destroy(); const c = $('#'+id); if(!c || !window.Chart) return; CHARTS[id] = new Chart(c, cfg); }
|
||||
function chopts(isMoney){ return {maintainAspectRatio:false,plugins:{legend:{display:false}},scales:{
|
||||
x:{ticks:{font:{size:10},maxRotation:45,autoSkip:true}},
|
||||
y:{beginAtZero:true,ticks:{font:{size:10},callback:v=>isMoney?'$'+Number(v).toLocaleString():v}}}}; }
|
||||
function lineChart(id,labels,data,label,isMoney){ mkChart(id,{type:'line',data:{labels,datasets:[{label,data,borderColor:PINK,backgroundColor:'rgba(209,15,122,.12)',fill:true,tension:.3,pointRadius:2}]},options:chopts(isMoney)}); }
|
||||
function barChart(id,labels,data,label,isMoney){ mkChart(id,{type:'bar',data:{labels,datasets:[{label,data,backgroundColor:PINK,borderRadius:4}]},options:chopts(isMoney)}); }
|
||||
function donutChart(id,labels,data){ mkChart(id,{type:'doughnut',data:{labels,datasets:[{data,backgroundColor:PALETTE,borderWidth:0}]},options:{maintainAspectRatio:false,plugins:{legend:{position:'right',labels:{boxWidth:12,font:{size:11}}}}}}); }
|
||||
|
||||
// ---------- Inventory ----------
|
||||
let invState = { q:'', kind:'', crate_id:null, page:1 };
|
||||
let invItems = {}; // sku -> row (for the edit modal)
|
||||
@ -522,17 +534,67 @@ async function vCrates(){
|
||||
function crateOpen(id){ invState = { q:'', kind:'', crate_id:id, page:1 }; go('inventory'); }
|
||||
|
||||
// ---------- Reports ----------
|
||||
async function vReports(){
|
||||
const m = $('#content'); m.innerHTML = '<h2>Reports</h2><div class="muted">loading…</div>';
|
||||
const d = await api('/reports'), s = d.summary;
|
||||
const months = d.by_month.map(x => `<tr><td>${x.month}</td><td>${x.orders}</td><td class="price">${money(x.revenue)}</td></tr>`).join('');
|
||||
const top = d.top.map(x => `<tr><td>${escapeH(x.item_name||'—')}</td><td>${x.qty}</td><td class="price">${money(x.revenue)}</td></tr>`).join('');
|
||||
m.innerHTML = '<h2>Reports</h2><div class="kpis">'
|
||||
+ kpi(s.orders.toLocaleString(),'total sales',true) + kpi(money(s.revenue),'revenue',true) + kpi(money(s.avg_order),'avg order')
|
||||
+ '</div><div class="card"><h3>Revenue by month</h3><table><thead><tr><th>Month</th><th>Sales</th><th>Revenue</th></tr></thead><tbody>'
|
||||
+ (months||'<tr><td colspan=3 class=muted>no data</td></tr>') + '</tbody></table></div>'
|
||||
+ '<div class="card"><h3>Top items</h3><table><thead><tr><th>Item</th><th>Qty</th><th>Revenue</th></tr></thead><tbody>'
|
||||
+ (top||'<tr><td colspan=3 class=muted>no data</td></tr>') + '</tbody></table></div>';
|
||||
// ---------- Reports workbench (date range + dimension + metric, all lazy) ----------
|
||||
let RP = {days:30, from:null, to:null, metric:'revenue', report:'trend'};
|
||||
const RP_TABS = [{r:'trend',l:'Revenue trend'},{r:'genre',l:'By genre'},{r:'style',l:'By style'},{r:'format',l:'By format'},
|
||||
{r:'condition',l:'By condition'},{r:'payment',l:'By payment'},{r:'weekday',l:'By weekday'},{r:'hour',l:'By hour'},
|
||||
{r:'top:release',l:'Top releases'},{r:'top:artist',l:'Top artists'},{r:'top:label',l:'Top labels'},{r:'top:customer',l:'Top customers'}];
|
||||
function vReports(){
|
||||
$('#content').innerHTML = '<h2>Reports</h2>'
|
||||
+ '<div class="card"><div class="bar" style="flex-wrap:wrap;gap:8px;align-items:center">'
|
||||
+ '<span class="muted">Period</span>'
|
||||
+ [['7','7d'],['30','30d'],['90','90d'],['365','12mo'],['0','All']].map(x=>`<button class="ghost rpd" data-d="${x[0]}" onclick="rpDays(${x[0]})">${x[1]}</button>`).join('')
|
||||
+ '<span class="muted">·</span><input type="date" id="rpFrom" onchange="rpCustom()" style="width:140px"><input type="date" id="rpTo" onchange="rpCustom()" style="width:140px">'
|
||||
+ '<span style="flex:1"></span><span class="muted">Metric</span>'
|
||||
+ '<button class="ghost" id="rpRev" onclick="rpMetric(\'revenue\')">Revenue</button><button class="ghost" id="rpCnt" onclick="rpMetric(\'count\')">Count</button>'
|
||||
+ '</div></div>'
|
||||
+ '<div class="kpis" id="rpKpis"><div class="muted">loading…</div></div>'
|
||||
+ '<div class="card"><div class="bar" id="rpTabs" style="flex-wrap:wrap;gap:6px"></div></div>'
|
||||
+ '<div class="card"><div style="height:300px"><canvas id="rpChart"></canvas></div></div>'
|
||||
+ '<div class="card"><div id="rpTable"></div></div>';
|
||||
$('#rpTabs').innerHTML = RP_TABS.map(t=>`<button class="ghost rpt" data-r="${t.r}" onclick="rpReport('${t.r}')">${t.l}</button>`).join('');
|
||||
rpSync(); loadRpKpis(); rpReport(RP.report);
|
||||
}
|
||||
function pq(){ return (RP.from && RP.to) ? ('from='+RP.from+'&to='+RP.to) : ('days='+RP.days); }
|
||||
function rpDays(d){ RP.days=d; RP.from=RP.to=null; $('#rpFrom').value=''; $('#rpTo').value=''; rpSync(); loadRpKpis(); rpReport(RP.report); }
|
||||
function rpCustom(){ const f=$('#rpFrom').value, t=$('#rpTo').value; if(f&&t){ RP.from=f; RP.to=t; rpSync(); loadRpKpis(); rpReport(RP.report); } }
|
||||
function rpMetric(mt){ RP.metric=mt; rpSync(); rpReport(RP.report); }
|
||||
function rpReport(r){ RP.report=r; rpSync(); if(r==='trend') rpTrend(); else if(r.startsWith('top:')) rpTop(r.slice(4)); else rpBreakdown(r); }
|
||||
function rpSync(){
|
||||
document.querySelectorAll('.rpd').forEach(b=>b.classList.toggle('on', !RP.from && +b.dataset.d===+RP.days));
|
||||
document.querySelectorAll('.rpt').forEach(b=>b.classList.toggle('on', b.dataset.r===RP.report));
|
||||
if($('#rpRev')){ $('#rpRev').classList.toggle('on',RP.metric==='revenue'); $('#rpCnt').classList.toggle('on',RP.metric==='count'); }
|
||||
}
|
||||
async function loadRpKpis(){
|
||||
const k = await api('/report/kpis?'+pq());
|
||||
$('#rpKpis').innerHTML = kpi(k.sales.toLocaleString(),'sales',true) + kpi(money(k.revenue),'revenue',true)
|
||||
+ kpi(money(k.avg_sale),'avg sale') + kpi(Number(k.units||0).toLocaleString(),'units')
|
||||
+ kpi(k.customers.toLocaleString(),'customers') + kpi(money(k.discounts),'discounts given')
|
||||
+ kpi(k.in_stock.toLocaleString(),'in stock now') + (k.open_laybys?kpi(k.open_laybys,'open laybys'):'');
|
||||
}
|
||||
function rpTable(head, rows){
|
||||
$('#rpTable').innerHTML = '<table><thead><tr>'+head.map(h=>`<th>${escapeH(h)}</th>`).join('')+'</tr></thead><tbody>'
|
||||
+ (rows.map(r=>'<tr>'+r.map((c,i)=>`<td${i>0?' class="price"':''}>${escapeH(String(c))}</td>`).join('')+'</tr>').join('') || '<tr><td class=muted colspan=9>no data in this period</td></tr>')
|
||||
+ '</tbody></table>';
|
||||
}
|
||||
async function rpTrend(){
|
||||
const bucket = (RP.from||RP.days===0||RP.days>120)?'month':(RP.days>31?'week':'day');
|
||||
const d = await api('/report/series?'+pq()+'&bucket='+bucket);
|
||||
const isRev = RP.metric==='revenue';
|
||||
lineChart('rpChart', d.series.map(x=>x.bucket), d.series.map(x=>isRev?x.revenue:x.sales), isRev?'Revenue':'Sales', isRev);
|
||||
rpTable(['Period','Sales','Revenue'], d.series.map(x=>[x.bucket, x.sales, money(x.revenue)]));
|
||||
}
|
||||
async function rpBreakdown(dim){
|
||||
const d = await api('/report/breakdown?dim='+dim+'&metric='+RP.metric+'&'+pq()+'&limit=15');
|
||||
const isRev = RP.metric==='revenue', vals = d.rows.map(x=>isRev?x.revenue:x.count);
|
||||
if(dim!=='weekday' && dim!=='hour' && d.rows.length<=8) donutChart('rpChart', d.rows.map(x=>x.name), vals);
|
||||
else barChart('rpChart', d.rows.map(x=>x.name), vals, isRev?'Revenue':'Count', isRev);
|
||||
rpTable(['Name','Units','Revenue'], d.rows.map(x=>[x.name, x.count, money(x.revenue)]));
|
||||
}
|
||||
async function rpTop(dim){
|
||||
const d = await api('/report/top?dim='+dim+'&'+pq()+'&limit=15');
|
||||
barChart('rpChart', d.rows.map(x=>x.name), d.rows.map(x=>x.revenue), 'Revenue', true);
|
||||
rpTable([dim.charAt(0).toUpperCase()+dim.slice(1),'Units','Revenue'], d.rows.map(x=>[x.name, x.count, money(x.revenue)]));
|
||||
}
|
||||
|
||||
// ---------- Store map ----------
|
||||
|
||||
Loading…
Reference in New Issue
Block a user