Adds more consistency in various UX topics closes #447 #448
49 changed files with 3802 additions and 2341 deletions
|
|
@ -60,6 +60,9 @@ We are building a membership management system (Mila) using the following techno
|
||||||
7. [Documentation Standards](#7-documentation-standards)
|
7. [Documentation Standards](#7-documentation-standards)
|
||||||
8. [Accessibility Guidelines](#8-accessibility-guidelines)
|
8. [Accessibility Guidelines](#8-accessibility-guidelines)
|
||||||
|
|
||||||
|
**Related documents:**
|
||||||
|
- **UI / UX:** [`DESIGN_DUIDELINES.md`](../DESIGN_DUIDELINES.md) defines visual and interaction consistency: use of CoreComponents (no raw DaisyUI in views), page skeleton (`<.header>`, `mt-6 space-y-6`), **Back button left in header for edit/new forms** (§2.2), typography, buttons, forms, tables, flash/toast, and microcopy (e.g. German "du" and glossary). Follow "components first" and semantic variants instead of hard-coded colors.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Setup and Architectural Conventions
|
## 1. Setup and Architectural Conventions
|
||||||
|
|
|
||||||
426
DESIGN_DUIDELINES.md
Normal file
426
DESIGN_DUIDELINES.md
Normal file
|
|
@ -0,0 +1,426 @@
|
||||||
|
# UI Design Guidelines (Mila / Phoenix LiveView + DaisyUI)
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
This document defines Mila’s **UI system** to ensure **UX consistency**, **accessibility**, and **maintainability** across Phoenix LiveView pages:
|
||||||
|
|
||||||
|
- consistent DaisyUI usage
|
||||||
|
- typography & spacing
|
||||||
|
- button intent & labeling
|
||||||
|
- list/search/filter UX
|
||||||
|
- tables behavior (row click, tooltips, alignment)
|
||||||
|
- flash/toast UX (position, stacking, auto-dismiss, tones)
|
||||||
|
- standard page skeletons (index/detail/form)
|
||||||
|
- microcopy conventions (German “du” tone)
|
||||||
|
|
||||||
|
> Engineering practices (LiveView load budget, testing, security, etc.) are defined in `docs/CODE_GUIDELINES.md`.
|
||||||
|
> This document focuses on **visual + UX** consistency and references engineering rules where needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1) Principles
|
||||||
|
|
||||||
|
### 1.1 Components first (no raw DaisyUI classes in views)
|
||||||
|
- **MUST:** Use `MvWeb.CoreComponents` (e.g. `<.button>`, `<.header>`, `<.table>`, `<.input>`, `<.flash_group>`, `<.form_section>`).
|
||||||
|
- **MUST NOT:** Write DaisyUI component classes directly in LiveViews/HEEX (e.g. `btn`, `alert`, `table`, `input`, `select`, `tooltip`) unless you are implementing them **inside** CoreComponents.
|
||||||
|
- **MAY:** Use Tailwind for layout only: `flex`, `grid`, `gap-*`, `p-*`, `max-w-*`, `sm:*`, etc.
|
||||||
|
|
||||||
|
### 1.2 DaisyUI for look, Tailwind for layout
|
||||||
|
- DaisyUI: component visuals + semantic variants (`btn-primary`, `alert-error`, `badge`, `tooltip`).
|
||||||
|
- Tailwind: spacing, alignment, responsiveness.
|
||||||
|
|
||||||
|
### 1.3 Semantics over hard-coded colors
|
||||||
|
- **MUST NOT:** Use “status colors” in views (`bg-green-500`, `text-blue-500`, …).
|
||||||
|
- **MUST:** Express intent via component props / DaisyUI semantic variants.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) Page Skeleton & “Chrome” (mandatory)
|
||||||
|
|
||||||
|
### 2.1 Standard page layout
|
||||||
|
Every authenticated page should follow the same structure:
|
||||||
|
|
||||||
|
1) `<.header>` (title + optional subtitle + actions)
|
||||||
|
2) content area with consistent vertical rhythm (`mt-6 space-y-6`)
|
||||||
|
3) optional footer actions for forms
|
||||||
|
|
||||||
|
**MUST:** Use `<.header>` on every page (except login/public pages).
|
||||||
|
**SHOULD:** Put short explanations into `<:subtitle>` rather than sprinkling random text blocks.
|
||||||
|
|
||||||
|
### 2.2 Edit/New form header: Back button left (mandatory)
|
||||||
|
|
||||||
|
For LiveViews that render an edit or new form (e.g. member, group, role, user, custom field, membership fee type):
|
||||||
|
|
||||||
|
- **MUST:** Provide a **Back** button on the **left** side of the header using the `<:leading>` slot (same as data fields: Back left, title next, primary action on the right).
|
||||||
|
- **MUST:** Use the same pattern everywhere: Back button with `variant="neutral"`, arrow-left icon, and label “Back”. It navigates to the previous context (e.g. detail page or index) via a `return_path`-style helper.
|
||||||
|
- **SHOULD:** Place the primary action (e.g. “Save”) in `<:actions>` on the right.
|
||||||
|
- **Rationale:** Users expect a consistent way to leave the form without submitting; Back left matches the data fields edit view and keeps primary actions on the right.
|
||||||
|
|
||||||
|
**Template for form pages:**
|
||||||
|
```heex
|
||||||
|
<.header>
|
||||||
|
<:leading>
|
||||||
|
<.button navigate={return_path(@return_to, @resource)} variant="neutral">
|
||||||
|
<.icon name="hero-arrow-left" class="size-4" />
|
||||||
|
{gettext("Back")}
|
||||||
|
</.button>
|
||||||
|
</:leading>
|
||||||
|
Page title (e.g. “Edit Member” or “New User”)
|
||||||
|
<:subtitle>Short explanation.</:subtitle>
|
||||||
|
<:actions>
|
||||||
|
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
|
||||||
|
{gettext("Save")}
|
||||||
|
</.button>
|
||||||
|
</:actions>
|
||||||
|
</.header>
|
||||||
|
```
|
||||||
|
|
||||||
|
If the `<.header>` is outside the `<.form>`, the submit button must reference the form via the `form` attribute (e.g. `form="user-form"`).
|
||||||
|
|
||||||
|
## 3) Typography (system)
|
||||||
|
|
||||||
|
Use these standard roles:
|
||||||
|
|
||||||
|
| Role | Use | Class |
|
||||||
|
|---|---|---|
|
||||||
|
| Page title (H1) | main page title | `text-xl font-semibold leading-8` |
|
||||||
|
| Subtitle | helper under title | `text-sm text-base-content/70` |
|
||||||
|
| Section title (H2) | section headings | `text-lg font-semibold` |
|
||||||
|
| Helper text | under inputs | `text-sm text-base-content/70` |
|
||||||
|
| Fine print | small hints | `text-xs text-base-content/60` |
|
||||||
|
| Empty state | no data | `text-base-content/60 italic` |
|
||||||
|
| Destructive text | danger | `text-error` |
|
||||||
|
|
||||||
|
**MUST:** Page titles via `<.header>`.
|
||||||
|
**MUST:** Section titles via `<.form_section title="…">` (for forms) or a consistent section wrapper (if you introduce a `<.card>` later).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) States: Loading, Empty, Error (mandatory consistency)
|
||||||
|
|
||||||
|
### 4.1 Loading state
|
||||||
|
- **MUST:** Show a consistent loading indicator when data is not ready.
|
||||||
|
- **MUST NOT:** Render empty states while loading (avoid flicker).
|
||||||
|
- **SHOULD:** Prefer “skeleton rows” for tables or a spinner in content area.
|
||||||
|
|
||||||
|
### 4.2 Empty state pattern
|
||||||
|
Empty states must be consistent:
|
||||||
|
- short message
|
||||||
|
- optional primary CTA (“Create …”)
|
||||||
|
- optional secondary help link
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```heex
|
||||||
|
<div class="space-y-3">
|
||||||
|
<p class="text-base-content/60 italic">No members yet.</p>
|
||||||
|
<.button variant="primary" navigate={~p"/members/new"}>Create member</.button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
### 4.3 Error state pattern
|
||||||
|
- **MUST:** Use flash/toast for global errors.
|
||||||
|
- **SHOULD:** Also show inline error state near the relevant content area if the page cannot proceed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5) Buttons (intent, labels, variants)
|
||||||
|
|
||||||
|
### 5.1 Decision rule: action vs status
|
||||||
|
- **MUST:** Button labels describe **actions** (verb-first):
|
||||||
|
- ✅ Save, Create member, Send invite, Import CSV
|
||||||
|
- ❌ Active, Success, Done (status belongs elsewhere)
|
||||||
|
- **MUST:** Status belongs in badges/labels or read-only text, not in CTAs.
|
||||||
|
|
||||||
|
### 5.2 Standard variants (mandatory set)
|
||||||
|
Buttons must be rendered via `<.button>` and mapped to DaisyUI internally.
|
||||||
|
|
||||||
|
**Supported variants:**
|
||||||
|
- `primary` (main CTA)
|
||||||
|
- `secondary` (supporting)
|
||||||
|
- `neutral` (cancel/back)
|
||||||
|
- `ghost` (low emphasis; table/toolbars)
|
||||||
|
- `outline` (alternative CTA)
|
||||||
|
- `danger` (destructive)
|
||||||
|
- `link` (inline; rare)
|
||||||
|
- `icon` (icon-only)
|
||||||
|
|
||||||
|
**Sizes:** `sm`, `md` (default), `lg` (rare)
|
||||||
|
|
||||||
|
### 5.3 Placement rules
|
||||||
|
- Header CTA inside `<.header><:actions>`.
|
||||||
|
- Form footer: primary right; cancel/secondary left.
|
||||||
|
- Tables: use `ghost`/`icon` for row actions (avoid `primary` inside rows).
|
||||||
|
|
||||||
|
### 5.4 Primary vs Secondary (UX consistency rules)
|
||||||
|
|
||||||
|
#### One primary action per screen
|
||||||
|
- MUST: Each screen/section has at most one **primary** action (e.g. Save, Create, Start import).
|
||||||
|
- SHOULD: Additional actions are secondary/neutral/ghost, not additional primary.
|
||||||
|
|
||||||
|
#### Primary vs Secondary meaning
|
||||||
|
- Primary = the most important/most common action to complete the user task.
|
||||||
|
- Secondary = supporting actions (Cancel/Back/Edit in tool contexts), lower emphasis.
|
||||||
|
|
||||||
|
#### Order and placement (choose and apply consistently)
|
||||||
|
We follow these ordering rules:
|
||||||
|
- MUST: Order buttons by priority: **Primary → Secondary → Tertiary**.
|
||||||
|
- Forms: Decide once (primary-left OR primary-right) and apply everywhere.
|
||||||
|
- Dialogs/confirmations: Place the confirmation action consistently (e.g. trailing edge, confirmation closest to edge).
|
||||||
|
|
||||||
|
#### Cancel/Back consistency
|
||||||
|
- MUST: Cancel/Back is **never** styled as primary.
|
||||||
|
- MUST: Cancel/Back placement is consistent across the app (same side, same label).
|
||||||
|
|
||||||
|
#### Implementation requirement
|
||||||
|
- MUST: Use CoreComponents (`<.button>`) with `variant`/`size` props.
|
||||||
|
- MUST NOT: Use ad-hoc classes like `class="secondary"` on `<.button>`; instead extend CoreComponents to support `secondary`, `neutral`, `ghost`, `danger`, etc.
|
||||||
|
|
||||||
|
#### Ghost buttons (accessibility requirements)
|
||||||
|
|
||||||
|
Ghost buttons are allowed for low-emphasis actions (toolbars, table actions), but:
|
||||||
|
|
||||||
|
- MUST: Focus indicator is clearly visible (do not remove outlines).
|
||||||
|
- MUST: UI contrast for the control (and meaningful icons) meets WCAG non-text contrast (≥ 3:1).
|
||||||
|
- MUST: Icon-only ghost buttons provide an accessible name (`aria-label`) and preferably a tooltip.
|
||||||
|
- SHOULD: Hit target is large enough for touch/motor accessibility (recommend ~44x44px).
|
||||||
|
If these cannot be met, use `secondary`/`outline` instead of `ghost`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6) Forms (structure + interaction rules)
|
||||||
|
|
||||||
|
### 6.1 Structure
|
||||||
|
- **MUST:** Forms are grouped into `<.form_section title="…">`.
|
||||||
|
- **MUST:** All inputs via `<.input>`.
|
||||||
|
|
||||||
|
### 6.2 Validation timing (consistent UX)
|
||||||
|
- **MUST:** Validate on submit always.
|
||||||
|
- **SHOULD:** Validate on change only where it helps; use debounce to avoid “error spam”.
|
||||||
|
- **MUST:** Define a consistent “when errors appear” rule:
|
||||||
|
- Preferred: show field errors after first submit attempt OR after the field has been touched (pick one and apply everywhere).
|
||||||
|
|
||||||
|
> Engineering note (implementation): follow LiveView load budget in `CODE_GUIDELINES.md` (no DB reads on `phx-change` by default).
|
||||||
|
|
||||||
|
### 6.3 Required fields
|
||||||
|
- **MUST:** Required fields are marked consistently (UI indicator + accessible text).
|
||||||
|
- **SHOULD:** If required-ness is configurable via settings, display it consistently in the form.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7) Lists, Search & Filters (mandatory UX consistency)
|
||||||
|
|
||||||
|
### 7.1 Standard filter/search bar pattern
|
||||||
|
- **MUST:** All list pages use the same search/filter placement (choose one layout and apply everywhere).
|
||||||
|
- Recommended: top area above the table, aligned with page actions.
|
||||||
|
- **MUST:** Always provide “Clear filters” when filters are active.
|
||||||
|
- **MUST:** Filter state is reflected in URL params (so reload/back/share works consistently).
|
||||||
|
|
||||||
|
### 7.2 URL behavior (UX rule)
|
||||||
|
- Use `push_patch` for in-page state changes: filters, sorting, pagination, tabs.
|
||||||
|
- Use `push_navigate` for actual page transitions: details, edit, new.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8) Tables (mandatory UX)
|
||||||
|
|
||||||
|
### 8.1 Default behavior: row click opens details
|
||||||
|
- **DEFAULT:** Clicking a row navigates to the details page.
|
||||||
|
- **EXCEPTIONS:** Highly interactive rows may disable row-click (document why).
|
||||||
|
- **Row outline (CoreComponents):** When `row_click` is set, rows get a subtle hover and focus-within ring (theme-friendly). Use `selected_row_id` to show a stronger selected outline (e.g. from URL `?highlight=id` or last selection); the Back link from detail can use `?highlight=id` so the row is visually selected when returning to the index.
|
||||||
|
|
||||||
|
**IMPORTANT (correctness with our `<.table>` CoreComponent):**
|
||||||
|
Our table implementation attaches the `phx-click` to the **`<td>`** when `row_click` is set. That means click events bubble from inner elements up to the cell unless we stop propagation.
|
||||||
|
|
||||||
|
So, for interactive elements inside a clickable row, you must **stop propagation using `Phoenix.LiveView.JS.stop_propagation/1`**, not a custom attribute.
|
||||||
|
|
||||||
|
✅ Correct pattern (one click handler that both stops propagation and triggers an event):
|
||||||
|
```heex
|
||||||
|
<.table
|
||||||
|
id="members"
|
||||||
|
rows={@members}
|
||||||
|
row_click={fn m -> JS.navigate(~p"/members/#{m.id}") end}
|
||||||
|
>
|
||||||
|
<:col :let={m} label="Name">
|
||||||
|
<%= m.last_name %>, <%= m.first_name %>
|
||||||
|
</:col>
|
||||||
|
|
||||||
|
<:col :let={m} label="Newsletter">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="checkbox checkbox-sm"
|
||||||
|
checked={m.newsletter}
|
||||||
|
phx-click={JS.push("toggle_newsletter", value: %{id: m.id}) |> JS.stop_propagation()}
|
||||||
|
/>
|
||||||
|
</:col>
|
||||||
|
|
||||||
|
<:action :let={m}>
|
||||||
|
<.button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
navigate={~p"/members/#{m.id}/edit"}
|
||||||
|
phx-click={JS.stop_propagation()}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</.button>
|
||||||
|
</:action>
|
||||||
|
</.table>
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- The checkbox uses `phx-click={JS.push(...) |> JS.stop_propagation()}` so it won’t trigger row navigation.
|
||||||
|
- The Edit button also stops propagation to avoid accidental row navigation when clicked.
|
||||||
|
|
||||||
|
### 8.2 Tooltips (mandatory where needed)
|
||||||
|
- **MUST:** Tooltips for:
|
||||||
|
- icon-only actions
|
||||||
|
- truncated content
|
||||||
|
- status badges that require explanation
|
||||||
|
- **MUST:** Provide tooltips via a shared wrapper (recommended `<.tooltip>` CoreComponent).
|
||||||
|
- **MUST NOT:** Scatter ad-hoc tooltip markup in views.
|
||||||
|
|
||||||
|
### 8.3 Alignment & density conventions
|
||||||
|
- **MUST:** Text columns left-aligned.
|
||||||
|
- **MUST:** Numeric columns right-aligned.
|
||||||
|
- **MUST:** Action column right-aligned.
|
||||||
|
- **SHOULD:** Table density is consistent:
|
||||||
|
- default density for most tables
|
||||||
|
- a single “dense” option only if needed (via a prop, not per-page random classes)
|
||||||
|
|
||||||
|
### 8.4 Truncation standard
|
||||||
|
- **MUST:** Truncate long values consistently (same max widths for name/email-like fields).
|
||||||
|
- **MUST:** Tooltip reveals full value when truncated.
|
||||||
|
|
||||||
|
### 8.5 Loading/Lists/Tables: keep filters visible on desktop
|
||||||
|
- On **desktop (lg: breakpoint)** only: list pages with large datasets (e.g. Members overview) keep the page header and filter/search bar visible while the user scrolls. Only the table body scrolls inside a constrained area (`lg:max-h-[calc(100vh-<offset>)] lg:overflow-auto`). This preserves context and avoids losing filters when scrolling.
|
||||||
|
- On **mobile**, sticky headers are not used; the layout uses normal flow (header and table scroll with the page) to preserve vertical space.
|
||||||
|
- When the table is inside such a scroll container, use the CoreComponents table’s `sticky_header={true}` so the table’s `<thead>` stays sticky within the scroll area on desktop (`lg:sticky lg:top-0`, opaque background `bg-base-100`, z-index). Sticky areas must not overlap content at 200% zoom; focus order must remain header → filters → table.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9) Flash / Toast messages (mandatory UX)
|
||||||
|
|
||||||
|
### 9.1 Location + stacking
|
||||||
|
- **MUST:** Position flash/toasts at the bottom of the viewport (pick bottom-right or bottom-center; be consistent).
|
||||||
|
- **MUST:** Stack all flash messages with consistent spacing.
|
||||||
|
- **SHOULD:** Newest appears on top.
|
||||||
|
|
||||||
|
### 9.2 Auto-dismiss
|
||||||
|
- **MUST:** Flash messages disappear automatically:
|
||||||
|
- info/success: 4–6s
|
||||||
|
- warning: 6–8s
|
||||||
|
- error: 8–12s (or manual dismiss for critical errors)
|
||||||
|
- **MUST:** Keep a dismiss button for accessibility and user control.
|
||||||
|
- **Status:** Not yet implemented. See [feature-roadmap](docs/feature-roadmap.md) → Flash: Auto-dismiss and consistency.
|
||||||
|
|
||||||
|
### 9.3 Variants (unified)
|
||||||
|
- Supported semantic variants: `info`, `success`, `warning`, `error`.
|
||||||
|
- **MUST:** Use the same variants for all flash types, : e.g. `success` for copy success, no separate tone or styling. This keeps flash UX consistent across the app.
|
||||||
|
|
||||||
|
### 9.4 Accessibility
|
||||||
|
- Flash must work with screen readers (live region behavior belongs in the flash component implementation).
|
||||||
|
- See `CODE_GUIDELINES.md` Accessibility → live regions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10) Mutations & feedback patterns (create/update/delete/import)
|
||||||
|
|
||||||
|
### 10.1 Mutation feedback is always two-part
|
||||||
|
For create/update/delete:
|
||||||
|
- **MUST:** Show a toast/flash message
|
||||||
|
- **MUST:** Show a visible UI update (navigate, row removed, values updated)
|
||||||
|
|
||||||
|
No “silent success”.
|
||||||
|
|
||||||
|
### 10.2 Destructive actions: one standard confirmation pattern
|
||||||
|
- **MUST:** All destructive actions use the same confirm style and wording conventions.
|
||||||
|
- Choose one approach and standardize:
|
||||||
|
- `JS.confirm("…")` everywhere (simple, consistent)
|
||||||
|
- or a modal component everywhere (more flexible, more work)
|
||||||
|
|
||||||
|
**Recommended copy style:**
|
||||||
|
- Title/confirm text is clear and specific (what will be deleted, consequences).
|
||||||
|
- Buttons: `Cancel` (neutral) + `Delete` (danger).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11) Detail pages (consistent structure)
|
||||||
|
|
||||||
|
Detail pages should not drift into random layouts.
|
||||||
|
|
||||||
|
**MUST:** Use consistent structure:
|
||||||
|
- header with primary action (Edit)
|
||||||
|
- sections/cards for grouped info
|
||||||
|
- “Danger zone” section at bottom for destructive actions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12) Navigation rules (UX consistency)
|
||||||
|
|
||||||
|
- **MUST:** `push_patch` for in-page state: sorting, filtering, pagination, tabs.
|
||||||
|
- **MUST:** `push_navigate` for page transitions: detail/edit/new.
|
||||||
|
- **SHOULD:** Back button behavior must feel predictable (URL reflects state).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13) Microcopy conventions (German “du” tone + glossary)
|
||||||
|
|
||||||
|
### 13.1 Tone
|
||||||
|
- **MUST:** All German user-facing text uses informal address (“du”).
|
||||||
|
- **MUST:** Use consistent verbs for common actions:
|
||||||
|
- Save: “Speichern”
|
||||||
|
- Cancel: “Abbrechen”
|
||||||
|
- Delete: “Löschen”
|
||||||
|
- Edit: “Bearbeiten”
|
||||||
|
|
||||||
|
### 13.2 Preferred terms (starter glossary)
|
||||||
|
- Member: “Mitglied”
|
||||||
|
- Fee/Contribution: “Beitrag”
|
||||||
|
- Settings: “Einstellungen”
|
||||||
|
- Group: “Gruppe”
|
||||||
|
- Import/Export: “Import/Export”
|
||||||
|
- Clear filters: “Filter zurücksetzen” (use when filters are active; button label in list/filter UX)
|
||||||
|
|
||||||
|
Add to this glossary when new terminology appears.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14) Destructive actions: Delete flow (canonical)
|
||||||
|
|
||||||
|
This section defines the canonical delete flow for list/detail/form resources (e.g. members). Use it as the single pattern; do not introduce a second pattern elsewhere.
|
||||||
|
|
||||||
|
### Tables: no row action buttons
|
||||||
|
- **MUST NOT:** Show Edit or Delete as row action buttons (or dropdown actions) in list/table views.
|
||||||
|
- **MUST:** Remove any existing edit/delete row actions from tables so that the only way to edit or delete is via the flow below.
|
||||||
|
|
||||||
|
### Navigation: row click → details
|
||||||
|
- **MUST:** Clicking a table row navigates to the resource details page (e.g. `/members/:id`).
|
||||||
|
- **MUST NOT:** Use the table for primary edit/delete actions.
|
||||||
|
|
||||||
|
### Edit: from details header, not from table
|
||||||
|
- **MUST:** Provide a clear primary “Edit” CTA in the details page header (e.g. “Edit member”).
|
||||||
|
- **MUST:** Edit is reached from the details page (e.g. “Edit member” button in header), not from the list/table.
|
||||||
|
|
||||||
|
### Delete: only via “Danger zone”
|
||||||
|
- **MUST:** Delete is available only in a dedicated “Danger zone” section at the bottom of the page.
|
||||||
|
- **MUST:** Use the same “Danger zone” on both the details page and the edit form when the user is authorized to destroy the resource.
|
||||||
|
- **MUST NOT:** Place delete in the table, in the header next to Edit, or in any other location outside the Danger zone.
|
||||||
|
|
||||||
|
### Danger zone layout and wording (canonical pattern)
|
||||||
|
- **Heading:** “Danger zone” (H2, `aria-labelledby` for the section, semantic colour e.g. `text-error`).
|
||||||
|
- **Explanatory text:** One short paragraph stating that the action cannot be undone and mentioning consequences (e.g. related data removed). Use `text-base-content/70` for the text.
|
||||||
|
- **Layout:** Section with heading outside a bordered box; content inside a single bordered, rounded box (`border border-base-300 rounded-lg p-4 bg-base-100`).
|
||||||
|
- **Button:** One destructive action only (e.g. “Delete member”). Use CoreComponents `<.button variant="danger">`. No primary or secondary actions mixed inside the Danger zone.
|
||||||
|
|
||||||
|
### Confirmation and button semantics
|
||||||
|
- **MUST:** Use a single confirmation step (e.g. `data-confirm` / browser confirm or one modal). Do not introduce a second confirmation pattern in this flow.
|
||||||
|
- **Confirm copy:** Message must include the resource name and state that the action “cannot be undone” (e.g. “Are you sure you want to delete %{name}? This action cannot be undone.”).
|
||||||
|
- **Button:** Accessible label (visible text + `aria-label` that includes the resource name, e.g. “Delete member %{name}”). Icon (e.g. trash) is optional and must not replace the text label for the primary action.
|
||||||
|
|
||||||
|
### Accessibility
|
||||||
|
- **MUST:** Button has an accessible name (`aria-label` when icon-only or in addition to visible text as above).
|
||||||
|
- **MUST:** Focus and keyboard: button is focusable and activatable via keyboard; focus management must not trap the user.
|
||||||
|
- **MUST:** Contrast and visibility: Danger zone heading and button use semantic danger styling with sufficient contrast (WCAG AA).
|
||||||
|
|
||||||
|
### Authorization visibility
|
||||||
|
- **MUST:** Show the Danger zone only when the current user is authorized to destroy the resource (e.g. `can?(current_user, :destroy, resource)`).
|
||||||
|
- **MUST NOT:** Show the Danger zone or the delete button when the user cannot destroy the resource; no “disabled” delete button for unauthorized users.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
@ -191,6 +191,11 @@
|
||||||
- ❌ Mobile navigation
|
- ❌ Mobile navigation
|
||||||
- ❌ Context-sensitive help
|
- ❌ Context-sensitive help
|
||||||
- ❌ Onboarding tooltips
|
- ❌ Onboarding tooltips
|
||||||
|
- ❌ **Flash: Auto-dismiss and consistency** (Design Guidelines §9)
|
||||||
|
- Auto-dismiss: info/success 4–6s, warning 6–8s, error 8–12s; dismiss button kept for accessibility.
|
||||||
|
- Implement via JS hook (e.g. `FlashAutoDismiss`) + `data-dismiss-ms` (or `data-kind`) on flash component; on timeout push `lv:clear-flash` and hide element.
|
||||||
|
- LiveView: add shared `handle_event("lv:clear-flash", %{"key" => key}, socket)` (e.g. in `MvWeb` live_view quote) calling `clear_flash(socket, key)`.
|
||||||
|
- All flashes (including “Email copied”) use the same variants (info, success, warning, error); no special tone. See `DESIGN_DUIDELINES.md` §9.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,15 +60,15 @@ defmodule MvWeb.CoreComponents do
|
||||||
id={@id}
|
id={@id}
|
||||||
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
|
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
|
||||||
role="alert"
|
role="alert"
|
||||||
class="z-50 toast toast-top toast-end"
|
class="pointer-events-auto"
|
||||||
{@rest}
|
{@rest}
|
||||||
>
|
>
|
||||||
<div class={[
|
<div class={[
|
||||||
"alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap",
|
"alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap",
|
||||||
@kind == :info && "alert-info",
|
@kind == :info && "alert-info",
|
||||||
@kind == :error && "alert-error",
|
@kind == :error && "alert-error",
|
||||||
@kind == :success && "bg-green-500 text-white",
|
@kind == :success && "alert-success",
|
||||||
@kind == :warning && "bg-blue-100 text-blue-800 border border-blue-300"
|
@kind == :warning && "alert-warning"
|
||||||
]}>
|
]}>
|
||||||
<.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" />
|
<.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" />
|
||||||
<.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
|
<.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
|
||||||
|
|
@ -90,33 +90,71 @@ defmodule MvWeb.CoreComponents do
|
||||||
@doc """
|
@doc """
|
||||||
Renders a button with navigation support.
|
Renders a button with navigation support.
|
||||||
|
|
||||||
|
## Variants (Design Guidelines §5.2)
|
||||||
|
- primary (main CTA)
|
||||||
|
- secondary (supporting)
|
||||||
|
- neutral (cancel/back)
|
||||||
|
- ghost (low emphasis; table/toolbars)
|
||||||
|
- outline (alternative CTA)
|
||||||
|
- danger (destructive)
|
||||||
|
- link (inline; rare)
|
||||||
|
- icon (icon-only)
|
||||||
|
|
||||||
|
## Sizes
|
||||||
|
- sm, md (default), lg
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
<.button>Send!</.button>
|
<.button>Send!</.button>
|
||||||
<.button phx-click="go" variant="primary">Send!</.button>
|
<.button phx-click="go" variant="primary">Send!</.button>
|
||||||
<.button navigate={~p"/"}>Home</.button>
|
<.button navigate={~p"/"} variant="secondary">Home</.button>
|
||||||
|
<.button variant="ghost" size="sm">Edit</.button>
|
||||||
<.button disabled={true}>Disabled</.button>
|
<.button disabled={true}>Disabled</.button>
|
||||||
"""
|
"""
|
||||||
attr :rest, :global, include: ~w(href navigate patch method data-testid)
|
attr :rest, :global, include: ~w(href navigate patch method data-testid form)
|
||||||
attr :variant, :string, values: ~w(primary)
|
|
||||||
|
attr :variant, :string,
|
||||||
|
values: ~w(primary secondary neutral ghost outline danger link icon),
|
||||||
|
default: "primary"
|
||||||
|
|
||||||
|
attr :size, :string, values: ~w(sm md lg), default: "md"
|
||||||
attr :disabled, :boolean, default: false, doc: "Whether the button is disabled"
|
attr :disabled, :boolean, default: false, doc: "Whether the button is disabled"
|
||||||
slot :inner_block, required: true
|
slot :inner_block, required: true
|
||||||
|
|
||||||
def button(assigns) do
|
def button(assigns) do
|
||||||
rest = assigns.rest
|
rest = assigns.rest
|
||||||
variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"}
|
variant = assigns[:variant] || "primary"
|
||||||
assigns = assign(assigns, :class, Map.fetch!(variants, assigns[:variant]))
|
size = assigns[:size] || "md"
|
||||||
|
|
||||||
|
variant_classes = %{
|
||||||
|
"primary" => "btn-primary",
|
||||||
|
"secondary" => "btn-secondary",
|
||||||
|
"neutral" => "btn-neutral",
|
||||||
|
"ghost" => "btn-ghost",
|
||||||
|
"outline" => "btn-outline",
|
||||||
|
"danger" => "btn-error",
|
||||||
|
"link" => "btn-link",
|
||||||
|
"icon" => "btn-ghost btn-square"
|
||||||
|
}
|
||||||
|
|
||||||
|
size_classes = %{
|
||||||
|
"sm" => "btn-sm",
|
||||||
|
"md" => "",
|
||||||
|
"lg" => "btn-lg"
|
||||||
|
}
|
||||||
|
|
||||||
|
base_class = Map.fetch!(variant_classes, variant)
|
||||||
|
size_class = size_classes[size]
|
||||||
|
btn_class = [base_class, size_class] |> Enum.reject(&(&1 == "")) |> Enum.join(" ")
|
||||||
|
|
||||||
|
assigns = assign(assigns, :btn_class, btn_class)
|
||||||
|
|
||||||
if rest[:href] || rest[:navigate] || rest[:patch] do
|
if rest[:href] || rest[:navigate] || rest[:patch] do
|
||||||
# For links, we can't use disabled attribute, so we use btn-disabled class
|
|
||||||
# DaisyUI's btn-disabled provides the same styling as :disabled on buttons
|
|
||||||
link_class =
|
link_class =
|
||||||
if assigns[:disabled],
|
if assigns[:disabled],
|
||||||
do: ["btn", assigns.class, "btn-disabled"],
|
do: ["btn", btn_class, "btn-disabled"],
|
||||||
else: ["btn", assigns.class]
|
else: ["btn", btn_class]
|
||||||
|
|
||||||
# Prevent interaction when disabled
|
|
||||||
# Remove navigation attributes to prevent "Open in new tab", "Copy link" etc.
|
|
||||||
link_attrs =
|
link_attrs =
|
||||||
if assigns[:disabled] do
|
if assigns[:disabled] do
|
||||||
rest
|
rest
|
||||||
|
|
@ -138,13 +176,49 @@ defmodule MvWeb.CoreComponents do
|
||||||
"""
|
"""
|
||||||
else
|
else
|
||||||
~H"""
|
~H"""
|
||||||
<button class={["btn", @class]} disabled={@disabled} {@rest}>
|
<button class={["btn", @btn_class]} disabled={@disabled} {@rest}>
|
||||||
{render_slot(@inner_block)}
|
{render_slot(@inner_block)}
|
||||||
</button>
|
</button>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Wraps content with a DaisyUI tooltip. Use for icon-only actions, truncated content,
|
||||||
|
or status badges that need explanation (Design Guidelines §8.2).
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
<.tooltip content={gettext("Edit")}>
|
||||||
|
<.button variant="icon" size="sm"><.icon name="hero-pencil" /></.button>
|
||||||
|
</.tooltip>
|
||||||
|
|
||||||
|
<.tooltip content={@full_name} position="top">
|
||||||
|
<span class="truncate max-w-32">{@full_name}</span>
|
||||||
|
</.tooltip>
|
||||||
|
"""
|
||||||
|
attr :content, :string, required: true, doc: "Tooltip text (data-tip)"
|
||||||
|
|
||||||
|
attr :position, :string,
|
||||||
|
values: ~w(top bottom left right),
|
||||||
|
default: "bottom"
|
||||||
|
|
||||||
|
attr :wrap_class, :string, default: nil, doc: "Additional classes for the wrapper"
|
||||||
|
slot :inner_block, required: true
|
||||||
|
|
||||||
|
def tooltip(assigns) do
|
||||||
|
position_class = "tooltip tooltip-#{assigns.position}"
|
||||||
|
wrap_class = [position_class, assigns.wrap_class] |> Enum.reject(&is_nil/1) |> Enum.join(" ")
|
||||||
|
|
||||||
|
assigns = assign(assigns, :wrap_class, wrap_class)
|
||||||
|
|
||||||
|
~H"""
|
||||||
|
<div class={@wrap_class} data-tip={@content}>
|
||||||
|
{render_slot(@inner_block)}
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Renders a dropdown menu.
|
Renders a dropdown menu.
|
||||||
|
|
||||||
|
|
@ -437,7 +511,7 @@ defmodule MvWeb.CoreComponents do
|
||||||
{@rest}
|
{@rest}
|
||||||
/>{@label}<span
|
/>{@label}<span
|
||||||
:if={@is_required}
|
:if={@is_required}
|
||||||
class="text-red-700 tooltip tooltip-right"
|
class="text-error tooltip tooltip-right"
|
||||||
data-tip={gettext("This field is required")}
|
data-tip={gettext("This field is required")}
|
||||||
>*</span>
|
>*</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -456,7 +530,7 @@ defmodule MvWeb.CoreComponents do
|
||||||
<span :if={@label} class="mb-1 label">
|
<span :if={@label} class="mb-1 label">
|
||||||
{@label}<span
|
{@label}<span
|
||||||
:if={@rest[:required]}
|
:if={@rest[:required]}
|
||||||
class="text-red-700 tooltip tooltip-right"
|
class="text-error tooltip tooltip-right"
|
||||||
data-tip={gettext("This field cannot be empty")}
|
data-tip={gettext("This field cannot be empty")}
|
||||||
>*</span>
|
>*</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -485,7 +559,7 @@ defmodule MvWeb.CoreComponents do
|
||||||
<span :if={@label} class="mb-1 label">
|
<span :if={@label} class="mb-1 label">
|
||||||
{@label}<span
|
{@label}<span
|
||||||
:if={@rest[:required]}
|
:if={@rest[:required]}
|
||||||
class="text-red-700 tooltip tooltip-right"
|
class="text-error tooltip tooltip-right"
|
||||||
data-tip={gettext("This field cannot be empty")}
|
data-tip={gettext("This field cannot be empty")}
|
||||||
>*</span>
|
>*</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -514,7 +588,7 @@ defmodule MvWeb.CoreComponents do
|
||||||
<span :if={@label} class="mb-1 label">
|
<span :if={@label} class="mb-1 label">
|
||||||
{@label}<span
|
{@label}<span
|
||||||
:if={@rest[:required]}
|
:if={@rest[:required]}
|
||||||
class="text-red-700 tooltip tooltip-right"
|
class="text-error tooltip tooltip-right"
|
||||||
data-tip={gettext("This field cannot be empty")}
|
data-tip={gettext("This field cannot be empty")}
|
||||||
>*</span>
|
>*</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -559,17 +633,24 @@ defmodule MvWeb.CoreComponents do
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Renders a header with title.
|
Renders a header with title.
|
||||||
|
|
||||||
|
Use the `:leading` slot for the Back button (left side, consistent with data fields).
|
||||||
|
Use the `:actions` slot for primary actions (e.g. Save) on the right.
|
||||||
"""
|
"""
|
||||||
attr :class, :string, default: nil
|
attr :class, :string, default: nil
|
||||||
|
|
||||||
|
slot :leading, doc: "Content on the left (e.g. Back button)"
|
||||||
slot :inner_block, required: true
|
slot :inner_block, required: true
|
||||||
slot :subtitle
|
slot :subtitle
|
||||||
slot :actions
|
slot :actions
|
||||||
|
|
||||||
def header(assigns) do
|
def header(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4", @class]}>
|
<header class={["flex items-center gap-6 pb-4", @class]}>
|
||||||
<div>
|
<div :if={@leading != []} class="shrink-0">
|
||||||
|
{render_slot(@leading)}
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
<h1 class="text-xl font-semibold leading-8">
|
<h1 class="text-xl font-semibold leading-8">
|
||||||
{render_slot(@inner_block)}
|
{render_slot(@inner_block)}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
@ -577,7 +658,9 @@ defmodule MvWeb.CoreComponents do
|
||||||
{render_slot(@subtitle)}
|
{render_slot(@subtitle)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-4 justify-end">{render_slot(@actions)}</div>
|
<div :if={@actions != []} class="shrink-0 flex gap-4 justify-end">
|
||||||
|
{render_slot(@actions)}
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
@ -585,18 +668,51 @@ defmodule MvWeb.CoreComponents do
|
||||||
@doc ~S"""
|
@doc ~S"""
|
||||||
Renders a table with generic styling.
|
Renders a table with generic styling.
|
||||||
|
|
||||||
|
When `row_click` is set, clicking a row (or a data cell) triggers the handler.
|
||||||
|
Rows with `row_click` get a subtle hover and focus-within outline (theme-friendly ring).
|
||||||
|
When `selected_row_id` is set and matches a row's id (via `row_value_id` or `row_item.(row).id`),
|
||||||
|
that row gets a stronger selected outline (ring-primary) for accessibility (not color-only).
|
||||||
|
|
||||||
|
The action column has no phx-click on its `<td>`, so action buttons do not trigger row navigation.
|
||||||
|
For interactive elements inside other columns (e.g. checkboxes, buttons), use
|
||||||
|
`Phoenix.LiveView.JS.stop_propagation()` in the element's phx-click so the row click is not fired.
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
<.table id="users" rows={@users}>
|
<.table id="users" rows={@users}>
|
||||||
<:col :let={user} label="id">{user.id}</:col>
|
<:col :let={user} label="id">{user.id}</:col>
|
||||||
<:col :let={user} label="username">{user.username}</:col>
|
<:col :let={user} label="username">{user.username}</:col>
|
||||||
</.table>
|
</.table>
|
||||||
|
|
||||||
|
<.table id="members" rows={@members} row_click={fn m -> JS.navigate(~p"/members/#{m}") end} selected_row_id={@selected_member_id}>
|
||||||
|
<:col :let={m} label="Name">{m.name}</:col>
|
||||||
|
</.table>
|
||||||
"""
|
"""
|
||||||
attr :id, :string, required: true
|
attr :id, :string, required: true
|
||||||
attr :rows, :list, required: true
|
attr :rows, :list, required: true
|
||||||
attr :row_id, :any, default: nil, doc: "the function for generating the row id"
|
attr :row_id, :any, default: nil, doc: "the function for generating the row id"
|
||||||
attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"
|
attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"
|
||||||
|
|
||||||
|
attr :selected_row_id, :any,
|
||||||
|
default: nil,
|
||||||
|
doc:
|
||||||
|
"when set, the row whose id equals this value gets selected styling (single row, e.g. from URL)"
|
||||||
|
|
||||||
|
attr :row_selected?, :any,
|
||||||
|
default: nil,
|
||||||
|
doc:
|
||||||
|
"optional; function (row_item) -> boolean to mark multiple rows as selected (e.g. checkbox selection); overrides selected_row_id when set"
|
||||||
|
|
||||||
|
attr :row_tooltip, :string,
|
||||||
|
default: nil,
|
||||||
|
doc:
|
||||||
|
"optional; when row_click is set, tooltip text for the row (e.g. gettext(\"Click to view\")). Shown as title on hover and as sr-only for screen readers."
|
||||||
|
|
||||||
|
attr :row_value_id, :any,
|
||||||
|
default: nil,
|
||||||
|
doc:
|
||||||
|
"optional; function (row) -> id for comparing with selected_row_id; defaults to row_item.(row).id"
|
||||||
|
|
||||||
attr :row_item, :any,
|
attr :row_item, :any,
|
||||||
default: &Function.identity/1,
|
default: &Function.identity/1,
|
||||||
doc: "the function for mapping each row before calling the :col and :action slots"
|
doc: "the function for mapping each row before calling the :col and :action slots"
|
||||||
|
|
@ -608,6 +724,11 @@ defmodule MvWeb.CoreComponents do
|
||||||
attr :sort_field, :any, default: nil, doc: "current sort field"
|
attr :sort_field, :any, default: nil, doc: "current sort field"
|
||||||
attr :sort_order, :atom, default: nil, doc: "current sort order"
|
attr :sort_order, :atom, default: nil, doc: "current sort order"
|
||||||
|
|
||||||
|
attr :sticky_header, :boolean,
|
||||||
|
default: false,
|
||||||
|
doc:
|
||||||
|
"when true, thead th get lg:sticky lg:top-0 bg-base-100 z-10 for use inside a scroll container on desktop"
|
||||||
|
|
||||||
slot :col, required: true do
|
slot :col, required: true do
|
||||||
attr :label, :string
|
attr :label, :string
|
||||||
attr :class, :string
|
attr :class, :string
|
||||||
|
|
@ -625,6 +746,12 @@ defmodule MvWeb.CoreComponents do
|
||||||
assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
|
assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Function to get the row's value id for selected_row_id comparison (no extra DB reads)
|
||||||
|
row_value_id_fn =
|
||||||
|
assigns[:row_value_id] || fn row -> assigns.row_item.(row).id end
|
||||||
|
|
||||||
|
assigns = assign(assigns, :row_value_id_fn, row_value_id_fn)
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<div class="overflow-auto">
|
<div class="overflow-auto">
|
||||||
<table class="table table-zebra">
|
<table class="table table-zebra">
|
||||||
|
|
@ -632,12 +759,12 @@ defmodule MvWeb.CoreComponents do
|
||||||
<tr>
|
<tr>
|
||||||
<th
|
<th
|
||||||
:for={col <- @col}
|
:for={col <- @col}
|
||||||
class={Map.get(col, :class)}
|
class={table_th_class(col, @sticky_header)}
|
||||||
aria-sort={table_th_aria_sort(col, @sort_field, @sort_order)}
|
aria-sort={table_th_aria_sort(col, @sort_field, @sort_order)}
|
||||||
>
|
>
|
||||||
{col[:label]}
|
{col[:label]}
|
||||||
</th>
|
</th>
|
||||||
<th :for={dyn_col <- @dynamic_cols}>
|
<th :for={dyn_col <- @dynamic_cols} class={table_th_sticky_class(@sticky_header)}>
|
||||||
<.live_component
|
<.live_component
|
||||||
module={MvWeb.Components.SortHeaderComponent}
|
module={MvWeb.Components.SortHeaderComponent}
|
||||||
id={:"sort_custom_field_#{dyn_col[:custom_field].id}"}
|
id={:"sort_custom_field_#{dyn_col[:custom_field].id}"}
|
||||||
|
|
@ -647,15 +774,21 @@ defmodule MvWeb.CoreComponents do
|
||||||
sort_order={@sort_order}
|
sort_order={@sort_order}
|
||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
<th :if={@action != []}>
|
<th :if={@action != []} class={table_th_sticky_class(@sticky_header)}>
|
||||||
<span class="sr-only">{gettext("Actions")}</span>
|
<span class="sr-only">{gettext("Actions")}</span>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
|
<tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
|
||||||
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
|
<tr
|
||||||
|
:for={row <- @rows}
|
||||||
|
id={@row_id && @row_id.(row)}
|
||||||
|
class={table_row_tr_class(@row_click, table_row_selected?(assigns, row))}
|
||||||
|
data-selected={table_row_selected?(assigns, row) && "true"}
|
||||||
|
title={@row_click && @row_tooltip}
|
||||||
|
>
|
||||||
<td
|
<td
|
||||||
:for={col <- @col}
|
:for={{col, col_idx} <- Enum.with_index(@col)}
|
||||||
phx-click={
|
phx-click={
|
||||||
(col[:col_click] && col[:col_click].(@row_item.(row))) ||
|
(col[:col_click] && col[:col_click].(@row_item.(row))) ||
|
||||||
(@row_click && @row_click.(row))
|
(@row_click && @row_click.(row))
|
||||||
|
|
@ -689,6 +822,9 @@ defmodule MvWeb.CoreComponents do
|
||||||
Enum.join(classes, " ")
|
Enum.join(classes, " ")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<%= if col_idx == 0 && @row_click && @row_tooltip do %>
|
||||||
|
<span class="sr-only">{@row_tooltip}</span>
|
||||||
|
<% end %>
|
||||||
{render_slot(col, @row_item.(row))}
|
{render_slot(col, @row_item.(row))}
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
|
|
@ -722,6 +858,43 @@ defmodule MvWeb.CoreComponents do
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Returns true if the row is selected (via row_selected?/1 or selected_row_id match).
|
||||||
|
defp table_row_selected?(assigns, row) do
|
||||||
|
item = assigns.row_item.(row)
|
||||||
|
|
||||||
|
if assigns[:row_selected?] do
|
||||||
|
assigns.row_selected?.(item)
|
||||||
|
else
|
||||||
|
assigns[:selected_row_id] != nil and
|
||||||
|
assigns.row_value_id_fn.(row) == assigns.selected_row_id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Returns CSS classes for table row: hover/focus-within outline when row_click is set,
|
||||||
|
# and stronger selected outline when selected (WCAG: not color-only).
|
||||||
|
# Hover/focus-within are omitted for the selected row so the selected ring stays visible.
|
||||||
|
defp table_row_tr_class(row_click, selected?) do
|
||||||
|
has_row_click? = not is_nil(row_click)
|
||||||
|
base = []
|
||||||
|
|
||||||
|
base =
|
||||||
|
if has_row_click? and not selected?,
|
||||||
|
do:
|
||||||
|
base ++
|
||||||
|
[
|
||||||
|
"hover:ring-2",
|
||||||
|
"hover:ring-inset",
|
||||||
|
"hover:ring-base-content/10",
|
||||||
|
"focus-within:ring-2",
|
||||||
|
"focus-within:ring-inset",
|
||||||
|
"focus-within:ring-base-content/10"
|
||||||
|
],
|
||||||
|
else: base
|
||||||
|
|
||||||
|
base = if selected?, do: base ++ ["ring-2", "ring-inset", "ring-primary"], else: base
|
||||||
|
Enum.join(base, " ")
|
||||||
|
end
|
||||||
|
|
||||||
defp table_th_aria_sort(col, sort_field, sort_order) do
|
defp table_th_aria_sort(col, sort_field, sort_order) do
|
||||||
col_sort = Map.get(col, :sort_field)
|
col_sort = Map.get(col, :sort_field)
|
||||||
|
|
||||||
|
|
@ -732,6 +905,18 @@ defmodule MvWeb.CoreComponents do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Combines column class with optional sticky header classes (desktop only; theme-friendly bg).
|
||||||
|
defp table_th_class(col, sticky_header) do
|
||||||
|
base = Map.get(col, :class)
|
||||||
|
sticky = if sticky_header, do: "lg:sticky lg:top-0 bg-base-100 z-10", else: nil
|
||||||
|
[base, sticky] |> Enum.filter(& &1) |> Enum.join(" ")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp table_th_sticky_class(true),
|
||||||
|
do: "lg:sticky lg:top-0 bg-base-100 z-10"
|
||||||
|
|
||||||
|
defp table_th_sticky_class(_), do: nil
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Renders a data list.
|
Renders a data list.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,11 @@ defmodule MvWeb.Layouts do
|
||||||
|
|
||||||
def flash_group(assigns) do
|
def flash_group(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<div id={@id} aria-live="polite" class="z-50 flex flex-col gap-2 toast toast-top toast-end">
|
<div
|
||||||
|
id={@id}
|
||||||
|
aria-live="polite"
|
||||||
|
class="z-50 toast toast-bottom toast-end flex flex-col gap-2 pointer-events-none"
|
||||||
|
>
|
||||||
<.flash kind={:success} flash={@flash} />
|
<.flash kind={:success} flash={@flash} />
|
||||||
<.flash kind={:warning} flash={@flash} />
|
<.flash kind={:warning} flash={@flash} />
|
||||||
<.flash kind={:info} flash={@flash} />
|
<.flash kind={:info} flash={@flash} />
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@
|
||||||
<div
|
<div
|
||||||
id="flash-group-root"
|
id="flash-group-root"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
class="z-50 flex flex-col gap-2 toast toast-top toast-end"
|
class="z-50 flex flex-col gap-2 toast toast-bottom toast-end"
|
||||||
>
|
>
|
||||||
<.flash id="flash-success-root" kind={:success} flash={@flash} />
|
<.flash id="flash-success-root" kind={:success} flash={@flash} />
|
||||||
<.flash id="flash-warning-root" kind={:warning} flash={@flash} />
|
<.flash id="flash-warning-root" kind={:warning} flash={@flash} />
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ defmodule MvWeb.AuthController do
|
||||||
|> store_in_session(user)
|
|> store_in_session(user)
|
||||||
# If your resource has a different name, update the assign name here (i.e :current_admin)
|
# If your resource has a different name, update the assign name here (i.e :current_admin)
|
||||||
|> assign(:current_user, user)
|
|> assign(:current_user, user)
|
||||||
|> put_flash(:info, message)
|
|> put_flash(:success, message)
|
||||||
|> redirect(to: return_to)
|
|> redirect(to: return_to)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -322,7 +322,7 @@ defmodule MvWeb.AuthController do
|
||||||
|
|
||||||
conn
|
conn
|
||||||
|> clear_session(:mv)
|
|> clear_session(:mv)
|
||||||
|> put_flash(:info, gettext("You are now signed out"))
|
|> put_flash(:success, gettext("You are now signed out"))
|
||||||
|> redirect(to: return_to)
|
|> redirect(to: return_to)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,7 @@ defmodule MvWeb.LinkOidcAccountLive do
|
||||||
|
|
||||||
socket
|
socket
|
||||||
|> put_flash(
|
|> put_flash(
|
||||||
:info,
|
:success,
|
||||||
dgettext("auth", "Account activated! Redirecting to complete sign-in...")
|
dgettext("auth", "Account activated! Redirecting to complete sign-in...")
|
||||||
)
|
)
|
||||||
|> Phoenix.LiveView.redirect(to: ~p"/auth/user/oidc")
|
|> Phoenix.LiveView.redirect(to: ~p"/auth/user/oidc")
|
||||||
|
|
@ -217,7 +217,7 @@ defmodule MvWeb.LinkOidcAccountLive do
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> put_flash(
|
|> put_flash(
|
||||||
:info,
|
:success,
|
||||||
dgettext(
|
dgettext(
|
||||||
"auth",
|
"auth",
|
||||||
"Your OIDC account has been successfully linked! Redirecting to complete sign-in..."
|
"Your OIDC account has been successfully linked! Redirecting to complete sign-in..."
|
||||||
|
|
|
||||||
|
|
@ -188,7 +188,7 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
|
||||||
|
|
||||||
defp find_custom_field_name(id, _field_string, custom_fields) do
|
defp find_custom_field_name(id, _field_string, custom_fields) do
|
||||||
case Enum.find(custom_fields, fn cf -> to_string(cf.id) == id end) do
|
case Enum.find(custom_fields, fn cf -> to_string(cf.id) == id end) do
|
||||||
nil -> gettext("Custom Field %{id}", id: id)
|
nil -> gettext("Datafield %{id}", id: id)
|
||||||
custom_field -> custom_field.name
|
custom_field -> custom_field.name
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -64,11 +64,11 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
||||||
phx-key="Escape"
|
phx-key="Escape"
|
||||||
phx-target={@myself}
|
phx-target={@myself}
|
||||||
>
|
>
|
||||||
<button
|
<.button
|
||||||
type="button"
|
type="button"
|
||||||
tabindex="0"
|
variant="secondary"
|
||||||
class={[
|
class={[
|
||||||
"btn gap-2",
|
"gap-2",
|
||||||
(@cycle_status_filter || map_size(@group_filters) > 0 ||
|
(@cycle_status_filter || map_size(@group_filters) > 0 ||
|
||||||
active_boolean_filters_count(@boolean_filters) > 0) &&
|
active_boolean_filters_count(@boolean_filters) > 0) &&
|
||||||
"btn-active"
|
"btn-active"
|
||||||
|
|
@ -104,7 +104,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
||||||
>
|
>
|
||||||
{@member_count}
|
{@member_count}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</.button>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
NOTE: We use a div panel instead of ul.menu/li structure to avoid DaisyUI menu styles
|
NOTE: We use a div panel instead of ul.menu/li structure to avoid DaisyUI menu styles
|
||||||
|
|
@ -252,7 +252,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
||||||
<!-- Custom Fields Group -->
|
<!-- Custom Fields Group -->
|
||||||
<div :if={length(@boolean_custom_fields) > 0} class="mb-2">
|
<div :if={length(@boolean_custom_fields) > 0} class="mb-2">
|
||||||
<div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider">
|
<div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider">
|
||||||
{gettext("Custom Fields")}
|
{gettext("Individual datafields")}
|
||||||
</div>
|
</div>
|
||||||
<div class="max-h-60 overflow-y-auto pr-2">
|
<div class="max-h-60 overflow-y-auto pr-2">
|
||||||
<fieldset
|
<fieldset
|
||||||
|
|
@ -318,22 +318,24 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="mt-4 flex justify-between pt-3 border-t border-base-200">
|
<div class="mt-4 flex justify-between pt-3 border-t border-base-200">
|
||||||
<button
|
<.button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="neutral"
|
||||||
|
size="sm"
|
||||||
phx-click="reset_filters"
|
phx-click="reset_filters"
|
||||||
phx-target={@myself}
|
phx-target={@myself}
|
||||||
class="btn btn-sm"
|
|
||||||
>
|
>
|
||||||
{gettext("Reset")}
|
{gettext("Clear filters")}
|
||||||
</button>
|
</.button>
|
||||||
<button
|
<.button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
phx-click="close_dropdown"
|
phx-click="close_dropdown"
|
||||||
phx-target={@myself}
|
phx-target={@myself}
|
||||||
class="btn btn-primary btn-sm"
|
|
||||||
>
|
>
|
||||||
{gettext("Close")}
|
{gettext("Close")}
|
||||||
</button>
|
</.button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -458,7 +460,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
||||||
boolean_filter_label(boolean_custom_fields, boolean_filters)
|
boolean_filter_label(boolean_custom_fields, boolean_filters)
|
||||||
|
|
||||||
true ->
|
true ->
|
||||||
gettext("All")
|
gettext("Apply filters")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,25 +19,28 @@ defmodule MvWeb.Components.SortHeaderComponent do
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<div class="tooltip tooltip-bottom" data-tip={aria_sort(@field, @sort_field, @sort_order)}>
|
<div>
|
||||||
<button
|
<.tooltip content={aria_sort(@field, @sort_field, @sort_order)} position="bottom">
|
||||||
type="button"
|
<.button
|
||||||
aria-label={aria_sort(@field, @sort_field, @sort_order)}
|
type="button"
|
||||||
class="btn btn-ghost select-none"
|
variant="ghost"
|
||||||
phx-click="sort"
|
aria-label={aria_sort(@field, @sort_field, @sort_order)}
|
||||||
phx-value-field={@field}
|
class="select-none"
|
||||||
data-testid={@field}
|
phx-click="sort"
|
||||||
>
|
phx-value-field={@field}
|
||||||
{@label}
|
data-testid={@field}
|
||||||
<%= if @sort_field == @field do %>
|
>
|
||||||
<.icon name={if @sort_order == :asc, do: "hero-chevron-up", else: "hero-chevron-down"} />
|
{@label}
|
||||||
<% else %>
|
<%= if @sort_field == @field do %>
|
||||||
<.icon
|
<.icon name={if @sort_order == :asc, do: "hero-chevron-up", else: "hero-chevron-down"} />
|
||||||
name="hero-chevron-up-down"
|
<% else %>
|
||||||
class="opacity-40"
|
<.icon
|
||||||
/>
|
name="hero-chevron-up-down"
|
||||||
<% end %>
|
class="opacity-40"
|
||||||
</button>
|
/>
|
||||||
|
<% end %>
|
||||||
|
</.button>
|
||||||
|
</.tooltip>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -24,11 +24,13 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
|
||||||
<div class="flex items-center gap-4 mb-4">
|
<div class="flex items-center gap-4 mb-4">
|
||||||
<.button
|
<.button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="neutral"
|
||||||
phx-click="cancel"
|
phx-click="cancel"
|
||||||
phx-target={@myself}
|
phx-target={@myself}
|
||||||
aria-label={gettext("Back to settings")}
|
aria-label={gettext("Back to settings")}
|
||||||
>
|
>
|
||||||
<.icon name="hero-arrow-left" class="w-4 h-4" />
|
<.icon name="hero-arrow-left" class="size-4" />
|
||||||
|
{gettext("Back")}
|
||||||
</.button>
|
</.button>
|
||||||
<h3 class="card-title">
|
<h3 class="card-title">
|
||||||
{if @custom_field, do: gettext("Edit Data Field"), else: gettext("New Data Field")}
|
{if @custom_field, do: gettext("Edit Data Field"), else: gettext("New Data Field")}
|
||||||
|
|
@ -96,8 +98,35 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
|
||||||
label={gettext("Show in overview")}
|
label={gettext("Show in overview")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<%= if @custom_field do %>
|
||||||
|
<%!-- Danger zone: canonical pattern (same as member form) --%>
|
||||||
|
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
|
||||||
|
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
|
||||||
|
{gettext("Danger zone")}
|
||||||
|
</h2>
|
||||||
|
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
||||||
|
<p class="text-base-content/70 mb-4">
|
||||||
|
{gettext(
|
||||||
|
"Deleting this data field cannot be undone. All datafield values for this field will be permanently removed."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<.button
|
||||||
|
type="button"
|
||||||
|
variant="danger"
|
||||||
|
phx-click="request_delete"
|
||||||
|
phx-target={@myself}
|
||||||
|
data-testid="custom-field-delete"
|
||||||
|
aria-label={gettext("Delete data field %{name}", name: @custom_field.name)}
|
||||||
|
>
|
||||||
|
<.icon name="hero-trash" class="size-4" />
|
||||||
|
{gettext("Delete data field")}
|
||||||
|
</.button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<div class="justify-end mt-4 card-actions">
|
<div class="justify-end mt-4 card-actions">
|
||||||
<.button type="button" phx-click="cancel" phx-target={@myself}>
|
<.button type="button" variant="neutral" phx-click="cancel" phx-target={@myself}>
|
||||||
{gettext("Cancel")}
|
{gettext("Cancel")}
|
||||||
</.button>
|
</.button>
|
||||||
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
||||||
|
|
@ -168,6 +197,15 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("request_delete", _params, socket) do
|
||||||
|
if custom_field = socket.assigns[:custom_field] do
|
||||||
|
send(self(), {:open_delete_modal_for, custom_field})
|
||||||
|
end
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
defp assign_form(%{assigns: %{custom_field: custom_field}} = socket) do
|
defp assign_form(%{assigns: %{custom_field: custom_field}} = socket) do
|
||||||
form =
|
form =
|
||||||
if custom_field do
|
if custom_field do
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
||||||
JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself)
|
JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself)
|
||||||
end
|
end
|
||||||
}
|
}
|
||||||
|
row_tooltip={gettext("Click to edit datafield")}
|
||||||
>
|
>
|
||||||
<:col :let={{_id, custom_field}} label={gettext("Name")}>{custom_field.name}</:col>
|
<:col :let={{_id, custom_field}} label={gettext("Name")}>{custom_field.name}</:col>
|
||||||
|
|
||||||
|
|
@ -95,22 +96,6 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
||||||
{gettext("No")}
|
{gettext("No")}
|
||||||
</span>
|
</span>
|
||||||
</:col>
|
</:col>
|
||||||
|
|
||||||
<:action :let={{_id, custom_field}}>
|
|
||||||
<.link phx-click={
|
|
||||||
JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself)
|
|
||||||
}>
|
|
||||||
{gettext("Edit")}
|
|
||||||
</.link>
|
|
||||||
</:action>
|
|
||||||
|
|
||||||
<:action :let={{_id, custom_field}}>
|
|
||||||
<.link phx-click={
|
|
||||||
JS.push("prepare_delete", value: %{id: custom_field.id}, target: @myself)
|
|
||||||
}>
|
|
||||||
{gettext("Delete")}
|
|
||||||
</.link>
|
|
||||||
</:action>
|
|
||||||
</.table>
|
</.table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -164,17 +149,17 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-action">
|
<div class="modal-action">
|
||||||
<button phx-click="cancel_delete" phx-target={@myself} class="btn">
|
<.button variant="neutral" phx-click="cancel_delete" phx-target={@myself}>
|
||||||
{gettext("Cancel")}
|
{gettext("Cancel")}
|
||||||
</button>
|
</.button>
|
||||||
<button
|
<.button
|
||||||
|
variant="danger"
|
||||||
phx-click="confirm_delete"
|
phx-click="confirm_delete"
|
||||||
phx-target={@myself}
|
phx-target={@myself}
|
||||||
class="btn btn-error"
|
|
||||||
disabled={@slug_confirmation != @custom_field_to_delete.slug}
|
disabled={@slug_confirmation != @custom_field_to_delete.slug}
|
||||||
>
|
>
|
||||||
{gettext("Delete Custom Field and All Values")}
|
{gettext("Delete Datafields and All Values")}
|
||||||
</button>
|
</.button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
@ -222,16 +207,38 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
||||||
# Get actor from assigns or fall back to socket assigns
|
# Get actor from assigns or fall back to socket assigns
|
||||||
actor = Map.get(assigns, :actor, socket.assigns[:actor])
|
actor = Map.get(assigns, :actor, socket.assigns[:actor])
|
||||||
|
|
||||||
{:ok,
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(assigns)
|
|> assign(assigns)
|
||||||
|> assign_new(:show_form, fn -> false end)
|
|> assign_new(:show_form, fn -> false end)
|
||||||
|> assign_new(:form_id, fn -> "custom-field-form-new" end)
|
|> assign_new(:form_id, fn -> "custom-field-form-new" end)
|
||||||
|> assign_new(:editing_custom_field, fn -> nil end)
|
|> assign_new(:editing_custom_field, fn -> nil end)
|
||||||
|> assign_new(:show_delete_modal, fn -> false end)
|
|> assign_new(:show_delete_modal, fn -> false end)
|
||||||
|> assign_new(:custom_field_to_delete, fn -> nil end)
|
|> assign_new(:custom_field_to_delete, fn -> nil end)
|
||||||
|> assign_new(:slug_confirmation, fn -> "" end)
|
|> assign_new(:slug_confirmation, fn -> "" end)
|
||||||
|> stream(:custom_fields, stream_custom_fields(actor, self()), reset: true)}
|
|> stream(:custom_fields, stream_custom_fields(actor, self()), reset: true)
|
||||||
|
|
||||||
|
# Open delete modal when requested from form (e.g. Danger zone in FormComponent)
|
||||||
|
socket =
|
||||||
|
case Map.get(assigns, :open_delete_for_id) do
|
||||||
|
nil ->
|
||||||
|
socket
|
||||||
|
|
||||||
|
id ->
|
||||||
|
custom_field =
|
||||||
|
Ash.get!(Mv.Membership.CustomField, id,
|
||||||
|
load: [:assigned_members_count],
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign(:show_delete_modal, true)
|
||||||
|
|> assign(:custom_field_to_delete, custom_field)
|
||||||
|
|> assign(:slug_confirmation, "")
|
||||||
|
|> assign(:open_delete_for_id, nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
{:ok, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
|
||||||
|
|
@ -64,12 +64,12 @@ defmodule MvWeb.DatafieldsLive do
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign(:active_editing_section, nil)
|
|> assign(:active_editing_section, nil)
|
||||||
|> put_flash(:info, gettext("Data field %{action} successfully", action: action))}
|
|> put_flash(:success, gettext("Data field %{action} successfully", action: action))}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info({:custom_field_deleted, _custom_field}, socket) do
|
def handle_info({:custom_field_deleted, _custom_field}, socket) do
|
||||||
{:noreply, put_flash(socket, :info, gettext("Data field deleted successfully"))}
|
{:noreply, put_flash(socket, :success, gettext("Data field deleted successfully"))}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
@ -101,6 +101,17 @@ defmodule MvWeb.DatafieldsLive do
|
||||||
{:noreply, assign(socket, :active_editing_section, section)}
|
{:noreply, assign(socket, :active_editing_section, section)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Open delete modal for custom field (triggered from Danger zone in FormComponent)
|
||||||
|
@impl true
|
||||||
|
def handle_info({:open_delete_modal_for, custom_field}, socket) do
|
||||||
|
send_update(MvWeb.CustomFieldLive.IndexComponent,
|
||||||
|
id: "custom-fields-component",
|
||||||
|
open_delete_for_id: custom_field.id
|
||||||
|
)
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info({:member_field_saved, _member_field, action}, socket) do
|
def handle_info({:member_field_saved, _member_field, action}, socket) do
|
||||||
{:ok, updated_settings} = Membership.get_settings()
|
{:ok, updated_settings} = Membership.get_settings()
|
||||||
|
|
@ -115,7 +126,7 @@ defmodule MvWeb.DatafieldsLive do
|
||||||
socket
|
socket
|
||||||
|> assign(:settings, updated_settings)
|
|> assign(:settings, updated_settings)
|
||||||
|> assign(:active_editing_section, nil)
|
|> assign(:active_editing_section, nil)
|
||||||
|> put_flash(:info, gettext("Member field %{action} successfully", action: action))}
|
|> put_flash(:success, gettext("Member field %{action} successfully", action: action))}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,7 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
||||||
{gettext("Save Settings")}
|
{gettext("Save Name")}
|
||||||
</.button>
|
</.button>
|
||||||
</.form>
|
</.form>
|
||||||
</.form_section>
|
</.form_section>
|
||||||
|
|
@ -181,18 +181,18 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
<.button
|
<.button
|
||||||
:if={Mv.Config.vereinfacht_configured?()}
|
:if={Mv.Config.vereinfacht_configured?()}
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="outline"
|
||||||
phx-click="test_vereinfacht_connection"
|
phx-click="test_vereinfacht_connection"
|
||||||
phx-disable-with={gettext("Testing...")}
|
phx-disable-with={gettext("Testing...")}
|
||||||
class="btn-outline"
|
|
||||||
>
|
>
|
||||||
{gettext("Test Integration")}
|
{gettext("Test Integration")}
|
||||||
</.button>
|
</.button>
|
||||||
<.button
|
<.button
|
||||||
:if={Mv.Config.vereinfacht_configured?()}
|
:if={Mv.Config.vereinfacht_configured?()}
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="outline"
|
||||||
phx-click="sync_vereinfacht_contacts"
|
phx-click="sync_vereinfacht_contacts"
|
||||||
phx-disable-with={gettext("Syncing...")}
|
phx-disable-with={gettext("Syncing...")}
|
||||||
class="btn-outline"
|
|
||||||
>
|
>
|
||||||
{gettext("Sync all members without Vereinfacht contact")}
|
{gettext("Sync all members without Vereinfacht contact")}
|
||||||
</.button>
|
</.button>
|
||||||
|
|
@ -357,20 +357,21 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
errors_with_names = enrich_sync_errors(errors)
|
errors_with_names = enrich_sync_errors(errors)
|
||||||
result = %{synced: synced, errors: errors_with_names}
|
result = %{synced: synced, errors: errors_with_names}
|
||||||
|
|
||||||
|
{flash_kind, flash_message} =
|
||||||
|
if(errors_with_names == [],
|
||||||
|
do: {:success, gettext("Synced %{count} member(s) to Vereinfacht.", count: synced)},
|
||||||
|
else:
|
||||||
|
{:warning,
|
||||||
|
gettext("Synced %{count} member(s). %{error_count} failed.",
|
||||||
|
count: synced,
|
||||||
|
error_count: length(errors_with_names)
|
||||||
|
)}
|
||||||
|
)
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(:last_vereinfacht_sync_result, result)
|
|> assign(:last_vereinfacht_sync_result, result)
|
||||||
|> put_flash(
|
|> put_flash(flash_kind, flash_message)
|
||||||
:info,
|
|
||||||
if(errors_with_names == [],
|
|
||||||
do: gettext("Synced %{count} member(s) to Vereinfacht.", count: synced),
|
|
||||||
else:
|
|
||||||
gettext("Synced %{count} member(s). %{error_count} failed.",
|
|
||||||
count: synced,
|
|
||||||
error_count: length(errors_with_names)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
|
|
||||||
|
|
@ -409,7 +410,7 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
|> assign(:oidc_client_secret_set, present?(fresh_settings.oidc_client_secret))
|
|> assign(:oidc_client_secret_set, present?(fresh_settings.oidc_client_secret))
|
||||||
|> assign(:oidc_configured, Mv.Config.oidc_configured?())
|
|> assign(:oidc_configured, Mv.Config.oidc_configured?())
|
||||||
|> assign(:vereinfacht_test_result, test_result)
|
|> assign(:vereinfacht_test_result, test_result)
|
||||||
|> put_flash(:info, gettext("Settings updated successfully"))
|
|> put_flash(:success, gettext("Settings updated successfully"))
|
||||||
|> assign_form()
|
|> assign_form()
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
|
|
|
||||||
|
|
@ -78,30 +78,56 @@ defmodule MvWeb.GroupLive.Form do
|
||||||
~H"""
|
~H"""
|
||||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||||
<.form for={@form} id="group-form" phx-change="validate" phx-submit="save">
|
<.form for={@form} id="group-form" phx-change="validate" phx-submit="save">
|
||||||
<%!-- Header with Back button, Title, and Save button --%>
|
<.header>
|
||||||
<div class="flex items-center justify-between gap-4 pb-4">
|
<:leading>
|
||||||
<.button navigate={return_path(@return_to, @group)} type="button">
|
<.button navigate={return_path(@return_to, @group)} variant="neutral">
|
||||||
<.icon name="hero-arrow-left" class="size-4" />
|
<.icon name="hero-arrow-left" class="size-4" />
|
||||||
{gettext("Back")}
|
{gettext("Back")}
|
||||||
</.button>
|
</.button>
|
||||||
|
</:leading>
|
||||||
|
{@page_title}
|
||||||
|
<:actions>
|
||||||
|
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
|
||||||
|
{gettext("Save")}
|
||||||
|
</.button>
|
||||||
|
</:actions>
|
||||||
|
</.header>
|
||||||
|
|
||||||
<h1 class="text-2xl font-bold text-center flex-1">
|
<div class="mt-6 space-y-6">
|
||||||
{@page_title}
|
<div class="max-w-2xl space-y-4">
|
||||||
</h1>
|
<.input field={@form[:name]} label={gettext("Name")} required />
|
||||||
|
<.input
|
||||||
|
field={@form[:description]}
|
||||||
|
type="textarea"
|
||||||
|
label={gettext("Description")}
|
||||||
|
rows="4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
|
<%!-- Danger zone: canonical pattern (same as member form) --%>
|
||||||
{gettext("Save")}
|
<%= if @group && can?(@current_user, :destroy, @group) do %>
|
||||||
</.button>
|
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
|
||||||
</div>
|
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
|
||||||
|
{gettext("Danger zone")}
|
||||||
<div class="max-w-2xl space-y-4">
|
</h2>
|
||||||
<.input field={@form[:name]} label={gettext("Name")} required />
|
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
||||||
<.input
|
<p class="text-base-content/70 mb-4">
|
||||||
field={@form[:description]}
|
{gettext(
|
||||||
type="textarea"
|
"Deleting this group cannot be undone. All member-group associations will be permanently removed."
|
||||||
label={gettext("Description")}
|
)}
|
||||||
rows="4"
|
</p>
|
||||||
/>
|
<.button
|
||||||
|
variant="danger"
|
||||||
|
navigate={~p"/groups/#{@group.slug}?confirm_delete=1"}
|
||||||
|
data-testid="group-form-delete-btn"
|
||||||
|
aria-label={gettext("Delete group %{name}", name: @group.name)}
|
||||||
|
>
|
||||||
|
<.icon name="hero-trash" class="size-4" />
|
||||||
|
{gettext("Delete group")}
|
||||||
|
</.button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</.form>
|
</.form>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
|
|
@ -129,7 +155,7 @@ defmodule MvWeb.GroupLive.Form do
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> put_flash(:info, gettext("Group saved successfully."))
|
|> put_flash(:success, gettext("Group saved successfully."))
|
||||||
|> push_navigate(to: redirect_path)
|
|> push_navigate(to: redirect_path)
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
|
|
|
||||||
|
|
@ -39,72 +39,47 @@ defmodule MvWeb.GroupLive.Index do
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||||
<div class="flex items-center justify-between mb-6">
|
<.header>
|
||||||
<h1 class="text-2xl font-bold">{gettext("Groups")}</h1>
|
{gettext("Groups")}
|
||||||
<%= if can?(@current_user, :create, Mv.Membership.Group) do %>
|
<:actions>
|
||||||
<.button navigate={~p"/groups/new"} variant="primary">
|
<%= if can?(@current_user, :create, Mv.Membership.Group) do %>
|
||||||
<.icon name="hero-plus" class="size-4 mr-2" />
|
<.button navigate={~p"/groups/new"} variant="primary">
|
||||||
{gettext("Create Group")}
|
<.icon name="hero-plus" class="size-4 mr-2" />
|
||||||
</.button>
|
{gettext("Create Group")}
|
||||||
|
</.button>
|
||||||
|
<% end %>
|
||||||
|
</:actions>
|
||||||
|
</.header>
|
||||||
|
|
||||||
|
<div class="mt-6 space-y-6">
|
||||||
|
<%= if Enum.empty?(@groups) do %>
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<p class="text-base-content/60 italic">{gettext("No groups")}</p>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<.table
|
||||||
|
id="groups-table"
|
||||||
|
rows={@groups}
|
||||||
|
row_id={fn group -> "group-#{group.id}" end}
|
||||||
|
row_click={fn group -> JS.navigate(~p"/groups/#{group.slug}") end}
|
||||||
|
row_tooltip={gettext("Click for group details")}
|
||||||
|
>
|
||||||
|
<:col :let={group} label={gettext("Name")}>
|
||||||
|
{group.name}
|
||||||
|
</:col>
|
||||||
|
<:col :let={group} label={gettext("Description")}>
|
||||||
|
<%= if group.description do %>
|
||||||
|
{group.description}
|
||||||
|
<% else %>
|
||||||
|
<span class="text-base-content/50 italic">—</span>
|
||||||
|
<% end %>
|
||||||
|
</:col>
|
||||||
|
<:col :let={group} label={gettext("Members")} class="text-right">
|
||||||
|
{group.member_count || 0}
|
||||||
|
</:col>
|
||||||
|
</.table>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= if Enum.empty?(@groups) do %>
|
|
||||||
<div class="text-center py-12">
|
|
||||||
<p class="text-base-content/70">{gettext("No groups")}</p>
|
|
||||||
</div>
|
|
||||||
<% else %>
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="table table-zebra">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>{gettext("Name")}</th>
|
|
||||||
<th>{gettext("Description")}</th>
|
|
||||||
<th>{gettext("Members")}</th>
|
|
||||||
<th>{gettext("Actions")}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<%= for group <- @groups do %>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
{group.name}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<%= if group.description do %>
|
|
||||||
{group.description}
|
|
||||||
<% else %>
|
|
||||||
<span class="text-base-content/50 italic">—</span>
|
|
||||||
<% end %>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<%= if group.member_count do %>
|
|
||||||
{group.member_count}
|
|
||||||
<% else %>
|
|
||||||
0
|
|
||||||
<% end %>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<.link navigate={~p"/groups/#{group.slug}"} class="btn btn-sm btn-ghost">
|
|
||||||
{gettext("View")}
|
|
||||||
</.link>
|
|
||||||
<%= if can?(@current_user, :update, Mv.Membership.Group) do %>
|
|
||||||
<.link
|
|
||||||
navigate={~p"/groups/#{group.slug}/edit"}
|
|
||||||
class="btn btn-sm btn-ghost"
|
|
||||||
>
|
|
||||||
{gettext("Edit")}
|
|
||||||
</.link>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<% end %>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -39,18 +39,18 @@ defmodule MvWeb.GroupLive.Show do
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_params(%{"slug" => slug}, _url, socket) do
|
def handle_params(%{"slug" => slug} = params, _url, socket) do
|
||||||
actor = current_actor(socket)
|
actor = current_actor(socket)
|
||||||
|
|
||||||
# Check if user can read groups
|
# Check if user can read groups
|
||||||
if can?(actor, :read, Mv.Membership.Group) do
|
if can?(actor, :read, Mv.Membership.Group) do
|
||||||
load_group_by_slug(socket, slug, actor)
|
load_group_by_slug(socket, slug, actor, params)
|
||||||
else
|
else
|
||||||
{:noreply, redirect(socket, to: ~p"/members")}
|
{:noreply, redirect(socket, to: ~p"/members")}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp load_group_by_slug(socket, slug, actor) do
|
defp load_group_by_slug(socket, slug, actor, params) do
|
||||||
# Load group with members and member_count
|
# Load group with members and member_count
|
||||||
# Using explicit load ensures efficient preloading of members relationship
|
# Using explicit load ensures efficient preloading of members relationship
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
@ -68,10 +68,16 @@ defmodule MvWeb.GroupLive.Show do
|
||||||
|> redirect(to: ~p"/groups")}
|
|> redirect(to: ~p"/groups")}
|
||||||
|
|
||||||
{:ok, group} ->
|
{:ok, group} ->
|
||||||
{:noreply,
|
open_delete = params["confirm_delete"] == "1" && can?(actor, :destroy, group)
|
||||||
socket
|
|
||||||
|> assign(:page_title, group.name)
|
socket =
|
||||||
|> assign(:group, group)}
|
socket
|
||||||
|
|> assign(:page_title, group.name)
|
||||||
|
|> assign(:group, group)
|
||||||
|
|> assign(:show_delete_modal, open_delete)
|
||||||
|
|> assign(:name_confirmation, "")
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
|
||||||
{:error, _error} ->
|
{:error, _error} ->
|
||||||
{:noreply,
|
{:noreply,
|
||||||
|
|
@ -85,318 +91,346 @@ defmodule MvWeb.GroupLive.Show do
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||||
<%!-- Header with Back button, Name, and Edit/Delete buttons --%>
|
<.header>
|
||||||
<div class="flex items-center justify-between gap-4 pb-4">
|
<:leading>
|
||||||
<.button navigate={~p"/groups"} aria-label={gettext("Back to groups list")}>
|
<.button
|
||||||
<.icon name="hero-arrow-left" class="size-4" />
|
navigate={~p"/groups"}
|
||||||
{gettext("Back")}
|
variant="neutral"
|
||||||
</.button>
|
aria-label={gettext("Back to groups list")}
|
||||||
|
>
|
||||||
<h1 class="text-2xl font-bold text-center flex-1">
|
<.icon name="hero-arrow-left" class="size-4" />
|
||||||
{@group.name}
|
{gettext("Back")}
|
||||||
</h1>
|
</.button>
|
||||||
|
</:leading>
|
||||||
<div class="flex gap-2">
|
{@group.name}
|
||||||
|
<:actions>
|
||||||
<%= if can?(@current_user, :update, @group) do %>
|
<%= if can?(@current_user, :update, @group) do %>
|
||||||
<.button
|
<.button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
navigate={~p"/groups/#{@group.slug}/edit"}
|
navigate={~p"/groups/#{@group.slug}/edit"}
|
||||||
data-testid="group-show-edit-btn"
|
data-testid="group-show-edit-btn"
|
||||||
>
|
>
|
||||||
{gettext("Edit")}
|
<.icon name="hero-pencil-square" /> {gettext("Edit group")}
|
||||||
</.button>
|
</.button>
|
||||||
<% end %>
|
<% end %>
|
||||||
<%= if can?(@current_user, :destroy, @group) do %>
|
</:actions>
|
||||||
<.button
|
</.header>
|
||||||
class="btn-error"
|
|
||||||
phx-click="open_delete_modal"
|
|
||||||
data-testid="group-show-delete-btn"
|
|
||||||
>
|
|
||||||
{gettext("Delete")}
|
|
||||||
</.button>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%!-- Group Information --%>
|
<div class="mt-6 space-y-6">
|
||||||
<div class="max-w-2xl space-y-6 mb-6">
|
<%!-- Group Information --%>
|
||||||
<div>
|
<div class="max-w-2xl space-y-6 mb-6">
|
||||||
<h2 class="text-lg font-semibold mb-2">{gettext("Description")}</h2>
|
<div>
|
||||||
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
<h2 class="text-lg font-semibold mb-2">{gettext("Description")}</h2>
|
||||||
<%= if @group.description && String.trim(@group.description) != "" do %>
|
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
||||||
<p class="whitespace-pre-wrap">{@group.description}</p>
|
<%= if @group.description && String.trim(@group.description) != "" do %>
|
||||||
<% else %>
|
<p class="whitespace-pre-wrap">{@group.description}</p>
|
||||||
<p class="text-base-content/50 italic">{gettext("No description")}</p>
|
<% else %>
|
||||||
<% end %>
|
<p class="text-base-content/50 italic">{gettext("No description")}</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-lg font-semibold mb-2">{gettext("Members")}</h2>
|
<h2 class="text-lg font-semibold mb-2">{gettext("Members")}</h2>
|
||||||
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
||||||
<p class="mb-4" data-testid="group-show-member-count">
|
<p class="mb-4" data-testid="group-show-member-count">
|
||||||
{ngettext(
|
{ngettext(
|
||||||
"Total: %{count} member",
|
"Total: %{count} member",
|
||||||
"Total: %{count} members",
|
"Total: %{count} members",
|
||||||
@group.member_count || 0,
|
@group.member_count || 0,
|
||||||
count: @group.member_count || 0
|
count: @group.member_count || 0
|
||||||
)}
|
)}
|
||||||
</p>
|
|
||||||
|
|
||||||
<%= if can?(@current_user, :update, @group) do %>
|
|
||||||
<div class="mb-4">
|
|
||||||
<%= if assigns[:show_add_member_input] do %>
|
|
||||||
<div class="join w-full">
|
|
||||||
<form phx-change="search_members" class="flex-1">
|
|
||||||
<div class="relative">
|
|
||||||
<div class="input input-bordered join-item w-full flex flex-wrap gap-1 items-center py-1 px-2">
|
|
||||||
<%= for member <- @selected_members do %>
|
|
||||||
<span class="badge badge-outline badge flex items-center gap-1">
|
|
||||||
{MvWeb.Helpers.MemberHelpers.display_name(member)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-ghost btn-xs p-0 h-4 w-4 min-h-0"
|
|
||||||
phx-click="remove_selected_member"
|
|
||||||
phx-value-member_id={member.id}
|
|
||||||
aria-label={
|
|
||||||
gettext("Remove %{name}",
|
|
||||||
name: MvWeb.Helpers.MemberHelpers.display_name(member)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<.icon name="hero-x-mark" class="size-3" />
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
<% end %>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="member-search-input"
|
|
||||||
data-testid="group-show-member-search-input"
|
|
||||||
role="combobox"
|
|
||||||
phx-hook="ComboBox"
|
|
||||||
phx-focus="show_member_dropdown"
|
|
||||||
phx-debounce="300"
|
|
||||||
phx-keydown="member_dropdown_keydown"
|
|
||||||
phx-mounted={JS.focus()}
|
|
||||||
value={@member_search_query}
|
|
||||||
placeholder={
|
|
||||||
if Enum.empty?(@selected_members),
|
|
||||||
do: gettext("Search for a member..."),
|
|
||||||
else: ""
|
|
||||||
}
|
|
||||||
class="flex-1 min-w-[120px] border-0 focus:outline-none bg-transparent"
|
|
||||||
name="member_search"
|
|
||||||
aria-label={gettext("Search for a member")}
|
|
||||||
aria-autocomplete="list"
|
|
||||||
aria-controls="member-dropdown"
|
|
||||||
aria-expanded={to_string(@show_member_dropdown)}
|
|
||||||
aria-activedescendant={
|
|
||||||
if @focused_member_index,
|
|
||||||
do: "member-option-#{@focused_member_index}",
|
|
||||||
else: nil
|
|
||||||
}
|
|
||||||
autocomplete="off"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%= if length(@available_members) > 0 do %>
|
|
||||||
<div
|
|
||||||
id="member-dropdown"
|
|
||||||
role="listbox"
|
|
||||||
aria-label={gettext("Available members")}
|
|
||||||
class={"absolute z-10 w-full mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg max-h-60 overflow-auto top-full #{if !@show_member_dropdown, do: "hidden"}"}
|
|
||||||
phx-click-away="hide_member_dropdown"
|
|
||||||
>
|
|
||||||
<%= for {member, index} <- Enum.with_index(@available_members) do %>
|
|
||||||
<div
|
|
||||||
id={"member-option-#{index}"}
|
|
||||||
role="option"
|
|
||||||
tabindex="0"
|
|
||||||
aria-selected={to_string(@focused_member_index == index)}
|
|
||||||
phx-click="select_member"
|
|
||||||
phx-value-id={member.id}
|
|
||||||
data-member-id={member.id}
|
|
||||||
class={[
|
|
||||||
"px-4 py-3 cursor-pointer border-b border-base-300 last:border-b-0",
|
|
||||||
if(@focused_member_index == index,
|
|
||||||
do: "bg-base-300",
|
|
||||||
else: "hover:bg-base-200"
|
|
||||||
)
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<p class="font-medium">
|
|
||||||
{MvWeb.Helpers.MemberHelpers.display_name(member)}
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-base-content/70">
|
|
||||||
{member.email || gettext("No email")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-primary join-item"
|
|
||||||
phx-click="add_selected_members"
|
|
||||||
data-testid="group-show-add-selected-members-btn"
|
|
||||||
disabled={Enum.empty?(@selected_member_ids)}
|
|
||||||
aria-label={gettext("Add members")}
|
|
||||||
>
|
|
||||||
<.icon name="hero-plus" class="size-5" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn join-item"
|
|
||||||
phx-click="hide_add_member_input"
|
|
||||||
aria-label={gettext("Cancel")}
|
|
||||||
>
|
|
||||||
{gettext("Cancel")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<% else %>
|
|
||||||
<.button
|
|
||||||
variant="primary"
|
|
||||||
phx-click="show_add_member_input"
|
|
||||||
aria-label={gettext("Add Member")}
|
|
||||||
>
|
|
||||||
{gettext("Add Member")}
|
|
||||||
</.button>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<%= if Enum.empty?(@group.members || []) do %>
|
|
||||||
<p class="text-base-content/50 italic" data-testid="group-show-no-members">
|
|
||||||
{gettext("No members in this group")}
|
|
||||||
</p>
|
</p>
|
||||||
<% else %>
|
|
||||||
<div class="overflow-x-auto" data-testid="group-show-members-table">
|
<%= if can?(@current_user, :update, @group) do %>
|
||||||
<table class="table table-zebra">
|
<div class="mb-4">
|
||||||
<thead>
|
<%= if assigns[:show_add_member_input] do %>
|
||||||
<tr>
|
<div class="join w-full">
|
||||||
<th>{gettext("Name")}</th>
|
<form phx-change="search_members" class="flex-1">
|
||||||
<th>{gettext("Email")}</th>
|
<div class="relative">
|
||||||
<%= if can?(@current_user, :update, @group) do %>
|
<div class="input input-bordered join-item w-full flex flex-wrap gap-1 items-center py-1 px-2">
|
||||||
<th class="w-0">{gettext("Actions")}</th>
|
<%= for member <- @selected_members do %>
|
||||||
<% end %>
|
<span class="badge badge-outline badge flex items-center gap-1">
|
||||||
</tr>
|
{MvWeb.Helpers.MemberHelpers.display_name(member)}
|
||||||
</thead>
|
<.tooltip content={gettext("Remove")} position="top">
|
||||||
<tbody>
|
<.button
|
||||||
<%= for member <- @group.members do %>
|
type="button"
|
||||||
<tr>
|
variant="icon"
|
||||||
<td>
|
size="sm"
|
||||||
<.link
|
phx-click="remove_selected_member"
|
||||||
navigate={~p"/members/#{member.id}"}
|
phx-value-member_id={member.id}
|
||||||
class="link link-primary"
|
aria-label={
|
||||||
>
|
gettext("Remove %{name}",
|
||||||
{MvWeb.Helpers.MemberHelpers.display_name(member)}
|
name: MvWeb.Helpers.MemberHelpers.display_name(member)
|
||||||
</.link>
|
)
|
||||||
</td>
|
}
|
||||||
<td>
|
class="p-0 h-4 w-4 min-h-0"
|
||||||
<%= if member.email do %>
|
>
|
||||||
<a
|
<.icon name="hero-x-mark" class="size-3" />
|
||||||
href={"mailto:#{member.email}"}
|
</.button>
|
||||||
class="text-blue-700 hover:text-blue-800 underline"
|
</.tooltip>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="member-search-input"
|
||||||
|
data-testid="group-show-member-search-input"
|
||||||
|
role="combobox"
|
||||||
|
phx-hook="ComboBox"
|
||||||
|
phx-focus="show_member_dropdown"
|
||||||
|
phx-debounce="300"
|
||||||
|
phx-keydown="member_dropdown_keydown"
|
||||||
|
phx-mounted={JS.focus()}
|
||||||
|
value={@member_search_query}
|
||||||
|
placeholder={
|
||||||
|
if Enum.empty?(@selected_members),
|
||||||
|
do: gettext("Search for a member..."),
|
||||||
|
else: ""
|
||||||
|
}
|
||||||
|
class="flex-1 min-w-[120px] border-0 focus:outline-none bg-transparent"
|
||||||
|
name="member_search"
|
||||||
|
aria-label={gettext("Search for a member")}
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-controls="member-dropdown"
|
||||||
|
aria-expanded={to_string(@show_member_dropdown)}
|
||||||
|
aria-activedescendant={
|
||||||
|
if @focused_member_index,
|
||||||
|
do: "member-option-#{@focused_member_index}",
|
||||||
|
else: nil
|
||||||
|
}
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= if length(@available_members) > 0 do %>
|
||||||
|
<div
|
||||||
|
id="member-dropdown"
|
||||||
|
role="listbox"
|
||||||
|
aria-label={gettext("Available members")}
|
||||||
|
class={"absolute z-10 w-full mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg max-h-60 overflow-auto top-full #{if !@show_member_dropdown, do: "hidden"}"}
|
||||||
|
phx-click-away="hide_member_dropdown"
|
||||||
>
|
>
|
||||||
{member.email}
|
<%= for {member, index} <- Enum.with_index(@available_members) do %>
|
||||||
</a>
|
<div
|
||||||
<% else %>
|
id={"member-option-#{index}"}
|
||||||
<span class="text-base-content/50 italic">—</span>
|
role="option"
|
||||||
|
tabindex="0"
|
||||||
|
aria-selected={to_string(@focused_member_index == index)}
|
||||||
|
phx-click="select_member"
|
||||||
|
phx-value-id={member.id}
|
||||||
|
data-member-id={member.id}
|
||||||
|
class={[
|
||||||
|
"px-4 py-3 cursor-pointer border-b border-base-300 last:border-b-0",
|
||||||
|
if(@focused_member_index == index,
|
||||||
|
do: "bg-base-300",
|
||||||
|
else: "hover:bg-base-200"
|
||||||
|
)
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<p class="font-medium">
|
||||||
|
{MvWeb.Helpers.MemberHelpers.display_name(member)}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-base-content/70">
|
||||||
|
{member.email || gettext("No email")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</td>
|
</div>
|
||||||
|
</form>
|
||||||
|
<.button
|
||||||
|
type="button"
|
||||||
|
variant="primary"
|
||||||
|
phx-click="add_selected_members"
|
||||||
|
data-testid="group-show-add-selected-members-btn"
|
||||||
|
disabled={Enum.empty?(@selected_member_ids)}
|
||||||
|
aria-label={gettext("Add members")}
|
||||||
|
class="join-item"
|
||||||
|
>
|
||||||
|
<.icon name="hero-plus" class="size-5" />
|
||||||
|
</.button>
|
||||||
|
<.button
|
||||||
|
type="button"
|
||||||
|
variant="neutral"
|
||||||
|
phx-click="hide_add_member_input"
|
||||||
|
aria-label={gettext("Cancel")}
|
||||||
|
class="join-item"
|
||||||
|
>
|
||||||
|
{gettext("Cancel")}
|
||||||
|
</.button>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<.button
|
||||||
|
variant="primary"
|
||||||
|
phx-click="show_add_member_input"
|
||||||
|
aria-label={gettext("Add Member")}
|
||||||
|
>
|
||||||
|
{gettext("Add Member")}
|
||||||
|
</.button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= if Enum.empty?(@group.members || []) do %>
|
||||||
|
<p class="text-base-content/50 italic" data-testid="group-show-no-members">
|
||||||
|
{gettext("No members in this group")}
|
||||||
|
</p>
|
||||||
|
<% else %>
|
||||||
|
<div class="overflow-x-auto" data-testid="group-show-members-table">
|
||||||
|
<table class="table table-zebra">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{gettext("Name")}</th>
|
||||||
|
<th>{gettext("Email")}</th>
|
||||||
<%= if can?(@current_user, :update, @group) do %>
|
<%= if can?(@current_user, :update, @group) do %>
|
||||||
<td>
|
<th class="w-0">{gettext("Actions")}</th>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-ghost btn-sm text-error"
|
|
||||||
phx-click="remove_member"
|
|
||||||
phx-value-member_id={member.id}
|
|
||||||
data-testid="group-show-remove-member"
|
|
||||||
aria-label={gettext("Remove member from group")}
|
|
||||||
data-tooltip={gettext("Remove")}
|
|
||||||
>
|
|
||||||
<.icon name="hero-trash" class="size-4" />
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
</tr>
|
</tr>
|
||||||
<% end %>
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
<%= for member <- @group.members do %>
|
||||||
</div>
|
<tr>
|
||||||
<% end %>
|
<td>
|
||||||
|
<.link
|
||||||
|
navigate={~p"/members/#{member.id}"}
|
||||||
|
class="link link-primary"
|
||||||
|
>
|
||||||
|
{MvWeb.Helpers.MemberHelpers.display_name(member)}
|
||||||
|
</.link>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<%= if member.email do %>
|
||||||
|
<a
|
||||||
|
href={"mailto:#{member.email}"}
|
||||||
|
class="link link-primary"
|
||||||
|
>
|
||||||
|
{member.email}
|
||||||
|
</a>
|
||||||
|
<% else %>
|
||||||
|
<span class="text-base-content/50 italic">—</span>
|
||||||
|
<% end %>
|
||||||
|
</td>
|
||||||
|
<%= if can?(@current_user, :update, @group) do %>
|
||||||
|
<td>
|
||||||
|
<.tooltip content={gettext("Remove")} position="left">
|
||||||
|
<.button
|
||||||
|
type="button"
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
phx-click="remove_member"
|
||||||
|
phx-value-member_id={member.id}
|
||||||
|
data-testid="group-show-remove-member"
|
||||||
|
aria-label={gettext("Remove member from group")}
|
||||||
|
>
|
||||||
|
<.icon name="hero-trash" class="size-4" />
|
||||||
|
</.button>
|
||||||
|
</.tooltip>
|
||||||
|
</td>
|
||||||
|
<% end %>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<%!-- Delete Confirmation Modal --%>
|
<%!-- Danger zone: canonical pattern (same as member show) --%>
|
||||||
<%= if assigns[:show_delete_modal] do %>
|
<%= if can?(@current_user, :destroy, @group) do %>
|
||||||
<dialog id="delete-group-modal" class="modal modal-open" role="dialog">
|
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
|
||||||
<div class="modal-box">
|
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
|
||||||
<h3 class="text-lg font-bold mb-4">{gettext("Delete Group")}</h3>
|
{gettext("Danger zone")}
|
||||||
<p class="mb-4">
|
</h2>
|
||||||
{gettext("Are you sure you want to delete this group? This action cannot be undone.")}
|
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
||||||
</p>
|
<p class="text-base-content/70 mb-4">
|
||||||
<%= if @group.member_count && @group.member_count > 0 do %>
|
{gettext(
|
||||||
<div class="alert alert-warning mb-4">
|
"Deleting this group cannot be undone. All member-group associations will be permanently removed."
|
||||||
<.icon name="hero-exclamation-triangle" class="size-5" />
|
)}
|
||||||
<span>
|
</p>
|
||||||
{ngettext(
|
<.button
|
||||||
"This group has %{count} member. All member-group associations will be permanently deleted.",
|
variant="danger"
|
||||||
"This group has %{count} members. All member-group associations will be permanently deleted.",
|
|
||||||
@group.member_count,
|
|
||||||
count: @group.member_count
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
<div>
|
|
||||||
<label for="group-name-confirmation" class="label">
|
|
||||||
<span class="label-text">
|
|
||||||
{gettext("To confirm deletion, please enter the group name:")}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<div class="p-2 mb-2 font-mono text-lg font-bold break-all rounded bg-base-200">
|
|
||||||
{@group.name}
|
|
||||||
</div>
|
|
||||||
<form phx-change="update_name_confirmation">
|
|
||||||
<input
|
|
||||||
id="group-name-confirmation"
|
|
||||||
name="name"
|
|
||||||
type="text"
|
|
||||||
value={@name_confirmation || ""}
|
|
||||||
placeholder={gettext("Enter the group name to confirm")}
|
|
||||||
autocomplete="off"
|
|
||||||
phx-debounce="200"
|
|
||||||
class="w-full input input-bordered"
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div class="modal-action">
|
|
||||||
<button
|
|
||||||
type="button"
|
type="button"
|
||||||
class="btn"
|
phx-click="open_delete_modal"
|
||||||
phx-click="cancel_delete"
|
data-testid="group-show-delete-btn"
|
||||||
aria-label={gettext("Cancel")}
|
aria-label={gettext("Delete group %{name}", name: @group.name)}
|
||||||
>
|
>
|
||||||
{gettext("Cancel")}
|
<.icon name="hero-trash" class="size-4" />
|
||||||
</button>
|
{gettext("Delete group")}
|
||||||
<button
|
</.button>
|
||||||
type="button"
|
|
||||||
class="btn btn-error"
|
|
||||||
phx-click="confirm_delete"
|
|
||||||
phx-value-slug={@group.slug}
|
|
||||||
disabled={(@name_confirmation || "") != @group.name}
|
|
||||||
aria-label={gettext("Delete group")}
|
|
||||||
>
|
|
||||||
{gettext("Delete")}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</dialog>
|
<% end %>
|
||||||
<% end %>
|
|
||||||
|
<%!-- Delete Confirmation Modal --%>
|
||||||
|
<%= if assigns[:show_delete_modal] do %>
|
||||||
|
<dialog id="delete-group-modal" class="modal modal-open" role="dialog">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 class="text-lg font-bold mb-4">{gettext("Delete Group")}</h3>
|
||||||
|
<p class="mb-4">
|
||||||
|
{gettext("Are you sure you want to delete this group? This action cannot be undone.")}
|
||||||
|
</p>
|
||||||
|
<%= if @group.member_count && @group.member_count > 0 do %>
|
||||||
|
<div class="alert alert-warning mb-4">
|
||||||
|
<.icon name="hero-exclamation-triangle" class="size-5" />
|
||||||
|
<span>
|
||||||
|
{ngettext(
|
||||||
|
"This group has %{count} member. All member-group associations will be permanently deleted.",
|
||||||
|
"This group has %{count} members. All member-group associations will be permanently deleted.",
|
||||||
|
@group.member_count,
|
||||||
|
count: @group.member_count
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<div>
|
||||||
|
<label for="group-name-confirmation" class="label">
|
||||||
|
<span class="label-text">
|
||||||
|
{gettext("To confirm deletion, please enter the group name:")}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div class="p-2 mb-2 font-mono text-lg font-bold break-all rounded bg-base-200">
|
||||||
|
{@group.name}
|
||||||
|
</div>
|
||||||
|
<form phx-change="update_name_confirmation">
|
||||||
|
<input
|
||||||
|
id="group-name-confirmation"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
value={@name_confirmation || ""}
|
||||||
|
placeholder={gettext("Enter the group name to confirm")}
|
||||||
|
autocomplete="off"
|
||||||
|
phx-debounce="200"
|
||||||
|
class="w-full input input-bordered"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-action">
|
||||||
|
<.button
|
||||||
|
type="button"
|
||||||
|
variant="neutral"
|
||||||
|
phx-click="cancel_delete"
|
||||||
|
aria-label={gettext("Cancel")}
|
||||||
|
>
|
||||||
|
{gettext("Cancel")}
|
||||||
|
</.button>
|
||||||
|
<.button
|
||||||
|
type="button"
|
||||||
|
variant="danger"
|
||||||
|
phx-click="confirm_delete"
|
||||||
|
phx-value-slug={@group.slug}
|
||||||
|
disabled={(@name_confirmation || "") != @group.name}
|
||||||
|
aria-label={gettext("Delete group")}
|
||||||
|
>
|
||||||
|
{gettext("Delete")}
|
||||||
|
</.button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
@ -900,7 +934,7 @@ defmodule MvWeb.GroupLive.Show do
|
||||||
:ok ->
|
:ok ->
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> put_flash(:info, gettext("Group deleted successfully."))
|
|> put_flash(:success, gettext("Group deleted successfully."))
|
||||||
|> redirect(to: ~p"/groups")}
|
|> redirect(to: ~p"/groups")}
|
||||||
|
|
||||||
{:error, error} ->
|
{:error, error} ->
|
||||||
|
|
|
||||||
|
|
@ -42,11 +42,13 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
|
||||||
<div class="flex items-center gap-4 mb-4">
|
<div class="flex items-center gap-4 mb-4">
|
||||||
<.button
|
<.button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="neutral"
|
||||||
phx-click="cancel"
|
phx-click="cancel"
|
||||||
phx-target={@myself}
|
phx-target={@myself}
|
||||||
aria-label={gettext("Back to Settings")}
|
aria-label={gettext("Back to settings")}
|
||||||
>
|
>
|
||||||
<.icon name="hero-arrow-left" class="w-4 h-4" />
|
<.icon name="hero-arrow-left" class="size-4" />
|
||||||
|
{gettext("Back")}
|
||||||
</.button>
|
</.button>
|
||||||
<h3 class="card-title">
|
<h3 class="card-title">
|
||||||
{gettext("Edit Field: %{field}", field: @field_label)}
|
{gettext("Edit Field: %{field}", field: @field_label)}
|
||||||
|
|
@ -176,7 +178,7 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="justify-end mt-4 card-actions">
|
<div class="justify-end mt-4 card-actions">
|
||||||
<.button type="button" phx-click="cancel" phx-target={@myself}>
|
<.button type="button" variant="neutral" phx-click="cancel" phx-target={@myself}>
|
||||||
{gettext("Cancel")}
|
{gettext("Cancel")}
|
||||||
</.button>
|
</.button>
|
||||||
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,12 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
|
||||||
:if={!@show_form}
|
:if={!@show_form}
|
||||||
id="member_fields"
|
id="member_fields"
|
||||||
rows={@member_fields}
|
rows={@member_fields}
|
||||||
|
row_click={
|
||||||
|
fn {field_name, _field_data} ->
|
||||||
|
JS.push("edit_member_field", value: %{"field" => field_name}, target: @myself)
|
||||||
|
end
|
||||||
|
}
|
||||||
|
row_tooltip={gettext("Click to edit datafield")}
|
||||||
>
|
>
|
||||||
<:col :let={{_field_name, field_data}} label={gettext("Name")}>
|
<:col :let={{_field_name, field_data}} label={gettext("Name")}>
|
||||||
{MemberFields.label(field_data.field)}
|
{MemberFields.label(field_data.field)}
|
||||||
|
|
@ -86,16 +92,6 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
|
||||||
{gettext("No")}
|
{gettext("No")}
|
||||||
</span>
|
</span>
|
||||||
</:col>
|
</:col>
|
||||||
|
|
||||||
<:action :let={{_field_name, field_data}}>
|
|
||||||
<.link
|
|
||||||
phx-click="edit_member_field"
|
|
||||||
phx-value-field={Atom.to_string(field_data.field)}
|
|
||||||
phx-target={@myself}
|
|
||||||
>
|
|
||||||
{gettext("Edit")}
|
|
||||||
</.link>
|
|
||||||
</:action>
|
|
||||||
</.table>
|
</.table>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
- Create new members with personal information
|
- Create new members with personal information
|
||||||
- Edit existing member details
|
- Edit existing member details
|
||||||
- Grouped sections for better organization
|
- Grouped sections for better organization
|
||||||
- Tab navigation (Payments tab disabled, coming soon)
|
|
||||||
- Manage custom properties (dynamic fields, displayed sorted by name)
|
- Manage custom properties (dynamic fields, displayed sorted by name)
|
||||||
- Real-time validation with visual feedback
|
- Real-time validation with visual feedback
|
||||||
|
|
||||||
|
|
@ -21,6 +20,7 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
"""
|
"""
|
||||||
use MvWeb, :live_view
|
use MvWeb, :live_view
|
||||||
|
|
||||||
|
require Logger
|
||||||
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
|
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
|
||||||
|
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
|
|
@ -38,222 +38,248 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
~H"""
|
~H"""
|
||||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||||
<.form for={@form} id="member-form" phx-change="validate" phx-submit="save">
|
<.form for={@form} id="member-form" phx-change="validate" phx-submit="save">
|
||||||
<%!-- Header with Back button, Name display, and Save button --%>
|
<.header>
|
||||||
<div class="flex items-center justify-between gap-4 pb-4">
|
<:leading>
|
||||||
<.button navigate={return_path(@return_to, @member)} type="button">
|
<.button navigate={return_path(@return_to, @member)} variant="neutral">
|
||||||
<.icon name="hero-arrow-left" class="size-4" />
|
<.icon name="hero-arrow-left" class="size-4" />
|
||||||
{gettext("Back")}
|
{gettext("Back")}
|
||||||
</.button>
|
</.button>
|
||||||
|
</:leading>
|
||||||
|
<%= if @member do %>
|
||||||
|
{MvWeb.Helpers.MemberHelpers.display_name(@member)}
|
||||||
|
<% else %>
|
||||||
|
{gettext("New Member")}
|
||||||
|
<% end %>
|
||||||
|
<:actions>
|
||||||
|
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
|
||||||
|
{gettext("Save")}
|
||||||
|
</.button>
|
||||||
|
</:actions>
|
||||||
|
</.header>
|
||||||
|
|
||||||
<h1 class="text-2xl font-bold text-center flex-1">
|
<div class="mt-6 space-y-6">
|
||||||
<%= if @member do %>
|
<%!-- Tab navigation: Payments tab not shown on new/edit (only on member show) --%>
|
||||||
{MvWeb.Helpers.MemberHelpers.display_name(@member)}
|
<div role="tablist" class="tabs tabs-bordered">
|
||||||
<% else %>
|
<button type="button" role="tab" class="tab tab-active" aria-selected="true">
|
||||||
{gettext("New Member")}
|
<.icon name="hero-identification" class="size-4 mr-2" />
|
||||||
<% end %>
|
{gettext("Contact Data")}
|
||||||
</h1>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
|
<%!-- Personal Data and Custom Fields Row --%>
|
||||||
{gettext("Save")}
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||||
</.button>
|
<%!-- Personal Data Section --%>
|
||||||
</div>
|
<div>
|
||||||
|
<.form_section title={gettext("Personal Data")}>
|
||||||
<%!-- Tab Navigation --%>
|
<div class="space-y-4">
|
||||||
<div role="tablist" class="tabs tabs-bordered mb-6">
|
<%!-- Name Row --%>
|
||||||
<button type="button" role="tab" class="tab tab-active" aria-selected="true">
|
<div class="flex gap-4">
|
||||||
<.icon name="hero-identification" class="size-4 mr-2" />
|
<div class="w-48">
|
||||||
{gettext("Contact Data")}
|
<.input
|
||||||
</button>
|
field={@form[:first_name]}
|
||||||
<button
|
label={gettext("First Name")}
|
||||||
type="button"
|
required={@member_field_required_map[:first_name]}
|
||||||
role="tab"
|
/>
|
||||||
class="tab"
|
</div>
|
||||||
disabled
|
<div class="w-48">
|
||||||
aria-disabled="true"
|
<.input
|
||||||
title={gettext("Coming soon")}
|
field={@form[:last_name]}
|
||||||
>
|
label={gettext("Last Name")}
|
||||||
<.icon name="hero-credit-card" class="size-4 mr-2" />
|
required={@member_field_required_map[:last_name]}
|
||||||
{gettext("Payments")}
|
/>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<%!-- Personal Data and Custom Fields Row --%>
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
|
||||||
<%!-- Personal Data Section --%>
|
|
||||||
<div>
|
|
||||||
<.form_section title={gettext("Personal Data")}>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<%!-- Name Row --%>
|
|
||||||
<div class="flex gap-4">
|
|
||||||
<div class="w-48">
|
|
||||||
<.input
|
|
||||||
field={@form[:first_name]}
|
|
||||||
label={gettext("First Name")}
|
|
||||||
required={@member_field_required_map[:first_name]}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="w-48">
|
|
||||||
<.input
|
|
||||||
field={@form[:last_name]}
|
|
||||||
label={gettext("Last Name")}
|
|
||||||
required={@member_field_required_map[:last_name]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%!-- Address: Country, Postal Code, City in one row --%>
|
<%!-- Address: Country, Postal Code, City in one row --%>
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
<div class="w-48">
|
<div class="w-48">
|
||||||
<.input field={@form[:country]} label={gettext("Country")} />
|
<.input field={@form[:country]} label={gettext("Country")} />
|
||||||
|
</div>
|
||||||
|
<div class="w-24">
|
||||||
|
<.input
|
||||||
|
field={@form[:postal_code]}
|
||||||
|
label={gettext("Postal Code")}
|
||||||
|
required={@member_field_required_map[:postal_code]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="w-48">
|
||||||
|
<.input field={@form[:city]} label={gettext("City")} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-24">
|
|
||||||
<.input
|
|
||||||
field={@form[:postal_code]}
|
|
||||||
label={gettext("Postal Code")}
|
|
||||||
required={@member_field_required_map[:postal_code]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="w-48">
|
|
||||||
<.input field={@form[:city]} label={gettext("City")} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%!-- Street and Nr. below --%>
|
<%!-- Street and Nr. below --%>
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
|
<div class="w-64">
|
||||||
|
<.input field={@form[:street]} label={gettext("Street")} />
|
||||||
|
</div>
|
||||||
|
<div class="w-24">
|
||||||
|
<.input field={@form[:house_number]} label={gettext("Nr.")} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%!-- Email --%>
|
||||||
<div class="w-64">
|
<div class="w-64">
|
||||||
<.input field={@form[:street]} label={gettext("Street")} />
|
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-24">
|
|
||||||
<.input field={@form[:house_number]} label={gettext("Nr.")} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%!-- Email --%>
|
<%!-- Membership Dates Row --%>
|
||||||
<div class="w-64">
|
<div class="flex gap-4">
|
||||||
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
|
<div class="w-36">
|
||||||
</div>
|
<.input
|
||||||
|
field={@form[:join_date]}
|
||||||
<%!-- Membership Dates Row --%>
|
label={gettext("Join Date")}
|
||||||
<div class="flex gap-4">
|
type="date"
|
||||||
<div class="w-36">
|
required={@member_field_required_map[:join_date]}
|
||||||
<.input
|
/>
|
||||||
field={@form[:join_date]}
|
</div>
|
||||||
label={gettext("Join Date")}
|
<div class="w-36">
|
||||||
type="date"
|
<.input
|
||||||
required={@member_field_required_map[:join_date]}
|
field={@form[:exit_date]}
|
||||||
/>
|
label={gettext("Exit Date")}
|
||||||
|
type="date"
|
||||||
|
required={@member_field_required_map[:exit_date]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-36">
|
|
||||||
|
<%!-- Notes --%>
|
||||||
|
<div>
|
||||||
<.input
|
<.input
|
||||||
field={@form[:exit_date]}
|
field={@form[:notes]}
|
||||||
label={gettext("Exit Date")}
|
label={gettext("Notes")}
|
||||||
type="date"
|
type="textarea"
|
||||||
required={@member_field_required_map[:exit_date]}
|
required={@member_field_required_map[:notes]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</.form_section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<%!-- Notes --%>
|
<%!-- Custom Fields Section --%>
|
||||||
|
<%= if Enum.any?(@custom_fields) do %>
|
||||||
|
<div>
|
||||||
|
<.form_section title={gettext("Custom Fields")}>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<%!-- Render in sorted order by finding the form for each sorted custom field --%>
|
||||||
|
<%= for cf <- @sorted_custom_fields do %>
|
||||||
|
<.inputs_for :let={f_cfv} field={@form[:custom_field_values]}>
|
||||||
|
<%= if f_cfv[:custom_field_id].value == cf.id do %>
|
||||||
|
<div class={if cf.value_type == :boolean, do: "flex items-end", else: ""}>
|
||||||
|
<.inputs_for :let={value_form} field={f_cfv[:value]}>
|
||||||
|
<.input
|
||||||
|
field={value_form[:value]}
|
||||||
|
label={cf.name}
|
||||||
|
type={custom_field_input_type(cf.value_type)}
|
||||||
|
required={cf.required}
|
||||||
|
/>
|
||||||
|
</.inputs_for>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name={f_cfv[:custom_field_id].name}
|
||||||
|
value={f_cfv[:custom_field_id].value}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</.inputs_for>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</.form_section>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%!-- Membership Fee Section --%>
|
||||||
|
<div class="max-w-xl">
|
||||||
|
<.form_section title={gettext("Membership Fee")}>
|
||||||
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<.input
|
<label class="label">
|
||||||
field={@form[:notes]}
|
<span class="label-text font-semibold">{gettext("Membership Fee Type")}</span>
|
||||||
label={gettext("Notes")}
|
</label>
|
||||||
type="textarea"
|
<select
|
||||||
required={@member_field_required_map[:notes]}
|
class="select select-bordered w-full"
|
||||||
/>
|
name={@form[:membership_fee_type_id].name}
|
||||||
|
phx-change="validate"
|
||||||
|
value={@form[:membership_fee_type_id].value || ""}
|
||||||
|
>
|
||||||
|
<%!-- No "None" option: a membership fee type is required (validated in Member resource). --%>
|
||||||
|
<option value="">{gettext("Select a membership fee type")}</option>
|
||||||
|
<%= for fee_type <- @available_fee_types do %>
|
||||||
|
<option
|
||||||
|
value={fee_type.id}
|
||||||
|
selected={fee_type.id == @form[:membership_fee_type_id].value}
|
||||||
|
>
|
||||||
|
{fee_type.name} ({MembershipFeeHelpers.format_currency(fee_type.amount)}, {MembershipFeeHelpers.format_interval(
|
||||||
|
fee_type.interval
|
||||||
|
)})
|
||||||
|
</option>
|
||||||
|
<% end %>
|
||||||
|
</select>
|
||||||
|
<%= for error <- List.wrap(@form.errors[:membership_fee_type_id] || []) do %>
|
||||||
|
<% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %>
|
||||||
|
<p class="text-error text-sm mt-1">{msg}</p>
|
||||||
|
<% end %>
|
||||||
|
<%= if @interval_warning do %>
|
||||||
|
<div class="alert alert-warning mt-2">
|
||||||
|
<.icon name="hero-exclamation-triangle" class="size-5" />
|
||||||
|
<span>{@interval_warning}</span>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<p class="text-sm text-base-content/60 mt-2">
|
||||||
|
{gettext(
|
||||||
|
"Select a membership fee type for this member. Members can only switch between types with the same interval."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</.form_section>
|
</.form_section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%!-- Custom Fields Section --%>
|
<%!-- Bottom Action Buttons --%>
|
||||||
<%= if Enum.any?(@custom_fields) do %>
|
<div class="flex justify-end gap-4 mt-6">
|
||||||
<div>
|
<.button navigate={return_path(@return_to, @member)} variant="neutral" type="button">
|
||||||
<.form_section title={gettext("Custom Fields")}>
|
{gettext("Cancel")}
|
||||||
<div class="grid grid-cols-2 gap-4">
|
</.button>
|
||||||
<%!-- Render in sorted order by finding the form for each sorted custom field --%>
|
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
|
||||||
<%= for cf <- @sorted_custom_fields do %>
|
{gettext("Save Member")}
|
||||||
<.inputs_for :let={f_cfv} field={@form[:custom_field_values]}>
|
</.button>
|
||||||
<%= if f_cfv[:custom_field_id].value == cf.id do %>
|
</div>
|
||||||
<div class={if cf.value_type == :boolean, do: "flex items-end", else: ""}>
|
|
||||||
<.inputs_for :let={value_form} field={f_cfv[:value]}>
|
|
||||||
<.input
|
|
||||||
field={value_form[:value]}
|
|
||||||
label={cf.name}
|
|
||||||
type={custom_field_input_type(cf.value_type)}
|
|
||||||
required={cf.required}
|
|
||||||
/>
|
|
||||||
</.inputs_for>
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name={f_cfv[:custom_field_id].name}
|
|
||||||
value={f_cfv[:custom_field_id].value}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</.inputs_for>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</.form_section>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%!-- Membership Fee Section --%>
|
<%!-- Danger zone: same section pattern as MemberLive.Show (canonical) --%>
|
||||||
<div class="max-w-xl">
|
<%= if @member && can?(@current_user, :destroy, @member) do %>
|
||||||
<.form_section title={gettext("Membership Fee")}>
|
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
|
||||||
<div class="space-y-4">
|
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
|
||||||
<div>
|
{gettext("Danger zone")}
|
||||||
<label class="label">
|
</h2>
|
||||||
<span class="label-text font-semibold">{gettext("Membership Fee Type")}</span>
|
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
||||||
</label>
|
<p class="text-base-content/70 mb-4">
|
||||||
<select
|
|
||||||
class="select select-bordered w-full"
|
|
||||||
name={@form[:membership_fee_type_id].name}
|
|
||||||
phx-change="validate"
|
|
||||||
value={@form[:membership_fee_type_id].value || ""}
|
|
||||||
>
|
|
||||||
<%!-- No "None" option: a membership fee type is required (validated in Member resource). --%>
|
|
||||||
<option value="">{gettext("Select a membership fee type")}</option>
|
|
||||||
<%= for fee_type <- @available_fee_types do %>
|
|
||||||
<option
|
|
||||||
value={fee_type.id}
|
|
||||||
selected={fee_type.id == @form[:membership_fee_type_id].value}
|
|
||||||
>
|
|
||||||
{fee_type.name} ({MembershipFeeHelpers.format_currency(fee_type.amount)}, {MembershipFeeHelpers.format_interval(
|
|
||||||
fee_type.interval
|
|
||||||
)})
|
|
||||||
</option>
|
|
||||||
<% end %>
|
|
||||||
</select>
|
|
||||||
<%= for error <- List.wrap(@form.errors[:membership_fee_type_id] || []) do %>
|
|
||||||
<% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %>
|
|
||||||
<p class="text-error text-sm mt-1">{msg}</p>
|
|
||||||
<% end %>
|
|
||||||
<%= if @interval_warning do %>
|
|
||||||
<div class="alert alert-warning mt-2">
|
|
||||||
<.icon name="hero-exclamation-triangle" class="size-5" />
|
|
||||||
<span>{@interval_warning}</span>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
<p class="text-sm text-base-content/60 mt-2">
|
|
||||||
{gettext(
|
{gettext(
|
||||||
"Select a membership fee type for this member. Members can only switch between types with the same interval."
|
"Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed."
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
<.button
|
||||||
|
variant="danger"
|
||||||
|
type="button"
|
||||||
|
phx-click="delete"
|
||||||
|
phx-value-id={@member.id}
|
||||||
|
data-confirm={
|
||||||
|
gettext("Are you sure you want to delete %{name}? This action cannot be undone.",
|
||||||
|
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
data-testid="member-delete"
|
||||||
|
aria-label={
|
||||||
|
gettext("Delete member %{name}",
|
||||||
|
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<.icon name="hero-trash" class="size-4" />
|
||||||
|
{gettext("Delete member")}
|
||||||
|
</.button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</.form_section>
|
<% end %>
|
||||||
</div>
|
|
||||||
|
|
||||||
<%!-- Bottom Action Buttons --%>
|
|
||||||
<div class="flex justify-end gap-4 mt-6">
|
|
||||||
<.button navigate={return_path(@return_to, @member)} type="button">
|
|
||||||
{gettext("Cancel")}
|
|
||||||
</.button>
|
|
||||||
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
|
|
||||||
{gettext("Save Member")}
|
|
||||||
</.button>
|
|
||||||
</div>
|
</div>
|
||||||
</.form>
|
</.form>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
|
|
@ -374,6 +400,41 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("delete", %{"id" => id}, socket) do
|
||||||
|
member = socket.assigns.member
|
||||||
|
actor = current_actor(socket)
|
||||||
|
|
||||||
|
cond do
|
||||||
|
is_nil(member) ->
|
||||||
|
{:noreply, put_flash(socket, :error, gettext("Member not found"))}
|
||||||
|
|
||||||
|
to_string(id) != to_string(member.id) ->
|
||||||
|
{:noreply, put_flash(socket, :error, gettext("Member not found"))}
|
||||||
|
|
||||||
|
true ->
|
||||||
|
handle_member_delete_destroy(socket, member, actor)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp handle_member_delete_destroy(socket, member, actor) do
|
||||||
|
case Ash.destroy(member, actor: actor) do
|
||||||
|
:ok ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:success, gettext("Member deleted successfully"))
|
||||||
|
|> push_navigate(to: ~p"/members")}
|
||||||
|
|
||||||
|
{:error, %Ash.Error.Forbidden{}} ->
|
||||||
|
{:noreply,
|
||||||
|
put_flash(socket, :error, gettext("You do not have permission to delete this member"))}
|
||||||
|
|
||||||
|
{:error, error} ->
|
||||||
|
Logger.warning("Member delete failed: member_id=#{member.id} error=#{inspect(error)}")
|
||||||
|
{:noreply, put_flash(socket, :error, format_destroy_error(error))}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp handle_save_success(socket, member) do
|
defp handle_save_success(socket, member) do
|
||||||
notify_parent({:saved, member})
|
notify_parent({:saved, member})
|
||||||
|
|
||||||
|
|
@ -386,7 +447,7 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> put_flash(:info, flash_message)
|
|> put_flash(:success, flash_message)
|
||||||
|> maybe_put_vereinfacht_sync_flash(member.id)
|
|> maybe_put_vereinfacht_sync_flash(member.id)
|
||||||
|> push_navigate(to: return_path(socket.assigns.return_to, member))
|
|> push_navigate(to: return_path(socket.assigns.return_to, member))
|
||||||
|
|
||||||
|
|
@ -421,6 +482,19 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp format_destroy_error(%Ash.Error.Invalid{errors: errors}) do
|
||||||
|
error_messages =
|
||||||
|
Enum.map(errors, fn
|
||||||
|
%{field: field, message: message} -> "#{field}: #{message}"
|
||||||
|
%{message: message} -> message
|
||||||
|
_ -> inspect(errors)
|
||||||
|
end)
|
||||||
|
|
||||||
|
Enum.join(error_messages, ", ")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_destroy_error(error), do: inspect(error)
|
||||||
|
|
||||||
defp handle_save_error(socket, form) do
|
defp handle_save_error(socket, form) do
|
||||||
# Always show a flash message when save fails
|
# Always show a flash message when save fails
|
||||||
# Field-level validation errors are displayed in form fields, but flash provides additional feedback
|
# Field-level validation errors are displayed in form fields, but flash provides additional feedback
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
- `sort_order` - Sort direction (:asc or :desc)
|
- `sort_order` - Sort direction (:asc or :desc)
|
||||||
|
|
||||||
## Events
|
## Events
|
||||||
- `delete` - Remove a member from the database
|
|
||||||
- `select_member` - Toggle individual member selection
|
- `select_member` - Toggle individual member selection
|
||||||
- `select_all` - Toggle selection of all visible members
|
- `select_all` - Toggle selection of all visible members
|
||||||
- `copy_emails` - Copy email addresses of selected members to clipboard
|
- `copy_emails` - Copy email addresses of selected members to clipboard
|
||||||
|
|
@ -123,6 +122,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|> assign(:groups, groups)
|
|> assign(:groups, groups)
|
||||||
|> assign(:boolean_custom_field_filters, %{})
|
|> assign(:boolean_custom_field_filters, %{})
|
||||||
|> assign(:selected_members, MapSet.new())
|
|> assign(:selected_members, MapSet.new())
|
||||||
|
|> assign(:selected_member_id, nil)
|
||||||
|> assign(:settings, settings)
|
|> assign(:settings, settings)
|
||||||
|> assign(:custom_fields_visible, custom_fields_visible)
|
|> assign(:custom_fields_visible, custom_fields_visible)
|
||||||
|> assign(:all_custom_fields, all_custom_fields)
|
|> assign(:all_custom_fields, all_custom_fields)
|
||||||
|
|
@ -157,48 +157,14 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
Handles member-related UI events.
|
Handles member-related UI events.
|
||||||
|
|
||||||
## Supported events:
|
## Supported events:
|
||||||
- `"delete"` - Removes a member from the database
|
|
||||||
- `"select_member"` - Toggles individual member selection
|
- `"select_member"` - Toggles individual member selection
|
||||||
- `"select_all"` - Toggles selection of all visible members
|
- `"select_all"` - Toggles selection of all visible members
|
||||||
- `"sort"` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL
|
- `"sort"` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL
|
||||||
"""
|
"""
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("delete", %{"id" => id}, socket) do
|
def handle_event("select_row_and_navigate", %{"id" => id}, socket) do
|
||||||
actor = current_actor(socket)
|
# Navigate to member show. Back button on show page uses ?highlight=id so returning to index shows row as selected.
|
||||||
|
{:noreply, push_navigate(socket, to: ~p"/members/#{id}")}
|
||||||
case Ash.get(Mv.Membership.Member, id, actor: actor) do
|
|
||||||
{:ok, member} ->
|
|
||||||
case Ash.destroy(member, actor: actor) do
|
|
||||||
:ok ->
|
|
||||||
updated_members = Enum.reject(socket.assigns.members, &(&1.id == id))
|
|
||||||
|
|
||||||
{:noreply,
|
|
||||||
socket
|
|
||||||
|> assign(:members, updated_members)
|
|
||||||
|> put_flash(:info, gettext("Member deleted successfully"))}
|
|
||||||
|
|
||||||
{:error, %Ash.Error.Forbidden{}} ->
|
|
||||||
{:noreply,
|
|
||||||
put_flash(
|
|
||||||
socket,
|
|
||||||
:error,
|
|
||||||
gettext("You do not have permission to delete this member")
|
|
||||||
)}
|
|
||||||
|
|
||||||
{:error, error} ->
|
|
||||||
{:noreply, put_flash(socket, :error, format_error(error))}
|
|
||||||
end
|
|
||||||
|
|
||||||
{:error, %Ash.Error.Query.NotFound{}} ->
|
|
||||||
{:noreply, put_flash(socket, :error, gettext("Member not found"))}
|
|
||||||
|
|
||||||
{:error, %Ash.Error.Forbidden{} = _error} ->
|
|
||||||
{:noreply,
|
|
||||||
put_flash(socket, :error, gettext("You do not have permission to access this member"))}
|
|
||||||
|
|
||||||
{:error, error} ->
|
|
||||||
{:noreply, put_flash(socket, :error, format_error(error))}
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
@ -343,22 +309,6 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
{:noreply, push_patch(socket, to: ~p"/members?#{query_params}", replace: true)}
|
{:noreply, push_patch(socket, to: ~p"/members?#{query_params}", replace: true)}
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper to format errors for display
|
|
||||||
defp format_error(%Ash.Error.Invalid{errors: errors}) do
|
|
||||||
error_messages =
|
|
||||||
Enum.map(errors, fn error ->
|
|
||||||
case error do
|
|
||||||
%{field: field, message: message} -> "#{field}: #{message}"
|
|
||||||
%{message: message} -> message
|
|
||||||
_ -> inspect(error)
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
|
|
||||||
Enum.join(error_messages, ", ")
|
|
||||||
end
|
|
||||||
|
|
||||||
defp format_error(error), do: inspect(error)
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------
|
# -----------------------------------------------------------------
|
||||||
# Handle Infos from Child Components
|
# Handle Infos from Child Components
|
||||||
# -----------------------------------------------------------------
|
# -----------------------------------------------------------------
|
||||||
|
|
@ -656,6 +606,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|> assign(:member_fields_visible_db, visible_member_fields_db)
|
|> assign(:member_fields_visible_db, visible_member_fields_db)
|
||||||
|> assign(:member_fields_visible_computed, visible_member_fields_computed)
|
|> assign(:member_fields_visible_computed, visible_member_fields_computed)
|
||||||
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|
||||||
|
|> assign(:selected_member_id, parse_highlight_param(params["highlight"]))
|
||||||
|
|
||||||
next_sig = build_signature(socket)
|
next_sig = build_signature(socket)
|
||||||
|
|
||||||
|
|
@ -855,6 +806,18 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Parses optional "highlight" URL param (member id for selected row styling). Returns nil if missing or invalid.
|
||||||
|
defp parse_highlight_param(nil), do: nil
|
||||||
|
defp parse_highlight_param(""), do: nil
|
||||||
|
|
||||||
|
defp parse_highlight_param(id) when is_binary(id) do
|
||||||
|
if String.length(id) <= @max_uuid_length and match?({:ok, _}, Ecto.UUID.cast(id)),
|
||||||
|
do: id,
|
||||||
|
else: nil
|
||||||
|
end
|
||||||
|
|
||||||
|
defp parse_highlight_param(_), do: nil
|
||||||
|
|
||||||
defp merge_fields_param_from_uri(params, nil), do: params
|
defp merge_fields_param_from_uri(params, nil), do: params
|
||||||
|
|
||||||
defp merge_fields_param_from_uri(params, %URI{query: query}) when is_binary(query) do
|
defp merge_fields_param_from_uri(params, %URI{query: query}) when is_binary(query) do
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
selected_count={@selected_count}
|
selected_count={@selected_count}
|
||||||
/>
|
/>
|
||||||
<.button
|
<.button
|
||||||
class="secondary"
|
variant="secondary"
|
||||||
id="copy-emails-btn"
|
id="copy-emails-btn"
|
||||||
phx-hook="CopyToClipboard"
|
phx-hook="CopyToClipboard"
|
||||||
phx-click="copy_emails"
|
phx-click="copy_emails"
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
{gettext("Copy email addresses")} ({@selected_count})
|
{gettext("Copy email addresses")} ({@selected_count})
|
||||||
</.button>
|
</.button>
|
||||||
<.button
|
<.button
|
||||||
class="secondary"
|
variant="secondary"
|
||||||
id="open-email-btn"
|
id="open-email-btn"
|
||||||
href={"mailto:?bcc=" <> @mailto_bcc}
|
href={"mailto:?bcc=" <> @mailto_bcc}
|
||||||
disabled={not @any_selected?}
|
disabled={not @any_selected?}
|
||||||
|
|
@ -54,13 +54,12 @@
|
||||||
boolean_filters={@boolean_custom_field_filters}
|
boolean_filters={@boolean_custom_field_filters}
|
||||||
member_count={length(@members)}
|
member_count={length(@members)}
|
||||||
/>
|
/>
|
||||||
<button
|
<.button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
class={["gap-2", @show_current_cycle && "btn-active"]}
|
||||||
phx-click="toggle_cycle_view"
|
phx-click="toggle_cycle_view"
|
||||||
class={[
|
data-testid="toggle-cycle-view"
|
||||||
"btn gap-2",
|
|
||||||
@show_current_cycle && "btn-active"
|
|
||||||
]}
|
|
||||||
aria-label={
|
aria-label={
|
||||||
if(@show_current_cycle,
|
if(@show_current_cycle,
|
||||||
do: gettext("Current Cycle Payment Status"),
|
do: gettext("Current Cycle Payment Status"),
|
||||||
|
|
@ -81,7 +80,7 @@
|
||||||
else: gettext("Last Cycle Payment Status")
|
else: gettext("Last Cycle Payment Status")
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</.button>
|
||||||
<.live_component
|
<.live_component
|
||||||
module={MvWeb.Components.FieldVisibilityDropdownComponent}
|
module={MvWeb.Components.FieldVisibilityDropdownComponent}
|
||||||
id="field-visibility-dropdown"
|
id="field-visibility-dropdown"
|
||||||
|
|
@ -91,334 +90,329 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<.table
|
<%!-- On desktop (lg:), only the table area scrolls; header and filters stay visible. On mobile, normal flow. --%>
|
||||||
id="members"
|
<div
|
||||||
rows={@members}
|
class="lg:max-h-[calc(100vh-14rem)] lg:overflow-auto min-h-0"
|
||||||
row_id={fn member -> "row-#{member.id}" end}
|
data-testid="members-table-scroll"
|
||||||
row_click={fn member -> JS.navigate(~p"/members/#{member}") end}
|
role="region"
|
||||||
dynamic_cols={@dynamic_cols}
|
aria-label={gettext("Members table")}
|
||||||
sort_field={@sort_field}
|
|
||||||
sort_order={@sort_order}
|
|
||||||
>
|
>
|
||||||
|
<.table
|
||||||
|
id="members"
|
||||||
|
rows={@members}
|
||||||
|
sticky_header={true}
|
||||||
|
row_id={fn member -> "row-#{member.id}" end}
|
||||||
|
row_click={fn member -> JS.push("select_row_and_navigate", value: %{id: member.id}) end}
|
||||||
|
row_tooltip={gettext("Click for member details")}
|
||||||
|
row_selected?={fn member -> MapSet.member?(@selected_members, member.id) end}
|
||||||
|
dynamic_cols={@dynamic_cols}
|
||||||
|
sort_field={@sort_field}
|
||||||
|
sort_order={@sort_order}
|
||||||
|
>
|
||||||
|
|
||||||
<!-- <:col :let={member} label="Id">{member.id}</:col> -->
|
<!-- <:col :let={member} label="Id">{member.id}</:col> -->
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
col_click={&MvWeb.MemberLive.Index.checkbox_column_click/1}
|
col_click={&MvWeb.MemberLive.Index.checkbox_column_click/1}
|
||||||
label={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
|
<.input
|
||||||
|
type="checkbox"
|
||||||
|
name="select_all"
|
||||||
|
phx-click="select_all"
|
||||||
|
checked={MapSet.equal?(@selected_members, @members |> Enum.map(& &1.id) |> MapSet.new())}
|
||||||
|
aria-label={gettext("Select all members")}
|
||||||
|
role="checkbox"
|
||||||
|
/>
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
>
|
||||||
<.input
|
<.input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
name="select_all"
|
name={member.id}
|
||||||
phx-click="select_all"
|
checked={MapSet.member?(@selected_members, member.id)}
|
||||||
checked={MapSet.equal?(@selected_members, @members |> Enum.map(& &1.id) |> MapSet.new())}
|
aria-label={gettext("Select member")}
|
||||||
aria-label={gettext("Select all members")}
|
|
||||||
role="checkbox"
|
role="checkbox"
|
||||||
/>
|
/>
|
||||||
"""
|
</:col>
|
||||||
}
|
<:col
|
||||||
>
|
:let={member}
|
||||||
<.input
|
:if={:first_name in @member_fields_visible}
|
||||||
type="checkbox"
|
label={
|
||||||
name={member.id}
|
~H"""
|
||||||
checked={MapSet.member?(@selected_members, member.id)}
|
<.live_component
|
||||||
aria-label={gettext("Select member")}
|
module={MvWeb.Components.SortHeaderComponent}
|
||||||
role="checkbox"
|
id={:sort_first_name}
|
||||||
/>
|
field={:first_name}
|
||||||
</:col>
|
label={gettext("First name")}
|
||||||
<:col
|
sort_field={@sort_field}
|
||||||
:let={member}
|
sort_order={@sort_order}
|
||||||
:if={:first_name in @member_fields_visible}
|
/>
|
||||||
label={
|
"""
|
||||||
~H"""
|
}
|
||||||
<.live_component
|
>
|
||||||
module={MvWeb.Components.SortHeaderComponent}
|
{member.first_name}
|
||||||
id={:sort_first_name}
|
</:col>
|
||||||
field={:first_name}
|
<:col
|
||||||
label={gettext("First name")}
|
:let={member}
|
||||||
sort_field={@sort_field}
|
:if={:last_name in @member_fields_visible}
|
||||||
sort_order={@sort_order}
|
label={
|
||||||
/>
|
~H"""
|
||||||
"""
|
<.live_component
|
||||||
}
|
module={MvWeb.Components.SortHeaderComponent}
|
||||||
>
|
id={:sort_last_name}
|
||||||
{member.first_name}
|
field={:last_name}
|
||||||
</:col>
|
label={gettext("Last name")}
|
||||||
<:col
|
sort_field={@sort_field}
|
||||||
:let={member}
|
sort_order={@sort_order}
|
||||||
:if={:last_name in @member_fields_visible}
|
/>
|
||||||
label={
|
"""
|
||||||
~H"""
|
}
|
||||||
<.live_component
|
>
|
||||||
module={MvWeb.Components.SortHeaderComponent}
|
{member.last_name}
|
||||||
id={:sort_last_name}
|
</:col>
|
||||||
field={:last_name}
|
<:col
|
||||||
label={gettext("Last name")}
|
:let={member}
|
||||||
sort_field={@sort_field}
|
:if={:email in @member_fields_visible}
|
||||||
sort_order={@sort_order}
|
label={
|
||||||
/>
|
~H"""
|
||||||
"""
|
<.live_component
|
||||||
}
|
module={MvWeb.Components.SortHeaderComponent}
|
||||||
>
|
id={:sort_email}
|
||||||
{member.last_name}
|
field={:email}
|
||||||
</:col>
|
label={gettext("Email")}
|
||||||
<:col
|
sort_field={@sort_field}
|
||||||
:let={member}
|
sort_order={@sort_order}
|
||||||
:if={:email in @member_fields_visible}
|
/>
|
||||||
label={
|
"""
|
||||||
~H"""
|
}
|
||||||
<.live_component
|
>
|
||||||
module={MvWeb.Components.SortHeaderComponent}
|
{member.email}
|
||||||
id={:sort_email}
|
</:col>
|
||||||
field={:email}
|
<:col
|
||||||
label={gettext("Email")}
|
:let={member}
|
||||||
sort_field={@sort_field}
|
:if={:join_date in @member_fields_visible}
|
||||||
sort_order={@sort_order}
|
label={
|
||||||
/>
|
~H"""
|
||||||
"""
|
<.live_component
|
||||||
}
|
module={MvWeb.Components.SortHeaderComponent}
|
||||||
>
|
id={:sort_join_date}
|
||||||
{member.email}
|
field={:join_date}
|
||||||
</:col>
|
label={gettext("Join Date")}
|
||||||
<:col
|
sort_field={@sort_field}
|
||||||
:let={member}
|
sort_order={@sort_order}
|
||||||
:if={:join_date in @member_fields_visible}
|
/>
|
||||||
label={
|
"""
|
||||||
~H"""
|
}
|
||||||
<.live_component
|
>
|
||||||
module={MvWeb.Components.SortHeaderComponent}
|
{MvWeb.MemberLive.Index.format_date(member.join_date)}
|
||||||
id={:sort_join_date}
|
</:col>
|
||||||
field={:join_date}
|
<:col
|
||||||
label={gettext("Join Date")}
|
:let={member}
|
||||||
sort_field={@sort_field}
|
:if={:exit_date in @member_fields_visible}
|
||||||
sort_order={@sort_order}
|
label={
|
||||||
/>
|
~H"""
|
||||||
"""
|
<.live_component
|
||||||
}
|
module={MvWeb.Components.SortHeaderComponent}
|
||||||
>
|
id={:sort_exit_date}
|
||||||
{MvWeb.MemberLive.Index.format_date(member.join_date)}
|
field={:exit_date}
|
||||||
</:col>
|
label={gettext("Exit Date")}
|
||||||
<:col
|
sort_field={@sort_field}
|
||||||
:let={member}
|
sort_order={@sort_order}
|
||||||
:if={:exit_date in @member_fields_visible}
|
/>
|
||||||
label={
|
"""
|
||||||
~H"""
|
}
|
||||||
<.live_component
|
>
|
||||||
module={MvWeb.Components.SortHeaderComponent}
|
{MvWeb.MemberLive.Index.format_date(member.exit_date)}
|
||||||
id={:sort_exit_date}
|
</:col>
|
||||||
field={:exit_date}
|
<:col
|
||||||
label={gettext("Exit Date")}
|
:let={member}
|
||||||
sort_field={@sort_field}
|
:if={:notes in @member_fields_visible}
|
||||||
sort_order={@sort_order}
|
label={gettext("Notes")}
|
||||||
/>
|
>
|
||||||
"""
|
{member.notes}
|
||||||
}
|
</:col>
|
||||||
>
|
<:col
|
||||||
{MvWeb.MemberLive.Index.format_date(member.exit_date)}
|
:let={member}
|
||||||
</:col>
|
:if={:country in @member_fields_visible}
|
||||||
<:col
|
label={
|
||||||
:let={member}
|
~H"""
|
||||||
:if={:notes in @member_fields_visible}
|
<.live_component
|
||||||
label={gettext("Notes")}
|
module={MvWeb.Components.SortHeaderComponent}
|
||||||
>
|
id={:sort_country}
|
||||||
{member.notes}
|
field={:country}
|
||||||
</:col>
|
label={gettext("Country")}
|
||||||
<:col
|
sort_field={@sort_field}
|
||||||
:let={member}
|
sort_order={@sort_order}
|
||||||
:if={:country in @member_fields_visible}
|
/>
|
||||||
label={
|
"""
|
||||||
~H"""
|
}
|
||||||
<.live_component
|
>
|
||||||
module={MvWeb.Components.SortHeaderComponent}
|
{member.country}
|
||||||
id={:sort_country}
|
</:col>
|
||||||
field={:country}
|
<:col
|
||||||
label={gettext("Country")}
|
:let={member}
|
||||||
sort_field={@sort_field}
|
:if={:city in @member_fields_visible}
|
||||||
sort_order={@sort_order}
|
label={
|
||||||
/>
|
~H"""
|
||||||
"""
|
<.live_component
|
||||||
}
|
module={MvWeb.Components.SortHeaderComponent}
|
||||||
>
|
id={:sort_city}
|
||||||
{member.country}
|
field={:city}
|
||||||
</:col>
|
label={gettext("City")}
|
||||||
<:col
|
sort_field={@sort_field}
|
||||||
:let={member}
|
sort_order={@sort_order}
|
||||||
:if={:city in @member_fields_visible}
|
/>
|
||||||
label={
|
"""
|
||||||
~H"""
|
}
|
||||||
<.live_component
|
>
|
||||||
module={MvWeb.Components.SortHeaderComponent}
|
{member.city}
|
||||||
id={:sort_city}
|
</:col>
|
||||||
field={:city}
|
<:col
|
||||||
label={gettext("City")}
|
:let={member}
|
||||||
sort_field={@sort_field}
|
:if={:street in @member_fields_visible}
|
||||||
sort_order={@sort_order}
|
label={
|
||||||
/>
|
~H"""
|
||||||
"""
|
<.live_component
|
||||||
}
|
module={MvWeb.Components.SortHeaderComponent}
|
||||||
>
|
id={:sort_street}
|
||||||
{member.city}
|
field={:street}
|
||||||
</:col>
|
label={gettext("Street")}
|
||||||
<:col
|
sort_field={@sort_field}
|
||||||
:let={member}
|
sort_order={@sort_order}
|
||||||
:if={:street in @member_fields_visible}
|
/>
|
||||||
label={
|
"""
|
||||||
~H"""
|
}
|
||||||
<.live_component
|
>
|
||||||
module={MvWeb.Components.SortHeaderComponent}
|
{member.street}
|
||||||
id={:sort_street}
|
</:col>
|
||||||
field={:street}
|
<:col
|
||||||
label={gettext("Street")}
|
:let={member}
|
||||||
sort_field={@sort_field}
|
:if={:house_number in @member_fields_visible}
|
||||||
sort_order={@sort_order}
|
label={
|
||||||
/>
|
~H"""
|
||||||
"""
|
<.live_component
|
||||||
}
|
module={MvWeb.Components.SortHeaderComponent}
|
||||||
>
|
id={:sort_house_number}
|
||||||
{member.street}
|
field={:house_number}
|
||||||
</:col>
|
label={gettext("House Number")}
|
||||||
<:col
|
sort_field={@sort_field}
|
||||||
:let={member}
|
sort_order={@sort_order}
|
||||||
:if={:house_number in @member_fields_visible}
|
/>
|
||||||
label={
|
"""
|
||||||
~H"""
|
}
|
||||||
<.live_component
|
>
|
||||||
module={MvWeb.Components.SortHeaderComponent}
|
{member.house_number}
|
||||||
id={:sort_house_number}
|
</:col>
|
||||||
field={:house_number}
|
<:col
|
||||||
label={gettext("House Number")}
|
:let={member}
|
||||||
sort_field={@sort_field}
|
:if={:postal_code in @member_fields_visible}
|
||||||
sort_order={@sort_order}
|
label={
|
||||||
/>
|
~H"""
|
||||||
"""
|
<.live_component
|
||||||
}
|
module={MvWeb.Components.SortHeaderComponent}
|
||||||
>
|
id={:sort_postal_code}
|
||||||
{member.house_number}
|
field={:postal_code}
|
||||||
</:col>
|
label={gettext("Postal Code")}
|
||||||
<:col
|
sort_field={@sort_field}
|
||||||
:let={member}
|
sort_order={@sort_order}
|
||||||
:if={:postal_code in @member_fields_visible}
|
/>
|
||||||
label={
|
"""
|
||||||
~H"""
|
}
|
||||||
<.live_component
|
>
|
||||||
module={MvWeb.Components.SortHeaderComponent}
|
{member.postal_code}
|
||||||
id={:sort_postal_code}
|
</:col>
|
||||||
field={:postal_code}
|
<:col
|
||||||
label={gettext("Postal Code")}
|
:let={member}
|
||||||
sort_field={@sort_field}
|
:if={:membership_fee_start_date in @member_fields_visible}
|
||||||
sort_order={@sort_order}
|
label={
|
||||||
/>
|
~H"""
|
||||||
"""
|
<.live_component
|
||||||
}
|
module={MvWeb.Components.SortHeaderComponent}
|
||||||
>
|
id={:sort_membership_fee_start_date}
|
||||||
{member.postal_code}
|
field={:membership_fee_start_date}
|
||||||
</:col>
|
label={gettext("Membership Fee Start Date")}
|
||||||
<:col
|
sort_field={@sort_field}
|
||||||
:let={member}
|
sort_order={@sort_order}
|
||||||
:if={:membership_fee_start_date in @member_fields_visible}
|
/>
|
||||||
label={
|
"""
|
||||||
~H"""
|
}
|
||||||
<.live_component
|
>
|
||||||
module={MvWeb.Components.SortHeaderComponent}
|
{MvWeb.MemberLive.Index.format_date(member.membership_fee_start_date)}
|
||||||
id={:sort_membership_fee_start_date}
|
</:col>
|
||||||
field={:membership_fee_start_date}
|
<:col
|
||||||
label={gettext("Membership Fee Start Date")}
|
:let={member}
|
||||||
sort_field={@sort_field}
|
:if={:membership_fee_type in @member_fields_visible}
|
||||||
sort_order={@sort_order}
|
label={
|
||||||
/>
|
~H"""
|
||||||
"""
|
<.live_component
|
||||||
}
|
module={MvWeb.Components.SortHeaderComponent}
|
||||||
>
|
id={:sort_membership_fee_type}
|
||||||
{MvWeb.MemberLive.Index.format_date(member.membership_fee_start_date)}
|
field={:membership_fee_type}
|
||||||
</:col>
|
label={gettext("Fee Type")}
|
||||||
<:col
|
sort_field={@sort_field}
|
||||||
:let={member}
|
sort_order={@sort_order}
|
||||||
:if={:membership_fee_type in @member_fields_visible}
|
/>
|
||||||
label={
|
"""
|
||||||
~H"""
|
}
|
||||||
<.live_component
|
>
|
||||||
module={MvWeb.Components.SortHeaderComponent}
|
<%= if member.membership_fee_type do %>
|
||||||
id={:sort_membership_fee_type}
|
{member.membership_fee_type.name}
|
||||||
field={:membership_fee_type}
|
<% else %>
|
||||||
label={gettext("Fee Type")}
|
<span class="text-base-content/50">—</span>
|
||||||
sort_field={@sort_field}
|
<% end %>
|
||||||
sort_order={@sort_order}
|
</:col>
|
||||||
/>
|
<:col
|
||||||
"""
|
:let={member}
|
||||||
}
|
:if={:membership_fee_status in @member_fields_visible}
|
||||||
>
|
label={gettext("Membership Fee Status")}
|
||||||
<%= if member.membership_fee_type do %>
|
>
|
||||||
{member.membership_fee_type.name}
|
<%= if badge = MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge(
|
||||||
<% else %>
|
|
||||||
<span class="text-base-content/50">—</span>
|
|
||||||
<% end %>
|
|
||||||
</:col>
|
|
||||||
<:col
|
|
||||||
:let={member}
|
|
||||||
:if={:membership_fee_status in @member_fields_visible}
|
|
||||||
label={gettext("Membership Fee Status")}
|
|
||||||
>
|
|
||||||
<%= if badge = MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge(
|
|
||||||
MvWeb.MemberLive.Index.MembershipFeeStatus.get_cycle_status_for_member(member, @show_current_cycle)
|
MvWeb.MemberLive.Index.MembershipFeeStatus.get_cycle_status_for_member(member, @show_current_cycle)
|
||||||
) do %>
|
) do %>
|
||||||
<span class={["badge", badge.color]}>
|
<span class={["badge", badge.color]}>
|
||||||
<.icon name={badge.icon} class="size-4" />
|
<.icon name={badge.icon} class="size-4" />
|
||||||
{badge.label}
|
{badge.label}
|
||||||
</span>
|
</span>
|
||||||
<% else %>
|
<% else %>
|
||||||
<span class="badge badge-ghost">{gettext("No cycle")}</span>
|
<span class="badge badge-ghost">{gettext("No cycle")}</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</:col>
|
</:col>
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:groups in @member_fields_visible}
|
:if={:groups in @member_fields_visible}
|
||||||
label={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
<.live_component
|
<.live_component
|
||||||
module={MvWeb.Components.SortHeaderComponent}
|
module={MvWeb.Components.SortHeaderComponent}
|
||||||
id={:sort_groups}
|
id={:sort_groups}
|
||||||
field={:groups}
|
field={:groups}
|
||||||
label={gettext("Groups")}
|
label={gettext("Groups")}
|
||||||
sort_field={@sort_field}
|
sort_field={@sort_field}
|
||||||
sort_order={@sort_order}
|
sort_order={@sort_order}
|
||||||
/>
|
/>
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<%= for group <- (member.groups || []) do %>
|
<%= for group <- (member.groups || []) do %>
|
||||||
<span
|
<span
|
||||||
class="badge badge-outline badge-primary"
|
class="badge badge-outline badge-primary"
|
||||||
aria-label={gettext("Member of group %{name}", name: group.name)}
|
aria-label={gettext("Member of group %{name}", name: group.name)}
|
||||||
>
|
>
|
||||||
{group.name}
|
{group.name}
|
||||||
</span>
|
</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
<%= if (member.groups || []) == [] do %>
|
<%= if (member.groups || []) == [] do %>
|
||||||
<span class="text-base-content/50">—</span>
|
<span class="text-base-content/50">—</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</:col>
|
</:col>
|
||||||
<:action :let={member}>
|
<:action :let={member}>
|
||||||
<div class="sr-only">
|
<div class="sr-only">
|
||||||
<.link navigate={~p"/members/#{member}"}>{gettext("Show")}</.link>
|
<.link navigate={~p"/members/#{member}"} data-testid="member-show-link">
|
||||||
</div>
|
{gettext("Show")}
|
||||||
|
</.link>
|
||||||
<%= if can?(@current_user, :update, member) do %>
|
</div>
|
||||||
<.link navigate={~p"/members/#{member}/edit"} data-testid="member-edit">
|
</:action>
|
||||||
{gettext("Edit")}
|
</.table>
|
||||||
</.link>
|
</div>
|
||||||
<% end %>
|
|
||||||
</:action>
|
|
||||||
|
|
||||||
<:action :let={member}>
|
|
||||||
<%= if can?(@current_user, :destroy, member) do %>
|
|
||||||
<.link
|
|
||||||
phx-click={JS.push("delete", value: %{id: member.id}) |> hide("#row-#{member.id}")}
|
|
||||||
data-confirm={gettext("Are you sure?")}
|
|
||||||
data-testid="member-delete"
|
|
||||||
>
|
|
||||||
{gettext("Delete")}
|
|
||||||
</.link>
|
|
||||||
<% end %>
|
|
||||||
</:action>
|
|
||||||
</.table>
|
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
|
|
|
||||||
|
|
@ -30,235 +30,311 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||||
<%!-- Header with Back button, Name, and Edit button --%>
|
<.header>
|
||||||
<div class="flex items-center justify-between gap-4 pb-4">
|
<:leading>
|
||||||
<.button navigate={~p"/members"} aria-label={gettext("Back to members list")}>
|
|
||||||
<.icon name="hero-arrow-left" class="size-4" />
|
|
||||||
{gettext("Back")}
|
|
||||||
</.button>
|
|
||||||
|
|
||||||
<h1 class="text-2xl font-bold text-center flex-1">
|
|
||||||
{MvWeb.Helpers.MemberHelpers.display_name(@member)}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<%= if can?(@current_user, :update, @member) do %>
|
|
||||||
<.button
|
<.button
|
||||||
variant="primary"
|
navigate={~p"/members?highlight=#{@member.id}"}
|
||||||
navigate={~p"/members/#{@member}/edit?return_to=show"}
|
variant="neutral"
|
||||||
data-testid="member-edit"
|
aria-label={gettext("Back to members list")}
|
||||||
>
|
>
|
||||||
{gettext("Edit Member")}
|
<.icon name="hero-arrow-left" class="size-4" />
|
||||||
|
{gettext("Back")}
|
||||||
</.button>
|
</.button>
|
||||||
<% end %>
|
</:leading>
|
||||||
</div>
|
{MvWeb.Helpers.MemberHelpers.display_name(@member)}
|
||||||
|
<:actions>
|
||||||
|
<%= if can?(@current_user, :update, @member) do %>
|
||||||
|
<.button
|
||||||
|
variant="primary"
|
||||||
|
navigate={~p"/members/#{@member}/edit?return_to=show"}
|
||||||
|
data-testid="member-edit"
|
||||||
|
>
|
||||||
|
<.icon name="hero-pencil-square" /> {gettext("Edit member")}
|
||||||
|
</.button>
|
||||||
|
<% end %>
|
||||||
|
</:actions>
|
||||||
|
</.header>
|
||||||
|
|
||||||
<%!-- Tab Navigation --%>
|
<div class="mt-6 space-y-6">
|
||||||
<div role="tablist" class="tabs tabs-bordered mb-6">
|
<%!-- Tab Navigation: surface only behind buttons (inline); tabs-bordered; tabs-lg; both tabs keyboard-focusable --%>
|
||||||
<button
|
<div
|
||||||
role="tab"
|
role="tablist"
|
||||||
class={[
|
class="tabs tabs-bordered tabs-lg bg-base-200/60 rounded-box p-1 w-fit"
|
||||||
"tab",
|
|
||||||
if(@active_tab == :contact, do: "tab-active", else: "!text-gray-800")
|
|
||||||
]}
|
|
||||||
aria-selected={@active_tab == :contact}
|
|
||||||
phx-click="switch_tab"
|
|
||||||
phx-value-tab="contact"
|
|
||||||
>
|
>
|
||||||
<.icon name="hero-identification" class="size-4 mr-2" />
|
<button
|
||||||
{gettext("Contact Data")}
|
id="member-tab-contact"
|
||||||
</button>
|
role="tab"
|
||||||
<button
|
type="button"
|
||||||
role="tab"
|
tabindex="0"
|
||||||
class={[
|
aria-selected={@active_tab == :contact}
|
||||||
"tab",
|
aria-controls="member-tabpanel-contact"
|
||||||
if(@active_tab == :membership_fees, do: "tab-active", else: "!text-gray-800")
|
class={[
|
||||||
]}
|
"tab flex items-center gap-2",
|
||||||
aria-selected={@active_tab == :membership_fees}
|
if(@active_tab == :contact, do: "tab-active", else: "text-base-content/70")
|
||||||
phx-click="switch_tab"
|
]}
|
||||||
phx-value-tab="membership_fees"
|
phx-click="switch_tab"
|
||||||
>
|
phx-value-tab="contact"
|
||||||
<.icon name="hero-credit-card" class="size-4 mr-2" />
|
>
|
||||||
{gettext("Membership Fees")}
|
<.icon name="hero-identification" class="size-4 shrink-0" />
|
||||||
</button>
|
{gettext("Contact Data")}
|
||||||
</div>
|
</button>
|
||||||
|
<button
|
||||||
|
id="member-tab-membership_fees"
|
||||||
|
role="tab"
|
||||||
|
type="button"
|
||||||
|
tabindex="0"
|
||||||
|
aria-selected={@active_tab == :membership_fees}
|
||||||
|
aria-controls="member-tabpanel-membership_fees"
|
||||||
|
class={[
|
||||||
|
"tab flex items-center gap-2",
|
||||||
|
if(@active_tab == :membership_fees, do: "tab-active", else: "text-base-content/70")
|
||||||
|
]}
|
||||||
|
phx-click="switch_tab"
|
||||||
|
phx-value-tab="membership_fees"
|
||||||
|
>
|
||||||
|
<.icon name="hero-credit-card" class="size-4 shrink-0" />
|
||||||
|
{gettext("Membership Fees")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<%= if @active_tab == :contact do %>
|
<%= if @active_tab == :contact do %>
|
||||||
<%!-- Contact Data Tab Content --%>
|
<%!-- Contact Data Tab Content --%>
|
||||||
<%!-- Personal Data and Custom Fields Row --%>
|
<div
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
id="member-tabpanel-contact"
|
||||||
<%!-- Personal Data Section --%>
|
role="tabpanel"
|
||||||
<div>
|
aria-labelledby="member-tab-contact"
|
||||||
<.section_box title={gettext("Personal Data")}>
|
>
|
||||||
<div class="space-y-4">
|
<%!-- Personal Data and Custom Fields Row --%>
|
||||||
<%!-- Name Row --%>
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||||
<div class="flex gap-6">
|
<%!-- Personal Data Section --%>
|
||||||
<.data_field label={gettext("First Name")} value={@member.first_name} class="w-48" />
|
<div>
|
||||||
<.data_field label={gettext("Last Name")} value={@member.last_name} class="w-48" />
|
<.section_box title={gettext("Personal Data")}>
|
||||||
</div>
|
<div class="space-y-4">
|
||||||
|
<%!-- Name Row --%>
|
||||||
|
<div class="flex gap-6">
|
||||||
|
<.data_field
|
||||||
|
label={gettext("First Name")}
|
||||||
|
value={@member.first_name}
|
||||||
|
class="w-48"
|
||||||
|
/>
|
||||||
|
<.data_field
|
||||||
|
label={gettext("Last Name")}
|
||||||
|
value={@member.last_name}
|
||||||
|
class="w-48"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<%!-- Address --%>
|
<%!-- Address --%>
|
||||||
<div>
|
<div>
|
||||||
<.data_field label={gettext("Address")} value={format_address(@member)} />
|
<.data_field label={gettext("Address")} value={format_address(@member)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%!-- Email --%>
|
<%!-- Email --%>
|
||||||
<div>
|
<div>
|
||||||
<.data_field label={gettext("Email")}>
|
<.data_field label={gettext("Email")}>
|
||||||
<a
|
<a
|
||||||
href={"mailto:#{MvWeb.MemberLive.Index.format_member_email(@member)}"}
|
href={"mailto:#{MvWeb.MemberLive.Index.format_member_email(@member)}"}
|
||||||
class="text-blue-700 hover:text-blue-800 underline"
|
class="text-blue-700 hover:text-blue-800 underline"
|
||||||
>
|
>
|
||||||
{@member.email}
|
{@member.email}
|
||||||
</a>
|
</a>
|
||||||
</.data_field>
|
</.data_field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%!-- Membership Dates Row --%>
|
<%!-- Membership Dates Row --%>
|
||||||
<div class="flex gap-6">
|
<div class="flex gap-6">
|
||||||
<.data_field
|
<.data_field
|
||||||
label={gettext("Join Date")}
|
label={gettext("Join Date")}
|
||||||
value={format_date(@member.join_date)}
|
value={format_date(@member.join_date)}
|
||||||
class="w-28"
|
class="w-28"
|
||||||
/>
|
/>
|
||||||
<.data_field
|
<.data_field
|
||||||
label={gettext("Exit Date")}
|
label={gettext("Exit Date")}
|
||||||
value={format_date(@member.exit_date)}
|
value={format_date(@member.exit_date)}
|
||||||
class="w-28"
|
class="w-28"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%!-- Linked User: only show when current user can see other users (e.g. admin).
|
<%!-- Linked User: only show when current user can see other users (e.g. admin).
|
||||||
read_only cannot see linked user, so hide the section to avoid "No user linked" when
|
read_only cannot see linked user, so hide the section to avoid "No user linked" when
|
||||||
a user is linked but not visible. --%>
|
a user is linked but not visible. --%>
|
||||||
<%= if can_access_page?(@current_user, "/users") do %>
|
<%= if can_access_page?(@current_user, "/users") do %>
|
||||||
<div>
|
<div>
|
||||||
<.data_field label={gettext("Linked User")}>
|
<.data_field label={gettext("Linked User")}>
|
||||||
<%= if @member.user do %>
|
<%= if @member.user do %>
|
||||||
<.link
|
<.link
|
||||||
navigate={~p"/users/#{@member.user}"}
|
navigate={~p"/users/#{@member.user}"}
|
||||||
class="text-blue-700 hover:text-blue-800 underline inline-flex items-center gap-1"
|
class="text-blue-700 hover:text-blue-800 underline inline-flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<.icon name="hero-user" class="size-4" />
|
<.icon name="hero-user" class="size-4" />
|
||||||
{@member.user.email}
|
{@member.user.email}
|
||||||
</.link>
|
</.link>
|
||||||
|
<% else %>
|
||||||
|
<span class="text-base-content/70 italic">
|
||||||
|
{gettext("No user linked")}
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
</.data_field>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%!-- Groups (in Personal Data) --%>
|
||||||
|
<% groups = @member.groups || [] %>
|
||||||
|
<div>
|
||||||
|
<.data_field label={gettext("Groups")}>
|
||||||
|
<%= if Enum.empty?(groups) do %>
|
||||||
|
<span class="text-base-content/70 italic">{gettext("No groups")}</span>
|
||||||
|
<% else %>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<%= for group <- groups do %>
|
||||||
|
<.button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
navigate={~p"/groups/#{group.slug}"}
|
||||||
|
aria-label={gettext("Member of group %{name}", name: group.name)}
|
||||||
|
>
|
||||||
|
{group.name}
|
||||||
|
</.button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</.data_field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%!-- Notes --%>
|
||||||
|
<%= if @member.notes && String.trim(@member.notes) != "" do %>
|
||||||
|
<div>
|
||||||
|
<.data_field label={gettext("Notes")}>
|
||||||
|
<p class="whitespace-pre-wrap text-base-content/80">{@member.notes}</p>
|
||||||
|
</.data_field>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</.section_box>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%!-- Custom Fields Section --%>
|
||||||
|
<%= if Enum.any?(@custom_fields) do %>
|
||||||
|
<div>
|
||||||
|
<.section_box title={gettext("Custom Fields")}>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<%= for custom_field <- @custom_fields do %>
|
||||||
|
<% cfv = find_custom_field_value(@member.custom_field_values, custom_field.id) %>
|
||||||
|
<.data_field label={custom_field.name}>
|
||||||
|
{format_custom_field_value(cfv, custom_field.value_type)}
|
||||||
|
</.data_field>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</.section_box>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%!-- Payment Data Section --%>
|
||||||
|
<div class="w-full">
|
||||||
|
<.section_box title={gettext("Payment Data")}>
|
||||||
|
<%= if @member.membership_fee_type do %>
|
||||||
|
<div class="flex gap-6 flex-wrap">
|
||||||
|
<.data_field
|
||||||
|
label={gettext("Type")}
|
||||||
|
value={@member.membership_fee_type.name}
|
||||||
|
class="min-w-32"
|
||||||
|
/>
|
||||||
|
<.data_field
|
||||||
|
label={gettext("Membership Fee")}
|
||||||
|
value={MembershipFeeHelpers.format_currency(@member.membership_fee_type.amount)}
|
||||||
|
class="min-w-24"
|
||||||
|
/>
|
||||||
|
<.data_field
|
||||||
|
label={gettext("Payment Interval")}
|
||||||
|
value={
|
||||||
|
MembershipFeeHelpers.format_interval(@member.membership_fee_type.interval)
|
||||||
|
}
|
||||||
|
class="min-w-32"
|
||||||
|
/>
|
||||||
|
<.data_field label={gettext("Last Cycle")} class="min-w-32">
|
||||||
|
<%= if @member.last_cycle_status do %>
|
||||||
|
<% status = @member.last_cycle_status %>
|
||||||
|
<span class={["badge", MembershipFeeHelpers.status_color(status)]}>
|
||||||
|
{format_status_label(status)}
|
||||||
|
</span>
|
||||||
<% else %>
|
<% else %>
|
||||||
<span class="text-base-content/70 italic">{gettext("No user linked")}</span>
|
<span class="badge badge-ghost">{gettext("No cycles")}</span>
|
||||||
|
<% end %>
|
||||||
|
</.data_field>
|
||||||
|
<.data_field label={gettext("Current Cycle")} class="min-w-36">
|
||||||
|
<%= if @member.current_cycle_status do %>
|
||||||
|
<% status = @member.current_cycle_status %>
|
||||||
|
<span class={["badge", MembershipFeeHelpers.status_color(status)]}>
|
||||||
|
{format_status_label(status)}
|
||||||
|
</span>
|
||||||
|
<% else %>
|
||||||
|
<span class="badge badge-ghost">{gettext("No cycles")}</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</.data_field>
|
</.data_field>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% else %>
|
||||||
|
<div class="text-base-content/70 italic">
|
||||||
<%!-- Groups (in Personal Data) --%>
|
{gettext("No membership fee type assigned")}
|
||||||
<% groups = @member.groups || [] %>
|
|
||||||
<div>
|
|
||||||
<.data_field label={gettext("Groups")}>
|
|
||||||
<%= if Enum.empty?(groups) do %>
|
|
||||||
<span class="text-base-content/70 italic">{gettext("No groups")}</span>
|
|
||||||
<% else %>
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<%= for group <- groups do %>
|
|
||||||
<.link
|
|
||||||
navigate={~p"/groups/#{group.slug}"}
|
|
||||||
class="btn btn-xs btn-outline btn-primary"
|
|
||||||
aria-label={gettext("Member of group %{name}", name: group.name)}
|
|
||||||
>
|
|
||||||
{group.name}
|
|
||||||
</.link>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</.data_field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%!-- Notes --%>
|
|
||||||
<%= if @member.notes && String.trim(@member.notes) != "" do %>
|
|
||||||
<div>
|
|
||||||
<.data_field label={gettext("Notes")}>
|
|
||||||
<p class="whitespace-pre-wrap text-base-content/80">{@member.notes}</p>
|
|
||||||
</.data_field>
|
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
|
||||||
</.section_box>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%!-- Custom Fields Section --%>
|
|
||||||
<%= if Enum.any?(@custom_fields) do %>
|
|
||||||
<div>
|
|
||||||
<.section_box title={gettext("Custom Fields")}>
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<%= for custom_field <- @custom_fields do %>
|
|
||||||
<% cfv = find_custom_field_value(@member.custom_field_values, custom_field.id) %>
|
|
||||||
<.data_field label={custom_field.name}>
|
|
||||||
{format_custom_field_value(cfv, custom_field.value_type)}
|
|
||||||
</.data_field>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</.section_box>
|
</.section_box>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
</div>
|
||||||
</div>
|
<% end %>
|
||||||
|
|
||||||
<%!-- Payment Data Section --%>
|
<%= if @active_tab == :membership_fees do %>
|
||||||
<div class="w-full">
|
<%!-- Membership Fees Tab Content --%>
|
||||||
<.section_box title={gettext("Payment Data")}>
|
<div
|
||||||
<%= if @member.membership_fee_type do %>
|
id="member-tabpanel-membership_fees"
|
||||||
<div class="flex gap-6 flex-wrap">
|
role="tabpanel"
|
||||||
<.data_field
|
aria-labelledby="member-tab-membership_fees"
|
||||||
label={gettext("Type")}
|
>
|
||||||
value={@member.membership_fee_type.name}
|
<.live_component
|
||||||
class="min-w-32"
|
module={MvWeb.MemberLive.Show.MembershipFeesComponent}
|
||||||
/>
|
id={"membership-fees-#{@member.id}"}
|
||||||
<.data_field
|
member={@member}
|
||||||
label={gettext("Membership Fee")}
|
current_user={@current_user}
|
||||||
value={MembershipFeeHelpers.format_currency(@member.membership_fee_type.amount)}
|
vereinfacht_receipts={@vereinfacht_receipts}
|
||||||
class="min-w-24"
|
/>
|
||||||
/>
|
</div>
|
||||||
<.data_field
|
<% end %>
|
||||||
label={gettext("Payment Interval")}
|
|
||||||
value={MembershipFeeHelpers.format_interval(@member.membership_fee_type.interval)}
|
|
||||||
class="min-w-32"
|
|
||||||
/>
|
|
||||||
<.data_field label={gettext("Last Cycle")} class="min-w-32">
|
|
||||||
<%= if @member.last_cycle_status do %>
|
|
||||||
<% status = @member.last_cycle_status %>
|
|
||||||
<span class={["badge", MembershipFeeHelpers.status_color(status)]}>
|
|
||||||
{format_status_label(status)}
|
|
||||||
</span>
|
|
||||||
<% else %>
|
|
||||||
<span class="badge badge-ghost">{gettext("No cycles")}</span>
|
|
||||||
<% end %>
|
|
||||||
</.data_field>
|
|
||||||
<.data_field label={gettext("Current Cycle")} class="min-w-36">
|
|
||||||
<%= if @member.current_cycle_status do %>
|
|
||||||
<% status = @member.current_cycle_status %>
|
|
||||||
<span class={["badge", MembershipFeeHelpers.status_color(status)]}>
|
|
||||||
{format_status_label(status)}
|
|
||||||
</span>
|
|
||||||
<% else %>
|
|
||||||
<span class="badge badge-ghost">{gettext("No cycles")}</span>
|
|
||||||
<% end %>
|
|
||||||
</.data_field>
|
|
||||||
</div>
|
|
||||||
<% else %>
|
|
||||||
<div class="text-base-content/70 italic">
|
|
||||||
{gettext("No membership fee type assigned")}
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</.section_box>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<%= if @active_tab == :membership_fees do %>
|
<%!-- Danger zone: same section pattern as section_box (h2 outside border) --%>
|
||||||
<%!-- Membership Fees Tab Content --%>
|
<%= if can?(@current_user, :destroy, @member) do %>
|
||||||
<.live_component
|
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
|
||||||
module={MvWeb.MemberLive.Show.MembershipFeesComponent}
|
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
|
||||||
id={"membership-fees-#{@member.id}"}
|
{gettext("Danger zone")}
|
||||||
member={@member}
|
</h2>
|
||||||
current_user={@current_user}
|
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
||||||
vereinfacht_receipts={@vereinfacht_receipts}
|
<p class="text-base-content/70 mb-4">
|
||||||
/>
|
{gettext(
|
||||||
<% end %>
|
"Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<.button
|
||||||
|
variant="danger"
|
||||||
|
phx-click="delete"
|
||||||
|
phx-value-id={@member.id}
|
||||||
|
data-confirm={
|
||||||
|
gettext("Are you sure you want to delete %{name}? This action cannot be undone.",
|
||||||
|
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
data-testid="member-delete"
|
||||||
|
aria-label={
|
||||||
|
gettext("Delete member %{name}",
|
||||||
|
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<.icon name="hero-trash" class="size-4" />
|
||||||
|
{gettext("Delete member")}
|
||||||
|
</.button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
@ -320,6 +396,37 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
{:noreply, assign(socket, :active_tab, :membership_fees)}
|
{:noreply, assign(socket, :active_tab, :membership_fees)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("delete", %{"id" => id}, socket) do
|
||||||
|
member = socket.assigns.member
|
||||||
|
actor = current_actor(socket)
|
||||||
|
|
||||||
|
if to_string(id) != to_string(member.id) do
|
||||||
|
{:noreply, put_flash(socket, :error, gettext("Member not found"))}
|
||||||
|
else
|
||||||
|
case Ash.destroy(member, actor: actor) do
|
||||||
|
:ok ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:success, gettext("Member deleted successfully"))
|
||||||
|
|> push_navigate(to: ~p"/members")}
|
||||||
|
|
||||||
|
{:error, %Ash.Error.Forbidden{}} ->
|
||||||
|
{:noreply,
|
||||||
|
put_flash(
|
||||||
|
socket,
|
||||||
|
:error,
|
||||||
|
gettext("You do not have permission to delete this member")
|
||||||
|
)}
|
||||||
|
|
||||||
|
{:error, error} ->
|
||||||
|
require Logger
|
||||||
|
Logger.warning("Member delete failed: member_id=#{member.id} error=#{inspect(error)}")
|
||||||
|
{:noreply, put_flash(socket, :error, format_error(error))}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def handle_event("load_vereinfacht_receipts", %{"contact_id" => contact_id}, socket) do
|
def handle_event("load_vereinfacht_receipts", %{"contact_id" => contact_id}, socket) do
|
||||||
response =
|
response =
|
||||||
case Mv.Vereinfacht.Client.get_contact_with_receipts(contact_id) do
|
case Mv.Vereinfacht.Client.get_contact_with_receipts(contact_id) do
|
||||||
|
|
@ -350,6 +457,19 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
defp page_title(:show), do: gettext("Show Member")
|
defp page_title(:show), do: gettext("Show Member")
|
||||||
defp page_title(:edit), do: gettext("Edit Member")
|
defp page_title(:edit), do: gettext("Edit Member")
|
||||||
|
|
||||||
|
defp format_error(%Ash.Error.Invalid{errors: errors}) do
|
||||||
|
error_messages =
|
||||||
|
Enum.map(errors, fn
|
||||||
|
%{field: field, message: message} -> "#{field}: #{message}"
|
||||||
|
%{message: message} -> message
|
||||||
|
_ -> inspect(errors)
|
||||||
|
end)
|
||||||
|
|
||||||
|
Enum.join(error_messages, ", ")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_error(error), do: inspect(error)
|
||||||
|
|
||||||
# -----------------------------------------------------------------
|
# -----------------------------------------------------------------
|
||||||
# Helper Components
|
# Helper Components
|
||||||
# -----------------------------------------------------------------
|
# -----------------------------------------------------------------
|
||||||
|
|
@ -403,7 +523,7 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
~H"""
|
~H"""
|
||||||
<a
|
<a
|
||||||
href={"mailto:#{@email}"}
|
href={"mailto:#{@email}"}
|
||||||
class="text-blue-700 hover:text-blue-800 underline"
|
class="link link-primary"
|
||||||
>
|
>
|
||||||
{@display}
|
{@display}
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -66,14 +66,15 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
<.icon name="hero-arrow-top-right-on-square" class="inline-block size-4" />
|
<.icon name="hero-arrow-top-right-on-square" class="inline-block size-4" />
|
||||||
</.link>
|
</.link>
|
||||||
<div>
|
<div>
|
||||||
<button
|
<.button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
phx-click="load_vereinfacht_receipts"
|
phx-click="load_vereinfacht_receipts"
|
||||||
phx-value-contact_id={@member.vereinfacht_contact_id}
|
phx-value-contact_id={@member.vereinfacht_contact_id}
|
||||||
class="btn btn-sm btn-ghost"
|
|
||||||
>
|
>
|
||||||
{gettext("Show bookings/receipts from Vereinfacht")}
|
{gettext("Show bookings/receipts from Vereinfacht")}
|
||||||
</button>
|
</.button>
|
||||||
</div>
|
</div>
|
||||||
<%= if @vereinfacht_receipts do %>
|
<%= if @vereinfacht_receipts do %>
|
||||||
<div
|
<div
|
||||||
|
|
@ -148,9 +149,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
</.button>
|
</.button>
|
||||||
<.button
|
<.button
|
||||||
:if={Enum.any?(@cycles) and @can_destroy_cycle}
|
:if={Enum.any?(@cycles) and @can_destroy_cycle}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
phx-click="delete_all_cycles"
|
phx-click="delete_all_cycles"
|
||||||
phx-target={@myself}
|
phx-target={@myself}
|
||||||
class="btn btn-sm btn-error btn-outline"
|
|
||||||
title={gettext("Delete all cycles")}
|
title={gettext("Delete all cycles")}
|
||||||
>
|
>
|
||||||
<.icon name="hero-trash" class="size-4" />
|
<.icon name="hero-trash" class="size-4" />
|
||||||
|
|
@ -158,9 +160,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
</.button>
|
</.button>
|
||||||
<.button
|
<.button
|
||||||
:if={@member.membership_fee_type != nil and @can_create_cycle}
|
:if={@member.membership_fee_type != nil and @can_create_cycle}
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
phx-click="open_create_cycle_modal"
|
phx-click="open_create_cycle_modal"
|
||||||
phx-target={@myself}
|
phx-target={@myself}
|
||||||
class="btn btn-sm btn-primary"
|
|
||||||
title={gettext("Create a new cycle manually")}
|
title={gettext("Create a new cycle manually")}
|
||||||
>
|
>
|
||||||
<.icon name="hero-plus" class="size-4" />
|
<.icon name="hero-plus" class="size-4" />
|
||||||
|
|
@ -259,17 +262,18 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<%= if @can_destroy_cycle do %>
|
<%= if @can_destroy_cycle do %>
|
||||||
<button
|
<.button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
phx-click="delete_cycle"
|
phx-click="delete_cycle"
|
||||||
phx-value-cycle_id={cycle.id}
|
phx-value-cycle_id={cycle.id}
|
||||||
phx-target={@myself}
|
phx-target={@myself}
|
||||||
class="btn btn-sm btn-error btn-outline"
|
|
||||||
title={gettext("Delete cycle")}
|
title={gettext("Delete cycle")}
|
||||||
>
|
>
|
||||||
<.icon name="hero-trash" class="size-4" />
|
<.icon name="hero-trash" class="size-4" />
|
||||||
{gettext("Delete")}
|
{gettext("Delete")}
|
||||||
</button>
|
</.button>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</:action>
|
</:action>
|
||||||
|
|
@ -309,10 +313,15 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-action">
|
<div class="modal-action">
|
||||||
<button type="button" phx-click="cancel_edit_amount" phx-target={@myself} class="btn">
|
<.button
|
||||||
|
type="button"
|
||||||
|
variant="neutral"
|
||||||
|
phx-click="cancel_edit_amount"
|
||||||
|
phx-target={@myself}
|
||||||
|
>
|
||||||
{gettext("Cancel")}
|
{gettext("Cancel")}
|
||||||
</button>
|
</.button>
|
||||||
<button type="submit" class="btn btn-primary">{gettext("Save")}</button>
|
<.button type="submit" variant="primary">{gettext("Save")}</.button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -334,17 +343,17 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
)} - {MembershipFeeHelpers.format_currency(@deleting_cycle.amount)}
|
)} - {MembershipFeeHelpers.format_currency(@deleting_cycle.amount)}
|
||||||
</p>
|
</p>
|
||||||
<div class="modal-action">
|
<div class="modal-action">
|
||||||
<button phx-click="cancel_delete_cycle" phx-target={@myself} class="btn">
|
<.button variant="neutral" phx-click="cancel_delete_cycle" phx-target={@myself}>
|
||||||
{gettext("Cancel")}
|
{gettext("Cancel")}
|
||||||
</button>
|
</.button>
|
||||||
<button
|
<.button
|
||||||
|
variant="danger"
|
||||||
phx-click="confirm_delete_cycle"
|
phx-click="confirm_delete_cycle"
|
||||||
phx-value-cycle_id={@deleting_cycle.id}
|
phx-value-cycle_id={@deleting_cycle.id}
|
||||||
phx-target={@myself}
|
phx-target={@myself}
|
||||||
class="btn btn-error"
|
|
||||||
>
|
>
|
||||||
{gettext("Delete")}
|
{gettext("Delete")}
|
||||||
</button>
|
</.button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
@ -385,20 +394,20 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-action">
|
<div class="modal-action">
|
||||||
<button phx-click="cancel_delete_all_cycles" phx-target={@myself} class="btn">
|
<.button variant="neutral" phx-click="cancel_delete_all_cycles" phx-target={@myself}>
|
||||||
{gettext("Cancel")}
|
{gettext("Cancel")}
|
||||||
</button>
|
</.button>
|
||||||
<button
|
<.button
|
||||||
|
variant="danger"
|
||||||
phx-click="confirm_delete_all_cycles"
|
phx-click="confirm_delete_all_cycles"
|
||||||
phx-target={@myself}
|
phx-target={@myself}
|
||||||
class="btn btn-error"
|
|
||||||
disabled={
|
disabled={
|
||||||
String.trim(String.downcase(@delete_all_confirmation)) !=
|
String.trim(String.downcase(@delete_all_confirmation)) !=
|
||||||
String.downcase(gettext("Yes"))
|
String.downcase(gettext("Yes"))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{gettext("Delete All")}
|
{gettext("Delete All")}
|
||||||
</button>
|
</.button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
@ -472,10 +481,15 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<div class="modal-action">
|
<div class="modal-action">
|
||||||
<button type="button" phx-click="cancel_create_cycle" phx-target={@myself} class="btn">
|
<.button
|
||||||
|
type="button"
|
||||||
|
variant="neutral"
|
||||||
|
phx-click="cancel_create_cycle"
|
||||||
|
phx-target={@myself}
|
||||||
|
>
|
||||||
{gettext("Cancel")}
|
{gettext("Cancel")}
|
||||||
</button>
|
</.button>
|
||||||
<button type="submit" class="btn btn-primary">{gettext("Create")}</button>
|
<.button type="submit" variant="primary">{gettext("Create")}</.button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -548,7 +562,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
get_available_fee_types(updated_member, actor)
|
get_available_fee_types(updated_member, actor)
|
||||||
)
|
)
|
||||||
|> assign(:interval_warning, nil)
|
|> assign(:interval_warning, nil)
|
||||||
|> put_flash(:info, gettext("Membership fee type removed"))}
|
|> put_flash(:success, gettext("Membership fee type removed"))}
|
||||||
|
|
||||||
{:error, error} ->
|
{:error, error} ->
|
||||||
{:noreply, put_flash(socket, :error, format_error(error))}
|
{:noreply, put_flash(socket, :error, format_error(error))}
|
||||||
|
|
@ -607,7 +621,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
get_available_fee_types(updated_member, actor)
|
get_available_fee_types(updated_member, actor)
|
||||||
)
|
)
|
||||||
|> assign(:interval_warning, nil)
|
|> assign(:interval_warning, nil)
|
||||||
|> put_flash(:info, gettext("Membership fee type updated. Cycles regenerated."))}
|
|> put_flash(:success, gettext("Membership fee type updated. Cycles regenerated."))}
|
||||||
|
|
||||||
{:error, error} ->
|
{:error, error} ->
|
||||||
{:noreply, put_flash(socket, :error, format_error(error))}
|
{:noreply, put_flash(socket, :error, format_error(error))}
|
||||||
|
|
@ -635,7 +649,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign(:cycles, updated_cycles)
|
|> assign(:cycles, updated_cycles)
|
||||||
|> put_flash(:info, gettext("Cycle status updated"))}
|
|> put_flash(:success, gettext("Cycle status updated"))}
|
||||||
|
|
||||||
{:error, %Ash.Error.Invalid{} = error} ->
|
{:error, %Ash.Error.Invalid{} = error} ->
|
||||||
error_msg =
|
error_msg =
|
||||||
|
|
@ -691,7 +705,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
|> assign(:member, updated_member)
|
|> assign(:member, updated_member)
|
||||||
|> assign(:cycles, cycles)
|
|> assign(:cycles, cycles)
|
||||||
|> assign(:regenerating, false)
|
|> assign(:regenerating, false)
|
||||||
|> put_flash(:info, gettext("Cycles regenerated successfully"))}
|
|> put_flash(:success, gettext("Cycles regenerated successfully"))}
|
||||||
|
|
||||||
{:error, error} ->
|
{:error, error} ->
|
||||||
{:noreply,
|
{:noreply,
|
||||||
|
|
@ -741,7 +755,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
socket
|
socket
|
||||||
|> assign(:cycles, updated_cycles)
|
|> assign(:cycles, updated_cycles)
|
||||||
|> assign(:editing_cycle, nil)
|
|> assign(:editing_cycle, nil)
|
||||||
|> put_flash(:info, gettext("Cycle amount updated"))}
|
|> put_flash(:success, gettext("Cycle amount updated"))}
|
||||||
|
|
||||||
{:error, error} ->
|
{:error, error} ->
|
||||||
{:noreply,
|
{:noreply,
|
||||||
|
|
@ -780,7 +794,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
socket
|
socket
|
||||||
|> assign(:cycles, updated_cycles)
|
|> assign(:cycles, updated_cycles)
|
||||||
|> assign(:deleting_cycle, nil)
|
|> assign(:deleting_cycle, nil)
|
||||||
|> put_flash(:info, gettext("Cycle deleted"))}
|
|> put_flash(:success, gettext("Cycle deleted"))}
|
||||||
|
|
||||||
{:ok, _destroyed} ->
|
{:ok, _destroyed} ->
|
||||||
# Handle case where return_destroyed? is true
|
# Handle case where return_destroyed? is true
|
||||||
|
|
@ -790,7 +804,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
socket
|
socket
|
||||||
|> assign(:cycles, updated_cycles)
|
|> assign(:cycles, updated_cycles)
|
||||||
|> assign(:deleting_cycle, nil)
|
|> assign(:deleting_cycle, nil)
|
||||||
|> put_flash(:info, gettext("Cycle deleted"))}
|
|> put_flash(:success, gettext("Cycle deleted"))}
|
||||||
|
|
||||||
{:error, error} ->
|
{:error, error} ->
|
||||||
{:noreply,
|
{:noreply,
|
||||||
|
|
@ -936,7 +950,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
|> assign(:creating_cycle, false)
|
|> assign(:creating_cycle, false)
|
||||||
|> assign(:create_cycle_date, nil)
|
|> assign(:create_cycle_date, nil)
|
||||||
|> assign(:create_cycle_error, nil)
|
|> assign(:create_cycle_error, nil)
|
||||||
|> put_flash(:info, gettext("Cycle created successfully"))}
|
|> put_flash(:success, gettext("Cycle created successfully"))}
|
||||||
|
|
||||||
{:error, error} ->
|
{:error, error} ->
|
||||||
{:noreply,
|
{:noreply,
|
||||||
|
|
@ -999,7 +1013,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
|> assign(:member, updated_member)
|
|> assign(:member, updated_member)
|
||||||
|> assign(:cycles, updated_cycles)
|
|> assign(:cycles, updated_cycles)
|
||||||
|> reset_modal.()
|
|> reset_modal.()
|
||||||
|> put_flash(:info, gettext("All cycles deleted"))}
|
|> put_flash(:success, gettext("All cycles deleted"))}
|
||||||
|
|
||||||
{:ok, _} ->
|
{:ok, _} ->
|
||||||
{:noreply,
|
{:noreply,
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign(:settings, updated_settings)
|
|> assign(:settings, updated_settings)
|
||||||
|> put_flash(:info, gettext("Settings saved successfully."))
|
|> put_flash(:success, gettext("Settings saved successfully."))
|
||||||
|> assign_form()}
|
|> assign_form()}
|
||||||
|
|
||||||
{:error, form} ->
|
{:error, form} ->
|
||||||
|
|
@ -105,7 +105,7 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|
||||||
socket
|
socket
|
||||||
|> assign(:membership_fee_types, updated_types)
|
|> assign(:membership_fee_types, updated_types)
|
||||||
|> assign(:member_counts, updated_counts)
|
|> assign(:member_counts, updated_counts)
|
||||||
|> put_flash(:info, gettext("Membership fee type deleted"))}
|
|> put_flash(:success, gettext("Membership fee type deleted"))}
|
||||||
|
|
||||||
{:error, %Ash.Error.Forbidden{}} ->
|
{:error, %Ash.Error.Forbidden{}} ->
|
||||||
{:noreply,
|
{:noreply,
|
||||||
|
|
@ -239,10 +239,10 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|
||||||
|
|
||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary w-full">
|
<.button type="submit" variant="primary" class="w-full">
|
||||||
<.icon name="hero-check" class="size-5" />
|
<.icon name="hero-check" class="size-5" />
|
||||||
{gettext("Save Settings")}
|
{gettext("Save Settings")}
|
||||||
</button>
|
</.button>
|
||||||
</.form>
|
</.form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -333,24 +333,27 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|
||||||
</:col>
|
</:col>
|
||||||
|
|
||||||
<:action :let={mft}>
|
<:action :let={mft}>
|
||||||
<.link
|
<.tooltip content={gettext("Edit membership fee type")} position="left">
|
||||||
navigate={~p"/membership_fee_settings/#{mft.id}/edit_fee_type"}
|
<.button
|
||||||
class="btn btn-ghost btn-xs"
|
variant="ghost"
|
||||||
aria-label={gettext("Edit membership fee type")}
|
size="sm"
|
||||||
>
|
navigate={~p"/membership_fee_settings/#{mft.id}/edit_fee_type"}
|
||||||
<.icon name="hero-pencil" class="size-4" />
|
aria-label={gettext("Edit membership fee type")}
|
||||||
</.link>
|
>
|
||||||
|
<.icon name="hero-pencil" class="size-4" />
|
||||||
|
</.button>
|
||||||
|
</.tooltip>
|
||||||
</:action>
|
</:action>
|
||||||
|
|
||||||
<:action :let={mft}>
|
<:action :let={mft}>
|
||||||
<div
|
<.tooltip
|
||||||
:if={get_member_count(mft, @member_counts) > 0}
|
:if={get_member_count(mft, @member_counts) > 0}
|
||||||
class="tooltip tooltip-left"
|
content={
|
||||||
data-tip={
|
|
||||||
gettext("Cannot delete - %{count} member(s) assigned",
|
gettext("Cannot delete - %{count} member(s) assigned",
|
||||||
count: get_member_count(mft, @member_counts)
|
count: get_member_count(mft, @member_counts)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
position="left"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
phx-click="delete"
|
phx-click="delete"
|
||||||
|
|
@ -366,17 +369,18 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|
||||||
>
|
>
|
||||||
<.icon name="hero-trash" class="size-4" />
|
<.icon name="hero-trash" class="size-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</.tooltip>
|
||||||
<button
|
<.button
|
||||||
:if={get_member_count(mft, @member_counts) == 0}
|
:if={get_member_count(mft, @member_counts) == 0}
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
phx-click="delete"
|
phx-click="delete"
|
||||||
phx-value-id={mft.id}
|
phx-value-id={mft.id}
|
||||||
data-confirm={gettext("Are you sure?")}
|
data-confirm={gettext("Are you sure?")}
|
||||||
class="btn btn-ghost btn-xs text-error"
|
|
||||||
aria-label={gettext("Delete Membership Fee Type")}
|
aria-label={gettext("Delete Membership Fee Type")}
|
||||||
>
|
>
|
||||||
<.icon name="hero-trash" class="size-4" />
|
<.icon name="hero-trash" class="size-4" />
|
||||||
</button>
|
</.button>
|
||||||
</:action>
|
</:action>
|
||||||
</.table>
|
</.table>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,10 +27,26 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
|
||||||
~H"""
|
~H"""
|
||||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||||
<.header>
|
<.header>
|
||||||
|
<:leading>
|
||||||
|
<.button navigate={return_path(@return_to, @membership_fee_type)} variant="neutral">
|
||||||
|
<.icon name="hero-arrow-left" class="size-4" />
|
||||||
|
{gettext("Back")}
|
||||||
|
</.button>
|
||||||
|
</:leading>
|
||||||
{@page_title}
|
{@page_title}
|
||||||
<:subtitle>
|
<:subtitle>
|
||||||
{gettext("Use this form to manage membership fee types in your database.")}
|
{gettext("Use this form to manage membership fee types in your database.")}
|
||||||
</:subtitle>
|
</:subtitle>
|
||||||
|
<:actions>
|
||||||
|
<.button
|
||||||
|
form="membership-fee-type-form"
|
||||||
|
phx-disable-with={gettext("Saving...")}
|
||||||
|
variant="primary"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{gettext("Save")}
|
||||||
|
</.button>
|
||||||
|
</:actions>
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
<.form
|
<.form
|
||||||
|
|
@ -176,20 +192,20 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-action">
|
<div class="modal-action">
|
||||||
<button
|
<.button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="neutral"
|
||||||
phx-click="cancel_amount_change"
|
phx-click="cancel_amount_change"
|
||||||
class="btn"
|
|
||||||
>
|
>
|
||||||
{gettext("Cancel")}
|
{gettext("Cancel")}
|
||||||
</button>
|
</.button>
|
||||||
<button
|
<.button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="primary"
|
||||||
phx-click="confirm_amount_change"
|
phx-click="confirm_amount_change"
|
||||||
class="btn btn-primary"
|
|
||||||
>
|
>
|
||||||
{gettext("Confirm Change")}
|
{gettext("Confirm Change")}
|
||||||
</button>
|
</.button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
@ -317,7 +333,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> put_flash(:info, gettext("Membership fee type saved successfully"))
|
|> put_flash(:success, gettext("Membership fee type saved successfully"))
|
||||||
|> push_navigate(to: return_path(socket.assigns.return_to, membership_fee_type))
|
|> push_navigate(to: return_path(socket.assigns.return_to, membership_fee_type))
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
|
|
|
||||||
|
|
@ -78,24 +78,27 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
|
||||||
</:col>
|
</:col>
|
||||||
|
|
||||||
<:action :let={mft}>
|
<:action :let={mft}>
|
||||||
<.link
|
<.tooltip content={gettext("Edit membership fee type")} position="left">
|
||||||
navigate={~p"/membership_fee_settings/#{mft.id}/edit_fee_type"}
|
<.button
|
||||||
class="btn btn-ghost btn-xs"
|
variant="ghost"
|
||||||
aria-label={gettext("Edit membership fee type")}
|
size="sm"
|
||||||
>
|
navigate={~p"/membership_fee_settings/#{mft.id}/edit_fee_type"}
|
||||||
<.icon name="hero-pencil" class="size-4" />
|
aria-label={gettext("Edit membership fee type")}
|
||||||
</.link>
|
>
|
||||||
|
<.icon name="hero-pencil" class="size-4" />
|
||||||
|
</.button>
|
||||||
|
</.tooltip>
|
||||||
</:action>
|
</:action>
|
||||||
|
|
||||||
<:action :let={mft}>
|
<:action :let={mft}>
|
||||||
<div
|
<.tooltip
|
||||||
:if={get_member_count(mft, @member_counts) > 0}
|
:if={get_member_count(mft, @member_counts) > 0}
|
||||||
class="tooltip tooltip-left"
|
content={
|
||||||
data-tip={
|
|
||||||
gettext("Cannot delete - %{count} member(s) assigned",
|
gettext("Cannot delete - %{count} member(s) assigned",
|
||||||
count: get_member_count(mft, @member_counts)
|
count: get_member_count(mft, @member_counts)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
position="left"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
phx-click="delete"
|
phx-click="delete"
|
||||||
|
|
@ -111,17 +114,18 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
|
||||||
>
|
>
|
||||||
<.icon name="hero-trash" class="size-4" />
|
<.icon name="hero-trash" class="size-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</.tooltip>
|
||||||
<button
|
<.button
|
||||||
:if={get_member_count(mft, @member_counts) == 0}
|
:if={get_member_count(mft, @member_counts) == 0}
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
phx-click="delete"
|
phx-click="delete"
|
||||||
phx-value-id={mft.id}
|
phx-value-id={mft.id}
|
||||||
data-confirm={gettext("Are you sure?")}
|
data-confirm={gettext("Are you sure?")}
|
||||||
class="btn btn-ghost btn-xs text-error"
|
|
||||||
aria-label={gettext("Delete Membership Fee Type")}
|
aria-label={gettext("Delete Membership Fee Type")}
|
||||||
>
|
>
|
||||||
<.icon name="hero-trash" class="size-4" />
|
<.icon name="hero-trash" class="size-4" />
|
||||||
</button>
|
</.button>
|
||||||
</:action>
|
</:action>
|
||||||
</.table>
|
</.table>
|
||||||
|
|
||||||
|
|
@ -145,7 +149,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
|
||||||
socket
|
socket
|
||||||
|> assign(:membership_fee_types, updated_types)
|
|> assign(:membership_fee_types, updated_types)
|
||||||
|> assign(:member_counts, updated_counts)
|
|> assign(:member_counts, updated_counts)
|
||||||
|> put_flash(:info, gettext("Membership fee type deleted"))}
|
|> put_flash(:success, gettext("Membership fee type deleted"))}
|
||||||
|
|
||||||
{:error, %Ash.Error.Forbidden{}} ->
|
{:error, %Ash.Error.Forbidden{}} ->
|
||||||
{:noreply,
|
{:noreply,
|
||||||
|
|
|
||||||
|
|
@ -21,66 +21,70 @@ defmodule MvWeb.RoleLive.Form do
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||||
<.header>
|
|
||||||
{@page_title}
|
|
||||||
<:subtitle>{gettext("Use this form to manage roles in your database.")}</:subtitle>
|
|
||||||
</.header>
|
|
||||||
|
|
||||||
<.form class="max-w-xl" for={@form} id="role-form" phx-change="validate" phx-submit="save">
|
<.form class="max-w-xl" for={@form} id="role-form" phx-change="validate" phx-submit="save">
|
||||||
<.input field={@form[:name]} type="text" label={gettext("Name")} required />
|
<.header>
|
||||||
|
<:leading>
|
||||||
|
<.button navigate={return_path(@return_to, @role)} variant="neutral">
|
||||||
|
<.icon name="hero-arrow-left" class="size-4" />
|
||||||
|
{gettext("Back")}
|
||||||
|
</.button>
|
||||||
|
</:leading>
|
||||||
|
{@page_title}
|
||||||
|
<:subtitle>{gettext("Use this form to manage roles in your database.")}</:subtitle>
|
||||||
|
<:actions>
|
||||||
|
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
|
||||||
|
{gettext("Save")}
|
||||||
|
</.button>
|
||||||
|
</:actions>
|
||||||
|
</.header>
|
||||||
|
|
||||||
<.input
|
<div class="mt-6 space-y-6">
|
||||||
field={@form[:description]}
|
<.input field={@form[:name]} type="text" label={gettext("Name")} required />
|
||||||
type="textarea"
|
|
||||||
label={gettext("Description")}
|
|
||||||
rows="3"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="form-control">
|
<.input
|
||||||
<label class="label" for="role-form_permission_set_name">
|
field={@form[:description]}
|
||||||
<span class="label-text font-semibold">
|
type="textarea"
|
||||||
{gettext("Permission Set")}
|
label={gettext("Description")}
|
||||||
<span class="text-red-700">*</span>
|
rows="3"
|
||||||
</span>
|
/>
|
||||||
</label>
|
|
||||||
<select
|
<div class="form-control">
|
||||||
class={[
|
<label class="label" for="role-form_permission_set_name">
|
||||||
"select select-bordered w-full",
|
<span class="label-text font-semibold">
|
||||||
@form.errors[:permission_set_name] && "select-error"
|
{gettext("Permission Set")}
|
||||||
]}
|
<span class="text-red-700">*</span>
|
||||||
name="role[permission_set_name]"
|
</span>
|
||||||
id="role-form_permission_set_name"
|
</label>
|
||||||
required
|
<select
|
||||||
aria-label={gettext("Permission Set")}
|
class={[
|
||||||
>
|
"select select-bordered w-full",
|
||||||
<option value="">{gettext("Select permission set")}</option>
|
@form.errors[:permission_set_name] && "select-error"
|
||||||
<%= for permission_set <- all_permission_sets() do %>
|
]}
|
||||||
<option
|
name="role[permission_set_name]"
|
||||||
value={permission_set}
|
id="role-form_permission_set_name"
|
||||||
selected={@form[:permission_set_name].value == permission_set}
|
required
|
||||||
>
|
aria-label={gettext("Permission Set")}
|
||||||
{format_permission_set_option(permission_set)}
|
>
|
||||||
</option>
|
<option value="">{gettext("Select permission set")}</option>
|
||||||
|
<%= for permission_set <- all_permission_sets() do %>
|
||||||
|
<option
|
||||||
|
value={permission_set}
|
||||||
|
selected={@form[:permission_set_name].value == permission_set}
|
||||||
|
>
|
||||||
|
{format_permission_set_option(permission_set)}
|
||||||
|
</option>
|
||||||
|
<% end %>
|
||||||
|
</select>
|
||||||
|
<%= if @form.errors[:permission_set_name] do %>
|
||||||
|
<%= for error <- List.wrap(@form.errors[:permission_set_name]) do %>
|
||||||
|
<% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %>
|
||||||
|
<p class="mt-1.5 flex gap-2 items-center text-sm text-error">
|
||||||
|
<.icon name="hero-exclamation-circle" class="size-5" />
|
||||||
|
{msg}
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</select>
|
</div>
|
||||||
<%= if @form.errors[:permission_set_name] do %>
|
|
||||||
<%= for error <- List.wrap(@form.errors[:permission_set_name]) do %>
|
|
||||||
<% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %>
|
|
||||||
<p class="mt-1.5 flex gap-2 items-center text-sm text-error">
|
|
||||||
<.icon name="hero-exclamation-circle" class="size-5" />
|
|
||||||
{msg}
|
|
||||||
</p>
|
|
||||||
<% end %>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
|
||||||
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
|
|
||||||
{gettext("Save Role")}
|
|
||||||
</.button>
|
|
||||||
<.button navigate={return_path(@return_to, @role)} type="button">
|
|
||||||
{gettext("Cancel")}
|
|
||||||
</.button>
|
|
||||||
</div>
|
</div>
|
||||||
</.form>
|
</.form>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
|
|
@ -175,7 +179,7 @@ defmodule MvWeb.RoleLive.Form do
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> put_flash(:info, gettext("Role saved successfully."))
|
|> put_flash(:success, gettext("Role saved successfully."))
|
||||||
|> push_navigate(to: redirect_path)
|
|> push_navigate(to: redirect_path)
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,8 @@ defmodule MvWeb.RoleLive.Index do
|
||||||
## Features
|
## Features
|
||||||
- List all roles with name, description, permission_set_name, is_system_role
|
- List all roles with name, description, permission_set_name, is_system_role
|
||||||
- Create new roles
|
- Create new roles
|
||||||
- Navigate to role details and edit forms
|
- Navigate to role details (row click) and edit from details header
|
||||||
- Delete non-system roles
|
- Delete only via Danger zone on role show page
|
||||||
|
|
||||||
## Events
|
|
||||||
- `delete` - Remove a role from the database (only non-system roles)
|
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
Only admins can access this page (enforced by authorization).
|
Only admins can access this page (enforced by authorization).
|
||||||
|
|
@ -21,8 +18,7 @@ defmodule MvWeb.RoleLive.Index do
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
import MvWeb.RoleLive.Helpers,
|
import MvWeb.RoleLive.Helpers, only: [permission_set_badge_class: 1]
|
||||||
only: [format_error: 1, permission_set_badge_class: 1, opts_with_actor: 3]
|
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
|
|
@ -37,83 +33,6 @@ defmodule MvWeb.RoleLive.Index do
|
||||||
|> assign(:user_counts, user_counts)}
|
|> assign(:user_counts, user_counts)}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
|
||||||
def handle_event("delete", %{"id" => id}, socket) do
|
|
||||||
case Authorization.get_role(id, actor: socket.assigns.current_user) do
|
|
||||||
{:ok, role} ->
|
|
||||||
handle_delete_role(role, id, socket)
|
|
||||||
|
|
||||||
{:error, %Ash.Error.Query.NotFound{}} ->
|
|
||||||
{:noreply,
|
|
||||||
put_flash(
|
|
||||||
socket,
|
|
||||||
:error,
|
|
||||||
gettext("Role not found.")
|
|
||||||
)}
|
|
||||||
|
|
||||||
{:error, error} ->
|
|
||||||
error_message = format_error(error)
|
|
||||||
|
|
||||||
{:noreply,
|
|
||||||
put_flash(
|
|
||||||
socket,
|
|
||||||
:error,
|
|
||||||
gettext("Failed to delete role: %{error}", error: error_message)
|
|
||||||
)}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp handle_delete_role(role, id, socket) do
|
|
||||||
if role.is_system_role do
|
|
||||||
{:noreply,
|
|
||||||
put_flash(
|
|
||||||
socket,
|
|
||||||
:error,
|
|
||||||
gettext("System roles cannot be deleted.")
|
|
||||||
)}
|
|
||||||
else
|
|
||||||
user_count = recalculate_user_count(role, socket.assigns.current_user)
|
|
||||||
|
|
||||||
if user_count > 0 do
|
|
||||||
{:noreply,
|
|
||||||
put_flash(
|
|
||||||
socket,
|
|
||||||
:error,
|
|
||||||
gettext(
|
|
||||||
"Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first.",
|
|
||||||
count: user_count
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
else
|
|
||||||
perform_role_deletion(role, id, socket)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp perform_role_deletion(role, id, socket) do
|
|
||||||
case Authorization.destroy_role(role, actor: socket.assigns.current_user) do
|
|
||||||
:ok ->
|
|
||||||
updated_roles = Enum.reject(socket.assigns.roles, &(&1.id == id))
|
|
||||||
updated_counts = Map.delete(socket.assigns.user_counts, id)
|
|
||||||
|
|
||||||
{:noreply,
|
|
||||||
socket
|
|
||||||
|> assign(:roles, updated_roles)
|
|
||||||
|> assign(:user_counts, updated_counts)
|
|
||||||
|> put_flash(:info, gettext("Role deleted successfully."))}
|
|
||||||
|
|
||||||
{:error, error} ->
|
|
||||||
error_message = format_error(error)
|
|
||||||
|
|
||||||
{:noreply,
|
|
||||||
put_flash(
|
|
||||||
socket,
|
|
||||||
:error,
|
|
||||||
gettext("Failed to delete role: %{error}", error: error_message)
|
|
||||||
)}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@spec load_roles(map() | nil) :: [Mv.Authorization.Role.t()]
|
@spec load_roles(map() | nil) :: [Mv.Authorization.Role.t()]
|
||||||
defp load_roles(actor) do
|
defp load_roles(actor) do
|
||||||
opts = MvWeb.LiveHelpers.ash_actor_opts(actor)
|
opts = MvWeb.LiveHelpers.ash_actor_opts(actor)
|
||||||
|
|
@ -154,15 +73,4 @@ defmodule MvWeb.RoleLive.Index do
|
||||||
defp get_user_count(role, user_counts) do
|
defp get_user_count(role, user_counts) do
|
||||||
Map.get(user_counts, role.id, 0)
|
Map.get(user_counts, role.id, 0)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Recalculates user count for a specific role (used before deletion)
|
|
||||||
@spec recalculate_user_count(Mv.Authorization.Role.t(), map() | nil) :: non_neg_integer()
|
|
||||||
defp recalculate_user_count(role, actor) do
|
|
||||||
opts = opts_with_actor([], actor, Mv.Accounts)
|
|
||||||
|
|
||||||
case Ash.count(Accounts.User |> Ash.Query.filter(role_id == ^role.id), opts) do
|
|
||||||
{:ok, count} -> count
|
|
||||||
_ -> 0
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
id="roles"
|
id="roles"
|
||||||
rows={@roles}
|
rows={@roles}
|
||||||
row_click={fn role -> JS.navigate(~p"/admin/roles/#{role}") end}
|
row_click={fn role -> JS.navigate(~p"/admin/roles/#{role}") end}
|
||||||
|
row_tooltip={gettext("Click for role details")}
|
||||||
>
|
>
|
||||||
<:col :let={role} label={gettext("Name")}>
|
<:col :let={role} label={gettext("Name")}>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
|
@ -52,46 +53,5 @@
|
||||||
<:col :let={role} label={gettext("Users")}>
|
<:col :let={role} label={gettext("Users")}>
|
||||||
<span class="badge badge-ghost">{get_user_count(role, @user_counts)}</span>
|
<span class="badge badge-ghost">{get_user_count(role, @user_counts)}</span>
|
||||||
</:col>
|
</:col>
|
||||||
|
|
||||||
<:action :let={role}>
|
|
||||||
<div class="sr-only">
|
|
||||||
<.link navigate={~p"/admin/roles/#{role}"}>{gettext("Show")}</.link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%= if can?(@current_user, :update, Mv.Authorization.Role) do %>
|
|
||||||
<.link navigate={~p"/admin/roles/#{role}/edit"} class="btn btn-ghost btn-sm">
|
|
||||||
<.icon name="hero-pencil" class="size-4" />
|
|
||||||
{gettext("Edit")}
|
|
||||||
</.link>
|
|
||||||
<% end %>
|
|
||||||
</:action>
|
|
||||||
|
|
||||||
<:action :let={role}>
|
|
||||||
<%= if can?(@current_user, :destroy, Mv.Authorization.Role) and not role.is_system_role do %>
|
|
||||||
<.link
|
|
||||||
phx-click={JS.push("delete", value: %{id: role.id}) |> hide("#row-#{role.id}")}
|
|
||||||
data-confirm={gettext("Are you sure?")}
|
|
||||||
class="btn btn-ghost btn-sm text-error"
|
|
||||||
>
|
|
||||||
<.icon name="hero-trash" class="size-4" />
|
|
||||||
{gettext("Delete")}
|
|
||||||
</.link>
|
|
||||||
<% else %>
|
|
||||||
<div
|
|
||||||
:if={role.is_system_role}
|
|
||||||
class="tooltip tooltip-left"
|
|
||||||
data-tip={gettext("System roles cannot be deleted")}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="btn btn-ghost btn-sm text-error opacity-50 cursor-not-allowed"
|
|
||||||
disabled={true}
|
|
||||||
aria-label={gettext("Cannot delete system role")}
|
|
||||||
>
|
|
||||||
<.icon name="hero-trash" class="size-4" />
|
|
||||||
{gettext("Delete")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</:action>
|
|
||||||
</.table>
|
</.table>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,7 @@ defmodule MvWeb.RoleLive.Show do
|
||||||
:ok ->
|
:ok ->
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> put_flash(:info, gettext("Role deleted successfully."))
|
|> put_flash(:success, gettext("Role deleted successfully."))
|
||||||
|> push_navigate(to: ~p"/admin/roles")}
|
|> push_navigate(to: ~p"/admin/roles")}
|
||||||
|
|
||||||
{:error, error} ->
|
{:error, error} ->
|
||||||
|
|
@ -161,27 +161,28 @@ defmodule MvWeb.RoleLive.Show do
|
||||||
~H"""
|
~H"""
|
||||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||||
<.header>
|
<.header>
|
||||||
|
<:leading>
|
||||||
|
<.button
|
||||||
|
navigate={~p"/admin/roles"}
|
||||||
|
variant="neutral"
|
||||||
|
aria-label={gettext("Back to roles list")}
|
||||||
|
>
|
||||||
|
<.icon name="hero-arrow-left" class="size-4" />
|
||||||
|
{gettext("Back")}
|
||||||
|
</.button>
|
||||||
|
</:leading>
|
||||||
{gettext("Role")} {@role.name}
|
{gettext("Role")} {@role.name}
|
||||||
<:subtitle>{gettext("Role details and permissions.")}</:subtitle>
|
<:subtitle>{gettext("Role details and permissions.")}</:subtitle>
|
||||||
|
|
||||||
<:actions>
|
<:actions>
|
||||||
<.button navigate={~p"/admin/roles"} aria-label={gettext("Back to roles list")}>
|
|
||||||
<.icon name="hero-arrow-left" />
|
|
||||||
<span class="sr-only">{gettext("Back to roles list")}</span>
|
|
||||||
</.button>
|
|
||||||
<%= if can?(@current_user, :update, Mv.Authorization.Role) do %>
|
<%= if can?(@current_user, :update, Mv.Authorization.Role) do %>
|
||||||
<.button variant="primary" navigate={~p"/admin/roles/#{@role}/edit"}>
|
<.button
|
||||||
<.icon name="hero-pencil-square" /> {gettext("Edit Role")}
|
variant="primary"
|
||||||
</.button>
|
navigate={~p"/admin/roles/#{@role}/edit"}
|
||||||
<% end %>
|
data-testid="role-show-edit-btn"
|
||||||
<%= if can?(@current_user, :destroy, Mv.Authorization.Role) and not @role.is_system_role do %>
|
|
||||||
<.link
|
|
||||||
phx-click={JS.push("delete", value: %{id: @role.id})}
|
|
||||||
data-confirm={gettext("Are you sure?")}
|
|
||||||
class="btn btn-error"
|
|
||||||
>
|
>
|
||||||
<.icon name="hero-trash" /> {gettext("Delete Role")}
|
<.icon name="hero-pencil-square" /> {gettext("Edit role")}
|
||||||
</.link>
|
</.button>
|
||||||
<% end %>
|
<% end %>
|
||||||
</:actions>
|
</:actions>
|
||||||
</.header>
|
</.header>
|
||||||
|
|
@ -208,6 +209,37 @@ defmodule MvWeb.RoleLive.Show do
|
||||||
<% end %>
|
<% end %>
|
||||||
</:item>
|
</:item>
|
||||||
</.list>
|
</.list>
|
||||||
|
|
||||||
|
<%!-- Danger zone: canonical pattern (same as member show) --%>
|
||||||
|
<%= if can?(@current_user, :destroy, Mv.Authorization.Role) and not @role.is_system_role do %>
|
||||||
|
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
|
||||||
|
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
|
||||||
|
{gettext("Danger zone")}
|
||||||
|
</h2>
|
||||||
|
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
||||||
|
<p class="text-base-content/70 mb-4">
|
||||||
|
{gettext(
|
||||||
|
"Deleting this role cannot be undone. Users assigned to this role must be reassigned first."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<.button
|
||||||
|
variant="danger"
|
||||||
|
phx-click={JS.push("delete", value: %{id: @role.id})}
|
||||||
|
data-confirm={
|
||||||
|
gettext(
|
||||||
|
"Are you sure you want to delete the role %{name}? This action cannot be undone.",
|
||||||
|
name: @role.name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
data-testid="role-delete"
|
||||||
|
aria-label={gettext("Delete role %{name}", name: @role.name)}
|
||||||
|
>
|
||||||
|
<.icon name="hero-trash" class="size-4" />
|
||||||
|
{gettext("Delete role")}
|
||||||
|
</.button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<% end %>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -39,14 +39,31 @@ defmodule MvWeb.UserLive.Form do
|
||||||
|
|
||||||
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
|
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
|
||||||
import MvWeb.Authorization, only: [can?: 3]
|
import MvWeb.Authorization, only: [can?: 3]
|
||||||
|
import MvWeb.ErrorHelpers, only: [format_ash_error: 1]
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||||
<.header>
|
<.header>
|
||||||
|
<:leading>
|
||||||
|
<.button navigate={return_path(@return_to, @user)} variant="neutral">
|
||||||
|
<.icon name="hero-arrow-left" class="size-4" />
|
||||||
|
{gettext("Back")}
|
||||||
|
</.button>
|
||||||
|
</:leading>
|
||||||
{@page_title}
|
{@page_title}
|
||||||
<:subtitle>{gettext("Use this form to manage user records in your database.")}</:subtitle>
|
<:subtitle>{gettext("Use this form to manage user records in your database.")}</:subtitle>
|
||||||
|
<:actions>
|
||||||
|
<.button
|
||||||
|
form="user-form"
|
||||||
|
phx-disable-with={gettext("Saving...")}
|
||||||
|
variant="primary"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{gettext("Save User")}
|
||||||
|
</.button>
|
||||||
|
</:actions>
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
<.form class="max-w-xl" for={@form} id="user-form" phx-change="validate" phx-submit="save">
|
<.form class="max-w-xl" for={@form} id="user-form" phx-change="validate" phx-submit="save">
|
||||||
|
|
@ -167,13 +184,14 @@ defmodule MvWeb.UserLive.Form do
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-green-700">{@user.member.email}</p>
|
<p class="text-sm text-green-700">{@user.member.email}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<.button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
phx-click="unlink_member"
|
phx-click="unlink_member"
|
||||||
class="btn btn-sm btn-error"
|
|
||||||
>
|
>
|
||||||
{gettext("Unlink Member")}
|
{gettext("Unlink Member")}
|
||||||
</button>
|
</.button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
|
|
@ -280,11 +298,46 @@ defmodule MvWeb.UserLive.Form do
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
<%!-- Danger zone: canonical pattern (same as member form) --%>
|
||||||
|
<%= if @user && can?(@current_user, :destroy, @user) && !Mv.Helpers.SystemActor.system_user?(@user) do %>
|
||||||
|
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
|
||||||
|
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
|
||||||
|
{gettext("Danger zone")}
|
||||||
|
</h2>
|
||||||
|
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
||||||
|
<p class="text-base-content/70 mb-4">
|
||||||
|
{gettext(
|
||||||
|
"Deleting this user cannot be undone. The user account and any linked member association will be affected."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<.button
|
||||||
|
type="button"
|
||||||
|
variant="danger"
|
||||||
|
phx-click="delete"
|
||||||
|
phx-value-id={@user.id}
|
||||||
|
data-confirm={
|
||||||
|
gettext(
|
||||||
|
"Are you sure you want to delete the user %{email}? This action cannot be undone.",
|
||||||
|
email: @user.email
|
||||||
|
)
|
||||||
|
}
|
||||||
|
data-testid="user-delete"
|
||||||
|
aria-label={gettext("Delete user %{email}", email: @user.email)}
|
||||||
|
>
|
||||||
|
<.icon name="hero-trash" class="size-4" />
|
||||||
|
{gettext("Delete user")}
|
||||||
|
</.button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
|
<.button navigate={return_path(@return_to, @user)} variant="neutral">
|
||||||
|
{gettext("Cancel")}
|
||||||
|
</.button>
|
||||||
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
||||||
{gettext("Save User")}
|
{gettext("Save User")}
|
||||||
</.button>
|
</.button>
|
||||||
<.button navigate={return_path(@return_to, @user)}>{gettext("Cancel")}</.button>
|
|
||||||
</div>
|
</div>
|
||||||
</.form>
|
</.form>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
|
|
@ -401,6 +454,26 @@ defmodule MvWeb.UserLive.Form do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("delete", %{"id" => id}, socket) do
|
||||||
|
user = socket.assigns.user
|
||||||
|
actor = current_actor(socket)
|
||||||
|
|
||||||
|
cond do
|
||||||
|
is_nil(user) ->
|
||||||
|
{:noreply, put_flash(socket, :error, gettext("User not found"))}
|
||||||
|
|
||||||
|
to_string(id) != to_string(user.id) ->
|
||||||
|
{:noreply, put_flash(socket, :error, gettext("User not found"))}
|
||||||
|
|
||||||
|
Mv.Helpers.SystemActor.system_user?(user) ->
|
||||||
|
{:noreply, put_flash(socket, :error, gettext("System user cannot be deleted."))}
|
||||||
|
|
||||||
|
true ->
|
||||||
|
handle_user_delete_destroy(socket, user, actor)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("show_member_dropdown", _params, socket) do
|
def handle_event("show_member_dropdown", _params, socket) do
|
||||||
{:noreply, assign(socket, show_member_dropdown: true)}
|
{:noreply, assign(socket, show_member_dropdown: true)}
|
||||||
|
|
@ -511,6 +584,23 @@ defmodule MvWeb.UserLive.Form do
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp handle_user_delete_destroy(socket, user, actor) do
|
||||||
|
case Ash.destroy(user, domain: Mv.Accounts, actor: actor) do
|
||||||
|
:ok ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:success, gettext("User deleted successfully"))
|
||||||
|
|> push_navigate(to: ~p"/users")}
|
||||||
|
|
||||||
|
{:error, %Ash.Error.Forbidden{}} ->
|
||||||
|
{:noreply,
|
||||||
|
put_flash(socket, :error, gettext("You do not have permission to delete this user"))}
|
||||||
|
|
||||||
|
{:error, error} ->
|
||||||
|
{:noreply, put_flash(socket, :error, format_ash_error(error))}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp handle_member_linking(socket, user, actor) do
|
defp handle_member_linking(socket, user, actor) do
|
||||||
result = perform_member_link_action(socket, user, actor)
|
result = perform_member_link_action(socket, user, actor)
|
||||||
|
|
||||||
|
|
@ -553,7 +643,7 @@ defmodule MvWeb.UserLive.Form do
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> put_flash(:info, gettext("User %{action} successfully", action: action))
|
|> put_flash(:success, gettext("User %{action} successfully", action: action))
|
||||||
|> push_navigate(to: return_path(socket.assigns.return_to, updated_user))
|
|> push_navigate(to: return_path(socket.assigns.return_to, updated_user))
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
|
|
|
||||||
|
|
@ -5,18 +5,12 @@ defmodule MvWeb.UserLive.Index do
|
||||||
## Features
|
## Features
|
||||||
- List all users with email and linked member
|
- List all users with email and linked member
|
||||||
- Sort users by email (default)
|
- Sort users by email (default)
|
||||||
- Delete users
|
- Navigate to user details (row click) and edit from details header
|
||||||
- Navigate to user details and edit forms
|
- Delete only via Danger zone on user show/edit
|
||||||
- Bulk selection for future batch operations
|
|
||||||
|
|
||||||
## Relationships
|
## Relationships
|
||||||
Displays linked member information when a user is connected to a member account.
|
Displays linked member information when a user is connected to a member account.
|
||||||
|
|
||||||
## Events
|
|
||||||
- `delete` - Remove a user from the database
|
|
||||||
- `select_user` - Toggle individual user selection
|
|
||||||
- `select_all` - Toggle selection of all visible users
|
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
User deletion requires admin permissions (enforced by Ash policies).
|
User deletion requires admin permissions (enforced by Ash policies).
|
||||||
"""
|
"""
|
||||||
|
|
@ -26,7 +20,6 @@ defmodule MvWeb.UserLive.Index do
|
||||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
import MvWeb.ErrorHelpers, only: [format_ash_error: 1]
|
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
|
|
@ -44,63 +37,7 @@ defmodule MvWeb.UserLive.Index do
|
||||||
|> assign(:page_title, gettext("Listing Users"))
|
|> assign(:page_title, gettext("Listing Users"))
|
||||||
|> assign(:sort_field, :email)
|
|> assign(:sort_field, :email)
|
||||||
|> assign(:sort_order, :asc)
|
|> assign(:sort_order, :asc)
|
||||||
|> assign(:users, sorted)
|
|> assign(:users, sorted)}
|
||||||
|> assign(:selected_users, [])}
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def handle_event("delete", %{"id" => id}, socket) do
|
|
||||||
actor = current_actor(socket)
|
|
||||||
|
|
||||||
case Ash.get(Mv.Accounts.User, id, domain: Mv.Accounts, actor: actor) do
|
|
||||||
{:ok, user} ->
|
|
||||||
case Ash.destroy(user, domain: Mv.Accounts, actor: actor) do
|
|
||||||
:ok ->
|
|
||||||
updated_users = Enum.reject(socket.assigns.users, &(&1.id == id))
|
|
||||||
|
|
||||||
{:noreply,
|
|
||||||
socket
|
|
||||||
|> assign(:users, updated_users)
|
|
||||||
|> put_flash(:info, gettext("User deleted successfully"))}
|
|
||||||
|
|
||||||
{:error, %Ash.Error.Forbidden{}} ->
|
|
||||||
{:noreply,
|
|
||||||
put_flash(
|
|
||||||
socket,
|
|
||||||
:error,
|
|
||||||
gettext("You do not have permission to delete this user")
|
|
||||||
)}
|
|
||||||
|
|
||||||
{:error, error} ->
|
|
||||||
{:noreply, put_flash(socket, :error, format_ash_error(error))}
|
|
||||||
end
|
|
||||||
|
|
||||||
{:error, %Ash.Error.Query.NotFound{}} ->
|
|
||||||
{:noreply, put_flash(socket, :error, gettext("User not found"))}
|
|
||||||
|
|
||||||
{:error, %Ash.Error.Forbidden{} = _error} ->
|
|
||||||
{:noreply,
|
|
||||||
put_flash(socket, :error, gettext("You do not have permission to access this user"))}
|
|
||||||
|
|
||||||
{:error, error} ->
|
|
||||||
{:noreply, put_flash(socket, :error, format_ash_error(error))}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Selects one user in the list of users
|
|
||||||
@impl true
|
|
||||||
def handle_event("select_user", %{"id" => id}, socket) do
|
|
||||||
# Normalize ID to string for consistent comparison
|
|
||||||
id_str = to_string(id)
|
|
||||||
|
|
||||||
selected =
|
|
||||||
if id_str in socket.assigns.selected_users do
|
|
||||||
List.delete(socket.assigns.selected_users, id_str)
|
|
||||||
else
|
|
||||||
[id_str | socket.assigns.selected_users]
|
|
||||||
end
|
|
||||||
|
|
||||||
{:noreply, assign(socket, :selected_users, selected)}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Sorts the list of users according to a field, when you click on the column header
|
# Sorts the list of users according to a field, when you click on the column header
|
||||||
|
|
@ -127,24 +64,6 @@ defmodule MvWeb.UserLive.Index do
|
||||||
|> assign(:users, sorted_users)}
|
|> assign(:users, sorted_users)}
|
||||||
end
|
end
|
||||||
|
|
||||||
# Selects all users in the list of users
|
|
||||||
@impl true
|
|
||||||
def handle_event("select_all", _params, socket) do
|
|
||||||
users = socket.assigns.users
|
|
||||||
|
|
||||||
# Normalize IDs to strings for consistent comparison
|
|
||||||
all_ids = Enum.map(users, &to_string(&1.id))
|
|
||||||
|
|
||||||
selected =
|
|
||||||
if Enum.sort(socket.assigns.selected_users) == Enum.sort(all_ids) do
|
|
||||||
[]
|
|
||||||
else
|
|
||||||
all_ids
|
|
||||||
end
|
|
||||||
|
|
||||||
{:noreply, assign(socket, :selected_users, selected)}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp toggle_order(:asc), do: :desc
|
defp toggle_order(:asc), do: :desc
|
||||||
defp toggle_order(:desc), do: :asc
|
defp toggle_order(:desc), do: :asc
|
||||||
defp sort_fun(:asc), do: &<=/2
|
defp sort_fun(:asc), do: &<=/2
|
||||||
|
|
|
||||||
|
|
@ -15,36 +15,10 @@
|
||||||
rows={@users}
|
rows={@users}
|
||||||
row_id={fn user -> "row-#{user.id}" end}
|
row_id={fn user -> "row-#{user.id}" end}
|
||||||
row_click={fn user -> JS.navigate(~p"/users/#{user}") end}
|
row_click={fn user -> JS.navigate(~p"/users/#{user}") end}
|
||||||
|
row_tooltip={gettext("Click for user details")}
|
||||||
sort_field={@sort_field}
|
sort_field={@sort_field}
|
||||||
sort_order={@sort_order}
|
sort_order={@sort_order}
|
||||||
>
|
>
|
||||||
<:col
|
|
||||||
:let={user}
|
|
||||||
label={
|
|
||||||
~H"""
|
|
||||||
<.input
|
|
||||||
type="checkbox"
|
|
||||||
name="select_all"
|
|
||||||
phx-click="select_all"
|
|
||||||
checked={Enum.sort(@selected_users) == Enum.map(@users, &to_string(&1.id)) |> Enum.sort()}
|
|
||||||
aria-label={gettext("Select all users")}
|
|
||||||
role="checkbox"
|
|
||||||
/>
|
|
||||||
"""
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<.input
|
|
||||||
type="checkbox"
|
|
||||||
name={to_string(user.id)}
|
|
||||||
phx-click="select_user"
|
|
||||||
phx-value-id={to_string(user.id)}
|
|
||||||
checked={to_string(user.id) in @selected_users}
|
|
||||||
phx-capture-click
|
|
||||||
phx-stop-propagation
|
|
||||||
aria-label={gettext("Select user")}
|
|
||||||
role="checkbox"
|
|
||||||
/>
|
|
||||||
</:col>
|
|
||||||
<:col
|
<:col
|
||||||
:let={user}
|
:let={user}
|
||||||
sort_field={:email}
|
sort_field={:email}
|
||||||
|
|
@ -83,29 +57,5 @@
|
||||||
<span class="text-base-content/70">—</span>
|
<span class="text-base-content/70">—</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</:col>
|
</:col>
|
||||||
|
|
||||||
<:action :let={user}>
|
|
||||||
<div class="sr-only">
|
|
||||||
<.link navigate={~p"/users/#{user}"}>{gettext("Show")}</.link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%= if can?(@current_user, :update, user) do %>
|
|
||||||
<.link navigate={~p"/users/#{user}/edit"} data-testid="user-edit">
|
|
||||||
{gettext("Edit")}
|
|
||||||
</.link>
|
|
||||||
<% end %>
|
|
||||||
</:action>
|
|
||||||
|
|
||||||
<:action :let={user}>
|
|
||||||
<%= if can?(@current_user, :destroy, user) do %>
|
|
||||||
<.link
|
|
||||||
phx-click={JS.push("delete", value: %{id: user.id}) |> hide("#row-#{user.id}")}
|
|
||||||
data-confirm={gettext("Are you sure?")}
|
|
||||||
data-testid="user-delete"
|
|
||||||
>
|
|
||||||
{gettext("Delete")}
|
|
||||||
</.link>
|
|
||||||
<% end %>
|
|
||||||
</:action>
|
|
||||||
</.table>
|
</.table>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
|
|
|
||||||
|
|
@ -27,20 +27,27 @@ defmodule MvWeb.UserLive.Show do
|
||||||
use MvWeb, :live_view
|
use MvWeb, :live_view
|
||||||
|
|
||||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||||
|
import MvWeb.ErrorHelpers, only: [format_ash_error: 1]
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||||
<.header>
|
<.header>
|
||||||
|
<:leading>
|
||||||
|
<.button
|
||||||
|
navigate={~p"/users"}
|
||||||
|
variant="neutral"
|
||||||
|
aria-label={gettext("Back to users list")}
|
||||||
|
>
|
||||||
|
<.icon name="hero-arrow-left" class="size-4" />
|
||||||
|
{gettext("Back")}
|
||||||
|
</.button>
|
||||||
|
</:leading>
|
||||||
{gettext("User")} {@user.email}
|
{gettext("User")} {@user.email}
|
||||||
<:subtitle>{gettext("This is a user record from your database.")}</:subtitle>
|
<:subtitle>{gettext("This is a user record from your database.")}</:subtitle>
|
||||||
|
|
||||||
<:actions>
|
<:actions>
|
||||||
<.button navigate={~p"/users"} aria-label={gettext("Back to users list")}>
|
|
||||||
<.icon name="hero-arrow-left" />
|
|
||||||
<span class="sr-only">{gettext("Back to users list")}</span>
|
|
||||||
</.button>
|
|
||||||
<%= if can?(@current_user, :update, @user) do %>
|
<%= if can?(@current_user, :update, @user) do %>
|
||||||
<.button
|
<.button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
|
|
@ -80,6 +87,38 @@ defmodule MvWeb.UserLive.Show do
|
||||||
<% end %>
|
<% end %>
|
||||||
</:item>
|
</:item>
|
||||||
</.list>
|
</.list>
|
||||||
|
|
||||||
|
<%!-- Danger zone: canonical pattern (same as member show) --%>
|
||||||
|
<%= if can?(@current_user, :destroy, @user) and not Mv.Helpers.SystemActor.system_user?(@user) do %>
|
||||||
|
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
|
||||||
|
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
|
||||||
|
{gettext("Danger zone")}
|
||||||
|
</h2>
|
||||||
|
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
||||||
|
<p class="text-base-content/70 mb-4">
|
||||||
|
{gettext(
|
||||||
|
"Deleting this user cannot be undone. The user account and any linked member association will be affected."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<.button
|
||||||
|
variant="danger"
|
||||||
|
phx-click="delete"
|
||||||
|
phx-value-id={@user.id}
|
||||||
|
data-confirm={
|
||||||
|
gettext(
|
||||||
|
"Are you sure you want to delete the user %{email}? This action cannot be undone.",
|
||||||
|
email: @user.email
|
||||||
|
)
|
||||||
|
}
|
||||||
|
data-testid="user-delete"
|
||||||
|
aria-label={gettext("Delete user %{email}", email: @user.email)}
|
||||||
|
>
|
||||||
|
<.icon name="hero-trash" class="size-4" />
|
||||||
|
{gettext("Delete user")}
|
||||||
|
</.button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<% end %>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
@ -103,4 +142,38 @@ defmodule MvWeb.UserLive.Show do
|
||||||
|> assign(:user, user)}
|
|> assign(:user, user)}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("delete", %{"id" => id}, socket) do
|
||||||
|
user = socket.assigns.user
|
||||||
|
actor = current_actor(socket)
|
||||||
|
|
||||||
|
cond do
|
||||||
|
to_string(id) != to_string(user.id) ->
|
||||||
|
{:noreply, put_flash(socket, :error, gettext("User not found"))}
|
||||||
|
|
||||||
|
Mv.Helpers.SystemActor.system_user?(user) ->
|
||||||
|
{:noreply, put_flash(socket, :error, gettext("System user cannot be deleted."))}
|
||||||
|
|
||||||
|
true ->
|
||||||
|
handle_user_delete_destroy(socket, user, actor)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp handle_user_delete_destroy(socket, user, actor) do
|
||||||
|
case Ash.destroy(user, domain: Mv.Accounts, actor: actor) do
|
||||||
|
:ok ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:success, gettext("User deleted successfully"))
|
||||||
|
|> push_navigate(to: ~p"/users")}
|
||||||
|
|
||||||
|
{:error, %Ash.Error.Forbidden{}} ->
|
||||||
|
{:noreply,
|
||||||
|
put_flash(socket, :error, gettext("You do not have permission to delete this user"))}
|
||||||
|
|
||||||
|
{:error, error} ->
|
||||||
|
{:noreply, put_flash(socket, :error, format_ash_error(error))}
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -11,18 +11,13 @@ msgstr ""
|
||||||
"Language: de\n"
|
"Language: de\n"
|
||||||
|
|
||||||
#: lib/mv_web/components/core_components.ex
|
#: lib/mv_web/components/core_components.ex
|
||||||
#: lib/mv_web/live/group_live/index.ex
|
|
||||||
#: lib/mv_web/live/group_live/show.ex
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Actions"
|
msgid "Actions"
|
||||||
msgstr "Aktionen"
|
msgstr "Aktionen"
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||||
#: lib/mv_web/live/role_live/index.html.heex
|
|
||||||
#: lib/mv_web/live/role_live/show.ex
|
|
||||||
#: lib/mv_web/live/user_live/index.html.heex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Are you sure?"
|
msgid "Are you sure?"
|
||||||
msgstr "Bist du sicher?"
|
msgstr "Bist du sicher?"
|
||||||
|
|
@ -40,25 +35,14 @@ msgstr "Verbindung wird wiederhergestellt"
|
||||||
msgid "City"
|
msgid "City"
|
||||||
msgstr "Stadt"
|
msgstr "Stadt"
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
|
||||||
#: lib/mv_web/live/group_live/show.ex
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
|
||||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
#: lib/mv_web/live/role_live/index.html.heex
|
|
||||||
#: lib/mv_web/live/user_live/index.html.heex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Delete"
|
msgid "Delete"
|
||||||
msgstr "Löschen"
|
msgstr "Löschen"
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
|
||||||
#: lib/mv_web/live/group_live/index.ex
|
|
||||||
#: lib/mv_web/live/group_live/show.ex
|
|
||||||
#: lib/mv_web/live/member_field_live/index_component.ex
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
|
||||||
#: lib/mv_web/live/role_live/form.ex
|
#: lib/mv_web/live/role_live/form.ex
|
||||||
#: lib/mv_web/live/role_live/index.html.heex
|
|
||||||
#: lib/mv_web/live/user_live/form.ex
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
#: lib/mv_web/live/user_live/index.html.heex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Edit"
|
msgid "Edit"
|
||||||
msgstr "Bearbeiten"
|
msgstr "Bearbeiten"
|
||||||
|
|
@ -110,8 +94,6 @@ msgid "New Member"
|
||||||
msgstr "Neues Mitglied"
|
msgstr "Neues Mitglied"
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
#: lib/mv_web/live/role_live/index.html.heex
|
|
||||||
#: lib/mv_web/live/user_live/index.html.heex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Show"
|
msgid "Show"
|
||||||
msgstr "Anzeigen"
|
msgstr "Anzeigen"
|
||||||
|
|
@ -277,7 +259,6 @@ msgstr "Dein Passwort wurde erfolgreich zurückgesetzt"
|
||||||
#: lib/mv_web/live/member_live/form.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||||
#: lib/mv_web/live/role_live/form.ex
|
|
||||||
#: lib/mv_web/live/user_live/form.ex
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
|
|
@ -491,16 +472,6 @@ msgstr "Passwort"
|
||||||
msgid "Password requirements"
|
msgid "Password requirements"
|
||||||
msgstr "Passwort-Anforderungen"
|
msgstr "Passwort-Anforderungen"
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/index.html.heex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Select all users"
|
|
||||||
msgstr "Alle Benutzer*innen auswählen"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/index.html.heex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Select user"
|
|
||||||
msgstr "Benutzer*in auswählen"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/form.ex
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Set Password"
|
msgid "Set Password"
|
||||||
|
|
@ -616,7 +587,6 @@ msgstr "E-Mail kann nicht aktualisiert werden: Diese E-Mail-Adresse ist bereits
|
||||||
msgid "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account."
|
msgid "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account."
|
||||||
msgstr "Diese E-Mail-Adresse ist bereits mit einem anderen OIDC-Konto verknüpft. Es können nicht mehrere OIDC-Provider mit demselben Konto verknüpft werden."
|
msgstr "Diese E-Mail-Adresse ist bereits mit einem anderen OIDC-Konto verknüpft. Es können nicht mehrere OIDC-Provider mit demselben Konto verknüpft werden."
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#: lib/mv_web/live/member_live/form.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
@ -635,11 +605,6 @@ msgstr[1] "%{count} Mitglieder haben Werte für dieses benutzerdefinierte Feld z
|
||||||
msgid "All custom field values will be permanently deleted when you delete this custom field."
|
msgid "All custom field values will be permanently deleted when you delete this custom field."
|
||||||
msgstr "Alle benutzerdefinierten Feldwerte werden beim Löschen dieses benutzerdefinierten Feldes dauerhaft gelöscht."
|
msgstr "Alle benutzerdefinierten Feldwerte werden beim Löschen dieses benutzerdefinierten Feldes dauerhaft gelöscht."
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Delete Custom Field and All Values"
|
|
||||||
msgstr "Benutzerdefiniertes Feld und alle Werte löschen"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Enter the text above to confirm"
|
msgid "Enter the text above to confirm"
|
||||||
|
|
@ -673,7 +638,6 @@ msgstr "Vereinsdaten"
|
||||||
msgid "Manage global settings for the association."
|
msgid "Manage global settings for the association."
|
||||||
msgstr "Passe übergreifende Einstellungen für den Verein an."
|
msgstr "Passe übergreifende Einstellungen für den Verein an."
|
||||||
|
|
||||||
#: lib/mv_web/live/global_settings_live.ex
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Save Settings"
|
msgid "Save Settings"
|
||||||
|
|
@ -790,19 +754,21 @@ msgstr "Alle"
|
||||||
msgid "Address"
|
msgid "Address"
|
||||||
msgstr "Adresse"
|
msgstr "Adresse"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
#: lib/mv_web/live/group_live/form.ex
|
#: lib/mv_web/live/group_live/form.ex
|
||||||
#: lib/mv_web/live/group_live/show.ex
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
|
#: lib/mv_web/live/member_field_live/form_component.ex
|
||||||
#: lib/mv_web/live/member_live/form.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
|
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||||
|
#: lib/mv_web/live/role_live/form.ex
|
||||||
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
|
#: lib/mv_web/live/user_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Back"
|
msgid "Back"
|
||||||
msgstr "Zurück"
|
msgstr "Zurück"
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/form.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Coming soon"
|
|
||||||
msgstr "Demnächst verfügbar"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/form.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
@ -820,7 +786,6 @@ msgid "Payment Data"
|
||||||
msgstr "Beitragsdaten"
|
msgstr "Beitragsdaten"
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
#: lib/mv_web/live/components/member_filter_component.ex
|
||||||
#: lib/mv_web/live/member_live/form.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Payments"
|
msgid "Payments"
|
||||||
msgstr "Zahlungen"
|
msgstr "Zahlungen"
|
||||||
|
|
@ -834,6 +799,8 @@ msgstr "Persönliche Daten"
|
||||||
#: lib/mv_web/live/group_live/form.ex
|
#: lib/mv_web/live/group_live/form.ex
|
||||||
#: lib/mv_web/live/member_live/form.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||||
|
#: lib/mv_web/live/role_live/form.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Save"
|
msgid "Save"
|
||||||
msgstr "Speichern"
|
msgstr "Speichern"
|
||||||
|
|
@ -851,11 +818,6 @@ msgstr "Mitglied erstellen"
|
||||||
msgid "Amount"
|
msgid "Amount"
|
||||||
msgstr "Betrag"
|
msgstr "Betrag"
|
||||||
|
|
||||||
#: lib/mv_web/live/member_field_live/form_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Back to Settings"
|
|
||||||
msgstr "Zurück zu den Einstellungen"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
@ -981,11 +943,6 @@ msgstr "Unbezahlt"
|
||||||
msgid "Yearly"
|
msgid "Yearly"
|
||||||
msgstr "jährlich"
|
msgstr "jährlich"
|
||||||
|
|
||||||
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Custom Field %{id}"
|
|
||||||
msgstr "Benutzerdefiniertes Feld %{id}"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Last name"
|
msgid "Last name"
|
||||||
|
|
@ -1575,6 +1532,7 @@ msgid "Show/Hide Columns"
|
||||||
msgstr "Spalten ein-/ausblenden"
|
msgstr "Spalten ein-/ausblenden"
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
|
#: lib/mv_web/live/member_field_live/form_component.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Back to settings"
|
msgid "Back to settings"
|
||||||
msgstr "Zurück zu den Einstellungen"
|
msgstr "Zurück zu den Einstellungen"
|
||||||
|
|
@ -1620,22 +1578,11 @@ msgstr "Datenfeld speichern"
|
||||||
msgid "Back to roles list"
|
msgid "Back to roles list"
|
||||||
msgstr "Zurück zur Rollen-Liste"
|
msgstr "Zurück zur Rollen-Liste"
|
||||||
|
|
||||||
#: lib/mv_web/live/role_live/index.html.heex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Cannot delete system role"
|
|
||||||
msgstr "System-Rolle kann nicht gelöscht werden"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/role_live/index.html.heex
|
#: lib/mv_web/live/role_live/index.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Custom"
|
msgid "Custom"
|
||||||
msgstr "Benutzerdefiniert"
|
msgstr "Benutzerdefiniert"
|
||||||
|
|
||||||
#: lib/mv_web/live/role_live/show.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Edit Role"
|
|
||||||
msgstr "Rolle bearbeiten"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/role_live/index.ex
|
|
||||||
#: lib/mv_web/live/role_live/show.ex
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Failed to delete role: %{error}"
|
msgid "Failed to delete role: %{error}"
|
||||||
|
|
@ -1652,7 +1599,6 @@ msgstr "Rollen auflisten"
|
||||||
msgid "Manage user roles and their permission sets."
|
msgid "Manage user roles and their permission sets."
|
||||||
msgstr "Verwalte Benutzer*innen-Rollen und ihre Berechtigungssätze."
|
msgstr "Verwalte Benutzer*innen-Rollen und ihre Berechtigungssätze."
|
||||||
|
|
||||||
#: lib/mv_web/live/role_live/index.ex
|
|
||||||
#: lib/mv_web/live/role_live/show.ex
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first."
|
msgid "Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first."
|
||||||
|
|
@ -1663,11 +1609,6 @@ msgstr "Rolle kann nicht gelöscht werden. %{count} Benutzer*in(nen) sind dieser
|
||||||
msgid "Close sidebar"
|
msgid "Close sidebar"
|
||||||
msgstr "Sidebar schließen"
|
msgstr "Sidebar schließen"
|
||||||
|
|
||||||
#: lib/mv_web/live/role_live/show.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Delete Role"
|
|
||||||
msgstr "Rolle löschen"
|
|
||||||
|
|
||||||
#: lib/mv_web/components/layouts/sidebar.ex
|
#: lib/mv_web/components/layouts/sidebar.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Main navigation"
|
msgid "Main navigation"
|
||||||
|
|
@ -1711,7 +1652,6 @@ msgstr "Profil"
|
||||||
msgid "Role"
|
msgid "Role"
|
||||||
msgstr "Rolle"
|
msgstr "Rolle"
|
||||||
|
|
||||||
#: lib/mv_web/live/role_live/index.ex
|
|
||||||
#: lib/mv_web/live/role_live/show.ex
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Role deleted successfully."
|
msgid "Role deleted successfully."
|
||||||
|
|
@ -1723,7 +1663,6 @@ msgid "Role details and permissions."
|
||||||
msgstr "Rollen-Details und Berechtigungen."
|
msgstr "Rollen-Details und Berechtigungen."
|
||||||
|
|
||||||
#: lib/mv_web/live/role_live/form.ex
|
#: lib/mv_web/live/role_live/form.ex
|
||||||
#: lib/mv_web/live/role_live/index.ex
|
|
||||||
#: lib/mv_web/live/role_live/show.ex
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Role not found."
|
msgid "Role not found."
|
||||||
|
|
@ -1734,11 +1673,6 @@ msgstr "Rolle nicht gefunden."
|
||||||
msgid "Role saved successfully."
|
msgid "Role saved successfully."
|
||||||
msgstr "Rolle erfolgreich gespeichert."
|
msgstr "Rolle erfolgreich gespeichert."
|
||||||
|
|
||||||
#: lib/mv_web/live/role_live/form.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Save Role"
|
|
||||||
msgstr "Rolle speichern"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/role_live/form.ex
|
#: lib/mv_web/live/role_live/form.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Select permission set"
|
msgid "Select permission set"
|
||||||
|
|
@ -1760,12 +1694,6 @@ msgstr "System"
|
||||||
msgid "System Role"
|
msgid "System Role"
|
||||||
msgstr "System-Rolle"
|
msgstr "System-Rolle"
|
||||||
|
|
||||||
#: lib/mv_web/live/role_live/index.html.heex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "System roles cannot be deleted"
|
|
||||||
msgstr "System-Rollen können nicht gelöscht werden"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/role_live/index.ex
|
|
||||||
#: lib/mv_web/live/role_live/show.ex
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "System roles cannot be deleted."
|
msgid "System roles cannot be deleted."
|
||||||
|
|
@ -1842,12 +1770,14 @@ msgstr "Mitgliedsbeitragsart nicht gefunden"
|
||||||
msgid "User %{action} successfully"
|
msgid "User %{action} successfully"
|
||||||
msgstr "Benutzer*in wurde erfolgreich %{action}"
|
msgstr "Benutzer*in wurde erfolgreich %{action}"
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/index.ex
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
|
#: lib/mv_web/live/user_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "User deleted successfully"
|
msgid "User deleted successfully"
|
||||||
msgstr "Benutzer*in erfolgreich gelöscht"
|
msgstr "Benutzer*in erfolgreich gelöscht"
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/index.ex
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
|
#: lib/mv_web/live/user_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "User not found"
|
msgid "User not found"
|
||||||
msgstr "Benutzer*in nicht gefunden"
|
msgstr "Benutzer*in nicht gefunden"
|
||||||
|
|
@ -1858,18 +1788,14 @@ msgstr "Benutzer*in nicht gefunden"
|
||||||
msgid "You do not have permission to access this membership fee type"
|
msgid "You do not have permission to access this membership fee type"
|
||||||
msgstr "Du hast keine Berechtigung, auf diese Mitgliedsbeitragsart zuzugreifen"
|
msgstr "Du hast keine Berechtigung, auf diese Mitgliedsbeitragsart zuzugreifen"
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/index.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "You do not have permission to access this user"
|
|
||||||
msgstr "Du hast keine Berechtigung, auf diese*n Benutzer*in zuzugreifen"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "You do not have permission to delete this membership fee type"
|
msgid "You do not have permission to delete this membership fee type"
|
||||||
msgstr "Du hast keine Berechtigung, diese Mitgliedsbeitragsart zu löschen"
|
msgstr "Du hast keine Berechtigung, diese Mitgliedsbeitragsart zu löschen"
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/index.ex
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
|
#: lib/mv_web/live/user_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "You do not have permission to delete this user"
|
msgid "You do not have permission to delete this user"
|
||||||
msgstr "Du hast keine Berechtigung, diese*n Benutzer*in zu löschen"
|
msgstr "Du hast keine Berechtigung, diese*n Benutzer*in zu löschen"
|
||||||
|
|
@ -1890,22 +1816,20 @@ msgstr "aktualisiert"
|
||||||
msgid "Unknown error"
|
msgid "Unknown error"
|
||||||
msgstr "Unbekannter Fehler"
|
msgstr "Unbekannter Fehler"
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Member deleted successfully"
|
msgid "Member deleted successfully"
|
||||||
msgstr "Mitglied wurde erfolgreich gelöscht"
|
msgstr "Mitglied wurde erfolgreich gelöscht"
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Member not found"
|
msgid "Member not found"
|
||||||
msgstr "Mitglied nicht gefunden"
|
msgstr "Mitglied nicht gefunden"
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
#, elixir-autogen, elixir-format
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
msgid "You do not have permission to access this member"
|
|
||||||
msgstr "Du hast keine Berechtigung, auf dieses Mitglied zuzugreifen"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "You do not have permission to delete this member"
|
msgid "You do not have permission to delete this member"
|
||||||
msgstr "Du hast keine Berechtigung, dieses Mitglied zu löschen"
|
msgstr "Du hast keine Berechtigung, dieses Mitglied zu löschen"
|
||||||
|
|
@ -1990,11 +1914,6 @@ msgstr "Mitgliedsfilter"
|
||||||
msgid "Payment Status"
|
msgid "Payment Status"
|
||||||
msgstr "Bezahlstatus"
|
msgstr "Bezahlstatus"
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Reset"
|
|
||||||
msgstr "Zurücksetzen"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/import_live/components.ex
|
#: lib/mv_web/live/import_live/components.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid " (Field: %{field})"
|
msgid " (Field: %{field})"
|
||||||
|
|
@ -2161,6 +2080,7 @@ msgstr "Gruppe erstellen"
|
||||||
msgid "Delete Group"
|
msgid "Delete Group"
|
||||||
msgstr "Gruppe löschen"
|
msgstr "Gruppe löschen"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/group_live/form.ex
|
||||||
#: lib/mv_web/live/group_live/show.ex
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Delete group"
|
msgid "Delete group"
|
||||||
|
|
@ -2241,11 +2161,6 @@ msgstr[1] "Diese Gruppe hat %{count} Mitglieder. Alle Mitglied-Gruppen-Zuordnung
|
||||||
msgid "To confirm deletion, please enter the group name:"
|
msgid "To confirm deletion, please enter the group name:"
|
||||||
msgstr "Um die Löschung zu bestätigen, gib bitte den Gruppennamen ein:"
|
msgstr "Um die Löschung zu bestätigen, gib bitte den Gruppennamen ein:"
|
||||||
|
|
||||||
#: lib/mv_web/live/group_live/index.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "View"
|
|
||||||
msgstr "Anzeigen"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/group_live/show.ex
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Total: %{count} member"
|
msgid "Total: %{count} member"
|
||||||
|
|
@ -3120,3 +3035,278 @@ msgstr "Nur OIDC-Anmeldung (Passwort-Login ausblenden)"
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button."
|
msgid "When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button."
|
||||||
msgstr "Wenn aktiviert und OIDC konfiguriert ist, zeigt die Anmeldeseite nur den Single-Sign-On-Button."
|
msgstr "Wenn aktiviert und OIDC konfiguriert ist, zeigt die Anmeldeseite nur den Single-Sign-On-Button."
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/member_filter_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Clear filters"
|
||||||
|
msgstr "Filter zurücksetzen“"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/member_filter_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Apply filters"
|
||||||
|
msgstr "Filter auswählen"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Are you sure you want to delete %{name}? This action cannot be undone."
|
||||||
|
msgstr "Möchtest du diese Gruppe wirklich löschen? Die Aktion kann nicht rückgängig gemacht werden."
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
|
#: lib/mv_web/live/group_live/form.ex
|
||||||
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
|
#: lib/mv_web/live/user_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Danger zone"
|
||||||
|
msgstr "Gefahrenzone"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Delete member"
|
||||||
|
msgstr "Mitglied löschen"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Delete member %{name}"
|
||||||
|
msgstr "Mitglied %{name} löschen"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed."
|
||||||
|
msgstr "Das Löschen des Mitglieds kann nicht rückgängig gemacht werden. Alle dazugehörigen Daten (z.B. Mitgliedsbeitragszylen) werden gelöscht."
|
||||||
|
|
||||||
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Edit group"
|
||||||
|
msgstr "Gruppe bearbeiten"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Edit member"
|
||||||
|
msgstr "Mitglied bearbeiten"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Edit role"
|
||||||
|
msgstr "Rolle bearbeiten"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/group_live/index.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Click for group details"
|
||||||
|
msgstr "Klicke für Gruppen-Details"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Click for member details"
|
||||||
|
msgstr "Klicke für Mitglieds-Details"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/role_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Click for role details"
|
||||||
|
msgstr "Klicke für Rollen-Details"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/user_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Click for user details"
|
||||||
|
msgstr "Klicke für Benutzer*innen-Details"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Members table"
|
||||||
|
msgstr "Mitglieder"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Are you sure you want to delete the role %{name}? This action cannot be undone."
|
||||||
|
msgstr "Möchtest du diese Gruppe wirklich löschen? Die Aktion kann nicht rückgängig gemacht werden."
|
||||||
|
|
||||||
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
|
#: lib/mv_web/live/user_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Are you sure you want to delete the user %{email}? This action cannot be undone."
|
||||||
|
msgstr "Möchtest du diese Gruppe wirklich löschen? Die Aktion kann nicht rückgängig gemacht werden."
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
|
#: lib/mv_web/live/member_field_live/index_component.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Click to edit datafield"
|
||||||
|
msgstr "Klicke für Datenfeld-Details"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Delete data field"
|
||||||
|
msgstr "Datenfeld löschen"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Delete data field %{name}"
|
||||||
|
msgstr "Datenfeld %{name} löschen"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/group_live/form.ex
|
||||||
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Delete group %{name}"
|
||||||
|
msgstr "Gruppe %{name} löschen"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Delete role"
|
||||||
|
msgstr "Rolle löschen"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Delete role %{name}"
|
||||||
|
msgstr "Mitglied %{name} löschen"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
|
#: lib/mv_web/live/user_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Delete user"
|
||||||
|
msgstr "Löschen"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
|
#: lib/mv_web/live/user_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Delete user %{email}"
|
||||||
|
msgstr "Benutzer*in %{email} löschen"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/group_live/form.ex
|
||||||
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Deleting this group cannot be undone. All member-group associations will be permanently removed."
|
||||||
|
msgstr "Das Löschen der Gruppe kann nicht rückgängig gemacht werden. Alle Mitglieds-Gruppen Zugehörigkeiten werden gelöscht."
|
||||||
|
|
||||||
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Deleting this role cannot be undone. Users assigned to this role must be reassigned first."
|
||||||
|
msgstr "Das Löschen dieser Rolle kann nicht rückgängig gemacht werden. Benutzer*inen die dieser Rolle zugewiesen wurden, müssen zuerst einer anderen Rolle zugewiesen werden."
|
||||||
|
|
||||||
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
|
#: lib/mv_web/live/user_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Deleting this user cannot be undone. The user account and any linked member association will be affected."
|
||||||
|
msgstr "Das Löschen kann nicht rückgängig gemacht werden. Der Account und Verlinkungen zu Mitgliedern werden entfernt."
|
||||||
|
|
||||||
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
|
#: lib/mv_web/live/user_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "System user cannot be deleted."
|
||||||
|
msgstr "System-Rollen können nicht gelöscht werden."
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Save Name"
|
||||||
|
msgstr "Speichern"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Datafield %{id}"
|
||||||
|
msgstr "Datenfelder"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Delete Datafields and All Values"
|
||||||
|
msgstr "Benutzerdefiniertes Feld und alle Werte löschen"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Deleting this data field cannot be undone. All datafield values for this field will be permanently removed."
|
||||||
|
msgstr "Das Löschen dieses Datenfeldes kann nicht rückgängig gemacht werden. Alle "
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/member_filter_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Individual datafields"
|
||||||
|
msgstr "Individuelle Datenfelder"
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/member_field_live/form_component.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Back to Settings"
|
||||||
|
#~ msgstr "Zurück zu den Einstellungen"
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/role_live/index.html.heex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Cannot delete system role"
|
||||||
|
#~ msgstr "System-Rolle kann nicht gelöscht werden"
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Click for custom field details"
|
||||||
|
#~ msgstr "Klicke für Datenfeld-Details"
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/member_field_live/index_component.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Click for datafield details"
|
||||||
|
#~ msgstr "Klicke für Datenfeld-Details"
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/member_live/form.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Coming soon"
|
||||||
|
#~ msgstr "Demnächst verfügbar"
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/components/field_visibility_dropdown_component.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Custom Field %{id}"
|
||||||
|
#~ msgstr "Benutzerdefiniertes Feld %{id}"
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
|
#~ #: lib/mv_web/live/member_field_live/index_component.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||||
|
#~ msgid "Edit datafield"
|
||||||
|
#~ msgstr "Datenfeld bearbeiten"
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/user_live/index.html.heex
|
||||||
|
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||||
|
#~ msgid "Edit user"
|
||||||
|
#~ msgstr "Benutzer*in bearbeiten"
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/components/member_filter_component.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Reset"
|
||||||
|
#~ msgstr "Zurücksetzen"
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/role_live/show.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Rolle bearbeiten"
|
||||||
|
#~ msgstr "Rolle bearbeiten"
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/role_live/form.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Save Role"
|
||||||
|
#~ msgstr "Rolle speichern"
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/user_live/index.html.heex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Select all users"
|
||||||
|
#~ msgstr "Alle Benutzer*innen auswählen"
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/user_live/index.html.heex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Select user"
|
||||||
|
#~ msgstr "Benutzer*in auswählen"
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/role_live/index.html.heex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "System roles cannot be deleted"
|
||||||
|
#~ msgstr "System-Rollen können nicht gelöscht werden"
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/group_live/index.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "View"
|
||||||
|
#~ msgstr "Anzeigen"
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/member_live/index.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "You do not have permission to access this member"
|
||||||
|
#~ msgstr "Du hast keine Berechtigung, auf dieses Mitglied zuzugreifen"
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/user_live/index.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "You do not have permission to access this user"
|
||||||
|
#~ msgstr "Du hast keine Berechtigung, auf diese*n Benutzer*in zuzugreifen"
|
||||||
|
|
|
||||||
|
|
@ -12,18 +12,13 @@ msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/components/core_components.ex
|
#: lib/mv_web/components/core_components.ex
|
||||||
#: lib/mv_web/live/group_live/index.ex
|
|
||||||
#: lib/mv_web/live/group_live/show.ex
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Actions"
|
msgid "Actions"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||||
#: lib/mv_web/live/role_live/index.html.heex
|
|
||||||
#: lib/mv_web/live/role_live/show.ex
|
|
||||||
#: lib/mv_web/live/user_live/index.html.heex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Are you sure?"
|
msgid "Are you sure?"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -41,25 +36,14 @@ msgstr ""
|
||||||
msgid "City"
|
msgid "City"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
|
||||||
#: lib/mv_web/live/group_live/show.ex
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
|
||||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
#: lib/mv_web/live/role_live/index.html.heex
|
|
||||||
#: lib/mv_web/live/user_live/index.html.heex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Delete"
|
msgid "Delete"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
|
||||||
#: lib/mv_web/live/group_live/index.ex
|
|
||||||
#: lib/mv_web/live/group_live/show.ex
|
|
||||||
#: lib/mv_web/live/member_field_live/index_component.ex
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
|
||||||
#: lib/mv_web/live/role_live/form.ex
|
#: lib/mv_web/live/role_live/form.ex
|
||||||
#: lib/mv_web/live/role_live/index.html.heex
|
|
||||||
#: lib/mv_web/live/user_live/form.ex
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
#: lib/mv_web/live/user_live/index.html.heex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Edit"
|
msgid "Edit"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -111,8 +95,6 @@ msgid "New Member"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
#: lib/mv_web/live/role_live/index.html.heex
|
|
||||||
#: lib/mv_web/live/user_live/index.html.heex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Show"
|
msgid "Show"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -278,7 +260,6 @@ msgstr ""
|
||||||
#: lib/mv_web/live/member_live/form.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||||
#: lib/mv_web/live/role_live/form.ex
|
|
||||||
#: lib/mv_web/live/user_live/form.ex
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
|
|
@ -492,16 +473,6 @@ msgstr ""
|
||||||
msgid "Password requirements"
|
msgid "Password requirements"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/index.html.heex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Select all users"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/index.html.heex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Select user"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/form.ex
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Set Password"
|
msgid "Set Password"
|
||||||
|
|
@ -617,7 +588,6 @@ msgstr ""
|
||||||
msgid "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account."
|
msgid "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#: lib/mv_web/live/member_live/form.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
@ -636,11 +606,6 @@ msgstr[1] ""
|
||||||
msgid "All custom field values will be permanently deleted when you delete this custom field."
|
msgid "All custom field values will be permanently deleted when you delete this custom field."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Delete Custom Field and All Values"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Enter the text above to confirm"
|
msgid "Enter the text above to confirm"
|
||||||
|
|
@ -674,7 +639,6 @@ msgstr ""
|
||||||
msgid "Manage global settings for the association."
|
msgid "Manage global settings for the association."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/global_settings_live.ex
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Save Settings"
|
msgid "Save Settings"
|
||||||
|
|
@ -791,19 +755,21 @@ msgstr ""
|
||||||
msgid "Address"
|
msgid "Address"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
#: lib/mv_web/live/group_live/form.ex
|
#: lib/mv_web/live/group_live/form.ex
|
||||||
#: lib/mv_web/live/group_live/show.ex
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
|
#: lib/mv_web/live/member_field_live/form_component.ex
|
||||||
#: lib/mv_web/live/member_live/form.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
|
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||||
|
#: lib/mv_web/live/role_live/form.ex
|
||||||
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
|
#: lib/mv_web/live/user_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Back"
|
msgid "Back"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/form.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Coming soon"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/form.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
@ -821,7 +787,6 @@ msgid "Payment Data"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
#: lib/mv_web/live/components/member_filter_component.ex
|
||||||
#: lib/mv_web/live/member_live/form.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Payments"
|
msgid "Payments"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -835,6 +800,8 @@ msgstr ""
|
||||||
#: lib/mv_web/live/group_live/form.ex
|
#: lib/mv_web/live/group_live/form.ex
|
||||||
#: lib/mv_web/live/member_live/form.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||||
|
#: lib/mv_web/live/role_live/form.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Save"
|
msgid "Save"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -852,11 +819,6 @@ msgstr ""
|
||||||
msgid "Amount"
|
msgid "Amount"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_field_live/form_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Back to Settings"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
@ -982,11 +944,6 @@ msgstr ""
|
||||||
msgid "Yearly"
|
msgid "Yearly"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Custom Field %{id}"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Last name"
|
msgid "Last name"
|
||||||
|
|
@ -1576,6 +1533,7 @@ msgid "Show/Hide Columns"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
|
#: lib/mv_web/live/member_field_live/form_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Back to settings"
|
msgid "Back to settings"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -1621,22 +1579,11 @@ msgstr ""
|
||||||
msgid "Back to roles list"
|
msgid "Back to roles list"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/role_live/index.html.heex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Cannot delete system role"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/role_live/index.html.heex
|
#: lib/mv_web/live/role_live/index.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Custom"
|
msgid "Custom"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/role_live/show.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Edit Role"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/role_live/index.ex
|
|
||||||
#: lib/mv_web/live/role_live/show.ex
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Failed to delete role: %{error}"
|
msgid "Failed to delete role: %{error}"
|
||||||
|
|
@ -1653,7 +1600,6 @@ msgstr ""
|
||||||
msgid "Manage user roles and their permission sets."
|
msgid "Manage user roles and their permission sets."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/role_live/index.ex
|
|
||||||
#: lib/mv_web/live/role_live/show.ex
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first."
|
msgid "Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first."
|
||||||
|
|
@ -1664,11 +1610,6 @@ msgstr ""
|
||||||
msgid "Close sidebar"
|
msgid "Close sidebar"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/role_live/show.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Delete Role"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/components/layouts/sidebar.ex
|
#: lib/mv_web/components/layouts/sidebar.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Main navigation"
|
msgid "Main navigation"
|
||||||
|
|
@ -1712,7 +1653,6 @@ msgstr ""
|
||||||
msgid "Role"
|
msgid "Role"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/role_live/index.ex
|
|
||||||
#: lib/mv_web/live/role_live/show.ex
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Role deleted successfully."
|
msgid "Role deleted successfully."
|
||||||
|
|
@ -1724,7 +1664,6 @@ msgid "Role details and permissions."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/role_live/form.ex
|
#: lib/mv_web/live/role_live/form.ex
|
||||||
#: lib/mv_web/live/role_live/index.ex
|
|
||||||
#: lib/mv_web/live/role_live/show.ex
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Role not found."
|
msgid "Role not found."
|
||||||
|
|
@ -1735,11 +1674,6 @@ msgstr ""
|
||||||
msgid "Role saved successfully."
|
msgid "Role saved successfully."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/role_live/form.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Save Role"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/role_live/form.ex
|
#: lib/mv_web/live/role_live/form.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Select permission set"
|
msgid "Select permission set"
|
||||||
|
|
@ -1761,12 +1695,6 @@ msgstr ""
|
||||||
msgid "System Role"
|
msgid "System Role"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/role_live/index.html.heex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "System roles cannot be deleted"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/role_live/index.ex
|
|
||||||
#: lib/mv_web/live/role_live/show.ex
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "System roles cannot be deleted."
|
msgid "System roles cannot be deleted."
|
||||||
|
|
@ -1843,12 +1771,14 @@ msgstr ""
|
||||||
msgid "User %{action} successfully"
|
msgid "User %{action} successfully"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/index.ex
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
|
#: lib/mv_web/live/user_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "User deleted successfully"
|
msgid "User deleted successfully"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/index.ex
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
|
#: lib/mv_web/live/user_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "User not found"
|
msgid "User not found"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -1859,18 +1789,14 @@ msgstr ""
|
||||||
msgid "You do not have permission to access this membership fee type"
|
msgid "You do not have permission to access this membership fee type"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/index.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "You do not have permission to access this user"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "You do not have permission to delete this membership fee type"
|
msgid "You do not have permission to delete this membership fee type"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/index.ex
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
|
#: lib/mv_web/live/user_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "You do not have permission to delete this user"
|
msgid "You do not have permission to delete this user"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -1891,22 +1817,20 @@ msgstr ""
|
||||||
msgid "Unknown error"
|
msgid "Unknown error"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Member deleted successfully"
|
msgid "Member deleted successfully"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Member not found"
|
msgid "Member not found"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
#, elixir-autogen, elixir-format
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
msgid "You do not have permission to access this member"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "You do not have permission to delete this member"
|
msgid "You do not have permission to delete this member"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -1991,11 +1915,6 @@ msgstr ""
|
||||||
msgid "Payment Status"
|
msgid "Payment Status"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Reset"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/import_live/components.ex
|
#: lib/mv_web/live/import_live/components.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid " (Field: %{field})"
|
msgid " (Field: %{field})"
|
||||||
|
|
@ -2162,6 +2081,7 @@ msgstr ""
|
||||||
msgid "Delete Group"
|
msgid "Delete Group"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/group_live/form.ex
|
||||||
#: lib/mv_web/live/group_live/show.ex
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Delete group"
|
msgid "Delete group"
|
||||||
|
|
@ -2242,11 +2162,6 @@ msgstr[1] ""
|
||||||
msgid "To confirm deletion, please enter the group name:"
|
msgid "To confirm deletion, please enter the group name:"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/group_live/index.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "View"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/group_live/show.ex
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Total: %{count} member"
|
msgid "Total: %{count} member"
|
||||||
|
|
@ -3115,3 +3030,192 @@ msgstr ""
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button."
|
msgid "When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/member_filter_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Clear filters"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/member_filter_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Apply filters"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Are you sure you want to delete %{name}? This action cannot be undone."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
|
#: lib/mv_web/live/group_live/form.ex
|
||||||
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
|
#: lib/mv_web/live/user_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Danger zone"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Delete member"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Delete member %{name}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Edit group"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Edit member"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Edit role"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/group_live/index.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Click for group details"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Click for member details"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/role_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Click for role details"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/user_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Click for user details"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Members table"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Are you sure you want to delete the role %{name}? This action cannot be undone."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
|
#: lib/mv_web/live/user_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Are you sure you want to delete the user %{email}? This action cannot be undone."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
|
#: lib/mv_web/live/member_field_live/index_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Click to edit datafield"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Delete data field"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Delete data field %{name}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/group_live/form.ex
|
||||||
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Delete group %{name}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Delete role"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Delete role %{name}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
|
#: lib/mv_web/live/user_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Delete user"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
|
#: lib/mv_web/live/user_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Delete user %{email}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/group_live/form.ex
|
||||||
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Deleting this group cannot be undone. All member-group associations will be permanently removed."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Deleting this role cannot be undone. Users assigned to this role must be reassigned first."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
|
#: lib/mv_web/live/user_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Deleting this user cannot be undone. The user account and any linked member association will be affected."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
|
#: lib/mv_web/live/user_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "System user cannot be deleted."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Save Name"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Datafield %{id}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Delete Datafields and All Values"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Deleting this data field cannot be undone. All datafield values for this field will be permanently removed."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/member_filter_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Individual datafields"
|
||||||
|
msgstr ""
|
||||||
|
|
|
||||||
|
|
@ -12,18 +12,13 @@ msgstr ""
|
||||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||||
|
|
||||||
#: lib/mv_web/components/core_components.ex
|
#: lib/mv_web/components/core_components.ex
|
||||||
#: lib/mv_web/live/group_live/index.ex
|
|
||||||
#: lib/mv_web/live/group_live/show.ex
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Actions"
|
msgid "Actions"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||||
#: lib/mv_web/live/role_live/index.html.heex
|
|
||||||
#: lib/mv_web/live/role_live/show.ex
|
|
||||||
#: lib/mv_web/live/user_live/index.html.heex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Are you sure?"
|
msgid "Are you sure?"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -41,25 +36,14 @@ msgstr ""
|
||||||
msgid "City"
|
msgid "City"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
|
||||||
#: lib/mv_web/live/group_live/show.ex
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
|
||||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
#: lib/mv_web/live/role_live/index.html.heex
|
|
||||||
#: lib/mv_web/live/user_live/index.html.heex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Delete"
|
msgid "Delete"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
|
||||||
#: lib/mv_web/live/group_live/index.ex
|
|
||||||
#: lib/mv_web/live/group_live/show.ex
|
|
||||||
#: lib/mv_web/live/member_field_live/index_component.ex
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
|
||||||
#: lib/mv_web/live/role_live/form.ex
|
#: lib/mv_web/live/role_live/form.ex
|
||||||
#: lib/mv_web/live/role_live/index.html.heex
|
|
||||||
#: lib/mv_web/live/user_live/form.ex
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
#: lib/mv_web/live/user_live/index.html.heex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Edit"
|
msgid "Edit"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -111,8 +95,6 @@ msgid "New Member"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
#: lib/mv_web/live/role_live/index.html.heex
|
|
||||||
#: lib/mv_web/live/user_live/index.html.heex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Show"
|
msgid "Show"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -278,7 +260,6 @@ msgstr ""
|
||||||
#: lib/mv_web/live/member_live/form.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||||
#: lib/mv_web/live/role_live/form.ex
|
|
||||||
#: lib/mv_web/live/user_live/form.ex
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
|
|
@ -492,16 +473,6 @@ msgstr ""
|
||||||
msgid "Password requirements"
|
msgid "Password requirements"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/index.html.heex
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
|
||||||
msgid "Select all users"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/index.html.heex
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
|
||||||
msgid "Select user"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/form.ex
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Set Password"
|
msgid "Set Password"
|
||||||
|
|
@ -617,7 +588,6 @@ msgstr ""
|
||||||
msgid "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account."
|
msgid "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#: lib/mv_web/live/member_live/form.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
|
@ -636,11 +606,6 @@ msgstr[1] ""
|
||||||
msgid "All custom field values will be permanently deleted when you delete this custom field."
|
msgid "All custom field values will be permanently deleted when you delete this custom field."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Delete Custom Field and All Values"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Enter the text above to confirm"
|
msgid "Enter the text above to confirm"
|
||||||
|
|
@ -674,7 +639,6 @@ msgstr ""
|
||||||
msgid "Manage global settings for the association."
|
msgid "Manage global settings for the association."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/global_settings_live.ex
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Save Settings"
|
msgid "Save Settings"
|
||||||
|
|
@ -791,19 +755,21 @@ msgstr ""
|
||||||
msgid "Address"
|
msgid "Address"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
#: lib/mv_web/live/group_live/form.ex
|
#: lib/mv_web/live/group_live/form.ex
|
||||||
#: lib/mv_web/live/group_live/show.ex
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
|
#: lib/mv_web/live/member_field_live/form_component.ex
|
||||||
#: lib/mv_web/live/member_live/form.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
|
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||||
|
#: lib/mv_web/live/role_live/form.ex
|
||||||
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
|
#: lib/mv_web/live/user_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Back"
|
msgid "Back"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/form.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Coming soon"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/form.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
@ -821,7 +787,6 @@ msgid "Payment Data"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
#: lib/mv_web/live/components/member_filter_component.ex
|
||||||
#: lib/mv_web/live/member_live/form.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Payments"
|
msgid "Payments"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -835,6 +800,8 @@ msgstr ""
|
||||||
#: lib/mv_web/live/group_live/form.ex
|
#: lib/mv_web/live/group_live/form.ex
|
||||||
#: lib/mv_web/live/member_live/form.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||||
|
#: lib/mv_web/live/role_live/form.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Save"
|
msgid "Save"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -852,11 +819,6 @@ msgstr ""
|
||||||
msgid "Amount"
|
msgid "Amount"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_field_live/form_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Back to Settings"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
@ -982,11 +944,6 @@ msgstr ""
|
||||||
msgid "Yearly"
|
msgid "Yearly"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
|
||||||
msgid "Custom Field %{id}"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Last name"
|
msgid "Last name"
|
||||||
|
|
@ -1576,6 +1533,7 @@ msgid "Show/Hide Columns"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
|
#: lib/mv_web/live/member_field_live/form_component.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Back to settings"
|
msgid "Back to settings"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -1621,22 +1579,11 @@ msgstr ""
|
||||||
msgid "Back to roles list"
|
msgid "Back to roles list"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/role_live/index.html.heex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Cannot delete system role"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/role_live/index.html.heex
|
#: lib/mv_web/live/role_live/index.html.heex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Custom"
|
msgid "Custom"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/role_live/show.ex
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
|
||||||
msgid "Edit Role"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/role_live/index.ex
|
|
||||||
#: lib/mv_web/live/role_live/show.ex
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Failed to delete role: %{error}"
|
msgid "Failed to delete role: %{error}"
|
||||||
|
|
@ -1653,7 +1600,6 @@ msgstr ""
|
||||||
msgid "Manage user roles and their permission sets."
|
msgid "Manage user roles and their permission sets."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/role_live/index.ex
|
|
||||||
#: lib/mv_web/live/role_live/show.ex
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first."
|
msgid "Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first."
|
||||||
|
|
@ -1664,11 +1610,6 @@ msgstr ""
|
||||||
msgid "Close sidebar"
|
msgid "Close sidebar"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/role_live/show.ex
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
|
||||||
msgid "Delete Role"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/components/layouts/sidebar.ex
|
#: lib/mv_web/components/layouts/sidebar.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Main navigation"
|
msgid "Main navigation"
|
||||||
|
|
@ -1712,7 +1653,6 @@ msgstr ""
|
||||||
msgid "Role"
|
msgid "Role"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/role_live/index.ex
|
|
||||||
#: lib/mv_web/live/role_live/show.ex
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Role deleted successfully."
|
msgid "Role deleted successfully."
|
||||||
|
|
@ -1724,7 +1664,6 @@ msgid "Role details and permissions."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/role_live/form.ex
|
#: lib/mv_web/live/role_live/form.ex
|
||||||
#: lib/mv_web/live/role_live/index.ex
|
|
||||||
#: lib/mv_web/live/role_live/show.ex
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Role not found."
|
msgid "Role not found."
|
||||||
|
|
@ -1735,11 +1674,6 @@ msgstr ""
|
||||||
msgid "Role saved successfully."
|
msgid "Role saved successfully."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/role_live/form.ex
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
|
||||||
msgid "Save Role"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/role_live/form.ex
|
#: lib/mv_web/live/role_live/form.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Select permission set"
|
msgid "Select permission set"
|
||||||
|
|
@ -1761,12 +1695,6 @@ msgstr ""
|
||||||
msgid "System Role"
|
msgid "System Role"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/role_live/index.html.heex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "System roles cannot be deleted"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/role_live/index.ex
|
|
||||||
#: lib/mv_web/live/role_live/show.ex
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "System roles cannot be deleted."
|
msgid "System roles cannot be deleted."
|
||||||
|
|
@ -1843,12 +1771,14 @@ msgstr ""
|
||||||
msgid "User %{action} successfully"
|
msgid "User %{action} successfully"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/index.ex
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
|
#: lib/mv_web/live/user_live/show.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "User deleted successfully"
|
msgid "User deleted successfully"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/index.ex
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
|
#: lib/mv_web/live/user_live/show.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "User not found"
|
msgid "User not found"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -1859,18 +1789,14 @@ msgstr ""
|
||||||
msgid "You do not have permission to access this membership fee type"
|
msgid "You do not have permission to access this membership fee type"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/index.ex
|
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
|
||||||
msgid "You do not have permission to access this user"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "You do not have permission to delete this membership fee type"
|
msgid "You do not have permission to delete this membership fee type"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/user_live/index.ex
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
|
#: lib/mv_web/live/user_live/show.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "You do not have permission to delete this user"
|
msgid "You do not have permission to delete this user"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -1891,22 +1817,20 @@ msgstr ""
|
||||||
msgid "Unknown error"
|
msgid "Unknown error"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Member deleted successfully"
|
msgid "Member deleted successfully"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Member not found"
|
msgid "Member not found"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
#, elixir-autogen, elixir-format
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
msgid "You do not have permission to access this member"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "You do not have permission to delete this member"
|
msgid "You do not have permission to delete this member"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -1991,11 +1915,6 @@ msgstr ""
|
||||||
msgid "Payment Status"
|
msgid "Payment Status"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/components/member_filter_component.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Reset"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/import_live/components.ex
|
#: lib/mv_web/live/import_live/components.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid " (Field: %{field})"
|
msgid " (Field: %{field})"
|
||||||
|
|
@ -2162,6 +2081,7 @@ msgstr ""
|
||||||
msgid "Delete Group"
|
msgid "Delete Group"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/group_live/form.ex
|
||||||
#: lib/mv_web/live/group_live/show.ex
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Delete group"
|
msgid "Delete group"
|
||||||
|
|
@ -2242,11 +2162,6 @@ msgstr[1] ""
|
||||||
msgid "To confirm deletion, please enter the group name:"
|
msgid "To confirm deletion, please enter the group name:"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/group_live/index.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "View"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/group_live/show.ex
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Total: %{count} member"
|
msgid "Total: %{count} member"
|
||||||
|
|
@ -3115,3 +3030,278 @@ msgstr ""
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button."
|
msgid "When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/member_filter_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Clear filters"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/member_filter_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Apply filters"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Are you sure you want to delete %{name}? This action cannot be undone."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
|
#: lib/mv_web/live/group_live/form.ex
|
||||||
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
|
#: lib/mv_web/live/user_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Danger zone"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Delete member"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Delete member %{name}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Edit group"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Edit member"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Edit role"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/group_live/index.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Click for group details"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Click for member details"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/role_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Click for role details"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/user_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Click for user details"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Members table"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Are you sure you want to delete the role %{name}? This action cannot be undone."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
|
#: lib/mv_web/live/user_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Are you sure you want to delete the user %{email}? This action cannot be undone."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
|
#: lib/mv_web/live/member_field_live/index_component.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Click to edit datafield"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Delete data field"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Delete data field %{name}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/group_live/form.ex
|
||||||
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Delete group %{name}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Delete role"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Delete role %{name}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
|
#: lib/mv_web/live/user_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Delete user"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
|
#: lib/mv_web/live/user_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Delete user %{email}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/group_live/form.ex
|
||||||
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Deleting this group cannot be undone. All member-group associations will be permanently removed."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Deleting this role cannot be undone. Users assigned to this role must be reassigned first."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
|
#: lib/mv_web/live/user_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Deleting this user cannot be undone. The user account and any linked member association will be affected."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
|
#: lib/mv_web/live/user_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "System user cannot be deleted."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Save Name"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Datafield %{id}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Delete Datafields and All Values"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Deleting this data field cannot be undone. All datafield values for this field will be permanently removed."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/member_filter_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Individual datafields"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/member_field_live/form_component.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Back to Settings"
|
||||||
|
#~ msgstr ""
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/role_live/index.html.heex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Cannot delete system role"
|
||||||
|
#~ msgstr ""
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Click for custom field details"
|
||||||
|
#~ msgstr ""
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/member_field_live/index_component.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Click for datafield details"
|
||||||
|
#~ msgstr ""
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/member_live/form.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Coming soon"
|
||||||
|
#~ msgstr ""
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/components/field_visibility_dropdown_component.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||||
|
#~ msgid "Custom Field %{id}"
|
||||||
|
#~ msgstr ""
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
|
#~ #: lib/mv_web/live/member_field_live/index_component.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||||
|
#~ msgid "Edit datafield"
|
||||||
|
#~ msgstr ""
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/user_live/index.html.heex
|
||||||
|
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||||
|
#~ msgid "Edit user"
|
||||||
|
#~ msgstr ""
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/components/member_filter_component.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Reset"
|
||||||
|
#~ msgstr ""
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/role_live/show.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Rolle bearbeiten"
|
||||||
|
#~ msgstr ""
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/role_live/form.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||||
|
#~ msgid "Save Role"
|
||||||
|
#~ msgstr ""
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/user_live/index.html.heex
|
||||||
|
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||||
|
#~ msgid "Select all users"
|
||||||
|
#~ msgstr ""
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/user_live/index.html.heex
|
||||||
|
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||||
|
#~ msgid "Select user"
|
||||||
|
#~ msgstr ""
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/role_live/index.html.heex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "System roles cannot be deleted"
|
||||||
|
#~ msgstr ""
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/group_live/index.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "View"
|
||||||
|
#~ msgstr ""
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/member_live/index.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "You do not have permission to access this member"
|
||||||
|
#~ msgstr ""
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/user_live/index.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||||
|
#~ msgid "You do not have permission to access this user"
|
||||||
|
#~ msgstr ""
|
||||||
|
|
|
||||||
154
test/mv_web/components/core_components_table_test.exs
Normal file
154
test/mv_web/components/core_components_table_test.exs
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
defmodule MvWeb.Components.CoreComponentsTableTest do
|
||||||
|
@moduledoc """
|
||||||
|
Tests for the CoreComponents table: row hover/focus and selected styling.
|
||||||
|
"""
|
||||||
|
use MvWeb.ConnCase, async: true
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
alias MvWeb.CoreComponents
|
||||||
|
|
||||||
|
describe "table row_click styling" do
|
||||||
|
test "when row_click is set, table rows have hover and focus-within ring classes" do
|
||||||
|
rows = [%{id: "1", name: "Alice"}, %{id: "2", name: "Bob"}]
|
||||||
|
|
||||||
|
assigns = %{
|
||||||
|
id: "test-table",
|
||||||
|
rows: rows,
|
||||||
|
row_id: fn r -> "row-#{r.id}" end,
|
||||||
|
row_click: fn _ -> nil end,
|
||||||
|
row_item: &Function.identity/1,
|
||||||
|
col: [
|
||||||
|
%{
|
||||||
|
__slot__: :col,
|
||||||
|
label: "Name",
|
||||||
|
inner_block: fn _socket, item -> [item[:name] || item["name"] || ""] end
|
||||||
|
}
|
||||||
|
],
|
||||||
|
dynamic_cols: [],
|
||||||
|
action: []
|
||||||
|
}
|
||||||
|
|
||||||
|
html = render_component(&CoreComponents.table/1, assigns)
|
||||||
|
|
||||||
|
assert html =~ "hover:ring-2"
|
||||||
|
assert html =~ "focus-within:ring-2"
|
||||||
|
assert html =~ "hover:ring-base-content/10"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "when row_click is nil, table rows do not have hover ring classes" do
|
||||||
|
rows = [%{id: "1", name: "Alice"}]
|
||||||
|
|
||||||
|
assigns = %{
|
||||||
|
id: "test-table",
|
||||||
|
rows: rows,
|
||||||
|
row_id: fn r -> "row-#{r.id}" end,
|
||||||
|
row_click: nil,
|
||||||
|
row_item: &Function.identity/1,
|
||||||
|
col: [
|
||||||
|
%{
|
||||||
|
__slot__: :col,
|
||||||
|
label: "Name",
|
||||||
|
inner_block: fn _socket, item -> [item[:name] || ""] end
|
||||||
|
}
|
||||||
|
],
|
||||||
|
dynamic_cols: [],
|
||||||
|
action: []
|
||||||
|
}
|
||||||
|
|
||||||
|
html = render_component(&CoreComponents.table/1, assigns)
|
||||||
|
|
||||||
|
refute html =~ "hover:ring-2"
|
||||||
|
refute html =~ "focus-within:ring-2"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "table selected_row_id styling" do
|
||||||
|
test "when selected_row_id matches a row id, that row has data-selected and ring-primary" do
|
||||||
|
rows = [%{id: "one", name: "Alice"}, %{id: "two", name: "Bob"}]
|
||||||
|
|
||||||
|
assigns = %{
|
||||||
|
id: "test-table",
|
||||||
|
rows: rows,
|
||||||
|
row_id: fn r -> "row-#{r.id}" end,
|
||||||
|
row_click: fn _ -> nil end,
|
||||||
|
selected_row_id: "two",
|
||||||
|
row_item: &Function.identity/1,
|
||||||
|
col: [
|
||||||
|
%{
|
||||||
|
__slot__: :col,
|
||||||
|
label: "Name",
|
||||||
|
inner_block: fn _socket, item -> [item[:name] || ""] end
|
||||||
|
}
|
||||||
|
],
|
||||||
|
dynamic_cols: [],
|
||||||
|
action: []
|
||||||
|
}
|
||||||
|
|
||||||
|
html = render_component(&CoreComponents.table/1, assigns)
|
||||||
|
|
||||||
|
assert html =~ ~s(id="row-two")
|
||||||
|
assert html =~ ~s(data-selected="true")
|
||||||
|
assert html =~ "ring-primary"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "when selected_row_id is nil, no row has data-selected" do
|
||||||
|
rows = [%{id: "1", name: "Alice"}]
|
||||||
|
|
||||||
|
assigns = %{
|
||||||
|
id: "test-table",
|
||||||
|
rows: rows,
|
||||||
|
row_id: fn r -> "row-#{r.id}" end,
|
||||||
|
row_click: nil,
|
||||||
|
selected_row_id: nil,
|
||||||
|
row_item: &Function.identity/1,
|
||||||
|
col: [
|
||||||
|
%{
|
||||||
|
__slot__: :col,
|
||||||
|
label: "Name",
|
||||||
|
inner_block: fn _socket, item -> [item[:name] || ""] end
|
||||||
|
}
|
||||||
|
],
|
||||||
|
dynamic_cols: [],
|
||||||
|
action: []
|
||||||
|
}
|
||||||
|
|
||||||
|
html = render_component(&CoreComponents.table/1, assigns)
|
||||||
|
|
||||||
|
refute html =~ ~s(data-selected="true")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "when row_selected? is set, multiple rows can have data-selected and ring-primary" do
|
||||||
|
rows = [%{id: "a", name: "Alice"}, %{id: "b", name: "Bob"}, %{id: "c", name: "Claire"}]
|
||||||
|
selected_ids = MapSet.new(["a", "c"])
|
||||||
|
|
||||||
|
assigns = %{
|
||||||
|
id: "test-table",
|
||||||
|
rows: rows,
|
||||||
|
row_id: fn r -> "row-#{r.id}" end,
|
||||||
|
row_click: fn _ -> nil end,
|
||||||
|
row_selected?: fn item -> MapSet.member?(selected_ids, item.id) end,
|
||||||
|
row_item: &Function.identity/1,
|
||||||
|
col: [
|
||||||
|
%{
|
||||||
|
__slot__: :col,
|
||||||
|
label: "Name",
|
||||||
|
inner_block: fn _socket, item -> [item[:name] || ""] end
|
||||||
|
}
|
||||||
|
],
|
||||||
|
dynamic_cols: [],
|
||||||
|
action: []
|
||||||
|
}
|
||||||
|
|
||||||
|
html = render_component(&CoreComponents.table/1, assigns)
|
||||||
|
|
||||||
|
# Two rows selected (a and c), one not (b)
|
||||||
|
assert html =~ ~s(id="row-a")
|
||||||
|
assert html =~ ~s(id="row-b")
|
||||||
|
assert html =~ ~s(id="row-c")
|
||||||
|
# data-selected appears twice (for row a and row c)
|
||||||
|
assert String.contains?(html, ~s(data-selected="true"))
|
||||||
|
assert html =~ "ring-primary"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -46,6 +46,17 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|
||||||
%{conn: conn, user: user_with_role}
|
%{conn: conn, user: user_with_role}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Delete is in the edit form (FormComponent); open form by clicking the name cell (unique td with phx-click)
|
||||||
|
defp open_delete_modal(view, custom_field) do
|
||||||
|
view
|
||||||
|
|> element("tr#custom_fields-#{custom_field.id} td", custom_field.name)
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
|
view
|
||||||
|
|> element("[data-testid=custom-field-delete]")
|
||||||
|
|> render_click()
|
||||||
|
end
|
||||||
|
|
||||||
describe "delete button and modal" do
|
describe "delete button and modal" do
|
||||||
test "opens modal with correct member count when delete is clicked", %{conn: conn} do
|
test "opens modal with correct member count when delete is clicked", %{conn: conn} do
|
||||||
{:ok, member} = create_member()
|
{:ok, member} = create_member()
|
||||||
|
|
@ -55,11 +66,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|
||||||
create_custom_field_value(member, custom_field, "test")
|
create_custom_field_value(member, custom_field, "test")
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/admin/datafields")
|
{:ok, view, _html} = live(conn, ~p"/admin/datafields")
|
||||||
|
open_delete_modal(view, custom_field)
|
||||||
# Click delete button - find the delete link within the component
|
|
||||||
view
|
|
||||||
|> element("#custom-fields-component a", "Delete")
|
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
# Modal should be visible
|
# Modal should be visible
|
||||||
assert has_element?(view, "#delete-custom-field-modal")
|
assert has_element?(view, "#delete-custom-field-modal")
|
||||||
|
|
@ -81,23 +88,17 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|
||||||
create_custom_field_value(member2, custom_field, "test2")
|
create_custom_field_value(member2, custom_field, "test2")
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/admin/datafields")
|
{:ok, view, _html} = live(conn, ~p"/admin/datafields")
|
||||||
|
open_delete_modal(view, custom_field)
|
||||||
view
|
|
||||||
|> element("#custom-fields-component a", "Delete")
|
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
# Should show plural form
|
# Should show plural form
|
||||||
assert render(view) =~ "2 members have values assigned for this custom field"
|
assert render(view) =~ "2 members have values assigned for this custom field"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "shows 0 members for custom field without values", %{conn: conn} do
|
test "shows 0 members for custom field without values", %{conn: conn} do
|
||||||
{:ok, _custom_field} = create_custom_field("test_field", :string)
|
{:ok, custom_field} = create_custom_field("test_field", :string)
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/admin/datafields")
|
{:ok, view, _html} = live(conn, ~p"/admin/datafields")
|
||||||
|
open_delete_modal(view, custom_field)
|
||||||
view
|
|
||||||
|> element("#custom-fields-component a", "Delete")
|
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
# Should show 0 members
|
# Should show 0 members
|
||||||
assert render(view) =~ "0 members have values assigned for this custom field"
|
assert render(view) =~ "0 members have values assigned for this custom field"
|
||||||
|
|
@ -109,10 +110,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|
||||||
{:ok, custom_field} = create_custom_field("test_field", :string)
|
{:ok, custom_field} = create_custom_field("test_field", :string)
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/admin/datafields")
|
{:ok, view, _html} = live(conn, ~p"/admin/datafields")
|
||||||
|
open_delete_modal(view, custom_field)
|
||||||
view
|
|
||||||
|> element("#custom-fields-component a", "Delete")
|
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
# Type in slug input - use element to find the form with phx-target
|
# Type in slug input - use element to find the form with phx-target
|
||||||
view
|
view
|
||||||
|
|
@ -124,13 +122,10 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "delete button is disabled when slug doesn't match", %{conn: conn} do
|
test "delete button is disabled when slug doesn't match", %{conn: conn} do
|
||||||
{:ok, _custom_field} = create_custom_field("test_field", :string)
|
{:ok, custom_field} = create_custom_field("test_field", :string)
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/admin/datafields")
|
{:ok, view, _html} = live(conn, ~p"/admin/datafields")
|
||||||
|
open_delete_modal(view, custom_field)
|
||||||
view
|
|
||||||
|> element("#custom-fields-component a", "Delete")
|
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
# Type wrong slug - use element to find the form with phx-target
|
# Type wrong slug - use element to find the form with phx-target
|
||||||
view
|
view
|
||||||
|
|
@ -149,11 +144,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|
||||||
{:ok, custom_field_value} = create_custom_field_value(member, custom_field, "test")
|
{:ok, custom_field_value} = create_custom_field_value(member, custom_field, "test")
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/admin/datafields")
|
{:ok, view, _html} = live(conn, ~p"/admin/datafields")
|
||||||
|
open_delete_modal(view, custom_field)
|
||||||
# Open modal
|
|
||||||
view
|
|
||||||
|> element("#custom-fields-component a", "Delete")
|
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
# Enter correct slug - use element to find the form with phx-target
|
# Enter correct slug - use element to find the form with phx-target
|
||||||
view
|
view
|
||||||
|
|
@ -162,7 +153,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|
||||||
|
|
||||||
# Click confirm
|
# Click confirm
|
||||||
view
|
view
|
||||||
|> element("#delete-custom-field-modal button", "Delete Custom Field and All Values")
|
|> element("#delete-custom-field-modal button", "Delete Datafields and All Values")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Should show success message
|
# Should show success message
|
||||||
|
|
@ -186,10 +177,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|
||||||
{:ok, custom_field} = create_custom_field("test_field", :string)
|
{:ok, custom_field} = create_custom_field("test_field", :string)
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/admin/datafields")
|
{:ok, view, _html} = live(conn, ~p"/admin/datafields")
|
||||||
|
open_delete_modal(view, custom_field)
|
||||||
view
|
|
||||||
|> element("#custom-fields-component a", "Delete")
|
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
# Enter wrong slug - use element to find the form with phx-target
|
# Enter wrong slug - use element to find the form with phx-target
|
||||||
view
|
view
|
||||||
|
|
@ -210,10 +198,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|
||||||
{:ok, custom_field} = create_custom_field("test_field", :string)
|
{:ok, custom_field} = create_custom_field("test_field", :string)
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/admin/datafields")
|
{:ok, view, _html} = live(conn, ~p"/admin/datafields")
|
||||||
|
open_delete_modal(view, custom_field)
|
||||||
view
|
|
||||||
|> element("#custom-fields-component a", "Delete")
|
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
# Modal should be visible
|
# Modal should be visible
|
||||||
assert has_element?(view, "#delete-custom-field-modal")
|
assert has_element?(view, "#delete-custom-field-modal")
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ defmodule MvWeb.MemberLiveAuthorizationTest do
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
|
# Index table has no Edit/Delete per row (only sr-only Show link); ensure they are not present
|
||||||
refute has_element?(view, "#row-#{member.id} [data-testid=member-edit]")
|
refute has_element?(view, "#row-#{member.id} [data-testid=member-edit]")
|
||||||
refute has_element?(view, "#row-#{member.id} [data-testid=member-delete]")
|
refute has_element?(view, "#row-#{member.id} [data-testid=member-delete]")
|
||||||
end
|
end
|
||||||
|
|
@ -31,17 +32,18 @@ defmodule MvWeb.MemberLiveAuthorizationTest do
|
||||||
|
|
||||||
describe "Member Index - Kassenwart (normal_user)" do
|
describe "Member Index - Kassenwart (normal_user)" do
|
||||||
@tag role: :normal_user
|
@tag role: :normal_user
|
||||||
test "sees New Member and Edit buttons", %{conn: conn} do
|
test "sees New Member and Show link in row", %{conn: conn} do
|
||||||
member = Fixtures.member_fixture()
|
member = Fixtures.member_fixture()
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
assert has_element?(view, "[data-testid=member-new]")
|
assert has_element?(view, "[data-testid=member-new]")
|
||||||
assert has_element?(view, "#row-#{member.id} [data-testid=member-edit]")
|
# Index table action column has sr-only Show link only (Edit is on member show page)
|
||||||
|
assert has_element?(view, "#row-#{member.id} [data-testid=member-show-link]")
|
||||||
end
|
end
|
||||||
|
|
||||||
@tag role: :normal_user
|
@tag role: :normal_user
|
||||||
test "does not see Delete button", %{conn: conn} do
|
test "does not see Delete button in table", %{conn: conn} do
|
||||||
member = Fixtures.member_fixture()
|
member = Fixtures.member_fixture()
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
@ -52,14 +54,14 @@ defmodule MvWeb.MemberLiveAuthorizationTest do
|
||||||
|
|
||||||
describe "Member Index - Admin" do
|
describe "Member Index - Admin" do
|
||||||
@tag role: :admin
|
@tag role: :admin
|
||||||
test "sees New Member, Edit and Delete buttons", %{conn: conn} do
|
test "sees New Member and Show link in row", %{conn: conn} do
|
||||||
member = Fixtures.member_fixture()
|
member = Fixtures.member_fixture()
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
assert has_element?(view, "[data-testid=member-new]")
|
assert has_element?(view, "[data-testid=member-new]")
|
||||||
assert has_element?(view, "#row-#{member.id} [data-testid=member-edit]")
|
# Index table action column has sr-only Show link only (Edit/Delete are on member show page)
|
||||||
assert has_element?(view, "#row-#{member.id} [data-testid=member-delete]")
|
assert has_element?(view, "#row-#{member.id} [data-testid=member-show-link]")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -138,7 +138,7 @@ defmodule MvWeb.RoleLiveTest do
|
||||||
assert html =~ "System Role" || html =~ "system"
|
assert html =~ "System Role" || html =~ "system"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "delete button disabled for system roles", %{conn: conn, actor: actor} do
|
test "delete button not shown for system roles", %{conn: conn, actor: actor} do
|
||||||
system_role =
|
system_role =
|
||||||
Role
|
Role
|
||||||
|> Ash.Changeset.for_create(:create_role, %{
|
|> Ash.Changeset.for_create(:create_role, %{
|
||||||
|
|
@ -148,28 +148,19 @@ defmodule MvWeb.RoleLiveTest do
|
||||||
|> Ash.Changeset.force_change_attribute(:is_system_role, true)
|
|> Ash.Changeset.force_change_attribute(:is_system_role, true)
|
||||||
|> Ash.create!(actor: actor)
|
|> Ash.create!(actor: actor)
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/admin/roles")
|
{:ok, view, _html} = live(conn, "/admin/roles/#{system_role.id}")
|
||||||
|
|
||||||
assert has_element?(
|
# Danger zone (and delete button) is not rendered for system roles
|
||||||
view,
|
refute has_element?(view, "[data-testid=role-delete]")
|
||||||
"button[phx-click='delete'][phx-value-id='#{system_role.id}'][disabled]"
|
|
||||||
) ||
|
|
||||||
not has_element?(
|
|
||||||
view,
|
|
||||||
"button[phx-click='delete'][phx-value-id='#{system_role.id}']"
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "delete button enabled for non-system roles", %{conn: conn} do
|
test "delete button enabled for non-system roles", %{conn: conn} do
|
||||||
role = create_role()
|
role = create_role()
|
||||||
|
|
||||||
{:ok, view, html} = live(conn, "/admin/roles")
|
{:ok, view, _html} = live(conn, "/admin/roles/#{role.id}")
|
||||||
|
|
||||||
# Delete is a link with phx-click containing delete event
|
# Delete is on show page (Danger zone)
|
||||||
# Check if delete link exists in HTML (phx-click contains delete and role id)
|
assert has_element?(view, "[data-testid=role-delete]")
|
||||||
assert (html =~ "phx-click" && html =~ "delete" && html =~ role.id) ||
|
|
||||||
has_element?(view, "a[phx-click*='delete'][phx-value-id='#{role.id}']") ||
|
|
||||||
has_element?(view, "a[aria-label='Delete role']")
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "new role button navigates to form", %{conn: conn} do
|
test "new role button navigates to form", %{conn: conn} do
|
||||||
|
|
@ -393,21 +384,21 @@ defmodule MvWeb.RoleLiveTest do
|
||||||
test "deletes non-system role", %{conn: conn, actor: actor} do
|
test "deletes non-system role", %{conn: conn, actor: actor} do
|
||||||
role = create_role()
|
role = create_role()
|
||||||
|
|
||||||
{:ok, view, html} = live(conn, "/admin/roles")
|
{:ok, view, _html} = live(conn, "/admin/roles/#{role.id}")
|
||||||
|
|
||||||
# Delete is a link - JS.push creates phx-click with value containing id
|
# Delete from Danger zone on show page
|
||||||
# Verify the role id is in the HTML (in phx-click value)
|
view
|
||||||
assert html =~ role.id
|
|> element("[data-testid=role-delete]")
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
# Send delete event directly to avoid selector issues with multiple delete buttons
|
assert_redirect(view, "/admin/roles")
|
||||||
render_click(view, "delete", %{"id" => role.id})
|
|
||||||
|
|
||||||
# Verify deletion by checking database
|
# Verify deletion by checking database
|
||||||
assert {:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{}]}} =
|
assert {:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{}]}} =
|
||||||
Authorization.get_role(role.id, actor: actor)
|
Authorization.get_role(role.id, actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "fails to delete system role with error message", %{conn: conn, actor: actor} do
|
test "system role has no delete button and cannot be deleted", %{conn: conn, actor: actor} do
|
||||||
system_role =
|
system_role =
|
||||||
Role
|
Role
|
||||||
|> Ash.Changeset.for_create(:create_role, %{
|
|> Ash.Changeset.for_create(:create_role, %{
|
||||||
|
|
@ -417,19 +408,12 @@ defmodule MvWeb.RoleLiveTest do
|
||||||
|> Ash.Changeset.force_change_attribute(:is_system_role, true)
|
|> Ash.Changeset.force_change_attribute(:is_system_role, true)
|
||||||
|> Ash.create!(actor: actor)
|
|> Ash.create!(actor: actor)
|
||||||
|
|
||||||
{:ok, view, html} = live(conn, "/admin/roles")
|
{:ok, view, _html} = live(conn, "/admin/roles/#{system_role.id}")
|
||||||
|
|
||||||
# System role delete button should be disabled
|
# Danger zone is not rendered for system roles (no delete button)
|
||||||
assert html =~ "disabled" || html =~ "cursor-not-allowed" ||
|
refute has_element?(view, "[data-testid=role-delete]")
|
||||||
html =~ "System roles cannot be deleted"
|
|
||||||
|
|
||||||
# Try to delete via event (backend check)
|
# Role still exists
|
||||||
render_click(view, "delete", %{"id" => system_role.id})
|
|
||||||
|
|
||||||
# Should show error message
|
|
||||||
assert render(view) =~ "System roles cannot be deleted"
|
|
||||||
|
|
||||||
# Role should still exist
|
|
||||||
{:ok, _role} = Authorization.get_role(system_role.id, actor: actor)
|
{:ok, _role} = Authorization.get_role(system_role.id, actor: actor)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,16 @@ defmodule MvWeb.UserLiveAuthorizationTest do
|
||||||
|
|
||||||
describe "User Index - Admin" do
|
describe "User Index - Admin" do
|
||||||
@tag role: :admin
|
@tag role: :admin
|
||||||
test "sees New User, Edit and Delete buttons", %{conn: conn} do
|
test "sees New User button; Edit and Delete are on show page", %{conn: conn} do
|
||||||
user = Fixtures.user_with_role_fixture("admin")
|
user = Fixtures.user_with_role_fixture("admin")
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/users")
|
{:ok, index_view, _html} = live(conn, "/users")
|
||||||
|
assert has_element?(index_view, "[data-testid=user-new]")
|
||||||
|
|
||||||
assert has_element?(view, "[data-testid=user-new]")
|
# Edit and Delete are on user show page (Danger zone), not on index
|
||||||
assert has_element?(view, "#row-#{user.id} [data-testid=user-edit]")
|
{:ok, show_view, _html} = live(conn, "/users/#{user.id}")
|
||||||
assert has_element?(view, "#row-#{user.id} [data-testid=user-delete]")
|
assert has_element?(show_view, "[data-testid=user-edit]")
|
||||||
|
assert has_element?(show_view, "[data-testid=user-delete]")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,85 @@ defmodule MvWeb.MemberLive.FormErrorHandlingTest do
|
||||||
Tests for error handling in the member form, specifically flash message display.
|
Tests for error handling in the member form, specifically flash message display.
|
||||||
"""
|
"""
|
||||||
use MvWeb.ConnCase, async: false
|
use MvWeb.ConnCase, async: false
|
||||||
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
import Phoenix.LiveViewTest
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
|
describe "danger zone on edit" do
|
||||||
|
@tag :ui
|
||||||
|
test "edit form shows Danger zone and delete button when user can destroy member", %{
|
||||||
|
conn: conn
|
||||||
|
} do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
{:ok, member} =
|
||||||
|
Mv.Membership.create_member(
|
||||||
|
%{first_name: "Delete", last_name: "FromEdit", email: "delete.from.edit@example.com"},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, html} = live(conn, ~p"/members/#{member}/edit")
|
||||||
|
|
||||||
|
assert html =~ gettext("Danger zone")
|
||||||
|
assert has_element?(view, "[data-testid='member-delete']")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "delete event from edit form removes member and redirects to /members", %{conn: conn} do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
{:ok, member} =
|
||||||
|
Mv.Membership.create_member(
|
||||||
|
%{
|
||||||
|
first_name: "ToDelete",
|
||||||
|
last_name: "FromForm",
|
||||||
|
email: "todelete.from.form.#{System.unique_integer([:positive])}@example.com"
|
||||||
|
},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members/#{member}/edit")
|
||||||
|
|
||||||
|
view
|
||||||
|
|> render_click("delete", %{"id" => member.id})
|
||||||
|
|
||||||
|
assert_redirect(view, ~p"/members")
|
||||||
|
|
||||||
|
refute Mv.Membership.Member
|
||||||
|
|> Ash.Query.filter(id == ^member.id)
|
||||||
|
|> Ash.exists?()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "tab visibility" do
|
||||||
|
@tag :ui
|
||||||
|
test "Payments tab is not visible on new member form", %{conn: conn} do
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, _view, html} = live(conn, "/members/new")
|
||||||
|
|
||||||
|
refute html =~ gettext("Payments")
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag :ui
|
||||||
|
test "Payments tab is not visible on edit member form", %{conn: conn} do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
{:ok, member} =
|
||||||
|
Mv.Membership.create_member(
|
||||||
|
%{first_name: "Edit", last_name: "Member", email: "edit@example.com"},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/members/#{member}/edit")
|
||||||
|
|
||||||
|
refute html =~ gettext("Payments")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "error handling - flash messages" do
|
describe "error handling - flash messages" do
|
||||||
setup do
|
setup do
|
||||||
{:ok, settings} = Mv.Membership.get_settings()
|
{:ok, settings} = Mv.Membership.get_settings()
|
||||||
|
|
|
||||||
|
|
@ -107,9 +107,9 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
# Toggle to current cycle (use the button in the header, not the one in the column)
|
# Toggle to current cycle (use the button in the header)
|
||||||
view
|
view
|
||||||
|> element("button[phx-click='toggle_cycle_view'].btn.gap-2")
|
|> element("[data-testid=toggle-cycle-view]")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
html = render(view)
|
html = render(view)
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,35 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
|> Ash.create!(actor: actor)
|
|> Ash.create!(actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "desktop layout: scroll container and sticky table header" do
|
||||||
|
@describetag :ui
|
||||||
|
|
||||||
|
test "header and filters are outside scroll container; table is in scroll container with lg:max-h and lg:overflow-auto",
|
||||||
|
%{conn: conn} do
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
assert html =~ ~r/data-testid="members-table-scroll"/
|
||||||
|
# Scroll container has lg: overflow and max-height for desktop-only scroll
|
||||||
|
assert html =~ "lg:overflow-auto"
|
||||||
|
assert html =~ "lg:max-h-[calc(100vh-14rem)]"
|
||||||
|
|
||||||
|
# Header (page title) is present and not inside the scroll container (scroll container comes after filters)
|
||||||
|
assert html =~ "Members"
|
||||||
|
assert html =~ "id=\"members\""
|
||||||
|
end
|
||||||
|
|
||||||
|
test "table thead has sticky classes on desktop when sticky_header is set", %{conn: conn} do
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
# CoreComponents table with sticky_header adds lg:sticky lg:top-0 bg-base-100 z-10 to th
|
||||||
|
assert html =~ "lg:sticky"
|
||||||
|
assert html =~ "lg:top-0"
|
||||||
|
assert html =~ "bg-base-100"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "translations" do
|
describe "translations" do
|
||||||
@describetag :ui
|
@describetag :ui
|
||||||
|
|
||||||
|
|
@ -267,36 +296,80 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
assert is_list(state.socket.assigns.members)
|
assert is_list(state.socket.assigns.members)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "can delete a member without error", %{conn: conn} do
|
@tag :ui
|
||||||
|
test "member index does not render Edit or Delete actions", %{conn: conn} do
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
# Create a test member first
|
{:ok, _member} =
|
||||||
{:ok, member} =
|
|
||||||
Mv.Membership.create_member(
|
Mv.Membership.create_member(
|
||||||
%{
|
%{first_name: "Test", last_name: "User", email: "test@example.com"},
|
||||||
first_name: "Test",
|
|
||||||
last_name: "User",
|
|
||||||
email: "test@example.com"
|
|
||||||
},
|
|
||||||
actor: system_actor
|
actor: system_actor
|
||||||
)
|
)
|
||||||
|
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, index_view, _html} = live(conn, "/members")
|
{:ok, view, html} = live(conn, "/members")
|
||||||
|
|
||||||
# Verify the member is displayed
|
refute has_element?(view, "[data-testid='member-edit']")
|
||||||
assert has_element?(index_view, "#members", "Test User")
|
refute html =~ ~s(data-testid="member-delete")
|
||||||
|
end
|
||||||
|
|
||||||
# Click the delete link for this member
|
@tag :ui
|
||||||
index_view
|
test "row click navigates to member show", %{conn: conn} do
|
||||||
|> element("a", "Delete")
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
{:ok, member} =
|
||||||
|
Mv.Membership.create_member(
|
||||||
|
%{first_name: "Row", last_name: "Click", email: "rowclick@example.com"},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
|
# Click a data cell (e.g. second column = first name) to trigger row navigation
|
||||||
|
view
|
||||||
|
|> element("#row-#{member.id} td:nth-child(2)")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Verify the member is no longer displayed
|
assert_redirect(view, ~p"/members/#{member}")
|
||||||
refute has_element?(index_view, "#members", "Test User")
|
end
|
||||||
|
|
||||||
# Verify the member was actually deleted from the database
|
describe "table row outline (hover and selected)" do
|
||||||
assert not (Mv.Membership.Member |> Ash.Query.filter(id == ^member.id) |> Ash.exists?())
|
@describetag :ui
|
||||||
|
|
||||||
|
test "clickable rows have hover and focus-within ring classes", %{conn: conn} do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
{:ok, _member} =
|
||||||
|
Mv.Membership.create_member(
|
||||||
|
%{first_name: "Hover", last_name: "Test", email: "hover@example.com"},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, _view, html} = live(conn, "/members")
|
||||||
|
|
||||||
|
# CoreComponents table adds hover and focus-within ring when row_click is set
|
||||||
|
assert html =~ "hover:ring-2"
|
||||||
|
assert html =~ "focus-within:ring-2"
|
||||||
|
assert html =~ "hover:ring-base-content/10"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "selected outline only from checkbox selection, not from highlight param", %{conn: conn} do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
{:ok, member} =
|
||||||
|
Mv.Membership.create_member(
|
||||||
|
%{first_name: "Highlight", last_name: "Only", email: "highlight@example.com"},
|
||||||
|
actor: system_actor
|
||||||
|
)
|
||||||
|
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, _html} = live(conn, "/members?highlight=#{member.id}")
|
||||||
|
|
||||||
|
# Outline is only for checkbox selection; highlight param does not set data-selected
|
||||||
|
refute has_element?(view, "tr#row-#{member.id}[data-selected='true']")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "copy_emails feature" do
|
describe "copy_emails feature" do
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,37 @@ defmodule MvWeb.MemberLive.ShowTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "delete action" do
|
||||||
|
test "renders Danger zone section and Delete button when user can destroy member", %{
|
||||||
|
conn: conn,
|
||||||
|
member: member
|
||||||
|
} do
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, html} = live(conn, ~p"/members/#{member}")
|
||||||
|
|
||||||
|
assert has_element?(view, "[data-testid='member-delete']")
|
||||||
|
assert html =~ gettext("Danger zone")
|
||||||
|
assert has_element?(view, "section[aria-labelledby='danger-zone-heading']")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "delete event removes member and redirects to index", %{
|
||||||
|
conn: conn,
|
||||||
|
member: member
|
||||||
|
} do
|
||||||
|
conn = conn_with_oidc_user(conn)
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members/#{member}")
|
||||||
|
|
||||||
|
view
|
||||||
|
|> render_click("delete", %{"id" => member.id})
|
||||||
|
|
||||||
|
assert_redirect(view, ~p"/members")
|
||||||
|
|
||||||
|
refute Mv.Membership.Member
|
||||||
|
|> Ash.Query.filter(id == ^member.id)
|
||||||
|
|> Ash.exists?()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "custom field value formatting" do
|
describe "custom field value formatting" do
|
||||||
test "formats string custom field values", %{conn: conn, member: member, actor: actor} do
|
test "formats string custom field values", %{conn: conn, member: member, actor: actor} do
|
||||||
{:ok, custom_field} =
|
{:ok, custom_field} =
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,10 @@ defmodule MvWeb.UserLive.IndexTest do
|
||||||
assert html =~ "alice@example.com"
|
assert html =~ "alice@example.com"
|
||||||
assert html =~ "bob@example.com"
|
assert html =~ "bob@example.com"
|
||||||
|
|
||||||
# UI elements: New User button, action links
|
# UI elements: New User button; row click navigates to show (no Edit/Delete on index)
|
||||||
assert html =~ "New User"
|
assert html =~ "New User"
|
||||||
assert html =~ "Edit"
|
# Row or navigation contains user id (e.g. row id or phx-click navigate)
|
||||||
assert html =~ "Delete"
|
assert html =~ "row-#{user1.id}" or html =~ to_string(user1.id)
|
||||||
assert html =~ ~r/href="[^"]*\/users\/#{user1.id}\/edit"/
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@tag :ui
|
@tag :ui
|
||||||
|
|
@ -116,177 +115,29 @@ defmodule MvWeb.UserLive.IndexTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "checkbox selection functionality" do
|
|
||||||
setup do
|
|
||||||
user1 = create_test_user(%{email: "user1@example.com", oidc_id: "user1"})
|
|
||||||
user2 = create_test_user(%{email: "user2@example.com", oidc_id: "user2"})
|
|
||||||
%{users: [user1, user2]}
|
|
||||||
end
|
|
||||||
|
|
||||||
@tag :ui
|
|
||||||
test "shows checkbox UI elements", %{conn: conn, users: [user1, user2]} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, _view, html} = live(conn, "/users")
|
|
||||||
|
|
||||||
# Check select all checkbox exists
|
|
||||||
assert html =~ ~s(name="select_all")
|
|
||||||
assert html =~ ~s(phx-click="select_all")
|
|
||||||
|
|
||||||
# Check individual user checkboxes exist
|
|
||||||
assert html =~ ~s(name="#{user1.id}")
|
|
||||||
assert html =~ ~s(name="#{user2.id}")
|
|
||||||
assert html =~ ~s(phx-click="select_user")
|
|
||||||
end
|
|
||||||
|
|
||||||
@tag :ui
|
|
||||||
test "can select and deselect individual users", %{conn: conn, users: [user1, user2]} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, view, _html} = live(conn, "/users")
|
|
||||||
|
|
||||||
# Initially, individual checkboxes should exist but not be checked
|
|
||||||
assert view |> element("input[type='checkbox'][name='#{user1.id}']") |> has_element?()
|
|
||||||
assert view |> element("input[type='checkbox'][name='#{user2.id}']") |> has_element?()
|
|
||||||
|
|
||||||
refute view
|
|
||||||
|> element("input[type='checkbox'][name='select_all'][checked]")
|
|
||||||
|> has_element?()
|
|
||||||
|
|
||||||
# Select first user checkbox
|
|
||||||
html = view |> element("input[type='checkbox'][name='#{user1.id}']") |> render_click()
|
|
||||||
assert html =~ "Email"
|
|
||||||
assert html =~ to_string(user1.email)
|
|
||||||
|
|
||||||
# The select_all checkbox should still not be checked (not all users selected)
|
|
||||||
refute view
|
|
||||||
|> element("input[type='checkbox'][name='select_all'][checked]")
|
|
||||||
|> has_element?()
|
|
||||||
|
|
||||||
# Deselect user
|
|
||||||
html = view |> element("input[type='checkbox'][name='#{user1.id}']") |> render_click()
|
|
||||||
assert html =~ "Email"
|
|
||||||
|
|
||||||
refute view
|
|
||||||
|> element("input[type='checkbox'][name='select_all'][checked]")
|
|
||||||
|> has_element?()
|
|
||||||
end
|
|
||||||
|
|
||||||
@tag :ui
|
|
||||||
test "select all and deselect all functionality", %{conn: conn, users: [user1, user2]} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, view, _html} = live(conn, "/users")
|
|
||||||
|
|
||||||
# Initially no checkboxes should be checked
|
|
||||||
refute view
|
|
||||||
|> element("input[type='checkbox'][name='select_all'][checked]")
|
|
||||||
|> has_element?()
|
|
||||||
|
|
||||||
refute view
|
|
||||||
|> element("input[type='checkbox'][name='#{user1.id}'][checked]")
|
|
||||||
|> has_element?()
|
|
||||||
|
|
||||||
refute view
|
|
||||||
|> element("input[type='checkbox'][name='#{user2.id}'][checked]")
|
|
||||||
|> has_element?()
|
|
||||||
|
|
||||||
# Click select all
|
|
||||||
html = view |> element("input[type='checkbox'][name='select_all']") |> render_click()
|
|
||||||
|
|
||||||
# After selecting all, the select_all checkbox should be checked
|
|
||||||
assert view
|
|
||||||
|> element("input[type='checkbox'][name='select_all'][checked]")
|
|
||||||
|> has_element?()
|
|
||||||
|
|
||||||
assert html =~ "Email"
|
|
||||||
assert html =~ to_string(user1.email)
|
|
||||||
assert html =~ to_string(user2.email)
|
|
||||||
|
|
||||||
# Then deselect all
|
|
||||||
html = view |> element("input[type='checkbox'][name='select_all']") |> render_click()
|
|
||||||
|
|
||||||
# After deselecting all, no checkboxes should be checked
|
|
||||||
refute view
|
|
||||||
|> element("input[type='checkbox'][name='select_all'][checked]")
|
|
||||||
|> has_element?()
|
|
||||||
|
|
||||||
refute view
|
|
||||||
|> element("input[type='checkbox'][name='#{user1.id}'][checked]")
|
|
||||||
|> has_element?()
|
|
||||||
|
|
||||||
refute view
|
|
||||||
|> element("input[type='checkbox'][name='#{user2.id}'][checked]")
|
|
||||||
|> has_element?()
|
|
||||||
|
|
||||||
assert html =~ "Email"
|
|
||||||
end
|
|
||||||
|
|
||||||
@tag :slow
|
|
||||||
test "select all automatically checks when all individual users are selected", %{
|
|
||||||
conn: conn,
|
|
||||||
users: [_user1, _user2]
|
|
||||||
} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, view, html} = live(conn, "/users")
|
|
||||||
|
|
||||||
# Get all user IDs from the rendered HTML by finding all checkboxes with phx-click="select_user"
|
|
||||||
# Extract user IDs from the HTML (they appear as name attributes on checkboxes)
|
|
||||||
user_ids =
|
|
||||||
html
|
|
||||||
|> String.split("phx-click=\"select_user\"")
|
|
||||||
|> Enum.flat_map(fn part ->
|
|
||||||
case Regex.run(~r/name="([^"]+)"[^>]*phx-value-id/, part) do
|
|
||||||
[_, user_id] -> [user_id]
|
|
||||||
_ -> []
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
|> Enum.uniq()
|
|
||||||
|
|
||||||
# Skip if no users found (shouldn't happen, but be safe)
|
|
||||||
if user_ids != [] do
|
|
||||||
# Initially nothing should be checked
|
|
||||||
refute view
|
|
||||||
|> element("input[type='checkbox'][name='select_all'][checked]")
|
|
||||||
|> has_element?()
|
|
||||||
|
|
||||||
# Select all users one by one
|
|
||||||
Enum.each(user_ids, fn user_id ->
|
|
||||||
view |> element("input[type='checkbox'][name='#{user_id}']") |> render_click()
|
|
||||||
end)
|
|
||||||
|
|
||||||
# Now select all should be automatically checked (all individual users are selected)
|
|
||||||
assert view
|
|
||||||
|> element("input[type='checkbox'][name='select_all'][checked]")
|
|
||||||
|> has_element?()
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "delete functionality" do
|
describe "delete functionality" do
|
||||||
test "can delete a user", %{conn: conn} do
|
# Delete is only on user show page (Danger zone), not on index (per CODE_GUIDELINES: at most one UI smoke test for delete)
|
||||||
_user = create_test_user(%{email: "delete-me@example.com"})
|
test "can delete a user from show page", %{conn: conn} do
|
||||||
|
user = create_test_user(%{email: "delete-me@example.com"})
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, view, _html} = live(conn, "/users")
|
{:ok, index_view, _html} = live(conn, "/users")
|
||||||
|
assert render(index_view) =~ "delete-me@example.com"
|
||||||
|
|
||||||
# Confirm user is displayed
|
# Navigate to user show and trigger delete from Danger zone
|
||||||
assert render(view) =~ "delete-me@example.com"
|
{:ok, show_view, _html} = live(conn, "/users/#{user.id}")
|
||||||
|
|
||||||
# Click the delete button (phx-click="delete" event)
|
show_view
|
||||||
view |> element("tbody tr:first-child a[data-confirm]") |> render_click()
|
|> element("[data-testid=user-delete]")
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
# Verify user was actually deleted (should not appear in HTML anymore)
|
# Should redirect to index
|
||||||
html = render(view)
|
assert_redirect(show_view, "/users")
|
||||||
|
|
||||||
|
# Reload index with same session; user should be gone
|
||||||
|
{:ok, _view_after, html} = live(conn, "/users")
|
||||||
refute html =~ "delete-me@example.com"
|
refute html =~ "delete-me@example.com"
|
||||||
# Table header should still be there
|
|
||||||
assert html =~ "Email"
|
assert html =~ "Email"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "shows delete confirmation", %{conn: conn} do
|
|
||||||
_user = create_test_user(%{email: "confirm-delete@example.com"})
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
{:ok, _view, html} = live(conn, "/users")
|
|
||||||
|
|
||||||
# Check that delete link has confirmation attribute
|
|
||||||
assert html =~ ~s(data-confirm="Are you sure?")
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "navigation" do
|
describe "navigation" do
|
||||||
|
|
@ -296,36 +147,14 @@ defmodule MvWeb.UserLive.IndexTest do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, _view, html} = live(conn, "/users")
|
{:ok, _view, html} = live(conn, "/users")
|
||||||
|
|
||||||
# Check that user row contains link to show page
|
# Row click navigates to show page (edit is on show page)
|
||||||
assert html =~ ~s(/users/#{user.id})
|
assert html =~ ~s(/users/#{user.id})
|
||||||
|
|
||||||
# Check edit link points to correct edit page
|
|
||||||
assert html =~ ~s(/users/#{user.id}/edit)
|
|
||||||
|
|
||||||
# Check new user button points to correct new page
|
# Check new user button points to correct new page
|
||||||
assert html =~ ~s(/users/new)
|
assert html =~ ~s(/users/new)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "translations" do
|
|
||||||
@tag :ui
|
|
||||||
test "shows translations for selection in different locales", %{conn: conn} do
|
|
||||||
conn = conn_with_oidc_user(conn)
|
|
||||||
|
|
||||||
# Test German translations
|
|
||||||
conn = Plug.Test.init_test_session(conn, locale: "de")
|
|
||||||
{:ok, _view, html_de} = live(conn, "/users")
|
|
||||||
assert html_de =~ "Alle Benutzer*innen auswählen"
|
|
||||||
assert html_de =~ "Benutzer*in auswählen"
|
|
||||||
|
|
||||||
# Test English translations
|
|
||||||
Gettext.put_locale(MvWeb.Gettext, "en")
|
|
||||||
{:ok, _view, html_en} = live(conn, "/users")
|
|
||||||
# Check that aria-label attributes exist (structure is there)
|
|
||||||
assert html_en =~ ~s(aria-label=)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "edge cases" do
|
describe "edge cases" do
|
||||||
test "handles empty user list gracefully", %{conn: conn} do
|
test "handles empty user list gracefully", %{conn: conn} do
|
||||||
# Don't create any users besides the authenticated one
|
# Don't create any users besides the authenticated one
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue