mirror of
https://github.com/we-promise/sure
synced 2026-04-25 17:15:07 +02:00
Reports print functionality (#622)
* Print initial impl * Try to keep the bigger section together * /* Tufte-inspired Print Report Styles */ * styling * I8n * Move print styling out. * FIX unrelated test ordering on line 53 - import.rows.first doesn't guarantee ordering. Without an explicit ORDER BY, the database may return rows in any order. * Update print-report.css * Update print.html.erb * pass data to view * Update index.html.erb * Fix ERB helpers * Update reports_helper.rb
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
|
||||
@import "./google-sign-in.css";
|
||||
@import "./date-picker-dark-mode.css";
|
||||
@import "./print-report.css";
|
||||
|
||||
@layer components {
|
||||
.pcr-app{
|
||||
|
||||
296
app/assets/tailwind/print-report.css
Normal file
296
app/assets/tailwind/print-report.css
Normal file
@@ -0,0 +1,296 @@
|
||||
/*
|
||||
Print Report Styles
|
||||
Tufte-inspired styling for the printable financial report.
|
||||
Uses design system tokens where applicable.
|
||||
*/
|
||||
|
||||
/* Print Body & Container */
|
||||
.print-body {
|
||||
background: var(--color-white);
|
||||
color: var(--color-gray-900);
|
||||
font-family: var(--font-sans);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.print-container {
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 24px;
|
||||
}
|
||||
|
||||
.tufte-report {
|
||||
font-size: 11px;
|
||||
color: var(--color-gray-900);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.tufte-header {
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 2px solid var(--color-gray-900);
|
||||
}
|
||||
|
||||
.tufte-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 4px 0;
|
||||
color: var(--color-gray-900);
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.tufte-period {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-gray-600);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.tufte-meta {
|
||||
font-size: 10px;
|
||||
color: var(--color-gray-500);
|
||||
margin: 8px 0 0 0;
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
.tufte-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.tufte-section-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: var(--color-gray-900);
|
||||
margin: 0 0 12px 0;
|
||||
border-bottom: 1px solid var(--color-gray-200);
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.tufte-subsection {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
margin: 16px 0 8px 0;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid var(--color-gray-100);
|
||||
}
|
||||
|
||||
/* Metric Cards */
|
||||
.tufte-metric-card {
|
||||
display: inline-block;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.tufte-metric-card-main {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tufte-metric-card-label {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--color-gray-500);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.tufte-metric-card-value {
|
||||
display: block;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.tufte-metric-card-change {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
margin-top: 4px;
|
||||
padding: 1px 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.tufte-metric-card-sm .tufte-metric-card-value {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.tufte-metric-card-sm .tufte-metric-card-label {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
/* Metric Row (horizontal layout) */
|
||||
.tufte-metric-row {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Semantic Colors */
|
||||
.tufte-income { color: var(--color-green-700); }
|
||||
.tufte-expense { color: var(--color-red-700); }
|
||||
.tufte-muted { color: var(--color-gray-500); font-size: 10px; }
|
||||
.tufte-up { color: var(--color-green-700); }
|
||||
.tufte-down { color: var(--color-red-700); }
|
||||
|
||||
/* Two Column Layout */
|
||||
.tufte-two-col {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 32px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* Tables - Clean, readable style */
|
||||
.tufte-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.tufte-table thead th {
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--color-gray-600);
|
||||
padding: 8px 12px 8px 0;
|
||||
border-bottom: 2px solid var(--color-gray-900);
|
||||
}
|
||||
|
||||
.tufte-table tbody td {
|
||||
padding: 6px 12px 6px 0;
|
||||
border-bottom: 1px solid var(--color-gray-200);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.tufte-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.tufte-table tfoot td {
|
||||
padding: 8px 12px 6px 0;
|
||||
border-top: 2px solid var(--color-gray-900);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tufte-table.tufte-compact thead th {
|
||||
padding: 6px 8px 6px 0;
|
||||
}
|
||||
|
||||
.tufte-table.tufte-compact tbody td {
|
||||
padding: 5px 8px 5px 0;
|
||||
}
|
||||
|
||||
.tufte-right {
|
||||
text-align: right;
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
|
||||
.tufte-highlight {
|
||||
background: var(--color-yellow-100);
|
||||
}
|
||||
|
||||
.tufte-highlight td:first-child {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Category Dots */
|
||||
.tufte-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Footnotes */
|
||||
.tufte-footnote {
|
||||
font-size: 10px;
|
||||
color: var(--color-gray-500);
|
||||
margin-top: 8px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.tufte-footer {
|
||||
margin-top: 32px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--color-gray-200);
|
||||
font-size: 10px;
|
||||
color: var(--color-gray-500);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Print-specific overrides */
|
||||
@media print {
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 15mm 18mm;
|
||||
}
|
||||
|
||||
/* Scoped to .print-body to avoid affecting other pages when printing */
|
||||
.print-body {
|
||||
font-size: 10px;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
|
||||
.print-container {
|
||||
max-width: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tufte-section {
|
||||
page-break-inside: auto;
|
||||
}
|
||||
|
||||
.tufte-section-title {
|
||||
page-break-after: avoid;
|
||||
}
|
||||
|
||||
.tufte-table {
|
||||
page-break-inside: auto;
|
||||
}
|
||||
|
||||
.tufte-table thead {
|
||||
display: table-header-group;
|
||||
}
|
||||
|
||||
.tufte-table tr {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.tufte-two-col {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.tufte-keep-together {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.tufte-header {
|
||||
page-break-after: avoid;
|
||||
}
|
||||
|
||||
/* Force colors in print */
|
||||
.tufte-income { color: var(--color-green-700) !important; }
|
||||
.tufte-expense { color: var(--color-red-700) !important; }
|
||||
.tufte-up { color: var(--color-green-700) !important; }
|
||||
.tufte-down { color: var(--color-red-700) !important; }
|
||||
|
||||
.tufte-footer {
|
||||
page-break-before: avoid;
|
||||
}
|
||||
|
||||
.tufte-highlight {
|
||||
background: var(--color-yellow-100) !important;
|
||||
}
|
||||
}
|
||||
@@ -7,38 +7,7 @@ class ReportsController < ApplicationController
|
||||
before_action :authenticate_for_export, only: :export_transactions
|
||||
|
||||
def index
|
||||
@period_type = params[:period_type]&.to_sym || :monthly
|
||||
@start_date = parse_date_param(:start_date) || default_start_date
|
||||
@end_date = parse_date_param(:end_date) || default_end_date
|
||||
|
||||
# Validate and fix date range if end_date is before start_date
|
||||
validate_and_fix_date_range(show_flash: true)
|
||||
|
||||
# Build the period
|
||||
@period = Period.custom(start_date: @start_date, end_date: @end_date)
|
||||
@previous_period = build_previous_period
|
||||
|
||||
# Get aggregated data
|
||||
@current_income_totals = Current.family.income_statement.income_totals(period: @period)
|
||||
@current_expense_totals = Current.family.income_statement.expense_totals(period: @period)
|
||||
|
||||
@previous_income_totals = Current.family.income_statement.income_totals(period: @previous_period)
|
||||
@previous_expense_totals = Current.family.income_statement.expense_totals(period: @previous_period)
|
||||
|
||||
# Calculate summary metrics
|
||||
@summary_metrics = build_summary_metrics
|
||||
|
||||
# Build trend data (last 6 months)
|
||||
@trends_data = build_trends_data
|
||||
|
||||
# Net worth metrics
|
||||
@net_worth_metrics = build_net_worth_metrics
|
||||
|
||||
# Transactions breakdown
|
||||
@transactions = build_transactions_breakdown
|
||||
|
||||
# Investment metrics (must be before build_reports_sections)
|
||||
@investment_metrics = build_investment_metrics
|
||||
setup_report_data(show_flash: true)
|
||||
|
||||
# Build reports sections for collapsible/reorderable UI
|
||||
@reports_sections = build_reports_sections
|
||||
@@ -46,6 +15,12 @@ class ReportsController < ApplicationController
|
||||
@breadcrumbs = [ [ "Home", root_path ], [ "Reports", nil ] ]
|
||||
end
|
||||
|
||||
def print
|
||||
setup_report_data(show_flash: false)
|
||||
|
||||
render layout: "print"
|
||||
end
|
||||
|
||||
def update_preferences
|
||||
if Current.user.update_reports_preferences(preferences_params)
|
||||
head :ok
|
||||
@@ -114,6 +89,44 @@ class ReportsController < ApplicationController
|
||||
end
|
||||
|
||||
private
|
||||
def setup_report_data(show_flash: false)
|
||||
@period_type = params[:period_type]&.to_sym || :monthly
|
||||
@start_date = parse_date_param(:start_date) || default_start_date
|
||||
@end_date = parse_date_param(:end_date) || default_end_date
|
||||
|
||||
# Validate and fix date range if end_date is before start_date
|
||||
validate_and_fix_date_range(show_flash: show_flash)
|
||||
|
||||
# Build the period
|
||||
@period = Period.custom(start_date: @start_date, end_date: @end_date)
|
||||
@previous_period = build_previous_period
|
||||
|
||||
# Get aggregated data
|
||||
@current_income_totals = Current.family.income_statement.income_totals(period: @period)
|
||||
@current_expense_totals = Current.family.income_statement.expense_totals(period: @period)
|
||||
|
||||
@previous_income_totals = Current.family.income_statement.income_totals(period: @previous_period)
|
||||
@previous_expense_totals = Current.family.income_statement.expense_totals(period: @previous_period)
|
||||
|
||||
# Calculate summary metrics
|
||||
@summary_metrics = build_summary_metrics
|
||||
|
||||
# Build trend data (last 6 months)
|
||||
@trends_data = build_trends_data
|
||||
|
||||
# Net worth metrics
|
||||
@net_worth_metrics = build_net_worth_metrics
|
||||
|
||||
# Transactions breakdown
|
||||
@transactions = build_transactions_breakdown
|
||||
|
||||
# Investment metrics
|
||||
@investment_metrics = build_investment_metrics
|
||||
|
||||
# Flags for view rendering
|
||||
@has_accounts = Current.family.accounts.any?
|
||||
end
|
||||
|
||||
def preferences_params
|
||||
prefs = params.require(:preferences)
|
||||
{}.tap do |permitted|
|
||||
|
||||
34
app/helpers/reports_helper.rb
Normal file
34
app/helpers/reports_helper.rb
Normal file
@@ -0,0 +1,34 @@
|
||||
module ReportsHelper
|
||||
# Generate SVG polyline points for a sparkline chart
|
||||
# Returns empty string if fewer than 2 data points (can't draw a line with 1 point)
|
||||
def sparkline_points(values, width: 60, height: 16)
|
||||
return "" if values.nil? || values.length < 2 || values.all? { |v| v.nil? || v.zero? }
|
||||
|
||||
nums = values.map(&:to_f)
|
||||
max_val = nums.max
|
||||
min_val = nums.min
|
||||
range = max_val - min_val
|
||||
range = 1.0 if range.zero?
|
||||
|
||||
points = nums.each_with_index.map do |val, i|
|
||||
x = (i.to_f / [ nums.length - 1, 1 ].max) * width
|
||||
y = height - ((val - min_val) / range * (height - 2)) - 1
|
||||
"#{x.round(1)},#{y.round(1)}"
|
||||
end
|
||||
|
||||
points.join(" ")
|
||||
end
|
||||
|
||||
# Calculate cumulative net values from trends data
|
||||
def cumulative_net_values(trends)
|
||||
return [] if trends.nil?
|
||||
|
||||
running = 0
|
||||
trends.map { |t| running += t[:net].to_i; running }
|
||||
end
|
||||
|
||||
# Check if trends data has enough points for sparklines (need at least 2)
|
||||
def has_sparkline_data?(trends_data)
|
||||
trends_data&.length.to_i >= 2
|
||||
end
|
||||
end
|
||||
28
app/views/layouts/print.html.erb
Normal file
28
app/views/layouts/print.html.erb
Normal file
@@ -0,0 +1,28 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="font-sans">
|
||||
<head>
|
||||
<title><%= content_for(:title) || t("reports.print.document_title") %></title>
|
||||
|
||||
<%= csrf_meta_tags %>
|
||||
<%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %>
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
</head>
|
||||
|
||||
<body class="bg-white text-gray-900 antialiased print-body">
|
||||
<div class="print-container">
|
||||
<%= yield %>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Auto-trigger print dialog when page loads
|
||||
window.onload = function() {
|
||||
// Small delay to ensure styles are loaded
|
||||
setTimeout(function() {
|
||||
window.print();
|
||||
}, 500);
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -17,37 +17,49 @@
|
||||
<% end %>
|
||||
|
||||
<%# Period Navigation Tabs %>
|
||||
<div class="flex items-center gap-2 overflow-x-auto pb-2">
|
||||
<%= render DS::Link.new(
|
||||
text: t("reports.index.periods.monthly"),
|
||||
variant: @period_type == :monthly ? "secondary" : "ghost",
|
||||
href: reports_path(period_type: :monthly),
|
||||
size: :sm
|
||||
) %>
|
||||
<%= render DS::Link.new(
|
||||
text: t("reports.index.periods.quarterly"),
|
||||
variant: @period_type == :quarterly ? "secondary" : "ghost",
|
||||
href: reports_path(period_type: :quarterly),
|
||||
size: :sm
|
||||
) %>
|
||||
<%= render DS::Link.new(
|
||||
text: t("reports.index.periods.ytd"),
|
||||
variant: @period_type == :ytd ? "secondary" : "ghost",
|
||||
href: reports_path(period_type: :ytd),
|
||||
size: :sm
|
||||
) %>
|
||||
<%= render DS::Link.new(
|
||||
text: t("reports.index.periods.last_6_months"),
|
||||
variant: @period_type == :last_6_months ? "secondary" : "ghost",
|
||||
href: reports_path(period_type: :last_6_months),
|
||||
size: :sm
|
||||
) %>
|
||||
<%= render DS::Link.new(
|
||||
text: t("reports.index.periods.custom"),
|
||||
variant: @period_type == :custom ? "secondary" : "ghost",
|
||||
href: reports_path(period_type: :custom),
|
||||
size: :sm
|
||||
) %>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-2 overflow-x-auto pb-2">
|
||||
<%= render DS::Link.new(
|
||||
text: t("reports.index.periods.monthly"),
|
||||
variant: @period_type == :monthly ? "secondary" : "ghost",
|
||||
href: reports_path(period_type: :monthly),
|
||||
size: :sm
|
||||
) %>
|
||||
<%= render DS::Link.new(
|
||||
text: t("reports.index.periods.quarterly"),
|
||||
variant: @period_type == :quarterly ? "secondary" : "ghost",
|
||||
href: reports_path(period_type: :quarterly),
|
||||
size: :sm
|
||||
) %>
|
||||
<%= render DS::Link.new(
|
||||
text: t("reports.index.periods.ytd"),
|
||||
variant: @period_type == :ytd ? "secondary" : "ghost",
|
||||
href: reports_path(period_type: :ytd),
|
||||
size: :sm
|
||||
) %>
|
||||
<%= render DS::Link.new(
|
||||
text: t("reports.index.periods.last_6_months"),
|
||||
variant: @period_type == :last_6_months ? "secondary" : "ghost",
|
||||
href: reports_path(period_type: :last_6_months),
|
||||
size: :sm
|
||||
) %>
|
||||
<%= render DS::Link.new(
|
||||
text: t("reports.index.periods.custom"),
|
||||
variant: @period_type == :custom ? "secondary" : "ghost",
|
||||
href: reports_path(period_type: :custom),
|
||||
size: :sm
|
||||
) %>
|
||||
</div>
|
||||
|
||||
<%# Print Report Button %>
|
||||
<%= link_to print_reports_path(period_type: @period_type, start_date: @start_date, end_date: @end_date),
|
||||
target: "_blank",
|
||||
rel: "noopener",
|
||||
aria: { label: t("reports.index.print_report") },
|
||||
class: "inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-secondary bg-surface-inset hover:bg-surface-hover rounded-lg transition-colors flex-shrink-0" do %>
|
||||
<%= icon("printer", size: "sm") %>
|
||||
<span class="hidden sm:inline"><%= t("reports.index.print_report") %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%# Custom Date Range Picker (only shown when custom is selected) %>
|
||||
|
||||
345
app/views/reports/print.html.erb
Normal file
345
app/views/reports/print.html.erb
Normal file
@@ -0,0 +1,345 @@
|
||||
<% content_for :title do %>
|
||||
<%= t("reports.print.document_title") %> - <%= @start_date.strftime("%B %d, %Y") %> to <%= @end_date.strftime("%B %d, %Y") %>
|
||||
<% end %>
|
||||
|
||||
<div class="tufte-report">
|
||||
<%# Header %>
|
||||
<header class="tufte-header">
|
||||
<h1 class="tufte-title"><%= t("reports.print.title") %></h1>
|
||||
<span class="tufte-period"><%= @start_date.strftime("%B %d, %Y") %> – <%= @end_date.strftime("%B %d, %Y") %></span>
|
||||
<p class="tufte-meta"><%= Current.family.name %> · <%= t("reports.print.generated_on", date: Time.current.strftime("%B %d, %Y")) %></p>
|
||||
</header>
|
||||
|
||||
<%# Summary %>
|
||||
<section class="tufte-section">
|
||||
<h2 class="tufte-section-title"><%= t("reports.print.summary.title") %></h2>
|
||||
<div class="tufte-metric-row">
|
||||
<div class="tufte-metric-card tufte-metric-card-sm">
|
||||
<span class="tufte-metric-card-label"><%= t("reports.print.summary.income") %></span>
|
||||
<span class="tufte-metric-card-value tufte-income"><%= @summary_metrics[:current_income].format %></span>
|
||||
<% if @summary_metrics[:income_change] && @summary_metrics[:income_change] != 0 %>
|
||||
<span class="tufte-metric-card-change <%= @summary_metrics[:income_change] >= 0 ? "tufte-up" : "tufte-down" %>">
|
||||
<%= t("reports.print.summary.vs_prior", percent: (@summary_metrics[:income_change] >= 0 ? "+#{@summary_metrics[:income_change]}" : @summary_metrics[:income_change].to_s)) %>
|
||||
</span>
|
||||
<% end %>
|
||||
<% if has_sparkline_data?(@trends_data) %>
|
||||
<svg width="60" height="20" viewBox="0 0 60 20" style="display:block;margin-top:6px;">
|
||||
<polyline points="<%= sparkline_points(@trends_data.map { |t| t[:income] }, width: 60, height: 20) %>" fill="none" stroke="#047857" stroke-width="1.5" />
|
||||
</svg>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="tufte-metric-card tufte-metric-card-sm">
|
||||
<span class="tufte-metric-card-label"><%= t("reports.print.summary.expenses") %></span>
|
||||
<span class="tufte-metric-card-value tufte-expense"><%= @summary_metrics[:current_expenses].format %></span>
|
||||
<% if @summary_metrics[:expense_change] && @summary_metrics[:expense_change] != 0 %>
|
||||
<span class="tufte-metric-card-change <%= @summary_metrics[:expense_change] >= 0 ? "tufte-down" : "tufte-up" %>">
|
||||
<%= t("reports.print.summary.vs_prior", percent: (@summary_metrics[:expense_change] >= 0 ? "+#{@summary_metrics[:expense_change]}" : @summary_metrics[:expense_change].to_s)) %>
|
||||
</span>
|
||||
<% end %>
|
||||
<% if has_sparkline_data?(@trends_data) %>
|
||||
<svg width="60" height="20" viewBox="0 0 60 20" style="display:block;margin-top:6px;">
|
||||
<polyline points="<%= sparkline_points(@trends_data.map { |t| t[:expenses] }, width: 60, height: 20) %>" fill="none" stroke="#b91c1c" stroke-width="1.5" />
|
||||
</svg>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="tufte-metric-card tufte-metric-card-sm">
|
||||
<span class="tufte-metric-card-label"><%= t("reports.print.summary.net_savings") %></span>
|
||||
<span class="tufte-metric-card-value <%= @summary_metrics[:net_savings] >= 0 ? "tufte-income" : "tufte-expense" %>"><%= @summary_metrics[:net_savings].format %></span>
|
||||
<%
|
||||
# Calculate savings rate
|
||||
savings_rate = @summary_metrics[:current_income].amount > 0 ? ((@summary_metrics[:net_savings].amount / @summary_metrics[:current_income].amount) * 100).round(0) : 0
|
||||
%>
|
||||
<% if savings_rate != 0 %>
|
||||
<span class="tufte-metric-card-change" style="color: #666;"><%= t("reports.print.summary.of_income", percent: savings_rate) %></span>
|
||||
<% end %>
|
||||
<% if has_sparkline_data?(@trends_data) %>
|
||||
<svg width="60" height="20" viewBox="0 0 60 20" style="display:block;margin-top:6px;">
|
||||
<polyline points="<%= sparkline_points(@trends_data.map { |t| t[:net] }, width: 60, height: 20) %>" fill="none" stroke="<%= @summary_metrics[:net_savings] >= 0 ? "#047857" : "#b91c1c" %>" stroke-width="1.5" />
|
||||
</svg>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if @summary_metrics[:budget_percent] %>
|
||||
<div class="tufte-metric-card tufte-metric-card-sm">
|
||||
<span class="tufte-metric-card-label"><%= t("reports.print.summary.budget") %></span>
|
||||
<span class="tufte-metric-card-value"><%= @summary_metrics[:budget_percent] %>%</span>
|
||||
<span class="tufte-metric-card-change" style="color: #666;"><%= t("reports.print.summary.used") %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%# Net Worth %>
|
||||
<% if @has_accounts %>
|
||||
<section class="tufte-section">
|
||||
<h2 class="tufte-section-title"><%= t("reports.print.net_worth.title") %></h2>
|
||||
<div class="tufte-metric-row">
|
||||
<div class="tufte-metric-card">
|
||||
<span class="tufte-metric-card-label"><%= t("reports.print.net_worth.current_balance") %></span>
|
||||
<span class="tufte-metric-card-value <%= @net_worth_metrics[:current_net_worth] >= 0 ? "tufte-income" : "tufte-expense" %>">
|
||||
<%= @net_worth_metrics[:current_net_worth].format %>
|
||||
</span>
|
||||
<% if @net_worth_metrics[:trend] %>
|
||||
<span class="tufte-metric-card-change" style="color: <%= @net_worth_metrics[:trend].color %>">
|
||||
<%= @net_worth_metrics[:trend].value >= 0 ? "+" : "" %><%= @net_worth_metrics[:trend].value.format %> (<%= @net_worth_metrics[:trend].percent_formatted %>) <%= t("reports.print.net_worth.this_period") %>
|
||||
</span>
|
||||
<% end %>
|
||||
<% if has_sparkline_data?(@trends_data) %>
|
||||
<svg width="80" height="24" viewBox="0 0 80 24" style="display:block;margin-top:8px;">
|
||||
<polyline points="<%= sparkline_points(cumulative_net_values(@trends_data), width: 80, height: 24) %>" fill="none" stroke="<%= @net_worth_metrics[:current_net_worth] >= 0 ? "#047857" : "#b91c1c" %>" stroke-width="1.5" />
|
||||
</svg>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tufte-two-col">
|
||||
<div>
|
||||
<h3 class="tufte-subsection"><%= t("reports.print.net_worth.assets") %> <span class="tufte-income"><%= @net_worth_metrics[:total_assets].format %></span></h3>
|
||||
<% if @net_worth_metrics[:asset_groups].any? %>
|
||||
<table class="tufte-table tufte-compact">
|
||||
<tbody>
|
||||
<% @net_worth_metrics[:asset_groups].each do |group| %>
|
||||
<tr>
|
||||
<td><%= group[:name] %></td>
|
||||
<td class="tufte-right"><%= group[:total].format %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% end %>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="tufte-subsection"><%= t("reports.print.net_worth.liabilities") %> <span class="tufte-expense"><%= @net_worth_metrics[:total_liabilities].format %></span></h3>
|
||||
<% if @net_worth_metrics[:liability_groups].any? %>
|
||||
<table class="tufte-table tufte-compact">
|
||||
<tbody>
|
||||
<% @net_worth_metrics[:liability_groups].each do |group| %>
|
||||
<tr>
|
||||
<td><%= group[:name] %></td>
|
||||
<td class="tufte-right"><%= group[:total].format %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% else %>
|
||||
<p class="tufte-muted" style="margin: 0;"><%= t("reports.print.net_worth.no_liabilities") %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<%# Monthly Trends %>
|
||||
<% if has_sparkline_data?(@trends_data) %>
|
||||
<section class="tufte-section">
|
||||
<h2 class="tufte-section-title"><%= t("reports.print.trends.title") %></h2>
|
||||
<table class="tufte-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><%= t("reports.print.trends.month") %></th>
|
||||
<th class="tufte-right"><%= t("reports.print.trends.income") %></th>
|
||||
<th class="tufte-right"><%= t("reports.print.trends.expenses") %></th>
|
||||
<th class="tufte-right"><%= t("reports.print.trends.net") %></th>
|
||||
<th class="tufte-right"><%= t("reports.print.trends.savings_rate") %></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @trends_data.each do |trend| %>
|
||||
<tr class="<%= trend[:is_current_month] ? "tufte-highlight" : "" %>">
|
||||
<td><%= trend[:month] %><%= trend[:is_current_month] ? " *" : "" %></td>
|
||||
<td class="tufte-right tufte-income"><%= Money.new(trend[:income], Current.family.currency).format %></td>
|
||||
<td class="tufte-right tufte-expense"><%= Money.new(trend[:expenses], Current.family.currency).format %></td>
|
||||
<td class="tufte-right <%= trend[:net] >= 0 ? "tufte-income" : "tufte-expense" %>"><%= Money.new(trend[:net], Current.family.currency).format %></td>
|
||||
<td class="tufte-right">
|
||||
<% month_savings_rate = trend[:income] > 0 ? ((trend[:net].to_f / trend[:income].to_f) * 100).round(0) : 0 %>
|
||||
<%= month_savings_rate %>%
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<%
|
||||
total_income = @trends_data.sum { |t| t[:income].to_d }
|
||||
total_expenses = @trends_data.sum { |t| t[:expenses].to_d }
|
||||
total_net = @trends_data.sum { |t| t[:net].to_d }
|
||||
trends_count = @trends_data.length
|
||||
avg_income = trends_count > 0 ? (total_income / trends_count) : 0
|
||||
avg_expenses = trends_count > 0 ? (total_expenses / trends_count) : 0
|
||||
avg_net = trends_count > 0 ? (total_net / trends_count) : 0
|
||||
overall_savings_rate = total_income > 0 ? ((total_net / total_income) * 100).round(0) : 0
|
||||
%>
|
||||
<tr>
|
||||
<td><%= t("reports.print.trends.average") %></td>
|
||||
<td class="tufte-right tufte-income"><%= Money.new(avg_income, Current.family.currency).format %></td>
|
||||
<td class="tufte-right tufte-expense"><%= Money.new(avg_expenses, Current.family.currency).format %></td>
|
||||
<td class="tufte-right <%= avg_net >= 0 ? "tufte-income" : "tufte-expense" %>"><%= Money.new(avg_net, Current.family.currency).format %></td>
|
||||
<td class="tufte-right"><%= overall_savings_rate %>%</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
<% if @trends_data.any? { |t| t[:is_current_month] } %>
|
||||
<p class="tufte-footnote"><%= t("reports.print.trends.current_month_note") %></p>
|
||||
<% end %>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<%# Investments %>
|
||||
<% if @investment_metrics[:has_investments] %>
|
||||
<section class="tufte-section">
|
||||
<h2 class="tufte-section-title"><%= t("reports.print.investments.title") %></h2>
|
||||
<div class="tufte-metric-row">
|
||||
<div class="tufte-metric-card tufte-metric-card-sm">
|
||||
<span class="tufte-metric-card-label"><%= t("reports.print.investments.portfolio_value") %></span>
|
||||
<span class="tufte-metric-card-value"><%= format_money(@investment_metrics[:portfolio_value]) %></span>
|
||||
<% if has_sparkline_data?(@trends_data) %>
|
||||
<svg width="60" height="20" viewBox="0 0 60 20" style="display:block;margin-top:6px;">
|
||||
<polyline points="<%= sparkline_points(cumulative_net_values(@trends_data), width: 60, height: 20) %>" fill="none" stroke="#6366f1" stroke-width="1.5" />
|
||||
</svg>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if @investment_metrics[:unrealized_trend] %>
|
||||
<div class="tufte-metric-card tufte-metric-card-sm">
|
||||
<span class="tufte-metric-card-label"><%= t("reports.print.investments.total_return") %></span>
|
||||
<span class="tufte-metric-card-value" style="color: <%= @investment_metrics[:unrealized_trend].color %>">
|
||||
<%= @investment_metrics[:unrealized_trend].value >= 0 ? "+" : "" %><%= format_money(Money.new(@investment_metrics[:unrealized_trend].value, Current.family.currency)) %>
|
||||
</span>
|
||||
<span class="tufte-metric-card-change" style="color: <%= @investment_metrics[:unrealized_trend].color %>">
|
||||
<%= @investment_metrics[:unrealized_trend].percent_formatted %>
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="tufte-metric-card tufte-metric-card-sm">
|
||||
<span class="tufte-metric-card-label"><%= t("reports.print.investments.contributions") %></span>
|
||||
<span class="tufte-metric-card-value tufte-income"><%= format_money(@investment_metrics[:period_contributions]) %></span>
|
||||
<span class="tufte-metric-card-change" style="color: #666;"><%= t("reports.print.investments.this_period") %></span>
|
||||
</div>
|
||||
<div class="tufte-metric-card tufte-metric-card-sm">
|
||||
<span class="tufte-metric-card-label"><%= t("reports.print.investments.withdrawals") %></span>
|
||||
<span class="tufte-metric-card-value tufte-expense"><%= format_money(@investment_metrics[:period_withdrawals]) %></span>
|
||||
<span class="tufte-metric-card-change" style="color: #666;"><%= t("reports.print.investments.this_period") %></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if @investment_metrics[:top_holdings].any? %>
|
||||
<h3 class="tufte-subsection"><%= t("reports.print.investments.top_holdings") %></h3>
|
||||
<table class="tufte-table tufte-compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><%= t("reports.print.investments.holding") %></th>
|
||||
<th class="tufte-right"><%= t("reports.print.investments.weight") %></th>
|
||||
<th class="tufte-right"><%= t("reports.print.investments.value") %></th>
|
||||
<th class="tufte-right"><%= t("reports.print.investments.return") %></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @investment_metrics[:top_holdings].each do |holding| %>
|
||||
<tr>
|
||||
<td><strong><%= holding.ticker %></strong> <span class="tufte-muted"><%= truncate(holding.name, length: 25) %></span></td>
|
||||
<td class="tufte-right"><%= number_to_percentage(holding.weight || 0, precision: 1) %></td>
|
||||
<td class="tufte-right"><%= format_money(holding.amount_money) %></td>
|
||||
<td class="tufte-right">
|
||||
<% if holding.trend %>
|
||||
<span style="color: <%= holding.trend.color %>"><%= holding.trend.percent_formatted %></span>
|
||||
<% else %>
|
||||
<span class="tufte-muted">—</span>
|
||||
<% end %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% end %>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<%# Spending by Category %>
|
||||
<% if @transactions.any? %>
|
||||
<section class="tufte-section tufte-keep-together">
|
||||
<h2 class="tufte-section-title"><%= t("reports.print.spending.title") %></h2>
|
||||
<%
|
||||
income_groups = @transactions.select { |g| g[:type] == "income" }
|
||||
expense_groups = @transactions.select { |g| g[:type] == "expense" }
|
||||
income_total = income_groups.sum { |g| g[:total] }
|
||||
expense_total = expense_groups.sum { |g| g[:total] }
|
||||
%>
|
||||
|
||||
<div class="tufte-two-col">
|
||||
<% if income_groups.any? %>
|
||||
<div>
|
||||
<h3 class="tufte-subsection"><%= t("reports.print.spending.income") %> <span class="tufte-income"><%= Money.new(income_total, Current.family.currency).format %></span></h3>
|
||||
<table class="tufte-table tufte-compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><%= t("reports.print.spending.category") %></th>
|
||||
<th class="tufte-right"><%= t("reports.print.spending.amount") %></th>
|
||||
<th class="tufte-right"><%= t("reports.print.spending.percent") %></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% income_groups.first(8).each do |group| %>
|
||||
<% percentage = income_total.zero? ? 0 : (group[:total].to_f / income_total * 100).round(0) %>
|
||||
<tr>
|
||||
<td>
|
||||
<span class="tufte-dot" style="background: <%= group[:category_color] %>"></span>
|
||||
<%= group[:category_name] %>
|
||||
</td>
|
||||
<td class="tufte-right"><%= Money.new(group[:total], Current.family.currency).format %></td>
|
||||
<td class="tufte-right tufte-muted"><%= percentage %>%</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
<% if income_groups.length > 8 %>
|
||||
<tr>
|
||||
<td class="tufte-muted"><%= t("reports.print.spending.more_categories", count: income_groups.length - 8) %></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if expense_groups.any? %>
|
||||
<div>
|
||||
<h3 class="tufte-subsection"><%= t("reports.print.spending.expenses") %> <span class="tufte-expense"><%= Money.new(expense_total, Current.family.currency).format %></span></h3>
|
||||
<table class="tufte-table tufte-compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><%= t("reports.print.spending.category") %></th>
|
||||
<th class="tufte-right"><%= t("reports.print.spending.amount") %></th>
|
||||
<th class="tufte-right"><%= t("reports.print.spending.percent") %></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% expense_groups.first(8).each do |group| %>
|
||||
<% percentage = expense_total.zero? ? 0 : (group[:total].to_f / expense_total * 100).round(0) %>
|
||||
<tr>
|
||||
<td>
|
||||
<span class="tufte-dot" style="background: <%= group[:category_color] %>"></span>
|
||||
<%= group[:category_name] %>
|
||||
</td>
|
||||
<td class="tufte-right"><%= Money.new(group[:total], Current.family.currency).format %></td>
|
||||
<td class="tufte-right tufte-muted"><%= percentage %>%</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
<% if expense_groups.length > 8 %>
|
||||
<tr>
|
||||
<td class="tufte-muted"><%= t("reports.print.spending.more_categories", count: expense_groups.length - 8) %></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<footer class="tufte-footer">
|
||||
<%= product_name %> · <%= @start_date.strftime("%B %Y") %> – <%= @end_date.strftime("%B %Y") %>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -5,6 +5,7 @@ en:
|
||||
title: Reports
|
||||
subtitle: Comprehensive insights into your financial health
|
||||
export: Export CSV
|
||||
print_report: Print Report
|
||||
drag_to_reorder: "Drag to reorder section"
|
||||
toggle_section: "Toggle section visibility"
|
||||
periods:
|
||||
@@ -149,3 +150,57 @@ en:
|
||||
open_sheets: Open Google Sheets
|
||||
go_to_api_keys: Go to API Keys
|
||||
close: Got it
|
||||
print:
|
||||
document_title: Financial Report
|
||||
title: Financial Report
|
||||
generated_on: "Generated %{date}"
|
||||
# Summary section
|
||||
summary:
|
||||
title: Summary
|
||||
income: Income
|
||||
expenses: Expenses
|
||||
net_savings: Net Savings
|
||||
budget: Budget
|
||||
vs_prior: "%{percent}% vs prior"
|
||||
of_income: "%{percent}% of income"
|
||||
used: used
|
||||
# Net Worth section
|
||||
net_worth:
|
||||
title: Net Worth
|
||||
current_balance: Current Balance
|
||||
this_period: this period
|
||||
assets: Assets
|
||||
liabilities: Liabilities
|
||||
no_liabilities: No liabilities
|
||||
# Monthly Trends section
|
||||
trends:
|
||||
title: Monthly Trends
|
||||
month: Month
|
||||
income: Income
|
||||
expenses: Expenses
|
||||
net: Net
|
||||
savings_rate: Savings Rate
|
||||
average: Average
|
||||
current_month_note: "* Current month (partial data)"
|
||||
# Investments section
|
||||
investments:
|
||||
title: Investments
|
||||
portfolio_value: Portfolio Value
|
||||
total_return: Total Return
|
||||
contributions: Contributions
|
||||
withdrawals: Withdrawals
|
||||
this_period: this period
|
||||
top_holdings: Top Holdings
|
||||
holding: Holding
|
||||
weight: Weight
|
||||
value: Value
|
||||
return: Return
|
||||
# Spending by Category section
|
||||
spending:
|
||||
title: Spending by Category
|
||||
income: Income
|
||||
expenses: Expenses
|
||||
category: Category
|
||||
amount: Amount
|
||||
percent: "%"
|
||||
more_categories: "+ %{count} more categories"
|
||||
|
||||
@@ -135,6 +135,7 @@ Rails.application.routes.draw do
|
||||
patch :update_preferences, on: :collection
|
||||
get :export_transactions, on: :collection
|
||||
get :google_sheets_instructions, on: :collection
|
||||
get :print, on: :collection
|
||||
end
|
||||
|
||||
resources :budgets, only: %i[index show edit update], param: :month_year do
|
||||
|
||||
@@ -50,9 +50,10 @@ class ImportEncodingTest < ActiveSupport::TestCase
|
||||
assert_equal 3, import.rows_count, "Expected 3 data rows"
|
||||
|
||||
# Verify Polish characters were preserved correctly
|
||||
first_row = import.rows.first
|
||||
assert_not_nil first_row, "Expected first row to exist"
|
||||
assert_includes first_row.name, "spożywczy", "Polish characters should be preserved"
|
||||
# Check that any row contains the Polish characters (test is about encoding, not ordering)
|
||||
assert import.rows.any? { |row| row.name&.include?("spożywczy") }, "Polish characters should be preserved"
|
||||
# Also verify other Polish characters from different rows
|
||||
assert import.rows.any? { |row| row.name&.include?("Café") }, "Extended Latin characters should be preserved"
|
||||
end
|
||||
|
||||
test "handles UTF-8 files without modification" do
|
||||
|
||||
Reference in New Issue
Block a user