Compare commits

...

6 commits

Author SHA1 Message Date
99aa8969f0
Update docs and guidelines for statistics feature
All checks were successful
continuous-integration/drone/push Build is passing
- CODE_GUIDELINES.md and feature-roadmap.md
- Add statistics-page-implementation-plan.md
2026-02-10 22:44:36 +01:00
76b8d9e30e
Update gettext: extract and add DE/EN for statistics strings 2026-02-10 22:44:28 +01:00
82b9ef282f
Pass actor through CycleGenerator so seeds can use admin
- get_actor(opts): use opts[:actor] or system actor
- load_member, do_generate_cycles, create_cycles pass opts
- Seeds pass admin_user_with_role for Ash.load! and cycle updates
2026-02-10 22:44:24 +01:00
9ac275203c
Add StatisticsLive: overview, bars by year, pie chart
- Summary cards: active/inactive members, open amount
- Joins and exits by year (horizontal bars)
- Contributions by year: table with stacked bar above amounts
- Column order: Paid, Unpaid, Suspended, Total; color dots for legend
- All years combined pie chart
- LiveView tests
2026-02-10 22:44:19 +01:00
cc59a40a1b
Add statistics route, permissions, and sidebar entry
- /statistics route and PagePaths.statistics
- Permission sets: viewer and admin can access /statistics
- Sidebar link with can_access_page check
- Plug and sidebar tests updated
2026-02-10 22:44:13 +01:00
b26d66aa93
Add Statistics module for member and cycle aggregates
- first_join_year, active/inactive counts, joins/exits by year
- cycle_totals_by_year, open_amount_total
- Unit tests for Statistics
2026-02-10 22:44:07 +01:00
19 changed files with 1473 additions and 65 deletions

View file

@ -118,7 +118,8 @@ lib/
│ ├── mailer.ex # Email mailer
│ ├── release.ex # Release tasks
│ ├── repo.ex # Database repository
│ └── secrets.ex # Secret management
│ ├── secrets.ex # Secret management
│ └── statistics.ex # Reporting: member/cycle aggregates (counts, sums by year)
├── mv_web/ # Web interface layer
│ ├── components/ # UI components
│ │ ├── core_components.ex
@ -155,6 +156,7 @@ lib/
│ │ ├── import_export_live.ex # CSV import/export LiveView (mount/events/glue only)
│ │ ├── import_export_live/ # Import/Export UI components
│ │ │ └── components.ex # custom_fields_notice, template_links, import_form, progress, results
│ │ ├── statistics_live.ex # Statistics page (aggregates, year filter, joins/exits by year)
│ │ └── contribution_type_live/ # Contribution types (mock-up)
│ ├── auth_overrides.ex # AshAuthentication overrides
│ ├── endpoint.ex # Phoenix endpoint

View file

@ -291,10 +291,10 @@
#### 10. **Reporting & Analytics** 📊
**Current State:**
- ❌ No reporting features
- **Statistics page (MVP)** `/statistics` with active/inactive member counts, joins/exits by year, cycle totals, open amount (2026-02-10)
**Missing Features:**
- ❌ Member statistics dashboard
- ❌ Extended member statistics dashboard
- ❌ Membership growth charts
- ❌ Payment reports
- ❌ Custom report builder

View file

@ -26,6 +26,7 @@ This document lists all protected routes, which permission set may access them,
| `/groups/new` | ✗ | ✗ | ✗ | ✓ |
| `/groups/:slug` | ✗ | ✓ | ✓ | ✓ |
| `/groups/:slug/edit` | ✗ | ✗ | ✗ | ✓ |
| `/statistics` | ✗ | ✓ | ✓ | ✓ |
| `/admin/roles` | ✗ | ✗ | ✗ | ✓ |
| `/admin/roles/new` | ✗ | ✗ | ✗ | ✓ |
| `/admin/roles/:id` | ✗ | ✗ | ✗ | ✓ |

View file

