From ba39632c38f408380aa45e88c38b7c1fcc860016 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Wed, 1 Jul 2026 00:09:58 +0000 Subject: [PATCH 01/30] chore(deps): update mix dependencies --- mix.exs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mix.exs b/mix.exs index 23e8a6fe..02861f93 100644 --- a/mix.exs +++ b/mix.exs @@ -37,7 +37,7 @@ defmodule Mv.MixProject do # Type `mix help deps` for examples and options. defp deps do [ - {:tidewave, "~> 0.5", only: [:dev]}, + {:tidewave, "~> 0.6", only: [:dev]}, {:sourceror, "~> 1.8", only: [:dev, :test]}, {:live_debugger, "~> 1.0", only: [:dev]}, {:ash_admin, "~> 1.0"}, @@ -54,11 +54,11 @@ defmodule Mv.MixProject do {:postgrex, ">= 0.0.0"}, {:phoenix_html, "~> 4.1"}, {:phoenix_live_reload, "~> 1.2", only: :dev}, - {:phoenix_live_view, "~> 1.1.0-rc.3"}, + {:phoenix_live_view, "~> 1.2.0"}, {:lazy_html, ">= 0.0.0", only: :test}, {:phoenix_live_dashboard, "~> 0.8.3"}, {:esbuild, "~> 0.9", runtime: Mix.env() == :dev}, - {:tailwind, "~> 0.4", runtime: Mix.env() == :dev}, + {:tailwind, "~> 0.5", runtime: Mix.env() == :dev}, {:heroicons, github: "tailwindlabs/heroicons", tag: "v2.2.0", @@ -70,7 +70,7 @@ defmodule Mv.MixProject do {:swoosh, "~> 1.16"}, # Required by Swoosh.Adapters.SMTP (and its Helpers use mimemail, which gen_smtp brings in) {:gen_smtp, "~> 1.0"}, - {:req, "~> 0.5"}, + {:req, "~> 0.6"}, {:telemetry_metrics, "~> 1.0"}, {:telemetry_poller, "~> 1.0"}, {:gettext, "~> 1.0"}, From b745b13ca56d6ea84c44f948a447546fc1de720b Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 3 Jul 2026 11:04:43 +0200 Subject: [PATCH 02/30] feat(membership-fees): denormalize cycle_end so fee status filters run in the database Store the cycle's end date alongside its start so the payment-status filter can be a plain date comparison in SQL instead of loading every cycle and classifying it in memory. The value is fixed when the cycle is created (the fee type's interval is immutable and the update action never changes the start date), so the denormalized column can never drift from the computed end and needs no write-time reconciliation. --- lib/membership_fees/cycle_end_backfill.ex | 53 ++++++ lib/membership_fees/membership_fee_cycle.ex | 19 +- .../changes/set_cycle_end.ex | 68 +++++++ lib/mv/membership_fees/cycle_generator.ex | 9 +- ...add_cycle_end_to_membership_fee_cycles.exs | 32 ++++ .../membership_fee_cycles/20260630163240.json | 173 ++++++++++++++++++ test/membership_fees/cycle_end_test.exs | 98 ++++++++++ 7 files changed, 448 insertions(+), 4 deletions(-) create mode 100644 lib/membership_fees/cycle_end_backfill.ex create mode 100644 lib/membership_fees/membership_fee_cycle/changes/set_cycle_end.ex create mode 100644 priv/repo/migrations/20260630163238_add_cycle_end_to_membership_fee_cycles.exs create mode 100644 priv/resource_snapshots/repo/membership_fee_cycles/20260630163240.json create mode 100644 test/membership_fees/cycle_end_test.exs diff --git a/lib/membership_fees/cycle_end_backfill.ex b/lib/membership_fees/cycle_end_backfill.ex new file mode 100644 index 00000000..1d739dad --- /dev/null +++ b/lib/membership_fees/cycle_end_backfill.ex @@ -0,0 +1,53 @@ +defmodule Mv.MembershipFees.CycleEndBackfill do + @moduledoc """ + One-time backfill for the denormalized `cycle_end` column on + `membership_fee_cycles`. + + For every cycle whose `cycle_end` is still `NULL`, sets it to + `CalendarCycles.calculate_cycle_end(cycle_start, interval)` using the cycle's + own fee-type interval. Idempotent: rows that already have a `cycle_end` are + left untouched, so it is safe to re-run. + + Invoked from the `cycle_end` migration. Kept as a module function (rather than + inline migration SQL) so the exact calendar arithmetic is reused from + `CalendarCycles` and can be tested directly. + """ + + import Ecto.Query + + alias Mv.MembershipFees.CalendarCycles + alias Mv.Repo + + @doc """ + Backfills `cycle_end` for all cycles missing it. Returns `{:ok, updated_count}`. + """ + @spec run() :: {:ok, non_neg_integer()} + def run do + rows = + from(c in "membership_fee_cycles", + join: t in "membership_fee_types", + on: c.membership_fee_type_id == t.id, + where: is_nil(c.cycle_end), + select: {c.id, c.cycle_start, t.interval} + ) + |> Repo.all() + + count = + Enum.reduce(rows, 0, fn {id, cycle_start, interval}, acc -> + cycle_end = CalendarCycles.calculate_cycle_end(cycle_start, normalize_interval(interval)) + + from(c in "membership_fee_cycles", where: c.id == ^id) + |> Repo.update_all(set: [cycle_end: cycle_end]) + + acc + 1 + end) + + {:ok, count} + end + + # The raw query returns the enum column as a string. + defp normalize_interval(interval) when is_atom(interval), do: interval + + defp normalize_interval(interval) when is_binary(interval), + do: String.to_existing_atom(interval) +end diff --git a/lib/membership_fees/membership_fee_cycle.ex b/lib/membership_fees/membership_fee_cycle.ex index cd628871..cb881938 100644 --- a/lib/membership_fees/membership_fee_cycle.ex +++ b/lib/membership_fees/membership_fee_cycle.ex @@ -13,7 +13,9 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do - `notes` - Optional notes for this cycle ## Design Decisions - - **No cycle_end field**: Calculated from cycle_start + interval (from fee type) + - **Denormalized cycle_end**: Set at create from cycle_start + interval (fee type). + Because the fee type interval and the cycle_start are immutable after create, + cycle_end never drifts, so it can be filtered/sorted in SQL. - **Amount stored per cycle**: Preserves historical amounts when fee type changes - **Calendar-aligned cycles**: All cycles start on calendar boundaries @@ -46,6 +48,14 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do create :create do primary? true accept [:cycle_start, :amount, :status, :notes, :member_id, :membership_fee_type_id] + + argument :interval, :atom do + allow_nil? true + + description "Optional pre-resolved fee-type interval; when given, SetCycleEnd skips the fee-type lookup" + end + + change Mv.MembershipFees.MembershipFeeCycle.Changes.SetCycleEnd end update :update do @@ -106,6 +116,13 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do description "Start date of the billing cycle" end + attribute :cycle_end, :date do + allow_nil? false + public? true + + description "Denormalized end date of the cycle (cycle_start + interval), set at create" + end + attribute :amount, :decimal do allow_nil? false public? true diff --git a/lib/membership_fees/membership_fee_cycle/changes/set_cycle_end.ex b/lib/membership_fees/membership_fee_cycle/changes/set_cycle_end.ex new file mode 100644 index 00000000..296f952c --- /dev/null +++ b/lib/membership_fees/membership_fee_cycle/changes/set_cycle_end.ex @@ -0,0 +1,68 @@ +defmodule Mv.MembershipFees.MembershipFeeCycle.Changes.SetCycleEnd do + @moduledoc """ + Ash change that denormalizes `cycle_end` on a membership fee cycle at create time. + + `cycle_end` is computed as `CalendarCycles.calculate_cycle_end(cycle_start, interval)`, + where `interval` is read from the cycle's `membership_fee_type`. Because a fee type's + `interval` is immutable and the cycle's `cycle_start` is never updated, the stored + `cycle_end` is fixed at create time and never drifts. + + When the caller already knows the interval (e.g. the bulk cycle generator, which + resolves the fee type once for a whole batch), it may pass it via the `:interval` + create argument; the change then uses that value and issues no fee-type query, + avoiding an N+1 lookup across a batch of cycles. + + Otherwise the fee-type interval is read with `authorize?: false`: it is an internal + lookup of immutable reference data needed to populate a derived column, not a + user-facing read, and cycle creation is itself a systemic operation. Reading it under + the caller's actor is unreliable here (the actor is not always present in the change + context, e.g. when a changeset is built with `for_create/3` and the actor is only + supplied to `Ash.create/2`). + + When `cycle_start` or `membership_fee_type_id` are missing the changeset is returned + unchanged so the normal `allow_nil?` validations surface the error. + """ + use Ash.Resource.Change + + alias Mv.MembershipFees.CalendarCycles + alias Mv.MembershipFees.MembershipFeeType + + @impl true + def change(changeset, _opts, _context) do + with {:ok, cycle_start} <- fetch(changeset, :cycle_start), + {:ok, fee_type_id} <- fetch(changeset, :membership_fee_type_id), + {:ok, interval} <- resolve_interval(changeset, fee_type_id) do + cycle_end = CalendarCycles.calculate_cycle_end(cycle_start, interval) + Ash.Changeset.force_change_attribute(changeset, :cycle_end, cycle_end) + else + _ -> changeset + end + end + + defp fetch(changeset, field) do + case Ash.Changeset.fetch_change(changeset, field) do + {:ok, value} when not is_nil(value) -> + {:ok, value} + + _ -> + case Map.get(changeset.data, field) do + nil -> :error + value -> {:ok, value} + end + end + end + + defp resolve_interval(changeset, fee_type_id) do + case Ash.Changeset.get_argument(changeset, :interval) do + nil -> fetch_interval(fee_type_id) + interval -> {:ok, interval} + end + end + + defp fetch_interval(fee_type_id) do + case Ash.get(MembershipFeeType, fee_type_id, authorize?: false) do + {:ok, %{interval: interval}} -> {:ok, interval} + _ -> :error + end + end +end diff --git a/lib/mv/membership_fees/cycle_generator.ex b/lib/mv/membership_fees/cycle_generator.ex index a6c2d300..8bd76d5e 100644 --- a/lib/mv/membership_fees/cycle_generator.ex +++ b/lib/mv/membership_fees/cycle_generator.ex @@ -305,7 +305,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, interval, opts) else {:ok, [], []} end @@ -400,7 +400,7 @@ defmodule Mv.MembershipFees.CycleGenerator do end end - defp create_cycles(cycle_starts, member_id, fee_type_id, amount, opts) do + defp create_cycles(cycle_starts, member_id, fee_type_id, amount, interval, opts) do actor = get_actor(opts) create_opts = Helpers.ash_actor_opts(actor) @@ -414,7 +414,10 @@ defmodule Mv.MembershipFees.CycleGenerator do member_id: member_id, membership_fee_type_id: fee_type_id, amount: amount, - status: :unpaid + status: :unpaid, + # Pass the already-resolved interval so SetCycleEnd does not re-fetch the + # fee type for every cycle in the batch (avoids an N+1 lookup). + interval: interval } handle_cycle_creation_result( diff --git a/priv/repo/migrations/20260630163238_add_cycle_end_to_membership_fee_cycles.exs b/priv/repo/migrations/20260630163238_add_cycle_end_to_membership_fee_cycles.exs new file mode 100644 index 00000000..5cabc9a5 --- /dev/null +++ b/priv/repo/migrations/20260630163238_add_cycle_end_to_membership_fee_cycles.exs @@ -0,0 +1,32 @@ +defmodule Mv.Repo.Migrations.AddCycleEndToMembershipFeeCycles do + @moduledoc """ + Adds the denormalized `cycle_end` column to `membership_fee_cycles`. + + The column is added nullable, backfilled from `cycle_start + interval` for every + existing row (via `Mv.MembershipFees.CycleEndBackfill`), and only then made + `NOT NULL`. Going forward the column is populated at create time by the + `SetCycleEnd` change. + """ + + use Ecto.Migration + + def up do + alter table(:membership_fee_cycles) do + add :cycle_end, :date + end + + flush() + + {:ok, _count} = Mv.MembershipFees.CycleEndBackfill.run() + + alter table(:membership_fee_cycles) do + modify :cycle_end, :date, null: false + end + end + + def down do + alter table(:membership_fee_cycles) do + remove :cycle_end + end + end +end diff --git a/priv/resource_snapshots/repo/membership_fee_cycles/20260630163240.json b/priv/resource_snapshots/repo/membership_fee_cycles/20260630163240.json new file mode 100644 index 00000000..e2646f6e --- /dev/null +++ b/priv/resource_snapshots/repo/membership_fee_cycles/20260630163240.json @@ -0,0 +1,173 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"uuid_generate_v7()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "cycle_start", + "type": "date" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "cycle_end", + "type": "date" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": 2, + "size": null, + "source": "amount", + "type": "decimal" + }, + { + "allow_nil?": false, + "default": "\"unpaid\"", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "status", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "notes", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "membership_fee_cycles_member_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "members" + }, + "scale": null, + "size": null, + "source": "member_id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "membership_fee_cycles_membership_fee_type_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "membership_fee_types" + }, + "scale": null, + "size": null, + "source": "membership_fee_type_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "create_table_options": null, + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "455B6D49C804301DF13F950318644BF28B491535A94F4ABF7805AA1A53287401", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "membership_fee_cycles_unique_cycle_per_member_index", + "keys": [ + { + "type": "atom", + "value": "member_id" + }, + { + "type": "atom", + "value": "cycle_start" + } + ], + "name": "unique_cycle_per_member", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "membership_fee_cycles" +} \ No newline at end of file diff --git a/test/membership_fees/cycle_end_test.exs b/test/membership_fees/cycle_end_test.exs new file mode 100644 index 00000000..23e6ed59 --- /dev/null +++ b/test/membership_fees/cycle_end_test.exs @@ -0,0 +1,98 @@ +defmodule Mv.MembershipFees.CycleEndTest do + @moduledoc """ + Tests for the denormalized `cycle_end` column on membership fee cycles: + the create-time change that sets it, and the one-time backfill helper. + """ + use Mv.DataCase, async: false + + import Mv.Fixtures, only: [create_fee_type: 2, member_fixture_with_actor: 2] + + alias Mv.MembershipFees.CalendarCycles + alias Mv.MembershipFees.CycleEndBackfill + alias Mv.MembershipFees.MembershipFeeCycle + + setup do + %{actor: Mv.Helpers.SystemActor.get_system_actor()} + end + + defp create_member(fee_type, actor) do + member_fixture_with_actor(%{membership_fee_type_id: fee_type.id}, actor) + end + + describe "create sets cycle_end" do + for interval <- [:monthly, :quarterly, :half_yearly, :yearly] do + test "create sets cycle_end from cycle_start + #{interval} interval", %{actor: actor} do + interval = unquote(interval) + fee_type = create_fee_type(%{interval: interval}, actor) + member = create_member(fee_type, actor) + cycle_start = ~D[2024-02-01] + + cycle = + MembershipFeeCycle + |> Ash.Changeset.for_create(:create, %{ + cycle_start: cycle_start, + amount: Decimal.new("50.00"), + member_id: member.id, + membership_fee_type_id: fee_type.id + }) + |> Ash.create!(actor: actor) + + assert cycle.cycle_end == CalendarCycles.calculate_cycle_end(cycle_start, interval) + end + end + end + + describe "create with interval argument" do + test "uses the passed interval and skips the fee-type lookup", %{actor: actor} do + # Fee type carries :yearly, but the caller passes :monthly explicitly. + # If the interval argument is honored (no per-row fee-type lookup), the + # stored cycle_end reflects the argument, not the fee type's interval. + fee_type = create_fee_type(%{interval: :yearly}, actor) + member = create_member(fee_type, actor) + cycle_start = ~D[2024-02-01] + + cycle = + MembershipFeeCycle + |> Ash.Changeset.for_create(:create, %{ + cycle_start: cycle_start, + amount: Decimal.new("50.00"), + member_id: member.id, + membership_fee_type_id: fee_type.id, + interval: :monthly + }) + |> Ash.create!(actor: actor) + + assert cycle.cycle_end == CalendarCycles.calculate_cycle_end(cycle_start, :monthly) + end + end + + describe "backfill" do + test "fills cycle_end for pre-existing rows matching calculate_cycle_end/2", %{actor: actor} do + fee_type = create_fee_type(%{interval: :quarterly}, actor) + member = create_member(fee_type, actor) + + cycle = + MembershipFeeCycle + |> Ash.Changeset.for_create(:create, %{ + cycle_start: ~D[2024-04-01], + amount: Decimal.new("50.00"), + member_id: member.id, + membership_fee_type_id: fee_type.id + }) + |> Ash.create!(actor: actor) + + # Simulate a pre-existing row that predates the cycle_end column. + Repo.query!("ALTER TABLE membership_fee_cycles ALTER COLUMN cycle_end DROP NOT NULL") + + Repo.query!("UPDATE membership_fee_cycles SET cycle_end = NULL WHERE id = $1", [ + Ecto.UUID.dump!(cycle.id) + ]) + + assert {:ok, count} = CycleEndBackfill.run() + assert count >= 1 + + reloaded = Ash.get!(MembershipFeeCycle, cycle.id, actor: actor) + assert reloaded.cycle_end == CalendarCycles.calculate_cycle_end(~D[2024-04-01], :quarterly) + end + end +end From e64f55c36a68fc0bdead9c67d184d7efdc8e0833 Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 3 Jul 2026 11:12:38 +0200 Subject: [PATCH 03/30] feat(member): back the overview with a keyset-paginated :overview read action --- lib/membership/member.ex | 75 +++-- lib/membership/membership.ex | 1 + .../live/member_live/index/overview_query.ex | 261 ++++++++++++++++++ .../member_live/index/overview_query_test.exs | 135 +++++++++ .../index_cycle_status_filter_test.exs | 78 ++++++ .../index_keyset_property_test.exs | 86 ++++++ 6 files changed, 615 insertions(+), 21 deletions(-) create mode 100644 lib/mv_web/live/member_live/index/overview_query.ex create mode 100644 test/mv_web/live/member_live/index/overview_query_test.exs create mode 100644 test/mv_web/member_live/index_cycle_status_filter_test.exs create mode 100644 test/mv_web/member_live/index_keyset_property_test.exs diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 0f67a00d..f57c32a5 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -266,6 +266,15 @@ defmodule Mv.Membership.Member do accept [:vereinfacht_contact_id] end + # Keyset-paginated read for the member overview. Filters and sort are + # composed onto the query by `MvWeb.MemberLive.Index.OverviewQuery`; this + # action only enables keyset pagination with a unique `id` tie-breaker so + # pages never skip or duplicate rows. + read :overview do + description "Keyset-paginated member overview read" + pagination keyset?: true, default_limit: 50, required?: false + end + # Action to handle fuzzy search on specific fields read :search do argument :query, :string, allow_nil?: true @@ -279,27 +288,7 @@ defmodule Mv.Membership.Member do threshold = Ash.Query.get_argument(query, :similarity_threshold) || @default_similarity_threshold - if is_binary(q) and String.trim(q) != "" do - q2 = String.trim(q) - # Sanitize for LIKE patterns (escape % and _), limit length to 100 chars - q2_sanitized = sanitize_search_query(q2) - pat = "%" <> q2_sanitized <> "%" - - # Build search filters grouped by search type for maintainability - # Priority: FTS > Substring > Custom Fields > Fuzzy Matching - # Note: FTS and fuzzy use q2 (unsanitized), LIKE-based filters use pat (sanitized) - fts_match = build_fts_filter(q2) - substring_match = build_substring_filter(q2_sanitized, pat) - custom_field_match = build_custom_field_filter(pat) - fuzzy_match = build_fuzzy_filter(q2, threshold) - - query - |> Ash.Query.filter( - expr(^fts_match or ^substring_match or ^custom_field_match or ^fuzzy_match) - ) - else - query - end + apply_fuzzy_search_filter(query, q, threshold) end end @@ -738,6 +727,13 @@ defmodule Mv.Membership.Member do end end + aggregates do + # Alphabetically-first group name, used to sort the overview by group DB-side. + # Mirrors the previous in-memory "first group name" sort key; members with no + # groups yield nil (sorted last ascending via NULLS LAST). + min :first_group_name, :groups, :name + end + # Define identities for upsert operations identities do identity :unique_email, [:email] @@ -1212,6 +1208,43 @@ defmodule Mv.Membership.Member do end end + @doc """ + Applies the same fuzzy/full-text search filter as the `:search` action as a + plain filter on the given query, without switching the query's read action. + + Used by the overview query builder so search composes with the `:overview` + keyset-paginated read action instead of replacing it. + """ + @spec apply_overview_search(Ash.Query.t(), String.t() | nil) :: Ash.Query.t() + def apply_overview_search(query, q) do + apply_fuzzy_search_filter(query, q || "", @default_similarity_threshold) + end + + # Shared by the `:search` action and `apply_overview_search/2`. + defp apply_fuzzy_search_filter(query, q, threshold) do + if is_binary(q) and String.trim(q) != "" do + q2 = String.trim(q) + # Sanitize for LIKE patterns (escape % and _), limit length to 100 chars + q2_sanitized = sanitize_search_query(q2) + pat = "%" <> q2_sanitized <> "%" + + # Build search filters grouped by search type for maintainability + # Priority: FTS > Substring > Custom Fields > Fuzzy Matching + # Note: FTS and fuzzy use q2 (unsanitized), LIKE-based filters use pat (sanitized) + fts_match = build_fts_filter(q2) + substring_match = build_substring_filter(q2_sanitized, pat) + custom_field_match = build_custom_field_filter(pat) + fuzzy_match = build_fuzzy_filter(q2, threshold) + + Ash.Query.filter( + query, + expr(^fts_match or ^substring_match or ^custom_field_match or ^fuzzy_match) + ) + else + query + end + end + # ============================================================================ # Search Input Sanitization # ============================================================================ diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex index 2a5b17fb..b7c5974d 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -45,6 +45,7 @@ defmodule Mv.Membership do resource Mv.Membership.Member do define :create_member, action: :create_member define :list_members, action: :read + define :overview_members, action: :overview define :update_member, action: :update_member define :destroy_member, action: :destroy end diff --git a/lib/mv_web/live/member_live/index/overview_query.ex b/lib/mv_web/live/member_live/index/overview_query.ex new file mode 100644 index 00000000..93882d4d --- /dev/null +++ b/lib/mv_web/live/member_live/index/overview_query.ex @@ -0,0 +1,261 @@ +defmodule MvWeb.MemberLive.Index.OverviewQuery do + @moduledoc """ + Builds the Ash query for the member overview from the LiveView's filter/sort + state, so every filter and sort resolves in PostgreSQL via the `:overview` + keyset-paginated read action. + + All previously in-memory passes (cycle status, boolean/date custom fields, + group sort) are expressed here as DB filters/sorts. A unique `id` tie-breaker + is always appended to the sort so keyset pages never skip or duplicate rows. + + `build/1` returns an unread `Ash.Query`; the caller supplies `page:`/`actor:` + options to `Ash.read/2`. + """ + + import Ash.Expr + + alias Mv.Membership.Member + alias MvWeb.MemberLive.Index.DateFilter + + require Ash.Query + + @type opts :: %{optional(atom()) => term()} + + @doc """ + Builds the `:overview` query from the given filter/sort options. + + Recognised keys (all optional): + + * `:search` — full-text search string + * `:group_filters` / `:groups` — `%{group_id => :in | :not_in}` and the valid groups + * `:fee_type_filters` / `:fee_types` — `%{fee_type_id => :in | :not_in}` and valid fee types + * `:date_filters` — built-in join/exit date filter map (see `DateFilter`) + * `:sort_field` / `:sort_order` — sort key and direction + * `:today` — reference date for cycle math (defaults to `Date.utc_today/0`) + """ + @spec build(opts()) :: Ash.Query.t() + def build(opts \\ %{}) do + Member + |> Ash.Query.for_read(:overview) + |> apply_search(opts[:search]) + |> apply_group_filters(opts[:group_filters], opts[:groups]) + |> apply_fee_type_filters(opts[:fee_type_filters], opts[:fee_types]) + |> apply_date_filters(opts[:date_filters]) + |> apply_cycle_status_filter( + opts[:cycle_status_filter], + opts[:show_current_cycle], + today(opts) + ) + |> apply_sort(opts[:sort_field], opts[:sort_order]) + end + + defp today(opts), do: opts[:today] || Date.utc_today() + + # --------------------------------------------------------------------------- + # Search + # --------------------------------------------------------------------------- + + defp apply_search(query, search) when is_binary(search), + do: Member.apply_overview_search(query, search) + + defp apply_search(query, _), do: query + + # --------------------------------------------------------------------------- + # Group filters (AND across selected groups) + # --------------------------------------------------------------------------- + + defp apply_group_filters(query, group_filters, _groups) + when group_filters in [nil, %{}], + do: query + + defp apply_group_filters(query, group_filters, groups) do + valid_ids = valid_id_set(groups) + + Enum.reduce(group_filters, query, fn {group_id_str, value}, q -> + if MapSet.member?(valid_ids, group_id_str) do + apply_one_group_filter(q, group_id_str, value) + else + q + end + end) + end + + defp apply_one_group_filter(query, group_id_str, :in) do + case Ecto.UUID.cast(group_id_str) do + {:ok, uuid} -> Ash.Query.filter(query, expr(exists(member_groups, group_id == ^uuid))) + _ -> query + end + end + + defp apply_one_group_filter(query, group_id_str, :not_in) do + case Ecto.UUID.cast(group_id_str) do + {:ok, uuid} -> Ash.Query.filter(query, expr(not exists(member_groups, group_id == ^uuid))) + _ -> query + end + end + + defp apply_one_group_filter(query, _id, _value), do: query + + # --------------------------------------------------------------------------- + # Fee-type filters (:in OR; :not_in AND) + # --------------------------------------------------------------------------- + + defp apply_fee_type_filters(query, fee_type_filters, _fee_types) + when fee_type_filters in [nil, %{}], + do: query + + defp apply_fee_type_filters(query, fee_type_filters, fee_types) do + valid_ids = valid_id_set(fee_types) + + {in_filters, not_in_filters} = + fee_type_filters + |> Enum.filter(fn {id_str, _} -> MapSet.member?(valid_ids, id_str) end) + |> Enum.split_with(fn {_, value} -> value == :in end) + + in_uuids = cast_uuids(Enum.map(in_filters, fn {id_str, _} -> id_str end)) + + query = + if in_uuids == [] do + query + else + Ash.Query.filter(query, expr(membership_fee_type_id in ^in_uuids)) + end + + Enum.reduce(not_in_filters, query, fn {id_str, _}, q -> + case Ecto.UUID.cast(id_str) do + {:ok, uuid} -> + Ash.Query.filter( + q, + expr(membership_fee_type_id != ^uuid or is_nil(membership_fee_type_id)) + ) + + _ -> + q + end + end) + end + + # --------------------------------------------------------------------------- + # Cycle-status filter (paid/unpaid, current or last-completed cycle) + # + # Backed by the DB cycle-status aggregates (denormalized `cycle_end`). A member + # matches only when its selected-cycle status equals the requested status; + # members with no matching cycle (nil aggregate) are excluded — exactly as the + # previous in-memory classifier behaved. + # --------------------------------------------------------------------------- + + defp apply_cycle_status_filter(query, status, _show_current, _today) + when status not in [:paid, :unpaid], + do: query + + defp apply_cycle_status_filter(query, status, true = _show_current, today) do + # Current cycle: contains today; when several would, the one with the latest + # cycle_start wins. Keep members whose winning current cycle has `status`. + Ash.Query.filter( + query, + expr( + exists( + membership_fee_cycles, + cycle_start <= ^today and cycle_end >= ^today and status == ^status and + not exists( + member.membership_fee_cycles, + cycle_start <= ^today and cycle_end >= ^today and + cycle_start > parent(cycle_start) + ) + ) + ) + ) + end + + defp apply_cycle_status_filter(query, status, _show_current, today) do + # Last completed cycle: most recent cycle that has ended (cycle_end < today). + Ash.Query.filter( + query, + expr( + exists( + membership_fee_cycles, + cycle_end < ^today and status == ^status and + not exists( + member.membership_fee_cycles, + cycle_end < ^today and cycle_start > parent(cycle_start) + ) + ) + ) + ) + end + + # --------------------------------------------------------------------------- + # Built-in date filters (join/exit) + # --------------------------------------------------------------------------- + + defp apply_date_filters(query, nil), do: query + + defp apply_date_filters(query, filters) when is_map(filters), + do: DateFilter.apply_ash_filter(query, filters) + + # --------------------------------------------------------------------------- + # Sort (always append the unique id tie-breaker for keyset stability) + # --------------------------------------------------------------------------- + + defp apply_sort(query, nil, _order), do: Ash.Query.sort(query, id: :asc) + defp apply_sort(query, _field, nil), do: Ash.Query.sort(query, id: :asc) + + defp apply_sort(query, field, order) do + case sort_key(field) do + nil -> Ash.Query.sort(query, id: :asc) + key -> Ash.Query.sort(query, [{key, order}, {:id, :asc}]) + end + end + + # Resolves a sort field (atom or string) to a DB sort key, or nil if it is a + # computed field handled elsewhere / not sortable. + defp sort_key(field) when field in [:membership_fee_type, "membership_fee_type"], + do: "membership_fee_type.name" + + defp sort_key(field) when field in [:groups, "groups"], do: :first_group_name + + defp sort_key(field) when is_atom(field) do + if field in member_sort_fields(), do: field, else: nil + end + + defp sort_key(field) when is_binary(field) do + allowed = MapSet.new(member_sort_fields(), &Atom.to_string/1) + if MapSet.member?(allowed, field), do: String.to_existing_atom(field), else: nil + end + + defp sort_key(_), do: nil + + defp member_sort_fields do + Mv.Constants.member_fields() -- [:notes] + end + + # --------------------------------------------------------------------------- + # Helpers + # --------------------------------------------------------------------------- + + defp valid_id_set(records) when is_list(records) do + records + |> Enum.map(&to_string(&1.id)) + |> Enum.map(&normalize_uuid/1) + |> Enum.reject(&is_nil/1) + |> MapSet.new() + end + + defp valid_id_set(_), do: MapSet.new() + + defp normalize_uuid(raw) when is_binary(raw) do + case Ecto.UUID.cast(String.trim(raw)) do + {:ok, uuid} -> to_string(uuid) + _ -> nil + end + end + + defp normalize_uuid(_), do: nil + + defp cast_uuids(id_strs) do + id_strs + |> Enum.map(&Ecto.UUID.cast/1) + |> Enum.filter(&match?({:ok, _}, &1)) + |> Enum.map(fn {:ok, uuid} -> uuid end) + end +end diff --git a/test/mv_web/live/member_live/index/overview_query_test.exs b/test/mv_web/live/member_live/index/overview_query_test.exs new file mode 100644 index 00000000..c8ec3fdc --- /dev/null +++ b/test/mv_web/live/member_live/index/overview_query_test.exs @@ -0,0 +1,135 @@ +defmodule MvWeb.MemberLive.Index.OverviewQueryTest do + @moduledoc """ + Resource-level tests for the DB-backed overview query builder: keyset + pagination and the standard DB filters/sort pushed down from the LiveView. + """ + use Mv.DataCase, async: false + + import Mv.Fixtures, + only: [member_fixture_with_actor: 2, create_fee_type: 2, group_fixture: 1] + + alias Mv.Membership + alias MvWeb.MemberLive.Index.OverviewQuery + + setup do + %{actor: Mv.Helpers.SystemActor.get_system_actor()} + end + + defp ids(results), do: Enum.map(results, & &1.id) |> MapSet.new() + + defp read_all_pages(query, actor, limit) do + page = Ash.read!(query, page: [limit: limit], actor: actor) + collect_pages(page, [page.results]) + end + + defp collect_pages(%{more?: true} = page, acc) do + next = Ash.page!(page, :next) + collect_pages(next, [next.results | acc]) + end + + defp collect_pages(_page, acc), do: acc |> Enum.reverse() |> List.flatten() + + describe "keyset pagination" do + test "concatenated pages cover the full set once with no duplicates", %{actor: actor} do + members = + for _ <- 1..5, do: member_fixture_with_actor(%{}, actor) + + expected = ids(members) + + query = OverviewQuery.build(%{sort_field: :inserted_at, sort_order: :asc}) + all = read_all_pages(query, actor, 2) + + assert ids(all) == expected + assert length(all) == length(Enum.uniq_by(all, & &1.id)) + end + end + + describe "DB filters" do + test "search matches name DB-side", %{actor: actor} do + hit = member_fixture_with_actor(%{first_name: "Zorblax", last_name: "Quux"}, actor) + _miss = member_fixture_with_actor(%{first_name: "Otto", last_name: "Normal"}, actor) + + results = + OverviewQuery.build(%{search: "Zorblax"}) + |> Ash.read!(actor: actor) + + assert hit.id in Enum.map(results, & &1.id) + refute Enum.any?(results, &(&1.first_name == "Otto")) + end + + test "group :in and :not_in resolve DB-side", %{actor: actor} do + group = group_fixture(%{name: "Board #{System.unique_integer([:positive])}"}) + in_member = member_fixture_with_actor(%{}, actor) + out_member = member_fixture_with_actor(%{}, actor) + + {:ok, _} = + Membership.create_member_group(%{member_id: in_member.id, group_id: group.id}, + actor: actor + ) + + opts = %{group_filters: %{to_string(group.id) => :in}, groups: [group]} + in_ids = OverviewQuery.build(opts) |> Ash.read!(actor: actor) |> Enum.map(& &1.id) + assert in_member.id in in_ids + refute out_member.id in in_ids + + not_opts = %{group_filters: %{to_string(group.id) => :not_in}, groups: [group]} + out_ids = OverviewQuery.build(not_opts) |> Ash.read!(actor: actor) |> Enum.map(& &1.id) + refute in_member.id in out_ids + assert out_member.id in out_ids + end + + test "fee-type :in resolves DB-side", %{actor: actor} do + ft = create_fee_type(%{}, actor) + with_ft = member_fixture_with_actor(%{membership_fee_type_id: ft.id}, actor) + without_ft = member_fixture_with_actor(%{}, actor) + + opts = %{fee_type_filters: %{to_string(ft.id) => :in}, fee_types: [ft]} + result_ids = OverviewQuery.build(opts) |> Ash.read!(actor: actor) |> Enum.map(& &1.id) + + assert with_ft.id in result_ids + refute without_ft.id in result_ids + end + end + + describe "group sort (DB aggregate)" do + test "sorts by first group name DB-side, members with groups before those without", %{ + actor: actor + } do + g_a = group_fixture(%{name: "AAA #{System.unique_integer([:positive])}"}) + g_z = group_fixture(%{name: "ZZZ #{System.unique_integer([:positive])}"}) + + m_a = member_fixture_with_actor(%{}, actor) + m_z = member_fixture_with_actor(%{}, actor) + m_none = member_fixture_with_actor(%{}, actor) + + {:ok, _} = + Membership.create_member_group(%{member_id: m_a.id, group_id: g_a.id}, actor: actor) + + {:ok, _} = + Membership.create_member_group(%{member_id: m_z.id, group_id: g_z.id}, actor: actor) + + asc = + OverviewQuery.build(%{sort_field: :groups, sort_order: :asc}) + |> Ash.read!(actor: actor) + |> Enum.map(& &1.id) + |> Enum.filter(&(&1 in [m_a.id, m_z.id, m_none.id])) + + # group members first (AAA before ZZZ), member without groups last + assert asc == [m_a.id, m_z.id, m_none.id] + end + end + + describe "sort" do + test "sort by last_name is deterministic with id tie-breaker", %{actor: actor} do + a = member_fixture_with_actor(%{last_name: "Same"}, actor) + b = member_fixture_with_actor(%{last_name: "Same"}, actor) + + results = + OverviewQuery.build(%{sort_field: :last_name, sort_order: :asc}) + |> Ash.read!(actor: actor) + + ordered = results |> Enum.filter(&(&1.id in [a.id, b.id])) |> Enum.map(& &1.id) + assert ordered == Enum.sort([a.id, b.id]) + end + end +end diff --git a/test/mv_web/member_live/index_cycle_status_filter_test.exs b/test/mv_web/member_live/index_cycle_status_filter_test.exs new file mode 100644 index 00000000..f6bb4467 --- /dev/null +++ b/test/mv_web/member_live/index_cycle_status_filter_test.exs @@ -0,0 +1,78 @@ +defmodule MvWeb.MemberLive.IndexCycleStatusFilterTest do + @moduledoc """ + §1.13 — the paid/unpaid cycle-status filter, under the current or last-completed + cycle view, returns the same members as the previous in-memory classifier, + computed DB-side via the `cycle_end`-backed aggregates. + """ + use Mv.DataCase, async: false + + import Mv.Fixtures, only: [create_fee_type: 2, member_fixture_with_actor: 2, create_cycle: 4] + + alias Mv.Membership.Member + alias MvWeb.MemberLive.Index.MembershipFeeStatus + alias MvWeb.MemberLive.Index.OverviewQuery + + @today ~D[2024-07-15] + + setup do + actor = Mv.Helpers.SystemActor.get_system_actor() + ft = create_fee_type(%{interval: :yearly}, actor) + + # current 2024 paid, last-completed 2023 unpaid + paid_now = member_fixture_with_actor(%{membership_fee_type_id: ft.id}, actor) + create_cycle(paid_now, ft, %{cycle_start: ~D[2024-01-01], status: :paid}, actor) + create_cycle(paid_now, ft, %{cycle_start: ~D[2023-01-01], status: :unpaid}, actor) + + # current 2024 unpaid, last-completed 2023 paid + unpaid_now = member_fixture_with_actor(%{membership_fee_type_id: ft.id}, actor) + create_cycle(unpaid_now, ft, %{cycle_start: ~D[2024-01-01], status: :unpaid}, actor) + create_cycle(unpaid_now, ft, %{cycle_start: ~D[2023-01-01], status: :paid}, actor) + + # no cycles + _no_cycles = member_fixture_with_actor(%{membership_fee_type_id: ft.id}, actor) + + %{actor: actor, paid_now: paid_now, unpaid_now: unpaid_now} + end + + defp db_ids(status, show_current, actor) do + OverviewQuery.build(%{ + cycle_status_filter: status, + show_current_cycle: show_current, + today: @today + }) + |> Ash.read!(actor: actor) + |> MapSet.new(& &1.id) + end + + defp oracle_ids(status, show_current, actor) do + Member + |> Ash.Query.load(membership_fee_cycles: [:membership_fee_type]) + |> Ash.Query.load(:membership_fee_type) + |> Ash.read!(actor: actor) + |> Enum.filter(fn m -> + MembershipFeeStatus.get_cycle_status_for_member(m, show_current, @today) == status + end) + |> MapSet.new(& &1.id) + end + + # {status, show_current, member-key expected in the result} + @cases [ + {:paid, true, :paid_now}, + {:unpaid, true, :unpaid_now}, + {:paid, false, :unpaid_now}, + {:unpaid, false, :paid_now} + ] + + for {status, show_current, expected_key} <- @cases do + test "#{status} under #{if show_current, do: "current", else: "last"} cycle matches oracle", + %{actor: actor} = ctx do + status = unquote(status) + show_current = unquote(show_current) + expected = Map.fetch!(ctx, unquote(expected_key)) + + db = db_ids(status, show_current, actor) + assert db == oracle_ids(status, show_current, actor) + assert expected.id in db + end + end +end diff --git a/test/mv_web/member_live/index_keyset_property_test.exs b/test/mv_web/member_live/index_keyset_property_test.exs new file mode 100644 index 00000000..aaaa43b8 --- /dev/null +++ b/test/mv_web/member_live/index_keyset_property_test.exs @@ -0,0 +1,86 @@ +defmodule MvWeb.MemberLive.IndexKeysetPropertyTest do + @moduledoc """ + §2.2 — Keyset pagination completeness: concatenating every keyset page yields + exactly the full filtered set once each (no duplicates, no gaps) in a stable + total order. + + §2.3 — Sort determinism with the unique `id` tie-breaker: members equal on the + sort key are ordered by `id`, and the result set equals the unsorted set. + """ + use Mv.DataCase, async: false + use ExUnitProperties + + import Mv.Fixtures, only: [member_fixture_with_actor: 2] + + alias Mv.Membership.Member + alias MvWeb.MemberLive.Index.OverviewQuery + + setup do + %{actor: Mv.Helpers.SystemActor.get_system_actor()} + end + + defp clear_members(actor) do + Member |> Ash.read!(actor: actor) |> Enum.each(&Ash.destroy!(&1, actor: actor)) + end + + defp read_all_pages(query, actor, limit) do + page = Ash.read!(query, page: [limit: limit], actor: actor) + collect(page, page.results) + end + + defp collect(%{more?: true} = page, acc) do + next = Ash.page!(page, :next) + collect(next, acc ++ next.results) + end + + defp collect(_page, acc), do: acc + + property "keyset pages cover the full set once, no dupes or gaps", %{actor: actor} do + check all( + n <- integer(1..12), + limit <- integer(1..5), + sort <- member_of([:last_name, :join_date, :inserted_at]), + order <- member_of([:asc, :desc]), + max_runs: 25 + ) do + clear_members(actor) + # Deliberate ties on last_name so the tie-breaker is exercised. + members = + for i <- 1..n do + member_fixture_with_actor(%{last_name: "Tie#{rem(i, 3)}"}, actor) + end + + expected = MapSet.new(members, & &1.id) + + query = OverviewQuery.build(%{sort_field: sort, sort_order: order}) + paged = read_all_pages(query, actor, limit) + paged_ids = Enum.map(paged, & &1.id) + + assert MapSet.new(paged_ids) == expected + assert length(paged_ids) == MapSet.size(expected) + end + end + + property "sort with ties is a deterministic total order broken by id", %{actor: actor} do + check all( + n <- integer(2..10), + order <- member_of([:asc, :desc]), + max_runs: 25 + ) do + clear_members(actor) + + members = + for _ <- 1..n do + member_fixture_with_actor(%{last_name: "Same"}, actor) + end + + ordered = + OverviewQuery.build(%{sort_field: :last_name, sort_order: order}) + |> Ash.read!(actor: actor) + |> Enum.map(& &1.id) + + # All last_names equal -> order is fully determined by the id tie-breaker (asc). + assert ordered == Enum.sort(Enum.map(members, & &1.id)) + end + end +end From b09cdf7f3ad859c491d8875a1bb211210c895e40 Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 3 Jul 2026 11:19:59 +0200 Subject: [PATCH 04/30] feat(member): resolve custom-field filters and sorting in PostgreSQL Push the boolean and date custom-field predicates and custom-field sorting down to JSONB expressions so the overview stops classifying custom-field values in memory. A GIN index on custom_field_values.value keeps the boolean membership predicates index-served. --- lib/membership/custom_field_value.ex | 17 ++ .../live/member_live/index/date_filter.ex | 48 ++++ .../live/member_live/index/overview_query.ex | 137 +++++++++- ...dd_custom_field_values_value_gin_index.exs | 21 ++ .../custom_field_values/20260630170638.json | 148 +++++++++++ ...index_boolean_custom_field_filter_test.exs | 83 ++++++ .../index_date_custom_field_filter_test.exs | 91 +++++++ .../index_filter_parity_property_test.exs | 245 ++++++++++++++++++ .../index_overview_query_sort_test.exs | 98 +++++++ 9 files changed, 883 insertions(+), 5 deletions(-) create mode 100644 priv/repo/migrations/20260630170637_add_custom_field_values_value_gin_index.exs create mode 100644 priv/resource_snapshots/repo/custom_field_values/20260630170638.json create mode 100644 test/mv_web/member_live/index_boolean_custom_field_filter_test.exs create mode 100644 test/mv_web/member_live/index_date_custom_field_filter_test.exs create mode 100644 test/mv_web/member_live/index_filter_parity_property_test.exs create mode 100644 test/mv_web/member_live/index_overview_query_sort_test.exs diff --git a/lib/membership/custom_field_value.ex b/lib/membership/custom_field_value.ex index 623455d6..91404aad 100644 --- a/lib/membership/custom_field_value.ex +++ b/lib/membership/custom_field_value.ex @@ -48,6 +48,13 @@ defmodule Mv.Membership.CustomFieldValue do table "custom_field_values" repo Mv.Repo + custom_indexes do + # GIN index on the JSONB value to serve boolean custom-field containment + # predicates (`value @> '{"value": }'`) used by the member overview. + # Default jsonb_ops supports the `@>` operator we rely on. + index ["value"], name: "custom_field_values_value_gin_index", using: "gin" + end + references do reference :member, on_delete: :delete reference :custom_field, on_delete: :delete @@ -133,6 +140,16 @@ defmodule Mv.Membership.CustomFieldValue do calculations do calculate :value_to_string, :string, expr(value[:value] <> "") + + # Typed sort keys for DB-side ordering of the member overview by a custom + # field. The stored JSONB value (`{"type": ..., "value": ...}`) is cast per + # the field's value type so integers sort numerically and dates + # chronologically. Empty strings collapse to NULL so they sort last, matching + # the previous in-memory `CustomFieldSort` behaviour. + calculate :sort_text, :string, expr(fragment("nullif(?->>'value','')", value)) + calculate :sort_numeric, :decimal, expr(fragment("nullif(?->>'value','')::numeric", value)) + calculate :sort_date, :date, expr(fragment("nullif(?->>'value','')::date", value)) + calculate :sort_boolean, :boolean, expr(fragment("nullif(?->>'value','')::boolean", value)) end # Ensure a member can only have one custom field value per custom field diff --git a/lib/mv_web/live/member_live/index/date_filter.ex b/lib/mv_web/live/member_live/index/date_filter.ex index 14d1aef0..b24c2f49 100644 --- a/lib/mv_web/live/member_live/index/date_filter.ex +++ b/lib/mv_web/live/member_live/index/date_filter.ex @@ -271,6 +271,54 @@ defmodule MvWeb.MemberLive.Index.DateFilter do end end + @doc """ + DB-side equivalent of the custom-date portion of `apply_in_memory/3`. + + For each active custom-date filter, adds a predicate keeping members that have + a stored date value for the field within the inclusive `[from, to]` bounds. + The value is read from the JSONB union (`value->>'type' = 'date'`, then + `(value->>'value')::date`). Members without a date value row for the field are + excluded, mirroring the in-memory behaviour exactly. + """ + @spec apply_custom_date_ash_filter(Ash.Query.t(), map(), [map()]) :: Ash.Query.t() + def apply_custom_date_ash_filter(%Ash.Query{} = query, filters, date_custom_fields) + when is_map(filters) and is_list(date_custom_fields) do + filters + |> active_custom_date_filters(date_custom_fields) + |> Enum.reduce(query, fn {id, bounds}, q -> apply_one_custom_date_filter(q, id, bounds) end) + end + + defp apply_one_custom_date_filter(query, id, %{from: from, to: to}) do + case Ecto.UUID.cast(id) do + {:ok, uuid} -> filter_custom_date(query, uuid, from, to) + _ -> query + end + end + + defp filter_custom_date(query, uuid, from, to) do + Ash.Query.filter( + query, + expr( + exists( + custom_field_values, + custom_field_id == ^uuid and + fragment("?->>'type' = 'date'", value) and + ^custom_date_bounds(from, to) + ) + ) + ) + end + + defp custom_date_bounds(nil, to), do: expr(fragment("(?->>'value')::date <= ?", value, ^to)) + defp custom_date_bounds(from, nil), do: expr(fragment("(?->>'value')::date >= ?", value, ^from)) + + defp custom_date_bounds(from, to) do + expr( + fragment("(?->>'value')::date >= ?", value, ^from) and + fragment("(?->>'value')::date <= ?", value, ^to) + ) + end + @doc """ Returns the UUID string keys of `filters` that name an active (at-least-one- bound-set) custom date field. The UUID must appear in `date_custom_fields` diff --git a/lib/mv_web/live/member_live/index/overview_query.ex b/lib/mv_web/live/member_live/index/overview_query.ex index 93882d4d..bf4ef85c 100644 --- a/lib/mv_web/live/member_live/index/overview_query.ex +++ b/lib/mv_web/live/member_live/index/overview_query.ex @@ -18,6 +18,7 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do alias MvWeb.MemberLive.Index.DateFilter require Ash.Query + require Ash.Sort @type opts :: %{optional(atom()) => term()} @@ -40,13 +41,18 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do |> apply_search(opts[:search]) |> apply_group_filters(opts[:group_filters], opts[:groups]) |> apply_fee_type_filters(opts[:fee_type_filters], opts[:fee_types]) + |> apply_boolean_custom_field_filters( + opts[:boolean_custom_field_filters], + opts[:boolean_custom_fields] + ) |> apply_date_filters(opts[:date_filters]) + |> apply_custom_date_filters(opts[:date_filters], opts[:date_custom_fields]) |> apply_cycle_status_filter( opts[:cycle_status_filter], opts[:show_current_cycle], today(opts) ) - |> apply_sort(opts[:sort_field], opts[:sort_order]) + |> apply_sort(opts[:sort_field], opts[:sort_order], opts[:custom_fields] || []) end defp today(opts), do: opts[:today] || Date.utc_today() @@ -135,6 +141,48 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do end) end + # --------------------------------------------------------------------------- + # Boolean custom-field filters (AND across selected fields) + # + # Mirrors the previous in-memory behaviour: a member matches a `{field => bool}` + # filter only if it has a stored value row for that field whose boolean value + # equals `bool`. A member with no value row is excluded. Uses a JSONB + # containment predicate (`value @> '{"value": }'`), which the GIN index on + # `custom_field_values.value` can serve. + # --------------------------------------------------------------------------- + + defp apply_boolean_custom_field_filters(query, filters, _boolean_fields) + when filters in [nil, %{}], + do: query + + defp apply_boolean_custom_field_filters(query, filters, boolean_fields) do + valid_ids = valid_id_set(boolean_fields) + + filters + |> Enum.filter(fn {id_str, _} -> MapSet.member?(valid_ids, id_str) end) + |> Enum.reduce(query, fn {id_str, bool}, q -> + case Ecto.UUID.cast(id_str) do + {:ok, uuid} -> apply_one_boolean_filter(q, uuid, bool) + _ -> q + end + end) + end + + defp apply_one_boolean_filter(query, uuid, bool) when is_boolean(bool) do + Ash.Query.filter( + query, + expr( + exists( + custom_field_values, + custom_field_id == ^uuid and + fragment("? @> jsonb_build_object('value', ?::boolean)", value, ^bool) + ) + ) + ) + end + + defp apply_one_boolean_filter(query, _uuid, _bool), do: query + # --------------------------------------------------------------------------- # Cycle-status filter (paid/unpaid, current or last-completed cycle) # @@ -193,20 +241,99 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do defp apply_date_filters(query, filters) when is_map(filters), do: DateFilter.apply_ash_filter(query, filters) + defp apply_custom_date_filters(query, filters, custom_fields) + when is_map(filters) and is_list(custom_fields), + do: DateFilter.apply_custom_date_ash_filter(query, filters, custom_fields) + + defp apply_custom_date_filters(query, _filters, _custom_fields), do: query + # --------------------------------------------------------------------------- # Sort (always append the unique id tie-breaker for keyset stability) # --------------------------------------------------------------------------- - defp apply_sort(query, nil, _order), do: Ash.Query.sort(query, id: :asc) - defp apply_sort(query, _field, nil), do: Ash.Query.sort(query, id: :asc) + defp apply_sort(query, nil, _order, _custom_fields), do: Ash.Query.sort(query, id: :asc) + defp apply_sort(query, _field, nil, _custom_fields), do: Ash.Query.sort(query, id: :asc) - defp apply_sort(query, field, order) do + defp apply_sort(query, field, order, custom_fields) do case sort_key(field) do - nil -> Ash.Query.sort(query, id: :asc) + nil -> apply_custom_field_sort(query, field, order, custom_fields) key -> Ash.Query.sort(query, [{key, order}, {:id, :asc}]) end end + # Sorts by a custom field's JSONB value DB-side. The value is read from the + # first matching `custom_field_values` row for the field, cast per the field's + # value type so integers sort numerically and dates chronologically. Empty + # strings and members without a value row sort last in both directions + # (NULLS LAST), mirroring the previous in-memory `CustomFieldSort` behaviour. + defp apply_custom_field_sort(query, field, order, custom_fields) do + with id_str when is_binary(id_str) <- custom_field_id(field), + %{} = cf <- Enum.find(custom_fields, &(to_string(&1.id) == id_str)) do + nils_order = if order == :desc, do: :desc_nils_last, else: :asc_nils_last + + Ash.Query.sort(query, [ + {custom_field_sort_calc(cf.value_type, cf.id), nils_order}, + {:id, :asc} + ]) + else + _ -> Ash.Query.sort(query, id: :asc) + end + end + + defp custom_field_id(field) when is_atom(field), do: custom_field_id(Atom.to_string(field)) + + defp custom_field_id(field) when is_binary(field) do + case String.split(field, Mv.Constants.custom_field_prefix()) do + ["", id_str] -> id_str + _ -> nil + end + end + + defp custom_field_id(_), do: nil + + # Maps the field's value type to the first matching custom-field value's typed + # sort key (scoped to the field), backed by the `sort_*` calculations on + # `CustomFieldValue` (a raw fragment is not allowed as an aggregate `field:`). + defp custom_field_sort_calc(:integer, id), + do: + Ash.Sort.expr_sort( + first(custom_field_values, + field: :sort_numeric, + query: [filter: expr(custom_field_id == ^id)] + ), + :decimal + ) + + defp custom_field_sort_calc(:date, id), + do: + Ash.Sort.expr_sort( + first(custom_field_values, + field: :sort_date, + query: [filter: expr(custom_field_id == ^id)] + ), + :date + ) + + defp custom_field_sort_calc(:boolean, id), + do: + Ash.Sort.expr_sort( + first(custom_field_values, + field: :sort_boolean, + query: [filter: expr(custom_field_id == ^id)] + ), + :boolean + ) + + defp custom_field_sort_calc(_type, id), + do: + Ash.Sort.expr_sort( + first(custom_field_values, + field: :sort_text, + query: [filter: expr(custom_field_id == ^id)] + ), + :string + ) + # Resolves a sort field (atom or string) to a DB sort key, or nil if it is a # computed field handled elsewhere / not sortable. defp sort_key(field) when field in [:membership_fee_type, "membership_fee_type"], diff --git a/priv/repo/migrations/20260630170637_add_custom_field_values_value_gin_index.exs b/priv/repo/migrations/20260630170637_add_custom_field_values_value_gin_index.exs new file mode 100644 index 00000000..00ba06c5 --- /dev/null +++ b/priv/repo/migrations/20260630170637_add_custom_field_values_value_gin_index.exs @@ -0,0 +1,21 @@ +defmodule Mv.Repo.Migrations.AddCustomFieldValuesValueGinIndex do + @moduledoc """ + Adds a GIN index on `custom_field_values.value` (JSONB) to serve the boolean + custom-field containment predicates used by the member overview. + """ + + use Ecto.Migration + + def up do + create index(:custom_field_values, ["value"], + name: "custom_field_values_value_gin_index", + using: "gin" + ) + end + + def down do + drop_if_exists index(:custom_field_values, ["value"], + name: "custom_field_values_value_gin_index" + ) + end +end diff --git a/priv/resource_snapshots/repo/custom_field_values/20260630170638.json b/priv/resource_snapshots/repo/custom_field_values/20260630170638.json new file mode 100644 index 00000000..654d4231 --- /dev/null +++ b/priv/resource_snapshots/repo/custom_field_values/20260630170638.json @@ -0,0 +1,148 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "value", + "type": "map" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "custom_field_values_member_id_fkey", + "on_delete": "delete", + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "members" + }, + "scale": null, + "size": null, + "source": "member_id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "custom_field_values_custom_field_id_fkey", + "on_delete": "delete", + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "custom_fields" + }, + "scale": null, + "size": null, + "source": "custom_field_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "create_table_options": null, + "custom_indexes": [ + { + "all_tenants?": false, + "concurrently": false, + "error_fields": [ + "value" + ], + "fields": [ + { + "type": "string", + "value": "value" + } + ], + "include": null, + "message": null, + "name": "custom_field_values_value_gin_index", + "nulls_distinct": true, + "prefix": null, + "table": null, + "unique": false, + "using": "gin", + "where": null + } + ], + "custom_statements": [], + "has_create_action": true, + "hash": "6B19604F0AA26F503094E6AF5086FDAE0D054096B24099376EFF4BBCFF7E3CB5", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "custom_field_values_unique_custom_field_per_member_index", + "keys": [ + { + "type": "atom", + "value": "member_id" + }, + { + "type": "atom", + "value": "custom_field_id" + } + ], + "name": "unique_custom_field_per_member", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "custom_field_values" +} \ No newline at end of file diff --git a/test/mv_web/member_live/index_boolean_custom_field_filter_test.exs b/test/mv_web/member_live/index_boolean_custom_field_filter_test.exs new file mode 100644 index 00000000..46eaf64b --- /dev/null +++ b/test/mv_web/member_live/index_boolean_custom_field_filter_test.exs @@ -0,0 +1,83 @@ +defmodule MvWeb.MemberLive.IndexBooleanCustomFieldFilterTest do + @moduledoc """ + §1.15 — DB-backed boolean custom-field filter (in/not_in) must match the + previous in-memory behaviour exactly, including a member with no stored value + row for the field. + """ + use Mv.DataCase, async: false + + import Mv.Fixtures, only: [member_fixture_with_actor: 2] + + alias Mv.Membership.CustomField + alias Mv.Membership.CustomFieldValue + alias Mv.Membership.Member + alias MvWeb.MemberLive.Index + alias MvWeb.MemberLive.Index.OverviewQuery + + setup do + actor = Mv.Helpers.SystemActor.get_system_actor() + + {:ok, field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "newsletter_#{System.unique_integer([:positive])}", + value_type: :boolean, + show_in_overview: true + }) + |> Ash.create(actor: actor) + + yes = member_fixture_with_actor(%{}, actor) + no = member_fixture_with_actor(%{}, actor) + none = member_fixture_with_actor(%{}, actor) + + set_bool(yes, field, true, actor) + set_bool(no, field, false, actor) + + %{actor: actor, field: field, yes: yes, no: no, none: none} + end + + defp set_bool(member, field, bool, actor) do + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: field.id, + value: %{"_union_type" => "boolean", "_union_value" => bool} + }) + |> Ash.create!(actor: actor) + end + + defp db_ids(filters, field, actor) do + OverviewQuery.build(%{ + boolean_custom_field_filters: filters, + boolean_custom_fields: [field] + }) + |> Ash.read!(actor: actor) + |> MapSet.new(& &1.id) + end + + defp oracle_ids(filters, field, actor) do + members = + Member + |> Ash.Query.load(custom_field_values: [:custom_field]) + |> Ash.read!(actor: actor) + + Index.apply_boolean_custom_field_filters(members, filters, [field]) + |> MapSet.new(& &1.id) + end + + test "filter true matches only members with stored true value", ctx do + filters = %{to_string(ctx.field.id) => true} + assert db_ids(filters, ctx.field, ctx.actor) == oracle_ids(filters, ctx.field, ctx.actor) + assert ctx.yes.id in db_ids(filters, ctx.field, ctx.actor) + refute ctx.no.id in db_ids(filters, ctx.field, ctx.actor) + refute ctx.none.id in db_ids(filters, ctx.field, ctx.actor) + end + + test "filter false matches only members with stored false value (not missing rows)", ctx do + filters = %{to_string(ctx.field.id) => false} + assert db_ids(filters, ctx.field, ctx.actor) == oracle_ids(filters, ctx.field, ctx.actor) + assert ctx.no.id in db_ids(filters, ctx.field, ctx.actor) + refute ctx.yes.id in db_ids(filters, ctx.field, ctx.actor) + refute ctx.none.id in db_ids(filters, ctx.field, ctx.actor) + end +end diff --git a/test/mv_web/member_live/index_date_custom_field_filter_test.exs b/test/mv_web/member_live/index_date_custom_field_filter_test.exs new file mode 100644 index 00000000..3e8ad7df --- /dev/null +++ b/test/mv_web/member_live/index_date_custom_field_filter_test.exs @@ -0,0 +1,91 @@ +defmodule MvWeb.MemberLive.IndexDateCustomFieldFilterTest do + @moduledoc """ + §1.16 — DB-backed date custom-field range filter must match the previous + in-memory behaviour exactly, including inclusive boundaries and members with + no stored value row (excluded). + """ + use Mv.DataCase, async: false + + import Mv.Fixtures, only: [member_fixture_with_actor: 2] + + alias Mv.Membership.CustomField + alias Mv.Membership.CustomFieldValue + alias Mv.Membership.Member + alias MvWeb.MemberLive.Index.DateFilter + alias MvWeb.MemberLive.Index.OverviewQuery + + setup do + actor = Mv.Helpers.SystemActor.get_system_actor() + + {:ok, field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "birthday_#{System.unique_integer([:positive])}", + value_type: :date, + show_in_overview: true + }) + |> Ash.create(actor: actor) + + on_lower = member_with_date(field, ~D[2024-01-01], actor) + inside = member_with_date(field, ~D[2024-06-15], actor) + on_upper = member_with_date(field, ~D[2024-12-31], actor) + below = member_with_date(field, ~D[2023-12-31], actor) + above = member_with_date(field, ~D[2025-01-01], actor) + none = member_fixture_with_actor(%{}, actor) + + %{ + actor: actor, + field: field, + on_lower: on_lower, + inside: inside, + on_upper: on_upper, + below: below, + above: above, + none: none + } + end + + defp member_with_date(field, date, actor) do + member = member_fixture_with_actor(%{}, actor) + + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: field.id, + value: %{"_union_type" => "date", "_union_value" => date} + }) + |> Ash.create!(actor: actor) + + member + end + + defp filters(field), do: %{to_string(field.id) => %{from: ~D[2024-01-01], to: ~D[2024-12-31]}} + + defp db_ids(field, actor) do + OverviewQuery.build(%{date_filters: filters(field), date_custom_fields: [field]}) + |> Ash.read!(actor: actor) + |> MapSet.new(& &1.id) + end + + defp oracle_ids(field, actor) do + members = + Member + |> Ash.Query.load(custom_field_values: [:custom_field]) + |> Ash.read!(actor: actor) + + DateFilter.apply_in_memory(members, filters(field), [field]) + |> MapSet.new(& &1.id) + end + + test "inclusive range matches in-memory oracle, boundaries included", ctx do + db = db_ids(ctx.field, ctx.actor) + assert db == oracle_ids(ctx.field, ctx.actor) + + assert ctx.on_lower.id in db + assert ctx.inside.id in db + assert ctx.on_upper.id in db + refute ctx.below.id in db + refute ctx.above.id in db + refute ctx.none.id in db + end +end diff --git a/test/mv_web/member_live/index_filter_parity_property_test.exs b/test/mv_web/member_live/index_filter_parity_property_test.exs new file mode 100644 index 00000000..8cd17ce8 --- /dev/null +++ b/test/mv_web/member_live/index_filter_parity_property_test.exs @@ -0,0 +1,245 @@ +defmodule MvWeb.MemberLive.IndexFilterParityPropertyTest do + @moduledoc """ + §2.1 — Filter result-set parity. + + For generated member populations × filter-parameter combinations, the set of + member ids returned by the DB-backed `:overview` query equals the set returned + by the previous in-memory implementation (the reference oracle). + + The unchanged DB filters (group, fee-type) are applied identically in both + arms; the property isolates the filters that moved from memory to the DB + (boolean custom fields, date custom fields, paid/unpaid cycle status) by + applying them DB-side in one arm and in-memory in the other. + """ + use Mv.DataCase, async: false + use ExUnitProperties + + import Mv.Fixtures, only: [create_fee_type: 2, member_fixture_with_actor: 2, create_cycle: 4] + + alias Mv.Membership.CustomField + alias Mv.Membership.CustomFieldValue + alias Mv.Membership.Member + alias MvWeb.MemberLive.Index + alias MvWeb.MemberLive.Index.DateFilter + alias MvWeb.MemberLive.Index.MembershipFeeStatus + alias MvWeb.MemberLive.Index.OverviewQuery + + @today ~D[2024-07-15] + @cycle_starts [~D[2023-01-01], ~D[2024-01-01], ~D[2025-01-01]] + @date_values [~D[2023-06-01], ~D[2024-06-15], ~D[2025-06-01]] + + setup do + actor = Mv.Helpers.SystemActor.get_system_actor() + ft1 = create_fee_type(%{interval: :yearly}, actor) + ft2 = create_fee_type(%{interval: :yearly}, actor) + + {:ok, bool_cf} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "flag_#{System.unique_integer([:positive])}", + value_type: :boolean, + show_in_overview: true + }) + |> Ash.create(actor: actor) + + {:ok, date_cf} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "dt_#{System.unique_integer([:positive])}", + value_type: :date, + show_in_overview: true + }) + |> Ash.create(actor: actor) + + %{actor: actor, ft1: ft1, ft2: ft2, bool_cf: bool_cf, date_cf: date_cf} + end + + # ----- generators --------------------------------------------------------- + + defp status_gen, do: StreamData.member_of([:unpaid, :paid, :suspended]) + + defp member_spec_gen do + StreamData.fixed_map(%{ + fee_type: StreamData.member_of([:ft1, :ft2]), + cycles: + StreamData.list_of( + StreamData.tuple({StreamData.member_of(@cycle_starts), status_gen()}), + max_length: 3 + ), + bool: StreamData.member_of([:none, true, false]), + date: StreamData.member_of([:none | @date_values]), + in_group: StreamData.boolean() + }) + end + + defp filter_params_gen do + StreamData.fixed_map(%{ + cycle_status: StreamData.member_of([nil, :paid, :unpaid]), + show_current: StreamData.boolean(), + bool: StreamData.member_of([nil, true, false]), + date_range: StreamData.member_of([nil, {~D[2024-01-01], ~D[2024-12-31]}]), + group: StreamData.member_of([nil, :in, :not_in]), + fee_type: StreamData.member_of([nil, :in, :not_in]) + }) + end + + # ----- helpers ------------------------------------------------------------ + + defp clear_members(actor) do + Member |> Ash.read!(actor: actor) |> Enum.each(&Ash.destroy!(&1, actor: actor)) + end + + defp create_population(specs, ctx) do + Enum.map(specs, fn spec -> + ft = if spec.fee_type == :ft1, do: ctx.ft1, else: ctx.ft2 + member = member_fixture_with_actor(%{membership_fee_type_id: ft.id}, ctx.actor) + + spec.cycles + |> Enum.uniq_by(fn {start, _} -> start end) + |> Enum.each(fn {start, status} -> + create_cycle(member, ft, %{cycle_start: start, status: status}, ctx.actor) + end) + + maybe_set_value(member, ctx.bool_cf, spec.bool, "boolean", ctx.actor) + maybe_set_value(member, ctx.date_cf, spec.date, "date", ctx.actor) + + if spec.in_group, do: add_to_group(member, ctx, ctx.actor) + {member.id, spec} + end) + end + + defp maybe_set_value(_member, _cf, :none, _type, _actor), do: :ok + + defp maybe_set_value(member, cf, value, type, actor) do + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: cf.id, + value: %{"_union_type" => type, "_union_value" => value} + }) + |> Ash.create!(actor: actor) + end + + defp add_to_group(member, ctx, actor) do + Mv.Membership.create_member_group(%{member_id: member.id, group_id: ctx.group.id}, + actor: actor + ) + end + + defp base_opts(params, ctx) do + %{} + |> put_group(params.group, ctx) + |> put_fee_type(params.fee_type, ctx) + end + + defp put_group(opts, nil, _ctx), do: opts + + defp put_group(opts, dir, ctx), + do: Map.merge(opts, %{group_filters: %{to_string(ctx.group.id) => dir}, groups: [ctx.group]}) + + defp put_fee_type(opts, nil, _ctx), do: opts + + defp put_fee_type(opts, dir, ctx), + do: + Map.merge(opts, %{fee_type_filters: %{to_string(ctx.ft1.id) => dir}, fee_types: [ctx.ft1]}) + + defp moved_opts(params, ctx) do + %{today: @today} + |> put_cycle(params.cycle_status, params.show_current) + |> put_bool(params.bool, ctx) + |> put_date(params.date_range, ctx) + end + + defp put_cycle(opts, nil, _show), do: opts + + defp put_cycle(opts, status, show), + do: Map.merge(opts, %{cycle_status_filter: status, show_current_cycle: show}) + + defp put_bool(opts, nil, _ctx), do: opts + + defp put_bool(opts, bool, ctx), + do: + Map.merge(opts, %{ + boolean_custom_field_filters: %{to_string(ctx.bool_cf.id) => bool}, + boolean_custom_fields: [ctx.bool_cf] + }) + + defp put_date(opts, nil, _ctx), do: opts + + defp put_date(opts, {from, to}, ctx), + do: + Map.merge(opts, %{ + date_filters: %{to_string(ctx.date_cf.id) => %{from: from, to: to}}, + date_custom_fields: [ctx.date_cf] + }) + + defp db_ids(params, ctx) do + base_opts(params, ctx) + |> Map.merge(moved_opts(params, ctx)) + |> OverviewQuery.build() + |> Ash.read!(actor: ctx.actor) + |> MapSet.new(& &1.id) + end + + defp oracle_ids(params, ctx) do + base_members = + base_opts(params, ctx) + |> OverviewQuery.build() + |> Ash.Query.load([ + :membership_fee_type, + {:custom_field_values, [:custom_field]}, + {:membership_fee_cycles, [:membership_fee_type]} + ]) + |> Ash.read!(actor: ctx.actor) + + base_members + |> apply_oracle_bool(params, ctx) + |> apply_oracle_date(params, ctx) + |> apply_oracle_cycle(params) + |> MapSet.new(& &1.id) + end + + defp apply_oracle_bool(members, %{bool: nil}, _ctx), do: members + + defp apply_oracle_bool(members, %{bool: bool}, ctx) do + Index.apply_boolean_custom_field_filters( + members, + %{to_string(ctx.bool_cf.id) => bool}, + [ctx.bool_cf] + ) + end + + defp apply_oracle_date(members, %{date_range: nil}, _ctx), do: members + + defp apply_oracle_date(members, %{date_range: {from, to}}, ctx) do + DateFilter.apply_in_memory( + members, + %{to_string(ctx.date_cf.id) => %{from: from, to: to}}, + [ctx.date_cf] + ) + end + + defp apply_oracle_cycle(members, %{cycle_status: nil}), do: members + + defp apply_oracle_cycle(members, %{cycle_status: status, show_current: show}) do + Enum.filter(members, fn m -> + MembershipFeeStatus.get_cycle_status_for_member(m, show, @today) == status + end) + end + + property "DB-backed filter result set equals the in-memory oracle", ctx do + check all( + specs <- StreamData.list_of(member_spec_gen(), min_length: 1, max_length: 5), + params <- filter_params_gen(), + max_runs: 30 + ) do + clear_members(ctx.actor) + group = Mv.Fixtures.group_fixture(%{name: "G#{System.unique_integer([:positive])}"}) + ctx = Map.put(ctx, :group, group) + + _population = create_population(specs, ctx) + + assert db_ids(params, ctx) == oracle_ids(params, ctx) + end + end +end diff --git a/test/mv_web/member_live/index_overview_query_sort_test.exs b/test/mv_web/member_live/index_overview_query_sort_test.exs new file mode 100644 index 00000000..ddf0320b --- /dev/null +++ b/test/mv_web/member_live/index_overview_query_sort_test.exs @@ -0,0 +1,98 @@ +defmodule MvWeb.MemberLive.IndexOverviewQuerySortTest do + @moduledoc """ + §2.3 — Sort determinism for custom-field sorts pushed to the DB. + + The keyset property test covers standard fields; these examples pin the + type-aware custom-field ordering (numeric vs lexical, chronological dates) and + the NULLS-LAST behaviour for missing/empty values in both directions, which + the previous in-memory `CustomFieldSort` guaranteed. + """ + use Mv.DataCase, async: false + + import Mv.Fixtures, only: [member_fixture_with_actor: 2] + + alias Mv.Membership.CustomField + alias Mv.Membership.CustomFieldValue + alias MvWeb.MemberLive.Index.OverviewQuery + + setup do + %{actor: Mv.Helpers.SystemActor.get_system_actor()} + end + + defp clear_members(actor) do + Mv.Membership.Member |> Ash.read!(actor: actor) |> Enum.each(&Ash.destroy!(&1, actor: actor)) + end + + defp create_field(value_type, actor) do + {:ok, field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "sort_#{value_type}_#{System.unique_integer([:positive])}", + value_type: value_type, + show_in_overview: true + }) + |> Ash.create(actor: actor) + + field + end + + defp set_value(member, field, union_type, union_value, actor) do + {:ok, _} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: field.id, + value: %{"_union_type" => union_type, "_union_value" => union_value} + }) + |> Ash.create(actor: actor) + end + + defp sorted_ids(field, order, actor) do + OverviewQuery.build(%{ + sort_field: "custom_field_#{field.id}", + sort_order: order, + custom_fields: [field] + }) + |> Ash.read!(actor: actor, page: [limit: 100]) + |> then(& &1.results) + |> Enum.map(& &1.id) + end + + test "integer custom field sorts numerically, missing value last in both directions", %{ + actor: actor + } do + clear_members(actor) + field = create_field(:integer, actor) + a = member_fixture_with_actor(%{first_name: "A"}, actor) + b = member_fixture_with_actor(%{first_name: "B"}, actor) + none = member_fixture_with_actor(%{first_name: "N"}, actor) + # 9 vs 10: lexical order would place "10" before "9"; numeric must not. + set_value(a, field, "integer", 10, actor) + set_value(b, field, "integer", 9, actor) + + assert sorted_ids(field, :asc, actor) == [b.id, a.id, none.id] + assert sorted_ids(field, :desc, actor) == [a.id, b.id, none.id] + end + + test "date custom field sorts chronologically", %{actor: actor} do + clear_members(actor) + field = create_field(:date, actor) + older = member_fixture_with_actor(%{first_name: "Older"}, actor) + newer = member_fixture_with_actor(%{first_name: "Newer"}, actor) + set_value(older, field, "date", "1981-01-29", actor) + set_value(newer, field, "date", "1986-07-02", actor) + + assert sorted_ids(field, :asc, actor) == [older.id, newer.id] + end + + test "empty string value sorts last like a missing value", %{actor: actor} do + clear_members(actor) + field = create_field(:string, actor) + filled = member_fixture_with_actor(%{first_name: "Filled"}, actor) + empty = member_fixture_with_actor(%{first_name: "Empty"}, actor) + set_value(filled, field, "string", "AAA", actor) + set_value(empty, field, "string", "", actor) + + assert sorted_ids(field, :asc, actor) == [filled.id, empty.id] + end +end From b79d7ac9eac468fd2ee12fd1c5f09c2c3d89b8a4 Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 3 Jul 2026 11:31:44 +0200 Subject: [PATCH 05/30] feat(member): stream the overview with keyset infinite scroll instead of loading every member --- assets/js/app.js | 63 ++ .../components/bulk_actions_dropdown.ex | 32 +- lib/mv_web/components/core_components.ex | 38 +- lib/mv_web/live/member_live/index.ex | 649 ++++++++---------- lib/mv_web/live/member_live/index.html.heex | 55 +- priv/gettext/de/LC_MESSAGES/default.po | 104 ++- priv/gettext/default.pot | 89 ++- priv/gettext/en/LC_MESSAGES/default.po | 104 ++- .../member_live/index_bulk_scope_test.exs | 71 ++ .../member_live/index_filter_panel_test.exs | 79 +++ .../member_live/index_live_region_test.exs | 68 ++ .../member_live/index_pagination_test.exs | 96 +++ test/mv_web/member_live/index_test.exs | 70 +- 13 files changed, 1067 insertions(+), 451 deletions(-) create mode 100644 test/mv_web/member_live/index_bulk_scope_test.exs create mode 100644 test/mv_web/member_live/index_filter_panel_test.exs create mode 100644 test/mv_web/member_live/index_live_region_test.exs create mode 100644 test/mv_web/member_live/index_pagination_test.exs diff --git a/assets/js/app.js b/assets/js/app.js index a003e279..a6a7d694 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -126,6 +126,29 @@ Hooks.RowSelectionGuard = { } } +// StickyViewportWidth: size a `position: sticky; left: 0` element to the visible width +// (clientWidth) of its horizontally-scrolling container. The infinite-scroll loading bar +// lives in a full-width table row that is wider than the viewport when the table scrolls +// horizontally; matching the sticky element to the container's visible width keeps its +// centered content pinned at the bottom-center of the visible area instead of drifting off +// with the scrolled table. The data-scroll-container attribute names the container element id. +Hooks.StickyViewportWidth = { + mounted() { + this.container = document.getElementById(this.el.dataset.scrollContainer) + this.sync = () => { + if (this.container) this.el.style.width = this.container.clientWidth + "px" + } + this.sync() + window.addEventListener("resize", this.sync) + }, + updated() { + this.sync() + }, + destroyed() { + window.removeEventListener("resize", this.sync) + } +} + // FocusRestore hook: WCAG 2.4.3 — when a modal closes, focus returns to the trigger element (e.g. "Delete member" button) Hooks.FocusRestore = { mounted() { @@ -360,6 +383,37 @@ Hooks.SidebarState = { } } +// LoadMorePrefetch: fires an infinite-scroll load event as the sentinel +// approaches the viewport, rather than only once it is fully scrolled into view. +// An IntersectionObserver rooted at the scroll container with a positive bottom +// rootMargin treats the sentinel as visible while it is still that many pixels +// below the fold, so the next page loads ahead of time. It complements the +// built-in phx-viewport-bottom binding (which stays as a reliable backstop); +// duplicate loads are harmless because the server guards on "more?" and the +// stream de-duplicates rows by id. +Hooks.LoadMorePrefetch = { + mounted() { + const container = document.getElementById(this.el.dataset.scrollContainer) + const rootMargin = `0px 0px ${this.el.dataset.margin || "600px"} 0px` + const event = this.el.dataset.event || "load_more" + let lastPush = 0 + + this.observer = new IntersectionObserver((entries) => { + if (!entries.some((entry) => entry.isIntersecting)) return + const now = Date.now() + if (now - lastPush < 400) return + lastPush = now + this.pushEvent(event) + }, {root: container || null, rootMargin, threshold: 0}) + + this.observer.observe(this.el) + }, + + destroyed() { + if (this.observer) this.observer.disconnect() + } +} + let liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, params: { @@ -378,6 +432,15 @@ window.addEventListener("phx:set-input-value", (e) => { } }) +// Return the members list to the top after a sort or filter change. The server +// resets the keyset stream to page 1 on those changes and pushes this event; a +// user scrolled down would otherwise be stranded past the shorter content with +// infinite scroll not re-arming. No-op if the scroll container is absent. +window.addEventListener("phx:members:scroll-top", () => { + const el = document.getElementById("members-table-guard") + if (el) el.scrollTop = 0 +}) + // Show progress bar on live navigation and form submits topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) window.addEventListener("phx:page-loading-start", _info => topbar.show(300)) diff --git a/lib/mv_web/components/bulk_actions_dropdown.ex b/lib/mv_web/components/bulk_actions_dropdown.ex index d0b6172a..c6db9145 100644 --- a/lib/mv_web/components/bulk_actions_dropdown.ex +++ b/lib/mv_web/components/bulk_actions_dropdown.ex @@ -36,11 +36,13 @@ defmodule MvWeb.Components.BulkActionsDropdown do ## Event routing - `dropdown_menu/1` sends `toggle_dropdown`/`close_dropdown` to `@myself`, so the - component owns its own `:open` state. The copy item carries an *un-targeted* - `phx-click="copy_emails"`, which therefore reaches the parent LiveView's - `handle_event/3` (which keeps access to `@members`), plus the - `CopyToClipboard` hook. + `dropdown_menu/1` sends `toggle_dropdown`/`close_dropdown` *un-targeted*, so the + parent LiveView owns the `:open` state (passed back in via the `open` assign). + This lets the parent fetch the mailto recipients lazily when the dropdown opens + rather than on every selection/filter change. The copy item likewise carries an + un-targeted `phx-click="copy_emails"`, which reaches the parent's + `handle_event/3` (which keeps access to `@members`), plus the `CopyToClipboard` + hook. """ use MvWeb, :live_component use Gettext, backend: MvWeb.Gettext @@ -72,13 +74,13 @@ defmodule MvWeb.Components.BulkActionsDropdown do |> assign(:recipient_count, assigns[:recipient_count] || 0) |> assign(:mailto_disabled?, assigns[:mailto_disabled?] || false) - # The parent never sets :open (the component owns it via toggle/close). - # Honouring an explicit :open assign keeps the component renderable in - # isolation (render_component/2) for structural tests. + # The parent owns :open and passes it in. Defaulting to false keeps the + # component renderable in isolation (render_component/2) for structural tests + # that omit it. socket = case Map.fetch(assigns, :open) do {:ok, open} -> assign(socket, :open, open) - :error -> socket + :error -> assign_new(socket, :open, fn -> false end) end {:ok, socket} @@ -98,7 +100,7 @@ defmodule MvWeb.Components.BulkActionsDropdown do button_label={gettext("Actions")} icon="hero-bolt" open={@open} - phx_target={@myself} + phx_target={nil} menu_width="w-70" menu_align="left" button_class="btn-secondary gap-2" @@ -232,12 +234,6 @@ defmodule MvWeb.Components.BulkActionsDropdown do end end - @impl true - def handle_event("toggle_dropdown", _params, socket) do - {:noreply, assign(socket, :open, !socket.assigns.open)} - end - - def handle_event("close_dropdown", _params, socket) do - {:noreply, assign(socket, :open, false)} - end + # Open/close are handled by the parent LiveView (see "Event routing"); the + # component no longer owns dropdown state. end diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 2ed22fd1..d01b3d63 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -953,6 +953,11 @@ defmodule MvWeb.CoreComponents do doc: "when true, first header/body column gets sticky left positioning to keep selection controls visible" + attr :viewport_bottom, :any, + default: nil, + doc: + "optional phx-viewport-bottom event (string) rendered on the streamed tbody so the built-in InfiniteScroll hook appends the next page without shifting the scroll position; nil omits the binding" + slot :col, required: true do attr :label, :string attr :class, :string @@ -964,6 +969,10 @@ defmodule MvWeb.CoreComponents do slot :action, doc: "the slot for showing user actions in the last table column" + slot :footer, + doc: + "optional after-rows content rendered as a full-width row in a separate, non-streamed tbody. Combined with viewport_bottom it acts as the infinite-scroll sentinel (e.g. a loading indicator) that fires the load event and disappears once no more pages remain." + def table(assigns) do assigns = with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do @@ -986,6 +995,15 @@ defmodule MvWeb.CoreComponents do assigns = assign(assigns, :first_row_click_col_idx, first_row_click_col_idx) + # Total column span for full-width footer rows (e.g. the infinite-scroll + # loading indicator): fixed columns + dynamic custom-field columns + the + # optional action column. + col_count = + length(assigns.col) + length(assigns[:dynamic_cols] || []) + + if assigns.action != [], do: 1, else: 0 + + assigns = assign(assigns, :col_count, col_count) + ~H"""
- + + <%!-- After-rows footer in its own, non-streamed tbody so it stays put + outside the phx-update="stream" container. When viewport_bottom is set it + doubles as the infinite-scroll sentinel: rendered only while more pages + exist, so once the last page loads it disappears and stops firing. --%> + + + + {render_slot(@footer)} + + +
""" diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index c991f7fe..f246f5ee 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -30,8 +30,6 @@ defmodule MvWeb.MemberLive.Index do import MvWeb.LiveHelpers, only: [current_actor: 1] alias Mv.Membership - alias Mv.Membership.CustomFieldSort - alias Mv.Membership.Member, as: MemberResource alias Mv.MembershipFees alias Mv.MembershipFees.MembershipFeeType alias MvWeb.Helpers.DateFormatter @@ -42,6 +40,7 @@ defmodule MvWeb.MemberLive.Index do alias MvWeb.MemberLive.Index.FilterParams alias MvWeb.MemberLive.Index.Formatter alias MvWeb.MemberLive.Index.MembershipFeeStatus + alias MvWeb.MemberLive.Index.OverviewQuery require Ash.Query require Logger @@ -173,11 +172,29 @@ defmodule MvWeb.MemberLive.Index do ) |> assign(:show_current_cycle, false) |> assign(:membership_fee_status_filter, nil) + |> assign(:page, nil) + |> assign(:after_cursor, nil) + |> assign(:more?, false) + |> assign(:total_count, 0) + |> assign(:loading?, false) + # The bulk-actions dropdown's open state lives here so the mailto recipient + # list can be fetched lazily on open (never on a mere selection change). + |> assign(:bulk_actions_open, false) + |> assign(:mailto_bcc, "") + |> assign(:recipient_count, 0) + |> assign(:mailto_disabled?, false) + |> stream_configure(:members, dom_id: &"row-#{&1.id}") + |> stream(:members, []) |> assign_export_payload() {:ok, socket} end + # Number of members fetched per keyset page (matches the :overview action's + # default_limit). Mount loads one page; further pages arrive via infinite + # scroll (phx-viewport-bottom) so the socket never holds the full table. + @page_limit 50 + # ----------------------------------------------------------------- # Handle Events # ----------------------------------------------------------------- @@ -208,12 +225,15 @@ defmodule MvWeb.MemberLive.Index do {:noreply, socket |> assign(:selected_members, selected) + |> restream_member(id) |> update_selection_assigns()} end @impl true def handle_event("select_all", _params, socket) do - all_ids = socket.assigns.members |> Enum.map(& &1.id) |> MapSet.new() + # Select-all spans the whole filtered set, not just the loaded page (§1.17): + # re-query every matching id. Toggling off when all are already selected. + all_ids = all_filtered_member_ids(socket) selected = if MapSet.equal?(socket.assigns.selected_members, all_ids) do @@ -225,6 +245,33 @@ defmodule MvWeb.MemberLive.Index do {:noreply, socket |> assign(:selected_members, selected) + |> restream_all_members() + |> update_selection_assigns()} + end + + # The bulk-actions dropdown routes its open/close here (un-targeted) so the + # mailto recipient list is fetched only when the menu actually opens, not on + # every selection or filter change. + @impl true + def handle_event("toggle_dropdown", _params, socket) do + open? = !socket.assigns.bulk_actions_open + + socket = assign(socket, :bulk_actions_open, open?) + socket = if open?, do: assign_mailto_recipients(socket), else: socket + + {:noreply, socket} + end + + @impl true + def handle_event("close_dropdown", _params, socket) do + {:noreply, assign(socket, :bulk_actions_open, false)} + end + + @impl true + def handle_event("load_more", _params, socket) do + {:noreply, + socket + |> load_more() |> update_selection_assigns()} end @@ -236,6 +283,7 @@ defmodule MvWeb.MemberLive.Index do socket |> assign(:show_current_cycle, new_show_current) |> load_members() + |> scroll_list_to_top() |> update_selection_assigns() query_params = @@ -247,21 +295,16 @@ defmodule MvWeb.MemberLive.Index do new_path = ~p"/members?#{query_params}" - {:noreply, push_patch(socket, to: new_path, replace: true)} + {:noreply, push_reload(socket, new_path)} end @impl true def handle_event("copy_emails", _params, socket) do - members = socket.assigns.members - selected_ids = socket.assigns.selected_members - any_selected? = Enum.any?(members, &MapSet.member?(selected_ids, &1.id)) - - # Recipients follow the current scope: the selection when present, otherwise - # every member in the (filtered) list. Members without an email are excluded - # in both cases (unchanged missing-email handling). With no selection we no - # longer hard-stop with "No members selected" — we act on the scope; the - # empty-recipient feedback below is preserved. - formatted_emails = scope_member_emails(members, selected_ids, any_selected?) + # Recipients follow the current scope, re-queried from the DB so a no-selection + # (all/filtered) copy or a select-all copy spans the whole filtered set rather + # than only the loaded page (§1.17). Members without an email are excluded; + # the empty-recipient feedback below is preserved. + formatted_emails = scope_emails(socket) email_count = length(formatted_emails) if email_count == 0 do @@ -310,6 +353,7 @@ defmodule MvWeb.MemberLive.Index do |> assign(:sort_order, new_order) |> update_sort_components(old_field, new_field, new_order) |> load_members() + |> scroll_list_to_top() |> update_selection_assigns() # URL sync - push_patch happens synchronously in the event handler @@ -325,7 +369,7 @@ defmodule MvWeb.MemberLive.Index do socket.assigns[:fields_in_url?] || false ) - {:noreply, push_patch(socket, to: ~p"/members?#{query_params}", replace: true)} + {:noreply, push_reload(socket, ~p"/members?#{query_params}")} end # ----------------------------------------------------------------- @@ -347,6 +391,7 @@ defmodule MvWeb.MemberLive.Index do socket |> assign(:query, q) |> load_members() + |> scroll_list_to_top() |> update_selection_assigns() query_params = @@ -358,7 +403,7 @@ defmodule MvWeb.MemberLive.Index do new_path = ~p"/members?#{query_params}" - {:noreply, push_patch(socket, to: new_path, replace: true)} + {:noreply, push_reload(socket, new_path)} end @impl true @@ -367,6 +412,7 @@ defmodule MvWeb.MemberLive.Index do socket |> assign(:cycle_status_filter, filter) |> load_members() + |> scroll_list_to_top() |> update_selection_assigns() query_params = @@ -377,7 +423,7 @@ defmodule MvWeb.MemberLive.Index do ) new_path = ~p"/members?#{query_params}" - {:noreply, push_patch(socket, to: new_path, replace: true)} + {:noreply, push_reload(socket, new_path)} end @impl true @@ -393,6 +439,7 @@ defmodule MvWeb.MemberLive.Index do socket |> assign(:boolean_custom_field_filters, updated_filters) |> load_members() + |> scroll_list_to_top() |> update_selection_assigns() query_params = @@ -403,7 +450,7 @@ defmodule MvWeb.MemberLive.Index do ) new_path = ~p"/members?#{query_params}" - {:noreply, push_patch(socket, to: new_path, replace: true)} + {:noreply, push_reload(socket, new_path)} end @impl true @@ -421,6 +468,7 @@ defmodule MvWeb.MemberLive.Index do socket |> assign(:group_filters, group_filters) |> load_members() + |> scroll_list_to_top() |> update_selection_assigns() query_params = @@ -431,7 +479,7 @@ defmodule MvWeb.MemberLive.Index do ) new_path = ~p"/members?#{query_params}" - {:noreply, push_patch(socket, to: new_path, replace: true)} + {:noreply, push_reload(socket, new_path)} end @impl true @@ -449,6 +497,7 @@ defmodule MvWeb.MemberLive.Index do socket |> assign(:fee_type_filters, fee_type_filters) |> load_members() + |> scroll_list_to_top() |> update_selection_assigns() query_params = @@ -459,7 +508,7 @@ defmodule MvWeb.MemberLive.Index do ) new_path = ~p"/members?#{query_params}" - {:noreply, push_patch(socket, to: new_path, replace: true)} + {:noreply, push_reload(socket, new_path)} end @impl true @@ -468,6 +517,7 @@ defmodule MvWeb.MemberLive.Index do socket |> assign(:date_filters, new_date_filters) |> load_members() + |> scroll_list_to_top() |> update_selection_assigns() query_params = @@ -478,7 +528,7 @@ defmodule MvWeb.MemberLive.Index do ) new_path = ~p"/members?#{query_params}" - {:noreply, push_patch(socket, to: new_path, replace: true)} + {:noreply, push_reload(socket, new_path)} end # Backward compatibility: tuple form delegates to map form @@ -537,6 +587,7 @@ defmodule MvWeb.MemberLive.Index do |> assign(:boolean_custom_field_filters, Map.get(opts, :boolean_filters, %{})) |> assign(:date_filters, Map.get(opts, :date_filters, DateFilter.default())) |> load_members() + |> scroll_list_to_top() |> update_selection_assigns() query_params = @@ -547,7 +598,7 @@ defmodule MvWeb.MemberLive.Index do ) new_path = ~p"/members?#{query_params}" - {:noreply, push_patch(socket, to: new_path, replace: true)} + {:noreply, push_reload(socket, new_path)} end @impl true @@ -691,6 +742,9 @@ defmodule MvWeb.MemberLive.Index do |> update_selection_assigns() end + # The (re)load is complete: clear the busy flag set by `push_reload/2` (§1.10). + socket = assign(socket, :loading?, false) + # Update sort components after rendering socket = if socket.assigns[:sort_needs_update] do @@ -806,7 +860,7 @@ defmodule MvWeb.MemberLive.Index do |> maybe_add_field_selection(socket.assigns[:user_field_selection], true) new_path = ~p"/members?#{query_params}" - push_patch(socket, to: new_path, replace: true) + push_reload(socket, new_path) end defp update_session_field_selection(socket, selection) do @@ -975,81 +1029,145 @@ defmodule MvWeb.MemberLive.Index do # Loading members # ------------------------------------------------------------- + # Loads the first keyset page of the overview and resets the stream. All + # filtering and sorting run in PostgreSQL via the `:overview` read action + # (see OverviewQuery); the socket holds only the loaded window, never the + # whole table. defp load_members(socket) do - search_query = socket.assigns.query + # `count: true` runs one COUNT for the active filter set so the live region + # can announce the exact total matching count (§1.9), not just the loaded + # page size. + page = read_overview_page(socket, limit: @page_limit, count: true) - query = - Mv.Membership.Member - |> Ash.Query.new() - |> Ash.Query.select(@overview_fields) + socket + |> assign(:members, index_by_id(page.results)) + |> assign(:after_cursor, next_cursor(page)) + |> assign(:more?, page.more?) + |> assign(:total_count, page.count || length(page.results)) + |> AshPhoenix.LiveView.assign_page_and_stream_result(page, + results_key: :members, + page_key: :page, + stream_opts: [reset: true] + ) + end - query = load_custom_field_values(query, compute_ids_to_load(socket)) + # Returns the members scroll container to the top after a sort/filter reset. + # A reset re-streams from page 1, so a user who was scrolled down would be left + # past the (now shorter) content with infinite scroll not re-arming; scrolling + # back to the top shows the new ordering from #1 and re-arms the loader. Only + # used on genuine resets, never on load_more (which must preserve position). + # The client handler (app.js) is a no-op if the container is not found. + defp scroll_list_to_top(socket), do: push_event(socket, "members:scroll-top", %{}) - query = MembershipFeeStatus.load_cycles_for_members(query, socket.assigns.show_current_cycle) + # Marks the table region busy (aria-busy, §1.10) for the reload triggered by a + # filter/sort/search patch, then hands off to the patch. `handle_params` clears + # the flag once the page has been (re)loaded. + defp push_reload(socket, path) do + socket + |> assign(:loading?, true) + |> push_patch(to: path, replace: true) + end - # Load groups for each member (id, name, slug only) - query = - Ash.Query.load(query, groups: [:id, :name, :slug]) + # Fetches the next keyset page (if any) and appends it to the stream and the + # loaded window. Triggered by phx-viewport-bottom; a no-op once the last page + # has been reached so the bottom sentinel stops fetching. + defp load_more(%{assigns: %{more?: false}} = socket), do: socket - # Load membership_fee_type when the column is visible or when sorting by it - query = - if :membership_fee_type in socket.assigns.member_fields_visible or - socket.assigns.sort_field in [:membership_fee_type, "membership_fee_type"] do - Ash.Query.load(query, membership_fee_type: [:id, :name]) - else - query - end + defp load_more(socket) do + page = read_overview_page(socket, limit: @page_limit, after: socket.assigns.after_cursor) - query = apply_search_filter(query, search_query) + socket = + Enum.reduce(page.results, socket, fn member, acc -> + stream_insert(acc, :members, member) + end) - query = apply_group_filters(query, socket.assigns[:group_filters], socket.assigns[:groups]) + socket + |> assign(:members, Map.merge(socket.assigns.members, index_by_id(page.results))) + |> assign(:after_cursor, next_cursor(page)) + |> assign(:more?, page.more?) + |> assign(:page, %{page | results: nil}) + end - query = - apply_fee_type_filters(query, socket.assigns[:fee_type_filters], socket.assigns[:fee_types]) + # Loaded members are held as a `%{id => member}` map (not a list): appends on + # load_more stay O(page) via Map.merge instead of copying an ever-growing list + # with `++`, and a per-row selection re-render is an O(1) lookup by id. + defp index_by_id(members), do: Map.new(members, &{&1.id, &1}) - # Built-in date filters (join_date, exit_date) are pushed to the DB so - # excluded rows never reach the BEAM. The active_only default is part of - # this — fresh load returns only members without an exit_date or with an - # exit_date strictly in the future. - query = - DateFilter.apply_ash_filter(query, socket.assigns.date_filters) + # Re-inserts a single loaded member into the stream so its row re-renders with + # the current selection state (checkbox + selected highlight). Stream rows are + # not re-rendered on assign changes, so a selection toggle must re-stream the + # affected row. + defp restream_member(socket, id) do + case Map.get(socket.assigns[:members] || %{}, id) do + nil -> socket + member -> stream_insert(socket, :members, member) + end + end - # Use ALL custom fields for sorting (not just show_in_overview subset) - custom_fields_for_sort = socket.assigns.all_custom_fields + # Re-inserts every loaded member so all visible rows reflect a bulk selection + # change (select-all / deselect-all). + defp restream_all_members(socket) do + Enum.reduce(Map.values(socket.assigns[:members] || %{}), socket, fn member, acc -> + stream_insert(acc, :members, member) + end) + end - {query, sort_after_load} = - maybe_sort( - query, - socket.assigns.sort_field, - socket.assigns.sort_order, - custom_fields_for_sort - ) + # Reads one keyset page of the overview with display loads applied. Reads run + # as the real user actor so Ash policies are enforced. + defp read_overview_page(socket, page_opts) do + socket + |> overview_query() + |> Ash.read!(actor: current_actor(socket), page: page_opts) + end - # Errors in handle_params are handled by Phoenix LiveView - actor = current_actor(socket) - members = Ash.read!(query, actor: actor) + # The cursor of the last loaded row, used as the `after` bound for the next + # page; nil once there are no further pages. + defp next_cursor(%{more?: true, results: results}) when results != [], + do: List.last(results).__metadata__.keyset - # Custom field values are already filtered at the database level in load_custom_field_values/2 - # No need for in-memory filtering anymore + defp next_cursor(_page), do: nil - members = apply_in_memory_filters(members, socket) + # Builds the `:overview` query (filter/sort via OverviewQuery) and layers on + # the display-only loads (custom field values, cycles, groups, fee type). + defp overview_query(socket) do + socket + |> overview_query_opts() + |> OverviewQuery.build() + |> Ash.Query.select(@overview_fields) + |> load_custom_field_values(compute_ids_to_load(socket)) + |> MembershipFeeStatus.load_cycles_for_members(socket.assigns.show_current_cycle) + |> Ash.Query.load(groups: [:id, :name, :slug]) + |> maybe_load_fee_type(socket) + end - # Sort in memory if needed (custom fields, groups, group_count; computed fields are blocked) - # Note: :groups is in computed_member_fields() but can be sorted in-memory, so we only block :membership_fee_status - members = - if sort_after_load and - socket.assigns.sort_field != :membership_fee_status do - sort_members_in_memory( - members, - socket.assigns.sort_field, - socket.assigns.sort_order, - custom_fields_for_sort - ) - else - members - end + defp maybe_load_fee_type(query, socket) do + if :membership_fee_type in socket.assigns.member_fields_visible or + socket.assigns.sort_field in [:membership_fee_type, "membership_fee_type"] do + Ash.Query.load(query, membership_fee_type: [:id, :name]) + else + query + end + end - assign(socket, :members, members) + # Assembles the filter/sort options OverviewQuery understands from the current + # LiveView assigns. + defp overview_query_opts(socket) do + %{ + search: socket.assigns.query, + group_filters: socket.assigns[:group_filters], + groups: socket.assigns[:groups], + fee_type_filters: socket.assigns[:fee_type_filters], + fee_types: socket.assigns[:fee_types], + boolean_custom_field_filters: socket.assigns.boolean_custom_field_filters, + boolean_custom_fields: socket.assigns.boolean_custom_fields, + date_filters: socket.assigns.date_filters, + date_custom_fields: socket.assigns[:date_custom_fields], + cycle_status_filter: socket.assigns.cycle_status_filter, + show_current_cycle: socket.assigns.show_current_cycle, + sort_field: socket.assigns.sort_field, + sort_order: socket.assigns.sort_order, + custom_fields: socket.assigns.all_custom_fields + } end # Collects every custom field UUID whose values must be loaded for a given @@ -1083,24 +1201,6 @@ defmodule MvWeb.MemberLive.Index do |> Enum.uniq() end - # Post-DB filtering: cycle status, boolean custom fields, and custom date - # fields. Date custom fields are last so they see the already-narrowed list. - defp apply_in_memory_filters(members, socket) do - members - |> apply_cycle_status_filter( - socket.assigns.cycle_status_filter, - socket.assigns.show_current_cycle - ) - |> apply_boolean_custom_field_filters( - socket.assigns.boolean_custom_field_filters, - socket.assigns.all_custom_fields - ) - |> DateFilter.apply_in_memory( - socket.assigns.date_filters, - socket.assigns[:date_custom_fields] || [] - ) - end - defp load_custom_field_values(query, []), do: query defp load_custom_field_values(query, custom_field_ids) do @@ -1117,166 +1217,10 @@ defmodule MvWeb.MemberLive.Index do # Helper Functions # ------------------------------------------------------------- - defp apply_search_filter(query, search_query) do - if search_query && String.trim(search_query) != "" do - query - |> MemberResource.fuzzy_search(%{query: search_query}) - else - query - end - end - - # Multiple group filters combine with AND: member must match all selected group conditions. - defp apply_group_filters(query, group_filters, _groups) when group_filters == %{}, do: query - - defp apply_group_filters(query, group_filters, groups) do - valid_ids = - groups - |> Enum.map(&normalize_uuid_string(to_string(&1.id))) - |> Enum.reject(&is_nil/1) - |> MapSet.new() - - Enum.reduce(group_filters, query, fn {group_id_str, value}, q -> - member? = MapSet.member?(valid_ids, group_id_str) - - if member? do - apply_one_group_filter(q, group_id_str, value) - else - q - end - end) - end - - defp apply_one_group_filter(query, _group_id_str, nil), do: query - - defp apply_one_group_filter(query, group_id_str, :in) do - case Ecto.UUID.cast(group_id_str) do - {:ok, group_uuid} -> - Ash.Query.filter(query, expr(exists(member_groups, group_id == ^group_uuid))) - - _ -> - query - end - end - - defp apply_one_group_filter(query, group_id_str, :not_in) do - case Ecto.UUID.cast(group_id_str) do - {:ok, group_uuid} -> - Ash.Query.filter(query, expr(not exists(member_groups, group_id == ^group_uuid))) - - _ -> - query - end - end - - defp apply_one_group_filter(query, _, _), do: query - - # Fee type filters: :in selections combine with OR (member has any of the selected types); - # :not_in selections combine with AND (member must not have type A and not have type B). - defp apply_fee_type_filters(query, fee_type_filters, _fee_types) when fee_type_filters == %{}, - do: query - - defp apply_fee_type_filters(query, fee_type_filters, fee_types) do - valid_ids = - fee_types - |> Enum.map(&normalize_uuid_string(to_string(&1.id))) - |> Enum.reject(&is_nil/1) - |> MapSet.new() - - {in_id_strs, not_in_filters} = - fee_type_filters - |> Enum.filter(fn {id_str, _} -> MapSet.member?(valid_ids, id_str) end) - |> Enum.split_with(fn {_, value} -> value == :in end) - - in_uuids = - in_id_strs - |> Enum.map(fn {id_str, _} -> id_str end) - |> Enum.map(&Ecto.UUID.cast/1) - |> Enum.filter(&match?({:ok, _}, &1)) - |> Enum.map(fn {:ok, uuid} -> uuid end) - - query = - if in_uuids == [] do - query - else - Ash.Query.filter(query, expr(membership_fee_type_id in ^in_uuids)) - end - - Enum.reduce(not_in_filters, query, fn {fee_type_id_str, _}, q -> - apply_one_fee_type_filter(q, fee_type_id_str, :not_in) - end) - end - - defp apply_one_fee_type_filter(query, fee_type_id_str, :not_in) do - case Ecto.UUID.cast(fee_type_id_str) do - {:ok, fee_type_uuid} -> - Ash.Query.filter( - query, - expr(membership_fee_type_id != ^fee_type_uuid or is_nil(membership_fee_type_id)) - ) - - _ -> - query - end - end - - defp apply_cycle_status_filter(members, nil, _show_current), do: members - - defp apply_cycle_status_filter(members, status, show_current) - when status in [:paid, :unpaid] do - MembershipFeeStatus.filter_members_by_cycle_status(members, status, show_current) - end - defp toggle_order(:asc), do: :desc defp toggle_order(:desc), do: :asc defp toggle_order(nil), do: :asc - # Function to sort the column if needed. - # Only DB member fields and custom fields; computed fields (e.g. membership_fee_status) are never passed to Ash. - # Returns {query, sort_after_load} where sort_after_load is true if we need to sort in memory. - defp maybe_sort(query, nil, _order, _custom_fields), do: {query, false} - defp maybe_sort(query, _field, nil, _custom_fields), do: {query, false} - - defp maybe_sort(query, field, order, _custom_fields) do - # :groups is in computed_member_fields() but can be sorted in-memory - # Only :membership_fee_status should be blocked from sorting - if field == :membership_fee_status or field == "membership_fee_status" do - {query, false} - else - apply_sort_to_query(query, field, order) - end - end - - defp apply_sort_to_query(query, field, order) do - cond do - # Groups sort -> after load (in memory) - field in [:groups, "groups"] -> - {query, true} - - # Membership fee type sort -> by related name at DB - field in [:membership_fee_type, "membership_fee_type"] -> - {Ash.Query.sort(query, [{"membership_fee_type.name", order}]), false} - - # Custom field sort -> after load - custom_field_sort?(field) -> - {query, true} - - # DB field sort (atom) - is_atom(field) -> - {Ash.Query.sort(query, [{field, order}]), false} - - # DB field sort (string) -> convert only if allowed - is_binary(field) -> - case safe_member_field_atom_only(field) do - nil -> {query, false} - atom -> {Ash.Query.sort(query, [{atom, order}]), false} - end - - true -> - {query, false} - end - end - defp valid_sort_field?(field) when is_atom(field) do # :groups is in computed_member_fields() but can be sorted # Only :membership_fee_status should be blocked @@ -1331,19 +1275,6 @@ defmodule MvWeb.MemberLive.Index do defp custom_field_sort?(_), do: false - defp extract_custom_field_id(field) when is_atom(field) do - field |> Atom.to_string() |> extract_custom_field_id() - end - - defp extract_custom_field_id(field) when is_binary(field) do - case String.split(field, @custom_field_prefix) do - ["", id_str] -> id_str - _ -> nil - end - end - - defp extract_custom_field_id(_), do: nil - defp extract_custom_field_ids(visible_custom_fields) do Enum.map(visible_custom_fields, fn field_string -> case String.split(field_string, @custom_field_prefix) do @@ -1354,91 +1285,6 @@ defmodule MvWeb.MemberLive.Index do |> Enum.filter(&(&1 != nil)) end - defp sort_members_in_memory(members, field, order, custom_fields) do - if field in [:groups, "groups"] do - sort_members_by_groups(members, order) - else - custom_field_id_str = extract_custom_field_id(field) - - case custom_field_id_str do - nil -> members - id_str -> sort_members_by_custom_field(members, id_str, order, custom_fields) - end - end - end - - defp sort_members_by_groups(members, order) do - # Members with groups first, then by first group name alphabetically (min = first by sort order) - first_group_name = fn member -> - (member.groups || []) - |> Enum.map(& &1.name) - |> Enum.min(fn -> nil end) - end - - members - |> Enum.sort_by(fn member -> - name = first_group_name.(member) - # Nil (no groups) sorts last in asc, first in desc - {name == nil, name || ""} - end) - |> then(fn list -> if order == :desc, do: Enum.reverse(list), else: list end) - end - - defp sort_members_by_custom_field(members, id_str, order, custom_fields) do - custom_field = find_custom_field_by_id(custom_fields, id_str) - - case custom_field do - nil -> members - cf -> sort_members_with_custom_field(members, cf, order) - end - end - - defp find_custom_field_by_id(custom_fields, id_str) do - Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end) - end - - defp sort_members_with_custom_field(members, custom_field, order) do - {members_with_values, members_without_values} = - split_members_by_value_presence(members, custom_field) - - sorted_with_values = sort_members_with_values(members_with_values, custom_field, order) - sorted_with_values ++ members_without_values - end - - defp split_members_by_value_presence(members, custom_field) do - Enum.split_with(members, fn member -> has_non_empty_value?(member, custom_field) end) - end - - defp has_non_empty_value?(member, custom_field) do - case get_custom_field_value(member, custom_field) do - nil -> - false - - cfv -> - not empty_value?(cfv.value, custom_field.value_type) - end - end - - defp sort_members_with_values(members_with_values, custom_field, order) do - sorted = - Enum.sort_by(members_with_values, fn member -> - cfv = get_custom_field_value(member, custom_field) - CustomFieldSort.sort_key(cfv.value, custom_field.value_type) - end) - - if order == :desc, do: Enum.reverse(sorted), else: sorted - end - - defp empty_value?(%Ash.Union{value: value, type: type}, _expected_type), - do: empty_value?(value, type) - - defp empty_value?(nil, _type), do: true - - defp empty_value?(value, type) when type in [:string, :email] and is_binary(value), - do: String.trim(value) == "" - - defp empty_value?(_value, _type), do: false - defp maybe_update_sort(socket, %{"sort_field" => sf, "sort_order" => so}) do field = determine_field(socket.assigns.sort_field, sf) order = determine_order(socket.assigns.sort_order, so) @@ -1800,11 +1646,11 @@ defmodule MvWeb.MemberLive.Index do def format_date(date), do: DateFormatter.format_date(date) defp update_selection_assigns(socket) do - members = socket.assigns[:members] || [] selected_members = socket.assigns.selected_members - - selected_count = Enum.count(members, &MapSet.member?(selected_members, &1.id)) - any_selected? = Enum.any?(members, &MapSet.member?(selected_members, &1.id)) + # The selection may span members beyond the loaded page (after select-all), + # so its size is the MapSet size, not a count over the loaded window. + selected_count = MapSet.size(selected_members) + any_selected? = selected_count > 0 # Scope drives the trigger label: the selection when present, otherwise the # whole list (filtered, when a search term or any filter is active). @@ -1815,13 +1661,31 @@ defmodule MvWeb.MemberLive.Index do true -> :all end - # Copy/Mailto recipients: the members in scope that have a usable email. - # With a selection that is the selected subset (existing behaviour); without - # a selection it is every member in scope (deliberate behaviour change). In - # both cases members without an email are excluded, exactly as today's - # format_selected_member_emails does for the selection case. - recipient_emails = scope_member_emails(members, selected_members, any_selected?) - recipient_count = length(recipient_emails) + # No DB work here: the mailto recipient list is computed lazily when the + # bulk-actions dropdown opens (assign_mailto_recipients/1), so a selection or + # filter change never reads members for a link the user may never open. + socket + |> assign(:selected_count, selected_count) + |> assign(:scope, scope) + |> assign_export_payload() + end + + # Fetches the mailto BCC recipients for the current bulk scope. Called only when + # the bulk-actions dropdown opens (the mailto item, a native anchor, is rendered + # only then), keeping selection toggles free of DB reads. + defp assign_mailto_recipients(socket) do + selected_count = MapSet.size(socket.assigns.selected_members) + + # Size of the whole bulk scope (§1.17): the selection, or the full filtered + # total — never just the loaded page. + scope_size = if selected_count > 0, do: selected_count, else: socket.assigns.total_count + cap = Mv.Constants.max_mailto_bulk_recipients() + mailto_disabled? = scope_size >= cap + + # Mailto recipients are bounded by the cap; only re-query the (small) scope + # when it is within the cap, otherwise the link is disabled anyway. + recipient_emails = if mailto_disabled?, do: [], else: scope_emails(socket, limit: cap) + recipient_count = if mailto_disabled?, do: scope_size, else: length(recipient_emails) # RFC 6068: mailto URI params must use %20 for spaces, not + (encode_www_form uses +) mailto_bcc = @@ -1830,29 +1694,60 @@ defmodule MvWeb.MemberLive.Index do |> URI.encode_www_form() |> String.replace("+", "%20") - mailto_disabled? = recipient_count >= Mv.Constants.max_mailto_bulk_recipients() - socket - |> assign(:selected_count, selected_count) - |> assign(:scope, scope) |> assign(:recipient_count, recipient_count) |> assign(:mailto_disabled?, mailto_disabled?) |> assign(:mailto_bcc, mailto_bcc) - |> assign_export_payload() end - # Returns the formatted "Name " recipient list for the current scope: - # the selected members when any are selected, otherwise every member in the - # (filtered) list. Members without an email are excluded in both cases. - defp scope_member_emails(members, selected_members, true = _any_selected?), - do: format_selected_member_emails(members, selected_members) - - defp scope_member_emails(members, _selected_members, false = _any_selected?) do - members + # Formatted "Name " recipients for the current bulk scope, re-queried + # from the DB so the set spans the whole selection / filtered total rather than + # only the loaded page (§1.17). Members without an email are excluded. + defp scope_emails(socket, opts \\ []) do + socket + |> scope_members(opts) |> Enum.filter(fn member -> member.email && member.email != "" end) |> Enum.map(&format_member_email/1) end + # MapSet of every member id matching the active filters (no pagination), used + # by select-all so the selection spans the full filtered set (§1.17). + defp all_filtered_member_ids(socket) do + socket + |> overview_query_opts() + |> Map.put(:sort_field, nil) + |> OverviewQuery.build() + |> Ash.Query.select([:id]) + |> Ash.read!(actor: current_actor(socket)) + |> MapSet.new(& &1.id) + end + + # Members matching the current bulk scope: the selected members when any are + # selected, otherwise every member matching the active filters. Re-queried as + # the real user actor so policies are enforced. + defp scope_members(socket, opts) do + selected = socket.assigns.selected_members + + query = + if MapSet.size(selected) > 0 do + ids = MapSet.to_list(selected) + + Mv.Membership.Member + |> Ash.Query.for_read(:overview) + |> Ash.Query.filter(expr(id in ^ids)) + else + socket + |> overview_query_opts() + |> Map.put(:sort_field, nil) + |> OverviewQuery.build() + end + |> Ash.Query.select([:id, :first_name, :last_name, :email]) + + query = if opts[:limit], do: Ash.Query.limit(query, opts[:limit]), else: query + + Ash.read!(query, actor: current_actor(socket)) + end + @doc """ Returns true when the member list is restricted by a non-empty search term or any active filter (cycle status, group, fee type, boolean custom field, or a diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index eb7085dc..d9049146 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -5,6 +5,7 @@ <.live_component module={MvWeb.Components.BulkActionsDropdown} id="bulk-actions-dropdown" + open={@bulk_actions_open} export_payload_json={@export_payload_json} selected_count={@selected_count} scope={@scope} @@ -39,7 +40,7 @@ boolean_filters={@boolean_custom_field_filters} date_custom_fields={@date_custom_fields} date_filters={@date_filters} - member_count={length(@members)} + member_count={@total_count} /> <.tooltip content={ @@ -80,6 +81,19 @@ /> + <%!-- Polite live region: present on first render (before it is filled) so + screen readers announce the exact total matching count on every filter change + (WCAG 4.1.3). --%> +
+ {ngettext("%{count} member", "%{count} members", @total_count, count: @total_count)} +
+ <%!-- On desktop (lg:), only the table area scrolls; header and filters stay visible. On mobile, normal flow. --%>
<.table id="members" - rows={@members} + rows={@streams.members} + row_item={fn {_dom_id, member} -> member end} wrapper_overflow_class="overflow-visible" sticky_header={true} sticky_first_col={true} - row_id={fn member -> "row-#{member.id}" end} - row_click={fn member -> JS.push("select_row_and_navigate", value: %{id: member.id}) end} + viewport_bottom={@more? && "load_more"} + row_click={ + fn {_dom_id, member} -> + JS.push("select_row_and_navigate", value: %{id: member.id}) + end + } row_tooltip={gettext("Click for member details")} row_selected?={fn member -> MapSet.member?(@selected_members, member.id) end} dynamic_cols={@dynamic_cols} @@ -114,7 +134,7 @@ type="checkbox" name="select_all" phx-click="select_all" - checked={MapSet.equal?(@selected_members, @members |> Enum.map(& &1.id) |> MapSet.new())} + checked={@total_count > 0 and @selected_count == @total_count} aria-label={gettext("Select all members")} role="checkbox" /> @@ -405,6 +425,31 @@
+ <:footer> + <%!-- Invisible sentinel: starts the next-page load as it approaches the + viewport (see the LoadMorePrefetch hook), so rows load ahead of reaching + the very bottom. Only rendered while more pages exist (with the footer). --%> + +
+ + {gettext("Loading more members …")} +
+ diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 38ad3e54..db07d05f 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -126,7 +126,9 @@ msgstr "Feld hinzufügen" msgid "Add members" msgstr "Mitglieder hinzufügen" +#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Address" msgstr "Adresse" @@ -192,8 +194,7 @@ msgid "An account with this email already exists. Please verify your password to msgstr "Ein Konto mit dieser E-Mail existiert bereits. Bitte gib dein Passwort ein, um dein OIDC-Konto zu verknüpfen." #: lib/mv_web/helpers/ash_error_helpers.ex -#: lib/mv_web/live/membership_fee_settings_live.ex -#: lib/mv_web/live/membership_fee_type_live/index.ex +#: lib/mv_web/helpers/membership_fee_helpers.ex #: lib/mv_web/live/role_live/helpers.ex #, elixir-autogen, elixir-format msgid "An error occurred" @@ -2288,14 +2289,12 @@ msgstr "Beitragseinstellungen" msgid "Membership fee start" msgstr "Beitragsbeginn" -#: lib/mv_web/live/membership_fee_settings_live.ex -#: lib/mv_web/live/membership_fee_type_live/index.ex +#: lib/mv_web/helpers/membership_fee_helpers.ex #, elixir-autogen, elixir-format msgid "Membership fee type deleted" msgstr "Mitgliedsbeitragsart gelöscht" -#: lib/mv_web/live/membership_fee_settings_live.ex -#: lib/mv_web/live/membership_fee_type_live/index.ex +#: lib/mv_web/helpers/membership_fee_helpers.ex #, elixir-autogen, elixir-format msgid "Membership fee type not found" msgstr "Mitgliedsbeitragsart nicht gefunden" @@ -2350,12 +2349,14 @@ msgstr "Monatliches Intervall – Beitrittszeitraum einbezogen" #: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_field_live/index_component.ex +#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/show.ex +#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Name" msgstr "Name" @@ -2406,6 +2407,7 @@ msgstr "Neue*r Benutzer*in" msgid "New amount" msgstr "Neuer Betrag" +#: lib/mv_web/components/core_components.ex #: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/join_request_live/show.ex @@ -2693,10 +2695,10 @@ msgstr "Optionen" #: lib/mv/membership/members_pdf.ex #: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/member_live/index/membership_fee_status.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" msgstr "Bezahlt" @@ -3293,11 +3295,6 @@ msgstr "Buchungen/Belege aus Vereinfacht anzeigen" msgid "Show in overview" msgstr "In der Übersicht anzeigen" -#: lib/mv_web/live/components/field_visibility_dropdown_component.ex -#, elixir-autogen, elixir-format -msgid "Show/Hide Columns" -msgstr "Spalten ein-/ausblenden" - #: lib/mv_web/live/auth/sign_in_live.ex #, elixir-autogen, elixir-format msgid "Sign in" @@ -3391,10 +3388,10 @@ msgid "Summary" msgstr "Zusammenfassung" #: lib/mv/membership/members_pdf.ex +#: lib/mv_web/live/member_live/index/membership_fee_status.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/statistics_live.ex -#: lib/mv_web/member_live/index/membership_fee_status.ex #, elixir-autogen, elixir-format msgid "Suspended" msgstr "Pausiert" @@ -3700,10 +3697,10 @@ msgstr "Aufhebung der Verknüpfung geplant" #: lib/mv/membership/members_pdf.ex #: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/member_live/index/membership_fee_status.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" msgstr "Unbezahlt" @@ -3904,6 +3901,7 @@ msgstr "Jährliches Intervall – Beitrittszeitraum nicht einbezogen" msgid "Yearly Interval - Joining Cycle Included" msgstr "Jährliches Intervall – Beitrittszeitraum einbezogen" +#: lib/mv_web/components/core_components.ex #: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/join_request_live/show.ex @@ -3956,8 +3954,7 @@ msgstr "Du kannst Links einfügen: ganze Adressen (https://…) oder als [Linkte msgid "You do not have permission to %{action} members." msgstr "Du hast keine Berechtigung, Mitglieder zu %{action}." -#: lib/mv_web/live/membership_fee_settings_live.ex -#: lib/mv_web/live/membership_fee_type_live/index.ex +#: lib/mv_web/helpers/membership_fee_helpers.ex #, elixir-autogen, elixir-format msgid "You do not have permission to access this membership fee type" msgstr "Du hast keine Berechtigung, auf diese Mitgliedsbeitragsart zuzugreifen." @@ -3973,8 +3970,7 @@ msgstr "Du hast keine Berechtigung, auf diese Seite zuzugreifen." msgid "You do not have permission to delete this member" msgstr "Du hast keine Berechtigung, dieses Mitglied zu löschen." -#: lib/mv_web/live/membership_fee_settings_live.ex -#: lib/mv_web/live/membership_fee_type_live/index.ex +#: lib/mv_web/helpers/membership_fee_helpers.ex #, elixir-autogen, elixir-format msgid "You do not have permission to delete this membership fee type" msgstr "Du hast keine Berechtigung, diese Mitgliedsbeitragsart zu löschen." @@ -4145,3 +4141,75 @@ msgstr "Öffnen" msgctxt "status" msgid "Open" msgstr "Offen" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "%{count} member" +msgid_plural "%{count} members" +msgstr[0] "%{count} Mitglied" +msgstr[1] "%{count} Mitglieder" + +#: lib/mv_web/live/components/search_bar_component.ex +#, elixir-autogen, elixir-format +msgid "Clear search" +msgstr "Suche zurücksetzen" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "Compact address field" +msgstr "Kompaktes Adressfeld" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "Compact mode" +msgstr "Kompakter Modus" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "View settings" +msgstr "Ansichtseinstellungen" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "include email" +msgstr "inklusive E-Mail" + +#: lib/mv_web/components/core_components.ex +#, elixir-autogen, elixir-format +msgid "Reset to default" +msgstr "Auf Standard zurücksetzen" + +#: lib/mv_web/live/components/field_visibility_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "Columns" +msgstr "Spalten" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Loading more members …" +msgstr "Weitere Mitglieder werden geladen …" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "View" +msgstr "Ansicht" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "Compact Member field" +msgstr "Kompaktes Mitglied-Feld" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Click to sort by city" +msgstr "Klicke, um nach Ort zu sortieren" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Click to sort by last name" +msgstr "Klicke, um nach Nachname zu sortieren" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "No address" +msgstr "Keine Adresse" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 4c9a10a0..c3b21727 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -127,7 +127,9 @@ msgstr "" msgid "Add members" msgstr "" +#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Address" msgstr "" @@ -2348,12 +2350,14 @@ msgstr "" #: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_field_live/index_component.ex +#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/show.ex +#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Name" msgstr "" @@ -2404,6 +2408,7 @@ msgstr "" msgid "New amount" msgstr "" +#: lib/mv_web/components/core_components.ex #: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/join_request_live/show.ex @@ -2691,10 +2696,10 @@ msgstr "" #: lib/mv/membership/members_pdf.ex #: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/member_live/index/membership_fee_status.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" msgstr "" @@ -3291,11 +3296,6 @@ msgstr "" msgid "Show in overview" msgstr "" -#: lib/mv_web/live/components/field_visibility_dropdown_component.ex -#, elixir-autogen, elixir-format -msgid "Show/Hide Columns" -msgstr "" - #: lib/mv_web/live/auth/sign_in_live.ex #, elixir-autogen, elixir-format msgid "Sign in" @@ -3389,10 +3389,10 @@ msgid "Summary" msgstr "" #: lib/mv/membership/members_pdf.ex +#: lib/mv_web/live/member_live/index/membership_fee_status.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/statistics_live.ex -#: lib/mv_web/member_live/index/membership_fee_status.ex #, elixir-autogen, elixir-format msgid "Suspended" msgstr "" @@ -3698,10 +3698,10 @@ msgstr "" #: lib/mv/membership/members_pdf.ex #: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/member_live/index/membership_fee_status.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" msgstr "" @@ -3901,6 +3901,7 @@ msgstr "" msgid "Yearly Interval - Joining Cycle Included" msgstr "" +#: lib/mv_web/components/core_components.ex #: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/join_request_live/show.ex @@ -4140,3 +4141,75 @@ msgstr "" msgctxt "status" msgid "Open" msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "%{count} member" +msgid_plural "%{count} members" +msgstr[0] "" +msgstr[1] "" + +#: lib/mv_web/live/components/search_bar_component.ex +#, elixir-autogen, elixir-format +msgid "Clear search" +msgstr "" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "Compact address field" +msgstr "" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "Compact mode" +msgstr "" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "View settings" +msgstr "" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "include email" +msgstr "" + +#: lib/mv_web/components/core_components.ex +#, elixir-autogen, elixir-format +msgid "Reset to default" +msgstr "" + +#: lib/mv_web/live/components/field_visibility_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "Columns" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Loading more members …" +msgstr "" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "View" +msgstr "" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "Compact Member field" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Click to sort by city" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Click to sort by last name" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "No address" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index cda87b5a..fe871774 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -127,7 +127,9 @@ msgstr "" msgid "Add members" msgstr "" +#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/show.ex +#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Address" msgstr "" @@ -193,8 +195,7 @@ msgid "An account with this email already exists. Please verify your password to msgstr "" #: lib/mv_web/helpers/ash_error_helpers.ex -#: lib/mv_web/live/membership_fee_settings_live.ex -#: lib/mv_web/live/membership_fee_type_live/index.ex +#: lib/mv_web/helpers/membership_fee_helpers.ex #: lib/mv_web/live/role_live/helpers.ex #, elixir-autogen, elixir-format msgid "An error occurred" @@ -2289,14 +2290,12 @@ msgstr "" msgid "Membership fee start" msgstr "" -#: lib/mv_web/live/membership_fee_settings_live.ex -#: lib/mv_web/live/membership_fee_type_live/index.ex +#: lib/mv_web/helpers/membership_fee_helpers.ex #, elixir-autogen, elixir-format, fuzzy msgid "Membership fee type deleted" msgstr "" -#: lib/mv_web/live/membership_fee_settings_live.ex -#: lib/mv_web/live/membership_fee_type_live/index.ex +#: lib/mv_web/helpers/membership_fee_helpers.ex #, elixir-autogen, elixir-format, fuzzy msgid "Membership fee type not found" msgstr "" @@ -2351,12 +2350,14 @@ msgstr "" #: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_field_live/index_component.ex +#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/show.ex +#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Name" msgstr "" @@ -2407,6 +2408,7 @@ msgstr "" msgid "New amount" msgstr "" +#: lib/mv_web/components/core_components.ex #: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/join_request_live/show.ex @@ -2694,10 +2696,10 @@ msgstr "" #: lib/mv/membership/members_pdf.ex #: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/member_live/index/membership_fee_status.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" msgstr "" @@ -3294,11 +3296,6 @@ msgstr "" msgid "Show in overview" msgstr "" -#: lib/mv_web/live/components/field_visibility_dropdown_component.ex -#, elixir-autogen, elixir-format -msgid "Show/Hide Columns" -msgstr "" - #: lib/mv_web/live/auth/sign_in_live.ex #, elixir-autogen, elixir-format msgid "Sign in" @@ -3392,10 +3389,10 @@ msgid "Summary" msgstr "" #: lib/mv/membership/members_pdf.ex +#: lib/mv_web/live/member_live/index/membership_fee_status.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/statistics_live.ex -#: lib/mv_web/member_live/index/membership_fee_status.ex #, elixir-autogen, elixir-format msgid "Suspended" msgstr "" @@ -3701,10 +3698,10 @@ msgstr "" #: lib/mv/membership/members_pdf.ex #: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/member_live/index/membership_fee_status.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" msgstr "" @@ -3904,6 +3901,7 @@ msgstr "" msgid "Yearly Interval - Joining Cycle Included" msgstr "" +#: lib/mv_web/components/core_components.ex #: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/join_request_live/show.ex @@ -3956,8 +3954,7 @@ msgstr "" msgid "You do not have permission to %{action} members." msgstr "" -#: lib/mv_web/live/membership_fee_settings_live.ex -#: lib/mv_web/live/membership_fee_type_live/index.ex +#: lib/mv_web/helpers/membership_fee_helpers.ex #, elixir-autogen, elixir-format, fuzzy msgid "You do not have permission to access this membership fee type" msgstr "" @@ -3973,8 +3970,7 @@ msgstr "" msgid "You do not have permission to delete this member" msgstr "" -#: lib/mv_web/live/membership_fee_settings_live.ex -#: lib/mv_web/live/membership_fee_type_live/index.ex +#: lib/mv_web/helpers/membership_fee_helpers.ex #, elixir-autogen, elixir-format, fuzzy msgid "You do not have permission to delete this membership fee type" msgstr "" @@ -4145,3 +4141,75 @@ msgstr "Open" msgctxt "status" msgid "Open" msgstr "Open" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "%{count} member" +msgid_plural "%{count} members" +msgstr[0] "" +msgstr[1] "" + +#: lib/mv_web/live/components/search_bar_component.ex +#, elixir-autogen, elixir-format +msgid "Clear search" +msgstr "" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "Compact address field" +msgstr "" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "Compact mode" +msgstr "" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "View settings" +msgstr "" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "include email" +msgstr "" + +#: lib/mv_web/components/core_components.ex +#, elixir-autogen, elixir-format +msgid "Reset to default" +msgstr "" + +#: lib/mv_web/live/components/field_visibility_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "Columns" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Loading more members …" +msgstr "" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format +msgid "View" +msgstr "" + +#: lib/mv_web/live/components/view_settings_dropdown_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Compact Member field" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Click to sort by city" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Click to sort by last name" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "No address" +msgstr "" diff --git a/test/mv_web/member_live/index_bulk_scope_test.exs b/test/mv_web/member_live/index_bulk_scope_test.exs new file mode 100644 index 00000000..66626539 --- /dev/null +++ b/test/mv_web/member_live/index_bulk_scope_test.exs @@ -0,0 +1,71 @@ +defmodule MvWeb.MemberLive.IndexBulkScopeTest do + @moduledoc """ + §1.17 — Bulk "all" scope spans the whole filtered set (re-queried from the DB), + not only the loaded/visible page, and the count reflects the full filtered + total. + """ + use MvWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + alias Mv.Helpers.SystemActor + + # More than one keyset page so "loaded" and "all matching" differ. + @total 60 + + defp seed(n) do + actor = SystemActor.get_system_actor() + + Enum.each(1..n, fn i -> + idx = String.pad_leading(Integer.to_string(i), 3, "0") + + {:ok, _} = + Mv.Membership.create_member( + %{first_name: "Bulk#{idx}", last_name: "Scope", email: "bulk#{idx}@example.com"}, + actor: actor + ) + end) + end + + defp scope_badge_text(view) do + view + |> render() + |> LazyHTML.from_fragment() + |> LazyHTML.query(~s([data-testid="bulk-actions-scope-badge"])) + |> LazyHTML.text() + |> String.trim() + end + + test "select all selects the whole filtered set, not just the loaded page", %{conn: conn} do + seed(@total) + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, ~p"/members") + + view |> element("[phx-click='select_all']") |> render_click() + + # The selection count badge reflects all matching members, not the 50 loaded. + assert scope_badge_text(view) == "#{@total}" + end + + test "copy-emails with no selection copies every matching member's email", %{conn: conn} do + seed(@total) + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, ~p"/members") + + result = render_hook(view, "copy_emails", %{}) + + # All 60 matching members are copied, not only the 50 loaded rows. + assert result =~ "#{@total}" + end + + test "select-all then copy copies the whole filtered set", %{conn: conn} do + seed(@total) + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, ~p"/members") + + view |> element("[phx-click='select_all']") |> render_click() + result = render_hook(view, "copy_emails", %{}) + + assert result =~ "#{@total}" + end +end diff --git a/test/mv_web/member_live/index_filter_panel_test.exs b/test/mv_web/member_live/index_filter_panel_test.exs new file mode 100644 index 00000000..770c0944 --- /dev/null +++ b/test/mv_web/member_live/index_filter_panel_test.exs @@ -0,0 +1,79 @@ +defmodule MvWeb.MemberLive.IndexFilterPanelTest do + @moduledoc """ + §1.18 — The existing "Apply filters" panel is unchanged: same fieldset/legend + form structure and payment-status controls, no chip/add-filter-builder + paradigm (that is #548). Its filters now resolve DB-side via the `:overview` + read action. + """ + use MvWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + alias Mv.Helpers.SystemActor + + setup %{conn: conn} do + {:ok, _} = + Mv.Membership.create_member( + %{first_name: "Panel", last_name: "Member", email: "panel@example.com"}, + actor: SystemActor.get_system_actor() + ) + + %{conn: conn_with_oidc_user(conn)} + end + + defp open_filter(view) do + view + |> element(~s(button[phx-click="toggle_dropdown"][aria-label="Filter members"])) + |> render_click() + end + + test "the filter panel keeps its fieldset form structure and payment controls", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + open_filter(view) + + # Semantic form structure (fieldset/legend, radio controls) is preserved. + assert has_element?(view, ~s(form[data-testid="member-filter-form"])) + assert has_element?(view, "#payment-filter-all") + assert has_element?(view, "#payment-filter-paid") + assert has_element?(view, "#payment-filter-unpaid") + end + + test "no chip / add-filter-builder paradigm is present (#548 is out of scope)", %{conn: conn} do + {:ok, view, html} = live(conn, ~p"/members") + open_filter(view) + html = html <> render(view) + + refute html =~ ~s(data-testid="add-filter") + refute html =~ ~s(data-testid="filter-chip") + refute html =~ "filter-builder" + end + + test "the panel's payment filter resolves DB-side via :overview", %{conn: conn} do + system_actor = SystemActor.get_system_actor() + fee_type = Mv.Fixtures.create_fee_type(%{interval: :yearly}, system_actor) + last_year_start = Date.new!(Date.utc_today().year - 1, 1, 1) + + {:ok, paid} = + Mv.Membership.create_member( + %{ + first_name: "PaidPanel", + last_name: "X", + email: "paidpanel@example.com", + membership_fee_type_id: fee_type.id + }, + actor: system_actor + ) + + Mv.Fixtures.create_cycle( + paid, + fee_type, + %{cycle_start: last_year_start, status: :paid, replace_existing: true}, + system_actor + ) + + {:ok, _view, html} = live(conn, ~p"/members?cycle_status_filter=paid") + + assert html =~ "PaidPanel" + refute html =~ "Panel Member" + end +end diff --git a/test/mv_web/member_live/index_live_region_test.exs b/test/mv_web/member_live/index_live_region_test.exs new file mode 100644 index 00000000..152e5eed --- /dev/null +++ b/test/mv_web/member_live/index_live_region_test.exs @@ -0,0 +1,68 @@ +defmodule MvWeb.MemberLive.IndexLiveRegionTest do + @moduledoc """ + §1.9 — The polite live region announces the exact total matching count, + computed via a count query for the active filter set. + §1.10 — The live region element exists in the DOM before it is filled, and the + table region carries `aria-busy`. + """ + use MvWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + alias Mv.Helpers.SystemActor + + defp seed(n) do + actor = SystemActor.get_system_actor() + + Enum.each(1..n, fn i -> + idx = String.pad_leading(Integer.to_string(i), 3, "0") + + {:ok, _} = + Mv.Membership.create_member( + %{first_name: "Lr#{idx}", last_name: "Region", email: "lr#{idx}@example.com"}, + actor: actor + ) + end) + end + + test "polite live region exists on first render and announces the exact total", %{conn: conn} do + seed(60) + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, ~p"/members") + + # Present-before-fill: the live region element is in the initial DOM. + assert has_element?(view, "#members-result-count[aria-live='polite']") + # Exact total, not the loaded page size (60 matched, only 50 loaded). + region = view |> element("#members-result-count") |> render() + assert region =~ "60 members" + end + + test "table region binds aria-busy to the loading flag (settled after load)", %{conn: conn} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, ~p"/members") + + # aria-busy is bound to @loading? (`to_string(@loading?)`), not hardcoded: once + # the page has loaded the region is not busy. It is set true across a + # filter/sort/search reload patch (push_reload/2, §1.10). + assert has_element?(view, "[data-testid='members-table-scroll'][aria-busy='false']") + end + + test "count reflects the active filter set, not the whole table", %{conn: conn} do + actor = SystemActor.get_system_actor() + + {:ok, _} = + Mv.Membership.create_member( + %{first_name: "Findme", last_name: "Unique", email: "findme@example.com"}, + actor: actor + ) + + seed(5) + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, ~p"/members?query=Findme") + + # Only the single matching member is counted (1 of 6 total). + region = view |> element("#members-result-count") |> render() + assert region =~ "1 member" + refute region =~ "6 member" + end +end diff --git a/test/mv_web/member_live/index_pagination_test.exs b/test/mv_web/member_live/index_pagination_test.exs new file mode 100644 index 00000000..e21a5531 --- /dev/null +++ b/test/mv_web/member_live/index_pagination_test.exs @@ -0,0 +1,96 @@ +defmodule MvWeb.MemberLive.IndexPaginationTest do + @moduledoc """ + §1.7 — Mount loads only one keyset page, not the whole table. + §1.8 — phx-viewport-bottom fetches and appends the next page; once the last + page is reached no further fetch is issued. + """ + use MvWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + require Ash.Query + + alias Mv.Helpers.SystemActor + + # Matches the :overview default_limit / @page_limit in the LiveView. + @page_limit 50 + + defp seed_members(n) do + actor = SystemActor.get_system_actor() + + Enum.each(1..n, fn i -> + # Zero-padded names keep first_name-ascending order deterministic. + idx = String.pad_leading(Integer.to_string(i), 4, "0") + + {:ok, _} = + Mv.Membership.create_member( + %{first_name: "Page#{idx}", last_name: "Member", email: "page#{idx}@example.com"}, + actor: actor + ) + end) + end + + defp row_count(html) do + ~r/]*id="row-/ |> Regex.scan(html) |> length() + end + + test "mount loads exactly one page when more members exist", %{conn: conn} do + seed_members(@page_limit + 10) + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, ~p"/members") + + assert row_count(html) == @page_limit + # More rows remain, so the infinite-scroll sentinel is armed. + assert html =~ ~s(phx-viewport-bottom="load_more") + end + + test "viewport-bottom appends the next page and stops at the last page", %{conn: conn} do + seed_members(@page_limit + 10) + conn = conn_with_oidc_user(conn) + {:ok, view, html} = live(conn, ~p"/members") + + assert row_count(html) == @page_limit + + # Fetch the next page: the remaining rows are appended to the stream. + html_after = render_hook(view, "load_more", %{}) + assert row_count(html_after) == @page_limit + 10 + + # Last page reached: the sentinel is disarmed and a further fetch is a no-op. + refute html_after =~ ~s(phx-viewport-bottom="load_more") + html_again = render_hook(view, "load_more", %{}) + assert row_count(html_again) == @page_limit + 10 + end + + test "selecting a row from a later page re-renders it as checked", %{conn: conn} do + seed_members(@page_limit + 10) + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, ~p"/members") + + # Load the second page so its rows are in the loaded window. + render_hook(view, "load_more", %{}) + + # The highest-index member sorts last and lives on the second page. + actor = SystemActor.get_system_actor() + + last_member = + Mv.Membership.Member + |> Ash.Query.sort(first_name: :asc) + |> Ash.read!(actor: actor) + |> List.last() + + html = render_click(view, "select_member", %{"id" => last_member.id}) + + # The re-streamed row for a later-page member reflects the selection. + assert html =~ ~s(id="row-#{last_member.id}") + assert has_element?(view, ~s(tr#row-#{last_member.id} input[type="checkbox"][checked])) + end + + test "single page does not arm the infinite-scroll sentinel", %{conn: conn} do + seed_members(3) + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, ~p"/members") + + assert row_count(html) == 3 + refute html =~ ~s(phx-viewport-bottom="load_more") + end +end diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index 19855349..8f31beb1 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -306,7 +306,8 @@ defmodule MvWeb.MemberLive.IndexTest do # asserted on internal state to preserve the original coverage of the callback. assigns = :sys.get_state(view.pid).socket.assigns assert assigns.query == "Friedrich" - assert is_list(assigns.members) + # Loaded members are held as a `%{id => member}` map (the restream lookup window). + assert is_map(assigns.members) end @tag :ui @@ -1069,6 +1070,63 @@ defmodule MvWeb.MemberLive.IndexTest do assert bcc =~ "scope1%40example.com" refute bcc =~ "scope2%40example.com" end + + test "selecting a member does not read the DB for mailto recipients (deferred to open)", + %{conn: conn, member1: member1} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + # A checkbox toggle is a non-search interaction; it must not issue a member + # read just to precompute a mailto link the user may never open. The + # recipient list is fetched lazily when the bulk-actions dropdown opens. + member_reads = + capture_member_select_queries(fn -> + render_click(view, "select_member", %{"id" => member1.id}) + end) + + assert member_reads == [], + "select_member must not read members for the mailto recipients; got: #{inspect(member_reads)}" + + # Opening the dropdown still surfaces the correct recipients for the selection. + bcc = mailto_bcc(view) + assert bcc =~ "scope1%40example.com" + refute bcc =~ "scope2%40example.com" + end + + # Captures every SELECT against the members table emitted while `fun` runs. + defp capture_member_select_queries(fun) do + test_pid = self() + handler_id = "test-member-select-#{System.unique_integer([:positive])}" + + :telemetry.attach( + handler_id, + [:mv, :repo, :query], + fn _event, _measurements, metadata, _config -> + sql = metadata[:query] || "" + + if String.contains?(sql, "SELECT") and String.contains?(sql, "\"members\"") do + send(test_pid, {:member_query, sql}) + end + end, + nil + ) + + try do + fun.() + after + :telemetry.detach(handler_id) + end + + collect_member_queries([]) + end + + defp collect_member_queries(acc) do + receive do + {:member_query, sql} -> collect_member_queries([sql | acc]) + after + 0 -> Enum.reverse(acc) + end + end end describe "cycle status filter" do @@ -2372,12 +2430,12 @@ defmodule MvWeb.MemberLive.IndexTest do # Should complete in less than 1 second (1000ms) assert duration < 1000, "Filter took #{duration}ms, expected < 1000ms" - # Verify filtering worked correctly - should show all true members - Enum.each(1..75, fn i -> - assert html =~ "TrueMember#{i}" - end) + # The overview now keyset-paginates: mount loads only the first page, not + # the whole filtered set (§1.7). The filter still resolves DB-side, so the + # loaded page contains only matching (true) members and never a non-matching + # (false) one. + assert html =~ "TrueMember" - # Should not show false members Enum.each(1..75, fn i -> refute html =~ "FalseMember#{i}" end) From af2cc2e0d492447d3d9d81901cb1575cf552faf1 Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 3 Jul 2026 11:35:46 +0200 Subject: [PATCH 06/30] feat(member): condense the overview into composite name and address cells --- assets/css/app.css | 184 ++++++++++++++++++ lib/mv_web/components/core_components.ex | 16 +- .../live/components/search_bar_component.ex | 17 ++ lib/mv_web/live/member_live/index.html.heex | 78 ++++++-- .../member_live/index/field_visibility.ex | 27 ++- lib/mv_web/translations/member_fields.ex | 2 + .../components/sort_header_component_test.exs | 75 ++++--- .../index/field_visibility_test.exs | 26 +-- .../member_live/index_address_cell_test.exs | 87 +++++++++ .../index_custom_fields_sorting_test.exs | 10 +- .../index_default_columns_test.exs | 58 ++++++ .../index_field_visibility_test.exs | 20 +- .../index_groups_url_params_test.exs | 5 +- .../index_member_fields_display_test.exs | 15 +- .../member_live/index_search_clear_test.exs | 56 ++++++ .../member_live/index_sticky_pinned_test.exs | 56 ++++++ test/mv_web/member_live/index_test.exs | 77 ++++++-- 17 files changed, 716 insertions(+), 93 deletions(-) create mode 100644 test/mv_web/member_live/index_address_cell_test.exs create mode 100644 test/mv_web/member_live/index_default_columns_test.exs create mode 100644 test/mv_web/member_live/index_search_clear_test.exs create mode 100644 test/mv_web/member_live/index_sticky_pinned_test.exs diff --git a/assets/css/app.css b/assets/css/app.css index 611e9ad1..37091909 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -678,6 +678,78 @@ /* This file is for your main application CSS */ +/* ============================================ + Table header density scaling + ============================================ */ + +/* + * DaisyUI table size modifiers (table-xs, table-sm, etc.) scope their font-size + * rule to ":not(thead, tfoot) tr", so the header row stays at the base 0.875 rem + * regardless of the chosen density. We extend each modifier to thead so the + * header scales together with the body. + */ +.table.table-xs thead tr { font-size: 0.6875rem; } +.table.table-sm thead tr { font-size: 0.75rem; } +.table.table-lg thead tr { font-size: 1.125rem; } +.table.table-xl thead tr { font-size: 1.375rem; } + +/* + * Density-tracking badges and sort icons in the member overview. + * + * The member table renders its rows via LiveView streams, so toggling density + * only swaps the size class (table-xs <-> table-md); it does NOT + * re-render the streamed rows. A per-row `size=` conditional therefore bakes a + * stale badge size into the streamed DOM. To make badges (and, for visual + * consistency, the header sort glyphs) follow the density toggle without any + * row re-render, their size is driven purely from the table size class here. + * + * Badge values mirror daisyUI's own .badge-xs / .badge-md (font-size, --size + * which the base .badge uses for height, and padding-inline). Scoped to the + * member overview via #members-keyboard so unrelated badges are unaffected. + */ +#members-keyboard .table.table-xs .badge { + --size: calc(var(--size-selector, 0.25rem) * 4); + font-size: 0.625rem; + padding-inline: calc(0.25rem * 2 - var(--border)); +} +#members-keyboard .table.table-md .badge { + --size: calc(var(--size-selector, 0.25rem) * 6); + font-size: 0.875rem; + padding-inline: calc(0.25rem * 3 - var(--border)); +} + +/* Sort glyphs scale with the header text (11px compact / 14px comfortable). */ +#members-keyboard .table.table-md thead .sort-icon { + width: 1.5rem; + height: 1.5rem; +} +#members-keyboard .table.table-xs thead .sort-icon { + width: 1.125rem; + height: 1.125rem; +} + +/* + * Infinite-scroll loading row. + * + * The loading indicator must read like a full, accented data row rather than a + * narrow pinned bar. The accent (base-200 background + a top separator) and the + * padding live on the full-width footer cell, which spans every column via + * colspan, so the accent is visible edge-to-edge even while the table is scrolled + * horizontally. The spinner + label inside stay pinned to the visible viewport + * width (StickyViewportWidth hook) and transparent, so they remain centered on + * horizontal scroll while the cell accent shows through behind them. + * + * The row height tracks the active density (table-xs compact / table-md + * comfortable) so it matches a normal data row in each mode. + */ +#members-footer > tr > td { + background-color: var(--color-base-200); + border-top: 1px solid var(--color-base-300); + padding: 0; +} +.table.table-xs #members-loading-bar { min-height: 3.375rem; } +.table.table-md #members-loading-bar { min-height: 4.1875rem; } + /* ============================================ SortableList: drag-and-drop table rows ============================================ */ @@ -773,3 +845,115 @@ [data-sticky-first-col-rows="true"] .table.table-zebra tbody tr input.checkbox:focus:not(:focus-visible) { outline: none; } + +/* + * Sticky checkbox column: border-collapse: separate avoids the Chromium artifact + * where a collapsed row border is repainted (a spurious 1px line) under a + * position: sticky cell. The row separators are moved onto the cells so they + * still render in the separate model (matching DaisyUI's faint divider color). + */ +[data-sticky-first-col-rows="true"] .table.table-zebra { + border-collapse: separate; + border-spacing: 0; +} +[data-sticky-first-col-rows="true"] + .table.table-zebra + :where(thead tr, tbody tr:not(:last-child)) + > :where(th, td) { + border-bottom: var(--border) solid color-mix(in oklch, var(--color-base-content) 5%, #0000); +} + +/* + * Horizontal-scroll fade for the pinned checkbox column: instead of hiding the + * scrolled-away content behind a hard opaque block, a gradient on the right edge + * of the sticky cell fades the incoming content, signalling there is more behind + * it. overflow: visible lets the ::after gradient extend past the cell; the cell + * keeps its own (zebra) background so the checkbox stays legible. + */ +[data-sticky-first-col-rows="true"] .table.table-zebra td.sticky-first-col-cell { + overflow: visible; +} +[data-sticky-first-col-rows="true"] .table.table-zebra td.sticky-first-col-cell::after, +[data-sticky-first-col-rows="true"] .table.table-zebra thead th.sticky-first-col-th::after { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: 100%; + width: 1.25rem; + pointer-events: none; +} +[data-sticky-first-col-rows="true"] + .table.table-zebra + tbody + tr:nth-child(odd) + > td.sticky-first-col-cell::after { + background: linear-gradient(to right, var(--color-base-100), transparent); +} +[data-sticky-first-col-rows="true"] + .table.table-zebra + tbody + tr:nth-child(even) + > td.sticky-first-col-cell::after { + background: linear-gradient(to right, var(--color-base-200), transparent); +} +[data-sticky-first-col-rows="true"] + .table.table-zebra + tbody + tr[data-row-interactive="true"]:is(:hover, :has(:focus-visible)) + > td.sticky-first-col-cell::after { + background: linear-gradient(to right, var(--color-base-300), transparent); +} +[data-sticky-first-col-rows="true"] .table.table-zebra thead th.sticky-first-col-th::after { + background: linear-gradient(to right, var(--color-base-100), transparent); +} +/* Compact density: narrower fade so the gradient does not visually reach into the + checkbox column's own content area in the tighter table-xs cell. */ +[data-sticky-first-col-rows="true"] .table.table-zebra.table-xs td.sticky-first-col-cell::after, +[data-sticky-first-col-rows="true"] .table.table-zebra.table-xs thead th.sticky-first-col-th::after { + width: 0.5rem; +} + +/* + * Sort-header tooltip. Rendered via the native Popover API (see the SortTooltip + * JS hook), so it lives in the browser TOP LAYER: it is not clipped by the + * members table's overflow (overflow-x:auto forces overflow-y:auto, which used + * to clip a CSS pseudo-tooltip below the header) and paints above the sticky + * header, neighbor cells, and the pinned checkbox column's fade — without any + * z-index hacks. This block only resets the popover UA defaults and gives it a + * small daisyUI-flavoured tooltip look; the JS hook sets top/left each show. + */ +.sort-tooltip { + position: fixed; + inset: auto; + margin: 0; + border: 0; + padding: 0.25rem 0.5rem; + width: max-content; + max-width: 16rem; + overflow: visible; + background: var(--color-neutral, oklch(0.2 0 0)); + color: var(--color-neutral-content, oklch(0.98 0 0)); + font-size: 0.75rem; + line-height: 1rem; + border-radius: 0.25rem; + box-shadow: 0 1px 3px rgb(0 0 0 / 0.3); + pointer-events: none; +} + +/* + * Vertically center the row checkbox in the sticky first column using the + * table-native vertical-align: middle. This keeps display: table-cell intact so + * the cell background fills the full row height (no white gap at the bottom). + * The fieldset margin-bottom reset removes the mb-2 from the shared input + * component so no residual margin shifts the checkbox off-center. + */ +[data-sticky-first-col-rows="true"] .table.table-zebra td.sticky-first-col-cell, +[data-sticky-first-col-rows="true"] .table.table-zebra thead th.sticky-first-col-th { + vertical-align: middle; +} +[data-sticky-first-col-rows="true"] .table.table-zebra td.sticky-first-col-cell fieldset, +[data-sticky-first-col-rows="true"] .table.table-zebra thead th.sticky-first-col-th fieldset { + margin-bottom: 0; +} + diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index d01b3d63..7af829a8 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -953,6 +953,11 @@ defmodule MvWeb.CoreComponents do doc: "when true, first header/body column gets sticky left positioning to keep selection controls visible" + attr :sticky_second_col, :boolean, + default: false, + doc: + "when true, the second column is also pinned left (e.g. an identifier column kept visible while scrolling horizontally)" + attr :viewport_bottom, :any, default: nil, doc: @@ -1018,7 +1023,9 @@ defmodule MvWeb.CoreComponents do :for={{col, col_idx} <- Enum.with_index(@col)} class={[ table_th_class(col, @sticky_header), - @sticky_first_col && col_idx == 0 && "sticky left-0 z-30 bg-base-100" + @sticky_first_col && col_idx == 0 && + "sticky-first-col-th sticky left-0 z-30 bg-base-100", + @sticky_second_col && col_idx == 1 && "sticky left-12 z-30 bg-base-100" ]} aria-sort={table_th_aria_sort(col, @sort_field, @sort_order)} > @@ -1079,6 +1086,13 @@ defmodule MvWeb.CoreComponents do classes end + classes = + if @sticky_second_col && col_idx == 1 do + ["sticky left-12 z-20 bg-base-100" | classes] + else + classes + end + classes = if col_class == nil || (col_class && !String.contains?(col_class, "text-center")) do ["truncate" | classes] diff --git a/lib/mv_web/live/components/search_bar_component.ex b/lib/mv_web/live/components/search_bar_component.ex index ac03a637..d31c563e 100644 --- a/lib/mv_web/live/components/search_bar_component.ex +++ b/lib/mv_web/live/components/search_bar_component.ex @@ -49,6 +49,17 @@ defmodule MvWeb.Components.SearchBarComponent do phx-target={@myself} phx-debounce="300" /> + """ @@ -61,4 +72,10 @@ defmodule MvWeb.Components.SearchBarComponent do send(self(), {:search_changed, q}) {:noreply, assign(socket, :query, q)} end + + # Clears the query and resets the result set to the unfiltered list (§1.3). + def handle_event("clear_search", _params, socket) do + send(self(), {:search_changed, ""}) + {:noreply, assign(socket, :query, "")} + end end diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index d9049146..55bf93f5 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -149,6 +149,48 @@ role="checkbox" /> + <:col + :let={member} + :if={:name in @member_fields_visible} + label={gettext("Name")} + > + <% full_name = + [member.first_name, member.last_name] + |> Enum.reject(&(&1 in [nil, ""])) + |> Enum.join(" ") %> +
+
+ {full_name} +
+
+ {member.email} +
+
+ + <:col + :let={member} + :if={:address in @member_fields_visible} + label={gettext("Address")} + > + <% line1 = + [member.street, member.house_number] + |> Enum.reject(&(&1 in [nil, ""])) + |> Enum.join(" ") %> + <% line2 = + [member.postal_code, member.city] + |> Enum.reject(&(&1 in [nil, ""])) + |> Enum.join(" ") %> + <.maybe_value value={line1 <> line2} empty_sr_text={gettext("No address")}> +
+
{line1}
+
{line2}
+
+ + <:col :let={member} :if={:first_name in @member_fields_visible} @@ -280,7 +322,9 @@ """ } > - {member.city} + <.maybe_value value={member.city} empty_sr_text={gettext("Not specified")}> + {member.city} + <:col :let={member} @@ -298,7 +342,9 @@ """ } > - {member.street} + <.maybe_value value={member.street} empty_sr_text={gettext("Not specified")}> + {member.street} + <:col :let={member} @@ -316,7 +362,9 @@ """ } > - {member.house_number} + <.maybe_value value={member.house_number} empty_sr_text={gettext("Not specified")}> + {member.house_number} + <:col :let={member} @@ -334,7 +382,9 @@ """ } > - {member.postal_code} + <.maybe_value value={member.postal_code} empty_sr_text={gettext("Not specified")}> + {member.postal_code} + <:col :let={member} @@ -407,15 +457,17 @@ } > <.maybe_value value={member.groups} empty_sr_text={gettext("No group assignment")}> - <%= for group <- (member.groups || []) do %> - <.badge - variant="primary" - style="outline" - aria-label={gettext("Member of group %{name}", name: group.name)} - > - {group.name} - - <% end %> +
+ <%= for group <- (member.groups || []) do %> + <.badge + variant="primary" + style="outline" + aria-label={gettext("Member of group %{name}", name: group.name)} + > + {group.name} + + <% end %> +
<:action :let={member}> diff --git a/lib/mv_web/live/member_live/index/field_visibility.ex b/lib/mv_web/live/member_live/index/field_visibility.ex index 52ebe86f..8db026d5 100644 --- a/lib/mv_web/live/member_live/index/field_visibility.ex +++ b/lib/mv_web/live/member_live/index/field_visibility.ex @@ -29,7 +29,28 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do # Single UI key for "Membership Fee Status"; only this appears in the dropdown. # Groups and membership_fee_type are also pseudo fields (not in member_fields(), displayed in the table). - @pseudo_member_fields [:membership_fee_status, :membership_fee_type, :groups] + # :name and :address are composite display-only columns (Name = name + email, + # Address = street/house number over postal code/city). + @pseudo_member_fields [:membership_fee_status, :membership_fee_type, :groups, :name, :address] + + # Curated default-visible column set (§1.2): the columns shown on first visit + # when there is no persisted selection and no global override. Everything else + # (the individual name/address sub-fields, notes, dates other than join date) + # defaults to hidden so the table fits common desktop widths. + @default_visible_fields MapSet.new([ + :name, + :address, + :membership_fee_type, + :membership_fee_status, + :groups, + :join_date + ]) + + @doc """ + The curated default-visible columns (member-field atoms) for a first visit. + """ + @spec default_visible_fields() :: [atom()] + def default_visible_fields, do: MapSet.to_list(@default_visible_fields) # Export/API may accept this as alias; must not appear in the UI options list. @export_only_alias :payment_status @@ -276,13 +297,13 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do domain_map = Enum.reduce(domain_fields, %{}, fn field, acc -> field_string = Atom.to_string(field) - default_visibility = if field == :exit_date, do: false, else: true + default_visibility = MapSet.member?(@default_visible_fields, field) show_in_overview = Map.get(visibility_config, field, default_visibility) Map.put(acc, field_string, show_in_overview) end) Enum.reduce(@pseudo_member_fields, domain_map, fn field, acc -> - Map.put(acc, Atom.to_string(field), true) + Map.put(acc, Atom.to_string(field), MapSet.member?(@default_visible_fields, field)) end) end diff --git a/lib/mv_web/translations/member_fields.ex b/lib/mv_web/translations/member_fields.ex index fa7abff8..9fd620c3 100644 --- a/lib/mv_web/translations/member_fields.ex +++ b/lib/mv_web/translations/member_fields.ex @@ -32,6 +32,8 @@ defmodule MvWeb.Translations.MemberFields do def label(:membership_fee_status), do: gettext("Membership Fee Status") def label(:membership_fee_type), do: gettext("Fee Type") def label(:groups), do: gettext("Groups") + def label(:name), do: gettext("Name") + def label(:address), do: gettext("Address") # Fallback for unknown fields def label(field) do diff --git a/test/mv_web/components/sort_header_component_test.exs b/test/mv_web/components/sort_header_component_test.exs index 0a7bf806..955c3c3f 100644 --- a/test/mv_web/components/sort_header_component_test.exs +++ b/test/mv_web/components/sort_header_component_test.exs @@ -2,10 +2,14 @@ defmodule MvWeb.Components.SortHeaderComponentTest do use MvWeb.ConnCase, async: true import Phoenix.LiveViewTest + # The curated default columns hide the individual name/address sort headers, + # so these component tests make every sortable field visible via ?fields=. + @cols "first_name,email,street,house_number,postal_code,city,country,join_date" + describe "rendering" do test "renders with correct attributes", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") + {:ok, view, _html} = live(conn, "/members?fields=#{@cols}") # Test that the component renders with correct attributes assert has_element?(view, "[data-testid='first_name']") @@ -15,7 +19,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do test "renders all sortable headers", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") + {:ok, view, _html} = live(conn, "/members?fields=#{@cols}") sortable_fields = [ :first_name, @@ -35,7 +39,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do test "renders correct labels", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") + {:ok, view, _html} = live(conn, "/members?fields=#{@cols}") # Test specific labels assert has_element?(view, "button[phx-value-field='first_name']", "First name") @@ -47,7 +51,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do describe "sort icons" do test "shows neutral icon for specific field when not sorted", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") + {:ok, view, _html} = live(conn, "/members?fields=#{@cols}") # The neutral icon has the opcity class we can test for # Test that EMAIL field specifically shows neutral icon @@ -59,7 +63,9 @@ defmodule MvWeb.Components.SortHeaderComponentTest do test "shows ascending icon for specific field when sorted ascending", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, html} = live(conn, "/members?query=&sort_field=city&sort_order=asc") + + {:ok, view, html} = + live(conn, "/members?fields=#{@cols}&query=&sort_field=city&sort_order=asc") # Test that FIRST_NAME field specifically shows ascending icon # Test CSS classes - no opacity for active state @@ -80,7 +86,9 @@ defmodule MvWeb.Components.SortHeaderComponentTest do test "shows descending icon for specific field when sorted descending", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, _view, html} = live(conn, "/members?query=&sort_field=email&sort_order=desc") + + {:ok, _view, html} = + live(conn, "/members?fields=#{@cols}&query=&sort_field=email&sort_order=desc") # Count occurrences to ensure only one descending sort icon. Dropdown # triggers carry their own trailing "hero-chevron-down size-4" chevron, so @@ -92,7 +100,9 @@ defmodule MvWeb.Components.SortHeaderComponentTest do test "multiple fields can have different icon states", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?query=&sort_field=city&sort_order=asc") + + {:ok, view, _html} = + live(conn, "/members?fields=#{@cols}&query=&sort_field=city&sort_order=asc") # CITY field should be active (ascending) refute has_element?(view, "[data-testid='city'] .opacity-40") @@ -109,7 +119,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do test "icon state changes correctly when clicking different fields", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") + {:ok, view, _html} = live(conn, "/members?fields=#{@cols}") # Start: all fields neutral except first name as default assert has_element?(view, "[data-testid='city'] .opacity-40") @@ -139,15 +149,19 @@ defmodule MvWeb.Components.SortHeaderComponentTest do conn = conn_with_oidc_user(conn) # Test EMAIL field specifically - {:ok, view, html_asc} = live(conn, "/members?sort_field=email&sort_order=asc") + {:ok, view, html_asc} = + live(conn, "/members?fields=#{@cols}&sort_field=email&sort_order=asc") + assert html_asc =~ "hero-chevron-up" refute has_element?(view, "[data-testid='email'] .opacity-40") - {:ok, view, html_desc} = live(conn, "/members?sort_field=email&sort_order=desc") + {:ok, view, html_desc} = + live(conn, "/members?fields=#{@cols}&sort_field=email&sort_order=desc") + assert html_desc =~ "hero-chevron-down" refute has_element?(view, "[data-testid='email'] .opacity-40") - {:ok, view, html_neutral} = live(conn, "/members") + {:ok, view, html_neutral} = live(conn, "/members?fields=#{@cols}") assert html_neutral =~ "hero-chevron-up-down" assert has_element?(view, "[data-testid='email'] .opacity-40") end @@ -156,7 +170,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do conn = conn_with_oidc_user(conn) # Test neutral state - only one field should have active sort icon - {:ok, _view, html_neutral} = live(conn, "/members") + {:ok, _view, html_neutral} = live(conn, "/members?fields=#{@cols}") # Count active icons (should be exactly 1 - ascending for default sort field) up_count = html_neutral |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1) @@ -166,7 +180,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do assert down_count == 0, "Expected 0 descending icons, got #{down_count}" # Test descending state - {:ok, _view, html_desc} = live(conn, "/members?sort_field=first_name&sort_order=desc") + {:ok, _view, html_desc} = + live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=desc") up_count = html_desc |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1) down_count = active_sort_down_count(html_desc) @@ -190,7 +205,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do describe "accessibility" do test "sets aria-label correctly for unsorted state", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") + {:ok, view, _html} = live(conn, "/members?fields=#{@cols}") # Check aria-label for unsorted state assert has_element?(view, "button[phx-value-field='city'][aria-label='Click to sort']") @@ -198,7 +213,9 @@ defmodule MvWeb.Components.SortHeaderComponentTest do test "sets aria-label correctly for ascending sort", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?sort_field=first_name&sort_order=asc") + + {:ok, view, _html} = + live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=asc") # Check aria-label for ascending sort assert has_element?(view, "button[phx-value-field='first_name'][aria-label='ascending']") @@ -206,7 +223,9 @@ defmodule MvWeb.Components.SortHeaderComponentTest do test "sets aria-label correctly for descending sort", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?sort_field=first_name&sort_order=desc") + + {:ok, view, _html} = + live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=desc") # Check aria-label for descending sort assert has_element?(view, "button[phx-value-field='first_name'][aria-label='descending']") @@ -214,7 +233,9 @@ defmodule MvWeb.Components.SortHeaderComponentTest do test "includes tooltip with correct aria-label", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?sort_field=first_name&sort_order=asc") + + {:ok, view, _html} = + live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=asc") # Check that tooltip div exists with correct data-tip assert has_element?(view, "[data-testid='first_name']") @@ -223,7 +244,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do test "aria-labels work for all sortable fields", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?sort_field=email&sort_order=desc") + {:ok, view, _html} = live(conn, "/members?fields=#{@cols}&sort_field=email&sort_order=desc") # Test aria-labels for different fields assert has_element?(view, "button[phx-value-field='email'][aria-label='descending']") @@ -240,7 +261,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do describe "component behavior" do test "clicking triggers sort event on parent LiveView", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") + {:ok, view, _html} = live(conn, "/members?fields=#{@cols}") # Click on the first name sort header view @@ -253,7 +274,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do test "component handles different field types correctly", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") + {:ok, view, _html} = live(conn, "/members?fields=#{@cols}") # Test that different field types render correctly assert has_element?(view, "button[phx-value-field='first_name']") @@ -265,7 +286,9 @@ defmodule MvWeb.Components.SortHeaderComponentTest do describe "edge cases" do test "handles invalid sort field gracefully", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, html} = live(conn, "/members?sort_field=invalid_field&sort_order=asc") + + {:ok, view, html} = + live(conn, "/members?fields=#{@cols}&sort_field=invalid_field&sort_order=asc") # Should not crash and should default sorting for first name assert html =~ "hero-chevron-up-down" @@ -274,7 +297,9 @@ defmodule MvWeb.Components.SortHeaderComponentTest do test "handles invalid sort order gracefully", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, html} = live(conn, "/members?sort_field=first_name&sort_order=invalid") + + {:ok, view, html} = + live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=invalid") # Should default to ascending assert html =~ "hero-chevron-up" @@ -283,7 +308,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do test "handles empty sort parameters", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, html} = live(conn, "/members?sort_field=&sort_order=") + {:ok, view, html} = live(conn, "/members?fields=#{@cols}&sort_field=&sort_order=") # Should show neutral icons assert html =~ "hero-chevron-up-down" @@ -294,7 +319,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do describe "icon state transitions" do test "icon changes when sorting state changes", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") + {:ok, view, _html} = live(conn, "/members?fields=#{@cols}") # Start with neutral state assert has_element?(view, "[data-testid='city'] .opacity-40") @@ -310,7 +335,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do test "multiple fields can be tested for icon states", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, html} = live(conn, "/members?sort_field=email&sort_order=desc") + {:ok, view, html} = live(conn, "/members?fields=#{@cols}&sort_field=email&sort_order=desc") # Email should be active (descending) assert html =~ "hero-chevron-down" diff --git a/test/mv_web/live/member_live/index/field_visibility_test.exs b/test/mv_web/live/member_live/index/field_visibility_test.exs index d86893d9..427cbead 100644 --- a/test/mv_web/live/member_live/index/field_visibility_test.exs +++ b/test/mv_web/live/member_live/index/field_visibility_test.exs @@ -89,17 +89,19 @@ defmodule MvWeb.MemberLive.Index.FieldVisibilityTest do assert result["email"] == true end - test "defaults to true when field not in settings" do + test "defaults to the curated set when field not in settings" do user_selection = %{} - settings = %{member_field_visibility: %{first_name: false}} + settings = %{member_field_visibility: %{join_date: false}} custom_fields = [] result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields) - # first_name from settings - assert result["first_name"] == false - # email defaults to true (not in settings) - assert result["email"] == true + # join_date from settings overrides its curated default + assert result["join_date"] == false + # name is in the curated default set -> visible + assert result["name"] == true + # email is not in the curated default set -> hidden + assert result["email"] == false end test "handles custom fields visibility" do @@ -160,9 +162,9 @@ defmodule MvWeb.MemberLive.Index.FieldVisibilityTest do result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields) - # Should default all fields to true - assert result["first_name"] == true - assert result["email"] == true + # Falls back to the curated default set (no crash on nil) + assert result["join_date"] == true + assert result["email"] == false end test "handles missing member_field_visibility key" do @@ -172,9 +174,9 @@ defmodule MvWeb.MemberLive.Index.FieldVisibilityTest do result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields) - # Should default all fields to true - assert result["first_name"] == true - assert result["email"] == true + # Falls back to the curated default set + assert result["join_date"] == true + assert result["email"] == false end test "includes all fields in result" do diff --git a/test/mv_web/member_live/index_address_cell_test.exs b/test/mv_web/member_live/index_address_cell_test.exs new file mode 100644 index 00000000..53d8dc04 --- /dev/null +++ b/test/mv_web/member_live/index_address_cell_test.exs @@ -0,0 +1,87 @@ +defmodule MvWeb.MemberLive.IndexAddressCellTest do + @moduledoc """ + §1.1 — The address renders as one composite cell: line 1 = street + house + number, line 2 = postal code + city. + §1.12 — City and postal code stay findable via search despite the composite + cell. + """ + use MvWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + alias Mv.Helpers.SystemActor + + defp create_member(attrs) do + {:ok, member} = + Mv.Membership.create_member( + Map.merge( + %{first_name: "Addr", last_name: "Tester", email: "addr@example.com"}, + attrs + ), + actor: SystemActor.get_system_actor() + ) + + member + end + + test "address shows as one cell with street/house on line 1 and postal/city on line 2", %{ + conn: conn + } do + member = + create_member(%{ + street: "Hauptstraße", + house_number: "12a", + postal_code: "10115", + city: "Berlin" + }) + + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, ~p"/members") + + cell = + view + |> element("#row-#{member.id} [data-testid='member-address']") + |> render() + + assert cell =~ "Hauptstraße" + assert cell =~ "12a" + assert cell =~ "10115" + assert cell =~ "Berlin" + + # Composite, not four separate sortable address columns. + refute has_element?(view, "[data-testid='street']") + refute has_element?(view, "[data-testid='city']") + end + + test "member with no address parts renders a screen-reader label, not a blank cell", %{ + conn: conn + } do + member = create_member(%{email: "no-address@example.com"}) + + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, ~p"/members") + + row = view |> element("#row-#{member.id}") |> render() + + assert row =~ "No address" + refute has_element?(view, "#row-#{member.id} [data-testid='member-address']") + end + + test "members stay findable by city via search", %{conn: conn} do + member = create_member(%{city: "Hamburg", email: "city-search@example.com"}) + + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, ~p"/members?query=Hamburg") + + assert has_element?(view, "#row-#{member.id}") + end + + test "members stay findable by postal code via search", %{conn: conn} do + member = create_member(%{postal_code: "99999", email: "postal-search@example.com"}) + + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, ~p"/members?query=99999") + + assert has_element?(view, "#row-#{member.id}") + end +end diff --git a/test/mv_web/member_live/index_custom_fields_sorting_test.exs b/test/mv_web/member_live/index_custom_fields_sorting_test.exs index 4119205c..0355766b 100644 --- a/test/mv_web/member_live/index_custom_fields_sorting_test.exs +++ b/test/mv_web/member_live/index_custom_fields_sorting_test.exs @@ -206,14 +206,20 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do conn = conn_with_oidc_user(conn) {:ok, view, _html} = - live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") + live( + conn, + "/members?fields=email&query=&sort_field=custom_field_#{field.id}&sort_order=desc" + ) # Click on email column view |> element("[data-testid='email']") |> render_click() - assert_patch(view, "/members?query=&sort_field=email&sort_order=asc") + # The fields param rides along on the patch, so assert the sort outcome. + path = assert_patch(view) + assert path =~ "sort_field=email" + assert path =~ "sort_order=asc" end test "clicking custom field column after regular column works", %{ diff --git a/test/mv_web/member_live/index_default_columns_test.exs b/test/mv_web/member_live/index_default_columns_test.exs new file mode 100644 index 00000000..04d3c993 --- /dev/null +++ b/test/mv_web/member_live/index_default_columns_test.exs @@ -0,0 +1,58 @@ +defmodule MvWeb.MemberLive.IndexDefaultColumnsTest do + @moduledoc """ + §1.2 — With no persisted column selection, exactly the curated default columns + are visible: selection checkbox, Name (name + email), Address (composite), + fee type, fee status, groups, join date. + """ + use MvWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + alias Mv.Helpers.SystemActor + + setup %{conn: conn} do + {:ok, _} = + Mv.Membership.create_member( + %{ + first_name: "Col", + last_name: "Default", + email: "col@example.com", + street: "Musterweg", + house_number: "1", + postal_code: "10115", + city: "Berlin" + }, + actor: SystemActor.get_system_actor() + ) + + %{conn: conn_with_oidc_user(conn)} + end + + test "curated columns are visible by default", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + # Composite Name + Address cells. + assert has_element?(view, "[data-testid='member-name']") + assert has_element?(view, "[data-testid='member-address']") + # Fee type, fee status, groups, join date headers. + assert has_element?(view, "[data-testid='membership_fee_type']") + assert has_element?(view, "[data-testid='join_date']") + assert has_element?(view, "th", "Membership Fee Status") + assert has_element?(view, "[data-testid='groups']") + end + + test "the individual name/address sub-fields are hidden by default", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + for field <- ~w(first_name last_name email city street house_number postal_code country) do + refute has_element?(view, "[data-testid='#{field}']") + end + end + + test "an explicit field selection still overrides the curated default", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members?fields=email") + + assert has_element?(view, "[data-testid='email']") + refute has_element?(view, "[data-testid='member-name']") + end +end diff --git a/test/mv_web/member_live/index_field_visibility_test.exs b/test/mv_web/member_live/index_field_visibility_test.exs index 63dff1c4..71744f83 100644 --- a/test/mv_web/member_live/index_field_visibility_test.exs +++ b/test/mv_web/member_live/index_field_visibility_test.exs @@ -148,20 +148,20 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members") - # Verify email is visible initially + # The curated Name column carries the email, so it is visible initially. html = render(view) assert html =~ "alice@example.com" - # Open dropdown and hide email + # Open dropdown and hide the Name column view |> element("button[aria-controls='field-visibility-menu']") |> render_click() view - |> element("button[phx-click='select_item'][phx-value-item='email']") + |> element("button[phx-click='select_item'][phx-value-item='name']") |> render_click() - # Email should no longer be visible + # The Name column (and the email it carries) is no longer visible html = render(view) refute html =~ "alice@example.com" refute html =~ "bob@example.com" @@ -309,13 +309,13 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members") - # Hide a field via dropdown + # Hide the curated Name column (which carries the email) via dropdown view |> element("button[aria-controls='field-visibility-menu']") |> render_click() view - |> element("button[phx-click='select_item'][phx-value-item='email']") + |> element("button[phx-click='select_item'][phx-value-item='name']") |> render_click() html = render(view) @@ -429,7 +429,7 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members") - # Verify email is visible initially + # The curated Name column carries the email, so it is visible initially. html = render(view) assert html =~ "alice@example.com" @@ -438,12 +438,12 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do |> element("button[aria-controls='field-visibility-menu']") |> render_click() - # Simulate Enter key press on email field button + # Simulate Enter key press on the Name field button view - |> element("button[phx-click='select_item'][phx-value-item='email']") + |> element("button[phx-click='select_item'][phx-value-item='name']") |> render_keydown(%{key: "Enter"}) - # Email should no longer be visible + # The Name column (and the email it carries) is no longer visible html = render(view) refute html =~ "alice@example.com" end diff --git a/test/mv_web/member_live/index_groups_url_params_test.exs b/test/mv_web/member_live/index_groups_url_params_test.exs index 8ce82aaa..8b92cb90 100644 --- a/test/mv_web/member_live/index_groups_url_params_test.exs +++ b/test/mv_web/member_live/index_groups_url_params_test.exs @@ -125,7 +125,10 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do conn = conn_with_oidc_user(conn) {:ok, view, html} = - live(conn, "/members?sort_field=first_name&sort_order=desc&group_#{group1.id}=in") + live( + conn, + "/members?fields=first_name&sort_field=first_name&sort_order=desc&group_#{group1.id}=in" + ) assert html =~ member1.first_name assert has_element?(view, "[data-testid='first_name'][aria-label*='descending']") diff --git a/test/mv_web/member_live/index_member_fields_display_test.exs b/test/mv_web/member_live/index_member_fields_display_test.exs index f3dadac5..6c222276 100644 --- a/test/mv_web/member_live/index_member_fields_display_test.exs +++ b/test/mv_web/member_live/index_member_fields_display_test.exs @@ -43,22 +43,23 @@ defmodule MvWeb.MemberLive.IndexMemberFieldsDisplayTest do end end - test "respects show_in_overview config", %{conn: conn, member1: m} do + test "respects show_in_overview config", %{conn: conn} do + # Global settings still drive column visibility: hiding a curated default + # column via settings removes it, while unaffected columns stay visible. {:ok, settings} = Mv.Membership.get_settings() - fields_to_hide = [:street, :house_number] {:ok, _} = Mv.Membership.update_settings(settings, %{ - member_field_visibility: Map.new(fields_to_hide, &{Atom.to_string(&1), false}) + member_field_visibility: %{"join_date" => false} }) conn = conn_with_oidc_user(conn) # Use search query to filter to only the expected member (Alice) # This significantly improves test performance by avoiding loading all members from other tests - {:ok, _view, html} = live(conn, "/members?query=Alice") + {:ok, view, _html} = live(conn, "/members?query=Alice") - assert html =~ "Email" - assert html =~ m.email - refute html =~ m.street + refute has_element?(view, "[data-testid='join_date']") + # The composite Name column (and its email content) is unaffected. + assert has_element?(view, "[data-testid='member-name']") end end diff --git a/test/mv_web/member_live/index_search_clear_test.exs b/test/mv_web/member_live/index_search_clear_test.exs new file mode 100644 index 00000000..14a8aa25 --- /dev/null +++ b/test/mv_web/member_live/index_search_clear_test.exs @@ -0,0 +1,56 @@ +defmodule MvWeb.MemberLive.IndexSearchClearTest do + @moduledoc """ + §1.3 — Activating the search clear (×) control clears the query, removes the + URL search param, and resets the result set to the unfiltered list. + """ + use MvWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + alias Mv.Helpers.SystemActor + + setup %{conn: conn} do + actor = SystemActor.get_system_actor() + + {:ok, _} = + Mv.Membership.create_member( + %{first_name: "Findable", last_name: "One", email: "findable@example.com"}, + actor: actor + ) + + {:ok, other} = + Mv.Membership.create_member( + %{first_name: "Other", last_name: "Two", email: "other@example.com"}, + actor: actor + ) + + %{conn: conn_with_oidc_user(conn), other: other} + end + + test "clear control only appears when a query is present", %{conn: conn} do + {:ok, no_query, _} = live(conn, ~p"/members") + refute has_element?(no_query, "[data-testid='search-clear']") + + {:ok, with_query, _} = live(conn, ~p"/members?query=Findable") + assert has_element?(with_query, "[data-testid='search-clear']") + end + + test "clear resets query, URL param and the result set", %{conn: conn, other: other} do + {:ok, view, _html} = live(conn, ~p"/members?query=Findable") + + # The filtered list excludes the non-matching member. + refute has_element?(view, "#row-#{other.id}") + + view |> element("[data-testid='search-clear']") |> render_click() + + # Query cleared in the URL (search param reset to empty). + path = assert_patch(view) + assert path =~ "query=" + refute path =~ "query=Findable" + + # Result set resets to the unfiltered list (the previously excluded member returns). + assert has_element?(view, "#row-#{other.id}") + # The input no longer carries the query. + refute has_element?(view, "[data-testid='search-input'][value='Findable']") + end +end diff --git a/test/mv_web/member_live/index_sticky_pinned_test.exs b/test/mv_web/member_live/index_sticky_pinned_test.exs new file mode 100644 index 00000000..95eac861 --- /dev/null +++ b/test/mv_web/member_live/index_sticky_pinned_test.exs @@ -0,0 +1,56 @@ +defmodule MvWeb.MemberLive.IndexStickyPinnedTest do + @moduledoc """ + §1.19 — When the table scrolls vertically and horizontally, the header stays + sticky and the identifier (Name) column stays pinned; overflowing cell content + truncates with ellipsis and exposes a hover/focus tooltip. + """ + use MvWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + alias Mv.Helpers.SystemActor + + @moduletag :ui + + setup %{conn: conn} do + {:ok, _} = + Mv.Membership.create_member( + %{ + first_name: "Reginald", + last_name: "Worthington-Smythe", + email: "reginald@example.com", + street: "A Very Long Street Name That Overflows", + house_number: "123", + postal_code: "10115", + city: "Berlin" + }, + actor: SystemActor.get_system_actor() + ) + + %{conn: conn_with_oidc_user(conn)} + end + + test "header is sticky and the identifier column is pinned", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/members") + + # Sticky header (desktop). + assert html =~ "lg:sticky" + assert html =~ "lg:top-0" + + # Checkbox column pinned at left-0, identifier (Name) column pinned beside it. + assert html =~ "left-0" + assert html =~ "left-12" + end + + test "composite cells truncate and expose a tooltip via title", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + name_cell = view |> element("[data-testid='member-name']") |> render() + assert name_cell =~ "truncate" + assert name_cell =~ ~s(title="Reginald Worthington-Smythe") + + address_cell = view |> element("[data-testid='member-address']") |> render() + assert address_cell =~ "truncate" + assert address_cell =~ ~s(title="A Very Long Street Name That Overflows 123") + end +end diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index 8f31beb1..e1de77a8 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -154,9 +154,13 @@ defmodule MvWeb.MemberLive.IndexTest do describe "sorting integration" do @describetag :ui + # The curated default columns no longer expose the individual name/address + # sort headers, so these tests make the relevant column visible via ?fields=. + # That puts a `fields` param on every push_patch, hence the relaxed + # substring assertions instead of exact patch strings. test "clicking a column header toggles sort order and updates the URL", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") + {:ok, view, _html} = live(conn, "/members?fields=email") # The component data test ids are built with the name of the field # First click – should sort ASC @@ -164,32 +168,44 @@ defmodule MvWeb.MemberLive.IndexTest do |> element("[data-testid='email']") |> render_click() - # The LiveView pushes a patch with the new query params - assert_patch(view, "/members?query=&sort_field=email&sort_order=asc") + path_asc = assert_patch(view) + assert path_asc =~ "sort_field=email" + assert path_asc =~ "sort_order=asc" # Second click – toggles to DESC view |> element("[data-testid='email']") |> render_click() - assert_patch(view, "/members?query=&sort_field=email&sort_order=desc") + path_desc = assert_patch(view) + assert path_desc =~ "sort_field=email" + assert path_desc =~ "sort_order=desc" end test "clicking different column header resets order to ascending", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?sort_field=email&sort_order=desc") + + {:ok, view, _html} = + live(conn, "/members?fields=first_name,email&sort_field=email&sort_order=desc") # Click on a different column view |> element("[data-testid='first_name']") |> render_click() - assert_patch(view, "/members?query=&sort_field=first_name&sort_order=asc") + path = assert_patch(view) + assert path =~ "sort_field=first_name" + assert path =~ "sort_order=asc" end test "all sortable columns work correctly", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") + + {:ok, view, _html} = + live( + conn, + "/members?fields=first_name,email,street,house_number,postal_code,city,country,join_date" + ) # default ascending sorting with first name assert has_element?(view, "[data-testid='first_name'][aria-label='ascending']") @@ -209,30 +225,40 @@ defmodule MvWeb.MemberLive.IndexTest do |> element("[data-testid='#{field}']") |> render_click() - assert_patch(view, "/members?query=&sort_field=#{field}&sort_order=asc") + path = assert_patch(view) + assert path =~ "sort_field=#{field}" + assert path =~ "sort_order=asc" end end test "sorting works with search query", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?query=test") + {:ok, view, _html} = live(conn, "/members?fields=email&query=test") view |> element("[data-testid='email']") |> render_click() - assert_patch(view, "/members?query=test&sort_field=email&sort_order=asc") + path = assert_patch(view) + assert path =~ "query=test" + assert path =~ "sort_field=email" + assert path =~ "sort_order=asc" end test "sorting maintains search query when toggling order", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?query=test&sort_field=email&sort_order=asc") + + {:ok, view, _html} = + live(conn, "/members?fields=email&query=test&sort_field=email&sort_order=asc") view |> element("[data-testid='email']") |> render_click() - assert_patch(view, "/members?query=test&sort_field=email&sort_order=desc") + path = assert_patch(view) + assert path =~ "query=test" + assert path =~ "sort_field=email" + assert path =~ "sort_order=desc" end end @@ -240,7 +266,9 @@ defmodule MvWeb.MemberLive.IndexTest do @describetag :ui test "handle_params reads sort query and applies it", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?query=&sort_field=email&sort_order=desc") + + {:ok, view, _html} = + live(conn, "/members?fields=email&query=&sort_field=email&sort_order=desc") # Check that the sort state is correctly applied assert has_element?(view, "[data-testid='email'][aria-label='descending']") @@ -248,7 +276,9 @@ defmodule MvWeb.MemberLive.IndexTest do test "handle_params handles invalid sort field gracefully", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?query=&sort_field=invalid_field&sort_order=asc") + + {:ok, view, _html} = + live(conn, "/members?fields=first_name&query=&sort_field=invalid_field&sort_order=asc") # Should not crash and should show default first name order assert has_element?(view, "[data-testid='first_name'][aria-label='ascending']") @@ -256,7 +286,9 @@ defmodule MvWeb.MemberLive.IndexTest do test "handle_params preserves search query with sort params", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?query=test&sort_field=email&sort_order=desc") + + {:ok, view, _html} = + live(conn, "/members?fields=email&query=test&sort_field=email&sort_order=desc") # Both search and sort should be preserved assert has_element?(view, "[data-testid='email'][aria-label='descending']") @@ -267,7 +299,9 @@ defmodule MvWeb.MemberLive.IndexTest do @describetag :ui test "search maintains sort state", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?query=&sort_field=email&sort_order=desc") + + {:ok, view, _html} = + live(conn, "/members?fields=email&query=&sort_field=email&sort_order=desc") # Perform search view @@ -280,7 +314,9 @@ defmodule MvWeb.MemberLive.IndexTest do test "sort maintains search state", %{conn: conn} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?query=test&sort_field=email&sort_order=asc") + + {:ok, view, _html} = + live(conn, "/members?fields=email&query=test&sort_field=email&sort_order=asc") # Perform sort view @@ -288,7 +324,10 @@ defmodule MvWeb.MemberLive.IndexTest do |> render_click() # Search state should be maintained - assert_patch(view, "/members?query=test&sort_field=email&sort_order=desc") + path = assert_patch(view) + assert path =~ "query=test" + assert path =~ "sort_field=email" + assert path =~ "sort_order=desc" end end @@ -1578,7 +1617,7 @@ defmodule MvWeb.MemberLive.IndexTest do boolean_field = create_boolean_custom_field() {:ok, view, _html} = - live(conn, "/members?bf_#{boolean_field.id}=true") + live(conn, "/members?fields=email&bf_#{boolean_field.id}=true") # Test sort toggle preserves filter view From dfc616257d453428fe0f872c697b90714aa437d9 Mon Sep 17 00:00:00 2001 From: Simon Date: Mon, 6 Jul 2026 10:45:53 +0200 Subject: [PATCH 07/30] feat(member): let members tailor overview density and visible columns The View dropdown drives row density and whether the Member and Address fields render as composite cells or split into their underlying columns; the column manager toggles visibility and resets to the curated default. All choices persist per browser through the URL/session/cookie/global chain, so a saved layout survives reloads without a per-account store. --- assets/js/app.js | 42 ++- lib/mv_web/components/core_components.ex | 38 +- .../field_visibility_dropdown_component.ex | 10 +- .../live/components/sort_header_component.ex | 7 +- .../view_settings_dropdown_component.ex | 129 +++++++ lib/mv_web/live/member_live/index.ex | 348 ++++++++++++++---- lib/mv_web/live/member_live/index.html.heex | 281 ++++++++------ .../member_live/index/field_visibility.ex | 217 ++++++++++- .../live/member_live/index/view_settings.ex | 186 ++++++++++ .../components/sort_header_component_test.exs | 58 +-- .../index/field_visibility_test.exs | 50 +++ .../member_live/index/view_settings_test.exs | 109 ++++++ .../member_live/index_a11y_hardening_test.exs | 78 ++++ .../member_live/index_column_manager_test.exs | 76 ++++ .../index_default_columns_test.exs | 11 +- .../mv_web/member_live/index_density_test.exs | 73 ++++ .../index_field_visibility_test.exs | 49 ++- .../index_groups_url_params_test.exs | 12 +- .../index_member_fields_display_test.exs | 3 + test/mv_web/member_live/index_test.exs | 27 +- .../member_live/index_view_settings_test.exs | 102 +++++ 21 files changed, 1643 insertions(+), 263 deletions(-) create mode 100644 lib/mv_web/live/components/view_settings_dropdown_component.ex create mode 100644 lib/mv_web/live/member_live/index/view_settings.ex create mode 100644 test/mv_web/live/member_live/index/view_settings_test.exs create mode 100644 test/mv_web/member_live/index_a11y_hardening_test.exs create mode 100644 test/mv_web/member_live/index_column_manager_test.exs create mode 100644 test/mv_web/member_live/index_density_test.exs create mode 100644 test/mv_web/member_live/index_view_settings_test.exs diff --git a/assets/js/app.js b/assets/js/app.js index a6a7d694..85bbc6ba 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -414,15 +414,46 @@ Hooks.LoadMorePrefetch = { } } +// Reads a browser cookie value by name (used to echo the persisted member +// view-settings to the server on connect, since the live socket's connect-info +// does not expose cookies). +function getCookie(name) { + const match = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)")) + return match ? decodeURIComponent(match[1]) : null +} + let liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, params: { _csrf_token: csrfToken, - timezone: getBrowserTimezone() + timezone: getBrowserTimezone(), + view_settings: getCookie("member_view_settings") }, hooks: Hooks }) +// Persist the member-overview view settings per browser/device (§1.5/§1.20). The +// LiveView pushes "store-view-settings" (density + compact field toggles) on +// change; we write a long-lived cookie that the LiveView reads back on the next +// full page load (via the request cookie on the dead render and via connect +// params on the connected mount). +// Return the members list to the top after a sort or filter change. The server +// resets the keyset stream to page 1 on those changes and pushes this event; a +// user scrolled down would otherwise be stranded past the shorter content with +// infinite scroll not re-arming. No-op if the scroll container is absent. +window.addEventListener("phx:members:scroll-top", () => { + const el = document.getElementById("members-table-guard") + if (el) el.scrollTop = 0 +}) + +window.addEventListener("phx:store-view-settings", (e) => { + const json = e.detail && e.detail.view_settings + if (typeof json === "string" && json.length > 0) { + const maxAge = 365 * 24 * 60 * 60 + document.cookie = `member_view_settings=${encodeURIComponent(json)};path=/;max-age=${maxAge};samesite=lax` + } +}) + // Listen for custom events from LiveView window.addEventListener("phx:set-input-value", (e) => { const {id, value} = e.detail @@ -432,15 +463,6 @@ window.addEventListener("phx:set-input-value", (e) => { } }) -// Return the members list to the top after a sort or filter change. The server -// resets the keyset stream to page 1 on those changes and pushes this event; a -// user scrolled down would otherwise be stranded past the shorter content with -// infinite scroll not re-arming. No-op if the scroll container is absent. -window.addEventListener("phx:members:scroll-top", () => { - const el = document.getElementById("members-table-guard") - if (el) el.scrollTop = 0 -}) - // Show progress bar on live navigation and form submits topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) window.addEventListener("phx:page-loading-start", _info => topbar.show(300)) diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 7af829a8..2b4dcde5 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -250,7 +250,7 @@ defmodule MvWeb.CoreComponents do attr :size, :any, default: "md", - doc: "Badge size: sm | md" + doc: "Badge size: xs | sm | md" attr :sr_label, :string, default: nil, @@ -269,7 +269,7 @@ defmodule MvWeb.CoreComponents do variant_class = "badge-#{variant}" style_class = badge_style_class(style) - size_class = "badge-#{size}" + size_class = badge_size_class(size) # Outline has transparent bg in DaisyUI; add bg so it stays visible on base-200/base-300 outline_bg = if style == "outline", do: "bg-base-100", else: nil @@ -309,6 +309,13 @@ defmodule MvWeb.CoreComponents do defp badge_style_class("outline"), do: "badge-outline" defp badge_style_class(_), do: nil + # Literal strings so Tailwind's content scanner sees badge-xs / badge-sm / badge-md / badge-lg + # and does not purge them from the CSS bundle (string interpolation "badge-#{size}" would be invisible). + defp badge_size_class("xs"), do: "badge-xs" + defp badge_size_class("sm"), do: "badge-sm" + defp badge_size_class("lg"), do: "badge-lg" + defp badge_size_class(_), do: "badge-md" + @doc """ Renders a visually empty table cell with screen-reader-only text (WCAG). @@ -443,6 +450,11 @@ defmodule MvWeb.CoreComponents do attr :selected, :map, default: %{} attr :open, :boolean, default: false, doc: "Whether the dropdown is open" attr :show_select_buttons, :boolean, default: false, doc: "Show select all/none buttons" + + attr :show_reset_button, :boolean, + default: false, + doc: "Show a reset-to-default icon button next to All/None (emits reset_fields)" + attr :phx_target, :any, required: true, doc: "The LiveView/LiveComponent target for events" attr :menu_class, :string, default: nil, doc: "Additional CSS classes for the menu" attr :menu_width, :string, default: "w-64", doc: "Width class for the menu (default: w-64)" @@ -546,6 +558,19 @@ defmodule MvWeb.CoreComponents do > {gettext("None")} + @@ -938,6 +963,11 @@ defmodule MvWeb.CoreComponents do attr :sort_field, :any, default: nil, doc: "current sort field" attr :sort_order, :atom, default: nil, doc: "current sort order" + attr :size_class, :string, + default: "", + doc: + "optional DaisyUI table-size class controlling row density (e.g. table-xs, table-md); driven by the view-settings density value" + attr :sticky_header, :boolean, default: false, doc: @@ -1016,7 +1046,7 @@ defmodule MvWeb.CoreComponents do data-sticky-first-col-rows={@sticky_first_col && "true"} phx-hook={@row_click && "TableRowKeydown"} > -
+
- - {if dyn_col[:render] do - rendered = dyn_col[:render].(@row_item.(row)) + <%= if dyn_col[:custom_field].value_type == :boolean do %> + <% val = dyn_col[:render] && dyn_col[:render].(@row_item.(row)) %> + <%= cond do %> + <% val == true -> %> +
+ <.icon + name="hero-check-circle" + class="size-4 text-success" + aria-hidden="true" + /> + {gettext("Yes")} +
+ <% val == false -> %> +
+ <.icon + name="hero-x-circle" + class="size-4 text-error" + aria-hidden="true" + /> + {gettext("No")} +
+ <% true -> %> + <.empty_cell sr_text={gettext("Not specified")} /> + <% end %> + <% else %> + {if dyn_col[:render] do + rendered = dyn_col[:render].(@row_item.(row)) - if rendered == "" do - "" + if rendered == "" do + "" + else + rendered + end else - rendered - end - else - "" - end} + "" + end} + <% end %> @@ -437,8 +433,7 @@ defmodule MvWeb.StatisticsLive do class="inline-block w-2 h-2 rounded-full bg-success shrink-0" aria-hidden="true" title={gettext("Paid")} - > - + >
@@ -115,6 +116,13 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do {:noreply, assign(socket, :selected_fields, all)} end + # reset to the curated default column set (the parent owns the settings needed + # to compute the default, so it recomputes and re-applies the selection) + def handle_event("reset_fields", _params, socket) do + send(self(), {:fields_reset}) + {:noreply, socket} + end + # select none def handle_event("select_none", _params, socket) do none = diff --git a/lib/mv_web/live/components/sort_header_component.ex b/lib/mv_web/live/components/sort_header_component.ex index c4850c41..d3ab1e94 100644 --- a/lib/mv_web/live/components/sort_header_component.ex +++ b/lib/mv_web/live/components/sort_header_component.ex @@ -32,11 +32,14 @@ defmodule MvWeb.Components.SortHeaderComponent do > {@label} <%= if @sort_field == @field do %> - <.icon name={if @sort_order == :asc, do: "hero-chevron-up", else: "hero-chevron-down"} /> + <.icon + name={if @sort_order == :asc, do: "hero-chevron-up", else: "hero-chevron-down"} + class="sort-icon" + /> <% else %> <.icon name="hero-chevron-up-down" - class="opacity-40" + class="sort-icon opacity-40" /> <% end %> diff --git a/lib/mv_web/live/components/view_settings_dropdown_component.ex b/lib/mv_web/live/components/view_settings_dropdown_component.ex new file mode 100644 index 00000000..b6cbec7a --- /dev/null +++ b/lib/mv_web/live/components/view_settings_dropdown_component.ex @@ -0,0 +1,129 @@ +defmodule MvWeb.Components.ViewSettingsDropdownComponent do + @moduledoc """ + LiveComponent for the member-overview view settings (§1.20). + + Renders an icon button with a tooltip that opens a dropdown of toggles: + + * Compact mode — table row density (compact ↔ comfortable) + * Compact Member field — composite last+first name cell, with a sub-toggle + "include email" + * Compact address field — composite street / postal+city cell + + Turning a composite field off surfaces the underlying separate columns in the + table. The component is display-only: it emits `{:view_setting_toggled, key}` + to the parent LiveView, which owns and persists the settings. + + ## Props + - `:density` — `:compact` or `:comfortable` + - `:compact_member` — boolean + - `:member_include_email` — boolean + - `:compact_address` — boolean + - `:id` — component id + """ + + use MvWeb, :live_component + + @impl true + def update(assigns, socket) do + {:ok, + socket + |> assign(assigns) + |> assign_new(:open, fn -> false end)} + end + + @impl true + def render(assigns) do + ~H""" +
+ <.dropdown_menu + id="view-settings-menu" + icon="hero-cog-6-tooth" + button_label={gettext("View")} + open={@open} + phx_target={@myself} + menu_width="w-72" + testid="view-settings" + button_testid="view-settings-button" + > +
  • + {gettext("View settings")} +
  • + + <.toggle_item + setting="density" + label={gettext("Compact mode")} + checked={@density == :compact} + target={@myself} + /> + + <.toggle_item + setting="compact_member" + label={gettext("Compact Member field")} + checked={@compact_member} + target={@myself} + /> + + <%!-- The email line lives inside the composite Member cell, so this + sub-toggle is only meaningful while that cell is compact. When the + composite is off, email is its own column and this toggle is omitted. --%> +
  • + <.toggle_item + setting="member_include_email" + label={gettext("include email")} + checked={@member_include_email} + target={@myself} + /> +
  • + + <.toggle_item + setting="compact_address" + label={gettext("Compact address field")} + checked={@compact_address} + target={@myself} + /> + +
    + """ + end + + attr :setting, :string, required: true + attr :label, :string, required: true + attr :checked, :boolean, required: true + attr :target, :any, required: true + + defp toggle_item(assigns) do + ~H""" + + """ + end + + @impl true + def handle_event("toggle_dropdown", _params, socket) do + {:noreply, assign(socket, :open, !socket.assigns.open)} + end + + def handle_event("close_dropdown", _params, socket) do + {:noreply, assign(socket, :open, false)} + end + + def handle_event("toggle", %{"setting" => setting}, socket) do + send(self(), {:view_setting_toggled, String.to_existing_atom(setting)}) + {:noreply, socket} + end +end diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index f246f5ee..714efe9a 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -41,6 +41,7 @@ defmodule MvWeb.MemberLive.Index do alias MvWeb.MemberLive.Index.Formatter alias MvWeb.MemberLive.Index.MembershipFeeStatus alias MvWeb.MemberLive.Index.OverviewQuery + alias MvWeb.MemberLive.Index.ViewSettings require Ash.Query require Logger @@ -123,16 +124,31 @@ defmodule MvWeb.MemberLive.Index do # Load user field selection from session session_selection = FieldSelection.get_from_session(session) - # FIX: ensure dropdown doesn’t show duplicate fields (e.g. membership fee status twice) + # Resolve the per-browser view settings first: the columns the manager offers + # (composite vs. constituent Name/Address columns) follow these settings (§7b). + view_settings = ViewSettings.resolve(session, connect_conn(socket), connect_params(socket)) + compact_member = view_settings.compact_member + member_include_email = view_settings.member_include_email + compact_address = view_settings.compact_address + + # The dropdown offers exactly the columns applicable to the current view + # settings, so composite and constituent columns are never both listed. all_available_fields = - all_custom_fields - |> FieldVisibility.get_all_available_fields() + FieldVisibility.get_offered_fields( + all_custom_fields, + compact_member, + member_include_email, + compact_address + ) initial_selection = FieldVisibility.merge_with_global_settings( session_selection, settings, - all_custom_fields + all_custom_fields, + compact_member: compact_member, + member_include_email: member_include_email, + compact_address: compact_address ) socket = @@ -183,6 +199,7 @@ defmodule MvWeb.MemberLive.Index do |> assign(:mailto_bcc, "") |> assign(:recipient_count, 0) |> assign(:mailto_disabled?, false) + |> assign_view_settings(view_settings) |> stream_configure(:members, dom_id: &"row-#{&1.id}") |> stream(:members, []) |> assign_export_payload() @@ -385,6 +402,11 @@ defmodule MvWeb.MemberLive.Index do - `{:fields_selected, selection}` - Select all/deselect all event from FieldVisibilityDropdownComponent """ + @impl true + def handle_info({:view_setting_toggled, key}, socket) do + {:noreply, toggle_view_setting(socket, key)} + end + @impl true def handle_info({:search_changed, q}, socket) do socket = @@ -604,34 +626,10 @@ defmodule MvWeb.MemberLive.Index do @impl true def handle_info({:field_toggled, field_string, visible}, socket) do new_selection = Map.put(socket.assigns.user_field_selection, field_string, visible) - socket = update_session_field_selection(socket, new_selection) - - final_selection = - FieldVisibility.merge_with_global_settings( - new_selection, - socket.assigns.settings, - socket.assigns.all_custom_fields - ) - - visible_member_fields = - final_selection - |> FieldVisibility.get_visible_member_fields() - |> Enum.uniq() - - visible_member_fields_db = FieldVisibility.get_visible_member_fields_db(final_selection) - - visible_member_fields_computed = - FieldVisibility.get_visible_member_fields_computed(final_selection) - - visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection) socket = socket - |> assign(:user_field_selection, final_selection) - |> assign(:member_fields_visible, visible_member_fields) - |> assign(:member_fields_visible_db, visible_member_fields_db) - |> assign(:member_fields_visible_computed, visible_member_fields_computed) - |> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields)) + |> assign_field_visibility(new_selection) |> load_members() |> prepare_dynamic_cols() |> update_selection_assigns() @@ -641,35 +639,29 @@ defmodule MvWeb.MemberLive.Index do end @impl true - def handle_info({:fields_selected, selection}, socket) do - socket = update_session_field_selection(socket, selection) + def handle_info({:fields_reset}, socket) do + # Reset restores the curated default column set (§1.6): recompute the default + # visibility from the global settings and re-apply it as the selection. + {cm, ie, ca} = view_flags(socket) - final_selection = + default_selection = FieldVisibility.merge_with_global_settings( - selection, + %{}, socket.assigns.settings, - socket.assigns.all_custom_fields + socket.assigns.all_custom_fields, + compact_member: cm, + member_include_email: ie, + compact_address: ca ) - visible_member_fields = - final_selection - |> FieldVisibility.get_visible_member_fields() - |> Enum.uniq() - - visible_member_fields_db = FieldVisibility.get_visible_member_fields_db(final_selection) - - visible_member_fields_computed = - FieldVisibility.get_visible_member_fields_computed(final_selection) - - visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection) + handle_info({:fields_selected, default_selection}, socket) + end + @impl true + def handle_info({:fields_selected, selection}, socket) do socket = socket - |> assign(:user_field_selection, final_selection) - |> assign(:member_fields_visible, visible_member_fields) - |> assign(:member_fields_visible_db, visible_member_fields_db) - |> assign(:member_fields_visible_computed, visible_member_fields_computed) - |> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields)) + |> assign_field_visibility(selection) |> load_members() |> prepare_dynamic_cols() |> update_selection_assigns() @@ -697,18 +689,6 @@ defmodule MvWeb.MemberLive.Index do url_selection = FieldSelection.parse_from_url(params) final_selection = compute_final_field_selection(fields_in_url?, url_selection, socket) - visible_member_fields = - final_selection - |> FieldVisibility.get_visible_member_fields() - |> Enum.uniq() - - visible_member_fields_db = FieldVisibility.get_visible_member_fields_db(final_selection) - - visible_member_fields_computed = - FieldVisibility.get_visible_member_fields_computed(final_selection) - - visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection) - socket = socket |> maybe_update_search(params) @@ -721,11 +701,7 @@ defmodule MvWeb.MemberLive.Index do |> maybe_update_show_current_cycle(params) |> assign(:fields_in_url?, fields_in_url?) |> assign(:query, params["query"]) - |> assign(:user_field_selection, final_selection) - |> assign(:member_fields_visible, visible_member_fields) - |> assign(:member_fields_visible_db, visible_member_fields_db) - |> assign(:member_fields_visible_computed, visible_member_fields_computed) - |> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields)) + |> assign_visibility_derivations(final_selection) |> assign(:selected_member_id, parse_highlight_param(params["highlight"])) next_sig = build_signature(socket) @@ -863,8 +839,52 @@ defmodule MvWeb.MemberLive.Index do push_reload(socket, new_path) end - defp update_session_field_selection(socket, selection) do - assign(socket, :user_field_selection, selection) + # The current composite view flags; the offered column set is scoped to these. + defp view_flags(socket) do + {socket.assigns.compact_member, socket.assigns.member_include_email, + socket.assigns.compact_address} + end + + # Merges a raw selection with the global settings scoped to the current view + # settings, then assigns it together with the visibility-derived assigns. + defp assign_field_visibility(socket, selection) do + {cm, ie, ca} = view_flags(socket) + + final = + FieldVisibility.merge_with_global_settings( + selection, + socket.assigns.settings, + socket.assigns.all_custom_fields, + compact_member: cm, + member_include_email: ie, + compact_address: ca + ) + + assign_visibility_derivations(socket, final) + end + + # Assigns user_field_selection and every visibility-derived assign the table and + # export payload read from an already merged (offered-scoped) selection. + defp assign_visibility_derivations(socket, final_selection) do + visible_member_fields = + final_selection + |> FieldVisibility.get_visible_member_fields() + |> Enum.uniq() + + visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection) + + socket + |> assign(:user_field_selection, final_selection) + |> assign(:member_fields_visible, visible_member_fields) + |> assign( + :member_fields_visible_db, + FieldVisibility.get_visible_member_fields_db(final_selection) + ) + |> assign( + :member_fields_visible_computed, + FieldVisibility.get_visible_member_fields_computed(final_selection) + ) + |> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields)) end defp build_query_params(opts) when is_map(opts) do @@ -904,8 +924,14 @@ defmodule MvWeb.MemberLive.Index do end defp compute_final_field_selection(true, url_selection, socket) do + {cm, ie, ca} = view_flags(socket) + only_url = - FieldVisibility.selection_from_url_only(url_selection, socket.assigns.all_custom_fields) + FieldVisibility.selection_from_url_only(url_selection, socket.assigns.all_custom_fields, + compact_member: cm, + member_include_email: ie, + compact_address: ca + ) visible_members = FieldVisibility.get_visible_member_fields(only_url) visible_custom = FieldVisibility.get_visible_custom_fields(only_url) @@ -919,6 +945,8 @@ defmodule MvWeb.MemberLive.Index do end defp compute_final_field_selection(false, url_selection, socket) do + {cm, ie, ca} = view_flags(socket) + merged = FieldSelection.merge_sources( url_selection, @@ -929,7 +957,10 @@ defmodule MvWeb.MemberLive.Index do FieldVisibility.merge_with_global_settings( merged, socket.assigns.settings, - socket.assigns.all_custom_fields + socket.assigns.all_custom_fields, + compact_member: cm, + member_include_email: ie, + compact_address: ca ) end @@ -941,6 +972,181 @@ defmodule MvWeb.MemberLive.Index do end end + # The connect-info Plug.Conn (present on the initial dead render), used to read + # per-browser cookies such as the persisted view settings. + defp connect_conn(socket) do + case socket.private[:connect_info] do + %Plug.Conn{} = conn -> conn + _ -> nil + end + end + + # The LiveView connect params (present only on the connected mount). The client + # echoes the persisted view-settings cookie here because the connect-info map + # of a live socket does not expose cookies. + defp connect_params(socket) do + if connected?(socket), do: get_connect_params(socket), else: nil + end + + # Assigns the full view-settings map plus the derived per-setting assigns the + # template renders from. + defp assign_view_settings(socket, settings) do + socket + |> assign(:view_settings, settings) + |> assign(:density, settings.density) + |> assign(:compact_member, settings.compact_member) + |> assign(:member_include_email, settings.member_include_email) + |> assign(:compact_address, settings.compact_address) + end + + # Updates a single view setting, re-derives the template assigns and persists + # the whole settings map per browser (client writes the cookie; the connected + # mount reads it back via connect params). + defp update_view_setting(socket, key, value) do + settings = Map.put(socket.assigns.view_settings, key, value) + + socket + |> assign_view_settings(settings) + |> push_event("store-view-settings", %{view_settings: ViewSettings.to_json(settings)}) + end + + # Flips the density setting. This only re-renders the table wrapper attribute + # (row spacing token), so the streamed rows do not need to be re-rendered. + defp toggle_view_setting(socket, :density) do + new = if socket.assigns.density == :compact, do: :comfortable, else: :compact + update_view_setting(socket, :density, new) + end + + # Flips the "include email" sub-toggle. Only meaningful while the composite + # Member cell is on: it moves the email between an in-cell line (on) and a + # separate E-Mail column (off), so besides re-rendering the rows it recomputes + # which columns the manager offers and their visibility. + defp toggle_view_setting(socket, :member_include_email) do + new_value = not socket.assigns.view_settings.member_include_email + + socket + |> update_view_setting(:member_include_email, new_value) + |> apply_include_email_toggle(new_value) + |> load_members() + |> prepare_dynamic_cols() + |> update_selection_assigns() + |> push_field_selection_url() + end + + # Flips the compact Member field. Besides re-rendering the rows, this changes + # which columns the manager offers (composite "Name" vs. Vorname/Nachname/E-Mail, + # §7b). The email visibility carries over between the two representations, + # analogously to the "include email" sub-toggle: composite + include-email ⇄ the + # E-Mail column being visible. + defp toggle_view_setting(socket, :compact_member) do + new_value = not socket.assigns.view_settings.compact_member + email_visible? = email_currently_visible?(socket) + + socket + |> update_view_setting(:compact_member, new_value) + |> sync_include_email(new_value, email_visible?) + |> apply_member_toggle(new_value, email_visible?) + |> load_members() + |> prepare_dynamic_cols() + |> update_selection_assigns() + |> push_field_selection_url() + end + + # Flips the compact Address field: offers the composite "Adresse" vs. the + # separate Straße/Hausnummer/PLZ/Ort columns, making the newly relevant columns + # visible and recomputing the offered set + visibility for the new mode. + defp toggle_view_setting(socket, :compact_address) do + new_value = not socket.assigns.view_settings.compact_address + + fields = if new_value, do: [:address], else: [:street, :house_number, :postal_code, :city] + + socket + |> update_view_setting(:compact_address, new_value) + |> apply_composite_toggle(fields) + |> load_members() + |> prepare_dynamic_cols() + |> update_selection_assigns() + |> push_field_selection_url() + end + + # Whether the member email is currently surfaced: while the composite is on, + # either folded into the cell (include_email) or as a separate E-Mail column; + # while it is off, as its own column. Read before the flip, so `compact_member` + # still holds the previous mode. + defp email_currently_visible?(socket) do + if socket.assigns.compact_member do + socket.assigns.member_include_email or :email in socket.assigns.member_fields_visible + else + :email in socket.assigns.member_fields_visible + end + end + + # Turning include_email OFF surfaces the email as a separate column; make it + # visible. Turning it ON folds the email into the cell, so the column is no + # longer offered (the recompute drops it). + defp apply_include_email_toggle(socket, false = _include_email) do + selection = Map.put(socket.assigns.user_field_selection, "email", true) + recompute_offered(socket, selection) + end + + defp apply_include_email_toggle(socket, true = _include_email), + do: recompute_offered(socket, socket.assigns.user_field_selection) + + # When switching to the composite, mirror the email state onto the include-email + # sub-toggle so the email keeps being surfaced; when switching away, the + # sub-toggle is not applicable. + defp sync_include_email(socket, true = _new_compact, email_visible?), + do: update_view_setting(socket, :member_include_email, email_visible?) + + defp sync_include_email(socket, false = _new_compact, _email_visible?), do: socket + + # Makes the now-relevant Member columns visible for the new mode. Turning the + # composite on surfaces "Name" (with the email either folded via include_email + # or kept as a separate column); turning it off surfaces Vorname/Nachname and + # carries the prior email visibility onto the E-Mail column. + defp apply_member_toggle(socket, true = _new_compact, email_visible?) do + selection = + socket.assigns.user_field_selection + |> Map.put("name", true) + |> Map.put("email", email_visible?) + + recompute_offered(socket, selection) + end + + defp apply_member_toggle(socket, false = _new_compact, email_visible?) do + selection = + socket.assigns.user_field_selection + |> Map.put("first_name", true) + |> Map.put("last_name", true) + |> Map.put("email", email_visible?) + + recompute_offered(socket, selection) + end + + # Sets the given columns visible, then recomputes the offered set + visibility. + defp apply_composite_toggle(socket, fields) do + selection = + Enum.reduce(fields, socket.assigns.user_field_selection, fn field, acc -> + Map.put(acc, Atom.to_string(field), true) + end) + + recompute_offered(socket, selection) + end + + # Recomputes the offered column list and the derived visibility for the current + # mode. `update_view_setting` has already updated the compact_* assigns, so + # `view_flags/1` reflects the new mode here. + defp recompute_offered(socket, selection) do + {cm, ie, ca} = view_flags(socket) + + socket + |> assign( + :all_available_fields, + FieldVisibility.get_offered_fields(socket.assigns.all_custom_fields, cm, ie, ca) + ) + |> assign_field_visibility(selection) + end + # Parses optional "highlight" URL param (member id for selected row styling). Returns nil if missing or invalid. defp parse_highlight_param(nil), do: nil defp parse_highlight_param(""), do: nil diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index 55bf93f5..9c0db729 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -5,7 +5,6 @@ <.live_component module={MvWeb.Components.BulkActionsDropdown} id="bulk-actions-dropdown" - open={@bulk_actions_open} export_payload_json={@export_payload_json} selected_count={@selected_count} scope={@scope} @@ -72,13 +71,23 @@ - <.live_component - module={MvWeb.Components.FieldVisibilityDropdownComponent} - id="field-visibility-dropdown" - all_fields={@all_available_fields} - custom_fields={@all_custom_fields} - selected_fields={@user_field_selection} - /> +
    + <.live_component + module={MvWeb.Components.FieldVisibilityDropdownComponent} + id="field-visibility-dropdown" + all_fields={@all_available_fields} + custom_fields={@all_custom_fields} + selected_fields={@user_field_selection} + /> + <.live_component + module={MvWeb.Components.ViewSettingsDropdownComponent} + id="view-settings-dropdown" + density={@density} + compact_member={@compact_member} + member_include_email={@member_include_email} + compact_address={@compact_address} + /> +
    <%!-- Polite live region: present on first render (before it is filled) so @@ -103,6 +112,8 @@ role="region" aria-label={gettext("Members table")} aria-busy={to_string(@loading?)} + data-density={@density} + tabindex="0" > <.table id="members" @@ -122,6 +133,7 @@ dynamic_cols={@dynamic_cols} sort_field={@sort_field} sort_order={@sort_order} + size_class={if @density == :compact, do: "table-xs", else: "table-md"} > @@ -130,10 +142,14 @@ col_click={&MvWeb.MemberLive.Index.checkbox_column_click/1} label={ ~H""" - <.input + 0 and @selected_count < @total_count)} checked={@total_count > 0 and @selected_count == @total_count} aria-label={gettext("Select all members")} role="checkbox" @@ -141,9 +157,10 @@ """ } > - <.input + <:col :let={member} - :if={:name in @member_fields_visible} - label={gettext("Name")} + :if={:name in @member_fields_visible and @compact_member} + sort_field={:name} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_name} + field={:name} + label={gettext("Name")} + sort_field={@sort_field} + sort_order={@sort_order} + sort_hint={gettext("Click to sort by last name")} + /> + """ + } > <% full_name = [member.first_name, member.last_name] @@ -163,7 +193,8 @@ {full_name}
    @@ -171,29 +202,10 @@
    - <:col - :let={member} - :if={:address in @member_fields_visible} - label={gettext("Address")} - > - <% line1 = - [member.street, member.house_number] - |> Enum.reject(&(&1 in [nil, ""])) - |> Enum.join(" ") %> - <% line2 = - [member.postal_code, member.city] - |> Enum.reject(&(&1 in [nil, ""])) - |> Enum.join(" ") %> - <.maybe_value value={line1 <> line2} empty_sr_text={gettext("No address")}> -
    -
    {line1}
    -
    {line2}
    -
    - - <:col :let={member} :if={:first_name in @member_fields_visible} + sort_field={:first_name} label={ ~H""" <.live_component @@ -212,6 +224,7 @@ <:col :let={member} :if={:last_name in @member_fields_visible} + sort_field={:last_name} label={ ~H""" <.live_component @@ -230,6 +243,7 @@ <:col :let={member} :if={:email in @member_fields_visible} + sort_field={:email} label={ ~H""" <.live_component @@ -245,9 +259,117 @@ > {member.email} + <:col + :let={member} + :if={:address in @member_fields_visible and @compact_address} + sort_field={:address} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_address} + field={:address} + label={gettext("Address")} + sort_field={@sort_field} + sort_order={@sort_order} + sort_hint={gettext("Click to sort by city")} + /> + """ + } + > + <% line1 = + [member.street, member.house_number] + |> Enum.reject(&(&1 in [nil, ""])) + |> Enum.join(" ") %> + <% line2 = + [member.postal_code, member.city] + |> Enum.reject(&(&1 in [nil, ""])) + |> Enum.join(" ") %> +
    +
    {line1}
    +
    {line2}
    +
    + + <:col + :let={member} + :if={:street in @member_fields_visible} + sort_field={:street} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_street} + field={:street} + label={gettext("Street")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.street} + + <:col + :let={member} + :if={:house_number in @member_fields_visible} + sort_field={:house_number} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_house_number} + field={:house_number} + label={gettext("House Number")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.house_number} + + <:col + :let={member} + :if={:postal_code in @member_fields_visible} + sort_field={:postal_code} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_postal_code} + field={:postal_code} + label={gettext("Postal Code")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.postal_code} + + <:col + :let={member} + :if={:city in @member_fields_visible} + sort_field={:city} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_city} + field={:city} + label={gettext("City")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.city} + <:col :let={member} :if={:join_date in @member_fields_visible} + sort_field={:join_date} label={ ~H""" <.live_component @@ -266,6 +388,7 @@ <:col :let={member} :if={:exit_date in @member_fields_visible} + sort_field={:exit_date} label={ ~H""" <.live_component @@ -291,6 +414,7 @@ <:col :let={member} :if={:country in @member_fields_visible} + sort_field={:country} label={ ~H""" <.live_component @@ -306,89 +430,10 @@ > {member.country} - <:col - :let={member} - :if={:city in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_city} - field={:city} - label={gettext("City")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - <.maybe_value value={member.city} empty_sr_text={gettext("Not specified")}> - {member.city} - - - <:col - :let={member} - :if={:street in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_street} - field={:street} - label={gettext("Street")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - <.maybe_value value={member.street} empty_sr_text={gettext("Not specified")}> - {member.street} - - - <:col - :let={member} - :if={:house_number in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_house_number} - field={:house_number} - label={gettext("House Number")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - <.maybe_value value={member.house_number} empty_sr_text={gettext("Not specified")}> - {member.house_number} - - - <:col - :let={member} - :if={:postal_code in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_postal_code} - field={:postal_code} - label={gettext("Postal Code")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - <.maybe_value value={member.postal_code} empty_sr_text={gettext("Not specified")}> - {member.postal_code} - - <:col :let={member} :if={:membership_fee_start_date in @member_fields_visible} + sort_field={:membership_fee_start_date} label={ ~H""" <.live_component @@ -407,6 +452,7 @@ <:col :let={member} :if={:membership_fee_type in @member_fields_visible} + sort_field={:membership_fee_type} label={ ~H""" <.live_component @@ -443,6 +489,7 @@ <:col :let={member} :if={:groups in @member_fields_visible} + sort_field={:groups} label={ ~H""" <.live_component @@ -491,11 +538,17 @@ class="h-0" > + <%!-- The accent (background + top separator) and row height live on the + full-width footer cell (see #members-footer in app.css) so they span the + whole table edge-to-edge and match the active density. This inner element + stays pinned to the visible viewport width via StickyViewportWidth and is + transparent, so the cell accent shows through while the spinner/label stay + centered during horizontal scroll. --%>
    diff --git a/lib/mv_web/live/member_live/index/field_visibility.ex b/lib/mv_web/live/member_live/index/field_visibility.ex index 8db026d5..682b7b9f 100644 --- a/lib/mv_web/live/member_live/index/field_visibility.ex +++ b/lib/mv_web/live/member_live/index/field_visibility.ex @@ -31,7 +31,21 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do # Groups and membership_fee_type are also pseudo fields (not in member_fields(), displayed in the table). # :name and :address are composite display-only columns (Name = name + email, # Address = street/house number over postal code/city). - @pseudo_member_fields [:membership_fee_status, :membership_fee_type, :groups, :name, :address] + # Order mirrors the overview table / export column order (fee type before fee + # status), so the Columns dropdown and the table agree. + @pseudo_member_fields [:membership_fee_type, :membership_fee_status, :groups, :name, :address] + + # The composite display columns and the constituent columns they replace. The + # column manager offers exactly one variant per group, selected by the view + # settings: the composite when the matching "compact" setting is on, otherwise + # the constituents (see `offered_member_fields/2`). + @name_composite :name + @name_constituents [:first_name, :last_name, :email] + @address_composite :address + @address_constituents [:street, :house_number, :postal_code, :city] + + # Export/API may accept this as alias; must not appear in the UI options list. + @export_only_alias :payment_status # Curated default-visible column set (§1.2): the columns shown on first visit # when there is no persisted selection and no global override. Everything else @@ -52,8 +66,139 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do @spec default_visible_fields() :: [atom()] def default_visible_fields, do: MapSet.to_list(@default_visible_fields) - # Export/API may accept this as alias; must not appear in the UI options list. - @export_only_alias :payment_status + @doc """ + The curated default-visible columns for the given view settings. + + In compact mode the composite `:name` / `:address` columns are visible by + default; when a composite is switched off, its constituent columns take over + as the defaults instead (§7b). + + While the Member composite is compact, `include_email` decides where the email + lives: folded into the Member cell (`true`, no separate column), or as a + default-visible `:email` column (`false`). + """ + @spec default_visible_fields(boolean(), boolean(), boolean()) :: [atom()] + def default_visible_fields(compact_member, include_email, compact_address) do + compact_member + |> default_visible_set(include_email, compact_address) + |> MapSet.to_list() + end + + defp default_visible_set(compact_member, include_email, compact_address) do + @default_visible_fields + |> apply_name_default(compact_member, include_email) + |> apply_group_default(compact_address, @address_composite, @address_constituents) + end + + # Compact Member cell with email folded in: only the composite is default. + defp apply_name_default(set, true = _compact, true = _include_email), do: set + + # Compact Member cell without folded email: the composite plus a separate + # default-visible E-Mail column. + defp apply_name_default(set, true = _compact, false = _include_email), + do: MapSet.put(set, :email) + + # Non-compact: drop the composite and make first/last name and email default. + defp apply_name_default(set, false = _compact, _include_email) do + set + |> MapSet.delete(@name_composite) + |> MapSet.union(MapSet.new(@name_constituents)) + end + + # Compact: the composite stays the default. Non-compact: drop the composite and + # make the constituents default-visible. + defp apply_group_default(set, true, _composite, _constituents), do: set + + defp apply_group_default(set, false, composite, constituents) do + set + |> MapSet.delete(composite) + |> MapSet.union(MapSet.new(constituents)) + end + + @doc """ + Member-field atoms the column manager offers for the given view settings, in + dropdown/table order. + + The identity block always leads, regardless of whether each group is compact: + + 1. the name group — the composite `:name` when compact, otherwise its + constituents `:first_name`, `:last_name`; + 2. the `:email` column, when offered as a separate column (it is folded into + the Member cell only while the composite is compact and `include_email` is + on); + 3. the address group — the composite `:address` when compact, otherwise its + constituents `:street`, `:house_number`, `:postal_code`, `:city`. + + Everything else follows in the configured Datenfelder order. This keeps the + name group ahead of the address group even when the name group is expanded + into `:first_name`/`:last_name` while the address stays compact. + """ + @spec offered_member_fields(boolean(), boolean(), boolean()) :: [atom()] + def offered_member_fields(compact_member, include_email, compact_address) do + name_group = if compact_member, do: [@name_composite], else: [:first_name, :last_name] + email_group = if email_offered?(compact_member, include_email), do: [:email], else: [] + + address_group = + if compact_address, do: [@address_composite], else: @address_constituents + + leading = name_group ++ email_group ++ address_group + + rest = + Enum.reject(overview_member_fields(), fn field -> + field == @export_only_alias or + hidden_variant?(field, compact_member, include_email, compact_address) or + field in leading + end) + + leading ++ rest + end + + # The email is a separate offered column except when it is folded into the + # compact Member cell (compact + include_email). + defp email_offered?(compact_member, include_email), do: not (compact_member and include_email) + + # The variant of a composite group that is not offered in the current mode. + defp hidden_variant?(field, compact_member, include_email, compact_address) do + name_variant_hidden?(field, compact_member, include_email) or + address_variant_hidden?(field, compact_address) + end + + # Compact hides Vorname/Nachname (folded into the composite). The email is + # hidden only when it is folded into the cell (compact + include_email); with + # include_email off it stays offered as a separate column. Non-compact hides the + # composite in favour of the constituents. + defp name_variant_hidden?(@name_composite, compact_member, _include_email), + do: not compact_member + + defp name_variant_hidden?(:email, compact_member, include_email), + do: compact_member and include_email + + defp name_variant_hidden?(field, compact_member, _include_email) + when field in [:first_name, :last_name], + do: compact_member + + defp name_variant_hidden?(_field, _compact_member, _include_email), do: false + + defp address_variant_hidden?(@address_composite, compact_address), + do: not compact_address + + defp address_variant_hidden?(field, compact_address) + when field in [:street, :house_number, :postal_code, :city], + do: compact_address + + defp address_variant_hidden?(_field, _compact_address), do: false + + @doc """ + All fields the column manager offers for the given view settings: the offered + member fields (see `offered_member_fields/3`) followed by the custom fields. + """ + @spec get_offered_fields([struct()], boolean(), boolean(), boolean()) :: [ + atom() | String.t() + ] + def get_offered_fields(custom_fields, compact_member, include_email, compact_address) do + custom_field_names = Enum.map(custom_fields, &"custom_field_#{&1.id}") + offered_member_fields(compact_member, include_email, compact_address) ++ custom_field_names + end defp overview_member_fields do Mv.Constants.member_fields() ++ @pseudo_member_fields @@ -93,8 +238,28 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do String.t() => boolean() } def selection_from_url_only(url_selection, custom_fields) when is_map(url_selection) do - all_fields = get_all_available_fields(custom_fields) + do_selection_from_url_only(url_selection, get_all_available_fields(custom_fields)) + end + def selection_from_url_only(_, _), do: %{} + + @doc """ + Like `selection_from_url_only/2`, but scoped to the columns offered for the + given view settings (`:compact_member` / `:member_include_email` / + `:compact_address` in `opts`). + """ + @spec selection_from_url_only(%{String.t() => boolean()}, [struct()], keyword()) :: %{ + String.t() => boolean() + } + def selection_from_url_only(url_selection, custom_fields, opts) + when is_map(url_selection) and is_list(opts) do + {cm, ie, ca} = view_opts(opts) + do_selection_from_url_only(url_selection, get_offered_fields(custom_fields, cm, ie, ca)) + end + + def selection_from_url_only(_, _, _), do: %{} + + defp do_selection_from_url_only(url_selection, all_fields) do Enum.reduce(all_fields, %{}, fn field, acc -> field_string = field_to_string(field) visible = Map.get(url_selection, field_string, false) @@ -102,7 +267,13 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do end) end - def selection_from_url_only(_, _), do: %{} + defp view_opts(opts) do + { + Keyword.get(opts, :compact_member, true), + Keyword.get(opts, :member_include_email, false), + Keyword.get(opts, :compact_address, true) + } + end @doc """ Merges user field selection with global settings. @@ -135,7 +306,31 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do ) :: %{String.t() => boolean()} def merge_with_global_settings(user_selection, global_settings, custom_fields) do all_fields = get_all_available_fields(custom_fields) - global_visibility = get_global_visibility_map(global_settings, custom_fields) + do_merge(user_selection, global_settings, custom_fields, all_fields, @default_visible_fields) + end + + @doc """ + Like `merge_with_global_settings/3`, but scoped to the columns offered for the + given view settings (`:compact_member` / `:member_include_email` / + `:compact_address` in `opts`). + + Only offered columns appear in the result, so a column that is not applicable + in the current mode (e.g. `:first_name` while the composite "Name" is on) never + leaks into the visible set. Defaults follow the mode via + `default_visible_fields/2`. + """ + @spec merge_with_global_settings(%{String.t() => boolean()}, map(), [struct()], keyword()) :: + %{String.t() => boolean()} + def merge_with_global_settings(user_selection, global_settings, custom_fields, opts) + when is_list(opts) do + {cm, ie, ca} = view_opts(opts) + all_fields = get_offered_fields(custom_fields, cm, ie, ca) + default_set = default_visible_set(cm, ie, ca) + do_merge(user_selection, global_settings, custom_fields, all_fields, default_set) + end + + defp do_merge(user_selection, global_settings, custom_fields, all_fields, default_set) do + global_visibility = get_global_visibility_map(global_settings, custom_fields, default_set) Enum.reduce(all_fields, %{}, fn field, acc -> field_string = field_to_string(field) @@ -280,15 +475,15 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do def get_visible_custom_fields(_), do: [] # Gets global visibility map from settings - defp get_global_visibility_map(settings, custom_fields) do - member_visibility = get_member_field_visibility_from_settings(settings) + defp get_global_visibility_map(settings, custom_fields, default_set) do + member_visibility = get_member_field_visibility_from_settings(settings, default_set) custom_field_visibility = get_custom_field_visibility(custom_fields) Map.merge(member_visibility, custom_field_visibility) end # Gets member field visibility from settings (domain fields from settings, pseudo fields default true) - defp get_member_field_visibility_from_settings(settings) do + defp get_member_field_visibility_from_settings(settings, default_set) do visibility_config = VisibilityConfig.normalize(Map.get(settings, :member_field_visibility, %{})) @@ -297,13 +492,13 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do domain_map = Enum.reduce(domain_fields, %{}, fn field, acc -> field_string = Atom.to_string(field) - default_visibility = MapSet.member?(@default_visible_fields, field) + default_visibility = MapSet.member?(default_set, field) show_in_overview = Map.get(visibility_config, field, default_visibility) Map.put(acc, field_string, show_in_overview) end) Enum.reduce(@pseudo_member_fields, domain_map, fn field, acc -> - Map.put(acc, Atom.to_string(field), MapSet.member?(@default_visible_fields, field)) + Map.put(acc, Atom.to_string(field), MapSet.member?(default_set, field)) end) end diff --git a/lib/mv_web/live/member_live/index/view_settings.ex b/lib/mv_web/live/member_live/index/view_settings.ex new file mode 100644 index 00000000..d80a076e --- /dev/null +++ b/lib/mv_web/live/member_live/index/view_settings.ex @@ -0,0 +1,186 @@ +defmodule MvWeb.MemberLive.Index.ViewSettings do + @moduledoc """ + Resolves and persists the member-overview view settings per browser/device. + + A view-settings value is a map of four keys: + + * `:density` — `:compact` or `:comfortable` (the row-spacing / compact mode) + * `:compact_member` — composite "Member" cell (last+first name) vs separate + Vorname/Nachname columns + * `:member_include_email` — whether the email is surfaced (email line in the + composite cell, or a separate Email column when the member field is split) + * `:compact_address` — composite Address cell (street / postal+city) vs + separate Straße/PLZ/Ort columns + + Persistence is per browser (not per account), resolved in priority order from + the LiveView connect params, the session, the request cookie, and finally the + global defaults (`defaults/0`). The chosen value is written to a long-lived + cookie by a small client-side listener; on the next connected mount the client + echoes the cookie back through the socket connect params (the connect-info map + on a live socket does not expose cookies), and on the disconnected render the + cookie is read directly from the request conn. + """ + + @cookie_name "member_view_settings" + @session_key "member_view_settings" + @connect_param "view_settings" + @cookie_max_age 365 * 24 * 60 * 60 + + @defaults %{ + density: :compact, + compact_member: true, + member_include_email: false, + compact_address: true + } + + @densities [:compact, :comfortable] + @bool_keys [:compact_member, :member_include_email, :compact_address] + + @type t :: %{ + density: :compact | :comfortable, + compact_member: boolean(), + member_include_email: boolean(), + compact_address: boolean() + } + + @doc "The global default view settings used when nothing is stored." + @spec defaults() :: %{ + density: :compact, + compact_member: true, + member_include_email: false, + compact_address: true + } + def defaults, do: @defaults + + @doc "The cookie name the settings are persisted under (used by the client listener)." + @spec cookie_name() :: String.t() + def cookie_name, do: @cookie_name + + @doc "The cookie max-age in seconds (365 days)." + @spec cookie_max_age() :: 31_536_000 + def cookie_max_age, do: @cookie_max_age + + @doc """ + Parses a raw map (string or atom keys, string/boolean values) into a validated + partial settings map with atom keys. Unknown keys and invalid values are + dropped, so a caller can safely `Map.merge/2` the result over another source. + """ + @spec parse(term()) :: %{optional(atom()) => term()} + def parse(raw) when is_map(raw) do + Enum.reduce(raw, %{}, fn {key, value}, acc -> + case parse_pair(to_string(key), value) do + {k, v} -> Map.put(acc, k, v) + :error -> acc + end + end) + end + + def parse(_), do: %{} + + defp parse_pair("density", value) do + case parse_density(value) do + nil -> :error + density -> {:density, density} + end + end + + defp parse_pair(key, value) + when key in ~w(compact_member member_include_email compact_address) do + case parse_bool(value) do + nil -> :error + bool -> {String.to_existing_atom(key), bool} + end + end + + defp parse_pair(_key, _value), do: :error + + defp parse_density(value) when value in @densities, do: value + defp parse_density("compact"), do: :compact + defp parse_density("comfortable"), do: :comfortable + defp parse_density(_), do: nil + + defp parse_bool(value) when is_boolean(value), do: value + defp parse_bool("true"), do: true + defp parse_bool("false"), do: false + defp parse_bool(_), do: nil + + @doc "Reads partial settings from the LiveView session map." + @spec get_from_session(map()) :: %{optional(atom()) => term()} + def get_from_session(session) when is_map(session), + do: parse_json(Map.get(session, @session_key)) + + def get_from_session(_), do: %{} + + @doc "Reads partial settings from the request cookie header of a connect-info conn." + @spec get_from_cookie(Plug.Conn.t() | nil) :: %{optional(atom()) => term()} + def get_from_cookie(%Plug.Conn{} = conn) do + case Plug.Conn.get_req_header(conn, "cookie") do + [cookie_header | _rest] -> + cookie_header + |> parse_cookie_header() + |> Map.get(@cookie_name) + |> parse_json() + + _ -> + %{} + end + end + + def get_from_cookie(_), do: %{} + + @doc "Reads partial settings from the LiveView connect params (raw JSON string)." + @spec get_from_connect_params(map() | nil) :: %{optional(atom()) => term()} + def get_from_connect_params(params) when is_map(params), + do: parse_json(Map.get(params, @connect_param)) + + def get_from_connect_params(_), do: %{} + + @doc """ + Resolves the effective view settings for a mount, merging the sources by + priority (connect params > session > cookie), falling back per key to the + global defaults. + """ + @spec resolve(map(), Plug.Conn.t() | nil, map() | nil) :: t() + def resolve(session, conn, connect_params \\ nil) do + @defaults + |> Map.merge(get_from_cookie(conn)) + |> Map.merge(get_from_session(session)) + |> Map.merge(get_from_connect_params(connect_params)) + end + + @doc "Serializes settings to a JSON string for the client-side cookie writer." + @spec to_json(map()) :: String.t() + def to_json(settings) when is_map(settings) do + settings + |> Map.take([:density | @bool_keys]) + |> Map.new(fn + {:density, density} -> {:density, to_string(density)} + {key, value} -> {key, value} + end) + |> Jason.encode!() + end + + defp parse_json(nil), do: %{} + + defp parse_json(json) when is_binary(json) do + case Jason.decode(json) do + {:ok, decoded} -> parse(decoded) + _ -> %{} + end + end + + defp parse_json(_), do: %{} + + # Parses a cookie header string into a name => value map. + defp parse_cookie_header(cookie_header) when is_binary(cookie_header) do + cookie_header + |> String.split(";") + |> Enum.map(&String.trim/1) + |> Enum.map(&String.split(&1, "=", parts: 2)) + |> Enum.reduce(%{}, fn + [key, value], acc -> Map.put(acc, key, URI.decode(value)) + [key], acc -> Map.put(acc, key, "") + _, acc -> acc + end) + end +end diff --git a/test/mv_web/components/sort_header_component_test.exs b/test/mv_web/components/sort_header_component_test.exs index 955c3c3f..2ae3ce01 100644 --- a/test/mv_web/components/sort_header_component_test.exs +++ b/test/mv_web/components/sort_header_component_test.exs @@ -4,11 +4,25 @@ defmodule MvWeb.Components.SortHeaderComponentTest do # The curated default columns hide the individual name/address sort headers, # so these component tests make every sortable field visible via ?fields=. + # The individual name/address constituents are only *offered* when the compact + # Member / Address view settings are off, so the session opts them out of the + # composite columns before selecting the fields via ?fields=. @cols "first_name,email,street,house_number,postal_code,city,country,join_date" + # Disables the compact Member/Address composites so first_name/last_name/email + # and street/house_number/postal_code/city are offered as individual sortable + # columns (persisted per browser via the view-settings session key). + defp non_compact(conn) do + Plug.Conn.put_session( + conn, + "member_view_settings", + ~s({"compact_member":false,"compact_address":false}) + ) + end + describe "rendering" do test "renders with correct attributes", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, view, _html} = live(conn, "/members?fields=#{@cols}") # Test that the component renders with correct attributes @@ -18,7 +32,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do end test "renders all sortable headers", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, view, _html} = live(conn, "/members?fields=#{@cols}") sortable_fields = [ @@ -38,7 +52,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do end test "renders correct labels", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, view, _html} = live(conn, "/members?fields=#{@cols}") # Test specific labels @@ -50,7 +64,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do describe "sort icons" do test "shows neutral icon for specific field when not sorted", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, view, _html} = live(conn, "/members?fields=#{@cols}") # The neutral icon has the opcity class we can test for @@ -62,7 +76,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do end test "shows ascending icon for specific field when sorted ascending", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, view, html} = live(conn, "/members?fields=#{@cols}&query=&sort_field=city&sort_order=asc") @@ -85,7 +99,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do end test "shows descending icon for specific field when sorted descending", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, _view, html} = live(conn, "/members?fields=#{@cols}&query=&sort_field=email&sort_order=desc") @@ -99,7 +113,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do end test "multiple fields can have different icon states", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, view, _html} = live(conn, "/members?fields=#{@cols}&query=&sort_field=city&sort_order=asc") @@ -118,7 +132,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do end test "icon state changes correctly when clicking different fields", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, view, _html} = live(conn, "/members?fields=#{@cols}") # Start: all fields neutral except first name as default @@ -146,7 +160,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do end test "specific field shows correct icon for each sort state", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() # Test EMAIL field specifically {:ok, view, html_asc} = @@ -167,7 +181,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do end test "icon distribution shows exactly one active sort icon", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() # Test neutral state - only one field should have active sort icon {:ok, _view, html_neutral} = live(conn, "/members?fields=#{@cols}") @@ -204,7 +218,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do describe "accessibility" do test "sets aria-label correctly for unsorted state", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, view, _html} = live(conn, "/members?fields=#{@cols}") # Check aria-label for unsorted state @@ -212,7 +226,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do end test "sets aria-label correctly for ascending sort", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, view, _html} = live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=asc") @@ -222,7 +236,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do end test "sets aria-label correctly for descending sort", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, view, _html} = live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=desc") @@ -232,7 +246,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do end test "includes tooltip with correct aria-label", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, view, _html} = live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=asc") @@ -243,7 +257,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do end test "aria-labels work for all sortable fields", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, view, _html} = live(conn, "/members?fields=#{@cols}&sort_field=email&sort_order=desc") # Test aria-labels for different fields @@ -260,7 +274,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do describe "component behavior" do test "clicking triggers sort event on parent LiveView", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, view, _html} = live(conn, "/members?fields=#{@cols}") # Click on the first name sort header @@ -273,7 +287,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do end test "component handles different field types correctly", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, view, _html} = live(conn, "/members?fields=#{@cols}") # Test that different field types render correctly @@ -285,7 +299,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do describe "edge cases" do test "handles invalid sort field gracefully", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, view, html} = live(conn, "/members?fields=#{@cols}&sort_field=invalid_field&sort_order=asc") @@ -296,7 +310,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do end test "handles invalid sort order gracefully", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, view, html} = live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=invalid") @@ -307,7 +321,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do end test "handles empty sort parameters", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, view, html} = live(conn, "/members?fields=#{@cols}&sort_field=&sort_order=") # Should show neutral icons @@ -318,7 +332,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do describe "icon state transitions" do test "icon changes when sorting state changes", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, view, _html} = live(conn, "/members?fields=#{@cols}") # Start with neutral state @@ -334,7 +348,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do end test "multiple fields can be tested for icon states", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, view, html} = live(conn, "/members?fields=#{@cols}&sort_field=email&sort_order=desc") # Email should be active (descending) diff --git a/test/mv_web/live/member_live/index/field_visibility_test.exs b/test/mv_web/live/member_live/index/field_visibility_test.exs index 427cbead..728c6cf5 100644 --- a/test/mv_web/live/member_live/index/field_visibility_test.exs +++ b/test/mv_web/live/member_live/index/field_visibility_test.exs @@ -201,6 +201,56 @@ defmodule MvWeb.MemberLive.Index.FieldVisibilityTest do end end + describe "offered_member_fields/3 ordering (name group, then email, then address group, then rest)" do + test "compact member + compact address: Name, E-Mail, Adresse lead" do + result = FieldVisibility.offered_member_fields(true, false, true) + + assert Enum.take(result, 3) == [:name, :email, :address] + # the composite constituents are not offered while their composite is on + refute :first_name in result + refute :street in result + # the Datenfelder-ordered rest trails the identity block + assert index_of(result, :address) < index_of(result, :join_date) + end + + test "compact member with email folded in: no separate E-Mail column" do + result = FieldVisibility.offered_member_fields(true, true, true) + + assert Enum.take(result, 2) == [:name, :address] + refute :email in result + end + + test "non-compact member + compact address: Vorname/Nachname/E-Mail before Adresse" do + result = FieldVisibility.offered_member_fields(false, false, true) + + # the reported bug: the composite address must not jump ahead of the + # expanded name constituents + assert Enum.take(result, 4) == [:first_name, :last_name, :email, :address] + assert index_of(result, :address) < index_of(result, :join_date) + end + + test "non-compact member + non-compact address: name, email, then the address block, then rest" do + result = FieldVisibility.offered_member_fields(false, false, false) + + assert Enum.take(result, 7) == [ + :first_name, + :last_name, + :email, + :street, + :house_number, + :postal_code, + :city + ] + + # join_date and later Datenfelder columns never precede the address block + assert index_of(result, :city) < index_of(result, :join_date) + refute :name in result + refute :address in result + end + end + + defp index_of(list, elem), do: Enum.find_index(list, &(&1 == elem)) + describe "get_visible_fields/1" do test "returns only fields with true visibility" do selection = %{ diff --git a/test/mv_web/live/member_live/index/view_settings_test.exs b/test/mv_web/live/member_live/index/view_settings_test.exs new file mode 100644 index 00000000..44a86f96 --- /dev/null +++ b/test/mv_web/live/member_live/index/view_settings_test.exs @@ -0,0 +1,109 @@ +defmodule MvWeb.MemberLive.Index.ViewSettingsTest do + @moduledoc """ + §1.20 / §1.5 — the member-overview view settings (density + the compact + member/address field toggles) and their per-browser persistence chain + (connect params > session > cookie > global default). + """ + use ExUnit.Case, async: true + + alias MvWeb.MemberLive.Index.ViewSettings + + test "defaults are all-compact with the email line off" do + assert ViewSettings.defaults() == %{ + density: :compact, + compact_member: true, + member_include_email: false, + compact_address: true + } + end + + describe "parse/1" do + test "reads a partial map with string keys and string/bool values" do + assert ViewSettings.parse(%{ + "density" => "comfortable", + "compact_member" => false, + "member_include_email" => true + }) == %{ + density: :comfortable, + compact_member: false, + member_include_email: true + } + end + + test "ignores unknown keys and invalid values" do + assert ViewSettings.parse(%{"density" => "huge", "bogus" => true}) == %{} + end + + test "returns empty map for non-maps" do + assert ViewSettings.parse(nil) == %{} + assert ViewSettings.parse("x") == %{} + end + end + + describe "get_from_* sources" do + test "session JSON string" do + session = %{"member_view_settings" => ~s({"density":"comfortable"})} + assert ViewSettings.get_from_session(session) == %{density: :comfortable} + assert ViewSettings.get_from_session(%{}) == %{} + end + + test "cookie header" do + json = ~s({"compact_address":false}) + conn = %Plug.Conn{req_headers: [{"cookie", "member_view_settings=#{URI.encode(json)}"}]} + assert ViewSettings.get_from_cookie(conn) == %{compact_address: false} + assert ViewSettings.get_from_cookie(%Plug.Conn{req_headers: []}) == %{} + assert ViewSettings.get_from_cookie(nil) == %{} + end + + test "connect params (raw JSON string under view_settings)" do + params = %{"view_settings" => ~s({"density":"comfortable","compact_member":false})} + + assert ViewSettings.get_from_connect_params(params) == %{ + density: :comfortable, + compact_member: false + } + + assert ViewSettings.get_from_connect_params(nil) == %{} + end + end + + describe "resolve/3 priority connect_params > session > cookie > default" do + test "falls back to defaults when nothing is stored" do + assert ViewSettings.resolve(%{}, nil, nil) == ViewSettings.defaults() + end + + test "cookie overrides default, session overrides cookie, connect params win" do + cookie_json = ~s({"density":"comfortable","compact_member":false,"compact_address":false}) + + conn = %Plug.Conn{ + req_headers: [{"cookie", "member_view_settings=#{URI.encode(cookie_json)}"}] + } + + session = %{"member_view_settings" => ~s({"compact_member":true})} + connect_params = %{"view_settings" => ~s({"density":"compact"})} + + assert ViewSettings.resolve(session, conn, connect_params) == %{ + # from connect params (highest) + density: :compact, + # from session (overrides cookie) + compact_member: true, + # from cookie + compact_address: false, + # default + member_include_email: false + } + end + end + + test "to_json/1 round-trips through parse/1" do + settings = %{ + density: :comfortable, + compact_member: false, + member_include_email: true, + compact_address: false + } + + assert settings |> ViewSettings.to_json() |> Jason.decode!() |> ViewSettings.parse() == + settings + end +end diff --git a/test/mv_web/member_live/index_a11y_hardening_test.exs b/test/mv_web/member_live/index_a11y_hardening_test.exs new file mode 100644 index 00000000..4b6434fc --- /dev/null +++ b/test/mv_web/member_live/index_a11y_hardening_test.exs @@ -0,0 +1,78 @@ +defmodule MvWeb.MemberLive.IndexA11yHardeningTest do + @moduledoc """ + §3.5 — WCAG 2.2 AA posture: ≥24px interactive targets in both densities, + scroll-margin on rows so a focused row is not obscured by the sticky header, + select-all `indeterminate` for partial selection, and reflow markup keeping the + table in its own focusable scroll region. + """ + use MvWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + alias Mv.Helpers.SystemActor + + @moduletag :ui + + setup %{conn: conn} do + actor = SystemActor.get_system_actor() + + members = + for i <- 1..3 do + {:ok, m} = + Mv.Membership.create_member( + %{first_name: "Wcag#{i}", last_name: "Row", email: "wcag#{i}@example.com"}, + actor: actor + ) + + m + end + + %{conn: conn_with_oidc_user(conn), members: members} + end + + test "rows reserve scroll-margin under the sticky header", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/members") + assert html =~ "scroll-mt-16" + end + + test "the table lives in its own focusable scroll region that reflows", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/members") + + # Focusable region (1.4.10: table scrolls within its own focusable region). + assert html =~ ~r/data-testid="members-table-scroll"[^>]*tabindex="0"/ + # The toolbar reflows to one column on narrow viewports. + assert html =~ "flex-wrap" + end + + test "interactive row targets meet the >=24px minimum in both densities", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + # Compact (default) and comfortable both keep the >=24px checkbox target. + assert has_element?(view, "input[type='checkbox'].min-h-6.min-w-6") + + # Switch to comfortable via the view-settings dropdown. + view |> element("[data-testid='view-settings-button']") |> render_click() + view |> element("[data-testid='view-setting-density']") |> render_click() + assert has_element?(view, "input[type='checkbox'].min-h-6.min-w-6") + end + + test "select-all reflects indeterminate for a partial selection", %{ + conn: conn, + members: members + } do + {:ok, view, _html} = live(conn, ~p"/members") + + # No selection: not indeterminate. + assert has_element?(view, "#select-all-checkbox[data-indeterminate='false']") + + # Select one of several -> partial -> indeterminate. + [m | _] = members + render_click(view, "select_member", %{"id" => m.id}) + assert has_element?(view, "#select-all-checkbox[data-indeterminate='true']") + + # Select all -> fully checked, not indeterminate. + view |> element("[phx-click='select_all']") |> render_click() + assert has_element?(view, "#select-all-checkbox[data-indeterminate='false']") + assert has_element?(view, "#select-all-checkbox[checked]") + end +end diff --git a/test/mv_web/member_live/index_column_manager_test.exs b/test/mv_web/member_live/index_column_manager_test.exs new file mode 100644 index 00000000..723b8f2d --- /dev/null +++ b/test/mv_web/member_live/index_column_manager_test.exs @@ -0,0 +1,76 @@ +defmodule MvWeb.MemberLive.IndexColumnManagerTest do + @moduledoc """ + §1.6 — The column manager sits in the filter toolbar, offers a per-column + visibility toggle plus All and None controls styled as buttons (not links), + and applies changes while preserving the existing persistence chain. + """ + use MvWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + alias Mv.Helpers.SystemActor + + setup %{conn: conn} do + {:ok, _} = + Mv.Membership.create_member( + %{first_name: "Manager", last_name: "Columns", email: "manager@example.com"}, + actor: SystemActor.get_system_actor() + ) + + %{conn: conn_with_oidc_user(conn)} + end + + test "column manager trigger sits alongside the filter controls", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + # Both the filter panel trigger and the column-manager trigger render in the + # same toolbar. + assert has_element?(view, ~s(button[aria-label="Filter members"])) + assert has_element?(view, "button[aria-controls='field-visibility-menu']") + end + + test "All and None are buttons, not links, and per-column toggles are checkboxes", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + view |> element("button[aria-controls='field-visibility-menu']") |> render_click() + + # All / None render as buttons with button styling. + assert has_element?(view, "button.btn[phx-click='select_all']", "All") + assert has_element?(view, "button.btn[phx-click='select_none']", "None") + refute has_element?(view, "a[phx-click='select_all']") + refute has_element?(view, "a[phx-click='select_none']") + + # Per-column visibility toggles are accessible checkbox menu items. + assert has_element?(view, "button[role='menuitemcheckbox'][phx-value-item='name']") + end + + test "toggling a column applies and is reflected in the URL (persistence)", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + view |> element("button[aria-controls='field-visibility-menu']") |> render_click() + view |> element("button[phx-value-item='join_date']") |> render_click() + + # The selection is pushed to the URL fields param so it survives reloads. + path = assert_patch(view) + assert path =~ "fields=" + refute has_element?(view, "[data-testid='join_date']") + end + + test "a reset button restores the curated default column set", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + view |> element("button[aria-controls='field-visibility-menu']") |> render_click() + + # Reset is a button styled like All/None, sitting next to them, with a tooltip. + assert has_element?(view, "button.btn[phx-click='reset_fields']") + + # Hide a curated-default column, then reset restores it. + view |> element("button[phx-value-item='join_date']") |> render_click() + assert_patch(view) + refute has_element?(view, "[data-testid='join_date']") + + view |> element("button[phx-click='reset_fields']") |> render_click() + assert_patch(view) + assert has_element?(view, "[data-testid='join_date']") + end +end diff --git a/test/mv_web/member_live/index_default_columns_test.exs b/test/mv_web/member_live/index_default_columns_test.exs index 04d3c993..5f5545d6 100644 --- a/test/mv_web/member_live/index_default_columns_test.exs +++ b/test/mv_web/member_live/index_default_columns_test.exs @@ -1,8 +1,9 @@ defmodule MvWeb.MemberLive.IndexDefaultColumnsTest do @moduledoc """ §1.2 — With no persisted column selection, exactly the curated default columns - are visible: selection checkbox, Name (name + email), Address (composite), - fee type, fee status, groups, join date. + are visible: selection checkbox, Name (composite, no in-cell email line), + E-Mail (its own column while the compact Member field keeps the email line off), + Address (composite), fee type, fee status, groups, join date. """ use MvWeb.ConnCase, async: false @@ -34,6 +35,8 @@ defmodule MvWeb.MemberLive.IndexDefaultColumnsTest do # Composite Name + Address cells. assert has_element?(view, "[data-testid='member-name']") assert has_element?(view, "[data-testid='member-address']") + # E-Mail is its own column by default (compact Member field, email line off). + assert has_element?(view, "[data-testid='email']") # Fee type, fee status, groups, join date headers. assert has_element?(view, "[data-testid='membership_fee_type']") assert has_element?(view, "[data-testid='join_date']") @@ -44,7 +47,9 @@ defmodule MvWeb.MemberLive.IndexDefaultColumnsTest do test "the individual name/address sub-fields are hidden by default", %{conn: conn} do {:ok, view, _html} = live(conn, ~p"/members") - for field <- ~w(first_name last_name email city street house_number postal_code country) do + # The composite Name/Address cells replace their constituents by default. + # E-Mail is excluded here: it is a default-visible column of its own. + for field <- ~w(first_name last_name city street house_number postal_code country) do refute has_element?(view, "[data-testid='#{field}']") end end diff --git a/test/mv_web/member_live/index_density_test.exs b/test/mv_web/member_live/index_density_test.exs new file mode 100644 index 00000000..dac7c3a6 --- /dev/null +++ b/test/mv_web/member_live/index_density_test.exs @@ -0,0 +1,73 @@ +defmodule MvWeb.MemberLive.IndexDensityTest do + @moduledoc """ + §1.4 — The density toggle switches the table row-spacing token and reflects the + active density in the control state. + §1.5 — Density persists across reload per browser (session/cookie), with the + global default applying when nothing is stored. + """ + use MvWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + alias Mv.Helpers.SystemActor + + setup %{conn: conn} do + {:ok, _} = + Mv.Membership.create_member( + %{first_name: "Dense", last_name: "Row", email: "dense@example.com"}, + actor: SystemActor.get_system_actor() + ) + + %{conn: conn_with_oidc_user(conn)} + end + + defp open_view_settings(view) do + view |> element("[data-testid='view-settings-button']") |> render_click() + view + end + + test "defaults to compact when nothing is stored", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + assert has_element?(view, "[data-testid='members-table-scroll'][data-density='compact']") + # The compact-mode toggle reflects the active (compact) state. + open_view_settings(view) + assert has_element?(view, "[data-testid='view-setting-density'][aria-checked='true']") + end + + test "toggle switches the density token and the control state", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + open_view_settings(view) + view |> element("[data-testid='view-setting-density']") |> render_click() + + assert has_element?(view, "[data-testid='members-table-scroll'][data-density='comfortable']") + assert has_element?(view, "[data-testid='view-setting-density'][aria-checked='false']") + + # Toggling again returns to compact. + view |> element("[data-testid='view-setting-density']") |> render_click() + assert has_element?(view, "[data-testid='members-table-scroll'][data-density='compact']") + end + + test "restores a persisted density from the session on reload", %{conn: conn} do + conn = + Plug.Test.init_test_session(conn, %{ + "member_view_settings" => ~s({"density":"comfortable"}) + }) + + {:ok, view, _html} = live(conn, ~p"/members") + + assert has_element?(view, "[data-testid='members-table-scroll'][data-density='comfortable']") + end + + test "restores a persisted density on the connected mount via connect params", %{conn: conn} do + # Faithful reload round-trip: the connected mount cannot read the cookie + # (the live socket's connect-info map has no cookies), so the client echoes + # the persisted value through connect params. This is the path that was + # broken in the R1 accept (cookie written but not restored on mount). + conn = put_connect_params(conn, %{"view_settings" => ~s({"density":"comfortable"})}) + {:ok, view, _html} = live(conn, ~p"/members") + + assert has_element?(view, "[data-testid='members-table-scroll'][data-density='comfortable']") + end +end diff --git a/test/mv_web/member_live/index_field_visibility_test.exs b/test/mv_web/member_live/index_field_visibility_test.exs index 71744f83..fc1e6fa4 100644 --- a/test/mv_web/member_live/index_field_visibility_test.exs +++ b/test/mv_web/member_live/index_field_visibility_test.exs @@ -22,6 +22,17 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do require Ash.Query + # Disables the compact Member/Address composites (per-browser view setting) so + # the individual constituent columns (first_name/last_name/email, street/…) are + # offered by the column manager and available via ?fields=. + defp non_compact(conn) do + Plug.Conn.put_session( + conn, + "member_view_settings", + ~s({"compact_member":false,"compact_address":false}) + ) + end + setup do system_actor = Mv.Helpers.SystemActor.get_system_actor() @@ -112,7 +123,9 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do end test "displays all member fields in dropdown", %{conn: conn} do - conn = conn_with_oidc_user(conn) + # The individual name/address constituents are only offered when the + # compact Member/Address composites are off, so opt out of them first. + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, view, _html} = live(conn, "/members") # Open dropdown @@ -148,9 +161,9 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members") - # The curated Name column carries the email, so it is visible initially. + # The curated Name column carries the member names, so they are visible. html = render(view) - assert html =~ "alice@example.com" + assert html =~ "Anderson" # Open dropdown and hide the Name column view @@ -161,10 +174,10 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do |> element("button[phx-click='select_item'][phx-value-item='name']") |> render_click() - # The Name column (and the email it carries) is no longer visible + # The Name column (and the names it carries) is no longer visible html = render(view) - refute html =~ "alice@example.com" - refute html =~ "bob@example.com" + refute html =~ "Anderson" + refute html =~ "Brown" end test "hiding custom field removes it from display", %{conn: conn, custom_field: custom_field} do @@ -298,8 +311,8 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do conn = conn_with_oidc_user(conn) {:ok, _view, html} = live(conn, "/members") - # All fields should be visible by default - assert html =~ "alice@example.com" + # The curated Name and Address columns are visible by default. + assert html =~ "Anderson" assert html =~ "Main St" end @@ -309,7 +322,7 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members") - # Hide the curated Name column (which carries the email) via dropdown + # Hide the curated Name column (which carries the names) via dropdown view |> element("button[aria-controls='field-visibility-menu']") |> render_click() @@ -319,7 +332,7 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do |> render_click() html = render(view) - refute html =~ "alice@example.com" + refute html =~ "Anderson" end end @@ -328,16 +341,16 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do conn = conn_with_oidc_user(conn) {:ok, _view, html} = live(conn, "/members?fields=") - # Should fall back to global settings - assert html =~ "alice@example.com" + # Should fall back to global settings (curated Name column visible) + assert html =~ "Anderson" end test "handles invalid field names in URL", %{conn: conn} do conn = conn_with_oidc_user(conn) {:ok, _view, html} = live(conn, "/members?fields=invalid_field,another_invalid") - # Should ignore invalid fields and use defaults - assert html =~ "alice@example.com" + # Should ignore invalid fields and use defaults (curated Name column visible) + assert html =~ "Anderson" end test "handles custom field that doesn't exist", %{conn: conn} do @@ -429,9 +442,9 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members") - # The curated Name column carries the email, so it is visible initially. + # The curated Name column carries the names, so they are visible initially. html = render(view) - assert html =~ "alice@example.com" + assert html =~ "Anderson" # Open dropdown view @@ -443,9 +456,9 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do |> element("button[phx-click='select_item'][phx-value-item='name']") |> render_keydown(%{key: "Enter"}) - # The Name column (and the email it carries) is no longer visible + # The Name column (and the names it carries) is no longer visible html = render(view) - refute html =~ "alice@example.com" + refute html =~ "Anderson" end end end diff --git a/test/mv_web/member_live/index_groups_url_params_test.exs b/test/mv_web/member_live/index_groups_url_params_test.exs index 8b92cb90..3de60923 100644 --- a/test/mv_web/member_live/index_groups_url_params_test.exs +++ b/test/mv_web/member_live/index_groups_url_params_test.exs @@ -20,6 +20,16 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do require Ash.Query + # Disables the compact Member composite (per-browser view setting) so the + # individual first_name column is offered and selectable via ?fields=. + defp non_compact(conn) do + Plug.Conn.put_session( + conn, + "member_view_settings", + ~s({"compact_member":false,"compact_address":false}) + ) + end + setup do system_actor = Mv.Helpers.SystemActor.get_system_actor() @@ -122,7 +132,7 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do member1: member1, group1: group1 } do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, view, html} = live( diff --git a/test/mv_web/member_live/index_member_fields_display_test.exs b/test/mv_web/member_live/index_member_fields_display_test.exs index 6c222276..7b1785bf 100644 --- a/test/mv_web/member_live/index_member_fields_display_test.exs +++ b/test/mv_web/member_live/index_member_fields_display_test.exs @@ -38,6 +38,9 @@ defmodule MvWeb.MemberLive.IndexMemberFieldsDisplayTest do conn = conn_with_oidc_user(conn) {:ok, _view, html} = live(conn, "/members") + # The composite Member cell carries first + last name; the in-cell email + # line stays off by default, but the email is surfaced in its own column + # (compact Member field, include-email off). for m <- [m1, m2], field <- [m.first_name, m.last_name, m.email] do assert html =~ field end diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index e1de77a8..14d76a82 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -13,6 +13,19 @@ defmodule MvWeb.MemberLive.IndexTest do alias Mv.Membership.CustomFieldValue alias MvWeb.MemberLive.Index, as: MemberIndex + # Disables the compact Member/Address composites (per-browser view setting) so + # the individual constituent columns (first_name/last_name, street/…) are + # offered by the column manager and selectable via ?fields=. The composite + # E-Mail column is offered even in compact mode, so email-only tests do not + # need this. + defp non_compact(conn) do + Plug.Conn.put_session( + conn, + "member_view_settings", + ~s({"compact_member":false,"compact_address":false}) + ) + end + describe "desktop layout: scroll container and sticky table header" do @describetag :ui @@ -155,9 +168,11 @@ defmodule MvWeb.MemberLive.IndexTest do describe "sorting integration" do @describetag :ui # The curated default columns no longer expose the individual name/address - # sort headers, so these tests make the relevant column visible via ?fields=. - # That puts a `fields` param on every push_patch, hence the relaxed - # substring assertions instead of exact patch strings. + # sort headers. The composite E-Mail column is offered even in compact mode + # (so email-only tests just add it via ?fields=), but the name/address + # constituents are only offered once their composite view setting is off — + # those tests additionally opt out via `non_compact/1`. Either way a `fields` + # param rides on every push_patch, hence the relaxed substring assertions. test "clicking a column header toggles sort order and updates the URL", %{conn: conn} do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members?fields=email") @@ -183,7 +198,7 @@ defmodule MvWeb.MemberLive.IndexTest do end test "clicking different column header resets order to ascending", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, view, _html} = live(conn, "/members?fields=first_name,email&sort_field=email&sort_order=desc") @@ -199,7 +214,7 @@ defmodule MvWeb.MemberLive.IndexTest do end test "all sortable columns work correctly", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, view, _html} = live( @@ -275,7 +290,7 @@ defmodule MvWeb.MemberLive.IndexTest do end test "handle_params handles invalid sort field gracefully", %{conn: conn} do - conn = conn_with_oidc_user(conn) + conn = conn |> conn_with_oidc_user() |> non_compact() {:ok, view, _html} = live(conn, "/members?fields=first_name&query=&sort_field=invalid_field&sort_order=asc") diff --git a/test/mv_web/member_live/index_view_settings_test.exs b/test/mv_web/member_live/index_view_settings_test.exs new file mode 100644 index 00000000..483a8214 --- /dev/null +++ b/test/mv_web/member_live/index_view_settings_test.exs @@ -0,0 +1,102 @@ +defmodule MvWeb.MemberLive.IndexViewSettingsTest do + @moduledoc """ + §1.20 — the view-settings dropdown toggles: the composite "Member" and address + fields, the "include email" sub-toggle and their effect on the rendered + columns. Turning a composite off surfaces the underlying separate columns. + §1.2 — email is not in the Member cell by default. + """ + use MvWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + alias Mv.Helpers.SystemActor + + setup %{conn: conn} do + {:ok, _} = + Mv.Membership.create_member( + %{ + first_name: "Ada", + last_name: "Lovelace", + email: "ada@example.com", + street: "Baker Street", + house_number: "221", + postal_code: "10115", + city: "Berlin" + }, + actor: SystemActor.get_system_actor() + ) + + %{conn: conn_with_oidc_user(conn)} + end + + defp open_view_settings(view) do + view |> element("[data-testid='view-settings-button']") |> render_click() + view + end + + test "member cell hides the email line by default", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + assert has_element?(view, "[data-testid='member-name']") + refute has_element?(view, "[data-testid='member-name-email']") + end + + test "the include-email sub-toggle surfaces the email line in the member cell", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + open_view_settings(view) + view |> element("[data-testid='view-setting-member-include-email']") |> render_click() + + assert has_element?(view, "[data-testid='member-name-email']", "ada@example.com") + end + + test "turning off the compact member field shows separate name columns", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + open_view_settings(view) + view |> element("[data-testid='view-setting-compact-member']") |> render_click() + + refute has_element?(view, "[data-testid='member-name']") + assert has_element?(view, "[data-testid='first_name']") + assert has_element?(view, "[data-testid='last_name']") + # With the composite off, the email is surfaced as its own column. + assert has_element?(view, "[data-testid='email']") + end + + test "with the compact member field off, the include-email sub-toggle is hidden", + %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + open_view_settings(view) + # While the composite Member field is on, the sub-toggle is available. + assert has_element?(view, "[data-testid='view-setting-member-include-email']") + + view |> element("[data-testid='view-setting-compact-member']") |> render_click() + + # Once the composite is off, the email is a separate column, so the + # in-cell "include email" sub-toggle no longer applies and is hidden. + refute has_element?(view, "[data-testid='view-setting-member-include-email']") + assert has_element?(view, "[data-testid='email']") + end + + test "the compact address field renders a composite address cell by default", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + assert has_element?(view, "[data-testid='member-address']") + refute has_element?(view, "[data-testid='street']") + refute has_element?(view, "[data-testid='postal_code']") + refute has_element?(view, "[data-testid='city']") + end + + test "turning off the compact address field shows separate address columns", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + open_view_settings(view) + view |> element("[data-testid='view-setting-compact-address']") |> render_click() + + refute has_element?(view, "[data-testid='member-address']") + assert has_element?(view, "[data-testid='street']") + assert has_element?(view, "[data-testid='postal_code']") + assert has_element?(view, "[data-testid='city']") + end +end From 0c375234c87c7a7433c6182f3d31bdda913f2b6d Mon Sep 17 00:00:00 2001 From: Simon Date: Mon, 6 Jul 2026 10:48:28 +0200 Subject: [PATCH 08/30] feat(member): make overview columns sortable through accessible sort headers --- assets/css/app.css | 13 +++ assets/js/app.js | 66 +++++++++++ lib/mv_web/components/core_components.ex | 18 ++- .../live/components/sort_header_component.ex | 69 +++++++----- lib/mv_web/live/member_live/index.ex | 23 ++-- lib/mv_web/live/member_live/index.html.heex | 35 ++++-- .../live/member_live/index/overview_query.ex | 16 +++ .../member_live/index_sort_a11y_test.exs | 55 +++++++++ .../index_sort_name_address_test.exs | 106 ++++++++++++++++++ 9 files changed, 350 insertions(+), 51 deletions(-) create mode 100644 test/mv_web/member_live/index_sort_a11y_test.exs create mode 100644 test/mv_web/member_live/index_sort_name_address_test.exs diff --git a/assets/css/app.css b/assets/css/app.css index 37091909..7a828ff3 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -957,3 +957,16 @@ margin-bottom: 0; } +/* + * Sort-header tooltips must render on top of the pinned checkbox column's + * horizontal-scroll fade (::after on .sticky-first-col-th). The hovered/focused + * header th is already lifted above the checkbox th (hover/focus-within:z-50), + * so the whole tooltip wins the stacking; this rule additionally keeps the + * daisyUI tooltip bubble's own pseudo-elements above the fade overlay. Scoped + * to the member overview header so no unrelated tooltip is affected. + */ +#members-keyboard thead .tooltip::before, +#members-keyboard thead .tooltip::after { + z-index: 60; +} + diff --git a/assets/js/app.js b/assets/js/app.js index 85bbc6ba..91172948 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -336,6 +336,72 @@ Hooks.SortableList = { } } +// SortTooltip hook: shows the sort-header hint in the browser TOP LAYER via the +// native Popover API, so it escapes the members table's overflow clipping +// (overflow-x:auto forces overflow-y:auto, which clips a CSS tooltip below the +// header) and paints above the sticky header, neighbor cells, and the pinned +// checkbox column's fade — no z-index fight. The popover is shown on hover AND +// keyboard focus and positioned next to the header with getBoundingClientRect +// (portable; CSS anchor-positioning is not yet supported in Safari/Firefox). +Hooks.SortTooltip = { + mounted() { + this.tip = document.getElementById(this.el.dataset.tooltipId) + // Feature-detect: without Popover API support fall back to no visual tooltip + // (the button's aria-label still conveys the hint to assistive tech). + if (!this.tip || typeof this.tip.showPopover !== "function") return + + this.position = () => { + const btn = this.el.getBoundingClientRect() + const tip = this.tip.getBoundingClientRect() + const gap = 6 + // Prefer just below the header; flip above when there is no room below. + let top = btn.bottom + gap + if (top + tip.height > window.innerHeight && btn.top - gap - tip.height >= 0) { + top = btn.top - gap - tip.height + } + // Left-align to the header, but keep the whole bubble on-screen. + let left = btn.left + const maxLeft = window.innerWidth - tip.width - gap + if (left > maxLeft) left = maxLeft + if (left < gap) left = gap + this.tip.style.top = `${Math.round(top)}px` + this.tip.style.left = `${Math.round(left)}px` + } + + this.show = () => { + try { + if (!this.tip.matches(":popover-open")) this.tip.showPopover() + // showPopover() makes it measurable; positioning in the same tick avoids + // a visible flash (JS runs before the browser paints). + this.position() + } catch (_e) {} + } + + this.hide = () => { + try { + if (this.tip.matches(":popover-open")) this.tip.hidePopover() + } catch (_e) {} + } + + this.onKey = (e) => { if (e.key === "Escape") this.hide() } + + this.el.addEventListener("mouseenter", this.show) + this.el.addEventListener("focus", this.show) + this.el.addEventListener("mouseleave", this.hide) + this.el.addEventListener("blur", this.hide) + this.el.addEventListener("keydown", this.onKey) + }, + + destroyed() { + if (this.hide) this.hide() + this.el.removeEventListener("mouseenter", this.show) + this.el.removeEventListener("focus", this.show) + this.el.removeEventListener("mouseleave", this.hide) + this.el.removeEventListener("blur", this.hide) + this.el.removeEventListener("keydown", this.onKey) + } +} + // SidebarState hook: Manages sidebar expanded/collapsed state Hooks.SidebarState = { mounted() { diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 2b4dcde5..76c44b1c 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -1061,7 +1061,17 @@ defmodule MvWeb.CoreComponents do > {col[:label]}
    + <.live_component module={MvWeb.Components.SortHeaderComponent} id={:"sort_custom_field_#{dyn_col[:custom_field].id}"} @@ -1240,8 +1250,8 @@ defmodule MvWeb.CoreComponents do end # Combines column class with optional sticky header classes (desktop only; theme-friendly bg). - # hover:z-20/focus-within:z-20 lift the active header cell above sibling sticky cells (shared z-10) - # so its hover tooltip bubble is not clipped by an adjacent opaque bg-base-100 header background. + # bg-base-100 + z-10 keep the sticky header opaque and above body rows on vertical scroll. + # Sort tooltips use the Popover API (top layer) and need no header z-lift. defp table_th_class(col, sticky_header) do base = Map.get(col, :class) sticky = if sticky_header, do: sticky_th_classes(), else: nil @@ -1253,7 +1263,7 @@ defmodule MvWeb.CoreComponents do defp table_th_sticky_class(_), do: nil defp sticky_th_classes, - do: "lg:sticky lg:top-0 bg-base-100 hover:bg-base-100 z-10 hover:z-20 focus-within:z-20" + do: "lg:sticky lg:top-0 bg-base-100 z-10" @doc """ Renders a reorderable table (sortable list) with drag handle and keyboard support. diff --git a/lib/mv_web/live/components/sort_header_component.ex b/lib/mv_web/live/components/sort_header_component.ex index d3ab1e94..a3470a75 100644 --- a/lib/mv_web/live/components/sort_header_component.ex +++ b/lib/mv_web/live/components/sort_header_component.ex @@ -6,44 +6,57 @@ defmodule MvWeb.Components.SortHeaderComponent do - label: string() # Column Heading (can be an heex template) - sort_field: atom() | nil # current sort field from parent liveview - sort_order: :asc | :desc | nil # current sorting order + - sort_hint: string() | nil # optional; overrides the generic "click + # to sort" tooltip/aria-label for columns + # whose sort key is non-obvious (e.g. the + # Name column sorts by last name) """ use MvWeb, :live_component @impl true def update(assigns, socket) do - {:ok, assign(socket, assigns)} + {:ok, socket |> assign(assigns) |> assign_new(:sort_hint, fn -> nil end)} end # Check if we can add the aria-sort label directly to the daisyUI header # aria-sort={aria_sort(@field, @sort_field, @sort_order)} @impl true def render(assigns) do + assigns = + assigns + |> assign(:tooltip_id, "sort-tooltip-#{assigns.field}") + |> assign(:hint, assigns.sort_hint || gettext("Click to sort")) + ~H"""
    - <.tooltip content={aria_sort(@field, @sort_field, @sort_order)} position="bottom"> - <.button - type="button" - variant="ghost" - aria-label={aria_sort(@field, @sort_field, @sort_order)} - class="select-none" - phx-click="sort" - phx-value-field={@field} - data-testid={@field} - > - {@label} - <%= if @sort_field == @field do %> - <.icon - name={if @sort_order == :asc, do: "hero-chevron-up", else: "hero-chevron-down"} - class="sort-icon" - /> - <% else %> - <.icon - name="hero-chevron-up-down" - class="sort-icon opacity-40" - /> - <% end %> - - + +
    """ end @@ -51,13 +64,13 @@ defmodule MvWeb.Components.SortHeaderComponent do # ------------------------------------------------- # Hilfsfunktionen für ARIA Attribute & Icon SVG # ------------------------------------------------- - defp aria_sort(field, sort_field, dir) when field == sort_field do + defp aria_sort(field, sort_field, dir, hint) when field == sort_field do case dir do :asc -> gettext("ascending") :desc -> gettext("descending") - nil -> gettext("Click to sort") + nil -> hint end end - defp aria_sort(_, _, _), do: gettext("Click to sort") + defp aria_sort(_, _, _, hint), do: hint end diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 714efe9a..deea80df 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -1450,7 +1450,9 @@ defmodule MvWeb.MemberLive.Index do defp valid_sort_field_db_or_custom?(field) when is_atom(field) do non_sortable_fields = [:notes] valid_fields = Mv.Constants.member_fields() -- non_sortable_fields - field in valid_fields or custom_field_sort?(field) or field in [:groups, :membership_fee_type] + + field in valid_fields or custom_field_sort?(field) or + field in [:groups, :membership_fee_type, :name, :address] end defp valid_sort_field_db_or_custom?(field) when is_binary(field) do @@ -1458,6 +1460,8 @@ defmodule MvWeb.MemberLive.Index do cond do field == "groups" -> :groups field == "membership_fee_type" -> :membership_fee_type + field == "name" -> :name + field == "address" -> :address true -> safe_member_field_atom_only(field) end @@ -1508,10 +1512,16 @@ defmodule MvWeb.MemberLive.Index do defp determine_field(default, ""), do: default defp determine_field(default, nil), do: default + # Computed/pseudo fields that are nonetheless sortable (they resolve to DB + # sort keys in OverviewQuery): groups aggregate and the composite Name/Address + # columns. Other computed fields (e.g. membership_fee_status) are not sortable. + @sortable_computed_fields [:groups, :name, :address] + defp determine_field(default, sf) when is_binary(sf) do - # Handle "groups" specially - it's in computed_member_fields() but can be sorted - if sf == "groups" do - :groups + sortable_strings = Enum.map(@sortable_computed_fields, &Atom.to_string/1) + + if sf in sortable_strings do + String.to_existing_atom(sf) else computed_strings = Enum.map(FieldVisibility.computed_member_fields(), &Atom.to_string/1) @@ -1522,9 +1532,8 @@ defmodule MvWeb.MemberLive.Index do end defp determine_field(default, sf) when is_atom(sf) do - # Handle :groups specially - it's in computed_member_fields() but can be sorted - if sf == :groups do - :groups + if sf in @sortable_computed_fields do + sf else if sf in FieldVisibility.computed_member_fields(), do: default, diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index 9c0db729..7709c2cb 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -5,6 +5,7 @@ <.live_component module={MvWeb.Components.BulkActionsDropdown} id="bulk-actions-dropdown" + open={@bulk_actions_open} export_payload_json={@export_payload_json} selected_count={@selected_count} scope={@scope} @@ -52,7 +53,9 @@ <.button type="button" variant="secondary" - class={["gap-2", @show_current_cycle && "btn-active"]} + class="gap-2" + active={@show_current_cycle} + aria-pressed={to_string(@show_current_cycle)} phx-click="toggle_cycle_view" data-testid="toggle-cycle-view" aria-label={ @@ -135,8 +138,6 @@ sort_order={@sort_order} size_class={if @density == :compact, do: "table-xs", else: "table-md"} > - - <:col :let={member} col_click={&MvWeb.MemberLive.Index.checkbox_column_click/1} @@ -285,10 +286,12 @@ [member.postal_code, member.city] |> Enum.reject(&(&1 in [nil, ""])) |> Enum.join(" ") %> -
    -
    {line1}
    -
    {line2}
    -
    + <.maybe_value value={line1 <> line2} empty_sr_text={gettext("No address")}> +
    +
    {line1}
    +
    {line2}
    +
    + <:col :let={member} @@ -307,7 +310,9 @@ """ } > - {member.street} + <.maybe_value value={member.street} empty_sr_text={gettext("Not specified")}> + {member.street} + <:col :let={member} @@ -326,7 +331,9 @@ """ } > - {member.house_number} + <.maybe_value value={member.house_number} empty_sr_text={gettext("Not specified")}> + {member.house_number} + <:col :let={member} @@ -345,7 +352,9 @@ """ } > - {member.postal_code} + <.maybe_value value={member.postal_code} empty_sr_text={gettext("Not specified")}> + {member.postal_code} + <:col :let={member} @@ -364,7 +373,9 @@ """ } > - {member.city} + <.maybe_value value={member.city} empty_sr_text={gettext("Not specified")}> + {member.city} + <:col :let={member} @@ -551,7 +562,7 @@ class="sticky left-0 flex items-center justify-center gap-2 text-base-content/70" data-testid="members-loading-row" > - + <.spinner size="sm" aria-hidden="true" /> {gettext("Loading more members …")} diff --git a/lib/mv_web/live/member_live/index/overview_query.ex b/lib/mv_web/live/member_live/index/overview_query.ex index bf4ef85c..ac3051f0 100644 --- a/lib/mv_web/live/member_live/index/overview_query.ex +++ b/lib/mv_web/live/member_live/index/overview_query.ex @@ -254,6 +254,22 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do defp apply_sort(query, nil, _order, _custom_fields), do: Ash.Query.sort(query, id: :asc) defp apply_sort(query, _field, nil, _custom_fields), do: Ash.Query.sort(query, id: :asc) + # Composite "Member" column: sort by last name then first name. The email that + # shares the cell is display-only and does not affect the sort key (§1.21). + defp apply_sort(query, field, order, _custom_fields) when field in [:name, "name"] do + Ash.Query.sort(query, [{:last_name, order}, {:first_name, order}, {:id, :asc}]) + end + + # Composite "Address" column: sort by city, then postal code, then street. + defp apply_sort(query, field, order, _custom_fields) when field in [:address, "address"] do + Ash.Query.sort(query, [ + {:city, order}, + {:postal_code, order}, + {:street, order}, + {:id, :asc} + ]) + end + defp apply_sort(query, field, order, custom_fields) do case sort_key(field) do nil -> apply_custom_field_sort(query, field, order, custom_fields) diff --git a/test/mv_web/member_live/index_sort_a11y_test.exs b/test/mv_web/member_live/index_sort_a11y_test.exs new file mode 100644 index 00000000..fa032ee3 --- /dev/null +++ b/test/mv_web/member_live/index_sort_a11y_test.exs @@ -0,0 +1,55 @@ +defmodule MvWeb.MemberLive.IndexSortA11yTest do + @moduledoc """ + §1.11 — When a column is sorted, exactly one `th` carries `aria-sort` with the + correct direction, a non-color sort glyph is present, and the sort is reflected + in the URL. + """ + use MvWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + alias Mv.Helpers.SystemActor + + @moduletag :ui + + setup %{conn: conn} do + {:ok, _} = + Mv.Membership.create_member( + %{first_name: "Sortable", last_name: "Member", email: "sortable@example.com"}, + actor: SystemActor.get_system_actor() + ) + + %{conn: conn_with_oidc_user(conn)} + end + + defp aria_sort_count(html) do + ~r/aria-sort="/ |> Regex.scan(html) |> length() + end + + test "exactly one th carries aria-sort ascending with a non-color glyph", %{conn: conn} do + {:ok, view, html} = live(conn, ~p"/members?sort_field=join_date&sort_order=asc") + + assert aria_sort_count(html) == 1 + assert has_element?(view, "th[aria-sort='ascending']") + # Non-color shape glyph for the active ascending sort. + assert html =~ "hero-chevron-up" + end + + test "aria-sort reflects descending and stays unique", %{conn: conn} do + {:ok, view, html} = live(conn, ~p"/members?sort_field=join_date&sort_order=desc") + + assert aria_sort_count(html) == 1 + assert has_element?(view, "th[aria-sort='descending']") + assert html =~ "hero-chevron-down" + end + + test "clicking a header reflects the sort in the URL", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + view |> element("[data-testid='join_date']") |> render_click() + + path = assert_patch(view) + assert path =~ "sort_field=join_date" + assert path =~ "sort_order=asc" + end +end diff --git a/test/mv_web/member_live/index_sort_name_address_test.exs b/test/mv_web/member_live/index_sort_name_address_test.exs new file mode 100644 index 00000000..519cebc6 --- /dev/null +++ b/test/mv_web/member_live/index_sort_name_address_test.exs @@ -0,0 +1,106 @@ +defmodule MvWeb.MemberLive.IndexSortNameAddressTest do + @moduledoc """ + §1.21 — the composite Member (Name) and Address columns are sortable DB-side: + Name by last name → first name (email in the cell is display-only), Address by + city → postal code → street. Ascending/descending, reflected in the URL and in + `aria-sort` (§1.11). + """ + use MvWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + alias Mv.Helpers.SystemActor + alias MvWeb.MemberLive.Index.OverviewQuery + + setup %{conn: conn} do + actor = SystemActor.get_system_actor() + + # Same last name, different first name — proves the first-name tie-breaker. + {:ok, _} = + Mv.Membership.create_member( + %{ + first_name: "Bruno", + last_name: "Meyer", + email: "z-bruno@example.com", + city: "Aachen", + postal_code: "52062", + street: "Zebra" + }, + actor: actor + ) + + {:ok, _} = + Mv.Membership.create_member( + %{ + first_name: "Anna", + last_name: "Meyer", + email: "a-anna@example.com", + city: "Zwickau", + postal_code: "08056", + street: "Alpha" + }, + actor: actor + ) + + %{conn: conn_with_oidc_user(conn), actor: actor} + end + + defp read_names(query, actor) do + query + |> Ash.read!(actor: actor) + |> Enum.map(&{&1.last_name, &1.first_name}) + end + + test "name sort orders by last name then first name, ignoring email", %{actor: actor} do + asc = OverviewQuery.build(%{sort_field: :name, sort_order: :asc}) |> read_names(actor) + + # Both share last name "Meyer"; the first-name tie-breaker orders Anna before + # Bruno, even though Bruno's email sorts first alphabetically. + assert asc == [{"Meyer", "Anna"}, {"Meyer", "Bruno"}] + + desc = OverviewQuery.build(%{sort_field: :name, sort_order: :desc}) |> read_names(actor) + assert desc == [{"Meyer", "Bruno"}, {"Meyer", "Anna"}] + end + + test "address sort orders by city, then postal code, then street", %{actor: actor} do + asc = + OverviewQuery.build(%{sort_field: :address, sort_order: :asc}) + |> Ash.read!(actor: actor) + |> Enum.map(& &1.city) + + assert asc == ["Aachen", "Zwickau"] + + desc = + OverviewQuery.build(%{sort_field: :address, sort_order: :desc}) + |> Ash.read!(actor: actor) + |> Enum.map(& &1.city) + + assert desc == ["Zwickau", "Aachen"] + end + + test "clicking the Name header sorts and reflects state in URL and aria-sort", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + view |> element("button[phx-click='sort'][data-testid='name']") |> render_click() + + path = assert_patch(view) + assert path =~ "sort_field=name" + # Exactly one column carries aria-sort (§1.11). + assert has_element?(view, "th[aria-sort='ascending']") + assert render(view) |> aria_sort_count() == 1 + end + + test "clicking the Address header sorts and reflects state in URL", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/members") + + view |> element("button[phx-click='sort'][data-testid='address']") |> render_click() + + path = assert_patch(view) + assert path =~ "sort_field=address" + assert has_element?(view, "th[aria-sort='ascending']") + end + + defp aria_sort_count(html) do + html |> String.split("aria-sort=") |> length() |> Kernel.-(1) + end +end From c2cb3edab85e39a172addbe155ec41e6ccc624a0 Mon Sep 17 00:00:00 2001 From: Simon Date: Mon, 6 Jul 2026 10:49:10 +0200 Subject: [PATCH 09/30] feat(member): bring the overview table up to WCAG 2.2 AA --- assets/css/app.css | 13 ---- assets/js/app.js | 16 +++++ lib/mv_web/components/core_components.ex | 67 ++++++++++++++----- lib/mv_web/live/member_live/index.ex | 11 ++- .../member_live/index_a11y_hardening_test.exs | 9 ++- .../member_live/index_sticky_pinned_test.exs | 14 ++-- 6 files changed, 91 insertions(+), 39 deletions(-) diff --git a/assets/css/app.css b/assets/css/app.css index 7a828ff3..37091909 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -957,16 +957,3 @@ margin-bottom: 0; } -/* - * Sort-header tooltips must render on top of the pinned checkbox column's - * horizontal-scroll fade (::after on .sticky-first-col-th). The hovered/focused - * header th is already lifted above the checkbox th (hover/focus-within:z-50), - * so the whole tooltip wins the stacking; this rule additionally keeps the - * daisyUI tooltip bubble's own pseudo-elements above the fade overlay. Scoped - * to the member overview header so no unrelated tooltip is affected. - */ -#members-keyboard thead .tooltip::before, -#members-keyboard thead .tooltip::after { - z-index: 60; -} - diff --git a/assets/js/app.js b/assets/js/app.js index 91172948..90e36102 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -36,6 +36,22 @@ function getBrowserTimezone() { // Hooks for LiveView components let Hooks = {} +// IndeterminateCheckbox: the `indeterminate` state of a checkbox is a JS +// property, not an HTML attribute, so it cannot be set from the server render. +// Mirror it from the data-indeterminate attribute on mount and every update +// (e.g. select-all reflecting a partial selection — WCAG select-all semantics). +Hooks.IndeterminateCheckbox = { + updateIndeterminate() { + this.el.indeterminate = this.el.dataset.indeterminate === "true" + }, + mounted() { + this.updateIndeterminate() + }, + updated() { + this.updateIndeterminate() + } +} + // CopyToClipboard hook: Copies text to clipboard when triggered by server event Hooks.CopyToClipboard = { mounted() { diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 76c44b1c..f8b95f93 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -1093,12 +1093,17 @@ defmodule MvWeb.CoreComponents do
    @@ -448,8 +443,7 @@ defmodule MvWeb.StatisticsLive do class="inline-block w-2 h-2 rounded-full bg-warning shrink-0" aria-hidden="true" title={gettext("Unpaid")} - > - + > @@ -459,8 +453,7 @@ defmodule MvWeb.StatisticsLive do class="inline-block w-2 h-2 rounded-full bg-base-content/20 shrink-0" aria-hidden="true" title={gettext("Suspended")} - > - + > diff --git a/lib/mv_web/live/user_live/form.ex b/lib/mv_web/live/user_live/form.ex index 2d295921..910b3d34 100644 --- a/lib/mv_web/live/user_live/form.ex +++ b/lib/mv_web/live/user_live/form.ex @@ -92,8 +92,8 @@ defmodule MvWeb.UserLive.Form do /> <% end %> - - + +
    - - + + <%= if @can_manage_member_linking do %>

    {gettext("Linked Member")}

    From 08bae3ce55fea04670e0c65f64081e2b100474f1 Mon Sep 17 00:00:00 2001 From: Simon Date: Mon, 6 Jul 2026 19:51:26 +0200 Subject: [PATCH 18/30] chore: disable mix hex.audit as long as unfixable security advisories can't be skipped --- .drone.jsonnet | 6 ++++-- mix.lock | 18 +++++++++--------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/.drone.jsonnet b/.drone.jsonnet index 388e8f4c..87f3260c 100644 --- a/.drone.jsonnet +++ b/.drone.jsonnet @@ -40,8 +40,10 @@ local step_lint = { 'mix compile --warnings-as-errors', // Check for compilation errors & warnings 'mix format --check-formatted', // Check formatting 'mix sobelow --config', // Security checks - 'mix deps.audit --ignore-file .deps_audit_ignore', // Known vulnerabilities - 'mix hex.audit', // Unmaintained dependencies + // mix hex.audit (Hex >= 2.5) fails on unpatched transitive advisories (cowlib, swoosh). + // mix deps.audit supports .deps_audit_ignore for accepted risks; re-enable hex.audit when + // ignore_advisories lands in Hex or those packages are patched. + 'mix deps.audit --ignore-file .deps_audit_ignore', 'mix credo --strict', // Code quality hints 'mix gettext.extract --check-up-to-date', // Translations up to date ], diff --git a/mix.lock b/mix.lock index 1cffe99c..17cb540d 100644 --- a/mix.lock +++ b/mix.lock @@ -1,11 +1,11 @@ %{ - "ash": {:hex, :ash, "3.27.7", "349e47b9fc293c8de56866f900f6e1a3a5deea1e110d205749f94a9833431811", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.6.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "eb8a1a74090d7f1753a63fe422cd493b7f50736e2d95d280ccfb508956dccc1d"}, + "ash": {:hex, :ash, "3.29.3", "ba3c286c10fef489e01e1896bb6e80dbcac357c55b6d375da9e9eb7b49e88234", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.6.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2a7a45d3a3b980457b5cc7e339a84a35c232a7ab99047bbf7ce55e7dc36df357"}, "ash_admin": {:hex, :ash_admin, "1.1.0", "df7c8075347ca9229f132648534a33319f29ae5aceed6c1015d138bba1a4811f", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.20 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:cinder, "~> 0.9", [hex: :cinder, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "f8bd6c08b584a315a9574c7bbe9c1c914bc5c51838045994b0e5369871f9b3d8"}, - "ash_authentication": {:hex, :ash_authentication, "4.13.7", "421b5ddb516026f6794435980a632109ec116af2afa68a45e15fb48b41c92cfa", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "> 0.2.0 and < 0.3.0", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "0d45ac3fdcca6902dabbe161ce63e9cea8f90583863c2e14261c9309e5837121"}, + "ash_authentication": {:hex, :ash_authentication, "4.14.1", "67ce78459f1064d0a0374beced5bf6bd106e98d59f3253a96525f3073c2493fb", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "> 0.2.0 and < 0.3.0", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "e149234fc70dc2544aac7a52656318bebca89a58ecbe98fb884ae41cf76fe6d7"}, "ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.16.0", "02045ecde9eeb30ab1bfffbdf693c64426af24902bcd533765eba725b9b9f46f", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "1107a45af771ee7c02ebe82abcaf9a778096e66b3e6cb2b6e614d22d1fe385f7"}, "ash_phoenix": {:hex, :ash_phoenix, "2.3.22", "f59a347ee09e1fa9973fe1b2faf7f8f793acc14dc09341062783b8eb1a9f5c99", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "4b34ea84e122c238ad1843888b8fd4d21aec27605b9b1e6e27e1b70329560fbb"}, - "ash_postgres": {:hex, :ash_postgres, "2.9.1", "bf4229d65706f794650edb47c9f30138a6e2d5af6efe002ca38e619306cca9f6", [:mix], [{:ash, "~> 3.24", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, "~> 0.6", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "72c0366649985a858d4ef8f906968cee339dfd7519bb0beaa2b4d87f3d5b0bb9"}, - "ash_sql": {:hex, :ash_sql, "0.6.3", "a708b34ba71b40141dab9e75dc44a095885ae4635b25135d3fd4c3620b299b97", [:mix], [{:ash, ">= 3.24.5 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, ">= 3.13.4 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "3ee461380d96dca32766a210ea60c64783f690ad5565f0434a00cd475e71e8b9"}, + "ash_postgres": {:hex, :ash_postgres, "2.10.0", "831d3b70b80560a6894cb17c98e3f72e3dbbc06e835dbac36363c27e186b1940", [:mix], [{:ash, "~> 3.29", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, "~> 0.6", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "e73298910d29b8051b30af390094165848b588288204ab12990f365c9fc653cc"}, + "ash_sql": {:hex, :ash_sql, "0.6.5", "4c64ccee3cbe7101a088aa84df54da9ce3622f7fb14cdbfc3e9fa9e010a5baa4", [:mix], [{:ash, ">= 3.24.5 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, ">= 3.13.4 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "e054670f5cf59e5dd2b13695c5de02ade592dc2f012ece10adb98a87e94cca8b"}, "assent": {:hex, :assent, "0.2.13", "11226365d2d8661d23e9a2cf94d3255e81054ff9d88ac877f28bfdf38fa4ef31", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "bf9f351b01dd6bceea1d1f157f05438f6765ce606e6eb8d29296003d29bf6eab"}, "bandit": {:hex, :bandit, "1.12.0", "6c5214daa2469644ac4ab0113b98abc24f75e348378e6a974c6343b3e5da22ef", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.5", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "45dac82dc86f45cf4a196dee9cc5a8b791d9c9469d996055f055e6ee36c66e20"}, "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"}, @@ -20,14 +20,14 @@ "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, "cowlib": {:hex, :cowlib, "2.18.0", "4a3ef8cb012c21c7cbb7c925f75405e26d7fcd393fd78c46187539dbc9a48310", [:make, :rebar3], [], "hexpm", "c4f89d33a61162d4cfdcb5a1af7e562d80a700b2573cc71020ce6521b152dc1a"}, "credo": {:hex, :credo, "1.7.18", "5c5596bf7aedf9c8c227f13272ac499fe8eae6237bd326f2f07dfc173786f042", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a189d164685fd945809e862fe76a7420c4398fa288d76257662aecb909d6b3e5"}, - "crux": {:hex, :crux, "0.1.3", "c698dee09d811678dcddad11a02a832c6bff100f1a7aee49ac44c87485bdbac8", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "04188ea9c1cee13e3ef132417200765857402dcc581f45a8a7862eec3b0530ff"}, - "db_connection": {:hex, :db_connection, "2.10.1", "d5465f6bcc125c1b8981c1dbf23c193ca16f446ec0b25832dc174f74f18be510", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "18ed94c6e627b4bf452dbd4df61b69a35a1e768525140bc1917b7a685026a6a3"}, + "crux": {:hex, :crux, "0.1.4", "1fa21f5ca886d3498f83871a3ef19379b80abf8cfcf2ed32007e80279e714f1e", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "ff7d880cf732d82360aa81e439b8d4831cd30768061c9233f90c97e10162b9c0"}, + "db_connection": {:hex, :db_connection, "2.10.2", "ae391e803a5adff104da913c2fc1c0c14a37f8b10001dcef568796e1fb7bf95c", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "510b14482330f1af6490a2fa0efd8d4f1435d1529b165647df22ac0f2df0fa93"}, "decimal": {:hex, :decimal, "3.1.1", "430d87b04011ce6cbd4fd205be758311a81f87d552d40904abd00f015935b1d0", [:mix], [], "hexpm", "c5f25f2ced74a0587d03e6023f595db8e924c9d3922c8c8ffd9edfc4498cf1f6"}, "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"}, - "ecto": {:hex, :ecto, "3.13.6", "352135b474f91d1ab99a1b502171d207e9db60421c9e3d0ecab4c7ab96b24d14", [:mix], [{:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8afa059bc16cd2c94739ec0a11e3e5df69d828125119109bef35f20a21a76af2"}, + "ecto": {:hex, :ecto, "3.14.0", "2fa64521eebfcb2670d907a86e4ad947290e9933706bb315e6fb5c21b172cb26", [:mix], [{:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "130d69ffb4285f9ce4792b65dfbb994fd13ea4cbc3cbea2524b199aa3de84af3"}, "ecto_commons": {:hex, :ecto_commons, "0.3.7", "f33c162a6f63695d5939af02c65a0e76aa6e7278b82c7bfc357ffbfea353bf0f", [:mix], [{:burnex, "~> 3.0", [hex: :burnex, repo: "hexpm", optional: true]}, {:ecto, "~> 3.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:ex_phone_number, "~> 0.4", [hex: :ex_phone_number, repo: "hexpm", optional: false]}, {:luhn, "~> 0.3.0", [hex: :luhn, repo: "hexpm", optional: false]}], "hexpm", "9c33771ebd38cd83d3f90fab6069826ba9d4f7580f1481b3c0913f8b9795c5fd"}, - "ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"}, + "ecto_sql": {:hex, :ecto_sql, "3.14.0", "06446ab8410d2f85bfbb80857ee224ab3b693700cbb38f6535d507449a627b2e", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.8", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f4d8d36faf294c9417b5a37ec7ac8217ee2abdef5fcf197ba690f361548d3949"}, "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, "erlex": {:hex, :erlex, "0.2.9", "7debbbaa9f4f368b8cd648983e0f1d7963028508e9c59e9d4ed504e94ef52a55", [:mix], [], "hexpm", "8cfffc0ec7159e6d73de2ab28a588064de80f88b2798d5cbe4482cbbc200178b"}, "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, @@ -87,7 +87,7 @@ "slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"}, "sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"}, "sourceror": {:hex, :sourceror, "1.12.2", "85bfd48159f020c0cbfc72f289f11456fdc05dc43719b6f2589fb969faefa113", [:mix], [], "hexpm", "da37d3da09c5b890528802c7056a8f585a061973820d7656b6e3649c14f0e9cb"}, - "spark": {:hex, :spark, "2.7.0", "e685b33c038f12851993880bb7e3b326117612eb746fe15828678c152f8321c6", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "e2f675fbda32375b01d9ee7c652671531027fd043bf4a91bafdb2ab716aa1122"}, + "spark": {:hex, :spark, "2.7.2", "36becc6ff03b40908cc821d403d7f06d893498e293d2f718afc6ca097fcb9d93", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "adb323ddbf9dbbe326f9e5def54ac96c47911e852b2c270bb19a5147c56f1b45"}, "spitfire": {:hex, :spitfire, "0.3.13", "edd207b065eaec57acc5484097d0aa3e97fe4246168c54e67a9af040b8dee4c1", [:mix], [], "hexpm", "3601be88ceed4967b584e96444de3e1d12d6555ae0864a7390b9cd5332d134b4"}, "splode": {:hex, :splode, "0.3.1", "9843c54f84f71b7833fec3f0be06c3cfb5be6b35960ee195ea4fad84b1c25030", [:mix], [], "hexpm", "8f2309b6ec2ecbb01435656429ed1d9ed04ba28797a3280c3b0d1217018ecfbd"}, "stream_data": {:hex, :stream_data, "1.3.0", "bde37905530aff386dea1ddd86ecbf00e6642dc074ceffc10b7d4e41dfd6aac9", [:mix], [], "hexpm", "3cc552e286e817dca43c98044c706eec9318083a1480c52ae2688b08e2936e3c"}, From 4b647c21e74c6fd65e4494b357be64941d42a8d2 Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 10 Jul 2026 16:27:14 +0200 Subject: [PATCH 19/30] feat(membership-fee): index fee cycles by member, status and end date --- lib/membership_fees/membership_fee_cycle.ex | 10 + ...membership_fee_cycles_status_end_index.exs | 22 ++ .../membership_fee_cycles/20260706191442.json | 206 ++++++++++++++++++ 3 files changed, 238 insertions(+) create mode 100644 priv/repo/migrations/20260706191440_add_membership_fee_cycles_status_end_index.exs create mode 100644 priv/resource_snapshots/repo/membership_fee_cycles/20260706191442.json diff --git a/lib/membership_fees/membership_fee_cycle.ex b/lib/membership_fees/membership_fee_cycle.ex index cb881938..d6842efe 100644 --- a/lib/membership_fees/membership_fee_cycle.ex +++ b/lib/membership_fees/membership_fee_cycle.ex @@ -36,6 +36,16 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do postgres do table "membership_fee_cycles" repo Mv.Repo + + custom_indexes do + # Serves the period-scoped unpaid-cycle count on the member overview: + # count of a member's cycles with status = :unpaid and cycle_end in the + # active period. The composite (member_id, status, cycle_end) lets the + # aggregate seek the per-member unpaid slice and range-scan cycle_end + # without touching paid/suspended rows. + index [:member_id, :status, :cycle_end], + name: "membership_fee_cycles_member_status_end_index" + end end resource do diff --git a/priv/repo/migrations/20260706191440_add_membership_fee_cycles_status_end_index.exs b/priv/repo/migrations/20260706191440_add_membership_fee_cycles_status_end_index.exs new file mode 100644 index 00000000..b3fb8a55 --- /dev/null +++ b/priv/repo/migrations/20260706191440_add_membership_fee_cycles_status_end_index.exs @@ -0,0 +1,22 @@ +defmodule Mv.Repo.Migrations.AddMembershipFeeCyclesStatusEndIndex do + @moduledoc """ + Adds a composite index on membership_fee_cycles(member_id, status, cycle_end) + to serve the period-scoped unpaid-cycle count on the member overview. + + This file was autogenerated with `mix ash_postgres.generate_migrations`. + """ + + use Ecto.Migration + + def up do + create index(:membership_fee_cycles, [:member_id, :status, :cycle_end], + name: "membership_fee_cycles_member_status_end_index" + ) + end + + def down do + drop_if_exists index(:membership_fee_cycles, [:member_id, :status, :cycle_end], + name: "membership_fee_cycles_member_status_end_index" + ) + end +end diff --git a/priv/resource_snapshots/repo/membership_fee_cycles/20260706191442.json b/priv/resource_snapshots/repo/membership_fee_cycles/20260706191442.json new file mode 100644 index 00000000..5d974b60 --- /dev/null +++ b/priv/resource_snapshots/repo/membership_fee_cycles/20260706191442.json @@ -0,0 +1,206 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"uuid_generate_v7()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "cycle_start", + "type": "date" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "cycle_end", + "type": "date" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": 2, + "size": null, + "source": "amount", + "type": "decimal" + }, + { + "allow_nil?": false, + "default": "\"unpaid\"", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "status", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "notes", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "membership_fee_cycles_member_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "members" + }, + "scale": null, + "size": null, + "source": "member_id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "membership_fee_cycles_membership_fee_type_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "membership_fee_types" + }, + "scale": null, + "size": null, + "source": "membership_fee_type_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "create_table_options": null, + "custom_indexes": [ + { + "all_tenants?": false, + "concurrently": false, + "error_fields": [ + "member_id", + "status", + "cycle_end" + ], + "fields": [ + { + "type": "atom", + "value": "member_id" + }, + { + "type": "atom", + "value": "status" + }, + { + "type": "atom", + "value": "cycle_end" + } + ], + "include": null, + "message": null, + "name": "membership_fee_cycles_member_status_end_index", + "nulls_distinct": true, + "prefix": null, + "table": null, + "unique": false, + "using": null, + "where": null + } + ], + "custom_statements": [], + "has_create_action": true, + "hash": "6CDBA37AC6000D63F3014D3EED08B613B32E8D908D598248747EFA093BC5246A", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "membership_fee_cycles_unique_cycle_per_member_index", + "keys": [ + { + "type": "atom", + "value": "member_id" + }, + { + "type": "atom", + "value": "cycle_start" + } + ], + "name": "unique_cycle_per_member", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "membership_fee_cycles" +} \ No newline at end of file From 95ef3177acd914ca6645285ad6eed3b61cf791f0 Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 10 Jul 2026 16:27:14 +0200 Subject: [PATCH 20/30] feat(member): add period-scoped unpaid-cycle payment-aging model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Counts each member's unpaid billing cycles that fall inside the selected period. Cycle boundaries are member-relative — derived from each member's fee-type interval — so "the current cycle" spans different calendar dates per member. --- lib/membership/member.ex | 19 +++ lib/mv/constants.ex | 38 +++++ .../live/member_live/index/payment_aging.ex | 131 ++++++++++++++++++ priv/gettext/de/LC_MESSAGES/default.po | 6 + priv/gettext/default.pot | 6 + .../index_payment_aging_property_test.exs | 82 +++++++++++ .../member_live/index_payment_aging_test.exs | 79 +++++++++++ .../member_live/index_payment_period_test.exs | 95 +++++++++++++ 8 files changed, 456 insertions(+) create mode 100644 lib/mv_web/live/member_live/index/payment_aging.ex create mode 100644 test/mv_web/member_live/index_payment_aging_property_test.exs create mode 100644 test/mv_web/member_live/index_payment_aging_test.exs create mode 100644 test/mv_web/member_live/index_payment_period_test.exs diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 9f7965bb..b0ff298f 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -717,6 +717,25 @@ defmodule Mv.Membership.Member do constraints one_of: [:unpaid, :paid, :suspended] end + calculate :unpaid_cycle_count, + :integer, + expr( + count(membership_fee_cycles, + query: [ + filter: + expr( + status == :unpaid and + (is_nil(^arg(:period_from)) or cycle_end >= ^arg(:period_from)) and + (is_nil(^arg(:period_to)) or cycle_end <= ^arg(:period_to)) + ) + ] + ) + ) do + description "Count of the member's unpaid cycles whose cycle_end is within the given period" + argument :period_from, :date, allow_nil?: true + argument :period_to, :date, allow_nil?: true + end + calculate :overdue_count, :integer do description "Count of unpaid cycles that have already ended (cycle_end < today)" # Automatically load cycles with all attributes and membership_fee_type diff --git a/lib/mv/constants.ex b/lib/mv/constants.ex index 657aa9b7..449d1868 100644 --- a/lib/mv/constants.ex +++ b/lib/mv/constants.ex @@ -38,6 +38,12 @@ defmodule Mv.Constants do @custom_date_filter_prefix "cdf_" + @payment_period_from_param "pay_from" + + @payment_period_to_param "pay_to" + + @payment_filter_param "pay_filter" + @max_boolean_filters 50 @max_mailto_bulk_recipients 50 @@ -162,6 +168,38 @@ defmodule Mv.Constants do """ def custom_date_filter_prefix, do: @custom_date_filter_prefix + @doc """ + Returns the URL parameter name for the payment-aging period lower bound + (ISO-8601 date; scopes both the payment column and the payment filter). + + ## Examples + + iex> Mv.Constants.payment_period_from_param() + "pay_from" + """ + def payment_period_from_param, do: @payment_period_from_param + + @doc """ + Returns the URL parameter name for the payment-aging period upper bound. + + ## Examples + + iex> Mv.Constants.payment_period_to_param() + "pay_to" + """ + def payment_period_to_param, do: @payment_period_to_param + + @doc """ + Returns the URL parameter name for the payment-count filter + (`fully_paid` | `unpaid_1` | `unpaid_2` | `unpaid_3`). + + ## Examples + + iex> Mv.Constants.payment_filter_param() + "pay_filter" + """ + def payment_filter_param, do: @payment_filter_param + @doc """ Returns the maximum number of boolean custom field filters allowed per request. diff --git a/lib/mv_web/live/member_live/index/payment_aging.ex b/lib/mv_web/live/member_live/index/payment_aging.ex new file mode 100644 index 00000000..18d3163b --- /dev/null +++ b/lib/mv_web/live/member_live/index/payment_aging.ex @@ -0,0 +1,131 @@ +defmodule MvWeb.MemberLive.Index.PaymentAging do + @moduledoc """ + Period-scoped payment aging for the member overview. + + The overview presents payment as an accounts-receivable aging count: per + member, the number of that member's cycles with `status == :unpaid` whose + denormalized `cycle_end` falls inside an active period. The period is a view + dimension that scopes the column and the payment filter together; its default + is **all outstanding** (both bounds nil → all time). Suspended cycles are + excluded from the count but surfaced separately in the badge tooltip. + + This module owns: + + * the period value shape and its default, + * URL param encode/decode for the period, + * the badge descriptor derived from a count, + * the open-cycles list (unpaid + suspended, member-relative) that backs the + badge tooltip. + + The count itself is computed DB-side by the `unpaid_cycle_count` calculation + on `Mv.Membership.Member`; `open_cycles/2` works over already-loaded cycles. + """ + + use Gettext, backend: MvWeb.Gettext + + alias Mv.Constants + alias MvWeb.Helpers.MembershipFeeHelpers + + @type period :: %{from: Date.t() | nil, to: Date.t() | nil} + + @payment_period_from_param Constants.payment_period_from_param() + @payment_period_to_param Constants.payment_period_to_param() + + @doc """ + The default period: all outstanding cycles, all time (both bounds nil). + """ + @spec default_period() :: period() + def default_period, do: %{from: nil, to: nil} + + @doc """ + Decodes URL params into a period. Absent or malformed ISO-8601 bounds fall + back to nil, so no params yields the all-outstanding default. + """ + @spec parse_period(map()) :: period() + def parse_period(params) when is_map(params) do + %{ + from: parse_date(Map.get(params, @payment_period_from_param)), + to: parse_date(Map.get(params, @payment_period_to_param)) + } + end + + @doc """ + Encodes a period into URL params. A nil bound is omitted; the default period + encodes to the empty map (a fresh URL is the canonical default). + """ + @spec to_params(period()) :: %{optional(String.t()) => String.t()} + def to_params(%{from: from, to: to}) do + %{} + |> maybe_put_date(@payment_period_from_param, from) + |> maybe_put_date(@payment_period_to_param, to) + end + + def to_params(_), do: %{} + + @doc """ + Badge descriptor for an unpaid-cycle count. A count of 0 reads "Paid" + (success); a positive count reads "N unpaid" (error). + """ + @spec badge(non_neg_integer()) :: %{ + variant: atom(), + label: String.t(), + count: non_neg_integer() + } + def badge(0), do: %{variant: :success, label: gettext("Paid"), count: 0} + + def badge(count) when is_integer(count) and count > 0 do + %{ + variant: MembershipFeeHelpers.status_variant(:unpaid), + label: gettext("%{count} unpaid", count: count), + count: count + } + end + + @doc """ + Splits a member's loaded cycles into the open (unpaid) and suspended cycles + whose `cycle_end` falls inside `period`, both sorted by `cycle_end`. Paid + cycles and cycles outside the period are dropped. Used to render the badge + tooltip. Expects `membership_fee_cycles` to be loaded on the member. + """ + @spec open_cycles(map(), period()) :: %{unpaid: [map()], suspended: [map()]} + def open_cycles(member, %{from: from, to: to}) do + cycles = in_period_cycles(member, from, to) + + %{ + unpaid: cycles |> Enum.filter(&(&1.status == :unpaid)) |> sort_by_end(), + suspended: cycles |> Enum.filter(&(&1.status == :suspended)) |> sort_by_end() + } + end + + defp in_period_cycles(member, from, to) do + case Map.get(member, :membership_fee_cycles) do + cycles when is_list(cycles) -> Enum.filter(cycles, &in_period?(&1.cycle_end, from, to)) + _ -> [] + end + end + + defp in_period?(%Date{} = cycle_end, from, to) do + (is_nil(from) or Date.compare(cycle_end, from) != :lt) and + (is_nil(to) or Date.compare(cycle_end, to) != :gt) + end + + defp in_period?(_, _, _), do: false + + defp sort_by_end(cycles), do: Enum.sort_by(cycles, & &1.cycle_end, Date) + + defp maybe_put_date(params, _key, nil), do: params + + defp maybe_put_date(params, key, %Date{} = date), + do: Map.put(params, key, Date.to_iso8601(date)) + + defp parse_date(nil), do: nil + + defp parse_date(value) when is_binary(value) do + case Date.from_iso8601(String.trim(value)) do + {:ok, date} -> date + _ -> nil + end + end + + defp parse_date(_), do: nil +end diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index db07d05f..cc8dae08 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -2696,6 +2696,7 @@ msgstr "Optionen" #: lib/mv/membership/members_pdf.ex #: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/member_live/index/membership_fee_status.ex +#: lib/mv_web/live/member_live/index/payment_aging.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 @@ -4213,3 +4214,8 @@ msgstr "Klicke, um nach Nachname zu sortieren" #, elixir-autogen, elixir-format msgid "No address" msgstr "Keine Adresse" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "%{count} unpaid" +msgstr "%{count} unbezahlt" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index c3b21727..34772344 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -2697,6 +2697,7 @@ msgstr "" #: lib/mv/membership/members_pdf.ex #: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/member_live/index/membership_fee_status.ex +#: lib/mv_web/live/member_live/index/payment_aging.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 @@ -4213,3 +4214,8 @@ msgstr "" #, elixir-autogen, elixir-format msgid "No address" msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "%{count} unpaid" +msgstr "" diff --git a/test/mv_web/member_live/index_payment_aging_property_test.exs b/test/mv_web/member_live/index_payment_aging_property_test.exs new file mode 100644 index 00000000..ae6d7223 --- /dev/null +++ b/test/mv_web/member_live/index_payment_aging_property_test.exs @@ -0,0 +1,82 @@ +defmodule MvWeb.MemberLive.IndexPaymentAgingPropertyTest do + @moduledoc """ + §2.5 — Unpaid-cycle count correctness (period-scoped, member-relative). + + For arbitrary per-member cycle sets (status × cycle_end) and arbitrary active + periods, the `unpaid_cycle_count` calculation equals the set-theoretic + definition `|{c : c.status == :unpaid and c.cycle_end ∈ period}|` — paid and + suspended cycles excluded, nil period bounds unbounded. + """ + use Mv.DataCase, async: false + use ExUnitProperties + + import Mv.Fixtures, only: [create_fee_type: 2, member_fixture_with_actor: 2, create_cycle: 4] + + alias Mv.MembershipFees.MembershipFeeCycle + + require Ash.Query + + # Distinct monthly cycle_starts so cycles never collide on the + # (member_id, cycle_start) identity; monthly keeps each cycle_end inside a + # single month for legible in/out-of-period placement. + @starts for y <- 2022..2026, m <- [1, 4, 7, 10], do: Date.new!(y, m, 1) + @bounds [nil, ~D[2023-01-01], ~D[2024-06-30], ~D[2025-12-31]] + + setup do + actor = Mv.Helpers.SystemActor.get_system_actor() + fee_type = create_fee_type(%{interval: :monthly}, actor) + %{actor: actor, fee_type: fee_type} + end + + defp cycle_gen do + StreamData.tuple( + {StreamData.member_of(@starts), StreamData.member_of([:unpaid, :paid, :suspended])} + ) + end + + defp in_period?(cycle_end, from, to) do + (is_nil(from) or Date.compare(cycle_end, from) != :lt) and + (is_nil(to) or Date.compare(cycle_end, to) != :gt) + end + + property "unpaid_cycle_count equals the set-theoretic definition", %{ + actor: actor, + fee_type: ft + } do + check all( + cycles <- StreamData.list_of(cycle_gen(), max_length: 6), + from <- StreamData.member_of(@bounds), + to <- StreamData.member_of(@bounds), + max_runs: 40 + ) do + member = member_fixture_with_actor(%{}, actor) + + cycles + |> Enum.uniq_by(fn {start, _status} -> start end) + |> Enum.each(fn {start, status} -> + create_cycle(member, ft, %{cycle_start: start, status: status}, actor) + end) + + stored = + MembershipFeeCycle + |> Ash.Query.filter(member_id == ^member.id) + |> Ash.read!(actor: actor) + + expected = + Enum.count(stored, fn c -> + c.status == :unpaid and in_period?(c.cycle_end, from, to) + end) + + actual = + member + |> Ash.load!([unpaid_cycle_count: %{period_from: from, period_to: to}], actor: actor) + |> Map.fetch!(:unpaid_cycle_count) + + assert actual == expected + + # Clean up so populations do not accumulate across runs. + Enum.each(stored, &Ash.destroy!(&1, actor: actor)) + Ash.destroy!(member, actor: actor) + end + end +end diff --git a/test/mv_web/member_live/index_payment_aging_test.exs b/test/mv_web/member_live/index_payment_aging_test.exs new file mode 100644 index 00000000..f80d1a42 --- /dev/null +++ b/test/mv_web/member_live/index_payment_aging_test.exs @@ -0,0 +1,79 @@ +defmodule MvWeb.MemberLive.IndexPaymentAgingTest do + @moduledoc """ + §1.13 / §3.3 — the period-scoped `unpaid_cycle_count` calculation on Member. + + Counts a member's cycles with `status == :unpaid` whose denormalized + `cycle_end` falls inside the active period; paid and suspended cycles are + never counted. A nil period bound means "unbounded on that side" (the + all-outstanding default counts every unpaid cycle). + """ + use Mv.DataCase, async: false + + import Mv.Fixtures, only: [create_fee_type: 2, member_fixture_with_actor: 2, create_cycle: 4] + + alias Mv.Membership.Member + + require Ash.Query + + setup do + actor = Mv.Helpers.SystemActor.get_system_actor() + # Monthly so several cycles can share one period without cycle_start collisions. + fee_type = create_fee_type(%{interval: :monthly}, actor) + %{actor: actor, fee_type: fee_type} + end + + defp count(member, from, to, actor) do + member + |> Ash.load!([unpaid_cycle_count: %{period_from: from, period_to: to}], actor: actor) + |> Map.fetch!(:unpaid_cycle_count) + end + + test "counts only unpaid cycles with cycle_end in the period; suspended and paid excluded", + %{actor: actor, fee_type: ft} do + # No membership_fee_type_id on the member itself → no auto-generated cycles. + member = member_fixture_with_actor(%{}, actor) + + # In-period unpaid (counted) + create_cycle(member, ft, %{cycle_start: ~D[2024-01-01], status: :unpaid}, actor) + create_cycle(member, ft, %{cycle_start: ~D[2024-02-01], status: :unpaid}, actor) + # In-period but not unpaid (excluded) + create_cycle(member, ft, %{cycle_start: ~D[2024-03-01], status: :paid}, actor) + create_cycle(member, ft, %{cycle_start: ~D[2024-04-01], status: :suspended}, actor) + # Unpaid but out of period (excluded) + create_cycle(member, ft, %{cycle_start: ~D[2023-01-01], status: :unpaid}, actor) + + assert count(member, ~D[2024-01-01], ~D[2024-12-31], actor) == 2 + + # Fully-paid member (only paid/suspended in period) → 0 + paid_member = member_fixture_with_actor(%{}, actor) + create_cycle(paid_member, ft, %{cycle_start: ~D[2024-01-01], status: :paid}, actor) + create_cycle(paid_member, ft, %{cycle_start: ~D[2024-02-01], status: :suspended}, actor) + assert count(paid_member, ~D[2024-01-01], ~D[2024-12-31], actor) == 0 + end + + test "nil period bounds count every unpaid cycle (all-outstanding default)", + %{actor: actor, fee_type: ft} do + member = member_fixture_with_actor(%{}, actor) + create_cycle(member, ft, %{cycle_start: ~D[2020-01-01], status: :unpaid}, actor) + create_cycle(member, ft, %{cycle_start: ~D[2024-06-01], status: :unpaid}, actor) + create_cycle(member, ft, %{cycle_start: ~D[2026-01-01], status: :unpaid}, actor) + create_cycle(member, ft, %{cycle_start: ~D[2025-01-01], status: :paid}, actor) + + assert count(member, nil, nil, actor) == 3 + end + + test "loads for a whole read query (DB-pushable aggregate)", %{actor: actor, fee_type: ft} do + member = member_fixture_with_actor(%{}, actor) + create_cycle(member, ft, %{cycle_start: ~D[2024-01-01], status: :unpaid}, actor) + + [loaded] = + Member + |> Ash.Query.filter(id == ^member.id) + |> Ash.Query.load( + unpaid_cycle_count: %{period_from: ~D[2024-01-01], period_to: ~D[2024-12-31]} + ) + |> Ash.read!(actor: actor) + + assert loaded.unpaid_cycle_count == 1 + end +end diff --git a/test/mv_web/member_live/index_payment_period_test.exs b/test/mv_web/member_live/index_payment_period_test.exs new file mode 100644 index 00000000..7b56240e --- /dev/null +++ b/test/mv_web/member_live/index_payment_period_test.exs @@ -0,0 +1,95 @@ +defmodule MvWeb.MemberLive.IndexPaymentPeriodTest do + @moduledoc """ + §1.13 / §1.14 / §3.3 — the PaymentAging module: period parse/serialize with + the all-outstanding default, badge formatting, and the open-cycles list that + backs the badge tooltip (unpaid cycles for the period; suspended shown apart). + """ + use Mv.DataCase, async: false + + import Mv.Fixtures, only: [create_fee_type: 2, member_fixture_with_actor: 2, create_cycle: 4] + + alias MvWeb.MemberLive.Index.PaymentAging + + require Ash.Query + + describe "period parsing / serialization" do + test "default period is all-outstanding (both bounds nil)" do + assert PaymentAging.default_period() == %{from: nil, to: nil} + end + + test "no params parses to the default period" do + assert PaymentAging.parse_period(%{}) == %{from: nil, to: nil} + end + + test "parses ISO bounds and round-trips through to_params" do + period = %{from: ~D[2024-01-01], to: ~D[2024-12-31]} + params = PaymentAging.to_params(period) + + assert params["pay_from"] == "2024-01-01" + assert params["pay_to"] == "2024-12-31" + assert PaymentAging.parse_period(params) == period + end + + test "default period serializes to no params" do + assert PaymentAging.to_params(PaymentAging.default_period()) == %{} + end + + test "malformed bounds fall back to nil" do + assert PaymentAging.parse_period(%{"pay_from" => "not-a-date"}) == %{from: nil, to: nil} + end + end + + describe "badge/1" do + test "0 renders as Paid (success)" do + badge = PaymentAging.badge(0) + assert badge.variant == :success + assert badge.label == "Paid" + end + + test "N > 0 renders as N unpaid (error)" do + badge = PaymentAging.badge(2) + assert badge.variant == :error + assert badge.label =~ "2" + assert badge.label =~ "unpaid" + end + end + + describe "open_cycles/2" do + setup do + actor = Mv.Helpers.SystemActor.get_system_actor() + ft = create_fee_type(%{interval: :monthly}, actor) + %{actor: actor, ft: ft} + end + + test "lists in-period unpaid cycles and flags suspended separately; paid excluded", + %{actor: actor, ft: ft} do + member = member_fixture_with_actor(%{}, actor) + create_cycle(member, ft, %{cycle_start: ~D[2024-01-01], status: :unpaid}, actor) + create_cycle(member, ft, %{cycle_start: ~D[2024-02-01], status: :unpaid}, actor) + create_cycle(member, ft, %{cycle_start: ~D[2024-03-01], status: :suspended}, actor) + create_cycle(member, ft, %{cycle_start: ~D[2024-04-01], status: :paid}, actor) + # out of period + create_cycle(member, ft, %{cycle_start: ~D[2023-01-01], status: :unpaid}, actor) + + member = Ash.load!(member, :membership_fee_cycles, actor: actor) + result = PaymentAging.open_cycles(member, %{from: ~D[2024-01-01], to: ~D[2024-12-31]}) + + assert length(result.unpaid) == 2 + assert length(result.suspended) == 1 + assert Enum.all?(result.unpaid, &(&1.status == :unpaid)) + assert Enum.all?(result.suspended, &(&1.status == :suspended)) + end + + test "nil bounds include every unpaid/suspended cycle", %{actor: actor, ft: ft} do + member = member_fixture_with_actor(%{}, actor) + create_cycle(member, ft, %{cycle_start: ~D[2020-01-01], status: :unpaid}, actor) + create_cycle(member, ft, %{cycle_start: ~D[2026-01-01], status: :suspended}, actor) + + member = Ash.load!(member, :membership_fee_cycles, actor: actor) + result = PaymentAging.open_cycles(member, PaymentAging.default_period()) + + assert length(result.unpaid) == 1 + assert length(result.suspended) == 1 + end + end +end From 7d1a71b1fd043b07c46e1457f6df9b7c238183d6 Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 10 Jul 2026 16:27:15 +0200 Subject: [PATCH 21/30] feat(overview): add period-scoped payment filter and field-picker descriptor --- .../live/member_live/index/date_filter.ex | 39 +++++ .../member_live/index/filter_descriptor.ex | 140 ++++++++++++++++++ .../live/member_live/index/overview_query.ex | 35 +++++ .../live/member_live/index/payment_aging.ex | 36 +++++ priv/gettext/de/LC_MESSAGES/default.po | 18 +++ priv/gettext/default.pot | 18 +++ priv/gettext/en/LC_MESSAGES/default.po | 24 +++ .../member_live/date_filter_property_test.exs | 68 +++++++++ .../member_live/index_active_former_test.exs | 64 ++++++++ .../member_live/index_field_picker_test.exs | 82 ++++++++++ .../member_live/index_payment_filter_test.exs | 77 ++++++++++ .../member_live/index_payment_period_test.exs | 53 +++++++ 12 files changed, 654 insertions(+) create mode 100644 lib/mv_web/live/member_live/index/filter_descriptor.ex create mode 100644 test/mv_web/member_live/index_active_former_test.exs create mode 100644 test/mv_web/member_live/index_field_picker_test.exs create mode 100644 test/mv_web/member_live/index_payment_filter_test.exs diff --git a/lib/mv_web/live/member_live/index/date_filter.ex b/lib/mv_web/live/member_live/index/date_filter.ex index b24c2f49..a26abae2 100644 --- a/lib/mv_web/live/member_live/index/date_filter.ex +++ b/lib/mv_web/live/member_live/index/date_filter.ex @@ -67,6 +67,45 @@ defmodule MvWeb.MemberLive.Index.DateFilter do } end + @typedoc """ + The three-state active/former quick filter. It is not a stored field but a + view onto `exit_date.mode`: `:active` ↔ `:active_only`, `:former` ↔ + `:inactive_only`, `:all` ↔ `:all`. A detailed `:custom` exit-date selection + has no quick equivalent and surfaces as `:custom` (no quick chip selected). + """ + @type quick_state :: :active | :former | :all | :custom + + @quick_to_mode %{active: :active_only, former: :inactive_only, all: :all} + + @doc """ + Derives the active/former quick-filter state from the single `exit_date` + source. Total function; an absent or malformed `exit_date` reads as the + default `:active`. This is the only reader — there is no second state to keep + in sync (§2.4). + """ + @spec quick_state(map()) :: quick_state() + def quick_state(filters) when is_map(filters) do + case Map.get(filters, :exit_date, %{}) do + %{mode: :active_only} -> :active + %{mode: :inactive_only} -> :former + %{mode: :all} -> :all + %{mode: :custom} -> :custom + _ -> :active + end + end + + @doc """ + Applies an active/former quick-filter choice onto the date filter state by + writing the corresponding `exit_date` mode and clearing its bounds. Only the + `exit_date` slice is touched; `join_date` and custom-date entries are left + intact, so the quick filter and the detailed control share one source. + """ + @spec set_quick_state(map(), :active | :former | :all) :: map() + def set_quick_state(filters, state) + when is_map(filters) and state in [:active, :former, :all] do + Map.put(filters, :exit_date, %{mode: Map.fetch!(@quick_to_mode, state), from: nil, to: nil}) + end + @doc """ Decodes URL params into a date filter state map. diff --git a/lib/mv_web/live/member_live/index/filter_descriptor.ex b/lib/mv_web/live/member_live/index/filter_descriptor.ex new file mode 100644 index 00000000..96ecd801 --- /dev/null +++ b/lib/mv_web/live/member_live/index/filter_descriptor.ex @@ -0,0 +1,140 @@ +defmodule MvWeb.MemberLive.Index.FilterDescriptor do + @moduledoc """ + Data-driven catalog of the filter fields offered by the member-overview + add-filter builder. + + Each descriptor is a plain, serializable map describing one pickable field: + + %{ + key: atom() | String.t(), # stable identifier (custom fields use their UUID string) + group: :quick | :membership | :custom_fields, + label: String.t(), # human-readable, gettext-translated + control: atom() # which type-aware value control the builder renders + } + + The catalog is the persistence basis the saved-views work (#549) docks onto: + it is derived purely from the field context (groups, fee types, custom fields) + and carries no UI or query state. + + Grouping (confirmed taxonomy): + + * `:quick` — Payment, Active/former (common shortcuts) + * `:membership` — Group, Fee type, Join date, Exit date + * `:custom_fields` — one entry per filterable club-defined custom field + (boolean and date fields; other value types are not filtered on the + overview) + + A group with no descriptors is omitted entirely (no header) — see + `visible_groups/1`. + """ + + use Gettext, backend: MvWeb.Gettext + + @type group :: :quick | :membership | :custom_fields + + @type t :: %{ + key: atom() | String.t(), + group: group(), + label: String.t(), + control: atom() + } + + # Fixed display order of the groups in the picker. + @group_order [:quick, :membership, :custom_fields] + + # Custom-field value types the overview can filter on. + @filterable_custom_field_types [:boolean, :date] + + @doc """ + Returns the ordered list of all descriptors available for the given field + context. Recognised context keys (all optional, default empty): + + * `:groups` — list of `%{id: _, name: _}` group structs + * `:fee_types` — list of fee-type structs + * `:custom_fields` — list of custom-field structs (`:value_type`, `:name`, `:id`) + """ + @spec all(map()) :: [t()] + def all(context) when is_map(context) do + quick() ++ + membership(Map.get(context, :groups, []), Map.get(context, :fee_types, [])) ++ + custom_fields(Map.get(context, :custom_fields, [])) + end + + @doc "The group an individual descriptor belongs to." + @spec group_for(t()) :: group() + def group_for(%{group: group}), do: group + + @doc "The fixed display order of the picker groups." + @spec group_order() :: [group()] + def group_order, do: @group_order + + @doc """ + Returns `[{group, [descriptor]}]` in display order, including only groups that + have at least one descriptor. Groups with no available fields are omitted so + the picker renders no empty header (§1.10). + """ + @spec visible_groups([t()]) :: [{group(), [t()]}] + def visible_groups(descriptors) when is_list(descriptors) do + grouped = Enum.group_by(descriptors, & &1.group) + + @group_order + |> Enum.map(fn group -> {group, Map.get(grouped, group, [])} end) + |> Enum.reject(fn {_group, ds} -> ds == [] end) + end + + # --- static groups -------------------------------------------------------- + + defp quick do + [ + %{key: :payment, group: :quick, label: gettext("Payment"), control: :payment_count}, + %{ + key: :active_former, + group: :quick, + label: gettext("Active / former"), + control: :active_former + } + ] + end + + defp membership(groups, fee_types) do + maybe_group(groups) ++ + maybe_fee_type(fee_types) ++ + [ + %{key: :join_date, group: :membership, label: gettext("Join date"), control: :date_range}, + %{key: :exit_date, group: :membership, label: gettext("Exit date"), control: :exit_date} + ] + end + + defp maybe_group([]), do: [] + + defp maybe_group(_groups), + do: [%{key: :group, group: :membership, label: gettext("Group"), control: :group_membership}] + + defp maybe_fee_type([]), do: [] + + defp maybe_fee_type(_fee_types), + do: [ + %{ + key: :fee_type, + group: :membership, + label: gettext("Fee type"), + control: :fee_type_membership + } + ] + + defp custom_fields(custom_fields) do + custom_fields + |> Enum.filter(&(Map.get(&1, :value_type) in @filterable_custom_field_types)) + |> Enum.map(fn cf -> + %{ + key: to_string(cf.id), + group: :custom_fields, + label: cf.name, + control: control_for_custom_field(cf.value_type) + } + end) + end + + defp control_for_custom_field(:boolean), do: :boolean + defp control_for_custom_field(:date), do: :date_range +end diff --git a/lib/mv_web/live/member_live/index/overview_query.ex b/lib/mv_web/live/member_live/index/overview_query.ex index ac3051f0..1afe499b 100644 --- a/lib/mv_web/live/member_live/index/overview_query.ex +++ b/lib/mv_web/live/member_live/index/overview_query.ex @@ -52,6 +52,7 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do opts[:show_current_cycle], today(opts) ) + |> apply_payment_filter(opts[:payment_filter], payment_period(opts)) |> apply_sort(opts[:sort_field], opts[:sort_order], opts[:custom_fields] || []) end @@ -232,6 +233,40 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do ) end + # --------------------------------------------------------------------------- + # Payment aging filter (period-scoped unpaid-cycle count) + # + # `fully_paid` keeps members with zero unpaid cycles whose cycle_end lies in + # the active period; `{:has_unpaid, n}` keeps members with at least n such + # cycles. Both push to SQL via the `unpaid_cycle_count` calculation (a + # correlated aggregate over `membership_fee_cycles`), so no member set is + # loaded into memory. Suspended/paid cycles are excluded by the calculation. + # --------------------------------------------------------------------------- + + defp payment_period(opts) do + case opts[:payment_period] do + %{from: _, to: _} = period -> period + _ -> %{from: nil, to: nil} + end + end + + defp apply_payment_filter(query, :fully_paid, %{from: from, to: to}) do + Ash.Query.filter( + query, + expr(unpaid_cycle_count(period_from: ^from, period_to: ^to) == 0) + ) + end + + defp apply_payment_filter(query, {:has_unpaid, n}, %{from: from, to: to}) + when is_integer(n) and n > 0 do + Ash.Query.filter( + query, + expr(unpaid_cycle_count(period_from: ^from, period_to: ^to) >= ^n) + ) + end + + defp apply_payment_filter(query, _filter, _period), do: query + # --------------------------------------------------------------------------- # Built-in date filters (join/exit) # --------------------------------------------------------------------------- diff --git a/lib/mv_web/live/member_live/index/payment_aging.ex b/lib/mv_web/live/member_live/index/payment_aging.ex index 18d3163b..9dbe3e0c 100644 --- a/lib/mv_web/live/member_live/index/payment_aging.ex +++ b/lib/mv_web/live/member_live/index/payment_aging.ex @@ -28,8 +28,11 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do @type period :: %{from: Date.t() | nil, to: Date.t() | nil} + @type filter :: nil | :fully_paid | {:has_unpaid, 1..3} + @payment_period_from_param Constants.payment_period_from_param() @payment_period_to_param Constants.payment_period_to_param() + @payment_filter_param Constants.payment_filter_param() @doc """ The default period: all outstanding cycles, all time (both bounds nil). @@ -62,6 +65,39 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do def to_params(_), do: %{} + @doc """ + Decodes the payment-count filter param. + + * `"fully_paid"` → `:fully_paid` (exactly 0 unpaid cycles in the period) + * `"unpaid_1"` / `"unpaid_2"` / `"unpaid_3"` → `{:has_unpaid, N}` + (at least N unpaid cycles in the period) + * anything else → `nil` (no payment-count filter) + """ + @spec parse_filter(term()) :: filter() + def parse_filter("fully_paid"), do: :fully_paid + def parse_filter("unpaid_1"), do: {:has_unpaid, 1} + def parse_filter("unpaid_2"), do: {:has_unpaid, 2} + def parse_filter("unpaid_3"), do: {:has_unpaid, 3} + def parse_filter(_), do: nil + + @doc """ + Encodes a payment-count filter into URL params. `nil` yields the empty map. + """ + @spec filter_to_params(filter()) :: %{optional(String.t()) => String.t()} + def filter_to_params(:fully_paid), do: %{@payment_filter_param => "fully_paid"} + + def filter_to_params({:has_unpaid, n}) when n in 1..3, + do: %{@payment_filter_param => "unpaid_#{n}"} + + def filter_to_params(_), do: %{} + + @doc """ + Decodes the payment-count filter from a full params map. + """ + @spec parse_filter_params(map()) :: filter() + def parse_filter_params(params) when is_map(params), + do: parse_filter(Map.get(params, @payment_filter_param)) + @doc """ Badge descriptor for an unpaid-cycle count. A count of 0 reads "Paid" (success); a positive count reads "N unpaid" (error). diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index cc8dae08..8749a6a5 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -1297,6 +1297,7 @@ msgid "Exit Date" msgstr "Austrittsdatum" #: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/member_live/index/filter_descriptor.ex #: lib/mv_web/live/member_live/show/deactivate_component.ex #, elixir-autogen, elixir-format msgid "Exit date" @@ -1470,6 +1471,7 @@ msgid "Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitrag msgstr "Beitragsstatus-Spalten (Membership Fee Status, Bezahlstatus, Mitgliedsbeitragsstatus) werden immer ignoriert und können nicht importiert werden." #: lib/mv_web/live/import_live/components.ex +#: lib/mv_web/live/member_live/index/filter_descriptor.ex #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format msgid "Fee type" @@ -1906,6 +1908,7 @@ msgid "Join confirmation" msgstr "Beitrittsbestätigung" #: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/member_live/index/filter_descriptor.ex #, elixir-autogen, elixir-format msgid "Join date" msgstr "Beitrittsdatum" @@ -4219,3 +4222,18 @@ msgstr "Keine Adresse" #, elixir-autogen, elixir-format msgid "%{count} unpaid" msgstr "%{count} unbezahlt" + +#: lib/mv_web/live/member_live/index/filter_descriptor.ex +#, elixir-autogen, elixir-format +msgid "Active / former" +msgstr "Aktiv / ehemalig" + +#: lib/mv_web/live/member_live/index/filter_descriptor.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Group" +msgstr "Gruppen" + +#: lib/mv_web/live/member_live/index/filter_descriptor.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Payment" +msgstr "Zahlungen" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 34772344..2e1df9f5 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -1298,6 +1298,7 @@ msgid "Exit Date" msgstr "" #: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/member_live/index/filter_descriptor.ex #: lib/mv_web/live/member_live/show/deactivate_component.ex #, elixir-autogen, elixir-format msgid "Exit date" @@ -1471,6 +1472,7 @@ msgid "Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitrag msgstr "" #: lib/mv_web/live/import_live/components.ex +#: lib/mv_web/live/member_live/index/filter_descriptor.ex #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format msgid "Fee type" @@ -1907,6 +1909,7 @@ msgid "Join confirmation" msgstr "" #: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/member_live/index/filter_descriptor.ex #, elixir-autogen, elixir-format msgid "Join date" msgstr "" @@ -4219,3 +4222,18 @@ msgstr "" #, elixir-autogen, elixir-format msgid "%{count} unpaid" msgstr "" + +#: lib/mv_web/live/member_live/index/filter_descriptor.ex +#, elixir-autogen, elixir-format +msgid "Active / former" +msgstr "" + +#: lib/mv_web/live/member_live/index/filter_descriptor.ex +#, elixir-autogen, elixir-format +msgid "Group" +msgstr "" + +#: lib/mv_web/live/member_live/index/filter_descriptor.ex +#, elixir-autogen, elixir-format +msgid "Payment" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index fe871774..faec9b03 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -1298,6 +1298,7 @@ msgid "Exit Date" msgstr "" #: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/member_live/index/filter_descriptor.ex #: lib/mv_web/live/member_live/show/deactivate_component.ex #, elixir-autogen, elixir-format, fuzzy msgid "Exit date" @@ -1471,6 +1472,7 @@ msgid "Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitrag msgstr "" #: lib/mv_web/live/import_live/components.ex +#: lib/mv_web/live/member_live/index/filter_descriptor.ex #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "Fee type" @@ -1907,6 +1909,7 @@ msgid "Join confirmation" msgstr "" #: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/member_live/index/filter_descriptor.ex #, elixir-autogen, elixir-format, fuzzy msgid "Join date" msgstr "" @@ -2697,6 +2700,7 @@ msgstr "" #: lib/mv/membership/members_pdf.ex #: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/member_live/index/membership_fee_status.ex +#: lib/mv_web/live/member_live/index/payment_aging.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 @@ -4213,3 +4217,23 @@ msgstr "" #, elixir-autogen, elixir-format msgid "No address" msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "%{count} unpaid" +msgstr "" + +#: lib/mv_web/live/member_live/index/filter_descriptor.ex +#, elixir-autogen, elixir-format +msgid "Active / former" +msgstr "" + +#: lib/mv_web/live/member_live/index/filter_descriptor.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Group" +msgstr "" + +#: lib/mv_web/live/member_live/index/filter_descriptor.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Payment" +msgstr "" diff --git a/test/mv_web/live/member_live/date_filter_property_test.exs b/test/mv_web/live/member_live/date_filter_property_test.exs index 8844f911..7160262f 100644 --- a/test/mv_web/live/member_live/date_filter_property_test.exs +++ b/test/mv_web/live/member_live/date_filter_property_test.exs @@ -113,6 +113,74 @@ defmodule MvWeb.MemberLive.Index.DateFilterPropertyTest do end end + # §2.4 — single-source exit-date state (quick ↔ detail) ---------------- + # + # The active/former quick filter is a shortcut onto the very same + # `exit_date` state the detailed control writes: there is no separate + # quick-filter field. This property drives interleaved sequences of quick + # and detailed changes and asserts that `quick_state/1` is always a pure + # function of the single `exit_date` source — i.e. the two views can never + # diverge. + + defp quick_choice_gen, do: member_of([:active, :former, :all]) + + defp command_gen do + one_of([ + gen(all(s <- quick_choice_gen()), do: {:quick, s}), + gen all( + mode <- exit_date_mode_gen(), + from <- optional_date_gen(), + to <- optional_date_gen() + ) do + {:detail, %{mode: mode, from: from, to: to}} + end + ]) + end + + defp apply_command(filters, {:quick, s}), do: DateFilter.set_quick_state(filters, s) + defp apply_command(filters, {:detail, exit_date}), do: Map.put(filters, :exit_date, exit_date) + + defp expected_quick_for(:active_only), do: :active + defp expected_quick_for(:inactive_only), do: :former + defp expected_quick_for(:all), do: :all + defp expected_quick_for(:custom), do: :custom + + property "quick filter is a single-source shortcut onto exit_date, never divergent" do + check all(commands <- list_of(command_gen(), max_length: 12)) do + final = + Enum.reduce(commands, DateFilter.default(), fn command, filters -> + next = apply_command(filters, command) + + # Invariant at every step: quick_state is derived solely from the + # exit_date mode — one consistent value, no second source. + assert DateFilter.quick_state(next) == expected_quick_for(next.exit_date.mode) + + # A quick command resolves to exactly the chosen state and clears bounds. + case command do + {:quick, s} -> + assert DateFilter.quick_state(next) == s + assert next.exit_date.from == nil + assert next.exit_date.to == nil + + _ -> + :ok + end + + next + end) + + # join_date is untouched by any exit_date command (no cross-contamination). + assert final.join_date == DateFilter.default().join_date + end + end + + property "set_quick_state ∘ quick_state round-trips for the three quick states" do + check all(s <- quick_choice_gen(), exit_date <- exit_date_state_gen()) do + filters = Map.put(DateFilter.default(), :exit_date, exit_date) + assert filters |> DateFilter.set_quick_state(s) |> DateFilter.quick_state() == s + end + end + property "encoding then decoding built-in date filter state is identity" do check all( join_date <- join_date_state_gen(), diff --git a/test/mv_web/member_live/index_active_former_test.exs b/test/mv_web/member_live/index_active_former_test.exs new file mode 100644 index 00000000..55093117 --- /dev/null +++ b/test/mv_web/member_live/index_active_former_test.exs @@ -0,0 +1,64 @@ +defmodule MvWeb.MemberLive.IndexActiveFormerTest do + @moduledoc """ + §1.7 — the active/former quick filter is a three-state shortcut onto the + shared `exit_date` state: + + * "Former" → members with a past exit date (`ed_mode=inactive_only`) + * "Active" → members with no or a future exit date (default / `active_only`) + * "All" → every member (`ed_mode=all`) + + These assert the *result semantics* through the live overview and its URL + contract, which the quick-filter UI drives; they are independent of the + particular chip control and survive the builder rework. + """ + use MvWeb.ConnCase, async: false + import Phoenix.LiveViewTest + + alias Mv.Fixtures + + setup %{conn: conn} do + conn = conn_with_oidc_user(conn) + + active = Fixtures.member_fixture(%{first_name: "Ava", last_name: "Active", exit_date: nil}) + + future = + Fixtures.member_fixture(%{ + first_name: "Finn", + last_name: "Future", + exit_date: Date.add(Date.utc_today(), 30) + }) + + former = + Fixtures.member_fixture(%{ + first_name: " Former", + last_name: "Past", + exit_date: Date.add(Date.utc_today(), -30) + }) + + %{conn: conn, active: active, future: future, former: former} + end + + test "Former shows only members with a past exit date", ctx do + {:ok, view, _html} = live(ctx.conn, "/members?ed_mode=inactive_only") + + assert has_element?(view, "#row-#{ctx.former.id}") + refute has_element?(view, "#row-#{ctx.active.id}") + refute has_element?(view, "#row-#{ctx.future.id}") + end + + test "Active shows members with no or a future exit date", ctx do + {:ok, view, _html} = live(ctx.conn, "/members") + + assert has_element?(view, "#row-#{ctx.active.id}") + assert has_element?(view, "#row-#{ctx.future.id}") + refute has_element?(view, "#row-#{ctx.former.id}") + end + + test "All shows every member regardless of exit date", ctx do + {:ok, view, _html} = live(ctx.conn, "/members?ed_mode=all") + + assert has_element?(view, "#row-#{ctx.active.id}") + assert has_element?(view, "#row-#{ctx.future.id}") + assert has_element?(view, "#row-#{ctx.former.id}") + end +end diff --git a/test/mv_web/member_live/index_field_picker_test.exs b/test/mv_web/member_live/index_field_picker_test.exs new file mode 100644 index 00000000..6f208d8c --- /dev/null +++ b/test/mv_web/member_live/index_field_picker_test.exs @@ -0,0 +1,82 @@ +defmodule MvWeb.MemberLive.IndexFieldPickerTest do + @moduledoc """ + §1.10 / §3.5 — the FilterDescriptor catalog behind the field picker. + + Fields are grouped Quick / Membership / Custom fields; a group with no + available fields is omitted (renders no header). The catalog is derived + purely from the field context and is serializable (the persistence basis for + saved views, #549). + """ + use ExUnit.Case, async: true + + alias MvWeb.MemberLive.Index.FilterDescriptor + + defp group_keys(descriptors, group) do + descriptors + |> Enum.filter(&(&1.group == group)) + |> Enum.map(& &1.key) + end + + test "groups are ordered Quick / Membership / Custom fields" do + context = %{ + groups: [%{id: Ecto.UUID.generate(), name: "Board"}], + fee_types: [%{id: Ecto.UUID.generate(), name: "Standard"}], + custom_fields: [%{id: Ecto.UUID.generate(), name: "Newsletter", value_type: :boolean}] + } + + descriptors = FilterDescriptor.all(context) + + assert Enum.map(FilterDescriptor.visible_groups(descriptors), &elem(&1, 0)) == + [:quick, :membership, :custom_fields] + end + + test "quick and membership fields are present with type-aware controls" do + descriptors = FilterDescriptor.all(%{groups: [%{id: Ecto.UUID.generate(), name: "G"}]}) + + assert group_keys(descriptors, :quick) == [:payment, :active_former] + assert :group in group_keys(descriptors, :membership) + assert :join_date in group_keys(descriptors, :membership) + assert :exit_date in group_keys(descriptors, :membership) + + payment = Enum.find(descriptors, &(&1.key == :payment)) + assert payment.control == :payment_count + end + + test "empty custom-fields group is omitted (no header)" do + descriptors = FilterDescriptor.all(%{custom_fields: []}) + + groups = Enum.map(FilterDescriptor.visible_groups(descriptors), &elem(&1, 0)) + refute :custom_fields in groups + end + + test "membership omits Group/Fee type when the club has none" do + descriptors = FilterDescriptor.all(%{groups: [], fee_types: []}) + + membership_keys = group_keys(descriptors, :membership) + refute :group in membership_keys + refute :fee_type in membership_keys + # Join/exit date are always available. + assert :join_date in membership_keys + assert :exit_date in membership_keys + end + + test "one custom-fields descriptor per filterable field; non-filterable types dropped" do + date_id = Ecto.UUID.generate() + bool_id = Ecto.UUID.generate() + + context = %{ + custom_fields: [ + %{id: date_id, name: "Birthday", value_type: :date}, + %{id: bool_id, name: "Consent", value_type: :boolean}, + %{id: Ecto.UUID.generate(), name: "Phone", value_type: :string} + ] + } + + custom = Enum.filter(FilterDescriptor.all(context), &(&1.group == :custom_fields)) + + assert Enum.map(custom, & &1.key) == [to_string(date_id), to_string(bool_id)] + # Keys are plain strings — serializable for #549. + assert Enum.all?(custom, &is_binary(&1.key)) + assert Enum.find(custom, &(&1.key == to_string(date_id))).control == :date_range + end +end diff --git a/test/mv_web/member_live/index_payment_filter_test.exs b/test/mv_web/member_live/index_payment_filter_test.exs new file mode 100644 index 00000000..f1b09fea --- /dev/null +++ b/test/mv_web/member_live/index_payment_filter_test.exs @@ -0,0 +1,77 @@ +defmodule MvWeb.MemberLive.IndexPaymentFilterTest do + @moduledoc """ + §1.16 / §2.2 / §3.3 — the payment-count filter in OverviewQuery. + + `fully_paid` selects members with 0 unpaid cycles in the active period; + `{:has_unpaid, N}` selects members with at least N — consistent with the + aging count (§2.5). All filtering resolves DB-side via the + `unpaid_cycle_count` calculation. + """ + use Mv.DataCase, async: false + + import Mv.Fixtures, only: [create_fee_type: 2, member_fixture_with_actor: 2, create_cycle: 4] + + alias MvWeb.MemberLive.Index.OverviewQuery + + @period %{from: ~D[2024-01-01], to: ~D[2024-12-31]} + + setup do + actor = Mv.Helpers.SystemActor.get_system_actor() + ft = create_fee_type(%{interval: :monthly}, actor) + + # Wipe the seeded/other members so id sets are deterministic. + Mv.Membership.Member + |> Ash.read!(actor: actor) + |> Enum.each(&Ash.destroy!(&1, actor: actor)) + + %{actor: actor, ft: ft} + end + + defp member_with_unpaid(n, actor, ft) do + member = member_fixture_with_actor(%{}, actor) + + Enum.each(1..max(n, 0)//1, fn i -> + create_cycle(member, ft, %{cycle_start: Date.new!(2024, i, 1), status: :unpaid}, actor) + end) + + member + end + + defp ids(filter, actor) do + %{payment_filter: filter, payment_period: @period} + |> OverviewQuery.build() + |> Ash.read!(actor: actor) + |> MapSet.new(& &1.id) + end + + test "fully_paid selects members with zero in-period unpaid cycles", %{actor: actor, ft: ft} do + paid = member_with_unpaid(0, actor, ft) + create_cycle(paid, ft, %{cycle_start: ~D[2024-05-01], status: :paid}, actor) + one = member_with_unpaid(1, actor, ft) + + result = ids(:fully_paid, actor) + assert MapSet.member?(result, paid.id) + refute MapSet.member?(result, one.id) + end + + test "has-unpaid thresholds select members with count >= N", %{actor: actor, ft: ft} do + zero = member_with_unpaid(0, actor, ft) + one = member_with_unpaid(1, actor, ft) + two = member_with_unpaid(2, actor, ft) + three = member_with_unpaid(3, actor, ft) + + assert ids({:has_unpaid, 1}, actor) == MapSet.new([one.id, two.id, three.id]) + assert ids({:has_unpaid, 2}, actor) == MapSet.new([two.id, three.id]) + assert ids({:has_unpaid, 3}, actor) == MapSet.new([three.id]) + refute MapSet.member?(ids({:has_unpaid, 1}, actor), zero.id) + end + + test "suspended cycles do not count toward has-unpaid", %{actor: actor, ft: ft} do + member = member_fixture_with_actor(%{}, actor) + create_cycle(member, ft, %{cycle_start: ~D[2024-01-01], status: :suspended}, actor) + create_cycle(member, ft, %{cycle_start: ~D[2024-02-01], status: :suspended}, actor) + + refute MapSet.member?(ids({:has_unpaid, 1}, actor), member.id) + assert MapSet.member?(ids(:fully_paid, actor), member.id) + end +end diff --git a/test/mv_web/member_live/index_payment_period_test.exs b/test/mv_web/member_live/index_payment_period_test.exs index 7b56240e..1cf7c0ce 100644 --- a/test/mv_web/member_live/index_payment_period_test.exs +++ b/test/mv_web/member_live/index_payment_period_test.exs @@ -5,6 +5,7 @@ defmodule MvWeb.MemberLive.IndexPaymentPeriodTest do backs the badge tooltip (unpaid cycles for the period; suspended shown apart). """ use Mv.DataCase, async: false + use ExUnitProperties import Mv.Fixtures, only: [create_fee_type: 2, member_fixture_with_actor: 2, create_cycle: 4] @@ -39,6 +40,58 @@ defmodule MvWeb.MemberLive.IndexPaymentPeriodTest do end end + describe "payment-count filter codec" do + test "round-trips fully_paid and has-unpaid thresholds" do + for state <- [:fully_paid, {:has_unpaid, 1}, {:has_unpaid, 2}, {:has_unpaid, 3}] do + params = PaymentAging.filter_to_params(state) + assert PaymentAging.parse_filter_params(params) == state + end + end + + test "nil filter serializes to no params and unknown values parse to nil" do + assert PaymentAging.filter_to_params(nil) == %{} + assert PaymentAging.parse_filter_params(%{}) == nil + assert PaymentAging.parse_filter_params(%{"pay_filter" => "bogus"}) == nil + end + + test "serializes to stable string tokens" do + assert PaymentAging.filter_to_params(:fully_paid) == %{"pay_filter" => "fully_paid"} + assert PaymentAging.filter_to_params({:has_unpaid, 2}) == %{"pay_filter" => "unpaid_2"} + end + end + + describe "payment URL round-trip (§2.1, payment keys)" do + @dates [nil, ~D[2020-01-01], ~D[2024-06-30], ~D[2025-12-31]] + @filters [nil, :fully_paid, {:has_unpaid, 1}, {:has_unpaid, 2}, {:has_unpaid, 3}] + + property "decode∘encode is canonical and idempotent for period + payment filter" do + check all( + from <- StreamData.member_of(@dates), + to <- StreamData.member_of(@dates), + filter <- StreamData.member_of(@filters) + ) do + period = %{from: from, to: to} + + params = + period + |> PaymentAging.to_params() + |> Map.merge(PaymentAging.filter_to_params(filter)) + + assert PaymentAging.parse_period(params) == period + assert PaymentAging.parse_filter_params(params) == filter + + # Idempotence: re-encoding the decoded state yields the same params. + reencoded = + params + |> PaymentAging.parse_period() + |> PaymentAging.to_params() + |> Map.merge(PaymentAging.filter_to_params(PaymentAging.parse_filter_params(params))) + + assert reencoded == params + end + end + end + describe "badge/1" do test "0 renders as Paid (success)" do badge = PaymentAging.badge(0) From c547ca19af3ac1fe7cb084b939d212eeec5f481a Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 10 Jul 2026 16:27:15 +0200 Subject: [PATCH 22/30] feat(overview): replace filter panel with add-filter builder and aging column Swaps the unbounded vertical filter panel for a Polaris-style add-filter builder (searchable grouped field picker, type-aware value control, applied chips) and colour-codes the fees column with each member's period-scoped unpaid-cycle count. --- .../add_filter_builder_component.ex | 1032 +++++++++++++++++ .../components/member_filter_component.ex | 1016 ---------------- lib/mv_web/live/member_live/index.ex | 168 +-- lib/mv_web/live/member_live/index.html.heex | 147 +-- .../live/member_live/index/date_filter.ex | 9 +- .../live/member_live/index/export_payload.ex | 13 +- .../member_live/index/filter_descriptor.ex | 2 +- .../live/member_live/index/overview_query.ex | 56 - .../live/member_live/index/payment_aging.ex | 132 ++- lib/mv_web/live/member_live/show.ex | 12 +- priv/gettext/de/LC_MESSAGES/default.po | 426 ++++--- priv/gettext/default.pot | 393 ++++--- priv/gettext/en/LC_MESSAGES/default.po | 422 ++++--- .../member_filter_component_test.exs | 660 ----------- .../member_live/index/export_payload_test.exs | 7 +- .../overview_query_cycle_status_test.exs | 78 -- .../index_add_filter_flow_test.exs | 88 ++ .../member_live/index_column_manager_test.exs | 6 +- .../member_live/index_date_presets_test.exs | 39 + .../index_default_columns_test.exs | 4 +- .../member_live/index_filter_chips_test.exs | 92 ++ .../index_filter_height_property_test.exs | 50 + .../member_live/index_filter_panel_test.exs | 79 -- .../index_filter_parity_property_test.exs | 46 +- .../index_groups_accessibility_test.exs | 35 +- .../member_live/index_groups_filter_test.exs | 15 +- .../index_groups_integration_test.exs | 28 +- .../index_groups_performance_test.exs | 8 +- .../index_groups_url_params_test.exs | 8 +- .../index_membership_fee_status_test.exs | 280 +---- .../member_live/index_payment_period_test.exs | 43 +- .../member_live/index_payment_wiring_test.exs | 72 ++ test/mv_web/member_live/index_test.exs | 416 ++----- test/support/conn_case.ex | 1 + test/support/filter_builder_helpers.ex | 83 ++ 35 files changed, 2817 insertions(+), 3149 deletions(-) create mode 100644 lib/mv_web/live/components/add_filter_builder_component.ex delete mode 100644 lib/mv_web/live/components/member_filter_component.ex delete mode 100644 test/mv_web/components/member_filter_component_test.exs delete mode 100644 test/mv_web/live/member_live/index/overview_query_cycle_status_test.exs create mode 100644 test/mv_web/member_live/index_add_filter_flow_test.exs create mode 100644 test/mv_web/member_live/index_date_presets_test.exs create mode 100644 test/mv_web/member_live/index_filter_chips_test.exs create mode 100644 test/mv_web/member_live/index_filter_height_property_test.exs delete mode 100644 test/mv_web/member_live/index_filter_panel_test.exs create mode 100644 test/mv_web/member_live/index_payment_wiring_test.exs create mode 100644 test/support/filter_builder_helpers.ex diff --git a/lib/mv_web/live/components/add_filter_builder_component.ex b/lib/mv_web/live/components/add_filter_builder_component.ex new file mode 100644 index 00000000..f4d2dc15 --- /dev/null +++ b/lib/mv_web/live/components/add_filter_builder_component.ex @@ -0,0 +1,1032 @@ +defmodule MvWeb.Components.AddFilterBuilderComponent do + @moduledoc """ + The member-overview add-filter builder (replaces the vertical + `MemberFilterComponent`). + + It follows the Polaris-style add-filter flow (Option C, append-right): + + * a "+ Add filter" trigger opens a combobox **field picker** grouped + Quick / Membership / Custom fields (see `FilterDescriptor`); + * picking a field closes the picker and opens a focused, type-aware **value + control** to the right of the existing chips (no detail "Add" button); + * a valid value selection **commits** an applied-filter chip and re-loads the + list; dismissing the pending control without a selection **drops** it with + no re-load; + * applied filters render as **one compact chip per value**, each a real + remove button (`aria-label="Remove filter: …"`, ≥24px target); + * a **Clear all** control removes every active filter in one re-load. + + Filter state itself lives in the parent LiveView assigns; this component is a + pure input layer. It emits the parent's existing filter messages unchanged: + + * `{:group_filter_changed, id_str, :in | :not_in | nil}` + * `{:fee_type_filter_changed, id_str, :in | :not_in | nil}` + * `{:boolean_filter_changed, id_str, true | false | nil}` + * `{:date_filters_changed, date_filters}` + + plus the period-scoped payment messages (§3.3): + + * `{:payment_filter_changed, nil | :fully_paid | {:has_unpaid, 1..3}}` + * `{:payment_period_changed, %{from: Date.t() | nil, to: Date.t() | nil}}` + + and `{:clear_all_filters}` for Clear all. + """ + use MvWeb, :live_component + + alias Mv.Constants + alias MvWeb.MemberLive.Index.DateFilter + alias MvWeb.MemberLive.Index.FilterDescriptor + alias MvWeb.MemberLive.Index.FilterParams + alias MvWeb.MemberLive.Index.PaymentAging + + @group_filter_prefix Constants.group_filter_prefix() + @fee_type_filter_prefix Constants.fee_type_filter_prefix() + @custom_date_filter_prefix Constants.custom_date_filter_prefix() + + @impl true + def mount(socket) do + {:ok, + socket + |> assign(:open_picker, false) + |> assign(:picker_query, "") + |> assign(:pending, nil)} + end + + @impl true + def update(assigns, socket) do + {:ok, + socket + |> assign(:id, assigns.id) + |> assign_membership_inputs(assigns) + |> assign_extra_inputs(assigns)} + end + + defp assign_membership_inputs(socket, assigns) do + socket + |> assign(:groups, assigns[:groups] || []) + |> assign(:group_filters, assigns[:group_filters] || %{}) + |> assign(:fee_types, assigns[:fee_types] || []) + |> assign(:fee_type_filters, assigns[:fee_type_filters] || %{}) + |> assign(:group_filter_prefix, @group_filter_prefix) + |> assign(:fee_type_filter_prefix, @fee_type_filter_prefix) + end + + defp assign_extra_inputs(socket, assigns) do + socket + |> assign(:boolean_custom_fields, assigns[:boolean_custom_fields] || []) + |> assign(:boolean_filters, assigns[:boolean_filters] || %{}) + |> assign(:date_custom_fields, assigns[:date_custom_fields] || []) + |> assign(:date_filters, assigns[:date_filters] || DateFilter.default()) + |> assign(:payment_filter, assigns[:payment_filter]) + |> assign(:payment_period, assigns[:payment_period] || PaymentAging.default_period()) + end + + # -------------------------------------------------------------------------- + # Render + # -------------------------------------------------------------------------- + + @impl true + def render(assigns) do + assigns = + assigns + |> assign(:chips, applied_chips(assigns)) + |> assign(:descriptors, descriptors(assigns)) + + ~H""" +
    +
    + + + <%!-- Field picker: an anchored dropdown (absolute, top-layer via z-index) + so opening it never reflows the toolbar. Sized to content — narrow and + tall, capped at 70vh with the option list scrolling internally. --%> + +
    + +
      +
    • + + {chip.label} + <%!-- Subtle remove control: a ≥24px hit target (WCAG 2.5.8) wrapping a + small glyph whose only hover feedback is a minimal rounded tint around + the × itself, so the interaction stays quiet and no tooltip is used. --%> + + +
    • +
    + + <%!-- Pending filter: the picked field renders a chip anchor and its + type-aware value control opens as an anchored dropdown below it (absolute, + top-layer), so the toolbar never reflows while choosing a value. Clicking + the chip (or Escape / click-away) dismisses without committing (§1.3). --%> +
    + +
    + {render_value_control(assigns)} +
    +
    + + +
    + """ + end + + # --- value controls ------------------------------------------------------- + + defp render_value_control(%{pending: %{control: :payment_count}} = assigns) do + ~H""" +
    +
    + {gettext("Payment")} + +
    + <.input + type="date" + name="pay_from" + label={gettext("Period from")} + class="input input-sm input-bordered" + value={date_iso(@payment_period[:from])} + /> + <.input + type="date" + name="pay_to" + label={gettext("Period to")} + class="input input-sm input-bordered" + value={date_iso(@payment_period[:to])} + /> +
    + """ + end + + defp render_value_control(%{pending: %{control: :active_former}} = assigns) do + ~H""" +
    +
    + {gettext("Active / former")} +
    + + + +
    +
    +
    + """ + end + + defp render_value_control(%{pending: %{control: :group_membership}} = assigns) do + ~H""" +
    +
    + {group.name} +
    + <.in_not_in_radios + name={"#{@group_filter_prefix}#{group.id}"} + current={Map.get(@group_filters, to_string(group.id))} + /> +
    +
    +
    + """ + end + + defp render_value_control(%{pending: %{control: :fee_type_membership}} = assigns) do + ~H""" +
    +
    + {ft.name} +
    + <.in_not_in_radios + name={"#{@fee_type_filter_prefix}#{ft.id}"} + current={Map.get(@fee_type_filters, to_string(ft.id))} + /> +
    +
    +
    + """ + end + + defp render_value_control(%{pending: %{control: :boolean, key: key}} = assigns) do + assigns = assign(assigns, :field_id, to_string(key)) + + ~H""" +
    +
    + {@pending.label} +
    + + + +
    +
    +
    + """ + end + + defp render_value_control(%{pending: %{control: :exit_date}} = assigns) do + ~H""" +
    +
    + {gettext("Exit date")} +
    + +
    +
    + <.input + type="date" + name="ed_from" + label={gettext("From")} + class="input input-sm input-bordered" + value={date_for(@date_filters, :exit_date, :from)} + /> + <.input + type="date" + name="ed_to" + label={gettext("To")} + class="input input-sm input-bordered" + value={date_for(@date_filters, :exit_date, :to)} + /> +
    +
    +
    + """ + end + + defp render_value_control(%{pending: %{control: :date_range, key: key}} = assigns) do + {from_name, to_name, from_val, to_val} = date_range_binding(assigns, key) + + assigns = + assigns + |> assign(:from_name, from_name) + |> assign(:to_name, to_name) + |> assign(:from_val, from_val) + |> assign(:to_val, to_val) + + ~H""" +
    +
    + {@pending.label} +
    + +
    +
    + <.input + type="date" + name={@from_name} + label={gettext("From")} + class="input input-sm input-bordered" + value={@from_val} + /> + <.input + type="date" + name={@to_name} + label={gettext("To")} + class="input input-sm input-bordered" + value={@to_val} + /> +
    +
    +
    + """ + end + + defp render_value_control(assigns), do: ~H"" + + # -------------------------------------------------------------------------- + # Events + # -------------------------------------------------------------------------- + + @impl true + def handle_event("toggle_picker", _params, socket) do + # Toggle so a second click on the trigger closes the open picker (matching the + # standard daisyUI dropdown behaviour). Opening it also clears any pending + # value control and resets the query. + if socket.assigns.open_picker do + {:noreply, assign(socket, :open_picker, false)} + else + {:noreply, + socket |> assign(:open_picker, true) |> assign(:pending, nil) |> assign(:picker_query, "")} + end + end + + @impl true + def handle_event("dismiss", _params, socket) do + # Drop-on-dismiss (§1.3): closing the picker or a pending value control + # without a selection adds no chip and triggers no re-load. + {:noreply, socket |> assign(:open_picker, false) |> assign(:pending, nil)} + end + + @impl true + def handle_event("filter_fields", %{"picker_query" => q}, socket) do + {:noreply, assign(socket, :picker_query, q)} + end + + @impl true + def handle_event("pick_field", %{"key" => key}, socket) do + descriptor = Enum.find(descriptors(socket.assigns), &(to_string(&1.key) == key)) + + {:noreply, + socket + |> assign(:open_picker, false) + |> assign(:picker_query, "") + |> assign(:pending, descriptor)} + end + + @impl true + def handle_event("apply_preset", %{"preset" => preset, "key" => key}, socket) do + case preset_range(preset) do + nil -> + {:noreply, socket} + + {from, to} -> + new_filters = put_date_range(socket.assigns.date_filters, key, from, to) + + maybe_send( + new_filters != socket.assigns.date_filters, + {:date_filters_changed, new_filters} + ) + + {:noreply, assign(socket, :pending, nil)} + end + end + + @impl true + def handle_event("commit", params, socket) do + dispatch_payment(socket, params) + dispatch_quick(socket, params) + + dispatch_prefix( + socket, + params, + @group_filter_prefix, + :group_filter_changed, + socket.assigns.groups + ) + + dispatch_prefix( + socket, + params, + @fee_type_filter_prefix, + :fee_type_filter_changed, + socket.assigns.fee_types + ) + + dispatch_boolean(socket, params) + dispatch_dates(socket, params) + + {:noreply, assign(socket, :pending, nil)} + end + + @impl true + def handle_event("remove_chip", %{"kind" => kind, "id" => id}, socket) do + remove_chip(kind, id, socket) + {:noreply, socket} + end + + @impl true + def handle_event("clear_all", _params, socket) do + send(self(), {:clear_all_filters}) + {:noreply, socket |> assign(:open_picker, false) |> assign(:pending, nil)} + end + + # --- dispatch helpers ----------------------------------------------------- + + defp dispatch_payment(socket, params) do + case Map.get(params, "pay") do + nil -> + :ok + + value -> + filter = PaymentAging.parse_filter(value) + maybe_send(filter != socket.assigns.payment_filter, {:payment_filter_changed, filter}) + + period = %{ + from: parse_date(Map.get(params, "pay_from")), + to: parse_date(Map.get(params, "pay_to")) + } + + maybe_send(period != socket.assigns.payment_period, {:payment_period_changed, period}) + end + end + + defp dispatch_quick(socket, params) do + case Map.get(params, "quick") do + q when q in ["active", "former", "all"] -> + new_filters = + DateFilter.set_quick_state(socket.assigns.date_filters, String.to_existing_atom(q)) + + maybe_send( + new_filters != socket.assigns.date_filters, + {:date_filters_changed, new_filters} + ) + + _ -> + :ok + end + end + + defp dispatch_prefix(socket, params, prefix, message, valid_records) do + if Enum.any?(params, fn {k, _} -> String.starts_with?(k, prefix) end) do + dispatch_prefix_entries(socket, params, prefix, message, valid_records) + end + end + + defp dispatch_prefix_entries(socket, params, prefix, message, valid_records) do + current = current_for(socket, message) + valid_ids = MapSet.new(Enum.map(valid_records, &to_string(&1.id))) + + params + |> FilterParams.parse_prefix_filters(prefix, &FilterParams.parse_in_not_in_value/1) + |> Enum.each(&maybe_send_prefix(&1, current, valid_ids, message)) + end + + defp maybe_send_prefix({id_str, value}, current, valid_ids, message) do + if MapSet.member?(valid_ids, id_str) and Map.get(current, id_str) != value do + send(self(), {message, id_str, value}) + end + end + + defp current_for(socket, :group_filter_changed), do: socket.assigns.group_filters + defp current_for(socket, :fee_type_filter_changed), do: socket.assigns.fee_type_filters + + defp dispatch_boolean(socket, params) do + case Map.get(params, "custom_boolean") do + map when is_map(map) -> + Enum.each(map, &maybe_send_boolean(&1, socket.assigns.boolean_filters)) + + _ -> + :ok + end + end + + defp maybe_send_boolean({id_str, value_str}, current) do + value = parse_tri_state(value_str) + + if Map.get(current, id_str) != value do + send(self(), {:boolean_filter_changed, id_str, value}) + end + end + + defp dispatch_dates(socket, params) do + if has_date_param?(params) do + new_filters = DateFilter.from_params(params, socket.assigns.date_custom_fields) + maybe_send(new_filters != socket.assigns.date_filters, {:date_filters_changed, new_filters}) + end + end + + defp has_date_param?(params) do + Enum.any?(params, fn {k, _} -> + k in ["ed_mode", "ed_from", "ed_to", "jd_from", "jd_to"] or + String.starts_with?(k, @custom_date_filter_prefix) + end) + end + + defp remove_chip("payment", _id, socket) do + maybe_send(socket.assigns.payment_filter != nil, {:payment_filter_changed, nil}) + end + + defp remove_chip("payment_period", _id, _socket) do + send(self(), {:payment_period_changed, PaymentAging.default_period()}) + end + + defp remove_chip("group", id, _socket), do: send(self(), {:group_filter_changed, id, nil}) + defp remove_chip("fee_type", id, _socket), do: send(self(), {:fee_type_filter_changed, id, nil}) + defp remove_chip("boolean", id, _socket), do: send(self(), {:boolean_filter_changed, id, nil}) + + defp remove_chip("date", id, socket) do + new_filters = clear_date_field(socket.assigns.date_filters, id) + send(self(), {:date_filters_changed, new_filters}) + end + + defp remove_chip(_kind, _id, _socket), do: :ok + + defp maybe_send(true, message), do: send(self(), message) + defp maybe_send(false, _message), do: :ok + + # -------------------------------------------------------------------------- + # Chips + # -------------------------------------------------------------------------- + + defp applied_chips(assigns) do + group_chips(assigns) ++ + fee_type_chips(assigns) ++ + boolean_chips(assigns) ++ + date_chips(assigns) ++ + payment_chips(assigns) + end + + defp group_chips(assigns) do + names = id_name_map(assigns.groups) + + for {id, value} <- assigns.group_filters do + label = + case value do + :in -> gettext("Group: %{name}", name: Map.get(names, id, id)) + :not_in -> gettext("Group: not %{name}", name: Map.get(names, id, id)) + end + + %{kind: "group", id: id, label: label} + end + end + + defp fee_type_chips(assigns) do + names = id_name_map(assigns.fee_types) + + for {id, value} <- assigns.fee_type_filters do + label = + case value do + :in -> gettext("Fee type: %{name}", name: Map.get(names, id, id)) + :not_in -> gettext("Fee type: not %{name}", name: Map.get(names, id, id)) + end + + %{kind: "fee_type", id: id, label: label} + end + end + + defp boolean_chips(assigns) do + names = id_name_map(assigns.boolean_custom_fields) + + for {id, value} <- assigns.boolean_filters do + state = if value, do: gettext("Yes"), else: gettext("No") + %{kind: "boolean", id: id, label: "#{Map.get(names, id, id)}: #{state}"} + end + end + + defp date_chips(assigns) do + filters = assigns.date_filters + + exit_chip(filters) ++ + builtin_range_chip(filters, :join_date, gettext("Joined")) ++ + custom_date_chips(assigns) + end + + defp exit_chip(filters) do + # :active_only is the default exit-date state, so it is not a user-applied + # filter and renders no chip; only the non-default states do. + case DateFilter.quick_state(filters) do + :active -> [] + :former -> [%{kind: "date", id: "exit_date", label: gettext("Former")}] + :custom -> range_chip("date", "exit_date", gettext("Exited"), Map.get(filters, :exit_date)) + :all -> [%{kind: "date", id: "exit_date", label: gettext("All (incl. former)")}] + end + end + + defp builtin_range_chip(filters, field, prefix) do + range_chip("date", to_string(field), prefix, Map.get(filters, field)) + end + + defp custom_date_chips(assigns) do + names = id_name_map(assigns.date_custom_fields) + + assigns.date_filters + |> Enum.flat_map(fn + {k, %{} = bounds} when is_binary(k) -> + range_chip("date", k, Map.get(names, k, k), bounds) + + _ -> + [] + end) + end + + defp range_chip(kind, id, prefix, %{} = bounds) do + from = bounds[:from] + to = bounds[:to] + + if from || to do + [%{kind: kind, id: id, label: "#{prefix}: #{date_iso(from) || "…"}–#{date_iso(to) || "…"}"}] + else + [] + end + end + + defp range_chip(_kind, _id, _prefix, _), do: [] + + defp payment_chips(assigns) do + filter_chip = + case assigns.payment_filter do + :fully_paid -> + [%{kind: "payment", id: "payment", label: gettext("Fully paid")}] + + {:has_unpaid, n} -> + [%{kind: "payment", id: "payment", label: gettext("≥ %{n} unpaid", n: n)}] + + _ -> + [] + end + + period = assigns.payment_period || %{} + + period_chip = + if period[:from] || period[:to] do + label = + "#{gettext("Period")}: #{date_iso(period[:from]) || "…"}–#{date_iso(period[:to]) || "…"}" + + [%{kind: "payment_period", id: "payment_period", label: label}] + else + [] + end + + filter_chip ++ period_chip + end + + # -------------------------------------------------------------------------- + # Helpers + # -------------------------------------------------------------------------- + + defp descriptors(assigns) do + FilterDescriptor.all(%{ + groups: assigns.groups, + fee_types: assigns.fee_types, + custom_fields: assigns.boolean_custom_fields ++ assigns.date_custom_fields + }) + |> filter_by_query(assigns[:picker_query]) + end + + defp filter_by_query(descriptors, q) when is_binary(q) and q != "" do + down = String.downcase(q) + Enum.filter(descriptors, &String.contains?(String.downcase(&1.label), down)) + end + + defp filter_by_query(descriptors, _q), do: descriptors + + defp id_name_map(records) do + Map.new(records, fn r -> {to_string(r.id), r.name} end) + end + + attr :name, :string, required: true + attr :current, :atom, default: nil + + defp in_not_in_radios(assigns) do + ~H""" + + + + """ + end + + defp group_label(:quick), do: gettext("Quick") + defp group_label(:membership), do: gettext("Membership") + defp group_label(:custom_fields), do: gettext("Custom fields") + + defp exit_modes do + [ + {"active_only", gettext("Active only")}, + {"all", gettext("All")}, + {"inactive_only", gettext("Former only")}, + {"custom", gettext("Range")} + ] + end + + defp exit_mode(%{exit_date: %{mode: mode}}), do: mode + defp exit_mode(_), do: :active_only + + defp quick(filters), do: DateFilter.quick_state(filters) + + defp parse_tri_state("true"), do: true + defp parse_tri_state("false"), do: false + defp parse_tri_state(_), do: nil + + defp date_iso(%Date{} = d), do: Date.to_iso8601(d) + defp date_iso(_), do: nil + + defp date_for(filters, field, bound) do + case filters do + %{^field => %{^bound => %Date{} = d}} -> Date.to_iso8601(d) + _ -> "" + end + end + + # Builds the {from_name, to_name, from_val, to_val} tuple for a date_range + # control keyed by a built-in field (:join_date) or a custom date field UUID. + defp date_range_binding(assigns, :join_date) do + {"jd_from", "jd_to", date_for(assigns.date_filters, :join_date, :from), + date_for(assigns.date_filters, :join_date, :to)} + end + + defp date_range_binding(assigns, key) do + id = to_string(key) + bounds = Map.get(assigns.date_filters, id, %{}) + + {"#{@custom_date_filter_prefix}#{id}_from", "#{@custom_date_filter_prefix}#{id}_to", + date_iso(bounds[:from]) || "", date_iso(bounds[:to]) || ""} + end + + # --- date presets --------------------------------------------------------- + + defp date_presets do + [ + {"this_year", gettext("This year")}, + {"last_year", gettext("Last year")}, + {"this_month", gettext("This month")}, + {"last_30_days", gettext("Last 30 days")} + ] + end + + defp preset_range("this_year") do + y = Date.utc_today().year + {Date.new!(y, 1, 1), Date.new!(y, 12, 31)} + end + + defp preset_range("last_year") do + y = Date.utc_today().year - 1 + {Date.new!(y, 1, 1), Date.new!(y, 12, 31)} + end + + defp preset_range("this_month") do + today = Date.utc_today() + {Date.beginning_of_month(today), Date.end_of_month(today)} + end + + defp preset_range("last_30_days") do + today = Date.utc_today() + {Date.add(today, -30), today} + end + + defp preset_range(_), do: nil + + # Writes a from/to range for a built-in or custom date field into the date + # filter map, matching DateFilter's internal shape. + defp put_date_range(filters, "join_date", from, to) do + Map.put(filters, :join_date, %{from: from, to: to}) + end + + defp put_date_range(filters, "exit_date", from, to) do + Map.put(filters, :exit_date, %{mode: :custom, from: from, to: to}) + end + + defp put_date_range(filters, key, from, to) when is_binary(key) do + Map.put(filters, key, %{from: from, to: to}) + end + + defp clear_date_field(filters, "join_date"), + do: Map.put(filters, :join_date, %{from: nil, to: nil}) + + defp clear_date_field(filters, "exit_date") do + Map.put(filters, :exit_date, %{mode: :active_only, from: nil, to: nil}) + end + + defp clear_date_field(filters, key) when is_binary(key), do: Map.delete(filters, key) + + defp parse_date(value) when is_binary(value) do + case Date.from_iso8601(String.trim(value)) do + {:ok, date} -> date + _ -> nil + end + end + + defp parse_date(_), do: nil +end diff --git a/lib/mv_web/live/components/member_filter_component.ex b/lib/mv_web/live/components/member_filter_component.ex deleted file mode 100644 index a0753387..00000000 --- a/lib/mv_web/live/components/member_filter_component.ex +++ /dev/null @@ -1,1016 +0,0 @@ -defmodule MvWeb.Components.MemberFilterComponent do - @moduledoc """ - Provides the MemberFilter Live-Component. - - A DaisyUI dropdown filter for filtering members by payment status and boolean custom fields. - Uses radio inputs in a segmented control pattern (join + btn) for tri-state boolean filters. - - ## Design Decisions - - - Uses `div` panel instead of `ul.menu/li` structure to avoid DaisyUI menu styles - (padding, display, hover, font sizes) that would interfere with form controls. - - Filter controls are form elements (fieldset with legend, radio inputs), not menu items. - Uses semantic `
    ` and `` for proper accessibility and form structure. - - Dropdown stays open when clicking filter segments to allow multiple filter changes. - - Uses `phx-change` on form for radio inputs instead of individual `phx-click` events. - - ## Props - - `:cycle_status_filter` - Current payment filter state: `nil` (all), `:paid`, or `:unpaid` - - `:groups` - List of groups (for per-group filter rows) - - `:group_filters` - Map of active group filters: `%{group_id => :in | :not_in}` (nil = All for that group). - Multiple active filters combine with AND (member must match all selected group conditions). - - `:fee_types` - List of membership fee types (for per-fee-type filter rows) - - `:fee_type_filters` - Map of active fee type filters: `%{fee_type_id => :in | :not_in}` (nil = All). - - `:boolean_custom_fields` - List of boolean custom fields to display - - `:boolean_filters` - Map of active boolean filters: `%{custom_field_id => true | false}` - - `:date_custom_fields` - List of date-typed custom fields rendered in the - "Custom date fields" section (each with `:id`, `:name`, `:value_type`). - - `:date_filters` - Date filter state map (see `MvWeb.MemberLive.Index.DateFilter`): - built-in `:join_date` / `:exit_date` bounds and mode, plus optional - UUID-keyed custom date field bound entries. - - `:id` - Component ID (required) - - `:member_count` - Number of filtered members to display in badge (optional, default: 0) - - ## Events - - Sends `{:payment_filter_changed, filter}` to parent when payment filter changes - - Sends `{:group_filter_changed, group_id_str, value}` to parent when a group filter changes (value: nil | :in | :not_in) - - Sends `{:fee_type_filter_changed, fee_type_id_str, value}` to parent when a fee type filter changes (value: nil | :in | :not_in) - - Sends `{:boolean_filter_changed, custom_field_id, filter_value}` to parent when boolean filter changes - - Sends `{:date_filters_changed, new_filters}` to parent when any date - filter input changes (built-in date bounds, exit_date mode, or custom - date field bounds). - """ - use MvWeb, :live_component - - alias MvWeb.MemberLive.Index.DateFilter - alias MvWeb.MemberLive.Index.FilterParams - - @group_filter_prefix Mv.Constants.group_filter_prefix() - @fee_type_filter_prefix Mv.Constants.fee_type_filter_prefix() - @custom_date_filter_prefix Mv.Constants.custom_date_filter_prefix() - - @impl true - def mount(socket) do - {:ok, assign(socket, :open, false)} - end - - @impl true - def update(assigns, socket) do - socket = - socket - |> assign(:id, assigns.id) - |> assign(:cycle_status_filter, assigns[:cycle_status_filter]) - |> assign_group_assigns(assigns) - |> assign_fee_type_assigns(assigns) - |> assign_boolean_assigns(assigns) - |> assign_date_assigns(assigns) - |> assign(:member_count, assigns[:member_count] || 0) - - {:ok, socket} - end - - defp assign_group_assigns(socket, assigns) do - socket - |> assign(:groups, assigns[:groups] || []) - |> assign(:group_filters, assigns[:group_filters] || %{}) - |> assign(:group_filter_prefix, @group_filter_prefix) - end - - defp assign_fee_type_assigns(socket, assigns) do - socket - |> assign(:fee_types, assigns[:fee_types] || []) - |> assign(:fee_type_filters, assigns[:fee_type_filters] || %{}) - |> assign(:fee_type_filter_prefix, @fee_type_filter_prefix) - end - - defp assign_boolean_assigns(socket, assigns) do - socket - |> assign(:boolean_custom_fields, assigns[:boolean_custom_fields] || []) - |> assign(:boolean_filters, assigns[:boolean_filters] || %{}) - end - - defp assign_date_assigns(socket, assigns) do - socket - |> assign(:date_custom_fields, assigns[:date_custom_fields] || []) - |> assign(:date_filters, assigns[:date_filters] || DateFilter.default()) - |> assign(:custom_date_filter_prefix, @custom_date_filter_prefix) - end - - @impl true - def render(assigns) do - ~H""" -
    - <.button - type="button" - variant="secondary" - class={[ - "gap-2", - (@cycle_status_filter || map_size(@group_filters) > 0 || - map_size(@fee_type_filters) > 0 || - active_boolean_filters_count(@boolean_filters) > 0 || - date_filters_active?(@date_filters)) && - "btn-active" - ]} - phx-click="toggle_dropdown" - phx-target={@myself} - aria-haspopup="true" - aria-expanded={to_string(@open)} - aria-label={gettext("Filter members")} - > - <.icon name="hero-funnel" class="h-5 w-5" /> - - <.badge - :if={active_boolean_filters_count(@boolean_filters) > 0} - variant="primary" - size="sm" - > - {active_boolean_filters_count(@boolean_filters)} - - <.badge - :if={ - (@cycle_status_filter || map_size(@group_filters) > 0 || - map_size(@fee_type_filters) > 0 || - date_filters_active?(@date_filters)) && - active_boolean_filters_count(@boolean_filters) == 0 - } - variant="primary" - size="sm" - > - {@member_count} - - <.icon name="hero-chevron-down" class="size-4" /> - - - - -
    - """ - end - - @impl true - def handle_event("toggle_dropdown", _params, socket) do - {:noreply, assign(socket, :open, !socket.assigns.open)} - end - - @impl true - def handle_event("close_dropdown", _params, socket) do - {:noreply, assign(socket, :open, false)} - end - - @impl true - def handle_event("update_filters", params, socket) do - payment_filter = parse_payment_filter(params) - - group_filters_parsed = - FilterParams.parse_prefix_filters( - params, - @group_filter_prefix, - &FilterParams.parse_in_not_in_value/1 - ) - - fee_type_filters_parsed = - FilterParams.parse_prefix_filters( - params, - @fee_type_filter_prefix, - &FilterParams.parse_in_not_in_value/1 - ) - - custom_boolean_filters_parsed = parse_custom_boolean_filters(params) - new_date_filters = DateFilter.from_params(params, socket.assigns.date_custom_fields) - - dispatch_payment_filter_change(socket, payment_filter) - dispatch_group_filter_changes(socket, group_filters_parsed) - dispatch_fee_type_filter_changes(socket, fee_type_filters_parsed) - dispatch_boolean_filter_changes(socket, custom_boolean_filters_parsed) - dispatch_date_filters_change(socket, new_date_filters) - - {:noreply, socket} - end - - @impl true - def handle_event("reset_filters", _params, socket) do - # Send single message to reset all filters at once (performance optimization) - # This avoids N×2 load_members() calls when resetting multiple filters - send( - self(), - {:reset_all_filters, - %{ - cycle_status_filter: nil, - boolean_filters: %{}, - group_filters: %{}, - fee_type_filters: %{} - }} - ) - - # Close dropdown after reset - {:noreply, assign(socket, :open, false)} - end - - # Parse tri-state filter value: "all" | "true" | "false" -> nil | true | false - defp parse_tri_state("true"), do: true - defp parse_tri_state("false"), do: false - defp parse_tri_state("all"), do: nil - defp parse_tri_state(_), do: nil - - defp parse_payment_filter(params) do - case Map.get(params, "payment_filter") do - "paid" -> :paid - "unpaid" -> :unpaid - _ -> nil - end - end - - defp parse_custom_boolean_filters(params) do - params - |> Map.get("custom_boolean", %{}) - |> Enum.reduce(%{}, fn {id_str, value_str}, acc -> - Map.put(acc, id_str, parse_tri_state(value_str)) - end) - end - - defp dispatch_payment_filter_change(socket, payment_filter) do - if payment_filter != socket.assigns.cycle_status_filter do - send(self(), {:payment_filter_changed, payment_filter}) - end - end - - defp dispatch_group_filter_changes(socket, group_filters_parsed) do - current = socket.assigns.group_filters - valid_ids = MapSet.new(Enum.map(socket.assigns.groups, &to_string(&1.id))) - - Enum.each(group_filters_parsed, fn {id_str, new_value} -> - if MapSet.member?(valid_ids, id_str) and Map.get(current, id_str) != new_value do - send(self(), {:group_filter_changed, id_str, new_value}) - end - end) - end - - defp dispatch_fee_type_filter_changes(socket, fee_type_filters_parsed) do - current = socket.assigns.fee_type_filters - valid_ids = MapSet.new(Enum.map(socket.assigns.fee_types, &to_string(&1.id))) - - Enum.each(fee_type_filters_parsed, fn {id_str, new_value} -> - if MapSet.member?(valid_ids, id_str) and Map.get(current, id_str) != new_value do - send(self(), {:fee_type_filter_changed, id_str, new_value}) - end - end) - end - - defp dispatch_boolean_filter_changes(socket, custom_boolean_filters_parsed) do - current = socket.assigns.boolean_filters - - Enum.each(custom_boolean_filters_parsed, fn {id_str, new_value} -> - if Map.get(current, id_str) != new_value do - send(self(), {:boolean_filter_changed, id_str, new_value}) - end - end) - end - - defp dispatch_date_filters_change(socket, new_date_filters) do - if new_date_filters != socket.assigns.date_filters do - send(self(), {:date_filters_changed, new_date_filters}) - end - end - - # Get display label for button - defp button_label( - cycle_status_filter, - groups, - group_filters, - fee_types, - fee_type_filters, - boolean_custom_fields, - boolean_filters, - date_filters - ) do - active_count = - count_active_filter_categories( - cycle_status_filter, - group_filters, - fee_type_filters, - boolean_filters, - date_filters - ) - - if active_count >= 2 do - ngettext("%{count} filter active", "%{count} filters active", active_count, - count: active_count - ) - else - cond do - cycle_status_filter -> - payment_filter_label(cycle_status_filter) - - map_size(group_filters) > 0 -> - group_filters_label(groups, group_filters) - - map_size(fee_type_filters) > 0 -> - fee_type_filters_label(fee_types, fee_type_filters) - - map_size(boolean_filters) > 0 -> - boolean_filter_label(boolean_custom_fields, boolean_filters) - - date_filters_active?(date_filters) -> - gettext("Dates") - - true -> - gettext("Apply filters") - end - end - end - - defp count_active_filter_categories( - cycle_status_filter, - group_filters, - fee_type_filters, - boolean_filters, - date_filters - ) do - [ - cycle_status_filter, - map_size(group_filters) > 0, - map_size(fee_type_filters) > 0, - map_size(boolean_filters) > 0, - date_filters_active?(date_filters) - ] - |> Enum.count(& &1) - end - - # Date filter is "active" when its state differs from the default — i.e. the - # user selected something other than active-only with no custom date bounds. - defp date_filters_active?(date_filters) when is_map(date_filters) do - date_filters != DateFilter.default() - end - - defp date_filters_active?(_), do: false - - defp group_filters_label(_groups, group_filters) when map_size(group_filters) == 0, - do: gettext("All") - - defp group_filters_label(groups, group_filters) do - groups_by_id = Map.new(groups, fn g -> {to_string(g.id), g.name} end) - - names = - group_filters - |> Enum.map(fn {group_id_str, _} -> Map.get(groups_by_id, group_id_str) end) - |> Enum.reject(&is_nil/1) - - label = Enum.join(names, ", ") - truncate_label(label, 30) - end - - defp fee_type_filters_label(_fee_types, fee_type_filters) when map_size(fee_type_filters) == 0, - do: gettext("All") - - defp fee_type_filters_label(fee_types, fee_type_filters) do - fee_types_by_id = Map.new(fee_types, fn ft -> {to_string(ft.id), ft.name} end) - - parts = - fee_type_filters - |> Enum.map(fn {fee_type_id_str, value} -> - fee_type_filter_part(Map.get(fee_types_by_id, fee_type_id_str), value) - end) - |> Enum.reject(&is_nil/1) - - label = Enum.join(parts, ", ") - truncate_label(label, 30) - end - - defp fee_type_filter_part(nil, _value), do: nil - - defp fee_type_filter_part(name, :not_in), do: gettext("without %{name}", name: name) - defp fee_type_filter_part(name, _), do: name - - # Get payment filter label - defp payment_filter_label(nil), do: gettext("All") - defp payment_filter_label(:paid), do: gettext("Paid") - defp payment_filter_label(:unpaid), do: gettext("Unpaid") - - # Get boolean filter label (comma-separated list of active filter names) - defp boolean_filter_label(_boolean_custom_fields, boolean_filters) - when map_size(boolean_filters) == 0 do - gettext("All") - end - - defp boolean_filter_label(boolean_custom_fields, boolean_filters) do - # Get names of active boolean filters - active_filter_names = - boolean_filters - |> Enum.map(fn {custom_field_id_str, _value} -> - Enum.find(boolean_custom_fields, fn cf -> to_string(cf.id) == custom_field_id_str end) - end) - |> Enum.filter(&(&1 != nil)) - |> Enum.map(& &1.name) - - # Join with comma and truncate if too long - label = Enum.join(active_filter_names, ", ") - truncate_label(label, 30) - end - - # Truncate label if longer than max_length - defp truncate_label(label, max_length) when byte_size(label) <= max_length, do: label - - defp truncate_label(label, max_length) do - String.slice(label, 0, max_length) <> "..." - end - - # Count active boolean filters - defp active_boolean_filters_count(boolean_filters) do - map_size(boolean_filters) - end - - # Get CSS classes for payment filter label based on current state - defp payment_filter_label_class(current_filter, expected_value) do - base_classes = "join-item btn btn-sm" - is_active = current_filter == expected_value - - cond do - # All button (nil expected) - expected_value == nil -> - if is_active do - "#{base_classes} btn-active" - else - "#{base_classes} btn" - end - - # Paid button - expected_value == :paid -> - if is_active do - "#{base_classes} btn-success btn-active" - else - "#{base_classes} btn" - end - - # Unpaid button - expected_value == :unpaid -> - if is_active do - "#{base_classes} btn-error btn-active" - else - "#{base_classes} btn" - end - - true -> - "#{base_classes} btn-outline" - end - end - - # Shared CSS classes for in/not_in filter labels (groups and fee types) - defp in_not_in_filter_label_class(filters, id, expected_value) do - base_classes = "join-item btn btn-sm" - current_value = Map.get(filters, to_string(id)) - is_active = current_value == expected_value - - case {expected_value, is_active} do - {_, false} -> "#{base_classes} btn" - {nil, true} -> "#{base_classes} btn-active" - {:in, true} -> "#{base_classes} btn-success btn-active" - {:not_in, true} -> "#{base_classes} btn-error btn-active" - end - end - - defp group_filter_label_class(group_filters, group_id, expected_value) do - in_not_in_filter_label_class(group_filters, group_id, expected_value) - end - - defp fee_type_filter_label_class(fee_type_filters, fee_type_id, expected_value) do - in_not_in_filter_label_class(fee_type_filters, fee_type_id, expected_value) - end - - # Get CSS classes for boolean filter label based on current state - defp boolean_filter_label_class(boolean_filters, custom_field_id, expected_value) do - base_classes = "join-item btn btn-sm" - current_value = Map.get(boolean_filters, to_string(custom_field_id)) - is_active = current_value == expected_value - - cond do - # All button (nil expected) - expected_value == nil -> - if is_active do - "#{base_classes} btn-active" - else - "#{base_classes} btn" - end - - # True button - expected_value == true -> - if is_active do - "#{base_classes} btn-success btn-active" - else - "#{base_classes} btn" - end - - # False button - expected_value == false -> - if is_active do - "#{base_classes} btn-error btn-active" - else - "#{base_classes} btn" - end - - true -> - "#{base_classes} btn-outline" - end - end - - # --- Date filter helpers ---------------------------------------------- - - defp exit_mode(%{exit_date: %{mode: mode}}), do: mode - defp exit_mode(_), do: :active_only - - defp exit_mode_label_class(date_filters, expected) do - base_classes = "join-item btn btn-sm" - - if exit_mode(date_filters) == expected do - "#{base_classes} btn-active" - else - "#{base_classes} btn" - end - end - - defp date_value_for_input(date_filters, field, bound) do - case date_filters do - %{^field => %{^bound => %Date{} = d}} -> Date.to_iso8601(d) - _ -> "" - end - end - - defp custom_date_value_for_input(date_filters, field_id, bound) do - key = to_string(field_id) - - case Map.get(date_filters, key) do - %{^bound => %Date{} = d} -> Date.to_iso8601(d) - _ -> "" - end - end -end diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 6933e005..b519aa2d 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -24,14 +24,14 @@ defmodule MvWeb.MemberLive.Index do - `select_row_and_navigate` - open a member's show page - `load_more` - fetch and append the next keyset page (infinite scroll) - `sort` - change the active sort column/direction - - `toggle_cycle_view` - switch the payment-status view between current and last cycle - `copy_emails` - copy emails of the selected members, or of the whole filtered set when none are selected ## Messages (`handle_info`, from the filter/search/view-settings components) - `search_changed`, `view_setting_toggled`, `field_toggled`, `fields_selected`, - `fields_reset`, `reset_all_filters`, and the per-filter change messages - (`group_filter_changed`, `fee_type_filter_changed`, `boolean_filter_changed`, - `date_filters_changed`, `payment_filter_changed`) + `fields_reset`, `reset_all_filters`, `clear_all_filters`, and the per-filter + change messages (`group_filter_changed`, `fee_type_filter_changed`, + `boolean_filter_changed`, `date_filters_changed`, `payment_filter_changed`, + `payment_period_changed`) ## Implementation Notes - Filtering/sorting/pagination run in PostgreSQL via Ash; the socket holds only @@ -58,6 +58,7 @@ defmodule MvWeb.MemberLive.Index do alias MvWeb.MemberLive.Index.Formatter alias MvWeb.MemberLive.Index.MembershipFeeStatus alias MvWeb.MemberLive.Index.OverviewQuery + alias MvWeb.MemberLive.Index.PaymentAging alias MvWeb.MemberLive.Index.ViewSettings require Ash.Query @@ -174,7 +175,8 @@ defmodule MvWeb.MemberLive.Index do |> assign(:query, "") |> assign_new(:sort_field, fn -> :first_name end) |> assign_new(:sort_order, fn -> :asc end) - |> assign(:cycle_status_filter, nil) + |> assign(:payment_filter, nil) + |> assign(:payment_period, PaymentAging.default_period()) |> assign(:group_filters, %{}) |> assign(:groups, groups) |> assign(:fee_type_filters, %{}) @@ -203,8 +205,6 @@ defmodule MvWeb.MemberLive.Index do :member_fields_visible_computed, FieldVisibility.get_visible_member_fields_computed(initial_selection) ) - |> assign(:show_current_cycle, false) - |> assign(:membership_fee_status_filter, nil) |> assign(:page, nil) |> assign(:after_cursor, nil) |> assign(:more?, false) @@ -309,29 +309,6 @@ defmodule MvWeb.MemberLive.Index do |> update_selection_assigns()} end - @impl true - def handle_event("toggle_cycle_view", _params, socket) do - new_show_current = !socket.assigns.show_current_cycle - - socket = - socket - |> assign(:show_current_cycle, new_show_current) - |> load_members() - |> scroll_list_to_top() - |> update_selection_assigns() - - query_params = - build_query_params(opts_for_query_params(socket, %{show_current_cycle: new_show_current})) - |> maybe_add_field_selection( - socket.assigns[:user_field_selection], - socket.assigns[:fields_in_url?] || false - ) - - new_path = ~p"/members?#{query_params}" - - {:noreply, push_reload(socket, new_path)} - end - @impl true def handle_event("copy_emails", _params, socket) do # Recipients follow the current scope, re-queried from the DB so a no-selection @@ -449,13 +426,13 @@ defmodule MvWeb.MemberLive.Index do def handle_info({:payment_filter_changed, filter}, socket) do socket = socket - |> assign(:cycle_status_filter, filter) + |> assign(:payment_filter, filter) |> load_members() |> scroll_list_to_top() |> update_selection_assigns() query_params = - build_query_params(opts_for_query_params(socket, %{cycle_status_filter: filter})) + build_query_params(opts_for_query_params(socket, %{payment_filter: filter})) |> maybe_add_field_selection( socket.assigns[:user_field_selection], socket.assigns[:fields_in_url?] || false @@ -465,6 +442,42 @@ defmodule MvWeb.MemberLive.Index do {:noreply, push_reload(socket, new_path)} end + @impl true + def handle_info({:payment_period_changed, period}, socket) do + socket = + socket + |> assign(:payment_period, period) + |> load_members() + |> scroll_list_to_top() + |> update_selection_assigns() + + query_params = + build_query_params(opts_for_query_params(socket, %{payment_period: period})) + |> maybe_add_field_selection( + socket.assigns[:user_field_selection], + socket.assigns[:fields_in_url?] || false + ) + + new_path = ~p"/members?#{query_params}" + {:noreply, push_reload(socket, new_path)} + end + + @impl true + def handle_info({:clear_all_filters}, socket) do + handle_info( + {:reset_all_filters, + %{ + payment_filter: nil, + payment_period: PaymentAging.default_period(), + group_filters: %{}, + fee_type_filters: %{}, + boolean_filters: %{}, + date_filters: DateFilter.default() + }}, + socket + ) + end + @impl true def handle_info({:boolean_filter_changed, custom_field_id_str, filter_value}, socket) do updated_filters = @@ -573,7 +586,8 @@ defmodule MvWeb.MemberLive.Index do def handle_info({:reset_all_filters, %{} = opts}, socket) do socket = socket - |> assign(:cycle_status_filter, Map.get(opts, :cycle_status_filter)) + |> assign(:payment_filter, Map.get(opts, :payment_filter)) + |> assign(:payment_period, Map.get(opts, :payment_period, PaymentAging.default_period())) |> assign(:group_filters, Map.get(opts, :group_filters, %{})) |> assign(:fee_type_filters, Map.get(opts, :fee_type_filters, %{})) |> assign(:boolean_custom_field_filters, Map.get(opts, :boolean_filters, %{})) @@ -663,12 +677,12 @@ defmodule MvWeb.MemberLive.Index do socket |> maybe_update_search(params) |> maybe_update_sort(params) - |> maybe_update_cycle_status_filter(params) + |> maybe_update_payment_filter(params) + |> maybe_update_payment_period(params) |> maybe_update_group_filters(params) |> maybe_update_fee_type_filters(params) |> maybe_update_boolean_filters(params) |> maybe_update_date_filters(params) - |> maybe_update_show_current_cycle(params) |> assign(:fields_in_url?, fields_in_url?) |> assign(:query, params["query"]) |> assign_visibility_derivations(final_selection) @@ -712,10 +726,10 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.query, socket.assigns.sort_field, socket.assigns.sort_order, - socket.assigns.cycle_status_filter, + socket.assigns.payment_filter, + socket.assigns.payment_period, socket.assigns[:group_filters], socket.assigns[:fee_type_filters], - socket.assigns.show_current_cycle, socket.assigns.boolean_custom_field_filters, socket.assigns.user_field_selection, socket.assigns[:visible_custom_field_ids] || [], @@ -866,14 +880,21 @@ defmodule MvWeb.MemberLive.Index do defp build_query_params(opts) when is_map(opts) do base_params = build_base_params(opts.query, opts.sort_field, opts.sort_order) - base_params = add_cycle_status_filter(base_params, opts.cycle_status_filter) base_params = add_group_filters(base_params, opts.group_filters || %{}) base_params = add_fee_type_filters(base_params, opts.fee_type_filters || %{}) - base_params = add_show_current_cycle(base_params, opts.show_current_cycle) + base_params = add_payment_params(base_params, opts.payment_filter, opts.payment_period) base_params = add_boolean_filters(base_params, opts.boolean_filters || %{}) add_date_filters(base_params, opts.date_filters) end + # Period-scoped payment model (§3.3): the payment-count filter and the active + # period both serialize into the flat URL contract (pay_filter/pay_from/pay_to). + defp add_payment_params(params, payment_filter, payment_period) do + params + |> Map.merge(PaymentAging.filter_to_params(payment_filter)) + |> Map.merge(PaymentAging.to_params(payment_period || PaymentAging.default_period())) + end + defp add_date_filters(params, date_filters) do Map.merge(params, DateFilter.to_params(date_filters)) end @@ -883,9 +904,9 @@ defmodule MvWeb.MemberLive.Index do query: socket.assigns.query, sort_field: socket.assigns.sort_field, sort_order: socket.assigns.sort_order, - cycle_status_filter: socket.assigns.cycle_status_filter, + payment_filter: socket.assigns.payment_filter, + payment_period: socket.assigns.payment_period, group_filters: socket.assigns[:group_filters] || %{}, - show_current_cycle: socket.assigns.show_current_cycle, boolean_filters: socket.assigns.boolean_custom_field_filters || %{}, fee_type_filters: socket.assigns[:fee_type_filters] || %{}, date_filters: socket.assigns.date_filters @@ -1187,17 +1208,6 @@ defmodule MvWeb.MemberLive.Index do end) end - defp add_cycle_status_filter(params, nil), do: params - defp add_cycle_status_filter(params, :paid), do: Map.put(params, "cycle_status_filter", "paid") - - defp add_cycle_status_filter(params, :unpaid), - do: Map.put(params, "cycle_status_filter", "unpaid") - - defp add_cycle_status_filter(params, _), do: params - - defp add_show_current_cycle(params, true), do: Map.put(params, "show_current_cycle", "true") - defp add_show_current_cycle(params, _), do: params - defp add_boolean_filters(params, boolean_filters) do Enum.reduce(boolean_filters, params, &add_boolean_filter/2) end @@ -1319,10 +1329,19 @@ defmodule MvWeb.MemberLive.Index do |> Ash.Query.select(@overview_fields) |> load_custom_field_values(compute_ids_to_load(socket)) |> MembershipFeeStatus.load_cycles_for_members() + |> load_unpaid_cycle_count(socket) |> Ash.Query.load(groups: [:id, :name, :slug]) |> maybe_load_fee_type(socket) end + # Loads the period-scoped unpaid-cycle count for the overview payment column + # (§1.13, §3.3). The count is a DB aggregate over `membership_fee_cycles` + # (denormalized `cycle_end`), scoped to the active period. + defp load_unpaid_cycle_count(query, socket) do + %{from: from, to: to} = socket.assigns.payment_period || PaymentAging.default_period() + Ash.Query.load(query, unpaid_cycle_count: %{period_from: from, period_to: to}) + end + defp maybe_load_fee_type(query, socket) do if :membership_fee_type in socket.assigns.member_fields_visible or socket.assigns.sort_field in [:membership_fee_type, "membership_fee_type"] do @@ -1345,8 +1364,8 @@ defmodule MvWeb.MemberLive.Index do boolean_custom_fields: socket.assigns.boolean_custom_fields, date_filters: socket.assigns.date_filters, date_custom_fields: socket.assigns[:date_custom_fields], - cycle_status_filter: socket.assigns.cycle_status_filter, - show_current_cycle: socket.assigns.show_current_cycle, + payment_filter: socket.assigns.payment_filter, + payment_period: socket.assigns.payment_period, sort_field: socket.assigns.sort_field, sort_order: socket.assigns.sort_order, custom_fields: socket.assigns.all_custom_fields @@ -1550,13 +1569,14 @@ defmodule MvWeb.MemberLive.Index do defp maybe_update_search(socket, _params), do: socket - defp maybe_update_cycle_status_filter(socket, %{"cycle_status_filter" => filter_str}) do - filter = determine_cycle_status_filter(filter_str) - assign(socket, :cycle_status_filter, filter) - end + # Period-scoped payment model (§3.3): a payment-count filter plus the active + # period, both driven purely from the URL so `handle_params` stays the source + # of truth. Absent params fall back to no filter / the all-outstanding period. + defp maybe_update_payment_filter(socket, params), + do: assign(socket, :payment_filter, PaymentAging.parse_filter_params(params)) - defp maybe_update_cycle_status_filter(socket, _params), - do: assign(socket, :cycle_status_filter, nil) + defp maybe_update_payment_period(socket, params), + do: assign(socket, :payment_period, PaymentAging.parse_period(params)) defp maybe_update_group_filters(socket, params) when is_map(params) do prefix = @group_filter_prefix @@ -1652,10 +1672,6 @@ defmodule MvWeb.MemberLive.Index do defp normalize_uuid_string(_), do: nil - defp determine_cycle_status_filter("paid"), do: :paid - defp determine_cycle_status_filter("unpaid"), do: :unpaid - defp determine_cycle_status_filter(_), do: nil - defp maybe_update_boolean_filters(socket, params) do boolean_custom_fields = socket.assigns.all_custom_fields @@ -1728,11 +1744,6 @@ defmodule MvWeb.MemberLive.Index do defp determine_boolean_filter("false"), do: false defp determine_boolean_filter(_), do: nil - defp maybe_update_show_current_cycle(socket, %{"show_current_cycle" => "true"}), - do: assign(socket, :show_current_cycle, true) - - defp maybe_update_show_current_cycle(socket, _params), do: socket - # URL params are the source of truth for filter state on every navigation. # When no date filter params are present, this falls through to the # active_only default — exactly the spec behavior for fresh load (§1.1). @@ -1820,6 +1831,23 @@ defmodule MvWeb.MemberLive.Index do def format_date(date), do: DateFormatter.format_date(date) + @doc """ + The payment column header, naming the active aging period (§1.17). The + all-outstanding default (both bounds nil) reads just "Payment"; a bounded + period appends the range so the scope is always legible. + """ + def payment_column_label(%{from: nil, to: nil}), do: gettext("Fees") + + def payment_column_label(%{from: from, to: to}) do + range = "#{payment_bound(from)}–#{payment_bound(to)}" + gettext("Fees · %{range}", range: range) + end + + def payment_column_label(_), do: gettext("Fees") + + defp payment_bound(%Date{} = d), do: Date.to_iso8601(d) + defp payment_bound(_), do: "…" + defp update_selection_assigns(socket) do selected_members = socket.assigns.selected_members # The selection may span members beyond the loaded page (after select-all), @@ -1939,7 +1967,7 @@ defmodule MvWeb.MemberLive.Index do end defp selection_filters_active?(assigns) do - not is_nil(assigns[:cycle_status_filter]) or + not is_nil(assigns[:payment_filter]) or map_size(assigns[:group_filters] || %{}) > 0 or map_size(assigns[:fee_type_filters] || %{}) > 0 or map_size(assigns[:boolean_custom_field_filters] || %{}) > 0 diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index 7709c2cb..5a56e922 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -21,17 +21,38 @@ -
    + <%!-- Two-row overview toolbar (Option C): row 1 is search + column/view + controls; row 2 is the dedicated filter row (add-filter + applied chips). --%> +
    +
    + <.live_component + module={MvWeb.Components.SearchBarComponent} + id="search-bar" + query={@query} + placeholder={gettext("Search...")} + /> +
    + <.live_component + module={MvWeb.Components.FieldVisibilityDropdownComponent} + id="field-visibility-dropdown" + all_fields={@all_available_fields} + custom_fields={@all_custom_fields} + selected_fields={@user_field_selection} + /> + <.live_component + module={MvWeb.Components.ViewSettingsDropdownComponent} + id="view-settings-dropdown" + density={@density} + compact_member={@compact_member} + member_include_email={@member_include_email} + compact_address={@compact_address} + /> +
    +
    + <.live_component - module={MvWeb.Components.SearchBarComponent} - id="search-bar" - query={@query} - placeholder={gettext("Search...")} - /> - <.live_component - module={MvWeb.Components.MemberFilterComponent} + module={MvWeb.Components.AddFilterBuilderComponent} id="member-filter" - cycle_status_filter={@cycle_status_filter} groups={@groups} group_filters={@group_filters} fee_types={@fee_types} @@ -40,57 +61,9 @@ boolean_filters={@boolean_custom_field_filters} date_custom_fields={@date_custom_fields} date_filters={@date_filters} - member_count={@total_count} + payment_filter={@payment_filter} + payment_period={@payment_period} /> - <.tooltip - content={ - gettext( - "Sets whether the payment status filter and the membership fee status column use the last completed or the current payment cycle." - ) - } - position="top" - > - <.button - type="button" - variant="secondary" - class="gap-2" - active={@show_current_cycle} - aria-pressed={to_string(@show_current_cycle)} - phx-click="toggle_cycle_view" - data-testid="toggle-cycle-view" - aria-label={ - if(@show_current_cycle, - do: gettext("Current payment cycle"), - else: gettext("Last payment cycle") - ) - } - > - <.icon name="hero-arrow-path" class="h-5 w-5" /> - - - -
    - <.live_component - module={MvWeb.Components.FieldVisibilityDropdownComponent} - id="field-visibility-dropdown" - all_fields={@all_available_fields} - custom_fields={@all_custom_fields} - selected_fields={@user_field_selection} - /> - <.live_component - module={MvWeb.Components.ViewSettingsDropdownComponent} - id="view-settings-dropdown" - density={@density} - compact_member={@compact_member} - member_include_email={@member_include_email} - compact_address={@compact_address} - /> -
    <%!-- Polite live region: present on first render (before it is filled) so @@ -131,7 +104,6 @@ JS.push("select_row_and_navigate", value: %{id: member.id}) end } - row_tooltip={gettext("Click for member details")} row_selected?={fn member -> MapSet.member?(@selected_members, member.id) end} dynamic_cols={@dynamic_cols} sort_field={@sort_field} @@ -484,17 +456,52 @@ <:col :let={member} :if={:membership_fee_status in @member_fields_visible} - label={gettext("Membership Fee Status")} + label={MvWeb.MemberLive.Index.payment_column_label(@payment_period)} > - <%= if badge = MembershipFeeStatus.format_cycle_status_badge( - MembershipFeeStatus.get_cycle_status_for_member(member, @show_current_cycle) - ) do %> - <.badge variant={badge.variant}> - <.icon name={badge.icon} class="size-4" /> - {badge.label} - - <% else %> - <.empty_cell sr_text={gettext("No cycle")} /> + <% count = member.unpaid_cycle_count || 0 %> + <% badge = PaymentAging.badge(count) %> + <% tip_id = "payment-tip-#{member.id}" %> + + <%= if count > 0 do %> + <% cycles = PaymentAging.open_cycles(member, @payment_period) %> + <% {shown, overflow} = PaymentAging.tooltip_cycles(cycles) %> + <% end %> <:col diff --git a/lib/mv_web/live/member_live/index/date_filter.ex b/lib/mv_web/live/member_live/index/date_filter.ex index a26abae2..ca6ed704 100644 --- a/lib/mv_web/live/member_live/index/date_filter.ex +++ b/lib/mv_web/live/member_live/index/date_filter.ex @@ -100,7 +100,14 @@ defmodule MvWeb.MemberLive.Index.DateFilter do `exit_date` slice is touched; `join_date` and custom-date entries are left intact, so the quick filter and the detailed control share one source. """ - @spec set_quick_state(map(), :active | :former | :all) :: map() + @spec set_quick_state(map(), :active | :former | :all) :: %{ + :exit_date => %{ + from: nil, + mode: :active_only | :inactive_only | :all, + to: nil + }, + optional(any()) => any() + } def set_quick_state(filters, state) when is_map(filters) and state in [:active, :former, :all] do Map.put(filters, :exit_date, %{mode: Map.fetch!(@quick_to_mode, state), from: nil, to: nil}) diff --git a/lib/mv_web/live/member_live/index/export_payload.ex b/lib/mv_web/live/member_live/index/export_payload.ex index 3c15c611..637da8a4 100644 --- a/lib/mv_web/live/member_live/index/export_payload.ex +++ b/lib/mv_web/live/member_live/index/export_payload.ex @@ -68,8 +68,12 @@ defmodule MvWeb.MemberLive.Index.ExportPayload do query: assigns[:query] || nil, sort_field: sort_field(assigns[:sort_field]), sort_order: sort_order(assigns[:sort_order]), - show_current_cycle: assigns[:show_current_cycle] || false, - cycle_status_filter: cycle_status_filter(assigns[:cycle_status_filter]), + # Decoupled from the removed overview current/last cycle toggle (§3.3): the + # export's fee-status column always uses the current cycle as its basis and + # applies no cycle-status row filter (the overview now filters payment via + # the period-scoped aging model, which the export does not mirror). + show_current_cycle: true, + cycle_status_filter: nil, boolean_filters: assigns[:boolean_custom_field_filters] || %{} } end @@ -87,11 +91,6 @@ defmodule MvWeb.MemberLive.Index.ExportPayload do def sort_order(:desc), do: "desc" def sort_order(o) when is_binary(o), do: o - defp cycle_status_filter(nil), do: nil - defp cycle_status_filter(:paid), do: "paid" - defp cycle_status_filter(:unpaid), do: "unpaid" - defp cycle_status_filter(_), do: nil - defp build_member_fields_list(ordered_db, member_fields_visible) do member_fields_visible = member_fields_visible || [] diff --git a/lib/mv_web/live/member_live/index/filter_descriptor.ex b/lib/mv_web/live/member_live/index/filter_descriptor.ex index 96ecd801..0e80dc5a 100644 --- a/lib/mv_web/live/member_live/index/filter_descriptor.ex +++ b/lib/mv_web/live/member_live/index/filter_descriptor.ex @@ -65,7 +65,7 @@ defmodule MvWeb.MemberLive.Index.FilterDescriptor do def group_for(%{group: group}), do: group @doc "The fixed display order of the picker groups." - @spec group_order() :: [group()] + @spec group_order() :: [group(), ...] def group_order, do: @group_order @doc """ diff --git a/lib/mv_web/live/member_live/index/overview_query.ex b/lib/mv_web/live/member_live/index/overview_query.ex index 1afe499b..a45dd077 100644 --- a/lib/mv_web/live/member_live/index/overview_query.ex +++ b/lib/mv_web/live/member_live/index/overview_query.ex @@ -47,17 +47,10 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do ) |> apply_date_filters(opts[:date_filters]) |> apply_custom_date_filters(opts[:date_filters], opts[:date_custom_fields]) - |> apply_cycle_status_filter( - opts[:cycle_status_filter], - opts[:show_current_cycle], - today(opts) - ) |> apply_payment_filter(opts[:payment_filter], payment_period(opts)) |> apply_sort(opts[:sort_field], opts[:sort_order], opts[:custom_fields] || []) end - defp today(opts), do: opts[:today] || Date.utc_today() - # --------------------------------------------------------------------------- # Search # --------------------------------------------------------------------------- @@ -184,55 +177,6 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do defp apply_one_boolean_filter(query, _uuid, _bool), do: query - # --------------------------------------------------------------------------- - # Cycle-status filter (paid/unpaid, current or last-completed cycle) - # - # Backed by the DB cycle-status aggregates (denormalized `cycle_end`). A member - # matches only when its selected-cycle status equals the requested status; - # members with no matching cycle (nil aggregate) are excluded — exactly as the - # previous in-memory classifier behaved. - # --------------------------------------------------------------------------- - - defp apply_cycle_status_filter(query, status, _show_current, _today) - when status not in [:paid, :unpaid], - do: query - - defp apply_cycle_status_filter(query, status, true = _show_current, today) do - # Current cycle: contains today; when several would, the one with the latest - # cycle_start wins. Keep members whose winning current cycle has `status`. - Ash.Query.filter( - query, - expr( - exists( - membership_fee_cycles, - cycle_start <= ^today and cycle_end >= ^today and status == ^status and - not exists( - member.membership_fee_cycles, - cycle_start <= ^today and cycle_end >= ^today and - cycle_start > parent(cycle_start) - ) - ) - ) - ) - end - - defp apply_cycle_status_filter(query, status, _show_current, today) do - # Last completed cycle: most recent cycle that has ended (cycle_end < today). - Ash.Query.filter( - query, - expr( - exists( - membership_fee_cycles, - cycle_end < ^today and status == ^status and - not exists( - member.membership_fee_cycles, - cycle_end < ^today and cycle_start > parent(cycle_start) - ) - ) - ) - ) - end - # --------------------------------------------------------------------------- # Payment aging filter (period-scoped unpaid-cycle count) # diff --git a/lib/mv_web/live/member_live/index/payment_aging.ex b/lib/mv_web/live/member_live/index/payment_aging.ex index 9dbe3e0c..e6154aa7 100644 --- a/lib/mv_web/live/member_live/index/payment_aging.ex +++ b/lib/mv_web/live/member_live/index/payment_aging.ex @@ -24,6 +24,7 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do use Gettext, backend: MvWeb.Gettext alias Mv.Constants + alias MvWeb.Helpers.DateFormatter alias MvWeb.Helpers.MembershipFeeHelpers @type period :: %{from: Date.t() | nil, to: Date.t() | nil} @@ -99,40 +100,111 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do do: parse_filter(Map.get(params, @payment_filter_param)) @doc """ - Badge descriptor for an unpaid-cycle count. A count of 0 reads "Paid" - (success); a positive count reads "N unpaid" (error). + Badge descriptor for an unpaid-cycle count. A count of 0 reads "Alle bezahlt" + (success/green); a positive count reads "N offen" (error/red). Reuses the + established cycle-status color and hero-icon codes so the overview badge reads + consistently with the member show page. """ @spec badge(non_neg_integer()) :: %{ variant: atom(), + color_class: String.t(), + icon: String.t(), label: String.t(), count: non_neg_integer() } - def badge(0), do: %{variant: :success, label: gettext("Paid"), count: 0} + def badge(0) do + %{ + variant: :success, + color_class: cycle_color_class(:paid), + icon: MembershipFeeHelpers.status_icon(:paid), + label: gettext("All paid"), + count: 0 + } + end def badge(count) when is_integer(count) and count > 0 do %{ variant: MembershipFeeHelpers.status_variant(:unpaid), - label: gettext("%{count} unpaid", count: count), + color_class: cycle_color_class(:unpaid), + icon: MembershipFeeHelpers.status_icon(:unpaid), + label: gettext("%{count} open", count: count), count: count } end @doc """ - Splits a member's loaded cycles into the open (unpaid) and suspended cycles - whose `cycle_end` falls inside `period`, both sorted by `cycle_end`. Paid - cycles and cycles outside the period are dropped. Used to render the badge - tooltip. Expects `membership_fee_cycles` to be loaded on the member. + A member's open cycles for the badge tooltip: the unpaid and suspended cycles + whose `cycle_end` falls inside `period`, merged into a single list sorted + chronologically by `cycle_start`. Paid cycles and cycles outside the period + are dropped. Expects `membership_fee_cycles` to be loaded on the member. """ - @spec open_cycles(map(), period()) :: %{unpaid: [map()], suspended: [map()]} + @spec open_cycles(map(), period()) :: [map()] def open_cycles(member, %{from: from, to: to}) do - cycles = in_period_cycles(member, from, to) - - %{ - unpaid: cycles |> Enum.filter(&(&1.status == :unpaid)) |> sort_by_end(), - suspended: cycles |> Enum.filter(&(&1.status == :suspended)) |> sort_by_end() - } + member + |> in_period_cycles(from, to) + |> Enum.filter(&(&1.status in [:unpaid, :suspended])) + |> Enum.sort_by(& &1.cycle_start, Date) end + @tooltip_slots 5 + + @doc """ + Partitions open cycles into the tooltip's fixed five-slot budget: at most + five badges total. Five or fewer cycles show in full with no overflow. More + than five collapse to the first four chronological cycles plus a single + overflow count (total − 4), so the tooltip renders four cycle badges and one + grey overflow badge — five items. + """ + @spec tooltip_cycles([map()]) :: {[map()], non_neg_integer()} + def tooltip_cycles(cycles) when is_list(cycles) do + if length(cycles) > @tooltip_slots do + {Enum.take(cycles, @tooltip_slots - 1), length(cycles) - (@tooltip_slots - 1)} + else + {cycles, 0} + end + end + + @doc """ + Static daisyUI badge color token for a cycle status, matching the codes used + on the member show page. Literal strings so Tailwind's content scanner keeps + them in the bundle (a runtime `badge-\#{status}` would be tree-shaken). + """ + @spec cycle_color_class(:paid | :unpaid | :suspended) :: String.t() + def cycle_color_class(:paid), do: "badge-success" + def cycle_color_class(:unpaid), do: "badge-error" + def cycle_color_class(:suspended), do: "badge-warning" + + @doc """ + Hero-icon name for a cycle status (reuses the established status icons). + """ + @spec cycle_icon(:paid | :unpaid | :suspended) :: String.t() + def cycle_icon(status), do: MembershipFeeHelpers.status_icon(status) + + @doc """ + Short, human-readable period label for a cycle, derived from its `cycle_start` + and the fee-type interval: + + * yearly → `"2025"` + * quarterly → `"Q1 2026"` + * half-yearly → `"H1 2026"` + * monthly → `"März 2026"` + + Falls back to a `dd.MM.yyyy–dd.MM.yyyy` date range when the interval is not + loaded/known. + """ + @spec short_period(map()) :: String.t() + def short_period(%{cycle_start: %Date{} = start} = cycle) do + case cycle_interval(cycle) do + :yearly -> Integer.to_string(start.year) + :quarterly -> "Q#{quarter(start.month)} #{start.year}" + :half_yearly -> "H#{half(start.month)} #{start.year}" + :monthly -> "#{month_name(start.month)} #{start.year}" + _ -> cycle_date_range(cycle) + end + end + + def short_period(cycle), do: cycle_date_range(cycle) + defp in_period_cycles(member, from, to) do case Map.get(member, :membership_fee_cycles) do cycles when is_list(cycles) -> Enum.filter(cycles, &in_period?(&1.cycle_end, from, to)) @@ -147,7 +219,35 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do defp in_period?(_, _, _), do: false - defp sort_by_end(cycles), do: Enum.sort_by(cycles, & &1.cycle_end, Date) + defp cycle_interval(%{membership_fee_type: %{interval: interval}}) + when interval in [:monthly, :quarterly, :half_yearly, :yearly], + do: interval + + defp cycle_interval(_), do: nil + + defp cycle_date_range(%{cycle_start: %Date{} = s, cycle_end: %Date{} = e}), + do: "#{DateFormatter.format_date(s)}–#{DateFormatter.format_date(e)}" + + defp cycle_date_range(%{cycle_start: %Date{} = s}), do: DateFormatter.format_date(s) + defp cycle_date_range(_), do: "" + + defp quarter(month), do: div(month - 1, 3) + 1 + + defp half(month) when month <= 6, do: 1 + defp half(_month), do: 2 + + defp month_name(1), do: gettext("January") + defp month_name(2), do: gettext("February") + defp month_name(3), do: gettext("March") + defp month_name(4), do: gettext("April") + defp month_name(5), do: gettext("May") + defp month_name(6), do: gettext("June") + defp month_name(7), do: gettext("July") + defp month_name(8), do: gettext("August") + defp month_name(9), do: gettext("September") + defp month_name(10), do: gettext("October") + defp month_name(11), do: gettext("November") + defp month_name(12), do: gettext("December") defp maybe_put_date(params, _key, nil), do: params diff --git a/lib/mv_web/live/member_live/show.ex b/lib/mv_web/live/member_live/show.ex index f1748591..d3979f46 100644 --- a/lib/mv_web/live/member_live/show.ex +++ b/lib/mv_web/live/member_live/show.ex @@ -424,7 +424,7 @@ defmodule MvWeb.MemberLive.Show do end @impl true - def handle_params(%{"id" => id}, _, socket) do + def handle_params(%{"id" => id} = params, _, socket) do actor = current_actor(socket) # Load custom fields once using assign_new to avoid repeated queries @@ -463,9 +463,17 @@ defmodule MvWeb.MemberLive.Show do {:noreply, socket |> Layouts.assign_page_title(content_title) - |> assign(:member, member)} + |> assign(:member, member) + |> maybe_assign_tab(params)} end + # Deep-link support: the overview payment badge drills into the fees history + # via `?tab=membership_fees` (§1.15). + defp maybe_assign_tab(socket, %{"tab" => "membership_fees"}), + do: assign(socket, :active_tab, :membership_fees) + + defp maybe_assign_tab(socket, _params), do: socket + @impl true def handle_event("switch_tab", %{"tab" => "contact"}, socket) do {:noreply, assign(socket, :active_tab, :contact)} diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 8749a6a5..a8243b50 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -20,13 +20,6 @@ msgstr " (Datenfeld: %{field})" msgid "%{count} failed" msgstr "%{count} fehlgeschlagen" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "%{count} filter active" -msgid_plural "%{count} filters active" -msgstr[0] "%{count} Filter aktiv" -msgstr[1] "%{count} Filter aktiv" - #: lib/mv_web/live/custom_field_live/index_component.ex #, elixir-autogen, elixir-format, fuzzy msgid "%{count} member has a value assigned for this datafield." @@ -39,16 +32,6 @@ msgstr[1] "%{count} Mitglieder haben Werte für dieses benutzerdefinierte Feld z msgid "%{count} synced" msgstr "%{count} synchronisiert" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "%{field} from" -msgstr "%{field} von" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "%{field} to" -msgstr "%{field} bis" - #: lib/mv/membership/import/member_csv.ex #, elixir-autogen, elixir-format msgid "(ISO-8601 format: YYYY-MM-DD)" @@ -106,7 +89,7 @@ msgstr "Aktionen" msgid "Active members" msgstr "Aktive Mitglieder" -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #, elixir-autogen, elixir-format msgid "Active only" msgstr "Nur aktive" @@ -149,7 +132,7 @@ msgid "Administration" msgstr "Administration" #: lib/mv_web/components/core_components.ex -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format msgid "All" @@ -210,11 +193,6 @@ msgstr "App-URL (Link zur Kontaktansicht)" msgid "Applicant data" msgstr "Angaben des Antragstellers" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Apply filters" -msgstr "Filter auswählen" - #: lib/mv_web/live/join_request_live/show.ex #, elixir-autogen, elixir-format msgid "Approve" @@ -489,11 +467,6 @@ msgstr "CSV-Datei auswählen" msgid "City" msgstr "Stadt" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Clear filters" -msgstr "Filter zurücksetzen" - #: lib/mv_web/live/join_request_live/index.ex #, elixir-autogen, elixir-format msgid "Click for details" @@ -504,11 +477,6 @@ msgstr "Klicken für Details" msgid "Click for group details" msgstr "Klicke für Gruppen-Details" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Click for member details" -msgstr "Klicke für Mitglieds-Details" - #: lib/mv_web/live/role_live/index.html.heex #, elixir-autogen, elixir-format msgid "Click for role details" @@ -550,11 +518,6 @@ msgstr "Client-ID" msgid "Client Secret" msgstr "Client-Geheimnis" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Close" -msgstr "Schließen" - #: lib/mv_web/components/layouts/sidebar.ex #, elixir-autogen, elixir-format msgid "Close sidebar" @@ -816,11 +779,6 @@ msgstr "Aktueller Zyklus" msgid "Current amount" msgstr "Aktueller Betrag" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Current payment cycle" -msgstr "Aktueller Zahlungszyklus" - #: lib/mv_web/live/role_live/index.html.heex #, elixir-autogen, elixir-format msgid "Custom" @@ -831,11 +789,6 @@ msgstr "Benutzerdefiniert" msgid "Custom Fields" msgstr "Benutzerdefinierte Felder" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Custom date fields" -msgstr "Benutzerdefinierte Datumsfelder" - #: lib/mv_web/live/import_live/components.ex #, elixir-autogen, elixir-format msgid "Custom field" @@ -915,11 +868,6 @@ msgstr "Datenfelder" msgid "Date" msgstr "Datum" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Dates" -msgstr "Daten" - #: lib/mv_web/live/member_live/show/deactivate_component.ex #, elixir-autogen, elixir-format msgid "Deactivate" @@ -1296,23 +1244,13 @@ msgstr "Beispiele" msgid "Exit Date" msgstr "Austrittsdatum" -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #: lib/mv_web/live/member_live/index/filter_descriptor.ex #: lib/mv_web/live/member_live/show/deactivate_component.ex #, elixir-autogen, elixir-format msgid "Exit date" msgstr "Austrittsdatum" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Exit date from" -msgstr "Austrittsdatum von" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Exit date to" -msgstr "Austrittsdatum bis" - #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format msgid "Exits" @@ -1487,11 +1425,6 @@ msgstr "Beitragsart '%{name}' nicht gefunden; Standard-Beitragsart wird verwende msgid "Fee type column (recognized headers): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unknown fee types fall back to the default." msgstr "Beitragsart-Spalte (erkannte Spaltennamen): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unbekannte Beitragsarten erhalten die Standard-Beitragsart." -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Fee types" -msgstr "Beitragsarten" - #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format msgid "Fee types could not be loaded." @@ -1508,11 +1441,6 @@ msgstr "Feld" msgid "Fields on the join form" msgstr "Felder im Beitrittsformular" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Filter members" -msgstr "Mitglieder filtern" - #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/translations/member_fields.ex @@ -1532,7 +1460,7 @@ msgstr "Vorname" msgid "Fixed after creation. Members can only switch between types with the same interval." msgstr "Festgelegt nach der Erstellung. Mitglieder können nur zwischen Beitragsarten mit gleichem Intervall wechseln." -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #, elixir-autogen, elixir-format msgid "From" msgstr "Von" @@ -1659,7 +1587,6 @@ msgid "Group saved successfully." msgstr "Gruppe erfolgreich gespeichert." #: lib/mv_web/components/layouts/sidebar.ex -#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/import_live/components.ex #: lib/mv_web/live/member_live/index.html.heex @@ -1774,11 +1701,6 @@ msgstr "Import-Status fehlt. Chunk %{idx} kann nicht verarbeitet werden." msgid "Inactive members" msgstr "Inaktive Mitglieder" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Inactive only" -msgstr "Nur ehemalige" - #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "Include both letters and numbers" @@ -1814,7 +1736,6 @@ msgstr "Falsche E-Mail oder Passwort" msgid "Individual Datafields" msgstr "Individuelle Datenfelder" -#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Individual datafields" @@ -1907,22 +1828,11 @@ msgstr "Beitrittsformular" msgid "Join confirmation" msgstr "Beitrittsbestätigung" -#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/member_live/index/filter_descriptor.ex #, elixir-autogen, elixir-format msgid "Join date" msgstr "Beitrittsdatum" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Join date from" -msgstr "Beitrittsdatum von" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Join date to" -msgstr "Beitrittsdatum bis" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "Join form enabled" @@ -2012,11 +1922,6 @@ msgstr "Nachname" msgid "Last name" msgstr "Nachname" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Last payment cycle" -msgstr "Letzter Zahlungszyklus" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "Last sync result:" @@ -2111,6 +2016,7 @@ msgstr "Als ausgesetzt markieren" msgid "Mark as unpaid" msgstr "Als unbezahlt markieren" +#: lib/mv_web/live/member_live/index/payment_aging.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "May" @@ -2157,11 +2063,6 @@ msgstr "Mitgliedsfeld" msgid "Member field %{action} successfully" msgstr "Mitgliedsfeld wurde erfolgreich %{action}" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Member filter" -msgstr "Mitgliedsfilter" - #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format msgid "Member is not in this group." @@ -2247,7 +2148,6 @@ msgstr "Mitgliedsbeitrag" msgid "Membership Fee Start Date" msgstr "Startdatum Mitgliedsbeitrag" -#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format, fuzzy msgid "Membership Fee Status" @@ -2411,7 +2311,7 @@ msgid "New amount" msgstr "Neuer Betrag" #: lib/mv_web/components/core_components.ex -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/join_request_live/show.ex #: lib/mv_web/live/member_field_live/index_component.ex @@ -2433,11 +2333,6 @@ msgstr "Für dieses Mitglied existiert kein Vereinfacht-Kontakt." msgid "No approved or rejected requests yet" msgstr "Noch keine genehmigten oder abgelehnten Anträge" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "No cycle" -msgstr "Kein Zyklus" - #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "No cycles" @@ -2697,9 +2592,7 @@ msgid "Options" msgstr "Optionen" #: lib/mv/membership/members_pdf.ex -#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/member_live/index/membership_fee_status.ex -#: lib/mv_web/live/member_live/index/payment_aging.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 @@ -2734,16 +2627,6 @@ msgstr "Beitragsdaten" msgid "Payment Interval" msgstr "Zahlungsintervall" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Payment Status" -msgstr "Bezahlstatus" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Payments" -msgstr "Zahlungen" - #: lib/mv_web/live/join_request_live/helpers.ex #, elixir-autogen, elixir-format msgid "Pending confirmation" @@ -2842,7 +2725,7 @@ msgstr "Vierteljährlich" msgid "Quarterly Interval - Joining Cycle Excluded" msgstr "Vierteljährliches Intervall – Beitrittszeitraum nicht einbezogen" -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #, elixir-autogen, elixir-format msgid "Range" msgstr "Zeitraum" @@ -3266,11 +3149,6 @@ msgstr "Server nicht erreichbar. Host und Port prüfen." msgid "Set Password" msgstr "Passwort setzen" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Sets whether the payment status filter and the membership fee status column use the last completed or the current payment cycle." -msgstr "Legt fest, ob Bezahlstatusfilter und Mitgliedsbeitragsstatus-Spalte den letzten abgeschlossenen oder den aktuellen Zahlungszyklus verwenden." - #: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "Settings saved successfully." @@ -3600,7 +3478,7 @@ msgstr "Diese*r Benutzer*in ist über SSO (Single Sign-On) verbunden. Ein hier f msgid "Tip: Paste email addresses into the BCC field for privacy compliance" msgstr "Tipp: E-Mail-Adressen ins BCC-Feld einfügen, für Datenschutzkonformität" -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #, elixir-autogen, elixir-format msgid "To" msgstr "Bis" @@ -3700,7 +3578,6 @@ msgid "Unlinking scheduled" msgstr "Aufhebung der Verknüpfung geplant" #: lib/mv/membership/members_pdf.ex -#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/member_live/index/membership_fee_status.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex @@ -3906,7 +3783,7 @@ msgid "Yearly Interval - Joining Cycle Included" msgstr "Jährliches Intervall – Beitrittszeitraum einbezogen" #: lib/mv_web/components/core_components.ex -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/join_request_live/show.ex #: lib/mv_web/live/member_field_live/index_component.ex @@ -4129,11 +4006,6 @@ msgstr "aktualisiert" msgid "updated" msgstr "aktualisiert" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "without %{name}" -msgstr "ohne %{name}" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgctxt "action" @@ -4218,22 +4090,284 @@ msgstr "Klicke, um nach Nachname zu sortieren" msgid "No address" msgstr "Keine Adresse" -#: lib/mv_web/live/member_live/index/payment_aging.ex -#, elixir-autogen, elixir-format -msgid "%{count} unpaid" -msgstr "%{count} unbezahlt" - +#: lib/mv_web/live/components/add_filter_builder_component.ex #: lib/mv_web/live/member_live/index/filter_descriptor.ex #, elixir-autogen, elixir-format msgid "Active / former" msgstr "Aktiv / ehemalig" #: lib/mv_web/live/member_live/index/filter_descriptor.ex -#, elixir-autogen, elixir-format, fuzzy +#, elixir-autogen, elixir-format msgid "Group" -msgstr "Gruppen" +msgstr "Gruppe" +#: lib/mv_web/live/components/add_filter_builder_component.ex #: lib/mv_web/live/member_live/index/filter_descriptor.ex -#, elixir-autogen, elixir-format, fuzzy +#, elixir-autogen, elixir-format msgid "Payment" -msgstr "Zahlungen" +msgstr "Zahlung" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "%{label} — open payment history" +msgstr "%{label} — Zahlungsverlauf öffnen" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Active" +msgstr "Aktiv" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Add filter" +msgstr "Filter hinzufügen" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "All (incl. former)" +msgstr "Alle (inkl. ehemalige)" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Available filters" +msgstr "Verfügbare Filter" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Clear all" +msgstr "Alle löschen" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Custom fields" +msgstr "Eigene Felder" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Exited" +msgstr "Ausgetreten" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Fee type: %{name}" +msgstr "Beitragsart: %{name}" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Fee type: not %{name}" +msgstr "Beitragsart: nicht %{name}" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Filter by …" +msgstr "Filtern nach …" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Former" +msgstr "Ehemalig" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Former only" +msgstr "Nur ehemalige" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Fully paid" +msgstr "Vollständig bezahlt" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Group: %{name}" +msgstr "Gruppe: %{name}" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Group: not %{name}" +msgstr "Gruppe: nicht %{name}" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Has unpaid ≥ 1" +msgstr "Offen ≥ 1" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Has unpaid ≥ 2" +msgstr "Offen ≥ 2" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Has unpaid ≥ 3" +msgstr "Offen ≥ 3" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Joined" +msgstr "Beigetreten" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Last 30 days" +msgstr "Letzte 30 Tage" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Last year" +msgstr "Letztes Jahr" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Membership" +msgstr "Mitgliedschaft" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Payment status" +msgstr "Zahlungsstatus" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Period" +msgstr "Zeitraum" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Period from" +msgstr "Zeitraum von" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Period to" +msgstr "Zeitraum bis" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Quick" +msgstr "Schnellfilter" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Remove filter: %{label}" +msgstr "Filter entfernen: %{label}" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "This month" +msgstr "Dieser Monat" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "This year" +msgstr "Dieses Jahr" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "≥ %{n} unpaid" +msgstr "≥ %{n} offen" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "%{count} open" +msgstr "%{count} offen" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "+%{count} more" +msgstr "+%{count} weitere" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "All paid" +msgstr "Alle bezahlt" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "April" +msgstr "April" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "August" +msgstr "August" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "December" +msgstr "Dezember" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "February" +msgstr "Februar" + +#: lib/mv_web/live/member_live/index.ex +#, elixir-autogen, elixir-format +msgid "Fees" +msgstr "Beiträge" + +#: lib/mv_web/live/member_live/index.ex +#, elixir-autogen, elixir-format +msgid "Fees · %{range}" +msgstr "Beiträge · %{range}" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "January" +msgstr "Januar" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "July" +msgstr "Juli" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "June" +msgstr "Juni" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "March" +msgstr "März" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "November" +msgstr "November" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "October" +msgstr "Oktober" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "September" +msgstr "September" + +#~ #: lib/mv_web/live/member_live/index.html.heex +#~ #, elixir-autogen, elixir-format +#~ msgid "Click for member details" +#~ msgstr "Klicke für Mitglieds-Details" + +#~ #: lib/mv_web/live/member_live/index.html.heex +#~ #, elixir-autogen, elixir-format +#~ msgid "No open cycles for this period" +#~ msgstr "Keine offenen Zyklen in diesem Zeitraum" + +#~ #: lib/mv_web/live/member_live/index.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Payment · %{range}" +#~ msgstr "Zahlung · %{range}" + +#~ #: lib/mv_web/live/member_live/index.html.heex +#~ #, elixir-autogen, elixir-format +#~ msgid "Suspended: %{date}" +#~ msgstr "Ausgesetzt: %{date}" + +#~ #: lib/mv_web/live/member_live/index.html.heex +#~ #, elixir-autogen, elixir-format +#~ msgid "Unpaid until %{date}" +#~ msgstr "Offen bis %{date}" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 2e1df9f5..9de292e9 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -21,13 +21,6 @@ msgstr "" msgid "%{count} failed" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "%{count} filter active" -msgid_plural "%{count} filters active" -msgstr[0] "" -msgstr[1] "" - #: lib/mv_web/live/custom_field_live/index_component.ex #, elixir-autogen, elixir-format msgid "%{count} member has a value assigned for this datafield." @@ -40,16 +33,6 @@ msgstr[1] "" msgid "%{count} synced" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "%{field} from" -msgstr "" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "%{field} to" -msgstr "" - #: lib/mv/membership/import/member_csv.ex #, elixir-autogen, elixir-format msgid "(ISO-8601 format: YYYY-MM-DD)" @@ -107,7 +90,7 @@ msgstr "" msgid "Active members" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #, elixir-autogen, elixir-format msgid "Active only" msgstr "" @@ -150,7 +133,7 @@ msgid "Administration" msgstr "" #: lib/mv_web/components/core_components.ex -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format msgid "All" @@ -211,11 +194,6 @@ msgstr "" msgid "Applicant data" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Apply filters" -msgstr "" - #: lib/mv_web/live/join_request_live/show.ex #, elixir-autogen, elixir-format msgid "Approve" @@ -490,11 +468,6 @@ msgstr "" msgid "City" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Clear filters" -msgstr "" - #: lib/mv_web/live/join_request_live/index.ex #, elixir-autogen, elixir-format msgid "Click for details" @@ -505,11 +478,6 @@ msgstr "" msgid "Click for group details" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Click for member details" -msgstr "" - #: lib/mv_web/live/role_live/index.html.heex #, elixir-autogen, elixir-format msgid "Click for role details" @@ -551,11 +519,6 @@ msgstr "" msgid "Client Secret" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Close" -msgstr "" - #: lib/mv_web/components/layouts/sidebar.ex #, elixir-autogen, elixir-format msgid "Close sidebar" @@ -817,11 +780,6 @@ msgstr "" msgid "Current amount" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Current payment cycle" -msgstr "" - #: lib/mv_web/live/role_live/index.html.heex #, elixir-autogen, elixir-format msgid "Custom" @@ -832,11 +790,6 @@ msgstr "" msgid "Custom Fields" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Custom date fields" -msgstr "" - #: lib/mv_web/live/import_live/components.ex #, elixir-autogen, elixir-format msgid "Custom field" @@ -916,11 +869,6 @@ msgstr "" msgid "Date" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Dates" -msgstr "" - #: lib/mv_web/live/member_live/show/deactivate_component.ex #, elixir-autogen, elixir-format msgid "Deactivate" @@ -1297,23 +1245,13 @@ msgstr "" msgid "Exit Date" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #: lib/mv_web/live/member_live/index/filter_descriptor.ex #: lib/mv_web/live/member_live/show/deactivate_component.ex #, elixir-autogen, elixir-format msgid "Exit date" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Exit date from" -msgstr "" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Exit date to" -msgstr "" - #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format msgid "Exits" @@ -1488,11 +1426,6 @@ msgstr "" msgid "Fee type column (recognized headers): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unknown fee types fall back to the default." msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Fee types" -msgstr "" - #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format msgid "Fee types could not be loaded." @@ -1509,11 +1442,6 @@ msgstr "" msgid "Fields on the join form" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Filter members" -msgstr "" - #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/translations/member_fields.ex @@ -1533,7 +1461,7 @@ msgstr "" msgid "Fixed after creation. Members can only switch between types with the same interval." msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #, elixir-autogen, elixir-format msgid "From" msgstr "" @@ -1660,7 +1588,6 @@ msgid "Group saved successfully." msgstr "" #: lib/mv_web/components/layouts/sidebar.ex -#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/import_live/components.ex #: lib/mv_web/live/member_live/index.html.heex @@ -1775,11 +1702,6 @@ msgstr "" msgid "Inactive members" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Inactive only" -msgstr "" - #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "Include both letters and numbers" @@ -1815,7 +1737,6 @@ msgstr "" msgid "Individual Datafields" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Individual datafields" @@ -1908,22 +1829,11 @@ msgstr "" msgid "Join confirmation" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/member_live/index/filter_descriptor.ex #, elixir-autogen, elixir-format msgid "Join date" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Join date from" -msgstr "" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Join date to" -msgstr "" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "Join form enabled" @@ -2013,11 +1923,6 @@ msgstr "" msgid "Last name" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Last payment cycle" -msgstr "" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "Last sync result:" @@ -2112,6 +2017,7 @@ msgstr "" msgid "Mark as unpaid" msgstr "" +#: lib/mv_web/live/member_live/index/payment_aging.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "May" @@ -2158,11 +2064,6 @@ msgstr "" msgid "Member field %{action} successfully" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Member filter" -msgstr "" - #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format msgid "Member is not in this group." @@ -2248,7 +2149,6 @@ msgstr "" msgid "Membership Fee Start Date" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Membership Fee Status" @@ -2412,7 +2312,7 @@ msgid "New amount" msgstr "" #: lib/mv_web/components/core_components.ex -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/join_request_live/show.ex #: lib/mv_web/live/member_field_live/index_component.ex @@ -2434,11 +2334,6 @@ msgstr "" msgid "No approved or rejected requests yet" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "No cycle" -msgstr "" - #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "No cycles" @@ -2698,9 +2593,7 @@ msgid "Options" msgstr "" #: lib/mv/membership/members_pdf.ex -#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/member_live/index/membership_fee_status.ex -#: lib/mv_web/live/member_live/index/payment_aging.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 @@ -2735,16 +2628,6 @@ msgstr "" msgid "Payment Interval" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Payment Status" -msgstr "" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Payments" -msgstr "" - #: lib/mv_web/live/join_request_live/helpers.ex #, elixir-autogen, elixir-format msgid "Pending confirmation" @@ -2843,7 +2726,7 @@ msgstr "" msgid "Quarterly Interval - Joining Cycle Excluded" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #, elixir-autogen, elixir-format msgid "Range" msgstr "" @@ -3267,11 +3150,6 @@ msgstr "" msgid "Set Password" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Sets whether the payment status filter and the membership fee status column use the last completed or the current payment cycle." -msgstr "" - #: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format msgid "Settings saved successfully." @@ -3601,7 +3479,7 @@ msgstr "" msgid "Tip: Paste email addresses into the BCC field for privacy compliance" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #, elixir-autogen, elixir-format msgid "To" msgstr "" @@ -3701,7 +3579,6 @@ msgid "Unlinking scheduled" msgstr "" #: lib/mv/membership/members_pdf.ex -#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/member_live/index/membership_fee_status.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex @@ -3906,7 +3783,7 @@ msgid "Yearly Interval - Joining Cycle Included" msgstr "" #: lib/mv_web/components/core_components.ex -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/join_request_live/show.ex #: lib/mv_web/live/member_field_live/index_component.ex @@ -4129,11 +4006,6 @@ msgstr "" msgid "updated" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "without %{name}" -msgstr "" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgctxt "action" @@ -4218,11 +4090,7 @@ msgstr "" msgid "No address" msgstr "" -#: lib/mv_web/live/member_live/index/payment_aging.ex -#, elixir-autogen, elixir-format -msgid "%{count} unpaid" -msgstr "" - +#: lib/mv_web/live/components/add_filter_builder_component.ex #: lib/mv_web/live/member_live/index/filter_descriptor.ex #, elixir-autogen, elixir-format msgid "Active / former" @@ -4233,7 +4101,248 @@ msgstr "" msgid "Group" msgstr "" +#: lib/mv_web/live/components/add_filter_builder_component.ex #: lib/mv_web/live/member_live/index/filter_descriptor.ex #, elixir-autogen, elixir-format msgid "Payment" msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "%{label} — open payment history" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Active" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Add filter" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "All (incl. former)" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Available filters" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Clear all" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Custom fields" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Exited" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Fee type: %{name}" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Fee type: not %{name}" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Filter by …" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Former" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Former only" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Fully paid" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Group: %{name}" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Group: not %{name}" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Has unpaid ≥ 1" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Has unpaid ≥ 2" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Has unpaid ≥ 3" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Joined" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Last 30 days" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Last year" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Membership" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Payment status" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Period" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Period from" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Period to" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Quick" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Remove filter: %{label}" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "This month" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "This year" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "≥ %{n} unpaid" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "%{count} open" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "+%{count} more" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "All paid" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "April" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "August" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "December" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "February" +msgstr "" + +#: lib/mv_web/live/member_live/index.ex +#, elixir-autogen, elixir-format +msgid "Fees" +msgstr "" + +#: lib/mv_web/live/member_live/index.ex +#, elixir-autogen, elixir-format +msgid "Fees · %{range}" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "January" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "July" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "June" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "March" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "November" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "October" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "September" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index faec9b03..427fb998 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -21,13 +21,6 @@ msgstr "" msgid "%{count} failed" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "%{count} filter active" -msgid_plural "%{count} filters active" -msgstr[0] "%{count} filter active" -msgstr[1] "%{count} filters active" - #: lib/mv_web/live/custom_field_live/index_component.ex #, elixir-autogen, elixir-format, fuzzy msgid "%{count} member has a value assigned for this datafield." @@ -40,16 +33,6 @@ msgstr[1] "" msgid "%{count} synced" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "%{field} from" -msgstr "" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "%{field} to" -msgstr "" - #: lib/mv/membership/import/member_csv.ex #, elixir-autogen, elixir-format msgid "(ISO-8601 format: YYYY-MM-DD)" @@ -107,7 +90,7 @@ msgstr "" msgid "Active members" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #, elixir-autogen, elixir-format, fuzzy msgid "Active only" msgstr "" @@ -150,7 +133,7 @@ msgid "Administration" msgstr "" #: lib/mv_web/components/core_components.ex -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format msgid "All" @@ -211,11 +194,6 @@ msgstr "" msgid "Applicant data" msgstr "Applicant data" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Apply filters" -msgstr "" - #: lib/mv_web/live/join_request_live/show.ex #, elixir-autogen, elixir-format msgid "Approve" @@ -490,11 +468,6 @@ msgstr "" msgid "City" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Clear filters" -msgstr "" - #: lib/mv_web/live/join_request_live/index.ex #, elixir-autogen, elixir-format msgid "Click for details" @@ -505,11 +478,6 @@ msgstr "Click for details" msgid "Click for group details" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Click for member details" -msgstr "" - #: lib/mv_web/live/role_live/index.html.heex #, elixir-autogen, elixir-format msgid "Click for role details" @@ -551,11 +519,6 @@ msgstr "" msgid "Client Secret" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Close" -msgstr "" - #: lib/mv_web/components/layouts/sidebar.ex #, elixir-autogen, elixir-format msgid "Close sidebar" @@ -817,11 +780,6 @@ msgstr "" msgid "Current amount" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Current payment cycle" -msgstr "Current payment cycle" - #: lib/mv_web/live/role_live/index.html.heex #, elixir-autogen, elixir-format, fuzzy msgid "Custom" @@ -832,11 +790,6 @@ msgstr "" msgid "Custom Fields" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Custom date fields" -msgstr "" - #: lib/mv_web/live/import_live/components.ex #, elixir-autogen, elixir-format, fuzzy msgid "Custom field" @@ -916,11 +869,6 @@ msgstr "" msgid "Date" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Dates" -msgstr "" - #: lib/mv_web/live/member_live/show/deactivate_component.ex #, elixir-autogen, elixir-format msgid "Deactivate" @@ -1297,23 +1245,13 @@ msgstr "" msgid "Exit Date" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #: lib/mv_web/live/member_live/index/filter_descriptor.ex #: lib/mv_web/live/member_live/show/deactivate_component.ex #, elixir-autogen, elixir-format, fuzzy msgid "Exit date" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Exit date from" -msgstr "" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Exit date to" -msgstr "" - #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format msgid "Exits" @@ -1488,11 +1426,6 @@ msgstr "" msgid "Fee type column (recognized headers): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unknown fee types fall back to the default." msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Fee types" -msgstr "Fee types" - #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format msgid "Fee types could not be loaded." @@ -1509,11 +1442,6 @@ msgstr "" msgid "Fields on the join form" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Filter members" -msgstr "" - #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/translations/member_fields.ex @@ -1533,7 +1461,7 @@ msgstr "" msgid "Fixed after creation. Members can only switch between types with the same interval." msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #, elixir-autogen, elixir-format msgid "From" msgstr "" @@ -1660,7 +1588,6 @@ msgid "Group saved successfully." msgstr "" #: lib/mv_web/components/layouts/sidebar.ex -#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/import_live/components.ex #: lib/mv_web/live/member_live/index.html.heex @@ -1775,11 +1702,6 @@ msgstr "" msgid "Inactive members" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Inactive only" -msgstr "" - #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "Include both letters and numbers" @@ -1815,7 +1737,6 @@ msgstr "" msgid "Individual Datafields" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Individual datafields" @@ -1908,22 +1829,11 @@ msgstr "" msgid "Join confirmation" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/member_live/index/filter_descriptor.ex #, elixir-autogen, elixir-format, fuzzy msgid "Join date" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Join date from" -msgstr "" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Join date to" -msgstr "" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "Join form enabled" @@ -2013,11 +1923,6 @@ msgstr "" msgid "Last name" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Last payment cycle" -msgstr "Last payment cycle" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "Last sync result:" @@ -2112,6 +2017,7 @@ msgstr "" msgid "Mark as unpaid" msgstr "" +#: lib/mv_web/live/member_live/index/payment_aging.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format msgid "May" @@ -2158,11 +2064,6 @@ msgstr "" msgid "Member field %{action} successfully" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Member filter" -msgstr "" - #: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format msgid "Member is not in this group." @@ -2248,7 +2149,6 @@ msgstr "" msgid "Membership Fee Start Date" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format, fuzzy msgid "Membership Fee Status" @@ -2412,7 +2312,7 @@ msgid "New amount" msgstr "" #: lib/mv_web/components/core_components.ex -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/join_request_live/show.ex #: lib/mv_web/live/member_field_live/index_component.ex @@ -2434,11 +2334,6 @@ msgstr "" msgid "No approved or rejected requests yet" msgstr "No approved or rejected requests yet" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "No cycle" -msgstr "" - #: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "No cycles" @@ -2698,9 +2593,7 @@ msgid "Options" msgstr "" #: lib/mv/membership/members_pdf.ex -#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/member_live/index/membership_fee_status.ex -#: lib/mv_web/live/member_live/index/payment_aging.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 @@ -2735,16 +2628,6 @@ msgstr "" msgid "Payment Interval" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "Payment Status" -msgstr "" - -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "Payments" -msgstr "" - #: lib/mv_web/live/join_request_live/helpers.ex #, elixir-autogen, elixir-format msgid "Pending confirmation" @@ -2843,7 +2726,7 @@ msgstr "" msgid "Quarterly Interval - Joining Cycle Excluded" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #, elixir-autogen, elixir-format msgid "Range" msgstr "" @@ -3267,11 +3150,6 @@ msgstr "" msgid "Set Password" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex -#, elixir-autogen, elixir-format -msgid "Sets whether the payment status filter and the membership fee status column use the last completed or the current payment cycle." -msgstr "Sets whether the payment status filter and the membership fee status column use the last completed or the current payment cycle." - #: lib/mv_web/live/membership_fee_settings_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "Settings saved successfully." @@ -3601,7 +3479,7 @@ msgstr "" msgid "Tip: Paste email addresses into the BCC field for privacy compliance" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #, elixir-autogen, elixir-format msgid "To" msgstr "" @@ -3701,7 +3579,6 @@ msgid "Unlinking scheduled" msgstr "" #: lib/mv/membership/members_pdf.ex -#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/member_live/index/membership_fee_status.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex @@ -3906,7 +3783,7 @@ msgid "Yearly Interval - Joining Cycle Included" msgstr "" #: lib/mv_web/components/core_components.ex -#: lib/mv_web/live/components/member_filter_component.ex +#: lib/mv_web/live/components/add_filter_builder_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/join_request_live/show.ex #: lib/mv_web/live/member_field_live/index_component.ex @@ -4129,11 +4006,6 @@ msgstr "" msgid "updated" msgstr "" -#: lib/mv_web/live/components/member_filter_component.ex -#, elixir-autogen, elixir-format -msgid "without %{name}" -msgstr "without %{name}" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgctxt "action" @@ -4218,22 +4090,284 @@ msgstr "" msgid "No address" msgstr "" -#: lib/mv_web/live/member_live/index/payment_aging.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "%{count} unpaid" -msgstr "" - +#: lib/mv_web/live/components/add_filter_builder_component.ex #: lib/mv_web/live/member_live/index/filter_descriptor.ex #, elixir-autogen, elixir-format msgid "Active / former" msgstr "" #: lib/mv_web/live/member_live/index/filter_descriptor.ex -#, elixir-autogen, elixir-format, fuzzy +#, elixir-autogen, elixir-format msgid "Group" msgstr "" +#: lib/mv_web/live/components/add_filter_builder_component.ex #: lib/mv_web/live/member_live/index/filter_descriptor.ex -#, elixir-autogen, elixir-format, fuzzy +#, elixir-autogen, elixir-format msgid "Payment" msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "%{label} — open payment history" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Active" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Add filter" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "All (incl. former)" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Available filters" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Clear all" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Custom fields" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Exited" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Fee type: %{name}" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Fee type: not %{name}" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Filter by …" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Former" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Former only" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Fully paid" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Group: %{name}" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Group: not %{name}" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Has unpaid ≥ 1" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Has unpaid ≥ 2" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Has unpaid ≥ 3" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Joined" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Last 30 days" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Last year" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Membership" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Payment status" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Period" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Period from" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Period to" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Quick" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "Remove filter: %{label}" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "This month" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "This year" +msgstr "" + +#: lib/mv_web/live/components/add_filter_builder_component.ex +#, elixir-autogen, elixir-format +msgid "≥ %{n} unpaid" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "%{count} open" +msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format, fuzzy +msgid "+%{count} more" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "All paid" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "April" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "August" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "December" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "February" +msgstr "" + +#: lib/mv_web/live/member_live/index.ex +#, elixir-autogen, elixir-format +msgid "Fees" +msgstr "" + +#: lib/mv_web/live/member_live/index.ex +#, elixir-autogen, elixir-format +msgid "Fees · %{range}" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "January" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "July" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "June" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "March" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "November" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format +msgid "October" +msgstr "" + +#: lib/mv_web/live/member_live/index/payment_aging.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "September" +msgstr "" + +#~ #: lib/mv_web/live/member_live/index.html.heex +#~ #, elixir-autogen, elixir-format +#~ msgid "Click for member details" +#~ msgstr "" + +#~ #: lib/mv_web/live/member_live/index.html.heex +#~ #, elixir-autogen, elixir-format +#~ msgid "No open cycles for this period" +#~ msgstr "" + +#~ #: lib/mv_web/live/member_live/index.ex +#~ #, elixir-autogen, elixir-format +#~ msgid "Payment · %{range}" +#~ msgstr "" + +#~ #: lib/mv_web/live/member_live/index.html.heex +#~ #, elixir-autogen, elixir-format +#~ msgid "Suspended: %{date}" +#~ msgstr "" + +#~ #: lib/mv_web/live/member_live/index.html.heex +#~ #, elixir-autogen, elixir-format +#~ msgid "Unpaid until %{date}" +#~ msgstr "" diff --git a/test/mv_web/components/member_filter_component_test.exs b/test/mv_web/components/member_filter_component_test.exs deleted file mode 100644 index b93acccb..00000000 --- a/test/mv_web/components/member_filter_component_test.exs +++ /dev/null @@ -1,660 +0,0 @@ -defmodule MvWeb.Components.MemberFilterComponentTest do - @moduledoc """ - Unit tests for the MemberFilterComponent. - - Tests cover: - - Rendering Payment Filter and Boolean Custom Fields - - Boolean filter selection and event emission - - Button label and badge logic - - Filtering to show only boolean custom fields - """ - # Kept async: false. The deferrable-FK migration removed the concurrent - # create_member deadlock, but this file additionally showed an async-isolation - # failure under load (filtered members from a parallel test leaking in), so it - # is not trivially async-safe; resolving that is a separate follow-up. - use MvWeb.ConnCase, async: false - - use Gettext, backend: MvWeb.Gettext - - import Phoenix.LiveViewTest - - alias Mv.Membership.CustomField - - # Helper to create a boolean custom field (uses system_actor - only admin can create) - defp create_boolean_custom_field(attrs \\ %{}) do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - - default_attrs = %{ - name: "test_boolean_#{System.unique_integer([:positive])}", - value_type: :boolean - } - - attrs = Map.merge(default_attrs, attrs) - - CustomField - |> Ash.Changeset.for_create(:create, attrs) - |> Ash.create!(actor: system_actor) - end - - # Helper to create a non-boolean custom field (uses system_actor - only admin can create) - defp create_string_custom_field(attrs \\ %{}) do - system_actor = Mv.Helpers.SystemActor.get_system_actor() - - default_attrs = %{ - name: "test_string_#{System.unique_integer([:positive])}", - value_type: :string - } - - attrs = Map.merge(default_attrs, attrs) - - CustomField - |> Ash.Changeset.for_create(:create, attrs) - |> Ash.create!(actor: system_actor) - end - - describe "rendering" do - test "trigger carries a trailing chevron affordance", %{conn: conn} do - conn = conn_with_oidc_user(conn) - {:ok, _view, html} = live(conn, "/members") - - # Mirror the shared dropdown affordance: a trailing chevron inside the - # bespoke filter trigger button. - chevron = - html - |> LazyHTML.from_fragment() - |> LazyHTML.query(~s(#member-filter button[aria-haspopup="true"] .hero-chevron-down)) - - assert Enum.count(chevron) == 1 - end - - test "renders boolean custom fields when present", %{conn: conn} do - conn = conn_with_oidc_user(conn) - boolean_field = create_boolean_custom_field(%{name: "Active Member"}) - - {:ok, view, _html} = live(conn, "/members") - - # Should show the boolean custom field name in the dropdown - view - |> element("#member-filter button[aria-haspopup='true']") - |> render_click() - - html = render(view) - assert html =~ boolean_field.name - end - - test "renders payment and custom fields groups when boolean fields exist", %{conn: conn} do - conn = conn_with_oidc_user(conn) - _boolean_field = create_boolean_custom_field() - - {:ok, view, _html} = live(conn, "/members") - - # Open dropdown - view - |> element("#member-filter button[aria-haspopup='true']") - |> render_click() - - html = render(view) - # Should have both "Payments" and "Custom Fields" group labels - assert html =~ gettext("Payments") || html =~ "Payment" - assert html =~ gettext("Individual datafields") - end - - test "renders only payment filter when no boolean custom fields exist", %{conn: conn} do - conn = conn_with_oidc_user(conn) - # Create a non-boolean field to ensure it's not shown - _string_field = create_string_custom_field() - - {:ok, view, _html} = live(conn, "/members") - - # Component should exist with correct ID - assert has_element?(view, "#member-filter") - - # Open dropdown - view - |> element("#member-filter button[aria-haspopup='true']") - |> render_click() - - html = render(view) - - # Should show payment filter options (check both English and translated) - assert html =~ "All" || html =~ gettext("All") - assert html =~ "Paid" || html =~ gettext("Paid") - assert html =~ "Unpaid" || html =~ gettext("Unpaid") - - # Should not show any boolean field names (since none exist) - # We can't easily check this without knowing field names, but the structure should be correct - end - end - - describe "boolean filter selection" do - test "selecting boolean filter sends correct event", %{conn: conn} do - conn = conn_with_oidc_user(conn) - boolean_field = create_boolean_custom_field(%{name: "Newsletter"}) - - {:ok, view, _html} = live(conn, "/members") - - # Open dropdown - view - |> element("#member-filter button[aria-haspopup='true']") - |> render_click() - - # Select "True" option for the boolean field using radio input - # Radio inputs trigger phx-change on the form, so we use render_change on the form - view - |> form("#member-filter form", %{ - "custom_boolean" => %{to_string(boolean_field.id) => "true"} - }) - |> render_change() - - # The event should be sent to the parent LiveView - # We verify this by checking that the URL is updated - assert_patch(view) - end - - test "payment filter still works after component extension", %{conn: conn} do - conn = conn_with_oidc_user(conn) - _boolean_field = create_boolean_custom_field() - - {:ok, view, _html} = live(conn, "/members") - - # Open dropdown - view - |> element("#member-filter button[aria-haspopup='true']") - |> render_click() - - # Select "Paid" option using radio input - # Radio inputs trigger phx-change on the form, so we use render_change on the form - view - |> form("#member-filter form", %{"payment_filter" => "paid"}) - |> render_change() - - # URL should be updated with cycle_status_filter=paid - path = assert_patch(view) - assert path =~ "cycle_status_filter=paid" - end - end - - describe "button label" do - test "shows active boolean filter names in button label", %{conn: conn} do - conn = conn_with_oidc_user(conn) - boolean_field1 = create_boolean_custom_field(%{name: "Active Member"}) - boolean_field2 = create_boolean_custom_field(%{name: "Newsletter"}) - - # Set filters via URL - {:ok, view, _html} = - live( - conn, - "/members?bf_#{boolean_field1.id}=true&bf_#{boolean_field2.id}=false" - ) - - # Component should exist - assert has_element?(view, "#member-filter") - - # Button label should contain the custom field names - # The exact format depends on implementation, but should show active filters - button_html = - view - |> element("#member-filter button[aria-haspopup='true']") - |> render() - - assert button_html =~ boolean_field1.name || button_html =~ boolean_field2.name - end - - test "truncates long custom field names in button label", %{conn: conn} do - conn = conn_with_oidc_user(conn) - # Create field with very long name (>30 characters) - long_name = String.duplicate("A", 50) - boolean_field = create_boolean_custom_field(%{name: long_name}) - - # Set filter via URL - {:ok, view, _html} = - live(conn, "/members?bf_#{boolean_field.id}=true") - - # Component should exist - assert has_element?(view, "#member-filter") - - # Get button label text - button_html = - view - |> element("#member-filter button[aria-haspopup='true']") - |> render() - - # Button label should be truncated - full name should NOT appear in button - # (it may appear in dropdown when opened, but not in the button label itself) - # Check that button doesn't contain the full 50-character name - refute button_html =~ long_name - - # Button should still contain some text (truncated version or indicator) - assert String.length(button_html) > 0 - end - - test "date-only activation (ed_mode=all) replaces the idle label", %{conn: conn} do - conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?ed_mode=all") - - button_html = - view - |> element("#member-filter button[aria-haspopup='true']") - |> render() - - # The idle label must not appear; some non-idle label is shown. This is - # the same observable contract as the other filter categories — the - # button visually communicates "a filter is active". The `btn-active` - # CSS class is set by the parent class= attribute but the `<.button>` - # core component currently composes its own class string and drops the - # caller-supplied one — that is a pre-existing component constraint, not - # specific to date filters. - refute button_html =~ gettext("Apply filters") - end - - test "date-only activation (jd_from) replaces the idle label", %{conn: conn} do - conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?jd_from=2024-01-15") - - button_html = - view - |> element("#member-filter button[aria-haspopup='true']") - |> render() - - refute button_html =~ gettext("Apply filters") - end - - test "date filter combined with one other filter shows '2 filters active'", %{conn: conn} do - conn = conn_with_oidc_user(conn) - boolean_field = create_boolean_custom_field(%{name: "Newsletter"}) - - {:ok, view, _html} = - live(conn, "/members?ed_mode=all&bf_#{boolean_field.id}=true") - - button_html = - view - |> element("#member-filter button[aria-haspopup='true']") - |> render() - - # With two distinct filter categories active, the label switches to the - # pluralized "N filters active" form. Without counting date filters as - # a category, this would show only "1 filter active" or the boolean - # field name. - assert button_html =~ "2" - assert button_html =~ gettext("filters active") - end - end - - describe "badge" do - test "shows total count of active boolean filters in badge", %{conn: conn} do - conn = conn_with_oidc_user(conn) - boolean_field1 = create_boolean_custom_field(%{name: "Field1"}) - boolean_field2 = create_boolean_custom_field(%{name: "Field2"}) - - # Set two filters via URL - {:ok, view, _html} = - live( - conn, - "/members?bf_#{boolean_field1.id}=true&bf_#{boolean_field2.id}=false" - ) - - # Component should exist - assert has_element?(view, "#member-filter") - - # Badge should be visible when boolean filters are active - assert has_element?(view, "#member-filter .badge") - - # Badge should show count of active boolean filters (2 in this case) - badge_html = - view - |> element("#member-filter .badge") - |> render() - - assert badge_html =~ "2" - end - end - - describe "filtering" do - test "only boolean custom fields are displayed, not string or integer fields", %{conn: conn} do - conn = conn_with_oidc_user(conn) - boolean_field = create_boolean_custom_field(%{name: "Boolean Field"}) - _string_field = create_string_custom_field(%{name: "String Field"}) - - {:ok, view, _html} = live(conn, "/members") - - # Open dropdown - view - |> element("#member-filter button[aria-haspopup='true']") - |> render_click() - - # Should show boolean field in the dropdown panel - # Extract only the dropdown panel HTML to check - dropdown_html = - view - |> element("#member-filter div[role='dialog']") - |> render() - - # Should show boolean field in dropdown - assert dropdown_html =~ boolean_field.name - - # Should not show string field name in the filter dropdown - # (String fields should not appear in boolean filter section) - refute dropdown_html =~ "String Field" - end - - test "renders the Dates section with exit_date and join_date controls", %{conn: conn} do - conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members") - - view - |> element("#member-filter button[aria-haspopup='true']") - |> render_click() - - dropdown_html = - view - |> element("#member-filter div[role='dialog']") - |> render() - - assert dropdown_html =~ gettext("Dates") - assert dropdown_html =~ gettext("Join date") - assert dropdown_html =~ gettext("Exit date") - # Exit-date segmented control modes. - assert dropdown_html =~ gettext("Active only") - assert dropdown_html =~ gettext("Inactive only") - # Built-in date inputs (always present for join_date and the ed_mode selector). - assert dropdown_html =~ ~s(name="jd_from") - assert dropdown_html =~ ~s(name="jd_to") - assert dropdown_html =~ ~s(name="ed_mode") - end - - test "exit_date custom mode reveals ed_from and ed_to inputs", %{conn: conn} do - conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?ed_mode=custom") - - view - |> element("#member-filter button[aria-haspopup='true']") - |> render_click() - - dropdown_html = - view - |> element("#member-filter div[role='dialog']") - |> render() - - assert dropdown_html =~ ~s(name="ed_from") - assert dropdown_html =~ ~s(name="ed_to") - end - - test "date inputs render via MvWeb.CoreComponents.input (no raw DaisyUI input markup)", - %{conn: conn} do - # DESIGN_GUIDELINES §1.1 mandates that LiveViews/HEEX use the project's - # `<.input>` wrapper rather than emitting raw `` tags carrying - # DaisyUI component classes (e.g. `input input-sm input-bordered`) - # directly in HEEX. `<.input>` is the project's single source of truth - # for input styling; bypassing it splits styling across many call sites. - # - # The recognizable structural fingerprint of `<.input>` is a wrapping - # `
    ` `
    """ } @@ -499,7 +501,6 @@ id={"payment-badge-#{member.id}"} class={["badge badge-soft badge-md", badge.color_class]} style={count > 0 && "anchor-name: #{anchor}"} - data-variant={badge.variant} phx-hook={count > 0 && "HoverPopover"} data-popover-target={count > 0 && tip_id} aria-details={count > 0 && tip_id} diff --git a/lib/mv_web/live/member_live/index/payment_aging.ex b/lib/mv_web/live/member_live/index/payment_aging.ex index 4d6576e1..644a1597 100644 --- a/lib/mv_web/live/member_live/index/payment_aging.ex +++ b/lib/mv_web/live/member_live/index/payment_aging.ex @@ -158,13 +158,12 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do def suspended_to_params(_), do: %{} @doc """ - Badge descriptor for an unpaid-cycle count. A count of 0 reads "Alle bezahlt" - (success/green); a positive count reads "N offen" (error/red). Reuses the - established cycle-status color and hero-icon codes so the overview badge reads - consistently with the member show page. + Badge descriptor for an unpaid-cycle count. A count of 0 reads "All paid" + (success/green); a positive count reads "N open" (error/red). The color class + is derived from the shared `MembershipFeeHelpers.status_variant/1` mapping, so + the overview badge reads consistently with the member show page. """ @spec badge(non_neg_integer()) :: %{ - variant: atom(), color_class: String.t(), icon: String.t(), label: String.t(), @@ -172,8 +171,7 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do } def badge(0) do %{ - variant: :success, - color_class: cycle_color_class(:paid), + color_class: status_badge_class(:paid), icon: MembershipFeeHelpers.status_icon(:paid), label: gettext("All paid"), count: 0 @@ -182,14 +180,23 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do def badge(count) when is_integer(count) and count > 0 do %{ - variant: MembershipFeeHelpers.status_variant(:unpaid), - color_class: cycle_color_class(:unpaid), + color_class: status_badge_class(:unpaid), icon: MembershipFeeHelpers.status_icon(:unpaid), label: gettext("%{count} open", count: count), count: count } end + @doc """ + DaisyUI badge color class for a cycle status, derived from the single shared + `MembershipFeeHelpers.status_variant/1` mapping so the overview badges, the + tooltip badges and the member show page all stay in lock-step. daisyUI emits + all `badge-*` component colors, so the interpolated class is always bundled. + """ + @spec status_badge_class(:paid | :unpaid | :suspended) :: String.t() + def status_badge_class(status), + do: "badge-#{MembershipFeeHelpers.status_variant(status)}" + @doc """ Compact short code for a payment period (§1.28), or nil when the period is not compactly codeable (the header then falls back to a plain "filtered" badge). diff --git a/test/mv_web/member_live/index_payment_period_test.exs b/test/mv_web/member_live/index_payment_period_test.exs index be256316..554aaf6a 100644 --- a/test/mv_web/member_live/index_payment_period_test.exs +++ b/test/mv_web/member_live/index_payment_period_test.exs @@ -144,16 +144,16 @@ defmodule MvWeb.MemberLive.IndexPaymentPeriodTest do end describe "badge/1" do - test "0 renders as All paid (success/green)" do + test "0 renders as All paid (success/green), color derived from the shared status variant" do badge = PaymentAging.badge(0) - assert badge.variant == :success + refute Map.has_key?(badge, :variant) assert badge.color_class == "badge-success" assert badge.label == "All paid" end - test "N > 0 renders as N open (error/red)" do + test "N > 0 renders as N open (error/red), color derived from the shared status variant" do badge = PaymentAging.badge(2) - assert badge.variant == :error + refute Map.has_key?(badge, :variant) assert badge.color_class == "badge-error" assert badge.label =~ "2" assert badge.label =~ "open" From 4d6294771a91a2e1dc3884f13a7901ffba99ad85 Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 10 Jul 2026 16:27:16 +0200 Subject: [PATCH 28/30] feat(overview): show suspended cycles separately in the payment tooltip --- lib/mv_web/live/member_live/index.ex | 1 + lib/mv_web/live/member_live/index.html.heex | 47 +++++++++++++++---- .../live/member_live/index/payment_aging.ex | 46 +++++++++--------- priv/gettext/de/LC_MESSAGES/default.po | 1 + priv/gettext/default.pot | 1 + priv/gettext/en/LC_MESSAGES/default.po | 1 + .../index_membership_fee_status_test.exs | 25 ++++++++++ 7 files changed, 91 insertions(+), 31 deletions(-) diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index af0214e7..62a82026 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -49,6 +49,7 @@ defmodule MvWeb.MemberLive.Index do alias Mv.MembershipFees alias Mv.MembershipFees.MembershipFeeType alias MvWeb.Helpers.DateFormatter + alias MvWeb.Helpers.MembershipFeeHelpers alias MvWeb.MemberLive.Index.AsOfDate alias MvWeb.MemberLive.Index.CustomFieldValueLookup alias MvWeb.MemberLive.Index.DateFilter diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index 0a5b9280..39182330 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -511,8 +511,9 @@ {badge.label} <%= if count > 0 do %> - <% cycles = PaymentAging.open_cycles(member, @payment_period) %> - <% {shown, overflow} = PaymentAging.tooltip_cycles(cycles) %> + <% %{unpaid: unpaid, suspended: suspended} = + PaymentAging.partition_open_cycles(member, @payment_period) %> + <% {shown, overflow} = PaymentAging.tooltip_cycles(unpaid) %>
    -
      +
      • - - <.icon name={PaymentAging.cycle_icon(cycle.status)} class="size-3.5" /> + <.badge + variant={MembershipFeeHelpers.status_variant(cycle.status)} + size="sm" + class="w-full justify-start" + > + <:icon> + <.icon name={MembershipFeeHelpers.status_icon(cycle.status)} class="size-3.5" /> + {PaymentAging.short_period(cycle)} - +
      • 0} class="flex"> @@ -538,6 +545,28 @@
      + <%!-- Suspended cycles are shown separately under their own label so + the status distinction is not color-only (§1.14). --%> +
      +

      {gettext("Suspended")}

      +
        +
      • + <.badge + variant={MembershipFeeHelpers.status_variant(cycle.status)} + size="sm" + class="w-full justify-start" + > + <:icon> + <.icon + name={MembershipFeeHelpers.status_icon(cycle.status)} + class="size-3.5" + /> + + {PaymentAging.short_period(cycle)} + +
      • +
      +
    <% end %> diff --git a/lib/mv_web/live/member_live/index/payment_aging.ex b/lib/mv_web/live/member_live/index/payment_aging.ex index 644a1597..4f48c326 100644 --- a/lib/mv_web/live/member_live/index/payment_aging.ex +++ b/lib/mv_web/live/member_live/index/payment_aging.ex @@ -272,14 +272,32 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do |> Enum.sort_by(& &1.cycle_start, Date) end + @doc """ + Partitions a member's open cycles for the period into the unpaid cycles (which + drive the count and the badge) and the suspended cycles, which the tooltip + surfaces in their own labeled section rather than by color alone (§1.14). + Both lists stay in chronological order. + """ + @spec partition_open_cycles(map(), period()) :: %{unpaid: [map()], suspended: [map()]} + def partition_open_cycles(member, period) do + {unpaid, suspended} = + member + |> open_cycles(period) + |> Enum.split_with(&(&1.status == :unpaid)) + + %{unpaid: unpaid, suspended: suspended} + end + @tooltip_slots 5 @doc """ - Partitions open cycles into the tooltip's fixed five-slot budget: at most - five badges total. Five or fewer cycles show in full with no overflow. More - than five collapse to the first four chronological cycles plus a single - overflow count (total − 4), so the tooltip renders four cycle badges and one - grey overflow badge — five items. + Partitions the tooltip's unpaid sub-list into a fixed five-slot budget: at + most five badges for the unpaid cycles. Five or fewer unpaid cycles show in + full with no overflow. More than five collapse to the first four chronological + cycles plus a single overflow count (total − 4), so the unpaid sub-list + renders four cycle badges and one grey overflow badge — five items. Suspended + cycles are listed separately (in their own labeled section) and are not capped + by this budget, so the tooltip's overall badge count can exceed five. """ @spec tooltip_cycles([map()]) :: {[map()], non_neg_integer()} def tooltip_cycles(cycles) when is_list(cycles) do @@ -290,22 +308,6 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do end end - @doc """ - Static daisyUI badge color token for a cycle status, matching the codes used - on the member show page. Literal strings so Tailwind's content scanner keeps - them in the bundle (a runtime `badge-\#{status}` would be tree-shaken). - """ - @spec cycle_color_class(:paid | :unpaid | :suspended) :: String.t() - def cycle_color_class(:paid), do: "badge-success" - def cycle_color_class(:unpaid), do: "badge-error" - def cycle_color_class(:suspended), do: "badge-warning" - - @doc """ - Hero-icon name for a cycle status (reuses the established status icons). - """ - @spec cycle_icon(:paid | :unpaid | :suspended) :: String.t() - def cycle_icon(status), do: MembershipFeeHelpers.status_icon(status) - @doc """ Short, human-readable period label for a cycle, derived from its `cycle_start` and the fee-type interval: @@ -313,7 +315,7 @@ defmodule MvWeb.MemberLive.Index.PaymentAging do * yearly → `"2025"` * quarterly → `"Q1 2026"` * half-yearly → `"H1 2026"` - * monthly → `"März 2026"` + * monthly → `"March 2026"` Falls back to a `dd.MM.yyyy–dd.MM.yyyy` date range when the interval is not loaded/known. diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 1a8682ad..5706d57b 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -3259,6 +3259,7 @@ msgid "Summary" msgstr "Zusammenfassung" #: lib/mv/membership/members_pdf.ex +#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index/membership_fee_status.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index ae7e899b..309f5ae4 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -3260,6 +3260,7 @@ msgid "Summary" msgstr "" #: lib/mv/membership/members_pdf.ex +#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index/membership_fee_status.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 9f43b72f..a8decc87 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -3260,6 +3260,7 @@ msgid "Summary" msgstr "" #: lib/mv/membership/members_pdf.ex +#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index/membership_fee_status.ex #: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex diff --git a/test/mv_web/member_live/index_membership_fee_status_test.exs b/test/mv_web/member_live/index_membership_fee_status_test.exs index ae6e5657..f4a2afdc 100644 --- a/test/mv_web/member_live/index_membership_fee_status_test.exs +++ b/test/mv_web/member_live/index_membership_fee_status_test.exs @@ -83,6 +83,31 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do assert has_element?(view, "#payment-tip-#{member.id}[popover]") end + test "the tooltip lists suspended cycles in a separate labeled section (§1.14)", %{conn: conn} do + fee_type = create_fee_type(%{interval: :yearly}) + member = create_member(%{first_name: "Mixed", membership_fee_type_id: fee_type.id}) + create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid}) + create_cycle(member, fee_type, %{cycle_start: ~D[2024-01-01], status: :suspended}) + + {:ok, view, _html} = live(conn, "/members") + + tip = "#payment-tip-#{member.id}" + + # Suspended cycles are shown separately, under their own text label — the + # distinction is not color-only. The suspended cycle's period lives in the + # suspended section and is absent from the unpaid list. + assert has_element?(view, "#{tip} [data-testid='payment-tooltip-suspended']", "Suspended") + + suspended = + view |> element("#{tip} [data-testid='payment-tooltip-suspended']") |> render() + + assert suspended =~ "2024" + + unpaid = view |> element("#{tip} [data-testid='payment-tooltip-unpaid']") |> render() + assert unpaid =~ "2023" + refute unpaid =~ "2024" + end + test "a fully-paid member renders the badge but no tooltip popover", %{conn: conn} do fee_type = create_fee_type(%{interval: :yearly}) member = create_member(%{first_name: "Clean", membership_fee_type_id: fee_type.id}) From fc634813d8ac5f208c8ddfbb1ef6edf1c50299da Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 10 Jul 2026 17:11:25 +0200 Subject: [PATCH 29/30] docs(changelog): record member-overview filter redesign under Unreleased --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f734002b..af124bce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,12 +10,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Members overview – view settings** – A new "View" dropdown switches the table between compact and comfortable density and controls how the Member and Address fields are shown: as combined composite cells or split into their underlying columns (Vorname/Nachname/E-Mail resp. Straße/PLZ/Ort). Every choice is remembered per browser. - **Members overview – reset columns to default** – The column-visibility menu gained a reset button that restores the curated default column set. +- **Members overview – add-filter builder** – A searchable, grouped field picker adds filters as editable chips instead of a fixed panel; the active filters are encoded in the URL, so a filtered view is bookmarkable and survives a reload. +- **Members overview – more filters** – New filters for members active on a chosen date (point-in-time), members with suspended cycles in the period, matching any of several selected groups (OR), and a range on the number of open payment cycles. +- **Members overview – payment aging column** – The fees column shows how many billing cycles each member has left unpaid in the selected period (cycles are member-relative), is sortable by that count, carries a period indicator on its header, and lists the open — and separately, the suspended — cycles in a hover tooltip. ### Changed - **Members overview – loads on demand** – The overview no longer reads every member into memory. Filtering, sorting and pagination now run in the database, and rows stream in via infinite scroll as you reach the bottom of the list, so large member lists open quickly. The exact number of matching members is announced (for example "250 Members"). - **Members overview – condensed name and address** – By default a member shows as a composite name cell and a two-line address cell (street / postal code + city); both columns are sortable and city and postal code stay findable via search. A clear (×) button was added to the search field. - **Members overview – bulk actions span the full result set** – Select-all, copy e-mail addresses, and export now act on the complete filtered set rather than only the rows currently loaded, and the count reflects the full filtered total. - **Members overview – sticky header, pinned column and sortable headers** – The table keeps a sticky header and pinned identifier column, sort headers that expose the active sort direction, boolean columns rendered as ✓/✗ icons, larger touch targets in both densities, and result-count and loading changes announced through a polite live region — meeting WCAG 2.2 AA for accessibility. +- **Members overview – filter panel replaced** – The vertical filter panel is replaced by the add-filter builder, and date ranges now use native date inputs with relative presets, with a quick year jump and manual entry. + +### Fixed +- **Members overview – keyboard-operable filter toggles** – The filter builder's Yes/No and status toggles can now be reached and switched with the keyboard and show a visible focus indicator (WCAG 2.1.1 Keyboard, 2.4.7 Focus Visible). ## [1.3.0] - 2026-06-16 From 051183689574149ea69fcdde3fb36a121d1e5856 Mon Sep 17 00:00:00 2001 From: Simon Date: Sat, 11 Jul 2026 14:29:32 +0200 Subject: [PATCH 30/30] fix(deps): pin ash_authentication below 4.14 to unblock OIDC build --- mix.exs | 6 +++++- mix.lock | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/mix.exs b/mix.exs index 02861f93..63a91671 100644 --- a/mix.exs +++ b/mix.exs @@ -45,7 +45,11 @@ defmodule Mv.MixProject do {:ash_phoenix, "~> 2.0"}, {:ash, "~> 3.0"}, {:bcrypt_elixir, "~> 3.0"}, - {:ash_authentication, "~> 4.9"}, + # Pinned below 4.14 until the identity_resource / UserIdentity migration lands. + # 4.14 makes an `identity_resource` mandatory for OIDC strategies; our custom + # `oidc_id` design already resolves users by the `sub` claim (never by email), + # so there is no live security gap. Unpin once UserIdentity + data migration exist. + {:ash_authentication, "~> 4.13.0"}, {:ash_authentication_phoenix, "~> 2.10"}, {:igniter, "~> 0.8", only: [:dev, :test]}, {:phoenix, "~> 1.8.0-rc.4", override: true}, diff --git a/mix.lock b/mix.lock index 17cb540d..cb724e83 100644 --- a/mix.lock +++ b/mix.lock @@ -1,7 +1,7 @@ %{ "ash": {:hex, :ash, "3.29.3", "ba3c286c10fef489e01e1896bb6e80dbcac357c55b6d375da9e9eb7b49e88234", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.6.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2a7a45d3a3b980457b5cc7e339a84a35c232a7ab99047bbf7ce55e7dc36df357"}, "ash_admin": {:hex, :ash_admin, "1.1.0", "df7c8075347ca9229f132648534a33319f29ae5aceed6c1015d138bba1a4811f", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.20 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:cinder, "~> 0.9", [hex: :cinder, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "f8bd6c08b584a315a9574c7bbe9c1c914bc5c51838045994b0e5369871f9b3d8"}, - "ash_authentication": {:hex, :ash_authentication, "4.14.1", "67ce78459f1064d0a0374beced5bf6bd106e98d59f3253a96525f3073c2493fb", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "> 0.2.0 and < 0.3.0", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "e149234fc70dc2544aac7a52656318bebca89a58ecbe98fb884ae41cf76fe6d7"}, + "ash_authentication": {:hex, :ash_authentication, "4.13.7", "421b5ddb516026f6794435980a632109ec116af2afa68a45e15fb48b41c92cfa", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "> 0.2.0 and < 0.3.0", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "0d45ac3fdcca6902dabbe161ce63e9cea8f90583863c2e14261c9309e5837121"}, "ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.16.0", "02045ecde9eeb30ab1bfffbdf693c64426af24902bcd533765eba725b9b9f46f", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "1107a45af771ee7c02ebe82abcaf9a778096e66b3e6cb2b6e614d22d1fe385f7"}, "ash_phoenix": {:hex, :ash_phoenix, "2.3.22", "f59a347ee09e1fa9973fe1b2faf7f8f793acc14dc09341062783b8eb1a9f5c99", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "4b34ea84e122c238ad1843888b8fd4d21aec27605b9b1e6e27e1b70329560fbb"}, "ash_postgres": {:hex, :ash_postgres, "2.10.0", "831d3b70b80560a6894cb17c98e3f72e3dbbc06e835dbac36363c27e186b1940", [:mix], [{:ash, "~> 3.29", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, "~> 0.6", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "e73298910d29b8051b30af390094165848b588288204ab12990f365c9fc653cc"},