feat: insight page

This commit is contained in:
Herculino Trotta
2025-02-16 00:14:23 -03:00
parent daf1f68b82
commit 195a8a68d6
15 changed files with 1062 additions and 262 deletions
+5 -2
View File
@@ -46,8 +46,11 @@
href="{% url 'net_worth_projected' %}">{% translate 'Projected' %}</a></li>
</ul>
</li>
<li class="nav-item">
<a class="nav-link {% active_link views='insights_index' %}" href="{% url 'insights_index' %}">{% trans 'Insights' %}</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {% active_link views='installment_plans_index||recurring_trasanctions_index||transactions_all_index' %}"
<a class="nav-link dropdown-toggle {% active_link views='installment_plans_index||recurring_trasanctions_index||transactions_all_index||transactions_trash_index' %}"
href="#" role="button"
data-bs-toggle="dropdown"
aria-expanded="false">
@@ -91,7 +94,7 @@
</ul>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {% active_link views='tags_index||entities_index||categories_index||accounts_index||account_groups_index||currencies_index||exchange_rates_index||rules_index' %}"
<a class="nav-link dropdown-toggle {% active_link views='tags_index||entities_index||categories_index||accounts_index||account_groups_index||currencies_index||exchange_rates_index||rules_index||import_profiles_index||automatic_exchange_rates_index' %}"
href="#" role="button"
data-bs-toggle="dropdown"
aria-expanded="false">
@@ -17,6 +17,10 @@
for x in datepickers
MonthYearPicker(it)
end
set datepickers to <.airyearpickerinput/> in me
for x in datepickers
YearPicker(it)
end
end
end
</script>
+106 -93
View File
@@ -1,107 +1,120 @@
{% extends "layouts/base.html" %}
{% load i18n %}
{% block content %}
<canvas id="sankeyChart" height="379"></canvas>
{% if type == 'account' %}
<div class="show-loading" hx-get="{% url 'insights_sankey_by_account' %}" hx-trigger="updated from:window" hx-swap="outerHTML" hx-include="#picker-form, #picker-type">
{% else %}
<div class="show-loading" hx-get="{% url 'insights_sankey_by_currency' %}" hx-trigger="updated from:window" hx-swap="outerHTML" hx-include="#picker-form, #picker-type">
{% endif %}
<div class="chart-container position-relative tw-min-h-[60vh] tw-max-h-[60vh] tw-h-full tw-w-full"
id="sankeyContainer"
_="init call setupSankeyChart() end">
<canvas id="sankeyChart"></canvas>
</div>
</div>
<script>
function setupSankeyChart(data, chartId = 'sankeyChart') {
// Format currency value
function formatCurrency(value, currency) {
return new Intl.NumberFormat(undefined, {
minimumFractionDigits: currency.decimal_places,
maximumFractionDigits: currency.decimal_places
}).format(value);
}
var data = {{ sankey_data|safe }};
const nodeIndices = {};
data.nodes.forEach((node, index) => {
nodeIndices[node.name] = index;
});
function setupSankeyChart(chartId = 'sankeyChart') {
function formatCurrency(value, currency) {
return new Intl.NumberFormat(undefined, {
minimumFractionDigits: currency.decimal_places,
maximumFractionDigits: currency.decimal_places
}).format(value);
}
// Format data for Chart.js
const chartData = {
datasets: [{
data: data.flows.map(flow => ({
from: flow.from_node,
to: flow.to_node,
flow: flow.flow
})),
colorMode: 'to',
labels: data.nodes.map(node => node.name),
size: 'max',
}]
};
// Create labels object mapping node IDs to display names
const labels = data.nodes.reduce((acc, node) => {
acc[node.id] = node.name;
return acc;
}, {});
// Chart configuration
const config = {
type: 'sankey',
data: chartData,
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
tooltip: {
callbacks: {
label: (context) => {
const flow = data.flows[context.dataIndex];
const formattedValue = formatCurrency(flow.original_amount, flow.currency);
return [
`De: ${flow.from_node}`,
`Para: ${flow.to_node}`,
`Valor: ${flow.currency.prefix}${formattedValue}${flow.currency.suffix}`,
`Porcentagem: ${flow.percentage.toFixed(2)}%`
];
// Define colors for each node based on its type
const colors = {};
data.nodes.forEach(node => {
if (node.id.startsWith('income_')) {
colors[node.id] = '#4dde80'; // Green for income
} else if (node.id.startsWith('expense_')) {
colors[node.id] = '#f87171'; // Red for expenses
} else {
colors[node.id] = '#fbb700'; // Primary for others
}
});
// Color getter functions
const getColor = (nodeId) => colors[nodeId];
const getHover = (nodeId) => colors[nodeId];
// Format data for Chart.js
const chartData = {
datasets: [{
data: data.flows.map(flow => ({
from: flow.from_node,
to: flow.to_node,
flow: flow.flow
})),
labels: labels,
colorFrom: (c) => getColor(c.dataset.data[c.dataIndex].from),
colorTo: (c) => getColor(c.dataset.data[c.dataIndex].to),
hoverColorFrom: (c) => getHover(c.dataset.data[c.dataIndex].from),
hoverColorTo: (c) => getHover(c.dataset.data[c.dataIndex].to),
colorMode: 'gradient',
alpha: 0.5,
size: 'max',
color: "white"
}]
};
const config = {
type: 'sankey',
data: chartData,
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
tooltip: {
callbacks: {
label: (context) => {
const flow = data.flows[context.dataIndex];
const fromNode = data.nodes.find(n => n.id === flow.from_node);
const toNode = data.nodes.find(n => n.id === flow.to_node);
const formattedValue = formatCurrency(flow.original_amount, flow.currency);
return [
`{% trans 'From' %}: ${fromNode.name}`,
`{% trans 'To' %}: ${toNode.name}`,
`{% trans 'Amount' %}: ${flow.currency.prefix}${formattedValue}${flow.currency.suffix}`,
`{% trans 'Percentage' %}: ${flow.percentage.toFixed(2)}%`
];
}
}
},
legend: {
display: false
},
title: {
display: false,
}
},
legend: {
display: false
},
title: {
display: true,
text: 'Fluxo de Transações',
font: {
size: 16
}
}
},
layout: {
padding: {
top: 20,
right: 20,
bottom: 20,
left: 20
}
}
};
// Destroy existing chart if it exists
const existingChart = Chart.getChart(chartId);
if (existingChart) {
existingChart.destroy();
}
};
// Destroy existing chart if it exists
const existingChart = Chart.getChart(chartId);
if (existingChart) {
existingChart.destroy();
// Create new chart
var chart = new Chart(
document.getElementById(chartId),
config
);
window.addEventListener('resize', () => {
chart.resize();
});
document.addEventListener('fullscreenchange', function () {
console.log('oi');
chart.resize();
});
}
// Create new chart
return new Chart(
document.getElementById(chartId),
config
);
}
// Usage in Django template or JavaScript file
document.addEventListener('DOMContentLoaded', function() {
// Assuming you have the Sankey data in a variable called sankeyData
// For Django template:
const sankeyData = {{ sankey_data|safe }};
console.log(sankeyData);
const chart = setupSankeyChart(sankeyData);
// Optional: Handle resize
window.addEventListener('resize', () => {
chart.resize();
});
});
</script>
{% endblock %}
+83 -12
View File
@@ -1,23 +1,94 @@
{% extends "layouts/base.html" %}
{% load crispy_forms_tags %}
{% load i18n %}
{% block content %}
<div class="container-fluid">
<div class="row mx-3 mt-3">
<div class="card shadow w-auto">
<div class="card-body">
<div class="btn-group" role="group" aria-label="Basic radio toggle button group" _="on change log 'oi'">
<input type="radio" class="btn-check" name="btnradio" id="btnradio1" autocomplete="off" checked>
<label class="btn btn-sm btn-outline-primary" for="btnradio1">{% translate 'Month' %}</label>
<div class="container-fluid">
<div class="row my-3">
<div class="col-lg-2 col-md-3 mb-3 mb-md-0">
<div class="">
<div class="mb-2 w-100 d-lg-inline-flex d-grid gap-2 flex-wrap justify-content-lg-center" role="group"
_="on change
set type to event.target.value
add .tw-hidden to <#picker-form > div:not(.tw-hidden)/>
<input type="radio" class="btn-check" name="btnradio" id="btnradio2" autocomplete="off">
<label class="btn btn-sm btn-outline-primary" for="btnradio2">{% translate 'Year' %}</label>
if type == 'month'
remove .tw-hidden from #month-form
end
if type == 'year'
remove .tw-hidden from #year-form
end
if type == 'month-range'
remove .tw-hidden from #month-range-form
end
if type == 'year-range'
remove .tw-hidden from #year-range-form
end
if type == 'date-range'
remove .tw-hidden from #date-range-form
end
then trigger updated"
id="picker-type">
<input type="radio" class="btn-check" name="type" value="month" id="monthradio" autocomplete="off" checked>
<label class="btn btn-sm btn-outline-primary flex-grow-1" for="monthradio">{% translate 'Month' %}</label>
<input type="radio" class="btn-check" name="btnradio" id="btnradio3" autocomplete="off">
<label class="btn btn-sm btn-outline-primary" for="btnradio3">{% translate 'Range' %}</label>
<input type="radio" class="btn-check" name="type" value="year" id="yearradio" autocomplete="off">
<label class="btn btn-sm btn-outline-primary flex-grow-1" for="yearradio">{% translate 'Year' %}</label>
<input type="radio" class="btn-check" name="type" value="month-range" id="monthrangeradio" autocomplete="off">
<label class="btn btn-sm btn-outline-primary flex-grow-1" for="monthrangeradio">{% translate 'Month Range' %}</label>
<input type="radio" class="btn-check" name="type" value="year-range" id="yearrangeradio" autocomplete="off">
<label class="btn btn-sm btn-outline-primary flex-grow-1" for="yearrangeradio">{% translate 'Year Range' %}</label>
<input type="radio" class="btn-check" name="type" value="date-range" id="daterangeradio" autocomplete="off">
<label class="btn btn-sm btn-outline-primary flex-grow-1" for="daterangeradio">{% translate 'Date Range' %}</label>
</div>
<form id="picker-form"
_="install init_datepicker
on change trigger updated">
<div id="month-form" class="">
{% crispy month_form %}
</div>
<div id="year-form" class="tw-hidden">
{% crispy year_form %}
</div>
<div id="month-range-form" class="tw-hidden">
{% crispy month_range_form %}
</div>
<div id="year-range-form" class="tw-hidden">
{% crispy year_range_form %}
</div>
<div id="date-range-form" class="tw-hidden">
{% crispy date_range_form %}
</div>
</form>
</div>
<hr class="mt-0">
<div class="nav flex-column nav-pills" id="v-pills-tab" role="tablist"
aria-orientation="vertical">
<button class="nav-link" id="v-pills-tab" data-bs-toggle="pill" data-bs-target="#v-pills-content"
type="button" role="tab" aria-controls="v-pills-content" aria-selected="false"
hx-get="{% url 'insights_sankey_by_account' %}" hx-include="#picker-form, #picker-type"
hx-indicator="#tab-content"
hx-target="#tab-content">{% trans 'Account Flow' %}
</button>
<button class="nav-link" id="v-pills-tab" data-bs-toggle="pill" data-bs-target="#v-pills-content"
type="button" role="tab" aria-controls="v-pills-content" aria-selected="false"
hx-get="{% url 'insights_sankey_by_currency' %}"
hx-include="#picker-form, #picker-type"
hx-indicator="#tab-content"
hx-target="#tab-content">{% trans 'Currency Flow' %}
</button>
</div>
</div>
<div class="col-md-9 col-lg-10">
<div class="tab-content w-100" id="v-pills-tabContent">
<div class="tab-pane fade" id="v-pills-content" role="tabpanel" tabindex="0">
<div id="tab-content" class="show-loading"></div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}