diff --git a/engine/core/templates/admin/dashboard/_filters.html b/engine/core/templates/admin/dashboard/_filters.html
new file mode 100644
index 00000000..ca57db27
--- /dev/null
+++ b/engine/core/templates/admin/dashboard/_filters.html
@@ -0,0 +1,22 @@
+{% load i18n unfold %}
+
+{% component "unfold/components/card.html" with class="mb-4" %}
+
+{% endcomponent %}
diff --git a/engine/core/templates/admin/dashboard/_kpis.html b/engine/core/templates/admin/dashboard/_kpis.html
index 85483682..e60465ad 100644
--- a/engine/core/templates/admin/dashboard/_kpis.html
+++ b/engine/core/templates/admin/dashboard/_kpis.html
@@ -1,39 +1,86 @@
{% load i18n unfold %}
-
+{#
+ KPI cards (mobile-first): GMV, Orders, AOV, Net revenue, Refund rate
+ Uses `kpi` dict and `currency_symbol` provided by dashboard_callback
+#}
+
+
+ {# GMV #}
{% component "unfold/components/card.html" %}
- {% component "unfold/components/text.html" %}
- {% trans "Revenue (gross, 30d)" %}
- {% endcomponent %}
+
+ {% component "unfold/components/text.html" %}{% trans "GMV" %} ({{ tf|default:30 }}d){% endcomponent %}
+ {% with d=kpi.gmv.delta_pct|default:0 %}
+
+ {% if d >= 0 %}+{% endif %}{{ d }}%
+
+ {% endwith %}
+
{% component "unfold/components/title.html" %}
- {% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ revenue_gross_30|default:0 }}
+ {% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ kpi.gmv.value|default:0 }}
{% endcomponent %}
{% endcomponent %}
+ {# Orders #}
{% component "unfold/components/card.html" %}
- {% component "unfold/components/text.html" %}
- {% trans "Revenue (net, 30d)" %}
- {% endcomponent %}
+
+ {% component "unfold/components/text.html" %}{% trans "Orders" %} ({{ tf|default:30 }}d){% endcomponent %}
+ {% with d=kpi.orders.delta_pct|default:0 %}
+
+ {% if d >= 0 %}+{% endif %}{{ d }}%
+
+ {% endwith %}
+
{% component "unfold/components/title.html" %}
- {% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ revenue_net_30|default:0 }}
+ {{ kpi.orders.value|default:0 }}
{% endcomponent %}
{% endcomponent %}
+ {# AOV #}
{% component "unfold/components/card.html" %}
- {% component "unfold/components/text.html" %}
- {% trans "Returns (30d)" %}
- {% endcomponent %}
+
+ {% component "unfold/components/text.html" %}{% trans "AOV" %} ({{ tf|default:30 }}d){% endcomponent %}
+ {% with d=kpi.aov.delta_pct|default:0 %}
+
+ {% if d >= 0 %}+{% endif %}{{ d }}%
+
+ {% endwith %}
+
{% component "unfold/components/title.html" %}
- {% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ returns_30|default:0 }}
+ {% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ kpi.aov.value|default:0 }}
{% endcomponent %}
{% endcomponent %}
+ {# Net revenue #}
{% component "unfold/components/card.html" %}
- {% component "unfold/components/text.html" %}
- {% trans "Processed orders (30d)" %}
- {% endcomponent %}
+
+ {% component "unfold/components/text.html" %}{% trans "Net revenue" %} ({{ tf|default:30 }}d){% endcomponent %}
+ {% with d=kpi.net.delta_pct|default:0 %}
+
+ {% if d >= 0 %}+{% endif %}{{ d }}%
+
+ {% endwith %}
+
{% component "unfold/components/title.html" %}
- {{ processed_orders_30|default:0 }}
+ {% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ kpi.net.value|default:0 }}
+ {% endcomponent %}
+ {% endcomponent %}
+
+ {# Refund rate #}
+ {% component "unfold/components/card.html" %}
+
+ {% component "unfold/components/text.html" %}{% trans "Refund rate" %} ({{ tf|default:30 }}d){% endcomponent %}
+ {% with d=kpi.refund_rate.delta_pct|default:0 %}
+
+ {% if d >= 0 %}+{% endif %}{{ d }}%
+
+ {% endwith %}
+
+ {% component "unfold/components/title.html" %}
+ {{ kpi.refund_rate.value|default:0 }}%
+ {% endcomponent %}
+ {% component "unfold/components/text.html" with class="text-xs text-gray-500 dark:text-gray-400" %}
+ {% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ returns_amount|default:0 }} {% trans "returned" %}
{% endcomponent %}
{% endcomponent %}
diff --git a/engine/core/templates/admin/dashboard/_low_stock.html b/engine/core/templates/admin/dashboard/_low_stock.html
new file mode 100644
index 00000000..c8677f4e
--- /dev/null
+++ b/engine/core/templates/admin/dashboard/_low_stock.html
@@ -0,0 +1,32 @@
+{% load i18n unfold %}
+
+{% component "unfold/components/card.html" %}
+ {% component "unfold/components/title.html" %}
+ {% trans "Low stock" %}
+ {% endcomponent %}
+
+ {% if low_stock_products %}
+
+ {% else %}
+ {% component "unfold/components/text.html" with class="text-sm text-gray-500 dark:text-gray-400" %}
+ {% trans "No low stock items." %}
+ {% endcomponent %}
+ {% endif %}
+{% endcomponent %}
diff --git a/engine/core/templates/admin/index.html b/engine/core/templates/admin/index.html
index 92693e81..08c4986f 100644
--- a/engine/core/templates/admin/index.html
+++ b/engine/core/templates/admin/index.html
@@ -21,14 +21,21 @@
{% endcomponent %}
+ {% include "admin/dashboard/_filters.html" %}
+
{% include "admin/dashboard/_kpis.html" %}
+
+ {% include "admin/dashboard/_daily_sales.html" %}
+
{% include "admin/dashboard/_income_overview.html" %}
- {% include "admin/dashboard/_quick_links.html" %}
- {% include "admin/dashboard/_daily_sales.html" %}
+
+ {% include "admin/dashboard/_low_stock.html" %}
+ {% include "admin/dashboard/_most_returned.html" %}
+
{% include "admin/dashboard/_customers_mix.html" %}
@@ -38,8 +45,8 @@
- {% include "admin/dashboard/_most_returned.html" %}
{% include "admin/dashboard/_top_categories.html" %}
+ {% include "admin/dashboard/_quick_links.html" %}
{% include "admin/dashboard/_product_lists.html" %}
diff --git a/engine/core/views.py b/engine/core/views.py
index bb2cc86d..0dd8016b 100644
--- a/engine/core/views.py
+++ b/engine/core/views.py
@@ -3,15 +3,16 @@ import mimetypes
import os
import traceback
from contextlib import suppress
-from datetime import timedelta
+from datetime import date, timedelta
import requests
+from constance import config
from django.conf import settings
from django.contrib.sitemaps.views import index as _sitemap_index_view
from django.contrib.sitemaps.views import sitemap as _sitemap_detail_view
from django.core.cache import cache
from django.core.exceptions import BadRequest
-from django.db.models import Count, Sum
+from django.db.models import Count, Sum, F
from django.http import FileResponse, Http404, HttpRequest, HttpResponse, HttpResponseRedirect, JsonResponse
from django.shortcuts import redirect
from django.template import Context
@@ -430,10 +431,117 @@ version.__doc__ = _( # type: ignore [assignment]
def dashboard_callback(request: HttpRequest, context: Context) -> Context:
- revenue_gross_30: float = get_revenue(clear=False)
- revenue_net_30: float = get_revenue(clear=True)
- returns_30: float = get_returns()
- processed_orders_30: int = get_total_processed_orders()
+ tf_map: dict[str, int] = {"7": 7, "30": 30, "90": 90, "360": 360}
+ tf_param = str(request.GET.get("tf", "30") or "30")
+ period_days: int = tf_map.get(tf_param, 30)
+ period = timedelta(days=period_days)
+
+ now_dt = tz_now()
+ cur_start = now_dt - period
+ prev_start = now_dt - timedelta(days=period_days * 2)
+ prev_end = cur_start
+
+ revenue_gross_cur: float = get_revenue(clear=False, period=period)
+ revenue_net_cur: float = get_revenue(clear=True, period=period)
+ returns_cur: float = get_returns(period=period)
+ processed_orders_cur: int = get_total_processed_orders(period=period)
+
+ orders_finished_cur = 0
+
+ with suppress(Exception):
+ orders_finished_cur: int = Order.objects.filter(
+ status="FINISHED", buy_time__lte=now_dt, buy_time__gte=cur_start
+ ).count()
+
+ def sum_gross_between(start: date | None, end: date | None) -> float:
+ result = 0.0
+ with suppress(Exception):
+ qs = (
+ OrderProduct.objects.filter(status__in=["FINISHED"], order__status="FINISHED")
+ .filter(order__buy_time__lt=end, order__buy_time__gte=start)
+ .aggregate(total=Sum(F("buy_price") * F("quantity")))
+ )
+ total = qs.get("total") or 0.0
+ result = round(float(total), 2)
+ return result
+
+ def sum_returns_between(start: date | None, end: date | None) -> float:
+ result = 0.0
+ with suppress(Exception):
+ qs = (
+ OrderProduct.objects.filter(status__in=["RETURNED"]) # returned items
+ .filter(order__buy_time__lt=end, order__buy_time__gte=start)
+ .aggregate(total=Sum(F("buy_price") * F("quantity")))
+ )
+ total = qs.get("total") or 0.0
+ result = round(float(total), 2)
+ return result
+
+ def count_finished_orders_between(start: date | None, end: date | None) -> int:
+ result = 0
+ with suppress(Exception):
+ result = Order.objects.filter(status="FINISHED", buy_time__lt=end, buy_time__gte=start).count()
+ return result
+
+ revenue_gross_prev = sum_gross_between(prev_start, prev_end)
+ returns_prev = sum_returns_between(prev_start, prev_end)
+ orders_finished_prev = count_finished_orders_between(prev_start, prev_end)
+
+ tax_rate = 0.0
+ tax_included = False
+ with suppress(Exception):
+ tax_rate = float(getattr(config, "TAX_RATE", 0.0) or 0.0)
+ tax_included = bool(getattr(config, "TAX_INCLUDED", False))
+
+ if tax_rate <= 0:
+ revenue_net_prev = revenue_gross_prev
+ else:
+ if tax_included:
+ divisor = 1.0 + (tax_rate / 100.0)
+ revenue_net_prev = revenue_gross_prev / divisor if divisor > 0 else revenue_gross_prev
+ else:
+ revenue_net_prev = revenue_gross_prev
+ revenue_net_prev = round(float(revenue_net_prev or 0.0), 2)
+
+ def pct_delta(cur: float | int, prev: float | int) -> float:
+ result = 0.0
+ with suppress(Exception):
+ cur_f = float(cur or 0)
+ prev_f = float(prev or 0)
+ if prev_f == 0:
+ result = 0.0 if cur_f == 0 else 100.0
+ result = round(((cur_f - prev_f) / prev_f) * 100.0, 1)
+ return result
+
+ aov_cur: float = round((revenue_gross_cur / orders_finished_cur), 2) if orders_finished_cur > 0 else 0.0
+ refund_rate_cur: float = round(((returns_cur / revenue_gross_cur) * 100.0), 1) if revenue_gross_cur > 0 else 0.0
+
+ aov_prev: float = round((revenue_gross_prev / orders_finished_prev), 2) if orders_finished_prev > 0 else 0.0
+ refund_rate_prev: float = round(((returns_prev / revenue_gross_prev) * 100.0), 1) if revenue_gross_prev > 0 else 0.0
+
+ kpi = {
+ "gmv": {
+ "value": revenue_gross_cur,
+ "prev": revenue_gross_prev,
+ "delta_pct": pct_delta(revenue_gross_cur, revenue_gross_prev),
+ },
+ "orders": {
+ "value": orders_finished_cur,
+ "prev": orders_finished_prev,
+ "delta_pct": pct_delta(orders_finished_cur, orders_finished_prev),
+ },
+ "aov": {"value": aov_cur, "prev": aov_prev, "delta_pct": pct_delta(aov_cur, aov_prev)},
+ "net": {
+ "value": revenue_net_cur,
+ "prev": revenue_net_prev,
+ "delta_pct": pct_delta(revenue_net_cur, revenue_net_prev),
+ },
+ "refund_rate": {
+ "value": refund_rate_cur,
+ "prev": refund_rate_prev,
+ "delta_pct": pct_delta(refund_rate_cur, refund_rate_prev),
+ },
+ }
currency_symbol: str = ""
with suppress(Exception):
currency_symbol = dict(getattr(settings, "CURRENCIES_WITH_SYMBOLS", ())).get(
@@ -499,7 +607,7 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context:
try:
today = tz_now().date()
- days = 30
+ days = period_days
date_axis = [today - timedelta(days=i) for i in range(days - 1, -1, -1)]
orders_map = get_daily_finished_orders_count(timedelta(days=days))
@@ -512,6 +620,7 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context:
context["daily_labels"] = labels
context["daily_orders"] = orders_series
context["daily_gross"] = gross_series
+ context["daily_title"] = f"Revenue & Orders (last {period_days}d)"
except Exception as e:
logger.warning("Failed to build daily stats: %s", e)
context["daily_labels"] = []
@@ -519,11 +628,48 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context:
context["daily_gross"] = []
with suppress(Exception):
today = tz_now().date()
- days = 30
+ days = period_days
date_axis = [today - timedelta(days=i) for i in range(days - 1, -1, -1)]
context["daily_labels"] = [d.strftime("%d %b") for d in date_axis]
context["daily_orders"] = [0 for _ in date_axis]
context["daily_gross"] = [0.0 for _ in date_axis]
+ context["daily_title"] = f"Revenue & Orders (last {period_days}d)"
+
+ low_stock_list: list[dict[str, str | int]] = []
+ with suppress(Exception):
+ products = (
+ Product.objects.annotate(total_qty=Sum("stocks__quantity"))
+ .values("id", "name", "sku", "total_qty")
+ .order_by("total_qty")[:5]
+ )
+ for p in products:
+ qty = int(p.get("total_qty") or 0)
+ low_stock_list.append(
+ {
+ "name": str(p.get("name") or ""),
+ "sku": str(p.get("sku") or ""),
+ "qty": qty,
+ "admin_url": reverse("admin:core_product_change", args=[p.get("id")]),
+ }
+ )
+
+ cache_key = f"dashboard_cb:{period_days}"
+ cached_pack = None
+ with suppress(Exception):
+ cached_pack = cache.get(cache_key)
+ if cached_pack is None:
+ cached_pack = {
+ "kpi": kpi,
+ "revenue_gross": revenue_gross_cur,
+ "revenue_net": revenue_net_cur,
+ "returns_amount": returns_cur,
+ "orders_finished": orders_finished_cur,
+ "aov": aov_cur,
+ "refund_rate": refund_rate_cur,
+ "low_stock_products": low_stock_list,
+ }
+ with suppress(Exception):
+ cache.set(cache_key, cached_pack, 600)
most_popular: dict[str, str | int | float | None] | None = None
most_popular_list: list[dict[str, str | int | float | None]] = []
@@ -612,10 +758,19 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context:
context.update(
{
"custom_variable": "value",
- "revenue_gross_30": revenue_gross_30,
- "revenue_net_30": revenue_net_30,
- "returns_30": returns_30,
- "processed_orders_30": processed_orders_30,
+ "timeframe_days": period_days,
+ "tf": period_days,
+ "kpi": kpi,
+ "revenue_gross": revenue_gross_cur,
+ "revenue_net": revenue_net_cur,
+ "returns_amount": returns_cur,
+ "orders_finished": orders_finished_cur,
+ "aov": aov_cur,
+ "refund_rate": refund_rate_cur,
+ "revenue_gross_30": revenue_gross_cur,
+ "revenue_net_30": revenue_net_cur,
+ "returns_30": returns_cur,
+ "processed_orders_30": processed_orders_cur,
"evibes_version": settings.EVIBES_VERSION,
"quick_links": quick_links,
"most_wished_product": most_wished,
@@ -627,6 +782,7 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context:
"shipped_vs_digital": shipped_vs_digital,
"most_returned_products": most_returned_products,
"top_categories": top_categories,
+ "low_stock_products": low_stock_list,
}
)
diff --git a/engine/vibes_auth/forms.py b/engine/vibes_auth/forms.py
index 6457d8e0..26725eac 100644
--- a/engine/vibes_auth/forms.py
+++ b/engine/vibes_auth/forms.py
@@ -6,6 +6,7 @@ from engine.vibes_auth.models import User
class UserForm(UserChangeForm): # type: ignore [type-arg]
password = ReadOnlyPasswordHashField(label="Password")
+
class Meta:
model = User
fields = "__all__"