Compare commits

..

1 commit

Author SHA1 Message Date
024149ccd1 docs: add link to user docu to readme
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-26 13:42:27 +01:00
47 changed files with 1215 additions and 2537 deletions

View file

@ -2775,14 +2775,6 @@ Building accessible applications ensures that all users, including those with di
<div phx-click="action">Click me</div> <div phx-click="action">Click me</div>
``` ```
**Tables (Core Component `<.table>` with `row_click`):**
- When `row_click` is set, the first column that does not use `col_click` gets `tabindex="0"` and `role="button"` so each row is reachable via Tab. The `TableRowKeydown` hook triggers the row action on Enter and Space (WCAG 2.1.1). Use `row_id` and `row_tooltip` for all clickable tables (e.g. Groups, Users, Roles, Members, Custom Fields, Member Fields) so the table is fully keyboard accessible.
**Empty table cells (missing values):**
- Do not use dashes ("-", "—", "") or "n/a" as placeholders. Use CoreComponents `<.empty_cell sr_text="…">` for a cell with no value, or `<.maybe_value value={…} empty_sr_text="…">` when content is conditional. The cell is visually empty; screen readers get the `sr_text` (e.g. "No cycle", "No group assignment", "Not specified"). See Design Guidelines §8.6.
**Tab Order:** **Tab Order:**
- Ensure logical tab order matches visual order - Ensure logical tab order matches visual order
@ -2792,11 +2784,7 @@ Building accessible applications ensures that all users, including those with di
### 8.4 Color and Contrast ### 8.4 Color and Contrast
**Ensure Sufficient Contrast (WCAG 2.2 AA: 4.5:1 for normal text):** **Ensure Sufficient Contrast:**
- Use the Core Component `<.badge>` for all badges; theme and `app.css` overrides ensure badge text meets 4.5:1 in light and dark theme (solid, soft, and outline styles). Cycle status "suspended" uses variant `:warning` (yellow) to match the edit cycle-status button.
- For other UI, prefer theme tokens (`text-*-content` on `bg-*`) or the `.text-success-aa` / `.text-error-aa` utility classes where theme contrast is insufficient.
- Member filter join buttons (All / Paid / Unpaid, etc.) use `.member-filter-dropdown`; `app.css` overrides ensure WCAG 4.5:1 for inactive and active states.
```elixir ```elixir
# Tailwind classes with sufficient contrast (4.5:1 minimum) # Tailwind classes with sufficient contrast (4.5:1 minimum)
@ -3015,56 +3003,24 @@ end
- [ ] Skip links are available - [ ] Skip links are available
- [ ] Tables have proper structure (th, scope, caption) - [ ] Tables have proper structure (th, scope, caption)
- [ ] ARIA labels used for icon-only buttons - [ ] ARIA labels used for icon-only buttons
- [ ] Modals/dialogs: focus moves into modal, aria-labelledby, keyboard dismiss (Escape)
- [ ] ARIA state attributes use string values `"true"` / `"false"` (not boolean), e.g. `aria-selected`, `aria-pressed`, `aria-expanded`.
- [ ] Tabs: when using `role="tablist"` / `role="tab"`, use roving tabindex (only active tab `tabindex="0"`) and ArrowLeft/ArrowRight to switch tabs.
### 8.11 Modals and Dialogs ### 8.11 DaisyUI Accessibility
Use a consistent, keyboard-accessible pattern for all confirmation and form modals (e.g. delete role, delete group, delete data field, edit cycle). Do not rely on `data-confirm` (browser `confirm()`) for destructive actions; use a LiveView-controlled `<dialog>` so focus and semantics are correct (WCAG 2.4.3, 2.1.2). DaisyUI components are designed with accessibility in mind, but ensure:
**Structure and semantics:**
- Use `<dialog>` with DaisyUI classes `modal modal-open` when the modal is visible.
- Add `role="dialog"` and `aria-labelledby` pointing to the modal titles `id` so screen readers announce the dialog and its purpose.
- Give the title (e.g. `<h3>`) a unique `id` (e.g. `id="delete-role-modal-title"`).
**Focus management (WCAG 2.4.3):**
- When the modal opens, move focus into the dialog. Use `phx-mounted={JS.focus()}` on the first focusable element:
- If the modal has an input (e.g. confirmation text), put `phx-mounted={JS.focus()}` on that input (e.g. delete data field, delete group).
- If the modal has only buttons (e.g. confirm/cancel), put `phx-mounted={JS.focus()}` on the Cancel (or first) button so the user can Tab to the primary action and confirm with the keyboard.
- This ensures that after choosing "Delete role" (or similar) with the keyboard, focus is inside the modal and the user can confirm or cancel without using the mouse.
**Layout and consistency:**
- Use `modal-box` for the content container and `modal-action` for the button row (Cancel + primary action).
- Place Cancel (or neutral) first, primary/danger action second.
- For destructive actions that require typing a confirmation string, use the same pattern as the delete data field modal: label, value to type, single input, then modal-action buttons.
**Closing:**
- Cancel button closes the modal (e.g. `phx-click="cancel_delete_modal"`).
- **MUST** support Escape to close (WCAG / WAI-ARIA dialog pattern): add `phx-keydown="dialog_keydown"` on the `<dialog>` and handle `dialog_keydown` with `key: "Escape"` to close (same effect as Cancel).
- **MUST** return focus to the trigger element when the modal closes (WCAG 2.4.3): give the trigger button a stable `id`, use the `FocusRestore` hook on a parent element, and on close (Cancel or Escape) call `push_event(socket, "focus_restore", %{id: "trigger-id"})` so keyboard users land where they started (e.g. "Delete member" button).
**Reference implementation:** Delete data field modal in `CustomFieldLive.IndexComponent` (input + `phx-mounted={JS.focus()}` on input; `aria-labelledby` on dialog). Delete role modal in `RoleLive.Show` (no input; `phx-mounted={JS.focus()}` on Cancel button).
### 8.12 DaisyUI Accessibility
DaisyUI components are designed with accessibility in mind. For modals and dialogs, follow §8.11 (Modals and Dialogs). Example structure:
```heex ```heex
<!-- Modal: use dialog + aria-labelledby + focus on first focusable (see §8.11) --> <!-- Modal accessibility -->
<dialog id="my-modal" class="modal modal-open" role="dialog" aria-labelledby="my-modal-title"> <dialog id="my-modal" class="modal" aria-labelledby="modal-title">
<div class="modal-box"> <div class="modal-box">
<h3 id="my-modal-title" class="text-lg font-bold"><%= gettext("Confirm Deletion") %></h3> <h2 id="modal-title"><%= gettext("Confirm Deletion") %></h2>
<p><%= gettext("Are you sure?") %></p> <p><%= gettext("Are you sure?") %></p>
<div class="modal-action"> <div class="modal-action">
<.button variant="neutral" phx-click="cancel" phx-mounted={JS.focus()}> <button class="btn" onclick="document.getElementById('my-modal').close()">
<%= gettext("Cancel") %> <%= gettext("Cancel") %>
</.button> </button>
<.button variant="danger" phx-click="confirm_delete"><%= gettext("Delete") %></.button> <button class="btn btn-error" phx-click="confirm-delete">
<%= gettext("Delete") %>
</button>
</div> </div>
</div> </div>
</dialog> </dialog>

View file

@ -293,12 +293,6 @@ Notes:
- On **mobile**, sticky headers are not used; the layout uses normal flow (header and table scroll with the page) to preserve vertical space. - 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. - 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.
### 8.6 Empty table cells (missing values)
- **MUST:** Missing values in tables are shown as **visually empty cells** (no dash, no "n/a").
- **MUST NOT:** Use dashes ("-", "—", "") or "n/a" as placeholders for empty cells.
- **MUST:** For accessibility, render a screen-reader-only label so the cell is not announced as "blank". Use the CoreComponents `<.empty_cell sr_text="…">` for a cell that has no value, or `<.maybe_value value={…} empty_sr_text="…">` when the cell content is conditional (value present vs. absent).
- **SHOULD:** Use context-specific `sr_text` where it helps (e.g. "No cycle", "No group assignment", "Not specified"). Default for "no value" is "Not specified".
--- ---
## 9) Flash / Toast messages (mandatory UX) ## 9) Flash / Toast messages (mandatory UX)
@ -337,17 +331,14 @@ No “silent success”.
### 10.2 Destructive actions: one standard confirmation pattern ### 10.2 Destructive actions: one standard confirmation pattern
- **MUST:** All destructive actions use the same confirm style and wording conventions. - **MUST:** All destructive actions use the same confirm style and wording conventions.
- **MUST:** Use the standard modal (see §10.3); do not use `data-confirm` / browser `confirm()` for destructive actions, so that focus and keyboard behaviour are consistent and accessible. - Choose one approach and standardize:
- `JS.confirm("…")` everywhere (simple, consistent)
- or a modal component everywhere (more flexible, more work)
**Recommended copy style:** **Recommended copy style:**
- Title/confirm text is clear and specific (what will be deleted, consequences). - Title/confirm text is clear and specific (what will be deleted, consequences).
- Buttons: `Cancel` (neutral) + `Delete` (danger). - Buttons: `Cancel` (neutral) + `Delete` (danger).
### 10.3 Dialogs and modals (mandatory)
- **MUST:** For every dialog (confirmations, form overlays, delete role/group/data field, edit cycle, etc.) use the **same modal pattern**: `<dialog>` with DaisyUI `modal modal-open`, `role="dialog"`, `aria-labelledby` on the title, and focus moved into the modal when it opens (first focusable element).
- **MUST NOT:** Use browser `confirm()` / `data-confirm` for destructive or important choices; use the LiveView-controlled modal so that keyboard users get focus inside the dialog and can confirm or cancel without the mouse.
- **Reference:** Full structure, focus management, and accessibility rules are in **`CODE_GUIDELINES.md` §8.11 (Modals and Dialogs)**. Follow that section for implementation (e.g. `phx-mounted={JS.focus()}` on the first focusable, consistent `modal-box` / `modal-action` layout).
--- ---
## 11) Detail pages (consistent structure) ## 11) Detail pages (consistent structure)

View file

@ -32,10 +32,10 @@ Most membership tools for clubs are either:
Our philosophy: **software should help people spend less time on administration and more time on their community.** Our philosophy: **software should help people spend less time on administration and more time on their community.**
## 📸 Screenshots ## User Documentation (German)
You can find our documentation for users here: https://wiki.local-it.org/s/mila-user-dokumentation
![Screenshot placeholder](docs/images/screenshot.png)
*This is how Mila might look in action.*
## 🔑 Features ## 🔑 Features

View file

@ -118,138 +118,6 @@
color: oklch(0.45 0.2 25); color: oklch(0.45 0.2 25);
} }
/* WCAG 2.2 AA: Badge contrast. DaisyUI .badge-outline uses transparent bg; we use
Core Component <.badge style="outline"> which adds .bg-base-100. This rule ensures
outline badges always have a visible background in both themes. */
[data-theme="light"] .badge.badge-outline,
[data-theme="dark"] .badge.badge-outline {
background-color: var(--color-base-100);
}
/* WCAG 2.2 AA (4.5:1 for normal text): Badge text must contrast with badge background.
Theme tokens *-content are often too light on * backgrounds in light theme, and
badge-soft uses variant as text on a light tint (low contrast). We override
--badge-fg (and for soft, color) so badge text meets 4.5:1 in both themes. */
/* Light theme: use dark text on all colored badges (solid, soft, outline). */
[data-theme="light"] .badge.badge-primary {
--badge-fg: oklch(0.25 0.08 47);
}
[data-theme="light"] .badge.badge-primary.badge-soft {
color: oklch(0.38 0.14 47);
}
[data-theme="light"] .badge.badge-success {
--badge-fg: oklch(0.26 0.06 165);
}
[data-theme="light"] .badge.badge-success.badge-soft {
color: oklch(0.35 0.10 165);
}
[data-theme="light"] .badge.badge-error {
--badge-fg: oklch(0.22 0.08 25);
}
[data-theme="light"] .badge.badge-error.badge-soft {
color: oklch(0.38 0.14 25);
}
[data-theme="light"] .badge.badge-warning {
--badge-fg: oklch(0.28 0.06 75);
}
[data-theme="light"] .badge.badge-warning.badge-soft {
color: oklch(0.42 0.12 75);
}
[data-theme="light"] .badge.badge-info {
--badge-fg: oklch(0.26 0.08 250);
}
[data-theme="light"] .badge.badge-info.badge-soft {
color: oklch(0.38 0.12 250);
}
[data-theme="light"] .badge.badge-neutral {
--badge-fg: oklch(0.22 0.01 285);
}
[data-theme="light"] .badge.badge-neutral.badge-soft {
color: oklch(0.32 0.02 285);
}
[data-theme="light"] .badge.badge-outline.badge-primary,
[data-theme="light"] .badge.badge-outline.badge-success,
[data-theme="light"] .badge.badge-outline.badge-error,
[data-theme="light"] .badge.badge-outline.badge-warning,
[data-theme="light"] .badge.badge-outline.badge-info,
[data-theme="light"] .badge.badge-outline.badge-neutral {
--badge-fg: oklch(0.25 0.02 285);
}
/* Dark theme: ensure badge backgrounds are dark enough for light content (4.5:1).
Slightly darken solid variant backgrounds so theme *-content (light) passes. */
[data-theme="dark"] .badge.badge-primary:not(.badge-soft):not(.badge-outline) {
--badge-bg: oklch(0.42 0.20 277);
--badge-fg: oklch(0.97 0.02 277);
}
[data-theme="dark"] .badge.badge-success:not(.badge-soft):not(.badge-outline) {
--badge-bg: oklch(0.42 0.10 185);
--badge-fg: oklch(0.97 0.01 185);
}
[data-theme="dark"] .badge.badge-error:not(.badge-soft):not(.badge-outline) {
--badge-bg: oklch(0.42 0.18 18);
--badge-fg: oklch(0.97 0.02 18);
}
[data-theme="dark"] .badge.badge-warning:not(.badge-soft):not(.badge-outline) {
--badge-bg: oklch(0.48 0.14 58);
--badge-fg: oklch(0.22 0.02 58);
}
[data-theme="dark"] .badge.badge-info:not(.badge-soft):not(.badge-outline) {
--badge-bg: oklch(0.45 0.14 242);
--badge-fg: oklch(0.97 0.02 242);
}
[data-theme="dark"] .badge.badge-neutral:not(.badge-soft):not(.badge-outline) {
--badge-bg: oklch(0.32 0.02 257);
--badge-fg: oklch(0.96 0.01 257);
}
[data-theme="dark"] .badge.badge-soft.badge-primary { color: oklch(0.85 0.12 277); }
[data-theme="dark"] .badge.badge-soft.badge-success { color: oklch(0.82 0.08 165); }
[data-theme="dark"] .badge.badge-soft.badge-error { color: oklch(0.82 0.14 25); }
[data-theme="dark"] .badge.badge-soft.badge-warning { color: oklch(0.88 0.10 75); }
[data-theme="dark"] .badge.badge-soft.badge-info { color: oklch(0.85 0.10 250); }
[data-theme="dark"] .badge.badge-soft.badge-neutral { color: oklch(0.90 0.01 257); }
[data-theme="dark"] .badge.badge-outline.badge-primary,
[data-theme="dark"] .badge.badge-outline.badge-success,
[data-theme="dark"] .badge.badge-outline.badge-error,
[data-theme="dark"] .badge.badge-outline.badge-warning,
[data-theme="dark"] .badge.badge-outline.badge-info,
[data-theme="dark"] .badge.badge-outline.badge-neutral {
--badge-fg: oklch(0.92 0.02 257);
}
/* WCAG 2.2 AA: Member filter join buttons (All / Paid / Unpaid, group, boolean).
Inactive state uses base-content on a light/dark surface; active state ensures
*-content on * background meets 4.5:1. */
.member-filter-dropdown .join .btn {
/* Inactive: ensure readable text (theme base-content may be low contrast on btn default) */
border-color: var(--color-base-300);
}
[data-theme="light"] .member-filter-dropdown .join .btn:not(.btn-active) {
color: oklch(0.25 0.02 285);
background-color: var(--color-base-100);
}
[data-theme="light"] .member-filter-dropdown .join .btn.btn-success.btn-active {
background-color: oklch(0.42 0.12 165);
color: oklch(0.98 0.01 165);
}
[data-theme="light"] .member-filter-dropdown .join .btn.btn-error.btn-active {
background-color: oklch(0.42 0.18 18);
color: oklch(0.98 0.02 18);
}
[data-theme="dark"] .member-filter-dropdown .join .btn:not(.btn-active) {
color: oklch(0.92 0.02 257);
background-color: var(--color-base-200);
}
[data-theme="dark"] .member-filter-dropdown .join .btn.btn-success.btn-active {
background-color: oklch(0.42 0.10 165);
color: oklch(0.97 0.01 165);
}
[data-theme="dark"] .member-filter-dropdown .join .btn.btn-error.btn-active {
background-color: oklch(0.42 0.18 18);
color: oklch(0.97 0.02 18);
}
/* ============================================ /* ============================================
Sidebar Base Styles Sidebar Base Styles
============================================ */ ============================================ */
@ -521,31 +389,4 @@
display: none !important; display: none !important;
} }
/* ============================================
WCAG 1.4.3: Primary button contrast (AA)
============================================ */
/* Override DaisyUI theme --color-primary-content so text on btn-primary (brand)
meets 4.5:1. In DevTools: inspect .btn-primary, check computed --color-primary
and --color-primary-content; verify contrast at https://webaim.org/resources/contrastchecker/ */
/* Light theme: primary is orange (brand); primary-content must be dark. */
[data-theme="light"] {
--color-primary-content: oklch(0.18 0.02 47);
--color-error: oklch(55% 0.253 17.585);
--color-error-content: oklch(98% 0 0);
}
/* Dark theme: primary is purple; ensure content is light and meets 4.5:1. */
[data-theme="dark"] {
--color-error: oklch(55% 0.253 17.585);
--color-error-content: oklch(98% 0 0);
--color-primary: oklch(72% 0.17 45);
--color-primary-content: oklch(0.18 0.02 47);
--color-secondary: oklch(48% 0.233 277.117);
--color-secondary-content: oklch(98% 0 0);
}
/* This file is for your main application CSS */ /* This file is for your main application CSS */

View file

