Compare commits
110 commits
828e09dce1
...
00ff2fa195
| Author | SHA1 | Date | |
|---|---|---|---|
| 00ff2fa195 | |||
| f8da12ad08 | |||
|
|
c2ac73e16c | ||
| b834a95d47 | |||
|
|
2974f4b2e9 | ||
| 9033e7a2b4 | |||
|
|
cc8bbe8630 | ||
| c98ab3f26d | |||
|
|
a90369e6cb | ||
| 9f6b84ed6c | |||
|
|
ab15fe039b | ||
| 844b4b6409 | |||
| 850f00fe22 | |||
| 08f563a412 | |||
| 058bfc2182 | |||
| 0df5d1c0b9 | |||
| 0d79e026e2 | |||
| 6f568bfe54 | |||
| 77ac3d1b18 | |||
| 619fdc90af | |||
| 856ce53295 | |||
| 3afc20c2e2 | |||
| ee6589c4fa | |||
| 5318b2c07d | |||
| d02add75ef | |||
| b2c2013b4d | |||
| 961261eff2 | |||
| 3035869fc8 | |||
| a8ea121800 | |||
| e9b99e6749 | |||
| f87e6d3e1d | |||
| 3cf8244cd6 | |||
| 1dd68bcaf2 | |||
| 33652265b8 | |||
| 398a63a98f | |||
| 8e58829e95 | |||
| ca702cf2c1 | |||
| 324425a991 | |||
| 4e101ea36e | |||
| e3ff3e610c | |||
| 2d2865b5a6 | |||
| 5718a37aca | |||
| def399122c | |||
| 1bb03b52c9 | |||
| 9233f56847 | |||
| 18766df224 | |||
| 46af6bbbed | |||
| 75dc7056ae | |||
| 562d7d6ab4 | |||
| a03056e6ae | |||
| 3241dd7d96 | |||
| e3d615acb8 | |||
| 46fb12c3f4 | |||
| 50a8657718 | |||
| 39de5c9237 | |||
| 239d784f3c | |||
| f25e198b0e | |||
| effb710741 | |||
| adb107e6a4 | |||
| c65b3808bf | |||
| 098b3b0a2a | |||
| be8a396ab6 | |||
| 128866ead3 | |||
| 9a1f0fbfa6 | |||
| 8f8c3f258a | |||
| 42fd8663aa | |||
| 128c712dbc | |||
| ab7fa38010 | |||
| d7b1b19c0b | |||
| 03ad853257 | |||
| 98dc73ee37 | |||
| 97c9ef670b | |||
| 10fe866de6 | |||
| acfbd8f62b | |||
| 0ab6a75377 | |||
| 004bf67f54 | |||
| e7fa3be74c | |||
| 03aacefb6e | |||
| 461b8d9c2a | |||
| b7a49eabe4 | |||
| 5b0881afa1 | |||
| 2eff93ee4a | |||
| e3ba6e9e7b | |||
| 94de6b2e8f | |||
| 803d9a0a94 | |||
| 3f723a3c3a | |||
| 75e6300637 | |||
| 29b39b2793 | |||
| 8899e1986a | |||
| e0702240d3 | |||
| cd46478024 | |||
| 4c66628802 | |||
| aece03c9c2 | |||
| 355d5bea9e | |||
| e8e47fd92a | |||
| 8ed9adeea0 | |||
| 5460ebdd5a | |||
| ebd590c81c | |||
| 5789079ab0 | |||
| bc989422e2 | |||
| 1fde2985e5 | |||
| 810a54c11f | |||
| 35cafd6e6a | |||
| 920cae656e | |||
| 99dc17bf4d | |||
| 06de9d2c8b | |||
| 09dfbe455b | |||
| a728968dad | |||
| 6084827c73 | |||
| bbc094daaa |
57 changed files with 8769 additions and 947 deletions
|
|
@ -4,7 +4,7 @@ name: check
|
|||
|
||||
services:
|
||||
- name: postgres
|
||||
image: docker.io/library/postgres:17.7
|
||||
image: docker.io/library/postgres:18.1
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
|
|
@ -57,7 +57,7 @@ steps:
|
|||
- mix gettext.extract --check-up-to-date
|
||||
|
||||
- name: wait_for_postgres
|
||||
image: docker.io/library/postgres:17.7
|
||||
image: docker.io/library/postgres:18.1
|
||||
commands:
|
||||
# Wait for postgres to become available
|
||||
- |
|
||||
|
|
@ -166,7 +166,7 @@ environment:
|
|||
|
||||
steps:
|
||||
- name: renovate
|
||||
image: renovate/renovate:42.44
|
||||
image: renovate/renovate:42.71
|
||||
environment:
|
||||
RENOVATE_CONFIG_FILE: "renovate_backend_config.js"
|
||||
RENOVATE_TOKEN:
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -44,3 +44,4 @@ npm-debug.log
|
|||
|
||||
# Docker secrets directory (generated by `just init-secrets`)
|
||||
/secrets/
|
||||
notes.md
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
elixir 1.18.3-otp-27
|
||||
erlang 27.3.4
|
||||
just 1.45.0
|
||||
just 1.46.0
|
||||
|
|
|
|||
318
code_review_fixes_-_membership_fee_features_e886dc4b.plan.md
Normal file
318
code_review_fixes_-_membership_fee_features_e886dc4b.plan.md
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
---
|
||||
name: Code Review Fixes - Membership Fee Features
|
||||
overview: Umsetzung der validen Code Review Punkte aus beiden Reviews mit Priorisierung nach Kritikalität. Fokus auf Transaktionssicherheit, Code-Qualität, Performance und UX-Verbesserungen.
|
||||
todos:
|
||||
- id: fix-after-action-tasks
|
||||
content: "after_action mit Task.start → after_transaction + Task.Supervisor: Task.Supervisor zu application.ex hinzufügen, after_action Hooks in after_transaction umwandeln, Task.Supervisor.async_nolink verwenden"
|
||||
status: pending
|
||||
- id: reduce-code-duplication
|
||||
content: "Code-Duplikation reduzieren: handle_cycle_generation/2 private Funktion extrahieren, alle drei Stellen (Create, Type Change, Date Change) verwenden"
|
||||
status: pending
|
||||
dependencies:
|
||||
- fix-after-action-tasks
|
||||
- id: fix-join-date-validation
|
||||
content: "join_date Validierung: Entweder Validierung wieder hinzufügen (validate compare(:join_date, less_than_or_equal_to: &Date.utc_today/0)) oder Dokumentation anpassen"
|
||||
status: pending
|
||||
- id: fix-load-cycles-docs
|
||||
content: "load_cycles_for_members: Entweder Dokumentation korrigieren (ehrlich machen) oder echte Filterung implementieren (z.B. nur letzte 2 Intervalle)"
|
||||
status: pending
|
||||
- id: fix-get-current-cycle-sort
|
||||
content: "get_current_cycle nondeterministisch: Vor List.first() nach cycle_start sortieren (desc) in MembershipFeeHelpers.get_current_cycle"
|
||||
status: pending
|
||||
- id: fix-n1-query-member-count
|
||||
content: "N+1 Query beheben: Aggregate auf MembershipFeeType definieren oder member_count einmalig vorab laden und in assigns cachen"
|
||||
status: pending
|
||||
- id: fix-assign-new-stale
|
||||
content: "assign_new → assign: In MembershipFeesComponent.update/2 immer assign(:cycles, cycles) und assign(:available_fee_types, available_fee_types) setzen"
|
||||
status: pending
|
||||
- id: fix-regenerating-flag
|
||||
content: "@regenerating auf true setzen: Direkt beim Event-Start in handle_event(\"regenerate_cycles\", ...) socket |> assign(:regenerating, true) setzen"
|
||||
status: pending
|
||||
- id: fix-create-cycle-parsing
|
||||
content: "Create-cycle parsing Fix: Decimal.parse explizit behandeln und {:error, :invalid_amount} zurückgeben statt :error"
|
||||
status: pending
|
||||
- id: fix-delete-all-atomic
|
||||
content: "Delete all cycles atomar: Bulk Delete Query verwenden (Ash bulk destroy oder Query-basiert) statt Enum.map"
|
||||
status: pending
|
||||
- id: improve-async-error-handling
|
||||
content: "Fehlerbehandlung bei async Tasks: Strukturierte Error-Logs mit Context, optional Retry-Mechanismus oder Event-System für Benachrichtigung"
|
||||
status: pending
|
||||
- id: improve-format-currency
|
||||
content: "format_currency Robustheit: Number.Currency verwenden oder robusteres Pattern Matching + Tests für Edge Cases (negative Zahlen, sehr große Zahlen)"
|
||||
status: pending
|
||||
- id: add-missing-typespecs
|
||||
content: "Fehlende Typespecs: @spec für SetDefaultMembershipFeeType.change/3 hinzufügen"
|
||||
status: pending
|
||||
- id: fix-race-condition
|
||||
content: "Potenzielle Race Condition: Prüfen ob Ash doppelte Auslösung verhindert, ggf. Logik anpassen (beide Änderungen in einem Hook zusammenfassen)"
|
||||
status: pending
|
||||
- id: extract-magic-values
|
||||
content: "Magic Numbers/Strings: Application.get_env(:mv, :sql_sandbox, false) in Konstante/Helper extrahieren (z.B. Mv.Config.sql_sandbox?/0)"
|
||||
status: pending
|
||||
- id: fix-domain-consistency
|
||||
content: "Domain-Konsistenz: Überall in MembershipFeesComponent domain: MembershipFees explizit angeben"
|
||||
status: pending
|
||||
- id: fix-test-helper
|
||||
content: "Test-Helper Fix: create_cycle/3 Helper - Cleanup nur einmal im Setup oder gezielt nur auto-generierte löschen"
|
||||
status: pending
|
||||
- id: fix-date-utc-today-param
|
||||
content: "Date.utc_today() Parameter: today Parameter durchgeben in get_cycle_status_for_member und Helper-Funktionen"
|
||||
status: pending
|
||||
- id: fix-ui-locale-input
|
||||
content: "UI/Locale Input Fix: type=\"number\" → type=\"text\" + inputmode=\"decimal\" + serverseitig \",\" → \".\" normalisieren"
|
||||
status: pending
|
||||
- id: fix-delete-confirmation
|
||||
content: "Delete-all-Confirmation robuster: String.trim() + case-insensitive Vergleich oder \"type DELETE\" Pattern"
|
||||
status: pending
|
||||
- id: fix-warning-state
|
||||
content: "Warning-State Fix: Bei Decimal.parse(:error) explizit hide_amount_warning(socket) aufrufen"
|
||||
status: pending
|
||||
- id: fix-double-toggle
|
||||
content: "Toggle entfernen: Toggle-Button im Spalten-Header entfernen (nur in Toolbar behalten)"
|
||||
status: pending
|
||||
- id: fix-format-consistency
|
||||
content: "Format-Konsistenz: Inputs ebenfalls auf Komma ausrichten oder serverseitig normalisieren"
|
||||
status: pending
|
||||
dependencies:
|
||||
- fix-ui-locale-input
|
||||
---
|
||||
|
||||
# Code Review Fixes - Membership Fee Features
|
||||
|
||||
## Kritische Probleme (Müssen vor Merge behoben werden)
|
||||
|
||||
### 1. after_action mit Task.start - Transaktionsprobleme
|
||||
|
||||
**Dateien:** `lib/membership/member.ex` (Zeilen 142, 279)
|
||||
|
||||
**Problem:** `Task.start/1` wird innerhalb von `after_action` Hooks verwendet. `after_action` läuft innerhalb der DB-Transaktion, daher:
|
||||
|
||||
- Tasks sehen möglicherweise noch nicht committed state
|
||||
- Tasks werden auch bei Rollback gestartet
|
||||
- Keine Supervision → Memory Leaks möglich
|
||||
|
||||
**Lösung:**
|
||||
|
||||
- `after_transaction` Hook verwenden (Ash Best Practice)
|
||||
- `Task.Supervisor` zum Supervision Tree hinzufügen (`lib/mv/application.ex`)
|
||||
- `Task.Supervisor.async_nolink/3` statt `Task.start/1` verwenden
|
||||
|
||||
**Betroffene Stellen:**
|
||||
|
||||
- Member Creation (Zeile 116-164)
|
||||
- Join/Exit Date Change (Zeile 250-301)
|
||||
|
||||
### 2. Code-Duplikation in Cycle-Generation-Logik
|
||||
|
||||
**Datei:** `lib/membership/member.ex`
|
||||
|
||||
**Problem:** Cycle-Generation-Logik ist dreimal dupliziert (Create, Type Change, Date Change)
|
||||
|
||||
**Lösung:** Extrahiere in private Funktion `handle_cycle_generation/2`
|
||||
|
||||
## Wichtige Probleme (Sollten behoben werden)
|
||||
|
||||
### 3. join_date Validierung entfernt, aber Dokumentation behauptet Gegenteil
|
||||
|
||||
**Datei:** `lib/membership/member.ex` (Zeile 27, 516-518)
|
||||
|
||||
**Problem:** Dokumentation sagt "join_date not in future", aber Validierung fehlt
|
||||
|
||||
**Lösung:** Dokumentation anpassen
|
||||
|
||||
### 4. load_cycles_for_members overpromises
|
||||
|
||||
**Datei:** `lib/mv_web/member_live/index/membership_fee_status.ex` (Zeile 36-40)
|
||||
|
||||
**Problem:** Dokumentation sagt "Only loads the relevant cycle per member" und "Filters cycles at database level", aber lädt alle Cycles
|
||||
|
||||
**Lösung:** echte Filterung implementieren (z.B. nur letzte 2 Intervalle)
|
||||
|
||||
### 5. get_current_cycle nondeterministisch
|
||||
|
||||
**Datei:** `lib/mv_web/helpers/membership_fee_helpers.ex` (Zeile 178-182)
|
||||
|
||||
**Problem:** `List.first()` ohne explizite Sortierung → Ergebnis hängt von Reihenfolge ab
|
||||
|
||||
**Lösung:** Vor `List.first()` nach `cycle_start` sortieren (desc)
|
||||
|
||||
### 6. N+1 Query durch get_member_count
|
||||
|
||||
**Datei:** `lib/mv_web/live/membership_fee_type_live/index.ex` (Zeile 134-140)
|
||||
|
||||
**Problem:** `get_member_count/1` wird pro Row aufgerufen → N+1 Query
|
||||
|
||||
**Lösung:** Aggregate auf MembershipFeeType definieren oder einmalig vorab laden
|
||||
|
||||
### 7. assign_new kann stale werden
|
||||
|
||||
**Datei:** `lib/mv_web/live/member_live/show/membership_fees_component.ex` (Zeile 402-403)
|
||||
|
||||
**Problem:** `assign_new(:cycles, ...)` und `assign_new(:available_fee_types, ...)` werden nur gesetzt, wenn Assign noch nicht existiert
|
||||
|
||||
**Lösung:** In `update/2` immer `assign(:cycles, cycles)` / `assign(:available_fee_types, available_fee_types)` setzen
|
||||
|
||||
### 8. @regenerating wird nie auf true gesetzt
|
||||
|
||||
**Datei:** `lib/mv_web/live/member_live/show/membership_fees_component.ex` (Zeile 526-561)
|
||||
|
||||
**Problem:** `regenerating` wird nur auf `false` gesetzt, nie auf `true` → Button/Spinner werden nie disabled
|
||||
|
||||
**Lösung:** Direkt beim Event-Start `socket |> assign(:regenerating, true)` setzen
|
||||
|
||||
### 9. Create-cycle parsing: invalid amount zeigt falsche Fehlermeldung
|
||||
|
||||
**Datei:** `lib/mv_web/live/member_live/show/membership_fees_component.ex` (Zeile 748-812)
|
||||
|
||||
**Problem:** `Decimal.parse/1` gibt `:error` zurück, aber `with` behandelt es als `:error` → landet in "Invalid date format" Branch
|
||||
|
||||
**Lösung:** Explizit `{:error, :invalid_amount}` zurückgeben:
|
||||
|
||||
```elixir
|
||||
amount = case Decimal.parse(amount_str) do
|
||||
{d, _} -> {:ok, d}
|
||||
:error -> {:error, :invalid_amount}
|
||||
end
|
||||
```
|
||||
|
||||
### 10. Delete all cycles: nicht atomar
|
||||
|
||||
**Datei:** `lib/mv_web/live/member_live/show/membership_fees_component.ex` (Zeile 666-714)
|
||||
|
||||
**Problem:** `Enum.map(cycles, &Ash.destroy/1)` → nicht atomar, teilweise gelöscht möglich
|
||||
|
||||
**Lösung:** Bulk Delete Query verwenden (Ash bulk destroy oder Query-basiert)
|
||||
|
||||
### 11. Fehlerbehandlung bei async Tasks
|
||||
|
||||
**Datei:** `lib/membership/member.ex`
|
||||
|
||||
**Problem:** Bei Fehlern in async Tasks wird nur geloggt, aber der Benutzer erhält keine Rückmeldung. Die Member-Aktion wird als erfolgreich zurückgegeben, auch wenn die Cycle-Generierung fehlschlägt. Keine Retry-Logik oder Monitoring.
|
||||
|
||||
**Lösung:**
|
||||
|
||||
- Für kritische Fälle: synchron ausführen oder Retry-Mechanismus implementieren
|
||||
- Für nicht-kritische Fälle: Event-System für spätere Benachrichtigung
|
||||
- Strukturierte Error-Logs mit Context
|
||||
- Optional: Error-Tracking (Sentry, etc.)
|
||||
|
||||
### 12. format_currency Robustheit
|
||||
|
||||
**Datei:** `lib/mv_web/helpers/membership_fee_helpers.ex` (Zeilen 27-51)
|
||||
|
||||
**Problem:** Die Funktion verwendet String-Manipulation für Formatierung. Edge Cases könnten problematisch sein (z.B. sehr große Zahlen, negative Werte).
|
||||
|
||||
**Lösung:**
|
||||
|
||||
- `Number.Currency` oder ähnliche Bibliothek verwenden
|
||||
- Oder: Robusteres Pattern Matching für Edge Cases
|
||||
- Tests für Edge Cases hinzufügen (negative Zahlen, sehr große Zahlen)
|
||||
|
||||
### 13. Fehlende Typespecs
|
||||
|
||||
**Datei:** `lib/membership/member/changes/set_default_membership_fee_type.ex`
|
||||
|
||||
**Problem:** Keine `@spec` für die `change/3` Funktion.
|
||||
|
||||
**Lösung:** Typespecs hinzufügen für bessere Dokumentation und Dialyzer-Support.
|
||||
|
||||
### 14. Potenzielle Race Condition
|
||||
|
||||
**Datei:** `lib/membership/member.ex` (Zeile 250-301)
|
||||
|
||||
**Problem:** Wenn `join_date` und `exit_date` gleichzeitig geändert werden, könnte die Cycle-Generierung zweimal ausgelöst werden (einmal pro Änderung).
|
||||
|
||||
**Lösung:** Prüfen, ob Ash dies bereits verhindert, oder Logik anpassen (z.B. beide Änderungen in einem Hook zusammenfassen).
|
||||
|
||||
### 15. Magic Numbers/Strings
|
||||
|
||||
**Problem:** `Application.get_env(:mv, :sql_sandbox, false)` wird mehrfach verwendet.
|
||||
|
||||
**Lösung:** Extrahiere in Konstante oder Helper-Funktion (z.B. `Mv.Config.sql_sandbox?/0`).
|
||||
|
||||
## Mittlere Probleme (Nice-to-have)
|
||||
|
||||
### 16. Inconsistent use of domain
|
||||
|
||||
**Datei:** `lib/mv_web/live/member_live/show/membership_fees_component.ex` (Zeile 819-821)
|
||||
|
||||
**Problem:** Einige Actions verwenden `domain: MembershipFees`, andere nicht
|
||||
|
||||
**Lösung:** Konsistent `domain` überall verwenden
|
||||
|
||||
### 17. Tests: create_cycle/3 löscht jedes Mal alle Cycles
|
||||
|
||||
**Datei:** `test/mv_web/member_live/index/membership_fee_status_test.exs` (Zeile 45-52)
|
||||
|
||||
**Problem:** Helper löscht vor jedem Create alle Cycles → Tests prüfen nicht, was sie denken
|
||||
|
||||
**Lösung:** Cleanup nur einmal im Setup oder gezielt nur auto-generierte löschen
|
||||
|
||||
### 18. Tests/Design: Date.utc_today() macht Tests flaky
|
||||
|
||||
**Problem:** Tests hängen von `Date.utc_today()` ab → nicht deterministisch
|
||||
|
||||
**Lösung:** `today` Parameter durchgeben (z.B. `get_cycle_status_for_member(member, show_current, today \\ Date.utc_today())`)
|
||||
|
||||
### 19. UI/Locale: input type="number" + Decimal/Komma
|
||||
|
||||
**Problem:** `type="number"` funktioniert nicht zuverlässig mit Komma als Dezimaltrenner
|
||||
|
||||
**Lösung:** `type="text"` + `inputmode="decimal"` + serverseitig "," → "." normalisieren
|
||||
|
||||
### 20. Delete-all-Confirmation: String-Vergleich ist fragil
|
||||
|
||||
**Datei:** `lib/mv_web/live/member_live/show/membership_fees_component.ex` (Zeile 296-298)
|
||||
|
||||
**Problem:** String-Vergleich gegen `gettext("Yes")` und `"Yes"` → fragil bei Whitespace/Locale
|
||||
|
||||
**Lösung:** `String.trim()` + case-insensitive Vergleich oder "type DELETE" Pattern
|
||||
|
||||
### 21. MembershipFeeType Form: Warning-State kann hängen bleiben
|
||||
|
||||
**Datei:** `lib/mv_web/live/membership_fee_type_live/form.ex` (Zeile 367-378)
|
||||
|
||||
**Problem:** Bei `Decimal.parse(:error)` wird nur `socket` zurückgegeben → Warning kann stehen bleiben
|
||||
|
||||
**Lösung:** Bei `:error` explizit `hide_amount_warning(socket)` aufrufen
|
||||
|
||||
### 22. UI/UX: Toggle ist doppelt vorhanden
|
||||
|
||||
**Datei:** `lib/mv_web/live/member_live/index.html.heex` (Zeile 45-72, 284-296)
|
||||
|
||||
**Problem:** Toggle-Button sowohl in Toolbar als auch im Spalten-Header
|
||||
|
||||
**Lösung:** Toggle im Spalten-Header entfernen (nur in Toolbar behalten)
|
||||
|
||||
### 23. Konsistenz: format_currency vs Inputs
|
||||
|
||||
**Problem:** `format_currency` formatiert deutsch (Komma), aber Inputs erwarten Punkt
|
||||
|
||||
**Lösung:** Inputs ebenfalls auf Komma ausrichten oder serverseitig normalisieren
|
||||
|
||||
## Implementierungsreihenfolge
|
||||
|
||||
1. **Kritisch:** after_action → after_transaction + Task.Supervisor
|
||||
2. **Kritisch:** Code-Duplikation reduzieren
|
||||
3. **Wichtig:** join_date Validierung/Dokumentation
|
||||
4. **Wichtig:** load_cycles_for_members Dokumentation/Implementierung
|
||||
5. **Wichtig:** get_current_cycle Sortierung
|
||||
6. **Wichtig:** N+1 Query beheben
|
||||
7. **Wichtig:** assign_new → assign
|
||||
8. **Wichtig:** @regenerating auf true setzen
|
||||
9. **Wichtig:** Create-cycle parsing Fix
|
||||
10. **Wichtig:** Delete all cycles atomar
|
||||
11. **Wichtig:** Fehlerbehandlung bei async Tasks
|
||||
12. **Wichtig:** format_currency Robustheit
|
||||
13. **Wichtig:** Fehlende Typespecs
|
||||
14. **Wichtig:** Potenzielle Race Condition prüfen/beheben
|
||||
15. **Wichtig:** Magic Numbers/Strings extrahieren
|
||||
16. **Mittel:** Domain-Konsistenz
|
||||
17. **Mittel:** Test-Helper Fix
|
||||
18. **Mittel:** Date.utc_today() Parameter
|
||||
19. **Mittel:** UI/Locale Fixes
|
||||
20. **Mittel:** String-Vergleich robuster
|
||||
21. **Mittel:** Warning-State Fix
|
||||
22. **Mittel:** Toggle entfernen
|
||||
23. **Mittel:** Format-Konsistenz
|
||||
|
||||
|
|
@ -95,7 +95,16 @@ config :tailwind,
|
|||
# Configures Elixir's Logger
|
||||
config :logger, :default_formatter,
|
||||
format: "$time $metadata[$level] $message\n",
|
||||
metadata: [:request_id]
|
||||
metadata: [
|
||||
:request_id,
|
||||
:user_id,
|
||||
:member_id,
|
||||
:member_email,
|
||||
:error,
|
||||
:error_type,
|
||||
:cycles_count,
|
||||
:notifications_count
|
||||
]
|
||||
|
||||
# Use Jason for JSON parsing in Phoenix
|
||||
config :phoenix, :json_library, Jason
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ services:
|
|||
restart: unless-stopped
|
||||
|
||||
db-prod:
|
||||
image: postgres:17.7-alpine
|
||||
image: postgres:18.1-alpine
|
||||
container_name: mv-prod-db
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ networks:
|
|||
|
||||
services:
|
||||
db:
|
||||
image: postgres:17.7-alpine
|
||||
image: postgres:18.1-alpine
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
|
|
@ -29,7 +29,7 @@ services:
|
|||
|
||||
rauthy:
|
||||
container_name: rauthy-dev
|
||||
image: ghcr.io/sebadob/rauthy:0.33.1
|
||||
image: ghcr.io/sebadob/rauthy:0.33.4
|
||||
environment:
|
||||
- LOCAL_TEST=true
|
||||
- SMTP_URL=mailcrab
|
||||
|
|
|
|||
137
docs/test-status-membership-fee-ui.md
Normal file
137
docs/test-status-membership-fee-ui.md
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
# Test Status: Membership Fee UI Components
|
||||
|
||||
**Date:** 2025-01-XX
|
||||
**Status:** Tests Written - Implementation Complete
|
||||
|
||||
## Übersicht
|
||||
|
||||
Alle Tests für die Membership Fee UI-Komponenten wurden geschrieben. Die Tests sind TDD-konform geschrieben und sollten erfolgreich laufen, da die Implementation bereits vorhanden ist.
|
||||
|
||||
## Test-Dateien
|
||||
|
||||
### Helper Module Tests
|
||||
|
||||
**Datei:** `test/mv_web/helpers/membership_fee_helpers_test.exs`
|
||||
- ✅ format_currency/1 formats correctly
|
||||
- ✅ format_interval/1 formats all interval types
|
||||
- ✅ format_cycle_range/2 formats date ranges correctly
|
||||
- ✅ get_last_completed_cycle/2 returns correct cycle
|
||||
- ✅ get_current_cycle/2 returns correct cycle
|
||||
- ✅ status_color/1 returns correct color classes
|
||||
- ✅ status_icon/1 returns correct icon names
|
||||
|
||||
**Status:** Alle Tests sollten erfolgreich sein (Implementation vorhanden)
|
||||
|
||||
**Datei:** `test/mv_web/member_live/index/membership_fee_status_test.exs`
|
||||
- ✅ load_cycles_for_members/2 efficiently loads cycles
|
||||
- ✅ get_cycle_status_for_member/2 returns correct status
|
||||
- ✅ format_cycle_status_badge/1 returns correct badge
|
||||
|
||||
**Status:** Alle Tests sollten erfolgreich sein (Implementation vorhanden)
|
||||
|
||||
### Member List View Tests
|
||||
|
||||
**Datei:** `test/mv_web/member_live/index_membership_fee_status_test.exs`
|
||||
- ✅ Status column displays correctly
|
||||
- ✅ Shows last completed cycle status by default
|
||||
- ✅ Toggle switches to current cycle view
|
||||
- ✅ Color coding for paid/unpaid/suspended
|
||||
- ✅ Filter "Unpaid in last cycle" works
|
||||
- ✅ Filter "Unpaid in current cycle" works
|
||||
- ✅ Handles members without cycles gracefully
|
||||
- ✅ Loads cycles efficiently without N+1 queries
|
||||
|
||||
**Status:** Alle Tests sollten erfolgreich sein (Implementation vorhanden)
|
||||
|
||||
### Member Detail View Tests
|
||||
|
||||
**Datei:** `test/mv_web/member_live/show_membership_fees_test.exs`
|
||||
- ✅ Cycles table displays all cycles
|
||||
- ✅ Table columns show correct data
|
||||
- ✅ Membership fee type dropdown shows only same-interval types
|
||||
- ✅ Warning displayed if different interval selected
|
||||
- ✅ Status change actions work (mark as paid/suspended/unpaid)
|
||||
- ✅ Cycle regeneration works
|
||||
- ✅ Handles members without membership fee type gracefully
|
||||
|
||||
**Status:** Alle Tests sollten erfolgreich sein (Implementation vorhanden)
|
||||
|
||||
### Membership Fee Types Admin Tests
|
||||
|
||||
**Datei:** `test/mv_web/live/membership_fee_type_live/index_test.exs`
|
||||
- ✅ List displays all types with correct data
|
||||
- ✅ Member count column shows correct count
|
||||
- ✅ Create button navigates to form
|
||||
- ✅ Edit button per row navigates to edit form
|
||||
- ✅ Delete button disabled if type is in use
|
||||
- ✅ Delete button works if type is not in use
|
||||
- ✅ Only admin can access
|
||||
|
||||
**Status:** Alle Tests sollten erfolgreich sein (Implementation vorhanden)
|
||||
|
||||
**Datei:** `test/mv_web/live/membership_fee_type_live/form_test.exs`
|
||||
- ✅ Create form works
|
||||
- ✅ Edit form loads existing type data
|
||||
- ✅ Interval field editable on create
|
||||
- ✅ Interval field grayed out on edit
|
||||
- ✅ Amount change warning displays on edit
|
||||
- ✅ Amount change warning shows correct affected member count
|
||||
- ✅ Amount change can be confirmed
|
||||
- ✅ Amount change can be cancelled
|
||||
- ✅ Validation errors display correctly
|
||||
- ✅ Only admin can access
|
||||
|
||||
**Status:** Alle Tests sollten erfolgreich sein (Implementation vorhanden)
|
||||
|
||||
### Member Form Tests
|
||||
|
||||
**Datei:** `test/mv_web/member_live/form_membership_fee_type_test.exs`
|
||||
- ✅ Membership fee type dropdown displays in form
|
||||
- ✅ Shows available types
|
||||
- ✅ Filters to same interval types if member has type
|
||||
- ✅ Warning displayed if different interval selected
|
||||
- ✅ Warning cleared if same interval selected
|
||||
- ✅ Form saves with selected membership fee type
|
||||
- ✅ New members get default membership fee type
|
||||
|
||||
**Status:** Alle Tests sollten erfolgreich sein (Implementation vorhanden)
|
||||
|
||||
### Integration Tests
|
||||
|
||||
**Datei:** `test/mv_web/member_live/membership_fee_integration_test.exs`
|
||||
- ✅ End-to-end: Create type → Assign to member → View cycles → Change status
|
||||
- ✅ End-to-end: Change member type → Cycles regenerate
|
||||
- ✅ End-to-end: Update settings → New members get default type
|
||||
- ✅ End-to-end: Delete cycle → Confirmation → Cycle deleted
|
||||
- ✅ End-to-end: Edit cycle amount → Modal → Amount updated
|
||||
|
||||
**Status:** Alle Tests sollten erfolgreich sein (Implementation vorhanden)
|
||||
|
||||
## Test-Ausführung
|
||||
|
||||
Alle Tests können mit folgenden Befehlen ausgeführt werden:
|
||||
|
||||
```bash
|
||||
# Alle Tests
|
||||
mix test
|
||||
|
||||
# Nur Membership Fee Tests
|
||||
mix test test/mv_web/helpers/membership_fee_helpers_test.exs
|
||||
mix test test/mv_web/member_live/
|
||||
mix test test/mv_web/live/membership_fee_type_live/
|
||||
|
||||
# Mit Coverage
|
||||
mix test --cover
|
||||
```
|
||||
|
||||
## Bekannte Probleme
|
||||
|
||||
Keine bekannten Probleme. Alle Tests sollten erfolgreich laufen, da die Implementation bereits vorhanden ist.
|
||||
|
||||
## Nächste Schritte
|
||||
|
||||
1. ✅ Tests geschrieben
|
||||
2. ⏳ Tests ausführen und verifizieren
|
||||
3. ⏳ Eventuelle Anpassungen basierend auf Test-Ergebnissen
|
||||
4. ⏳ Code-Review durchführen
|
||||
|
||||
|
|
@ -39,6 +39,7 @@ defmodule Mv.Membership.Member do
|
|||
|
||||
require Ash.Query
|
||||
import Ash.Expr
|
||||
require Logger
|
||||
|
||||
# Module constants
|
||||
@member_search_limit 10
|
||||
|
|
@ -73,6 +74,9 @@ defmodule Mv.Membership.Member do
|
|||
|
||||
create :create_member do
|
||||
primary? true
|
||||
|
||||
# Note: Custom validation function cannot be done atomically (queries DB for required custom fields)
|
||||
# In Ash 3.0, require_atomic? is not available for create actions, but the validation will still work
|
||||
# Custom field values can be created along with member
|
||||
argument :custom_field_values, {:array, :map}
|
||||
# Allow user to be passed as argument for relationship management
|
||||
|
|
@ -102,6 +106,9 @@ defmodule Mv.Membership.Member do
|
|||
where [changing(:user)]
|
||||
end
|
||||
|
||||
# Auto-assign default membership fee type if not explicitly set
|
||||
change Mv.Membership.Member.Changes.SetDefaultMembershipFeeType
|
||||
|
||||
# Auto-calculate membership_fee_start_date if not manually set
|
||||
# Requires both join_date and membership_fee_type_id to be present
|
||||
change Mv.MembershipFees.Changes.SetMembershipFeeStartDate
|
||||
|
|
@ -110,54 +117,18 @@ defmodule Mv.Membership.Member do
|
|||
# Only runs if membership_fee_type_id is set
|
||||
# Note: Cycle generation runs asynchronously to not block the action,
|
||||
# but in test environment it runs synchronously for DB sandbox compatibility
|
||||
change after_action(fn _changeset, member, _context ->
|
||||
if member.membership_fee_type_id && member.join_date do
|
||||
if Application.get_env(:mv, :sql_sandbox, false) do
|
||||
# Run synchronously in test environment for DB sandbox compatibility
|
||||
# Use skip_lock?: true to avoid nested transactions (after_action runs within action transaction)
|
||||
# Return notifications to Ash so they are sent after commit
|
||||
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
|
||||
member.id,
|
||||
today: Date.utc_today(),
|
||||
skip_lock?: true
|
||||
) do
|
||||
{:ok, _cycles, notifications} ->
|
||||
{:ok, member, notifications}
|
||||
|
||||
{:error, reason} ->
|
||||
require Logger
|
||||
|
||||
Logger.warning(
|
||||
"Failed to generate cycles for member #{member.id}: #{inspect(reason)}"
|
||||
)
|
||||
|
||||
{:ok, member}
|
||||
change after_transaction(fn _changeset, result, _context ->
|
||||
case result do
|
||||
{:ok, member} ->
|
||||
if member.membership_fee_type_id && member.join_date do
|
||||
handle_cycle_generation(member)
|
||||
end
|
||||
else
|
||||
# Run asynchronously in other environments
|
||||
# Send notifications explicitly since they cannot be returned via after_action
|
||||
Task.start(fn ->
|
||||
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id) do
|
||||
{:ok, _cycles, notifications} ->
|
||||
# Send notifications manually for async case
|
||||
if Enum.any?(notifications) do
|
||||
Ash.Notifier.notify(notifications)
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
require Logger
|
||||
|
||||
Logger.warning(
|
||||
"Failed to generate cycles for member #{member.id}: #{inspect(reason)}"
|
||||
)
|
||||
end
|
||||
end)
|
||||
|
||||
{:ok, member}
|
||||
end
|
||||
else
|
||||
{:ok, member}
|
||||
{:error, _} ->
|
||||
:ok
|
||||
end
|
||||
|
||||
result
|
||||
end)
|
||||
end
|
||||
|
||||
|
|
@ -239,6 +210,29 @@ defmodule Mv.Membership.Member do
|
|||
{:ok, member}
|
||||
end
|
||||
end)
|
||||
|
||||
# Trigger cycle regeneration when join_date or exit_date changes
|
||||
# Regenerates cycles based on new dates
|
||||
# Note: Cycle generation runs synchronously in test environment, asynchronously in production
|
||||
# CycleGenerator uses advisory locks and transactions internally to prevent race conditions
|
||||
# If both join_date and exit_date are changed simultaneously, this hook runs only once
|
||||
# (Ash ensures each after_transaction hook runs once per action, regardless of how many attributes changed)
|
||||
change after_transaction(fn changeset, result, _context ->
|
||||
case result do
|
||||
{:ok, member} ->
|
||||
join_date_changed = Ash.Changeset.changing_attribute?(changeset, :join_date)
|
||||
exit_date_changed = Ash.Changeset.changing_attribute?(changeset, :exit_date)
|
||||
|
||||
if (join_date_changed || exit_date_changed) && member.membership_fee_type_id do
|
||||
handle_cycle_generation(member)
|
||||
end
|
||||
|
||||
{:error, _} ->
|
||||
:ok
|
||||
end
|
||||
|
||||
result
|
||||
end)
|
||||
end
|
||||
|
||||
# Action to handle fuzzy search on specific fields
|
||||
|
|
@ -392,7 +386,7 @@ defmodule Mv.Membership.Member do
|
|||
end
|
||||
end
|
||||
|
||||
# Join date not in the future
|
||||
# Join date not in future
|
||||
validate compare(:join_date, less_than_or_equal_to: &Date.utc_today/0),
|
||||
where: [present(:join_date)],
|
||||
message: "cannot be in the future"
|
||||
|
|
@ -427,6 +421,32 @@ defmodule Mv.Membership.Member do
|
|||
{:error, field: :email, message: "is not a valid email"}
|
||||
end
|
||||
end
|
||||
|
||||
# Validate required custom fields
|
||||
validate fn changeset, _ ->
|
||||
provided_values = provided_custom_field_values(changeset)
|
||||
|
||||
case Mv.Membership.list_required_custom_fields() do
|
||||
{:ok, required_custom_fields} ->
|
||||
missing_fields = missing_required_fields(required_custom_fields, provided_values)
|
||||
|
||||
if Enum.empty?(missing_fields) do
|
||||
:ok
|
||||
else
|
||||
build_custom_field_validation_error(missing_fields)
|
||||
end
|
||||
|
||||
{:error, error} ->
|
||||
Logger.error(
|
||||
"Failed to load custom fields for validation: #{inspect(error)}. Required field validation cannot be performed."
|
||||
)
|
||||
|
||||
{:error,
|
||||
field: :custom_field_values,
|
||||
message:
|
||||
"Unable to validate required custom fields. Please try again or contact support."}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
attributes do
|
||||
|
|
@ -454,10 +474,6 @@ defmodule Mv.Membership.Member do
|
|||
constraints min_length: 5, max_length: 254
|
||||
end
|
||||
|
||||
attribute :paid, :boolean do
|
||||
allow_nil? true
|
||||
end
|
||||
|
||||
attribute :phone_number, :string do
|
||||
allow_nil? true
|
||||
end
|
||||
|
|
@ -868,6 +884,90 @@ defmodule Mv.Membership.Member do
|
|||
end
|
||||
end
|
||||
|
||||
# Handles cycle generation for a member, choosing sync or async execution
|
||||
# based on environment (test vs production)
|
||||
# This function encapsulates the common logic for cycle generation
|
||||
# to avoid code duplication across different hooks
|
||||
defp handle_cycle_generation(member) do
|
||||
if Mv.Config.sql_sandbox?() do
|
||||
handle_cycle_generation_sync(member)
|
||||
else
|
||||
handle_cycle_generation_async(member)
|
||||
end
|
||||
end
|
||||
|
||||
# Runs cycle generation synchronously (for test environment)
|
||||
defp handle_cycle_generation_sync(member) do
|
||||
require Logger
|
||||
|
||||
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
|
||||
member.id,
|
||||
today: Date.utc_today()
|
||||
) do
|
||||
{:ok, cycles, notifications} ->
|
||||
send_notifications_if_any(notifications)
|
||||
log_cycle_generation_success(member, cycles, notifications, sync: true)
|
||||
|
||||
{:error, reason} ->
|
||||
log_cycle_generation_error(member, reason, sync: true)
|
||||
end
|
||||
end
|
||||
|
||||
# Runs cycle generation asynchronously (for production environment)
|
||||
defp handle_cycle_generation_async(member) do
|
||||
Task.Supervisor.async_nolink(Mv.TaskSupervisor, fn ->
|
||||
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id) do
|
||||
{:ok, cycles, notifications} ->
|
||||
send_notifications_if_any(notifications)
|
||||
log_cycle_generation_success(member, cycles, notifications, sync: false)
|
||||
|
||||
{:error, reason} ->
|
||||
log_cycle_generation_error(member, reason, sync: false)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
# Sends notifications if any are present
|
||||
defp send_notifications_if_any(notifications) do
|
||||
if Enum.any?(notifications) do
|
||||
Ash.Notifier.notify(notifications)
|
||||
end
|
||||
end
|
||||
|
||||
# Logs successful cycle generation
|
||||
defp log_cycle_generation_success(member, cycles, notifications, sync: sync?) do
|
||||
require Logger
|
||||
|
||||
sync_label = if sync?, do: "", else: " (async)"
|
||||
|
||||
Logger.debug(
|
||||
"Successfully generated cycles for member#{sync_label}",
|
||||
member_id: member.id,
|
||||
cycles_count: length(cycles),
|
||||
notifications_count: length(notifications)
|
||||
)
|
||||
end
|
||||
|
||||
# Logs cycle generation errors
|
||||
defp log_cycle_generation_error(member, reason, sync: sync?) do
|
||||
require Logger
|
||||
|
||||
sync_label = if sync?, do: "", else: " (async)"
|
||||
|
||||
Logger.error(
|
||||
"Failed to generate cycles for member#{sync_label}",
|
||||
member_id: member.id,
|
||||
member_email: member.email,
|
||||
error: inspect(reason),
|
||||
error_type: error_type(reason)
|
||||
)
|
||||
end
|
||||
|
||||
# Helper to extract error type for structured logging
|
||||
defp error_type(%{__struct__: struct_name}), do: struct_name
|
||||
defp error_type(error) when is_atom(error), do: error
|
||||
defp error_type(_), do: :unknown
|
||||
|
||||
# Normalizes visibility config map keys from strings to atoms.
|
||||
# JSONB in PostgreSQL converts atom keys to string keys when storing.
|
||||
defp normalize_visibility_config(config) when is_map(config) do
|
||||
|
|
@ -1062,4 +1162,127 @@ defmodule Mv.Membership.Member do
|
|||
query
|
||||
end
|
||||
end
|
||||
|
||||
# Extracts provided custom field values from changeset
|
||||
# Handles both create (from argument) and update (from existing data) scenarios
|
||||
defp provided_custom_field_values(changeset) do
|
||||
custom_field_values_arg = Ash.Changeset.get_argument(changeset, :custom_field_values)
|
||||
|
||||
if is_nil(custom_field_values_arg) do
|
||||
extract_existing_values(changeset.data)
|
||||
else
|
||||
extract_argument_values(custom_field_values_arg)
|
||||
end
|
||||
end
|
||||
|
||||
# Extracts custom field values from existing member data (update scenario)
|
||||
defp extract_existing_values(member_data) do
|
||||
case Ash.load(member_data, :custom_field_values) do
|
||||
{:ok, %{custom_field_values: existing_values}} ->
|
||||
Enum.reduce(existing_values, %{}, &extract_value_from_cfv/2)
|
||||
|
||||
_ ->
|
||||
%{}
|
||||
end
|
||||
end
|
||||
|
||||
# Extracts value from a CustomFieldValue struct
|
||||
defp extract_value_from_cfv(cfv, acc) do
|
||||
value = extract_union_value(cfv.value)
|
||||
Map.put(acc, cfv.custom_field_id, value)
|
||||
end
|
||||
|
||||
# Extracts value from union type (map or direct value)
|
||||
defp extract_union_value(value) when is_map(value), do: Map.get(value, :value)
|
||||
defp extract_union_value(value), do: value
|
||||
|
||||
# Extracts custom field values from provided argument (create/update scenario)
|
||||
defp extract_argument_values(custom_field_values_arg) do
|
||||
Enum.reduce(custom_field_values_arg, %{}, &extract_value_from_arg/2)
|
||||
end
|
||||
|
||||
# Extracts value from argument map
|
||||
defp extract_value_from_arg(cfv, acc) do
|
||||
custom_field_id = Map.get(cfv, "custom_field_id")
|
||||
value_map = Map.get(cfv, "value", %{})
|
||||
actual_value = extract_value_from_map(value_map)
|
||||
Map.put(acc, custom_field_id, actual_value)
|
||||
end
|
||||
|
||||
# Extracts value from map, supporting both "value" and "_union_value" keys
|
||||
# Also handles Ash.Union structs (which have atom keys :value and :type)
|
||||
# Uses cond instead of || to preserve false values
|
||||
defp extract_value_from_map(value_map) do
|
||||
cond do
|
||||
# Handle Ash.Union struct - check if it's a struct with __struct__ == Ash.Union
|
||||
match?({:ok, Ash.Union}, Map.fetch(value_map, :__struct__)) ->
|
||||
Map.get(value_map, :value)
|
||||
|
||||
# Handle map with string keys
|
||||
Map.has_key?(value_map, "value") ->
|
||||
Map.get(value_map, "value")
|
||||
|
||||
Map.has_key?(value_map, "_union_value") ->
|
||||
Map.get(value_map, "_union_value")
|
||||
|
||||
# Handle map with atom keys
|
||||
Map.has_key?(value_map, :value) ->
|
||||
Map.get(value_map, :value)
|
||||
|
||||
true ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
# Finds which required custom fields are missing from provided values
|
||||
defp missing_required_fields(required_custom_fields, provided_values) do
|
||||
Enum.filter(required_custom_fields, fn cf ->
|
||||
value = Map.get(provided_values, cf.id)
|
||||
not value_present?(value, cf.value_type)
|
||||
end)
|
||||
end
|
||||
|
||||
# Builds validation error message for missing required custom fields
|
||||
defp build_custom_field_validation_error(missing_fields) do
|
||||
# Sort missing fields alphabetically for consistent error messages
|
||||
sorted_missing_fields = Enum.sort_by(missing_fields, & &1.name)
|
||||
missing_names = Enum.map_join(sorted_missing_fields, ", ", & &1.name)
|
||||
|
||||
{:error,
|
||||
field: :custom_field_values,
|
||||
message:
|
||||
Gettext.dgettext(MvWeb.Gettext, "default", "Required custom fields missing: %{fields}",
|
||||
fields: missing_names
|
||||
)}
|
||||
end
|
||||
|
||||
# Helper function to check if a value is present for a given custom field type
|
||||
# Boolean: false is valid, only nil is invalid
|
||||
# String: nil or empty strings are invalid
|
||||
# Integer: nil or empty strings are invalid, 0 is valid
|
||||
# Date: nil or empty strings are invalid
|
||||
# Email: nil or empty strings are invalid
|
||||
defp value_present?(nil, _type), do: false
|
||||
|
||||
defp value_present?(value, :boolean), do: not is_nil(value)
|
||||
|
||||
defp value_present?(value, :string), do: is_binary(value) and String.trim(value) != ""
|
||||
|
||||
defp value_present?(value, :integer) when is_integer(value), do: true
|
||||
|
||||
defp value_present?(value, :integer) when is_binary(value), do: String.trim(value) != ""
|
||||
|
||||
defp value_present?(_value, :integer), do: false
|
||||
|
||||
defp value_present?(value, :date) when is_struct(value, Date), do: true
|
||||
|
||||
defp value_present?(value, :date) when is_binary(value), do: String.trim(value) != ""
|
||||
|
||||
defp value_present?(_value, :date), do: false
|
||||
|
||||
defp value_present?(value, :email) when is_binary(value), do: String.trim(value) != ""
|
||||
|
||||
defp value_present?(_value, :email), do: false
|
||||
|
||||
defp value_present?(_value, _type), do: false
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
defmodule Mv.Membership.Member.Changes.SetDefaultMembershipFeeType do
|
||||
@moduledoc """
|
||||
Ash change that automatically assigns the default membership fee type to new members
|
||||
if no membership_fee_type_id is explicitly provided.
|
||||
|
||||
This change reads the default_membership_fee_type_id from global settings and
|
||||
assigns it to the member if membership_fee_type_id is nil.
|
||||
"""
|
||||
use Ash.Resource.Change
|
||||
|
||||
@spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t()
|
||||
def change(changeset, _opts, _context) do
|
||||
# Only set default if membership_fee_type_id is not already set
|
||||
current_type_id = Ash.Changeset.get_attribute(changeset, :membership_fee_type_id)
|
||||
|
||||
if is_nil(current_type_id) do
|
||||
apply_default_membership_fee_type(changeset)
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
defp apply_default_membership_fee_type(changeset) do
|
||||
case Mv.Membership.get_settings() do
|
||||
{:ok, settings} ->
|
||||
if settings.default_membership_fee_type_id do
|
||||
Ash.Changeset.force_change_attribute(
|
||||
changeset,
|
||||
:membership_fee_type_id,
|
||||
settings.default_membership_fee_type_id
|
||||
)
|
||||
else
|
||||
changeset
|
||||
end
|
||||
|
||||
{:error, _error} ->
|
||||
# If settings can't be loaded, continue without default
|
||||
# This prevents member creation from failing if settings are misconfigured
|
||||
changeset
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -21,6 +21,9 @@ defmodule Mv.Membership do
|
|||
use Ash.Domain,
|
||||
extensions: [AshAdmin.Domain, AshPhoenix]
|
||||
|
||||
require Ash.Query
|
||||
import Ash.Expr
|
||||
|
||||
admin do
|
||||
show? true
|
||||
end
|
||||
|
|
@ -125,6 +128,29 @@ defmodule Mv.Membership do
|
|||
|> Ash.update(domain: __MODULE__)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Lists only required custom fields.
|
||||
|
||||
This is an optimized version that filters at the database level instead of
|
||||
loading all custom fields and filtering in memory.
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, required_custom_fields}` - List of required custom fields
|
||||
- `{:error, error}` - Error reading custom fields
|
||||
|
||||
## Examples
|
||||
|
||||
iex> {:ok, required_fields} = Mv.Membership.list_required_custom_fields()
|
||||
iex> Enum.all?(required_fields, & &1.required)
|
||||
true
|
||||
"""
|
||||
def list_required_custom_fields do
|
||||
Mv.Membership.CustomField
|
||||
|> Ash.Query.filter(expr(required == true))
|
||||
|> Ash.read(domain: __MODULE__)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates the member field visibility configuration.
|
||||
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do
|
|||
|
||||
update :update do
|
||||
primary? true
|
||||
accept [:status, :notes]
|
||||
accept [:status, :notes, :amount]
|
||||
end
|
||||
|
||||
update :mark_as_paid do
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ defmodule Mv.Application do
|
|||
children = [
|
||||
MvWeb.Telemetry,
|
||||
Mv.Repo,
|
||||
{Task.Supervisor, name: Mv.TaskSupervisor},
|
||||
{DNSCluster, query: Application.get_env(:mv, :dns_cluster_query) || :ignore},
|
||||
{Phoenix.PubSub, name: Mv.PubSub},
|
||||
{AshAuthentication.Supervisor, otp_app: :my},
|
||||
|
|
|
|||
24
lib/mv/config.ex
Normal file
24
lib/mv/config.ex
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
defmodule Mv.Config do
|
||||
@moduledoc """
|
||||
Configuration helper functions for the application.
|
||||
|
||||
Provides centralized access to configuration values to avoid
|
||||
magic strings/atoms scattered throughout the codebase.
|
||||
"""
|
||||
|
||||
@doc """
|
||||
Returns whether SQL sandbox mode is enabled.
|
||||
|
||||
SQL sandbox mode is typically enabled in test environments
|
||||
to allow concurrent database access in tests.
|
||||
|
||||
## Returns
|
||||
|
||||
- `true` if SQL sandbox is enabled
|
||||
- `false` otherwise
|
||||
"""
|
||||
@spec sql_sandbox?() :: boolean()
|
||||
def sql_sandbox? do
|
||||
Application.get_env(:mv, :sql_sandbox, false)
|
||||
end
|
||||
end
|
||||
|
|
@ -7,7 +7,6 @@ defmodule Mv.Constants do
|
|||
:first_name,
|
||||
:last_name,
|
||||
:email,
|
||||
:paid,
|
||||
:phone_number,
|
||||
:join_date,
|
||||
:exit_date,
|
||||
|
|
|
|||
|
|
@ -299,11 +299,15 @@ defmodule Mv.MembershipFees.CalendarCycles do
|
|||
end
|
||||
|
||||
defp quarterly_cycle_end(cycle_start) do
|
||||
case cycle_start.month do
|
||||
1 -> Date.new!(cycle_start.year, 3, 31)
|
||||
4 -> Date.new!(cycle_start.year, 6, 30)
|
||||
7 -> Date.new!(cycle_start.year, 9, 30)
|
||||
10 -> Date.new!(cycle_start.year, 12, 31)
|
||||
# Ensure cycle_start is aligned to quarter boundary
|
||||
# This handles cases where cycle_start might not be at the correct quarter start (e.g., month 12)
|
||||
aligned_start = quarterly_cycle_start(cycle_start)
|
||||
|
||||
case aligned_start.month do
|
||||
1 -> Date.new!(aligned_start.year, 3, 31)
|
||||
4 -> Date.new!(aligned_start.year, 6, 30)
|
||||
7 -> Date.new!(aligned_start.year, 9, 30)
|
||||
10 -> Date.new!(aligned_start.year, 12, 31)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -313,9 +317,13 @@ defmodule Mv.MembershipFees.CalendarCycles do
|
|||
end
|
||||
|
||||
defp half_yearly_cycle_end(cycle_start) do
|
||||
case cycle_start.month do
|
||||
1 -> Date.new!(cycle_start.year, 6, 30)
|
||||
7 -> Date.new!(cycle_start.year, 12, 31)
|
||||
# Ensure cycle_start is aligned to half-year boundary
|
||||
# This handles cases where cycle_start might not be at the correct half-year start (e.g., month 10)
|
||||
aligned_start = half_yearly_cycle_start(cycle_start)
|
||||
|
||||
case aligned_start.month do
|
||||
1 -> Date.new!(aligned_start.year, 6, 30)
|
||||
7 -> Date.new!(aligned_start.year, 12, 31)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -379,25 +379,34 @@ defmodule Mv.MembershipFees.CycleGenerator do
|
|||
status: :unpaid
|
||||
}
|
||||
|
||||
case Ash.create(MembershipFeeCycle, attrs, return_notifications?: true) do
|
||||
{:ok, cycle, notifications} when is_list(notifications) ->
|
||||
{:ok, cycle, notifications}
|
||||
|
||||
{:ok, cycle} ->
|
||||
{:ok, cycle, []}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, {cycle_start, reason}}
|
||||
end
|
||||
handle_cycle_creation_result(
|
||||
Ash.create(MembershipFeeCycle, attrs, return_notifications?: true),
|
||||
cycle_start
|
||||
)
|
||||
end)
|
||||
|
||||
{successes, errors} = Enum.split_with(results, &match?({:ok, _, _}, &1))
|
||||
{successes, skips, errors} =
|
||||
Enum.reduce(results, {[], [], []}, fn
|
||||
{:ok, cycle, notifications}, {successes, skips, errors} ->
|
||||
{[{:ok, cycle, notifications} | successes], skips, errors}
|
||||
|
||||
{:skip, cycle_start}, {successes, skips, errors} ->
|
||||
{successes, [cycle_start | skips], errors}
|
||||
|
||||
{:error, error}, {successes, skips, errors} ->
|
||||
{successes, skips, [error | errors]}
|
||||
end)
|
||||
|
||||
all_notifications =
|
||||
Enum.flat_map(successes, fn {:ok, _cycle, notifications} -> notifications end)
|
||||
|
||||
if Enum.empty?(errors) do
|
||||
successful_cycles = Enum.map(successes, fn {:ok, cycle, _notifications} -> cycle end)
|
||||
|
||||
if Enum.any?(skips) do
|
||||
Logger.debug("Skipped #{length(skips)} cycles that already exist for member #{member_id}")
|
||||
end
|
||||
|
||||
{:ok, successful_cycles, all_notifications}
|
||||
else
|
||||
Logger.warning("Some cycles failed to create: #{inspect(errors)}")
|
||||
|
|
@ -407,4 +416,45 @@ defmodule Mv.MembershipFees.CycleGenerator do
|
|||
{:error, {:partial_failure, errors}}
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_cycle_creation_result({:ok, cycle, notifications}, _cycle_start)
|
||||
when is_list(notifications) do
|
||||
{:ok, cycle, notifications}
|
||||
end
|
||||
|
||||
defp handle_cycle_creation_result({:ok, cycle}, _cycle_start) do
|
||||
{:ok, cycle, []}
|
||||
end
|
||||
|
||||
defp handle_cycle_creation_result(
|
||||
{:error,
|
||||
%Ash.Error.Invalid{
|
||||
errors: [
|
||||
%Ash.Error.Changes.InvalidAttribute{
|
||||
private_vars: %{constraint: constraint, constraint_type: :unique}
|
||||
}
|
||||
]
|
||||
}} = error,
|
||||
cycle_start
|
||||
) do
|
||||
# Cycle already exists (unique constraint violation) - skip it silently
|
||||
# This makes the function idempotent and prevents errors on server restart
|
||||
handle_unique_constraint_violation(constraint, cycle_start, error)
|
||||
end
|
||||
|
||||
defp handle_cycle_creation_result({:error, reason}, cycle_start) do
|
||||
{:error, {cycle_start, reason}}
|
||||
end
|
||||
|
||||
defp handle_unique_constraint_violation(
|
||||
"membership_fee_cycles_unique_cycle_per_member_index",
|
||||
cycle_start,
|
||||
_error
|
||||
) do
|
||||
{:skip, cycle_start}
|
||||
end
|
||||
|
||||
defp handle_unique_constraint_violation(_constraint, cycle_start, error) do
|
||||
{:error, {cycle_start, error}}
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -333,7 +333,8 @@ defmodule MvWeb.CoreComponents do
|
|||
attr :error_class, :string, default: nil, doc: "the input error class to use over defaults"
|
||||
|
||||
attr :rest, :global,
|
||||
include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
|
||||
include:
|
||||
~w(accept autocomplete aria-required capture cols disabled form list max maxlength min minlength
|
||||
multiple pattern placeholder readonly required rows size step)
|
||||
|
||||
def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
|
||||
|
|
@ -353,6 +354,24 @@ defmodule MvWeb.CoreComponents do
|
|||
Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
|
||||
end)
|
||||
|
||||
# For checkboxes, we don't use HTML required attribute (means "must be checked")
|
||||
# Instead, we use aria-required for screen readers (WCAG 2.1, Success Criterion 3.3.2)
|
||||
# Extract required from rest and remove it, but keep aria-required if provided
|
||||
rest = assigns.rest || %{}
|
||||
is_required = Map.get(rest, :required, false)
|
||||
aria_required = Map.get(rest, :aria_required, if(is_required, do: "true", else: nil))
|
||||
|
||||
# Remove required from rest (we don't want HTML required on checkbox)
|
||||
rest_without_required = Map.delete(rest, :required)
|
||||
# Ensure aria-required is set if field is required
|
||||
rest_final =
|
||||
if aria_required,
|
||||
do: Map.put(rest_without_required, :aria_required, aria_required),
|
||||
else: rest_without_required
|
||||
|
||||
assigns = assign(assigns, :rest, rest_final)
|
||||
assigns = assign(assigns, :is_required, is_required)
|
||||
|
||||
~H"""
|
||||
<fieldset class="mb-2 fieldset">
|
||||
<label>
|
||||
|
|
@ -367,9 +386,9 @@ defmodule MvWeb.CoreComponents do
|
|||
class={@class || "checkbox checkbox-sm"}
|
||||
{@rest}
|
||||
/>{@label}<span
|
||||
:if={@rest[:required]}
|
||||
:if={@is_required}
|
||||
class="text-red-700 tooltip tooltip-right"
|
||||
data-tip={gettext("This field cannot be empty")}
|
||||
data-tip={gettext("This field is required")}
|
||||
>*</span>
|
||||
</span>
|
||||
</label>
|
||||
|
|
|
|||
|
|
@ -32,7 +32,9 @@ defmodule MvWeb.Layouts.Navbar do
|
|||
<details>
|
||||
<summary>{gettext("Contributions")}</summary>
|
||||
<ul class="bg-base-200 rounded-t-none p-2 z-10 w-48">
|
||||
<li><.link navigate="/contribution_types">{gettext("Contribution Types")}</.link></li>
|
||||
<li>
|
||||
<.link navigate="/membership_fee_types">{gettext("Membership Fee Types")}</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link navigate="/membership_fee_settings">
|
||||
{gettext("Membership Fee Settings")}
|
||||
|
|
|
|||
241
lib/mv_web/helpers/membership_fee_helpers.ex
Normal file
241
lib/mv_web/helpers/membership_fee_helpers.ex
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
defmodule MvWeb.Helpers.MembershipFeeHelpers do
|
||||
@moduledoc """
|
||||
Helper functions for membership fee UI components.
|
||||
|
||||
Provides formatting and utility functions for displaying membership fee
|
||||
information in LiveViews and templates.
|
||||
"""
|
||||
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
alias Mv.MembershipFees.CalendarCycles
|
||||
alias Mv.MembershipFees.MembershipFeeCycle
|
||||
alias Mv.Membership.Member
|
||||
|
||||
@doc """
|
||||
Formats a decimal amount as currency string.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> MvWeb.Helpers.MembershipFeeHelpers.format_currency(Decimal.new("60.00"))
|
||||
"60,00 €"
|
||||
|
||||
iex> MvWeb.Helpers.MembershipFeeHelpers.format_currency(Decimal.new("5.5"))
|
||||
"5,50 €"
|
||||
"""
|
||||
@spec format_currency(Decimal.t()) :: String.t()
|
||||
def format_currency(%Decimal{} = amount) do
|
||||
# Use German format: comma as decimal separator, always 2 decimal places
|
||||
normalized = Decimal.round(amount, 2)
|
||||
normalized_str = Decimal.to_string(normalized, :normal)
|
||||
|
||||
format_currency_parts(normalized_str)
|
||||
end
|
||||
|
||||
# Formats currency string with comma as decimal separator
|
||||
defp format_currency_parts(normalized_str) do
|
||||
case String.split(normalized_str, ".") do
|
||||
[int_part, dec_part] ->
|
||||
format_with_decimal_part(int_part, dec_part)
|
||||
|
||||
[int_part] ->
|
||||
"#{int_part},00 €"
|
||||
|
||||
_ ->
|
||||
# Fallback for unexpected split results
|
||||
"#{String.replace(normalized_str, ".", ",")} €"
|
||||
end
|
||||
end
|
||||
|
||||
# Formats currency with decimal part, ensuring exactly 2 decimal places
|
||||
defp format_with_decimal_part(int_part, dec_part) do
|
||||
dec_size = byte_size(dec_part)
|
||||
|
||||
formatted_dec =
|
||||
cond do
|
||||
dec_size == 1 -> "#{dec_part}0"
|
||||
dec_size == 2 -> dec_part
|
||||
dec_size > 2 -> String.slice(dec_part, 0, 2)
|
||||
true -> "00"
|
||||
end
|
||||
|
||||
"#{int_part},#{formatted_dec} €"
|
||||
end
|
||||
|
||||
@doc """
|
||||
Formats an interval atom as a translated string.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> MvWeb.Helpers.MembershipFeeHelpers.format_interval(:monthly)
|
||||
"Monthly"
|
||||
|
||||
iex> MvWeb.Helpers.MembershipFeeHelpers.format_interval(:yearly)
|
||||
"Yearly"
|
||||
"""
|
||||
@spec format_interval(:monthly | :quarterly | :half_yearly | :yearly) :: String.t()
|
||||
def format_interval(:monthly), do: gettext("Monthly")
|
||||
def format_interval(:quarterly), do: gettext("Quarterly")
|
||||
def format_interval(:half_yearly), do: gettext("Half-yearly")
|
||||
def format_interval(:yearly), do: gettext("Yearly")
|
||||
|
||||
@doc """
|
||||
Formats a cycle date range as a string.
|
||||
|
||||
Calculates the cycle end date from cycle_start and interval, then formats
|
||||
both dates in European format (dd.mm.yyyy).
|
||||
|
||||
## Examples
|
||||
|
||||
iex> cycle_start = ~D[2024-01-01]
|
||||
iex> MvWeb.Helpers.MembershipFeeHelpers.format_cycle_range(cycle_start, :yearly)
|
||||
"01.01.2024 - 31.12.2024"
|
||||
|
||||
iex> cycle_start = ~D[2024-03-01]
|
||||
iex> MvWeb.Helpers.MembershipFeeHelpers.format_cycle_range(cycle_start, :monthly)
|
||||
"01.03.2024 - 31.03.2024"
|
||||
"""
|
||||
@spec format_cycle_range(Date.t(), :monthly | :quarterly | :half_yearly | :yearly) :: String.t()
|
||||
def format_cycle_range(cycle_start, interval) do
|
||||
cycle_end = CalendarCycles.calculate_cycle_end(cycle_start, interval)
|
||||
start_str = format_date(cycle_start)
|
||||
end_str = format_date(cycle_end)
|
||||
"#{start_str} - #{end_str}"
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the last completed cycle for a member.
|
||||
|
||||
Returns the cycle that was most recently completed (ended before today).
|
||||
Returns `nil` if no completed cycles exist.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `member` - Member struct with loaded membership_fee_cycles and membership_fee_type
|
||||
- `today` - Optional date to use as reference (defaults to today)
|
||||
|
||||
## Returns
|
||||
|
||||
- `%MembershipFeeCycle{}` if found
|
||||
- `nil` if no completed cycle exists
|
||||
|
||||
## Examples
|
||||
|
||||
# Member with cycles from 2023 and 2024, today is 2025-01-15
|
||||
iex> cycle = MvWeb.Helpers.MembershipFeeHelpers.get_last_completed_cycle(member)
|
||||
# => %MembershipFeeCycle{cycle_start: ~D[2024-01-01], ...}
|
||||
"""
|
||||
@spec get_last_completed_cycle(Member.t(), Date.t() | nil) :: MembershipFeeCycle.t() | nil
|
||||
def get_last_completed_cycle(member, today \\ nil)
|
||||
|
||||
def get_last_completed_cycle(%Member{} = member, today) do
|
||||
today = today || Date.utc_today()
|
||||
|
||||
case member.membership_fee_type do
|
||||
nil ->
|
||||
nil
|
||||
|
||||
fee_type ->
|
||||
cycles = member.membership_fee_cycles || []
|
||||
|
||||
# Get all completed cycles (cycle_end < today)
|
||||
completed_cycles =
|
||||
cycles
|
||||
|> Enum.filter(fn cycle ->
|
||||
cycle_end = CalendarCycles.calculate_cycle_end(cycle.cycle_start, fee_type.interval)
|
||||
Date.compare(today, cycle_end) == :gt
|
||||
end)
|
||||
|
||||
# Return the most recent completed cycle (highest cycle_start)
|
||||
completed_cycles
|
||||
|> Enum.max_by(& &1.cycle_start, Date, fn -> nil end)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the current cycle for a member.
|
||||
|
||||
Returns the cycle that contains today's date.
|
||||
Returns `nil` if no current cycle exists.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `member` - Member struct with loaded membership_fee_cycles and membership_fee_type
|
||||
- `today` - Optional date to use as reference (defaults to today)
|
||||
|
||||
## Returns
|
||||
|
||||
- `%MembershipFeeCycle{}` if found
|
||||
- `nil` if no current cycle exists
|
||||
|
||||
## Examples
|
||||
|
||||
# Member with cycles, today is 2024-06-15 (within Q2 2024)
|
||||
iex> cycle = MvWeb.Helpers.MembershipFeeHelpers.get_current_cycle(member)
|
||||
# => %MembershipFeeCycle{cycle_start: ~D[2024-04-01], ...}
|
||||
"""
|
||||
@spec get_current_cycle(Member.t(), Date.t() | nil) :: MembershipFeeCycle.t() | nil
|
||||
def get_current_cycle(member, today \\ nil)
|
||||
|
||||
def get_current_cycle(%Member{} = member, today) do
|
||||
today = today || Date.utc_today()
|
||||
|
||||
case member.membership_fee_type do
|
||||
nil ->
|
||||
nil
|
||||
|
||||
fee_type ->
|
||||
cycles = member.membership_fee_cycles || []
|
||||
|
||||
cycles
|
||||
|> Enum.filter(fn cycle ->
|
||||
CalendarCycles.current_cycle?(cycle.cycle_start, fee_type.interval, today)
|
||||
end)
|
||||
|> Enum.sort_by(& &1.cycle_start, {:desc, Date})
|
||||
|> List.first()
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the CSS color class for a status badge.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> MvWeb.Helpers.MembershipFeeHelpers.status_color(:paid)
|
||||
"badge-success"
|
||||
|
||||
iex> MvWeb.Helpers.MembershipFeeHelpers.status_color(:unpaid)
|
||||
"badge-error"
|
||||
|
||||
iex> MvWeb.Helpers.MembershipFeeHelpers.status_color(:suspended)
|
||||
"badge-ghost"
|
||||
"""
|
||||
@spec status_color(:paid | :unpaid | :suspended) :: String.t()
|
||||
def status_color(:paid), do: "badge-success"
|
||||
def status_color(:unpaid), do: "badge-error"
|
||||
def status_color(:suspended), do: "badge-ghost"
|
||||
|
||||
@doc """
|
||||
Gets the icon name for a status.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> MvWeb.Helpers.MembershipFeeHelpers.status_icon(:paid)
|
||||
"hero-check-circle"
|
||||
|
||||
iex> MvWeb.Helpers.MembershipFeeHelpers.status_icon(:unpaid)
|
||||
"hero-x-circle"
|
||||
|
||||
iex> MvWeb.Helpers.MembershipFeeHelpers.status_icon(:suspended)
|
||||
"hero-pause-circle"
|
||||
"""
|
||||
@spec status_icon(:paid | :unpaid | :suspended) :: String.t()
|
||||
def status_icon(:paid), do: "hero-check-circle"
|
||||
def status_icon(:unpaid), do: "hero-x-circle"
|
||||
def status_icon(:suspended), do: "hero-pause-circle"
|
||||
|
||||
# Private helper function for date formatting
|
||||
defp format_date(%Date{} = date) do
|
||||
Calendar.strftime(date, "%d.%m.%Y")
|
||||
end
|
||||
end
|
||||
|
|
@ -2,11 +2,12 @@ defmodule MvWeb.Components.PaymentFilterComponent do
|
|||
@moduledoc """
|
||||
Provides the PaymentFilter Live-Component.
|
||||
|
||||
A dropdown filter for filtering members by payment status (paid/not paid/all).
|
||||
A dropdown filter for filtering members by cycle payment status (paid/unpaid/all).
|
||||
Uses DaisyUI dropdown styling and sends filter changes to parent LiveView.
|
||||
Filter is based on cycle status (last or current cycle, depending on cycle view toggle).
|
||||
|
||||
## Props
|
||||
- `:paid_filter` - Current filter state: `nil` (all), `:paid`, or `:not_paid`
|
||||
- `:cycle_status_filter` - Current filter state: `nil` (all), `:paid`, or `:unpaid`
|
||||
- `:id` - Component ID (required)
|
||||
- `:member_count` - Number of filtered members to display in badge (optional, default: 0)
|
||||
|
||||
|
|
@ -25,7 +26,7 @@ defmodule MvWeb.Components.PaymentFilterComponent do
|
|||
socket =
|
||||
socket
|
||||
|> assign(:id, assigns.id)
|
||||
|> assign(:paid_filter, assigns[:paid_filter])
|
||||
|> assign(:cycle_status_filter, assigns[:cycle_status_filter])
|
||||
|> assign(:member_count, assigns[:member_count] || 0)
|
||||
|
||||
{:ok, socket}
|
||||
|
|
@ -45,7 +46,7 @@ defmodule MvWeb.Components.PaymentFilterComponent do
|
|||
type="button"
|
||||
class={[
|
||||
"btn gap-2",
|
||||
@paid_filter && "btn-active"
|
||||
@cycle_status_filter && "btn-active"
|
||||
]}
|
||||
phx-click="toggle_dropdown"
|
||||
phx-target={@myself}
|
||||
|
|
@ -54,8 +55,8 @@ defmodule MvWeb.Components.PaymentFilterComponent do
|
|||
aria-label={gettext("Filter by payment status")}
|
||||
>
|
||||
<.icon name="hero-funnel" class="h-5 w-5" />
|
||||
<span class="hidden sm:inline">{filter_label(@paid_filter)}</span>
|
||||
<span :if={@paid_filter} class="badge badge-primary badge-sm">{@member_count}</span>
|
||||
<span class="hidden sm:inline">{filter_label(@cycle_status_filter)}</span>
|
||||
<span :if={@cycle_status_filter} class="badge badge-primary badge-sm">{@member_count}</span>
|
||||
</button>
|
||||
|
||||
<ul
|
||||
|
|
@ -70,22 +71,22 @@ defmodule MvWeb.Components.PaymentFilterComponent do
|
|||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={to_string(@paid_filter == nil)}
|
||||
class={@paid_filter == nil && "active"}
|
||||
aria-checked={to_string(@cycle_status_filter == nil)}
|
||||
class={@cycle_status_filter == nil && "active"}
|
||||
phx-click="select_filter"
|
||||
phx-value-filter=""
|
||||
phx-target={@myself}
|
||||
>
|
||||
<.icon name="hero-users" class="h-4 w-4" />
|
||||
{gettext("All payment statuses")}
|
||||
{gettext("All")}
|
||||
</button>
|
||||
</li>
|
||||
<li role="none">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={to_string(@paid_filter == :paid)}
|
||||
class={@paid_filter == :paid && "active"}
|
||||
aria-checked={to_string(@cycle_status_filter == :paid)}
|
||||
class={@cycle_status_filter == :paid && "active"}
|
||||
phx-click="select_filter"
|
||||
phx-value-filter="paid"
|
||||
phx-target={@myself}
|
||||
|
|
@ -98,14 +99,14 @@ defmodule MvWeb.Components.PaymentFilterComponent do
|
|||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={to_string(@paid_filter == :not_paid)}
|
||||
class={@paid_filter == :not_paid && "active"}
|
||||
aria-checked={to_string(@cycle_status_filter == :unpaid)}
|
||||
class={@cycle_status_filter == :unpaid && "active"}
|
||||
phx-click="select_filter"
|
||||
phx-value-filter="not_paid"
|
||||
phx-value-filter="unpaid"
|
||||
phx-target={@myself}
|
||||
>
|
||||
<.icon name="hero-x-circle" class="h-4 w-4 text-error" />
|
||||
{gettext("Not paid")}
|
||||
{gettext("Unpaid")}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
|
@ -136,11 +137,11 @@ defmodule MvWeb.Components.PaymentFilterComponent do
|
|||
|
||||
# Parse filter string to atom
|
||||
defp parse_filter("paid"), do: :paid
|
||||
defp parse_filter("not_paid"), do: :not_paid
|
||||
defp parse_filter("unpaid"), do: :unpaid
|
||||
defp parse_filter(_), do: nil
|
||||
|
||||
# Get display label for current filter
|
||||
defp filter_label(nil), do: gettext("All payment statuses")
|
||||
defp filter_label(nil), do: gettext("All")
|
||||
defp filter_label(:paid), do: gettext("Paid")
|
||||
defp filter_label(:not_paid), do: gettext("Not paid")
|
||||
defp filter_label(:unpaid), do: gettext("Unpaid")
|
||||
end
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ defmodule MvWeb.MemberLive.Form do
|
|||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
alias Mv.MembershipFees
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
# Sort custom fields by name for display only
|
||||
|
|
@ -144,6 +148,7 @@ defmodule MvWeb.MemberLive.Form do
|
|||
field={value_form[:value]}
|
||||
label={cf.name}
|
||||
type={custom_field_input_type(cf.value_type)}
|
||||
required={cf.required}
|
||||
/>
|
||||
</.inputs_for>
|
||||
<input
|
||||
|
|
@ -161,42 +166,46 @@ defmodule MvWeb.MemberLive.Form do
|
|||
<% end %>
|
||||
</div>
|
||||
|
||||
<%!-- Payment Data Section (Mockup) --%>
|
||||
<%!-- Membership Fee Section --%>
|
||||
<div class="max-w-xl">
|
||||
<.form_section title={gettext("Payment Data")}>
|
||||
<div role="alert" class="alert alert-info mb-4">
|
||||
<.icon name="hero-information-circle" class="size-5" />
|
||||
<span>{gettext("This data is for demonstration purposes only (mockup).")}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-8">
|
||||
<div class="w-24">
|
||||
<label for="mock-contribution" class="label text-sm font-medium">
|
||||
{gettext("Contribution")}
|
||||
<.form_section title={gettext("Membership Fee")}>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">{gettext("Membership Fee Type")}</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="mock-contribution"
|
||||
value="72 €"
|
||||
disabled
|
||||
class="input input-bordered w-full bg-base-200"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-40">
|
||||
<label class="label text-sm font-medium">{gettext("Payment Cycle")}</label>
|
||||
<div class="flex gap-3 mt-2">
|
||||
<label class="flex items-center gap-1 cursor-not-allowed opacity-60">
|
||||
<input type="radio" name="mock_cycle" checked disabled class="radio radio-sm" />
|
||||
<span class="text-sm">{gettext("monthly")}</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-1 cursor-not-allowed opacity-60">
|
||||
<input type="radio" name="mock_cycle" disabled class="radio radio-sm" />
|
||||
<span class="text-sm">{gettext("yearly")}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-24 flex items-end">
|
||||
<.input field={@form[:paid]} label={gettext("Paid")} type="checkbox" />
|
||||
<select
|
||||
class="select select-bordered w-full"
|
||||
name={@form[:membership_fee_type_id].name}
|
||||
phx-change="validate_membership_fee_type"
|
||||
value={@form[:membership_fee_type_id].value || ""}
|
||||
>
|
||||
<option value="">{gettext("None")}</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 {msg, _opts} <- @form.errors[:membership_fee_type_id] || [] do %>
|
||||
<p class="text-error text-sm mt-1">{msg}</p>
|
||||
<% end %>
|
||||
<%= if @interval_warning do %>
|
||||
<div class="alert alert-warning mt-2">
|
||||
<.icon name="hero-exclamation-triangle" class="size-5" />
|
||||
<span>{@interval_warning}</span>
|
||||
</div>
|
||||
<% end %>
|
||||
<p class="text-sm text-base-content/60 mt-2">
|
||||
{gettext(
|
||||
"Select a membership fee type for this member. Members can only switch between types with the same interval."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</.form_section>
|
||||
|
|
@ -235,12 +244,15 @@ defmodule MvWeb.MemberLive.Form do
|
|||
member =
|
||||
case params["id"] do
|
||||
nil -> nil
|
||||
id -> Ash.get!(Mv.Membership.Member, id)
|
||||
id -> Ash.get!(Mv.Membership.Member, id, load: [:membership_fee_type])
|
||||
end
|
||||
|
||||
page_title =
|
||||
if is_nil(member), do: gettext("Create Member"), else: gettext("Edit Member")
|
||||
|
||||
# Load available membership fee types
|
||||
available_fee_types = load_available_fee_types(member)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:return_to, return_to(params["return_to"]))
|
||||
|
|
@ -248,6 +260,8 @@ defmodule MvWeb.MemberLive.Form do
|
|||
|> assign(:initial_custom_field_values, initial_custom_field_values)
|
||||
|> assign(member: member)
|
||||
|> assign(:page_title, page_title)
|
||||
|> assign(:available_fee_types, available_fee_types)
|
||||
|> assign(:interval_warning, nil)
|
||||
|> assign_form()}
|
||||
end
|
||||
|
||||
|
|
@ -256,7 +270,21 @@ defmodule MvWeb.MemberLive.Form do
|
|||
|
||||
@impl true
|
||||
def handle_event("validate", %{"member" => member_params}, socket) do
|
||||
{:noreply, assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, member_params))}
|
||||
validated_form = AshPhoenix.Form.validate(socket.assigns.form, member_params)
|
||||
|
||||
# Check for interval mismatch if membership_fee_type_id changed
|
||||
socket = check_interval_change(socket, member_params)
|
||||
|
||||
{:noreply, assign(socket, form: validated_form)}
|
||||
end
|
||||
|
||||
def handle_event(
|
||||
"validate_membership_fee_type",
|
||||
%{"member" => %{"membership_fee_type_id" => fee_type_id}},
|
||||
socket
|
||||
) do
|
||||
# Same validation as above, but triggered by select change
|
||||
handle_event("validate", %{"member" => %{"membership_fee_type_id" => fee_type_id}}, socket)
|
||||
end
|
||||
|
||||
def handle_event("save", %{"member" => member_params}, socket) do
|
||||
|
|
@ -348,6 +376,77 @@ defmodule MvWeb.MemberLive.Form do
|
|||
defp return_path("show", nil), do: ~p"/members"
|
||||
defp return_path("show", member), do: ~p"/members/#{member.id}"
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Helper Functions
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
defp load_available_fee_types(member) do
|
||||
all_types =
|
||||
MembershipFeeType
|
||||
|> Ash.Query.sort(name: :asc)
|
||||
|> Ash.read!(domain: MembershipFees)
|
||||
|
||||
# If member has a fee type, filter to same interval
|
||||
if member && member.membership_fee_type do
|
||||
Enum.filter(all_types, fn type ->
|
||||
type.interval == member.membership_fee_type.interval
|
||||
end)
|
||||
else
|
||||
all_types
|
||||
end
|
||||
end
|
||||
|
||||
# Checks if membership fee type interval changed and updates socket assigns
|
||||
defp check_interval_change(socket, member_params) do
|
||||
if Map.has_key?(member_params, "membership_fee_type_id") &&
|
||||
socket.assigns.member &&
|
||||
socket.assigns.member.membership_fee_type do
|
||||
handle_interval_change(socket, member_params["membership_fee_type_id"])
|
||||
else
|
||||
socket
|
||||
end
|
||||
end
|
||||
|
||||
# Handles interval change validation
|
||||
defp handle_interval_change(socket, new_fee_type_id) do
|
||||
if new_fee_type_id != "" &&
|
||||
new_fee_type_id != socket.assigns.member.membership_fee_type_id do
|
||||
validate_interval_match(socket, new_fee_type_id)
|
||||
else
|
||||
assign(socket, :interval_warning, nil)
|
||||
end
|
||||
end
|
||||
|
||||
# Validates that new fee type has same interval as current
|
||||
defp validate_interval_match(socket, new_fee_type_id) do
|
||||
new_fee_type = find_fee_type(socket.assigns.available_fee_types, new_fee_type_id)
|
||||
|
||||
if new_fee_type &&
|
||||
new_fee_type.interval != socket.assigns.member.membership_fee_type.interval do
|
||||
show_interval_warning(socket, new_fee_type)
|
||||
else
|
||||
assign(socket, :interval_warning, nil)
|
||||
end
|
||||
end
|
||||
|
||||
# Shows interval mismatch warning
|
||||
defp show_interval_warning(socket, new_fee_type) do
|
||||
assign(
|
||||
socket,
|
||||
:interval_warning,
|
||||
gettext(
|
||||
"Warning: Changing from %{old_interval} to %{new_interval} is not allowed. Please select a membership fee type with the same interval.",
|
||||
old_interval:
|
||||
MembershipFeeHelpers.format_interval(socket.assigns.member.membership_fee_type.interval),
|
||||
new_interval: MembershipFeeHelpers.format_interval(new_fee_type.interval)
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
defp find_fee_type(fee_types, fee_type_id) do
|
||||
Enum.find(fee_types, &(&1.id == fee_type_id))
|
||||
end
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Helper Functions for Custom Fields
|
||||
# -----------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
alias MvWeb.Helpers.DateFormatter
|
||||
alias MvWeb.MemberLive.Index.FieldSelection
|
||||
alias MvWeb.MemberLive.Index.FieldVisibility
|
||||
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
||||
|
||||
# Prefix used in sort field names for custom fields (e.g., "custom_field_<id>")
|
||||
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
||||
|
|
@ -97,7 +98,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|> assign(:query, "")
|
||||
|> assign_new(:sort_field, fn -> :first_name end)
|
||||
|> assign_new(:sort_order, fn -> :asc end)
|
||||
|> assign(:paid_filter, nil)
|
||||
|> assign(:cycle_status_filter, nil)
|
||||
|> assign(:selected_members, MapSet.new())
|
||||
|> assign(:settings, settings)
|
||||
|> assign(:custom_fields_visible, custom_fields_visible)
|
||||
|
|
@ -108,6 +109,8 @@ defmodule MvWeb.MemberLive.Index do
|
|||
:member_fields_visible,
|
||||
FieldVisibility.get_visible_member_fields(initial_selection)
|
||||
)
|
||||
|> assign(:show_current_cycle, false)
|
||||
|> assign(:membership_fee_status_filter, nil)
|
||||
|
||||
# We call handle params to use the query from the URL
|
||||
{:ok, socket}
|
||||
|
|
@ -168,6 +171,31 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|> update_selection_assigns()}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("toggle_cycle_view", _params, socket) do
|
||||
new_show_current = !socket.assigns.show_current_cycle
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:show_current_cycle, new_show_current)
|
||||
|> load_members()
|
||||
|> update_selection_assigns()
|
||||
|
||||
# Update URL to reflect cycle view change
|
||||
query_params =
|
||||
build_query_params(
|
||||
socket.assigns.query,
|
||||
socket.assigns.sort_field,
|
||||
socket.assigns.sort_order,
|
||||
socket.assigns.cycle_status_filter,
|
||||
new_show_current
|
||||
)
|
||||
|
||||
new_path = ~p"/members?#{query_params}"
|
||||
|
||||
{:noreply, push_patch(socket, to: new_path, replace: true)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("copy_emails", _params, socket) do
|
||||
selected_ids = socket.assigns.selected_members
|
||||
|
|
@ -251,7 +279,13 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|
||||
# Build the URL with queries
|
||||
query_params =
|
||||
build_query_params(q, existing_field_query, existing_sort_query, socket.assigns.paid_filter)
|
||||
build_query_params(
|
||||
q,
|
||||
existing_field_query,
|
||||
existing_sort_query,
|
||||
socket.assigns.cycle_status_filter,
|
||||
socket.assigns.show_current_cycle
|
||||
)
|
||||
|
||||
# Set the new path with params
|
||||
new_path = ~p"/members?#{query_params}"
|
||||
|
|
@ -268,7 +302,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
def handle_info({:payment_filter_changed, filter}, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:paid_filter, filter)
|
||||
|> assign(:cycle_status_filter, filter)
|
||||
|> load_members()
|
||||
|> update_selection_assigns()
|
||||
|
||||
|
|
@ -278,7 +312,8 @@ defmodule MvWeb.MemberLive.Index do
|
|||
socket.assigns.query,
|
||||
socket.assigns.sort_field,
|
||||
socket.assigns.sort_order,
|
||||
filter
|
||||
filter,
|
||||
socket.assigns.show_current_cycle
|
||||
)
|
||||
|
||||
new_path = ~p"/members?#{query_params}"
|
||||
|
|
@ -392,7 +427,8 @@ defmodule MvWeb.MemberLive.Index do
|
|||
socket
|
||||
|> maybe_update_search(params)
|
||||
|> maybe_update_sort(params)
|
||||
|> maybe_update_paid_filter(params)
|
||||
|> maybe_update_cycle_status_filter(params)
|
||||
|> maybe_update_show_current_cycle(params)
|
||||
|> assign(:query, params["query"])
|
||||
|> assign(:user_field_selection, final_selection)
|
||||
|> assign(:member_fields_visible, visible_member_fields)
|
||||
|
|
@ -501,7 +537,8 @@ defmodule MvWeb.MemberLive.Index do
|
|||
socket.assigns.query,
|
||||
field_str,
|
||||
Atom.to_string(order),
|
||||
socket.assigns.paid_filter
|
||||
socket.assigns.cycle_status_filter,
|
||||
socket.assigns.show_current_cycle
|
||||
)
|
||||
|
||||
new_path = ~p"/members?#{query_params}"
|
||||
|
|
@ -513,16 +550,6 @@ defmodule MvWeb.MemberLive.Index do
|
|||
)}
|
||||
end
|
||||
|
||||
# Builds query parameters including field selection
|
||||
defp build_query_params(socket, base_params) do
|
||||
# Use query from base_params if provided, otherwise fall back to socket.assigns.query
|
||||
query_value = Map.get(base_params, "query") || socket.assigns.query || ""
|
||||
|
||||
base_params
|
||||
|> Map.put("query", query_value)
|
||||
|> maybe_add_field_selection(socket.assigns[:user_field_selection])
|
||||
end
|
||||
|
||||
# Adds field selection to query params if present
|
||||
defp maybe_add_field_selection(params, nil), do: params
|
||||
|
||||
|
|
@ -535,29 +562,21 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|
||||
# Pushes URL with updated field selection
|
||||
defp push_field_selection_url(socket) do
|
||||
base_params = %{
|
||||
"sort_field" => field_to_string(socket.assigns.sort_field),
|
||||
"sort_order" => Atom.to_string(socket.assigns.sort_order)
|
||||
}
|
||||
query_params =
|
||||
build_query_params(
|
||||
socket.assigns.query,
|
||||
socket.assigns.sort_field,
|
||||
socket.assigns.sort_order,
|
||||
socket.assigns.cycle_status_filter,
|
||||
socket.assigns.show_current_cycle
|
||||
)
|
||||
|> maybe_add_field_selection(socket.assigns[:user_field_selection])
|
||||
|
||||
# Include paid_filter if set
|
||||
base_params =
|
||||
case socket.assigns.paid_filter do
|
||||
nil -> base_params
|
||||
:paid -> Map.put(base_params, "paid_filter", "paid")
|
||||
:not_paid -> Map.put(base_params, "paid_filter", "not_paid")
|
||||
end
|
||||
|
||||
query_params = build_query_params(socket, base_params)
|
||||
new_path = ~p"/members?#{query_params}"
|
||||
|
||||
push_patch(socket, to: new_path, replace: true)
|
||||
end
|
||||
|
||||
# Converts field to string
|
||||
defp field_to_string(field) when is_atom(field), do: Atom.to_string(field)
|
||||
defp field_to_string(field) when is_binary(field), do: field
|
||||
|
||||
# Updates session field selection (stored in socket for now, actual session update via controller)
|
||||
defp update_session_field_selection(socket, selection) do
|
||||
# Store in socket for now - actual session persistence would require a controller
|
||||
|
|
@ -566,8 +585,14 @@ defmodule MvWeb.MemberLive.Index do
|
|||
end
|
||||
|
||||
# Builds URL query parameters map including all filter/sort state.
|
||||
# Converts paid_filter atom to string for URL.
|
||||
defp build_query_params(query, sort_field, sort_order, paid_filter) do
|
||||
# Converts cycle_status_filter atom to string for URL.
|
||||
defp build_query_params(
|
||||
query,
|
||||
sort_field,
|
||||
sort_order,
|
||||
cycle_status_filter,
|
||||
show_current_cycle
|
||||
) do
|
||||
field_str =
|
||||
if is_atom(sort_field) do
|
||||
Atom.to_string(sort_field)
|
||||
|
|
@ -588,11 +613,19 @@ defmodule MvWeb.MemberLive.Index do
|
|||
"sort_order" => order_str
|
||||
}
|
||||
|
||||
# Only add paid_filter to URL if it's set
|
||||
case paid_filter do
|
||||
nil -> base_params
|
||||
:paid -> Map.put(base_params, "paid_filter", "paid")
|
||||
:not_paid -> Map.put(base_params, "paid_filter", "not_paid")
|
||||
# Only add cycle_status_filter to URL if it's set
|
||||
base_params =
|
||||
case cycle_status_filter do
|
||||
nil -> base_params
|
||||
:paid -> Map.put(base_params, "cycle_status_filter", "paid")
|
||||
:unpaid -> Map.put(base_params, "cycle_status_filter", "unpaid")
|
||||
end
|
||||
|
||||
# Add show_current_cycle if true
|
||||
if show_current_cycle do
|
||||
Map.put(base_params, "show_current_cycle", "true")
|
||||
else
|
||||
base_params
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -627,12 +660,12 @@ defmodule MvWeb.MemberLive.Index do
|
|||
visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
|
||||
query = load_custom_field_values(query, visible_custom_field_ids)
|
||||
|
||||
# Load membership fee cycles for status display
|
||||
query = MembershipFeeStatus.load_cycles_for_members(query, socket.assigns.show_current_cycle)
|
||||
|
||||
# Apply the search filter first
|
||||
query = apply_search_filter(query, search_query)
|
||||
|
||||
# Apply payment status filter
|
||||
query = apply_paid_filter(query, socket.assigns.paid_filter)
|
||||
|
||||
# Apply sorting based on current socket state
|
||||
# For custom fields, we sort after loading
|
||||
{query, sort_after_load} =
|
||||
|
|
@ -650,6 +683,14 @@ defmodule MvWeb.MemberLive.Index do
|
|||
# Custom field values are already filtered at the database level in load_custom_field_values/2
|
||||
# No need for in-memory filtering anymore
|
||||
|
||||
# Apply cycle status filter if set
|
||||
members =
|
||||
apply_cycle_status_filter(
|
||||
members,
|
||||
socket.assigns.cycle_status_filter,
|
||||
socket.assigns.show_current_cycle
|
||||
)
|
||||
|
||||
# Sort in memory if needed (for custom fields)
|
||||
members =
|
||||
if sort_after_load do
|
||||
|
|
@ -707,22 +748,17 @@ defmodule MvWeb.MemberLive.Index do
|
|||
end
|
||||
end
|
||||
|
||||
# Applies payment status filter to the query.
|
||||
# Applies cycle status filter to members list.
|
||||
#
|
||||
# Filter values:
|
||||
# - nil: No filter, return all members
|
||||
# - :paid: Only members with paid == true
|
||||
# - :not_paid: Members with paid == false or paid == nil (not paid)
|
||||
defp apply_paid_filter(query, nil), do: query
|
||||
# - :paid: Only members with paid status in the selected cycle (last or current)
|
||||
# - :unpaid: Only members with unpaid status in the selected cycle (last or current)
|
||||
defp apply_cycle_status_filter(members, nil, _show_current), do: members
|
||||
|
||||
defp apply_paid_filter(query, :paid) do
|
||||
Ash.Query.filter(query, expr(paid == true))
|
||||
end
|
||||
|
||||
defp apply_paid_filter(query, :not_paid) do
|
||||
# Include both false and nil as "not paid"
|
||||
# Note: paid != true doesn't work correctly with NULL values in SQL
|
||||
Ash.Query.filter(query, expr(paid == false or is_nil(paid)))
|
||||
defp apply_cycle_status_filter(members, status, show_current)
|
||||
when status in [:paid, :unpaid] do
|
||||
MembershipFeeStatus.filter_members_by_cycle_status(members, status, show_current)
|
||||
end
|
||||
|
||||
# Functions to toggle sorting order
|
||||
|
|
@ -756,7 +792,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
defp valid_sort_field?(field) when is_atom(field) do
|
||||
# All member fields are sortable, but we exclude some that don't make sense
|
||||
# :id is not in member_fields, but we don't want to sort by it anyway
|
||||
non_sortable_fields = [:notes, :paid]
|
||||
non_sortable_fields = [:notes]
|
||||
valid_fields = Mv.Constants.member_fields() -- non_sortable_fields
|
||||
|
||||
field in valid_fields or custom_field_sort?(field)
|
||||
|
|
@ -1027,28 +1063,36 @@ defmodule MvWeb.MemberLive.Index do
|
|||
socket
|
||||
end
|
||||
|
||||
# Updates paid filter from URL parameters if present.
|
||||
# Updates cycle status filter from URL parameters if present.
|
||||
#
|
||||
# Validates the filter value, falling back to nil (no filter) if invalid.
|
||||
defp maybe_update_paid_filter(socket, %{"paid_filter" => filter_str}) do
|
||||
filter = determine_paid_filter(filter_str)
|
||||
assign(socket, :paid_filter, filter)
|
||||
defp maybe_update_cycle_status_filter(socket, %{"cycle_status_filter" => filter_str}) do
|
||||
filter = determine_cycle_status_filter(filter_str)
|
||||
assign(socket, :cycle_status_filter, filter)
|
||||
end
|
||||
|
||||
defp maybe_update_paid_filter(socket, _params) do
|
||||
defp maybe_update_cycle_status_filter(socket, _params) do
|
||||
# Reset filter if not in URL params
|
||||
assign(socket, :paid_filter, nil)
|
||||
assign(socket, :cycle_status_filter, nil)
|
||||
end
|
||||
|
||||
# Determines valid paid filter from URL parameter.
|
||||
# Determines valid cycle status filter from URL parameter.
|
||||
#
|
||||
# SECURITY: This function whitelists allowed filter values. Only "paid" and "not_paid"
|
||||
# SECURITY: This function whitelists allowed filter values. Only "paid" and "unpaid"
|
||||
# are accepted - all other input (including malicious strings) falls back to nil.
|
||||
# This ensures no raw user input is ever passed to Ash.Query.filter/2, following
|
||||
# Ash's security recommendation to never pass untrusted input directly to filters.
|
||||
defp determine_paid_filter("paid"), do: :paid
|
||||
defp determine_paid_filter("not_paid"), do: :not_paid
|
||||
defp determine_paid_filter(_), do: nil
|
||||
# This ensures no raw user input is ever passed to filter functions.
|
||||
defp determine_cycle_status_filter("paid"), do: :paid
|
||||
defp determine_cycle_status_filter("unpaid"), do: :unpaid
|
||||
defp determine_cycle_status_filter(_), do: nil
|
||||
|
||||
# Updates show_current_cycle from URL parameters if present.
|
||||
defp maybe_update_show_current_cycle(socket, %{"show_current_cycle" => "true"}) do
|
||||
assign(socket, :show_current_cycle, true)
|
||||
end
|
||||
|
||||
defp maybe_update_show_current_cycle(socket, _params) do
|
||||
socket
|
||||
end
|
||||
|
||||
# -------------------------------------------------------------
|
||||
# Helper Functions for Custom Field Values
|
||||
|
|
|
|||
|
|
@ -39,9 +39,37 @@
|
|||
<.live_component
|
||||
module={MvWeb.Components.PaymentFilterComponent}
|
||||
id="payment-filter"
|
||||
paid_filter={@paid_filter}
|
||||
cycle_status_filter={@cycle_status_filter}
|
||||
member_count={length(@members)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="toggle_cycle_view"
|
||||
class={[
|
||||
"btn gap-2",
|
||||
@show_current_cycle && "btn-active"
|
||||
]}
|
||||
aria-label={
|
||||
if(@show_current_cycle,
|
||||
do: gettext("Current Cycle Payment Status"),
|
||||
else: gettext("Last Cycle Payment Status")
|
||||
)
|
||||
}
|
||||
title={
|
||||
if(@show_current_cycle,
|
||||
do: gettext("Current Cycle Payment Status"),
|
||||
else: gettext("Last Cycle Payment Status")
|
||||
)
|
||||
}
|
||||
>
|
||||
<.icon name="hero-arrow-path" class="h-5 w-5" />
|
||||
<span class="hidden sm:inline">
|
||||
{if(@show_current_cycle,
|
||||
do: gettext("Current Cycle Payment Status"),
|
||||
else: gettext("Last Cycle Payment Status")
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
<.live_component
|
||||
module={MvWeb.Components.FieldVisibilityDropdownComponent}
|
||||
id="field-visibility-dropdown"
|
||||
|
|
@ -247,13 +275,20 @@
|
|||
>
|
||||
{MvWeb.MemberLive.Index.format_date(member.join_date)}
|
||||
</:col>
|
||||
<:col :let={member} :if={:paid in @member_fields_visible} label={gettext("Paid")}>
|
||||
<span class={[
|
||||
"badge",
|
||||
if(member.paid == true, do: "badge-success", else: "badge-error")
|
||||
]}>
|
||||
{if member.paid == true, do: gettext("Yes"), else: gettext("No")}
|
||||
</span>
|
||||
<:col
|
||||
:let={member}
|
||||
label={gettext("Membership Fee Status")}
|
||||
>
|
||||
<%= if badge = MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge(
|
||||
MvWeb.MemberLive.Index.MembershipFeeStatus.get_cycle_status_for_member(member, @show_current_cycle)
|
||||
) do %>
|
||||
<span class={["badge", badge.color]}>
|
||||
<.icon name={badge.icon} class="size-4" />
|
||||
{badge.label}
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="badge badge-ghost">{gettext("No cycle")}</span>
|
||||
<% end %>
|
||||
</:col>
|
||||
<:action :let={member}>
|
||||
<div class="sr-only">
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ defmodule MvWeb.MemberLive.Show do
|
|||
use MvWeb, :live_view
|
||||
import Ash.Query
|
||||
|
||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
|
|
@ -43,156 +45,243 @@ defmodule MvWeb.MemberLive.Show do
|
|||
|
||||
<%!-- Tab Navigation --%>
|
||||
<div role="tablist" class="tabs tabs-bordered mb-6">
|
||||
<button role="tab" class="tab tab-active" aria-selected="true">
|
||||
<button
|
||||
role="tab"
|
||||
class={[
|
||||
"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" />
|
||||
{gettext("Contact Data")}
|
||||
</button>
|
||||
<button role="tab" class="tab" disabled aria-disabled="true" title={gettext("Coming soon")}>
|
||||
<button
|
||||
role="tab"
|
||||
class={[
|
||||
"tab",
|
||||
if(@active_tab == :membership_fees, do: "tab-active", else: "!text-gray-800")
|
||||
]}
|
||||
aria-selected={@active_tab == :membership_fees}
|
||||
phx-click="switch_tab"
|
||||
phx-value-tab="membership_fees"
|
||||
>
|
||||
<.icon name="hero-credit-card" class="size-4 mr-2" />
|
||||
{gettext("Payments")}
|
||||
{gettext("Membership Fees")}
|
||||
</button>
|
||||
</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>
|
||||
<.section_box title={gettext("Personal Data")}>
|
||||
<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>
|
||||
<%= if @active_tab == :contact do %>
|
||||
<%!-- Contact Data Tab Content --%>
|
||||
<%!-- Personal Data and Custom Fields Row --%>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<%!-- Personal Data Section --%>
|
||||
<div>
|
||||
<.section_box title={gettext("Personal Data")}>
|
||||
<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 --%>
|
||||
<div>
|
||||
<.data_field label={gettext("Address")} value={format_address(@member)} />
|
||||
</div>
|
||||
|
||||
<%!-- Email --%>
|
||||
<div>
|
||||
<.data_field label={gettext("Email")}>
|
||||
<a
|
||||
href={"mailto:#{MvWeb.MemberLive.Index.format_member_email(@member)}"}
|
||||
class="text-blue-700 hover:text-blue-800 underline"
|
||||
>
|
||||
{@member.email}
|
||||
</a>
|
||||
</.data_field>
|
||||
</div>
|
||||
|
||||
<%!-- Phone --%>
|
||||
<div>
|
||||
<.data_field label={gettext("Phone")} value={@member.phone_number} />
|
||||
</div>
|
||||
|
||||
<%!-- Membership Dates Row --%>
|
||||
<div class="flex gap-6">
|
||||
<.data_field
|
||||
label={gettext("Join Date")}
|
||||
value={format_date(@member.join_date)}
|
||||
class="w-28"
|
||||
/>
|
||||
<.data_field
|
||||
label={gettext("Exit Date")}
|
||||
value={format_date(@member.exit_date)}
|
||||
class="w-28"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<%!-- Linked User --%>
|
||||
<div>
|
||||
<.data_field label={gettext("Linked User")}>
|
||||
<%= if @member.user do %>
|
||||
<.link
|
||||
navigate={~p"/users/#{@member.user}"}
|
||||
class="text-blue-700 hover:text-blue-800 underline inline-flex items-center gap-1"
|
||||
>
|
||||
<.icon name="hero-user" class="size-4" />
|
||||
{@member.user.email}
|
||||
</.link>
|
||||
<% else %>
|
||||
<span class="text-base-content/70 italic">{gettext("No user linked")}</span>
|
||||
<% end %>
|
||||
</.data_field>
|
||||
</div>
|
||||
|
||||
<%!-- Notes --%>
|
||||
<%= if @member.notes && String.trim(@member.notes) != "" do %>
|
||||
<%!-- Address --%>
|
||||
<div>
|
||||
<.data_field label={gettext("Notes")}>
|
||||
<p class="whitespace-pre-wrap text-base-content/80">{@member.notes}</p>
|
||||
<.data_field label={gettext("Address")} value={format_address(@member)} />
|
||||
</div>
|
||||
|
||||
<%!-- Email --%>
|
||||
<div>
|
||||
<.data_field label={gettext("Email")}>
|
||||
<a
|
||||
href={"mailto:#{MvWeb.MemberLive.Index.format_member_email(@member)}"}
|
||||
class="text-blue-700 hover:text-blue-800 underline"
|
||||
>
|
||||
{@member.email}
|
||||
</a>
|
||||
</.data_field>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</.section_box>
|
||||
</div>
|
||||
|
||||
<%!-- Custom Fields Section --%>
|
||||
<%= if Enum.any?(@member.custom_field_values) do %>
|
||||
<div>
|
||||
<.section_box title={gettext("Custom Fields")}>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<%= for cfv <- sort_custom_field_values(@member.custom_field_values) do %>
|
||||
<% custom_field = cfv.custom_field %>
|
||||
<% value_type = custom_field && custom_field.value_type %>
|
||||
<.data_field label={custom_field && custom_field.name}>
|
||||
{format_custom_field_value(cfv.value, value_type)}
|
||||
<%!-- Phone --%>
|
||||
<div>
|
||||
<.data_field label={gettext("Phone")} value={@member.phone_number} />
|
||||
</div>
|
||||
|
||||
<%!-- Membership Dates Row --%>
|
||||
<div class="flex gap-6">
|
||||
<.data_field
|
||||
label={gettext("Join Date")}
|
||||
value={format_date(@member.join_date)}
|
||||
class="w-28"
|
||||
/>
|
||||
<.data_field
|
||||
label={gettext("Exit Date")}
|
||||
value={format_date(@member.exit_date)}
|
||||
class="w-28"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<%!-- Linked User --%>
|
||||
<div>
|
||||
<.data_field label={gettext("Linked User")}>
|
||||
<%= if @member.user do %>
|
||||
<.link
|
||||
navigate={~p"/users/#{@member.user}"}
|
||||
class="text-blue-700 hover:text-blue-800 underline inline-flex items-center gap-1"
|
||||
>
|
||||
<.icon name="hero-user" class="size-4" />
|
||||
{@member.user.email}
|
||||
</.link>
|
||||
<% else %>
|
||||
<span class="text-base-content/70 italic">{gettext("No user linked")}</span>
|
||||
<% 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>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%!-- Payment Data Section (Mockup) --%>
|
||||
<div class="max-w-xl">
|
||||
<.section_box title={gettext("Payment Data")}>
|
||||
<div role="alert" class="alert alert-info mb-4">
|
||||
<.icon name="hero-information-circle" class="size-5" />
|
||||
<span>{gettext("This data is for demonstration purposes only (mockup).")}</span>
|
||||
</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>
|
||||
|
||||
<div class="flex gap-6">
|
||||
<.data_field label={gettext("Contribution")} value="72 €" class="w-24" />
|
||||
<.data_field label={gettext("Payment Cycle")} value={gettext("monthly")} class="w-28" />
|
||||
<.data_field label={gettext("Paid")} class="w-24">
|
||||
<%= if @member.paid do %>
|
||||
<span class="badge badge-success">{gettext("Paid")}</span>
|
||||
<% else %>
|
||||
<span class="badge badge-warning">{gettext("Pending")}</span>
|
||||
<% end %>
|
||||
</.data_field>
|
||||
</div>
|
||||
</.section_box>
|
||||
</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 %>
|
||||
<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 %>
|
||||
<%!-- Membership Fees Tab Content --%>
|
||||
<.live_component
|
||||
module={MvWeb.MemberLive.Show.MembershipFeesComponent}
|
||||
id={"membership-fees-#{@member.id}"}
|
||||
member={@member}
|
||||
/>
|
||||
<% end %>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, socket}
|
||||
{:ok, assign(socket, :active_tab, :contact)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(%{"id" => id}, _, socket) do
|
||||
# Load custom fields once using assign_new to avoid repeated queries
|
||||
socket =
|
||||
assign_new(socket, :custom_fields, fn ->
|
||||
Mv.Membership.CustomField
|
||||
|> Ash.Query.sort(name: :asc)
|
||||
|> Ash.read!()
|
||||
end)
|
||||
|
||||
query =
|
||||
Mv.Membership.Member
|
||||
|> filter(id == ^id)
|
||||
|> load([:user, custom_field_values: [:custom_field]])
|
||||
|> load([
|
||||
:user,
|
||||
:membership_fee_type,
|
||||
custom_field_values: [:custom_field],
|
||||
membership_fee_cycles: [:membership_fee_type]
|
||||
])
|
||||
|
||||
member = Ash.read_one!(query)
|
||||
|
||||
# Calculate last and current cycle status from loaded cycles
|
||||
last_cycle_status = get_last_cycle_status(member)
|
||||
current_cycle_status = get_current_cycle_status(member)
|
||||
|
||||
member =
|
||||
member
|
||||
|> Map.put(:last_cycle_status, last_cycle_status)
|
||||
|> Map.put(:current_cycle_status, current_cycle_status)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:page_title, page_title(socket.assigns.live_action))
|
||||
|> assign(:member, member)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("switch_tab", %{"tab" => "contact"}, socket) do
|
||||
{:noreply, assign(socket, :active_tab, :contact)}
|
||||
end
|
||||
|
||||
def handle_event("switch_tab", %{"tab" => "membership_fees"}, socket) do
|
||||
{:noreply, assign(socket, :active_tab, :membership_fees)}
|
||||
end
|
||||
|
||||
defp page_title(:show), do: gettext("Show Member")
|
||||
defp page_title(:edit), do: gettext("Edit Member")
|
||||
|
||||
|
|
@ -236,14 +325,56 @@ defmodule MvWeb.MemberLive.Show do
|
|||
"""
|
||||
end
|
||||
|
||||
# Renders a mailto link if email is present, otherwise renders empty value placeholder
|
||||
attr :email, :string, required: true
|
||||
attr :display, :string, default: nil
|
||||
|
||||
defp mailto_link(assigns) do
|
||||
display_text = assigns.display || assigns.email
|
||||
|
||||
if assigns.email && String.trim(assigns.email) != "" do
|
||||
assigns = %{email: assigns.email, display: display_text}
|
||||
|
||||
~H"""
|
||||
<a
|
||||
href={"mailto:#{@email}"}
|
||||
class="text-blue-700 hover:text-blue-800 underline"
|
||||
>
|
||||
{@display}
|
||||
</a>
|
||||
"""
|
||||
else
|
||||
render_empty_value()
|
||||
end
|
||||
end
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Helper Functions
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
defp display_value(nil), do: ""
|
||||
defp display_value(""), do: ""
|
||||
defp display_value(nil), do: render_empty_value()
|
||||
defp display_value(""), do: render_empty_value()
|
||||
defp display_value(value), do: value
|
||||
|
||||
defp format_status_label(:paid), do: gettext("Paid")
|
||||
defp format_status_label(:unpaid), do: gettext("Unpaid")
|
||||
defp format_status_label(:suspended), do: gettext("Suspended")
|
||||
defp format_status_label(nil), do: gettext("No status")
|
||||
|
||||
defp get_last_cycle_status(member) do
|
||||
case MembershipFeeHelpers.get_last_completed_cycle(member) do
|
||||
nil -> nil
|
||||
cycle -> cycle.status
|
||||
end
|
||||
end
|
||||
|
||||
defp get_current_cycle_status(member) do
|
||||
case MembershipFeeHelpers.get_current_cycle(member) do
|
||||
nil -> nil
|
||||
cycle -> cycle.status
|
||||
end
|
||||
end
|
||||
|
||||
defp format_address(member) do
|
||||
street_part =
|
||||
[member.street, member.house_number]
|
||||
|
|
@ -272,20 +403,34 @@ defmodule MvWeb.MemberLive.Show do
|
|||
|
||||
defp format_date(date), do: to_string(date)
|
||||
|
||||
# Sorts custom field values by custom field name
|
||||
defp sort_custom_field_values(custom_field_values) do
|
||||
Enum.sort_by(custom_field_values, fn cfv ->
|
||||
(cfv.custom_field && cfv.custom_field.name) || ""
|
||||
# Finds custom field value for a given custom field id
|
||||
# Returns the value (not the CustomFieldValue struct) or nil
|
||||
defp find_custom_field_value(nil, _custom_field_id), do: nil
|
||||
|
||||
defp find_custom_field_value(custom_field_values, custom_field_id)
|
||||
when is_list(custom_field_values) do
|
||||
Enum.find_value(custom_field_values, fn cfv ->
|
||||
if cfv.custom_field_id == custom_field_id or
|
||||
(cfv.custom_field && cfv.custom_field.id == custom_field_id) do
|
||||
cfv.value
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp find_custom_field_value(_custom_field_values, _custom_field_id), do: nil
|
||||
|
||||
# Formats custom field value based on type
|
||||
# Handles both CustomFieldValue structs and direct values
|
||||
defp format_custom_field_value(nil, _type), do: render_empty_value()
|
||||
|
||||
defp format_custom_field_value(%Mv.Membership.CustomFieldValue{} = cfv, value_type) do
|
||||
format_custom_field_value(cfv.value, value_type)
|
||||
end
|
||||
|
||||
defp format_custom_field_value(%Ash.Union{value: value, type: type}, _expected_type) do
|
||||
format_custom_field_value(value, type)
|
||||
end
|
||||
|
||||
defp format_custom_field_value(nil, _type), do: "—"
|
||||
|
||||
defp format_custom_field_value(value, :boolean) when is_boolean(value) do
|
||||
if value, do: gettext("Yes"), else: gettext("No")
|
||||
end
|
||||
|
|
@ -295,11 +440,15 @@ defmodule MvWeb.MemberLive.Show do
|
|||
end
|
||||
|
||||
defp format_custom_field_value(value, :email) when is_binary(value) do
|
||||
assigns = %{email: value}
|
||||
if String.trim(value) == "" do
|
||||
render_empty_value()
|
||||
else
|
||||
assigns = %{email: value}
|
||||
|
||||
~H"""
|
||||
<a href={"mailto:#{@email}"} class="text-blue-700 hover:text-blue-800 underline">{@email}</a>
|
||||
"""
|
||||
~H"""
|
||||
<.mailto_link email={@email} display={@email} />
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
||||
defp format_custom_field_value(value, :integer) when is_integer(value) do
|
||||
|
|
@ -307,8 +456,22 @@ defmodule MvWeb.MemberLive.Show do
|
|||
end
|
||||
|
||||
defp format_custom_field_value(value, _type) when is_binary(value) do
|
||||
if String.trim(value) == "", do: "—", else: value
|
||||
if String.trim(value) == "", do: render_empty_value(), else: value
|
||||
end
|
||||
|
||||
defp format_custom_field_value(value, _type), do: to_string(value)
|
||||
|
||||
# Renders accessible placeholder for empty values
|
||||
# Uses translated text for screen readers while maintaining visual consistency
|
||||
# The visual "—" is hidden from screen readers, while the translated text is only visible to screen readers
|
||||
defp render_empty_value do
|
||||
assigns = %{text: gettext("Not set")}
|
||||
|
||||
~H"""
|
||||
<span class="text-base-content/50 italic">
|
||||
<span aria-hidden="true">—</span>
|
||||
<span class="sr-only">{@text}</span>
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
|
|
|||
927
lib/mv_web/live/member_live/show/membership_fees_component.ex
Normal file
927
lib/mv_web/live/member_live/show/membership_fees_component.ex
Normal file
|
|
@ -0,0 +1,927 @@
|
|||
defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||
@moduledoc """
|
||||
LiveComponent for displaying and managing membership fees for a member.
|
||||
|
||||
## Features
|
||||
- Display all membership fee cycles in a table
|
||||
- Change membership fee type (with same-interval validation)
|
||||
- Change cycle status (paid/unpaid/suspended)
|
||||
- Regenerate cycles manually
|
||||
- Delete cycles (with confirmation)
|
||||
- Edit cycle amount (with modal)
|
||||
"""
|
||||
use MvWeb, :live_component
|
||||
|
||||
require Ash.Query
|
||||
|
||||
alias Mv.Membership
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias Mv.MembershipFees.MembershipFeeCycle
|
||||
alias Mv.MembershipFees.CycleGenerator
|
||||
alias Mv.MembershipFees.CalendarCycles
|
||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div id={@id}>
|
||||
<.section_box title={gettext("Membership Fees")}>
|
||||
<%!-- Membership Fee Type Display --%>
|
||||
<div class="mb-6">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">{gettext("Membership Fee Type")}</span>
|
||||
</label>
|
||||
<%= if @member.membership_fee_type do %>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">{@member.membership_fee_type.name}</span>
|
||||
<span class="text-base-content/60">
|
||||
({MembershipFeeHelpers.format_currency(@member.membership_fee_type.amount)}, {MembershipFeeHelpers.format_interval(
|
||||
@member.membership_fee_type.interval
|
||||
)})
|
||||
</span>
|
||||
</div>
|
||||
<% else %>
|
||||
<span class="text-base-content/60 italic">
|
||||
{gettext("No membership fee type assigned")}
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%!-- Action Buttons --%>
|
||||
<div class="flex gap-2 mb-4">
|
||||
<.button
|
||||
phx-click="regenerate_cycles"
|
||||
phx-target={@myself}
|
||||
class={["btn btn-sm btn-outline", if(@regenerating, do: "btn-disabled", else: "")]}
|
||||
title={gettext("Generate cycles from the last existing cycle to today")}
|
||||
>
|
||||
<.icon name="hero-arrow-path" class="size-4" />
|
||||
{if(@regenerating, do: gettext("Regenerating..."), else: gettext("Regenerate Cycles"))}
|
||||
</.button>
|
||||
<.button
|
||||
:if={Enum.any?(@cycles)}
|
||||
phx-click="delete_all_cycles"
|
||||
phx-target={@myself}
|
||||
class="btn btn-sm btn-error btn-outline"
|
||||
title={gettext("Delete all cycles")}
|
||||
>
|
||||
<.icon name="hero-trash" class="size-4" />
|
||||
{gettext("Delete All Cycles")}
|
||||
</.button>
|
||||
<.button
|
||||
:if={@member.membership_fee_type}
|
||||
phx-click="open_create_cycle_modal"
|
||||
phx-target={@myself}
|
||||
class="btn btn-sm btn-primary"
|
||||
title={gettext("Create a new cycle manually")}
|
||||
>
|
||||
<.icon name="hero-plus" class="size-4" />
|
||||
{gettext("Create Cycle")}
|
||||
</.button>
|
||||
</div>
|
||||
|
||||
<%!-- Cycles Table --%>
|
||||
<%= if Enum.any?(@cycles) do %>
|
||||
<.table
|
||||
id="membership-fee-cycles"
|
||||
rows={@cycles}
|
||||
row_id={fn cycle -> "cycle-#{cycle.id}" end}
|
||||
>
|
||||
<:col :let={cycle} label={gettext("Cycle")}>
|
||||
{MembershipFeeHelpers.format_cycle_range(
|
||||
cycle.cycle_start,
|
||||
cycle.membership_fee_type.interval
|
||||
)}
|
||||
</:col>
|
||||
|
||||
<:col :let={cycle} label={gettext("Interval")}>
|
||||
<span class="badge badge-outline">
|
||||
{MembershipFeeHelpers.format_interval(cycle.membership_fee_type.interval)}
|
||||
</span>
|
||||
</:col>
|
||||
|
||||
<:col :let={cycle} label={gettext("Amount")}>
|
||||
<span
|
||||
class="font-mono cursor-pointer hover:text-primary"
|
||||
phx-click="edit_cycle_amount"
|
||||
phx-value-cycle_id={cycle.id}
|
||||
phx-target={@myself}
|
||||
title={gettext("Click to edit amount")}
|
||||
>
|
||||
{MembershipFeeHelpers.format_currency(cycle.amount)}
|
||||
</span>
|
||||
</:col>
|
||||
|
||||
<:col :let={cycle} label={gettext("Status")}>
|
||||
<% badge = MembershipFeeHelpers.status_color(cycle.status) %>
|
||||
<% icon = MembershipFeeHelpers.status_icon(cycle.status) %>
|
||||
<span class={["badge", badge]}>
|
||||
<.icon name={icon} class="size-4" />
|
||||
{format_status_label(cycle.status)}
|
||||
</span>
|
||||
</:col>
|
||||
|
||||
<:action :let={cycle}>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
:if={cycle.status != :paid}
|
||||
type="button"
|
||||
phx-click="mark_cycle_status"
|
||||
phx-value-cycle_id={cycle.id}
|
||||
phx-value-status="paid"
|
||||
phx-target={@myself}
|
||||
class="btn btn-sm btn-success"
|
||||
title={gettext("Mark as paid")}
|
||||
>
|
||||
<.icon name="hero-check-circle" class="size-4" />
|
||||
{gettext("Paid")}
|
||||
</button>
|
||||
<button
|
||||
:if={cycle.status != :suspended}
|
||||
type="button"
|
||||
phx-click="mark_cycle_status"
|
||||
phx-value-cycle_id={cycle.id}
|
||||
phx-value-status="suspended"
|
||||
phx-target={@myself}
|
||||
class="btn btn-sm btn-outline btn-warning"
|
||||
title={gettext("Mark as suspended")}
|
||||
>
|
||||
<.icon name="hero-pause-circle" class="size-4" />
|
||||
{gettext("Suspended")}
|
||||
</button>
|
||||
<button
|
||||
:if={cycle.status != :unpaid}
|
||||
type="button"
|
||||
phx-click="mark_cycle_status"
|
||||
phx-value-cycle_id={cycle.id}
|
||||
phx-value-status="unpaid"
|
||||
phx-target={@myself}
|
||||
class="btn btn-sm btn-error"
|
||||
title={gettext("Mark as unpaid")}
|
||||
>
|
||||
<.icon name="hero-x-circle" class="size-4" />
|
||||
{gettext("Unpaid")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="delete_cycle"
|
||||
phx-value-cycle_id={cycle.id}
|
||||
phx-target={@myself}
|
||||
class="btn btn-sm btn-error btn-outline"
|
||||
title={gettext("Delete cycle")}
|
||||
>
|
||||
<.icon name="hero-trash" class="size-4" />
|
||||
{gettext("Delete")}
|
||||
</button>
|
||||
</div>
|
||||
</:action>
|
||||
</.table>
|
||||
<% else %>
|
||||
<div class="alert alert-info">
|
||||
<.icon name="hero-information-circle" class="size-5" />
|
||||
<span>
|
||||
{gettext(
|
||||
"No membership fee cycles found. Cycles will be generated automatically when a membership fee type is assigned."
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
</.section_box>
|
||||
|
||||
<%!-- Edit Cycle Amount Modal --%>
|
||||
<%= if @editing_cycle do %>
|
||||
<dialog id="edit-cycle-amount-modal" class="modal modal-open">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold">{gettext("Edit Cycle Amount")}</h3>
|
||||
<form phx-submit="save_cycle_amount" phx-target={@myself}>
|
||||
<input type="hidden" name="cycle_id" value={@editing_cycle.id} />
|
||||
<div class="form-control w-full mt-4">
|
||||
<label class="label">
|
||||
<span class="label-text">{gettext("Amount")}</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
name="amount"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={Decimal.to_string(@editing_cycle.amount) |> String.replace(".", ",")}
|
||||
class="input input-bordered w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button type="button" phx-click="cancel_edit_amount" phx-target={@myself} class="btn">
|
||||
{gettext("Cancel")}
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">{gettext("Save")}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
<% end %>
|
||||
|
||||
<%!-- Delete Cycle Confirmation Modal --%>
|
||||
<%= if @deleting_cycle do %>
|
||||
<dialog id="delete-cycle-modal" class="modal modal-open">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold">{gettext("Delete Cycle")}</h3>
|
||||
<p class="py-4">
|
||||
{gettext("Are you sure you want to delete this cycle?")}
|
||||
</p>
|
||||
<p class="text-sm text-base-content/70 mb-4">
|
||||
{MembershipFeeHelpers.format_cycle_range(
|
||||
@deleting_cycle.cycle_start,
|
||||
@deleting_cycle.membership_fee_type.interval
|
||||
)} - {MembershipFeeHelpers.format_currency(@deleting_cycle.amount)}
|
||||
</p>
|
||||
<div class="modal-action">
|
||||
<button phx-click="cancel_delete_cycle" phx-target={@myself} class="btn">
|
||||
{gettext("Cancel")}
|
||||
</button>
|
||||
<button
|
||||
phx-click="confirm_delete_cycle"
|
||||
phx-value-cycle_id={@deleting_cycle.id}
|
||||
phx-target={@myself}
|
||||
class="btn btn-error"
|
||||
>
|
||||
{gettext("Delete")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
<% end %>
|
||||
|
||||
<%!-- Delete All Cycles Confirmation Modal --%>
|
||||
<%= if @deleting_all_cycles do %>
|
||||
<dialog id="delete-all-cycles-modal" class="modal modal-open">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold text-error">{gettext("Delete All Cycles")}</h3>
|
||||
<div class="alert alert-warning mt-4">
|
||||
<.icon name="hero-exclamation-triangle" class="size-5" />
|
||||
<div>
|
||||
<h4 class="font-bold">{gettext("Warning")}</h4>
|
||||
<p>
|
||||
{gettext("You are about to delete all %{count} cycles for this member.",
|
||||
count: length(@cycles)
|
||||
)}
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
{gettext("This action cannot be undone.")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-control w-full mt-4">
|
||||
<label class="label">
|
||||
<span class="label-text">
|
||||
{gettext("Type '%{confirmation}' to confirm", confirmation: gettext("Yes"))}
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
phx-keyup="update_delete_all_confirmation"
|
||||
phx-target={@myself}
|
||||
value={@delete_all_confirmation || ""}
|
||||
class="input input-bordered w-full"
|
||||
placeholder={gettext("Yes")}
|
||||
/>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button phx-click="cancel_delete_all_cycles" phx-target={@myself} class="btn">
|
||||
{gettext("Cancel")}
|
||||
</button>
|
||||
<button
|
||||
phx-click="confirm_delete_all_cycles"
|
||||
phx-target={@myself}
|
||||
class="btn btn-error"
|
||||
disabled={
|
||||
String.trim(String.downcase(@delete_all_confirmation)) !=
|
||||
String.downcase(gettext("Yes"))
|
||||
}
|
||||
>
|
||||
{gettext("Delete All")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
<% end %>
|
||||
|
||||
<%!-- Create Cycle Modal --%>
|
||||
<%= if @creating_cycle do %>
|
||||
<dialog id="create-cycle-modal" class="modal modal-open">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold">{gettext("Create Cycle")}</h3>
|
||||
<form phx-submit="create_cycle" phx-target={@myself}>
|
||||
<div class="form-control w-full mt-4">
|
||||
<label class="label" for="create-cycle-date">
|
||||
<span class="label-text">{gettext("Date")}</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id="create-cycle-date"
|
||||
name="date"
|
||||
value={@create_cycle_date || ""}
|
||||
phx-change="update_create_cycle_date"
|
||||
phx-target={@myself}
|
||||
class="input input-bordered w-full"
|
||||
required
|
||||
aria-label={gettext("Date")}
|
||||
/>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">
|
||||
{gettext(
|
||||
"The cycle period will be calculated based on this date and the interval."
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<%= if @create_cycle_date do %>
|
||||
<div class="form-control w-full mt-4">
|
||||
<label class="label">
|
||||
<span class="label-text">{gettext("Cycle Period")}</span>
|
||||
</label>
|
||||
<div class="text-sm text-base-content/70">
|
||||
{format_create_cycle_period(
|
||||
@create_cycle_date,
|
||||
@member.membership_fee_type.interval
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="form-control w-full mt-4">
|
||||
<label class="label" for="create-cycle-amount">
|
||||
<span class="label-text">{gettext("Amount")}</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
id="create-cycle-amount"
|
||||
name="amount"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={
|
||||
Decimal.to_string(@member.membership_fee_type.amount) |> String.replace(".", ",")
|
||||
}
|
||||
class="input input-bordered w-full"
|
||||
required
|
||||
aria-label={gettext("Amount")}
|
||||
/>
|
||||
</div>
|
||||
<%= if @create_cycle_error do %>
|
||||
<div class="alert alert-error mt-4">
|
||||
<.icon name="hero-exclamation-circle" class="size-5" />
|
||||
<span>{@create_cycle_error}</span>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="modal-action">
|
||||
<button type="button" phx-click="cancel_create_cycle" phx-target={@myself} class="btn">
|
||||
{gettext("Cancel")}
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">{gettext("Create")}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
member = assigns.member
|
||||
|
||||
# Load cycles if not already loaded
|
||||
cycles =
|
||||
case member.membership_fee_cycles do
|
||||
nil -> []
|
||||
cycles when is_list(cycles) -> cycles
|
||||
_ -> []
|
||||
end
|
||||
|
||||
# Sort cycles by cycle_start descending (newest first)
|
||||
cycles = Enum.sort_by(cycles, & &1.cycle_start, {:desc, Date})
|
||||
|
||||
# Get available fee types (filtered to same interval if member has a type)
|
||||
available_fee_types = get_available_fee_types(member)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign(:cycles, cycles)
|
||||
|> assign(:available_fee_types, available_fee_types)
|
||||
|> assign_new(:interval_warning, fn -> nil end)
|
||||
|> assign_new(:editing_cycle, fn -> nil end)
|
||||
|> assign_new(:deleting_cycle, fn -> nil end)
|
||||
|> assign_new(:deleting_all_cycles, fn -> false end)
|
||||
|> assign_new(:delete_all_confirmation, fn -> "" end)
|
||||
|> assign_new(:creating_cycle, fn -> false end)
|
||||
|> assign_new(:create_cycle_date, fn -> nil end)
|
||||
|> assign_new(:create_cycle_error, fn -> nil end)
|
||||
|> assign_new(:regenerating, fn -> false end)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("change_membership_fee_type", %{"value" => ""}, socket) do
|
||||
# Remove membership fee type
|
||||
case update_member_fee_type(socket.assigns.member, nil) do
|
||||
{:ok, updated_member} ->
|
||||
send(self(), {:member_updated, updated_member})
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:member, updated_member)
|
||||
|> assign(:cycles, [])
|
||||
|> assign(:available_fee_types, get_available_fee_types(updated_member))
|
||||
|> assign(:interval_warning, nil)
|
||||
|> put_flash(:info, gettext("Membership fee type removed"))}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply, put_flash(socket, :error, format_error(error))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("change_membership_fee_type", %{"value" => fee_type_id}, socket) do
|
||||
member = socket.assigns.member
|
||||
new_fee_type = Ash.get!(MembershipFeeType, fee_type_id, domain: MembershipFees)
|
||||
|
||||
# Check if interval matches
|
||||
interval_warning =
|
||||
if member.membership_fee_type &&
|
||||
member.membership_fee_type.interval != new_fee_type.interval do
|
||||
gettext(
|
||||
"Warning: Changing from %{old_interval} to %{new_interval} is not allowed. Please select a membership fee type with the same interval.",
|
||||
old_interval: MembershipFeeHelpers.format_interval(member.membership_fee_type.interval),
|
||||
new_interval: MembershipFeeHelpers.format_interval(new_fee_type.interval)
|
||||
)
|
||||
else
|
||||
nil
|
||||
end
|
||||
|
||||
if interval_warning do
|
||||
{:noreply, assign(socket, :interval_warning, interval_warning)}
|
||||
else
|
||||
case update_member_fee_type(member, fee_type_id) do
|
||||
{:ok, updated_member} ->
|
||||
# Reload member with cycles
|
||||
updated_member =
|
||||
updated_member
|
||||
|> Ash.load!([
|
||||
:membership_fee_type,
|
||||
membership_fee_cycles: [:membership_fee_type]
|
||||
])
|
||||
|
||||
cycles =
|
||||
Enum.sort_by(
|
||||
updated_member.membership_fee_cycles || [],
|
||||
& &1.cycle_start,
|
||||
{:desc, Date}
|
||||
)
|
||||
|
||||
send(self(), {:member_updated, updated_member})
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:member, updated_member)
|
||||
|> assign(:cycles, cycles)
|
||||
|> assign(:available_fee_types, get_available_fee_types(updated_member))
|
||||
|> assign(:interval_warning, nil)
|
||||
|> put_flash(:info, gettext("Membership fee type updated. Cycles regenerated."))}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply, put_flash(socket, :error, format_error(error))}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("mark_cycle_status", %{"cycle_id" => cycle_id, "status" => status_str}, socket) do
|
||||
status = String.to_existing_atom(status_str)
|
||||
cycle = find_cycle(socket.assigns.cycles, cycle_id)
|
||||
|
||||
action =
|
||||
case status do
|
||||
:paid -> :mark_as_paid
|
||||
:unpaid -> :mark_as_unpaid
|
||||
:suspended -> :mark_as_suspended
|
||||
end
|
||||
|
||||
case Ash.update(cycle, action: action, domain: MembershipFees) do
|
||||
{:ok, updated_cycle} ->
|
||||
updated_cycles = replace_cycle(socket.assigns.cycles, updated_cycle)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:cycles, updated_cycles)
|
||||
|> put_flash(:info, gettext("Cycle status updated"))}
|
||||
|
||||
{:error, %Ash.Error.Invalid{} = error} ->
|
||||
error_msg =
|
||||
Enum.map_join(error.errors, ", ", fn e -> e.message end)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(
|
||||
:error,
|
||||
gettext("Failed to update cycle status: %{errors}", errors: error_msg)
|
||||
)}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, format_error(error))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("regenerate_cycles", _params, socket) do
|
||||
socket = assign(socket, :regenerating, true)
|
||||
member = socket.assigns.member
|
||||
|
||||
case CycleGenerator.generate_cycles_for_member(member.id) do
|
||||
{:ok, _new_cycles, _notifications} ->
|
||||
# Reload member with cycles
|
||||
updated_member =
|
||||
member
|
||||
|> Ash.load!([
|
||||
:membership_fee_type,
|
||||
membership_fee_cycles: [:membership_fee_type]
|
||||
])
|
||||
|
||||
cycles =
|
||||
Enum.sort_by(
|
||||
updated_member.membership_fee_cycles || [],
|
||||
& &1.cycle_start,
|
||||
{:desc, Date}
|
||||
)
|
||||
|
||||
send(self(), {:member_updated, updated_member})
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:member, updated_member)
|
||||
|> assign(:cycles, cycles)
|
||||
|> assign(:regenerating, false)
|
||||
|> put_flash(:info, gettext("Cycles regenerated successfully"))}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:regenerating, false)
|
||||
|> put_flash(:error, format_error(error))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("edit_cycle_amount", %{"cycle_id" => cycle_id}, socket) do
|
||||
cycle = find_cycle(socket.assigns.cycles, cycle_id)
|
||||
|
||||
# Load cycle with membership_fee_type for display
|
||||
cycle = Ash.load!(cycle, :membership_fee_type)
|
||||
|
||||
{:noreply, assign(socket, :editing_cycle, cycle)}
|
||||
end
|
||||
|
||||
def handle_event("cancel_edit_amount", _params, socket) do
|
||||
{:noreply, assign(socket, :editing_cycle, nil)}
|
||||
end
|
||||
|
||||
def handle_event("save_cycle_amount", %{"cycle_id" => cycle_id, "amount" => amount_str}, socket) do
|
||||
cycle = find_cycle(socket.assigns.cycles, cycle_id)
|
||||
|
||||
# Normalize comma to dot for decimal parsing (German locale support)
|
||||
normalized_amount_str = String.replace(amount_str, ",", ".")
|
||||
|
||||
case Decimal.parse(normalized_amount_str) do
|
||||
{amount, _} when is_struct(amount, Decimal) ->
|
||||
case cycle
|
||||
|> Ash.Changeset.for_update(:update, %{amount: amount})
|
||||
|> Ash.update(domain: MembershipFees) do
|
||||
{:ok, updated_cycle} ->
|
||||
updated_cycles = replace_cycle(socket.assigns.cycles, updated_cycle)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:cycles, updated_cycles)
|
||||
|> assign(:editing_cycle, nil)
|
||||
|> put_flash(:info, gettext("Cycle amount updated"))}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, format_error(error))}
|
||||
end
|
||||
|
||||
:error ->
|
||||
{:noreply, put_flash(socket, :error, gettext("Invalid amount format"))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("delete_cycle", %{"cycle_id" => cycle_id}, socket) do
|
||||
cycle = find_cycle(socket.assigns.cycles, cycle_id)
|
||||
|
||||
# Load cycle with membership_fee_type for display
|
||||
cycle = Ash.load!(cycle, :membership_fee_type)
|
||||
|
||||
{:noreply, assign(socket, :deleting_cycle, cycle)}
|
||||
end
|
||||
|
||||
def handle_event("cancel_delete_cycle", _params, socket) do
|
||||
{:noreply, assign(socket, :deleting_cycle, nil)}
|
||||
end
|
||||
|
||||
def handle_event("confirm_delete_cycle", %{"cycle_id" => cycle_id}, socket) do
|
||||
cycle = find_cycle(socket.assigns.cycles, cycle_id)
|
||||
|
||||
case Ash.destroy(cycle, domain: MembershipFees) do
|
||||
:ok ->
|
||||
updated_cycles = Enum.reject(socket.assigns.cycles, &(&1.id == cycle_id))
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:cycles, updated_cycles)
|
||||
|> assign(:deleting_cycle, nil)
|
||||
|> put_flash(:info, gettext("Cycle deleted"))}
|
||||
|
||||
{:ok, _destroyed} ->
|
||||
# Handle case where return_destroyed? is true
|
||||
updated_cycles = Enum.reject(socket.assigns.cycles, &(&1.id == cycle_id))
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:cycles, updated_cycles)
|
||||
|> assign(:deleting_cycle, nil)
|
||||
|> put_flash(:info, gettext("Cycle deleted"))}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:deleting_cycle, nil)
|
||||
|> put_flash(:error, format_error(error))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("delete_all_cycles", _params, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:deleting_all_cycles, true)
|
||||
|> assign(:delete_all_confirmation, "")}
|
||||
end
|
||||
|
||||
def handle_event("cancel_delete_all_cycles", _params, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:deleting_all_cycles, false)
|
||||
|> assign(:delete_all_confirmation, "")}
|
||||
end
|
||||
|
||||
def handle_event("update_delete_all_confirmation", %{"value" => value}, socket) do
|
||||
{:noreply, assign(socket, :delete_all_confirmation, value)}
|
||||
end
|
||||
|
||||
def handle_event("confirm_delete_all_cycles", _params, socket) do
|
||||
# Validate confirmation (case-insensitive, trimmed)
|
||||
confirmation = String.trim(String.downcase(socket.assigns.delete_all_confirmation))
|
||||
expected = String.downcase(gettext("Yes"))
|
||||
|
||||
if confirmation != expected do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:deleting_all_cycles, false)
|
||||
|> assign(:delete_all_confirmation, "")
|
||||
|> put_flash(:error, gettext("Confirmation text does not match"))}
|
||||
else
|
||||
member = socket.assigns.member
|
||||
|
||||
# Delete all cycles atomically using Ecto query
|
||||
import Ecto.Query
|
||||
|
||||
deleted_count =
|
||||
Mv.Repo.delete_all(
|
||||
from c in Mv.MembershipFees.MembershipFeeCycle,
|
||||
where: c.member_id == ^member.id
|
||||
)
|
||||
|
||||
if deleted_count > 0 do
|
||||
# Reload member to get updated cycles
|
||||
updated_member =
|
||||
member
|
||||
|> Ash.load!([
|
||||
:membership_fee_type,
|
||||
membership_fee_cycles: [:membership_fee_type]
|
||||
])
|
||||
|
||||
updated_cycles =
|
||||
Enum.sort_by(
|
||||
updated_member.membership_fee_cycles || [],
|
||||
& &1.cycle_start,
|
||||
{:desc, Date}
|
||||
)
|
||||
|
||||
send(self(), {:member_updated, updated_member})
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:member, updated_member)
|
||||
|> assign(:cycles, updated_cycles)
|
||||
|> assign(:deleting_all_cycles, false)
|
||||
|> assign(:delete_all_confirmation, "")
|
||||
|> put_flash(:info, gettext("All cycles deleted"))}
|
||||
else
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:deleting_all_cycles, false)
|
||||
|> assign(:delete_all_confirmation, "")
|
||||
|> put_flash(:info, gettext("No cycles to delete"))}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("open_create_cycle_modal", _params, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:creating_cycle, true)
|
||||
|> assign(:create_cycle_date, nil)
|
||||
|> assign(:create_cycle_error, nil)}
|
||||
end
|
||||
|
||||
def handle_event("cancel_create_cycle", _params, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:creating_cycle, false)
|
||||
|> assign(:create_cycle_date, nil)
|
||||
|> assign(:create_cycle_error, nil)}
|
||||
end
|
||||
|
||||
def handle_event("update_create_cycle_date", %{"date" => date_str}, socket) do
|
||||
date =
|
||||
case Date.from_iso8601(date_str) do
|
||||
{:ok, date} -> date
|
||||
_ -> nil
|
||||
end
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:create_cycle_date, date)
|
||||
|> assign(:create_cycle_error, nil)}
|
||||
end
|
||||
|
||||
def handle_event("create_cycle", %{"date" => date_str, "amount" => amount_str}, socket) do
|
||||
member = socket.assigns.member
|
||||
|
||||
# Normalize comma to dot for decimal parsing (German locale support)
|
||||
normalized_amount_str = String.replace(amount_str, ",", ".")
|
||||
|
||||
amount =
|
||||
case Decimal.parse(normalized_amount_str) do
|
||||
{d, _} when is_struct(d, Decimal) -> {:ok, d}
|
||||
:error -> {:error, :invalid_amount}
|
||||
end
|
||||
|
||||
with {:ok, date} <- Date.from_iso8601(date_str),
|
||||
{:ok, amount} <- amount,
|
||||
cycle_start <-
|
||||
CalendarCycles.calculate_cycle_start(date, member.membership_fee_type.interval),
|
||||
:ok <- validate_cycle_not_exists(socket.assigns.cycles, cycle_start) do
|
||||
attrs = %{
|
||||
cycle_start: cycle_start,
|
||||
amount: amount,
|
||||
status: :unpaid,
|
||||
member_id: member.id,
|
||||
membership_fee_type_id: member.membership_fee_type_id
|
||||
}
|
||||
|
||||
case Ash.create(MembershipFeeCycle, attrs, domain: MembershipFees) do
|
||||
{:ok, _new_cycle} ->
|
||||
# Reload member with cycles
|
||||
updated_member =
|
||||
member
|
||||
|> Ash.load!([
|
||||
:membership_fee_type,
|
||||
membership_fee_cycles: [:membership_fee_type]
|
||||
])
|
||||
|
||||
cycles =
|
||||
Enum.sort_by(
|
||||
updated_member.membership_fee_cycles || [],
|
||||
& &1.cycle_start,
|
||||
{:desc, Date}
|
||||
)
|
||||
|
||||
send(self(), {:member_updated, updated_member})
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:member, updated_member)
|
||||
|> assign(:cycles, cycles)
|
||||
|> assign(:creating_cycle, false)
|
||||
|> assign(:create_cycle_date, nil)
|
||||
|> assign(:create_cycle_error, nil)
|
||||
|> put_flash(:info, gettext("Cycle created successfully"))}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:create_cycle_error, format_error(error))}
|
||||
end
|
||||
else
|
||||
:error ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:create_cycle_error, gettext("Invalid date format"))}
|
||||
|
||||
{:error, :invalid_amount} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:create_cycle_error, gettext("Invalid amount format"))}
|
||||
|
||||
{:error, :cycle_exists} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(
|
||||
:create_cycle_error,
|
||||
gettext("A cycle for this period already exists")
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
# Helper functions
|
||||
|
||||
defp get_available_fee_types(member) do
|
||||
all_types =
|
||||
MembershipFeeType
|
||||
|> Ash.Query.sort(name: :asc)
|
||||
|> Ash.read!()
|
||||
|
||||
# If member has a fee type, filter to same interval
|
||||
if member.membership_fee_type do
|
||||
Enum.filter(all_types, fn type ->
|
||||
type.interval == member.membership_fee_type.interval
|
||||
end)
|
||||
else
|
||||
all_types
|
||||
end
|
||||
end
|
||||
|
||||
defp update_member_fee_type(member, fee_type_id) do
|
||||
attrs = %{membership_fee_type_id: fee_type_id}
|
||||
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, attrs, domain: Membership)
|
||||
|> Ash.update(domain: Membership)
|
||||
end
|
||||
|
||||
defp find_cycle(cycles, cycle_id) do
|
||||
case Enum.find(cycles, &(&1.id == cycle_id)) do
|
||||
nil -> raise "Cycle not found: #{cycle_id}"
|
||||
cycle -> cycle
|
||||
end
|
||||
end
|
||||
|
||||
defp replace_cycle(cycles, updated_cycle) do
|
||||
Enum.map(cycles, fn cycle ->
|
||||
if cycle.id == updated_cycle.id, do: updated_cycle, else: cycle
|
||||
end)
|
||||
end
|
||||
|
||||
defp format_status_label(:paid), do: gettext("Paid")
|
||||
defp format_status_label(:unpaid), do: gettext("Unpaid")
|
||||
defp format_status_label(:suspended), do: gettext("Suspended")
|
||||
|
||||
defp format_error(%Ash.Error.Invalid{} = error) do
|
||||
Enum.map_join(error.errors, ", ", fn e -> e.message end)
|
||||
end
|
||||
|
||||
defp format_error(error) when is_binary(error), do: error
|
||||
defp format_error(_error), do: gettext("An error occurred")
|
||||
|
||||
defp validate_cycle_not_exists(cycles, cycle_start) do
|
||||
if Enum.any?(cycles, &(&1.cycle_start == cycle_start)) do
|
||||
{:error, :cycle_exists}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp format_create_cycle_period(date, interval) when is_struct(date, Date) do
|
||||
cycle_start = CalendarCycles.calculate_cycle_start(date, interval)
|
||||
cycle_end = CalendarCycles.calculate_cycle_end(cycle_start, interval)
|
||||
|
||||
MembershipFeeHelpers.format_cycle_range(cycle_start, interval) <>
|
||||
" (#{Calendar.strftime(cycle_start, "%d.%m.%Y")} - #{Calendar.strftime(cycle_end, "%d.%m.%Y")})"
|
||||
end
|
||||
|
||||
defp format_create_cycle_period(_date, _interval), do: ""
|
||||
|
||||
# Helper component for section box
|
||||
attr :title, :string, required: true
|
||||
slot :inner_block, required: true
|
||||
|
||||
defp section_box(assigns) do
|
||||
~H"""
|
||||
<section class="mb-6">
|
||||
<h2 class="text-lg font-semibold mb-3">{@title}</h2>
|
||||
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
</section>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
|
@ -30,11 +30,40 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|
|||
|
||||
@impl true
|
||||
def handle_event("validate", %{"settings" => params}, socket) do
|
||||
{:noreply, assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, params))}
|
||||
# Normalize checkbox value: "on" -> true, missing -> false
|
||||
normalized_params =
|
||||
if Map.has_key?(params, "include_joining_cycle") do
|
||||
params
|
||||
|> Map.update("include_joining_cycle", false, fn
|
||||
"on" -> true
|
||||
"true" -> true
|
||||
true -> true
|
||||
_ -> false
|
||||
end)
|
||||
else
|
||||
Map.put(params, "include_joining_cycle", false)
|
||||
end
|
||||
|
||||
{:noreply,
|
||||
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, normalized_params))}
|
||||
end
|
||||
|
||||
def handle_event("save", %{"settings" => params}, socket) do
|
||||
case AshPhoenix.Form.submit(socket.assigns.form, params: params) do
|
||||
# Normalize checkbox value: "on" -> true, missing -> false
|
||||
normalized_params =
|
||||
if Map.has_key?(params, "include_joining_cycle") do
|
||||
params
|
||||
|> Map.update("include_joining_cycle", false, fn
|
||||
"on" -> true
|
||||
"true" -> true
|
||||
true -> true
|
||||
_ -> false
|
||||
end)
|
||||
else
|
||||
Map.put(params, "include_joining_cycle", false)
|
||||
end
|
||||
|
||||
case AshPhoenix.Form.submit(socket.assigns.form, params: normalized_params) do
|
||||
{:ok, updated_settings} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|
|
@ -101,8 +130,11 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|
|||
)})
|
||||
</option>
|
||||
</select>
|
||||
<%= for {msg, _opts} <- @form.errors[:default_membership_fee_type_id] || [] do %>
|
||||
<p class="text-error text-sm mt-1">{msg}</p>
|
||||
<%= if @form.errors[:default_membership_fee_type_id] do %>
|
||||
<%= for error <- List.wrap(@form.errors[:default_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 %>
|
||||
<% end %>
|
||||
<p class="text-sm text-base-content/60 mt-2">
|
||||
{gettext(
|
||||
|
|
@ -125,8 +157,11 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|
|||
{gettext("Include joining cycle")}
|
||||
</span>
|
||||
</label>
|
||||
<%= for {msg, _opts} <- @form.errors[:include_joining_cycle] || [] do %>
|
||||
<p class="text-error text-sm ml-9 mt-1">{msg}</p>
|
||||
<%= if @form.errors[:include_joining_cycle] do %>
|
||||
<%= for error <- List.wrap(@form.errors[:include_joining_cycle]) do %>
|
||||
<% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %>
|
||||
<p class="text-error text-sm ml-9 mt-1">{msg}</p>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<div class="ml-9 space-y-2">
|
||||
<p class="text-sm text-base-content/60">
|
||||
|
|
|
|||
455
lib/mv_web/live/membership_fee_type_live/form.ex
Normal file
455
lib/mv_web/live/membership_fee_type_live/form.ex
Normal file
|
|
@ -0,0 +1,455 @@
|
|||
defmodule MvWeb.MembershipFeeTypeLive.Form do
|
||||
@moduledoc """
|
||||
LiveView form for creating and editing membership fee types (Admin).
|
||||
|
||||
## Features
|
||||
- Create new membership fee types
|
||||
- Edit existing membership fee types (name, amount, description - NOT interval)
|
||||
- Amount change warning modal (shows impact on members)
|
||||
- Interval field grayed out on edit
|
||||
|
||||
## Permissions
|
||||
- Admin only
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
require Ash.Query
|
||||
|
||||
alias Mv.MembershipFees
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias Mv.Membership.Member
|
||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
{@page_title}
|
||||
<:subtitle>
|
||||
{gettext("Use this form to manage membership fee types in your database.")}
|
||||
</:subtitle>
|
||||
</.header>
|
||||
|
||||
<.form
|
||||
class="max-w-xl"
|
||||
for={@form}
|
||||
id="membership-fee-type-form"
|
||||
phx-change="validate"
|
||||
phx-submit="save"
|
||||
>
|
||||
<.input field={@form[:name]} type="text" label={gettext("Name")} required />
|
||||
|
||||
<.input
|
||||
field={@form[:amount]}
|
||||
label={gettext("Amount")}
|
||||
required
|
||||
phx-debounce="blur"
|
||||
/>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="membership-fee-type-form_interval">
|
||||
<span class="label-text font-semibold">
|
||||
{gettext("Interval")}
|
||||
<span
|
||||
:if={is_nil(@membership_fee_type)}
|
||||
class="text-red-700 tooltip tooltip-right"
|
||||
data-tip={gettext("This field cannot be empty")}
|
||||
>
|
||||
*
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<select
|
||||
class={[
|
||||
"select select-bordered w-full",
|
||||
@form.errors[:interval] && "select-error"
|
||||
]}
|
||||
disabled={!is_nil(@membership_fee_type)}
|
||||
name="membership_fee_type[interval]"
|
||||
id="membership-fee-type-form_interval"
|
||||
required={is_nil(@membership_fee_type)}
|
||||
aria-label={gettext("Interval")}
|
||||
>
|
||||
<option value="">{gettext("Select interval")}</option>
|
||||
<option
|
||||
value="monthly"
|
||||
selected={@form[:interval].value == :monthly || @form[:interval].value == "monthly"}
|
||||
>
|
||||
{gettext("Monthly")}
|
||||
</option>
|
||||
<option
|
||||
value="quarterly"
|
||||
selected={@form[:interval].value == :quarterly || @form[:interval].value == "quarterly"}
|
||||
>
|
||||
{gettext("Quarterly")}
|
||||
</option>
|
||||
<option
|
||||
value="half_yearly"
|
||||
selected={
|
||||
@form[:interval].value == :half_yearly || @form[:interval].value == "half_yearly"
|
||||
}
|
||||
>
|
||||
{gettext("Half-yearly")}
|
||||
</option>
|
||||
<option
|
||||
value="yearly"
|
||||
selected={@form[:interval].value == :yearly || @form[:interval].value == "yearly"}
|
||||
>
|
||||
{gettext("Yearly")}
|
||||
</option>
|
||||
</select>
|
||||
<%= if @form.errors[:interval] do %>
|
||||
<%= for error <- List.wrap(@form.errors[:interval]) 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 %>
|
||||
<%= if !is_nil(@membership_fee_type) do %>
|
||||
<label class="label">
|
||||
<span class="label-text-alt text-base-content/60">
|
||||
{gettext("Interval cannot be changed after creation.")}
|
||||
</span>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<.input
|
||||
field={@form[:description]}
|
||||
type="textarea"
|
||||
label={gettext("Description")}
|
||||
rows="3"
|
||||
/>
|
||||
|
||||
<div class="mt-4">
|
||||
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
|
||||
{gettext("Save Membership Fee Type")}
|
||||
</.button>
|
||||
<.button navigate={return_path(@return_to, @membership_fee_type)} type="button">
|
||||
{gettext("Cancel")}
|
||||
</.button>
|
||||
</div>
|
||||
</.form>
|
||||
|
||||
<%!-- Amount Change Warning Modal --%>
|
||||
<%= if @show_amount_warning do %>
|
||||
<dialog id="amount-warning-modal" class="modal modal-open">
|
||||
<div class="modal-box">
|
||||
<h2 class="text-lg font-bold">{gettext("Change Amount?")}</h2>
|
||||
<div class="py-4 space-y-4">
|
||||
<div class="alert alert-warning">
|
||||
<.icon name="hero-exclamation-triangle" class="size-5" />
|
||||
<div>
|
||||
<p class="font-semibold">
|
||||
{gettext("Changing the amount will affect %{count} member(s).",
|
||||
count: @affected_member_count
|
||||
)}
|
||||
</p>
|
||||
<p class="mt-2 text-sm">
|
||||
{gettext("Future unpaid cycles will be regenerated with the new amount.")}
|
||||
</p>
|
||||
<p class="mt-2 text-sm">
|
||||
{gettext("Already paid cycles will remain with the old amount.")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-base-content/70">{gettext("Current amount")}:</span>
|
||||
<span class="font-mono font-semibold">
|
||||
{MembershipFeeHelpers.format_currency(@old_amount)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-base-content/70">{gettext("New amount")}:</span>
|
||||
<span class="font-mono font-semibold text-base-content">
|
||||
{MembershipFeeHelpers.format_currency(@new_amount)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button
|
||||
type="button"
|
||||
phx-click="cancel_amount_change"
|
||||
class="btn"
|
||||
>
|
||||
{gettext("Cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="confirm_amount_change"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
{gettext("Confirm Change")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
<% end %>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(params, _session, socket) do
|
||||
membership_fee_type =
|
||||
case params["id"] do
|
||||
nil -> nil
|
||||
id -> Ash.get!(MembershipFeeType, id, domain: MembershipFees)
|
||||
end
|
||||
|
||||
page_title =
|
||||
if is_nil(membership_fee_type),
|
||||
do: gettext("New Membership Fee Type"),
|
||||
else: gettext("Edit Membership Fee Type")
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:return_to, return_to(params["return_to"]))
|
||||
|> assign(:membership_fee_type, membership_fee_type)
|
||||
|> assign(:page_title, page_title)
|
||||
|> assign(:show_amount_warning, false)
|
||||
|> assign(:old_amount, nil)
|
||||
|> assign(:new_amount, nil)
|
||||
|> assign(:affected_member_count, 0)
|
||||
|> assign(:pending_amount, nil)
|
||||
|> assign_form()}
|
||||
end
|
||||
|
||||
defp return_to("index"), do: "index"
|
||||
defp return_to(_), do: "index"
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"membership_fee_type" => params}, socket) do
|
||||
# Merge with existing form values to preserve unchanged fields
|
||||
# Extract values directly from form fields to get current state
|
||||
existing_values = get_existing_form_values(socket.assigns.form)
|
||||
|
||||
# Merge existing values with new params (new params take precedence)
|
||||
merged_params = Map.merge(existing_values, params)
|
||||
|
||||
# Convert interval string to atom if present
|
||||
merged_params =
|
||||
if Map.has_key?(merged_params, "interval") && is_binary(merged_params["interval"]) &&
|
||||
merged_params["interval"] != "" do
|
||||
Map.update!(merged_params, "interval", fn val ->
|
||||
String.to_existing_atom(val)
|
||||
end)
|
||||
else
|
||||
merged_params
|
||||
end
|
||||
|
||||
# Let Ash handle validation automatically - it will validate Decimal format
|
||||
validated_form = AshPhoenix.Form.validate(socket.assigns.form, merged_params)
|
||||
|
||||
# Check if amount changed on edit
|
||||
socket = check_amount_change(socket, merged_params)
|
||||
|
||||
{:noreply, assign(socket, form: validated_form)}
|
||||
end
|
||||
|
||||
def handle_event("cancel_amount_change", _params, socket) do
|
||||
# Reset form to original amount
|
||||
form = socket.assigns.form
|
||||
|
||||
original_amount =
|
||||
if socket.assigns.membership_fee_type do
|
||||
socket.assigns.membership_fee_type.amount
|
||||
else
|
||||
Decimal.new("0")
|
||||
end
|
||||
|
||||
# Update form with original amount
|
||||
updated_form =
|
||||
AshPhoenix.Form.validate(form, %{
|
||||
"amount" => Decimal.to_string(original_amount)
|
||||
})
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:form, updated_form)
|
||||
|> assign(:show_amount_warning, false)
|
||||
|> assign(:pending_amount, nil)}
|
||||
end
|
||||
|
||||
def handle_event("confirm_amount_change", _params, socket) do
|
||||
# Update form with pending amount and hide warning
|
||||
# Preserve all existing form values (name, description, etc.)
|
||||
form = socket.assigns.form
|
||||
existing_values = get_existing_form_values(form)
|
||||
|
||||
updated_form =
|
||||
if socket.assigns.pending_amount do
|
||||
# Merge existing values with confirmed amount to preserve all fields
|
||||
merged_params = Map.put(existing_values, "amount", socket.assigns.pending_amount)
|
||||
AshPhoenix.Form.validate(form, merged_params)
|
||||
else
|
||||
form
|
||||
end
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:form, updated_form)
|
||||
|> assign(:show_amount_warning, false)
|
||||
|> assign(:pending_amount, nil)}
|
||||
end
|
||||
|
||||
def handle_event("save", %{"membership_fee_type" => params}, socket) do
|
||||
# If amount warning was shown but not confirmed, don't save
|
||||
if socket.assigns.show_amount_warning do
|
||||
{:noreply, put_flash(socket, :error, gettext("Please confirm the amount change first"))}
|
||||
else
|
||||
case AshPhoenix.Form.submit(socket.assigns.form, params: params) do
|
||||
{:ok, membership_fee_type} ->
|
||||
notify_parent({:saved, membership_fee_type})
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> put_flash(:info, gettext("Membership fee type saved successfully"))
|
||||
|> push_navigate(to: return_path(socket.assigns.return_to, membership_fee_type))
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, form} ->
|
||||
{:noreply, assign(socket, form: form)}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@spec notify_parent(any()) :: any()
|
||||
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
|
||||
|
||||
@spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
|
||||
defp assign_form(%{assigns: %{membership_fee_type: membership_fee_type}} = socket) do
|
||||
form =
|
||||
if membership_fee_type do
|
||||
AshPhoenix.Form.for_update(
|
||||
membership_fee_type,
|
||||
:update,
|
||||
domain: MembershipFees,
|
||||
as: "membership_fee_type"
|
||||
)
|
||||
else
|
||||
AshPhoenix.Form.for_create(
|
||||
MembershipFeeType,
|
||||
:create,
|
||||
domain: MembershipFees,
|
||||
as: "membership_fee_type"
|
||||
)
|
||||
end
|
||||
|
||||
assign(socket, form: to_form(form))
|
||||
end
|
||||
|
||||
# Helper to extract existing form values to preserve them when only one field changes
|
||||
defp get_existing_form_values(form) do
|
||||
# Extract values directly from form fields to get current state
|
||||
# This ensures we get the actual current values, not just initial params
|
||||
%{}
|
||||
|> extract_form_value(form, :name, &to_string/1)
|
||||
|> extract_form_value(form, :amount, &format_amount_value/1)
|
||||
|> extract_form_value(form, :interval, &format_interval_value/1)
|
||||
|> extract_form_value(form, :description, &to_string/1)
|
||||
end
|
||||
|
||||
# Helper to extract a single form field value
|
||||
defp extract_form_value(acc, form, field, formatter) do
|
||||
if form[field] && form[field].value do
|
||||
Map.put(acc, to_string(field), formatter.(form[field].value))
|
||||
else
|
||||
acc
|
||||
end
|
||||
end
|
||||
|
||||
# Formats amount value (Decimal or string) to string
|
||||
defp format_amount_value(%Decimal{} = amount), do: Decimal.to_string(amount, :normal)
|
||||
defp format_amount_value(value) when is_binary(value), do: value
|
||||
defp format_amount_value(value), do: to_string(value)
|
||||
|
||||
# Formats interval value (atom or string) to string
|
||||
defp format_interval_value(value) when is_atom(value), do: Atom.to_string(value)
|
||||
defp format_interval_value(value) when is_binary(value), do: value
|
||||
defp format_interval_value(value), do: to_string(value)
|
||||
|
||||
@spec return_path(String.t(), MembershipFeeType.t() | nil) :: String.t()
|
||||
defp return_path("index", _membership_fee_type), do: ~p"/membership_fee_types"
|
||||
|
||||
@spec get_affected_member_count(String.t()) :: non_neg_integer()
|
||||
# Checks if amount changed and updates socket assigns accordingly
|
||||
defp check_amount_change(socket, params) do
|
||||
if socket.assigns.membership_fee_type && Map.has_key?(params, "amount") do
|
||||
# Get current amount from form and new amount from params
|
||||
current_form_amount = get_existing_form_values(socket.assigns.form)["amount"]
|
||||
new_amount_str = params["amount"]
|
||||
|
||||
# Only check amount change if amount field is actually being changed in this validation
|
||||
# This prevents re-triggering the warning when other fields (name, description) are edited
|
||||
if current_form_amount != new_amount_str do
|
||||
handle_amount_change(socket, new_amount_str, socket.assigns.membership_fee_type.amount)
|
||||
else
|
||||
# Amount didn't change in this validation - keep current warning state
|
||||
# If warning was already confirmed (pending_amount is nil and show_amount_warning is false), keep it hidden
|
||||
# If warning is shown but not confirmed, keep it shown
|
||||
socket
|
||||
end
|
||||
else
|
||||
socket
|
||||
end
|
||||
end
|
||||
|
||||
# Handles amount change detection and warning assignment
|
||||
defp handle_amount_change(socket, new_amount_str, old_amount) do
|
||||
case Decimal.parse(new_amount_str) do
|
||||
{new_amount, _} when is_struct(new_amount, Decimal) ->
|
||||
if Decimal.compare(new_amount, old_amount) != :eq do
|
||||
show_amount_warning(socket, old_amount, new_amount, new_amount_str)
|
||||
else
|
||||
hide_amount_warning(socket)
|
||||
end
|
||||
|
||||
:error ->
|
||||
hide_amount_warning(socket)
|
||||
end
|
||||
end
|
||||
|
||||
# Shows amount change warning with affected member count
|
||||
# Only calculates count if warning is being shown for the first time (false -> true)
|
||||
defp show_amount_warning(socket, old_amount, new_amount, new_amount_str) do
|
||||
# Only calculate count if warning is not already shown (optimization)
|
||||
affected_count =
|
||||
if socket.assigns.show_amount_warning do
|
||||
# Warning already shown, reuse existing count
|
||||
socket.assigns.affected_member_count
|
||||
else
|
||||
# Warning being shown for first time, calculate count
|
||||
get_affected_member_count(socket.assigns.membership_fee_type.id)
|
||||
end
|
||||
|
||||
socket
|
||||
|> assign(:show_amount_warning, true)
|
||||
|> assign(:old_amount, old_amount)
|
||||
|> assign(:new_amount, new_amount)
|
||||
|> assign(:affected_member_count, affected_count)
|
||||
|> assign(:pending_amount, new_amount_str)
|
||||
end
|
||||
|
||||
# Hides amount change warning
|
||||
defp hide_amount_warning(socket) do
|
||||
socket
|
||||
|> assign(:show_amount_warning, false)
|
||||
|> assign(:pending_amount, nil)
|
||||
end
|
||||
|
||||
defp get_affected_member_count(fee_type_id) do
|
||||
case Ash.count(Member |> Ash.Query.filter(membership_fee_type_id == ^fee_type_id)) do
|
||||
{:ok, count} -> count
|
||||
_ -> 0
|
||||
end
|
||||
end
|
||||
end
|
||||
224
lib/mv_web/live/membership_fee_type_live/index.ex
Normal file
224
lib/mv_web/live/membership_fee_type_live/index.ex
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
defmodule MvWeb.MembershipFeeTypeLive.Index do
|
||||
@moduledoc """
|
||||
LiveView for managing membership fee types (Admin).
|
||||
|
||||
## Features
|
||||
- List all membership fee types
|
||||
- Display: Name, Amount, Interval, Member count
|
||||
- Create new membership fee types
|
||||
- Edit existing membership fee types (name, amount, description - NOT interval)
|
||||
- Delete membership fee types (if no members assigned)
|
||||
|
||||
## Permissions
|
||||
- Admin only
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
require Ash.Query
|
||||
|
||||
alias Mv.MembershipFees
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias Mv.Membership
|
||||
alias Mv.Membership.Member
|
||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
fee_types = load_membership_fee_types()
|
||||
member_counts = load_member_counts(fee_types)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, gettext("Membership Fee Types"))
|
||||
|> assign(:membership_fee_types, fee_types)
|
||||
|> assign(:member_counts, member_counts)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
{gettext("Membership Fee Types")}
|
||||
<:subtitle>
|
||||
{gettext("Manage membership fee types for membership fees.")}
|
||||
</:subtitle>
|
||||
<:actions>
|
||||
<.button variant="primary" navigate={~p"/membership_fee_types/new"}>
|
||||
<.icon name="hero-plus" /> {gettext("New Membership Fee Type")}
|
||||
</.button>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<.table
|
||||
id="membership_fee_types"
|
||||
rows={@membership_fee_types}
|
||||
row_id={fn mft -> "mft-#{mft.id}" end}
|
||||
>
|
||||
<:col :let={mft} label={gettext("Name")}>
|
||||
<span class="font-medium">{mft.name}</span>
|
||||
<p :if={mft.description} class="text-sm text-base-content/70">{mft.description}</p>
|
||||
</:col>
|
||||
|
||||
<:col :let={mft} label={gettext("Amount")}>
|
||||
<span class="font-mono">{MembershipFeeHelpers.format_currency(mft.amount)}</span>
|
||||
</:col>
|
||||
|
||||
<:col :let={mft} label={gettext("Interval")}>
|
||||
<span class="badge badge-outline">
|
||||
{MembershipFeeHelpers.format_interval(mft.interval)}
|
||||
</span>
|
||||
</:col>
|
||||
|
||||
<:col :let={mft} label={gettext("Members")}>
|
||||
<span class="badge badge-ghost">{get_member_count(mft, @member_counts)}</span>
|
||||
</:col>
|
||||
|
||||
<:action :let={mft}>
|
||||
<.link
|
||||
navigate={~p"/membership_fee_types/#{mft.id}/edit"}
|
||||
class="btn btn-ghost btn-xs"
|
||||
aria-label={gettext("Edit membership fee type")}
|
||||
>
|
||||
<.icon name="hero-pencil" class="size-4" />
|
||||
</.link>
|
||||
</:action>
|
||||
|
||||
<:action :let={mft}>
|
||||
<div
|
||||
:if={get_member_count(mft, @member_counts) > 0}
|
||||
class="tooltip tooltip-left"
|
||||
data-tip={
|
||||
gettext("Cannot delete - %{count} member(s) assigned",
|
||||
count: get_member_count(mft, @member_counts)
|
||||
)
|
||||
}
|
||||
>
|
||||
<button
|
||||
phx-click="delete"
|
||||
phx-value-id={mft.id}
|
||||
data-confirm={gettext("Are you sure?")}
|
||||
class="btn btn-ghost btn-xs text-error opacity-50 cursor-not-allowed"
|
||||
aria-label={
|
||||
gettext("Cannot delete - %{count} member(s) assigned",
|
||||
count: get_member_count(mft, @member_counts)
|
||||
)
|
||||
}
|
||||
disabled={true}
|
||||
>
|
||||
<.icon name="hero-trash" class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
:if={get_member_count(mft, @member_counts) == 0}
|
||||
phx-click="delete"
|
||||
phx-value-id={mft.id}
|
||||
data-confirm={gettext("Are you sure?")}
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
aria-label={gettext("Delete membership fee type")}
|
||||
>
|
||||
<.icon name="hero-trash" class="size-4" />
|
||||
</button>
|
||||
</:action>
|
||||
</.table>
|
||||
|
||||
<.info_card />
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
fee_type = Ash.get!(MembershipFeeType, id, domain: MembershipFees)
|
||||
|
||||
case Ash.destroy(fee_type, domain: MembershipFees) do
|
||||
:ok ->
|
||||
updated_types = Enum.reject(socket.assigns.membership_fee_types, &(&1.id == id))
|
||||
updated_counts = Map.delete(socket.assigns.member_counts, id)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:membership_fee_types, updated_types)
|
||||
|> assign(:member_counts, updated_counts)
|
||||
|> put_flash(:info, gettext("Membership fee type deleted"))}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply, put_flash(socket, :error, format_error(error))}
|
||||
end
|
||||
end
|
||||
|
||||
# Helper functions
|
||||
|
||||
defp load_membership_fee_types do
|
||||
MembershipFeeType
|
||||
|> Ash.Query.sort(name: :asc)
|
||||
|> Ash.read!(domain: MembershipFees)
|
||||
end
|
||||
|
||||
# Loads all member counts for fee types in a single query to avoid N+1 queries
|
||||
defp load_member_counts(fee_types) do
|
||||
fee_type_ids = Enum.map(fee_types, & &1.id)
|
||||
|
||||
# Load all members with membership_fee_type_id in a single query
|
||||
members =
|
||||
Member
|
||||
|> Ash.Query.filter(membership_fee_type_id in ^fee_type_ids)
|
||||
|> Ash.Query.select([:membership_fee_type_id])
|
||||
|> Ash.read!(domain: Membership)
|
||||
|
||||
# Group by membership_fee_type_id and count
|
||||
members
|
||||
|> Enum.group_by(& &1.membership_fee_type_id)
|
||||
|> Enum.map(fn {fee_type_id, members_list} -> {fee_type_id, length(members_list)} end)
|
||||
|> Map.new()
|
||||
end
|
||||
|
||||
# Gets member count from preloaded assigns map
|
||||
defp get_member_count(fee_type, member_counts) do
|
||||
Map.get(member_counts, fee_type.id, 0)
|
||||
end
|
||||
|
||||
defp format_error(%Ash.Error.Invalid{} = error) do
|
||||
Enum.map_join(error.errors, ", ", fn e -> e.message end)
|
||||
end
|
||||
|
||||
defp format_error(error) when is_binary(error), do: error
|
||||
defp format_error(_error), do: gettext("An error occurred")
|
||||
|
||||
# Info card explaining the membership fee type concept
|
||||
defp info_card(assigns) do
|
||||
~H"""
|
||||
<div class="card bg-base-200 mt-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<.icon name="hero-information-circle" class="size-5" />
|
||||
{gettext("About Membership Fee Types")}
|
||||
</h2>
|
||||
<div class="prose prose-sm max-w-none">
|
||||
<p>
|
||||
{gettext(
|
||||
"Membership fee types define different membership fee structures. Each type has a fixed interval (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
|
||||
)}
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>{gettext("Name & Amount")}</strong>
|
||||
- {gettext("Can be changed at any time. Amount changes affect future periods only.")}
|
||||
</li>
|
||||
<li>
|
||||
<strong>{gettext("Interval")}</strong>
|
||||
- {gettext(
|
||||
"Fixed after creation. Members can only switch between types with the same interval."
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<strong>{gettext("Deletion")}</strong>
|
||||
- {gettext("Only possible if no members are assigned to this type.")}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
181
lib/mv_web/member_live/index/membership_fee_status.ex
Normal file
181
lib/mv_web/member_live/index/membership_fee_status.ex
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
defmodule MvWeb.MemberLive.Index.MembershipFeeStatus do
|
||||
@moduledoc """
|
||||
Helper module for membership fee status display in member list view.
|
||||
|
||||
Provides functions to efficiently load and determine cycle status for members
|
||||
in the list view, avoiding N+1 queries.
|
||||
"""
|
||||
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
alias Mv.Membership.Member
|
||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||
|
||||
@doc """
|
||||
Loads membership fee cycles for members efficiently.
|
||||
|
||||
Preloads cycles with membership_fee_type relationship to avoid N+1 queries.
|
||||
Note: This loads all cycles for each member. The filtering to get the relevant
|
||||
cycle (current or last completed) happens in `get_cycle_status_for_member/2`.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `query` - Ash query for members
|
||||
- `show_current` - If true, get current cycle status; if false, get last completed cycle status (currently unused, kept for API compatibility)
|
||||
- `today` - Optional date to use as reference (currently unused, kept for API compatibility)
|
||||
|
||||
## Returns
|
||||
|
||||
Modified query with cycles loaded
|
||||
|
||||
## Performance
|
||||
|
||||
Uses Ash.Query.load to efficiently preload cycles in a single query.
|
||||
All cycles are loaded; filtering happens in memory in `get_cycle_status_for_member/2`.
|
||||
"""
|
||||
@spec load_cycles_for_members(Ash.Query.t(), boolean(), Date.t() | nil) :: Ash.Query.t()
|
||||
def load_cycles_for_members(query, _show_current \\ false, _today \\ nil) do
|
||||
# Load membership_fee_type and cycles
|
||||
query
|
||||
|> Ash.Query.load([:membership_fee_type, membership_fee_cycles: [:membership_fee_type]])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the cycle status for a member.
|
||||
|
||||
Returns the status of either the last completed cycle or the current cycle,
|
||||
depending on the `show_current` parameter.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `member` - Member struct with loaded cycles and membership_fee_type
|
||||
- `show_current` - If true, get current cycle status; if false, get last completed cycle status
|
||||
|
||||
## Returns
|
||||
|
||||
- `:paid`, `:unpaid`, or `:suspended` if cycle exists
|
||||
- `nil` if no cycle exists
|
||||
|
||||
## Examples
|
||||
|
||||
# Get last completed cycle status
|
||||
iex> MvWeb.MemberLive.Index.MembershipFeeStatus.get_cycle_status_for_member(member, false)
|
||||
:paid
|
||||
|
||||
# Get current cycle status
|
||||
iex> MvWeb.MemberLive.Index.MembershipFeeStatus.get_cycle_status_for_member(member, true)
|
||||
:unpaid
|
||||
"""
|
||||
@spec get_cycle_status_for_member(Member.t(), boolean(), Date.t() | nil) ::
|
||||
:paid | :unpaid | :suspended | nil
|
||||
def get_cycle_status_for_member(member, show_current \\ false, today \\ nil) do
|
||||
cycle =
|
||||
if show_current do
|
||||
MembershipFeeHelpers.get_current_cycle(member, today)
|
||||
else
|
||||
MembershipFeeHelpers.get_last_completed_cycle(member, today)
|
||||
end
|
||||
|
||||
case cycle do
|
||||
nil -> nil
|
||||
cycle -> cycle.status
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Formats cycle status as a badge component.
|
||||
|
||||
Returns a map with badge information for rendering in templates.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `status` - Cycle status (`:paid`, `:unpaid`, `:suspended`, or `nil`)
|
||||
|
||||
## Returns
|
||||
|
||||
Map with `:color`, `:icon`, and `:label` keys, or `nil` if status is nil
|
||||
|
||||
## Examples
|
||||
|
||||
iex> MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge(:paid)
|
||||
%{color: "badge-success", icon: "hero-check-circle", label: "Paid"}
|
||||
|
||||
iex> MvWeb.MemberLive.Index.MembershipFeeStatus.format_cycle_status_badge(nil)
|
||||
nil
|
||||
"""
|
||||
@spec format_cycle_status_badge(:paid | :unpaid | :suspended | nil) ::
|
||||
%{color: String.t(), icon: String.t(), label: String.t()} | nil
|
||||
def format_cycle_status_badge(nil), do: nil
|
||||
|
||||
def format_cycle_status_badge(status) when status in [:paid, :unpaid, :suspended] do
|
||||
%{
|
||||
color: MembershipFeeHelpers.status_color(status),
|
||||
icon: MembershipFeeHelpers.status_icon(status),
|
||||
label: format_status_label(status)
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Filters members by cycle status (paid or unpaid).
|
||||
|
||||
Returns members that have the specified status in either the last completed cycle
|
||||
or the current cycle, depending on `show_current`.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `members` - List of member structs with loaded cycles
|
||||
- `status` - Cycle status to filter by (`:paid` or `:unpaid`)
|
||||
- `show_current` - If true, filter by current cycle; if false, filter by last completed cycle
|
||||
|
||||
## Returns
|
||||
|
||||
List of members with the specified cycle status
|
||||
|
||||
## Examples
|
||||
|
||||
# Filter unpaid members in last cycle
|
||||
iex> filter_members_by_cycle_status(members, :unpaid, false)
|
||||
[%Member{}, ...]
|
||||
|
||||
# Filter paid members in current cycle
|
||||
iex> filter_members_by_cycle_status(members, :paid, true)
|
||||
[%Member{}, ...]
|
||||
"""
|
||||
@spec filter_members_by_cycle_status([Member.t()], :paid | :unpaid, boolean()) :: [Member.t()]
|
||||
def filter_members_by_cycle_status(members, status, show_current \\ false)
|
||||
when status in [:paid, :unpaid] do
|
||||
Enum.filter(members, fn member ->
|
||||
member_status = get_cycle_status_for_member(member, show_current)
|
||||
member_status == status
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Filters members by unpaid cycle status.
|
||||
|
||||
Returns members that have unpaid cycles in either the last completed cycle
|
||||
or the current cycle, depending on `show_current`.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `members` - List of member structs with loaded cycles
|
||||
- `show_current` - If true, filter by current cycle; if false, filter by last completed cycle
|
||||
|
||||
## Returns
|
||||
|
||||
List of members with unpaid cycles
|
||||
|
||||
## Deprecated
|
||||
|
||||
This function is kept for backwards compatibility. Use `filter_members_by_cycle_status/3` instead.
|
||||
"""
|
||||
@spec filter_unpaid_members([Member.t()], boolean()) :: [Member.t()]
|
||||
def filter_unpaid_members(members, show_current \\ false) do
|
||||
filter_members_by_cycle_status(members, :unpaid, show_current)
|
||||
end
|
||||
|
||||
# Private helper function to format status label
|
||||
defp format_status_label(:paid), do: gettext("Paid")
|
||||
defp format_status_label(:unpaid), do: gettext("Unpaid")
|
||||
defp format_status_label(:suspended), do: gettext("Suspended")
|
||||
end
|
||||
|
|
@ -72,6 +72,11 @@ defmodule MvWeb.Router do
|
|||
# Membership Fee Settings
|
||||
live "/membership_fee_settings", MembershipFeeSettingsLive
|
||||
|
||||
# Membership Fee Types Management
|
||||
live "/membership_fee_types", MembershipFeeTypeLive.Index, :index
|
||||
live "/membership_fee_types/new", MembershipFeeTypeLive.Form, :new
|
||||
live "/membership_fee_types/:id/edit", MembershipFeeTypeLive.Form, :edit
|
||||
|
||||
# Contribution Management (Mock-ups)
|
||||
live "/contribution_types", ContributionTypeLive.Index, :index
|
||||
live "/contributions/member/:id", ContributionPeriodLive.Show, :show
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ defmodule MvWeb.Translations.MemberFields do
|
|||
def label(:first_name), do: gettext("First Name")
|
||||
def label(:last_name), do: gettext("Last Name")
|
||||
def label(:email), do: gettext("Email")
|
||||
def label(:paid), do: gettext("Paid")
|
||||
def label(:phone_number), do: gettext("Phone")
|
||||
def label(:join_date), do: gettext("Join Date")
|
||||
def label(:exit_date), do: gettext("Exit Date")
|
||||
|
|
|
|||
28
mix.lock
28
mix.lock
|
|
@ -1,27 +1,27 @@
|
|||
%{
|
||||
"ash": {:hex, :ash, "3.11.1", "9794620bffeb83d1803d92a64e7803f70b57372eb4addba5c12a24343cd04e1a", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 0.11", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.3.14 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, ">= 0.2.6 and < 1.0.0-0", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e0074302bb88d667635fcbfdacbf8a641c53973a3902d0e744f567a49ec808fc"},
|
||||
"ash": {:hex, :ash, "3.12.0", "5b78000df650d86b446d88977ef8aa5c9d9f7ffa1193fa3c4b901c60bff2d130", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 0.11", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.3.14 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, ">= 0.2.6 and < 1.0.0-0", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7cf45b4eb83aa0ab5e6707d6e4ea4a10c29ab20613c87f06344f7953b2ca5e18"},
|
||||
"ash_admin": {:hex, :ash_admin, "0.13.24", "4fafddc7b4450a92878b58630688c55cab20b0c27e35cad68f29811f73815816", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.1.8 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "8f298cf6cb6635744ed75dd401295ed52513ea4df169f0f89d6a9a75dc4de4dc"},
|
||||
"ash_authentication": {:hex, :ash_authentication, "4.13.3", "4d7a2e96b5a8fe68797ba0124cf40e6897c82b9fb69182fc5fdaac529b72d436", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "> 0.2.0 and < 0.3.0", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "03d95b68766b28cda241e68217f6d1d839be350f7e8f20923162b163fb521b91"},
|
||||
"ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.12.2", "a4646498a7e21fbdbe372f0d8afab08b5d7125b629f91bfcf8f4d1961bc9d57b", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "1dd6fa3a8f7d2563a53cf22aeda31770c855e927421af4d8bfaf480332acf721"},
|
||||
"ash_authentication": {:hex, :ash_authentication, "4.13.6", "95b17f0bfc00bd6e229145b90c7026f784ae81639e832de4b5c96a738de5ed46", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "> 0.2.0 and < 0.3.0", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "27ded84bdc61fd267794dee17a6cbe6e52d0f365d3e8ea0460d95977b82ac6f1"},
|
||||
"ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.14.1", "60d127a73c2144b39fa3dab045cc3f7fce0c3ccd2dd3e8534288f5da65f0c1db", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "3cd57aee855be3ccf2960ce0b005ad209c97fbfc81faa71212bcfbd6a4a90cae"},
|
||||
"ash_phoenix": {:hex, :ash_phoenix, "2.3.18", "fad1b8af1405758888086de568f08650c2911ee97074cfe2e325b14854bc43dd", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "7ec28f9216221e83b90d9c3605e9d1cdd228984e09a1a86c9b9d393cebf25222"},
|
||||
"ash_postgres": {:hex, :ash_postgres, "2.6.26", "f995bac8762ae039d4fb94cf2b628430aa69b0b30bf4366b96b3543dbd679ae7", [:mix], [{:ash, "~> 3.9", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.3.12 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "7050b3169d5a31d73f7e69a6564d1102cb2bc185e67ea428e78fda3da46a69fc"},
|
||||
"ash_sql": {:hex, :ash_sql, "0.3.15", "8b8daae1870ab37b4fb2f980e323194caf23cdb4218fef126c49cc11a01fa243", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, ">= 3.13.4 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "97432507b6f406eb2461e5d0fbf2e5104a8c61a2570322d11de2f124d822d8ff"},
|
||||
"ash_postgres": {:hex, :ash_postgres, "2.6.27", "7aa119cc420909573a51802f414a49a9fb21a06ee78769efd7a4db040e748f5c", [:mix], [{:ash, ">= 3.11.2 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.3.16 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "f5e71dc3f77bc0c52374869df4b66493e13c0e27507c3d10ff13158ef7ea506f"},
|
||||
"ash_sql": {:hex, :ash_sql, "0.3.16", "a4e62d2cf9b2f4a451067e5e3de28349a8d0e69cf50fc1861bad85f478ded046", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, ">= 3.13.4 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "f3d5a810b23e12e3e102799c68b1e934fa7f909ccaa4bd530f10c7317cfcfe56"},
|
||||
"assent": {:hex, :assent, "0.2.13", "11226365d2d8661d23e9a2cf94d3255e81054ff9d88ac877f28bfdf38fa4ef31", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "bf9f351b01dd6bceea1d1f157f05438f6765ce606e6eb8d29296003d29bf6eab"},
|
||||
"bandit": {:hex, :bandit, "1.8.0", "c2e93d7e3c5c794272fa4623124f827c6f24b643acc822be64c826f9447d92fb", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "8458ff4eed20ff2a2ea69d4854883a077c33ea42b51f6811b044ceee0fa15422"},
|
||||
"bandit": {:hex, :bandit, "1.10.1", "6b1f8609d947ae2a74da5bba8aee938c94348634e54e5625eef622ca0bbbb062", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "4b4c35f273030e44268ace53bf3d5991dfc385c77374244e2f960876547671aa"},
|
||||
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
|
||||
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
|
||||
"castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"},
|
||||
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
|
||||
"circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"},
|
||||
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
|
||||
"credo": {:hex, :credo, "1.7.14", "c7e75216cea8d978ba8c60ed9dede4cc79a1c99a266c34b3600dd2c33b96bc92", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "12a97d6bb98c277e4fb1dff45aaf5c137287416009d214fb46e68147bd9e0203"},
|
||||
"credo": {:hex, :credo, "1.7.15", "283da72eeb2fd3ccf7248f4941a0527efb97afa224bcdef30b4b580bc8258e1c", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "291e8645ea3fea7481829f1e1eb0881b8395db212821338e577a90bf225c5607"},
|
||||
"crux": {:hex, :crux, "0.1.2", "4441c9e3a34f1e340954ce96b9ad5a2de13ceb4f97b3f910211227bb92e2ca90", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "563ea3748ebfba9cc078e6d198a1d6a06015a8fae503f0b721363139f0ddb350"},
|
||||
"db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"},
|
||||
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
|
||||
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
|
||||
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
|
||||
"ecto_commons": {:hex, :ecto_commons, "0.3.6", "7b1d9e59396cf8c8cbe5a26d50d03f9b6d0fe6c640210dd503622f276f1e59bb", [:mix], [{:burnex, "~> 3.0", [hex: :burnex, repo: "hexpm", optional: true]}, {:ecto, "~> 3.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:ex_phone_number, "~> 0.2", [hex: :ex_phone_number, repo: "hexpm", optional: false]}, {:luhn, "~> 0.3.0", [hex: :luhn, repo: "hexpm", optional: false]}], "hexpm", "3f12981a1e398f206c5d2014e7b732b7ec91b110b9cb84875cb5b28fc75d7a0a"},
|
||||
"ecto_sql": {:hex, :ecto_sql, "3.13.3", "81f7067dd1951081888529002dbc71f54e5e891b69c60195040ea44697e1104a", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5751caea36c8f5dd0d1de6f37eceffea19d10bd53f20e5bbe31c45f2efc8944a"},
|
||||
"ecto_sql": {:hex, :ecto_sql, "3.13.4", "b6e9d07557ddba62508a9ce4a484989a5bb5e9a048ae0e695f6d93f095c25d60", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2b38cf0749ca4d1c5a8bcbff79bbe15446861ca12a61f9fba604486cb6b62a14"},
|
||||
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
|
||||
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
|
||||
"ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"},
|
||||
|
|
@ -56,7 +56,7 @@
|
|||
"phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"},
|
||||
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
|
||||
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"},
|
||||
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.18", "b5410017b3d4edf261d9c98ebc334e0637d7189457c730720cfc13e206443d43", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f189b759595feff0420e9a1d544396397f9cf9e2d5a8cb98ba5b6cab01927da0"},
|
||||
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.19", "c95e9acbc374fb796ee3e24bfecc8213123c74d9f9e45667ca40bb0a4d242953", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d5ad357d6b21562a5b431f0ad09dfe76db9ce5648c6949f1aac334c8c4455d32"},
|
||||
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
|
||||
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
|
||||
"phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"},
|
||||
|
|
@ -64,24 +64,24 @@
|
|||
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
|
||||
"postgrex": {:hex, :postgrex, "0.21.1", "2c5cc830ec11e7a0067dd4d623c049b3ef807e9507a424985b8dcf921224cd88", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "27d8d21c103c3cc68851b533ff99eef353e6a0ff98dc444ea751de43eb48bdac"},
|
||||
"reactor": {:hex, :reactor, "0.17.0", "eb8bdb530dbae824e2d36a8538f8ec4f3aa7c2d1b61b04959fa787c634f88b49", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "3c3bf71693adbad9117b11ec83cfed7d5851b916ade508ed9718de7ae165bf25"},
|
||||
"req": {:hex, :req, "0.5.16", "99ba6a36b014458e52a8b9a0543bfa752cb0344b2a9d756651db1281d4ba4450", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "974a7a27982b9b791df84e8f6687d21483795882a7840e8309abdbe08bb06f09"},
|
||||
"req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
|
||||
"rewrite": {:hex, :rewrite, "1.2.0", "80220eb14010e175b67c939397e1a8cdaa2c32db6e2e0a9d5e23e45c0414ce21", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "a1cd702bbb9d51613ab21091f04a386d750fc6f4516b81900df082d78b2d8c50"},
|
||||
"slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"},
|
||||
"sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"},
|
||||
"sourceror": {:hex, :sourceror, "1.10.0", "38397dedbbc286966ec48c7af13e228b171332be1ad731974438c77791945ce9", [:mix], [], "hexpm", "29dbdfc92e04569c9d8e6efdc422fc1d815f4bd0055dc7c51b8800fb75c4b3f1"},
|
||||
"spark": {:hex, :spark, "2.3.14", "a08420d08e6e0e49d740aed3e160f1cb894ba8f6b3f5e6c63253e9df1995265c", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "af50c4ea5dd67eba822247f1c98e1d4e598cb7f6c28ccf5d002f0e0718096f4f"},
|
||||
"spitfire": {:hex, :spitfire, "0.2.1", "29e154873f05444669c7453d3d931820822cbca5170e88f0f8faa1de74a79b47", [:mix], [], "hexpm", "6eeed75054a38341b2e1814d41bb0a250564092358de2669fdb57ff88141d91b"},
|
||||
"splode": {:hex, :splode, "0.2.9", "3a2776e187c82f42f5226b33b1220ccbff74f4bcc523dd4039c804caaa3ffdc7", [:mix], [], "hexpm", "8002b00c6e24f8bd1bcced3fbaa5c33346048047bb7e13d2f3ad428babbd95c3"},
|
||||
"splode": {:hex, :splode, "0.2.10", "f755ebc8e5dc1556869c0513cf5f3450be602a41e01196249306483c4badbec0", [:mix], [], "hexpm", "906b6dc17b7ebc9b9fd9a31360bf0bd691d20e934fb28795c0ddb0c19d3198f1"},
|
||||
"stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"},
|
||||
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
|
||||
"swoosh": {:hex, :swoosh, "1.19.9", "4eb2c471b8cf06adbdcaa1d57a0ad53c0ed9348ce8586a06cc491f9f0dbcb553", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "516898263a64925c31723c56bc7999a26e97b04e869707f681f4c9bca7ee1688"},
|
||||
"swoosh": {:hex, :swoosh, "1.20.0", "b04134c2b302da74c3a95ca4ddde191e4854d2847d6687783fecb023a9647598", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "13e610f709bae54851d68afb6862882aa646e5c974bf49e3bf5edd84a73cf213"},
|
||||
"tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
|
||||
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
|
||||
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
|
||||
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
|
||||
"text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"},
|
||||
"thousand_island": {:hex, :thousand_island, "1.4.2", "735fa783005d1703359bbd2d3a5a3a398075ba4456e5afe3c5b7cf4666303d36", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1c7637f16558fc1c35746d5ee0e83b18b8e59e18d28affd1f2fa1645f8bc7473"},
|
||||
"tidewave": {:hex, :tidewave, "0.5.2", "f549acffe9daeed8b6b547c232c60de987770da7f827f9b3300140dfc465b102", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "34ab3ffee7e402f05cd1eae68d0e77ed0e0d1925677971ef83634247553e8afd"},
|
||||
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
|
||||
"tidewave": {:hex, :tidewave, "0.5.4", "b7b6db62779a6faf139e630eb54f218cf3091ec5d39600197008db8474cb6fb2", [:mix], [{:bandit, ">= 1.10.1", [hex: :bandit, repo: "hexpm", optional: true]}, {:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "252c7cf4ffe81d4c5ad8ef709333e7124c5af554aa07dceab61135d0f205a898"},
|
||||
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
|
||||
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
|
||||
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
|
||||
|
|
|
|||
58
notes.md
58
notes.md
|
|
@ -1,58 +0,0 @@
|
|||
# User-Member Association - Test Status
|
||||
|
||||
## Test Files Created/Modified
|
||||
|
||||
### 1. test/membership/member_available_for_linking_test.exs (NEU)
|
||||
**Status**: Alle Tests sollten FEHLSCHLAGEN ❌
|
||||
**Grund**: Die `:available_for_linking` Action existiert noch nicht
|
||||
|
||||
Tests:
|
||||
- ✗ returns only unlinked members and limits to 10
|
||||
- ✗ limits results to 10 members even when more exist
|
||||
- ✗ email match: returns only member with matching email when exists
|
||||
- ✗ email match: returns all unlinked members when no email match
|
||||
- ✗ search query: filters by first_name, last_name, and email
|
||||
- ✗ email match takes precedence over search query
|
||||
|
||||
### 2. test/accounts/user_member_linking_test.exs (NEU)
|
||||
**Status**: Tests sollten teilweise ERFOLGREICH sein ✅ / teilweise FEHLSCHLAGEN ❌
|
||||
|
||||
Tests:
|
||||
- ✓ link user to member with different email syncs member email (sollte BESTEHEN - Email-Sync ist implementiert)
|
||||
- ✓ unlink member from user sets member to nil (sollte BESTEHEN - Unlink ist implementiert)
|
||||
- ✓ cannot link member already linked to another user (sollte BESTEHEN - Validierung existiert)
|
||||
- ✓ cannot change member link directly, must unlink first (sollte BESTEHEN - Validierung existiert)
|
||||
|
||||
### 3. test/mv_web/user_live/form_test.exs (ERWEITERT)
|
||||
**Status**: Alle neuen Tests sollten FEHLSCHLAGEN ❌
|
||||
**Grund**: Member-Linking UI ist noch nicht implementiert
|
||||
|
||||
Neue Tests:
|
||||
- ✗ shows linked member with unlink button when user has member
|
||||
- ✗ shows member search field when user has no member
|
||||
- ✗ selecting member and saving links member to user
|
||||
- ✗ unlinking member and saving removes member from user
|
||||
|
||||
### 4. test/mv_web/user_live/index_test.exs (ERWEITERT)
|
||||
**Status**: Neuer Test sollte FEHLSCHLAGEN ❌
|
||||
**Grund**: Member-Spalte wird noch nicht in der Index-View angezeigt
|
||||
|
||||
Neuer Test:
|
||||
- ✗ displays linked member name in user list
|
||||
|
||||
## Zusammenfassung
|
||||
|
||||
**Tests gesamt**: 13
|
||||
**Sollten BESTEHEN**: 4 (Backend-Validierungen bereits vorhanden)
|
||||
**Sollten FEHLSCHLAGEN**: 9 (Features noch nicht implementiert)
|
||||
|
||||
## Nächste Schritte
|
||||
|
||||
1. Implementiere `:available_for_linking` Action in `lib/membership/member.ex`
|
||||
2. Erstelle `MemberAutocompleteComponent` in `lib/mv_web/live/components/member_autocomplete_component.ex`
|
||||
3. Integriere Member-Linking UI in `lib/mv_web/live/user_live/form.ex`
|
||||
4. Füge Member-Spalte zu `lib/mv_web/live/user_live/index.ex` hinzu
|
||||
5. Füge Gettext-Übersetzungen hinzu
|
||||
|
||||
Nach jeder Implementierung: Tests erneut ausführen und prüfen, ob sie grün werden.
|
||||
|
||||
|
|
@ -17,6 +17,7 @@ msgid "Actions"
|
|||
msgstr "Aktionen"
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Are you sure?"
|
||||
|
|
@ -37,6 +38,7 @@ msgstr "Stadt"
|
|||
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#: lib/mv_web/live/custom_field_live/index_component.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/user_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Delete"
|
||||
|
|
@ -141,10 +143,9 @@ msgstr "Notizen"
|
|||
|
||||
#: lib/mv_web/live/components/payment_filter_component.ex
|
||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/translations/member_fields.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/member_live/index/membership_fee_status.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Paid"
|
||||
msgstr "Bezahlt"
|
||||
|
|
@ -170,6 +171,7 @@ msgstr "Mitglied speichern"
|
|||
#: lib/mv_web/live/custom_field_value_live/form.ex
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Saving..."
|
||||
|
|
@ -183,7 +185,6 @@ msgid "Street"
|
|||
msgstr "Straße"
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/live/member_live/index/formatter.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -196,9 +197,9 @@ msgid "Show Member"
|
|||
msgstr "Mitglied anzeigen"
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/live/member_live/index/formatter.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Yes"
|
||||
msgstr "Ja"
|
||||
|
|
@ -256,6 +257,8 @@ msgstr "Ihr Passwort wurde erfolgreich zurückgesetzt"
|
|||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#: lib/mv_web/live/custom_field_value_live/form.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cancel"
|
||||
|
|
@ -268,6 +271,7 @@ msgstr "Mitglied auswählen"
|
|||
|
||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Description"
|
||||
msgstr "Beschreibung"
|
||||
|
|
@ -302,6 +306,7 @@ msgstr "Mitglied"
|
|||
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#: lib/mv_web/live/member_live/index.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Members"
|
||||
msgstr "Mitglieder"
|
||||
|
|
@ -309,6 +314,8 @@ msgstr "Mitglieder"
|
|||
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Name"
|
||||
msgstr "Name"
|
||||
|
|
@ -771,11 +778,13 @@ msgid "Tip: Paste email addresses into the BCC field for privacy compliance"
|
|||
msgstr "Tipp: E-Mail-Adressen ins BCC-Feld einfügen für Datenschutzkonformität"
|
||||
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "This field cannot be empty"
|
||||
msgstr "Dieses Feld darf nicht leer bleiben"
|
||||
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#: lib/mv_web/live/components/payment_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "All"
|
||||
msgstr "Alle"
|
||||
|
|
@ -785,11 +794,6 @@ msgstr "Alle"
|
|||
msgid "Filter by payment status"
|
||||
msgstr "Nach Zahlungsstatus filtern"
|
||||
|
||||
#: lib/mv_web/live/components/payment_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Not paid"
|
||||
msgstr "Nicht bezahlt"
|
||||
|
||||
#: lib/mv_web/live/components/payment_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Payment filter"
|
||||
|
|
@ -807,7 +811,6 @@ msgid "Back"
|
|||
msgstr "Zurück"
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Coming soon"
|
||||
msgstr "Demnächst verfügbar"
|
||||
|
|
@ -818,40 +821,21 @@ msgstr "Demnächst verfügbar"
|
|||
msgid "Contact Data"
|
||||
msgstr "Kontaktdaten"
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Contribution"
|
||||
msgstr "Beitrag"
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Nr."
|
||||
msgstr "Nr."
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Payment Cycle"
|
||||
msgstr "Zahlungszyklus"
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Payment Data"
|
||||
msgstr "Beitragsdaten"
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Payments"
|
||||
msgstr "Zahlungen"
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Pending"
|
||||
msgstr "Ausstehend"
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -866,27 +850,11 @@ msgid "Phone"
|
|||
msgstr "Telefon"
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Save"
|
||||
msgstr "Speichern"
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "This data is for demonstration purposes only (mockup)."
|
||||
msgstr "Diese Daten dienen nur zu Demonstrationszwecken (Mockup)."
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "monthly"
|
||||
msgstr "monatlich"
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "yearly"
|
||||
msgstr "jährlich"
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Create Member"
|
||||
|
|
@ -906,6 +874,9 @@ msgstr "Über Beitragsarten"
|
|||
|
||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#: lib/mv_web/live/contribution_type_live/index.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/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Amount"
|
||||
msgstr "Betrag"
|
||||
|
|
@ -916,6 +887,7 @@ msgid "Back to Settings"
|
|||
msgstr "Zurück zu den Einstellungen"
|
||||
|
||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Can be changed at any time. Amount changes affect future periods only."
|
||||
msgstr "Kann jederzeit geändert werden. Änderungen des Betrags betreffen nur zukünftige Zyklen."
|
||||
|
|
@ -935,7 +907,6 @@ msgstr "Beitragsart ändern"
|
|||
msgid "Contribution Start"
|
||||
msgstr "Beitragsbeginn"
|
||||
|
||||
#: lib/mv_web/components/layouts/navbar.ex
|
||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Contribution Types"
|
||||
|
|
@ -967,6 +938,7 @@ msgid "Current"
|
|||
msgstr "Aktuell"
|
||||
|
||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Deletion"
|
||||
msgstr "Löschen"
|
||||
|
|
@ -982,6 +954,7 @@ msgid "Family"
|
|||
msgstr "Familie"
|
||||
|
||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Fixed after creation. Members can only switch between types with the same interval."
|
||||
msgstr "Festgelegt nach der Erstellung. Mitglieder können nur zwischen Beitragsarten mit gleichem Intervall wechseln."
|
||||
|
|
@ -991,9 +964,11 @@ msgstr "Festgelegt nach der Erstellung. Mitglieder können nur zwischen Beitrags
|
|||
msgid "Global Settings"
|
||||
msgstr "Vereinsdaten"
|
||||
|
||||
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Half-yearly"
|
||||
msgstr "Halbjährlich"
|
||||
|
|
@ -1011,6 +986,9 @@ msgstr "Ehrenamtlich"
|
|||
|
||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#: lib/mv_web/live/contribution_type_live/index.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/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Interval"
|
||||
msgstr "Zyklus"
|
||||
|
|
@ -1080,9 +1058,11 @@ msgstr "Mitglied seit"
|
|||
msgid "Members can only switch between contribution types with the same payment interval (e.g., yearly to yearly). This prevents complex period overlaps."
|
||||
msgstr "Mitglieder können nur zwischen Beitragsarten mit demselben Zahlungszyklus wechseln (z. B. jährlich zu jährlich). Dadurch werden komplexe Überlappungen vermieden."
|
||||
|
||||
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Monthly"
|
||||
msgstr "Monatlich"
|
||||
|
|
@ -1093,6 +1073,7 @@ msgid "Monthly fee for students and trainees"
|
|||
msgstr "Monatlicher Beitrag für Studierende und Auszubildende"
|
||||
|
||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Name & Amount"
|
||||
msgstr "Name & Betrag"
|
||||
|
|
@ -1108,6 +1089,7 @@ msgid "No fee for honorary members"
|
|||
msgstr "Kein Beitrag für ehrenamtliche Mitglieder"
|
||||
|
||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Only possible if no members are assigned to this type."
|
||||
msgstr "Nur möglich, wenn diesem Typ keine Mitglieder zugewiesen sind."
|
||||
|
|
@ -1128,9 +1110,11 @@ msgstr "Bezahlt durch Überweisung"
|
|||
msgid "Preview Mockup"
|
||||
msgstr "Vorschau"
|
||||
|
||||
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Quarterly"
|
||||
msgstr "Vierteljährlich"
|
||||
|
|
@ -1168,6 +1152,7 @@ msgid "Standard membership fee for regular members"
|
|||
msgstr "Regulärer Mitgliedsbeitrag für Vollmitglieder"
|
||||
|
||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Status"
|
||||
msgstr "Status"
|
||||
|
|
@ -1188,6 +1173,9 @@ msgid "Suspend"
|
|||
msgstr "Pausieren"
|
||||
|
||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/member_live/index/membership_fee_status.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Suspended"
|
||||
msgstr "Pausiert"
|
||||
|
|
@ -1208,7 +1196,11 @@ msgstr "Zeitraum"
|
|||
msgid "Total Contributions"
|
||||
msgstr "Gesamtbeiträge"
|
||||
|
||||
#: lib/mv_web/live/components/payment_filter_component.ex
|
||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/member_live/index/membership_fee_status.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Unpaid"
|
||||
msgstr "Unbezahlt"
|
||||
|
|
@ -1218,9 +1210,11 @@ msgstr "Unbezahlt"
|
|||
msgid "Why are not all contribution types shown?"
|
||||
msgstr "Warum werden nicht alle Beitragsarten angezeigt?"
|
||||
|
||||
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Yearly"
|
||||
msgstr "jährlich"
|
||||
|
|
@ -1241,6 +1235,7 @@ msgid "Last name"
|
|||
msgstr "Nachname"
|
||||
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "None"
|
||||
msgstr "Keine"
|
||||
|
|
@ -1301,6 +1296,7 @@ msgstr "Diese Felder können zusätzlich zu den normalen Daten ausgefüllt werde
|
|||
msgid "Value Type"
|
||||
msgstr "Wertetyp"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/translations/field_types.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Date"
|
||||
|
|
@ -1326,11 +1322,6 @@ msgstr "Textfeld"
|
|||
msgid "Yes/No-Selection"
|
||||
msgstr "Ja/Nein-Auswahl"
|
||||
|
||||
#: lib/mv_web/live/components/payment_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "All payment statuses"
|
||||
msgstr "Jeder Zahlungs-Zustand"
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Copy email addresses"
|
||||
|
|
@ -1346,6 +1337,11 @@ msgstr "Benutzerdefiniertes Feld speichern"
|
|||
msgid "Save Custom Field Value"
|
||||
msgstr "Benutzerdefinierten Feldwert speichern"
|
||||
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "This field is required"
|
||||
msgstr "Dieses Feld ist erforderlich"
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Configure global settings for membership fees."
|
||||
|
|
@ -1422,6 +1418,419 @@ msgstr "Jährliches Intervall – Beitrittszeitraum nicht einbezogen"
|
|||
msgid "Yearly Interval - Joining Cycle Included"
|
||||
msgstr "Jährliches Intervall – Beitrittszeitraum einbezogen"
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "About Membership Fee Types"
|
||||
msgstr "Über Mitgliedsbeitragsarten"
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Already paid cycles will remain with the old amount."
|
||||
msgstr "Bereits bezahlte Zyklen bleiben mit dem alten Betrag."
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "An error occurred"
|
||||
msgstr "Ein Fehler ist aufgetreten"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Are you sure you want to delete this cycle?"
|
||||
msgstr "Möchten Sie diesen Zyklus wirklich löschen?"
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cannot delete - %{count} member(s) assigned"
|
||||
msgstr "Löschen nicht möglich – %{count} Mitglied(er) zugewiesen"
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Change Amount?"
|
||||
msgstr "Betrag ändern?"
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Changing the amount will affect %{count} member(s)."
|
||||
msgstr "Die Änderung des Betrags betrifft %{count} Mitglied(er)."
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Confirm Change"
|
||||
msgstr "Änderung bestätigen"
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Current Cycle"
|
||||
msgstr "Aktueller Zyklus"
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Current amount"
|
||||
msgstr "Aktueller Betrag"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cycle"
|
||||
msgstr "Zyklus"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cycle amount updated"
|
||||
msgstr "Zyklusbetrag aktualisiert"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cycle deleted"
|
||||
msgstr "Zyklus gelöscht"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cycle status updated"
|
||||
msgstr "Zyklenstatus aktualisiert"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cycles regenerated successfully"
|
||||
msgstr "Zyklen erfolgreich regeneriert"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Delete Cycle"
|
||||
msgstr "Zyklus löschen"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Edit Cycle Amount"
|
||||
msgstr "Zyklusbetrag bearbeiten"
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Edit Membership Fee Type"
|
||||
msgstr "Mitgliedsbeitragsart bearbeiten"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Failed to update cycle status: %{errors}"
|
||||
msgstr "Fehler beim Aktualisieren des Zyklenstatus: %{errors}"
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Future unpaid cycles will be regenerated with the new amount."
|
||||
msgstr "Zukünftige unbezahlte Zyklen werden mit dem neuen Betrag regeneriert."
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Generate cycles from the last existing cycle to today"
|
||||
msgstr "Zyklen vom letzten existierenden Zyklus bis heute generieren"
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Interval cannot be changed after creation."
|
||||
msgstr "Das Intervall kann nach der Erstellung nicht geändert werden."
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Invalid amount format"
|
||||
msgstr "Ungültiges Betragsformat"
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Last Cycle"
|
||||
msgstr "Letzter Zyklus"
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Manage membership fee types for membership fees."
|
||||
msgstr "Mitgliedsbeitragsarten für Mitgliedsbeiträge verwalten."
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Mark as paid"
|
||||
msgstr "Als bezahlt markieren"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Mark as suspended"
|
||||
msgstr "Als ausgesetzt markieren"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Mark as unpaid"
|
||||
msgstr "Als unbezahlt markieren"
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Membership Fee"
|
||||
msgstr "Mitgliedsbeitrag"
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Membership Fee Status"
|
||||
msgstr "Mitgliedsbeitragsstatus"
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Membership Fee Type"
|
||||
msgstr "Mitgliedsbeitragsart"
|
||||
|
||||
#: lib/mv_web/components/layouts/navbar.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Membership Fee Types"
|
||||
msgstr "Mitgliedsbeitragsarten"
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Membership Fees"
|
||||
msgstr "Mitgliedsbeiträge"
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Membership fee type deleted"
|
||||
msgstr "Mitgliedsbeitragsart gelöscht"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Membership fee type removed"
|
||||
msgstr "Mitgliedsbeitragsart entfernt"
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Membership fee type saved successfully"
|
||||
msgstr "Mitgliedsbeitragsart erfolgreich gespeichert"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Membership fee type updated. Cycles regenerated."
|
||||
msgstr "Mitgliedsbeitragsart aktualisiert. Zyklen regeneriert."
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Membership fee types define different membership fee structures. Each type has a fixed interval (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
|
||||
msgstr "Mitgliedsbeitragsarten definieren verschiedene Mitgliedsbeitragsstrukturen. Jede Art hat ein festes Intervall (monatlich, vierteljährlich, halbjährlich, jährlich), das nach der Erstellung nicht geändert werden kann."
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "New Membership Fee Type"
|
||||
msgstr "Neue Mitgliedsbeitragsart"
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "New amount"
|
||||
msgstr "Neuer Betrag"
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No cycle"
|
||||
msgstr "Kein Zyklus"
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No cycles"
|
||||
msgstr "Keine Zyklen"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No membership fee cycles found. Cycles will be generated automatically when a membership fee type is assigned."
|
||||
msgstr "Keine Mitgliedsbeitragszylen gefunden. Zyklen werden automatisch generiert, wenn eine Mitgliedsbeitragsart zugewiesen wird."
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No membership fee type assigned"
|
||||
msgstr "Keine Mitgliedsbeitragsart zugewiesen"
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No status"
|
||||
msgstr "Kein Status"
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Please confirm the amount change first"
|
||||
msgstr "Bitte bestätigen Sie zuerst die Betragsänderung"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Regenerate Cycles"
|
||||
msgstr "Zyklen regenerieren"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Regenerating..."
|
||||
msgstr "Regeneriere..."
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Save Membership Fee Type"
|
||||
msgstr "Mitgliedsbeitragsart speichern"
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Select a membership fee type for this member. Members can only switch between types with the same interval."
|
||||
msgstr "Wählen Sie eine Mitgliedsbeitragsart für dieses Mitglied. Mitglieder können nur zwischen Arten mit demselben Intervall wechseln."
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Select interval"
|
||||
msgstr "Intervall auswählen"
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Type"
|
||||
msgstr "Art"
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Use this form to manage membership fee types in your database."
|
||||
msgstr "Verwenden Sie dieses Formular, um Mitgliedsbeitragsarten in Ihrer Datenbank zu verwalten."
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Warning: Changing from %{old_interval} to %{new_interval} is not allowed. Please select a membership fee type with the same interval."
|
||||
msgstr "Warnung: Wechsel von %{old_interval} zu %{new_interval} ist nicht erlaubt. Bitte wählen Sie eine Mitgliedsbeitragsart mit demselben Intervall."
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "A cycle for this period already exists"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "All cycles deleted"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Click to edit amount"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Create"
|
||||
msgstr "erstellt"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Create Cycle"
|
||||
msgstr "Aktueller Zyklus"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Create a new cycle manually"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Cycle Period"
|
||||
msgstr "Zyklus"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Cycle created successfully"
|
||||
msgstr "Zyklen erfolgreich regeneriert"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Delete All"
|
||||
msgstr "Löschen"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Delete All Cycles"
|
||||
msgstr "Alle Zyklen löschen"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Delete all cycles"
|
||||
msgstr "Zyklus löschen"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Delete cycle"
|
||||
msgstr "Zyklus löschen"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Invalid date format"
|
||||
msgstr "Ungültiges Betragsformat"
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Payment Interval"
|
||||
msgstr "Zahlungsfilter"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "The cycle period will be calculated based on this date and the interval."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "This action cannot be undone."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Type '%{confirmation}' to confirm"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Warning"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You are about to delete all %{count} cycles for this member."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Current Cycle Payment Status"
|
||||
msgstr "Aktueller Zyklus Zahlungsstatus"
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Last Cycle Payment Status"
|
||||
msgstr "Letzter Zyklus Zahlungsstatus"
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Delete membership fee type"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Edit membership fee type"
|
||||
msgstr "Mitgliedsbeitragsart bearbeiten"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Confirmation text does not match"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "No cycles to delete"
|
||||
msgstr "Keine Zyklen"
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Not set"
|
||||
msgstr "Nicht gesetzt"
|
||||
|
||||
#~ #: lib/mv_web/live/components/payment_filter_component.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "All payment statuses"
|
||||
#~ msgstr "Jeder Zahlungs-Zustand"
|
||||
|
||||
#~ #: lib/mv_web/live/custom_field_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Auto-generated identifier (immutable)"
|
||||
|
|
@ -1432,47 +1841,37 @@ msgstr "Jährliches Intervall – Beitrittszeitraum einbezogen"
|
|||
#~ msgid "Configure global settings for membership contributions."
|
||||
#~ msgstr "Globale Einstellungen für Mitgliedsbeiträge konfigurieren."
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/form.ex
|
||||
#~ #: lib/mv_web/live/member_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Contribution"
|
||||
#~ msgstr "Beitrag"
|
||||
|
||||
#~ #: lib/mv_web/components/layouts/navbar.ex
|
||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "Contribution Settings"
|
||||
#~ msgstr "Beitragseinstellungen"
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "Contribution start"
|
||||
#~ msgstr "Beitragsbeginn"
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Copy emails"
|
||||
#~ msgstr "E-Mails kopieren"
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/form.ex
|
||||
#~ #: lib/mv_web/live/member_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Custom Field Values"
|
||||
#~ msgstr "Benutzerdefinierte Feldwerte"
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Default Contribution Type"
|
||||
#~ msgstr "Standard-Beitragsart"
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
||||
#~ #: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Example: Member Contribution View"
|
||||
#~ msgstr "Beispiel: Ansicht Mitgliedsbeiträge"
|
||||
#~ msgid "Edit amount"
|
||||
#~ msgstr "Betrag bearbeiten"
|
||||
|
||||
#~ #: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Failed to save settings. Please check the errors below."
|
||||
#~ msgstr "Einstellungen konnten nicht gespeichert werden. Bitte prüfen Sie die Fehler unten."
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Generated periods"
|
||||
#~ msgstr "Generierte Zyklen"
|
||||
#~ #: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "Failed to delete some cycles: %{errors}"
|
||||
#~ msgstr "Konnte Feld nicht löschen: %{error}"
|
||||
|
||||
#~ #: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
|
|
@ -1484,58 +1883,89 @@ msgstr "Jährliches Intervall – Beitrittszeitraum einbezogen"
|
|||
#~ msgid "Include joining period"
|
||||
#~ msgstr "Beitrittsdatum einbeziehen"
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Monthly Interval - Joining Period Included"
|
||||
#~ msgstr "Monatliches Intervall – Beitrittszeitraum einbezogen"
|
||||
|
||||
#~ #: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "New Custom field"
|
||||
#~ msgstr "Benutzerdefiniertes Feld speichern"
|
||||
|
||||
#~ #: lib/mv_web/live/user_live/form.ex
|
||||
#~ #: lib/mv_web/live/user_live/show.ex
|
||||
#~ #: lib/mv_web/live/components/payment_filter_component.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Not set"
|
||||
#~ msgstr "Nicht gesetzt"
|
||||
#~ msgid "Not paid"
|
||||
#~ msgstr "Nicht bezahlt"
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Payment Cycle"
|
||||
#~ msgstr "Zahlungszyklus"
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Pending"
|
||||
#~ msgstr "Ausstehend"
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Quarterly Interval - Joining Period Excluded"
|
||||
#~ msgstr "Vierteljährliches Intervall – Beitrittszeitraum nicht einbezogen"
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
||||
#~ #: lib/mv_web/live/member_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "See how the contribution periods will be displayed for an individual member. This example shows Maria Weber with multiple contribution periods."
|
||||
#~ msgstr "Beispielhafte Anzeige der Beitragsperioden für ein einzelnes Mitglied. In diesem Beispiel wird Maria Weber mit mehreren Zyklen angezeigt."
|
||||
#~ msgid "Show Last/Current Cycle Payment Status"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
||||
#~ #: lib/mv_web/live/member_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "This contribution type is automatically assigned to all new members. Can be changed individually per member."
|
||||
#~ msgstr "Dieser Beitragstyp wird automatisch neuen Mitgliedern zugewiesen. Kann individuell angepasst werden."
|
||||
#~ msgid "Show current cycle"
|
||||
#~ msgstr "Aktuellen Zyklus anzeigen"
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Show last completed cycle"
|
||||
#~ msgstr "Letzten abgeschlossenen Zyklus anzeigen"
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Switch to current cycle"
|
||||
#~ msgstr "Zum aktuellen Zyklus wechseln"
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Switch to last completed cycle"
|
||||
#~ msgstr "Zum letzten abgeschlossenen Zyklus wechseln"
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/form.ex
|
||||
#~ #: lib/mv_web/live/member_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "This data is for demonstration purposes only (mockup)."
|
||||
#~ msgstr "Diese Daten dienen nur zu Demonstrationszwecken (Mockup)."
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Unpaid in current cycle"
|
||||
#~ msgstr "Unbezahlt im aktuellen Zyklus"
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Unpaid in last cycle"
|
||||
#~ msgstr "Unbezahlt im letzten Zyklus"
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "View Example Member"
|
||||
#~ msgstr "Beispielmitglied anzeigen"
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "When active: Members pay from the period of their joining."
|
||||
#~ msgstr "Wenn aktiviert: Mitglieder zahlen ab dem Zeitraum ihres Beitritts."
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "When inactive: Members pay from the next full period after joining."
|
||||
#~ msgstr "Wenn deaktiviert: Mitglieder zahlen ab dem nächsten vollen Beitragszyklus nach dem Beitritt."
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Yearly Interval - Joining Period Excluded"
|
||||
#~ msgstr "Jährliches Intervall – Beitrittszeitraum nicht einbezogen"
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Yearly Interval - Joining Period Included"
|
||||
#~ msgstr "Jährliches Intervall – Beitrittszeitraum einbezogen"
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/form.ex
|
||||
#~ #: lib/mv_web/live/member_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "monthly"
|
||||
#~ msgstr "monatlich"
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/form.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "yearly"
|
||||
#~ msgstr "jährlich"
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ msgid "Actions"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Are you sure?"
|
||||
|
|
@ -38,6 +39,7 @@ msgstr ""
|
|||
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#: lib/mv_web/live/custom_field_live/index_component.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/user_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Delete"
|
||||
|
|
@ -142,10 +144,9 @@ msgstr ""
|
|||
|
||||
#: lib/mv_web/live/components/payment_filter_component.ex
|
||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/translations/member_fields.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/member_live/index/membership_fee_status.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Paid"
|
||||
msgstr ""
|
||||
|
|
@ -171,6 +172,7 @@ msgstr ""
|
|||
#: lib/mv_web/live/custom_field_value_live/form.ex
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Saving..."
|
||||
|
|
@ -184,7 +186,6 @@ msgid "Street"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/live/member_live/index/formatter.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -197,9 +198,9 @@ msgid "Show Member"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/live/member_live/index/formatter.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Yes"
|
||||
msgstr ""
|
||||
|
|
@ -257,6 +258,8 @@ msgstr ""
|
|||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#: lib/mv_web/live/custom_field_value_live/form.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cancel"
|
||||
|
|
@ -269,6 +272,7 @@ msgstr ""
|
|||
|
||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Description"
|
||||
msgstr ""
|
||||
|
|
@ -303,6 +307,7 @@ msgstr ""
|
|||
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#: lib/mv_web/live/member_live/index.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Members"
|
||||
msgstr ""
|
||||
|
|
@ -310,6 +315,8 @@ msgstr ""
|
|||
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Name"
|
||||
msgstr ""
|
||||
|
|
@ -772,11 +779,13 @@ msgid "Tip: Paste email addresses into the BCC field for privacy compliance"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "This field cannot be empty"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#: lib/mv_web/live/components/payment_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "All"
|
||||
msgstr ""
|
||||
|
|
@ -786,11 +795,6 @@ msgstr ""
|
|||
msgid "Filter by payment status"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/payment_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Not paid"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/payment_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Payment filter"
|
||||
|
|
@ -808,7 +812,6 @@ msgid "Back"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Coming soon"
|
||||
msgstr ""
|
||||
|
|
@ -819,40 +822,21 @@ msgstr ""
|
|||
msgid "Contact Data"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Contribution"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Nr."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Payment Cycle"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Payment Data"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Payments"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Pending"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -867,27 +851,11 @@ msgid "Phone"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Save"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "This data is for demonstration purposes only (mockup)."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "monthly"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "yearly"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Create Member"
|
||||
|
|
@ -907,6 +875,9 @@ msgstr ""
|
|||
|
||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#: lib/mv_web/live/contribution_type_live/index.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/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Amount"
|
||||
msgstr ""
|
||||
|
|
@ -917,6 +888,7 @@ msgid "Back to Settings"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Can be changed at any time. Amount changes affect future periods only."
|
||||
msgstr ""
|
||||
|
|
@ -936,7 +908,6 @@ msgstr ""
|
|||
msgid "Contribution Start"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/navbar.ex
|
||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Contribution Types"
|
||||
|
|
@ -968,6 +939,7 @@ msgid "Current"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Deletion"
|
||||
msgstr ""
|
||||
|
|
@ -983,6 +955,7 @@ msgid "Family"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Fixed after creation. Members can only switch between types with the same interval."
|
||||
msgstr ""
|
||||
|
|
@ -992,9 +965,11 @@ msgstr ""
|
|||
msgid "Global Settings"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Half-yearly"
|
||||
msgstr ""
|
||||
|
|
@ -1012,6 +987,9 @@ msgstr ""
|
|||
|
||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#: lib/mv_web/live/contribution_type_live/index.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/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Interval"
|
||||
msgstr ""
|
||||
|
|
@ -1081,9 +1059,11 @@ msgstr ""
|
|||
msgid "Members can only switch between contribution types with the same payment interval (e.g., yearly to yearly). This prevents complex period overlaps."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Monthly"
|
||||
msgstr ""
|
||||
|
|
@ -1094,6 +1074,7 @@ msgid "Monthly fee for students and trainees"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Name & Amount"
|
||||
msgstr ""
|
||||
|
|
@ -1109,6 +1090,7 @@ msgid "No fee for honorary members"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Only possible if no members are assigned to this type."
|
||||
msgstr ""
|
||||
|
|
@ -1129,9 +1111,11 @@ msgstr ""
|
|||
msgid "Preview Mockup"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Quarterly"
|
||||
msgstr ""
|
||||
|
|
@ -1169,6 +1153,7 @@ msgid "Standard membership fee for regular members"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Status"
|
||||
msgstr ""
|
||||
|
|
@ -1189,6 +1174,9 @@ msgid "Suspend"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/member_live/index/membership_fee_status.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Suspended"
|
||||
msgstr ""
|
||||
|
|
@ -1209,7 +1197,11 @@ msgstr ""
|
|||
msgid "Total Contributions"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/payment_filter_component.ex
|
||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/member_live/index/membership_fee_status.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Unpaid"
|
||||
msgstr ""
|
||||
|
|
@ -1219,9 +1211,11 @@ msgstr ""
|
|||
msgid "Why are not all contribution types shown?"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Yearly"
|
||||
msgstr ""
|
||||
|
|
@ -1242,6 +1236,7 @@ msgid "Last name"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "None"
|
||||
msgstr ""
|
||||
|
|
@ -1302,6 +1297,7 @@ msgstr ""
|
|||
msgid "Value Type"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/translations/field_types.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Date"
|
||||
|
|
@ -1327,11 +1323,6 @@ msgstr ""
|
|||
msgid "Yes/No-Selection"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/payment_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "All payment statuses"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Copy email addresses"
|
||||
|
|
@ -1347,6 +1338,11 @@ msgstr ""
|
|||
msgid "Save Custom Field Value"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "This field is required"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Configure global settings for membership fees."
|
||||
|
|
@ -1422,3 +1418,411 @@ msgstr ""
|
|||
#, elixir-autogen, elixir-format
|
||||
msgid "Yearly Interval - Joining Cycle Included"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "About Membership Fee Types"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Already paid cycles will remain with the old amount."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "An error occurred"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Are you sure you want to delete this cycle?"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cannot delete - %{count} member(s) assigned"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Change Amount?"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Changing the amount will affect %{count} member(s)."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Confirm Change"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Current Cycle"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Current amount"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cycle"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cycle amount updated"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cycle deleted"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cycle status updated"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cycles regenerated successfully"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Delete Cycle"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Edit Cycle Amount"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Edit Membership Fee Type"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Failed to update cycle status: %{errors}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Future unpaid cycles will be regenerated with the new amount."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Generate cycles from the last existing cycle to today"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Interval cannot be changed after creation."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Invalid amount format"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Last Cycle"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Manage membership fee types for membership fees."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Mark as paid"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Mark as suspended"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Mark as unpaid"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Membership Fee"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Membership Fee Status"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Membership Fee Type"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/navbar.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Membership Fee Types"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Membership Fees"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Membership fee type deleted"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Membership fee type removed"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Membership fee type saved successfully"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Membership fee type updated. Cycles regenerated."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Membership fee types define different membership fee structures. Each type has a fixed interval (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "New Membership Fee Type"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "New amount"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No cycle"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No cycles"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No membership fee cycles found. Cycles will be generated automatically when a membership fee type is assigned."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No membership fee type assigned"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No status"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Please confirm the amount change first"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Regenerate Cycles"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Regenerating..."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Save Membership Fee Type"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Select a membership fee type for this member. Members can only switch between types with the same interval."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Select interval"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Type"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Use this form to manage membership fee types in your database."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Warning: Changing from %{old_interval} to %{new_interval} is not allowed. Please select a membership fee type with the same interval."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "A cycle for this period already exists"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "All cycles deleted"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Click to edit amount"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Create"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Create Cycle"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Create a new cycle manually"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cycle Period"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cycle created successfully"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Delete All"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Delete All Cycles"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Delete all cycles"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Delete cycle"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Invalid date format"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Payment Interval"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "The cycle period will be calculated based on this date and the interval."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "This action cannot be undone."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Type '%{confirmation}' to confirm"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Warning"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You are about to delete all %{count} cycles for this member."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Current Cycle Payment Status"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Last Cycle Payment Status"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Delete membership fee type"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Edit membership fee type"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Confirmation text does not match"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No cycles to delete"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Not set"
|
||||
msgstr ""
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ msgid "Actions"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Are you sure?"
|
||||
|
|
@ -38,6 +39,7 @@ msgstr ""
|
|||
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#: lib/mv_web/live/custom_field_live/index_component.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/user_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Delete"
|
||||
|
|
@ -142,10 +144,9 @@ msgstr ""
|
|||
|
||||
#: lib/mv_web/live/components/payment_filter_component.ex
|
||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/translations/member_fields.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/member_live/index/membership_fee_status.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Paid"
|
||||
msgstr ""
|
||||
|
|
@ -171,6 +172,7 @@ msgstr ""
|
|||
#: lib/mv_web/live/custom_field_value_live/form.ex
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Saving..."
|
||||
|
|
@ -184,7 +186,6 @@ msgid "Street"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/live/member_live/index/formatter.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -197,9 +198,9 @@ msgid "Show Member"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/live/member_live/index/formatter.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Yes"
|
||||
msgstr ""
|
||||
|
|
@ -257,6 +258,8 @@ msgstr ""
|
|||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#: lib/mv_web/live/custom_field_value_live/form.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cancel"
|
||||
|
|
@ -269,6 +272,7 @@ msgstr ""
|
|||
|
||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Description"
|
||||
msgstr ""
|
||||
|
|
@ -303,6 +307,7 @@ msgstr ""
|
|||
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#: lib/mv_web/live/member_live/index.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Members"
|
||||
msgstr ""
|
||||
|
|
@ -310,6 +315,8 @@ msgstr ""
|
|||
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Name"
|
||||
msgstr ""
|
||||
|
|
@ -772,11 +779,13 @@ msgid "Tip: Paste email addresses into the BCC field for privacy compliance"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "This field cannot be empty"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#: lib/mv_web/live/components/payment_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "All"
|
||||
msgstr ""
|
||||
|
|
@ -786,11 +795,6 @@ msgstr ""
|
|||
msgid "Filter by payment status"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/payment_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Not paid"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/payment_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Payment filter"
|
||||
|
|
@ -808,7 +812,6 @@ msgid "Back"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Coming soon"
|
||||
msgstr ""
|
||||
|
|
@ -819,40 +822,21 @@ msgstr ""
|
|||
msgid "Contact Data"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Contribution"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Nr."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Payment Cycle"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Payment Data"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Payments"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Pending"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -867,27 +851,11 @@ msgid "Phone"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Save"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "This data is for demonstration purposes only (mockup)."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "monthly"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "yearly"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Create Member"
|
||||
|
|
@ -907,6 +875,9 @@ msgstr ""
|
|||
|
||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#: lib/mv_web/live/contribution_type_live/index.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/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Amount"
|
||||
msgstr ""
|
||||
|
|
@ -917,6 +888,7 @@ msgid "Back to Settings"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Can be changed at any time. Amount changes affect future periods only."
|
||||
msgstr ""
|
||||
|
|
@ -936,7 +908,6 @@ msgstr ""
|
|||
msgid "Contribution Start"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/navbar.ex
|
||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Contribution Types"
|
||||
|
|
@ -968,6 +939,7 @@ msgid "Current"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Deletion"
|
||||
msgstr ""
|
||||
|
|
@ -983,6 +955,7 @@ msgid "Family"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Fixed after creation. Members can only switch between types with the same interval."
|
||||
msgstr ""
|
||||
|
|
@ -992,9 +965,11 @@ msgstr ""
|
|||
msgid "Global Settings"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Half-yearly"
|
||||
msgstr ""
|
||||
|
|
@ -1012,6 +987,9 @@ msgstr ""
|
|||
|
||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#: lib/mv_web/live/contribution_type_live/index.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/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Interval"
|
||||
msgstr ""
|
||||
|
|
@ -1081,9 +1059,11 @@ msgstr ""
|
|||
msgid "Members can only switch between contribution types with the same payment interval (e.g., yearly to yearly). This prevents complex period overlaps."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Monthly"
|
||||
msgstr ""
|
||||
|
|
@ -1094,6 +1074,7 @@ msgid "Monthly fee for students and trainees"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Name & Amount"
|
||||
msgstr ""
|
||||
|
|
@ -1109,6 +1090,7 @@ msgid "No fee for honorary members"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Only possible if no members are assigned to this type."
|
||||
msgstr ""
|
||||
|
|
@ -1129,9 +1111,11 @@ msgstr ""
|
|||
msgid "Preview Mockup"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Quarterly"
|
||||
msgstr ""
|
||||
|
|
@ -1169,6 +1153,7 @@ msgid "Standard membership fee for regular members"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Status"
|
||||
msgstr ""
|
||||
|
|
@ -1189,6 +1174,9 @@ msgid "Suspend"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/member_live/index/membership_fee_status.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Suspended"
|
||||
msgstr ""
|
||||
|
|
@ -1209,7 +1197,11 @@ msgstr ""
|
|||
msgid "Total Contributions"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/payment_filter_component.ex
|
||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/member_live/index/membership_fee_status.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Unpaid"
|
||||
msgstr ""
|
||||
|
|
@ -1219,9 +1211,11 @@ msgstr ""
|
|||
msgid "Why are not all contribution types shown?"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
||||
#: lib/mv_web/live/contribution_period_live/show.ex
|
||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Yearly"
|
||||
msgstr ""
|
||||
|
|
@ -1242,6 +1236,7 @@ msgid "Last name"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "None"
|
||||
msgstr ""
|
||||
|
|
@ -1302,6 +1297,7 @@ msgstr ""
|
|||
msgid "Value Type"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/translations/field_types.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Date"
|
||||
|
|
@ -1327,11 +1323,6 @@ msgstr ""
|
|||
msgid "Yes/No-Selection"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/payment_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "All payment statuses"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Copy email addresses"
|
||||
|
|
@ -1347,6 +1338,11 @@ msgstr ""
|
|||
msgid "Save Custom Field Value"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "This field is required"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Configure global settings for membership fees."
|
||||
|
|
@ -1423,6 +1419,419 @@ msgstr ""
|
|||
msgid "Yearly Interval - Joining Cycle Included"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "About Membership Fee Types"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Already paid cycles will remain with the old amount."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "An error occurred"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Are you sure you want to delete this cycle?"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Cannot delete - %{count} member(s) assigned"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Change Amount?"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Changing the amount will affect %{count} member(s)."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Confirm Change"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Current Cycle"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Current amount"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cycle"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cycle amount updated"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cycle deleted"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cycle status updated"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cycles regenerated successfully"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Delete Cycle"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Edit Cycle Amount"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Edit Membership Fee Type"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Failed to update cycle status: %{errors}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Future unpaid cycles will be regenerated with the new amount."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Generate cycles from the last existing cycle to today"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Interval cannot be changed after creation."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Invalid amount format"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Last Cycle"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Manage membership fee types for membership fees."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Mark as paid"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Mark as suspended"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Mark as unpaid"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Membership Fee"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Membership Fee Status"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Membership Fee Type"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/navbar.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Membership Fee Types"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Membership Fees"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Membership fee type deleted"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Membership fee type removed"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Membership fee type saved successfully"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Membership fee type updated. Cycles regenerated."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Membership fee types define different membership fee structures. Each type has a fixed interval (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "New Membership Fee Type"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "New amount"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No cycle"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No cycles"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No membership fee cycles found. Cycles will be generated automatically when a membership fee type is assigned."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No membership fee type assigned"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No status"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Please confirm the amount change first"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Regenerate Cycles"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Regenerating..."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Save Membership Fee Type"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Select a membership fee type for this member. Members can only switch between types with the same interval."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Select interval"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Type"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Use this form to manage membership fee types in your database."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Warning: Changing from %{old_interval} to %{new_interval} is not allowed. Please select a membership fee type with the same interval."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "A cycle for this period already exists"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "All cycles deleted"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Click to edit amount"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Create"
|
||||
msgstr "created"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Create Cycle"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Create a new cycle manually"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Cycle Period"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Cycle created successfully"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Delete All"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Delete All Cycles"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Delete all cycles"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Delete cycle"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Invalid date format"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Payment Interval"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "The cycle period will be calculated based on this date and the interval."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "This action cannot be undone."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Type '%{confirmation}' to confirm"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Warning"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You are about to delete all %{count} cycles for this member."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Current Cycle Payment Status"
|
||||
msgstr "Current Cycle Payment Status"
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Last Cycle Payment Status"
|
||||
msgstr "Last Cycle Payment Status"
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Delete membership fee type"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Edit membership fee type"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Confirmation text does not match"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "No cycles to delete"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Not set"
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/components/payment_filter_component.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "All payment statuses"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/custom_field_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Auto-generated identifier (immutable)"
|
||||
|
|
@ -1433,6 +1842,12 @@ msgstr ""
|
|||
#~ msgid "Configure global settings for membership contributions."
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/form.ex
|
||||
#~ #: lib/mv_web/live/member_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Contribution"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/components/layouts/navbar.ex
|
||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
|
|
@ -1449,28 +1864,34 @@ msgstr ""
|
|||
#~ msgid "Copy emails"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/form.ex
|
||||
#~ #: lib/mv_web/live/member_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Custom Field Values"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Default Contribution Type"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
||||
#~ #: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Edit amount"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Example: Member Contribution View"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "Failed to delete some cycles: %{errors}"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Failed to save settings. Please check the errors below."
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
||||
#~ #: lib/mv_web/live/user_live/index.html.heex
|
||||
#~ #: lib/mv_web/live/user_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Generated periods"
|
||||
#~ msgstr ""
|
||||
|
|
@ -1485,19 +1906,24 @@ msgstr ""
|
|||
#~ msgid "Include joining period"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Monthly Interval - Joining Period Included"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "New Custom field"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/user_live/show.ex
|
||||
#~ #: lib/mv_web/live/components/payment_filter_component.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Not paid"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "Not set"
|
||||
#~ msgid "Payment Cycle"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "Pending"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
||||
|
|
@ -1505,14 +1931,45 @@ msgstr ""
|
|||
#~ msgid "Quarterly Interval - Joining Period Excluded"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
||||
#~ #: lib/mv_web/live/member_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "See how the contribution periods will be displayed for an individual member. This example shows Maria Weber with multiple contribution periods."
|
||||
#~ msgid "Show Last/Current Cycle Payment Status"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
||||
#~ #: lib/mv_web/live/member_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "This contribution type is automatically assigned to all new members. Can be changed individually per member."
|
||||
#~ msgid "Show current cycle"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Show last completed cycle"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Switch to current cycle"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Switch to last completed cycle"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/form.ex
|
||||
#~ #: lib/mv_web/live/member_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "This data is for demonstration purposes only (mockup)."
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Unpaid in current cycle"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Unpaid in last cycle"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
||||
|
|
@ -1520,22 +1977,18 @@ msgstr ""
|
|||
#~ msgid "View Example Member"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "When active: Members pay from the period of their joining."
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "When inactive: Members pay from the next full period after joining."
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Yearly Interval - Joining Period Excluded"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/contribution_settings_live.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Yearly Interval - Joining Period Included"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/form.ex
|
||||
#~ #: lib/mv_web/live/member_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "monthly"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/form.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "yearly"
|
||||
#~ msgstr ""
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
defmodule Mv.Repo.Migrations.RemovePaidFromMembers do
|
||||
@moduledoc """
|
||||
Updates resources based on their most recent snapshots.
|
||||
|
||||
This file was autogenerated with `mix ash_postgres.generate_migrations`
|
||||
"""
|
||||
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
alter table(:members) do
|
||||
remove :paid
|
||||
end
|
||||
end
|
||||
|
||||
def down do
|
||||
alter table(:members) do
|
||||
add :paid, :boolean
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
alias Mv.Membership
|
||||
alias Mv.Accounts
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias Mv.MembershipFees.CycleGenerator
|
||||
|
||||
# Create example membership fee types
|
||||
for fee_type_attrs <- [
|
||||
|
|
@ -127,59 +128,153 @@ Accounts.create_user!(%{email: "admin@mv.local"}, upsert?: true, upsert_identity
|
|||
|> Ash.Changeset.for_update(:admin_set_password, %{password: "testpassword"})
|
||||
|> Ash.update!()
|
||||
|
||||
# Load all membership fee types for assignment
|
||||
# Sort by name to ensure deterministic order
|
||||
all_fee_types =
|
||||
MembershipFeeType
|
||||
|> Ash.Query.sort(name: :asc)
|
||||
|> Ash.read!()
|
||||
|> Enum.to_list()
|
||||
|
||||
# Create sample members for testing - use upsert to prevent duplicates
|
||||
for member_attrs <- [
|
||||
%{
|
||||
first_name: "Hans",
|
||||
last_name: "Müller",
|
||||
email: "hans.mueller@example.de",
|
||||
join_date: ~D[2023-01-15],
|
||||
paid: true,
|
||||
phone_number: "+49301234567",
|
||||
city: "München",
|
||||
street: "Hauptstraße",
|
||||
house_number: "42",
|
||||
postal_code: "80331"
|
||||
},
|
||||
%{
|
||||
first_name: "Greta",
|
||||
last_name: "Schmidt",
|
||||
email: "greta.schmidt@example.de",
|
||||
join_date: ~D[2023-02-01],
|
||||
paid: false,
|
||||
phone_number: "+49309876543",
|
||||
city: "Hamburg",
|
||||
street: "Lindenstraße",
|
||||
house_number: "17",
|
||||
postal_code: "20095",
|
||||
notes: "Interessiert an Fortgeschrittenen-Kursen"
|
||||
},
|
||||
%{
|
||||
first_name: "Friedrich",
|
||||
last_name: "Wagner",
|
||||
email: "friedrich.wagner@example.de",
|
||||
join_date: ~D[2022-11-10],
|
||||
paid: true,
|
||||
phone_number: "+49301122334",
|
||||
city: "Berlin",
|
||||
street: "Kastanienallee",
|
||||
house_number: "8"
|
||||
},
|
||||
%{
|
||||
first_name: "Marianne",
|
||||
last_name: "Wagner",
|
||||
email: "marianne.wagner@example.de",
|
||||
join_date: ~D[2022-11-10],
|
||||
paid: true,
|
||||
phone_number: "+49301122334",
|
||||
city: "Berlin",
|
||||
street: "Kastanienallee",
|
||||
house_number: "8"
|
||||
}
|
||||
] do
|
||||
# Member 1: Hans - All cycles paid
|
||||
# Member 2: Greta - All cycles unpaid
|
||||
# Member 3: Friedrich - Mixed cycles (paid, unpaid, suspended)
|
||||
# Member 4: Marianne - No membership fee type
|
||||
member_attrs_list = [
|
||||
%{
|
||||
first_name: "Hans",
|
||||
last_name: "Müller",
|
||||
email: "hans.mueller@example.de",
|
||||
join_date: ~D[2023-01-15],
|
||||
phone_number: "+49301234567",
|
||||
city: "München",
|
||||
street: "Hauptstraße",
|
||||
house_number: "42",
|
||||
postal_code: "80331",
|
||||
membership_fee_type_id: Enum.at(all_fee_types, 0).id,
|
||||
cycle_status: :all_paid
|
||||
},
|
||||
%{
|
||||
first_name: "Greta",
|
||||
last_name: "Schmidt",
|
||||
email: "greta.schmidt@example.de",
|
||||
join_date: ~D[2023-02-01],
|
||||
phone_number: "+49309876543",
|
||||
city: "Hamburg",
|
||||
street: "Lindenstraße",
|
||||
house_number: "17",
|
||||
postal_code: "20095",
|
||||
notes: "Interessiert an Fortgeschrittenen-Kursen",
|
||||
membership_fee_type_id: Enum.at(all_fee_types, 1).id,
|
||||
cycle_status: :all_unpaid
|
||||
},
|
||||
%{
|
||||
first_name: "Friedrich",
|
||||
last_name: "Wagner",
|
||||
email: "friedrich.wagner@example.de",
|
||||
join_date: ~D[2022-11-10],
|
||||
phone_number: "+49301122334",
|
||||
city: "Berlin",
|
||||
street: "Kastanienallee",
|
||||
house_number: "8",
|
||||
membership_fee_type_id: Enum.at(all_fee_types, 2).id,
|
||||
cycle_status: :mixed
|
||||
},
|
||||
%{
|
||||
first_name: "Marianne",
|
||||
last_name: "Wagner",
|
||||
email: "marianne.wagner@example.de",
|
||||
join_date: ~D[2022-11-10],
|
||||
phone_number: "+49301122334",
|
||||
city: "Berlin",
|
||||
street: "Kastanienallee",
|
||||
house_number: "8"
|
||||
# No membership_fee_type_id - member without fee type
|
||||
}
|
||||
]
|
||||
|
||||
# Create members and generate cycles
|
||||
Enum.each(member_attrs_list, fn member_attrs ->
|
||||
cycle_status = Map.get(member_attrs, :cycle_status)
|
||||
member_attrs_without_status = Map.delete(member_attrs, :cycle_status)
|
||||
|
||||
# Use upsert to prevent duplicates based on email
|
||||
Membership.create_member!(member_attrs, upsert?: true, upsert_identity: :unique_email)
|
||||
end
|
||||
# First create/update member without membership_fee_type_id to avoid overwriting existing assignments
|
||||
member_attrs_without_fee_type = Map.delete(member_attrs_without_status, :membership_fee_type_id)
|
||||
|
||||
member =
|
||||
Membership.create_member!(member_attrs_without_fee_type,
|
||||
upsert?: true,
|
||||
upsert_identity: :unique_email
|
||||
)
|
||||
|
||||
# Only set membership_fee_type_id if member doesn't have one yet (idempotent)
|
||||
final_member =
|
||||
if is_nil(member.membership_fee_type_id) and
|
||||
Map.has_key?(member_attrs_without_status, :membership_fee_type_id) do
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{
|
||||
membership_fee_type_id: member_attrs_without_status.membership_fee_type_id
|
||||
})
|
||||
|> Ash.update!()
|
||||
else
|
||||
member
|
||||
end
|
||||
|
||||
# Generate cycles if member has a fee type
|
||||
if final_member.membership_fee_type_id do
|
||||
# Load member with cycles to check if they already exist
|
||||
member_with_cycles =
|
||||
final_member
|
||||
|> Ash.load!(:membership_fee_cycles)
|
||||
|
||||
# Only generate if no cycles exist yet (to avoid duplicates on re-run)
|
||||
cycles =
|
||||
if Enum.empty?(member_with_cycles.membership_fee_cycles) do
|
||||
# Generate cycles
|
||||
{:ok, new_cycles, _notifications} =
|
||||
CycleGenerator.generate_cycles_for_member(final_member.id, skip_lock?: true)
|
||||
|
||||
new_cycles
|
||||
else
|
||||
# Use existing cycles
|
||||
member_with_cycles.membership_fee_cycles
|
||||
end
|
||||
|
||||
# Set cycle statuses based on member type
|
||||
if cycle_status do
|
||||
cycles
|
||||
|> Enum.sort_by(& &1.cycle_start, Date)
|
||||
|> Enum.with_index()
|
||||
|> Enum.each(fn {cycle, index} ->
|
||||
status =
|
||||
case cycle_status do
|
||||
:all_paid ->
|
||||
:paid
|
||||
|
||||
:all_unpaid ->
|
||||
:unpaid
|
||||
|
||||
:mixed ->
|
||||
# Mix: first paid, second unpaid, third suspended, then repeat
|
||||
case rem(index, 3) do
|
||||
0 -> :paid
|
||||
1 -> :unpaid
|
||||
2 -> :suspended
|
||||
end
|
||||
end
|
||||
|
||||
# Only update if status is different
|
||||
if cycle.status != status do
|
||||
cycle
|
||||
|> Ash.Changeset.for_update(:update, %{status: status})
|
||||
|> Ash.update!()
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
# Create additional users for user-member linking examples
|
||||
additional_users = [
|
||||
|
|
@ -204,7 +299,6 @@ linked_members = [
|
|||
last_name: "Weber",
|
||||
email: "maria.weber@example.de",
|
||||
join_date: ~D[2023-03-15],
|
||||
paid: true,
|
||||
phone_number: "+49301357924",
|
||||
city: "Frankfurt",
|
||||
street: "Goetheplatz",
|
||||
|
|
@ -219,7 +313,6 @@ linked_members = [
|
|||
last_name: "Klein",
|
||||
email: "thomas.klein@example.de",
|
||||
join_date: ~D[2023-04-01],
|
||||
paid: false,
|
||||
phone_number: "+49302468135",
|
||||
city: "Köln",
|
||||
street: "Rheinstraße",
|
||||
|
|
@ -232,24 +325,84 @@ linked_members = [
|
|||
]
|
||||
|
||||
# Create the linked members - use upsert to prevent duplicates
|
||||
Enum.each(linked_members, fn member_attrs ->
|
||||
# Assign fee types to linked members using round-robin
|
||||
# Continue from where we left off with the previous members
|
||||
Enum.with_index(linked_members)
|
||||
|> Enum.each(fn {member_attrs, index} ->
|
||||
user = member_attrs.user
|
||||
member_attrs_without_user = Map.delete(member_attrs, :user)
|
||||
|
||||
# Use upsert to prevent duplicates based on email
|
||||
# First create/update member without membership_fee_type_id to avoid overwriting existing assignments
|
||||
member_attrs_without_fee_type = Map.delete(member_attrs_without_user, :membership_fee_type_id)
|
||||
|
||||
# Check if user already has a member
|
||||
if user.member_id == nil do
|
||||
# User is free, create member and link - use upsert to prevent duplicates
|
||||
Membership.create_member!(
|
||||
Map.put(member_attrs_without_user, :user, %{id: user.id}),
|
||||
upsert?: true,
|
||||
upsert_identity: :unique_email
|
||||
)
|
||||
else
|
||||
# User already has a member, just create the member without linking - use upsert to prevent duplicates
|
||||
Membership.create_member!(member_attrs_without_user,
|
||||
upsert?: true,
|
||||
upsert_identity: :unique_email
|
||||
)
|
||||
member =
|
||||
if user.member_id == nil do
|
||||
# User is free, create member and link - use upsert to prevent duplicates
|
||||
Membership.create_member!(
|
||||
Map.put(member_attrs_without_fee_type, :user, %{id: user.id}),
|
||||
upsert?: true,
|
||||
upsert_identity: :unique_email
|
||||
)
|
||||
else
|
||||
# User already has a member, just create the member without linking - use upsert to prevent duplicates
|
||||
Membership.create_member!(member_attrs_without_fee_type,
|
||||
upsert?: true,
|
||||
upsert_identity: :unique_email
|
||||
)
|
||||
end
|
||||
|
||||
# Only set membership_fee_type_id if member doesn't have one yet (idempotent)
|
||||
final_member =
|
||||
if is_nil(member.membership_fee_type_id) do
|
||||
# Assign deterministically using round-robin
|
||||
# Start from where previous members ended (3 members before this)
|
||||
fee_type_index = rem(3 + index, length(all_fee_types))
|
||||
fee_type = Enum.at(all_fee_types, fee_type_index)
|
||||
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
||||
|> Ash.update!()
|
||||
else
|
||||
member
|
||||
end
|
||||
|
||||
# Generate cycles for linked members
|
||||
if final_member.membership_fee_type_id do
|
||||
# Load member with cycles to check if they already exist
|
||||
member_with_cycles =
|
||||
final_member
|
||||
|> Ash.load!(:membership_fee_cycles)
|
||||
|
||||
# Only generate if no cycles exist yet (to avoid duplicates on re-run)
|
||||
cycles =
|
||||
if Enum.empty?(member_with_cycles.membership_fee_cycles) do
|
||||
# Generate cycles
|
||||
{:ok, new_cycles, _notifications} =
|
||||
CycleGenerator.generate_cycles_for_member(final_member.id, skip_lock?: true)
|
||||
|
||||
new_cycles
|
||||
else
|
||||
# Use existing cycles
|
||||
member_with_cycles.membership_fee_cycles
|
||||
end
|
||||
|
||||
# Set some cycles to paid for linked members (mixed status)
|
||||
cycles
|
||||
|> Enum.sort_by(& &1.cycle_start, Date)
|
||||
|> Enum.with_index()
|
||||
|> Enum.each(fn {cycle, index} ->
|
||||
# Every other cycle is paid, rest unpaid
|
||||
status = if rem(index, 2) == 0, do: :paid, else: :unpaid
|
||||
|
||||
# Only update if status is different
|
||||
if cycle.status != status do
|
||||
cycle
|
||||
|> Ash.Changeset.for_update(:update, %{status: status})
|
||||
|> Ash.update!()
|
||||
end
|
||||
end)
|
||||
end
|
||||
end)
|
||||
|
||||
|
|
|
|||
132
priv/resource_snapshots/repo/custom_fields/20251218113900.json
Normal file
132
priv/resource_snapshots/repo/custom_fields/20251218113900.json
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
{
|
||||
"attributes": [
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"gen_random_uuid()\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": true,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "name",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "slug",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "value_type",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "description",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "false",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "required",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "true",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "show_in_overview",
|
||||
"type": "boolean"
|
||||
}
|
||||
],
|
||||
"base_filter": null,
|
||||
"check_constraints": [],
|
||||
"custom_indexes": [],
|
||||
"custom_statements": [],
|
||||
"has_create_action": true,
|
||||
"hash": "6FEA699A67D34CFBA261DA8316AB711F6853C4F953D42C5D7940B22D17699B2E",
|
||||
"identities": [
|
||||
{
|
||||
"all_tenants?": false,
|
||||
"base_filter": null,
|
||||
"index_name": "custom_fields_unique_name_index",
|
||||
"keys": [
|
||||
{
|
||||
"type": "atom",
|
||||
"value": "name"
|
||||
}
|
||||
],
|
||||
"name": "unique_name",
|
||||
"nils_distinct?": true,
|
||||
"where": null
|
||||
},
|
||||
{
|
||||
"all_tenants?": false,
|
||||
"base_filter": null,
|
||||
"index_name": "custom_fields_unique_slug_index",
|
||||
"keys": [
|
||||
{
|
||||
"type": "atom",
|
||||
"value": "slug"
|
||||
}
|
||||
],
|
||||
"name": "unique_slug",
|
||||
"nils_distinct?": true,
|
||||
"where": null
|
||||
}
|
||||
],
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"repo": "Elixir.Mv.Repo",
|
||||
"schema": null,
|
||||
"table": "custom_fields"
|
||||
}
|
||||
233
priv/resource_snapshots/repo/members/20251218113900.json
Normal file
233
priv/resource_snapshots/repo/members/20251218113900.json
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
{
|
||||
"attributes": [
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"uuid_generate_v7()\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": true,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "first_name",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "last_name",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "email",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "phone_number",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "join_date",
|
||||
"type": "date"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "exit_date",
|
||||
"type": "date"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "notes",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "city",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "street",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "house_number",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "postal_code",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "search_vector",
|
||||
"type": "tsvector"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "membership_fee_start_date",
|
||||
"type": "date"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": {
|
||||
"deferrable": false,
|
||||
"destination_attribute": "id",
|
||||
"destination_attribute_default": null,
|
||||
"destination_attribute_generated": null,
|
||||
"index?": false,
|
||||
"match_type": null,
|
||||
"match_with": null,
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"name": "members_membership_fee_type_id_fkey",
|
||||
"on_delete": null,
|
||||
"on_update": null,
|
||||
"primary_key?": true,
|
||||
"schema": "public",
|
||||
"table": "membership_fee_types"
|
||||
},
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "membership_fee_type_id",
|
||||
"type": "uuid"
|
||||
}
|
||||
],
|
||||
"base_filter": null,
|
||||
"check_constraints": [],
|
||||
"custom_indexes": [],
|
||||
"custom_statements": [],
|
||||
"has_create_action": true,
|
||||
"hash": "E18E4B404581EFF050F85E895FAE986B79DB62C9E1611164C92B46B954C371C1",
|
||||
"identities": [
|
||||
{
|
||||
"all_tenants?": false,
|
||||
"base_filter": null,
|
||||
"index_name": "members_unique_email_index",
|
||||
"keys": [
|
||||
{
|
||||
"type": "atom",
|
||||
"value": "email"
|
||||
}
|
||||
],
|
||||
"name": "unique_email",
|
||||
"nils_distinct?": true,
|
||||
"where": null
|
||||
}
|
||||
],
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"repo": "Elixir.Mv.Repo",
|
||||
"schema": null,
|
||||
"table": "members"
|
||||
}
|
||||
635
test/membership/member_required_custom_fields_test.exs
Normal file
635
test/membership/member_required_custom_fields_test.exs
Normal file
|
|
@ -0,0 +1,635 @@
|
|||
defmodule Mv.Membership.MemberRequiredCustomFieldsTest do
|
||||
@moduledoc """
|
||||
Tests for required custom fields validation.
|
||||
|
||||
Tests cover:
|
||||
- Member creation without required custom field → error
|
||||
- Member creation with empty required custom field (nil/empty string) → error
|
||||
- Member creation with valid required custom field → success
|
||||
- Member update: removing a required custom field value → error
|
||||
- Boolean required custom field: false is valid, nil is invalid
|
||||
"""
|
||||
use Mv.DataCase, async: true
|
||||
|
||||
alias Mv.Membership
|
||||
|
||||
setup do
|
||||
# Create required custom fields for different types
|
||||
{:ok, required_string_field} =
|
||||
Membership.CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "required_string",
|
||||
value_type: :string,
|
||||
required: true
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
{:ok, required_integer_field} =
|
||||
Membership.CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "required_integer",
|
||||
value_type: :integer,
|
||||
required: true
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
{:ok, required_boolean_field} =
|
||||
Membership.CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "required_boolean",
|
||||
value_type: :boolean,
|
||||
required: true
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
{:ok, required_date_field} =
|
||||
Membership.CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "required_date",
|
||||
value_type: :date,
|
||||
required: true
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
{:ok, required_email_field} =
|
||||
Membership.CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "required_email",
|
||||
value_type: :email,
|
||||
required: true
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
{:ok, optional_field} =
|
||||
Membership.CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "optional_string",
|
||||
value_type: :string,
|
||||
required: false
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
%{
|
||||
required_string_field: required_string_field,
|
||||
required_integer_field: required_integer_field,
|
||||
required_boolean_field: required_boolean_field,
|
||||
required_date_field: required_date_field,
|
||||
required_email_field: required_email_field,
|
||||
optional_field: optional_field
|
||||
}
|
||||
end
|
||||
|
||||
# Helper function to create all required custom fields with valid default values
|
||||
defp all_required_custom_fields_with_defaults(%{
|
||||
required_string_field: string_field,
|
||||
required_integer_field: integer_field,
|
||||
required_boolean_field: boolean_field,
|
||||
required_date_field: date_field,
|
||||
required_email_field: email_field
|
||||
}) do
|
||||
[
|
||||
%{
|
||||
"custom_field_id" => string_field.id,
|
||||
"value" => %{"_union_type" => "string", "_union_value" => "default"}
|
||||
},
|
||||
%{
|
||||
"custom_field_id" => integer_field.id,
|
||||
"value" => %{"_union_type" => "integer", "_union_value" => 0}
|
||||
},
|
||||
%{
|
||||
"custom_field_id" => boolean_field.id,
|
||||
"value" => %{"_union_type" => "boolean", "_union_value" => false}
|
||||
},
|
||||
%{
|
||||
"custom_field_id" => date_field.id,
|
||||
"value" => %{"_union_type" => "date", "_union_value" => ~D[2020-01-01]}
|
||||
},
|
||||
%{
|
||||
"custom_field_id" => email_field.id,
|
||||
"value" => %{"_union_type" => "email", "_union_value" => "test@example.com"}
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
describe "create_member with required custom fields" do
|
||||
@valid_attrs %{
|
||||
first_name: "John",
|
||||
last_name: "Doe",
|
||||
email: "john@example.com"
|
||||
}
|
||||
|
||||
test "fails when required custom field is missing", %{required_string_field: field} do
|
||||
attrs = Map.put(@valid_attrs, :custom_field_values, [])
|
||||
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
|
||||
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
|
||||
assert error_message(errors, :custom_field_values) =~ field.name
|
||||
end
|
||||
|
||||
test "fails when required string custom field has nil value",
|
||||
%{
|
||||
required_string_field: field
|
||||
} = context do
|
||||
# Start with all required fields having valid values
|
||||
custom_field_values =
|
||||
all_required_custom_fields_with_defaults(context)
|
||||
|> Enum.map(fn cfv ->
|
||||
if cfv["custom_field_id"] == field.id do
|
||||
%{cfv | "value" => %{"_union_type" => "string", "_union_value" => nil}}
|
||||
else
|
||||
cfv
|
||||
end
|
||||
end)
|
||||
|
||||
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
|
||||
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
|
||||
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
|
||||
assert error_message(errors, :custom_field_values) =~ field.name
|
||||
end
|
||||
|
||||
test "fails when required string custom field has empty string value",
|
||||
%{
|
||||
required_string_field: field
|
||||
} = context do
|
||||
# Start with all required fields having valid values
|
||||
custom_field_values =
|
||||
all_required_custom_fields_with_defaults(context)
|
||||
|> Enum.map(fn cfv ->
|
||||
if cfv["custom_field_id"] == field.id do
|
||||
%{cfv | "value" => %{"_union_type" => "string", "_union_value" => ""}}
|
||||
else
|
||||
cfv
|
||||
end
|
||||
end)
|
||||
|
||||
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
|
||||
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
|
||||
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
|
||||
assert error_message(errors, :custom_field_values) =~ field.name
|
||||
end
|
||||
|
||||
test "fails when required string custom field has whitespace-only value",
|
||||
%{
|
||||
required_string_field: field
|
||||
} = context do
|
||||
# Start with all required fields having valid values
|
||||
custom_field_values =
|
||||
all_required_custom_fields_with_defaults(context)
|
||||
|> Enum.map(fn cfv ->
|
||||
if cfv["custom_field_id"] == field.id do
|
||||
%{cfv | "value" => %{"_union_type" => "string", "_union_value" => " "}}
|
||||
else
|
||||
cfv
|
||||
end
|
||||
end)
|
||||
|
||||
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
|
||||
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
|
||||
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
|
||||
assert error_message(errors, :custom_field_values) =~ field.name
|
||||
end
|
||||
|
||||
test "succeeds when required string custom field has valid value",
|
||||
%{
|
||||
required_string_field: field
|
||||
} = context do
|
||||
# Start with all required fields having valid values, then update the string field
|
||||
custom_field_values =
|
||||
all_required_custom_fields_with_defaults(context)
|
||||
|> Enum.map(fn cfv ->
|
||||
if cfv["custom_field_id"] == field.id do
|
||||
%{cfv | "value" => %{"_union_type" => "string", "_union_value" => "test value"}}
|
||||
else
|
||||
cfv
|
||||
end
|
||||
end)
|
||||
|
||||
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
|
||||
|
||||
assert {:ok, _member} = Membership.create_member(attrs)
|
||||
end
|
||||
|
||||
test "fails when required integer custom field has nil value",
|
||||
%{
|
||||
required_integer_field: field
|
||||
} = context do
|
||||
custom_field_values =
|
||||
all_required_custom_fields_with_defaults(context)
|
||||
|> Enum.map(fn cfv ->
|
||||
if cfv["custom_field_id"] == field.id do
|
||||
%{cfv | "value" => %{"_union_type" => "integer", "_union_value" => nil}}
|
||||
else
|
||||
cfv
|
||||
end
|
||||
end)
|
||||
|
||||
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
|
||||
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
|
||||
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
|
||||
assert error_message(errors, :custom_field_values) =~ field.name
|
||||
end
|
||||
|
||||
test "fails when required integer custom field has empty string value",
|
||||
%{
|
||||
required_integer_field: field
|
||||
} = context do
|
||||
custom_field_values =
|
||||
all_required_custom_fields_with_defaults(context)
|
||||
|> Enum.map(fn cfv ->
|
||||
if cfv["custom_field_id"] == field.id do
|
||||
%{cfv | "value" => %{"_union_type" => "integer", "_union_value" => ""}}
|
||||
else
|
||||
cfv
|
||||
end
|
||||
end)
|
||||
|
||||
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
|
||||
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
|
||||
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
|
||||
assert error_message(errors, :custom_field_values) =~ field.name
|
||||
end
|
||||
|
||||
test "succeeds when required integer custom field has zero value",
|
||||
%{
|
||||
required_integer_field: _field
|
||||
} = context do
|
||||
custom_field_values = all_required_custom_fields_with_defaults(context)
|
||||
|
||||
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
|
||||
|
||||
assert {:ok, _member} = Membership.create_member(attrs)
|
||||
end
|
||||
|
||||
test "succeeds when required integer custom field has positive value",
|
||||
%{
|
||||
required_integer_field: field
|
||||
} = context do
|
||||
custom_field_values =
|
||||
all_required_custom_fields_with_defaults(context)
|
||||
|> Enum.map(fn cfv ->
|
||||
if cfv["custom_field_id"] == field.id do
|
||||
%{cfv | "value" => %{"_union_type" => "integer", "_union_value" => 42}}
|
||||
else
|
||||
cfv
|
||||
end
|
||||
end)
|
||||
|
||||
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
|
||||
|
||||
assert {:ok, _member} = Membership.create_member(attrs)
|
||||
end
|
||||
|
||||
test "fails when required boolean custom field has nil value",
|
||||
%{
|
||||
required_boolean_field: field
|
||||
} = context do
|
||||
custom_field_values =
|
||||
all_required_custom_fields_with_defaults(context)
|
||||
|> Enum.map(fn cfv ->
|
||||
if cfv["custom_field_id"] == field.id do
|
||||
%{cfv | "value" => %{"_union_type" => "boolean", "_union_value" => nil}}
|
||||
else
|
||||
cfv
|
||||
end
|
||||
end)
|
||||
|
||||
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
|
||||
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
|
||||
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
|
||||
assert error_message(errors, :custom_field_values) =~ field.name
|
||||
end
|
||||
|
||||
test "succeeds when required boolean custom field has false value",
|
||||
%{
|
||||
required_boolean_field: _field
|
||||
} = context do
|
||||
custom_field_values = all_required_custom_fields_with_defaults(context)
|
||||
|
||||
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
|
||||
|
||||
assert {:ok, _member} = Membership.create_member(attrs)
|
||||
end
|
||||
|
||||
test "succeeds when required boolean custom field has true value",
|
||||
%{
|
||||
required_boolean_field: field
|
||||
} = context do
|
||||
custom_field_values =
|
||||
all_required_custom_fields_with_defaults(context)
|
||||
|> Enum.map(fn cfv ->
|
||||
if cfv["custom_field_id"] == field.id do
|
||||
%{cfv | "value" => %{"_union_type" => "boolean", "_union_value" => true}}
|
||||
else
|
||||
cfv
|
||||
end
|
||||
end)
|
||||
|
||||
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
|
||||
|
||||
assert {:ok, _member} = Membership.create_member(attrs)
|
||||
end
|
||||
|
||||
test "fails when required date custom field has nil value",
|
||||
%{
|
||||
required_date_field: field
|
||||
} = context do
|
||||
custom_field_values =
|
||||
all_required_custom_fields_with_defaults(context)
|
||||
|> Enum.map(fn cfv ->
|
||||
if cfv["custom_field_id"] == field.id do
|
||||
%{cfv | "value" => %{"_union_type" => "date", "_union_value" => nil}}
|
||||
else
|
||||
cfv
|
||||
end
|
||||
end)
|
||||
|
||||
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
|
||||
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
|
||||
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
|
||||
assert error_message(errors, :custom_field_values) =~ field.name
|
||||
end
|
||||
|
||||
test "fails when required date custom field has empty string value",
|
||||
%{
|
||||
required_date_field: field
|
||||
} = context do
|
||||
custom_field_values =
|
||||
all_required_custom_fields_with_defaults(context)
|
||||
|> Enum.map(fn cfv ->
|
||||
if cfv["custom_field_id"] == field.id do
|
||||
%{cfv | "value" => %{"_union_type" => "date", "_union_value" => ""}}
|
||||
else
|
||||
cfv
|
||||
end
|
||||
end)
|
||||
|
||||
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
|
||||
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
|
||||
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
|
||||
assert error_message(errors, :custom_field_values) =~ field.name
|
||||
end
|
||||
|
||||
test "succeeds when required date custom field has valid date value",
|
||||
%{
|
||||
required_date_field: _field
|
||||
} = context do
|
||||
custom_field_values = all_required_custom_fields_with_defaults(context)
|
||||
|
||||
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
|
||||
|
||||
assert {:ok, _member} = Membership.create_member(attrs)
|
||||
end
|
||||
|
||||
test "fails when required email custom field has nil value",
|
||||
%{
|
||||
required_email_field: field
|
||||
} = context do
|
||||
custom_field_values =
|
||||
all_required_custom_fields_with_defaults(context)
|
||||
|> Enum.map(fn cfv ->
|
||||
if cfv["custom_field_id"] == field.id do
|
||||
%{cfv | "value" => %{"_union_type" => "email", "_union_value" => nil}}
|
||||
else
|
||||
cfv
|
||||
end
|
||||
end)
|
||||
|
||||
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
|
||||
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
|
||||
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
|
||||
assert error_message(errors, :custom_field_values) =~ field.name
|
||||
end
|
||||
|
||||
test "fails when required email custom field has empty string value",
|
||||
%{
|
||||
required_email_field: field
|
||||
} = context do
|
||||
custom_field_values =
|
||||
all_required_custom_fields_with_defaults(context)
|
||||
|> Enum.map(fn cfv ->
|
||||
if cfv["custom_field_id"] == field.id do
|
||||
%{cfv | "value" => %{"_union_type" => "email", "_union_value" => ""}}
|
||||
else
|
||||
cfv
|
||||
end
|
||||
end)
|
||||
|
||||
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
|
||||
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
|
||||
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
|
||||
assert error_message(errors, :custom_field_values) =~ field.name
|
||||
end
|
||||
|
||||
test "succeeds when required email custom field has valid email value",
|
||||
%{
|
||||
required_email_field: _field
|
||||
} = context do
|
||||
custom_field_values = all_required_custom_fields_with_defaults(context)
|
||||
|
||||
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
|
||||
|
||||
assert {:ok, _member} = Membership.create_member(attrs)
|
||||
end
|
||||
|
||||
test "succeeds when multiple required custom fields are provided",
|
||||
%{
|
||||
required_string_field: string_field,
|
||||
required_integer_field: integer_field,
|
||||
required_boolean_field: boolean_field
|
||||
} = context do
|
||||
custom_field_values =
|
||||
all_required_custom_fields_with_defaults(context)
|
||||
|> Enum.map(fn cfv ->
|
||||
cond do
|
||||
cfv["custom_field_id"] == string_field.id ->
|
||||
%{cfv | "value" => %{"_union_type" => "string", "_union_value" => "test"}}
|
||||
|
||||
cfv["custom_field_id"] == integer_field.id ->
|
||||
%{cfv | "value" => %{"_union_type" => "integer", "_union_value" => 42}}
|
||||
|
||||
cfv["custom_field_id"] == boolean_field.id ->
|
||||
%{cfv | "value" => %{"_union_type" => "boolean", "_union_value" => true}}
|
||||
|
||||
true ->
|
||||
cfv
|
||||
end
|
||||
end)
|
||||
|
||||
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
|
||||
|
||||
assert {:ok, _member} = Membership.create_member(attrs)
|
||||
end
|
||||
|
||||
test "fails when one of multiple required custom fields is missing",
|
||||
%{
|
||||
required_string_field: string_field,
|
||||
required_integer_field: integer_field
|
||||
} = context do
|
||||
# Provide only string field, missing integer, boolean, and date
|
||||
custom_field_values =
|
||||
all_required_custom_fields_with_defaults(context)
|
||||
|> Enum.filter(fn cfv ->
|
||||
cfv["custom_field_id"] == string_field.id
|
||||
end)
|
||||
|> Enum.map(fn cfv ->
|
||||
%{cfv | "value" => %{"_union_type" => "string", "_union_value" => "test"}}
|
||||
end)
|
||||
|
||||
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
|
||||
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
|
||||
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
|
||||
assert error_message(errors, :custom_field_values) =~ integer_field.name
|
||||
end
|
||||
|
||||
test "succeeds when optional custom field is missing", %{} = context do
|
||||
# Provide all required fields, but no optional field
|
||||
custom_field_values = all_required_custom_fields_with_defaults(context)
|
||||
|
||||
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
|
||||
|
||||
assert {:ok, _member} = Membership.create_member(attrs)
|
||||
end
|
||||
|
||||
test "succeeds when optional custom field has nil value",
|
||||
%{optional_field: field} = context do
|
||||
# Provide all required fields plus optional field with nil
|
||||
custom_field_values =
|
||||
all_required_custom_fields_with_defaults(context) ++
|
||||
[
|
||||
%{
|
||||
"custom_field_id" => field.id,
|
||||
"value" => %{"_union_type" => "string", "_union_value" => nil}
|
||||
}
|
||||
]
|
||||
|
||||
attrs = Map.put(@valid_attrs, :custom_field_values, custom_field_values)
|
||||
|
||||
assert {:ok, _member} = Membership.create_member(attrs)
|
||||
end
|
||||
end
|
||||
|
||||
describe "update_member with required custom fields" do
|
||||
test "fails when removing a required custom field value",
|
||||
%{
|
||||
required_string_field: field
|
||||
} = context do
|
||||
# Create member with all required custom fields
|
||||
custom_field_values = all_required_custom_fields_with_defaults(context)
|
||||
|
||||
{:ok, member} =
|
||||
Membership.create_member(%{
|
||||
first_name: "John",
|
||||
last_name: "Doe",
|
||||
email: "john@example.com",
|
||||
custom_field_values: custom_field_values
|
||||
})
|
||||
|
||||
# Try to update without the required custom field
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} =
|
||||
Membership.update_member(member, %{custom_field_values: []})
|
||||
|
||||
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
|
||||
assert error_message(errors, :custom_field_values) =~ field.name
|
||||
end
|
||||
|
||||
test "fails when setting required custom field value to empty",
|
||||
%{
|
||||
required_string_field: field
|
||||
} = context do
|
||||
# Create member with all required custom fields
|
||||
custom_field_values = all_required_custom_fields_with_defaults(context)
|
||||
|
||||
{:ok, member} =
|
||||
Membership.create_member(%{
|
||||
first_name: "John",
|
||||
last_name: "Doe",
|
||||
email: "john@example.com",
|
||||
custom_field_values: custom_field_values
|
||||
})
|
||||
|
||||
# Try to update with empty value for the string field
|
||||
updated_custom_field_values =
|
||||
all_required_custom_fields_with_defaults(context)
|
||||
|> Enum.map(fn cfv ->
|
||||
if cfv["custom_field_id"] == field.id do
|
||||
%{cfv | "value" => %{"_union_type" => "string", "_union_value" => ""}}
|
||||
else
|
||||
cfv
|
||||
end
|
||||
end)
|
||||
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} =
|
||||
Membership.update_member(member, %{
|
||||
custom_field_values: updated_custom_field_values
|
||||
})
|
||||
|
||||
assert error_message(errors, :custom_field_values) =~ "Required custom fields missing"
|
||||
assert error_message(errors, :custom_field_values) =~ field.name
|
||||
end
|
||||
|
||||
test "succeeds when updating required custom field to valid value",
|
||||
%{
|
||||
required_string_field: field
|
||||
} = context do
|
||||
# Create member with all required custom fields
|
||||
custom_field_values = all_required_custom_fields_with_defaults(context)
|
||||
|
||||
{:ok, member} =
|
||||
Membership.create_member(%{
|
||||
first_name: "John",
|
||||
last_name: "Doe",
|
||||
email: "john@example.com",
|
||||
custom_field_values: custom_field_values
|
||||
})
|
||||
|
||||
# Load existing custom field values to get their IDs
|
||||
{:ok, member_with_cfvs} = Ash.load(member, :custom_field_values)
|
||||
|
||||
# Update with new valid value for the string field, using existing IDs
|
||||
updated_custom_field_values =
|
||||
member_with_cfvs.custom_field_values
|
||||
|> Enum.map(fn cfv ->
|
||||
if cfv.custom_field_id == field.id do
|
||||
%{
|
||||
"id" => cfv.id,
|
||||
"custom_field_id" => cfv.custom_field_id,
|
||||
"value" => %{"_union_type" => "string", "_union_value" => "new value"}
|
||||
}
|
||||
else
|
||||
# Keep other fields as they are
|
||||
value_type = Atom.to_string(cfv.value.type)
|
||||
actual_value = cfv.value.value
|
||||
|
||||
%{
|
||||
"id" => cfv.id,
|
||||
"custom_field_id" => cfv.custom_field_id,
|
||||
"value" => %{"_union_type" => value_type, "_union_value" => actual_value}
|
||||
}
|
||||
end
|
||||
end)
|
||||
|
||||
assert {:ok, _updated_member} =
|
||||
Membership.update_member(member, %{
|
||||
custom_field_values: updated_custom_field_values
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
# Helper function for error evaluation
|
||||
defp error_message(errors, field) do
|
||||
errors
|
||||
|> Enum.filter(fn err -> Map.get(err, :field) == field end)
|
||||
|> Enum.map_join(" ", &Map.get(&1, :message, ""))
|
||||
end
|
||||
end
|
||||
|
|
@ -6,7 +6,6 @@ defmodule Mv.Membership.MemberTest do
|
|||
@valid_attrs %{
|
||||
first_name: "John",
|
||||
last_name: "Doe",
|
||||
paid: true,
|
||||
email: "john@example.com",
|
||||
phone_number: "+49123456789",
|
||||
join_date: ~D[2020-01-01],
|
||||
|
|
@ -42,14 +41,6 @@ defmodule Mv.Membership.MemberTest do
|
|||
assert error_message(errors, :email) =~ "is not a valid email"
|
||||
end
|
||||
|
||||
test "Paid is optional but must be boolean if specified" do
|
||||
attrs = Map.put(@valid_attrs, :paid, nil)
|
||||
attrs2 = Map.put(@valid_attrs, :paid, "yes")
|
||||
assert {:ok, _member} = Membership.create_member(Map.delete(attrs, :paid))
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs2)
|
||||
assert error_message(errors, :paid) =~ "is invalid"
|
||||
end
|
||||
|
||||
test "Phone number is optional but must have a valid format if specified" do
|
||||
attrs = Map.put(@valid_attrs, :phone_number, "abc")
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
|
||||
|
|
@ -58,12 +49,12 @@ defmodule Mv.Membership.MemberTest do
|
|||
assert {:ok, _member} = Membership.create_member(attrs2)
|
||||
end
|
||||
|
||||
test "Join date is optional but must not be in the future" do
|
||||
test "Join date cannot be in the future" do
|
||||
attrs = Map.put(@valid_attrs, :join_date, Date.utc_today() |> Date.add(1))
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
|
||||
assert error_message(errors, :join_date) =~ "cannot be in the future"
|
||||
attrs2 = Map.delete(@valid_attrs, :join_date)
|
||||
assert {:ok, _member} = Membership.create_member(attrs2)
|
||||
|
||||
assert {:error,
|
||||
%Ash.Error.Invalid{errors: [%Ash.Error.Changes.InvalidAttribute{field: :join_date}]}} =
|
||||
Membership.create_member(attrs)
|
||||
end
|
||||
|
||||
test "Exit date is optional but must not be before join date if both are specified" do
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ defmodule MvWeb.Components.PaymentFilterComponentTest do
|
|||
Unit tests for the PaymentFilterComponent.
|
||||
|
||||
Tests cover:
|
||||
- Rendering in all 3 filter states (nil, :paid, :not_paid)
|
||||
- Rendering in all 3 filter states (nil, :paid, :unpaid)
|
||||
- Event emission when selecting options
|
||||
- ARIA attributes for accessibility
|
||||
- Dropdown open/close behavior
|
||||
|
|
@ -25,15 +25,15 @@ defmodule MvWeb.Components.PaymentFilterComponentTest do
|
|||
|
||||
test "renders with paid filter active", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members?paid_filter=paid")
|
||||
{:ok, view, _html} = live(conn, "/members?cycle_status_filter=paid")
|
||||
|
||||
# Should show badge when filter is active
|
||||
assert has_element?(view, "#payment-filter .badge")
|
||||
end
|
||||
|
||||
test "renders with not_paid filter active", %{conn: conn} do
|
||||
test "renders with unpaid filter active", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members?paid_filter=not_paid")
|
||||
{:ok, view, _html} = live(conn, "/members?cycle_status_filter=unpaid")
|
||||
|
||||
# Should show badge when filter is active
|
||||
assert has_element?(view, "#payment-filter .badge")
|
||||
|
|
@ -82,7 +82,7 @@ defmodule MvWeb.Components.PaymentFilterComponentTest do
|
|||
describe "filter selection" do
|
||||
test "selecting 'All' clears the filter and updates URL", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members?paid_filter=paid")
|
||||
{:ok, view, _html} = live(conn, "/members?cycle_status_filter=paid")
|
||||
|
||||
# Open dropdown
|
||||
view
|
||||
|
|
@ -94,7 +94,7 @@ defmodule MvWeb.Components.PaymentFilterComponentTest do
|
|||
|> element("#payment-filter button[phx-value-filter='']")
|
||||
|> render_click()
|
||||
|
||||
# URL should not contain paid_filter param - wait for patch
|
||||
# URL should not contain cycle_status_filter param - wait for patch
|
||||
assert_patch(view)
|
||||
end
|
||||
|
||||
|
|
@ -112,12 +112,12 @@ defmodule MvWeb.Components.PaymentFilterComponentTest do
|
|||
|> element("#payment-filter button[phx-value-filter='paid']")
|
||||
|> render_click()
|
||||
|
||||
# Wait for patch and check URL contains paid_filter=paid
|
||||
# Wait for patch and check URL contains cycle_status_filter=paid
|
||||
path = assert_patch(view)
|
||||
assert path =~ "paid_filter=paid"
|
||||
assert path =~ "cycle_status_filter=paid"
|
||||
end
|
||||
|
||||
test "selecting 'Not paid' sets the filter and updates URL", %{conn: conn} do
|
||||
test "selecting 'Unpaid' sets the filter and updates URL", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
|
|
@ -126,14 +126,14 @@ defmodule MvWeb.Components.PaymentFilterComponentTest do
|
|||
|> element("#payment-filter button[aria-haspopup='true']")
|
||||
|> render_click()
|
||||
|
||||
# Select "Not paid" option
|
||||
# Select "Unpaid" option
|
||||
view
|
||||
|> element("#payment-filter button[phx-value-filter='not_paid']")
|
||||
|> element("#payment-filter button[phx-value-filter='unpaid']")
|
||||
|> render_click()
|
||||
|
||||
# Wait for patch and check URL contains paid_filter=not_paid
|
||||
# Wait for patch and check URL contains cycle_status_filter=unpaid
|
||||
path = assert_patch(view)
|
||||
assert path =~ "paid_filter=not_paid"
|
||||
assert path =~ "cycle_status_filter=unpaid"
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -166,7 +166,7 @@ defmodule MvWeb.Components.PaymentFilterComponentTest do
|
|||
|
||||
test "has aria-checked on selected option", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members?paid_filter=paid")
|
||||
{:ok, view, _html} = live(conn, "/members?cycle_status_filter=paid")
|
||||
|
||||
# Open dropdown
|
||||
view
|
||||
|
|
|
|||
260
test/mv_web/helpers/membership_fee_helpers_test.exs
Normal file
260
test/mv_web/helpers/membership_fee_helpers_test.exs
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
|
||||
@moduledoc """
|
||||
Tests for MembershipFeeHelpers module.
|
||||
"""
|
||||
use Mv.DataCase, async: true
|
||||
|
||||
require Ash.Query
|
||||
|
||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||
alias Mv.MembershipFees.CalendarCycles
|
||||
|
||||
describe "format_currency/1" do
|
||||
test "formats decimal amount correctly" do
|
||||
assert MembershipFeeHelpers.format_currency(Decimal.new("60.00")) == "60,00 €"
|
||||
assert MembershipFeeHelpers.format_currency(Decimal.new("5.5")) == "5,50 €"
|
||||
assert MembershipFeeHelpers.format_currency(Decimal.new("100")) == "100,00 €"
|
||||
assert MembershipFeeHelpers.format_currency(Decimal.new("0.99")) == "0,99 €"
|
||||
end
|
||||
end
|
||||
|
||||
describe "format_interval/1" do
|
||||
test "formats all interval types correctly" do
|
||||
assert MembershipFeeHelpers.format_interval(:monthly) == "Monthly"
|
||||
assert MembershipFeeHelpers.format_interval(:quarterly) == "Quarterly"
|
||||
assert MembershipFeeHelpers.format_interval(:half_yearly) == "Half-yearly"
|
||||
assert MembershipFeeHelpers.format_interval(:yearly) == "Yearly"
|
||||
end
|
||||
end
|
||||
|
||||
describe "format_cycle_range/2" do
|
||||
test "formats yearly cycle range correctly" do
|
||||
cycle_start = ~D[2024-01-01]
|
||||
interval = :yearly
|
||||
_cycle_end = CalendarCycles.calculate_cycle_end(cycle_start, interval)
|
||||
|
||||
result = MembershipFeeHelpers.format_cycle_range(cycle_start, interval)
|
||||
assert result =~ "2024"
|
||||
assert result =~ "01.01"
|
||||
assert result =~ "31.12"
|
||||
end
|
||||
|
||||
test "formats quarterly cycle range correctly" do
|
||||
cycle_start = ~D[2024-01-01]
|
||||
interval = :quarterly
|
||||
_cycle_end = CalendarCycles.calculate_cycle_end(cycle_start, interval)
|
||||
|
||||
result = MembershipFeeHelpers.format_cycle_range(cycle_start, interval)
|
||||
assert result =~ "2024"
|
||||
assert result =~ "01.01"
|
||||
assert result =~ "31.03"
|
||||
end
|
||||
|
||||
test "formats monthly cycle range correctly" do
|
||||
cycle_start = ~D[2024-03-01]
|
||||
interval = :monthly
|
||||
_cycle_end = CalendarCycles.calculate_cycle_end(cycle_start, interval)
|
||||
|
||||
result = MembershipFeeHelpers.format_cycle_range(cycle_start, interval)
|
||||
assert result =~ "2024"
|
||||
assert result =~ "01.03"
|
||||
assert result =~ "31.03"
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_last_completed_cycle/2" do
|
||||
test "returns last completed cycle for member" do
|
||||
# Create test data
|
||||
fee_type =
|
||||
Mv.MembershipFees.MembershipFeeType
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Test Type",
|
||||
amount: Decimal.new("50.00"),
|
||||
interval: :yearly
|
||||
})
|
||||
|> Ash.create!()
|
||||
|
||||
# Create member without fee type first to avoid auto-generation
|
||||
member =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Test",
|
||||
last_name: "Member",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
join_date: ~D[2022-01-01]
|
||||
})
|
||||
|> Ash.create!()
|
||||
|
||||
# Assign fee type after member creation (this may generate cycles, but we'll create our own)
|
||||
member =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
||||
|> Ash.update!()
|
||||
|
||||
# Delete any auto-generated cycles first
|
||||
cycles =
|
||||
Mv.MembershipFees.MembershipFeeCycle
|
||||
|> Ash.Query.filter(member_id == ^member.id)
|
||||
|> Ash.read!()
|
||||
|
||||
Enum.each(cycles, fn cycle -> Ash.destroy!(cycle) end)
|
||||
|
||||
# Create cycles manually
|
||||
_cycle_2022 =
|
||||
Mv.MembershipFees.MembershipFeeCycle
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
cycle_start: ~D[2022-01-01],
|
||||
amount: Decimal.new("50.00"),
|
||||
member_id: member.id,
|
||||
membership_fee_type_id: fee_type.id,
|
||||
status: :paid
|
||||
})
|
||||
|> Ash.create!()
|
||||
|
||||
cycle_2023 =
|
||||
Mv.MembershipFees.MembershipFeeCycle
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
cycle_start: ~D[2023-01-01],
|
||||
amount: Decimal.new("50.00"),
|
||||
member_id: member.id,
|
||||
membership_fee_type_id: fee_type.id,
|
||||
status: :paid
|
||||
})
|
||||
|> Ash.create!()
|
||||
|
||||
# Load cycles with membership_fee_type relationship
|
||||
member =
|
||||
member
|
||||
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|
||||
|> Ash.load!(:membership_fee_type)
|
||||
|
||||
# Use a fixed date in 2024 to ensure 2023 is last completed
|
||||
today = ~D[2024-06-15]
|
||||
last_cycle = MembershipFeeHelpers.get_last_completed_cycle(member, today)
|
||||
|
||||
assert last_cycle.id == cycle_2023.id
|
||||
end
|
||||
|
||||
test "returns nil if no cycles exist" do
|
||||
fee_type =
|
||||
Mv.MembershipFees.MembershipFeeType
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Test Type",
|
||||
amount: Decimal.new("50.00"),
|
||||
interval: :yearly
|
||||
})
|
||||
|> Ash.create!()
|
||||
|
||||
# Create member without fee type first
|
||||
member =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Test",
|
||||
last_name: "Member",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com"
|
||||
})
|
||||
|> Ash.create!()
|
||||
|
||||
# Assign fee type
|
||||
member =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
||||
|> Ash.update!()
|
||||
|
||||
# Delete any auto-generated cycles
|
||||
cycles =
|
||||
Mv.MembershipFees.MembershipFeeCycle
|
||||
|> Ash.Query.filter(member_id == ^member.id)
|
||||
|> Ash.read!()
|
||||
|
||||
Enum.each(cycles, fn cycle -> Ash.destroy!(cycle) end)
|
||||
|
||||
# Load cycles and fee type (will be empty)
|
||||
member =
|
||||
member
|
||||
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|
||||
|> Ash.load!(:membership_fee_type)
|
||||
|
||||
last_cycle = MembershipFeeHelpers.get_last_completed_cycle(member, Date.utc_today())
|
||||
assert last_cycle == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_current_cycle/2" do
|
||||
test "returns current cycle for member" do
|
||||
fee_type =
|
||||
Mv.MembershipFees.MembershipFeeType
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Test Type",
|
||||
amount: Decimal.new("50.00"),
|
||||
interval: :yearly
|
||||
})
|
||||
|> Ash.create!()
|
||||
|
||||
# Create member without fee type first
|
||||
member =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Test",
|
||||
last_name: "Member",
|
||||
email: "test#{System.unique_integer([:positive])}@example.com",
|
||||
join_date: ~D[2023-01-01]
|
||||
})
|
||||
|> Ash.create!()
|
||||
|
||||
# Assign fee type
|
||||
member =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
||||
|> Ash.update!()
|
||||
|
||||
# Delete any auto-generated cycles
|
||||
cycles =
|
||||
Mv.MembershipFees.MembershipFeeCycle
|
||||
|> Ash.Query.filter(member_id == ^member.id)
|
||||
|> Ash.read!()
|
||||
|
||||
Enum.each(cycles, fn cycle -> Ash.destroy!(cycle) end)
|
||||
|
||||
today = Date.utc_today()
|
||||
current_year_start = %{today | month: 1, day: 1}
|
||||
|
||||
current_cycle =
|
||||
Mv.MembershipFees.MembershipFeeCycle
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
cycle_start: current_year_start,
|
||||
amount: Decimal.new("50.00"),
|
||||
member_id: member.id,
|
||||
membership_fee_type_id: fee_type.id,
|
||||
status: :unpaid
|
||||
})
|
||||
|> Ash.create!()
|
||||
|
||||
# Load cycles with membership_fee_type relationship
|
||||
member =
|
||||
member
|
||||
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|
||||
|> Ash.load!(:membership_fee_type)
|
||||
|
||||
result = MembershipFeeHelpers.get_current_cycle(member, today)
|
||||
|
||||
assert result.id == current_cycle.id
|
||||
end
|
||||
end
|
||||
|
||||
describe "status_color/1" do
|
||||
test "returns correct color classes for statuses" do
|
||||
assert MembershipFeeHelpers.status_color(:paid) == "badge-success"
|
||||
assert MembershipFeeHelpers.status_color(:unpaid) == "badge-error"
|
||||
assert MembershipFeeHelpers.status_color(:suspended) == "badge-ghost"
|
||||
end
|
||||
end
|
||||
|
||||
describe "status_icon/1" do
|
||||
test "returns correct icon names for statuses" do
|
||||
assert MembershipFeeHelpers.status_icon(:paid) == "hero-check-circle"
|
||||
assert MembershipFeeHelpers.status_icon(:unpaid) == "hero-x-circle"
|
||||
assert MembershipFeeHelpers.status_icon(:suspended) == "hero-pause-circle"
|
||||
end
|
||||
end
|
||||
end
|
||||
218
test/mv_web/live/membership_fee_type_live/form_test.exs
Normal file
218
test/mv_web/live/membership_fee_type_live/form_test.exs
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
defmodule MvWeb.MembershipFeeTypeLive.FormTest do
|
||||
@moduledoc """
|
||||
Tests for membership fee types create/edit form.
|
||||
"""
|
||||
use MvWeb.ConnCase, async: false
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias Mv.Membership.Member
|
||||
|
||||
require Ash.Query
|
||||
|
||||
setup %{conn: conn} do
|
||||
# Create admin user
|
||||
{:ok, user} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Changeset.for_create(:register_with_password, %{
|
||||
email: "admin#{System.unique_integer([:positive])}@mv.local",
|
||||
password: "testpassword123"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
authenticated_conn = conn_with_password_user(conn, user)
|
||||
%{conn: authenticated_conn, user: user}
|
||||
end
|
||||
|
||||
# Helper to create a membership fee type
|
||||
defp create_fee_type(attrs) do
|
||||
default_attrs = %{
|
||||
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
||||
amount: Decimal.new("50.00"),
|
||||
interval: :yearly
|
||||
}
|
||||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
MembershipFeeType
|
||||
|> Ash.Changeset.for_create(:create, attrs)
|
||||
|> Ash.create!()
|
||||
end
|
||||
|
||||
# Helper to create a member
|
||||
defp create_member(attrs) do
|
||||
default_attrs = %{
|
||||
first_name: "Test",
|
||||
last_name: "Member",
|
||||
email: "test.member.#{System.unique_integer([:positive])}@example.com"
|
||||
}
|
||||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, attrs)
|
||||
|> Ash.create!()
|
||||
end
|
||||
|
||||
describe "create form" do
|
||||
test "creates new membership fee type", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, "/membership_fee_types/new")
|
||||
|
||||
form_data = %{
|
||||
"membership_fee_type[name]" => "New Type",
|
||||
"membership_fee_type[amount]" => "75.00",
|
||||
"membership_fee_type[interval]" => "yearly",
|
||||
"membership_fee_type[description]" => "Test description"
|
||||
}
|
||||
|
||||
{:error, {:live_redirect, %{to: to}}} =
|
||||
view
|
||||
|> form("#membership-fee-type-form", form_data)
|
||||
|> render_submit()
|
||||
|
||||
assert to == "/membership_fee_types"
|
||||
|
||||
# Verify type was created
|
||||
type =
|
||||
MembershipFeeType
|
||||
|> Ash.Query.filter(name == "New Type")
|
||||
|> Ash.read_one!()
|
||||
|
||||
assert type.amount == Decimal.new("75.00")
|
||||
assert type.interval == :yearly
|
||||
end
|
||||
|
||||
test "interval field is editable on create", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, "/membership_fee_types/new")
|
||||
|
||||
# Interval field should be editable (not disabled)
|
||||
refute html =~ "disabled" || html =~ "readonly"
|
||||
end
|
||||
end
|
||||
|
||||
describe "edit form" do
|
||||
test "loads existing type data", %{conn: conn} do
|
||||
fee_type = create_fee_type(%{name: "Existing Type", amount: Decimal.new("60.00")})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/membership_fee_types/#{fee_type.id}/edit")
|
||||
|
||||
assert html =~ "Existing Type"
|
||||
assert html =~ "60" || html =~ "60,00"
|
||||
end
|
||||
|
||||
test "interval field is grayed out on edit", %{conn: conn} do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/membership_fee_types/#{fee_type.id}/edit")
|
||||
|
||||
# Interval field should be disabled
|
||||
assert html =~ "disabled" || html =~ "readonly"
|
||||
end
|
||||
|
||||
test "amount change warning displays on edit", %{conn: conn} do
|
||||
fee_type = create_fee_type(%{amount: Decimal.new("50.00")})
|
||||
create_member(%{membership_fee_type_id: fee_type.id})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/membership_fee_types/#{fee_type.id}/edit")
|
||||
|
||||
# Change amount
|
||||
view
|
||||
|> form("#membership-fee-type-form", %{"membership_fee_type[amount]" => "75.00"})
|
||||
|> render_change()
|
||||
|
||||
# Should show warning in rendered view
|
||||
html = render(view)
|
||||
assert html =~ "affect" || html =~ "Change Amount"
|
||||
end
|
||||
|
||||
test "amount change warning shows correct affected member count", %{conn: conn} do
|
||||
fee_type = create_fee_type(%{amount: Decimal.new("50.00")})
|
||||
|
||||
# Create 3 members
|
||||
Enum.each(1..3, fn _ ->
|
||||
create_member(%{membership_fee_type_id: fee_type.id})
|
||||
end)
|
||||
|
||||
{:ok, view, _html} = live(conn, "/membership_fee_types/#{fee_type.id}/edit")
|
||||
|
||||
# Change amount
|
||||
html =
|
||||
view
|
||||
|> form("#membership-fee-type-form", %{"membership_fee_type[amount]" => "75.00"})
|
||||
|> render_change()
|
||||
|
||||
# Should show affected count
|
||||
assert html =~ "3" || html =~ "members" || html =~ "Mitglieder"
|
||||
end
|
||||
|
||||
test "amount change can be confirmed", %{conn: conn} do
|
||||
fee_type = create_fee_type(%{amount: Decimal.new("50.00")})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/membership_fee_types/#{fee_type.id}/edit")
|
||||
|
||||
# Change amount and confirm
|
||||
view
|
||||
|> form("#membership-fee-type-form", %{"membership_fee_type[amount]" => "75.00"})
|
||||
|> render_change()
|
||||
|
||||
view
|
||||
|> element("button[phx-click='confirm_amount_change']")
|
||||
|> render_click()
|
||||
|
||||
# Submit the form to actually save the change
|
||||
view
|
||||
|> form("#membership-fee-type-form", %{"membership_fee_type[amount]" => "75.00"})
|
||||
|> render_submit()
|
||||
|
||||
# Amount should be updated
|
||||
updated_type = Ash.read_one!(MembershipFeeType |> Ash.Query.filter(id == ^fee_type.id))
|
||||
assert updated_type.amount == Decimal.new("75.00")
|
||||
end
|
||||
|
||||
test "amount change can be cancelled", %{conn: conn} do
|
||||
fee_type = create_fee_type(%{amount: Decimal.new("50.00")})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/membership_fee_types/#{fee_type.id}/edit")
|
||||
|
||||
# Change amount and cancel
|
||||
view
|
||||
|> form("#membership-fee-type-form", %{"membership_fee_type[amount]" => "75.00"})
|
||||
|> render_change()
|
||||
|
||||
view
|
||||
|> element("button[phx-click='cancel_amount_change']")
|
||||
|> render_click()
|
||||
|
||||
# Amount should remain unchanged
|
||||
updated_type = Ash.read_one!(MembershipFeeType |> Ash.Query.filter(id == ^fee_type.id))
|
||||
assert updated_type.amount == Decimal.new("50.00")
|
||||
end
|
||||
|
||||
test "validation errors display correctly", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, "/membership_fee_types/new")
|
||||
|
||||
# Submit with invalid data
|
||||
html =
|
||||
view
|
||||
|> form("#membership-fee-type-form", %{
|
||||
"membership_fee_type[name]" => "",
|
||||
"membership_fee_type[amount]" => ""
|
||||
})
|
||||
|> render_submit()
|
||||
|
||||
# Should show validation errors
|
||||
assert html =~ "can't be blank" || html =~ "darf nicht leer sein" || html =~ "required"
|
||||
end
|
||||
end
|
||||
|
||||
describe "permissions" do
|
||||
test "only admin can access", %{conn: conn} do
|
||||
# This test assumes non-admin users cannot access
|
||||
{:ok, _view, html} = live(conn, "/membership_fee_types/new")
|
||||
|
||||
# Should show the form (admin user in setup)
|
||||
assert html =~ "Membership Fee Type" || html =~ "Beitragsart"
|
||||
end
|
||||
end
|
||||
end
|
||||
151
test/mv_web/live/membership_fee_type_live/index_test.exs
Normal file
151
test/mv_web/live/membership_fee_type_live/index_test.exs
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
|
||||
@moduledoc """
|
||||
Tests for membership fee types list view.
|
||||
"""
|
||||
use MvWeb.ConnCase, async: false
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias Mv.Membership.Member
|
||||
|
||||
require Ash.Query
|
||||
|
||||
setup %{conn: conn} do
|
||||
# Create admin user
|
||||
{:ok, user} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Changeset.for_create(:register_with_password, %{
|
||||
email: "admin#{System.unique_integer([:positive])}@mv.local",
|
||||
password: "testpassword123"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
authenticated_conn = conn_with_password_user(conn, user)
|
||||
%{conn: authenticated_conn, user: user}
|
||||
end
|
||||
|
||||
# Helper to create a membership fee type
|
||||
defp create_fee_type(attrs) do
|
||||
default_attrs = %{
|
||||
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
||||
amount: Decimal.new("50.00"),
|
||||
interval: :yearly
|
||||
}
|
||||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
MembershipFeeType
|
||||
|> Ash.Changeset.for_create(:create, attrs)
|
||||
|> Ash.create!()
|
||||
end
|
||||
|
||||
# Helper to create a member
|
||||
defp create_member(attrs) do
|
||||
default_attrs = %{
|
||||
first_name: "Test",
|
||||
last_name: "Member",
|
||||
email: "test.member.#{System.unique_integer([:positive])}@example.com"
|
||||
}
|
||||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, attrs)
|
||||
|> Ash.create!()
|
||||
end
|
||||
|
||||
describe "list display" do
|
||||
test "displays all membership fee types with correct data", %{conn: conn} do
|
||||
_fee_type1 =
|
||||
create_fee_type(%{name: "Regular", amount: Decimal.new("60.00"), interval: :yearly})
|
||||
|
||||
_fee_type2 =
|
||||
create_fee_type(%{name: "Reduced", amount: Decimal.new("30.00"), interval: :yearly})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/membership_fee_types")
|
||||
|
||||
assert html =~ "Regular"
|
||||
assert html =~ "Reduced"
|
||||
assert html =~ "60" || html =~ "60,00"
|
||||
assert html =~ "30" || html =~ "30,00"
|
||||
assert html =~ "Yearly" || html =~ "Jährlich"
|
||||
end
|
||||
|
||||
test "member count column shows correct count", %{conn: conn} do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
|
||||
# Create 3 members with this fee type
|
||||
Enum.each(1..3, fn _ ->
|
||||
create_member(%{membership_fee_type_id: fee_type.id})
|
||||
end)
|
||||
|
||||
{:ok, _view, html} = live(conn, "/membership_fee_types")
|
||||
|
||||
assert html =~ "3" || html =~ "Members" || html =~ "Mitglieder"
|
||||
end
|
||||
|
||||
test "create button navigates to form", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, "/membership_fee_types")
|
||||
|
||||
{:error, {:live_redirect, %{to: to}}} =
|
||||
view
|
||||
|> element("a[href='/membership_fee_types/new']")
|
||||
|> render_click()
|
||||
|
||||
assert to == "/membership_fee_types/new"
|
||||
end
|
||||
|
||||
test "edit button per row navigates to edit form", %{conn: conn} do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/membership_fee_types")
|
||||
|
||||
{:error, {:live_redirect, %{to: to}}} =
|
||||
view
|
||||
|> element("a[href='/membership_fee_types/#{fee_type.id}/edit']")
|
||||
|> render_click()
|
||||
|
||||
assert to == "/membership_fee_types/#{fee_type.id}/edit"
|
||||
end
|
||||
end
|
||||
|
||||
describe "delete functionality" do
|
||||
test "delete button disabled if type is in use", %{conn: conn} do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
create_member(%{membership_fee_type_id: fee_type.id})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/membership_fee_types")
|
||||
|
||||
# Delete button should be disabled
|
||||
assert html =~ "disabled" || html =~ "cursor-not-allowed"
|
||||
end
|
||||
|
||||
test "delete button works if type is not in use", %{conn: conn} do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
# No members assigned
|
||||
|
||||
{:ok, view, _html} = live(conn, "/membership_fee_types")
|
||||
|
||||
# Delete button should be enabled
|
||||
view
|
||||
|> element("button[phx-click='delete'][phx-value-id='#{fee_type.id}']")
|
||||
|> render_click()
|
||||
|
||||
# Type should be deleted
|
||||
assert {:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{}]}} =
|
||||
Ash.get(MembershipFeeType, fee_type.id, domain: Mv.MembershipFees)
|
||||
end
|
||||
end
|
||||
|
||||
describe "permissions" do
|
||||
test "only admin can access", %{conn: conn} do
|
||||
# This test assumes non-admin users cannot access
|
||||
# Adjust based on actual permission implementation
|
||||
{:ok, _view, html} = live(conn, "/membership_fee_types")
|
||||
|
||||
# Should show the page (admin user in setup)
|
||||
assert html =~ "Membership Fee Types" || html =~ "Beitragsarten"
|
||||
end
|
||||
end
|
||||
end
|
||||
167
test/mv_web/member_live/form_membership_fee_type_test.exs
Normal file
167
test/mv_web/member_live/form_membership_fee_type_test.exs
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
|
||||
@moduledoc """
|
||||
Tests for membership fee type dropdown in member form.
|
||||
"""
|
||||
use MvWeb.ConnCase, async: false
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Mv.Membership.Member
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
|
||||
require Ash.Query
|
||||
|
||||
setup %{conn: conn} do
|
||||
# Create admin user
|
||||
{:ok, user} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Changeset.for_create(:register_with_password, %{
|
||||
email: "admin#{System.unique_integer([:positive])}@mv.local",
|
||||
password: "testpassword123"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
authenticated_conn = conn_with_password_user(conn, user)
|
||||
%{conn: authenticated_conn, user: user}
|
||||
end
|
||||
|
||||
# Helper to create a membership fee type
|
||||
defp create_fee_type(attrs) do
|
||||
default_attrs = %{
|
||||
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
||||
amount: Decimal.new("50.00"),
|
||||
interval: :yearly
|
||||
}
|
||||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
MembershipFeeType
|
||||
|> Ash.Changeset.for_create(:create, attrs)
|
||||
|> Ash.create!()
|
||||
end
|
||||
|
||||
# Helper to create a member
|
||||
defp create_member(attrs) do
|
||||
default_attrs = %{
|
||||
first_name: "Test",
|
||||
last_name: "Member",
|
||||
email: "test.member.#{System.unique_integer([:positive])}@example.com"
|
||||
}
|
||||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, attrs)
|
||||
|> Ash.create!()
|
||||
end
|
||||
|
||||
describe "membership fee type dropdown" do
|
||||
test "displays in form", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, "/members/new")
|
||||
|
||||
# Should show membership fee type dropdown
|
||||
assert html =~ "membership_fee_type_id" || html =~ "Membership Fee Type" ||
|
||||
html =~ "Beitragsart"
|
||||
end
|
||||
|
||||
test "shows available types", %{conn: conn} do
|
||||
_fee_type1 = create_fee_type(%{name: "Type 1", interval: :yearly})
|
||||
_fee_type2 = create_fee_type(%{name: "Type 2", interval: :yearly})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/members/new")
|
||||
|
||||
assert html =~ "Type 1"
|
||||
assert html =~ "Type 2"
|
||||
end
|
||||
|
||||
test "filters to same interval types if member has type", %{conn: conn} do
|
||||
yearly_type = create_fee_type(%{name: "Yearly Type", interval: :yearly})
|
||||
_monthly_type = create_fee_type(%{name: "Monthly Type", interval: :monthly})
|
||||
|
||||
member = create_member(%{membership_fee_type_id: yearly_type.id})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/members/#{member.id}/edit")
|
||||
|
||||
# Should show yearly type but not monthly
|
||||
assert html =~ "Yearly Type"
|
||||
refute html =~ "Monthly Type"
|
||||
end
|
||||
|
||||
test "shows warning if different interval selected", %{conn: conn} do
|
||||
yearly_type = create_fee_type(%{name: "Yearly Type", interval: :yearly})
|
||||
monthly_type = create_fee_type(%{name: "Monthly Type", interval: :monthly})
|
||||
|
||||
member = create_member(%{membership_fee_type_id: yearly_type.id})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/members/#{member.id}/edit")
|
||||
|
||||
# Monthly type should not be in the dropdown (filtered by interval)
|
||||
refute html =~ monthly_type.id
|
||||
|
||||
# Only yearly types should be available
|
||||
assert html =~ yearly_type.id
|
||||
end
|
||||
|
||||
test "warning cleared if same interval selected", %{conn: conn} do
|
||||
yearly_type1 = create_fee_type(%{name: "Yearly Type 1", interval: :yearly})
|
||||
yearly_type2 = create_fee_type(%{name: "Yearly Type 2", interval: :yearly})
|
||||
|
||||
member = create_member(%{membership_fee_type_id: yearly_type1.id})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members/#{member.id}/edit")
|
||||
|
||||
# Select another yearly type (should not show warning)
|
||||
html =
|
||||
view
|
||||
|> form("#member-form", %{"member[membership_fee_type_id]" => yearly_type2.id})
|
||||
|> render_change()
|
||||
|
||||
refute html =~ "Warning" || html =~ "Warnung"
|
||||
end
|
||||
|
||||
test "form saves with selected membership fee type", %{conn: conn} do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members/new")
|
||||
|
||||
form_data = %{
|
||||
"member[first_name]" => "Test",
|
||||
"member[last_name]" => "Member",
|
||||
"member[email]" => "test#{System.unique_integer([:positive])}@example.com",
|
||||
"member[membership_fee_type_id]" => fee_type.id
|
||||
}
|
||||
|
||||
{:error, {:live_redirect, %{to: _to}}} =
|
||||
view
|
||||
|> form("#member-form", form_data)
|
||||
|> render_submit()
|
||||
|
||||
# Verify member was created with fee type
|
||||
member =
|
||||
Member
|
||||
|> Ash.Query.filter(email == ^form_data["member[email]"])
|
||||
|> Ash.read_one!()
|
||||
|
||||
assert member.membership_fee_type_id == fee_type.id
|
||||
end
|
||||
|
||||
test "new members get default membership fee type", %{conn: conn} do
|
||||
# Set default fee type in settings
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
|
||||
{:ok, settings} = Mv.Membership.get_settings()
|
||||
|
||||
settings
|
||||
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|
||||
default_membership_fee_type_id: fee_type.id
|
||||
})
|
||||
|> Ash.update!()
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members/new")
|
||||
|
||||
# Form should have default fee type selected
|
||||
html = render(view)
|
||||
assert html =~ fee_type.name || html =~ "selected"
|
||||
end
|
||||
end
|
||||
end
|
||||
362
test/mv_web/member_live/index/membership_fee_status_test.exs
Normal file
362
test/mv_web/member_live/index/membership_fee_status_test.exs
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
|
||||
@moduledoc """
|
||||
Tests for MembershipFeeStatus helper module.
|
||||
"""
|
||||
use Mv.DataCase, async: false
|
||||
|
||||
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
||||
alias Mv.Membership.Member
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias Mv.MembershipFees.MembershipFeeCycle
|
||||
|
||||
require Ash.Query
|
||||
|
||||
# Helper to create a membership fee type
|
||||
defp create_fee_type(attrs) do
|
||||
default_attrs = %{
|
||||
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
||||
amount: Decimal.new("50.00"),
|
||||
interval: :yearly
|
||||
}
|
||||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
MembershipFeeType
|
||||
|> Ash.Changeset.for_create(:create, attrs)
|
||||
|> Ash.create!()
|
||||
end
|
||||
|
||||
# Helper to create a member
|
||||
defp create_member(attrs) do
|
||||
default_attrs = %{
|
||||
first_name: "Test",
|
||||
last_name: "Member",
|
||||
email: "test.member.#{System.unique_integer([:positive])}@example.com"
|
||||
}
|
||||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, attrs)
|
||||
|> Ash.create!()
|
||||
end
|
||||
|
||||
# Helper to create a cycle
|
||||
# Note: Does not delete existing cycles - tests should manage their own test data
|
||||
# If cleanup is needed, it should be done in setup or explicitly in the test
|
||||
defp create_cycle(member, fee_type, attrs) do
|
||||
default_attrs = %{
|
||||
cycle_start: ~D[2023-01-01],
|
||||
amount: Decimal.new("50.00"),
|
||||
member_id: member.id,
|
||||
membership_fee_type_id: fee_type.id,
|
||||
status: :unpaid
|
||||
}
|
||||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
MembershipFeeCycle
|
||||
|> Ash.Changeset.for_create(:create, attrs)
|
||||
|> Ash.create!()
|
||||
end
|
||||
|
||||
describe "load_cycles_for_members/2" do
|
||||
test "efficiently loads cycles for members" do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
|
||||
member1 = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
member2 = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
|
||||
create_cycle(member1, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
|
||||
create_cycle(member2, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
|
||||
|
||||
query =
|
||||
Member
|
||||
|> Ash.Query.filter(id in [^member1.id, ^member2.id])
|
||||
|> MembershipFeeStatus.load_cycles_for_members()
|
||||
|
||||
members = Ash.read!(query)
|
||||
|
||||
assert length(members) == 2
|
||||
|
||||
# Verify cycles are loaded
|
||||
member1_loaded = Enum.find(members, &(&1.id == member1.id))
|
||||
member2_loaded = Enum.find(members, &(&1.id == member2.id))
|
||||
|
||||
assert member1_loaded.membership_fee_cycles != nil
|
||||
assert member2_loaded.membership_fee_cycles != nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_cycle_status_for_member/2" do
|
||||
test "returns status of last completed cycle" do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
# Create member without fee type to avoid auto-generation
|
||||
member = create_member(%{})
|
||||
|
||||
# Assign fee type
|
||||
member =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
||||
|> Ash.update!()
|
||||
|
||||
# Delete any auto-generated cycles
|
||||
cycles =
|
||||
Mv.MembershipFees.MembershipFeeCycle
|
||||
|> Ash.Query.filter(member_id == ^member.id)
|
||||
|> Ash.read!()
|
||||
|
||||
Enum.each(cycles, fn cycle -> Ash.destroy!(cycle) end)
|
||||
|
||||
# Create cycles with dates that ensure 2023 is last completed
|
||||
# Use a fixed "today" date in 2024 to make 2023 the last completed
|
||||
create_cycle(member, fee_type, %{cycle_start: ~D[2022-01-01], status: :paid})
|
||||
create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
|
||||
|
||||
# Load cycles with membership_fee_type relationship
|
||||
member =
|
||||
member
|
||||
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|
||||
|> Ash.load!(:membership_fee_type)
|
||||
|
||||
# Use fixed date in 2024 to ensure 2023 is last completed
|
||||
# We need to manually set the date for the helper function
|
||||
# Since get_cycle_status_for_member doesn't take a date, we need to ensure
|
||||
# the cycles are properly loaded with their fee_type relationship
|
||||
status = MembershipFeeStatus.get_cycle_status_for_member(member, false)
|
||||
|
||||
# The status depends on what Date.utc_today() returns
|
||||
# If we're in 2024 or later, 2023 should be last completed
|
||||
# If we're still in 2023, 2022 would be last completed
|
||||
# For this test, we'll just verify it returns a valid status
|
||||
assert status in [:paid, :unpaid, :suspended, nil]
|
||||
end
|
||||
|
||||
test "returns status of current cycle when show_current is true" do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
# Create member without fee type to avoid auto-generation
|
||||
member = create_member(%{})
|
||||
|
||||
# Assign fee type
|
||||
member =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
||||
|> Ash.update!()
|
||||
|
||||
# Delete any auto-generated cycles
|
||||
cycles =
|
||||
Mv.MembershipFees.MembershipFeeCycle
|
||||
|> Ash.Query.filter(member_id == ^member.id)
|
||||
|> Ash.read!()
|
||||
|
||||
Enum.each(cycles, fn cycle -> Ash.destroy!(cycle) end)
|
||||
|
||||
# Create cycles - use current year for current cycle
|
||||
today = Date.utc_today()
|
||||
current_year_start = %{today | month: 1, day: 1}
|
||||
last_year_start = %{current_year_start | year: current_year_start.year - 1}
|
||||
|
||||
create_cycle(member, fee_type, %{cycle_start: last_year_start, status: :paid})
|
||||
create_cycle(member, fee_type, %{cycle_start: current_year_start, status: :suspended})
|
||||
|
||||
# Load cycles with membership_fee_type relationship
|
||||
member =
|
||||
member
|
||||
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|
||||
|> Ash.load!(:membership_fee_type)
|
||||
|
||||
status = MembershipFeeStatus.get_cycle_status_for_member(member, true)
|
||||
|
||||
# Should return status of current cycle
|
||||
assert status == :suspended
|
||||
end
|
||||
|
||||
test "returns nil if no cycles exist" do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
# Create member without fee type to avoid auto-generation
|
||||
member = create_member(%{})
|
||||
|
||||
# Assign fee type
|
||||
member =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|
||||
|> Ash.update!()
|
||||
|
||||
# Delete any auto-generated cycles
|
||||
cycles =
|
||||
Mv.MembershipFees.MembershipFeeCycle
|
||||
|> Ash.Query.filter(member_id == ^member.id)
|
||||
|> Ash.read!()
|
||||
|
||||
Enum.each(cycles, fn cycle -> Ash.destroy!(cycle) end)
|
||||
|
||||
# Load cycles and fee type first (will be empty)
|
||||
member =
|
||||
member
|
||||
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|
||||
|> Ash.load!(:membership_fee_type)
|
||||
|
||||
status = MembershipFeeStatus.get_cycle_status_for_member(member, false)
|
||||
|
||||
assert status == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "format_cycle_status_badge/1" do
|
||||
test "returns badge component for paid status" do
|
||||
result = MembershipFeeStatus.format_cycle_status_badge(:paid)
|
||||
assert result.color == "badge-success"
|
||||
assert result.icon == "hero-check-circle"
|
||||
assert result.label == "Paid" || result.label == "Bezahlt"
|
||||
end
|
||||
|
||||
test "returns badge component for unpaid status" do
|
||||
result = MembershipFeeStatus.format_cycle_status_badge(:unpaid)
|
||||
assert result.color == "badge-error"
|
||||
assert result.icon == "hero-x-circle"
|
||||
assert result.label == "Unpaid" || result.label == "Unbezahlt"
|
||||
end
|
||||
|
||||
test "returns badge component for suspended status" do
|
||||
result = MembershipFeeStatus.format_cycle_status_badge(:suspended)
|
||||
assert result.color == "badge-ghost"
|
||||
assert result.icon == "hero-pause-circle"
|
||||
assert result.label == "Suspended" || result.label == "Ausgesetzt"
|
||||
end
|
||||
|
||||
test "handles nil status gracefully" do
|
||||
result = MembershipFeeStatus.format_cycle_status_badge(nil)
|
||||
assert result == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "filter_members_by_cycle_status/3" do
|
||||
test "filters paid members in last cycle" do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
today = Date.utc_today()
|
||||
last_year_start = Date.new!(today.year - 1, 1, 1)
|
||||
|
||||
# Member with paid last cycle
|
||||
member1 = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
create_cycle(member1, fee_type, %{cycle_start: last_year_start, status: :paid})
|
||||
|
||||
# Member with unpaid last cycle
|
||||
member2 = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
create_cycle(member2, fee_type, %{cycle_start: last_year_start, status: :unpaid})
|
||||
|
||||
members =
|
||||
[member1, member2]
|
||||
|> Enum.map(fn m ->
|
||||
m
|
||||
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|
||||
|> Ash.load!(:membership_fee_type)
|
||||
end)
|
||||
|
||||
filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :paid, false)
|
||||
|
||||
assert length(filtered) == 1
|
||||
assert List.first(filtered).id == member1.id
|
||||
end
|
||||
|
||||
test "filters unpaid members in last cycle" do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
today = Date.utc_today()
|
||||
last_year_start = Date.new!(today.year - 1, 1, 1)
|
||||
|
||||
# Member with paid last cycle
|
||||
member1 = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
create_cycle(member1, fee_type, %{cycle_start: last_year_start, status: :paid})
|
||||
|
||||
# Member with unpaid last cycle
|
||||
member2 = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
create_cycle(member2, fee_type, %{cycle_start: last_year_start, status: :unpaid})
|
||||
|
||||
members =
|
||||
[member1, member2]
|
||||
|> Enum.map(fn m ->
|
||||
m
|
||||
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|
||||
|> Ash.load!(:membership_fee_type)
|
||||
end)
|
||||
|
||||
filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :unpaid, false)
|
||||
|
||||
assert length(filtered) == 1
|
||||
assert List.first(filtered).id == member2.id
|
||||
end
|
||||
|
||||
test "filters paid members in current cycle" do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
today = Date.utc_today()
|
||||
current_year_start = Date.new!(today.year, 1, 1)
|
||||
|
||||
# Member with paid current cycle
|
||||
member1 = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
create_cycle(member1, fee_type, %{cycle_start: current_year_start, status: :paid})
|
||||
|
||||
# Member with unpaid current cycle
|
||||
member2 = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
create_cycle(member2, fee_type, %{cycle_start: current_year_start, status: :unpaid})
|
||||
|
||||
members =
|
||||
[member1, member2]
|
||||
|> Enum.map(fn m ->
|
||||
m
|
||||
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|
||||
|> Ash.load!(:membership_fee_type)
|
||||
end)
|
||||
|
||||
filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :paid, true)
|
||||
|
||||
assert length(filtered) == 1
|
||||
assert List.first(filtered).id == member1.id
|
||||
end
|
||||
|
||||
test "filters unpaid members in current cycle" do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
today = Date.utc_today()
|
||||
current_year_start = Date.new!(today.year, 1, 1)
|
||||
|
||||
# Member with paid current cycle
|
||||
member1 = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
create_cycle(member1, fee_type, %{cycle_start: current_year_start, status: :paid})
|
||||
|
||||
# Member with unpaid current cycle
|
||||
member2 = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
create_cycle(member2, fee_type, %{cycle_start: current_year_start, status: :unpaid})
|
||||
|
||||
members =
|
||||
[member1, member2]
|
||||
|> Enum.map(fn m ->
|
||||
m
|
||||
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|
||||
|> Ash.load!(:membership_fee_type)
|
||||
end)
|
||||
|
||||
filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :unpaid, true)
|
||||
|
||||
assert length(filtered) == 1
|
||||
assert List.first(filtered).id == member2.id
|
||||
end
|
||||
|
||||
test "returns all members when filter is nil" do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member1 = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
member2 = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
|
||||
members =
|
||||
[member1, member2]
|
||||
|> Enum.map(fn m ->
|
||||
m
|
||||
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|
||||
|> Ash.load!(:membership_fee_type)
|
||||
end)
|
||||
|
||||
# filter_unpaid_members should still work for backwards compatibility
|
||||
filtered = MembershipFeeStatus.filter_unpaid_members(members, false)
|
||||
|
||||
# Both members have no cycles, so both should be filtered out
|
||||
assert Enum.empty?(filtered)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -10,7 +10,8 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
|||
- Integration with member list display
|
||||
- Custom fields visibility
|
||||
"""
|
||||
use MvWeb.ConnCase, async: true
|
||||
# async: false to prevent PostgreSQL deadlocks when creating members and custom fields
|
||||
use MvWeb.ConnCase, async: false
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
require Ash.Query
|
||||
|
|
|
|||
261
test/mv_web/member_live/index_membership_fee_status_test.exs
Normal file
261
test/mv_web/member_live/index_membership_fee_status_test.exs
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
|
||||
@moduledoc """
|
||||
Tests for membership fee status column in member list view.
|
||||
"""
|
||||
use MvWeb.ConnCase, async: false
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Mv.Membership.Member
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias Mv.MembershipFees.MembershipFeeCycle
|
||||
|
||||
require Ash.Query
|
||||
|
||||
setup %{conn: conn} do
|
||||
# Create admin user
|
||||
{:ok, user} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Changeset.for_create(:register_with_password, %{
|
||||
email: "admin#{System.unique_integer([:positive])}@mv.local",
|
||||
password: "testpassword123"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
conn = conn_with_password_user(conn, user)
|
||||
%{conn: conn, user: user}
|
||||
end
|
||||
|
||||
# Helper to create a membership fee type
|
||||
defp create_fee_type(attrs) do
|
||||
default_attrs = %{
|
||||
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
||||
amount: Decimal.new("50.00"),
|
||||
interval: :yearly
|
||||
}
|
||||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
MembershipFeeType
|
||||
|> Ash.Changeset.for_create(:create, attrs)
|
||||
|> Ash.create!()
|
||||
end
|
||||
|
||||
# Helper to create a member
|
||||
defp create_member(attrs) do
|
||||
default_attrs = %{
|
||||
first_name: "Test",
|
||||
last_name: "Member",
|
||||
email: "test.member.#{System.unique_integer([:positive])}@example.com"
|
||||
}
|
||||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, attrs)
|
||||
|> Ash.create!()
|
||||
end
|
||||
|
||||
# Helper to create a cycle
|
||||
defp create_cycle(member, fee_type, attrs) do
|
||||
# Delete any auto-generated cycles first to avoid conflicts
|
||||
existing_cycles =
|
||||
MembershipFeeCycle
|
||||
|> Ash.Query.filter(member_id == ^member.id)
|
||||
|> Ash.read!()
|
||||
|
||||
Enum.each(existing_cycles, fn cycle -> Ash.destroy!(cycle) end)
|
||||
|
||||
default_attrs = %{
|
||||
cycle_start: ~D[2023-01-01],
|
||||
amount: Decimal.new("50.00"),
|
||||
member_id: member.id,
|
||||
membership_fee_type_id: fee_type.id,
|
||||
status: :unpaid
|
||||
}
|
||||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
MembershipFeeCycle
|
||||
|> Ash.Changeset.for_create(:create, attrs)
|
||||
|> Ash.create!()
|
||||
end
|
||||
|
||||
describe "status column display" do
|
||||
test "shows status column in member list", %{conn: conn} do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
# Should show membership fee status column
|
||||
assert html =~ "Membership Fee Status" || html =~ "Mitgliedsbeitrag Status"
|
||||
end
|
||||
|
||||
test "shows last completed cycle status by default", %{conn: conn} do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
create_cycle(member, fee_type, %{cycle_start: ~D[2022-01-01], status: :paid})
|
||||
create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Should show unpaid status (2023 is last completed)
|
||||
html = render(view)
|
||||
assert html =~ "hero-x-circle" || html =~ "unpaid"
|
||||
end
|
||||
|
||||
test "toggle switches to current cycle view", %{conn: conn} do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
|
||||
today = Date.utc_today()
|
||||
current_year_start = %{today | month: 1, day: 1}
|
||||
|
||||
create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
|
||||
create_cycle(member, fee_type, %{cycle_start: current_year_start, status: :suspended})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Toggle to current cycle (use the button in the header, not the one in the column)
|
||||
view
|
||||
|> element("button[phx-click='toggle_cycle_view'].btn.gap-2")
|
||||
|> render_click()
|
||||
|
||||
html = render(view)
|
||||
# Should show suspended status (current cycle)
|
||||
assert html =~ "hero-pause-circle" || html =~ "suspended"
|
||||
end
|
||||
|
||||
test "shows correct color coding for paid status", %{conn: conn} do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
html = render(view)
|
||||
assert html =~ "text-success" || html =~ "hero-check-circle"
|
||||
end
|
||||
|
||||
test "shows correct color coding for unpaid status", %{conn: conn} do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
html = render(view)
|
||||
assert html =~ "text-error" || html =~ "hero-x-circle"
|
||||
end
|
||||
|
||||
test "shows correct color coding for suspended status", %{conn: conn} do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :suspended})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
html = render(view)
|
||||
assert html =~ "text-base-content/60" || html =~ "hero-pause-circle"
|
||||
end
|
||||
|
||||
test "handles members without cycles gracefully", %{conn: conn} do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
# No cycles created
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
html = render(view)
|
||||
# Should not crash, may show empty or default state
|
||||
assert html =~ member.first_name
|
||||
end
|
||||
end
|
||||
|
||||
describe "filters" do
|
||||
test "filter unpaid in last cycle works", %{conn: conn} do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
|
||||
# Member with unpaid last cycle
|
||||
member1 = create_member(%{first_name: "UnpaidMember", membership_fee_type_id: fee_type.id})
|
||||
create_cycle(member1, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
|
||||
|
||||
# Member with paid last cycle
|
||||
member2 = create_member(%{first_name: "PaidMember", membership_fee_type_id: fee_type.id})
|
||||
create_cycle(member2, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
|
||||
|
||||
# Verify cycles exist in database
|
||||
cycles1 =
|
||||
MembershipFeeCycle
|
||||
|> Ash.Query.filter(member_id == ^member1.id)
|
||||
|> Ash.read!()
|
||||
|
||||
cycles2 =
|
||||
MembershipFeeCycle
|
||||
|> Ash.Query.filter(member_id == ^member2.id)
|
||||
|> Ash.read!()
|
||||
|
||||
refute Enum.empty?(cycles1)
|
||||
refute Enum.empty?(cycles2)
|
||||
|
||||
{:ok, _view, html} = live(conn, "/members?cycle_status_filter=unpaid")
|
||||
|
||||
assert html =~ "UnpaidMember"
|
||||
refute html =~ "PaidMember"
|
||||
end
|
||||
|
||||
test "filter unpaid in current cycle works", %{conn: conn} do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
|
||||
today = Date.utc_today()
|
||||
current_year_start = %{today | month: 1, day: 1}
|
||||
|
||||
# Member with unpaid current cycle
|
||||
member1 = create_member(%{first_name: "UnpaidCurrent", membership_fee_type_id: fee_type.id})
|
||||
create_cycle(member1, fee_type, %{cycle_start: current_year_start, status: :unpaid})
|
||||
|
||||
# Member with paid current cycle
|
||||
member2 = create_member(%{first_name: "PaidCurrent", membership_fee_type_id: fee_type.id})
|
||||
create_cycle(member2, fee_type, %{cycle_start: current_year_start, status: :paid})
|
||||
|
||||
# Verify cycles exist in database
|
||||
cycles1 =
|
||||
MembershipFeeCycle
|
||||
|> Ash.Query.filter(member_id == ^member1.id)
|
||||
|> Ash.read!()
|
||||
|
||||
cycles2 =
|
||||
MembershipFeeCycle
|
||||
|> Ash.Query.filter(member_id == ^member2.id)
|
||||
|> Ash.read!()
|
||||
|
||||
refute Enum.empty?(cycles1)
|
||||
refute Enum.empty?(cycles2)
|
||||
|
||||
{:ok, _view, html} =
|
||||
live(conn, "/members?cycle_status_filter=unpaid&show_current_cycle=true")
|
||||
|
||||
assert html =~ "UnpaidCurrent"
|
||||
refute html =~ "PaidCurrent"
|
||||
end
|
||||
end
|
||||
|
||||
describe "performance" do
|
||||
test "loads cycles efficiently without N+1 queries", %{conn: conn} do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
|
||||
# Create multiple members with cycles
|
||||
Enum.each(1..5, fn _ ->
|
||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
|
||||
end)
|
||||
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
# Should render without errors (N+1 would cause performance issues)
|
||||
assert html =~ "Members" || html =~ "Mitglieder"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -457,220 +457,204 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "payment filter integration" do
|
||||
setup do
|
||||
# Create members with different payment status
|
||||
# Use unique names that won't appear elsewhere in the HTML
|
||||
{:ok, paid_member} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Zahler",
|
||||
last_name: "Mitglied",
|
||||
email: "zahler@example.com",
|
||||
paid: true
|
||||
describe "cycle status filter" do
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias Mv.MembershipFees.MembershipFeeCycle
|
||||
|
||||
# Helper to create a membership fee type
|
||||
defp create_fee_type(attrs) do
|
||||
default_attrs = %{
|
||||
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
||||
amount: Decimal.new("50.00"),
|
||||
interval: :yearly
|
||||
}
|
||||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
MembershipFeeType
|
||||
|> Ash.Changeset.for_create(:create, attrs)
|
||||
|> Ash.create!()
|
||||
end
|
||||
|
||||
# Helper to create a member
|
||||
defp create_member(attrs) do
|
||||
default_attrs = %{
|
||||
first_name: "Test",
|
||||
last_name: "Member",
|
||||
email: "test.member.#{System.unique_integer([:positive])}@example.com"
|
||||
}
|
||||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
Mv.Membership.Member
|
||||
|> Ash.Changeset.for_create(:create_member, attrs)
|
||||
|> Ash.create!()
|
||||
end
|
||||
|
||||
# Helper to create a cycle
|
||||
defp create_cycle(member, fee_type, attrs) do
|
||||
# Delete any auto-generated cycles first to avoid conflicts
|
||||
existing_cycles =
|
||||
MembershipFeeCycle
|
||||
|> Ash.Query.filter(member_id == ^member.id)
|
||||
|> Ash.read!()
|
||||
|
||||
Enum.each(existing_cycles, fn cycle -> Ash.destroy!(cycle) end)
|
||||
|
||||
default_attrs = %{
|
||||
cycle_start: ~D[2023-01-01],
|
||||
amount: Decimal.new("50.00"),
|
||||
member_id: member.id,
|
||||
membership_fee_type_id: fee_type.id,
|
||||
status: :unpaid
|
||||
}
|
||||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
MembershipFeeCycle
|
||||
|> Ash.Changeset.for_create(:create, attrs)
|
||||
|> Ash.create!()
|
||||
end
|
||||
|
||||
test "filter shows only members with paid status in last cycle", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
today = Date.utc_today()
|
||||
last_year_start = Date.new!(today.year - 1, 1, 1)
|
||||
|
||||
# Member with paid last cycle
|
||||
paid_member =
|
||||
create_member(%{
|
||||
first_name: "PaidLast",
|
||||
membership_fee_type_id: fee_type.id
|
||||
})
|
||||
|
||||
{:ok, unpaid_member} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Nichtzahler",
|
||||
last_name: "Mitglied",
|
||||
email: "nichtzahler@example.com",
|
||||
paid: false
|
||||
create_cycle(paid_member, fee_type, %{cycle_start: last_year_start, status: :paid})
|
||||
|
||||
# Member with unpaid last cycle
|
||||
unpaid_member =
|
||||
create_member(%{
|
||||
first_name: "UnpaidLast",
|
||||
membership_fee_type_id: fee_type.id
|
||||
})
|
||||
|
||||
{:ok, nil_paid_member} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Unbestimmt",
|
||||
last_name: "Mitglied",
|
||||
email: "unbestimmt@example.com"
|
||||
# paid is nil by default
|
||||
create_cycle(unpaid_member, fee_type, %{cycle_start: last_year_start, status: :unpaid})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/members?cycle_status_filter=paid")
|
||||
|
||||
assert html =~ "PaidLast"
|
||||
refute html =~ "UnpaidLast"
|
||||
end
|
||||
|
||||
test "filter shows only members with unpaid status in last cycle", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
today = Date.utc_today()
|
||||
last_year_start = Date.new!(today.year - 1, 1, 1)
|
||||
|
||||
# Member with paid last cycle
|
||||
paid_member =
|
||||
create_member(%{
|
||||
first_name: "PaidLast",
|
||||
membership_fee_type_id: fee_type.id
|
||||
})
|
||||
|
||||
%{paid_member: paid_member, unpaid_member: unpaid_member, nil_paid_member: nil_paid_member}
|
||||
create_cycle(paid_member, fee_type, %{cycle_start: last_year_start, status: :paid})
|
||||
|
||||
# Member with unpaid last cycle
|
||||
unpaid_member =
|
||||
create_member(%{
|
||||
first_name: "UnpaidLast",
|
||||
membership_fee_type_id: fee_type.id
|
||||
})
|
||||
|
||||
create_cycle(unpaid_member, fee_type, %{cycle_start: last_year_start, status: :unpaid})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/members?cycle_status_filter=unpaid")
|
||||
|
||||
refute html =~ "PaidLast"
|
||||
assert html =~ "UnpaidLast"
|
||||
end
|
||||
|
||||
test "filter shows all members when no filter is active", %{
|
||||
conn: conn,
|
||||
paid_member: paid_member,
|
||||
unpaid_member: unpaid_member,
|
||||
nil_paid_member: nil_paid_member
|
||||
} do
|
||||
test "filter shows only members with paid status in current cycle", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
today = Date.utc_today()
|
||||
current_year_start = Date.new!(today.year, 1, 1)
|
||||
|
||||
assert html =~ paid_member.first_name
|
||||
assert html =~ unpaid_member.first_name
|
||||
assert html =~ nil_paid_member.first_name
|
||||
# Member with paid current cycle
|
||||
paid_member =
|
||||
create_member(%{
|
||||
first_name: "PaidCurrent",
|
||||
membership_fee_type_id: fee_type.id
|
||||
})
|
||||
|
||||
create_cycle(paid_member, fee_type, %{cycle_start: current_year_start, status: :paid})
|
||||
|
||||
# Member with unpaid current cycle
|
||||
unpaid_member =
|
||||
create_member(%{
|
||||
first_name: "UnpaidCurrent",
|
||||
membership_fee_type_id: fee_type.id
|
||||
})
|
||||
|
||||
create_cycle(unpaid_member, fee_type, %{cycle_start: current_year_start, status: :unpaid})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/members?cycle_status_filter=paid&show_current_cycle=true")
|
||||
|
||||
assert html =~ "PaidCurrent"
|
||||
refute html =~ "UnpaidCurrent"
|
||||
end
|
||||
|
||||
test "filter shows only paid members when paid filter is active", %{
|
||||
conn: conn,
|
||||
paid_member: paid_member,
|
||||
unpaid_member: unpaid_member,
|
||||
nil_paid_member: nil_paid_member
|
||||
} do
|
||||
test "filter shows only members with unpaid status in current cycle", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members?paid_filter=paid")
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
today = Date.utc_today()
|
||||
current_year_start = Date.new!(today.year, 1, 1)
|
||||
|
||||
assert html =~ paid_member.first_name
|
||||
refute html =~ unpaid_member.first_name
|
||||
refute html =~ nil_paid_member.first_name
|
||||
# Member with paid current cycle
|
||||
paid_member =
|
||||
create_member(%{
|
||||
first_name: "PaidCurrent",
|
||||
membership_fee_type_id: fee_type.id
|
||||
})
|
||||
|
||||
create_cycle(paid_member, fee_type, %{cycle_start: current_year_start, status: :paid})
|
||||
|
||||
# Member with unpaid current cycle
|
||||
unpaid_member =
|
||||
create_member(%{
|
||||
first_name: "UnpaidCurrent",
|
||||
membership_fee_type_id: fee_type.id
|
||||
})
|
||||
|
||||
create_cycle(unpaid_member, fee_type, %{cycle_start: current_year_start, status: :unpaid})
|
||||
|
||||
{:ok, _view, html} =
|
||||
live(conn, "/members?cycle_status_filter=unpaid&show_current_cycle=true")
|
||||
|
||||
refute html =~ "PaidCurrent"
|
||||
assert html =~ "UnpaidCurrent"
|
||||
end
|
||||
|
||||
test "filter shows only unpaid members (including nil) when not_paid filter is active", %{
|
||||
conn: conn,
|
||||
paid_member: paid_member,
|
||||
unpaid_member: unpaid_member,
|
||||
nil_paid_member: nil_paid_member
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members?paid_filter=not_paid")
|
||||
|
||||
refute html =~ paid_member.first_name
|
||||
assert html =~ unpaid_member.first_name
|
||||
assert html =~ nil_paid_member.first_name
|
||||
end
|
||||
|
||||
test "filter combines with search query (AND)", %{
|
||||
conn: conn,
|
||||
paid_member: paid_member
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members?query=Zahler&paid_filter=paid")
|
||||
|
||||
assert html =~ paid_member.first_name
|
||||
end
|
||||
|
||||
test "filter combines with sorting", %{conn: conn} do
|
||||
test "toggle cycle view updates URL and preserves filter", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
|
||||
{:ok, view, _html} =
|
||||
live(conn, "/members?paid_filter=paid&sort_field=first_name&sort_order=asc")
|
||||
# Start with last cycle view and paid filter
|
||||
{:ok, view, _html} = live(conn, "/members?cycle_status_filter=paid")
|
||||
|
||||
# Click on email sort header
|
||||
# Toggle to current cycle - this should update URL and preserve filter
|
||||
# Use the button in the toolbar
|
||||
view
|
||||
|> element("[data-testid='email']")
|
||||
|> element("button[phx-click='toggle_cycle_view']")
|
||||
|> render_click()
|
||||
|
||||
# Filter should be preserved in URL
|
||||
# Wait for patch to complete
|
||||
path = assert_patch(view)
|
||||
assert path =~ "paid_filter=paid"
|
||||
assert path =~ "sort_field=email"
|
||||
end
|
||||
|
||||
test "URL parameter paid_filter is set when selecting filter", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Open filter dropdown
|
||||
view
|
||||
|> element("#payment-filter button[aria-haspopup='true']")
|
||||
|> render_click()
|
||||
|
||||
# Select "Paid" option
|
||||
view
|
||||
|> element("#payment-filter button[phx-value-filter='paid']")
|
||||
|> render_click()
|
||||
|
||||
path = assert_patch(view)
|
||||
assert path =~ "paid_filter=paid"
|
||||
end
|
||||
|
||||
test "URL parameter is correctly read on page load", %{
|
||||
conn: conn,
|
||||
paid_member: paid_member
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members?paid_filter=paid")
|
||||
|
||||
# Only paid member should be visible
|
||||
assert html =~ paid_member.first_name
|
||||
# Filter badge should be visible
|
||||
assert html =~ "badge"
|
||||
end
|
||||
|
||||
test "invalid URL parameter is ignored", %{
|
||||
conn: conn,
|
||||
paid_member: paid_member,
|
||||
unpaid_member: unpaid_member
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members?paid_filter=invalid_value")
|
||||
|
||||
# All members should be visible (filter not applied)
|
||||
assert html =~ paid_member.first_name
|
||||
assert html =~ unpaid_member.first_name
|
||||
end
|
||||
|
||||
test "search maintains filter state", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members?paid_filter=paid")
|
||||
|
||||
# Perform search
|
||||
view
|
||||
|> element("[data-testid='search-input']")
|
||||
|> render_change(%{"query" => "test"})
|
||||
|
||||
# Filter state should be maintained in URL
|
||||
path = assert_patch(view)
|
||||
assert path =~ "paid_filter=paid"
|
||||
end
|
||||
end
|
||||
|
||||
describe "paid column in table" do
|
||||
setup do
|
||||
{:ok, paid_member} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Paid",
|
||||
last_name: "Member",
|
||||
email: "paid.column@example.com",
|
||||
paid: true
|
||||
})
|
||||
|
||||
{:ok, unpaid_member} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Unpaid",
|
||||
last_name: "Member",
|
||||
email: "unpaid.column@example.com",
|
||||
paid: false
|
||||
})
|
||||
|
||||
%{paid_member: paid_member, unpaid_member: unpaid_member}
|
||||
end
|
||||
|
||||
test "paid column shows green badge for paid members", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
# Check for success badge (green)
|
||||
assert html =~ "badge-success"
|
||||
end
|
||||
|
||||
test "paid column shows red badge for unpaid members", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
# Check for error badge (red)
|
||||
assert html =~ "badge-error"
|
||||
end
|
||||
|
||||
test "paid column shows 'Yes' for paid members", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
Gettext.put_locale(MvWeb.Gettext, "en")
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
# The table should contain "Yes" text inside badge
|
||||
assert html =~ "badge-success"
|
||||
assert html =~ "Yes"
|
||||
end
|
||||
|
||||
test "paid column shows 'No' for unpaid members", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
Gettext.put_locale(MvWeb.Gettext, "en")
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
# The table should contain "No" text inside badge
|
||||
assert html =~ "badge-error"
|
||||
assert html =~ "No"
|
||||
# URL should contain both filter and show_current_cycle
|
||||
assert path =~ "cycle_status_filter=paid"
|
||||
assert path =~ "show_current_cycle=true"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
237
test/mv_web/member_live/membership_fee_integration_test.exs
Normal file
237
test/mv_web/member_live/membership_fee_integration_test.exs
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
defmodule MvWeb.MemberLive.MembershipFeeIntegrationTest do
|
||||
@moduledoc """
|
||||
Integration tests for membership fee UI workflows.
|
||||
"""
|
||||
use MvWeb.ConnCase, async: false
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Mv.Membership.Member
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias Mv.MembershipFees.MembershipFeeCycle
|
||||
|
||||
require Ash.Query
|
||||
|
||||
setup do
|
||||
# Create admin user
|
||||
{:ok, user} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Changeset.for_create(:register_with_password, %{
|
||||
email: "admin#{System.unique_integer([:positive])}@mv.local",
|
||||
password: "testpassword123"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
conn = conn_with_password_user(build_conn(), user)
|
||||
%{conn: conn, user: user}
|
||||
end
|
||||
|
||||
# Helper to create a membership fee type
|
||||
defp create_fee_type(attrs) do
|
||||
default_attrs = %{
|
||||
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
||||
amount: Decimal.new("50.00"),
|
||||
interval: :yearly
|
||||
}
|
||||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
MembershipFeeType
|
||||
|> Ash.Changeset.for_create(:create, attrs)
|
||||
|> Ash.create!()
|
||||
end
|
||||
|
||||
# Helper to create a member
|
||||
defp create_member(attrs) do
|
||||
default_attrs = %{
|
||||
first_name: "Test",
|
||||
last_name: "Member",
|
||||
email: "test.member.#{System.unique_integer([:positive])}@example.com"
|
||||
}
|
||||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, attrs)
|
||||
|> Ash.create!()
|
||||
end
|
||||
|
||||
describe "end-to-end workflows" do
|
||||
test "create type → assign to member → view cycles → change status", %{conn: conn} do
|
||||
# Create type
|
||||
fee_type = create_fee_type(%{name: "Regular", interval: :yearly})
|
||||
|
||||
# Assign to member
|
||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
|
||||
# View cycles
|
||||
{:ok, view, html} = live(conn, "/members/#{member.id}")
|
||||
|
||||
assert html =~ "Membership Fees" || html =~ "Mitgliedsbeiträge"
|
||||
|
||||
# Get a cycle
|
||||
cycles =
|
||||
MembershipFeeCycle
|
||||
|> Ash.Query.filter(member_id == ^member.id)
|
||||
|> Ash.read!()
|
||||
|
||||
if !Enum.empty?(cycles) do
|
||||
cycle = List.first(cycles)
|
||||
|
||||
# Switch to Membership Fees tab
|
||||
view
|
||||
|> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
|
||||
|> render_click()
|
||||
|
||||
# Change status
|
||||
view
|
||||
|> element("button[phx-click='mark_cycle_status'][phx-value-cycle_id='#{cycle.id}']")
|
||||
|> render_click()
|
||||
|
||||
# Verify status changed
|
||||
updated_cycle = Ash.read_one!(MembershipFeeCycle |> Ash.Query.filter(id == ^cycle.id))
|
||||
assert updated_cycle.status == :paid
|
||||
end
|
||||
end
|
||||
|
||||
test "change member type → cycles regenerate", %{conn: conn} do
|
||||
fee_type1 =
|
||||
create_fee_type(%{name: "Type 1", interval: :yearly, amount: Decimal.new("50.00")})
|
||||
|
||||
fee_type2 =
|
||||
create_fee_type(%{name: "Type 2", interval: :yearly, amount: Decimal.new("75.00")})
|
||||
|
||||
member = create_member(%{membership_fee_type_id: fee_type1.id})
|
||||
|
||||
# Change type
|
||||
{:ok, view, _html} = live(conn, "/members/#{member.id}/edit")
|
||||
|
||||
view
|
||||
|> form("#member-form", %{"member[membership_fee_type_id]" => fee_type2.id})
|
||||
|> render_submit()
|
||||
|
||||
# Verify cycles regenerated with new amount
|
||||
cycles =
|
||||
MembershipFeeCycle
|
||||
|> Ash.Query.filter(member_id == ^member.id)
|
||||
|> Ash.Query.filter(status == :unpaid)
|
||||
|> Ash.read!()
|
||||
|
||||
# Future unpaid cycles should have new amount
|
||||
Enum.each(cycles, fn cycle ->
|
||||
if Date.compare(cycle.cycle_start, Date.utc_today()) != :lt do
|
||||
assert Decimal.equal?(cycle.amount, fee_type2.amount)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
test "update settings → new members get default type", %{conn: conn} do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
|
||||
# Update settings
|
||||
{:ok, settings} = Mv.Membership.get_settings()
|
||||
|
||||
settings
|
||||
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|
||||
default_membership_fee_type_id: fee_type.id
|
||||
})
|
||||
|> Ash.update!()
|
||||
|
||||
# Create new member
|
||||
{:ok, view, _html} = live(conn, "/members/new")
|
||||
|
||||
form_data = %{
|
||||
"member[first_name]" => "New",
|
||||
"member[last_name]" => "Member",
|
||||
"member[email]" => "new#{System.unique_integer([:positive])}@example.com"
|
||||
}
|
||||
|
||||
{:error, {:live_redirect, %{to: _to}}} =
|
||||
view
|
||||
|> form("#member-form", form_data)
|
||||
|> render_submit()
|
||||
|
||||
# Verify member got default type
|
||||
member =
|
||||
Member
|
||||
|> Ash.Query.filter(email == ^form_data["member[email]"])
|
||||
|> Ash.read_one!()
|
||||
|
||||
assert member.membership_fee_type_id == fee_type.id
|
||||
end
|
||||
|
||||
test "delete cycle → confirmation → cycle deleted", %{conn: conn} do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
|
||||
cycle =
|
||||
MembershipFeeCycle
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
cycle_start: ~D[2023-01-01],
|
||||
amount: Decimal.new("50.00"),
|
||||
member_id: member.id,
|
||||
membership_fee_type_id: fee_type.id,
|
||||
status: :unpaid
|
||||
})
|
||||
|> Ash.create!()
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
||||
|
||||
# Switch to Membership Fees tab
|
||||
view
|
||||
|> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
|
||||
|> render_click()
|
||||
|
||||
# Delete cycle with confirmation
|
||||
view
|
||||
|> element("button[phx-click='delete_cycle'][phx-value-cycle_id='#{cycle.id}']")
|
||||
|> render_click()
|
||||
|
||||
# Confirm deletion
|
||||
view
|
||||
|> element("button[phx-click='confirm_delete_cycle'][phx-value-cycle_id='#{cycle.id}']")
|
||||
|> render_click()
|
||||
|
||||
# Verify cycle deleted - Ash.read_one returns {:ok, nil} if not found
|
||||
result = MembershipFeeCycle |> Ash.Query.filter(id == ^cycle.id) |> Ash.read_one()
|
||||
assert result == {:ok, nil}
|
||||
end
|
||||
|
||||
test "edit cycle amount → modal → amount updated", %{conn: conn} do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
|
||||
cycle =
|
||||
MembershipFeeCycle
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
cycle_start: ~D[2023-01-01],
|
||||
amount: Decimal.new("50.00"),
|
||||
member_id: member.id,
|
||||
membership_fee_type_id: fee_type.id,
|
||||
status: :unpaid
|
||||
})
|
||||
|> Ash.create!()
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
||||
|
||||
# Switch to Membership Fees tab
|
||||
view
|
||||
|> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
|
||||
|> render_click()
|
||||
|
||||
# Open edit modal by clicking on the amount span
|
||||
view
|
||||
|> element("span[phx-click='edit_cycle_amount'][phx-value-cycle_id='#{cycle.id}']")
|
||||
|> render_click()
|
||||
|
||||
# Update amount
|
||||
view
|
||||
|> form("form[phx-submit='save_cycle_amount']", %{"amount" => "75.00"})
|
||||
|> render_submit()
|
||||
|
||||
# Verify amount updated
|
||||
updated_cycle = Ash.read_one!(MembershipFeeCycle |> Ash.Query.filter(id == ^cycle.id))
|
||||
assert updated_cycle.amount == Decimal.new("75.00")
|
||||
end
|
||||
end
|
||||
end
|
||||
270
test/mv_web/member_live/show_membership_fees_test.exs
Normal file
270
test/mv_web/member_live/show_membership_fees_test.exs
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
||||
@moduledoc """
|
||||
Tests for membership fees section in member detail view.
|
||||
"""
|
||||
use MvWeb.ConnCase, async: false
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Mv.Membership.Member
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias Mv.MembershipFees.MembershipFeeCycle
|
||||
|
||||
require Ash.Query
|
||||
|
||||
setup %{conn: conn} do
|
||||
# Create admin user
|
||||
{:ok, user} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Changeset.for_create(:register_with_password, %{
|
||||
email: "admin#{System.unique_integer([:positive])}@mv.local",
|
||||
password: "testpassword123"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
conn = conn_with_password_user(conn, user)
|
||||
%{conn: conn, user: user}
|
||||
end
|
||||
|
||||
# Helper to create a membership fee type
|
||||
defp create_fee_type(attrs) do
|
||||
default_attrs = %{
|
||||
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
||||
amount: Decimal.new("50.00"),
|
||||
interval: :yearly
|
||||
}
|
||||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
MembershipFeeType
|
||||
|> Ash.Changeset.for_create(:create, attrs)
|
||||
|> Ash.create!()
|
||||
end
|
||||
|
||||
# Helper to create a member
|
||||
defp create_member(attrs) do
|
||||
default_attrs = %{
|
||||
first_name: "Test",
|
||||
last_name: "Member",
|
||||
email: "test.member.#{System.unique_integer([:positive])}@example.com"
|
||||
}
|
||||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, attrs)
|
||||
|> Ash.create!()
|
||||
end
|
||||
|
||||
# Helper to create a cycle
|
||||
defp create_cycle(member, fee_type, attrs) do
|
||||
# Delete any auto-generated cycles first to avoid conflicts
|
||||
existing_cycles =
|
||||
MembershipFeeCycle
|
||||
|> Ash.Query.filter(member_id == ^member.id)
|
||||
|> Ash.read!()
|
||||
|
||||
Enum.each(existing_cycles, fn cycle -> Ash.destroy!(cycle) end)
|
||||
|
||||
default_attrs = %{
|
||||
cycle_start: ~D[2023-01-01],
|
||||
amount: Decimal.new("50.00"),
|
||||
member_id: member.id,
|
||||
membership_fee_type_id: fee_type.id,
|
||||
status: :unpaid
|
||||
}
|
||||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
MembershipFeeCycle
|
||||
|> Ash.Changeset.for_create(:create, attrs)
|
||||
|> Ash.create!()
|
||||
end
|
||||
|
||||
describe "cycles table display" do
|
||||
test "displays all cycles for member", %{conn: conn} do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
|
||||
_cycle1 = create_cycle(member, fee_type, %{cycle_start: ~D[2022-01-01], status: :paid})
|
||||
_cycle2 = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
||||
|
||||
# Switch to membership fees tab
|
||||
view
|
||||
|> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
|
||||
|> render_click()
|
||||
|
||||
html = render(view)
|
||||
|
||||
# Should show cycles table
|
||||
assert html =~ "Membership Fees" || html =~ "Mitgliedsbeiträge"
|
||||
# Check for formatted cycle dates (e.g., "01.01.2022" or "2022")
|
||||
assert html =~ "2022" || html =~ "2023" || html =~ "01.01.2022" || html =~ "01.01.2023"
|
||||
end
|
||||
|
||||
test "table columns show correct data", %{conn: conn} do
|
||||
fee_type = create_fee_type(%{interval: :yearly, amount: Decimal.new("60.00")})
|
||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
|
||||
create_cycle(member, fee_type, %{
|
||||
cycle_start: ~D[2023-01-01],
|
||||
amount: Decimal.new("60.00"),
|
||||
status: :paid
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
||||
|
||||
# Switch to membership fees tab
|
||||
view
|
||||
|> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
|
||||
|> render_click()
|
||||
|
||||
html = render(view)
|
||||
|
||||
# Should show interval, amount, status
|
||||
assert html =~ "Yearly" || html =~ "Jährlich"
|
||||
assert html =~ "60" || html =~ "60,00"
|
||||
assert html =~ "paid" || html =~ "bezahlt"
|
||||
end
|
||||
end
|
||||
|
||||
describe "membership fee type display" do
|
||||
test "shows assigned membership fee type", %{conn: conn} do
|
||||
yearly_type = create_fee_type(%{interval: :yearly, name: "Yearly Type"})
|
||||
_monthly_type = create_fee_type(%{interval: :monthly, name: "Monthly Type"})
|
||||
|
||||
member = create_member(%{membership_fee_type_id: yearly_type.id})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/members/#{member.id}")
|
||||
|
||||
# Should show yearly type name
|
||||
assert html =~ "Yearly Type"
|
||||
end
|
||||
|
||||
test "shows no type message when no type assigned", %{conn: conn} do
|
||||
member = create_member(%{})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/members/#{member.id}")
|
||||
|
||||
# Should show message about no type assigned
|
||||
assert html =~ "No membership fee type assigned" || html =~ "No type"
|
||||
end
|
||||
end
|
||||
|
||||
describe "status change actions" do
|
||||
test "mark as paid works", %{conn: conn} do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
|
||||
cycle = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
||||
|
||||
# Switch to membership fees tab
|
||||
view
|
||||
|> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
|
||||
|> render_click()
|
||||
|
||||
# Mark as paid
|
||||
view
|
||||
|> element(
|
||||
"button[phx-click='mark_cycle_status'][phx-value-cycle_id='#{cycle.id}'][phx-value-status='paid']"
|
||||
)
|
||||
|> render_click()
|
||||
|
||||
# Verify cycle is now paid
|
||||
updated_cycle = Ash.read_one!(MembershipFeeCycle |> Ash.Query.filter(id == ^cycle.id))
|
||||
assert updated_cycle.status == :paid
|
||||
end
|
||||
|
||||
test "mark as suspended works", %{conn: conn} do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
|
||||
cycle = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
||||
|
||||
# Switch to membership fees tab
|
||||
view
|
||||
|> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
|
||||
|> render_click()
|
||||
|
||||
# Mark as suspended
|
||||
view
|
||||
|> element(
|
||||
"button[phx-click='mark_cycle_status'][phx-value-cycle_id='#{cycle.id}'][phx-value-status='suspended']"
|
||||
)
|
||||
|> render_click()
|
||||
|
||||
# Verify cycle is now suspended
|
||||
updated_cycle = Ash.read_one!(MembershipFeeCycle |> Ash.Query.filter(id == ^cycle.id))
|
||||
assert updated_cycle.status == :suspended
|
||||
end
|
||||
|
||||
test "mark as unpaid works", %{conn: conn} do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
|
||||
cycle = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
||||
|
||||
# Switch to membership fees tab
|
||||
view
|
||||
|> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
|
||||
|> render_click()
|
||||
|
||||
# Mark as unpaid
|
||||
view
|
||||
|> element(
|
||||
"button[phx-click='mark_cycle_status'][phx-value-cycle_id='#{cycle.id}'][phx-value-status='unpaid']"
|
||||
)
|
||||
|> render_click()
|
||||
|
||||
# Verify cycle is now unpaid
|
||||
updated_cycle = Ash.read_one!(MembershipFeeCycle |> Ash.Query.filter(id == ^cycle.id))
|
||||
assert updated_cycle.status == :unpaid
|
||||
end
|
||||
end
|
||||
|
||||
describe "cycle regeneration" do
|
||||
test "manual regeneration button exists and can be clicked", %{conn: conn} do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
||||
|
||||
# Switch to membership fees tab
|
||||
view
|
||||
|> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
|
||||
|> render_click()
|
||||
|
||||
# Verify regenerate button exists
|
||||
assert has_element?(view, "button[phx-click='regenerate_cycles']")
|
||||
|
||||
# Trigger regeneration (just verify it doesn't crash)
|
||||
view
|
||||
|> element("button[phx-click='regenerate_cycles']")
|
||||
|> render_click()
|
||||
|
||||
# Verify the action completed without error
|
||||
# (The actual cycle generation depends on many factors, so we just test the UI works)
|
||||
assert render(view) =~ "Membership Fees" || render(view) =~ "Mitgliedsbeiträge"
|
||||
end
|
||||
end
|
||||
|
||||
describe "edge cases" do
|
||||
test "handles members without membership fee type gracefully", %{conn: conn} do
|
||||
# No fee type
|
||||
member = create_member(%{})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/members/#{member.id}")
|
||||
|
||||
# Should not crash
|
||||
assert html =~ member.first_name
|
||||
end
|
||||
end
|
||||
end
|
||||
175
test/mv_web/member_live/show_test.exs
Normal file
175
test/mv_web/member_live/show_test.exs
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
defmodule MvWeb.MemberLive.ShowTest do
|
||||
@moduledoc """
|
||||
Tests for the member show page.
|
||||
|
||||
Tests cover:
|
||||
- Displaying member information
|
||||
- Custom Fields section visibility (Issue #282 regression test)
|
||||
- Custom field values formatting
|
||||
|
||||
## Note on async: false
|
||||
Tests use `async: false` (not `async: true`) to prevent PostgreSQL deadlocks
|
||||
when creating members and custom fields concurrently. This is intentional and
|
||||
documented here to avoid confusion in commit messages.
|
||||
"""
|
||||
# async: false to prevent PostgreSQL deadlocks when creating members and custom fields
|
||||
use MvWeb.ConnCase, async: false
|
||||
import Phoenix.LiveViewTest
|
||||
require Ash.Query
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
|
||||
|
||||
setup do
|
||||
# Create test member
|
||||
{:ok, member} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Alice",
|
||||
last_name: "Anderson",
|
||||
email: "alice@example.com"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
%{member: member}
|
||||
end
|
||||
|
||||
describe "custom fields section visibility (Issue #282)" do
|
||||
test "displays Custom Fields section even when member has no custom field values", %{
|
||||
conn: conn,
|
||||
member: member
|
||||
} do
|
||||
# Create a custom field but no value for the member
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "phone_mobile",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, ~p"/members/#{member}")
|
||||
|
||||
# Custom Fields section should be visible
|
||||
assert html =~ gettext("Custom Fields")
|
||||
|
||||
# Custom field label should be visible
|
||||
assert html =~ custom_field.name
|
||||
|
||||
# Value should show placeholder for empty value
|
||||
assert html =~ "—" or html =~ gettext("Not set")
|
||||
end
|
||||
|
||||
test "displays Custom Fields section with multiple custom fields, some without values", %{
|
||||
conn: conn,
|
||||
member: member
|
||||
} do
|
||||
# Create multiple custom fields
|
||||
{:ok, field1} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "phone_mobile",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
{:ok, field2} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "membership_number",
|
||||
value_type: :integer
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# Create value only for first field
|
||||
{:ok, _cfv} =
|
||||
CustomFieldValue
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
member_id: member.id,
|
||||
custom_field_id: field1.id,
|
||||
value: %{"_union_type" => "string", "_union_value" => "+49123456789"}
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, ~p"/members/#{member}")
|
||||
|
||||
# Custom Fields section should be visible
|
||||
assert html =~ gettext("Custom Fields")
|
||||
|
||||
# Both field labels should be visible
|
||||
assert html =~ field1.name
|
||||
assert html =~ field2.name
|
||||
|
||||
# First field should show value
|
||||
assert html =~ "+49123456789"
|
||||
|
||||
# Second field should show placeholder
|
||||
assert html =~ "—" or html =~ gettext("Not set")
|
||||
end
|
||||
|
||||
test "does not display Custom Fields section when no custom fields exist", %{
|
||||
conn: conn,
|
||||
member: member
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, ~p"/members/#{member}")
|
||||
|
||||
# Custom Fields section should NOT be visible
|
||||
refute html =~ gettext("Custom Fields")
|
||||
end
|
||||
end
|
||||
|
||||
describe "custom field value formatting" do
|
||||
test "formats string custom field values", %{conn: conn, member: member} do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "phone_mobile",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
{:ok, _cfv} =
|
||||
CustomFieldValue
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
member_id: member.id,
|
||||
custom_field_id: custom_field.id,
|
||||
value: %{"_union_type" => "string", "_union_value" => "+49123456789"}
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, ~p"/members/#{member}")
|
||||
|
||||
assert html =~ "+49123456789"
|
||||
end
|
||||
|
||||
test "formats email custom field values as mailto links", %{conn: conn, member: member} do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "private_email",
|
||||
value_type: :email
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
{:ok, _cfv} =
|
||||
CustomFieldValue
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
member_id: member.id,
|
||||
custom_field_id: custom_field.id,
|
||||
value: %{"_union_type" => "email", "_union_value" => "private@example.com"}
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, ~p"/members/#{member}")
|
||||
|
||||
# Should contain mailto link
|
||||
assert html =~ ~s(href="mailto:private@example.com")
|
||||
assert html =~ "private@example.com"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
defmodule MvWeb.UserLive.FormTest do
|
||||
use MvWeb.ConnCase, async: true
|
||||
# async: false to prevent PostgreSQL deadlocks when creating members and users
|
||||
use MvWeb.ConnCase, async: false
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
# Helper to setup authenticated connection and live view
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
defmodule Mv.SeedsTest do
|
||||
use Mv.DataCase, async: false
|
||||
|
||||
require Ash.Query
|
||||
|
||||
describe "Seeds script" do
|
||||
test "runs successfully without errors" do
|
||||
# Run the seeds script - should not raise any errors
|
||||
|
|
@ -42,5 +44,76 @@ defmodule Mv.SeedsTest do
|
|||
assert length(custom_fields_count_1) == length(custom_fields_count_2),
|
||||
"CustomFields count should remain same after re-running seeds"
|
||||
end
|
||||
|
||||
test "at least one member has no membership fee type assigned" do
|
||||
# Run the seeds script
|
||||
assert Code.eval_file("priv/repo/seeds.exs")
|
||||
|
||||
# Get all members
|
||||
{:ok, members} = Ash.read(Mv.Membership.Member)
|
||||
|
||||
# At least one member should have no membership_fee_type_id
|
||||
members_without_fee_type =
|
||||
Enum.filter(members, fn member -> member.membership_fee_type_id == nil end)
|
||||
|
||||
assert not Enum.empty?(members_without_fee_type),
|
||||
"At least one member should have no membership fee type assigned"
|
||||
end
|
||||
|
||||
test "each membership fee type has at least one member" do
|
||||
# Run the seeds script
|
||||
assert Code.eval_file("priv/repo/seeds.exs")
|
||||
|
||||
# Get all fee types and members
|
||||
{:ok, fee_types} = Ash.read(Mv.MembershipFees.MembershipFeeType)
|
||||
{:ok, members} = Ash.read(Mv.Membership.Member)
|
||||
|
||||
# Group members by fee type (excluding nil)
|
||||
members_by_fee_type =
|
||||
members
|
||||
|> Enum.filter(&(&1.membership_fee_type_id != nil))
|
||||
|> Enum.group_by(& &1.membership_fee_type_id)
|
||||
|
||||
# Each fee type should have at least one member
|
||||
Enum.each(fee_types, fn fee_type ->
|
||||
members_for_type = Map.get(members_by_fee_type, fee_type.id, [])
|
||||
|
||||
assert not Enum.empty?(members_for_type),
|
||||
"Membership fee type #{fee_type.name} should have at least one member assigned"
|
||||
end)
|
||||
end
|
||||
|
||||
test "members with fee types have cycles with various statuses" do
|
||||
# Run the seeds script
|
||||
assert Code.eval_file("priv/repo/seeds.exs")
|
||||
|
||||
# Get all members with fee types
|
||||
{:ok, members} = Ash.read(Mv.Membership.Member)
|
||||
|
||||
members_with_fee_types =
|
||||
members
|
||||
|> Enum.filter(&(&1.membership_fee_type_id != nil))
|
||||
|
||||
# At least one member should have cycles
|
||||
assert not Enum.empty?(members_with_fee_types),
|
||||
"At least one member should have a membership fee type"
|
||||
|
||||
# Check that cycles exist and have various statuses
|
||||
all_cycle_statuses =
|
||||
members_with_fee_types
|
||||
|> Enum.flat_map(fn member ->
|
||||
Mv.MembershipFees.MembershipFeeCycle
|
||||
|> Ash.Query.filter(member_id == ^member.id)
|
||||
|> Ash.read!()
|
||||
end)
|
||||
|> Enum.map(& &1.status)
|
||||
|
||||
# At least one cycle should be paid
|
||||
assert :paid in all_cycle_statuses, "At least one cycle should be paid"
|
||||
# At least one cycle should be unpaid
|
||||
assert :unpaid in all_cycle_statuses, "At least one cycle should be unpaid"
|
||||
# At least one cycle should be suspended
|
||||
assert :suspended in all_cycle_statuses, "At least one cycle should be suspended"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue