diff --git a/lib/mv_web/live/member_live/show.ex b/lib/mv_web/live/member_live/show.ex index d84fca4..faa4ffc 100644 --- a/lib/mv_web/live/member_live/show.ex +++ b/lib/mv_web/live/member_live/show.ex @@ -43,139 +43,163 @@ defmodule MvWeb.MemberLive.Show do <%!-- Tab Navigation --%>
- -
- <%!-- Personal Data and Custom Fields Row --%> -
- <%!-- Personal Data Section --%> -
- <.section_box title={gettext("Personal Data")}> -
- <%!-- Name Row --%> -
- <.data_field label={gettext("First Name")} value={@member.first_name} class="w-48" /> - <.data_field label={gettext("Last Name")} value={@member.last_name} class="w-48" /> -
+ <%= if @active_tab == :contact do %> + <%!-- Contact Data Tab Content --%> + <%!-- Personal Data and Custom Fields Row --%> +
+ <%!-- Personal Data Section --%> +
+ <.section_box title={gettext("Personal Data")}> +
+ <%!-- Name Row --%> +
+ <.data_field label={gettext("First Name")} value={@member.first_name} class="w-48" /> + <.data_field label={gettext("Last Name")} value={@member.last_name} class="w-48" /> +
- <%!-- Address --%> -
- <.data_field label={gettext("Address")} value={format_address(@member)} /> -
- - <%!-- Email --%> -
- <.data_field label={gettext("Email")}> - - {@member.email} - - -
- - <%!-- Phone --%> -
- <.data_field label={gettext("Phone")} value={@member.phone_number} /> -
- - <%!-- Membership Dates Row --%> -
- <.data_field - label={gettext("Join Date")} - value={format_date(@member.join_date)} - class="w-28" - /> - <.data_field - label={gettext("Exit Date")} - value={format_date(@member.exit_date)} - class="w-28" - /> -
- - <%!-- Linked User --%> -
- <.data_field label={gettext("Linked User")}> - <%= if @member.user do %> - <.link - navigate={~p"/users/#{@member.user}"} - class="text-blue-700 hover:text-blue-800 underline inline-flex items-center gap-1" - > - <.icon name="hero-user" class="size-4" /> - {@member.user.email} - - <% else %> - {gettext("No user linked")} - <% end %> - -
- - <%!-- Notes --%> - <%= if @member.notes && String.trim(@member.notes) != "" do %> + <%!-- Address --%>
- <.data_field label={gettext("Notes")}> -

{@member.notes}

+ <.data_field label={gettext("Address")} value={format_address(@member)} /> +
+ + <%!-- Email --%> +
+ <.data_field label={gettext("Email")}> + + {@member.email} +
- <% end %> -
- -
- <%!-- Custom Fields Section --%> - <%= if Enum.any?(@member.custom_field_values) do %> -
- <.section_box title={gettext("Custom Fields")}> -
- <%= for cfv <- sort_custom_field_values(@member.custom_field_values) do %> - <% custom_field = cfv.custom_field %> - <% value_type = custom_field && custom_field.value_type %> - <.data_field label={custom_field && custom_field.name}> - {format_custom_field_value(cfv.value, value_type)} + <%!-- Phone --%> +
+ <.data_field label={gettext("Phone")} value={@member.phone_number} /> +
+ + <%!-- Membership Dates Row --%> +
+ <.data_field + label={gettext("Join Date")} + value={format_date(@member.join_date)} + class="w-28" + /> + <.data_field + label={gettext("Exit Date")} + value={format_date(@member.exit_date)} + class="w-28" + /> +
+ + <%!-- Linked User --%> +
+ <.data_field label={gettext("Linked User")}> + <%= if @member.user do %> + <.link + navigate={~p"/users/#{@member.user}"} + class="text-blue-700 hover:text-blue-800 underline inline-flex items-center gap-1" + > + <.icon name="hero-user" class="size-4" /> + {@member.user.email} + + <% else %> + {gettext("No user linked")} + <% end %> +
+ + <%!-- Notes --%> + <%= if @member.notes && String.trim(@member.notes) != "" do %> +
+ <.data_field label={gettext("Notes")}> +

{@member.notes}

+ +
<% end %>
- <% end %> -
- <%!-- Payment Data Section (Mockup) --%> -
- <.section_box title={gettext("Payment Data")}> - + <%!-- Custom Fields Section --%> + <%= if Enum.any?(@member.custom_field_values) do %> +
+ <.section_box title={gettext("Custom Fields")}> +
+ <%= for cfv <- sort_custom_field_values(@member.custom_field_values) do %> + <% custom_field = cfv.custom_field %> + <% value_type = custom_field && custom_field.value_type %> + <.data_field label={custom_field && custom_field.name}> + {format_custom_field_value(cfv.value, value_type)} + + <% end %> +
+ +
+ <% end %> +
-
- <.data_field label={gettext("Contribution")} value="72 €" class="w-24" /> - <.data_field label={gettext("Payment Cycle")} value={gettext("monthly")} class="w-28" /> - <.data_field label={gettext("Paid")} class="w-24"> - <%= if @member.paid do %> - {gettext("Paid")} - <% else %> - {gettext("Pending")} - <% end %> - -
- -
+ <%!-- Payment Data Section (Mockup) --%> +
+ <.section_box title={gettext("Payment Data")}> + + +
+ <.data_field label={gettext("Contribution")} value="72 €" class="w-24" /> + <.data_field label={gettext("Payment Cycle")} value={gettext("monthly")} class="w-28" /> + <.data_field label={gettext("Paid")} class="w-24"> + <%= if @member.paid do %> + {gettext("Paid")} + <% else %> + {gettext("Pending")} + <% end %> + +
+ +
+ <% 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 %> """ 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") diff --git a/lib/mv_web/live/member_live/show/membership_fees_component.ex b/lib/mv_web/live/member_live/show/membership_fees_component.ex new file mode 100644 index 0000000..2a24591 --- /dev/null +++ b/lib/mv_web/live/member_live/show/membership_fees_component.ex @@ -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""" +
+ <.section_box title={gettext("Membership Fees")}> + <%!-- Membership Fee Type Selection --%> +
+ + + <%= if @interval_warning do %> +
+ <.icon name="hero-exclamation-triangle" class="size-5" /> + {@interval_warning} +
+ <% end %> +
+ + <%!-- Action Buttons --%> +
+ <.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 + 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")} + +
+ + <%!-- 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 :let={cycle} label={gettext("Interval")}> + + {MembershipFeeHelpers.format_interval(cycle.membership_fee_type.interval)} + + + + <:col :let={cycle} label={gettext("Amount")}> + {MembershipFeeHelpers.format_currency(cycle.amount)} + + + <:col :let={cycle} label={gettext("Status")}> + <% badge = MembershipFeeHelpers.status_color(cycle.status) %> + <% icon = MembershipFeeHelpers.status_icon(cycle.status) %> + + <.icon name={icon} class="size-4" /> + {format_status_label(cycle.status)} + + + + <:action :let={cycle}> + + + + <% else %> +
+ <.icon name="hero-information-circle" class="size-5" /> + + {gettext( + "No membership fee cycles found. Cycles will be generated automatically when a membership fee type is assigned." + )} + +
+ <% end %> + + + <%!-- Edit Cycle Amount Modal --%> + <%= if @editing_cycle do %> + + + + <% end %> + + <%!-- Delete Cycle Confirmation Modal --%> + <%= if @deleting_cycle do %> + + + + <% end %> +
+ """ + 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 + 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""" +
+

{@title}

+
+ {render_slot(@inner_block)} +
+
+ """ + end +end