Merge branch 'main' into feature/223_member_checkbox
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
1b06f885bf
60 changed files with 8281 additions and 1644 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>
|
||||
|
|
@ -119,6 +119,123 @@ defmodule MvWeb.CoreComponents do
|
|||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a dropdown menu.
|
||||
|
||||
## Examples
|
||||
|
||||
<.dropdown_menu items={@items} open={@open} phx_target={@myself} />
|
||||
"""
|
||||
attr :id, :string, default: "dropdown-menu"
|
||||
attr :items, :list, required: true, doc: "List of %{label: string, value: any} maps"
|
||||
attr :button_label, :string, default: "Dropdown"
|
||||
attr :icon, :string, default: nil
|
||||
attr :checkboxes, :boolean, default: false
|
||||
attr :selected, :map, default: %{}
|
||||
attr :open, :boolean, default: false, doc: "Whether the dropdown is open"
|
||||
attr :show_select_buttons, :boolean, default: false, doc: "Show select all/none buttons"
|
||||
attr :phx_target, :any, required: true, doc: "The LiveView/LiveComponent target for events"
|
||||
|
||||
def dropdown_menu(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
class="relative"
|
||||
phx-click-away="close_dropdown"
|
||||
phx-target={@phx_target}
|
||||
phx-window-keydown="close_dropdown"
|
||||
phx-key="Escape"
|
||||
data-testid="dropdown-menu"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={@open}
|
||||
aria-controls={@id}
|
||||
class="btn btn-ghost"
|
||||
phx-click="toggle_dropdown"
|
||||
phx-target={@phx_target}
|
||||
data-testid="dropdown-button"
|
||||
>
|
||||
<%= if @icon do %>
|
||||
<.icon name={@icon} />
|
||||
<% end %>
|
||||
<span>{@button_label}</span>
|
||||
</button>
|
||||
|
||||
<ul
|
||||
:if={@open}
|
||||
id={@id}
|
||||
role="menu"
|
||||
class="absolute right-0 mt-2 bg-base-100 z-[100] p-2 shadow-lg rounded-box w-64 max-h-96 overflow-y-auto border border-base-300"
|
||||
tabindex="0"
|
||||
phx-target={@phx_target}
|
||||
>
|
||||
<li :if={@show_select_buttons} role="none">
|
||||
<div class="flex justify-between items-center mb-2 px-2">
|
||||
<span class="font-semibold">{gettext("Options")}</span>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-label={gettext("Select all")}
|
||||
phx-click="select_all"
|
||||
phx-target={@phx_target}
|
||||
class="btn btn-xs btn-ghost"
|
||||
>
|
||||
{gettext("All")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-label={gettext("Select none")}
|
||||
phx-click="select_none"
|
||||
phx-target={@phx_target}
|
||||
class="btn btn-xs btn-ghost"
|
||||
>
|
||||
{gettext("None")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li :if={@show_select_buttons} role="separator" class="divider my-1"></li>
|
||||
|
||||
<%= for item <- @items do %>
|
||||
<li role="none">
|
||||
<button
|
||||
type="button"
|
||||
role={if @checkboxes, do: "menuitemcheckbox", else: "menuitem"}
|
||||
aria-checked={
|
||||
if @checkboxes, do: to_string(Map.get(@selected, item.value, true)), else: nil
|
||||
}
|
||||
tabindex="0"
|
||||
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left"
|
||||
phx-click="select_item"
|
||||
phx-keydown="select_item"
|
||||
phx-key="Enter"
|
||||
phx-value-item={item.value}
|
||||
phx-target={@phx_target}
|
||||
>
|
||||
<%= if @checkboxes do %>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Map.get(@selected, item.value, true)}
|
||||
class="checkbox checkbox-sm checkbox-primary"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<% end %>
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders an input with label and error messages.
|
||||
|
||||
|
|
@ -369,64 +486,66 @@ 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={
|
||||
<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={
|
||||
(col[:col_click] && col[:col_click].(@row_item.(row))) ||
|
||||
(@row_click && @row_click.(row))
|
||||
}
|
||||
class={(col[:col_click] || @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))
|
||||
class={["max-w-xs truncate", (col[:col_click] || @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
|
||||
|
||||
|
|
|
|||
|
|
@ -23,8 +23,19 @@ defmodule MvWeb.Layouts.Navbar do
|
|||
<a class="btn btn-ghost text-xl">{@club_name}</a>
|
||||
<ul class="menu menu-horizontal bg-base-200">
|
||||
<li><.link navigate="/members">{gettext("Members")}</.link></li>
|
||||
<li><.link navigate="/custom_fields">{gettext("Custom Fields")}</.link></li>
|
||||
<li><.link navigate="/settings">{gettext("Settings")}</.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
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
|
||||
@moduledoc """
|
||||
LiveComponent for managing field visibility in the member overview.
|
||||
|
||||
Provides an accessible dropdown menu where users can select/deselect
|
||||
which member fields and custom fields are visible in the table.
|
||||
|
||||
## Props
|
||||
- `:all_fields` - List of all available fields
|
||||
- `:custom_fields` - List of CustomField resources
|
||||
- `:selected_fields` - Map field_name → boolean
|
||||
- `:id` - Component ID
|
||||
|
||||
## Events sent to parent:
|
||||
- `{:field_toggled, field, value}`
|
||||
- `{:fields_selected, map}`
|
||||
"""
|
||||
|
||||
use MvWeb, :live_component
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# UPDATE
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign_new(:open, fn -> false end)
|
||||
|> assign_new(:all_fields, fn -> [] end)
|
||||
|> assign_new(:custom_fields, fn -> [] end)
|
||||
|> assign_new(:selected_fields, fn -> %{} end)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RENDER
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
all_fields = assigns.all_fields || []
|
||||
custom_fields = assigns.custom_fields || []
|
||||
|
||||
all_items =
|
||||
Enum.map(extract_member_field_keys(all_fields), fn field ->
|
||||
%{
|
||||
value: field_to_string(field),
|
||||
label: format_field_label(field)
|
||||
}
|
||||
end) ++
|
||||
Enum.map(extract_custom_field_keys(all_fields), fn field ->
|
||||
%{
|
||||
value: field,
|
||||
label: format_custom_field_label(field, custom_fields)
|
||||
}
|
||||
end)
|
||||
|
||||
assigns = assign(assigns, :all_items, all_items)
|
||||
|
||||
# LiveComponents require a static HTML element as root, not a function component
|
||||
~H"""
|
||||
<div>
|
||||
<.dropdown_menu
|
||||
id="field-visibility-menu"
|
||||
icon="hero-adjustments-horizontal"
|
||||
button_label={gettext("Columns")}
|
||||
items={@all_items}
|
||||
checkboxes={true}
|
||||
selected={@selected_fields}
|
||||
open={@open}
|
||||
show_select_buttons={true}
|
||||
phx_target={@myself}
|
||||
/>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EVENTS (matching the Core Component API)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@impl true
|
||||
def handle_event("toggle_dropdown", _params, socket) do
|
||||
{:noreply, assign(socket, :open, !socket.assigns.open)}
|
||||
end
|
||||
|
||||
def handle_event("close_dropdown", _params, socket) do
|
||||
{:noreply, assign(socket, :open, false)}
|
||||
end
|
||||
|
||||
# toggle single item
|
||||
def handle_event("select_item", %{"item" => item}, socket) do
|
||||
current = Map.get(socket.assigns.selected_fields, item, true)
|
||||
updated = Map.put(socket.assigns.selected_fields, item, !current)
|
||||
|
||||
send(self(), {:field_toggled, item, !current})
|
||||
{:noreply, assign(socket, :selected_fields, updated)}
|
||||
end
|
||||
|
||||
# select all
|
||||
def handle_event("select_all", _params, socket) do
|
||||
all =
|
||||
socket.assigns.all_fields
|
||||
|> Enum.map(&field_to_string/1)
|
||||
|> Enum.map(&{&1, true})
|
||||
|> Enum.into(%{})
|
||||
|
||||
send(self(), {:fields_selected, all})
|
||||
{:noreply, assign(socket, :selected_fields, all)}
|
||||
end
|
||||
|
||||
# select none
|
||||
def handle_event("select_none", _params, socket) do
|
||||
none =
|
||||
socket.assigns.all_fields
|
||||
|> Enum.map(&field_to_string/1)
|
||||
|> Enum.map(&{&1, false})
|
||||
|> Enum.into(%{})
|
||||
|
||||
send(self(), {:fields_selected, none})
|
||||
{:noreply, assign(socket, :selected_fields, none)}
|
||||
end
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HELPERS (with defensive nil guards)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
defp extract_member_field_keys(nil), do: []
|
||||
|
||||
defp extract_member_field_keys(fields) do
|
||||
prefix = Mv.Constants.custom_field_prefix()
|
||||
|
||||
Enum.filter(fields, fn field ->
|
||||
is_atom(field) ||
|
||||
(is_binary(field) && not String.starts_with?(field, prefix))
|
||||
end)
|
||||
end
|
||||
|
||||
defp extract_custom_field_keys(nil), do: []
|
||||
|
||||
defp extract_custom_field_keys(fields) do
|
||||
prefix = Mv.Constants.custom_field_prefix()
|
||||
|
||||
Enum.filter(fields, fn field ->
|
||||
is_binary(field) && String.starts_with?(field, prefix)
|
||||
end)
|
||||
end
|
||||
|
||||
defp field_to_string(field) when is_atom(field), do: Atom.to_string(field)
|
||||
defp field_to_string(field) when is_binary(field), do: field
|
||||
|
||||
defp format_field_label(field) do
|
||||
field
|
||||
|> field_to_string()
|
||||
|> String.replace("_", " ")
|
||||
|> String.split()
|
||||
|> Enum.map_join(" ", &String.capitalize/1)
|
||||
end
|
||||
|
||||
defp format_custom_field_label(field_string, custom_fields) do
|
||||
id = String.trim_leading(field_string, Mv.Constants.custom_field_prefix())
|
||||
find_custom_field_name(id, field_string, custom_fields)
|
||||
end
|
||||
|
||||
defp find_custom_field_name("", field_string, _custom_fields), do: field_string
|
||||
|
||||
defp find_custom_field_name(id, _field_string, custom_fields) do
|
||||
case Enum.find(custom_fields, fn cf -> to_string(cf.id) == id end) do
|
||||
nil -> gettext("Custom Field %{id}", id: id)
|
||||
custom_field -> custom_field.name
|
||||
end
|
||||
end
|
||||
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
|
||||
|
|
@ -1,142 +0,0 @@
|
|||
defmodule MvWeb.CustomFieldLive.Form do
|
||||
@moduledoc """
|
||||
LiveView form for creating and editing custom fields (admin).
|
||||
|
||||
## Features
|
||||
- Create new custom field definitions
|
||||
- Edit existing custom fields
|
||||
- Select value type from supported types
|
||||
- Set immutable and required flags
|
||||
- Real-time validation
|
||||
|
||||
## Form Fields
|
||||
**Required:**
|
||||
- name - Unique identifier (e.g., "phone_mobile", "emergency_contact")
|
||||
- value_type - Data type (:string, :integer, :boolean, :date, :email)
|
||||
|
||||
**Optional:**
|
||||
- description - Human-readable explanation
|
||||
- immutable - If true, values cannot be changed after creation (default: false)
|
||||
- required - If true, all members must have this custom field (default: false)
|
||||
- show_in_overview - If true, this custom field will be displayed in the member overview table (default: true)
|
||||
|
||||
## Value Type Selection
|
||||
- `:string` - Text data (unlimited length)
|
||||
- `:integer` - Numeric data
|
||||
- `:boolean` - True/false flags
|
||||
- `:date` - Date values
|
||||
- `:email` - Validated email addresses
|
||||
|
||||
## Events
|
||||
- `validate` - Real-time form validation
|
||||
- `save` - Submit form (create or update custom field)
|
||||
|
||||
## Security
|
||||
Custom field management is restricted to admin users.
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
{@page_title}
|
||||
<:subtitle>
|
||||
{gettext("Use this form to manage custom_field records in your database.")}
|
||||
</:subtitle>
|
||||
</.header>
|
||||
|
||||
<.form for={@form} id="custom_field-form" phx-change="validate" phx-submit="save">
|
||||
<.input field={@form[:name]} type="text" label={gettext("Name")} />
|
||||
|
||||
<.input
|
||||
field={@form[:value_type]}
|
||||
type="select"
|
||||
label={gettext("Value type")}
|
||||
options={
|
||||
Ash.Resource.Info.attribute(Mv.Membership.CustomField, :value_type).constraints[:one_of]
|
||||
}
|
||||
/>
|
||||
<.input field={@form[:description]} type="text" label={gettext("Description")} />
|
||||
<.input field={@form[:immutable]} type="checkbox" label={gettext("Immutable")} />
|
||||
<.input field={@form[:required]} type="checkbox" label={gettext("Required")} />
|
||||
<.input field={@form[:show_in_overview]} type="checkbox" label={gettext("Show in overview")} />
|
||||
|
||||
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
||||
{gettext("Save Custom field")}
|
||||
</.button>
|
||||
<.button navigate={return_path(@return_to, @custom_field)}>{gettext("Cancel")}</.button>
|
||||
</.form>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(params, _session, socket) do
|
||||
custom_field =
|
||||
case params["id"] do
|
||||
nil -> nil
|
||||
id -> Ash.get!(Mv.Membership.CustomField, id)
|
||||
end
|
||||
|
||||
action = if is_nil(custom_field), do: "New", else: "Edit"
|
||||
page_title = action <> " " <> "Custom field"
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:return_to, return_to(params["return_to"]))
|
||||
|> assign(custom_field: custom_field)
|
||||
|> assign(:page_title, page_title)
|
||||
|> assign_form()}
|
||||
end
|
||||
|
||||
defp return_to("show"), do: "show"
|
||||
defp return_to(_), do: "index"
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"custom_field" => custom_field_params}, socket) do
|
||||
{:noreply,
|
||||
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, custom_field_params))}
|
||||
end
|
||||
|
||||
def handle_event("save", %{"custom_field" => custom_field_params}, socket) do
|
||||
case AshPhoenix.Form.submit(socket.assigns.form, params: custom_field_params) do
|
||||
{:ok, custom_field} ->
|
||||
notify_parent({:saved, custom_field})
|
||||
|
||||
action =
|
||||
case socket.assigns.form.source.type do
|
||||
:create -> gettext("create")
|
||||
:update -> gettext("update")
|
||||
other -> to_string(other)
|
||||
end
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> put_flash(:info, gettext("Custom field %{action} successfully", action: action))
|
||||
|> push_navigate(to: return_path(socket.assigns.return_to, custom_field))
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, form} ->
|
||||
{:noreply, assign(socket, form: form)}
|
||||
end
|
||||
end
|
||||
|
||||
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
|
||||
|
||||
defp assign_form(%{assigns: %{custom_field: custom_field}} = socket) do
|
||||
form =
|
||||
if custom_field do
|
||||
AshPhoenix.Form.for_update(custom_field, :update, as: "custom_field")
|
||||
else
|
||||
AshPhoenix.Form.for_create(Mv.Membership.CustomField, :create, as: "custom_field")
|
||||
end
|
||||
|
||||
assign(socket, form: to_form(form))
|
||||
end
|
||||
|
||||
defp return_path("index", _custom_field), do: ~p"/custom_fields"
|
||||
defp return_path("show", custom_field), do: ~p"/custom_fields/#{custom_field.id}"
|
||||
end
|
||||
127
lib/mv_web/live/custom_field_live/form_component.ex
Normal file
127
lib/mv_web/live/custom_field_live/form_component.ex
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
defmodule MvWeb.CustomFieldLive.FormComponent do
|
||||
@moduledoc """
|
||||
LiveComponent form for creating and editing custom fields (embedded in settings).
|
||||
|
||||
## Features
|
||||
- Create new custom field definitions
|
||||
- Edit existing custom fields
|
||||
- Select value type from supported types
|
||||
- Set immutable and required flags
|
||||
- Real-time validation
|
||||
|
||||
## Props
|
||||
- `custom_field` - The custom field to edit (nil for new)
|
||||
- `on_save` - Callback function to call when form is saved
|
||||
- `on_cancel` - Callback function to call when form is cancelled
|
||||
"""
|
||||
use MvWeb, :live_component
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div id={@id} class="mb-8 border shadow-xl card border-base-300">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<.button
|
||||
type="button"
|
||||
phx-click="cancel"
|
||||
phx-target={@myself}
|
||||
aria-label={gettext("Back to custom field overview")}
|
||||
>
|
||||
<.icon name="hero-arrow-left" class="w-4 h-4" />
|
||||
</.button>
|
||||
<h3 class="card-title">
|
||||
{if @custom_field, do: gettext("Edit Custom Field"), else: gettext("New Custom Field")}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<.form
|
||||
for={@form}
|
||||
id={@id <> "-form"}
|
||||
phx-change="validate"
|
||||
phx-submit="save"
|
||||
phx-target={@myself}
|
||||
>
|
||||
<.input field={@form[:name]} type="text" label={gettext("Name")} />
|
||||
|
||||
<.input
|
||||
field={@form[:value_type]}
|
||||
type="select"
|
||||
label={gettext("Value type")}
|
||||
options={
|
||||
Ash.Resource.Info.attribute(Mv.Membership.CustomField, :value_type).constraints[:one_of]
|
||||
}
|
||||
/>
|
||||
<.input field={@form[:description]} type="text" label={gettext("Description")} />
|
||||
<.input field={@form[:immutable]} type="checkbox" label={gettext("Immutable")} />
|
||||
<.input field={@form[:required]} type="checkbox" label={gettext("Required")} />
|
||||
<.input
|
||||
field={@form[:show_in_overview]}
|
||||
type="checkbox"
|
||||
label={gettext("Show in overview")}
|
||||
/>
|
||||
|
||||
<div class="justify-end mt-4 card-actions">
|
||||
<.button type="button" phx-click="cancel" phx-target={@myself}>
|
||||
{gettext("Cancel")}
|
||||
</.button>
|
||||
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
||||
{gettext("Save Custom field")}
|
||||
</.button>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign_form()}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"custom_field" => custom_field_params}, socket) do
|
||||
{:noreply,
|
||||
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, custom_field_params))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("save", %{"custom_field" => custom_field_params}, socket) do
|
||||
case AshPhoenix.Form.submit(socket.assigns.form, params: custom_field_params) do
|
||||
{:ok, custom_field} ->
|
||||
action =
|
||||
case socket.assigns.form.source.type do
|
||||
:create -> gettext("create")
|
||||
:update -> gettext("update")
|
||||
other -> to_string(other)
|
||||
end
|
||||
|
||||
socket.assigns.on_save.(custom_field, action)
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, form} ->
|
||||
{:noreply, assign(socket, form: form)}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("cancel", _params, socket) do
|
||||
socket.assigns.on_cancel.()
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
defp assign_form(%{assigns: %{custom_field: custom_field}} = socket) do
|
||||
form =
|
||||
if custom_field do
|
||||
AshPhoenix.Form.for_update(custom_field, :update, as: "custom_field")
|
||||
else
|
||||
AshPhoenix.Form.for_create(Mv.Membership.CustomField, :create, as: "custom_field")
|
||||
end
|
||||
|
||||
assign(socket, form: to_form(form))
|
||||
end
|
||||
end
|
||||
|
|
@ -1,199 +0,0 @@
|
|||
defmodule MvWeb.CustomFieldLive.Index do
|
||||
@moduledoc """
|
||||
LiveView for managing custom field definitions (admin).
|
||||
|
||||
## Features
|
||||
- List all custom fields
|
||||
- Display type information (name, value type, description)
|
||||
- Show immutable and required flags
|
||||
- Create new custom fields
|
||||
- Edit existing custom fields
|
||||
- Delete custom fields with confirmation (cascades to all custom field values)
|
||||
|
||||
## Displayed Information
|
||||
- Name: Unique identifier for the custom field
|
||||
- Value type: Data type constraint (string, integer, boolean, date, email)
|
||||
- Description: Human-readable explanation
|
||||
- Immutable: Whether custom field values can be changed after creation
|
||||
- Required: Whether all members must have this custom field (future feature)
|
||||
|
||||
## Events
|
||||
- `prepare_delete` - Opens deletion confirmation modal with member count
|
||||
- `confirm_delete` - Executes deletion after slug verification
|
||||
- `cancel_delete` - Cancels deletion and closes modal
|
||||
- `update_slug_confirmation` - Updates slug input state
|
||||
|
||||
## Security
|
||||
Custom field management is restricted to admin users.
|
||||
Deletion requires entering the custom field's slug to prevent accidental deletions.
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
Listing Custom fields
|
||||
<:actions>
|
||||
<.button variant="primary" navigate={~p"/custom_fields/new"}>
|
||||
<.icon name="hero-plus" /> New Custom field
|
||||
</.button>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<.table
|
||||
id="custom_fields"
|
||||
rows={@streams.custom_fields}
|
||||
row_click={fn {_id, custom_field} -> JS.navigate(~p"/custom_fields/#{custom_field}") end}
|
||||
>
|
||||
<:col :let={{_id, custom_field}} label="Name">{custom_field.name}</:col>
|
||||
|
||||
<:col :let={{_id, custom_field}} label="Description">{custom_field.description}</:col>
|
||||
|
||||
<:action :let={{_id, custom_field}}>
|
||||
<div class="sr-only">
|
||||
<.link navigate={~p"/custom_fields/#{custom_field}"}>Show</.link>
|
||||
</div>
|
||||
|
||||
<.link navigate={~p"/custom_fields/#{custom_field}/edit"}>Edit</.link>
|
||||
</:action>
|
||||
|
||||
<:action :let={{_id, custom_field}}>
|
||||
<.link phx-click={JS.push("prepare_delete", value: %{id: custom_field.id})}>
|
||||
Delete
|
||||
</.link>
|
||||
</:action>
|
||||
</.table>
|
||||
|
||||
<%!-- Delete Confirmation Modal --%>
|
||||
<dialog :if={@show_delete_modal} id="delete-custom-field-modal" class="modal modal-open">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">{gettext("Delete Custom Field")}</h3>
|
||||
|
||||
<div class="py-4 space-y-4">
|
||||
<div class="alert alert-warning">
|
||||
<.icon name="hero-exclamation-triangle" class="h-5 w-5" />
|
||||
<div>
|
||||
<p class="font-semibold">
|
||||
{ngettext(
|
||||
"%{count} member has a value assigned for this custom field.",
|
||||
"%{count} members have values assigned for this custom field.",
|
||||
@custom_field_to_delete.assigned_members_count,
|
||||
count: @custom_field_to_delete.assigned_members_count
|
||||
)}
|
||||
</p>
|
||||
<p class="text-sm mt-2">
|
||||
{gettext(
|
||||
"All custom field values will be permanently deleted when you delete this custom field."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="slug-confirmation" class="label">
|
||||
<span class="label-text">
|
||||
{gettext("To confirm deletion, please enter this text:")}
|
||||
</span>
|
||||
</label>
|
||||
<div class="font-mono font-bold text-lg mb-2 p-2 bg-base-200 rounded break-all">
|
||||
{@custom_field_to_delete.slug}
|
||||
</div>
|
||||
<form phx-change="update_slug_confirmation">
|
||||
<input
|
||||
id="slug-confirmation"
|
||||
name="slug"
|
||||
type="text"
|
||||
value={@slug_confirmation}
|
||||
placeholder={gettext("Enter the text above to confirm")}
|
||||
autocomplete="off"
|
||||
phx-mounted={JS.focus()}
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button phx-click="cancel_delete" class="btn">
|
||||
{gettext("Cancel")}
|
||||
</button>
|
||||
<button
|
||||
phx-click="confirm_delete"
|
||||
class="btn btn-error"
|
||||
disabled={@slug_confirmation != @custom_field_to_delete.slug}
|
||||
>
|
||||
{gettext("Delete Custom Field and All Values")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Listing Custom fields")
|
||||
|> assign(:show_delete_modal, false)
|
||||
|> assign(:custom_field_to_delete, nil)
|
||||
|> assign(:slug_confirmation, "")
|
||||
|> stream(:custom_fields, Ash.read!(Mv.Membership.CustomField))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("prepare_delete", %{"id" => id}, socket) do
|
||||
custom_field = Ash.get!(Mv.Membership.CustomField, id, load: [:assigned_members_count])
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:custom_field_to_delete, custom_field)
|
||||
|> assign(:show_delete_modal, true)
|
||||
|> assign(:slug_confirmation, "")}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("update_slug_confirmation", %{"slug" => slug}, socket) do
|
||||
{:noreply, assign(socket, :slug_confirmation, slug)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("confirm_delete", _params, socket) do
|
||||
custom_field = socket.assigns.custom_field_to_delete
|
||||
|
||||
if socket.assigns.slug_confirmation == custom_field.slug do
|
||||
# Delete the custom field (CASCADE will handle custom field values)
|
||||
case Ash.destroy(custom_field) do
|
||||
:ok ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Custom field deleted successfully")
|
||||
|> assign(:show_delete_modal, false)
|
||||
|> assign(:custom_field_to_delete, nil)
|
||||
|> assign(:slug_confirmation, "")
|
||||
|> stream_delete(:custom_fields, custom_field)}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, "Failed to delete custom field: #{inspect(error)}")}
|
||||
end
|
||||
else
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, "Slug does not match. Deletion cancelled.")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("cancel_delete", _params, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:show_delete_modal, false)
|
||||
|> assign(:custom_field_to_delete, nil)
|
||||
|> assign(:slug_confirmation, "")}
|
||||
end
|
||||
end
|
||||
261
lib/mv_web/live/custom_field_live/index_component.ex
Normal file
261
lib/mv_web/live/custom_field_live/index_component.ex
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
defmodule MvWeb.CustomFieldLive.IndexComponent do
|
||||
@moduledoc """
|
||||
LiveComponent for managing custom field definitions (embedded in settings).
|
||||
|
||||
## Features
|
||||
- List all custom fields
|
||||
- Display type information (name, value type, description)
|
||||
- Show immutable and required flags
|
||||
- Create new custom fields
|
||||
- Edit existing custom fields
|
||||
- Delete custom fields with confirmation (cascades to all custom field values)
|
||||
"""
|
||||
use MvWeb, :live_component
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div id={@id}>
|
||||
<.header>
|
||||
{gettext("Custom Fields")}
|
||||
<:subtitle>
|
||||
{gettext("These will appear in addition to other data when adding new members.")}
|
||||
</:subtitle>
|
||||
<:actions>
|
||||
<.button variant="primary" phx-click="new_custom_field" phx-target={@myself}>
|
||||
<.icon name="hero-plus" /> {gettext("New Custom field")}
|
||||
</.button>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<%!-- Show form when creating or editing --%>
|
||||
<div :if={@show_form} class="mb-8">
|
||||
<.live_component
|
||||
module={MvWeb.CustomFieldLive.FormComponent}
|
||||
id={@form_id}
|
||||
custom_field={@editing_custom_field}
|
||||
on_save={
|
||||
fn custom_field, action -> send(self(), {:custom_field_saved, custom_field, action}) end
|
||||
}
|
||||
on_cancel={fn -> send_update(__MODULE__, id: @id, show_form: false) end}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<%!-- Hide table when form is visible --%>
|
||||
<.table
|
||||
:if={!@show_form}
|
||||
id="custom_fields"
|
||||
rows={@streams.custom_fields}
|
||||
row_click={
|
||||
fn {_id, custom_field} ->
|
||||
JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself)
|
||||
end
|
||||
}
|
||||
>
|
||||
<:col :let={{_id, custom_field}} label={gettext("Name")}>{custom_field.name}</:col>
|
||||
|
||||
<:col :let={{_id, custom_field}} label={gettext("Value Type")}>
|
||||
{custom_field.value_type}
|
||||
</:col>
|
||||
|
||||
<:col :let={{_id, custom_field}} label={gettext("Description")}>
|
||||
{custom_field.description}
|
||||
</:col>
|
||||
|
||||
<:col :let={{_id, custom_field}} label={gettext("Show in Overview")}>
|
||||
<span :if={custom_field.show_in_overview} class="badge badge-success">
|
||||
{gettext("Yes")}
|
||||
</span>
|
||||
<span :if={!custom_field.show_in_overview} class="badge badge-ghost">
|
||||
{gettext("No")}
|
||||
</span>
|
||||
</:col>
|
||||
|
||||
<:action :let={{_id, custom_field}}>
|
||||
<.link phx-click={
|
||||
JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself)
|
||||
}>
|
||||
{gettext("Edit")}
|
||||
</.link>
|
||||
</:action>
|
||||
|
||||
<:action :let={{_id, custom_field}}>
|
||||
<.link phx-click={JS.push("prepare_delete", value: %{id: custom_field.id}, target: @myself)}>
|
||||
{gettext("Delete")}
|
||||
</.link>
|
||||
</:action>
|
||||
</.table>
|
||||
|
||||
<%!-- Delete Confirmation Modal --%>
|
||||
<dialog :if={@show_delete_modal} id="delete-custom-field-modal" class="modal modal-open">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold">{gettext("Delete Custom Field")}</h3>
|
||||
|
||||
<div class="py-4 space-y-4">
|
||||
<div class="alert alert-warning">
|
||||
<.icon name="hero-exclamation-triangle" class="w-5 h-5" />
|
||||
<div>
|
||||
<p class="font-semibold">
|
||||
{ngettext(
|
||||
"%{count} member has a value assigned for this custom field.",
|
||||
"%{count} members have values assigned for this custom field.",
|
||||
@custom_field_to_delete.assigned_members_count,
|
||||
count: @custom_field_to_delete.assigned_members_count
|
||||
)}
|
||||
</p>
|
||||
<p class="mt-2 text-sm">
|
||||
{gettext(
|
||||
"All custom field values will be permanently deleted when you delete this custom field."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="slug-confirmation" class="label">
|
||||
<span class="label-text">
|
||||
{gettext("To confirm deletion, please enter this text:")}
|
||||
</span>
|
||||
</label>
|
||||
<div class="p-2 mb-2 font-mono text-lg font-bold break-all rounded bg-base-200">
|
||||
{@custom_field_to_delete.slug}
|
||||
</div>
|
||||
<form phx-change="update_slug_confirmation" phx-target={@myself}>
|
||||
<input
|
||||
id="slug-confirmation"
|
||||
name="slug"
|
||||
type="text"
|
||||
value={@slug_confirmation}
|
||||
placeholder={gettext("Enter the text above to confirm")}
|
||||
autocomplete="off"
|
||||
phx-mounted={JS.focus()}
|
||||
class="w-full input input-bordered"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button phx-click="cancel_delete" phx-target={@myself} class="btn">
|
||||
{gettext("Cancel")}
|
||||
</button>
|
||||
<button
|
||||
phx-click="confirm_delete"
|
||||
phx-target={@myself}
|
||||
class="btn btn-error"
|
||||
disabled={@slug_confirmation != @custom_field_to_delete.slug}
|
||||
>
|
||||
{gettext("Delete Custom Field and All Values")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
# If show_form is explicitly provided in assigns, reset editing state
|
||||
socket =
|
||||
if Map.has_key?(assigns, :show_form) and assigns.show_form == false do
|
||||
socket
|
||||
|> assign(:editing_custom_field, nil)
|
||||
|> assign(:form_id, "custom-field-form-new")
|
||||
else
|
||||
socket
|
||||
end
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign_new(:show_form, fn -> false end)
|
||||
|> assign_new(:form_id, fn -> "custom-field-form-new" end)
|
||||
|> assign_new(:editing_custom_field, fn -> nil end)
|
||||
|> assign_new(:show_delete_modal, fn -> false end)
|
||||
|> assign_new(:custom_field_to_delete, fn -> nil end)
|
||||
|> assign_new(:slug_confirmation, fn -> "" end)
|
||||
|> stream(:custom_fields, Ash.read!(Mv.Membership.CustomField), reset: true)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("new_custom_field", _params, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:show_form, true)
|
||||
|> assign(:editing_custom_field, nil)
|
||||
|> assign(:form_id, "custom-field-form-new")}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("edit_custom_field", %{"id" => id}, socket) do
|
||||
custom_field = Ash.get!(Mv.Membership.CustomField, id)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:show_form, true)
|
||||
|> assign(:editing_custom_field, custom_field)
|
||||
|> assign(:form_id, "custom-field-form-#{id}")}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("prepare_delete", %{"id" => id}, socket) do
|
||||
custom_field = Ash.get!(Mv.Membership.CustomField, id, load: [:assigned_members_count])
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:custom_field_to_delete, custom_field)
|
||||
|> assign(:show_delete_modal, true)
|
||||
|> assign(:slug_confirmation, "")}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("update_slug_confirmation", %{"slug" => slug}, socket) do
|
||||
{:noreply, assign(socket, :slug_confirmation, slug)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("confirm_delete", _params, socket) do
|
||||
custom_field = socket.assigns.custom_field_to_delete
|
||||
|
||||
if socket.assigns.slug_confirmation == custom_field.slug do
|
||||
case Ash.destroy(custom_field) do
|
||||
:ok ->
|
||||
send(self(), {:custom_field_deleted, custom_field})
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:show_delete_modal, false)
|
||||
|> assign(:custom_field_to_delete, nil)
|
||||
|> assign(:slug_confirmation, "")
|
||||
|> stream_delete(:custom_fields, custom_field)}
|
||||
|
||||
{:error, error} ->
|
||||
send(self(), {:custom_field_delete_error, error})
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:show_delete_modal, false)
|
||||
|> assign(:custom_field_to_delete, nil)
|
||||
|> assign(:slug_confirmation, "")}
|
||||
end
|
||||
else
|
||||
send(self(), :custom_field_slug_mismatch)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:show_delete_modal, false)
|
||||
|> assign(:custom_field_to_delete, nil)
|
||||
|> assign(:slug_confirmation, "")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("cancel_delete", _params, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:show_delete_modal, false)
|
||||
|> assign(:custom_field_to_delete, nil)
|
||||
|> assign(:slug_confirmation, "")}
|
||||
end
|
||||
end
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
defmodule MvWeb.CustomFieldLive.Show do
|
||||
@moduledoc """
|
||||
LiveView for displaying a single custom field's details (admin).
|
||||
|
||||
## Features
|
||||
- Display custom field definition
|
||||
- Show all attributes (name, value type, description, flags)
|
||||
- Navigate to edit form
|
||||
- Return to custom field list
|
||||
|
||||
## Displayed Information
|
||||
- ID: Internal UUID identifier
|
||||
- Slug: URL-friendly identifier (auto-generated, immutable)
|
||||
- Name: Unique identifier
|
||||
- Value type: Data type constraint
|
||||
- Description: Optional explanation
|
||||
- Immutable flag: Whether values can be changed
|
||||
- Required flag: Whether all members need this custom field
|
||||
|
||||
## Navigation
|
||||
- Back to custom field list
|
||||
- Edit custom field
|
||||
|
||||
## Security
|
||||
Custom field details are restricted to admin users.
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
Custom field {@custom_field.slug}
|
||||
<:subtitle>This is a custom_field record from your database.</:subtitle>
|
||||
|
||||
<:actions>
|
||||
<.button navigate={~p"/custom_fields"}>
|
||||
<.icon name="hero-arrow-left" />
|
||||
</.button>
|
||||
<.button
|
||||
variant="primary"
|
||||
navigate={~p"/custom_fields/#{@custom_field}/edit?return_to=show"}
|
||||
>
|
||||
<.icon name="hero-pencil-square" /> Edit Custom field
|
||||
</.button>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<.list>
|
||||
<:item title="Id">{@custom_field.id}</:item>
|
||||
|
||||
<:item title="Slug">
|
||||
{@custom_field.slug}
|
||||
<p class="mt-2 text-sm leading-6 text-zinc-600">
|
||||
{gettext("Auto-generated identifier (immutable)")}
|
||||
</p>
|
||||
</:item>
|
||||
|
||||
<:item title="Name">{@custom_field.name}</:item>
|
||||
|
||||
<:item title="Description">{@custom_field.description}</:item>
|
||||
</.list>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(%{"id" => id}, _session, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Show Custom field")
|
||||
|> assign(:custom_field, Ash.get!(Mv.Membership.CustomField, id))}
|
||||
end
|
||||
end
|
||||
|
|
@ -4,6 +4,7 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
|
||||
## Features
|
||||
- Edit the association/club name
|
||||
- Manage custom fields
|
||||
- Real-time form validation
|
||||
- Success/error feedback
|
||||
|
||||
|
|
@ -28,7 +29,7 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, gettext("Club Settings"))
|
||||
|> assign(:page_title, gettext("Settings"))
|
||||
|> assign(:settings, settings)
|
||||
|> assign_form()}
|
||||
end
|
||||
|
|
@ -38,12 +39,16 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
{gettext("Club Settings")}
|
||||
{gettext("Settings")}
|
||||
<:subtitle>
|
||||
{gettext("Manage global settings for the association.")}
|
||||
</:subtitle>
|
||||
</.header>
|
||||
|
||||
<%!-- Club Settings Section --%>
|
||||
<.header>
|
||||
{gettext("Club Settings")}
|
||||
</.header>
|
||||
<.form for={@form} id="settings-form" phx-change="validate" phx-submit="save">
|
||||
<.input
|
||||
field={@form[:club_name]}
|
||||
|
|
@ -56,6 +61,12 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
{gettext("Save Settings")}
|
||||
</.button>
|
||||
</.form>
|
||||
|
||||
<%!-- Custom Fields Section --%>
|
||||
<.live_component
|
||||
module={MvWeb.CustomFieldLive.IndexComponent}
|
||||
id="custom-fields-component"
|
||||
/>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
|
@ -66,6 +77,7 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, setting_params))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("save", %{"setting" => setting_params}, socket) do
|
||||
case AshPhoenix.Form.submit(socket.assigns.form, params: setting_params) do
|
||||
{:ok, updated_settings} ->
|
||||
|
|
@ -82,6 +94,37 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:custom_field_saved, _custom_field, action}, socket) do
|
||||
send_update(MvWeb.CustomFieldLive.IndexComponent,
|
||||
id: "custom-fields-component",
|
||||
show_form: false
|
||||
)
|
||||
|
||||
{:noreply,
|
||||
put_flash(socket, :info, gettext("Custom field %{action} successfully", action: action))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:custom_field_deleted, _custom_field}, socket) do
|
||||
{:noreply, put_flash(socket, :info, gettext("Custom field deleted successfully"))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:custom_field_delete_error, error}, socket) do
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("Failed to delete custom field: %{error}", error: inspect(error))
|
||||
)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(:custom_field_slug_mismatch, socket) do
|
||||
{:noreply, put_flash(socket, :error, gettext("Slug does not match. Deletion cancelled."))}
|
||||
end
|
||||
|
||||
defp assign_form(%{assigns: %{settings: settings}} = socket) do
|
||||
form =
|
||||
AshPhoenix.Form.for_update(
|
||||
|
|
|
|||
|
|
@ -5,80 +5,212 @@ defmodule MvWeb.MemberLive.Form do
|
|||
## Features
|
||||
- Create new members with personal information
|
||||
- Edit existing member details
|
||||
- Manage custom properties (dynamic fields)
|
||||
- Grouped sections for better organization
|
||||
- Tab navigation (Payments tab disabled, coming soon)
|
||||
- Manage custom properties (dynamic fields, displayed sorted by name)
|
||||
- Real-time validation with visual feedback
|
||||
- Link/unlink user accounts
|
||||
|
||||
## Form Fields
|
||||
**Required:**
|
||||
- first_name, last_name, email
|
||||
|
||||
**Optional:**
|
||||
- phone_number, address fields (city, street, house_number, postal_code)
|
||||
- join_date, exit_date
|
||||
- paid status
|
||||
- notes
|
||||
|
||||
## Custom Field Values
|
||||
Members can have dynamic custom field values defined by CustomFields.
|
||||
The form dynamically renders inputs based on available CustomFields.
|
||||
## Form Sections
|
||||
- Personal Data: Name, address, contact information, membership dates, notes
|
||||
- Custom Fields: Dynamic fields in uniform grid layout (displayed sorted by name)
|
||||
- Payment Data: Mockup section (not editable)
|
||||
|
||||
## Events
|
||||
- `validate` - Real-time form validation
|
||||
- `save` - Submit form (create or update member)
|
||||
- Custom field value management events for adding/removing custom fields
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
# Sort custom fields by name for display only
|
||||
sorted_custom_fields = Enum.sort_by(assigns.custom_fields, & &1.name)
|
||||
assigns = assign(assigns, :sorted_custom_fields, sorted_custom_fields)
|
||||
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
{@page_title}
|
||||
<:subtitle>
|
||||
{gettext("Fields marked with an asterisk (*) cannot be empty.")}
|
||||
</:subtitle>
|
||||
</.header>
|
||||
|
||||
<.form for={@form} id="member-form" phx-change="validate" phx-submit="save">
|
||||
<.input field={@form[:first_name]} label={gettext("First Name")} required />
|
||||
<.input field={@form[:last_name]} label={gettext("Last Name")} required />
|
||||
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
|
||||
<.input field={@form[:paid]} label={gettext("Paid")} type="checkbox" />
|
||||
<.input field={@form[:phone_number]} label={gettext("Phone Number")} />
|
||||
<.input field={@form[:join_date]} label={gettext("Join Date")} type="date" />
|
||||
<.input field={@form[:exit_date]} label={gettext("Exit Date")} type="date" />
|
||||
<.input field={@form[:notes]} label={gettext("Notes")} />
|
||||
<.input field={@form[:city]} label={gettext("City")} />
|
||||
<.input field={@form[:street]} label={gettext("Street")} />
|
||||
<.input field={@form[:house_number]} label={gettext("House Number")} />
|
||||
<.input field={@form[:postal_code]} label={gettext("Postal Code")} />
|
||||
<%!-- Header with Back button, Name display, and Save button --%>
|
||||
<div class="flex items-center justify-between gap-4 pb-4">
|
||||
<.button navigate={return_path(@return_to, @member)} type="button">
|
||||
<.icon name="hero-arrow-left" class="size-4" />
|
||||
{gettext("Back")}
|
||||
</.button>
|
||||
|
||||
<h3 class="mt-8 mb-2 text-lg font-semibold">{gettext("Custom Field Values")}</h3>
|
||||
<.inputs_for :let={f_custom_field_value} field={@form[:custom_field_values]}>
|
||||
<% type =
|
||||
Enum.find(@custom_fields, &(&1.id == f_custom_field_value[:custom_field_id].value)) %>
|
||||
<.inputs_for :let={value_form} field={f_custom_field_value[:value]}>
|
||||
<% input_type =
|
||||
cond do
|
||||
type && type.value_type == :boolean -> "checkbox"
|
||||
type && type.value_type == :date -> :date
|
||||
true -> :text
|
||||
end %>
|
||||
<.input field={value_form[:value]} label={type && type.name} type={input_type} />
|
||||
</.inputs_for>
|
||||
<input
|
||||
type="hidden"
|
||||
name={f_custom_field_value[:custom_field_id].name}
|
||||
value={f_custom_field_value[:custom_field_id].value}
|
||||
/>
|
||||
</.inputs_for>
|
||||
<h1 class="text-2xl font-bold text-center flex-1">
|
||||
<%= if @member do %>
|
||||
{@member.first_name} {@member.last_name}
|
||||
<% else %>
|
||||
{gettext("New Member")}
|
||||
<% end %>
|
||||
</h1>
|
||||
|
||||
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
||||
{gettext("Save Member")}
|
||||
</.button>
|
||||
<.button navigate={return_path(@return_to, @member)}>{gettext("Cancel")}</.button>
|
||||
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
|
||||
{gettext("Save")}
|
||||
</.button>
|
||||
</div>
|
||||
|
||||
<%!-- Tab Navigation --%>
|
||||
<div role="tablist" class="tabs tabs-bordered mb-6">
|
||||
<button type="button" role="tab" class="tab tab-active" aria-selected="true">
|
||||
<.icon name="hero-identification" class="size-4 mr-2" />
|
||||
{gettext("Contact Data")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="tab"
|
||||
disabled
|
||||
aria-disabled="true"
|
||||
title={gettext("Coming soon")}
|
||||
>
|
||||
<.icon name="hero-credit-card" class="size-4 mr-2" />
|
||||
{gettext("Payments")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<%!-- Personal Data and Custom Fields Row --%>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<%!-- Personal Data Section --%>
|
||||
<div>
|
||||
<.form_section title={gettext("Personal Data")}>
|
||||
<div class="space-y-4">
|
||||
<%!-- Name Row --%>
|
||||
<div class="flex gap-4">
|
||||
<div class="w-48">
|
||||
<.input field={@form[:first_name]} label={gettext("First Name")} required />
|
||||
</div>
|
||||
<div class="w-48">
|
||||
<.input field={@form[:last_name]} label={gettext("Last Name")} required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Address Row --%>
|
||||
<div class="flex gap-4">
|
||||
<div class="flex-1">
|
||||
<.input field={@form[:street]} label={gettext("Street")} />
|
||||
</div>
|
||||
<div class="w-16">
|
||||
<.input field={@form[:house_number]} label={gettext("Nr.")} />
|
||||
</div>
|
||||
<div class="w-24">
|
||||
<.input field={@form[:postal_code]} label={gettext("Postal Code")} />
|
||||
</div>
|
||||
<div class="w-32">
|
||||
<.input field={@form[:city]} label={gettext("City")} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Email --%>
|
||||
<div>
|
||||
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
|
||||
</div>
|
||||
|
||||
<%!-- Phone --%>
|
||||
<div>
|
||||
<.input field={@form[:phone_number]} label={gettext("Phone")} type="tel" />
|
||||
</div>
|
||||
|
||||
<%!-- Membership Dates Row --%>
|
||||
<div class="flex gap-4">
|
||||
<div class="w-36">
|
||||
<.input field={@form[:join_date]} label={gettext("Join Date")} type="date" />
|
||||
</div>
|
||||
<div class="w-36">
|
||||
<.input field={@form[:exit_date]} label={gettext("Exit Date")} type="date" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Notes --%>
|
||||
<div>
|
||||
<.input field={@form[:notes]} label={gettext("Notes")} type="textarea" />
|
||||
</div>
|
||||
</div>
|
||||
</.form_section>
|
||||
</div>
|
||||
|
||||
<%!-- Custom Fields Section --%>
|
||||
<%= if Enum.any?(@custom_fields) do %>
|
||||
<div>
|
||||
<.form_section title={gettext("Custom Fields")}>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<%!-- Render in sorted order by finding the form for each sorted custom field --%>
|
||||
<%= for cf <- @sorted_custom_fields do %>
|
||||
<.inputs_for :let={f_cfv} field={@form[:custom_field_values]}>
|
||||
<%= if f_cfv[:custom_field_id].value == cf.id do %>
|
||||
<div class={if cf.value_type == :boolean, do: "flex items-end", else: ""}>
|
||||
<.inputs_for :let={value_form} field={f_cfv[:value]}>
|
||||
<.input
|
||||
field={value_form[:value]}
|
||||
label={cf.name}
|
||||
type={custom_field_input_type(cf.value_type)}
|
||||
/>
|
||||
</.inputs_for>
|
||||
<input
|
||||
type="hidden"
|
||||
name={f_cfv[:custom_field_id].name}
|
||||
value={f_cfv[:custom_field_id].value}
|
||||
/>
|
||||
</div>
|
||||
<% end %>
|
||||
</.inputs_for>
|
||||
<% end %>
|
||||
</div>
|
||||
</.form_section>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%!-- Payment Data Section (Mockup) --%>
|
||||
<div class="max-w-xl">
|
||||
<.form_section title={gettext("Payment Data")}>
|
||||
<div role="alert" class="alert alert-info mb-4">
|
||||
<.icon name="hero-information-circle" class="size-5" />
|
||||
<span>{gettext("This data is for demonstration purposes only (mockup).")}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-8">
|
||||
<div class="w-24">
|
||||
<label for="mock-contribution" class="label text-sm font-medium">
|
||||
{gettext("Contribution")}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="mock-contribution"
|
||||
value="72 €"
|
||||
disabled
|
||||
class="input input-bordered w-full bg-base-200"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-40">
|
||||
<label class="label text-sm font-medium">{gettext("Payment Cycle")}</label>
|
||||
<div class="flex gap-3 mt-2">
|
||||
<label class="flex items-center gap-1 cursor-not-allowed opacity-60">
|
||||
<input type="radio" name="mock_cycle" checked disabled class="radio radio-sm" />
|
||||
<span class="text-sm">{gettext("monthly")}</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-1 cursor-not-allowed opacity-60">
|
||||
<input type="radio" name="mock_cycle" disabled class="radio radio-sm" />
|
||||
<span class="text-sm">{gettext("yearly")}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-24 flex items-end">
|
||||
<.input field={@form[:paid]} label={gettext("Paid")} type="checkbox" />
|
||||
</div>
|
||||
</div>
|
||||
</.form_section>
|
||||
</div>
|
||||
|
||||
<%!-- Bottom Action Buttons --%>
|
||||
<div class="flex justify-end gap-4 mt-6">
|
||||
<.button navigate={return_path(@return_to, @member)} type="button">
|
||||
{gettext("Cancel")}
|
||||
</.button>
|
||||
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
|
||||
{gettext("Save Member")}
|
||||
</.button>
|
||||
</div>
|
||||
</.form>
|
||||
</Layouts.app>
|
||||
"""
|
||||
|
|
@ -106,8 +238,8 @@ defmodule MvWeb.MemberLive.Form do
|
|||
id -> Ash.get!(Mv.Membership.Member, id)
|
||||
end
|
||||
|
||||
action = if is_nil(member), do: "New", else: "Edit"
|
||||
page_title = action <> " " <> "Member"
|
||||
page_title =
|
||||
if is_nil(member), do: gettext("Create Member"), else: gettext("Edit Member")
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|
|
@ -213,5 +345,37 @@ defmodule MvWeb.MemberLive.Form do
|
|||
end
|
||||
|
||||
defp return_path("index", _member), do: ~p"/members"
|
||||
defp return_path("show", nil), do: ~p"/members"
|
||||
defp return_path("show", member), do: ~p"/members/#{member.id}"
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Helper Components
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
# Renders a form section box with border and title.
|
||||
attr :title, :string, required: true
|
||||
slot :inner_block, required: true
|
||||
|
||||
defp form_section(assigns) do
|
||||
~H"""
|
||||
<section class="mb-6">
|
||||
<h2 class="text-lg font-semibold mb-3">{@title}</h2>
|
||||
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
</section>
|
||||
"""
|
||||
end
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Helper Functions for Custom Fields
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
# Returns input type for custom field based on value type
|
||||
defp custom_field_input_type(:string), do: "text"
|
||||
defp custom_field_input_type(:integer), do: "number"
|
||||
defp custom_field_input_type(:boolean), do: "checkbox"
|
||||
defp custom_field_input_type(:date), do: "date"
|
||||
defp custom_field_input_type(:email), do: "email"
|
||||
defp custom_field_input_type(_), do: "text"
|
||||
end
|
||||
|
|
|
|||
|
|
@ -32,9 +32,12 @@ 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
|
||||
|
||||
# Prefix used in sort field names for custom fields (e.g., "custom_field_<id>")
|
||||
@custom_field_prefix "custom_field_"
|
||||
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
||||
|
||||
# Member fields that are loaded for the overview
|
||||
# Uses constants from Mv.Constants to ensure consistency
|
||||
|
|
@ -49,8 +52,8 @@ defmodule MvWeb.MemberLive.Index do
|
|||
payment filter, and member selection. Actual data loading happens in `handle_params/3`.
|
||||
"""
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
# Load custom fields that should be shown in overview
|
||||
def mount(_params, session, socket) do
|
||||
# Load custom fields that should be shown in overview (for display)
|
||||
# Note: Using Ash.read! (bang version) - errors will be handled by Phoenix LiveView
|
||||
# and result in a 500 error page. This is appropriate for LiveViews where errors
|
||||
# should be visible to the user rather than silently failing.
|
||||
|
|
@ -60,6 +63,12 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|> Ash.Query.sort(name: :asc)
|
||||
|> Ash.read!()
|
||||
|
||||
# Load ALL custom fields for the dropdown (to show all available fields)
|
||||
all_custom_fields =
|
||||
Mv.Membership.CustomField
|
||||
|> Ash.Query.sort(name: :asc)
|
||||
|> Ash.read!()
|
||||
|
||||
# Load settings once to avoid N+1 queries
|
||||
settings =
|
||||
case Membership.get_settings() do
|
||||
|
|
@ -68,6 +77,20 @@ defmodule MvWeb.MemberLive.Index do
|
|||
{:error, _} -> %{member_field_visibility: %{}}
|
||||
end
|
||||
|
||||
# Load user field selection from session
|
||||
session_selection = FieldSelection.get_from_session(session)
|
||||
|
||||
# Get all available fields (for dropdown - includes ALL custom fields)
|
||||
all_available_fields = FieldVisibility.get_all_available_fields(all_custom_fields)
|
||||
|
||||
# Merge session selection with global settings for initial state (use all_custom_fields)
|
||||
initial_selection =
|
||||
FieldVisibility.merge_with_global_settings(
|
||||
session_selection,
|
||||
settings,
|
||||
all_custom_fields
|
||||
)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:page_title, gettext("Members"))
|
||||
|
|
@ -76,8 +99,15 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|> assign_new(:sort_order, fn -> :asc end)
|
||||
|> assign(:paid_filter, nil)
|
||||
|> assign(:selected_members, MapSet.new())
|
||||
|> assign(:settings, settings)
|
||||
|> assign(:custom_fields_visible, custom_fields_visible)
|
||||
|> assign(:member_fields_visible, get_visible_member_fields(settings))
|
||||
|> assign(:all_custom_fields, all_custom_fields)
|
||||
|> assign(:all_available_fields, all_available_fields)
|
||||
|> assign(:user_field_selection, initial_selection)
|
||||
|> assign(
|
||||
:member_fields_visible,
|
||||
FieldVisibility.get_visible_member_fields(initial_selection)
|
||||
)
|
||||
|
||||
# We call handle params to use the query from the URL
|
||||
{:ok, socket}
|
||||
|
|
@ -182,6 +212,8 @@ defmodule MvWeb.MemberLive.Index do
|
|||
## Supported messages:
|
||||
- `{:sort, field}` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL
|
||||
- `{:search_changed, query}` - Search event from SearchBarComponent. Filters members and syncs URL
|
||||
- `{:field_toggled, field, visible}` - Field toggle event from FieldVisibilityDropdownComponent
|
||||
- `{:fields_selected, selection}` - Select all/deselect all event from FieldVisibilityDropdownComponent
|
||||
"""
|
||||
@impl true
|
||||
def handle_info({:sort, field_str}, socket) do
|
||||
|
|
@ -250,24 +282,111 @@ defmodule MvWeb.MemberLive.Index do
|
|||
)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:field_toggled, field_string, visible}, socket) do
|
||||
# Update user field selection
|
||||
new_selection = Map.put(socket.assigns.user_field_selection, field_string, visible)
|
||||
|
||||
# Save to session (cookie will be saved on next page load via handle_params)
|
||||
socket = update_session_field_selection(socket, new_selection)
|
||||
|
||||
# Merge with global settings (use all_custom_fields to allow enabling globally hidden fields)
|
||||
final_selection =
|
||||
FieldVisibility.merge_with_global_settings(
|
||||
new_selection,
|
||||
socket.assigns.settings,
|
||||
socket.assigns.all_custom_fields
|
||||
)
|
||||
|
||||
# Get visible fields
|
||||
visible_member_fields = FieldVisibility.get_visible_member_fields(final_selection)
|
||||
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:user_field_selection, final_selection)
|
||||
|> assign(:member_fields_visible, visible_member_fields)
|
||||
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|
||||
|> load_members()
|
||||
|> prepare_dynamic_cols()
|
||||
|> push_field_selection_url()
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:fields_selected, selection}, socket) do
|
||||
# Save to session
|
||||
socket = update_session_field_selection(socket, selection)
|
||||
|
||||
# Merge with global settings (use all_custom_fields for merging)
|
||||
final_selection =
|
||||
FieldVisibility.merge_with_global_settings(
|
||||
selection,
|
||||
socket.assigns.settings,
|
||||
socket.assigns.all_custom_fields
|
||||
)
|
||||
|
||||
# Get visible fields
|
||||
visible_member_fields = FieldVisibility.get_visible_member_fields(final_selection)
|
||||
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:user_field_selection, final_selection)
|
||||
|> assign(:member_fields_visible, visible_member_fields)
|
||||
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|
||||
|> load_members()
|
||||
|> prepare_dynamic_cols()
|
||||
|> push_field_selection_url()
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Handle Params from the URL
|
||||
# -----------------------------------------------------------------
|
||||
@doc """
|
||||
Handles URL parameter changes.
|
||||
|
||||
Parses query parameters for search query, sort field, sort order, and payment filter,
|
||||
Parses query parameters for search query, sort field, sort order, and payment filter, and field selection,
|
||||
then loads members accordingly. This enables bookmarkable URLs and
|
||||
browser back/forward navigation.
|
||||
"""
|
||||
@impl true
|
||||
def handle_params(params, _url, socket) do
|
||||
# Parse field selection from URL
|
||||
url_selection = FieldSelection.parse_from_url(params)
|
||||
|
||||
# Merge with session selection (URL has priority)
|
||||
merged_selection =
|
||||
FieldSelection.merge_sources(
|
||||
url_selection,
|
||||
socket.assigns.user_field_selection,
|
||||
%{}
|
||||
)
|
||||
|
||||
# Merge with global settings (use all_custom_fields for merging)
|
||||
final_selection =
|
||||
FieldVisibility.merge_with_global_settings(
|
||||
merged_selection,
|
||||
socket.assigns.settings,
|
||||
socket.assigns.all_custom_fields
|
||||
)
|
||||
|
||||
# Get visible fields
|
||||
visible_member_fields = FieldVisibility.get_visible_member_fields(final_selection)
|
||||
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> maybe_update_search(params)
|
||||
|> maybe_update_sort(params)
|
||||
|> maybe_update_paid_filter(params)
|
||||
|> assign(:query, params["query"])
|
||||
|> assign(:user_field_selection, final_selection)
|
||||
|> assign(:member_fields_visible, visible_member_fields)
|
||||
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|
||||
|> load_members()
|
||||
|> prepare_dynamic_cols()
|
||||
|
||||
|
|
@ -280,10 +399,17 @@ defmodule MvWeb.MemberLive.Index do
|
|||
# - `:custom_field` - The CustomField resource
|
||||
# - `:render` - A function that formats the custom field value for a given member
|
||||
#
|
||||
# Only includes custom fields that are visible according to user field selection.
|
||||
#
|
||||
# Returns the socket with `:dynamic_cols` assigned.
|
||||
defp prepare_dynamic_cols(socket) do
|
||||
visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
|
||||
|
||||
# Use all_custom_fields to allow users to enable globally hidden custom fields
|
||||
dynamic_cols =
|
||||
Enum.map(socket.assigns.custom_fields_visible, fn custom_field ->
|
||||
socket.assigns.all_custom_fields
|
||||
|> Enum.filter(fn custom_field -> custom_field.id in visible_custom_field_ids end)
|
||||
|> Enum.map(fn custom_field ->
|
||||
%{
|
||||
custom_field: custom_field,
|
||||
render: fn member ->
|
||||
|
|
@ -376,6 +502,58 @@ defmodule MvWeb.MemberLive.Index do
|
|||
)}
|
||||
end
|
||||
|
||||
# Builds query parameters including field selection
|
||||
defp build_query_params(socket, base_params) do
|
||||
# Use query from base_params if provided, otherwise fall back to socket.assigns.query
|
||||
query_value = Map.get(base_params, "query") || socket.assigns.query || ""
|
||||
|
||||
base_params
|
||||
|> Map.put("query", query_value)
|
||||
|> maybe_add_field_selection(socket.assigns[:user_field_selection])
|
||||
end
|
||||
|
||||
# Adds field selection to query params if present
|
||||
defp maybe_add_field_selection(params, nil), do: params
|
||||
|
||||
defp maybe_add_field_selection(params, selection) when is_map(selection) do
|
||||
fields_param = FieldSelection.to_url_param(selection)
|
||||
if fields_param != "", do: Map.put(params, "fields", fields_param), else: params
|
||||
end
|
||||
|
||||
defp maybe_add_field_selection(params, _), do: params
|
||||
|
||||
# Pushes URL with updated field selection
|
||||
defp push_field_selection_url(socket) do
|
||||
base_params = %{
|
||||
"sort_field" => field_to_string(socket.assigns.sort_field),
|
||||
"sort_order" => Atom.to_string(socket.assigns.sort_order)
|
||||
}
|
||||
|
||||
# Include paid_filter if set
|
||||
base_params =
|
||||
case socket.assigns.paid_filter do
|
||||
nil -> base_params
|
||||
:paid -> Map.put(base_params, "paid_filter", "paid")
|
||||
:not_paid -> Map.put(base_params, "paid_filter", "not_paid")
|
||||
end
|
||||
|
||||
query_params = build_query_params(socket, base_params)
|
||||
new_path = ~p"/members?#{query_params}"
|
||||
|
||||
push_patch(socket, to: new_path, replace: true)
|
||||
end
|
||||
|
||||
# Converts field to string
|
||||
defp field_to_string(field) when is_atom(field), do: Atom.to_string(field)
|
||||
defp field_to_string(field) when is_binary(field), do: field
|
||||
|
||||
# Updates session field selection (stored in socket for now, actual session update via controller)
|
||||
defp update_session_field_selection(socket, selection) do
|
||||
# Store in socket for now - actual session persistence would require a controller
|
||||
# This is a placeholder for future session persistence
|
||||
assign(socket, :user_field_selection, selection)
|
||||
end
|
||||
|
||||
# Builds URL query parameters map including all filter/sort state.
|
||||
# Converts paid_filter atom to string for URL.
|
||||
defp build_query_params(query, sort_field, sort_order, paid_filter) do
|
||||
|
|
@ -434,9 +612,9 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|> Ash.Query.new()
|
||||
|> Ash.Query.select(@overview_fields)
|
||||
|
||||
# Load custom field values for visible custom fields
|
||||
custom_field_ids_list = Enum.map(socket.assigns.custom_fields_visible, & &1.id)
|
||||
query = load_custom_field_values(query, custom_field_ids_list)
|
||||
# Load custom field values for visible custom fields (based on user selection)
|
||||
visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
|
||||
query = load_custom_field_values(query, visible_custom_field_ids)
|
||||
|
||||
# Apply the search filter first
|
||||
query = apply_search_filter(query, search_query)
|
||||
|
|
@ -614,6 +792,18 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|
||||
defp extract_custom_field_id(_), do: nil
|
||||
|
||||
# Extracts custom field IDs from visible custom field strings
|
||||
# Format: "custom_field_<id>" -> <id>
|
||||
defp extract_custom_field_ids(visible_custom_fields) do
|
||||
Enum.map(visible_custom_fields, fn field_string ->
|
||||
case String.split(field_string, @custom_field_prefix) do
|
||||
["", id] -> id
|
||||
_ -> nil
|
||||
end
|
||||
end)
|
||||
|> Enum.filter(&(&1 != nil))
|
||||
end
|
||||
|
||||
# Sorts members in memory by a custom field value.
|
||||
#
|
||||
# Process:
|
||||
|
|
@ -920,31 +1110,6 @@ defmodule MvWeb.MemberLive.Index do
|
|||
end
|
||||
end
|
||||
|
||||
# Gets the list of member fields that should be visible in the overview.
|
||||
#
|
||||
# Reads the visibility configuration from Settings and returns only the fields
|
||||
# where show_in_overview is true. Fields not configured in settings default to true.
|
||||
#
|
||||
# Performance: This function uses the already-loaded settings to avoid N+1 queries.
|
||||
# Settings should be loaded once in mount/3 and passed to this function.
|
||||
#
|
||||
# Parameters:
|
||||
# - `settings` - The settings struct loaded from the database
|
||||
#
|
||||
# Returns a list of atoms representing visible member field names.
|
||||
#
|
||||
# Fields are read from the global Constants module.
|
||||
@spec get_visible_member_fields(map()) :: [atom()]
|
||||
defp get_visible_member_fields(settings) do
|
||||
# Get all eligible fields from the global constants
|
||||
all_fields = Mv.Constants.member_fields()
|
||||
|
||||
# JSONB stores keys as strings
|
||||
visibility_config = settings.member_field_visibility || %{}
|
||||
|
||||
# Filter to only return visible fields
|
||||
Enum.filter(all_fields, fn field ->
|
||||
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
|
||||
|
|
|
|||
|
|
@ -44,6 +44,13 @@
|
|||
paid_filter={@paid_filter}
|
||||
member_count={length(@members)}
|
||||
/>
|
||||
<.live_component
|
||||
module={MvWeb.Components.FieldVisibilityDropdownComponent}
|
||||
id="field-visibility-dropdown"
|
||||
all_fields={@all_available_fields}
|
||||
custom_fields={@all_custom_fields}
|
||||
selected_fields={@user_field_selection}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<.table
|
||||
|
|
@ -82,6 +89,7 @@
|
|||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:first_name in @member_fields_visible}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
|
|
@ -95,7 +103,25 @@
|
|||
"""
|
||||
}
|
||||
>
|
||||
{member.first_name} {member.last_name}
|
||||
{member.first_name}
|
||||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:last_name in @member_fields_visible}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
module={MvWeb.Components.SortHeaderComponent}
|
||||
id={:sort_last_name}
|
||||
field={:last_name}
|
||||
label={gettext("Last name")}
|
||||
sort_field={@sort_field}
|
||||
sort_order={@sort_order}
|
||||
/>
|
||||
"""
|
||||
}
|
||||
>
|
||||
{member.last_name}
|
||||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
|
|
@ -221,9 +247,9 @@
|
|||
"""
|
||||
}
|
||||
>
|
||||
{member.join_date}
|
||||
{MvWeb.MemberLive.Index.format_date(member.join_date)}
|
||||
</:col>
|
||||
<:col :let={member} label={gettext("Paid")}>
|
||||
<:col :let={member} :if={:paid in @member_fields_visible} label={gettext("Paid")}>
|
||||
<span class={[
|
||||
"badge",
|
||||
if(member.paid == true, do: "badge-success", else: "badge-error")
|
||||
|
|
|
|||
231
lib/mv_web/live/member_live/index/field_selection.ex
Normal file
231
lib/mv_web/live/member_live/index/field_selection.ex
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
defmodule MvWeb.MemberLive.Index.FieldSelection do
|
||||
@moduledoc """
|
||||
Handles user-specific field selection persistence and URL parameter parsing.
|
||||
|
||||
This module manages:
|
||||
- Reading/writing field selection from cookies (persistent storage)
|
||||
- Reading/writing field selection from session (temporary storage)
|
||||
- Parsing field selection from URL parameters
|
||||
- Merging multiple sources with priority: URL > Session > Cookie
|
||||
|
||||
## Data Format
|
||||
|
||||
Field selection is stored as a map:
|
||||
```elixir
|
||||
%{
|
||||
"first_name" => true,
|
||||
"email" => true,
|
||||
"street" => false,
|
||||
"custom_field_abc-123" => true
|
||||
}
|
||||
```
|
||||
|
||||
## Cookie/Session Format
|
||||
|
||||
Stored as JSON string: `{"first_name":true,"email":true}`
|
||||
|
||||
## URL Format
|
||||
|
||||
Comma-separated list: `?fields=first_name,email,custom_field_abc-123`
|
||||
"""
|
||||
|
||||
@cookie_name "member_field_selection"
|
||||
@cookie_max_age 365 * 24 * 60 * 60
|
||||
@session_key "member_field_selection"
|
||||
|
||||
@doc """
|
||||
Reads field selection from session.
|
||||
|
||||
Returns a map of field names (strings) to boolean visibility values.
|
||||
Returns empty map if no selection is stored.
|
||||
"""
|
||||
@spec get_from_session(map()) :: %{String.t() => boolean()}
|
||||
def get_from_session(session) when is_map(session) do
|
||||
case Map.get(session, @session_key) do
|
||||
nil -> %{}
|
||||
json_string when is_binary(json_string) -> parse_json(json_string)
|
||||
_ -> %{}
|
||||
end
|
||||
end
|
||||
|
||||
def get_from_session(_), do: %{}
|
||||
|
||||
@doc """
|
||||
Saves field selection to session.
|
||||
|
||||
Converts the map to JSON string and stores it in the session.
|
||||
"""
|
||||
@spec save_to_session(map(), %{String.t() => boolean()}) :: map()
|
||||
def save_to_session(session, selection) when is_map(selection) do
|
||||
json_string = Jason.encode!(selection)
|
||||
Map.put(session, @session_key, json_string)
|
||||
end
|
||||
|
||||
def save_to_session(session, _), do: session
|
||||
|
||||
@doc """
|
||||
Reads field selection from cookie.
|
||||
|
||||
Returns a map of field names (strings) to boolean visibility values.
|
||||
Returns empty map if no cookie is present.
|
||||
|
||||
Note: This function parses the raw Cookie header. In LiveView, cookies
|
||||
are typically accessed via get_connect_info.
|
||||
"""
|
||||
@spec get_from_cookie(Plug.Conn.t()) :: %{String.t() => boolean()}
|
||||
def get_from_cookie(conn) do
|
||||
# get_req_header always returns a list ([] if no header, [value] if present)
|
||||
case Plug.Conn.get_req_header(conn, "cookie") do
|
||||
[] ->
|
||||
%{}
|
||||
|
||||
[cookie_header | _rest] ->
|
||||
cookies = parse_cookie_header(cookie_header)
|
||||
|
||||
case Map.get(cookies, @cookie_name) do
|
||||
nil -> %{}
|
||||
json_string when is_binary(json_string) -> parse_json(json_string)
|
||||
_ -> %{}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Parses cookie header string into a map
|
||||
defp parse_cookie_header(cookie_header) when is_binary(cookie_header) do
|
||||
cookie_header
|
||||
|> String.split(";")
|
||||
|> Enum.map(&String.trim/1)
|
||||
|> Enum.map(&String.split(&1, "=", parts: 2))
|
||||
|> Enum.reduce(%{}, fn
|
||||
[key, value], acc -> Map.put(acc, key, URI.decode(value))
|
||||
[key], acc -> Map.put(acc, key, "")
|
||||
_, acc -> acc
|
||||
end)
|
||||
end
|
||||
|
||||
defp parse_cookie_header(_), do: %{}
|
||||
|
||||
@doc """
|
||||
Saves field selection to cookie.
|
||||
|
||||
Sets a persistent cookie with the field selection as JSON.
|
||||
"""
|
||||
@spec save_to_cookie(Plug.Conn.t(), %{String.t() => boolean()}) :: Plug.Conn.t()
|
||||
def save_to_cookie(conn, selection) when is_map(selection) do
|
||||
json_string = Jason.encode!(selection)
|
||||
secure = Application.get_env(:mv, :use_secure_cookies, false)
|
||||
|
||||
Plug.Conn.put_resp_cookie(conn, @cookie_name, json_string,
|
||||
max_age: @cookie_max_age,
|
||||
same_site: "Lax",
|
||||
http_only: true,
|
||||
secure: secure
|
||||
)
|
||||
end
|
||||
|
||||
def save_to_cookie(conn, _), do: conn
|
||||
|
||||
@doc """
|
||||
Parses field selection from URL parameters.
|
||||
|
||||
Expects a comma-separated list of field names in the `fields` parameter.
|
||||
All fields in the list are set to `true` (visible).
|
||||
|
||||
## Examples
|
||||
|
||||
iex> parse_from_url(%{"fields" => "first_name,email"})
|
||||
%{"first_name" => true, "email" => true}
|
||||
|
||||
iex> parse_from_url(%{"fields" => "custom_field_abc-123"})
|
||||
%{"custom_field_abc-123" => true}
|
||||
|
||||
iex> parse_from_url(%{})
|
||||
%{}
|
||||
"""
|
||||
@spec parse_from_url(map()) :: %{String.t() => boolean()}
|
||||
def parse_from_url(params) when is_map(params) do
|
||||
case Map.get(params, "fields") do
|
||||
nil -> %{}
|
||||
"" -> %{}
|
||||
fields_string when is_binary(fields_string) -> parse_fields_string(fields_string)
|
||||
_ -> %{}
|
||||
end
|
||||
end
|
||||
|
||||
def parse_from_url(_), do: %{}
|
||||
|
||||
@doc """
|
||||
Merges multiple field selection sources with priority.
|
||||
|
||||
Priority order (highest to lowest):
|
||||
1. URL parameters
|
||||
2. Session
|
||||
3. Cookie
|
||||
|
||||
Later sources override earlier ones for the same field.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> merge_sources(%{"first_name" => true}, %{"email" => true}, %{"street" => true})
|
||||
%{"first_name" => true, "email" => true, "street" => true}
|
||||
|
||||
iex> merge_sources(%{"first_name" => false}, %{"first_name" => true}, %{})
|
||||
%{"first_name" => false} # URL has priority
|
||||
"""
|
||||
@spec merge_sources(
|
||||
%{String.t() => boolean()},
|
||||
%{String.t() => boolean()},
|
||||
%{String.t() => boolean()}
|
||||
) :: %{String.t() => boolean()}
|
||||
def merge_sources(url_selection, session_selection, cookie_selection) do
|
||||
%{}
|
||||
|> Map.merge(cookie_selection)
|
||||
|> Map.merge(session_selection)
|
||||
|> Map.merge(url_selection)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Converts field selection map to URL parameter string.
|
||||
|
||||
Returns a comma-separated string of visible fields (where value is `true`).
|
||||
|
||||
## Examples
|
||||
|
||||
iex> to_url_param(%{"first_name" => true, "email" => true, "street" => false})
|
||||
"first_name,email"
|
||||
"""
|
||||
@spec to_url_param(%{String.t() => boolean()}) :: String.t()
|
||||
def to_url_param(selection) when is_map(selection) do
|
||||
selection
|
||||
|> Enum.filter(fn {_field, visible} -> visible end)
|
||||
|> Enum.map_join(",", fn {field, _visible} -> field end)
|
||||
end
|
||||
|
||||
def to_url_param(_), do: ""
|
||||
|
||||
# Parses a JSON string into a map, handling errors gracefully
|
||||
defp parse_json(json_string) when is_binary(json_string) do
|
||||
case Jason.decode(json_string) do
|
||||
{:ok, decoded} when is_map(decoded) ->
|
||||
# Ensure all values are booleans
|
||||
Enum.reduce(decoded, %{}, fn
|
||||
{key, value}, acc when is_boolean(value) -> Map.put(acc, key, value)
|
||||
{key, _value}, acc -> Map.put(acc, key, true)
|
||||
end)
|
||||
|
||||
_ ->
|
||||
%{}
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_json(_), do: %{}
|
||||
|
||||
# Parses a comma-separated string of field names
|
||||
defp parse_fields_string(fields_string) do
|
||||
fields_string
|
||||
|> String.split(",")
|
||||
|> Enum.map(&String.trim/1)
|
||||
|> Enum.filter(&(&1 != ""))
|
||||
|> Enum.reduce(%{}, fn field, acc -> Map.put(acc, field, true) end)
|
||||
end
|
||||
end
|
||||
239
lib/mv_web/live/member_live/index/field_visibility.ex
Normal file
239
lib/mv_web/live/member_live/index/field_visibility.ex
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
||||
@moduledoc """
|
||||
Manages field visibility by merging user-specific selection with global settings.
|
||||
|
||||
This module handles:
|
||||
- Getting all available fields (member fields + custom fields)
|
||||
- Merging user selection with global settings (user selection takes priority)
|
||||
- Falling back to global settings when no user selection exists
|
||||
- Converting between different field name formats (atoms vs strings)
|
||||
|
||||
## Field Naming Convention
|
||||
|
||||
- **Member Fields**: Atoms (e.g., `:first_name`, `:email`)
|
||||
- **Custom Fields**: Strings with format `"custom_field_<id>"` (e.g., `"custom_field_abc-123"`)
|
||||
|
||||
## Priority Order
|
||||
|
||||
1. User-specific selection (from URL/Session/Cookie)
|
||||
2. Global settings (from database)
|
||||
3. Default (all fields visible)
|
||||
"""
|
||||
|
||||
@doc """
|
||||
Gets all available fields for selection.
|
||||
|
||||
Returns a list of field identifiers:
|
||||
- Member fields as atoms (e.g., `:first_name`, `:email`)
|
||||
- Custom fields as strings (e.g., `"custom_field_abc-123"`)
|
||||
|
||||
## Parameters
|
||||
|
||||
- `custom_fields` - List of CustomField resources that are available
|
||||
|
||||
## Returns
|
||||
|
||||
List of field identifiers (atoms and strings)
|
||||
"""
|
||||
@spec get_all_available_fields([struct()]) :: [atom() | String.t()]
|
||||
def get_all_available_fields(custom_fields) do
|
||||
member_fields = Mv.Constants.member_fields()
|
||||
custom_field_names = Enum.map(custom_fields, &"custom_field_#{&1.id}")
|
||||
|
||||
member_fields ++ custom_field_names
|
||||
end
|
||||
|
||||
@doc """
|
||||
Merges user field selection with global settings.
|
||||
|
||||
User selection takes priority over global settings. If a field is not in the
|
||||
user selection, the global setting is used. If a field is not in global settings,
|
||||
it defaults to `true` (visible).
|
||||
|
||||
## Parameters
|
||||
|
||||
- `user_selection` - Map of field names (strings) to boolean visibility
|
||||
- `global_settings` - Settings struct with `member_field_visibility` field
|
||||
- `custom_fields` - List of CustomField resources
|
||||
|
||||
## Returns
|
||||
|
||||
Map of field names (strings) to boolean visibility values
|
||||
|
||||
## Examples
|
||||
|
||||
iex> user_selection = %{"first_name" => false}
|
||||
iex> settings = %{member_field_visibility: %{first_name: true, email: true}}
|
||||
iex> merge_with_global_settings(user_selection, settings, [])
|
||||
%{"first_name" => false, "email" => true} # User selection overrides global
|
||||
"""
|
||||
@spec merge_with_global_settings(
|
||||
%{String.t() => boolean()},
|
||||
map(),
|
||||
[struct()]
|
||||
) :: %{String.t() => boolean()}
|
||||
def merge_with_global_settings(user_selection, global_settings, custom_fields) do
|
||||
all_fields = get_all_available_fields(custom_fields)
|
||||
global_visibility = get_global_visibility_map(global_settings, custom_fields)
|
||||
|
||||
Enum.reduce(all_fields, %{}, fn field, acc ->
|
||||
field_string = field_to_string(field)
|
||||
|
||||
visibility =
|
||||
case Map.get(user_selection, field_string) do
|
||||
nil -> Map.get(global_visibility, field_string, true)
|
||||
user_value -> user_value
|
||||
end
|
||||
|
||||
Map.put(acc, field_string, visibility)
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the list of visible fields from a field selection map.
|
||||
|
||||
Returns only fields where visibility is `true`.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `field_selection` - Map of field names to boolean visibility
|
||||
|
||||
## Returns
|
||||
|
||||
List of field identifiers (atoms for member fields, strings for custom fields)
|
||||
|
||||
## Examples
|
||||
|
||||
iex> selection = %{"first_name" => true, "email" => false, "street" => true}
|
||||
iex> get_visible_fields(selection)
|
||||
[:first_name, :street]
|
||||
"""
|
||||
@spec get_visible_fields(%{String.t() => boolean()}) :: [atom() | String.t()]
|
||||
def get_visible_fields(field_selection) when is_map(field_selection) do
|
||||
field_selection
|
||||
|> Enum.filter(fn {_field, visible} -> visible end)
|
||||
|> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
|
||||
end
|
||||
|
||||
def get_visible_fields(_), do: []
|
||||
|
||||
@doc """
|
||||
Gets visible member fields from field selection.
|
||||
|
||||
Returns only member fields (atoms) that are visible.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> selection = %{"first_name" => true, "email" => true, "custom_field_123" => true}
|
||||
iex> get_visible_member_fields(selection)
|
||||
[:first_name, :email]
|
||||
"""
|
||||
@spec get_visible_member_fields(%{String.t() => boolean()}) :: [atom()]
|
||||
def get_visible_member_fields(field_selection) when is_map(field_selection) do
|
||||
member_fields = Mv.Constants.member_fields()
|
||||
|
||||
field_selection
|
||||
|> Enum.filter(fn {field_string, visible} ->
|
||||
field_atom = to_field_identifier(field_string)
|
||||
visible && field_atom in member_fields
|
||||
end)
|
||||
|> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
|
||||
end
|
||||
|
||||
def get_visible_member_fields(_), do: []
|
||||
|
||||
@doc """
|
||||
Gets visible custom fields from field selection.
|
||||
|
||||
Returns only custom field identifiers (strings) that are visible.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> selection = %{"first_name" => true, "custom_field_123" => true, "custom_field_456" => false}
|
||||
iex> get_visible_custom_fields(selection)
|
||||
["custom_field_123"]
|
||||
"""
|
||||
@spec get_visible_custom_fields(%{String.t() => boolean()}) :: [String.t()]
|
||||
def get_visible_custom_fields(field_selection) when is_map(field_selection) do
|
||||
prefix = Mv.Constants.custom_field_prefix()
|
||||
|
||||
field_selection
|
||||
|> Enum.filter(fn {field_string, visible} ->
|
||||
visible && String.starts_with?(field_string, prefix)
|
||||
end)
|
||||
|> Enum.map(fn {field_string, _visible} -> field_string end)
|
||||
end
|
||||
|
||||
def get_visible_custom_fields(_), do: []
|
||||
|
||||
# Gets global visibility map from settings
|
||||
defp get_global_visibility_map(settings, custom_fields) do
|
||||
member_visibility = get_member_field_visibility_from_settings(settings)
|
||||
custom_field_visibility = get_custom_field_visibility(custom_fields)
|
||||
|
||||
Map.merge(member_visibility, custom_field_visibility)
|
||||
end
|
||||
|
||||
# Gets member field visibility from settings
|
||||
defp get_member_field_visibility_from_settings(settings) do
|
||||
visibility_config =
|
||||
normalize_visibility_config(Map.get(settings, :member_field_visibility, %{}))
|
||||
|
||||
member_fields = Mv.Constants.member_fields()
|
||||
|
||||
Enum.reduce(member_fields, %{}, fn field, acc ->
|
||||
field_string = Atom.to_string(field)
|
||||
show_in_overview = Map.get(visibility_config, field, true)
|
||||
Map.put(acc, field_string, show_in_overview)
|
||||
end)
|
||||
end
|
||||
|
||||
# Gets custom field visibility (all custom fields with show_in_overview=true are visible)
|
||||
defp get_custom_field_visibility(custom_fields) do
|
||||
prefix = Mv.Constants.custom_field_prefix()
|
||||
|
||||
Enum.reduce(custom_fields, %{}, fn custom_field, acc ->
|
||||
field_string = "#{prefix}#{custom_field.id}"
|
||||
visible = Map.get(custom_field, :show_in_overview, true)
|
||||
Map.put(acc, field_string, visible)
|
||||
end)
|
||||
end
|
||||
|
||||
# Normalizes visibility config map keys from strings to atoms
|
||||
defp normalize_visibility_config(config) when is_map(config) do
|
||||
Enum.reduce(config, %{}, fn
|
||||
{key, value}, acc when is_atom(key) ->
|
||||
Map.put(acc, key, value)
|
||||
|
||||
{key, value}, acc when is_binary(key) ->
|
||||
try do
|
||||
atom_key = String.to_existing_atom(key)
|
||||
Map.put(acc, atom_key, value)
|
||||
rescue
|
||||
ArgumentError -> acc
|
||||
end
|
||||
|
||||
_, acc ->
|
||||
acc
|
||||
end)
|
||||
end
|
||||
|
||||
defp normalize_visibility_config(_), do: %{}
|
||||
|
||||
# Converts field string to atom (for member fields) or keeps as string (for custom fields)
|
||||
defp to_field_identifier(field_string) when is_binary(field_string) do
|
||||
if String.starts_with?(field_string, Mv.Constants.custom_field_prefix()) do
|
||||
field_string
|
||||
else
|
||||
try do
|
||||
String.to_existing_atom(field_string)
|
||||
rescue
|
||||
ArgumentError -> field_string
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Converts field identifier to string
|
||||
defp field_to_string(field) when is_atom(field), do: Atom.to_string(field)
|
||||
defp field_to_string(field) when is_binary(field), do: field
|
||||
end
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -3,19 +3,16 @@ defmodule MvWeb.MemberLive.Show do
|
|||
LiveView for displaying a single member's details.
|
||||
|
||||
## Features
|
||||
- Display all member information (personal, contact, address)
|
||||
- Show linked user account (if exists)
|
||||
- Display custom field values
|
||||
- Display all member information in grouped sections
|
||||
- Tab navigation for future features (Payments)
|
||||
- Show custom field values with type-based formatting
|
||||
- Navigate to edit form
|
||||
- Return to member list
|
||||
|
||||
## Displayed Information
|
||||
- Basic: name, email, dates (join, exit)
|
||||
- Contact: phone number
|
||||
- Address: street, house number, postal code, city
|
||||
- Status: paid flag
|
||||
- Relationships: linked user account
|
||||
- Custom: dynamic custom field values from CustomFields
|
||||
## Sections
|
||||
- Personal Data: Name, address, contact information, membership dates, notes
|
||||
- Custom Fields: Dynamic fields in uniform grid layout (sorted by name)
|
||||
- Payment Data: Mockup section with placeholder data
|
||||
|
||||
## Navigation
|
||||
- Back to member list
|
||||
|
|
@ -28,66 +25,150 @@ defmodule MvWeb.MemberLive.Show do
|
|||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
{@member.first_name} {@member.last_name}
|
||||
<:subtitle>{gettext("This is a member record from your database.")}</:subtitle>
|
||||
<%!-- Header with Back button, Name, and Edit button --%>
|
||||
<div class="flex items-center justify-between gap-4 pb-4">
|
||||
<.button navigate={~p"/members"} aria-label={gettext("Back to members list")}>
|
||||
<.icon name="hero-arrow-left" class="size-4" />
|
||||
{gettext("Back")}
|
||||
</.button>
|
||||
|
||||
<:actions>
|
||||
<.button navigate={~p"/members"} aria-label={gettext("Back to members list")}>
|
||||
<.icon name="hero-arrow-left" />
|
||||
<span class="sr-only">{gettext("Back to members list")}</span>
|
||||
</.button>
|
||||
<.button variant="primary" navigate={~p"/members/#{@member}/edit?return_to=show"}>
|
||||
<.icon name="hero-pencil-square" /> {gettext("Edit Member")}
|
||||
</.button>
|
||||
</:actions>
|
||||
</.header>
|
||||
<h1 class="text-2xl font-bold text-center flex-1">
|
||||
{@member.first_name} {@member.last_name}
|
||||
</h1>
|
||||
|
||||
<.list>
|
||||
<:item title={gettext("Id")}>{@member.id}</:item>
|
||||
<:item title={gettext("First Name")}>{@member.first_name}</:item>
|
||||
<:item title={gettext("Last Name")}>{@member.last_name}</:item>
|
||||
<:item title={gettext("Email")}>{@member.email}</:item>
|
||||
<:item title={gettext("Paid")}>
|
||||
{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("Notes")}>{@member.notes}</:item>
|
||||
<:item title={gettext("City")}>{@member.city}</:item>
|
||||
<:item title={gettext("Street")}>{@member.street}</:item>
|
||||
<:item title={gettext("House Number")}>{@member.house_number}</:item>
|
||||
<:item title={gettext("Postal Code")}>{@member.postal_code}</:item>
|
||||
<:item title={gettext("Linked User")}>
|
||||
<%= if @member.user do %>
|
||||
<.link
|
||||
navigate={~p"/users/#{@member.user}"}
|
||||
class="text-blue-600 hover:text-blue-800 underline"
|
||||
>
|
||||
<.icon name="hero-user" class="h-4 w-4 inline mr-1" />
|
||||
{@member.user.email}
|
||||
</.link>
|
||||
<% else %>
|
||||
<span class="text-gray-500 italic">{gettext("No user linked")}</span>
|
||||
<% end %>
|
||||
</:item>
|
||||
</.list>
|
||||
<.button variant="primary" navigate={~p"/members/#{@member}/edit?return_to=show"}>
|
||||
{gettext("Edit Member")}
|
||||
</.button>
|
||||
</div>
|
||||
|
||||
<h3 class="mt-8 mb-2 text-lg font-semibold">{gettext("Custom Field Values")}</h3>
|
||||
<.generic_list items={
|
||||
Enum.map(@member.custom_field_values, fn cfv ->
|
||||
{
|
||||
# name
|
||||
cfv.custom_field && cfv.custom_field.name,
|
||||
# value
|
||||
case cfv.value do
|
||||
%{value: v} -> v
|
||||
v -> v
|
||||
end
|
||||
}
|
||||
end)
|
||||
} />
|
||||
<%!-- Tab Navigation --%>
|
||||
<div role="tablist" class="tabs tabs-bordered mb-6">
|
||||
<button role="tab" class="tab tab-active" aria-selected="true">
|
||||
<.icon name="hero-identification" class="size-4 mr-2" />
|
||||
{gettext("Contact Data")}
|
||||
</button>
|
||||
<button role="tab" class="tab" disabled aria-disabled="true" title={gettext("Coming soon")}>
|
||||
<.icon name="hero-credit-card" class="size-4 mr-2" />
|
||||
{gettext("Payments")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<%!-- Personal Data and Custom Fields Row --%>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<%!-- Personal Data Section --%>
|
||||
<div>
|
||||
<.section_box title={gettext("Personal Data")}>
|
||||
<div class="space-y-4">
|
||||
<%!-- Name Row --%>
|
||||
<div class="flex gap-6">
|
||||
<.data_field label={gettext("First Name")} value={@member.first_name} class="w-48" />
|
||||
<.data_field label={gettext("Last Name")} value={@member.last_name} class="w-48" />
|
||||
</div>
|
||||
|
||||
<%!-- Address --%>
|
||||
<div>
|
||||
<.data_field label={gettext("Address")} value={format_address(@member)} />
|
||||
</div>
|
||||
|
||||
<%!-- Email --%>
|
||||
<div>
|
||||
<.data_field label={gettext("Email")}>
|
||||
<a
|
||||
href={"mailto:#{MvWeb.MemberLive.Index.format_member_email(@member)}"}
|
||||
class="text-blue-700 hover:text-blue-800 underline"
|
||||
>
|
||||
{@member.email}
|
||||
</a>
|
||||
</.data_field>
|
||||
</div>
|
||||
|
||||
<%!-- Phone --%>
|
||||
<div>
|
||||
<.data_field label={gettext("Phone")} value={@member.phone_number} />
|
||||
</div>
|
||||
|
||||
<%!-- Membership Dates Row --%>
|
||||
<div class="flex gap-6">
|
||||
<.data_field
|
||||
label={gettext("Join Date")}
|
||||
value={format_date(@member.join_date)}
|
||||
class="w-28"
|
||||
/>
|
||||
<.data_field
|
||||
label={gettext("Exit Date")}
|
||||
value={format_date(@member.exit_date)}
|
||||
class="w-28"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<%!-- Linked User --%>
|
||||
<div>
|
||||
<.data_field label={gettext("Linked User")}>
|
||||
<%= if @member.user do %>
|
||||
<.link
|
||||
navigate={~p"/users/#{@member.user}"}
|
||||
class="text-blue-700 hover:text-blue-800 underline inline-flex items-center gap-1"
|
||||
>
|
||||
<.icon name="hero-user" class="size-4" />
|
||||
{@member.user.email}
|
||||
</.link>
|
||||
<% else %>
|
||||
<span class="text-base-content/70 italic">{gettext("No user linked")}</span>
|
||||
<% end %>
|
||||
</.data_field>
|
||||
</div>
|
||||
|
||||
<%!-- Notes --%>
|
||||
<%= if @member.notes && String.trim(@member.notes) != "" do %>
|
||||
<div>
|
||||
<.data_field label={gettext("Notes")}>
|
||||
<p class="whitespace-pre-wrap text-base-content/80">{@member.notes}</p>
|
||||
</.data_field>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</.section_box>
|
||||
</div>
|
||||
|
||||
<%!-- Custom Fields Section --%>
|
||||
<%= if Enum.any?(@member.custom_field_values) do %>
|
||||
<div>
|
||||
<.section_box title={gettext("Custom Fields")}>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<%= for cfv <- sort_custom_field_values(@member.custom_field_values) do %>
|
||||
<% custom_field = cfv.custom_field %>
|
||||
<% value_type = custom_field && custom_field.value_type %>
|
||||
<.data_field label={custom_field && custom_field.name}>
|
||||
{format_custom_field_value(cfv.value, value_type)}
|
||||
</.data_field>
|
||||
<% end %>
|
||||
</div>
|
||||
</.section_box>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%!-- Payment Data Section (Mockup) --%>
|
||||
<div class="max-w-xl">
|
||||
<.section_box title={gettext("Payment Data")}>
|
||||
<div role="alert" class="alert alert-info mb-4">
|
||||
<.icon name="hero-information-circle" class="size-5" />
|
||||
<span>{gettext("This data is for demonstration purposes only (mockup).")}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-6">
|
||||
<.data_field label={gettext("Contribution")} value="72 €" class="w-24" />
|
||||
<.data_field label={gettext("Payment Cycle")} value={gettext("monthly")} class="w-28" />
|
||||
<.data_field label={gettext("Paid")} class="w-24">
|
||||
<%= if @member.paid do %>
|
||||
<span class="badge badge-success">{gettext("Paid")}</span>
|
||||
<% else %>
|
||||
<span class="badge badge-warning">{gettext("Pending")}</span>
|
||||
<% end %>
|
||||
</.data_field>
|
||||
</div>
|
||||
</.section_box>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
|
@ -114,4 +195,120 @@ defmodule MvWeb.MemberLive.Show do
|
|||
|
||||
defp page_title(:show), do: gettext("Show Member")
|
||||
defp page_title(:edit), do: gettext("Edit Member")
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Helper Components
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
# Renders a section box with border and title.
|
||||
attr :title, :string, required: true
|
||||
slot :inner_block, required: true
|
||||
|
||||
defp section_box(assigns) do
|
||||
~H"""
|
||||
<section class="mb-6">
|
||||
<h2 class="text-lg font-semibold mb-3">{@title}</h2>
|
||||
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
</section>
|
||||
"""
|
||||
end
|
||||
|
||||
# Renders a labeled data field.
|
||||
attr :label, :string, required: true
|
||||
attr :value, :string, default: nil
|
||||
attr :class, :string, default: ""
|
||||
slot :inner_block
|
||||
|
||||
defp data_field(assigns) do
|
||||
~H"""
|
||||
<dl class={@class}>
|
||||
<dt class="text-sm font-medium text-base-content/70">{@label}</dt>
|
||||
<dd class="mt-1 text-base-content">
|
||||
<%= if @inner_block != [] do %>
|
||||
{render_slot(@inner_block)}
|
||||
<% else %>
|
||||
{display_value(@value)}
|
||||
<% end %>
|
||||
</dd>
|
||||
</dl>
|
||||
"""
|
||||
end
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Helper Functions
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
defp display_value(nil), do: ""
|
||||
defp display_value(""), do: ""
|
||||
defp display_value(value), do: value
|
||||
|
||||
defp format_address(member) do
|
||||
street_part =
|
||||
[member.street, member.house_number]
|
||||
|> Enum.filter(&(&1 && &1 != ""))
|
||||
|> Enum.join(" ")
|
||||
|
||||
city_part =
|
||||
[member.postal_code, member.city]
|
||||
|> Enum.filter(&(&1 && &1 != ""))
|
||||
|> Enum.join(" ")
|
||||
|
||||
[street_part, city_part]
|
||||
|> Enum.filter(&(&1 != ""))
|
||||
|> Enum.join(", ")
|
||||
|> case do
|
||||
"" -> nil
|
||||
address -> address
|
||||
end
|
||||
end
|
||||
|
||||
defp format_date(nil), do: nil
|
||||
|
||||
defp format_date(%Date{} = date) do
|
||||
Calendar.strftime(date, "%d.%m.%Y")
|
||||
end
|
||||
|
||||
defp format_date(date), do: to_string(date)
|
||||
|
||||
# Sorts custom field values by custom field name
|
||||
defp sort_custom_field_values(custom_field_values) do
|
||||
Enum.sort_by(custom_field_values, fn cfv ->
|
||||
(cfv.custom_field && cfv.custom_field.name) || ""
|
||||
end)
|
||||
end
|
||||
|
||||
# Formats custom field value based on type
|
||||
defp format_custom_field_value(%Ash.Union{value: value, type: type}, _expected_type) do
|
||||
format_custom_field_value(value, type)
|
||||
end
|
||||
|
||||
defp format_custom_field_value(nil, _type), do: "—"
|
||||
|
||||
defp format_custom_field_value(value, :boolean) when is_boolean(value) do
|
||||
if value, do: gettext("Yes"), else: gettext("No")
|
||||
end
|
||||
|
||||
defp format_custom_field_value(%Date{} = date, :date) do
|
||||
Calendar.strftime(date, "%d.%m.%Y")
|
||||
end
|
||||
|
||||
defp format_custom_field_value(value, :email) when is_binary(value) do
|
||||
assigns = %{email: value}
|
||||
|
||||
~H"""
|
||||
<a href={"mailto:#{@email}"} class="text-blue-700 hover:text-blue-800 underline">{@email}</a>
|
||||
"""
|
||||
end
|
||||
|
||||
defp format_custom_field_value(value, :integer) when is_integer(value) do
|
||||
Integer.to_string(value)
|
||||
end
|
||||
|
||||
defp format_custom_field_value(value, _type) when is_binary(value) do
|
||||
if String.trim(value) == "", do: "—", else: value
|
||||
end
|
||||
|
||||
defp format_custom_field_value(value, _type), do: to_string(value)
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -55,12 +55,6 @@ defmodule MvWeb.Router do
|
|||
live "/members/:id", MemberLive.Show, :show
|
||||
live "/members/:id/show/edit", MemberLive.Show, :edit
|
||||
|
||||
live "/custom_fields", CustomFieldLive.Index, :index
|
||||
live "/custom_fields/new", CustomFieldLive.Form, :new
|
||||
live "/custom_fields/:id/edit", CustomFieldLive.Form, :edit
|
||||
live "/custom_fields/:id", CustomFieldLive.Show, :show
|
||||
live "/custom_fields/:id/show/edit", CustomFieldLive.Show, :edit
|
||||
|
||||
live "/custom_field_values", CustomFieldValueLive.Index, :index
|
||||
live "/custom_field_values/new", CustomFieldValueLive.Form, :new
|
||||
live "/custom_field_values/:id/edit", CustomFieldValueLive.Form, :edit
|
||||
|
|
@ -75,6 +69,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