Merge branch 'main' into feat/299_plz
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
commit
63040afee7
68 changed files with 4858 additions and 743 deletions
|
|
@ -23,6 +23,9 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
require Ash.Query
|
||||
import Ash.Expr
|
||||
|
||||
alias Mv.Membership
|
||||
|
||||
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
||||
|
|
@ -41,11 +44,23 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
|> assign(:settings, settings)
|
||||
|> assign(:active_editing_section, nil)
|
||||
|> assign(:locale, locale)
|
||||
|> assign(:vereinfacht_env_configured, Mv.Config.vereinfacht_env_configured?())
|
||||
|> assign(:vereinfacht_api_url_env_set, Mv.Config.vereinfacht_api_url_env_set?())
|
||||
|> assign(:vereinfacht_api_key_env_set, Mv.Config.vereinfacht_api_key_env_set?())
|
||||
|> assign(:vereinfacht_club_id_env_set, Mv.Config.vereinfacht_club_id_env_set?())
|
||||
|> assign(:vereinfacht_app_url_env_set, Mv.Config.vereinfacht_app_url_env_set?())
|
||||
|> assign(:vereinfacht_api_key_set, present?(settings.vereinfacht_api_key))
|
||||
|> assign(:last_vereinfacht_sync_result, nil)
|
||||
|> assign_form()
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
defp present?(nil), do: false
|
||||
defp present?(""), do: false
|
||||
defp present?(s) when is_binary(s), do: String.trim(s) != ""
|
||||
defp present?(_), do: false
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
|
|
@ -74,6 +89,98 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
</.button>
|
||||
</.form>
|
||||
</.form_section>
|
||||
<%!-- Vereinfacht Integration Section --%>
|
||||
<.form_section title={gettext("Vereinfacht Integration")}>
|
||||
<%= if @vereinfacht_env_configured do %>
|
||||
<p class="text-sm text-base-content/70 mb-4">
|
||||
{gettext("Some values are set via environment variables. Those fields are read-only.")}
|
||||
</p>
|
||||
<% end %>
|
||||
<.form for={@form} id="vereinfacht-form" phx-change="validate" phx-submit="save">
|
||||
<div class="grid gap-4">
|
||||
<.input
|
||||
field={@form[:vereinfacht_api_url]}
|
||||
type="text"
|
||||
label={gettext("API URL")}
|
||||
disabled={@vereinfacht_api_url_env_set}
|
||||
placeholder={
|
||||
if(@vereinfacht_api_url_env_set,
|
||||
do: gettext("From VEREINFACHT_API_URL"),
|
||||
else: "https://api.verein.visuel.dev/api/v1"
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div class="form-control">
|
||||
<label class="label" for={@form[:vereinfacht_api_key].id}>
|
||||
<span class="label-text">{gettext("API Key")}</span>
|
||||
<%= if @vereinfacht_api_key_set do %>
|
||||
<span class="label-text-alt badge badge-ghost">{gettext("(set)")}</span>
|
||||
<% end %>
|
||||
</label>
|
||||
<.input
|
||||
field={@form[:vereinfacht_api_key]}
|
||||
type="password"
|
||||
label=""
|
||||
disabled={@vereinfacht_api_key_env_set}
|
||||
placeholder={
|
||||
if(@vereinfacht_api_key_env_set,
|
||||
do: gettext("From VEREINFACHT_API_KEY"),
|
||||
else:
|
||||
if(@vereinfacht_api_key_set,
|
||||
do: gettext("Leave blank to keep current"),
|
||||
else: nil
|
||||
)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<.input
|
||||
field={@form[:vereinfacht_club_id]}
|
||||
type="text"
|
||||
label={gettext("Club ID")}
|
||||
disabled={@vereinfacht_club_id_env_set}
|
||||
placeholder={
|
||||
if(@vereinfacht_club_id_env_set, do: gettext("From VEREINFACHT_CLUB_ID"), else: "2")
|
||||
}
|
||||
/>
|
||||
<.input
|
||||
field={@form[:vereinfacht_app_url]}
|
||||
type="text"
|
||||
label={gettext("App URL (contact view link)")}
|
||||
disabled={@vereinfacht_app_url_env_set}
|
||||
placeholder={
|
||||
if(@vereinfacht_app_url_env_set,
|
||||
do: gettext("From VEREINFACHT_APP_URL"),
|
||||
else: "https://app.verein.visuel.dev"
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<.button
|
||||
:if={
|
||||
not (@vereinfacht_api_url_env_set and @vereinfacht_api_key_env_set and
|
||||
@vereinfacht_club_id_env_set)
|
||||
}
|
||||
phx-disable-with={gettext("Saving...")}
|
||||
variant="primary"
|
||||
class="mt-2"
|
||||
>
|
||||
{gettext("Save Vereinfacht Settings")}
|
||||
</.button>
|
||||
<.button
|
||||
:if={Mv.Config.vereinfacht_configured?()}
|
||||
type="button"
|
||||
phx-click="sync_vereinfacht_contacts"
|
||||
phx-disable-with={gettext("Syncing...")}
|
||||
class="mt-4 btn-outline"
|
||||
>
|
||||
{gettext("Sync all members without Vereinfacht contact")}
|
||||
</.button>
|
||||
<%= if @last_vereinfacht_sync_result do %>
|
||||
<.vereinfacht_sync_result result={@last_vereinfacht_sync_result} />
|
||||
<% end %>
|
||||
</.form>
|
||||
</.form_section>
|
||||
<%!-- Memberdata Section --%>
|
||||
<.form_section title={gettext("Memberdata")}>
|
||||
<.live_component
|
||||
|
|
@ -100,18 +207,54 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, setting_params))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("sync_vereinfacht_contacts", _params, socket) do
|
||||
case Mv.Vereinfacht.sync_members_without_contact() do
|
||||
{:ok, %{synced: synced, errors: errors}} ->
|
||||
errors_with_names = enrich_sync_errors(errors)
|
||||
result = %{synced: synced, errors: errors_with_names}
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:last_vereinfacht_sync_result, result)
|
||||
|> put_flash(
|
||||
:info,
|
||||
if(errors_with_names == [],
|
||||
do: gettext("Synced %{count} member(s) to Vereinfacht.", count: synced),
|
||||
else:
|
||||
gettext("Synced %{count} member(s). %{error_count} failed.",
|
||||
count: synced,
|
||||
error_count: length(errors_with_names)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, :not_configured} ->
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("Vereinfacht is not configured. Set API URL, API Key, and Club ID.")
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("save", %{"setting" => setting_params}, socket) do
|
||||
actor = MvWeb.LiveHelpers.current_actor(socket)
|
||||
# Never send blank API key so we do not overwrite the stored secret (security)
|
||||
setting_params_clean = drop_blank_vereinfacht_api_key(setting_params)
|
||||
|
||||
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, setting_params, actor) do
|
||||
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, setting_params_clean, actor) do
|
||||
{:ok, _updated_settings} ->
|
||||
# Reload settings from database to ensure all dependent data is updated
|
||||
{:ok, fresh_settings} = Membership.get_settings()
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:settings, fresh_settings)
|
||||
|> assign(:vereinfacht_api_key_set, present?(fresh_settings.vereinfacht_api_key))
|
||||
|> put_flash(:info, gettext("Settings updated successfully"))
|
||||
|> assign_form()
|
||||
|
||||
|
|
@ -122,6 +265,16 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
end
|
||||
end
|
||||
|
||||
defp drop_blank_vereinfacht_api_key(params) when is_map(params) do
|
||||
case params do
|
||||
%{"vereinfacht_api_key" => v} when v in [nil, ""] ->
|
||||
Map.delete(params, "vereinfacht_api_key")
|
||||
|
||||
_ ->
|
||||
params
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:custom_field_saved, _custom_field, action}, socket) do
|
||||
send_update(MvWeb.CustomFieldLive.IndexComponent,
|
||||
|
|
@ -202,9 +355,12 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
end
|
||||
|
||||
defp assign_form(%{assigns: %{settings: settings}} = socket) do
|
||||
# Never put API key into form/DOM to avoid secret leak in source or DevTools
|
||||
settings_for_form = %{settings | vereinfacht_api_key: nil}
|
||||
|
||||
form =
|
||||
AshPhoenix.Form.for_update(
|
||||
settings,
|
||||
settings_for_form,
|
||||
:update,
|
||||
api: Membership,
|
||||
as: "setting",
|
||||
|
|
@ -213,4 +369,74 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
|
||||
assign(socket, form: to_form(form))
|
||||
end
|
||||
|
||||
defp enrich_sync_errors([]), do: []
|
||||
|
||||
defp enrich_sync_errors(errors) when is_list(errors) do
|
||||
name_by_id = fetch_member_names_by_ids(Enum.map(errors, fn {id, _} -> id end))
|
||||
|
||||
Enum.map(errors, fn {member_id, reason} ->
|
||||
%{
|
||||
member_id: member_id,
|
||||
member_name: Map.get(name_by_id, member_id) || to_string(member_id),
|
||||
message: Mv.Vereinfacht.format_error(reason),
|
||||
detail: extract_vereinfacht_detail(reason)
|
||||
}
|
||||
end)
|
||||
end
|
||||
|
||||
defp fetch_member_names_by_ids(ids) do
|
||||
actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
opts = Mv.Helpers.ash_actor_opts(actor)
|
||||
query = Ash.Query.filter(Mv.Membership.Member, expr(id in ^ids))
|
||||
|
||||
case Ash.read(query, opts) do
|
||||
{:ok, members} ->
|
||||
Map.new(members, fn m -> {m.id, MvWeb.Helpers.MemberHelpers.display_name(m)} end)
|
||||
|
||||
_ ->
|
||||
%{}
|
||||
end
|
||||
end
|
||||
|
||||
defp extract_vereinfacht_detail({:http, _status, detail}) when is_binary(detail), do: detail
|
||||
defp extract_vereinfacht_detail(_), do: nil
|
||||
|
||||
defp translate_vereinfacht_message(%{detail: detail}) when is_binary(detail) do
|
||||
gettext("Vereinfacht: %{detail}",
|
||||
detail: Gettext.dgettext(MvWeb.Gettext, "default", detail)
|
||||
)
|
||||
end
|
||||
|
||||
defp translate_vereinfacht_message(%{message: message}) do
|
||||
Gettext.dgettext(MvWeb.Gettext, "default", message)
|
||||
end
|
||||
|
||||
attr :result, :map, required: true
|
||||
|
||||
defp vereinfacht_sync_result(assigns) do
|
||||
~H"""
|
||||
<div class="mt-4 p-4 rounded-lg border border-base-300 bg-base-200 space-y-2">
|
||||
<p class="font-medium">
|
||||
{gettext("Last sync result:")}
|
||||
<span class="text-success-aa ml-1">{gettext("%{count} synced", count: @result.synced)}</span>
|
||||
<%= if @result.errors != [] do %>
|
||||
<span class="text-error-aa ml-1">
|
||||
{gettext("%{count} failed", count: length(@result.errors))}
|
||||
</span>
|
||||
<% end %>
|
||||
</p>
|
||||
<%= if @result.errors != [] do %>
|
||||
<p class="text-sm text-base-content/70 mt-2">{gettext("Failed members:")}</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 max-h-48 overflow-y-auto">
|
||||
<%= for err <- @result.errors do %>
|
||||
<li>
|
||||
<span class="font-medium">{err.member_name}</span>: {translate_vereinfacht_message(err)}
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -17,10 +17,12 @@ defmodule MvWeb.GroupLive.Show do
|
|||
|
||||
require Logger
|
||||
|
||||
import Ash.Expr
|
||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||
import MvWeb.Authorization
|
||||
|
||||
alias Mv.Membership
|
||||
alias MvWeb.Helpers.MemberHelpers, as: MemberHelpers
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
|
|
@ -29,6 +31,7 @@ defmodule MvWeb.GroupLive.Show do
|
|||
|> assign(:show_add_member_input, false)
|
||||
|> assign(:member_search_query, "")
|
||||
|> assign(:available_members, [])
|
||||
|> assign(:add_member_candidates, [])
|
||||
|> assign(:selected_member_ids, [])
|
||||
|> assign(:selected_members, [])
|
||||
|> assign(:show_member_dropdown, false)
|
||||
|
|
@ -94,13 +97,21 @@ defmodule MvWeb.GroupLive.Show do
|
|||
</h1>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<%= if can?(@current_user, :update, Mv.Membership.Group) do %>
|
||||
<.button variant="primary" navigate={~p"/groups/#{@group.slug}/edit"}>
|
||||
<%= if can?(@current_user, :update, @group) do %>
|
||||
<.button
|
||||
variant="primary"
|
||||
navigate={~p"/groups/#{@group.slug}/edit"}
|
||||
data-testid="group-show-edit-btn"
|
||||
>
|
||||
{gettext("Edit")}
|
||||
</.button>
|
||||
<% end %>
|
||||
<%= if can?(@current_user, :destroy, Mv.Membership.Group) do %>
|
||||
<.button class="btn-error" phx-click="open_delete_modal">
|
||||
<%= if can?(@current_user, :destroy, @group) do %>
|
||||
<.button
|
||||
class="btn-error"
|
||||
phx-click="open_delete_modal"
|
||||
data-testid="group-show-delete-btn"
|
||||
>
|
||||
{gettext("Delete")}
|
||||
</.button>
|
||||
<% end %>
|
||||
|
|
@ -123,7 +134,7 @@ defmodule MvWeb.GroupLive.Show do
|
|||
<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">
|
||||
<p class="mb-4" data-testid="group-show-member-count">
|
||||
{ngettext(
|
||||
"Total: %{count} member",
|
||||
"Total: %{count} members",
|
||||
|
|
@ -132,7 +143,7 @@ defmodule MvWeb.GroupLive.Show do
|
|||
)}
|
||||
</p>
|
||||
|
||||
<%= if can?(@current_user, :update, Mv.Membership.Group) do %>
|
||||
<%= if can?(@current_user, :update, @group) do %>
|
||||
<div class="mb-4">
|
||||
<%= if assigns[:show_add_member_input] do %>
|
||||
<div class="join w-full">
|
||||
|
|
@ -160,6 +171,7 @@ defmodule MvWeb.GroupLive.Show do
|
|||
<input
|
||||
type="text"
|
||||
id="member-search-input"
|
||||
data-testid="group-show-member-search-input"
|
||||
role="combobox"
|
||||
phx-hook="ComboBox"
|
||||
phx-focus="show_member_dropdown"
|
||||
|
|
@ -228,6 +240,7 @@ defmodule MvWeb.GroupLive.Show do
|
|||
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")}
|
||||
>
|
||||
|
|
@ -255,15 +268,17 @@ defmodule MvWeb.GroupLive.Show do
|
|||
<% end %>
|
||||
|
||||
<%= if Enum.empty?(@group.members || []) do %>
|
||||
<p class="text-base-content/50 italic">{gettext("No members in this group")}</p>
|
||||
<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">
|
||||
<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, Mv.Membership.Group) do %>
|
||||
<%= if can?(@current_user, :update, @group) do %>
|
||||
<th class="w-0">{gettext("Actions")}</th>
|
||||
<% end %>
|
||||
</tr>
|
||||
|
|
@ -291,13 +306,14 @@ defmodule MvWeb.GroupLive.Show do
|
|||
<span class="text-base-content/50 italic">—</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<%= if can?(@current_user, :update, Mv.Membership.Group) do %>
|
||||
<%= if can?(@current_user, :update, @group) do %>
|
||||
<td>
|
||||
<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")}
|
||||
>
|
||||
|
|
@ -431,28 +447,31 @@ defmodule MvWeb.GroupLive.Show do
|
|||
# Add Member Events
|
||||
@impl true
|
||||
def handle_event("show_add_member_input", _params, socket) do
|
||||
# Reload group to ensure we have the latest members list
|
||||
actor = current_actor(socket)
|
||||
group = socket.assigns.group
|
||||
socket = reload_group(socket, group.slug, actor)
|
||||
# Load candidate members once (single DB read). Search/focus then filter in memory (R2).
|
||||
socket =
|
||||
socket
|
||||
|> assign(:show_add_member_input, true)
|
||||
|> assign(:member_search_query, "")
|
||||
|> assign(:selected_member_ids, [])
|
||||
|> assign(:selected_members, [])
|
||||
|> assign(:show_member_dropdown, false)
|
||||
|> assign(:focused_member_index, nil)
|
||||
|> load_add_member_candidates()
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:show_add_member_input, true)
|
||||
|> assign(:member_search_query, "")
|
||||
|> assign(:available_members, [])
|
||||
|> assign(:selected_member_ids, [])
|
||||
|> assign(:selected_members, [])
|
||||
|> assign(:show_member_dropdown, false)
|
||||
|> assign(:focused_member_index, nil)}
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("show_member_dropdown", _params, socket) do
|
||||
# Use existing group.members for filtering; reload only on add/remove
|
||||
# Filter in memory from preloaded candidates; no DB read (R2).
|
||||
query = socket.assigns.member_search_query || ""
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> load_available_members("")
|
||||
|> assign(
|
||||
:available_members,
|
||||
filter_candidates_in_memory(socket.assigns.add_member_candidates, query)
|
||||
)
|
||||
|> assign(:show_member_dropdown, true)
|
||||
|> assign(:focused_member_index, nil)
|
||||
|
||||
|
|
@ -466,6 +485,7 @@ defmodule MvWeb.GroupLive.Show do
|
|||
|> assign(:show_add_member_input, false)
|
||||
|> assign(:member_search_query, "")
|
||||
|> assign(:available_members, [])
|
||||
|> assign(:add_member_candidates, [])
|
||||
|> assign(:selected_member_ids, [])
|
||||
|> assign(:selected_members, [])
|
||||
|> assign(:show_member_dropdown, false)
|
||||
|
|
@ -532,11 +552,13 @@ defmodule MvWeb.GroupLive.Show do
|
|||
|
||||
@impl true
|
||||
def handle_event("search_members", %{"member_search" => query}, socket) do
|
||||
# Use existing group.members for filtering; reload only on add/remove
|
||||
# Filter in memory from preloaded candidates; no DB read (R2).
|
||||
candidates = socket.assigns.add_member_candidates || []
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:member_search_query, query)
|
||||
|> load_available_members(query)
|
||||
|> assign(:available_members, filter_candidates_in_memory(candidates, query))
|
||||
|> assign(:show_member_dropdown, true)
|
||||
|> assign(:focused_member_index, nil)
|
||||
|
||||
|
|
@ -660,47 +682,69 @@ defmodule MvWeb.GroupLive.Show do
|
|||
end
|
||||
end
|
||||
|
||||
defp load_available_members(socket, query) do
|
||||
# Load candidate members once when opening add-member UI (single DB read).
|
||||
defp load_add_member_candidates(socket) do
|
||||
require Ash.Query
|
||||
|
||||
current_member_ids = group_member_ids_set(socket.assigns.group)
|
||||
base_query = available_members_base_query(query)
|
||||
|
||||
# Fetch more than 10, then exclude already-in-group and take 10 (avoids empty dropdown when first N are all in group)
|
||||
fetch_limit = 50
|
||||
limited_query = Ash.Query.limit(base_query, fetch_limit)
|
||||
group = socket.assigns.group
|
||||
exclude_ids = group_member_ids_set(group) |> MapSet.to_list()
|
||||
actor = current_actor(socket)
|
||||
|
||||
case Ash.read(limited_query, actor: actor, domain: Mv.Membership) do
|
||||
{:ok, members} ->
|
||||
available =
|
||||
members
|
||||
|> Enum.reject(fn m -> MapSet.member?(current_member_ids, m.id) end)
|
||||
|> Enum.take(10)
|
||||
if exclude_ids == [] do
|
||||
# No members in group; load first N members
|
||||
query =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Query.sort([:last_name, :first_name])
|
||||
|> Ash.Query.limit(300)
|
||||
|
||||
assign(socket, available_members: available)
|
||||
do_load_add_member_candidates(socket, query, actor)
|
||||
else
|
||||
query =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Query.filter(expr(id not in ^exclude_ids))
|
||||
|> Ash.Query.sort([:last_name, :first_name])
|
||||
|> Ash.Query.limit(300)
|
||||
|
||||
do_load_add_member_candidates(socket, query, actor)
|
||||
end
|
||||
end
|
||||
|
||||
defp do_load_add_member_candidates(socket, query, actor) do
|
||||
case Ash.read(query, actor: actor, domain: Mv.Membership) do
|
||||
{:ok, candidates} ->
|
||||
socket
|
||||
|> assign(:add_member_candidates, candidates)
|
||||
|> assign(:available_members, Enum.take(candidates, 10))
|
||||
|
||||
{:error, error} ->
|
||||
Logger.warning("Failed to load available members for group: #{inspect(error)}")
|
||||
Logger.warning("Failed to load add-member candidates: #{inspect(error)}")
|
||||
|
||||
socket
|
||||
|> put_flash(:error, gettext("Could not load member search. Please try again."))
|
||||
|> put_flash(:error, gettext("Could not load member list. Please try again."))
|
||||
|> assign(:add_member_candidates, [])
|
||||
|> assign(:available_members, [])
|
||||
end
|
||||
end
|
||||
|
||||
defp available_members_base_query(query) do
|
||||
search_query = if query && String.trim(query) != "", do: String.trim(query), else: nil
|
||||
# Filter preloaded candidates by query string (name/email). No DB read. R2.
|
||||
defp filter_candidates_in_memory(candidates, query) when is_list(candidates) do
|
||||
q = if is_binary(query), do: String.trim(query) |> String.downcase(), else: ""
|
||||
|
||||
if search_query do
|
||||
Mv.Membership.Member
|
||||
|> Ash.Query.for_read(:search, %{query: search_query})
|
||||
if q == "" do
|
||||
candidates |> Enum.take(10)
|
||||
else
|
||||
Mv.Membership.Member
|
||||
|> Ash.Query.new()
|
||||
candidates
|
||||
|> Enum.filter(fn m ->
|
||||
name = MemberHelpers.display_name(m) |> String.downcase()
|
||||
email = (m.email || "") |> String.downcase()
|
||||
String.contains?(name, q) or String.contains?(email, q)
|
||||
end)
|
||||
|> Enum.take(10)
|
||||
end
|
||||
end
|
||||
|
||||
defp filter_candidates_in_memory(_, _), do: []
|
||||
|
||||
defp group_member_ids_set(group) do
|
||||
members = group.members || []
|
||||
members |> Enum.map(& &1.id) |> MapSet.new()
|
||||
|
|
@ -740,6 +784,7 @@ defmodule MvWeb.GroupLive.Show do
|
|||
|> assign(:show_add_member_input, false)
|
||||
|> assign(:member_search_query, "")
|
||||
|> assign(:available_members, [])
|
||||
|> assign(:add_member_candidates, [])
|
||||
|> assign(:selected_member_ids, [])
|
||||
|> assign(:selected_members, [])
|
||||
|> assign(:show_member_dropdown, false)
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
|
|||
- `on_cancel` - Callback function to call when form is cancelled
|
||||
|
||||
## Note
|
||||
Member fields are technical fields that cannot be changed (name, value_type, description, required).
|
||||
Only the visibility (show_in_overview) can be modified.
|
||||
Member fields are technical fields that cannot be changed (name, value_type).
|
||||
Visibility (show_in_overview) and required flag are stored in Settings and can be modified.
|
||||
"""
|
||||
use MvWeb, :live_component
|
||||
|
||||
|
|
@ -27,14 +27,13 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
|
|||
alias MvWeb.Helpers.FieldTypeFormatter
|
||||
alias MvWeb.Translations.MemberFields
|
||||
|
||||
@required_fields [:first_name, :last_name, :email]
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:field_attributes, get_field_attributes(assigns.member_field))
|
||||
|> assign(:is_email_field?, assigns.member_field == :email)
|
||||
|> assign(:vereinfacht_required_field?, vereinfacht_required_field?(assigns))
|
||||
|> assign(:field_label, MemberFields.label(assigns.member_field))
|
||||
|
||||
~H"""
|
||||
|
|
@ -117,89 +116,64 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
|
|||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:if={@is_email_field?}
|
||||
class="tooltip tooltip-right"
|
||||
data-tip={gettext("This is a technical field and cannot be changed")}
|
||||
aria-label={gettext("This is a technical field and cannot be changed")}
|
||||
>
|
||||
<fieldset class="mb-2 fieldset">
|
||||
<label>
|
||||
<span class="mb-1 label flex items-center gap-2">
|
||||
{gettext("Description")}
|
||||
<.icon
|
||||
name="hero-information-circle"
|
||||
class="w-4 h-4 text-base-content/60 cursor-help"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
name={@form[:description].name}
|
||||
id={@form[:description].id}
|
||||
value={@form[:description].value}
|
||||
disabled
|
||||
readonly
|
||||
class="w-full input"
|
||||
/>
|
||||
</label>
|
||||
</fieldset>
|
||||
</div>
|
||||
<.input
|
||||
:if={not @is_email_field?}
|
||||
field={@form[:description]}
|
||||
type="text"
|
||||
label={gettext("Description")}
|
||||
disabled={@is_email_field?}
|
||||
readonly={@is_email_field?}
|
||||
/>
|
||||
|
||||
<div
|
||||
:if={@is_email_field?}
|
||||
class="tooltip tooltip-right"
|
||||
data-tip={gettext("This is a technical field and cannot be changed")}
|
||||
aria-label={gettext("This is a technical field and cannot be changed")}
|
||||
>
|
||||
<fieldset class="mb-2 fieldset">
|
||||
<label>
|
||||
<input type="hidden" name={@form[:required].name} value="false" disabled />
|
||||
<span class="label flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
name={@form[:required].name}
|
||||
id={@form[:required].id}
|
||||
value="true"
|
||||
checked={@form[:required].value}
|
||||
disabled
|
||||
readonly
|
||||
class="checkbox checkbox-sm"
|
||||
/>
|
||||
<span class="flex items-center gap-2">
|
||||
{gettext("Required")}
|
||||
<.icon
|
||||
name="hero-information-circle"
|
||||
class="w-4 h-4 text-base-content/60 cursor-help"
|
||||
aria-hidden="true"
|
||||
<%!-- Line break before Required / Show in overview block --%>
|
||||
<div class="mt-4">
|
||||
<%!-- Required: disabled for email (always required) or Vereinfacht-required fields when integration is active --%>
|
||||
<div
|
||||
:if={@is_email_field? or @vereinfacht_required_field?}
|
||||
class="tooltip tooltip-right"
|
||||
data-tip={
|
||||
if(@is_email_field?,
|
||||
do: gettext("This is a technical field and cannot be changed"),
|
||||
else: gettext("Required for Vereinfacht integration and cannot be disabled.")
|
||||
)
|
||||
}
|
||||
aria-label={
|
||||
if(@is_email_field?,
|
||||
do: gettext("This is a technical field and cannot be changed"),
|
||||
else: gettext("Required for Vereinfacht integration and cannot be disabled.")
|
||||
)
|
||||
}
|
||||
>
|
||||
<fieldset class="mb-2 fieldset">
|
||||
<label>
|
||||
<input type="hidden" name={@form[:required].name} value="true" />
|
||||
<span class="label flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
name={@form[:required].name}
|
||||
id={@form[:required].id}
|
||||
value="true"
|
||||
checked={@form[:required].value}
|
||||
disabled
|
||||
readonly
|
||||
class="checkbox checkbox-sm"
|
||||
/>
|
||||
<span class="flex items-center gap-2">
|
||||
{gettext("Required")}
|
||||
<.icon
|
||||
name="hero-information-circle"
|
||||
class="w-4 h-4 text-base-content/60 cursor-help"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
</div>
|
||||
<.input
|
||||
:if={not @is_email_field?}
|
||||
field={@form[:required]}
|
||||
type="checkbox"
|
||||
label={gettext("Required")}
|
||||
disabled={@is_email_field?}
|
||||
readonly={@is_email_field?}
|
||||
/>
|
||||
</label>
|
||||
</fieldset>
|
||||
</div>
|
||||
<.input
|
||||
:if={not @is_email_field? and not @vereinfacht_required_field?}
|
||||
field={@form[:required]}
|
||||
type="checkbox"
|
||||
label={gettext("Required")}
|
||||
/>
|
||||
|
||||
<.input
|
||||
field={@form[:show_in_overview]}
|
||||
type="checkbox"
|
||||
label={gettext("Show in overview")}
|
||||
/>
|
||||
<.input
|
||||
field={@form[:show_in_overview]}
|
||||
type="checkbox"
|
||||
label={gettext("Show in overview")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="justify-end mt-4 card-actions">
|
||||
<.button type="button" phx-click="cancel" phx-target={@myself}>
|
||||
|
|
@ -225,24 +199,35 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
|
|||
|
||||
@impl true
|
||||
def handle_event("validate", %{"member_field" => member_field_params}, socket) do
|
||||
# For member fields, we only validate show_in_overview
|
||||
# Other fields are read-only or derived from the Member Resource
|
||||
form = socket.assigns.form
|
||||
|
||||
updated_params =
|
||||
member_field_params
|
||||
|> Map.put(
|
||||
"show_in_overview",
|
||||
# Unchecked checkboxes are not in params; preserve current form value when key is missing
|
||||
show_in_overview =
|
||||
if Map.has_key?(member_field_params, "show_in_overview") do
|
||||
TypeParsers.parse_boolean(member_field_params["show_in_overview"])
|
||||
)
|
||||
|> Map.put("name", form.source["name"])
|
||||
|> Map.put("value_type", form.source["value_type"])
|
||||
|> Map.put("description", form.source["description"])
|
||||
|> Map.put("required", form.source["required"])
|
||||
else
|
||||
form.source["show_in_overview"]
|
||||
end
|
||||
|
||||
required =
|
||||
socket.assigns.vereinfacht_required_field? ||
|
||||
if Map.has_key?(member_field_params, "required") do
|
||||
TypeParsers.parse_boolean(member_field_params["required"])
|
||||
else
|
||||
form.source["required"]
|
||||
end
|
||||
|
||||
# Merge so we keep name/value_type and have current checkbox state; use as new form source
|
||||
merged_source =
|
||||
form.source
|
||||
|> Map.merge(%{
|
||||
"show_in_overview" => show_in_overview,
|
||||
"required" => required,
|
||||
"name" => form.source["name"],
|
||||
"value_type" => form.source["value_type"]
|
||||
})
|
||||
|
||||
updated_form =
|
||||
form
|
||||
|> Map.put(:value, updated_params)
|
||||
to_form(merged_source, as: "member_field")
|
||||
|> Map.put(:errors, [])
|
||||
|
||||
{:noreply, assign(socket, form: updated_form)}
|
||||
|
|
@ -250,23 +235,36 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
|
|||
|
||||
@impl true
|
||||
def handle_event("save", %{"member_field" => member_field_params}, socket) do
|
||||
# Only show_in_overview can be changed for member fields
|
||||
show_in_overview = TypeParsers.parse_boolean(member_field_params["show_in_overview"])
|
||||
form = socket.assigns.form
|
||||
# Unchecked checkboxes are not in submit params; use form source when key missing
|
||||
show_in_overview =
|
||||
if Map.has_key?(member_field_params, "show_in_overview") do
|
||||
TypeParsers.parse_boolean(member_field_params["show_in_overview"])
|
||||
else
|
||||
form.source["show_in_overview"]
|
||||
end
|
||||
|
||||
required =
|
||||
socket.assigns.vereinfacht_required_field? ||
|
||||
if Map.has_key?(member_field_params, "required") do
|
||||
TypeParsers.parse_boolean(member_field_params["required"])
|
||||
else
|
||||
form.source["required"]
|
||||
end
|
||||
|
||||
field_string = Atom.to_string(socket.assigns.member_field)
|
||||
|
||||
# Use atomic action to update only this single field
|
||||
# This prevents lost updates in concurrent scenarios
|
||||
case Membership.update_single_member_field_visibility(
|
||||
case Membership.update_single_member_field(
|
||||
socket.assigns.settings,
|
||||
field: field_string,
|
||||
show_in_overview: show_in_overview
|
||||
show_in_overview: show_in_overview,
|
||||
required: required
|
||||
) do
|
||||
{:ok, _updated_settings} ->
|
||||
socket.assigns.on_save.(socket.assigns.member_field, "update")
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, error} ->
|
||||
# Add error to form
|
||||
form =
|
||||
socket.assigns.form
|
||||
|> Map.put(:errors, [
|
||||
|
|
@ -288,16 +286,29 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
|
|||
defp assign_form(%{assigns: %{member_field: member_field, settings: settings}} = socket) do
|
||||
field_attributes = get_field_attributes(member_field)
|
||||
visibility_config = settings.member_field_visibility || %{}
|
||||
normalized_config = VisibilityConfig.normalize(visibility_config)
|
||||
show_in_overview = Map.get(normalized_config, member_field, true)
|
||||
required_config = settings.member_field_required || %{}
|
||||
normalized_visibility = VisibilityConfig.normalize(visibility_config)
|
||||
normalized_required = VisibilityConfig.normalize(required_config)
|
||||
show_in_overview = Map.get(normalized_visibility, member_field, true)
|
||||
vereinfacht_required? = Mv.Config.vereinfacht_configured?()
|
||||
# Persist in socket so validate/save can enforce server-side without relying on render assigns
|
||||
socket =
|
||||
assign(
|
||||
socket,
|
||||
:vereinfacht_required_field?,
|
||||
vereinfacht_required_field?(%{member_field: member_field})
|
||||
)
|
||||
|
||||
# Email always required; Vereinfacht-required fields when integration active; else from settings
|
||||
required =
|
||||
member_field == :email ||
|
||||
(vereinfacht_required? && Mv.Constants.vereinfacht_required_field?(member_field)) ||
|
||||
Map.get(normalized_required, member_field, false)
|
||||
|
||||
# Create a manual form structure with string keys
|
||||
# Note: immutable is not included as it's not editable for member fields
|
||||
form_data = %{
|
||||
"name" => MemberFields.label(member_field),
|
||||
"value_type" => FieldTypeFormatter.format(field_attributes.value_type),
|
||||
"description" => field_attributes.description || "",
|
||||
"required" => field_attributes.required,
|
||||
"required" => required,
|
||||
"show_in_overview" => show_in_overview
|
||||
}
|
||||
|
||||
|
|
@ -307,24 +318,14 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
|
|||
end
|
||||
|
||||
defp get_field_attributes(field) when is_atom(field) do
|
||||
# Get attribute info from Member Resource
|
||||
alias Ash.Resource.Info
|
||||
|
||||
case Info.attribute(Mv.Membership.Member, field) do
|
||||
nil ->
|
||||
# Fallback for fields not in resource (shouldn't happen with Constants)
|
||||
%{
|
||||
value_type: :string,
|
||||
description: nil,
|
||||
required: field in @required_fields
|
||||
}
|
||||
%{value_type: :string}
|
||||
|
||||
attribute ->
|
||||
%{
|
||||
value_type: attribute.type,
|
||||
description: nil,
|
||||
required: not attribute.allow_nil?
|
||||
}
|
||||
%{value_type: attribute.type}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -335,4 +336,9 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
|
|||
defp format_error(error) do
|
||||
inspect(error)
|
||||
end
|
||||
|
||||
defp vereinfacht_required_field?(assigns) do
|
||||
Mv.Config.vereinfacht_configured?() &&
|
||||
Mv.Constants.vereinfacht_required_field?(assigns.member_field)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
|
|||
assigns =
|
||||
assigns
|
||||
|> assign(:member_fields, get_member_fields_with_visibility(assigns.settings))
|
||||
|> assign(:required?, &required?/1)
|
||||
|
||||
~H"""
|
||||
<div id={@id}>
|
||||
|
|
@ -62,22 +61,15 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
|
|||
{format_value_type(field_data.field)}
|
||||
</:col>
|
||||
|
||||
<:col :let={{_field_name, field_data}} label={gettext("Description")}>
|
||||
{field_data.description || ""}
|
||||
</:col>
|
||||
|
||||
<:col
|
||||
:let={{_field_name, field_data}}
|
||||
label={gettext("Required")}
|
||||
class="max-w-[9.375rem] text-center"
|
||||
>
|
||||
<span
|
||||
:if={@required?.(field_data.field)}
|
||||
class="text-base-content font-semibold"
|
||||
>
|
||||
<span :if={field_data.required} class="text-base-content font-semibold">
|
||||
{gettext("Required")}
|
||||
</span>
|
||||
<span :if={!@required?.(field_data.field)} class="text-base-content/70">
|
||||
<span :if={!field_data.required} class="text-base-content/70">
|
||||
{gettext("Optional")}
|
||||
</span>
|
||||
</:col>
|
||||
|
|
@ -173,26 +165,35 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
|
|||
{:error, _} ->
|
||||
# Return a minimal struct-like map for fallback
|
||||
# This is only used for initial rendering, actual settings will be loaded properly
|
||||
%{member_field_visibility: %{}}
|
||||
%{member_field_visibility: %{}, member_field_required: %{}}
|
||||
end
|
||||
end
|
||||
|
||||
defp get_member_fields_with_visibility(settings) do
|
||||
member_fields = Mv.Constants.member_fields()
|
||||
visibility_config = settings.member_field_visibility || %{}
|
||||
required_config = settings.member_field_required || %{}
|
||||
vereinfacht_required? = Mv.Config.vereinfacht_configured?()
|
||||
|
||||
# Normalize visibility config keys to atoms
|
||||
normalized_config = VisibilityConfig.normalize(visibility_config)
|
||||
normalized_visibility = VisibilityConfig.normalize(visibility_config)
|
||||
normalized_required = VisibilityConfig.normalize(required_config)
|
||||
|
||||
Enum.map(member_fields, fn field ->
|
||||
show_in_overview = Map.get(normalized_config, field, true)
|
||||
show_in_overview = Map.get(normalized_visibility, field, true)
|
||||
|
||||
# Email always required; Vereinfacht-required fields when integration active; else from settings
|
||||
required =
|
||||
field == :email ||
|
||||
(vereinfacht_required? && Mv.Constants.vereinfacht_required_field?(field)) ||
|
||||
Map.get(normalized_required, field, false)
|
||||
|
||||
attribute = Info.attribute(Mv.Membership.Member, field)
|
||||
|
||||
%{
|
||||
field: field,
|
||||
show_in_overview: show_in_overview,
|
||||
value_type: (attribute && attribute.type) || :string,
|
||||
description: nil
|
||||
required: required,
|
||||
value_type: (attribute && attribute.type) || :string
|
||||
}
|
||||
end)
|
||||
|> Enum.map(fn field_data ->
|
||||
|
|
@ -206,14 +207,4 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
|
|||
attribute -> FieldTypeFormatter.format(attribute.type)
|
||||
end
|
||||
end
|
||||
|
||||
# Check if a field is required by checking the actual attribute definition
|
||||
defp required?(field) when is_atom(field) do
|
||||
case Info.attribute(Mv.Membership.Member, field) do
|
||||
nil -> false
|
||||
attribute -> not attribute.allow_nil?
|
||||
end
|
||||
end
|
||||
|
||||
defp required?(_), do: false
|
||||
end
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ defmodule MvWeb.MemberLive.Form do
|
|||
|
||||
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
|
||||
|
||||
alias Mv.Membership
|
||||
alias Mv.Membership.Helpers.VisibilityConfig
|
||||
alias Mv.MembershipFees
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||
|
|
@ -84,10 +86,18 @@ defmodule MvWeb.MemberLive.Form do
|
|||
<%!-- Name Row --%>
|
||||
<div class="flex gap-4">
|
||||
<div class="w-48">
|
||||
<.input field={@form[:first_name]} label={gettext("First Name")} />
|
||||
<.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")} />
|
||||
<.input
|
||||
field={@form[:last_name]}
|
||||
label={gettext("Last Name")}
|
||||
required={@member_field_required_map[:last_name]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -97,7 +107,11 @@ defmodule MvWeb.MemberLive.Form do
|
|||
<.input field={@form[:country]} label={gettext("Country")} />
|
||||
</div>
|
||||
<div class="w-24">
|
||||
<.input field={@form[:postal_code]} label={gettext("Postal Code")} />
|
||||
<.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")} />
|
||||
|
|
@ -122,16 +136,31 @@ defmodule MvWeb.MemberLive.Form do
|
|||
<%!-- Membership Dates Row --%>
|
||||
<div class="flex gap-4">
|
||||
<div class="w-36">
|
||||
<.input field={@form[:join_date]} label={gettext("Join Date")} type="date" />
|
||||
<.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" />
|
||||
<.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" />
|
||||
<.input
|
||||
field={@form[:notes]}
|
||||
label={gettext("Notes")}
|
||||
type="textarea"
|
||||
required={@member_field_required_map[:notes]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</.form_section>
|
||||
|
|
@ -261,6 +290,9 @@ defmodule MvWeb.MemberLive.Form do
|
|||
# Load available membership fee types
|
||||
available_fee_types = load_available_fee_types(member, actor)
|
||||
|
||||
# Load settings to know which member fields are required (for asterisk/tooltip)
|
||||
member_field_required_map = get_member_field_required_map()
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:return_to, return_to(params["return_to"]))
|
||||
|
|
@ -270,9 +302,38 @@ defmodule MvWeb.MemberLive.Form do
|
|||
|> assign(:page_title, page_title)
|
||||
|> assign(:available_fee_types, available_fee_types)
|
||||
|> assign(:interval_warning, nil)
|
||||
|> assign(:member_field_required_map, member_field_required_map)
|
||||
|> assign_form()}
|
||||
end
|
||||
|
||||
defp get_member_field_required_map do
|
||||
vereinfacht_required? = Mv.Config.vereinfacht_configured?()
|
||||
|
||||
case Membership.get_settings() do
|
||||
{:ok, settings} ->
|
||||
required_config = settings.member_field_required || %{}
|
||||
normalized = VisibilityConfig.normalize(required_config)
|
||||
|
||||
Mv.Constants.member_fields()
|
||||
|> Enum.map(fn field ->
|
||||
required =
|
||||
field == :email ||
|
||||
(vereinfacht_required? && Mv.Constants.vereinfacht_required_field?(field)) ||
|
||||
Map.get(normalized, field, false)
|
||||
|
||||
{field, required}
|
||||
end)
|
||||
|> Map.new()
|
||||
|
||||
{:error, _} ->
|
||||
# Email always required; Vereinfacht fields when integration active
|
||||
Map.new(Mv.Constants.member_fields(), fn f ->
|
||||
{f,
|
||||
f == :email || (vereinfacht_required? && Mv.Constants.vereinfacht_required_field?(f))}
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
defp return_to("show"), do: "show"
|
||||
defp return_to(_), do: "index"
|
||||
|
||||
|
|
@ -326,11 +387,40 @@ defmodule MvWeb.MemberLive.Form do
|
|||
socket =
|
||||
socket
|
||||
|> put_flash(:info, flash_message)
|
||||
|> maybe_put_vereinfacht_sync_flash(member.id)
|
||||
|> push_navigate(to: return_path(socket.assigns.return_to, member))
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
defp maybe_put_vereinfacht_sync_flash(socket, member_id) do
|
||||
case Mv.Vereinfacht.SyncFlash.take(to_string(member_id)) do
|
||||
{:warning, message} ->
|
||||
put_flash(socket, :warning, translate_vereinfacht_flash(message))
|
||||
|
||||
{:ok, _message} ->
|
||||
# Optionally show sync success; for now we keep only the main success message
|
||||
socket
|
||||
|
||||
nil ->
|
||||
socket
|
||||
end
|
||||
end
|
||||
|
||||
defp translate_vereinfacht_flash(message) when is_binary(message) do
|
||||
prefix = "Vereinfacht: "
|
||||
|
||||
if String.starts_with?(message, prefix) do
|
||||
detail = message |> String.trim_leading(prefix) |> String.trim()
|
||||
|
||||
Gettext.dgettext(MvWeb.Gettext, "default", "Vereinfacht: %{detail}",
|
||||
detail: Gettext.dgettext(MvWeb.Gettext, "default", detail)
|
||||
)
|
||||
else
|
||||
Gettext.dgettext(MvWeb.Gettext, "default", message)
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_save_error(socket, form) do
|
||||
# Always show a flash message when save fails
|
||||
# Field-level validation errors are displayed in form fields, but flash provides additional feedback
|
||||
|
|
|
|||
|
|
@ -682,6 +682,19 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|> update_selection_assigns()
|
||||
end
|
||||
|
||||
# Update sort components after rendering
|
||||
socket =
|
||||
if socket.assigns[:sort_needs_update] do
|
||||
old_field = socket.assigns[:previous_sort_field] || socket.assigns.sort_field
|
||||
|
||||
socket
|
||||
|> update_sort_components(old_field, socket.assigns.sort_field, socket.assigns.sort_order)
|
||||
|> assign(:sort_needs_update, false)
|
||||
|> assign(:previous_sort_field, nil)
|
||||
else
|
||||
socket
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
|
|
@ -940,9 +953,10 @@ defmodule MvWeb.MemberLive.Index do
|
|||
)
|
||||
|
||||
# Sort in memory if needed (custom fields, groups, group_count; computed fields are blocked)
|
||||
# Note: :groups is in computed_member_fields() but can be sorted in-memory, so we only block :membership_fee_status
|
||||
members =
|
||||
if sort_after_load and
|
||||
socket.assigns.sort_field not in FieldVisibility.computed_member_fields() do
|
||||
socket.assigns.sort_field != :membership_fee_status do
|
||||
sort_members_in_memory(
|
||||
members,
|
||||
socket.assigns.sort_field,
|
||||
|
|
@ -1044,21 +1058,15 @@ defmodule MvWeb.MemberLive.Index do
|
|||
defp maybe_sort(query, _field, nil, _custom_fields), do: {query, false}
|
||||
|
||||
defp maybe_sort(query, field, order, _custom_fields) do
|
||||
if computed_field?(field) do
|
||||
# :groups is in computed_member_fields() but can be sorted in-memory
|
||||
# Only :membership_fee_status should be blocked from sorting
|
||||
if field == :membership_fee_status or field == "membership_fee_status" do
|
||||
{query, false}
|
||||
else
|
||||
apply_sort_to_query(query, field, order)
|
||||
end
|
||||
end
|
||||
|
||||
defp computed_field?(field) do
|
||||
computed_atoms = FieldVisibility.computed_member_fields()
|
||||
computed_strings = Enum.map(computed_atoms, &Atom.to_string/1)
|
||||
|
||||
(is_atom(field) and field in computed_atoms) or
|
||||
(is_binary(field) and field in computed_strings)
|
||||
end
|
||||
|
||||
defp apply_sort_to_query(query, field, order) do
|
||||
cond do
|
||||
# Groups sort -> after load (in memory)
|
||||
|
|
@ -1086,13 +1094,19 @@ defmodule MvWeb.MemberLive.Index do
|
|||
end
|
||||
|
||||
defp valid_sort_field?(field) when is_atom(field) do
|
||||
if field in FieldVisibility.computed_member_fields(),
|
||||
do: false,
|
||||
else: valid_sort_field_db_or_custom?(field)
|
||||
# :groups is in computed_member_fields() but can be sorted
|
||||
# Only :membership_fee_status should be blocked
|
||||
if field == :membership_fee_status do
|
||||
false
|
||||
else
|
||||
valid_sort_field_db_or_custom?(field)
|
||||
end
|
||||
end
|
||||
|
||||
defp valid_sort_field?(field) when is_binary(field) do
|
||||
if field in Enum.map(FieldVisibility.computed_member_fields(), &Atom.to_string/1) do
|
||||
# "groups" is in computed_member_fields() but can be sorted
|
||||
# Only "membership_fee_status" should be blocked
|
||||
if field == "membership_fee_status" do
|
||||
false
|
||||
else
|
||||
valid_sort_field_db_or_custom?(field)
|
||||
|
|
@ -1249,10 +1263,13 @@ defmodule MvWeb.MemberLive.Index do
|
|||
defp maybe_update_sort(socket, %{"sort_field" => sf, "sort_order" => so}) do
|
||||
field = determine_field(socket.assigns.sort_field, sf)
|
||||
order = determine_order(socket.assigns.sort_order, so)
|
||||
old_field = socket.assigns.sort_field
|
||||
|
||||
socket
|
||||
|> assign(:sort_field, field)
|
||||
|> assign(:sort_order, order)
|
||||
|> assign(:sort_needs_update, old_field != field or socket.assigns.sort_order != order)
|
||||
|> assign(:previous_sort_field, old_field)
|
||||
end
|
||||
|
||||
defp maybe_update_sort(socket, _), do: socket
|
||||
|
|
@ -1261,17 +1278,27 @@ defmodule MvWeb.MemberLive.Index do
|
|||
defp determine_field(default, nil), do: default
|
||||
|
||||
defp determine_field(default, sf) when is_binary(sf) do
|
||||
computed_strings = Enum.map(FieldVisibility.computed_member_fields(), &Atom.to_string/1)
|
||||
# Handle "groups" specially - it's in computed_member_fields() but can be sorted
|
||||
if sf == "groups" do
|
||||
:groups
|
||||
else
|
||||
computed_strings = Enum.map(FieldVisibility.computed_member_fields(), &Atom.to_string/1)
|
||||
|
||||
if sf in computed_strings,
|
||||
do: default,
|
||||
else: determine_field_after_computed_check(default, sf)
|
||||
if sf in computed_strings,
|
||||
do: default,
|
||||
else: determine_field_after_computed_check(default, sf)
|
||||
end
|
||||
end
|
||||
|
||||
defp determine_field(default, sf) when is_atom(sf) do
|
||||
if sf in FieldVisibility.computed_member_fields(),
|
||||
do: default,
|
||||
else: determine_field_after_computed_check(default, sf)
|
||||
# Handle :groups specially - it's in computed_member_fields() but can be sorted
|
||||
if sf == :groups do
|
||||
:groups
|
||||
else
|
||||
if sf in FieldVisibility.computed_member_fields(),
|
||||
do: default,
|
||||
else: determine_field_after_computed_check(default, sf)
|
||||
end
|
||||
end
|
||||
|
||||
defp determine_field(default, _), do: default
|
||||
|
|
@ -1620,6 +1647,14 @@ defmodule MvWeb.MemberLive.Index do
|
|||
FieldVisibility.computed_member_fields()
|
||||
|> Enum.filter(&(&1 in member_fields_computed))
|
||||
|
||||
# Include groups in export only if it's visible in the table
|
||||
member_fields_with_groups =
|
||||
if :groups in socket.assigns[:member_fields_visible] do
|
||||
ordered_member_fields_db ++ ["groups"]
|
||||
else
|
||||
ordered_member_fields_db
|
||||
end
|
||||
|
||||
# Order custom fields like the table (same as dynamic_cols / all_custom_fields order)
|
||||
ordered_custom_field_ids =
|
||||
socket.assigns.all_custom_fields
|
||||
|
|
@ -1628,7 +1663,11 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|
||||
%{
|
||||
selected_ids: socket.assigns.selected_members |> MapSet.to_list(),
|
||||
member_fields: Enum.map(ordered_member_fields_db, &Atom.to_string/1),
|
||||
member_fields:
|
||||
Enum.map(member_fields_with_groups, fn
|
||||
f when is_atom(f) -> Atom.to_string(f)
|
||||
f when is_binary(f) -> f
|
||||
end),
|
||||
computed_fields: Enum.map(ordered_computed_fields, &Atom.to_string/1),
|
||||
custom_field_ids: ordered_custom_field_ids,
|
||||
column_order:
|
||||
|
|
|
|||
|
|
@ -349,6 +349,7 @@
|
|||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:groups in @member_fields_visible}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
|
|
|
|||
|
|
@ -28,7 +28,8 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
|||
alias Mv.Membership.Helpers.VisibilityConfig
|
||||
|
||||
# Single UI key for "Membership Fee Status"; only this appears in the dropdown.
|
||||
@pseudo_member_fields [:membership_fee_status]
|
||||
# Groups is also a pseudo field (not a DB attribute, but displayed in the table).
|
||||
@pseudo_member_fields [:membership_fee_status, :groups]
|
||||
|
||||
# Export/API may accept this as alias; must not appear in the UI options list.
|
||||
@export_only_alias :payment_status
|
||||
|
|
@ -201,7 +202,7 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
|||
"""
|
||||
@spec get_visible_member_fields_computed(%{String.t() => boolean()}) :: [atom()]
|
||||
def get_visible_member_fields_computed(field_selection) when is_map(field_selection) do
|
||||
computed_set = MapSet.new(@pseudo_member_fields)
|
||||
computed_set = MapSet.new([:membership_fee_status])
|
||||
|
||||
field_selection
|
||||
|> Enum.filter(fn {field_string, visible} ->
|
||||
|
|
|
|||
|
|
@ -256,6 +256,7 @@ defmodule MvWeb.MemberLive.Show do
|
|||
id={"membership-fees-#{@member.id}"}
|
||||
member={@member}
|
||||
current_user={@current_user}
|
||||
vereinfacht_receipts={@vereinfacht_receipts}
|
||||
/>
|
||||
<% end %>
|
||||
</Layouts.app>
|
||||
|
|
@ -264,7 +265,10 @@ defmodule MvWeb.MemberLive.Show do
|
|||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, assign(socket, :active_tab, :contact)}
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:active_tab, :contact)
|
||||
|> assign(:vereinfacht_receipts, nil)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
|
@ -316,6 +320,16 @@ defmodule MvWeb.MemberLive.Show do
|
|||
{:noreply, assign(socket, :active_tab, :membership_fees)}
|
||||
end
|
||||
|
||||
def handle_event("load_vereinfacht_receipts", %{"contact_id" => contact_id}, socket) do
|
||||
response =
|
||||
case Mv.Vereinfacht.Client.get_contact_with_receipts(contact_id) do
|
||||
{:ok, receipts} -> {:ok, receipts}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
|
||||
{:noreply, assign(socket, :vereinfacht_receipts, response)}
|
||||
end
|
||||
|
||||
# Flash set in LiveComponent is not shown in parent layout; child sends this to display flash
|
||||
@impl true
|
||||
def handle_info({:put_flash, type, message}, socket) do
|
||||
|
|
|
|||
|
|
@ -50,6 +50,90 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
<% end %>
|
||||
</div>
|
||||
|
||||
<%!-- Vereinfacht: contact info when synced, or warning when API is configured but no contact --%>
|
||||
<%= if Mv.Config.vereinfacht_configured?() do %>
|
||||
<%= if @vereinfacht_contact_present do %>
|
||||
<div class="mb-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<.link
|
||||
:if={Mv.Config.vereinfacht_contact_view_url(@member.vereinfacht_contact_id)}
|
||||
href={Mv.Config.vereinfacht_contact_view_url(@member.vereinfacht_contact_id)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="link link-accent underline inline-flex items-center gap-1 w-fit"
|
||||
>
|
||||
{gettext("View contact in Vereinfacht")}
|
||||
<.icon name="hero-arrow-top-right-on-square" class="inline-block size-4" />
|
||||
</.link>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="load_vereinfacht_receipts"
|
||||
phx-value-contact_id={@member.vereinfacht_contact_id}
|
||||
class="btn btn-sm btn-ghost"
|
||||
>
|
||||
{gettext("Show bookings/receipts from Vereinfacht")}
|
||||
</button>
|
||||
</div>
|
||||
<%= if @vereinfacht_receipts do %>
|
||||
<div
|
||||
class="mt-2 rounded border border-base-300 bg-base-200 p-3 overflow-x-auto max-h-96 overflow-y-auto"
|
||||
tabindex="0"
|
||||
role="region"
|
||||
aria-label={gettext("Vereinfacht receipts")}
|
||||
>
|
||||
<%= if match?({:ok, _}, @vereinfacht_receipts) do %>
|
||||
<% {_, receipts} = @vereinfacht_receipts %>
|
||||
<%= if receipts == [] do %>
|
||||
<p class="text-sm text-base-content/70">{gettext("No receipts")}</p>
|
||||
<% else %>
|
||||
<% cols = receipt_display_columns(receipts) %>
|
||||
<table class="table table-xs table-pin-rows">
|
||||
<thead>
|
||||
<tr>
|
||||
<%= for {_key, translated_label} <- cols do %>
|
||||
<th>{translated_label}</th>
|
||||
<% end %>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<%= for r <- receipts do %>
|
||||
<tr>
|
||||
<%= for {col_key, _header_key} <- cols do %>
|
||||
<td>{format_receipt_cell(col_key, r[col_key])}</td>
|
||||
<% end %>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<% {:error, reason} = @vereinfacht_receipts %>
|
||||
<p class="text-sm text-error">
|
||||
{gettext("Error loading receipts: %{reason}",
|
||||
reason: format_vereinfacht_error(reason)
|
||||
)}
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="mb-4 rounded-lg border border-warning/50 bg-warning/10 p-3">
|
||||
<p class="text-warning font-medium flex items-center gap-2">
|
||||
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0" />
|
||||
{gettext("No Vereinfacht contact exists for this member.")}
|
||||
</p>
|
||||
<p class="text-sm text-base-content/70 mt-1">
|
||||
{gettext(
|
||||
"Sync this member from Settings (Vereinfacht section) or save the member again to create the contact."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%!-- Action Buttons (only when user has permission) --%>
|
||||
<div class="flex gap-2 mb-4">
|
||||
<.button
|
||||
|
|
@ -431,6 +515,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
|> assign(:can_create_cycle, can_create_cycle)
|
||||
|> assign(:can_destroy_cycle, can_destroy_cycle)
|
||||
|> assign(:can_update_cycle, can_update_cycle)
|
||||
|> assign(:vereinfacht_contact_present, present_contact_id?(member.vereinfacht_contact_id))
|
||||
|> assign_new(:interval_warning, fn -> nil end)
|
||||
|> assign_new(:editing_cycle, fn -> nil end)
|
||||
|> assign_new(:deleting_cycle, fn -> nil end)
|
||||
|
|
@ -439,7 +524,8 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
|> assign_new(:creating_cycle, fn -> false end)
|
||||
|> assign_new(:create_cycle_date, fn -> nil end)
|
||||
|> assign_new(:create_cycle_error, fn -> nil end)
|
||||
|> assign_new(:regenerating, fn -> false end)}
|
||||
|> assign_new(:regenerating, fn -> false end)
|
||||
|> assign_new(:vereinfacht_receipts, fn -> nil end)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
|
@ -997,6 +1083,142 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
|
||||
defp format_create_cycle_period(_date, _interval), do: ""
|
||||
|
||||
defp present_contact_id?(nil), do: false
|
||||
defp present_contact_id?(id) when is_binary(id), do: String.trim(id) != ""
|
||||
defp present_contact_id?(_), do: false
|
||||
|
||||
defp format_vereinfacht_error({:http, status, detail}) when is_binary(detail),
|
||||
do: "HTTP #{status} – #{detail}"
|
||||
|
||||
defp format_vereinfacht_error({:http, status, _}), do: "HTTP #{status}"
|
||||
defp format_vereinfacht_error(reason), do: inspect(reason)
|
||||
|
||||
# Ordered receipt columns: {api_key, gettext key for header}. Only columns present in data are shown.
|
||||
@receipt_column_spec [
|
||||
{:amount, "Amount"},
|
||||
{:bookingDate, "Booking date"},
|
||||
{:createdAt, "Created at"},
|
||||
{:receiptType, "Receipt type"},
|
||||
{:referenceNumber, "Reference number"},
|
||||
{:status, "Status"},
|
||||
{:updatedAt, "Updated at"}
|
||||
]
|
||||
|
||||
defp receipt_display_columns(receipts) when is_list(receipts) do
|
||||
keys_in_data = receipts |> Enum.flat_map(&Map.keys/1) |> MapSet.new()
|
||||
|
||||
Enum.filter(@receipt_column_spec, fn {key, _} -> MapSet.member?(keys_in_data, key) end)
|
||||
|> Enum.map(fn {key, msgid} -> {key, Gettext.gettext(MvWeb.Gettext, msgid)} end)
|
||||
end
|
||||
|
||||
defp format_receipt_cell(:amount, nil), do: "—"
|
||||
|
||||
defp format_receipt_cell(:amount, val) when is_number(val) do
|
||||
case Decimal.cast(val) do
|
||||
{:ok, d} -> MembershipFeeHelpers.format_currency(d)
|
||||
_ -> to_string(val)
|
||||
end
|
||||
end
|
||||
|
||||
defp format_receipt_cell(:amount, val) when is_binary(val) do
|
||||
case Decimal.parse(val) do
|
||||
{d, _} -> MembershipFeeHelpers.format_currency(d)
|
||||
:error -> val
|
||||
end
|
||||
end
|
||||
|
||||
defp format_receipt_cell(:amount, val), do: to_string(val)
|
||||
|
||||
defp format_receipt_cell(:status, nil), do: "—"
|
||||
|
||||
defp format_receipt_cell(:status, val) when is_binary(val) do
|
||||
translate_receipt_status(val)
|
||||
end
|
||||
|
||||
defp format_receipt_cell(:status, val), do: translate_receipt_status(to_string(val))
|
||||
|
||||
defp format_receipt_cell(:receiptType, nil), do: "—"
|
||||
|
||||
defp format_receipt_cell(:receiptType, val) when is_binary(val) do
|
||||
translate_receipt_type(val)
|
||||
end
|
||||
|
||||
defp format_receipt_cell(:receiptType, val), do: translate_receipt_type(to_string(val))
|
||||
|
||||
defp format_receipt_cell(col_key, nil) when col_key in [:bookingDate, :createdAt, :updatedAt],
|
||||
do: "—"
|
||||
|
||||
defp format_receipt_cell(col_key, val) when col_key in [:bookingDate, :createdAt, :updatedAt] do
|
||||
format_receipt_date(val)
|
||||
end
|
||||
|
||||
defp format_receipt_cell(_col_key, val) when is_binary(val), do: val
|
||||
defp format_receipt_cell(_col_key, val) when is_number(val), do: to_string(val)
|
||||
|
||||
defp format_receipt_cell(_col_key, val) when is_boolean(val),
|
||||
do: if(val, do: gettext("Yes"), else: gettext("No"))
|
||||
|
||||
defp format_receipt_cell(_col_key, %Date{} = d), do: format_receipt_date_short(d)
|
||||
defp format_receipt_cell(_col_key, val) when is_map(val) or is_list(val), do: Jason.encode!(val)
|
||||
defp format_receipt_cell(_col_key, val), do: to_string(val)
|
||||
|
||||
defp format_receipt_date(%Date{} = d), do: format_receipt_date_short(d)
|
||||
|
||||
defp format_receipt_date(val) when is_binary(val) do
|
||||
case parse_receipt_date(val) do
|
||||
{:ok, d} -> format_receipt_date_short(d)
|
||||
_ -> val
|
||||
end
|
||||
end
|
||||
|
||||
defp format_receipt_date(val), do: to_string(val)
|
||||
|
||||
# Parses ISO date or datetime string to Date (uses first 10 chars for datetime strings)
|
||||
defp parse_receipt_date(val) when is_binary(val) do
|
||||
date_str = if String.length(val) >= 10, do: String.slice(val, 0, 10), else: val
|
||||
Date.from_iso8601(date_str)
|
||||
end
|
||||
|
||||
# Format as "12. Dez. 2025" (day. abbreviated month. year) with translated month
|
||||
defp format_receipt_date_short(%Date{day: day, month: month, year: year}) do
|
||||
"#{day}. #{receipt_month_abbr(month)} #{year}"
|
||||
end
|
||||
|
||||
defp receipt_month_abbr(1), do: gettext("Jan.")
|
||||
defp receipt_month_abbr(2), do: gettext("Feb.")
|
||||
defp receipt_month_abbr(3), do: gettext("Mar.")
|
||||
defp receipt_month_abbr(4), do: gettext("Apr.")
|
||||
defp receipt_month_abbr(5), do: gettext("May")
|
||||
defp receipt_month_abbr(6), do: gettext("Jun.")
|
||||
defp receipt_month_abbr(7), do: gettext("Jul.")
|
||||
defp receipt_month_abbr(8), do: gettext("Aug.")
|
||||
defp receipt_month_abbr(9), do: gettext("Sep.")
|
||||
defp receipt_month_abbr(10), do: gettext("Oct.")
|
||||
defp receipt_month_abbr(11), do: gettext("Nov.")
|
||||
defp receipt_month_abbr(12), do: gettext("Dec.")
|
||||
defp receipt_month_abbr(_), do: ""
|
||||
|
||||
# Translate API status values for display (extend as API returns more values)
|
||||
defp translate_receipt_status("paid"), do: gettext("Paid")
|
||||
defp translate_receipt_status("unpaid"), do: gettext("Unpaid")
|
||||
defp translate_receipt_status("suspended"), do: gettext("Suspended")
|
||||
defp translate_receipt_status("open"), do: gettext("Open")
|
||||
defp translate_receipt_status("cancelled"), do: gettext("Cancelled")
|
||||
defp translate_receipt_status("draft"), do: gettext("Draft")
|
||||
defp translate_receipt_status("incompleted"), do: gettext("Incompleted")
|
||||
defp translate_receipt_status("completed"), do: gettext("Completed")
|
||||
defp translate_receipt_status("empty"), do: "—"
|
||||
defp translate_receipt_status(other), do: other
|
||||
|
||||
# Translate API receipt type values (extend as API returns more values)
|
||||
defp translate_receipt_type("invoice"), do: gettext("Invoice")
|
||||
defp translate_receipt_type("receipt"), do: gettext("Receipt")
|
||||
defp translate_receipt_type("credit_note"), do: gettext("Credit note")
|
||||
defp translate_receipt_type("credit"), do: gettext("Credit")
|
||||
defp translate_receipt_type("expense"), do: gettext("Expense")
|
||||
defp translate_receipt_type("income"), do: gettext("Income")
|
||||
defp translate_receipt_type(other), do: other
|
||||
|
||||
# Helper component for section box
|
||||
attr :title, :string, required: true
|
||||
slot :inner_block, required: true
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue