feat(member): condense the overview into composite name and address cells

This commit is contained in:
Simon 2026-07-03 11:35:46 +02:00
parent b79d7ac9ea
commit af2cc2e0d4
17 changed files with 716 additions and 93 deletions

View file

@ -953,6 +953,11 @@ defmodule MvWeb.CoreComponents do
doc:
"when true, first header/body column gets sticky left positioning to keep selection controls visible"
attr :sticky_second_col, :boolean,
default: false,
doc:
"when true, the second column is also pinned left (e.g. an identifier column kept visible while scrolling horizontally)"
attr :viewport_bottom, :any,
default: nil,
doc:
@ -1018,7 +1023,9 @@ defmodule MvWeb.CoreComponents do
:for={{col, col_idx} <- Enum.with_index(@col)}
class={[
table_th_class(col, @sticky_header),
@sticky_first_col && col_idx == 0 && "sticky left-0 z-30 bg-base-100"
@sticky_first_col && col_idx == 0 &&
"sticky-first-col-th sticky left-0 z-30 bg-base-100",
@sticky_second_col && col_idx == 1 && "sticky left-12 z-30 bg-base-100"
]}
aria-sort={table_th_aria_sort(col, @sort_field, @sort_order)}
>
@ -1079,6 +1086,13 @@ defmodule MvWeb.CoreComponents do
classes
end
classes =
if @sticky_second_col && col_idx == 1 do
["sticky left-12 z-20 bg-base-100" | classes]
else
classes
end
classes =
if col_class == nil || (col_class && !String.contains?(col_class, "text-center")) do
["truncate" | classes]

View file

@ -49,6 +49,17 @@ defmodule MvWeb.Components.SearchBarComponent do
phx-target={@myself}
phx-debounce="300"
/>
<button
:if={@query not in [nil, ""]}
type="button"
phx-click="clear_search"
phx-target={@myself}
data-testid="search-clear"
aria-label={gettext("Clear search")}
class="opacity-50 hover:opacity-100 cursor-pointer"
>
<.icon name="hero-x-mark" class="h-[1em]" />
</button>
</label>
</form>
"""
@ -61,4 +72,10 @@ defmodule MvWeb.Components.SearchBarComponent do
send(self(), {:search_changed, q})
{:noreply, assign(socket, :query, q)}
end
# Clears the query and resets the result set to the unfiltered list (§1.3).
def handle_event("clear_search", _params, socket) do
send(self(), {:search_changed, ""})
{:noreply, assign(socket, :query, "")}
end
end

View file

@ -149,6 +149,48 @@
role="checkbox"
/>
</:col>
<:col
:let={member}
:if={:name in @member_fields_visible}
label={gettext("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.email not in [nil, ""]}
class="opacity-70 truncate"
title={member.email}
>
{member.email}
</div>
</div>
</:col>
<:col
:let={member}
:if={:address in @member_fields_visible}
label={gettext("Address")}
>
<% 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={:first_name in @member_fields_visible}
@ -280,7 +322,9 @@
"""
}
>
{member.city}
<.maybe_value value={member.city} empty_sr_text={gettext("Not specified")}>
{member.city}
</.maybe_value>
</:col>
<:col
:let={member}
@ -298,7 +342,9 @@
"""
}
>
{member.street}
<.maybe_value value={member.street} empty_sr_text={gettext("Not specified")}>
{member.street}
</.maybe_value>
</:col>
<:col
:let={member}
@ -316,7 +362,9 @@
"""
}
>
{member.house_number}
<.maybe_value value={member.house_number} empty_sr_text={gettext("Not specified")}>
{member.house_number}
</.maybe_value>
</:col>
<:col
:let={member}
@ -334,7 +382,9 @@
"""
}
>
{member.postal_code}
<.maybe_value value={member.postal_code} empty_sr_text={gettext("Not specified")}>
{member.postal_code}
</.maybe_value>
</:col>
<:col
:let={member}
@ -407,15 +457,17 @@
}
>
<.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}
</.badge>
<% end %>
<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}>

View file

@ -29,7 +29,28 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
# Single UI key for "Membership Fee Status"; only this appears in the dropdown.
# Groups and membership_fee_type are also pseudo fields (not in member_fields(), displayed in the table).
@pseudo_member_fields [:membership_fee_status, :membership_fee_type, :groups]
# :name and :address are composite display-only columns (Name = name + email,
# Address = street/house number over postal code/city).
@pseudo_member_fields [:membership_fee_status, :membership_fee_type, :groups, :name, :address]
# Curated default-visible column set (§1.2): the columns shown on first visit
# when there is no persisted selection and no global override. Everything else
# (the individual name/address sub-fields, notes, dates other than join date)
# defaults to hidden so the table fits common desktop widths.
@default_visible_fields MapSet.new([
:name,
:address,
:membership_fee_type,
:membership_fee_status,
:groups,
:join_date
])
@doc """
The curated default-visible columns (member-field atoms) for a first visit.
"""
@spec default_visible_fields() :: [atom()]
def default_visible_fields, do: MapSet.to_list(@default_visible_fields)
# Export/API may accept this as alias; must not appear in the UI options list.
@export_only_alias :payment_status
@ -276,13 +297,13 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
domain_map =
Enum.reduce(domain_fields, %{}, fn field, acc ->
field_string = Atom.to_string(field)
default_visibility = if field == :exit_date, do: false, else: true
default_visibility = MapSet.member?(@default_visible_fields, field)
show_in_overview = Map.get(visibility_config, field, default_visibility)
Map.put(acc, field_string, show_in_overview)
end)
Enum.reduce(@pseudo_member_fields, domain_map, fn field, acc ->
Map.put(acc, Atom.to_string(field), true)
Map.put(acc, Atom.to_string(field), MapSet.member?(@default_visible_fields, field))
end)
end

View file

@ -32,6 +32,8 @@ defmodule MvWeb.Translations.MemberFields do
def label(:membership_fee_status), do: gettext("Membership Fee Status")
def label(:membership_fee_type), do: gettext("Fee Type")
def label(:groups), do: gettext("Groups")
def label(:name), do: gettext("Name")
def label(:address), do: gettext("Address")
# Fallback for unknown fields
def label(field) do