@ -73,53 +73,6 @@ Hooks.ComboBox = {
} }
} }
// TableRowKeydown hook: WCAG 2.1.1 — when a table row cell has data-row-clickable,
// Enter and Space trigger a click so row_click tables are keyboard activatable
Hooks.TableRowKeydown = {
mounted() {
this.handleKeydown = (e) => {
if (
e.target.getAttribute("data-row-clickable") === "true" &&
(e.key === "Enter" || e.key === " ")
) {
e.preventDefault()
e.target.click()
}
}
this.el.addEventListener("keydown", this.handleKeydown)
},
destroyed() {
this.el.removeEventListener("keydown", this.handleKeydown)
}
}
// FocusRestore hook: WCAG 2.4.3 — when a modal closes, focus returns to the trigger element (e.g. "Delete member" button)
Hooks.FocusRestore = {
mounted() {
this.handleEvent("focus_restore", ({id}) => {
const el = document.getElementById(id)
if (el) el.focus()
})
}
}
// TabListKeydown hook: WCAG tab pattern — prevent default for ArrowLeft/ArrowRight so the server can handle tab switch (roving tabindex)
Hooks.TabListKeydown = {
mounted() {
this.handleKeydown = (e) => {
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault()
}
}
this.el.addEventListener('keydown', this.handleKeydown)
},
destroyed() {
this.el.removeEventListener('keydown', this.handleKeydown)
}
}
// SidebarState hook: Manages sidebar expanded/collapsed state // SidebarState hook: Manages sidebar expanded/collapsed state
Hooks.SidebarState = { Hooks.SidebarState = {
mounted() { mounted() {

View file

@ -1,88 +0,0 @@
# Phase 1 — Badge WCAG Analysis & Migration
## 1) Repo-Analyse (Stand vor Änderungen)
### Badge-Verwendungen (alle Fundstellen)
| Datei | Kontext | Markup |
|-------|---------|--------|
| `lib/mv_web/live/member_field_live/index_component.ex` | Tabelle (show_in_overview) | `<span class="badge badge-success">` / `<span class="badge badge-ghost">` |
| `lib/mv_web/live/components/member_filter_component.ex` | Filter-Chips (Anzahl) | `<span class="badge badge-primary badge-sm">` (2×) |
| `lib/mv_web/live/role_live/index.html.heex` | Tabelle (System Role, Permission Set, Custom) | `badge-warning`, `permission_set_badge_class()`, `badge-ghost` (User Count) |
| `lib/mv_web/helpers/membership_fee_helpers.ex` | Helper | `status_color/1` → "badge-success" \| "badge-error" \| "badge-ghost" |
| `lib/mv_web/live/member_live/show.ex` | Mitgliedsdetail (Beiträge) | `<span class={["badge", status_color(status)]}>`, `badge-ghost` (No cycles) |
| `lib/mv_web/live/membership_fee_settings_live.ex` | Settings (Fee Types) | `badge-outline`, `badge-ghost` (member count) |
| `lib/mv_web/live/membership_fee_type_live/index.ex` | Index (Fee Types) | `badge-outline`, `badge-ghost` (member count) |
| `lib/mv_web/live/role_live/index.ex` | (Helper-Import) | `permission_set_badge_class/1` |
| `lib/mv_web/live/member_live/show/membership_fees_component.ex` | Mitgliedsbeiträge | `badge-outline`, `["badge", status_color]` |
| `lib/mv_web/live/custom_field_live/index_component.ex` | Tabelle (show_in_overview) | `badge-success`, `badge-ghost` |
| `lib/mv_web/member_live/index/membership_fee_status.ex` | Helper | `format_cycle_status_badge/1` → map mit `color`, `icon`, `label` |
| `lib/mv_web/live/global_settings_live.ex` | Form (label-text-alt) | `badge badge-ghost` "(set)" (2×) |
| `lib/mv_web/live/member_live/index.html.heex` | Tabelle (Status) | `format_cycle_status_badge` + `<span class={["badge", badge.color]}>`, `badge-ghost` (No cycle), `badge-outline badge-primary` (Filter-Chip) |
| `lib/mv_web/live/role_live/helpers.ex` | Helper | `permission_set_badge_class/1` → "badge badge-* badge-sm" |
| `lib/mv_web/live/group_live/show.ex` | Card | `badge badge-outline badge` |
| `lib/mv_web/live/role_live/show.ex` | Detail | `permission_set_badge_class`, `badge-warning` (System), `badge-ghost` (No) |
### DaisyUI/Tailwind Config
- **Tailwind:** `assets/tailwind.config.js` — erweitert nur `theme.extend.colors.brand`; kein DaisyUI hier.
- **DaisyUI:** wird in `assets/css/app.css` per `@plugin "../vendor/daisyui"` mit `themes: false` geladen.
- **Themes:** Zwei Custom-Themes in `app.css`:
- `@plugin "../vendor/daisyui-theme"` mit `name: "dark"` (default: false)
- `@plugin "../vendor/daisyui-theme"` mit `name: "light"` (default: true)
- **Theme-Umschaltung:** `lib/mv_web/components/layouts/root.html.heex` — Inline-Script setzt `document.documentElement.setAttribute("data-theme", "light"|"dark")` aus `localStorage["phx:theme"]` oder `prefers-color-scheme`. Sidebar enthält Theme-Toggle (`<.theme_toggle />`).
### Core Components
- **Modul:** `lib/mv_web/components/core_components.ex` (MvWeb.CoreComponents).
- **Vorhanden:** flash, button, dropdown_menu, form_section, input, header, table, icon, link, etc.
- **Badge:** Bisher keine zentrale `<.badge>`-Komponente.
### DaisyUI Badge (Vendor)
- **Default:** `--badge-bg: var(--badge-color, var(--color-base-100))`, `--badge-fg: var(--color-base-content)`.
- **badge-outline:** `--badge-bg: "#0000"` (transparent) → Kontrastproblem auf base-200/base-300.
- **badge-ghost:** `background-color: var(--color-base-200)`, `color: var(--color-base-content)` → auf base-200-Flächen kaum sichtbar.
- **badge-soft:** color-mix 8% Variante mit base-100 → sichtbar; Text ist Variantenfarbe (Kontrast prüfen).
---
## 2) Core Component <.badge> API (geplant)
- **attr :variant**`:neutral | :primary | :info | :success | :warning | :error`
- **attr :style**`:soft | :solid | :outline` (Default: `:soft`)
- **attr :size**`:sm | :md` (Default: `:md`)
- **slot :inner_block** — Badge-Text
- **attr :sr_label** — optional, für Icon-only (Screen Reader)
- **slot :icon** — optional
Regeln:
- `:soft` und `:solid` nutzen sichtbaren Hintergrund (kein transparenter Ghost als Default).
- `:outline` setzt immer einen Hintergrund (z. B. `bg-base-100`), damit der Rand auf grauen Flächen sichtbar bleibt.
- Ghost nur als explizites Opt-in; dann mit `bg-base-100` für Sichtbarkeit.
---
## 3) Theme-Overrides (WCAG)
- In `app.css` sind bereits Custom-Themes für `light` und `dark` mit eigenen Tokens.
- **Badge-Kontrast (WCAG 2.2 AA 4.5:1):** Zusätzliche Overrides in `app.css`:
- **Light theme:** Dunkle `--badge-fg` für alle Varianten (primary, success, error, warning, info, neutral); für `badge-soft` dunklere Textfarbe (`color`) auf getöntem Hintergrund; für `badge-outline` einheitlich dunkle Schrift auf base-100.
- **Dark theme:** Leicht abgedunkelte Badge-Hintergründe für Solid-Badges, damit die hellen *-content-Farben 4.5:1 erreichen; für `badge-soft` hellere, gut lesbare Variantentöne; für `badge-outline` heller Text (`--badge-fg`) auf base-100.
---
## 4) Migration (erledigt)
- Alle `<span class="badge ...">` durch `<.badge variant="..." style="...">...</.badge>` ersetzt.
- Klickbare Chips (z. B. Group Show „Remove“) bleiben als <.badge> mit Button im inner_block (Badge ist nur Container).
- **Neue Helper:** `MembershipFeeHelpers.status_variant/1` (→ :success | :error | :warning; suspended = :warning wie Edit-Button), `RoleLive.Helpers.permission_set_badge_variant/1` (→ :neutral | :info | :success | :error).
- **Angepasst:** `MembershipFeeStatus.format_cycle_status_badge/1` liefert zusätzlich `:variant` für <.badge>.
- **Migrierte Stellen:** member_field_live, member_filter_component, role_live (index + show), member_live (show, index, membership_fees_component), membership_fee_settings_live, membership_fee_type_live, custom_field_live, global_settings_live, group_live/show.
## 5) Weitere Anpassungen (nach Phase 1)
- **Filter Join-Buttons (WCAG):** In `app.css` Kontrast-Overrides für `.member-filter-dropdown .join .btn` (inaktiv: base-100/base-200 + dunkle/helle Schrift; aktiv: success/error mit 4.5:1).
- **Badge „Pausiert“ (suspended):** `status_variant(:suspended)``:warning` (gelb), damit Badge dieselbe Farbe wie der Edit-Button (btn-warning) hat.
- **Filter-Dropdown schließen:** `phx-click-away` vom inneren Panel auf den äußeren Wrapper (`member-filter-dropdown`) verschoben; Klick auf den Filter-Button schließt das Dropdown (konsistent mit Spalten/Ausblenden).

View file

@ -360,29 +360,13 @@ defmodule Mv.Config do
end end
@doc """ @doc """
Returns the OIDC client secret. Returns the OIDC client secret. ENV first, then Settings.
In production, uses the value from config :mv, :oidc (set by runtime.exs from OIDC_CLIENT_SECRET or OIDC_CLIENT_SECRET_FILE).
Otherwise ENV OIDC_CLIENT_SECRET, then Settings.
""" """
@spec oidc_client_secret() :: String.t() | nil @spec oidc_client_secret() :: String.t() | nil
def oidc_client_secret do def oidc_client_secret do
case Application.get_env(:mv, :oidc) do env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret)
oidc when is_list(oidc) -> oidc_client_secret_from_config(Keyword.get(oidc, :client_secret))
_ -> env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret)
end
end end
defp oidc_client_secret_from_config(nil),
do: env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret)
defp oidc_client_secret_from_config(secret) when is_binary(secret) do
s = String.trim(secret)
if s != "", do: s, else: env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret)
end
defp oidc_client_secret_from_config(_),
do: env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret)
@doc """ @doc """
Returns the OIDC admin group name (for role sync). ENV first, then Settings. Returns the OIDC admin group name (for role sync). ENV first, then Settings.
""" """
@ -442,10 +426,7 @@ defmodule Mv.Config do
def oidc_client_id_env_set?, do: env_set?("OIDC_CLIENT_ID") def oidc_client_id_env_set?, do: env_set?("OIDC_CLIENT_ID")
def oidc_base_url_env_set?, do: env_set?("OIDC_BASE_URL") def oidc_base_url_env_set?, do: env_set?("OIDC_BASE_URL")
def oidc_redirect_uri_env_set?, do: env_set?("OIDC_REDIRECT_URI") def oidc_redirect_uri_env_set?, do: env_set?("OIDC_REDIRECT_URI")
def oidc_client_secret_env_set?, do: env_set?("OIDC_CLIENT_SECRET")
def oidc_client_secret_env_set?,
do: env_set?("OIDC_CLIENT_SECRET") or env_set?("OIDC_CLIENT_SECRET_FILE")
def oidc_admin_group_name_env_set?, do: env_set?("OIDC_ADMIN_GROUP_NAME") def oidc_admin_group_name_env_set?, do: env_set?("OIDC_ADMIN_GROUP_NAME")
def oidc_groups_claim_env_set?, do: env_set?("OIDC_GROUPS_CLAIM") def oidc_groups_claim_env_set?, do: env_set?("OIDC_GROUPS_CLAIM")
def oidc_only_env_set?, do: env_set?("OIDC_ONLY") def oidc_only_env_set?, do: env_set?("OIDC_ONLY")

View file

@ -31,21 +31,6 @@ defmodule MvWeb.CoreComponents do
alias Phoenix.LiveView.JS alias Phoenix.LiveView.JS
# WCAG 2.4.7 / 2.4.11: Shared focus ring for buttons and dropdown (trigger + items)
@button_focus_classes [
"focus-visible:outline-none",
"focus-visible:ring-2",
"focus-visible:ring-offset-2",
"focus-visible:ring-offset-base-100",
"focus-visible:ring-base-content/60"
]
@doc """
Returns the shared focus ring class list for buttons and dropdown items (WCAG 2.4.7).
Use when building custom dropdown item buttons so they match <.button> and dropdown trigger.
"""
def button_focus_classes, do: @button_focus_classes
@doc """ @doc """
Renders flash notices. Renders flash notices.
@ -162,16 +147,13 @@ defmodule MvWeb.CoreComponents do
size_class = size_classes[size] size_class = size_classes[size]
btn_class = [base_class, size_class] |> Enum.reject(&(&1 == "")) |> Enum.join(" ") btn_class = [base_class, size_class] |> Enum.reject(&(&1 == "")) |> Enum.join(" ")
assigns = assigns = assign(assigns, :btn_class, btn_class)
assigns
|> assign(:btn_class, btn_class)
|> assign(:button_focus_classes, @button_focus_classes)
if rest[:href] || rest[:navigate] || rest[:patch] do if rest[:href] || rest[:navigate] || rest[:patch] do
link_class = link_class =
if assigns[:disabled], if assigns[:disabled],
do: ["btn", btn_class, "btn-disabled"] ++ @button_focus_classes, do: ["btn", btn_class, "btn-disabled"],
else: ["btn", btn_class] ++ @button_focus_classes else: ["btn", btn_class]
link_attrs = link_attrs =
if assigns[:disabled] do if assigns[:disabled] do
@ -194,187 +176,13 @@ defmodule MvWeb.CoreComponents do
""" """
else else
~H""" ~H"""
<button <button class={["btn", @btn_class]} disabled={@disabled} {@rest}>
class={["btn", @btn_class] ++ @button_focus_classes}
disabled={@disabled}
{@rest}
>
{render_slot(@inner_block)} {render_slot(@inner_block)}
</button> </button>
""" """
end end
end end
@doc """
Renders a non-interactive badge with WCAG-compliant contrast.
Use for status labels, counts, or tags. For clickable elements (e.g. filter chips),
use a button or link component instead, not this badge.
## Variants and styles
- **variant:** `:neutral`, `:primary`, `:info`, `:success`, `:warning`, `:error`
- **style:** `:soft` (default, tinted background), `:solid`, `:outline`
- **size:** `:sm`, `:md` (default)
Outline and soft styles always use a visible background so the badge remains
readable on base-200/base-300 surfaces (WCAG 2.2 AA). Ghost style is not exposed
by default to avoid low-contrast on gray backgrounds.
## Examples
<.badge variant="success">Paid</.badge>
<.badge variant="error" style="solid">Unpaid</.badge>
<.badge variant="neutral" size="sm">Custom</.badge>
<.badge variant="primary" style="outline">Label</.badge>
<.badge variant="success" sr_label="Paid">
<.icon name="hero-check-circle" class="size-4" />
</.badge>
"""
attr :variant, :any,
default: "neutral",
doc: "Color variant: neutral | primary | info | success | warning | error (string or atom)"
attr :style, :any,
default: "soft",
doc: "Visual style: soft | solid | outline; :outline gets bg-base-100 for contrast"
attr :size, :any,
default: "md",
doc: "Badge size: sm | md"
attr :sr_label, :string,
default: nil,
doc: "Optional screen-reader label for icon-only content"
attr :rest, :global, doc: "Arbitrary HTML attributes (e.g. id, class, data-testid)"
slot :inner_block, required: true, doc: "Badge text (and optional icon)"
slot :icon, doc: "Optional leading icon slot"
def badge(assigns) do
# Normalize so both HEEx strings (variant="neutral") and helper atoms (variant={:neutral}) work
variant = to_string(assigns.variant || "neutral")
style = to_string(assigns.style || "soft")
size = to_string(assigns.size || "md")
variant_class = "badge-#{variant}"
style_class = badge_style_class(style)
size_class = "badge-#{size}"
# Outline has transparent bg in DaisyUI; add bg so it stays visible on base-200/base-300
outline_bg = if style == "outline", do: "bg-base-100", else: nil
rest = assigns.rest || []
rest = if is_list(rest), do: rest, else: Map.to_list(rest)
extra_class = Keyword.get(rest, :class)
rest = Keyword.drop(rest, [:class])
rest = if assigns.sr_label, do: Keyword.put(rest, :"aria-label", assigns.sr_label), else: rest
class =
["badge", variant_class, style_class, size_class, outline_bg, extra_class]
|> List.flatten()
|> Enum.reject(&is_nil/1)
|> Enum.join(" ")
assigns =
assigns
|> assign(:class, class)
|> assign(:rest, rest)
|> assign(:has_icon, assigns.icon != [])
~H"""
<span class={@class} {@rest}>
<%= if @has_icon do %>
{render_slot(@icon)}
<% end %>
{render_slot(@inner_block)}
<%= if @sr_label do %>
<span class="sr-only">{@sr_label}</span>
<% end %>
</span>
"""
end
defp badge_style_class("soft"), do: "badge-soft"
defp badge_style_class("solid"), do: nil
defp badge_style_class("outline"), do: "badge-outline"
defp badge_style_class(_), do: nil
@doc """
Renders a visually empty table cell with screen-reader-only text (WCAG).
Use when a table cell has no value so that:
- The cell appears empty (no dash, no "n/a").
- Screen readers still get a meaningful label (e.g. "No cycle", "No group assignment").
See CODE_GUIDELINES §8 (Empty table cells) and Design Guidelines §8.6.
## Examples
<.empty_cell sr_text={gettext("No cycle")} />
<.empty_cell sr_text={gettext("No group assignment")} />
<.empty_cell sr_text={gettext("Not specified")} />
"""
attr :sr_text, :string,
required: true,
doc: "Text read by screen readers when the cell is visually empty"
def empty_cell(assigns) do
~H"""
<span class="sr-only">{@sr_text}</span>
"""
end
@doc """
Renders content when value is present, otherwise an accessible empty cell.
Use in table cells for optional fields: when `value` is blank, only the
screen-reader text is shown (visually empty). Otherwise the inner block is rendered.
Blank check: `nil`, `false`, `[]`, `""`, whitespace-only string, or `%Ash.NotLoaded{}` count as empty.
See CODE_GUIDELINES §8 (Empty table cells) and Design Guidelines §8.6.
## Examples
<.maybe_value value={member.membership_fee_type} empty_sr_text={gettext("No fee type")}>
{member.membership_fee_type.name}
</.maybe_value>
<.maybe_value value={member.groups} empty_sr_text={gettext("No group assignment")}>
<%= for g <- member.groups do %>
<.badge variant="primary" style="outline">{g.name}</.badge>
<% end %>
</.maybe_value>
"""
attr :value, :any, doc: "Value to check; if blank, empty_cell is rendered"
attr :empty_sr_text, :string,
default: nil,
doc: "Screen-reader text when value is blank (default: gettext \"Not specified\")"
slot :inner_block, required: true
def maybe_value(assigns) do
empty_sr = assigns.empty_sr_text || gettext("Not specified")
assigns = assign(assigns, :empty_sr_text, empty_sr)
assigns = assign(assigns, :blank?, value_blank?(assigns.value))
~H"""
<%= if @blank? do %>
<.empty_cell sr_text={@empty_sr_text} />
<% else %>
{render_slot(@inner_block)}
<% end %>
"""
end
defp value_blank?(nil), do: true
defp value_blank?(false), do: true
defp value_blank?([]), do: true
defp value_blank?(%Ash.NotLoaded{}), do: true
defp value_blank?(v) when is_binary(v), do: String.trim(v) == ""
defp value_blank?(_), do: false
@doc """ @doc """
Wraps content with a DaisyUI tooltip. Use for icon-only actions, truncated content, Wraps content with a DaisyUI tooltip. Use for icon-only actions, truncated content,
or status badges that need explanation (Design Guidelines §8.2). or status badges that need explanation (Design Guidelines §8.2).
@ -457,11 +265,7 @@ defmodule MvWeb.CoreComponents do
def dropdown_menu(assigns) do def dropdown_menu(assigns) do
menu_testid = assigns.menu_testid || "#{assigns.testid}-menu" menu_testid = assigns.menu_testid || "#{assigns.testid}-menu"
assigns = assign(assigns, :menu_testid, menu_testid)
assigns =
assigns
|> assign(:menu_testid, menu_testid)
|> assign(:button_focus_classes, @button_focus_classes)
~H""" ~H"""
<div <div
@ -477,10 +281,17 @@ defmodule MvWeb.CoreComponents do
tabindex="0" tabindex="0"
role="button" role="button"
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={if @open, do: "true", else: "false"} aria-expanded={@open}
aria-controls={@id} aria-controls={@id}
aria-label={@button_label} aria-label={@button_label}
class={["btn"] ++ @button_focus_classes ++ [@button_class]} class={[
"btn",
"focus:outline-none",
"focus-visible:ring-2",
"focus-visible:ring-offset-2",
"focus-visible:ring-base-content/20",
@button_class
]}
phx-click="toggle_dropdown" phx-click="toggle_dropdown"
phx-target={@phx_target} phx-target={@phx_target}
data-testid={@button_testid} data-testid={@button_testid}
@ -548,12 +359,7 @@ defmodule MvWeb.CoreComponents do
if @checkboxes, do: to_string(Map.get(@selected, item.value, true)), else: nil if @checkboxes, do: to_string(Map.get(@selected, item.value, true)), else: nil
} }
tabindex="0" tabindex="0"
class={ class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-base-content/20 focus-visible:ring-inset"
[
"flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left",
"focus-visible:ring-inset"
] ++ @button_focus_classes
}
phx-click="select_item" phx-click="select_item"
phx-keydown="select_item" phx-keydown="select_item"
phx-key="Enter" phx-key="Enter"
@ -864,8 +670,6 @@ defmodule MvWeb.CoreComponents do
When `row_click` is set, clicking a row (or a data cell) triggers the handler. 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). Rows with `row_click` get a subtle hover and focus-within outline (theme-friendly ring).
For keyboard accessibility (WCAG 2.1.1), the first column without `col_click` gets
`tabindex="0"` and `role="button"`; the TableRowKeydown hook triggers the row action on Enter/Space.
When `selected_row_id` is set and matches a row's id (via `row_value_id` or `row_item.(row).id`), 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). that row gets a stronger selected outline (ring-primary) for accessibility (not color-only).
@ -948,22 +752,8 @@ defmodule MvWeb.CoreComponents do
assigns = assign(assigns, :row_value_id_fn, row_value_id_fn) assigns = assign(assigns, :row_value_id_fn, row_value_id_fn)
# WCAG 2.1.1: when row_click is set, first column without col_click gets tabindex="0"
# so rows are reachable via Tab; TableRowKeydown hook triggers click on Enter/Space
first_row_click_col_idx =
if assigns[:row_click] do
Enum.find_index(assigns[:col] || [], fn c -> !c[:col_click] end)
end
assigns =
assign(assigns, :first_row_click_col_idx, first_row_click_col_idx)
~H""" ~H"""
<div <div class="overflow-auto">
id={@row_click && "#{@id}-keyboard"}
class="overflow-auto"
phx-hook={@row_click && "TableRowKeydown"}
>
<table class="table table-zebra"> <table class="table table-zebra">
<thead> <thead>
<tr> <tr>
@ -999,11 +789,6 @@ defmodule MvWeb.CoreComponents do
> >
<td <td
:for={{col, col_idx} <- Enum.with_index(@col)} :for={{col, col_idx} <- Enum.with_index(@col)}
tabindex={if @row_click && @first_row_click_col_idx == col_idx, do: 0, else: nil}
role={if @row_click && @first_row_click_col_idx == col_idx, do: "button", else: nil}
data-row-clickable={
if @row_click && @first_row_click_col_idx == col_idx, do: "true", else: nil
}
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))
@ -1027,19 +812,6 @@ defmodule MvWeb.CoreComponents do
classes classes
end end
# WCAG: no focus ring on the cell itself; row shows focus via focus-within
classes =
if @row_click && @first_row_click_col_idx == col_idx do
[
"focus:outline-none",
"focus-visible:outline-none",
"focus:ring-0",
"focus-visible:ring-0" | classes
]
else
classes
end
classes = classes =
if col_class do if col_class do
[col_class | classes] [col_class | classes]

View file

@ -8,16 +8,6 @@ defmodule MvWeb.Components.ExportDropdown do
use MvWeb, :live_component use MvWeb, :live_component
use Gettext, backend: MvWeb.Gettext use Gettext, backend: MvWeb.Gettext
# Same focus ring as CoreComponents button/dropdown (WCAG 2.4.7)
defp dropdown_item_class do
focus =
MvWeb.CoreComponents.button_focus_classes()
|> Kernel.++(["focus-visible:ring-inset"])
|> Enum.join(" ")
"flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left #{focus}"
end
@impl true @impl true
def mount(socket) do def mount(socket) do
{:ok, assign(socket, :open, false)} {:ok, assign(socket, :open, false)}
@ -69,7 +59,7 @@ defmodule MvWeb.Components.ExportDropdown do
<button <button
type="submit" type="submit"
role="menuitem" role="menuitem"
class={dropdown_item_class()} class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-base-content/20 focus-visible:ring-inset"
aria-label={gettext("Export members to CSV")} aria-label={gettext("Export members to CSV")}
data-testid="export-csv-link" data-testid="export-csv-link"
> >
@ -85,7 +75,7 @@ defmodule MvWeb.Components.ExportDropdown do
<button <button
type="submit" type="submit"
role="menuitem" role="menuitem"
class={dropdown_item_class()} class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-base-content/20 focus-visible:ring-inset"
aria-label={gettext("Export members to PDF")} aria-label={gettext("Export members to PDF")}
data-testid="export-pdf-link" data-testid="export-pdf-link"
> >

View file

@ -219,17 +219,6 @@ defmodule MvWeb.Helpers.MembershipFeeHelpers do
def status_color(:unpaid), do: "badge-error" def status_color(:unpaid), do: "badge-error"
def status_color(:suspended), do: "badge-ghost" def status_color(:suspended), do: "badge-ghost"
@doc """
Returns the Core Components badge variant for a cycle status (WCAG-compliant).
Use with <.badge variant={MembershipFeeHelpers.status_variant(status)}>.
Suspended uses :warning (yellow) to match the edit cycle-status button.
"""
@spec status_variant(:paid | :unpaid | :suspended) :: :success | :error | :warning
def status_variant(:paid), do: :success
def status_variant(:unpaid), do: :error
def status_variant(:suspended), do: :warning
@doc """ @doc """
Gets the icon name for a status. Gets the icon name for a status.

View file

