refactor: use core components

This commit is contained in:
carla 2026-02-25 09:12:33 +01:00
parent f0be98316c
commit b7c93f19cb
26 changed files with 1080 additions and 954 deletions

View file

@ -60,15 +60,15 @@ defmodule MvWeb.CoreComponents do
id={@id} id={@id}
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")} phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
role="alert" role="alert"
class="z-50 toast toast-top toast-end" class="z-50 toast toast-bottom toast-end"
{@rest} {@rest}
> >
<div class={[ <div class={[
"alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap", "alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap",
@kind == :info && "alert-info", @kind == :info && "alert-info",
@kind == :error && "alert-error", @kind == :error && "alert-error",
@kind == :success && "bg-green-500 text-white", @kind == :success && "alert-success",
@kind == :warning && "bg-blue-100 text-blue-800 border border-blue-300" @kind == :warning && "alert-warning"
]}> ]}>
<.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" /> <.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" />
<.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" /> <.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
@ -90,33 +90,71 @@ defmodule MvWeb.CoreComponents do
@doc """ @doc """
Renders a button with navigation support. Renders a button with navigation support.
## Variants (Design Guidelines §5.2)
- primary (main CTA)
- secondary (supporting)
- neutral (cancel/back)
- ghost (low emphasis; table/toolbars)
- outline (alternative CTA)
- danger (destructive)
- link (inline; rare)
- icon (icon-only)
## Sizes
- sm, md (default), lg
## Examples ## Examples
<.button>Send!</.button> <.button>Send!</.button>
<.button phx-click="go" variant="primary">Send!</.button> <.button phx-click="go" variant="primary">Send!</.button>
<.button navigate={~p"/"}>Home</.button> <.button navigate={~p"/"} variant="secondary">Home</.button>
<.button variant="ghost" size="sm">Edit</.button>
<.button disabled={true}>Disabled</.button> <.button disabled={true}>Disabled</.button>
""" """
attr :rest, :global, include: ~w(href navigate patch method data-testid) attr :rest, :global, include: ~w(href navigate patch method data-testid)
attr :variant, :string, values: ~w(primary)
attr :variant, :string,
values: ~w(primary secondary neutral ghost outline danger link icon),
default: "primary"
attr :size, :string, values: ~w(sm md lg), default: "md"
attr :disabled, :boolean, default: false, doc: "Whether the button is disabled" attr :disabled, :boolean, default: false, doc: "Whether the button is disabled"
slot :inner_block, required: true slot :inner_block, required: true
def button(assigns) do def button(assigns) do
rest = assigns.rest rest = assigns.rest
variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"} variant = assigns[:variant] || "primary"
assigns = assign(assigns, :class, Map.fetch!(variants, assigns[:variant])) size = assigns[:size] || "md"
variant_classes = %{
"primary" => "btn-primary",
"secondary" => "btn-secondary",
"neutral" => "btn-neutral",
"ghost" => "btn-ghost",
"outline" => "btn-outline",
"danger" => "btn-error",
"link" => "btn-link",
"icon" => "btn-ghost btn-square"
}
size_classes = %{
"sm" => "btn-sm",
"md" => "",
"lg" => "btn-lg"
}
base_class = Map.fetch!(variant_classes, variant)
size_class = size_classes[size]
btn_class = [base_class, size_class] |> Enum.reject(&(&1 == "")) |> Enum.join(" ")
assigns = assign(assigns, :btn_class, btn_class)
if rest[:href] || rest[:navigate] || rest[:patch] do if rest[:href] || rest[:navigate] || rest[:patch] do
# For links, we can't use disabled attribute, so we use btn-disabled class
# DaisyUI's btn-disabled provides the same styling as :disabled on buttons
link_class = link_class =
if assigns[:disabled], if assigns[:disabled],
do: ["btn", assigns.class, "btn-disabled"], do: ["btn", btn_class, "btn-disabled"],
else: ["btn", assigns.class] else: ["btn", btn_class]
# Prevent interaction when disabled
# Remove navigation attributes to prevent "Open in new tab", "Copy link" etc.
link_attrs = link_attrs =
if assigns[:disabled] do if assigns[:disabled] do
rest rest
@ -138,13 +176,49 @@ defmodule MvWeb.CoreComponents do
""" """
else else
~H""" ~H"""
<button class={["btn", @class]} disabled={@disabled} {@rest}> <button class={["btn", @btn_class]} disabled={@disabled} {@rest}>
{render_slot(@inner_block)} {render_slot(@inner_block)}
</button> </button>
""" """
end end
end end
@doc """
Wraps content with a DaisyUI tooltip. Use for icon-only actions, truncated content,
or status badges that need explanation (Design Guidelines §8.2).
## Examples
<.tooltip content={gettext("Edit")}>
<.button variant="icon" size="sm"><.icon name="hero-pencil" /></.button>
</.tooltip>
<.tooltip content={@full_name} position="top">
<span class="truncate max-w-32">{@full_name}</span>
</.tooltip>
"""
attr :content, :string, required: true, doc: "Tooltip text (data-tip)"
attr :position, :string,
values: ~w(top bottom left right),
default: "bottom"
attr :wrap_class, :string, default: nil, doc: "Additional classes for the wrapper"
slot :inner_block, required: true
def tooltip(assigns) do
position_class = "tooltip tooltip-#{assigns.position}"
wrap_class = [position_class, assigns.wrap_class] |> Enum.reject(&is_nil/1) |> Enum.join(" ")
assigns = assign(assigns, :wrap_class, wrap_class)
~H"""
<div class={@wrap_class} data-tip={@content}>
{render_slot(@inner_block)}
</div>
"""
end
@doc """ @doc """
Renders a dropdown menu. Renders a dropdown menu.
@ -437,7 +511,7 @@ defmodule MvWeb.CoreComponents do
{@rest} {@rest}
/>{@label}<span />{@label}<span
:if={@is_required} :if={@is_required}
class="text-red-700 tooltip tooltip-right" class="text-error tooltip tooltip-right"
data-tip={gettext("This field is required")} data-tip={gettext("This field is required")}
>*</span> >*</span>
</span> </span>
@ -456,7 +530,7 @@ defmodule MvWeb.CoreComponents do
<span :if={@label} class="mb-1 label"> <span :if={@label} class="mb-1 label">
{@label}<span {@label}<span
:if={@rest[:required]} :if={@rest[:required]}
class="text-red-700 tooltip tooltip-right" class="text-error tooltip tooltip-right"
data-tip={gettext("This field cannot be empty")} data-tip={gettext("This field cannot be empty")}
>*</span> >*</span>
</span> </span>
@ -485,7 +559,7 @@ defmodule MvWeb.CoreComponents do
<span :if={@label} class="mb-1 label"> <span :if={@label} class="mb-1 label">
{@label}<span {@label}<span
:if={@rest[:required]} :if={@rest[:required]}
class="text-red-700 tooltip tooltip-right" class="text-error tooltip tooltip-right"
data-tip={gettext("This field cannot be empty")} data-tip={gettext("This field cannot be empty")}
>*</span> >*</span>
</span> </span>
@ -514,7 +588,7 @@ defmodule MvWeb.CoreComponents do
<span :if={@label} class="mb-1 label"> <span :if={@label} class="mb-1 label">
{@label}<span {@label}<span
:if={@rest[:required]} :if={@rest[:required]}
class="text-red-700 tooltip tooltip-right" class="text-error tooltip tooltip-right"
data-tip={gettext("This field cannot be empty")} data-tip={gettext("This field cannot be empty")}
>*</span> >*</span>
</span> </span>
@ -585,6 +659,11 @@ defmodule MvWeb.CoreComponents do
@doc ~S""" @doc ~S"""
Renders a table with generic styling. Renders a table with generic styling.
When `row_click` is set, clicking a row (or a data cell) triggers the handler.
The action column has no phx-click on its `<td>`, so action buttons do not trigger row navigation.
For interactive elements inside other columns (e.g. checkboxes, buttons), use
`Phoenix.LiveView.JS.stop_propagation()` in the element's phx-click so the row click is not fired.
## Examples ## Examples
<.table id="users" rows={@users}> <.table id="users" rows={@users}>

View file

@ -115,7 +115,7 @@ defmodule MvWeb.Layouts do
def flash_group(assigns) do def flash_group(assigns) do
~H""" ~H"""
<div id={@id} aria-live="polite" class="z-50 flex flex-col gap-2 toast toast-top toast-end"> <div id={@id} aria-live="polite" class="z-50 flex flex-col gap-2 toast toast-bottom toast-end">
<.flash kind={:success} flash={@flash} /> <.flash kind={:success} flash={@flash} />
<.flash kind={:warning} flash={@flash} /> <.flash kind={:warning} flash={@flash} />
<.flash kind={:info} flash={@flash} /> <.flash kind={:info} flash={@flash} />

View file

@ -72,7 +72,7 @@
<div <div
id="flash-group-root" id="flash-group-root"
aria-live="polite" aria-live="polite"
class="z-50 flex flex-col gap-2 toast toast-top toast-end" class="z-50 flex flex-col gap-2 toast toast-bottom toast-end"
> >
<.flash id="flash-success-root" kind={:success} flash={@flash} /> <.flash id="flash-success-root" kind={:success} flash={@flash} />
<.flash id="flash-warning-root" kind={:warning} flash={@flash} /> <.flash id="flash-warning-root" kind={:warning} flash={@flash} />

View file

