diff --git a/.drone.yml b/.drone.yml index a1ace49..a2eacef 100644 --- a/.drone.yml +++ b/.drone.yml @@ -273,7 +273,7 @@ environment: steps: - name: renovate - image: renovate/renovate:43.10 + image: renovate/renovate:43.8 environment: RENOVATE_CONFIG_FILE: "renovate_backend_config.js" RENOVATE_TOKEN: diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index cc58ca9..565cbdd 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -118,8 +118,7 @@ lib/ │ ├── mailer.ex # Email mailer │ ├── release.ex # Release tasks │ ├── repo.ex # Database repository -│ ├── secrets.ex # Secret management -│ └── statistics.ex # Reporting: member/cycle aggregates (counts, sums by year) +│ └── secrets.ex # Secret management ├── mv_web/ # Web interface layer │ ├── components/ # UI components │ │ ├── core_components.ex @@ -156,7 +155,6 @@ lib/ │ │ ├── import_export_live.ex # CSV import/export LiveView (mount/events/glue only) │ │ ├── import_export_live/ # Import/Export UI components │ │ │ └── components.ex # custom_fields_notice, template_links, import_form, progress, results -│ │ ├── statistics_live.ex # Statistics page (aggregates, year filter, joins/exits by year) │ │ └── contribution_type_live/ # Contribution types (mock-up) │ ├── auth_overrides.ex # AshAuthentication overrides │ ├── endpoint.ex # Phoenix endpoint diff --git a/docs/feature-roadmap.md b/docs/feature-roadmap.md index 3812598..7e28eea 100644 --- a/docs/feature-roadmap.md +++ b/docs/feature-roadmap.md @@ -291,10 +291,10 @@ #### 10. **Reporting & Analytics** 📊 **Current State:** -- ✅ **Statistics page (MVP)** – `/statistics` with active/inactive member counts, joins/exits by year, cycle totals, open amount (2026-02-10) +- ❌ No reporting features **Missing Features:** -- ❌ Extended member statistics dashboard +- ❌ Member statistics dashboard - ❌ Membership growth charts - ❌ Payment reports - ❌ Custom report builder diff --git a/docs/groups-architecture.md b/docs/groups-architecture.md index 27d9d18..735898c 100644 --- a/docs/groups-architecture.md +++ b/docs/groups-architecture.md @@ -314,24 +314,9 @@ lib/ - Display group name and description - List all members in group - Link to member detail pages -- Add members to group (via inline combobox with search/autocomplete) -- Remove members from group (via remove button per member) - Edit group button (navigates to `/groups/:slug/edit`) - Delete group button (with confirmation modal) -**Add Member Functionality:** -- "Add Member" button displayed above member table (only for users with `:update` permission) -- Opens inline add member area with member search/autocomplete (combobox) -- Search filters out members already in the group -- Selecting a member adds them to the group immediately -- Success/error flash messages provide feedback -- "Cancel" button closes the inline add member area without adding - -**Remove Member Functionality:** -- "Remove" button (icon button) for each member in table (only for users with `:update` permission) -- Clicking remove immediately removes member from group (no confirmation dialog) -- Success/error flash messages provide feedback - **Note:** Uses slug for routing to provide URL-friendly, readable group URLs (e.g., `/groups/board-members`). ### Group Form Pages @@ -769,7 +754,6 @@ Each functional unit can be implemented as a **separate issue**: - **Issue 4:** Groups in Member Detail (Unit 5) - **Issue 5:** Groups in Member Search (Unit 6) - **Issue 6:** Permissions (Unit 7) -- **Issue 7:** Add/Remove Members in Group Detail View **Alternative:** Issues 3 and 4 can be combined, as they both concern the display of groups. @@ -815,27 +799,6 @@ Each functional unit can be implemented as a **separate issue**: **Estimation:** 3-4h -### Phase 2a: Add/Remove Members in Group Detail View - -**Goal:** Enable adding and removing members from groups via UI - -**Tasks:** -1. Add "Add Member" button above member table in Group Detail View -2. Implement inline add member with search/autocomplete -3. Add "Remove" button for each member in table -4. Implement add/remove functionality with flash messages -5. Ensure proper authorization checks - -**Deliverables:** -- Members can be added to groups via UI -- Members can be removed from groups via UI -- Proper feedback via flash messages -- Authorization enforced - -**Estimation:** 2-3h - -**Note:** This phase extends Phase 2 and can be implemented as Issue 7 after Issue 2 is complete. - ### Phase 3: Member Overview Integration **Goal:** Display and filter groups in member overview @@ -902,9 +865,9 @@ Each functional unit can be implemented as a **separate issue**: **Estimation:** 1-2h -### Total Estimation: 15-21h +### Total Estimation: 13-18h -**Note:** This includes all 7 issues. The original MVP estimation was 13-15h, with Issue 7 adding 2-3h for the add/remove members functionality in the Group Detail View. +**Note:** This aligns with the issue estimation of 15h. --- @@ -997,55 +960,6 @@ Each functional unit can be implemented as a **separate issue**: - Only admins can manage groups - All users can view groups (if they can view members) -### Issue 7: Add/Remove Members in Group Detail View -**Type:** Frontend -**Estimation:** 2-3h -**Dependencies:** Issue 1 (Backend must be functional), Issue 2 (Group Detail View must exist) - -**Tasks:** -- Add "Add Member" button above member table in Group Detail View (`/groups/:slug`) -- Implement inline add member for member selection with search/autocomplete -- Add "Remove" button for each member in the member table -- Implement add member functionality (create MemberGroup association) -- Implement remove member functionality (destroy MemberGroup association) -- Add flash messages for success/error feedback -- Ensure proper authorization checks (only users with `:update` permission on Group can add/remove) -- Filter out members already in the group from search results -- Reload group data after add/remove operations - -**Acceptance Criteria:** -- "Add Member" button is visible above member table (only for users with `:update` permission) -- Clicking "Add Member" opens inline add member area with member search/autocomplete -- Search filters members and excludes those already in the group -- Selecting a member from search adds them to the group -- Success flash message is displayed when member is added -- Error flash message is displayed if member is already in group or other error occurs -- Each member row in the table has a "Remove" button (only visible for users with `:update` permission) -- Clicking "Remove" immediately removes the member from the group (no confirmation dialog) -- Success flash message is displayed when member is removed -- Group member list and member count update automatically after add/remove -- Inline add member area closes after successful member addition -- Authorization is enforced server-side in event handlers -- UI respects permission checks (buttons hidden for unauthorized users) - -**Technical Notes:** -- Reuse member search pattern from `UserLive.Form` (ComboBox hook with autocomplete) -- Use `Membership.create_member_group/1` for adding members -- Use `Membership.destroy_member_group/1` for removing members -- Filter search results to exclude members already in the group (check `group.members`) -- Reload group with `:members` and `:member_count` after operations -- Use inline combobox pattern (delete group uses a separate confirmation modal) -- Ensure accessibility: proper ARIA labels, keyboard navigation, focus management - -**UI/UX Details:** -- Inline add member section (no modal; combobox above member table) -- Search input placeholder: "Search for a member..." -- Search results show member name and email -- "Add" button in inline area (disabled until member selected) -- "Cancel" button to close inline add member area -- Remove button can be an icon button (trash icon) with tooltip -- Flash messages: "Member added successfully" / "Member removed successfully" / error messages - --- ## Testing Strategy diff --git a/docs/page-permission-route-coverage.md b/docs/page-permission-route-coverage.md index b8eafbd..9151a44 100644 --- a/docs/page-permission-route-coverage.md +++ b/docs/page-permission-route-coverage.md @@ -26,7 +26,6 @@ This document lists all protected routes, which permission set may access them, | `/groups/new` | ✗ | ✗ | ✗ | ✓ | | `/groups/:slug` | ✗ | ✓ | ✓ | ✓ | | `/groups/:slug/edit` | ✗ | ✗ | ✗ | ✓ | -| `/statistics` | ✗ | ✓ | ✓ | ✓ | | `/admin/roles` | ✗ | ✗ | ✗ | ✓ | | `/admin/roles/new` | ✗ | ✗ | ✗ | ✓ | | `/admin/roles/:id` | ✗ | ✗ | ✗ | ✓ | diff --git a/docs/statistics-page-implementation-plan.md b/docs/statistics-page-implementation-plan.md deleted file mode 100644 index 2225ae9..0000000 --- a/docs/statistics-page-implementation-plan.md +++ /dev/null @@ -1,163 +0,0 @@ -# Statistics Page – Implementation Plan - -**Project:** Mila – Membership Management System -**Feature:** Statistics page at `/statistics` -**Scope:** MVP only (no export, no optional extensions) -**Last updated:** 2026-02-10 - ---- - -## Decisions (from clarification) - -| Topic | Decision | -|-------|----------| -| Route | `/statistics` | -| Navigation | Top-level menu (next to Members, Fee Types) | -| Permission | read_only, normal_user, admin (same as member list) | -| Charts | HTML/CSS and SVG only (no Contex, no Chart.js) | -| MVP scope | Minimal: active/inactive, joins/exits per year, contribution sums per year, open amount | -| Open amount | Total unpaid only (no overdue vs. not-yet-due split in MVP) | - -Excluded from this plan: Export (CSV/PDF), caching, month/quarter filters, “members per fee type”, “members per group”, and overdue split. - ---- - -## 1. Statistics module (`Mv.Statistics`) - -**Goal:** Central module for all statistics; LiveView only calls this API. Uses Ash reads with actor so policies apply. - -**Location:** `lib/mv/statistics.ex` (new). - -**Functions to implement:** - -| Function | Purpose | Data source | -|----------|---------|-------------| -| `active_member_count(opts)` | Count members with `exit_date == nil` | `Member` read with filter | -| `inactive_member_count(opts)` | Count members with `exit_date != nil` | `Member` read with filter | -| `joins_by_year(year, opts)` | Count members with `join_date` in given year | `Member` read, filter by year, count | -| `exits_by_year(year, opts)` | Count members with `exit_date` in given year | `Member` read, filter by year, count | -| `cycle_totals_by_year(year, opts)` | For cycles with `cycle_start` in year: total sum, and sums/counts by status (paid, unpaid, suspended) | `MembershipFeeCycle` read (filter by year via `cycle_start`), aggregate sum(amount) and count per status in Elixir or via Ash aggregates | -| `open_amount_total(opts)` | Sum of `amount` for all cycles with `status == :unpaid` | `MembershipFeeCycle` read with filter `status == :unpaid`, sum(amount) | - -All functions accept `opts` (keyword list) and pass `actor: opts[:actor]` (and `domain:` where needed) to Ash calls. No new resources; only read actions on existing `Member` and `MembershipFeeCycle`. - -**Implementation notes:** - -- Use `Ash.Query.filter(Member, expr(...))` for date filters; for “year”, filter `join_date >= first_day_of_year` and `join_date <= last_day_of_year` (same for `exit_date` and for `MembershipFeeCycle.cycle_start`). -- For `cycle_totals_by_year`: either multiple Ash reads (one per status) with sum aggregate, or one read of cycles in that year and `Enum.group_by(..., :status)` then sum amounts in Elixir. -- Use `Mv.MembershipFees.CalendarCycles` only if needed for interval (e.g. cycle_end); for “cycle in year” the `cycle_start` year is enough. - -**Tests:** Unit tests in `test/mv/statistics_test.exs` for each function (with fixtures: members with join_date/exit_date, cycles with cycle_start/amount/status). Use `Mv.Helpers.SystemActor.get_system_actor()` in tests for Ash read authorization where appropriate. - ---- - -## 2. Route and authorization - -**Router** ([lib/mv_web/router.ex](lib/mv_web/router.ex)): - -- In the same `ash_authentication_live_session` block where `/members` and `/membership_fee_types` live, add: - - `live "/statistics", StatisticsLive, :index` - -**PagePaths** ([lib/mv_web/page_paths.ex](lib/mv_web/page_paths.ex)): - -- Add module attribute `@statistics "/statistics"`. -- Add `def statistics, do: @statistics`. -- No change to `@admin_page_paths` (statistics is top-level). - -**Page permission** (route matrix is driven by [lib/mv/authorization/permission_sets.ex](lib/mv/authorization/permission_sets.ex)): - -- Add `"/statistics"` to the `pages` list of **read_only** (e.g. after `"/groups/:slug"`) and to the `pages` list of **normal_user** (e.g. after groups entries). **admin** already has `"*"` so no change. -- **own_data** must not list `/statistics` (so they cannot access it). -- Update [docs/page-permission-route-coverage.md](docs/page-permission-route-coverage.md): add row for `| /statistics | ✗ | ✓ | ✓ | ✓ |`. -- Add test in `test/mv_web/plugs/check_page_permission_test.exs`: read_only and normal_user and admin can access `/statistics`; own_data cannot. - ---- - -## 3. Sidebar - -**File:** [lib/mv_web/components/layouts/sidebar.ex](lib/mv_web/components/layouts/sidebar.ex). - -- In `sidebar_menu`, after the “Fee Types” menu item and before the “Administration” block, add a conditional menu item for Statistics: - - `can_access_page?(@current_user, PagePaths.statistics())` → show link. - - `href={~p"/statistics"}`, `icon="hero-chart-bar"` (or similar), `label={gettext("Statistics")}`. - ---- - -## 4. Statistics LiveView - -**Module:** `MvWeb.StatisticsLive` -**File:** `lib/mv_web/live/statistics_live.ex` -**Mount:** `:index` only. - -**Behaviour:** - -- `on_mount`: use `MvWeb.LiveUserAuth, :live_user_required` and ensure role/permission check (same as other protected LiveViews). In `mount` or `handle_params`, set default selected year to current year (e.g. `Date.utc_today().year`). -- **Assigns:** `:year` (integer), `:active_count`, `:inactive_count`, `:joins_this_year`, `:exits_this_year`, `:cycle_totals` (map with keys e.g. `:total`, `:paid`, `:unpaid`, `:suspended` for the selected year), `:open_amount_total`, and any extra needed for the bar data (e.g. list of `%{year: y, joins: j, exits: e}` for a small range of years if you show a minimal bar chart). -- **Year filter:** A single select or dropdown for year (e.g. from “first year with data” to current year). On change, send event (e.g. `"set_year"`) with `%{"year" => year}`; in `handle_event` update `assigns.year` and reload data by calling `Mv.Statistics` again and re-assigning. - -**Data loading:** - -- In `mount` and whenever year changes, call `Mv.Statistics` with `actor: current_actor(socket)` (and optionally `year: @year` where needed). Assign results to socket. Handle errors (e.g. redirect or flash) if a call fails. - -**Layout (sections):** - -1. **Page title:** e.g. “Statistics” (gettext). -2. **Year filter:** One control to select year; applies to “joins/exits” and “contribution sums” for that year. -3. **Cards (top row):** - - Active members (count) - - Inactive members (count) - - Joins in selected year - - Exits in selected year - - Open amount total (sum of all unpaid cycles; format with `MvWeb.Helpers.MembershipFeeHelpers.format_currency/1`) - - Optionally: “Paid this year” (from `cycle_totals_by_year` for selected year) -4. **Contributions for selected year:** One section showing for the chosen year: total (Soll), paid, unpaid, suspended (sums and optionally counts). Use simple table or key-value list; no chart required for MVP. -5. **Joins / Exits by year (simple bar chart):** Data: e.g. last 5 or 10 years. For each year, show joins and exits as horizontal bars (HTML/CSS: e.g. `div` with `width: #{percent}%`). Pure HTML/SVG; no external chart library. Use Tailwind/DaisyUI for layout and cards. - -**Accessibility:** Semantic HTML; headings (e.g. `h2`) for each section; ensure year filter has a label; format numbers in a screen-reader-friendly way (e.g. no purely visual abbreviations without aria-label). - -**i18n:** All user-visible strings via gettext (e.g. “Statistics”, “Active members”, “Inactive members”, “Joins (year)”, “Exits (year)”, “Open amount”, “Contributions for year”, “Total”, “Paid”, “Unpaid”, “Suspended”). Add keys to `priv/gettext` as needed. - ---- - -## 5. Implementation order (tasks) - -Execute in this order so that each step is testable: - -1. **Statistics module** - - Add `lib/mv/statistics.ex` with the six functions above and `@moduledoc`. - - Add `test/mv/statistics_test.exs` with tests for each function (use fixtures for members and cycles; pass actor in opts). - - Run tests and fix until green. - -2. **Route and permission** - - Add `live "/statistics", StatisticsLive, :index` in router. - - Add `statistics/0` and `@statistics` in PagePaths. - - Add `/statistics` to page permission logic so read_only, normal_user, admin are allowed and own_data is denied. - - Update `docs/page-permission-route-coverage.md` and add/update plug tests for `/statistics`. - -3. **Sidebar** - - Add Statistics link in sidebar (top-level) with `can_access_page?` and `PagePaths.statistics()`. - -4. **StatisticsLive** - - Create `lib/mv_web/live/statistics_live.ex` with mount, assigns, year param, and data loading from `Mv.Statistics`. - - Implement UI: title, year filter, cards, contribution section, simple joins/exits bar (HTML). - - Add gettext keys and use them in the template. - - Optionally add a simple LiveView test (e.g. authenticated user sees statistics page and key labels). - -5. **CI and docs** - - Run `just ci-dev` (or project equivalent); fix formatting, Credo, and tests. - - In [docs/feature-roadmap.md](docs/feature-roadmap.md), update “Reporting & Analytics” to reflect that a basic statistics page is implemented (MVP). - - In [CODE_GUIDELINES.md](CODE_GUIDELINES.md), add a short note under a suitable section (e.g. “Reporting” or “LiveView”) that statistics are provided by `Mv.Statistics` and displayed in `StatisticsLive`, if desired. - ---- - -## 6. Out of scope (not in this plan) - -- Export (CSV/PDF). -- Caching (ETS/GenServer/HTTP). -- Month or quarter filters. -- “Members per fee type” or “members per group” statistics. -- Overdue vs. not-yet-due split for open amount. -- Contex or Chart.js. -- New database tables or Ash resources. - -These can be added later as separate tasks or follow-up plans. diff --git a/lib/mv/authorization/permission_sets.ex b/lib/mv/authorization/permission_sets.ex index fffc818..ea878a2 100644 --- a/lib/mv/authorization/permission_sets.ex +++ b/lib/mv/authorization/permission_sets.ex @@ -178,9 +178,7 @@ defmodule Mv.Authorization.PermissionSets do # Groups overview "/groups", # Group detail - "/groups/:slug", - # Statistics - "/statistics" + "/groups/:slug" ] } end @@ -245,9 +243,7 @@ defmodule Mv.Authorization.PermissionSets do # Group detail "/groups/:slug", # Edit group - "/groups/:slug/edit", - # Statistics - "/statistics" + "/groups/:slug/edit" ] } end diff --git a/lib/mv/membership_fees/cycle_generator.ex b/lib/mv/membership_fees/cycle_generator.ex index 1a33ca8..ec33914 100644 --- a/lib/mv/membership_fees/cycle_generator.ex +++ b/lib/mv/membership_fees/cycle_generator.ex @@ -87,7 +87,7 @@ defmodule Mv.MembershipFees.CycleGenerator do def generate_cycles_for_member(member_or_id, opts \\ []) def generate_cycles_for_member(member_id, opts) when is_binary(member_id) do - case load_member(member_id, opts) do + case load_member(member_id) 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?, opts) + do_generate_cycles_with_lock(member, today, skip_lock?) 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?, opts) do - # Lock already set by caller (e.g., regenerate_cycles_on_type_change or seeds) + defp do_generate_cycles_with_lock(member, today, true = _skip_lock?) do + # Lock already set by caller (e.g., regenerate_cycles_on_type_change) # Just generate cycles without additional locking - do_generate_cycles(member, today, opts) + do_generate_cycles(member, today) end - defp do_generate_cycles_with_lock(member, today, false, opts) do + defp do_generate_cycles_with_lock(member, today, false) 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, opts) do + case do_generate_cycles(member, today) 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,33 +235,25 @@ defmodule Mv.MembershipFees.CycleGenerator do # Private functions - # 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) + defp load_member(member_id) do + system_actor = SystemActor.get_system_actor() + opts = Helpers.ash_actor_opts(system_actor) query = Member |> Ash.Query.filter(id == ^member_id) |> Ash.Query.load([:membership_fee_type, :membership_fee_cycles]) - case Ash.read_one(query, read_opts) do + case Ash.read_one(query, opts) do {:ok, nil} -> {:error, :member_not_found} {:ok, member} -> {:ok, member} {:error, reason} -> {:error, reason} end end - defp do_generate_cycles(member, today, opts) do + defp do_generate_cycles(member, today) do # Reload member with relationships to ensure fresh data - case load_member(member.id, opts) do + case load_member(member.id) do {:ok, member} -> cond do is_nil(member.membership_fee_type_id) -> @@ -271,7 +263,7 @@ defmodule Mv.MembershipFees.CycleGenerator do {:error, :no_join_date} true -> - generate_missing_cycles(member, today, opts) + generate_missing_cycles(member, today) end {:error, reason} -> @@ -279,7 +271,7 @@ defmodule Mv.MembershipFees.CycleGenerator do end end - defp generate_missing_cycles(member, today, opts) do + defp generate_missing_cycles(member, today) do fee_type = member.membership_fee_type interval = fee_type.interval amount = fee_type.amount @@ -295,7 +287,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, opts) + create_cycles(cycle_starts, member.id, fee_type.id, amount) else {:ok, [], []} end @@ -390,9 +382,9 @@ defmodule Mv.MembershipFees.CycleGenerator do end end - defp create_cycles(cycle_starts, member_id, fee_type_id, amount, opts) do - actor = get_actor(opts) - create_opts = Helpers.ash_actor_opts(actor) + 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) # Always use return_notifications?: true to collect notifications # Notifications will be returned to the caller, who is responsible for @@ -408,7 +400,7 @@ defmodule Mv.MembershipFees.CycleGenerator do } handle_cycle_creation_result( - Ash.create(MembershipFeeCycle, attrs, [return_notifications?: true] ++ create_opts), + Ash.create(MembershipFeeCycle, attrs, [return_notifications?: true] ++ opts), cycle_start ) end) diff --git a/lib/mv/statistics.ex b/lib/mv/statistics.ex deleted file mode 100644 index b3c54c0..0000000 --- a/lib/mv/statistics.ex +++ /dev/null @@ -1,237 +0,0 @@ -defmodule Mv.Statistics do - @moduledoc """ - Aggregated statistics for members and membership fee cycles. - - Used by the statistics LiveView to display counts and sums. All functions - accept an `opts` keyword list and pass `:actor` (and `:domain` where needed) - to Ash reads so that policies are enforced. - """ - - require Ash.Query - import Ash.Expr - - require Logger - - alias Mv.Membership.Member - alias Mv.MembershipFees - alias Mv.MembershipFees.MembershipFeeCycle - - @doc """ - Returns the earliest year in which any member has a join_date. - - Used to determine the start of the "relevant" year range for statistics - (from first membership to current year). Returns `nil` if no member has - a join_date. - """ - @spec first_join_year(keyword()) :: non_neg_integer() | nil - def first_join_year(opts) do - query = - Member - |> Ash.Query.filter(expr(not is_nil(join_date))) - |> Ash.Query.sort(join_date: :asc) - |> Ash.Query.limit(1) - - case Ash.read_one(query, opts) do - {:ok, nil} -> - nil - - {:ok, member} -> - member.join_date.year - - {:error, reason} -> - Logger.warning("Statistics.first_join_year failed: #{inspect(reason)}") - nil - end - end - - @doc """ - Returns the count of active members (exit_date is nil). - """ - @spec active_member_count(keyword()) :: non_neg_integer() - def active_member_count(opts) do - query = - Member - |> Ash.Query.filter(expr(is_nil(exit_date))) - - case Ash.count(query, opts) do - {:ok, count} -> - count - - {:error, reason} -> - Logger.warning("Statistics.active_member_count failed: #{inspect(reason)}") - 0 - end - end - - @doc """ - Returns the count of inactive members (exit_date is not nil). - """ - @spec inactive_member_count(keyword()) :: non_neg_integer() - def inactive_member_count(opts) do - query = - Member - |> Ash.Query.filter(expr(not is_nil(exit_date))) - - case Ash.count(query, opts) do - {:ok, count} -> - count - - {:error, reason} -> - Logger.warning("Statistics.inactive_member_count failed: #{inspect(reason)}") - 0 - end - end - - @doc """ - Returns the count of members who joined in the given year (join_date in that year). - """ - @spec joins_by_year(integer(), keyword()) :: non_neg_integer() - def joins_by_year(year, opts) do - first_day = Date.new!(year, 1, 1) - last_day = Date.new!(year, 12, 31) - - query = - Member - |> Ash.Query.filter(expr(join_date >= ^first_day and join_date <= ^last_day)) - - case Ash.count(query, opts) do - {:ok, count} -> - count - - {:error, reason} -> - Logger.warning("Statistics.joins_by_year failed: #{inspect(reason)}") - 0 - end - end - - @doc """ - Returns the count of members who exited in the given year (exit_date in that year). - """ - @spec exits_by_year(integer(), keyword()) :: non_neg_integer() - def exits_by_year(year, opts) do - first_day = Date.new!(year, 1, 1) - last_day = Date.new!(year, 12, 31) - - query = - Member - |> Ash.Query.filter(expr(exit_date >= ^first_day and exit_date <= ^last_day)) - - case Ash.count(query, opts) do - {:ok, count} -> - count - - {:error, reason} -> - Logger.warning("Statistics.exits_by_year failed: #{inspect(reason)}") - 0 - end - end - - @doc """ - Returns totals for membership fee cycles whose cycle_start falls in the given year. - - Returns a map with keys: `:total`, `:paid`, `:unpaid`, `:suspended` (each a Decimal sum). - """ - @spec cycle_totals_by_year(integer(), keyword()) :: %{ - total: Decimal.t(), - paid: Decimal.t(), - unpaid: Decimal.t(), - suspended: Decimal.t() - } - def cycle_totals_by_year(year, opts) do - first_day = Date.new!(year, 1, 1) - last_day = Date.new!(year, 12, 31) - - query = - MembershipFeeCycle - |> Ash.Query.filter(expr(cycle_start >= ^first_day and cycle_start <= ^last_day)) - - query = maybe_filter_by_fee_type(query, opts) - # Only pass actor and domain to Ash.read; fee_type_id is only for our filter above - opts_for_read = - opts - |> Keyword.drop([:fee_type_id]) - |> Keyword.put(:domain, MembershipFees) - - case Ash.read(query, opts_for_read) do - {:ok, cycles} -> - cycle_totals_from_cycles(cycles) - - {:error, reason} -> - Logger.warning("Statistics.cycle_totals_by_year failed: #{inspect(reason)}") - zero_cycle_totals() - end - end - - defp cycle_totals_from_cycles(cycles) do - by_status = Enum.group_by(cycles, & &1.status) - sum = fn status -> sum_amounts(by_status[status] || []) end - - total = - [:paid, :unpaid, :suspended] - |> Enum.map(&sum.(&1)) - |> Enum.reduce(Decimal.new(0), &Decimal.add/2) - - %{ - total: total, - paid: sum.(:paid), - unpaid: sum.(:unpaid), - suspended: sum.(:suspended) - } - end - - defp sum_amounts(cycles), - do: Enum.reduce(cycles, Decimal.new(0), fn c, acc -> Decimal.add(acc, c.amount) end) - - defp zero_cycle_totals do - %{ - total: Decimal.new(0), - paid: Decimal.new(0), - unpaid: Decimal.new(0), - suspended: Decimal.new(0) - } - end - - defp maybe_filter_by_fee_type(query, opts) do - case Keyword.get(opts, :fee_type_id) do - nil -> - query - - id when is_binary(id) -> - # Only apply filter for valid UUID strings (e.g. from form/URL) - if Ecto.UUID.cast(id) != :error do - Ash.Query.filter(query, expr(membership_fee_type_id == ^id)) - else - query - end - - id -> - Ash.Query.filter(query, expr(membership_fee_type_id == ^id)) - end - end - - @doc """ - Returns the sum of amount for all cycles with status :unpaid. - """ - @spec open_amount_total(keyword()) :: Decimal.t() - def open_amount_total(opts) do - query = - MembershipFeeCycle - |> Ash.Query.filter(expr(status == :unpaid)) - - query = maybe_filter_by_fee_type(query, opts) - - opts_for_read = - opts - |> Keyword.drop([:fee_type_id]) - |> Keyword.put(:domain, MembershipFees) - - case Ash.read(query, opts_for_read) do - {:ok, cycles} -> - Enum.reduce(cycles, Decimal.new(0), fn c, acc -> Decimal.add(acc, c.amount) end) - - {:error, reason} -> - Logger.warning("Statistics.open_amount_total failed: #{inspect(reason)}") - Decimal.new(0) - end - end -end diff --git a/lib/mv_web/components/layouts/sidebar.ex b/lib/mv_web/components/layouts/sidebar.ex index 1896f24..89519ae 100644 --- a/lib/mv_web/components/layouts/sidebar.ex +++ b/lib/mv_web/components/layouts/sidebar.ex @@ -88,14 +88,6 @@ defmodule MvWeb.Layouts.Sidebar do /> <% end %> - <%= if can_access_page?(@current_user, PagePaths.statistics()) do %> - <.menu_item - href={~p"/statistics"} - icon="hero-chart-bar" - label={gettext("Statistics")} - /> - <% end %> - <%= if admin_menu_visible?(@current_user) do %> <.menu_group icon="hero-cog-6-tooth" diff --git a/lib/mv_web/live/group_live/show.ex b/lib/mv_web/live/group_live/show.ex index 0251fb6..0899728 100644 --- a/lib/mv_web/live/group_live/show.ex +++ b/lib/mv_web/live/group_live/show.ex @@ -15,8 +15,6 @@ defmodule MvWeb.GroupLive.Show do """ use MvWeb, :live_view - require Logger - import MvWeb.LiveHelpers, only: [current_actor: 1] import MvWeb.Authorization @@ -24,15 +22,7 @@ defmodule MvWeb.GroupLive.Show do @impl true def mount(_params, _session, socket) do - {:ok, - socket - |> assign(:show_add_member_input, false) - |> assign(:member_search_query, "") - |> assign(:available_members, []) - |> assign(:selected_member_ids, []) - |> assign(:selected_members, []) - |> assign(:show_member_dropdown, false) - |> assign(:focused_member_index, nil)} + {:ok, socket} end @impl true @@ -132,128 +122,6 @@ defmodule MvWeb.GroupLive.Show do )}

