Add non-functional preview pages for Contribution Types, Settings, and Member Contribution Periods with German translations
345 lines
11 KiB
Elixir
345 lines
11 KiB
Elixir
defmodule MvWeb.ContributionPeriodLive.Show do
|
||
@moduledoc """
|
||
Mock-up LiveView for Member Contribution Periods (Admin/Treasurer View).
|
||
|
||
This is a preview-only page that displays the planned UI for viewing
|
||
and managing contribution periods for a specific member.
|
||
It shows static mock data and is not functional.
|
||
|
||
## Planned Features (Future Implementation)
|
||
- Display all contribution periods for a member
|
||
- Show period dates, interval, amount, and status
|
||
- Quick status change (paid/unpaid/suspended)
|
||
- Bulk marking of multiple periods
|
||
- Notes per period
|
||
|
||
## Note
|
||
This page is intentionally non-functional and serves as a UI mockup
|
||
for the upcoming Membership Contributions feature.
|
||
"""
|
||
use MvWeb, :live_view
|
||
|
||
@impl true
|
||
def mount(_params, _session, socket) do
|
||
{:ok,
|
||
socket
|
||
|> assign(:page_title, gettext("Member Contributions"))
|
||
|> assign(:member, mock_member())
|
||
|> assign(:periods, mock_periods())
|
||
|> assign(:selected_periods, MapSet.new())}
|
||
end
|
||
|
||
@impl true
|
||
def render(assigns) do
|
||
~H"""
|
||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||
<.mockup_warning />
|
||
|
||
<.header>
|
||
{gettext("Contributions for %{name}", name: "#{@member.first_name} #{@member.last_name}")}
|
||
<:subtitle>
|
||
{gettext("Contribution type")}:
|
||
<span class="font-semibold">{@member.contribution_type}</span>
|
||
· {gettext("Member since")}: <span class="font-mono">{@member.joined_at}</span>
|
||
</:subtitle>
|
||
<:actions>
|
||
<.link navigate={~p"/contribution_settings"} class="btn btn-ghost btn-sm">
|
||
<.icon name="hero-arrow-left" class="size-4" />
|
||
{gettext("Back to Settings")}
|
||
</.link>
|
||
</:actions>
|
||
</.header>
|
||
|
||
<%!-- Member Info Card --%>
|
||
<div class="mb-6 shadow card bg-base-100">
|
||
<div class="card-body">
|
||
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||
<div>
|
||
<span class="text-sm text-base-content/60">{gettext("Email")}</span>
|
||
<p class="font-medium">{@member.email}</p>
|
||
</div>
|
||
<div>
|
||
<span class="text-sm text-base-content/60">{gettext("Contribution Start")}</span>
|
||
<p class="font-mono">{@member.contribution_start}</p>
|
||
</div>
|
||
<div>
|
||
<span class="text-sm text-base-content/60">{gettext("Total Contributions")}</span>
|
||
<p class="font-semibold">{length(@periods)}</p>
|
||
</div>
|
||
<div>
|
||
<span class="text-sm text-base-content/60">{gettext("Open Contributions")}</span>
|
||
<p class="font-semibold text-error">
|
||
{Enum.count(@periods, &(&1.status == :unpaid))}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<%!-- Contribution Type Change --%>
|
||
<div class="mb-6 card bg-base-200">
|
||
<div class="py-4 card-body">
|
||
<div class="flex flex-wrap items-center gap-4">
|
||
<span class="font-semibold">{gettext("Change Contribution Type")}:</span>
|
||
<select class="w-64 select select-bordered select-sm" disabled>
|
||
<option selected>{@member.contribution_type} (60,00 €, {gettext("Yearly")})</option>
|
||
<option>{gettext("Reduced")} (30,00 €, {gettext("Yearly")})</option>
|
||
<option>{gettext("Honorary")} (0,00 €, {gettext("Yearly")})</option>
|
||
</select>
|
||
<span
|
||
class="text-sm text-base-content/60 cursor-help tooltip tooltip-bottom"
|
||
data-tip={
|
||
gettext(
|
||
"Members can only switch between contribution types with the same payment interval (e.g., yearly to yearly). This prevents complex period overlaps."
|
||
)
|
||
}
|
||
>
|
||
<.icon name="hero-question-mark-circle" class="inline size-4" />
|
||
{gettext("Why are not all contribution types shown?")}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<%!-- Bulk Actions --%>
|
||
<div class="flex flex-wrap items-center gap-4 mb-4">
|
||
<span class="text-sm text-base-content/60">
|
||
{ngettext(
|
||
"%{count} period selected",
|
||
"%{count} periods selected",
|
||
MapSet.size(@selected_periods),
|
||
count: MapSet.size(@selected_periods)
|
||
)}
|
||
</span>
|
||
<button class="btn btn-sm btn-success" disabled>
|
||
<.icon name="hero-check" class="size-4" />
|
||
{gettext("Mark as Paid")}
|
||
</button>
|
||
<button class="btn btn-sm btn-ghost" disabled>
|
||
<.icon name="hero-minus-circle" class="size-4" />
|
||
{gettext("Mark as Suspended")}
|
||
</button>
|
||
<button class="btn btn-sm btn-ghost" disabled>
|
||
<.icon name="hero-x-circle" class="size-4" />
|
||
{gettext("Mark as Unpaid")}
|
||
</button>
|
||
</div>
|
||
|
||
<%!-- Periods Table --%>
|
||
<div class="overflow-x-auto">
|
||
<table class="table table-zebra">
|
||
<thead>
|
||
<tr>
|
||
<th>
|
||
<input type="checkbox" class="checkbox checkbox-sm" disabled />
|
||
</th>
|
||
<th>{gettext("Time Period")}</th>
|
||
<th>{gettext("Interval")}</th>
|
||
<th>{gettext("Amount")}</th>
|
||
<th>{gettext("Status")}</th>
|
||
<th>{gettext("Notes")}</th>
|
||
<th>{gettext("Actions")}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr :for={period <- @periods} class={period_row_class(period.status)}>
|
||
<td>
|
||
<input
|
||
type="checkbox"
|
||
class="checkbox checkbox-sm"
|
||
checked={MapSet.member?(@selected_periods, period.id)}
|
||
disabled
|
||
/>
|
||
</td>
|
||
<td>
|
||
<div class="font-mono">
|
||
{period.period_start} – {period.period_end}
|
||
</div>
|
||
<div :if={period.is_current} class="mt-1 badge badge-info badge-sm">
|
||
{gettext("Current")}
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<span class="badge badge-outline badge-sm">{format_interval(period.interval)}</span>
|
||
</td>
|
||
<td>
|
||
<span class="font-mono">{format_currency(period.amount)}</span>
|
||
</td>
|
||
<td>
|
||
<.status_badge status={period.status} />
|
||
</td>
|
||
<td>
|
||
<span :if={period.notes} class="text-sm italic text-base-content/60">
|
||
{period.notes}
|
||
</span>
|
||
<span :if={!period.notes} class="text-base-content/30">—</span>
|
||
</td>
|
||
<td class="w-0 font-semibold whitespace-nowrap">
|
||
<div class="flex gap-4">
|
||
<.link
|
||
href="#"
|
||
class={[
|
||
"cursor-not-allowed",
|
||
if(period.status == :paid, do: "invisible", else: "opacity-50")
|
||
]}
|
||
>
|
||
{gettext("Paid")}
|
||
</.link>
|
||
<.link
|
||
href="#"
|
||
class={[
|
||
"cursor-not-allowed",
|
||
if(period.status == :suspended, do: "invisible", else: "opacity-50")
|
||
]}
|
||
>
|
||
{gettext("Suspend")}
|
||
</.link>
|
||
<.link
|
||
href="#"
|
||
class={[
|
||
"cursor-not-allowed",
|
||
if(period.status != :paid, do: "invisible", else: "opacity-50")
|
||
]}
|
||
>
|
||
{gettext("Reopen")}
|
||
</.link>
|
||
<.link href="#" class="opacity-50 cursor-not-allowed">
|
||
{gettext("Note")}
|
||
</.link>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</Layouts.app>
|
||
"""
|
||
end
|
||
|
||
# Mock-up warning banner component - subtle orange style
|
||
defp mockup_warning(assigns) do
|
||
~H"""
|
||
<div class="flex items-center gap-3 px-4 py-3 mb-6 border rounded-lg border-warning text-warning bg-base-100">
|
||
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0" />
|
||
<div>
|
||
<span class="font-semibold">{gettext("Preview Mockup")}</span>
|
||
<span class="ml-2 text-sm text-base-content/70">
|
||
– {gettext("This page is not functional and only displays the planned features.")}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
"""
|
||
end
|
||
|
||
# Status badge component
|
||
attr :status, :atom, required: true
|
||
|
||
defp status_badge(%{status: :paid} = assigns) do
|
||
~H"""
|
||
<span class="gap-1 badge badge-success">
|
||
<.icon name="hero-check-circle-mini" class="size-3" />
|
||
{gettext("Paid")}
|
||
</span>
|
||
"""
|
||
end
|
||
|
||
defp status_badge(%{status: :unpaid} = assigns) do
|
||
~H"""
|
||
<span class="gap-1 badge badge-error">
|
||
<.icon name="hero-x-circle-mini" class="size-3" />
|
||
{gettext("Unpaid")}
|
||
</span>
|
||
"""
|
||
end
|
||
|
||
defp status_badge(%{status: :suspended} = assigns) do
|
||
~H"""
|
||
<span class="gap-1 badge badge-neutral">
|
||
<.icon name="hero-pause-circle-mini" class="size-3" />
|
||
{gettext("Suspended")}
|
||
</span>
|
||
"""
|
||
end
|
||
|
||
defp period_row_class(:unpaid), do: "bg-error/5"
|
||
defp period_row_class(:suspended), do: "bg-base-200/50"
|
||
defp period_row_class(_), do: ""
|
||
|
||
# Mock member data
|
||
defp mock_member do
|
||
%{
|
||
id: "123",
|
||
first_name: "Maria",
|
||
last_name: "Weber",
|
||
email: "maria.weber@example.de",
|
||
contribution_type: gettext("Regular"),
|
||
joined_at: "15.03.2021",
|
||
contribution_start: "01.01.2021"
|
||
}
|
||
end
|
||
|
||
# Mock periods data
|
||
defp mock_periods do
|
||
[
|
||
%{
|
||
id: "p1",
|
||
period_start: "01.01.2025",
|
||
period_end: "31.12.2025",
|
||
interval: :yearly,
|
||
amount: Decimal.new("60.00"),
|
||
status: :unpaid,
|
||
notes: nil,
|
||
is_current: true
|
||
},
|
||
%{
|
||
id: "p2",
|
||
period_start: "01.01.2024",
|
||
period_end: "31.12.2024",
|
||
interval: :yearly,
|
||
amount: Decimal.new("60.00"),
|
||
status: :paid,
|
||
notes: gettext("Paid via bank transfer"),
|
||
is_current: false
|
||
},
|
||
%{
|
||
id: "p3",
|
||
period_start: "01.01.2023",
|
||
period_end: "31.12.2023",
|
||
interval: :yearly,
|
||
amount: Decimal.new("50.00"),
|
||
status: :paid,
|
||
notes: nil,
|
||
is_current: false
|
||
},
|
||
%{
|
||
id: "p4",
|
||
period_start: "01.01.2022",
|
||
period_end: "31.12.2022",
|
||
interval: :yearly,
|
||
amount: Decimal.new("50.00"),
|
||
status: :paid,
|
||
notes: nil,
|
||
is_current: false
|
||
},
|
||
%{
|
||
id: "p5",
|
||
period_start: "01.01.2021",
|
||
period_end: "31.12.2021",
|
||
interval: :yearly,
|
||
amount: Decimal.new("50.00"),
|
||
status: :suspended,
|
||
notes: gettext("Joining year - reduced to 0"),
|
||
is_current: false
|
||
}
|
||
]
|
||
end
|
||
|
||
defp format_currency(%Decimal{} = amount) do
|
||
"#{Decimal.to_string(amount)} €"
|
||
end
|
||
|
||
defp format_interval(:monthly), do: gettext("Monthly")
|
||
defp format_interval(:quarterly), do: gettext("Quarterly")
|
||
defp format_interval(:half_yearly), do: gettext("Half-yearly")
|
||
defp format_interval(:yearly), do: gettext("Yearly")
|
||
end
|