@ -64,11 +64,11 @@ defmodule MvWeb.Components.MemberFilterComponent do
phx-key="Escape" phx-key="Escape"
phx-target={@myself} phx-target={@myself}
> >
<button <.button
type="button" type="button"
tabindex="0" variant="secondary"
class={[ class={[
"btn gap-2", "gap-2",
(@cycle_status_filter || map_size(@group_filters) > 0 || (@cycle_status_filter || map_size(@group_filters) > 0 ||
active_boolean_filters_count(@boolean_filters) > 0) && active_boolean_filters_count(@boolean_filters) > 0) &&
"btn-active" "btn-active"
@ -104,7 +104,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
> >
{@member_count} {@member_count}
</span> </span>
</button> </.button>
<!-- <!--
NOTE: We use a div panel instead of ul.menu/li structure to avoid DaisyUI menu styles NOTE: We use a div panel instead of ul.menu/li structure to avoid DaisyUI menu styles
@ -318,22 +318,24 @@ defmodule MvWeb.Components.MemberFilterComponent do
<!-- Footer --> <!-- Footer -->
<div class="mt-4 flex justify-between pt-3 border-t border-base-200"> <div class="mt-4 flex justify-between pt-3 border-t border-base-200">
<button <.button
type="button" type="button"
variant="neutral"
size="sm"
phx-click="reset_filters" phx-click="reset_filters"
phx-target={@myself} phx-target={@myself}
class="btn btn-sm"
> >
{gettext("Reset")} {gettext("Clear filters")}
</button> </.button>
<button <.button
type="button" type="button"
variant="primary"
size="sm"
phx-click="close_dropdown" phx-click="close_dropdown"
phx-target={@myself} phx-target={@myself}
class="btn btn-primary btn-sm"
> >
{gettext("Close")} {gettext("Close")}
</button> </.button>
</div> </div>
</form> </form>
</div> </div>

View file

@ -19,25 +19,28 @@ defmodule MvWeb.Components.SortHeaderComponent do
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div class="tooltip tooltip-bottom" data-tip={aria_sort(@field, @sort_field, @sort_order)}> <div>
<button <.tooltip content={aria_sort(@field, @sort_field, @sort_order)} position="bottom">
type="button" <.button
aria-label={aria_sort(@field, @sort_field, @sort_order)} type="button"
class="btn btn-ghost select-none" variant="ghost"
phx-click="sort" aria-label={aria_sort(@field, @sort_field, @sort_order)}
phx-value-field={@field} class="select-none"
data-testid={@field} phx-click="sort"
> phx-value-field={@field}
{@label} data-testid={@field}
<%= if @sort_field == @field do %> >
<.icon name={if @sort_order == :asc, do: "hero-chevron-up", else: "hero-chevron-down"} /> {@label}
<% else %> <%= if @sort_field == @field do %>
<.icon <.icon name={if @sort_order == :asc, do: "hero-chevron-up", else: "hero-chevron-down"} />
name="hero-chevron-up-down" <% else %>
class="opacity-40" <.icon
/> name="hero-chevron-up-down"
<% end %> class="opacity-40"
</button> />
<% end %>
</.button>
</.tooltip>
</div> </div>
""" """
end end

View file

@ -24,11 +24,13 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
<div class="flex items-center gap-4 mb-4"> <div class="flex items-center gap-4 mb-4">
<.button <.button
type="button" type="button"
variant="neutral"
phx-click="cancel" phx-click="cancel"
phx-target={@myself} phx-target={@myself}
aria-label={gettext("Back to settings")} aria-label={gettext("Back to settings")}
> >
<.icon name="hero-arrow-left" class="w-4 h-4" /> <.icon name="hero-arrow-left" class="size-4" />
{gettext("Back")}
</.button> </.button>
<h3 class="card-title"> <h3 class="card-title">
{if @custom_field, do: gettext("Edit Data Field"), else: gettext("New Data Field")} {if @custom_field, do: gettext("Edit Data Field"), else: gettext("New Data Field")}
@ -97,7 +99,7 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
/> />
<div class="justify-end mt-4 card-actions"> <div class="justify-end mt-4 card-actions">
<.button type="button" phx-click="cancel" phx-target={@myself}> <.button type="button" variant="neutral" phx-click="cancel" phx-target={@myself}>
{gettext("Cancel")} {gettext("Cancel")}
</.button> </.button>
<.button phx-disable-with={gettext("Saving...")} variant="primary"> <.button phx-disable-with={gettext("Saving...")} variant="primary">

View file

@ -100,7 +100,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
<.link phx-click={ <.link phx-click={
JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself) JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself)
}> }>
{gettext("Edit")} {gettext("Edit datafield")}
</.link> </.link>
</:action> </:action>
@ -164,17 +164,17 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
</div> </div>
<div class="modal-action"> <div class="modal-action">
<button phx-click="cancel_delete" phx-target={@myself} class="btn"> <.button variant="neutral" phx-click="cancel_delete" phx-target={@myself}>
{gettext("Cancel")} {gettext("Cancel")}
</button> </.button>
<button <.button
variant="danger"
phx-click="confirm_delete" phx-click="confirm_delete"
phx-target={@myself} phx-target={@myself}
class="btn btn-error"
disabled={@slug_confirmation != @custom_field_to_delete.slug} disabled={@slug_confirmation != @custom_field_to_delete.slug}
> >
{gettext("Delete Custom Field and All Values")} {gettext("Delete Custom Field and All Values")}
</button> </.button>
</div> </div>
</div> </div>
</dialog> </dialog>

View file

@ -181,18 +181,18 @@ defmodule MvWeb.GlobalSettingsLive do
<.button <.button
:if={Mv.Config.vereinfacht_configured?()} :if={Mv.Config.vereinfacht_configured?()}
type="button" type="button"
variant="outline"
phx-click="test_vereinfacht_connection" phx-click="test_vereinfacht_connection"
phx-disable-with={gettext("Testing...")} phx-disable-with={gettext("Testing...")}
class="btn-outline"
> >
{gettext("Test Integration")} {gettext("Test Integration")}
</.button> </.button>
<.button <.button
:if={Mv.Config.vereinfacht_configured?()} :if={Mv.Config.vereinfacht_configured?()}
type="button" type="button"
variant="outline"
phx-click="sync_vereinfacht_contacts" phx-click="sync_vereinfacht_contacts"
phx-disable-with={gettext("Syncing...")} phx-disable-with={gettext("Syncing...")}
class="btn-outline"
> >
{gettext("Sync all members without Vereinfacht contact")} {gettext("Sync all members without Vereinfacht contact")}
</.button> </.button>

View file

@ -78,30 +78,29 @@ defmodule MvWeb.GroupLive.Form do
~H""" ~H"""
<Layouts.app flash={@flash} current_user={@current_user}> <Layouts.app flash={@flash} current_user={@current_user}>
<.form for={@form} id="group-form" phx-change="validate" phx-submit="save"> <.form for={@form} id="group-form" phx-change="validate" phx-submit="save">
<%!-- Header with Back button, Title, and Save button --%> <.header>
<div class="flex items-center justify-between gap-4 pb-4"> {@page_title}
<.button navigate={return_path(@return_to, @group)} type="button"> <:actions>
<.icon name="hero-arrow-left" class="size-4" /> <.button navigate={return_path(@return_to, @group)} variant="neutral">
{gettext("Back")} <.icon name="hero-arrow-left" class="size-4" />
</.button> {gettext("Back")}
</.button>
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save")}
</.button>
</:actions>
</.header>
<h1 class="text-2xl font-bold text-center flex-1"> <div class="mt-6 space-y-6">
{@page_title} <div class="max-w-2xl space-y-4">
</h1> <.input field={@form[:name]} label={gettext("Name")} required />
<.input
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit"> field={@form[:description]}
{gettext("Save")} type="textarea"
</.button> label={gettext("Description")}
</div> rows="4"
/>
<div class="max-w-2xl space-y-4"> </div>
<.input field={@form[:name]} label={gettext("Name")} required />
<.input
field={@form[:description]}
type="textarea"
label={gettext("Description")}
rows="4"
/>
</div> </div>
</.form> </.form>
</Layouts.app> </Layouts.app>

View file

@ -39,72 +39,64 @@ defmodule MvWeb.GroupLive.Index do
def render(assigns) do def render(assigns) do
~H""" ~H"""
<Layouts.app flash={@flash} current_user={@current_user}> <Layouts.app flash={@flash} current_user={@current_user}>
<div class="flex items-center justify-between mb-6"> <.header>
<h1 class="text-2xl font-bold">{gettext("Groups")}</h1> {gettext("Groups")}
<%= if can?(@current_user, :create, Mv.Membership.Group) do %> <:actions>
<.button navigate={~p"/groups/new"} variant="primary"> <%= if can?(@current_user, :create, Mv.Membership.Group) do %>
<.icon name="hero-plus" class="size-4 mr-2" /> <.button navigate={~p"/groups/new"} variant="primary">
{gettext("Create Group")} <.icon name="hero-plus" class="size-4 mr-2" />
</.button> {gettext("Create Group")}
</.button>
<% end %>
</:actions>
</.header>
<div class="mt-6 space-y-6">
<%= if Enum.empty?(@groups) do %>
<div class="text-center py-12">
<p class="text-base-content/60 italic">{gettext("No groups")}</p>
</div>
<% else %>
<.table
id="groups-table"
rows={@groups}
row_id={fn group -> "group-#{group.id}" end}
row_click={fn group -> JS.navigate(~p"/groups/#{group.slug}") end}
>
<:col :let={group} label={gettext("Name")}>
{group.name}
</:col>
<:col :let={group} label={gettext("Description")}>
<%= if group.description do %>
{group.description}
<% else %>
<span class="text-base-content/50 italic"></span>
<% end %>
</:col>
<:col :let={group} label={gettext("Members")} class="text-right">
{group.member_count || 0}
</:col>
<:action :let={group}>
<.button
variant="ghost"
size="sm"
navigate={~p"/groups/#{group.slug}"}
>
{gettext("View")}
</.button>
<%= if can?(@current_user, :update, Mv.Membership.Group) do %>
<.button
variant="ghost"
size="sm"
navigate={~p"/groups/#{group.slug}/edit"}
>
{gettext("Edit group")}
</.button>
<% end %>
</:action>
</.table>
<% end %> <% end %>
</div> </div>
<%= if Enum.empty?(@groups) do %>
<div class="text-center py-12">
<p class="text-base-content/70">{gettext("No groups")}</p>
</div>
<% else %>
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>{gettext("Name")}</th>
<th>{gettext("Description")}</th>
<th>{gettext("Members")}</th>
<th>{gettext("Actions")}</th>
</tr>
</thead>
<tbody>
<%= for group <- @groups do %>
<tr>
<td>
{group.name}
</td>
<td>
<%= if group.description do %>
{group.description}
<% else %>
<span class="text-base-content/50 italic"></span>
<% end %>
</td>
<td>
<%= if group.member_count do %>
{group.member_count}
<% else %>
0
<% end %>
</td>
<td>
<div class="flex gap-2">
<.link navigate={~p"/groups/#{group.slug}"} class="btn btn-sm btn-ghost">
{gettext("View")}
</.link>
<%= if can?(@current_user, :update, Mv.Membership.Group) do %>
<.link
navigate={~p"/groups/#{group.slug}/edit"}
class="btn btn-sm btn-ghost"
>
{gettext("Edit")}
</.link>
<% end %>
</div>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
</Layouts.app> </Layouts.app>
""" """
end end

View file

@ -85,318 +85,327 @@ defmodule MvWeb.GroupLive.Show do
def render(assigns) do def render(assigns) do
~H""" ~H"""
<Layouts.app flash={@flash} current_user={@current_user}> <Layouts.app flash={@flash} current_user={@current_user}>
<%!-- Header with Back button, Name, and Edit/Delete buttons --%> <.header>
<div class="flex items-center justify-between gap-4 pb-4"> {@group.name}
<.button navigate={~p"/groups"} aria-label={gettext("Back to groups list")}> <:actions>
<.icon name="hero-arrow-left" class="size-4" /> <.button
{gettext("Back")} navigate={~p"/groups"}
</.button> variant="neutral"
aria-label={gettext("Back to groups list")}
<h1 class="text-2xl font-bold text-center flex-1"> >
{@group.name} <.icon name="hero-arrow-left" class="size-4" />
</h1> {gettext("Back")}
</.button>
<div class="flex gap-2">
<%= if can?(@current_user, :update, @group) do %> <%= if can?(@current_user, :update, @group) do %>
<.button <.button
variant="primary" variant="primary"
navigate={~p"/groups/#{@group.slug}/edit"} navigate={~p"/groups/#{@group.slug}/edit"}
data-testid="group-show-edit-btn" data-testid="group-show-edit-btn"
> >
{gettext("Edit")} {gettext("Edit group")}
</.button> </.button>
<% end %> <% end %>
<%= if can?(@current_user, :destroy, @group) do %> <%= if can?(@current_user, :destroy, @group) do %>
<.button <.button
class="btn-error" variant="danger"
phx-click="open_delete_modal" phx-click="open_delete_modal"
data-testid="group-show-delete-btn" data-testid="group-show-delete-btn"
> >
{gettext("Delete")} {gettext("Delete")}
</.button> </.button>
<% end %> <% end %>
</div> </:actions>
</div> </.header>
<%!-- Group Information --%> <div class="mt-6 space-y-6">
<div class="max-w-2xl space-y-6 mb-6"> <%!-- Group Information --%>
<div> <div class="max-w-2xl space-y-6 mb-6">
<h2 class="text-lg font-semibold mb-2">{gettext("Description")}</h2> <div>
<div class="border border-base-300 rounded-lg p-4 bg-base-100"> <h2 class="text-lg font-semibold mb-2">{gettext("Description")}</h2>
<%= if @group.description && String.trim(@group.description) != "" do %> <div class="border border-base-300 rounded-lg p-4 bg-base-100">
<p class="whitespace-pre-wrap">{@group.description}</p> <%= if @group.description && String.trim(@group.description) != "" do %>
<% else %> <p class="whitespace-pre-wrap">{@group.description}</p>
<p class="text-base-content/50 italic">{gettext("No description")}</p> <% else %>
<% end %> <p class="text-base-content/50 italic">{gettext("No description")}</p>
<% end %>
</div>
</div> </div>
</div>
<div> <div>
<h2 class="text-lg font-semibold mb-2">{gettext("Members")}</h2> <h2 class="text-lg font-semibold mb-2">{gettext("Members")}</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100"> <div class="border border-base-300 rounded-lg p-4 bg-base-100">
<p class="mb-4" data-testid="group-show-member-count"> <p class="mb-4" data-testid="group-show-member-count">
{ngettext( {ngettext(
"Total: %{count} member", "Total: %{count} member",
"Total: %{count} members", "Total: %{count} members",
@group.member_count || 0, @group.member_count || 0,
count: @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">
<%= for member <- @selected_members do %>
<span class="badge badge-outline badge flex items-center gap-1">
{MvWeb.Helpers.MemberHelpers.display_name(member)}
<button
type="button"
class="btn btn-ghost btn-xs p-0 h-4 w-4 min-h-0"
phx-click="remove_selected_member"
phx-value-member_id={member.id}
aria-label={
gettext("Remove %{name}",
name: MvWeb.Helpers.MemberHelpers.display_name(member)
)
}
>
<.icon name="hero-x-mark" class="size-3" />
</button>
</span>
<% 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"
)
]}
>
<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"
class="btn btn-primary join-item"
phx-click="add_selected_members"
data-testid="group-show-add-selected-members-btn"
disabled={Enum.empty?(@selected_member_ids)}
aria-label={gettext("Add members")}
>
<.icon name="hero-plus" class="size-5" />
</button>
<button
type="button"
class="btn join-item"
phx-click="hide_add_member_input"
aria-label={gettext("Cancel")}
>
{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> </p>
<% else %>
<div class="overflow-x-auto" data-testid="group-show-members-table"> <%= if can?(@current_user, :update, @group) do %>
<table class="table table-zebra"> <div class="mb-4">
<thead> <%= if assigns[:show_add_member_input] do %>
<tr> <div class="join w-full">
<th>{gettext("Name")}</th> <form phx-change="search_members" class="flex-1">
<th>{gettext("Email")}</th> <div class="relative">
<%= if can?(@current_user, :update, @group) do %> <div class="input input-bordered join-item w-full flex flex-wrap gap-1 items-center py-1 px-2">
<th class="w-0">{gettext("Actions")}</th> <%= for member <- @selected_members do %>
<% end %> <span class="badge badge-outline badge flex items-center gap-1">
</tr> {MvWeb.Helpers.MemberHelpers.display_name(member)}
</thead> <.tooltip content={gettext("Remove")} position="top">
<tbody> <.button
<%= for member <- @group.members do %> type="button"
<tr> variant="icon"
<td> size="sm"
<.link phx-click="remove_selected_member"
navigate={~p"/members/#{member.id}"} phx-value-member_id={member.id}
class="link link-primary" aria-label={
> gettext("Remove %{name}",
{MvWeb.Helpers.MemberHelpers.display_name(member)} name: MvWeb.Helpers.MemberHelpers.display_name(member)
</.link> )
</td> }
<td> class="p-0 h-4 w-4 min-h-0"
<%= if member.email do %> >
<a <.icon name="hero-x-mark" class="size-3" />
href={"mailto:#{member.email}"} </.button>
class="text-blue-700 hover:text-blue-800 underline" </.tooltip>
</span>
<% 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"
> >
{member.email} <%= for {member, index} <- Enum.with_index(@available_members) do %>
</a> <div
<% else %> id={"member-option-#{index}"}
<span class="text-base-content/50 italic"></span> 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 %> <% end %>
</td> </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>
<% else %>
<div class="overflow-x-auto" data-testid="group-show-members-table">
<table class="table table-zebra">
<thead>
<tr>
<th>{gettext("Name")}</th>
<th>{gettext("Email")}</th>
<%= if can?(@current_user, :update, @group) do %> <%= if can?(@current_user, :update, @group) do %>
<td> <th class="w-0">{gettext("Actions")}</th>
<button
type="button"
class="btn btn-ghost btn-sm text-error"
phx-click="remove_member"
phx-value-member_id={member.id}
data-testid="group-show-remove-member"
aria-label={gettext("Remove member from group")}
data-tooltip={gettext("Remove")}
>
<.icon name="hero-trash" class="size-4" />
</button>
</td>
<% end %> <% end %>
</tr> </tr>
<% end %> </thead>
</tbody> <tbody>
</table> <%= for member <- @group.members do %>
</div> <tr>
<% end %> <td>
<.link
navigate={~p"/members/#{member.id}"}
class="link link-primary"
>
{MvWeb.Helpers.MemberHelpers.display_name(member)}
</.link>
</td>
<td>
<%= if member.email do %>
<a
href={"mailto:#{member.email}"}
class="link link-primary"
>
{member.email}
</a>
<% else %>
<span class="text-base-content/50 italic"></span>
<% end %>
</td>
<%= if can?(@current_user, :update, @group) do %>
<td>
<.tooltip content={gettext("Remove")} position="left">
<.button
type="button"
variant="danger"
size="sm"
phx-click="remove_member"
phx-value-member_id={member.id}
data-testid="group-show-remove-member"
aria-label={gettext("Remove member from group")}
>
<.icon name="hero-trash" class="size-4" />
</.button>
</.tooltip>
</td>
<% end %>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
</div>
</div> </div>
</div> </div>
</div>
<%!-- Delete Confirmation Modal --%> <%!-- Delete Confirmation Modal --%>
<%= if assigns[:show_delete_modal] do %> <%= if assigns[:show_delete_modal] do %>
<dialog id="delete-group-modal" class="modal modal-open" role="dialog"> <dialog id="delete-group-modal" class="modal modal-open" role="dialog">
<div class="modal-box"> <div class="modal-box">
<h3 class="text-lg font-bold mb-4">{gettext("Delete Group")}</h3> <h3 class="text-lg font-bold mb-4">{gettext("Delete Group")}</h3>
<p class="mb-4"> <p class="mb-4">
{gettext("Are you sure you want to delete this group? This action cannot be undone.")} {gettext("Are you sure you want to delete this group? This action cannot be undone.")}
</p> </p>
<%= if @group.member_count && @group.member_count > 0 do %> <%= if @group.member_count && @group.member_count > 0 do %>
<div class="alert alert-warning mb-4"> <div class="alert alert-warning mb-4">
<.icon name="hero-exclamation-triangle" class="size-5" /> <.icon name="hero-exclamation-triangle" class="size-5" />
<span> <span>
{ngettext( {ngettext(
"This group has %{count} member. All member-group associations will be permanently deleted.", "This group has %{count} member. All member-group associations will be permanently deleted.",
"This group has %{count} members. All member-group associations will be permanently deleted.", "This group has %{count} members. All member-group associations will be permanently deleted.",
@group.member_count, @group.member_count,
count: @group.member_count count: @group.member_count
)} )}
</span> </span>
</div>
<% end %>
<div>
<label for="group-name-confirmation" class="label">
<span class="label-text">
{gettext("To confirm deletion, please enter the group name:")}
</span>
</label>
<div class="p-2 mb-2 font-mono text-lg font-bold break-all rounded bg-base-200">
{@group.name}
</div>
<form phx-change="update_name_confirmation">
<input
id="group-name-confirmation"
name="name"
type="text"
value={@name_confirmation || ""}
placeholder={gettext("Enter the group name to confirm")}
autocomplete="off"
phx-debounce="200"
class="w-full input input-bordered"
/>
</form>
</div> </div>
<% end %> <div class="modal-action">
<div> <.button
<label for="group-name-confirmation" class="label"> type="button"
<span class="label-text"> variant="neutral"
{gettext("To confirm deletion, please enter the group name:")} phx-click="cancel_delete"
</span> aria-label={gettext("Cancel")}
</label> >
<div class="p-2 mb-2 font-mono text-lg font-bold break-all rounded bg-base-200"> {gettext("Cancel")}
{@group.name} </.button>
<.button
type="button"
variant="danger"
phx-click="confirm_delete"
phx-value-slug={@group.slug}
disabled={(@name_confirmation || "") != @group.name}
aria-label={gettext("Delete group")}
>
{gettext("Delete")}
</.button>
</div> </div>
<form phx-change="update_name_confirmation">
<input
id="group-name-confirmation"
name="name"
type="text"
value={@name_confirmation || ""}
placeholder={gettext("Enter the group name to confirm")}
autocomplete="off"
phx-debounce="200"
class="w-full input input-bordered"
/>
</form>
</div> </div>
<div class="modal-action"> </dialog>
<button <% end %>
type="button" </div>
class="btn"
phx-click="cancel_delete"
aria-label={gettext("Cancel")}
>
{gettext("Cancel")}
</button>
<button
type="button"
class="btn btn-error"
phx-click="confirm_delete"
phx-value-slug={@group.slug}
disabled={(@name_confirmation || "") != @group.name}
aria-label={gettext("Delete group")}
>
{gettext("Delete")}
</button>
</div>
</div>
</dialog>
<% end %>
</Layouts.app> </Layouts.app>
""" """
end end

View file

@ -42,11 +42,13 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
<div class="flex items-center gap-4 mb-4"> <div class="flex items-center gap-4 mb-4">
<.button <.button
type="button" type="button"
variant="neutral"
phx-click="cancel" phx-click="cancel"
phx-target={@myself} phx-target={@myself}
aria-label={gettext("Back to Settings")} aria-label={gettext("Back to settings")}
> >
<.icon name="hero-arrow-left" class="w-4 h-4" /> <.icon name="hero-arrow-left" class="size-4" />
{gettext("Back")}
</.button> </.button>
<h3 class="card-title"> <h3 class="card-title">
{gettext("Edit Field: %{field}", field: @field_label)} {gettext("Edit Field: %{field}", field: @field_label)}
@ -176,7 +178,7 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
</div> </div>
<div class="justify-end mt-4 card-actions"> <div class="justify-end mt-4 card-actions">
<.button type="button" phx-click="cancel" phx-target={@myself}> <.button type="button" variant="neutral" phx-click="cancel" phx-target={@myself}>
{gettext("Cancel")} {gettext("Cancel")}
</.button> </.button>
<.button phx-disable-with={gettext("Saving...")} variant="primary"> <.button phx-disable-with={gettext("Saving...")} variant="primary">

View file

@ -52,6 +52,11 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
:if={!@show_form} :if={!@show_form}
id="member_fields" id="member_fields"
rows={@member_fields} rows={@member_fields}
row_click={
fn {field_name, _field_data} ->
JS.push("edit_member_field", value: %{"field" => field_name}, target: @myself)
end
}
> >
<:col :let={{_field_name, field_data}} label={gettext("Name")}> <:col :let={{_field_name, field_data}} label={gettext("Name")}>
{MemberFields.label(field_data.field)} {MemberFields.label(field_data.field)}
@ -93,7 +98,7 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
phx-value-field={Atom.to_string(field_data.field)} phx-value-field={Atom.to_string(field_data.field)}
phx-target={@myself} phx-target={@myself}
> >
{gettext("Edit")} {gettext("Edit datafield")}
</.link> </.link>
</:action> </:action>
</.table> </.table>

View file

@ -38,227 +38,226 @@ defmodule MvWeb.MemberLive.Form do
~H""" ~H"""
<Layouts.app flash={@flash} current_user={@current_user}> <Layouts.app flash={@flash} current_user={@current_user}>
<.form for={@form} id="member-form" phx-change="validate" phx-submit="save"> <.form for={@form} id="member-form" phx-change="validate" phx-submit="save">
<%!-- Header with Back button, Name display, and Save button --%> <.header>
<div class="flex items-center justify-between gap-4 pb-4"> <%= if @member do %>
<.button navigate={return_path(@return_to, @member)} type="button"> {MvWeb.Helpers.MemberHelpers.display_name(@member)}
<.icon name="hero-arrow-left" class="size-4" /> <% else %>
{gettext("Back")} {gettext("New Member")}
</.button> <% end %>
<:actions>
<.button navigate={return_path(@return_to, @member)} variant="neutral">
<.icon name="hero-arrow-left" class="size-4" />
{gettext("Back")}
</.button>
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save")}
</.button>
</:actions>
</.header>
<h1 class="text-2xl font-bold text-center flex-1"> <div class="mt-6 space-y-6">
<%= if @member do %> <%!-- Tab Navigation --%>
{MvWeb.Helpers.MemberHelpers.display_name(@member)} <div role="tablist" class="tabs tabs-bordered">
<% else %> <button type="button" role="tab" class="tab tab-active" aria-selected="true">
{gettext("New Member")} <.icon name="hero-identification" class="size-4 mr-2" />
{gettext("Contact Data")}
</button>
<button
type="button"
role="tab"
class="tab"
disabled
aria-disabled="true"
title={gettext("Coming soon")}
>
<.icon name="hero-credit-card" class="size-4 mr-2" />
{gettext("Payments")}
</button>
</div>
<%!-- 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]}
/>
</div>
<div class="w-48">
<.input
field={@form[:last_name]}
label={gettext("Last Name")}
required={@member_field_required_map[:last_name]}
/>
</div>
</div>
<%!-- Address Row --%>
<div class="flex gap-4">
<div class="flex-1">
<.input
field={@form[:street]}
label={gettext("Street")}
required={@member_field_required_map[:street]}
/>
</div>
<div class="w-16">
<.input
field={@form[:house_number]}
label={gettext("Nr.")}
required={@member_field_required_map[:house_number]}
/>
</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-32">
<.input
field={@form[:city]}
label={gettext("City")}
required={@member_field_required_map[:city]}
/>
</div>
</div>
<%!-- Email (always required) --%>
<div>
<.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]}
/>
</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>
<%!-- Notes --%>
<div>
<.input
field={@form[:notes]}
label={gettext("Notes")}
type="textarea"
required={@member_field_required_map[:notes]}
/>
</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 %> <% end %>
</h1> </div>
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit"> <%!-- Membership Fee Section --%>
{gettext("Save")} <div class="max-w-xl">
</.button> <.form_section title={gettext("Membership Fee")}>
</div>
<%!-- Tab Navigation --%>
<div role="tablist" class="tabs tabs-bordered mb-6">
<button type="button" role="tab" class="tab tab-active" aria-selected="true">
<.icon name="hero-identification" class="size-4 mr-2" />
{gettext("Contact Data")}
</button>
<button
type="button"
role="tab"
class="tab"
disabled
aria-disabled="true"
title={gettext("Coming soon")}
>
<.icon name="hero-credit-card" class="size-4 mr-2" />
{gettext("Payments")}
</button>
</div>
<%!-- 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"> <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>
<%!-- Address Row --%>
<div class="flex gap-4">
<div class="flex-1">
<.input
field={@form[:street]}
label={gettext("Street")}
required={@member_field_required_map[:street]}
/>
</div>
<div class="w-16">
<.input
field={@form[:house_number]}
label={gettext("Nr.")}
required={@member_field_required_map[:house_number]}
/>
</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-32">
<.input
field={@form[:city]}
label={gettext("City")}
required={@member_field_required_map[:city]}
/>
</div>
</div>
<%!-- Email (always required) --%>
<div> <div>
<.input field={@form[:email]} label={gettext("Email")} required type="email" /> <label class="label">
</div> <span class="label-text font-semibold">{gettext("Membership Fee Type")}</span>
</label>
<%!-- Membership Dates Row --%> <select
<div class="flex gap-4"> class="select select-bordered w-full"
<div class="w-36"> name={@form[:membership_fee_type_id].name}
<.input phx-change="validate"
field={@form[:join_date]} value={@form[:membership_fee_type_id].value || ""}
label={gettext("Join Date")} >
type="date" <%!-- No "None" option: a membership fee type is required (validated in Member resource). --%>
required={@member_field_required_map[:join_date]} <option value="">{gettext("Select a membership fee type")}</option>
/> <%= for fee_type <- @available_fee_types do %>
</div> <option
<div class="w-36"> value={fee_type.id}
<.input selected={fee_type.id == @form[:membership_fee_type_id].value}
field={@form[:exit_date]} >
label={gettext("Exit Date")} {fee_type.name} ({MembershipFeeHelpers.format_currency(fee_type.amount)}, {MembershipFeeHelpers.format_interval(
type="date" fee_type.interval
required={@member_field_required_map[:exit_date]} )})
/> </option>
</div> <% end %>
</div> </select>
<%= for error <- List.wrap(@form.errors[:membership_fee_type_id] || []) do %>
<%!-- Notes --%> <% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %>
<div> <p class="text-error text-sm mt-1">{msg}</p>
<.input <% end %>
field={@form[:notes]} <%= if @interval_warning do %>
label={gettext("Notes")} <div class="alert alert-warning mt-2">
type="textarea" <.icon name="hero-exclamation-triangle" class="size-5" />
required={@member_field_required_map[:notes]} <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>
</div> </div>
</.form_section> </.form_section>
</div> </div>
<%!-- Custom Fields Section --%> <%!-- Bottom Action Buttons --%>
<%= if Enum.any?(@custom_fields) do %> <div class="flex justify-end gap-4 mt-6">
<div> <.button navigate={return_path(@return_to, @member)} variant="neutral" type="button">
<.form_section title={gettext("Custom Fields")}> {gettext("Cancel")}
<div class="grid grid-cols-2 gap-4"> </.button>
<%!-- Render in sorted order by finding the form for each sorted custom field --%> <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
<%= for cf <- @sorted_custom_fields do %> {gettext("Save Member")}
<.inputs_for :let={f_cfv} field={@form[:custom_field_values]}> </.button>
<%= if f_cfv[:custom_field_id].value == cf.id do %> </div>
<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>
<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>
<%!-- Bottom Action Buttons --%>
<div class="flex justify-end gap-4 mt-6">
<.button navigate={return_path(@return_to, @member)} type="button">
{gettext("Cancel")}
</.button>
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save Member")}
</.button>
</div> </div>
</.form> </.form>
</Layouts.app> </Layouts.app>

View file

@ -9,7 +9,7 @@
selected_count={@selected_count} selected_count={@selected_count}
/> />
<.button <.button
class="secondary" variant="secondary"
id="copy-emails-btn" id="copy-emails-btn"
phx-hook="CopyToClipboard" phx-hook="CopyToClipboard"
phx-click="copy_emails" phx-click="copy_emails"
@ -20,7 +20,7 @@
{gettext("Copy email addresses")} ({@selected_count}) {gettext("Copy email addresses")} ({@selected_count})
</.button> </.button>
<.button <.button
class="secondary" variant="secondary"
id="open-email-btn" id="open-email-btn"
href={"mailto:?bcc=" <> @mailto_bcc} href={"mailto:?bcc=" <> @mailto_bcc}
disabled={not @any_selected?} disabled={not @any_selected?}
@ -54,13 +54,11 @@
boolean_filters={@boolean_custom_field_filters} boolean_filters={@boolean_custom_field_filters}
member_count={length(@members)} member_count={length(@members)}
/> />
<button <.button
type="button" type="button"
variant="secondary"
class={["gap-2", @show_current_cycle && "btn-active"]}
phx-click="toggle_cycle_view" phx-click="toggle_cycle_view"
class={[
"btn gap-2",
@show_current_cycle && "btn-active"
]}
aria-label={ aria-label={
if(@show_current_cycle, if(@show_current_cycle,
do: gettext("Current Cycle Payment Status"), do: gettext("Current Cycle Payment Status"),
@ -81,7 +79,7 @@
else: gettext("Last Cycle Payment Status") else: gettext("Last Cycle Payment Status")
)} )}
</span> </span>
</button> </.button>
<.live_component <.live_component
module={MvWeb.Components.FieldVisibilityDropdownComponent} module={MvWeb.Components.FieldVisibilityDropdownComponent}
id="field-visibility-dropdown" id="field-visibility-dropdown"
@ -386,7 +384,7 @@
<%= if can?(@current_user, :update, member) do %> <%= if can?(@current_user, :update, member) do %>
<.link navigate={~p"/members/#{member}/edit"} data-testid="member-edit"> <.link navigate={~p"/members/#{member}/edit"} data-testid="member-edit">
{gettext("Edit")} {gettext("Edit member")}
</.link> </.link>
<% end %> <% end %>
</:action> </:action>

View file

@ -30,235 +30,243 @@ defmodule MvWeb.MemberLive.Show do
def render(assigns) do def render(assigns) do
~H""" ~H"""
<Layouts.app flash={@flash} current_user={@current_user}> <Layouts.app flash={@flash} current_user={@current_user}>
<%!-- Header with Back button, Name, and Edit button --%> <.header>
<div class="flex items-center justify-between gap-4 pb-4"> {MvWeb.Helpers.MemberHelpers.display_name(@member)}
<.button navigate={~p"/members"} aria-label={gettext("Back to members list")}> <:actions>
<.icon name="hero-arrow-left" class="size-4" />
{gettext("Back")}
</.button>
<h1 class="text-2xl font-bold text-center flex-1">
{MvWeb.Helpers.MemberHelpers.display_name(@member)}
</h1>
<%= if can?(@current_user, :update, @member) do %>
<.button <.button
variant="primary" navigate={~p"/members"}
navigate={~p"/members/#{@member}/edit?return_to=show"} variant="neutral"
data-testid="member-edit" aria-label={gettext("Back to members list")}
> >
{gettext("Edit Member")} <.icon name="hero-arrow-left" class="size-4" />
{gettext("Back")}
</.button> </.button>
<% end %> <%= if can?(@current_user, :update, @member) do %>
</div> <.button
variant="primary"
navigate={~p"/members/#{@member}/edit?return_to=show"}
data-testid="member-edit"
>
{gettext("Edit member")}
</.button>
<% end %>
</:actions>
</.header>
<%!-- Tab Navigation --%> <div class="mt-6 space-y-6">
<div role="tablist" class="tabs tabs-bordered mb-6"> <%!-- Tab Navigation --%>
<button <div role="tablist" class="tabs tabs-bordered">
role="tab" <button
class={[ role="tab"
"tab", class={[
if(@active_tab == :contact, do: "tab-active", else: "!text-gray-800") "tab",
]} if(@active_tab == :contact, do: "tab-active", else: "!text-gray-800")
aria-selected={@active_tab == :contact} ]}
phx-click="switch_tab" aria-selected={@active_tab == :contact}
phx-value-tab="contact" phx-click="switch_tab"
> phx-value-tab="contact"
<.icon name="hero-identification" class="size-4 mr-2" /> >
{gettext("Contact Data")} <.icon name="hero-identification" class="size-4 mr-2" />
</button> {gettext("Contact Data")}
<button </button>
role="tab" <button
class={[ role="tab"
"tab", class={[
if(@active_tab == :membership_fees, do: "tab-active", else: "!text-gray-800") "tab",
]} if(@active_tab == :membership_fees, do: "tab-active", else: "!text-gray-800")
aria-selected={@active_tab == :membership_fees} ]}
phx-click="switch_tab" aria-selected={@active_tab == :membership_fees}
phx-value-tab="membership_fees" phx-click="switch_tab"
> phx-value-tab="membership_fees"
<.icon name="hero-credit-card" class="size-4 mr-2" /> >
{gettext("Membership Fees")} <.icon name="hero-credit-card" class="size-4 mr-2" />
</button> {gettext("Membership Fees")}
</div> </button>
</div>
<%= if @active_tab == :contact do %> <%= if @active_tab == :contact do %>
<%!-- Contact Data Tab Content --%> <%!-- Contact Data Tab Content --%>
<%!-- Personal Data and Custom Fields Row --%> <%!-- Personal Data and Custom Fields Row --%>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<%!-- Personal Data Section --%> <%!-- Personal Data Section --%>
<div> <div>
<.section_box title={gettext("Personal Data")}> <.section_box title={gettext("Personal Data")}>
<div class="space-y-4"> <div class="space-y-4">
<%!-- Name Row --%> <%!-- Name Row --%>
<div class="flex gap-6"> <div class="flex gap-6">
<.data_field label={gettext("First Name")} value={@member.first_name} class="w-48" /> <.data_field
<.data_field label={gettext("Last Name")} value={@member.last_name} class="w-48" /> label={gettext("First Name")}
</div> value={@member.first_name}
class="w-48"
/>
<.data_field label={gettext("Last Name")} value={@member.last_name} class="w-48" />
</div>
<%!-- Address --%> <%!-- Address --%>
<div> <div>
<.data_field label={gettext("Address")} value={format_address(@member)} /> <.data_field label={gettext("Address")} value={format_address(@member)} />
</div> </div>
<%!-- Email --%> <%!-- Email --%>
<div> <div>
<.data_field label={gettext("Email")}> <.data_field label={gettext("Email")}>
<a <a
href={"mailto:#{MvWeb.MemberLive.Index.format_member_email(@member)}"} href={"mailto:#{MvWeb.MemberLive.Index.format_member_email(@member)}"}
class="text-blue-700 hover:text-blue-800 underline" class="text-blue-700 hover:text-blue-800 underline"
> >
{@member.email} {@member.email}
</a> </a>
</.data_field> </.data_field>
</div> </div>
<%!-- Membership Dates Row --%> <%!-- Membership Dates Row --%>
<div class="flex gap-6"> <div class="flex gap-6">
<.data_field <.data_field
label={gettext("Join Date")} label={gettext("Join Date")}
value={format_date(@member.join_date)} value={format_date(@member.join_date)}
class="w-28" class="w-28"
/> />
<.data_field <.data_field
label={gettext("Exit Date")} label={gettext("Exit Date")}
value={format_date(@member.exit_date)} value={format_date(@member.exit_date)}
class="w-28" class="w-28"
/> />
</div> </div>
<%!-- Linked User: only show when current user can see other users (e.g. admin). <%!-- Linked User: only show when current user can see other users (e.g. admin).
read_only cannot see linked user, so hide the section to avoid "No user linked" when read_only cannot see linked user, so hide the section to avoid "No user linked" when
a user is linked but not visible. --%> a user is linked but not visible. --%>
<%= if can_access_page?(@current_user, "/users") do %> <%= if can_access_page?(@current_user, "/users") do %>
<div>
<.data_field label={gettext("Linked User")}>
<%= if @member.user do %>
<.link
navigate={~p"/users/#{@member.user}"}
class="text-blue-700 hover:text-blue-800 underline inline-flex items-center gap-1"
>
<.icon name="hero-user" class="size-4" />
{@member.user.email}
</.link>
<% else %>
<span class="text-base-content/70 italic">{gettext("No user linked")}</span>
<% end %>
</.data_field>
</div>
<% end %>
<%!-- Groups (in Personal Data) --%>
<% groups = @member.groups || [] %>
<div> <div>
<.data_field label={gettext("Linked User")}> <.data_field label={gettext("Groups")}>
<%= if @member.user do %> <%= if Enum.empty?(groups) do %>
<.link <span class="text-base-content/70 italic">{gettext("No groups")}</span>
navigate={~p"/users/#{@member.user}"}
class="text-blue-700 hover:text-blue-800 underline inline-flex items-center gap-1"
>
<.icon name="hero-user" class="size-4" />
{@member.user.email}
</.link>
<% else %> <% else %>
<span class="text-base-content/70 italic">{gettext("No user linked")}</span> <div class="flex flex-wrap gap-2">
<%= for group <- groups do %>
<.button
variant="outline"
size="sm"
navigate={~p"/groups/#{group.slug}"}
aria-label={gettext("Member of group %{name}", name: group.name)}
>
{group.name}
</.button>
<% end %>
</div>
<% end %> <% end %>
</.data_field> </.data_field>
</div> </div>
<% end %>
<%!-- Groups (in Personal Data) --%> <%!-- Notes --%>
<% groups = @member.groups || [] %> <%= if @member.notes && String.trim(@member.notes) != "" do %>
<div> <div>
<.data_field label={gettext("Groups")}> <.data_field label={gettext("Notes")}>
<%= if Enum.empty?(groups) do %> <p class="whitespace-pre-wrap text-base-content/80">{@member.notes}</p>
<span class="text-base-content/70 italic">{gettext("No groups")}</span> </.data_field>
<% else %> </div>
<div class="flex flex-wrap gap-2">
<%= for group <- groups do %>
<.link
navigate={~p"/groups/#{group.slug}"}
class="btn btn-xs btn-outline btn-primary"
aria-label={gettext("Member of group %{name}", name: group.name)}
>
{group.name}
</.link>
<% end %>
</div>
<% end %>
</.data_field>
</div>
<%!-- Notes --%>
<%= if @member.notes && String.trim(@member.notes) != "" do %>
<div>
<.data_field label={gettext("Notes")}>
<p class="whitespace-pre-wrap text-base-content/80">{@member.notes}</p>
</.data_field>
</div>
<% end %>
</div>
</.section_box>
</div>
<%!-- Custom Fields Section --%>
<%= if Enum.any?(@custom_fields) do %>
<div>
<.section_box title={gettext("Custom Fields")}>
<div class="grid grid-cols-2 gap-4">
<%= for custom_field <- @custom_fields do %>
<% cfv = find_custom_field_value(@member.custom_field_values, custom_field.id) %>
<.data_field label={custom_field.name}>
{format_custom_field_value(cfv, custom_field.value_type)}
</.data_field>
<% end %> <% end %>
</div> </div>
</.section_box> </.section_box>
</div> </div>
<% end %>
</div>
<%!-- Payment Data Section --%> <%!-- Custom Fields Section --%>
<div class="w-full"> <%= if Enum.any?(@custom_fields) do %>
<.section_box title={gettext("Payment Data")}> <div>
<%= if @member.membership_fee_type do %> <.section_box title={gettext("Custom Fields")}>
<div class="flex gap-6 flex-wrap"> <div class="grid grid-cols-2 gap-4">
<.data_field <%= for custom_field <- @custom_fields do %>
label={gettext("Type")} <% cfv = find_custom_field_value(@member.custom_field_values, custom_field.id) %>
value={@member.membership_fee_type.name} <.data_field label={custom_field.name}>
class="min-w-32" {format_custom_field_value(cfv, custom_field.value_type)}
/> </.data_field>
<.data_field <% end %>
label={gettext("Membership Fee")} </div>
value={MembershipFeeHelpers.format_currency(@member.membership_fee_type.amount)} </.section_box>
class="min-w-24"
/>
<.data_field
label={gettext("Payment Interval")}
value={MembershipFeeHelpers.format_interval(@member.membership_fee_type.interval)}
class="min-w-32"
/>
<.data_field label={gettext("Last Cycle")} class="min-w-32">
<%= if @member.last_cycle_status do %>
<% status = @member.last_cycle_status %>
<span class={["badge", MembershipFeeHelpers.status_color(status)]}>
{format_status_label(status)}
</span>
<% else %>
<span class="badge badge-ghost">{gettext("No cycles")}</span>
<% end %>
</.data_field>
<.data_field label={gettext("Current Cycle")} class="min-w-36">
<%= if @member.current_cycle_status do %>
<% status = @member.current_cycle_status %>
<span class={["badge", MembershipFeeHelpers.status_color(status)]}>
{format_status_label(status)}
</span>
<% else %>
<span class="badge badge-ghost">{gettext("No cycles")}</span>
<% end %>
</.data_field>
</div>
<% else %>
<div class="text-base-content/70 italic">
{gettext("No membership fee type assigned")}
</div> </div>
<% end %> <% end %>
</.section_box> </div>
</div>
<% end %>
<%= if @active_tab == :membership_fees do %> <%!-- Payment Data Section --%>
<%!-- Membership Fees Tab Content --%> <div class="w-full">
<.live_component <.section_box title={gettext("Payment Data")}>
module={MvWeb.MemberLive.Show.MembershipFeesComponent} <%= if @member.membership_fee_type do %>
id={"membership-fees-#{@member.id}"} <div class="flex gap-6 flex-wrap">
member={@member} <.data_field
current_user={@current_user} label={gettext("Type")}
vereinfacht_receipts={@vereinfacht_receipts} value={@member.membership_fee_type.name}
/> class="min-w-32"
<% end %> />
<.data_field
label={gettext("Membership Fee")}
value={MembershipFeeHelpers.format_currency(@member.membership_fee_type.amount)}
class="min-w-24"
/>
<.data_field
label={gettext("Payment Interval")}
value={MembershipFeeHelpers.format_interval(@member.membership_fee_type.interval)}
class="min-w-32"
/>
<.data_field label={gettext("Last Cycle")} class="min-w-32">
<%= if @member.last_cycle_status do %>
<% status = @member.last_cycle_status %>
<span class={["badge", MembershipFeeHelpers.status_color(status)]}>
{format_status_label(status)}
</span>
<% else %>
<span class="badge badge-ghost">{gettext("No cycles")}</span>
<% end %>
</.data_field>
<.data_field label={gettext("Current Cycle")} class="min-w-36">
<%= if @member.current_cycle_status do %>
<% status = @member.current_cycle_status %>
<span class={["badge", MembershipFeeHelpers.status_color(status)]}>
{format_status_label(status)}
</span>
<% else %>
<span class="badge badge-ghost">{gettext("No cycles")}</span>
<% end %>
</.data_field>
</div>
<% else %>
<div class="text-base-content/70 italic">
{gettext("No membership fee type assigned")}
</div>
<% end %>
</.section_box>
</div>
<% end %>
<%= if @active_tab == :membership_fees do %>
<%!-- Membership Fees Tab Content --%>
<.live_component
module={MvWeb.MemberLive.Show.MembershipFeesComponent}
id={"membership-fees-#{@member.id}"}
member={@member}
current_user={@current_user}
vereinfacht_receipts={@vereinfacht_receipts}
/>
<% end %>
</div>
</Layouts.app> </Layouts.app>
""" """
end end
@ -403,7 +411,7 @@ defmodule MvWeb.MemberLive.Show do
~H""" ~H"""
<a <a
href={"mailto:#{@email}"} href={"mailto:#{@email}"}
class="text-blue-700 hover:text-blue-800 underline" class="link link-primary"
> >
{@display} {@display}
</a> </a>

