Membership Fee 6 - UI Components & LiveViews closes #280 #304

Merged
moritz merged 79 commits from feature/280_membership_fee_ui into main 2025-12-26 23:14:50 +01:00
Showing only changes of commit 856ce53295 - Show all commits

View file

@ -200,11 +200,12 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
<span class="label-text">{gettext("Amount")}</span>
</label>
<input
type="number"
type="text"
inputmode="decimal"
name="amount"
step="0.01"
min="0"
value={Decimal.to_string(@editing_cycle.amount)}
value={Decimal.to_string(@editing_cycle.amount) |> String.replace(".", ",")}
class="input input-bordered w-full"
required
/>
@ -294,7 +295,8 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
phx-target={@myself}
class="btn btn-error"
disabled={
@delete_all_confirmation != gettext("Yes") && @delete_all_confirmation != "Yes"
String.trim(String.downcase(@delete_all_confirmation)) !=
String.downcase(gettext("Yes"))
}
>
{gettext("Delete All")}
@ -351,12 +353,15 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
<span class="label-text">{gettext("Amount")}</span>
</label>
<input
type="number"
type="text"
inputmode="decimal"
id="create-cycle-amount"
name="amount"
step="0.01"
min="0"
value={Decimal.to_string(@member.membership_fee_type.amount)}
value={
Decimal.to_string(@member.membership_fee_type.amount) |> String.replace(".", ",")
}
class="input input-bordered w-full"
required
aria-label={gettext("Amount")}
@ -403,8 +408,8 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
{:ok,
socket
|> assign(assigns)
|> assign_new(:cycles, fn -> cycles end)
|> assign_new(:available_fee_types, fn -> available_fee_types end)
|> assign(:cycles, cycles)
|> assign(:available_fee_types, available_fee_types)
|> assign_new(:interval_warning, fn -> nil end)
|> assign_new(:editing_cycle, fn -> nil end)
|> assign_new(:deleting_cycle, fn -> nil end)
@ -438,7 +443,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
def handle_event("change_membership_fee_type", %{"value" => fee_type_id}, socket) do
member = socket.assigns.member
new_fee_type = Ash.get!(MembershipFeeType, fee_type_id)
new_fee_type = Ash.get!(MembershipFeeType, fee_type_id, domain: MembershipFees)
# Check if interval matches
interval_warning =
@ -500,7 +505,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
:suspended -> :mark_as_suspended
end
case Ash.update(cycle, action: action) do
case Ash.update(cycle, action: action, domain: MembershipFees) do
{:ok, updated_cycle} ->
updated_cycles = replace_cycle(socket.assigns.cycles, updated_cycle)
@ -528,6 +533,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
end
def handle_event("regenerate_cycles", _params, socket) do
socket = assign(socket, :regenerating, true)
member = socket.assigns.member
case CycleGenerator.generate_cycles_for_member(member.id) do
@ -580,11 +586,14 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
def handle_event("save_cycle_amount", %{"cycle_id" => cycle_id, "amount" => amount_str}, socket) do
cycle = find_cycle(socket.assigns.cycles, cycle_id)
case Decimal.parse(amount_str) do
# Normalize comma to dot for decimal parsing (German locale support)
normalized_amount_str = String.replace(amount_str, ",", ".")
case Decimal.parse(normalized_amount_str) do
{amount, _} when is_struct(amount, Decimal) ->
case cycle
|> Ash.Changeset.for_update(:update, %{amount: amount})
|> Ash.update() do
|> Ash.update(domain: MembershipFees) do
{:ok, updated_cycle} ->
updated_cycles = replace_cycle(socket.assigns.cycles, updated_cycle)
@ -621,7 +630,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
def handle_event("confirm_delete_cycle", %{"cycle_id" => cycle_id}, socket) do
cycle = find_cycle(socket.assigns.cycles, cycle_id)
case Ash.destroy(cycle) do
case Ash.destroy(cycle, domain: MembershipFees) do
:ok ->
updated_cycles = Enum.reject(socket.assigns.cycles, &(&1.id == cycle_id))
@ -668,19 +677,29 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
end
def handle_event("confirm_delete_all_cycles", _params, socket) do
# Validate confirmation (case-insensitive, trimmed)
confirmation = String.trim(String.downcase(socket.assigns.delete_all_confirmation))
expected = String.downcase(gettext("Yes"))
if confirmation != expected do
{:noreply,
socket
|> assign(:deleting_all_cycles, false)
|> assign(:delete_all_confirmation, "")
|> put_flash(:error, gettext("Confirmation text does not match"))}
else
member = socket.assigns.member
cycles = socket.assigns.cycles
# Delete all cycles
results =
Enum.map(cycles, fn cycle ->
Ash.destroy(cycle)
end)
# Delete all cycles atomically using Ecto query
import Ecto.Query
# Check if all deletions were successful
errors = Enum.filter(results, &match?({:error, _}, &1))
deleted_count =
Mv.Repo.delete_all(
from c in Mv.MembershipFees.MembershipFeeCycle,
where: c.member_id == ^member.id
)
if Enum.empty?(errors) do
if deleted_count > 0 do
# Reload member to get updated cycles
updated_member =
member
@ -706,14 +725,12 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|> assign(:delete_all_confirmation, "")
|> put_flash(:info, gettext("All cycles deleted"))}
else
error_msg =
Enum.map_join(errors, ", ", fn {:error, error} -> format_error(error) end)
{:noreply,
socket
|> assign(:deleting_all_cycles, false)
|> assign(:delete_all_confirmation, "")
|> put_flash(:error, gettext("Failed to delete some cycles: %{errors}", errors: error_msg))}
|> put_flash(:info, gettext("No cycles to delete"))}
end
end
end
@ -749,8 +766,17 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
def handle_event("create_cycle", %{"date" => date_str, "amount" => amount_str}, socket) do
member = socket.assigns.member
# Normalize comma to dot for decimal parsing (German locale support)
normalized_amount_str = String.replace(amount_str, ",", ".")
amount =
case Decimal.parse(normalized_amount_str) do
{d, _} when is_struct(d, Decimal) -> {:ok, d}
:error -> {:error, :invalid_amount}
end
with {:ok, date} <- Date.from_iso8601(date_str),
{amount, _} when is_struct(amount, Decimal) <- Decimal.parse(amount_str),
{:ok, amount} <- amount,
cycle_start <-
CalendarCycles.calculate_cycle_start(date, member.membership_fee_type.interval),
:ok <- validate_cycle_not_exists(socket.assigns.cycles, cycle_start) do
@ -762,7 +788,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
membership_fee_type_id: member.membership_fee_type_id
}
case Ash.create(MembershipFeeCycle, attrs) do
case Ash.create(MembershipFeeCycle, attrs, domain: MembershipFees) do
{:ok, _new_cycle} ->
# Reload member with cycles
updated_member =