<.header> {@content_title} <:actions> <.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} mailto_bcc={@mailto_bcc} recipient_count={@recipient_count} mailto_disabled?={@mailto_disabled?} /> <%= if can?(@current_user, :create, Mv.Membership.Member) do %> <.button variant="primary" navigate={~p"/members/new"} data-testid="member-new"> <.icon name="hero-plus" /> {gettext("New Member")} <% end %> <%!-- 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.AddFilterBuilderComponent} id="member-filter" groups={@groups} group_filters={@group_filters} fee_types={@fee_types} fee_type_filters={@fee_type_filters} boolean_custom_fields={@boolean_custom_fields} boolean_filters={@boolean_custom_field_filters} date_custom_fields={@date_custom_fields} date_filters={@date_filters} payment_filter={@payment_filter} payment_period={@payment_period} />
<%!-- 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={@streams.members} row_item={fn {_dom_id, member} -> member end} wrapper_overflow_class="overflow-visible" sticky_header={true} sticky_first_col={true} viewport_bottom={@more? && "load_more"} row_click={ fn {_dom_id, member} -> JS.push("select_row_and_navigate", value: %{id: member.id}) end } row_selected?={fn member -> MapSet.member?(@selected_members, member.id) end} dynamic_cols={@dynamic_cols} sort_field={@sort_field} 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} label={ ~H""" 0 and @selected_count < @total_count)} checked={@total_count > 0 and @selected_count == @total_count} aria-label={gettext("Select all members")} role="checkbox" /> """ } > <:col :let={member} :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] |> Enum.reject(&(&1 in [nil, ""])) |> Enum.join(" ") %>
{full_name}
{member.email}
<:col :let={member} :if={:first_name in @member_fields_visible} sort_field={:first_name} label={ ~H""" <.live_component module={MvWeb.Components.SortHeaderComponent} id={:sort_first_name} field={:first_name} label={gettext("First name")} sort_field={@sort_field} sort_order={@sort_order} /> """ } > {member.first_name} <:col :let={member} :if={:last_name in @member_fields_visible} sort_field={:last_name} label={ ~H""" <.live_component module={MvWeb.Components.SortHeaderComponent} id={:sort_last_name} field={:last_name} label={gettext("Last name")} sort_field={@sort_field} sort_order={@sort_order} /> """ } > {member.last_name} <:col :let={member} :if={:email in @member_fields_visible} sort_field={:email} label={ ~H""" <.live_component module={MvWeb.Components.SortHeaderComponent} id={:sort_email} field={:email} label={gettext("Email")} sort_field={@sort_field} sort_order={@sort_order} /> """ } > {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(" ") %> <.maybe_value value={line1 <> line2} empty_sr_text={gettext("No address")}>
{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} /> """ } > <.maybe_value value={member.street} empty_sr_text={gettext("Not specified")}> {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} /> """ } > <.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} 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} /> """ } > <.maybe_value value={member.postal_code} empty_sr_text={gettext("Not specified")}> {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} /> """ } > <.maybe_value value={member.city} empty_sr_text={gettext("Not specified")}> {member.city} <:col :let={member} :if={:join_date in @member_fields_visible} sort_field={:join_date} label={ ~H""" <.live_component module={MvWeb.Components.SortHeaderComponent} id={:sort_join_date} field={:join_date} label={gettext("Join Date")} sort_field={@sort_field} sort_order={@sort_order} /> """ } > {MvWeb.MemberLive.Index.format_date(member.join_date)} <:col :let={member} :if={:exit_date in @member_fields_visible} sort_field={:exit_date} label={ ~H""" <.live_component module={MvWeb.Components.SortHeaderComponent} id={:sort_exit_date} field={:exit_date} label={gettext("Exit Date")} sort_field={@sort_field} sort_order={@sort_order} /> """ } > {MvWeb.MemberLive.Index.format_date(member.exit_date)} <:col :let={member} :if={:notes in @member_fields_visible} label={gettext("Notes")} > {member.notes} <:col :let={member} :if={:country in @member_fields_visible} sort_field={:country} label={ ~H""" <.live_component module={MvWeb.Components.SortHeaderComponent} id={:sort_country} field={:country} label={gettext("Country")} sort_field={@sort_field} sort_order={@sort_order} /> """ } > {member.country} <:col :let={member} :if={:membership_fee_start_date in @member_fields_visible} sort_field={:membership_fee_start_date} label={ ~H""" <.live_component module={MvWeb.Components.SortHeaderComponent} id={:sort_membership_fee_start_date} field={:membership_fee_start_date} label={gettext("Membership Fee Start Date")} sort_field={@sort_field} sort_order={@sort_order} /> """ } > {MvWeb.MemberLive.Index.format_date(member.membership_fee_start_date)} <:col :let={member} :if={:membership_fee_type in @member_fields_visible} sort_field={:membership_fee_type} label={ ~H""" <.live_component module={MvWeb.Components.SortHeaderComponent} id={:sort_membership_fee_type} field={:membership_fee_type} label={gettext("Fee Type")} sort_field={@sort_field} sort_order={@sort_order} /> """ } > <.maybe_value value={member.membership_fee_type} empty_sr_text={gettext("Not specified")}> {member.membership_fee_type.name} <:col :let={member} :if={:membership_fee_status in @member_fields_visible} label={MvWeb.MemberLive.Index.payment_column_label(@payment_period)} > <% 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 :let={member} :if={:groups in @member_fields_visible} sort_field={:groups} label={ ~H""" <.live_component module={MvWeb.Components.SortHeaderComponent} id={:sort_groups} field={:groups} label={gettext("Groups")} sort_field={@sort_field} sort_order={@sort_order} /> """ } > <.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 %>
<:action :let={member}>
<.link navigate={~p"/members/#{member}"} data-testid="member-show-link"> {gettext("Show")}
<: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). --%> <%!-- 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. --%>
<.spinner size="sm" aria-hidden="true" /> {gettext("Loading more members …")}