View file

@ -66,14 +66,15 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
<.icon name="hero-arrow-top-right-on-square" class="inline-block size-4" /> <.icon name="hero-arrow-top-right-on-square" class="inline-block size-4" />
</.link> </.link>
<div> <div>
<button <.button
type="button" type="button"
variant="ghost"
size="sm"
phx-click="load_vereinfacht_receipts" phx-click="load_vereinfacht_receipts"
phx-value-contact_id={@member.vereinfacht_contact_id} phx-value-contact_id={@member.vereinfacht_contact_id}
class="btn btn-sm btn-ghost"
> >
{gettext("Show bookings/receipts from Vereinfacht")} {gettext("Show bookings/receipts from Vereinfacht")}
</button> </.button>
</div> </div>
<%= if @vereinfacht_receipts do %> <%= if @vereinfacht_receipts do %>
<div <div
@ -148,9 +149,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
</.button> </.button>
<.button <.button
:if={Enum.any?(@cycles) and @can_destroy_cycle} :if={Enum.any?(@cycles) and @can_destroy_cycle}
variant="outline"
size="sm"
phx-click="delete_all_cycles" phx-click="delete_all_cycles"
phx-target={@myself} phx-target={@myself}
class="btn btn-sm btn-error btn-outline"
title={gettext("Delete all cycles")} title={gettext("Delete all cycles")}
> >
<.icon name="hero-trash" class="size-4" /> <.icon name="hero-trash" class="size-4" />
@ -158,9 +160,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
</.button> </.button>
<.button <.button
:if={@member.membership_fee_type != nil and @can_create_cycle} :if={@member.membership_fee_type != nil and @can_create_cycle}
variant="primary"
size="sm"
phx-click="open_create_cycle_modal" phx-click="open_create_cycle_modal"
phx-target={@myself} phx-target={@myself}
class="btn btn-sm btn-primary"
title={gettext("Create a new cycle manually")} title={gettext("Create a new cycle manually")}
> >
<.icon name="hero-plus" class="size-4" /> <.icon name="hero-plus" class="size-4" />
@ -259,17 +262,18 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
</div> </div>
<% end %> <% end %>
<%= if @can_destroy_cycle do %> <%= if @can_destroy_cycle do %>
<button <.button
type="button" type="button"
variant="outline"
size="sm"
phx-click="delete_cycle" phx-click="delete_cycle"
phx-value-cycle_id={cycle.id} phx-value-cycle_id={cycle.id}
phx-target={@myself} phx-target={@myself}
class="btn btn-sm btn-error btn-outline"
title={gettext("Delete cycle")} title={gettext("Delete cycle")}
> >
<.icon name="hero-trash" class="size-4" /> <.icon name="hero-trash" class="size-4" />
{gettext("Delete")} {gettext("Delete")}
</button> </.button>
<% end %> <% end %>
</div> </div>
</:action> </:action>
@ -309,10 +313,15 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
/> />
</div> </div>
<div class="modal-action"> <div class="modal-action">
<button type="button" phx-click="cancel_edit_amount" phx-target={@myself} class="btn"> <.button
type="button"
variant="neutral"
phx-click="cancel_edit_amount"
phx-target={@myself}
>
{gettext("Cancel")} {gettext("Cancel")}
</button> </.button>
<button type="submit" class="btn btn-primary">{gettext("Save")}</button> <.button type="submit" variant="primary">{gettext("Save")}</.button>
</div> </div>
</form> </form>
</div> </div>
@ -334,17 +343,17 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
)} - {MembershipFeeHelpers.format_currency(@deleting_cycle.amount)} )} - {MembershipFeeHelpers.format_currency(@deleting_cycle.amount)}
</p> </p>
<div class="modal-action"> <div class="modal-action">
<button phx-click="cancel_delete_cycle" phx-target={@myself} class="btn"> <.button variant="neutral" phx-click="cancel_delete_cycle" phx-target={@myself}>
{gettext("Cancel")} {gettext("Cancel")}
</button> </.button>
<button <.button
variant="danger"
phx-click="confirm_delete_cycle" phx-click="confirm_delete_cycle"
phx-value-cycle_id={@deleting_cycle.id} phx-value-cycle_id={@deleting_cycle.id}
phx-target={@myself} phx-target={@myself}
class="btn btn-error"
> >
{gettext("Delete")} {gettext("Delete")}
</button> </.button>
</div> </div>
</div> </div>
</dialog> </dialog>
@ -385,20 +394,20 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
/> />
</div> </div>
<div class="modal-action"> <div class="modal-action">
<button phx-click="cancel_delete_all_cycles" phx-target={@myself} class="btn"> <.button variant="neutral" phx-click="cancel_delete_all_cycles" phx-target={@myself}>
{gettext("Cancel")} {gettext("Cancel")}
</button> </.button>
<button <.button
variant="danger"
phx-click="confirm_delete_all_cycles" phx-click="confirm_delete_all_cycles"
phx-target={@myself} phx-target={@myself}
class="btn btn-error"
disabled={ disabled={
String.trim(String.downcase(@delete_all_confirmation)) != String.trim(String.downcase(@delete_all_confirmation)) !=
String.downcase(gettext("Yes")) String.downcase(gettext("Yes"))
} }
> >
{gettext("Delete All")} {gettext("Delete All")}
</button> </.button>
</div> </div>
</div> </div>
</dialog> </dialog>
@ -472,10 +481,15 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
</div> </div>
<% end %> <% end %>
<div class="modal-action"> <div class="modal-action">
<button type="button" phx-click="cancel_create_cycle" phx-target={@myself} class="btn"> <.button
type="button"
variant="neutral"
phx-click="cancel_create_cycle"
phx-target={@myself}
>
{gettext("Cancel")} {gettext("Cancel")}
</button> </.button>
<button type="submit" class="btn btn-primary">{gettext("Create")}</button> <.button type="submit" variant="primary">{gettext("Create")}</.button>
</div> </div>
</form> </form>
</div> </div>

