Merge branch 'main' into feature/209_hide_field_dropdown
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
This commit is contained in:
commit
c17445975c
34 changed files with 3967 additions and 433 deletions
|
|
@ -79,7 +79,7 @@ defmodule MvWeb.CoreComponents do
|
|||
<p>{msg}</p>
|
||||
</div>
|
||||
<div class="flex-1" />
|
||||
<button type="button" class="group self-start cursor-pointer" aria-label={gettext("close")}>
|
||||
<button type="button" class="self-start cursor-pointer group" aria-label={gettext("close")}>
|
||||
<.icon name="hero-x-mark" class="size-5 opacity-40 group-hover:opacity-70" />
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -500,61 +500,63 @@ defmodule MvWeb.CoreComponents do
|
|||
end
|
||||
|
||||
~H"""
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th :for={col <- @col}>{col[:label]}</th>
|
||||
<th :for={dyn_col <- @dynamic_cols}>
|
||||
<.live_component
|
||||
module={MvWeb.Components.SortHeaderComponent}
|
||||
id={:"sort_custom_field_#{dyn_col[:custom_field].id}"}
|
||||
field={"custom_field_#{dyn_col[:custom_field].id}"}
|
||||
label={dyn_col[:custom_field].name}
|
||||
sort_field={@sort_field}
|
||||
sort_order={@sort_order}
|
||||
/>
|
||||
</th>
|
||||
<th :if={@action != []}>
|
||||
<span class="sr-only">{gettext("Actions")}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
|
||||
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
|
||||
<td
|
||||
:for={col <- @col}
|
||||
phx-click={@row_click && @row_click.(row)}
|
||||
class={@row_click && "hover:cursor-pointer"}
|
||||
>
|
||||
{render_slot(col, @row_item.(row))}
|
||||
</td>
|
||||
<td
|
||||
:for={dyn_col <- @dynamic_cols}
|
||||
phx-click={@row_click && @row_click.(row)}
|
||||
class={@row_click && "hover:cursor-pointer"}
|
||||
>
|
||||
{if dyn_col[:render] do
|
||||
rendered = dyn_col[:render].(@row_item.(row))
|
||||
<div class="overflow-auto">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th :for={col <- @col}>{col[:label]}</th>
|
||||
<th :for={dyn_col <- @dynamic_cols}>
|
||||
<.live_component
|
||||
module={MvWeb.Components.SortHeaderComponent}
|
||||
id={:"sort_custom_field_#{dyn_col[:custom_field].id}"}
|
||||
field={"custom_field_#{dyn_col[:custom_field].id}"}
|
||||
label={dyn_col[:custom_field].name}
|
||||
sort_field={@sort_field}
|
||||
sort_order={@sort_order}
|
||||
/>
|
||||
</th>
|
||||
<th :if={@action != []}>
|
||||
<span class="sr-only">{gettext("Actions")}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
|
||||
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
|
||||
<td
|
||||
:for={col <- @col}
|
||||
phx-click={@row_click && @row_click.(row)}
|
||||
class={["max-w-xs truncate", @row_click && "hover:cursor-pointer"]}
|
||||
>
|
||||
{render_slot(col, @row_item.(row))}
|
||||
</td>
|
||||
<td
|
||||
:for={dyn_col <- @dynamic_cols}
|
||||
phx-click={@row_click && @row_click.(row)}
|
||||
class={["max-w-xs truncate", @row_click && "hover:cursor-pointer"]}
|
||||
>
|
||||
{if dyn_col[:render] do
|
||||
rendered = dyn_col[:render].(@row_item.(row))
|
||||
|
||||
if rendered == "" do
|
||||
""
|
||||
if rendered == "" do
|
||||
""
|
||||
else
|
||||
rendered
|
||||
end
|
||||
else
|
||||
rendered
|
||||
end
|
||||
else
|
||||
""
|
||||
end}
|
||||
</td>
|
||||
<td :if={@action != []} class="w-0 font-semibold">
|
||||
<div class="flex gap-4">
|
||||
<%= for action <- @action do %>
|
||||
{render_slot(action, @row_item.(row))}
|
||||
<% end %>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
""
|
||||
end}
|
||||
</td>
|
||||
<td :if={@action != []} class="w-0 font-semibold">
|
||||
<div class="flex gap-4">
|
||||
<%= for action <- @action do %>
|
||||
{render_slot(action, @row_item.(row))}
|
||||
<% end %>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,17 @@ defmodule MvWeb.Layouts.Navbar do
|
|||
<li><.link navigate="/members">{gettext("Members")}</.link></li>
|
||||
<li><.link navigate="/custom_fields">{gettext("Custom Fields")}</.link></li>
|
||||
<li><.link navigate="/users">{gettext("Users")}</.link></li>
|
||||
<li>
|
||||
<details>
|
||||
<summary>{gettext("Contributions")}</summary>
|
||||
<ul class="bg-base-200 rounded-t-none p-2 z-10 w-48">
|
||||
<li><.link navigate="/contribution_types">{gettext("Contribution Types")}</.link></li>
|
||||
<li>
|
||||
<.link navigate="/contribution_settings">{gettext("Contribution Settings")}</.link>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
|
|
|
|||
|
|
@ -39,6 +39,11 @@ defmodule MvWeb.Endpoint do
|
|||
plug Phoenix.Ecto.CheckRepoStatus, otp_app: :mv
|
||||
end
|
||||
|
||||
# Enable Ecto SQL Sandbox in test environment for async tests
|
||||
if Application.compile_env(:mv, :sql_sandbox) do
|
||||
plug Phoenix.Ecto.SQL.Sandbox
|
||||
end
|
||||
|
||||
plug Phoenix.LiveDashboard.RequestLogger,
|
||||
param_key: "request_logger",
|
||||
cookie_key: "request_logger"
|
||||
|
|
|
|||
27
lib/mv_web/helpers/date_formatter.ex
Normal file
27
lib/mv_web/helpers/date_formatter.ex
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
defmodule MvWeb.Helpers.DateFormatter do
|
||||
@moduledoc """
|
||||
Centralized date formatting helper for the application.
|
||||
Formats dates in European format (dd.mm.yyyy).
|
||||
"""
|
||||
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
@doc """
|
||||
Formats a Date struct to European format (dd.mm.yyyy).
|
||||
|
||||
## Examples
|
||||
|
||||
iex> MvWeb.Helpers.DateFormatter.format_date(~D[2024-03-15])
|
||||
"15.03.2024"
|
||||
|
||||
iex> MvWeb.Helpers.DateFormatter.format_date(nil)
|
||||
""
|
||||
"""
|
||||
def format_date(%Date{} = date) do
|
||||
Calendar.strftime(date, "%d.%m.%Y")
|
||||
end
|
||||
|
||||
def format_date(nil), do: ""
|
||||
|
||||
def format_date(_), do: "Invalid date"
|
||||
end
|
||||
|
|
@ -19,7 +19,7 @@ defmodule MvWeb.Components.SortHeaderComponent do
|
|||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="tooltip" data-tip={aria_sort(@field, @sort_field, @sort_order)}>
|
||||
<div class="tooltip tooltip-bottom" data-tip={aria_sort(@field, @sort_field, @sort_order)}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={aria_sort(@field, @sort_field, @sort_order)}
|
||||
|
|
|
|||
345
lib/mv_web/live/contribution_period_live/show.ex
Normal file
345
lib/mv_web/live/contribution_period_live/show.ex
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
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
|
||||
277
lib/mv_web/live/contribution_settings_live.ex
Normal file
277
lib/mv_web/live/contribution_settings_live.ex
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
defmodule MvWeb.ContributionSettingsLive do
|
||||
@moduledoc """
|
||||
Mock-up LiveView for Contribution Settings (Admin).
|
||||
|
||||
This is a preview-only page that displays the planned UI for managing
|
||||
global contribution settings. It shows static mock data and is not functional.
|
||||
|
||||
## Planned Features (Future Implementation)
|
||||
- Set default contribution type for new members
|
||||
- Configure whether joining period is included in contributions
|
||||
- Explanatory text with examples
|
||||
|
||||
## Settings
|
||||
- `default_contribution_type_id` - UUID of the default contribution type
|
||||
- `include_joining_period` - Boolean whether to include joining 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("Contribution Settings"))
|
||||
|> assign(:contribution_types, mock_contribution_types())
|
||||
|> assign(:selected_type_id, "1")
|
||||
|> assign(:include_joining_period, true)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.mockup_warning />
|
||||
|
||||
<.header>
|
||||
{gettext("Contribution Settings")}
|
||||
<:subtitle>
|
||||
{gettext("Configure global settings for membership contributions.")}
|
||||
</:subtitle>
|
||||
</.header>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<%!-- Settings Form --%>
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<.icon name="hero-cog-6-tooth" class="size-5" />
|
||||
{gettext("Global Settings")}
|
||||
</h2>
|
||||
|
||||
<form class="space-y-6">
|
||||
<%!-- Default Contribution Type --%>
|
||||
<fieldset class="fieldset">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">
|
||||
{gettext("Default Contribution Type")}
|
||||
</span>
|
||||
</label>
|
||||
<select class="select select-bordered w-full" disabled>
|
||||
<option :for={ct <- @contribution_types} selected={ct.id == @selected_type_id}>
|
||||
{ct.name} ({format_currency(ct.amount)}, {format_interval(ct.interval)})
|
||||
</option>
|
||||
</select>
|
||||
<p class="text-sm text-base-content/60 mt-2">
|
||||
{gettext(
|
||||
"This contribution type is automatically assigned to all new members. Can be changed individually per member."
|
||||
)}
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<%!-- Include Joining Period --%>
|
||||
<fieldset class="fieldset">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary"
|
||||
checked={@include_joining_period}
|
||||
disabled
|
||||
/>
|
||||
<span class="label-text font-semibold">
|
||||
{gettext("Include joining period")}
|
||||
</span>
|
||||
</label>
|
||||
<div class="ml-9 space-y-2">
|
||||
<p class="text-sm text-base-content/60">
|
||||
{gettext("When active: Members pay from the period of their joining.")}
|
||||
</p>
|
||||
<p class="text-sm text-base-content/60">
|
||||
{gettext("When inactive: Members pay from the next full period after joining.")}
|
||||
</p>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<button type="button" class="btn btn-primary w-full" disabled>
|
||||
<.icon name="hero-check" class="size-5" />
|
||||
{gettext("Save Settings")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Examples Card --%>
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<.icon name="hero-light-bulb" class="size-5" />
|
||||
{gettext("Examples")}
|
||||
</h2>
|
||||
|
||||
<.example_section
|
||||
title={gettext("Yearly Interval - Joining Period Included")}
|
||||
joining_date="15.03.2023"
|
||||
include_joining={true}
|
||||
start_date="01.01.2023"
|
||||
periods={["2023", "2024", "2025"]}
|
||||
note={gettext("Member pays for the year they joined")}
|
||||
/>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<.example_section
|
||||
title={gettext("Yearly Interval - Joining Period Excluded")}
|
||||
joining_date="15.03.2023"
|
||||
include_joining={false}
|
||||
start_date="01.01.2024"
|
||||
periods={["2024", "2025"]}
|
||||
note={gettext("Member pays from the next full year")}
|
||||
/>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<.example_section
|
||||
title={gettext("Quarterly Interval - Joining Period Excluded")}
|
||||
joining_date="15.05.2024"
|
||||
include_joining={false}
|
||||
start_date="01.07.2024"
|
||||
periods={["Q3/2024", "Q4/2024", "Q1/2025"]}
|
||||
note={gettext("Member pays from the next full quarter")}
|
||||
/>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<.example_section
|
||||
title={gettext("Monthly Interval - Joining Period Included")}
|
||||
joining_date="15.03.2024"
|
||||
include_joining={true}
|
||||
start_date="01.03.2024"
|
||||
periods={["03/2024", "04/2024", "05/2024", "..."]}
|
||||
note={gettext("Member pays from the joining month")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<.example_member_card />
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
# Example member card with link to period view
|
||||
defp example_member_card(assigns) do
|
||||
~H"""
|
||||
<div class="card bg-base-100 shadow-xl mt-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<.icon name="hero-user" class="size-5" />
|
||||
{gettext("Example: Member Contribution View")}
|
||||
</h2>
|
||||
<p class="text-base-content/70">
|
||||
{gettext(
|
||||
"See how the contribution periods will be displayed for an individual member. This example shows Maria Weber with multiple contribution periods."
|
||||
)}
|
||||
</p>
|
||||
<div class="card-actions justify-end">
|
||||
<.link navigate={~p"/contributions/member/example"} class="btn btn-primary btn-sm">
|
||||
<.icon name="hero-eye" class="size-4" />
|
||||
{gettext("View Example Member")}
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# Mock-up warning banner component - subtle orange style
|
||||
defp mockup_warning(assigns) do
|
||||
~H"""
|
||||
<div class="border border-warning text-warning bg-base-100 rounded-lg px-4 py-3 mb-6 flex items-center gap-3">
|
||||
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0" />
|
||||
<div>
|
||||
<span class="font-semibold">{gettext("Preview Mockup")}</span>
|
||||
<span class="text-sm text-base-content/70 ml-2">
|
||||
– {gettext("This page is not functional and only displays the planned features.")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# Example section component
|
||||
attr :title, :string, required: true
|
||||
attr :joining_date, :string, required: true
|
||||
attr :include_joining, :boolean, required: true
|
||||
attr :start_date, :string, required: true
|
||||
attr :periods, :list, required: true
|
||||
attr :note, :string, required: true
|
||||
|
||||
defp example_section(assigns) do
|
||||
~H"""
|
||||
<div class="space-y-2">
|
||||
<h3 class="font-semibold text-sm">{@title}</h3>
|
||||
<div class="bg-base-300 rounded-lg p-3 text-sm space-y-1">
|
||||
<p>
|
||||
<span class="text-base-content/60">{gettext("Joining date")}:</span>
|
||||
<span class="font-mono">{@joining_date}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span class="text-base-content/60">{gettext("Contribution start")}:</span>
|
||||
<span class="font-mono font-semibold text-primary">{@start_date}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span class="text-base-content/60">{gettext("Generated periods")}:</span>
|
||||
<span class="font-mono">
|
||||
{Enum.join(@periods, ", ")}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-xs text-base-content/60 italic">→ {@note}</p>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# Mock data for demonstration
|
||||
defp mock_contribution_types do
|
||||
[
|
||||
%{
|
||||
id: "1",
|
||||
name: gettext("Regular"),
|
||||
amount: Decimal.new("60.00"),
|
||||
interval: :yearly
|
||||
},
|
||||
%{
|
||||
id: "2",
|
||||
name: gettext("Reduced"),
|
||||
amount: Decimal.new("30.00"),
|
||||
interval: :yearly
|
||||
},
|
||||
%{
|
||||
id: "3",
|
||||
name: gettext("Student"),
|
||||
amount: Decimal.new("5.00"),
|
||||
interval: :monthly
|
||||
},
|
||||
%{
|
||||
id: "4",
|
||||
name: gettext("Family"),
|
||||
amount: Decimal.new("25.00"),
|
||||
interval: :quarterly
|
||||
}
|
||||
]
|
||||
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
|
||||
205
lib/mv_web/live/contribution_type_live/index.ex
Normal file
205
lib/mv_web/live/contribution_type_live/index.ex
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
defmodule MvWeb.ContributionTypeLive.Index do
|
||||
@moduledoc """
|
||||
Mock-up LiveView for Contribution Types Management (Admin).
|
||||
|
||||
This is a preview-only page that displays the planned UI for managing
|
||||
contribution types. It shows static mock data and is not functional.
|
||||
|
||||
## Planned Features (Future Implementation)
|
||||
- List all contribution types
|
||||
- Display: Name, Amount, Interval, Member count
|
||||
- Create new contribution types
|
||||
- Edit existing contribution types (name, amount, description - NOT interval)
|
||||
- Delete contribution types (if no members assigned)
|
||||
|
||||
## 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("Contribution Types"))
|
||||
|> assign(:contribution_types, mock_contribution_types())}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.mockup_warning />
|
||||
|
||||
<.header>
|
||||
{gettext("Contribution Types")}
|
||||
<:subtitle>
|
||||
{gettext("Manage contribution types for membership fees.")}
|
||||
</:subtitle>
|
||||
<:actions>
|
||||
<button class="btn btn-primary" disabled>
|
||||
<.icon name="hero-plus" /> {gettext("New Contribution Type")}
|
||||
</button>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<.table id="contribution_types" rows={@contribution_types} row_id={fn ct -> "ct-#{ct.id}" end}>
|
||||
<:col :let={ct} label={gettext("Name")}>
|
||||
<span class="font-medium">{ct.name}</span>
|
||||
<p :if={ct.description} class="text-sm text-base-content/60">{ct.description}</p>
|
||||
</:col>
|
||||
|
||||
<:col :let={ct} label={gettext("Amount")}>
|
||||
<span class="font-mono">{format_currency(ct.amount)}</span>
|
||||
</:col>
|
||||
|
||||
<:col :let={ct} label={gettext("Interval")}>
|
||||
<span class="badge badge-outline">{format_interval(ct.interval)}</span>
|
||||
</:col>
|
||||
|
||||
<:col :let={ct} label={gettext("Members")}>
|
||||
<span class="badge badge-ghost">{ct.member_count}</span>
|
||||
</:col>
|
||||
|
||||
<:action :let={_ct}>
|
||||
<button class="btn btn-ghost btn-xs" disabled title={gettext("Edit")}>
|
||||
<.icon name="hero-pencil" class="size-4" />
|
||||
</button>
|
||||
</:action>
|
||||
|
||||
<:action :let={ct}>
|
||||
<button
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
disabled
|
||||
title={
|
||||
if ct.member_count > 0,
|
||||
do: gettext("Cannot delete - members assigned"),
|
||||
else: gettext("Delete")
|
||||
}
|
||||
>
|
||||
<.icon name="hero-trash" class="size-4" />
|
||||
</button>
|
||||
</:action>
|
||||
</.table>
|
||||
|
||||
<.info_card />
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
# Mock-up warning banner component - subtle orange style
|
||||
defp mockup_warning(assigns) do
|
||||
~H"""
|
||||
<div class="border border-warning text-warning bg-base-100 rounded-lg px-4 py-3 mb-6 flex items-center gap-3">
|
||||
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0" />
|
||||
<div>
|
||||
<span class="font-semibold">{gettext("Preview Mockup")}</span>
|
||||
<span class="text-sm text-base-content/70 ml-2">
|
||||
– {gettext("This page is not functional and only displays the planned features.")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# Info card explaining the contribution type concept
|
||||
defp info_card(assigns) do
|
||||
~H"""
|
||||
<div class="card bg-base-200 mt-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<.icon name="hero-information-circle" class="size-5" />
|
||||
{gettext("About Contribution Types")}
|
||||
</h2>
|
||||
<div class="prose prose-sm max-w-none">
|
||||
<p>
|
||||
{gettext(
|
||||
"Contribution types define different membership fee structures. Each type has a fixed interval (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
|
||||
)}
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>{gettext("Name & Amount")}</strong>
|
||||
- {gettext("Can be changed at any time. Amount changes affect future periods only.")}
|
||||
</li>
|
||||
<li>
|
||||
<strong>{gettext("Interval")}</strong>
|
||||
- {gettext(
|
||||
"Fixed after creation. Members can only switch between types with the same interval."
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<strong>{gettext("Deletion")}</strong>
|
||||
- {gettext("Only possible if no members are assigned to this type.")}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# Mock data for demonstration
|
||||
defp mock_contribution_types do
|
||||
[
|
||||
%{
|
||||
id: "1",
|
||||
name: gettext("Regular"),
|
||||
description: gettext("Standard membership fee for regular members"),
|
||||
amount: Decimal.new("60.00"),
|
||||
interval: :yearly,
|
||||
member_count: 45
|
||||
},
|
||||
%{
|
||||
id: "2",
|
||||
name: gettext("Reduced"),
|
||||
description: gettext("Reduced fee for unemployed, pensioners, or low income"),
|
||||
amount: Decimal.new("30.00"),
|
||||
interval: :yearly,
|
||||
member_count: 12
|
||||
},
|
||||
%{
|
||||
id: "3",
|
||||
name: gettext("Student"),
|
||||
description: gettext("Monthly fee for students and trainees"),
|
||||
amount: Decimal.new("5.00"),
|
||||
interval: :monthly,
|
||||
member_count: 8
|
||||
},
|
||||
%{
|
||||
id: "4",
|
||||
name: gettext("Family"),
|
||||
description: gettext("Quarterly fee for family memberships"),
|
||||
amount: Decimal.new("25.00"),
|
||||
interval: :quarterly,
|
||||
member_count: 15
|
||||
},
|
||||
%{
|
||||
id: "5",
|
||||
name: gettext("Supporting Member"),
|
||||
description: gettext("Half-yearly contribution for supporting members"),
|
||||
amount: Decimal.new("100.00"),
|
||||
interval: :half_yearly,
|
||||
member_count: 3
|
||||
},
|
||||
%{
|
||||
id: "6",
|
||||
name: gettext("Honorary"),
|
||||
description: gettext("No fee for honorary members"),
|
||||
amount: Decimal.new("0.00"),
|
||||
interval: :yearly,
|
||||
member_count: 2
|
||||
}
|
||||
]
|
||||
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
|
||||
|
|
@ -32,6 +32,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|
||||
alias Mv.Membership
|
||||
alias MvWeb.MemberLive.Index.Formatter
|
||||
alias MvWeb.Helpers.DateFormatter
|
||||
alias MvWeb.MemberLive.Index.FieldSelection
|
||||
alias MvWeb.MemberLive.Index.FieldVisibility
|
||||
|
||||
|
|
@ -168,13 +169,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
selected_ids = socket.assigns.selected_members
|
||||
|
||||
# Filter members that are in the selection and have email addresses
|
||||
formatted_emails =
|
||||
socket.assigns.members
|
||||
|> Enum.filter(fn member ->
|
||||
MapSet.member?(selected_ids, member.id) && member.email && member.email != ""
|
||||
end)
|
||||
|> Enum.map(&format_member_email/1)
|
||||
|
||||
formatted_emails = format_selected_member_emails(socket.assigns.members, selected_ids)
|
||||
email_count = length(formatted_emails)
|
||||
|
||||
cond do
|
||||
|
|
@ -1069,9 +1064,20 @@ defmodule MvWeb.MemberLive.Index do
|
|||
end
|
||||
end
|
||||
|
||||
# Filters selected members with email addresses and formats them.
|
||||
# Returns a list of formatted email strings in the format "First Last <email>".
|
||||
# Used by both copy_emails and mailto links.
|
||||
def format_selected_member_emails(members, selected_members) do
|
||||
members
|
||||
|> Enum.filter(fn member ->
|
||||
MapSet.member?(selected_members, member.id) && member.email && member.email != ""
|
||||
end)
|
||||
|> Enum.map(&format_member_email/1)
|
||||
end
|
||||
|
||||
# Formats a member's email in the format "First Last <email>"
|
||||
# Used for copy_emails feature to create email-client-friendly format.
|
||||
defp format_member_email(member) do
|
||||
# Used for copy_emails feature and mailto links to create email-client-friendly format.
|
||||
def format_member_email(member) do
|
||||
first_name = member.first_name || ""
|
||||
last_name = member.last_name || ""
|
||||
|
||||
|
|
@ -1114,4 +1120,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
Map.get(visibility_config, Atom.to_string(field), true)
|
||||
end)
|
||||
end
|
||||
|
||||
# Public helper function to format dates for use in templates
|
||||
def format_date(date), do: DateFormatter.format_date(date)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -14,7 +14,12 @@
|
|||
</.button>
|
||||
<.button
|
||||
:if={Enum.any?(@members, &MapSet.member?(@selected_members, &1.id))}
|
||||
href={"mailto:?bcc=#{@members |> Enum.filter(&(MapSet.member?(@selected_members, &1.id) && &1.email)) |> Enum.map(& &1.email) |> Enum.join(",")}"}
|
||||
href={
|
||||
"mailto:?bcc=" <>
|
||||
(MvWeb.MemberLive.Index.format_selected_member_emails(@members, @selected_members)
|
||||
|> Enum.join(", ")
|
||||
|> URI.encode())
|
||||
}
|
||||
aria-label={gettext("Open email program with BCC recipients")}
|
||||
>
|
||||
<.icon name="hero-envelope" />
|
||||
|
|
@ -245,7 +250,7 @@
|
|||
"""
|
||||
}
|
||||
>
|
||||
{member.join_date}
|
||||
{MvWeb.MemberLive.Index.format_date(member.join_date)}
|
||||
</:col>
|
||||
<:col :let={member} :if={:paid in @member_fields_visible} label={gettext("Paid")}>
|
||||
<span class={[
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ defmodule MvWeb.MemberLive.Index.Formatter do
|
|||
formats them appropriately for display in the UI.
|
||||
"""
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
alias MvWeb.Helpers.DateFormatter
|
||||
|
||||
@doc """
|
||||
Formats a custom field value for display.
|
||||
|
|
@ -61,11 +62,11 @@ defmodule MvWeb.MemberLive.Index.Formatter do
|
|||
defp format_value_by_type(value, :boolean, _) when value == false, do: gettext("No")
|
||||
defp format_value_by_type(value, :boolean, _), do: to_string(value)
|
||||
|
||||
defp format_value_by_type(%Date{} = date, :date, _), do: Date.to_string(date)
|
||||
defp format_value_by_type(%Date{} = date, :date, _), do: DateFormatter.format_date(date)
|
||||
|
||||
defp format_value_by_type(value, :date, _) when is_binary(value) do
|
||||
case Date.from_iso8601(value) do
|
||||
{:ok, date} -> Date.to_string(date)
|
||||
{:ok, date} -> DateFormatter.format_date(date)
|
||||
_ -> value
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ defmodule MvWeb.MemberLive.Show do
|
|||
"""
|
||||
use MvWeb, :live_view
|
||||
import Ash.Query
|
||||
alias MvWeb.Helpers.DateFormatter
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
|
|
@ -52,8 +53,8 @@ defmodule MvWeb.MemberLive.Show do
|
|||
{if @member.paid, do: gettext("Yes"), else: gettext("No")}
|
||||
</:item>
|
||||
<:item title={gettext("Phone Number")}>{@member.phone_number}</:item>
|
||||
<:item title={gettext("Join Date")}>{@member.join_date}</:item>
|
||||
<:item title={gettext("Exit Date")}>{@member.exit_date}</:item>
|
||||
<:item title={gettext("Join Date")}>{DateFormatter.format_date(@member.join_date)}</:item>
|
||||
<:item title={gettext("Exit Date")}>{DateFormatter.format_date(@member.exit_date)}</:item>
|
||||
<:item title={gettext("Notes")}>{@member.notes}</:item>
|
||||
<:item title={gettext("City")}>{@member.city}</:item>
|
||||
<:item title={gettext("Street")}>{@member.street}</:item>
|
||||
|
|
@ -81,10 +82,7 @@ defmodule MvWeb.MemberLive.Show do
|
|||
# name
|
||||
cfv.custom_field && cfv.custom_field.name,
|
||||
# value
|
||||
case cfv.value do
|
||||
%{value: v} -> v
|
||||
v -> v
|
||||
end
|
||||
format_custom_field_value(cfv)
|
||||
}
|
||||
end)
|
||||
} />
|
||||
|
|
@ -114,4 +112,17 @@ defmodule MvWeb.MemberLive.Show do
|
|||
|
||||
defp page_title(:show), do: gettext("Show Member")
|
||||
defp page_title(:edit), do: gettext("Edit Member")
|
||||
|
||||
defp format_custom_field_value(cfv) do
|
||||
value =
|
||||
case cfv.value do
|
||||
%{value: v} -> v
|
||||
v -> v
|
||||
end
|
||||
|
||||
case value do
|
||||
%Date{} = date -> DateFormatter.format_date(date)
|
||||
other -> other
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ defmodule MvWeb.UserLive.Form do
|
|||
<:subtitle>{gettext("Use this form to manage user records in your database.")}</:subtitle>
|
||||
</.header>
|
||||
|
||||
<.form for={@form} id="user-form" phx-change="validate" phx-submit="save">
|
||||
<.form class="max-w-xl" for={@form} id="user-form" phx-change="validate" phx-submit="save">
|
||||
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
|
||||
|
||||
<!-- Password Section -->
|
||||
|
|
@ -61,7 +61,7 @@ defmodule MvWeb.UserLive.Form do
|
|||
</label>
|
||||
|
||||
<%= if @show_password_fields do %>
|
||||
<div class="mt-4 space-y-4 p-4 bg-gray-50 rounded-lg">
|
||||
<div class="p-4 mt-4 space-y-4 rounded-lg bg-gray-50">
|
||||
<.input
|
||||
field={@form[:password]}
|
||||
label={gettext("Password")}
|
||||
|
|
@ -83,7 +83,7 @@ defmodule MvWeb.UserLive.Form do
|
|||
|
||||
<div class="text-sm text-gray-600">
|
||||
<p><strong>{gettext("Password requirements")}:</strong></p>
|
||||
<ul class="list-disc list-inside text-xs mt-1 space-y-1">
|
||||
<ul class="mt-1 space-y-1 text-xs list-disc list-inside">
|
||||
<li>{gettext("At least 8 characters")}</li>
|
||||
<li>{gettext("Include both letters and numbers")}</li>
|
||||
<li>{gettext("Consider using special characters")}</li>
|
||||
|
|
@ -91,7 +91,7 @@ defmodule MvWeb.UserLive.Form do
|
|||
</div>
|
||||
|
||||
<%= if @user do %>
|
||||
<div class="mt-3 p-3 bg-orange-50 border border-orange-200 rounded">
|
||||
<div class="p-3 mt-3 border border-orange-200 rounded bg-orange-50">
|
||||
<p class="text-sm text-orange-800">
|
||||
<strong>{gettext("Admin Note")}:</strong> {gettext(
|
||||
"As an administrator, you can directly set a new password for this user using the same secure Ash Authentication system."
|
||||
|
|
@ -102,7 +102,7 @@ defmodule MvWeb.UserLive.Form do
|
|||
</div>
|
||||
<% else %>
|
||||
<%= if @user do %>
|
||||
<div class="mt-4 p-4 bg-blue-50 rounded-lg">
|
||||
<div class="p-4 mt-4 rounded-lg bg-blue-50">
|
||||
<p class="text-sm text-blue-800">
|
||||
<strong>{gettext("Note")}:</strong> {gettext(
|
||||
"Check 'Change Password' above to set a new password for this user."
|
||||
|
|
@ -110,7 +110,7 @@ defmodule MvWeb.UserLive.Form do
|
|||
</p>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="mt-4 p-4 bg-yellow-50 rounded-lg">
|
||||
<div class="p-4 mt-4 rounded-lg bg-yellow-50">
|
||||
<p class="text-sm text-yellow-800">
|
||||
<strong>{gettext("Note")}:</strong> {gettext(
|
||||
"User will be created without a password. Check 'Set Password' to add one."
|
||||
|
|
@ -123,11 +123,11 @@ defmodule MvWeb.UserLive.Form do
|
|||
|
||||
<!-- Member Linking Section -->
|
||||
<div class="mt-6">
|
||||
<h2 class="text-base font-semibold mb-3">{gettext("Linked Member")}</h2>
|
||||
<h2 class="mb-3 text-base font-semibold">{gettext("Linked Member")}</h2>
|
||||
|
||||
<%= if @user && @user.member && !@unlink_member do %>
|
||||
<!-- Show linked member with unlink button -->
|
||||
<div class="p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<div class="p-4 border border-green-200 rounded-lg bg-green-50">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium text-green-900">
|
||||
|
|
@ -147,7 +147,7 @@ defmodule MvWeb.UserLive.Form do
|
|||
<% else %>
|
||||
<%= if @unlink_member do %>
|
||||
<!-- Show unlink pending message -->
|
||||
<div class="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<div class="p-4 border border-yellow-200 rounded-lg bg-yellow-50">
|
||||
<p class="text-sm text-yellow-800">
|
||||
<strong>{gettext("Unlinking scheduled")}:</strong> {gettext(
|
||||
"Member will be unlinked when you save. Cannot select new member until saved."
|
||||
|
|
@ -219,7 +219,7 @@ defmodule MvWeb.UserLive.Form do
|
|||
</div>
|
||||
|
||||
<%= if @user && @user.email && @available_members != [] && Enum.all?(@available_members, &(&1.email == to_string(@user.email))) do %>
|
||||
<div class="p-3 bg-yellow-50 border border-yellow-200 rounded">
|
||||
<div class="p-3 border border-yellow-200 rounded bg-yellow-50">
|
||||
<p class="text-sm text-yellow-800">
|
||||
<strong>{gettext("Note")}:</strong> {gettext(
|
||||
"A member with this email already exists. To link with a different member, please change one of the email addresses first."
|
||||
|
|
@ -231,12 +231,12 @@ defmodule MvWeb.UserLive.Form do
|
|||
<%= if @selected_member_id && @selected_member_name do %>
|
||||
<div
|
||||
id="member-selected"
|
||||
class="mt-2 p-3 bg-blue-50 border border-blue-200 rounded-lg"
|
||||
class="p-3 mt-2 border border-blue-200 rounded-lg bg-blue-50"
|
||||
>
|
||||
<p class="text-sm text-blue-800">
|
||||
<strong>{gettext("Selected")}:</strong> {@selected_member_name}
|
||||
</p>
|
||||
<p class="text-xs text-blue-600 mt-1">
|
||||
<p class="mt-1 text-xs text-blue-600">
|
||||
{gettext("Save to confirm linking.")}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -245,10 +245,12 @@ defmodule MvWeb.UserLive.Form do
|
|||
<% end %>
|
||||
</div>
|
||||
|
||||
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
||||
{gettext("Save User")}
|
||||
</.button>
|
||||
<.button navigate={return_path(@return_to, @user)}>{gettext("Cancel")}</.button>
|
||||
<div class="mt-4">
|
||||
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
||||
{gettext("Save User")}
|
||||
</.button>
|
||||
<.button navigate={return_path(@return_to, @user)}>{gettext("Cancel")}</.button>
|
||||
</div>
|
||||
</.form>
|
||||
</Layouts.app>
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -49,7 +49,6 @@
|
|||
>
|
||||
{user.email}
|
||||
</:col>
|
||||
<:col :let={user} label={gettext("OIDC ID")}>{user.oidc_id}</:col>
|
||||
<:col :let={user} label={gettext("Linked Member")}>
|
||||
<%= if user.member do %>
|
||||
{user.member.first_name} {user.member.last_name}
|
||||
|
|
|
|||
|
|
@ -46,9 +46,7 @@ defmodule MvWeb.UserLive.Show do
|
|||
</.header>
|
||||
|
||||
<.list>
|
||||
<:item title={gettext("ID")}>{@user.id}</:item>
|
||||
<:item title={gettext("Email")}>{@user.email}</:item>
|
||||
<:item title={gettext("OIDC ID")}>{@user.oidc_id || gettext("Not set")}</:item>
|
||||
<:item title={gettext("Password Authentication")}>
|
||||
{if @user.hashed_password, do: gettext("Enabled"), else: gettext("Not enabled")}
|
||||
</:item>
|
||||
|
|
@ -56,13 +54,13 @@ defmodule MvWeb.UserLive.Show do
|
|||
<%= if @user.member do %>
|
||||
<.link
|
||||
navigate={~p"/members/#{@user.member}"}
|
||||
class="text-blue-600 hover:text-blue-800 underline"
|
||||
class="text-blue-600 underline hover:text-blue-800"
|
||||
>
|
||||
<.icon name="hero-users" class="h-4 w-4 inline mr-1" />
|
||||
<.icon name="hero-users" class="inline w-4 h-4 mr-1" />
|
||||
{@user.member.first_name} {@user.member.last_name}
|
||||
</.link>
|
||||
<% else %>
|
||||
<span class="text-gray-500 italic">{gettext("No member linked")}</span>
|
||||
<span class="italic text-gray-500">{gettext("No member linked")}</span>
|
||||
<% end %>
|
||||
</:item>
|
||||
</.list>
|
||||
|
|
|
|||
|
|
@ -75,6 +75,11 @@ defmodule MvWeb.Router do
|
|||
|
||||
live "/settings", GlobalSettingsLive
|
||||
|
||||
# Contribution Management (Mock-ups)
|
||||
live "/contribution_types", ContributionTypeLive.Index, :index
|
||||
live "/contribution_settings", ContributionSettingsLive
|
||||
live "/contributions/member/:id", ContributionPeriodLive.Show, :show
|
||||
|
||||
post "/set_locale", LocaleController, :set_locale
|
||||
end
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue