feat: adds keyboard accessibility to tabs
This commit is contained in:
parent
615b4b866b
commit
2922a4d1ee
11 changed files with 680 additions and 613 deletions
|
|
@ -2775,6 +2775,10 @@ Building accessible applications ensures that all users, including those with di
|
|||
<div phx-click="action">Click me</div>
|
||||
```
|
||||
|
||||
**Tables (Core Component `<.table>` with `row_click`):**
|
||||
|
||||
- When `row_click` is set, the first column that does not use `col_click` gets `tabindex="0"` and `role="button"` so each row is reachable via Tab. The `TableRowKeydown` hook triggers the row action on Enter and Space (WCAG 2.1.1). Use `row_id` and `row_tooltip` for all clickable tables (e.g. Groups, Users, Roles, Members, Custom Fields, Member Fields) so the table is fully keyboard accessible.
|
||||
|
||||
**Tab Order:**
|
||||
|
||||
- Ensure logical tab order matches visual order
|
||||
|
|
|
|||
|
|
@ -73,6 +73,27 @@ Hooks.ComboBox = {
|
|||
}
|
||||
}
|
||||
|
||||
// TableRowKeydown hook: WCAG 2.1.1 — when a table row cell has data-row-clickable,
|
||||
// Enter and Space trigger a click so row_click tables are keyboard activatable
|
||||
Hooks.TableRowKeydown = {
|
||||
mounted() {
|
||||
this.handleKeydown = (e) => {
|
||||
if (
|
||||
e.target.getAttribute("data-row-clickable") === "true" &&
|
||||
(e.key === "Enter" || e.key === " ")
|
||||
) {
|
||||
e.preventDefault()
|
||||
e.target.click()
|
||||
}
|
||||
}
|
||||
this.el.addEventListener("keydown", this.handleKeydown)
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
this.el.removeEventListener("keydown", this.handleKeydown)
|
||||
}
|
||||
}
|
||||
|
||||
// SidebarState hook: Manages sidebar expanded/collapsed state
|
||||
Hooks.SidebarState = {
|
||||
mounted() {
|
||||
|
|
|
|||
|
|
@ -765,6 +765,8 @@ defmodule MvWeb.CoreComponents do
|
|||
|
||||
When `row_click` is set, clicking a row (or a data cell) triggers the handler.
|
||||
Rows with `row_click` get a subtle hover and focus-within outline (theme-friendly ring).
|
||||
For keyboard accessibility (WCAG 2.1.1), the first column without `col_click` gets
|
||||
`tabindex="0"` and `role="button"`; the TableRowKeydown hook triggers the row action on Enter/Space.
|
||||
When `selected_row_id` is set and matches a row's id (via `row_value_id` or `row_item.(row).id`),
|
||||
that row gets a stronger selected outline (ring-primary) for accessibility (not color-only).
|
||||
|
||||
|
|
@ -847,8 +849,22 @@ defmodule MvWeb.CoreComponents do
|
|||
|
||||
assigns = assign(assigns, :row_value_id_fn, row_value_id_fn)
|
||||
|
||||
# WCAG 2.1.1: when row_click is set, first column without col_click gets tabindex="0"
|
||||
# so rows are reachable via Tab; TableRowKeydown hook triggers click on Enter/Space
|
||||
first_row_click_col_idx =
|
||||
if assigns[:row_click] do
|
||||
Enum.find_index(assigns[:col] || [], fn c -> !c[:col_click] end)
|
||||
end
|
||||
|
||||
assigns =
|
||||
assign(assigns, :first_row_click_col_idx, first_row_click_col_idx)
|
||||
|
||||
~H"""
|
||||
<div class="overflow-auto">
|
||||
<div
|
||||
id={@row_click && "#{@id}-keyboard"}
|
||||
class="overflow-auto"
|
||||
phx-hook={@row_click && "TableRowKeydown"}
|
||||
>
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
|
|
@ -884,6 +900,11 @@ defmodule MvWeb.CoreComponents do
|
|||
>
|
||||
<td
|
||||
:for={{col, col_idx} <- Enum.with_index(@col)}
|
||||
tabindex={if @row_click && @first_row_click_col_idx == col_idx, do: 0, else: nil}
|
||||
role={if @row_click && @first_row_click_col_idx == col_idx, do: "button", else: nil}
|
||||
data-row-clickable={
|
||||
if @row_click && @first_row_click_col_idx == col_idx, do: "true", else: nil
|
||||
}
|
||||
phx-click={
|
||||
(col[:col_click] && col[:col_click].(@row_item.(row))) ||
|
||||
(@row_click && @row_click.(row))
|
||||
|
|
@ -907,6 +928,19 @@ defmodule MvWeb.CoreComponents do
|
|||
classes
|
||||
end
|
||||
|
||||
# WCAG: no focus ring on the cell itself; row shows focus via focus-within
|
||||
classes =
|
||||
if @row_click && @first_row_click_col_idx == col_idx do
|
||||
[
|
||||
"focus:outline-none",
|
||||
"focus-visible:outline-none",
|
||||
"focus:ring-0",
|
||||
"focus-visible:ring-0" | classes
|
||||
]
|
||||
else
|
||||
classes
|
||||
end
|
||||
|
||||
classes =
|
||||
if col_class do
|
||||
[col_class | classes]
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
{@member_count}
|
||||
</.badge>
|
||||
</.button>
|
||||
|
||||
|
||||
<!--
|
||||
NOTE: We use a div panel instead of ul.menu/li structure to avoid DaisyUI menu styles
|
||||
(padding, display, hover, font sizes) that would interfere with our form controls.
|
||||
|
|
@ -182,7 +182,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Groups: one row per group with All / Yes / No (like Custom Fields) -->
|
||||
<div :if={length(@groups) > 0} class="mb-4">
|
||||
<div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider">
|
||||
|
|
@ -249,7 +249,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Custom Fields Group -->
|
||||
<div :if={length(@boolean_custom_fields) > 0} class="mb-2">
|
||||
<div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider">
|
||||
|
|
@ -316,7 +316,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="mt-4 flex justify-between pt-3 border-t border-base-200">
|
||||
<.button
|
||||
|
|
|
|||
|
|
@ -130,149 +130,153 @@ defmodule MvWeb.GroupLive.Show do
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold mb-2">{gettext("Members")}</h2>
|
||||
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
||||
<p class="mb-4" data-testid="group-show-member-count">
|
||||
{ngettext(
|
||||
"Total: %{count} member",
|
||||
"Total: %{count} members",
|
||||
@group.member_count || 0,
|
||||
count: @group.member_count || 0
|
||||
)}
|
||||
</p>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold mb-2">{gettext("Members")}</h2>
|
||||
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
||||
<p class="mb-4" data-testid="group-show-member-count">
|
||||
{ngettext(
|
||||
"Total: %{count} member",
|
||||
"Total: %{count} members",
|
||||
@group.member_count || 0,
|
||||
count: @group.member_count || 0
|
||||
)}
|
||||
</p>
|
||||
|
||||
<%= if can?(@current_user, :update, @group) do %>
|
||||
<div class="mb-4">
|
||||
<%= if assigns[:show_add_member_input] do %>
|
||||
<div class="join w-full">
|
||||
<form phx-change="search_members" class="flex-1">
|
||||
<div class="relative">
|
||||
<div class="input input-bordered join-item w-full flex flex-wrap gap-1 items-center py-1 px-2">
|
||||
<%= if can?(@current_user, :update, @group) do %>
|
||||
<div class="mb-4">
|
||||
<%= if assigns[:show_add_member_input] do %>
|
||||
<div class="join w-full">
|
||||
<form phx-change="search_members" class="flex-1">
|
||||
<div class="relative">
|
||||
<div class="input input-bordered join-item w-full flex flex-wrap gap-1 items-center py-1 px-2">
|
||||
<%= for member <- @selected_members do %>
|
||||
<.badge variant="primary" style="outline" class="flex items-center gap-1">
|
||||
{MvWeb.Helpers.MemberHelpers.display_name(member)}
|
||||
<.tooltip content={gettext("Remove")} position="top">
|
||||
<.button
|
||||
type="button"
|
||||
variant="icon"
|
||||
size="sm"
|
||||
phx-click="remove_selected_member"
|
||||
phx-value-member_id={member.id}
|
||||
aria-label={
|
||||
gettext("Remove %{name}",
|
||||
name: MvWeb.Helpers.MemberHelpers.display_name(member)
|
||||
)
|
||||
}
|
||||
class="p-0 h-4 w-4 min-h-0"
|
||||
>
|
||||
<.icon name="hero-x-mark" class="size-3" />
|
||||
</.button>
|
||||
</.tooltip>
|
||||
</.badge>
|
||||
<% end %>
|
||||
<input
|
||||
type="text"
|
||||
id="member-search-input"
|
||||
data-testid="group-show-member-search-input"
|
||||
role="combobox"
|
||||
phx-hook="ComboBox"
|
||||
phx-focus="show_member_dropdown"
|
||||
phx-debounce="300"
|
||||
phx-keydown="member_dropdown_keydown"
|
||||
phx-mounted={JS.focus()}
|
||||
value={@member_search_query}
|
||||
placeholder={
|
||||
if Enum.empty?(@selected_members),
|
||||
do: gettext("Search for a member..."),
|
||||
else: ""
|
||||
}
|
||||
class="flex-1 min-w-[120px] border-0 focus:outline-none bg-transparent"
|
||||
name="member_search"
|
||||
aria-label={gettext("Search for a member")}
|
||||
aria-autocomplete="list"
|
||||
aria-controls="member-dropdown"
|
||||
aria-expanded={to_string(@show_member_dropdown)}
|
||||
aria-activedescendant={
|
||||
if @focused_member_index,
|
||||
do: "member-option-#{@focused_member_index}",
|
||||
else: nil
|
||||
}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<%= if length(@available_members) > 0 do %>
|
||||
<div
|
||||
id="member-dropdown"
|
||||
role="listbox"
|
||||
aria-label={gettext("Available members")}
|
||||
class={"absolute z-10 w-full mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg max-h-60 overflow-auto top-full #{if !@show_member_dropdown, do: "hidden"}"}
|
||||
phx-click-away="hide_member_dropdown"
|
||||
>
|
||||
<%= for {member, index} <- Enum.with_index(@available_members) do %>
|
||||
<div
|
||||
id={"member-option-#{index}"}
|
||||
role="option"
|
||||
tabindex="0"
|
||||
aria-selected={to_string(@focused_member_index == index)}
|
||||
phx-click="select_member"
|
||||
phx-value-id={member.id}
|
||||
data-member-id={member.id}
|
||||
class={[
|
||||
"px-4 py-3 cursor-pointer border-b border-base-300 last:border-b-0",
|
||||
if(@focused_member_index == index,
|
||||
do: "bg-base-300",
|
||||
else: "hover:bg-base-200"
|
||||
)
|
||||
]}
|
||||
<.badge
|
||||
variant="primary"
|
||||
style="outline"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<p class="font-medium">
|
||||
{MvWeb.Helpers.MemberHelpers.display_name(member)}
|
||||
</p>
|
||||
<p class="text-sm text-base-content/70">
|
||||
{member.email || gettext("No email")}
|
||||
</p>
|
||||
</div>
|
||||
{MvWeb.Helpers.MemberHelpers.display_name(member)}
|
||||
<.tooltip content={gettext("Remove")} position="top">
|
||||
<.button
|
||||
type="button"
|
||||
variant="icon"
|
||||
size="sm"
|
||||
phx-click="remove_selected_member"
|
||||
phx-value-member_id={member.id}
|
||||
aria-label={
|
||||
gettext("Remove %{name}",
|
||||
name: MvWeb.Helpers.MemberHelpers.display_name(member)
|
||||
)
|
||||
}
|
||||
class="p-0 h-4 w-4 min-h-0"
|
||||
>
|
||||
<.icon name="hero-x-mark" class="size-3" />
|
||||
</.button>
|
||||
</.tooltip>
|
||||
</.badge>
|
||||
<% end %>
|
||||
<input
|
||||
type="text"
|
||||
id="member-search-input"
|
||||
data-testid="group-show-member-search-input"
|
||||
role="combobox"
|
||||
phx-hook="ComboBox"
|
||||
phx-focus="show_member_dropdown"
|
||||
phx-debounce="300"
|
||||
phx-keydown="member_dropdown_keydown"
|
||||
phx-mounted={JS.focus()}
|
||||
value={@member_search_query}
|
||||
placeholder={
|
||||
if Enum.empty?(@selected_members),
|
||||
do: gettext("Search for a member..."),
|
||||
else: ""
|
||||
}
|
||||
class="flex-1 min-w-[120px] border-0 focus:outline-none bg-transparent"
|
||||
name="member_search"
|
||||
aria-label={gettext("Search for a member")}
|
||||
aria-autocomplete="list"
|
||||
aria-controls="member-dropdown"
|
||||
aria-expanded={to_string(@show_member_dropdown)}
|
||||
aria-activedescendant={
|
||||
if @focused_member_index,
|
||||
do: "member-option-#{@focused_member_index}",
|
||||
else: nil
|
||||
}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</form>
|
||||
<.button
|
||||
type="button"
|
||||
variant="primary"
|
||||
phx-click="add_selected_members"
|
||||
data-testid="group-show-add-selected-members-btn"
|
||||
disabled={Enum.empty?(@selected_member_ids)}
|
||||
aria-label={gettext("Add members")}
|
||||
class="join-item"
|
||||
>
|
||||
<.icon name="hero-plus" class="size-5" />
|
||||
</.button>
|
||||
<.button
|
||||
type="button"
|
||||
variant="neutral"
|
||||
phx-click="hide_add_member_input"
|
||||
aria-label={gettext("Cancel")}
|
||||
class="join-item"
|
||||
>
|
||||
{gettext("Cancel")}
|
||||
</.button>
|
||||
</div>
|
||||
<% else %>
|
||||
<.button
|
||||
variant="primary"
|
||||
phx-click="show_add_member_input"
|
||||
aria-label={gettext("Add Member")}
|
||||
>
|
||||
{gettext("Add Member")}
|
||||
</.button>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if Enum.empty?(@group.members || []) do %>
|
||||
<%= if length(@available_members) > 0 do %>
|
||||
<div
|
||||
id="member-dropdown"
|
||||
role="listbox"
|
||||
aria-label={gettext("Available members")}
|
||||
class={"absolute z-10 w-full mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg max-h-60 overflow-auto top-full #{if !@show_member_dropdown, do: "hidden"}"}
|
||||
phx-click-away="hide_member_dropdown"
|
||||
>
|
||||
<%= for {member, index} <- Enum.with_index(@available_members) do %>
|
||||
<div
|
||||
id={"member-option-#{index}"}
|
||||
role="option"
|
||||
tabindex="0"
|
||||
aria-selected={to_string(@focused_member_index == index)}
|
||||
phx-click="select_member"
|
||||
phx-value-id={member.id}
|
||||
data-member-id={member.id}
|
||||
class={[
|
||||
"px-4 py-3 cursor-pointer border-b border-base-300 last:border-b-0",
|
||||
if(@focused_member_index == index,
|
||||
do: "bg-base-300",
|
||||
else: "hover:bg-base-200"
|
||||
)
|
||||
]}
|
||||
>
|
||||
<p class="font-medium">
|
||||
{MvWeb.Helpers.MemberHelpers.display_name(member)}
|
||||
</p>
|
||||
<p class="text-sm text-base-content/70">
|
||||
{member.email || gettext("No email")}
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</form>
|
||||
<.button
|
||||
type="button"
|
||||
variant="primary"
|
||||
phx-click="add_selected_members"
|
||||
data-testid="group-show-add-selected-members-btn"
|
||||
disabled={Enum.empty?(@selected_member_ids)}
|
||||
aria-label={gettext("Add members")}
|
||||
class="join-item"
|
||||
>
|
||||
<.icon name="hero-plus" class="size-5" />
|
||||
</.button>
|
||||
<.button
|
||||
type="button"
|
||||
variant="neutral"
|
||||
phx-click="hide_add_member_input"
|
||||
aria-label={gettext("Cancel")}
|
||||
class="join-item"
|
||||
>
|
||||
{gettext("Cancel")}
|
||||
</.button>
|
||||
</div>
|
||||
<% else %>
|
||||
<.button
|
||||
variant="primary"
|
||||
phx-click="show_add_member_input"
|
||||
aria-label={gettext("Add Member")}
|
||||
>
|
||||
{gettext("Add Member")}
|
||||
</.button>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if Enum.empty?(@group.members || []) do %>
|
||||
<p class="text-base-content/50 italic" data-testid="group-show-no-members">
|
||||
{gettext("No members in this group")}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
|
|||
:if={!@show_form}
|
||||
id="member_fields"
|
||||
rows={@member_fields}
|
||||
row_id={fn {field_name, _field_data} -> "member_field-#{field_name}" end}
|
||||
row_click={
|
||||
fn {field_name, _field_data} ->
|
||||
JS.push("edit_member_field", value: %{"field" => field_name}, target: @myself)
|
||||
|
|
|
|||
|
|
@ -85,218 +85,219 @@ defmodule MvWeb.MemberLive.Form do
|
|||
>
|
||||
<%!-- Personal Data and Custom Fields Row --%>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<%!-- Personal Data Section --%>
|
||||
<div>
|
||||
<.form_section title={gettext("Personal Data")}>
|
||||
<div class="space-y-4">
|
||||
<%!-- Name Row --%>
|
||||
<div class="flex gap-4">
|
||||
<div class="w-48">
|
||||
<.input
|
||||
field={@form[:first_name]}
|
||||
label={gettext("First Name")}
|
||||
required={@member_field_required_map[:first_name]}
|
||||
/>
|
||||
<%!-- Personal Data Section --%>
|
||||
<div>
|
||||
<.form_section title={gettext("Personal Data")}>
|
||||
<div class="space-y-4">
|
||||
<%!-- Name Row --%>
|
||||
<div class="flex gap-4">
|
||||
<div class="w-48">
|
||||
<.input
|
||||
field={@form[:first_name]}
|
||||
label={gettext("First Name")}
|
||||
required={@member_field_required_map[:first_name]}
|
||||
/>
|
||||
</div>
|
||||
<div class="w-48">
|
||||
<.input
|
||||
field={@form[:last_name]}
|
||||
label={gettext("Last Name")}
|
||||
required={@member_field_required_map[:last_name]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-48">
|
||||
<.input
|
||||
field={@form[:last_name]}
|
||||
label={gettext("Last Name")}
|
||||
required={@member_field_required_map[:last_name]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Address: Country, Postal Code, City in one row --%>
|
||||
<div class="flex gap-4">
|
||||
<div class="w-48">
|
||||
<.input field={@form[:country]} label={gettext("Country")} />
|
||||
<%!-- Address: Country, Postal Code, City in one row --%>
|
||||
<div class="flex gap-4">
|
||||
<div class="w-48">
|
||||
<.input field={@form[:country]} label={gettext("Country")} />
|
||||
</div>
|
||||
<div class="w-24">
|
||||
<.input
|
||||
field={@form[:postal_code]}
|
||||
label={gettext("Postal Code")}
|
||||
required={@member_field_required_map[:postal_code]}
|
||||
/>
|
||||
</div>
|
||||
<div class="w-48">
|
||||
<.input field={@form[:city]} label={gettext("City")} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-24">
|
||||
<.input
|
||||
field={@form[:postal_code]}
|
||||
label={gettext("Postal Code")}
|
||||
required={@member_field_required_map[:postal_code]}
|
||||
/>
|
||||
</div>
|
||||
<div class="w-48">
|
||||
<.input field={@form[:city]} label={gettext("City")} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Street and Nr. below --%>
|
||||
<div class="flex gap-4">
|
||||
<%!-- Street and Nr. below --%>
|
||||
<div class="flex gap-4">
|
||||
<div class="w-64">
|
||||
<.input field={@form[:street]} label={gettext("Street")} />
|
||||
</div>
|
||||
<div class="w-24">
|
||||
<.input field={@form[:house_number]} label={gettext("Nr.")} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Email --%>
|
||||
<div class="w-64">
|
||||
<.input field={@form[:street]} label={gettext("Street")} />
|
||||
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
|
||||
</div>
|
||||
<div class="w-24">
|
||||
<.input field={@form[:house_number]} label={gettext("Nr.")} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Email --%>
|
||||
<div class="w-64">
|
||||
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
|
||||
</div>
|
||||
|
||||
<%!-- Membership Dates Row --%>
|
||||
<div class="flex gap-4">
|
||||
<div class="w-36">
|
||||
<.input
|
||||
field={@form[:join_date]}
|
||||
label={gettext("Join Date")}
|
||||
type="date"
|
||||
required={@member_field_required_map[:join_date]}
|
||||
/>
|
||||
<%!-- Membership Dates Row --%>
|
||||
<div class="flex gap-4">
|
||||
<div class="w-36">
|
||||
<.input
|
||||
field={@form[:join_date]}
|
||||
label={gettext("Join Date")}
|
||||
type="date"
|
||||
required={@member_field_required_map[:join_date]}
|
||||
/>
|
||||
</div>
|
||||
<div class="w-36">
|
||||
<.input
|
||||
field={@form[:exit_date]}
|
||||
label={gettext("Exit Date")}
|
||||
type="date"
|
||||
required={@member_field_required_map[:exit_date]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-36">
|
||||
|
||||
<%!-- Notes --%>
|
||||
<div>
|
||||
<.input
|
||||
field={@form[:exit_date]}
|
||||
label={gettext("Exit Date")}
|
||||
type="date"
|
||||
required={@member_field_required_map[:exit_date]}
|
||||
field={@form[:notes]}
|
||||
label={gettext("Notes")}
|
||||
type="textarea"
|
||||
required={@member_field_required_map[:notes]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</.form_section>
|
||||
</div>
|
||||
|
||||
<%!-- Notes --%>
|
||||
<%!-- Custom Fields Section --%>
|
||||
<%= if Enum.any?(@custom_fields) do %>
|
||||
<div>
|
||||
<.form_section title={gettext("Custom Fields")}>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<%!-- Render in sorted order by finding the form for each sorted custom field --%>
|
||||
<%= for cf <- @sorted_custom_fields do %>
|
||||
<.inputs_for :let={f_cfv} field={@form[:custom_field_values]}>
|
||||
<%= if f_cfv[:custom_field_id].value == cf.id do %>
|
||||
<div class={if cf.value_type == :boolean, do: "flex items-end", else: ""}>
|
||||
<.inputs_for :let={value_form} field={f_cfv[:value]}>
|
||||
<.input
|
||||
field={value_form[:value]}
|
||||
label={cf.name}
|
||||
type={custom_field_input_type(cf.value_type)}
|
||||
required={cf.required}
|
||||
/>
|
||||
</.inputs_for>
|
||||
<input
|
||||
type="hidden"
|
||||
name={f_cfv[:custom_field_id].name}
|
||||
value={f_cfv[:custom_field_id].value}
|
||||
/>
|
||||
</div>
|
||||
<% end %>
|
||||
</.inputs_for>
|
||||
<% end %>
|
||||
</div>
|
||||
</.form_section>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%!-- Membership Fee Section --%>
|
||||
<div class="max-w-xl">
|
||||
<.form_section title={gettext("Membership Fee")}>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<.input
|
||||
field={@form[:notes]}
|
||||
label={gettext("Notes")}
|
||||
type="textarea"
|
||||
required={@member_field_required_map[:notes]}
|
||||
/>
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">{gettext("Membership Fee Type")}</span>
|
||||
</label>
|
||||
<select
|
||||
class="select select-bordered w-full"
|
||||
name={@form[:membership_fee_type_id].name}
|
||||
phx-change="validate"
|
||||
value={@form[:membership_fee_type_id].value || ""}
|
||||
>
|
||||
<%!-- No "None" option: a membership fee type is required (validated in Member resource). --%>
|
||||
<option value="">{gettext("Select a membership fee type")}</option>
|
||||
<%= for fee_type <- @available_fee_types do %>
|
||||
<option
|
||||
value={fee_type.id}
|
||||
selected={fee_type.id == @form[:membership_fee_type_id].value}
|
||||
>
|
||||
{fee_type.name} ({MembershipFeeHelpers.format_currency(fee_type.amount)}, {MembershipFeeHelpers.format_interval(
|
||||
fee_type.interval
|
||||
)})
|
||||
</option>
|
||||
<% end %>
|
||||
</select>
|
||||
<%= for error <- List.wrap(@form.errors[:membership_fee_type_id] || []) do %>
|
||||
<% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %>
|
||||
<p class="text-error text-sm mt-1">{msg}</p>
|
||||
<% end %>
|
||||
<%= if @interval_warning do %>
|
||||
<div class="alert alert-warning mt-2">
|
||||
<.icon name="hero-exclamation-triangle" class="size-5" />
|
||||
<span>{@interval_warning}</span>
|
||||
</div>
|
||||
<% end %>
|
||||
<p class="text-sm text-base-content/60 mt-2">
|
||||
{gettext(
|
||||
"Select a membership fee type for this member. Members can only switch between types with the same interval."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</.form_section>
|
||||
</div>
|
||||
|
||||
<%!-- Custom Fields Section --%>
|
||||
<%= if Enum.any?(@custom_fields) do %>
|
||||
<div>
|
||||
<.form_section title={gettext("Custom Fields")}>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<%!-- Render in sorted order by finding the form for each sorted custom field --%>
|
||||
<%= for cf <- @sorted_custom_fields do %>
|
||||
<.inputs_for :let={f_cfv} field={@form[:custom_field_values]}>
|
||||
<%= if f_cfv[:custom_field_id].value == cf.id do %>
|
||||
<div class={if cf.value_type == :boolean, do: "flex items-end", else: ""}>
|
||||
<.inputs_for :let={value_form} field={f_cfv[:value]}>
|
||||
<.input
|
||||
field={value_form[:value]}
|
||||
label={cf.name}
|
||||
type={custom_field_input_type(cf.value_type)}
|
||||
required={cf.required}
|
||||
/>
|
||||
</.inputs_for>
|
||||
<input
|
||||
type="hidden"
|
||||
name={f_cfv[:custom_field_id].name}
|
||||
value={f_cfv[:custom_field_id].value}
|
||||
/>
|
||||
</div>
|
||||
<% end %>
|
||||
</.inputs_for>
|
||||
<% end %>
|
||||
</div>
|
||||
</.form_section>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<%!-- Bottom Action Buttons --%>
|
||||
<div class="flex justify-end gap-4 mt-6">
|
||||
<.button navigate={return_path(@return_to, @member)} variant="neutral" type="button">
|
||||
{gettext("Cancel")}
|
||||
</.button>
|
||||
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
|
||||
{gettext("Save Member")}
|
||||
</.button>
|
||||
</div>
|
||||
|
||||
<%!-- Membership Fee Section --%>
|
||||
<div class="max-w-xl">
|
||||
<.form_section title={gettext("Membership Fee")}>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">{gettext("Membership Fee Type")}</span>
|
||||
</label>
|
||||
<select
|
||||
class="select select-bordered w-full"
|
||||
name={@form[:membership_fee_type_id].name}
|
||||
phx-change="validate"
|
||||
value={@form[:membership_fee_type_id].value || ""}
|
||||
>
|
||||
<%!-- No "None" option: a membership fee type is required (validated in Member resource). --%>
|
||||
<option value="">{gettext("Select a membership fee type")}</option>
|
||||
<%= for fee_type <- @available_fee_types do %>
|
||||
<option
|
||||
value={fee_type.id}
|
||||
selected={fee_type.id == @form[:membership_fee_type_id].value}
|
||||
>
|
||||
{fee_type.name} ({MembershipFeeHelpers.format_currency(fee_type.amount)}, {MembershipFeeHelpers.format_interval(
|
||||
fee_type.interval
|
||||
)})
|
||||
</option>
|
||||
<% end %>
|
||||
</select>
|
||||
<%= for error <- List.wrap(@form.errors[:membership_fee_type_id] || []) do %>
|
||||
<% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %>
|
||||
<p class="text-error text-sm mt-1">{msg}</p>
|
||||
<% end %>
|
||||
<%= if @interval_warning do %>
|
||||
<div class="alert alert-warning mt-2">
|
||||
<.icon name="hero-exclamation-triangle" class="size-5" />
|
||||
<span>{@interval_warning}</span>
|
||||
</div>
|
||||
<% end %>
|
||||
<p class="text-sm text-base-content/60 mt-2">
|
||||
<%!-- Danger zone: same section pattern as MemberLive.Show (canonical) --%>
|
||||
<%= if @member && can?(@current_user, :destroy, @member) do %>
|
||||
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
|
||||
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
|
||||
{gettext("Danger zone")}
|
||||
</h2>
|
||||
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
||||
<p class="text-base-content/70 mb-4">
|
||||
{gettext(
|
||||
"Select a membership fee type for this member. Members can only switch between types with the same interval."
|
||||
"Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed."
|
||||
)}
|
||||
</p>
|
||||
<.button
|
||||
variant="danger"
|
||||
type="button"
|
||||
phx-click="delete"
|
||||
phx-value-id={@member.id}
|
||||
data-confirm={
|
||||
gettext(
|
||||
"Are you sure you want to delete %{name}? This action cannot be undone.",
|
||||
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
|
||||
)
|
||||
}
|
||||
data-testid="member-delete"
|
||||
aria-label={
|
||||
gettext("Delete member %{name}",
|
||||
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
|
||||
)
|
||||
}
|
||||
>
|
||||
<.icon name="hero-trash" class="size-4" />
|
||||
{gettext("Delete member")}
|
||||
</.button>
|
||||
</div>
|
||||
</div>
|
||||
</.form_section>
|
||||
</div>
|
||||
|
||||
<%!-- Bottom Action Buttons --%>
|
||||
<div class="flex justify-end gap-4 mt-6">
|
||||
<.button navigate={return_path(@return_to, @member)} variant="neutral" type="button">
|
||||
{gettext("Cancel")}
|
||||
</.button>
|
||||
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
|
||||
{gettext("Save Member")}
|
||||
</.button>
|
||||
</div>
|
||||
|
||||
<%!-- Danger zone: same section pattern as MemberLive.Show (canonical) --%>
|
||||
<%= if @member && can?(@current_user, :destroy, @member) do %>
|
||||
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
|
||||
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
|
||||
{gettext("Danger zone")}
|
||||
</h2>
|
||||
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
||||
<p class="text-base-content/70 mb-4">
|
||||
{gettext(
|
||||
"Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed."
|
||||
)}
|
||||
</p>
|
||||
<.button
|
||||
variant="danger"
|
||||
type="button"
|
||||
phx-click="delete"
|
||||
phx-value-id={@member.id}
|
||||
data-confirm={
|
||||
gettext("Are you sure you want to delete %{name}? This action cannot be undone.",
|
||||
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
|
||||
)
|
||||
}
|
||||
data-testid="member-delete"
|
||||
aria-label={
|
||||
gettext("Delete member %{name}",
|
||||
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
|
||||
)
|
||||
}
|
||||
>
|
||||
<.icon name="hero-trash" class="size-4" />
|
||||
{gettext("Delete member")}
|
||||
</.button>
|
||||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
</section>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</.form>
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@
|
|||
sort_field={@sort_field}
|
||||
sort_order={@sort_order}
|
||||
>
|
||||
|
||||
|
||||
<!-- <:col :let={member} label="Id">{member.id}</:col> -->
|
||||
<:col
|
||||
:let={member}
|
||||
|
|
@ -134,286 +134,286 @@
|
|||
aria-label={gettext("Select member")}
|
||||
role="checkbox"
|
||||
/>
|
||||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:first_name in @member_fields_visible}
|
||||
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}
|
||||
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}
|
||||
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={:join_date in @member_fields_visible}
|
||||
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}
|
||||
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}
|
||||
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={:city in @member_fields_visible}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
module={MvWeb.Components.SortHeaderComponent}
|
||||
id={:sort_city}
|
||||
field={:city}
|
||||
label={gettext("City")}
|
||||
sort_field={@sort_field}
|
||||
sort_order={@sort_order}
|
||||
/>
|
||||
"""
|
||||
}
|
||||
>
|
||||
{member.city}
|
||||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:street in @member_fields_visible}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
module={MvWeb.Components.SortHeaderComponent}
|
||||
id={:sort_street}
|
||||
field={:street}
|
||||
label={gettext("Street")}
|
||||
sort_field={@sort_field}
|
||||
sort_order={@sort_order}
|
||||
/>
|
||||
"""
|
||||
}
|
||||
>
|
||||
{member.street}
|
||||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:house_number in @member_fields_visible}
|
||||
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}
|
||||
/>
|
||||
"""
|
||||
}
|
||||
>
|
||||
{member.house_number}
|
||||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:postal_code in @member_fields_visible}
|
||||
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}
|
||||
/>
|
||||
"""
|
||||
}
|
||||
>
|
||||
{member.postal_code}
|
||||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:membership_fee_start_date in @member_fields_visible}
|
||||
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}
|
||||
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}
|
||||
/>
|
||||
"""
|
||||
}
|
||||
>
|
||||
<%= if member.membership_fee_type do %>
|
||||
{member.membership_fee_type.name}
|
||||
<% else %>
|
||||
<span class="text-base-content/50">—</span>
|
||||
<% end %>
|
||||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:membership_fee_status in @member_fields_visible}
|
||||
label={gettext("Membership Fee Status")}
|
||||
>
|
||||
<%= if badge = MembershipFeeStatus.format_cycle_status_badge(
|
||||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:first_name in @member_fields_visible}
|
||||
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}
|
||||
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}
|
||||
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={:join_date in @member_fields_visible}
|
||||
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}
|
||||
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}
|
||||
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={:city in @member_fields_visible}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
module={MvWeb.Components.SortHeaderComponent}
|
||||
id={:sort_city}
|
||||
field={:city}
|
||||
label={gettext("City")}
|
||||
sort_field={@sort_field}
|
||||
sort_order={@sort_order}
|
||||
/>
|
||||
"""
|
||||
}
|
||||
>
|
||||
{member.city}
|
||||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:street in @member_fields_visible}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
module={MvWeb.Components.SortHeaderComponent}
|
||||
id={:sort_street}
|
||||
field={:street}
|
||||
label={gettext("Street")}
|
||||
sort_field={@sort_field}
|
||||
sort_order={@sort_order}
|
||||
/>
|
||||
"""
|
||||
}
|
||||
>
|
||||
{member.street}
|
||||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:house_number in @member_fields_visible}
|
||||
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}
|
||||
/>
|
||||
"""
|
||||
}
|
||||
>
|
||||
{member.house_number}
|
||||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:postal_code in @member_fields_visible}
|
||||
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}
|
||||
/>
|
||||
"""
|
||||
}
|
||||
>
|
||||
{member.postal_code}
|
||||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:membership_fee_start_date in @member_fields_visible}
|
||||
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}
|
||||
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}
|
||||
/>
|
||||
"""
|
||||
}
|
||||
>
|
||||
<%= if member.membership_fee_type do %>
|
||||
{member.membership_fee_type.name}
|
||||
<% else %>
|
||||
<span class="text-base-content/50">—</span>
|
||||
<% end %>
|
||||
</: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 %>
|
||||
<.badge variant="neutral">{gettext("No cycle")}</.badge>
|
||||
<% end %>
|
||||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:groups in @member_fields_visible}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
module={MvWeb.Components.SortHeaderComponent}
|
||||
id={:sort_groups}
|
||||
field={:groups}
|
||||
label={gettext("Groups")}
|
||||
sort_field={@sort_field}
|
||||
sort_order={@sort_order}
|
||||
/>
|
||||
"""
|
||||
}
|
||||
>
|
||||
<%= for group <- (member.groups || []) do %>
|
||||
<.badge
|
||||
variant="primary"
|
||||
style="outline"
|
||||
aria-label={gettext("Member of group %{name}", name: group.name)}
|
||||
>
|
||||
{group.name}
|
||||
</.badge>
|
||||
<% end %>
|
||||
<%= if (member.groups || []) == [] do %>
|
||||
<span class="text-base-content/50">—</span>
|
||||
<% end %>
|
||||
</:col>
|
||||
<:action :let={member}>
|
||||
<div class="sr-only">
|
||||
<.link navigate={~p"/members/#{member}"} data-testid="member-show-link">
|
||||
{gettext("Show")}
|
||||
</.link>
|
||||
</div>
|
||||
</:action>
|
||||
</.table>
|
||||
<.badge variant={badge.variant}>
|
||||
<.icon name={badge.icon} class="size-4" />
|
||||
{badge.label}
|
||||
</.badge>
|
||||
<% else %>
|
||||
<.badge variant="neutral">{gettext("No cycle")}</.badge>
|
||||
<% end %>
|
||||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:groups in @member_fields_visible}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
module={MvWeb.Components.SortHeaderComponent}
|
||||
id={:sort_groups}
|
||||
field={:groups}
|
||||
label={gettext("Groups")}
|
||||
sort_field={@sort_field}
|
||||
sort_order={@sort_order}
|
||||
/>
|
||||
"""
|
||||
}
|
||||
>
|
||||
<%= for group <- (member.groups || []) do %>
|
||||
<.badge
|
||||
variant="primary"
|
||||
style="outline"
|
||||
aria-label={gettext("Member of group %{name}", name: group.name)}
|
||||
>
|
||||
{group.name}
|
||||
</.badge>
|
||||
<% end %>
|
||||
<%= if (member.groups || []) == [] do %>
|
||||
<span class="text-base-content/50">—</span>
|
||||
<% end %>
|
||||
</:col>
|
||||
<:action :let={member}>
|
||||
<div class="sr-only">
|
||||
<.link navigate={~p"/members/#{member}"} data-testid="member-show-link">
|
||||
{gettext("Show")}
|
||||
</.link>
|
||||
</div>
|
||||
</:action>
|
||||
</.table>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
|
|
|
|||
|
|
@ -254,7 +254,9 @@ defmodule MvWeb.MemberLive.Show do
|
|||
/>
|
||||
<.data_field label={gettext("Last Cycle")} class="min-w-32">
|
||||
<%= if @member.last_cycle_status do %>
|
||||
<.badge variant={MembershipFeeHelpers.status_variant(@member.last_cycle_status)}>
|
||||
<.badge variant={
|
||||
MembershipFeeHelpers.status_variant(@member.last_cycle_status)
|
||||
}>
|
||||
{format_status_label(@member.last_cycle_status)}
|
||||
</.badge>
|
||||
<% else %>
|
||||
|
|
|
|||
|
|
@ -18,8 +18,7 @@ defmodule MvWeb.RoleLive.Index do
|
|||
|
||||
require Ash.Query
|
||||
|
||||
import MvWeb.RoleLive.Helpers,
|
||||
only: [format_error: 1, permission_set_badge_variant: 1, opts_with_actor: 3]
|
||||
import MvWeb.RoleLive.Helpers, only: [permission_set_badge_variant: 1]
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
<.table
|
||||
id="roles"
|
||||
rows={@roles}
|
||||
row_id={fn role -> "role-#{role.id}" end}
|
||||
row_click={fn role -> JS.navigate(~p"/admin/roles/#{role}") end}
|
||||
row_tooltip={gettext("Click for role details")}
|
||||
>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue