mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-07-08 22:05:11 +02:00
@@ -8,6 +8,7 @@ from apps.common.widgets.datepicker import (
|
|||||||
AirYearPickerInput,
|
AirYearPickerInput,
|
||||||
AirDatePickerInput,
|
AirDatePickerInput,
|
||||||
)
|
)
|
||||||
|
from apps.transactions.models import TransactionCategory
|
||||||
|
|
||||||
|
|
||||||
class SingleMonthForm(forms.Form):
|
class SingleMonthForm(forms.Form):
|
||||||
@@ -108,3 +109,20 @@ class DateRangeForm(forms.Form):
|
|||||||
css_class="mb-0",
|
css_class="mb-0",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryForm(forms.Form):
|
||||||
|
category = forms.ModelChoiceField(
|
||||||
|
required=False,
|
||||||
|
label=_("Category"),
|
||||||
|
queryset=TransactionCategory.objects.filter(active=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.helper = FormHelper()
|
||||||
|
self.helper.form_tag = False
|
||||||
|
self.helper.disable_csrf = True
|
||||||
|
|
||||||
|
self.helper.layout = Layout("category")
|
||||||
|
|||||||
@@ -14,4 +14,19 @@ urlpatterns = [
|
|||||||
views.sankey_by_currency,
|
views.sankey_by_currency,
|
||||||
name="insights_sankey_by_currency",
|
name="insights_sankey_by_currency",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"insights/category-explorer/",
|
||||||
|
views.category_explorer_index,
|
||||||
|
name="category_explorer_index",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"insights/category-explorer/account/",
|
||||||
|
views.category_sum_by_account,
|
||||||
|
name="category_sum_by_account",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"insights/category-explorer/currency/",
|
||||||
|
views.category_sum_by_currency,
|
||||||
|
name="category_sum_by_currency",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
from django.db.models import Sum, Case, When, F, DecimalField, Value
|
||||||
|
from django.db.models.functions import Coalesce
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
|
def get_category_sums_by_account(queryset, category):
|
||||||
|
"""
|
||||||
|
Returns income/expense sums per account for a specific category.
|
||||||
|
"""
|
||||||
|
sums = (
|
||||||
|
queryset.filter(category=category)
|
||||||
|
.values("account__name")
|
||||||
|
.annotate(
|
||||||
|
income=Coalesce(
|
||||||
|
Sum(
|
||||||
|
Case(
|
||||||
|
When(type="IN", then="amount"),
|
||||||
|
default=Value(0),
|
||||||
|
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
Value(0),
|
||||||
|
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||||
|
),
|
||||||
|
expense=Coalesce(
|
||||||
|
Sum(
|
||||||
|
Case(
|
||||||
|
When(type="EX", then=-F("amount")),
|
||||||
|
default=Value(0),
|
||||||
|
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
Value(0),
|
||||||
|
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.order_by("account__name")
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"labels": [item["account__name"] for item in sums],
|
||||||
|
"datasets": [
|
||||||
|
{
|
||||||
|
"label": _("Income"),
|
||||||
|
"data": [float(item["income"]) for item in sums],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": _("Expenses"),
|
||||||
|
"data": [float(item["expense"]) for item in sums],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_category_sums_by_currency(queryset, category):
|
||||||
|
"""
|
||||||
|
Returns income/expense sums per currency for a specific category.
|
||||||
|
"""
|
||||||
|
sums = (
|
||||||
|
queryset.filter(category=category)
|
||||||
|
.values("account__currency__code")
|
||||||
|
.annotate(
|
||||||
|
income=Coalesce(
|
||||||
|
Sum(
|
||||||
|
Case(
|
||||||
|
When(type="IN", then="amount"),
|
||||||
|
default=Value(0),
|
||||||
|
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
Value(0),
|
||||||
|
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||||
|
),
|
||||||
|
expense=Coalesce(
|
||||||
|
Sum(
|
||||||
|
Case(
|
||||||
|
When(type="EX", then=-F("amount")),
|
||||||
|
default=Value(0),
|
||||||
|
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
Value(0),
|
||||||
|
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.order_by("account__currency__code")
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"labels": [item["account__currency__code"] for item in sums],
|
||||||
|
"datasets": [
|
||||||
|
{
|
||||||
|
"label": _("Income"),
|
||||||
|
"data": [float(item["income"]) for item in sums],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": _("Expenses"),
|
||||||
|
"data": [float(item["expense"]) for item in sums],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
+77
-10
@@ -1,24 +1,28 @@
|
|||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.views.decorators.http import require_http_methods
|
from django.views.decorators.http import require_http_methods
|
||||||
|
|
||||||
from dateutil.relativedelta import relativedelta
|
from apps.common.decorators.htmx import only_htmx
|
||||||
|
|
||||||
from apps.transactions.models import Transaction
|
|
||||||
from apps.insights.utils.sankey import (
|
|
||||||
generate_sankey_data_by_account,
|
|
||||||
generate_sankey_data_by_currency,
|
|
||||||
)
|
|
||||||
from apps.insights.forms import (
|
from apps.insights.forms import (
|
||||||
SingleMonthForm,
|
SingleMonthForm,
|
||||||
SingleYearForm,
|
SingleYearForm,
|
||||||
MonthRangeForm,
|
MonthRangeForm,
|
||||||
YearRangeForm,
|
YearRangeForm,
|
||||||
DateRangeForm,
|
DateRangeForm,
|
||||||
|
CategoryForm,
|
||||||
|
)
|
||||||
|
from apps.insights.utils.category_explorer import (
|
||||||
|
get_category_sums_by_account,
|
||||||
|
get_category_sums_by_currency,
|
||||||
|
)
|
||||||
|
from apps.insights.utils.sankey import (
|
||||||
|
generate_sankey_data_by_account,
|
||||||
|
generate_sankey_data_by_currency,
|
||||||
)
|
)
|
||||||
from apps.common.decorators.htmx import only_htmx
|
|
||||||
from apps.insights.utils.transactions import get_transactions
|
from apps.insights.utils.transactions import get_transactions
|
||||||
|
from apps.transactions.models import TransactionCategory
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@@ -61,7 +65,7 @@ def index(request):
|
|||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
@require_http_methods(["GET", "POST"])
|
@require_http_methods(["GET"])
|
||||||
def sankey_by_account(request):
|
def sankey_by_account(request):
|
||||||
# Get filtered transactions
|
# Get filtered transactions
|
||||||
|
|
||||||
@@ -79,7 +83,7 @@ def sankey_by_account(request):
|
|||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
@require_http_methods(["GET", "POST"])
|
@require_http_methods(["GET"])
|
||||||
def sankey_by_currency(request):
|
def sankey_by_currency(request):
|
||||||
# Get filtered transactions
|
# Get filtered transactions
|
||||||
transactions = get_transactions(request)
|
transactions = get_transactions(request)
|
||||||
@@ -92,3 +96,66 @@ def sankey_by_currency(request):
|
|||||||
"insights/fragments/sankey.html",
|
"insights/fragments/sankey.html",
|
||||||
{"sankey_data": sankey_data, "type": "currency"},
|
{"sankey_data": sankey_data, "type": "currency"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def category_explorer_index(request):
|
||||||
|
category_form = CategoryForm()
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"insights/fragments/category_explorer/index.html",
|
||||||
|
{"category_form": category_form},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def category_sum_by_account(request):
|
||||||
|
# Get filtered transactions
|
||||||
|
transactions = get_transactions(request)
|
||||||
|
|
||||||
|
category = request.GET.get("category")
|
||||||
|
|
||||||
|
if category:
|
||||||
|
category = TransactionCategory.objects.get(id=category)
|
||||||
|
|
||||||
|
# Generate data
|
||||||
|
account_data = get_category_sums_by_account(transactions, category)
|
||||||
|
else:
|
||||||
|
account_data = None
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"insights/fragments/category_explorer/charts/account.html",
|
||||||
|
{"account_data": account_data},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def category_sum_by_currency(request):
|
||||||
|
# Get filtered transactions
|
||||||
|
transactions = get_transactions(request)
|
||||||
|
|
||||||
|
category = request.GET.get("category")
|
||||||
|
|
||||||
|
if category:
|
||||||
|
category = TransactionCategory.objects.get(id=category)
|
||||||
|
|
||||||
|
# Generate data
|
||||||
|
currency_data = get_category_sums_by_currency(transactions, category)
|
||||||
|
else:
|
||||||
|
currency_data = None
|
||||||
|
|
||||||
|
print(currency_data)
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"insights/fragments/category_explorer/charts/currency.html",
|
||||||
|
{"currency_data": currency_data},
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
<div class="chart-container" style="position: relative; height:400px; width:100%" _="init call setupAccountChart() end">
|
||||||
|
<canvas id="accountChart"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Get the data from your Django view (passed as JSON)
|
||||||
|
var accountData = {{ account_data|safe }};
|
||||||
|
|
||||||
|
function setupAccountChart() {
|
||||||
|
var chartOptions = {
|
||||||
|
indexAxis: 'y', // This makes the chart horizontal
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
stacked: true, // Enable stacking on the x-axis
|
||||||
|
title: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
stacked: true, // Enable stacking on the y-axis
|
||||||
|
title: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: function (context) {
|
||||||
|
if (context.parsed.x !== null) {
|
||||||
|
return new Intl.NumberFormat(undefined).format(Math.abs(context.parsed.x)); // Using abs for display
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
new Chart(
|
||||||
|
document.getElementById('accountChart'),
|
||||||
|
{
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: accountData.labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "{% trans 'Income' %}",
|
||||||
|
data: accountData.datasets[0].data,
|
||||||
|
backgroundColor: '#4dde80',
|
||||||
|
stack: 'stack0'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "{% trans 'Expenses' %}",
|
||||||
|
data: accountData.datasets[1].data,
|
||||||
|
backgroundColor: '#f87171',
|
||||||
|
stack: 'stack0'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
...chartOptions,
|
||||||
|
plugins: {
|
||||||
|
...chartOptions.plugins,
|
||||||
|
title: {
|
||||||
|
display: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
<div class="chart-container" style="position: relative; height:400px; width:100%"
|
||||||
|
_="init call setupCurrencyChart() end">
|
||||||
|
<canvas id="currencyChart"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Get the data from your Django view (passed as JSON)
|
||||||
|
var currencyData = {{ currency_data|safe }};
|
||||||
|
|
||||||
|
function setupCurrencyChart() {
|
||||||
|
var chartOptions = {
|
||||||
|
indexAxis: 'y', // This makes the chart horizontal
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
stacked: true, // Enable stacking on the x-axis
|
||||||
|
title: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
stacked: true, // Enable stacking on the y-axis
|
||||||
|
title: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: function (context) {
|
||||||
|
if (context.parsed.x !== null) {
|
||||||
|
return new Intl.NumberFormat(undefined).format(Math.abs(context.parsed.x)); // Using abs for display
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
new Chart(
|
||||||
|
document.getElementById('currencyChart'),
|
||||||
|
{
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: currencyData.labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "{% trans 'Income' %}",
|
||||||
|
data: currencyData.datasets[0].data,
|
||||||
|
backgroundColor: '#4dde80',
|
||||||
|
stack: 'stack0'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "{% trans 'Expenses' %}",
|
||||||
|
data: currencyData.datasets[1].data,
|
||||||
|
backgroundColor: '#f87171',
|
||||||
|
stack: 'stack0'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
...chartOptions,
|
||||||
|
plugins: {
|
||||||
|
...chartOptions.plugins,
|
||||||
|
title: {
|
||||||
|
display: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
{% load crispy_forms_tags %}
|
||||||
|
|
||||||
|
<form _="install init_tom_select
|
||||||
|
on change trigger updated" id="category-form">
|
||||||
|
{% crispy category_form %}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="row row-cols-1 row-cols-lg-2 gx-3 gy-3">
|
||||||
|
<div class="col">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
{% trans "Income/Expense by Account" %}
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="account-card" class="show-loading" hx-get="{% url 'category_sum_by_account' %}"
|
||||||
|
hx-trigger="updated from:window" hx-include="#category-form, #picker-form, #picker-type">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
{% trans "Income/Expense by Currency" %}
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="currency-card" class="show-loading" hx-get="{% url 'category_sum_by_currency' %}"
|
||||||
|
hx-trigger="updated from:window" hx-include="#category-form, #picker-form, #picker-type">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
@@ -104,23 +104,10 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Destroy existing chart if it exists
|
|
||||||
const existingChart = Chart.getChart(chartId);
|
|
||||||
if (existingChart) {
|
|
||||||
existingChart.destroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new chart
|
// Create new chart
|
||||||
var chart = new Chart(
|
new Chart(
|
||||||
document.getElementById(chartId),
|
document.getElementById(chartId),
|
||||||
config
|
config
|
||||||
);
|
);
|
||||||
window.addEventListener('resize', () => {
|
|
||||||
chart.resize();
|
|
||||||
});
|
|
||||||
document.addEventListener('fullscreenchange', function () {
|
|
||||||
console.log('oi');
|
|
||||||
chart.resize();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -80,6 +80,13 @@
|
|||||||
hx-indicator="#tab-content"
|
hx-indicator="#tab-content"
|
||||||
hx-target="#tab-content">{% trans 'Currency Flow' %}
|
hx-target="#tab-content">{% trans 'Currency Flow' %}
|
||||||
</button>
|
</button>
|
||||||
|
<button class="nav-link" id="v-pills-tab" data-bs-toggle="pill" data-bs-target="#v-pills-content"
|
||||||
|
type="button" role="tab" aria-controls="v-pills-content" aria-selected="false"
|
||||||
|
hx-get="{% url 'category_explorer_index' %}"
|
||||||
|
hx-include="#picker-form, #picker-type"
|
||||||
|
hx-indicator="#tab-content"
|
||||||
|
hx-target="#tab-content">{% trans 'Category Explorer' %}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-9 col-lg-10">
|
<div class="col-md-9 col-lg-10">
|
||||||
|
|||||||
Reference in New Issue
Block a user