Member show: Vereinfacht link only, receipts table from API

- Show only 'Kontakt in Vereinfacht anzeigen' link (no Contact ID / Debug)
- Button loads receipts via get_contact_with_receipts, table with formatted columns
This commit is contained in:
Moritz 2026-02-23 19:21:24 +01:00
parent ede3df12ef
commit b60ab3f392
Signed by: moritz
GPG key ID: 1020A035E5DD0824
2 changed files with 172 additions and 32 deletions

View file

@ -256,7 +256,7 @@ defmodule MvWeb.MemberLive.Show do
id={"membership-fees-#{@member.id}"} id={"membership-fees-#{@member.id}"}
member={@member} member={@member}
current_user={@current_user} current_user={@current_user}
vereinfacht_debug_response={@vereinfacht_debug_response} vereinfacht_receipts={@vereinfacht_receipts}
/> />
<% end %> <% end %>
</Layouts.app> </Layouts.app>
@ -268,7 +268,7 @@ defmodule MvWeb.MemberLive.Show do
{:ok, {:ok,
socket socket
|> assign(:active_tab, :contact) |> assign(:active_tab, :contact)
|> assign(:vereinfacht_debug_response, nil)} |> assign(:vereinfacht_receipts, nil)}
end end
@impl true @impl true
@ -320,14 +320,14 @@ defmodule MvWeb.MemberLive.Show do
{:noreply, assign(socket, :active_tab, :membership_fees)} {:noreply, assign(socket, :active_tab, :membership_fees)}
end end
def handle_event("load_vereinfacht_debug", %{"contact_id" => contact_id}, socket) do def handle_event("load_vereinfacht_receipts", %{"contact_id" => contact_id}, socket) do
response = response =
case Mv.Vereinfacht.Client.get_contact(contact_id) do case Mv.Vereinfacht.Client.get_contact_with_receipts(contact_id) do
{:ok, body} -> {:ok, body} {:ok, receipts} -> {:ok, receipts}
{:error, reason} -> {:error, reason} {:error, reason} -> {:error, reason}
end end
{:noreply, assign(socket, :vereinfacht_debug_response, response)} {:noreply, assign(socket, :vereinfacht_receipts, response)}
end end
# Flash set in LiveComponent is not shown in parent layout; child sends this to display flash # Flash set in LiveComponent is not shown in parent layout; child sends this to display flash

View file

@ -54,42 +54,67 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
<%= if Mv.Config.vereinfacht_configured?() do %> <%= if Mv.Config.vereinfacht_configured?() do %>
<%= if @member.vereinfacht_contact_id do %> <%= if @member.vereinfacht_contact_id do %>
<div class="mb-4"> <div class="mb-4">
<label class="label"> <div class="flex flex-col gap-2">
<span class="label-text font-semibold">{gettext("Vereinfacht")}</span>
</label>
<div class="flex flex-col gap-1">
<span class="font-mono text-sm">
{gettext("Contact ID: %{id}", id: @member.vereinfacht_contact_id)}
</span>
<.link <.link
:if={Mv.Config.vereinfacht_contact_view_url(@member.vereinfacht_contact_id)} :if={Mv.Config.vereinfacht_contact_view_url(@member.vereinfacht_contact_id)}
href={Mv.Config.vereinfacht_contact_view_url(@member.vereinfacht_contact_id)} href={Mv.Config.vereinfacht_contact_view_url(@member.vereinfacht_contact_id)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="link link-accent underline inline-flex items-center gap-1" class="link link-accent underline inline-flex items-center gap-1 w-fit"
> >
{gettext("View contact in Vereinfacht")} {gettext("View contact in Vereinfacht")}
<.icon name="hero-arrow-top-right-on-square" class="inline-block size-4" /> <.icon name="hero-arrow-top-right-on-square" class="inline-block size-4" />
</.link> </.link>
<div class="mt-2"> <div>
<span class="text-base-content/70 text-sm">{gettext("Debug:")}</span>
<button <button
type="button" type="button"
phx-click="load_vereinfacht_debug" phx-click="load_vereinfacht_receipts"
phx-value-contact_id={@member.vereinfacht_contact_id} phx-value-contact_id={@member.vereinfacht_contact_id}
class="btn btn-sm btn-ghost ml-1" class="btn btn-sm btn-ghost"
> >
{gettext("Load API response")} {gettext("Show bookings/receipts from Vereinfacht")}
</button> </button>
</div> </div>
<%= if @vereinfacht_debug_response do %> <%= if @vereinfacht_receipts do %>
<div <div
class="mt-2 rounded border border-base-300 bg-base-200 p-3 overflow-x-auto max-h-96 overflow-y-auto" class="mt-2 rounded border border-base-300 bg-base-200 p-3 overflow-x-auto max-h-96 overflow-y-auto"
tabindex="0" tabindex="0"
role="region" role="region"
aria-label={gettext("Vereinfacht API response")} aria-label={gettext("Vereinfacht receipts")}
> >
<pre class="text-xs whitespace-pre-wrap font-mono"><%= format_vereinfacht_debug_response(@vereinfacht_debug_response) %></pre> <%= 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> </div>
<% end %> <% end %>
</div> </div>
@ -499,7 +524,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|> assign_new(:create_cycle_date, fn -> nil end) |> assign_new(:create_cycle_date, fn -> nil end)
|> assign_new(:create_cycle_error, 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_debug_response, fn -> nil end)} |> assign_new(:vereinfacht_receipts, fn -> nil end)}
end end
@impl true @impl true
@ -1057,23 +1082,138 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
defp format_create_cycle_period(_date, _interval), do: "" defp format_create_cycle_period(_date, _interval), do: ""
defp format_vereinfacht_debug_response({:ok, body}) when is_map(body) do defp format_vereinfacht_error({:http, status, detail}) when is_binary(detail),
Jason.encode!(body, pretty: true) 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 end
defp format_vereinfacht_debug_response({:error, {:http, status, detail}}) defp format_receipt_cell(:amount, nil), do: ""
when is_binary(detail) do
"Error: HTTP #{status} #{detail}" 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 end
defp format_vereinfacht_debug_response({:error, {:http, status, _}}) do defp format_receipt_cell(:amount, val) when is_binary(val) do
"Error: HTTP #{status}" case Decimal.parse(val) do
{d, _} -> MembershipFeeHelpers.format_currency(d)
:error -> val
end
end end
defp format_vereinfacht_debug_response({:error, reason}) do defp format_receipt_cell(:amount, val), do: to_string(val)
"Error: " <> inspect(reason)
defp format_receipt_cell(:status, nil), do: ""
defp format_receipt_cell(:status, val) when is_binary(val) do
translate_receipt_status(val)
end 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 # 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