614 lines
20 KiB
Text
614 lines
20 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>
|
|
|
|
<%!-- Two-row overview toolbar (Option C): row 1 is search + column/view
|
|
controls; row 2 is the dedicated filter row (add-filter + applied chips). --%>
|
|
<div class="flex flex-col gap-3">
|
|
<div class="flex flex-wrap gap-4 items-center">
|
|
<.live_component
|
|
module={MvWeb.Components.SearchBarComponent}
|
|
id="search-bar"
|
|
query={@query}
|
|
placeholder={gettext("Search...")}
|
|
/>
|
|
<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>
|
|
|
|
<.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}
|
|
suspended={@suspended}
|
|
as_of_date={@as_of_date}
|
|
/>
|
|
</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}
|
|
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"""
|
|
<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={: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(" ") %>
|
|
<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}
|
|
sort_field={:payment}
|
|
label={
|
|
~H"""
|
|
<div class="flex items-center gap-1.5">
|
|
<.live_component
|
|
module={MvWeb.Components.SortHeaderComponent}
|
|
id={:sort_payment}
|
|
field={:payment}
|
|
label={MvWeb.MemberLive.Index.payment_column_label(@payment_period)}
|
|
sort_hint={gettext("Click to sort by open payment cycles")}
|
|
sort_field={@sort_field}
|
|
sort_order={@sort_order}
|
|
/>
|
|
<span
|
|
:if={MvWeb.MemberLive.Index.payment_period_active?(@payment_period)}
|
|
class="badge badge-soft badge-primary badge-sm font-normal shrink-0"
|
|
title={PaymentAging.period_tooltip(@payment_period)}
|
|
aria-label={PaymentAging.period_tooltip(@payment_period)}
|
|
data-testid="payment-period-badge"
|
|
>
|
|
{PaymentAging.period_short_code(@payment_period) || gettext("filtered")}
|
|
</span>
|
|
</div>
|
|
"""
|
|
}
|
|
>
|
|
<% count = member.unpaid_cycle_count || 0 %>
|
|
<% badge = PaymentAging.badge(count) %>
|
|
<% tip_id = "payment-tip-#{member.id}" %>
|
|
<% anchor = "--pay-#{member.id}" %>
|
|
<%!-- The badge is a real navigation link (semantic, keyboard- and
|
|
right-click-friendly) into the member's payment history. Its hover/focus
|
|
tooltip is a native Popover-API element in the top layer, placed via CSS
|
|
anchor positioning (see the `payment-tip` class + the HoverPopover hook),
|
|
so it escapes the members table's `overflow` clipping without any custom
|
|
coordinate math. No tooltip renders when the member is fully paid. --%>
|
|
<.link
|
|
navigate={~p"/members/#{member.id}?tab=membership_fees"}
|
|
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}
|
|
aria-label={gettext("%{label} — open payment history", label: badge.label)}
|
|
data-testid="payment-badge"
|
|
>
|
|
<.icon name={badge.icon} class="size-4" />
|
|
{badge.label}
|
|
</.link>
|
|
<%= if count > 0 do %>
|
|
<% cycles = PaymentAging.open_cycles(member, @payment_period) %>
|
|
<% {shown, overflow} = PaymentAging.tooltip_cycles(cycles) %>
|
|
<div
|
|
id={tip_id}
|
|
popover="manual"
|
|
role="tooltip"
|
|
style={"position-anchor: #{anchor}"}
|
|
class="payment-tip m-0 w-fit rounded-box border border-base-300 bg-base-100 p-2 text-sm shadow-xl"
|
|
data-testid="payment-tooltip"
|
|
>
|
|
<ul class="m-0 flex list-none flex-col items-stretch gap-1 p-0">
|
|
<li :for={cycle <- shown} class="flex">
|
|
<span class={[
|
|
"badge badge-soft badge-sm w-full justify-start",
|
|
PaymentAging.cycle_color_class(cycle.status)
|
|
]}>
|
|
<.icon name={PaymentAging.cycle_icon(cycle.status)} class="size-3.5" />
|
|
{PaymentAging.short_period(cycle)}
|
|
</span>
|
|
</li>
|
|
<li :if={overflow > 0} class="flex">
|
|
<span class="badge badge-soft badge-sm badge-ghost w-full justify-start">
|
|
<.icon name="hero-plus-circle" class="size-3.5" />
|
|
{gettext("%{count} more", count: overflow)}
|
|
</span>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
<% 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>
|