mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-09-13 21:32:08 +02:00
feat(insights): add tags and entities overview
This commit is contained in:
@@ -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' %}"
|
||||
>{% trans 'Categories 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user