{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")}>
<: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}" %>
0 && "SortTooltip"}
id={"payment-badge-#{member.id}"}
data-tooltip-id={count > 0 && tip_id}
phx-click={JS.navigate(~p"/members/#{member.id}?tab=membership_fees")}
aria-label={gettext("%{label} — open payment history", label: badge.label)}
data-testid="payment-badge"
>
<.icon name={badge.icon} class="size-4" />
{badge.label}
<%= if count > 0 do %>
<% cycles = PaymentAging.open_cycles(member, @payment_period) %>
<% {shown, overflow} = PaymentAging.tooltip_cycles(cycles) %>
<.icon name={PaymentAging.cycle_icon(cycle.status)} class="size-3.5" />
{PaymentAging.short_period(cycle)}
0} class="flex">
{gettext("+%{count} more", count: overflow)}
<% 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 …")}