View file

@ -239,10 +239,10 @@ defmodule MvWeb.MembershipFeeSettingsLive do
<div class="divider"></div> <div class="divider"></div>
<button type="submit" class="btn btn-primary w-full"> <.button type="submit" variant="primary" class="w-full">
<.icon name="hero-check" class="size-5" /> <.icon name="hero-check" class="size-5" />
{gettext("Save Settings")} {gettext("Save Settings")}
</button> </.button>
</.form> </.form>
</div> </div>
</div> </div>
@ -333,24 +333,27 @@ defmodule MvWeb.MembershipFeeSettingsLive do
</:col> </:col>
<:action :let={mft}> <:action :let={mft}>
<.link <.tooltip content={gettext("Edit membership fee type")} position="left">
navigate={~p"/membership_fee_settings/#{mft.id}/edit_fee_type"} <.button
class="btn btn-ghost btn-xs" variant="ghost"
aria-label={gettext("Edit membership fee type")} size="sm"
> navigate={~p"/membership_fee_settings/#{mft.id}/edit_fee_type"}
<.icon name="hero-pencil" class="size-4" /> aria-label={gettext("Edit membership fee type")}
</.link> >
<.icon name="hero-pencil" class="size-4" />
</.button>
</.tooltip>
</:action> </:action>
<:action :let={mft}> <:action :let={mft}>
<div <.tooltip
:if={get_member_count(mft, @member_counts) > 0} :if={get_member_count(mft, @member_counts) > 0}
class="tooltip tooltip-left" content={
data-tip={
gettext("Cannot delete - %{count} member(s) assigned", gettext("Cannot delete - %{count} member(s) assigned",
count: get_member_count(mft, @member_counts) count: get_member_count(mft, @member_counts)
) )
} }
position="left"
> >
<button <button
phx-click="delete" phx-click="delete"
@ -366,17 +369,18 @@ defmodule MvWeb.MembershipFeeSettingsLive do
> >
<.icon name="hero-trash" class="size-4" /> <.icon name="hero-trash" class="size-4" />
</button> </button>
</div> </.tooltip>
<button <.button
:if={get_member_count(mft, @member_counts) == 0} :if={get_member_count(mft, @member_counts) == 0}
variant="danger"
size="sm"
phx-click="delete" phx-click="delete"
phx-value-id={mft.id} phx-value-id={mft.id}
data-confirm={gettext("Are you sure?")} data-confirm={gettext("Are you sure?")}
class="btn btn-ghost btn-xs text-error"
aria-label={gettext("Delete Membership Fee Type")} aria-label={gettext("Delete Membership Fee Type")}
> >
<.icon name="hero-trash" class="size-4" /> <.icon name="hero-trash" class="size-4" />
</button> </.button>
</:action> </:action>
</.table> </.table>

