566 lines
18 KiB
Text
566 lines
18 KiB
Text
<Layouts.app flash={@flash} current_user={@current_user}>
|
|
<.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")}
|
|
</.button>
|
|
<% end %>
|
|
</:actions>
|
|
</.header>
|
|
|
|
<div class="flex flex-wrap gap-4 items-center">
|
|
<.live_component
|
|
module={MvWeb.Components.SearchBarComponent}
|
|
id="search-bar"
|
|
query={@query}
|
|
placeholder={gettext("Search...")}
|
|
/>
|
|
<.live_component
|
|
module={MvWeb.Components.MemberFilterComponent}
|
|
id="member-filter"
|
|
cycle_status_filter={@cycle_status_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}
|
|
member_count={@total_count}
|
|
/>
|
|
<.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" />
|
|
<span class="hidden sm:inline">
|
|
{if(@show_current_cycle,
|
|
do: gettext("Current payment cycle"),
|
|
else: gettext("Last payment cycle")
|
|
)}
|
|
</span>
|
|
</.button>
|
|
</.tooltip>
|
|
<div class="ml-auto flex items-center gap-4">
|
|
<.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}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- 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). --%>
|
|
<div
|
|
id="members-result-count"
|
|
role="status"
|
|
aria-live="polite"
|
|
aria-atomic="true"
|
|
class="sr-only"
|
|
>
|
|
{ngettext("%{count} member", "%{count} members", @total_count, count: @total_count)}
|
|
</div>
|
|
|
|
<%!-- On desktop (lg:), only the table area scrolls; header and filters stay visible. On mobile, normal flow. --%>
|
|
<div
|
|
id="members-table-guard"
|
|
phx-hook="RowSelectionGuard"
|
|
class="lg:max-h-[calc(100vh-14rem)] lg:overflow-auto min-h-0"
|
|
data-testid="members-table-scroll"
|
|
role="region"
|
|
aria-label={gettext("Members table")}
|
|
aria-busy={to_string(@loading?)}
|
|
data-density={@density}
|
|
tabindex="0"
|
|
>
|
|
<.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}
|
|
infinite_scroll={@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}
|
|
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"""
|
|
<input
|
|
type="checkbox"
|
|
id="select-all-checkbox"
|
|
name="select_all"
|
|
class="checkbox checkbox-sm"
|
|
phx-click="select_all"
|
|
phx-hook="IndeterminateCheckbox"
|
|
data-indeterminate={to_string(@selected_count > 0 and @selected_count < @total_count)}
|
|
checked={@total_count > 0 and @selected_count == @total_count}
|
|
aria-label={gettext("Select all members")}
|
|
role="checkbox"
|
|
/>
|
|
"""
|
|
}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
name={member.id}
|
|
class="checkbox checkbox-sm"
|
|
checked={MapSet.member?(@selected_members, member.id)}
|
|
aria-label={gettext("Select member")}
|
|
role="checkbox"
|
|
/>
|
|
</:col>
|
|
<:col
|
|
:let={member}
|
|
:if={:name in @member_fields_visible and @compact_member}
|
|
sort_field={if @sort_field in [:first_name, :last_name], do: @sort_field}
|
|
label={
|
|
~H"""
|
|
<MvWeb.Components.MemberNameSortHeader.composite_name_sort_header
|
|
sort_field={@sort_field}
|
|
sort_order={@sort_order}
|
|
/>
|
|
"""
|
|
}
|
|
>
|
|
<% full_name =
|
|
[member.first_name, member.last_name]
|
|
|> Enum.reject(&(&1 in [nil, ""]))
|
|
|> Enum.join(" ") %>
|
|
<div data-testid="member-name">
|
|
<div class="font-medium truncate" title={full_name}>
|
|
{full_name}
|
|
</div>
|
|
<div
|
|
:if={@member_include_email and member.email not in [nil, ""]}
|
|
data-testid="member-name-email"
|
|
class="opacity-70 truncate"
|
|
title={member.email}
|
|
>
|
|
{member.email}
|
|
</div>
|
|
</div>
|
|
</:col>
|
|
<: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>
|
|
<: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>
|
|
<: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>
|
|
<: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")}>
|
|
<div data-testid="member-address">
|
|
<div class="truncate" title={line1}>{line1}</div>
|
|
<div class="opacity-70 truncate" title={line2}>{line2}</div>
|
|
</div>
|
|
</.maybe_value>
|
|
</:col>
|
|
<: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}
|
|
</.maybe_value>
|
|
</:col>
|
|
<: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}
|
|
</.maybe_value>
|
|
</:col>
|
|
<: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}
|
|
</.maybe_value>
|
|
</:col>
|
|
<: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}
|
|
</.maybe_value>
|
|
</:col>
|
|
<: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>
|
|
<: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>
|
|
<:col
|
|
:let={member}
|
|
:if={:notes in @member_fields_visible}
|
|
label={gettext("Notes")}
|
|
>
|
|
{member.notes}
|
|
</:col>
|
|
<: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>
|
|
<: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>
|
|
<: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}
|
|
</.maybe_value>
|
|
</:col>
|
|
<:col
|
|
:let={member}
|
|
:if={:membership_fee_status in @member_fields_visible}
|
|
label={gettext("Membership Fee Status")}
|
|
>
|
|
<%= 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}
|
|
</.badge>
|
|
<% else %>
|
|
<.empty_cell sr_text={gettext("No cycle")} />
|
|
<% end %>
|
|
</:col>
|
|
<: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")}>
|
|
<div class="flex flex-wrap gap-1 w-max">
|
|
<%= for group <- (member.groups || []) do %>
|
|
<.badge
|
|
variant="primary"
|
|
style="outline"
|
|
aria-label={gettext("Member of group %{name}", name: group.name)}
|
|
>
|
|
{group.name}
|
|
</.badge>
|
|
<% end %>
|
|
</div>
|
|
</.maybe_value>
|
|
</:col>
|
|
<:action :let={member}>
|
|
<div class="sr-only">
|
|
<.link navigate={~p"/members/#{member}"} data-testid="member-show-link">
|
|
{gettext("Show")}
|
|
</.link>
|
|
</div>
|
|
</:action>
|
|
<: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). --%>
|
|
<div
|
|
id="members-load-more-sentinel"
|
|
phx-hook="LoadMorePrefetch"
|
|
data-scroll-container="members-table-guard"
|
|
data-event="load_more"
|
|
data-margin="600px"
|
|
aria-hidden="true"
|
|
class="h-0"
|
|
>
|
|
</div>
|
|
<%!-- 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. --%>
|
|
<div
|
|
id="members-loading-bar"
|
|
phx-hook="StickyViewportWidth"
|
|
data-scroll-container="members-table-guard"
|
|
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 …")}
|
|
</div>
|
|
</:footer>
|
|
</.table>
|
|
</div>
|
|
</Layouts.app>
|