mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-07-04 12:01:50 +02:00
Merge pull request #243
feat(insights:category-overview): select if you want to view table or bar charts, defaults to table
This commit is contained in:
@@ -168,6 +168,12 @@ 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):
|
||||||
|
view_type = request.session.get("insights_category_explorer_view_type", "table")
|
||||||
|
|
||||||
|
if "view_type" in request.GET:
|
||||||
|
view_type = request.GET["view_type"]
|
||||||
|
request.session["insights_category_explorer_view_type"] = view_type
|
||||||
|
|
||||||
# Get filtered transactions
|
# Get filtered transactions
|
||||||
transactions = get_transactions(request, include_silent=True)
|
transactions = get_transactions(request, include_silent=True)
|
||||||
|
|
||||||
@@ -178,7 +184,7 @@ def category_overview(request):
|
|||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"insights/fragments/category_overview/index.html",
|
"insights/fragments/category_overview/index.html",
|
||||||
{"total_table": total_table},
|
{"total_table": total_table, "view_type": view_type},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
{% spaceless %}
|
{% spaceless %}
|
||||||
|
{% load i18n %}
|
||||||
<span class="tw-text-xs text-white-50 mx-1"
|
<span class="tw-text-xs text-white-50 mx-1"
|
||||||
data-bs-toggle="tooltip"
|
data-bs-toggle="tooltip"
|
||||||
data-bs-title="{{ content }}">
|
data-bs-title="{{ content }}">
|
||||||
<i class="fa-solid fa-circle-question fa-fw"></i>
|
<i class="{% if not icon %}fa-solid fa-circle-question{% else %}{{ icon }}{% endif %} fa-fw"></i>
|
||||||
</span>
|
</span>
|
||||||
{% endspaceless %}
|
{% endspaceless %}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
{% if icon %}<i class="{{ icon }}"></i>{% else %}<span class="fw-bold">{{ title.0 }}</span>{% endif %}
|
{% if icon %}<i class="{{ icon }}"></i>{% else %}<span class="fw-bold">{{ title.0 }}</span>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="tw-text-{{ color }}-400 fw-bold tw-mr-[50px]" {{ attrs }}>{{ title }}{% if help_text %}{% include 'includes/help_icon.html' with content=help_text %}{% endif %}</h5>
|
<h5 class="tw-text-{{ color }}-400 fw-bold tw-mr-[50px]" {{ attrs }}>{{ title }}{% if help_text %}<c-ui.help-icon :content="help_text" icon=""></c-ui.help-icon>{% endif %}</h5>
|
||||||
{{ slot }}
|
{{ slot }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,226 +1,252 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
<div hx-get="{% url 'category_overview' %}" hx-trigger="updated from:window" class="show-loading" hx-swap="outerHTML"
|
<div hx-get="{% url 'category_overview' %}" hx-trigger="updated from:window" class="show-loading" hx-swap="outerHTML"
|
||||||
hx-include="#picker-form, #picker-type">
|
hx-include="#picker-form, #picker-type, #view-type">
|
||||||
|
<div class="h-100 text-center mb-4">
|
||||||
|
<div class="btn-group btn-group-sm gap-3" role="group" aria-label="Basic radio toggle button group" id="view-type">
|
||||||
|
<input type="radio" class="btn-check"
|
||||||
|
name="view_type"
|
||||||
|
id="table-view"
|
||||||
|
autocomplete="off"
|
||||||
|
value="table"
|
||||||
|
_="on change trigger updated"
|
||||||
|
{% if view_type == "table" %}checked{% endif %}>
|
||||||
|
<label class="btn btn-outline-primary rounded-5" for="table-view"><i
|
||||||
|
class="fa-solid fa-table fa-fw me-2"></i>{% trans 'Table' %}</label>
|
||||||
|
|
||||||
|
<input type="radio"
|
||||||
|
class="btn-check"
|
||||||
|
name="view_type"
|
||||||
|
id="bars-view"
|
||||||
|
autocomplete="off"
|
||||||
|
value="bars"
|
||||||
|
_="on change trigger updated"
|
||||||
|
{% if view_type == "bars" %}checked{% endif %}>
|
||||||
|
<label class="btn btn-outline-primary rounded-5" for="bars-view"><i
|
||||||
|
class="fa-solid fa-chart-bar fa-fw me-2"></i>{% trans 'Bars' %}</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% if total_table %}
|
{% if total_table %}
|
||||||
<div class="table-responsive">
|
{% if view_type == "table" %}
|
||||||
<table class="table table-striped table-hover">
|
<div class="table-responsive">
|
||||||
<thead>
|
<table class="table table-striped table-hover table-bordered">
|
||||||
<tr>
|
<thead>
|
||||||
<th scope="col">{% trans 'Category' %}</th>
|
|
||||||
<th scope="col">{% trans 'Income' %}</th>
|
|
||||||
<th scope="col">{% trans 'Expense' %}</th>
|
|
||||||
<th scope="col">{% trans 'Total' %}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for category in total_table.values %}
|
|
||||||
<tr>
|
<tr>
|
||||||
<th>{% if category.name %}{{ category.name }}{% else %}{% trans 'Uncategorized' %}{% endif %}</th>
|
<th scope="col">{% trans 'Category' %}</th>
|
||||||
<td>
|
<th scope="col">{% trans 'Income' %}</th>
|
||||||
{% for currency in category.currencies.values %}
|
<th scope="col">{% trans 'Expense' %}</th>
|
||||||
{% if currency.total_income != 0 %}
|
<th scope="col">{% trans 'Total' %}</th>
|
||||||
<c-amount.display
|
|
||||||
:amount="currency.total_income"
|
|
||||||
:prefix="currency.currency.prefix"
|
|
||||||
:suffix="currency.currency.suffix"
|
|
||||||
:decimal_places="currency.currency.decimal_places"
|
|
||||||
color="green"></c-amount.display>
|
|
||||||
{% else %}
|
|
||||||
<div>-</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{% for currency in category.currencies.values %}
|
|
||||||
{% if currency.total_expense != 0 %}
|
|
||||||
<c-amount.display
|
|
||||||
:amount="currency.total_expense"
|
|
||||||
:prefix="currency.currency.prefix"
|
|
||||||
:suffix="currency.currency.suffix"
|
|
||||||
:decimal_places="currency.currency.decimal_places"
|
|
||||||
color="red"></c-amount.display>
|
|
||||||
{% else %}
|
|
||||||
<div>-</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{% for currency in category.currencies.values %}
|
|
||||||
{% if currency.total_final != 0 %}
|
|
||||||
<c-amount.display
|
|
||||||
:amount="currency.total_final"
|
|
||||||
:prefix="currency.currency.prefix"
|
|
||||||
:suffix="currency.currency.suffix"
|
|
||||||
:decimal_places="currency.currency.decimal_places"
|
|
||||||
color="{% if currency.total_final < 0 %}red{% else %}green{% endif %}"></c-amount.display>
|
|
||||||
{% else %}
|
|
||||||
<div>-</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{% for category in total_table.values %}
|
||||||
</div>
|
<tr>
|
||||||
|
<th>{% if category.name %}{{ category.name }}{% else %}{% trans 'Uncategorized' %}{% endif %}</th>
|
||||||
<div class="mt-4">
|
<td>
|
||||||
<div class="chart-container" _="init call setupChart() end" style="position: relative; height:90vh; width:100%">
|
{% for currency in category.currencies.values %}
|
||||||
<canvas id="categoryChart"></canvas>
|
{% if currency.total_income != 0 %}
|
||||||
|
<c-amount.display
|
||||||
|
:amount="currency.total_income"
|
||||||
|
:prefix="currency.currency.prefix"
|
||||||
|
:suffix="currency.currency.suffix"
|
||||||
|
:decimal_places="currency.currency.decimal_places"
|
||||||
|
color="green"></c-amount.display>
|
||||||
|
{% else %}
|
||||||
|
<div>-</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% for currency in category.currencies.values %}
|
||||||
|
{% if currency.total_expense != 0 %}
|
||||||
|
<c-amount.display
|
||||||
|
:amount="currency.total_expense"
|
||||||
|
:prefix="currency.currency.prefix"
|
||||||
|
:suffix="currency.currency.suffix"
|
||||||
|
:decimal_places="currency.currency.decimal_places"
|
||||||
|
color="red"></c-amount.display>
|
||||||
|
{% else %}
|
||||||
|
<div>-</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% for currency in category.currencies.values %}
|
||||||
|
{% if currency.total_final != 0 %}
|
||||||
|
<c-amount.display
|
||||||
|
:amount="currency.total_final"
|
||||||
|
:prefix="currency.currency.prefix"
|
||||||
|
:suffix="currency.currency.suffix"
|
||||||
|
:decimal_places="currency.currency.decimal_places"
|
||||||
|
color="{% if currency.total_final < 0 %}red{% else %}green{% endif %}"></c-amount.display>
|
||||||
|
{% else %}
|
||||||
|
<div>-</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{{ total_table|json_script:"categoryOverviewData" }}
|
{% elif view_type == "bars" %}
|
||||||
|
<div>
|
||||||
|
<div class="chart-container" _="init call setupChart() end" style="position: relative; height:80vh; width:100%">
|
||||||
|
<canvas id="categoryChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
{{ total_table|json_script:"categoryOverviewData" }}
|
||||||
function setupChart() {
|
|
||||||
var rawData = JSON.parse(document.getElementById('categoryOverviewData').textContent);
|
|
||||||
|
|
||||||
// --- Dynamic Data Processing ---
|
<script>
|
||||||
var categories = [];
|
function setupChart() {
|
||||||
var currencyDetails = {}; // Stores details like { BRL: {code: 'BRL', name: 'Real', ...}, ... }
|
var rawData = JSON.parse(document.getElementById('categoryOverviewData').textContent);
|
||||||
var currencyData = {}; // Stores data arrays like { BRL: [val1, null, val3,...], ... }
|
|
||||||
|
|
||||||
// Pass 1: Collect categories and currency details
|
// --- Dynamic Data Processing ---
|
||||||
Object.values(rawData).forEach(cat => {
|
var categories = [];
|
||||||
var categoryName = cat.name === null ? "{% trans 'Uncategorized' %}" : cat.name;
|
var currencyDetails = {}; // Stores details like { BRL: {code: 'BRL', name: 'Real', ...}, ... }
|
||||||
if (!categories.includes(categoryName)) {
|
var currencyData = {}; // Stores data arrays like { BRL: [val1, null, val3,...], ... }
|
||||||
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
|
// Pass 1: Collect categories and currency details
|
||||||
Object.keys(currencyDetails).forEach(code => {
|
Object.values(rawData).forEach(cat => {
|
||||||
currencyData[code] = new Array(categories.length).fill(null);
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Pass 2: Populate data arrays (store all valid numbers now)
|
// Initialize data structure for each currency with nulls
|
||||||
Object.values(rawData).forEach(cat => {
|
Object.keys(currencyDetails).forEach(code => {
|
||||||
var categoryName = cat.name === null ? "{% trans 'Uncategorized' %}" : cat.name;
|
currencyData[code] = new Array(categories.length).fill(null);
|
||||||
var catIndex = categories.indexOf(categoryName);
|
});
|
||||||
if (catIndex === -1) return;
|
|
||||||
|
|
||||||
if (cat.currencies) {
|
// Pass 2: Populate data arrays (store all valid numbers now)
|
||||||
Object.values(cat.currencies).forEach(curr => {
|
Object.values(rawData).forEach(cat => {
|
||||||
var code = curr.currency?.code;
|
var categoryName = cat.name === null ? "{% trans 'Uncategorized' %}" : cat.name;
|
||||||
if (code && currencyData[code]) {
|
var catIndex = categories.indexOf(categoryName);
|
||||||
var value = parseFloat(curr.total_final);
|
if (catIndex === -1) return;
|
||||||
// Store the number if it's valid, otherwise keep null
|
|
||||||
currencyData[code][catIndex] = !isNaN(value) ? value : null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Dynamic Chart Configuration ---
|
if (cat.currencies) {
|
||||||
var datasets = Object.keys(currencyDetails).map((code, index) => {
|
Object.values(cat.currencies).forEach(curr => {
|
||||||
return {
|
var code = curr.currency?.code;
|
||||||
label: currencyDetails[code].name, // Use currency name for the legend label
|
if (code && currencyData[code]) {
|
||||||
data: currencyData[code],
|
var value = parseFloat(curr.total_final);
|
||||||
currencyCode: code, // Store code for easy lookup in tooltip
|
// Store the number if it's valid, otherwise keep null
|
||||||
borderWidth: 1
|
currencyData[code][catIndex] = !isNaN(value) ? value : null;
|
||||||
};
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
new Chart(document.getElementById('categoryChart'),
|
// --- Dynamic Chart Configuration ---
|
||||||
{
|
var datasets = Object.keys(currencyDetails).map((code, index) => {
|
||||||
type: 'bar',
|
return {
|
||||||
data: {
|
label: currencyDetails[code].name, // Use currency name for the legend label
|
||||||
labels: categories,
|
data: currencyData[code],
|
||||||
datasets: datasets
|
currencyCode: code, // Store code for easy lookup in tooltip
|
||||||
},
|
borderWidth: 1
|
||||||
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) {
|
new Chart(document.getElementById('categoryChart'),
|
||||||
// Display the category name if the value is null/undefined
|
{
|
||||||
return null;
|
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'
|
||||||
|
|
||||||
let formattedValue = '';
|
if (value === null || value === undefined || !details) {
|
||||||
try {
|
// Display the category name if the value is null/undefined
|
||||||
// Use Intl.NumberFormat for ALL values, configured with locale and exact decimal places
|
return null;
|
||||||
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
|
let formattedValue = '';
|
||||||
return `${details.prefix}${formattedValue}${details.suffix}`;
|
try {
|
||||||
}
|
// Use Intl.NumberFormat for ALL values, configured with locale and exact decimal places
|
||||||
}
|
formattedValue = new Intl.NumberFormat(undefined, {
|
||||||
},
|
minimumFractionDigits: details.decimal_places,
|
||||||
legend: {
|
maximumFractionDigits: details.decimal_places,
|
||||||
position: 'top',
|
// Do NOT use style: 'currency' here, as we add prefix/suffix manually
|
||||||
}
|
}).format(value);
|
||||||
},
|
} catch (e) {
|
||||||
scales: {
|
formattedValue = value.toFixed(details.decimal_places);
|
||||||
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>
|
|
||||||
|
|
||||||
|
// 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 %}
|
{% else %}
|
||||||
<c-msg.empty title="{% translate "No categories" %}"></c-msg.empty>
|
<c-msg.empty title="{% translate "No categories" %}"></c-msg.empty>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
{% load crispy_forms_tags %}
|
{% load crispy_forms_tags %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block title %}{% translate 'Insights' %}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<div class="row my-3 h-100">
|
<div class="row my-3 h-100">
|
||||||
|
|||||||
Reference in New Issue
Block a user