View file

@ -176,20 +176,20 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
</div> </div>
<div class="modal-action"> <div class="modal-action">
<button <.button
type="button" type="button"
variant="neutral"
phx-click="cancel_amount_change" phx-click="cancel_amount_change"
class="btn"
> >
{gettext("Cancel")} {gettext("Cancel")}
</button> </.button>
<button <.button
type="button" type="button"
variant="primary"
phx-click="confirm_amount_change" phx-click="confirm_amount_change"
class="btn btn-primary"
> >
{gettext("Confirm Change")} {gettext("Confirm Change")}
</button> </.button>
</div> </div>
</div> </div>
</dialog> </dialog>

View file

@ -78,24 +78,27 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
</:col> </:col>
<:action :let={mft}> <:action :let={mft}>
<.link <.tooltip content={gettext("Edit membership fee type")} position="left">
navigate={~p"/membership_fee_settings/#{mft.id}/edit_fee_type"} <.button
class="btn btn-ghost btn-xs" variant="ghost"
aria-label={gettext("Edit membership fee type")} size="sm"
> navigate={~p"/membership_fee_settings/#{mft.id}/edit_fee_type"}
<.icon name="hero-pencil" class="size-4" /> aria-label={gettext("Edit membership fee type")}
</.link> >
<.icon name="hero-pencil" class="size-4" />
</.button>
</.tooltip>
</:action> </:action>
<:action :let={mft}> <:action :let={mft}>
<div <.tooltip
:if={get_member_count(mft, @member_counts) > 0} :if={get_member_count(mft, @member_counts) > 0}
class="tooltip tooltip-left" content={
data-tip={
gettext("Cannot delete - %{count} member(s) assigned", gettext("Cannot delete - %{count} member(s) assigned",
count: get_member_count(mft, @member_counts) count: get_member_count(mft, @member_counts)
) )
} }
position="left"
> >
<button <button
phx-click="delete" phx-click="delete"
@ -111,17 +114,18 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
> >
<.icon name="hero-trash" class="size-4" /> <.icon name="hero-trash" class="size-4" />
</button> </button>
</div> </.tooltip>
<button <.button
:if={get_member_count(mft, @member_counts) == 0} :if={get_member_count(mft, @member_counts) == 0}
variant="danger"
size="sm"
phx-click="delete" phx-click="delete"
phx-value-id={mft.id} phx-value-id={mft.id}
data-confirm={gettext("Are you sure?")} data-confirm={gettext("Are you sure?")}
class="btn btn-ghost btn-xs text-error"
aria-label={gettext("Delete Membership Fee Type")} aria-label={gettext("Delete Membership Fee Type")}
> >
<.icon name="hero-trash" class="size-4" /> <.icon name="hero-trash" class="size-4" />
</button> </.button>
</:action> </:action>
</.table> </.table>

