Features: 1) Add customer mix and shipped vs digital metrics to dashboard context;

Fixes: 1) Replace failed daily stats with empty lists instead of crashing; 2) Fix inconsistent variable scoping and cleanup;

Extra: 1) Refactor daily stats logic to handle exceptions gracefully; 2) Improve readability by grouping related metrics; 3) Add type annotations for new context variables.
This commit is contained in:
Egor Pavlovich Gorbunov 2025-11-17 22:42:38 +03:00
parent 68890017f6
commit 71389ee278
13 changed files with 717 additions and 396 deletions

View file

@ -0,0 +1,28 @@
{% load i18n unfold %}
{% component "unfold/components/card.html" %}
{% component "unfold/components/title.html" %}
{% trans "Customers mix (30d)" %}
{% endcomponent %}
{% if customers_mix.total|default:0 > 0 %}
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between text-sm">
<span class="text-gray-600 dark:text-gray-300">{% trans "New customers" %}</span>
<span class="font-medium">{{ customers_mix.new }} ({{ customers_mix.new_pct }}%)</span>
</div>
{% component "unfold/components/progress.html" with value=customers_mix.new_pct title='' description='' %}
{% endcomponent %}
<div class="flex items-center justify-between text-sm mt-2">
<span class="text-gray-600 dark:text-gray-300">{% trans "Returning customers" %}</span>
<span class="font-medium">{{ customers_mix.returning }} ({{ customers_mix.returning_pct }}%)</span>
</div>
{% component "unfold/components/progress.html" with value=customers_mix.returning_pct title='' description='' %}
{% endcomponent %}
</div>
{% else %}
{% component "unfold/components/text.html" %}
{% trans "No customer activity in the last 30 days." %}
{% endcomponent %}
{% endif %}
{% endcomponent %}

View file

@ -0,0 +1,129 @@
{% load i18n unfold static %}
{% component "unfold/components/card.html" %}
{% component "unfold/components/title.html" %}
{% trans "Daily sales (30d)" %}
{% endcomponent %}
<div class="w-full">
<canvas id="dailySalesChart" height="120"></canvas>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
{{ daily_labels|json_script:"daily-labels" }}
{{ daily_orders|json_script:"daily-orders" }}
{{ daily_gross|json_script:"daily-gross" }}
<script>
(function () {
try {
let labels = [];
let orders = [];
let gross = [];
const elL = document.getElementById('daily-labels');
const elO = document.getElementById('daily-orders');
const elG = document.getElementById('daily-gross');
if (elL && elO && elG) {
labels = JSON.parse(elL.textContent || '[]');
orders = JSON.parse(elO.textContent || '[]');
gross = JSON.parse(elG.textContent || '[]');
}
if (!labels || labels.length === 0) {
const now = new Date();
labels = [];
orders = [];
gross = [];
for (let i = 29; i >= 0; i--) {
const d = new Date(now);
d.setDate(now.getDate() - i);
const day = String(d.getDate()).padStart(2, '0');
const month = d.toLocaleString(undefined, {month: 'short'});
labels.push(`${day} ${month}`);
orders.push(0);
gross.push(0);
}
}
const ctx = document.getElementById('dailySalesChart').getContext('2d');
const green = 'rgb(34,197,94)';
const blue = 'rgb(59,130,246)';
const currency = "{% if currency_symbol %}{{ currency_symbol }}{% endif %}";
const allZeroOrders = (orders || []).every(function (v) { return Number(v || 0) === 0; });
const allZeroGross = (gross || []).every(function (v) { return Number(v || 0) === 0; });
new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: '{{ _("Orders (FINISHED)") }}',
data: orders,
borderColor: allZeroOrders ? 'rgba(0,0,0,0)' : green,
backgroundColor: allZeroOrders ? 'rgba(0,0,0,0)' : green,
borderWidth: allZeroOrders ? 0 : 2,
tension: 0.25,
pointRadius: allZeroOrders ? 0 : 2,
yAxisID: 'yOrders',
},
{
label: '{{ _("Gross revenue") }}',
data: gross,
borderColor: allZeroGross ? 'rgba(0,0,0,0)' : blue,
backgroundColor: allZeroGross ? 'rgba(0,0,0,0)' : blue,
borderWidth: allZeroGross ? 0 : 2,
tension: 0.25,
pointRadius: allZeroGross ? 0 : 2,
yAxisID: 'yRevenue',
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {mode: 'index', intersect: false},
plugins: {
legend: {display: true},
tooltip: {
callbacks: {
label: function (context) {
const label = context.dataset.label || '';
const val = context.parsed.y;
if (context.dataset.yAxisID === 'yRevenue') {
return `${label}: ${currency}${val}`;
}
return `${label}: ${val}`;
}
}
}
},
scales: {
x: {
grid: {display: false}
},
yOrders: {
type: 'linear',
position: 'left',
title: {display: true, text: '{{ _("Orders") }}'},
grid: {color: 'rgba(0,0,0,0.06)'},
ticks: {precision: 0}
},
yRevenue: {
type: 'linear',
position: 'right',
title: {display: true, text: '{{ _("Gross") }}'},
grid: {drawOnChartArea: false},
ticks: {
callback: function (value) {
return `${currency}${value}`;
}
}
}
}
}
});
} catch (e) {
console && console.warn && console.warn('Chart init failed', e);
}
})();
</script>
{% endcomponent %}

View file

@ -0,0 +1,118 @@
{% load i18n unfold arith %}
{% with gross=revenue_gross_30|default:0 returns=returns_30|default:0 %}
{% with total=gross|add:returns %}
{% component "unfold/components/card.html" with class="xl:col-span-2" %}
{% component "unfold/components/title.html" %}
{% trans "Income overview" %}
{% endcomponent %}
{% with net=revenue_net_30|default:0 %}
{% with tax_amt=gross|sub:net %}
{% with returns_capped=returns %}
{% if returns > gross %}
{% with returns_capped=gross %}{% endwith %}
{% endif %}
{% with tax_amt_pos=tax_amt %}
{% if tax_amt_pos < 0 %}
{% with tax_amt_pos=0 %}{% endwith %}
{% endif %}
{% with net_for_pie=gross|sub:tax_amt_pos|sub:returns_capped %}
{% if net_for_pie < 0 %}
{% with net_for_pie=0 %}{% endwith %}
{% endif %}
<div class="flex flex-col sm:flex-row items-center gap-6">
<div class="relative w-48 h-48">
<canvas id="incomePieChart" width="192" height="192"></canvas>
</div>
<div>
<div class="flex items-center gap-2 mb-2">
<span class="inline-block w-3 h-3 rounded-sm" style="background:rgb(34,197,94)"></span>
<span class="text-sm text-gray-600 dark:text-gray-300">{% trans "Net" %}:</span>
<span class="font-semibold">{% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ net }}</span>
</div>
{% if tax_amt_pos > 0 %}
<div class="flex items-center gap-2 mb-2">
<span class="inline-block w-3 h-3 rounded-sm" style="background:rgb(249,115,22)"></span>
<span class="text-sm text-gray-600 dark:text-gray-300">{% trans "Taxes" %}:</span>
<span class="font-semibold">{% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ tax_amt_pos|floatformat:2 }}</span>
</div>
{% endif %}
<div class="flex items-center gap-2 mb-2">
<span class="inline-block w-3 h-3 rounded-sm" style="background:rgb(239,68,68)"></span>
<span class="text-sm text-gray-600 dark:text-gray-300">{% trans "Returns" %}:</span>
<span class="font-semibold">{% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ returns }}</span>
</div>
<div class="flex items-center gap-2">
<span class="inline-block w-3 h-3 rounded-sm" style="background:linear-gradient(90deg, rgba(0,0,0,0.15), rgba(0,0,0,0.15))"></span>
<span class="text-sm text-gray-600 dark:text-gray-300">{% trans "Gross (pie total)" %}:</span>
<span class="font-semibold">{% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ gross }}</span>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
(function () {
try {
const ctx = document.getElementById('incomePieChart').getContext('2d');
const gross = Number({{ gross|default:0 }});
const netBackend = Number({{ net|default:0 }});
const returnsRaw = Number({{ returns_capped|default:0 }});
const taxes = Math.max(gross - netBackend, 0);
const returnsVal = Math.min(returnsRaw, gross);
const netVal = Math.max(gross - taxes - returnsVal, 0);
let dataValues = [netVal, taxes, returnsVal];
let labels = ['{{ _("Net") }}', '{{ _("Taxes") }}', '{{ _("Returns") }}'];
let colors = ['rgb(34,197,94)', 'rgb(249,115,22)', 'rgb(239,68,68)'];
const sum = dataValues.reduce((a, b) => a + Number(b || 0), 0);
if (sum <= 0) {
dataValues = [1];
labels = ['{{ _("No data") }}'];
colors = ['rgba(0,0,0,0.1)'];
}
const currency = "{% if currency_symbol %}{{ currency_symbol }}{% endif %}";
new Chart(ctx, {
type: 'doughnut',
data: {
labels: labels,
datasets: [{
data: dataValues,
backgroundColor: colors,
borderWidth: 0,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {position: 'bottom'},
tooltip: {
callbacks: {
label: function (context) {
const label = context.label || '';
const val = context.parsed || 0;
if (labels.length === 1) return label;
return `${label}: ${currency}${val}`;
}
}
}
},
cutout: '55%'
}
});
} catch (e) {
console && console.warn && console.warn('Pie chart init failed', e);
}
})();
</script>
{% endwith %}
{% endwith %}
{% endwith %}
{% endwith %}
{% endwith %}
{% endcomponent %}
{% endwith %}
{% endwith %}

View file

@ -0,0 +1,39 @@
{% load i18n unfold %}
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4 mb-6">
{% component "unfold/components/card.html" %}
{% component "unfold/components/text.html" %}
{% trans "Revenue (gross, 30d)" %}
{% endcomponent %}
{% component "unfold/components/title.html" %}
{% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ revenue_gross_30|default:0 }}
{% endcomponent %}
{% endcomponent %}
{% component "unfold/components/card.html" %}
{% component "unfold/components/text.html" %}
{% trans "Revenue (net, 30d)" %}
{% endcomponent %}
{% component "unfold/components/title.html" %}
{% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ revenue_net_30|default:0 }}
{% endcomponent %}
{% endcomponent %}
{% component "unfold/components/card.html" %}
{% component "unfold/components/text.html" %}
{% trans "Returns (30d)" %}
{% endcomponent %}
{% component "unfold/components/title.html" %}
{% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ returns_30|default:0 }}
{% endcomponent %}
{% endcomponent %}
{% component "unfold/components/card.html" %}
{% component "unfold/components/text.html" %}
{% trans "Processed orders (30d)" %}
{% endcomponent %}
{% component "unfold/components/title.html" %}
{{ processed_orders_30|default:0 }}
{% endcomponent %}
{% endcomponent %}
</div>

View file

