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

Open
moritz wants to merge 65 commits from feature/280_membership_fee_ui into main
Showing only changes of commit 128866ead3 - Show all commits

View file

@ -16,7 +16,9 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
alias Mv.Membership alias Mv.Membership
alias Mv.MembershipFees.MembershipFeeType alias Mv.MembershipFees.MembershipFeeType
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.CycleGenerator alias Mv.MembershipFees.CycleGenerator
alias Mv.MembershipFees.CalendarCycles
alias MvWeb.Helpers.MembershipFeeHelpers alias MvWeb.Helpers.MembershipFeeHelpers
@impl true @impl true
@ -56,6 +58,26 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
<.icon name="hero-arrow-path" class="size-4" /> <.icon name="hero-arrow-path" class="size-4" />
{if(@regenerating, do: gettext("Regenerating..."), else: gettext("Regenerate Cycles"))} {if(@regenerating, do: gettext("Regenerating..."), else: gettext("Regenerate Cycles"))}
</.button> </.button>
<.button
:if={Enum.any?(@cycles)}
phx-click="delete_all_cycles"
phx-target={@myself}
class="btn btn-sm btn-error btn-outline"
title={gettext("Delete all cycles")}
>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete All Cycles")}
</.button>
<.button
:if={@member.membership_fee_type}
phx-click="open_create_cycle_modal"
phx-target={@myself}
class="btn btn-sm btn-primary"
title={gettext("Create a new cycle manually")}
>
<.icon name="hero-plus" class="size-4" />
{gettext("Create Cycle")}
</.button>
</div> </div>
<%!-- Cycles Table --%> <%!-- Cycles Table --%>
@ -79,7 +101,15 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
</:col> </:col>
<:col :let={cycle} label={gettext("Amount")}> <:col :let={cycle} label={gettext("Amount")}>
<span class="font-mono">{MembershipFeeHelpers.format_currency(cycle.amount)}</span> <span
class="font-mono cursor-pointer hover:text-primary"
phx-click="edit_cycle_amount"
phx-value-cycle_id={cycle.id}
phx-target={@myself}
title={gettext("Click to edit amount")}
>
{MembershipFeeHelpers.format_currency(cycle.amount)}
</span>
</:col> </:col>
<:col :let={cycle} label={gettext("Status")}> <:col :let={cycle} label={gettext("Status")}>
@ -92,80 +122,59 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
</:col> </:col>
<:action :let={cycle}> <:action :let={cycle}>
<div class="dropdown dropdown-end"> <div class="flex gap-1 flex-wrap">
<label tabindex="0" class="btn btn-ghost btn-xs">
<.icon name="hero-ellipsis-vertical" class="size-4" />
</label>
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box z-[1] w-52 p-2 shadow"
>
<li>
<button <button
:if={cycle.status != :paid}
type="button" type="button"
phx-click="mark_cycle_status" phx-click="mark_cycle_status"
phx-value-cycle_id={cycle.id} phx-value-cycle_id={cycle.id}
phx-value-status="paid" phx-value-status="paid"
phx-target={@myself} phx-target={@myself}
class={if(cycle.status == :paid, do: "active", else: "")} class="btn btn-sm btn-success"
title={gettext("Mark as paid")}
> >
<.icon name="hero-check-circle" class="size-4" /> <.icon name="hero-check-circle" class="size-4" />
{gettext("Mark as paid")} {gettext("Paid")}
</button> </button>
</li>
<li>
<button
type="button"
phx-click="mark_cycle_status"
phx-value-cycle_id={cycle.id}
phx-value-status="unpaid"
phx-target={@myself}
class={if(cycle.status == :unpaid, do: "active", else: "")}
>
<.icon name="hero-x-circle" class="size-4" />
{gettext("Mark as unpaid")}
</button>
</li>
<li>
<button <button
type="button" type="button"
phx-click="mark_cycle_status" phx-click="mark_cycle_status"
phx-value-cycle_id={cycle.id} phx-value-cycle_id={cycle.id}
phx-value-status="suspended" phx-value-status="suspended"
phx-target={@myself} phx-target={@myself}
class={if(cycle.status == :suspended, do: "active", else: "")} class={[
"btn btn-sm",
if(cycle.status == :suspended, do: "btn-warning", else: "btn-outline btn-warning")
]}
title={gettext("Mark as suspended")}
> >
<.icon name="hero-pause-circle" class="size-4" /> <.icon name="hero-pause-circle" class="size-4" />
{gettext("Mark as suspended")} {gettext("Suspended")}
</button> </button>
</li>
<li>
<hr class="my-1" />
</li>
<li>
<button <button
:if={cycle.status != :unpaid}
type="button" type="button"
phx-click="edit_cycle_amount" phx-click="mark_cycle_status"
phx-value-cycle_id={cycle.id} phx-value-cycle_id={cycle.id}
phx-value-status="unpaid"
phx-target={@myself} phx-target={@myself}
class="btn btn-sm btn-error"
title={gettext("Mark as unpaid")}
> >
<.icon name="hero-pencil" class="size-4" /> <.icon name="hero-x-circle" class="size-4" />
{gettext("Edit amount")} {gettext("Unpaid")}
</button> </button>
</li>
<li>
<button <button
type="button" type="button"
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="text-error" class="btn btn-sm btn-error btn-outline"
title={gettext("Delete cycle")}
> >
<.icon name="hero-trash" class="size-4" /> <.icon name="hero-trash" class="size-4" />
{gettext("Delete")} {gettext("Delete")}
</button> </button>
</li>
</ul>
</div> </div>
</:action> </:action>
</.table> </.table>
@ -243,6 +252,130 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
</div> </div>
</dialog> </dialog>
<% end %> <% end %>
<%!-- Delete All Cycles Confirmation Modal --%>
<%= if @deleting_all_cycles do %>
<dialog id="delete-all-cycles-modal" class="modal modal-open">
<div class="modal-box">
<h3 class="text-lg font-bold text-error">{gettext("Delete All Cycles")}</h3>
<div class="alert alert-warning mt-4">
<.icon name="hero-exclamation-triangle" class="size-5" />
<div>
<h4 class="font-bold">{gettext("Warning")}</h4>
<p>
{gettext("You are about to delete all %{count} cycles for this member.",
count: length(@cycles)
)}
</p>
<p class="mt-2">
{gettext("This action cannot be undone.")}
</p>
</div>
</div>
<div class="form-control w-full mt-4">
<label class="label">
<span class="label-text">
{gettext("Type '%{confirmation}' to confirm", confirmation: gettext("Yes"))}
</span>
</label>
<input
type="text"
phx-keyup="update_delete_all_confirmation"
phx-target={@myself}
value={@delete_all_confirmation || ""}
class="input input-bordered w-full"
placeholder={gettext("Yes")}
/>
</div>
<div class="modal-action">
<button phx-click="cancel_delete_all_cycles" phx-target={@myself} class="btn">
{gettext("Cancel")}
</button>
<button
phx-click="confirm_delete_all_cycles"
phx-target={@myself}
class="btn btn-error"
disabled={
@delete_all_confirmation != gettext("Yes") && @delete_all_confirmation != "Yes"
}
>
{gettext("Delete All")}
</button>
</div>
</div>
</dialog>
<% end %>
<%!-- Create Cycle Modal --%>
<%= if @creating_cycle do %>
<dialog id="create-cycle-modal" class="modal modal-open">
<div class="modal-box">
<h3 class="text-lg font-bold">{gettext("Create Cycle")}</h3>
<form phx-submit="create_cycle" phx-target={@myself}>
<div class="form-control w-full mt-4">
<label class="label">
<span class="label-text">{gettext("Date")}</span>
moritz marked this conversation as resolved

