Merge pull request #195

feat(insights): add Categories Overview
This commit is contained in:
Herculino Trotta
2025-02-27 23:33:25 -03:00
committed by GitHub
9 changed files with 338 additions and 24 deletions

View File

@@ -29,6 +29,11 @@ urlpatterns = [
views.category_sum_by_currency,
name="category_sum_by_currency",
),
path(
"insights/category-overview/",
views.category_overview,
name="category_overview",
),
path(
"insights/late-transactions/",
views.late_transactions,

View File

@@ -0,0 +1,165 @@
from decimal import Decimal
from django.db import models
from django.db.models import Sum, Case, When, Value, DecimalField
from django.db.models.functions import Coalesce
from apps.transactions.models import Transaction
from apps.currencies.models import Currency
from apps.currencies.utils.convert import convert
def get_categories_totals(transactions_queryset, ignore_empty=False):
# Get metrics for each category and currency in a single query
category_currency_metrics = (
transactions_queryset.values(
"category",
"category__name",
"account__currency",
"account__currency__code",
"account__currency__name",
"account__currency__decimal_places",
"account__currency__prefix",
"account__currency__suffix",
"account__currency__exchange_currency",
)
.annotate(
expense_current=Coalesce(
Sum(
Case(
When(
type=Transaction.Type.EXPENSE, is_paid=True, then="amount"
),
default=Value(0),
output_field=models.DecimalField(),
)
),
Decimal("0"),
),
expense_projected=Coalesce(
Sum(
Case(
When(
type=Transaction.Type.EXPENSE, is_paid=False, then="amount"
),
default=Value(0),
output_field=models.DecimalField(),
)
),
Decimal("0"),
),
income_current=Coalesce(
Sum(
Case(
When(type=Transaction.Type.INCOME, is_paid=True, then="amount"),
default=Value(0),
output_field=models.DecimalField(),
)
),
Decimal("0"),
),
income_projected=Coalesce(
Sum(
Case(
When(
type=Transaction.Type.INCOME, is_paid=False, then="amount"
),
default=Value(0),
output_field=models.DecimalField(),
)
),
Decimal("0"),
),
)
.order_by("category__name")
)
# Process the results to structure by category
result = {}
for metric in category_currency_metrics:
# Skip empty categories if ignore_empty is True
if ignore_empty and all(
metric[field] == Decimal("0")
for field in [
"expense_current",
"expense_projected",
"income_current",
"income_projected",
]
):
continue
# Calculate derived totals
total_current = metric["income_current"] - metric["expense_current"]
total_projected = metric["income_projected"] - metric["expense_projected"]
total_income = metric["income_current"] + metric["income_projected"]
total_expense = metric["expense_current"] + metric["expense_projected"]
total_final = total_current + total_projected
category_id = metric["category"]
currency_id = metric["account__currency"]
if category_id not in result:
result[category_id] = {"name": metric["category__name"], "currencies": {}}
# Add currency data
currency_data = {
"currency": {
"code": metric["account__currency__code"],
"name": metric["account__currency__name"],
"decimal_places": metric["account__currency__decimal_places"],
"prefix": metric["account__currency__prefix"],
"suffix": metric["account__currency__suffix"],
},
"expense_current": metric["expense_current"],
"expense_projected": metric["expense_projected"],
"total_expense": total_expense,
"income_current": metric["income_current"],
"income_projected": metric["income_projected"],
"total_income": total_income,
"total_current": total_current,
"total_projected": total_projected,
"total_final": total_final,
}
# Add exchanged values if exchange_currency exists
if metric["account__currency__exchange_currency"]:
from_currency = Currency.objects.get(id=currency_id)
exchange_currency = Currency.objects.get(
id=metric["account__currency__exchange_currency"]
)
exchanged = {}
for field in [
"expense_current",
"expense_projected",
"income_current",
"income_projected",
"total_income",
"total_expense",
"total_current",
"total_projected",
"total_final",
]:
amount, prefix, suffix, decimal_places = convert(
amount=currency_data[field],
from_currency=from_currency,
to_currency=exchange_currency,
)
if amount is not None:
exchanged[field] = amount
if "currency" not in exchanged:
exchanged["currency"] = {
"prefix": prefix,
"suffix": suffix,
"decimal_places": decimal_places,
"code": exchange_currency.code,
"name": exchange_currency.name,
}
if exchanged:
currency_data["exchanged"] = exchanged
result[category_id]["currencies"][currency_id] = currency_data
return result

View File

@@ -1,3 +1,6 @@
import decimal
import json
from dateutil.relativedelta import relativedelta
from django.contrib.auth.decorators import login_required
from django.shortcuts import render
@@ -23,6 +26,7 @@ from apps.insights.utils.sankey import (
)
from apps.insights.utils.transactions import get_transactions
from apps.transactions.models import TransactionCategory, Transaction
from apps.insights.utils.category_overview import get_categories_totals
@login_required
@@ -159,6 +163,24 @@ def category_sum_by_currency(request):
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def category_overview(request):
# Get filtered transactions
transactions = get_transactions(request, include_silent=True)
total_table = get_categories_totals(
transactions_queryset=transactions, ignore_empty=False
)
return render(
request,
"insights/fragments/category_overview/index.html",
{"total_table": total_table},
)
@only_htmx
@login_required
@require_http_methods(["GET"])

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-02-24 16:30-0300\n"
"POT-Creation-Date: 2025-02-27 23:32-0300\n"
"PO-Revision-Date: 2025-02-24 22:33+0100\n"
"Last-Translator: \n"
"Language-Team: \n"
@@ -76,6 +76,7 @@ msgstr "Neuer Saldo"
#: apps/transactions/forms.py:299 apps/transactions/forms.py:479
#: apps/transactions/forms.py:724 apps/transactions/models.py:203
#: apps/transactions/models.py:378 apps/transactions/models.py:558
#: templates/insights/fragments/category_overview/index.html:9
msgid "Category"
msgstr "Kategorie"
@@ -880,6 +881,7 @@ msgstr "Vorgang erfolgreich gelöscht"
#: apps/insights/forms.py:119 apps/insights/utils/sankey.py:36
#: apps/insights/utils/sankey.py:167
#: templates/insights/fragments/category_overview/index.html:18
msgid "Uncategorized"
msgstr "Unkategorisiert"
@@ -1252,6 +1254,7 @@ msgstr "Entität"
#: templates/calendar_view/fragments/list.html:52
#: templates/calendar_view/fragments/list.html:54
#: templates/cotton/ui/quick_transactions_buttons.html:10
#: templates/insights/fragments/category_overview/index.html:10
#: templates/monthly_overview/fragments/monthly_summary.html:39
msgid "Income"
msgstr "Einnahme"
@@ -1262,6 +1265,7 @@ msgstr "Einnahme"
#: templates/calendar_view/fragments/list.html:56
#: templates/calendar_view/fragments/list.html:58
#: templates/cotton/ui/quick_transactions_buttons.html:18
#: templates/insights/fragments/category_overview/index.html:11
msgid "Expense"
msgstr "Ausgabe"
@@ -1888,6 +1892,7 @@ msgid "Muted"
msgstr "Ausgeblendet"
#: templates/categories/fragments/table.html:57
#: templates/insights/fragments/category_overview/index.html:67
msgid "No categories"
msgstr "Keine Kategorien"
@@ -2451,6 +2456,11 @@ msgstr "Einnahmen/Ausgaben nach Konto"
msgid "Income/Expense by Currency"
msgstr "Einnahmen/Ausgaben nach Währung"
#: templates/insights/fragments/category_overview/index.html:12
#: templates/monthly_overview/fragments/monthly_summary.html:167
msgid "Total"
msgstr "Gesamt"
#: templates/insights/fragments/late_transactions.html:15
msgid "All good!"
msgstr "Alles gut!"
@@ -2506,10 +2516,16 @@ msgid "Category Explorer"
msgstr "Kategorien-Explorer"
#: templates/insights/pages/index.html:102
#, fuzzy
#| msgid "Categories"
msgid "Categories Overview"
msgstr "Kategorien"
#: templates/insights/pages/index.html:109
msgid "Late Transactions"
msgstr "Verspätete Transaktionen"
#: templates/insights/pages/index.html:108
#: templates/insights/pages/index.html:115
msgid "Latest Transactions"
msgstr "Letzte Transaktionen"
@@ -2607,10 +2623,6 @@ msgstr "erwartet"
msgid "Expenses"
msgstr "Ausgaben"
#: templates/monthly_overview/fragments/monthly_summary.html:167
msgid "Total"
msgstr "Gesamt"
#: templates/monthly_overview/fragments/monthly_summary.html:257
msgid "Distribution"
msgstr "Verteilung"

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-02-24 23:04-0300\n"
"POT-Creation-Date: 2025-02-27 23:32-0300\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -75,6 +75,7 @@ msgstr ""
#: apps/transactions/forms.py:299 apps/transactions/forms.py:479
#: apps/transactions/forms.py:724 apps/transactions/models.py:203
#: apps/transactions/models.py:378 apps/transactions/models.py:558
#: templates/insights/fragments/category_overview/index.html:9
msgid "Category"
msgstr ""
@@ -862,6 +863,7 @@ msgstr ""
#: apps/insights/forms.py:119 apps/insights/utils/sankey.py:36
#: apps/insights/utils/sankey.py:167
#: templates/insights/fragments/category_overview/index.html:18
msgid "Uncategorized"
msgstr ""
@@ -1221,6 +1223,7 @@ msgstr ""
#: templates/calendar_view/fragments/list.html:52
#: templates/calendar_view/fragments/list.html:54
#: templates/cotton/ui/quick_transactions_buttons.html:10
#: templates/insights/fragments/category_overview/index.html:10
#: templates/monthly_overview/fragments/monthly_summary.html:39
msgid "Income"
msgstr ""
@@ -1231,6 +1234,7 @@ msgstr ""
#: templates/calendar_view/fragments/list.html:56
#: templates/calendar_view/fragments/list.html:58
#: templates/cotton/ui/quick_transactions_buttons.html:18
#: templates/insights/fragments/category_overview/index.html:11
msgid "Expense"
msgstr ""
@@ -1856,6 +1860,7 @@ msgid "Muted"
msgstr ""
#: templates/categories/fragments/table.html:57
#: templates/insights/fragments/category_overview/index.html:67
msgid "No categories"
msgstr ""
@@ -2414,6 +2419,11 @@ msgstr ""
msgid "Income/Expense by Currency"
msgstr ""
#: templates/insights/fragments/category_overview/index.html:12
#: templates/monthly_overview/fragments/monthly_summary.html:167
msgid "Total"
msgstr ""
#: templates/insights/fragments/late_transactions.html:15
msgid "All good!"
msgstr ""
@@ -2469,10 +2479,14 @@ msgid "Category Explorer"
msgstr ""
#: templates/insights/pages/index.html:102
msgid "Categories Overview"
msgstr ""
#: templates/insights/pages/index.html:109
msgid "Late Transactions"
msgstr ""
#: templates/insights/pages/index.html:108
#: templates/insights/pages/index.html:115
msgid "Latest Transactions"
msgstr ""
@@ -2567,10 +2581,6 @@ msgstr ""
msgid "Expenses"
msgstr ""
#: templates/monthly_overview/fragments/monthly_summary.html:167
msgid "Total"
msgstr ""
#: templates/monthly_overview/fragments/monthly_summary.html:257
msgid "Distribution"
msgstr ""

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-02-24 16:30-0300\n"
"POT-Creation-Date: 2025-02-27 23:32-0300\n"
"PO-Revision-Date: 2025-02-22 15:03+0100\n"
"Last-Translator: Dimitri Decrock <dimitri@fam-decrock.eu>\n"
"Language-Team: \n"
@@ -76,6 +76,7 @@ msgstr "Nieuw saldo"
#: apps/transactions/forms.py:299 apps/transactions/forms.py:479
#: apps/transactions/forms.py:724 apps/transactions/models.py:203
#: apps/transactions/models.py:378 apps/transactions/models.py:558
#: templates/insights/fragments/category_overview/index.html:9
msgid "Category"
msgstr "Categorie"
@@ -880,6 +881,7 @@ msgstr "Run met succes verwijderd"
#: apps/insights/forms.py:119 apps/insights/utils/sankey.py:36
#: apps/insights/utils/sankey.py:167
#: templates/insights/fragments/category_overview/index.html:18
msgid "Uncategorized"
msgstr "Ongecategoriseerd"
@@ -1247,6 +1249,7 @@ msgstr "Bedrijf"
#: templates/calendar_view/fragments/list.html:52
#: templates/calendar_view/fragments/list.html:54
#: templates/cotton/ui/quick_transactions_buttons.html:10
#: templates/insights/fragments/category_overview/index.html:10
#: templates/monthly_overview/fragments/monthly_summary.html:39
msgid "Income"
msgstr "Ontvangsten Transactie"
@@ -1257,6 +1260,7 @@ msgstr "Ontvangsten Transactie"
#: templates/calendar_view/fragments/list.html:56
#: templates/calendar_view/fragments/list.html:58
#: templates/cotton/ui/quick_transactions_buttons.html:18
#: templates/insights/fragments/category_overview/index.html:11
msgid "Expense"
msgstr "Uitgave Transactie"
@@ -1882,6 +1886,7 @@ msgid "Muted"
msgstr "Gedempt"
#: templates/categories/fragments/table.html:57
#: templates/insights/fragments/category_overview/index.html:67
msgid "No categories"
msgstr "Geen categorieën"
@@ -2443,6 +2448,11 @@ msgstr "Inkomsten/uitgaven per rekening"
msgid "Income/Expense by Currency"
msgstr "Inkomsten/uitgaven per Munteenheid"
#: templates/insights/fragments/category_overview/index.html:12
#: templates/monthly_overview/fragments/monthly_summary.html:167
msgid "Total"
msgstr "Totaal"
#: templates/insights/fragments/late_transactions.html:15
msgid "All good!"
msgstr "Allemaal goed!"
@@ -2498,10 +2508,16 @@ msgid "Category Explorer"
msgstr "Categorie Verkenner"
#: templates/insights/pages/index.html:102
#, fuzzy
#| msgid "Categories"
msgid "Categories Overview"
msgstr "Categorieën"
#: templates/insights/pages/index.html:109
msgid "Late Transactions"
msgstr "Betalingsachterstanden"
#: templates/insights/pages/index.html:108
#: templates/insights/pages/index.html:115
msgid "Latest Transactions"
msgstr "Laatste Verrichtingen"
@@ -2598,10 +2614,6 @@ msgstr "verwacht"
msgid "Expenses"
msgstr "Uitgaven"
#: templates/monthly_overview/fragments/monthly_summary.html:167
msgid "Total"
msgstr "Totaal"
#: templates/monthly_overview/fragments/monthly_summary.html:257
msgid "Distribution"
msgstr "Verdeling"

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-02-24 16:30-0300\n"
"POT-Creation-Date: 2025-02-27 23:32-0300\n"
"PO-Revision-Date: 2025-02-19 23:06-0300\n"
"Last-Translator: Herculino Trotta\n"
"Language-Team: \n"
@@ -76,6 +76,7 @@ msgstr "Novo saldo"
#: apps/transactions/forms.py:299 apps/transactions/forms.py:479
#: apps/transactions/forms.py:724 apps/transactions/models.py:203
#: apps/transactions/models.py:378 apps/transactions/models.py:558
#: templates/insights/fragments/category_overview/index.html:9
msgid "Category"
msgstr "Categoria"
@@ -878,6 +879,7 @@ msgstr "Importação apagada com sucesso"
#: apps/insights/forms.py:119 apps/insights/utils/sankey.py:36
#: apps/insights/utils/sankey.py:167
#: templates/insights/fragments/category_overview/index.html:18
msgid "Uncategorized"
msgstr "Sem categoria"
@@ -1244,6 +1246,7 @@ msgstr "Entidade"
#: templates/calendar_view/fragments/list.html:52
#: templates/calendar_view/fragments/list.html:54
#: templates/cotton/ui/quick_transactions_buttons.html:10
#: templates/insights/fragments/category_overview/index.html:10
#: templates/monthly_overview/fragments/monthly_summary.html:39
msgid "Income"
msgstr "Renda"
@@ -1254,6 +1257,7 @@ msgstr "Renda"
#: templates/calendar_view/fragments/list.html:56
#: templates/calendar_view/fragments/list.html:58
#: templates/cotton/ui/quick_transactions_buttons.html:18
#: templates/insights/fragments/category_overview/index.html:11
msgid "Expense"
msgstr "Despesa"
@@ -1879,6 +1883,7 @@ msgid "Muted"
msgstr "Silenciada"
#: templates/categories/fragments/table.html:57
#: templates/insights/fragments/category_overview/index.html:67
msgid "No categories"
msgstr "Nenhum categoria"
@@ -2441,6 +2446,11 @@ msgstr "Gasto/Despesa por Conta"
msgid "Income/Expense by Currency"
msgstr "Gasto/Despesa por Moeda"
#: templates/insights/fragments/category_overview/index.html:12
#: templates/monthly_overview/fragments/monthly_summary.html:167
msgid "Total"
msgstr "Total"
#: templates/insights/fragments/late_transactions.html:15
msgid "All good!"
msgstr "Tudo certo!"
@@ -2496,10 +2506,16 @@ msgid "Category Explorer"
msgstr "Explorador de Categoria"
#: templates/insights/pages/index.html:102
#, fuzzy
#| msgid "Categories"
msgid "Categories Overview"
msgstr "Categorias"
#: templates/insights/pages/index.html:109
msgid "Late Transactions"
msgstr "Transações Atrasadas"
#: templates/insights/pages/index.html:108
#: templates/insights/pages/index.html:115
msgid "Latest Transactions"
msgstr "Últimas Transações"
@@ -2596,10 +2612,6 @@ msgstr "previsto"
msgid "Expenses"
msgstr "Despesas"
#: templates/monthly_overview/fragments/monthly_summary.html:167
msgid "Total"
msgstr "Total"
#: templates/monthly_overview/fragments/monthly_summary.html:257
msgid "Distribution"
msgstr "Distribuição"

View File

@@ -0,0 +1,69 @@
{% load i18n %}
<div hx-get="{% url 'category_overview' %}" hx-trigger="updated from:window" class="show-loading" hx-swap="outerHTML" hx-include="#picker-form, #picker-type">
{% if total_table %}
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th scope="col">{% trans 'Category' %}</th>
<th scope="col">{% trans 'Income' %}</th>
<th scope="col">{% trans 'Expense' %}</th>
<th scope="col">{% trans 'Total' %}</th>
</tr>
</thead>
<tbody>
{% for category in total_table.values %}
<tr>
<th>{% if category.name %}{{ category.name }}{% else %}{% trans 'Uncategorized' %}{% endif %}</th>
<td>
{% for currency in category.currencies.values %}
{% if currency.total_income != 0 %}
<c-amount.display
:amount="currency.total_income"
:prefix="currency.currency.prefix"
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"
color="green"></c-amount.display>
{% else %}
<div>-</div>
{% endif %}
{% endfor %}
</td>
<td>
{% for currency in category.currencies.values %}
{% if currency.total_expense != 0 %}
<c-amount.display
:amount="currency.total_expense"
:prefix="currency.currency.prefix"
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"
color="red"></c-amount.display>
{% else %}
<div>-</div>
{% endif %}
{% endfor %}
</td>
<td>
{% for currency in category.currencies.values %}
{% if currency.total_final != 0 %}
<c-amount.display
:amount="currency.total_final"
:prefix="currency.currency.prefix"
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"
color="{% if currency.total_final < 0 %}red{% else %}green{% endif %}"></c-amount.display>
{% else %}
<div>-</div>
{% endif %}
{% endfor %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<c-msg.empty title="{% translate "No categories" %}"></c-msg.empty>
{% endif %}
</div>

View File

@@ -94,6 +94,13 @@
hx-indicator="#tab-content"
hx-target="#tab-content">{% trans 'Category Explorer' %}
</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_overview' %}"
hx-include="#picker-form, #picker-type"
hx-indicator="#tab-content"
hx-target="#tab-content">{% trans 'Categories Overview' %}
</button>
<hr>
<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"