diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md
index 565cbdd..cc58ca9 100644
--- a/CODE_GUIDELINES.md
+++ b/CODE_GUIDELINES.md
@@ -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
diff --git a/config/config.exs b/config/config.exs
index 6720a5d..d4de2c2 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -58,6 +58,12 @@ config :mv,
max_rows: 1000
]
+# PDF Export configuration
+config :mv,
+ pdf_export: [
+ row_limit: 5000
+ ]
+
# OIDC group → role sync (optional). Overridden in runtime.exs from ENV in production.
config :mv, :oidc_role_sync,
admin_group_name: nil,
diff --git a/docs/feature-roadmap.md b/docs/feature-roadmap.md
index 1721139..67f01c8 100644
--- a/docs/feature-roadmap.md
+++ b/docs/feature-roadmap.md
@@ -292,10 +292,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
diff --git a/docs/page-permission-route-coverage.md b/docs/page-permission-route-coverage.md
index 9151a44..b8eafbd 100644
--- a/docs/page-permission-route-coverage.md
+++ b/docs/page-permission-route-coverage.md
@@ -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` | ✗ | ✗ | ✗ | ✓ |
diff --git a/docs/pdf-generation-imprintor.md b/docs/pdf-generation-imprintor.md
new file mode 100644
index 0000000..f8ce2ee
--- /dev/null
+++ b/docs/pdf-generation-imprintor.md
@@ -0,0 +1,71 @@
+# PDF Generation: Imprintor statt Chromium
+
+## Übersicht
+
+Für die PDF-Generierung in der Mitgliederverwaltung verwenden wir **Imprintor** (`~> 0.5.0`) anstelle von Chromium-basierten Lösungen (wie z.B. Puppeteer, Chrome Headless, oder ähnliche).
+
+## Warum Imprintor statt Chromium?
+
+### 1. Ressourceneffizienz
+
+- **Geringerer Speicherverbrauch**: Imprintor benötigt keine vollständige Browser-Instanz im Speicher
+- **Niedrigere CPU-Last**: Native PDF-Generierung ohne Browser-Rendering-Pipeline
+- **Kleinere Docker-Images**: Keine Chromium-Installation erforderlich (spart mehrere hundert MB)
+
+### 2. Performance
+
+- **Schnellere Generierung**: Direkte PDF-Generierung ohne HTML-Rendering-Overhead
+- **Bessere Skalierbarkeit**: Kann mehrere PDFs parallel generieren ohne Browser-Instanzen zu verwalten
+- **Niedrigere Latenz**: Keine Browser-Startup-Zeit
+
+### 3. Deployment & Wartung
+
+- **Einfacheres Deployment**: Keine System-Abhängigkeiten (Chromium, ChromeDriver, etc.)
+- **Weniger Wartungsaufwand**: Keine Browser-Version-Updates zu verwalten
+- **Bessere Container-Kompatibilität**: Funktioniert in minimalen Docker-Images (z.B. Alpine)
+
+### 4. Sicherheit
+
+- **Kleinere Angriffsfläche**: Keine Browser-Engine mit bekannten Sicherheitslücken
+- **Isolation**: Weniger System-Calls und externe Prozesse
+
+### 5. Elixir-Native Lösung
+
+- **Erlang/OTP-Integration**: Nutzt die Vorteile der BEAM-VM (Concurrency, Fault Tolerance)
+- **Type-Safety**: Bessere Integration mit Elixir-Typen und Pattern Matching
+- **Einfachere Fehlerbehandlung**: Elixir-native Error-Handling statt externer Prozesse
+
+## Wann Chromium trotzdem sinnvoll wäre
+
+Chromium-basierte Lösungen sind sinnvoll, wenn:
+- Komplexe JavaScript-Ausführung im HTML nötig ist
+- Moderne CSS-Features (Grid, Flexbox, etc.) kritisch sind
+- Screenshots von Web-Seiten generiert werden sollen
+- Dynamische Inhalte gerendert werden müssen, die JavaScript erfordern
+
+## Verwendung in diesem Projekt
+
+Imprintor wird für folgende Anwendungsfälle verwendet:
+- **Member-Export als PDF**: Generierung von Mitgliederlisten und -reports
+- **Statische Reports**: PDF-Generierung für vordefinierte Report-Formate
+- **Dokumente**: Generierung von Mitgliedschaftsbescheinigungen, Rechnungen, etc.
+
+## Technische Details
+
+- **Dependency**: `{:imprintor, "~> 0.5.0"}`
+- **Typ**: Native Elixir-Bibliothek (vermutlich basierend auf Rust-NIFs oder ähnlichen Technologien)
+- **Format**: Generiert PDF direkt aus HTML/Templates ohne Browser-Engine
+
+## Migration von Chromium (falls vorhanden)
+
+Falls zuvor eine Chromium-basierte Lösung verwendet wurde:
+1. HTML-Templates müssen ggf. angepasst werden (kein JavaScript-Support)
+2. CSS muss statisch sein (keine dynamischen Styles)
+3. Komplexe Layouts sollten vorher getestet werden
+
+## Weitere Ressourcen
+
+- [Imprintor auf Hex.pm](https://hex.pm/packages/imprintor)
+- [GitHub Repository](https://github.com/[imprintor-repo]) (falls verfügbar)
+
+
diff --git a/docs/statistics-page-implementation-plan.md b/docs/statistics-page-implementation-plan.md
new file mode 100644
index 0000000..2225ae9
--- /dev/null
+++ b/docs/statistics-page-implementation-plan.md
@@ -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.
diff --git a/lib/mv/authorization/permission_sets.ex b/lib/mv/authorization/permission_sets.ex
index ea878a2..fffc818 100644
--- a/lib/mv/authorization/permission_sets.ex
+++ b/lib/mv/authorization/permission_sets.ex
@@ -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
diff --git a/lib/mv/config.ex b/lib/mv/config.ex
index 007309a..bcbc8d9 100644
--- a/lib/mv/config.ex
+++ b/lib/mv/config.ex
@@ -116,4 +116,30 @@ defmodule Mv.Config do
defp parse_and_validate_integer(_value, default) do
default
end
+
+ @doc """
+ Returns the maximum number of rows allowed in PDF exports.
+
+ Reads the `row_limit` value from the PDF export configuration.
+
+ ## Returns
+
+ - Maximum number of rows (default: 5000)
+
+ ## Examples
+
+ iex> Mv.Config.pdf_export_row_limit()
+ 5000
+ """
+ @spec pdf_export_row_limit() :: pos_integer()
+ def pdf_export_row_limit do
+ get_pdf_export_config(:row_limit, 5000)
+ end
+
+ # Helper function to get PDF export config values
+ defp get_pdf_export_config(key, default) do
+ Application.get_env(:mv, :pdf_export, [])
+ |> Keyword.get(key, default)
+ |> parse_and_validate_integer(default)
+ end
end
diff --git a/lib/mv/membership/member_export/build.ex b/lib/mv/membership/member_export/build.ex
new file mode 100644
index 0000000..ce1e98c
--- /dev/null
+++ b/lib/mv/membership/member_export/build.ex
@@ -0,0 +1,433 @@
+defmodule Mv.Membership.MemberExport.Build do
+ @moduledoc """
+ Builds export data structure for member exports (CSV/PDF).
+
+ Extracts common logic for loading, filtering, sorting, and formatting member data
+ into a unified structure that can be used by both CSV and PDF exporters.
+
+ Returns a structure:
+ ```
+ %{
+ columns: [%{key: term(), kind: :member_field | :custom_field | :computed, ...}],
+ rows: [[cell_string, ...]],
+ meta: %{generated_at: String.t(), member_count: integer(), ...}
+ }
+ ```
+
+ No translations/Gettext in this module - labels come from the web layer via a function.
+ """
+
+ require Ash.Query
+ import Ash.Expr
+
+ alias Mv.Membership.{CustomField, CustomFieldValueFormatter, Member, MemberExportSort}
+ alias MvWeb.MemberLive.Index.MembershipFeeStatus
+
+ @custom_field_prefix Mv.Constants.custom_field_prefix()
+
+ @doc """
+ Builds export data structure from parsed parameters.
+
+ - `actor` - Ash actor (e.g. current user)
+ - `parsed` - Map with export parameters (from `MemberExport.parse_params/1`)
+ - `label_fn` - Function to get labels for columns: `(key) -> String.t()`
+
+ Returns `{:ok, data}` or `{:error, :forbidden}`.
+
+ The `data` map contains:
+ - `columns`: List of column specs with `key`, `kind`, and optional `custom_field`
+ - `rows`: List of rows, each row is a list of cell strings
+ - `meta`: Metadata including `generated_at` and `member_count`
+ """
+ @spec build(struct(), map(), (term() -> String.t())) ::
+ {:ok, map()} | {:error, :forbidden}
+ def build(actor, parsed, label_fn) when is_function(label_fn, 1) do
+ # Ensure sort custom field is loaded if needed
+ parsed = ensure_sort_custom_field_loaded(parsed)
+
+ custom_field_ids_union =
+ (parsed.custom_field_ids ++ Map.keys(parsed.boolean_filters || %{})) |> Enum.uniq()
+
+ with {:ok, custom_fields_by_id} <- load_custom_fields_by_id(custom_field_ids_union, actor),
+ {:ok, members} <- load_members(actor, parsed, custom_fields_by_id) do
+ columns = build_columns(parsed, custom_fields_by_id, label_fn)
+ rows = build_rows(members, columns, custom_fields_by_id)
+ meta = build_meta(members)
+
+ {:ok, %{columns: columns, rows: rows, meta: meta}}
+ end
+ end
+
+ defp ensure_sort_custom_field_loaded(%{custom_field_ids: ids, sort_field: sort_field} = parsed) do
+ case extract_sort_custom_field_id(sort_field) do
+ nil -> parsed
+ id -> %{parsed | custom_field_ids: Enum.uniq([id | ids])}
+ end
+ end
+
+ defp extract_sort_custom_field_id(field) when is_binary(field) do
+ if String.starts_with?(field, @custom_field_prefix) do
+ String.trim_leading(field, @custom_field_prefix)
+ else
+ nil
+ end
+ end
+
+ defp extract_sort_custom_field_id(_), do: nil
+
+ defp load_custom_fields_by_id([], _actor), do: {:ok, %{}}
+
+ defp load_custom_fields_by_id(custom_field_ids, actor) do
+ query =
+ CustomField
+ |> Ash.Query.filter(expr(id in ^custom_field_ids))
+ |> Ash.Query.select([:id, :name, :value_type])
+
+ case Ash.read(query, actor: actor) do
+ {:ok, custom_fields} ->
+ by_id = build_custom_fields_by_id(custom_field_ids, custom_fields)
+ {:ok, by_id}
+
+ {:error, %Ash.Error.Forbidden{}} ->
+ {:error, :forbidden}
+ end
+ end
+
+ defp build_custom_fields_by_id(custom_field_ids, custom_fields) do
+ Enum.reduce(custom_field_ids, %{}, fn id, acc ->
+ find_and_add_custom_field(acc, id, custom_fields)
+ end)
+ end
+
+ defp find_and_add_custom_field(acc, id, custom_fields) do
+ case Enum.find(custom_fields, fn cf -> to_string(cf.id) == to_string(id) end) do
+ nil -> acc
+ cf -> Map.put(acc, id, cf)
+ end
+ end
+
+ defp load_members(actor, parsed, custom_fields_by_id) do
+ {query, sort_after_load} = build_members_query(parsed, custom_fields_by_id)
+
+ case Ash.read(query, actor: actor) do
+ {:ok, members} ->
+ processed_members =
+ process_loaded_members(members, parsed, custom_fields_by_id, sort_after_load)
+
+ {:ok, processed_members}
+
+ {:error, %Ash.Error.Forbidden{}} ->
+ {:error, :forbidden}
+ end
+ end
+
+ defp build_members_query(parsed, _custom_fields_by_id) do
+ select_fields =
+ [:id] ++ Enum.map(parsed.selectable_member_fields, &String.to_existing_atom/1)
+
+ custom_field_ids_union = parsed.custom_field_ids ++ Map.keys(parsed.boolean_filters || %{})
+
+ need_cycles =
+ parsed.show_current_cycle or parsed.cycle_status_filter != nil or
+ parsed.computed_fields != [] or
+ "membership_fee_status" in parsed.member_fields
+
+ query =
+ Member
+ |> Ash.Query.new()
+ |> Ash.Query.select(select_fields)
+ |> load_custom_field_values_query(custom_field_ids_union)
+ |> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
+
+ query =
+ if parsed.selected_ids != [] do
+ Ash.Query.filter(query, expr(id in ^parsed.selected_ids))
+ else
+ apply_search(query, parsed.query)
+ end
+
+ # Apply sorting at query level if possible (not custom fields)
+ maybe_sort(query, parsed.sort_field, parsed.sort_order)
+ end
+
+ defp process_loaded_members(members, parsed, custom_fields_by_id, sort_after_load) do
+ members
+ |> apply_post_load_filters(parsed, custom_fields_by_id)
+ |> apply_post_load_sorting(parsed, custom_fields_by_id, sort_after_load)
+ |> add_computed_fields(parsed.computed_fields, parsed.show_current_cycle)
+ end
+
+ defp apply_post_load_filters(members, parsed, custom_fields_by_id) do
+ if parsed.selected_ids == [] do
+ members
+ |> apply_cycle_status_filter(parsed.cycle_status_filter, parsed.show_current_cycle)
+ |> MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
+ parsed.boolean_filters || %{},
+ Map.values(custom_fields_by_id)
+ )
+ else
+ members
+ end
+ end
+
+ defp apply_post_load_sorting(members, parsed, custom_fields_by_id, sort_after_load) do
+ # Sort after load for custom fields (always, even with selected_ids)
+ if sort_after_load do
+ sort_members_by_custom_field(
+ members,
+ parsed.sort_field,
+ parsed.sort_order,
+ Map.values(custom_fields_by_id)
+ )
+ else
+ # For selected_ids, we may need to apply sorting that wasn't done at query level
+ if (parsed.selected_ids != [] and parsed.sort_field) && parsed.sort_order do
+ # Re-sort in memory to ensure consistent ordering
+ sort_members_in_memory(members, parsed.sort_field, parsed.sort_order)
+ else
+ members
+ end
+ end
+ end
+
+ defp sort_members_in_memory(members, field, order) when is_binary(field) do
+ field_atom = String.to_existing_atom(field)
+
+ if field_atom in Mv.Constants.member_fields() do
+ sort_by_field(members, field_atom, order)
+ else
+ members
+ end
+ rescue
+ ArgumentError -> members
+ end
+
+ defp sort_members_in_memory(members, _field, _order), do: members
+
+ defp sort_by_field(members, field_atom, order) do
+ key_fn = fn member -> Map.get(member, field_atom) end
+ compare_fn = build_compare_fn(order)
+
+ Enum.sort_by(members, key_fn, compare_fn)
+ end
+
+ defp build_compare_fn("asc"), do: fn a, b -> a <= b end
+ defp build_compare_fn("desc"), do: fn a, b -> b <= a end
+ defp build_compare_fn(_), do: fn _a, _b -> true end
+
+ defp load_custom_field_values_query(query, []), do: query
+
+ defp load_custom_field_values_query(query, custom_field_ids) do
+ cfv_query =
+ Mv.Membership.CustomFieldValue
+ |> Ash.Query.filter(expr(custom_field_id in ^custom_field_ids))
+ |> Ash.Query.load(custom_field: [:id, :name, :value_type])
+
+ Ash.Query.load(query, custom_field_values: cfv_query)
+ end
+
+ defp apply_search(query, nil), do: query
+ defp apply_search(query, ""), do: query
+
+ defp apply_search(query, q) when is_binary(q) do
+ if String.trim(q) != "" do
+ Member.fuzzy_search(query, %{query: q})
+ else
+ query
+ end
+ end
+
+ defp maybe_sort(query, nil, _order), do: {query, false}
+ defp maybe_sort(query, _field, nil), do: {query, false}
+
+ defp maybe_sort(query, field, order) when is_binary(field) do
+ if custom_field_sort?(field) do
+ {query, true}
+ else
+ field_atom = String.to_existing_atom(field)
+
+ if field_atom in (Mv.Constants.member_fields() -- [:notes]) do
+ {Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false}
+ else
+ {query, false}
+ end
+ end
+ rescue
+ ArgumentError -> {query, false}
+ end
+
+ defp sort_members_by_custom_field(members, _field, _order, _custom_fields) when members == [],
+ do: []
+
+ defp sort_members_by_custom_field(members, field, order, custom_fields) when is_binary(field) do
+ id_str = String.trim_leading(field, @custom_field_prefix)
+ custom_field = Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end)
+
+ if is_nil(custom_field), do: members
+
+ key_fn = fn member ->
+ cfv = find_cfv(member, custom_field)
+ raw = if cfv, do: cfv.value, else: nil
+ MemberExportSort.custom_field_sort_key(custom_field.value_type, raw)
+ end
+
+ members
+ |> Enum.map(fn m -> {m, key_fn.(m)} end)
+ |> Enum.sort(fn {_, ka}, {_, kb} -> MemberExportSort.key_lt(ka, kb, order) end)
+ |> Enum.map(fn {m, _} -> m end)
+ end
+
+ defp find_cfv(member, custom_field) do
+ (member.custom_field_values || [])
+ |> Enum.find(fn cfv ->
+ to_string(cfv.custom_field_id) == to_string(custom_field.id) or
+ (Map.get(cfv, :custom_field) &&
+ to_string(cfv.custom_field.id) == to_string(custom_field.id))
+ end)
+ end
+
+ defp custom_field_sort?(field), do: String.starts_with?(field, @custom_field_prefix)
+
+ defp maybe_load_cycles(query, false, _show_current), do: query
+
+ defp maybe_load_cycles(query, true, show_current) do
+ MembershipFeeStatus.load_cycles_for_members(query, show_current)
+ end
+
+ defp apply_cycle_status_filter(members, nil, _show_current), do: members
+
+ defp apply_cycle_status_filter(members, status, show_current) when status in [:paid, :unpaid] do
+ MembershipFeeStatus.filter_members_by_cycle_status(members, status, show_current)
+ end
+
+ defp apply_cycle_status_filter(members, _status, _show_current), do: members
+
+ defp add_computed_fields(members, computed_fields, show_current_cycle) do
+ computed_fields = computed_fields || []
+
+ if "membership_fee_status" in computed_fields do
+ Enum.map(members, fn member ->
+ status = MembershipFeeStatus.get_cycle_status_for_member(member, show_current_cycle)
+ # Format as string for export (controller will handle translation)
+ status_string = format_membership_fee_status(status)
+ Map.put(member, :membership_fee_status, status_string)
+ end)
+ else
+ members
+ end
+ end
+
+ defp format_membership_fee_status(:paid), do: "paid"
+ defp format_membership_fee_status(:unpaid), do: "unpaid"
+ defp format_membership_fee_status(:suspended), do: "suspended"
+ defp format_membership_fee_status(nil), do: ""
+
+ defp build_columns(parsed, custom_fields_by_id, label_fn) do
+ member_cols =
+ Enum.map(parsed.selectable_member_fields, fn field ->
+ %{
+ key: field,
+ kind: :member_field,
+ label: label_fn.(field)
+ }
+ end)
+
+ computed_cols =
+ Enum.map(parsed.computed_fields, fn key ->
+ atom_key = String.to_existing_atom(key)
+
+ %{
+ key: atom_key,
+ kind: :computed,
+ label: label_fn.(atom_key)
+ }
+ end)
+
+ custom_cols =
+ parsed.custom_field_ids
+ |> Enum.map(fn id ->
+ cf = Map.get(custom_fields_by_id, id) || Map.get(custom_fields_by_id, to_string(id))
+
+ if cf do
+ %{
+ key: to_string(id),
+ kind: :custom_field,
+ label: cf.name,
+ custom_field: cf
+ }
+ else
+ nil
+ end
+ end)
+ |> Enum.reject(&is_nil/1)
+
+ member_cols ++ computed_cols ++ custom_cols
+ end
+
+ defp build_rows(members, columns, custom_fields_by_id) do
+ Enum.map(members, fn member ->
+ Enum.map(columns, fn col -> cell_value(member, col, custom_fields_by_id) end)
+ end)
+ end
+
+ defp cell_value(member, %{kind: :member_field, key: key}, _custom_fields_by_id) do
+ key_atom = key_to_atom(key)
+ value = Map.get(member, key_atom)
+ format_member_value(value)
+ end
+
+ defp cell_value(member, %{kind: :custom_field, key: id, custom_field: cf}, _custom_fields_by_id) do
+ cfv = get_cfv_by_id(member, id)
+
+ if cfv do
+ CustomFieldValueFormatter.format_custom_field_value(cfv.value, cf)
+ else
+ ""
+ end
+ end
+
+ defp cell_value(member, %{kind: :computed, key: key}, _custom_fields_by_id) do
+ value = Map.get(member, key)
+ if is_binary(value), do: value, else: ""
+ end
+
+ defp key_to_atom(k) when is_atom(k), do: k
+
+ defp key_to_atom(k) when is_binary(k) do
+ try do
+ String.to_existing_atom(k)
+ rescue
+ ArgumentError -> k
+ end
+ end
+
+ defp get_cfv_by_id(member, id) do
+ values =
+ case Map.get(member, :custom_field_values) do
+ v when is_list(v) -> v
+ _ -> []
+ end
+
+ id_str = to_string(id)
+
+ Enum.find(values, fn cfv ->
+ to_string(cfv.custom_field_id) == id_str or
+ (Map.get(cfv, :custom_field) && to_string(cfv.custom_field.id) == id_str)
+ end)
+ end
+
+ defp format_member_value(nil), do: ""
+ defp format_member_value(true), do: "true"
+ defp format_member_value(false), do: "false"
+ defp format_member_value(%Date{} = d), do: Date.to_iso8601(d)
+ defp format_member_value(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
+ defp format_member_value(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt)
+ defp format_member_value(value), do: to_string(value)
+
+ defp build_meta(members) do
+ %{
+ generated_at: DateTime.utc_now() |> DateTime.to_iso8601(),
+ member_count: length(members)
+ }
+ end
+end
diff --git a/lib/mv/membership/members_pdf.ex b/lib/mv/membership/members_pdf.ex
new file mode 100644
index 0000000..0d6e469
--- /dev/null
+++ b/lib/mv/membership/members_pdf.ex
@@ -0,0 +1,456 @@
+defmodule Mv.Membership.MembersPDF do
+ @moduledoc """
+ Exports members to PDF using Typst templates and Imprintor.
+
+ Uses the same data structure as `MemberExport.Build` and converts it
+ to the format expected by the Typst template. Handles internationalization
+ for PDF-specific labels (title, metadata) and membership fee status.
+
+ Ensures deterministic output by maintaining column and row order.
+
+ Creates a temporary directory per request and copies the template there
+ to avoid symlink issues and ensure isolation.
+ """
+
+ require Logger
+
+ use Gettext, backend: MvWeb.Gettext
+
+ alias Mv.Config
+
+ @template_filename "members_export.typ"
+
+ @doc """
+ Renders export data to PDF binary.
+
+ - `export_data` - Map from `MemberExport.Build.build/3` with `columns`, `rows`, and `meta`
+ - `opts` - Keyword list with `:locale` (default: "en") and `:club_name` (default: "Club")
+
+ Returns `{:ok, binary}` where binary is the PDF content, or `{:error, term}`.
+
+ The PDF binary starts with "%PDF" (PDF magic bytes).
+
+ Validates row count against configured limit before processing.
+ """
+ @spec render(map(), keyword()) :: {:ok, binary()} | {:error, term()}
+ def render(export_data, opts \\ []) do
+ row_count = length(export_data.rows)
+ max_rows = Config.pdf_export_row_limit()
+
+ if row_count > max_rows do
+ Logger.warning(
+ "PDF export rejected: row count exceeds limit (rows: #{row_count}, max: #{max_rows})",
+ error_type: :row_limit_exceeded
+ )
+
+ {:error, {:row_limit_exceeded, row_count, max_rows}}
+ else
+ Logger.info(
+ "Starting PDF export (rows: #{row_count}, columns: #{length(export_data.columns)})"
+ )
+
+ locale = Keyword.get(opts, :locale, "en")
+ club_name = Keyword.get(opts, :club_name, "Club")
+ create_and_use_temp_directory(export_data, locale, club_name)
+ end
+ end
+
+ defp create_and_use_temp_directory(export_data, locale, club_name) do
+ case create_temp_directory() do
+ {:ok, temp_dir} ->
+ try do
+ with {:ok, template_content} <- load_template(),
+ {:ok, _template_path} <- copy_template_to_temp(temp_dir, template_content),
+ {:ok, template_data} <-
+ convert_to_template_format(export_data, locale, club_name),
+ {:ok, config} <-
+ build_imprintor_config(template_content, template_data, temp_dir),
+ {:ok, pdf_binary} <- compile_to_pdf(config) do
+ Logger.info("PDF export completed successfully (rows: #{length(export_data.rows)})")
+
+ {:ok, pdf_binary}
+ else
+ {:error, reason} = error ->
+ Logger.error("PDF export failed: #{inspect(reason)}",
+ error_type: :pdf_export_failed
+ )
+
+ error
+ end
+ after
+ cleanup_temp_directory(temp_dir)
+ end
+
+ {:error, reason} = error ->
+ Logger.error("Failed to create temp directory: #{inspect(reason)}",
+ error_type: :temp_dir_creation_failed
+ )
+
+ error
+ end
+ end
+
+ defp create_temp_directory do
+ # Create unique temp directory per request
+ temp_base = System.tmp_dir!()
+ temp_dir = Path.join(temp_base, "mv_pdf_export_#{System.unique_integer([:positive])}")
+
+ case File.mkdir_p(temp_dir) do
+ :ok -> {:ok, temp_dir}
+ {:error, reason} -> {:error, {:temp_dir_creation_failed, reason}}
+ end
+ end
+
+ defp load_template do
+ # Try multiple paths: compiled app path and source path (for tests/dev)
+ template_paths = [
+ Path.join(Application.app_dir(:mv, "priv"), "pdf_templates/#{@template_filename}"),
+ Path.join([File.cwd!(), "priv", "pdf_templates", @template_filename])
+ ]
+
+ Enum.reduce_while(template_paths, nil, fn path, _acc ->
+ case File.read(path) do
+ {:ok, content} -> {:halt, {:ok, content}}
+ {:error, _reason} -> {:cont, nil}
+ end
+ end)
+ |> case do
+ {:ok, content} -> {:ok, content}
+ nil -> {:error, {:template_not_found, :enoent}}
+ end
+ end
+
+ defp copy_template_to_temp(temp_dir, template_content) do
+ # Write template to temp directory (no symlinks, actual file copy)
+ template_path = Path.join(temp_dir, @template_filename)
+
+ case File.write(template_path, template_content) do
+ :ok -> {:ok, template_path}
+ {:error, reason} -> {:error, {:template_copy_failed, reason}}
+ end
+ end
+
+ defp cleanup_temp_directory(temp_dir) do
+ # Clean up temp directory and all contents
+ case File.rm_rf(temp_dir) do
+ {:ok, _} ->
+ :ok
+
+ {:error, reason, _} ->
+ Logger.warning("Failed to cleanup temp directory: #{temp_dir}, error: #{inspect(reason)}")
+ end
+ end
+
+ defp convert_to_template_format(export_data, locale, club_name) do
+ # Set locale for translations
+ Gettext.put_locale(MvWeb.Gettext, locale)
+
+ headers = Enum.map(export_data.columns, & &1.label)
+ column_count = length(export_data.columns)
+
+ meta = Map.get(export_data, :meta) || Map.get(export_data, "meta") || %{}
+
+ generated_at_raw =
+ Map.get(meta, :generated_at) ||
+ Map.get(meta, "generated_at") ||
+ DateTime.utc_now() |> DateTime.to_iso8601()
+
+ generated_at = format_datetime(generated_at_raw, locale)
+
+ member_count =
+ Map.get(meta, :member_count) ||
+ Map.get(meta, "member_count") ||
+ length(export_data.rows)
+
+ # Translate membership fee status and format dates in rows
+ rows =
+ export_data.rows
+ |> translate_membership_fee_status_in_rows(export_data.columns)
+ |> format_dates_in_rows(export_data.columns, locale)
+
+ # Build title based on locale
+ title = build_title(locale, club_name)
+
+ # Build translated labels for metadata
+ created_at_label = gettext("Created at:")
+ member_count_label = gettext("Member count:")
+
+ template_data = %{
+ "title" => title,
+ "created_at_label" => created_at_label,
+ "member_count_label" => member_count_label,
+ "generated_at" => generated_at,
+ "column_count" => column_count,
+ "headers" => headers,
+ "rows" => rows,
+ "columns" =>
+ Enum.map(export_data.columns, fn col ->
+ %{
+ "key" => to_string(col.key),
+ "kind" => to_string(col.kind),
+ "label" => col.label
+ }
+ end),
+ "meta" => %{
+ "generated_at" => generated_at,
+ "member_count" => member_count
+ },
+ "locale" => locale
+ }
+
+ {:ok, template_data}
+ end
+
+ defp build_title(_locale, club_name) do
+ gettext("Member %{club_name}", club_name: club_name)
+ end
+
+ defp format_datetime(iso8601_string, locale) when is_binary(iso8601_string) do
+ # Try to parse as DateTime first
+ case DateTime.from_iso8601(iso8601_string) do
+ {:ok, datetime, _offset} ->
+ format_datetime(datetime, locale)
+
+ {:ok, datetime} ->
+ format_datetime(datetime, locale)
+
+ {:error, _} ->
+ # Try NaiveDateTime if DateTime parsing fails
+ case NaiveDateTime.from_iso8601(iso8601_string) do
+ {:ok, naive_dt} ->
+ # Convert to DateTime in UTC
+ datetime = DateTime.from_naive!(naive_dt, "Etc/UTC")
+ format_datetime(datetime, locale)
+
+ {:error, _} ->
+ # If both fail, return original string
+ iso8601_string
+ end
+ end
+ end
+
+ defp format_datetime(%DateTime{} = datetime, locale) do
+ # Format as readable date and time (locale-specific)
+ case locale do
+ "de" ->
+ # German format: dd.mm.yyyy - HH:MM Uhr
+ Calendar.strftime(datetime, "%d.%m.%Y - %H:%M Uhr")
+
+ _ ->
+ # English format: MM/DD/YYYY HH:MM AM/PM
+ Calendar.strftime(datetime, "%m/%d/%Y %I:%M %p")
+ end
+ end
+
+ defp format_datetime(_, _), do: ""
+
+ defp format_date(%Date{} = date, locale) do
+ # Format as readable date (locale-specific)
+ case locale do
+ "de" ->
+ # German format: dd.mm.yyyy
+ Calendar.strftime(date, "%d.%m.%Y")
+
+ _ ->
+ # English format: MM/DD/YYYY
+ Calendar.strftime(date, "%m/%d/%Y")
+ end
+ end
+
+ defp format_date(_, _), do: ""
+
+ defp format_dates_in_rows(rows, columns, locale) do
+ date_indices = find_date_column_indices(columns)
+
+ if date_indices == [] do
+ rows
+ else
+ format_rows_dates(rows, date_indices, locale)
+ end
+ end
+
+ defp find_date_column_indices(columns) do
+ columns
+ |> Enum.with_index()
+ |> Enum.filter(fn {col, _idx} -> date_column?(col) end)
+ |> Enum.map(fn {_col, idx} -> idx end)
+ end
+
+ defp format_rows_dates(rows, date_indices, locale) do
+ Enum.map(rows, fn row -> format_row_dates(row, date_indices, locale) end)
+ end
+
+ defp format_row_dates(row, date_indices, locale) do
+ Enum.with_index(row)
+ |> Enum.map(fn {cell_value, idx} ->
+ if idx in date_indices do
+ format_cell_date(cell_value, locale)
+ else
+ cell_value
+ end
+ end)
+ end
+
+ defp date_column?(%{kind: :member_field, key: key}) do
+ key_atom = key_to_atom_safe(key)
+ key_atom in [:join_date, :exit_date, :membership_fee_start_date]
+ end
+
+ defp date_column?(_), do: false
+
+ defp key_to_atom_safe(key) when is_binary(key) do
+ try do
+ String.to_existing_atom(key)
+ rescue
+ ArgumentError -> key
+ end
+ end
+
+ defp key_to_atom_safe(key), do: key
+
+ defp format_cell_date(cell_value, locale) when is_binary(cell_value) do
+ format_cell_date_iso8601(cell_value, locale)
+ end
+
+ defp format_cell_date(cell_value, _locale), do: cell_value
+
+ defp format_cell_date_iso8601(cell_value, locale) do
+ case Date.from_iso8601(cell_value) do
+ {:ok, date} -> format_date(date, locale)
+ _ -> format_cell_date_datetime(cell_value, locale)
+ end
+ end
+
+ defp format_cell_date_datetime(cell_value, locale) do
+ case DateTime.from_iso8601(cell_value) do
+ {:ok, datetime} -> format_datetime(datetime, locale)
+ _ -> format_cell_date_naive(cell_value, locale)
+ end
+ end
+
+ defp format_cell_date_naive(cell_value, locale) do
+ case NaiveDateTime.from_iso8601(cell_value) do
+ {:ok, naive_dt} ->
+ datetime = DateTime.from_naive!(naive_dt, "Etc/UTC")
+ format_datetime(datetime, locale)
+
+ _ ->
+ cell_value
+ end
+ end
+
+ defp translate_membership_fee_status_in_rows(rows, columns) do
+ status_col_index = find_membership_fee_status_index(columns)
+
+ if status_col_index do
+ translate_rows_status(rows, status_col_index)
+ else
+ rows
+ end
+ end
+
+ defp find_membership_fee_status_index(columns) do
+ Enum.find_index(columns, fn col ->
+ col.kind == :computed && col.key == :membership_fee_status
+ end)
+ end
+
+ defp translate_rows_status(rows, status_col_index) do
+ Enum.map(rows, fn row ->
+ List.update_at(row, status_col_index, &translate_membership_fee_status/1)
+ end)
+ end
+
+ defp translate_membership_fee_status("paid"), do: gettext("Paid")
+ defp translate_membership_fee_status("unpaid"), do: gettext("Unpaid")
+ defp translate_membership_fee_status("suspended"), do: gettext("Suspended")
+ defp translate_membership_fee_status(value), do: value
+
+ defp build_imprintor_config(template_content, template_data, root_directory) do
+ # Imprintor.Config.new(source_document, inputs, options)
+ # inputs: %{"elixir_data" => template_data} for sys.inputs.elixir_data in template
+ # options: set root_directory to temp dir to ensure no symlink issues
+ # extra_fonts: list of font file paths for Typst to use
+ extra_fonts = get_extra_fonts()
+ options = [root_directory: root_directory, extra_fonts: extra_fonts]
+
+ config = Imprintor.Config.new(template_content, template_data, options)
+ {:ok, config}
+ end
+
+ defp get_extra_fonts do
+ font_paths = get_font_paths()
+
+ Enum.reduce_while(font_paths, [], &find_fonts_in_path/2)
+ |> normalize_fonts_result()
+ end
+
+ defp get_font_paths do
+ [
+ Path.join(Application.app_dir(:mv, "priv"), "fonts"),
+ Path.join([File.cwd!(), "priv", "fonts"])
+ ]
+ end
+
+ defp find_fonts_in_path(base_path, _acc) do
+ case File.ls(base_path) do
+ {:ok, files} -> process_font_files(files, base_path)
+ {:error, _reason} -> {:cont, []}
+ end
+ end
+
+ defp process_font_files(files, base_path) do
+ fonts =
+ files
+ |> Enum.filter(&String.ends_with?(&1, ".ttf"))
+ |> Enum.map(&Path.join(base_path, &1))
+ |> Enum.sort()
+
+ if fonts != [] do
+ {:halt, fonts}
+ else
+ {:cont, []}
+ end
+ end
+
+ defp normalize_fonts_result([]), do: []
+ defp normalize_fonts_result(fonts), do: fonts
+
+ defp compile_to_pdf(config) do
+ case Imprintor.compile_to_pdf(config) do
+ {:ok, pdf_binary} when is_binary(pdf_binary) ->
+ # Verify PDF magic bytes
+ if String.starts_with?(pdf_binary, "%PDF") do
+ {:ok, pdf_binary}
+ else
+ Logger.error(
+ "PDF compilation returned invalid format (start: #{String.slice(pdf_binary, 0, 20)})"
+ )
+
+ {:error, :invalid_pdf_format}
+ end
+
+ {:error, reason} ->
+ Logger.error("PDF compilation failed",
+ error: inspect(reason),
+ error_type: :imprintor_compile_error
+ )
+
+ {:error, {:compile_error, reason}}
+
+ other ->
+ Logger.error("PDF compilation returned unexpected result: #{inspect(other)}",
+ error_type: :unexpected_result
+ )
+
+ {:error, {:unexpected_result, other}}
+ end
+ rescue
+ e ->
+ Logger.error("PDF compilation raised exception: #{inspect(e)}",
+ error_type: :compile_exception
+ )
+
+ {:error, {:compile_exception, e}}
+ end
+end
diff --git a/lib/mv/membership_fees/cycle_generator.ex b/lib/mv/membership_fees/cycle_generator.ex
index ec33914..1a33ca8 100644
--- a/lib/mv/membership_fees/cycle_generator.ex
+++ b/lib/mv/membership_fees/cycle_generator.ex
@@ -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)
diff --git a/lib/mv/statistics.ex b/lib/mv/statistics.ex
new file mode 100644
index 0000000..b3c54c0
--- /dev/null
+++ b/lib/mv/statistics.ex
@@ -0,0 +1,237 @@
+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
+
+ require Logger
+
+ 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, reason} ->
+ Logger.warning("Statistics.first_join_year failed: #{inspect(reason)}")
+ 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, reason} ->
+ Logger.warning("Statistics.active_member_count failed: #{inspect(reason)}")
+ 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, reason} ->
+ Logger.warning("Statistics.inactive_member_count failed: #{inspect(reason)}")
+ 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, reason} ->
+ Logger.warning("Statistics.joins_by_year failed: #{inspect(reason)}")
+ 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, reason} ->
+ Logger.warning("Statistics.exits_by_year failed: #{inspect(reason)}")
+ 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))
+
+ 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_for_read) do
+ {:ok, cycles} ->
+ cycle_totals_from_cycles(cycles)
+
+ {:error, reason} ->
+ Logger.warning("Statistics.cycle_totals_by_year failed: #{inspect(reason)}")
+ 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
+
+ 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.
+ """
+ @spec open_amount_total(keyword()) :: Decimal.t()
+ def open_amount_total(opts) do
+ query =
+ MembershipFeeCycle
+ |> Ash.Query.filter(expr(status == :unpaid))
+
+ query = maybe_filter_by_fee_type(query, opts)
+
+ 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)
+
+ {:error, reason} ->
+ Logger.warning("Statistics.open_amount_total failed: #{inspect(reason)}")
+ Decimal.new(0)
+ end
+ end
+end
diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex
index 60f3636..40cb800 100644
--- a/lib/mv_web/components/core_components.ex
+++ b/lib/mv_web/components/core_components.ex
@@ -151,9 +151,17 @@ defmodule MvWeb.CoreComponents do
## Examples
<.dropdown_menu items={@items} open={@open} phx_target={@myself} />
+
+ When using custom content (e.g., forms), use the inner_block slot:
+
+ <.dropdown_menu button_label="Export" icon="hero-arrow-down-tray" open={@open} phx_target={@myself}>
+
+
+
+
"""
attr :id, :string, default: "dropdown-menu"
- attr :items, :list, required: true, doc: "List of %{label: string, value: any} maps"
+ attr :items, :list, default: [], doc: "List of %{label: string, value: any} maps"
attr :button_label, :string, default: "Dropdown"
attr :icon, :string, default: nil
attr :checkboxes, :boolean, default: false
@@ -161,8 +169,30 @@ defmodule MvWeb.CoreComponents do
attr :open, :boolean, default: false, doc: "Whether the dropdown is open"
attr :show_select_buttons, :boolean, default: false, doc: "Show select all/none buttons"
attr :phx_target, :any, required: true, doc: "The LiveView/LiveComponent target for events"
+ attr :menu_class, :string, default: nil, doc: "Additional CSS classes for the menu"
+ attr :menu_width, :string, default: "w-64", doc: "Width class for the menu (default: w-64)"
+
+ attr :button_class, :string,
+ default: nil,
+ doc: "Additional CSS classes for the button (e.g., btn-secondary)"
+
+ attr :menu_align, :string,
+ default: "right",
+ doc: "Menu alignment: 'left' or 'right' (default: right)"
+
+ attr :testid, :string, default: "dropdown-menu", doc: "data-testid for the dropdown container"
+ attr :button_testid, :string, default: "dropdown-button", doc: "data-testid for the button"
+
+ attr :menu_testid, :string,
+ default: nil,
+ doc: "data-testid for the menu (defaults to testid + '-menu')"
+
+ slot :inner_block, doc: "Custom content for the dropdown menu (e.g., forms)"
def dropdown_menu(assigns) do
+ menu_testid = assigns.menu_testid || "#{assigns.testid}-menu"
+ assigns = assign(assigns, :menu_testid, menu_testid)
+
~H"""
- {render_slot(@actions)}
+ {render_slot(@actions)}
"""
end
diff --git a/lib/mv_web/components/export_dropdown.ex b/lib/mv_web/components/export_dropdown.ex
new file mode 100644
index 0000000..9462193
--- /dev/null
+++ b/lib/mv_web/components/export_dropdown.ex
@@ -0,0 +1,100 @@
+defmodule MvWeb.Components.ExportDropdown do
+ @moduledoc """
+ Export dropdown component for member export (CSV/PDF).
+
+ Provides an accessible dropdown menu with CSV and PDF export options.
+ Uses the same export payload as the previous single-button export.
+ """
+ use MvWeb, :live_component
+ use Gettext, backend: MvWeb.Gettext
+
+ @impl true
+ def mount(socket) do
+ {:ok, assign(socket, :open, false)}
+ end
+
+ @impl true
+ def update(assigns, socket) do
+ socket =
+ socket
+ |> assign(:id, assigns.id)
+ |> assign(:export_payload_json, assigns[:export_payload_json] || "")
+ |> assign(:selected_count, assigns[:selected_count] || 0)
+
+ {:ok, socket}
+ end
+
+ @impl true
+ def render(assigns) do
+ button_label =
+ gettext("Export") <>
+ " (" <>
+ if(assigns.selected_count == 0,
+ do: gettext("all"),
+ else: to_string(assigns.selected_count)
+ ) <>
+ ")"
+
+ assigns = assign(assigns, :button_label, button_label)
+
+ ~H"""
+
+ <.dropdown_menu
+ id={"#{@id}-menu"}
+ button_label={@button_label}
+ icon="hero-arrow-down-tray"
+ open={@open}
+ phx_target={@myself}
+ menu_width="w-48"
+ menu_align="left"
+ button_class="btn-secondary gap-2"
+ testid="export-dropdown"
+ button_testid="export-dropdown-button"
+ menu_testid="export-dropdown-menu"
+ >
+
+
+
+
+
+
+
+
+ """
+ end
+
+ @impl true
+ def handle_event("toggle_dropdown", _params, socket) do
+ {:noreply, assign(socket, :open, !socket.assigns.open)}
+ end
+
+ def handle_event("close_dropdown", _params, socket) do
+ {:noreply, assign(socket, :open, false)}
+ end
+end
diff --git a/lib/mv_web/components/layouts/sidebar.ex b/lib/mv_web/components/layouts/sidebar.ex
index 89519ae..1896f24 100644
--- a/lib/mv_web/components/layouts/sidebar.ex
+++ b/lib/mv_web/components/layouts/sidebar.ex
@@ -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"
diff --git a/lib/mv_web/controllers/member_pdf_export_controller.ex b/lib/mv_web/controllers/member_pdf_export_controller.ex
new file mode 100644
index 0000000..63feef2
--- /dev/null
+++ b/lib/mv_web/controllers/member_pdf_export_controller.ex
@@ -0,0 +1,159 @@
+defmodule MvWeb.MemberPdfExportController do
+ @moduledoc """
+ PDF export for members.
+
+ Expects `payload` as JSON string form param.
+ Uses the same actor/permissions as the member overview.
+ """
+
+ use MvWeb, :controller
+
+ require Logger
+
+ alias Mv.Authorization.Actor
+ alias Mv.Membership.{MemberExport, MemberExport.Build, MembersPDF}
+ alias MvWeb.Translations.MemberFields
+
+ use Gettext, backend: MvWeb.Gettext
+
+ @payload_required_message "payload required"
+ @invalid_json_message "invalid JSON"
+ @export_failed_message "Failed to generate PDF export"
+
+ @allowed_member_field_strings Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
+
+ def export(conn, %{"payload" => payload}) when is_binary(payload) do
+ actor = current_actor(conn)
+
+ if is_nil(actor) do
+ forbidden(conn)
+ else
+ locale = get_locale(conn)
+ club_name = get_club_name()
+
+ with {:ok, decoded} <- decode_json_map(payload),
+ parsed <- MemberExport.parse_params(decoded),
+ {:ok, export_data} <- Build.build(actor, parsed, &label_for_column/1),
+ {:ok, pdf_binary} <-
+ MembersPDF.render(export_data, locale: locale, club_name: club_name) do
+ filename = "members-#{Date.utc_today()}.pdf"
+
+ send_download(
+ conn,
+ {:binary, pdf_binary},
+ filename: filename,
+ content_type: "application/pdf"
+ )
+ else
+ {:error, :invalid_json} ->
+ bad_request(conn, @invalid_json_message)
+
+ {:error, :forbidden} ->
+ forbidden(conn)
+
+ {:error, {:row_limit_exceeded, row_count, max_rows}} ->
+ unprocessable_entity(conn, %{
+ error: "row_limit_exceeded",
+ message:
+ gettext("Export contains %{count} rows, maximum is %{max}",
+ count: row_count,
+ max: max_rows
+ ),
+ row_count: row_count,
+ max_rows: max_rows
+ })
+
+ {:error, reason} ->
+ Logger.warning("PDF export failed: #{inspect(reason)}")
+
+ internal_error(conn, %{
+ error: "export_failed",
+ message: gettext(@export_failed_message)
+ })
+ end
+ end
+ end
+
+ def export(conn, _params) do
+ bad_request(conn, @payload_required_message)
+ end
+
+ # --- Actor / auth ---
+
+ defp current_actor(conn) do
+ conn.assigns[:current_user]
+ |> Actor.ensure_loaded()
+ end
+
+ defp forbidden(conn) do
+ conn
+ |> put_status(:forbidden)
+ |> json(%{error: "forbidden", message: "Forbidden"})
+ |> halt()
+ end
+
+ # --- Decoding / validation ---
+
+ defp decode_json_map(payload) when is_binary(payload) do
+ case Jason.decode(payload) do
+ {:ok, decoded} when is_map(decoded) -> {:ok, decoded}
+ _ -> {:error, :invalid_json}
+ end
+ end
+
+ # --- Column labels ---
+
+ # Goal: translate known member fields to UI labels, but never crash.
+ # - Atoms: label directly.
+ # - Binaries: only translate if they are known member fields (allowlist); otherwise return the string.
+ # This avoids String.to_existing_atom/1 exceptions for arbitrary keys (e.g., "custom_field_...").
+ defp label_for_column(key) when is_atom(key) do
+ MemberFields.label(key)
+ end
+
+ defp label_for_column(key) when is_binary(key) do
+ if key in @allowed_member_field_strings do
+ # Safe because key is in allowlist which originates from existing atoms
+ MemberFields.label(String.to_existing_atom(key))
+ else
+ key
+ end
+ end
+
+ defp label_for_column(key) do
+ to_string(key)
+ end
+
+ # --- Locale and club name ---
+
+ defp get_locale(conn) do
+ conn.assigns[:locale] || Gettext.get_locale(MvWeb.Gettext) || "en"
+ end
+
+ defp get_club_name do
+ case Mv.Membership.get_settings() do
+ {:ok, settings} -> settings.club_name
+ _ -> "Club"
+ end
+ end
+
+ # --- JSON responses ---
+
+ defp bad_request(conn, message) when is_binary(message) do
+ conn
+ |> put_status(:bad_request)
+ |> json(%{error: "bad_request", message: message})
+ end
+
+ defp unprocessable_entity(conn, body) when is_map(body) do
+ conn
+ |> put_status(:unprocessable_entity)
+ |> json(body)
+ end
+
+ defp internal_error(conn, body) when is_map(body) do
+ conn
+ |> put_status(:internal_server_error)
+ |> json(body)
+ end
+end
diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex
index ad84952..59ee8f9 100644
--- a/lib/mv_web/live/member_live/index.ex
+++ b/lib/mv_web/live/member_live/index.ex
@@ -810,53 +810,59 @@ defmodule MvWeb.MemberLive.Index do
show_current_cycle,
boolean_filters
) do
- field_str =
- if is_atom(sort_field) do
- Atom.to_string(sort_field)
- else
- sort_field
- end
+ base_params = build_base_params(query, sort_field, sort_order)
+ base_params = add_cycle_status_filter(base_params, cycle_status_filter)
+ base_params = add_group_filters(base_params, group_filters)
+ base_params = add_show_current_cycle(base_params, show_current_cycle)
+ add_boolean_filters(base_params, boolean_filters)
+ end
- order_str =
- if is_atom(sort_order) do
- Atom.to_string(sort_order)
- else
- sort_order
- end
-
- base_params = %{
- "query" => query,
- "sort_field" => field_str,
- "sort_order" => order_str
+ defp build_base_params(query, sort_field, sort_order) do
+ %{
+ "query" => query || "",
+ "sort_field" => normalize_sort_field(sort_field),
+ "sort_order" => normalize_sort_order(sort_order)
}
+ end
- base_params =
- case cycle_status_filter do
- nil -> base_params
- :paid -> Map.put(base_params, "cycle_status_filter", "paid")
- :unpaid -> Map.put(base_params, "cycle_status_filter", "unpaid")
- end
+ defp normalize_sort_field(nil), do: ""
+ defp normalize_sort_field(field) when is_atom(field), do: Atom.to_string(field)
+ defp normalize_sort_field(field) when is_binary(field), do: field
+ defp normalize_sort_field(_), do: ""
- base_params =
- Enum.reduce(group_filters, base_params, fn {group_id_str, value}, acc ->
- param_value = if value == :in, do: "in", else: "not_in"
- Map.put(acc, "#{@group_filter_prefix}#{group_id_str}", param_value)
- end)
+ defp normalize_sort_order(nil), do: ""
+ defp normalize_sort_order(order) when is_atom(order), do: Atom.to_string(order)
+ defp normalize_sort_order(order) when is_binary(order), do: order
+ defp normalize_sort_order(_), do: ""
- base_params =
- if show_current_cycle do
- Map.put(base_params, "show_current_cycle", "true")
- else
- base_params
- end
-
- Enum.reduce(boolean_filters, base_params, fn {custom_field_id, filter_value}, acc ->
- param_key = "#{@boolean_filter_prefix}#{custom_field_id}"
- param_value = if filter_value == true, do: "true", else: "false"
- Map.put(acc, param_key, param_value)
+ defp add_group_filters(params, group_filters) do
+ Enum.reduce(group_filters, params, fn {group_id_str, value}, acc ->
+ param_value = if value == :in, do: "in", else: "not_in"
+ Map.put(acc, "#{@group_filter_prefix}#{group_id_str}", param_value)
end)
end
+ defp add_cycle_status_filter(params, nil), do: params
+ defp add_cycle_status_filter(params, :paid), do: Map.put(params, "cycle_status_filter", "paid")
+
+ defp add_cycle_status_filter(params, :unpaid),
+ do: Map.put(params, "cycle_status_filter", "unpaid")
+
+ defp add_cycle_status_filter(params, _), do: params
+
+ defp add_show_current_cycle(params, true), do: Map.put(params, "show_current_cycle", "true")
+ defp add_show_current_cycle(params, _), do: params
+
+ defp add_boolean_filters(params, boolean_filters) do
+ Enum.reduce(boolean_filters, params, &add_boolean_filter/2)
+ end
+
+ defp add_boolean_filter({custom_field_id, filter_value}, acc) do
+ param_key = "#{@boolean_filter_prefix}#{custom_field_id}"
+ param_value = if filter_value == true, do: "true", else: "false"
+ Map.put(acc, param_key, param_value)
+ end
+
# -------------------------------------------------------------
# Loading members
# -------------------------------------------------------------
diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex
index f809490..311447b 100644
--- a/lib/mv_web/live/member_live/index.html.heex
+++ b/lib/mv_web/live/member_live/index.html.heex
@@ -2,20 +2,12 @@
<.header>
{gettext("Members")}
<:actions>
-
+ <.live_component
+ module={MvWeb.Components.ExportDropdown}
+ id="export-dropdown"
+ export_payload_json={@export_payload_json}
+ selected_count={@selected_count}
+ />
<.button
class="secondary"
id="copy-emails-btn"
diff --git a/lib/mv_web/live/statistics_live.ex b/lib/mv_web/live/statistics_live.ex
new file mode 100644
index 0000000..32dda6f
--- /dev/null
+++ b/lib/mv_web/live/statistics_live.ex
@@ -0,0 +1,628 @@
+defmodule MvWeb.StatisticsLive do
+ @moduledoc """
+ LiveView for the statistics page at /statistics.
+
+ Displays aggregated member and membership fee cycle statistics.
+ """
+ use MvWeb, :live_view
+
+ require Logger
+
+ 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
+ # Only static assigns and fee types here; load_statistics runs once in handle_params
+ socket =
+ socket
+ |> assign(:page_title, gettext("Statistics"))
+ |> assign(:selected_fee_type_id, nil)
+ |> load_fee_types()
+
+ {:ok, socket}
+ end
+
+ @impl true
+ 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"""
+
+ <.header>
+ {gettext("Statistics")}
+ <:subtitle>{gettext("Overview from first membership to today")}
+
+
+
+ {gettext("Members")}
+
+
+
+
+ {gettext("Active members")}
+
+
": " <> to_string(@active_count)}
+ >
+ {@active_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("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")}
+
+
+
+
+
+
+
+ """
+ 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) when is_binary(id) do
+ trimmed = String.trim(id)
+
+ to =
+ if trimmed == "",
+ do: ~p"/statistics",
+ else: ~p"/statistics" <> "?" <> URI.encode_query(%{"fee_type_id" => trimmed})
+
+ {:noreply, push_patch(socket, to: to)}
+ end
+
+ attr :joins_exits_by_year, :list, required: true
+
+ 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
+
+ rows_with_pct =
+ Enum.map(rows, fn row ->
+ sum = row.joins + row.exits
+
+ bar_pct =
+ if max_total > 0 and sum > 0 do
+ min(100.0, Float.round(sum / max_total * 100, 1))
+ else
+ 0
+ end
+
+ seg_scale = max(sum, 1)
+ joins_pct = min(100.0, row.joins / seg_scale * 100)
+ exits_pct = min(100.0, 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"""
+
+
+
+
+ | {gettext("Year")} |
+ {gettext("Joins")} |
+ {gettext("Exits")} |
+
+
+
+ <%= for row <- @rows do %>
+
+ |
+ {row.year}
+ |
+
+
+ |
+
+
+ |
+
+ {row.joins}
+
+
+
+ |
+
+
+ {row.exits}
+
+
+
+ |
+
+ <% end %>
+
+
+
+ """
+ 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()
+ |> min(100.0)
+
+ unpaid_pct =
+ row.unpaid
+ |> Decimal.div(seg_scale)
+ |> Decimal.mult(100)
+ |> Decimal.to_float()
+ |> min(100.0)
+
+ suspended_pct =
+ row.suspended
+ |> Decimal.div(seg_scale)
+ |> Decimal.mult(100)
+ |> Decimal.to_float()
+ |> min(100.0)
+
+ %{
+ 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"""
+
+
+
+
+ | {gettext("Year")} |
+ {gettext("Paid")} |
+ {gettext("Unpaid")} |
+ {gettext("Suspended")} |
+ {gettext("Total")} |
+
+
+
+ <%= for row <- @rows do %>
+
+ |
+ <%= if row.summary do %>
+ {gettext("Total")}
+ <% else %>
+ {row.year}
+ <% end %>
+ |
+
+
+ |
+
+
+ |
+
+ {row.paid_formatted}
+
+
+
+ |
+
+
+ {row.unpaid_formatted}
+
+
+
+ |
+
+
+ {row.suspended_formatted}
+
+
+
+ |
+
+ {row.total_formatted}
+ |
+
+ <% end %>
+
+
+
+ """
+ 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)
+ pct = value |> Decimal.div(scale) |> Decimal.mult(100) |> Decimal.to_float()
+ min(100.0, pct)
+ 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"""
+
+
+ """
+ end
+
+ defp load_fee_types(socket) do
+ actor = current_actor(socket)
+
+ case MembershipFeeType
+ |> Ash.Query.sort(name: :asc)
+ |> Ash.read(domain: Mv.MembershipFees, actor: actor) do
+ {:ok, fee_types} ->
+ assign(socket, :membership_fee_types, fee_types)
+
+ {:error, reason} ->
+ Logger.warning("StatisticsLive: failed to load fee types: #{inspect(reason)}")
+
+ socket
+ |> put_flash(:error, gettext("Fee types could not be loaded."))
+ |> assign(:membership_fee_types, [])
+ end
+ end
+
+ defp load_statistics(socket) do
+ actor = current_actor(socket)
+ fee_type_id = socket.assigns[:selected_fee_type_id]
+ # Member stats must never depend on fee type (only contributions do)
+ opts_member = [actor: actor]
+
+ opts_contributions =
+ [actor: actor] ++ if fee_type_id, do: [fee_type_id: fee_type_id], else: []
+
+ current_year = Date.utc_today().year
+ first_year = Statistics.first_join_year(opts_member) || current_year
+ years = first_year..current_year |> Enum.to_list() |> Enum.reverse()
+
+ active_count = Statistics.active_member_count(opts_member)
+ inactive_count = Statistics.inactive_member_count(opts_member)
+ joins_exits_by_year = build_joins_exits_by_year(years, opts_member)
+ contributions_by_year = build_contributions_by_year(years, opts_contributions)
+ totals_over_all_years = sum_cycle_totals(contributions_by_year)
+
+ assign(socket,
+ years: years,
+ active_count: active_count,
+ inactive_count: inactive_count,
+ 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
diff --git a/lib/mv_web/page_paths.ex b/lib/mv_web/page_paths.ex
index 5606c76..2720c0f 100644
--- a/lib/mv_web/page_paths.ex
+++ b/lib/mv_web/page_paths.ex
@@ -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
diff --git a/lib/mv_web/router.ex b/lib/mv_web/router.ex
index 97e0642..61532ff 100644
--- a/lib/mv_web/router.ex
+++ b/lib/mv_web/router.ex
@@ -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
@@ -92,6 +95,7 @@ defmodule MvWeb.Router do
live "/admin/import-export", ImportExportLive
post "/members/export.csv", MemberExportController, :export
+ post "/members/export.pdf", MemberPdfExportController, :export
post "/set_locale", LocaleController, :set_locale
end
diff --git a/mix.exs b/mix.exs
index 8ca214c..6ac9e8d 100644
--- a/mix.exs
+++ b/mix.exs
@@ -79,7 +79,8 @@ defmodule Mv.MixProject do
{:picosat_elixir, "~> 0.1"},
{:ecto_commons, "~> 0.3"},
{:slugify, "~> 1.3"},
- {:nimble_csv, "~> 1.0"}
+ {:nimble_csv, "~> 1.0"},
+ {:imprintor, "~> 0.5.0"}
]
end
diff --git a/mix.lock b/mix.lock
index 98e8726..0b581ff 100644
--- a/mix.lock
+++ b/mix.lock
@@ -36,6 +36,7 @@
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"igniter": {:hex, :igniter, "0.7.2", "81c132c0df95963c7a228f74a32d3348773743ed9651f24183bfce0fe6ff16d1", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "f4cab73ec31f4fb452de1a17037f8a08826105265aa2d76486fcb848189bef9b"},
+ "imprintor": {:hex, :imprintor, "0.5.0", "3266aa8487cc6eab3915a578c79d49e489d1bacf959a6535b1ef32acc62d71cc", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "d4bbfbd26c2ddbb7eb38894b7412c0ef62f953cbb176df3cccbd266fe890c12f"},
"iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"},
@@ -68,6 +69,7 @@
"reactor": {:hex, :reactor, "1.0.0", "024bd13df910bcb8c01cebed4f10bd778269a141a1c8a234e4f67796ac4883cf", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "ae8eb507fffc517f5aa5947db9d2ede2db8bae63b66c94ccb5a2027d30f830a0"},
"req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
"rewrite": {:hex, :rewrite, "1.2.0", "80220eb14010e175b67c939397e1a8cdaa2c32db6e2e0a9d5e23e45c0414ce21", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "a1cd702bbb9d51613ab21091f04a386d750fc6f4516b81900df082d78b2d8c50"},
+ "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"},
"slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"},
"sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"},
"sourceror": {:hex, :sourceror, "1.10.1", "325753ed460fe9fa34ebb4deda76d57b2e1507dcd78a5eb9e1c41bfb78b7cdfe", [:mix], [], "hexpm", "288f3079d93865cd1e3e20df5b884ef2cb440e0e03e8ae393624ee8a770ba588"},
diff --git a/priv/fonts/.gitkeep b/priv/fonts/.gitkeep
new file mode 100644
index 0000000..0baaaae
--- /dev/null
+++ b/priv/fonts/.gitkeep
@@ -0,0 +1,5 @@
+# This file ensures the fonts directory is tracked by git
+# Place TTF font files here
+
+
+
diff --git a/priv/fonts/LiberationMono-Bold.ttf b/priv/fonts/LiberationMono-Bold.ttf
new file mode 100644
index 0000000..2e46737
Binary files /dev/null and b/priv/fonts/LiberationMono-Bold.ttf differ
diff --git a/priv/fonts/LiberationMono-BoldItalic.ttf b/priv/fonts/LiberationMono-BoldItalic.ttf
new file mode 100644
index 0000000..d1f46d7
Binary files /dev/null and b/priv/fonts/LiberationMono-BoldItalic.ttf differ
diff --git a/priv/fonts/LiberationMono-Italic.ttf b/priv/fonts/LiberationMono-Italic.ttf
new file mode 100644
index 0000000..954c394
Binary files /dev/null and b/priv/fonts/LiberationMono-Italic.ttf differ
diff --git a/priv/fonts/LiberationMono-Regular.ttf b/priv/fonts/LiberationMono-Regular.ttf
new file mode 100644
index 0000000..e774859
Binary files /dev/null and b/priv/fonts/LiberationMono-Regular.ttf differ
diff --git a/priv/fonts/LiberationSans-Bold.ttf b/priv/fonts/LiberationSans-Bold.ttf
new file mode 100644
index 0000000..dc5d57f
Binary files /dev/null and b/priv/fonts/LiberationSans-Bold.ttf differ
diff --git a/priv/fonts/LiberationSans-BoldItalic.ttf b/priv/fonts/LiberationSans-BoldItalic.ttf
new file mode 100644
index 0000000..158488a
Binary files /dev/null and b/priv/fonts/LiberationSans-BoldItalic.ttf differ
diff --git a/priv/fonts/LiberationSans-Italic.ttf b/priv/fonts/LiberationSans-Italic.ttf
new file mode 100644
index 0000000..25970d9
Binary files /dev/null and b/priv/fonts/LiberationSans-Italic.ttf differ
diff --git a/priv/fonts/LiberationSans-Regular.ttf b/priv/fonts/LiberationSans-Regular.ttf
new file mode 100644
index 0000000..e633985
Binary files /dev/null and b/priv/fonts/LiberationSans-Regular.ttf differ
diff --git a/priv/fonts/LiberationSerif-Bold.ttf b/priv/fonts/LiberationSerif-Bold.ttf
new file mode 100644
index 0000000..3c7c55b
Binary files /dev/null and b/priv/fonts/LiberationSerif-Bold.ttf differ
diff --git a/priv/fonts/LiberationSerif-BoldItalic.ttf b/priv/fonts/LiberationSerif-BoldItalic.ttf
new file mode 100644
index 0000000..6b35d9f
Binary files /dev/null and b/priv/fonts/LiberationSerif-BoldItalic.ttf differ
diff --git a/priv/fonts/LiberationSerif-Italic.ttf b/priv/fonts/LiberationSerif-Italic.ttf
new file mode 100644
index 0000000..54d5164
Binary files /dev/null and b/priv/fonts/LiberationSerif-Italic.ttf differ
diff --git a/priv/fonts/LiberationSerif-Regular.ttf b/priv/fonts/LiberationSerif-Regular.ttf
new file mode 100644
index 0000000..5e5550c
Binary files /dev/null and b/priv/fonts/LiberationSerif-Regular.ttf differ
diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po
index 968885c..1784d4b 100644
--- a/priv/gettext/de/LC_MESSAGES/default.po
+++ b/priv/gettext/de/LC_MESSAGES/default.po
@@ -150,9 +150,11 @@ msgstr "Hausnummer"
msgid "Notes"
msgstr "Notizen"
+#: lib/mv/membership/members_pdf.ex
#: 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"
@@ -318,6 +320,7 @@ msgstr "Benutzer*innen auflisten"
#: lib/mv_web/live/member_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/membership_fee_type_live/index.ex
+#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Members"
msgstr "Mitglieder"
@@ -762,6 +765,7 @@ msgstr "Dieses Feld darf nicht leer bleiben"
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/components/member_filter_component.ex
+#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "All"
msgstr "Alle"
@@ -929,16 +933,20 @@ msgstr "Vierteljährlich"
msgid "Status"
msgstr "Status"
+#: lib/mv/membership/members_pdf.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 "Suspended"
msgstr "Pausiert"
+#: lib/mv/membership/members_pdf.ex
#: 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"
@@ -2388,17 +2396,12 @@ msgstr "Mitgliederdaten verwalten"
msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, so they must be listed in the list of member data (like e-mail or first name). Unknown data field columns will be ignored with a warning."
msgstr "Verwende die Namen der Datenfelder als Spaltennamen in der CSV Datei. Datenfelder müssen in Mila bereits angelegt sein, damit sie importiert werden können. sie müssen in der Liste der Mitgliederdaten als Datenfeld enthalten sein (z.B. E-Mail). Spalten mit unbekannten Spaltenüberschriften werden mit einer Warnung ignoriert."
-#: lib/mv_web/live/member_live/index.html.heex
+#: lib/mv_web/components/export_dropdown.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Export members to CSV"
msgstr "Mitglieder importieren (CSV)"
-#: lib/mv_web/live/member_live/index.html.heex
-#, elixir-autogen, elixir-format
-msgid "Export to CSV"
-msgstr "Nach CSV exportieren"
-
-#: lib/mv_web/live/member_live/index.html.heex
+#: lib/mv_web/components/export_dropdown.ex
#, elixir-autogen, elixir-format
msgid "all"
msgstr "alle"
@@ -2474,3 +2477,148 @@ msgstr "Unbezahlt"
#, elixir-autogen, elixir-format
msgid "Member of group %{name}"
msgstr "Mitglied der Gruppe %{name}"
+
+#: lib/mv_web/live/statistics_live.ex
+#, elixir-autogen, elixir-format
+msgid "Active members"
+msgstr "Aktive Mitglieder"
+
+#: 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/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 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
+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 "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 "Contributions by year as table with stacked bars"
+msgstr "Beiträge nach Jahr als Tabelle mit gestapelten Balken"
+
+#: lib/mv_web/live/statistics_live.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Contributions"
+msgstr "Beiträge"
+
+#: lib/mv_web/live/statistics_live.ex
+#, elixir-autogen, elixir-format
+msgid "Fee type"
+msgstr "Beitragsart"
+
+#: lib/mv_web/live/statistics_live.ex
+#, elixir-autogen, elixir-format
+msgid "Member numbers by year"
+msgstr "Mitgliederzahlen 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} (Jahre mit Mitgliederdaten)"
+
+#: lib/mv_web/live/statistics_live.ex
+#, elixir-autogen, elixir-format
+msgid "Member numbers by year as table with bars"
+msgstr "Mitgliederzahlen nach Jahr als Tabelle mit Balken"
+
+#: lib/mv_web/live/statistics_live.ex
+#, elixir-autogen, elixir-format
+msgid "Fee types could not be loaded."
+msgstr "Beitragsarten konnten nicht geladen werden."
+
+#: lib/mv_web/components/export_dropdown.ex
+#, elixir-autogen, elixir-format
+msgid "CSV"
+msgstr "CSV"
+
+#: lib/mv/membership/members_pdf.ex
+#, elixir-autogen, elixir-format
+msgid "Created at:"
+msgstr "Erstellt am:"
+
+#: lib/mv_web/components/export_dropdown.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Export"
+msgstr "Nach CSV exportieren"
+
+#: lib/mv_web/controllers/member_pdf_export_controller.ex
+#, elixir-autogen, elixir-format
+msgid "Export contains %{count} rows, maximum is %{max}"
+msgstr "Export enthält %{count} Zeilen, Maximum ist %{max}"
+
+#: lib/mv_web/components/export_dropdown.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Export members to PDF"
+msgstr "Mitglieder als PDF exportieren"
+
+#: lib/mv_web/controllers/member_pdf_export_controller.ex
+#, elixir-autogen, elixir-format
+msgid "Failed to generate PDF export"
+msgstr "Erstellen des PDF Exports ist gescheitert"
+
+#: lib/mv/membership/members_pdf.ex
+#, elixir-autogen, elixir-format
+msgid "Member %{club_name}"
+msgstr "Mitglieder %{club_name}"
+
+#: lib/mv/membership/members_pdf.ex
+#, elixir-autogen, elixir-format
+msgid "Member count:"
+msgstr "Anzahl Mitglieder:"
+
+#: lib/mv_web/components/export_dropdown.ex
+#, elixir-autogen, elixir-format
+msgid "PDF"
+msgstr "PDF"
+
+#~ #: lib/mv_web/live/global_settings_live.ex
+#~ #, elixir-autogen, elixir-format, fuzzy
+#~ msgid "Custom Fields in CSV Import"
+#~ msgstr "Benutzerdefinierte Felder"
+
+#~ #, elixir-autogen, elixir-format
+#~ msgid "Failed to prepare CSV import: %{error}"
+#~ msgstr "Das Vorbereiten des CSV Imports ist gescheitert: %{error}"
diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot
index 49aaa94..af24afd 100644
--- a/priv/gettext/default.pot
+++ b/priv/gettext/default.pot
@@ -151,9 +151,11 @@ msgstr ""
msgid "Notes"
msgstr ""
+#: lib/mv/membership/members_pdf.ex
#: 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"
@@ -319,6 +321,7 @@ msgstr ""
#: lib/mv_web/live/member_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/membership_fee_type_live/index.ex
+#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Members"
msgstr ""
@@ -763,6 +766,7 @@ msgstr ""
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/components/member_filter_component.ex
+#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "All"
msgstr ""
@@ -930,16 +934,20 @@ msgstr ""
msgid "Status"
msgstr ""
+#: lib/mv/membership/members_pdf.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 "Suspended"
msgstr ""
+#: lib/mv/membership/members_pdf.ex
#: 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"
@@ -2389,17 +2397,12 @@ msgstr ""
msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, so they must be listed in the list of member data (like e-mail or first name). Unknown data field columns will be ignored with a warning."
msgstr ""
-#: lib/mv_web/live/member_live/index.html.heex
+#: lib/mv_web/components/export_dropdown.ex
#, elixir-autogen, elixir-format
msgid "Export members to CSV"
msgstr ""
-#: lib/mv_web/live/member_live/index.html.heex
-#, elixir-autogen, elixir-format
-msgid "Export to CSV"
-msgstr ""
-
-#: lib/mv_web/live/member_live/index.html.heex
+#: lib/mv_web/components/export_dropdown.ex
#, elixir-autogen, elixir-format
msgid "all"
msgstr ""
@@ -2475,3 +2478,139 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "Member of group %{name}"
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/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 "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 "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 ""
+
+#: lib/mv_web/live/statistics_live.ex
+#, elixir-autogen, elixir-format
+msgid "Contributions"
+msgstr ""
+
+#: lib/mv_web/live/statistics_live.ex
+#, elixir-autogen, elixir-format
+msgid "Fee type"
+msgstr ""
+
+#: lib/mv_web/live/statistics_live.ex
+#, elixir-autogen, elixir-format
+msgid "Member numbers 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 "Member numbers by year as table with bars"
+msgstr ""
+
+#: lib/mv_web/live/statistics_live.ex
+#, elixir-autogen, elixir-format
+msgid "Fee types could not be loaded."
+msgstr ""
+
+#: lib/mv_web/components/export_dropdown.ex
+#, elixir-autogen, elixir-format
+msgid "CSV"
+msgstr ""
+
+#: lib/mv/membership/members_pdf.ex
+#, elixir-autogen, elixir-format
+msgid "Created at:"
+msgstr ""
+
+#: lib/mv_web/components/export_dropdown.ex
+#, elixir-autogen, elixir-format
+msgid "Export"
+msgstr ""
+
+#: lib/mv_web/controllers/member_pdf_export_controller.ex
+#, elixir-autogen, elixir-format
+msgid "Export contains %{count} rows, maximum is %{max}"
+msgstr ""
+
+#: lib/mv_web/components/export_dropdown.ex
+#, elixir-autogen, elixir-format
+msgid "Export members to PDF"
+msgstr ""
+
+#: lib/mv_web/controllers/member_pdf_export_controller.ex
+#, elixir-autogen, elixir-format
+msgid "Failed to generate PDF export"
+msgstr ""
+
+#: lib/mv/membership/members_pdf.ex
+#, elixir-autogen, elixir-format
+msgid "Member %{club_name}"
+msgstr ""
+
+#: lib/mv/membership/members_pdf.ex
+#, elixir-autogen, elixir-format
+msgid "Member count:"
+msgstr ""
+
+#: lib/mv_web/components/export_dropdown.ex
+#, elixir-autogen, elixir-format
+msgid "PDF"
+msgstr ""
diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po
index 0b0efea..88da6ff 100644
--- a/priv/gettext/en/LC_MESSAGES/default.po
+++ b/priv/gettext/en/LC_MESSAGES/default.po
@@ -151,9 +151,11 @@ msgstr ""
msgid "Notes"
msgstr ""
+#: lib/mv/membership/members_pdf.ex
#: 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"
@@ -319,6 +321,7 @@ msgstr ""
#: lib/mv_web/live/member_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/membership_fee_type_live/index.ex
+#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Members"
msgstr ""
@@ -763,6 +766,7 @@ msgstr ""
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/components/member_filter_component.ex
+#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "All"
msgstr ""
@@ -930,16 +934,20 @@ msgstr ""
msgid "Status"
msgstr ""
+#: lib/mv/membership/members_pdf.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 "Suspended"
msgstr ""
+#: lib/mv/membership/members_pdf.ex
#: 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"
@@ -2389,17 +2397,12 @@ msgstr ""
msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, so they must be listed in the list of member data (like e-mail or first name). Unknown data field columns will be ignored with a warning."
msgstr ""
-#: lib/mv_web/live/member_live/index.html.heex
+#: lib/mv_web/components/export_dropdown.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Export members to CSV"
msgstr ""
-#: lib/mv_web/live/member_live/index.html.heex
-#, elixir-autogen, elixir-format
-msgid "Export to CSV"
-msgstr ""
-
-#: lib/mv_web/live/member_live/index.html.heex
+#: lib/mv_web/components/export_dropdown.ex
#, elixir-autogen, elixir-format
msgid "all"
msgstr ""
@@ -2475,3 +2478,144 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "Member of group %{name}"
msgstr ""
+
+#: lib/mv_web/live/statistics_live.ex
+#, elixir-autogen, elixir-format, fuzzy
+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/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 pie: paid %{paid}%%, unpaid %{unpaid}%%, suspended %{suspended}%%"
+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 "Overview from first membership to today"
+msgstr ""
+
+#: lib/mv_web/live/statistics_live.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Contributions by year as table with stacked bars"
+msgstr ""
+
+#: lib/mv_web/live/statistics_live.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Contributions"
+msgstr ""
+
+#: lib/mv_web/live/statistics_live.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Fee type"
+msgstr ""
+
+#: lib/mv_web/live/statistics_live.ex
+#, elixir-autogen, elixir-format
+msgid "Member numbers 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, fuzzy
+msgid "Member numbers by year as table with bars"
+msgstr ""
+
+#: lib/mv_web/live/statistics_live.ex
+#, elixir-autogen, elixir-format
+msgid "Fee types could not be loaded."
+msgstr ""
+
+#: lib/mv_web/components/export_dropdown.ex
+#, elixir-autogen, elixir-format
+msgid "CSV"
+msgstr ""
+
+#: lib/mv/membership/members_pdf.ex
+#, elixir-autogen, elixir-format
+msgid "Created at:"
+msgstr "Created at:"
+
+#: lib/mv_web/components/export_dropdown.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Export"
+msgstr ""
+
+#: lib/mv_web/controllers/member_pdf_export_controller.ex
+#, elixir-autogen, elixir-format
+msgid "Export contains %{count} rows, maximum is %{max}"
+msgstr ""
+
+#: lib/mv_web/components/export_dropdown.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Export members to PDF"
+msgstr ""
+
+#: lib/mv_web/controllers/member_pdf_export_controller.ex
+#, elixir-autogen, elixir-format
+msgid "Failed to generate PDF export"
+msgstr ""
+
+#: lib/mv/membership/members_pdf.ex
+#, elixir-autogen, elixir-format
+msgid "Member %{club_name}"
+msgstr "Member %{club_name}"
+
+#: lib/mv/membership/members_pdf.ex
+#, elixir-autogen, elixir-format
+msgid "Member count:"
+msgstr "Member count:"
+
+#: lib/mv_web/components/export_dropdown.ex
+#, elixir-autogen, elixir-format
+msgid "PDF"
+msgstr ""
+
+#~ #: lib/mv_web/live/global_settings_live.ex
+#~ #, elixir-autogen, elixir-format, fuzzy
+#~ msgid "Custom Fields in CSV Import"
+#~ msgstr ""
diff --git a/priv/pdf_templates/members_export.typ b/priv/pdf_templates/members_export.typ
new file mode 100644
index 0000000..5dca208
--- /dev/null
+++ b/priv/pdf_templates/members_export.typ
@@ -0,0 +1,95 @@
+// Typst template for member export (PDF)
+// Expected sys.inputs.elixir_data:
+// {
+// "columns": [{"key": "...", "kind": "...", "label": "..."}, ...],
+// "rows": [["cell1", "cell2", ...], ...],
+// "meta": {"generated_at": "...", "member_count": 123}
+// }
+
+#set page(
+ paper: "a4",
+ flipped: true,
+ margin: (top: 1.2cm, bottom: 1.2cm, left: 1.0cm, right: 1.0cm)
+)
+
+#set text(size: 9pt, hyphenate: true)
+#set heading(numbering: none)
+
+// Enable text wrapping in table cells
+#show table.cell: it => box(width: 100%)[#it]
+
+#let data = sys.inputs.elixir_data
+#let columns = data.at("columns", default: ())
+#let rows = data.at("rows", default: ())
+#let meta = data.at("meta", default: (generated_at: "", member_count: rows.len()))
+#let title = data.at("title", default: "Member Export")
+#let created_at_label = data.at("created_at_label", default: "Created at:")
+#let member_count_label = data.at("member_count_label", default: "Member count:")
+
+// Title
+#align(center)[
+ #text(size: 14pt, weight: "bold")[#title]
+]
+
+#v(0.4cm)
+
+// Export metadata
+#set text(size: 8pt, fill: black)
+#grid(
+ columns: (1fr, 1fr),
+ gutter: 1cm,
+ [*#created_at_label* #meta.at("generated_at", default: "")],
+ [*#member_count_label* #meta.at("member_count", default: rows.len())],
+)
+
+#v(0.6cm)
+
+// ---- Horizontal paging config ----
+#let fixed_count = calc.min(2, columns.len())
+#let max_dynamic_cols = 5
+#let fixed_col_widths = (32mm, 32mm)
+
+#let fixed_cols = columns.slice(0, fixed_count)
+#let dynamic_cols = columns.slice(fixed_count, columns.len())
+#let dynamic_chunks = dynamic_cols.chunks(max_dynamic_cols)
+
+#let render_chunk(chunk_index, dyn_cols_chunk) = [
+ #let dyn_count = dyn_cols_chunk.len()
+ #let start = fixed_count + chunk_index * max_dynamic_cols
+
+ #let page_cols = fixed_cols + dyn_cols_chunk
+ #let headers = page_cols.map(c => c.at("label", default: ""))
+
+ // widths: first two columns fixed (32mm, 42mm), rest distributed as 1fr
+ #let widths = (
+ if fixed_count >= 1 { fixed_col_widths.at(0) } else { 1fr },
+ if fixed_count >= 2 { fixed_col_widths.at(1) } else { 1fr },
+ ..((1fr,) * dyn_count)
+ )
+
+ #let header_cells = headers.map(h => text(weight: "bold", size: 9pt)[#h])
+
+ // Body cells (row-major), nur die Spalten dieses Chunks
+ #let body_cells = (
+ rows
+ .map(row => row.slice(0, fixed_count) + row.slice(start, start + dyn_count))
+ .map(cells => cells.map(cell => text(size: 8.5pt)[#cell]))
+ .flatten()
+ )
+
+ #table(
+ columns: widths,
+ table.header(..header_cells),
+ ..body_cells,
+ )
+]
+
+// ---- Output ----
+#if dynamic_cols.len() == 0 {
+ render_chunk(0, ())
+} else {
+ for (i, chunk) in dynamic_chunks.enumerate() {
+ render_chunk(i, chunk)
+ if i < dynamic_chunks.len() - 1 { pagebreak() }
+ }
+}
diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs
index f686c73..e96ca6e 100644
--- a/priv/repo/seeds.exs
+++ b/priv/repo/seeds.exs
@@ -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
diff --git a/test/mv/membership/member_export_build_test.exs b/test/mv/membership/member_export_build_test.exs
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/test/mv/membership/member_export_build_test.exs
@@ -0,0 +1 @@
+
diff --git a/test/mv/membership/members_pdf_test.exs b/test/mv/membership/members_pdf_test.exs
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/test/mv/membership/members_pdf_test.exs
@@ -0,0 +1 @@
+
diff --git a/test/mv/statistics_test.exs b/test/mv/statistics_test.exs
new file mode 100644
index 0000000..d4b4e05
--- /dev/null
+++ b/test/mv/statistics_test.exs
@@ -0,0 +1,227 @@
+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.Membership.Member
+ 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
+ # Guarantee empty member table so the assertion is deterministic
+ Member
+ |> Ash.read!(actor: actor)
+ |> Enum.each(&Ash.destroy!(&1, actor: actor))
+
+ result = Statistics.first_join_year(actor: actor)
+ assert is_nil(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
+
+ test "when fee_type_id is passed in opts, returns only cycles of that fee type", %{
+ actor: actor
+ } do
+ fee_type_a = create_fee_type(actor, %{amount: Decimal.new("30.00")})
+ fee_type_b = create_fee_type(actor, %{amount: Decimal.new("70.00")})
+
+ _m1 =
+ Mv.Fixtures.member_fixture(%{
+ join_date: ~D[2020-01-01],
+ membership_fee_type_id: fee_type_a.id
+ })
+
+ _m2 =
+ Mv.Fixtures.member_fixture(%{
+ join_date: ~D[2020-01-01],
+ membership_fee_type_id: fee_type_b.id
+ })
+
+ # Without filter: both fee types' cycles (2024)
+ all_result = Statistics.cycle_totals_by_year(2024, actor: actor)
+ assert Decimal.equal?(all_result.total, Decimal.new("100.00"))
+
+ # With fee_type_id as string (as from form/URL): only that type's cycles
+ opts_a = [actor: actor, fee_type_id: to_string(fee_type_a.id)]
+ result_a = Statistics.cycle_totals_by_year(2024, opts_a)
+ assert Decimal.equal?(result_a.total, Decimal.new("30.00"))
+
+ opts_b = [actor: actor, fee_type_id: to_string(fee_type_b.id)]
+ result_b = Statistics.cycle_totals_by_year(2024, opts_b)
+ assert Decimal.equal?(result_b.total, Decimal.new("70.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
diff --git a/test/mv_web/components/sidebar_authorization_test.exs b/test/mv_web/components/sidebar_authorization_test.exs
index 079572f..110d9e5 100644
--- a/test/mv_web/components/sidebar_authorization_test.exs
+++ b/test/mv_web/components/sidebar_authorization_test.exs
@@ -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")
diff --git a/test/mv_web/live/statistics_live_test.exs b/test/mv_web/live/statistics_live_test.exs
new file mode 100644
index 0000000..ed6128f
--- /dev/null
+++ b/test/mv_web/live/statistics_live_test.exs
@@ -0,0 +1,78 @@
+defmodule MvWeb.StatisticsLiveTest do
+ @moduledoc """
+ Tests for the Statistics LiveView at /statistics.
+
+ Uses explicit auth: conn is authenticated with a role that has access to
+ the statistics page (read_only by default; override with @tag :role).
+ """
+ use MvWeb.ConnCase, async: true
+
+ import Phoenix.LiveViewTest
+
+ alias Mv.MembershipFees.MembershipFeeType
+
+ describe "statistics page" do
+ @describetag role: :read_only
+ 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 =~ "Unpaid"
+ assert html =~ "Contributions by year"
+ assert html =~ "Member numbers 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
+
+ test "fee_type_id in URL updates selected filter and contributions", %{conn: conn} do
+ actor = Mv.Helpers.SystemActor.get_system_actor()
+
+ fee_types =
+ MembershipFeeType
+ |> Ash.Query.sort(name: :asc)
+ |> Ash.read!(domain: Mv.MembershipFees, actor: actor)
+
+ fee_type =
+ case List.first(fee_types) do
+ nil ->
+ MembershipFeeType
+ |> Ash.Changeset.for_create(:create, %{
+ name: "Test Fee #{System.unique_integer([:positive])}",
+ amount: Decimal.new("50.00"),
+ interval: :yearly
+ })
+ |> Ash.create!(actor: actor)
+
+ ft ->
+ ft
+ end
+
+ path = ~p"/statistics" <> "?" <> URI.encode_query(%{"fee_type_id" => fee_type.id})
+ {:ok, view, html} = live(conn, path)
+
+ assert view |> element("select#fee-type-filter") |> has_element?()
+ assert html =~ fee_type.name
+ assert html =~ "Contributions by year"
+ end
+ end
+
+ describe "statistics page with own_data role" do
+ @describetag role: :member
+ test "redirects when user has only own_data (no access to statistics page)", %{conn: conn} do
+ # member role uses own_data permission set; /statistics is not in own_data pages
+ conn = get(conn, ~p"/statistics")
+ assert redirected_to(conn) != ~p"/statistics"
+ end
+ end
+end
diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs
index 9d4a429..4f36795 100644
--- a/test/mv_web/member_live/index_test.exs
+++ b/test/mv_web/member_live/index_test.exs
@@ -522,7 +522,7 @@ defmodule MvWeb.MemberLive.IndexTest do
end
end
- describe "export to CSV" do
+ describe "export dropdown" do
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
@@ -535,34 +535,139 @@ defmodule MvWeb.MemberLive.IndexTest do
%{member1: m1}
end
- test "export button is rendered when no selection and shows (all)", %{conn: conn} do
+ test "export dropdown button is rendered when no selection and shows (all)", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
+ # Dropdown button should be present
+ assert html =~ ~s(data-testid="export-dropdown")
+ assert html =~ ~s(data-testid="export-dropdown-button")
+ assert html =~ "Export"
# Button text shows "all" when 0 selected (locale-dependent)
- assert html =~ "Export to CSV"
assert html =~ "all" or html =~ "All"
end
- test "after select_member event export button shows (1)", %{conn: conn, member1: member1} do
+ test "after select_member event export dropdown shows (1)", %{conn: conn, member1: member1} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
render_click(view, "select_member", %{"id" => member1.id})
html = render(view)
- assert html =~ "Export to CSV"
+ assert html =~ "Export"
assert html =~ "(1)"
end
- test "form has correct action and payload hidden input", %{conn: conn} do
+ test "dropdown opens and closes on click", %{conn: conn} do
conn = conn_with_oidc_user(conn)
- {:ok, _view, html} = live(conn, "/members")
+ {:ok, view, _html} = live(conn, "/members")
+ # Initially closed
+ refute has_element?(view, ~s([data-testid="export-dropdown-menu"]))
+
+ # Click to open
+ view
+ |> element(~s([data-testid="export-dropdown-button"]))
+ |> render_click()
+
+ # Menu should be visible
+ assert has_element?(view, ~s([data-testid="export-dropdown-menu"]))
+
+ # Click to close
+ view
+ |> element(~s([data-testid="export-dropdown-button"]))
+ |> render_click()
+
+ # Menu should be hidden
+ refute has_element?(view, ~s([data-testid="export-dropdown-menu"]))
+ end
+
+ test "dropdown has click-away and ESC handlers", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, _html} = live(conn, "/members")
+
+ # Open dropdown
+ view
+ |> element(~s([data-testid="export-dropdown-button"]))
+ |> render_click()
+
+ html = render(view)
+ assert has_element?(view, ~s([data-testid="export-dropdown-menu"]))
+
+ # Check that click-away handler is present
+ assert html =~ ~s(phx-click-away="close_dropdown")
+ # Check that ESC handler is present
+ assert html =~ ~s(phx-window-keydown="close_dropdown")
+ assert html =~ ~s(phx-key="Escape")
+ end
+
+ test "dropdown menu contains CSV and PDF export links with correct payload", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, _html} = live(conn, "/members")
+
+ # Open dropdown
+ view
+ |> element(~s([data-testid="export-dropdown-button"]))
+ |> render_click()
+
+ html = render(view)
+
+ # Check CSV link
+ assert html =~ ~s(data-testid="export-csv-link")
assert html =~ "/members/export.csv"
assert html =~ ~s(name="payload")
assert html =~ ~s(type="hidden")
assert html =~ ~s(name="_csrf_token")
+
+ # Check PDF link
+ assert html =~ ~s(data-testid="export-pdf-link")
+ assert html =~ "/members/export.pdf"
+ assert html =~ ~s(name="payload")
+ assert html =~ ~s(type="hidden")
+ assert html =~ ~s(name="_csrf_token")
+
+ # Both forms should have the same payload
+ csv_form_payload = extract_payload_from_form(html, "/members/export.csv")
+ pdf_form_payload = extract_payload_from_form(html, "/members/export.pdf")
+
+ assert csv_form_payload == pdf_form_payload
+ assert csv_form_payload != nil
+ end
+
+ test "dropdown has correct ARIA attributes", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, _html} = live(conn, "/members")
+
+ html = render(view)
+
+ # Button should have aria-haspopup="menu"
+ assert html =~ ~s(aria-haspopup="menu")
+ # Button should have aria-expanded="false" when closed
+ assert html =~ ~s(aria-expanded="false")
+ # Button should have aria-controls pointing to menu
+ assert html =~ ~s(aria-controls="export-dropdown-menu")
+
+ # Open dropdown
+ view
+ |> element(~s([data-testid="export-dropdown-button"]))
+ |> render_click()
+
+ html = render(view)
+ # Button should have aria-expanded="true" when open
+ assert html =~ ~s(aria-expanded="true")
+ # Menu should have role="menu"
+ assert html =~ ~s(role="menu")
+ end
+
+ # Helper to extract payload value from form HTML
+ defp extract_payload_from_form(html, action_path) do
+ case Regex.run(
+ ~r/