Adds more consistency in various UX topics closes #447 #448

Merged
carla merged 9 commits from feat/447_concistency into main 2026-02-25 17:34:12 +01:00
49 changed files with 3802 additions and 2341 deletions

View file

@ -60,6 +60,9 @@ We are building a membership management system (Mila) using the following techno
7. [Documentation Standards](#7-documentation-standards) 7. [Documentation Standards](#7-documentation-standards)
8. [Accessibility Guidelines](#8-accessibility-guidelines) 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 ## 1. Setup and Architectural Conventions

426
DESIGN_DUIDELINES.md Normal file
View file

@ -0,0 +1,426 @@
# UI Design Guidelines (Mila / Phoenix LiveView + DaisyUI)
## Purpose
This document defines Milas **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")}
</.button>
</:leading>
Page title (e.g. “Edit Member” or “New User”)
<:subtitle>Short explanation.</:subtitle>
<:actions>
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save")}
</.button>
</:actions>
</.header>
```
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
<div class="space-y-3">
<p class="text-base-content/60 italic">No members yet.</p>
<.button variant="primary" navigate={~p"/members/new"}>Create member</.button>
</div>
### 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 **`<td>`** 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>
<:col :let={m} label="Newsletter">
<input
type="checkbox"
class="checkbox checkbox-sm"
checked={m.newsletter}
phx-click={JS.push("toggle_newsletter", value: %{id: m.id}) |> JS.stop_propagation()}
/>
</:col>
<:action :let={m}>
<.button
variant="ghost"
size="sm"
navigate={~p"/members/#{m.id}/edit"}
phx-click={JS.stop_propagation()}
>
Edit
</.button>
</:action>
</.table>
Notes:
- The checkbox uses `phx-click={JS.push(...) |> JS.stop_propagation()}` so it wont 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-<offset>)] 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 tables `sticky_header={true}` so the tables `<thead>` 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: 46s
- warning: 68s
- error: 812s (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.
---

View file

@ -191,6 +191,11 @@
- ❌ Mobile navigation - ❌ Mobile navigation
- ❌ Context-sensitive help - ❌ Context-sensitive help
- ❌ Onboarding tooltips - ❌ Onboarding tooltips
- ❌ **Flash: Auto-dismiss and consistency** (Design Guidelines §9)
- Auto-dismiss: info/success 46s, warning 68s, error 812s; 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.
--- ---

View file

@ -60,15 +60,15 @@ defmodule MvWeb.CoreComponents do
id={@id} id={@id}
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")} phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
role="alert" role="alert"
class="z-50 toast toast-top toast-end" class="pointer-events-auto"
{@rest} {@rest}
> >
<div class={[ <div class={[
"alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap", "alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap",
@kind == :info && "alert-info", @kind == :info && "alert-info",
@kind == :error && "alert-error", @kind == :error && "alert-error",
@kind == :success && "bg-green-500 text-white", @kind == :success && "alert-success",
@kind == :warning && "bg-blue-100 text-blue-800 border border-blue-300" @kind == :warning && "alert-warning"
]}> ]}>
<.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" /> <.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" /> <.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
@ -90,33 +90,71 @@ defmodule MvWeb.CoreComponents do
@doc """ @doc """
Renders a button with navigation support. 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 ## Examples
<.button>Send!</.button> <.button>Send!</.button>
<.button phx-click="go" variant="primary">Send!</.button> <.button phx-click="go" variant="primary">Send!</.button>
<.button navigate={~p"/"}>Home</.button> <.button navigate={~p"/"} variant="secondary">Home</.button>
<.button variant="ghost" size="sm">Edit</.button>
<.button disabled={true}>Disabled</.button> <.button disabled={true}>Disabled</.button>
""" """
attr :rest, :global, include: ~w(href navigate patch method data-testid) attr :rest, :global, include: ~w(href navigate patch method data-testid form)
attr :variant, :string, values: ~w(primary)
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 :disabled, :boolean, default: false, doc: "Whether the button is disabled" attr :disabled, :boolean, default: false, doc: "Whether the button is disabled"
slot :inner_block, required: true slot :inner_block, required: true
def button(assigns) do def button(assigns) do
rest = assigns.rest rest = assigns.rest
variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"} variant = assigns[:variant] || "primary"
assigns = assign(assigns, :class, Map.fetch!(variants, assigns[:variant])) 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)
if rest[:href] || rest[:navigate] || rest[:patch] do 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 = link_class =
if assigns[:disabled], if assigns[:disabled],
do: ["btn", assigns.class, "btn-disabled"], do: ["btn", btn_class, "btn-disabled"],
else: ["btn", assigns.class] else: ["btn", btn_class]
# Prevent interaction when disabled
# Remove navigation attributes to prevent "Open in new tab", "Copy link" etc.
link_attrs = link_attrs =
if assigns[:disabled] do if assigns[:disabled] do
rest rest
@ -138,13 +176,49 @@ defmodule MvWeb.CoreComponents do
""" """
else else
~H""" ~H"""
<button class={["btn", @class]} disabled={@disabled} {@rest}> <button class={["btn", @btn_class]} disabled={@disabled} {@rest}>
{render_slot(@inner_block)} {render_slot(@inner_block)}
</button> </button>
""" """
end end
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" /></.button>
</.tooltip>
<.tooltip content={@full_name} position="top">
<span class="truncate max-w-32">{@full_name}</span>
</.tooltip>
"""
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"""
<div class={@wrap_class} data-tip={@content}>
{render_slot(@inner_block)}
</div>
"""
end
@doc """ @doc """
Renders a dropdown menu. Renders a dropdown menu.
@ -437,7 +511,7 @@ defmodule MvWeb.CoreComponents do
{@rest} {@rest}
/>{@label}<span />{@label}<span
:if={@is_required} :if={@is_required}
class="text-red-700 tooltip tooltip-right" class="text-error tooltip tooltip-right"
data-tip={gettext("This field is required")} data-tip={gettext("This field is required")}
>*</span> >*</span>
</span> </span>
@ -456,7 +530,7 @@ defmodule MvWeb.CoreComponents do
<span :if={@label} class="mb-1 label"> <span :if={@label} class="mb-1 label">
{@label}<span {@label}<span
:if={@rest[:required]} :if={@rest[:required]}
class="text-red-700 tooltip tooltip-right" class="text-error tooltip tooltip-right"
data-tip={gettext("This field cannot be empty")} data-tip={gettext("This field cannot be empty")}
>*</span> >*</span>
</span> </span>
@ -485,7 +559,7 @@ defmodule MvWeb.CoreComponents do
<span :if={@label} class="mb-1 label"> <span :if={@label} class="mb-1 label">
{@label}<span {@label}<span
:if={@rest[:required]} :if={@rest[:required]}
class="text-red-700 tooltip tooltip-right" class="text-error tooltip tooltip-right"
data-tip={gettext("This field cannot be empty")} data-tip={gettext("This field cannot be empty")}
>*</span> >*</span>
</span> </span>
@ -514,7 +588,7 @@ defmodule MvWeb.CoreComponents do
<span :if={@label} class="mb-1 label"> <span :if={@label} class="mb-1 label">
{@label}<span {@label}<span
:if={@rest[:required]} :if={@rest[:required]}
class="text-red-700 tooltip tooltip-right" class="text-error tooltip tooltip-right"
data-tip={gettext("This field cannot be empty")} data-tip={gettext("This field cannot be empty")}
>*</span> >*</span>
</span> </span>
@ -559,17 +633,24 @@ defmodule MvWeb.CoreComponents do
@doc """ @doc """
Renders a header with title. 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 attr :class, :string, default: nil
slot :leading, doc: "Content on the left (e.g. Back button)"
slot :inner_block, required: true slot :inner_block, required: true
slot :subtitle slot :subtitle
slot :actions slot :actions
def header(assigns) do def header(assigns) do
~H""" ~H"""
<header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4", @class]}> <header class={["flex items-center gap-6 pb-4", @class]}>
<div> <div :if={@leading != []} class="shrink-0">
{render_slot(@leading)}
</div>
<div class="min-w-0 flex-1">
<h1 class="text-xl font-semibold leading-8"> <h1 class="text-xl font-semibold leading-8">
{render_slot(@inner_block)} {render_slot(@inner_block)}
</h1> </h1>
@ -577,7 +658,9 @@ defmodule MvWeb.CoreComponents do
{render_slot(@subtitle)} {render_slot(@subtitle)}
</p> </p>
</div> </div>
<div class="flex gap-4 justify-end">{render_slot(@actions)}</div> <div :if={@actions != []} class="shrink-0 flex gap-4 justify-end">
{render_slot(@actions)}
</div>
</header> </header>
""" """
end end
@ -585,18 +668,51 @@ defmodule MvWeb.CoreComponents do
@doc ~S""" @doc ~S"""
Renders a table with generic styling. 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 `<td>`, 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 ## Examples
<.table id="users" rows={@users}> <.table id="users" rows={@users}>
<:col :let={user} label="id">{user.id}</:col> <:col :let={user} label="id">{user.id}</:col>
<:col :let={user} label="username">{user.username}</:col> <:col :let={user} label="username">{user.username}</:col>
</.table> </.table>
<.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}</:col>
</.table>
""" """
attr :id, :string, required: true attr :id, :string, required: true
attr :rows, :list, required: true attr :rows, :list, required: true
attr :row_id, :any, default: nil, doc: "the function for generating the row id" 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 :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, attr :row_item, :any,
default: &Function.identity/1, default: &Function.identity/1,
doc: "the function for mapping each row before calling the :col and :action slots" doc: "the function for mapping each row before calling the :col and :action slots"
@ -608,6 +724,11 @@ defmodule MvWeb.CoreComponents do
attr :sort_field, :any, default: nil, doc: "current sort field" attr :sort_field, :any, default: nil, doc: "current sort field"
attr :sort_order, :atom, default: nil, doc: "current sort order" 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 slot :col, required: true do
attr :label, :string attr :label, :string
attr :class, :string attr :class, :string
@ -625,6 +746,12 @@ defmodule MvWeb.CoreComponents do
assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end) assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
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""" ~H"""
<div class="overflow-auto"> <div class="overflow-auto">
<table class="table table-zebra"> <table class="table table-zebra">
@ -632,12 +759,12 @@ defmodule MvWeb.CoreComponents do
<tr> <tr>
<th <th
:for={col <- @col} :for={col <- @col}
class={Map.get(col, :class)} class={table_th_class(col, @sticky_header)}
aria-sort={table_th_aria_sort(col, @sort_field, @sort_order)} aria-sort={table_th_aria_sort(col, @sort_field, @sort_order)}
> >
{col[:label]} {col[:label]}
</th> </th>
<th :for={dyn_col <- @dynamic_cols}> <th :for={dyn_col <- @dynamic_cols} class={table_th_sticky_class(@sticky_header)}>
<.live_component <.live_component
module={MvWeb.Components.SortHeaderComponent} module={MvWeb.Components.SortHeaderComponent}
id={:"sort_custom_field_#{dyn_col[:custom_field].id}"} id={:"sort_custom_field_#{dyn_col[:custom_field].id}"}
@ -647,15 +774,21 @@ defmodule MvWeb.CoreComponents do
sort_order={@sort_order} sort_order={@sort_order}
/> />
</th> </th>
<th :if={@action != []}> <th :if={@action != []} class={table_th_sticky_class(@sticky_header)}>
<span class="sr-only">{gettext("Actions")}</span> <span class="sr-only">{gettext("Actions")}</span>
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}> <tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}> <tr
:for={row <- @rows}
id={@row_id && @row_id.(row)}
class={table_row_tr_class(@row_click, table_row_selected?(assigns, row))}
data-selected={table_row_selected?(assigns, row) && "true"}
title={@row_click && @row_tooltip}
>
<td <td
:for={col <- @col} :for={{col, col_idx} <- Enum.with_index(@col)}
phx-click={ phx-click={
(col[:col_click] && col[:col_click].(@row_item.(row))) || (col[:col_click] && col[:col_click].(@row_item.(row))) ||
(@row_click && @row_click.(row)) (@row_click && @row_click.(row))
@ -689,6 +822,9 @@ defmodule MvWeb.CoreComponents do
Enum.join(classes, " ") Enum.join(classes, " ")
} }
> >
<%= if col_idx == 0 && @row_click && @row_tooltip do %>
<span class="sr-only">{@row_tooltip}</span>
<% end %>
{render_slot(col, @row_item.(row))} {render_slot(col, @row_item.(row))}
</td> </td>
<td <td
@ -722,6 +858,43 @@ defmodule MvWeb.CoreComponents do
""" """
end end
# Returns true if the row is selected (via row_selected?/1 or selected_row_id match).
defp table_row_selected?(assigns, row) do
item = assigns.row_item.(row)
if assigns[:row_selected?] do
assigns.row_selected?.(item)
else
assigns[:selected_row_id] != nil and
assigns.row_value_id_fn.(row) == assigns.selected_row_id
end
end
# Returns CSS classes for table row: hover/focus-within outline when row_click is set,
# and stronger selected outline when selected (WCAG: not color-only).
# Hover/focus-within are omitted for the selected row so the selected ring stays visible.
defp table_row_tr_class(row_click, selected?) do
has_row_click? = not is_nil(row_click)
base = []
base =
if has_row_click? and not selected?,
do:
base ++
[
"hover:ring-2",
"hover:ring-inset",
"hover:ring-base-content/10",
"focus-within:ring-2",
"focus-within:ring-inset",
"focus-within:ring-base-content/10"
],
else: base
base = if selected?, do: base ++ ["ring-2", "ring-inset", "ring-primary"], else: base
Enum.join(base, " ")
end
defp table_th_aria_sort(col, sort_field, sort_order) do defp table_th_aria_sort(col, sort_field, sort_order) do
col_sort = Map.get(col, :sort_field) col_sort = Map.get(col, :sort_field)
@ -732,6 +905,18 @@ defmodule MvWeb.CoreComponents do
end end
end end
# Combines column class with optional sticky header classes (desktop only; theme-friendly bg).
defp table_th_class(col, sticky_header) do
base = Map.get(col, :class)
sticky = if sticky_header, do: "lg:sticky lg:top-0 bg-base-100 z-10", else: nil
[base, sticky] |> 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 """ @doc """
Renders a data list. Renders a data list.

View file

@ -115,7 +115,11 @@ defmodule MvWeb.Layouts do
def flash_group(assigns) do def flash_group(assigns) do
~H""" ~H"""
<div id={@id} aria-live="polite" class="z-50 flex flex-col gap-2 toast toast-top toast-end"> <div
id={@id}
aria-live="polite"
class="z-50 toast toast-bottom toast-end flex flex-col gap-2 pointer-events-none"
>
<.flash kind={:success} flash={@flash} /> <.flash kind={:success} flash={@flash} />
<.flash kind={:warning} flash={@flash} /> <.flash kind={:warning} flash={@flash} />
<.flash kind={:info} flash={@flash} /> <.flash kind={:info} flash={@flash} />

View file

@ -72,7 +72,7 @@
<div <div
id="flash-group-root" id="flash-group-root"
aria-live="polite" aria-live="polite"
class="z-50 flex flex-col gap-2 toast toast-top toast-end" class="z-50 flex flex-col gap-2 toast toast-bottom toast-end"
> >
<.flash id="flash-success-root" kind={:success} flash={@flash} /> <.flash id="flash-success-root" kind={:success} flash={@flash} />
<.flash id="flash-warning-root" kind={:warning} flash={@flash} /> <.flash id="flash-warning-root" kind={:warning} flash={@flash} />

View file

@ -31,7 +31,7 @@ defmodule MvWeb.AuthController do
|> store_in_session(user) |> store_in_session(user)
# If your resource has a different name, update the assign name here (i.e :current_admin) # If your resource has a different name, update the assign name here (i.e :current_admin)
|> assign(:current_user, user) |> assign(:current_user, user)
|> put_flash(:info, message) |> put_flash(:success, message)
|> redirect(to: return_to) |> redirect(to: return_to)
end end
@ -322,7 +322,7 @@ defmodule MvWeb.AuthController do
conn conn
|> clear_session(:mv) |> clear_session(:mv)
|> put_flash(:info, gettext("You are now signed out")) |> put_flash(:success, gettext("You are now signed out"))
|> redirect(to: return_to) |> redirect(to: return_to)
end end
end end

View file

@ -81,7 +81,7 @@ defmodule MvWeb.LinkOidcAccountLive do
socket socket
|> put_flash( |> put_flash(
:info, :success,
dgettext("auth", "Account activated! Redirecting to complete sign-in...") dgettext("auth", "Account activated! Redirecting to complete sign-in...")
) )
|> Phoenix.LiveView.redirect(to: ~p"/auth/user/oidc") |> Phoenix.LiveView.redirect(to: ~p"/auth/user/oidc")
@ -217,7 +217,7 @@ defmodule MvWeb.LinkOidcAccountLive do
{:noreply, {:noreply,
socket socket
|> put_flash( |> put_flash(
:info, :success,
dgettext( dgettext(
"auth", "auth",
"Your OIDC account has been successfully linked! Redirecting to complete sign-in..." "Your OIDC account has been successfully linked! Redirecting to complete sign-in..."

View file

@ -188,7 +188,7 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
defp find_custom_field_name(id, _field_string, custom_fields) 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 case Enum.find(custom_fields, fn cf -> to_string(cf.id) == id end) do
nil -> gettext("Custom Field %{id}", id: id) nil -> gettext("Datafield %{id}", id: id)
custom_field -> custom_field.name custom_field -> custom_field.name
end end
end end

View file

@ -64,11 +64,11 @@ defmodule MvWeb.Components.MemberFilterComponent do
phx-key="Escape" phx-key="Escape"
phx-target={@myself} phx-target={@myself}
> >
<button <.button
type="button" type="button"
tabindex="0" variant="secondary"
class={[ class={[
"btn gap-2", "gap-2",
(@cycle_status_filter || map_size(@group_filters) > 0 || (@cycle_status_filter || map_size(@group_filters) > 0 ||
active_boolean_filters_count(@boolean_filters) > 0) && active_boolean_filters_count(@boolean_filters) > 0) &&
"btn-active" "btn-active"
@ -104,7 +104,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
> >
{@member_count} {@member_count}
</span> </span>
</button> </.button>
<!-- <!--
NOTE: We use a div panel instead of ul.menu/li structure to avoid DaisyUI menu styles NOTE: We use a div panel instead of ul.menu/li structure to avoid DaisyUI menu styles
@ -252,7 +252,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
<!-- Custom Fields Group --> <!-- Custom Fields Group -->
<div :if={length(@boolean_custom_fields) > 0} class="mb-2"> <div :if={length(@boolean_custom_fields) > 0} class="mb-2">
<div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider"> <div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider">
{gettext("Custom Fields")} {gettext("Individual datafields")}
</div> </div>
<div class="max-h-60 overflow-y-auto pr-2"> <div class="max-h-60 overflow-y-auto pr-2">
<fieldset <fieldset
@ -318,22 +318,24 @@ defmodule MvWeb.Components.MemberFilterComponent do
<!-- Footer --> <!-- Footer -->
<div class="mt-4 flex justify-between pt-3 border-t border-base-200"> <div class="mt-4 flex justify-between pt-3 border-t border-base-200">
<button <.button
type="button" type="button"
variant="neutral"
size="sm"
phx-click="reset_filters" phx-click="reset_filters"
phx-target={@myself} phx-target={@myself}
class="btn btn-sm"
> >
{gettext("Reset")} {gettext("Clear filters")}
</button> </.button>
<button <.button
type="button" type="button"
variant="primary"
size="sm"
phx-click="close_dropdown" phx-click="close_dropdown"
phx-target={@myself} phx-target={@myself}
class="btn btn-primary btn-sm"
> >
{gettext("Close")} {gettext("Close")}
</button> </.button>
</div> </div>
</form> </form>
</div> </div>
@ -458,7 +460,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
boolean_filter_label(boolean_custom_fields, boolean_filters) boolean_filter_label(boolean_custom_fields, boolean_filters)
true -> true ->
gettext("All") gettext("Apply filters")
end end
end end

View file

@ -19,25 +19,28 @@ defmodule MvWeb.Components.SortHeaderComponent do
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div class="tooltip tooltip-bottom" data-tip={aria_sort(@field, @sort_field, @sort_order)}> <div>
<button <.tooltip content={aria_sort(@field, @sort_field, @sort_order)} position="bottom">
type="button" <.button
aria-label={aria_sort(@field, @sort_field, @sort_order)} type="button"
class="btn btn-ghost select-none" variant="ghost"
phx-click="sort" aria-label={aria_sort(@field, @sort_field, @sort_order)}
phx-value-field={@field} class="select-none"
data-testid={@field} phx-click="sort"
> phx-value-field={@field}
{@label} data-testid={@field}
<%= if @sort_field == @field do %> >
<.icon name={if @sort_order == :asc, do: "hero-chevron-up", else: "hero-chevron-down"} /> {@label}
<% else %> <%= if @sort_field == @field do %>
<.icon <.icon name={if @sort_order == :asc, do: "hero-chevron-up", else: "hero-chevron-down"} />
name="hero-chevron-up-down" <% else %>
class="opacity-40" <.icon
/> name="hero-chevron-up-down"
<% end %> class="opacity-40"
</button> />
<% end %>
</.button>
</.tooltip>
</div> </div>
""" """
end end

View file

@ -24,11 +24,13 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
<div class="flex items-center gap-4 mb-4"> <div class="flex items-center gap-4 mb-4">
<.button <.button
type="button" type="button"
variant="neutral"
phx-click="cancel" phx-click="cancel"
phx-target={@myself} phx-target={@myself}
aria-label={gettext("Back to settings")} aria-label={gettext("Back to settings")}
> >
<.icon name="hero-arrow-left" class="w-4 h-4" /> <.icon name="hero-arrow-left" class="size-4" />
{gettext("Back")}
</.button> </.button>
<h3 class="card-title"> <h3 class="card-title">
{if @custom_field, do: gettext("Edit Data Field"), else: gettext("New Data Field")} {if @custom_field, do: gettext("Edit Data Field"), else: gettext("New Data Field")}
@ -96,8 +98,35 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
label={gettext("Show in overview")} label={gettext("Show in overview")}
/> />
<%= if @custom_field do %>
<%!-- Danger zone: canonical pattern (same as member form) --%>
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
{gettext("Danger zone")}
</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
<p class="text-base-content/70 mb-4">
{gettext(
"Deleting this data field cannot be undone. All datafield values for this field will be permanently removed."
)}
</p>
<.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")}
</.button>
</div>
</section>
<% end %>
<div class="justify-end mt-4 card-actions"> <div class="justify-end mt-4 card-actions">
<.button type="button" phx-click="cancel" phx-target={@myself}> <.button type="button" variant="neutral" phx-click="cancel" phx-target={@myself}>
{gettext("Cancel")} {gettext("Cancel")}
</.button> </.button>
<.button phx-disable-with={gettext("Saving...")} variant="primary"> <.button phx-disable-with={gettext("Saving...")} variant="primary">
@ -168,6 +197,15 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
{:noreply, socket} {:noreply, socket}
end 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 defp assign_form(%{assigns: %{custom_field: custom_field}} = socket) do
form = form =
if custom_field do if custom_field do

View file

@ -59,6 +59,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself) JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself)
end end
} }
row_tooltip={gettext("Click to edit datafield")}
> >
<:col :let={{_id, custom_field}} label={gettext("Name")}>{custom_field.name}</:col> <:col :let={{_id, custom_field}} label={gettext("Name")}>{custom_field.name}</:col>
@ -95,22 +96,6 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
{gettext("No")} {gettext("No")}
</span> </span>
</:col> </:col>
<:action :let={{_id, custom_field}}>
<.link phx-click={
JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself)
}>
{gettext("Edit")}
</.link>
</:action>
<:action :let={{_id, custom_field}}>
<.link phx-click={
JS.push("prepare_delete", value: %{id: custom_field.id}, target: @myself)
}>
{gettext("Delete")}
</.link>
</:action>
</.table> </.table>
</div> </div>
@ -164,17 +149,17 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
</div> </div>
<div class="modal-action"> <div class="modal-action">
<button phx-click="cancel_delete" phx-target={@myself} class="btn"> <.button variant="neutral" phx-click="cancel_delete" phx-target={@myself}>
{gettext("Cancel")} {gettext("Cancel")}
</button> </.button>
<button <.button
variant="danger"
phx-click="confirm_delete" phx-click="confirm_delete"
phx-target={@myself} phx-target={@myself}
class="btn btn-error"
disabled={@slug_confirmation != @custom_field_to_delete.slug} disabled={@slug_confirmation != @custom_field_to_delete.slug}
> >
{gettext("Delete Custom Field and All Values")} {gettext("Delete Datafields and All Values")}
</button> </.button>
</div> </div>
</div> </div>
</dialog> </dialog>
@ -222,16 +207,38 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
# Get actor from assigns or fall back to socket assigns # Get actor from assigns or fall back to socket assigns
actor = Map.get(assigns, :actor, socket.assigns[:actor]) actor = Map.get(assigns, :actor, socket.assigns[:actor])
{:ok, socket =
socket socket
|> assign(assigns) |> assign(assigns)
|> assign_new(:show_form, fn -> false end) |> assign_new(:show_form, fn -> false end)
|> assign_new(:form_id, fn -> "custom-field-form-new" end) |> assign_new(:form_id, fn -> "custom-field-form-new" end)
|> assign_new(:editing_custom_field, fn -> nil end) |> assign_new(:editing_custom_field, fn -> nil end)
|> assign_new(:show_delete_modal, fn -> false end) |> assign_new(:show_delete_modal, fn -> false end)
|> assign_new(:custom_field_to_delete, fn -> nil end) |> assign_new(:custom_field_to_delete, fn -> nil end)
|> assign_new(:slug_confirmation, fn -> "" end) |> assign_new(:slug_confirmation, fn -> "" end)
|> stream(:custom_fields, stream_custom_fields(actor, self()), reset: true)} |> 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}
end end
@impl true @impl true

View file

@ -64,12 +64,12 @@ defmodule MvWeb.DatafieldsLive do
{:noreply, {:noreply,
socket socket
|> assign(:active_editing_section, nil) |> assign(:active_editing_section, nil)
|> put_flash(:info, gettext("Data field %{action} successfully", action: action))} |> put_flash(:success, gettext("Data field %{action} successfully", action: action))}
end end
@impl true @impl true
def handle_info({:custom_field_deleted, _custom_field}, socket) do def handle_info({:custom_field_deleted, _custom_field}, socket) do
{:noreply, put_flash(socket, :info, gettext("Data field deleted successfully"))} {:noreply, put_flash(socket, :success, gettext("Data field deleted successfully"))}
end end
@impl true @impl true
@ -101,6 +101,17 @@ defmodule MvWeb.DatafieldsLive do
{:noreply, assign(socket, :active_editing_section, section)} {:noreply, assign(socket, :active_editing_section, section)}
end 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 @impl true
def handle_info({:member_field_saved, _member_field, action}, socket) do def handle_info({:member_field_saved, _member_field, action}, socket) do
{:ok, updated_settings} = Membership.get_settings() {:ok, updated_settings} = Membership.get_settings()
@ -115,7 +126,7 @@ defmodule MvWeb.DatafieldsLive do
socket socket
|> assign(:settings, updated_settings) |> assign(:settings, updated_settings)
|> assign(:active_editing_section, nil) |> assign(:active_editing_section, nil)
|> put_flash(:info, gettext("Member field %{action} successfully", action: action))} |> put_flash(:success, gettext("Member field %{action} successfully", action: action))}
end end
@impl true @impl true

View file

@ -95,7 +95,7 @@ defmodule MvWeb.GlobalSettingsLive do
</div> </div>
<.button phx-disable-with={gettext("Saving...")} variant="primary"> <.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save Settings")} {gettext("Save Name")}
</.button> </.button>
</.form> </.form>
</.form_section> </.form_section>
@ -181,18 +181,18 @@ defmodule MvWeb.GlobalSettingsLive do
<.button <.button
:if={Mv.Config.vereinfacht_configured?()} :if={Mv.Config.vereinfacht_configured?()}
type="button" type="button"
variant="outline"
phx-click="test_vereinfacht_connection" phx-click="test_vereinfacht_connection"
phx-disable-with={gettext("Testing...")} phx-disable-with={gettext("Testing...")}
class="btn-outline"
> >
{gettext("Test Integration")} {gettext("Test Integration")}
</.button> </.button>
<.button <.button
:if={Mv.Config.vereinfacht_configured?()} :if={Mv.Config.vereinfacht_configured?()}
type="button" type="button"
variant="outline"
phx-click="sync_vereinfacht_contacts" phx-click="sync_vereinfacht_contacts"
phx-disable-with={gettext("Syncing...")} phx-disable-with={gettext("Syncing...")}
class="btn-outline"
> >
{gettext("Sync all members without Vereinfacht contact")} {gettext("Sync all members without Vereinfacht contact")}
</.button> </.button>
@ -357,20 +357,21 @@ defmodule MvWeb.GlobalSettingsLive do
errors_with_names = enrich_sync_errors(errors) errors_with_names = enrich_sync_errors(errors)
result = %{synced: synced, errors: errors_with_names} 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 =
socket socket
|> assign(:last_vereinfacht_sync_result, result) |> assign(:last_vereinfacht_sync_result, result)
|> put_flash( |> put_flash(flash_kind, flash_message)
: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} {:noreply, socket}
@ -409,7 +410,7 @@ defmodule MvWeb.GlobalSettingsLive do
|> assign(:oidc_client_secret_set, present?(fresh_settings.oidc_client_secret)) |> assign(:oidc_client_secret_set, present?(fresh_settings.oidc_client_secret))
|> assign(:oidc_configured, Mv.Config.oidc_configured?()) |> assign(:oidc_configured, Mv.Config.oidc_configured?())
|> assign(:vereinfacht_test_result, test_result) |> assign(:vereinfacht_test_result, test_result)
|> put_flash(:info, gettext("Settings updated successfully")) |> put_flash(:success, gettext("Settings updated successfully"))
|> assign_form() |> assign_form()
{:noreply, socket} {:noreply, socket}

View file

@ -78,30 +78,56 @@ defmodule MvWeb.GroupLive.Form do
~H""" ~H"""
<Layouts.app flash={@flash} current_user={@current_user}> <Layouts.app flash={@flash} current_user={@current_user}>
<.form for={@form} id="group-form" phx-change="validate" phx-submit="save"> <.form for={@form} id="group-form" phx-change="validate" phx-submit="save">
<%!-- Header with Back button, Title, and Save button --%> <.header>
<div class="flex items-center justify-between gap-4 pb-4"> <:leading>
<.button navigate={return_path(@return_to, @group)} type="button"> <.button navigate={return_path(@return_to, @group)} variant="neutral">
<.icon name="hero-arrow-left" class="size-4" /> <.icon name="hero-arrow-left" class="size-4" />
{gettext("Back")} {gettext("Back")}
</.button> </.button>
</:leading>
{@page_title}
<:actions>
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save")}
</.button>
</:actions>
</.header>
<h1 class="text-2xl font-bold text-center flex-1"> <div class="mt-6 space-y-6">
{@page_title} <div class="max-w-2xl space-y-4">
</h1> <.input field={@form[:name]} label={gettext("Name")} required />
<.input
field={@form[:description]}
type="textarea"
label={gettext("Description")}
rows="4"
/>
</div>
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit"> <%!-- Danger zone: canonical pattern (same as member form) --%>
{gettext("Save")} <%= if @group && can?(@current_user, :destroy, @group) do %>
</.button> <section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
</div> <h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
{gettext("Danger zone")}
<div class="max-w-2xl space-y-4"> </h2>
<.input field={@form[:name]} label={gettext("Name")} required /> <div class="border border-base-300 rounded-lg p-4 bg-base-100">
<.input <p class="text-base-content/70 mb-4">
field={@form[:description]} {gettext(
type="textarea" "Deleting this group cannot be undone. All member-group associations will be permanently removed."
label={gettext("Description")} )}
rows="4" </p>
/> <.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")}
</.button>
</div>
</section>
<% end %>
</div> </div>
</.form> </.form>
</Layouts.app> </Layouts.app>
@ -129,7 +155,7 @@ defmodule MvWeb.GroupLive.Form do
socket = socket =
socket socket
|> put_flash(:info, gettext("Group saved successfully.")) |> put_flash(:success, gettext("Group saved successfully."))
|> push_navigate(to: redirect_path) |> push_navigate(to: redirect_path)
{:noreply, socket} {:noreply, socket}

View file

@ -39,72 +39,47 @@ defmodule MvWeb.GroupLive.Index do
def render(assigns) do def render(assigns) do
~H""" ~H"""
<Layouts.app flash={@flash} current_user={@current_user}> <Layouts.app flash={@flash} current_user={@current_user}>
<div class="flex items-center justify-between mb-6"> <.header>
<h1 class="text-2xl font-bold">{gettext("Groups")}</h1> {gettext("Groups")}
<%= if can?(@current_user, :create, Mv.Membership.Group) do %> <:actions>
<.button navigate={~p"/groups/new"} variant="primary"> <%= if can?(@current_user, :create, Mv.Membership.Group) do %>
<.icon name="hero-plus" class="size-4 mr-2" /> <.button navigate={~p"/groups/new"} variant="primary">
{gettext("Create Group")} <.icon name="hero-plus" class="size-4 mr-2" />
</.button> {gettext("Create Group")}
</.button>
<% end %>
</:actions>
</.header>
<div class="mt-6 space-y-6">
<%= if Enum.empty?(@groups) do %>
<div class="text-center py-12">
<p class="text-base-content/60 italic">{gettext("No groups")}</p>
</div>
<% 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>
<:col :let={group} label={gettext("Description")}>
<%= if group.description do %>
{group.description}
<% else %>
<span class="text-base-content/50 italic"></span>
<% end %>
</:col>
<:col :let={group} label={gettext("Members")} class="text-right">
{group.member_count || 0}
</:col>
</.table>
<% end %> <% end %>
</div> </div>
<%= if Enum.empty?(@groups) do %>
<div class="text-center py-12">
<p class="text-base-content/70">{gettext("No groups")}</p>
</div>
<% else %>
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>{gettext("Name")}</th>
<th>{gettext("Description")}</th>
<th>{gettext("Members")}</th>
<th>{gettext("Actions")}</th>
</tr>
</thead>
<tbody>
<%= for group <- @groups do %>
<tr>
<td>
{group.name}
</td>
<td>
<%= if group.description do %>
{group.description}
<% else %>
<span class="text-base-content/50 italic"></span>
<% end %>
</td>
<td>
<%= if group.member_count do %>
{group.member_count}
<% else %>
0
<% end %>
</td>
<td>
<div class="flex gap-2">
<.link navigate={~p"/groups/#{group.slug}"} class="btn btn-sm btn-ghost">
{gettext("View")}
</.link>
<%= if can?(@current_user, :update, Mv.Membership.Group) do %>
<.link
navigate={~p"/groups/#{group.slug}/edit"}
class="btn btn-sm btn-ghost"
>
{gettext("Edit")}
</.link>
<% end %>
</div>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
</Layouts.app> </Layouts.app>
""" """
end end

View file

@ -39,18 +39,18 @@ defmodule MvWeb.GroupLive.Show do
end end
@impl true @impl true
def handle_params(%{"slug" => slug}, _url, socket) do def handle_params(%{"slug" => slug} = params, _url, socket) do
actor = current_actor(socket) actor = current_actor(socket)
# Check if user can read groups # Check if user can read groups
if can?(actor, :read, Mv.Membership.Group) do if can?(actor, :read, Mv.Membership.Group) do
load_group_by_slug(socket, slug, actor) load_group_by_slug(socket, slug, actor, params)
else else
{:noreply, redirect(socket, to: ~p"/members")} {:noreply, redirect(socket, to: ~p"/members")}
end end
end end
defp load_group_by_slug(socket, slug, actor) do defp load_group_by_slug(socket, slug, actor, params) do
# Load group with members and member_count # Load group with members and member_count
# Using explicit load ensures efficient preloading of members relationship # Using explicit load ensures efficient preloading of members relationship
require Ash.Query require Ash.Query
@ -68,10 +68,16 @@ defmodule MvWeb.GroupLive.Show do
|> redirect(to: ~p"/groups")} |> redirect(to: ~p"/groups")}
{:ok, group} -> {:ok, group} ->
{:noreply, open_delete = params["confirm_delete"] == "1" && can?(actor, :destroy, group)
socket
|> assign(:page_title, group.name) socket =
|> assign(:group, group)} socket
|> assign(:page_title, group.name)
|> assign(:group, group)
|> assign(:show_delete_modal, open_delete)
|> assign(:name_confirmation, "")
{:noreply, socket}
{:error, _error} -> {:error, _error} ->
{:noreply, {:noreply,
@ -85,318 +91,346 @@ defmodule MvWeb.GroupLive.Show do
def render(assigns) do def render(assigns) do
~H""" ~H"""
<Layouts.app flash={@flash} current_user={@current_user}> <Layouts.app flash={@flash} current_user={@current_user}>
<%!-- Header with Back button, Name, and Edit/Delete buttons --%> <.header>
<div class="flex items-center justify-between gap-4 pb-4"> <:leading>
<.button navigate={~p"/groups"} aria-label={gettext("Back to groups list")}> <.button
<.icon name="hero-arrow-left" class="size-4" /> navigate={~p"/groups"}
{gettext("Back")} variant="neutral"
</.button> aria-label={gettext("Back to groups list")}
>
<h1 class="text-2xl font-bold text-center flex-1"> <.icon name="hero-arrow-left" class="size-4" />
{@group.name} {gettext("Back")}
</h1> </.button>
</:leading>
<div class="flex gap-2"> {@group.name}
<:actions>
<%= if can?(@current_user, :update, @group) do %> <%= if can?(@current_user, :update, @group) do %>
<.button <.button
variant="primary" variant="primary"
navigate={~p"/groups/#{@group.slug}/edit"} navigate={~p"/groups/#{@group.slug}/edit"}
data-testid="group-show-edit-btn" data-testid="group-show-edit-btn"
> >
{gettext("Edit")} <.icon name="hero-pencil-square" /> {gettext("Edit group")}
</.button> </.button>
<% end %> <% end %>
<%= if can?(@current_user, :destroy, @group) do %> </:actions>
<.button </.header>
class="btn-error"
phx-click="open_delete_modal"
data-testid="group-show-delete-btn"
>
{gettext("Delete")}
</.button>
<% end %>
</div>
</div>
<%!-- Group Information --%> <div class="mt-6 space-y-6">
<div class="max-w-2xl space-y-6 mb-6"> <%!-- Group Information --%>
<div> <div class="max-w-2xl space-y-6 mb-6">
<h2 class="text-lg font-semibold mb-2">{gettext("Description")}</h2> <div>
<div class="border border-base-300 rounded-lg p-4 bg-base-100"> <h2 class="text-lg font-semibold mb-2">{gettext("Description")}</h2>
<%= if @group.description && String.trim(@group.description) != "" do %> <div class="border border-base-300 rounded-lg p-4 bg-base-100">
<p class="whitespace-pre-wrap">{@group.description}</p> <%= if @group.description && String.trim(@group.description) != "" do %>
<% else %> <p class="whitespace-pre-wrap">{@group.description}</p>
<p class="text-base-content/50 italic">{gettext("No description")}</p> <% else %>
<% end %> <p class="text-base-content/50 italic">{gettext("No description")}</p>
<% end %>
</div>
</div> </div>
</div>
<div> <div>
<h2 class="text-lg font-semibold mb-2">{gettext("Members")}</h2> <h2 class="text-lg font-semibold mb-2">{gettext("Members")}</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100"> <div class="border border-base-300 rounded-lg p-4 bg-base-100">
<p class="mb-4" data-testid="group-show-member-count"> <p class="mb-4" data-testid="group-show-member-count">
{ngettext( {ngettext(
"Total: %{count} member", "Total: %{count} member",
"Total: %{count} members", "Total: %{count} members",
@group.member_count || 0, @group.member_count || 0,
count: @group.member_count || 0 count: @group.member_count || 0
)} )}
</p>
<%= if can?(@current_user, :update, @group) do %>
<div class="mb-4">
<%= if assigns[:show_add_member_input] do %>
<div class="join w-full">
<form phx-change="search_members" class="flex-1">
<div class="relative">
<div class="input input-bordered join-item w-full flex flex-wrap gap-1 items-center py-1 px-2">
<%= for member <- @selected_members do %>
<span class="badge badge-outline badge flex items-center gap-1">
{MvWeb.Helpers.MemberHelpers.display_name(member)}
<button
type="button"
class="btn btn-ghost btn-xs p-0 h-4 w-4 min-h-0"
phx-click="remove_selected_member"
phx-value-member_id={member.id}
aria-label={
gettext("Remove %{name}",
name: MvWeb.Helpers.MemberHelpers.display_name(member)
)
}
>
<.icon name="hero-x-mark" class="size-3" />
</button>
</span>
<% end %>
<input
type="text"
id="member-search-input"
data-testid="group-show-member-search-input"
role="combobox"
phx-hook="ComboBox"
phx-focus="show_member_dropdown"
phx-debounce="300"
phx-keydown="member_dropdown_keydown"
phx-mounted={JS.focus()}
value={@member_search_query}
placeholder={
if Enum.empty?(@selected_members),
do: gettext("Search for a member..."),
else: ""
}
class="flex-1 min-w-[120px] border-0 focus:outline-none bg-transparent"
name="member_search"
aria-label={gettext("Search for a member")}
aria-autocomplete="list"
aria-controls="member-dropdown"
aria-expanded={to_string(@show_member_dropdown)}
aria-activedescendant={
if @focused_member_index,
do: "member-option-#{@focused_member_index}",
else: nil
}
autocomplete="off"
/>
</div>
<%= if length(@available_members) > 0 do %>
<div
id="member-dropdown"
role="listbox"
aria-label={gettext("Available members")}
class={"absolute z-10 w-full mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg max-h-60 overflow-auto top-full #{if !@show_member_dropdown, do: "hidden"}"}
phx-click-away="hide_member_dropdown"
>
<%= for {member, index} <- Enum.with_index(@available_members) do %>
<div
id={"member-option-#{index}"}
role="option"
tabindex="0"
aria-selected={to_string(@focused_member_index == index)}
phx-click="select_member"
phx-value-id={member.id}
data-member-id={member.id}
class={[
"px-4 py-3 cursor-pointer border-b border-base-300 last:border-b-0",
if(@focused_member_index == index,
do: "bg-base-300",
else: "hover:bg-base-200"
)
]}
>
<p class="font-medium">
{MvWeb.Helpers.MemberHelpers.display_name(member)}
</p>
<p class="text-sm text-base-content/70">
{member.email || gettext("No email")}
</p>
</div>
<% end %>
</div>
<% end %>
</div>
</form>
<button
type="button"
class="btn btn-primary join-item"
phx-click="add_selected_members"
data-testid="group-show-add-selected-members-btn"
disabled={Enum.empty?(@selected_member_ids)}
aria-label={gettext("Add members")}
>
<.icon name="hero-plus" class="size-5" />
</button>
<button
type="button"
class="btn join-item"
phx-click="hide_add_member_input"
aria-label={gettext("Cancel")}
>
{gettext("Cancel")}
</button>
</div>
<% else %>
<.button
variant="primary"
phx-click="show_add_member_input"
aria-label={gettext("Add Member")}
>
{gettext("Add Member")}
</.button>
<% end %>
</div>
<% end %>
<%= if Enum.empty?(@group.members || []) do %>
<p class="text-base-content/50 italic" data-testid="group-show-no-members">
{gettext("No members in this group")}
</p> </p>
<% else %>
<div class="overflow-x-auto" data-testid="group-show-members-table"> <%= if can?(@current_user, :update, @group) do %>
<table class="table table-zebra"> <div class="mb-4">
<thead> <%= if assigns[:show_add_member_input] do %>
<tr> <div class="join w-full">
<th>{gettext("Name")}</th> <form phx-change="search_members" class="flex-1">
<th>{gettext("Email")}</th> <div class="relative">
<%= if can?(@current_user, :update, @group) do %> <div class="input input-bordered join-item w-full flex flex-wrap gap-1 items-center py-1 px-2">
<th class="w-0">{gettext("Actions")}</th> <%= for member <- @selected_members do %>
<% end %> <span class="badge badge-outline badge flex items-center gap-1">
</tr> {MvWeb.Helpers.MemberHelpers.display_name(member)}
</thead> <.tooltip content={gettext("Remove")} position="top">
<tbody> <.button
<%= for member <- @group.members do %> type="button"
<tr> variant="icon"
<td> size="sm"
<.link phx-click="remove_selected_member"
navigate={~p"/members/#{member.id}"} phx-value-member_id={member.id}
class="link link-primary" aria-label={
> gettext("Remove %{name}",
{MvWeb.Helpers.MemberHelpers.display_name(member)} name: MvWeb.Helpers.MemberHelpers.display_name(member)
</.link> )
</td> }
<td> class="p-0 h-4 w-4 min-h-0"
<%= if member.email do %> >
<a <.icon name="hero-x-mark" class="size-3" />
href={"mailto:#{member.email}"} </.button>
class="text-blue-700 hover:text-blue-800 underline" </.tooltip>
</span>
<% end %>
<input
type="text"
id="member-search-input"
data-testid="group-show-member-search-input"
role="combobox"
phx-hook="ComboBox"
phx-focus="show_member_dropdown"
phx-debounce="300"
phx-keydown="member_dropdown_keydown"
phx-mounted={JS.focus()}
value={@member_search_query}
placeholder={
if Enum.empty?(@selected_members),
do: gettext("Search for a member..."),
else: ""
}
class="flex-1 min-w-[120px] border-0 focus:outline-none bg-transparent"
name="member_search"
aria-label={gettext("Search for a member")}
aria-autocomplete="list"
aria-controls="member-dropdown"
aria-expanded={to_string(@show_member_dropdown)}
aria-activedescendant={
if @focused_member_index,
do: "member-option-#{@focused_member_index}",
else: nil
}
autocomplete="off"
/>
</div>
<%= if length(@available_members) > 0 do %>
<div
id="member-dropdown"
role="listbox"
aria-label={gettext("Available members")}
class={"absolute z-10 w-full mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg max-h-60 overflow-auto top-full #{if !@show_member_dropdown, do: "hidden"}"}
phx-click-away="hide_member_dropdown"
> >
{member.email} <%= for {member, index} <- Enum.with_index(@available_members) do %>
</a> <div
<% else %> id={"member-option-#{index}"}
<span class="text-base-content/50 italic"></span> role="option"
tabindex="0"
aria-selected={to_string(@focused_member_index == index)}
phx-click="select_member"
phx-value-id={member.id}
data-member-id={member.id}
class={[
"px-4 py-3 cursor-pointer border-b border-base-300 last:border-b-0",
if(@focused_member_index == index,
do: "bg-base-300",
else: "hover:bg-base-200"
)
]}
>
<p class="font-medium">
{MvWeb.Helpers.MemberHelpers.display_name(member)}
</p>
<p class="text-sm text-base-content/70">
{member.email || gettext("No email")}
</p>
</div>
<% end %>
</div>
<% end %> <% end %>
</td> </div>
</form>
<.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>
<.button
type="button"
variant="neutral"
phx-click="hide_add_member_input"
aria-label={gettext("Cancel")}
class="join-item"
>
{gettext("Cancel")}
</.button>
</div>
<% else %>
<.button
variant="primary"
phx-click="show_add_member_input"
aria-label={gettext("Add Member")}
>
{gettext("Add Member")}
</.button>
<% end %>
</div>
<% end %>
<%= if Enum.empty?(@group.members || []) do %>
<p class="text-base-content/50 italic" data-testid="group-show-no-members">
{gettext("No members in this group")}
</p>
<% else %>
<div class="overflow-x-auto" data-testid="group-show-members-table">
<table class="table table-zebra">
<thead>
<tr>
<th>{gettext("Name")}</th>
<th>{gettext("Email")}</th>
<%= if can?(@current_user, :update, @group) do %> <%= if can?(@current_user, :update, @group) do %>
<td> <th class="w-0">{gettext("Actions")}</th>
<button
type="button"
class="btn btn-ghost btn-sm text-error"
phx-click="remove_member"
phx-value-member_id={member.id}
data-testid="group-show-remove-member"
aria-label={gettext("Remove member from group")}
data-tooltip={gettext("Remove")}
>
<.icon name="hero-trash" class="size-4" />
</button>
</td>
<% end %> <% end %>
</tr> </tr>
<% end %> </thead>
</tbody> <tbody>
</table> <%= for member <- @group.members do %>
</div> <tr>
<% end %> <td>
<.link
navigate={~p"/members/#{member.id}"}
class="link link-primary"
>
{MvWeb.Helpers.MemberHelpers.display_name(member)}
</.link>
</td>
<td>
<%= if member.email do %>
<a
href={"mailto:#{member.email}"}
class="link link-primary"
>
{member.email}
</a>
<% else %>
<span class="text-base-content/50 italic"></span>
<% end %>
</td>
<%= if can?(@current_user, :update, @group) do %>
<td>
<.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" />
</.button>
</.tooltip>
</td>
<% end %>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
</div>
</div> </div>
</div> </div>
</div>
<%!-- Delete Confirmation Modal --%> <%!-- Danger zone: canonical pattern (same as member show) --%>
<%= if assigns[:show_delete_modal] do %> <%= if can?(@current_user, :destroy, @group) do %>
<dialog id="delete-group-modal" class="modal modal-open" role="dialog"> <section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
<div class="modal-box"> <h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
<h3 class="text-lg font-bold mb-4">{gettext("Delete Group")}</h3> {gettext("Danger zone")}
<p class="mb-4"> </h2>
{gettext("Are you sure you want to delete this group? This action cannot be undone.")} <div class="border border-base-300 rounded-lg p-4 bg-base-100">
</p> <p class="text-base-content/70 mb-4">
<%= if @group.member_count && @group.member_count > 0 do %> {gettext(
<div class="alert alert-warning mb-4"> "Deleting this group cannot be undone. All member-group associations will be permanently removed."
<.icon name="hero-exclamation-triangle" class="size-5" /> )}
<span> </p>
{ngettext( <.button
"This group has %{count} member. All member-group associations will be permanently deleted.", variant="danger"
"This group has %{count} members. All member-group associations will be permanently deleted.",
@group.member_count,
count: @group.member_count
)}
</span>
</div>
<% end %>
<div>
<label for="group-name-confirmation" class="label">
<span class="label-text">
{gettext("To confirm deletion, please enter the group name:")}
</span>
</label>
<div class="p-2 mb-2 font-mono text-lg font-bold break-all rounded bg-base-200">
{@group.name}
</div>
<form phx-change="update_name_confirmation">
<input
id="group-name-confirmation"
name="name"
type="text"
value={@name_confirmation || ""}
placeholder={gettext("Enter the group name to confirm")}
autocomplete="off"
phx-debounce="200"
class="w-full input input-bordered"
/>
</form>
</div>
<div class="modal-action">
<button
type="button" type="button"
class="btn" phx-click="open_delete_modal"
phx-click="cancel_delete" data-testid="group-show-delete-btn"
aria-label={gettext("Cancel")} aria-label={gettext("Delete group %{name}", name: @group.name)}
> >
{gettext("Cancel")} <.icon name="hero-trash" class="size-4" />
</button> {gettext("Delete group")}
<button </.button>
type="button"
class="btn btn-error"
phx-click="confirm_delete"
phx-value-slug={@group.slug}
disabled={(@name_confirmation || "") != @group.name}
aria-label={gettext("Delete group")}
>
{gettext("Delete")}
</button>
</div> </div>
</div> </section>
</dialog> <% end %>
<% end %>
<%!-- Delete Confirmation Modal --%>
<%= if assigns[:show_delete_modal] do %>
<dialog id="delete-group-modal" class="modal modal-open" role="dialog">
<div class="modal-box">
<h3 class="text-lg font-bold mb-4">{gettext("Delete Group")}</h3>
<p class="mb-4">
{gettext("Are you sure you want to delete this group? This action cannot be undone.")}
</p>
<%= if @group.member_count && @group.member_count > 0 do %>
<div class="alert alert-warning mb-4">
<.icon name="hero-exclamation-triangle" class="size-5" />
<span>
{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
)}
</span>
</div>
<% end %>
<div>
<label for="group-name-confirmation" class="label">
<span class="label-text">
{gettext("To confirm deletion, please enter the group name:")}
</span>
</label>
<div class="p-2 mb-2 font-mono text-lg font-bold break-all rounded bg-base-200">
{@group.name}
</div>
<form phx-change="update_name_confirmation">
<input
id="group-name-confirmation"
name="name"
type="text"
value={@name_confirmation || ""}
placeholder={gettext("Enter the group name to confirm")}
autocomplete="off"
phx-debounce="200"
class="w-full input input-bordered"
/>
</form>
</div>
<div class="modal-action">
<.button
type="button"
variant="neutral"
phx-click="cancel_delete"
aria-label={gettext("Cancel")}
>
{gettext("Cancel")}
</.button>
<.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")}
</.button>
</div>
</div>
</dialog>
<% end %>
</div>
</Layouts.app> </Layouts.app>
""" """
end end
@ -900,7 +934,7 @@ defmodule MvWeb.GroupLive.Show do
:ok -> :ok ->
{:noreply, {:noreply,
socket socket
|> put_flash(:info, gettext("Group deleted successfully.")) |> put_flash(:success, gettext("Group deleted successfully."))
|> redirect(to: ~p"/groups")} |> redirect(to: ~p"/groups")}
{:error, error} -> {:error, error} ->

View file

@ -42,11 +42,13 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
<div class="flex items-center gap-4 mb-4"> <div class="flex items-center gap-4 mb-4">
<.button <.button
type="button" type="button"
variant="neutral"
phx-click="cancel" phx-click="cancel"
phx-target={@myself} phx-target={@myself}
aria-label={gettext("Back to Settings")} aria-label={gettext("Back to settings")}
> >
<.icon name="hero-arrow-left" class="w-4 h-4" /> <.icon name="hero-arrow-left" class="size-4" />
{gettext("Back")}
</.button> </.button>
<h3 class="card-title"> <h3 class="card-title">
{gettext("Edit Field: %{field}", field: @field_label)} {gettext("Edit Field: %{field}", field: @field_label)}
@ -176,7 +178,7 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
</div> </div>
<div class="justify-end mt-4 card-actions"> <div class="justify-end mt-4 card-actions">
<.button type="button" phx-click="cancel" phx-target={@myself}> <.button type="button" variant="neutral" phx-click="cancel" phx-target={@myself}>
{gettext("Cancel")} {gettext("Cancel")}
</.button> </.button>
<.button phx-disable-with={gettext("Saving...")} variant="primary"> <.button phx-disable-with={gettext("Saving...")} variant="primary">

View file

@ -52,6 +52,12 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
:if={!@show_form} :if={!@show_form}
id="member_fields" id="member_fields"
rows={@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")}> <:col :let={{_field_name, field_data}} label={gettext("Name")}>
{MemberFields.label(field_data.field)} {MemberFields.label(field_data.field)}
@ -86,16 +92,6 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
{gettext("No")} {gettext("No")}
</span> </span>
</:col> </:col>
<: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")}
</.link>
</:action>
</.table> </.table>
</div> </div>
""" """

View file

@ -6,7 +6,6 @@ defmodule MvWeb.MemberLive.Form do
- Create new members with personal information - Create new members with personal information
- Edit existing member details - Edit existing member details
- Grouped sections for better organization - Grouped sections for better organization
- Tab navigation (Payments tab disabled, coming soon)
- Manage custom properties (dynamic fields, displayed sorted by name) - Manage custom properties (dynamic fields, displayed sorted by name)
- Real-time validation with visual feedback - Real-time validation with visual feedback
@ -21,6 +20,7 @@ defmodule MvWeb.MemberLive.Form do
""" """
use MvWeb, :live_view use MvWeb, :live_view
require Logger
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3] import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
alias Mv.Membership alias Mv.Membership
@ -38,222 +38,248 @@ defmodule MvWeb.MemberLive.Form do
~H""" ~H"""
<Layouts.app flash={@flash} current_user={@current_user}> <Layouts.app flash={@flash} current_user={@current_user}>
<.form for={@form} id="member-form" phx-change="validate" phx-submit="save"> <.form for={@form} id="member-form" phx-change="validate" phx-submit="save">
<%!-- Header with Back button, Name display, and Save button --%> <.header>
<div class="flex items-center justify-between gap-4 pb-4"> <:leading>
<.button navigate={return_path(@return_to, @member)} type="button"> <.button navigate={return_path(@return_to, @member)} variant="neutral">
<.icon name="hero-arrow-left" class="size-4" /> <.icon name="hero-arrow-left" class="size-4" />
{gettext("Back")} {gettext("Back")}
</.button> </.button>
</:leading>
<%= 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")}
</.button>
</:actions>
</.header>
<h1 class="text-2xl font-bold text-center flex-1"> <div class="mt-6 space-y-6">
<%= if @member do %> <%!-- Tab navigation: Payments tab not shown on new/edit (only on member show) --%>
{MvWeb.Helpers.MemberHelpers.display_name(@member)} <div role="tablist" class="tabs tabs-bordered">
<% else %> <button type="button" role="tab" class="tab tab-active" aria-selected="true">
{gettext("New Member")} <.icon name="hero-identification" class="size-4 mr-2" />
<% end %> {gettext("Contact Data")}
</h1> </button>
</div>
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit"> <%!-- Personal Data and Custom Fields Row --%>
{gettext("Save")} <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
</.button> <%!-- Personal Data Section --%>
</div> <div>
<.form_section title={gettext("Personal Data")}>
<%!-- Tab Navigation --%> <div class="space-y-4">
<div role="tablist" class="tabs tabs-bordered mb-6"> <%!-- Name Row --%>
<button type="button" role="tab" class="tab tab-active" aria-selected="true"> <div class="flex gap-4">
<.icon name="hero-identification" class="size-4 mr-2" /> <div class="w-48">
{gettext("Contact Data")} <.input
</button> field={@form[:first_name]}
<button label={gettext("First Name")}
type="button" required={@member_field_required_map[:first_name]}
role="tab" />
class="tab" </div>
disabled <div class="w-48">
aria-disabled="true" <.input
title={gettext("Coming soon")} field={@form[:last_name]}
> label={gettext("Last Name")}
<.icon name="hero-credit-card" class="size-4 mr-2" /> required={@member_field_required_map[:last_name]}
{gettext("Payments")} />
</button> </div>
</div>
<%!-- Personal Data and Custom Fields Row --%>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<%!-- Personal Data Section --%>
<div>
<.form_section title={gettext("Personal Data")}>
<div class="space-y-4">
<%!-- Name Row --%>
<div class="flex gap-4">
<div class="w-48">
<.input
field={@form[:first_name]}
label={gettext("First Name")}
required={@member_field_required_map[:first_name]}
/>
</div> </div>
<div class="w-48">
<.input
field={@form[:last_name]}
label={gettext("Last Name")}
required={@member_field_required_map[:last_name]}
/>
</div>
</div>
<%!-- Address: Country, Postal Code, City in one row --%> <%!-- Address: Country, Postal Code, City in one row --%>
<div class="flex gap-4"> <div class="flex gap-4">
<div class="w-48"> <div class="w-48">
<.input field={@form[:country]} label={gettext("Country")} /> <.input field={@form[:country]} label={gettext("Country")} />
</div>
<div class="w-24">
<.input
field={@form[:postal_code]}
label={gettext("Postal Code")}
required={@member_field_required_map[:postal_code]}
/>
</div>
<div class="w-48">
<.input field={@form[:city]} label={gettext("City")} />
</div>
</div> </div>
<div class="w-24">
<.input
field={@form[:postal_code]}
label={gettext("Postal Code")}
required={@member_field_required_map[:postal_code]}
/>
</div>
<div class="w-48">
<.input field={@form[:city]} label={gettext("City")} />
</div>
</div>
<%!-- Street and Nr. below --%> <%!-- Street and Nr. below --%>
<div class="flex gap-4"> <div class="flex gap-4">
<div class="w-64">
<.input field={@form[:street]} label={gettext("Street")} />
</div>
<div class="w-24">
<.input field={@form[:house_number]} label={gettext("Nr.")} />
</div>
</div>
<%!-- Email --%>
<div class="w-64"> <div class="w-64">
<.input field={@form[:street]} label={gettext("Street")} /> <.input field={@form[:email]} label={gettext("Email")} required type="email" />
</div> </div>
<div class="w-24">
<.input field={@form[:house_number]} label={gettext("Nr.")} />
</div>
</div>
<%!-- Email --%> <%!-- Membership Dates Row --%>
<div class="w-64"> <div class="flex gap-4">
<.input field={@form[:email]} label={gettext("Email")} required type="email" /> <div class="w-36">
</div> <.input
field={@form[:join_date]}
<%!-- Membership Dates Row --%> label={gettext("Join Date")}
<div class="flex gap-4"> type="date"
<div class="w-36"> required={@member_field_required_map[:join_date]}
<.input />
field={@form[:join_date]} </div>
label={gettext("Join Date")} <div class="w-36">
type="date" <.input
required={@member_field_required_map[:join_date]} field={@form[:exit_date]}
/> label={gettext("Exit Date")}
type="date"
required={@member_field_required_map[:exit_date]}
/>
</div>
</div> </div>
<div class="w-36">
<%!-- Notes --%>
<div>
<.input <.input
field={@form[:exit_date]} field={@form[:notes]}
label={gettext("Exit Date")} label={gettext("Notes")}
type="date" type="textarea"
required={@member_field_required_map[:exit_date]} required={@member_field_required_map[:notes]}
/> />
</div> </div>
</div> </div>
</.form_section>
</div>
<%!-- Notes --%> <%!-- Custom Fields Section --%>
<%= if Enum.any?(@custom_fields) do %>
<div>
<.form_section title={gettext("Custom Fields")}>
<div class="grid grid-cols-2 gap-4">
<%!-- Render in sorted order by finding the form for each sorted custom field --%>
<%= for cf <- @sorted_custom_fields do %>
<.inputs_for :let={f_cfv} field={@form[:custom_field_values]}>
<%= if f_cfv[:custom_field_id].value == cf.id do %>
<div class={if cf.value_type == :boolean, do: "flex items-end", else: ""}>
<.inputs_for :let={value_form} field={f_cfv[:value]}>
<.input
field={value_form[:value]}
label={cf.name}
type={custom_field_input_type(cf.value_type)}
required={cf.required}
/>
</.inputs_for>
<input
type="hidden"
name={f_cfv[:custom_field_id].name}
value={f_cfv[:custom_field_id].value}
/>
</div>
<% end %>
</.inputs_for>
<% end %>
</div>
</.form_section>
</div>
<% end %>
</div>
<%!-- Membership Fee Section --%>
<div class="max-w-xl">
<.form_section title={gettext("Membership Fee")}>
<div class="space-y-4">
<div> <div>
<.input <label class="label">
field={@form[:notes]} <span class="label-text font-semibold">{gettext("Membership Fee Type")}</span>
label={gettext("Notes")} </label>
type="textarea" <select
required={@member_field_required_map[:notes]} class="select select-bordered w-full"
/> name={@form[:membership_fee_type_id].name}
phx-change="validate"
value={@form[:membership_fee_type_id].value || ""}
>
<%!-- No "None" option: a membership fee type is required (validated in Member resource). --%>
<option value="">{gettext("Select a membership fee type")}</option>
<%= for fee_type <- @available_fee_types do %>
<option
value={fee_type.id}
selected={fee_type.id == @form[:membership_fee_type_id].value}
>
{fee_type.name} ({MembershipFeeHelpers.format_currency(fee_type.amount)}, {MembershipFeeHelpers.format_interval(
fee_type.interval
)})
</option>
<% end %>
</select>
<%= for error <- List.wrap(@form.errors[:membership_fee_type_id] || []) do %>
<% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %>
<p class="text-error text-sm mt-1">{msg}</p>
<% end %>
<%= if @interval_warning do %>
<div class="alert alert-warning mt-2">
<.icon name="hero-exclamation-triangle" class="size-5" />
<span>{@interval_warning}</span>
</div>
<% end %>
<p class="text-sm text-base-content/60 mt-2">
{gettext(
"Select a membership fee type for this member. Members can only switch between types with the same interval."
)}
</p>
</div> </div>
</div> </div>
</.form_section> </.form_section>
</div> </div>
<%!-- Custom Fields Section --%> <%!-- Bottom Action Buttons --%>
<%= if Enum.any?(@custom_fields) do %> <div class="flex justify-end gap-4 mt-6">
<div> <.button navigate={return_path(@return_to, @member)} variant="neutral" type="button">
<.form_section title={gettext("Custom Fields")}> {gettext("Cancel")}
<div class="grid grid-cols-2 gap-4"> </.button>
<%!-- Render in sorted order by finding the form for each sorted custom field --%> <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
<%= for cf <- @sorted_custom_fields do %> {gettext("Save Member")}
<.inputs_for :let={f_cfv} field={@form[:custom_field_values]}> </.button>
<%= if f_cfv[:custom_field_id].value == cf.id do %> </div>
<div class={if cf.value_type == :boolean, do: "flex items-end", else: ""}>
<.inputs_for :let={value_form} field={f_cfv[:value]}>
<.input
field={value_form[:value]}
label={cf.name}
type={custom_field_input_type(cf.value_type)}
required={cf.required}
/>
</.inputs_for>
<input
type="hidden"
name={f_cfv[:custom_field_id].name}
value={f_cfv[:custom_field_id].value}
/>
</div>
<% end %>
</.inputs_for>
<% end %>
</div>
</.form_section>
</div>
<% end %>
</div>
<%!-- Membership Fee Section --%> <%!-- Danger zone: same section pattern as MemberLive.Show (canonical) --%>
<div class="max-w-xl"> <%= if @member && can?(@current_user, :destroy, @member) do %>
<.form_section title={gettext("Membership Fee")}> <section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
<div class="space-y-4"> <h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
<div> {gettext("Danger zone")}
<label class="label"> </h2>
<span class="label-text font-semibold">{gettext("Membership Fee Type")}</span> <div class="border border-base-300 rounded-lg p-4 bg-base-100">
</label> <p class="text-base-content/70 mb-4">
<select
class="select select-bordered w-full"
name={@form[:membership_fee_type_id].name}
phx-change="validate"
value={@form[:membership_fee_type_id].value || ""}
>
<%!-- No "None" option: a membership fee type is required (validated in Member resource). --%>
<option value="">{gettext("Select a membership fee type")}</option>
<%= for fee_type <- @available_fee_types do %>
<option
value={fee_type.id}
selected={fee_type.id == @form[:membership_fee_type_id].value}
>
{fee_type.name} ({MembershipFeeHelpers.format_currency(fee_type.amount)}, {MembershipFeeHelpers.format_interval(
fee_type.interval
)})
</option>
<% end %>
</select>
<%= for error <- List.wrap(@form.errors[:membership_fee_type_id] || []) do %>
<% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %>
<p class="text-error text-sm mt-1">{msg}</p>
<% end %>
<%= if @interval_warning do %>
<div class="alert alert-warning mt-2">
<.icon name="hero-exclamation-triangle" class="size-5" />
<span>{@interval_warning}</span>
</div>
<% end %>
<p class="text-sm text-base-content/60 mt-2">
{gettext( {gettext(
"Select a membership fee type for this member. Members can only switch between types with the same interval." "Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed."
)} )}
</p> </p>
<.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")}
</.button>
</div> </div>
</div> </section>
</.form_section> <% end %>
</div>
<%!-- Bottom Action Buttons --%>
<div class="flex justify-end gap-4 mt-6">
<.button navigate={return_path(@return_to, @member)} type="button">
{gettext("Cancel")}
</.button>
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save Member")}
</.button>
</div> </div>
</.form> </.form>
</Layouts.app> </Layouts.app>
@ -374,6 +400,41 @@ defmodule MvWeb.MemberLive.Form do
end end
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 defp handle_save_success(socket, member) do
notify_parent({:saved, member}) notify_parent({:saved, member})
@ -386,7 +447,7 @@ defmodule MvWeb.MemberLive.Form do
socket = socket =
socket socket
|> put_flash(:info, flash_message) |> put_flash(:success, flash_message)
|> maybe_put_vereinfacht_sync_flash(member.id) |> maybe_put_vereinfacht_sync_flash(member.id)
|> push_navigate(to: return_path(socket.assigns.return_to, member)) |> push_navigate(to: return_path(socket.assigns.return_to, member))
@ -421,6 +482,19 @@ defmodule MvWeb.MemberLive.Form do
end end
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 defp handle_save_error(socket, form) do
# Always show a flash message when save fails # Always show a flash message when save fails
# Field-level validation errors are displayed in form fields, but flash provides additional feedback # Field-level validation errors are displayed in form fields, but flash provides additional feedback

View file

@ -15,7 +15,6 @@ defmodule MvWeb.MemberLive.Index do
- `sort_order` - Sort direction (:asc or :desc) - `sort_order` - Sort direction (:asc or :desc)
## Events ## Events
- `delete` - Remove a member from the database
- `select_member` - Toggle individual member selection - `select_member` - Toggle individual member selection
- `select_all` - Toggle selection of all visible members - `select_all` - Toggle selection of all visible members
- `copy_emails` - Copy email addresses of selected members to clipboard - `copy_emails` - Copy email addresses of selected members to clipboard
@ -123,6 +122,7 @@ defmodule MvWeb.MemberLive.Index do
|> assign(:groups, groups) |> assign(:groups, groups)
|> assign(:boolean_custom_field_filters, %{}) |> assign(:boolean_custom_field_filters, %{})
|> assign(:selected_members, MapSet.new()) |> assign(:selected_members, MapSet.new())
|> assign(:selected_member_id, nil)
|> assign(:settings, settings) |> assign(:settings, settings)
|> assign(:custom_fields_visible, custom_fields_visible) |> assign(:custom_fields_visible, custom_fields_visible)
|> assign(:all_custom_fields, all_custom_fields) |> assign(:all_custom_fields, all_custom_fields)
@ -157,48 +157,14 @@ defmodule MvWeb.MemberLive.Index do
Handles member-related UI events. Handles member-related UI events.
## Supported events: ## Supported events:
- `"delete"` - Removes a member from the database
- `"select_member"` - Toggles individual member selection - `"select_member"` - Toggles individual member selection
- `"select_all"` - Toggles selection of all visible members - `"select_all"` - Toggles selection of all visible members
- `"sort"` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL - `"sort"` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL
""" """
@impl true @impl true
def handle_event("delete", %{"id" => id}, socket) do def handle_event("select_row_and_navigate", %{"id" => id}, socket) do
actor = current_actor(socket) # 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}")}
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 end
@impl true @impl true
@ -343,22 +309,6 @@ defmodule MvWeb.MemberLive.Index do
{:noreply, push_patch(socket, to: ~p"/members?#{query_params}", replace: true)} {:noreply, push_patch(socket, to: ~p"/members?#{query_params}", replace: true)}
end 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 # Handle Infos from Child Components
# ----------------------------------------------------------------- # -----------------------------------------------------------------
@ -656,6 +606,7 @@ defmodule MvWeb.MemberLive.Index do
|> assign(:member_fields_visible_db, visible_member_fields_db) |> assign(:member_fields_visible_db, visible_member_fields_db)
|> assign(:member_fields_visible_computed, visible_member_fields_computed) |> assign(:member_fields_visible_computed, visible_member_fields_computed)
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields)) |> 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) next_sig = build_signature(socket)
@ -855,6 +806,18 @@ defmodule MvWeb.MemberLive.Index do
end end
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, nil), do: params
defp merge_fields_param_from_uri(params, %URI{query: query}) when is_binary(query) do defp merge_fields_param_from_uri(params, %URI{query: query}) when is_binary(query) do

View file

@ -9,7 +9,7 @@
selected_count={@selected_count} selected_count={@selected_count}
/> />
<.button <.button
class="secondary" variant="secondary"
id="copy-emails-btn" id="copy-emails-btn"
phx-hook="CopyToClipboard" phx-hook="CopyToClipboard"
phx-click="copy_emails" phx-click="copy_emails"
@ -20,7 +20,7 @@
{gettext("Copy email addresses")} ({@selected_count}) {gettext("Copy email addresses")} ({@selected_count})
</.button> </.button>
<.button <.button
class="secondary" variant="secondary"
id="open-email-btn" id="open-email-btn"
href={"mailto:?bcc=" <> @mailto_bcc} href={"mailto:?bcc=" <> @mailto_bcc}
disabled={not @any_selected?} disabled={not @any_selected?}
@ -54,13 +54,12 @@
boolean_filters={@boolean_custom_field_filters} boolean_filters={@boolean_custom_field_filters}
member_count={length(@members)} member_count={length(@members)}
/> />
<button <.button
type="button" type="button"
variant="secondary"
class={["gap-2", @show_current_cycle && "btn-active"]}
phx-click="toggle_cycle_view" phx-click="toggle_cycle_view"
class={[ data-testid="toggle-cycle-view"
"btn gap-2",
@show_current_cycle && "btn-active"
]}
aria-label={ aria-label={
if(@show_current_cycle, if(@show_current_cycle,
do: gettext("Current Cycle Payment Status"), do: gettext("Current Cycle Payment Status"),
@ -81,7 +80,7 @@
else: gettext("Last Cycle Payment Status") else: gettext("Last Cycle Payment Status")
)} )}
</span> </span>
</button> </.button>
<.live_component <.live_component
module={MvWeb.Components.FieldVisibilityDropdownComponent} module={MvWeb.Components.FieldVisibilityDropdownComponent}
id="field-visibility-dropdown" id="field-visibility-dropdown"
@ -91,334 +90,329 @@
/> />
</div> </div>
<.table <%!-- On desktop (lg:), only the table area scrolls; header and filters stay visible. On mobile, normal flow. --%>
id="members" <div
rows={@members} class="lg:max-h-[calc(100vh-14rem)] lg:overflow-auto min-h-0"
row_id={fn member -> "row-#{member.id}" end} data-testid="members-table-scroll"
row_click={fn member -> JS.navigate(~p"/members/#{member}") end} role="region"
dynamic_cols={@dynamic_cols} aria-label={gettext("Members table")}
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} label="Id">{member.id}</:col> --> <!-- <:col :let={member} label="Id">{member.id}</:col> -->
<:col <:col
:let={member} :let={member}
col_click={&MvWeb.MemberLive.Index.checkbox_column_click/1} col_click={&MvWeb.MemberLive.Index.checkbox_column_click/1}
label={ label={
~H""" ~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"
/>
"""
}
>
<.input <.input
type="checkbox" type="checkbox"
name="select_all" name={member.id}
phx-click="select_all" checked={MapSet.member?(@selected_members, member.id)}
checked={MapSet.equal?(@selected_members, @members |> Enum.map(& &1.id) |> MapSet.new())} aria-label={gettext("Select member")}
aria-label={gettext("Select all members")}
role="checkbox" role="checkbox"
/> />
""" </:col>
} <:col
> :let={member}
<.input :if={:first_name in @member_fields_visible}
type="checkbox" label={
name={member.id} ~H"""
checked={MapSet.member?(@selected_members, member.id)} <.live_component
aria-label={gettext("Select member")} module={MvWeb.Components.SortHeaderComponent}
role="checkbox" id={:sort_first_name}
/> field={:first_name}
</:col> label={gettext("First name")}
<:col sort_field={@sort_field}
:let={member} sort_order={@sort_order}
:if={:first_name in @member_fields_visible} />
label={ """
~H""" }
<.live_component >
module={MvWeb.Components.SortHeaderComponent} {member.first_name}
id={:sort_first_name} </:col>
field={:first_name} <:col
label={gettext("First name")} :let={member}
sort_field={@sort_field} :if={:last_name in @member_fields_visible}
sort_order={@sort_order} label={
/> ~H"""
""" <.live_component
} module={MvWeb.Components.SortHeaderComponent}
> id={:sort_last_name}
{member.first_name} field={:last_name}
</:col> label={gettext("Last name")}
<:col sort_field={@sort_field}
:let={member} sort_order={@sort_order}
:if={:last_name in @member_fields_visible} />
label={ """
~H""" }
<.live_component >
module={MvWeb.Components.SortHeaderComponent} {member.last_name}
id={:sort_last_name} </:col>
field={:last_name} <:col
label={gettext("Last name")} :let={member}
sort_field={@sort_field} :if={:email in @member_fields_visible}
sort_order={@sort_order} label={
/> ~H"""
""" <.live_component
} module={MvWeb.Components.SortHeaderComponent}
> id={:sort_email}
{member.last_name} field={:email}
</:col> label={gettext("Email")}
<:col sort_field={@sort_field}
:let={member} sort_order={@sort_order}
:if={:email in @member_fields_visible} />
label={ """
~H""" }
<.live_component >
module={MvWeb.Components.SortHeaderComponent} {member.email}
id={:sort_email} </:col>
field={:email} <:col
label={gettext("Email")} :let={member}
sort_field={@sort_field} :if={:join_date in @member_fields_visible}
sort_order={@sort_order} label={
/> ~H"""
""" <.live_component
} module={MvWeb.Components.SortHeaderComponent}
> id={:sort_join_date}
{member.email} field={:join_date}
</:col> label={gettext("Join Date")}
<:col sort_field={@sort_field}
:let={member} sort_order={@sort_order}
:if={:join_date in @member_fields_visible} />
label={ """
~H""" }
<.live_component >
module={MvWeb.Components.SortHeaderComponent} {MvWeb.MemberLive.Index.format_date(member.join_date)}
id={:sort_join_date} </:col>
field={:join_date} <:col
label={gettext("Join Date")} :let={member}
sort_field={@sort_field} :if={:exit_date in @member_fields_visible}
sort_order={@sort_order} label={
/> ~H"""
""" <.live_component
} module={MvWeb.Components.SortHeaderComponent}
> id={:sort_exit_date}
{MvWeb.MemberLive.Index.format_date(member.join_date)} field={:exit_date}
</:col> label={gettext("Exit Date")}
<:col sort_field={@sort_field}
:let={member} sort_order={@sort_order}
:if={:exit_date in @member_fields_visible} />
label={ """
~H""" }
<.live_component >
module={MvWeb.Components.SortHeaderComponent} {MvWeb.MemberLive.Index.format_date(member.exit_date)}
id={:sort_exit_date} </:col>
field={:exit_date} <:col
label={gettext("Exit Date")} :let={member}
sort_field={@sort_field} :if={:notes in @member_fields_visible}
sort_order={@sort_order} label={gettext("Notes")}
/> >
""" {member.notes}
} </:col>
> <:col
{MvWeb.MemberLive.Index.format_date(member.exit_date)} :let={member}
</:col> :if={:country in @member_fields_visible}
<:col label={
:let={member} ~H"""
:if={:notes in @member_fields_visible} <.live_component
label={gettext("Notes")} module={MvWeb.Components.SortHeaderComponent}
> id={:sort_country}
{member.notes} field={:country}
</:col> label={gettext("Country")}
<:col sort_field={@sort_field}
:let={member} sort_order={@sort_order}
:if={:country in @member_fields_visible} />
label={ """
~H""" }
<.live_component >
module={MvWeb.Components.SortHeaderComponent} {member.country}
id={:sort_country} </:col>
field={:country} <:col
label={gettext("Country")} :let={member}
sort_field={@sort_field} :if={:city in @member_fields_visible}
sort_order={@sort_order} label={
/> ~H"""
""" <.live_component
} module={MvWeb.Components.SortHeaderComponent}
> id={:sort_city}
{member.country} field={:city}
</:col> label={gettext("City")}
<:col sort_field={@sort_field}
:let={member} sort_order={@sort_order}
:if={:city in @member_fields_visible} />
label={ """
~H""" }
<.live_component >
module={MvWeb.Components.SortHeaderComponent} {member.city}
id={:sort_city} </:col>
field={:city} <:col
label={gettext("City")} :let={member}
sort_field={@sort_field} :if={:street in @member_fields_visible}
sort_order={@sort_order} label={
/> ~H"""
""" <.live_component
} module={MvWeb.Components.SortHeaderComponent}
> id={:sort_street}
{member.city} field={:street}
</:col> label={gettext("Street")}
<:col sort_field={@sort_field}
:let={member} sort_order={@sort_order}
:if={:street in @member_fields_visible} />
label={ """
~H""" }
<.live_component >
module={MvWeb.Components.SortHeaderComponent} {member.street}
id={:sort_street} </:col>
field={:street} <:col
label={gettext("Street")} :let={member}
sort_field={@sort_field} :if={:house_number in @member_fields_visible}
sort_order={@sort_order} label={
/> ~H"""
""" <.live_component
} module={MvWeb.Components.SortHeaderComponent}
> id={:sort_house_number}
{member.street} field={:house_number}
</:col> label={gettext("House Number")}
<:col sort_field={@sort_field}
:let={member} sort_order={@sort_order}
:if={:house_number in @member_fields_visible} />
label={ """
~H""" }
<.live_component >
module={MvWeb.Components.SortHeaderComponent} {member.house_number}
id={:sort_house_number} </:col>
field={:house_number} <:col
label={gettext("House Number")} :let={member}
sort_field={@sort_field} :if={:postal_code in @member_fields_visible}
sort_order={@sort_order} label={
/> ~H"""
""" <.live_component
} module={MvWeb.Components.SortHeaderComponent}
> id={:sort_postal_code}
{member.house_number} field={:postal_code}
</:col> label={gettext("Postal Code")}
<:col sort_field={@sort_field}
:let={member} sort_order={@sort_order}
:if={:postal_code in @member_fields_visible} />
label={ """
~H""" }
<.live_component >
module={MvWeb.Components.SortHeaderComponent} {member.postal_code}
id={:sort_postal_code} </:col>
field={:postal_code} <:col
label={gettext("Postal Code")} :let={member}
sort_field={@sort_field} :if={:membership_fee_start_date in @member_fields_visible}
sort_order={@sort_order} label={
/> ~H"""
""" <.live_component
} module={MvWeb.Components.SortHeaderComponent}
> id={:sort_membership_fee_start_date}
{member.postal_code} field={:membership_fee_start_date}
</:col> label={gettext("Membership Fee Start Date")}
<:col sort_field={@sort_field}
:let={member} sort_order={@sort_order}
:if={:membership_fee_start_date in @member_fields_visible} />
label={ """
~H""" }
<.live_component >
module={MvWeb.Components.SortHeaderComponent} {MvWeb.MemberLive.Index.format_date(member.membership_fee_start_date)}
id={:sort_membership_fee_start_date} </:col>
field={:membership_fee_start_date} <:col
label={gettext("Membership Fee Start Date")} :let={member}
sort_field={@sort_field} :if={:membership_fee_type in @member_fields_visible}
sort_order={@sort_order} label={
/> ~H"""
""" <.live_component
} module={MvWeb.Components.SortHeaderComponent}
> id={:sort_membership_fee_type}
{MvWeb.MemberLive.Index.format_date(member.membership_fee_start_date)} field={:membership_fee_type}
</:col> label={gettext("Fee Type")}
<:col sort_field={@sort_field}
:let={member} sort_order={@sort_order}
:if={:membership_fee_type in @member_fields_visible} />
label={ """
~H""" }
<.live_component >
module={MvWeb.Components.SortHeaderComponent} <%= if member.membership_fee_type do %>
id={:sort_membership_fee_type} {member.membership_fee_type.name}
field={:membership_fee_type} <% else %>
label={gettext("Fee Type")} <span class="text-base-content/50">—</span>
sort_field={@sort_field} <% end %>
sort_order={@sort_order} </:col>
/> <:col
""" :let={member}
} :if={:membership_fee_status in @member_fields_visible}
> label={gettext("Membership Fee Status")}
<%= if member.membership_fee_type do %> >
{member.membership_fee_type.name} <%= if badge = MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge(
<% else %>
<span class="text-base-content/50">—</span>
<% end %>
</:col>
<: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) MvWeb.MemberLive.Index.MembershipFeeStatus.get_cycle_status_for_member(member, @show_current_cycle)
) do %> ) do %>
<span class={["badge", badge.color]}> <span class={["badge", badge.color]}>
<.icon name={badge.icon} class="size-4" /> <.icon name={badge.icon} class="size-4" />
{badge.label} {badge.label}
</span> </span>
<% else %> <% else %>
<span class="badge badge-ghost">{gettext("No cycle")}</span> <span class="badge badge-ghost">{gettext("No cycle")}</span>
<% end %> <% end %>
</:col> </:col>
<:col <:col
:let={member} :let={member}
:if={:groups in @member_fields_visible} :if={:groups in @member_fields_visible}
label={ label={
~H""" ~H"""
<.live_component <.live_component
module={MvWeb.Components.SortHeaderComponent} module={MvWeb.Components.SortHeaderComponent}
id={:sort_groups} id={:sort_groups}
field={:groups} field={:groups}
label={gettext("Groups")} label={gettext("Groups")}
sort_field={@sort_field} sort_field={@sort_field}
sort_order={@sort_order} sort_order={@sort_order}
/> />
""" """
} }
> >
<%= for group <- (member.groups || []) do %> <%= for group <- (member.groups || []) do %>
<span <span
class="badge badge-outline badge-primary" class="badge badge-outline badge-primary"
aria-label={gettext("Member of group %{name}", name: group.name)} aria-label={gettext("Member of group %{name}", name: group.name)}
> >
{group.name} {group.name}
</span> </span>
<% end %> <% end %>
<%= if (member.groups || []) == [] do %> <%= if (member.groups || []) == [] do %>
<span class="text-base-content/50">—</span> <span class="text-base-content/50">—</span>
<% end %> <% end %>
</:col> </:col>
<:action :let={member}> <:action :let={member}>
<div class="sr-only"> <div class="sr-only">
<.link navigate={~p"/members/#{member}"}>{gettext("Show")}</.link> <.link navigate={~p"/members/#{member}"} data-testid="member-show-link">
</div> {gettext("Show")}
</.link>
<%= if can?(@current_user, :update, member) do %> </div>
<.link navigate={~p"/members/#{member}/edit"} data-testid="member-edit"> </:action>
{gettext("Edit")} </.table>
</.link> </div>
<% end %>
</:action>
<: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")}
</.link>
<% end %>
</:action>
</.table>
</Layouts.app> </Layouts.app>

View file

@ -30,235 +30,311 @@ defmodule MvWeb.MemberLive.Show do
def render(assigns) do def render(assigns) do
~H""" ~H"""
<Layouts.app flash={@flash} current_user={@current_user}> <Layouts.app flash={@flash} current_user={@current_user}>
<%!-- Header with Back button, Name, and Edit button --%> <.header>
<div class="flex items-center justify-between gap-4 pb-4"> <:leading>
<.button navigate={~p"/members"} aria-label={gettext("Back to members list")}>
<.icon name="hero-arrow-left" class="size-4" />
{gettext("Back")}
</.button>
<h1 class="text-2xl font-bold text-center flex-1">
{MvWeb.Helpers.MemberHelpers.display_name(@member)}
</h1>
<%= if can?(@current_user, :update, @member) do %>
<.button <.button
variant="primary" navigate={~p"/members?highlight=#{@member.id}"}
navigate={~p"/members/#{@member}/edit?return_to=show"} variant="neutral"
data-testid="member-edit" aria-label={gettext("Back to members list")}
> >
{gettext("Edit Member")} <.icon name="hero-arrow-left" class="size-4" />
{gettext("Back")}
</.button> </.button>
<% end %> </:leading>
</div> {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")}
</.button>
<% end %>
</:actions>
</.header>
<%!-- Tab Navigation --%> <div class="mt-6 space-y-6">
<div role="tablist" class="tabs tabs-bordered mb-6"> <%!-- Tab Navigation: surface only behind buttons (inline); tabs-bordered; tabs-lg; both tabs keyboard-focusable --%>
<button <div
role="tab" role="tablist"
class={[ class="tabs tabs-bordered tabs-lg bg-base-200/60 rounded-box p-1 w-fit"
"tab",
if(@active_tab == :contact, do: "tab-active", else: "!text-gray-800")
]}
aria-selected={@active_tab == :contact}
phx-click="switch_tab"
phx-value-tab="contact"
> >
<.icon name="hero-identification" class="size-4 mr-2" /> <button
{gettext("Contact Data")} id="member-tab-contact"
</button> role="tab"
<button type="button"
role="tab" tabindex="0"
class={[ aria-selected={@active_tab == :contact}
"tab", aria-controls="member-tabpanel-contact"
if(@active_tab == :membership_fees, do: "tab-active", else: "!text-gray-800") class={[
]} "tab flex items-center gap-2",
aria-selected={@active_tab == :membership_fees} if(@active_tab == :contact, do: "tab-active", else: "text-base-content/70")
phx-click="switch_tab" ]}
phx-value-tab="membership_fees" phx-click="switch_tab"
> phx-value-tab="contact"
<.icon name="hero-credit-card" class="size-4 mr-2" /> >
{gettext("Membership Fees")} <.icon name="hero-identification" class="size-4 shrink-0" />
</button> {gettext("Contact Data")}
</div> </button>
<button
id="member-tab-membership_fees"
role="tab"
type="button"
tabindex="0"
aria-selected={@active_tab == :membership_fees}
aria-controls="member-tabpanel-membership_fees"
class={[
"tab flex items-center gap-2",
if(@active_tab == :membership_fees, do: "tab-active", else: "text-base-content/70")
]}
phx-click="switch_tab"
phx-value-tab="membership_fees"
>
<.icon name="hero-credit-card" class="size-4 shrink-0" />
{gettext("Membership Fees")}
</button>
</div>
<%= if @active_tab == :contact do %> <%= if @active_tab == :contact do %>
<%!-- Contact Data Tab Content --%> <%!-- Contact Data Tab Content --%>
<%!-- Personal Data and Custom Fields Row --%> <div
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6"> id="member-tabpanel-contact"
<%!-- Personal Data Section --%> role="tabpanel"
<div> aria-labelledby="member-tab-contact"
<.section_box title={gettext("Personal Data")}> >
<div class="space-y-4"> <%!-- Personal Data and Custom Fields Row --%>
<%!-- Name Row --%> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<div class="flex gap-6"> <%!-- Personal Data Section --%>
<.data_field label={gettext("First Name")} value={@member.first_name} class="w-48" /> <div>
<.data_field label={gettext("Last Name")} value={@member.last_name} class="w-48" /> <.section_box title={gettext("Personal Data")}>
</div> <div class="space-y-4">
<%!-- Name Row --%>
<div class="flex gap-6">
<.data_field
label={gettext("First Name")}
value={@member.first_name}
class="w-48"
/>
<.data_field
label={gettext("Last Name")}
value={@member.last_name}
class="w-48"
/>
</div>
<%!-- Address --%> <%!-- Address --%>
<div> <div>
<.data_field label={gettext("Address")} value={format_address(@member)} /> <.data_field label={gettext("Address")} value={format_address(@member)} />
</div> </div>
<%!-- Email --%> <%!-- Email --%>
<div> <div>
<.data_field label={gettext("Email")}> <.data_field label={gettext("Email")}>
<a <a
href={"mailto:#{MvWeb.MemberLive.Index.format_member_email(@member)}"} href={"mailto:#{MvWeb.MemberLive.Index.format_member_email(@member)}"}
class="text-blue-700 hover:text-blue-800 underline" class="text-blue-700 hover:text-blue-800 underline"
> >
{@member.email} {@member.email}
</a> </a>
</.data_field> </.data_field>
</div> </div>
<%!-- Membership Dates Row --%> <%!-- Membership Dates Row --%>
<div class="flex gap-6"> <div class="flex gap-6">
<.data_field <.data_field
label={gettext("Join Date")} label={gettext("Join Date")}
value={format_date(@member.join_date)} value={format_date(@member.join_date)}
class="w-28" class="w-28"
/> />
<.data_field <.data_field
label={gettext("Exit Date")} label={gettext("Exit Date")}
value={format_date(@member.exit_date)} value={format_date(@member.exit_date)}
class="w-28" class="w-28"
/> />
</div> </div>
<%!-- Linked User: only show when current user can see other users (e.g. admin). <%!-- 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 read_only cannot see linked user, so hide the section to avoid "No user linked" when
a user is linked but not visible. --%> a user is linked but not visible. --%>
<%= if can_access_page?(@current_user, "/users") do %> <%= if can_access_page?(@current_user, "/users") do %>
<div> <div>
<.data_field label={gettext("Linked User")}> <.data_field label={gettext("Linked User")}>
<%= if @member.user do %> <%= if @member.user do %>
<.link <.link
navigate={~p"/users/#{@member.user}"} navigate={~p"/users/#{@member.user}"}
class="text-blue-700 hover:text-blue-800 underline inline-flex items-center gap-1" class="text-blue-700 hover:text-blue-800 underline inline-flex items-center gap-1"
> >
<.icon name="hero-user" class="size-4" /> <.icon name="hero-user" class="size-4" />
{@member.user.email} {@member.user.email}
</.link> </.link>
<% else %>
<span class="text-base-content/70 italic">
{gettext("No user linked")}
</span>
<% end %>
</.data_field>
</div>
<% end %>
<%!-- Groups (in Personal Data) --%>
<% groups = @member.groups || [] %>
<div>
<.data_field label={gettext("Groups")}>
<%= if Enum.empty?(groups) do %>
<span class="text-base-content/70 italic">{gettext("No groups")}</span>
<% else %>
<div class="flex flex-wrap gap-2">
<%= for group <- groups do %>
<.button
variant="outline"
size="sm"
navigate={~p"/groups/#{group.slug}"}
aria-label={gettext("Member of group %{name}", name: group.name)}
>
{group.name}
</.button>
<% end %>
</div>
<% end %>
</.data_field>
</div>
<%!-- Notes --%>
<%= if @member.notes && String.trim(@member.notes) != "" do %>
<div>
<.data_field label={gettext("Notes")}>
<p class="whitespace-pre-wrap text-base-content/80">{@member.notes}</p>
</.data_field>
</div>
<% end %>
</div>
</.section_box>
</div>
<%!-- Custom Fields Section --%>
<%= if Enum.any?(@custom_fields) do %>
<div>
<.section_box title={gettext("Custom Fields")}>
<div class="grid grid-cols-2 gap-4">
<%= 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)}
</.data_field>
<% end %>
</div>
</.section_box>
</div>
<% end %>
</div>
<%!-- Payment Data Section --%>
<div class="w-full">
<.section_box title={gettext("Payment Data")}>
<%= if @member.membership_fee_type do %>
<div class="flex gap-6 flex-wrap">
<.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 %>
<span class={["badge", MembershipFeeHelpers.status_color(status)]}>
{format_status_label(status)}
</span>
<% else %> <% else %>
<span class="text-base-content/70 italic">{gettext("No user linked")}</span> <span class="badge badge-ghost">{gettext("No cycles")}</span>
<% end %>
</.data_field>
<.data_field label={gettext("Current Cycle")} class="min-w-36">
<%= if @member.current_cycle_status do %>
<% status = @member.current_cycle_status %>
<span class={["badge", MembershipFeeHelpers.status_color(status)]}>
{format_status_label(status)}
</span>
<% else %>
<span class="badge badge-ghost">{gettext("No cycles")}</span>
<% end %> <% end %>
</.data_field> </.data_field>
</div> </div>
<% end %> <% else %>
<div class="text-base-content/70 italic">
<%!-- Groups (in Personal Data) --%> {gettext("No membership fee type assigned")}
<% groups = @member.groups || [] %>
<div>
<.data_field label={gettext("Groups")}>
<%= if Enum.empty?(groups) do %>
<span class="text-base-content/70 italic">{gettext("No groups")}</span>
<% else %>
<div class="flex flex-wrap gap-2">
<%= 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}
</.link>
<% end %>
</div>
<% end %>
</.data_field>
</div>
<%!-- Notes --%>
<%= if @member.notes && String.trim(@member.notes) != "" do %>
<div>
<.data_field label={gettext("Notes")}>
<p class="whitespace-pre-wrap text-base-content/80">{@member.notes}</p>
</.data_field>
</div> </div>
<% end %> <% end %>
</div>
</.section_box>
</div>
<%!-- Custom Fields Section --%>
<%= if Enum.any?(@custom_fields) do %>
<div>
<.section_box title={gettext("Custom Fields")}>
<div class="grid grid-cols-2 gap-4">
<%= 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)}
</.data_field>
<% end %>
</div>
</.section_box> </.section_box>
</div> </div>
<% end %> </div>
</div> <% end %>
<%!-- Payment Data Section --%> <%= if @active_tab == :membership_fees do %>
<div class="w-full"> <%!-- Membership Fees Tab Content --%>
<.section_box title={gettext("Payment Data")}> <div
<%= if @member.membership_fee_type do %> id="member-tabpanel-membership_fees"
<div class="flex gap-6 flex-wrap"> role="tabpanel"
<.data_field aria-labelledby="member-tab-membership_fees"
label={gettext("Type")} >
value={@member.membership_fee_type.name} <.live_component
class="min-w-32" module={MvWeb.MemberLive.Show.MembershipFeesComponent}
/> id={"membership-fees-#{@member.id}"}
<.data_field member={@member}
label={gettext("Membership Fee")} current_user={@current_user}
value={MembershipFeeHelpers.format_currency(@member.membership_fee_type.amount)} vereinfacht_receipts={@vereinfacht_receipts}
class="min-w-24" />
/> </div>
<.data_field <% end %>
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 %>
<span class={["badge", MembershipFeeHelpers.status_color(status)]}>
{format_status_label(status)}
</span>
<% else %>
<span class="badge badge-ghost">{gettext("No cycles")}</span>
<% end %>
</.data_field>
<.data_field label={gettext("Current Cycle")} class="min-w-36">
<%= if @member.current_cycle_status do %>
<% status = @member.current_cycle_status %>
<span class={["badge", MembershipFeeHelpers.status_color(status)]}>
{format_status_label(status)}
</span>
<% else %>
<span class="badge badge-ghost">{gettext("No cycles")}</span>
<% end %>
</.data_field>
</div>
<% else %>
<div class="text-base-content/70 italic">
{gettext("No membership fee type assigned")}
</div>
<% end %>
</.section_box>
</div>
<% end %>
<%= if @active_tab == :membership_fees do %> <%!-- Danger zone: same section pattern as section_box (h2 outside border) --%>
<%!-- Membership Fees Tab Content --%> <%= if can?(@current_user, :destroy, @member) do %>
<.live_component <section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
module={MvWeb.MemberLive.Show.MembershipFeesComponent} <h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
id={"membership-fees-#{@member.id}"} {gettext("Danger zone")}
member={@member} </h2>
current_user={@current_user} <div class="border border-base-300 rounded-lg p-4 bg-base-100">
vereinfacht_receipts={@vereinfacht_receipts} <p class="text-base-content/70 mb-4">
/> {gettext(
<% end %> "Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed."
)}
</p>
<.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")}
</.button>
</div>
</section>
<% end %>
</div>
</Layouts.app> </Layouts.app>
""" """
end end
@ -320,6 +396,37 @@ defmodule MvWeb.MemberLive.Show do
{:noreply, assign(socket, :active_tab, :membership_fees)} {:noreply, assign(socket, :active_tab, :membership_fees)}
end 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 def handle_event("load_vereinfacht_receipts", %{"contact_id" => contact_id}, socket) do
response = response =
case Mv.Vereinfacht.Client.get_contact_with_receipts(contact_id) do case Mv.Vereinfacht.Client.get_contact_with_receipts(contact_id) do
@ -350,6 +457,19 @@ defmodule MvWeb.MemberLive.Show do
defp page_title(:show), do: gettext("Show Member") defp page_title(:show), do: gettext("Show Member")
defp page_title(:edit), do: gettext("Edit 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 # Helper Components
# ----------------------------------------------------------------- # -----------------------------------------------------------------
@ -403,7 +523,7 @@ defmodule MvWeb.MemberLive.Show do
~H""" ~H"""
<a <a
href={"mailto:#{@email}"} href={"mailto:#{@email}"}
class="text-blue-700 hover:text-blue-800 underline" class="link link-primary"
> >
{@display} {@display}
</a> </a>

View file

@ -66,14 +66,15 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
<.icon name="hero-arrow-top-right-on-square" class="inline-block size-4" /> <.icon name="hero-arrow-top-right-on-square" class="inline-block size-4" />
</.link> </.link>
<div> <div>
<button <.button
type="button" type="button"
variant="ghost"
size="sm"
phx-click="load_vereinfacht_receipts" phx-click="load_vereinfacht_receipts"
phx-value-contact_id={@member.vereinfacht_contact_id} phx-value-contact_id={@member.vereinfacht_contact_id}
class="btn btn-sm btn-ghost"
> >
{gettext("Show bookings/receipts from Vereinfacht")} {gettext("Show bookings/receipts from Vereinfacht")}
</button> </.button>
</div> </div>
<%= if @vereinfacht_receipts do %> <%= if @vereinfacht_receipts do %>
<div <div
@ -148,9 +149,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
</.button> </.button>
<.button <.button
:if={Enum.any?(@cycles) and @can_destroy_cycle} :if={Enum.any?(@cycles) and @can_destroy_cycle}
variant="outline"
size="sm"
phx-click="delete_all_cycles" phx-click="delete_all_cycles"
phx-target={@myself} phx-target={@myself}
class="btn btn-sm btn-error btn-outline"
title={gettext("Delete all cycles")} title={gettext("Delete all cycles")}
> >
<.icon name="hero-trash" class="size-4" /> <.icon name="hero-trash" class="size-4" />
@ -158,9 +160,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
</.button> </.button>
<.button <.button
:if={@member.membership_fee_type != nil and @can_create_cycle} :if={@member.membership_fee_type != nil and @can_create_cycle}
variant="primary"
size="sm"
phx-click="open_create_cycle_modal" phx-click="open_create_cycle_modal"
phx-target={@myself} phx-target={@myself}
class="btn btn-sm btn-primary"
title={gettext("Create a new cycle manually")} title={gettext("Create a new cycle manually")}
> >
<.icon name="hero-plus" class="size-4" /> <.icon name="hero-plus" class="size-4" />
@ -259,17 +262,18 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
</div> </div>
<% end %> <% end %>
<%= if @can_destroy_cycle do %> <%= if @can_destroy_cycle do %>
<button <.button
type="button" type="button"
variant="outline"
size="sm"
phx-click="delete_cycle" phx-click="delete_cycle"
phx-value-cycle_id={cycle.id} phx-value-cycle_id={cycle.id}
phx-target={@myself} phx-target={@myself}
class="btn btn-sm btn-error btn-outline"
title={gettext("Delete cycle")} title={gettext("Delete cycle")}
> >
<.icon name="hero-trash" class="size-4" /> <.icon name="hero-trash" class="size-4" />
{gettext("Delete")} {gettext("Delete")}
</button> </.button>
<% end %> <% end %>
</div> </div>
</:action> </:action>
@ -309,10 +313,15 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
/> />
</div> </div>
<div class="modal-action"> <div class="modal-action">
<button type="button" phx-click="cancel_edit_amount" phx-target={@myself} class="btn"> <.button
type="button"
variant="neutral"
phx-click="cancel_edit_amount"
phx-target={@myself}
>
{gettext("Cancel")} {gettext("Cancel")}
</button> </.button>
<button type="submit" class="btn btn-primary">{gettext("Save")}</button> <.button type="submit" variant="primary">{gettext("Save")}</.button>
</div> </div>
</form> </form>
</div> </div>
@ -334,17 +343,17 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
)} - {MembershipFeeHelpers.format_currency(@deleting_cycle.amount)} )} - {MembershipFeeHelpers.format_currency(@deleting_cycle.amount)}
</p> </p>
<div class="modal-action"> <div class="modal-action">
<button phx-click="cancel_delete_cycle" phx-target={@myself} class="btn"> <.button variant="neutral" phx-click="cancel_delete_cycle" phx-target={@myself}>
{gettext("Cancel")} {gettext("Cancel")}
</button> </.button>
<button <.button
variant="danger"
phx-click="confirm_delete_cycle" phx-click="confirm_delete_cycle"
phx-value-cycle_id={@deleting_cycle.id} phx-value-cycle_id={@deleting_cycle.id}
phx-target={@myself} phx-target={@myself}
class="btn btn-error"
> >
{gettext("Delete")} {gettext("Delete")}
</button> </.button>
</div> </div>
</div> </div>
</dialog> </dialog>
@ -385,20 +394,20 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
/> />
</div> </div>
<div class="modal-action"> <div class="modal-action">
<button phx-click="cancel_delete_all_cycles" phx-target={@myself} class="btn"> <.button variant="neutral" phx-click="cancel_delete_all_cycles" phx-target={@myself}>
{gettext("Cancel")} {gettext("Cancel")}
</button> </.button>
<button <.button
variant="danger"
phx-click="confirm_delete_all_cycles" phx-click="confirm_delete_all_cycles"
phx-target={@myself} phx-target={@myself}
class="btn btn-error"
disabled={ disabled={
String.trim(String.downcase(@delete_all_confirmation)) != String.trim(String.downcase(@delete_all_confirmation)) !=
String.downcase(gettext("Yes")) String.downcase(gettext("Yes"))
} }
> >
{gettext("Delete All")} {gettext("Delete All")}
</button> </.button>
</div> </div>
</div> </div>
</dialog> </dialog>
@ -472,10 +481,15 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
</div> </div>
<% end %> <% end %>
<div class="modal-action"> <div class="modal-action">
<button type="button" phx-click="cancel_create_cycle" phx-target={@myself} class="btn"> <.button
type="button"
variant="neutral"
phx-click="cancel_create_cycle"
phx-target={@myself}
>
{gettext("Cancel")} {gettext("Cancel")}
</button> </.button>
<button type="submit" class="btn btn-primary">{gettext("Create")}</button> <.button type="submit" variant="primary">{gettext("Create")}</.button>
</div> </div>
</form> </form>
</div> </div>
@ -548,7 +562,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
get_available_fee_types(updated_member, actor) get_available_fee_types(updated_member, actor)
) )
|> assign(:interval_warning, nil) |> assign(:interval_warning, nil)
|> put_flash(:info, gettext("Membership fee type removed"))} |> put_flash(:success, gettext("Membership fee type removed"))}
{:error, error} -> {:error, error} ->
{:noreply, put_flash(socket, :error, format_error(error))} {:noreply, put_flash(socket, :error, format_error(error))}
@ -607,7 +621,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
get_available_fee_types(updated_member, actor) get_available_fee_types(updated_member, actor)
) )
|> assign(:interval_warning, nil) |> assign(:interval_warning, nil)
|> put_flash(:info, gettext("Membership fee type updated. Cycles regenerated."))} |> put_flash(:success, gettext("Membership fee type updated. Cycles regenerated."))}
{:error, error} -> {:error, error} ->
{:noreply, put_flash(socket, :error, format_error(error))} {:noreply, put_flash(socket, :error, format_error(error))}
@ -635,7 +649,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
{:noreply, {:noreply,
socket socket
|> assign(:cycles, updated_cycles) |> assign(:cycles, updated_cycles)
|> put_flash(:info, gettext("Cycle status updated"))} |> put_flash(:success, gettext("Cycle status updated"))}
{:error, %Ash.Error.Invalid{} = error} -> {:error, %Ash.Error.Invalid{} = error} ->
error_msg = error_msg =
@ -691,7 +705,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|> assign(:member, updated_member) |> assign(:member, updated_member)
|> assign(:cycles, cycles) |> assign(:cycles, cycles)
|> assign(:regenerating, false) |> assign(:regenerating, false)
|> put_flash(:info, gettext("Cycles regenerated successfully"))} |> put_flash(:success, gettext("Cycles regenerated successfully"))}
{:error, error} -> {:error, error} ->
{:noreply, {:noreply,
@ -741,7 +755,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
socket socket
|> assign(:cycles, updated_cycles) |> assign(:cycles, updated_cycles)
|> assign(:editing_cycle, nil) |> assign(:editing_cycle, nil)
|> put_flash(:info, gettext("Cycle amount updated"))} |> put_flash(:success, gettext("Cycle amount updated"))}
{:error, error} -> {:error, error} ->
{:noreply, {:noreply,
@ -780,7 +794,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
socket socket
|> assign(:cycles, updated_cycles) |> assign(:cycles, updated_cycles)
|> assign(:deleting_cycle, nil) |> assign(:deleting_cycle, nil)
|> put_flash(:info, gettext("Cycle deleted"))} |> put_flash(:success, gettext("Cycle deleted"))}
{:ok, _destroyed} -> {:ok, _destroyed} ->
# Handle case where return_destroyed? is true # Handle case where return_destroyed? is true
@ -790,7 +804,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
socket socket
|> assign(:cycles, updated_cycles) |> assign(:cycles, updated_cycles)
|> assign(:deleting_cycle, nil) |> assign(:deleting_cycle, nil)
|> put_flash(:info, gettext("Cycle deleted"))} |> put_flash(:success, gettext("Cycle deleted"))}
{:error, error} -> {:error, error} ->
{:noreply, {:noreply,
@ -936,7 +950,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|> assign(:creating_cycle, false) |> assign(:creating_cycle, false)
|> assign(:create_cycle_date, nil) |> assign(:create_cycle_date, nil)
|> assign(:create_cycle_error, nil) |> assign(:create_cycle_error, nil)
|> put_flash(:info, gettext("Cycle created successfully"))} |> put_flash(:success, gettext("Cycle created successfully"))}
{:error, error} -> {:error, error} ->
{:noreply, {:noreply,
@ -999,7 +1013,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|> assign(:member, updated_member) |> assign(:member, updated_member)
|> assign(:cycles, updated_cycles) |> assign(:cycles, updated_cycles)
|> reset_modal.() |> reset_modal.()
|> put_flash(:info, gettext("All cycles deleted"))} |> put_flash(:success, gettext("All cycles deleted"))}
{:ok, _} -> {:ok, _} ->
{:noreply, {:noreply,

View file

@ -82,7 +82,7 @@ defmodule MvWeb.MembershipFeeSettingsLive do
{:noreply, {:noreply,
socket socket
|> assign(:settings, updated_settings) |> assign(:settings, updated_settings)
|> put_flash(:info, gettext("Settings saved successfully.")) |> put_flash(:success, gettext("Settings saved successfully."))
|> assign_form()} |> assign_form()}
{:error, form} -> {:error, form} ->
@ -105,7 +105,7 @@ defmodule MvWeb.MembershipFeeSettingsLive do
socket socket
|> assign(:membership_fee_types, updated_types) |> assign(:membership_fee_types, updated_types)
|> assign(:member_counts, updated_counts) |> assign(:member_counts, updated_counts)
|> put_flash(:info, gettext("Membership fee type deleted"))} |> put_flash(:success, gettext("Membership fee type deleted"))}
{:error, %Ash.Error.Forbidden{}} -> {:error, %Ash.Error.Forbidden{}} ->
{:noreply, {:noreply,
@ -239,10 +239,10 @@ defmodule MvWeb.MembershipFeeSettingsLive do
<div class="divider"></div> <div class="divider"></div>
<button type="submit" class="btn btn-primary w-full"> <.button type="submit" variant="primary" class="w-full">
<.icon name="hero-check" class="size-5" /> <.icon name="hero-check" class="size-5" />
{gettext("Save Settings")} {gettext("Save Settings")}
</button> </.button>
</.form> </.form>
</div> </div>
</div> </div>
@ -333,24 +333,27 @@ defmodule MvWeb.MembershipFeeSettingsLive do
</:col> </:col>
<:action :let={mft}> <:action :let={mft}>
<.link <.tooltip content={gettext("Edit membership fee type")} position="left">
navigate={~p"/membership_fee_settings/#{mft.id}/edit_fee_type"} <.button
class="btn btn-ghost btn-xs" variant="ghost"
aria-label={gettext("Edit membership fee type")} size="sm"
> navigate={~p"/membership_fee_settings/#{mft.id}/edit_fee_type"}
<.icon name="hero-pencil" class="size-4" /> aria-label={gettext("Edit membership fee type")}
</.link> >
<.icon name="hero-pencil" class="size-4" />
</.button>
</.tooltip>
</:action> </:action>
<:action :let={mft}> <:action :let={mft}>
<div <.tooltip
:if={get_member_count(mft, @member_counts) > 0} :if={get_member_count(mft, @member_counts) > 0}
class="tooltip tooltip-left" content={
data-tip={
gettext("Cannot delete - %{count} member(s) assigned", gettext("Cannot delete - %{count} member(s) assigned",
count: get_member_count(mft, @member_counts) count: get_member_count(mft, @member_counts)
) )
} }
position="left"
> >
<button <button
phx-click="delete" phx-click="delete"
@ -366,17 +369,18 @@ defmodule MvWeb.MembershipFeeSettingsLive do
> >
<.icon name="hero-trash" class="size-4" /> <.icon name="hero-trash" class="size-4" />
</button> </button>
</div> </.tooltip>
<button <.button
:if={get_member_count(mft, @member_counts) == 0} :if={get_member_count(mft, @member_counts) == 0}
variant="danger"
size="sm"
phx-click="delete" phx-click="delete"
phx-value-id={mft.id} phx-value-id={mft.id}
data-confirm={gettext("Are you sure?")} data-confirm={gettext("Are you sure?")}
class="btn btn-ghost btn-xs text-error"
aria-label={gettext("Delete Membership Fee Type")} aria-label={gettext("Delete Membership Fee Type")}
> >
<.icon name="hero-trash" class="size-4" /> <.icon name="hero-trash" class="size-4" />
</button> </.button>
</:action> </:action>
</.table> </.table>

View file

@ -27,10 +27,26 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
~H""" ~H"""
<Layouts.app flash={@flash} current_user={@current_user}> <Layouts.app flash={@flash} current_user={@current_user}>
<.header> <.header>
<:leading>
<.button navigate={return_path(@return_to, @membership_fee_type)} variant="neutral">
<.icon name="hero-arrow-left" class="size-4" />
{gettext("Back")}
</.button>
</:leading>
{@page_title} {@page_title}
<:subtitle> <:subtitle>
{gettext("Use this form to manage membership fee types in your database.")} {gettext("Use this form to manage membership fee types in your database.")}
</:subtitle> </:subtitle>
<:actions>
<.button
form="membership-fee-type-form"
phx-disable-with={gettext("Saving...")}
variant="primary"
type="submit"
>
{gettext("Save")}
</.button>
</:actions>
</.header> </.header>
<.form <.form
@ -176,20 +192,20 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
</div> </div>
<div class="modal-action"> <div class="modal-action">
<button <.button
type="button" type="button"
variant="neutral"
phx-click="cancel_amount_change" phx-click="cancel_amount_change"
class="btn"
> >
{gettext("Cancel")} {gettext("Cancel")}
</button> </.button>
<button <.button
type="button" type="button"
variant="primary"
phx-click="confirm_amount_change" phx-click="confirm_amount_change"
class="btn btn-primary"
> >
{gettext("Confirm Change")} {gettext("Confirm Change")}
</button> </.button>
</div> </div>
</div> </div>
</dialog> </dialog>
@ -317,7 +333,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
socket = socket =
socket socket
|> put_flash(:info, gettext("Membership fee type saved successfully")) |> put_flash(:success, gettext("Membership fee type saved successfully"))
|> push_navigate(to: return_path(socket.assigns.return_to, membership_fee_type)) |> push_navigate(to: return_path(socket.assigns.return_to, membership_fee_type))
{:noreply, socket} {:noreply, socket}

View file

@ -78,24 +78,27 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
</:col> </:col>
<:action :let={mft}> <:action :let={mft}>
<.link <.tooltip content={gettext("Edit membership fee type")} position="left">
navigate={~p"/membership_fee_settings/#{mft.id}/edit_fee_type"} <.button
class="btn btn-ghost btn-xs" variant="ghost"
aria-label={gettext("Edit membership fee type")} size="sm"
> navigate={~p"/membership_fee_settings/#{mft.id}/edit_fee_type"}
<.icon name="hero-pencil" class="size-4" /> aria-label={gettext("Edit membership fee type")}
</.link> >
<.icon name="hero-pencil" class="size-4" />
</.button>
</.tooltip>
</:action> </:action>
<:action :let={mft}> <:action :let={mft}>
<div <.tooltip
:if={get_member_count(mft, @member_counts) > 0} :if={get_member_count(mft, @member_counts) > 0}
class="tooltip tooltip-left" content={
data-tip={
gettext("Cannot delete - %{count} member(s) assigned", gettext("Cannot delete - %{count} member(s) assigned",
count: get_member_count(mft, @member_counts) count: get_member_count(mft, @member_counts)
) )
} }
position="left"
> >
<button <button
phx-click="delete" phx-click="delete"
@ -111,17 +114,18 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
> >
<.icon name="hero-trash" class="size-4" /> <.icon name="hero-trash" class="size-4" />
</button> </button>
</div> </.tooltip>
<button <.button
:if={get_member_count(mft, @member_counts) == 0} :if={get_member_count(mft, @member_counts) == 0}
variant="danger"
size="sm"
phx-click="delete" phx-click="delete"
phx-value-id={mft.id} phx-value-id={mft.id}
data-confirm={gettext("Are you sure?")} data-confirm={gettext("Are you sure?")}
class="btn btn-ghost btn-xs text-error"
aria-label={gettext("Delete Membership Fee Type")} aria-label={gettext("Delete Membership Fee Type")}
> >
<.icon name="hero-trash" class="size-4" /> <.icon name="hero-trash" class="size-4" />
</button> </.button>
</:action> </:action>
</.table> </.table>
@ -145,7 +149,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
socket socket
|> assign(:membership_fee_types, updated_types) |> assign(:membership_fee_types, updated_types)
|> assign(:member_counts, updated_counts) |> assign(:member_counts, updated_counts)
|> put_flash(:info, gettext("Membership fee type deleted"))} |> put_flash(:success, gettext("Membership fee type deleted"))}
{:error, %Ash.Error.Forbidden{}} -> {:error, %Ash.Error.Forbidden{}} ->
{:noreply, {:noreply,

View file

@ -21,66 +21,70 @@ defmodule MvWeb.RoleLive.Form do
def render(assigns) do def render(assigns) do
~H""" ~H"""
<Layouts.app flash={@flash} current_user={@current_user}> <Layouts.app flash={@flash} current_user={@current_user}>
<.header>
{@page_title}
<:subtitle>{gettext("Use this form to manage roles in your database.")}</:subtitle>
</.header>
<.form class="max-w-xl" for={@form} id="role-form" phx-change="validate" phx-submit="save"> <.form class="max-w-xl" for={@form} id="role-form" phx-change="validate" phx-submit="save">
<.input field={@form[:name]} type="text" label={gettext("Name")} required /> <.header>
<:leading>
<.button navigate={return_path(@return_to, @role)} variant="neutral">
<.icon name="hero-arrow-left" class="size-4" />
{gettext("Back")}
</.button>
</:leading>
{@page_title}
<:subtitle>{gettext("Use this form to manage roles in your database.")}</:subtitle>
<:actions>
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save")}
</.button>
</:actions>
</.header>
<.input <div class="mt-6 space-y-6">
field={@form[:description]} <.input field={@form[:name]} type="text" label={gettext("Name")} required />
type="textarea"
label={gettext("Description")}
rows="3"
/>
<div class="form-control"> <.input
<label class="label" for="role-form_permission_set_name"> field={@form[:description]}
<span class="label-text font-semibold"> type="textarea"
{gettext("Permission Set")} label={gettext("Description")}
<span class="text-red-700">*</span> rows="3"
</span> />
</label>
<select <div class="form-control">
class={[ <label class="label" for="role-form_permission_set_name">
"select select-bordered w-full", <span class="label-text font-semibold">
@form.errors[:permission_set_name] && "select-error" {gettext("Permission Set")}
]} <span class="text-red-700">*</span>
name="role[permission_set_name]" </span>
id="role-form_permission_set_name" </label>
required <select
aria-label={gettext("Permission Set")} class={[
> "select select-bordered w-full",
<option value="">{gettext("Select permission set")}</option> @form.errors[:permission_set_name] && "select-error"
<%= for permission_set <- all_permission_sets() do %> ]}
<option name="role[permission_set_name]"
value={permission_set} id="role-form_permission_set_name"
selected={@form[:permission_set_name].value == permission_set} required
> aria-label={gettext("Permission Set")}
{format_permission_set_option(permission_set)} >
</option> <option value="">{gettext("Select permission set")}</option>
<%= for permission_set <- all_permission_sets() do %>
<option
value={permission_set}
selected={@form[:permission_set_name].value == permission_set}
>
{format_permission_set_option(permission_set)}
</option>
<% end %>
</select>
<%= if @form.errors[:permission_set_name] do %>
<%= for error <- List.wrap(@form.errors[:permission_set_name]) do %>
<% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %>
<p class="mt-1.5 flex gap-2 items-center text-sm text-error">
<.icon name="hero-exclamation-circle" class="size-5" />
{msg}
</p>
<% end %>
<% end %> <% end %>
</select> </div>
<%= if @form.errors[:permission_set_name] do %>
<%= for error <- List.wrap(@form.errors[:permission_set_name]) do %>
<% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %>
<p class="mt-1.5 flex gap-2 items-center text-sm text-error">
<.icon name="hero-exclamation-circle" class="size-5" />
{msg}
</p>
<% end %>
<% end %>
</div>
<div class="mt-4">
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save Role")}
</.button>
<.button navigate={return_path(@return_to, @role)} type="button">
{gettext("Cancel")}
</.button>
</div> </div>
</.form> </.form>
</Layouts.app> </Layouts.app>
@ -175,7 +179,7 @@ defmodule MvWeb.RoleLive.Form do
socket = socket =
socket socket
|> put_flash(:info, gettext("Role saved successfully.")) |> put_flash(:success, gettext("Role saved successfully."))
|> push_navigate(to: redirect_path) |> push_navigate(to: redirect_path)
{:noreply, socket} {:noreply, socket}

View file

@ -5,11 +5,8 @@ defmodule MvWeb.RoleLive.Index do
## Features ## Features
- List all roles with name, description, permission_set_name, is_system_role - List all roles with name, description, permission_set_name, is_system_role
- Create new roles - Create new roles
- Navigate to role details and edit forms - Navigate to role details (row click) and edit from details header
- Delete non-system roles - Delete only via Danger zone on role show page
## Events
- `delete` - Remove a role from the database (only non-system roles)
## Security ## Security
Only admins can access this page (enforced by authorization). Only admins can access this page (enforced by authorization).
@ -21,8 +18,7 @@ defmodule MvWeb.RoleLive.Index do
require Ash.Query require Ash.Query
import MvWeb.RoleLive.Helpers, import MvWeb.RoleLive.Helpers, only: [permission_set_badge_class: 1]
only: [format_error: 1, permission_set_badge_class: 1, opts_with_actor: 3]
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
@ -37,83 +33,6 @@ defmodule MvWeb.RoleLive.Index do
|> assign(:user_counts, user_counts)} |> assign(:user_counts, user_counts)}
end 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()] @spec load_roles(map() | nil) :: [Mv.Authorization.Role.t()]
defp load_roles(actor) do defp load_roles(actor) do
opts = MvWeb.LiveHelpers.ash_actor_opts(actor) opts = MvWeb.LiveHelpers.ash_actor_opts(actor)
@ -154,15 +73,4 @@ defmodule MvWeb.RoleLive.Index do
defp get_user_count(role, user_counts) do defp get_user_count(role, user_counts) do
Map.get(user_counts, role.id, 0) Map.get(user_counts, role.id, 0)
end 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 end

View file

@ -17,6 +17,7 @@
id="roles" id="roles"
rows={@roles} rows={@roles}
row_click={fn role -> JS.navigate(~p"/admin/roles/#{role}") end} row_click={fn role -> JS.navigate(~p"/admin/roles/#{role}") end}
row_tooltip={gettext("Click for role details")}
> >
<:col :let={role} label={gettext("Name")}> <:col :let={role} label={gettext("Name")}>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@ -52,46 +53,5 @@
<:col :let={role} label={gettext("Users")}> <:col :let={role} label={gettext("Users")}>
<span class="badge badge-ghost">{get_user_count(role, @user_counts)}</span> <span class="badge badge-ghost">{get_user_count(role, @user_counts)}</span>
</:col> </:col>
<:action :let={role}>
<div class="sr-only">
<.link navigate={~p"/admin/roles/#{role}"}>{gettext("Show")}</.link>
</div>
<%= 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")}
</.link>
<% end %>
</:action>
<: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")}
</.link>
<% else %>
<div
:if={role.is_system_role}
class="tooltip tooltip-left"
data-tip={gettext("System roles cannot be deleted")}
>
<button
class="btn btn-ghost btn-sm text-error opacity-50 cursor-not-allowed"
disabled={true}
aria-label={gettext("Cannot delete system role")}
>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete")}
</button>
</div>
<% end %>
</:action>
</.table> </.table>
</Layouts.app> </Layouts.app>

View file

@ -124,7 +124,7 @@ defmodule MvWeb.RoleLive.Show do
:ok -> :ok ->
{:noreply, {:noreply,
socket socket
|> put_flash(:info, gettext("Role deleted successfully.")) |> put_flash(:success, gettext("Role deleted successfully."))
|> push_navigate(to: ~p"/admin/roles")} |> push_navigate(to: ~p"/admin/roles")}
{:error, error} -> {:error, error} ->
@ -161,27 +161,28 @@ defmodule MvWeb.RoleLive.Show do
~H""" ~H"""
<Layouts.app flash={@flash} current_user={@current_user}> <Layouts.app flash={@flash} current_user={@current_user}>
<.header> <.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")}
</.button>
</:leading>
{gettext("Role")} {@role.name} {gettext("Role")} {@role.name}
<:subtitle>{gettext("Role details and permissions.")}</:subtitle> <:subtitle>{gettext("Role details and permissions.")}</:subtitle>
<:actions> <:actions>
<.button navigate={~p"/admin/roles"} aria-label={gettext("Back to roles list")}>
<.icon name="hero-arrow-left" />
<span class="sr-only">{gettext("Back to roles list")}</span>
</.button>
<%= if can?(@current_user, :update, Mv.Authorization.Role) do %> <%= if can?(@current_user, :update, Mv.Authorization.Role) do %>
<.button variant="primary" navigate={~p"/admin/roles/#{@role}/edit"}> <.button
<.icon name="hero-pencil-square" /> {gettext("Edit Role")} variant="primary"
</.button> navigate={~p"/admin/roles/#{@role}/edit"}
<% end %> data-testid="role-show-edit-btn"
<%= 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")} <.icon name="hero-pencil-square" /> {gettext("Edit role")}
</.link> </.button>
<% end %> <% end %>
</:actions> </:actions>
</.header> </.header>
@ -208,6 +209,37 @@ defmodule MvWeb.RoleLive.Show do
<% end %> <% end %>
</:item> </:item>
</.list> </.list>
<%!-- Danger zone: canonical pattern (same as member show) --%>
<%= if can?(@current_user, :destroy, Mv.Authorization.Role) and not @role.is_system_role do %>
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
{gettext("Danger zone")}
</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
<p class="text-base-content/70 mb-4">
{gettext(
"Deleting this role cannot be undone. Users assigned to this role must be reassigned first."
)}
</p>
<.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")}
</.button>
</div>
</section>
<% end %>
</Layouts.app> </Layouts.app>
""" """
end end

View file

@ -39,14 +39,31 @@ defmodule MvWeb.UserLive.Form do
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3] import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
import MvWeb.Authorization, only: [can?: 3] import MvWeb.Authorization, only: [can?: 3]
import MvWeb.ErrorHelpers, only: [format_ash_error: 1]
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<Layouts.app flash={@flash} current_user={@current_user}> <Layouts.app flash={@flash} current_user={@current_user}>
<.header> <.header>
<:leading>
<.button navigate={return_path(@return_to, @user)} variant="neutral">
<.icon name="hero-arrow-left" class="size-4" />
{gettext("Back")}
</.button>
</:leading>
{@page_title} {@page_title}
<:subtitle>{gettext("Use this form to manage user records in your database.")}</:subtitle> <:subtitle>{gettext("Use this form to manage user records in your database.")}</:subtitle>
<:actions>
<.button
form="user-form"
phx-disable-with={gettext("Saving...")}
variant="primary"
type="submit"
>
{gettext("Save User")}
</.button>
</:actions>
</.header> </.header>
<.form class="max-w-xl" for={@form} id="user-form" phx-change="validate" phx-submit="save"> <.form class="max-w-xl" for={@form} id="user-form" phx-change="validate" phx-submit="save">
@ -167,13 +184,14 @@ defmodule MvWeb.UserLive.Form do
</p> </p>
<p class="text-sm text-green-700">{@user.member.email}</p> <p class="text-sm text-green-700">{@user.member.email}</p>
</div> </div>
<button <.button
type="button" type="button"
variant="danger"
size="sm"
phx-click="unlink_member" phx-click="unlink_member"
class="btn btn-sm btn-error"
> >
{gettext("Unlink Member")} {gettext("Unlink Member")}
</button> </.button>
</div> </div>
</div> </div>
<% else %> <% else %>
@ -280,11 +298,46 @@ defmodule MvWeb.UserLive.Form do
</div> </div>
<% end %> <% end %>
<%!-- Danger zone: canonical pattern (same as member form) --%>
<%= if @user && can?(@current_user, :destroy, @user) && !Mv.Helpers.SystemActor.system_user?(@user) do %>
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
{gettext("Danger zone")}
</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
<p class="text-base-content/70 mb-4">
{gettext(
"Deleting this user cannot be undone. The user account and any linked member association will be affected."
)}
</p>
<.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")}
</.button>
</div>
</section>
<% end %>
<div class="mt-4"> <div class="mt-4">
<.button navigate={return_path(@return_to, @user)} variant="neutral">
{gettext("Cancel")}
</.button>
<.button phx-disable-with={gettext("Saving...")} variant="primary"> <.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save User")} {gettext("Save User")}
</.button> </.button>
<.button navigate={return_path(@return_to, @user)}>{gettext("Cancel")}</.button>
</div> </div>
</.form> </.form>
</Layouts.app> </Layouts.app>
@ -401,6 +454,26 @@ defmodule MvWeb.UserLive.Form do
end end
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 @impl true
def handle_event("show_member_dropdown", _params, socket) do def handle_event("show_member_dropdown", _params, socket) do
{:noreply, assign(socket, show_member_dropdown: true)} {:noreply, assign(socket, show_member_dropdown: true)}
@ -511,6 +584,23 @@ defmodule MvWeb.UserLive.Form do
{:noreply, socket} {:noreply, socket}
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
defp handle_member_linking(socket, user, actor) do defp handle_member_linking(socket, user, actor) do
result = perform_member_link_action(socket, user, actor) result = perform_member_link_action(socket, user, actor)
@ -553,7 +643,7 @@ defmodule MvWeb.UserLive.Form do
socket = socket =
socket socket
|> put_flash(:info, gettext("User %{action} successfully", action: action)) |> put_flash(:success, gettext("User %{action} successfully", action: action))
|> push_navigate(to: return_path(socket.assigns.return_to, updated_user)) |> push_navigate(to: return_path(socket.assigns.return_to, updated_user))
{:noreply, socket} {:noreply, socket}

View file

@ -5,18 +5,12 @@ defmodule MvWeb.UserLive.Index do
## Features ## Features
- List all users with email and linked member - List all users with email and linked member
- Sort users by email (default) - Sort users by email (default)
- Delete users - Navigate to user details (row click) and edit from details header
- Navigate to user details and edit forms - Delete only via Danger zone on user show/edit
- Bulk selection for future batch operations
## Relationships ## Relationships
Displays linked member information when a user is connected to a member account. 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 ## Security
User deletion requires admin permissions (enforced by Ash policies). User deletion requires admin permissions (enforced by Ash policies).
""" """
@ -26,7 +20,6 @@ defmodule MvWeb.UserLive.Index do
import MvWeb.LiveHelpers, only: [current_actor: 1] import MvWeb.LiveHelpers, only: [current_actor: 1]
require Ash.Query require Ash.Query
import MvWeb.ErrorHelpers, only: [format_ash_error: 1]
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
@ -44,63 +37,7 @@ defmodule MvWeb.UserLive.Index do
|> assign(:page_title, gettext("Listing Users")) |> assign(:page_title, gettext("Listing Users"))
|> assign(:sort_field, :email) |> assign(:sort_field, :email)
|> assign(:sort_order, :asc) |> 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 end
# Sorts the list of users according to a field, when you click on the column header # Sorts the list of users according to a field, when you click on the column header
@ -127,24 +64,6 @@ defmodule MvWeb.UserLive.Index do
|> assign(:users, sorted_users)} |> assign(:users, sorted_users)}
end 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(:asc), do: :desc
defp toggle_order(:desc), do: :asc defp toggle_order(:desc), do: :asc
defp sort_fun(:asc), do: &<=/2 defp sort_fun(:asc), do: &<=/2

View file

@ -15,36 +15,10 @@
rows={@users} rows={@users}
row_id={fn user -> "row-#{user.id}" end} row_id={fn user -> "row-#{user.id}" end}
row_click={fn user -> JS.navigate(~p"/users/#{user}") end} row_click={fn user -> JS.navigate(~p"/users/#{user}") end}
row_tooltip={gettext("Click for user details")}
sort_field={@sort_field} sort_field={@sort_field}
sort_order={@sort_order} 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>
<:col <:col
:let={user} :let={user}
sort_field={:email} sort_field={:email}
@ -83,29 +57,5 @@
<span class="text-base-content/70">—</span> <span class="text-base-content/70">—</span>
<% end %> <% end %>
</:col> </:col>
<:action :let={user}>
<div class="sr-only">
<.link navigate={~p"/users/#{user}"}>{gettext("Show")}</.link>
</div>
<%= if can?(@current_user, :update, user) do %>
<.link navigate={~p"/users/#{user}/edit"} data-testid="user-edit">
{gettext("Edit")}
</.link>
<% end %>
</:action>
<: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")}
</.link>
<% end %>
</:action>
</.table> </.table>
</Layouts.app> </Layouts.app>

View file

@ -27,20 +27,27 @@ defmodule MvWeb.UserLive.Show do
use MvWeb, :live_view use MvWeb, :live_view
import MvWeb.LiveHelpers, only: [current_actor: 1] import MvWeb.LiveHelpers, only: [current_actor: 1]
import MvWeb.ErrorHelpers, only: [format_ash_error: 1]
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<Layouts.app flash={@flash} current_user={@current_user}> <Layouts.app flash={@flash} current_user={@current_user}>
<.header> <.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")}
</.button>
</:leading>
{gettext("User")} {@user.email} {gettext("User")} {@user.email}
<:subtitle>{gettext("This is a user record from your database.")}</:subtitle> <:subtitle>{gettext("This is a user record from your database.")}</:subtitle>
<:actions> <:actions>
<.button navigate={~p"/users"} aria-label={gettext("Back to users list")}>
<.icon name="hero-arrow-left" />
<span class="sr-only">{gettext("Back to users list")}</span>
</.button>
<%= if can?(@current_user, :update, @user) do %> <%= if can?(@current_user, :update, @user) do %>
<.button <.button
variant="primary" variant="primary"
@ -80,6 +87,38 @@ defmodule MvWeb.UserLive.Show do
<% end %> <% end %>
</:item> </:item>
</.list> </.list>
<%!-- Danger zone: canonical pattern (same as member show) --%>
<%= if can?(@current_user, :destroy, @user) and not Mv.Helpers.SystemActor.system_user?(@user) do %>
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
{gettext("Danger zone")}
</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
<p class="text-base-content/70 mb-4">
{gettext(
"Deleting this user cannot be undone. The user account and any linked member association will be affected."
)}
</p>
<.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")}
</.button>
</div>
</section>
<% end %>
</Layouts.app> </Layouts.app>
""" """
end end
@ -103,4 +142,38 @@ defmodule MvWeb.UserLive.Show do
|> assign(:user, user)} |> assign(:user, user)}
end end
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 end

View file

@ -11,18 +11,13 @@ msgstr ""
"Language: de\n" "Language: de\n"
#: lib/mv_web/components/core_components.ex #: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Actions" msgid "Actions"
msgstr "Aktionen" 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_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.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 #, elixir-autogen, elixir-format
msgid "Are you sure?" msgid "Are you sure?"
msgstr "Bist du sicher?" msgstr "Bist du sicher?"
@ -40,25 +35,14 @@ msgstr "Verbindung wird wiederhergestellt"
msgid "City" msgid "City"
msgstr "Stadt" msgstr "Stadt"
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/group_live/show.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/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 #, elixir-autogen, elixir-format
msgid "Delete" msgid "Delete"
msgstr "Löschen" 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/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/form.ex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Edit" msgid "Edit"
msgstr "Bearbeiten" msgstr "Bearbeiten"
@ -110,8 +94,6 @@ msgid "New Member"
msgstr "Neues Mitglied" msgstr "Neues Mitglied"
#: lib/mv_web/live/member_live/index.html.heex #: 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 #, elixir-autogen, elixir-format
msgid "Show" msgid "Show"
msgstr "Anzeigen" msgstr "Anzeigen"
@ -277,7 +259,6 @@ msgstr "Dein Passwort wurde erfolgreich zurückgesetzt"
#: lib/mv_web/live/member_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/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/membership_fee_type_live/form.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 #: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Cancel" msgid "Cancel"
@ -491,16 +472,6 @@ msgstr "Passwort"
msgid "Password requirements" msgid "Password requirements"
msgstr "Passwort-Anforderungen" 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 #: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Set Password" msgid "Set Password"
@ -616,7 +587,6 @@ 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." 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." 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/form.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -635,11 +605,6 @@ 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." 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." 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 #: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Enter the text above to confirm" msgid "Enter the text above to confirm"
@ -673,7 +638,6 @@ msgstr "Vereinsdaten"
msgid "Manage global settings for the association." msgid "Manage global settings for the association."
msgstr "Passe übergreifende Einstellungen für den Verein an." 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 #: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Save Settings" msgid "Save Settings"
@ -790,19 +754,21 @@ msgstr "Alle"
msgid "Address" msgid "Address"
msgstr "Adresse" 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/form.ex
#: lib/mv_web/live/group_live/show.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/form.ex
#: lib/mv_web/live/member_live/show.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 #, elixir-autogen, elixir-format
msgid "Back" msgid "Back"
msgstr "Zurück" 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/form.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -820,7 +786,6 @@ msgid "Payment Data"
msgstr "Beitragsdaten" msgstr "Beitragsdaten"
#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Payments" msgid "Payments"
msgstr "Zahlungen" msgstr "Zahlungen"
@ -834,6 +799,8 @@ msgstr "Persönliche Daten"
#: lib/mv_web/live/group_live/form.ex #: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/member_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/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 #, elixir-autogen, elixir-format
msgid "Save" msgid "Save"
msgstr "Speichern" msgstr "Speichern"
@ -851,11 +818,6 @@ msgstr "Mitglied erstellen"
msgid "Amount" msgid "Amount"
msgstr "Betrag" 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_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -981,11 +943,6 @@ msgstr "Unbezahlt"
msgid "Yearly" msgid "Yearly"
msgstr "jährlich" 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 #: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Last name" msgid "Last name"
@ -1575,6 +1532,7 @@ msgid "Show/Hide Columns"
msgstr "Spalten ein-/ausblenden" msgstr "Spalten ein-/ausblenden"
#: lib/mv_web/live/custom_field_live/form_component.ex #: 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 #, elixir-autogen, elixir-format, fuzzy
msgid "Back to settings" msgid "Back to settings"
msgstr "Zurück zu den Einstellungen" msgstr "Zurück zu den Einstellungen"
@ -1620,22 +1578,11 @@ msgstr "Datenfeld speichern"
msgid "Back to roles list" msgid "Back to roles list"
msgstr "Zurück zur Rollen-Liste" 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 #: lib/mv_web/live/role_live/index.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Custom" msgid "Custom"
msgstr "Benutzerdefiniert" 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 #: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Failed to delete role: %{error}" msgid "Failed to delete role: %{error}"
@ -1652,7 +1599,6 @@ msgstr "Rollen auflisten"
msgid "Manage user roles and their permission sets." msgid "Manage user roles and their permission sets."
msgstr "Verwalte Benutzer*innen-Rollen und ihre Berechtigungssätze." 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 #: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first." msgid "Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first."
@ -1663,11 +1609,6 @@ msgstr "Rolle kann nicht gelöscht werden. %{count} Benutzer*in(nen) sind dieser
msgid "Close sidebar" msgid "Close sidebar"
msgstr "Sidebar schließen" 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 #: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Main navigation" msgid "Main navigation"
@ -1711,7 +1652,6 @@ msgstr "Profil"
msgid "Role" msgid "Role"
msgstr "Rolle" msgstr "Rolle"
#: lib/mv_web/live/role_live/index.ex
#: lib/mv_web/live/role_live/show.ex #: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Role deleted successfully." msgid "Role deleted successfully."
@ -1723,7 +1663,6 @@ msgid "Role details and permissions."
msgstr "Rollen-Details und Berechtigungen." msgstr "Rollen-Details und Berechtigungen."
#: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/role_live/form.ex
#: lib/mv_web/live/role_live/index.ex
#: lib/mv_web/live/role_live/show.ex #: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Role not found." msgid "Role not found."
@ -1734,11 +1673,6 @@ msgstr "Rolle nicht gefunden."
msgid "Role saved successfully." msgid "Role saved successfully."
msgstr "Rolle erfolgreich gespeichert." 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 #: lib/mv_web/live/role_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Select permission set" msgid "Select permission set"
@ -1760,12 +1694,6 @@ msgstr "System"
msgid "System Role" msgid "System Role"
msgstr "System-Rolle" 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 #: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "System roles cannot be deleted." msgid "System roles cannot be deleted."
@ -1842,12 +1770,14 @@ msgstr "Mitgliedsbeitragsart nicht gefunden"
msgid "User %{action} successfully" msgid "User %{action} successfully"
msgstr "Benutzer*in wurde erfolgreich %{action}" msgstr "Benutzer*in wurde erfolgreich %{action}"
#: lib/mv_web/live/user_live/index.ex #: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "User deleted successfully" msgid "User deleted successfully"
msgstr "Benutzer*in erfolgreich gelöscht" msgstr "Benutzer*in erfolgreich gelöscht"
#: lib/mv_web/live/user_live/index.ex #: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "User not found" msgid "User not found"
msgstr "Benutzer*in nicht gefunden" msgstr "Benutzer*in nicht gefunden"
@ -1858,18 +1788,14 @@ msgstr "Benutzer*in nicht gefunden"
msgid "You do not have permission to access this membership fee type" msgid "You do not have permission to access this membership fee type"
msgstr "Du hast keine Berechtigung, auf diese Mitgliedsbeitragsart zuzugreifen" 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_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "You do not have permission to delete this membership fee type" msgid "You do not have permission to delete this membership fee type"
msgstr "Du hast keine Berechtigung, diese Mitgliedsbeitragsart zu löschen" msgstr "Du hast keine Berechtigung, diese Mitgliedsbeitragsart zu löschen"
#: lib/mv_web/live/user_live/index.ex #: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "You do not have permission to delete this user" msgid "You do not have permission to delete this user"
msgstr "Du hast keine Berechtigung, diese*n Benutzer*in zu löschen" msgstr "Du hast keine Berechtigung, diese*n Benutzer*in zu löschen"
@ -1890,22 +1816,20 @@ msgstr "aktualisiert"
msgid "Unknown error" msgid "Unknown error"
msgstr "Unbekannter Fehler" msgstr "Unbekannter Fehler"
#: lib/mv_web/live/member_live/index.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Member deleted successfully" msgid "Member deleted successfully"
msgstr "Mitglied wurde erfolgreich gelöscht" msgstr "Mitglied wurde erfolgreich gelöscht"
#: lib/mv_web/live/member_live/index.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Member not found" msgid "Member not found"
msgstr "Mitglied nicht gefunden" msgstr "Mitglied nicht gefunden"
#: lib/mv_web/live/member_live/index.ex #: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format #: lib/mv_web/live/member_live/show.ex
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 #, elixir-autogen, elixir-format
msgid "You do not have permission to delete this member" msgid "You do not have permission to delete this member"
msgstr "Du hast keine Berechtigung, dieses Mitglied zu löschen" msgstr "Du hast keine Berechtigung, dieses Mitglied zu löschen"
@ -1990,11 +1914,6 @@ msgstr "Mitgliedsfilter"
msgid "Payment Status" msgid "Payment Status"
msgstr "Bezahlstatus" 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 #: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid " (Field: %{field})" msgid " (Field: %{field})"
@ -2161,6 +2080,7 @@ msgstr "Gruppe erstellen"
msgid "Delete Group" msgid "Delete Group"
msgstr "Gruppe löschen" msgstr "Gruppe löschen"
#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Delete group" msgid "Delete group"
@ -2241,11 +2161,6 @@ msgstr[1] "Diese Gruppe hat %{count} Mitglieder. Alle Mitglied-Gruppen-Zuordnung
msgid "To confirm deletion, please enter the group name:" msgid "To confirm deletion, please enter the group name:"
msgstr "Um die Löschung zu bestätigen, gib bitte den Gruppennamen ein:" 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 #: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Total: %{count} member" msgid "Total: %{count} member"
@ -3120,3 +3035,278 @@ msgstr "Nur OIDC-Anmeldung (Passwort-Login ausblenden)"
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button." 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." 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"

View file

@ -12,18 +12,13 @@ msgid ""
msgstr "" msgstr ""
#: lib/mv_web/components/core_components.ex #: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Actions" msgid "Actions"
msgstr "" 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_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.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 #, elixir-autogen, elixir-format
msgid "Are you sure?" msgid "Are you sure?"
msgstr "" msgstr ""
@ -41,25 +36,14 @@ msgstr ""
msgid "City" msgid "City"
msgstr "" msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/group_live/show.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/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 #, elixir-autogen, elixir-format
msgid "Delete" msgid "Delete"
msgstr "" 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/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/form.ex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Edit" msgid "Edit"
msgstr "" msgstr ""
@ -111,8 +95,6 @@ msgid "New Member"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex #: 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 #, elixir-autogen, elixir-format
msgid "Show" msgid "Show"
msgstr "" msgstr ""
@ -278,7 +260,6 @@ msgstr ""
#: lib/mv_web/live/member_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/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/membership_fee_type_live/form.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 #: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Cancel" msgid "Cancel"
@ -492,16 +473,6 @@ msgstr ""
msgid "Password requirements" msgid "Password requirements"
msgstr "" 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 #: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Set Password" msgid "Set Password"
@ -617,7 +588,6 @@ msgstr ""
msgid "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account." msgid "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account."
msgstr "" msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -636,11 +606,6 @@ msgstr[1] ""
msgid "All custom field values will be permanently deleted when you delete this custom field." msgid "All custom field values will be permanently deleted when you delete this custom field."
msgstr "" 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 #: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Enter the text above to confirm" msgid "Enter the text above to confirm"
@ -674,7 +639,6 @@ msgstr ""
msgid "Manage global settings for the association." msgid "Manage global settings for the association."
msgstr "" msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Save Settings" msgid "Save Settings"
@ -791,19 +755,21 @@ msgstr ""
msgid "Address" msgid "Address"
msgstr "" 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/form.ex
#: lib/mv_web/live/group_live/show.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/form.ex
#: lib/mv_web/live/member_live/show.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 #, elixir-autogen, elixir-format
msgid "Back" msgid "Back"
msgstr "" 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/form.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -821,7 +787,6 @@ msgid "Payment Data"
msgstr "" msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Payments" msgid "Payments"
msgstr "" msgstr ""
@ -835,6 +800,8 @@ msgstr ""
#: lib/mv_web/live/group_live/form.ex #: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/member_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/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 #, elixir-autogen, elixir-format
msgid "Save" msgid "Save"
msgstr "" msgstr ""
@ -852,11 +819,6 @@ msgstr ""
msgid "Amount" msgid "Amount"
msgstr "" 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_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -982,11 +944,6 @@ msgstr ""
msgid "Yearly" msgid "Yearly"
msgstr "" 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 #: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Last name" msgid "Last name"
@ -1576,6 +1533,7 @@ msgid "Show/Hide Columns"
msgstr "" msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex #: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Back to settings" msgid "Back to settings"
msgstr "" msgstr ""
@ -1621,22 +1579,11 @@ msgstr ""
msgid "Back to roles list" msgid "Back to roles list"
msgstr "" 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 #: lib/mv_web/live/role_live/index.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Custom" msgid "Custom"
msgstr "" 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 #: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Failed to delete role: %{error}" msgid "Failed to delete role: %{error}"
@ -1653,7 +1600,6 @@ msgstr ""
msgid "Manage user roles and their permission sets." msgid "Manage user roles and their permission sets."
msgstr "" msgstr ""
#: lib/mv_web/live/role_live/index.ex
#: lib/mv_web/live/role_live/show.ex #: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first." msgid "Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first."
@ -1664,11 +1610,6 @@ msgstr ""
msgid "Close sidebar" msgid "Close sidebar"
msgstr "" msgstr ""
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format
msgid "Delete Role"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Main navigation" msgid "Main navigation"
@ -1712,7 +1653,6 @@ msgstr ""
msgid "Role" msgid "Role"
msgstr "" msgstr ""
#: lib/mv_web/live/role_live/index.ex
#: lib/mv_web/live/role_live/show.ex #: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Role deleted successfully." msgid "Role deleted successfully."
@ -1724,7 +1664,6 @@ msgid "Role details and permissions."
msgstr "" msgstr ""
#: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/role_live/form.ex
#: lib/mv_web/live/role_live/index.ex
#: lib/mv_web/live/role_live/show.ex #: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Role not found." msgid "Role not found."
@ -1735,11 +1674,6 @@ msgstr ""
msgid "Role saved successfully." msgid "Role saved successfully."
msgstr "" 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 #: lib/mv_web/live/role_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Select permission set" msgid "Select permission set"
@ -1761,12 +1695,6 @@ msgstr ""
msgid "System Role" msgid "System Role"
msgstr "" 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 #: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "System roles cannot be deleted." msgid "System roles cannot be deleted."
@ -1843,12 +1771,14 @@ msgstr ""
msgid "User %{action} successfully" msgid "User %{action} successfully"
msgstr "" msgstr ""
#: lib/mv_web/live/user_live/index.ex #: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "User deleted successfully" msgid "User deleted successfully"
msgstr "" msgstr ""
#: lib/mv_web/live/user_live/index.ex #: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "User not found" msgid "User not found"
msgstr "" msgstr ""
@ -1859,18 +1789,14 @@ msgstr ""
msgid "You do not have permission to access this membership fee type" msgid "You do not have permission to access this membership fee type"
msgstr "" 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_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "You do not have permission to delete this membership fee type" msgid "You do not have permission to delete this membership fee type"
msgstr "" msgstr ""
#: lib/mv_web/live/user_live/index.ex #: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "You do not have permission to delete this user" msgid "You do not have permission to delete this user"
msgstr "" msgstr ""
@ -1891,22 +1817,20 @@ msgstr ""
msgid "Unknown error" msgid "Unknown error"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Member deleted successfully" msgid "Member deleted successfully"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Member not found" msgid "Member not found"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.ex #: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format #: lib/mv_web/live/member_live/show.ex
msgid "You do not have permission to access this member"
msgstr ""
#: lib/mv_web/live/member_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "You do not have permission to delete this member" msgid "You do not have permission to delete this member"
msgstr "" msgstr ""
@ -1991,11 +1915,6 @@ msgstr ""
msgid "Payment Status" msgid "Payment Status"
msgstr "" 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 #: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid " (Field: %{field})" msgid " (Field: %{field})"
@ -2162,6 +2081,7 @@ msgstr ""
msgid "Delete Group" msgid "Delete Group"
msgstr "" msgstr ""
#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Delete group" msgid "Delete group"
@ -2242,11 +2162,6 @@ msgstr[1] ""
msgid "To confirm deletion, please enter the group name:" msgid "To confirm deletion, please enter the group name:"
msgstr "" msgstr ""
#: lib/mv_web/live/group_live/index.ex
#, elixir-autogen, elixir-format
msgid "View"
msgstr ""
#: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Total: %{count} member" msgid "Total: %{count} member"
@ -3115,3 +3030,192 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button." msgid "When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button."
msgstr "" 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 ""

View file

@ -12,18 +12,13 @@ msgstr ""
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: lib/mv_web/components/core_components.ex #: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Actions" msgid "Actions"
msgstr "" 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_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.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 #, elixir-autogen, elixir-format
msgid "Are you sure?" msgid "Are you sure?"
msgstr "" msgstr ""
@ -41,25 +36,14 @@ msgstr ""
msgid "City" msgid "City"
msgstr "" msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/group_live/show.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/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 #, elixir-autogen, elixir-format
msgid "Delete" msgid "Delete"
msgstr "" 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/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/form.ex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Edit" msgid "Edit"
msgstr "" msgstr ""
@ -111,8 +95,6 @@ msgid "New Member"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex #: 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 #, elixir-autogen, elixir-format
msgid "Show" msgid "Show"
msgstr "" msgstr ""
@ -278,7 +260,6 @@ msgstr ""
#: lib/mv_web/live/member_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/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/membership_fee_type_live/form.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 #: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Cancel" msgid "Cancel"
@ -492,16 +473,6 @@ msgstr ""
msgid "Password requirements" msgid "Password requirements"
msgstr "" 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 #: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Set Password" msgid "Set Password"
@ -617,7 +588,6 @@ msgstr ""
msgid "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account." msgid "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account."
msgstr "" msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
@ -636,11 +606,6 @@ msgstr[1] ""
msgid "All custom field values will be permanently deleted when you delete this custom field." msgid "All custom field values will be permanently deleted when you delete this custom field."
msgstr "" 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 #: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Enter the text above to confirm" msgid "Enter the text above to confirm"
@ -674,7 +639,6 @@ msgstr ""
msgid "Manage global settings for the association." msgid "Manage global settings for the association."
msgstr "" msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Save Settings" msgid "Save Settings"
@ -791,19 +755,21 @@ msgstr ""
msgid "Address" msgid "Address"
msgstr "" 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/form.ex
#: lib/mv_web/live/group_live/show.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/form.ex
#: lib/mv_web/live/member_live/show.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 #, elixir-autogen, elixir-format
msgid "Back" msgid "Back"
msgstr "" 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/form.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -821,7 +787,6 @@ msgid "Payment Data"
msgstr "" msgstr ""
#: lib/mv_web/live/components/member_filter_component.ex #: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Payments" msgid "Payments"
msgstr "" msgstr ""
@ -835,6 +800,8 @@ msgstr ""
#: lib/mv_web/live/group_live/form.ex #: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/member_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/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 #, elixir-autogen, elixir-format, fuzzy
msgid "Save" msgid "Save"
msgstr "" msgstr ""
@ -852,11 +819,6 @@ msgstr ""
msgid "Amount" msgid "Amount"
msgstr "" 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_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -982,11 +944,6 @@ msgstr ""
msgid "Yearly" msgid "Yearly"
msgstr "" 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 #: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Last name" msgid "Last name"
@ -1576,6 +1533,7 @@ msgid "Show/Hide Columns"
msgstr "" msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex #: 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 #, elixir-autogen, elixir-format, fuzzy
msgid "Back to settings" msgid "Back to settings"
msgstr "" msgstr ""
@ -1621,22 +1579,11 @@ msgstr ""
msgid "Back to roles list" msgid "Back to roles list"
msgstr "" 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 #: lib/mv_web/live/role_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Custom" msgid "Custom"
msgstr "" 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 #: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Failed to delete role: %{error}" msgid "Failed to delete role: %{error}"
@ -1653,7 +1600,6 @@ msgstr ""
msgid "Manage user roles and their permission sets." msgid "Manage user roles and their permission sets."
msgstr "" msgstr ""
#: lib/mv_web/live/role_live/index.ex
#: lib/mv_web/live/role_live/show.ex #: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first." msgid "Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first."
@ -1664,11 +1610,6 @@ msgstr ""
msgid "Close sidebar" msgid "Close sidebar"
msgstr "" 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 #: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Main navigation" msgid "Main navigation"
@ -1712,7 +1653,6 @@ msgstr ""
msgid "Role" msgid "Role"
msgstr "" msgstr ""
#: lib/mv_web/live/role_live/index.ex
#: lib/mv_web/live/role_live/show.ex #: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Role deleted successfully." msgid "Role deleted successfully."
@ -1724,7 +1664,6 @@ msgid "Role details and permissions."
msgstr "" msgstr ""
#: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/role_live/form.ex
#: lib/mv_web/live/role_live/index.ex
#: lib/mv_web/live/role_live/show.ex #: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Role not found." msgid "Role not found."
@ -1735,11 +1674,6 @@ msgstr ""
msgid "Role saved successfully." msgid "Role saved successfully."
msgstr "" 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 #: lib/mv_web/live/role_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Select permission set" msgid "Select permission set"
@ -1761,12 +1695,6 @@ msgstr ""
msgid "System Role" msgid "System Role"
msgstr "" 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 #: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "System roles cannot be deleted." msgid "System roles cannot be deleted."
@ -1843,12 +1771,14 @@ msgstr ""
msgid "User %{action} successfully" msgid "User %{action} successfully"
msgstr "" msgstr ""
#: lib/mv_web/live/user_live/index.ex #: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "User deleted successfully" msgid "User deleted successfully"
msgstr "" msgstr ""
#: lib/mv_web/live/user_live/index.ex #: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "User not found" msgid "User not found"
msgstr "" msgstr ""
@ -1859,18 +1789,14 @@ msgstr ""
msgid "You do not have permission to access this membership fee type" msgid "You do not have permission to access this membership fee type"
msgstr "" 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_settings_live.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "You do not have permission to delete this membership fee type" msgid "You do not have permission to delete this membership fee type"
msgstr "" msgstr ""
#: lib/mv_web/live/user_live/index.ex #: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "You do not have permission to delete this user" msgid "You do not have permission to delete this user"
msgstr "" msgstr ""
@ -1891,22 +1817,20 @@ msgstr ""
msgid "Unknown error" msgid "Unknown error"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Member deleted successfully" msgid "Member deleted successfully"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Member not found" msgid "Member not found"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.ex #: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format #: lib/mv_web/live/member_live/show.ex
msgid "You do not have permission to access this member"
msgstr ""
#: lib/mv_web/live/member_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "You do not have permission to delete this member" msgid "You do not have permission to delete this member"
msgstr "" msgstr ""
@ -1991,11 +1915,6 @@ msgstr ""
msgid "Payment Status" msgid "Payment Status"
msgstr "" 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 #: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid " (Field: %{field})" msgid " (Field: %{field})"
@ -2162,6 +2081,7 @@ msgstr ""
msgid "Delete Group" msgid "Delete Group"
msgstr "" msgstr ""
#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Delete group" msgid "Delete group"
@ -2242,11 +2162,6 @@ msgstr[1] ""
msgid "To confirm deletion, please enter the group name:" msgid "To confirm deletion, please enter the group name:"
msgstr "" msgstr ""
#: lib/mv_web/live/group_live/index.ex
#, elixir-autogen, elixir-format
msgid "View"
msgstr ""
#: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Total: %{count} member" msgid "Total: %{count} member"
@ -3115,3 +3030,278 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button." msgid "When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button."
msgstr "" 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 ""

View file

@ -0,0 +1,154 @@
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

View file

@ -46,6 +46,17 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
%{conn: conn, user: user_with_role} %{conn: conn, user: user_with_role}
end 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 describe "delete button and modal" do
test "opens modal with correct member count when delete is clicked", %{conn: conn} do test "opens modal with correct member count when delete is clicked", %{conn: conn} do
{:ok, member} = create_member() {:ok, member} = create_member()
@ -55,11 +66,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
create_custom_field_value(member, custom_field, "test") create_custom_field_value(member, custom_field, "test")
{:ok, view, _html} = live(conn, ~p"/admin/datafields") {: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 # Modal should be visible
assert has_element?(view, "#delete-custom-field-modal") assert has_element?(view, "#delete-custom-field-modal")
@ -81,23 +88,17 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
create_custom_field_value(member2, custom_field, "test2") create_custom_field_value(member2, custom_field, "test2")
{:ok, view, _html} = live(conn, ~p"/admin/datafields") {: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 # Should show plural form
assert render(view) =~ "2 members have values assigned for this custom field" assert render(view) =~ "2 members have values assigned for this custom field"
end end
test "shows 0 members for custom field without values", %{conn: conn} do 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") {: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 # Should show 0 members
assert render(view) =~ "0 members have values assigned for this custom field" assert render(view) =~ "0 members have values assigned for this custom field"
@ -109,10 +110,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest 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") {: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 # Type in slug input - use element to find the form with phx-target
view view
@ -124,13 +122,10 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
end end
test "delete button is disabled when slug doesn't match", %{conn: conn} do 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") {: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 # Type wrong slug - use element to find the form with phx-target
view view
@ -149,11 +144,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
{:ok, custom_field_value} = create_custom_field_value(member, custom_field, "test") {:ok, custom_field_value} = create_custom_field_value(member, custom_field, "test")
{:ok, view, _html} = live(conn, ~p"/admin/datafields") {: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 # Enter correct slug - use element to find the form with phx-target
view view
@ -162,7 +153,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
# Click confirm # Click confirm
view view
|> element("#delete-custom-field-modal button", "Delete Custom Field and All Values") |> element("#delete-custom-field-modal button", "Delete Datafields and All Values")
|> render_click() |> render_click()
# Should show success message # Should show success message
@ -186,10 +177,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest 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") {: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 # Enter wrong slug - use element to find the form with phx-target
view view
@ -210,10 +198,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest 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") {: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 # Modal should be visible
assert has_element?(view, "#delete-custom-field-modal") assert has_element?(view, "#delete-custom-field-modal")

View file

@ -24,6 +24,7 @@ defmodule MvWeb.MemberLiveAuthorizationTest do
{:ok, view, _html} = live(conn, "/members") {: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-edit]")
refute has_element?(view, "#row-#{member.id} [data-testid=member-delete]") refute has_element?(view, "#row-#{member.id} [data-testid=member-delete]")
end end
@ -31,17 +32,18 @@ defmodule MvWeb.MemberLiveAuthorizationTest do
describe "Member Index - Kassenwart (normal_user)" do describe "Member Index - Kassenwart (normal_user)" do
@tag role: :normal_user @tag role: :normal_user
test "sees New Member and Edit buttons", %{conn: conn} do test "sees New Member and Show link in row", %{conn: conn} do
member = Fixtures.member_fixture() member = Fixtures.member_fixture()
{:ok, view, _html} = live(conn, "/members") {:ok, view, _html} = live(conn, "/members")
assert has_element?(view, "[data-testid=member-new]") assert has_element?(view, "[data-testid=member-new]")
assert has_element?(view, "#row-#{member.id} [data-testid=member-edit]") # 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]")
end end
@tag role: :normal_user @tag role: :normal_user
test "does not see Delete button", %{conn: conn} do test "does not see Delete button in table", %{conn: conn} do
member = Fixtures.member_fixture() member = Fixtures.member_fixture()
{:ok, view, _html} = live(conn, "/members") {:ok, view, _html} = live(conn, "/members")
@ -52,14 +54,14 @@ defmodule MvWeb.MemberLiveAuthorizationTest do
describe "Member Index - Admin" do describe "Member Index - Admin" do
@tag role: :admin @tag role: :admin
test "sees New Member, Edit and Delete buttons", %{conn: conn} do test "sees New Member and Show link in row", %{conn: conn} do
member = Fixtures.member_fixture() member = Fixtures.member_fixture()
{:ok, view, _html} = live(conn, "/members") {:ok, view, _html} = live(conn, "/members")
assert has_element?(view, "[data-testid=member-new]") assert has_element?(view, "[data-testid=member-new]")
assert has_element?(view, "#row-#{member.id} [data-testid=member-edit]") # 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-delete]") assert has_element?(view, "#row-#{member.id} [data-testid=member-show-link]")
end end
end end

View file

@ -138,7 +138,7 @@ defmodule MvWeb.RoleLiveTest do
assert html =~ "System Role" || html =~ "system" assert html =~ "System Role" || html =~ "system"
end end
test "delete button disabled for system roles", %{conn: conn, actor: actor} do test "delete button not shown for system roles", %{conn: conn, actor: actor} do
system_role = system_role =
Role Role
|> Ash.Changeset.for_create(:create_role, %{ |> Ash.Changeset.for_create(:create_role, %{
@ -148,28 +148,19 @@ defmodule MvWeb.RoleLiveTest do
|> Ash.Changeset.force_change_attribute(:is_system_role, true) |> Ash.Changeset.force_change_attribute(:is_system_role, true)
|> Ash.create!(actor: actor) |> Ash.create!(actor: actor)
{:ok, view, _html} = live(conn, "/admin/roles") {:ok, view, _html} = live(conn, "/admin/roles/#{system_role.id}")
assert has_element?( # Danger zone (and delete button) is not rendered for system roles
view, refute has_element?(view, "[data-testid=role-delete]")
"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 end
test "delete button enabled for non-system roles", %{conn: conn} do test "delete button enabled for non-system roles", %{conn: conn} do
role = create_role() role = create_role()
{:ok, view, html} = live(conn, "/admin/roles") {:ok, view, _html} = live(conn, "/admin/roles/#{role.id}")
# Delete is a link with phx-click containing delete event # Delete is on show page (Danger zone)
# Check if delete link exists in HTML (phx-click contains delete and role id) assert has_element?(view, "[data-testid=role-delete]")
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 end
test "new role button navigates to form", %{conn: conn} do test "new role button navigates to form", %{conn: conn} do
@ -393,21 +384,21 @@ defmodule MvWeb.RoleLiveTest do
test "deletes non-system role", %{conn: conn, actor: actor} do test "deletes non-system role", %{conn: conn, actor: actor} do
role = create_role() role = create_role()
{:ok, view, html} = live(conn, "/admin/roles") {:ok, view, _html} = live(conn, "/admin/roles/#{role.id}")
# Delete is a link - JS.push creates phx-click with value containing id # Delete from Danger zone on show page
# Verify the role id is in the HTML (in phx-click value) view
assert html =~ role.id |> element("[data-testid=role-delete]")
|> render_click()
# Send delete event directly to avoid selector issues with multiple delete buttons assert_redirect(view, "/admin/roles")
render_click(view, "delete", %{"id" => role.id})
# Verify deletion by checking database # Verify deletion by checking database
assert {:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{}]}} = assert {:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{}]}} =
Authorization.get_role(role.id, actor: actor) Authorization.get_role(role.id, actor: actor)
end end
test "fails to delete system role with error message", %{conn: conn, actor: actor} do test "system role has no delete button and cannot be deleted", %{conn: conn, actor: actor} do
system_role = system_role =
Role Role
|> Ash.Changeset.for_create(:create_role, %{ |> Ash.Changeset.for_create(:create_role, %{
@ -417,19 +408,12 @@ defmodule MvWeb.RoleLiveTest do
|> Ash.Changeset.force_change_attribute(:is_system_role, true) |> Ash.Changeset.force_change_attribute(:is_system_role, true)
|> Ash.create!(actor: actor) |> Ash.create!(actor: actor)
{:ok, view, html} = live(conn, "/admin/roles") {:ok, view, _html} = live(conn, "/admin/roles/#{system_role.id}")
# System role delete button should be disabled # Danger zone is not rendered for system roles (no delete button)
assert html =~ "disabled" || html =~ "cursor-not-allowed" || refute has_element?(view, "[data-testid=role-delete]")
html =~ "System roles cannot be deleted"
# Try to delete via event (backend check) # Role still exists
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) {:ok, _role} = Authorization.get_role(system_role.id, actor: actor)
end end
end end

View file

@ -10,14 +10,16 @@ defmodule MvWeb.UserLiveAuthorizationTest do
describe "User Index - Admin" do describe "User Index - Admin" do
@tag role: :admin @tag role: :admin
test "sees New User, Edit and Delete buttons", %{conn: conn} do test "sees New User button; Edit and Delete are on show page", %{conn: conn} do
user = Fixtures.user_with_role_fixture("admin") user = Fixtures.user_with_role_fixture("admin")
{:ok, view, _html} = live(conn, "/users") {:ok, index_view, _html} = live(conn, "/users")
assert has_element?(index_view, "[data-testid=user-new]")
assert has_element?(view, "[data-testid=user-new]") # Edit and Delete are on user show page (Danger zone), not on index
assert has_element?(view, "#row-#{user.id} [data-testid=user-edit]") {:ok, show_view, _html} = live(conn, "/users/#{user.id}")
assert has_element?(view, "#row-#{user.id} [data-testid=user-delete]") assert has_element?(show_view, "[data-testid=user-edit]")
assert has_element?(show_view, "[data-testid=user-delete]")
end end
end end

View file

@ -3,11 +3,85 @@ defmodule MvWeb.MemberLive.FormErrorHandlingTest do
Tests for error handling in the member form, specifically flash message display. Tests for error handling in the member form, specifically flash message display.
""" """
use MvWeb.ConnCase, async: false use MvWeb.ConnCase, async: false
use Gettext, backend: MvWeb.Gettext
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
require Ash.Query 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 describe "error handling - flash messages" do
setup do setup do
{:ok, settings} = Mv.Membership.get_settings() {:ok, settings} = Mv.Membership.get_settings()

View file

@ -107,9 +107,9 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
{:ok, view, _html} = live(conn, "/members") {:ok, view, _html} = live(conn, "/members")
# Toggle to current cycle (use the button in the header, not the one in the column) # Toggle to current cycle (use the button in the header)
view view
|> element("button[phx-click='toggle_cycle_view'].btn.gap-2") |> element("[data-testid=toggle-cycle-view]")
|> render_click() |> render_click()
html = render(view) html = render(view)

View file

@ -46,6 +46,35 @@ defmodule MvWeb.MemberLive.IndexTest do
|> Ash.create!(actor: actor) |> Ash.create!(actor: actor)
end 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 describe "translations" do
@describetag :ui @describetag :ui
@ -267,36 +296,80 @@ defmodule MvWeb.MemberLive.IndexTest do
assert is_list(state.socket.assigns.members) assert is_list(state.socket.assigns.members)
end end
test "can delete a member without error", %{conn: conn} do @tag :ui
test "member index does not render Edit or Delete actions", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create a test member first {:ok, _member} =
{:ok, member} =
Mv.Membership.create_member( Mv.Membership.create_member(
%{ %{first_name: "Test", last_name: "User", email: "test@example.com"},
first_name: "Test",
last_name: "User",
email: "test@example.com"
},
actor: system_actor actor: system_actor
) )
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, index_view, _html} = live(conn, "/members") {:ok, view, html} = live(conn, "/members")
# Verify the member is displayed refute has_element?(view, "[data-testid='member-edit']")
assert has_element?(index_view, "#members", "Test User") refute html =~ ~s(data-testid="member-delete")
end
# Click the delete link for this member @tag :ui
index_view test "row click navigates to member show", %{conn: conn} do
|> element("a", "Delete") system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, member} =
Mv.Membership.create_member(
%{first_name: "Row", last_name: "Click", email: "rowclick@example.com"},
actor: system_actor
)
conn = conn_with_oidc_user(conn)
{:ok, 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)")
|> render_click() |> render_click()
# Verify the member is no longer displayed assert_redirect(view, ~p"/members/#{member}")
refute has_element?(index_view, "#members", "Test User") end
# Verify the member was actually deleted from the database describe "table row outline (hover and selected)" do
assert not (Mv.Membership.Member |> Ash.Query.filter(id == ^member.id) |> Ash.exists?()) @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
end end
describe "copy_emails feature" do describe "copy_emails feature" do

View file

@ -134,6 +134,37 @@ defmodule MvWeb.MemberLive.ShowTest do
end end
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 describe "custom field value formatting" do
test "formats string custom field values", %{conn: conn, member: member, actor: actor} do test "formats string custom field values", %{conn: conn, member: member, actor: actor} do
{:ok, custom_field} = {:ok, custom_field} =

View file

@ -16,11 +16,10 @@ defmodule MvWeb.UserLive.IndexTest do
assert html =~ "alice@example.com" assert html =~ "alice@example.com"
assert html =~ "bob@example.com" assert html =~ "bob@example.com"
# UI elements: New User button, action links # UI elements: New User button; row click navigates to show (no Edit/Delete on index)
assert html =~ "New User" assert html =~ "New User"
assert html =~ "Edit" # Row or navigation contains user id (e.g. row id or phx-click navigate)
assert html =~ "Delete" assert html =~ "row-#{user1.id}" or html =~ to_string(user1.id)
assert html =~ ~r/href="[^"]*\/users\/#{user1.id}\/edit"/
end end
@tag :ui @tag :ui
@ -116,177 +115,29 @@ defmodule MvWeb.UserLive.IndexTest do
end end
end end
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, _view, html} = live(conn, "/users")
# Check select all checkbox exists
assert html =~ ~s(name="select_all")
assert html =~ ~s(phx-click="select_all")
# 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
@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")
# 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 describe "delete functionality" do
test "can delete a user", %{conn: conn} do # Delete is only on user show page (Danger zone), not on index (per CODE_GUIDELINES: at most one UI smoke test for delete)
_user = create_test_user(%{email: "delete-me@example.com"}) test "can delete a user from show page", %{conn: conn} do
user = create_test_user(%{email: "delete-me@example.com"})
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/users") {:ok, index_view, _html} = live(conn, "/users")
assert render(index_view) =~ "delete-me@example.com"
# Confirm user is displayed # Navigate to user show and trigger delete from Danger zone
assert render(view) =~ "delete-me@example.com" {:ok, show_view, _html} = live(conn, "/users/#{user.id}")
# Click the delete button (phx-click="delete" event) show_view
view |> element("tbody tr:first-child a[data-confirm]") |> render_click() |> element("[data-testid=user-delete]")
|> render_click()
# Verify user was actually deleted (should not appear in HTML anymore) # Should redirect to index
html = render(view) assert_redirect(show_view, "/users")
# Reload index with same session; user should be gone
{:ok, _view_after, html} = live(conn, "/users")
refute html =~ "delete-me@example.com" refute html =~ "delete-me@example.com"
# Table header should still be there
assert html =~ "Email" assert html =~ "Email"
end 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 end
describe "navigation" do describe "navigation" do
@ -296,36 +147,14 @@ defmodule MvWeb.UserLive.IndexTest do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/users") {:ok, _view, html} = live(conn, "/users")
# Check that user row contains link to show page # Row click navigates to show page (edit is on show page)
assert html =~ ~s(/users/#{user.id}) 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 # Check new user button points to correct new page
assert html =~ ~s(/users/new) assert html =~ ~s(/users/new)
end end
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 describe "edge cases" do
test "handles empty user list gracefully", %{conn: conn} do test "handles empty user list gracefully", %{conn: conn} do
# Don't create any users besides the authenticated one # Don't create any users besides the authenticated one