@ -0,0 +1,27 @@
{% load i18n unfold %}
{% component "unfold/components/card.html" %}
{% component "unfold/components/title.html" %}
{% trans "Most returned products (30d)" %}
{% endcomponent %}
{% if most_returned_products %}
<ul class="flex flex-col divide-y divide-gray-200 dark:divide-base-700/50">
{% for p in most_returned_products %}
<li class="py-2 first:pt-0 last:pb-0">
<a href="{{ p.admin_url }}" class="flex items-center gap-4">
{% if p.image %}
<img src="{{ p.image }}" alt="{{ p.name }}"
class="w-12 h-12 object-cover rounded"/>
{% endif %}
<span class="font-medium flex-1 truncate">{{ p.name }}</span>
<span class="text-xs px-2 py-0.5 rounded bg-red-500/10 text-red-700 dark:text-red-300">×{{ p.count }}</span>
</a>
</li>
{% endfor %}
</ul>
{% else %}
{% component "unfold/components/text.html" %}
{% trans "No returns in the last 30 days." %}
{% endcomponent %}
{% endif %}
{% endcomponent %}

View file

@ -0,0 +1,71 @@
{% load i18n unfold %}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-6">
{% component "unfold/components/card.html" %}
{% component "unfold/components/title.html" %}
{% trans "Most wished product" %}
{% endcomponent %}
{% if most_wished_products %}
<ul class="flex flex-col divide-y divide-gray-200 dark:divide-base-700/50">
{% for p in most_wished_products %}
<li class="py-2 first:pt-0 last:pb-0">
<a href="{{ p.admin_url }}" class="flex items-center gap-4">
{% if p.image %}
<img src="{{ p.image }}" alt="{{ p.name }}"
class="w-12 h-12 object-cover rounded"/>
{% endif %}
<span class="font-medium flex-1 truncate">{{ p.name }}</span>
<span class="text-xs px-2 py-0.5 rounded bg-base-700/[.06] dark:bg-white/[.06] text-gray-700 dark:text-gray-200">{{ p.count }}</span>
</a>
</li>
{% endfor %}
</ul>
{% elif most_wished_product %}
<a href="{{ most_wished_product.admin_url }}" class="flex items-center gap-4">
{% if most_wished_product.image %}
<img src="{{ most_wished_product.image }}" alt="{{ most_wished_product.name }}"
class="w-16 h-16 object-cover rounded"/>
{% endif %}
<span class="font-medium">{{ most_wished_product.name }}</span>
</a>
{% else %}
{% component "unfold/components/text.html" %}
{% trans "No data yet." %}
{% endcomponent %}
{% endif %}
{% endcomponent %}
{% component "unfold/components/card.html" %}
{% component "unfold/components/title.html" %}
{% trans "Most popular product" %}
{% endcomponent %}
{% if most_popular_products %}
<ul class="flex flex-col divide-y divide-gray-200 dark:divide-base-700/50">
{% for p in most_popular_products %}
<li class="py-2 first:pt-0 last:pb-0">
<a href="{{ p.admin_url }}" class="flex items-center gap-4">
{% if p.image %}
<img src="{{ p.image }}" alt="{{ p.name }}"
class="w-12 h-12 object-cover rounded"/>
{% endif %}
<span class="font-medium flex-1 truncate">{{ p.name }}</span>
<span class="text-xs px-2 py-0.5 rounded bg-base-700/[.06] dark:bg-white/[.06] text-gray-700 dark:text-gray-200">{{ p.count }}</span>
</a>
</li>
{% endfor %}
</ul>
{% elif most_popular_product %}
<a href="{{ most_popular_product.admin_url }}" class="flex items-center gap-4">
{% if most_popular_product.image %}
<img src="{{ most_popular_product.image }}" alt="{{ most_popular_product.name }}"
class="w-16 h-16 object-cover rounded"/>
{% endif %}
<span class="font-medium">{{ most_popular_product.name }}</span>
</a>
{% else %}
{% component "unfold/components/text.html" %}
{% trans "No data yet." %}
{% endcomponent %}
{% endif %}
{% endcomponent %}
</div>

View file

@ -0,0 +1,15 @@
{% load i18n unfold %}
{% component "unfold/components/card.html" %}
{% component "unfold/components/title.html" %}
{% trans "Quick Links" %}
{% endcomponent %}
{% if quick_links %}
{% component "unfold/components/navigation.html" with class="flex flex-col gap-1" items=quick_links %}
{% endcomponent %}
{% else %}
{% component "unfold/components/text.html" %}
{% trans "No links available." %}
{% endcomponent %}
{% endif %}
{% endcomponent %}

View file

@ -0,0 +1,29 @@
{% load i18n unfold %}
{% component "unfold/components/card.html" %}
{% component "unfold/components/title.html" %}
{% trans "Shipped vs Digital (30d)" %}
{% endcomponent %}
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between text-sm">
<span class="text-gray-600 dark:text-gray-300">{% trans "Digital" %}</span>
<span class="font-medium">
{{ shipped_vs_digital.digital_qty }} ({% firstof shipped_vs_digital.digital_pct 0 %}%) ·
{% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ shipped_vs_digital.digital_gross }}
</span>
</div>
{% component "unfold/components/progress.html" with value=shipped_vs_digital.digital_pct title='' description='' %}
{% endcomponent %}
<div class="flex items-center justify-between text-sm mt-2">
<span class="text-gray-600 dark:text-gray-300">{% trans "Shipped" %}</span>
<span class="font-medium">
{{ shipped_vs_digital.shipped_qty }} ({% firstof shipped_vs_digital.shipped_pct 0 %}%) ·
{% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ shipped_vs_digital.shipped_gross }}
</span>
</div>
{% component "unfold/components/progress.html" with value=shipped_vs_digital.shipped_pct title='' description='' %}
{% endcomponent %}
</div>
{% endcomponent %}

