Add a shop sales dashboard with 30-day charts (#8)

## Summary
- Closes #7
- Portal Retail **Sales** page: 30-day orders/revenue/units/AOV, daily charts, top products
- Home dashboard cards for 30-day shop sales and revenue when `FEATURE_SHOP` is on
- Stacks on #6 (shop catalog). Merge that first, then retarget `master` if needed.

## Test plan
- [ ] Sales page requires login; empty state with no paid orders
- [ ] Paid/fulfilled in the last 30 days counted; draft/open/cancelled/old excluded
- [ ] Top products ranked by units; home dashboard shows 30-day sales count
- [ ] `manage.py test shop core.tests_features`

Reviewed-on: #8
This commit was merged in pull request #8.
This commit is contained in:
2026-09-06 14:00:04 -07:00
parent 80a5f5dadf
commit 23897b8b62
10 changed files with 514 additions and 1 deletions
+57
View File
@@ -264,6 +264,63 @@ body.portal {
line-height: 1.2; line-height: 1.2;
white-space: nowrap; white-space: nowrap;
} }
.chart-placeholder.chart-daily {
gap: 3px;
padding: 16px 10px 8px;
overflow-x: auto;
}
.chart-placeholder.chart-daily .chart-bar-col {
flex: 1 0 8px;
min-width: 6px;
}
.chart-placeholder.chart-daily .chart-bar-col .bar {
min-height: 0;
}
.chart-placeholder.chart-daily .chart-bar-col .bar.is-zero {
height: 0 !important;
min-height: 0;
opacity: 0.2;
}
.chart-placeholder.chart-daily .chart-bar-label {
font-size: 10px;
}
.chart-placeholder.chart-revenue .chart-bar-col .bar {
background: var(--monica-primary-light);
}
.hbar-chart {
display: flex;
flex-direction: column;
gap: 12px;
}
.hbar-row {
display: grid;
grid-template-columns: minmax(72px, 160px) 1fr auto;
gap: 12px;
align-items: center;
}
.hbar-label {
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hbar-track {
height: 16px;
background: #e8f4f5;
overflow: hidden;
}
.hbar-fill {
height: 100%;
background: var(--monica-primary);
opacity: 0.8;
min-width: 0;
}
.hbar-value {
font-size: 13px;
font-weight: 600;
min-width: 2.5ch;
text-align: right;
}
.consent-pills { display: flex; gap: 6px; flex-wrap: wrap; } .consent-pills { display: flex; gap: 6px; flex-wrap: wrap; }
.preview-pane { .preview-pane {
+1
View File
@@ -123,6 +123,7 @@ class InstalledOptionalAppsTests(TestCase):
self.assertTrue(reverse("social_ai:generate").startswith("/portal/social/api/generate/")) self.assertTrue(reverse("social_ai:generate").startswith("/portal/social/api/generate/"))
self.assertTrue(reverse("shop:list").startswith("/shop/")) self.assertTrue(reverse("shop:list").startswith("/shop/"))
self.assertTrue(reverse("shop_portal:product_list").startswith("/portal/shop/")) self.assertTrue(reverse("shop_portal:product_list").startswith("/portal/shop/"))
self.assertTrue(reverse("shop_portal:sales").startswith("/portal/shop/sales/"))
self.assertTrue(reverse("pos_sync:event_list").startswith("/portal/pos/")) self.assertTrue(reverse("pos_sync:event_list").startswith("/portal/pos/"))
self.assertTrue(reverse("events:list").startswith("/events/")) self.assertTrue(reverse("events:list").startswith("/events/"))
self.assertTrue(reverse("events_portal:event_list").startswith("/portal/events/")) self.assertTrue(reverse("events_portal:event_list").startswith("/portal/events/"))
@@ -32,6 +32,14 @@
<div class="label">Open shop orders</div> <div class="label">Open shop orders</div>
<div class="value">{{ open_shop_orders|default:0 }}</div> <div class="value">{{ open_shop_orders|default:0 }}</div>
</div> </div>
<div class="stat-card">
<div class="label">Sales (30 days)</div>
<div class="value">{{ shop_sales_30d|default:0 }}</div>
</div>
<div class="stat-card">
<div class="label">Shop revenue (30 days)</div>
<div class="value" style="font-size:22px">{{ shop_revenue_30d|default:0 }}</div>
</div>
<div class="stat-card"> <div class="stat-card">
<div class="label">Low stock</div> <div class="label">Low stock</div>
<div class="value">{{ low_stock_products|default:0 }}</div> <div class="value">{{ low_stock_products|default:0 }}</div>
+9
View File
@@ -63,6 +63,15 @@ def tianji_tracking(request):
nav_section = "social_accounts" nav_section = "social_accounts"
elif namespace == "social": elif namespace == "social":
nav_section = "social" nav_section = "social"
elif namespace == "shop_portal":
if url_name.startswith("product"):
nav_section = "shop_products"
elif url_name.startswith("order"):
nav_section = "shop_orders"
elif url_name == "sales":
nav_section = "shop_sales"
elif namespace == "shop":
nav_section = "shop"
return { return {
"tianji_enabled": getattr(settings, "TIANJI_ENABLED", False) "tianji_enabled": getattr(settings, "TIANJI_ENABLED", False)
+11
View File
@@ -9,6 +9,13 @@ from core.registry import (
def register() -> None: def register() -> None:
register_feature("shop") register_feature("shop")
register_public_nav(section="shop", label="Shop", url_name="shop:list", order=30) register_public_nav(section="shop", label="Shop", url_name="shop:list", order=30)
register_portal_nav(
section="shop_sales",
label="Sales",
url_name="shop_portal:sales",
group="Retail",
order=5,
)
register_portal_nav( register_portal_nav(
section="shop_products", section="shop_products",
label="Products", label="Products",
@@ -28,7 +35,9 @@ def register() -> None:
def _dashboard(request) -> dict: def _dashboard(request) -> dict:
from shop.models import Order, Product from shop.models import Order, Product
from shop.stats import sales_summary
sales = sales_summary()
return { return {
"open_shop_orders": Order.objects.filter( "open_shop_orders": Order.objects.filter(
status__in=[Order.Status.OPEN, Order.Status.PAID] status__in=[Order.Status.OPEN, Order.Status.PAID]
@@ -39,4 +48,6 @@ def _dashboard(request) -> dict:
fulfillment=Product.Fulfillment.STOCKED, fulfillment=Product.Fulfillment.STOCKED,
stock_qty__lte=3, stock_qty__lte=3,
).count(), ).count(),
"shop_sales_30d": sales["order_count"],
"shop_revenue_30d": sales["revenue"],
} }
+1
View File
@@ -5,6 +5,7 @@ from shop import views
app_name = "shop_portal" app_name = "shop_portal"
urlpatterns = [ urlpatterns = [
path("sales/", views.portal_sales, name="sales"),
path("products/", views.portal_product_list, name="product_list"), path("products/", views.portal_product_list, name="product_list"),
path("products/new/", views.portal_product_edit, name="product_new"), path("products/new/", views.portal_product_edit, name="product_new"),
path("products/<uuid:pk>/", views.portal_product_edit, name="product_edit"), path("products/<uuid:pk>/", views.portal_product_edit, name="product_edit"),
+166
View File
@@ -0,0 +1,166 @@
"""Sales dashboard aggregates for FEATURE_SHOP."""
from __future__ import annotations
from datetime import date, datetime, time, timedelta
from decimal import Decimal
from django.conf import settings
from django.db.models import Count, F, Max, Q, Sum
from django.utils import timezone
from shop.models import Order, OrderItem
SOLD_STATUSES = (Order.Status.PAID, Order.Status.FULFILLED)
SALES_WINDOW_DAYS = 30
def _money(value) -> Decimal:
return (value or Decimal("0")).quantize(Decimal("0.01"))
def _window_start(days: int):
today = timezone.localdate()
start_date = today - timedelta(days=days - 1)
start_dt = timezone.make_aware(
datetime.combine(start_date, time.min),
timezone.get_current_timezone(),
)
return today, start_date, start_dt
def _sold_orders(start_dt=None):
qs = Order.objects.filter(status__in=SOLD_STATUSES)
if start_dt is None:
return qs
return qs.filter(
Q(paid_at__gte=start_dt) | Q(paid_at__isnull=True, created_at__gte=start_dt)
)
def _sale_date(order) -> date:
when = order.paid_at or order.created_at
return timezone.localtime(when).date()
def _bar_pct(value: Decimal | int | float, peak: Decimal | int | float) -> int:
if not peak:
return 0
if not value:
return 0
return max(8, int(round((float(value) / float(peak)) * 100)))
def sales_summary(*, days: int = SALES_WINDOW_DAYS) -> dict:
"""Order count and revenue for the rolling sales window."""
_, _, start_dt = _window_start(days)
totals = _sold_orders(start_dt).aggregate(
order_count=Count("id"), revenue=Sum("amount")
)
return {
"order_count": int(totals["order_count"] or 0),
"revenue": _money(totals["revenue"]),
}
def sales_dashboard(*, days: int = SALES_WINDOW_DAYS) -> dict:
"""Paid/fulfilled order stats, daily series, and top products."""
_, start_date, start_dt = _window_start(days)
sold = _sold_orders(start_dt)
totals = sold.aggregate(order_count=Count("id"), revenue=Sum("amount"))
order_count = int(totals["order_count"] or 0)
revenue = _money(totals["revenue"])
units = int(
OrderItem.objects.filter(order__in=sold).aggregate(total=Sum("quantity"))[
"total"
]
or 0
)
aov = _money(revenue / order_count) if order_count else Decimal("0.00")
currency = (settings.STRIPE_CURRENCY or "usd").lower()
by_day: dict = {
start_date + timedelta(days=offset): {
"count": 0,
"revenue": Decimal("0.00"),
}
for offset in range(days)
}
for order in sold.only("paid_at", "created_at", "amount"):
day = _sale_date(order)
bucket = by_day.get(day)
if bucket is None:
continue
bucket["count"] += 1
bucket["revenue"] += order.amount or Decimal("0")
peak_count = max((row["count"] for row in by_day.values()), default=0)
peak_revenue = max((row["revenue"] for row in by_day.values()), default=Decimal("0"))
daily_sales = []
daily_revenue = []
for index, (day, row) in enumerate(by_day.items()):
tick = index == 0 or index == days - 1 or day.weekday() == 0
label = f"{day.strftime('%b')} {day.day}"
daily_sales.append(
{
"date": day,
"label": label,
"count": row["count"],
"revenue": _money(row["revenue"]),
"pct": _bar_pct(row["count"], peak_count),
"tick": tick,
"tick_label": label if tick else "",
}
)
daily_revenue.append(
{
"date": day,
"label": label,
"count": row["count"],
"revenue": _money(row["revenue"]),
"pct": _bar_pct(row["revenue"], peak_revenue),
"tick": tick,
"tick_label": label if tick else "",
}
)
product_rows = list(
OrderItem.objects.filter(order__in=sold)
.values("sku")
.annotate(
units=Sum("quantity"),
revenue=Sum(F("unit_price") * F("quantity")),
product_name=Max("product__name"),
item_name=Max("name"),
)
.order_by("-units", "-revenue")[:8]
)
peak_units = max((int(row["units"] or 0) for row in product_rows), default=0)
top_products = []
for row in product_rows:
units_sold = int(row["units"] or 0)
top_products.append(
{
"sku": row["sku"],
"name": row["product_name"] or row["item_name"] or row["sku"],
"units": units_sold,
"revenue": _money(row["revenue"]),
"bar_pct": _bar_pct(units_sold, peak_units),
}
)
recent_orders = list(sold.order_by("-paid_at", "-created_at")[:8])
return {
"days": days,
"currency": currency,
"order_count": order_count,
"revenue": revenue,
"units_sold": units,
"aov": aov,
"daily_sales": daily_sales,
"daily_revenue": daily_revenue,
"top_products": top_products,
"recent_orders": recent_orders,
"has_sales": order_count > 0,
}
+147
View File
@@ -0,0 +1,147 @@
{% extends "portal_base.html" %}
{% block title %}Sales · Portal{% endblock %}
{% block topbar_title %}Sales{% endblock %}
{% block portal_content %}
<div class="stat-row">
<div class="stat-card">
<div class="label">Orders ({{ days }} days)</div>
<div class="value">{{ order_count }}</div>
</div>
<div class="stat-card">
<div class="label">Revenue ({{ days }} days)</div>
<div class="value">{{ revenue }} {{ currency|upper }}</div>
</div>
<div class="stat-card">
<div class="label">Units sold</div>
<div class="value">{{ units_sold }}</div>
</div>
<div class="stat-card">
<div class="label">Average order</div>
<div class="value">{{ aov }} {{ currency|upper }}</div>
</div>
</div>
<div class="panel">
<div class="panel-h">
<h2>Orders per day</h2>
<span class="muted">Last {{ days }} days</span>
</div>
<div class="panel-b">
<div class="chart-placeholder chart-daily" role="img"
aria-label="Paid orders per day for the last {{ days }} days">
{% for bar in daily_sales %}
<div class="chart-bar-col">
<div class="bar{% if not bar.count %} is-zero{% endif %}"
style="height:{{ bar.pct }}%"
title="{{ bar.label }}: {{ bar.count }} sale{{ bar.count|pluralize }} · {{ bar.revenue }} {{ currency|upper }}"></div>
<div class="chart-bar-meta">
<span class="chart-bar-label">{% if bar.tick %}{{ bar.tick_label }}{% else %}&nbsp;{% endif %}</span>
</div>
</div>
{% endfor %}
</div>
{% if not has_sales %}
<p class="empty-state" style="padding-top:12px">No paid orders in this window.</p>
{% endif %}
</div>
</div>
<div class="panel">
<div class="panel-h">
<h2>Revenue per day</h2>
<span class="muted">Last {{ days }} days</span>
</div>
<div class="panel-b">
<div class="chart-placeholder chart-daily chart-revenue" role="img"
aria-label="Revenue per day for the last {{ days }} days">
{% for bar in daily_revenue %}
<div class="chart-bar-col">
<div class="bar{% if not bar.revenue %} is-zero{% endif %}"
style="height:{{ bar.pct }}%"
title="{{ bar.label }}: {{ bar.revenue }} {{ currency|upper }}"></div>
<div class="chart-bar-meta">
<span class="chart-bar-label">{% if bar.tick %}{{ bar.tick_label }}{% else %}&nbsp;{% endif %}</span>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
<div class="split">
<div class="panel">
<div class="panel-h">
<h2>Top products</h2>
<span class="muted">By units sold</span>
</div>
<div class="panel-b">
{% if top_products %}
<div class="hbar-chart" role="img" aria-label="Top products by units sold">
{% for row in top_products %}
<div class="hbar-row" title="{{ row.name }} · {{ row.units }} sold · {{ row.revenue }} {{ currency|upper }}">
<div class="hbar-label">{{ row.name }}</div>
<div class="hbar-track">
<div class="hbar-fill" style="width:{{ row.bar_pct }}%"></div>
</div>
<div class="hbar-value">{{ row.units }}</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="empty-state" style="margin:0;padding:0">No product sales yet.</p>
{% endif %}
</div>
</div>
<div class="panel">
<div class="panel-h">
<h2>Product mix</h2>
<a class="btn btn-sm btn-ghost" href="{% url 'shop_portal:product_list' %}">Products</a>
</div>
<div class="panel-b" style="padding:0">
<table class="table">
<thead>
<tr><th>Product</th><th>SKU</th><th>Units</th><th>Revenue</th></tr>
</thead>
<tbody>
{% for row in top_products %}
<tr>
<td>{{ row.name }}</td>
<td>{{ row.sku }}</td>
<td>{{ row.units }}</td>
<td>{{ row.revenue }} {{ currency|upper }}</td>
</tr>
{% empty %}
<tr><td colspan="4" class="empty-state">No paid line items in the last {{ days }} days.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="panel">
<div class="panel-h">
<h2>Recent sales</h2>
<a class="btn btn-sm btn-ghost" href="{% url 'shop_portal:order_list' %}">All orders</a>
</div>
<div class="panel-b" style="padding:0">
<table class="table">
<thead>
<tr><th>Number</th><th>Customer</th><th>Amount</th><th>Status</th></tr>
</thead>
<tbody>
{% for order in recent_orders %}
<tr>
<td><a href="{% url 'shop_portal:order_detail' order.pk %}">{{ order.number }}</a></td>
<td>{{ order.customer_name|default:order.email }}</td>
<td>{{ order.amount }} {{ order.currency|upper }}</td>
<td>{{ order.get_status_display }}</td>
</tr>
{% empty %}
<tr><td colspan="4" class="empty-state">No paid orders yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+108 -1
View File
@@ -1,11 +1,13 @@
from datetime import timedelta
from decimal import Decimal from decimal import Decimal
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core import mail from django.core import mail
from django.test import Client, TestCase, override_settings from django.test import Client, TestCase, override_settings
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
from shop.models import Order, Product from shop.models import Order, OrderItem, Product
from shop.services import ( from shop.services import (
ShopError, ShopError,
add_to_cart, add_to_cart,
@@ -15,6 +17,7 @@ from shop.services import (
mark_paid, mark_paid,
next_order_number, next_order_number,
) )
from shop.stats import sales_dashboard
def _product(**kwargs): def _product(**kwargs):
@@ -145,3 +148,107 @@ class ShopPortalTests(TestCase):
self.assertEqual(adjust.status_code, 302) self.assertEqual(adjust.status_code, 302)
product.refresh_from_db() product.refresh_from_db()
self.assertEqual(product.stock_qty, 7) self.assertEqual(product.stock_qty, 7)
def _sold_order(product, *, qty=1, paid_at=None, status=None, number=None):
n = Order.objects.count() + 1
order = Order.objects.create(
number=number or f"ORD-TEST-{n:04d}",
email=f"buyer{n}@example.com",
customer_name="Pat",
status=status or Order.Status.PAID,
amount=product.price * qty,
currency="usd",
paid_at=paid_at if paid_at is not None else timezone.now(),
)
OrderItem.objects.create(
order=order,
product=product,
name=product.name,
sku=product.sku,
quantity=qty,
unit_price=product.price,
)
return order
class ShopSalesDashboardTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user("merchant", password="test-pass-123")
self.client = Client()
self.client.login(username="merchant", password="test-pass-123")
self.dragon = _product(name="Dragon", sku="DRAGON", price=Decimal("18.00"))
self.fox = _product(name="Fox", sku="FOX", price=Decimal("12.00"))
def test_sales_requires_login(self):
anon = Client()
self.assertEqual(anon.get(reverse("shop_portal:sales")).status_code, 302)
def test_empty_dashboard(self):
data = sales_dashboard()
self.assertEqual(data["order_count"], 0)
self.assertEqual(data["revenue"], Decimal("0.00"))
self.assertEqual(len(data["daily_sales"]), 30)
self.assertFalse(data["has_sales"])
response = self.client.get(reverse("shop_portal:sales"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "No paid orders in this window")
self.assertContains(response, "Sales")
self.assertContains(response, "Orders per day")
self.assertContains(response, "Top products")
def test_counts_paid_and_fulfilled_in_window(self):
now = timezone.now()
_sold_order(self.dragon, qty=2, paid_at=now)
_sold_order(self.fox, qty=5, paid_at=now - timedelta(days=2))
_sold_order(
self.dragon,
qty=1,
paid_at=now - timedelta(days=1),
status=Order.Status.FULFILLED,
)
_sold_order(
self.fox,
qty=9,
paid_at=now - timedelta(days=40),
)
open_order = _sold_order(self.dragon, qty=3, paid_at=None, status=Order.Status.OPEN)
open_order.paid_at = None
open_order.save(update_fields=["paid_at"])
cancelled = _sold_order(
self.fox, qty=4, paid_at=now, status=Order.Status.CANCELLED
)
self.assertEqual(cancelled.status, Order.Status.CANCELLED)
data = sales_dashboard()
self.assertEqual(data["order_count"], 3)
self.assertEqual(data["units_sold"], 8)
self.assertEqual(data["revenue"], Decimal("114.00"))
self.assertEqual(data["aov"], Decimal("38.00"))
self.assertEqual([row["sku"] for row in data["top_products"]], ["FOX", "DRAGON"])
self.assertEqual(data["top_products"][0]["units"], 5)
self.assertEqual(data["top_products"][1]["units"], 3)
today_bar = data["daily_sales"][-1]
self.assertEqual(today_bar["count"], 1)
response = self.client.get(reverse("shop_portal:sales"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Dragon")
self.assertContains(response, "Fox")
self.assertContains(response, "114.00")
self.assertContains(response, "Orders per day")
self.assertContains(response, "Revenue per day")
def test_portal_nav_and_home_stats(self):
_sold_order(self.dragon, qty=1)
home = self.client.get(reverse("dashboard:home"))
self.assertEqual(home.status_code, 200)
self.assertEqual(home.context["shop_sales_30d"], 1)
self.assertEqual(home.context["shop_revenue_30d"], Decimal("18.00"))
self.assertContains(home, reverse("shop_portal:sales"))
self.assertContains(home, "Sales (30 days)")
products = self.client.get(reverse("shop_portal:product_list"))
self.assertContains(products, "Sales")
sales = self.client.get(reverse("shop_portal:sales"))
self.assertContains(sales, 'class="active"')
+6
View File
@@ -26,6 +26,7 @@ from shop.services import (
save_cart, save_cart,
set_cart_qty, set_cart_qty,
) )
from shop.stats import sales_dashboard
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -222,6 +223,11 @@ def portal_stock_adjust(request, pk):
return redirect("shop_portal:product_edit", pk=product.pk) return redirect("shop_portal:product_edit", pk=product.pk)
@login_required
def portal_sales(request):
return render(request, "shop/portal/sales.html", sales_dashboard())
@login_required @login_required
def portal_order_list(request): def portal_order_list(request):
orders = Order.objects.all()[:200] orders = Order.objects.all()[:200]