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

Open
moritz wants to merge 65 commits from feature/280_membership_fee_ui into main
2 changed files with 714 additions and 109 deletions
Showing only changes of commit 920cae656e - Show all commits

View file

@ -43,16 +43,30 @@ defmodule MvWeb.MemberLive.Show do
<%!-- Tab Navigation --%>
<div role="tablist" class="tabs tabs-bordered mb-6">
<button role="tab" class="tab tab-active" aria-selected="true">
<button
role="tab"
class={["tab", if(@active_tab == :contact, do: "tab-active", else: "")]}
aria-selected={@active_tab == :contact}
phx-click="switch_tab"
phx-value-tab="contact"
>
<.icon name="hero-identification" class="size-4 mr-2" />
{gettext("Contact Data")}
</button>
<button role="tab" class="tab" disabled aria-disabled="true" title={gettext("Coming soon")}>
<button
role="tab"
class={["tab", if(@active_tab == :membership_fees, do: "tab-active", else: "")]}
aria-selected={@active_tab == :membership_fees}
phx-click="switch_tab"
phx-value-tab="membership_fees"
>
<.icon name="hero-credit-card" class="size-4 mr-2" />
{gettext("Payments")}
{gettext("Membership Fees")}
</button>
</div>
<%= if @active_tab == :contact do %>
<%!-- Contact Data Tab Content --%>
<%!-- Personal Data and Custom Fields Row --%>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<%!-- Personal Data Section --%>
@ -169,13 +183,23 @@ defmodule MvWeb.MemberLive.Show do
</div>
</.section_box>
</div>
<% end %>
<%= if @active_tab == :membership_fees do %>
<%!-- Membership Fees Tab Content --%>
<.live_component
module={MvWeb.MemberLive.Show.MembershipFeesComponent}
id={"membership-fees-#{@member.id}"}
member={@member}
/>
<% end %>
</Layouts.app>
"""
end
@impl true
def mount(_params, _session, socket) do
{:ok, socket}
{:ok, assign(socket, :active_tab, :contact)}
end
@impl true
@ -183,7 +207,12 @@ defmodule MvWeb.MemberLive.Show do
query =
Mv.Membership.Member
|> filter(id == ^id)
|> load([:user, custom_field_values: [:custom_field]])
|> load([
:user,
:membership_fee_type,
custom_field_values: [:custom_field],
membership_fee_cycles: [:membership_fee_type]
])
member = Ash.read_one!(query)
@ -193,6 +222,15 @@ defmodule MvWeb.MemberLive.Show do
|> assign(:member, member)}
end
@impl true
def handle_event("switch_tab", %{"tab" => "contact"}, socket) do
{:noreply, assign(socket, :active_tab, :contact)}
end
def handle_event("switch_tab", %{"tab" => "membership_fees"}, socket) do
{:noreply, assign(socket, :active_tab, :membership_fees)}
end
defp page_title(:show), do: gettext("Show Member")
defp page_title(:edit), do: gettext("Edit Member")

View file

@ -0,0 +1,567 @@
defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
@moduledoc """
LiveComponent for displaying and managing membership fees for a member.
## Features
- Display all membership fee cycles in a table
- Change membership fee type (with same-interval validation)
- Change cycle status (paid/unpaid/suspended)
- Regenerate cycles manually
- Delete cycles (with confirmation)
- Edit cycle amount (with modal)
"""
use MvWeb, :live_component
require Ash.Query
alias Mv.Membership
alias Mv.MembershipFees.MembershipFeeType
alias Mv.MembershipFees.CycleGenerator
alias MvWeb.Helpers.MembershipFeeHelpers
@impl true
def render(assigns) do
~H"""
<div id={@id}>
<.section_box title={gettext("Membership Fees")}>
<%!-- Membership Fee Type Selection --%>
<div class="mb-6">
<label class="label">
<span class="label-text font-semibold">{gettext("Membership Fee Type")}</span>
</label>
<select
class="select select-bordered w-full max-w-xs"
phx-change="change_membership_fee_type"
phx-target={@myself}
value={@member.membership_fee_type_id || ""}
>
<option value="">{gettext("None")}</option>
<%= for fee_type <- @available_fee_types do %>
<option value={fee_type.id} selected={fee_type.id == @member.membership_fee_type_id}>
{fee_type.name} ({MembershipFeeHelpers.format_currency(fee_type.amount)}, {MembershipFeeHelpers.format_interval(
fee_type.interval
)})
</option>
<% end %>
</select>
<%= if @interval_warning do %>
<div class="alert alert-warning mt-2">
<.icon name="hero-exclamation-triangle" class="size-5" />
<span>{@interval_warning}</span>
</div>
<% end %>
</div>
<%!-- Action Buttons --%>
<div class="flex gap-2 mb-4">
<.button
phx-click="regenerate_cycles"
phx-target={@myself}
class={["btn btn-sm btn-outline", if(@regenerating, do: "btn-disabled", else: "")]}
>
<.icon name="hero-arrow-path" class="size-4" />
{if(@regenerating, do: gettext("Regenerating..."), else: gettext("Regenerate Cycles"))}
</.button>
<.button
phx-click="regenerate_missing_cycles"
phx-target={@myself}
class={["btn btn-sm btn-outline", if(@regenerating, do: "btn-disabled", else: "")]}
>
<.icon name="hero-plus-circle" class="size-4" />
{gettext("Regenerate Missing Cycles")}
</.button>
</div>
<%!-- Cycles Table --%>
<%= if Enum.any?(@cycles) do %>
<.table
id="membership-fee-cycles"
rows={@cycles}
row_id={fn cycle -> "cycle-#{cycle.id}" end}
>
<:col :let={cycle} label={gettext("Cycle")}>
{MembershipFeeHelpers.format_cycle_range(
cycle.cycle_start,
cycle.membership_fee_type.interval
)}
</:col>
<:col :let={cycle} label={gettext("Interval")}>
<span class="badge badge-outline">
{MembershipFeeHelpers.format_interval(cycle.membership_fee_type.interval)}
</span>
</:col>
<:col :let={cycle} label={gettext("Amount")}>
<span class="font-mono">{MembershipFeeHelpers.format_currency(cycle.amount)}</span>
</:col>
<:col :let={cycle} label={gettext("Status")}>
<% badge = MembershipFeeHelpers.status_color(cycle.status) %>
<% icon = MembershipFeeHelpers.status_icon(cycle.status) %>
<span class={["badge", badge]}>
<.icon name={icon} class="size-4" />
{format_status_label(cycle.status)}
</span>
</:col>
<:action :let={cycle}>
<div class="dropdown dropdown-end">
<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
type="button"
phx-click="mark_cycle_status"
phx-value-cycle_id={cycle.id}
phx-value-status="paid"
phx-target={@myself}
class={if(cycle.status == :paid, do: "active", else: "")}
>
<.icon name="hero-check-circle" class="size-4" />
{gettext("Mark as paid")}
</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
type="button"
phx-click="mark_cycle_status"
phx-value-cycle_id={cycle.id}
phx-value-status="suspended"
phx-target={@myself}
class={if(cycle.status == :suspended, do: "active", else: "")}
>
<.icon name="hero-pause-circle" class="size-4" />
{gettext("Mark as suspended")}
</button>
</li>
<li>
<hr class="my-1" />
</li>
<li>
<button
type="button"
phx-click="edit_cycle_amount"
phx-value-cycle_id={cycle.id}
phx-target={@myself}
>
<.icon name="hero-pencil" class="size-4" />
{gettext("Edit amount")}
</button>
</li>
<li>
<button
type="button"
phx-click="delete_cycle"
phx-value-cycle_id={cycle.id}
phx-target={@myself}
class="text-error"
>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete")}
</button>
</li>
</ul>
</div>
</:action>
</.table>
<% else %>
<div class="alert alert-info">
<.icon name="hero-information-circle" class="size-5" />
<span>
{gettext(
"No membership fee cycles found. Cycles will be generated automatically when a membership fee type is assigned."
)}
</span>
</div>
<% end %>
</.section_box>
<%!-- Edit Cycle Amount Modal --%>
<%= if @editing_cycle do %>
<dialog id="edit-cycle-amount-modal" class="modal modal-open">
<div class="modal-box">
<h3 class="text-lg font-bold">{gettext("Edit Cycle Amount")}</h3>
<form phx-submit="save_cycle_amount" phx-target={@myself}>
<input type="hidden" name="cycle_id" value={@editing_cycle.id} />
<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(@editing_cycle.amount)}
class="input input-bordered w-full"
required
/>
</div>
<div class="modal-action">
<button type="button" phx-click="cancel_edit_amount" phx-target={@myself} class="btn">
{gettext("Cancel")}
</button>
<button type="submit" class="btn btn-primary">{gettext("Save")}</button>
</div>
</form>
</div>
</dialog>
<% end %>
<%!-- Delete Cycle Confirmation Modal --%>
<%= if @deleting_cycle do %>
<dialog id="delete-cycle-modal" class="modal modal-open">
<div class="modal-box">
<h3 class="text-lg font-bold">{gettext("Delete Cycle")}</h3>
<p class="py-4">
{gettext("Are you sure you want to delete this cycle?")}
</p>
<p class="text-sm text-base-content/70 mb-4">
{MembershipFeeHelpers.format_cycle_range(
@deleting_cycle.cycle_start,
@deleting_cycle.membership_fee_type.interval
)} - {MembershipFeeHelpers.format_currency(@deleting_cycle.amount)}
</p>
<div class="modal-action">
<button phx-click="cancel_delete_cycle" phx-target={@myself} class="btn">
{gettext("Cancel")}
</button>
<button
phx-click="confirm_delete_cycle"
phx-value-cycle_id={@deleting_cycle.id}
phx-target={@myself}
class="btn btn-error"
>
{gettext("Delete")}
</button>
</div>
</div>
</dialog>
<% end %>
</div>
"""
end
@impl true
def update(assigns, socket) do
member = assigns.member
# Load cycles if not already loaded
cycles =
case member.membership_fee_cycles do
nil -> []
cycles when is_list(cycles) -> cycles
_ -> []
end
# Sort cycles by cycle_start descending (newest first)
cycles = Enum.sort_by(cycles, & &1.cycle_start, {:desc, Date})
# Get available fee types (filtered to same interval if member has a type)
available_fee_types = get_available_fee_types(member)
{:ok,
socket
|> assign(assigns)
|> assign_new(:cycles, fn -> cycles end)
|> assign_new(:available_fee_types, fn -> available_fee_types end)
|> assign_new(:interval_warning, fn -> nil end)
|> assign_new(:editing_cycle, fn -> nil end)
|> assign_new(:deleting_cycle, fn -> nil end)
|> assign_new(:regenerating, fn -> false end)}
end
@impl true
def handle_event("change_membership_fee_type", %{"value" => ""}, socket) do
# Remove membership fee type
case update_member_fee_type(socket.assigns.member, nil) do
{:ok, updated_member} ->
send(self(), {:member_updated, updated_member})
{:noreply,
socket
|> assign(:member, updated_member)
|> assign(:cycles, [])
|> assign(:available_fee_types, get_available_fee_types(updated_member))
|> assign(:interval_warning, nil)
|> put_flash(:info, gettext("Membership fee type removed"))}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_error(error))}
end
end
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)
# Check if interval matches
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
interval_warning =
if member.membership_fee_type &&
member.membership_fee_type.interval != new_fee_type.interval do
gettext(
"Warning: Changing from %{old_interval} to %{new_interval} is not allowed. Please select a membership fee type with the same interval.",
old_interval: MembershipFeeHelpers.format_interval(member.membership_fee_type.interval),
new_interval: MembershipFeeHelpers.format_interval(new_fee_type.interval)
)
else
nil
end
if interval_warning do
{:noreply, assign(socket, :interval_warning, interval_warning)}
else
case update_member_fee_type(member, fee_type_id) do
{:ok, updated_member} ->
# Reload member with cycles
updated_member =
updated_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(:available_fee_types, get_available_fee_types(updated_member))
|> assign(:interval_warning, nil)
|> put_flash(:info, gettext("Membership fee type updated. Cycles regenerated."))}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_error(error))}
end
end
end
def handle_event("mark_cycle_status", %{"cycle_id" => cycle_id, "status" => status_str}, socket) do
status = String.to_existing_atom(status_str)
cycle = find_cycle(socket.assigns.cycles, cycle_id)
action =
case status do
:paid -> :mark_as_paid
:unpaid -> :mark_as_unpaid
:suspended -> :mark_as_suspended
end
case Ash.update!(cycle, action) do
updated_cycle ->
updated_cycles = replace_cycle(socket.assigns.cycles, updated_cycle)
{:noreply,
socket
|> assign(:cycles, updated_cycles)
|> put_flash(:info, gettext("Cycle status updated"))}
end
end
def handle_event("regenerate_cycles", _params, socket) do
member = socket.assigns.member
case CycleGenerator.generate_cycles_for_member(member.id) do
{:ok, _new_cycles} ->
# 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(:regenerating, false)
|> put_flash(:info, gettext("Cycles regenerated successfully"))}
{:error, error} ->
{:noreply,
socket
|> assign(:regenerating, false)
|> put_flash(:error, format_error(error))}
end
end
def handle_event("regenerate_missing_cycles", _params, socket) do
# Same as regenerate_cycles - CycleGenerator already handles missing cycles only
handle_event("regenerate_cycles", %{}, socket)
end
def handle_event("edit_cycle_amount", %{"cycle_id" => cycle_id}, socket) do
cycle = find_cycle(socket.assigns.cycles, cycle_id)
# Load cycle with membership_fee_type for display
cycle = Ash.load!(cycle, :membership_fee_type)
{:noreply, assign(socket, :editing_cycle, cycle)}
end
def handle_event("cancel_edit_amount", _params, socket) do
{:noreply, assign(socket, :editing_cycle, nil)}
end
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
{amount, _} when is_struct(amount, Decimal) ->
case Ash.update(cycle, :update, %{amount: amount}) do
{:ok, updated_cycle} ->
updated_cycles = replace_cycle(socket.assigns.cycles, updated_cycle)
{:noreply,
socket
|> assign(:cycles, updated_cycles)
|> assign(:editing_cycle, nil)
|> put_flash(:info, gettext("Cycle amount updated"))}
{:error, error} ->
{:noreply,
socket
|> put_flash(:error, format_error(error))}
end
:error ->
{:noreply, put_flash(socket, :error, gettext("Invalid amount format"))}
end
end
def handle_event("delete_cycle", %{"cycle_id" => cycle_id}, socket) do
cycle = find_cycle(socket.assigns.cycles, cycle_id)
# Load cycle with membership_fee_type for display
cycle = Ash.load!(cycle, :membership_fee_type)
{:noreply, assign(socket, :deleting_cycle, cycle)}
end
def handle_event("cancel_delete_cycle", _params, socket) do
{:noreply, assign(socket, :deleting_cycle, nil)}
end
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
:ok ->
updated_cycles = Enum.reject(socket.assigns.cycles, &(&1.id == cycle_id))
{:noreply,
socket
|> assign(:cycles, updated_cycles)
|> assign(:deleting_cycle, nil)
|> put_flash(:info, gettext("Cycle deleted"))}
{:error, error} ->
{:noreply,
socket
|> assign(:deleting_cycle, nil)
|> put_flash(:error, format_error(error))}
end
end
# Helper functions
defp get_available_fee_types(member) do
all_types =
MembershipFeeType
|> Ash.Query.sort(name: :asc)
|> Ash.read!()
# If member has a fee type, filter to same interval
if member.membership_fee_type do
Enum.filter(all_types, fn type ->
type.interval == member.membership_fee_type.interval
end)
else
all_types
end
end
defp update_member_fee_type(member, fee_type_id) do
attrs = %{membership_fee_type_id: fee_type_id}
member
|> Ash.Changeset.for_update(:update_member, attrs, domain: Membership)
|> Ash.update(domain: Membership)
end
defp find_cycle(cycles, cycle_id) do
case Enum.find(cycles, &(&1.id == cycle_id)) do
nil -> raise "Cycle not found: #{cycle_id}"
cycle -> cycle
end
end
defp replace_cycle(cycles, updated_cycle) do
Enum.map(cycles, fn cycle ->
if cycle.id == updated_cycle.id, do: updated_cycle, else: cycle
end)
end
defp format_status_label(:paid), do: gettext("Paid")
defp format_status_label(:unpaid), do: gettext("Unpaid")
defp format_status_label(:suspended), do: gettext("Suspended")
defp format_error(%Ash.Error.Invalid{} = error) do
Enum.map_join(error.errors, ", ", fn e -> e.message end)
end
defp format_error(error) when is_binary(error), do: error
defp format_error(_error), do: gettext("An error occurred")
# Helper component for section box
attr :title, :string, required: true
slot :inner_block, required: true
defp section_box(assigns) do
~H"""
<section class="mb-6">
<h2 class="text-lg font-semibold mb-3">{@title}</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
{render_slot(@inner_block)}
</div>
</section>
"""
end
end