diff --git a/.drone.yml b/.drone.yml
index 335fe3b..dc2aaae 100644
--- a/.drone.yml
+++ b/.drone.yml
@@ -273,7 +273,7 @@ environment:
steps:
- name: renovate
- image: renovate/renovate:43.39
+ image: renovate/renovate:43.35
environment:
RENOVATE_CONFIG_FILE: "renovate_backend_config.js"
RENOVATE_TOKEN:
diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md
index d4769f3..50c9eca 100644
--- a/CODE_GUIDELINES.md
+++ b/CODE_GUIDELINES.md
@@ -60,9 +60,6 @@ We are building a membership management system (Mila) using the following techno
7. [Documentation Standards](#7-documentation-standards)
8. [Accessibility Guidelines](#8-accessibility-guidelines)
-**Related documents:**
-- **UI / UX:** [`DESIGN_DUIDELINES.md`](../DESIGN_DUIDELINES.md) defines visual and interaction consistency: use of CoreComponents (no raw DaisyUI in views), page skeleton (`<.header>`, `mt-6 space-y-6`), **Back button left in header for edit/new forms** (§2.2), typography, buttons, forms, tables, flash/toast, and microcopy (e.g. German "du" and glossary). Follow "components first" and semantic variants instead of hard-coded colors.
-
---
## 1. Setup and Architectural Conventions
diff --git a/DESIGN_DUIDELINES.md b/DESIGN_DUIDELINES.md
deleted file mode 100644
index fc3acac..0000000
--- a/DESIGN_DUIDELINES.md
+++ /dev/null
@@ -1,426 +0,0 @@
-# UI Design Guidelines (Mila / Phoenix LiveView + DaisyUI)
-
-## Purpose
-This document defines Mila’s **UI system** to ensure **UX consistency**, **accessibility**, and **maintainability** across Phoenix LiveView pages:
-
-- consistent DaisyUI usage
-- typography & spacing
-- button intent & labeling
-- list/search/filter UX
-- tables behavior (row click, tooltips, alignment)
-- flash/toast UX (position, stacking, auto-dismiss, tones)
-- standard page skeletons (index/detail/form)
-- microcopy conventions (German “du” tone)
-
-> Engineering practices (LiveView load budget, testing, security, etc.) are defined in `docs/CODE_GUIDELINES.md`.
-> This document focuses on **visual + UX** consistency and references engineering rules where needed.
-
----
-
-## 1) Principles
-
-### 1.1 Components first (no raw DaisyUI classes in views)
-- **MUST:** Use `MvWeb.CoreComponents` (e.g. `<.button>`, `<.header>`, `<.table>`, `<.input>`, `<.flash_group>`, `<.form_section>`).
-- **MUST NOT:** Write DaisyUI component classes directly in LiveViews/HEEX (e.g. `btn`, `alert`, `table`, `input`, `select`, `tooltip`) unless you are implementing them **inside** CoreComponents.
-- **MAY:** Use Tailwind for layout only: `flex`, `grid`, `gap-*`, `p-*`, `max-w-*`, `sm:*`, etc.
-
-### 1.2 DaisyUI for look, Tailwind for layout
-- DaisyUI: component visuals + semantic variants (`btn-primary`, `alert-error`, `badge`, `tooltip`).
-- Tailwind: spacing, alignment, responsiveness.
-
-### 1.3 Semantics over hard-coded colors
-- **MUST NOT:** Use “status colors” in views (`bg-green-500`, `text-blue-500`, …).
-- **MUST:** Express intent via component props / DaisyUI semantic variants.
-
----
-
-## 2) Page Skeleton & “Chrome” (mandatory)
-
-### 2.1 Standard page layout
-Every authenticated page should follow the same structure:
-
-1) `<.header>` (title + optional subtitle + actions)
-2) content area with consistent vertical rhythm (`mt-6 space-y-6`)
-3) optional footer actions for forms
-
-**MUST:** Use `<.header>` on every page (except login/public pages).
-**SHOULD:** Put short explanations into `<:subtitle>` rather than sprinkling random text blocks.
-
-### 2.2 Edit/New form header: Back button left (mandatory)
-
-For LiveViews that render an edit or new form (e.g. member, group, role, user, custom field, membership fee type):
-
-- **MUST:** Provide a **Back** button on the **left** side of the header using the `<:leading>` slot (same as data fields: Back left, title next, primary action on the right).
-- **MUST:** Use the same pattern everywhere: Back button with `variant="neutral"`, arrow-left icon, and label “Back”. It navigates to the previous context (e.g. detail page or index) via a `return_path`-style helper.
-- **SHOULD:** Place the primary action (e.g. “Save”) in `<:actions>` on the right.
-- **Rationale:** Users expect a consistent way to leave the form without submitting; Back left matches the data fields edit view and keeps primary actions on the right.
-
-**Template for form pages:**
-```heex
-<.header>
- <:leading>
- <.button navigate={return_path(@return_to, @resource)} variant="neutral">
- <.icon name="hero-arrow-left" class="size-4" />
- {gettext("Back")}
-
-
- Page title (e.g. “Edit Member” or “New User”)
- <:subtitle>Short explanation.
- <:actions>
- <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
- {gettext("Save")}
-
-
-
-```
-
-If the `<.header>` is outside the `<.form>`, the submit button must reference the form via the `form` attribute (e.g. `form="user-form"`).
-
-## 3) Typography (system)
-
-Use these standard roles:
-
-| Role | Use | Class |
-|---|---|---|
-| Page title (H1) | main page title | `text-xl font-semibold leading-8` |
-| Subtitle | helper under title | `text-sm text-base-content/70` |
-| Section title (H2) | section headings | `text-lg font-semibold` |
-| Helper text | under inputs | `text-sm text-base-content/70` |
-| Fine print | small hints | `text-xs text-base-content/60` |
-| Empty state | no data | `text-base-content/60 italic` |
-| Destructive text | danger | `text-error` |
-
-**MUST:** Page titles via `<.header>`.
-**MUST:** Section titles via `<.form_section title="…">` (for forms) or a consistent section wrapper (if you introduce a `<.card>` later).
-
----
-
-## 4) States: Loading, Empty, Error (mandatory consistency)
-
-### 4.1 Loading state
-- **MUST:** Show a consistent loading indicator when data is not ready.
-- **MUST NOT:** Render empty states while loading (avoid flicker).
-- **SHOULD:** Prefer “skeleton rows” for tables or a spinner in content area.
-
-### 4.2 Empty state pattern
-Empty states must be consistent:
-- short message
-- optional primary CTA (“Create …”)
-- optional secondary help link
-
-**Example:**
-```heex
-
-
No members yet.
- <.button variant="primary" navigate={~p"/members/new"}>Create member
-
-
-### 4.3 Error state pattern
-- **MUST:** Use flash/toast for global errors.
-- **SHOULD:** Also show inline error state near the relevant content area if the page cannot proceed.
-
----
-
-## 5) Buttons (intent, labels, variants)
-
-### 5.1 Decision rule: action vs status
-- **MUST:** Button labels describe **actions** (verb-first):
- - ✅ Save, Create member, Send invite, Import CSV
- - ❌ Active, Success, Done (status belongs elsewhere)
-- **MUST:** Status belongs in badges/labels or read-only text, not in CTAs.
-
-### 5.2 Standard variants (mandatory set)
-Buttons must be rendered via `<.button>` and mapped to DaisyUI internally.
-
-**Supported variants:**
-- `primary` (main CTA)
-- `secondary` (supporting)
-- `neutral` (cancel/back)
-- `ghost` (low emphasis; table/toolbars)
-- `outline` (alternative CTA)
-- `danger` (destructive)
-- `link` (inline; rare)
-- `icon` (icon-only)
-
-**Sizes:** `sm`, `md` (default), `lg` (rare)
-
-### 5.3 Placement rules
-- Header CTA inside `<.header><:actions>`.
-- Form footer: primary right; cancel/secondary left.
-- Tables: use `ghost`/`icon` for row actions (avoid `primary` inside rows).
-
-### 5.4 Primary vs Secondary (UX consistency rules)
-
-#### One primary action per screen
-- MUST: Each screen/section has at most one **primary** action (e.g. Save, Create, Start import).
-- SHOULD: Additional actions are secondary/neutral/ghost, not additional primary.
-
-#### Primary vs Secondary meaning
-- Primary = the most important/most common action to complete the user task.
-- Secondary = supporting actions (Cancel/Back/Edit in tool contexts), lower emphasis.
-
-#### Order and placement (choose and apply consistently)
-We follow these ordering rules:
-- MUST: Order buttons by priority: **Primary → Secondary → Tertiary**.
-- Forms: Decide once (primary-left OR primary-right) and apply everywhere.
-- Dialogs/confirmations: Place the confirmation action consistently (e.g. trailing edge, confirmation closest to edge).
-
-#### Cancel/Back consistency
-- MUST: Cancel/Back is **never** styled as primary.
-- MUST: Cancel/Back placement is consistent across the app (same side, same label).
-
-#### Implementation requirement
-- MUST: Use CoreComponents (`<.button>`) with `variant`/`size` props.
-- MUST NOT: Use ad-hoc classes like `class="secondary"` on `<.button>`; instead extend CoreComponents to support `secondary`, `neutral`, `ghost`, `danger`, etc.
-
-#### Ghost buttons (accessibility requirements)
-
-Ghost buttons are allowed for low-emphasis actions (toolbars, table actions), but:
-
-- MUST: Focus indicator is clearly visible (do not remove outlines).
-- MUST: UI contrast for the control (and meaningful icons) meets WCAG non-text contrast (≥ 3:1).
-- MUST: Icon-only ghost buttons provide an accessible name (`aria-label`) and preferably a tooltip.
-- SHOULD: Hit target is large enough for touch/motor accessibility (recommend ~44x44px).
-If these cannot be met, use `secondary`/`outline` instead of `ghost`.
-
-
----
-
-## 6) Forms (structure + interaction rules)
-
-### 6.1 Structure
-- **MUST:** Forms are grouped into `<.form_section title="…">`.
-- **MUST:** All inputs via `<.input>`.
-
-### 6.2 Validation timing (consistent UX)
-- **MUST:** Validate on submit always.
-- **SHOULD:** Validate on change only where it helps; use debounce to avoid “error spam”.
-- **MUST:** Define a consistent “when errors appear” rule:
- - Preferred: show field errors after first submit attempt OR after the field has been touched (pick one and apply everywhere).
-
-> Engineering note (implementation): follow LiveView load budget in `CODE_GUIDELINES.md` (no DB reads on `phx-change` by default).
-
-### 6.3 Required fields
-- **MUST:** Required fields are marked consistently (UI indicator + accessible text).
-- **SHOULD:** If required-ness is configurable via settings, display it consistently in the form.
-
----
-
-## 7) Lists, Search & Filters (mandatory UX consistency)
-
-### 7.1 Standard filter/search bar pattern
-- **MUST:** All list pages use the same search/filter placement (choose one layout and apply everywhere).
- - Recommended: top area above the table, aligned with page actions.
-- **MUST:** Always provide “Clear filters” when filters are active.
-- **MUST:** Filter state is reflected in URL params (so reload/back/share works consistently).
-
-### 7.2 URL behavior (UX rule)
-- Use `push_patch` for in-page state changes: filters, sorting, pagination, tabs.
-- Use `push_navigate` for actual page transitions: details, edit, new.
-
----
-
-## 8) Tables (mandatory UX)
-
-### 8.1 Default behavior: row click opens details
-- **DEFAULT:** Clicking a row navigates to the details page.
-- **EXCEPTIONS:** Highly interactive rows may disable row-click (document why).
-- **Row outline (CoreComponents):** When `row_click` is set, rows get a subtle hover and focus-within ring (theme-friendly). Use `selected_row_id` to show a stronger selected outline (e.g. from URL `?highlight=id` or last selection); the Back link from detail can use `?highlight=id` so the row is visually selected when returning to the index.
-
-**IMPORTANT (correctness with our `<.table>` CoreComponent):**
-Our table implementation attaches the `phx-click` to the **``** when `row_click` is set. That means click events bubble from inner elements up to the cell unless we stop propagation.
-
-So, for interactive elements inside a clickable row, you must **stop propagation using `Phoenix.LiveView.JS.stop_propagation/1`**, not a custom attribute.
-
-✅ Correct pattern (one click handler that both stops propagation and triggers an event):
-```heex
-<.table
- id="members"
- rows={@members}
- row_click={fn m -> JS.navigate(~p"/members/#{m.id}") end}
->
- <:col :let={m} label="Name">
- <%= m.last_name %>, <%= m.first_name %>
-
-
- <:col :let={m} label="Newsletter">
- JS.stop_propagation()}
- />
-
-
- <:action :let={m}>
- <.button
- variant="ghost"
- size="sm"
- navigate={~p"/members/#{m.id}/edit"}
- phx-click={JS.stop_propagation()}
- >
- Edit
-
-
-
-
-Notes:
-- The checkbox uses `phx-click={JS.push(...) |> JS.stop_propagation()}` so it won’t trigger row navigation.
-- The Edit button also stops propagation to avoid accidental row navigation when clicked.
-
-### 8.2 Tooltips (mandatory where needed)
-- **MUST:** Tooltips for:
- - icon-only actions
- - truncated content
- - status badges that require explanation
-- **MUST:** Provide tooltips via a shared wrapper (recommended `<.tooltip>` CoreComponent).
-- **MUST NOT:** Scatter ad-hoc tooltip markup in views.
-
-### 8.3 Alignment & density conventions
-- **MUST:** Text columns left-aligned.
-- **MUST:** Numeric columns right-aligned.
-- **MUST:** Action column right-aligned.
-- **SHOULD:** Table density is consistent:
- - default density for most tables
- - a single “dense” option only if needed (via a prop, not per-page random classes)
-
-### 8.4 Truncation standard
-- **MUST:** Truncate long values consistently (same max widths for name/email-like fields).
-- **MUST:** Tooltip reveals full value when truncated.
-
-### 8.5 Loading/Lists/Tables: keep filters visible on desktop
-- On **desktop (lg: breakpoint)** only: list pages with large datasets (e.g. Members overview) keep the page header and filter/search bar visible while the user scrolls. Only the table body scrolls inside a constrained area (`lg:max-h-[calc(100vh-)] lg:overflow-auto`). This preserves context and avoids losing filters when scrolling.
-- On **mobile**, sticky headers are not used; the layout uses normal flow (header and table scroll with the page) to preserve vertical space.
-- When the table is inside such a scroll container, use the CoreComponents table’s `sticky_header={true}` so the table’s `` stays sticky within the scroll area on desktop (`lg:sticky lg:top-0`, opaque background `bg-base-100`, z-index). Sticky areas must not overlap content at 200% zoom; focus order must remain header → filters → table.
-
----
-
-## 9) Flash / Toast messages (mandatory UX)
-
-### 9.1 Location + stacking
-- **MUST:** Position flash/toasts at the bottom of the viewport (pick bottom-right or bottom-center; be consistent).
-- **MUST:** Stack all flash messages with consistent spacing.
-- **SHOULD:** Newest appears on top.
-
-### 9.2 Auto-dismiss
-- **MUST:** Flash messages disappear automatically:
- - info/success: 4–6s
- - warning: 6–8s
- - error: 8–12s (or manual dismiss for critical errors)
-- **MUST:** Keep a dismiss button for accessibility and user control.
-- **Status:** Not yet implemented. See [feature-roadmap](docs/feature-roadmap.md) → Flash: Auto-dismiss and consistency.
-
-### 9.3 Variants (unified)
-- Supported semantic variants: `info`, `success`, `warning`, `error`.
-- **MUST:** Use the same variants for all flash types, : e.g. `success` for copy success, no separate tone or styling. This keeps flash UX consistent across the app.
-
-### 9.4 Accessibility
-- Flash must work with screen readers (live region behavior belongs in the flash component implementation).
-- See `CODE_GUIDELINES.md` Accessibility → live regions.
-
----
-
-## 10) Mutations & feedback patterns (create/update/delete/import)
-
-### 10.1 Mutation feedback is always two-part
-For create/update/delete:
-- **MUST:** Show a toast/flash message
-- **MUST:** Show a visible UI update (navigate, row removed, values updated)
-
-No “silent success”.
-
-### 10.2 Destructive actions: one standard confirmation pattern
-- **MUST:** All destructive actions use the same confirm style and wording conventions.
-- Choose one approach and standardize:
- - `JS.confirm("…")` everywhere (simple, consistent)
- - or a modal component everywhere (more flexible, more work)
-
-**Recommended copy style:**
-- Title/confirm text is clear and specific (what will be deleted, consequences).
-- Buttons: `Cancel` (neutral) + `Delete` (danger).
-
----
-
-## 11) Detail pages (consistent structure)
-
-Detail pages should not drift into random layouts.
-
-**MUST:** Use consistent structure:
-- header with primary action (Edit)
-- sections/cards for grouped info
-- “Danger zone” section at bottom for destructive actions
-
----
-
-## 12) Navigation rules (UX consistency)
-
-- **MUST:** `push_patch` for in-page state: sorting, filtering, pagination, tabs.
-- **MUST:** `push_navigate` for page transitions: detail/edit/new.
-- **SHOULD:** Back button behavior must feel predictable (URL reflects state).
-
----
-
-## 13) Microcopy conventions (German “du” tone + glossary)
-
-### 13.1 Tone
-- **MUST:** All German user-facing text uses informal address (“du”).
-- **MUST:** Use consistent verbs for common actions:
- - Save: “Speichern”
- - Cancel: “Abbrechen”
- - Delete: “Löschen”
- - Edit: “Bearbeiten”
-
-### 13.2 Preferred terms (starter glossary)
-- Member: “Mitglied”
-- Fee/Contribution: “Beitrag”
-- Settings: “Einstellungen”
-- Group: “Gruppe”
-- Import/Export: “Import/Export”
-- Clear filters: “Filter zurücksetzen” (use when filters are active; button label in list/filter UX)
-
-Add to this glossary when new terminology appears.
-
----
-
-## 14) Destructive actions: Delete flow (canonical)
-
-This section defines the canonical delete flow for list/detail/form resources (e.g. members). Use it as the single pattern; do not introduce a second pattern elsewhere.
-
-### Tables: no row action buttons
-- **MUST NOT:** Show Edit or Delete as row action buttons (or dropdown actions) in list/table views.
-- **MUST:** Remove any existing edit/delete row actions from tables so that the only way to edit or delete is via the flow below.
-
-### Navigation: row click → details
-- **MUST:** Clicking a table row navigates to the resource details page (e.g. `/members/:id`).
-- **MUST NOT:** Use the table for primary edit/delete actions.
-
-### Edit: from details header, not from table
-- **MUST:** Provide a clear primary “Edit” CTA in the details page header (e.g. “Edit member”).
-- **MUST:** Edit is reached from the details page (e.g. “Edit member” button in header), not from the list/table.
-
-### Delete: only via “Danger zone”
-- **MUST:** Delete is available only in a dedicated “Danger zone” section at the bottom of the page.
-- **MUST:** Use the same “Danger zone” on both the details page and the edit form when the user is authorized to destroy the resource.
-- **MUST NOT:** Place delete in the table, in the header next to Edit, or in any other location outside the Danger zone.
-
-### Danger zone layout and wording (canonical pattern)
-- **Heading:** “Danger zone” (H2, `aria-labelledby` for the section, semantic colour e.g. `text-error`).
-- **Explanatory text:** One short paragraph stating that the action cannot be undone and mentioning consequences (e.g. related data removed). Use `text-base-content/70` for the text.
-- **Layout:** Section with heading outside a bordered box; content inside a single bordered, rounded box (`border border-base-300 rounded-lg p-4 bg-base-100`).
-- **Button:** One destructive action only (e.g. “Delete member”). Use CoreComponents `<.button variant="danger">`. No primary or secondary actions mixed inside the Danger zone.
-
-### Confirmation and button semantics
-- **MUST:** Use a single confirmation step (e.g. `data-confirm` / browser confirm or one modal). Do not introduce a second confirmation pattern in this flow.
-- **Confirm copy:** Message must include the resource name and state that the action “cannot be undone” (e.g. “Are you sure you want to delete %{name}? This action cannot be undone.”).
-- **Button:** Accessible label (visible text + `aria-label` that includes the resource name, e.g. “Delete member %{name}”). Icon (e.g. trash) is optional and must not replace the text label for the primary action.
-
-### Accessibility
-- **MUST:** Button has an accessible name (`aria-label` when icon-only or in addition to visible text as above).
-- **MUST:** Focus and keyboard: button is focusable and activatable via keyboard; focus management must not trap the user.
-- **MUST:** Contrast and visibility: Danger zone heading and button use semantic danger styling with sufficient contrast (WCAG AA).
-
-### Authorization visibility
-- **MUST:** Show the Danger zone only when the current user is authorized to destroy the resource (e.g. `can?(current_user, :destroy, resource)`).
-- **MUST NOT:** Show the Danger zone or the delete button when the user cannot destroy the resource; no “disabled” delete button for unauthorized users.
-
----
diff --git a/docs/feature-roadmap.md b/docs/feature-roadmap.md
index 66b46eb..b699560 100644
--- a/docs/feature-roadmap.md
+++ b/docs/feature-roadmap.md
@@ -191,11 +191,6 @@
- ❌ Mobile navigation
- ❌ Context-sensitive help
- ❌ Onboarding tooltips
-- ❌ **Flash: Auto-dismiss and consistency** (Design Guidelines §9)
- - Auto-dismiss: info/success 4–6s, warning 6–8s, error 8–12s; dismiss button kept for accessibility.
- - Implement via JS hook (e.g. `FlashAutoDismiss`) + `data-dismiss-ms` (or `data-kind`) on flash component; on timeout push `lv:clear-flash` and hide element.
- - LiveView: add shared `handle_event("lv:clear-flash", %{"key" => key}, socket)` (e.g. in `MvWeb` live_view quote) calling `clear_flash(socket, key)`.
- - All flashes (including “Email copied”) use the same variants (info, success, warning, error); no special tone. See `DESIGN_DUIDELINES.md` §9.
---
diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex
index 85c26c7..21e3546 100644
--- a/lib/mv_web/components/core_components.ex
+++ b/lib/mv_web/components/core_components.ex
@@ -60,15 +60,15 @@ defmodule MvWeb.CoreComponents do
id={@id}
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
role="alert"
- class="pointer-events-auto"
+ class="z-50 toast toast-top toast-end"
{@rest}
>
<.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" />
<.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
@@ -90,71 +90,33 @@ defmodule MvWeb.CoreComponents do
@doc """
Renders a button with navigation support.
- ## Variants (Design Guidelines §5.2)
- - primary (main CTA)
- - secondary (supporting)
- - neutral (cancel/back)
- - ghost (low emphasis; table/toolbars)
- - outline (alternative CTA)
- - danger (destructive)
- - link (inline; rare)
- - icon (icon-only)
-
- ## Sizes
- - sm, md (default), lg
-
## Examples
<.button>Send!
<.button phx-click="go" variant="primary">Send!
- <.button navigate={~p"/"} variant="secondary">Home
- <.button variant="ghost" size="sm">Edit
+ <.button navigate={~p"/"}>Home
<.button disabled={true}>Disabled
"""
- attr :rest, :global, include: ~w(href navigate patch method data-testid form)
-
- attr :variant, :string,
- values: ~w(primary secondary neutral ghost outline danger link icon),
- default: "primary"
-
- attr :size, :string, values: ~w(sm md lg), default: "md"
+ attr :rest, :global, include: ~w(href navigate patch method data-testid)
+ attr :variant, :string, values: ~w(primary)
attr :disabled, :boolean, default: false, doc: "Whether the button is disabled"
slot :inner_block, required: true
def button(assigns) do
rest = assigns.rest
- variant = assigns[:variant] || "primary"
- size = assigns[:size] || "md"
-
- variant_classes = %{
- "primary" => "btn-primary",
- "secondary" => "btn-secondary",
- "neutral" => "btn-neutral",
- "ghost" => "btn-ghost",
- "outline" => "btn-outline",
- "danger" => "btn-error",
- "link" => "btn-link",
- "icon" => "btn-ghost btn-square"
- }
-
- size_classes = %{
- "sm" => "btn-sm",
- "md" => "",
- "lg" => "btn-lg"
- }
-
- base_class = Map.fetch!(variant_classes, variant)
- size_class = size_classes[size]
- btn_class = [base_class, size_class] |> Enum.reject(&(&1 == "")) |> Enum.join(" ")
-
- assigns = assign(assigns, :btn_class, btn_class)
+ variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"}
+ assigns = assign(assigns, :class, Map.fetch!(variants, assigns[:variant]))
if rest[:href] || rest[:navigate] || rest[:patch] do
+ # For links, we can't use disabled attribute, so we use btn-disabled class
+ # DaisyUI's btn-disabled provides the same styling as :disabled on buttons
link_class =
if assigns[:disabled],
- do: ["btn", btn_class, "btn-disabled"],
- else: ["btn", btn_class]
+ do: ["btn", assigns.class, "btn-disabled"],
+ else: ["btn", assigns.class]
+ # Prevent interaction when disabled
+ # Remove navigation attributes to prevent "Open in new tab", "Copy link" etc.
link_attrs =
if assigns[:disabled] do
rest
@@ -176,49 +138,13 @@ defmodule MvWeb.CoreComponents do
"""
else
~H"""
-
+
{render_slot(@inner_block)}
"""
end
end
- @doc """
- Wraps content with a DaisyUI tooltip. Use for icon-only actions, truncated content,
- or status badges that need explanation (Design Guidelines §8.2).
-
- ## Examples
-
- <.tooltip content={gettext("Edit")}>
- <.button variant="icon" size="sm"><.icon name="hero-pencil" />
-
-
- <.tooltip content={@full_name} position="top">
- {@full_name}
-
- """
- attr :content, :string, required: true, doc: "Tooltip text (data-tip)"
-
- attr :position, :string,
- values: ~w(top bottom left right),
- default: "bottom"
-
- attr :wrap_class, :string, default: nil, doc: "Additional classes for the wrapper"
- slot :inner_block, required: true
-
- def tooltip(assigns) do
- position_class = "tooltip tooltip-#{assigns.position}"
- wrap_class = [position_class, assigns.wrap_class] |> Enum.reject(&is_nil/1) |> Enum.join(" ")
-
- assigns = assign(assigns, :wrap_class, wrap_class)
-
- ~H"""
-
- {render_slot(@inner_block)}
-
- """
- end
-
@doc """
Renders a dropdown menu.
@@ -511,7 +437,7 @@ defmodule MvWeb.CoreComponents do
{@rest}
/>{@label}*
@@ -530,7 +456,7 @@ defmodule MvWeb.CoreComponents do
{@label}*
@@ -559,7 +485,7 @@ defmodule MvWeb.CoreComponents do
{@label}*
@@ -588,7 +514,7 @@ defmodule MvWeb.CoreComponents do
{@label}*
@@ -633,24 +559,17 @@ defmodule MvWeb.CoreComponents do
@doc """
Renders a header with title.
-
- Use the `:leading` slot for the Back button (left side, consistent with data fields).
- Use the `:actions` slot for primary actions (e.g. Save) on the right.
"""
attr :class, :string, default: nil
- slot :leading, doc: "Content on the left (e.g. Back button)"
slot :inner_block, required: true
slot :subtitle
slot :actions
def header(assigns) do
~H"""
-
-
- {render_slot(@leading)}
-
-
+
+
{render_slot(@inner_block)}
@@ -658,9 +577,7 @@ defmodule MvWeb.CoreComponents do
{render_slot(@subtitle)}
-
- {render_slot(@actions)}
-
+ {render_slot(@actions)}
"""
end
@@ -668,51 +585,18 @@ defmodule MvWeb.CoreComponents do
@doc ~S"""
Renders a table with generic styling.
- When `row_click` is set, clicking a row (or a data cell) triggers the handler.
- Rows with `row_click` get a subtle hover and focus-within outline (theme-friendly ring).
- When `selected_row_id` is set and matches a row's id (via `row_value_id` or `row_item.(row).id`),
- that row gets a stronger selected outline (ring-primary) for accessibility (not color-only).
-
- The action column has no phx-click on its `
`, so action buttons do not trigger row navigation.
- For interactive elements inside other columns (e.g. checkboxes, buttons), use
- `Phoenix.LiveView.JS.stop_propagation()` in the element's phx-click so the row click is not fired.
-
## Examples
<.table id="users" rows={@users}>
<:col :let={user} label="id">{user.id}
<:col :let={user} label="username">{user.username}
-
- <.table id="members" rows={@members} row_click={fn m -> JS.navigate(~p"/members/#{m}") end} selected_row_id={@selected_member_id}>
- <:col :let={m} label="Name">{m.name}
-
"""
attr :id, :string, required: true
attr :rows, :list, required: true
attr :row_id, :any, default: nil, doc: "the function for generating the row id"
attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"
- attr :selected_row_id, :any,
- default: nil,
- doc:
- "when set, the row whose id equals this value gets selected styling (single row, e.g. from URL)"
-
- attr :row_selected?, :any,
- default: nil,
- doc:
- "optional; function (row_item) -> boolean to mark multiple rows as selected (e.g. checkbox selection); overrides selected_row_id when set"
-
- attr :row_tooltip, :string,
- default: nil,
- doc:
- "optional; when row_click is set, tooltip text for the row (e.g. gettext(\"Click to view\")). Shown as title on hover and as sr-only for screen readers."
-
- attr :row_value_id, :any,
- default: nil,
- doc:
- "optional; function (row) -> id for comparing with selected_row_id; defaults to row_item.(row).id"
-
attr :row_item, :any,
default: &Function.identity/1,
doc: "the function for mapping each row before calling the :col and :action slots"
@@ -724,11 +608,6 @@ defmodule MvWeb.CoreComponents do
attr :sort_field, :any, default: nil, doc: "current sort field"
attr :sort_order, :atom, default: nil, doc: "current sort order"
- attr :sticky_header, :boolean,
- default: false,
- doc:
- "when true, thead th get lg:sticky lg:top-0 bg-base-100 z-10 for use inside a scroll container on desktop"
-
slot :col, required: true do
attr :label, :string
attr :class, :string
@@ -746,12 +625,6 @@ defmodule MvWeb.CoreComponents do
assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
end
- # Function to get the row's value id for selected_row_id comparison (no extra DB reads)
- row_value_id_fn =
- assigns[:row_value_id] || fn row -> assigns.row_item.(row).id end
-
- assigns = assign(assigns, :row_value_id_fn, row_value_id_fn)
-
~H"""
@@ -759,12 +632,12 @@ defmodule MvWeb.CoreComponents do
{col[:label]}
-
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:"sort_custom_field_#{dyn_col[:custom_field].id}"}
@@ -774,21 +647,15 @@ defmodule MvWeb.CoreComponents do
sort_order={@sort_order}
/>
-
{gettext("Actions")}
-
+
- <%= if col_idx == 0 && @row_click && @row_tooltip do %>
- {@row_tooltip}
- <% end %>
{render_slot(col, @row_item.(row))}
Enum.filter(& &1) |> Enum.join(" ")
- end
-
- defp table_th_sticky_class(true),
- do: "lg:sticky lg:top-0 bg-base-100 z-10"
-
- defp table_th_sticky_class(_), do: nil
-
@doc """
Renders a data list.
diff --git a/lib/mv_web/components/layouts.ex b/lib/mv_web/components/layouts.ex
index 79983c5..89e3549 100644
--- a/lib/mv_web/components/layouts.ex
+++ b/lib/mv_web/components/layouts.ex
@@ -115,11 +115,7 @@ defmodule MvWeb.Layouts do
def flash_group(assigns) do
~H"""
-
+
<.flash kind={:success} flash={@flash} />
<.flash kind={:warning} flash={@flash} />
<.flash kind={:info} flash={@flash} />
diff --git a/lib/mv_web/components/layouts/root.html.heex b/lib/mv_web/components/layouts/root.html.heex
index e107d5b..a47fcc7 100644
--- a/lib/mv_web/components/layouts/root.html.heex
+++ b/lib/mv_web/components/layouts/root.html.heex
@@ -72,7 +72,7 @@
<.flash id="flash-success-root" kind={:success} flash={@flash} />
<.flash id="flash-warning-root" kind={:warning} flash={@flash} />
diff --git a/lib/mv_web/controllers/auth_controller.ex b/lib/mv_web/controllers/auth_controller.ex
index 20a76f5..28f3846 100644
--- a/lib/mv_web/controllers/auth_controller.ex
+++ b/lib/mv_web/controllers/auth_controller.ex
@@ -31,7 +31,7 @@ defmodule MvWeb.AuthController do
|> store_in_session(user)
# If your resource has a different name, update the assign name here (i.e :current_admin)
|> assign(:current_user, user)
- |> put_flash(:success, message)
+ |> put_flash(:info, message)
|> redirect(to: return_to)
end
@@ -322,7 +322,7 @@ defmodule MvWeb.AuthController do
conn
|> clear_session(:mv)
- |> put_flash(:success, gettext("You are now signed out"))
+ |> put_flash(:info, gettext("You are now signed out"))
|> redirect(to: return_to)
end
end
diff --git a/lib/mv_web/live/auth/link_oidc_account_live.ex b/lib/mv_web/live/auth/link_oidc_account_live.ex
index 01bd57b..b6c24b1 100644
--- a/lib/mv_web/live/auth/link_oidc_account_live.ex
+++ b/lib/mv_web/live/auth/link_oidc_account_live.ex
@@ -81,7 +81,7 @@ defmodule MvWeb.LinkOidcAccountLive do
socket
|> put_flash(
- :success,
+ :info,
dgettext("auth", "Account activated! Redirecting to complete sign-in...")
)
|> Phoenix.LiveView.redirect(to: ~p"/auth/user/oidc")
@@ -217,7 +217,7 @@ defmodule MvWeb.LinkOidcAccountLive do
{:noreply,
socket
|> put_flash(
- :success,
+ :info,
dgettext(
"auth",
"Your OIDC account has been successfully linked! Redirecting to complete sign-in..."
diff --git a/lib/mv_web/live/components/field_visibility_dropdown_component.ex b/lib/mv_web/live/components/field_visibility_dropdown_component.ex
index 58777da..a8e8d45 100644
--- a/lib/mv_web/live/components/field_visibility_dropdown_component.ex
+++ b/lib/mv_web/live/components/field_visibility_dropdown_component.ex
@@ -188,7 +188,7 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
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("Datafield %{id}", id: id)
+ nil -> gettext("Custom Field %{id}", id: id)
custom_field -> custom_field.name
end
end
diff --git a/lib/mv_web/live/components/member_filter_component.ex b/lib/mv_web/live/components/member_filter_component.ex
index 4ee72d3..ef6f32e 100644
--- a/lib/mv_web/live/components/member_filter_component.ex
+++ b/lib/mv_web/live/components/member_filter_component.ex
@@ -64,11 +64,11 @@ defmodule MvWeb.Components.MemberFilterComponent do
phx-key="Escape"
phx-target={@myself}
>
- <.button
+
0 ||
active_boolean_filters_count(@boolean_filters) > 0) &&
"btn-active"
@@ -104,7 +104,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
>
{@member_count}
-
+
0} class="mb-2">
- {gettext("Individual datafields")}
+ {gettext("Custom Fields")}
- <.button
+
- {gettext("Clear filters")}
-
- <.button
+ {gettext("Reset")}
+
+
{gettext("Close")}
-
+
@@ -460,7 +458,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
boolean_filter_label(boolean_custom_fields, boolean_filters)
true ->
- gettext("Apply filters")
+ gettext("All")
end
end
diff --git a/lib/mv_web/live/components/sort_header_component.ex b/lib/mv_web/live/components/sort_header_component.ex
index c4850c4..d548efa 100644
--- a/lib/mv_web/live/components/sort_header_component.ex
+++ b/lib/mv_web/live/components/sort_header_component.ex
@@ -19,28 +19,25 @@ defmodule MvWeb.Components.SortHeaderComponent do
@impl true
def render(assigns) do
~H"""
-
- <.tooltip content={aria_sort(@field, @sort_field, @sort_order)} position="bottom">
- <.button
- type="button"
- variant="ghost"
- aria-label={aria_sort(@field, @sort_field, @sort_order)}
- class="select-none"
- phx-click="sort"
- phx-value-field={@field}
- data-testid={@field}
- >
- {@label}
- <%= if @sort_field == @field do %>
- <.icon name={if @sort_order == :asc, do: "hero-chevron-up", else: "hero-chevron-down"} />
- <% else %>
- <.icon
- name="hero-chevron-up-down"
- class="opacity-40"
- />
- <% end %>
-
-
+
+
+ {@label}
+ <%= if @sort_field == @field do %>
+ <.icon name={if @sort_order == :asc, do: "hero-chevron-up", else: "hero-chevron-down"} />
+ <% else %>
+ <.icon
+ name="hero-chevron-up-down"
+ class="opacity-40"
+ />
+ <% end %>
+
"""
end
diff --git a/lib/mv_web/live/custom_field_live/form_component.ex b/lib/mv_web/live/custom_field_live/form_component.ex
index aac67dc..f89f767 100644
--- a/lib/mv_web/live/custom_field_live/form_component.ex
+++ b/lib/mv_web/live/custom_field_live/form_component.ex
@@ -24,13 +24,11 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
<.button
type="button"
- variant="neutral"
phx-click="cancel"
phx-target={@myself}
aria-label={gettext("Back to settings")}
>
- <.icon name="hero-arrow-left" class="size-4" />
- {gettext("Back")}
+ <.icon name="hero-arrow-left" class="w-4 h-4" />
{if @custom_field, do: gettext("Edit Data Field"), else: gettext("New Data Field")}
@@ -98,35 +96,8 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
label={gettext("Show in overview")}
/>
- <%= if @custom_field do %>
- <%!-- Danger zone: canonical pattern (same as member form) --%>
-
-
- {gettext("Danger zone")}
-
-
-
- {gettext(
- "Deleting this data field cannot be undone. All datafield values for this field will be permanently removed."
- )}
-
- <.button
- type="button"
- variant="danger"
- phx-click="request_delete"
- phx-target={@myself}
- data-testid="custom-field-delete"
- aria-label={gettext("Delete data field %{name}", name: @custom_field.name)}
- >
- <.icon name="hero-trash" class="size-4" />
- {gettext("Delete data field")}
-
-
-
- <% end %>
-
- <.button type="button" variant="neutral" phx-click="cancel" phx-target={@myself}>
+ <.button type="button" phx-click="cancel" phx-target={@myself}>
{gettext("Cancel")}
<.button phx-disable-with={gettext("Saving...")} variant="primary">
@@ -197,15 +168,6 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
{:noreply, socket}
end
- @impl true
- def handle_event("request_delete", _params, socket) do
- if custom_field = socket.assigns[:custom_field] do
- send(self(), {:open_delete_modal_for, custom_field})
- end
-
- {:noreply, socket}
- end
-
defp assign_form(%{assigns: %{custom_field: custom_field}} = socket) do
form =
if custom_field do
diff --git a/lib/mv_web/live/custom_field_live/index_component.ex b/lib/mv_web/live/custom_field_live/index_component.ex
index 3b70c3d..a670a3e 100644
--- a/lib/mv_web/live/custom_field_live/index_component.ex
+++ b/lib/mv_web/live/custom_field_live/index_component.ex
@@ -59,7 +59,6 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself)
end
}
- row_tooltip={gettext("Click to edit datafield")}
>
<:col :let={{_id, custom_field}} label={gettext("Name")}>{custom_field.name}
@@ -96,6 +95,22 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
{gettext("No")}
+
+ <:action :let={{_id, custom_field}}>
+ <.link phx-click={
+ JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself)
+ }>
+ {gettext("Edit")}
+
+
+
+ <:action :let={{_id, custom_field}}>
+ <.link phx-click={
+ JS.push("prepare_delete", value: %{id: custom_field.id}, target: @myself)
+ }>
+ {gettext("Delete")}
+
+
@@ -149,17 +164,17 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
- <.button variant="neutral" phx-click="cancel_delete" phx-target={@myself}>
+
{gettext("Cancel")}
-
- <.button
- variant="danger"
+
+
- {gettext("Delete Datafields and All Values")}
-
+ {gettext("Delete Custom Field and All Values")}
+
@@ -207,38 +222,16 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
# Get actor from assigns or fall back to socket assigns
actor = Map.get(assigns, :actor, socket.assigns[:actor])
- socket =
- 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, stream_custom_fields(actor, self()), reset: true)
-
- # Open delete modal when requested from form (e.g. Danger zone in FormComponent)
- socket =
- case Map.get(assigns, :open_delete_for_id) do
- nil ->
- socket
-
- id ->
- custom_field =
- Ash.get!(Mv.Membership.CustomField, id,
- load: [:assigned_members_count],
- actor: actor
- )
-
- socket
- |> assign(:show_delete_modal, true)
- |> assign(:custom_field_to_delete, custom_field)
- |> assign(:slug_confirmation, "")
- |> assign(:open_delete_for_id, nil)
- end
-
- {:ok, socket}
+ {: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, stream_custom_fields(actor, self()), reset: true)}
end
@impl true
diff --git a/lib/mv_web/live/datafields_live.ex b/lib/mv_web/live/datafields_live.ex
index 0fc4c3c..f7436ab 100644
--- a/lib/mv_web/live/datafields_live.ex
+++ b/lib/mv_web/live/datafields_live.ex
@@ -64,12 +64,12 @@ defmodule MvWeb.DatafieldsLive do
{:noreply,
socket
|> assign(:active_editing_section, nil)
- |> put_flash(:success, gettext("Data field %{action} successfully", action: action))}
+ |> put_flash(:info, gettext("Data field %{action} successfully", action: action))}
end
@impl true
def handle_info({:custom_field_deleted, _custom_field}, socket) do
- {:noreply, put_flash(socket, :success, gettext("Data field deleted successfully"))}
+ {:noreply, put_flash(socket, :info, gettext("Data field deleted successfully"))}
end
@impl true
@@ -101,17 +101,6 @@ defmodule MvWeb.DatafieldsLive do
{:noreply, assign(socket, :active_editing_section, section)}
end
- # Open delete modal for custom field (triggered from Danger zone in FormComponent)
- @impl true
- def handle_info({:open_delete_modal_for, custom_field}, socket) do
- send_update(MvWeb.CustomFieldLive.IndexComponent,
- id: "custom-fields-component",
- open_delete_for_id: custom_field.id
- )
-
- {:noreply, socket}
- end
-
@impl true
def handle_info({:member_field_saved, _member_field, action}, socket) do
{:ok, updated_settings} = Membership.get_settings()
@@ -126,7 +115,7 @@ defmodule MvWeb.DatafieldsLive do
socket
|> assign(:settings, updated_settings)
|> assign(:active_editing_section, nil)
- |> put_flash(:success, gettext("Member field %{action} successfully", action: action))}
+ |> put_flash(:info, gettext("Member field %{action} successfully", action: action))}
end
@impl true
diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex
index bb8eb32..752c8d6 100644
--- a/lib/mv_web/live/global_settings_live.ex
+++ b/lib/mv_web/live/global_settings_live.ex
@@ -95,7 +95,7 @@ defmodule MvWeb.GlobalSettingsLive do
<.button phx-disable-with={gettext("Saving...")} variant="primary">
- {gettext("Save Name")}
+ {gettext("Save Settings")}
@@ -181,18 +181,18 @@ defmodule MvWeb.GlobalSettingsLive do
<.button
:if={Mv.Config.vereinfacht_configured?()}
type="button"
- variant="outline"
phx-click="test_vereinfacht_connection"
phx-disable-with={gettext("Testing...")}
+ class="btn-outline"
>
{gettext("Test Integration")}
<.button
:if={Mv.Config.vereinfacht_configured?()}
type="button"
- variant="outline"
phx-click="sync_vereinfacht_contacts"
phx-disable-with={gettext("Syncing...")}
+ class="btn-outline"
>
{gettext("Sync all members without Vereinfacht contact")}
@@ -357,21 +357,20 @@ defmodule MvWeb.GlobalSettingsLive do
errors_with_names = enrich_sync_errors(errors)
result = %{synced: synced, errors: errors_with_names}
- {flash_kind, flash_message} =
- if(errors_with_names == [],
- do: {:success, gettext("Synced %{count} member(s) to Vereinfacht.", count: synced)},
- else:
- {:warning,
- gettext("Synced %{count} member(s). %{error_count} failed.",
- count: synced,
- error_count: length(errors_with_names)
- )}
- )
-
socket =
socket
|> assign(:last_vereinfacht_sync_result, result)
- |> put_flash(flash_kind, flash_message)
+ |> put_flash(
+ :info,
+ if(errors_with_names == [],
+ do: gettext("Synced %{count} member(s) to Vereinfacht.", count: synced),
+ else:
+ gettext("Synced %{count} member(s). %{error_count} failed.",
+ count: synced,
+ error_count: length(errors_with_names)
+ )
+ )
+ )
{:noreply, socket}
@@ -410,7 +409,7 @@ defmodule MvWeb.GlobalSettingsLive do
|> assign(:oidc_client_secret_set, present?(fresh_settings.oidc_client_secret))
|> assign(:oidc_configured, Mv.Config.oidc_configured?())
|> assign(:vereinfacht_test_result, test_result)
- |> put_flash(:success, gettext("Settings updated successfully"))
+ |> put_flash(:info, gettext("Settings updated successfully"))
|> assign_form()
{:noreply, socket}
diff --git a/lib/mv_web/live/group_live/form.ex b/lib/mv_web/live/group_live/form.ex
index 2e79a7f..0ffba09 100644
--- a/lib/mv_web/live/group_live/form.ex
+++ b/lib/mv_web/live/group_live/form.ex
@@ -78,56 +78,30 @@ defmodule MvWeb.GroupLive.Form do
~H"""
<.form for={@form} id="group-form" phx-change="validate" phx-submit="save">
- <.header>
- <:leading>
- <.button navigate={return_path(@return_to, @group)} variant="neutral">
- <.icon name="hero-arrow-left" class="size-4" />
- {gettext("Back")}
-
-
- {@page_title}
- <:actions>
- <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
- {gettext("Save")}
-
-
-
+ <%!-- Header with Back button, Title, and Save button --%>
+
+ <.button navigate={return_path(@return_to, @group)} type="button">
+ <.icon name="hero-arrow-left" class="size-4" />
+ {gettext("Back")}
+
-
-
- <.input field={@form[:name]} label={gettext("Name")} required />
- <.input
- field={@form[:description]}
- type="textarea"
- label={gettext("Description")}
- rows="4"
- />
-
+
+ {@page_title}
+
- <%!-- Danger zone: canonical pattern (same as member form) --%>
- <%= if @group && can?(@current_user, :destroy, @group) do %>
-
-
- {gettext("Danger zone")}
-
-
-
- {gettext(
- "Deleting this group cannot be undone. All member-group associations will be permanently removed."
- )}
-
- <.button
- variant="danger"
- navigate={~p"/groups/#{@group.slug}?confirm_delete=1"}
- data-testid="group-form-delete-btn"
- aria-label={gettext("Delete group %{name}", name: @group.name)}
- >
- <.icon name="hero-trash" class="size-4" />
- {gettext("Delete group")}
-
-
-
- <% end %>
+ <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
+ {gettext("Save")}
+
+
+
+
+ <.input field={@form[:name]} label={gettext("Name")} required />
+ <.input
+ field={@form[:description]}
+ type="textarea"
+ label={gettext("Description")}
+ rows="4"
+ />
@@ -155,7 +129,7 @@ defmodule MvWeb.GroupLive.Form do
socket =
socket
- |> put_flash(:success, gettext("Group saved successfully."))
+ |> put_flash(:info, gettext("Group saved successfully."))
|> push_navigate(to: redirect_path)
{:noreply, socket}
diff --git a/lib/mv_web/live/group_live/index.ex b/lib/mv_web/live/group_live/index.ex
index ff22b91..deab7e1 100644
--- a/lib/mv_web/live/group_live/index.ex
+++ b/lib/mv_web/live/group_live/index.ex
@@ -39,47 +39,72 @@ defmodule MvWeb.GroupLive.Index do
def render(assigns) do
~H"""
- <.header>
- {gettext("Groups")}
- <:actions>
- <%= if can?(@current_user, :create, Mv.Membership.Group) do %>
- <.button navigate={~p"/groups/new"} variant="primary">
- <.icon name="hero-plus" class="size-4 mr-2" />
- {gettext("Create Group")}
-
- <% end %>
-
-
-
-
- <%= if Enum.empty?(@groups) do %>
-
-
{gettext("No groups")}
-
- <% else %>
- <.table
- id="groups-table"
- rows={@groups}
- row_id={fn group -> "group-#{group.id}" end}
- row_click={fn group -> JS.navigate(~p"/groups/#{group.slug}") end}
- row_tooltip={gettext("Click for group details")}
- >
- <:col :let={group} label={gettext("Name")}>
- {group.name}
-
- <:col :let={group} label={gettext("Description")}>
- <%= if group.description do %>
- {group.description}
- <% else %>
-
—
- <% end %>
-
- <:col :let={group} label={gettext("Members")} class="text-right">
- {group.member_count || 0}
-
-
+
+
{gettext("Groups")}
+ <%= if can?(@current_user, :create, Mv.Membership.Group) do %>
+ <.button navigate={~p"/groups/new"} variant="primary">
+ <.icon name="hero-plus" class="size-4 mr-2" />
+ {gettext("Create Group")}
+
<% end %>
+
+ <%= if Enum.empty?(@groups) do %>
+
+
{gettext("No groups")}
+
+ <% else %>
+
+
+
+
+ {gettext("Name")}
+ {gettext("Description")}
+ {gettext("Members")}
+ {gettext("Actions")}
+
+
+
+ <%= for group <- @groups do %>
+
+
+ {group.name}
+
+
+ <%= if group.description do %>
+ {group.description}
+ <% else %>
+ —
+ <% end %>
+
+
+ <%= if group.member_count do %>
+ {group.member_count}
+ <% else %>
+ 0
+ <% end %>
+
+
+
+ <.link navigate={~p"/groups/#{group.slug}"} class="btn btn-sm btn-ghost">
+ {gettext("View")}
+
+ <%= if can?(@current_user, :update, Mv.Membership.Group) do %>
+ <.link
+ navigate={~p"/groups/#{group.slug}/edit"}
+ class="btn btn-sm btn-ghost"
+ >
+ {gettext("Edit")}
+
+ <% end %>
+
+
+
+ <% end %>
+
+
+
+ <% end %>
"""
end
diff --git a/lib/mv_web/live/group_live/show.ex b/lib/mv_web/live/group_live/show.ex
index dbc0523..0c7e93e 100644
--- a/lib/mv_web/live/group_live/show.ex
+++ b/lib/mv_web/live/group_live/show.ex
@@ -39,18 +39,18 @@ defmodule MvWeb.GroupLive.Show do
end
@impl true
- def handle_params(%{"slug" => slug} = params, _url, socket) do
+ def handle_params(%{"slug" => slug}, _url, socket) do
actor = current_actor(socket)
# Check if user can read groups
if can?(actor, :read, Mv.Membership.Group) do
- load_group_by_slug(socket, slug, actor, params)
+ load_group_by_slug(socket, slug, actor)
else
{:noreply, redirect(socket, to: ~p"/members")}
end
end
- defp load_group_by_slug(socket, slug, actor, params) do
+ defp load_group_by_slug(socket, slug, actor) do
# Load group with members and member_count
# Using explicit load ensures efficient preloading of members relationship
require Ash.Query
@@ -68,16 +68,10 @@ defmodule MvWeb.GroupLive.Show do
|> redirect(to: ~p"/groups")}
{:ok, group} ->
- open_delete = params["confirm_delete"] == "1" && can?(actor, :destroy, group)
-
- socket =
- socket
- |> assign(:page_title, group.name)
- |> assign(:group, group)
- |> assign(:show_delete_modal, open_delete)
- |> assign(:name_confirmation, "")
-
- {:noreply, socket}
+ {:noreply,
+ socket
+ |> assign(:page_title, group.name)
+ |> assign(:group, group)}
{:error, _error} ->
{:noreply,
@@ -91,346 +85,318 @@ defmodule MvWeb.GroupLive.Show do
def render(assigns) do
~H"""
- <.header>
- <:leading>
- <.button
- navigate={~p"/groups"}
- variant="neutral"
- aria-label={gettext("Back to groups list")}
- >
- <.icon name="hero-arrow-left" class="size-4" />
- {gettext("Back")}
-
-
- {@group.name}
- <:actions>
+ <%!-- Header with Back button, Name, and Edit/Delete buttons --%>
+
+ <.button navigate={~p"/groups"} aria-label={gettext("Back to groups list")}>
+ <.icon name="hero-arrow-left" class="size-4" />
+ {gettext("Back")}
+
+
+
+ {@group.name}
+
+
+
<%= if can?(@current_user, :update, @group) do %>
<.button
variant="primary"
navigate={~p"/groups/#{@group.slug}/edit"}
data-testid="group-show-edit-btn"
>
- <.icon name="hero-pencil-square" /> {gettext("Edit group")}
+ {gettext("Edit")}
<% end %>
-
-
+ <%= if can?(@current_user, :destroy, @group) do %>
+ <.button
+ class="btn-error"
+ phx-click="open_delete_modal"
+ data-testid="group-show-delete-btn"
+ >
+ {gettext("Delete")}
+
+ <% end %>
+
+
-
- <%!-- Group Information --%>
-
-
-
{gettext("Description")}
-
- <%= if @group.description && String.trim(@group.description) != "" do %>
-
{@group.description}
- <% else %>
-
{gettext("No description")}
- <% end %>
-
-
-
-
-
{gettext("Members")}
-
-
- {ngettext(
- "Total: %{count} member",
- "Total: %{count} members",
- @group.member_count || 0,
- count: @group.member_count || 0
- )}
-
-
- <%= if can?(@current_user, :update, @group) do %>
-
- <%= if assigns[:show_add_member_input] do %>
-
-
- <.button
- type="button"
- variant="primary"
- phx-click="add_selected_members"
- data-testid="group-show-add-selected-members-btn"
- disabled={Enum.empty?(@selected_member_ids)}
- aria-label={gettext("Add members")}
- class="join-item"
- >
- <.icon name="hero-plus" class="size-5" />
-
- <.button
- type="button"
- variant="neutral"
- phx-click="hide_add_member_input"
- aria-label={gettext("Cancel")}
- class="join-item"
- >
- {gettext("Cancel")}
-
-
- <% else %>
- <.button
- variant="primary"
- phx-click="show_add_member_input"
- aria-label={gettext("Add Member")}
- >
- {gettext("Add Member")}
-
- <% end %>
-
- <% end %>
-
- <%= if Enum.empty?(@group.members || []) do %>
-
- {gettext("No members in this group")}
-
- <% else %>
-
-
-
-
- {gettext("Name")}
- {gettext("Email")}
- <%= if can?(@current_user, :update, @group) do %>
- {gettext("Actions")}
- <% end %>
-
-
-
- <%= for member <- @group.members do %>
-
-
- <.link
- navigate={~p"/members/#{member.id}"}
- class="link link-primary"
- >
- {MvWeb.Helpers.MemberHelpers.display_name(member)}
-
-
-
- <%= if member.email do %>
-
- {member.email}
-
- <% else %>
- —
- <% end %>
-
- <%= if can?(@current_user, :update, @group) do %>
-
- <.tooltip content={gettext("Remove")} position="left">
- <.button
- type="button"
- variant="danger"
- size="sm"
- phx-click="remove_member"
- phx-value-member_id={member.id}
- data-testid="group-show-remove-member"
- aria-label={gettext("Remove member from group")}
- >
- <.icon name="hero-trash" class="size-4" />
-
-
-
- <% end %>
-
- <% end %>
-
-
-
- <% end %>
-
+ <%!-- Group Information --%>
+
+
+
{gettext("Description")}
+
+ <%= if @group.description && String.trim(@group.description) != "" do %>
+
{@group.description}
+ <% else %>
+
{gettext("No description")}
+ <% end %>
- <%!-- Danger zone: canonical pattern (same as member show) --%>
- <%= if can?(@current_user, :destroy, @group) do %>
-
-
- {gettext("Danger zone")}
-
-
-
- {gettext(
- "Deleting this group cannot be undone. All member-group associations will be permanently removed."
- )}
-
- <.button
- variant="danger"
- type="button"
- phx-click="open_delete_modal"
- data-testid="group-show-delete-btn"
- aria-label={gettext("Delete group %{name}", name: @group.name)}
- >
- <.icon name="hero-trash" class="size-4" />
- {gettext("Delete group")}
-
-
-
- <% end %>
+
+
{gettext("Members")}
+
+
+ {ngettext(
+ "Total: %{count} member",
+ "Total: %{count} members",
+ @group.member_count || 0,
+ count: @group.member_count || 0
+ )}
+
- <%!-- Delete Confirmation Modal --%>
- <%= if assigns[:show_delete_modal] do %>
-
-
-
{gettext("Delete Group")}
-
- {gettext("Are you sure you want to delete this group? This action cannot be undone.")}
+ <%= if can?(@current_user, :update, @group) do %>
+
+ <%= if assigns[:show_add_member_input] do %>
+
+
+
+ <.icon name="hero-plus" class="size-5" />
+
+
+ {gettext("Cancel")}
+
+
+ <% else %>
+ <.button
+ variant="primary"
+ phx-click="show_add_member_input"
+ aria-label={gettext("Add Member")}
+ >
+ {gettext("Add Member")}
+
+ <% end %>
+
+ <% end %>
+
+ <%= if Enum.empty?(@group.members || []) do %>
+
+ {gettext("No members in this group")}
- <%= if @group.member_count && @group.member_count > 0 do %>
-
- <.icon name="hero-exclamation-triangle" class="size-5" />
-
- {ngettext(
- "This group has %{count} member. All member-group associations will be permanently deleted.",
- "This group has %{count} members. All member-group associations will be permanently deleted.",
- @group.member_count,
- count: @group.member_count
- )}
-
-
- <% end %>
-
-
-
- {gettext("To confirm deletion, please enter the group name:")}
-
-
-
- {@group.name}
-
-
+ <% else %>
+
+
+
+
+ {gettext("Name")}
+ {gettext("Email")}
+ <%= if can?(@current_user, :update, @group) do %>
+ {gettext("Actions")}
+ <% end %>
+
+
+
+ <%= for member <- @group.members do %>
+
+
+ <.link
+ navigate={~p"/members/#{member.id}"}
+ class="link link-primary"
+ >
+ {MvWeb.Helpers.MemberHelpers.display_name(member)}
+
+
+
+ <%= if member.email do %>
+
+ {member.email}
+
+ <% else %>
+ —
+ <% end %>
+
+ <%= if can?(@current_user, :update, @group) do %>
+
+
+ <.icon name="hero-trash" class="size-4" />
+
+
+ <% end %>
+
+ <% end %>
+
+
-
- <.button
- type="button"
- variant="neutral"
- phx-click="cancel_delete"
- aria-label={gettext("Cancel")}
- >
- {gettext("Cancel")}
-
- <.button
- type="button"
- variant="danger"
- phx-click="confirm_delete"
- phx-value-slug={@group.slug}
- disabled={(@name_confirmation || "") != @group.name}
- aria-label={gettext("Delete group")}
- >
- {gettext("Delete")}
-
-
-
-
- <% end %>
+ <% end %>
+
+
+
+ <%!-- Delete Confirmation Modal --%>
+ <%= if assigns[:show_delete_modal] do %>
+
+
+
{gettext("Delete Group")}
+
+ {gettext("Are you sure you want to delete this group? This action cannot be undone.")}
+
+ <%= if @group.member_count && @group.member_count > 0 do %>
+
+ <.icon name="hero-exclamation-triangle" class="size-5" />
+
+ {ngettext(
+ "This group has %{count} member. All member-group associations will be permanently deleted.",
+ "This group has %{count} members. All member-group associations will be permanently deleted.",
+ @group.member_count,
+ count: @group.member_count
+ )}
+
+
+ <% end %>
+
+
+
+ {gettext("To confirm deletion, please enter the group name:")}
+
+
+
+ {@group.name}
+
+
+
+
+
+ {gettext("Cancel")}
+
+
+ {gettext("Delete")}
+
+
+
+
+ <% end %>
"""
end
@@ -934,7 +900,7 @@ defmodule MvWeb.GroupLive.Show do
:ok ->
{:noreply,
socket
- |> put_flash(:success, gettext("Group deleted successfully."))
+ |> put_flash(:info, gettext("Group deleted successfully."))
|> redirect(to: ~p"/groups")}
{:error, error} ->
diff --git a/lib/mv_web/live/member_field_live/form_component.ex b/lib/mv_web/live/member_field_live/form_component.ex
index 17266a8..ae9e239 100644
--- a/lib/mv_web/live/member_field_live/form_component.ex
+++ b/lib/mv_web/live/member_field_live/form_component.ex
@@ -42,13 +42,11 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
<.button
type="button"
- variant="neutral"
phx-click="cancel"
phx-target={@myself}
- aria-label={gettext("Back to settings")}
+ aria-label={gettext("Back to Settings")}
>
- <.icon name="hero-arrow-left" class="size-4" />
- {gettext("Back")}
+ <.icon name="hero-arrow-left" class="w-4 h-4" />
{gettext("Edit Field: %{field}", field: @field_label)}
@@ -178,7 +176,7 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
- <.button type="button" variant="neutral" phx-click="cancel" phx-target={@myself}>
+ <.button type="button" phx-click="cancel" phx-target={@myself}>
{gettext("Cancel")}
<.button phx-disable-with={gettext("Saving...")} variant="primary">
diff --git a/lib/mv_web/live/member_field_live/index_component.ex b/lib/mv_web/live/member_field_live/index_component.ex
index 28384b5..db62778 100644
--- a/lib/mv_web/live/member_field_live/index_component.ex
+++ b/lib/mv_web/live/member_field_live/index_component.ex
@@ -52,12 +52,6 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
:if={!@show_form}
id="member_fields"
rows={@member_fields}
- row_click={
- fn {field_name, _field_data} ->
- JS.push("edit_member_field", value: %{"field" => field_name}, target: @myself)
- end
- }
- row_tooltip={gettext("Click to edit datafield")}
>
<:col :let={{_field_name, field_data}} label={gettext("Name")}>
{MemberFields.label(field_data.field)}
@@ -92,6 +86,16 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
{gettext("No")}
+
+ <:action :let={{_field_name, field_data}}>
+ <.link
+ phx-click="edit_member_field"
+ phx-value-field={Atom.to_string(field_data.field)}
+ phx-target={@myself}
+ >
+ {gettext("Edit")}
+
+
"""
diff --git a/lib/mv_web/live/member_live/form.ex b/lib/mv_web/live/member_live/form.ex
index c98feb9..81db0fe 100644
--- a/lib/mv_web/live/member_live/form.ex
+++ b/lib/mv_web/live/member_live/form.ex
@@ -6,6 +6,7 @@ defmodule MvWeb.MemberLive.Form do
- Create new members with personal information
- Edit existing member details
- 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
@@ -20,7 +21,6 @@ defmodule MvWeb.MemberLive.Form do
"""
use MvWeb, :live_view
- require Logger
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
alias Mv.Membership
@@ -38,248 +38,222 @@ defmodule MvWeb.MemberLive.Form do
~H"""
<.form for={@form} id="member-form" phx-change="validate" phx-submit="save">
- <.header>
- <:leading>
- <.button navigate={return_path(@return_to, @member)} variant="neutral">
- <.icon name="hero-arrow-left" class="size-4" />
- {gettext("Back")}
-
-
- <%= if @member do %>
- {MvWeb.Helpers.MemberHelpers.display_name(@member)}
- <% else %>
- {gettext("New Member")}
- <% end %>
- <:actions>
- <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
- {gettext("Save")}
-
-
-
+ <%!-- Header with Back button, Name display, and Save button --%>
+
+ <.button navigate={return_path(@return_to, @member)} type="button">
+ <.icon name="hero-arrow-left" class="size-4" />
+ {gettext("Back")}
+
-
- <%!-- Tab navigation: Payments tab not shown on new/edit (only on member show) --%>
-
-
- <.icon name="hero-identification" class="size-4 mr-2" />
- {gettext("Contact Data")}
-
-
+
+ <%= if @member do %>
+ {MvWeb.Helpers.MemberHelpers.display_name(@member)}
+ <% else %>
+ {gettext("New Member")}
+ <% end %>
+
- <%!-- Personal Data and Custom Fields Row --%>
-
- <%!-- Personal Data Section --%>
-
- <.form_section title={gettext("Personal Data")}>
-
- <%!-- Name Row --%>
-
-
- <.input
- field={@form[:first_name]}
- label={gettext("First Name")}
- required={@member_field_required_map[:first_name]}
- />
-
-
- <.input
- field={@form[:last_name]}
- label={gettext("Last Name")}
- required={@member_field_required_map[:last_name]}
- />
-
-
+ <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
+ {gettext("Save")}
+
+
- <%!-- Address: Country, Postal Code, City in one row --%>
-
-
- <.input field={@form[:country]} label={gettext("Country")} />
-
-
- <.input
- field={@form[:postal_code]}
- label={gettext("Postal Code")}
- required={@member_field_required_map[:postal_code]}
- />
-
-
- <.input field={@form[:city]} label={gettext("City")} />
-
-
+ <%!-- Tab Navigation --%>
+
+
+ <.icon name="hero-identification" class="size-4 mr-2" />
+ {gettext("Contact Data")}
+
+
+ <.icon name="hero-credit-card" class="size-4 mr-2" />
+ {gettext("Payments")}
+
+
- <%!-- Street and Nr. below --%>
-
-
- <.input field={@form[:street]} label={gettext("Street")} />
-
-
- <.input field={@form[:house_number]} label={gettext("Nr.")} />
-
-
-
- <%!-- Email --%>
-
- <.input field={@form[:email]} label={gettext("Email")} required type="email" />
-
-
- <%!-- Membership Dates Row --%>
-
-
- <.input
- field={@form[:join_date]}
- label={gettext("Join Date")}
- type="date"
- required={@member_field_required_map[:join_date]}
- />
-
-
- <.input
- field={@form[:exit_date]}
- label={gettext("Exit Date")}
- type="date"
- required={@member_field_required_map[:exit_date]}
- />
-
-
-
- <%!-- Notes --%>
-
+ <%!-- Personal Data and Custom Fields Row --%>
+
+ <%!-- Personal Data Section --%>
+
+ <.form_section title={gettext("Personal Data")}>
+
+ <%!-- Name Row --%>
+
+
<.input
- field={@form[:notes]}
- label={gettext("Notes")}
- type="textarea"
- required={@member_field_required_map[:notes]}
+ field={@form[:first_name]}
+ label={gettext("First Name")}
+ required={@member_field_required_map[:first_name]}
+ />
+
+
+ <.input
+ field={@form[:last_name]}
+ label={gettext("Last Name")}
+ required={@member_field_required_map[:last_name]}
/>
-
-
- <%!-- Custom Fields Section --%>
- <%= if Enum.any?(@custom_fields) do %>
-
- <.form_section title={gettext("Custom Fields")}>
-
- <%!-- 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 %>
-
- <.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)}
- required={cf.required}
- />
-
-
-
- <% end %>
-
- <% end %>
+ <%!-- Address: Country, Postal Code, City in one row --%>
+
+
+ <.input field={@form[:country]} label={gettext("Country")} />
-
-
- <% end %>
-
+
+ <.input
+ field={@form[:postal_code]}
+ label={gettext("Postal Code")}
+ required={@member_field_required_map[:postal_code]}
+ />
+
+
+ <.input field={@form[:city]} label={gettext("City")} />
+
+
- <%!-- Membership Fee Section --%>
-
- <.form_section title={gettext("Membership Fee")}>
-
+ <%!-- Street and Nr. below --%>
+
+
+ <.input field={@form[:street]} label={gettext("Street")} />
+
+
+ <.input field={@form[:house_number]} label={gettext("Nr.")} />
+
+
+
+ <%!-- Email --%>
+
+ <.input field={@form[:email]} label={gettext("Email")} required type="email" />
+
+
+ <%!-- Membership Dates Row --%>
+
+
+ <.input
+ field={@form[:join_date]}
+ label={gettext("Join Date")}
+ type="date"
+ required={@member_field_required_map[:join_date]}
+ />
+
+
+ <.input
+ field={@form[:exit_date]}
+ label={gettext("Exit Date")}
+ type="date"
+ required={@member_field_required_map[:exit_date]}
+ />
+
+
+
+ <%!-- Notes --%>
-
- {gettext("Membership Fee Type")}
-
-
- <%!-- No "None" option: a membership fee type is required (validated in Member resource). --%>
- {gettext("Select a membership fee type")}
- <%= for fee_type <- @available_fee_types do %>
-
- {fee_type.name} ({MembershipFeeHelpers.format_currency(fee_type.amount)}, {MembershipFeeHelpers.format_interval(
- fee_type.interval
- )})
-
- <% end %>
-
- <%= for error <- List.wrap(@form.errors[:membership_fee_type_id] || []) do %>
- <% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %>
-
{msg}
- <% end %>
- <%= if @interval_warning do %>
-
- <.icon name="hero-exclamation-triangle" class="size-5" />
- {@interval_warning}
-
- <% end %>
-
- {gettext(
- "Select a membership fee type for this member. Members can only switch between types with the same interval."
- )}
-
+ <.input
+ field={@form[:notes]}
+ label={gettext("Notes")}
+ type="textarea"
+ required={@member_field_required_map[:notes]}
+ />
- <%!-- Bottom Action Buttons --%>
-
- <.button navigate={return_path(@return_to, @member)} variant="neutral" type="button">
- {gettext("Cancel")}
-
- <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
- {gettext("Save Member")}
-
-
+ <%!-- Custom Fields Section --%>
+ <%= if Enum.any?(@custom_fields) do %>
+
+ <.form_section title={gettext("Custom Fields")}>
+
+ <%!-- 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 %>
+
+ <.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)}
+ required={cf.required}
+ />
+
+
+
+ <% end %>
+
+ <% end %>
+
+
+
+ <% end %>
+
- <%!-- Danger zone: same section pattern as MemberLive.Show (canonical) --%>
- <%= if @member && can?(@current_user, :destroy, @member) do %>
-
-
- {gettext("Danger zone")}
-
-
-
+ <%!-- Membership Fee Section --%>
+
+ <.form_section title={gettext("Membership Fee")}>
+
+
+
+ {gettext("Membership Fee Type")}
+
+
+ <%!-- No "None" option: a membership fee type is required (validated in Member resource). --%>
+ {gettext("Select a membership fee type")}
+ <%= for fee_type <- @available_fee_types do %>
+
+ {fee_type.name} ({MembershipFeeHelpers.format_currency(fee_type.amount)}, {MembershipFeeHelpers.format_interval(
+ fee_type.interval
+ )})
+
+ <% end %>
+
+ <%= for error <- List.wrap(@form.errors[:membership_fee_type_id] || []) do %>
+ <% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %>
+
{msg}
+ <% end %>
+ <%= if @interval_warning do %>
+
+ <.icon name="hero-exclamation-triangle" class="size-5" />
+ {@interval_warning}
+
+ <% end %>
+
{gettext(
- "Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed."
+ "Select a membership fee type for this member. Members can only switch between types with the same interval."
)}
- <.button
- variant="danger"
- type="button"
- phx-click="delete"
- phx-value-id={@member.id}
- data-confirm={
- gettext("Are you sure you want to delete %{name}? This action cannot be undone.",
- name: MvWeb.Helpers.MemberHelpers.display_name(@member)
- )
- }
- data-testid="member-delete"
- aria-label={
- gettext("Delete member %{name}",
- name: MvWeb.Helpers.MemberHelpers.display_name(@member)
- )
- }
- >
- <.icon name="hero-trash" class="size-4" />
- {gettext("Delete member")}
-
-
- <% end %>
+
+
+
+
+ <%!-- Bottom Action Buttons --%>
+
+ <.button navigate={return_path(@return_to, @member)} type="button">
+ {gettext("Cancel")}
+
+ <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
+ {gettext("Save Member")}
+
@@ -400,41 +374,6 @@ defmodule MvWeb.MemberLive.Form do
end
end
- @impl true
- def handle_event("delete", %{"id" => id}, socket) do
- member = socket.assigns.member
- actor = current_actor(socket)
-
- cond do
- is_nil(member) ->
- {:noreply, put_flash(socket, :error, gettext("Member not found"))}
-
- to_string(id) != to_string(member.id) ->
- {:noreply, put_flash(socket, :error, gettext("Member not found"))}
-
- true ->
- handle_member_delete_destroy(socket, member, actor)
- end
- end
-
- defp handle_member_delete_destroy(socket, member, actor) do
- case Ash.destroy(member, actor: actor) do
- :ok ->
- {:noreply,
- socket
- |> put_flash(:success, gettext("Member deleted successfully"))
- |> push_navigate(to: ~p"/members")}
-
- {:error, %Ash.Error.Forbidden{}} ->
- {:noreply,
- put_flash(socket, :error, gettext("You do not have permission to delete this member"))}
-
- {:error, error} ->
- Logger.warning("Member delete failed: member_id=#{member.id} error=#{inspect(error)}")
- {:noreply, put_flash(socket, :error, format_destroy_error(error))}
- end
- end
-
defp handle_save_success(socket, member) do
notify_parent({:saved, member})
@@ -447,7 +386,7 @@ defmodule MvWeb.MemberLive.Form do
socket =
socket
- |> put_flash(:success, flash_message)
+ |> put_flash(:info, flash_message)
|> maybe_put_vereinfacht_sync_flash(member.id)
|> push_navigate(to: return_path(socket.assigns.return_to, member))
@@ -482,19 +421,6 @@ defmodule MvWeb.MemberLive.Form do
end
end
- defp format_destroy_error(%Ash.Error.Invalid{errors: errors}) do
- error_messages =
- Enum.map(errors, fn
- %{field: field, message: message} -> "#{field}: #{message}"
- %{message: message} -> message
- _ -> inspect(errors)
- end)
-
- Enum.join(error_messages, ", ")
- end
-
- defp format_destroy_error(error), do: inspect(error)
-
defp handle_save_error(socket, form) do
# Always show a flash message when save fails
# Field-level validation errors are displayed in form fields, but flash provides additional feedback
diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex
index 1be35b4..3283b5c 100644
--- a/lib/mv_web/live/member_live/index.ex
+++ b/lib/mv_web/live/member_live/index.ex
@@ -15,6 +15,7 @@ defmodule MvWeb.MemberLive.Index do
- `sort_order` - Sort direction (:asc or :desc)
## Events
+ - `delete` - Remove a member from the database
- `select_member` - Toggle individual member selection
- `select_all` - Toggle selection of all visible members
- `copy_emails` - Copy email addresses of selected members to clipboard
@@ -122,7 +123,6 @@ defmodule MvWeb.MemberLive.Index do
|> assign(:groups, groups)
|> assign(:boolean_custom_field_filters, %{})
|> assign(:selected_members, MapSet.new())
- |> assign(:selected_member_id, nil)
|> assign(:settings, settings)
|> assign(:custom_fields_visible, custom_fields_visible)
|> assign(:all_custom_fields, all_custom_fields)
@@ -157,14 +157,48 @@ defmodule MvWeb.MemberLive.Index do
Handles member-related UI events.
## Supported events:
+ - `"delete"` - Removes a member from the database
- `"select_member"` - Toggles individual member selection
- `"select_all"` - Toggles selection of all visible members
- `"sort"` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL
"""
@impl true
- def handle_event("select_row_and_navigate", %{"id" => id}, socket) do
- # Navigate to member show. Back button on show page uses ?highlight=id so returning to index shows row as selected.
- {:noreply, push_navigate(socket, to: ~p"/members/#{id}")}
+ def handle_event("delete", %{"id" => id}, socket) do
+ actor = current_actor(socket)
+
+ case Ash.get(Mv.Membership.Member, id, actor: actor) do
+ {:ok, member} ->
+ case Ash.destroy(member, actor: actor) do
+ :ok ->
+ updated_members = Enum.reject(socket.assigns.members, &(&1.id == id))
+
+ {:noreply,
+ socket
+ |> assign(:members, updated_members)
+ |> put_flash(:info, gettext("Member deleted successfully"))}
+
+ {:error, %Ash.Error.Forbidden{}} ->
+ {:noreply,
+ put_flash(
+ socket,
+ :error,
+ gettext("You do not have permission to delete this member")
+ )}
+
+ {:error, error} ->
+ {:noreply, put_flash(socket, :error, format_error(error))}
+ end
+
+ {:error, %Ash.Error.Query.NotFound{}} ->
+ {:noreply, put_flash(socket, :error, gettext("Member not found"))}
+
+ {:error, %Ash.Error.Forbidden{} = _error} ->
+ {:noreply,
+ put_flash(socket, :error, gettext("You do not have permission to access this member"))}
+
+ {:error, error} ->
+ {:noreply, put_flash(socket, :error, format_error(error))}
+ end
end
@impl true
@@ -309,6 +343,22 @@ defmodule MvWeb.MemberLive.Index do
{:noreply, push_patch(socket, to: ~p"/members?#{query_params}", replace: true)}
end
+ # Helper to format errors for display
+ defp format_error(%Ash.Error.Invalid{errors: errors}) do
+ error_messages =
+ Enum.map(errors, fn error ->
+ case error do
+ %{field: field, message: message} -> "#{field}: #{message}"
+ %{message: message} -> message
+ _ -> inspect(error)
+ end
+ end)
+
+ Enum.join(error_messages, ", ")
+ end
+
+ defp format_error(error), do: inspect(error)
+
# -----------------------------------------------------------------
# Handle Infos from Child Components
# -----------------------------------------------------------------
@@ -606,7 +656,6 @@ defmodule MvWeb.MemberLive.Index do
|> assign(:member_fields_visible_db, visible_member_fields_db)
|> assign(:member_fields_visible_computed, visible_member_fields_computed)
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
- |> assign(:selected_member_id, parse_highlight_param(params["highlight"]))
next_sig = build_signature(socket)
@@ -806,18 +855,6 @@ defmodule MvWeb.MemberLive.Index do
end
end
- # Parses optional "highlight" URL param (member id for selected row styling). Returns nil if missing or invalid.
- defp parse_highlight_param(nil), do: nil
- defp parse_highlight_param(""), do: nil
-
- defp parse_highlight_param(id) when is_binary(id) do
- if String.length(id) <= @max_uuid_length and match?({:ok, _}, Ecto.UUID.cast(id)),
- do: id,
- else: nil
- end
-
- defp parse_highlight_param(_), do: nil
-
defp merge_fields_param_from_uri(params, nil), do: params
defp merge_fields_param_from_uri(params, %URI{query: query}) when is_binary(query) do
diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex
index af2863f..da9c3d5 100644
--- a/lib/mv_web/live/member_live/index.html.heex
+++ b/lib/mv_web/live/member_live/index.html.heex
@@ -9,7 +9,7 @@
selected_count={@selected_count}
/>
<.button
- variant="secondary"
+ class="secondary"
id="copy-emails-btn"
phx-hook="CopyToClipboard"
phx-click="copy_emails"
@@ -20,7 +20,7 @@
{gettext("Copy email addresses")} ({@selected_count})
<.button
- variant="secondary"
+ class="secondary"
id="open-email-btn"
href={"mailto:?bcc=" <> @mailto_bcc}
disabled={not @any_selected?}
@@ -54,12 +54,13 @@
boolean_filters={@boolean_custom_field_filters}
member_count={length(@members)}
/>
- <.button
+
-
+
<.live_component
module={MvWeb.Components.FieldVisibilityDropdownComponent}
id="field-visibility-dropdown"
@@ -90,329 +91,334 @@
/>
- <%!-- On desktop (lg:), only the table area scrolls; header and filters stay visible. On mobile, normal flow. --%>
- "row-#{member.id}" end}
+ row_click={fn member -> JS.navigate(~p"/members/#{member}") end}
+ dynamic_cols={@dynamic_cols}
+ sort_field={@sort_field}
+ sort_order={@sort_order}
>
- <.table
- id="members"
- rows={@members}
- sticky_header={true}
- row_id={fn member -> "row-#{member.id}" end}
- row_click={fn member -> JS.push("select_row_and_navigate", value: %{id: member.id}) end}
- row_tooltip={gettext("Click for member details")}
- row_selected?={fn member -> MapSet.member?(@selected_members, member.id) end}
- dynamic_cols={@dynamic_cols}
- sort_field={@sort_field}
- sort_order={@sort_order}
- >
-
+
- <:col
- :let={member}
- col_click={&MvWeb.MemberLive.Index.checkbox_column_click/1}
- label={
- ~H"""
- <.input
- type="checkbox"
- name="select_all"
- phx-click="select_all"
- checked={MapSet.equal?(@selected_members, @members |> Enum.map(& &1.id) |> MapSet.new())}
- aria-label={gettext("Select all members")}
- role="checkbox"
- />
- """
- }
- >
+ <:col
+ :let={member}
+ col_click={&MvWeb.MemberLive.Index.checkbox_column_click/1}
+ label={
+ ~H"""
<.input
type="checkbox"
- name={member.id}
- checked={MapSet.member?(@selected_members, member.id)}
- aria-label={gettext("Select member")}
+ name="select_all"
+ phx-click="select_all"
+ checked={MapSet.equal?(@selected_members, @members |> Enum.map(& &1.id) |> MapSet.new())}
+ aria-label={gettext("Select all members")}
role="checkbox"
/>
-
- <:col
- :let={member}
- :if={:first_name in @member_fields_visible}
- label={
- ~H"""
- <.live_component
- module={MvWeb.Components.SortHeaderComponent}
- id={:sort_first_name}
- field={:first_name}
- label={gettext("First name")}
- sort_field={@sort_field}
- sort_order={@sort_order}
- />
- """
- }
- >
- {member.first_name}
-
- <: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
- :let={member}
- :if={:email in @member_fields_visible}
- label={
- ~H"""
- <.live_component
- module={MvWeb.Components.SortHeaderComponent}
- id={:sort_email}
- field={:email}
- label={gettext("Email")}
- sort_field={@sort_field}
- sort_order={@sort_order}
- />
- """
- }
- >
- {member.email}
-
- <:col
- :let={member}
- :if={:join_date in @member_fields_visible}
- label={
- ~H"""
- <.live_component
- module={MvWeb.Components.SortHeaderComponent}
- id={:sort_join_date}
- field={:join_date}
- label={gettext("Join Date")}
- sort_field={@sort_field}
- sort_order={@sort_order}
- />
- """
- }
- >
- {MvWeb.MemberLive.Index.format_date(member.join_date)}
-
- <:col
- :let={member}
- :if={:exit_date in @member_fields_visible}
- label={
- ~H"""
- <.live_component
- module={MvWeb.Components.SortHeaderComponent}
- id={:sort_exit_date}
- field={:exit_date}
- label={gettext("Exit Date")}
- sort_field={@sort_field}
- sort_order={@sort_order}
- />
- """
- }
- >
- {MvWeb.MemberLive.Index.format_date(member.exit_date)}
-
- <:col
- :let={member}
- :if={:notes in @member_fields_visible}
- label={gettext("Notes")}
- >
- {member.notes}
-
- <:col
- :let={member}
- :if={:country in @member_fields_visible}
- label={
- ~H"""
- <.live_component
- module={MvWeb.Components.SortHeaderComponent}
- id={:sort_country}
- field={:country}
- label={gettext("Country")}
- sort_field={@sort_field}
- sort_order={@sort_order}
- />
- """
- }
- >
- {member.country}
-
- <:col
- :let={member}
- :if={:city in @member_fields_visible}
- label={
- ~H"""
- <.live_component
- module={MvWeb.Components.SortHeaderComponent}
- id={:sort_city}
- field={:city}
- label={gettext("City")}
- sort_field={@sort_field}
- sort_order={@sort_order}
- />
- """
- }
- >
- {member.city}
-
- <:col
- :let={member}
- :if={:street in @member_fields_visible}
- label={
- ~H"""
- <.live_component
- module={MvWeb.Components.SortHeaderComponent}
- id={:sort_street}
- field={:street}
- label={gettext("Street")}
- sort_field={@sort_field}
- sort_order={@sort_order}
- />
- """
- }
- >
- {member.street}
-
- <:col
- :let={member}
- :if={:house_number in @member_fields_visible}
- label={
- ~H"""
- <.live_component
- module={MvWeb.Components.SortHeaderComponent}
- id={:sort_house_number}
- field={:house_number}
- label={gettext("House Number")}
- sort_field={@sort_field}
- sort_order={@sort_order}
- />
- """
- }
- >
- {member.house_number}
-
- <:col
- :let={member}
- :if={:postal_code in @member_fields_visible}
- label={
- ~H"""
- <.live_component
- module={MvWeb.Components.SortHeaderComponent}
- id={:sort_postal_code}
- field={:postal_code}
- label={gettext("Postal Code")}
- sort_field={@sort_field}
- sort_order={@sort_order}
- />
- """
- }
- >
- {member.postal_code}
-
- <:col
- :let={member}
- :if={:membership_fee_start_date in @member_fields_visible}
- label={
- ~H"""
- <.live_component
- module={MvWeb.Components.SortHeaderComponent}
- id={:sort_membership_fee_start_date}
- field={:membership_fee_start_date}
- label={gettext("Membership Fee Start Date")}
- sort_field={@sort_field}
- sort_order={@sort_order}
- />
- """
- }
- >
- {MvWeb.MemberLive.Index.format_date(member.membership_fee_start_date)}
-
- <:col
- :let={member}
- :if={:membership_fee_type in @member_fields_visible}
- label={
- ~H"""
- <.live_component
- module={MvWeb.Components.SortHeaderComponent}
- id={:sort_membership_fee_type}
- field={:membership_fee_type}
- label={gettext("Fee Type")}
- sort_field={@sort_field}
- sort_order={@sort_order}
- />
- """
- }
- >
- <%= if member.membership_fee_type do %>
- {member.membership_fee_type.name}
- <% else %>
-
—
- <% end %>
-
- <:col
- :let={member}
- :if={:membership_fee_status in @member_fields_visible}
- label={gettext("Membership Fee Status")}
- >
- <%= if badge = MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge(
+ """
+ }
+ >
+ <.input
+ type="checkbox"
+ name={member.id}
+ checked={MapSet.member?(@selected_members, member.id)}
+ aria-label={gettext("Select member")}
+ role="checkbox"
+ />
+
+ <:col
+ :let={member}
+ :if={:first_name in @member_fields_visible}
+ label={
+ ~H"""
+ <.live_component
+ module={MvWeb.Components.SortHeaderComponent}
+ id={:sort_first_name}
+ field={:first_name}
+ label={gettext("First name")}
+ sort_field={@sort_field}
+ sort_order={@sort_order}
+ />
+ """
+ }
+ >
+ {member.first_name}
+
+ <: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
+ :let={member}
+ :if={:email in @member_fields_visible}
+ label={
+ ~H"""
+ <.live_component
+ module={MvWeb.Components.SortHeaderComponent}
+ id={:sort_email}
+ field={:email}
+ label={gettext("Email")}
+ sort_field={@sort_field}
+ sort_order={@sort_order}
+ />
+ """
+ }
+ >
+ {member.email}
+
+ <:col
+ :let={member}
+ :if={:join_date in @member_fields_visible}
+ label={
+ ~H"""
+ <.live_component
+ module={MvWeb.Components.SortHeaderComponent}
+ id={:sort_join_date}
+ field={:join_date}
+ label={gettext("Join Date")}
+ sort_field={@sort_field}
+ sort_order={@sort_order}
+ />
+ """
+ }
+ >
+ {MvWeb.MemberLive.Index.format_date(member.join_date)}
+
+ <:col
+ :let={member}
+ :if={:exit_date in @member_fields_visible}
+ label={
+ ~H"""
+ <.live_component
+ module={MvWeb.Components.SortHeaderComponent}
+ id={:sort_exit_date}
+ field={:exit_date}
+ label={gettext("Exit Date")}
+ sort_field={@sort_field}
+ sort_order={@sort_order}
+ />
+ """
+ }
+ >
+ {MvWeb.MemberLive.Index.format_date(member.exit_date)}
+
+ <:col
+ :let={member}
+ :if={:notes in @member_fields_visible}
+ label={gettext("Notes")}
+ >
+ {member.notes}
+
+ <:col
+ :let={member}
+ :if={:country in @member_fields_visible}
+ label={
+ ~H"""
+ <.live_component
+ module={MvWeb.Components.SortHeaderComponent}
+ id={:sort_country}
+ field={:country}
+ label={gettext("Country")}
+ sort_field={@sort_field}
+ sort_order={@sort_order}
+ />
+ """
+ }
+ >
+ {member.country}
+
+ <:col
+ :let={member}
+ :if={:city in @member_fields_visible}
+ label={
+ ~H"""
+ <.live_component
+ module={MvWeb.Components.SortHeaderComponent}
+ id={:sort_city}
+ field={:city}
+ label={gettext("City")}
+ sort_field={@sort_field}
+ sort_order={@sort_order}
+ />
+ """
+ }
+ >
+ {member.city}
+
+ <:col
+ :let={member}
+ :if={:street in @member_fields_visible}
+ label={
+ ~H"""
+ <.live_component
+ module={MvWeb.Components.SortHeaderComponent}
+ id={:sort_street}
+ field={:street}
+ label={gettext("Street")}
+ sort_field={@sort_field}
+ sort_order={@sort_order}
+ />
+ """
+ }
+ >
+ {member.street}
+
+ <:col
+ :let={member}
+ :if={:house_number in @member_fields_visible}
+ label={
+ ~H"""
+ <.live_component
+ module={MvWeb.Components.SortHeaderComponent}
+ id={:sort_house_number}
+ field={:house_number}
+ label={gettext("House Number")}
+ sort_field={@sort_field}
+ sort_order={@sort_order}
+ />
+ """
+ }
+ >
+ {member.house_number}
+
+ <:col
+ :let={member}
+ :if={:postal_code in @member_fields_visible}
+ label={
+ ~H"""
+ <.live_component
+ module={MvWeb.Components.SortHeaderComponent}
+ id={:sort_postal_code}
+ field={:postal_code}
+ label={gettext("Postal Code")}
+ sort_field={@sort_field}
+ sort_order={@sort_order}
+ />
+ """
+ }
+ >
+ {member.postal_code}
+
+ <:col
+ :let={member}
+ :if={:membership_fee_start_date in @member_fields_visible}
+ label={
+ ~H"""
+ <.live_component
+ module={MvWeb.Components.SortHeaderComponent}
+ id={:sort_membership_fee_start_date}
+ field={:membership_fee_start_date}
+ label={gettext("Membership Fee Start Date")}
+ sort_field={@sort_field}
+ sort_order={@sort_order}
+ />
+ """
+ }
+ >
+ {MvWeb.MemberLive.Index.format_date(member.membership_fee_start_date)}
+
+ <:col
+ :let={member}
+ :if={:membership_fee_type in @member_fields_visible}
+ label={
+ ~H"""
+ <.live_component
+ module={MvWeb.Components.SortHeaderComponent}
+ id={:sort_membership_fee_type}
+ field={:membership_fee_type}
+ label={gettext("Fee Type")}
+ sort_field={@sort_field}
+ sort_order={@sort_order}
+ />
+ """
+ }
+ >
+ <%= if member.membership_fee_type do %>
+ {member.membership_fee_type.name}
+ <% else %>
+
—
+ <% end %>
+
+ <:col
+ :let={member}
+ :if={:membership_fee_status in @member_fields_visible}
+ label={gettext("Membership Fee Status")}
+ >
+ <%= if badge = MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge(
MvWeb.MemberLive.Index.MembershipFeeStatus.get_cycle_status_for_member(member, @show_current_cycle)
) do %>
-
- <.icon name={badge.icon} class="size-4" />
- {badge.label}
-
- <% else %>
-
{gettext("No cycle")}
- <% end %>
-
- <:col
- :let={member}
- :if={:groups in @member_fields_visible}
- label={
- ~H"""
- <.live_component
- module={MvWeb.Components.SortHeaderComponent}
- id={:sort_groups}
- field={:groups}
- label={gettext("Groups")}
- sort_field={@sort_field}
- sort_order={@sort_order}
- />
- """
- }
- >
- <%= for group <- (member.groups || []) do %>
-
- {group.name}
-
- <% end %>
- <%= if (member.groups || []) == [] do %>
-
—
- <% end %>
-
- <:action :let={member}>
-
- <.link navigate={~p"/members/#{member}"} data-testid="member-show-link">
- {gettext("Show")}
-
-
-
-
-
+
+ <.icon name={badge.icon} class="size-4" />
+ {badge.label}
+
+ <% else %>
+ {gettext("No cycle")}
+ <% end %>
+
+ <:col
+ :let={member}
+ :if={:groups in @member_fields_visible}
+ label={
+ ~H"""
+ <.live_component
+ module={MvWeb.Components.SortHeaderComponent}
+ id={:sort_groups}
+ field={:groups}
+ label={gettext("Groups")}
+ sort_field={@sort_field}
+ sort_order={@sort_order}
+ />
+ """
+ }
+ >
+ <%= for group <- (member.groups || []) do %>
+
+ {group.name}
+
+ <% end %>
+ <%= if (member.groups || []) == [] do %>
+ —
+ <% end %>
+
+ <:action :let={member}>
+
+ <.link navigate={~p"/members/#{member}"}>{gettext("Show")}
+
+
+ <%= if can?(@current_user, :update, member) do %>
+ <.link navigate={~p"/members/#{member}/edit"} data-testid="member-edit">
+ {gettext("Edit")}
+
+ <% end %>
+
+
+ <:action :let={member}>
+ <%= if can?(@current_user, :destroy, member) do %>
+ <.link
+ phx-click={JS.push("delete", value: %{id: member.id}) |> hide("#row-#{member.id}")}
+ data-confirm={gettext("Are you sure?")}
+ data-testid="member-delete"
+ >
+ {gettext("Delete")}
+
+ <% end %>
+
+
diff --git a/lib/mv_web/live/member_live/show.ex b/lib/mv_web/live/member_live/show.ex
index abe8453..c05da63 100644
--- a/lib/mv_web/live/member_live/show.ex
+++ b/lib/mv_web/live/member_live/show.ex
@@ -30,311 +30,235 @@ defmodule MvWeb.MemberLive.Show do
def render(assigns) do
~H"""
- <.header>
- <:leading>
+ <%!-- Header with Back button, Name, and Edit button --%>
+
+ <.button navigate={~p"/members"} aria-label={gettext("Back to members list")}>
+ <.icon name="hero-arrow-left" class="size-4" />
+ {gettext("Back")}
+
+
+
+ {MvWeb.Helpers.MemberHelpers.display_name(@member)}
+
+
+ <%= if can?(@current_user, :update, @member) do %>
<.button
- navigate={~p"/members?highlight=#{@member.id}"}
- variant="neutral"
- aria-label={gettext("Back to members list")}
+ variant="primary"
+ navigate={~p"/members/#{@member}/edit?return_to=show"}
+ data-testid="member-edit"
>
- <.icon name="hero-arrow-left" class="size-4" />
- {gettext("Back")}
+ {gettext("Edit Member")}
-
- {MvWeb.Helpers.MemberHelpers.display_name(@member)}
- <:actions>
- <%= if can?(@current_user, :update, @member) do %>
- <.button
- variant="primary"
- navigate={~p"/members/#{@member}/edit?return_to=show"}
- data-testid="member-edit"
- >
- <.icon name="hero-pencil-square" /> {gettext("Edit member")}
-
- <% end %>
-
-
-
-
- <%!-- Tab Navigation: surface only behind buttons (inline); tabs-bordered; tabs-lg; both tabs keyboard-focusable --%>
-
-
- <.icon name="hero-identification" class="size-4 shrink-0" />
- {gettext("Contact Data")}
-
-
- <.icon name="hero-credit-card" class="size-4 shrink-0" />
- {gettext("Membership Fees")}
-
-
-
- <%= if @active_tab == :contact do %>
- <%!-- Contact Data Tab Content --%>
-
- <% end %>
-
- <%= if @active_tab == :membership_fees do %>
- <%!-- Membership Fees Tab Content --%>
-
- <.live_component
- module={MvWeb.MemberLive.Show.MembershipFeesComponent}
- id={"membership-fees-#{@member.id}"}
- member={@member}
- current_user={@current_user}
- vereinfacht_receipts={@vereinfacht_receipts}
- />
-
- <% end %>
-
- <%!-- Danger zone: same section pattern as section_box (h2 outside border) --%>
- <%= if can?(@current_user, :destroy, @member) do %>
-
-
- {gettext("Danger zone")}
-
-
-
- {gettext(
- "Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed."
- )}
-
- <.button
- variant="danger"
- phx-click="delete"
- phx-value-id={@member.id}
- data-confirm={
- gettext("Are you sure you want to delete %{name}? This action cannot be undone.",
- name: MvWeb.Helpers.MemberHelpers.display_name(@member)
- )
- }
- data-testid="member-delete"
- aria-label={
- gettext("Delete member %{name}",
- name: MvWeb.Helpers.MemberHelpers.display_name(@member)
- )
- }
- >
- <.icon name="hero-trash" class="size-4" />
- {gettext("Delete member")}
-
-
-
<% end %>
+
+ <%!-- Tab Navigation --%>
+
+
+ <.icon name="hero-identification" class="size-4 mr-2" />
+ {gettext("Contact Data")}
+
+
+ <.icon name="hero-credit-card" class="size-4 mr-2" />
+ {gettext("Membership Fees")}
+
+
+
+ <%= if @active_tab == :contact do %>
+ <%!-- Contact Data Tab Content --%>
+ <%!-- Personal Data and Custom Fields Row --%>
+
+ <%!-- Personal Data Section --%>
+
+ <.section_box title={gettext("Personal Data")}>
+
+ <%!-- Name Row --%>
+
+ <.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" />
+
+
+ <%!-- Address --%>
+
+ <.data_field label={gettext("Address")} value={format_address(@member)} />
+
+
+ <%!-- Email --%>
+
+
+ <%!-- Membership Dates Row --%>
+
+ <.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"
+ />
+
+
+ <%!-- Linked User: only show when current user can see other users (e.g. admin).
+ read_only cannot see linked user, so hide the section to avoid "No user linked" when
+ a user is linked but not visible. --%>
+ <%= if can_access_page?(@current_user, "/users") do %>
+
+ <.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}
+
+ <% else %>
+ {gettext("No user linked")}
+ <% end %>
+
+
+ <% end %>
+
+ <%!-- Groups (in Personal Data) --%>
+ <% groups = @member.groups || [] %>
+
+ <.data_field label={gettext("Groups")}>
+ <%= if Enum.empty?(groups) do %>
+
{gettext("No groups")}
+ <% else %>
+
+ <%= for group <- groups do %>
+ <.link
+ navigate={~p"/groups/#{group.slug}"}
+ class="btn btn-xs btn-outline btn-primary"
+ aria-label={gettext("Member of group %{name}", name: group.name)}
+ >
+ {group.name}
+
+ <% end %>
+
+ <% end %>
+
+
+
+ <%!-- Notes --%>
+ <%= if @member.notes && String.trim(@member.notes) != "" do %>
+
+ <.data_field label={gettext("Notes")}>
+
{@member.notes}
+
+
+ <% end %>
+
+
+
+
+ <%!-- Custom Fields Section --%>
+ <%= if Enum.any?(@custom_fields) do %>
+
+ <.section_box title={gettext("Custom Fields")}>
+
+ <%= for custom_field <- @custom_fields do %>
+ <% cfv = find_custom_field_value(@member.custom_field_values, custom_field.id) %>
+ <.data_field label={custom_field.name}>
+ {format_custom_field_value(cfv, custom_field.value_type)}
+
+ <% end %>
+
+
+
+ <% end %>
+
+
+ <%!-- Payment Data Section --%>
+
+ <.section_box title={gettext("Payment Data")}>
+ <%= if @member.membership_fee_type do %>
+
+ <.data_field
+ label={gettext("Type")}
+ value={@member.membership_fee_type.name}
+ class="min-w-32"
+ />
+ <.data_field
+ label={gettext("Membership Fee")}
+ value={MembershipFeeHelpers.format_currency(@member.membership_fee_type.amount)}
+ class="min-w-24"
+ />
+ <.data_field
+ label={gettext("Payment Interval")}
+ value={MembershipFeeHelpers.format_interval(@member.membership_fee_type.interval)}
+ class="min-w-32"
+ />
+ <.data_field label={gettext("Last Cycle")} class="min-w-32">
+ <%= if @member.last_cycle_status do %>
+ <% status = @member.last_cycle_status %>
+
+ {format_status_label(status)}
+
+ <% else %>
+ {gettext("No cycles")}
+ <% end %>
+
+ <.data_field label={gettext("Current Cycle")} class="min-w-36">
+ <%= if @member.current_cycle_status do %>
+ <% status = @member.current_cycle_status %>
+
+ {format_status_label(status)}
+
+ <% else %>
+ {gettext("No cycles")}
+ <% end %>
+
+
+ <% else %>
+
+ {gettext("No membership fee type assigned")}
+
+ <% end %>
+
+
+ <% end %>
+
+ <%= if @active_tab == :membership_fees do %>
+ <%!-- Membership Fees Tab Content --%>
+ <.live_component
+ module={MvWeb.MemberLive.Show.MembershipFeesComponent}
+ id={"membership-fees-#{@member.id}"}
+ member={@member}
+ current_user={@current_user}
+ vereinfacht_receipts={@vereinfacht_receipts}
+ />
+ <% end %>
"""
end
@@ -396,37 +320,6 @@ defmodule MvWeb.MemberLive.Show do
{:noreply, assign(socket, :active_tab, :membership_fees)}
end
- @impl true
- def handle_event("delete", %{"id" => id}, socket) do
- member = socket.assigns.member
- actor = current_actor(socket)
-
- if to_string(id) != to_string(member.id) do
- {:noreply, put_flash(socket, :error, gettext("Member not found"))}
- else
- case Ash.destroy(member, actor: actor) do
- :ok ->
- {:noreply,
- socket
- |> put_flash(:success, gettext("Member deleted successfully"))
- |> push_navigate(to: ~p"/members")}
-
- {:error, %Ash.Error.Forbidden{}} ->
- {:noreply,
- put_flash(
- socket,
- :error,
- gettext("You do not have permission to delete this member")
- )}
-
- {:error, error} ->
- require Logger
- Logger.warning("Member delete failed: member_id=#{member.id} error=#{inspect(error)}")
- {:noreply, put_flash(socket, :error, format_error(error))}
- end
- end
- end
-
def handle_event("load_vereinfacht_receipts", %{"contact_id" => contact_id}, socket) do
response =
case Mv.Vereinfacht.Client.get_contact_with_receipts(contact_id) do
@@ -457,19 +350,6 @@ defmodule MvWeb.MemberLive.Show do
defp page_title(:show), do: gettext("Show Member")
defp page_title(:edit), do: gettext("Edit Member")
- defp format_error(%Ash.Error.Invalid{errors: errors}) do
- error_messages =
- Enum.map(errors, fn
- %{field: field, message: message} -> "#{field}: #{message}"
- %{message: message} -> message
- _ -> inspect(errors)
- end)
-
- Enum.join(error_messages, ", ")
- end
-
- defp format_error(error), do: inspect(error)
-
# -----------------------------------------------------------------
# Helper Components
# -----------------------------------------------------------------
@@ -523,7 +403,7 @@ defmodule MvWeb.MemberLive.Show do
~H"""
{@display}
diff --git a/lib/mv_web/live/member_live/show/membership_fees_component.ex b/lib/mv_web/live/member_live/show/membership_fees_component.ex
index 23f0dda..b8757a0 100644
--- a/lib/mv_web/live/member_live/show/membership_fees_component.ex
+++ b/lib/mv_web/live/member_live/show/membership_fees_component.ex
@@ -66,15 +66,14 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
<.icon name="hero-arrow-top-right-on-square" class="inline-block size-4" />
- <.button
+
{gettext("Show bookings/receipts from Vereinfacht")}
-
+
<%= if @vereinfacht_receipts do %>
<.button
:if={Enum.any?(@cycles) and @can_destroy_cycle}
- variant="outline"
- size="sm"
phx-click="delete_all_cycles"
phx-target={@myself}
+ class="btn btn-sm btn-error btn-outline"
title={gettext("Delete all cycles")}
>
<.icon name="hero-trash" class="size-4" />
@@ -160,10 +158,9 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
<.button
:if={@member.membership_fee_type != nil and @can_create_cycle}
- variant="primary"
- size="sm"
phx-click="open_create_cycle_modal"
phx-target={@myself}
+ class="btn btn-sm btn-primary"
title={gettext("Create a new cycle manually")}
>
<.icon name="hero-plus" class="size-4" />
@@ -262,18 +259,17 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
<% end %>
<%= if @can_destroy_cycle do %>
- <.button
+
<.icon name="hero-trash" class="size-4" />
{gettext("Delete")}
-
+
<% end %>
@@ -313,15 +309,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
/>
- <.button
- type="button"
- variant="neutral"
- phx-click="cancel_edit_amount"
- phx-target={@myself}
- >
+
{gettext("Cancel")}
-
- <.button type="submit" variant="primary">{gettext("Save")}
+
+ {gettext("Save")}
@@ -343,17 +334,17 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
)} - {MembershipFeeHelpers.format_currency(@deleting_cycle.amount)}
- <.button variant="neutral" phx-click="cancel_delete_cycle" phx-target={@myself}>
+
{gettext("Cancel")}
-
- <.button
- variant="danger"
+
+
{gettext("Delete")}
-
+
@@ -394,20 +385,20 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
/>
- <.button variant="neutral" phx-click="cancel_delete_all_cycles" phx-target={@myself}>
+
{gettext("Cancel")}
-
- <.button
- variant="danger"
+
+
{gettext("Delete All")}
-
+
@@ -481,15 +472,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
<% end %>
- <.button
- type="button"
- variant="neutral"
- phx-click="cancel_create_cycle"
- phx-target={@myself}
- >
+
{gettext("Cancel")}
-
- <.button type="submit" variant="primary">{gettext("Create")}
+
+ {gettext("Create")}
@@ -562,7 +548,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
get_available_fee_types(updated_member, actor)
)
|> assign(:interval_warning, nil)
- |> put_flash(:success, gettext("Membership fee type removed"))}
+ |> put_flash(:info, gettext("Membership fee type removed"))}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_error(error))}
@@ -621,7 +607,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
get_available_fee_types(updated_member, actor)
)
|> assign(:interval_warning, nil)
- |> put_flash(:success, gettext("Membership fee type updated. Cycles regenerated."))}
+ |> put_flash(:info, gettext("Membership fee type updated. Cycles regenerated."))}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_error(error))}
@@ -649,7 +635,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
{:noreply,
socket
|> assign(:cycles, updated_cycles)
- |> put_flash(:success, gettext("Cycle status updated"))}
+ |> put_flash(:info, gettext("Cycle status updated"))}
{:error, %Ash.Error.Invalid{} = error} ->
error_msg =
@@ -705,7 +691,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|> assign(:member, updated_member)
|> assign(:cycles, cycles)
|> assign(:regenerating, false)
- |> put_flash(:success, gettext("Cycles regenerated successfully"))}
+ |> put_flash(:info, gettext("Cycles regenerated successfully"))}
{:error, error} ->
{:noreply,
@@ -755,7 +741,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
socket
|> assign(:cycles, updated_cycles)
|> assign(:editing_cycle, nil)
- |> put_flash(:success, gettext("Cycle amount updated"))}
+ |> put_flash(:info, gettext("Cycle amount updated"))}
{:error, error} ->
{:noreply,
@@ -794,7 +780,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
socket
|> assign(:cycles, updated_cycles)
|> assign(:deleting_cycle, nil)
- |> put_flash(:success, gettext("Cycle deleted"))}
+ |> put_flash(:info, gettext("Cycle deleted"))}
{:ok, _destroyed} ->
# Handle case where return_destroyed? is true
@@ -804,7 +790,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
socket
|> assign(:cycles, updated_cycles)
|> assign(:deleting_cycle, nil)
- |> put_flash(:success, gettext("Cycle deleted"))}
+ |> put_flash(:info, gettext("Cycle deleted"))}
{:error, error} ->
{:noreply,
@@ -950,7 +936,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|> assign(:creating_cycle, false)
|> assign(:create_cycle_date, nil)
|> assign(:create_cycle_error, nil)
- |> put_flash(:success, gettext("Cycle created successfully"))}
+ |> put_flash(:info, gettext("Cycle created successfully"))}
{:error, error} ->
{:noreply,
@@ -1013,7 +999,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|> assign(:member, updated_member)
|> assign(:cycles, updated_cycles)
|> reset_modal.()
- |> put_flash(:success, gettext("All cycles deleted"))}
+ |> put_flash(:info, gettext("All cycles deleted"))}
{:ok, _} ->
{:noreply,
diff --git a/lib/mv_web/live/membership_fee_settings_live.ex b/lib/mv_web/live/membership_fee_settings_live.ex
index 84ce662..bfa20f8 100644
--- a/lib/mv_web/live/membership_fee_settings_live.ex
+++ b/lib/mv_web/live/membership_fee_settings_live.ex
@@ -82,7 +82,7 @@ defmodule MvWeb.MembershipFeeSettingsLive do
{:noreply,
socket
|> assign(:settings, updated_settings)
- |> put_flash(:success, gettext("Settings saved successfully."))
+ |> put_flash(:info, gettext("Settings saved successfully."))
|> assign_form()}
{:error, form} ->
@@ -105,7 +105,7 @@ defmodule MvWeb.MembershipFeeSettingsLive do
socket
|> assign(:membership_fee_types, updated_types)
|> assign(:member_counts, updated_counts)
- |> put_flash(:success, gettext("Membership fee type deleted"))}
+ |> put_flash(:info, gettext("Membership fee type deleted"))}
{:error, %Ash.Error.Forbidden{}} ->
{:noreply,
@@ -239,10 +239,10 @@ defmodule MvWeb.MembershipFeeSettingsLive do
- <.button type="submit" variant="primary" class="w-full">
+
<.icon name="hero-check" class="size-5" />
{gettext("Save Settings")}
-
+
@@ -333,27 +333,24 @@ defmodule MvWeb.MembershipFeeSettingsLive do
<:action :let={mft}>
- <.tooltip content={gettext("Edit membership fee type")} position="left">
- <.button
- variant="ghost"
- size="sm"
- navigate={~p"/membership_fee_settings/#{mft.id}/edit_fee_type"}
- aria-label={gettext("Edit membership fee type")}
- >
- <.icon name="hero-pencil" class="size-4" />
-
-
+ <.link
+ navigate={~p"/membership_fee_settings/#{mft.id}/edit_fee_type"}
+ class="btn btn-ghost btn-xs"
+ aria-label={gettext("Edit membership fee type")}
+ >
+ <.icon name="hero-pencil" class="size-4" />
+
<:action :let={mft}>
- <.tooltip
+
0}
- content={
+ class="tooltip tooltip-left"
+ data-tip={
gettext("Cannot delete - %{count} member(s) assigned",
count: get_member_count(mft, @member_counts)
)
}
- position="left"
>
<.icon name="hero-trash" class="size-4" />
-
- <.button
+
+
<.icon name="hero-trash" class="size-4" />
-
+
diff --git a/lib/mv_web/live/membership_fee_type_live/form.ex b/lib/mv_web/live/membership_fee_type_live/form.ex
index 237b4b4..d8569e2 100644
--- a/lib/mv_web/live/membership_fee_type_live/form.ex
+++ b/lib/mv_web/live/membership_fee_type_live/form.ex
@@ -27,26 +27,10 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
~H"""
<.header>
- <:leading>
- <.button navigate={return_path(@return_to, @membership_fee_type)} variant="neutral">
- <.icon name="hero-arrow-left" class="size-4" />
- {gettext("Back")}
-
-
{@page_title}
<:subtitle>
{gettext("Use this form to manage membership fee types in your database.")}
- <:actions>
- <.button
- form="membership-fee-type-form"
- phx-disable-with={gettext("Saving...")}
- variant="primary"
- type="submit"
- >
- {gettext("Save")}
-
-
<.form
@@ -192,20 +176,20 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
- <.button
+
{gettext("Cancel")}
-
- <.button
+
+
{gettext("Confirm Change")}
-
+
@@ -333,7 +317,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
socket =
socket
- |> put_flash(:success, gettext("Membership fee type saved successfully"))
+ |> put_flash(:info, gettext("Membership fee type saved successfully"))
|> push_navigate(to: return_path(socket.assigns.return_to, membership_fee_type))
{:noreply, socket}
diff --git a/lib/mv_web/live/membership_fee_type_live/index.ex b/lib/mv_web/live/membership_fee_type_live/index.ex
index ee3b791..f5f760f 100644
--- a/lib/mv_web/live/membership_fee_type_live/index.ex
+++ b/lib/mv_web/live/membership_fee_type_live/index.ex
@@ -78,27 +78,24 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
<:action :let={mft}>
- <.tooltip content={gettext("Edit membership fee type")} position="left">
- <.button
- variant="ghost"
- size="sm"
- navigate={~p"/membership_fee_settings/#{mft.id}/edit_fee_type"}
- aria-label={gettext("Edit membership fee type")}
- >
- <.icon name="hero-pencil" class="size-4" />
-
-
+ <.link
+ navigate={~p"/membership_fee_settings/#{mft.id}/edit_fee_type"}
+ class="btn btn-ghost btn-xs"
+ aria-label={gettext("Edit membership fee type")}
+ >
+ <.icon name="hero-pencil" class="size-4" />
+
<:action :let={mft}>
- <.tooltip
+ 0}
- content={
+ class="tooltip tooltip-left"
+ data-tip={
gettext("Cannot delete - %{count} member(s) assigned",
count: get_member_count(mft, @member_counts)
)
}
- position="left"
>
<.icon name="hero-trash" class="size-4" />
-
- <.button
+
+
<.icon name="hero-trash" class="size-4" />
-
+
@@ -149,7 +145,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
socket
|> assign(:membership_fee_types, updated_types)
|> assign(:member_counts, updated_counts)
- |> put_flash(:success, gettext("Membership fee type deleted"))}
+ |> put_flash(:info, gettext("Membership fee type deleted"))}
{:error, %Ash.Error.Forbidden{}} ->
{:noreply,
diff --git a/lib/mv_web/live/role_live/form.ex b/lib/mv_web/live/role_live/form.ex
index e066555..ea76fe8 100644
--- a/lib/mv_web/live/role_live/form.ex
+++ b/lib/mv_web/live/role_live/form.ex
@@ -21,70 +21,66 @@ defmodule MvWeb.RoleLive.Form do
def render(assigns) do
~H"""
+ <.header>
+ {@page_title}
+ <:subtitle>{gettext("Use this form to manage roles in your database.")}
+
+
<.form class="max-w-xl" for={@form} id="role-form" phx-change="validate" phx-submit="save">
- <.header>
- <:leading>
- <.button navigate={return_path(@return_to, @role)} variant="neutral">
- <.icon name="hero-arrow-left" class="size-4" />
- {gettext("Back")}
-
-
- {@page_title}
- <:subtitle>{gettext("Use this form to manage roles in your database.")}
- <:actions>
- <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
- {gettext("Save")}
-
-
-
+ <.input field={@form[:name]} type="text" label={gettext("Name")} required />
-
- <.input field={@form[:name]} type="text" label={gettext("Name")} required />
+ <.input
+ field={@form[:description]}
+ type="textarea"
+ label={gettext("Description")}
+ rows="3"
+ />
- <.input
- field={@form[:description]}
- type="textarea"
- label={gettext("Description")}
- rows="3"
- />
-
-
+
+
+ <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
+ {gettext("Save Role")}
+
+ <.button navigate={return_path(@return_to, @role)} type="button">
+ {gettext("Cancel")}
+
@@ -179,7 +175,7 @@ defmodule MvWeb.RoleLive.Form do
socket =
socket
- |> put_flash(:success, gettext("Role saved successfully."))
+ |> put_flash(:info, gettext("Role saved successfully."))
|> push_navigate(to: redirect_path)
{:noreply, socket}
diff --git a/lib/mv_web/live/role_live/index.ex b/lib/mv_web/live/role_live/index.ex
index ed64eb7..091b191 100644
--- a/lib/mv_web/live/role_live/index.ex
+++ b/lib/mv_web/live/role_live/index.ex
@@ -5,8 +5,11 @@ defmodule MvWeb.RoleLive.Index do
## Features
- List all roles with name, description, permission_set_name, is_system_role
- Create new roles
- - Navigate to role details (row click) and edit from details header
- - Delete only via Danger zone on role show page
+ - Navigate to role details and edit forms
+ - Delete non-system roles
+
+ ## Events
+ - `delete` - Remove a role from the database (only non-system roles)
## Security
Only admins can access this page (enforced by authorization).
@@ -18,7 +21,8 @@ defmodule MvWeb.RoleLive.Index do
require Ash.Query
- import MvWeb.RoleLive.Helpers, only: [permission_set_badge_class: 1]
+ import MvWeb.RoleLive.Helpers,
+ only: [format_error: 1, permission_set_badge_class: 1, opts_with_actor: 3]
@impl true
def mount(_params, _session, socket) do
@@ -33,6 +37,83 @@ defmodule MvWeb.RoleLive.Index do
|> assign(:user_counts, user_counts)}
end
+ @impl true
+ def handle_event("delete", %{"id" => id}, socket) do
+ case Authorization.get_role(id, actor: socket.assigns.current_user) do
+ {:ok, role} ->
+ handle_delete_role(role, id, socket)
+
+ {:error, %Ash.Error.Query.NotFound{}} ->
+ {:noreply,
+ put_flash(
+ socket,
+ :error,
+ gettext("Role not found.")
+ )}
+
+ {:error, error} ->
+ error_message = format_error(error)
+
+ {:noreply,
+ put_flash(
+ socket,
+ :error,
+ gettext("Failed to delete role: %{error}", error: error_message)
+ )}
+ end
+ end
+
+ defp handle_delete_role(role, id, socket) do
+ if role.is_system_role do
+ {:noreply,
+ put_flash(
+ socket,
+ :error,
+ gettext("System roles cannot be deleted.")
+ )}
+ else
+ user_count = recalculate_user_count(role, socket.assigns.current_user)
+
+ if user_count > 0 do
+ {:noreply,
+ put_flash(
+ socket,
+ :error,
+ gettext(
+ "Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first.",
+ count: user_count
+ )
+ )}
+ else
+ perform_role_deletion(role, id, socket)
+ end
+ end
+ end
+
+ defp perform_role_deletion(role, id, socket) do
+ case Authorization.destroy_role(role, actor: socket.assigns.current_user) do
+ :ok ->
+ updated_roles = Enum.reject(socket.assigns.roles, &(&1.id == id))
+ updated_counts = Map.delete(socket.assigns.user_counts, id)
+
+ {:noreply,
+ socket
+ |> assign(:roles, updated_roles)
+ |> assign(:user_counts, updated_counts)
+ |> put_flash(:info, gettext("Role deleted successfully."))}
+
+ {:error, error} ->
+ error_message = format_error(error)
+
+ {:noreply,
+ put_flash(
+ socket,
+ :error,
+ gettext("Failed to delete role: %{error}", error: error_message)
+ )}
+ end
+ end
+
@spec load_roles(map() | nil) :: [Mv.Authorization.Role.t()]
defp load_roles(actor) do
opts = MvWeb.LiveHelpers.ash_actor_opts(actor)
@@ -73,4 +154,15 @@ defmodule MvWeb.RoleLive.Index do
defp get_user_count(role, user_counts) do
Map.get(user_counts, role.id, 0)
end
+
+ # Recalculates user count for a specific role (used before deletion)
+ @spec recalculate_user_count(Mv.Authorization.Role.t(), map() | nil) :: non_neg_integer()
+ defp recalculate_user_count(role, actor) do
+ opts = opts_with_actor([], actor, Mv.Accounts)
+
+ case Ash.count(Accounts.User |> Ash.Query.filter(role_id == ^role.id), opts) do
+ {:ok, count} -> count
+ _ -> 0
+ end
+ end
end
diff --git a/lib/mv_web/live/role_live/index.html.heex b/lib/mv_web/live/role_live/index.html.heex
index 43f2fc7..f409944 100644
--- a/lib/mv_web/live/role_live/index.html.heex
+++ b/lib/mv_web/live/role_live/index.html.heex
@@ -17,7 +17,6 @@
id="roles"
rows={@roles}
row_click={fn role -> JS.navigate(~p"/admin/roles/#{role}") end}
- row_tooltip={gettext("Click for role details")}
>
<:col :let={role} label={gettext("Name")}>
@@ -53,5 +52,46 @@
<:col :let={role} label={gettext("Users")}>
{get_user_count(role, @user_counts)}
+
+ <:action :let={role}>
+
+ <.link navigate={~p"/admin/roles/#{role}"}>{gettext("Show")}
+
+
+ <%= if can?(@current_user, :update, Mv.Authorization.Role) do %>
+ <.link navigate={~p"/admin/roles/#{role}/edit"} class="btn btn-ghost btn-sm">
+ <.icon name="hero-pencil" class="size-4" />
+ {gettext("Edit")}
+
+ <% end %>
+
+
+ <:action :let={role}>
+ <%= if can?(@current_user, :destroy, Mv.Authorization.Role) and not role.is_system_role do %>
+ <.link
+ phx-click={JS.push("delete", value: %{id: role.id}) |> hide("#row-#{role.id}")}
+ data-confirm={gettext("Are you sure?")}
+ class="btn btn-ghost btn-sm text-error"
+ >
+ <.icon name="hero-trash" class="size-4" />
+ {gettext("Delete")}
+
+ <% else %>
+
+
+ <.icon name="hero-trash" class="size-4" />
+ {gettext("Delete")}
+
+
+ <% end %>
+
diff --git a/lib/mv_web/live/role_live/show.ex b/lib/mv_web/live/role_live/show.ex
index 4f36eca..0e1c7ca 100644
--- a/lib/mv_web/live/role_live/show.ex
+++ b/lib/mv_web/live/role_live/show.ex
@@ -124,7 +124,7 @@ defmodule MvWeb.RoleLive.Show do
:ok ->
{:noreply,
socket
- |> put_flash(:success, gettext("Role deleted successfully."))
+ |> put_flash(:info, gettext("Role deleted successfully."))
|> push_navigate(to: ~p"/admin/roles")}
{:error, error} ->
@@ -161,29 +161,28 @@ defmodule MvWeb.RoleLive.Show do
~H"""
<.header>
- <:leading>
- <.button
- navigate={~p"/admin/roles"}
- variant="neutral"
- aria-label={gettext("Back to roles list")}
- >
- <.icon name="hero-arrow-left" class="size-4" />
- {gettext("Back")}
-
-
{gettext("Role")} {@role.name}
<:subtitle>{gettext("Role details and permissions.")}
<:actions>
+ <.button navigate={~p"/admin/roles"} aria-label={gettext("Back to roles list")}>
+ <.icon name="hero-arrow-left" />
+ {gettext("Back to roles list")}
+
<%= if can?(@current_user, :update, Mv.Authorization.Role) do %>
- <.button
- variant="primary"
- navigate={~p"/admin/roles/#{@role}/edit"}
- data-testid="role-show-edit-btn"
- >
- <.icon name="hero-pencil-square" /> {gettext("Edit role")}
+ <.button variant="primary" navigate={~p"/admin/roles/#{@role}/edit"}>
+ <.icon name="hero-pencil-square" /> {gettext("Edit Role")}
<% end %>
+ <%= if can?(@current_user, :destroy, Mv.Authorization.Role) and not @role.is_system_role do %>
+ <.link
+ phx-click={JS.push("delete", value: %{id: @role.id})}
+ data-confirm={gettext("Are you sure?")}
+ class="btn btn-error"
+ >
+ <.icon name="hero-trash" /> {gettext("Delete Role")}
+
+ <% end %>
@@ -209,37 +208,6 @@ defmodule MvWeb.RoleLive.Show do
<% end %>
-
- <%!-- Danger zone: canonical pattern (same as member show) --%>
- <%= if can?(@current_user, :destroy, Mv.Authorization.Role) and not @role.is_system_role do %>
-
-
- {gettext("Danger zone")}
-
-
-
- {gettext(
- "Deleting this role cannot be undone. Users assigned to this role must be reassigned first."
- )}
-
- <.button
- variant="danger"
- phx-click={JS.push("delete", value: %{id: @role.id})}
- data-confirm={
- gettext(
- "Are you sure you want to delete the role %{name}? This action cannot be undone.",
- name: @role.name
- )
- }
- data-testid="role-delete"
- aria-label={gettext("Delete role %{name}", name: @role.name)}
- >
- <.icon name="hero-trash" class="size-4" />
- {gettext("Delete role")}
-
-
-
- <% end %>
"""
end
diff --git a/lib/mv_web/live/user_live/form.ex b/lib/mv_web/live/user_live/form.ex
index f7c440d..46e23b3 100644
--- a/lib/mv_web/live/user_live/form.ex
+++ b/lib/mv_web/live/user_live/form.ex
@@ -39,31 +39,14 @@ defmodule MvWeb.UserLive.Form do
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
import MvWeb.Authorization, only: [can?: 3]
- import MvWeb.ErrorHelpers, only: [format_ash_error: 1]
@impl true
def render(assigns) do
~H"""
<.header>
- <:leading>
- <.button navigate={return_path(@return_to, @user)} variant="neutral">
- <.icon name="hero-arrow-left" class="size-4" />
- {gettext("Back")}
-
-
{@page_title}
<:subtitle>{gettext("Use this form to manage user records in your database.")}
- <:actions>
- <.button
- form="user-form"
- phx-disable-with={gettext("Saving...")}
- variant="primary"
- type="submit"
- >
- {gettext("Save User")}
-
-
<.form class="max-w-xl" for={@form} id="user-form" phx-change="validate" phx-submit="save">
@@ -184,14 +167,13 @@ defmodule MvWeb.UserLive.Form do
{@user.member.email}
- <.button
+
{gettext("Unlink Member")}
-
+
<% else %>
@@ -298,46 +280,11 @@ defmodule MvWeb.UserLive.Form do
<% end %>
- <%!-- Danger zone: canonical pattern (same as member form) --%>
- <%= if @user && can?(@current_user, :destroy, @user) && !Mv.Helpers.SystemActor.system_user?(@user) do %>
-
-
- {gettext("Danger zone")}
-
-
-
- {gettext(
- "Deleting this user cannot be undone. The user account and any linked member association will be affected."
- )}
-
- <.button
- type="button"
- variant="danger"
- phx-click="delete"
- phx-value-id={@user.id}
- data-confirm={
- gettext(
- "Are you sure you want to delete the user %{email}? This action cannot be undone.",
- email: @user.email
- )
- }
- data-testid="user-delete"
- aria-label={gettext("Delete user %{email}", email: @user.email)}
- >
- <.icon name="hero-trash" class="size-4" />
- {gettext("Delete user")}
-
-
-
- <% end %>
-
- <.button navigate={return_path(@return_to, @user)} variant="neutral">
- {gettext("Cancel")}
-
<.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save User")}
+ <.button navigate={return_path(@return_to, @user)}>{gettext("Cancel")}
@@ -454,26 +401,6 @@ defmodule MvWeb.UserLive.Form do
end
end
- @impl true
- def handle_event("delete", %{"id" => id}, socket) do
- user = socket.assigns.user
- actor = current_actor(socket)
-
- cond do
- is_nil(user) ->
- {:noreply, put_flash(socket, :error, gettext("User not found"))}
-
- to_string(id) != to_string(user.id) ->
- {:noreply, put_flash(socket, :error, gettext("User not found"))}
-
- Mv.Helpers.SystemActor.system_user?(user) ->
- {:noreply, put_flash(socket, :error, gettext("System user cannot be deleted."))}
-
- true ->
- handle_user_delete_destroy(socket, user, actor)
- end
- end
-
@impl true
def handle_event("show_member_dropdown", _params, socket) do
{:noreply, assign(socket, show_member_dropdown: true)}
@@ -584,23 +511,6 @@ defmodule MvWeb.UserLive.Form do
{:noreply, socket}
end
- defp handle_user_delete_destroy(socket, user, actor) do
- case Ash.destroy(user, domain: Mv.Accounts, actor: actor) do
- :ok ->
- {:noreply,
- socket
- |> put_flash(:success, gettext("User deleted successfully"))
- |> push_navigate(to: ~p"/users")}
-
- {:error, %Ash.Error.Forbidden{}} ->
- {:noreply,
- put_flash(socket, :error, gettext("You do not have permission to delete this user"))}
-
- {:error, error} ->
- {:noreply, put_flash(socket, :error, format_ash_error(error))}
- end
- end
-
defp handle_member_linking(socket, user, actor) do
result = perform_member_link_action(socket, user, actor)
@@ -643,7 +553,7 @@ defmodule MvWeb.UserLive.Form do
socket =
socket
- |> put_flash(:success, gettext("User %{action} successfully", action: action))
+ |> put_flash(:info, gettext("User %{action} successfully", action: action))
|> push_navigate(to: return_path(socket.assigns.return_to, updated_user))
{:noreply, socket}
diff --git a/lib/mv_web/live/user_live/index.ex b/lib/mv_web/live/user_live/index.ex
index 4858202..72cc55c 100644
--- a/lib/mv_web/live/user_live/index.ex
+++ b/lib/mv_web/live/user_live/index.ex
@@ -5,12 +5,18 @@ defmodule MvWeb.UserLive.Index do
## Features
- List all users with email and linked member
- Sort users by email (default)
- - Navigate to user details (row click) and edit from details header
- - Delete only via Danger zone on user show/edit
+ - Delete users
+ - Navigate to user details and edit forms
+ - Bulk selection for future batch operations
## Relationships
Displays linked member information when a user is connected to a member account.
+ ## Events
+ - `delete` - Remove a user from the database
+ - `select_user` - Toggle individual user selection
+ - `select_all` - Toggle selection of all visible users
+
## Security
User deletion requires admin permissions (enforced by Ash policies).
"""
@@ -20,6 +26,7 @@ defmodule MvWeb.UserLive.Index do
import MvWeb.LiveHelpers, only: [current_actor: 1]
require Ash.Query
+ import MvWeb.ErrorHelpers, only: [format_ash_error: 1]
@impl true
def mount(_params, _session, socket) do
@@ -37,7 +44,63 @@ defmodule MvWeb.UserLive.Index do
|> assign(:page_title, gettext("Listing Users"))
|> assign(:sort_field, :email)
|> assign(:sort_order, :asc)
- |> assign(:users, sorted)}
+ |> assign(:users, sorted)
+ |> assign(:selected_users, [])}
+ end
+
+ @impl true
+ def handle_event("delete", %{"id" => id}, socket) do
+ actor = current_actor(socket)
+
+ case Ash.get(Mv.Accounts.User, id, domain: Mv.Accounts, actor: actor) do
+ {:ok, user} ->
+ case Ash.destroy(user, domain: Mv.Accounts, actor: actor) do
+ :ok ->
+ updated_users = Enum.reject(socket.assigns.users, &(&1.id == id))
+
+ {:noreply,
+ socket
+ |> assign(:users, updated_users)
+ |> put_flash(:info, gettext("User deleted successfully"))}
+
+ {:error, %Ash.Error.Forbidden{}} ->
+ {:noreply,
+ put_flash(
+ socket,
+ :error,
+ gettext("You do not have permission to delete this user")
+ )}
+
+ {:error, error} ->
+ {:noreply, put_flash(socket, :error, format_ash_error(error))}
+ end
+
+ {:error, %Ash.Error.Query.NotFound{}} ->
+ {:noreply, put_flash(socket, :error, gettext("User not found"))}
+
+ {:error, %Ash.Error.Forbidden{} = _error} ->
+ {:noreply,
+ put_flash(socket, :error, gettext("You do not have permission to access this user"))}
+
+ {:error, error} ->
+ {:noreply, put_flash(socket, :error, format_ash_error(error))}
+ end
+ end
+
+ # Selects one user in the list of users
+ @impl true
+ def handle_event("select_user", %{"id" => id}, socket) do
+ # Normalize ID to string for consistent comparison
+ id_str = to_string(id)
+
+ selected =
+ if id_str in socket.assigns.selected_users do
+ List.delete(socket.assigns.selected_users, id_str)
+ else
+ [id_str | socket.assigns.selected_users]
+ end
+
+ {:noreply, assign(socket, :selected_users, selected)}
end
# Sorts the list of users according to a field, when you click on the column header
@@ -64,6 +127,24 @@ defmodule MvWeb.UserLive.Index do
|> assign(:users, sorted_users)}
end
+ # Selects all users in the list of users
+ @impl true
+ def handle_event("select_all", _params, socket) do
+ users = socket.assigns.users
+
+ # Normalize IDs to strings for consistent comparison
+ all_ids = Enum.map(users, &to_string(&1.id))
+
+ selected =
+ if Enum.sort(socket.assigns.selected_users) == Enum.sort(all_ids) do
+ []
+ else
+ all_ids
+ end
+
+ {:noreply, assign(socket, :selected_users, selected)}
+ end
+
defp toggle_order(:asc), do: :desc
defp toggle_order(:desc), do: :asc
defp sort_fun(:asc), do: &<=/2
diff --git a/lib/mv_web/live/user_live/index.html.heex b/lib/mv_web/live/user_live/index.html.heex
index 86f0ab7..ab13f90 100644
--- a/lib/mv_web/live/user_live/index.html.heex
+++ b/lib/mv_web/live/user_live/index.html.heex
@@ -15,10 +15,36 @@
rows={@users}
row_id={fn user -> "row-#{user.id}" end}
row_click={fn user -> JS.navigate(~p"/users/#{user}") end}
- row_tooltip={gettext("Click for user details")}
sort_field={@sort_field}
sort_order={@sort_order}
>
+ <:col
+ :let={user}
+ label={
+ ~H"""
+ <.input
+ type="checkbox"
+ name="select_all"
+ phx-click="select_all"
+ checked={Enum.sort(@selected_users) == Enum.map(@users, &to_string(&1.id)) |> Enum.sort()}
+ aria-label={gettext("Select all users")}
+ role="checkbox"
+ />
+ """
+ }
+ >
+ <.input
+ type="checkbox"
+ name={to_string(user.id)}
+ phx-click="select_user"
+ phx-value-id={to_string(user.id)}
+ checked={to_string(user.id) in @selected_users}
+ phx-capture-click
+ phx-stop-propagation
+ aria-label={gettext("Select user")}
+ role="checkbox"
+ />
+
<:col
:let={user}
sort_field={:email}
@@ -57,5 +83,29 @@
—
<% end %>
+
+ <:action :let={user}>
+
+ <.link navigate={~p"/users/#{user}"}>{gettext("Show")}
+
+
+ <%= if can?(@current_user, :update, user) do %>
+ <.link navigate={~p"/users/#{user}/edit"} data-testid="user-edit">
+ {gettext("Edit")}
+
+ <% end %>
+
+
+ <:action :let={user}>
+ <%= if can?(@current_user, :destroy, user) do %>
+ <.link
+ phx-click={JS.push("delete", value: %{id: user.id}) |> hide("#row-#{user.id}")}
+ data-confirm={gettext("Are you sure?")}
+ data-testid="user-delete"
+ >
+ {gettext("Delete")}
+
+ <% end %>
+
diff --git a/lib/mv_web/live/user_live/show.ex b/lib/mv_web/live/user_live/show.ex
index d7a12b2..4d803cd 100644
--- a/lib/mv_web/live/user_live/show.ex
+++ b/lib/mv_web/live/user_live/show.ex
@@ -27,27 +27,20 @@ defmodule MvWeb.UserLive.Show do
use MvWeb, :live_view
import MvWeb.LiveHelpers, only: [current_actor: 1]
- import MvWeb.ErrorHelpers, only: [format_ash_error: 1]
@impl true
def render(assigns) do
~H"""
<.header>
- <:leading>
- <.button
- navigate={~p"/users"}
- variant="neutral"
- aria-label={gettext("Back to users list")}
- >
- <.icon name="hero-arrow-left" class="size-4" />
- {gettext("Back")}
-
-
{gettext("User")} {@user.email}
<:subtitle>{gettext("This is a user record from your database.")}
<:actions>
+ <.button navigate={~p"/users"} aria-label={gettext("Back to users list")}>
+ <.icon name="hero-arrow-left" />
+ {gettext("Back to users list")}
+
<%= if can?(@current_user, :update, @user) do %>
<.button
variant="primary"
@@ -87,38 +80,6 @@ defmodule MvWeb.UserLive.Show do
<% end %>
-
- <%!-- Danger zone: canonical pattern (same as member show) --%>
- <%= if can?(@current_user, :destroy, @user) and not Mv.Helpers.SystemActor.system_user?(@user) do %>
-
-
- {gettext("Danger zone")}
-
-
-
- {gettext(
- "Deleting this user cannot be undone. The user account and any linked member association will be affected."
- )}
-
- <.button
- variant="danger"
- phx-click="delete"
- phx-value-id={@user.id}
- data-confirm={
- gettext(
- "Are you sure you want to delete the user %{email}? This action cannot be undone.",
- email: @user.email
- )
- }
- data-testid="user-delete"
- aria-label={gettext("Delete user %{email}", email: @user.email)}
- >
- <.icon name="hero-trash" class="size-4" />
- {gettext("Delete user")}
-
-
-
- <% end %>
"""
end
@@ -142,38 +103,4 @@ defmodule MvWeb.UserLive.Show do
|> assign(:user, user)}
end
end
-
- @impl true
- def handle_event("delete", %{"id" => id}, socket) do
- user = socket.assigns.user
- actor = current_actor(socket)
-
- cond do
- to_string(id) != to_string(user.id) ->
- {:noreply, put_flash(socket, :error, gettext("User not found"))}
-
- Mv.Helpers.SystemActor.system_user?(user) ->
- {:noreply, put_flash(socket, :error, gettext("System user cannot be deleted."))}
-
- true ->
- handle_user_delete_destroy(socket, user, actor)
- end
- end
-
- defp handle_user_delete_destroy(socket, user, actor) do
- case Ash.destroy(user, domain: Mv.Accounts, actor: actor) do
- :ok ->
- {:noreply,
- socket
- |> put_flash(:success, gettext("User deleted successfully"))
- |> push_navigate(to: ~p"/users")}
-
- {:error, %Ash.Error.Forbidden{}} ->
- {:noreply,
- put_flash(socket, :error, gettext("You do not have permission to delete this user"))}
-
- {:error, error} ->
- {:noreply, put_flash(socket, :error, format_ash_error(error))}
- end
- end
end
diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po
index 2f3a1f8..3dc41b2 100644
--- a/priv/gettext/de/LC_MESSAGES/default.po
+++ b/priv/gettext/de/LC_MESSAGES/default.po
@@ -11,13 +11,18 @@ msgstr ""
"Language: de\n"
#: lib/mv_web/components/core_components.ex
+#: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Actions"
msgstr "Aktionen"
+#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
+#: lib/mv_web/live/role_live/index.html.heex
+#: lib/mv_web/live/role_live/show.ex
+#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Are you sure?"
msgstr "Bist du sicher?"
@@ -35,14 +40,25 @@ msgstr "Verbindung wird wiederhergestellt"
msgid "City"
msgstr "Stadt"
+#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/group_live/show.ex
+#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#: lib/mv_web/live/role_live/index.html.heex
+#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Delete"
msgstr "Löschen"
+#: lib/mv_web/live/custom_field_live/index_component.ex
+#: lib/mv_web/live/group_live/index.ex
+#: lib/mv_web/live/group_live/show.ex
+#: lib/mv_web/live/member_field_live/index_component.ex
+#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/role_live/form.ex
+#: lib/mv_web/live/role_live/index.html.heex
#: lib/mv_web/live/user_live/form.ex
+#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Edit"
msgstr "Bearbeiten"
@@ -94,6 +110,8 @@ msgid "New Member"
msgstr "Neues Mitglied"
#: lib/mv_web/live/member_live/index.html.heex
+#: lib/mv_web/live/role_live/index.html.heex
+#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Show"
msgstr "Anzeigen"
@@ -259,6 +277,7 @@ msgstr "Dein Passwort wurde erfolgreich zurückgesetzt"
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
+#: lib/mv_web/live/role_live/form.ex
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Cancel"
@@ -472,6 +491,16 @@ msgstr "Passwort"
msgid "Password requirements"
msgstr "Passwort-Anforderungen"
+#: lib/mv_web/live/user_live/index.html.heex
+#, elixir-autogen, elixir-format
+msgid "Select all users"
+msgstr "Alle Benutzer*innen auswählen"
+
+#: lib/mv_web/live/user_live/index.html.heex
+#, elixir-autogen, elixir-format
+msgid "Select user"
+msgstr "Benutzer*in auswählen"
+
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Set Password"
@@ -587,6 +616,7 @@ msgstr "E-Mail kann nicht aktualisiert werden: Diese E-Mail-Adresse ist bereits
msgid "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account."
msgstr "Diese E-Mail-Adresse ist bereits mit einem anderen OIDC-Konto verknüpft. Es können nicht mehrere OIDC-Provider mit demselben Konto verknüpft werden."
+#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
@@ -605,6 +635,11 @@ msgstr[1] "%{count} Mitglieder haben Werte für dieses benutzerdefinierte Feld z
msgid "All custom field values will be permanently deleted when you delete this custom field."
msgstr "Alle benutzerdefinierten Feldwerte werden beim Löschen dieses benutzerdefinierten Feldes dauerhaft gelöscht."
+#: lib/mv_web/live/custom_field_live/index_component.ex
+#, elixir-autogen, elixir-format
+msgid "Delete Custom Field and All Values"
+msgstr "Benutzerdefiniertes Feld und alle Werte löschen"
+
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format
msgid "Enter the text above to confirm"
@@ -638,6 +673,7 @@ msgstr "Vereinsdaten"
msgid "Manage global settings for the association."
msgstr "Passe übergreifende Einstellungen für den Verein an."
+#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Save Settings"
@@ -754,21 +790,19 @@ msgstr "Alle"
msgid "Address"
msgstr "Adresse"
-#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/group_live/show.ex
-#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
-#: lib/mv_web/live/membership_fee_type_live/form.ex
-#: lib/mv_web/live/role_live/form.ex
-#: lib/mv_web/live/role_live/show.ex
-#: lib/mv_web/live/user_live/form.ex
-#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format
msgid "Back"
msgstr "Zurück"
+#: lib/mv_web/live/member_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "Coming soon"
+msgstr "Demnächst verfügbar"
+
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
@@ -786,6 +820,7 @@ msgid "Payment Data"
msgstr "Beitragsdaten"
#: lib/mv_web/live/components/member_filter_component.ex
+#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Payments"
msgstr "Zahlungen"
@@ -799,8 +834,6 @@ msgstr "Persönliche Daten"
#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
-#: lib/mv_web/live/membership_fee_type_live/form.ex
-#: lib/mv_web/live/role_live/form.ex
#, elixir-autogen, elixir-format
msgid "Save"
msgstr "Speichern"
@@ -818,6 +851,11 @@ msgstr "Mitglied erstellen"
msgid "Amount"
msgstr "Betrag"
+#: lib/mv_web/live/member_field_live/form_component.ex
+#, elixir-autogen, elixir-format
+msgid "Back to Settings"
+msgstr "Zurück zu den Einstellungen"
+
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
@@ -943,6 +981,11 @@ msgstr "Unbezahlt"
msgid "Yearly"
msgstr "jährlich"
+#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
+#, elixir-autogen, elixir-format
+msgid "Custom Field %{id}"
+msgstr "Benutzerdefiniertes Feld %{id}"
+
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Last name"
@@ -1532,7 +1575,6 @@ msgid "Show/Hide Columns"
msgstr "Spalten ein-/ausblenden"
#: lib/mv_web/live/custom_field_live/form_component.ex
-#: lib/mv_web/live/member_field_live/form_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Back to settings"
msgstr "Zurück zu den Einstellungen"
@@ -1578,11 +1620,22 @@ msgstr "Datenfeld speichern"
msgid "Back to roles list"
msgstr "Zurück zur Rollen-Liste"
+#: lib/mv_web/live/role_live/index.html.heex
+#, elixir-autogen, elixir-format
+msgid "Cannot delete system role"
+msgstr "System-Rolle kann nicht gelöscht werden"
+
#: lib/mv_web/live/role_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Custom"
msgstr "Benutzerdefiniert"
+#: lib/mv_web/live/role_live/show.ex
+#, elixir-autogen, elixir-format
+msgid "Edit Role"
+msgstr "Rolle bearbeiten"
+
+#: lib/mv_web/live/role_live/index.ex
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format
msgid "Failed to delete role: %{error}"
@@ -1599,6 +1652,7 @@ msgstr "Rollen auflisten"
msgid "Manage user roles and their permission sets."
msgstr "Verwalte Benutzer*innen-Rollen und ihre Berechtigungssätze."
+#: lib/mv_web/live/role_live/index.ex
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format
msgid "Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first."
@@ -1609,6 +1663,11 @@ msgstr "Rolle kann nicht gelöscht werden. %{count} Benutzer*in(nen) sind dieser
msgid "Close sidebar"
msgstr "Sidebar schließen"
+#: lib/mv_web/live/role_live/show.ex
+#, elixir-autogen, elixir-format
+msgid "Delete Role"
+msgstr "Rolle löschen"
+
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
msgid "Main navigation"
@@ -1652,6 +1711,7 @@ msgstr "Profil"
msgid "Role"
msgstr "Rolle"
+#: lib/mv_web/live/role_live/index.ex
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format
msgid "Role deleted successfully."
@@ -1663,6 +1723,7 @@ msgid "Role details and permissions."
msgstr "Rollen-Details und Berechtigungen."
#: lib/mv_web/live/role_live/form.ex
+#: lib/mv_web/live/role_live/index.ex
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format
msgid "Role not found."
@@ -1673,6 +1734,11 @@ msgstr "Rolle nicht gefunden."
msgid "Role saved successfully."
msgstr "Rolle erfolgreich gespeichert."
+#: lib/mv_web/live/role_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "Save Role"
+msgstr "Rolle speichern"
+
#: lib/mv_web/live/role_live/form.ex
#, elixir-autogen, elixir-format
msgid "Select permission set"
@@ -1694,6 +1760,12 @@ msgstr "System"
msgid "System Role"
msgstr "System-Rolle"
+#: lib/mv_web/live/role_live/index.html.heex
+#, elixir-autogen, elixir-format
+msgid "System roles cannot be deleted"
+msgstr "System-Rollen können nicht gelöscht werden"
+
+#: lib/mv_web/live/role_live/index.ex
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format
msgid "System roles cannot be deleted."
@@ -1770,14 +1842,12 @@ msgstr "Mitgliedsbeitragsart nicht gefunden"
msgid "User %{action} successfully"
msgstr "Benutzer*in wurde erfolgreich %{action}"
-#: lib/mv_web/live/user_live/form.ex
-#: lib/mv_web/live/user_live/show.ex
+#: lib/mv_web/live/user_live/index.ex
#, elixir-autogen, elixir-format
msgid "User deleted successfully"
msgstr "Benutzer*in erfolgreich gelöscht"
-#: lib/mv_web/live/user_live/form.ex
-#: lib/mv_web/live/user_live/show.ex
+#: lib/mv_web/live/user_live/index.ex
#, elixir-autogen, elixir-format
msgid "User not found"
msgstr "Benutzer*in nicht gefunden"
@@ -1788,14 +1858,18 @@ msgstr "Benutzer*in nicht gefunden"
msgid "You do not have permission to access this membership fee type"
msgstr "Du hast keine Berechtigung, auf diese Mitgliedsbeitragsart zuzugreifen"
+#: lib/mv_web/live/user_live/index.ex
+#, elixir-autogen, elixir-format
+msgid "You do not have permission to access this user"
+msgstr "Du hast keine Berechtigung, auf diese*n Benutzer*in zuzugreifen"
+
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to delete this membership fee type"
msgstr "Du hast keine Berechtigung, diese Mitgliedsbeitragsart zu löschen"
-#: lib/mv_web/live/user_live/form.ex
-#: lib/mv_web/live/user_live/show.ex
+#: lib/mv_web/live/user_live/index.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to delete this user"
msgstr "Du hast keine Berechtigung, diese*n Benutzer*in zu löschen"
@@ -1816,20 +1890,22 @@ msgstr "aktualisiert"
msgid "Unknown error"
msgstr "Unbekannter Fehler"
-#: lib/mv_web/live/member_live/form.ex
-#: lib/mv_web/live/member_live/show.ex
+#: lib/mv_web/live/member_live/index.ex
#, elixir-autogen, elixir-format
msgid "Member deleted successfully"
msgstr "Mitglied wurde erfolgreich gelöscht"
-#: lib/mv_web/live/member_live/form.ex
-#: lib/mv_web/live/member_live/show.ex
+#: lib/mv_web/live/member_live/index.ex
#, elixir-autogen, elixir-format
msgid "Member not found"
msgstr "Mitglied nicht gefunden"
-#: lib/mv_web/live/member_live/form.ex
-#: lib/mv_web/live/member_live/show.ex
+#: lib/mv_web/live/member_live/index.ex
+#, elixir-autogen, elixir-format
+msgid "You do not have permission to access this member"
+msgstr "Du hast keine Berechtigung, auf dieses Mitglied zuzugreifen"
+
+#: lib/mv_web/live/member_live/index.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to delete this member"
msgstr "Du hast keine Berechtigung, dieses Mitglied zu löschen"
@@ -1914,6 +1990,11 @@ msgstr "Mitgliedsfilter"
msgid "Payment Status"
msgstr "Bezahlstatus"
+#: lib/mv_web/live/components/member_filter_component.ex
+#, elixir-autogen, elixir-format
+msgid "Reset"
+msgstr "Zurücksetzen"
+
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid " (Field: %{field})"
@@ -2080,7 +2161,6 @@ msgstr "Gruppe erstellen"
msgid "Delete Group"
msgstr "Gruppe löschen"
-#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Delete group"
@@ -2161,6 +2241,11 @@ msgstr[1] "Diese Gruppe hat %{count} Mitglieder. Alle Mitglied-Gruppen-Zuordnung
msgid "To confirm deletion, please enter the group name:"
msgstr "Um die Löschung zu bestätigen, gib bitte den Gruppennamen ein:"
+#: lib/mv_web/live/group_live/index.ex
+#, elixir-autogen, elixir-format
+msgid "View"
+msgstr "Anzeigen"
+
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Total: %{count} member"
@@ -3035,278 +3120,3 @@ msgstr "Nur OIDC-Anmeldung (Passwort-Login ausblenden)"
#, elixir-autogen, elixir-format
msgid "When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button."
msgstr "Wenn aktiviert und OIDC konfiguriert ist, zeigt die Anmeldeseite nur den Single-Sign-On-Button."
-
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Clear filters"
-msgstr "Filter zurücksetzen“"
-
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Apply filters"
-msgstr "Filter auswählen"
-
-#: lib/mv_web/live/member_live/form.ex
-#: lib/mv_web/live/member_live/show.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Are you sure you want to delete %{name}? This action cannot be undone."
-msgstr "Möchtest du diese Gruppe wirklich löschen? Die Aktion kann nicht rückgängig gemacht werden."
-
-#: lib/mv_web/live/custom_field_live/form_component.ex
-#: lib/mv_web/live/group_live/form.ex
-#: lib/mv_web/live/group_live/show.ex
-#: lib/mv_web/live/member_live/form.ex
-#: lib/mv_web/live/member_live/show.ex
-#: lib/mv_web/live/role_live/show.ex
-#: lib/mv_web/live/user_live/form.ex
-#: lib/mv_web/live/user_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Danger zone"
-msgstr "Gefahrenzone"
-
-#: lib/mv_web/live/member_live/form.ex
-#: lib/mv_web/live/member_live/show.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Delete member"
-msgstr "Mitglied löschen"
-
-#: lib/mv_web/live/member_live/form.ex
-#: lib/mv_web/live/member_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Delete member %{name}"
-msgstr "Mitglied %{name} löschen"
-
-#: lib/mv_web/live/member_live/form.ex
-#: lib/mv_web/live/member_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed."
-msgstr "Das Löschen des Mitglieds kann nicht rückgängig gemacht werden. Alle dazugehörigen Daten (z.B. Mitgliedsbeitragszylen) werden gelöscht."
-
-#: lib/mv_web/live/group_live/show.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Edit group"
-msgstr "Gruppe bearbeiten"
-
-#: lib/mv_web/live/member_live/show.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Edit member"
-msgstr "Mitglied bearbeiten"
-
-#: lib/mv_web/live/role_live/show.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Edit role"
-msgstr "Rolle bearbeiten"
-
-#: lib/mv_web/live/group_live/index.ex
-#, elixir-autogen, elixir-format
-msgid "Click for group details"
-msgstr "Klicke für Gruppen-Details"
-
-#: lib/mv_web/live/member_live/index.html.heex
-#, elixir-autogen, elixir-format
-msgid "Click for member details"
-msgstr "Klicke für Mitglieds-Details"
-
-#: lib/mv_web/live/role_live/index.html.heex
-#, elixir-autogen, elixir-format
-msgid "Click for role details"
-msgstr "Klicke für Rollen-Details"
-
-#: lib/mv_web/live/user_live/index.html.heex
-#, elixir-autogen, elixir-format
-msgid "Click for user details"
-msgstr "Klicke für Benutzer*innen-Details"
-
-#: lib/mv_web/live/member_live/index.html.heex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Members table"
-msgstr "Mitglieder"
-
-#: lib/mv_web/live/role_live/show.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Are you sure you want to delete the role %{name}? This action cannot be undone."
-msgstr "Möchtest du diese Gruppe wirklich löschen? Die Aktion kann nicht rückgängig gemacht werden."
-
-#: lib/mv_web/live/user_live/form.ex
-#: lib/mv_web/live/user_live/show.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Are you sure you want to delete the user %{email}? This action cannot be undone."
-msgstr "Möchtest du diese Gruppe wirklich löschen? Die Aktion kann nicht rückgängig gemacht werden."
-
-#: lib/mv_web/live/custom_field_live/index_component.ex
-#: lib/mv_web/live/member_field_live/index_component.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Click to edit datafield"
-msgstr "Klicke für Datenfeld-Details"
-
-#: lib/mv_web/live/custom_field_live/form_component.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Delete data field"
-msgstr "Datenfeld löschen"
-
-#: lib/mv_web/live/custom_field_live/form_component.ex
-#, elixir-autogen, elixir-format
-msgid "Delete data field %{name}"
-msgstr "Datenfeld %{name} löschen"
-
-#: lib/mv_web/live/group_live/form.ex
-#: lib/mv_web/live/group_live/show.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Delete group %{name}"
-msgstr "Gruppe %{name} löschen"
-
-#: lib/mv_web/live/role_live/show.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Delete role"
-msgstr "Rolle löschen"
-
-#: lib/mv_web/live/role_live/show.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Delete role %{name}"
-msgstr "Mitglied %{name} löschen"
-
-#: lib/mv_web/live/user_live/form.ex
-#: lib/mv_web/live/user_live/show.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Delete user"
-msgstr "Löschen"
-
-#: lib/mv_web/live/user_live/form.ex
-#: lib/mv_web/live/user_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Delete user %{email}"
-msgstr "Benutzer*in %{email} löschen"
-
-#: lib/mv_web/live/group_live/form.ex
-#: lib/mv_web/live/group_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Deleting this group cannot be undone. All member-group associations will be permanently removed."
-msgstr "Das Löschen der Gruppe kann nicht rückgängig gemacht werden. Alle Mitglieds-Gruppen Zugehörigkeiten werden gelöscht."
-
-#: lib/mv_web/live/role_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Deleting this role cannot be undone. Users assigned to this role must be reassigned first."
-msgstr "Das Löschen dieser Rolle kann nicht rückgängig gemacht werden. Benutzer*inen die dieser Rolle zugewiesen wurden, müssen zuerst einer anderen Rolle zugewiesen werden."
-
-#: lib/mv_web/live/user_live/form.ex
-#: lib/mv_web/live/user_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Deleting this user cannot be undone. The user account and any linked member association will be affected."
-msgstr "Das Löschen kann nicht rückgängig gemacht werden. Der Account und Verlinkungen zu Mitgliedern werden entfernt."
-
-#: lib/mv_web/live/user_live/form.ex
-#: lib/mv_web/live/user_live/show.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "System user cannot be deleted."
-msgstr "System-Rollen können nicht gelöscht werden."
-
-#: lib/mv_web/live/global_settings_live.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Save Name"
-msgstr "Speichern"
-
-#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Datafield %{id}"
-msgstr "Datenfelder"
-
-#: lib/mv_web/live/custom_field_live/index_component.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Delete Datafields and All Values"
-msgstr "Benutzerdefiniertes Feld und alle Werte löschen"
-
-#: lib/mv_web/live/custom_field_live/form_component.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Deleting this data field cannot be undone. All datafield values for this field will be permanently removed."
-msgstr "Das Löschen dieses Datenfeldes kann nicht rückgängig gemacht werden. Alle "
-
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Individual datafields"
-msgstr "Individuelle Datenfelder"
-
-#~ #: lib/mv_web/live/member_field_live/form_component.ex
-#~ #, elixir-autogen, elixir-format
-#~ msgid "Back to Settings"
-#~ msgstr "Zurück zu den Einstellungen"
-
-#~ #: lib/mv_web/live/role_live/index.html.heex
-#~ #, elixir-autogen, elixir-format
-#~ msgid "Cannot delete system role"
-#~ msgstr "System-Rolle kann nicht gelöscht werden"
-
-#~ #: lib/mv_web/live/custom_field_live/index_component.ex
-#~ #, elixir-autogen, elixir-format
-#~ msgid "Click for custom field details"
-#~ msgstr "Klicke für Datenfeld-Details"
-
-#~ #: lib/mv_web/live/member_field_live/index_component.ex
-#~ #, elixir-autogen, elixir-format
-#~ msgid "Click for datafield details"
-#~ msgstr "Klicke für Datenfeld-Details"
-
-#~ #: lib/mv_web/live/member_live/form.ex
-#~ #, elixir-autogen, elixir-format
-#~ msgid "Coming soon"
-#~ msgstr "Demnächst verfügbar"
-
-#~ #: lib/mv_web/live/components/field_visibility_dropdown_component.ex
-#~ #, elixir-autogen, elixir-format
-#~ msgid "Custom Field %{id}"
-#~ msgstr "Benutzerdefiniertes Feld %{id}"
-
-#~ #: lib/mv_web/live/custom_field_live/index_component.ex
-#~ #: lib/mv_web/live/member_field_live/index_component.ex
-#~ #, elixir-autogen, elixir-format, fuzzy
-#~ msgid "Edit datafield"
-#~ msgstr "Datenfeld bearbeiten"
-
-#~ #: lib/mv_web/live/user_live/index.html.heex
-#~ #, elixir-autogen, elixir-format, fuzzy
-#~ msgid "Edit user"
-#~ msgstr "Benutzer*in bearbeiten"
-
-#~ #: lib/mv_web/live/components/member_filter_component.ex
-#~ #, elixir-autogen, elixir-format
-#~ msgid "Reset"
-#~ msgstr "Zurücksetzen"
-
-#~ #: lib/mv_web/live/role_live/show.ex
-#~ #, elixir-autogen, elixir-format
-#~ msgid "Rolle bearbeiten"
-#~ msgstr "Rolle bearbeiten"
-
-#~ #: lib/mv_web/live/role_live/form.ex
-#~ #, elixir-autogen, elixir-format
-#~ msgid "Save Role"
-#~ msgstr "Rolle speichern"
-
-#~ #: lib/mv_web/live/user_live/index.html.heex
-#~ #, elixir-autogen, elixir-format
-#~ msgid "Select all users"
-#~ msgstr "Alle Benutzer*innen auswählen"
-
-#~ #: lib/mv_web/live/user_live/index.html.heex
-#~ #, elixir-autogen, elixir-format
-#~ msgid "Select user"
-#~ msgstr "Benutzer*in auswählen"
-
-#~ #: lib/mv_web/live/role_live/index.html.heex
-#~ #, elixir-autogen, elixir-format
-#~ msgid "System roles cannot be deleted"
-#~ msgstr "System-Rollen können nicht gelöscht werden"
-
-#~ #: lib/mv_web/live/group_live/index.ex
-#~ #, elixir-autogen, elixir-format
-#~ msgid "View"
-#~ msgstr "Anzeigen"
-
-#~ #: lib/mv_web/live/member_live/index.ex
-#~ #, elixir-autogen, elixir-format
-#~ msgid "You do not have permission to access this member"
-#~ msgstr "Du hast keine Berechtigung, auf dieses Mitglied zuzugreifen"
-
-#~ #: lib/mv_web/live/user_live/index.ex
-#~ #, elixir-autogen, elixir-format
-#~ msgid "You do not have permission to access this user"
-#~ msgstr "Du hast keine Berechtigung, auf diese*n Benutzer*in zuzugreifen"
diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot
index 7413a20..41cc407 100644
--- a/priv/gettext/default.pot
+++ b/priv/gettext/default.pot
@@ -12,13 +12,18 @@ msgid ""
msgstr ""
#: lib/mv_web/components/core_components.ex
+#: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Actions"
msgstr ""
+#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
+#: lib/mv_web/live/role_live/index.html.heex
+#: lib/mv_web/live/role_live/show.ex
+#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Are you sure?"
msgstr ""
@@ -36,14 +41,25 @@ msgstr ""
msgid "City"
msgstr ""
+#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/group_live/show.ex
+#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#: lib/mv_web/live/role_live/index.html.heex
+#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Delete"
msgstr ""
+#: lib/mv_web/live/custom_field_live/index_component.ex
+#: lib/mv_web/live/group_live/index.ex
+#: lib/mv_web/live/group_live/show.ex
+#: lib/mv_web/live/member_field_live/index_component.ex
+#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/role_live/form.ex
+#: lib/mv_web/live/role_live/index.html.heex
#: lib/mv_web/live/user_live/form.ex
+#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Edit"
msgstr ""
@@ -95,6 +111,8 @@ msgid "New Member"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
+#: lib/mv_web/live/role_live/index.html.heex
+#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Show"
msgstr ""
@@ -260,6 +278,7 @@ msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
+#: lib/mv_web/live/role_live/form.ex
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Cancel"
@@ -473,6 +492,16 @@ msgstr ""
msgid "Password requirements"
msgstr ""
+#: lib/mv_web/live/user_live/index.html.heex
+#, elixir-autogen, elixir-format
+msgid "Select all users"
+msgstr ""
+
+#: lib/mv_web/live/user_live/index.html.heex
+#, elixir-autogen, elixir-format
+msgid "Select user"
+msgstr ""
+
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Set Password"
@@ -588,6 +617,7 @@ msgstr ""
msgid "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account."
msgstr ""
+#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
@@ -606,6 +636,11 @@ msgstr[1] ""
msgid "All custom field values will be permanently deleted when you delete this custom field."
msgstr ""
+#: lib/mv_web/live/custom_field_live/index_component.ex
+#, elixir-autogen, elixir-format
+msgid "Delete Custom Field and All Values"
+msgstr ""
+
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format
msgid "Enter the text above to confirm"
@@ -639,6 +674,7 @@ msgstr ""
msgid "Manage global settings for the association."
msgstr ""
+#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Save Settings"
@@ -755,21 +791,19 @@ msgstr ""
msgid "Address"
msgstr ""
-#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/group_live/show.ex
-#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
-#: lib/mv_web/live/membership_fee_type_live/form.ex
-#: lib/mv_web/live/role_live/form.ex
-#: lib/mv_web/live/role_live/show.ex
-#: lib/mv_web/live/user_live/form.ex
-#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format
msgid "Back"
msgstr ""
+#: lib/mv_web/live/member_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "Coming soon"
+msgstr ""
+
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
@@ -787,6 +821,7 @@ msgid "Payment Data"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
+#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Payments"
msgstr ""
@@ -800,8 +835,6 @@ msgstr ""
#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
-#: lib/mv_web/live/membership_fee_type_live/form.ex
-#: lib/mv_web/live/role_live/form.ex
#, elixir-autogen, elixir-format
msgid "Save"
msgstr ""
@@ -819,6 +852,11 @@ msgstr ""
msgid "Amount"
msgstr ""
+#: lib/mv_web/live/member_field_live/form_component.ex
+#, elixir-autogen, elixir-format
+msgid "Back to Settings"
+msgstr ""
+
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
@@ -944,6 +982,11 @@ msgstr ""
msgid "Yearly"
msgstr ""
+#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
+#, elixir-autogen, elixir-format
+msgid "Custom Field %{id}"
+msgstr ""
+
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Last name"
@@ -1533,7 +1576,6 @@ msgid "Show/Hide Columns"
msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
-#: lib/mv_web/live/member_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Back to settings"
msgstr ""
@@ -1579,11 +1621,22 @@ msgstr ""
msgid "Back to roles list"
msgstr ""
+#: lib/mv_web/live/role_live/index.html.heex
+#, elixir-autogen, elixir-format
+msgid "Cannot delete system role"
+msgstr ""
+
#: lib/mv_web/live/role_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Custom"
msgstr ""
+#: lib/mv_web/live/role_live/show.ex
+#, elixir-autogen, elixir-format
+msgid "Edit Role"
+msgstr ""
+
+#: lib/mv_web/live/role_live/index.ex
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format
msgid "Failed to delete role: %{error}"
@@ -1600,6 +1653,7 @@ msgstr ""
msgid "Manage user roles and their permission sets."
msgstr ""
+#: lib/mv_web/live/role_live/index.ex
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format
msgid "Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first."
@@ -1610,6 +1664,11 @@ msgstr ""
msgid "Close sidebar"
msgstr ""
+#: lib/mv_web/live/role_live/show.ex
+#, elixir-autogen, elixir-format
+msgid "Delete Role"
+msgstr ""
+
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
msgid "Main navigation"
@@ -1653,6 +1712,7 @@ msgstr ""
msgid "Role"
msgstr ""
+#: lib/mv_web/live/role_live/index.ex
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format
msgid "Role deleted successfully."
@@ -1664,6 +1724,7 @@ msgid "Role details and permissions."
msgstr ""
#: lib/mv_web/live/role_live/form.ex
+#: lib/mv_web/live/role_live/index.ex
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format
msgid "Role not found."
@@ -1674,6 +1735,11 @@ msgstr ""
msgid "Role saved successfully."
msgstr ""
+#: lib/mv_web/live/role_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "Save Role"
+msgstr ""
+
#: lib/mv_web/live/role_live/form.ex
#, elixir-autogen, elixir-format
msgid "Select permission set"
@@ -1695,6 +1761,12 @@ msgstr ""
msgid "System Role"
msgstr ""
+#: lib/mv_web/live/role_live/index.html.heex
+#, elixir-autogen, elixir-format
+msgid "System roles cannot be deleted"
+msgstr ""
+
+#: lib/mv_web/live/role_live/index.ex
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format
msgid "System roles cannot be deleted."
@@ -1771,14 +1843,12 @@ msgstr ""
msgid "User %{action} successfully"
msgstr ""
-#: lib/mv_web/live/user_live/form.ex
-#: lib/mv_web/live/user_live/show.ex
+#: lib/mv_web/live/user_live/index.ex
#, elixir-autogen, elixir-format
msgid "User deleted successfully"
msgstr ""
-#: lib/mv_web/live/user_live/form.ex
-#: lib/mv_web/live/user_live/show.ex
+#: lib/mv_web/live/user_live/index.ex
#, elixir-autogen, elixir-format
msgid "User not found"
msgstr ""
@@ -1789,14 +1859,18 @@ msgstr ""
msgid "You do not have permission to access this membership fee type"
msgstr ""
+#: lib/mv_web/live/user_live/index.ex
+#, elixir-autogen, elixir-format
+msgid "You do not have permission to access this user"
+msgstr ""
+
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to delete this membership fee type"
msgstr ""
-#: lib/mv_web/live/user_live/form.ex
-#: lib/mv_web/live/user_live/show.ex
+#: lib/mv_web/live/user_live/index.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to delete this user"
msgstr ""
@@ -1817,20 +1891,22 @@ msgstr ""
msgid "Unknown error"
msgstr ""
-#: lib/mv_web/live/member_live/form.ex
-#: lib/mv_web/live/member_live/show.ex
+#: lib/mv_web/live/member_live/index.ex
#, elixir-autogen, elixir-format
msgid "Member deleted successfully"
msgstr ""
-#: lib/mv_web/live/member_live/form.ex
-#: lib/mv_web/live/member_live/show.ex
+#: lib/mv_web/live/member_live/index.ex
#, elixir-autogen, elixir-format
msgid "Member not found"
msgstr ""
-#: lib/mv_web/live/member_live/form.ex
-#: lib/mv_web/live/member_live/show.ex
+#: lib/mv_web/live/member_live/index.ex
+#, elixir-autogen, elixir-format
+msgid "You do not have permission to access this member"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to delete this member"
msgstr ""
@@ -1915,6 +1991,11 @@ msgstr ""
msgid "Payment Status"
msgstr ""
+#: lib/mv_web/live/components/member_filter_component.ex
+#, elixir-autogen, elixir-format
+msgid "Reset"
+msgstr ""
+
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid " (Field: %{field})"
@@ -2081,7 +2162,6 @@ msgstr ""
msgid "Delete Group"
msgstr ""
-#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Delete group"
@@ -2162,6 +2242,11 @@ msgstr[1] ""
msgid "To confirm deletion, please enter the group name:"
msgstr ""
+#: lib/mv_web/live/group_live/index.ex
+#, elixir-autogen, elixir-format
+msgid "View"
+msgstr ""
+
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Total: %{count} member"
@@ -3030,192 +3115,3 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button."
msgstr ""
-
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Clear filters"
-msgstr ""
-
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Apply filters"
-msgstr ""
-
-#: lib/mv_web/live/member_live/form.ex
-#: lib/mv_web/live/member_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Are you sure you want to delete %{name}? This action cannot be undone."
-msgstr ""
-
-#: lib/mv_web/live/custom_field_live/form_component.ex
-#: lib/mv_web/live/group_live/form.ex
-#: lib/mv_web/live/group_live/show.ex
-#: lib/mv_web/live/member_live/form.ex
-#: lib/mv_web/live/member_live/show.ex
-#: lib/mv_web/live/role_live/show.ex
-#: lib/mv_web/live/user_live/form.ex
-#: lib/mv_web/live/user_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Danger zone"
-msgstr ""
-
-#: lib/mv_web/live/member_live/form.ex
-#: lib/mv_web/live/member_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Delete member"
-msgstr ""
-
-#: lib/mv_web/live/member_live/form.ex
-#: lib/mv_web/live/member_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Delete member %{name}"
-msgstr ""
-
-#: lib/mv_web/live/member_live/form.ex
-#: lib/mv_web/live/member_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed."
-msgstr ""
-
-#: lib/mv_web/live/group_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Edit group"
-msgstr ""
-
-#: lib/mv_web/live/member_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Edit member"
-msgstr ""
-
-#: lib/mv_web/live/role_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Edit role"
-msgstr ""
-
-#: lib/mv_web/live/group_live/index.ex
-#, elixir-autogen, elixir-format
-msgid "Click for group details"
-msgstr ""
-
-#: lib/mv_web/live/member_live/index.html.heex
-#, elixir-autogen, elixir-format
-msgid "Click for member details"
-msgstr ""
-
-#: lib/mv_web/live/role_live/index.html.heex
-#, elixir-autogen, elixir-format
-msgid "Click for role details"
-msgstr ""
-
-#: lib/mv_web/live/user_live/index.html.heex
-#, elixir-autogen, elixir-format
-msgid "Click for user details"
-msgstr ""
-
-#: lib/mv_web/live/member_live/index.html.heex
-#, elixir-autogen, elixir-format
-msgid "Members table"
-msgstr ""
-
-#: lib/mv_web/live/role_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Are you sure you want to delete the role %{name}? This action cannot be undone."
-msgstr ""
-
-#: lib/mv_web/live/user_live/form.ex
-#: lib/mv_web/live/user_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Are you sure you want to delete the user %{email}? This action cannot be undone."
-msgstr ""
-
-#: lib/mv_web/live/custom_field_live/index_component.ex
-#: lib/mv_web/live/member_field_live/index_component.ex
-#, elixir-autogen, elixir-format
-msgid "Click to edit datafield"
-msgstr ""
-
-#: lib/mv_web/live/custom_field_live/form_component.ex
-#, elixir-autogen, elixir-format
-msgid "Delete data field"
-msgstr ""
-
-#: lib/mv_web/live/custom_field_live/form_component.ex
-#, elixir-autogen, elixir-format
-msgid "Delete data field %{name}"
-msgstr ""
-
-#: lib/mv_web/live/group_live/form.ex
-#: lib/mv_web/live/group_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Delete group %{name}"
-msgstr ""
-
-#: lib/mv_web/live/role_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Delete role"
-msgstr ""
-
-#: lib/mv_web/live/role_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Delete role %{name}"
-msgstr ""
-
-#: lib/mv_web/live/user_live/form.ex
-#: lib/mv_web/live/user_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Delete user"
-msgstr ""
-
-#: lib/mv_web/live/user_live/form.ex
-#: lib/mv_web/live/user_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Delete user %{email}"
-msgstr ""
-
-#: lib/mv_web/live/group_live/form.ex
-#: lib/mv_web/live/group_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Deleting this group cannot be undone. All member-group associations will be permanently removed."
-msgstr ""
-
-#: lib/mv_web/live/role_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Deleting this role cannot be undone. Users assigned to this role must be reassigned first."
-msgstr ""
-
-#: lib/mv_web/live/user_live/form.ex
-#: lib/mv_web/live/user_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Deleting this user cannot be undone. The user account and any linked member association will be affected."
-msgstr ""
-
-#: lib/mv_web/live/user_live/form.ex
-#: lib/mv_web/live/user_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "System user cannot be deleted."
-msgstr ""
-
-#: lib/mv_web/live/global_settings_live.ex
-#, elixir-autogen, elixir-format
-msgid "Save Name"
-msgstr ""
-
-#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
-#, elixir-autogen, elixir-format
-msgid "Datafield %{id}"
-msgstr ""
-
-#: lib/mv_web/live/custom_field_live/index_component.ex
-#, elixir-autogen, elixir-format
-msgid "Delete Datafields and All Values"
-msgstr ""
-
-#: lib/mv_web/live/custom_field_live/form_component.ex
-#, elixir-autogen, elixir-format
-msgid "Deleting this data field cannot be undone. All datafield values for this field will be permanently removed."
-msgstr ""
-
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Individual datafields"
-msgstr ""
diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po
index 51aab4f..8d466ac 100644
--- a/priv/gettext/en/LC_MESSAGES/default.po
+++ b/priv/gettext/en/LC_MESSAGES/default.po
@@ -12,13 +12,18 @@ msgstr ""
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: lib/mv_web/components/core_components.ex
+#: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Actions"
msgstr ""
+#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
+#: lib/mv_web/live/role_live/index.html.heex
+#: lib/mv_web/live/role_live/show.ex
+#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Are you sure?"
msgstr ""
@@ -36,14 +41,25 @@ msgstr ""
msgid "City"
msgstr ""
+#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/group_live/show.ex
+#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
+#: lib/mv_web/live/role_live/index.html.heex
+#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Delete"
msgstr ""
+#: lib/mv_web/live/custom_field_live/index_component.ex
+#: lib/mv_web/live/group_live/index.ex
+#: lib/mv_web/live/group_live/show.ex
+#: lib/mv_web/live/member_field_live/index_component.ex
+#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/role_live/form.ex
+#: lib/mv_web/live/role_live/index.html.heex
#: lib/mv_web/live/user_live/form.ex
+#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Edit"
msgstr ""
@@ -95,6 +111,8 @@ msgid "New Member"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
+#: lib/mv_web/live/role_live/index.html.heex
+#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Show"
msgstr ""
@@ -260,6 +278,7 @@ msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex
+#: lib/mv_web/live/role_live/form.ex
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Cancel"
@@ -473,6 +492,16 @@ msgstr ""
msgid "Password requirements"
msgstr ""
+#: lib/mv_web/live/user_live/index.html.heex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Select all users"
+msgstr ""
+
+#: lib/mv_web/live/user_live/index.html.heex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Select user"
+msgstr ""
+
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Set Password"
@@ -588,6 +617,7 @@ msgstr ""
msgid "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account."
msgstr ""
+#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
@@ -606,6 +636,11 @@ msgstr[1] ""
msgid "All custom field values will be permanently deleted when you delete this custom field."
msgstr ""
+#: lib/mv_web/live/custom_field_live/index_component.ex
+#, elixir-autogen, elixir-format
+msgid "Delete Custom Field and All Values"
+msgstr ""
+
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format
msgid "Enter the text above to confirm"
@@ -639,6 +674,7 @@ msgstr ""
msgid "Manage global settings for the association."
msgstr ""
+#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Save Settings"
@@ -755,21 +791,19 @@ msgstr ""
msgid "Address"
msgstr ""
-#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/group_live/show.ex
-#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
-#: lib/mv_web/live/membership_fee_type_live/form.ex
-#: lib/mv_web/live/role_live/form.ex
-#: lib/mv_web/live/role_live/show.ex
-#: lib/mv_web/live/user_live/form.ex
-#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format
msgid "Back"
msgstr ""
+#: lib/mv_web/live/member_live/form.ex
+#, elixir-autogen, elixir-format
+msgid "Coming soon"
+msgstr ""
+
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
@@ -787,6 +821,7 @@ msgid "Payment Data"
msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
+#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format
msgid "Payments"
msgstr ""
@@ -800,8 +835,6 @@ msgstr ""
#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
-#: lib/mv_web/live/membership_fee_type_live/form.ex
-#: lib/mv_web/live/role_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Save"
msgstr ""
@@ -819,6 +852,11 @@ msgstr ""
msgid "Amount"
msgstr ""
+#: lib/mv_web/live/member_field_live/form_component.ex
+#, elixir-autogen, elixir-format
+msgid "Back to Settings"
+msgstr ""
+
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format
@@ -944,6 +982,11 @@ msgstr ""
msgid "Yearly"
msgstr ""
+#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Custom Field %{id}"
+msgstr ""
+
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy
msgid "Last name"
@@ -1533,7 +1576,6 @@ msgid "Show/Hide Columns"
msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
-#: lib/mv_web/live/member_field_live/form_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Back to settings"
msgstr ""
@@ -1579,11 +1621,22 @@ msgstr ""
msgid "Back to roles list"
msgstr ""
+#: lib/mv_web/live/role_live/index.html.heex
+#, elixir-autogen, elixir-format
+msgid "Cannot delete system role"
+msgstr ""
+
#: lib/mv_web/live/role_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy
msgid "Custom"
msgstr ""
+#: lib/mv_web/live/role_live/show.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Edit Role"
+msgstr ""
+
+#: lib/mv_web/live/role_live/index.ex
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Failed to delete role: %{error}"
@@ -1600,6 +1653,7 @@ msgstr ""
msgid "Manage user roles and their permission sets."
msgstr ""
+#: lib/mv_web/live/role_live/index.ex
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format
msgid "Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first."
@@ -1610,6 +1664,11 @@ msgstr ""
msgid "Close sidebar"
msgstr ""
+#: lib/mv_web/live/role_live/show.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Delete Role"
+msgstr ""
+
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
msgid "Main navigation"
@@ -1653,6 +1712,7 @@ msgstr ""
msgid "Role"
msgstr ""
+#: lib/mv_web/live/role_live/index.ex
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Role deleted successfully."
@@ -1664,6 +1724,7 @@ msgid "Role details and permissions."
msgstr ""
#: lib/mv_web/live/role_live/form.ex
+#: lib/mv_web/live/role_live/index.ex
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format
msgid "Role not found."
@@ -1674,6 +1735,11 @@ msgstr ""
msgid "Role saved successfully."
msgstr ""
+#: lib/mv_web/live/role_live/form.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Save Role"
+msgstr ""
+
#: lib/mv_web/live/role_live/form.ex
#, elixir-autogen, elixir-format
msgid "Select permission set"
@@ -1695,6 +1761,12 @@ msgstr ""
msgid "System Role"
msgstr ""
+#: lib/mv_web/live/role_live/index.html.heex
+#, elixir-autogen, elixir-format
+msgid "System roles cannot be deleted"
+msgstr ""
+
+#: lib/mv_web/live/role_live/index.ex
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "System roles cannot be deleted."
@@ -1771,14 +1843,12 @@ msgstr ""
msgid "User %{action} successfully"
msgstr ""
-#: lib/mv_web/live/user_live/form.ex
-#: lib/mv_web/live/user_live/show.ex
+#: lib/mv_web/live/user_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "User deleted successfully"
msgstr ""
-#: lib/mv_web/live/user_live/form.ex
-#: lib/mv_web/live/user_live/show.ex
+#: lib/mv_web/live/user_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "User not found"
msgstr ""
@@ -1789,14 +1859,18 @@ msgstr ""
msgid "You do not have permission to access this membership fee type"
msgstr ""
+#: lib/mv_web/live/user_live/index.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "You do not have permission to access this user"
+msgstr ""
+
#: lib/mv_web/live/membership_fee_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "You do not have permission to delete this membership fee type"
msgstr ""
-#: lib/mv_web/live/user_live/form.ex
-#: lib/mv_web/live/user_live/show.ex
+#: lib/mv_web/live/user_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "You do not have permission to delete this user"
msgstr ""
@@ -1817,20 +1891,22 @@ msgstr ""
msgid "Unknown error"
msgstr ""
-#: lib/mv_web/live/member_live/form.ex
-#: lib/mv_web/live/member_live/show.ex
+#: lib/mv_web/live/member_live/index.ex
#, elixir-autogen, elixir-format
msgid "Member deleted successfully"
msgstr ""
-#: lib/mv_web/live/member_live/form.ex
-#: lib/mv_web/live/member_live/show.ex
+#: lib/mv_web/live/member_live/index.ex
#, elixir-autogen, elixir-format
msgid "Member not found"
msgstr ""
-#: lib/mv_web/live/member_live/form.ex
-#: lib/mv_web/live/member_live/show.ex
+#: lib/mv_web/live/member_live/index.ex
+#, elixir-autogen, elixir-format
+msgid "You do not have permission to access this member"
+msgstr ""
+
+#: lib/mv_web/live/member_live/index.ex
#, elixir-autogen, elixir-format
msgid "You do not have permission to delete this member"
msgstr ""
@@ -1915,6 +1991,11 @@ msgstr ""
msgid "Payment Status"
msgstr ""
+#: lib/mv_web/live/components/member_filter_component.ex
+#, elixir-autogen, elixir-format
+msgid "Reset"
+msgstr ""
+
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid " (Field: %{field})"
@@ -2081,7 +2162,6 @@ msgstr ""
msgid "Delete Group"
msgstr ""
-#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Delete group"
@@ -2162,6 +2242,11 @@ msgstr[1] ""
msgid "To confirm deletion, please enter the group name:"
msgstr ""
+#: lib/mv_web/live/group_live/index.ex
+#, elixir-autogen, elixir-format
+msgid "View"
+msgstr ""
+
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Total: %{count} member"
@@ -3030,278 +3115,3 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button."
msgstr ""
-
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Clear filters"
-msgstr ""
-
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Apply filters"
-msgstr ""
-
-#: lib/mv_web/live/member_live/form.ex
-#: lib/mv_web/live/member_live/show.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Are you sure you want to delete %{name}? This action cannot be undone."
-msgstr ""
-
-#: lib/mv_web/live/custom_field_live/form_component.ex
-#: lib/mv_web/live/group_live/form.ex
-#: lib/mv_web/live/group_live/show.ex
-#: lib/mv_web/live/member_live/form.ex
-#: lib/mv_web/live/member_live/show.ex
-#: lib/mv_web/live/role_live/show.ex
-#: lib/mv_web/live/user_live/form.ex
-#: lib/mv_web/live/user_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Danger zone"
-msgstr ""
-
-#: lib/mv_web/live/member_live/form.ex
-#: lib/mv_web/live/member_live/show.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Delete member"
-msgstr ""
-
-#: lib/mv_web/live/member_live/form.ex
-#: lib/mv_web/live/member_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Delete member %{name}"
-msgstr ""
-
-#: lib/mv_web/live/member_live/form.ex
-#: lib/mv_web/live/member_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed."
-msgstr ""
-
-#: lib/mv_web/live/group_live/show.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Edit group"
-msgstr ""
-
-#: lib/mv_web/live/member_live/show.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Edit member"
-msgstr ""
-
-#: lib/mv_web/live/role_live/show.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Edit role"
-msgstr ""
-
-#: lib/mv_web/live/group_live/index.ex
-#, elixir-autogen, elixir-format
-msgid "Click for group details"
-msgstr ""
-
-#: lib/mv_web/live/member_live/index.html.heex
-#, elixir-autogen, elixir-format
-msgid "Click for member details"
-msgstr ""
-
-#: lib/mv_web/live/role_live/index.html.heex
-#, elixir-autogen, elixir-format
-msgid "Click for role details"
-msgstr ""
-
-#: lib/mv_web/live/user_live/index.html.heex
-#, elixir-autogen, elixir-format
-msgid "Click for user details"
-msgstr ""
-
-#: lib/mv_web/live/member_live/index.html.heex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Members table"
-msgstr ""
-
-#: lib/mv_web/live/role_live/show.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Are you sure you want to delete the role %{name}? This action cannot be undone."
-msgstr ""
-
-#: lib/mv_web/live/user_live/form.ex
-#: lib/mv_web/live/user_live/show.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Are you sure you want to delete the user %{email}? This action cannot be undone."
-msgstr ""
-
-#: lib/mv_web/live/custom_field_live/index_component.ex
-#: lib/mv_web/live/member_field_live/index_component.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Click to edit datafield"
-msgstr ""
-
-#: lib/mv_web/live/custom_field_live/form_component.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Delete data field"
-msgstr ""
-
-#: lib/mv_web/live/custom_field_live/form_component.ex
-#, elixir-autogen, elixir-format
-msgid "Delete data field %{name}"
-msgstr ""
-
-#: lib/mv_web/live/group_live/form.ex
-#: lib/mv_web/live/group_live/show.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Delete group %{name}"
-msgstr ""
-
-#: lib/mv_web/live/role_live/show.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Delete role"
-msgstr ""
-
-#: lib/mv_web/live/role_live/show.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Delete role %{name}"
-msgstr ""
-
-#: lib/mv_web/live/user_live/form.ex
-#: lib/mv_web/live/user_live/show.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Delete user"
-msgstr ""
-
-#: lib/mv_web/live/user_live/form.ex
-#: lib/mv_web/live/user_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Delete user %{email}"
-msgstr ""
-
-#: lib/mv_web/live/group_live/form.ex
-#: lib/mv_web/live/group_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Deleting this group cannot be undone. All member-group associations will be permanently removed."
-msgstr ""
-
-#: lib/mv_web/live/role_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Deleting this role cannot be undone. Users assigned to this role must be reassigned first."
-msgstr ""
-
-#: lib/mv_web/live/user_live/form.ex
-#: lib/mv_web/live/user_live/show.ex
-#, elixir-autogen, elixir-format
-msgid "Deleting this user cannot be undone. The user account and any linked member association will be affected."
-msgstr ""
-
-#: lib/mv_web/live/user_live/form.ex
-#: lib/mv_web/live/user_live/show.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "System user cannot be deleted."
-msgstr ""
-
-#: lib/mv_web/live/global_settings_live.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Save Name"
-msgstr ""
-
-#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Datafield %{id}"
-msgstr ""
-
-#: lib/mv_web/live/custom_field_live/index_component.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Delete Datafields and All Values"
-msgstr ""
-
-#: lib/mv_web/live/custom_field_live/form_component.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Deleting this data field cannot be undone. All datafield values for this field will be permanently removed."
-msgstr ""
-
-#: lib/mv_web/live/components/member_filter_component.ex
-#, elixir-autogen, elixir-format
-msgid "Individual datafields"
-msgstr ""
-
-#~ #: lib/mv_web/live/member_field_live/form_component.ex
-#~ #, elixir-autogen, elixir-format
-#~ msgid "Back to Settings"
-#~ msgstr ""
-
-#~ #: lib/mv_web/live/role_live/index.html.heex
-#~ #, elixir-autogen, elixir-format
-#~ msgid "Cannot delete system role"
-#~ msgstr ""
-
-#~ #: lib/mv_web/live/custom_field_live/index_component.ex
-#~ #, elixir-autogen, elixir-format
-#~ msgid "Click for custom field details"
-#~ msgstr ""
-
-#~ #: lib/mv_web/live/member_field_live/index_component.ex
-#~ #, elixir-autogen, elixir-format
-#~ msgid "Click for datafield details"
-#~ msgstr ""
-
-#~ #: lib/mv_web/live/member_live/form.ex
-#~ #, elixir-autogen, elixir-format
-#~ msgid "Coming soon"
-#~ msgstr ""
-
-#~ #: lib/mv_web/live/components/field_visibility_dropdown_component.ex
-#~ #, elixir-autogen, elixir-format, fuzzy
-#~ msgid "Custom Field %{id}"
-#~ msgstr ""
-
-#~ #: lib/mv_web/live/custom_field_live/index_component.ex
-#~ #: lib/mv_web/live/member_field_live/index_component.ex
-#~ #, elixir-autogen, elixir-format, fuzzy
-#~ msgid "Edit datafield"
-#~ msgstr ""
-
-#~ #: lib/mv_web/live/user_live/index.html.heex
-#~ #, elixir-autogen, elixir-format, fuzzy
-#~ msgid "Edit user"
-#~ msgstr ""
-
-#~ #: lib/mv_web/live/components/member_filter_component.ex
-#~ #, elixir-autogen, elixir-format
-#~ msgid "Reset"
-#~ msgstr ""
-
-#~ #: lib/mv_web/live/role_live/show.ex
-#~ #, elixir-autogen, elixir-format
-#~ msgid "Rolle bearbeiten"
-#~ msgstr ""
-
-#~ #: lib/mv_web/live/role_live/form.ex
-#~ #, elixir-autogen, elixir-format, fuzzy
-#~ msgid "Save Role"
-#~ msgstr ""
-
-#~ #: lib/mv_web/live/user_live/index.html.heex
-#~ #, elixir-autogen, elixir-format, fuzzy
-#~ msgid "Select all users"
-#~ msgstr ""
-
-#~ #: lib/mv_web/live/user_live/index.html.heex
-#~ #, elixir-autogen, elixir-format, fuzzy
-#~ msgid "Select user"
-#~ msgstr ""
-
-#~ #: lib/mv_web/live/role_live/index.html.heex
-#~ #, elixir-autogen, elixir-format
-#~ msgid "System roles cannot be deleted"
-#~ msgstr ""
-
-#~ #: lib/mv_web/live/group_live/index.ex
-#~ #, elixir-autogen, elixir-format
-#~ msgid "View"
-#~ msgstr ""
-
-#~ #: lib/mv_web/live/member_live/index.ex
-#~ #, elixir-autogen, elixir-format
-#~ msgid "You do not have permission to access this member"
-#~ msgstr ""
-
-#~ #: lib/mv_web/live/user_live/index.ex
-#~ #, elixir-autogen, elixir-format, fuzzy
-#~ msgid "You do not have permission to access this user"
-#~ msgstr ""
diff --git a/test/mv_web/components/core_components_table_test.exs b/test/mv_web/components/core_components_table_test.exs
deleted file mode 100644
index 931b42a..0000000
--- a/test/mv_web/components/core_components_table_test.exs
+++ /dev/null
@@ -1,154 +0,0 @@
-defmodule MvWeb.Components.CoreComponentsTableTest do
- @moduledoc """
- Tests for the CoreComponents table: row hover/focus and selected styling.
- """
- use MvWeb.ConnCase, async: true
-
- import Phoenix.LiveViewTest
-
- alias MvWeb.CoreComponents
-
- describe "table row_click styling" do
- test "when row_click is set, table rows have hover and focus-within ring classes" do
- rows = [%{id: "1", name: "Alice"}, %{id: "2", name: "Bob"}]
-
- assigns = %{
- id: "test-table",
- rows: rows,
- row_id: fn r -> "row-#{r.id}" end,
- row_click: fn _ -> nil end,
- row_item: &Function.identity/1,
- col: [
- %{
- __slot__: :col,
- label: "Name",
- inner_block: fn _socket, item -> [item[:name] || item["name"] || ""] end
- }
- ],
- dynamic_cols: [],
- action: []
- }
-
- html = render_component(&CoreComponents.table/1, assigns)
-
- assert html =~ "hover:ring-2"
- assert html =~ "focus-within:ring-2"
- assert html =~ "hover:ring-base-content/10"
- end
-
- test "when row_click is nil, table rows do not have hover ring classes" do
- rows = [%{id: "1", name: "Alice"}]
-
- assigns = %{
- id: "test-table",
- rows: rows,
- row_id: fn r -> "row-#{r.id}" end,
- row_click: nil,
- row_item: &Function.identity/1,
- col: [
- %{
- __slot__: :col,
- label: "Name",
- inner_block: fn _socket, item -> [item[:name] || ""] end
- }
- ],
- dynamic_cols: [],
- action: []
- }
-
- html = render_component(&CoreComponents.table/1, assigns)
-
- refute html =~ "hover:ring-2"
- refute html =~ "focus-within:ring-2"
- end
- end
-
- describe "table selected_row_id styling" do
- test "when selected_row_id matches a row id, that row has data-selected and ring-primary" do
- rows = [%{id: "one", name: "Alice"}, %{id: "two", name: "Bob"}]
-
- assigns = %{
- id: "test-table",
- rows: rows,
- row_id: fn r -> "row-#{r.id}" end,
- row_click: fn _ -> nil end,
- selected_row_id: "two",
- row_item: &Function.identity/1,
- col: [
- %{
- __slot__: :col,
- label: "Name",
- inner_block: fn _socket, item -> [item[:name] || ""] end
- }
- ],
- dynamic_cols: [],
- action: []
- }
-
- html = render_component(&CoreComponents.table/1, assigns)
-
- assert html =~ ~s(id="row-two")
- assert html =~ ~s(data-selected="true")
- assert html =~ "ring-primary"
- end
-
- test "when selected_row_id is nil, no row has data-selected" do
- rows = [%{id: "1", name: "Alice"}]
-
- assigns = %{
- id: "test-table",
- rows: rows,
- row_id: fn r -> "row-#{r.id}" end,
- row_click: nil,
- selected_row_id: nil,
- row_item: &Function.identity/1,
- col: [
- %{
- __slot__: :col,
- label: "Name",
- inner_block: fn _socket, item -> [item[:name] || ""] end
- }
- ],
- dynamic_cols: [],
- action: []
- }
-
- html = render_component(&CoreComponents.table/1, assigns)
-
- refute html =~ ~s(data-selected="true")
- end
-
- test "when row_selected? is set, multiple rows can have data-selected and ring-primary" do
- rows = [%{id: "a", name: "Alice"}, %{id: "b", name: "Bob"}, %{id: "c", name: "Claire"}]
- selected_ids = MapSet.new(["a", "c"])
-
- assigns = %{
- id: "test-table",
- rows: rows,
- row_id: fn r -> "row-#{r.id}" end,
- row_click: fn _ -> nil end,
- row_selected?: fn item -> MapSet.member?(selected_ids, item.id) end,
- row_item: &Function.identity/1,
- col: [
- %{
- __slot__: :col,
- label: "Name",
- inner_block: fn _socket, item -> [item[:name] || ""] end
- }
- ],
- dynamic_cols: [],
- action: []
- }
-
- html = render_component(&CoreComponents.table/1, assigns)
-
- # Two rows selected (a and c), one not (b)
- assert html =~ ~s(id="row-a")
- assert html =~ ~s(id="row-b")
- assert html =~ ~s(id="row-c")
- # data-selected appears twice (for row a and row c)
- assert String.contains?(html, ~s(data-selected="true"))
- assert html =~ "ring-primary"
- end
- end
-end
diff --git a/test/mv_web/live/custom_field_live/deletion_test.exs b/test/mv_web/live/custom_field_live/deletion_test.exs
index 5ec955e..28f98a2 100644
--- a/test/mv_web/live/custom_field_live/deletion_test.exs
+++ b/test/mv_web/live/custom_field_live/deletion_test.exs
@@ -46,17 +46,6 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
%{conn: conn, user: user_with_role}
end
- # Delete is in the edit form (FormComponent); open form by clicking the name cell (unique td with phx-click)
- defp open_delete_modal(view, custom_field) do
- view
- |> element("tr#custom_fields-#{custom_field.id} td", custom_field.name)
- |> render_click()
-
- view
- |> element("[data-testid=custom-field-delete]")
- |> render_click()
- end
-
describe "delete button and modal" do
test "opens modal with correct member count when delete is clicked", %{conn: conn} do
{:ok, member} = create_member()
@@ -66,7 +55,11 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
create_custom_field_value(member, custom_field, "test")
{:ok, view, _html} = live(conn, ~p"/admin/datafields")
- open_delete_modal(view, custom_field)
+
+ # Click delete button - find the delete link within the component
+ view
+ |> element("#custom-fields-component a", "Delete")
+ |> render_click()
# Modal should be visible
assert has_element?(view, "#delete-custom-field-modal")
@@ -88,17 +81,23 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
create_custom_field_value(member2, custom_field, "test2")
{:ok, view, _html} = live(conn, ~p"/admin/datafields")
- open_delete_modal(view, custom_field)
+
+ view
+ |> element("#custom-fields-component a", "Delete")
+ |> render_click()
# Should show plural form
assert render(view) =~ "2 members have values assigned for this custom field"
end
test "shows 0 members for custom field without values", %{conn: conn} do
- {:ok, custom_field} = create_custom_field("test_field", :string)
+ {:ok, _custom_field} = create_custom_field("test_field", :string)
{:ok, view, _html} = live(conn, ~p"/admin/datafields")
- open_delete_modal(view, custom_field)
+
+ view
+ |> element("#custom-fields-component a", "Delete")
+ |> render_click()
# Should show 0 members
assert render(view) =~ "0 members have values assigned for this custom field"
@@ -110,7 +109,10 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
{:ok, custom_field} = create_custom_field("test_field", :string)
{:ok, view, _html} = live(conn, ~p"/admin/datafields")
- open_delete_modal(view, custom_field)
+
+ view
+ |> element("#custom-fields-component a", "Delete")
+ |> render_click()
# Type in slug input - use element to find the form with phx-target
view
@@ -122,10 +124,13 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
end
test "delete button is disabled when slug doesn't match", %{conn: conn} do
- {:ok, custom_field} = create_custom_field("test_field", :string)
+ {:ok, _custom_field} = create_custom_field("test_field", :string)
{:ok, view, _html} = live(conn, ~p"/admin/datafields")
- open_delete_modal(view, custom_field)
+
+ view
+ |> element("#custom-fields-component a", "Delete")
+ |> render_click()
# Type wrong slug - use element to find the form with phx-target
view
@@ -144,7 +149,11 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
{:ok, custom_field_value} = create_custom_field_value(member, custom_field, "test")
{:ok, view, _html} = live(conn, ~p"/admin/datafields")
- open_delete_modal(view, custom_field)
+
+ # Open modal
+ view
+ |> element("#custom-fields-component a", "Delete")
+ |> render_click()
# Enter correct slug - use element to find the form with phx-target
view
@@ -153,7 +162,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
# Click confirm
view
- |> element("#delete-custom-field-modal button", "Delete Datafields and All Values")
+ |> element("#delete-custom-field-modal button", "Delete Custom Field and All Values")
|> render_click()
# Should show success message
@@ -177,7 +186,10 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
{:ok, custom_field} = create_custom_field("test_field", :string)
{:ok, view, _html} = live(conn, ~p"/admin/datafields")
- open_delete_modal(view, custom_field)
+
+ view
+ |> element("#custom-fields-component a", "Delete")
+ |> render_click()
# Enter wrong slug - use element to find the form with phx-target
view
@@ -198,7 +210,10 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
{:ok, custom_field} = create_custom_field("test_field", :string)
{:ok, view, _html} = live(conn, ~p"/admin/datafields")
- open_delete_modal(view, custom_field)
+
+ view
+ |> element("#custom-fields-component a", "Delete")
+ |> render_click()
# Modal should be visible
assert has_element?(view, "#delete-custom-field-modal")
diff --git a/test/mv_web/live/member_live_authorization_test.exs b/test/mv_web/live/member_live_authorization_test.exs
index c5db9d6..9a23019 100644
--- a/test/mv_web/live/member_live_authorization_test.exs
+++ b/test/mv_web/live/member_live_authorization_test.exs
@@ -24,7 +24,6 @@ defmodule MvWeb.MemberLiveAuthorizationTest do
{:ok, view, _html} = live(conn, "/members")
- # Index table has no Edit/Delete per row (only sr-only Show link); ensure they are not present
refute has_element?(view, "#row-#{member.id} [data-testid=member-edit]")
refute has_element?(view, "#row-#{member.id} [data-testid=member-delete]")
end
@@ -32,18 +31,17 @@ defmodule MvWeb.MemberLiveAuthorizationTest do
describe "Member Index - Kassenwart (normal_user)" do
@tag role: :normal_user
- test "sees New Member and Show link in row", %{conn: conn} do
+ test "sees New Member and Edit buttons", %{conn: conn} do
member = Fixtures.member_fixture()
{:ok, view, _html} = live(conn, "/members")
assert has_element?(view, "[data-testid=member-new]")
- # Index table action column has sr-only Show link only (Edit is on member show page)
- assert has_element?(view, "#row-#{member.id} [data-testid=member-show-link]")
+ assert has_element?(view, "#row-#{member.id} [data-testid=member-edit]")
end
@tag role: :normal_user
- test "does not see Delete button in table", %{conn: conn} do
+ test "does not see Delete button", %{conn: conn} do
member = Fixtures.member_fixture()
{:ok, view, _html} = live(conn, "/members")
@@ -54,14 +52,14 @@ defmodule MvWeb.MemberLiveAuthorizationTest do
describe "Member Index - Admin" do
@tag role: :admin
- test "sees New Member and Show link in row", %{conn: conn} do
+ test "sees New Member, Edit and Delete buttons", %{conn: conn} do
member = Fixtures.member_fixture()
{:ok, view, _html} = live(conn, "/members")
assert has_element?(view, "[data-testid=member-new]")
- # Index table action column has sr-only Show link only (Edit/Delete are on member show page)
- assert has_element?(view, "#row-#{member.id} [data-testid=member-show-link]")
+ assert has_element?(view, "#row-#{member.id} [data-testid=member-edit]")
+ assert has_element?(view, "#row-#{member.id} [data-testid=member-delete]")
end
end
diff --git a/test/mv_web/live/role_live_test.exs b/test/mv_web/live/role_live_test.exs
index 57ce814..cb112f2 100644
--- a/test/mv_web/live/role_live_test.exs
+++ b/test/mv_web/live/role_live_test.exs
@@ -138,7 +138,7 @@ defmodule MvWeb.RoleLiveTest do
assert html =~ "System Role" || html =~ "system"
end
- test "delete button not shown for system roles", %{conn: conn, actor: actor} do
+ test "delete button disabled for system roles", %{conn: conn, actor: actor} do
system_role =
Role
|> Ash.Changeset.for_create(:create_role, %{
@@ -148,19 +148,28 @@ defmodule MvWeb.RoleLiveTest do
|> Ash.Changeset.force_change_attribute(:is_system_role, true)
|> Ash.create!(actor: actor)
- {:ok, view, _html} = live(conn, "/admin/roles/#{system_role.id}")
+ {:ok, view, _html} = live(conn, "/admin/roles")
- # Danger zone (and delete button) is not rendered for system roles
- refute has_element?(view, "[data-testid=role-delete]")
+ assert has_element?(
+ view,
+ "button[phx-click='delete'][phx-value-id='#{system_role.id}'][disabled]"
+ ) ||
+ not has_element?(
+ view,
+ "button[phx-click='delete'][phx-value-id='#{system_role.id}']"
+ )
end
test "delete button enabled for non-system roles", %{conn: conn} do
role = create_role()
- {:ok, view, _html} = live(conn, "/admin/roles/#{role.id}")
+ {:ok, view, html} = live(conn, "/admin/roles")
- # Delete is on show page (Danger zone)
- assert has_element?(view, "[data-testid=role-delete]")
+ # Delete is a link with phx-click containing delete event
+ # Check if delete link exists in HTML (phx-click contains delete and role id)
+ assert (html =~ "phx-click" && html =~ "delete" && html =~ role.id) ||
+ has_element?(view, "a[phx-click*='delete'][phx-value-id='#{role.id}']") ||
+ has_element?(view, "a[aria-label='Delete role']")
end
test "new role button navigates to form", %{conn: conn} do
@@ -384,21 +393,21 @@ defmodule MvWeb.RoleLiveTest do
test "deletes non-system role", %{conn: conn, actor: actor} do
role = create_role()
- {:ok, view, _html} = live(conn, "/admin/roles/#{role.id}")
+ {:ok, view, html} = live(conn, "/admin/roles")
- # Delete from Danger zone on show page
- view
- |> element("[data-testid=role-delete]")
- |> render_click()
+ # Delete is a link - JS.push creates phx-click with value containing id
+ # Verify the role id is in the HTML (in phx-click value)
+ assert html =~ role.id
- assert_redirect(view, "/admin/roles")
+ # Send delete event directly to avoid selector issues with multiple delete buttons
+ render_click(view, "delete", %{"id" => role.id})
# Verify deletion by checking database
assert {:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{}]}} =
Authorization.get_role(role.id, actor: actor)
end
- test "system role has no delete button and cannot be deleted", %{conn: conn, actor: actor} do
+ test "fails to delete system role with error message", %{conn: conn, actor: actor} do
system_role =
Role
|> Ash.Changeset.for_create(:create_role, %{
@@ -408,12 +417,19 @@ defmodule MvWeb.RoleLiveTest do
|> Ash.Changeset.force_change_attribute(:is_system_role, true)
|> Ash.create!(actor: actor)
- {:ok, view, _html} = live(conn, "/admin/roles/#{system_role.id}")
+ {:ok, view, html} = live(conn, "/admin/roles")
- # Danger zone is not rendered for system roles (no delete button)
- refute has_element?(view, "[data-testid=role-delete]")
+ # System role delete button should be disabled
+ assert html =~ "disabled" || html =~ "cursor-not-allowed" ||
+ html =~ "System roles cannot be deleted"
- # Role still exists
+ # Try to delete via event (backend check)
+ render_click(view, "delete", %{"id" => system_role.id})
+
+ # Should show error message
+ assert render(view) =~ "System roles cannot be deleted"
+
+ # Role should still exist
{:ok, _role} = Authorization.get_role(system_role.id, actor: actor)
end
end
diff --git a/test/mv_web/live/user_live_authorization_test.exs b/test/mv_web/live/user_live_authorization_test.exs
index ee9f2b6..f4b4746 100644
--- a/test/mv_web/live/user_live_authorization_test.exs
+++ b/test/mv_web/live/user_live_authorization_test.exs
@@ -10,16 +10,14 @@ defmodule MvWeb.UserLiveAuthorizationTest do
describe "User Index - Admin" do
@tag role: :admin
- test "sees New User button; Edit and Delete are on show page", %{conn: conn} do
+ test "sees New User, Edit and Delete buttons", %{conn: conn} do
user = Fixtures.user_with_role_fixture("admin")
- {:ok, index_view, _html} = live(conn, "/users")
- assert has_element?(index_view, "[data-testid=user-new]")
+ {:ok, view, _html} = live(conn, "/users")
- # Edit and Delete are on user show page (Danger zone), not on index
- {:ok, show_view, _html} = live(conn, "/users/#{user.id}")
- assert has_element?(show_view, "[data-testid=user-edit]")
- assert has_element?(show_view, "[data-testid=user-delete]")
+ assert has_element?(view, "[data-testid=user-new]")
+ assert has_element?(view, "#row-#{user.id} [data-testid=user-edit]")
+ assert has_element?(view, "#row-#{user.id} [data-testid=user-delete]")
end
end
diff --git a/test/mv_web/member_live/form_error_handling_test.exs b/test/mv_web/member_live/form_error_handling_test.exs
index 0ec142e..d61d3fd 100644
--- a/test/mv_web/member_live/form_error_handling_test.exs
+++ b/test/mv_web/member_live/form_error_handling_test.exs
@@ -3,85 +3,11 @@ defmodule MvWeb.MemberLive.FormErrorHandlingTest do
Tests for error handling in the member form, specifically flash message display.
"""
use MvWeb.ConnCase, async: false
- use Gettext, backend: MvWeb.Gettext
import Phoenix.LiveViewTest
require Ash.Query
- describe "danger zone on edit" do
- @tag :ui
- test "edit form shows Danger zone and delete button when user can destroy member", %{
- conn: conn
- } do
- system_actor = Mv.Helpers.SystemActor.get_system_actor()
-
- {:ok, member} =
- Mv.Membership.create_member(
- %{first_name: "Delete", last_name: "FromEdit", email: "delete.from.edit@example.com"},
- actor: system_actor
- )
-
- conn = conn_with_oidc_user(conn)
- {:ok, view, html} = live(conn, ~p"/members/#{member}/edit")
-
- assert html =~ gettext("Danger zone")
- assert has_element?(view, "[data-testid='member-delete']")
- end
-
- test "delete event from edit form removes member and redirects to /members", %{conn: conn} do
- system_actor = Mv.Helpers.SystemActor.get_system_actor()
-
- {:ok, member} =
- Mv.Membership.create_member(
- %{
- first_name: "ToDelete",
- last_name: "FromForm",
- email: "todelete.from.form.#{System.unique_integer([:positive])}@example.com"
- },
- actor: system_actor
- )
-
- conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, ~p"/members/#{member}/edit")
-
- view
- |> render_click("delete", %{"id" => member.id})
-
- assert_redirect(view, ~p"/members")
-
- refute Mv.Membership.Member
- |> Ash.Query.filter(id == ^member.id)
- |> Ash.exists?()
- end
- end
-
- describe "tab visibility" do
- @tag :ui
- test "Payments tab is not visible on new member form", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- {:ok, _view, html} = live(conn, "/members/new")
-
- refute html =~ gettext("Payments")
- end
-
- @tag :ui
- test "Payments tab is not visible on edit member form", %{conn: conn} do
- system_actor = Mv.Helpers.SystemActor.get_system_actor()
-
- {:ok, member} =
- Mv.Membership.create_member(
- %{first_name: "Edit", last_name: "Member", email: "edit@example.com"},
- actor: system_actor
- )
-
- conn = conn_with_oidc_user(conn)
- {:ok, _view, html} = live(conn, ~p"/members/#{member}/edit")
-
- refute html =~ gettext("Payments")
- end
- end
-
describe "error handling - flash messages" do
setup do
{:ok, settings} = Mv.Membership.get_settings()
diff --git a/test/mv_web/member_live/index_membership_fee_status_test.exs b/test/mv_web/member_live/index_membership_fee_status_test.exs
index add2fba..bbd9159 100644
--- a/test/mv_web/member_live/index_membership_fee_status_test.exs
+++ b/test/mv_web/member_live/index_membership_fee_status_test.exs
@@ -107,9 +107,9 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
{:ok, view, _html} = live(conn, "/members")
- # Toggle to current cycle (use the button in the header)
+ # Toggle to current cycle (use the button in the header, not the one in the column)
view
- |> element("[data-testid=toggle-cycle-view]")
+ |> element("button[phx-click='toggle_cycle_view'].btn.gap-2")
|> render_click()
html = render(view)
diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs
index ec35f4d..5246d80 100644
--- a/test/mv_web/member_live/index_test.exs
+++ b/test/mv_web/member_live/index_test.exs
@@ -46,35 +46,6 @@ defmodule MvWeb.MemberLive.IndexTest do
|> Ash.create!(actor: actor)
end
- describe "desktop layout: scroll container and sticky table header" do
- @describetag :ui
-
- test "header and filters are outside scroll container; table is in scroll container with lg:max-h and lg:overflow-auto",
- %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- {:ok, _view, html} = live(conn, ~p"/members")
-
- assert html =~ ~r/data-testid="members-table-scroll"/
- # Scroll container has lg: overflow and max-height for desktop-only scroll
- assert html =~ "lg:overflow-auto"
- assert html =~ "lg:max-h-[calc(100vh-14rem)]"
-
- # Header (page title) is present and not inside the scroll container (scroll container comes after filters)
- assert html =~ "Members"
- assert html =~ "id=\"members\""
- end
-
- test "table thead has sticky classes on desktop when sticky_header is set", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- {:ok, _view, html} = live(conn, ~p"/members")
-
- # CoreComponents table with sticky_header adds lg:sticky lg:top-0 bg-base-100 z-10 to th
- assert html =~ "lg:sticky"
- assert html =~ "lg:top-0"
- assert html =~ "bg-base-100"
- end
- end
-
describe "translations" do
@describetag :ui
@@ -296,80 +267,36 @@ defmodule MvWeb.MemberLive.IndexTest do
assert is_list(state.socket.assigns.members)
end
- @tag :ui
- test "member index does not render Edit or Delete actions", %{conn: conn} do
- system_actor = Mv.Helpers.SystemActor.get_system_actor()
-
- {:ok, _member} =
- Mv.Membership.create_member(
- %{first_name: "Test", last_name: "User", email: "test@example.com"},
- actor: system_actor
- )
-
- conn = conn_with_oidc_user(conn)
- {:ok, view, html} = live(conn, "/members")
-
- refute has_element?(view, "[data-testid='member-edit']")
- refute html =~ ~s(data-testid="member-delete")
- end
-
- @tag :ui
- test "row click navigates to member show", %{conn: conn} do
+ test "can delete a member without error", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
+ # Create a test member first
{:ok, member} =
Mv.Membership.create_member(
- %{first_name: "Row", last_name: "Click", email: "rowclick@example.com"},
+ %{
+ first_name: "Test",
+ last_name: "User",
+ email: "test@example.com"
+ },
actor: system_actor
)
conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members")
+ {:ok, index_view, _html} = live(conn, "/members")
- # Click a data cell (e.g. second column = first name) to trigger row navigation
- view
- |> element("#row-#{member.id} td:nth-child(2)")
+ # Verify the member is displayed
+ assert has_element?(index_view, "#members", "Test User")
+
+ # Click the delete link for this member
+ index_view
+ |> element("a", "Delete")
|> render_click()
- assert_redirect(view, ~p"/members/#{member}")
- end
+ # Verify the member is no longer displayed
+ refute has_element?(index_view, "#members", "Test User")
- describe "table row outline (hover and selected)" do
- @describetag :ui
-
- test "clickable rows have hover and focus-within ring classes", %{conn: conn} do
- system_actor = Mv.Helpers.SystemActor.get_system_actor()
-
- {:ok, _member} =
- Mv.Membership.create_member(
- %{first_name: "Hover", last_name: "Test", email: "hover@example.com"},
- actor: system_actor
- )
-
- conn = conn_with_oidc_user(conn)
- {:ok, _view, html} = live(conn, "/members")
-
- # CoreComponents table adds hover and focus-within ring when row_click is set
- assert html =~ "hover:ring-2"
- assert html =~ "focus-within:ring-2"
- assert html =~ "hover:ring-base-content/10"
- end
-
- test "selected outline only from checkbox selection, not from highlight param", %{conn: conn} do
- system_actor = Mv.Helpers.SystemActor.get_system_actor()
-
- {:ok, member} =
- Mv.Membership.create_member(
- %{first_name: "Highlight", last_name: "Only", email: "highlight@example.com"},
- actor: system_actor
- )
-
- conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members?highlight=#{member.id}")
-
- # Outline is only for checkbox selection; highlight param does not set data-selected
- refute has_element?(view, "tr#row-#{member.id}[data-selected='true']")
- end
+ # Verify the member was actually deleted from the database
+ assert not (Mv.Membership.Member |> Ash.Query.filter(id == ^member.id) |> Ash.exists?())
end
describe "copy_emails feature" do
diff --git a/test/mv_web/member_live/show_test.exs b/test/mv_web/member_live/show_test.exs
index 54829de..26c3f00 100644
--- a/test/mv_web/member_live/show_test.exs
+++ b/test/mv_web/member_live/show_test.exs
@@ -134,37 +134,6 @@ defmodule MvWeb.MemberLive.ShowTest do
end
end
- describe "delete action" do
- test "renders Danger zone section and Delete button when user can destroy member", %{
- conn: conn,
- member: member
- } do
- conn = conn_with_oidc_user(conn)
- {:ok, view, html} = live(conn, ~p"/members/#{member}")
-
- assert has_element?(view, "[data-testid='member-delete']")
- assert html =~ gettext("Danger zone")
- assert has_element?(view, "section[aria-labelledby='danger-zone-heading']")
- end
-
- test "delete event removes member and redirects to index", %{
- conn: conn,
- member: member
- } do
- conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, ~p"/members/#{member}")
-
- view
- |> render_click("delete", %{"id" => member.id})
-
- assert_redirect(view, ~p"/members")
-
- refute Mv.Membership.Member
- |> Ash.Query.filter(id == ^member.id)
- |> Ash.exists?()
- end
- end
-
describe "custom field value formatting" do
test "formats string custom field values", %{conn: conn, member: member, actor: actor} do
{:ok, custom_field} =
diff --git a/test/mv_web/user_live/index_test.exs b/test/mv_web/user_live/index_test.exs
index f748000..11cd70b 100644
--- a/test/mv_web/user_live/index_test.exs
+++ b/test/mv_web/user_live/index_test.exs
@@ -16,10 +16,11 @@ defmodule MvWeb.UserLive.IndexTest do
assert html =~ "alice@example.com"
assert html =~ "bob@example.com"
- # UI elements: New User button; row click navigates to show (no Edit/Delete on index)
+ # UI elements: New User button, action links
assert html =~ "New User"
- # Row or navigation contains user id (e.g. row id or phx-click navigate)
- assert html =~ "row-#{user1.id}" or html =~ to_string(user1.id)
+ assert html =~ "Edit"
+ assert html =~ "Delete"
+ assert html =~ ~r/href="[^"]*\/users\/#{user1.id}\/edit"/
end
@tag :ui
@@ -115,28 +116,176 @@ defmodule MvWeb.UserLive.IndexTest do
end
end
- describe "delete functionality" do
- # Delete is only on user show page (Danger zone), not on index (per CODE_GUIDELINES: at most one UI smoke test for delete)
- test "can delete a user from show page", %{conn: conn} do
- user = create_test_user(%{email: "delete-me@example.com"})
+ describe "checkbox selection functionality" do
+ setup do
+ user1 = create_test_user(%{email: "user1@example.com", oidc_id: "user1"})
+ user2 = create_test_user(%{email: "user2@example.com", oidc_id: "user2"})
+ %{users: [user1, user2]}
+ end
+
+ @tag :ui
+ test "shows checkbox UI elements", %{conn: conn, users: [user1, user2]} do
conn = conn_with_oidc_user(conn)
- {:ok, index_view, _html} = live(conn, "/users")
- assert render(index_view) =~ "delete-me@example.com"
+ {:ok, _view, html} = live(conn, "/users")
- # Navigate to user show and trigger delete from Danger zone
- {:ok, show_view, _html} = live(conn, "/users/#{user.id}")
+ # Check select all checkbox exists
+ assert html =~ ~s(name="select_all")
+ assert html =~ ~s(phx-click="select_all")
- show_view
- |> element("[data-testid=user-delete]")
- |> render_click()
+ # Check individual user checkboxes exist
+ assert html =~ ~s(name="#{user1.id}")
+ assert html =~ ~s(name="#{user2.id}")
+ assert html =~ ~s(phx-click="select_user")
+ end
- # Should redirect to index
- assert_redirect(show_view, "/users")
+ @tag :ui
+ test "can select and deselect individual users", %{conn: conn, users: [user1, user2]} do
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, _html} = live(conn, "/users")
- # Reload index with same session; user should be gone
- {:ok, _view_after, html} = live(conn, "/users")
- refute html =~ "delete-me@example.com"
+ # Initially, individual checkboxes should exist but not be checked
+ assert view |> element("input[type='checkbox'][name='#{user1.id}']") |> has_element?()
+ assert view |> element("input[type='checkbox'][name='#{user2.id}']") |> has_element?()
+
+ refute view
+ |> element("input[type='checkbox'][name='select_all'][checked]")
+ |> has_element?()
+
+ # Select first user checkbox
+ html = view |> element("input[type='checkbox'][name='#{user1.id}']") |> render_click()
assert html =~ "Email"
+ assert html =~ to_string(user1.email)
+
+ # The select_all checkbox should still not be checked (not all users selected)
+ refute view
+ |> element("input[type='checkbox'][name='select_all'][checked]")
+ |> has_element?()
+
+ # Deselect user
+ html = view |> element("input[type='checkbox'][name='#{user1.id}']") |> render_click()
+ assert html =~ "Email"
+
+ refute view
+ |> element("input[type='checkbox'][name='select_all'][checked]")
+ |> has_element?()
+ end
+
+ @tag :ui
+ test "select all and deselect all functionality", %{conn: conn, users: [user1, user2]} do
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, _html} = live(conn, "/users")
+
+ # Initially no checkboxes should be checked
+ refute view
+ |> element("input[type='checkbox'][name='select_all'][checked]")
+ |> has_element?()
+
+ refute view
+ |> element("input[type='checkbox'][name='#{user1.id}'][checked]")
+ |> has_element?()
+
+ refute view
+ |> element("input[type='checkbox'][name='#{user2.id}'][checked]")
+ |> has_element?()
+
+ # Click select all
+ html = view |> element("input[type='checkbox'][name='select_all']") |> render_click()
+
+ # After selecting all, the select_all checkbox should be checked
+ assert view
+ |> element("input[type='checkbox'][name='select_all'][checked]")
+ |> has_element?()
+
+ assert html =~ "Email"
+ assert html =~ to_string(user1.email)
+ assert html =~ to_string(user2.email)
+
+ # Then deselect all
+ html = view |> element("input[type='checkbox'][name='select_all']") |> render_click()
+
+ # After deselecting all, no checkboxes should be checked
+ refute view
+ |> element("input[type='checkbox'][name='select_all'][checked]")
+ |> has_element?()
+
+ refute view
+ |> element("input[type='checkbox'][name='#{user1.id}'][checked]")
+ |> has_element?()
+
+ refute view
+ |> element("input[type='checkbox'][name='#{user2.id}'][checked]")
+ |> has_element?()
+
+ assert html =~ "Email"
+ end
+
+ @tag :slow
+ test "select all automatically checks when all individual users are selected", %{
+ conn: conn,
+ users: [_user1, _user2]
+ } do
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, html} = live(conn, "/users")
+
+ # Get all user IDs from the rendered HTML by finding all checkboxes with phx-click="select_user"
+ # Extract user IDs from the HTML (they appear as name attributes on checkboxes)
+ user_ids =
+ html
+ |> String.split("phx-click=\"select_user\"")
+ |> Enum.flat_map(fn part ->
+ case Regex.run(~r/name="([^"]+)"[^>]*phx-value-id/, part) do
+ [_, user_id] -> [user_id]
+ _ -> []
+ end
+ end)
+ |> Enum.uniq()
+
+ # Skip if no users found (shouldn't happen, but be safe)
+ if user_ids != [] do
+ # Initially nothing should be checked
+ refute view
+ |> element("input[type='checkbox'][name='select_all'][checked]")
+ |> has_element?()
+
+ # Select all users one by one
+ Enum.each(user_ids, fn user_id ->
+ view |> element("input[type='checkbox'][name='#{user_id}']") |> render_click()
+ end)
+
+ # Now select all should be automatically checked (all individual users are selected)
+ assert view
+ |> element("input[type='checkbox'][name='select_all'][checked]")
+ |> has_element?()
+ end
+ end
+ end
+
+ describe "delete functionality" do
+ test "can delete a user", %{conn: conn} do
+ _user = create_test_user(%{email: "delete-me@example.com"})
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, _html} = live(conn, "/users")
+
+ # Confirm user is displayed
+ assert render(view) =~ "delete-me@example.com"
+
+ # Click the delete button (phx-click="delete" event)
+ view |> element("tbody tr:first-child a[data-confirm]") |> render_click()
+
+ # Verify user was actually deleted (should not appear in HTML anymore)
+ html = render(view)
+ refute html =~ "delete-me@example.com"
+ # Table header should still be there
+ assert html =~ "Email"
+ end
+
+ test "shows delete confirmation", %{conn: conn} do
+ _user = create_test_user(%{email: "confirm-delete@example.com"})
+ conn = conn_with_oidc_user(conn)
+ {:ok, _view, html} = live(conn, "/users")
+
+ # Check that delete link has confirmation attribute
+ assert html =~ ~s(data-confirm="Are you sure?")
end
end
@@ -147,14 +296,36 @@ defmodule MvWeb.UserLive.IndexTest do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/users")
- # Row click navigates to show page (edit is on show page)
+ # Check that user row contains link to show page
assert html =~ ~s(/users/#{user.id})
+ # Check edit link points to correct edit page
+ assert html =~ ~s(/users/#{user.id}/edit)
+
# Check new user button points to correct new page
assert html =~ ~s(/users/new)
end
end
+ describe "translations" do
+ @tag :ui
+ test "shows translations for selection in different locales", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+
+ # Test German translations
+ conn = Plug.Test.init_test_session(conn, locale: "de")
+ {:ok, _view, html_de} = live(conn, "/users")
+ assert html_de =~ "Alle Benutzer*innen auswählen"
+ assert html_de =~ "Benutzer*in auswählen"
+
+ # Test English translations
+ Gettext.put_locale(MvWeb.Gettext, "en")
+ {:ok, _view, html_en} = live(conn, "/users")
+ # Check that aria-label attributes exist (structure is there)
+ assert html_en =~ ~s(aria-label=)
+ end
+ end
+
describe "edge cases" do
test "handles empty user list gracefully", %{conn: conn} do
# Don't create any users besides the authenticated one