View file

@ -0,0 +1,26 @@
{% load i18n unfold %}
{% component "unfold/components/card.html" %}
{% component "unfold/components/title.html" %}
{% trans "Top categories by quantity (30d)" %}
{% endcomponent %}
{% if top_categories %}
<ul class="flex flex-col divide-y divide-gray-200 dark:divide-base-700/50">
{% for c in top_categories %}
<li class="py-2 first:pt-0 last:pb-0">
<a href="{{ c.admin_url }}" class="flex items-center gap-4">
<span class="font-medium flex-1 truncate">{{ c.name }}</span>
<span class="text-xs px-2 py-0.5 rounded bg-base-700/[.06] dark:bg-white/[.06] text-gray-700 dark:text-gray-200">{{ c.qty }}</span>
<span class="text-xs text-gray-600 dark:text-gray-300 whitespace-nowrap">
{% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ c.gross }}
</span>
</a>
</li>
{% endfor %}
</ul>
{% else %}
{% component "unfold/components/text.html" %}
{% trans "No category sales in the last 30 days." %}
{% endcomponent %}
{% endif %}
{% endcomponent %}

View file

@ -21,383 +21,28 @@
<br/> <br/>
{% endcomponent %} {% endcomponent %}
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4 mb-6"> {% include "admin/dashboard/_kpis.html" %}
{% component "unfold/components/card.html" %}
{% component "unfold/components/text.html" %}
{% trans "Revenue (gross, 30d)" %}
{% endcomponent %}
{% component "unfold/components/title.html" %}
{% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ revenue_gross_30|default:0 }}
{% endcomponent %}
{% endcomponent %}
{% component "unfold/components/card.html" %}
{% component "unfold/components/text.html" %}
{% trans "Revenue (net, 30d)" %}
{% endcomponent %}
{% component "unfold/components/title.html" %}
{% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ revenue_net_30|default:0 }}
{% endcomponent %}
{% endcomponent %}
{% component "unfold/components/card.html" %}
{% component "unfold/components/text.html" %}
{% trans "Returns (30d)" %}
{% endcomponent %}
{% component "unfold/components/title.html" %}
{% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ returns_30|default:0 }}
{% endcomponent %}
{% endcomponent %}
{% component "unfold/components/card.html" %}
{% component "unfold/components/text.html" %}
{% trans "Processed orders (30d)" %}
{% endcomponent %}
{% component "unfold/components/title.html" %}
{{ processed_orders_30|default:0 }}
{% endcomponent %}
{% endcomponent %}
</div>
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6 items-start"> <div class="grid grid-cols-1 xl:grid-cols-3 gap-6 items-start">
{% with gross=revenue_gross_30|default:0 returns=returns_30|default:0 %} {% include "admin/dashboard/_income_overview.html" %}
{% with total=gross|add:returns %} {% include "admin/dashboard/_quick_links.html" %}
{% component "unfold/components/card.html" with class="xl:col-span-2" %}
{% component "unfold/components/title.html" %}
{% trans "Income overview" %}
{% endcomponent %}
{% if total and total > 0 %}
{% with net=revenue_net_30|default:0 %}
{% with tax_amt=gross|sub:net %}
{% with returns_capped=returns %}
{% if returns > gross %}
{% with returns_capped=gross %}{% endwith %}
{% endif %}
{% with tax_amt_pos=tax_amt %}
{% if tax_amt_pos < 0 %}
{% with tax_amt_pos=0 %}{% endwith %}
{% endif %}
{% with net_for_pie=gross|sub:tax_amt_pos|sub:returns_capped %}
{% if net_for_pie < 0 %}
{% with net_for_pie=0 %}{% endwith %}
{% endif %}
{% widthratio net_for_pie gross 360 as net_deg %}
{% widthratio tax_amt_pos gross 360 as tax_deg %}
{% widthratio returns_capped gross 360 as ret_deg %}
{% with net_end=net_deg %}
{% with tax_end=net_end|add:tax_deg %}
{% with ret_end=tax_end|add:ret_deg %}
<div class="flex flex-col sm:flex-row items-center gap-6">
<div class="relative w-48 h-48">
<canvas id="incomePieChart" width="192"
height="192"></canvas>
</div>
<div>
<div class="flex items-center gap-2 mb-2">
<span class="inline-block w-3 h-3 rounded-sm"
style="background:rgb(34,197,94)"></span>
<span class="text-sm text-gray-600 dark:text-gray-300">{% trans "Net" %}:</span>
<span class="font-semibold">{% if currency_symbol %}
{{ currency_symbol }}{% endif %}{{ net }}</span>
</div>
{% if tax_amt_pos > 0 %}
<div class="flex items-center gap-2 mb-2">
<span class="inline-block w-3 h-3 rounded-sm"
style="background:rgb(249,115,22)"></span>
<span class="text-sm text-gray-600 dark:text-gray-300">{% trans "Taxes" %}:</span>
<span class="font-semibold">{% if currency_symbol %}
{{ currency_symbol }}{% endif %}{{ tax_amt_pos|floatformat:2 }}</span>
</div>
{% endif %}
<div class="flex items-center gap-2 mb-2">
<span class="inline-block w-3 h-3 rounded-sm"
style="background:rgb(239,68,68)"></span>
<span class="text-sm text-gray-600 dark:text-gray-300">{% trans "Returns" %}:</span>
<span class="font-semibold">{% if currency_symbol %}
{{ currency_symbol }}{% endif %}{{ returns }}</span>
</div>
<div class="flex items-center gap-2">
<span class="inline-block w-3 h-3 rounded-sm"
style="background:linear-gradient(90deg, rgba(0,0,0,0.15), rgba(0,0,0,0.15))"></span>
<span class="text-sm text-gray-600 dark:text-gray-300">{% trans "Gross (pie total)" %}:</span>
<span class="font-semibold">{% if currency_symbol %}
{{ currency_symbol }}{% endif %}{{ gross }}</span>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
(function () {
try {
const ctx = document.getElementById('incomePieChart').getContext('2d');
const dataValues = [
{{ net_for_pie|floatformat:2 }},
{{ tax_amt_pos|floatformat:2 }},
{{ returns_capped|floatformat:2 }}
];
const labels = [
'{{ _("Net") }}',
'{{ _("Taxes") }}',
'{{ _("Returns") }}'
];
const colors = [
'rgb(34,197,94)',
'rgb(249,115,22)',
'rgb(239,68,68)'
];
const hasData = dataValues.some(function (v) {
return Number(v) > 0;
});
if (!hasData) {
return;
}
const currency = "{% if currency_symbol %}{{ currency_symbol }}{% endif %}";
new Chart(ctx, {
type: 'doughnut',
data: {
labels: labels,
datasets: [{
data: dataValues,
backgroundColor: colors,
borderWidth: 0,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {position: 'bottom'},
tooltip: {
callbacks: {
label: function (context) {
const label = context.label || '';
const val = context.parsed || 0;
return `${label}: ${currency}${val}`;
}
}
}
},
cutout: '55%'
}
});
} catch (e) {
console && console.warn && console.warn('Pie chart init failed', e);
}
})();
</script>
{% endwith %}
{% endwith %}
{% endwith %}
{% endwith %}
{% endwith %}
{% endwith %}
{% endwith %}
{% endwith %}
{% else %}
{% component "unfold/components/text.html" %}
{% trans "Not enough data for chart yet." %}
{% endcomponent %}
{% endif %}
{% endcomponent %}
{% endwith %}
{% endwith %}
{% component "unfold/components/card.html" %}
{% component "unfold/components/title.html" %}
{% trans "Quick Links" %}
{% endcomponent %}
{% if quick_links %}
{% component "unfold/components/navigation.html" with class="flex flex-col gap-1" items=quick_links %}
{% endcomponent %}
{% else %}
{% component "unfold/components/text.html" %}
{% trans "No links available." %}
{% endcomponent %}
{% endif %}
{% endcomponent %}
</div> </div>
{% component "unfold/components/card.html" %} {% include "admin/dashboard/_daily_sales.html" %}
{% component "unfold/components/title.html" %}
{% trans "Daily sales (30d)" %}
{% endcomponent %}
{% if daily_labels and daily_labels|length > 0 %}
<div class="w-full">
<canvas id="dailySalesChart" height="120"></canvas>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
{{ daily_labels|json_script:"daily-labels" }}
{{ daily_orders|json_script:"daily-orders" }}
{{ daily_gross|json_script:"daily-gross" }}
<script>
(function () {
try {
const labels = JSON.parse(document.getElementById('daily-labels').textContent);
const orders = JSON.parse(document.getElementById('daily-orders').textContent);
const gross = JSON.parse(document.getElementById('daily-gross').textContent);
const ctx = document.getElementById('dailySalesChart').getContext('2d');
const green = 'rgb(34,197,94)';
const blue = 'rgb(59,130,246)';
const currency = "{% if currency_symbol %}{{ currency_symbol }}{% endif %}";
new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: '{{ _("Orders (FINISHED)") }}',
data: orders,
borderColor: green,
backgroundColor: green,
borderWidth: 2,
tension: 0.25,
pointRadius: 2,
yAxisID: 'yOrders',
},
{
label: '{{ _("Gross revenue") }}',
data: gross,
borderColor: blue,
backgroundColor: blue,
borderWidth: 2,
tension: 0.25,
pointRadius: 2,
yAxisID: 'yRevenue',
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {mode: 'index', intersect: false},
plugins: {
legend: {display: true},
tooltip: {
callbacks: {
label: function (context) {
const label = context.dataset.label || '';
const val = context.parsed.y;
if (context.dataset.yAxisID === 'yRevenue') {
return `${label}: ${currency}${val}`;
}
return `${label}: ${val}`;
}
}
}
},
scales: {
x: {
grid: {display: false}
},
yOrders: {
type: 'linear',
position: 'left',
title: {display: true, text: '{{ _("Orders") }}'},
grid: {color: 'rgba(0,0,0,0.06)'},
ticks: {precision: 0}
},
yRevenue: {
type: 'linear',
position: 'right',
title: {display: true, text: '{{ _("Gross") }}'},
grid: {drawOnChartArea: false},
ticks: {
callback: function (value) {
return `${currency}${value}`;
}
}
}
}
}
});
} catch (e) {
console && console.warn && console.warn('Chart init failed', e);
}
})();
</script>
{% else %}
{% component "unfold/components/text.html" %}
{% trans "Not enough data for chart yet." %}
{% endcomponent %}
{% endif %}
{% endcomponent %}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-6"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-6">
{% component "unfold/components/card.html" %} {% include "admin/dashboard/_customers_mix.html" %}
{% component "unfold/components/title.html" %} {% if shipped_vs_digital.digital_qty|default:0 > 0 and shipped_vs_digital.shipped_qty|default:0 > 0 %}
{% trans "Most wished product" %} {% include "admin/dashboard/_shipped_vs_digital.html" %}
{% endcomponent %} {% endif %}
{% if most_wished_products %}
<ul class="flex flex-col divide-y divide-gray-200 dark:divide-base-700/50">
{% for p in most_wished_products %}
<li class="py-2 first:pt-0 last:pb-0">
<a href="{{ p.admin_url }}" class="flex items-center gap-4">
{% if p.image %}
<img src="{{ p.image }}" alt="{{ p.name }}"
class="w-12 h-12 object-cover rounded"/>
{% endif %}
<span class="font-medium flex-1 truncate">{{ p.name }}</span>
<span class="text-xs px-2 py-0.5 rounded bg-base-700/[.06] dark:bg-white/[.06] text-gray-700 dark:text-gray-200">{{ p.count }}</span>
</a>
</li>
{% endfor %}
</ul>
{% elif most_wished_product %}
<a href="{{ most_wished_product.admin_url }}" class="flex items-center gap-4">
{% if most_wished_product.image %}
<img src="{{ most_wished_product.image }}" alt="{{ most_wished_product.name }}"
class="w-16 h-16 object-cover rounded"/>
{% endif %}
<span class="font-medium">{{ most_wished_product.name }}</span>
</a>
{% else %}
{% component "unfold/components/text.html" %}
{% trans "No data yet." %}
{% endcomponent %}
{% endif %}
{% endcomponent %}
{% component "unfold/components/card.html" %}
{% component "unfold/components/title.html" %}
{% trans "Most popular product" %}
{% endcomponent %}
{% if most_popular_products %}
<ul class="flex flex-col divide-y divide-gray-200 dark:divide-base-700/50">
{% for p in most_popular_products %}
<li class="py-2 first:pt-0 last:pb-0">
<a href="{{ p.admin_url }}" class="flex items-center gap-4">
{% if p.image %}
<img src="{{ p.image }}" alt="{{ p.name }}"
class="w-12 h-12 object-cover rounded"/>
{% endif %}
<span class="font-medium flex-1 truncate">{{ p.name }}</span>
<span class="text-xs px-2 py-0.5 rounded bg-base-700/[.06] dark:bg-white/[.06] text-gray-700 dark:text-gray-200">{{ p.count }}</span>
</a>
</li>
{% endfor %}
</ul>
{% elif most_popular_product %}
<a href="{{ most_popular_product.admin_url }}" class="flex items-center gap-4">
{% if most_popular_product.image %}
<img src="{{ most_popular_product.image }}" alt="{{ most_popular_product.name }}"
class="w-16 h-16 object-cover rounded"/>
{% endif %}
<span class="font-medium">{{ most_popular_product.name }}</span>
</a>
{% else %}
{% component "unfold/components/text.html" %}
{% trans "No data yet." %}
{% endcomponent %}
{% endif %}
{% endcomponent %}
</div> </div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-6">
{% include "admin/dashboard/_most_returned.html" %}
{% include "admin/dashboard/_top_categories.html" %}
</div>
{% include "admin/dashboard/_product_lists.html" %}
{% component "unfold/components/separator.html" %} {% component "unfold/components/separator.html" %}
{% endcomponent %} {% endcomponent %}