- <%= if can?(@current_user, :update, Mv.Membership.Group) do %> -
- <%= if assigns[:show_add_member_input] do %> -
-
-
-
- <%= for member <- @selected_members do %> - - {MvWeb.Helpers.MemberHelpers.display_name(member)} - - - <% end %> - -
- - <%= if length(@available_members) > 0 do %> -
- <%= for {member, index} <- Enum.with_index(@available_members) do %> -
-

- {MvWeb.Helpers.MemberHelpers.display_name(member)} -

-

- {member.email || gettext("No email")} -

-
- <% end %> -
- <% end %> -
-
- - -
- <% else %> - <.button - variant="primary" - phx-click="show_add_member_input" - aria-label={gettext("Add Member")} - > - {gettext("Add Member")} - - <% end %> -
- <% end %> - <%= if Enum.empty?(@group.members || []) do %>

{gettext("No members in this group")}

<% else %> @@ -263,9 +131,6 @@ defmodule MvWeb.GroupLive.Show do {gettext("Name")} {gettext("Email")} - <%= if can?(@current_user, :update, Mv.Membership.Group) do %> - {gettext("Actions")} - <% end %> @@ -291,20 +156,6 @@ defmodule MvWeb.GroupLive.Show do <% end %> - <%= if can?(@current_user, :update, Mv.Membership.Group) do %> - - - - <% end %> <% end %> @@ -385,13 +236,11 @@ defmodule MvWeb.GroupLive.Show do """ end - # Delete Modal Events @impl true def handle_event("open_delete_modal", _params, socket) do {:noreply, assign(socket, show_delete_modal: true, name_confirmation: "")} end - @impl true def handle_event("cancel_delete", _params, socket) do {:noreply, socket @@ -399,12 +248,10 @@ defmodule MvWeb.GroupLive.Show do |> assign(:name_confirmation, "")} end - @impl true def handle_event("update_name_confirmation", %{"name" => name}, socket) do {:noreply, assign(socket, :name_confirmation, name)} end - @impl true def handle_event("confirm_delete", %{"slug" => slug}, socket) do actor = current_actor(socket) group = socket.assigns.group @@ -428,417 +275,6 @@ defmodule MvWeb.GroupLive.Show do end end - # Add Member Events - @impl true - def handle_event("show_add_member_input", _params, socket) do - # Reload group to ensure we have the latest members list - actor = current_actor(socket) - group = socket.assigns.group - socket = reload_group(socket, group.slug, actor) - - {:noreply, - socket - |> assign(:show_add_member_input, true) - |> assign(:member_search_query, "") - |> assign(:available_members, []) - |> assign(:selected_member_ids, []) - |> assign(:selected_members, []) - |> assign(:show_member_dropdown, false) - |> assign(:focused_member_index, nil)} - end - - @impl true - def handle_event("show_member_dropdown", _params, socket) do - # Use existing group.members for filtering; reload only on add/remove - socket = - socket - |> load_available_members("") - |> assign(:show_member_dropdown, true) - |> assign(:focused_member_index, nil) - - {:noreply, socket} - end - - @impl true - def handle_event("hide_add_member_input", _params, socket) do - {:noreply, - socket - |> assign(:show_add_member_input, false) - |> assign(:member_search_query, "") - |> assign(:available_members, []) - |> assign(:selected_member_ids, []) - |> assign(:selected_members, []) - |> assign(:show_member_dropdown, false) - |> assign(:focused_member_index, nil)} - end - - @impl true - def handle_event("hide_member_dropdown", _params, socket) do - {:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)} - end - - @impl true - def handle_event("member_dropdown_keydown", %{"key" => "ArrowDown"}, socket) do - return_if_dropdown_closed(socket, fn -> - max_index = length(socket.assigns.available_members) - 1 - current = socket.assigns.focused_member_index - - new_index = - case current do - nil -> 0 - index when index < max_index -> index + 1 - _ -> current - end - - {:noreply, assign(socket, focused_member_index: new_index)} - end) - end - - @impl true - def handle_event("member_dropdown_keydown", %{"key" => "ArrowUp"}, socket) do - return_if_dropdown_closed(socket, fn -> - current = socket.assigns.focused_member_index - - new_index = - case current do - nil -> 0 - 0 -> 0 - index -> index - 1 - end - - {:noreply, assign(socket, focused_member_index: new_index)} - end) - end - - @impl true - def handle_event("member_dropdown_keydown", %{"key" => "Enter"}, socket) do - return_if_dropdown_closed(socket, fn -> - select_focused_member(socket) - end) - end - - @impl true - def handle_event("member_dropdown_keydown", %{"key" => "Escape"}, socket) do - return_if_dropdown_closed(socket, fn -> - {:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)} - end) - end - - @impl true - def handle_event("member_dropdown_keydown", _params, socket) do - # Ignore other keys - {:noreply, socket} - end - - @impl true - def handle_event("search_members", %{"member_search" => query}, socket) do - # Use existing group.members for filtering; reload only on add/remove - socket = - socket - |> assign(:member_search_query, query) - |> load_available_members(query) - |> assign(:show_member_dropdown, true) - |> assign(:focused_member_index, nil) - - {:noreply, socket} - end - - @impl true - def handle_event("select_member", %{"id" => member_id}, socket) do - # Check if member is already selected - if member_id in socket.assigns.selected_member_ids do - {:noreply, socket} - else - # Find the selected member - selected_member = Enum.find(socket.assigns.available_members, &(&1.id == member_id)) - - if selected_member do - socket = - socket - |> assign(:selected_member_ids, [member_id | socket.assigns.selected_member_ids]) - |> assign(:selected_members, [selected_member | socket.assigns.selected_members]) - |> assign(:member_search_query, "") - |> assign(:show_member_dropdown, false) - |> assign(:focused_member_index, nil) - - {:noreply, socket} - else - {:noreply, socket} - end - end - end - - @impl true - def handle_event("remove_selected_member", %{"member_id" => member_id}, socket) do - socket = - socket - |> assign(:selected_member_ids, List.delete(socket.assigns.selected_member_ids, member_id)) - |> assign( - :selected_members, - Enum.reject(socket.assigns.selected_members, &(&1.id == member_id)) - ) - - {:noreply, socket} - end - - @impl true - def handle_event("add_selected_members", _params, socket) do - actor = current_actor(socket) - group = socket.assigns.group - - # Server-side authorization check - if can?(actor, :update, group) do - member_ids = Enum.uniq(socket.assigns.selected_member_ids) - perform_add_members(socket, group, member_ids, actor) - else - {:noreply, - socket - |> put_flash(:error, gettext("Not authorized.")) - |> redirect(to: ~p"/groups/#{group.slug}")} - end - end - - @impl true - def handle_event("remove_member", %{"member_id" => member_id}, socket) do - actor = current_actor(socket) - group = socket.assigns.group - - # Server-side authorization check - if can?(actor, :update, group) do - perform_remove_member(socket, group, member_id, actor) - else - {:noreply, - socket - |> put_flash(:error, gettext("Not authorized.")) - |> redirect(to: ~p"/groups/#{group.slug}")} - end - end - - # Helper functions - defp return_if_dropdown_closed(socket, fun) do - if socket.assigns.show_member_dropdown do - fun.() - else - {:noreply, socket} - end - end - - defp select_focused_member(socket) do - case socket.assigns.focused_member_index do - nil -> - {:noreply, socket} - - index -> - select_member_by_index(socket, index) - end - end - - defp select_member_by_index(socket, index) do - case Enum.at(socket.assigns.available_members, index) do - nil -> - {:noreply, socket} - - member -> - add_member_to_selection(socket, member) - end - end - - defp add_member_to_selection(socket, member) do - # Check if member is already selected - if member.id in socket.assigns.selected_member_ids do - {:noreply, socket} - else - socket = - socket - |> assign(:selected_member_ids, [member.id | socket.assigns.selected_member_ids]) - |> assign(:selected_members, [member | socket.assigns.selected_members]) - |> assign(:member_search_query, "") - |> assign(:show_member_dropdown, false) - |> assign(:focused_member_index, nil) - - {:noreply, socket} - end - end - - defp load_available_members(socket, query) do - require Ash.Query - - current_member_ids = group_member_ids_set(socket.assigns.group) - base_query = available_members_base_query(query) - - # Fetch more than 10, then exclude already-in-group and take 10 (avoids empty dropdown when first N are all in group) - fetch_limit = 50 - limited_query = Ash.Query.limit(base_query, fetch_limit) - actor = current_actor(socket) - - case Ash.read(limited_query, actor: actor, domain: Mv.Membership) do - {:ok, members} -> - available = - members - |> Enum.reject(fn m -> MapSet.member?(current_member_ids, m.id) end) - |> Enum.take(10) - - assign(socket, available_members: available) - - {:error, error} -> - Logger.warning("Failed to load available members for group: #{inspect(error)}") - - socket - |> put_flash(:error, gettext("Could not load member search. Please try again.")) - |> assign(:available_members, []) - end - end - - defp available_members_base_query(query) do - search_query = if query && String.trim(query) != "", do: String.trim(query), else: nil - - if search_query do - Mv.Membership.Member - |> Ash.Query.for_read(:search, %{query: search_query}) - else - Mv.Membership.Member - |> Ash.Query.new() - end - end - - defp group_member_ids_set(group) do - members = group.members || [] - members |> Enum.map(& &1.id) |> MapSet.new() - end - - defp perform_add_members(socket, group, member_ids, actor) when is_list(member_ids) do - # Add all members in a transaction-like manner - results = - Enum.map(member_ids, fn member_id -> - Membership.create_member_group( - %{member_id: member_id, group_id: group.id}, - actor: actor - ) - end) - - # Check for errors - errors = Enum.filter(results, &match?({:error, _}, &1)) - - if Enum.empty?(errors) do - handle_successful_add_members(socket, group, actor) - else - handle_failed_add_members(socket, group, errors, actor) - end - end - - defp perform_add_members(socket, _group, _member_ids, _actor) do - {:noreply, - socket - |> put_flash(:error, gettext("No members selected."))} - end - - defp handle_successful_add_members(socket, group, actor) do - socket = reload_group(socket, group.slug, actor) - - {:noreply, - socket - |> assign(:show_add_member_input, false) - |> assign(:member_search_query, "") - |> assign(:available_members, []) - |> assign(:selected_member_ids, []) - |> assign(:selected_members, []) - |> assign(:show_member_dropdown, false) - |> assign(:focused_member_index, nil)} - end - - defp handle_failed_add_members(socket, group, errors, actor) do - error_messages = extract_error_messages(errors) - - # Still reload to show any successful additions - socket = reload_group(socket, group.slug, actor) - - {:noreply, - socket - |> put_flash( - :error, - gettext("Some members could not be added: %{errors}", errors: error_messages) - ) - |> assign(:show_add_member_input, true)} - end - - defp extract_error_messages(errors) do - Enum.map(errors, fn {:error, error} -> - format_single_error(error) - end) - |> Enum.uniq() - |> Enum.join(", ") - end - - defp format_single_error(%{errors: [%{message: message}]}) when is_binary(message), do: message - - defp format_single_error(%{errors: [%{field: :member_id, message: message}]}) - when is_binary(message), - do: message - - defp format_single_error(error), do: format_error(error) - - defp perform_remove_member(socket, group, member_id, actor) do - require Ash.Query - - # Find the MemberGroup association - query = - Mv.Membership.MemberGroup - |> Ash.Query.filter(member_id == ^member_id and group_id == ^group.id) - - case Ash.read_one(query, actor: actor, domain: Mv.Membership) do - {:ok, nil} -> - {:noreply, - socket - |> put_flash(:error, gettext("Member is not in this group."))} - - {:ok, member_group} -> - case Membership.destroy_member_group(member_group, actor: actor) do - :ok -> - # Reload group with members and member_count - socket = reload_group(socket, group.slug, actor) - - {:noreply, socket} - - {:error, error} -> - error_message = format_error(error) - - {:noreply, - socket - |> put_flash( - :error, - gettext("Failed to remove member: %{error}", error: error_message) - )} - end - - {:error, error} -> - error_message = format_error(error) - - {:noreply, - socket - |> put_flash( - :error, - gettext("Failed to remove member: %{error}", error: error_message) - )} - end - end - - defp reload_group(socket, slug, actor) do - require Ash.Query - - query = - Mv.Membership.Group - |> Ash.Query.filter(slug == ^slug) - |> Ash.Query.load([:members, :member_count]) - - case Ash.read_one(query, actor: actor, domain: Mv.Membership) do - {:ok, group} -> - assign(socket, :group, group) - - {:error, _} -> - socket - end - end - defp handle_delete_confirmation(socket, group, actor) do if socket.assigns.name_confirmation == group.name do perform_group_deletion(socket, group, actor) diff --git a/lib/mv_web/live/statistics_live.ex b/lib/mv_web/live/statistics_live.ex deleted file mode 100644 index 32dda6f..0000000 --- a/lib/mv_web/live/statistics_live.ex +++ /dev/null @@ -1,628 +0,0 @@ -defmodule MvWeb.StatisticsLive do - @moduledoc """ - LiveView for the statistics page at /statistics. - - Displays aggregated member and membership fee cycle statistics. - """ - use MvWeb, :live_view - - require Logger - - import MvWeb.LiveHelpers, only: [current_actor: 1] - alias Mv.Statistics - alias Mv.MembershipFees.MembershipFeeType - alias MvWeb.Helpers.MembershipFeeHelpers - - @impl true - def mount(_params, _session, socket) do - # Only static assigns and fee types here; load_statistics runs once in handle_params - socket = - socket - |> assign(:page_title, gettext("Statistics")) - |> assign(:selected_fee_type_id, nil) - |> load_fee_types() - - {:ok, socket} - end - - @impl true - def handle_params(params, uri, socket) do - # Query params: after push_patch, params may not include query string in some cases; - # always derive from URI as well so fee_type_id is reliable. - uri_query = if uri, do: URI.decode_query(URI.parse(uri).query || ""), else: %{} - fee_type_id = params["fee_type_id"] || uri_query["fee_type_id"] - fee_type_id = normalize_fee_type_id(fee_type_id) - - socket = - socket - |> assign(:selected_fee_type_id, fee_type_id) - |> load_statistics() - - {:noreply, socket} - end - - defp normalize_fee_type_id(nil), do: nil - defp normalize_fee_type_id(""), do: nil - - defp normalize_fee_type_id(id) when is_binary(id) do - case String.trim(id) do - "" -> nil - trimmed -> trimmed - end - end - - defp normalize_fee_type_id(_), do: nil - - @impl true - def render(assigns) do - ~H""" - - <.header> - {gettext("Statistics")} - <:subtitle>{gettext("Overview from first membership to today")} - - -
-