@ -58,9 +58,8 @@ defmodule MvWeb.Components.MemberFilterComponent do
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div <div
class="relative member-filter-dropdown" class="relative"
id={@id} id={@id}
phx-click-away={if @open, do: "close_dropdown", else: nil}
phx-window-keydown={@open && "close_dropdown"} phx-window-keydown={@open && "close_dropdown"}
phx-key="Escape" phx-key="Escape"
phx-target={@myself} phx-target={@myself}
@ -90,23 +89,21 @@ defmodule MvWeb.Components.MemberFilterComponent do
@boolean_filters @boolean_filters
)} )}
</span> </span>
<.badge <span
:if={active_boolean_filters_count(@boolean_filters) > 0} :if={active_boolean_filters_count(@boolean_filters) > 0}
variant="primary" class="badge badge-primary badge-sm"
size="sm"
> >
{active_boolean_filters_count(@boolean_filters)} {active_boolean_filters_count(@boolean_filters)}
</.badge> </span>
<.badge <span
:if={ :if={
(@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
} }
variant="primary" class="badge badge-primary badge-sm"
size="sm"
> >
{@member_count} {@member_count}
</.badge> </span>
</.button> </.button>
<!-- <!--
@ -121,6 +118,8 @@ defmodule MvWeb.Components.MemberFilterComponent do
:if={@open} :if={@open}
tabindex="0" tabindex="0"
class="absolute left-0 mt-2 w-[28rem] rounded-box border border-base-300 bg-base-100 p-4 shadow-xl z-[100]" class="absolute left-0 mt-2 w-[28rem] rounded-box border border-base-300 bg-base-100 p-4 shadow-xl z-[100]"
phx-click-away="close_dropdown"
phx-target={@myself}
role="dialog" role="dialog"
aria-label={gettext("Member filter")} aria-label={gettext("Member filter")}
> >

View file

@ -111,7 +111,6 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
)} )}
</p> </p>
<.button <.button
id="delete-custom-field-trigger"
type="button" type="button"
variant="danger" variant="danger"
phx-click="request_delete" phx-click="request_delete"

View file

@ -19,8 +19,8 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
assigns = assign(assigns, :field_type_label, &MvWeb.Translations.FieldTypes.label/1) assigns = assign(assigns, :field_type_label, &MvWeb.Translations.FieldTypes.label/1)
~H""" ~H"""
<div id={@id}> <div id={@id} class="mt-8">
<div :if={!@show_form} class="flex"> <div class="flex">
<p class="text-sm text-base-content/70"> <p class="text-sm text-base-content/70">
{gettext("These will appear in addition to other data when adding new members.")} {gettext("These will appear in addition to other data when adding new members.")}
</p> </p>
@ -54,7 +54,6 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
<.table <.table
id="custom_fields_table" id="custom_fields_table"
rows={@streams.custom_fields} rows={@streams.custom_fields}
row_id={fn {_stream_key, cf} -> "custom_fields-#{cf.id}" end}
row_click={ row_click={
fn {_id, custom_field} -> fn {_id, custom_field} ->
JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself) JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself)
@ -90,29 +89,20 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
label={gettext("Show in overview")} label={gettext("Show in overview")}
class="max-w-[9.375rem] text-center" class="max-w-[9.375rem] text-center"
> >
<.badge :if={custom_field.show_in_overview} variant="success"> <span :if={custom_field.show_in_overview} class="badge badge-success">
{gettext("Yes")} {gettext("Yes")}
</.badge> </span>
<.badge :if={!custom_field.show_in_overview} variant="neutral"> <span :if={!custom_field.show_in_overview} class="badge badge-ghost">
{gettext("No")} {gettext("No")}
</.badge> </span>
</:col> </:col>
</.table> </.table>
</div> </div>
<%!-- Delete Confirmation Modal (WCAG: focus in modal, aria-labelledby) --%> <%!-- Delete Confirmation Modal --%>
<dialog <dialog :if={@show_delete_modal} id="delete-custom-field-modal" class="modal modal-open">
:if={@show_delete_modal}
id="delete-custom-field-modal"
class="modal modal-open"
role="dialog"
aria-labelledby="delete-custom-field-modal-title"
phx-keydown="dialog_keydown"
>
<div class="modal-box"> <div class="modal-box">
<h3 id="delete-custom-field-modal-title" class="text-lg font-bold"> <h3 class="text-lg font-bold">{gettext("Delete Data Field")}</h3>
{gettext("Delete Data Field")}
</h3>
<div class="py-4 space-y-4"> <div class="py-4 space-y-4">
<div class="alert alert-warning"> <div class="alert alert-warning">
@ -120,15 +110,15 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
<div> <div>
<p class="font-semibold"> <p class="font-semibold">
{ngettext( {ngettext(
"%{count} member has a value assigned for this datafield.", "%{count} member has a value assigned for this custom field.",
"%{count} members have values assigned for this datafield.", "%{count} members have values assigned for this custom field.",
@custom_field_to_delete.assigned_members_count, @custom_field_to_delete.assigned_members_count,
count: @custom_field_to_delete.assigned_members_count count: @custom_field_to_delete.assigned_members_count
)} )}
</p> </p>
<p class="mt-2 text-sm"> <p class="mt-2 text-sm">
{gettext( {gettext(
"All datafield values will be permanently deleted when you delete this datafield." "All custom field values will be permanently deleted when you delete this custom field."
)} )}
</p> </p>
</div> </div>
@ -194,8 +184,8 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
@impl true @impl true
def update(assigns, socket) do def update(assigns, socket) do
# Use socket state so send_update(open_delete_for_id: ...) does not trigger false "form closed" # Track previous show_form state to detect when form is closed
previous_show_form = socket.assigns[:show_form] || false previous_show_form = Map.get(socket.assigns, :show_form, false)
# If show_form is explicitly provided in assigns, reset editing state # If show_form is explicitly provided in assigns, reset editing state
socket = socket =
@ -207,6 +197,13 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
socket socket
end end
# Detect when form is closed (show_form changes from true to false)
new_show_form = Map.get(assigns, :show_form, false)
if previous_show_form and not new_show_form do
send(self(), {:editing_section_changed, nil})
end
# 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])
@ -228,8 +225,6 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
socket socket
id -> id ->
send(self(), {:custom_field_delete_modal_open, true})
custom_field = custom_field =
Ash.get!(Mv.Membership.CustomField, id, Ash.get!(Mv.Membership.CustomField, id,
load: [:assigned_members_count], load: [:assigned_members_count],
@ -243,13 +238,6 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|> assign(:open_delete_for_id, nil) |> assign(:open_delete_for_id, nil)
end end
# Detect form closed only from final socket state (not from assigns alone)
current_show_form = socket.assigns[:show_form] || false
if previous_show_form and not current_show_form do
send(self(), {:editing_section_changed, nil})
end
{:ok, socket} {:ok, socket}
end end
@ -294,8 +282,6 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
actor: actor actor: actor
) )
send(self(), {:custom_field_delete_modal_open, true})
{:noreply, {:noreply,
socket socket
|> assign(:custom_field_to_delete, custom_field) |> assign(:custom_field_to_delete, custom_field)
@ -316,7 +302,6 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
if socket.assigns.slug_confirmation == custom_field.slug do if socket.assigns.slug_confirmation == custom_field.slug do
case Ash.destroy(custom_field, actor: actor) do case Ash.destroy(custom_field, actor: actor) do
:ok -> :ok ->
send(self(), {:custom_field_delete_modal_open, false})
send(self(), {:custom_field_deleted, custom_field}) send(self(), {:custom_field_deleted, custom_field})
{:noreply, {:noreply,
@ -327,7 +312,6 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|> stream_delete(:custom_fields, custom_field)} |> stream_delete(:custom_fields, custom_field)}
{:error, error} -> {:error, error} ->
send(self(), {:custom_field_delete_modal_open, false})
send(self(), {:custom_field_delete_error, error}) send(self(), {:custom_field_delete_error, error})
{:noreply, {:noreply,
@ -337,7 +321,6 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|> assign(:slug_confirmation, "")} |> assign(:slug_confirmation, "")}
end end
else else
send(self(), {:custom_field_delete_modal_open, false})
send(self(), :custom_field_slug_mismatch) send(self(), :custom_field_slug_mismatch)
{:noreply, {:noreply,
@ -350,22 +333,10 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
@impl true @impl true
def handle_event("cancel_delete", _params, socket) do def handle_event("cancel_delete", _params, socket) do
send(self(), {:custom_field_delete_modal_open, false}) {:noreply,
{:noreply, close_delete_modal_and_restore_focus(socket)} socket
end |> assign(:show_delete_modal, false)
|> assign(:custom_field_to_delete, nil)
def handle_event("dialog_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do |> assign(:slug_confirmation, "")}
send(self(), {:custom_field_delete_modal_open, false})
{:noreply, close_delete_modal_and_restore_focus(socket)}
end
def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket}
defp close_delete_modal_and_restore_focus(socket) do
socket
|> assign(:show_delete_modal, false)
|> assign(:custom_field_to_delete, nil)
|> assign(:slug_confirmation, "")
|> push_event("focus_restore", %{id: "delete-custom-field-trigger"})
end end
end end

View file

@ -19,32 +19,9 @@ defmodule MvWeb.DatafieldsLive do
socket socket
|> assign(:page_title, gettext("Datafields")) |> assign(:page_title, gettext("Datafields"))
|> assign(:settings, settings) |> assign(:settings, settings)
|> assign(:active_editing_section, nil) |> assign(:active_editing_section, nil)}
|> assign(:custom_field_delete_modal_open, false)}
end end
@impl true
def handle_event("window_keydown", %{"key" => key}, socket)
when key in ["Escape", "Esc"] do
if socket.assigns[:custom_field_delete_modal_open] do
send_update(MvWeb.CustomFieldLive.IndexComponent,
id: "custom-fields-component",
show_delete_modal: false,
custom_field_to_delete: nil,
slug_confirmation: ""
)
{:noreply,
socket
|> assign(:custom_field_delete_modal_open, false)
|> push_event("focus_restore", %{id: "delete-custom-field-trigger"})}
else
{:noreply, socket}
end
end
def handle_event("window_keydown", _params, socket), do: {:noreply, socket}
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
@ -52,68 +29,31 @@ defmodule MvWeb.DatafieldsLive do
<.header> <.header>
{gettext("Datafields")} {gettext("Datafields")}
<:subtitle> <:subtitle>
{gettext( {gettext("Configure member fields and custom data fields.")}
"Configure which data you want to save for your members. Define individual datafields."
)}
</:subtitle> </:subtitle>
</.header> </.header>
<%!-- Overview: both sections with form_section wrappers; FocusRestore for custom field delete modal --%> <.form_section title={gettext("Member fields")}>
<div
:if={@active_editing_section == nil}
id="datafields-focus-root"
class="mt-6 space-y-6"
phx-hook="FocusRestore"
phx-window-keydown={if @custom_field_delete_modal_open, do: "window_keydown", else: nil}
>
<.form_section title={gettext("Personal Data")}>
<.live_component
module={MvWeb.MemberFieldLive.IndexComponent}
id="member-fields-component"
settings={@settings}
/>
</.form_section>
<.form_section title={gettext("Individual Datafields")}>
<.live_component
module={MvWeb.CustomFieldLive.IndexComponent}
id="custom-fields-component"
actor={@current_user}
/>
</.form_section>
</div>
<%!-- Edit mode: only the active section, no section title/card wrapper --%>
<div :if={@active_editing_section == :member_fields} class="mt-6">
<.live_component <.live_component
:if={@active_editing_section != :custom_fields}
module={MvWeb.MemberFieldLive.IndexComponent} module={MvWeb.MemberFieldLive.IndexComponent}
id="member-fields-component" id="member-fields-component"
settings={@settings} settings={@settings}
/> />
</div> </.form_section>
<div <.form_section title={gettext("Custom fields")}>
:if={@active_editing_section == :custom_fields}
id="datafields-focus-root"
class="mt-6"
phx-hook="FocusRestore"
phx-window-keydown={if @custom_field_delete_modal_open, do: "window_keydown", else: nil}
>
<.live_component <.live_component
:if={@active_editing_section != :member_fields}
module={MvWeb.CustomFieldLive.IndexComponent} module={MvWeb.CustomFieldLive.IndexComponent}
id="custom-fields-component" id="custom-fields-component"
actor={@current_user} actor={@current_user}
/> />
</div> </.form_section>
</Layouts.app> </Layouts.app>
""" """
end end
@impl true
def handle_info({:custom_field_delete_modal_open, open}, socket) do
{:noreply, assign(socket, :custom_field_delete_modal_open, open)}
end
@impl true @impl true
def handle_info({:custom_field_saved, _custom_field, action}, socket) do def handle_info({:custom_field_saved, _custom_field, action}, socket) do
send_update(MvWeb.CustomFieldLive.IndexComponent, send_update(MvWeb.CustomFieldLive.IndexComponent,

View file

@ -124,9 +124,7 @@ defmodule MvWeb.GlobalSettingsLive do
<label class="label" for={@form[:vereinfacht_api_key].id}> <label class="label" for={@form[:vereinfacht_api_key].id}>
<span class="label-text">{gettext("API Key")}</span> <span class="label-text">{gettext("API Key")}</span>
<%= if @vereinfacht_api_key_set do %> <%= if @vereinfacht_api_key_set do %>
<span class="label-text-alt"> <span class="label-text-alt badge badge-ghost">{gettext("(set)")}</span>
<.badge variant="neutral" size="sm">{gettext("(set)")}</.badge>
</span>
<% end %> <% end %>
</label> </label>
<.input <.input
@ -253,9 +251,7 @@ defmodule MvWeb.GlobalSettingsLive do
<label class="label" for={@form[:oidc_client_secret].id}> <label class="label" for={@form[:oidc_client_secret].id}>
<span class="label-text">{gettext("Client Secret")}</span> <span class="label-text">{gettext("Client Secret")}</span>
<%= if @oidc_client_secret_set do %> <%= if @oidc_client_secret_set do %>
<span class="label-text-alt"> <span class="label-text-alt badge badge-ghost">{gettext("(set)")}</span>
<.badge variant="neutral" size="sm">{gettext("(set)")}</.badge>
</span>
<% end %> <% end %>
</label> </label>
<.input <.input

View file

@ -68,9 +68,11 @@ defmodule MvWeb.GroupLive.Index do
{group.name} {group.name}
</:col> </:col>
<:col :let={group} label={gettext("Description")}> <:col :let={group} label={gettext("Description")}>
<.maybe_value value={group.description} empty_sr_text={gettext("Not specified")}> <%= if group.description do %>
{group.description} {group.description}
</.maybe_value> <% else %>
<span class="text-base-content/50 italic"></span>
<% end %>
</:col> </:col>
<:col :let={group} label={gettext("Members")} class="text-right"> <:col :let={group} label={gettext("Members")} class="text-right">
{group.member_count || 0} {group.member_count || 0}

View file

@ -116,12 +116,7 @@ defmodule MvWeb.GroupLive.Show do
</:actions> </:actions>
</.header> </.header>
<div <div class="mt-6 space-y-6">
id="group-show-focus-root"
class="mt-6 space-y-6"
phx-hook="FocusRestore"
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
>
<%!-- Group Information --%> <%!-- Group Information --%>
<div class="max-w-2xl space-y-6 mb-6"> <div class="max-w-2xl space-y-6 mb-6">
<div> <div>
@ -155,11 +150,7 @@ defmodule MvWeb.GroupLive.Show do
<div class="relative"> <div class="relative">
<div class="input input-bordered join-item w-full flex flex-wrap gap-1 items-center py-1 px-2"> <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 %> <%= for member <- @selected_members do %>
<.badge <span class="badge badge-outline badge flex items-center gap-1">
variant="primary"
style="outline"
class="flex items-center gap-1"
>
{MvWeb.Helpers.MemberHelpers.display_name(member)} {MvWeb.Helpers.MemberHelpers.display_name(member)}
<.tooltip content={gettext("Remove")} position="top"> <.tooltip content={gettext("Remove")} position="top">
<.button <.button
@ -178,7 +169,7 @@ defmodule MvWeb.GroupLive.Show do
<.icon name="hero-x-mark" class="size-3" /> <.icon name="hero-x-mark" class="size-3" />
</.button> </.button>
</.tooltip> </.tooltip>
</.badge> </span>
<% end %> <% end %>
<input <input
type="text" type="text"
@ -309,14 +300,16 @@ defmodule MvWeb.GroupLive.Show do
</.link> </.link>
</td> </td>
<td> <td>
<.maybe_value value={member.email} empty_sr_text={gettext("No email")}> <%= if member.email do %>
<a <a
href={"mailto:#{member.email}"} href={"mailto:#{member.email}"}
class="link link-primary" class="link link-primary"
> >
{member.email} {member.email}
</a> </a>
</.maybe_value> <% else %>
<span class="text-base-content/50 italic"></span>
<% end %>
</td> </td>
<%= if can?(@current_user, :update, @group) do %> <%= if can?(@current_user, :update, @group) do %>
<td> <td>
@ -358,7 +351,6 @@ defmodule MvWeb.GroupLive.Show do
)} )}
</p> </p>
<.button <.button
id="delete-group-trigger"
variant="danger" variant="danger"
type="button" type="button"
phx-click="open_delete_modal" phx-click="open_delete_modal"
@ -372,19 +364,11 @@ defmodule MvWeb.GroupLive.Show do
</section> </section>
<% end %> <% end %>
<%!-- Delete Confirmation Modal (WCAG: focus in modal, aria-labelledby) --%> <%!-- Delete Confirmation Modal --%>
<%= if assigns[:show_delete_modal] do %> <%= if assigns[:show_delete_modal] do %>
<dialog <dialog id="delete-group-modal" class="modal modal-open" role="dialog">
id="delete-group-modal"
class="modal modal-open"
role="dialog"
aria-labelledby="delete-group-modal-title"
phx-keydown="dialog_keydown"
>
<div class="modal-box"> <div class="modal-box">
<h3 id="delete-group-modal-title" class="text-lg font-bold mb-4"> <h3 class="text-lg font-bold mb-4">{gettext("Delete Group")}</h3>
{gettext("Delete Group")}
</h3>
<p class="mb-4"> <p class="mb-4">
{gettext("Are you sure you want to delete this group? This action cannot be undone.")} {gettext("Are you sure you want to delete this group? This action cannot be undone.")}
</p> </p>
@ -419,7 +403,6 @@ defmodule MvWeb.GroupLive.Show do
placeholder={gettext("Enter the group name to confirm")} placeholder={gettext("Enter the group name to confirm")}
autocomplete="off" autocomplete="off"
phx-debounce="200" phx-debounce="200"
phx-mounted={JS.focus()}
class="w-full input input-bordered" class="w-full input input-bordered"
/> />
</form> </form>
@ -460,25 +443,12 @@ defmodule MvWeb.GroupLive.Show do
@impl true @impl true
def handle_event("cancel_delete", _params, socket) do def handle_event("cancel_delete", _params, socket) do
{:noreply, close_delete_modal_and_restore_focus(socket)} {:noreply,
socket
|> assign(:show_delete_modal, false)
|> assign(:name_confirmation, "")}
end end
def handle_event("window_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
if socket.assigns[:show_delete_modal] do
{:noreply, close_delete_modal_and_restore_focus(socket)}
else
{:noreply, socket}
end
end
def handle_event("window_keydown", _params, socket), do: {:noreply, socket}
def handle_event("dialog_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
{:noreply, close_delete_modal_and_restore_focus(socket)}
end
def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket}
@impl true @impl true
def handle_event("update_name_confirmation", %{"name" => name}, socket) do def handle_event("update_name_confirmation", %{"name" => name}, socket) do
{:noreply, assign(socket, :name_confirmation, name)} {:noreply, assign(socket, :name_confirmation, name)}
@ -959,13 +929,6 @@ defmodule MvWeb.GroupLive.Show do
end end
end end
defp close_delete_modal_and_restore_focus(socket) do
socket
|> assign(:show_delete_modal, false)
|> assign(:name_confirmation, "")
|> push_event("focus_restore", %{id: "delete-group-trigger"})
end
defp perform_group_deletion(socket, group, actor) do defp perform_group_deletion(socket, group, actor) do
case Membership.destroy_group(group, actor: actor) do case Membership.destroy_group(group, actor: actor) do
:ok -> :ok ->

View file

@ -25,7 +25,7 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
~H""" ~H"""
<div id={@id}> <div id={@id}>
<p :if={!@show_form} class="text-sm text-base-content/70 mb-4"> <p class="text-sm text-base-content/70 mb-4">
{gettext( {gettext(
"These fields are neccessary for MILA to handle member identification and payment calculations in the future. Thus you cannot delete these fields but hide them in the member overview." "These fields are neccessary for MILA to handle member identification and payment calculations in the future. Thus you cannot delete these fields but hide them in the member overview."
)} )}
@ -52,7 +52,6 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
:if={!@show_form} :if={!@show_form}
id="member_fields" id="member_fields"
rows={@member_fields} rows={@member_fields}
row_id={fn {field_name, _field_data} -> "member_field-#{field_name}" end}
row_click={ row_click={
fn {field_name, _field_data} -> fn {field_name, _field_data} ->
JS.push("edit_member_field", value: %{"field" => field_name}, target: @myself) JS.push("edit_member_field", value: %{"field" => field_name}, target: @myself)
@ -86,12 +85,12 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
label={gettext("Show in overview")} label={gettext("Show in overview")}
class="max-w-[9.375rem] text-center" class="max-w-[9.375rem] text-center"
> >
<.badge :if={field_data.show_in_overview} variant="success"> <span :if={field_data.show_in_overview} class="badge badge-success">
{gettext("Yes")} {gettext("Yes")}
</.badge> </span>
<.badge :if={!field_data.show_in_overview} variant="neutral"> <span :if={!field_data.show_in_overview} class="badge badge-ghost">
{gettext("No")} {gettext("No")}
</.badge> </span>
</:col> </:col>
</.table> </.table>
</div> </div>
@ -100,8 +99,8 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
@impl true @impl true
def update(assigns, socket) do def update(assigns, socket) do
# Use socket state so send_update(show_form: false) is the only trigger for "form closed" # Track previous show_form state to detect when form is closed
previous_show_form = socket.assigns[:show_form] || false previous_show_form = Map.get(socket.assigns, :show_form, false)
# If show_form is explicitly provided in assigns, reset editing state # If show_form is explicitly provided in assigns, reset editing state
socket = socket =
@ -113,22 +112,20 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
socket socket
end end
socket = # Detect when form is closed (show_form changes from true to false)
socket new_show_form = Map.get(assigns, :show_form, false)
|> assign(assigns)
|> assign_new(:settings, fn -> get_settings() end)
|> assign_new(:show_form, fn -> false end)
|> assign_new(:form_id, fn -> "member-field-form-new" end)
|> assign_new(:editing_member_field, fn -> nil end)
# Detect form closed only from final socket state (not from assigns alone) if previous_show_form and not new_show_form do
current_show_form = socket.assigns[:show_form] || false
if previous_show_form and not current_show_form do
send(self(), {:editing_section_changed, nil}) send(self(), {:editing_section_changed, nil})
end end
{:ok, socket} {:ok,
socket
|> assign(assigns)
|> assign_new(:settings, fn -> get_settings() end)
|> assign_new(:show_form, fn -> false end)
|> assign_new(:form_id, fn -> "member-field-form-new" end)
|> assign_new(:editing_member_field, fn -> nil end)}
end end
@impl true @impl true

View file

@ -38,312 +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">
<div <.header>
id="member-form-focus-root" <:leading>
phx-hook="FocusRestore" <.button navigate={return_path(@return_to, @member)} variant="neutral">
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil} <.icon name="hero-arrow-left" class="size-4" />
> {gettext("Back")}
<.header> </.button>
<:leading> </:leading>
<.button navigate={return_path(@return_to, @member)} variant="neutral"> <%= if @member do %>
<.icon name="hero-arrow-left" class="size-4" /> {MvWeb.Helpers.MemberHelpers.display_name(@member)}
{gettext("Back")} <% else %>
</.button> {gettext("New Member")}
</:leading> <% end %>
<%= if @member do %> <:actions>
{MvWeb.Helpers.MemberHelpers.display_name(@member)} <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
<% else %> {gettext("Save")}
{gettext("New Member")} </.button>
<% end %> </:actions>
<:actions> </.header>
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save")}
</.button>
</:actions>
</.header>
<div class="mt-6 space-y-6"> <div class="mt-6 space-y-6">
<%!-- Tab navigation: same styling as member show; only Contact Data tab (no Membership Fees on edit) --%> <%!-- Tab navigation: Payments tab not shown on new/edit (only on member show) --%>
<div <div role="tablist" class="tabs tabs-bordered">
role="tablist" <button type="button" role="tab" class="tab tab-active" aria-selected="true">
class="tabs tabs-bordered tabs-lg bg-base-200/60 rounded-box p-1 w-fit" <.icon name="hero-identification" class="size-4 mr-2" />
> {gettext("Contact Data")}
<button </button>
id="member-tab-contact" </div>
role="tab"
type="button" <%!-- Personal Data and Custom Fields Row --%>
tabindex="0" <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
aria-selected="true" <%!-- Personal Data Section --%>
aria-controls="member-tabpanel-contact" <div>
class="tab tab-active flex items-center gap-2" <.form_section title={gettext("Personal Data")}>
> <div class="space-y-4">
<.icon name="hero-identification" class="size-4 shrink-0" /> <%!-- Name Row --%>
{gettext("Contact Data")} <div class="flex gap-4">
</button> <div class="w-48">
<.input
field={@form[:first_name]}
label={gettext("First Name")}
required={@member_field_required_map[:first_name]}
/>
</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 --%>
<div class="flex gap-4">
<div class="w-48">
<.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>
<%!-- Street and Nr. below --%>
<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">
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
</div>
<%!-- Membership Dates Row --%>
<div class="flex gap-4">
<div class="w-36">
<.input
field={@form[:join_date]}
label={gettext("Join Date")}
type="date"
required={@member_field_required_map[:join_date]}
/>
</div>
<div class="w-36">
<.input
field={@form[:exit_date]}
label={gettext("Exit Date")}
type="date"
required={@member_field_required_map[:exit_date]}
/>
</div>
</div>
<%!-- Notes --%>
<div>
<.input
field={@form[:notes]}
label={gettext("Notes")}
type="textarea"
required={@member_field_required_map[:notes]}
/>
</div>
</div>
</.form_section>
</div> </div>
<%!-- Contact Data Tab Content (same structure as member show) --%> <%!-- Custom Fields Section --%>
<div <%= if Enum.any?(@custom_fields) do %>
id="member-tabpanel-contact" <div>
role="tabpanel" <.form_section title={gettext("Custom Fields")}>
aria-labelledby="member-tab-contact" <div class="grid grid-cols-2 gap-4">
> <%!-- Render in sorted order by finding the form for each sorted custom field --%>
<%!-- Personal Data and Custom Fields Row --%> <%= for cf <- @sorted_custom_fields do %>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6"> <.inputs_for :let={f_cfv} field={@form[:custom_field_values]}>
<%!-- Personal Data Section --%> <%= if f_cfv[:custom_field_id].value == cf.id do %>
<div> <div class={if cf.value_type == :boolean, do: "flex items-end", else: ""}>
<.form_section title={gettext("Personal Data")}> <.inputs_for :let={value_form} field={f_cfv[:value]}>
<div class="space-y-4"> <.input
<%!-- Name Row --%> field={value_form[:value]}
<div class="flex gap-4"> label={cf.name}
<div class="w-48"> type={custom_field_input_type(cf.value_type)}
<.input required={cf.required}
field={@form[:first_name]} />
label={gettext("First Name")} </.inputs_for>
required={@member_field_required_map[:first_name]} <input
/> type="hidden"
</div> name={f_cfv[:custom_field_id].name}
<div class="w-48"> value={f_cfv[:custom_field_id].value}
<.input />
field={@form[:last_name]} </div>
label={gettext("Last Name")}
required={@member_field_required_map[:last_name]}
/>
</div>
</div>
<%!-- Address: Country, Postal Code, City in one row --%>
<div class="flex gap-4">
<div class="w-48">
<.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>
<%!-- Street and Nr. below --%>
<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">
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
</div>
<%!-- Membership Dates Row --%>
<div class="flex gap-4">
<div class="w-36">
<.input
field={@form[:join_date]}
label={gettext("Join Date")}
type="date"
required={@member_field_required_map[:join_date]}
/>
</div>
<div class="w-36">
<.input
field={@form[:exit_date]}
label={gettext("Exit Date")}
type="date"
required={@member_field_required_map[:exit_date]}
/>
</div>
</div>
<%!-- Notes --%>
<div>
<.input
field={@form[:notes]}
label={gettext("Notes")}
type="textarea"
required={@member_field_required_map[:notes]}
/>
</div>
</div>
</.form_section>
</div>
<%!-- Custom Fields Section --%>
<%= if Enum.any?(@custom_fields) do %>
<div>
<.form_section title={gettext("Custom Fields")}>
<div class="grid grid-cols-2 gap-4">
<%!-- Render in sorted order by finding the form for each sorted custom field --%>
<%= for cf <- @sorted_custom_fields do %>
<.inputs_for :let={f_cfv} field={@form[:custom_field_values]}>
<%= if f_cfv[:custom_field_id].value == cf.id do %>
<div class={
if cf.value_type == :boolean, do: "flex items-end", else: ""
}>
<.inputs_for :let={value_form} field={f_cfv[:value]}>
<.input
field={value_form[:value]}
label={cf.name}
type={custom_field_input_type(cf.value_type)}
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 %> <% end %>
</div> </.inputs_for>
</.form_section> <% end %>
</div>
<% end %>
</div>
<%!-- Membership Fee Section --%>
<div class="max-w-xl">
<.form_section title={gettext("Membership Fee")}>
<div class="space-y-4">
<div>
<label class="label">
<span class="label-text font-semibold">{gettext("Membership Fee Type")}</span>
</label>
<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(
"Select a membership fee type for this member. Members can only switch between types with the same interval."
)}
</p>
</div>
</div> </div>
</.form_section> </.form_section>
</div> </div>
<% end %>
</div>
<%!-- Bottom Action Buttons --%> <%!-- Membership Fee Section --%>
<div class="flex justify-end gap-4 mt-6"> <div class="max-w-xl">
<.button navigate={return_path(@return_to, @member)} variant="neutral" type="button"> <.form_section title={gettext("Membership Fee")}>
{gettext("Cancel")} <div class="space-y-4">
</.button> <div>
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit"> <label class="label">
{gettext("Save Member")} <span class="label-text font-semibold">{gettext("Membership Fee Type")}</span>
</label>
<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(
"Select a membership fee type for this member. Members can only switch between types with the same interval."
)}
</p>
</div>
</div>
</.form_section>
</div>
<%!-- Bottom Action Buttons --%>
<div class="flex justify-end gap-4 mt-6">
<.button navigate={return_path(@return_to, @member)} variant="neutral" type="button">
{gettext("Cancel")}
</.button>
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save Member")}
</.button>
</div>
<%!-- Danger zone: same section pattern as MemberLive.Show (canonical) --%>
<%= if @member && can?(@current_user, :destroy, @member) 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 member cannot be undone. All related data (e.g. membership fee cycles) will be removed."
)}
</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> </.button>
</div> </div>
</section>
<%!-- Danger zone: same section pattern as MemberLive.Show (canonical) --%> <% end %>
<%= if @member && can?(@current_user, :destroy, @member) 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 member cannot be undone. All related data (e.g. membership fee cycles) will be removed."
)}
</p>
<.button
id="delete-member-form-trigger"
variant="danger"
type="button"
phx-click="open_delete_modal"
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 %>
<%!-- Delete Member Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%>
<%= if @member && assigns[:show_delete_modal] do %>
<dialog
id="delete-member-form-modal"
class="modal modal-open"
role="dialog"
aria-labelledby="delete-member-form-modal-title"
phx-keydown="dialog_keydown"
>
<div class="modal-box">
<h3 id="delete-member-form-modal-title" class="text-lg font-bold">
{gettext("Delete Member")}
</h3>
<p class="py-4">
{gettext(
"Are you sure you want to delete %{name}? This action cannot be undone.",
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
)}
</p>
<div class="modal-action">
<.button
type="button"
variant="neutral"
phx-click="cancel_delete_modal"
phx-mounted={JS.focus()}
id="delete-member-form-modal-cancel"
aria-label={gettext("Cancel")}
>
{gettext("Cancel")}
</.button>
<.button
type="button"
variant="danger"
phx-click={JS.push("delete", value: %{id: @member.id})}
aria-label={gettext("Delete member")}
>
{gettext("Delete")}
</.button>
</div>
</div>
</dialog>
<% end %>
</div>
</div>
</div> </div>
</.form> </.form>
</Layouts.app> </Layouts.app>
@ -393,7 +329,6 @@ defmodule MvWeb.MemberLive.Form do
|> assign(:available_fee_types, available_fee_types) |> assign(:available_fee_types, available_fee_types)
|> assign(:interval_warning, nil) |> assign(:interval_warning, nil)
|> assign(:member_field_required_map, member_field_required_map) |> assign(:member_field_required_map, member_field_required_map)
|> assign_new(:show_delete_modal, fn -> false end)
|> assign_form()} |> assign_form()}
end end
@ -465,32 +400,6 @@ defmodule MvWeb.MemberLive.Form do
end end
end end
@impl true
def handle_event("open_delete_modal", _params, socket) do
{:noreply, assign(socket, :show_delete_modal, true)}
end
@impl true
def handle_event("cancel_delete_modal", _params, socket) do
{:noreply, close_delete_modal_and_restore_focus(socket)}
end
def handle_event("dialog_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
{:noreply, close_delete_modal_and_restore_focus(socket)}
end
def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket}
def handle_event("window_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
if socket.assigns[:show_delete_modal] do
{:noreply, close_delete_modal_and_restore_focus(socket)}
else
{:noreply, socket}
end
end
def handle_event("window_keydown", _params, socket), do: {:noreply, socket}
@impl true @impl true
def handle_event("delete", %{"id" => id}, socket) do def handle_event("delete", %{"id" => id}, socket) do
member = socket.assigns.member member = socket.assigns.member
@ -498,16 +407,10 @@ defmodule MvWeb.MemberLive.Form do
cond do cond do
is_nil(member) -> is_nil(member) ->
{:noreply, {:noreply, put_flash(socket, :error, gettext("Member not found"))}
socket
|> put_flash(:error, gettext("Member not found"))
|> assign(:show_delete_modal, false)}
to_string(id) != to_string(member.id) -> to_string(id) != to_string(member.id) ->
{:noreply, {:noreply, put_flash(socket, :error, gettext("Member not found"))}
socket
|> put_flash(:error, gettext("Member not found"))
|> assign(:show_delete_modal, false)}
true -> true ->
handle_member_delete_destroy(socket, member, actor) handle_member_delete_destroy(socket, member, actor)
@ -524,26 +427,14 @@ defmodule MvWeb.MemberLive.Form do
{:error, %Ash.Error.Forbidden{}} -> {:error, %Ash.Error.Forbidden{}} ->
{:noreply, {:noreply,
socket put_flash(socket, :error, gettext("You do not have permission to delete this member"))}
|> put_flash(:error, gettext("You do not have permission to delete this member"))
|> assign(:show_delete_modal, false)}
{:error, error} -> {:error, error} ->
Logger.warning("Member delete failed: member_id=#{member.id} error=#{inspect(error)}") Logger.warning("Member delete failed: member_id=#{member.id} error=#{inspect(error)}")
{:noreply, put_flash(socket, :error, format_destroy_error(error))}
{:noreply,
socket
|> put_flash(:error, format_destroy_error(error))
|> assign(:show_delete_modal, false)}
end end
end end
defp close_delete_modal_and_restore_focus(socket) do
socket
|> assign(:show_delete_modal, false)
|> push_event("focus_restore", %{id: "delete-member-form-trigger"})
end
defp handle_save_success(socket, member) do defp handle_save_success(socket, member) do
notify_parent({:saved, member}) notify_parent({:saved, member})

View file

@ -1644,13 +1644,11 @@ defmodule MvWeb.MemberLive.Index do
selected_count = Enum.count(members, &MapSet.member?(selected_members, &1.id)) selected_count = Enum.count(members, &MapSet.member?(selected_members, &1.id))
any_selected? = Enum.any?(members, &MapSet.member?(selected_members, &1.id)) any_selected? = Enum.any?(members, &MapSet.member?(selected_members, &1.id))
# RFC 6068: mailto URI params must use %20 for spaces, not + (encode_www_form uses +)
mailto_bcc = mailto_bcc =
if any_selected? do if any_selected? do
format_selected_member_emails(members, selected_members) format_selected_member_emails(members, selected_members)
|> Enum.join(", ") |> Enum.join(", ")
|> URI.encode_www_form() |> URI.encode_www_form()
|> String.replace("+", "%20")
else else
"" ""
end end

View file

@ -356,24 +356,26 @@
""" """
} }
> >
<.maybe_value value={member.membership_fee_type} empty_sr_text={gettext("Not specified")}> <%= if member.membership_fee_type do %>
{member.membership_fee_type.name} {member.membership_fee_type.name}
</.maybe_value> <% else %>
<span class="text-base-content/50">—</span>
<% end %>
</:col> </:col>
<:col <:col
:let={member} :let={member}
:if={:membership_fee_status in @member_fields_visible} :if={:membership_fee_status in @member_fields_visible}
label={gettext("Membership Fee Status")} label={gettext("Membership Fee Status")}
> >
<%= if badge = MembershipFeeStatus.format_cycle_status_badge( <%= if badge = MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge(
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 %>
<.badge variant={badge.variant}> <span class={["badge", badge.color]}>
<.icon name={badge.icon} class="size-4" /> <.icon name={badge.icon} class="size-4" />
{badge.label} {badge.label}
</.badge> </span>
<% else %> <% else %>
<.empty_cell sr_text={gettext("No cycle")} /> <span class="badge badge-ghost">{gettext("No cycle")}</span>
<% end %> <% end %>
</:col> </:col>
<:col <:col
@ -392,17 +394,17 @@
""" """
} }
> >
<.maybe_value value={member.groups} empty_sr_text={gettext("No group assignment")}> <%= for group <- (member.groups || []) do %>
<%= for group <- (member.groups || []) do %> <span
<.badge class="badge badge-outline badge-primary"
variant="primary" aria-label={gettext("Member of group %{name}", name: group.name)}
style="outline" >
aria-label={gettext("Member of group %{name}", name: group.name)} {group.name}
> </span>
{group.name} <% end %>
</.badge> <%= if (member.groups || []) == [] do %>
<% end %> <span class="text-base-content/50">—</span>
</.maybe_value> <% end %>
</:col> </:col>
<:action :let={member}> <:action :let={member}>
<div class="sr-only"> <div class="sr-only">