View file

@ -1,12 +1,14 @@
from contextlib import suppress from contextlib import suppress
from datetime import date, timedelta from datetime import date, timedelta
from typing import Any
from constance import config from constance import config
from django.db.models import Count, F, QuerySet, Sum from django.db.models import Count, F, QuerySet, Sum
from django.db.models.functions import Coalesce, TruncDate from django.db.models.functions import Coalesce, TruncDate
from django.urls import reverse
from django.utils.timezone import now from django.utils.timezone import now
from engine.core.models import Order, OrderProduct from engine.core.models import Category, Order, OrderProduct, Product
def get_period_order_products( def get_period_order_products(
@ -110,3 +112,156 @@ def get_daily_gross_revenue(period: timedelta = timedelta(days=30)) -> dict[date
if d: if d:
result[d] = total_f result[d] = total_f
return result return result
def get_top_returned_products(period: timedelta = timedelta(days=30), limit: int = 10) -> list[dict[str, Any]]:
current = now()
period_start = current - period
qs = (
OrderProduct.objects.filter(
status="RETURNED",
order__status="FINISHED",
order__buy_time__lte=current,
order__buy_time__gte=period_start,
product__isnull=False,
)
.values("product")
.annotate(
returned_qty=Coalesce(Sum("quantity"), 0),
returned_amount=Coalesce(Sum(F("buy_price") * F("quantity")), 0.0),
)
.order_by("-returned_qty")[:limit]
)
result: list[dict[str, Any]] = []
prod_ids = [row["product"] for row in qs if row.get("product")]
products = Product.objects.filter(pk__in=prod_ids)
product_by_id = {p.pk: p for p in products}
for row in qs:
pid = row.get("product")
if not pid or pid not in product_by_id:
continue
p = product_by_id[pid]
img = ""
with suppress(Exception):
img = p.images.first().image_url if p.images.exists() else "" # type: ignore [union-attr]
result.append(
{
"name": p.name,
"image": img,
"admin_url": reverse("admin:core_product_change", args=[p.pk]),
"count": int(row.get("returned_qty", 0) or 0),
"amount": round(float(row.get("returned_amount", 0.0) or 0.0), 2),
}
)
return result
def get_customer_mix(period: timedelta = timedelta(days=30)) -> dict[str, int]:
current = now()
period_start = current - period
period_users = (
Order.objects.filter(status="FINISHED", buy_time__lte=current, buy_time__gte=period_start, user__isnull=False)
.values_list("user_id", flat=True)
.distinct()
)
if not period_users:
return {"new": 0, "returning": 0}
lifetime_counts = (
Order.objects.filter(status="FINISHED", user_id__in=period_users).values("user_id").annotate(c=Count("id"))
)
new_cnt = 0
ret_cnt = 0
for row in lifetime_counts:
c = int(row.get("c", 0) or 0)
if c <= 1:
new_cnt += 1
else:
ret_cnt += 1
return {"new": new_cnt, "returning": ret_cnt}
def get_top_categories_by_qty(period: timedelta = timedelta(days=30), limit: int = 10) -> list[dict[str, Any]]:
current = now()
period_start = current - period
qs = (
OrderProduct.objects.filter(
status="FINISHED",
order__status="FINISHED",
order__buy_time__lte=current,
order__buy_time__gte=period_start,
product__isnull=False,
product__category__isnull=False,
)
.values("product__category")
.annotate(
qty=Coalesce(Sum("quantity"), 0),
gross=Coalesce(Sum(F("buy_price") * F("quantity")), 0.0),
)
.order_by("-qty", "-gross")[:limit]
)
cat_ids = [row["product__category"] for row in qs if row.get("product__category")]
cats = Category.objects.filter(pk__in=cat_ids)
cat_by_id = {c.pk: c for c in cats}
result: list[dict[str, Any]] = []
for row in qs:
cid = row.get("product__category")
if not cid or cid not in cat_by_id:
continue
c = cat_by_id[cid]
result.append(
{
"name": c.name,
"admin_url": reverse("admin:core_category_change", args=[c.pk]),
"qty": int(row.get("qty", 0) or 0),
"gross": round(float(row.get("gross", 0.0) or 0.0), 2),
}
)
return result
def get_shipped_vs_digital_mix(period: timedelta = timedelta(days=30)) -> dict[str, float | int]:
current = now()
period_start = current - period
qs = (
OrderProduct.objects.filter(
status="FINISHED",
order__status="FINISHED",
order__buy_time__lte=current,
order__buy_time__gte=period_start,
product__isnull=False,
)
.values("product__is_digital")
.annotate(
qty=Coalesce(Sum("quantity"), 0),
gross=Coalesce(Sum(F("buy_price") * F("quantity")), 0.0),
)
)
digital_qty = 0
shipped_qty = 0
digital_gross = 0.0
shipped_gross = 0.0
for row in qs:
is_digital = bool(row.get("product__is_digital"))
q = int(row.get("qty", 0) or 0)
g = row.get("gross", 0.0) or 0.0
try:
g = float(g)
except (TypeError, ValueError):
g = 0.0
if is_digital:
digital_qty += q
digital_gross += g
else:
shipped_qty += q
shipped_gross += g
return {
"digital_qty": int(digital_qty),
"shipped_qty": int(shipped_qty),
"digital_gross": round(float(digital_gross), 2),
"shipped_gross": round(float(shipped_gross), 2),
}

View file

@ -1,9 +1,9 @@
import logging import logging
from datetime import timedelta
import mimetypes import mimetypes
import os import os
import traceback import traceback
from contextlib import suppress from contextlib import suppress
from datetime import timedelta
import requests import requests
from django.conf import settings from django.conf import settings
@ -18,8 +18,8 @@ from django.template import Context
from django.urls import reverse from django.urls import reverse
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.utils.http import urlsafe_base64_decode from django.utils.http import urlsafe_base64_decode
from django.utils.translation import gettext_lazy as _
from django.utils.timezone import now as tz_now from django.utils.timezone import now as tz_now
from django.utils.translation import gettext_lazy as _
from django.views.decorators.cache import cache_page from django.views.decorators.cache import cache_page
from django.views.decorators.vary import vary_on_headers from django.views.decorators.vary import vary_on_headers
from django_ratelimit.decorators import ratelimit from django_ratelimit.decorators import ratelimit
@ -60,11 +60,15 @@ from engine.core.serializers import (
from engine.core.utils import get_project_parameters, is_url_safe from engine.core.utils import get_project_parameters, is_url_safe
from engine.core.utils.caching import web_cache from engine.core.utils.caching import web_cache
from engine.core.utils.commerce import ( from engine.core.utils.commerce import (
get_returns, get_customer_mix,
get_revenue,
get_total_processed_orders,
get_daily_finished_orders_count, get_daily_finished_orders_count,
get_daily_gross_revenue, get_daily_gross_revenue,
get_returns,
get_revenue,
get_shipped_vs_digital_mix,
get_top_categories_by_qty,
get_top_returned_products,
get_total_processed_orders,
) )
from engine.core.utils.emailing import contact_us_email from engine.core.utils.emailing import contact_us_email
from engine.core.utils.languages import get_flag_by_language from engine.core.utils.languages import get_flag_by_language
@ -452,9 +456,7 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context:
} }
) )
# Single most wished product (backward compatibility)
most_wished: dict[str, str | int | float | None] | None = None most_wished: dict[str, str | int | float | None] | None = None
# Top 10 most wished products
most_wished_list: list[dict[str, str | int | float | None]] = [] most_wished_list: list[dict[str, str | int | float | None]] = []
with suppress(Exception): with suppress(Exception):
wished_qs = ( wished_qs = (
@ -475,12 +477,10 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context:
"admin_url": reverse("admin:core_product_change", args=[product.pk]), "admin_url": reverse("admin:core_product_change", args=[product.pk]),
} }
# Build top 10 list
wished_top10 = list(wished_qs[:10]) wished_top10 = list(wished_qs[:10])
if wished_top10: if wished_top10:
counts_map = {row["products"]: row["cnt"] for row in wished_top10 if row.get("products")} counts_map = {row["products"]: row["cnt"] for row in wished_top10 if row.get("products")}
products = Product.objects.filter(pk__in=counts_map.keys()) products = Product.objects.filter(pk__in=counts_map.keys())
# Preserve order as in wished_top10
product_by_id = {p.pk: p for p in products} product_by_id = {p.pk: p for p in products}
for row in wished_top10: for row in wished_top10:
pid = row.get("products") pid = row.get("products")
@ -497,9 +497,7 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context:
} }
) )
# Daily stats for the last 30 days
try: try:
# Build continuous date axis
today = tz_now().date() today = tz_now().date()
days = 30 days = 30
date_axis = [today - timedelta(days=i) for i in range(days - 1, -1, -1)] date_axis = [today - timedelta(days=i) for i in range(days - 1, -1, -1)]
@ -507,7 +505,6 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context:
orders_map = get_daily_finished_orders_count(timedelta(days=days)) orders_map = get_daily_finished_orders_count(timedelta(days=days))
gross_map = get_daily_gross_revenue(timedelta(days=days)) gross_map = get_daily_gross_revenue(timedelta(days=days))
# Labels in day-month format, e.g., 05 Nov
labels = [d.strftime("%d %b") for d in date_axis] labels = [d.strftime("%d %b") for d in date_axis]
orders_series = [int(orders_map.get(d, 0) or 0) for d in date_axis] orders_series = [int(orders_map.get(d, 0) or 0) for d in date_axis]
gross_series = [float(gross_map.get(d, 0.0) or 0.0) for d in date_axis] gross_series = [float(gross_map.get(d, 0.0) or 0.0) for d in date_axis]
@ -515,15 +512,20 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context:
context["daily_labels"] = labels context["daily_labels"] = labels
context["daily_orders"] = orders_series context["daily_orders"] = orders_series
context["daily_gross"] = gross_series context["daily_gross"] = gross_series
except Exception as e: # pragma: no cover - fail safe except Exception as e:
logger.warning("Failed to build daily stats: %s", e) logger.warning("Failed to build daily stats: %s", e)
context["daily_labels"] = [] context["daily_labels"] = []
context["daily_orders"] = [] context["daily_orders"] = []
context["daily_gross"] = [] context["daily_gross"] = []
with suppress(Exception):
today = tz_now().date()
days = 30
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]
# Single most popular product (backward compatibility)
most_popular: dict[str, str | int | float | None] | None = None most_popular: dict[str, str | int | float | None] | None = None
# Top 10 most popular products by quantity
most_popular_list: list[dict[str, str | int | float | None]] = [] most_popular_list: list[dict[str, str | int | float | None]] = []
with suppress(Exception): with suppress(Exception):
popular_qs = ( popular_qs = (
@ -563,6 +565,50 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context:
} }
) )
customers_mix: dict[str, int | float] = {"new": 0, "returning": 0, "new_pct": 0.0, "returning_pct": 0.0}
with suppress(Exception):
mix = get_customer_mix()
n = int(mix.get("new", 0))
r = int(mix.get("returning", 0))
t = max(n + r, 0)
new_pct = round((n / t * 100.0), 1) if t > 0 else 0.0
ret_pct = round((r / t * 100.0), 1) if t > 0 else 0.0
customers_mix = {"new": n, "returning": r, "new_pct": new_pct, "returning_pct": ret_pct, "total": t}
shipped_vs_digital: dict[str, int | float] = {
"digital_qty": 0,
"shipped_qty": 0,
"digital_gross": 0.0,
"shipped_gross": 0.0,
"digital_pct": 0.0,
"shipped_pct": 0.0,
}
with suppress(Exception):
svd = get_shipped_vs_digital_mix()
dq = int(svd.get("digital_qty", 0))
sq = int(svd.get("shipped_qty", 0))
total_q = dq + sq
digital_pct = round((dq / total_q * 100.0), 1) if total_q > 0 else 0.0
shipped_pct = round((sq / total_q * 100.0), 1) if total_q > 0 else 0.0
shipped_vs_digital.update(
{
"digital_qty": dq,
"shipped_qty": sq,
"digital_gross": float(svd.get("digital_gross", 0.0) or 0.0),
"shipped_gross": float(svd.get("shipped_gross", 0.0) or 0.0),
"digital_pct": digital_pct,
"shipped_pct": shipped_pct,
}
)
most_returned_products: list[dict[str, str | int | float]] = []
with suppress(Exception):
most_returned_products = get_top_returned_products()
top_categories: list[dict[str, str | int | float]] = []
with suppress(Exception):
top_categories = get_top_categories_by_qty()
context.update( context.update(
{ {
"custom_variable": "value", "custom_variable": "value",
@ -577,6 +623,10 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context:
"most_wished_products": most_wished_list, "most_wished_products": most_wished_list,
"most_popular_products": most_popular_list, "most_popular_products": most_popular_list,
"currency_symbol": currency_symbol, "currency_symbol": currency_symbol,
"customers_mix": customers_mix,
"shipped_vs_digital": shipped_vs_digital,
"most_returned_products": most_returned_products,
"top_categories": top_categories,
} }
) )

View file

@ -1,11 +0,0 @@
"""
Deprecated: Jazzmin settings (removed in favor of django-unfold).
This file is intentionally left as a stub to avoid accidental imports.
If imported, raise an explicit error guiding developers to Unfold.
"""
raise ImportError(
"Jazzmin configuration has been removed. Use django-unfold instead. "
"See evibes/settings/unfold.py and INSTALLED_APPS in evibes/settings/base.py."
)