Merge branch 'main' into feature/278_membership_fee_settings
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Moritz 2025-12-16 15:12:51 +01:00
commit 651f518215
Signed by: moritz
GPG key ID: 1020A035E5DD0824
19 changed files with 278 additions and 156 deletions

View file

@ -95,9 +95,11 @@ defmodule MvWeb.CoreComponents do
<.button>Send!</.button>
<.button phx-click="go" variant="primary">Send!</.button>
<.button navigate={~p"/"}>Home</.button>
<.button disabled={true}>Disabled</.button>
"""
attr :rest, :global, include: ~w(href navigate patch method)
attr :variant, :string, values: ~w(primary)
attr :disabled, :boolean, default: false, doc: "Whether the button is disabled"
slot :inner_block, required: true
def button(%{rest: rest} = assigns) do
@ -105,14 +107,37 @@ defmodule MvWeb.CoreComponents do
assigns = assign(assigns, :class, Map.fetch!(variants, assigns[:variant]))
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 =
if assigns[:disabled],
do: ["btn", assigns.class, "btn-disabled"],
else: ["btn", assigns.class]
# Prevent interaction when disabled
# Remove navigation attributes to prevent "Open in new tab", "Copy link" etc.
link_attrs =
if assigns[:disabled] do
rest
|> Map.drop([:href, :navigate, :patch])
|> Map.merge(%{tabindex: "-1", "aria-disabled": "true"})
else
rest
end
assigns =
assigns
|> assign(:link_class, link_class)
|> assign(:link_attrs, link_attrs)
~H"""
<.link class={["btn", @class]} {@rest}>
<.link class={@link_class} {@link_attrs}>
{render_slot(@inner_block)}
</.link>
"""
else
~H"""
<button class={["btn", @class]} {@rest}>
<button class={["btn", @class]} disabled={@disabled} {@rest}>
{render_slot(@inner_block)}
</button>
"""

View file

@ -36,12 +36,16 @@ defmodule MvWeb.Layouts do
default: nil,
doc: "the current [scope](https://hexdocs.pm/phoenix/scopes.html)"
attr :club_name, :string,
default: nil,
doc: "optional club name to pass to navbar"
slot :inner_block, required: true
def app(assigns) do
~H"""
<%= if @current_user do %>
<.navbar current_user={@current_user} />
<.navbar current_user={@current_user} club_name={@club_name} />
<% end %>
<main class="px-4 py-20 sm:px-6 lg:px-16">
<div class="mx-auto max-full space-y-4">

View file