View file

@ -55,26 +55,18 @@ defmodule MvWeb.MemberLive.Show do
</:actions> </:actions>
</.header> </.header>
<div <div class="mt-6 space-y-6">
id="member-show-focus-root" <%!-- Tab Navigation: surface only behind buttons (inline); tabs-bordered; tabs-lg; both tabs keyboard-focusable --%>
class="mt-6 space-y-6"
phx-hook="FocusRestore"
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
>
<%!-- Tab Navigation: roving tabindex (only active tab tabindex="0"), ArrowLeft/ArrowRight (WCAG tab pattern) --%>
<div <div
id="member-tablist"
role="tablist" role="tablist"
class="tabs tabs-bordered tabs-lg bg-base-200/60 rounded-box p-1 w-fit" class="tabs tabs-bordered tabs-lg bg-base-200/60 rounded-box p-1 w-fit"
phx-hook="TabListKeydown"
phx-keydown="tab_keydown"
> >
<button <button
id="member-tab-contact" id="member-tab-contact"
role="tab" role="tab"
type="button" type="button"
tabindex={if @active_tab == :contact, do: "0", else: "-1"} tabindex="0"
aria-selected={if @active_tab == :contact, do: "true", else: "false"} aria-selected={@active_tab == :contact}
aria-controls="member-tabpanel-contact" aria-controls="member-tabpanel-contact"
class={[ class={[
"tab flex items-center gap-2", "tab flex items-center gap-2",
@ -90,8 +82,8 @@ defmodule MvWeb.MemberLive.Show do
id="member-tab-membership_fees" id="member-tab-membership_fees"
role="tab" role="tab"
type="button" type="button"
tabindex={if @active_tab == :membership_fees, do: "0", else: "-1"} tabindex="0"
aria-selected={if @active_tab == :membership_fees, do: "true", else: "false"} aria-selected={@active_tab == :membership_fees}
aria-controls="member-tabpanel-membership_fees" aria-controls="member-tabpanel-membership_fees"
class={[ class={[
"tab flex items-center gap-2", "tab flex items-center gap-2",
@ -262,24 +254,22 @@ defmodule MvWeb.MemberLive.Show do
/> />
<.data_field label={gettext("Last Cycle")} class="min-w-32"> <.data_field label={gettext("Last Cycle")} class="min-w-32">
<%= if @member.last_cycle_status do %> <%= if @member.last_cycle_status do %>
<.badge variant={ <% status = @member.last_cycle_status %>
MembershipFeeHelpers.status_variant(@member.last_cycle_status) <span class={["badge", MembershipFeeHelpers.status_color(status)]}>
}> {format_status_label(status)}
{format_status_label(@member.last_cycle_status)} </span>
</.badge>
<% else %> <% else %>
<.badge variant="neutral">{gettext("No cycles")}</.badge> <span class="badge badge-ghost">{gettext("No cycles")}</span>
<% end %> <% end %>
</.data_field> </.data_field>
<.data_field label={gettext("Current Cycle")} class="min-w-36"> <.data_field label={gettext("Current Cycle")} class="min-w-36">
<%= if @member.current_cycle_status do %> <%= if @member.current_cycle_status do %>
<.badge variant={ <% status = @member.current_cycle_status %>
MembershipFeeHelpers.status_variant(@member.current_cycle_status) <span class={["badge", MembershipFeeHelpers.status_color(status)]}>
}> {format_status_label(status)}
{format_status_label(@member.current_cycle_status)} </span>
</.badge>
<% else %> <% else %>
<.badge variant="neutral">{gettext("No cycles")}</.badge> <span class="badge badge-ghost">{gettext("No cycles")}</span>
<% end %> <% end %>
</.data_field> </.data_field>
</div> </div>
@ -323,9 +313,14 @@ defmodule MvWeb.MemberLive.Show do
)} )}
</p> </p>
<.button <.button
id="delete-member-trigger"
variant="danger" variant="danger"
phx-click="open_delete_modal" 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" data-testid="member-delete"
aria-label={ aria-label={
gettext("Delete member %{name}", gettext("Delete member %{name}",
@ -339,48 +334,6 @@ defmodule MvWeb.MemberLive.Show do
</div> </div>
</section> </section>
<% end %> <% end %>
<%!-- Delete Member Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%>
<%= if assigns[:show_delete_modal] do %>
<dialog
id="delete-member-modal"
class="modal modal-open"
role="dialog"
aria-labelledby="delete-member-modal-title"
phx-keydown="dialog_keydown"
>
<div class="modal-box">
<h3 id="delete-member-modal-title" class="text-lg font-bold">
{gettext("Delete Member")}
</h3>
<p class="py-4">
{gettext("Are you sure you want to delete %{name}? This action cannot be undone.",
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
)}
</p>
<div class="modal-action">
<.button
type="button"
variant="neutral"
phx-click="cancel_delete_modal"
phx-mounted={JS.focus()}
id="delete-member-modal-cancel"
aria-label={gettext("Cancel")}
>
{gettext("Cancel")}
</.button>
<.button
type="button"
variant="danger"
phx-click={JS.push("delete", value: %{id: @member.id})}
aria-label={gettext("Delete member")}
>
{gettext("Delete")}
</.button>
</div>
</div>
</dialog>
<% end %>
</div> </div>
</Layouts.app> </Layouts.app>
""" """
@ -391,8 +344,7 @@ defmodule MvWeb.MemberLive.Show do
{:ok, {:ok,
socket socket
|> assign(:active_tab, :contact) |> assign(:active_tab, :contact)
|> assign(:vereinfacht_receipts, nil) |> assign(:vereinfacht_receipts, nil)}
|> assign_new(:show_delete_modal, fn -> false end)}
end end
@impl true @impl true
@ -444,58 +396,13 @@ 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("tab_keydown", %{"key" => key}, socket)
when key in ["ArrowLeft", "ArrowRight"] do
new_tab =
case {key, socket.assigns.active_tab} do
{"ArrowRight", :contact} -> :membership_fees
{"ArrowLeft", :membership_fees} -> :contact
_ -> socket.assigns.active_tab
end
{:noreply, assign(socket, :active_tab, new_tab)}
end
def handle_event("tab_keydown", _params, socket), do: {:noreply, socket}
@impl true
def handle_event("open_delete_modal", _params, socket) do
{:noreply, assign(socket, :show_delete_modal, true)}
end
@impl true
def handle_event("cancel_delete_modal", _params, socket) do
{:noreply, close_delete_modal_and_restore_focus(socket)}
end
# Escape closes modal (WCAG). phx-window-keydown ensures Escape is captured regardless of focus.
def handle_event("window_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
if socket.assigns[:show_delete_modal] do
{:noreply, close_delete_modal_and_restore_focus(socket)}
else
{:noreply, socket}
end
end
def handle_event("window_keydown", _params, socket), do: {:noreply, socket}
def handle_event("dialog_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
{:noreply, close_delete_modal_and_restore_focus(socket)}
end
def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket}
@impl true @impl true
def handle_event("delete", %{"id" => id}, socket) do def handle_event("delete", %{"id" => id}, socket) do
member = socket.assigns.member member = socket.assigns.member
actor = current_actor(socket) actor = current_actor(socket)
if to_string(id) != to_string(member.id) do if to_string(id) != to_string(member.id) do
{:noreply, {:noreply, put_flash(socket, :error, gettext("Member not found"))}
socket
|> put_flash(:error, gettext("Member not found"))
|> assign(:show_delete_modal, false)}
else else
case Ash.destroy(member, actor: actor) do case Ash.destroy(member, actor: actor) do
:ok -> :ok ->
@ -506,21 +413,16 @@ defmodule MvWeb.MemberLive.Show do
{:error, %Ash.Error.Forbidden{}} -> {:error, %Ash.Error.Forbidden{}} ->
{:noreply, {:noreply,
socket put_flash(
|> put_flash( socket,
:error, :error,
gettext("You do not have permission to delete this member") gettext("You do not have permission to delete this member")
) )}
|> assign(:show_delete_modal, false)}
{:error, error} -> {:error, error} ->
require Logger require Logger
Logger.warning("Member delete failed: member_id=#{member.id} error=#{inspect(error)}") Logger.warning("Member delete failed: member_id=#{member.id} error=#{inspect(error)}")
{:noreply, put_flash(socket, :error, format_error(error))}
{:noreply,
socket
|> put_flash(:error, format_error(error))
|> assign(:show_delete_modal, false)}
end end
end end
end end
@ -535,13 +437,6 @@ defmodule MvWeb.MemberLive.Show do
{:noreply, assign(socket, :vereinfacht_receipts, response)} {:noreply, assign(socket, :vereinfacht_receipts, response)}
end end
# WCAG 2.4.3: when modal closes, return focus to the trigger (Delete member button)
defp close_delete_modal_and_restore_focus(socket) do
socket
|> assign(:show_delete_modal, false)
|> push_event("focus_restore", %{id: "delete-member-trigger"})
end
# Flash set in LiveComponent is not shown in parent layout; child sends this to display flash # Flash set in LiveComponent is not shown in parent layout; child sends this to display flash
@impl true @impl true
def handle_info({:put_flash, type, message}, socket) do def handle_info({:put_flash, type, message}, socket) do
@ -608,11 +503,7 @@ defmodule MvWeb.MemberLive.Show do
<%= if @inner_block != [] do %> <%= if @inner_block != [] do %>
{render_slot(@inner_block)} {render_slot(@inner_block)}
<% else %> <% else %>
<%= if value_blank?(@value) do %> {display_value(@value)}
<.empty_cell sr_text={gettext("Not set")} />
<% else %>
{@value}
<% end %>
<% end %> <% end %>
</dd> </dd>
</dl> </dl>
@ -646,9 +537,9 @@ defmodule MvWeb.MemberLive.Show do
# Helper Functions # Helper Functions
# ----------------------------------------------------------------- # -----------------------------------------------------------------
defp value_blank?(nil), do: true defp display_value(nil), do: render_empty_value()
defp value_blank?(v) when is_binary(v), do: String.trim(v) == "" defp display_value(""), do: render_empty_value()
defp value_blank?(_), do: false defp display_value(value), do: value
defp format_status_label(:paid), do: gettext("Paid") defp format_status_label(:paid), do: gettext("Paid")
defp format_status_label(:unpaid), do: gettext("Unpaid") defp format_status_label(:unpaid), do: gettext("Unpaid")
@ -737,10 +628,10 @@ defmodule MvWeb.MemberLive.Show do
if String.trim(value) == "" do if String.trim(value) == "" do
render_empty_value() render_empty_value()
else else
assigns = %{email: value, display: value} assigns = %{email: value}
~H""" ~H"""
<.mailto_link email={@email} display={@display} /> <.mailto_link email={@email} display={@email} />
""" """
end end
end end
@ -755,10 +646,17 @@ defmodule MvWeb.MemberLive.Show do
defp format_custom_field_value(value, _type), do: to_string(value) defp format_custom_field_value(value, _type), do: to_string(value)
# Renders accessible empty value: visually empty, screen-reader text only (see Design Guidelines §8.6). # Renders accessible placeholder for empty values
# Returns safe HTML so it can be used from helpers without LiveView assigns. # Uses translated text for screen readers while maintaining visual consistency
# The visual "—" is hidden from screen readers, while the translated text is only visible to screen readers
defp render_empty_value do defp render_empty_value do
text = gettext("Not set") assigns = %{text: gettext("Not set")}
{:safe, ["<span class=\"sr-only\">", Phoenix.HTML.Engine.html_escape(text), "</span>"]}
~H"""
<span class="text-base-content/50 italic">
<span aria-hidden="true"></span>
<span class="sr-only">{@text}</span>
</span>
"""
end end
end end

View file

@ -101,13 +101,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
<%= for r <- receipts do %> <%= for r <- receipts do %>
<tr> <tr>
<%= for {col_key, _header_key} <- cols do %> <%= for {col_key, _header_key} <- cols do %>
<td> <td>{format_receipt_cell(col_key, r[col_key])}</td>
<%= if (cell_content = format_receipt_cell(col_key, r[col_key])) != nil do %>
{cell_content}
<% else %>
<.empty_cell sr_text={receipt_empty_sr_text(col_key)} />
<% end %>
</td>
<% end %> <% end %>
</tr> </tr>
<% end %> <% end %>
@ -192,9 +186,9 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
</:col> </:col>
<:col :let={cycle} label={gettext("Interval")}> <:col :let={cycle} label={gettext("Interval")}>
<.badge variant="neutral" style="outline"> <span class="badge badge-outline">
{MembershipFeeHelpers.format_interval(cycle.membership_fee_type.interval)} {MembershipFeeHelpers.format_interval(cycle.membership_fee_type.interval)}
</.badge> </span>
</:col> </:col>
<:col :let={cycle} label={gettext("Amount")}> <:col :let={cycle} label={gettext("Amount")}>
@ -214,10 +208,12 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
</:col> </:col>
<:col :let={cycle} label={gettext("Status")}> <:col :let={cycle} label={gettext("Status")}>
<.badge variant={MembershipFeeHelpers.status_variant(cycle.status)}> <% badge = MembershipFeeHelpers.status_color(cycle.status) %>
<.icon name={MembershipFeeHelpers.status_icon(cycle.status)} class="size-4" /> <% icon = MembershipFeeHelpers.status_icon(cycle.status) %>
<span class={["badge", badge]}>
<.icon name={icon} class="size-4" />
{format_status_label(cycle.status)} {format_status_label(cycle.status)}
</.badge> </span>
</:col> </:col>
<:action :let={cycle}> <:action :let={cycle}>
@ -231,7 +227,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
phx-value-status="paid" phx-value-status="paid"
phx-target={@myself} phx-target={@myself}
class={cycle_status_btn_class(cycle.status, :paid)} class={cycle_status_btn_class(cycle.status, :paid)}
aria-pressed={if cycle.status == :paid, do: "true", else: "false"} aria-pressed={cycle.status == :paid}
title={gettext("Mark as paid")} title={gettext("Mark as paid")}
> >
<.icon name="hero-check-circle" class="size-4" /> <.icon name="hero-check-circle" class="size-4" />
@ -244,7 +240,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
phx-value-status="suspended" phx-value-status="suspended"
phx-target={@myself} phx-target={@myself}
class={cycle_status_btn_class(cycle.status, :suspended)} class={cycle_status_btn_class(cycle.status, :suspended)}
aria-pressed={if cycle.status == :suspended, do: "true", else: "false"} aria-pressed={cycle.status == :suspended}
title={gettext("Mark as suspended")} title={gettext("Mark as suspended")}
> >
<.icon name="hero-pause-circle" class="size-4" /> <.icon name="hero-pause-circle" class="size-4" />
@ -257,7 +253,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
phx-value-status="unpaid" phx-value-status="unpaid"
phx-target={@myself} phx-target={@myself}
class={cycle_status_btn_class(cycle.status, :unpaid)} class={cycle_status_btn_class(cycle.status, :unpaid)}
aria-pressed={if cycle.status == :unpaid, do: "true", else: "false"} aria-pressed={cycle.status == :unpaid}
title={gettext("Mark as unpaid")} title={gettext("Mark as unpaid")}
> >
<.icon name="hero-x-circle" class="size-4" /> <.icon name="hero-x-circle" class="size-4" />
@ -294,19 +290,11 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
<% end %> <% end %>
</.section_box> </.section_box>
<%!-- Edit Cycle Amount Modal (WCAG: focus in modal, aria-labelledby) --%> <%!-- Edit Cycle Amount Modal --%>
<%= if @editing_cycle do %> <%= if @editing_cycle do %>
<dialog <dialog id="edit-cycle-amount-modal" class="modal modal-open">
id="edit-cycle-amount-modal"
class="modal modal-open"
role="dialog"
aria-labelledby="edit-cycle-amount-modal-title"
phx-keydown="dialog_keydown"
>
<div class="modal-box"> <div class="modal-box">
<h3 id="edit-cycle-amount-modal-title" class="text-lg font-bold"> <h3 class="text-lg font-bold">{gettext("Edit Cycle Amount")}</h3>
{gettext("Edit Cycle Amount")}
</h3>
<form phx-submit="save_cycle_amount" phx-target={@myself}> <form phx-submit="save_cycle_amount" phx-target={@myself}>
<input type="hidden" name="cycle_id" value={@editing_cycle.id} /> <input type="hidden" name="cycle_id" value={@editing_cycle.id} />
<div class="form-control w-full mt-4"> <div class="form-control w-full mt-4">
@ -322,7 +310,6 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
value={Decimal.to_string(@editing_cycle.amount) |> String.replace(".", ",")} value={Decimal.to_string(@editing_cycle.amount) |> String.replace(".", ",")}
class="input input-bordered w-full" class="input input-bordered w-full"
required required
phx-mounted={JS.focus()}
/> />
</div> </div>
<div class="modal-action"> <div class="modal-action">
@ -341,17 +328,11 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
</dialog> </dialog>
<% end %> <% end %>
<%!-- Delete Cycle Confirmation Modal (WCAG: focus in modal, aria-labelledby) --%> <%!-- Delete Cycle Confirmation Modal --%>
<%= if @deleting_cycle do %> <%= if @deleting_cycle do %>
<dialog <dialog id="delete-cycle-modal" class="modal modal-open">
id="delete-cycle-modal"
class="modal modal-open"
role="dialog"
aria-labelledby="delete-cycle-modal-title"
phx-keydown="dialog_keydown"
>
<div class="modal-box"> <div class="modal-box">
<h3 id="delete-cycle-modal-title" class="text-lg font-bold">{gettext("Delete Cycle")}</h3> <h3 class="text-lg font-bold">{gettext("Delete Cycle")}</h3>
<p class="py-4"> <p class="py-4">
{gettext("Are you sure you want to delete this cycle?")} {gettext("Are you sure you want to delete this cycle?")}
</p> </p>
@ -362,12 +343,7 @@ 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 <.button variant="neutral" phx-click="cancel_delete_cycle" phx-target={@myself}>
variant="neutral"
phx-click="cancel_delete_cycle"
phx-target={@myself}
phx-mounted={JS.focus()}
>
{gettext("Cancel")} {gettext("Cancel")}
</.button> </.button>
<.button <.button
@ -383,19 +359,11 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
</dialog> </dialog>
<% end %> <% end %>
<%!-- Delete All Cycles Confirmation Modal (WCAG: focus in modal, aria-labelledby) --%> <%!-- Delete All Cycles Confirmation Modal --%>
<%= if @deleting_all_cycles do %> <%= if @deleting_all_cycles do %>
<dialog <dialog id="delete-all-cycles-modal" class="modal modal-open">
id="delete-all-cycles-modal"
class="modal modal-open"
role="dialog"
aria-labelledby="delete-all-cycles-modal-title"
phx-keydown="dialog_keydown"
>
<div class="modal-box"> <div class="modal-box">
<h3 id="delete-all-cycles-modal-title" class="text-lg font-bold text-error"> <h3 class="text-lg font-bold text-error">{gettext("Delete All Cycles")}</h3>
{gettext("Delete All Cycles")}
</h3>
<div class="alert alert-warning mt-4"> <div class="alert alert-warning mt-4">
<.icon name="hero-exclamation-triangle" class="size-5" /> <.icon name="hero-exclamation-triangle" class="size-5" />
<div> <div>
@ -423,7 +391,6 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
value={@delete_all_confirmation || ""} value={@delete_all_confirmation || ""}
class="input input-bordered w-full" class="input input-bordered w-full"
placeholder={gettext("Yes")} placeholder={gettext("Yes")}
phx-mounted={JS.focus()}
/> />
</div> </div>
<div class="modal-action"> <div class="modal-action">
@ -446,17 +413,11 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
</dialog> </dialog>
<% end %> <% end %>
<%!-- Create Cycle Modal (WCAG: focus in modal, aria-labelledby) --%> <%!-- Create Cycle Modal --%>
<%= if @creating_cycle do %> <%= if @creating_cycle do %>
<dialog <dialog id="create-cycle-modal" class="modal modal-open">
id="create-cycle-modal"
class="modal modal-open"
role="dialog"
aria-labelledby="create-cycle-modal-title"
phx-keydown="dialog_keydown"
>
<div class="modal-box"> <div class="modal-box">
<h3 id="create-cycle-modal-title" class="text-lg font-bold">{gettext("Create Cycle")}</h3> <h3 class="text-lg font-bold">{gettext("Create Cycle")}</h3>
<form phx-submit="create_cycle" phx-target={@myself}> <form phx-submit="create_cycle" phx-target={@myself}>
<div class="form-control w-full mt-4"> <div class="form-control w-full mt-4">
<label class="label" for="create-cycle-date"> <label class="label" for="create-cycle-date">
@ -472,7 +433,6 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
class="input input-bordered w-full" class="input input-bordered w-full"
required required
aria-label={gettext("Date")} aria-label={gettext("Date")}
phx-mounted={JS.focus()}
/> />
<label class="label"> <label class="label">
<span class="label-text-alt"> <span class="label-text-alt">
@ -921,35 +881,6 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|> assign(:create_cycle_error, nil)} |> assign(:create_cycle_error, nil)}
end end
def handle_event("dialog_keydown", %{"key" => "Escape"}, socket) do
socket =
cond do
socket.assigns[:editing_cycle] ->
assign(socket, :editing_cycle, nil)
socket.assigns[:deleting_cycle] ->
assign(socket, :deleting_cycle, nil)
socket.assigns[:deleting_all_cycles] ->
socket
|> assign(:deleting_all_cycles, false)
|> assign(:delete_all_confirmation, "")
socket.assigns[:creating_cycle] ->
socket
|> assign(:creating_cycle, false)
|> assign(:create_cycle_date, nil)
|> assign(:create_cycle_error, nil)
true ->
socket
end
{:noreply, socket}
end
def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket}
def handle_event("update_create_cycle_date", %{"date" => date_str}, socket) do def handle_event("update_create_cycle_date", %{"date" => date_str}, socket) do
date = date =
case Date.from_iso8601(date_str) do case Date.from_iso8601(date_str) do
@ -1196,11 +1127,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|> Enum.map(fn {key, msgid} -> {key, Gettext.gettext(MvWeb.Gettext, msgid)} end) |> Enum.map(fn {key, msgid} -> {key, Gettext.gettext(MvWeb.Gettext, msgid)} end)
end end
# Screen-reader text for empty receipt table cells (visually empty, A11y) defp format_receipt_cell(:amount, nil), do: ""
defp receipt_empty_sr_text(:status), do: gettext("Not set")
defp receipt_empty_sr_text(_), do: gettext("Not specified")
defp format_receipt_cell(:amount, nil), do: nil
defp format_receipt_cell(:amount, val) when is_number(val) do defp format_receipt_cell(:amount, val) when is_number(val) do
case Decimal.cast(val) do case Decimal.cast(val) do
@ -1218,7 +1145,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
defp format_receipt_cell(:amount, val), do: to_string(val) defp format_receipt_cell(:amount, val), do: to_string(val)
defp format_receipt_cell(:status, nil), do: nil defp format_receipt_cell(:status, nil), do: ""
defp format_receipt_cell(:status, val) when is_binary(val) do defp format_receipt_cell(:status, val) when is_binary(val) do
translate_receipt_status(val) translate_receipt_status(val)
@ -1226,7 +1153,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
defp format_receipt_cell(:status, val), do: translate_receipt_status(to_string(val)) defp format_receipt_cell(:status, val), do: translate_receipt_status(to_string(val))
defp format_receipt_cell(:receiptType, nil), do: nil defp format_receipt_cell(:receiptType, nil), do: ""
defp format_receipt_cell(:receiptType, val) when is_binary(val) do defp format_receipt_cell(:receiptType, val) when is_binary(val) do
translate_receipt_type(val) translate_receipt_type(val)
@ -1235,7 +1162,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
defp format_receipt_cell(:receiptType, val), do: translate_receipt_type(to_string(val)) defp format_receipt_cell(:receiptType, val), do: translate_receipt_type(to_string(val))
defp format_receipt_cell(col_key, nil) when col_key in [:bookingDate, :createdAt, :updatedAt], defp format_receipt_cell(col_key, nil) when col_key in [:bookingDate, :createdAt, :updatedAt],
do: nil do: ""
defp format_receipt_cell(col_key, val) when col_key in [:bookingDate, :createdAt, :updatedAt] do defp format_receipt_cell(col_key, val) when col_key in [:bookingDate, :createdAt, :updatedAt] do
format_receipt_date(val) format_receipt_date(val)
@ -1296,7 +1223,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
defp translate_receipt_status("draft"), do: gettext("Draft") defp translate_receipt_status("draft"), do: gettext("Draft")
defp translate_receipt_status("incompleted"), do: gettext("Incompleted") defp translate_receipt_status("incompleted"), do: gettext("Incompleted")
defp translate_receipt_status("completed"), do: gettext("Completed") defp translate_receipt_status("completed"), do: gettext("Completed")
defp translate_receipt_status("empty"), do: nil defp translate_receipt_status("empty"), do: ""
defp translate_receipt_status(other), do: other defp translate_receipt_status(other), do: other
# Translate API receipt type values (extend as API returns more values) # Translate API receipt type values (extend as API returns more values)

View file

@ -142,7 +142,7 @@ defmodule MvWeb.MembershipFeeSettingsLive do
<.header> <.header>
{gettext("Membership Fee Settings")} {gettext("Membership Fee Settings")}
<:subtitle> <:subtitle>
{gettext("Configure fee types for membership fees.")} {gettext("Configure global settings and fee types for membership fees.")}
</:subtitle> </:subtitle>
<:actions> <:actions>
<.button variant="primary" navigate={~p"/membership_fee_settings/new_fee_type"}> <.button variant="primary" navigate={~p"/membership_fee_settings/new_fee_type"}>
@ -177,7 +177,7 @@ defmodule MvWeb.MembershipFeeSettingsLive do
id="default_membership_fee_type_id" id="default_membership_fee_type_id"
name="settings[default_membership_fee_type_id]" name="settings[default_membership_fee_type_id]"
class={[ class={[
"select select-bordered", "select select-bordered w-full",
if(@form.errors[:default_membership_fee_type_id], do: "select-error", else: "") if(@form.errors[:default_membership_fee_type_id], do: "select-error", else: "")
]} ]}
phx-debounce="blur" phx-debounce="blur"
@ -323,13 +323,13 @@ defmodule MvWeb.MembershipFeeSettingsLive do
</:col> </:col>
<:col :let={mft} label={gettext("Interval")}> <:col :let={mft} label={gettext("Interval")}>
<.badge variant="neutral" style="outline"> <span class="badge badge-outline">
{MembershipFeeHelpers.format_interval(mft.interval)} {MembershipFeeHelpers.format_interval(mft.interval)}
</.badge> </span>
</:col> </:col>
<:col :let={mft} label={gettext("Members")}> <:col :let={mft} label={gettext("Members")}>
<span class="text-sm">{get_member_count(mft, @member_counts)}</span> <span class="badge badge-ghost">{get_member_count(mft, @member_counts)}</span>
</:col> </:col>
<:action :let={mft}> <:action :let={mft}>

View file

@ -34,6 +34,9 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
</.button> </.button>
</:leading> </:leading>
{@page_title} {@page_title}
<:subtitle>
{gettext("Use this form to manage membership fee types in your database.")}
</:subtitle>
<:actions> <:actions>
<.button <.button
form="membership-fee-type-form" form="membership-fee-type-form"

View file

@ -68,13 +68,13 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
</:col> </:col>
<:col :let={mft} label={gettext("Interval")}> <:col :let={mft} label={gettext("Interval")}>
<.badge variant="neutral" style="outline"> <span class="badge badge-outline">
{MembershipFeeHelpers.format_interval(mft.interval)} {MembershipFeeHelpers.format_interval(mft.interval)}
</.badge> </span>
</:col> </:col>
<:col :let={mft} label={gettext("Members")}> <:col :let={mft} label={gettext("Members")}>
<.badge variant="neutral">{get_member_count(mft, @member_counts)}</.badge> <span class="badge badge-ghost">{get_member_count(mft, @member_counts)}</span>
</:col> </:col>
<:action :let={mft}> <:action :let={mft}>

View file

@ -30,6 +30,7 @@ defmodule MvWeb.RoleLive.Form do
</.button> </.button>
</:leading> </:leading>
{@page_title} {@page_title}
<:subtitle>{gettext("Use this form to manage roles in your database.")}</:subtitle>
<:actions> <:actions>
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit"> <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save")} {gettext("Save")}

View file

@ -18,8 +18,6 @@ defmodule MvWeb.RoleLive.Helpers do
@doc """ @doc """
Returns the CSS badge class for a permission set name. Returns the CSS badge class for a permission set name.
Deprecated for new code: prefer `permission_set_badge_variant/1` with <.badge>.
""" """
@spec permission_set_badge_class(String.t()) :: String.t() @spec permission_set_badge_class(String.t()) :: String.t()
def permission_set_badge_class("own_data"), do: "badge badge-neutral badge-sm" def permission_set_badge_class("own_data"), do: "badge badge-neutral badge-sm"
@ -28,18 +26,6 @@ defmodule MvWeb.RoleLive.Helpers do
def permission_set_badge_class("admin"), do: "badge badge-error badge-sm" def permission_set_badge_class("admin"), do: "badge badge-error badge-sm"
def permission_set_badge_class(_), do: "badge badge-ghost badge-sm" def permission_set_badge_class(_), do: "badge badge-ghost badge-sm"
@doc """
Returns the Core Components badge variant for a permission set name (WCAG-compliant).
Use with <.badge variant={permission_set_badge_variant(permission_set_name)} size="sm">.
"""
@spec permission_set_badge_variant(String.t()) :: :neutral | :info | :success | :error
def permission_set_badge_variant("own_data"), do: :neutral
def permission_set_badge_variant("read_only"), do: :info
def permission_set_badge_variant("normal_user"), do: :success
def permission_set_badge_variant("admin"), do: :error
def permission_set_badge_variant(_), do: :neutral
@doc """ @doc """
Builds Ash options with actor and domain, ensuring actor is never nil in real paths. Builds Ash options with actor and domain, ensuring actor is never nil in real paths.
""" """

View file

@ -18,7 +18,7 @@ defmodule MvWeb.RoleLive.Index do
require Ash.Query require Ash.Query
import MvWeb.RoleLive.Helpers, only: [permission_set_badge_variant: 1] import MvWeb.RoleLive.Helpers, only: [permission_set_badge_class: 1]
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do

View file

@ -16,16 +16,15 @@
<.table <.table
id="roles" id="roles"
rows={@roles} rows={@roles}
row_id={fn role -> "role-#{role.id}" end}
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")} 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">
<span class="font-medium">{role.name}</span> <span class="font-medium">{role.name}</span>
<.badge :if={role.is_system_role} variant="warning" size="sm"> <%= if role.is_system_role do %>
{gettext("System Role")} <span class="badge badge-warning badge-sm">{gettext("System Role")}</span>
</.badge> <% end %>
</div> </div>
</:col> </:col>
@ -38,22 +37,21 @@
</:col> </:col>
<:col :let={role} label={gettext("Permission Set")}> <:col :let={role} label={gettext("Permission Set")}>
<.badge variant={permission_set_badge_variant(role.permission_set_name)} size="sm"> <span class={permission_set_badge_class(role.permission_set_name)}>
{role.permission_set_name} {role.permission_set_name}
</.badge> </span>
</:col> </:col>
<:col :let={role} label={gettext("Type")}> <:col :let={role} label={gettext("Type")}>
<.badge :if={role.is_system_role} variant="warning" size="sm"> <%= if role.is_system_role do %>
{gettext("System")} <span class="badge badge-warning badge-sm">{gettext("System")}</span>
</.badge> <% else %>
<.badge :if={!role.is_system_role} variant="neutral" size="sm"> <span class="badge badge-ghost badge-sm">{gettext("Custom")}</span>
{gettext("Custom")} <% end %>
</.badge>
</:col> </:col>
<:col :let={role} label={gettext("Users")}> <:col :let={role} label={gettext("Users")}>
<span class="text-sm">{get_user_count(role, @user_counts)}</span> <span class="badge badge-ghost">{get_user_count(role, @user_counts)}</span>
</:col> </:col>
</.table> </.table>
</Layouts.app> </Layouts.app>

View file

@ -17,7 +17,7 @@ defmodule MvWeb.RoleLive.Show do
require Ash.Query require Ash.Query
import MvWeb.RoleLive.Helpers, import MvWeb.RoleLive.Helpers,
only: [format_error: 1, permission_set_badge_variant: 1, opts_with_actor: 3] only: [format_error: 1, permission_set_badge_class: 1, opts_with_actor: 3]
@impl true @impl true
def mount(%{"id" => id}, _session, socket) do def mount(%{"id" => id}, _session, socket) do
@ -35,8 +35,7 @@ defmodule MvWeb.RoleLive.Show do
socket socket
|> assign(:page_title, gettext("Show Role")) |> assign(:page_title, gettext("Show Role"))
|> assign(:role, role) |> assign(:role, role)
|> assign(:user_count, user_count) |> assign(:user_count, user_count)}
|> assign(:show_delete_modal, false)}
{:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{} | _]}} -> {:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{} | _]}} ->
{:ok, {:ok,
@ -85,61 +84,35 @@ defmodule MvWeb.RoleLive.Show do
error_message = format_error(error) error_message = format_error(error)
{:noreply, {:noreply,
socket put_flash(
|> put_flash( socket,
:error, :error,
gettext("Failed to delete role: %{error}", error: error_message) gettext("Failed to delete role: %{error}", error: error_message)
) )}
|> assign(:show_delete_modal, false)}
end end
end end
@impl true
def handle_event("open_delete_modal", _params, socket) do
{:noreply, assign(socket, :show_delete_modal, true)}
end
@impl true
def handle_event("cancel_delete_modal", _params, socket) do
{:noreply, close_delete_modal_and_restore_focus(socket)}
end
def handle_event("window_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
if socket.assigns[:show_delete_modal] do
{:noreply, close_delete_modal_and_restore_focus(socket)}
else
{:noreply, socket}
end
end
def handle_event("window_keydown", _params, socket), do: {:noreply, socket}
def handle_event("dialog_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
{:noreply, close_delete_modal_and_restore_focus(socket)}
end
def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket}
defp handle_delete_role(role, socket) do defp handle_delete_role(role, socket) do
if role.is_system_role do if role.is_system_role do
{:noreply, {:noreply,
socket put_flash(
|> put_flash(:error, gettext("System roles cannot be deleted.")) socket,
|> assign(:show_delete_modal, false)} :error,
gettext("System roles cannot be deleted.")
)}
else else
user_count = recalculate_user_count(role, socket.assigns.current_user) user_count = recalculate_user_count(role, socket.assigns.current_user)
if user_count > 0 do if user_count > 0 do
{:noreply, {:noreply,
socket put_flash(
|> put_flash( socket,
:error, :error,
gettext( gettext(
"Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first.", "Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first.",
count: user_count count: user_count
) )
) )}
|> assign(:show_delete_modal, false)}
else else
perform_role_deletion(role, socket) perform_role_deletion(role, socket)
end end
@ -183,12 +156,6 @@ defmodule MvWeb.RoleLive.Show do
recalculate_user_count(role, actor) recalculate_user_count(role, actor)
end end
defp close_delete_modal_and_restore_focus(socket) do
socket
|> assign(:show_delete_modal, false)
|> push_event("focus_restore", %{id: "delete-role-trigger"})
end
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
@ -220,103 +187,59 @@ defmodule MvWeb.RoleLive.Show do
</:actions> </:actions>
</.header> </.header>
<div <.list>
id="role-show-focus-root" <:item title={gettext("Name")}>{@role.name}</:item>
phx-hook="FocusRestore" <:item title={gettext("Description")}>
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil} <%= if @role.description do %>
> {@role.description}
<.list> <% else %>
<:item title={gettext("Name")}>{@role.name}</:item> <span class="text-base-content/70 italic">{gettext("No description")}</span>
<:item title={gettext("Description")}> <% end %>
<%= if @role.description do %> </:item>
{@role.description} <:item title={gettext("Permission Set")}>
<% else %> <span class={permission_set_badge_class(@role.permission_set_name)}>
<span class="text-base-content/70 italic">{gettext("No description")}</span> {@role.permission_set_name}
<% end %> </span>
</:item> </:item>
<:item title={gettext("Permission Set")}> <:item title={gettext("System Role")}>
<.badge variant={permission_set_badge_variant(@role.permission_set_name)}> <%= if @role.is_system_role do %>
{@role.permission_set_name} <span class="badge badge-warning">{gettext("Yes")}</span>
</.badge> <% else %>
</:item> <span class="badge badge-ghost">{gettext("No")}</span>
<:item title={gettext("System Role")}> <% end %>
<.badge :if={@role.is_system_role} variant="warning"> </:item>
{gettext("Yes")} </.list>
</.badge>
<.badge :if={!@role.is_system_role} variant="neutral">
{gettext("No")}
</.badge>
</:item>
</.list>
<%!-- Danger zone: canonical pattern (same as member show) --%> <%!-- Danger zone: canonical pattern (same as member show) --%>
<%= if can?(@current_user, :destroy, Mv.Authorization.Role) and not @role.is_system_role do %> <%= 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"> <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"> <h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
{gettext("Danger zone")} {gettext("Danger zone")}
</h2> </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="text-base-content/70 mb-4"> <p class="text-base-content/70 mb-4">
{gettext( {gettext(
"Deleting this role cannot be undone. Users assigned to this role must be reassigned first." "Deleting this role cannot be undone. Users assigned to this role must be reassigned first."
)} )}
</p> </p>
<.button <.button
id="delete-role-trigger" variant="danger"
variant="danger" phx-click={JS.push("delete", value: %{id: @role.id})}
phx-click="open_delete_modal" data-confirm={
data-testid="role-delete" gettext(
aria-label={gettext("Delete role %{name}", name: @role.name)}
>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete role")}
</.button>
</div>
</section>
<% end %>
<%!-- Delete Role Confirmation Modal (WCAG: focus moves into modal, keyboard confirm/cancel) --%>
<%= if assigns[:show_delete_modal] do %>
<dialog
id="delete-role-modal"
class="modal modal-open"
role="dialog"
aria-labelledby="delete-role-modal-title"
phx-keydown="dialog_keydown"
>
<div class="modal-box">
<h3 id="delete-role-modal-title" class="text-lg font-bold">{gettext("Delete Role")}</h3>
<p class="py-4">
{gettext(
"Are you sure you want to delete the role %{name}? This action cannot be undone.", "Are you sure you want to delete the role %{name}? This action cannot be undone.",
name: @role.name name: @role.name
)} )
</p> }
<div class="modal-action"> data-testid="role-delete"
<.button aria-label={gettext("Delete role %{name}", name: @role.name)}
type="button" >
variant="neutral" <.icon name="hero-trash" class="size-4" />
phx-click="cancel_delete_modal" {gettext("Delete role")}
phx-mounted={JS.focus()} </.button>
id="delete-role-modal-cancel" </div>
aria-label={gettext("Cancel")} </section>
> <% end %>
{gettext("Cancel")}
</.button>
<.button
type="button"
variant="danger"
phx-click={JS.push("delete", value: %{id: @role.id})}
data-testid="role-delete-confirm"
aria-label={gettext("Delete role")}
>
{gettext("Delete")}
</.button>
</div>
</div>
</dialog>
<% end %>
</div>
</Layouts.app> </Layouts.app>
""" """
end end

View file

@ -59,6 +59,7 @@ defmodule MvWeb.StatisticsLive do
<Layouts.app flash={@flash} current_user={@current_user}> <Layouts.app flash={@flash} current_user={@current_user}>
<.header> <.header>
{gettext("Statistics")} {gettext("Statistics")}
<:subtitle>{gettext("Overview from first membership to today")}</:subtitle>
</.header> </.header>
<section class="mb-8" aria-labelledby="members-heading"> <section class="mb-8" aria-labelledby="members-heading">

View file

@ -53,6 +53,7 @@ defmodule MvWeb.UserLive.Form do
</.button> </.button>
</:leading> </:leading>
{@page_title} {@page_title}
<:subtitle>{gettext("Use this form to manage user records in your database.")}</:subtitle>
<:actions> <:actions>
<.button <.button
form="user-form" form="user-form"
@ -65,323 +66,280 @@ defmodule MvWeb.UserLive.Form do
</:actions> </:actions>
</.header> </.header>
<div <.form class="max-w-xl" for={@form} id="user-form" phx-change="validate" phx-submit="save">
id="user-form-focus-root" <.input field={@form[:email]} label={gettext("Email")} required type="email" />
phx-hook="FocusRestore"
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
>
<.form class="max-w-xl" for={@form} id="user-form" phx-change="validate" phx-submit="save">
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
<%= if @user && @can_assign_role do %> <%= if @user && @can_assign_role do %>
<div class="mt-4"> <div class="mt-4">
<.input <.input
field={@form[:role_id]} field={@form[:role_id]}
type="select" type="select"
label={gettext("Role")} label={gettext("Role")}
options={Enum.map(@roles, &{&1.name, &1.id})} options={Enum.map(@roles, &{&1.name, &1.id})}
prompt={gettext("Select role...")} prompt={gettext("Select role...")}
/> />
</div> </div>
<% end %> <% end %>
<!-- Password Section --> <!-- Password Section -->
<div class="mt-6"> <div class="mt-6">
<label class="flex items-center space-x-2"> <label class="flex items-center space-x-2">
<input <input
type="checkbox" type="checkbox"
name="set_password" name="set_password"
phx-click="toggle_password_section" phx-click="toggle_password_section"
checked={@show_password_fields} checked={@show_password_fields}
class="checkbox checkbox-sm" class="checkbox checkbox-sm"
/> />
<span class="text-sm font-medium"> <span class="text-sm font-medium">
{if @user, do: gettext("Change Password"), else: gettext("Set Password")} {if @user, do: gettext("Change Password"), else: gettext("Set Password")}
</span> </span>
</label> </label>
<%= if @show_password_fields do %> <%= if @show_password_fields do %>
<div class="p-4 mt-4 space-y-4 rounded-lg bg-gray-50"> <div class="p-4 mt-4 space-y-4 rounded-lg bg-gray-50">
<%= if @user && MvWeb.Helpers.UserHelpers.has_oidc?(@user) do %> <%= if @user && MvWeb.Helpers.UserHelpers.has_oidc?(@user) do %>
<div class="p-3 mb-4 border border-red-300 rounded-lg bg-red-50" role="alert"> <div class="p-3 mb-4 border border-red-300 rounded-lg bg-red-50" role="alert">
<p class="text-sm font-semibold text-red-800"> <p class="text-sm font-semibold text-red-800">
{gettext("SSO / OIDC user")} {gettext("SSO / OIDC user")}
</p> </p>
<p class="mt-1 text-sm text-red-700"> <p class="mt-1 text-sm text-red-700">
{gettext( {gettext(
"This user is linked via SSO (Single Sign-On). A password set or changed here only affects login with email and password in this application. It does not change the password in your identity provider (e.g. Authentik). To change the SSO password, use the identity provider or your organization's IT." "This user is linked via SSO (Single Sign-On). A password set or changed here only affects login with email and password in this application. It does not change the password in your identity provider (e.g. Authentik). To change the SSO password, use the identity provider or your organization's IT."
)} )}
</p> </p>
</div> </div>
<% end %> <% end %>
<.input
field={@form[:password]}
label={gettext("Password")}
type="password"
required
autocomplete="new-password"
/>
<!-- Only show password confirmation for new users (register_with_password) -->
<%= if !@user do %>
<.input <.input
field={@form[:password]} field={@form[:password_confirmation]}
label={gettext("Password")} label={gettext("Confirm Password")}
type="password" type="password"
required required
autocomplete="new-password" autocomplete="new-password"
/> />
<!-- Only show password confirmation for new users (register_with_password) -->
<%= if !@user do %>
<.input
field={@form[:password_confirmation]}
label={gettext("Confirm Password")}
type="password"
required
autocomplete="new-password"
/>
<% end %>
<div class="text-sm text-gray-600">
<p><strong>{gettext("Password requirements")}:</strong></p>
<ul class="mt-1 space-y-1 text-xs list-disc list-inside">
<li>{gettext("At least 8 characters")}</li>
<li>{gettext("Include both letters and numbers")}</li>
<li>{gettext("Consider using special characters")}</li>
</ul>
</div>
<%= if @user && @can_manage_member_linking do %>
<div class="p-3 mt-3 border border-orange-200 rounded bg-orange-50">
<p class="text-sm text-orange-800">
<strong>{gettext("Admin Note")}:</strong> {gettext(
"As an administrator, you can directly set a new password for this user using the same secure Ash Authentication system."
)}
</p>
</div>
<% end %>
</div>
<% else %>
<%= if @user do %>
<div class="p-4 mt-4 rounded-lg bg-blue-50">
<p class="text-sm text-blue-800">
<strong>{gettext("Note")}:</strong> {gettext(
"Check 'Change Password' above to set a new password for this user."
)}
</p>
</div>
<% else %>
<div class="p-4 mt-4 rounded-lg bg-yellow-50">
<p class="text-sm text-yellow-800">
<strong>{gettext("Note")}:</strong> {gettext(
"User will be created without a password. Check 'Set Password' to add one."
)}
</p>
</div>
<% end %> <% end %>
<% end %>
</div>
<!-- Member Linking Section (admin only: only admins can link/unlink users to members) -->
<%= if @can_manage_member_linking do %>
<div class="mt-6">
<h2 class="mb-3 text-base font-semibold">{gettext("Linked Member")}</h2>
<%= if @user && @user.member && !@unlink_member do %> <div class="text-sm text-gray-600">
<!-- Show linked member with unlink button --> <p><strong>{gettext("Password requirements")}:</strong></p>
<div class="p-4 border border-green-200 rounded-lg bg-green-50"> <ul class="mt-1 space-y-1 text-xs list-disc list-inside">
<div class="flex items-center justify-between"> <li>{gettext("At least 8 characters")}</li>
<div> <li>{gettext("Include both letters and numbers")}</li>
<p class="font-medium text-green-900"> <li>{gettext("Consider using special characters")}</li>
{MvWeb.Helpers.MemberHelpers.display_name(@user.member)} </ul>
</p> </div>
<p class="text-sm text-green-700">{@user.member.email}</p>
</div>
<.button
type="button"
variant="danger"
size="sm"
phx-click="unlink_member"
>
{gettext("Unlink Member")}
</.button>
</div>
</div>
<% else %>
<%= if @unlink_member do %>
<!-- Show unlink pending message -->
<div class="p-4 border border-yellow-200 rounded-lg bg-yellow-50">
<p class="text-sm text-yellow-800">
<strong>{gettext("Unlinking scheduled")}:</strong> {gettext(
"Member will be unlinked when you save. Cannot select new member until saved."
)}
</p>
</div>
<% end %>
<!-- Show member search/selection for unlinked users -->
<div class="space-y-3">
<div class="relative">
<input
type="text"
id="member-search-input"
role="combobox"
phx-hook="ComboBox"
phx-focus="show_member_dropdown"
phx-change="search_members"
phx-debounce="300"
phx-window-keydown="member_dropdown_keydown"
value={@member_search_query}
placeholder={gettext("Search for a member to link...")}
class="w-full input"
name="member_search"
disabled={@unlink_member}
aria-label={gettext("Search for member to link")}
aria-describedby={if @selected_member_name, do: "member-selected", else: nil}
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"
/>
<%= if length(@available_members) > 0 do %> <%= if @user && @can_manage_member_linking do %>
<div <div class="p-3 mt-3 border border-orange-200 rounded bg-orange-50">
id="member-dropdown" <p class="text-sm text-orange-800">
role="listbox" <strong>{gettext("Admin Note")}:</strong> {gettext(
aria-label={gettext("Available members")} "As an administrator, you can directly set a new password for this user using the same secure Ash Authentication system."
class={"absolute z-10 w-full mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg max-h-60 overflow-auto #{if !@show_member_dropdown, do: "hidden"}"} )}
phx-click-away="hide_member_dropdown" </p>
>
<%= 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}</p>
</div>
<% end %>
</div>
<% end %>
</div>
<%= if @user && @user.email && @available_members != [] && Enum.all?(@available_members, &(&1.email == to_string(@user.email))) do %>
<div class="p-3 border border-yellow-200 rounded bg-yellow-50">
<p class="text-sm text-yellow-800">
<strong>{gettext("Note")}:</strong> {gettext(
"A member with this email already exists. To link with a different member, please change one of the email addresses first."
)}
</p>
</div>
<% end %>
<%= if @selected_member_id && @selected_member_name do %>
<div
id="member-selected"
class="p-3 mt-2 border border-blue-200 rounded-lg bg-blue-50"
>
<p class="text-sm text-blue-800">
<strong>{gettext("Selected")}:</strong> {@selected_member_name}
</p>
<p class="mt-1 text-xs text-blue-600">
{gettext("Save to confirm linking.")}
</p>
</div>
<% end %>
</div> </div>
<% end %> <% end %>
</div> </div>
<% end %> <% else %>
<%= if @user do %>
<%!-- Danger zone: canonical pattern (same as member form) --%> <div class="p-4 mt-4 rounded-lg bg-blue-50">
<%= if @user && can?(@current_user, :destroy, @user) && !Mv.Helpers.SystemActor.system_user?(@user) do %> <p class="text-sm text-blue-800">
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading"> <strong>{gettext("Note")}:</strong> {gettext(
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error"> "Check 'Change Password' above to set a new password for this user."
{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> </p>
<.button
id="delete-user-form-trigger"
type="button"
variant="danger"
phx-click="open_delete_modal"
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> </div>
</section> <% else %>
<% end %> <div class="p-4 mt-4 rounded-lg bg-yellow-50">
<p class="text-sm text-yellow-800">
<%!-- Delete User Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%> <strong>{gettext("Note")}:</strong> {gettext(
<%= if @user && assigns[:show_delete_modal] do %> "User will be created without a password. Check 'Set Password' to add one."
<dialog
id="delete-user-form-modal"
class="modal modal-open"
role="dialog"
aria-labelledby="delete-user-form-modal-title"
phx-keydown="dialog_keydown"
>
<div class="modal-box">
<h3 id="delete-user-form-modal-title" class="text-lg font-bold">
{gettext("Delete User")}
</h3>
<p class="py-4">
{gettext(
"Are you sure you want to delete the user %{email}? This action cannot be undone.",
email: @user.email
)} )}
</p> </p>
<div class="modal-action"> </div>
<.button <% end %>
type="button" <% end %>
variant="neutral" </div>
phx-click="cancel_delete_modal"
phx-mounted={JS.focus()} <!-- Member Linking Section (admin only: only admins can link/unlink users to members) -->
id="delete-user-form-modal-cancel" <%= if @can_manage_member_linking do %>
aria-label={gettext("Cancel")} <div class="mt-6">
> <h2 class="mb-3 text-base font-semibold">{gettext("Linked Member")}</h2>
{gettext("Cancel")}
</.button> <%= if @user && @user.member && !@unlink_member do %>
<!-- Show linked member with unlink button -->
<div class="p-4 border border-green-200 rounded-lg bg-green-50">
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-green-900">
{MvWeb.Helpers.MemberHelpers.display_name(@user.member)}
</p>
<p class="text-sm text-green-700">{@user.member.email}</p>
</div>
<.button <.button
type="button" type="button"
variant="danger" variant="danger"
phx-click={JS.push("delete", value: %{id: @user.id})} size="sm"
aria-label={gettext("Delete user")} phx-click="unlink_member"
> >
{gettext("Delete")} {gettext("Unlink Member")}
</.button> </.button>
</div> </div>
</div> </div>
</dialog> <% else %>
<% end %> <%= if @unlink_member do %>
<!-- Show unlink pending message -->
<div class="p-4 border border-yellow-200 rounded-lg bg-yellow-50">
<p class="text-sm text-yellow-800">
<strong>{gettext("Unlinking scheduled")}:</strong> {gettext(
"Member will be unlinked when you save. Cannot select new member until saved."
)}
</p>
</div>
<% end %>
<!-- Show member search/selection for unlinked users -->
<div class="space-y-3">
<div class="relative">
<input
type="text"
id="member-search-input"
role="combobox"
phx-hook="ComboBox"
phx-focus="show_member_dropdown"
phx-change="search_members"
phx-debounce="300"
phx-window-keydown="member_dropdown_keydown"
value={@member_search_query}
placeholder={gettext("Search for a member to link...")}
class="w-full input"
name="member_search"
disabled={@unlink_member}
aria-label={gettext("Search for member to link")}
aria-describedby={if @selected_member_name, do: "member-selected", else: nil}
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 class="mt-4"> <%= if length(@available_members) > 0 do %>
<.button navigate={return_path(@return_to, @user)} variant="neutral"> <div
{gettext("Cancel")} id="member-dropdown"
</.button> role="listbox"
<.button phx-disable-with={gettext("Saving...")} variant="primary"> aria-label={gettext("Available members")}
{gettext("Save User")} class={"absolute z-10 w-full mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg max-h-60 overflow-auto #{if !@show_member_dropdown, do: "hidden"}"}
</.button> 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}</p>
</div>
<% end %>
</div>
<% end %>
</div>
<%= if @user && @user.email && @available_members != [] && Enum.all?(@available_members, &(&1.email == to_string(@user.email))) do %>
<div class="p-3 border border-yellow-200 rounded bg-yellow-50">
<p class="text-sm text-yellow-800">
<strong>{gettext("Note")}:</strong> {gettext(
"A member with this email already exists. To link with a different member, please change one of the email addresses first."
)}
</p>
</div>
<% end %>
<%= if @selected_member_id && @selected_member_name do %>
<div
id="member-selected"
class="p-3 mt-2 border border-blue-200 rounded-lg bg-blue-50"
>
<p class="text-sm text-blue-800">
<strong>{gettext("Selected")}:</strong> {@selected_member_name}
</p>
<p class="mt-1 text-xs text-blue-600">
{gettext("Save to confirm linking.")}
</p>
</div>
<% end %>
</div>
<% end %>
</div> </div>
</.form> <% end %>
</div>
<%!-- 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">
<.button navigate={return_path(@return_to, @user)} variant="neutral">
{gettext("Cancel")}
</.button>
<.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save User")}
</.button>
</div>
</.form>
</Layouts.app> </Layouts.app>
""" """
end end
@ -441,7 +399,6 @@ defmodule MvWeb.UserLive.Form do
|> assign(:selected_member_name, nil) |> assign(:selected_member_name, nil)
|> assign(:unlink_member, false) |> assign(:unlink_member, false)
|> assign(:focused_member_index, nil) |> assign(:focused_member_index, nil)
|> assign_new(:show_delete_modal, fn -> false end)
|> load_initial_members() |> load_initial_members()
|> assign_form()} |> assign_form()}
end end
@ -497,32 +454,6 @@ defmodule MvWeb.UserLive.Form do
end end
end end
@impl true
def handle_event("open_delete_modal", _params, socket) do
{:noreply, assign(socket, :show_delete_modal, true)}
end
@impl true
def handle_event("cancel_delete_modal", _params, socket) do
{:noreply, close_delete_modal_and_restore_focus(socket)}
end
def handle_event("dialog_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
{:noreply, close_delete_modal_and_restore_focus(socket)}
end
def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket}
def handle_event("window_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
if socket.assigns[:show_delete_modal] do
{:noreply, close_delete_modal_and_restore_focus(socket)}
else
{:noreply, socket}
end
end
def handle_event("window_keydown", _params, socket), do: {:noreply, socket}
@impl true @impl true
def handle_event("delete", %{"id" => id}, socket) do def handle_event("delete", %{"id" => id}, socket) do
user = socket.assigns.user user = socket.assigns.user
@ -530,22 +461,13 @@ defmodule MvWeb.UserLive.Form do
cond do cond do
is_nil(user) -> is_nil(user) ->
{:noreply, {:noreply, put_flash(socket, :error, gettext("User not found"))}
socket
|> put_flash(:error, gettext("User not found"))
|> assign(:show_delete_modal, false)}
to_string(id) != to_string(user.id) -> to_string(id) != to_string(user.id) ->
{:noreply, {:noreply, put_flash(socket, :error, gettext("User not found"))}
socket
|> put_flash(:error, gettext("User not found"))
|> assign(:show_delete_modal, false)}
Mv.Helpers.SystemActor.system_user?(user) -> Mv.Helpers.SystemActor.system_user?(user) ->
{:noreply, {:noreply, put_flash(socket, :error, gettext("System user cannot be deleted."))}
socket
|> put_flash(:error, gettext("System user cannot be deleted."))
|> assign(:show_delete_modal, false)}
true -> true ->
handle_user_delete_destroy(socket, user, actor) handle_user_delete_destroy(socket, user, actor)
@ -672,24 +594,13 @@ defmodule MvWeb.UserLive.Form do
{:error, %Ash.Error.Forbidden{}} -> {:error, %Ash.Error.Forbidden{}} ->
{:noreply, {:noreply,
socket put_flash(socket, :error, gettext("You do not have permission to delete this user"))}
|> put_flash(:error, gettext("You do not have permission to delete this user"))
|> assign(:show_delete_modal, false)}
{:error, error} -> {:error, error} ->
{:noreply, {:noreply, put_flash(socket, :error, format_ash_error(error))}
socket
|> put_flash(:error, format_ash_error(error))
|> assign(:show_delete_modal, false)}
end end
end end
defp close_delete_modal_and_restore_focus(socket) do
socket
|> assign(:show_delete_modal, false)
|> push_event("focus_restore", %{id: "delete-user-form-trigger"})
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)

