diff --git a/lib/mv/statistics.ex b/lib/mv/statistics.ex index e11606e..b3a9a50 100644 --- a/lib/mv/statistics.ex +++ b/lib/mv/statistics.ex @@ -121,9 +121,14 @@ defmodule Mv.Statistics do MembershipFeeCycle |> Ash.Query.filter(expr(cycle_start >= ^first_day and cycle_start <= ^last_day)) - opts_with_domain = Keyword.put(opts, :domain, MembershipFees) + query = maybe_filter_by_fee_type(query, opts) + # Only pass actor and domain to Ash.read; fee_type_id is only for our filter above + opts_for_read = + opts + |> Keyword.drop([:fee_type_id]) + |> Keyword.put(:domain, MembershipFees) - case Ash.read(query, opts_with_domain) do + case Ash.read(query, opts_for_read) do {:ok, cycles} -> cycle_totals_from_cycles(cycles) {:error, _} -> zero_cycle_totals() end @@ -158,6 +163,24 @@ defmodule Mv.Statistics do } end + defp maybe_filter_by_fee_type(query, opts) do + case Keyword.get(opts, :fee_type_id) do + nil -> + query + + id when is_binary(id) -> + # Only apply filter for valid UUID strings (e.g. from form/URL) + if Ecto.UUID.cast(id) != :error do + Ash.Query.filter(query, expr(membership_fee_type_id == ^id)) + else + query + end + + id -> + Ash.Query.filter(query, expr(membership_fee_type_id == ^id)) + end + end + @doc """ Returns the sum of amount for all cycles with status :unpaid. """ @@ -167,9 +190,14 @@ defmodule Mv.Statistics do MembershipFeeCycle |> Ash.Query.filter(expr(status == :unpaid)) - opts_with_domain = Keyword.put(opts, :domain, MembershipFees) + query = maybe_filter_by_fee_type(query, opts) - case Ash.read(query, opts_with_domain) do + opts_for_read = + opts + |> Keyword.drop([:fee_type_id]) + |> Keyword.put(:domain, MembershipFees) + + case Ash.read(query, opts_for_read) do {:ok, cycles} -> Enum.reduce(cycles, Decimal.new(0), fn c, acc -> Decimal.add(acc, c.amount) end) diff --git a/lib/mv_web/live/statistics_live.ex b/lib/mv_web/live/statistics_live.ex index 5fcbc13..edd416b 100644 --- a/lib/mv_web/live/statistics_live.ex +++ b/lib/mv_web/live/statistics_live.ex @@ -8,23 +8,49 @@ defmodule MvWeb.StatisticsLive do import MvWeb.LiveHelpers, only: [current_actor: 1] alias Mv.Statistics + alias Mv.MembershipFees.MembershipFeeType alias MvWeb.Helpers.MembershipFeeHelpers @impl true def mount(_params, _session, socket) do socket = socket - |> load_statistics() |> assign(:page_title, gettext("Statistics")) + |> assign(:selected_fee_type_id, nil) + |> load_fee_types() + |> load_statistics() {:ok, socket} end @impl true - def handle_params(_params, _uri, socket) do - {:noreply, load_statistics(socket)} + def handle_params(params, uri, socket) do + # Query params: after push_patch, params may not include query string in some cases; + # always derive from URI as well so fee_type_id is reliable. + uri_query = if uri, do: URI.decode_query(URI.parse(uri).query || ""), else: %{} + fee_type_id = params["fee_type_id"] || uri_query["fee_type_id"] + fee_type_id = normalize_fee_type_id(fee_type_id) + + socket = + socket + |> assign(:selected_fee_type_id, fee_type_id) + |> load_statistics() + + {:noreply, socket} end + defp normalize_fee_type_id(nil), do: nil + defp normalize_fee_type_id(""), do: nil + + defp normalize_fee_type_id(id) when is_binary(id) do + case String.trim(id) do + "" -> nil + trimmed -> trimmed + end + end + + defp normalize_fee_type_id(_), do: nil + @impl true def render(assigns) do ~H""" @@ -34,94 +60,104 @@ defmodule MvWeb.StatisticsLive do <:subtitle>{gettext("Overview from first membership to today")} -
-
-
-

- {gettext("Active members")} -

