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:
parent
68890017f6
commit
71389ee278
13 changed files with 717 additions and 396 deletions
28
engine/core/templates/admin/dashboard/_customers_mix.html
Normal file
28
engine/core/templates/admin/dashboard/_customers_mix.html
Normal 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 %}
|
||||
129
engine/core/templates/admin/dashboard/_daily_sales.html
Normal file
129
engine/core/templates/admin/dashboard/_daily_sales.html
Normal 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 %}
|
||||
118
engine/core/templates/admin/dashboard/_income_overview.html
Normal file
118
engine/core/templates/admin/dashboard/_income_overview.html
Normal 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 %}
|
||||
39
engine/core/templates/admin/dashboard/_kpis.html
Normal file
39
engine/core/templates/admin/dashboard/_kpis.html
Normal 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>
|
||||
27
engine/core/templates/admin/dashboard/_most_returned.html
Normal file
27
engine/core/templates/admin/dashboard/_most_returned.html
Normal 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 %}
|
||||
71
engine/core/templates/admin/dashboard/_product_lists.html
Normal file
71
engine/core/templates/admin/dashboard/_product_lists.html
Normal 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>
|
||||
15
engine/core/templates/admin/dashboard/_quick_links.html
Normal file
15
engine/core/templates/admin/dashboard/_quick_links.html
Normal 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 %}
|
||||
|
|
@ -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 %}
|
||||
26
engine/core/templates/admin/dashboard/_top_categories.html
Normal file
26
engine/core/templates/admin/dashboard/_top_categories.html
Normal 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 %}
|
||||
|
|
@ -21,383 +21,28 @@
|
|||
<br/>
|
||||
{% endcomponent %}
|
||||
|
||||
<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>
|
||||
{% include "admin/dashboard/_kpis.html" %}
|
||||
|
||||
<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 %}
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
{% include "admin/dashboard/_income_overview.html" %}
|
||||
{% include "admin/dashboard/_quick_links.html" %}
|
||||
</div>
|
||||
|
||||
{% component "unfold/components/card.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 %}
|
||||
{% include "admin/dashboard/_daily_sales.html" %}
|
||||
|
||||
<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"/>
|
||||
{% include "admin/dashboard/_customers_mix.html" %}
|
||||
{% if shipped_vs_digital.digital_qty|default:0 > 0 and shipped_vs_digital.shipped_qty|default:0 > 0 %}
|
||||
{% include "admin/dashboard/_shipped_vs_digital.html" %}
|
||||
{% 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 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" %}
|
||||
{% endcomponent %}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
from contextlib import suppress
|
||||
from datetime import date, timedelta
|
||||
from typing import Any
|
||||
|
||||
from constance import config
|
||||
from django.db.models import Count, F, QuerySet, Sum
|
||||
from django.db.models.functions import Coalesce, TruncDate
|
||||
from django.urls import reverse
|
||||
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(
|
||||
|
|
@ -110,3 +112,156 @@ def get_daily_gross_revenue(period: timedelta = timedelta(days=30)) -> dict[date
|
|||
if d:
|
||||
result[d] = total_f
|
||||
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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import logging
|
||||
from datetime import timedelta
|
||||
import mimetypes
|
||||
import os
|
||||
import traceback
|
||||
from contextlib import suppress
|
||||
from datetime import timedelta
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
|
|
@ -18,8 +18,8 @@ from django.template import Context
|
|||
from django.urls import reverse
|
||||
from django.utils.decorators import method_decorator
|
||||
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.translation import gettext_lazy as _
|
||||
from django.views.decorators.cache import cache_page
|
||||
from django.views.decorators.vary import vary_on_headers
|
||||
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.caching import web_cache
|
||||
from engine.core.utils.commerce import (
|
||||
get_returns,
|
||||
get_revenue,
|
||||
get_total_processed_orders,
|
||||
get_customer_mix,
|
||||
get_daily_finished_orders_count,
|
||||
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.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
|
||||
# Top 10 most wished products
|
||||
most_wished_list: list[dict[str, str | int | float | None]] = []
|
||||
with suppress(Exception):
|
||||
wished_qs = (
|
||||
|
|
@ -475,12 +477,10 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context:
|
|||
"admin_url": reverse("admin:core_product_change", args=[product.pk]),
|
||||
}
|
||||
|
||||
# Build top 10 list
|
||||
wished_top10 = list(wished_qs[:10])
|
||||
if wished_top10:
|
||||
counts_map = {row["products"]: row["cnt"] for row in wished_top10 if row.get("products")}
|
||||
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}
|
||||
for row in wished_top10:
|
||||
pid = row.get("products")
|
||||
|
|
@ -497,9 +497,7 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context:
|
|||
}
|
||||
)
|
||||
|
||||
# Daily stats for the last 30 days
|
||||
try:
|
||||
# Build continuous date axis
|
||||
today = tz_now().date()
|
||||
days = 30
|
||||
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))
|
||||
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]
|
||||
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]
|
||||
|
|
@ -515,15 +512,20 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context:
|
|||
context["daily_labels"] = labels
|
||||
context["daily_orders"] = orders_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)
|
||||
context["daily_labels"] = []
|
||||
context["daily_orders"] = []
|
||||
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
|
||||
# Top 10 most popular products by quantity
|
||||
most_popular_list: list[dict[str, str | int | float | None]] = []
|
||||
with suppress(Exception):
|
||||
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(
|
||||
{
|
||||
"custom_variable": "value",
|
||||
|
|
@ -577,6 +623,10 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context:
|
|||
"most_wished_products": most_wished_list,
|
||||
"most_popular_products": most_popular_list,
|
||||
"currency_symbol": currency_symbol,
|
||||
"customers_mix": customers_mix,
|
||||
"shipped_vs_digital": shipped_vs_digital,
|
||||
"most_returned_products": most_returned_products,
|
||||
"top_categories": top_categories,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
)
|
||||
Loading…
Reference in a new issue