mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-09-13 13:22:03 +02:00
feat(insights): add tags and entities overview
This commit is contained in:
@@ -34,6 +34,16 @@ urlpatterns = [
|
|||||||
views.category_overview,
|
views.category_overview,
|
||||||
name="category_overview",
|
name="category_overview",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"insights/tag-overview/",
|
||||||
|
views.tag_overview,
|
||||||
|
name="tag_overview",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"insights/entity-overview/",
|
||||||
|
views.entity_overview,
|
||||||
|
name="entity_overview",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"insights/late-transactions/",
|
"insights/late-transactions/",
|
||||||
views.late_transactions,
|
views.late_transactions,
|
||||||
|
|||||||
@@ -1,504 +0,0 @@
|
|||||||
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, show_entities=False
|
|
||||||
):
|
|
||||||
# Step 1: Aggregate transaction data by category and currency.
|
|
||||||
# This query calculates the total current and projected income/expense for each
|
|
||||||
# category by grouping transactions and summing up their amounts based on their
|
|
||||||
# type (income/expense) and payment status (paid/unpaid).
|
|
||||||
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")
|
|
||||||
)
|
|
||||||
|
|
||||||
# Step 2: Aggregate transaction data by tag, category, and currency.
|
|
||||||
# This is similar to the category metrics but adds tags to the grouping,
|
|
||||||
# allowing for a breakdown of totals by tag within each category. It also
|
|
||||||
# handles untagged transactions, where the 'tags' field is None.
|
|
||||||
tag_metrics = transactions_queryset.values(
|
|
||||||
"category",
|
|
||||||
"tags",
|
|
||||||
"tags__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"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Step 3: Initialize the main dictionary to structure the final results.
|
|
||||||
# The data will be organized hierarchically: category -> currency -> tags -> entities.
|
|
||||||
result = {}
|
|
||||||
|
|
||||||
# Step 4: Process the aggregated category metrics to build the initial result structure.
|
|
||||||
# This loop iterates through each category's metrics and populates the `result` dict.
|
|
||||||
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": {},
|
|
||||||
"tags": {}, # Add tags container
|
|
||||||
}
|
|
||||||
|
|
||||||
# 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,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Step 4a: Handle currency conversion for category totals if an exchange currency is defined.
|
|
||||||
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
|
|
||||||
|
|
||||||
# Step 5: Process the aggregated tag metrics and integrate them into the result structure.
|
|
||||||
for tag_metric in tag_metrics:
|
|
||||||
category_id = tag_metric["category"]
|
|
||||||
tag_id = tag_metric["tags"] # Will be None for untagged transactions
|
|
||||||
|
|
||||||
if category_id in result:
|
|
||||||
# Initialize the tag container if not exists
|
|
||||||
if "tags" not in result[category_id]:
|
|
||||||
result[category_id]["tags"] = {}
|
|
||||||
|
|
||||||
# Determine if this is a tagged or untagged transaction
|
|
||||||
tag_key = tag_id if tag_id is not None else "untagged"
|
|
||||||
tag_name = tag_metric["tags__name"] if tag_id is not None else None
|
|
||||||
|
|
||||||
if tag_key not in result[category_id]["tags"]:
|
|
||||||
result[category_id]["tags"][tag_key] = {
|
|
||||||
"name": tag_name,
|
|
||||||
"currencies": {},
|
|
||||||
"entities": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
currency_id = tag_metric["account__currency"]
|
|
||||||
|
|
||||||
# Calculate tag totals
|
|
||||||
tag_total_current = (
|
|
||||||
tag_metric["income_current"] - tag_metric["expense_current"]
|
|
||||||
)
|
|
||||||
tag_total_projected = (
|
|
||||||
tag_metric["income_projected"] - tag_metric["expense_projected"]
|
|
||||||
)
|
|
||||||
tag_total_income = (
|
|
||||||
tag_metric["income_current"] + tag_metric["income_projected"]
|
|
||||||
)
|
|
||||||
tag_total_expense = (
|
|
||||||
tag_metric["expense_current"] + tag_metric["expense_projected"]
|
|
||||||
)
|
|
||||||
tag_total_final = tag_total_current + tag_total_projected
|
|
||||||
|
|
||||||
tag_currency_data = {
|
|
||||||
"currency": {
|
|
||||||
"code": tag_metric["account__currency__code"],
|
|
||||||
"name": tag_metric["account__currency__name"],
|
|
||||||
"decimal_places": tag_metric["account__currency__decimal_places"],
|
|
||||||
"prefix": tag_metric["account__currency__prefix"],
|
|
||||||
"suffix": tag_metric["account__currency__suffix"],
|
|
||||||
},
|
|
||||||
"expense_current": tag_metric["expense_current"],
|
|
||||||
"expense_projected": tag_metric["expense_projected"],
|
|
||||||
"total_expense": tag_total_expense,
|
|
||||||
"income_current": tag_metric["income_current"],
|
|
||||||
"income_projected": tag_metric["income_projected"],
|
|
||||||
"total_income": tag_total_income,
|
|
||||||
"total_current": tag_total_current,
|
|
||||||
"total_projected": tag_total_projected,
|
|
||||||
"total_final": tag_total_final,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Step 5a: Handle currency conversion for tag totals.
|
|
||||||
if tag_metric["account__currency__exchange_currency"]:
|
|
||||||
from_currency = Currency.objects.get(id=currency_id)
|
|
||||||
exchange_currency = Currency.objects.get(
|
|
||||||
id=tag_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=tag_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:
|
|
||||||
tag_currency_data["exchanged"] = exchanged
|
|
||||||
|
|
||||||
result[category_id]["tags"][tag_key]["currencies"][
|
|
||||||
currency_id
|
|
||||||
] = tag_currency_data
|
|
||||||
|
|
||||||
# Step 6: If requested, aggregate and process entity-level data.
|
|
||||||
if show_entities:
|
|
||||||
entity_metrics = transactions_queryset.values(
|
|
||||||
"category",
|
|
||||||
"tags",
|
|
||||||
"entities",
|
|
||||||
"entities__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"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
for entity_metric in entity_metrics:
|
|
||||||
category_id = entity_metric["category"]
|
|
||||||
tag_id = entity_metric["tags"]
|
|
||||||
entity_id = entity_metric["entities"]
|
|
||||||
|
|
||||||
if category_id in result:
|
|
||||||
tag_key = tag_id if tag_id is not None else "untagged"
|
|
||||||
if tag_key in result[category_id]["tags"]:
|
|
||||||
entity_key = entity_id if entity_id is not None else "no_entity"
|
|
||||||
entity_name = (
|
|
||||||
entity_metric["entities__name"]
|
|
||||||
if entity_id is not None
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
if "entities" not in result[category_id]["tags"][tag_key]:
|
|
||||||
result[category_id]["tags"][tag_key]["entities"] = {}
|
|
||||||
|
|
||||||
if (
|
|
||||||
entity_key
|
|
||||||
not in result[category_id]["tags"][tag_key]["entities"]
|
|
||||||
):
|
|
||||||
result[category_id]["tags"][tag_key]["entities"][entity_key] = {
|
|
||||||
"name": entity_name,
|
|
||||||
"currencies": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
currency_id = entity_metric["account__currency"]
|
|
||||||
|
|
||||||
entity_total_current = (
|
|
||||||
entity_metric["income_current"]
|
|
||||||
- entity_metric["expense_current"]
|
|
||||||
)
|
|
||||||
entity_total_projected = (
|
|
||||||
entity_metric["income_projected"]
|
|
||||||
- entity_metric["expense_projected"]
|
|
||||||
)
|
|
||||||
entity_total_income = (
|
|
||||||
entity_metric["income_current"]
|
|
||||||
+ entity_metric["income_projected"]
|
|
||||||
)
|
|
||||||
entity_total_expense = (
|
|
||||||
entity_metric["expense_current"]
|
|
||||||
+ entity_metric["expense_projected"]
|
|
||||||
)
|
|
||||||
entity_total_final = entity_total_current + entity_total_projected
|
|
||||||
|
|
||||||
entity_currency_data = {
|
|
||||||
"currency": {
|
|
||||||
"code": entity_metric["account__currency__code"],
|
|
||||||
"name": entity_metric["account__currency__name"],
|
|
||||||
"decimal_places": entity_metric[
|
|
||||||
"account__currency__decimal_places"
|
|
||||||
],
|
|
||||||
"prefix": entity_metric["account__currency__prefix"],
|
|
||||||
"suffix": entity_metric["account__currency__suffix"],
|
|
||||||
},
|
|
||||||
"expense_current": entity_metric["expense_current"],
|
|
||||||
"expense_projected": entity_metric["expense_projected"],
|
|
||||||
"total_expense": entity_total_expense,
|
|
||||||
"income_current": entity_metric["income_current"],
|
|
||||||
"income_projected": entity_metric["income_projected"],
|
|
||||||
"total_income": entity_total_income,
|
|
||||||
"total_current": entity_total_current,
|
|
||||||
"total_projected": entity_total_projected,
|
|
||||||
"total_final": entity_total_final,
|
|
||||||
}
|
|
||||||
|
|
||||||
if entity_metric["account__currency__exchange_currency"]:
|
|
||||||
from_currency = Currency.objects.get(id=currency_id)
|
|
||||||
exchange_currency = Currency.objects.get(
|
|
||||||
id=entity_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=entity_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:
|
|
||||||
entity_currency_data["exchanged"] = exchanged
|
|
||||||
|
|
||||||
result[category_id]["tags"][tag_key]["entities"][entity_key][
|
|
||||||
"currencies"
|
|
||||||
][currency_id] = entity_currency_data
|
|
||||||
|
|
||||||
return result
|
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
from django.db.models import Sum, Case, When, Value
|
||||||
|
from django.db.models.functions import Coalesce
|
||||||
|
|
||||||
|
from apps.currencies.models import Currency
|
||||||
|
from apps.currencies.utils.convert import convert
|
||||||
|
from apps.transactions.models import Transaction
|
||||||
|
|
||||||
|
# Grouping levels an overview can be built from. Any ordering of these keys is a
|
||||||
|
# valid hierarchy, which is what makes the categories, tags and entities
|
||||||
|
# overviews the same view with a different level order.
|
||||||
|
LEVELS = {
|
||||||
|
"categories": {"field": "category", "name": "category__name"},
|
||||||
|
"tags": {"field": "tags", "name": "tags__name"},
|
||||||
|
"entities": {"field": "entities", "name": "entities__name"},
|
||||||
|
}
|
||||||
|
|
||||||
|
CURRENCY_FIELDS = (
|
||||||
|
"account__currency",
|
||||||
|
"account__currency__code",
|
||||||
|
"account__currency__name",
|
||||||
|
"account__currency__decimal_places",
|
||||||
|
"account__currency__prefix",
|
||||||
|
"account__currency__suffix",
|
||||||
|
"account__currency__exchange_currency",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Aggregated amounts, plus the totals derived from them. Every one of these is
|
||||||
|
# converted when the account's currency has an exchange currency.
|
||||||
|
AGGREGATED_FIELDS = (
|
||||||
|
"expense_current",
|
||||||
|
"expense_projected",
|
||||||
|
"income_current",
|
||||||
|
"income_projected",
|
||||||
|
)
|
||||||
|
TOTAL_FIELDS = (
|
||||||
|
"total_income",
|
||||||
|
"total_expense",
|
||||||
|
"total_current",
|
||||||
|
"total_projected",
|
||||||
|
"total_final",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fields picked for display, per "showing" mode. Doing it here keeps the
|
||||||
|
# templates down to one branch per cell instead of one per mode.
|
||||||
|
SHOWN_FIELDS = {
|
||||||
|
"current": ("income_current", "expense_current", "total_current"),
|
||||||
|
"projected": ("income_projected", "expense_projected", "total_projected"),
|
||||||
|
"final": ("total_income", "total_expense", "total_final"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Key used for rows where the grouping field is null (untagged transactions, for
|
||||||
|
# instance). None is already a meaningful key for the top level, so children use
|
||||||
|
# a sentinel string to keep the dictionaries JSON serializable.
|
||||||
|
NO_VALUE = "none"
|
||||||
|
|
||||||
|
|
||||||
|
def _child_key(value):
|
||||||
|
return NO_VALUE if value is None else value
|
||||||
|
|
||||||
|
|
||||||
|
def _sum_of(transaction_type, is_paid):
|
||||||
|
return Coalesce(
|
||||||
|
Sum(
|
||||||
|
Case(
|
||||||
|
When(type=transaction_type, is_paid=is_paid, then="amount"),
|
||||||
|
default=Value(0),
|
||||||
|
output_field=models.DecimalField(),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
Decimal("0"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _aggregate(transactions_queryset, group_fields, name_field):
|
||||||
|
"""Sum income/expense per currency, grouped by the given fields."""
|
||||||
|
return (
|
||||||
|
transactions_queryset.values(*group_fields, name_field, *CURRENCY_FIELDS)
|
||||||
|
.annotate(
|
||||||
|
expense_current=_sum_of(Transaction.Type.EXPENSE, is_paid=True),
|
||||||
|
expense_projected=_sum_of(Transaction.Type.EXPENSE, is_paid=False),
|
||||||
|
income_current=_sum_of(Transaction.Type.INCOME, is_paid=True),
|
||||||
|
income_projected=_sum_of(Transaction.Type.INCOME, is_paid=False),
|
||||||
|
)
|
||||||
|
.order_by(name_field)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_currency_data(metric, showing):
|
||||||
|
"""Turn one aggregated row into the currency payload the templates read."""
|
||||||
|
total_current = metric["income_current"] - metric["expense_current"]
|
||||||
|
total_projected = metric["income_projected"] - metric["expense_projected"]
|
||||||
|
|
||||||
|
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": metric["expense_current"] + metric["expense_projected"],
|
||||||
|
"income_current": metric["income_current"],
|
||||||
|
"income_projected": metric["income_projected"],
|
||||||
|
"total_income": metric["income_current"] + metric["income_projected"],
|
||||||
|
"total_current": total_current,
|
||||||
|
"total_projected": total_projected,
|
||||||
|
"total_final": total_current + total_projected,
|
||||||
|
}
|
||||||
|
|
||||||
|
income, expense, total = SHOWN_FIELDS.get(showing, SHOWN_FIELDS["final"])
|
||||||
|
currency_data["shown"] = {
|
||||||
|
"income": currency_data[income],
|
||||||
|
"expense": currency_data[expense],
|
||||||
|
"total": currency_data[total],
|
||||||
|
}
|
||||||
|
|
||||||
|
if metric["account__currency__exchange_currency"]:
|
||||||
|
from_currency = Currency.objects.get(id=metric["account__currency"])
|
||||||
|
exchange_currency = Currency.objects.get(
|
||||||
|
id=metric["account__currency__exchange_currency"]
|
||||||
|
)
|
||||||
|
|
||||||
|
exchanged = {}
|
||||||
|
for field in AGGREGATED_FIELDS + TOTAL_FIELDS:
|
||||||
|
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
|
||||||
|
|
||||||
|
return currency_data
|
||||||
|
|
||||||
|
|
||||||
|
def get_grouped_totals(
|
||||||
|
transactions_queryset, levels, showing="final", ignore_empty=False, depth=1
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Build a nested income/expense breakdown of ``transactions_queryset``.
|
||||||
|
|
||||||
|
``showing`` picks which set of amounts lands in each row's ``shown`` key:
|
||||||
|
``current`` (paid only), ``projected`` (unpaid only) or ``final`` (both).
|
||||||
|
|
||||||
|
``levels`` is an ordered sequence of keys from ``LEVELS`` describing the
|
||||||
|
hierarchy (e.g. ``("categories", "tags", "entities")``); ``depth`` says how
|
||||||
|
many of those levels to actually aggregate, so the caller only pays for the
|
||||||
|
rows it is going to show.
|
||||||
|
|
||||||
|
Returns a dict keyed by the first level's id::
|
||||||
|
|
||||||
|
{
|
||||||
|
id: {
|
||||||
|
"name": str | None,
|
||||||
|
"search_path": str,
|
||||||
|
"currencies": {currency_id: {...}},
|
||||||
|
"children": {id: {"name": ..., "currencies": ..., "children": ...}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
A null grouping value keeps ``None`` as the key at the top level and uses
|
||||||
|
``NO_VALUE`` further down; ``name`` is ``None`` in both cases so templates
|
||||||
|
can render the label that fits the level.
|
||||||
|
"""
|
||||||
|
levels = [level for level in levels if level in LEVELS][: max(depth, 1)]
|
||||||
|
if not levels:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
fields = [LEVELS[level]["field"] for level in levels]
|
||||||
|
names = [LEVELS[level]["name"] for level in levels]
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
for metric in _aggregate(transactions_queryset, fields[:1], names[0]):
|
||||||
|
if ignore_empty and all(
|
||||||
|
metric[field] == Decimal("0") for field in AGGREGATED_FIELDS
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
node = result.setdefault(
|
||||||
|
metric[fields[0]],
|
||||||
|
{
|
||||||
|
"name": metric[names[0]],
|
||||||
|
"search_path": f"{len(result)}/",
|
||||||
|
"currencies": {},
|
||||||
|
"children": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
node["currencies"][metric["account__currency"]] = _build_currency_data(
|
||||||
|
metric, showing
|
||||||
|
)
|
||||||
|
|
||||||
|
# Each extra level is aggregated on its own and grafted onto the node its
|
||||||
|
# parent ids point at, so a child of a skipped (empty) parent is dropped.
|
||||||
|
for index in range(1, len(levels)):
|
||||||
|
for metric in _aggregate(
|
||||||
|
transactions_queryset, fields[: index + 1], names[index]
|
||||||
|
):
|
||||||
|
node = result.get(metric[fields[0]])
|
||||||
|
for parent_field in fields[1:index]:
|
||||||
|
if node is None:
|
||||||
|
break
|
||||||
|
node = node["children"].get(_child_key(metric[parent_field]))
|
||||||
|
|
||||||
|
if node is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
child = node["children"].setdefault(
|
||||||
|
_child_key(metric[fields[index]]),
|
||||||
|
{
|
||||||
|
"name": metric[names[index]],
|
||||||
|
"search_path": f"{node['search_path']}{len(node['children'])}/",
|
||||||
|
"currencies": {},
|
||||||
|
"children": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
child["currencies"][metric["account__currency"]] = _build_currency_data(
|
||||||
|
metric, showing
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
+94
-44
@@ -4,7 +4,9 @@ from dateutil.relativedelta import relativedelta
|
|||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.db.models import Sum
|
from django.db.models import Sum
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views.decorators.http import require_http_methods
|
from django.views.decorators.http import require_http_methods
|
||||||
|
|
||||||
from apps.common.decorators.htmx import only_htmx
|
from apps.common.decorators.htmx import only_htmx
|
||||||
@@ -20,7 +22,7 @@ from apps.insights.utils.category_explorer import (
|
|||||||
get_category_sums_by_account,
|
get_category_sums_by_account,
|
||||||
get_category_sums_by_currency,
|
get_category_sums_by_currency,
|
||||||
)
|
)
|
||||||
from apps.insights.utils.category_overview import get_categories_totals
|
from apps.insights.utils.overview import get_grouped_totals
|
||||||
from apps.insights.utils.sankey import (
|
from apps.insights.utils.sankey import (
|
||||||
generate_sankey_data_by_account,
|
generate_sankey_data_by_account,
|
||||||
generate_sankey_data_by_currency,
|
generate_sankey_data_by_currency,
|
||||||
@@ -31,6 +33,81 @@ from apps.insights.utils.month_by_month import get_month_by_month_data
|
|||||||
from apps.transactions.models import TransactionCategory, Transaction
|
from apps.transactions.models import TransactionCategory, Transaction
|
||||||
from apps.transactions.utils.calculations import calculate_currency_totals
|
from apps.transactions.utils.calculations import calculate_currency_totals
|
||||||
|
|
||||||
|
# Labels for the grouping levels an overview can be built from. The overviews
|
||||||
|
# only differ by which level sits at the top, so everything user facing lives
|
||||||
|
# here instead of in three near identical views and templates.
|
||||||
|
OVERVIEW_LEVELS = {
|
||||||
|
"categories": {
|
||||||
|
"label": _("Categories"),
|
||||||
|
"singular": _("Category"),
|
||||||
|
"empty": _("Uncategorized"),
|
||||||
|
"empty_message": _("No categories"),
|
||||||
|
"icon": "fa-solid fa-icons",
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"label": _("Tags"),
|
||||||
|
"singular": _("Tag"),
|
||||||
|
"empty": _("Untagged"),
|
||||||
|
"empty_message": _("No tags"),
|
||||||
|
"icon": "fa-solid fa-hashtag",
|
||||||
|
},
|
||||||
|
"entities": {
|
||||||
|
"label": _("Entities"),
|
||||||
|
"singular": _("Entity"),
|
||||||
|
"empty": _("No entity"),
|
||||||
|
"empty_message": _("No entities"),
|
||||||
|
"icon": "fa-solid fa-user-group",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _render_overview(request, levels, url_name):
|
||||||
|
"""Render the overview table/chart for ``levels``, top level first."""
|
||||||
|
session_prefix = f"insights_{levels[0]}_overview"
|
||||||
|
|
||||||
|
def setting(name, default, cast=None):
|
||||||
|
key = f"{session_prefix}_{name}"
|
||||||
|
if name in request.GET:
|
||||||
|
value = cast(request.GET[name]) if cast else request.GET[name]
|
||||||
|
request.session[key] = value
|
||||||
|
return value
|
||||||
|
return request.session.get(key, default)
|
||||||
|
|
||||||
|
view_type = setting("view_type", "table")
|
||||||
|
showing = setting("showing", "final")
|
||||||
|
show_level_2 = setting("show_level_2", True, cast=lambda value: value == "on")
|
||||||
|
show_level_3 = setting("show_level_3", False, cast=lambda value: value == "on")
|
||||||
|
|
||||||
|
if show_level_2:
|
||||||
|
depth = 3 if show_level_3 else 2
|
||||||
|
else:
|
||||||
|
depth = 1
|
||||||
|
|
||||||
|
total_table = get_grouped_totals(
|
||||||
|
transactions_queryset=get_transactions(request, include_silent=True),
|
||||||
|
levels=levels,
|
||||||
|
showing=showing,
|
||||||
|
ignore_empty=False,
|
||||||
|
depth=depth,
|
||||||
|
)
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"insights/fragments/overview/index.html",
|
||||||
|
{
|
||||||
|
"total_table": total_table,
|
||||||
|
"refresh_url": reverse(url_name),
|
||||||
|
"view_type": view_type,
|
||||||
|
"showing": showing,
|
||||||
|
"show_level_2": show_level_2,
|
||||||
|
"show_level_3": show_level_3,
|
||||||
|
"level_1": OVERVIEW_LEVELS[levels[0]],
|
||||||
|
"level_2": OVERVIEW_LEVELS[levels[1]],
|
||||||
|
"level_3": OVERVIEW_LEVELS[levels[2]],
|
||||||
|
"empty_message": OVERVIEW_LEVELS[levels[0]]["empty_message"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
@@ -174,51 +251,24 @@ def category_sum_by_currency(request):
|
|||||||
@login_required
|
@login_required
|
||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
def category_overview(request):
|
def category_overview(request):
|
||||||
if "view_type" in request.GET:
|
return _render_overview(
|
||||||
view_type = request.GET["view_type"]
|
request, ("categories", "tags", "entities"), "category_overview"
|
||||||
request.session["insights_category_explorer_view_type"] = view_type
|
|
||||||
else:
|
|
||||||
view_type = request.session.get("insights_category_explorer_view_type", "table")
|
|
||||||
|
|
||||||
if "show_tags" in request.GET:
|
|
||||||
show_tags = request.GET["show_tags"] == "on"
|
|
||||||
request.session["insights_category_explorer_show_tags"] = show_tags
|
|
||||||
else:
|
|
||||||
show_tags = request.session.get("insights_category_explorer_show_tags", True)
|
|
||||||
|
|
||||||
if "show_entities" in request.GET:
|
|
||||||
show_entities = request.GET["show_entities"] == "on"
|
|
||||||
request.session["insights_category_explorer_show_entities"] = show_entities
|
|
||||||
else:
|
|
||||||
show_entities = request.session.get(
|
|
||||||
"insights_category_explorer_show_entities", False
|
|
||||||
)
|
|
||||||
|
|
||||||
if "showing" in request.GET:
|
|
||||||
showing = request.GET["showing"]
|
|
||||||
request.session["insights_category_explorer_showing"] = showing
|
|
||||||
else:
|
|
||||||
showing = request.session.get("insights_category_explorer_showing", "final")
|
|
||||||
|
|
||||||
# Get filtered transactions
|
|
||||||
transactions = get_transactions(request, include_silent=True)
|
|
||||||
|
|
||||||
total_table = get_categories_totals(
|
|
||||||
transactions_queryset=transactions,
|
|
||||||
ignore_empty=False,
|
|
||||||
show_entities=show_entities,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return render(
|
|
||||||
request,
|
@only_htmx
|
||||||
"insights/fragments/category_overview/index.html",
|
@login_required
|
||||||
{
|
@require_http_methods(["GET"])
|
||||||
"total_table": total_table,
|
def tag_overview(request):
|
||||||
"view_type": view_type,
|
return _render_overview(request, ("tags", "categories", "entities"), "tag_overview")
|
||||||
"show_tags": show_tags,
|
|
||||||
"show_entities": show_entities,
|
|
||||||
"showing": showing,
|
@only_htmx
|
||||||
},
|
@login_required
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def entity_overview(request):
|
||||||
|
return _render_overview(
|
||||||
|
request, ("entities", "categories", "tags"), "entity_overview"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,556 +0,0 @@
|
|||||||
{% 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, #view-type, #show-tags, #showing, #show-entities">
|
|
||||||
<div class="h-full text-center mb-4">
|
|
||||||
<div class="tabs tabs-box mx-auto w-fit" role="group" id="view-type" _="on change trigger updated">
|
|
||||||
<label class="tab">
|
|
||||||
<input type="radio"
|
|
||||||
name="view_type"
|
|
||||||
id="table-view"
|
|
||||||
autocomplete="off"
|
|
||||||
value="table"
|
|
||||||
aria-label="{% trans 'Table' %}"
|
|
||||||
{% if view_type == "table" %}checked{% endif %}>
|
|
||||||
<i class="fa-solid fa-table fa-fw me-2"></i>
|
|
||||||
{% trans 'Table' %}
|
|
||||||
</label>
|
|
||||||
<label class="tab">
|
|
||||||
<input type="radio"
|
|
||||||
name="view_type"
|
|
||||||
id="bars-view"
|
|
||||||
autocomplete="off"
|
|
||||||
value="bars"
|
|
||||||
aria-label="{% trans 'Bars' %}"
|
|
||||||
{% if view_type == "bars" %}checked{% endif %}>
|
|
||||||
<i class="fa-solid fa-chart-bar fa-fw me-2"></i>
|
|
||||||
{% trans 'Bars' %}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="my-3 flex flex-col gap-3 md:flex-row justify-between">
|
|
||||||
<div class="flex gap-4">
|
|
||||||
{% if view_type == 'table' %}
|
|
||||||
<div id="show-tags">
|
|
||||||
<label class="label">
|
|
||||||
<input type="hidden" name="show_tags" value="off">
|
|
||||||
<input type="checkbox" class="toggle toggle-primary toggle-sm" id="show-tags-switch" name="show_tags"
|
|
||||||
_="on change trigger updated" {% if show_tags %}checked{% endif %}>
|
|
||||||
<span>
|
|
||||||
{% trans 'Tags' %}
|
|
||||||
</span>
|
|
||||||
<c-ui.help-icon
|
|
||||||
content="{% trans 'Transaction amounts associated with multiple tags will be counted once for each tag' %}"
|
|
||||||
icon="fa-solid fa-circle-exclamation"></c-ui.help-icon>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div id="show-entities" class="{% if not show_tags %}hidden{% endif %}">
|
|
||||||
<label class="label">
|
|
||||||
<input type="hidden" name="show_entities" value="off">
|
|
||||||
<input type="checkbox" class="toggle toggle-primary toggle-sm" id="show-entities-switch"
|
|
||||||
name="show_entities"
|
|
||||||
_="on change trigger updated" {% if show_entities %}checked{% endif %}>
|
|
||||||
<span>
|
|
||||||
{% trans 'Entities' %}
|
|
||||||
</span>
|
|
||||||
<c-ui.help-icon
|
|
||||||
content="{% trans 'Transaction amounts associated with multiple tags will be counted once for each tag' %}"
|
|
||||||
icon="fa-solid fa-circle-exclamation"></c-ui.help-icon>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="join" role="group" id="showing" _="on change trigger updated">
|
|
||||||
<input type="radio" class="join-item btn btn-outline btn-primary btn-sm" name="showing" id="showing-projected"
|
|
||||||
autocomplete="off" aria-label="{% trans 'Projected' %}"
|
|
||||||
value="projected" {% if showing == 'projected' %}checked{% endif %}>
|
|
||||||
|
|
||||||
<input type="radio" class="join-item btn btn-outline btn-primary btn-sm" name="showing" id="showing-current"
|
|
||||||
autocomplete="off" value="current" aria-label="{% trans 'Current' %}"
|
|
||||||
{% if showing == 'current' %}checked{% endif %}>
|
|
||||||
|
|
||||||
<input type="radio" class="join-item btn btn-outline btn-primary btn-sm" name="showing" id="showing-final"
|
|
||||||
autocomplete="off" value="final" aria-label="{% trans 'Final total' %}"
|
|
||||||
{% if showing == 'final' %}checked{% endif %}>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% if total_table %}
|
|
||||||
{% if view_type == "table" %}
|
|
||||||
<div class="card bg-base-100 card-border">
|
|
||||||
<div class="card-body">
|
|
||||||
<c-config.search></c-config.search>
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="table">
|
|
||||||
<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 %}
|
|
||||||
{# Category row #}
|
|
||||||
<tr class="font-semibold" data-search-path="{{ forloop.counter0 }}/">
|
|
||||||
<th class="text-nowrap">{% if category.name %}{{ category.name }}{% else %}{% trans 'Uncategorized' %}{% endif %}</th>
|
|
||||||
<td class="text-nowrap">
|
|
||||||
{% for currency in category.currencies.values %}
|
|
||||||
{% if showing == 'current' and currency.income_current != 0 %}
|
|
||||||
<c-amount.display
|
|
||||||
:amount="currency.income_current"
|
|
||||||
:prefix="currency.currency.prefix"
|
|
||||||
:suffix="currency.currency.suffix"
|
|
||||||
:decimal_places="currency.currency.decimal_places"
|
|
||||||
color="green"></c-amount.display>
|
|
||||||
{% elif showing == 'projected' and currency.income_projected != 0 %}
|
|
||||||
<c-amount.display
|
|
||||||
:amount="currency.income_projected"
|
|
||||||
:prefix="currency.currency.prefix"
|
|
||||||
:suffix="currency.currency.suffix"
|
|
||||||
:decimal_places="currency.currency.decimal_places"
|
|
||||||
color="green"></c-amount.display>
|
|
||||||
{% elif showing == 'final' and 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 class="text-nowrap">
|
|
||||||
{% for currency in category.currencies.values %}
|
|
||||||
{% if showing == 'current' and currency.expense_current != 0 %}
|
|
||||||
<c-amount.display
|
|
||||||
:amount="currency.expense_current"
|
|
||||||
:prefix="currency.currency.prefix"
|
|
||||||
:suffix="currency.currency.suffix"
|
|
||||||
:decimal_places="currency.currency.decimal_places"
|
|
||||||
color="red"></c-amount.display>
|
|
||||||
{% elif showing == 'projected' and currency.expense_projected != 0 %}
|
|
||||||
<c-amount.display
|
|
||||||
:amount="currency.expense_projected"
|
|
||||||
:prefix="currency.currency.prefix"
|
|
||||||
:suffix="currency.currency.suffix"
|
|
||||||
:decimal_places="currency.currency.decimal_places"
|
|
||||||
color="red"></c-amount.display>
|
|
||||||
{% elif showing == 'final' and 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 class="text-nowrap">
|
|
||||||
{% for currency in category.currencies.values %}
|
|
||||||
{% if showing == 'current' and currency.total_current != 0 %}
|
|
||||||
<c-amount.display
|
|
||||||
:amount="currency.total_current"
|
|
||||||
: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>
|
|
||||||
{% elif showing == 'projected' and currency.total_projected != 0 %}
|
|
||||||
<c-amount.display
|
|
||||||
:amount="currency.total_projected"
|
|
||||||
: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>
|
|
||||||
{% elif showing == 'final' and 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>
|
|
||||||
|
|
||||||
<!-- Tag rows -->
|
|
||||||
{% if show_tags %}
|
|
||||||
{% for tag_id, tag in category.tags.items %}
|
|
||||||
{% if tag.name or not tag.name and category.tags.values|length > 1 %}
|
|
||||||
<tr class="bg-base-200"
|
|
||||||
data-search-path="{{ forloop.parentloop.counter0 }}/{{ forloop.counter0 }}/">
|
|
||||||
<td class="ps-6 text-nowrap">
|
|
||||||
<i class="fa-solid fa-hashtag fa-fw me-2 text-base-content/60"></i>{% if tag.name %}{{ tag.name }}{% else %}{% trans 'Untagged' %}{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="text-nowrap">
|
|
||||||
{% for currency in tag.currencies.values %}
|
|
||||||
{% if showing == 'current' and currency.income_current != 0 %}
|
|
||||||
<c-amount.display
|
|
||||||
:amount="currency.income_current"
|
|
||||||
:prefix="currency.currency.prefix"
|
|
||||||
:suffix="currency.currency.suffix"
|
|
||||||
:decimal_places="currency.currency.decimal_places"
|
|
||||||
color="green"></c-amount.display>
|
|
||||||
{% elif showing == 'projected' and currency.income_projected != 0 %}
|
|
||||||
<c-amount.display
|
|
||||||
:amount="currency.income_projected"
|
|
||||||
:prefix="currency.currency.prefix"
|
|
||||||
:suffix="currency.currency.suffix"
|
|
||||||
:decimal_places="currency.currency.decimal_places"
|
|
||||||
color="green"></c-amount.display>
|
|
||||||
{% elif showing == 'final' and 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 class="text-nowrap">
|
|
||||||
{% for currency in tag.currencies.values %}
|
|
||||||
{% if showing == 'current' and currency.expense_current != 0 %}
|
|
||||||
<c-amount.display
|
|
||||||
:amount="currency.expense_current"
|
|
||||||
:prefix="currency.currency.prefix"
|
|
||||||
:suffix="currency.currency.suffix"
|
|
||||||
:decimal_places="currency.currency.decimal_places"
|
|
||||||
color="red"></c-amount.display>
|
|
||||||
{% elif showing == 'projected' and currency.expense_projected != 0 %}
|
|
||||||
<c-amount.display
|
|
||||||
:amount="currency.expense_projected"
|
|
||||||
:prefix="currency.currency.prefix"
|
|
||||||
:suffix="currency.currency.suffix"
|
|
||||||
:decimal_places="currency.currency.decimal_places"
|
|
||||||
color="red"></c-amount.display>
|
|
||||||
{% elif showing == 'final' and 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 class="text-nowrap">
|
|
||||||
{% for currency in tag.currencies.values %}
|
|
||||||
{% if showing == 'current' and currency.total_current != 0 %}
|
|
||||||
<c-amount.display
|
|
||||||
:amount="currency.total_current"
|
|
||||||
: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>
|
|
||||||
{% elif showing == 'projected' and currency.total_projected != 0 %}
|
|
||||||
<c-amount.display
|
|
||||||
:amount="currency.total_projected"
|
|
||||||
: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>
|
|
||||||
{% elif showing == 'final' and 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>
|
|
||||||
{# Entity rows #}
|
|
||||||
{% if show_entities %}
|
|
||||||
{% for entity_id, entity in tag.entities.items %}
|
|
||||||
{% if entity.name or not entity.name and tag.entities.values|length > 1 %}
|
|
||||||
<tr class="bg-base-300"
|
|
||||||
data-search-path="{{ forloop.parentloop.parentloop.counter0 }}/{{ forloop.parentloop.counter0 }}/{{ forloop.counter0 }}/">
|
|
||||||
<td class="ps-10 text-nowrap">
|
|
||||||
<i class="fa-solid fa-user-group fa-fw me-2 text-base-content/60"></i>{% if entity.name %}{{ entity.name }}{% else %}{% trans 'No entity' %}{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="text-nowrap">
|
|
||||||
{% for currency in entity.currencies.values %}
|
|
||||||
{% if showing == 'current' and currency.income_current != 0 %}
|
|
||||||
<c-amount.display
|
|
||||||
:amount="currency.income_current"
|
|
||||||
:prefix="currency.currency.prefix"
|
|
||||||
:suffix="currency.currency.suffix"
|
|
||||||
:decimal_places="currency.currency.decimal_places"
|
|
||||||
color="green"></c-amount.display>
|
|
||||||
{% elif showing == 'projected' and currency.income_projected != 0 %}
|
|
||||||
<c-amount.display
|
|
||||||
:amount="currency.income_projected"
|
|
||||||
:prefix="currency.currency.prefix"
|
|
||||||
:suffix="currency.currency.suffix"
|
|
||||||
:decimal_places="currency.currency.decimal_places"
|
|
||||||
color="green"></c-amount.display>
|
|
||||||
{% elif showing == 'final' and 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 class="text-nowrap">
|
|
||||||
{% for currency in entity.currencies.values %}
|
|
||||||
{% if showing == 'current' and currency.expense_current != 0 %}
|
|
||||||
<c-amount.display
|
|
||||||
:amount="currency.expense_current"
|
|
||||||
:prefix="currency.currency.prefix"
|
|
||||||
:suffix="currency.currency.suffix"
|
|
||||||
:decimal_places="currency.currency.decimal_places"
|
|
||||||
color="red"></c-amount.display>
|
|
||||||
{% elif showing == 'projected' and currency.expense_projected != 0 %}
|
|
||||||
<c-amount.display
|
|
||||||
:amount="currency.expense_projected"
|
|
||||||
:prefix="currency.currency.prefix"
|
|
||||||
:suffix="currency.currency.suffix"
|
|
||||||
:decimal_places="currency.currency.decimal_places"
|
|
||||||
color="red"></c-amount.display>
|
|
||||||
{% elif showing == 'final' and 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 class="text-nowrap">
|
|
||||||
{% for currency in entity.currencies.values %}
|
|
||||||
{% if showing == 'current' and currency.total_current != 0 %}
|
|
||||||
<c-amount.display
|
|
||||||
:amount="currency.total_current"
|
|
||||||
: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>
|
|
||||||
{% elif showing == 'projected' and currency.total_projected != 0 %}
|
|
||||||
<c-amount.display
|
|
||||||
:amount="currency.total_projected"
|
|
||||||
: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>
|
|
||||||
{% elif showing == 'final' and 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>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
{% elif view_type == "bars" %}
|
|
||||||
<div class="card bg-base-100 card-border">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="chart-container relative h-[75vh] w-full" _="init call setupChart() end">
|
|
||||||
<canvas id="categoryChart"></canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{ total_table|json_script:"categoryOverviewData" }}
|
|
||||||
{{ showing|json_script:"showingString" }}
|
|
||||||
|
|
||||||
<script>
|
|
||||||
function setupChart() {
|
|
||||||
var rawData = JSON.parse(document.getElementById('categoryOverviewData').textContent);
|
|
||||||
var showing_string = JSON.parse(document.getElementById('showingString').textContent);
|
|
||||||
|
|
||||||
// --- Dynamic Data Processing ---
|
|
||||||
var categories = [];
|
|
||||||
var currencyDetails = {}; // Stores details like { BRL: {code: 'BRL', name: 'Real', ...}, ... }
|
|
||||||
var currencyData = {}; // Stores data arrays like { BRL: [val1, null, val3,...], ... }
|
|
||||||
|
|
||||||
// Pass 1: Collect categories and currency details
|
|
||||||
Object.values(rawData).forEach(cat => {
|
|
||||||
var categoryName = cat.name === null ? "{% trans 'Uncategorized' %}" : cat.name;
|
|
||||||
if (!categories.includes(categoryName)) {
|
|
||||||
categories.push(categoryName);
|
|
||||||
}
|
|
||||||
if (cat.currencies) {
|
|
||||||
Object.values(cat.currencies).forEach(curr => {
|
|
||||||
var details = curr.currency;
|
|
||||||
if (details && details.code && !currencyDetails[details.code]) {
|
|
||||||
var decimals = parseInt(details.decimal_places, 10);
|
|
||||||
currencyDetails[details.code] = {
|
|
||||||
code: details.code,
|
|
||||||
name: details.name || details.code,
|
|
||||||
prefix: details.prefix || '',
|
|
||||||
suffix: details.suffix || '',
|
|
||||||
// Ensure decimal_places is a non-negative integer
|
|
||||||
decimal_places: !isNaN(decimals) && decimals >= 0 ? decimals : 2
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize data structure for each currency with nulls
|
|
||||||
Object.keys(currencyDetails).forEach(code => {
|
|
||||||
currencyData[code] = new Array(categories.length).fill(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pass 2: Populate data arrays (store all valid numbers now)
|
|
||||||
Object.values(rawData).forEach(cat => {
|
|
||||||
var categoryName = cat.name === null ? "{% trans 'Uncategorized' %}" : cat.name;
|
|
||||||
var catIndex = categories.indexOf(categoryName);
|
|
||||||
if (catIndex === -1) return;
|
|
||||||
|
|
||||||
if (cat.currencies) {
|
|
||||||
Object.values(cat.currencies).forEach(curr => {
|
|
||||||
var code = curr.currency?.code;
|
|
||||||
if (code && currencyData[code]) {
|
|
||||||
if (showing_string == 'current') {
|
|
||||||
var value = parseFloat(curr.total_current);
|
|
||||||
} else if (showing_string == 'projected') {
|
|
||||||
var value = parseFloat(curr.total_projected);
|
|
||||||
} else {
|
|
||||||
var value = parseFloat(curr.total_final);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store the number if it's valid, otherwise keep null
|
|
||||||
currencyData[code][catIndex] = !isNaN(value) ? value : null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Dynamic Chart Configuration ---
|
|
||||||
var datasets = Object.keys(currencyDetails).map((code, index) => {
|
|
||||||
return {
|
|
||||||
label: currencyDetails[code].name, // Use currency name for the legend label
|
|
||||||
data: currencyData[code],
|
|
||||||
currencyCode: code, // Store code for easy lookup in tooltip
|
|
||||||
borderWidth: 1
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
new Chart(document.getElementById('categoryChart'),
|
|
||||||
{
|
|
||||||
type: 'bar',
|
|
||||||
data: {
|
|
||||||
labels: categories,
|
|
||||||
datasets: datasets
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
indexAxis: 'y',
|
|
||||||
responsive: true,
|
|
||||||
interaction: {
|
|
||||||
intersect: false,
|
|
||||||
mode: 'nearest',
|
|
||||||
axis: "y"
|
|
||||||
},
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
plugins: {
|
|
||||||
title: {
|
|
||||||
display: false
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
callbacks: {
|
|
||||||
label: function (context) {
|
|
||||||
const dataset = context.dataset;
|
|
||||||
const currencyCode = dataset.currencyCode;
|
|
||||||
const details = currencyDetails[currencyCode];
|
|
||||||
const value = context.parsed.x; // Use 'x' because indexAxis is 'y'
|
|
||||||
|
|
||||||
if (value === null || value === undefined || !details) {
|
|
||||||
// Display the category name if the value is null/undefined
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let formattedValue = '';
|
|
||||||
try {
|
|
||||||
// Use Intl.NumberFormat for ALL values, configured with locale and exact decimal places
|
|
||||||
formattedValue = new Intl.NumberFormat(undefined, {
|
|
||||||
minimumFractionDigits: details.decimal_places,
|
|
||||||
maximumFractionDigits: details.decimal_places,
|
|
||||||
// Do NOT use style: 'currency' here, as we add prefix/suffix manually
|
|
||||||
}).format(value);
|
|
||||||
} catch (e) {
|
|
||||||
formattedValue = value.toFixed(details.decimal_places);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return label with currency name and formatted value including prefix/suffix
|
|
||||||
return `${details.prefix}${formattedValue}${details.suffix}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
legend: {
|
|
||||||
position: 'top',
|
|
||||||
}
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
x: {
|
|
||||||
stacked: true,
|
|
||||||
type: 'linear',
|
|
||||||
title: {
|
|
||||||
display: true,
|
|
||||||
text: '{% trans 'Final Total' %}'
|
|
||||||
},
|
|
||||||
ticks: {
|
|
||||||
// Format ticks using the detected locale
|
|
||||||
callback: function (value, index, ticks) {
|
|
||||||
return value.toLocaleString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
y: {
|
|
||||||
stacked: true,
|
|
||||||
title: {
|
|
||||||
display: false,
|
|
||||||
text: '{% trans 'Category' %}'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
|
||||||
<c-msg.empty title="{% translate "No categories" %}"></c-msg.empty>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
{% comment %}
|
||||||
|
One overview row. Expects: node, indent, icon, empty_label, row_class,
|
||||||
|
search_path and, for the top level, is_root.
|
||||||
|
{% endcomment %}
|
||||||
|
<tr class="{{ row_class }}" data-search-path="{{ search_path }}">
|
||||||
|
{% if is_root %}
|
||||||
|
<th class="text-nowrap">{% if node.name %}{{ node.name }}{% else %}{{ empty_label }}{% endif %}</th>
|
||||||
|
{% else %}
|
||||||
|
<td class="{{ indent }} text-nowrap">
|
||||||
|
<i class="{{ icon }} fa-fw me-2 text-base-content/60"></i>{% if node.name %}{{ node.name }}{% else %}{{ empty_label }}{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
<td class="text-nowrap">
|
||||||
|
{% for currency in node.currencies.values %}
|
||||||
|
{% if currency.shown.income != 0 %}
|
||||||
|
<c-amount.display
|
||||||
|
:amount="currency.shown.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 class="text-nowrap">
|
||||||
|
{% for currency in node.currencies.values %}
|
||||||
|
{% if currency.shown.expense != 0 %}
|
||||||
|
<c-amount.display
|
||||||
|
:amount="currency.shown.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 class="text-nowrap">
|
||||||
|
{% for currency in node.currencies.values %}
|
||||||
|
{% if currency.shown.total != 0 %}
|
||||||
|
<c-amount.display
|
||||||
|
:amount="currency.shown.total"
|
||||||
|
: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>
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
<div hx-get="{{ refresh_url }}" hx-trigger="updated from:window" class="show-loading" hx-swap="outerHTML"
|
||||||
|
hx-include="#picker-form, #picker-type, #view-type, #show-level-2, #showing, #show-level-3">
|
||||||
|
<div class="h-full text-center mb-4">
|
||||||
|
<div class="tabs tabs-box mx-auto w-fit" role="group" id="view-type" _="on change trigger updated">
|
||||||
|
<label class="tab">
|
||||||
|
<input type="radio"
|
||||||
|
name="view_type"
|
||||||
|
id="table-view"
|
||||||
|
autocomplete="off"
|
||||||
|
value="table"
|
||||||
|
aria-label="{% trans 'Table' %}"
|
||||||
|
{% if view_type == "table" %}checked{% endif %}>
|
||||||
|
<i class="fa-solid fa-table fa-fw me-2"></i>
|
||||||
|
{% trans 'Table' %}
|
||||||
|
</label>
|
||||||
|
<label class="tab">
|
||||||
|
<input type="radio"
|
||||||
|
name="view_type"
|
||||||
|
id="bars-view"
|
||||||
|
autocomplete="off"
|
||||||
|
value="bars"
|
||||||
|
aria-label="{% trans 'Bars' %}"
|
||||||
|
{% if view_type == "bars" %}checked{% endif %}>
|
||||||
|
<i class="fa-solid fa-chart-bar fa-fw me-2"></i>
|
||||||
|
{% trans 'Bars' %}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="my-3 flex flex-col gap-3 md:flex-row justify-between">
|
||||||
|
<div class="flex gap-4">
|
||||||
|
{% if view_type == 'table' %}
|
||||||
|
<div id="show-level-2">
|
||||||
|
<label class="label">
|
||||||
|
<input type="hidden" name="show_level_2" value="off">
|
||||||
|
<input type="checkbox" class="toggle toggle-primary toggle-sm" id="show-level-2-switch" name="show_level_2"
|
||||||
|
_="on change trigger updated" {% if show_level_2 %}checked{% endif %}>
|
||||||
|
<span>
|
||||||
|
{{ level_2.label }}
|
||||||
|
</span>
|
||||||
|
<c-ui.help-icon
|
||||||
|
content="{% trans 'Transaction amounts associated with multiple tags or entities will be counted once for each one of them' %}"
|
||||||
|
icon="fa-solid fa-circle-exclamation"></c-ui.help-icon>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="show-level-3" class="{% if not show_level_2 %}hidden{% endif %}">
|
||||||
|
<label class="label">
|
||||||
|
<input type="hidden" name="show_level_3" value="off">
|
||||||
|
<input type="checkbox" class="toggle toggle-primary toggle-sm" id="show-level-3-switch"
|
||||||
|
name="show_level_3"
|
||||||
|
_="on change trigger updated" {% if show_level_3 %}checked{% endif %}>
|
||||||
|
<span>
|
||||||
|
{{ level_3.label }}
|
||||||
|
</span>
|
||||||
|
<c-ui.help-icon
|
||||||
|
content="{% trans 'Transaction amounts associated with multiple tags or entities will be counted once for each one of them' %}"
|
||||||
|
icon="fa-solid fa-circle-exclamation"></c-ui.help-icon>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="join" role="group" id="showing" _="on change trigger updated">
|
||||||
|
<input type="radio" class="join-item btn btn-outline btn-primary btn-sm" name="showing" id="showing-projected"
|
||||||
|
autocomplete="off" aria-label="{% trans 'Projected' %}"
|
||||||
|
value="projected" {% if showing == 'projected' %}checked{% endif %}>
|
||||||
|
|
||||||
|
<input type="radio" class="join-item btn btn-outline btn-primary btn-sm" name="showing" id="showing-current"
|
||||||
|
autocomplete="off" value="current" aria-label="{% trans 'Current' %}"
|
||||||
|
{% if showing == 'current' %}checked{% endif %}>
|
||||||
|
|
||||||
|
<input type="radio" class="join-item btn btn-outline btn-primary btn-sm" name="showing" id="showing-final"
|
||||||
|
autocomplete="off" value="final" aria-label="{% trans 'Final total' %}"
|
||||||
|
{% if showing == 'final' %}checked{% endif %}>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if total_table %}
|
||||||
|
{% if view_type == "table" %}
|
||||||
|
<div class="card bg-base-100 card-border">
|
||||||
|
<div class="card-body">
|
||||||
|
<c-config.search></c-config.search>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">{{ level_1.singular }}</th>
|
||||||
|
<th scope="col">{% trans 'Income' %}</th>
|
||||||
|
<th scope="col">{% trans 'Expense' %}</th>
|
||||||
|
<th scope="col">{% trans 'Total' %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in total_table.values %}
|
||||||
|
{# Top level row #}
|
||||||
|
{% include "insights/fragments/overview/_row.html" with node=item is_root=True row_class="font-semibold" empty_label=level_1.empty search_path=item.search_path %}
|
||||||
|
|
||||||
|
{# Second level rows #}
|
||||||
|
{% if show_level_2 %}
|
||||||
|
{% for child in item.children.values %}
|
||||||
|
{% if child.name or item.children.values|length > 1 %}
|
||||||
|
{% include "insights/fragments/overview/_row.html" with node=child row_class="bg-base-200" indent="ps-6" icon=level_2.icon empty_label=level_2.empty search_path=child.search_path %}
|
||||||
|
|
||||||
|
{# Third level rows #}
|
||||||
|
{% if show_level_3 %}
|
||||||
|
{% for grandchild in child.children.values %}
|
||||||
|
{% if grandchild.name or child.children.values|length > 1 %}
|
||||||
|
{% include "insights/fragments/overview/_row.html" with node=grandchild row_class="bg-base-300" indent="ps-10" icon=level_3.icon empty_label=level_3.empty search_path=grandchild.search_path %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% elif view_type == "bars" %}
|
||||||
|
<div class="card bg-base-100 card-border">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="chart-container relative h-[75vh] w-full" _="init call setupChart() end">
|
||||||
|
<canvas id="overviewChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ total_table|json_script:"overviewData" }}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function setupChart() {
|
||||||
|
var rawData = JSON.parse(document.getElementById('overviewData').textContent);
|
||||||
|
|
||||||
|
// --- Dynamic Data Processing ---
|
||||||
|
var items = [];
|
||||||
|
var currencyDetails = {}; // Stores details like { BRL: {code: 'BRL', name: 'Real', ...}, ... }
|
||||||
|
var currencyData = {}; // Stores data arrays like { BRL: [val1, null, val3,...], ... }
|
||||||
|
|
||||||
|
// Pass 1: Collect items and currency details
|
||||||
|
Object.values(rawData).forEach(item => {
|
||||||
|
var itemName = item.name === null ? "{{ level_1.empty|escapejs }}" : item.name;
|
||||||
|
if (!items.includes(itemName)) {
|
||||||
|
items.push(itemName);
|
||||||
|
}
|
||||||
|
if (item.currencies) {
|
||||||
|
Object.values(item.currencies).forEach(curr => {
|
||||||
|
var details = curr.currency;
|
||||||
|
if (details && details.code && !currencyDetails[details.code]) {
|
||||||
|
var decimals = parseInt(details.decimal_places, 10);
|
||||||
|
currencyDetails[details.code] = {
|
||||||
|
code: details.code,
|
||||||
|
name: details.name || details.code,
|
||||||
|
prefix: details.prefix || '',
|
||||||
|
suffix: details.suffix || '',
|
||||||
|
// Ensure decimal_places is a non-negative integer
|
||||||
|
decimal_places: !isNaN(decimals) && decimals >= 0 ? decimals : 2
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize data structure for each currency with nulls
|
||||||
|
Object.keys(currencyDetails).forEach(code => {
|
||||||
|
currencyData[code] = new Array(items.length).fill(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pass 2: Populate data arrays (store all valid numbers now)
|
||||||
|
Object.values(rawData).forEach(item => {
|
||||||
|
var itemName = item.name === null ? "{{ level_1.empty|escapejs }}" : item.name;
|
||||||
|
var itemIndex = items.indexOf(itemName);
|
||||||
|
if (itemIndex === -1) return;
|
||||||
|
|
||||||
|
if (item.currencies) {
|
||||||
|
Object.values(item.currencies).forEach(curr => {
|
||||||
|
var code = curr.currency?.code;
|
||||||
|
if (code && currencyData[code]) {
|
||||||
|
var value = parseFloat(curr.shown.total);
|
||||||
|
|
||||||
|
// Store the number if it's valid, otherwise keep null
|
||||||
|
currencyData[code][itemIndex] = !isNaN(value) ? value : null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Dynamic Chart Configuration ---
|
||||||
|
var datasets = Object.keys(currencyDetails).map((code, index) => {
|
||||||
|
return {
|
||||||
|
label: currencyDetails[code].name, // Use currency name for the legend label
|
||||||
|
data: currencyData[code],
|
||||||
|
currencyCode: code, // Store code for easy lookup in tooltip
|
||||||
|
borderWidth: 1
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
new Chart(document.getElementById('overviewChart'),
|
||||||
|
{
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: items,
|
||||||
|
datasets: datasets
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
indexAxis: 'y',
|
||||||
|
responsive: true,
|
||||||
|
interaction: {
|
||||||
|
intersect: false,
|
||||||
|
mode: 'nearest',
|
||||||
|
axis: "y"
|
||||||
|
},
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
title: {
|
||||||
|
display: false
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: function (context) {
|
||||||
|
const dataset = context.dataset;
|
||||||
|
const currencyCode = dataset.currencyCode;
|
||||||
|
const details = currencyDetails[currencyCode];
|
||||||
|
const value = context.parsed.x; // Use 'x' because indexAxis is 'y'
|
||||||
|
|
||||||
|
if (value === null || value === undefined || !details) {
|
||||||
|
// Display the item name if the value is null/undefined
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let formattedValue = '';
|
||||||
|
try {
|
||||||
|
// Use Intl.NumberFormat for ALL values, configured with locale and exact decimal places
|
||||||
|
formattedValue = new Intl.NumberFormat(undefined, {
|
||||||
|
minimumFractionDigits: details.decimal_places,
|
||||||
|
maximumFractionDigits: details.decimal_places,
|
||||||
|
// Do NOT use style: 'currency' here, as we add prefix/suffix manually
|
||||||
|
}).format(value);
|
||||||
|
} catch (e) {
|
||||||
|
formattedValue = value.toFixed(details.decimal_places);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return label with currency name and formatted value including prefix/suffix
|
||||||
|
return `${details.prefix}${formattedValue}${details.suffix}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
position: 'top',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
stacked: true,
|
||||||
|
type: 'linear',
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: '{% trans 'Total' %}'
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
// Format ticks using the detected locale
|
||||||
|
callback: function (value, index, ticks) {
|
||||||
|
return value.toLocaleString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
stacked: true,
|
||||||
|
title: {
|
||||||
|
display: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<c-msg.empty title="{{ empty_message }}"></c-msg.empty>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
@@ -93,6 +93,16 @@
|
|||||||
hx-get="{% url 'category_overview' %}"
|
hx-get="{% url 'category_overview' %}"
|
||||||
>{% trans 'Categories Overview' %}
|
>{% trans 'Categories Overview' %}
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-ghost btn-free justify-start text-start" data-bs-target="#v-pills-content"
|
||||||
|
type="button" role="tab" aria-controls="v-pills-content" aria-selected="false"
|
||||||
|
hx-get="{% url 'tag_overview' %}"
|
||||||
|
>{% trans 'Tags Overview' %}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-ghost btn-free justify-start text-start" data-bs-target="#v-pills-content"
|
||||||
|
type="button" role="tab" aria-controls="v-pills-content" aria-selected="false"
|
||||||
|
hx-get="{% url 'entity_overview' %}"
|
||||||
|
>{% trans 'Entities Overview' %}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user