-

": " <> to_string(@active_count)} - > - {@active_count} -

+
+

{gettext("Members")}

+
+
+
+

+ {gettext("Active members")} +

+

": " <> to_string(@active_count)} + > + {@active_count} +

+
+
+
+
+

+ {gettext("Inactive members")} +

+

": " <> to_string(@inactive_count)} + > + {@inactive_count} +

+
-
-

- {gettext("Inactive members")} -

-

": " <> to_string(@inactive_count)} - > - {@inactive_count} +

+

{gettext("Member numbers by year")}

+

+ {gettext("From %{first} to %{last} (relevant years with membership data)", + first: @years |> List.last() |> to_string(), + last: @years |> List.first() |> to_string() + )}

+ <.member_numbers_table joins_exits_by_year={@joins_exits_by_year} />
-
-
-

- {gettext("Open amount")} -

-

": " <> MembershipFeeHelpers.format_currency(@open_amount_total)} - > - {MembershipFeeHelpers.format_currency(@open_amount_total)} -

-
-
-
- -
-
-

- {gettext("Joins and exits by year")} -

-

- {gettext("From %{first} to %{last} (relevant years with membership data)", - first: @years |> List.last() |> to_string(), - last: @years |> List.first() |> to_string() - )} -

- <.joins_exits_bars joins_exits_by_year={@joins_exits_by_year} /> -
-
-
-

- {gettext("Contributions by year")} -

-
-
- <.contributions_bars_by_year - contributions_by_year={@contributions_by_year} - totals_over_all_years={@totals_over_all_years} - /> -
-
-

{gettext("All years combined (pie)")}

- <.contributions_pie cycle_totals={@totals_over_all_years} /> -

- - {gettext("Paid")} - - - {gettext("Unpaid")} - - - {gettext("Suspended")} -

+
+

+ {gettext("Contributions")} +

+
+
+
+ + +
+
+
+
+
+

{gettext("Contributions by year")}

+
+
+ <.contributions_bars_by_year + contributions_by_year={@contributions_by_year} + totals_over_all_years={@totals_over_all_years} + /> +
+
+

{gettext("All years combined (pie)")}

+ <.contributions_pie cycle_totals={@totals_over_all_years} /> +

+ + {gettext("Paid")} + + + {gettext("Unpaid")} + + + {gettext("Suspended")} +

+
@@ -130,70 +166,117 @@ defmodule MvWeb.StatisticsLive do """ end + @impl true + def handle_event("change_fee_type", %{"fee_type_id" => ""}, socket) do + {:noreply, push_patch(socket, to: ~p"/statistics")} + end + + def handle_event("change_fee_type", %{"fee_type_id" => id}, socket) do + {:noreply, push_patch(socket, to: ~p"/statistics?fee_type_id=#{id}")} + end + attr :joins_exits_by_year, :list, required: true - defp joins_exits_bars(assigns) do - join_values = Enum.map(assigns.joins_exits_by_year, & &1.joins) - exit_values = Enum.map(assigns.joins_exits_by_year, & &1.exits) - max_joins = max((join_values != [] && Enum.max(join_values)) || 0, 1) - max_exits = max((exit_values != [] && Enum.max(exit_values)) || 0, 1) + defp member_numbers_table(assigns) do + rows = assigns.joins_exits_by_year + total_activity = Enum.map(rows, fn r -> r.joins + r.exits end) + max_total = (total_activity != [] && Enum.max(total_activity)) || 1 - assigns = assign(assigns, :max_joins, max_joins) - assigns = assign(assigns, :max_exits, max_exits) + rows_with_pct = + Enum.map(rows, fn row -> + sum = row.joins + row.exits + + bar_pct = + if max_total > 0 and sum > 0, do: Float.round(sum / max_total * 100, 1), else: 0 + + seg_scale = max(sum, 1) + joins_pct = row.joins / seg_scale * 100 + exits_pct = row.exits / seg_scale * 100 + + %{ + year: row.year, + joins: row.joins, + exits: row.exits, + bar_pct: bar_pct, + joins_pct: Float.round(joins_pct, 1), + exits_pct: Float.round(exits_pct, 1) + } + end) + + assigns = assign(assigns, :rows, rows_with_pct) ~H"""