View file

@ -1,7 +1,6 @@
<Layouts.app flash={@flash} current_user={@current_user}> <Layouts.app flash={@flash} current_user={@current_user}>
<.header> <.header>
{gettext("Users")} {gettext("Listing Users")}
<:subtitle>{gettext("Manage users and their permissions.")}</:subtitle>
<:actions> <:actions>
<%= if can?(@current_user, :create, Mv.Accounts.User) do %> <%= if can?(@current_user, :create, Mv.Accounts.User) do %>
<.button variant="primary" navigate={~p"/users/new"} data-testid="user-new"> <.button variant="primary" navigate={~p"/users/new"} data-testid="user-new">
@ -38,25 +37,25 @@
{user.role.name} {user.role.name}
</:col> </:col>
<:col :let={user} label={gettext("Linked Member")}> <:col :let={user} label={gettext("Linked Member")}>
<.maybe_value value={user.member} empty_sr_text={gettext("No member linked")}> <%= if user.member do %>
{MvWeb.Helpers.MemberHelpers.display_name(user.member)} {MvWeb.Helpers.MemberHelpers.display_name(user.member)}
</.maybe_value> <% else %>
<span class="text-base-content/70">{gettext("No member linked")}</span>
<% end %>
</:col> </:col>
<:col :let={user} label={gettext("Password")}> <:col :let={user} label={gettext("Password")}>
<.maybe_value <%= if MvWeb.Helpers.UserHelpers.has_password?(user) do %>
value={MvWeb.Helpers.UserHelpers.has_password?(user)}
empty_sr_text={gettext("Not set")}
>
<span>{gettext("Enabled")}</span> <span>{gettext("Enabled")}</span>
</.maybe_value> <% else %>
<span class="text-base-content/70">—</span>
<% end %>
</:col> </:col>
<:col :let={user} label={gettext("OIDC")}> <:col :let={user} label={gettext("OIDC")}>
<.maybe_value <%= if MvWeb.Helpers.UserHelpers.has_oidc?(user) do %>
value={MvWeb.Helpers.UserHelpers.has_oidc?(user)}
empty_sr_text={gettext("Not set")}
>
<span>{gettext("Linked")}</span> <span>{gettext("Linked")}</span>
</.maybe_value> <% else %>
<span class="text-base-content/70">—</span>
<% end %>
</:col> </:col>
</.table> </.table>
</Layouts.app> </Layouts.app>

