Merge remote-tracking branch 'origin/main' into feature/member-overview-groups
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing

This commit is contained in:
Simon 2026-02-16 15:57:57 +01:00
commit 6831ba046f
Signed by: simon
GPG key ID: 40E7A58C4AA1EDB2
48 changed files with 3516 additions and 182 deletions

View file

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

View file

@ -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,

View file

@ -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

View file

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

View file

@ -0,0 +1,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)

View file

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

View file

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

View file

@ -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

View file

@ -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

View file

@ -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

View file

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

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

@ -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

View file

@ -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}>
<li role="none">
<form>...</form>
</li>
</.dropdown_menu>
"""
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"""
<div
class="relative"
@ -170,7 +200,7 @@ defmodule MvWeb.CoreComponents do
phx-target={@phx_target}
phx-window-keydown="close_dropdown"
phx-key="Escape"
data-testid="dropdown-menu"
data-testid={@testid}
>
<button
type="button"
@ -180,10 +210,17 @@ defmodule MvWeb.CoreComponents do
aria-expanded={@open}
aria-controls={@id}
aria-label={@button_label}
class="btn focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-base-content/20"
class={[
"btn",
"focus:outline-none",
"focus-visible:ring-2",
"focus-visible:ring-offset-2",
"focus-visible:ring-base-content/20",
@button_class
]}
phx-click="toggle_dropdown"
phx-target={@phx_target}
data-testid="dropdown-button"
data-testid={@button_testid}
>
<%= if @icon do %>
<.icon name={@icon} />
@ -195,69 +232,79 @@ defmodule MvWeb.CoreComponents do
:if={@open}
id={@id}
role="menu"
class="absolute right-0 mt-2 bg-base-100 z-[100] p-2 shadow-lg rounded-box w-64 max-h-96 overflow-y-auto border border-base-300"
class={[
"absolute mt-2 bg-base-100 z-[100] p-2 shadow-lg rounded-box max-h-96 overflow-y-auto border border-base-300",
if(@menu_align == "left", do: "left-0", else: "right-0"),
@menu_width,
@menu_class
]}
tabindex="0"
phx-target={@phx_target}
data-testid={@menu_testid}
>
<li :if={@show_select_buttons} role="none">
<div class="flex justify-between items-center mb-2 px-2">
<span class="font-semibold">{gettext("Options")}</span>
<div class="flex gap-1">
<button
type="button"
role="menuitem"
aria-label={gettext("Select all")}
phx-click="select_all"
phx-target={@phx_target}
class="btn btn-xs btn-ghost"
>
{gettext("All")}
</button>
<button
type="button"
role="menuitem"
aria-label={gettext("Select none")}
phx-click="select_none"
phx-target={@phx_target}
class="btn btn-xs btn-ghost"
>
{gettext("None")}
</button>
<%= if assigns.inner_block != [] do %>
{render_slot(@inner_block)}
<% else %>
<li :if={@show_select_buttons} role="none">
<div class="flex justify-between items-center mb-2 px-2">
<span class="font-semibold">{gettext("Options")}</span>
<div class="flex gap-1">
<button
type="button"
role="menuitem"
aria-label={gettext("Select all")}
phx-click="select_all"
phx-target={@phx_target}
class="btn btn-xs btn-ghost"
>
{gettext("All")}
</button>
<button
type="button"
role="menuitem"
aria-label={gettext("Select none")}
phx-click="select_none"
phx-target={@phx_target}
class="btn btn-xs btn-ghost"
>
{gettext("None")}
</button>
</div>
</div>
</div>
</li>
<li :if={@show_select_buttons} role="separator" class="divider my-1"></li>
<%= for item <- @items do %>
<li role="none">
<button
type="button"
role={if @checkboxes, do: "menuitemcheckbox", else: "menuitem"}
aria-label={item.label}
aria-checked={
if @checkboxes, do: to_string(Map.get(@selected, item.value, true)), else: nil
}
tabindex="0"
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-base-content/20 focus-visible:ring-inset"
phx-click="select_item"
phx-keydown="select_item"
phx-key="Enter"
phx-value-item={item.value}
phx-target={@phx_target}
>
<%= if @checkboxes do %>
<input
type="checkbox"
checked={Map.get(@selected, item.value, true)}
class="checkbox checkbox-sm checkbox-primary pointer-events-none"
tabindex="-1"
aria-hidden="true"
/>
<% end %>
<span>{item.label}</span>
</button>
</li>
<li :if={@show_select_buttons} role="separator" class="divider my-1"></li>
<%= for item <- @items do %>
<li role="none">
<button
type="button"
role={if @checkboxes, do: "menuitemcheckbox", else: "menuitem"}
aria-label={item.label}
aria-checked={
if @checkboxes, do: to_string(Map.get(@selected, item.value, true)), else: nil
}
tabindex="0"
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-base-content/20 focus-visible:ring-inset"
phx-click="select_item"
phx-keydown="select_item"
phx-key="Enter"
phx-value-item={item.value}
phx-target={@phx_target}
>
<%= if @checkboxes do %>
<input
type="checkbox"
checked={Map.get(@selected, item.value, true)}
class="checkbox checkbox-sm checkbox-primary pointer-events-none"
tabindex="-1"
aria-hidden="true"
/>
<% end %>
<span>{item.label}</span>
</button>
</li>
<% end %>
<% end %>
</ul>
</div>
@ -512,7 +559,7 @@ defmodule MvWeb.CoreComponents do
{render_slot(@subtitle)}
</p>
</div>
<div class="flex-none">{render_slot(@actions)}</div>
<div class="flex gap-4 justify-end">{render_slot(@actions)}</div>
</header>
"""
end

View file

@ -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"""
<div id={@id} data-testid="export-dropdown" class="flex-auto flex-wrap">
<.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"
>
<li role="none">
<form method="post" action={~p"/members/export.csv"} target="_blank" class="w-full">
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
<input type="hidden" name="payload" value={@export_payload_json} />
<button
type="submit"
role="menuitem"
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-base-content/20 focus-visible:ring-inset"
aria-label={gettext("Export members to CSV")}
data-testid="export-csv-link"
>
<.icon name="hero-document-arrow-down" class="h-4 w-4" />
<span>{gettext("CSV")}</span>
</button>
</form>
</li>
<li role="none">
<form method="post" action={~p"/members/export.pdf"} target="_blank" class="w-full">
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
<input type="hidden" name="payload" value={@export_payload_json} />
<button
type="submit"
role="menuitem"
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-base-content/20 focus-visible:ring-inset"
aria-label={gettext("Export members to PDF")}
data-testid="export-pdf-link"
>
<.icon name="hero-document-text" class="h-4 w-4" />
<span>{gettext("PDF")}</span>
</button>
</form>
</li>
</.dropdown_menu>
</div>
"""
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

View file

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

View file

@ -0,0 +1,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

View file

@ -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
# -------------------------------------------------------------

View file

@ -2,20 +2,12 @@
<.header>
{gettext("Members")}
<:actions>
<form method="post" action={~p"/members/export.csv"} target="_blank" class="inline">
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
<input type="hidden" name="payload" value={@export_payload_json} />
<button
type="submit"
class="btn btn-secondary gap-2"
aria-label={gettext("Export members to CSV")}
>
<.icon name="hero-arrow-down-tray" />
{gettext("Export to CSV")} ({if @selected_count == 0,
do: gettext("all"),
else: @selected_count})
</button>
</form>
<.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"

View file

@ -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"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
{gettext("Statistics")}
<:subtitle>{gettext("Overview from first membership to today")}</:subtitle>
</.header>
<section class="mb-8" aria-labelledby="members-heading">
<h2 id="members-heading" class="text-xl font-semibold mb-4">{gettext("Members")}</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
<div class="card bg-base-200 shadow-md border border-base-300">
<div class="card-body p-5">
<h3 class="card-title text-sm font-medium text-base-content/80">
{gettext("Active members")}
</h3>
<p
class="text-3xl font-bold tabular-nums"
aria-label={gettext("Active members") <> ": " <> to_string(@active_count)}
>
{@active_count}
</p>
</div>
</div>
<div class="card bg-base-200 shadow-md border border-base-300">
<div class="card-body p-5">
<h3 class="card-title text-sm font-medium text-base-content/80">
{gettext("Inactive members")}
</h3>
<p
class="text-3xl font-bold tabular-nums"
aria-label={gettext("Inactive members") <> ": " <> to_string(@inactive_count)}
>
{@inactive_count}
</p>
</div>
</div>
</div>
<div class="card bg-base-200 shadow-md border border-base-300">
<div class="card-body">
<h3 class="card-title text-lg mb-4">{gettext("Member numbers by year")}</h3>
<p class="text-sm text-base-content/70 mb-4">
{gettext("From %{first} to %{last} (relevant years with membership data)",
first: @years |> List.last() |> to_string(),
last: @years |> List.first() |> to_string()
)}
</p>
<.member_numbers_table joins_exits_by_year={@joins_exits_by_year} />
</div>
</div>
</section>
<section class="mb-8" aria-labelledby="contributions-heading">
<h2 id="contributions-heading" class="text-xl font-semibold mb-4">
{gettext("Contributions")}
</h2>
<div class="flex flex-wrap items-end gap-4 mb-6">
<div class="flex items-center gap-2">
<form id="fee-type-form" phx-change="change_fee_type" class="flex items-center gap-2">
<label for="fee-type-filter" class="text-sm font-medium text-base-content/80">
{gettext("Fee type")}:
</label>
<select
id="fee-type-filter"
name="fee_type_id"
class="select select-bordered select-sm min-w-[10rem]"
>
<option value="" selected={@selected_fee_type_id in [nil, ""]}>
{gettext("All")}
</option>
<%= for ft <- @membership_fee_types do %>
<option
value={ft.id}
selected={to_string(@selected_fee_type_id) == to_string(ft.id)}
>
{ft.name}
</option>
<% end %>
</select>
</form>
</div>
</div>
<div class="card bg-base-200 shadow-md border border-base-300">
<div class="card-body">
<h3 class="card-title text-lg mb-4">{gettext("Contributions by year")}</h3>
<div class="flex flex-col gap-8 items-start">
<div class="w-full">
<.contributions_bars_by_year
contributions_by_year={@contributions_by_year}
totals_over_all_years={@totals_over_all_years}
/>
</div>
<div class="w-full flex flex-col items-center pt-6 mt-2 border-t border-base-300">
<h4 class="text-sm font-semibold mb-3">{gettext("All years combined (pie)")}</h4>
<.contributions_pie cycle_totals={@totals_over_all_years} />
<p class="text-xs text-base-content/70 mt-2">
<span class="inline-block w-2 h-2 rounded-full bg-success align-middle mr-1"></span>
{gettext("Paid")}
<span class="inline-block w-2 h-2 rounded-full bg-warning align-middle mx-2 mr-1">
</span>
{gettext("Unpaid")}
<span class="inline-block w-2 h-2 rounded-full bg-base-content/20 align-middle mx-2 mr-1">
</span>
{gettext("Suspended")}
</p>
</div>
</div>
</div>
</div>
</section>
</Layouts.app>
"""
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"""
<div
class="overflow-x-auto"
role="img"
aria-label={gettext("Member numbers by year as table with bars")}
>
<table class="table table-sm w-full">
<thead class="bg-base-300">
<tr>
<th scope="col" class="text-base-content font-semibold w-20">{gettext("Year")}</th>
<th scope="col" class="text-base-content font-semibold">{gettext("Joins")}</th>
<th scope="col" class="text-base-content font-semibold">{gettext("Exits")}</th>
</tr>
</thead>
<tbody>
<%= for row <- @rows do %>
<tr>
<td
rowspan="2"
class="font-mono align-middle border-b-0"
>
{row.year}
</td>
<td colspan="2" class="align-top p-1 pb-0 border-b-0">
<div class="h-6 rounded overflow-hidden bg-base-300 relative min-w-[4rem]">
<div
class="flex h-full absolute left-0 top-0 bottom-0 min-w-0 rounded"
style={"width: #{max(0, row.bar_pct)}%"}
>
<div
class="h-full bg-success min-w-0 rounded-l"
style={"width: #{row.joins_pct}%"}
title={gettext("Joins")}
>
</div>
<div
class="h-full bg-error min-w-0 rounded-r"
style={"width: #{row.exits_pct}%"}
title={gettext("Exits")}
>
</div>
</div>
</div>
</td>
</tr>
<tr>
<td class="align-middle text-xs font-mono text-base-content whitespace-nowrap pt-0">
<span class="inline-flex items-center gap-1.5">
{row.joins}
<span
class="inline-block w-2 h-2 rounded-full bg-success shrink-0"
aria-hidden="true"
title={gettext("Joins")}
>
</span>
</span>
</td>
<td class="align-middle text-xs font-mono text-base-content whitespace-nowrap pt-0">
<span class="inline-flex items-center gap-1.5">
{row.exits}
<span
class="inline-block w-2 h-2 rounded-full bg-error shrink-0"
aria-hidden="true"
title={gettext("Exits")}
>
</span>
</span>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
"""
end
attr :contributions_by_year, :list, required: true
attr :totals_over_all_years, :map, required: true
defp contributions_bars_by_year(assigns) do
rows = assigns.contributions_by_year
totals = assigns.totals_over_all_years
all_rows_with_decimals =
Enum.map(rows, fn row ->
%{
year: row.year,
summary: false,
total: row.total,
paid: row.paid,
unpaid: row.unpaid,
suspended: row.suspended
}
end) ++
[
%{
year: nil,
summary: true,
total: totals.total,
paid: totals.paid,
unpaid: totals.unpaid,
suspended: totals.suspended
}
]
max_total = max_decimal(all_rows_with_decimals, :total)
rows_with_pct =
Enum.map(all_rows_with_decimals, fn row ->
bar_pct = bar_pct(row.total, max_total)
sum_positive =
Decimal.add(Decimal.add(row.paid, row.unpaid), row.suspended)
seg_scale =
if Decimal.compare(sum_positive, 0) == :gt, do: sum_positive, else: Decimal.new(1)
paid_pct =
row.paid
|> Decimal.div(seg_scale)
|> Decimal.mult(100)
|> Decimal.to_float()
|> 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"""
<div
class="overflow-x-auto"
role="img"
aria-label={gettext("Contributions by year as table with stacked bars")}
>
<table class="table table-sm w-full">
<thead class="bg-base-300">
<tr>
<th scope="col" class="text-base-content font-semibold w-20">{gettext("Year")}</th>
<th scope="col" class="text-base-content font-semibold">{gettext("Paid")}</th>
<th scope="col" class="text-base-content font-semibold">{gettext("Unpaid")}</th>
<th scope="col" class="text-base-content font-semibold">{gettext("Suspended")}</th>
<th scope="col" class="text-base-content font-semibold">{gettext("Total")}</th>
</tr>
</thead>
<tbody>
<%= for row <- @rows do %>
<tr class={row.summary && "border-t-2 border-base-300 bg-base-300/30"}>
<td
rowspan="2"
class={"font-mono align-middle border-b-0 #{if row.summary, do: "font-semibold", else: ""}"}
>
<%= if row.summary do %>
{gettext("Total")}
<% else %>
{row.year}
<% end %>
</td>
<td colspan="4" class="align-top p-1 pb-0 border-b-0">
<div class="h-6 rounded overflow-hidden bg-base-300 relative min-w-[4rem]">
<div
class="flex h-full absolute left-0 top-0 bottom-0 min-w-0 rounded"
style={"width: #{max(0, Float.round(row.bar_pct, 1))}%"}
>
<div
class="h-full bg-success min-w-0 rounded-l"
style={"width: #{Float.round(row.paid_pct, 1)}%"}
title={gettext("Paid")}
>
</div>
<div
class="h-full bg-warning min-w-0"
style={"width: #{Float.round(row.unpaid_pct, 1)}%"}
title={gettext("Unpaid")}
>
</div>
<div
class="h-full bg-base-content/20 min-w-0 rounded-r"
style={"width: #{Float.round(row.suspended_pct, 1)}%"}
title={gettext("Suspended")}
>
</div>
</div>
</div>
</td>
</tr>
<tr class={row.summary && "bg-base-300/30"}>
<td class="align-middle text-xs font-mono text-base-content whitespace-nowrap pt-0">
<span class="inline-flex items-center gap-1.5">
{row.paid_formatted}
<span
class="inline-block w-2 h-2 rounded-full bg-success shrink-0"
aria-hidden="true"
title={gettext("Paid")}
>
</span>
</span>
</td>
<td class="align-middle text-xs font-mono text-base-content whitespace-nowrap pt-0">
<span class="inline-flex items-center gap-1.5">
{row.unpaid_formatted}
<span
class="inline-block w-2 h-2 rounded-full bg-warning shrink-0"
aria-hidden="true"
title={gettext("Unpaid")}
>
</span>
</span>
</td>
<td class="align-middle text-xs font-mono text-base-content whitespace-nowrap pt-0">
<span class="inline-flex items-center gap-1.5">
{row.suspended_formatted}
<span
class="inline-block w-2 h-2 rounded-full bg-base-content/20 shrink-0"
aria-hidden="true"
title={gettext("Suspended")}
>
</span>
</span>
</td>
<td class="align-middle text-xs font-mono text-base-content whitespace-nowrap pt-0">
{row.total_formatted}
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
"""
end
defp max_decimal(rows, key) do
Enum.reduce(rows, Decimal.new(0), fn row, acc ->
val = Map.get(row, key)
if Decimal.compare(val, acc) == :gt, do: val, else: acc
end)
end
defp bar_pct(value, max) do
scale = if Decimal.compare(max, 0) == :gt, do: max, else: Decimal.new(1)
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"""
<div
class="w-40 h-40 min-h-[10rem] rounded-full shrink-0 border-2 border-base-300 bg-base-300"
style={"background: conic-gradient(#{@gradient_stops});"}
role="img"
aria-label={
gettext(
"Contributions pie: paid %{paid}%%, unpaid %{unpaid}%%, suspended %{suspended}%%",
paid: Float.round(@paid_pct, 1),
unpaid: Float.round(@unpaid_pct, 1),
suspended: Float.round(@suspended_pct, 1)
)
}
title={"#{gettext("Paid")}: #{Float.round(@paid_pct, 1)}%, #{gettext("Unpaid")}: #{Float.round(@unpaid_pct, 1)}%, #{gettext("Suspended")}: #{Float.round(@suspended_pct, 1)}%"}
>
</div>
"""
end
defp load_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

View file

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

View file

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

View file

@ -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

View file

@ -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"},

5
priv/fonts/.gitkeep Normal file
View file

@ -0,0 +1,5 @@
# This file ensures the fonts directory is tracked by git
# Place TTF font files here

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -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}"

View file

@ -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 ""

View file

@ -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 ""

View file

@ -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() }
}
}

View file

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

View file

@ -0,0 +1 @@

View file

@ -0,0 +1 @@

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

@ -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

View file

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

View file

@ -0,0 +1,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

View file

@ -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/<form[^>]*action="#{Regex.escape(action_path)}"[^>]*>.*?<input[^>]*name="payload"[^>]*value="([^"]+)"/s,
html
) do
[_, payload] -> payload
_ -> nil
end
end
end

View file

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