mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-09-13 21:32:08 +02:00
Merge pull request #602
feat(insights): replace fixed overviews with a single arrangeable overview
This commit is contained in:
@@ -30,19 +30,14 @@ urlpatterns = [
|
|||||||
name="category_sum_by_currency",
|
name="category_sum_by_currency",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"insights/category-overview/",
|
"insights/overview/",
|
||||||
views.category_overview,
|
views.overview,
|
||||||
name="category_overview",
|
name="insights_overview",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"insights/tag-overview/",
|
"insights/overview/results/",
|
||||||
views.tag_overview,
|
views.overview_results,
|
||||||
name="tag_overview",
|
name="insights_overview_results",
|
||||||
),
|
|
||||||
path(
|
|
||||||
"insights/entity-overview/",
|
|
||||||
views.entity_overview,
|
|
||||||
name="entity_overview",
|
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"insights/late-transactions/",
|
"insights/late-transactions/",
|
||||||
|
|||||||
@@ -8,15 +8,16 @@ from apps.currencies.models import Currency
|
|||||||
from apps.currencies.utils.convert import convert
|
from apps.currencies.utils.convert import convert
|
||||||
from apps.transactions.models import Transaction
|
from apps.transactions.models import Transaction
|
||||||
|
|
||||||
# Grouping levels an overview can be built from. Any ordering of these keys is a
|
# Grouping levels the overview can be built from. Any ordering of these keys is
|
||||||
# valid hierarchy, which is what makes the categories, tags and entities
|
# a valid hierarchy, which is what lets the user arrange the chain freely.
|
||||||
# overviews the same view with a different level order.
|
|
||||||
LEVELS = {
|
LEVELS = {
|
||||||
"categories": {"field": "category", "name": "category__name"},
|
"categories": {"field": "category", "name": "category__name"},
|
||||||
"tags": {"field": "tags", "name": "tags__name"},
|
"tags": {"field": "tags", "name": "tags__name"},
|
||||||
"entities": {"field": "entities", "name": "entities__name"},
|
"entities": {"field": "entities", "name": "entities__name"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LEVEL_KEYS = tuple(LEVELS)
|
||||||
|
|
||||||
CURRENCY_FIELDS = (
|
CURRENCY_FIELDS = (
|
||||||
"account__currency",
|
"account__currency",
|
||||||
"account__currency__code",
|
"account__currency__code",
|
||||||
@@ -234,3 +235,35 @@ def get_grouped_totals(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _known(values):
|
||||||
|
"""Drop anything that is not a level, and any repeat."""
|
||||||
|
levels = []
|
||||||
|
for value in values:
|
||||||
|
if value in LEVEL_KEYS and value not in levels:
|
||||||
|
levels.append(value)
|
||||||
|
return levels
|
||||||
|
|
||||||
|
|
||||||
|
def clean_chain(values):
|
||||||
|
"""
|
||||||
|
The full chain in the submitted order.
|
||||||
|
|
||||||
|
Every level always has a chip, switched on or not, so a short or unknown
|
||||||
|
submission is padded back out rather than rejected.
|
||||||
|
"""
|
||||||
|
chain = _known(values)
|
||||||
|
return chain + [key for key in LEVEL_KEYS if key not in chain]
|
||||||
|
|
||||||
|
|
||||||
|
def clean_levels(values, chain):
|
||||||
|
"""
|
||||||
|
The levels actually switched on, ordered by their place in ``chain``.
|
||||||
|
|
||||||
|
Taking the order from the chain rather than from the submission keeps the
|
||||||
|
hierarchy and the chips in step even if the two disagree. Never empty: the
|
||||||
|
first chip stands in, since an overview with no levels has nothing to show.
|
||||||
|
"""
|
||||||
|
enabled = set(_known(values))
|
||||||
|
return [key for key in chain if key in enabled] or list(chain[:1])
|
||||||
|
|||||||
+92
-64
@@ -4,7 +4,6 @@ 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.utils.translation import gettext_lazy as _
|
||||||
from django.views.decorators.http import require_http_methods
|
from django.views.decorators.http import require_http_methods
|
||||||
@@ -22,7 +21,12 @@ 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.overview import get_grouped_totals
|
from apps.insights.utils.overview import (
|
||||||
|
LEVEL_KEYS,
|
||||||
|
clean_chain,
|
||||||
|
clean_levels,
|
||||||
|
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,
|
||||||
@@ -33,9 +37,7 @@ 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
|
# Labels for the grouping levels the overview can be built from.
|
||||||
# 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 = {
|
OVERVIEW_LEVELS = {
|
||||||
"categories": {
|
"categories": {
|
||||||
"label": _("Categories"),
|
"label": _("Categories"),
|
||||||
@@ -61,54 +63,6 @@ OVERVIEW_LEVELS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
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"])
|
||||||
def index(request):
|
def index(request):
|
||||||
@@ -247,28 +201,102 @@ def category_sum_by_currency(request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
OVERVIEW_SESSION_PREFIX = "insights_overview"
|
||||||
|
|
||||||
|
# What a first visit shows: categories broken down by tags, as the old
|
||||||
|
# Categories Overview did, with entities available but switched off.
|
||||||
|
OVERVIEW_DEFAULT_LEVELS = ["categories", "tags"]
|
||||||
|
|
||||||
|
|
||||||
|
def _overview_setting(request, name, default):
|
||||||
|
"""Read a control from the query string, falling back to the last one used."""
|
||||||
|
key = f"{OVERVIEW_SESSION_PREFIX}_{name}"
|
||||||
|
if name in request.GET:
|
||||||
|
request.session[key] = request.GET[name]
|
||||||
|
return request.GET[name]
|
||||||
|
return request.session.get(key, default)
|
||||||
|
|
||||||
|
|
||||||
|
def _overview_chain(request):
|
||||||
|
"""The chip order and the switched on levels, from the request or session."""
|
||||||
|
chain_key = f"{OVERVIEW_SESSION_PREFIX}_chain"
|
||||||
|
levels_key = f"{OVERVIEW_SESSION_PREFIX}_levels"
|
||||||
|
|
||||||
|
if "chain" in request.GET:
|
||||||
|
chain = clean_chain(request.GET.getlist("chain"))
|
||||||
|
levels = clean_levels(request.GET.getlist("level"), chain)
|
||||||
|
request.session[chain_key] = chain
|
||||||
|
request.session[levels_key] = levels
|
||||||
|
else:
|
||||||
|
chain = clean_chain(request.session.get(chain_key, list(LEVEL_KEYS)))
|
||||||
|
levels = clean_levels(
|
||||||
|
request.session.get(levels_key, OVERVIEW_DEFAULT_LEVELS), chain
|
||||||
|
)
|
||||||
|
|
||||||
|
return chain, levels
|
||||||
|
|
||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
def category_overview(request):
|
def overview(request):
|
||||||
return _render_overview(
|
"""
|
||||||
request, ("categories", "tags", "entities"), "category_overview"
|
One overview whose level chain the user arranges.
|
||||||
|
|
||||||
|
Renders only the controls; they stay put while the results below them
|
||||||
|
reload, so rearranging the chain never pulls the chips out from under the
|
||||||
|
pointer. The chips hold the order, which is why nothing here rewrites it.
|
||||||
|
"""
|
||||||
|
chain, levels = _overview_chain(request)
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"insights/fragments/overview/index.html",
|
||||||
|
{
|
||||||
|
"chips": [
|
||||||
|
{
|
||||||
|
"key": key,
|
||||||
|
"meta": OVERVIEW_LEVELS[key],
|
||||||
|
"enabled": key in levels,
|
||||||
|
"position": levels.index(key) + 1 if key in levels else "",
|
||||||
|
}
|
||||||
|
for key in chain
|
||||||
|
],
|
||||||
|
"view_type": _overview_setting(request, "view_type", "table"),
|
||||||
|
"showing": _overview_setting(request, "showing", "final"),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
def tag_overview(request):
|
def overview_results(request):
|
||||||
return _render_overview(request, ("tags", "categories", "entities"), "tag_overview")
|
"""The table or chart for the arrangement the controls submitted."""
|
||||||
|
chain, levels = _overview_chain(request)
|
||||||
|
view_type = _overview_setting(request, "view_type", "table")
|
||||||
|
showing = _overview_setting(request, "showing", "final")
|
||||||
|
|
||||||
|
total_table = get_grouped_totals(
|
||||||
|
transactions_queryset=get_transactions(request, include_silent=True),
|
||||||
|
levels=levels,
|
||||||
|
showing=showing,
|
||||||
|
ignore_empty=False,
|
||||||
|
depth=len(levels),
|
||||||
|
)
|
||||||
|
|
||||||
@only_htmx
|
return render(
|
||||||
@login_required
|
request,
|
||||||
@require_http_methods(["GET"])
|
"insights/fragments/overview/_results.html",
|
||||||
def entity_overview(request):
|
{
|
||||||
return _render_overview(
|
"total_table": total_table,
|
||||||
request, ("entities", "categories", "tags"), "entity_overview"
|
"view_type": view_type,
|
||||||
|
"showing": showing,
|
||||||
|
"level_1": OVERVIEW_LEVELS[levels[0]],
|
||||||
|
"level_2": OVERVIEW_LEVELS[levels[1]] if len(levels) > 1 else None,
|
||||||
|
"level_3": OVERVIEW_LEVELS[levels[2]] if len(levels) > 2 else None,
|
||||||
|
"empty_message": OVERVIEW_LEVELS[levels[0]]["empty_message"],
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
{% 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 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 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);
|
||||||
|
var emptyLabel = "{{ level_1.empty|escapejs }}";
|
||||||
|
|
||||||
|
var items = [];
|
||||||
|
var currencyDetails = {};
|
||||||
|
var currencyData = {};
|
||||||
|
|
||||||
|
// Pass 1: collect the top level rows and the currencies in play
|
||||||
|
Object.values(rawData).forEach(item => {
|
||||||
|
var itemName = item.name === null ? emptyLabel : 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 || '',
|
||||||
|
decimal_places: !isNaN(decimals) && decimals >= 0 ? decimals : 2
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.keys(currencyDetails).forEach(code => {
|
||||||
|
currencyData[code] = new Array(items.length).fill(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pass 2: fill one series per currency, leaving gaps as null
|
||||||
|
Object.values(rawData).forEach(item => {
|
||||||
|
var itemName = item.name === null ? emptyLabel : 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);
|
||||||
|
currencyData[code][itemIndex] = !isNaN(value) ? value : null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var datasets = Object.keys(currencyDetails).map(code => {
|
||||||
|
return {
|
||||||
|
label: currencyDetails[code].name,
|
||||||
|
data: currencyData[code],
|
||||||
|
currencyCode: code,
|
||||||
|
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 details = currencyDetails[context.dataset.currencyCode];
|
||||||
|
const value = context.parsed.x; // 'x' because indexAxis is 'y'
|
||||||
|
|
||||||
|
if (value === null || value === undefined || !details) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let formattedValue = '';
|
||||||
|
try {
|
||||||
|
formattedValue = new Intl.NumberFormat(undefined, {
|
||||||
|
minimumFractionDigits: details.decimal_places,
|
||||||
|
maximumFractionDigits: details.decimal_places,
|
||||||
|
}).format(value);
|
||||||
|
} catch (e) {
|
||||||
|
formattedValue = value.toFixed(details.decimal_places);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${details.prefix}${formattedValue}${details.suffix}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
position: 'top',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
stacked: true,
|
||||||
|
type: 'linear',
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: '{% trans 'Total' %}'
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
callback: function (value) {
|
||||||
|
return value.toLocaleString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
stacked: true,
|
||||||
|
title: {
|
||||||
|
display: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<c-msg.empty title="{{ empty_message }}"></c-msg.empty>
|
||||||
|
{% endif %}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
{% comment %}
|
||||||
|
Overview insight: the level chain is arranged here instead of being baked
|
||||||
|
into the view. Only #overview-results reloads when something changes, so the
|
||||||
|
controls never get pulled out from under the pointer.
|
||||||
|
{% endcomment %}
|
||||||
|
|
||||||
<div hx-get="{{ refresh_url }}" hx-trigger="updated from:window" class="show-loading" hx-swap="outerHTML"
|
<div>
|
||||||
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="h-full text-center mb-4">
|
||||||
<div class="tabs tabs-box mx-auto w-fit" role="group" id="view-type" _="on change trigger updated">
|
<div class="tabs tabs-box mx-auto w-fit" role="group" id="view-type" _="on change trigger updated">
|
||||||
<label class="tab">
|
<label class="tab">
|
||||||
@@ -28,37 +32,36 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="my-3 flex flex-col gap-3 md:flex-row justify-between">
|
<div class="my-3 flex flex-col gap-3 md:flex-row md:items-center justify-between">
|
||||||
<div class="flex gap-4">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
{% if view_type == 'table' %}
|
<div class="flex flex-wrap items-center gap-2" id="level-chain"
|
||||||
<div id="show-level-2">
|
x-data
|
||||||
<label class="label">
|
x-sort.ghost="levelChainSorted()"
|
||||||
<input type="hidden" name="show_level_2" value="off">
|
x-sort:config="{ ghostClass: 'opacity-40' }"
|
||||||
<input type="checkbox" class="toggle toggle-primary toggle-sm" id="show-level-2-switch" name="show_level_2"
|
data-last-level-title="{% trans 'At least one level has to stay on' %}">
|
||||||
_="on change trigger updated" {% if show_level_2 %}checked{% endif %}>
|
{% for chip in chips %}
|
||||||
<span>
|
{# The hidden inputs sit outside .join: it rounds its end from :last-child, which they would otherwise be #}
|
||||||
{{ level_2.label }}
|
<div class="level-chip inline-flex{% if not chip.enabled %} level-off{% endif %}" data-level="{{ chip.key }}">
|
||||||
</span>
|
{# --radius-field is what .join reads for its outer corners, so overriding it here turns the chip into a pill #}
|
||||||
<c-ui.help-icon
|
<div class="join bg-base-200 rounded-full [--radius-field:9999px]">
|
||||||
content="{% trans 'Transaction amounts associated with multiple tags or entities will be counted once for each one of them' %}"
|
<button class="join-item btn btn-sm btn-ghost ps-3 pe-2 cursor-grab active:cursor-grabbing" type="button"
|
||||||
icon="fa-solid fa-circle-exclamation"></c-ui.help-icon>
|
x-sort:handle data-handle
|
||||||
</label>
|
aria-label="{% trans 'Drag to reorder, or use the left and right arrow keys' %}"><i
|
||||||
</div>
|
class="fa-solid fa-grip-vertical fa-fw"></i></button>
|
||||||
<div id="show-level-3" class="{% if not show_level_2 %}hidden{% endif %}">
|
<button class="join-item btn btn-sm pe-4 gap-2" type="button" data-toggle
|
||||||
<label class="label">
|
aria-label="{{ chip.meta.label }}">
|
||||||
<input type="hidden" name="show_level_3" value="off">
|
<span class="level-position badge badge-xs badge-neutral">{{ chip.position }}</span>
|
||||||
<input type="checkbox" class="toggle toggle-primary toggle-sm" id="show-level-3-switch"
|
<span class="level-label"><i class="{{ chip.meta.icon }} fa-fw me-1"></i>{{ chip.meta.label }}</span>
|
||||||
name="show_level_3"
|
</button>
|
||||||
_="on change trigger updated" {% if show_level_3 %}checked{% endif %}>
|
</div>
|
||||||
<span>
|
<input type="hidden" name="chain" value="{{ chip.key }}">
|
||||||
{{ level_3.label }}
|
<input type="hidden" name="level" value="{{ chip.key }}" {% if not chip.enabled %}disabled{% endif %}>
|
||||||
</span>
|
</div>
|
||||||
<c-ui.help-icon
|
{% endfor %}
|
||||||
content="{% trans 'Transaction amounts associated with multiple tags or entities will be counted once for each one of them' %}"
|
</div>
|
||||||
icon="fa-solid fa-circle-exclamation"></c-ui.help-icon>
|
<c-ui.help-icon
|
||||||
</label>
|
content="{% trans 'Transaction amounts associated with multiple tags or entities will be counted once for each one of them' %}"
|
||||||
</div>
|
icon="fa-solid fa-circle-exclamation"></c-ui.help-icon>
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="join" role="group" id="showing" _="on change trigger updated">
|
<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"
|
<input type="radio" class="join-item btn btn-outline btn-primary btn-sm" name="showing" id="showing-projected"
|
||||||
@@ -74,210 +77,102 @@
|
|||||||
{% if showing == 'final' %}checked{% endif %}>
|
{% if showing == 'final' %}checked{% endif %}>
|
||||||
</div>
|
</div>
|
||||||
</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 #}
|
<div id="overview-results" class="show-loading"
|
||||||
{% if show_level_2 %}
|
hx-get="{% url 'insights_overview_results' %}"
|
||||||
{% for child in item.children.values %}
|
hx-trigger="load, updated from:window"
|
||||||
{% if child.name or item.children.values|length > 1 %}
|
hx-include="#picker-form, #picker-type, #view-type, #level-chain, #showing"
|
||||||
{% 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 %}
|
hx-sync="this:replace"></div>
|
||||||
|
|
||||||
{# Third level rows #}
|
<script>
|
||||||
{% if show_level_3 %}
|
// The chips are the source of truth for the chain: their DOM order is the
|
||||||
{% for grandchild in child.children.values %}
|
// hierarchy and their hidden inputs are what gets submitted. Dragging is
|
||||||
{% if grandchild.name or child.children.values|length > 1 %}
|
// therefore instant, and the server only reads the result back.
|
||||||
{% 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 %}
|
function setupLevelChain(root) {
|
||||||
{% endif %}
|
function refresh() {
|
||||||
{% endfor %}
|
var chips = Array.prototype.slice.call(root.querySelectorAll('.level-chip'));
|
||||||
{% endif %}
|
var enabled = chips.filter(function (chip) {
|
||||||
{% endif %}
|
return !chip.classList.contains('level-off');
|
||||||
{% 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
|
chips.forEach(function (chip) {
|
||||||
Object.keys(currencyDetails).forEach(code => {
|
var off = chip.classList.contains('level-off');
|
||||||
currencyData[code] = new Array(items.length).fill(null);
|
var toggle = chip.querySelector('[data-toggle]');
|
||||||
|
var label = chip.querySelector('.level-label');
|
||||||
|
var position = chip.querySelector('.level-position');
|
||||||
|
|
||||||
|
chip.querySelector('input[name="level"]').disabled = off;
|
||||||
|
|
||||||
|
// An off level reads as daisyUI's disabled button, but stays
|
||||||
|
// clickable. Using .btn-disabled would set pointer-events:none
|
||||||
|
// and strand it in the off state.
|
||||||
|
toggle.classList.toggle('btn-primary', !off);
|
||||||
|
toggle.classList.toggle('btn-ghost', off);
|
||||||
|
label.classList.toggle('line-through', off);
|
||||||
|
label.classList.toggle('opacity-50', off);
|
||||||
|
chip.classList.toggle('opacity-80', off);
|
||||||
|
|
||||||
|
position.textContent = off ? '' : (enabled.indexOf(chip) + 1);
|
||||||
|
position.hidden = off;
|
||||||
|
|
||||||
|
// Same reasoning for the last one standing: mark it refused
|
||||||
|
// rather than disabled, so it still looks switched on.
|
||||||
|
var last = !off && enabled.length === 1;
|
||||||
|
toggle.setAttribute('aria-disabled', last ? 'true' : 'false');
|
||||||
|
toggle.classList.toggle('cursor-not-allowed', last);
|
||||||
|
toggle.title = last ? (root.dataset.lastLevelTitle || '') : '';
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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 %}
|
function commit() {
|
||||||
{% else %}
|
refresh();
|
||||||
<c-msg.empty title="{{ empty_message }}"></c-msg.empty>
|
window.dispatchEvent(new CustomEvent('updated'));
|
||||||
{% endif %}
|
}
|
||||||
|
|
||||||
|
function move(chip, step) {
|
||||||
|
var sibling = step < 0 ? chip.previousElementSibling : chip.nextElementSibling;
|
||||||
|
if (!sibling) return false;
|
||||||
|
if (step < 0) {
|
||||||
|
root.insertBefore(chip, sibling);
|
||||||
|
} else {
|
||||||
|
root.insertBefore(sibling, chip);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.addEventListener('click', function (event) {
|
||||||
|
var toggle = event.target.closest('[data-toggle]');
|
||||||
|
if (!toggle || !root.contains(toggle)) return;
|
||||||
|
if (toggle.getAttribute('aria-disabled') === 'true') return;
|
||||||
|
toggle.closest('.level-chip').classList.toggle('level-off');
|
||||||
|
commit();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dragging is mouse and touch only, so the handle also reorders with
|
||||||
|
// the arrow keys rather than leaving the chain keyboard inaccessible.
|
||||||
|
root.addEventListener('keydown', function (event) {
|
||||||
|
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
|
||||||
|
var handle = event.target.closest('[data-handle]');
|
||||||
|
if (!handle || !root.contains(handle)) return;
|
||||||
|
if (!move(handle.closest('.level-chip'), event.key === 'ArrowLeft' ? -1 : 1)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
handle.focus();
|
||||||
|
commit();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reached from the x-sort handler once Sortable has moved the chip.
|
||||||
|
root.levelChainCommit = commit;
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function levelChainSorted() {
|
||||||
|
var root = document.getElementById('level-chain');
|
||||||
|
if (root && root.levelChainCommit) root.levelChainCommit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wired up here rather than with a hyperscript init, so it does not
|
||||||
|
// depend on which of the two runs first after an htmx swap.
|
||||||
|
setupLevelChain(document.getElementById('level-chain'));
|
||||||
|
</script>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -90,18 +90,8 @@
|
|||||||
</button>
|
</button>
|
||||||
<button class="btn btn-ghost btn-free justify-start text-start" data-bs-target="#v-pills-content"
|
<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"
|
type="button" role="tab" aria-controls="v-pills-content" aria-selected="false"
|
||||||
hx-get="{% url 'category_overview' %}"
|
hx-get="{% url 'insights_overview' %}"
|
||||||
>{% trans 'Categories Overview' %}
|
>{% trans '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 '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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Generated
+16
@@ -11,6 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@alpinejs/collapse": "^3.15.12",
|
"@alpinejs/collapse": "^3.15.12",
|
||||||
"@alpinejs/mask": "^3.15.12",
|
"@alpinejs/mask": "^3.15.12",
|
||||||
|
"@alpinejs/sort": "^3.17.2",
|
||||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||||
"@fortawesome/fontawesome-free": "^7.2.0",
|
"@fortawesome/fontawesome-free": "^7.2.0",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
@@ -52,6 +53,15 @@
|
|||||||
"integrity": "sha512-FcOVQp+tsIiBeNwWsH1BjAhJa+R/b6Tv6sPfSMRZlp1e/4w4H44Kagd9Aq86xpFgeI5dibJil2TG0+3LqB4JoA==",
|
"integrity": "sha512-FcOVQp+tsIiBeNwWsH1BjAhJa+R/b6Tv6sPfSMRZlp1e/4w4H44Kagd9Aq86xpFgeI5dibJil2TG0+3LqB4JoA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@alpinejs/sort": {
|
||||||
|
"version": "3.17.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@alpinejs/sort/-/sort-3.17.2.tgz",
|
||||||
|
"integrity": "sha512-72v63zuFkepgO2nwi8wNrD1z6pAY3S+d+lCwSZ9Q39hkIFISxPXKffEh3qtTu7Dzlc98EYLz4kVS/pxp3mdw5g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"sortablejs": "^1.15.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/runtime": {
|
"node_modules/@babel/runtime": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||||
@@ -1950,6 +1960,12 @@
|
|||||||
"integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==",
|
"integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/sortablejs": {
|
||||||
|
"version": "1.15.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz",
|
||||||
|
"integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@alpinejs/collapse": "^3.15.12",
|
"@alpinejs/collapse": "^3.15.12",
|
||||||
"@alpinejs/mask": "^3.15.12",
|
"@alpinejs/mask": "^3.15.12",
|
||||||
|
"@alpinejs/sort": "^3.17.2",
|
||||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||||
"@fortawesome/fontawesome-free": "^7.2.0",
|
"@fortawesome/fontawesome-free": "^7.2.0",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import './_htmx.js';
|
|||||||
import Alpine from "alpinejs";
|
import Alpine from "alpinejs";
|
||||||
import mask from '@alpinejs/mask';
|
import mask from '@alpinejs/mask';
|
||||||
import collapse from '@alpinejs/collapse'
|
import collapse from '@alpinejs/collapse'
|
||||||
|
import sort from '@alpinejs/sort';
|
||||||
import { create, all } from 'mathjs';
|
import { create, all } from 'mathjs';
|
||||||
|
|
||||||
window.Alpine = Alpine;
|
window.Alpine = Alpine;
|
||||||
@@ -12,6 +13,7 @@ window.math = create(all, {
|
|||||||
|
|
||||||
Alpine.plugin(mask);
|
Alpine.plugin(mask);
|
||||||
Alpine.plugin(collapse);
|
Alpine.plugin(collapse);
|
||||||
|
Alpine.plugin(sort);
|
||||||
Alpine.start();
|
Alpine.start();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user