mirror of
https://github.com/we-promise/sure.git
synced 2026-04-19 20:14:08 +00:00
Implement an outflows section (#220)
This commit is contained in:
@@ -7,10 +7,23 @@ class PagesController < ApplicationController
|
||||
@balance_sheet = Current.family.balance_sheet
|
||||
@accounts = Current.family.accounts.visible.with_attached_logo
|
||||
|
||||
period_param = params[:cashflow_period]
|
||||
@cashflow_period = if period_param.present?
|
||||
# Handle cashflow period
|
||||
cashflow_period_param = params[:cashflow_period]
|
||||
@cashflow_period = if cashflow_period_param.present?
|
||||
begin
|
||||
Period.from_key(period_param)
|
||||
Period.from_key(cashflow_period_param)
|
||||
rescue Period::InvalidKeyError
|
||||
Period.last_30_days
|
||||
end
|
||||
else
|
||||
Period.last_30_days
|
||||
end
|
||||
|
||||
# Handle outflows period
|
||||
outflows_period_param = params[:outflows_period]
|
||||
@outflows_period = if outflows_period_param.present?
|
||||
begin
|
||||
Period.from_key(outflows_period_param)
|
||||
rescue Period::InvalidKeyError
|
||||
Period.last_30_days
|
||||
end
|
||||
@@ -19,10 +32,15 @@ class PagesController < ApplicationController
|
||||
end
|
||||
|
||||
family_currency = Current.family.currency
|
||||
income_totals = Current.family.income_statement.income_totals(period: @cashflow_period)
|
||||
expense_totals = Current.family.income_statement.expense_totals(period: @cashflow_period)
|
||||
|
||||
@cashflow_sankey_data = build_cashflow_sankey_data(income_totals, expense_totals, family_currency)
|
||||
# Get data for cashflow section
|
||||
income_totals = Current.family.income_statement.income_totals(period: @cashflow_period)
|
||||
cashflow_expense_totals = Current.family.income_statement.expense_totals(period: @cashflow_period)
|
||||
@cashflow_sankey_data = build_cashflow_sankey_data(income_totals, cashflow_expense_totals, family_currency)
|
||||
|
||||
# Get data for outflows section (using its own period)
|
||||
outflows_expense_totals = Current.family.income_statement.expense_totals(period: @outflows_period)
|
||||
@outflows_data = build_outflows_donut_data(outflows_expense_totals)
|
||||
|
||||
@breadcrumbs = [ [ "Home", root_path ], [ "Dashboard", nil ] ]
|
||||
end
|
||||
@@ -152,4 +170,26 @@ class PagesController < ApplicationController
|
||||
|
||||
{ nodes: nodes, links: links, currency_symbol: Money::Currency.new(currency_symbol).symbol }
|
||||
end
|
||||
|
||||
def build_outflows_donut_data(expense_totals)
|
||||
currency_symbol = Money::Currency.new(expense_totals.currency).symbol
|
||||
total = expense_totals.total
|
||||
|
||||
# Only include top-level categories with non-zero amounts
|
||||
categories = expense_totals.category_totals
|
||||
.reject { |ct| ct.category.parent_id.present? || ct.total.zero? }
|
||||
.sort_by { |ct| -ct.total }
|
||||
.map do |ct|
|
||||
{
|
||||
id: ct.category.id,
|
||||
name: ct.category.name,
|
||||
amount: ct.total.to_f.round(2),
|
||||
percentage: ct.weight.round(1),
|
||||
color: ct.category.color.presence || Category::UNCATEGORIZED_COLOR,
|
||||
icon: ct.category.lucide_icon
|
||||
}
|
||||
end
|
||||
|
||||
{ categories: categories, total: total.to_f.round(2), currency_symbol: currency_symbol }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -10,10 +10,17 @@ export default class extends Controller {
|
||||
overageSegmentId: { type: String, default: "overage" },
|
||||
segmentHeight: { type: Number, default: 3 },
|
||||
segmentOpacity: { type: Number, default: 1 },
|
||||
extendedHover: { type: Boolean, default: false },
|
||||
hoverExtension: { type: Number, default: 3 },
|
||||
enableClick: { type: Boolean, default: false },
|
||||
startDate: String,
|
||||
endDate: String,
|
||||
};
|
||||
|
||||
#viewBoxSize = 100;
|
||||
#minSegmentAngle = this.segmentHeightValue * 0.01;
|
||||
#minSegmentAngle = 0.02; // Minimum angle in radians (~1.15 degrees)
|
||||
#padAngle = 0.005; // Spacing between segments (~0.29 degrees)
|
||||
#visiblePaths = null;
|
||||
|
||||
connect() {
|
||||
this.#draw();
|
||||
@@ -40,7 +47,7 @@ export default class extends Controller {
|
||||
...s,
|
||||
amount: Math.max(
|
||||
Number(s.amount),
|
||||
totalPieValue * this.#minSegmentAngle,
|
||||
totalPieValue * (this.#minSegmentAngle / (2 * Math.PI)),
|
||||
),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
@@ -58,10 +65,15 @@ export default class extends Controller {
|
||||
};
|
||||
|
||||
#teardown() {
|
||||
d3.select(this.chartContainerTarget).selectAll("*").remove();
|
||||
if (this.hasChartContainerTarget) {
|
||||
d3.select(this.chartContainerTarget).selectAll("*").remove();
|
||||
}
|
||||
this.#visiblePaths = null;
|
||||
}
|
||||
|
||||
#draw() {
|
||||
if (!this.hasChartContainerTarget) return;
|
||||
|
||||
const svg = d3
|
||||
.select(this.chartContainerTarget)
|
||||
.append("svg")
|
||||
@@ -79,37 +91,73 @@ export default class extends Controller {
|
||||
.innerRadius(this.#viewBoxSize / 2 - this.segmentHeightValue)
|
||||
.outerRadius(this.#viewBoxSize / 2)
|
||||
.cornerRadius(this.segmentHeightValue)
|
||||
.padAngle(this.#minSegmentAngle);
|
||||
.padAngle(this.#padAngle);
|
||||
|
||||
const segmentArcs = svg
|
||||
const g = svg
|
||||
.append("g")
|
||||
.attr(
|
||||
"transform",
|
||||
`translate(${this.#viewBoxSize / 2}, ${this.#viewBoxSize / 2})`,
|
||||
)
|
||||
);
|
||||
|
||||
const segmentGroups = g
|
||||
.selectAll("arc")
|
||||
.data(pie(this.#data))
|
||||
.enter()
|
||||
.append("g")
|
||||
.attr("class", "arc pointer-events-auto")
|
||||
.attr("class", "arc pointer-events-auto");
|
||||
|
||||
// Add invisible hover paths with extended area if enabled
|
||||
if (this.extendedHoverValue) {
|
||||
const hoverArc = d3
|
||||
.arc()
|
||||
.innerRadius(this.#viewBoxSize / 2 - this.segmentHeightValue - this.hoverExtensionValue)
|
||||
.outerRadius(this.#viewBoxSize / 2 + this.hoverExtensionValue)
|
||||
.padAngle(this.#padAngle);
|
||||
|
||||
segmentGroups
|
||||
.append("path")
|
||||
.attr("class", "hover-path")
|
||||
.attr("d", hoverArc)
|
||||
.attr("fill", "transparent")
|
||||
.attr("data-segment-id", (d) => d.data.id)
|
||||
.style("pointer-events", "all");
|
||||
}
|
||||
|
||||
// Add visible paths
|
||||
const segmentArcs = segmentGroups
|
||||
.append("path")
|
||||
.attr("class", "visible-path")
|
||||
.attr("data-segment-id", (d) => d.data.id)
|
||||
.attr("data-original-color", this.#transformRingColor)
|
||||
.attr("fill", this.#transformRingColor)
|
||||
.attr("d", mainArc);
|
||||
|
||||
// Disable pointer events on visible paths if extended hover is enabled
|
||||
if (this.extendedHoverValue) {
|
||||
segmentArcs.style("pointer-events", "none");
|
||||
}
|
||||
|
||||
// Cache the visible paths selection for performance
|
||||
this.#visiblePaths = d3.select(this.chartContainerTarget).selectAll("path.visible-path");
|
||||
|
||||
// Ensures that user can click on default content without triggering hover on a segment if that is their intent
|
||||
let hoverTimeout = null;
|
||||
|
||||
segmentArcs
|
||||
segmentGroups
|
||||
.on("mouseover", (event) => {
|
||||
hoverTimeout = setTimeout(() => {
|
||||
this.#clearSegmentHover();
|
||||
this.#handleSegmentHover(event);
|
||||
}, 150);
|
||||
}, 10);
|
||||
})
|
||||
.on("mouseleave", () => {
|
||||
clearTimeout(hoverTimeout);
|
||||
})
|
||||
.on("click", (event, d) => {
|
||||
if (this.enableClickValue) {
|
||||
this.#handleClick(d.data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -131,19 +179,20 @@ export default class extends Controller {
|
||||
|
||||
if (!template) return;
|
||||
|
||||
d3.select(this.chartContainerTarget)
|
||||
.selectAll("path")
|
||||
.attr("fill", function () {
|
||||
if (this.dataset.segmentId === segmentId) {
|
||||
if (this.dataset.segmentId === unusedSegmentId) {
|
||||
return "var(--budget-unused-fill)";
|
||||
}
|
||||
// Use cached selection if available for better performance
|
||||
const paths = this.#visiblePaths || d3.select(this.chartContainerTarget).selectAll("path.visible-path");
|
||||
|
||||
return this.dataset.originalColor;
|
||||
paths.attr("fill", function () {
|
||||
if (this.dataset.segmentId === segmentId) {
|
||||
if (this.dataset.segmentId === unusedSegmentId) {
|
||||
return "var(--budget-unused-fill)";
|
||||
}
|
||||
|
||||
return "var(--budget-unallocated-fill)";
|
||||
});
|
||||
return this.dataset.originalColor;
|
||||
}
|
||||
|
||||
return "var(--budget-unallocated-fill)";
|
||||
});
|
||||
|
||||
this.defaultContentTarget.classList.add("hidden");
|
||||
template.classList.remove("hidden");
|
||||
@@ -153,11 +202,14 @@ export default class extends Controller {
|
||||
#clearSegmentHover = () => {
|
||||
this.defaultContentTarget.classList.remove("hidden");
|
||||
|
||||
d3.select(this.chartContainerTarget)
|
||||
.selectAll("path")
|
||||
// Use cached selection if available for better performance
|
||||
const paths = this.#visiblePaths || d3.select(this.chartContainerTarget).selectAll("path.visible-path");
|
||||
|
||||
paths
|
||||
.attr("fill", function () {
|
||||
return this.dataset.originalColor;
|
||||
});
|
||||
})
|
||||
.style("opacity", null); // Clear inline opacity style
|
||||
|
||||
for (const child of this.contentContainerTarget.children) {
|
||||
if (child !== this.defaultContentTarget) {
|
||||
@@ -165,4 +217,35 @@ export default class extends Controller {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handles click on segment (optional, controlled by enableClick value)
|
||||
#handleClick(segment) {
|
||||
if (!segment.name || !this.startDateValue || !this.endDateValue) return;
|
||||
|
||||
const segmentName = encodeURIComponent(segment.name);
|
||||
const startDate = this.startDateValue;
|
||||
const endDate = this.endDateValue;
|
||||
|
||||
const url = `/transactions?q[categories][]=${segmentName}&q[start_date]=${startDate}&q[end_date]=${endDate}`;
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
// Public methods for external highlighting (e.g., from category list hover)
|
||||
highlightSegment(event) {
|
||||
const segmentId = event.currentTarget.dataset.categoryId;
|
||||
|
||||
// Use cached selection if available for better performance
|
||||
const paths = this.#visiblePaths || d3.select(this.chartContainerTarget).selectAll("path.visible-path");
|
||||
|
||||
paths.style("opacity", function() {
|
||||
return this.dataset.segmentId === segmentId ? 1 : 0.3;
|
||||
});
|
||||
}
|
||||
|
||||
unhighlightSegment() {
|
||||
// Use cached selection if available for better performance
|
||||
const paths = this.#visiblePaths || d3.select(this.chartContainerTarget).selectAll("path.visible-path");
|
||||
|
||||
paths.style("opacity", null); // Clear inline opacity style
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
data-theme="<%= theme %>"
|
||||
data-controller="theme"
|
||||
data-theme-user-preference-value="<%= Current.user&.theme || "system" %>"
|
||||
class="h-full text-primary overflow-hidden lg:overflow-auto font-sans <%= @os %>">
|
||||
class="h-full text-primary overflow-hidden font-sans <%= @os %>">
|
||||
<head>
|
||||
<%= render "layouts/shared/head" %>
|
||||
<%= yield :head %>
|
||||
</head>
|
||||
|
||||
<body class="h-full overflow-hidden lg:overflow-auto antialiased">
|
||||
<body class="h-full overflow-hidden antialiased">
|
||||
<% if Rails.env.development? %>
|
||||
<button hidden data-controller="hotkey" data-hotkey="t t /" data-action="theme#toggle"></button>
|
||||
<% end %>
|
||||
|
||||
@@ -34,6 +34,17 @@
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<% if @outflows_data[:categories].present? %>
|
||||
<%= turbo_frame_tag "outflows_donut_section" do %>
|
||||
<section class="bg-container py-4 rounded-xl shadow-border-xs mb-6">
|
||||
<%= render partial: "pages/dashboard/outflows_donut", locals: {
|
||||
outflows_data: @outflows_data,
|
||||
period: @outflows_period
|
||||
} %>
|
||||
</section>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<section class="bg-container py-4 rounded-xl shadow-border-xs">
|
||||
<%= render partial: "pages/dashboard/net_worth_chart", locals: {
|
||||
balance_sheet: @balance_sheet,
|
||||
|
||||
87
app/views/pages/dashboard/_outflows_donut.html.erb
Normal file
87
app/views/pages/dashboard/_outflows_donut.html.erb
Normal file
@@ -0,0 +1,87 @@
|
||||
<%# locals: (outflows_data:, period:) %>
|
||||
<div id="outflows-donut-section">
|
||||
<div class="flex justify-between items-center gap-4 px-4 mb-4">
|
||||
<h2 class="text-lg font-medium inline-flex items-center gap-1.5">
|
||||
Outflows
|
||||
</h2>
|
||||
|
||||
<%= form_with url: root_path, method: :get, data: { controller: "auto-submit-form", turbo_frame: "outflows_donut_section" } do |form| %>
|
||||
<%= form.select :outflows_period,
|
||||
Period.as_options,
|
||||
{ selected: period.key },
|
||||
data: { "auto-submit-form-target": "auto" },
|
||||
class: "bg-container border border-secondary font-medium rounded-lg px-3 py-2 text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0" %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="px-4">
|
||||
<div class="flex flex-col lg:flex-row gap-8 items-center">
|
||||
<!-- Donut Chart -->
|
||||
<div class="w-full lg:w-1/3 max-w-[300px]">
|
||||
<div class="h-[300px] relative"
|
||||
data-controller="donut-chart"
|
||||
data-donut-chart-segments-value="<%= outflows_data[:categories].to_json %>"
|
||||
data-donut-chart-segment-height-value="5"
|
||||
data-donut-chart-segment-opacity-value="0.9"
|
||||
data-donut-chart-extended-hover-value="true"
|
||||
data-donut-chart-hover-extension-value="3"
|
||||
data-donut-chart-enable-click-value="true"
|
||||
data-donut-chart-start-date-value="<%= period.date_range.first %>"
|
||||
data-donut-chart-end-date-value="<%= period.date_range.last %>">
|
||||
<div data-donut-chart-target="chartContainer" class="absolute inset-0 pointer-events-none"></div>
|
||||
|
||||
<div data-donut-chart-target="contentContainer" class="flex justify-center items-center h-full">
|
||||
<div data-donut-chart-target="defaultContent" class="flex flex-col items-center">
|
||||
<div class="text-secondary text-sm mb-2">
|
||||
<span>Total Outflows</span>
|
||||
</div>
|
||||
|
||||
<div class="text-3xl font-medium text-primary">
|
||||
<%= outflows_data[:currency_symbol] %><%= number_with_delimiter(outflows_data[:total], delimiter: ',') %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% outflows_data[:categories].each do |category| %>
|
||||
<div id="segment_<%= category[:id] %>" class="hidden">
|
||||
<div class="flex flex-col gap-2 items-center">
|
||||
<p class="text-sm text-secondary"><%= category[:name] %></p>
|
||||
|
||||
<p class="text-3xl font-medium text-primary">
|
||||
<%= outflows_data[:currency_symbol] %><%= number_with_delimiter(category[:amount], delimiter: ',') %>
|
||||
</p>
|
||||
|
||||
<p class="text-sm text-secondary"><%= category[:percentage] %>%</p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category List -->
|
||||
<div class="w-full lg:w-2/3">
|
||||
<div class="space-y-2">
|
||||
<% outflows_data[:categories].each do |category| %>
|
||||
<%= link_to transactions_path(q: { categories: [category[:name]], start_date: period.date_range.first, end_date: period.date_range.last }),
|
||||
class: "flex items-center justify-between p-3 rounded-lg hover:bg-container-inset transition-colors cursor-pointer group",
|
||||
data: {
|
||||
turbo_frame: "_top",
|
||||
category_id: category[:id],
|
||||
action: "mouseenter->donut-chart#highlightSegment mouseleave->donut-chart#unhighlightSegment"
|
||||
} do %>
|
||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div class="w-3 h-3 rounded-full flex-shrink-0" style="background-color: <%= category[:color] %>"></div>
|
||||
<%= icon(category[:icon], class: "w-4 h-4 text-primary flex-shrink-0") %>
|
||||
<span class="text-sm font-medium text-primary truncate"><%= category[:name] %></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 flex-shrink-0">
|
||||
<span class="text-sm font-medium text-primary whitespace-nowrap"><%= outflows_data[:currency_symbol] %><%= number_with_delimiter(category[:amount], delimiter: ',') %></span>
|
||||
<span class="text-sm text-secondary whitespace-nowrap"><%= category[:percentage] %>%</span>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user