View file

@ -45,6 +45,8 @@ defmodule MvWeb.UserLive.Show do
</.button> </.button>
</:leading> </:leading>
{gettext("User")} {@user.email} {gettext("User")} {@user.email}
<:subtitle>{gettext("This is a user record from your database.")}</:subtitle>
<:actions> <:actions>
<%= if can?(@current_user, :update, @user) do %> <%= if can?(@current_user, :update, @user) do %>
<.button <.button
@ -58,106 +60,65 @@ defmodule MvWeb.UserLive.Show do
</:actions> </:actions>
</.header> </.header>
<div <.list>
id="user-show-focus-root" <:item title={gettext("Email")}>{@user.email}</:item>
phx-hook="FocusRestore" <:item title={gettext("Role")}>{@user.role.name}</:item>
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil} <:item title={gettext("Password Authentication")}>
> {if MvWeb.Helpers.UserHelpers.has_password?(@user),
<.list> do: gettext("Enabled"),
<:item title={gettext("Email")}>{@user.email}</:item> else: gettext("Not enabled")}
<:item title={gettext("Role")}>{@user.role.name}</:item> </:item>
<:item title={gettext("Password Authentication")}> <:item title={gettext("OIDC")}>
{if MvWeb.Helpers.UserHelpers.has_password?(@user), {if MvWeb.Helpers.UserHelpers.has_oidc?(@user),
do: gettext("Enabled"), do: gettext("Linked"),
else: gettext("Not enabled")} else: gettext("Not linked")}
</:item> </:item>
<:item title={gettext("OIDC")}> <:item title={gettext("Linked Member")}>
{if MvWeb.Helpers.UserHelpers.has_oidc?(@user), <%= if @user.member do %>
do: gettext("Linked"), <.link
else: gettext("Not linked")} navigate={~p"/members/#{@user.member}"}
</:item> class="text-blue-600 underline hover:text-blue-800"
<:item title={gettext("Linked Member")}> >
<%= if @user.member do %> <.icon name="hero-users" class="inline w-4 h-4 mr-1" />
<.link {MvWeb.Helpers.MemberHelpers.display_name(@user.member)}
navigate={~p"/members/#{@user.member}"} </.link>
class="text-blue-600 underline hover:text-blue-800" <% else %>
> <span class="italic text-gray-500">{gettext("No member linked")}</span>
<.icon name="hero-users" class="inline w-4 h-4 mr-1" /> <% end %>
{MvWeb.Helpers.MemberHelpers.display_name(@user.member)} </:item>
</.link> </.list>
<% else %>
<span class="italic text-gray-500">{gettext("No member linked")}</span>
<% end %>
</:item>
</.list>
<%!-- Danger zone: canonical pattern (same as member show) --%> <%!-- Danger zone: canonical pattern (same as member show) --%>
<%= if can?(@current_user, :destroy, @user) and not Mv.Helpers.SystemActor.system_user?(@user) do %> <%= 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"> <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"> <h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
{gettext("Danger zone")} {gettext("Danger zone")}
</h2> </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="text-base-content/70 mb-4"> <p class="text-base-content/70 mb-4">
{gettext( {gettext(
"Deleting this user cannot be undone. The user account and any linked member association will be affected." "Deleting this user cannot be undone. The user account and any linked member association will be affected."
)} )}
</p> </p>
<.button <.button
id="delete-user-trigger" variant="danger"
variant="danger" phx-click="delete"
phx-click="open_delete_modal" phx-value-id={@user.id}
data-testid="user-delete" data-confirm={
aria-label={gettext("Delete user %{email}", email: @user.email)} gettext(
>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete user")}
</.button>
</div>
</section>
<% end %>
<%!-- Delete User Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%>
<%= if assigns[:show_delete_modal] do %>
<dialog
id="delete-user-modal"
class="modal modal-open"
role="dialog"
aria-labelledby="delete-user-modal-title"
phx-keydown="dialog_keydown"
>
<div class="modal-box">
<h3 id="delete-user-modal-title" class="text-lg font-bold">{gettext("Delete User")}</h3>
<p class="py-4">
{gettext(
"Are you sure you want to delete the user %{email}? This action cannot be undone.", "Are you sure you want to delete the user %{email}? This action cannot be undone.",
email: @user.email email: @user.email
)} )
</p> }
<div class="modal-action"> data-testid="user-delete"
<.button aria-label={gettext("Delete user %{email}", email: @user.email)}
type="button" >
variant="neutral" <.icon name="hero-trash" class="size-4" />
phx-click="cancel_delete_modal" {gettext("Delete user")}
phx-mounted={JS.focus()} </.button>
id="delete-user-modal-cancel" </div>
aria-label={gettext("Cancel")} </section>
> <% end %>
{gettext("Cancel")}
</.button>
<.button
type="button"
variant="danger"
phx-click={JS.push("delete", value: %{id: @user.id})}
aria-label={gettext("Delete user")}
>
{gettext("Delete")}
</.button>
</div>
</div>
</dialog>
<% end %>
</div>
</Layouts.app> </Layouts.app>
""" """
end end
@ -178,37 +139,10 @@ defmodule MvWeb.UserLive.Show do
{:ok, {:ok,
socket socket
|> assign(:page_title, gettext("Show User")) |> assign(:page_title, gettext("Show User"))
|> assign(:user, user) |> assign(:user, user)}
|> assign(:show_delete_modal, false)}
end end
end end
@impl true
def handle_event("open_delete_modal", _params, socket) do
{:noreply, assign(socket, :show_delete_modal, true)}
end
@impl true
def handle_event("cancel_delete_modal", _params, socket) do
{:noreply, close_delete_modal_and_restore_focus(socket)}
end
def handle_event("window_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
if socket.assigns[:show_delete_modal] do
{:noreply, close_delete_modal_and_restore_focus(socket)}
else
{:noreply, socket}
end
end
def handle_event("window_keydown", _params, socket), do: {:noreply, socket}
def handle_event("dialog_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
{:noreply, close_delete_modal_and_restore_focus(socket)}
end
def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket}
@impl true @impl true
def handle_event("delete", %{"id" => id}, socket) do def handle_event("delete", %{"id" => id}, socket) do
user = socket.assigns.user user = socket.assigns.user
@ -216,16 +150,10 @@ defmodule MvWeb.UserLive.Show do
cond do cond do
to_string(id) != to_string(user.id) -> to_string(id) != to_string(user.id) ->
{:noreply, {:noreply, put_flash(socket, :error, gettext("User not found"))}
socket
|> put_flash(:error, gettext("User not found"))
|> assign(:show_delete_modal, false)}
Mv.Helpers.SystemActor.system_user?(user) -> Mv.Helpers.SystemActor.system_user?(user) ->
{:noreply, {:noreply, put_flash(socket, :error, gettext("System user cannot be deleted."))}
socket
|> put_flash(:error, gettext("System user cannot be deleted."))
|> assign(:show_delete_modal, false)}
true -> true ->
handle_user_delete_destroy(socket, user, actor) handle_user_delete_destroy(socket, user, actor)
@ -242,21 +170,10 @@ defmodule MvWeb.UserLive.Show do
{:error, %Ash.Error.Forbidden{}} -> {:error, %Ash.Error.Forbidden{}} ->
{:noreply, {:noreply,
socket put_flash(socket, :error, gettext("You do not have permission to delete this user"))}
|> put_flash(:error, gettext("You do not have permission to delete this user"))
|> assign(:show_delete_modal, false)}
{:error, error} -> {:error, error} ->
{:noreply, {:noreply, put_flash(socket, :error, format_ash_error(error))}
socket
|> put_flash(:error, format_ash_error(error))
|> assign(:show_delete_modal, false)}
end end
end end
defp close_delete_modal_and_restore_focus(socket) do
socket
|> assign(:show_delete_modal, false)
|> push_event("focus_restore", %{id: "delete-user-trigger"})
end
end end

View file

@ -93,30 +93,22 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatus do
## Returns ## Returns
Map with `:variant`, `:icon`, and `:label` keys (and legacy `:color`), or `nil` if status is nil. Map with `:color`, `:icon`, and `:label` keys, or `nil` if status is nil
Use `:variant` with <.badge variant={badge.variant}> for WCAG-compliant rendering.
## Examples ## Examples
iex> MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge(:paid) iex> MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge(:paid)
%{variant: :success, color: "badge-success", icon: "hero-check-circle", label: "Paid"} %{color: "badge-success", icon: "hero-check-circle", label: "Paid"}
iex> MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge(nil) iex> MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge(nil)
nil nil
""" """
@spec format_cycle_status_badge(:paid | :unpaid | :suspended | nil) :: @spec format_cycle_status_badge(:paid | :unpaid | :suspended | nil) ::
%{ %{color: String.t(), icon: String.t(), label: String.t()} | nil
variant: :success | :error | :warning,
color: String.t(),
icon: String.t(),
label: String.t()
}
| nil
def format_cycle_status_badge(nil), do: nil def format_cycle_status_badge(nil), do: nil
def format_cycle_status_badge(status) when status in [:paid, :unpaid, :suspended] do def format_cycle_status_badge(status) when status in [:paid, :unpaid, :suspended] do
%{ %{
variant: MembershipFeeHelpers.status_variant(status),
color: MembershipFeeHelpers.status_color(status), color: MembershipFeeHelpers.status_color(status),
icon: MembershipFeeHelpers.status_icon(status), icon: MembershipFeeHelpers.status_icon(status),
label: format_status_label(status) label: format_status_label(status)

View file

@ -36,12 +36,7 @@ msgid "City"
msgstr "Stadt" msgstr "Stadt"
#: lib/mv_web/live/group_live/show.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/member_live/show/membership_fees_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.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 "Delete" msgid "Delete"
msgstr "Löschen" msgstr "Löschen"
@ -262,12 +257,9 @@ msgstr "Dein Passwort wurde erfolgreich zurückgesetzt"
#: 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_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/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/show.ex
#: lib/mv_web/live/user_live/form.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 "Cancel" msgid "Cancel"
msgstr "Abbrechen" msgstr "Abbrechen"
@ -302,6 +294,7 @@ msgid "Logout"
msgstr "Abmelden" msgstr "Abmelden"
#: lib/mv_web/live/user_live/index.ex #: lib/mv_web/live/user_live/index.ex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Listing Users" msgid "Listing Users"
msgstr "Benutzer*innen auflisten" msgstr "Benutzer*innen auflisten"
@ -388,6 +381,16 @@ msgstr "Benutzer*in speichern"
msgid "Show User" msgid "Show User"
msgstr "Benutzer*in anzeigen" msgstr "Benutzer*in anzeigen"
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format
msgid "This is a user record from your database."
msgstr "Dies ist ein Benutzer*innen-Datensatz aus Ihrer Datenbank."
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Use this form to manage user records in your database."
msgstr "Verwende dieses Formular, um Benutzer*innen-Datensätze zu verwalten."
#: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/show.ex #: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -530,7 +533,6 @@ msgstr "Suchen..."
#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/role_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 "Users" msgid "Users"
msgstr "Benutzer*innen" msgstr "Benutzer*innen"
@ -591,6 +593,18 @@ msgstr "Diese E-Mail-Adresse ist bereits mit einem anderen OIDC-Konto verknüpft
msgid "Custom Fields" msgid "Custom Fields"
msgstr "Benutzerdefinierte Felder" msgstr "Benutzerdefinierte Felder"
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format
msgid "%{count} member has a value assigned for this custom field."
msgid_plural "%{count} members have values assigned for this custom field."
msgstr[0] "%{count} Mitglied hat einen Wert für dieses benutzerdefinierte Feld zugewiesen."
msgstr[1] "%{count} Mitglieder haben Werte für dieses benutzerdefinierte Feld zugewiesen."
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format
msgid "All custom field values will be permanently deleted when you delete this custom field."
msgstr "Alle benutzerdefinierten Feldwerte werden beim Löschen dieses benutzerdefinierten Feldes dauerhaft gelöscht."
#: lib/mv_web/live/custom_field_live/index_component.ex #: 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"
@ -776,7 +790,6 @@ msgstr "Beitragsdaten"
msgid "Payments" msgid "Payments"
msgstr "Zahlungen" msgstr "Zahlungen"
#: lib/mv_web/live/datafields_live.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
@ -1375,8 +1388,6 @@ msgid "None (no default)"
msgstr "Keine (kein Standard)" msgstr "Keine (kein Standard)"
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Not set" msgid "Not set"
msgstr "Nicht gesetzt" msgstr "Nicht gesetzt"
@ -1462,6 +1473,11 @@ msgstr "Art"
msgid "Type '%{confirmation}' to confirm" msgid "Type '%{confirmation}' to confirm"
msgstr "Gib '%{confirmation}' ein, um zu bestätigen" msgstr "Gib '%{confirmation}' ein, um zu bestätigen"
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Use this form to manage membership fee types in your database."
msgstr "Verwende dieses Formular, um Mitgliedsbeitragsarten in deiner Datenbank zu verwalten."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Warning" msgid "Warning"
@ -1688,6 +1704,11 @@ msgstr "System-Rollen können nicht gelöscht werden."
msgid "Toggle sidebar" msgid "Toggle sidebar"
msgstr "Sidebar umschalten" msgstr "Sidebar umschalten"
#: lib/mv_web/live/role_live/form.ex
#, elixir-autogen, elixir-format
msgid "Use this form to manage roles in your database."
msgstr "Verwende dieses Formular, um Rollen in deiner Datenbank zu verwalten."
#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "User menu" msgid "User menu"
@ -2407,6 +2428,11 @@ msgstr "Alle Jahre zusammengefasst (Kreis)"
msgid "Contributions by year" msgid "Contributions by year"
msgstr "Beiträge nach Jahr" msgstr "Beiträge nach Jahr"
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Overview from first membership to today"
msgstr "Übersicht vom ersten Eintritt bis heute"
#: lib/mv_web/live/statistics_live.ex #: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Contributions by year as table with stacked bars" msgid "Contributions by year as table with stacked bars"
@ -2884,6 +2910,11 @@ msgstr "CSV Datei auswählen"
msgid "Import Members" msgid "Import Members"
msgstr "Mitglieder importieren (CSV)" msgstr "Mitglieder importieren (CSV)"
#~ #: lib/mv_web/live/import_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Datei auswählen"
#~ msgstr ""
#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Admin group name" msgid "Admin group name"
@ -2909,6 +2940,21 @@ msgstr "Client-ID"
msgid "Client Secret" msgid "Client Secret"
msgstr "Client-Geheimnis" msgstr "Client-Geheimnis"
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Configure global settings and fee types for membership fees."
msgstr "Globale Einstellungen für Mitgliedsbeiträge konfigurieren."
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format
msgid "Configure member fields and custom data fields."
msgstr "Mitgliedsfelder und benutzerdefinierte Datenfelder konfigurieren."
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Custom fields"
msgstr "Benutzerdefinierte Felder"
#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/datafields_live.ex #: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -2950,6 +2996,11 @@ msgstr "Aus OIDC_REDIRECT_URI"
msgid "Groups claim" msgid "Groups claim"
msgstr "Gruppenclaim" msgstr "Gruppenclaim"
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format
msgid "Member fields"
msgstr "Mitgliedsfelder"
#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Membership fee settings" msgid "Membership fee settings"
@ -3174,64 +3225,88 @@ msgstr "Das Löschen dieses Datenfeldes kann nicht rückgängig gemacht werden.
msgid "Individual datafields" msgid "Individual datafields"
msgstr "Individuelle Datenfelder" msgstr "Individuelle Datenfelder"
#: lib/mv_web/live/member_live/form.ex #~ #: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_live/show.ex #~ #, elixir-autogen, elixir-format
#, elixir-autogen, elixir-format, fuzzy #~ msgid "Back to Settings"
msgid "Delete Member" #~ msgstr "Zurück zu den Einstellungen"
msgstr "Mitglied löschen"
#: lib/mv_web/live/role_live/show.ex #~ #: lib/mv_web/live/role_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy #~ #, elixir-autogen, elixir-format
msgid "Delete Role" #~ msgid "Cannot delete system role"
msgstr "Rolle löschen" #~ msgstr "System-Rolle kann nicht gelöscht werden"
#: lib/mv_web/live/user_live/form.ex #~ #: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/user_live/show.ex #~ #, elixir-autogen, elixir-format
#, elixir-autogen, elixir-format, fuzzy #~ msgid "Click for custom field details"
msgid "Delete User" #~ msgstr "Klicke für Datenfeld-Details"
msgstr "Benutzer*in löschen"
#: lib/mv_web/live/membership_fee_settings_live.ex #~ #: lib/mv_web/live/member_field_live/index_component.ex
#, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
msgid "Configure fee types for membership fees." #~ msgid "Click for datafield details"
msgstr "Verwalte Beitragsarten und Mitgliedsbeiträge." #~ msgstr "Klicke für Datenfeld-Details"
#: lib/mv_web/live/datafields_live.ex #~ #: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
msgid "Configure which data you want to save for your members. Define individual datafields." #~ msgid "Coming soon"
msgstr "Verwalte welche Daten du für eure Mitglieder speichern möchtest. Lege individuelle datenfelder an." #~ msgstr "Demnächst verfügbar"
#: lib/mv_web/live/user_live/index.html.heex #~ #: lib/mv_web/live/components/field_visibility_dropdown_component.ex
#, elixir-autogen, elixir-format, fuzzy #~ #, elixir-autogen, elixir-format
msgid "Manage users and their permissions." #~ msgid "Custom Field %{id}"
msgstr "Verwalte Benutzer*innen und ihre Berechtigungen." #~ msgstr "Benutzerdefiniertes Feld %{id}"
#: lib/mv_web/live/custom_field_live/index_component.ex #~ #: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format, fuzzy #~ #: lib/mv_web/live/member_field_live/index_component.ex
msgid "%{count} member has a value assigned for this datafield." #~ #, elixir-autogen, elixir-format, fuzzy
msgid_plural "%{count} members have values assigned for this datafield." #~ msgid "Edit datafield"
msgstr[0] "%{count} Mitglied hat einen Wert für dieses benutzerdefinierte Feld zugewiesen." #~ msgstr "Datenfeld bearbeiten"
msgstr[1] "%{count} Mitglieder haben Werte für dieses benutzerdefinierte Feld zugewiesen."
#: lib/mv_web/live/datafields_live.ex #~ #: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy #~ #, elixir-autogen, elixir-format, fuzzy
msgid "Individual Datafields" #~ msgid "Edit user"
msgstr "Individuelle Datenfelder" #~ msgstr "Benutzer*in bearbeiten"
#: lib/mv_web/live/member_live/index.html.heex #~ #: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format, fuzzy #~ #, elixir-autogen, elixir-format
msgid "No group assignment" #~ msgid "Reset"
msgstr "Keine Gruppenzuordnung" #~ msgstr "Zurücksetzen"
#: lib/mv_web/components/core_components.ex #~ #: lib/mv_web/live/role_live/show.ex
#: lib/mv_web/live/group_live/index.ex #~ #, elixir-autogen, elixir-format
#: lib/mv_web/live/member_live/index.html.heex #~ msgid "Rolle bearbeiten"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex #~ msgstr "Rolle bearbeiten"
#, elixir-autogen, elixir-format
msgid "Not specified"
msgstr "Nicht angegeben"
#: lib/mv_web/live/custom_field_live/index_component.ex #~ #: lib/mv_web/live/role_live/form.ex
#, elixir-autogen, elixir-format, fuzzy #~ #, elixir-autogen, elixir-format
msgid "All datafield values will be permanently deleted when you delete this datafield." #~ msgid "Save Role"
msgstr "Alle benutzerdefinierten Feldwerte werden beim Löschen dieses benutzerdefinierten Feldes dauerhaft gelöscht." #~ 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

@ -37,12 +37,7 @@ msgid "City"
msgstr "" msgstr ""
#: lib/mv_web/live/group_live/show.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/member_live/show/membership_fees_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.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 "Delete" msgid "Delete"
msgstr "" msgstr ""
@ -263,12 +258,9 @@ msgstr ""
#: 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_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/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/show.ex
#: lib/mv_web/live/user_live/form.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 "Cancel" msgid "Cancel"
msgstr "" msgstr ""
@ -303,6 +295,7 @@ msgid "Logout"
msgstr "" msgstr ""
#: lib/mv_web/live/user_live/index.ex #: lib/mv_web/live/user_live/index.ex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Listing Users" msgid "Listing Users"
msgstr "" msgstr ""
@ -389,6 +382,16 @@ msgstr ""
msgid "Show User" msgid "Show User"
msgstr "" msgstr ""
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format
msgid "This is a user record from your database."
msgstr ""
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "Use this form to manage user records in your database."
msgstr ""
#: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/show.ex #: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -531,7 +534,6 @@ msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/role_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 "Users" msgid "Users"
msgstr "" msgstr ""
@ -592,6 +594,18 @@ msgstr ""
msgid "Custom Fields" msgid "Custom Fields"
msgstr "" msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format
msgid "%{count} member has a value assigned for this custom field."
msgid_plural "%{count} members have values assigned for this custom field."
msgstr[0] ""
msgstr[1] ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format
msgid "All custom field values will be permanently deleted when you delete this custom field."
msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex #: 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"
@ -777,7 +791,6 @@ msgstr ""
msgid "Payments" msgid "Payments"
msgstr "" msgstr ""
#: lib/mv_web/live/datafields_live.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
@ -1376,8 +1389,6 @@ msgid "None (no default)"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Not set" msgid "Not set"
msgstr "" msgstr ""
@ -1463,6 +1474,11 @@ msgstr ""
msgid "Type '%{confirmation}' to confirm" msgid "Type '%{confirmation}' to confirm"
msgstr "" msgstr ""
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format
msgid "Use this form to manage membership fee types in your database."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Warning" msgid "Warning"
@ -1689,6 +1705,11 @@ msgstr ""
msgid "Toggle sidebar" msgid "Toggle sidebar"
msgstr "" msgstr ""
#: lib/mv_web/live/role_live/form.ex
#, elixir-autogen, elixir-format
msgid "Use this form to manage roles in your database."
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "User menu" msgid "User menu"
@ -2408,6 +2429,11 @@ msgstr ""
msgid "Contributions by year" msgid "Contributions by year"
msgstr "" msgstr ""
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Overview from first membership to today"
msgstr ""
#: lib/mv_web/live/statistics_live.ex #: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Contributions by year as table with stacked bars" msgid "Contributions by year as table with stacked bars"
@ -2909,6 +2935,21 @@ msgstr ""
msgid "Client Secret" msgid "Client Secret"
msgstr "" msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Configure global settings and fee types for membership fees."
msgstr ""
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format
msgid "Configure member fields and custom data fields."
msgstr ""
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format
msgid "Custom fields"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/datafields_live.ex #: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -2950,6 +2991,11 @@ msgstr ""
msgid "Groups claim" msgid "Groups claim"
msgstr "" msgstr ""
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format
msgid "Member fields"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Membership fee settings" msgid "Membership fee settings"
@ -3173,65 +3219,3 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Individual datafields" msgid "Individual datafields"
msgstr "" 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/role_live/show.ex
#, elixir-autogen, elixir-format
msgid "Delete Role"
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/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Configure fee types for membership fees."
msgstr ""
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format
msgid "Configure which data you want to save for your members. Define individual datafields."
msgstr ""
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Manage users and their permissions."
msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format
msgid "%{count} member has a value assigned for this datafield."
msgid_plural "%{count} members have values assigned for this datafield."
msgstr[0] ""
msgstr[1] ""
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format
msgid "Individual Datafields"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "No group assignment"
msgstr ""
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Not specified"
msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format
msgid "All datafield values will be permanently deleted when you delete this datafield."
msgstr ""

View file

@ -37,12 +37,7 @@ msgid "City"
msgstr "" msgstr ""
#: lib/mv_web/live/group_live/show.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/member_live/show/membership_fees_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.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 "Delete" msgid "Delete"
msgstr "" msgstr ""
@ -263,12 +258,9 @@ msgstr ""
#: 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_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/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/show.ex
#: lib/mv_web/live/user_live/form.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 "Cancel" msgid "Cancel"
msgstr "" msgstr ""
@ -303,6 +295,7 @@ msgid "Logout"
msgstr "" msgstr ""
#: lib/mv_web/live/user_live/index.ex #: lib/mv_web/live/user_live/index.ex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Listing Users" msgid "Listing Users"
msgstr "" msgstr ""
@ -389,6 +382,16 @@ msgstr ""
msgid "Show User" msgid "Show User"
msgstr "" msgstr ""
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "This is a user record from your database."
msgstr ""
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Use this form to manage user records in your database."
msgstr ""
#: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/show.ex #: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -531,7 +534,6 @@ msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/role_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, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Users" msgid "Users"
msgstr "" msgstr ""
@ -592,6 +594,18 @@ msgstr ""
msgid "Custom Fields" msgid "Custom Fields"
msgstr "" msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format
msgid "%{count} member has a value assigned for this custom field."
msgid_plural "%{count} members have values assigned for this custom field."
msgstr[0] ""
msgstr[1] ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format
msgid "All custom field values will be permanently deleted when you delete this custom field."
msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex #: 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"
@ -777,7 +791,6 @@ msgstr ""
msgid "Payments" msgid "Payments"
msgstr "" msgstr ""
#: lib/mv_web/live/datafields_live.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
@ -1376,8 +1389,6 @@ msgid "None (no default)"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Not set" msgid "Not set"
msgstr "" msgstr ""
@ -1463,6 +1474,11 @@ msgstr ""
msgid "Type '%{confirmation}' to confirm" msgid "Type '%{confirmation}' to confirm"
msgstr "" msgstr ""
#: lib/mv_web/live/membership_fee_type_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Use this form to manage membership fee types in your database."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Warning" msgid "Warning"
@ -1689,6 +1705,11 @@ msgstr ""
msgid "Toggle sidebar" msgid "Toggle sidebar"
msgstr "" msgstr ""
#: lib/mv_web/live/role_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Use this form to manage roles in your database."
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "User menu" msgid "User menu"
@ -2408,6 +2429,11 @@ msgstr ""
msgid "Contributions by year" msgid "Contributions by year"
msgstr "" msgstr ""
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Overview from first membership to today"
msgstr ""
#: lib/mv_web/live/statistics_live.ex #: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Contributions by year as table with stacked bars" msgid "Contributions by year as table with stacked bars"
@ -2909,6 +2935,21 @@ msgstr ""
msgid "Client Secret" msgid "Client Secret"
msgstr "" msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Configure global settings and fee types for membership fees."
msgstr ""
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format
msgid "Configure member fields and custom data fields."
msgstr ""
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Custom fields"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/datafields_live.ex #: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -2950,6 +2991,11 @@ msgstr ""
msgid "Groups claim" msgid "Groups claim"
msgstr "" msgstr ""
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Member fields"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Membership fee settings" msgid "Membership fee settings"
@ -3174,64 +3220,88 @@ msgstr ""
msgid "Individual datafields" msgid "Individual datafields"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex #~ #: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_live/show.ex #~ #, elixir-autogen, elixir-format
#, elixir-autogen, elixir-format, fuzzy #~ msgid "Back to Settings"
msgid "Delete Member" #~ msgstr ""
msgstr ""
#: lib/mv_web/live/role_live/show.ex #~ #: lib/mv_web/live/role_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy #~ #, elixir-autogen, elixir-format
msgid "Delete Role" #~ msgid "Cannot delete system role"
msgstr "" #~ msgstr ""
#: lib/mv_web/live/user_live/form.ex #~ #: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/user_live/show.ex #~ #, elixir-autogen, elixir-format
#, elixir-autogen, elixir-format, fuzzy #~ msgid "Click for custom field details"
msgid "Delete User" #~ msgstr ""
msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex #~ #: lib/mv_web/live/member_field_live/index_component.ex
#, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
msgid "Configure fee types for membership fees." #~ msgid "Click for datafield details"
msgstr "" #~ msgstr ""
#: lib/mv_web/live/datafields_live.ex #~ #: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
msgid "Configure which data you want to save for your members. Define individual datafields." #~ msgid "Coming soon"
msgstr "" #~ msgstr ""
#: lib/mv_web/live/user_live/index.html.heex #~ #: lib/mv_web/live/components/field_visibility_dropdown_component.ex
#, elixir-autogen, elixir-format, fuzzy #~ #, elixir-autogen, elixir-format, fuzzy
msgid "Manage users and their permissions." #~ msgid "Custom Field %{id}"
msgstr "" #~ 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, fuzzy #~ #: lib/mv_web/live/member_field_live/index_component.ex
msgid "%{count} member has a value assigned for this datafield." #~ #, elixir-autogen, elixir-format, fuzzy
msgid_plural "%{count} members have values assigned for this datafield." #~ msgid "Edit datafield"
msgstr[0] "" #~ msgstr ""
msgstr[1] ""
#: lib/mv_web/live/datafields_live.ex #~ #: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy #~ #, elixir-autogen, elixir-format, fuzzy
msgid "Individual Datafields" #~ msgid "Edit user"
msgstr "" #~ msgstr ""
#: lib/mv_web/live/member_live/index.html.heex #~ #: lib/mv_web/live/components/member_filter_component.ex
#, elixir-autogen, elixir-format, fuzzy #~ #, elixir-autogen, elixir-format
msgid "No group assignment" #~ msgid "Reset"
msgstr "" #~ msgstr ""
#: lib/mv_web/components/core_components.ex #~ #: lib/mv_web/live/role_live/show.ex
#: lib/mv_web/live/group_live/index.ex #~ #, elixir-autogen, elixir-format
#: lib/mv_web/live/member_live/index.html.heex #~ msgid "Rolle bearbeiten"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex #~ msgstr ""
#, elixir-autogen, elixir-format
msgid "Not specified"
msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex #~ #: lib/mv_web/live/role_live/form.ex
#, elixir-autogen, elixir-format, fuzzy #~ #, elixir-autogen, elixir-format, fuzzy
msgid "All datafield values will be permanently deleted when you delete this datafield." #~ msgid "Save Role"
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/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

@ -1,91 +0,0 @@
defmodule MvWeb.Components.CoreComponentsBadgeTest do
@moduledoc """
Unit tests for the Core Components badge (WCAG-compliant, non-transparent).
"""
use MvWeb.ConnCase, async: true
import Phoenix.Component
import Phoenix.LiveViewTest
import MvWeb.CoreComponents
describe "badge/1" do
test "default variant renders with badge and badge-neutral classes (visible, not ghost)" do
assigns = %{}
html =
rendered_to_string(~H"""
<.badge variant="neutral">Label</.badge>
""")
assert html =~ "badge"
assert html =~ "badge-neutral"
assert html =~ "badge-soft"
refute html =~ "badge-ghost"
assert html =~ "Label"
end
test "success variant renders badge-success and badge-soft" do
assigns = %{}
html =
rendered_to_string(~H"""
<.badge variant="success">Paid</.badge>
""")
assert html =~ "badge-success"
assert html =~ "badge-soft"
assert html =~ "Paid"
end
test "outline style includes bg-base-100 for contrast" do
assigns = %{}
html =
rendered_to_string(~H"""
<.badge variant="primary" style="outline">Outline</.badge>
""")
assert html =~ "badge-outline"
assert html =~ "bg-base-100"
assert html =~ "Outline"
end
test "solid style has no badge-soft or badge-outline" do
assigns = %{}
html =
rendered_to_string(~H"""
<.badge variant="error" style="solid">Error</.badge>
""")
assert html =~ "badge-error"
refute html =~ "badge-soft"
refute html =~ "badge-outline"
assert html =~ "Error"
end
test "size sm adds badge-sm" do
assigns = %{}
html =
rendered_to_string(~H"""
<.badge variant="neutral" size="sm">Small</.badge>
""")
assert html =~ "badge-sm"
assert html =~ "Small"
end
test "renders as span (non-interactive)" do
assigns = %{}
html =
rendered_to_string(~H"""
<.badge variant="info">Info</.badge>
""")
assert html =~ ~r/<span[^>]*class="[^"]*badge[^"]*"/
refute html =~ ~r/<button/
end
end
end

View file

@ -254,14 +254,6 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
end end
end end
describe "status_variant/1" do
test "returns badge variant for <.badge> (suspended uses warning to match edit button)" do
assert MembershipFeeHelpers.status_variant(:paid) == :success
assert MembershipFeeHelpers.status_variant(:unpaid) == :error
assert MembershipFeeHelpers.status_variant(:suspended) == :warning
end
end
describe "status_color/1" do describe "status_color/1" do
test "returns correct color classes for statuses" do test "returns correct color classes for statuses" do
assert MembershipFeeHelpers.status_color(:paid) == "badge-success" assert MembershipFeeHelpers.status_color(:paid) == "badge-success"

View file

@ -46,19 +46,15 @@ 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). First row click opens form (overview) or switches # Delete is in the edit form (FormComponent); open form by clicking the name cell (unique td with phx-click)
# to edit-mode (new component shows table). If delete button is visible, click it; else click row
# again to open the form, then click delete.
defp open_delete_modal(view, custom_field) do defp open_delete_modal(view, custom_field) do
row_selector = "tr#custom_fields-#{custom_field.id} td" view
view |> element(row_selector, custom_field.name) |> render_click() |> element("tr#custom_fields-#{custom_field.id} td", custom_field.name)
|> render_click()
if has_element?(view, "[data-testid=custom-field-delete]") do view
view |> element("[data-testid=custom-field-delete]") |> render_click() |> element("[data-testid=custom-field-delete]")
else |> render_click()
view |> element(row_selector, custom_field.name) |> render_click()
view |> element("[data-testid=custom-field-delete]") |> render_click()
end
end end
describe "delete button and modal" do describe "delete button and modal" do
@ -75,12 +71,8 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
# Modal should be visible # Modal should be visible
assert has_element?(view, "#delete-custom-field-modal") assert has_element?(view, "#delete-custom-field-modal")
# Edit mode: section titles must not reappear when modal opens (regression)
refute has_element?(view, "h2", "Member fields")
refute has_element?(view, "h2", "Custom fields")
# Should show correct member count (1 member) # Should show correct member count (1 member)
assert render(view) =~ "1 member has a value assigned for this datafield" assert render(view) =~ "1 member has a value assigned for this custom field"
# Should show the slug # Should show the slug
assert render(view) =~ custom_field.slug assert render(view) =~ custom_field.slug
@ -99,7 +91,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
open_delete_modal(view, custom_field) open_delete_modal(view, custom_field)
# Should show plural form # Should show plural form
assert render(view) =~ "2 members have values assigned for this datafield" 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
@ -109,7 +101,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
open_delete_modal(view, custom_field) open_delete_modal(view, custom_field)
# Should show 0 members # Should show 0 members
assert render(view) =~ "0 members have values assigned for this datafield" assert render(view) =~ "0 members have values assigned for this custom field"
end end
end end

View file

@ -83,21 +83,6 @@ defmodule MvWeb.MemberFieldLive.IndexComponentTest do
end end
end end
describe "edit mode visibility" do
test "clicking member field row shows only form, no section titles", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/datafields")
# Row click is on the first td (no col_click); click that cell to open edit form
view
|> element("tr#member_field-first_name td:first-child")
|> render_click()
assert has_element?(view, "#member-field-form-first_name")
refute has_element?(view, "h2", "Custom fields")
refute has_element?(view, "h2", "Member fields")
end
end
describe "required fields" do describe "required fields" do
setup do setup do
{:ok, settings} = Membership.get_settings() {:ok, settings} = Membership.get_settings()

View file

@ -386,16 +386,11 @@ defmodule MvWeb.RoleLiveTest do
{:ok, view, _html} = live(conn, "/admin/roles/#{role.id}") {:ok, view, _html} = live(conn, "/admin/roles/#{role.id}")
# Open delete modal from Danger zone # Delete from Danger zone on show page
view view
|> element("[data-testid=role-delete]") |> element("[data-testid=role-delete]")
|> render_click() |> render_click()
# Confirm deletion in modal
view
|> element("[data-testid=role-delete-confirm]")
|> render_click()
assert_redirect(view, "/admin/roles") assert_redirect(view, "/admin/roles")
# Verify deletion by checking database # Verify deletion by checking database

View file

@ -29,8 +29,9 @@ defmodule MvWeb.StatisticsLiveTest do
test "page shows overview of all relevant years without year selector", %{conn: conn} do test "page shows overview of all relevant years without year selector", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/statistics") {:ok, _view, html} = live(conn, ~p"/statistics")
# Page shows multi-year data (member numbers by year) and year column; no single-year selector as main control # No year dropdown: single select for year should not be present as main control
assert html =~ "Member numbers by year" assert html =~ "Overview" or html =~ "overview"
# table header or legend
assert html =~ "Year" assert html =~ "Year"
end end

View file

@ -95,20 +95,6 @@ defmodule MvWeb.MemberLive.IndexGroupsDisplayTest do
assert html =~ member3.first_name assert html =~ member3.first_name
end end
test "empty group cell is visually empty with sr-only text (no dash)", %{
conn: conn,
member3: member3
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
assert html =~ member3.first_name
# Screen reader gets a meaningful label for the empty cell
assert html =~ "sr-only"
assert html =~ "No group assignment"
# No visible dash as placeholder (Design Guidelines §8.6)
refute html =~ ~r/<span[^>]*class="[^"]*text-base-content\/50[^"]*"[^>]*>—<\/span>/
end
test "displays group name correctly in badge", %{conn: conn, group1: group1} do test "displays group name correctly in badge", %{conn: conn, group1: group1} do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members") {:ok, _view, html} = live(conn, "/members")

View file

@ -123,17 +123,13 @@ defmodule MvWeb.UserLive.IndexTest do
{:ok, index_view, _html} = live(conn, "/users") {:ok, index_view, _html} = live(conn, "/users")
assert render(index_view) =~ "delete-me@example.com" assert render(index_view) =~ "delete-me@example.com"
# Navigate to user show, open delete modal, then confirm in modal (WCAG modal pattern) # Navigate to user show and trigger delete from Danger zone
{:ok, show_view, _html} = live(conn, "/users/#{user.id}") {:ok, show_view, _html} = live(conn, "/users/#{user.id}")
show_view show_view
|> element("[data-testid=user-delete]") |> element("[data-testid=user-delete]")
|> render_click() |> render_click()
show_view
|> element("#delete-user-modal button", "Delete")
|> render_click()
# Should redirect to index # Should redirect to index
assert_redirect(show_view, "/users") assert_redirect(show_view, "/users")
@ -210,9 +206,7 @@ defmodule MvWeb.UserLive.IndexTest do
end end
describe "Password column display" do describe "Password column display" do
test "user without password shows empty cell with sr-only text in Password column", %{ test "user without password shows em dash in Password column", %{conn: conn} do
conn: conn
} do
# User created with hashed_password: nil (no password) - must not get default password # User created with hashed_password: nil (no password) - must not get default password
user_no_pw = user_no_pw =
create_test_user(%{ create_test_user(%{
@ -225,13 +219,9 @@ defmodule MvWeb.UserLive.IndexTest do
assert html =~ "no-password@example.com" assert html =~ "no-password@example.com"
# Password column: visually empty, screen-reader gets "Not set" (Design Guidelines §8.6) # Password column must show "—" (em dash) for user without password, not "Enabled"
row = view |> element("tr#row-#{user_no_pw.id}") |> render() row = view |> element("tr#row-#{user_no_pw.id}") |> render()
assert row =~ "sr-only", "Password column should have sr-only text for accessibility" assert row =~ "", "Password column should show em dash for user without password"
assert row =~ "Not set", "Screen reader should get 'Not set' for empty password"
refute row =~ "",
"Password column must not show dash (use empty cell + sr-only per CODE_GUIDELINES §8)"
refute row =~ "Enabled", refute row =~ "Enabled",
"Password column must not show Enabled when user has no password" "Password column must not show Enabled when user has no password"