Compare commits

..

22 commits

Author SHA1 Message Date
70d574813c Merge pull request 'chore(deps): update renovate/renovate docker tag to v42.99' (#455) from renovate/renovate-renovate-42.x into main
Some checks reported errors
continuous-integration/drone/push Build was killed
Reviewed-on: #455
2026-03-03 14:30:05 +01:00
Renovate Bot
30b61718a7 chore(deps): update renovate/renovate docker tag to v42.99
Some checks reported errors
continuous-integration/drone/push Build was killed
2026-03-03 14:29:45 +01:00
a37c2f5d13 Merge pull request 'chore(deps): update mix dependencies' (#453) from renovate/mix-dependencies into main
Some checks reported errors
continuous-integration/drone/push Build was killed
Reviewed-on: #453
2026-03-03 14:28:16 +01:00
Renovate Bot
844f5a18d1 chore(deps): update mix dependencies
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2026-03-03 00:04:19 +00:00
f3be6ee198 Merge pull request '[Bug] OIDC: use Application config :oidc from runtime.exs for client secret in prod' (#456) from fix/oidc into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #456
2026-03-02 15:18:08 +01:00
3187d408c5
OIDC: use Application config :oidc from runtime.exs for client secret in prod
Some checks reported errors
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/production Build is passing
2026-03-02 15:09:33 +01:00
8fac974b1b Merge pull request 'Enhances accessibiity closes #421' (#450) from feat/421_accessibility into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #450
2026-02-26 21:03:00 +01:00
7f15909cc6 fix tests
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2026-02-26 17:14:47 +01:00
e0484a0533 formatting
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is failing
2026-02-26 15:30:27 +01:00
c71c7d6ed6 fix: color contrast dark mode and keyboard moadals 2026-02-26 15:24:29 +01:00
5516c7fe62 fix: remove + from name in email field 2026-02-26 14:02:47 +01:00
4ac56958b4 feat: keep empty cells consistent empty
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-26 13:37:35 +01:00
9751525a0c fix: datafield edit view was shown alongside othe relements
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-26 12:37:52 +01:00
faf80bfb4b refactor: consistend subheadings
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-26 12:10:42 +01:00
88831685fc i18n: update translations
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-26 11:56:24 +01:00
2c49018ab7 feat: improve color contrast 2026-02-26 11:54:24 +01:00
e422e5f4ef feat: consistent and accessible modal on delete
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-26 11:17:21 +01:00
2922a4d1ee feat: adds keyboard accessibility to tabs 2026-02-26 10:37:57 +01:00
615b4b866b style: fix tab in edit mode 2026-02-26 09:42:10 +01:00
cde6a68591 fix merge format issue 2026-02-26 09:35:09 +01:00
73382c2c3f Merge branch 'main' into feat/421_accessibility
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-26 08:49:55 +01:00
d0b8cb672a style: consistent badges with sufficient color contrast 2026-02-26 08:33:52 +01:00
50 changed files with 2562 additions and 1239 deletions

View file

@ -273,7 +273,7 @@ environment:
steps:
- name: renovate
image: renovate/renovate:42.97
image: renovate/renovate:42.99
environment:
RENOVATE_CONFIG_FILE: "renovate_backend_config.js"
RENOVATE_TOKEN:

View file

@ -2775,6 +2775,14 @@ Building accessible applications ensures that all users, including those with di
<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:**
- Ensure logical tab order matches visual order
@ -2784,7 +2792,11 @@ Building accessible applications ensures that all users, including those with di
### 8.4 Color and Contrast
**Ensure Sufficient Contrast:**
**Ensure Sufficient Contrast (WCAG 2.2 AA: 4.5:1 for normal text):**
- 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
# Tailwind classes with sufficient contrast (4.5:1 minimum)
@ -3003,24 +3015,56 @@ end
- [ ] Skip links are available
- [ ] Tables have proper structure (th, scope, caption)
- [ ] 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 DaisyUI Accessibility
### 8.11 Modals and Dialogs
DaisyUI components are designed with accessibility in mind, but ensure:
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).
**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
<!-- Modal accessibility -->
<dialog id="my-modal" class="modal" aria-labelledby="modal-title">
<!-- Modal: use dialog + aria-labelledby + focus on first focusable (see §8.11) -->
<dialog id="my-modal" class="modal modal-open" role="dialog" aria-labelledby="my-modal-title">
<div class="modal-box">
<h2 id="modal-title"><%= gettext("Confirm Deletion") %></h2>
<h3 id="my-modal-title" class="text-lg font-bold"><%= gettext("Confirm Deletion") %></h3>
<p><%= gettext("Are you sure?") %></p>
<div class="modal-action">
<button class="btn" onclick="document.getElementById('my-modal').close()">
<.button variant="neutral" phx-click="cancel" phx-mounted={JS.focus()}>
<%= gettext("Cancel") %>
</button>
<button class="btn btn-error" phx-click="confirm-delete">
<%= gettext("Delete") %>
</button>
</.button>
<.button variant="danger" phx-click="confirm_delete"><%= gettext("Delete") %></.button>
</div>
</div>
</dialog>

View file

@ -293,6 +293,12 @@ Notes:
- 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.
### 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)
@ -331,14 +337,17 @@ No “silent success”.
### 10.2 Destructive actions: one standard confirmation pattern
- **MUST:** All destructive actions use the same confirm style and wording conventions.
- Choose one approach and standardize:
- `JS.confirm("…")` everywhere (simple, consistent)
- or a modal component everywhere (more flexible, more work)
- **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.
**Recommended copy style:**
- Title/confirm text is clear and specific (what will be deleted, consequences).
- 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)

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.**
## User Documentation (German)
You can find our documentation for users here: https://wiki.local-it.org/s/mila-user-dokumentation
## 📸 Screenshots
![Screenshot placeholder](docs/images/screenshot.png)
*This is how Mila might look in action.*
## 🔑 Features

View file

@ -118,6 +118,138 @@
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
============================================ */
@ -389,4 +521,31 @@
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 */

View file

@ -73,6 +73,53 @@ 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
Hooks.SidebarState = {
mounted() {

View file

@ -0,0 +1,88 @@
# 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,12 +360,28 @@ defmodule Mv.Config do
end
@doc """
Returns the OIDC client secret. ENV first, then Settings.
Returns the OIDC client secret.
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
def oidc_client_secret do
env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret)
case Application.get_env(:mv, :oidc) do
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
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 """
Returns the OIDC admin group name (for role sync). ENV first, then Settings.
@ -426,7 +442,10 @@ defmodule Mv.Config do
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_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_groups_claim_env_set?, do: env_set?("OIDC_GROUPS_CLAIM")
def oidc_only_env_set?, do: env_set?("OIDC_ONLY")

View file

@ -31,6 +31,21 @@ defmodule MvWeb.CoreComponents do
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 """
Renders flash notices.
@ -147,13 +162,16 @@ defmodule MvWeb.CoreComponents do
size_class = size_classes[size]
btn_class = [base_class, size_class] |> Enum.reject(&(&1 == "")) |> Enum.join(" ")
assigns = assign(assigns, :btn_class, btn_class)
assigns =
assigns
|> assign(:btn_class, btn_class)
|> assign(:button_focus_classes, @button_focus_classes)
if rest[:href] || rest[:navigate] || rest[:patch] do
link_class =
if assigns[:disabled],
do: ["btn", btn_class, "btn-disabled"],
else: ["btn", btn_class]
do: ["btn", btn_class, "btn-disabled"] ++ @button_focus_classes,
else: ["btn", btn_class] ++ @button_focus_classes
link_attrs =
if assigns[:disabled] do
@ -176,13 +194,187 @@ defmodule MvWeb.CoreComponents do
"""
else
~H"""
<button class={["btn", @btn_class]} disabled={@disabled} {@rest}>
<button
class={["btn", @btn_class] ++ @button_focus_classes}
disabled={@disabled}
{@rest}
>
{render_slot(@inner_block)}
</button>
"""
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 """
Wraps content with a DaisyUI tooltip. Use for icon-only actions, truncated content,
or status badges that need explanation (Design Guidelines §8.2).
@ -265,7 +457,11 @@ defmodule MvWeb.CoreComponents do
def dropdown_menu(assigns) do
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"""
<div
@ -281,17 +477,10 @@ defmodule MvWeb.CoreComponents do
tabindex="0"
role="button"
aria-haspopup="menu"
aria-expanded={@open}
aria-expanded={if @open, do: "true", else: "false"}
aria-controls={@id}
aria-label={@button_label}
class={[
"btn",
"focus:outline-none",
"focus-visible:ring-2",
"focus-visible:ring-offset-2",
"focus-visible:ring-base-content/20",
@button_class
]}
class={["btn"] ++ @button_focus_classes ++ [@button_class]}
phx-click="toggle_dropdown"
phx-target={@phx_target}
data-testid={@button_testid}
@ -359,7 +548,12 @@ defmodule MvWeb.CoreComponents do
if @checkboxes, do: to_string(Map.get(@selected, item.value, true)), else: nil
}
tabindex="0"
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-base-content/20 focus-visible:ring-inset"
class={
[
"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-keydown="select_item"
phx-key="Enter"
@ -670,6 +864,8 @@ defmodule MvWeb.CoreComponents do
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).
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`),
that row gets a stronger selected outline (ring-primary) for accessibility (not color-only).
@ -752,8 +948,22 @@ defmodule MvWeb.CoreComponents do
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"""
<div class="overflow-auto">
<div
id={@row_click && "#{@id}-keyboard"}
class="overflow-auto"
phx-hook={@row_click && "TableRowKeydown"}
>
<table class="table table-zebra">
<thead>
<tr>
@ -789,6 +999,11 @@ defmodule MvWeb.CoreComponents do
>
<td
: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={
(col[:col_click] && col[:col_click].(@row_item.(row))) ||
(@row_click && @row_click.(row))
@ -812,6 +1027,19 @@ defmodule MvWeb.CoreComponents do
classes
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 =
if col_class do
[col_class | classes]

View file

@ -8,6 +8,16 @@ defmodule MvWeb.Components.ExportDropdown do
use MvWeb, :live_component
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
def mount(socket) do
{:ok, assign(socket, :open, false)}
@ -59,7 +69,7 @@ defmodule MvWeb.Components.ExportDropdown do
<button
type="submit"
role="menuitem"
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"
class={dropdown_item_class()}
aria-label={gettext("Export members to CSV")}
data-testid="export-csv-link"
>
@ -75,7 +85,7 @@ defmodule MvWeb.Components.ExportDropdown do
<button
type="submit"
role="menuitem"
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"
class={dropdown_item_class()}
aria-label={gettext("Export members to PDF")}
data-testid="export-pdf-link"
>

View file

@ -219,6 +219,17 @@ defmodule MvWeb.Helpers.MembershipFeeHelpers do
def status_color(:unpaid), do: "badge-error"
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 """
Gets the icon name for a status.

View file

@ -58,8 +58,9 @@ defmodule MvWeb.Components.MemberFilterComponent do
def render(assigns) do
~H"""
<div
class="relative"
class="relative member-filter-dropdown"
id={@id}
phx-click-away={if @open, do: "close_dropdown", else: nil}
phx-window-keydown={@open && "close_dropdown"}
phx-key="Escape"
phx-target={@myself}
@ -89,21 +90,23 @@ defmodule MvWeb.Components.MemberFilterComponent do
@boolean_filters
)}
</span>
<span
<.badge
:if={active_boolean_filters_count(@boolean_filters) > 0}
class="badge badge-primary badge-sm"
variant="primary"
size="sm"
>
{active_boolean_filters_count(@boolean_filters)}
</span>
<span
</.badge>
<.badge
:if={
(@cycle_status_filter || map_size(@group_filters) > 0) &&
active_boolean_filters_count(@boolean_filters) == 0
}
class="badge badge-primary badge-sm"
variant="primary"
size="sm"
>
{@member_count}
</span>
</.badge>
</.button>
<!--
@ -118,8 +121,6 @@ defmodule MvWeb.Components.MemberFilterComponent do
:if={@open}
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]"
phx-click-away="close_dropdown"
phx-target={@myself}
role="dialog"
aria-label={gettext("Member filter")}
>

View file

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

View file

@ -19,9 +19,32 @@ defmodule MvWeb.DatafieldsLive do
socket
|> assign(:page_title, gettext("Datafields"))
|> assign(:settings, settings)
|> assign(:active_editing_section, nil)}
|> assign(:active_editing_section, nil)
|> assign(:custom_field_delete_modal_open, false)}
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
def render(assigns) do
~H"""
@ -29,31 +52,68 @@ defmodule MvWeb.DatafieldsLive do
<.header>
{gettext("Datafields")}
<:subtitle>
{gettext("Configure member fields and custom data fields.")}
{gettext(
"Configure which data you want to save for your members. Define individual datafields."
)}
</:subtitle>
</.header>
<.form_section title={gettext("Member fields")}>
<%!-- Overview: both sections with form_section wrappers; FocusRestore for custom field delete modal --%>
<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
:if={@active_editing_section != :custom_fields}
module={MvWeb.MemberFieldLive.IndexComponent}
id="member-fields-component"
settings={@settings}
/>
</.form_section>
<.form_section title={gettext("Custom fields")}>
<.form_section title={gettext("Individual Datafields")}>
<.live_component
:if={@active_editing_section != :member_fields}
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
module={MvWeb.MemberFieldLive.IndexComponent}
id="member-fields-component"
settings={@settings}
/>
</div>
<div
: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
module={MvWeb.CustomFieldLive.IndexComponent}
id="custom-fields-component"
actor={@current_user}
/>
</div>
</Layouts.app>
"""
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
def handle_info({:custom_field_saved, _custom_field, action}, socket) do
send_update(MvWeb.CustomFieldLive.IndexComponent,

View file

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

View file

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

View file

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

View file

@ -25,7 +25,7 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
~H"""
<div id={@id}>
<p class="text-sm text-base-content/70 mb-4">
<p :if={!@show_form} class="text-sm text-base-content/70 mb-4">
{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."
)}
@ -52,6 +52,7 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
:if={!@show_form}
id="member_fields"
rows={@member_fields}
row_id={fn {field_name, _field_data} -> "member_field-#{field_name}" end}
row_click={
fn {field_name, _field_data} ->
JS.push("edit_member_field", value: %{"field" => field_name}, target: @myself)
@ -85,12 +86,12 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
label={gettext("Show in overview")}
class="max-w-[9.375rem] text-center"
>
<span :if={field_data.show_in_overview} class="badge badge-success">
<.badge :if={field_data.show_in_overview} variant="success">
{gettext("Yes")}
</span>
<span :if={!field_data.show_in_overview} class="badge badge-ghost">
</.badge>
<.badge :if={!field_data.show_in_overview} variant="neutral">
{gettext("No")}
</span>
</.badge>
</:col>
</.table>
</div>
@ -99,8 +100,8 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
@impl true
def update(assigns, socket) do
# Track previous show_form state to detect when form is closed
previous_show_form = Map.get(socket.assigns, :show_form, false)
# Use socket state so send_update(show_form: false) is the only trigger for "form closed"
previous_show_form = socket.assigns[:show_form] || false
# If show_form is explicitly provided in assigns, reset editing state
socket =
@ -112,20 +113,22 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
socket
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
{:ok,
socket =
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)}
|> assign_new(:editing_member_field, fn -> nil 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}
end
@impl true

View file

@ -38,6 +38,11 @@ defmodule MvWeb.MemberLive.Form do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.form for={@form} id="member-form" phx-change="validate" phx-submit="save">
<div
id="member-form-focus-root"
phx-hook="FocusRestore"
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
>
<.header>
<:leading>
<.button navigate={return_path(@return_to, @member)} variant="neutral">
@ -58,14 +63,31 @@ defmodule MvWeb.MemberLive.Form do
</.header>
<div class="mt-6 space-y-6">
<%!-- Tab navigation: Payments tab not shown on new/edit (only on member show) --%>
<div role="tablist" class="tabs tabs-bordered">
<button type="button" role="tab" class="tab tab-active" aria-selected="true">
<.icon name="hero-identification" class="size-4 mr-2" />
<%!-- Tab navigation: same styling as member show; only Contact Data tab (no Membership Fees on edit) --%>
<div
role="tablist"
class="tabs tabs-bordered tabs-lg bg-base-200/60 rounded-box p-1 w-fit"
>
<button
id="member-tab-contact"
role="tab"
type="button"
tabindex="0"
aria-selected="true"
aria-controls="member-tabpanel-contact"
class="tab tab-active flex items-center gap-2"
>
<.icon name="hero-identification" class="size-4 shrink-0" />
{gettext("Contact Data")}
</button>
</div>
<%!-- Contact Data Tab Content (same structure as member show) --%>
<div
id="member-tabpanel-contact"
role="tabpanel"
aria-labelledby="member-tab-contact"
>
<%!-- Personal Data and Custom Fields Row --%>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<%!-- Personal Data Section --%>
@ -164,7 +186,9 @@ defmodule MvWeb.MemberLive.Form do
<%= 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: ""}>
<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]}
@ -258,15 +282,10 @@ defmodule MvWeb.MemberLive.Form do
)}
</p>
<.button
id="delete-member-form-trigger"
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)
)
}
phx-click="open_delete_modal"
data-testid="member-delete"
aria-label={
gettext("Delete member %{name}",
@ -280,6 +299,51 @@ defmodule MvWeb.MemberLive.Form do
</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>
</.form>
</Layouts.app>
@ -329,6 +393,7 @@ defmodule MvWeb.MemberLive.Form do
|> assign(:available_fee_types, available_fee_types)
|> assign(:interval_warning, nil)
|> assign(:member_field_required_map, member_field_required_map)
|> assign_new(:show_delete_modal, fn -> false end)
|> assign_form()}
end
@ -400,6 +465,32 @@ defmodule MvWeb.MemberLive.Form do
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
def handle_event("delete", %{"id" => id}, socket) do
member = socket.assigns.member
@ -407,10 +498,16 @@ defmodule MvWeb.MemberLive.Form do
cond do
is_nil(member) ->
{:noreply, put_flash(socket, :error, gettext("Member not found"))}
{:noreply,
socket
|> put_flash(:error, gettext("Member not found"))
|> assign(:show_delete_modal, false)}
to_string(id) != to_string(member.id) ->
{:noreply, put_flash(socket, :error, gettext("Member not found"))}
{:noreply,
socket
|> put_flash(:error, gettext("Member not found"))
|> assign(:show_delete_modal, false)}
true ->
handle_member_delete_destroy(socket, member, actor)
@ -427,14 +524,26 @@ defmodule MvWeb.MemberLive.Form do
{:error, %Ash.Error.Forbidden{}} ->
{:noreply,
put_flash(socket, :error, gettext("You do not have permission to delete this member"))}
socket
|> put_flash(:error, gettext("You do not have permission to delete this member"))
|> assign(:show_delete_modal, false)}
{:error, 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
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
notify_parent({:saved, member})

View file

@ -1644,11 +1644,13 @@ defmodule MvWeb.MemberLive.Index do
selected_count = Enum.count(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 =
if any_selected? do
format_selected_member_emails(members, selected_members)
|> Enum.join(", ")
|> URI.encode_www_form()
|> String.replace("+", "%20")
else
""
end

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -18,6 +18,8 @@ defmodule MvWeb.RoleLive.Helpers do
@doc """
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()
def permission_set_badge_class("own_data"), do: "badge badge-neutral badge-sm"
@ -26,6 +28,18 @@ defmodule MvWeb.RoleLive.Helpers do
def permission_set_badge_class("admin"), do: "badge badge-error 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 """
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
import MvWeb.RoleLive.Helpers, only: [permission_set_badge_class: 1]
import MvWeb.RoleLive.Helpers, only: [permission_set_badge_variant: 1]
@impl true
def mount(_params, _session, socket) do

View file

@ -16,15 +16,16 @@
<.table
id="roles"
rows={@roles}
row_id={fn role -> "role-#{role.id}" end}
row_click={fn role -> JS.navigate(~p"/admin/roles/#{role}") end}
row_tooltip={gettext("Click for role details")}
>
<:col :let={role} label={gettext("Name")}>
<div class="flex items-center gap-2">
<span class="font-medium">{role.name}</span>
<%= if role.is_system_role do %>
<span class="badge badge-warning badge-sm">{gettext("System Role")}</span>
<% end %>
<.badge :if={role.is_system_role} variant="warning" size="sm">
{gettext("System Role")}
</.badge>
</div>
</:col>
@ -37,21 +38,22 @@
</:col>
<:col :let={role} label={gettext("Permission Set")}>
<span class={permission_set_badge_class(role.permission_set_name)}>
<.badge variant={permission_set_badge_variant(role.permission_set_name)} size="sm">
{role.permission_set_name}
</span>
</.badge>
</:col>
<:col :let={role} label={gettext("Type")}>
<%= if role.is_system_role do %>
<span class="badge badge-warning badge-sm">{gettext("System")}</span>
<% else %>
<span class="badge badge-ghost badge-sm">{gettext("Custom")}</span>
<% end %>
<.badge :if={role.is_system_role} variant="warning" size="sm">
{gettext("System")}
</.badge>
<.badge :if={!role.is_system_role} variant="neutral" size="sm">
{gettext("Custom")}
</.badge>
</:col>
<:col :let={role} label={gettext("Users")}>
<span class="badge badge-ghost">{get_user_count(role, @user_counts)}</span>
<span class="text-sm">{get_user_count(role, @user_counts)}</span>
</:col>
</.table>
</Layouts.app>

View file

@ -17,7 +17,7 @@ defmodule MvWeb.RoleLive.Show do
require Ash.Query
import MvWeb.RoleLive.Helpers,
only: [format_error: 1, permission_set_badge_class: 1, opts_with_actor: 3]
only: [format_error: 1, permission_set_badge_variant: 1, opts_with_actor: 3]
@impl true
def mount(%{"id" => id}, _session, socket) do
@ -35,7 +35,8 @@ defmodule MvWeb.RoleLive.Show do
socket
|> assign(:page_title, gettext("Show 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{} | _]}} ->
{:ok,
@ -84,35 +85,61 @@ defmodule MvWeb.RoleLive.Show do
error_message = format_error(error)
{:noreply,
put_flash(
socket,
socket
|> put_flash(
:error,
gettext("Failed to delete role: %{error}", error: error_message)
)}
)
|> assign(:show_delete_modal, false)}
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
if role.is_system_role do
{:noreply,
put_flash(
socket,
:error,
gettext("System roles cannot be deleted.")
)}
socket
|> put_flash(:error, gettext("System roles cannot be deleted."))
|> assign(:show_delete_modal, false)}
else
user_count = recalculate_user_count(role, socket.assigns.current_user)
if user_count > 0 do
{:noreply,
put_flash(
socket,
socket
|> put_flash(
:error,
gettext(
"Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first.",
count: user_count
)
)}
)
|> assign(:show_delete_modal, false)}
else
perform_role_deletion(role, socket)
end
@ -156,6 +183,12 @@ defmodule MvWeb.RoleLive.Show do
recalculate_user_count(role, actor)
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
def render(assigns) do
~H"""
@ -187,6 +220,11 @@ defmodule MvWeb.RoleLive.Show do
</:actions>
</.header>
<div
id="role-show-focus-root"
phx-hook="FocusRestore"
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
>
<.list>
<:item title={gettext("Name")}>{@role.name}</:item>
<:item title={gettext("Description")}>
@ -197,16 +235,17 @@ defmodule MvWeb.RoleLive.Show do
<% end %>
</:item>
<:item title={gettext("Permission Set")}>
<span class={permission_set_badge_class(@role.permission_set_name)}>
<.badge variant={permission_set_badge_variant(@role.permission_set_name)}>
{@role.permission_set_name}
</span>
</.badge>
</:item>
<:item title={gettext("System Role")}>
<%= if @role.is_system_role do %>
<span class="badge badge-warning">{gettext("Yes")}</span>
<% else %>
<span class="badge badge-ghost">{gettext("No")}</span>
<% end %>
<.badge :if={@role.is_system_role} variant="warning">
{gettext("Yes")}
</.badge>
<.badge :if={!@role.is_system_role} variant="neutral">
{gettext("No")}
</.badge>
</:item>
</.list>
@ -223,14 +262,9 @@ defmodule MvWeb.RoleLive.Show do
)}
</p>
<.button
id="delete-role-trigger"
variant="danger"
phx-click={JS.push("delete", value: %{id: @role.id})}
data-confirm={
gettext(
"Are you sure you want to delete the role %{name}? This action cannot be undone.",
name: @role.name
)
}
phx-click="open_delete_modal"
data-testid="role-delete"
aria-label={gettext("Delete role %{name}", name: @role.name)}
>
@ -240,6 +274,49 @@ defmodule MvWeb.RoleLive.Show do
</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.",
name: @role.name
)}
</p>
<div class="modal-action">
<.button
type="button"
variant="neutral"
phx-click="cancel_delete_modal"
phx-mounted={JS.focus()}
id="delete-role-modal-cancel"
aria-label={gettext("Cancel")}
>
{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>
"""
end

View file

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

View file

@ -53,7 +53,6 @@ defmodule MvWeb.UserLive.Form do
</.button>
</:leading>
{@page_title}
<:subtitle>{gettext("Use this form to manage user records in your database.")}</:subtitle>
<:actions>
<.button
form="user-form"
@ -66,6 +65,11 @@ defmodule MvWeb.UserLive.Form do
</:actions>
</.header>
<div
id="user-form-focus-root"
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" />
@ -311,16 +315,10 @@ defmodule MvWeb.UserLive.Form do
)}
</p>
<.button
id="delete-user-form-trigger"
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
)
}
phx-click="open_delete_modal"
data-testid="user-delete"
aria-label={gettext("Delete user %{email}", email: @user.email)}
>
@ -331,6 +329,49 @@ defmodule MvWeb.UserLive.Form do
</section>
<% end %>
<%!-- Delete User Confirmation Modal (WCAG: focus in modal, keyboard confirm/cancel) --%>
<%= if @user && assigns[:show_delete_modal] do %>
<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>
<div class="modal-action">
<.button
type="button"
variant="neutral"
phx-click="cancel_delete_modal"
phx-mounted={JS.focus()}
id="delete-user-form-modal-cancel"
aria-label={gettext("Cancel")}
>
{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 class="mt-4">
<.button navigate={return_path(@return_to, @user)} variant="neutral">
{gettext("Cancel")}
@ -340,6 +381,7 @@ defmodule MvWeb.UserLive.Form do
</.button>
</div>
</.form>
</div>
</Layouts.app>
"""
end
@ -399,6 +441,7 @@ defmodule MvWeb.UserLive.Form do
|> assign(:selected_member_name, nil)
|> assign(:unlink_member, false)
|> assign(:focused_member_index, nil)
|> assign_new(:show_delete_modal, fn -> false end)
|> load_initial_members()
|> assign_form()}
end
@ -454,6 +497,32 @@ defmodule MvWeb.UserLive.Form do
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
def handle_event("delete", %{"id" => id}, socket) do
user = socket.assigns.user
@ -461,13 +530,22 @@ defmodule MvWeb.UserLive.Form do
cond do
is_nil(user) ->
{:noreply, put_flash(socket, :error, gettext("User not found"))}
{:noreply,
socket
|> put_flash(:error, gettext("User not found"))
|> assign(:show_delete_modal, false)}
to_string(id) != to_string(user.id) ->
{:noreply, put_flash(socket, :error, gettext("User not found"))}
{:noreply,
socket
|> put_flash(:error, gettext("User not found"))
|> assign(:show_delete_modal, false)}
Mv.Helpers.SystemActor.system_user?(user) ->
{:noreply, put_flash(socket, :error, gettext("System user cannot be deleted."))}
{:noreply,
socket
|> put_flash(:error, gettext("System user cannot be deleted."))
|> assign(:show_delete_modal, false)}
true ->
handle_user_delete_destroy(socket, user, actor)
@ -594,13 +672,24 @@ defmodule MvWeb.UserLive.Form do
{:error, %Ash.Error.Forbidden{}} ->
{:noreply,
put_flash(socket, :error, gettext("You do not have permission to delete this user"))}
socket
|> put_flash(:error, gettext("You do not have permission to delete this user"))
|> assign(:show_delete_modal, false)}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_ash_error(error))}
{:noreply,
socket
|> put_flash(:error, format_ash_error(error))
|> assign(:show_delete_modal, false)}
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
result = perform_member_link_action(socket, user, actor)

View file

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

View file

@ -45,8 +45,6 @@ defmodule MvWeb.UserLive.Show do
</.button>
</:leading>
{gettext("User")} {@user.email}
<:subtitle>{gettext("This is a user record from your database.")}</:subtitle>
<:actions>
<%= if can?(@current_user, :update, @user) do %>
<.button
@ -60,6 +58,11 @@ defmodule MvWeb.UserLive.Show do
</:actions>
</.header>
<div
id="user-show-focus-root"
phx-hook="FocusRestore"
phx-window-keydown={if @show_delete_modal, do: "window_keydown", else: nil}
>
<.list>
<:item title={gettext("Email")}>{@user.email}</:item>
<:item title={gettext("Role")}>{@user.role.name}</:item>
@ -101,15 +104,9 @@ defmodule MvWeb.UserLive.Show do
)}
</p>
<.button
id="delete-user-trigger"
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
)
}
phx-click="open_delete_modal"
data-testid="user-delete"
aria-label={gettext("Delete user %{email}", email: @user.email)}
>
@ -119,6 +116,48 @@ defmodule MvWeb.UserLive.Show do
</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.",
email: @user.email
)}
</p>
<div class="modal-action">
<.button
type="button"
variant="neutral"
phx-click="cancel_delete_modal"
phx-mounted={JS.focus()}
id="delete-user-modal-cancel"
aria-label={gettext("Cancel")}
>
{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>
"""
end
@ -139,10 +178,37 @@ defmodule MvWeb.UserLive.Show do
{:ok,
socket
|> assign(:page_title, gettext("Show User"))
|> assign(:user, user)}
|> assign(:user, user)
|> assign(:show_delete_modal, false)}
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
def handle_event("delete", %{"id" => id}, socket) do
user = socket.assigns.user
@ -150,10 +216,16 @@ defmodule MvWeb.UserLive.Show do
cond do
to_string(id) != to_string(user.id) ->
{:noreply, put_flash(socket, :error, gettext("User not found"))}
{:noreply,
socket
|> put_flash(:error, gettext("User not found"))
|> assign(:show_delete_modal, false)}
Mv.Helpers.SystemActor.system_user?(user) ->
{:noreply, put_flash(socket, :error, gettext("System user cannot be deleted."))}
{:noreply,
socket
|> put_flash(:error, gettext("System user cannot be deleted."))
|> assign(:show_delete_modal, false)}
true ->
handle_user_delete_destroy(socket, user, actor)
@ -170,10 +242,21 @@ defmodule MvWeb.UserLive.Show do
{:error, %Ash.Error.Forbidden{}} ->
{:noreply,
put_flash(socket, :error, gettext("You do not have permission to delete this user"))}
socket
|> put_flash(:error, gettext("You do not have permission to delete this user"))
|> assign(:show_delete_modal, false)}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_ash_error(error))}
{:noreply,
socket
|> put_flash(:error, format_ash_error(error))
|> assign(:show_delete_modal, false)}
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

View file

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

View file

@ -39,7 +39,7 @@ defmodule Mv.MixProject do
{:tidewave, "~> 0.5", only: [:dev]},
{:sourceror, "~> 1.8", only: [:dev, :test]},
{:live_debugger, "~> 0.6", only: [:dev]},
{:ash_admin, "~> 0.13"},
{:ash_admin, "~> 0.14"},
{:ash_postgres, "~> 2.0"},
{:ash_phoenix, "~> 2.0"},
{:ash, "~> 3.0"},

View file

@ -1,17 +1,18 @@
%{
"ash": {:hex, :ash, "3.16.0", "6389927b322ca7fa7990a75730133db44fcff6368adb63f41cf9eec7a5d38862", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.3.14 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1ea69d932ea2ae6cc2971b92576d8ac2721218a8f2f3599e0e25305edb56949b"},
"ash_admin": {:hex, :ash_admin, "0.13.24", "4fafddc7b4450a92878b58630688c55cab20b0c27e35cad68f29811f73815816", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.1.8 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "8f298cf6cb6635744ed75dd401295ed52513ea4df169f0f89d6a9a75dc4de4dc"},
"ash": {:hex, :ash, "3.19.1", "b5e933547d948e44d27adaed5737195488292fc2066e7fe60dd3ac83a0c4e54f", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.3.14 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "697ac3e4fc6080cb03b1e4ee9088cb8a313a5299686ba1aa91efc86ec4028b6e"},
"ash_admin": {:hex, :ash_admin, "0.14.0", "1a8f61f6cef7af757852e94a916a152bd3f3c3620b094de84a008120675adccd", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.1.8 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:cinder, "~> 0.9", [hex: :cinder, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "d3bc34c266491ae3177f2a76ad97bbe916c4d3a41d56196db9d95e76413b3455"},
"ash_authentication": {:hex, :ash_authentication, "4.13.7", "421b5ddb516026f6794435980a632109ec116af2afa68a45e15fb48b41c92cfa", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "> 0.2.0 and < 0.3.0", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "0d45ac3fdcca6902dabbe161ce63e9cea8f90583863c2e14261c9309e5837121"},
"ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.15.0", "89e71e96a3d954aed7ed0c1f511d42cbfd19009b813f580b12749b01bbea5148", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "d2da66dcf62bc1054ce8f5d9c2829b1dff1dbc3f1d03f9ef0cbe89123d7df107"},
"ash_phoenix": {:hex, :ash_phoenix, "2.3.19", "244b24256a7d730e5223f36f371a95971542a547a12f0fb73406f67977e86c97", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "754a7d869a3961a927abb7ff700af9895d2e69dd3b8f9471b0aa8e859cc4b135"},
"ash_postgres": {:hex, :ash_postgres, "2.6.31", "2fde375f7ff5b0a4d1ec54d64089e65c4460ff08be222119e7587b820ebd782b", [:mix], [{:ash, "~> 3.15", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.4.3 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "0f045d905fe63eb6d43313309dded5db294e437fb8e9ddcf769d4f838b9c5274"},
"ash_sql": {:hex, :ash_sql, "0.4.4", "7e8943b984ad416ba46d297fea6b4d2bcea25c8dfe5666e22d14c42182907798", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, ">= 3.13.4 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "19859ba3f111f1e6e4b0b9ab2f7d849e17b6b0ea5dc54811b3e2b54a7ddff5c0"},
"ash_phoenix": {:hex, :ash_phoenix, "2.3.20", "022682396892046f48dc35a137bbea9c1e4c6a6d58e71d795defd2f071c3b138", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "0655a90b042a5e8873b32ba2f0b52c7c9b8da0fd415518bef41ac03a7b07e02e"},
"ash_postgres": {:hex, :ash_postgres, "2.6.32", "4bdb281bdffd69c08337396d00ffa0ee429a83b5ac3c843e3982ecfb0aae342b", [:mix], [{:ash, "~> 3.15", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.4.3 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "d1df73f9425bd8fbff325a21e06b4ae64a1eebdec38ed524121f2ebbbd62c971"},
"ash_sql": {:hex, :ash_sql, "0.4.5", "30030675ce995570fcedccd3c0671d85beff03cc0c480e7da5002842dccf0277", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, ">= 3.13.4 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "131e06e13ebcf06fc8d050267a5b29f6cc8ef6a781712e61a456f17726a64ea5"},
"assent": {:hex, :assent, "0.2.13", "11226365d2d8661d23e9a2cf94d3255e81054ff9d88ac877f28bfdf38fa4ef31", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "bf9f351b01dd6bceea1d1f157f05438f6765ce606e6eb8d29296003d29bf6eab"},
"bandit": {:hex, :bandit, "1.10.2", "d15ea32eb853b5b42b965b24221eb045462b2ba9aff9a0bda71157c06338cbff", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "27b2a61b647914b1726c2ced3601473be5f7aa6bb468564a688646a689b3ee45"},
"bandit": {:hex, :bandit, "1.10.3", "1e5d168fa79ec8de2860d1b4d878d97d4fbbe2fdbe7b0a7d9315a4359d1d4bb9", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "99a52d909c48db65ca598e1962797659e3c0f1d06e825a50c3d75b74a5e2db18"},
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
"cinder": {:hex, :cinder, "0.12.1", "02ae4988e025fb32c37e4e7f2e491586b952918c0dd99d856da13271cd680e16", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, "~> 2.3", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 1.0.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "a48b5677c1f57619d9d7564fb2bd7928f93750a2e8c0b1b145852a30ecf2aa20"},
"circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"},
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
"credo": {:hex, :credo, "1.7.16", "a9f1389d13d19c631cb123c77a813dbf16449a2aebf602f590defa08953309d4", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d0562af33756b21f248f066a9119e3890722031b6d199f22e3cf95550e4f1579"},
@ -20,12 +21,12 @@
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
"ecto_commons": {:hex, :ecto_commons, "0.3.6", "7b1d9e59396cf8c8cbe5a26d50d03f9b6d0fe6c640210dd503622f276f1e59bb", [:mix], [{:burnex, "~> 3.0", [hex: :burnex, repo: "hexpm", optional: true]}, {:ecto, "~> 3.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:ex_phone_number, "~> 0.2", [hex: :ex_phone_number, repo: "hexpm", optional: false]}, {:luhn, "~> 0.3.0", [hex: :luhn, repo: "hexpm", optional: false]}], "hexpm", "3f12981a1e398f206c5d2014e7b732b7ec91b110b9cb84875cb5b28fc75d7a0a"},
"ecto_commons": {:hex, :ecto_commons, "0.3.7", "f33c162a6f63695d5939af02c65a0e76aa6e7278b82c7bfc357ffbfea353bf0f", [:mix], [{:burnex, "~> 3.0", [hex: :burnex, repo: "hexpm", optional: true]}, {:ecto, "~> 3.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:ex_phone_number, "~> 0.4", [hex: :ex_phone_number, repo: "hexpm", optional: false]}, {:luhn, "~> 0.3.0", [hex: :luhn, repo: "hexpm", optional: false]}], "hexpm", "9c33771ebd38cd83d3f90fab6069826ba9d4f7580f1481b3c0913f8b9795c5fd"},
"ecto_sql": {:hex, :ecto_sql, "3.13.4", "b6e9d07557ddba62508a9ce4a484989a5bb5e9a048ae0e695f6d93f095c25d60", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2b38cf0749ca4d1c5a8bcbff79bbe15446861ca12a61f9fba604486cb6b62a14"},
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
"ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"},
"ex_phone_number": {:hex, :ex_phone_number, "0.4.8", "c1c5e6f0673822a2a7b439b43af7d3eb1a5c19ae582b772b8b8d12625dd51ec1", [:mix], [{:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}], "hexpm", "43e2357c6b8cfe556bcd417f4ce9aaef267a786e31a2938902daaa0d36f69757"},
"ex_phone_number": {:hex, :ex_phone_number, "0.4.10", "11809f6600b2ecb0a2e75d496c2ec2f273d49d1e2f58b2be2667decb0aabfb43", [:mix], [{:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}], "hexpm", "eefccf58d8149d64af658721bff0edcb9e9b8943f74000ede151948ef03046c1"},
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
"finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"},
@ -43,7 +44,7 @@
"jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"},
"lazy_html": {:hex, :lazy_html, "0.1.10", "ffe42a0b4e70859cf21a33e12a251e0c76c1dff76391609bd56702a0ef5bc429", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "50f67e5faa09d45a99c1ddf3fac004f051997877dc8974c5797bb5ccd8e27058"},
"libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"},
"live_debugger": {:hex, :live_debugger, "0.6.0", "77fcbb11b1909ff6edc29a755aa5f14cb176d188b24593526b3e482be7519990", [:mix], [{:file_system, "~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.8 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "ad2458f4acd8b86e15b1cf7aef3304907e858f4ac35644986e5c958ea993ffb3"},
"live_debugger": {:hex, :live_debugger, "0.6.1", "da9c9813105380c92d3318a40d47c7514b078d85b2502238788dc22aa6e2f5c2", [:mix], [{:file_system, "~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.8 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "e0893569b427abc5e94fd43d24ddd9cbbe68e57d99364ae1efb8a6c1d597c9db"},
"luhn": {:hex, :luhn, "0.3.3", "5aa0c6a32c2db4b9db9f9b883ba8301c1ae169d57199b9e6cb1ba2707bc51d96", [:mix], [], "hexpm", "3e823a913a25aab51352c727f135278d22954874d5f0835be81ed4fec3daf78d"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
@ -52,13 +53,13 @@
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"},
"phoenix": {:hex, :phoenix, "1.8.3", "49ac5e485083cb1495a905e47eb554277bdd9c65ccb4fc5100306b350151aa95", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "36169f95cc2e155b78be93d9590acc3f462f1e5438db06e6248613f27c80caec"},
"phoenix": {:hex, :phoenix, "1.8.4", "0387f84f00071cba8d71d930b9121b2fb3645197a9206c31b908d2e7902a4851", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "c988b1cd3b084eebb13e6676d572597d387fa607dab258526637b4e6c4c08543"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
"phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"},
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.22", "9b3c985bfe38e82668594a8ce90008548f30b9f23b718ebaea4701710ce9006f", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e1395d5622d8bf02113cb58183589b3da6f1751af235768816e90cc3ec5f1188"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.25", "abc1bdf7f148d7f9a003f149834cc858b24290c433b10ef6d1cbb1d6e9a211ca", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b8946e474799da1f874eab7e9ce107502c96ca318ed46d19f811f847df270865"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
"phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"},
@ -72,24 +73,24 @@
"rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"},
"slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"},
"sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"},
"sourceror": {:hex, :sourceror, "1.10.1", "325753ed460fe9fa34ebb4deda76d57b2e1507dcd78a5eb9e1c41bfb78b7cdfe", [:mix], [], "hexpm", "288f3079d93865cd1e3e20df5b884ef2cb440e0e03e8ae393624ee8a770ba588"},
"spark": {:hex, :spark, "2.4.0", "f93d3ae6b5f3004e956d52f359fa40670366685447631bc7c058f4fbf250ebf3", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "4e5185f5737cd987bb9ef377ae3462a55b8312f5007c2bc4ad6e850d14ac0111"},
"spitfire": {:hex, :spitfire, "0.3.3", "be195b27648f21454932bf46014455cdbce4fca55fef1f0e41d36076c47b6c4a", [:mix], [], "hexpm", "5dc51c3b61a1d98cdcac1c130f0a374d22d51beed982df90834bdd616356e1fa"},
"sourceror": {:hex, :sourceror, "1.11.0", "df2cdaffdc323e804009ff50b50bb31e6f2d6e116d936ccf22981f592594d624", [:mix], [], "hexpm", "6e26f572bdfc21d7ad397f596b4cfbbf31d7112126fe3e902c120947073231a8"},
"spark": {:hex, :spark, "2.4.1", "d6807291e74b51f6efb6dd4e0d58216ae3729d45c35c456e049556e7e946e364", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "8b065733de9840cac584515f82182ac5ba66a973a47bc5036348dc740662b46b"},
"spitfire": {:hex, :spitfire, "0.3.7", "d6051f94f554d33d038ab3c1d7e017293ae30429cc6b267b08cb6ad69e35e9a3", [], [], "hexpm", "798ff97db02477b05fa3db8e2810cebda6ed5d90c6de6b21aa65abd577599744"},
"splode": {:hex, :splode, "0.3.0", "ff8effecc509a51245df2f864ec78d849248647c37a75886033e3b1a53ca9470", [:mix], [], "hexpm", "73cfd0892d7316d6f2c93e6e8784bd6e137b2aa38443de52fd0a25171d106d81"},
"stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"},
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
"swoosh": {:hex, :swoosh, "1.21.0", "9f4fa629447774cfc9ad684d8a87a85384e8fce828b6390dd535dfbd43c9ee2a", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9127157bfb33b7e154d0f1ba4e888e14b08ede84e81dedcb318a2f33dbc6db51"},
"swoosh": {:hex, :swoosh, "1.22.1", "8450ac62d0a7cb82f0765592037cab2d30cbc7801acd879f77b8f672a9b49f58", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "13795cd69e137c7a6b99850b938177fa3713bd6b95e92b3bdcdb29b70e88868e"},
"tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
"text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"},
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
"tidewave": {:hex, :tidewave, "0.5.4", "b7b6db62779a6faf139e630eb54f218cf3091ec5d39600197008db8474cb6fb2", [:mix], [{:bandit, ">= 1.10.1", [hex: :bandit, repo: "hexpm", optional: true]}, {:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "252c7cf4ffe81d4c5ad8ef709333e7124c5af554aa07dceab61135d0f205a898"},
"tidewave": {:hex, :tidewave, "0.5.5", "a125dfc87f99daf0e2280b3a9719b874c616ead5926cdf9cdfe4fcc19a020eff", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "825ebb4fa20de005785efa21e5a88c04d81c3f57552638d12ff3def2f203dbf7"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
"yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"},
"yaml_elixir": {:hex, :yaml_elixir, "2.12.0", "30343ff5018637a64b1b7de1ed2a3ca03bc641410c1f311a4dbdc1ffbbf449c7", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "ca6bacae7bac917a7155dca0ab6149088aa7bc800c94d0fe18c5238f53b313c6"},
"yaml_elixir": {:hex, :yaml_elixir, "2.12.1", "d74f2d82294651b58dac849c45a82aaea639766797359baff834b64439f6b3f4", [], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "d9ac16563c737d55f9bfeed7627489156b91268a3a21cd55c54eb2e335207fed"},
"ymlr": {:hex, :ymlr, "5.1.4", "b924d61e1fc1ec371cde6ab3ccd9311110b1e052fc5c2460fb322e8380e7712a", [:mix], [], "hexpm", "75f16cf0709fbd911b30311a0359a7aa4b5476346c01882addefd5f2b1cfaa51"},
}

View file

@ -36,7 +36,12 @@ msgid "City"
msgstr "Stadt"
#: 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/role_live/show.ex
#: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format
msgid "Delete"
msgstr "Löschen"
@ -257,9 +262,12 @@ msgstr "Dein Passwort wurde erfolgreich zurückgesetzt"
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.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/show.ex
#, elixir-autogen, elixir-format
msgid "Cancel"
msgstr "Abbrechen"
@ -294,7 +302,6 @@ msgid "Logout"
msgstr "Abmelden"
#: lib/mv_web/live/user_live/index.ex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Listing Users"
msgstr "Benutzer*innen auflisten"
@ -381,16 +388,6 @@ msgstr "Benutzer*in speichern"
msgid "Show User"
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/show.ex
#, elixir-autogen, elixir-format
@ -533,6 +530,7 @@ msgstr "Suchen..."
#: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/role_live/index.html.heex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Users"
msgstr "Benutzer*innen"
@ -593,18 +591,6 @@ msgstr "Diese E-Mail-Adresse ist bereits mit einem anderen OIDC-Konto verknüpft
msgid "Custom Fields"
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
#, elixir-autogen, elixir-format
msgid "Enter the text above to confirm"
@ -790,6 +776,7 @@ msgstr "Beitragsdaten"
msgid "Payments"
msgstr "Zahlungen"
#: lib/mv_web/live/datafields_live.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
@ -1388,6 +1375,8 @@ msgid "None (no default)"
msgstr "Keine (kein Standard)"
#: 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
msgid "Not set"
msgstr "Nicht gesetzt"
@ -1473,11 +1462,6 @@ msgstr "Art"
msgid "Type '%{confirmation}' to confirm"
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
#, elixir-autogen, elixir-format
msgid "Warning"
@ -1704,11 +1688,6 @@ msgstr "System-Rollen können nicht gelöscht werden."
msgid "Toggle sidebar"
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
#, elixir-autogen, elixir-format
msgid "User menu"
@ -2428,11 +2407,6 @@ msgstr "Alle Jahre zusammengefasst (Kreis)"
msgid "Contributions by year"
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
#, elixir-autogen, elixir-format
msgid "Contributions by year as table with stacked bars"
@ -2910,11 +2884,6 @@ msgstr "CSV Datei auswählen"
msgid "Import Members"
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
#, elixir-autogen, elixir-format
msgid "Admin group name"
@ -2940,21 +2909,6 @@ msgstr "Client-ID"
msgid "Client Secret"
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/live/datafields_live.ex
#, elixir-autogen, elixir-format
@ -2996,11 +2950,6 @@ msgstr "Aus OIDC_REDIRECT_URI"
msgid "Groups claim"
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
#, elixir-autogen, elixir-format, fuzzy
msgid "Membership fee settings"
@ -3225,88 +3174,64 @@ msgstr "Das Löschen dieses Datenfeldes kann nicht rückgängig gemacht werden.
msgid "Individual datafields"
msgstr "Individuelle Datenfelder"
#~ #: lib/mv_web/live/member_field_live/form_component.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Back to Settings"
#~ msgstr "Zurück zu den Einstellungen"
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Delete Member"
msgstr "Mitglied löschen"
#~ #: lib/mv_web/live/role_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Cannot delete system role"
#~ msgstr "System-Rolle kann nicht gelöscht werden"
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Delete Role"
msgstr "Rolle löschen"
#~ #: lib/mv_web/live/custom_field_live/index_component.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Click for custom field details"
#~ msgstr "Klicke für Datenfeld-Details"
#: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Delete User"
msgstr "Benutzer*in löschen"
#~ #: lib/mv_web/live/member_field_live/index_component.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Click for datafield details"
#~ msgstr "Klicke für Datenfeld-Details"
#: lib/mv_web/live/membership_fee_settings_live.ex
#, elixir-autogen, elixir-format
msgid "Configure fee types for membership fees."
msgstr "Verwalte Beitragsarten und Mitgliedsbeiträge."
#~ #: lib/mv_web/live/member_live/form.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Coming soon"
#~ msgstr "Demnächst verfügbar"
#: 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 "Verwalte welche Daten du für eure Mitglieder speichern möchtest. Lege individuelle datenfelder an."
#~ #: lib/mv_web/live/components/field_visibility_dropdown_component.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Custom Field %{id}"
#~ msgstr "Benutzerdefiniertes Feld %{id}"
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy
msgid "Manage users and their permissions."
msgstr "Verwalte Benutzer*innen und ihre Berechtigungen."
#~ #: lib/mv_web/live/custom_field_live/index_component.ex
#~ #: lib/mv_web/live/member_field_live/index_component.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Edit datafield"
#~ msgstr "Datenfeld bearbeiten"
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "%{count} member has a value assigned for this datafield."
msgid_plural "%{count} members have values assigned for this datafield."
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/user_live/index.html.heex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Edit user"
#~ msgstr "Benutzer*in bearbeiten"
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Individual Datafields"
msgstr "Individuelle Datenfelder"
#~ #: lib/mv_web/live/components/member_filter_component.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Reset"
#~ msgstr "Zurücksetzen"
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy
msgid "No group assignment"
msgstr "Keine Gruppenzuordnung"
#~ #: lib/mv_web/live/role_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Rolle bearbeiten"
#~ msgstr "Rolle bearbeiten"
#: 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 "Nicht angegeben"
#~ #: lib/mv_web/live/role_live/form.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Save Role"
#~ msgstr "Rolle speichern"
#~ #: lib/mv_web/live/user_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Select all users"
#~ msgstr "Alle Benutzer*innen auswählen"
#~ #: lib/mv_web/live/user_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Select user"
#~ msgstr "Benutzer*in auswählen"
#~ #: lib/mv_web/live/role_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "System roles cannot be deleted"
#~ msgstr "System-Rollen können nicht gelöscht werden"
#~ #: lib/mv_web/live/group_live/index.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "View"
#~ msgstr "Anzeigen"
#~ #: lib/mv_web/live/member_live/index.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "You do not have permission to access this member"
#~ msgstr "Du hast keine Berechtigung, auf dieses Mitglied zuzugreifen"
#~ #: lib/mv_web/live/user_live/index.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "You do not have permission to access this user"
#~ msgstr "Du hast keine Berechtigung, auf diese*n Benutzer*in zuzugreifen"
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "All datafield values will be permanently deleted when you delete this datafield."
msgstr "Alle benutzerdefinierten Feldwerte werden beim Löschen dieses benutzerdefinierten Feldes dauerhaft gelöscht."

View file

@ -37,7 +37,12 @@ msgid "City"
msgstr ""
#: 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/role_live/show.ex
#: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format
msgid "Delete"
msgstr ""
@ -258,9 +263,12 @@ msgstr ""
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.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/show.ex
#, elixir-autogen, elixir-format
msgid "Cancel"
msgstr ""
@ -295,7 +303,6 @@ msgid "Logout"
msgstr ""
#: lib/mv_web/live/user_live/index.ex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Listing Users"
msgstr ""
@ -382,16 +389,6 @@ msgstr ""
msgid "Show User"
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/show.ex
#, elixir-autogen, elixir-format
@ -534,6 +531,7 @@ msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/role_live/index.html.heex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Users"
msgstr ""
@ -594,18 +592,6 @@ msgstr ""
msgid "Custom Fields"
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
#, elixir-autogen, elixir-format
msgid "Enter the text above to confirm"
@ -791,6 +777,7 @@ msgstr ""
msgid "Payments"
msgstr ""
#: lib/mv_web/live/datafields_live.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
@ -1389,6 +1376,8 @@ msgid "None (no default)"
msgstr ""
#: 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
msgid "Not set"
msgstr ""
@ -1474,11 +1463,6 @@ msgstr ""
msgid "Type '%{confirmation}' to confirm"
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
#, elixir-autogen, elixir-format
msgid "Warning"
@ -1705,11 +1689,6 @@ msgstr ""
msgid "Toggle sidebar"
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
#, elixir-autogen, elixir-format
msgid "User menu"
@ -2429,11 +2408,6 @@ msgstr ""
msgid "Contributions by year"
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
#, elixir-autogen, elixir-format
msgid "Contributions by year as table with stacked bars"
@ -2935,21 +2909,6 @@ msgstr ""
msgid "Client Secret"
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/live/datafields_live.ex
#, elixir-autogen, elixir-format
@ -2991,11 +2950,6 @@ msgstr ""
msgid "Groups claim"
msgstr ""
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format
msgid "Member fields"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
msgid "Membership fee settings"
@ -3219,3 +3173,65 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "Individual datafields"
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,7 +37,12 @@ msgid "City"
msgstr ""
#: 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/role_live/show.ex
#: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format
msgid "Delete"
msgstr ""
@ -258,9 +263,12 @@ msgstr ""
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.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/show.ex
#, elixir-autogen, elixir-format
msgid "Cancel"
msgstr ""
@ -295,7 +303,6 @@ msgid "Logout"
msgstr ""
#: lib/mv_web/live/user_live/index.ex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy
msgid "Listing Users"
msgstr ""
@ -382,16 +389,6 @@ msgstr ""
msgid "Show User"
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/show.ex
#, elixir-autogen, elixir-format
@ -534,6 +531,7 @@ msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/role_live/index.html.heex
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy
msgid "Users"
msgstr ""
@ -594,18 +592,6 @@ msgstr ""
msgid "Custom Fields"
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
#, elixir-autogen, elixir-format
msgid "Enter the text above to confirm"
@ -791,6 +777,7 @@ msgstr ""
msgid "Payments"
msgstr ""
#: lib/mv_web/live/datafields_live.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
@ -1389,6 +1376,8 @@ msgid "None (no default)"
msgstr ""
#: 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
msgid "Not set"
msgstr ""
@ -1474,11 +1463,6 @@ msgstr ""
msgid "Type '%{confirmation}' to confirm"
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
#, elixir-autogen, elixir-format
msgid "Warning"
@ -1705,11 +1689,6 @@ msgstr ""
msgid "Toggle sidebar"
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
#, elixir-autogen, elixir-format, fuzzy
msgid "User menu"
@ -2429,11 +2408,6 @@ msgstr ""
msgid "Contributions by year"
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
#, elixir-autogen, elixir-format, fuzzy
msgid "Contributions by year as table with stacked bars"
@ -2935,21 +2909,6 @@ msgstr ""
msgid "Client Secret"
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/live/datafields_live.ex
#, elixir-autogen, elixir-format
@ -2991,11 +2950,6 @@ msgstr ""
msgid "Groups claim"
msgstr ""
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Member fields"
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Membership fee settings"
@ -3220,88 +3174,64 @@ msgstr ""
msgid "Individual datafields"
msgstr ""
#~ #: lib/mv_web/live/member_field_live/form_component.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Back to Settings"
#~ msgstr ""
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Delete Member"
msgstr ""
#~ #: lib/mv_web/live/role_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Cannot delete system role"
#~ msgstr ""
#: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Delete Role"
msgstr ""
#~ #: lib/mv_web/live/custom_field_live/index_component.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Click for custom field details"
#~ msgstr ""
#: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Delete User"
msgstr ""
#~ #: lib/mv_web/live/member_field_live/index_component.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Click for datafield details"
#~ 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/member_live/form.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Coming soon"
#~ 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/components/field_visibility_dropdown_component.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Custom Field %{id}"
#~ msgstr ""
#: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy
msgid "Manage users and their permissions."
msgstr ""
#~ #: lib/mv_web/live/custom_field_live/index_component.ex
#~ #: lib/mv_web/live/member_field_live/index_component.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Edit datafield"
#~ msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format, fuzzy
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/user_live/index.html.heex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Edit user"
#~ msgstr ""
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Individual Datafields"
msgstr ""
#~ #: lib/mv_web/live/components/member_filter_component.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Reset"
#~ msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy
msgid "No group assignment"
msgstr ""
#~ #: lib/mv_web/live/role_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Rolle bearbeiten"
#~ 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/role_live/form.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Save Role"
#~ msgstr ""
#~ #: lib/mv_web/live/user_live/index.html.heex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Select all users"
#~ msgstr ""
#~ #: lib/mv_web/live/user_live/index.html.heex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Select user"
#~ msgstr ""
#~ #: lib/mv_web/live/role_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "System roles cannot be deleted"
#~ msgstr ""
#~ #: lib/mv_web/live/group_live/index.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "View"
#~ msgstr ""
#~ #: lib/mv_web/live/member_live/index.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "You do not have permission to access this member"
#~ msgstr ""
#~ #: lib/mv_web/live/user_live/index.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "You do not have permission to access this user"
#~ msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "All datafield values will be permanently deleted when you delete this datafield."
msgstr ""

View file

@ -0,0 +1,91 @@
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,6 +254,14 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
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
test "returns correct color classes for statuses" do
assert MembershipFeeHelpers.status_color(:paid) == "badge-success"

View file

@ -46,15 +46,19 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
%{conn: conn, user: user_with_role}
end
# Delete is in the edit form (FormComponent); open form by clicking the name cell (unique td with phx-click)
# Delete is in the edit form (FormComponent). First row click opens form (overview) or switches
# 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
view
|> element("tr#custom_fields-#{custom_field.id} td", custom_field.name)
|> render_click()
row_selector = "tr#custom_fields-#{custom_field.id} td"
view |> element(row_selector, custom_field.name) |> render_click()
view
|> element("[data-testid=custom-field-delete]")
|> render_click()
if has_element?(view, "[data-testid=custom-field-delete]") do
view |> element("[data-testid=custom-field-delete]") |> render_click()
else
view |> element(row_selector, custom_field.name) |> render_click()
view |> element("[data-testid=custom-field-delete]") |> render_click()
end
end
describe "delete button and modal" do
@ -71,8 +75,12 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
# Modal should be visible
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)
assert render(view) =~ "1 member has a value assigned for this custom field"
assert render(view) =~ "1 member has a value assigned for this datafield"
# Should show the slug
assert render(view) =~ custom_field.slug
@ -91,7 +99,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
open_delete_modal(view, custom_field)
# Should show plural form
assert render(view) =~ "2 members have values assigned for this custom field"
assert render(view) =~ "2 members have values assigned for this datafield"
end
test "shows 0 members for custom field without values", %{conn: conn} do
@ -101,7 +109,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
open_delete_modal(view, custom_field)
# Should show 0 members
assert render(view) =~ "0 members have values assigned for this custom field"
assert render(view) =~ "0 members have values assigned for this datafield"
end
end

View file

@ -83,6 +83,21 @@ defmodule MvWeb.MemberFieldLive.IndexComponentTest do
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
setup do
{:ok, settings} = Membership.get_settings()

View file

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

View file

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

View file

@ -95,6 +95,20 @@ defmodule MvWeb.MemberLive.IndexGroupsDisplayTest do
assert html =~ member3.first_name
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
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")

View file

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