If I select a date I get an error and the modal closes and the member overview is shown

If I select a date I get an error and the modal closes and the member overview is shown
</label>
<input
type="date"
name="date"
value={@create_cycle_date || ""}
phx-change="update_create_cycle_date"
phx-target={@myself}
class="input input-bordered w-full"
required
/>
<label class="label">
<span class="label-text-alt">
{gettext(
"The cycle period will be calculated based on this date and the interval."
)}
</span>
</label>
</div>
<%= if @create_cycle_date do %>
<div class="form-control w-full mt-4">
<label class="label">
<span class="label-text">{gettext("Cycle Period")}</span>
</label>
<div class="text-sm text-base-content/70">
{format_create_cycle_period(
@create_cycle_date,
@member.membership_fee_type.interval
)}
</div>
</div>
<% end %>
<div class="form-control w-full mt-4">
<label class="label">
<span class="label-text">{gettext("Amount")}</span>
</label>
<input
type="number"
name="amount"
step="0.01"
min="0"
value={Decimal.to_string(@member.membership_fee_type.amount)}
class="input input-bordered w-full"
required
/>
</div>
<%= if @create_cycle_error do %>
<div class="alert alert-error mt-4">
<.icon name="hero-exclamation-circle" class="size-5" />
<span>{@create_cycle_error}</span>
</div>
<% end %>
<div class="modal-action">
<button type="button" phx-click="cancel_create_cycle" phx-target={@myself} class="btn">
{gettext("Cancel")}
</button>
<button type="submit" class="btn btn-primary">{gettext("Create")}</button>
</div>
</form>
</div>
</dialog>
<% end %>
</div> </div>
""" """
end end
@ -273,6 +406,11 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|> assign_new(:interval_warning, fn -> nil end) |> assign_new(:interval_warning, fn -> nil end)
|> assign_new(:editing_cycle, fn -> nil end) |> assign_new(:editing_cycle, fn -> nil end)
|> assign_new(:deleting_cycle, fn -> nil end) |> assign_new(:deleting_cycle, fn -> nil end)
|> assign_new(:deleting_all_cycles, fn -> false end)
|> assign_new(:delete_all_confirmation, fn -> "" end)
|> 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)}
end end
@ -509,6 +647,173 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
end end
end end
def handle_event("delete_all_cycles", _params, socket) do
{:noreply,
socket
|> assign(:deleting_all_cycles, true)
|> assign(:delete_all_confirmation, "")}
end
def handle_event("cancel_delete_all_cycles", _params, socket) do
{:noreply,
socket
|> assign(:deleting_all_cycles, false)
|> assign(:delete_all_confirmation, "")}
end
def handle_event("update_delete_all_confirmation", %{"value" => value}, socket) do
{:noreply, assign(socket, :delete_all_confirmation, value)}
end
def handle_event("confirm_delete_all_cycles", _params, socket) do
member = socket.assigns.member
cycles = socket.assigns.cycles
# Delete all cycles
results =
Enum.map(cycles, fn cycle ->
Ash.destroy(cycle)
end)
# Check if all deletions were successful
errors = Enum.filter(results, &match?({:error, _}, &1))
if Enum.empty?(errors) do
# Reload member to get updated cycles
updated_member =
member
|> Ash.load!([
:membership_fee_type,
membership_fee_cycles: [:membership_fee_type]
])
updated_cycles =
Enum.sort_by(
updated_member.membership_fee_cycles || [],
& &1.cycle_start,
{:desc, Date}
)
send(self(), {:member_updated, updated_member})
{:noreply,
socket
|> assign(:member, updated_member)
|> assign(:cycles, updated_cycles)
|> assign(:deleting_all_cycles, false)
|> 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))}
end
end
def handle_event("open_create_cycle_modal", _params, socket) do
{:noreply,
socket
|> assign(:creating_cycle, true)
|> assign(:create_cycle_date, nil)
|> assign(:create_cycle_error, nil)}
end
def handle_event("cancel_create_cycle", _params, socket) do
{:noreply,
socket
|> assign(:creating_cycle, false)
|> assign(:create_cycle_date, nil)
|> assign(:create_cycle_error, nil)}
end
def handle_event("update_create_cycle_date", %{"value" => date_str}, socket) do
date =
case Date.from_iso8601(date_str) do
{:ok, date} -> date
_ -> nil
end
{:noreply,
socket
|> assign(:create_cycle_date, date)
|> assign(:create_cycle_error, nil)}
end
def handle_event("create_cycle", %{"date" => date_str, "amount" => amount_str}, socket) do
member = socket.assigns.member
with {:ok, date} <- Date.from_iso8601(date_str),
{amount, _} when is_struct(amount, Decimal) <- Decimal.parse(amount_str),
cycle_start <-
CalendarCycles.calculate_cycle_start(date, member.membership_fee_type.interval),
:ok <- validate_cycle_not_exists(socket.assigns.cycles, cycle_start) do
attrs = %{
cycle_start: cycle_start,
amount: amount,
status: :unpaid,
member_id: member.id,
membership_fee_type_id: member.membership_fee_type_id
}
case Ash.create(MembershipFeeCycle, attrs) do
{:ok, _new_cycle} ->
# Reload member with cycles
updated_member =
member
|> Ash.load!([
:membership_fee_type,
membership_fee_cycles: [:membership_fee_type]
])
cycles =
Enum.sort_by(
updated_member.membership_fee_cycles || [],
& &1.cycle_start,
{:desc, Date}
)
send(self(), {:member_updated, updated_member})
{:noreply,
socket
|> assign(:member, updated_member)
|> assign(:cycles, cycles)
|> assign(:creating_cycle, false)
|> assign(:create_cycle_date, nil)
|> assign(:create_cycle_error, nil)
|> put_flash(:info, gettext("Cycle created successfully"))}
{:error, error} ->
{:noreply,
socket
|> assign(:create_cycle_error, format_error(error))}
end
else
:error ->
{:noreply,
socket
|> assign(:create_cycle_error, gettext("Invalid date format"))}
{:error, :invalid_amount} ->
{:noreply,
socket
|> assign(:create_cycle_error, gettext("Invalid amount format"))}
{:error, :cycle_exists} ->
{:noreply,
socket
|> assign(
:create_cycle_error,
gettext("A cycle for this period already exists")
)}
end
end
# Helper functions # Helper functions
defp get_available_fee_types(member) do defp get_available_fee_types(member) do
@ -559,6 +864,24 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
defp format_error(error) when is_binary(error), do: error defp format_error(error) when is_binary(error), do: error
defp format_error(_error), do: gettext("An error occurred") defp format_error(_error), do: gettext("An error occurred")
defp validate_cycle_not_exists(cycles, cycle_start) do
if Enum.any?(cycles, &(&1.cycle_start == cycle_start)) do
{:error, :cycle_exists}
else
:ok
end
end
defp format_create_cycle_period(date, interval) when is_struct(date, Date) do
cycle_start = CalendarCycles.calculate_cycle_start(date, interval)
cycle_end = CalendarCycles.calculate_cycle_end(cycle_start, interval)
MembershipFeeHelpers.format_cycle_range(cycle_start, interval) <>
" (#{Calendar.strftime(cycle_start, "%d.%m.%Y")} - #{Calendar.strftime(cycle_end, "%d.%m.%Y")})"
end
defp format_create_cycle_period(_date, _interval), do: ""
# Helper component for section box # Helper component for section box
attr :title, :string, required: true attr :title, :string, required: true
slot :inner_block, required: true slot :inner_block, required: true