@ -0,0 +1,163 @@
# Statistics Page Implementation Plan
**Project:** Mila Membership Management System
**Feature:** Statistics page at `/statistics`
**Scope:** MVP only (no export, no optional extensions)
**Last updated:** 2026-02-10
---
## Decisions (from clarification)
| Topic | Decision |
|-------|----------|
| Route | `/statistics` |
| Navigation | Top-level menu (next to Members, Fee Types) |
| Permission | read_only, normal_user, admin (same as member list) |
| Charts | HTML/CSS and SVG only (no Contex, no Chart.js) |
| MVP scope | Minimal: active/inactive, joins/exits per year, contribution sums per year, open amount |
| Open amount | Total unpaid only (no overdue vs. not-yet-due split in MVP) |
Excluded from this plan: Export (CSV/PDF), caching, month/quarter filters, “members per fee type”, “members per group”, and overdue split.
---
## 1. Statistics module (`Mv.Statistics`)
**Goal:** Central module for all statistics; LiveView only calls this API. Uses Ash reads with actor so policies apply.
**Location:** `lib/mv/statistics.ex` (new).
**Functions to implement:**
| Function | Purpose | Data source |
|----------|---------|-------------|
| `active_member_count(opts)` | Count members with `exit_date == nil` | `Member` read with filter |
| `inactive_member_count(opts)` | Count members with `exit_date != nil` | `Member` read with filter |
| `joins_by_year(year, opts)` | Count members with `join_date` in given year | `Member` read, filter by year, count |
| `exits_by_year(year, opts)` | Count members with `exit_date` in given year | `Member` read, filter by year, count |
| `cycle_totals_by_year(year, opts)` | For cycles with `cycle_start` in year: total sum, and sums/counts by status (paid, unpaid, suspended) | `MembershipFeeCycle` read (filter by year via `cycle_start`), aggregate sum(amount) and count per status in Elixir or via Ash aggregates |
| `open_amount_total(opts)` | Sum of `amount` for all cycles with `status == :unpaid` | `MembershipFeeCycle` read with filter `status == :unpaid`, sum(amount) |
All functions accept `opts` (keyword list) and pass `actor: opts[:actor]` (and `domain:` where needed) to Ash calls. No new resources; only read actions on existing `Member` and `MembershipFeeCycle`.
**Implementation notes:**
- Use `Ash.Query.filter(Member, expr(...))` for date filters; for “year”, filter `join_date >= first_day_of_year` and `join_date <= last_day_of_year` (same for `exit_date` and for `MembershipFeeCycle.cycle_start`).
- For `cycle_totals_by_year`: either multiple Ash reads (one per status) with sum aggregate, or one read of cycles in that year and `Enum.group_by(..., :status)` then sum amounts in Elixir.
- Use `Mv.MembershipFees.CalendarCycles` only if needed for interval (e.g. cycle_end); for “cycle in year” the `cycle_start` year is enough.
**Tests:** Unit tests in `test/mv/statistics_test.exs` for each function (with fixtures: members with join_date/exit_date, cycles with cycle_start/amount/status). Use `Mv.Helpers.SystemActor.get_system_actor()` in tests for Ash read authorization where appropriate.
---
## 2. Route and authorization
**Router** ([lib/mv_web/router.ex](lib/mv_web/router.ex)):
- In the same `ash_authentication_live_session` block where `/members` and `/membership_fee_types` live, add:
- `live "/statistics", StatisticsLive, :index`
**PagePaths** ([lib/mv_web/page_paths.ex](lib/mv_web/page_paths.ex)):
- Add module attribute `@statistics "/statistics"`.
- Add `def statistics, do: @statistics`.
- No change to `@admin_page_paths` (statistics is top-level).
**Page permission** (route matrix is driven by [lib/mv/authorization/permission_sets.ex](lib/mv/authorization/permission_sets.ex)):
- Add `"/statistics"` to the `pages` list of **read_only** (e.g. after `"/groups/:slug"`) and to the `pages` list of **normal_user** (e.g. after groups entries). **admin** already has `"*"` so no change.
- **own_data** must not list `/statistics` (so they cannot access it).
- Update [docs/page-permission-route-coverage.md](docs/page-permission-route-coverage.md): add row for `| /statistics | ✗ | ✓ | ✓ | ✓ |`.
- Add test in `test/mv_web/plugs/check_page_permission_test.exs`: read_only and normal_user and admin can access `/statistics`; own_data cannot.
---
## 3. Sidebar
**File:** [lib/mv_web/components/layouts/sidebar.ex](lib/mv_web/components/layouts/sidebar.ex).
- In `sidebar_menu`, after the “Fee Types” menu item and before the “Administration” block, add a conditional menu item for Statistics:
- `can_access_page?(@current_user, PagePaths.statistics())` → show link.
- `href={~p"/statistics"}`, `icon="hero-chart-bar"` (or similar), `label={gettext("Statistics")}`.
---
## 4. Statistics LiveView
**Module:** `MvWeb.StatisticsLive`
**File:** `lib/mv_web/live/statistics_live.ex`
**Mount:** `:index` only.
**Behaviour:**
- `on_mount`: use `MvWeb.LiveUserAuth, :live_user_required` and ensure role/permission check (same as other protected LiveViews). In `mount` or `handle_params`, set default selected year to current year (e.g. `Date.utc_today().year`).
- **Assigns:** `:year` (integer), `:active_count`, `:inactive_count`, `:joins_this_year`, `:exits_this_year`, `:cycle_totals` (map with keys e.g. `:total`, `:paid`, `:unpaid`, `:suspended` for the selected year), `:open_amount_total`, and any extra needed for the bar data (e.g. list of `%{year: y, joins: j, exits: e}` for a small range of years if you show a minimal bar chart).
- **Year filter:** A single select or dropdown for year (e.g. from “first year with data” to current year). On change, send event (e.g. `"set_year"`) with `%{"year" => year}`; in `handle_event` update `assigns.year` and reload data by calling `Mv.Statistics` again and re-assigning.
**Data loading:**
- In `mount` and whenever year changes, call `Mv.Statistics` with `actor: current_actor(socket)` (and optionally `year: @year` where needed). Assign results to socket. Handle errors (e.g. redirect or flash) if a call fails.
**Layout (sections):**
1. **Page title:** e.g. “Statistics” (gettext).
2. **Year filter:** One control to select year; applies to “joins/exits” and “contribution sums” for that year.
3. **Cards (top row):**
- Active members (count)
- Inactive members (count)
- Joins in selected year
- Exits in selected year
- Open amount total (sum of all unpaid cycles; format with `MvWeb.Helpers.MembershipFeeHelpers.format_currency/1`)
- Optionally: “Paid this year” (from `cycle_totals_by_year` for selected year)
4. **Contributions for selected year:** One section showing for the chosen year: total (Soll), paid, unpaid, suspended (sums and optionally counts). Use simple table or key-value list; no chart required for MVP.
5. **Joins / Exits by year (simple bar chart):** Data: e.g. last 5 or 10 years. For each year, show joins and exits as horizontal bars (HTML/CSS: e.g. `div` with `width: #{percent}%`). Pure HTML/SVG; no external chart library. Use Tailwind/DaisyUI for layout and cards.
**Accessibility:** Semantic HTML; headings (e.g. `h2`) for each section; ensure year filter has a label; format numbers in a screen-reader-friendly way (e.g. no purely visual abbreviations without aria-label).
**i18n:** All user-visible strings via gettext (e.g. “Statistics”, “Active members”, “Inactive members”, “Joins (year)”, “Exits (year)”, “Open amount”, “Contributions for year”, “Total”, “Paid”, “Unpaid”, “Suspended”). Add keys to `priv/gettext` as needed.
---
## 5. Implementation order (tasks)
Execute in this order so that each step is testable:
1. **Statistics module**
- Add `lib/mv/statistics.ex` with the six functions above and `@moduledoc`.
- Add `test/mv/statistics_test.exs` with tests for each function (use fixtures for members and cycles; pass actor in opts).
- Run tests and fix until green.
2. **Route and permission**
- Add `live "/statistics", StatisticsLive, :index` in router.
- Add `statistics/0` and `@statistics` in PagePaths.
- Add `/statistics` to page permission logic so read_only, normal_user, admin are allowed and own_data is denied.
- Update `docs/page-permission-route-coverage.md` and add/update plug tests for `/statistics`.
3. **Sidebar**
- Add Statistics link in sidebar (top-level) with `can_access_page?` and `PagePaths.statistics()`.
4. **StatisticsLive**
- Create `lib/mv_web/live/statistics_live.ex` with mount, assigns, year param, and data loading from `Mv.Statistics`.
- Implement UI: title, year filter, cards, contribution section, simple joins/exits bar (HTML).
- Add gettext keys and use them in the template.
- Optionally add a simple LiveView test (e.g. authenticated user sees statistics page and key labels).
5. **CI and docs**
- Run `just ci-dev` (or project equivalent); fix formatting, Credo, and tests.
- In [docs/feature-roadmap.md](docs/feature-roadmap.md), update “Reporting & Analytics” to reflect that a basic statistics page is implemented (MVP).
- In [CODE_GUIDELINES.md](CODE_GUIDELINES.md), add a short note under a suitable section (e.g. “Reporting” or “LiveView”) that statistics are provided by `Mv.Statistics` and displayed in `StatisticsLive`, if desired.
---
## 6. Out of scope (not in this plan)
- Export (CSV/PDF).
- Caching (ETS/GenServer/HTTP).
- Month or quarter filters.
- “Members per fee type” or “members per group” statistics.
- Overdue vs. not-yet-due split for open amount.
- Contex or Chart.js.
- New database tables or Ash resources.
These can be added later as separate tasks or follow-up plans.

View file

@ -178,7 +178,9 @@ defmodule Mv.Authorization.PermissionSets do
# Groups overview
"/groups",
# Group detail
"/groups/:slug"
"/groups/:slug",
# Statistics
"/statistics"
]
}
end
@ -243,7 +245,9 @@ defmodule Mv.Authorization.PermissionSets do
# Group detail
"/groups/:slug",
# Edit group
"/groups/:slug/edit"
"/groups/:slug/edit",
# Statistics
"/statistics"
]
}
end

View file

