diff --git a/engine/core/templates/admin/dashboard/_daily_sales.html b/engine/core/templates/admin/dashboard/_daily_sales.html index 58c30b0f..8e436ef9 100644 --- a/engine/core/templates/admin/dashboard/_daily_sales.html +++ b/engine/core/templates/admin/dashboard/_daily_sales.html @@ -2,7 +2,7 @@ {% component "unfold/components/card.html" %} {% component "unfold/components/title.html" %} - {% trans "Daily sales (30d)" %} + {{ daily_title|default:_('Daily sales') }} {% endcomponent %}
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" %} +
+ {% component "unfold/components/text.html" with class="text-sm text-gray-500 dark:text-gray-400 mr-2" %} + {% trans "Timeframe" %} + {% endcomponent %} + {% with cur=tf|default:30 %} + 7d + 30d + 90d + 360d + {% endwith %} +
+ {% trans "TODO: Integrate GA/Yandex.Metrica & Ads metrics" %} +
+
+{% 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 %} +
+ {% for p in low_stock_products %} + +
+
{{ p.name }}
+
SKU: {{ p.sku }}
+
+
+ + {{ p.qty }} + +
+
+ {% endfor %} +
+ {% 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__"