mitgliederverwaltung/lib/mv_web/live/member_live/index.html.heex

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>