@ -87,7 +87,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
def generate_cycles_for_member(member_or_id, opts \\ [])
def generate_cycles_for_member(member_id, opts) when is_binary(member_id) do
case load_member(member_id) do
case load_member(member_id, opts) do
{:ok, member} -> generate_cycles_for_member(member, opts)
{:error, reason} -> {:error, reason}
end
@ -97,25 +97,25 @@ defmodule Mv.MembershipFees.CycleGenerator do
today = Keyword.get(opts, :today, Date.utc_today())
skip_lock? = Keyword.get(opts, :skip_lock?, false)
do_generate_cycles_with_lock(member, today, skip_lock?)
do_generate_cycles_with_lock(member, today, skip_lock?, opts)
end
# Generate cycles with lock handling
# Returns {:ok, cycles, notifications} - notifications are never sent here,
# they should be returned to the caller (e.g., via after_action hook)
defp do_generate_cycles_with_lock(member, today, true = _skip_lock?) do
# Lock already set by caller (e.g., regenerate_cycles_on_type_change)
defp do_generate_cycles_with_lock(member, today, true = _skip_lock?, opts) do
# Lock already set by caller (e.g., regenerate_cycles_on_type_change or seeds)
# Just generate cycles without additional locking
do_generate_cycles(member, today)
do_generate_cycles(member, today, opts)
end
defp do_generate_cycles_with_lock(member, today, false) do
defp do_generate_cycles_with_lock(member, today, false, opts) do
lock_key = :erlang.phash2(member.id)
Repo.transaction(fn ->
Ecto.Adapters.SQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
case do_generate_cycles(member, today) do
case do_generate_cycles(member, today, opts) do
{:ok, cycles, notifications} ->
# Return cycles and notifications - do NOT send notifications here
# They will be sent by the caller (e.g., via after_action hook)
@ -235,25 +235,33 @@ defmodule Mv.MembershipFees.CycleGenerator do
# Private functions
defp load_member(member_id) do
system_actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(system_actor)
# Use actor from opts when provided (e.g. seeds pass admin); otherwise system actor
defp get_actor(opts) do
case Keyword.get(opts, :actor) do
nil -> SystemActor.get_system_actor()
actor -> actor
end
end
defp load_member(member_id, opts) do
actor = get_actor(opts)
read_opts = Helpers.ash_actor_opts(actor)
query =
Member
|> Ash.Query.filter(id == ^member_id)
|> Ash.Query.load([:membership_fee_type, :membership_fee_cycles])
case Ash.read_one(query, opts) do
case Ash.read_one(query, read_opts) do
{:ok, nil} -> {:error, :member_not_found}
{:ok, member} -> {:ok, member}
{:error, reason} -> {:error, reason}
end
end
defp do_generate_cycles(member, today) do
defp do_generate_cycles(member, today, opts) do
# Reload member with relationships to ensure fresh data
case load_member(member.id) do
case load_member(member.id, opts) do
{:ok, member} ->
cond do
is_nil(member.membership_fee_type_id) ->
@ -263,7 +271,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
{:error, :no_join_date}
true ->
generate_missing_cycles(member, today)
generate_missing_cycles(member, today, opts)
end
{:error, reason} ->
@ -271,7 +279,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
end
end
defp generate_missing_cycles(member, today) do
defp generate_missing_cycles(member, today, opts) do
fee_type = member.membership_fee_type
interval = fee_type.interval
amount = fee_type.amount
@ -287,7 +295,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
# Only generate if start_date <= end_date
if start_date && Date.compare(start_date, end_date) != :gt do
cycle_starts = generate_cycle_starts(start_date, end_date, interval)
create_cycles(cycle_starts, member.id, fee_type.id, amount)
create_cycles(cycle_starts, member.id, fee_type.id, amount, opts)
else
{:ok, [], []}
end
@ -382,9 +390,9 @@ defmodule Mv.MembershipFees.CycleGenerator do
end
end
defp create_cycles(cycle_starts, member_id, fee_type_id, amount) do
system_actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(system_actor)
defp create_cycles(cycle_starts, member_id, fee_type_id, amount, opts) do
actor = get_actor(opts)
create_opts = Helpers.ash_actor_opts(actor)
# Always use return_notifications?: true to collect notifications
# Notifications will be returned to the caller, who is responsible for
@ -400,7 +408,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
}
handle_cycle_creation_result(
Ash.create(MembershipFeeCycle, attrs, [return_notifications?: true] ++ opts),
Ash.create(MembershipFeeCycle, attrs, [return_notifications?: true] ++ create_opts),
cycle_start
)
end)

180
lib/mv/statistics.ex Normal file
View file

@ -0,0 +1,180 @@
defmodule Mv.Statistics do
@moduledoc """
Aggregated statistics for members and membership fee cycles.
Used by the statistics LiveView to display counts and sums. All functions
accept an `opts` keyword list and pass `:actor` (and `:domain` where needed)
to Ash reads so that policies are enforced.
"""
require Ash.Query
import Ash.Expr
alias Mv.Membership.Member
alias Mv.MembershipFees
alias Mv.MembershipFees.MembershipFeeCycle
@doc """
Returns the earliest year in which any member has a join_date.
Used to determine the start of the "relevant" year range for statistics
(from first membership to current year). Returns `nil` if no member has
a join_date.
"""
@spec first_join_year(keyword()) :: non_neg_integer() | nil
def first_join_year(opts) do
query =
Member
|> Ash.Query.filter(expr(not is_nil(join_date)))
|> Ash.Query.sort(join_date: :asc)
|> Ash.Query.limit(1)
case Ash.read_one(query, opts) do
{:ok, nil} -> nil
{:ok, member} -> member.join_date.year
{:error, _} -> nil
end
end
@doc """
Returns the count of active members (exit_date is nil).
"""
@spec active_member_count(keyword()) :: non_neg_integer()
def active_member_count(opts) do
query =
Member
|> Ash.Query.filter(expr(is_nil(exit_date)))
case Ash.count(query, opts) do
{:ok, count} -> count
{:error, _} -> 0
end
end
@doc """
Returns the count of inactive members (exit_date is not nil).
"""
@spec inactive_member_count(keyword()) :: non_neg_integer()
def inactive_member_count(opts) do
query =
Member
|> Ash.Query.filter(expr(not is_nil(exit_date)))
case Ash.count(query, opts) do
{:ok, count} -> count
{:error, _} -> 0
end
end
@doc """
Returns the count of members who joined in the given year (join_date in that year).
"""
@spec joins_by_year(integer(), keyword()) :: non_neg_integer()
def joins_by_year(year, opts) do
first_day = Date.new!(year, 1, 1)
last_day = Date.new!(year, 12, 31)
query =
Member
|> Ash.Query.filter(expr(join_date >= ^first_day and join_date <= ^last_day))
case Ash.count(query, opts) do
{:ok, count} -> count
{:error, _} -> 0
end
end
@doc """
Returns the count of members who exited in the given year (exit_date in that year).
"""
@spec exits_by_year(integer(), keyword()) :: non_neg_integer()
def exits_by_year(year, opts) do
first_day = Date.new!(year, 1, 1)
last_day = Date.new!(year, 12, 31)
query =
Member
|> Ash.Query.filter(expr(exit_date >= ^first_day and exit_date <= ^last_day))
case Ash.count(query, opts) do
{:ok, count} -> count
{:error, _} -> 0
end
end
@doc """
Returns totals for membership fee cycles whose cycle_start falls in the given year.
Returns a map with keys: `:total`, `:paid`, `:unpaid`, `:suspended` (each a Decimal sum).
"""
@spec cycle_totals_by_year(integer(), keyword()) :: %{
total: Decimal.t(),
paid: Decimal.t(),
unpaid: Decimal.t(),
suspended: Decimal.t()
}
def cycle_totals_by_year(year, opts) do
first_day = Date.new!(year, 1, 1)
last_day = Date.new!(year, 12, 31)
query =
MembershipFeeCycle
|> Ash.Query.filter(expr(cycle_start >= ^first_day and cycle_start <= ^last_day))
opts_with_domain = Keyword.put(opts, :domain, MembershipFees)
case Ash.read(query, opts_with_domain) do
{:ok, cycles} -> cycle_totals_from_cycles(cycles)
{:error, _} -> zero_cycle_totals()
end
end
defp cycle_totals_from_cycles(cycles) do
by_status = Enum.group_by(cycles, & &1.status)
sum = fn status -> sum_amounts(by_status[status] || []) end
total =
[:paid, :unpaid, :suspended]
|> Enum.map(&sum.(&1))
|> Enum.reduce(Decimal.new(0), &Decimal.add/2)
%{
total: total,
paid: sum.(:paid),
unpaid: sum.(:unpaid),
suspended: sum.(:suspended)
}
end
defp sum_amounts(cycles),
do: Enum.reduce(cycles, Decimal.new(0), fn c, acc -> Decimal.add(acc, c.amount) end)
defp zero_cycle_totals do
%{
total: Decimal.new(0),
paid: Decimal.new(0),
unpaid: Decimal.new(0),
suspended: Decimal.new(0)
}
end
@doc """
Returns the sum of amount for all cycles with status :unpaid.
"""
@spec open_amount_total(keyword()) :: Decimal.t()
def open_amount_total(opts) do
query =
MembershipFeeCycle
|> Ash.Query.filter(expr(status == :unpaid))
opts_with_domain = Keyword.put(opts, :domain, MembershipFees)
case Ash.read(query, opts_with_domain) do
{:ok, cycles} ->
Enum.reduce(cycles, Decimal.new(0), fn c, acc -> Decimal.add(acc, c.amount) end)
{:error, _} ->
Decimal.new(0)
end
end
end

View file

@ -88,6 +88,14 @@ defmodule MvWeb.Layouts.Sidebar do
/>
<% end %>
<%= if can_access_page?(@current_user, PagePaths.statistics()) do %>
<.menu_item
href={~p"/statistics"}
icon="hero-chart-bar"
label={gettext("Statistics")}
/>
<% end %>
<%= if admin_menu_visible?(@current_user) do %>
<.menu_group
icon="hero-cog-6-tooth"

View file

@ -0,0 +1,498 @@
defmodule MvWeb.StatisticsLive do
@moduledoc """
LiveView for the statistics page at /statistics.
Displays aggregated member and membership fee cycle statistics.
"""
use MvWeb, :live_view
import MvWeb.LiveHelpers, only: [current_actor: 1]
alias Mv.Statistics
alias MvWeb.Helpers.MembershipFeeHelpers
@impl true
def mount(_params, _session, socket) do
socket =
socket
|> load_statistics()
|> assign(:page_title, gettext("Statistics"))
{:ok, socket}
end
@impl true
def handle_params(_params, _uri, socket) do
{:noreply, load_statistics(socket)}
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
{gettext("Statistics")}
<:subtitle>{gettext("Overview from first membership to today")}</:subtitle>
</.header>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">
<div class="card bg-base-200 shadow-md border border-base-300">
<div class="card-body p-5">
<h2 class="card-title text-sm font-medium text-base-content/80">
{gettext("Active members")}
</h2>
<p
class="text-3xl font-bold tabular-nums"
aria-label={gettext("Active members") <> ": " <> to_string(@active_count)}
>
{@active_count}
</p>
</div>
</div>
<div class="card bg-base-200 shadow-md border border-base-300">
<div class="card-body p-5">
<h2 class="card-title text-sm font-medium text-base-content/80">
{gettext("Inactive members")}
</h2>
<p
class="text-3xl font-bold tabular-nums"
aria-label={gettext("Inactive members") <> ": " <> to_string(@inactive_count)}
>
{@inactive_count}
</p>
</div>
</div>
<div class="card bg-base-200 shadow-md border border-base-300">
<div class="card-body p-5">
<h2 class="card-title text-sm font-medium text-base-content/80">
{gettext("Open amount")}
</h2>
<p
class="text-3xl font-bold tabular-nums"
aria-label={gettext("Open amount") <> ": " <> MembershipFeeHelpers.format_currency(@open_amount_total)}
>
{MembershipFeeHelpers.format_currency(@open_amount_total)}
</p>
</div>
</div>
</div>
<section
class="card bg-base-200 shadow-md border border-base-300 mb-8"
aria-labelledby="joins-exits-heading"
>
<div class="card-body">
<h2 id="joins-exits-heading" class="card-title text-lg mb-4">
{gettext("Joins and exits by year")}
</h2>
<p class="text-sm text-base-content/70 mb-4">
{gettext("From %{first} to %{last} (relevant years with membership data)",
first: @years |> List.last() |> to_string(),
last: @years |> List.first() |> to_string()
)}
</p>
<.joins_exits_bars joins_exits_by_year={@joins_exits_by_year} />
</div>
</section>
<section
class="card bg-base-200 shadow-md border border-base-300 mb-8"
aria-labelledby="contributions-heading"
>
<div class="card-body">
<h2 id="contributions-heading" class="card-title text-lg mb-4">
{gettext("Contributions by year")}
</h2>
<div class="flex flex-col gap-8 items-start">
<div class="w-full">
<.contributions_bars_by_year
contributions_by_year={@contributions_by_year}
totals_over_all_years={@totals_over_all_years}
/>
</div>
<div class="w-full flex flex-col items-center pt-6 mt-2 border-t border-base-300">
<h3 class="text-sm font-semibold mb-3">{gettext("All years combined (pie)")}</h3>
<.contributions_pie cycle_totals={@totals_over_all_years} />
<p class="text-xs text-base-content/70 mt-2">
<span class="inline-block w-2 h-2 rounded-full bg-success align-middle mr-1"></span>
{gettext("Paid")}
<span class="inline-block w-2 h-2 rounded-full bg-warning align-middle mx-2 mr-1">
</span>
{gettext("Unpaid")}
<span class="inline-block w-2 h-2 rounded-full bg-base-content/20 align-middle mx-2 mr-1">
</span>
{gettext("Suspended")}
</p>
</div>
</div>
</div>
</section>
</Layouts.app>
"""
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)
assigns = assign(assigns, :max_joins, max_joins)
assigns = assign(assigns, :max_exits, max_exits)
~H"""
<div
class="space-y-4"
role="img"
aria-label={gettext("Joins and exits by year as horizontal bar chart")}
>
<div class="flex gap-4 text-sm mb-2">
<span class="flex items-center gap-2">
<span class="inline-block w-4 h-3 rounded bg-success" aria-hidden="true"></span>
{gettext("Joins")}
</span>
<span class="flex items-center gap-2">
<span class="inline-block w-4 h-3 rounded bg-error" aria-hidden="true"></span>
{gettext("Exits")}
</span>
</div>
<div class="space-y-3">
<%= for item <- @joins_exits_by_year do %>
<div class="flex flex-col sm:flex-row sm:items-center gap-2">
<span class="w-12 font-mono text-sm shrink-0" id={"year-#{item.year}"}>{item.year}</span>
<div class="flex-1 min-w-0 space-y-1">
<div class="flex items-center gap-2">
<span class="text-xs w-8 shrink-0">{item.joins}</span>
<div
class="h-6 bg-base-300 rounded overflow-hidden flex min-w-[4rem]"
role="presentation"
>
<div
class="h-full bg-success transition-all min-w-[2px]"
style={"width: #{max(0, min(100, Float.round(item.joins / @max_joins * 100, 1)))}%"}
aria-hidden="true"
>
</div>
</div>
</div>
<div class="flex items-center gap-2">
<span class="text-xs w-8 shrink-0">{item.exits}</span>
<div
class="h-6 bg-base-300 rounded overflow-hidden flex min-w-[4rem]"
role="presentation"
>
<div
class="h-full bg-error transition-all min-w-[2px]"
style={"width: #{max(0, min(100, Float.round(item.exits / @max_exits * 100, 1)))}%"}
aria-hidden="true"
>
</div>
</div>
</div>
</div>
</div>
<% end %>
</div>
</div>
"""
end
attr :contributions_by_year, :list, required: true
attr :totals_over_all_years, :map, required: true
defp contributions_bars_by_year(assigns) do
rows = assigns.contributions_by_year
totals = assigns.totals_over_all_years
all_rows_with_decimals =
Enum.map(rows, fn row ->
%{
year: row.year,
summary: false,
total: row.total,
paid: row.paid,
unpaid: row.unpaid,
suspended: row.suspended
}
end) ++
[
%{
year: nil,
summary: true,
total: totals.total,
paid: totals.paid,
unpaid: totals.unpaid,
suspended: totals.suspended
}
]
max_total = max_decimal(all_rows_with_decimals, :total)
rows_with_pct =
Enum.map(all_rows_with_decimals, fn row ->
bar_pct = bar_pct(row.total, max_total)
sum_positive =
Decimal.add(Decimal.add(row.paid, row.unpaid), row.suspended)
seg_scale =
if Decimal.compare(sum_positive, 0) == :gt, do: sum_positive, else: Decimal.new(1)
paid_pct =
row.paid |> Decimal.div(seg_scale) |> Decimal.mult(100) |> Decimal.to_float()
unpaid_pct =
row.unpaid |> Decimal.div(seg_scale) |> Decimal.mult(100) |> Decimal.to_float()
suspended_pct =
row.suspended |> Decimal.div(seg_scale) |> Decimal.mult(100) |> Decimal.to_float()
%{
year: row.year,
summary: row.summary,
total_formatted: MembershipFeeHelpers.format_currency(row.total),
paid_formatted: MembershipFeeHelpers.format_currency(row.paid),
unpaid_formatted: MembershipFeeHelpers.format_currency(row.unpaid),
suspended_formatted: MembershipFeeHelpers.format_currency(row.suspended),
bar_pct: bar_pct,
paid_pct: paid_pct,
unpaid_pct: unpaid_pct,
suspended_pct: suspended_pct
}
end)
assigns = assign(assigns, :rows, rows_with_pct)
~H"""
<div
class="overflow-x-auto"
role="img"
aria-label={gettext("Contributions by year as table with stacked bars")}
>
<table class="table table-sm w-full">
<thead class="bg-base-300">
<tr>
<th scope="col" class="text-base-content font-semibold w-20">{gettext("Year")}</th>
<th scope="col" class="text-base-content font-semibold">{gettext("Paid")}</th>
<th scope="col" class="text-base-content font-semibold">{gettext("Unpaid")}</th>
<th scope="col" class="text-base-content font-semibold">{gettext("Suspended")}</th>
<th scope="col" class="text-base-content font-semibold">{gettext("Total")}</th>
</tr>
</thead>
<tbody>
<%= for row <- @rows do %>
<tr class={row.summary && "border-t-2 border-base-300 bg-base-300/30"}>
<td
rowspan="2"
class={"font-mono align-middle border-b-0 #{if row.summary, do: "font-semibold", else: ""}"}
>
<%= if row.summary do %>
{gettext("Total")}
<% else %>
{row.year}
<% end %>
</td>
<td colspan="4" class="align-top p-1 pb-0 border-b-0">
<div class="h-6 rounded overflow-hidden bg-base-300 relative min-w-[4rem]">
<div
class="flex h-full absolute left-0 top-0 bottom-0 min-w-0 rounded"
style={"width: #{max(0, Float.round(row.bar_pct, 1))}%"}
>
<div
class="h-full bg-success min-w-0 rounded-l"
style={"width: #{Float.round(row.paid_pct, 1)}%"}
title={gettext("Paid")}
>
</div>
<div
class="h-full bg-warning min-w-0"
style={"width: #{Float.round(row.unpaid_pct, 1)}%"}
title={gettext("Unpaid")}
>
</div>
<div
class="h-full bg-base-content/20 min-w-0 rounded-r"
style={"width: #{Float.round(row.suspended_pct, 1)}%"}
title={gettext("Suspended")}
>
</div>
</div>
</div>
</td>
</tr>
<tr class={row.summary && "bg-base-300/30"}>
<td class="align-middle text-xs font-mono text-base-content whitespace-nowrap pt-0">
<span class="inline-flex items-center gap-1.5">
{row.paid_formatted}
<span
class="inline-block w-2 h-2 rounded-full bg-success shrink-0"
aria-hidden="true"
title={gettext("Paid")}
>
</span>
</span>
</td>
<td class="align-middle text-xs font-mono text-base-content whitespace-nowrap pt-0">
<span class="inline-flex items-center gap-1.5">
{row.unpaid_formatted}
<span
class="inline-block w-2 h-2 rounded-full bg-warning shrink-0"
aria-hidden="true"
title={gettext("Unpaid")}
>
</span>
</span>
</td>
<td class="align-middle text-xs font-mono text-base-content whitespace-nowrap pt-0">
<span class="inline-flex items-center gap-1.5">
{row.suspended_formatted}
<span
class="inline-block w-2 h-2 rounded-full bg-base-content/20 shrink-0"
aria-hidden="true"
title={gettext("Suspended")}
>
</span>
</span>
</td>
<td class="align-middle text-xs font-mono text-base-content whitespace-nowrap pt-0">
{row.total_formatted}
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
"""
end
defp max_decimal(rows, key) do
Enum.reduce(rows, Decimal.new(0), fn row, acc ->
val = Map.get(row, key)
if Decimal.compare(val, acc) == :gt, do: val, else: acc
end)
end
defp bar_pct(value, max) do
scale = if Decimal.compare(max, 0) == :gt, do: max, else: Decimal.new(1)
value |> Decimal.div(scale) |> Decimal.mult(100) |> Decimal.to_float()
end
attr :cycle_totals, :map, required: true
defp contributions_pie(assigns) do
paid = assigns.cycle_totals.paid
unpaid = assigns.cycle_totals.unpaid
suspended = assigns.cycle_totals.suspended
sum_positive = Decimal.add(Decimal.add(paid, unpaid), suspended)
scale = if Decimal.compare(sum_positive, 0) == :gt, do: sum_positive, else: Decimal.new(1)
paid_pct = Decimal.div(paid, scale) |> Decimal.mult(100) |> Decimal.to_float()
unpaid_pct = Decimal.div(unpaid, scale) |> Decimal.mult(100) |> Decimal.to_float()
suspended_pct = Decimal.div(suspended, scale) |> Decimal.mult(100) |> Decimal.to_float()
# Conic gradient: 0deg = top, clockwise. Success (paid), warning (unpaid), base-300 (suspended)
# Use theme CSS variables (--color-*) so the pie renders in all themes
paid_deg = paid_pct * 3.6
unpaid_deg = unpaid_pct * 3.6
gradient_stops =
"var(--color-success) 0deg, var(--color-success) #{paid_deg}deg, var(--color-warning) #{paid_deg}deg, var(--color-warning) #{paid_deg + unpaid_deg}deg, var(--color-base-300) #{paid_deg + unpaid_deg}deg, var(--color-base-300) 360deg"
assigns =
assigns
|> assign(:paid_pct, paid_pct)
|> assign(:unpaid_pct, unpaid_pct)
|> assign(:suspended_pct, suspended_pct)
|> assign(:gradient_stops, gradient_stops)
~H"""
<div
class="w-40 h-40 min-h-[10rem] rounded-full shrink-0 border-2 border-base-300 bg-base-300"
style={"background: conic-gradient(#{@gradient_stops});"}
role="img"
aria-label={
gettext(
"Contributions pie: paid %{paid}%%, unpaid %{unpaid}%%, suspended %{suspended}%%",
paid: Float.round(@paid_pct, 1),
unpaid: Float.round(@unpaid_pct, 1),
suspended: Float.round(@suspended_pct, 1)
)
}
title={"#{gettext("Paid")}: #{Float.round(@paid_pct, 1)}%, #{gettext("Unpaid")}: #{Float.round(@unpaid_pct, 1)}%, #{gettext("Suspended")}: #{Float.round(@suspended_pct, 1)}%"}
>
</div>
"""
end
defp load_statistics(socket) do
actor = current_actor(socket)
opts = [actor: actor]
current_year = Date.utc_today().year
first_year = Statistics.first_join_year(opts) || current_year
years = first_year..current_year |> Enum.to_list() |> Enum.reverse()
active_count = Statistics.active_member_count(opts)
inactive_count = Statistics.inactive_member_count(opts)
open_amount_total = Statistics.open_amount_total(opts)
joins_exits_by_year = build_joins_exits_by_year(years, opts)
contributions_by_year = build_contributions_by_year(years, opts)
totals_over_all_years = sum_cycle_totals(contributions_by_year)
assign(socket,
years: years,
active_count: active_count,
inactive_count: inactive_count,
open_amount_total: open_amount_total,
joins_exits_by_year: joins_exits_by_year,
contributions_by_year: contributions_by_year,
totals_over_all_years: totals_over_all_years
)
end
defp build_joins_exits_by_year(years, opts) do
Enum.map(years, fn y ->
%{
year: y,
joins: Statistics.joins_by_year(y, opts),
exits: Statistics.exits_by_year(y, opts)
}
end)
end
defp build_contributions_by_year(years, opts) do
Enum.map(years, fn y ->
totals = Statistics.cycle_totals_by_year(y, opts)
%{
year: y,
total: totals.total,
paid: totals.paid,
unpaid: totals.unpaid,
suspended: totals.suspended
}
end)
end
defp sum_cycle_totals(contributions_by_year) do
Enum.reduce(
contributions_by_year,
%{
total: Decimal.new(0),
paid: Decimal.new(0),
unpaid: Decimal.new(0),
suspended: Decimal.new(0)
},
fn row, acc ->
%{
total: Decimal.add(acc.total, row.total),
paid: Decimal.add(acc.paid, row.paid),
unpaid: Decimal.add(acc.unpaid, row.unpaid),
suspended: Decimal.add(acc.suspended, row.suspended)
}
end
)
end
end

View file

@ -9,6 +9,7 @@ defmodule MvWeb.PagePaths do
# Sidebar top-level menu paths
@members "/members"
@membership_fee_types "/membership_fee_types"
@statistics "/statistics"
# Administration submenu paths (all must match router)
@users "/users"
@ -31,6 +32,9 @@ defmodule MvWeb.PagePaths do
@doc "Path for Membership Fee Types index (sidebar and page permission check)."
def membership_fee_types, do: @membership_fee_types
@doc "Path for Statistics page (sidebar and page permission check)."
def statistics, do: @statistics
@doc "Paths for Administration menu; show group if user can access any of these."
def admin_menu_paths, do: @admin_page_paths

View file

@ -73,6 +73,9 @@ defmodule MvWeb.Router do
# Membership Fee Types Management
live "/membership_fee_types", MembershipFeeTypeLive.Index, :index
# Statistics
live "/statistics", StatisticsLive, :index
live "/membership_fee_types/new", MembershipFeeTypeLive.Form, :new
live "/membership_fee_types/:id/edit", MembershipFeeTypeLive.Form, :edit

View file

@ -152,6 +152,7 @@ msgstr "Notizen"
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/statistics_live.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format
msgid "Paid"
@ -929,6 +930,7 @@ msgstr "Status"
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/statistics_live.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format
msgid "Suspended"
@ -937,6 +939,7 @@ msgstr "Pausiert"
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/statistics_live.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format
msgid "Unpaid"
@ -2401,22 +2404,118 @@ msgstr "Pausiert"
msgid "unpaid"
msgstr "Unbezahlt"
#~ #: lib/mv_web/live/global_settings_live.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Custom Fields in CSV Import"
#~ msgstr "Benutzerdefinierte Felder"
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Active members"
msgstr "Aktive Mitglieder"
#~ #: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Exits"
msgstr "Austritte"
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Inactive members"
msgstr "Inaktive Mitglieder"
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Joins"
msgstr "Eintritte"
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Joins and exits by year"
msgstr "Eintritte und Austritte nach Jahr"
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Open amount"
msgstr "Offener Betrag"
#: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Statistics"
msgstr "Statistik"
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Total"
msgstr "Gesamt"
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Year"
msgstr "Jahr"
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Contributions breakdown: paid %{paid}%%, unpaid %{unpaid}%%, suspended %{suspended}%%"
msgstr "Beitragsaufteilung: bezahlt %{paid}%, unbezahlt %{unpaid}%, pausiert %{suspended}%"
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Contributions pie: paid %{paid}%%, unpaid %{unpaid}%%, suspended %{suspended}%%"
msgstr "Beiträge Kreis: bezahlt %{paid}%, unbezahlt %{unpaid}%, pausiert %{suspended}%"
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Joins and exits by year as horizontal bar chart"
msgstr "Eintritte und Austritte nach Jahr"
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "All years combined (bar)"
msgstr "Alle Jahre zusammengefasst (Balken)"
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "All years combined (pie)"
msgstr "Alle Jahre zusammengefasst (Kreis)"
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Contributions by year"
msgstr "Beiträge nach Jahr"
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "From %{first} to %{last} (relevant years with membership data)"
msgstr "Von %{first} bis %{last} (relevante Jahre mit Mitgliedsdaten)"
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Overview from first membership to today"
msgstr "Übersicht vom ersten Eintritt bis heute"
#~ #: lib/mv_web/live/statistics_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Failed to prepare CSV import: %{error}"
#~ msgstr "Das Vorbereiten des CSV Imports ist gescheitert: %{error}"
#~ msgid "By amount (bar)"
#~ msgstr "Nach Betrag (Balken)"
#~ #: lib/mv_web/live/global_settings_live.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Individual data fields must be created in Mila before importing. Use the field name as the CSV column header. Unknown custom field columns will be ignored with a warning."
#~ msgstr "Individuelle Datenfelder müssen in Mila erstellt werden, bevor sie importiert werden können. Verwende den Namen des Datenfeldes als CSV-Spaltenüberschrift. Unbekannte Spaltenüberschriften werden mit einer Warnung ignoriert."
#~ #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#~ #: lib/mv_web/live/statistics_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Only administrators can regenerate cycles"
#~ msgstr "Nur Administrator*innen können Zyklen regenerieren"
#~ msgid "By amount (pie)"
#~ msgstr "Nach Betrag (Kreis)"
#~ #: lib/mv_web/live/statistics_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Exits (year)"
#~ msgstr "Austritte (Jahr)"
#~ #: lib/mv_web/live/statistics_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Joins (year)"
#~ msgstr "Eintritte (Jahr)"
#~ #: lib/mv_web/live/statistics_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Member and contribution overview"
#~ msgstr "Übersicht Mitglieder und Beiträge"
#~ #: lib/mv_web/live/statistics_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Paid this year"
#~ msgstr "Dieses Jahr bezahlt"

View file

@ -153,6 +153,7 @@ msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/statistics_live.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format
msgid "Paid"
@ -930,6 +931,7 @@ msgstr ""
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/statistics_live.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format
msgid "Suspended"
@ -938,6 +940,7 @@ msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/statistics_live.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format
msgid "Unpaid"
@ -2401,3 +2404,84 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "unpaid"
msgstr ""
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Active members"
msgstr ""
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Exits"
msgstr ""
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Inactive members"
msgstr ""
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Joins"
msgstr ""
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Joins and exits by year"
msgstr ""
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Open amount"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Statistics"
msgstr ""
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Total"
msgstr ""
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Year"
msgstr ""
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Contributions pie: paid %{paid}%%, unpaid %{unpaid}%%, suspended %{suspended}%%"
msgstr ""
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Joins and exits by year as horizontal bar chart"
msgstr ""
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "All years combined (pie)"
msgstr ""
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Contributions by year"
msgstr ""
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "From %{first} to %{last} (relevant years with membership data)"
msgstr ""
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Overview from first membership to today"
msgstr ""
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Contributions by year as table with stacked bars"
msgstr ""

View file

@ -153,6 +153,7 @@ msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/statistics_live.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format
msgid "Paid"
@ -930,6 +931,7 @@ msgstr ""
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/statistics_live.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format
msgid "Suspended"
@ -938,6 +940,7 @@ msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/statistics_live.ex
#: lib/mv_web/member_live/index/membership_fee_status.ex
#, elixir-autogen, elixir-format
msgid "Unpaid"
@ -2402,22 +2405,118 @@ msgstr ""
msgid "unpaid"
msgstr ""
#~ #: lib/mv_web/live/global_settings_live.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Custom Fields in CSV Import"
#~ msgstr ""
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Active members"
msgstr ""
#~ #: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Exits"
msgstr ""
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Inactive members"
msgstr ""
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Joins"
msgstr ""
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Joins and exits by year"
msgstr ""
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Open amount"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Statistics"
msgstr ""
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Total"
msgstr ""
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Year"
msgstr ""
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Contributions breakdown: paid %{paid}%%, unpaid %{unpaid}%%, suspended %{suspended}%%"
msgstr ""
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Contributions pie: paid %{paid}%%, unpaid %{unpaid}%%, suspended %{suspended}%%"
msgstr ""
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Joins and exits by year as horizontal bar chart"
msgstr ""
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "All years combined (bar)"
msgstr ""
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "All years combined (pie)"
msgstr ""
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Contributions by year"
msgstr ""
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "From %{first} to %{last} (relevant years with membership data)"
msgstr ""
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Overview from first membership to today"
msgstr ""
#~ #: lib/mv_web/live/statistics_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Failed to prepare CSV import: %{error}"
#~ msgid "By amount (bar)"
#~ msgstr ""
#~ #: lib/mv_web/live/global_settings_live.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Individual data fields must be created in Mila before importing. Use the field name as the CSV column header. Unknown custom field columns will be ignored with a warning."
#~ msgstr ""
#~ #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#~ #: lib/mv_web/live/statistics_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Only administrators can regenerate cycles"
#~ msgid "By amount (pie)"
#~ msgstr ""
#~ #: lib/mv_web/live/statistics_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Exits (year)"
#~ msgstr ""
#~ #: lib/mv_web/live/statistics_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Joins (year)"
#~ msgstr ""
#~ #: lib/mv_web/live/statistics_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Member and contribution overview"
#~ msgstr ""
#~ #: lib/mv_web/live/statistics_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Paid this year"
#~ msgstr ""

View file

@ -379,10 +379,9 @@ Enum.each(member_attrs_list, fn member_attrs ->
# Generate cycles if member has a fee type
if final_member.membership_fee_type_id do
# Load member with cycles to check if they already exist
# Load member with cycles to check if they already exist (actor required for auth)
member_with_cycles =
final_member
|> Ash.load!(:membership_fee_cycles)
Ash.load!(final_member, :membership_fee_cycles, actor: admin_user_with_role)
# Only generate if no cycles exist yet (to avoid duplicates on re-run)
cycles =
@ -427,7 +426,7 @@ Enum.each(member_attrs_list, fn member_attrs ->
if cycle.status != status do
cycle
|> Ash.Changeset.for_update(:update, %{status: status})
|> Ash.update!(actor: admin_user_with_role)
|> Ash.update!(actor: admin_user_with_role, domain: Mv.MembershipFees)
end
end)
end
@ -542,10 +541,9 @@ Enum.with_index(linked_members)
# Generate cycles for linked members
if final_member.membership_fee_type_id do
# Load member with cycles to check if they already exist
# Load member with cycles to check if they already exist (actor required for auth)
member_with_cycles =
final_member
|> Ash.load!(:membership_fee_cycles)
Ash.load!(final_member, :membership_fee_cycles, actor: admin_user_with_role)
# Only generate if no cycles exist yet (to avoid duplicates on re-run)
cycles =
@ -575,7 +573,7 @@ Enum.with_index(linked_members)
if cycle.status != status do
cycle
|> Ash.Changeset.for_update(:update, %{status: status})
|> Ash.update!()
|> Ash.update!(actor: admin_user_with_role, domain: Mv.MembershipFees)
end
end)
end

190
test/mv/statistics_test.exs Normal file
View file

@ -0,0 +1,190 @@
defmodule Mv.StatisticsTest do
@moduledoc """
Tests for Mv.Statistics module (member and membership fee cycle statistics).
"""
use Mv.DataCase, async: true
require Ash.Query
import Ash.Expr
alias Mv.Statistics
alias Mv.MembershipFees
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.MembershipFeeType
setup do
actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: actor}
end
defp create_fee_type(actor, attrs \\ %{}) do
MembershipFeeType
|> Ash.Changeset.for_create(
:create,
Map.merge(
%{
name: "Test Fee #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
interval: :yearly
},
attrs
)
)
|> Ash.create!(actor: actor)
end
describe "first_join_year/1" do
test "returns the year of the earliest join_date", %{actor: actor} do
Mv.Fixtures.member_fixture(%{join_date: ~D[2019-03-15]})
Mv.Fixtures.member_fixture(%{join_date: ~D[2022-01-01]})
assert Statistics.first_join_year(actor: actor) == 2019
end
test "returns the only member's join year when one member exists", %{actor: actor} do
Mv.Fixtures.member_fixture(%{join_date: ~D[2021-06-01]})
assert Statistics.first_join_year(actor: actor) == 2021
end
test "returns nil when no members exist", %{actor: actor} do
# Relies on empty member table for this test; may be nil if other tests created members
result = Statistics.first_join_year(actor: actor)
assert result == nil or is_integer(result)
end
end
describe "active_member_count/1" do
test "returns 0 when there are no members", %{actor: actor} do
assert Statistics.active_member_count(actor: actor) == 0
end
test "returns 1 when one member has no exit_date", %{actor: actor} do
Mv.Fixtures.member_fixture(%{join_date: ~D[2023-01-15]})
assert Statistics.active_member_count(actor: actor) == 1
end
test "returns 0 for that member when exit_date is set", %{actor: actor} do
_member =
Mv.Fixtures.member_fixture(%{join_date: ~D[2023-01-15], exit_date: ~D[2024-06-01]})
assert Statistics.active_member_count(actor: actor) == 0
end
test "counts only active members when mix of active and inactive", %{actor: actor} do
Mv.Fixtures.member_fixture(%{join_date: ~D[2022-01-01]})
Mv.Fixtures.member_fixture(%{join_date: ~D[2023-01-01], exit_date: ~D[2024-01-01]})
assert Statistics.active_member_count(actor: actor) == 1
end
end
describe "inactive_member_count/1" do
test "returns 0 when all members are active", %{actor: actor} do
Mv.Fixtures.member_fixture(%{join_date: ~D[2023-01-01]})
assert Statistics.inactive_member_count(actor: actor) == 0
end
test "returns 1 when one member has exit_date set", %{actor: actor} do
Mv.Fixtures.member_fixture(%{join_date: ~D[2022-01-01], exit_date: ~D[2024-06-01]})
assert Statistics.inactive_member_count(actor: actor) == 1
end
end
describe "joins_by_year/2" do
test "returns 0 for year with no joins", %{actor: actor} do
assert Statistics.joins_by_year(1999, actor: actor) == 0
end
test "returns 1 when one member has join_date in that year", %{actor: actor} do
Mv.Fixtures.member_fixture(%{join_date: ~D[2023-06-15]})
assert Statistics.joins_by_year(2023, actor: actor) == 1
end
test "returns 2 when two members joined in that year", %{actor: actor} do
Mv.Fixtures.member_fixture(%{join_date: ~D[2023-01-01]})
Mv.Fixtures.member_fixture(%{join_date: ~D[2023-12-31]})
assert Statistics.joins_by_year(2023, actor: actor) == 2
end
end
describe "exits_by_year/2" do
test "returns 0 for year with no exits", %{actor: actor} do
assert Statistics.exits_by_year(1999, actor: actor) == 0
end
test "returns 1 when one member has exit_date in that year", %{actor: actor} do
Mv.Fixtures.member_fixture(%{join_date: ~D[2020-01-01], exit_date: ~D[2023-06-15]})
assert Statistics.exits_by_year(2023, actor: actor) == 1
end
end
describe "cycle_totals_by_year/2" do
test "returns zero totals for year with no cycles", %{actor: actor} do
result = Statistics.cycle_totals_by_year(1999, actor: actor)
assert result.total == Decimal.new(0)
assert result.paid == Decimal.new(0)
assert result.unpaid == Decimal.new(0)
assert result.suspended == Decimal.new(0)
end
test "returns totals by status for cycles in that year", %{actor: actor} do
fee_type = create_fee_type(actor, %{amount: Decimal.new("50.00")})
# Creating members with fee type triggers cycle generation (2020..today). We use 2024 cycles.
_member1 =
Mv.Fixtures.member_fixture(%{
join_date: ~D[2020-01-01],
membership_fee_type_id: fee_type.id
})
_member2 =
Mv.Fixtures.member_fixture(%{
join_date: ~D[2020-01-01],
membership_fee_type_id: fee_type.id
})
# Get 2024 cycles and set status (each member has one 2024 yearly cycle from generator)
cycles_2024 =
MembershipFeeCycle
|> Ash.Query.filter(
expr(cycle_start >= ^~D[2024-01-01] and cycle_start < ^~D[2025-01-01])
)
|> Ash.read!(actor: actor)
|> Enum.sort_by(& &1.member_id)
[c1, c2] = cycles_2024
assert {:ok, _} = Ash.update(c1, %{status: :paid}, domain: MembershipFees, actor: actor)
assert {:ok, _} =
Ash.update(c2, %{status: :suspended}, domain: MembershipFees, actor: actor)
result = Statistics.cycle_totals_by_year(2024, actor: actor)
assert Decimal.equal?(result.total, Decimal.new("100.00"))
assert Decimal.equal?(result.paid, Decimal.new("50.00"))
assert Decimal.equal?(result.unpaid, Decimal.new(0))
assert Decimal.equal?(result.suspended, Decimal.new("50.00"))
end
end
describe "open_amount_total/1" do
test "returns 0 when there are no unpaid cycles", %{actor: actor} do
assert Statistics.open_amount_total(actor: actor) == Decimal.new(0)
end
test "returns sum of amount for all unpaid cycles", %{actor: actor} do
fee_type = create_fee_type(actor, %{amount: Decimal.new("50.00")})
_member =
Mv.Fixtures.member_fixture(%{
join_date: ~D[2020-01-01],
membership_fee_type_id: fee_type.id
})
# Cycle generator creates yearly cycles (2020..today), all unpaid by default
unpaid_sum = Statistics.open_amount_total(actor: actor)
assert Decimal.compare(unpaid_sum, Decimal.new(0)) == :gt
# Should be 50 * number of years from 2020 to current year
current_year = Date.utc_today().year
expected_count = current_year - 2020 + 1
assert Decimal.equal?(unpaid_sum, Decimal.new(50 * expected_count))
end
end
end

View file

@ -25,12 +25,13 @@ defmodule MvWeb.SidebarAuthorizationTest do
end
describe "sidebar menu with admin user" do
test "shows Members, Fee Types and Administration with all subitems" do
test "shows Members, Fee Types, Statistics and Administration with all subitems" do
user = Fixtures.user_with_role_fixture("admin")
html = render_sidebar(sidebar_assigns(user))
assert html =~ ~s(href="/members")
assert html =~ ~s(href="/membership_fee_types")
assert html =~ ~s(href="/statistics")
assert html =~ ~s(data-testid="sidebar-administration")
assert html =~ ~s(href="/users")
assert html =~ ~s(href="/groups")
@ -41,11 +42,12 @@ defmodule MvWeb.SidebarAuthorizationTest do
end
describe "sidebar menu with read_only user (Vorstand/Buchhaltung)" do
test "shows Members and Groups (from Administration)" do
test "shows Members, Statistics and Groups (from Administration)" do
user = Fixtures.user_with_role_fixture("read_only")
html = render_sidebar(sidebar_assigns(user))
assert html =~ ~s(href="/members")
assert html =~ ~s(href="/statistics")
assert html =~ ~s(href="/groups")
end
@ -61,11 +63,12 @@ defmodule MvWeb.SidebarAuthorizationTest do
end
describe "sidebar menu with normal_user (Kassenwart)" do
test "shows Members and Groups" do
test "shows Members, Statistics and Groups" do
user = Fixtures.user_with_role_fixture("normal_user")
html = render_sidebar(sidebar_assigns(user))
assert html =~ ~s(href="/members")
assert html =~ ~s(href="/statistics")
assert html =~ ~s(href="/groups")
end
@ -88,10 +91,11 @@ defmodule MvWeb.SidebarAuthorizationTest do
refute html =~ ~s(href="/members")
end
test "does not show Fee Types or Administration" do
test "does not show Statistics, Fee Types or Administration" do
user = Fixtures.user_with_role_fixture("own_data")
html = render_sidebar(sidebar_assigns(user))
refute html =~ ~s(href="/statistics")
refute html =~ ~s(href="/membership_fee_types")
refute html =~ ~s(href="/users")
refute html =~ ~s(data-testid="sidebar-administration")

View file

@ -0,0 +1,32 @@
defmodule MvWeb.StatisticsLiveTest do
@moduledoc """
Tests for the Statistics LiveView at /statistics.
"""
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
describe "statistics page" do
test "renders statistics page with title and key labels for authenticated user with access",
%{
conn: conn
} do
{:ok, _view, html} = live(conn, ~p"/statistics")
assert html =~ "Statistics"
assert html =~ "Active members"
assert html =~ "Open amount"
assert html =~ "Contributions by year"
assert html =~ "Joins and exits by year"
end
test "page shows overview of all relevant years without year selector", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/statistics")
# No year dropdown: single select for year should not be present as main control
assert html =~ "Overview" or html =~ "overview"
# table header or legend
assert html =~ "Year"
end
end
end

View file

@ -107,6 +107,37 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do
end
end
describe "statistics route /statistics" do
test "read_only can access /statistics" do
user = Fixtures.user_with_role_fixture("read_only")
conn = conn_with_user("/statistics", user) |> CheckPagePermission.call([])
refute conn.halted
end
test "normal_user can access /statistics" do
user = Fixtures.user_with_role_fixture("normal_user")
conn = conn_with_user("/statistics", user) |> CheckPagePermission.call([])
refute conn.halted
end
test "admin can access /statistics" do
user = Fixtures.user_with_role_fixture("admin")
conn = conn_with_user("/statistics", user) |> CheckPagePermission.call([])
refute conn.halted
end
test "own_data cannot access /statistics" do
user = Fixtures.user_with_role_fixture("own_data")
conn = conn_with_user("/statistics", user) |> CheckPagePermission.call([])
assert conn.halted
assert redirected_to(conn) == "/users/#{user.id}"
end
end
describe "read_only and normal_user denied on admin routes" do
test "read_only cannot access /admin/roles" do
user = Fixtures.user_with_role_fixture("read_only")