View file

@ -21,13 +21,23 @@ defmodule MvWeb.RoleLive.Form do
def render(assigns) do def render(assigns) do
~H""" ~H"""
<Layouts.app flash={@flash} current_user={@current_user}> <Layouts.app flash={@flash} current_user={@current_user}>
<.header>
{@page_title}
<:subtitle>{gettext("Use this form to manage roles in your database.")}</:subtitle>
</.header>
<.form class="max-w-xl" for={@form} id="role-form" phx-change="validate" phx-submit="save"> <.form class="max-w-xl" for={@form} id="role-form" phx-change="validate" phx-submit="save">
<.input field={@form[:name]} type="text" label={gettext("Name")} required /> <.header>
{@page_title}
<:subtitle>{gettext("Use this form to manage roles in your database.")}</:subtitle>
<:actions>
<.button navigate={return_path(@return_to, @role)} variant="neutral">
<.icon name="hero-arrow-left" class="size-4" />
{gettext("Back")}
</.button>
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save")}
</.button>
</:actions>
</.header>
<div class="mt-6 space-y-6">
<.input field={@form[:name]} type="text" label={gettext("Name")} required />
<.input <.input
field={@form[:description]} field={@form[:description]}
@ -73,14 +83,6 @@ defmodule MvWeb.RoleLive.Form do
<% end %> <% end %>
<% end %> <% end %>
</div> </div>
<div class="mt-4">
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save Role")}
</.button>
<.button navigate={return_path(@return_to, @role)} type="button">
{gettext("Cancel")}
</.button>
</div> </div>
</.form> </.form>
</Layouts.app> </Layouts.app>

View file

@ -59,28 +59,29 @@
</div> </div>
<%= if can?(@current_user, :update, Mv.Authorization.Role) do %> <%= if can?(@current_user, :update, Mv.Authorization.Role) do %>
<.link navigate={~p"/admin/roles/#{role}/edit"} class="btn btn-ghost btn-sm"> <.button variant="ghost" size="sm" navigate={~p"/admin/roles/#{role}/edit"}>
<.icon name="hero-pencil" class="size-4" /> <.icon name="hero-pencil" class="size-4" />
{gettext("Edit")} {gettext("Edit role")}
</.link> </.button>
<% end %> <% end %>
</:action> </:action>
<:action :let={role}> <:action :let={role}>
<%= if can?(@current_user, :destroy, Mv.Authorization.Role) and not role.is_system_role do %> <%= if can?(@current_user, :destroy, Mv.Authorization.Role) and not role.is_system_role do %>
<.link <.button
variant="danger"
size="sm"
phx-click={JS.push("delete", value: %{id: role.id}) |> hide("#row-#{role.id}")} phx-click={JS.push("delete", value: %{id: role.id}) |> hide("#row-#{role.id}")}
data-confirm={gettext("Are you sure?")} data-confirm={gettext("Are you sure?")}
class="btn btn-ghost btn-sm text-error"
> >
<.icon name="hero-trash" class="size-4" /> <.icon name="hero-trash" class="size-4" />
{gettext("Delete")} {gettext("Delete")}
</.link> </.button>
<% else %> <% else %>
<div <.tooltip
:if={role.is_system_role} :if={role.is_system_role}
class="tooltip tooltip-left" content={gettext("System roles cannot be deleted")}
data-tip={gettext("System roles cannot be deleted")} position="left"
> >
<button <button
class="btn btn-ghost btn-sm text-error opacity-50 cursor-not-allowed" class="btn btn-ghost btn-sm text-error opacity-50 cursor-not-allowed"
@ -90,7 +91,7 @@
<.icon name="hero-trash" class="size-4" /> <.icon name="hero-trash" class="size-4" />
{gettext("Delete")} {gettext("Delete")}
</button> </button>
</div> </.tooltip>
<% end %> <% end %>
</:action> </:action>
</.table> </.table>

View file

@ -165,23 +165,23 @@ defmodule MvWeb.RoleLive.Show do
<:subtitle>{gettext("Role details and permissions.")}</:subtitle> <:subtitle>{gettext("Role details and permissions.")}</:subtitle>
<:actions> <:actions>
<.button navigate={~p"/admin/roles"} aria-label={gettext("Back to roles list")}> <.button navigate={~p"/admin/roles"} variant="neutral" aria-label={gettext("Back to roles list")}>
<.icon name="hero-arrow-left" /> <.icon name="hero-arrow-left" class="size-4" />
<span class="sr-only">{gettext("Back to roles list")}</span> {gettext("Back")}
</.button> </.button>
<%= if can?(@current_user, :update, Mv.Authorization.Role) do %> <%= if can?(@current_user, :update, Mv.Authorization.Role) do %>
<.button variant="primary" navigate={~p"/admin/roles/#{@role}/edit"}> <.button variant="primary" navigate={~p"/admin/roles/#{@role}/edit"}>
<.icon name="hero-pencil-square" /> {gettext("Edit Role")} <.icon name="hero-pencil-square" /> {gettext("Rolle bearbeiten")}
</.button> </.button>
<% end %> <% end %>
<%= if can?(@current_user, :destroy, Mv.Authorization.Role) and not @role.is_system_role do %> <%= if can?(@current_user, :destroy, Mv.Authorization.Role) and not @role.is_system_role do %>
<.link <.button
variant="danger"
phx-click={JS.push("delete", value: %{id: @role.id})} phx-click={JS.push("delete", value: %{id: @role.id})}
data-confirm={gettext("Are you sure?")} data-confirm={gettext("Are you sure?")}
class="btn btn-error"
> >
<.icon name="hero-trash" /> {gettext("Delete Role")} <.icon name="hero-trash" /> {gettext("Delete Role")}
</.link> </.button>
<% end %> <% end %>
</:actions> </:actions>
</.header> </.header>

View file

@ -167,13 +167,14 @@ defmodule MvWeb.UserLive.Form do
</p> </p>
<p class="text-sm text-green-700">{@user.member.email}</p> <p class="text-sm text-green-700">{@user.member.email}</p>
</div> </div>
<button <.button
type="button" type="button"
variant="danger"
size="sm"
phx-click="unlink_member" phx-click="unlink_member"
class="btn btn-sm btn-error"
> >
{gettext("Unlink Member")} {gettext("Unlink Member")}
</button> </.button>
</div> </div>
</div> </div>
<% else %> <% else %>
@ -281,10 +282,12 @@ defmodule MvWeb.UserLive.Form do
<% end %> <% end %>
<div class="mt-4"> <div class="mt-4">
<.button navigate={return_path(@return_to, @user)} variant="neutral">
{gettext("Cancel")}
</.button>
<.button phx-disable-with={gettext("Saving...")} variant="primary"> <.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save User")} {gettext("Save User")}
</.button> </.button>
<.button navigate={return_path(@return_to, @user)}>{gettext("Cancel")}</.button>
</div> </div>
</.form> </.form>
</Layouts.app> </Layouts.app>

View file

@ -91,7 +91,7 @@
<%= if can?(@current_user, :update, user) do %> <%= if can?(@current_user, :update, user) do %>
<.link navigate={~p"/users/#{user}/edit"} data-testid="user-edit"> <.link navigate={~p"/users/#{user}/edit"} data-testid="user-edit">
{gettext("Edit")} {gettext("Edit user")}
</.link> </.link>
<% end %> <% end %>
</:action> </:action>

View file

@ -37,7 +37,7 @@ defmodule MvWeb.UserLive.Show do
<:subtitle>{gettext("This is a user record from your database.")}</:subtitle> <:subtitle>{gettext("This is a user record from your database.")}</:subtitle>
<:actions> <:actions>
<.button navigate={~p"/users"} aria-label={gettext("Back to users list")}> <.button navigate={~p"/users"} variant="neutral" aria-label={gettext("Back to users list")}>
<.icon name="hero-arrow-left" /> <.icon name="hero-arrow-left" />
<span class="sr-only">{gettext("Back to users list")}</span> <span class="sr-only">{gettext("Back to users list")}</span>
</.button> </.button>