diff --git a/site/core/tests_features.py b/site/core/tests_features.py
index 64e5e84..bf8ae66 100644
--- a/site/core/tests_features.py
+++ b/site/core/tests_features.py
@@ -123,6 +123,7 @@ class InstalledOptionalAppsTests(TestCase):
self.assertTrue(reverse("social_ai:generate").startswith("/portal/social/api/generate/"))
self.assertTrue(reverse("shop:list").startswith("/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("events:list").startswith("/events/"))
self.assertTrue(reverse("events_portal:event_list").startswith("/portal/events/"))
diff --git a/site/dashboard/templates/dashboard/home.html b/site/dashboard/templates/dashboard/home.html
index d968119..cd7340f 100644
--- a/site/dashboard/templates/dashboard/home.html
+++ b/site/dashboard/templates/dashboard/home.html
@@ -32,6 +32,14 @@
Low stock
{{ low_stock_products|default:0 }}
diff --git a/site/print_forge/static/css/portal.css b/site/print_forge/static/css/portal.css
index cfba0eb..521bc86 100644
--- a/site/print_forge/static/css/portal.css
+++ b/site/print_forge/static/css/portal.css
@@ -264,6 +264,63 @@ body.portal {
line-height: 1.2;
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; }
.preview-pane {
diff --git a/site/public/context_processors.py b/site/public/context_processors.py
index 587799e..8d83608 100644
--- a/site/public/context_processors.py
+++ b/site/public/context_processors.py
@@ -77,6 +77,13 @@ def tianji_tracking(request):
nav_section = "social_accounts"
elif namespace == "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"
diff --git a/site/shop/hooks.py b/site/shop/hooks.py
index 007960a..27d7fe2 100644
--- a/site/shop/hooks.py
+++ b/site/shop/hooks.py
@@ -9,6 +9,13 @@ from core.registry import (
def register() -> None:
register_feature("shop")
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(
section="shop_products",
label="Products",
@@ -30,7 +37,9 @@ def _dashboard(request) -> dict:
from django.db.models import Q
from shop.models import Order, Product
+ from shop.stats import sales_summary
+ sales = sales_summary()
return {
"open_shop_orders": Order.objects.filter(
status__in=[Order.Status.OPEN, Order.Status.PAID]
@@ -43,4 +52,6 @@ def _dashboard(request) -> dict:
.filter(Q(colors__stock_qty__lte=3) | Q(colors__isnull=True, stock_qty__lte=3))
.distinct()
.count(),
+ "shop_sales_30d": sales["order_count"],
+ "shop_revenue_30d": sales["revenue"],
}
diff --git a/site/shop/portal_urls.py b/site/shop/portal_urls.py
index a9b8434..cbd30b0 100644
--- a/site/shop/portal_urls.py
+++ b/site/shop/portal_urls.py
@@ -5,6 +5,7 @@ from shop import views
app_name = "shop_portal"
urlpatterns = [
+ path("sales/", views.portal_sales, name="sales"),
path("products/", views.portal_product_list, name="product_list"),
path("products/new/", views.portal_product_edit, name="product_new"),
path("products/
/", views.portal_product_edit, name="product_edit"),
diff --git a/site/shop/stats.py b/site/shop/stats.py
new file mode 100644
index 0000000..4466d4a
--- /dev/null
+++ b/site/shop/stats.py
@@ -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,
+ }
diff --git a/site/shop/templates/shop/portal/sales.html b/site/shop/templates/shop/portal/sales.html
new file mode 100644
index 0000000..2d82d2a
--- /dev/null
+++ b/site/shop/templates/shop/portal/sales.html
@@ -0,0 +1,147 @@
+{% extends "portal_base.html" %}
+{% block title %}Sales · Portal{% endblock %}
+{% block topbar_title %}Sales{% endblock %}
+{% block portal_content %}
+
+
+
Orders ({{ days }} days)
+
{{ order_count }}
+
+
+
Revenue ({{ days }} days)
+
{{ revenue }} {{ currency|upper }}
+
+
+
Units sold
+
{{ units_sold }}
+
+
+
Average order
+
{{ aov }} {{ currency|upper }}
+
+
+
+
+
+
Orders per day
+ Last {{ days }} days
+
+
+
+ {% for bar in daily_sales %}
+
+
+
+ {% if bar.tick %}{{ bar.tick_label }}{% else %} {% endif %}
+
+
+ {% endfor %}
+
+ {% if not has_sales %}
+
No paid orders in this window.
+ {% endif %}
+
+
+
+
+
+
Revenue per day
+ Last {{ days }} days
+
+
+
+ {% for bar in daily_revenue %}
+
+
+
+ {% if bar.tick %}{{ bar.tick_label }}{% else %} {% endif %}
+
+
+ {% endfor %}
+
+
+
+
+
+
+
+
Top products
+ By units sold
+
+
+ {% if top_products %}
+
+ {% for row in top_products %}
+
+
{{ row.name }}
+
+
{{ row.units }}
+
+ {% endfor %}
+
+ {% else %}
+
No product sales yet.
+ {% endif %}
+
+
+
+
+
+
+
+ | Product | SKU | Units | Revenue |
+
+
+ {% for row in top_products %}
+
+ | {{ row.name }} |
+ {{ row.sku }} |
+ {{ row.units }} |
+ {{ row.revenue }} {{ currency|upper }} |
+
+ {% empty %}
+ | No paid line items in the last {{ days }} days. |
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
+
+ | Number | Customer | Amount | Status |
+
+
+ {% for order in recent_orders %}
+
+ | {{ order.number }} |
+ {{ order.customer_name|default:order.email }} |
+ {{ order.amount }} {{ order.currency|upper }} |
+ {{ order.get_status_display }} |
+
+ {% empty %}
+ | No paid orders yet. |
+ {% endfor %}
+
+
+
+
+{% endblock %}
diff --git a/site/shop/tests.py b/site/shop/tests.py
index b3a6d26..1759986 100644
--- a/site/shop/tests.py
+++ b/site/shop/tests.py
@@ -1,4 +1,5 @@
import struct
+from datetime import timedelta
from decimal import Decimal
from io import BytesIO
from pathlib import Path
@@ -13,10 +14,11 @@ from django.core.files.uploadedfile import SimpleUploadedFile, TemporaryUploaded
from django.db import models
from django.test import Client, TestCase, override_settings
from django.urls import reverse
+from django.utils import timezone
from PIL import Image
from core.models import StoredFile
-from shop.models import Order, Product, ProductColor, ProductImage
+from shop.models import Order, OrderItem, Product, ProductColor, ProductImage
from shop.services import (
ShopError,
add_to_cart,
@@ -29,6 +31,7 @@ from shop.services import (
store_product_image,
store_product_stl,
)
+from shop.stats import sales_dashboard
def _tiny_png() -> bytes:
image = Image.new("RGBA", (8, 8), (200, 40, 40, 255))
@@ -591,3 +594,108 @@ class ShopPortalTests(TestCase):
self.assertFalse(tmp_path.exists())
self.assertEqual(bytes(stored.data), _tiny_stl())
self.assertEqual(stored.kind, StoredFile.Kind.PRODUCT_STL)
+
+
+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"')
+
diff --git a/site/shop/views.py b/site/shop/views.py
index 209f29d..b203b2c 100644
--- a/site/shop/views.py
+++ b/site/shop/views.py
@@ -34,6 +34,7 @@ from shop.services import (
store_product_stl,
sync_product_colors,
)
+from shop.stats import sales_dashboard
logger = logging.getLogger(__name__)
@@ -344,6 +345,11 @@ def portal_stock_adjust(request, 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
def portal_order_list(request):
orders = Order.objects.all()[:200]