{gettext("Members")}

-
-
-
-

- {gettext("Active members")} -

-

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

-
-
-
-
-

- {gettext("Inactive members")} -

-

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

-
-
-
-
-
-

{gettext("Member numbers by year")}

-

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

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

- {gettext("Contributions")} -

-
-
-
- - -
-
-
-
-
-

{gettext("Contributions by year")}

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

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

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

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

-
-
-
-
-
-
- """ - end - - @impl true - def handle_event("change_fee_type", %{"fee_type_id" => ""}, socket) do - {:noreply, push_patch(socket, to: ~p"/statistics")} - end - - def handle_event("change_fee_type", %{"fee_type_id" => id}, socket) when is_binary(id) do - trimmed = String.trim(id) - - to = - if trimmed == "", - do: ~p"/statistics", - else: ~p"/statistics" <> "?" <> URI.encode_query(%{"fee_type_id" => trimmed}) - - {:noreply, push_patch(socket, to: to)} - end - - attr :joins_exits_by_year, :list, required: true - - defp member_numbers_table(assigns) do - rows = assigns.joins_exits_by_year - total_activity = Enum.map(rows, fn r -> r.joins + r.exits end) - max_total = (total_activity != [] && Enum.max(total_activity)) || 1 - - rows_with_pct = - Enum.map(rows, fn row -> - sum = row.joins + row.exits - - bar_pct = - if max_total > 0 and sum > 0 do - min(100.0, Float.round(sum / max_total * 100, 1)) - else - 0 - end - - seg_scale = max(sum, 1) - joins_pct = min(100.0, row.joins / seg_scale * 100) - exits_pct = min(100.0, row.exits / seg_scale * 100) - - %{ - year: row.year, - joins: row.joins, - exits: row.exits, - bar_pct: bar_pct, - joins_pct: Float.round(joins_pct, 1), - exits_pct: Float.round(exits_pct, 1) - } - end) - - assigns = assign(assigns, :rows, rows_with_pct) - - ~H""" - - """ - 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""" - - """ - end - - defp max_decimal(rows, key) do - Enum.reduce(rows, Decimal.new(0), fn row, acc -> - val = Map.get(row, key) - if Decimal.compare(val, acc) == :gt, do: val, else: acc - end) - end - - defp bar_pct(value, max) do - scale = if Decimal.compare(max, 0) == :gt, do: max, else: Decimal.new(1) - pct = value |> Decimal.div(scale) |> Decimal.mult(100) |> Decimal.to_float() - min(100.0, pct) - end - - attr :cycle_totals, :map, required: true - - defp contributions_pie(assigns) do - paid = assigns.cycle_totals.paid - unpaid = assigns.cycle_totals.unpaid - suspended = assigns.cycle_totals.suspended - - sum_positive = Decimal.add(Decimal.add(paid, unpaid), suspended) - scale = if Decimal.compare(sum_positive, 0) == :gt, do: sum_positive, else: Decimal.new(1) - - paid_pct = Decimal.div(paid, scale) |> Decimal.mult(100) |> Decimal.to_float() - unpaid_pct = Decimal.div(unpaid, scale) |> Decimal.mult(100) |> Decimal.to_float() - suspended_pct = Decimal.div(suspended, scale) |> Decimal.mult(100) |> Decimal.to_float() - - # Conic gradient: 0deg = top, clockwise. Success (paid), warning (unpaid), base-300 (suspended) - # Use theme CSS variables (--color-*) so the pie renders in all themes - paid_deg = paid_pct * 3.6 - unpaid_deg = unpaid_pct * 3.6 - - gradient_stops = - "var(--color-success) 0deg, var(--color-success) #{paid_deg}deg, var(--color-warning) #{paid_deg}deg, var(--color-warning) #{paid_deg + unpaid_deg}deg, var(--color-base-300) #{paid_deg + unpaid_deg}deg, var(--color-base-300) 360deg" - - assigns = - assigns - |> assign(:paid_pct, paid_pct) - |> assign(:unpaid_pct, unpaid_pct) - |> assign(:suspended_pct, suspended_pct) - |> assign(:gradient_stops, gradient_stops) - - ~H""" - - """ - end - - defp load_fee_types(socket) do - actor = current_actor(socket) - - case MembershipFeeType - |> Ash.Query.sort(name: :asc) - |> Ash.read(domain: Mv.MembershipFees, actor: actor) do - {:ok, fee_types} -> - assign(socket, :membership_fee_types, fee_types) - - {:error, reason} -> - Logger.warning("StatisticsLive: failed to load fee types: #{inspect(reason)}") - - socket - |> put_flash(:error, gettext("Fee types could not be loaded.")) - |> assign(:membership_fee_types, []) - end - end - - defp load_statistics(socket) do - actor = current_actor(socket) - fee_type_id = socket.assigns[:selected_fee_type_id] - # Member stats must never depend on fee type (only contributions do) - opts_member = [actor: actor] - - opts_contributions = - [actor: actor] ++ if fee_type_id, do: [fee_type_id: fee_type_id], else: [] - - current_year = Date.utc_today().year - first_year = Statistics.first_join_year(opts_member) || current_year - years = first_year..current_year |> Enum.to_list() |> Enum.reverse() - - active_count = Statistics.active_member_count(opts_member) - inactive_count = Statistics.inactive_member_count(opts_member) - joins_exits_by_year = build_joins_exits_by_year(years, opts_member) - contributions_by_year = build_contributions_by_year(years, opts_contributions) - totals_over_all_years = sum_cycle_totals(contributions_by_year) - - assign(socket, - years: years, - active_count: active_count, - inactive_count: inactive_count, - joins_exits_by_year: joins_exits_by_year, - contributions_by_year: contributions_by_year, - totals_over_all_years: totals_over_all_years - ) - end - - defp build_joins_exits_by_year(years, opts) do - Enum.map(years, fn y -> - %{ - year: y, - joins: Statistics.joins_by_year(y, opts), - exits: Statistics.exits_by_year(y, opts) - } - end) - end - - defp build_contributions_by_year(years, opts) do - Enum.map(years, fn y -> - totals = Statistics.cycle_totals_by_year(y, opts) - - %{ - year: y, - total: totals.total, - paid: totals.paid, - unpaid: totals.unpaid, - suspended: totals.suspended - } - end) - end - - defp sum_cycle_totals(contributions_by_year) do - Enum.reduce( - contributions_by_year, - %{ - total: Decimal.new(0), - paid: Decimal.new(0), - unpaid: Decimal.new(0), - suspended: Decimal.new(0) - }, - fn row, acc -> - %{ - total: Decimal.add(acc.total, row.total), - paid: Decimal.add(acc.paid, row.paid), - unpaid: Decimal.add(acc.unpaid, row.unpaid), - suspended: Decimal.add(acc.suspended, row.suspended) - } - end - ) - end -end diff --git a/lib/mv_web/page_paths.ex b/lib/mv_web/page_paths.ex index 2720c0f..5606c76 100644 --- a/lib/mv_web/page_paths.ex +++ b/lib/mv_web/page_paths.ex @@ -9,7 +9,6 @@ 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" @@ -32,9 +31,6 @@ defmodule MvWeb.PagePaths do @doc "Path for Membership Fee Types index (sidebar and page permission check)." def membership_fee_types, do: @membership_fee_types - @doc "Path for Statistics page (sidebar and page permission check)." - def statistics, do: @statistics - @doc "Paths for Administration menu; show group if user can access any of these." def admin_menu_paths, do: @admin_page_paths diff --git a/lib/mv_web/router.ex b/lib/mv_web/router.ex index 4f8c8a5..97e0642 100644 --- a/lib/mv_web/router.ex +++ b/lib/mv_web/router.ex @@ -73,9 +73,6 @@ 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 diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index d8f80c1..96ecf4e 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -12,7 +12,6 @@ msgstr "" #: lib/mv_web/components/core_components.ex #: lib/mv_web/live/group_live/index.ex -#: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format msgid "Actions" msgstr "Aktionen" @@ -153,7 +152,6 @@ msgstr "Notizen" #: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex -#: lib/mv_web/live/statistics_live.ex #: lib/mv_web/member_live/index/membership_fee_status.ex #, elixir-autogen, elixir-format msgid "Paid" @@ -319,7 +317,6 @@ 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" @@ -673,7 +670,6 @@ msgstr "Einstellungen erfolgreich gespeichert" msgid "A member with this email already exists. To link with a different member, please change one of the email addresses first." msgstr "Ein Mitglied mit dieser E-Mail-Adresse existiert bereits. Um mit einem anderen Mitglied zu verknüpfen, ändere bitte zuerst eine der E-Mail-Adressen." -#: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "Available members" @@ -764,7 +760,6 @@ 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" @@ -934,7 +929,6 @@ msgstr "Status" #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex -#: lib/mv_web/live/statistics_live.ex #: lib/mv_web/member_live/index/membership_fee_status.ex #, elixir-autogen, elixir-format msgid "Suspended" @@ -943,7 +937,6 @@ msgstr "Pausiert" #: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex -#: lib/mv_web/live/statistics_live.ex #: lib/mv_web/member_live/index/membership_fee_status.ex #, elixir-autogen, elixir-format msgid "Unpaid" @@ -2255,71 +2248,6 @@ msgstr "Nicht berechtigt." msgid "Could not load data fields. Please check your permissions." msgstr "Datenfelder konnten nicht geladen werden. Bitte überprüfe deine Berechtigungen." -#: lib/mv_web/live/group_live/show.ex -#, elixir-autogen, elixir-format -msgid "Could not load member search. Please try again." -msgstr "Mitgliedersuche konnte nicht geladen werden. Bitte versuchen Sie es erneut." - -#: lib/mv_web/live/group_live/show.ex -#, elixir-autogen, elixir-format -msgid "Add Member" -msgstr "Mitglied hinzufügen" - -#: lib/mv_web/live/group_live/show.ex -#, elixir-autogen, elixir-format -msgid "Failed to remove member: %{error}" -msgstr "Mitglied konnte nicht entfernt werden: %{error}" - -#: lib/mv_web/live/group_live/show.ex -#, elixir-autogen, elixir-format -msgid "Member is not in this group." -msgstr "Mitglied ist nicht in dieser Gruppe." - -#: lib/mv_web/live/group_live/show.ex -#, elixir-autogen, elixir-format -msgid "No email" -msgstr "Keine E-Mail" - -#: lib/mv_web/live/group_live/show.ex -#, elixir-autogen, elixir-format -msgid "Remove" -msgstr "Entfernen" - -#: lib/mv_web/live/group_live/show.ex -#, elixir-autogen, elixir-format -msgid "Remove member from group" -msgstr "Mitglied aus Gruppe entfernen" - -#: lib/mv_web/live/group_live/show.ex -#, elixir-autogen, elixir-format -msgid "Search for a member" -msgstr "Nach einem Mitglied suchen" - -#: lib/mv_web/live/group_live/show.ex -#, elixir-autogen, elixir-format -msgid "Search for a member..." -msgstr "Nach einem Mitglied suchen..." - -#: lib/mv_web/live/group_live/show.ex -#, elixir-autogen, elixir-format -msgid "Add members" -msgstr "Mitglieder hinzufügen" - -#: lib/mv_web/live/group_live/show.ex -#, elixir-autogen, elixir-format -msgid "No members selected." -msgstr "Keine Mitglieder ausgewählt." - -#: lib/mv_web/live/group_live/show.ex -#, elixir-autogen, elixir-format -msgid "Remove %{name}" -msgstr "%{name} entfernen" - -#: lib/mv_web/live/group_live/show.ex -#, elixir-autogen, elixir-format -msgid "Some members could not be added: %{errors}" -msgstr "Einige Mitglieder konnten nicht hinzugefügt werden: %{errors}" - #: lib/mv_web/live/import_export_live/components.ex #, elixir-autogen, elixir-format, fuzzy msgid "CSV files only, maximum %{size} MB" @@ -2473,93 +2401,22 @@ msgstr "Pausiert" msgid "unpaid" msgstr "Unbezahlt" -#: lib/mv_web/live/statistics_live.ex -#, elixir-autogen, elixir-format -msgid "Active members" -msgstr "Aktive Mitglieder" +#~ #: lib/mv_web/live/global_settings_live.ex +#~ #, elixir-autogen, elixir-format, fuzzy +#~ msgid "Custom Fields in CSV Import" +#~ msgstr "Benutzerdefinierte Felder" -#: lib/mv_web/live/statistics_live.ex -#, elixir-autogen, elixir-format -msgid "Exits" -msgstr "Austritte" +#~ #: lib/mv_web/live/global_settings_live.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Failed to prepare CSV import: %{error}" +#~ msgstr "Das Vorbereiten des CSV Imports ist gescheitert: %{error}" -#: lib/mv_web/live/statistics_live.ex -#, elixir-autogen, elixir-format -msgid "Inactive members" -msgstr "Inaktive Mitglieder" +#~ #: lib/mv_web/live/global_settings_live.ex +#~ #, elixir-autogen, elixir-format, fuzzy +#~ msgid "Individual data fields must be created in Mila before importing. Use the field name as the CSV column header. Unknown custom field columns will be ignored with a warning." +#~ msgstr "Individuelle Datenfelder müssen in Mila erstellt werden, bevor sie importiert werden können. Verwende den Namen des Datenfeldes als CSV-Spaltenüberschrift. Unbekannte Spaltenüberschriften werden mit einer Warnung ignoriert." -#: lib/mv_web/live/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/live/member_live/show/membership_fees_component.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Only administrators can regenerate cycles" +#~ msgstr "Nur Administrator*innen können Zyklen regenerieren" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index cf55012..08d7ab9 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -13,7 +13,6 @@ msgstr "" #: lib/mv_web/components/core_components.ex #: lib/mv_web/live/group_live/index.ex -#: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format msgid "Actions" msgstr "" @@ -154,7 +153,6 @@ msgstr "" #: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex -#: lib/mv_web/live/statistics_live.ex #: lib/mv_web/member_live/index/membership_fee_status.ex #, elixir-autogen, elixir-format msgid "Paid" @@ -320,7 +318,6 @@ 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 "" @@ -674,7 +671,6 @@ msgstr "" msgid "A member with this email already exists. To link with a different member, please change one of the email addresses first." msgstr "" -#: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "Available members" @@ -765,7 +761,6 @@ 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 "" @@ -935,7 +930,6 @@ msgstr "" #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex -#: lib/mv_web/live/statistics_live.ex #: lib/mv_web/member_live/index/membership_fee_status.ex #, elixir-autogen, elixir-format msgid "Suspended" @@ -944,7 +938,6 @@ msgstr "" #: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex -#: lib/mv_web/live/statistics_live.ex #: lib/mv_web/member_live/index/membership_fee_status.ex #, elixir-autogen, elixir-format msgid "Unpaid" @@ -2256,73 +2249,8 @@ msgstr "" msgid "Could not load data fields. Please check your permissions." msgstr "" -#: lib/mv_web/live/group_live/show.ex -#, elixir-autogen, elixir-format -msgid "Could not load member search. Please try again." -msgstr "" - -#: lib/mv_web/live/group_live/show.ex -#, elixir-autogen, elixir-format -msgid "Add Member" -msgstr "" - -#: lib/mv_web/live/group_live/show.ex -#, elixir-autogen, elixir-format -msgid "Failed to remove member: %{error}" -msgstr "" - -#: lib/mv_web/live/group_live/show.ex -#, elixir-autogen, elixir-format -msgid "Member is not in this group." -msgstr "" - -#: lib/mv_web/live/group_live/show.ex -#, elixir-autogen, elixir-format -msgid "No email" -msgstr "" - -#: lib/mv_web/live/group_live/show.ex -#, elixir-autogen, elixir-format -msgid "Remove" -msgstr "" - -#: lib/mv_web/live/group_live/show.ex -#, elixir-autogen, elixir-format -msgid "Remove member from group" -msgstr "" - -#: lib/mv_web/live/group_live/show.ex -#, elixir-autogen, elixir-format -msgid "Search for a member" -msgstr "" - -#: lib/mv_web/live/group_live/show.ex -#, elixir-autogen, elixir-format -msgid "Search for a member..." -msgstr "" - -#: lib/mv_web/live/group_live/show.ex -#, elixir-autogen, elixir-format -msgid "Add members" -msgstr "" - -#: lib/mv_web/live/group_live/show.ex -#, elixir-autogen, elixir-format -msgid "No members selected." -msgstr "" - -#: lib/mv_web/live/group_live/show.ex -#, elixir-autogen, elixir-format -msgid "Remove %{name}" -msgstr "" - -#: lib/mv_web/live/group_live/show.ex -#, elixir-autogen, elixir-format -msgid "Some members could not be added: %{errors}" -msgstr "" - #: lib/mv_web/live/import_export_live/components.ex -#, elixir-autogen, elixir-format, fuzzy +#, elixir-autogen, elixir-format msgid "CSV files only, maximum %{size} MB" msgstr "" @@ -2473,94 +2401,3 @@ msgstr "" #, elixir-autogen, elixir-format msgid "unpaid" msgstr "" - -#: lib/mv_web/live/statistics_live.ex -#, elixir-autogen, elixir-format -msgid "Active members" -msgstr "" - -#: lib/mv_web/live/statistics_live.ex -#, elixir-autogen, elixir-format -msgid "Exits" -msgstr "" - -#: lib/mv_web/live/statistics_live.ex -#, elixir-autogen, elixir-format -msgid "Inactive members" -msgstr "" - -#: lib/mv_web/live/statistics_live.ex -#, elixir-autogen, elixir-format -msgid "Joins" -msgstr "" - -#: lib/mv_web/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 "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 1799738..98843b5 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -153,7 +153,6 @@ msgstr "" #: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex -#: lib/mv_web/live/statistics_live.ex #: lib/mv_web/member_live/index/membership_fee_status.ex #, elixir-autogen, elixir-format msgid "Paid" @@ -319,7 +318,6 @@ 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,7 +761,6 @@ 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 "" @@ -933,7 +930,6 @@ msgstr "" #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex -#: lib/mv_web/live/statistics_live.ex #: lib/mv_web/member_live/index/membership_fee_status.ex #, elixir-autogen, elixir-format msgid "Suspended" @@ -942,7 +938,6 @@ msgstr "" #: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex -#: lib/mv_web/live/statistics_live.ex #: lib/mv_web/member_live/index/membership_fee_status.ex #, elixir-autogen, elixir-format msgid "Unpaid" @@ -2254,11 +2249,6 @@ msgstr "" msgid "Could not load data fields. Please check your permissions." msgstr "" -#: lib/mv_web/live/group_live/show.ex -#, elixir-autogen, elixir-format -msgid "Could not load member search. Please try again." -msgstr "" - #: lib/mv_web/live/import_export_live/components.ex #, elixir-autogen, elixir-format, fuzzy msgid "CSV files only, maximum %{size} MB" @@ -2412,93 +2402,22 @@ msgstr "" msgid "unpaid" msgstr "" -#: lib/mv_web/live/statistics_live.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Active members" -msgstr "" +#~ #: lib/mv_web/live/global_settings_live.ex +#~ #, elixir-autogen, elixir-format, fuzzy +#~ msgid "Custom Fields in CSV Import" +#~ msgstr "" -#: lib/mv_web/live/statistics_live.ex -#, elixir-autogen, elixir-format -msgid "Exits" -msgstr "" +#~ #: lib/mv_web/live/global_settings_live.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Failed to prepare CSV import: %{error}" +#~ msgstr "" -#: lib/mv_web/live/statistics_live.ex -#, elixir-autogen, elixir-format -msgid "Inactive members" -msgstr "" +#~ #: lib/mv_web/live/global_settings_live.ex +#~ #, elixir-autogen, elixir-format, fuzzy +#~ msgid "Individual data fields must be created in Mila before importing. Use the field name as the CSV column header. Unknown custom field columns will be ignored with a warning." +#~ msgstr "" -#: lib/mv_web/live/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/live/member_live/show/membership_fees_component.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Only administrators can regenerate cycles" +#~ msgstr "" diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index e96ca6e..f686c73 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -379,9 +379,10 @@ 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 (actor required for auth) + # Load member with cycles to check if they already exist member_with_cycles = - Ash.load!(final_member, :membership_fee_cycles, actor: admin_user_with_role) + final_member + |> Ash.load!(:membership_fee_cycles) # Only generate if no cycles exist yet (to avoid duplicates on re-run) cycles = @@ -426,7 +427,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, domain: Mv.MembershipFees) + |> Ash.update!(actor: admin_user_with_role) end end) end @@ -541,9 +542,10 @@ 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 (actor required for auth) + # Load member with cycles to check if they already exist member_with_cycles = - Ash.load!(final_member, :membership_fee_cycles, actor: admin_user_with_role) + final_member + |> Ash.load!(:membership_fee_cycles) # Only generate if no cycles exist yet (to avoid duplicates on re-run) cycles = @@ -573,7 +575,7 @@ Enum.with_index(linked_members) if cycle.status != status do cycle |> Ash.Changeset.for_update(:update, %{status: status}) - |> Ash.update!(actor: admin_user_with_role, domain: Mv.MembershipFees) + |> Ash.update!() end end) end diff --git a/test/mv/statistics_test.exs b/test/mv/statistics_test.exs deleted file mode 100644 index d4b4e05..0000000 --- a/test/mv/statistics_test.exs +++ /dev/null @@ -1,227 +0,0 @@ -defmodule Mv.StatisticsTest do - @moduledoc """ - Tests for Mv.Statistics module (member and membership fee cycle statistics). - """ - use Mv.DataCase, async: true - - require Ash.Query - import Ash.Expr - - alias Mv.Membership.Member - alias Mv.Statistics - alias Mv.MembershipFees - alias Mv.MembershipFees.MembershipFeeCycle - alias Mv.MembershipFees.MembershipFeeType - - setup do - actor = Mv.Helpers.SystemActor.get_system_actor() - %{actor: actor} - end - - defp create_fee_type(actor, attrs) do - MembershipFeeType - |> Ash.Changeset.for_create( - :create, - Map.merge( - %{ - name: "Test Fee #{System.unique_integer([:positive])}", - amount: Decimal.new("50.00"), - interval: :yearly - }, - attrs - ) - ) - |> Ash.create!(actor: actor) - end - - describe "first_join_year/1" do - test "returns the year of the earliest join_date", %{actor: actor} do - Mv.Fixtures.member_fixture(%{join_date: ~D[2019-03-15]}) - Mv.Fixtures.member_fixture(%{join_date: ~D[2022-01-01]}) - assert Statistics.first_join_year(actor: actor) == 2019 - end - - test "returns the only member's join year when one member exists", %{actor: actor} do - Mv.Fixtures.member_fixture(%{join_date: ~D[2021-06-01]}) - assert Statistics.first_join_year(actor: actor) == 2021 - end - - test "returns nil when no members exist", %{actor: actor} do - # Guarantee empty member table so the assertion is deterministic - Member - |> Ash.read!(actor: actor) - |> Enum.each(&Ash.destroy!(&1, actor: actor)) - - result = Statistics.first_join_year(actor: actor) - assert is_nil(result) - end - end - - describe "active_member_count/1" do - test "returns 0 when there are no members", %{actor: actor} do - assert Statistics.active_member_count(actor: actor) == 0 - end - - test "returns 1 when one member has no exit_date", %{actor: actor} do - Mv.Fixtures.member_fixture(%{join_date: ~D[2023-01-15]}) - assert Statistics.active_member_count(actor: actor) == 1 - end - - test "returns 0 for that member when exit_date is set", %{actor: actor} do - _member = - Mv.Fixtures.member_fixture(%{join_date: ~D[2023-01-15], exit_date: ~D[2024-06-01]}) - - assert Statistics.active_member_count(actor: actor) == 0 - end - - test "counts only active members when mix of active and inactive", %{actor: actor} do - Mv.Fixtures.member_fixture(%{join_date: ~D[2022-01-01]}) - Mv.Fixtures.member_fixture(%{join_date: ~D[2023-01-01], exit_date: ~D[2024-01-01]}) - assert Statistics.active_member_count(actor: actor) == 1 - end - end - - describe "inactive_member_count/1" do - test "returns 0 when all members are active", %{actor: actor} do - Mv.Fixtures.member_fixture(%{join_date: ~D[2023-01-01]}) - assert Statistics.inactive_member_count(actor: actor) == 0 - end - - test "returns 1 when one member has exit_date set", %{actor: actor} do - Mv.Fixtures.member_fixture(%{join_date: ~D[2022-01-01], exit_date: ~D[2024-06-01]}) - assert Statistics.inactive_member_count(actor: actor) == 1 - end - end - - describe "joins_by_year/2" do - test "returns 0 for year with no joins", %{actor: actor} do - assert Statistics.joins_by_year(1999, actor: actor) == 0 - end - - test "returns 1 when one member has join_date in that year", %{actor: actor} do - Mv.Fixtures.member_fixture(%{join_date: ~D[2023-06-15]}) - assert Statistics.joins_by_year(2023, actor: actor) == 1 - end - - test "returns 2 when two members joined in that year", %{actor: actor} do - Mv.Fixtures.member_fixture(%{join_date: ~D[2023-01-01]}) - Mv.Fixtures.member_fixture(%{join_date: ~D[2023-12-31]}) - assert Statistics.joins_by_year(2023, actor: actor) == 2 - end - end - - describe "exits_by_year/2" do - test "returns 0 for year with no exits", %{actor: actor} do - assert Statistics.exits_by_year(1999, actor: actor) == 0 - end - - test "returns 1 when one member has exit_date in that year", %{actor: actor} do - Mv.Fixtures.member_fixture(%{join_date: ~D[2020-01-01], exit_date: ~D[2023-06-15]}) - assert Statistics.exits_by_year(2023, actor: actor) == 1 - end - end - - describe "cycle_totals_by_year/2" do - test "returns zero totals for year with no cycles", %{actor: actor} do - result = Statistics.cycle_totals_by_year(1999, actor: actor) - assert result.total == Decimal.new(0) - assert result.paid == Decimal.new(0) - assert result.unpaid == Decimal.new(0) - assert result.suspended == Decimal.new(0) - end - - test "returns totals by status for cycles in that year", %{actor: actor} do - fee_type = create_fee_type(actor, %{amount: Decimal.new("50.00")}) - - # Creating members with fee type triggers cycle generation (2020..today). We use 2024 cycles. - _member1 = - Mv.Fixtures.member_fixture(%{ - join_date: ~D[2020-01-01], - membership_fee_type_id: fee_type.id - }) - - _member2 = - Mv.Fixtures.member_fixture(%{ - join_date: ~D[2020-01-01], - membership_fee_type_id: fee_type.id - }) - - # Get 2024 cycles and set status (each member has one 2024 yearly cycle from generator) - cycles_2024 = - MembershipFeeCycle - |> Ash.Query.filter( - expr(cycle_start >= ^~D[2024-01-01] and cycle_start < ^~D[2025-01-01]) - ) - |> Ash.read!(actor: actor) - |> Enum.sort_by(& &1.member_id) - - [c1, c2] = cycles_2024 - assert {:ok, _} = Ash.update(c1, %{status: :paid}, domain: MembershipFees, actor: actor) - - assert {:ok, _} = - Ash.update(c2, %{status: :suspended}, domain: MembershipFees, actor: actor) - - result = Statistics.cycle_totals_by_year(2024, actor: actor) - assert Decimal.equal?(result.total, Decimal.new("100.00")) - assert Decimal.equal?(result.paid, Decimal.new("50.00")) - assert Decimal.equal?(result.unpaid, Decimal.new(0)) - assert Decimal.equal?(result.suspended, Decimal.new("50.00")) - end - - test "when fee_type_id is passed in opts, returns only cycles of that fee type", %{ - actor: actor - } do - fee_type_a = create_fee_type(actor, %{amount: Decimal.new("30.00")}) - fee_type_b = create_fee_type(actor, %{amount: Decimal.new("70.00")}) - - _m1 = - Mv.Fixtures.member_fixture(%{ - join_date: ~D[2020-01-01], - membership_fee_type_id: fee_type_a.id - }) - - _m2 = - Mv.Fixtures.member_fixture(%{ - join_date: ~D[2020-01-01], - membership_fee_type_id: fee_type_b.id - }) - - # Without filter: both fee types' cycles (2024) - all_result = Statistics.cycle_totals_by_year(2024, actor: actor) - assert Decimal.equal?(all_result.total, Decimal.new("100.00")) - - # With fee_type_id as string (as from form/URL): only that type's cycles - opts_a = [actor: actor, fee_type_id: to_string(fee_type_a.id)] - result_a = Statistics.cycle_totals_by_year(2024, opts_a) - assert Decimal.equal?(result_a.total, Decimal.new("30.00")) - - opts_b = [actor: actor, fee_type_id: to_string(fee_type_b.id)] - result_b = Statistics.cycle_totals_by_year(2024, opts_b) - assert Decimal.equal?(result_b.total, Decimal.new("70.00")) - end - end - - describe "open_amount_total/1" do - test "returns 0 when there are no unpaid cycles", %{actor: actor} do - assert Statistics.open_amount_total(actor: actor) == Decimal.new(0) - end - - test "returns sum of amount for all unpaid cycles", %{actor: actor} do - fee_type = create_fee_type(actor, %{amount: Decimal.new("50.00")}) - - _member = - Mv.Fixtures.member_fixture(%{ - join_date: ~D[2020-01-01], - membership_fee_type_id: fee_type.id - }) - - # Cycle generator creates yearly cycles (2020..today), all unpaid by default - unpaid_sum = Statistics.open_amount_total(actor: actor) - assert Decimal.compare(unpaid_sum, Decimal.new(0)) == :gt - # Should be 50 * number of years from 2020 to current year - current_year = Date.utc_today().year - expected_count = current_year - 2020 + 1 - assert Decimal.equal?(unpaid_sum, Decimal.new(50 * expected_count)) - end - end -end diff --git a/test/mv_web/components/sidebar_authorization_test.exs b/test/mv_web/components/sidebar_authorization_test.exs index 110d9e5..079572f 100644 --- a/test/mv_web/components/sidebar_authorization_test.exs +++ b/test/mv_web/components/sidebar_authorization_test.exs @@ -25,13 +25,12 @@ defmodule MvWeb.SidebarAuthorizationTest do end describe "sidebar menu with admin user" do - test "shows Members, Fee Types, Statistics and Administration with all subitems" do + test "shows Members, Fee Types 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") @@ -42,12 +41,11 @@ defmodule MvWeb.SidebarAuthorizationTest do end describe "sidebar menu with read_only user (Vorstand/Buchhaltung)" do - test "shows Members, Statistics and Groups (from Administration)" do + test "shows Members 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 @@ -63,12 +61,11 @@ defmodule MvWeb.SidebarAuthorizationTest do end describe "sidebar menu with normal_user (Kassenwart)" do - test "shows Members, Statistics and Groups" do + test "shows Members 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 @@ -91,11 +88,10 @@ defmodule MvWeb.SidebarAuthorizationTest do refute html =~ ~s(href="/members") end - test "does not show Statistics, Fee Types or Administration" do + test "does not show Fee Types or Administration" do user = Fixtures.user_with_role_fixture("own_data") html = render_sidebar(sidebar_assigns(user)) - refute html =~ ~s(href="/statistics") refute html =~ ~s(href="/membership_fee_types") refute html =~ ~s(href="/users") refute html =~ ~s(data-testid="sidebar-administration") diff --git a/test/mv_web/live/group_live/show_accessibility_test.exs b/test/mv_web/live/group_live/show_accessibility_test.exs deleted file mode 100644 index 97ce469..0000000 --- a/test/mv_web/live/group_live/show_accessibility_test.exs +++ /dev/null @@ -1,301 +0,0 @@ -defmodule MvWeb.GroupLive.ShowAccessibilityTest do - @moduledoc """ - Accessibility tests for Add/Remove Member functionality. - Tests ARIA labels, keyboard navigation, and screen reader support. - """ - - use MvWeb.ConnCase, async: false - import Phoenix.LiveViewTest - use Gettext, backend: MvWeb.Gettext - - alias Mv.Membership - alias Mv.Fixtures - - describe "ARIA labels and roles" do - test "search input has proper ARIA attributes", %{conn: conn} do - group = Fixtures.group_fixture() - - {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - - # Open inline input - view - |> element("button", "Add Member") - |> render_click() - - html = render(view) - - # Search input should have proper ARIA attributes - assert html =~ ~r/aria-label/ || - html =~ ~r/aria-autocomplete/ || - html =~ ~r/role=["']combobox["']/ - end - - test "search input has correct aria-label and aria-autocomplete attributes", %{conn: conn} do - group = Fixtures.group_fixture() - - {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - - # Open inline input - view - |> element("button", "Add Member") - |> render_click() - - html = render(view) - - # Search input should have ARIA attributes - assert html =~ ~r/aria-label.*[Ss]earch.*member/ || - html =~ ~r/aria-autocomplete=["']list["']/ - end - - test "remove button has aria-label with tooltip text", %{conn: conn} do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - group = Fixtures.group_fixture() - - {:ok, member} = - Membership.create_member( - %{ - first_name: "Alice", - last_name: "Smith", - email: "alice@example.com" - }, - actor: system_actor - ) - - Membership.create_member_group(%{member_id: member.id, group_id: group.id}, - actor: system_actor - ) - - {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - - html = render(view) - - # Remove button should have aria-label - assert html =~ ~r/aria-label.*[Rr]emove/ || - html =~ ~r/aria-label.*member/i - end - - test "add button has correct aria-label", %{conn: conn} do - group = Fixtures.group_fixture() - - {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - - # Open inline input - view - |> element("button", "Add Member") - |> render_click() - - html = render(view) - - # Add button should have aria-label - assert html =~ ~r/aria-label.*[Aa]dd/ || - html =~ ~r/button.*[Aa]dd/ - end - end - - describe "keyboard navigation" do - test "tab navigation works in inline add member area", %{conn: conn} do - # This test verifies that keyboard navigation is possible - # Actual tab order testing would require more complex setup - group = Fixtures.group_fixture() - - {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - - # Open inline input - view - |> element("button", "Add Member") - |> render_click() - - html = render(view) - - # Inline add member area should have focusable elements - assert html =~ ~r/input|button/ || - html =~ "#member-search-input" - end - - test "inline input can be closed", %{conn: conn} do - group = Fixtures.group_fixture() - - {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - - # Open inline input - view - |> element("button", "Add Member") - |> render_click() - - assert has_element?(view, "#member-search-input") - - # Click Add Member button again to close (or add a member to close it) - # For now, we verify the input is visible when opened - html = render(view) - assert html =~ "#member-search-input" || has_element?(view, "#member-search-input") - end - - test "enter/space activates buttons when focused", %{conn: conn} do - # This test verifies that buttons can be activated via keyboard - # Actual keyboard event testing would require more complex setup - system_actor = Mv.Helpers.SystemActor.get_system_actor() - group = Fixtures.group_fixture() - - {:ok, member} = - Membership.create_member( - %{ - first_name: "Bob", - last_name: "Jones", - email: "bob@example.com" - }, - actor: system_actor - ) - - {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - - # Open inline input - view - |> element("button", "Add Member") - |> render_click() - - # Select member - view - |> element("#member-search-input") - |> render_focus() - - # phx-change is on the form, so we need to trigger it via the form - view - |> element("form[phx-change='search_members']") - |> render_change(%{"member_search" => "Bob"}) - - view - |> element("[data-member-id='#{member.id}']") - |> render_click() - - # Add button should be enabled and clickable - view - |> element("button[phx-click='add_selected_members']") - |> render_click() - - # Should succeed (member should appear in list) - html = render(view) - assert html =~ "Bob" - end - - test "focus management: focus is set to input when opened", %{conn: conn} do - # This test verifies that focus is properly managed - # When inline input opens, focus should move to input field - group = Fixtures.group_fixture() - - {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - - # Open inline input - view - |> element("button", "Add Member") - |> render_click() - - html = render(view) - - # Input should be visible and focusable - assert html =~ "#member-search-input" || - html =~ ~r/autofocus|tabindex/ - end - end - - describe "screen reader support" do - test "search input has proper label for screen readers", %{conn: conn} do - group = Fixtures.group_fixture() - - {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - - # Open inline input - view - |> element("button", "Add Member") - |> render_click() - - html = render(view) - - # Input should have aria-label - assert html =~ ~r/aria-label.*[Ss]earch.*member/ || - html =~ ~r/aria-label/ - end - - test "search results are properly announced", %{conn: conn} do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - group = Fixtures.group_fixture() - - {:ok, _member} = - Membership.create_member( - %{ - first_name: "Charlie", - last_name: "Brown", - email: "charlie@example.com" - }, - actor: system_actor - ) - - {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - - # Open inline input - view - |> element("button", "Add Member") - |> render_click() - - # Search - view - |> element("#member-search-input") - |> render_focus() - - # phx-change is on the form, so we need to trigger it via the form - view - |> element("form[phx-change='search_members']") - |> render_change(%{"member_search" => "Charlie"}) - - html = render(view) - - # Search results should have proper ARIA attributes - assert html =~ ~r/role=["']listbox["']/ || - html =~ ~r/role=["']option["']/ || - html =~ "Charlie" - end - - test "flash messages are properly announced", %{conn: conn} do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - group = Fixtures.group_fixture() - - {:ok, member} = - Membership.create_member( - %{ - first_name: "David", - last_name: "Wilson", - email: "david@example.com" - }, - actor: system_actor - ) - - {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - - # Add member - view - |> element("button", "Add Member") - |> render_click() - - view - |> element("#member-search-input") - |> render_focus() - - # phx-change is on the form, so we need to trigger it via the form - view - |> element("form[phx-change='search_members']") - |> render_change(%{"member_search" => "David"}) - - view - |> element("[data-member-id='#{member.id}']") - |> render_click() - - view - |> element("button[phx-click='add_selected_members']") - |> render_click() - - html = render(view) - - # Member should appear in list (no flash message) - assert html =~ "David" - end - end -end diff --git a/test/mv_web/live/group_live/show_add_member_test.exs b/test/mv_web/live/group_live/show_add_member_test.exs deleted file mode 100644 index 783db9d..0000000 --- a/test/mv_web/live/group_live/show_add_member_test.exs +++ /dev/null @@ -1,460 +0,0 @@ -defmodule MvWeb.GroupLive.ShowAddMemberTest do - @moduledoc """ - Tests for adding members to groups via the inline Add Member combobox. - Tests successful add, error handling, and edge cases. - """ - - use MvWeb.ConnCase, async: false - import Phoenix.LiveViewTest - import MvWeb.GroupLiveHelpers - use Gettext, backend: MvWeb.Gettext - - alias Mv.Membership - alias Mv.Fixtures - - describe "successful add member" do - test "member is added to group after selection and clicking Add", %{conn: conn} do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - group = Fixtures.group_fixture() - - {:ok, member} = - Membership.create_member( - %{ - first_name: "Alice", - last_name: "Johnson", - email: "alice@example.com" - }, - actor: system_actor - ) - - {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - - open_add_member(view) - search_member(view, "Alice") - select_member(view, member) - add_selected(view) - - html = render(view) - assert html =~ "Alice" - assert html =~ "Johnson" - end - - test "member is successfully added to group (verified in list)", %{conn: conn} do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - group = Fixtures.group_fixture() - - {:ok, member} = - Membership.create_member( - %{ - first_name: "Bob", - last_name: "Smith", - email: "bob@example.com" - }, - actor: system_actor - ) - - {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - - # Open inline input and add member - view - |> element("button", "Add Member") - |> render_click() - - view - |> element("#member-search-input") - |> render_focus() - - # phx-change is on the form, so we need to trigger it via the form - view - |> element("form[phx-change='search_members']") - |> render_change(%{"member_search" => "Bob"}) - - view - |> element("[data-member-id='#{member.id}']") - |> render_click() - - view - |> element("button[phx-click='add_selected_members']") - |> render_click() - - html = render(view) - - # Verify member appears in group list (no success flash message) - assert html =~ "Bob" - assert html =~ "Smith" - end - - test "group member list updates automatically after add", %{conn: conn} do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - group = Fixtures.group_fixture() - - {:ok, member} = - Membership.create_member( - %{ - first_name: "Charlie", - last_name: "Brown", - email: "charlie@example.com" - }, - actor: system_actor - ) - - {:ok, view, html} = live(conn, "/groups/#{group.slug}") - - # Initially member should NOT be in list - refute html =~ "Charlie" - - # Add member - view - |> element("button", "Add Member") - |> render_click() - - view - |> element("#member-search-input") - |> render_focus() - - # phx-change is on the form, so we need to trigger it via the form - view - |> element("form[phx-change='search_members']") - |> render_change(%{"member_search" => "Charlie"}) - - view - |> element("[data-member-id='#{member.id}']") - |> render_click() - - view - |> element("button[phx-click='add_selected_members']") - |> render_click() - - # Member should now appear in list - html = render(view) - assert html =~ "Charlie" - assert html =~ "Brown" - end - - test "member count updates automatically after add", %{conn: conn} do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - group = Fixtures.group_fixture() - - {:ok, member} = - Membership.create_member( - %{ - first_name: "David", - last_name: "Wilson", - email: "david@example.com" - }, - actor: system_actor - ) - - {:ok, view, html} = live(conn, "/groups/#{group.slug}") - - # Get initial count (should be 0) - initial_count = extract_member_count(html) - - # Add member - view - |> element("button", "Add Member") - |> render_click() - - view - |> element("#member-search-input") - |> render_focus() - - # phx-change is on the form, so we need to trigger it via the form - view - |> element("form[phx-change='search_members']") - |> render_change(%{"member_search" => "David"}) - - view - |> element("[data-member-id='#{member.id}']") - |> render_click() - - view - |> element("button[phx-click='add_selected_members']") - |> render_click() - - # Count should have increased - html = render(view) - new_count = extract_member_count(html) - assert new_count == initial_count + 1 - end - - test "inline add member area closes after successful member addition", %{conn: conn} do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - group = Fixtures.group_fixture() - - {:ok, member} = - Membership.create_member( - %{ - first_name: "Eve", - last_name: "Davis", - email: "eve@example.com" - }, - actor: system_actor - ) - - {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - - # Open inline input - view - |> element("button", "Add Member") - |> render_click() - - assert has_element?(view, "#member-search-input") - - # Add member - view - |> element("#member-search-input") - |> render_focus() - - # phx-change is on the form, so we need to trigger it via the form - view - |> element("form[phx-change='search_members']") - |> render_change(%{"member_search" => "Eve"}) - - view - |> element("[data-member-id='#{member.id}']") - |> render_click() - - view - |> element("button[phx-click='add_selected_members']") - |> render_click() - - # Inline input should be closed (Add Member button should be visible again) - refute has_element?(view, "#member-search-input") - end - - test "Cancel button closes inline add member area without adding", %{conn: conn} do - group = Fixtures.group_fixture() - - {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - - open_add_member(view) - assert has_element?(view, "#member-search-input") - assert has_element?(view, "button[phx-click='hide_add_member_input']") - - cancel_add_member(view) - - refute has_element?(view, "#member-search-input") - assert has_element?(view, "button", "Add Member") - end - end - - describe "error handling" do - test "error flash message when member is already in group", %{conn: conn} do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - group = Fixtures.group_fixture() - - {:ok, member} = - Membership.create_member( - %{ - first_name: "Frank", - last_name: "Moore", - email: "frank@example.com" - }, - actor: system_actor - ) - - # Add member to group first - Membership.create_member_group(%{member_id: member.id, group_id: group.id}, - actor: system_actor - ) - - {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - - # Try to add same member again - view - |> element("button", "Add Member") - |> render_click() - - # Member should not appear in search (filtered out) - # But if they do appear somehow, try to add them - # phx-change is on the form, so we need to trigger it via the form - view - |> element("form[phx-change='search_members']") - |> render_change(%{"member_search" => "Frank"}) - - # If member appears in results (shouldn't), try to add - # This tests the server-side duplicate prevention - if has_element?(view, "[data-member-id='#{member.id}']") do - view - |> element("[data-member-id='#{member.id}']") - |> render_click() - - view - |> element("button", "Add") - |> render_click() - - # Should show error - html = render(view) - assert html =~ gettext("already in group") || html =~ ~r/already.*group|duplicate/i - end - end - - test "error flash message for other errors", %{conn: conn} do - # This test verifies that error handling works for unexpected errors - # We can't easily simulate all error cases, but we test the error path exists - _system_actor = Mv.Helpers.SystemActor.get_system_actor() - group = Fixtures.group_fixture() - - {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - - # Open inline input - view - |> element("button", "Add Member") - |> render_click() - - # Try to add with invalid member ID (if possible) - # This tests error handling path - # Note: Actual implementation will handle this - end - - test "inline input remains open on error (user can correct)", %{conn: conn} do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - group = Fixtures.group_fixture() - - {:ok, member} = - Membership.create_member( - %{ - first_name: "Grace", - last_name: "Taylor", - email: "grace@example.com" - }, - actor: system_actor - ) - - # Add member first - Membership.create_member_group(%{member_id: member.id, group_id: group.id}, - actor: system_actor - ) - - {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - - # Open inline input - view - |> element("button", "Add Member") - |> render_click() - - # Inline input should be open - assert has_element?(view, "#member-search-input") - - # If error occurs, inline input should remain open - # (Implementation will handle this) - end - - test "Add button remains disabled until member selected", %{conn: conn} do - group = Fixtures.group_fixture() - - {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - - # Open inline input - view - |> element("button", "Add Member") - |> render_click() - - # Add button should be disabled - assert has_element?(view, "button[phx-click='add_selected_members'][disabled]") - end - end - - describe "edge cases" do - test "add works for group with 0 members", %{conn: conn} do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - group = Fixtures.group_fixture() - - {:ok, member} = - Membership.create_member( - %{ - first_name: "Henry", - last_name: "Anderson", - email: "henry@example.com" - }, - actor: system_actor - ) - - {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - - # Add member to empty group - view - |> element("button", "Add Member") - |> render_click() - - view - |> element("#member-search-input") - |> render_focus() - - # phx-change is on the form, so we need to trigger it via the form - view - |> element("form[phx-change='search_members']") - |> render_change(%{"member_search" => "Henry"}) - - view - |> element("[data-member-id='#{member.id}']") - |> render_click() - - view - |> element("button[phx-click='add_selected_members']") - |> render_click() - - # Member should be added - html = render(view) - assert html =~ "Henry" - end - - test "add works when member is already in other groups", %{conn: conn} do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - group1 = Fixtures.group_fixture() - group2 = Fixtures.group_fixture() - - {:ok, member} = - Membership.create_member( - %{ - first_name: "Isabel", - last_name: "Martinez", - email: "isabel@example.com" - }, - actor: system_actor - ) - - # Add member to group1 - Membership.create_member_group(%{member_id: member.id, group_id: group1.id}, - actor: system_actor - ) - - {:ok, view, _html} = live(conn, "/groups/#{group2.slug}") - - # Add same member to group2 (should work) - view - |> element("button", "Add Member") - |> render_click() - - view - |> element("#member-search-input") - |> render_focus() - - # phx-change is on the form, so we need to trigger it via the form - view - |> element("form[phx-change='search_members']") - |> render_change(%{"member_search" => "Isabel"}) - - view - |> element("[data-member-id='#{member.id}']") - |> render_click() - - view - |> element("button[phx-click='add_selected_members']") - |> render_click() - - # Member should be added to group2 - html = render(view) - assert html =~ "Isabel" - end - end - - # Helper function to extract member count from HTML - defp extract_member_count(html) do - case Regex.run(~r/Total:\s*(\d+)/, html) do - [_, count_str] -> String.to_integer(count_str) - _ -> 0 - end - end -end diff --git a/test/mv_web/live/group_live/show_add_remove_members_test.exs b/test/mv_web/live/group_live/show_add_remove_members_test.exs deleted file mode 100644 index c014372..0000000 --- a/test/mv_web/live/group_live/show_add_remove_members_test.exs +++ /dev/null @@ -1,135 +0,0 @@ -defmodule MvWeb.GroupLive.ShowAddRemoveMembersTest do - @moduledoc """ - UI tests for Add/Remove Member buttons visibility and inline add member display. - Tests UI rendering and permission-based visibility. - """ - - use MvWeb.ConnCase, async: false - import Phoenix.LiveViewTest - use Gettext, backend: MvWeb.Gettext - - alias Mv.Membership - alias Mv.Fixtures - - describe "Add Member button visibility" do - @tag role: :read_only - test "read_only user can access group show page (page permission)", %{conn: conn} do - group = Fixtures.group_fixture() - conn = get(conn, "/groups/#{group.slug}") - assert conn.status == 200 - end - - test "Add Member button is visible for users with :update permission", %{conn: conn} do - group = Fixtures.group_fixture() - - {:ok, _view, html} = live(conn, "/groups/#{group.slug}") - - assert html =~ gettext("Add Member") or html =~ "Add Member" - end - - @tag role: :read_only - test "Add Member button is NOT visible for users without :update permission", %{conn: conn} do - group = Fixtures.group_fixture() - - {:ok, _view, html} = live(conn, "/groups/#{group.slug}") - - refute html =~ gettext("Add Member") - end - - test "Add Member button is positioned above member table", %{conn: conn} do - group = Fixtures.group_fixture() - - {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - - # Button should exist - assert has_element?(view, "button", gettext("Add Member")) || - has_element?(view, "a", gettext("Add Member")) - end - end - - describe "Remove button visibility" do - test "Remove button is visible for each member for users with :update permission", %{ - conn: conn - } do - group = Fixtures.group_fixture() - member = Fixtures.member_fixture(%{first_name: "Alice", last_name: "Smith"}) - system_actor = Mv.Helpers.SystemActor.get_system_actor() - - Membership.create_member_group(%{member_id: member.id, group_id: group.id}, - actor: system_actor - ) - - {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - - # Remove button should exist (can be icon button with trash icon) - html = render(view) - - assert html =~ "Remove" or html =~ "remove" or html =~ "trash" or - html =~ ~r/hero-trash|hero-x-mark/ - end - - @tag role: :read_only - test "Remove button is NOT visible for users without :update permission", %{conn: conn} do - group = Fixtures.group_fixture() - member = Fixtures.member_fixture(%{first_name: "Bob", last_name: "Jones"}) - system_actor = Mv.Helpers.SystemActor.get_system_actor() - - Membership.create_member_group(%{member_id: member.id, group_id: group.id}, - actor: system_actor - ) - - {:ok, _view, html} = live(conn, "/groups/#{group.slug}") - - # Remove button should NOT exist (check for trash icon or remove button specifically) - refute html =~ "hero-trash" or html =~ ~r/]*remove_member/ - end - end - - describe "inline add member input" do - test "inline input appears when Add Member button is clicked", %{conn: conn} do - group = Fixtures.group_fixture() - - {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - - # Click Add Member button - view - |> element("button", gettext("Add Member")) - |> render_click() - - # Inline input should be visible - assert has_element?(view, "#member-search-input") - end - - test "search input has correct placeholder", %{conn: conn} do - group = Fixtures.group_fixture() - - {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - - # Open inline input - view - |> element("button", gettext("Add Member")) - |> render_click() - - html = render(view) - - assert html =~ gettext("Search for a member...") || - html =~ ~r/search.*member/i - end - - test "Add button (plus icon) is disabled until member selected", %{conn: conn} do - group = Fixtures.group_fixture() - - {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - - # Open inline input - view - |> element("button", gettext("Add Member")) - |> render_click() - - html = render(view) - # Add button should exist and be disabled initially - assert has_element?(view, "button[phx-click='add_selected_members'][disabled]") || - html =~ ~r/disabled/ - end - end -end diff --git a/test/mv_web/live/group_live/show_authorization_test.exs b/test/mv_web/live/group_live/show_authorization_test.exs deleted file mode 100644 index f121b36..0000000 --- a/test/mv_web/live/group_live/show_authorization_test.exs +++ /dev/null @@ -1,285 +0,0 @@ -defmodule MvWeb.GroupLive.ShowAuthorizationTest do - @moduledoc """ - Tests for authorization and security in Add/Remove Member functionality. - Tests server-side authorization checks and UI permission enforcement. - """ - - use MvWeb.ConnCase, async: false - import Phoenix.LiveViewTest - use Gettext, backend: MvWeb.Gettext - - alias Mv.Membership - alias Mv.Fixtures - - describe "server-side authorization" do - test "add member event handler checks :update permission", %{conn: conn} do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - group = Fixtures.group_fixture() - - {:ok, member} = - Membership.create_member( - %{ - first_name: "Alice", - last_name: "Smith", - email: "alice@example.com" - }, - actor: system_actor - ) - - {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - - # Open inline input and try to add member - view - |> element("button", "Add Member") - |> render_click() - - view - |> element("#member-search-input") - |> render_focus() - - # phx-change is on the form, so we need to trigger it via the form - view - |> element("form[phx-change='search_members']") - |> render_change(%{"member_search" => "Alice"}) - - view - |> element("[data-member-id='#{member.id}']") - |> render_click() - - # Try to add (should succeed for admin) - view - |> element("button[phx-click='add_selected_members']") - |> render_click() - - # Should succeed (admin has :update permission, member should appear in list) - html = render(view) - assert html =~ "Alice" - end - - @tag role: :read_only - test "unauthorized user cannot add member (server-side check)", %{conn: conn} do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - group = Fixtures.group_fixture() - - {:ok, _member} = - Membership.create_member( - %{ - first_name: "Bob", - last_name: "Jones", - email: "bob@example.com" - }, - actor: system_actor - ) - - {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - - # Try to trigger add event directly (even if button is hidden) - # This tests server-side authorization - # Note: If button is hidden, we can't click it, but we test the event handler - # by trying to send the event directly if possible - - # For now, we verify that the button is not visible - html = render(view) - refute html =~ "Add Member" - end - - test "remove member event handler checks :update permission", %{conn: conn} do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - group = Fixtures.group_fixture() - - {:ok, member} = - Membership.create_member( - %{ - first_name: "Charlie", - last_name: "Brown", - email: "charlie@example.com" - }, - actor: system_actor - ) - - Membership.create_member_group(%{member_id: member.id, group_id: group.id}, - actor: system_actor - ) - - {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - - # Remove member (should succeed for admin) - view - |> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']") - |> render_click() - - # Should succeed (member should no longer be in list) - html = render(view) - refute html =~ "Charlie" - end - - @tag role: :read_only - test "unauthorized user cannot remove member (server-side check)", %{conn: conn} do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - group = Fixtures.group_fixture() - - {:ok, member} = - Membership.create_member( - %{ - first_name: "David", - last_name: "Wilson", - email: "david@example.com" - }, - actor: system_actor - ) - - Membership.create_member_group(%{member_id: member.id, group_id: group.id}, - actor: system_actor - ) - - {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - - # Remove button should not be visible - html = render(view) - - # Read-only user should NOT see Remove button (check for trash icon or remove button specifically) - refute html =~ "hero-trash" or html =~ ~r/]*remove_member/ - end - - test "error flash message on unauthorized access", %{conn: conn} do - # This test verifies that error messages are shown for unauthorized access - # Implementation will handle this in event handlers - _system_actor = Mv.Helpers.SystemActor.get_system_actor() - group = Fixtures.group_fixture() - - {:ok, _view, _html} = live(conn, "/groups/#{group.slug}") - - # For admin, should not see error - # For non-admin, buttons are hidden (UI-level check) - # Server-side check will show error if event is somehow triggered - end - end - - describe "UI permission checks" do - test "buttons are hidden for unauthorized users", %{conn: conn} do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - group = Fixtures.group_fixture() - - {:ok, member} = - Membership.create_member( - %{ - first_name: "Eve", - last_name: "Davis", - email: "eve@example.com" - }, - actor: system_actor - ) - - Membership.create_member_group(%{member_id: member.id, group_id: group.id}, - actor: system_actor - ) - - {:ok, _view, html} = live(conn, "/groups/#{group.slug}") - - # Admin should see buttons - assert html =~ "Add Member" || html =~ "Remove" - end - - @tag role: :read_only - test "Add Member button is hidden for read-only users", %{conn: conn} do - _system_actor = Mv.Helpers.SystemActor.get_system_actor() - group = Fixtures.group_fixture() - - {:ok, _view, html} = live(conn, "/groups/#{group.slug}") - - # Read-only user should NOT see Add Member button - refute html =~ "Add Member" - end - - @tag role: :read_only - test "Remove button is hidden for read-only users", %{conn: conn} do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - group = Fixtures.group_fixture() - - {:ok, member} = - Membership.create_member( - %{ - first_name: "Frank", - last_name: "Moore", - email: "frank@example.com" - }, - actor: system_actor - ) - - Membership.create_member_group(%{member_id: member.id, group_id: group.id}, - actor: system_actor - ) - - {:ok, _view, html} = live(conn, "/groups/#{group.slug}") - - # Read-only user should NOT see Remove button (check for trash icon or remove button specifically) - refute html =~ "hero-trash" or html =~ ~r/]*remove_member/ - end - - @tag role: :read_only - test "inline add member area cannot be opened for unauthorized users", %{conn: conn} do - group = Fixtures.group_fixture() - - {:ok, _view, html} = live(conn, "/groups/#{group.slug}") - - # Inline input should not be accessible (button hidden) - refute html =~ "Add Member" - refute html =~ "#member-search-input" - end - end - - describe "member (own_data) page access" do - # Members have no page permission for /groups or /groups/:slug; they are redirected. - # This tests that limited access for the member role is enforced. - @tag role: :member - test "member is redirected when accessing group show page", %{conn: conn} do - group = Fixtures.group_fixture() - - result = live(conn, "/groups/#{group.slug}") - - assert {:error, {:redirect, %{to: path, flash: %{"error" => _}}}} = result - assert path =~ ~r|^/users/[^/]+$| - end - - @tag role: :member - test "member is redirected when accessing groups index", %{conn: conn} do - result = live(conn, "/groups") - - assert {:error, {:redirect, %{to: path, flash: %{"error" => _}}}} = result - assert path =~ ~r|^/users/[^/]+$| - end - end - - describe "security edge cases" do - test "slug injection attempts are prevented", %{conn: conn} do - # Try to inject malicious content in slug - malicious_slug = "'; DROP TABLE groups; --" - - result = live(conn, "/groups/#{malicious_slug}") - - # Should not execute SQL, should return 404 or error - assert match?({:error, {:redirect, %{to: "/groups"}}}, result) || - match?({:error, {:live_redirect, %{to: "/groups"}}}, result) - end - - @tag :skip - test "non-existent member IDs are handled", %{conn: conn} do - # Future: test add_selected_members with invalid ID (would require pushing event with forged selected_member_ids) - group = Fixtures.group_fixture() - - {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - - assert has_element?(view, "button", "Add Member") - end - - test "non-existent group IDs are handled", %{conn: conn} do - # Accessing non-existent group should redirect - non_existent_slug = "non-existent-group-#{System.unique_integer([:positive])}" - - result = live(conn, "/groups/#{non_existent_slug}") - - assert match?({:error, {:redirect, %{to: "/groups"}}}, result) || - match?({:error, {:live_redirect, %{to: "/groups"}}}, result) - end - end -end diff --git a/test/mv_web/live/group_live/show_integration_test.exs b/test/mv_web/live/group_live/show_integration_test.exs deleted file mode 100644 index 0a82be8..0000000 --- a/test/mv_web/live/group_live/show_integration_test.exs +++ /dev/null @@ -1,432 +0,0 @@ -defmodule MvWeb.GroupLive.ShowIntegrationTest do - @moduledoc """ - Integration tests for Add/Remove Member functionality. - Tests data consistency, database operations, and multiple operations. - """ - - use MvWeb.ConnCase, async: false - import Phoenix.LiveViewTest - use Gettext, backend: MvWeb.Gettext - - alias Mv.Membership - alias Mv.Fixtures - - describe "data consistency" do - test "member appears in group after add (verified in database)", %{conn: conn} do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - group = Fixtures.group_fixture() - - {:ok, member} = - Membership.create_member( - %{ - first_name: "Alice", - last_name: "Smith", - email: "alice@example.com" - }, - actor: system_actor - ) - - {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - - # Add member via UI - view - |> element("button", "Add Member") - |> render_click() - - view - |> element("#member-search-input") - |> render_focus() - - # phx-change is on the form, so we need to trigger it via the form - view - |> element("form[phx-change='search_members']") - |> render_change(%{"member_search" => "Alice"}) - - view - |> element("[data-member-id='#{member.id}']") - |> render_click() - - view - |> element("button[phx-click='add_selected_members']") - |> render_click() - - # Verify in database - require Ash.Query - - query = - Mv.Membership.Group - |> Ash.Query.filter(slug == ^group.slug) - |> Ash.Query.load([:members]) - - {:ok, updated_group} = Ash.read_one(query, actor: system_actor, domain: Mv.Membership) - - # Member should be in group - assert Enum.any?(updated_group.members, &(&1.id == member.id)) - end - - test "member disappears from group after remove (verified in database)", %{conn: conn} do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - group = Fixtures.group_fixture() - - {:ok, member} = - Membership.create_member( - %{ - first_name: "Bob", - last_name: "Jones", - email: "bob@example.com" - }, - actor: system_actor - ) - - # Add member to group - Membership.create_member_group(%{member_id: member.id, group_id: group.id}, - actor: system_actor - ) - - {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - - # Remove member via UI - view - |> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']") - |> render_click() - - # Verify in database - require Ash.Query - - query = - Mv.Membership.Group - |> Ash.Query.filter(slug == ^group.slug) - |> Ash.Query.load([:members]) - - {:ok, updated_group} = Ash.read_one(query, actor: system_actor, domain: Mv.Membership) - - # Member should NOT be in group - refute Enum.any?(updated_group.members, &(&1.id == member.id)) - end - - test "MemberGroup association is created correctly", %{conn: conn} do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - group = Fixtures.group_fixture() - - {:ok, member} = - Membership.create_member( - %{ - first_name: "Charlie", - last_name: "Brown", - email: "charlie@example.com" - }, - actor: system_actor - ) - - {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - - # Add member - view - |> element("button", "Add Member") - |> render_click() - - view - |> element("#member-search-input") - |> render_focus() - - # phx-change is on the form, so we need to trigger it via the form - view - |> element("form[phx-change='search_members']") - |> render_change(%{"member_search" => "Charlie"}) - - view - |> element("[data-member-id='#{member.id}']") - |> render_click() - - view - |> element("button[phx-click='add_selected_members']") - |> render_click() - - # Verify MemberGroup association exists - require Ash.Query - - {:ok, member_groups} = - Ash.read( - Mv.Membership.MemberGroup - |> Ash.Query.filter(member_id == ^member.id and group_id == ^group.id), - actor: system_actor, - domain: Mv.Membership - ) - - assert length(member_groups) == 1 - assert hd(member_groups).member_id == member.id - assert hd(member_groups).group_id == group.id - end - - test "MemberGroup association is deleted correctly", %{conn: conn} do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - group = Fixtures.group_fixture() - - {:ok, member} = - Membership.create_member( - %{ - first_name: "David", - last_name: "Wilson", - email: "david@example.com" - }, - actor: system_actor - ) - - # Add member first - Membership.create_member_group(%{member_id: member.id, group_id: group.id}, - actor: system_actor - ) - - {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - - # Remove member - view - |> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']") - |> render_click() - - # Verify MemberGroup association is deleted - require Ash.Query - - {:ok, member_groups} = - Ash.read( - Mv.Membership.MemberGroup - |> Ash.Query.filter(member_id == ^member.id and group_id == ^group.id), - actor: system_actor, - domain: Mv.Membership - ) - - assert member_groups == [] - end - - test "member itself is NOT deleted (only association)", %{conn: conn} do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - group = Fixtures.group_fixture() - - {:ok, member} = - Membership.create_member( - %{ - first_name: "Eve", - last_name: "Davis", - email: "eve@example.com" - }, - actor: system_actor - ) - - # Add member to group - Membership.create_member_group(%{member_id: member.id, group_id: group.id}, - actor: system_actor - ) - - {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - - # Remove member from group - view - |> element("button[phx-click='remove_member']", "") - |> render_click() - - # Verify member still exists - {:ok, member_after_remove} = - Ash.get(Mv.Membership.Member, member.id, actor: system_actor) - - assert member_after_remove.id == member.id - assert member_after_remove.first_name == "Eve" - end - end - - describe "multiple operations" do - test "multiple members can be added sequentially", %{conn: conn} do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - group = Fixtures.group_fixture() - - {:ok, member1} = - Membership.create_member( - %{ - first_name: "Frank", - last_name: "Moore", - email: "frank@example.com" - }, - actor: system_actor - ) - - {:ok, member2} = - Membership.create_member( - %{ - first_name: "Grace", - last_name: "Taylor", - email: "grace@example.com" - }, - actor: system_actor - ) - - {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - - # Add first member - view - |> element("button", "Add Member") - |> render_click() - - view - |> element("#member-search-input") - |> render_focus() - - # phx-change is on the form, so we need to trigger it via the form - view - |> element("form[phx-change='search_members']") - |> render_change(%{"member_search" => "Frank"}) - - view - |> element("[data-member-id='#{member1.id}']") - |> render_click() - - view - |> element("button[phx-click='add_selected_members']") - |> render_click() - - # Add second member - view - |> element("button", "Add Member") - |> render_click() - - view - |> element("#member-search-input") - |> render_focus() - - # phx-change is on the form, so we need to trigger it via the form - view - |> element("form[phx-change='search_members']") - |> render_change(%{"member_search" => "Grace"}) - - view - |> element("[data-member-id='#{member2.id}']") - |> render_click() - - view - |> element("button[phx-click='add_selected_members']") - |> render_click() - - # Both members should be in list - html = render(view) - assert html =~ "Frank" - assert html =~ "Grace" - end - - test "multiple members can be removed sequentially", %{conn: conn} do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - group = Fixtures.group_fixture() - - {:ok, member1} = - Membership.create_member( - %{ - first_name: "Henry", - last_name: "Anderson", - email: "henry@example.com" - }, - actor: system_actor - ) - - {:ok, member2} = - Membership.create_member( - %{ - first_name: "Isabel", - last_name: "Martinez", - email: "isabel@example.com" - }, - actor: system_actor - ) - - # Add both members - Membership.create_member_group(%{member_id: member1.id, group_id: group.id}, - actor: system_actor - ) - - Membership.create_member_group(%{member_id: member2.id, group_id: group.id}, - actor: system_actor - ) - - {:ok, view, html} = live(conn, "/groups/#{group.slug}") - - # Both should be in list initially - assert html =~ "Henry" - assert html =~ "Isabel" - - # Remove first member - view - |> element("button[phx-click='remove_member'][phx-value-member_id='#{member1.id}']") - |> render_click() - - # Remove second member - view - |> element("button[phx-click='remove_member'][phx-value-member_id='#{member2.id}']") - |> render_click() - - # Both should be removed - html = render(view) - refute html =~ "Henry" - refute html =~ "Isabel" - end - - test "add and remove can be mixed", %{conn: conn} do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - group = Fixtures.group_fixture() - - {:ok, member1} = - Membership.create_member( - %{ - first_name: "Jack", - last_name: "White", - email: "jack@example.com" - }, - actor: system_actor - ) - - {:ok, member2} = - Membership.create_member( - %{ - first_name: "Kate", - last_name: "Black", - email: "kate@example.com" - }, - actor: system_actor - ) - - # Add member1 first - Membership.create_member_group(%{member_id: member1.id, group_id: group.id}, - actor: system_actor - ) - - {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - - # Add member2 - view - |> element("button", "Add Member") - |> render_click() - - view - |> element("#member-search-input") - |> render_focus() - - # phx-change is on the form, so we need to trigger it via the form - view - |> element("form[phx-change='search_members']") - |> render_change(%{"member_search" => "Kate"}) - - view - |> element("[data-member-id='#{member2.id}']") - |> render_click() - - view - |> element("button[phx-click='add_selected_members']") - |> render_click() - - # Remove member1 - view - |> element("button[phx-click='remove_member'][phx-value-member_id='#{member1.id}']") - |> render_click() - - # Only member2 should remain - html = render(view) - refute html =~ "Jack" - assert html =~ "Kate" - end - end -end diff --git a/test/mv_web/live/group_live/show_member_search_test.exs b/test/mv_web/live/group_live/show_member_search_test.exs deleted file mode 100644 index 75d1803..0000000 --- a/test/mv_web/live/group_live/show_member_search_test.exs +++ /dev/null @@ -1,339 +0,0 @@ -defmodule MvWeb.GroupLive.ShowMemberSearchTest do - @moduledoc """ - UI tests for member search functionality in inline Add Member combobox. - Tests search behavior and filtering of members already in group. - """ - - use MvWeb.ConnCase, async: false - import Phoenix.LiveViewTest - use Gettext, backend: MvWeb.Gettext - - alias Mv.Membership - alias Mv.Fixtures - - # Helper to setup authenticated connection for admin - defp setup_admin_conn(conn) do - conn_with_oidc_user(conn, %{email: "admin@example.com"}) - end - - describe "search functionality" do - test "search finds member by exact name", %{conn: conn} do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - conn = setup_admin_conn(conn) - group = Fixtures.group_fixture() - - {:ok, _member} = - Membership.create_member( - %{ - first_name: "Jonathan", - last_name: "Smith", - email: "jonathan.smith@example.com" - }, - actor: system_actor - ) - - {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - - # Open inline input - view - |> element("button", "Add Member") - |> render_click() - - # Type exact name - # phx-change is on the form, so we need to trigger it via the form - view - |> element("form[phx-change='search_members']") - |> render_change(%{"member_search" => "Jonathan"}) - - html = render(view) - - assert html =~ "Jonathan" - assert html =~ "Smith" - end - - test "search finds member by partial name (fuzzy)", %{conn: conn} do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - conn = setup_admin_conn(conn) - group = Fixtures.group_fixture() - - {:ok, _member} = - Membership.create_member( - %{ - first_name: "Jonathan", - last_name: "Smith", - email: "jonathan.smith@example.com" - }, - actor: system_actor - ) - - {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - - # Open inline input - view - |> element("button", "Add Member") - |> render_click() - - # Type partial name - # phx-change is on the form, so we need to trigger it via the form - view - |> element("form[phx-change='search_members']") - |> render_change(%{"member_search" => "Jon"}) - - html = render(view) - - # Fuzzy search should find Jonathan - assert html =~ "Jonathan" - assert html =~ "Smith" - end - - test "search finds member by email", %{conn: conn} do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - conn = setup_admin_conn(conn) - group = Fixtures.group_fixture() - - {:ok, _member} = - Membership.create_member( - %{ - first_name: "Alice", - last_name: "Johnson", - email: "alice.johnson@example.com" - }, - actor: system_actor - ) - - {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - - # Open inline input - view - |> element("button", "Add Member") - |> render_click() - - # Search by email - # phx-change is on the form, so we need to trigger it via the form - view - |> element("form[phx-change='search_members']") - |> render_change(%{"member_search" => "alice.johnson"}) - - html = render(view) - - assert html =~ "Alice" - assert html =~ "Johnson" - assert html =~ "alice.johnson@example.com" - end - - test "dropdown shows member name and email", %{conn: conn} do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - conn = setup_admin_conn(conn) - group = Fixtures.group_fixture() - - {:ok, _member} = - Membership.create_member( - %{ - first_name: "Bob", - last_name: "Williams", - email: "bob@example.com" - }, - actor: system_actor - ) - - {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - - # Open inline input - view - |> element("button", "Add Member") - |> render_click() - - # Focus and search - view - |> element("#member-search-input") - |> render_focus() - - # phx-change is on the form, so we need to trigger it via the form - view - |> element("form[phx-change='search_members']") - |> render_change(%{"member_search" => "Bob"}) - - html = render(view) - - assert html =~ "Bob" - assert html =~ "Williams" - assert html =~ "bob@example.com" - end - - test "ComboBox hook works (focus opens dropdown)", %{conn: conn} do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - conn = setup_admin_conn(conn) - group = Fixtures.group_fixture() - - {:ok, _member} = - Membership.create_member( - %{ - first_name: "Charlie", - last_name: "Brown", - email: "charlie@example.com" - }, - actor: system_actor - ) - - {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - - # Open inline input - view - |> element("button", "Add Member") - |> render_click() - - # Focus input - view - |> element("#member-search-input") - |> render_focus() - - html = render(view) - - # Dropdown should be visible - assert html =~ ~r/role="listbox"/ || html =~ "listbox" - end - end - - describe "filtering members already in group" do - test "members already in group are NOT shown in search results", %{conn: conn} do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - conn = setup_admin_conn(conn) - group = Fixtures.group_fixture() - - # Create member and add to group - {:ok, member_in_group} = - Membership.create_member( - %{ - first_name: "David", - last_name: "Miller", - email: "david@example.com" - }, - actor: system_actor - ) - - Membership.create_member_group(%{member_id: member_in_group.id, group_id: group.id}, - actor: system_actor - ) - - # Create another member NOT in group - {:ok, _member_not_in_group} = - Membership.create_member( - %{ - first_name: "David", - last_name: "Anderson", - email: "david.anderson@example.com" - }, - actor: system_actor - ) - - {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - - # Open inline input - view - |> element("button", "Add Member") - |> render_click() - - # Search for "David" - # phx-change is on the form, so we need to trigger it via the form - view - |> element("form[phx-change='search_members']") - |> render_change(%{"member_search" => "David"}) - - # Assert only on dropdown (available members), not the members table - dropdown_html = view |> element("#member-dropdown") |> render() - assert dropdown_html =~ "Anderson" - refute dropdown_html =~ "Miller" - end - - test "search filters correctly when group has many members", %{conn: conn} do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - conn = setup_admin_conn(conn) - group = Fixtures.group_fixture() - - # Add multiple members to group - Enum.each(1..5, fn i -> - {:ok, m} = - Membership.create_member( - %{ - first_name: "Member#{i}", - last_name: "InGroup", - email: "member#{i}@example.com" - }, - actor: system_actor - ) - - Membership.create_member_group(%{member_id: m.id, group_id: group.id}, - actor: system_actor - ) - end) - - # Create member NOT in group - {:ok, _member_not_in_group} = - Membership.create_member( - %{ - first_name: "Available", - last_name: "Member", - email: "available@example.com" - }, - actor: system_actor - ) - - {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - - # Open inline input - view - |> element("button", "Add Member") - |> render_click() - - # Search - # phx-change is on the form, so we need to trigger it via the form - view - |> element("form[phx-change='search_members']") - |> render_change(%{"member_search" => "Available"}) - - # Assert only on dropdown (available members), not the members table - dropdown_html = view |> element("#member-dropdown") |> render() - assert dropdown_html =~ "Available" - assert dropdown_html =~ "Member" - refute dropdown_html =~ "Member1" - refute dropdown_html =~ "Member2" - end - - test "search shows no results when all available members are already in group", %{conn: conn} do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - conn = setup_admin_conn(conn) - group = Fixtures.group_fixture() - - # Create and add all members to group - {:ok, member} = - Membership.create_member( - %{ - first_name: "Only", - last_name: "Member", - email: "only@example.com" - }, - actor: system_actor - ) - - Membership.create_member_group(%{member_id: member.id, group_id: group.id}, - actor: system_actor - ) - - {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - - # Open inline input - view - |> element("button", "Add Member") - |> render_click() - - # Search - # phx-change is on the form, so we need to trigger it via the form - view - |> element("form[phx-change='search_members']") - |> render_change(%{"member_search" => "Only"}) - - # When no available members, dropdown is not rendered (length(@available_members) == 0) - refute has_element?(view, "#member-dropdown") - end - end -end diff --git a/test/mv_web/live/group_live/show_remove_member_test.exs b/test/mv_web/live/group_live/show_remove_member_test.exs deleted file mode 100644 index d081b50..0000000 --- a/test/mv_web/live/group_live/show_remove_member_test.exs +++ /dev/null @@ -1,334 +0,0 @@ -defmodule MvWeb.GroupLive.ShowRemoveMemberTest do - @moduledoc """ - Tests for removing members from groups via the Remove button. - Tests successful remove, edge cases, and immediate removal (no confirmation). - """ - - use MvWeb.ConnCase, async: false - import Phoenix.LiveViewTest - use Gettext, backend: MvWeb.Gettext - - alias Mv.Membership - alias Mv.Fixtures - - describe "successful remove member" do - test "member is removed from group after clicking Remove", %{conn: conn} do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - group = Fixtures.group_fixture() - - {:ok, member} = - Membership.create_member( - %{ - first_name: "Alice", - last_name: "Smith", - email: "alice@example.com" - }, - actor: system_actor - ) - - # Add member to group - Membership.create_member_group(%{member_id: member.id, group_id: group.id}, - actor: system_actor - ) - - {:ok, view, html} = live(conn, "/groups/#{group.slug}") - - # Member should be in list initially - assert html =~ "Alice" - - # Click Remove button - view - |> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']") - |> render_click() - - # Member should no longer be in list (no success flash message) - html = render(view) - refute html =~ "Alice" - end - - test "member is successfully removed from group (verified in list)", %{conn: conn} do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - group = Fixtures.group_fixture() - - {:ok, member} = - Membership.create_member( - %{ - first_name: "Bob", - last_name: "Jones", - email: "bob@example.com" - }, - actor: system_actor - ) - - Membership.create_member_group(%{member_id: member.id, group_id: group.id}, - actor: system_actor - ) - - {:ok, view, html} = live(conn, "/groups/#{group.slug}") - - # Member should be in list initially - assert html =~ "Bob" - - # Remove member - view - |> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']") - |> render_click() - - html = render(view) - - # Member should no longer be in list (no success flash message) - refute html =~ "Bob" - end - - test "group member list updates automatically after remove", %{conn: conn} do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - group = Fixtures.group_fixture() - - {:ok, member} = - Membership.create_member( - %{ - first_name: "Charlie", - last_name: "Brown", - email: "charlie@example.com" - }, - actor: system_actor - ) - - Membership.create_member_group(%{member_id: member.id, group_id: group.id}, - actor: system_actor - ) - - {:ok, view, html} = live(conn, "/groups/#{group.slug}") - - # Member should be in list initially - assert html =~ "Charlie" - - # Remove member - view - |> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']") - |> render_click() - - # Member should no longer be in list - html = render(view) - refute html =~ "Charlie" - end - - test "member count updates automatically after remove", %{conn: conn} do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - group = Fixtures.group_fixture() - - {:ok, member1} = - Membership.create_member( - %{ - first_name: "David", - last_name: "Wilson", - email: "david@example.com" - }, - actor: system_actor - ) - - {:ok, member2} = - Membership.create_member( - %{ - first_name: "Eve", - last_name: "Davis", - email: "eve@example.com" - }, - actor: system_actor - ) - - # Add both members - Membership.create_member_group(%{member_id: member1.id, group_id: group.id}, - actor: system_actor - ) - - Membership.create_member_group(%{member_id: member2.id, group_id: group.id}, - actor: system_actor - ) - - {:ok, view, html} = live(conn, "/groups/#{group.slug}") - - # Get initial count (should be 2) - initial_count = extract_member_count(html) - assert initial_count >= 2 - - # Remove one member (need to get member_id from HTML or use first available) - # For this test, we'll remove the first member - _html_before = render(view) - # Extract first member ID from the rendered HTML or use a different approach - # Since we have member1 and member2, we can target member1 specifically - view - |> element("button[phx-click='remove_member'][phx-value-member_id='#{member1.id}']") - |> render_click() - - # Count should have decreased - html = render(view) - new_count = extract_member_count(html) - assert new_count == initial_count - 1 - end - - test "no confirmation dialog appears (immediate removal)", %{conn: conn} do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - group = Fixtures.group_fixture() - - {:ok, member} = - Membership.create_member( - %{ - first_name: "Frank", - last_name: "Moore", - email: "frank@example.com" - }, - actor: system_actor - ) - - Membership.create_member_group(%{member_id: member.id, group_id: group.id}, - actor: system_actor - ) - - {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - - # Click Remove - should remove immediately without confirmation - view - |> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']") - |> render_click() - - # No confirmation dialog should appear (immediate removal) - # This is verified by the member being removed without any dialog - - # Member should be removed - html = render(view) - refute html =~ "Frank" - end - end - - describe "edge cases" do - test "remove works for last member in group (group becomes empty)", %{conn: conn} do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - group = Fixtures.group_fixture() - - {:ok, member} = - Membership.create_member( - %{ - first_name: "Grace", - last_name: "Taylor", - email: "grace@example.com" - }, - actor: system_actor - ) - - Membership.create_member_group(%{member_id: member.id, group_id: group.id}, - actor: system_actor - ) - - {:ok, view, html} = live(conn, "/groups/#{group.slug}") - - # Member should be in list - assert html =~ "Grace" - - # Remove last member - view - |> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']") - |> render_click() - - # Group should show empty state - html = render(view) - - assert html =~ gettext("No members in this group") || - html =~ ~r/no.*members/i - - # Count should be 0 - count = extract_member_count(html) - assert count == 0 - end - - test "remove works when member is in multiple groups (only this group affected)", %{ - conn: conn - } do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - group1 = Fixtures.group_fixture() - group2 = Fixtures.group_fixture() - - {:ok, member} = - Membership.create_member( - %{ - first_name: "Henry", - last_name: "Anderson", - email: "henry@example.com" - }, - actor: system_actor - ) - - # Add member to both groups - Membership.create_member_group(%{member_id: member.id, group_id: group1.id}, - actor: system_actor - ) - - Membership.create_member_group(%{member_id: member.id, group_id: group2.id}, - actor: system_actor - ) - - {:ok, view, _html} = live(conn, "/groups/#{group1.slug}") - - # Remove from group1 - view - |> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']") - |> render_click() - - # Member should be removed from group1 - html = render(view) - refute html =~ "Henry" - - # Verify member is still in group2 - {:ok, _view2, html2} = live(conn, "/groups/#{group2.slug}") - assert html2 =~ "Henry" - end - - test "remove is idempotent (no error if member already removed)", %{conn: conn} do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - group = Fixtures.group_fixture() - - {:ok, member} = - Membership.create_member( - %{ - first_name: "Isabel", - last_name: "Martinez", - email: "isabel@example.com" - }, - actor: system_actor - ) - - Membership.create_member_group(%{member_id: member.id, group_id: group.id}, - actor: system_actor - ) - - {:ok, view, _html} = live(conn, "/groups/#{group.slug}") - - # Remove member first time - view - |> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']") - |> render_click() - - # Try to remove again (should not error, just be idempotent) - # Note: Implementation should handle this gracefully - # If button is still visible somehow, try to click again - html = render(view) - - if html =~ "Isabel" do - view - |> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']") - |> render_click() - - # Should not crash - assert render(view) - end - end - end - - # Helper function to extract member count from HTML - defp extract_member_count(html) do - case Regex.run(~r/Total:\s*(\d+)/, html) do - [_, count_str] -> String.to_integer(count_str) - _ -> 0 - end - end -end diff --git a/test/mv_web/live/statistics_live_test.exs b/test/mv_web/live/statistics_live_test.exs deleted file mode 100644 index ed6128f..0000000 --- a/test/mv_web/live/statistics_live_test.exs +++ /dev/null @@ -1,78 +0,0 @@ -defmodule MvWeb.StatisticsLiveTest do - @moduledoc """ - Tests for the Statistics LiveView at /statistics. - - Uses explicit auth: conn is authenticated with a role that has access to - the statistics page (read_only by default; override with @tag :role). - """ - use MvWeb.ConnCase, async: true - - import Phoenix.LiveViewTest - - alias Mv.MembershipFees.MembershipFeeType - - describe "statistics page" do - @describetag role: :read_only - test "renders statistics page with title and key labels for authenticated user with access", - %{ - conn: conn - } do - {:ok, _view, html} = live(conn, ~p"/statistics") - - assert html =~ "Statistics" - assert html =~ "Active members" - assert html =~ "Unpaid" - assert html =~ "Contributions by year" - assert html =~ "Member numbers by year" - end - - test "page shows overview of all relevant years without year selector", %{conn: conn} do - {:ok, _view, html} = live(conn, ~p"/statistics") - - # No year dropdown: single select for year should not be present as main control - assert html =~ "Overview" or html =~ "overview" - # table header or legend - assert html =~ "Year" - end - - test "fee_type_id in URL updates selected filter and contributions", %{conn: conn} do - actor = Mv.Helpers.SystemActor.get_system_actor() - - fee_types = - MembershipFeeType - |> Ash.Query.sort(name: :asc) - |> Ash.read!(domain: Mv.MembershipFees, actor: actor) - - fee_type = - case List.first(fee_types) do - nil -> - MembershipFeeType - |> Ash.Changeset.for_create(:create, %{ - name: "Test Fee #{System.unique_integer([:positive])}", - amount: Decimal.new("50.00"), - interval: :yearly - }) - |> Ash.create!(actor: actor) - - ft -> - ft - end - - path = ~p"/statistics" <> "?" <> URI.encode_query(%{"fee_type_id" => fee_type.id}) - {:ok, view, html} = live(conn, path) - - assert view |> element("select#fee-type-filter") |> has_element?() - assert html =~ fee_type.name - assert html =~ "Contributions by year" - end - end - - describe "statistics page with own_data role" do - @describetag role: :member - test "redirects when user has only own_data (no access to statistics page)", %{conn: conn} do - # member role uses own_data permission set; /statistics is not in own_data pages - conn = get(conn, ~p"/statistics") - assert redirected_to(conn) != ~p"/statistics" - end - end -end diff --git a/test/mv_web/plugs/check_page_permission_test.exs b/test/mv_web/plugs/check_page_permission_test.exs index e342744..2e33474 100644 --- a/test/mv_web/plugs/check_page_permission_test.exs +++ b/test/mv_web/plugs/check_page_permission_test.exs @@ -107,37 +107,6 @@ 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") diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index 89b6ab0..745be5a 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -178,7 +178,6 @@ defmodule MvWeb.ConnCase do :read_only -> # Vorstand/Buchhaltung: can read members, groups; cannot edit or access admin/settings read_only_user = Mv.Fixtures.user_with_role_fixture("read_only") - read_only_user = Mv.Authorization.Actor.ensure_loaded(read_only_user) authenticated_conn = conn_with_password_user(conn, read_only_user) {authenticated_conn, read_only_user} diff --git a/test/support/group_live_helpers.ex b/test/support/group_live_helpers.ex deleted file mode 100644 index 50e2f9e..0000000 --- a/test/support/group_live_helpers.ex +++ /dev/null @@ -1,59 +0,0 @@ -defmodule MvWeb.GroupLiveHelpers do - @moduledoc """ - Helpers for Group LiveView tests (e.g. group show add/remove member flow). - - Use these to reduce duplication in tests that open the add member area, - search, select, and add members. - """ - - import Phoenix.LiveViewTest - - @doc """ - Opens the inline add member area by clicking "Add Member". - """ - def open_add_member(view) do - view - |> element("button", "Add Member") - |> render_click() - end - - @doc """ - Triggers member search by focusing the input and sending a form change with the given query. - """ - def search_member(view, query) do - view - |> element("#member-search-input") - |> render_focus() - - view - |> element("form[phx-change='search_members']") - |> render_change(%{"member_search" => query}) - end - - @doc """ - Clicks the option for the given member in the dropdown (by data-member-id). - """ - def select_member(view, member) do - view - |> element("[data-member-id='#{member.id}']") - |> render_click() - end - - @doc """ - Clicks the "Add" button (add_selected_members). - """ - def add_selected(view) do - view - |> element("button[phx-click='add_selected_members']") - |> render_click() - end - - @doc """ - Clicks the "Cancel" button to close the inline add member area. - """ - def cancel_add_member(view) do - view - |> element("button[phx-click='hide_add_member_input']") - |> render_click() - end -end