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,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