@ -12,15 +12,18 @@ defmodule MvWeb.Layouts.Navbar do
required: true,
doc: "The current user - navbar is only shown when user is present"
def navbar(assigns) do
club_name = get_club_name()
attr :club_name, :string,
default: nil,
doc: "Optional club name - if not provided, will be loaded from database"
def navbar(assigns) do
club_name = assigns[:club_name] || get_club_name()
assigns = assign(assigns, :club_name, club_name)
~H"""
<header class="navbar bg-base-100 shadow-sm">
<div class="flex-1">
<a class="btn btn-ghost text-xl">{@club_name}</a>
<a href="/members" class="btn btn-ghost text-xl">{@club_name}</a>
<ul class="menu menu-horizontal bg-base-200">
<li><.link navigate="/members">{gettext("Members")}</.link></li>
<li><.link navigate="/settings">{gettext("Settings")}</.link></li>

View file

@ -77,7 +77,7 @@ defmodule MvWeb.Components.PaymentFilterComponent do
phx-target={@myself}
>
<.icon name="hero-users" class="h-4 w-4" />
{gettext("All")}
{gettext("All payment statuses")}
</button>
</li>
<li role="none">
@ -140,7 +140,7 @@ defmodule MvWeb.Components.PaymentFilterComponent do
defp parse_filter(_), do: nil
# Get display label for current filter
defp filter_label(nil), do: gettext("All")
defp filter_label(nil), do: gettext("All payment statuses")
defp filter_label(:paid), do: gettext("Paid")
defp filter_label(:not_paid), do: gettext("Not paid")
end

View file

@ -6,7 +6,7 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
- Create new custom field definitions
- Edit existing custom fields
- Select value type from supported types
- Set immutable and required flags
- Set required flag
- Real-time validation
## Props
@ -50,10 +50,10 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
label={gettext("Value type")}
options={
Ash.Resource.Info.attribute(Mv.Membership.CustomField, :value_type).constraints[:one_of]
|> Enum.map(fn type -> {MvWeb.Translations.FieldTypes.label(type), type} end)
}
/>
<.input field={@form[:description]} type="text" label={gettext("Description")} />
<.input field={@form[:immutable]} type="checkbox" label={gettext("Immutable")} />
<.input field={@form[:required]} type="checkbox" label={gettext("Required")} />
<.input
field={@form[:show_in_overview]}
@ -66,7 +66,7 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
{gettext("Cancel")}
</.button>
<.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save Custom field")}
{gettext("Save Custom Field")}
</.button>
</div>
</.form>

View file

@ -5,7 +5,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
## Features
- List all custom fields
- Display type information (name, value type, description)
- Show immutable and required flags
- Show required flag
- Create new custom fields
- Edit existing custom fields
- Delete custom fields with confirmation (cascades to all custom field values)
@ -30,7 +30,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
phx-click="new_custom_field"
phx-target={@myself}
>
<.icon name="hero-plus" /> {gettext("New Custom field")}
<.icon name="hero-plus" /> {gettext("New Custom Field")}
</.button>
</div>
</div>

View file

@ -72,7 +72,7 @@ defmodule MvWeb.CustomFieldValueLive.Form do
<% end %>
<.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save Custom field value")}
{gettext("Save Custom Field Value")}
</.button>
<.button navigate={return_path(@return_to, @custom_field_value)}>{gettext("Cancel")}</.button>
</.form>

View file

@ -37,7 +37,7 @@ defmodule MvWeb.GlobalSettingsLive do
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<Layouts.app flash={@flash} current_user={@current_user} club_name={@settings.club_name}>
<.header>
{gettext("Settings")}
<:subtitle>
@ -80,10 +80,13 @@ defmodule MvWeb.GlobalSettingsLive do
@impl true
def handle_event("save", %{"setting" => setting_params}, socket) do
case AshPhoenix.Form.submit(socket.assigns.form, params: setting_params) do
{:ok, updated_settings} ->
{:ok, _updated_settings} ->
# Reload settings from database to ensure all dependent data is updated
{:ok, fresh_settings} = Membership.get_settings()
socket =
socket
|> assign(:settings, updated_settings)
|> assign(:settings, fresh_settings)
|> put_flash(:info, gettext("Settings updated successfully"))
|> assign_form()

View file

@ -145,7 +145,10 @@ defmodule MvWeb.MemberLive.Index do
MapSet.put(socket.assigns.selected_members, id)
end
{:noreply, assign(socket, :selected_members, selected)}
{:noreply,
socket
|> assign(:selected_members, selected)
|> update_selection_assigns()}
end
@impl true
@ -159,7 +162,10 @@ defmodule MvWeb.MemberLive.Index do
all_ids
end
{:noreply, assign(socket, :selected_members, selected)}
{:noreply,
socket
|> assign(:selected_members, selected)
|> update_selection_assigns()}
end
@impl true
@ -238,6 +244,7 @@ defmodule MvWeb.MemberLive.Index do
socket
|> assign(:query, q)
|> load_members()
|> update_selection_assigns()
existing_field_query = socket.assigns.sort_field
existing_sort_query = socket.assigns.sort_order
@ -263,6 +270,7 @@ defmodule MvWeb.MemberLive.Index do
socket
|> assign(:paid_filter, filter)
|> load_members()
|> update_selection_assigns()
# Build the URL with all params including new filter
query_params =
@ -309,6 +317,7 @@ defmodule MvWeb.MemberLive.Index do
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|> load_members()
|> prepare_dynamic_cols()
|> update_selection_assigns()
|> push_field_selection_url()
{:noreply, socket}
@ -338,6 +347,7 @@ defmodule MvWeb.MemberLive.Index do
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|> load_members()
|> prepare_dynamic_cols()
|> update_selection_assigns()
|> push_field_selection_url()
{:noreply, socket}
@ -389,6 +399,7 @@ defmodule MvWeb.MemberLive.Index do
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|> load_members()
|> prepare_dynamic_cols()
|> update_selection_assigns()
{:noreply, socket}
end
@ -1112,4 +1123,34 @@ defmodule MvWeb.MemberLive.Index do
# Public helper function to format dates for use in templates
def format_date(date), do: DateFormatter.format_date(date)
# Updates selection-related assigns (selected_count, any_selected?, mailto_bcc)
# to avoid recalculating Enum.any? and Enum.count multiple times in templates.
#
# Note: Mailto URLs have length limits that vary by email client.
# For large selections, consider using export functionality instead.
defp update_selection_assigns(socket) do
members = socket.assigns.members
selected_members = socket.assigns.selected_members
selected_count =
Enum.count(members, &MapSet.member?(selected_members, &1.id))
any_selected? =
Enum.any?(members, &MapSet.member?(selected_members, &1.id))
mailto_bcc =
if any_selected? do
format_selected_member_emails(members, selected_members)
|> Enum.join(", ")
|> URI.encode_www_form()
else
""
end
socket
|> assign(:selected_count, selected_count)
|> assign(:any_selected?, any_selected?)
|> assign(:mailto_bcc, mailto_bcc)
end
end

View file

@ -3,23 +3,21 @@
{gettext("Members")}
<:actions>
<.button
:if={Enum.any?(@members, &MapSet.member?(@selected_members, &1.id))}
class="secondary"
id="copy-emails-btn"
phx-hook="CopyToClipboard"
phx-click="copy_emails"
disabled={not @any_selected?}
aria-label={gettext("Copy email addresses of selected members")}
>
<.icon name="hero-clipboard-document" />
{gettext("Copy emails")} ({Enum.count(@members, &MapSet.member?(@selected_members, &1.id))})
{gettext("Copy email addresses")} ({@selected_count})
</.button>
<.button
:if={Enum.any?(@members, &MapSet.member?(@selected_members, &1.id))}
href={
"mailto:?bcc=" <>
(MvWeb.MemberLive.Index.format_selected_member_emails(@members, @selected_members)
|> Enum.join(", ")
|> URI.encode())
}
class="secondary"
id="open-email-btn"
href={"mailto:?bcc=" <> @mailto_bcc}
disabled={not @any_selected?}
aria-label={gettext("Open email program with BCC recipients")}
>
<.icon name="hero-envelope" />